Skip to content

Split ray tracing into wavefront passes - #29

Merged
ComfyFluffy merged 25 commits into
mainfrom
wavefront-split-guides
Jul 27, 2026
Merged

Split ray tracing into wavefront passes#29
ComfyFluffy merged 25 commits into
mainfrom
wavefront-split-guides

Conversation

@ComfyFluffy

Copy link
Copy Markdown
Owner

Summary

  • split world ray tracing into a primary and guide pass plus an indirect shading pass connected by packed continuation records
  • deterministically split the first water or dielectric Fresnel interface while keeping later radiance transport and sampling in the indirect pass
  • keep the primary pass at one sample per pixel, resample queued leaves in the indirect pass, and restrict shader invocation reordering to indirect transport
  • add deterministic transmitted and reflected guide resolution, including transmission tint, reflected-content specular albedo, and motion reprojection for moving reflectors and reflected endpoints
  • unify dielectric medium tracking, refraction, absorption, water handling, ray cones, and material payload data across the transport path
  • simplify continuation state, queue validity handling, RIS plumbing, and terminal-bounce work
  • add the pipeline, material, configuration, and layout support required by the split

Validation

  • Gradle test suite
  • shader compilation and SPIR-V validation

ComfyFluffy and others added 25 commits July 25, 2026 21:54
Two related passes over the pq branch's guide work.

Roughness convention. LabPBR defines roughness = (1 - perceptualSmoothness)^2,
which IS the GGX alpha, and DLSS-RR documents its guide input as linear
roughness. The shader was squaring the stored value again, so the alpha actually
shaded was (1 - s)^4 and every LabPBR-textured surface rendered sharper than
authored. ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo now take alpha as-is.
RtMaterials.Profile held perceptual values, so those are converted to alpha
(identical shading for the profile path, only the units change); RtLabPbr was
already correct and is unchanged. The mip filter's Toksvig widening moves to a
plain linear average in alpha space, reproducing the alpha the old mixed-space
sqrt round-trip produced.

Thresholds that were written in perceptual space are converted, not relabelled:
the reflection-MV gate becomes SPEC_MOTION_ALPHA_MAX = 0.25, and the delta-mirror
threshold is MIRROR_ALPHA_MAX = 4e-4 (~12x tighter than the sun disc, so the lobe
is unresolvable). Below it ggxD is exactly 0 and the sun arrives solely via the
mirror ray hitting the disc, which is the correct accounting; above it authored
roughness passes through unmodified, so there is no floor and no cliff.

Restoring MIN_ROUGH is therefore unnecessary, but removing it had left the range
between the old 8-bit authored-zero epsilon and 0.045 unguarded. ggxD's 1e-7
denominator guard is documented as load-bearing rather than fixed: it truncates
the NEE highlight peak below alpha ~0.0135, which is what keeps the sun from
being double-counted against the BSDF-sampled ray that also sees the disc
(showCelestial, with no MIS between them).

Cleanups, no behaviour change:
  * gv_specAlb (write-only) and the six gv_specSurface* statics collapse into one
    SpecSurface struct, dropping the call-then-patch at the water branch and
    taking specularReflectionMotion from 10 parameters to 5.
  * One waterWaveSpectrum walk behind a WITH_DT generic replaces the duplicated
    spectrum in waterWaveGradTemporal, so the Q table and wave constants exist
    once and the derivative terms compile out for the caustics caller.
  * Drop the dead applyWaterWaves 3-arg overload and traceGuide's always-NONE
    rayFlags; make tracePath's captureGuides an explicit parameter instead of
    encoding it as sampleIndex != 0; drop two redundant normalizes.

Shaders compile and pass spirv-val; 34 tests pass. Not GPU-verified: LabPBR
surfaces will read visibly rougher, which is the point of the convention fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Water and glass were two branches implementing the same physics with different
approximations: water refracted and tracked `inWater` + a single `waterExt`,
glass was a thin tint multiply, and neither could express the other. Refraction
also could not ask what the ray was travelling THROUGH, only what it hit, so
`refract` was always fed `inWater ? ior : 1.0` and glass->water or nested water
were inexpressible.

One handler now covers both. What varies is per material, not per branch:

  * volume (PAYLOAD_DIELECTRIC_VOLUME) refracts and pushes a participating
    medium, so the segment inside is attenuated by that medium's extinction.
  * thin is a collapsed slab whose two interfaces cancel: no bend, no medium,
    tint applied once per crossing. Both faces still see the material's index,
    so the Fresnel glint is unchanged.
  * water additionally takes the wave normal and marks its medium for caustics.

The medium stack is depth 2 in named fields, not an array: a dynamically indexed
local would land in scratch memory and this raygen is already register-bound.
Depth 2 covers air->water->glass and air->glass->water; deeper nesting degrades
to air on the way out, and since `entering` is re-derived per face from geometry
rather than toggled, the path re-synchronises at the next crossing. Beer-Lambert
now reads the current medium's extinction, so it applies inside ANY volume
dielectric rather than only water, and PAYLOAD_WATER_ENTERING generalises to
PAYLOAD_DIELECTRIC_ENTERING (set for glass too, which it never was).

IOR and thin/volume become per-material instead of per-model. RtDielectrics
holds the built-in table: ice/packed/blue/frosted ice refract at 1.309 as
volumes, everything else translucent falls back to thin soda-lime 1.52, and
water keeps 1.333. Ice previously refracted like window glass. The table is
sprite-keyed and resolved ONCE per sprite, so it adds no variants to the
profile x glass x emitting cross product. `transmission.volume` in a material
JSON overrides the classification either way; transmission.ior already existed.

Glass blocks stay thin deliberately. Geometrically they are cubes, but a solid
refracting cube makes a window unreadable, so vanilla-style glass keeps the
collapsed-slab look while ice — which reads as a solid block of frozen water —
becomes a volume.

Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified.

Next: move the deterministic Fresnel split inside the bounce loop so it applies
at every diffuseDepth == 0 dielectric rather than only bounce 0, which also lets
resolveTransmissionGuide be deleted in favour of capturing guides on the
transmission branch the path already traces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ECTRIC

Every dielectric is a volume now: it refracts and pushes a participating medium
whose extinction attenuates the segment inside. The thin/volume axis added a
second behaviour to every dielectric site — Fresnel, transmitted direction, ray
bias, guide roughness, the transmission-guide walk and the throughput update all
branched on it — to model a case that the medium stack already subsumes. Glass
blocks now genuinely refract rather than passing the ray straight through with a
tint multiply.

With thin gone, PAYLOAD_DIELECTRIC_VOLUME, MATERIAL_FEATURE_DIELECTRIC_VOLUME,
FEATURE_DIELECTRIC_VOLUME and the transmission.volume JSON override all describe
a distinction that no longer exists, so they are removed. RtDielectrics collapses
to what actually varies: a per-sprite refractive index.

Naming. MATERIAL_GLASS covered glass, ice and anything else transparent, and was
never a good name once it stopped meaning "thin pane" — it is MATERIAL_DIELECTRIC
(MODEL_DIELECTRIC in Java). MATERIAL_WATER keeps its own id because it still owns
behaviour nothing else has: the animated wave normal, the caustic term, biome-tint
absorption calibrated per block of depth, and a chit path fed by the fluid mesher
rather than the translucent terrain layer. The JSON model names followed: the old
"thin_dielectric"/"volume_dielectric" pair becomes "dielectric"/"water", since
both were volumes and the distinction they named is gone. No shipped material
JSON used either name.

Two details preserved rather than collapsed:

  * The transmitted-ray bias stays per material, now named INSET_TRANSMIT_BIAS.
    It is not a thin/volume property — it exists because RtTerrainMesher recesses
    TRANSLUCENT quads by TRANSLUCENT_INSET, so a glass/ice face touching a slab
    needs a bias smaller than that inset or the ray restarts past the neighbour.
    Water is meshed by RtFluidMesher with no inset and takes the ordinary bias.
  * volumeExtinction now folds `transmission` in as a transmittance multiplier
    (-ln(tint * factor)) instead of lerping the tint toward white by it. The lerp
    inverted the factor's meaning at the low end: 0 produced a perfectly clear
    medium where every other use of transmission.factor means opaque.

Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified — glass and
ice both change how they refract, so they are the things to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Glass became a volume dielectric, which made TIR common where it was previously
impossible: the critical angle for 1.52 -> air is ~41 degrees, so a ray refracted
into a glass block and striking a side face is very often past it. That exposed a
latent bug in the transmission-guide walk.

On TIR the walk switched to the reflected direction and kept going, then reported
whatever it landed on through setTransmissionGuide, which marks its endpoint as
its own feature (gv_motionUseRefracted). main() therefore wrote that point's own
reprojection delta into gMotion. That is valid for a genuinely refracted
destination, where the near-constant refraction offset cancels between frames,
but wrong for a mirror image: a virtual image sweeps at roughly twice the
camera's rate and in the opposite sense, so the error grew with camera motion and
showed as large, visibly wrong motion vectors on glass.

TIR now bails out of the walk instead, leaving the foreground interface tuple the
caller already wrote. That is what TIR physically deserves: F is 1, so specular
albedo is 1 and diffuse albedo is 0, the pixel is entirely reflection, and
gSpecMotion already describes it correctly via the mirror-image reprojection in
previousReflectionNdc. Depth stays on the interface, the only real surface there.
The guide walk's cull mask no longer changes mid-walk, so it is a constant again.

Also guard the perspective divides in both motion paths, which the depth
computation two lines above already did but the motion vectors did not. A guide
point can project behind either camera -- most easily a mirror image, which sits
as far behind the reflector as its source is in front -- and w <= 0 turns the
divide into an arbitrarily large vector handed straight to DLSS-RR. Report no
motion there and let RR fall back. This covers gMotion (both the direct and
transmitted-feature branches) and gSpecMotion, which had the same latent hazard
even though the reported artifact was in the ordinary MV.

Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Views 8 (Emission Mask) and 9 (Emission Source) were the only consumers of
gv_emission/gv_emissionSource, payloadEmissionSource(), and the extra guide
capture at bounce 0 that fed them. All of it is gone rather than deferred:
payloadEmission()/EMISSION_SOURCE_* stay, since world.rchit still packs them
into the payload for actual emissive shading — only the raygen-side debug
unpacking and display path is removed.

RtVideoOptions' debugView option drops from a 0-9 enum to 0-7, and the two
English-only debug-view labels (no other locale had them translated) are gone.
The push-constant field itself is untouched; an out-of-range stored value
still falls through to the shader's default (motion) view.

39 tests pass; world.rgen.spv shrinks (1030576 -> 1017692 bytes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nsight put the raygen's problem in plain view. Every trace callsite reported
exactly TWO calling contexts, because main() called tracePath from two textual
sites (the transmission and reflection branches of the primary dielectric split)
and the compiler inlined the whole path tracer at both. Live state at the primary
trace had gone 308B -> 2932B against main, and the top stall had flipped from
LGSB to NOINST — an instruction-fetch stall, i.e. the shader had outgrown its
instruction cache.

The second copy was also largely dead at runtime: every guide-capture block and
the whole resolveTransmissionGuide walk sit behind captureGuides, which is false
on the reflection branch. It could never execute there and only occupied I-cache.

Continuations are now data. PathSegment carries everything needed to resume
tracing (ro, rd, throughput, medium, cone, seed, bounce, diffuseDepth, and the
capture/split permissions), tracePath takes one and may hand back another, and
main consumes them through a single call site in a bounded [loop]. The [loop]
attribute is load-bearing: unrolling would duplicate the path tracer again and
undo the whole change.

The split itself gets simpler as a result. Instead of a global branch selector
steering two whole-path retraces that main recombined by F afterwards, the
dielectric multiplies its own throughput by (1-F) and emits the reflection as a
segment weighted by F. Segments are then just summed. The pre-interface
contribution is accumulated once instead of once per branch, and the reflection
inherits the medium and the accumulated Beer-Lambert directly instead of
retracing the camera ray to rediscover them. Energy is unchanged.

Also hoists the guide resolve out of the tail: writeGuides() runs immediately
after the capturing sample, so the gv_* state dies there instead of staying live
across every later sample's traces (the single largest live value in the profile
was gv_hitCamRel at 504B). The debug views read the guide images back rather than
gv_*, so no guide state survives merely to service a branch that is off in every
ordinary frame.

Verified in the SPIR-V: OpReorderThreadWithHitObjectEXT and
OpHitObjectExecuteShaderEXT are down to 1 each (one bounce loop), and
OpHitObjectTraceRayEXT to 6 = 1 radiance + 5 visibility() sites, previously 10
contexts. world.rgen.spv: 1017692 -> 645972 bytes, a 36.5% reduction.

Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified, and the
sampling sequence changed (segments derive their own seeds), so noise will differ
frame to frame even though the estimator is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design + phasing for splitting the raygen into a primary/guide pass and an
indirect pass, connected by the guide images and a continuation-record append
buffer. PathSegment from b4a0e7b is already the record.

Records the profile evidence, the accepted duplication (dielectric transport in
both passes), a 48B packed record layout with its ~44-55MB budget, and explicit
kill criteria for M1 so the approach can be abandoned cheaply if the
bandwidth-vs-occupancy bet does not pay.

Flags the no-reorder SER A/B (already an open question in GPU_PERF_PLAN.md) as a
prerequisite so the two changes do not get entangled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generalizes RtPipeline.create to take String[] rgen, mirroring the rmiss array it
already accepted. N raygen records lead the SBT, the miss and hit regions shift
behind them, and trace() gains a raygenIndex that selects which record the
dispatch launches. The raygen region must name exactly one record (size ==
stride), so choosing a pass is only a matter of which record it points at.

Descriptor set, miss table and hit table are shared across raygens, so the two
passes of the wavefront split cost one pipeline rather than two.

No behaviour change: the world pipeline still registers a single raygen and every
dispatch passes index 0. Groundwork for M1 in docs/WAVEFRONT_PLAN.md.

39 tests pass. Not GPU-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b4a0e7b GPU-tested at 47ms against db6418b's 21ms. Contexts dropped 2 -> 1 as
designed, but live state at the primary trace went 2932B -> 3347B: the pending
segment (288B) is created at the primary dielectric and cannot be consumed until
tracePath returns, so it survives every subsequent trace in registers, and [loop]
additionally forced main's own state to become loop-carried. Halving the
instruction footprint bought nothing because occupancy, not instruction fetch, is
the binding constraint.

Second confirmation of GPU_PERF_PLAN.md's rule that trading live state for
instructions loses in this kernel. Recorded so the next reader does not repeat it.

Retargets the plan accordingly: the split is now the targeted fix for a diagnosed
regression rather than a speculative bet, the M1 baseline is db6418b's 21ms
rather than the regressed build, and a new kill criterion covers reproducing the
same failure through a buffer if pass A holds records live instead of writing and
exiting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M1 landed the two dispatches but left the implementation as one 1986-line include
that both raygens pulled in whole, with the pass difference expressed as two
trailing #if blocks. Separation was left to dead-code elimination: nothing stopped
guide code compiling into the indirect pass or RIS into the primary one, and
nothing said which half a given function belonged to.

Ten modules, each with a header stating what it owns and what it depends on:

  rt_core      bindings, worldPush/payload, shared constants
  rt_math      GGX, Fresnel, HG, PCG, direction sampling (stateless)
  rt_medium    extinction mappings + the depth-2 medium stack
  rt_segment   PathSegment and its packed 48-byte buffer form
  rt_water     wave spectrum, normal perturbation, caustics
  rt_trace     SBT/cull constants, payload builders, the three ray casts
  rt_lighting  light grid, RIS reservoirs, reservoir shading   INDIRECT ONLY
  rt_guides    gv_* state, spec surface + reflection MV, transmitted-destination
               walk, guide image writes, debug views            PRIMARY ONLY
  rt_primary   tracePrimary                                     PRIMARY ONLY
  rt_indirect  tracePath                                        INDIRECT ONLY

Each raygen now includes only what it needs, so the split is enforced by the
include graph instead of by DCE. The passes turned out to be genuinely disjoint:
tracePrimary references no RIS, no visibility() and no BSDF sampling, and
tracePath references guide state zero times (its one 'gv_' hit was a comment).

Not just cosmetic — excluding the unused modules shrank both passes:
  primary  368064 -> 295200 bytes (-19.8%)
  indirect 551528 -> 513744 bytes (-6.8%)
so DCE had not in fact been stripping it all.

Pure code motion: the 1891-line body was sliced verbatim (line counts reconcile
exactly) and both main() bodies are unchanged. Behaviour should be identical;
the size drop is the only intended difference. 39 tests pass, spirv-val clean.
NOT GPU-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.slanginc had no build-system meaning: the compile task's stage glob only
matches the eight "*.<stage>.slang" suffixes (rgen/rchit/rahit/rmiss/rcall/
comp/vert/frag), so a bare module name was already excluded from direct
compilation regardless of extension — same as world_common.slang. The rt_
prefix was redundant inside shaders/world, where every file is already
world-RT code.

core / math / medium / segment / water / trace / lighting / guides / primary /
indirect. Pure rename: git mv + updated #include paths in the two raygen entry
points + prose cross-references in each module's header comment. No line
moved, no code changed.

39 tests pass, spirv-val clean. SPIR-V byte counts shift by ~1KB from shorter
embedded debug-info source paths, not from any code difference. NOT
GPU-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ComfyFluffy
ComfyFluffy merged commit 5d6bf62 into main Jul 27, 2026
3 checks passed
mif9499 added a commit to mif9499/Caustica that referenced this pull request Jul 27, 2026
@ComfyFluffy
ComfyFluffy deleted the wavefront-split-guides branch July 31, 2026 16:24
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.

1 participant