---
title: "Showing an animated image"
description: "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."
section: "Topics"
slug: "topics-animated-images"
canonical: "https://origozero.ai/docs/topics-animated-images"
updated: "2026-09-13T00:36:31.859781880+00:00"
---

# Showing an animated image

```lua
-- 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:

```lua
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.

```lua
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:

```lua
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

```lua
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:

```lua
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:

```yaml
properties:
  - { name: frames,   type: texture_array, default: white }
  - { name: schedule, type: texture,       default: white }
```

```lua
renderer.material.create({
    shader = "myShader",
    textures = { frames = texRef.guid },
}, "myMat")
```

Sampling takes a layer index alongside the UV:

```wgsl
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:

```lua
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.

```lua
texRef:setSettings({ maxDimension = 256 })
```
