Skip to content

fix(shaders): per-ray underwater medium tracking (Phase 4 #134)#206

Merged
havokentity merged 6 commits into
integration/parallel-batch-1from
feat/underwater-medium-tracking
May 19, 2026
Merged

fix(shaders): per-ray underwater medium tracking (Phase 4 #134)#206
havokentity merged 6 commits into
integration/parallel-batch-1from
feat/underwater-medium-tracking

Conversation

@havokentity
Copy link
Copy Markdown
Owner

Summary

Fixes the last failure mode in #134's underwater rendering: TIR-reflected
rays escaping to the sky-color fallback returned bright sky-blue, which
punched holes through the deep-water illusion at glancing angles.

The previous fix (b2690ac) tinted only the camera-primary segment when
the camera was underwater. This PR replaces all the special-case Beer's-
law branches with proper per-ray medium tracking carried through the
bounce loop.

  • New per-ray int in_water state initialised from the new
    water_state.x push field. Host scans analytic primitives for
    MAT_WATER planes and sets the bit when the camera is on the
    below-the-surface side.
  • Per-segment Beer's-law attenuation applied at the top of each bounce
    iteration when in_water == 1. Subsumes the old 1.5m entry-side
    estimate and b2690ac's b==0 exit-side hack into one generic block.
  • MAT_WATER refract path flips in_water; TIR / reflection keep it
    (the ray stays in the same medium). MAT_DIELECTRIC left untouched per
    the issue's "optional but symmetric" callout.
  • Sky-on-miss override: in-water + miss returns
    throughput * water_state.yzw (configurable deep tint, default
    near-black). Throughput still modulates so long-path rays fade to true
    black via segment absorption along the way.
  • New cvars r_water_deep_color_r/g/b (defaults 0.0) plumbed through
    the new water_state.yzw.
  • New PtPush::water_state vec4 (mirror in Push + Frame cbuffers);
    static_assert + alignment guard updated.

Test plan

  • /tmp/agent24_under_v2.png (cam y=-1, pitch=75): Snell's window
    sun-disc visible, TIR pixels read DARK not sky-blue.
  • /tmp/agent24_above_v2.png (water_pool.cfg standard): half-
    submerged sphere refracts cleanly, deeper underwater portions read
    darker (proper segment absorption replacing 1.5m nominal).
  • /tmp/agent24_under_tir.png (cam y=-1, pitch=30, geometry-free):
    Snell's window cone visible upper centre, TIR pixels outside the
    cone are dark navy not bright sky.
  • water_pool golden regenerated to match the new (correct) look.
  • 20/20 non-golden ctest pass on Mac/Metal.
  • Metal goldens pass (cornell_csg, procedural_noon, bsc_night,
    bsc_night_clouds, smoke_emitter_basic, smoke_emitter_basic).
  • Software goldens unaffected.
  • Vulkan goldens have pre-existing failures unrelated to this PR
    (cornell_csg vulkan, sdf_smin_row vulkan) -- per project memory
    Mac Vulkan was untested before 2026-05-16.

Copilot AI review requested due to automatic review settings May 19, 2026 17:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@havokentity havokentity changed the base branch from main to integration/parallel-batch-1 May 19, 2026 17:13
@havokentity havokentity requested a review from Copilot May 19, 2026 17:13
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/engine/Engine.cpp Outdated
Comment thread shaders/PathTrace.slang Outdated
havokentity added a commit that referenced this pull request May 19, 2026
1. Engine.cpp:7227 -- the in-water camera test used the wrong half-
   space inequality. `prim_plane` and intersectPlane define planes
   as `dot(n, p) + d = 0` (documented in `prim_plane`'s help text
   and used by intersectPlane on both CPU and shader paths). The
   below-surface half-space is `dot(n, cam) + d <= 0`, not
   `dot(n, cam) <= d`. Worked for d == 0 by coincidence (water_pool
   golden), but a water plane at any non-zero altitude would have
   seeded `in_water` incorrectly. Rewrote the test as
   `signed_dist = dot(n, cam) + d; signed_dist <= 0`.

2. PathTrace.slang:3808 -- the `in_water` docstring referenced
   MAT_DIELECTRIC flipping the medium state, but the dielectric
   branch was intentionally left untouched (per the issue's
   "optional but symmetric" callout). Removed the MAT_DIELECTRIC
   reference and replaced with an explicit note that dielectric
   refraction is out of scope for Phase 4 -- documented the
   simplification (glass body underwater continues attenuating with
   water absorption) and pointed at Phase 5 SSS for the proper
   multi-medium stack.

Verified:
- Bit-identical render output on all three Phase 4 test scenes
  (water_pool.cfg + the two underwater fixtures) -- expected since
  the regressed water plane has d == 0.
- 20/20 non-golden ctest pass.
@havokentity havokentity requested a review from Copilot May 19, 2026 17:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

Comment thread shaders/PathTrace.slang Outdated
havokentity added a commit that referenced this pull request May 19, 2026
1. Engine.cpp:7227 -- the in-water camera test used the wrong half-
   space inequality. `prim_plane` and intersectPlane define planes
   as `dot(n, p) + d = 0` (documented in `prim_plane`'s help text
   and used by intersectPlane on both CPU and shader paths). The
   below-surface half-space is `dot(n, cam) + d <= 0`, not
   `dot(n, cam) <= d`. Worked for d == 0 by coincidence (water_pool
   golden), but a water plane at any non-zero altitude would have
   seeded `in_water` incorrectly. Rewrote the test as
   `signed_dist = dot(n, cam) + d; signed_dist <= 0`.

2. PathTrace.slang:3808 -- the `in_water` docstring referenced
   MAT_DIELECTRIC flipping the medium state, but the dielectric
   branch was intentionally left untouched (per the issue's
   "optional but symmetric" callout). Removed the MAT_DIELECTRIC
   reference and replaced with an explicit note that dielectric
   refraction is out of scope for Phase 4 -- documented the
   simplification (glass body underwater continues attenuating with
   water absorption) and pointed at Phase 5 SSS for the proper
   multi-medium stack.

Verified:
- Bit-identical render output on all three Phase 4 test scenes
  (water_pool.cfg + the two underwater fixtures) -- expected since
  the regressed water plane has d == 0.
- 20/20 non-golden ctest pass.
@havokentity havokentity force-pushed the feat/underwater-medium-tracking branch from 535ce20 to 756abb8 Compare May 19, 2026 17:30
@havokentity havokentity requested a review from Copilot May 19, 2026 17:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

shaders/PathTrace.slang:967

  • The Vulkan Frame UBO also needs to mirror the host PtPush layout. Since the host has smoke_params3 between smoke_params2 and motion_blur_params, this water_state field is shifted by one vec4 in SPIR-V and will read the wrong values unless the missing field/padding is added before motion_blur_params.
    float4 water_state;

Comment thread shaders/PathTrace.slang
havokentity added a commit that referenced this pull request May 19, 2026
Copilot flagged a host/shader cbuffer offset mismatch:
the host PtPush inserts smoke_params3 (wave-6 "looks like smoke")
between smoke_params2 and motion_blur_params, but the shader Push
and Frame cbuffers in PathTrace.slang went directly from
smoke_params2 to motion_blur_params. Result: the shader's
motion_blur_params reads the host's smoke_params3 bytes, and
water_state (this PR's new field) reads what should be
motion_blur_params -- underwater state seeded from random
shutter_speed-shaped bytes.

This was a pre-existing layout drift on the integration branch
(the host side of smoke wave-6 landed in PR #25 but the matching
shader change is still on the unmerged feat/smoke-wave6 branch,
commit 91c3bb4 -- the shader's smoke BRDF that reads
smoke_params3 lives there). Surfaced now because adding
water_state past the misaligned region makes the drift visible
(throwing off the actual rendered output rather than silently
ignoring a no-op slot).

Fix: add `float4 smoke_params3` to both the Metal Push cbuffer
and the Vulkan Frame cbuffer in PathTrace.slang, between
smoke_params2 and motion_blur_params. Docs the slot semantics
matching what Engine.cpp writes; the in-shader reader (the
smoke turbulence BRDF) is still on the unmerged feat/smoke-wave6
branch and will pick up the slot once that PR lands.
Net effect today: trailing fields (motion_blur_params,
water_state, editor_params) now read from the correct host
offsets on both backends.

Verified:
- 20/20 non-golden ctest pass.
- Underwater render output matches the pre-rebase v3 baseline
  (water tracking + Snell's window + dark TIR pixels all read
  correctly post fix); regenerated water_pool golden.
@havokentity havokentity requested a review from Copilot May 19, 2026 17:39
The previous Beer's-law fix (b2690ac) tinted only the camera-primary
segment when the camera was underwater. TIR-reflected underwater
rays still escaped to the sky-color fallback, returning bright
sky-blue through pixels that should be deep-water dark -- breaking
the underwater illusion at any glancing-angle TIR pixel.

Root cause: no per-ray "in_water" medium state. The MAT_WATER BRDF
branch only knew about the immediate surface hit, not whether the
ray had been travelling through water for several bounces. And the
sky-miss fallback unconditionally returned skyColor(rd), regardless
of medium.

Fix: per-ray medium tracking through the bounce loop.

1. New int `in_water` state carried alongside throughput / ro / rd.
   Initialised at ray-gen from water_state.x -- the host scans
   analytic primitives for MAT_WATER planes and sets the bit when
   the camera position is on the below-the-surface side.

2. At each bounce, after traceScene() returns, apply per-segment
   Beer's-law attenuation: throughput *= exp(-absorb * h.t) when
   in_water == 1. This replaces the old nominal 1.5m entry-side
   estimate AND subsumes b2690ac's b == 0 exit-side special case
   with a single generic per-bounce attenuation.

3. At MAT_WATER hits, flip in_water on the refract path:
   - ndi_s < 0  (entering water from above)  -> in_water = 1
   - ndi_s > 0  (exiting water from below)   -> in_water = 0
   - reflect / TIR keep in_water unchanged (ray stays in medium).
   MAT_DIELECTRIC is left untouched -- the Phase 4 scope flags
   symmetric handling as optional, and the typical case (small
   glass body underwater) is fine with the conservative behaviour.

4. Sky-on-miss override: when a ray escapes to infinity AND
   in_water == 1, return throughput * water_state.yzw (the
   configurable deep-water tint, default near-black) instead of
   skyColor(rd). TIR rays from underwater no longer punch bright
   sky-blue holes through the water. Throughput still modulates
   the deep tint so segment absorption fades long-path rays toward
   true black.

5. New cvars: r_water_deep_color_r/g/b (default 0.0 = honest dark
   void). Plumbed through water_state.yzw on the new PtPush field.

6. New PtPush::water_state vec4. Layout-mirrored in PathTrace.slang's
   Push (Metal) and Frame (Vulkan / SPIR-V) cbuffers between
   motion_blur_params and editor_params. Adds 16 B to the push
   struct; static_assert and alignment guard updated.

Verified on Mac M4 Max / Metal:
 * /tmp/agent24_under_v2.png (camera y=-1, pitch=75): dark navy
   surroundings, Snell's window cone visible in upper-right with
   sun-disc punch-through. TIR pixels (the rest of the frame) read
   as dark deep-water tint instead of bright sky.
 * /tmp/agent24_above_v2.png (water_pool.cfg standard): half-
   submerged sphere still refracts cleanly; underwater portion
   reads darker (post-fix segment absorption proportional to depth
   rather than nominal 1.5m).
 * /tmp/agent24_under_tir.png (cam y=-1, pitch=30, no objects):
   classic Snell's window centred, sun visible through the cone,
   TIR pixels outside the cone are dark navy not bright sky.
 * water_pool golden regenerated for the new look.
 * 20/20 non-golden ctest pass.
 * Metal goldens pass; sky-related software goldens
   (procedural_noon, bsc_night, bsc_night_clouds, smoke_emitter_basic,
   cornell_csg) all unchanged. Pre-existing Vulkan failures
   (cornell_csg vulkan, sdf_smin_row vulkan) unrelated -- per
   project memory Mac Vulkan was untested before 2026-05-16.

PT_WATER_ENABLED gating preserved: when the megakernel is built
without water support the new segment-absorption block, sky-miss
override and in_water state are all compiled out (the existing
PT_WATER_ENABLED guard pattern). The new cvars stay registered so
saved configs don't error out on cvar-not-found.
1. Engine.cpp:7227 -- the in-water camera test used the wrong half-
   space inequality. `prim_plane` and intersectPlane define planes
   as `dot(n, p) + d = 0` (documented in `prim_plane`'s help text
   and used by intersectPlane on both CPU and shader paths). The
   below-surface half-space is `dot(n, cam) + d <= 0`, not
   `dot(n, cam) <= d`. Worked for d == 0 by coincidence (water_pool
   golden), but a water plane at any non-zero altitude would have
   seeded `in_water` incorrectly. Rewrote the test as
   `signed_dist = dot(n, cam) + d; signed_dist <= 0`.

2. PathTrace.slang:3808 -- the `in_water` docstring referenced
   MAT_DIELECTRIC flipping the medium state, but the dielectric
   branch was intentionally left untouched (per the issue's
   "optional but symmetric" callout). Removed the MAT_DIELECTRIC
   reference and replaced with an explicit note that dielectric
   refraction is out of scope for Phase 4 -- documented the
   simplification (glass body underwater continues attenuating with
   water absorption) and pointed at Phase 5 SSS for the proper
   multi-medium stack.

Verified:
- Bit-identical render output on all three Phase 4 test scenes
  (water_pool.cfg + the two underwater fixtures) -- expected since
  the regressed water plane has d == 0.
- 20/20 non-golden ctest pass.
Copilot flagged a host/shader cbuffer offset mismatch:
the host PtPush inserts smoke_params3 (wave-6 "looks like smoke")
between smoke_params2 and motion_blur_params, but the shader Push
and Frame cbuffers in PathTrace.slang went directly from
smoke_params2 to motion_blur_params. Result: the shader's
motion_blur_params reads the host's smoke_params3 bytes, and
water_state (this PR's new field) reads what should be
motion_blur_params -- underwater state seeded from random
shutter_speed-shaped bytes.

This was a pre-existing layout drift on the integration branch
(the host side of smoke wave-6 landed in PR #25 but the matching
shader change is still on the unmerged feat/smoke-wave6 branch,
commit 91c3bb4 -- the shader's smoke BRDF that reads
smoke_params3 lives there). Surfaced now because adding
water_state past the misaligned region makes the drift visible
(throwing off the actual rendered output rather than silently
ignoring a no-op slot).

Fix: add `float4 smoke_params3` to both the Metal Push cbuffer
and the Vulkan Frame cbuffer in PathTrace.slang, between
smoke_params2 and motion_blur_params. Docs the slot semantics
matching what Engine.cpp writes; the in-shader reader (the
smoke turbulence BRDF) is still on the unmerged feat/smoke-wave6
branch and will pick up the slot once that PR lands.
Net effect today: trailing fields (motion_blur_params,
water_state, editor_params) now read from the correct host
offsets on both backends.

Verified:
- 20/20 non-golden ctest pass.
- Underwater render output matches the pre-rebase v3 baseline
  (water tracking + Snell's window + dark TIR pixels all read
  correctly post fix); regenerated water_pool golden.
@havokentity havokentity force-pushed the feat/underwater-medium-tracking branch from d750857 to 1f5aa9a Compare May 19, 2026 17:41
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/engine/Engine.cpp Outdated
Comment thread shaders/PathTrace.slang
Copilot flagged a mismatch between the host's in-water scan and
the shader's PRIM_PLANE intersection. PR #207 added a per-prim
unit quaternion (AnalyticPrim::orient[4]) which the shader uses
to rotate plane normals at intersect time via quatRotate (see
PathTrace.slang PRIM_PLANE branch in testAnalyticPrim). My
in-water test used the stored (unrotated) normal, so a rotated
MAT_WATER plane could end up on one side in the shader and the
other side in the host scan -- causing in_water to be seeded
incorrectly.

Fix: mirror the shader's rotation. Apply quatRotate(orient,
pos_or_n) to the stored normal before the signed-distance test.
Identity orient (0,0,0,1) collapses to n_raw so unrotated water
planes are bit-identical to pre-fix; the new path activates only
when the editor's rotate-mode gizmo (or a script) has set a
non-identity orient on the water plane.

Verified bit-identical render output on water_pool.cfg (water
plane is identity orient there, so the new path is a no-op on
this scene). 20/20 non-golden ctest pass.
Copy link
Copy Markdown
Contributor

Copilot AI commented May 19, 2026

@havokentity I've opened a new pull request, #209, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

Comment thread shaders/PathTrace.slang
…cs for primary-in-water)

Copilot flagged that the post-bounce atmospheric / volumetric march
fires on every dispatch with primary_t_recorded set, including
underwater frames. The march assumes an air medium (atmospheric
transmittance, Mie/Rayleigh in-scatter, cloud march -- all
sky-coloured) and would double-dim the camera-to-surface primary
segment AND add air-only single-scatter terms over a path that's
already attenuated by per-segment Beer's law in the bounce loop.

Same root issue as the pre-fix Beer's law: the renderer didn't
know the primary ray was underwater. Now that water_state.x +
in_water carries that signal through the bounce loop, gate the
two post-loop blocks behind a new `primary_in_water` flag latched
at ray-gen.

* `if (primary_hit && !primary_in_water) { ... }` -- skip surface
  aerial perspective + HDRI haze additive on underwater frames.
* `(vol_outer_gate) && primary_t_recorded && !primary_in_water` --
  skip atmospheric in-scatter march, cloud march, and smoke march
  for the same reason.

A proper SSS volumetric in-scatter through a water medium is a
Phase 5 task; for Phase 4 we just don't paint air-only single
scatter on an underwater frame.

Verified:
- /tmp/agent24_under_tir_v4.png: the previously-faint blue tinge
  in the lower-half "deep water" band is now a clean dark void.
- /tmp/agent24_under_v8.png: spheres + Snell's window still
  render correctly.
- /tmp/agent24_above_v8.png: water_pool.cfg above-water render
  is BIT-IDENTICAL to pre-fix -- the gates only activate when
  cam_in_water at ray-gen is true.
- 20/20 non-golden ctest pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

Comment thread shaders/PathTrace.slang
…ff path)

Copilot flagged that `primary_in_water` was seeded from
`water_state.x` even when `PT_WATER_ENABLED` was compiled off. In
that build the water BRDF, in-loop segment absorption, sky-miss
override, and the in_water medium tracking are ALL excised at
compile time -- but the atmospheric / volumetric skip gates I
added in d703a9b sit OUTSIDE the PT_WATER_ENABLED guard. Result:
on a `-DPT_WATER_ENABLED=OFF` build, a scene with a MAT_WATER
plane in its cfg (host still scans + sets water_state.x even
though the BRDF is gone) and the camera below it would lose
aerial perspective + volumetric in-scatter for no good reason --
the shader has no water medium to actually attenuate the primary
ray, so the volumetric march is the only thing producing the
correct in-air radiance.

Fix: gate `primary_in_water` itself on PT_WATER_ENABLED. When the
gate is off, force it to `false` so the atmospheric / volumetric
skips never fire -- matching what the rest of the megakernel
already does.

Verified water_pool.cfg renders bit-identical to d703a9b (the
gate is on in this build); 20/20 non-golden ctest pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated no new comments.

@havokentity havokentity merged commit 1e31c67 into integration/parallel-batch-1 May 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants