Split ray tracing into wavefront passes - #29
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Validation