volumetricSky Module
Procedural volumetric clouds rendered with compute-shader raymarching.
A complete sky-renderer system built on the generic GPU primitives
exposed through `compute.*` (3D textures, 2D storage textures,
compute shaders with texture bindings). Serves as a worked example:
nothing in this package needs Rust changes — the noise generator and
the raymarcher are both WGSL files in the package's `shaders/`
directory, hot-reloadable through the VFS.
The flow:
1. A 3D texture is allocated (default 256×128×256 RGBA16F).
2. The `cloud_noise` compute shader fills it with FBM noise
(procedural — no CPU upload).
3. The raymarcher (`cloud_raymarch_v3/v4/v5`) walks per-pixel rays,
sampling the cloud volume, doing Beer-Lambert absorption,
short shadow rays to the sun, and the Henyey-Greenstein phase
function. Output writes into a screen-sized 2D storage texture.
4. A TAA pass blends current+history into the final RT.
5. The result is composited via a post-process effect (or sampled
by a material — your choice).
Quick start — attach to any entity at the world origin:
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
local ent = entity.spawn("sky").id
entity(ent).component.add("VolumetricSky", { preset = somePreset })
The component drives `M.spawn` / `M.fillClouds` / `M.dispatchRaymarch`
automatically; direct M.* use is rare except in custom integrations.
copyPreset(src: { [string]: any }) → void
A preset table a caller owns outright. Every value in a preset is a number
or a short array of numbers, so one level of array copying detaches the
result from the table it was read out of — a caller tweaking what it got
back leaves the component's live preset, and these defaults, alone.
| arg | type | description |
|---|
| src | { [string]: any } | |
sortedNames(set: { [string]: boolean }) → void
The names a set holds, in a stable order, for an error message to list.
| arg | type | description |
|---|
| set | { [string]: boolean } | |
presetValueFault(key: string, value: any) → string
What is wrong with the value given for a weather parameter, or nil when
nothing is. The defaults declare each parameter's shape — a number, or an
array of that many numbers — and the cloud shaders read every parameter as
the shape its default carries, so a value of another shape is named here
and refused rather than stored.
| arg | type | description |
|---|
| key | string | |
| value | any | |
settableFields(comp: any) → void
The component fields a caller may set by name, read off the live component.
A sealed proxy enumerates exactly what `public` declared plus the engine's
own entries, so a refusal names the set the schema holds rather than a copy
of that set kept here. The callables among them are the component's error
surface, which takes no value.
| arg | type | description |
|---|
| comp | any | |
applyOptions(comp: any, opts: { [string]: any }, consumed: { [string]: boolean }?) → void
Apply an options table to a live sky component.
A key the weather configuration carries is folded into a copy of the
component's current preset, at the top level or inside a `preset` sub-table
alike, and the whole table is reassigned once — an index mutation would not
replicate. Every other key names a top-level component field and is written
by name. A key that is neither raises, naming the key alongside both sets
it could have come from, so the option a caller meant is in the message. A
weather parameter whose value is not the shape that parameter takes raises
the same way, because the component's `preset` is one table field and the
schema types the table rather than the parameters inside it.
`consumed` names the keys the caller has already answered for — the entity's
`name`, for the call that mints one. A key outside that set is the sky's
business and is routed or refused here.
| arg | type | description |
|---|
| comp | any | |
| opts | { [string]: any } | |
| consumed | { [string]: boolean }? | |
takeWeather(key: string, value: any) → void
| arg | type | description |
|---|
| key | string | |
| value | any | |
defaultPreset( ) →
A fresh copy of the sky's default weather configuration — every
shaping, placement and lighting parameter the cloud layer reads, at the
values a sky spawned with no options carries. The table is the caller's
to edit and hand to `M.spawn`.
examples
local p = M.defaultPreset(); p.coverage = 0.6; M.spawn({ preset = p })getShaderHandles(variant: ?) → void
| arg | type | description |
|---|
| variant | ? | |
ensureVolume(opts: ?) → void
Resource lifecycle
ensureRenderTarget(w: number, h: number, scale: number) → void
The cloud layer's render targets, as ordinary render targets the passes
read and write.
They are ONE size for every view the frame draws, and deliberately not
screen-tracking. A target that resizes to whatever camera is on is empty for
the first render at each new size, and an environment cube is six renders at
a size nothing else uses — so every face composited the target before
anything had written it at that size, and the clouds came out black in the
reflection while the sky above was white. The composite reads the layer by
UV, so one size serves a viewport, a capture and a cube face alike.
| arg | type | description |
|---|
| w | number | |
| h | number | |
| scale | number | |
fillClouds(params: { [string]: any }) → void
Dispatch the active `cloud_noise_*` shader against the cloud
volume, filling it with FBM density + colour based on `params`.
Called every frame by the component (wind drift advects per-frame).
`VolumetricSky.component:snapshotNoiseParams()`. Keys include
`cloudSize/cloudHeight, noiseScale, octaves, coverage, densityGain,
altitudeLow/altitudeHigh, anvilBias, detailScale/octaves/strength/
threshold, baseLowRemap/baseHighRemap, color, windX/Y/Z, seed,
cloudType, worleyBlend, shaderVariant`.
| arg | type | description |
|---|
| params | { [string]: any } | Flat noise-shaping table — typically the output of |
examples
M.fillClouds(component_snapshot_table)
dirNormalize(v: ?) → void
Raymarch dispatch — per-frame.
quatToRotMat(q: ?) → void
Build a row-major 4x4 inverse view-projection matrix from a camera
snapshot. Returned column-major (16 floats) to match WGSL `mat4x4<f32>`
column-major upload convention used by `cloud_raymarch.shader`.
The engine doesn't expose its actual view-projection through Luau yet,
so we reconstruct one from `layers.active.camera`'s position + rotation quaternion
+ fov + aspect. The scene camera projects through a reversed clip range:
NDC.z ∈ [0, 1] with near = 1 and far = 0, matching the scene depth buffer.
The camera looks along its local -Z, +Y up.
invViewProj(cam: ?, aspect: ?, fov_deg: ?, near: ?, far: ?) → void
| arg | type | description |
|---|
| cam | ? | |
| aspect | ? | |
| fov_deg | ? | |
| near | ? | |
| far | ? | |
viewProjFromCam(cam: ?, aspect: ?, fov_deg: ?, near: ?, far: ?) → void
Forward view-projection from the same camera snapshot — used by TAA
to reproject world-space points into the previous frame's screen
space. Produced in the same column-major convention as
`invViewProjFromCam`, so the WGSL shader uploads it identically.
Construction is the analytic inverse of invViewProjFromCam: we build
a view matrix (camera→world is RT; world→camera is its inverse) and
a perspective matrix over the same reversed clip range, and multiply.
| arg | type | description |
|---|
| cam | ? | |
| aspect | ? | |
| fov_deg | ? | |
| near | ? | |
| far | ? | |
centreRayVolumeDistance(camPos: ?, fwd: ?, aabbMin: ?, aabbMax: ?) → number
The distance the TAA pass carries history through.
The pass picks ONE world point along each view ray and uses it to find
where this pixel stood in the previous frame. A point at distance `d`
travels `translation / d` across the screen when the camera translates,
so the anchor has to stand where the cloud stands: an anchor metres from
the lens moves a large fraction of the screen for a step the cloud
kilometres away barely registers, and the history then arrives from a
different part of the picture.
Under the reversed clip range this module projects through — near at
NDC z = 1, far at z = 0 — the relation is
z = near * (far - d) / (d * (far - near))
so the middle of the [0, 1] band sits at two near planes.
The distance the cloud stands at is the middle of the view ray's own
traversal of the volume. `centreRayVolumeDistance` returns it for the ray
through the centre of the frame, falling back to the range of the volume's
centre for a ray that leaves the volume behind (a camera pointed at the
ground, or standing outside the slab looking away).
| arg | type | description |
|---|
| camPos | ? | |
| fwd | ? | |
| aabbMin | ? | |
| aabbMax | ? | |
dispatchRaymarch(params: { [string]: any }, viewport: { width: number, height: number }) → void
Dispatch the active `cloud_raymarch_*` shader, blend the result
with the prior frame via the TAA pass, and write the composited
output into `scene_volumetric_sky`. Called every frame by the
component after `M.fillClouds`.
The output is one full-screen layer for the ONE view named in
`params.camera`, and the frame composites it over everything that
frame drew, so the call belongs in the frame's deferred phase
(`task.defer`) where the camera every other script has written is
the camera the renderer is about to read. Issued from a component
`update`, it renders the layer for whatever the camera was midway
through the frame, and a camera moved later in that same frame
gets a layer belonging to somewhere else.
`VolumetricSky.component:snapshotRenderParams()`. Includes
camera, AABB envelope, density / phase, sun/sky lighting,
step/maxSteps quality knobs, taaBlendAlpha, shaderVariant.
| arg | type | description |
|---|
| params | { [string]: any } | Flat raymarch params — typically the first return of |
| viewport | { width: number, height: number } | `{ width, height }` of the target render texture. |
examples
M.dispatchRaymarch(component_render_table, { width = 1280, height = 720 })enqueueCloudPasses(ctx: any) → void
Enqueue the cloud layer's passes for the frame being drawn. Called by
the `volumetricSky` render feature once per frame; the renderer then runs
them for EVERY view the frame draws — the viewport, a capture, and each
face of a reflection probe's environment cube.
The chain is: raymarch the layer for the camera being drawn, copy it to
the target the composite reads, temporally accumulate over that copy where
there is a history to accumulate against, and lay the result over the
frame behind whatever geometry the depth buffer holds.
The accumulation is the one part that is not about the scene: it blends
against what the PREVIOUS frame of the same view held, and a cube face is
rendered once with no previous frame of its own. So it stays out of
environment captures, and the copy before it is what those faces read.
| arg | type | description |
|---|
| ctx | any | The render feature's frame context. |
examples
M.enqueueCloudPasses(ctx)
ensureCloudPrograms( ) → void
Register the two fragment programs the layer's passes draw with, and
put back either one the chain has lost. Called by the render feature on
every frame before its enqueues, so a program taken out from under a
standing sky — by a caller, by a scene handing the engine back the chain it
was found with — is registered again on the next frame rather than leaving
the layer with a pass that draws with nothing.
They are registered DISABLED: the chain never runs them, and they are there
so a render pass can name a program. What draws them is the pass.
examples
M.ensureCloudPrograms()
releaseCloudPasses( ) → void
Release the layer's render targets and its fragment programs. Called by
the render feature's teardown.
examples
M.releaseCloudPasses()
spawn(opts: { [string]: any }?) → string
Spawn the singleton VolumetricSky entity, or configure the one
already standing. Every weather parameter (`coverage`, `sunDir`,
`sunColor`, `sunIntensity`, `windSpeed`, …) is settable at the top level
or inside a `preset` sub-table, and every `VolumetricSky` component field
(`shaderVariant`, `cloudSize`, `taaBlendAlpha`, …) by its own name;
`name` names the entity. An option that is neither raises, listing both
sets. A weather parameter takes the shape its default carries — a number,
or an array of that many numbers — and a value of another shape raises
naming the parameter. Weather parameters left out keep the sky's current
values.
`name` for the entity's own.
| arg | type | description |
|---|
| opts | { [string]: any }? | Optional. Weather parameters and component fields, by name, plus |
examples
local skyId = M.spawn({ coverage = 0.45, sunIntensity = 4.0 })configure(opts: { [string]: any }) → boolean
Apply sky options to the sky already standing — the weather parameters
and component fields `M.spawn` takes, against an existing entity.
Parameters left out keep their current values; an option that names
neither a weather parameter nor a component field raises, listing both
sets, and so does a weather parameter given a value of the wrong shape.
none has been spawned.
| arg | type | description |
|---|
| opts | { [string]: any } | Weather parameters and component fields, by name. |
examples
M.configure({ coverage = 0.6, sunIntensity = 2.0 })find( ) → string
Look up the singleton sky entity by its conventional name.
examples
local id = M.find(); if id then entity.despawn(id) end
despawn( ) → void
Despawn the singleton sky entity. Does NOT free GPU resources —
call `M.destroy()` for that.
withComp( ) → any
Convenience setters — mutate the singleton component's fields.
All return `true` on success, `false` when no sky entity exists.
Two distinct field spaces (see VolumetricSky.component): the shaping
params (sun, coverage, density, windSpeed, …) live INSIDE the one
Sync'd `preset` table; only `wind` (animated drift), `shaderVariant`,
and the NoSync perf knobs are top-level fields. `setProp` writes a
top-level field directly; `setPresetProp` reads the current preset,
updates one entry, and reassigns the WHOLE table so the Sync setter
fires (index-mutation `preset.k = v` would not replicate — README).
setProp(key: string, value: any) → boolean
| arg | type | description |
|---|
| key | string | |
| value | any | |
setPresetProp(key: string, value: any) → boolean
| arg | type | description |
|---|
| key | string | |
| value | any | |
setCoverage(c: number) → boolean
Update `preset.coverage` on the active sky entity.
| arg | type | description |
|---|
| c | number | New coverage [0..1] — higher = sparser clouds. |
examples
M.setCoverage(0.5)
setDensity(g: number) → boolean
Update `preset.densityGain` on the active sky entity.
| arg | type | description |
|---|
| g | number | Density gain (v3/v4: baked extinction; v5: σ_t per unit length). |
examples
M.setDensity(0.45)
setSun(dir: { number }, color: { number }?, intensity: number?) → boolean
Update the sun direction (and optionally colour + intensity).
| arg | type | description |
|---|
| dir | { number } | Sun direction vec3 `{ x, y, z }` — pointing toward the sun. |
| color | { number }? | Optional sun colour `{ r, g, b }`. |
| intensity | number? | Optional HDR intensity (soft-clipped at 0.85 in tonemap). |
examples
M.setSun({ 0.5, 0.5, 0.5 }, { 1.0, 0.95, 0.85 }, 5.5)setWind(v: { number }) → boolean
Update the animated wind offset on the active sky entity.
| arg | type | description |
|---|
| v | { number } | Wind vec3 `{ x, y, z }` in noise space. |
examples
M.setWind({ 0.5, 0.0, 0.3 })destroy( ) → void
Release everything the cloud layer holds: the volume texture, the
layer's render targets, the uniform buffers, and the programs its passes
draw with. The frame after this call is the frame the engine
draws without a cloud layer in it, and a sky put up afterwards builds
each of them again. Call from the component's `onDespawn` or directly
when tearing down the sky.