Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cf2968d
fix reflected/refracted guides
ComfyFluffy Jul 25, 2026
8ff4929
fix water
ComfyFluffy Jul 25, 2026
341d370
Unify roughness on GGX alpha; guide/wave cleanups
ComfyFluffy Jul 25, 2026
c22ebf3
Unify water/glass behind one dielectric interface with a medium stack
ComfyFluffy Jul 25, 2026
4c105dd
Drop the thin-slab dielectric; rename MATERIAL_GLASS to MATERIAL_DIEL…
ComfyFluffy Jul 25, 2026
5fae79c
Stop total internal reflection leaking into the ordinary motion vector
ComfyFluffy Jul 25, 2026
957569b
add logs to gitignore
ComfyFluffy Jul 25, 2026
db6418b
Drop the emission-mask/emission-source debug views
ComfyFluffy Jul 26, 2026
b4a0e7b
Trace the path once: continuations as data, not a second inlined copy
ComfyFluffy Jul 26, 2026
22dd216
docs: wavefront split plan of record
ComfyFluffy Jul 26, 2026
d76f450
M0: multiple raygen shaders per RT pipeline
ComfyFluffy Jul 26, 2026
7bcc7b5
docs: record the b4a0e7b regression and retarget the plan
ComfyFluffy Jul 26, 2026
428922d
M1: split primary and indirect raygen passes
ComfyFluffy Jul 26, 2026
62e8aae
Split world_path.slanginc into modules per pass
ComfyFluffy Jul 26, 2026
cb5a51d
Rename rt_*.slanginc modules to *.slang
ComfyFluffy Jul 26, 2026
27e10d0
refactor
ComfyFluffy Jul 26, 2026
2ba359c
deterministic split
ComfyFluffy Jul 27, 2026
99c7449
Refine wavefront split ownership
ComfyFluffy Jul 27, 2026
a84bc5e
Use reflected content for specular guides
ComfyFluffy Jul 27, 2026
0fa1adf
simplify
ComfyFluffy Jul 27, 2026
5ef3531
Fix reflected motion reprojection
ComfyFluffy Jul 27, 2026
e5941a5
Remove completed wavefront plan
ComfyFluffy Jul 27, 2026
d4c7c4d
improve ser
ComfyFluffy Jul 27, 2026
b3b852d
drop nv ser
ComfyFluffy Jul 27, 2026
a8f122f
update water param & fix caustics
ComfyFluffy Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ out/
*.class
third_party/
bin/
/logs
16 changes: 10 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,16 @@ abstract class CompileShaders extends DefaultTask {
def base = outBase(src)
def spv = new File(scratchDir, "${base}.spv")
if (src.name.endsWith(".slang")) {
// Build both SER encodings from one source; RtDeviceBringup selects the supported variant.
compileOneSlang(src, spv, base == "world.rgen"
? ["-capability", "spvShaderInvocationReorderEXT"] : [])
if (base == "world.rgen") {
compileOneSlang(src, new File(scratchDir, "world_nv.rgen.spv"),
["-capability", "spvShaderInvocationReorderNV"])
def worldIndirectRaygen = base == "world.rgen"
if (worldIndirectRaygen) {
// Keep a capability-free TraceRay fallback for devices without EXT SER, and
// publish the reordered variant separately when the extension is available.
compileOneSlang(src, spv, [])
compileOneSlang(src, new File(scratchDir, "world_ser.rgen.spv"),
["-DCAUSTICA_ENABLE_EXT_SER", "-capability", "spvShaderInvocationReorderEXT"])
} else {
// Pass A intentionally uses ordinary TraceRay.
compileOneSlang(src, spv, [])
}
} else {
compileOne(src, spv, [])
Expand Down
298 changes: 298 additions & 0 deletions shaders/world/guides.slang

Large diffs are not rendered by default.

325 changes: 325 additions & 0 deletions shaders/world/lighting.slang
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
// Direct lighting: the emitter light grid, RIS reservoirs, and reservoir shading.
// INDIRECT PASS ONLY — the primary pass shades nothing. Depends on trace and math.

// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted).

import world_common;
import world_core;
import math;
import trace;

public struct Reservoir {
public float3 pos; // light sample point (rebased world space)
public float3 lnrm; // emitter outward normal
public float3 le; // emitter radiance
public float area; // emitter rectangle area (the area-light pdf factor)
public float M;
public float W;
public float wSum;
public float phat;
};

public Reservoir resEmpty() {
Reservoir r;
r.pos = float3(0.0, 0.0, 0.0);
r.lnrm = float3(0.0, 0.0, 0.0);
r.le = float3(0.0, 0.0, 0.0);
r.area = 0.0;
r.M = 0.0;
r.W = 0.0;
r.wSum = 0.0;
r.phat = 0.0;
return r;
}

// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its
// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to
// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver
// modes share one target function because they are mutually exclusive per candidate:
// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses;
// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front;
// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks
// when the ray points from the light through the slab toward the camera (same as the sun SSS term).
public float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n,
float3 v, float3 rd, float3 diffAlb, float3 F0, float rough,
bool twoSided, float sss, out float phat) {
phat = 0.0;
float3 toL = sp - hitPos;
float dist2 = dot(toL, toL);
if (dist2 < 1.0e-6) {
return float3(0.0, 0.0, 0.0);
}
float dist = sqrt(dist2);
float3 wi = toL / dist;
float cosL = max(0.0, dot(lnrm, -wi));
if (cosL <= 0.0) {
return float3(0.0, 0.0, 0.0); // receiver behind the emitter face
}
float ndl = dot(n, wi);
if (twoSided) {
ndl = abs(ndl);
}
float3 contrib = float3(0.0, 0.0, 0.0);
if (ndl > 0.0) {
float G = ndl * cosL / dist2; // area-light geometry term (x area)
float3 brdf = diffAlb * INV_PI;
// twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only
// shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that
// IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps
// billboards from picking up a rim highlight they were never meant to have.
if (!twoSided) {
float3 h = normalize(wi + v);
float ndh = max(0.0, dot(n, h));
float ndv = max(1.0e-4, dot(n, v));
float vdh = max(0.0, dot(v, h));
float D = ggxD(ndh, rough);
float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough);
float3 Fs = fresnelSchlick(vdh, F0);
brdf += (D * Gs) * Fs / (4.0 * ndv * ndl);
}
contrib = brdf * le * (G * area);
} else if (sss > 0.0) {
float backNdl = -ndl;
float G = backNdl * cosL / dist2;
float cosT = dot(wi, rd);
contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area);
}
phat = luminance(contrib);
return contrib;
}

// Select one light from the emitted-power distribution in O(1).
public void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) {
float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount);
uint column = min(uint(aliasSample), worldPush.lightCount - 1u);
if (pc.lightAliasAddr != 0) {
LightAlias a = ConstPtr<LightAlias>(pc.lightAliasAddr)[column];
bool self = aliasSample - float(column) < a.accept;
lightIndex = self ? column : a.aliasIndex;
} else {
lightIndex = column;
}
}

public bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) {
cell.spanOffset = 0u;
cell.spanCount = 0u;
cell.invWeightSum = 0.0;
cellCoord = int3(0, 0, 0);
if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false;
int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w));
if (coord.x < 0 || coord.y < 0 || coord.z < 0
|| coord.x >= worldPush.lightGridDims.x
|| coord.y >= worldPush.lightGridDims.y
|| coord.z >= worldPush.lightGridDims.z) return false;
uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y))
* uint(worldPush.lightGridDims.x) + uint(coord.x);
cell = ConstPtr<LightGridCell>(pc.lightGridCellAddr)[linear];
cellCoord = coord;
return cell.spanCount > 0u;
}

public void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed,
out uint lightIndex) {
float aliasSample = rndf(proposalSeed) * float(lightCount);
uint column = min(uint(aliasSample), lightCount - 1u);
LightAlias alias = ConstPtr<LightAlias>(pc.lightLocalAliasAddr)[firstLight + column];
bool self = aliasSample - float(column) < alias.accept;
uint localIndex = self ? column : alias.aliasIndex;
lightIndex = firstLight + localIndex;
}

public float unpackUnsignedFloat(uint bits, uint mantissaBits) {
uint mantissaMask = (1u << mantissaBits) - 1u;
uint mantissa = bits & mantissaMask;
uint exponent = (bits >> mantissaBits) & 31u;
if (exponent == 0u) {
return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits)));
}
return (1.0 + float(mantissa) / float(1u << mantissaBits))
* exp2(float(int(exponent) - 15));
}

public float3 lightRadiance(Light light) {
uint packed = light.le;
return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u),
unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u),
unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u));
}

public int3 lightSectionCoord(Light light) {
uint packed = light.section;
return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu),
int((packed >> 20u) & 0x3ffu));
}

// The half axes share three lanes two-at-a-time; see the Light record in world_common.
public float3 lightHalfU(Light light) {
return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x);
}

public float3 lightHalfV(Light light) {
return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz));
}

// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared.
public float3 lightCrossUV(Light light) {
return cross(lightHalfU(light), lightHalfV(light));
}

// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store.
public float lightArea(Light light) {
return 4.0 * length(lightCrossUV(light));
}

public float3 lightGeometricNormal(Light light) {
float3 normal = normalize(lightCrossUV(light));
return (light.section & 0x40000000u) != 0u ? -normal : normal;
}

public float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord,
float localProbability) {
float power = max(0.0, lightArea(light) * luminance(le));
float globalPdf = pc.lightAliasAddr != 0
? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount);
float localPdf = 0.0;
if (localProbability > 0.0 && power > 0.0) {
int3 delta = lightSectionCoord(light) - cellCoord;
if (all(abs(delta) <= int3(2, 2, 2))) {
float3 deltaF = float3(delta);
localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF));
}
}
return localProbability * localPdf + (1.0 - localProbability) * globalPdf;
}

public void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed,
out uint lightIndex) {
ConstPtr<LightGridSpan> spans = ConstPtr<LightGridSpan>(pc.lightGridSpanAddr);
float aliasSample = rndf(proposalSeed) * float(cell.spanCount);
uint column = min(uint(aliasSample), cell.spanCount - 1u);
LightGridSpan span = spans[cell.spanOffset + column];
bool self = aliasSample - float(column) < span.accept;
uint firstLight = self ? span.firstLight : span.aliasFirstLight;
uint lightCount = self ? (span.packedLightCounts & 0xffffu)
: (span.packedLightCounts >> 16u);
selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex);
}

// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section
// by emitted power and section distance, then its section-local power alias. Every light in
// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support.
// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single
// Light48 load: section power cancels with the section-local light PDF.
public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed,
out uint lightIndex) {
if (useLocal) {
selectLightGridSpanLight(cell, proposalSeed, lightIndex);
} else {
selectGlobalLight(proposalSeed, lightIndex);
}
}

// ---- RIS cost profile. Temporary probes pinned the candidate walk out of the shader to bound what
// memory-side RIS work could ever be worth. Against an
// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence
// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against
// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost
// was chasing depth, not divergence, and it lived at secondary vertices. A presampled candidate pool
// would attack the same 5.9ms structurally, but see the note on
// proposalSeed in tracePath for why it should share the pool rather than the seed.

// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen
// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones.
public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0,
float rough, bool twoSided, float sss, inout uint seed, inout uint proposalSeed) {
Reservoir r = resEmpty();
LightGridCell gridCell;
int3 gridCellCoord;
float3 gridLookup = hitPos;
if (pc.lightGridCellAddr != 0) {
// Stochastically blend across hard section boundaries. Every cell proposal retains full global
// support, so conditioning on this independently jittered lookup remains unbiased.
gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5)
* worldPush.lightGridOrigin.w;
}
bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord);
uint candidateCount = worldPush.risCandidates;
r.M = float(candidateCount);
// Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six
// local and two global candidates, with every lane taking the same branch for a given candidate.
// Counts that are not divisible by four use the nearest practical split with at least one global
// candidate; M=1 is global-only so the estimator retains full support.
uint globalCandidateCount = hasGridCell
? max(1u, (candidateCount + 2u) / 4u) : candidateCount;
uint localCandidateCount = candidateCount - globalCandidateCount;
float localProbability = float(localCandidateCount) / float(candidateCount);
for (uint c = 0u; c < candidateCount; c++) {
uint li;
uint globalsBefore = (c * globalCandidateCount) / candidateCount;
uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount;
bool useLocal = hasGridCell && globalsAfter == globalsBefore;
selectLightGridLight(gridCell, useLocal, proposalSeed, li);
Light lg = ConstPtr<Light>(pc.lightBufAddr)[li];
// Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area).
float s = rndf(seed) * 2.0 - 1.0;
float t = rndf(seed) * 2.0 - 1.0;
float3 sp = lg.pos + worldPush.lightRebase.xyz
+ s * lightHalfU(lg) + t * lightHalfV(lg);
float3 le = lightRadiance(lg);
float3 lightNormal = lightGeometricNormal(lg);
float area = lightArea(lg);
float phat;
evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd,
diffAlb, F0, rough, twoSided, sss, phat);
if (phat <= 0.0) {
continue;
}
float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability);
float w = phat / max(sourcePdf, 1.0e-20);
r.wSum += w;
if (rndf(seed) * r.wSum < w) { // weighted reservoir update
r.pos = sp;
r.lnrm = lightNormal;
r.le = le;
r.area = area;
r.phat = phat;
}
}
r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0;
return r;
}

// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow
// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for
// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the
// sample's side of the surface.
public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb,
float3 F0, float rough, bool twoSided, float sss) {
if (s.W <= 0.0 || s.phat <= 0.0) {
return float3(0.0, 0.0, 0.0);
}
float phat;
float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd,
diffAlb, F0, rough, twoSided, sss, phat);
if (phat <= 0.0) {
return float3(0.0, 0.0, 0.0);
}
// Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from
// that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip
// up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights
// closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black.
float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0;
float3 origin = hitPos + side * n * SURF_BIAS;
float3 toL = s.pos - origin;
float dist = length(toL);
// Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face.
VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999);
float3 vis = shadow.transmittance;
return contrib * vis * s.W;
}

// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks):
// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections.
// bit 1 (0x02) — the primary camera ray.
// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible
Loading
Loading