Volumetric Clouds#

The clouds subsystem (Src/GraphicsEngineVulkan/scene/atmospheric_effects/clouds/) is the only one with a compute pair, a shared host/shader constants header, a four-vec4 SceneUBO block and its own GUI panel. Everything here is shipped behaviour; open work lives in BACKLOG.md.

Pipeline shape#

Two compute kernels, both Slang-authored and SPIR-V-only (Resources/ShadersSlang/compute/noise.slang, compute/clouds.slang):

  • noise_main runs exactly once, from Clouds::init via dispatchNoiseGeneration, filling a kNoiseVolumeExtent^3 3D storage texture (RWTexture3D<float4>) that covers world-space [0, 1) once per axis. All four channels are written from independent Worley/value-noise terms: clouds_main’s sample_density weights every one of .rgba into either baseDensity or the cirrus band, so a constant channel silently flattens part of the cloud shape. BuildIntegrity.CloudNoiseVolumeCoversItsFullDomainAndWritesEveryChannelTheMarchReads pins both the extent and the no-bare-literal-channel contract.

  • clouds_main runs once per frame, only while the GUI’s “Enable Clouds” checkbox is on (VulkanRenderer::recordComputeCommands’s guiSceneSharedVars.clouds_enabled gate), ray-marching the noise volume into a screen-sized 2D storage texture.

Clouds::init runs createTextures -> createDescriptorSets -> createComputePipelines -> dispatchNoiseGeneration, so the noise dispatch comes last, after both descriptor sets point at the final image views. The noise volume is written, transitioned and sampled entirely on the graphics queue family (see Queue ownership below), so when VulkanDevice::graphicsFamilySupportsCompute() is false, dispatchNoiseGeneration warns and returns early instead of asserting; the noise texture stays zero-initialized haze.

clouds_main binds two descriptor sets: its own (set 0: binding 0 the RWTexture2D output, binding 1 a combined-image-sampler over the noise volume) and the engine’s shared render descriptor set (set 1: GlobalUBO at binding 0, SceneUBO at binding 1), the one every other shading stage binds. That is how clouds_main reads globalUBO.inv_projection/inv_view and scene.cam_pos/dirLight/the four cloud vec4s without a bespoke uniform buffer of its own.

The estimator#

Per pixel, clouds_main reconstructs a view ray from the precomputed inv_projection/inv_view (no per-pixel inverse(): Slang has none for SPIR-V) and intersects an axis-aligned box, the cloud “mesh” (iquilezles’ ray-box formula, box_intersect). The inverse model matrix is formed in the shader itself from the box half-extents and offset, since a diagonal scale+translate inverts trivially. On a hit, it ray-marches the box in num_march_steps steps of constant length (marchLength / num_march_steps), not a fraction of the distance already travelled, which once made the quality slider a de-facto density slider. BuildIntegrity.CloudRayMarchesUseAConstantStepLength guards the distinction in both the primary and the light march. Each step:

  • Samples density from the noise volume (sample_density: two world-space periods, 256 and 64 units, both wrapped with frac into the volume’s [0, 1) domain and blended) and accumulates Beer-Lambert transmittance (transmittance *= exp(-density * dt), the only assignment to transmittance inside the loop, so it is monotonically non-increasing by construction).

  • Marches a second, shorter ray toward the light (light_march, num_march_steps_to_light steps) from the sample point to get lightTransmittance, the self-shadowing term.

  • Weights the in-scattered contribution by the Henyey-Greenstein phase function (phase_HG), whose denominator must SUBTRACT 2*g*cosTheta so a positive g peaks forward (toward the sun, cosTheta = +1), not away from it.

  • Optionally attenuates (never raises) that contribution by a “powder effect” term modelling self-shadowing of dense droplets. It multiplies into lightEnergy, and must never appear on the right-hand side of a transmittance assignment.

BuildIntegrity.CloudScatteringKeepsItsPhaseSignAndItsMonotonicTransmittance pins the phase-function sign and the powder/transmittance separation as text-shape guards; GoldenRender.EnablingCloudsChangesTheFrameAndAddsDetail is the numerical oracle that covers both, so a regression in either meets a cheap CI-side text-shape guard and a rendered-pixel check.

Resource lifecycle#

  • recreateFrameResources(commandPool, width, height) (called from VulkanRenderer on swapchain resize) recreates only cloudOutputTexture at the new extent and rewrites its descriptor; the noise volume is content, not a frame resource, and stays untouched.

  • shaderHotReload(sharedLayout) destroys and recreates both compute pipelines but deliberately does not call dispatchNoiseGeneration again: descriptor sets and both textures stay untouched, so a shader edit never regenerates the (expensive, one-shot) noise volume.

  • cleanUp() is idempotent (a device null-check guards re-entry) and releases both descriptor sets, both pipeline+layout pairs and both textures.

Queue ownership and barriers#

The noise volume is an exclusive (single-queue-family) image, produced and consumed entirely on the graphics family: Clouds.cpp dispatches it on device->getGraphicsQueue() using the graphics command pool passed into init(), never a separate compute queue or a transient pool of its own. BuildIntegrity.CloudResourcesAreProducedAndConsumedOnOneQueue greps both Clouds.cpp and VulkanDevice.ixx for the shapes that would reintroduce a queue-family mismatch: getComputeQueue()/createCommandPool.

cloudOutputTexture is a single image, not duplicated per frame-in-flight, so VulkanRenderer::record_commands orders access to it with two explicit barriers around clouds.recordComputeCommands. Both are eGeneral -> eGeneral colour barriers recorded by one file-local helper, recordCloudOutputBarrier, and differ only in their stage and access masks:

  1. A fragment-shader -> compute-shader barrier before the dispatch closes the cross-frame write-after-read gap against the previous frame’s post-pass sample of the same image. A pipeline barrier orders against all previously submitted commands on the queue, not just the current command buffer, so it closes the gap even though the in-flight fence only guarantees the submission several frames prior has completed.

  2. A compute-shader -> fragment-shader barrier after the dispatch orders this frame’s write before the post pass’s read of the same image (PostStage’s own subpass dependency only covers eColorAttachmentOutput -> eColorAttachmentOutput and cannot order a compute-shader write).

The helper names its stages by hand instead of using VulkanImage::transitionImageLayout’s eGeneral -> eGeneral overload, which derives both stages from the layout alone: eAllCommands -> eAllCommands, a full pipeline stall every frame, for what needs one edge.

Measured 2026-08-01 with the first barrier in place (RX 9070 XT, Invoke-SyncValidation.ps1, khronos_validation.validate_sync=true): no SYNC-HAZARD across GoldenRender.CloudsAcrossManyFramesDoesNotLoseTheDevice (30+ frames with clouds on), nor in the frames the all-maximum case of the GUI input sweep completed before the path-tracing device loss of that time. The state before the fix was not measured under sync validation, so the barrier closes a spec gap, not a hazard seen and then fixed. The Linux lane has validated the same test on llvmpipe on every push since 2026-10-06 (gpu-golden-testing.md § Synchronization validation).

UBO packing#

SceneUboMarshal.hpp’s fillSceneUboClouds (the host packer) and clouds_main’s cloud-parameter unpack block (the sole shader-side consumer) are two hand-written mirrors of the same nine-value layout. The table maps each GUI control (GUI.cpp’s “Cloud Settings” tree node) -> shader field -> SceneUBO field/component; both sides must agree on it. BuildIntegrity.CloudUboPackingMatchesTheShaderUnpack pins the shader half, field by field, and BuildIntegrity.CloudsDocTablesMatchTheirSources pins this table against that same test’s data.

GUI control

Shader field

SceneUBO field

# march steps

cloud.num_march_steps

cloudParameters.w

# march steps to light

cloud.num_march_steps_to_light

cloudLightMarch.x

Density

cloud.scale

cloudMeshScale.w

Coverage threshold

cloud.threshold

cloudMeshOffset.w

Pillowness

cloud.pillowness

cloudParameters.x

Cirrus effect

cloud.cirrus_effect

cloudParameters.y

Powder effect

cloud.powder_effect

cloudParameters.z

Scale

cloud.radius

cloudMeshScale.xyz

Translation

cloud.offset

cloudMeshOffset.xyz

The “Enable Clouds” checkbox is not in this table: it gates recordComputeCommands/clouds_main’s dispatch and post.slang’s compositing branch through a push constant (PushConstantPost::clouds_enabled), not a SceneUBO field.

Dispatch and clamp constants#

CloudDispatch.hpp is the single source for every dispatch-grid and march-step-bound constant shared between Clouds.cpp, SceneUboMarshal.hpp, GUI.cpp’s slider ranges and the two shaders’ own defensive clamps. A shader must not trust a UBO, so clouds_main re-clamps both march-step counts on both sides even though the host already clamped them before packing.

Constant

Value

Shader token it pins

kNoiseVolumeExtent

128

noise.slang’s NOISE_VOLUME_EXTENT and its dispatch-grid divisor

kNoiseWorkgroupSize

8

noise.slang’s [numthreads(8, 8, 8)]

kCloudWorkgroupSize

16

clouds.slang’s [numthreads(16, 16, 1)]

kMinCloudMarchSteps

4

clouds.slang’s num_march_steps floor

kMaxCloudMarchSteps

128

clouds.slang’s num_march_steps clamp, upper bound

kMinCloudLightMarchSteps

1

clouds.slang’s num_march_steps_to_light clamp, lower bound

kMaxCloudLightMarchSteps

128

clouds.slang’s num_march_steps_to_light clamp, upper bound

BuildIntegrity.CloudDispatchGridsMatchTheShaderWorkgroupSizes pins the two workgroup-size rows against the shaders’ [numthreads(...)] attributes; BuildIntegrity.CloudMarchStepBoundsMatchTheShaderClamps pins the four march-step rows against clouds.slang’s clamp literals; both are mirrored by BuildIntegrity.CloudsDocTablesMatchTheirSources against this table’s values.

Compositing contract#

clouds_main writes float4(cloudColor, 1.0 - transmittance): premultiplied colour, alpha = one minus the accumulated transmittance. post/post.slang’s fs_main is the sole consumer: while pc_post.clouds_enabled is set, it composites with color = cloud.rgb + color * (1.0 - cloud.a) and the matching alpha blend, a straight premultiplied-over that clouds_main’s write satisfies exactly. Both live in the same push-constant-gated branch, so the GUI toggle cannot enable clouds without compositing, or the reverse.

Verification#

Every BuildIntegrity.Cloud* test named above, the doc-table check included, is a pure-CPU source-text/constant check: none needs a GPU adapter. The GPU tests are GoldenRender.EnablingCloudsChangesTheFrameAndAddsDetail (the numerical oracle) and GoldenRender.CloudsAcrossManyFramesDoesNotLoseTheDevice.