Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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.

FieldTypeSyncDefaultMeaning
presetSkyPreset tableSyncDEFAULT_PRESET (v4 cumulus)All shaping / weather / lighting (see below).
shaderVariantstringSync"v4""v3" | "v4" (default) | "v5".
windvec3Sync{0,0,0}Animated drift offset (advanced per frame from preset.windSpeed).
cloudSizenumberNoSync256Volume W,D resolution.
cloudHeightnumberNoSync128Volume H resolution.
stepSizenumberNoSync4.0World units per ray step.
maxStepsnumberNoSync224Ray-march step cap.
shadowStepsnumberNoSync8Shadow ray step count.
alphaCutoffnumberNoSync0.99Early-exit α threshold.
jitternumberNoSync1.0Ray-origin jitter strength.
taaBlendAlphanumberNoSync0.1TAA current-sample weight.
targetWidthnumberNoSync0Render target width (0 = viewport).
targetHeightnumberNoSync0Render target height.
noiseRegenRatenumberNoSync10Times 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 the cloudSize × cloudHeight × cloudSize volume 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 to preset, cloudSize, cloudHeight, shaderVariant or the render device, once the program it compiles is resident for an edit to the noise shader's own source, and at most noiseRegenRate times per second for wind drift. A sky whose preset.windSpeed is zero bakes once. cloudSize / cloudHeight set what one bake costs; noiseRegenRate and windSpeed set how often it is paid.
  • The raymarch (cloud_raymarch_*) runs for every view of every frame. stepSize, maxSteps, shadowSteps, alphaCutoff and targetWidth / targetHeight set 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) — advances wind (reading preset.windSpeed), hands the noise inputs to volumetricSky.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 via M.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.