Builtin Library
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.