Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commitdf78e4f826

README.md

volumeSequence

Playback for a run of .zvol frames, through a ring of resident volumes.

A sequence names its frames as VFS paths in playback order. It allocates resident 3D textures — one frame each — and nothing else: the frames the playhead is about to reach are read off the VFS on their own tasks and uploaded into whichever ring slot the playhead has travelled furthest past. So a 240-frame run and a 4-frame run cost the same on the GPU, and what a sequence costs is decided when it is opened rather than by how long it is.

local Seq = require("@builtin::systems.volumetrics.volumeSequence")

local frames = {}
for i = 0, 239 do
    frames[#frames + 1] = string.format("/source/sim/plume_%04d.zvol", i)
end

local seq = Seq.open({ name = "plume", frames = frames, resident = 3, fps = 24 })

local s = seq:stats()
print(s.residentBytes, s.allBytes)   --> 3145728   251658240

Drive it from a component, which is also what makes it visible:

entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })

or by hand, if the caller already has a tick of its own:

seq:tick(dt)
local frame = seq:current()
if frame then
    frame:render({ entity = entity(id), density = 6.0 })
end

The playhead advances once per engine frame however many callers tick the sequence — a component and a script, or two entities naming one run, share one playhead rather than each moving it. Every call still promotes the frame that has landed and starts the reads ahead of it.

Residency

Every frame of a run has the dimensions frame 1 declares, so one ring serves the whole run. stats() reports both what the ring holds (residentBytes) and what holding every frame at once would cost (allBytes); residency() says which frame each slot holds right now.

The ring needs at least two slots for a multi-frame run — one holds the picture on screen while the other takes the read — and open raises the count to two if it was asked for less. prefetch says how many frames ahead of the playhead reads are started, and defaults to every slot the ring has spare — a slot left empty is a frame that gets waited on later. It is bounded by those same slots, so asking for more than fits reads as far ahead as it can.

Keeping up

A tick that wants a frame no slot holds yet keeps showing the frame it had and counts a stall. Playback is therefore never blank and never blocks, and stats().stalls is the number that says whether the ring and the prefetch distance are big enough for the rate: a run whose reads keep up shows a stall count that stops rising once the ring has filled.

errors counts frames that failed to read or whose header disagreed with the ring. Each one is counted and logged once, with the path and what was wrong with it, and the run steps over it from then on rather than re-reading it every tick — so errors answers how many frames of the run are broken, and a mistyped path costs one line in the log rather than one per frame.

The rate and the playhead are the sequence's, not a caller's: setFps keeps the frame the playhead stands on and the fraction of the way through it, and seek lands on exactly the frame asked for.