God rays / volumetric sun shafts (like Black Dragon) #208

Closed
opened 2026-09-30 19:29:12 +00:00 by sky_line · 2 comments
Owner

Light shafts from the sun and moon through gaps in geometry (trees, windows, clouds), as Black Dragon offers, integrated into the Vulkan post / lighting chain with a Lightbox-style control set.

A full spec (BD's approach, the options for our pipeline, the recommended design, cost and settings) will be added as a comment.

Related: #118 (Photo later: motion blur, volumetric sun shafts, temporal AA), #119 (photography suite), #185 (RT sun shadows).

Light shafts from the sun and moon through gaps in geometry (trees, windows, clouds), as Black Dragon offers, integrated into the Vulkan post / lighting chain with a Lightbox-style control set. A full spec (BD's approach, the options for our pipeline, the recommended design, cost and settings) will be added as a comment. Related: #118 (Photo later: motion blur, volumetric sun shafts, temporal AA), #119 (photography suite), #185 (RT sun shadows).
Author
Owner

Spec: God rays / volumetric sun shafts (#208)

Related: #118, #119, #185 (RT sun). Based on the Vulkan renderer (llcleanrenderervk.cpp recordWorld, clean_draw_common.glsl, clean_frame_info.glsl, clean_taa.comp), research/black_dragon_features.md, and BD's shader source (github.com/NiranV/Black-Dragon-Viewer, class3/deferred/volumetricLightF.glsl).

1. What Black Dragon does

Where it runs: LLPipeline::renderVolumetric, one full-resolution fragment pass. It runs after tonemap and glow generation and before combineGlow, so it adds light to display-space LDR colour, which is not HDR-correct.

How it works:

  • It reconstructs the view position and marches godray_res samples from the eye toward the fragment, capped at 512 m.
  • Each sample gets a per-pixel rand(tc) offset. There is no temporal filtering.
  • Each step takes one non-PCF shadow tap, nonpcfShadowAtPos. The cascade is chosen by view z, ignoring the shadow far clip.
  • It sums a lit/shadow transition heuristic ("shaftify", weighted 1/i) and a lit amount (weighted i). This is not a scattering integral, and there is no phase function.
  • The result is scaled by haze_density/(blue_density+haze_density), sunlight_color, the multiplier, and max(falloff/depth,1). There is also an odd depth *= pow(depth,100) on raw depth.
  • The optional GODRAYS_FADE define fades the effect by sun direction.

Settings: RenderVolumetricLighting (on/off), …Resolution (steps; the research notes say 32), …Multiplier, …FalloffMultiplier, …Directional. The Multiplier/Falloff defaults are unconfirmed. The machinima panel's RenderGodrays* bindings are stale. Release 5.4.4 restored the feature after PBR broke it, "with better alpha handling, sorted behind DoF".

Known limitations:

  • Visible per-pixel noise.
  • Full-resolution cost.
  • LDR compositing, so it clips and ignores exposure.
  • Cascade seams and a hard 512 m end.
  • The heuristic's look changes with the step count.
  • Transparent surfaces aren't handled correctly.

2. Options for us

Technique Quality Cost (1440p) Artifacts TAA / render-scale fit
a Screen-space radial blur from the sun's screen position, with a depth occlusion mask (GPU Gems 3 ch.13) Stylised; only rays from a visible bright sun ~0.2 ms Vanishes when the sun is off-screen or behind the camera. Streaks bleed past occluders. No shafts when looking across a beam (window seen from inside a room) Trivial: recordFlareState already computes the sun's UV and visibility
b Ray march through the sun cascades: eye → min(depth, max distance), IGN / blue-noise dither, Henyey-Greenstein phase, half or quarter resolution plus bilateral upsample Physically based, works from any view angle, shafts through canopies and windows 0.3–0.6 ms at half res Cascade resolution limits thin shafts; seams at splits (blend like sunShadow); noise without accumulation. In RT sun mode (RenderVKSunShadows 2) the cascades aren't rendered, so the map is stale Good: dither varies per TAA phase; runs at render resolution
c Froxel grid: a clustered 3D in-scatter / transmittance texture, integrated front to back Best: local-light and projector shafts, fog volumes, correct over alpha and water 0.5–1 ms Blocky on thin geometry; temporal lag Needs its own reprojection. Later tier; reuses (b)'s shadow sampling
d Ray-query march against the TLAS Exact caster shapes, no seams, alpha-tested foliage 2–6 ms New plumbing: no .comp shader uses GL_EXT_ray_query today Heavy: quarter res, few steps, temporal accumulation required

Verdict: build (b). It fixes BD's flaws: HDR compositing, a real phase function, temporal accumulation, reduced resolution. (a) fails the window-into-a-dark-room and sun-off-screen cases. In RT sun mode, (b) gets a cheap dedicated cascade set; (d) is a later "ultra" tier.

Pass placement in recordWorld:

  1. March, compute clean_godrays.comp: right after the depth pre-pass and SSAO (g_ssao / g_occl). Opaque depth is final there, and the march doesn't depend on pass 1. Output: an RGBA16F in-scatter target at rw/scale × rh/scale (render extent, not display).
  2. Upsample and composite: a fullscreen additive draw after pass 2a (pre-water alpha), immediately before recordRefractCopy.
    • Why there: clean_taa.comp marks a pixel reactive where the final colour differs from uRef, the refraction copy. Shafts added after the copy would be flagged reactive and get less history weight exactly where the dither needs accumulating.
    • Before the copy, the shafts are in both images, so TAA accumulates the dither, and water refraction plus post-water alpha layer over them correctly.
    • When neither transparent water nor TAA is on, the copy is skipped; the composite still runs.
  3. Bilateral upsample: a 4-tap depth-weighted upsample against a half-resolution depth, built by a small checkerboard min/max downsample (or nearest in the march dispatch). clean_hiz.comp stores max depth and only runs with occlusion culling on, so don't depend on it.

Inputs (already in the frame UBO ssr.* / push constants):

  • Depth: uDepth (NEAREST).
  • Cascades: uShadowMap, shadow_mat[4], shadow_clip.
  • Light direction: atmosLightDir(). This is already whichever body is active, so moon shafts come for free.
  • Light colour: sun atmos[8] / moon atmos[9]*0.7, selected by atmos[9].w.
  • Haze: haze_density, blue_density, density_mul.
  • Noise: taa_jitter / taa_params, IGN (projIGN).

Scattering:

  • Per step: L += T * sigma_s * phaseHG(dot(V,L), g) * shadow(P) * sunColour * dt, with transmittance T from the same density. sigma_s = haze density × user intensity, so shafts follow the environment's haze.
  • March end: min(depth, shadow_clip.w, MaxDist). Taps beyond the far split count as lit, like sunShadow's fade.
  • One hardware-compare tap per step with a cross-split blend and no PCF; the dither plus TAA does the filtering.
  • Output is linear HDR, pre-exposure, so the existing exposure → tonemap chain handles it.

Noise and TAA:

  • Start offset: per-pixel IGN rotated by the TAA phase.
  • Without TAA (FXAA / SMAA / none): a cheap own history reprojected through taa_prev_vp (M3).
  • Snapshots (mOffscreen, one frame, no history): 4× steps at full resolution.

Sun position:

  • Off-screen or behind the camera: the march doesn't depend on the sun's screen position, and HG with g ≈ 0.6 fades naturally when looking away. That's the fallback BD's GODRAYS_FADE crudely approximates.
  • Below the horizon: the moon takes over at its dimmer colour.
  • Skip the pass when shadow_params.w == 0 or the light colour's luminance is ≈ 0.

Water:

  • Above water: clamp the march end at the water plane (water[3].w) for pixels below it.
  • Underwater (water[5].w): water fog colour and density (water[4]) as the medium, with light attenuated by depth below the surface. This is its own milestone; M1 turns the effect off underwater.

Other cases:

  • Probe and mirror captures: skipped. They're separate record* paths.
  • HUD / UI: never. The composite comes before selection and HUD.
  • Render scale: everything runs at render extent; TAA or the upscale blit carries it to display resolution.
  • RT sun mode: a reduced cascade set just for volumetrics (2 × 1024², depth-only, reusing the GPU-driven shadow cull) until (d) exists.

4. Settings and UI

Setting Type Range Default Notes
RenderVKGodRays U32 0–3 0 0 off, 1 low (quarter res, 12 steps), 2 medium (half res, 24), 3 high (half res, 48)
RenderVKGodRaysIntensity F32 0–4 1.0 Multiplies the haze-derived scattering
RenderVKGodRaysAnisotropy F32 0–0.95 0.6 HG g
RenderVKGodRaysMaxDist F32 16–512 m 256 Also clamped to the cascade far split
RenderVKGodRaysSteps U32 0 or 8–96 0 0 = the tier's step count
RenderVKGodRaysScale F32 0.25 / 0.5 / 1 0 0 = the tier's resolution
RenderVKGodRaysUnderwater bool true
  • UI: tier + intensity in panel_lightbox_scene.xml, next to the shadow and SSAO controls; the tier combo also in the Graphics prefs.
  • BD migration: alias BD's RenderVolumetricLighting* settings in the viewer importer: on → tier 2, Resolution → Steps, Multiplier → Intensity.

5. Cost targets

Estimates, to be confirmed with the timers. Mid = RTX 3060 (our mid-range datapoint), high = 4080-class.

Mid High
Tier 1 ≤ 0.25 ms ≤ 0.12 ms
Tier 2 (half res, 24 steps, ~0.9 Mpx) ≤ 0.5 ms ≤ 0.25 ms
Tier 3 ≤ 1.0 ms ≤ 0.5 ms
Upsample + composite ≤ 0.1 ms
RT-mode extra cascades ≤ 0.3 ms

GPU timers: sub-marks c_godrays after the march and g_godrays_comp after the composite. (Sub-marks, because the 8 main pass indices are a fixed pool.)

6. Milestones

  • M1: settings; the march compute (cascades, HG, IGN) at half res with a depth-aware upsample; composite before the refraction copy; off underwater; off in RT sun mode; timers; SPIR-V regenerated with regen_world_spv.py.
  • M2: tiers, the snapshot step boost, the water-plane clamp, a dedicated half-res depth downsample, Lightbox and Graphics UI.
  • M3: own temporal history for non-TAA frames; cascade-seam blending.
  • M4: the underwater medium.
  • M5: the RT-mode reduced cascade set; then (d), the ray-query compute tier.
  • Later: the froxel grid (c), which also feeds projector and local-light shafts.

7. Test plan

  1. Forest canopy at sunrise: shafts through leaf gaps with a low sun. No cascade seam in a slow pan, no crawling dither with TAA on, acceptable noise with FXAA.
  2. Window into a dark room, sun off-screen: beams visible; the room doesn't glow uniformly.
  3. Sun behind the camera, and at night: faint forward scatter only; the moon gives dim shafts; clean off at zero light.
  4. Underwater / water surface: no air haze under the water plane (M1: off underwater); no seam at the surface.
  5. Snapshots (mOffscreen, large sizes): no visible noise.
  6. Render scale 0.5 / 0.67: no edge halos around foliage or avatars.
  7. Probes / mirrors: captures unchanged (A/B the probe cube and hero mirror with the effect on and off).
  8. RT sun mode: no darkening from the stale shadow map (M1 skips it; M5 adds the reduced cascades).
  9. Performance: c_godrays per tier at 1440p against the targets.
  10. Debug view: raw in-scatter buffer.

Sources: BD repo, BD 2.4.1.9 volumetrics write-up, BD releases.

# Spec: God rays / volumetric sun shafts (#208) Related: #118, #119, #185 (RT sun). Based on the Vulkan renderer (`llcleanrenderervk.cpp` recordWorld, `clean_draw_common.glsl`, `clean_frame_info.glsl`, `clean_taa.comp`), `research/black_dragon_features.md`, and BD's shader source ([github.com/NiranV/Black-Dragon-Viewer](https://github.com/NiranV/Black-Dragon-Viewer), `class3/deferred/volumetricLightF.glsl`). ## 1. What Black Dragon does **Where it runs:** `LLPipeline::renderVolumetric`, one full-resolution fragment pass. It runs after tonemap and glow generation and before `combineGlow`, so it adds light to display-space LDR colour, which is not HDR-correct. **How it works:** - It reconstructs the view position and marches `godray_res` samples from the eye toward the fragment, capped at 512 m. - Each sample gets a per-pixel `rand(tc)` offset. There is no temporal filtering. - Each step takes one non-PCF shadow tap, `nonpcfShadowAtPos`. The cascade is chosen by view z, ignoring the shadow far clip. - It sums a lit/shadow transition heuristic ("shaftify", weighted 1/i) and a lit amount (weighted i). This is not a scattering integral, and there is no phase function. - The result is scaled by `haze_density/(blue_density+haze_density)`, `sunlight_color`, the multiplier, and `max(falloff/depth,1)`. There is also an odd `depth *= pow(depth,100)` on raw depth. - The optional `GODRAYS_FADE` define fades the effect by sun direction. **Settings:** `RenderVolumetricLighting` (on/off), `…Resolution` (steps; the research notes say 32), `…Multiplier`, `…FalloffMultiplier`, `…Directional`. The Multiplier/Falloff defaults are unconfirmed. The machinima panel's `RenderGodrays*` bindings are stale. Release 5.4.4 restored the feature after PBR broke it, "with better alpha handling, sorted behind DoF". **Known limitations:** - Visible per-pixel noise. - Full-resolution cost. - LDR compositing, so it clips and ignores exposure. - Cascade seams and a hard 512 m end. - The heuristic's look changes with the step count. - Transparent surfaces aren't handled correctly. ## 2. Options for us | | Technique | Quality | Cost (1440p) | Artifacts | TAA / render-scale fit | |---|---|---|---|---|---| | **a** | Screen-space radial blur from the sun's screen position, with a depth occlusion mask (GPU Gems 3 ch.13) | Stylised; only rays from a visible bright sun | ~0.2 ms | Vanishes when the sun is off-screen or behind the camera. Streaks bleed past occluders. No shafts when looking across a beam (window seen from inside a room) | Trivial: `recordFlareState` already computes the sun's UV and visibility | | **b** | Ray march through the sun cascades: eye → min(depth, max distance), IGN / blue-noise dither, Henyey-Greenstein phase, half or quarter resolution plus bilateral upsample | Physically based, works from any view angle, shafts through canopies and windows | 0.3–0.6 ms at half res | Cascade resolution limits thin shafts; seams at splits (blend like `sunShadow`); noise without accumulation. **In RT sun mode (`RenderVKSunShadows 2`) the cascades aren't rendered, so the map is stale** | Good: dither varies per TAA phase; runs at render resolution | | **c** | Froxel grid: a clustered 3D in-scatter / transmittance texture, integrated front to back | Best: local-light and projector shafts, fog volumes, correct over alpha and water | 0.5–1 ms | Blocky on thin geometry; temporal lag | Needs its own reprojection. Later tier; reuses (b)'s shadow sampling | | **d** | Ray-query march against the TLAS | Exact caster shapes, no seams, alpha-tested foliage | 2–6 ms | New plumbing: no `.comp` shader uses `GL_EXT_ray_query` today | Heavy: quarter res, few steps, temporal accumulation required | **Verdict: build (b).** It fixes BD's flaws: HDR compositing, a real phase function, temporal accumulation, reduced resolution. (a) fails the window-into-a-dark-room and sun-off-screen cases. In RT sun mode, (b) gets a cheap dedicated cascade set; (d) is a later "ultra" tier. ## 3. Recommended design **Pass placement in recordWorld:** 1. **March, compute `clean_godrays.comp`:** right after the depth pre-pass and SSAO (`g_ssao` / `g_occl`). Opaque depth is final there, and the march doesn't depend on pass 1. Output: an RGBA16F in-scatter target at `rw/scale × rh/scale` (render extent, not display). 2. **Upsample and composite:** a fullscreen additive draw after pass 2a (pre-water alpha), immediately before `recordRefractCopy`. - Why there: `clean_taa.comp` marks a pixel reactive where the final colour differs from `uRef`, the refraction copy. Shafts added after the copy would be flagged reactive and get less history weight exactly where the dither needs accumulating. - Before the copy, the shafts are in both images, so TAA accumulates the dither, and water refraction plus post-water alpha layer over them correctly. - When neither transparent water nor TAA is on, the copy is skipped; the composite still runs. 3. **Bilateral upsample:** a 4-tap depth-weighted upsample against a half-resolution depth, built by a small checkerboard min/max downsample (or nearest in the march dispatch). `clean_hiz.comp` stores max depth and only runs with occlusion culling on, so don't depend on it. **Inputs (already in the frame UBO `ssr.*` / push constants):** - Depth: `uDepth` (NEAREST). - Cascades: `uShadowMap`, `shadow_mat[4]`, `shadow_clip`. - Light direction: `atmosLightDir()`. This is already whichever body is active, so moon shafts come for free. - Light colour: sun `atmos[8]` / moon `atmos[9]*0.7`, selected by `atmos[9].w`. - Haze: `haze_density`, `blue_density`, `density_mul`. - Noise: `taa_jitter` / `taa_params`, IGN (`projIGN`). **Scattering:** - Per step: `L += T * sigma_s * phaseHG(dot(V,L), g) * shadow(P) * sunColour * dt`, with transmittance `T` from the same density. `sigma_s` = haze density × user intensity, so shafts follow the environment's haze. - March end: `min(depth, shadow_clip.w, MaxDist)`. Taps beyond the far split count as lit, like `sunShadow`'s fade. - One hardware-compare tap per step with a cross-split blend and no PCF; the dither plus TAA does the filtering. - Output is linear HDR, pre-exposure, so the existing exposure → tonemap chain handles it. **Noise and TAA:** - Start offset: per-pixel IGN rotated by the TAA phase. - Without TAA (FXAA / SMAA / none): a cheap own history reprojected through `taa_prev_vp` (M3). - Snapshots (`mOffscreen`, one frame, no history): 4× steps at full resolution. **Sun position:** - **Off-screen or behind the camera:** the march doesn't depend on the sun's screen position, and HG with g ≈ 0.6 fades naturally when looking away. That's the fallback BD's `GODRAYS_FADE` crudely approximates. - **Below the horizon:** the moon takes over at its dimmer colour. - **Skip the pass** when `shadow_params.w == 0` or the light colour's luminance is ≈ 0. **Water:** - Above water: clamp the march end at the water plane (`water[3].w`) for pixels below it. - Underwater (`water[5].w`): water fog colour and density (`water[4]`) as the medium, with light attenuated by depth below the surface. This is its own milestone; M1 turns the effect off underwater. **Other cases:** - **Probe and mirror captures:** skipped. They're separate `record*` paths. - **HUD / UI:** never. The composite comes before selection and HUD. - **Render scale:** everything runs at render extent; TAA or the upscale blit carries it to display resolution. - **RT sun mode:** a reduced cascade set just for volumetrics (2 × 1024², depth-only, reusing the GPU-driven shadow cull) until (d) exists. ## 4. Settings and UI | Setting | Type | Range | Default | Notes | |---|---|---|---|---| | `RenderVKGodRays` | U32 | 0–3 | 0 | 0 off, 1 low (quarter res, 12 steps), 2 medium (half res, 24), 3 high (half res, 48) | | `RenderVKGodRaysIntensity` | F32 | 0–4 | 1.0 | Multiplies the haze-derived scattering | | `RenderVKGodRaysAnisotropy` | F32 | 0–0.95 | 0.6 | HG g | | `RenderVKGodRaysMaxDist` | F32 | 16–512 m | 256 | Also clamped to the cascade far split | | `RenderVKGodRaysSteps` | U32 | 0 or 8–96 | 0 | 0 = the tier's step count | | `RenderVKGodRaysScale` | F32 | 0.25 / 0.5 / 1 | 0 | 0 = the tier's resolution | | `RenderVKGodRaysUnderwater` | bool | | true | | - **UI:** tier + intensity in `panel_lightbox_scene.xml`, next to the shadow and SSAO controls; the tier combo also in the Graphics prefs. - **BD migration:** alias BD's `RenderVolumetricLighting*` settings in the viewer importer: on → tier 2, Resolution → Steps, Multiplier → Intensity. ## 5. Cost targets Estimates, to be confirmed with the timers. Mid = RTX 3060 (our mid-range datapoint), high = 4080-class. | | Mid | High | |---|---|---| | Tier 1 | ≤ 0.25 ms | ≤ 0.12 ms | | Tier 2 (half res, 24 steps, ~0.9 Mpx) | ≤ 0.5 ms | ≤ 0.25 ms | | Tier 3 | ≤ 1.0 ms | ≤ 0.5 ms | | Upsample + composite | ≤ 0.1 ms | | | RT-mode extra cascades | ≤ 0.3 ms | | GPU timers: sub-marks `c_godrays` after the march and `g_godrays_comp` after the composite. (Sub-marks, because the 8 main pass indices are a fixed pool.) ## 6. Milestones - **M1:** settings; the march compute (cascades, HG, IGN) at half res with a depth-aware upsample; composite before the refraction copy; off underwater; off in RT sun mode; timers; SPIR-V regenerated with `regen_world_spv.py`. - **M2:** tiers, the snapshot step boost, the water-plane clamp, a dedicated half-res depth downsample, Lightbox and Graphics UI. - **M3:** own temporal history for non-TAA frames; cascade-seam blending. - **M4:** the underwater medium. - **M5:** the RT-mode reduced cascade set; then (d), the ray-query compute tier. - **Later:** the froxel grid (c), which also feeds projector and local-light shafts. ## 7. Test plan 1. **Forest canopy at sunrise:** shafts through leaf gaps with a low sun. No cascade seam in a slow pan, no crawling dither with TAA on, acceptable noise with FXAA. 2. **Window into a dark room, sun off-screen:** beams visible; the room doesn't glow uniformly. 3. **Sun behind the camera, and at night:** faint forward scatter only; the moon gives dim shafts; clean off at zero light. 4. **Underwater / water surface:** no air haze under the water plane (M1: off underwater); no seam at the surface. 5. **Snapshots** (`mOffscreen`, large sizes): no visible noise. 6. **Render scale 0.5 / 0.67:** no edge halos around foliage or avatars. 7. **Probes / mirrors:** captures unchanged (A/B the probe cube and hero mirror with the effect on and off). 8. **RT sun mode:** no darkening from the stale shadow map (M1 skips it; M5 adds the reduced cascades). 9. **Performance:** `c_godrays` per tier at 1440p against the targets. 10. **Debug view:** raw in-scatter buffer. Sources: [BD repo](https://github.com/NiranV/Black-Dragon-Viewer), [BD 2.4.1.9 volumetrics write-up](https://modemworld.me/2015/03/11/black-dragon-2-4-1-9-volumetric-lighting-and-more/), [BD releases](https://git.alchemyviewer.org/NiranV.Dean/blackdragon/-/releases).
Author
Owner

Verified done: VK god rays: cascade and ray-traced march, depth-aware composite, temporal accumulation, RT budget, Lightbox controls. Commits: 0512e01500, b64f029ac4, c2f39a2dd6, e9dfb6cf31, 731887e445, 186b633052, 9579c41ddb. Shipped in v1.0.0-beta.1.

Verified done: VK god rays: cascade and ray-traced march, depth-aware composite, temporal accumulation, RT budget, Lightbox controls. Commits: 0512e01500, b64f029ac4, c2f39a2dd6, e9dfb6cf31, 731887e445, 186b633052, 9579c41ddb. Shipped in v1.0.0-beta.1.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sky_line/Slipstream#208
No description provided.