Builtin Library
README.md
VolumetricSky
Singleton component that drives the procedural cloud layer. Attach
to one entity (usually a sky placeholder), and the component owns
the cloud volume + render target lifecycle, bakes the noise field
when what it is baked from changes, and dispatches the compute
raymarcher every frame into scene_volumetric_sky.
A composite (post-process or material) blends scene_volumetric_sky
over the scene — see demo/sky.scene/ for the full wiring.
Usage
-- Defaults (the v4-tuned cumulus look — `Sky.defaultPreset()` hands out a
-- copy of the same table this field defaults to)
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Or apply a captured preset:
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- Inline tweak. `component.add` writes the `preset` field as it is given,
-- so a table handed here carries the whole configuration:
local p = require("@builtin::systems.volumetrics.volumetricSky").defaultPreset()
p.noiseScale, p.octaves, p.coverage = 9.0, 5, 0.20
p.densityGain = 0.04 -- v5 σ_t
sky.component.add("VolumetricSky", { shaderVariant = "v5", preset = p })
volumetricSky.module's M.spawn / M.configure take the same parameters
by name, folding each into the current configuration and leaving the rest
where they stand:
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.spawn({ shaderVariant = "v5", noiseScale = 9.0, coverage = 0.20 })
Schema (Field<T> / Sync / NoSync)
The 28 shaping / weather / lighting params travel as ONE Sync field
— preset = Field.table(DEFAULT_PRESET, Sync) — so replicating a
sky change is one mutation, not 28, and copying a sky between
scenes is copying one field, not 28. Per-client quality knobs
sit outside the preset as NoSync fields so each client picks its
own tier without disturbing peers.
SkyPreset and DEFAULT_PRESET are declared by volumetricSky.module,
which this component reads its default from and which routes an option by
that same key set — so the shape below and the module's M.spawn options
are one declaration.
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
preset | SkyPreset table | Sync | DEFAULT_PRESET (v4 cumulus) | All shaping / weather / lighting (see below). |
shaderVariant | string | Sync | "v4" | "v3" | "v4" (default) | "v5". |
wind | vec3 | Sync | {0,0,0} | Animated drift offset (advanced per frame from preset.windSpeed). |
cloudSize | number | NoSync | 256 | Volume W,D resolution. |
cloudHeight | number | NoSync | 128 | Volume H resolution. |
stepSize | number | NoSync | 4.0 | World units per ray step. |
maxSteps | number | NoSync | 224 | Ray-march step cap. |
shadowSteps | number | NoSync | 8 | Shadow ray step count. |
alphaCutoff | number | NoSync | 0.99 | Early-exit α threshold. |
jitter | number | NoSync | 1.0 | Ray-origin jitter strength. |
taaBlendAlpha | number | NoSync | 0.1 | TAA current-sample weight. |
targetWidth | number | NoSync | 0 | Render target width (0 = viewport). |
targetHeight | number | NoSync | 0 | Render target height. |
noiseRegenRate | number | NoSync | 10 | Times per second wind drift re-bakes the cloud volume (0 = every frame the wind moved). |
Preset (SkyPreset) shape
Noise / shaping: noiseScale, octaves, coverage, densityGain, altitudeLow, altitudeHigh, anvilBias, detailScale, detailOctaves, detailStrength, detailThreshold, baseLowRemap, baseHighRemap, color, windSpeed, seed.
Volume placement: horizExtent, cloudBaseAltitude, cloudTopAltitude.
Look (visual, not perf): phaseG.
Sun / sky: sunDir, sunColor, sunIntensity, sunDiscSize, horizonColor, zenithColor, groundColor, ambient.
v5-only: cloudType, worleyBlend.
See presets/cumulus_default.preset/preset.yaml for the canonical
full preset shape, and the init.luau DEFAULT_PRESET table for
the runtime defaults.
Where the frame time goes
Two passes carry the cost, and different fields reach each:
- The bake (
cloud_noise_*) fills every voxel of thecloudSize × cloudHeight × cloudSizevolume with layered noise. It is the most expensive pass the layer has (around 10 ms at 256×128×256 on a desktop GPU), and it runs only when the volume's inputs move: at once for a change topreset,cloudSize,cloudHeight,shaderVariantor the render device, once the program it compiles is resident for an edit to the noise shader's own source, and at mostnoiseRegenRatetimes per second for wind drift. A sky whosepreset.windSpeedis zero bakes once.cloudSize/cloudHeightset what one bake costs;noiseRegenRateandwindSpeedset how often it is paid. - The raymarch (
cloud_raymarch_*) runs for every view of every frame.stepSize,maxSteps,shadowSteps,alphaCutoffandtargetWidth/targetHeightset its cost.
profiler.gpuFrame() reports both as compute.<shader> spans; a span's
frames count is how many frames of its window the pass ran in, and
volumetricSky.volumeBakes() counts the bakes themselves.
Field-mutation gotcha (and why update() reassigns the whole vec3)
Under the new Field<T> / Sync / NoSync schema, public is not a
plain Lua table — it's wrapped in a proxy whose __newindex
metamethod performs type-checks, marks the field dirty, and queues
Sync replication. Writing public.wind = {x,y,z} triggers all four
steps. Writing public.wind[1] = x reads public.wind (getter),
mutates the returned table directly, and bypasses the setter —
no Sync replication, no dirty flag, no onPropertyChanged. Always
assign the whole vec3 / preset / color table; never index-mutate.
The same caveat applies to public.preset: public.preset.coverage = 0.2
won't replicate. To change one preset field, copy the table, mutate,
reassign:
local p = public.preset
local next = {}
for k, v in pairs(p) do next[k] = v end
next.coverage = 0.2
public.preset = next
Lifecycle
awake: starts the sky's clock and creates the render feature that runs the layer's passes.update(dt)— advanceswind(readingpreset.windSpeed), hands the noise inputs tovolumetricSky.fillClouds, which bakes the volume when they moved (see "Where the frame time goes"), then queues the raymarch dispatch for the frame's deferred phase.onDespawn— releases the volume, the render target and the composite effect viaM.destroy.
Why the raymarch is dispatched from task.defer
The raymarch produces ONE full-screen layer for ONE view, and the frame
composites it over everything that frame drew — so it has to be rendered
from the view the frame ends up drawn with. Which view that is settles
only once the frame's script work is finished: a cutscene walking the
camera from a task loop, a camera controller, a component ordered after
this one, an editor tool — each writes the camera after update has
run, while the renderer reads the camera once all of them are done.
Taking the camera snapshot in the deferred phase reads what every one of
them has already written, and still queues the dispatch ahead of the
frame's render. Snapshotting in update instead renders the layer for
whatever the camera was midway through the frame; on a hard cut that is
a different place entirely, and the frame the cut lands on composites a
cloud layer belonging to the shot it just left.
A caller driving volumetricSky.dispatchRaymarch directly owes the same
discipline.
Related
volumetricSky.module— orchestration backend.presets/cumulus_default.preset— canonical full preset; copy to author named weather (storm, overcast, dawn, …).Volume.component— per-entity flow for non-cloud volumetric content.demo/sky.scene— worked example.