Log inGet started

Showing an animated image

An animated GIF, APNG or animated WebP imports as one texture carrying every frame: one layer per frame, plus the display time each frame was authored with. Putting it on a surface is one call.

-- The image was dropped in the world (or uploaded); the importer made it a
-- `.texture` container, as it does for any image.
local mat = renderer.material.animatedTexture("banner")

-- A flat quad to show it on: the `plane` mesh, pitched a quarter turn so it
-- stands up facing +Z with the image the right way up.
local id = entity.spawn("billboard", {
    position = { 0, 1.5, 0 },
    rotation = { Transform.eulerToQuat(0, math.pi / 2) },
})
entity(id).component.add("Model", { model = "plane" })

-- Wear it. Any Model does.
entity(id).component.get("Model"):applySessionMaterial(mat)

That is the whole path. The surface now plays the animation, looping, at the rate the file was timed for.

The quad it goes on

The built-in plane mesh is the flat surface an image goes on. It lies in its own XZ plane facing +Y, and carries the UVs u along its own +X and v along its own +Z — so the image's first row sits at the plane's -Z edge, and the rotation that stands the plane up decides which way v runs on screen.

Pitching it a quarter turn (Transform.eulerToQuat(0, math.pi / 2), +90° about X) turns the lit face toward +Z and lays v down the screen: the image's first row at the top, for a camera on +Z. Pitching the other way (-90°) turns the face toward -Z and runs v up the screen, so from that side the image reads first-row-last.

What a surface wears

animatedTexture returns a MaterialHandle. The handle OBJECT is what a surface takes, and applySessionMaterial is the call that gives it one — it keeps the handle in the session store and leaves the authored material field carrying its authored ref, so what a runtime material shows never reaches what the world saves:

model:applySessionMaterial(mat)          -- the handle object

The handle's guid is a second, different thing: the material's registry key, the name setProperty, describe and destroy file it under.

renderer.material.setProperty(mat.guid, "speed", 0.5)

A component's material field is the third: it resolves an asset. A registry key in one names no asset, so the component waits for one to register under that name (awake() DEFERRED — waiting for asset field(s) [material = '...']) and the entity carries neither mesh nor material while it waits.

What the import produced

The frames are layers of one texture, so there is no per-frame asset and no sprite-sheet packing. renderer.texture.info reads the shape straight off the payload:

local info = renderer.texture.info(vfs.read("/source/banner.texture/data.ztex"))
-- info.animated      true — the layers are a sequence in time
-- info.layers        4 — the frame count
-- info.frameDelaysMs { 40, 120, 40, 300 } — each frame's display time
-- info.durationMs    500 — one pass through them
-- info.isArray       true

layers is the frame count and frameDelaysMs is what the container asked for, frame by frame. Nothing is resampled onto a common rate: frames of unequal length are shown for the lengths they carry, and a file whose frames are 40, 120, 40 and 300 ms stays four frames rather than becoming twenty-five.

A still image reports layers = 1, animated = false, and imports exactly as it always did.

Steering the playback

renderer.material.animatedTexture("banner", {
    speed = 2,           -- twice the authored rate; 0 holds one frame
    startTime = 0.25,    -- seconds into the sequence this surface starts at
    alphaCutoff = 0.5,   -- discard fragments below this alpha
    baseColor = { 1, 0.8, 0.8, 1 },
    uvScale = { 2, 2 },
    uvOffset = { 0, 0 },
    key = "bannerMat",   -- the material's registry key
})

There is no play/pause/seek state anywhere — the frame is worked out from the engine clock every time the surface is drawn. So two surfaces sharing one texture and differing in startTime run the same animation out of phase, and speed = 0 with a chosen startTime freezes a surface on exactly the frame that time lands in.

That clock runs in edit mode as much as in play, and through a pause: a surface animates while the scene is stopped, so two screenshots taken moments apart are two different frames of it. speed = 0 holds one frame for as long as it is set, which is the state to compare two screenshots in.

speed and startTime are ordinary material properties, so they can be changed after the fact:

renderer.material.setProperty("bannerMat", "speed", 0.5)

Dropping the material

renderer.material.destroy(key) is scoped to the registry: it clears the definition filed under that key, so describe and list stop answering for it. A surface already wearing the handle goes on drawing what it was given — Model:restoreSessionMaterial() is what puts that Model back on its authored material.

Layer 0 is the still image

Anything that samples the texture as a plain 2D image — a UI image widget, a base_color_texture slot on pbr or unlit, a thumbnail — reads layer 0, the animation's first frame. Nothing has to know the texture has more layers, and an animated image dropped into a still slot shows its first frame rather than failing.

Sampling the layers yourself

The layers are a texture array, so a shader that wants them declares a texture_array slot and binds the texture's guid to it:

properties:
  - { name: frames,   type: texture_array, default: white }
  - { name: schedule, type: texture,       default: white }
renderer.material.create({
    shader = "myShader",
    textures = { frames = texRef.guid },
}, "myMat")

Sampling takes a layer index alongside the UV:

textureSample(frames, frames_sampler, uv, layer)

renderer.material.animatedTexture builds its material this way over the built-in animatedTexture shader — read @builtin::shaders.animatedTexture for the whole of it, including how it turns a time into a layer.

Turning a time into a layer

The timing has to reach the shader for it to pick a frame. renderer.texture.frameSchedule gives it in the form a sampler reads a sequence through — the second at which each layer stops being shown, counted from the start:

renderer.texture.frameSchedule("banner")   --> { 0.04, 0.16, 0.2, 0.5 }

A time is turned into a layer by finding the first entry it has not passed, which works whatever the individual frame times are. The built-in shader carries that table as its schedule texture — one texel per layer, the millisecond as a 24-bit integer with its high byte in R, read with textureLoad so the number comes back exactly as it was written — and scans it per pixel. renderer.material.animatedTexture builds and binds that texture; a shader that wants its own arrangement can pack the schedule however it likes.

The layers are not only for animation

The layer dimension is "a texture with several same-shaped slices", and timing is optional on top of it. A sprite sheet's cells and a LUT stack's slices are the same shape with no timing: frameDelaysMs is absent, animated is false, and frameSchedule answers nil. They still bind to a texture_array slot, and the shader picks the slice by whatever rule it wants — a character's facing, a palette index, a depth.

Each slice wraps on its own under REPEAT addressing, which is the reason to reach for an array rather than pack the slices into one atlas image: an atlas cell can never tile.

Cost

One array texture costs its layers: an N-frame animation is N full canvases in VRAM. A long animation at full resolution is a large texture — maxDimension in the texture's settings bounds each frame, and the count of them is whatever the file carried.

texRef:setSettings({ maxDimension = 256 })