diff --git a/build.gradle b/build.gradle index c23b48b0..b4b4bbae 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,13 @@ repositories { name = "Fabric" url = "https://maven.fabricmc.net/" } + maven { + name = "Modrinth" + url = "https://api.modrinth.com/maven" + content { + includeGroup "maven.modrinth" + } + } mavenCentral() } @@ -59,6 +66,11 @@ dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" implementation "net.fabricmc:fabric-loader:${project.loader_version}" implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + // FirstPerson Model, referenced only by the optional compat bridge for its render-state marker + // interface. Compile-only: the mod is neither bundled nor required at runtime, and the coordinate + // pins the Modrinth version id because the plain version number is shared across loaders. + compileOnly "maven.modrinth:first-person-model:6sgz2HEq" testImplementation platform("org.junit:junit-bom:5.12.2") testImplementation "org.junit.jupiter:junit-jupiter" testRuntimeOnly "org.junit.platform:junit-platform-launcher" diff --git a/shaders/pipelines/world/any_hit.rahit.slang b/shaders/pipelines/world/any_hit.rahit.slang index 67762c73..c346d396 100644 --- a/shaders/pipelines/world/any_hit.rahit.slang +++ b/shaders/pipelines/world/any_hit.rahit.slang @@ -23,6 +23,11 @@ static const float ENTITY_ALPHA_CUTOFF = 0.1; // entities: only discard near-ful // tinted pane still absorbs its own color on top of this. static const float TRANSLUCENT_NEUTRAL_EXTINCTION = 0.15; static const float WATER_SHADOW_TINT = 0.5; +// Neutral per-interface shadow attenuation for ice-family terrain (MATERIAL_FEATURE_ICE): strong enough +// that one block of ice (two interfaces, 0.15 squared) reads as near-opaque, removing the separated +// second shadow and blue cast that colored transmission produced, while stacked ice keeps darkening +// monotonically. Traversal still continues, so occluders behind the ice shadow normally. +static const float ICE_SHADOW_TRANSMITTANCE = 0.15; // Progressive 8x8 blue-noise-style alpha threshold pattern, with a golden-ratio temporal rotation. This keeps // translucent entity hits stochastic, but trades per-pixel white noise for a stable spatial distribution @@ -83,6 +88,20 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Radiance rays accept the surface and continue to closest-hit. Shadow rays use the same material // model here so entity dielectrics transmit instead of becoming opaque shadow blockers. bool shadowRay = (RayFlags() & RAY_FLAG_SKIP_CLOSEST_HIT_SHADER) != 0u; + // The first-person world stand-in is semi-transmissive to shadow rays: one neutral multiply by + // the configured transmittance per ray, however many of its layers the ray crosses. roughMetal + // is the once-marker — the shadow path never consumes it and guide.rmiss never writes it. + // Traversal always continues, so opaque geometry behind the body still blackens the ray in any + // intersection order, and the flags sentinel stays untouched. + if (instanceKind == ENTITY_BIT && shadowRay + && (g.reserved.x & ENTITY_GEOM_WORLD_STAND_IN) != 0u) { + if (payload.roughMetal == 0u) { + packAlbedo(payload, unpackAlbedo(payload) + * ConstPtr(pc.worldPushAddr)[0].shadowPolicy.x); + payload.roughMetal = 1u; + } + IgnoreHit(); + } if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_DIELECTRIC) { float3 tint709 = lerp(float3(1.0), srgbToLinear(texel.rgb) * srgbToLinear(epr.tint.rgb), texel.a); @@ -131,14 +150,21 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) if (bucket == BUCKET_TRANSLUCENT) { TerrainPrim pr = ConstPtr(sec.primAddr)[tri]; MaterialHeader materialHeader = ConstPtr(pc.materialTableAddr)[pr.materialId]; - float3 avgColor = max(bt709ToAcesCg( - materialHeader.average.rgb * srgbToLinear(pr.tint.rgb)), float3(1.0e-3)); - float3 colorExtinction = max(-log(avgColor), float3(0.0, 0.0, 0.0)); - // The neutral floor is a flat per-hit dimming, NOT scaled by alpha: vanilla clear glass has a low - // natural alpha, so folding it into the alpha-scaled term crushed it to near-zero for exactly the - // white/clear-glass case it's meant to cover. - packAlbedo(payload, unpackAlbedo(payload) - * exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION)); + // Ice replaces its colored Beer-Lambert tint with the fixed neutral attenuation, applied per + // interface with no marker and no termination; every other translucent material keeps the + // colored path below unchanged. + if ((materialHeader.features & MATERIAL_FEATURE_ICE) != 0u) { + packAlbedo(payload, unpackAlbedo(payload) * ICE_SHADOW_TRANSMITTANCE); + } else { + float3 avgColor = max(bt709ToAcesCg( + materialHeader.average.rgb * srgbToLinear(pr.tint.rgb)), float3(1.0e-3)); + float3 colorExtinction = max(-log(avgColor), float3(0.0, 0.0, 0.0)); + // The neutral floor is a flat per-hit dimming, NOT scaled by alpha: vanilla clear glass has a + // low natural alpha, so folding it into the alpha-scaled term crushed it to near-zero for + // exactly the white/clear-glass case it's meant to cover. + packAlbedo(payload, unpackAlbedo(payload) + * exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION)); + } IgnoreHit(); } diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index bf21f885..299ad6d7 100644 --- a/shaders/pipelines/world/closest_hit.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -18,11 +18,19 @@ void payloadSetPacked(inout Payload payload, uint material, float roughness, flo payload.iorTransmission = packHalf2(float2(ior, transmission)); } -// Which side of a dielectric face this hit is on, shared by every hit path. Comes from the face -// orientation, so it is re-derived at each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). -void payloadSetDielectric(inout Payload payload, uint material, bool entering) { +// Which side of a dielectric face this hit is on, plus the volume's canonical medium identity and ice +// marker, shared by every hit path. The side comes from the face orientation, so it is re-derived at +// each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). +void payloadSetDielectric(inout Payload payload, uint material, bool entering, + uint materialId, uint features) { if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; if (entering) payload.flags |= PAYLOAD_DIELECTRIC_ENTERING; + uint mediumId = material == MATERIAL_WATER + ? MEDIUM_ID_WATER : materialId + MEDIUM_ID_DIELECTRIC_BASE; + payload.flags |= (mediumId << PAYLOAD_MEDIUM_ID_SHIFT) & PAYLOAD_MEDIUM_ID_MASK; + if ((features & MATERIAL_FEATURE_ICE) != 0u) { + payload.flags |= PAYLOAD_SURFACE_ICE; + } } uint materialEmissionSource(MaterialHeader header, float emission) { @@ -322,7 +330,11 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); - payloadSetDielectric(payload, material, entering); + payloadSetDielectric(payload, material, entering, pr.materialId, header.features); + // After payloadSetPacked, which ASSIGNS flags rather than OR-ing into it. + if ((g.reserved.x & ENTITY_GEOM_LOCAL_VIEW) != 0u) { + payload.flags |= PAYLOAD_SURFACE_LOCAL_VIEW; + } return; } @@ -393,7 +405,8 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, MATERIAL_DIELECTRIC, glassSurface.roughness, glassSurface.metalness, 0.0, 0.0, materialHeader.params.z, materialHeader.params.w, EMISSION_SOURCE_NONE); - payloadSetDielectric(payload, MATERIAL_DIELECTRIC, entering); + payloadSetDielectric(payload, MATERIAL_DIELECTRIC, entering, pr.materialId, + materialHeader.features); return; } @@ -430,7 +443,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, materialEmissionSource(materialHeader, surface.emission)); - payloadSetDielectric(payload, material, entering); + payloadSetDielectric(payload, material, entering, pr.materialId, materialHeader.features); // RIS emitter-NEE membership: raygen gates this emitter's direct-hit emission term (RIS covers it). if ((pr.flags & TERRAIN_PRIM_IN_LIGHT_BUFFER) != 0u) { payload.flags |= PAYLOAD_EMITTER_IN_LIST; diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 7f135197..a985cdce 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -36,13 +36,18 @@ public struct SpecSurface { public float3 motionPrev; // current-minus-previous displacement of the reflecting surface public float roughness; public float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) + // Domain for the reflection probe. The interface's own domain, carried here because the probe runs + // from writeGuides — after tracePrimary returned and after the transmission chain has overwritten the + // global payload, at which point the primary hit's domain is no longer recoverable. + public uint secondaryRayMask; }; public static SpecSurface gv_spec = {}; // Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading // normal now and last frame, flat geometric normal for the bias) via the six-argument form. public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, - float3 biasNormal, float3 motionPrev, float roughness, float3 albedo) { + float3 biasNormal, float3 motionPrev, float roughness, float3 albedo, + uint secondaryRayMask) { SpecSurface s; s.camRel = camRel; s.normal = normal; @@ -51,17 +56,20 @@ public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previous s.motionPrev = motionPrev; s.roughness = roughness; s.albedo = albedo; + s.secondaryRayMask = secondaryRayMask; return s; } +// The convenience forms serve the static/sky/particle paths, which are always world surfaces. public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { return makeSpecSurface(camRel, normal, normal, normal, - float3(0.0, 0.0, 0.0), roughness, albedo); + float3(0.0, 0.0, 0.0), roughness, albedo, CULL_SECONDARY); } public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 motionPrev, float roughness, float3 albedo) { - return makeSpecSurface(camRel, normal, normal, normal, motionPrev, roughness, albedo); + return makeSpecSurface(camRel, normal, normal, normal, motionPrev, roughness, albedo, + CULL_SECONDARY); } // angle can land behind the eye even though the reflector itself is comfortably in view. public float2 projectPrevNdc(float3 worldPos, out bool valid) { @@ -107,7 +115,7 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, // Pass A stops radiance traversal at the first split, so reflection motion keeps one dedicated, // deterministic guide probe. - traceGuide(CULL_SECONDARY, + traceGuide(surface.secondaryRayMask, offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), RAY_TMIN, specDir, 10000.0, max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), @@ -158,10 +166,17 @@ public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 nor // Deterministic ordinary guide behind the first transmitted interface. This is guide-only work: // radiance reflection/transmission continuations are queued at the first split and traced by Pass B. -public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, +public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 transmittedDir, float3 surfaceBiasNormal, MediumStack medium, float rayBias, float rayConeWidth, float rayConeSpread, float3 guideFilter) { if (dot(transmittedDir, transmittedDir) <= 0.0) return; + // With the local view published, the whole chain keeps the union of the camera domain and the + // local-view secondary domain — matching the radiance chain's representation, never mid-switching + // to a mask that could hit the world stand-in. Unset keeps the per-crossing derivation below. + bool localViewPresent = (worldPush.flags & 4u) != 0u; + if (localViewPresent) { + rayMask = CULL_PRIMARY | CULL_LOCAL_VIEW_SECONDARY; + } float3 direction = normalize(transmittedDir); float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); @@ -169,7 +184,7 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, // The camera interface consumed bounce 0; the remaining configured bounce budget is the natural // cap for deterministic guide crossings too. for (uint crossing = 0u; crossing < worldPush.maxBounces; ++crossing) { - traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, + traceGuide(rayMask, ro, RAY_TMIN, direction, 10000.0, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, @@ -206,25 +221,50 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, worldPush.waterParams.w, waterFootprint); } - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payloadAlbedo(), transmission); - float etaT = entering ? entered.ior : medium.outer.ior; - float3 nextDirection = refract(direction, interfaceNormal, medium.current.ior / etaT); - if (dot(nextDirection, nextDirection) <= 0.0) { - // Never let a TIR reflection become ordinary diffuse/depth. + // An ice interface ends the guide chain: depth, position, normal and motion all describe this + // interface, never a blend with the destination behind it. Shaped like the TIR endpoint above. + if (material == MATERIAL_DIELECTRIC && payloadSurfaceIce()) { setTransmissionGuide(interfacePos - worldPush.camOffset, - isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), - interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false); + float3(payload.motionPrev), interfaceNormal, 0.0, + float3(0.0, 0.0, 0.0), false); return; } - if (!isWater && entering) { - guideFilter *= payloadAlbedo(); + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(payloadMediumId(), max(payloadIor(), 1.0), + payloadAlbedo(), transmission); + uint exitMatch = entering ? MEDIUM_EXIT_NO_MATCH : mediumResolveExit(medium, entered.mediumId); + bool opticalEvent = entering || exitMatch == MEDIUM_EXIT_CURRENT; + float3 nextDirection = direction; + if (opticalEvent) { + float etaT = entering ? entered.ior : medium.parent1.ior; + nextDirection = refract(direction, interfaceNormal, medium.current.ior / etaT); + if (dot(nextDirection, nextDirection) <= 0.0) { + // Never let a TIR reflection become ordinary diffuse/depth. + setTransmissionGuide(interfacePos - worldPush.camOffset, + isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false); + return; + } + if (!isWater && entering) { + guideFilter *= payloadAlbedo(); + } + if (entering) { + // A full stack ends the deterministic chain; the tuple keeps its last endpoint, the + // same fail-closed shape as exhausting the crossing budget. + if (!mediumPush(medium, entered)) { + return; + } + } else { + mediumCommitExit(medium, exitMatch); + } + } else if (mediumExitIsDeep(exitMatch)) { + mediumCommitExit(medium, exitMatch); } - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); + if (!localViewPresent) { + // Re-derive from THIS crossing, or a chain that leaves the local-view representation and + // enters world glass would keep probing in the local-view domain. + rayMask = CULL_PRIMARY | secondaryMaskForSurface(payload.flags); } direction = normalize(nextDirection); ro = offsetSurfaceOrigin(interfacePos, geometricNormal, direction, diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 2df3dc6a..2b180875 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -73,19 +73,41 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // remains active with its full configured candidate count at every hit. Pass A's primary/interface // prefix is outside this SSS quality budget. int indirectDepth = 0; + // Domain of the next continuation ray. The first trace resumes the record's domain; every later + // iteration re-selects it below, at the loop's single continuation-domain assignment point. + uint nextDomain = normalizeSecondaryDomain(seg.secondaryDomain); + uint baseSurfaceDomain = SECONDARY_DOMAIN_WORLD; + uint scatterClass = SCATTER_DIFFUSE; + // Camera-visible transmission continuity: seeded by the record, kept only across transmission + // lobes, cleared by everything else — and while true, transmission continuations stay in the + // local-view domain so the visible player never switches representation mid-chain. + bool cameraTransmissionContinuity = seg.cameraTransmissionContinuity; for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { + // The single continuation-domain assignment point, live once a prior hit has selected a lobe. + // Assigning per-branch instead would let a new upstream continuation path silently inherit the + // wrong domain without a rebase conflict. + if (bounce > seg.bounce) { + cameraTransmissionContinuity = cameraTransmissionContinuity + && scatterClass == SCATTER_TRANSMISSION; + nextDomain = continuationDomainForLobe(baseSurfaceDomain, scatterClass, + cameraTransmissionContinuity); + } // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. - // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person - // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. + // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the world stand-in. + // Every later ray takes its continuation domain — world surfaces keep CULL_SECONDARY for every + // lobe; a local-view surface's reflection-class lobes take CULL_REFLECTION, its other lobes + // CULL_LOCAL_VIEW_SECONDARY. #ifdef CAUSTICA_ENABLE_EXT_SER // SER lifetime phase: keep paths that are not roulette-eligible, paths that may terminate via // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : secondaryMaskForDomain(nextDomain), + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); #else - traceRadiance(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + traceRadiance(bounce == 0 ? CULL_PRIMARY : secondaryMaskForDomain(nextDomain), + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); #endif @@ -93,7 +115,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // A ray still in water should leave through a water interface. A miss instead means the // streamed/open volume has no known exit; do not reinterpret that unknown region as air // and reveal sky. - if (medium.current.water) { + if (mediumIsWater(medium.current)) { break; } @@ -105,6 +127,13 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { break; } + // Latch this hit's base domain before anything can overwrite the global payload. Every + // shadow/NEE/RIS/SSS ray cast from this vertex consumes baseSurfaceMask; the continuation's + // domain is selected separately, at the top of the next iteration, once the lobe is known. + baseSurfaceDomain = secondaryDomainForSurface(payload.flags); + uint baseSurfaceMask = secondaryMaskForDomain(baseSurfaceDomain); + scatterClass = SCATTER_DIFFUSE; + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies // to every hit reached while inside a volume dielectric (its own exit face, or whatever content // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so @@ -152,34 +181,47 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } // The medium this face opens into, and the one it returns to on the way out — which is what - // the stack remembers. - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); + // the stack remembers. Identity resolution decides whether the face is an optical event: + // entering and current-layer exits run Fresnel; a deeper or unmatched exit passes straight + // through, at most dropping the matched layer (see medium.slang). + Medium entered = makeDielectricMedium(payloadMediumId(), max(payloadIor(), 1.0), + tint, transmission); + uint exitMatch = entering ? MEDIUM_EXIT_NO_MATCH : mediumResolveExit(medium, entered.mediumId); + bool opticalEvent = entering || exitMatch == MEDIUM_EXIT_CURRENT; // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - bool chooseReflection = rndf(seed) < F; - if (chooseReflection) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen - rd = normalize(transmittedDir); + if (!opticalEvent) { + scatterClass = SCATTER_TRANSMISSION; + if (mediumExitIsDeep(exitMatch)) { + mediumCommitExit(medium, exitMatch); + } ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - // Crossing into or out of the volume. Absorption is the medium's job from here, so the - // tint is NOT also multiplied into the throughput — that would double-count it. - if (entering) { - mediumPush(medium, entered); + } else { + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.parent1.ior; + float cosI = clamp(dot(-rd, n), 0.0, 1.0); + float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + bool chooseReflection = rndf(seed) < F; + scatterClass = chooseReflection ? SCATTER_REFLECTION : SCATTER_TRANSMISSION; + if (chooseReflection) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); } else { - mediumPop(medium); + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + if (!mediumPush(medium, entered)) break; + } else { + mediumCommitExit(medium, exitMatch); + } } } showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc @@ -211,7 +253,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float ndl = abs(signedNdl); if (ndl > 0.0) { float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; - float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; + float3 vis = visibility(baseSurfaceMask, shadowOrigin, lightDir, 10000.0).transmittance; if (max(vis.r, max(vis.g, vis.b)) > 0.0) { L += throughput * albedo * INV_PI * celestialLight.illuminance * ndl * vis; } @@ -224,8 +266,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0, seed, proposalSeed); - L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0); + L += throughput * shadeReservoir(baseSurfaceMask, r, hitPos, n, v, rd, albedo, + float3(0.0, 0.0, 0.0), 1.0, true, 0.0); } if (bounce >= maxBounces) { @@ -286,12 +328,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } float ndl = max(0.0, dot(n, lightDir)); if (ndl > 0.0) { - VisibilityResult shadow = visibility(p, lightDir, 10000.0); + VisibilityResult shadow = visibility(baseSurfaceMask, p, lightDir, 10000.0); float3 vis = shadow.transmittance; // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, // so it applies to the whole NEE term (diffuse + specular). - if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { + if (mediumIsWater(medium.current) && waterWaves && shadow.waterHitT > 0.0) { vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { @@ -317,8 +359,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, seed, proposalSeed); - L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss); + L += throughput * shadeReservoir(baseSurfaceMask, r, hitPos, n, v, rd, diffAlb, F0, rough, + false, activeSss); } // Thin-surface SSS transmission. Light entering from the back face scatters through toward the @@ -329,11 +371,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { if (sss > 0.0 && hitDepth <= MAX_SSS_INDIRECT_DEPTH) { float backNdl = max(0.0, dot(-n, lightDir)); if (backNdl > 0.0) { - VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); + VisibilityResult shadowBack = visibility(baseSurfaceMask, hitPos - n * SURF_BIAS, + lightDir, 10000.0); float3 visB = shadowBack.transmittance; // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should // flicker with the same light bands as the floor around it. - if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { + if (mediumIsWater(medium.current) && waterWaves && shadowBack.waterHitT > 0.0) { visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, lightDir, shadowBack.waterHitT); } @@ -356,6 +399,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { ? 1.0 : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); if (rndf(seed) < ps) { + scatterClass = SCATTER_REFLECTION; float3 l; if (exactSpecular) { // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the diff --git a/shaders/pipelines/world/lighting.slang b/shaders/pipelines/world/lighting.slang index 4604e6bc..cf5ff99d 100644 --- a/shaders/pipelines/world/lighting.slang +++ b/shaders/pipelines/world/lighting.slang @@ -295,8 +295,8 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 // 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) { +public float3 shadeReservoir(uint rayMask, 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); } @@ -315,7 +315,7 @@ public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, flo 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); + VisibilityResult shadow = visibility(rayMask, origin, toL / dist, dist * 0.999); float3 vis = shadow.transmittance; return contrib * vis * s.W; } diff --git a/shaders/pipelines/world/medium.slang b/shaders/pipelines/world/medium.slang index f682cfd3..41e67a6e 100644 --- a/shaders/pipelines/world/medium.slang +++ b/shaders/pipelines/world/medium.slang @@ -1,4 +1,4 @@ -// Participating media: the tint-to-extinction mappings and the depth-2 medium stack the dielectric +// Participating media: the tint-to-extinction mappings and the depth-3 medium stack the dielectric // interface pushes and pops. Depends on core only. // Per-channel Beer–Lambert extinction from a water body's biome tint (carried in the payload's albedo view @@ -33,54 +33,104 @@ public float3 volumeExtinction(float3 tint, float transmission) { // travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the // per-segment Beer-Lambert attenuation. // -// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A +// The stack is depth 3 (current + the two it will return to) held in named fields, NOT an array. A // dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — -// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 -// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper -// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather -// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. +// paying an occupancy hit for nesting the game rarely produces would be a bad trade. Because `entering` +// is re-derived per face from geometry rather than toggled, the path re-synchronises at the next +// crossing instead of staying corrupted. +// +// Exits pair by identity (MEDIUM_ID_*, world_common) rather than by position, so a non-nested overlap +// — enter ice, enter glass, exit ice, exit glass — recovers the true surrounding medium instead of +// popping the wrong layer. MediumStackReferenceModelExhaustiveTest holds the executable Java reference +// model of these semantics; a change to either side must land in both. Air is the bottom sentinel and +// is never pushed, so every stack keeps a non-air prefix. + public struct Medium { public float ior; public float3 extinction; - public bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific + public uint mediumId; // canonical 20-bit identity (MEDIUM_ID_*) }; +// Water-specific behaviour — the wave-refraction caustic on submerged receivers, the water miss guard — +// keys off the identity; nothing else about a medium is water-specific. +public bool mediumIsWater(Medium m) { + return m.mediumId == MEDIUM_ID_WATER; +} + public struct MediumStack { public Medium current; - public Medium outer; + public Medium parent1; + public Medium parent2; }; public Medium airMedium() { Medium m; m.ior = 1.0; m.extinction = float3(0.0, 0.0, 0.0); - m.water = false; + m.mediumId = MEDIUM_ID_AIR; return m; } public MediumStack makeMediumStack(Medium start) { MediumStack s; s.current = start; - s.outer = airMedium(); + s.parent1 = airMedium(); + s.parent2 = airMedium(); return s; } -public void mediumPush(inout MediumStack stack, Medium entered) { - stack.outer = stack.current; +// False when the entered medium cannot be tracked: the stack already holds three real layers, or the +// id is the air sentinel. The caller must fail that continuation closed rather than corrupt a layer. +public bool mediumPush(inout MediumStack stack, Medium entered) { + if (entered.mediumId == MEDIUM_ID_AIR || stack.parent2.mediumId != MEDIUM_ID_AIR) { + return false; + } + stack.parent2 = stack.parent1; + stack.parent1 = stack.current; stack.current = entered; + return true; +} + +public static const uint MEDIUM_EXIT_CURRENT = 0u; +public static const uint MEDIUM_EXIT_PARENT1 = 1u; +public static const uint MEDIUM_EXIT_PARENT2 = 2u; +public static const uint MEDIUM_EXIT_NO_MATCH = 3u; + +// Nearest identity match from the top down, without mutating: a current-layer exit still needs +// parent1's IOR for Fresnel and refraction before the caller commits the removal. +public uint mediumResolveExit(MediumStack stack, uint exitingMediumId) { + if (exitingMediumId == MEDIUM_ID_AIR) return MEDIUM_EXIT_NO_MATCH; + if (stack.current.mediumId == exitingMediumId) return MEDIUM_EXIT_CURRENT; + if (stack.parent1.mediumId == exitingMediumId) return MEDIUM_EXIT_PARENT1; + if (stack.parent2.mediumId == exitingMediumId) return MEDIUM_EXIT_PARENT2; + return MEDIUM_EXIT_NO_MATCH; +} + +public bool mediumExitIsDeep(uint match) { + return match == MEDIUM_EXIT_PARENT1 || match == MEDIUM_EXIT_PARENT2; } -public void mediumPop(inout MediumStack stack) { - stack.current = stack.outer; - stack.outer = airMedium(); +// Removes exactly the matched layer, keeping every nearer one; no-match leaves the stack untouched. +public void mediumCommitExit(inout MediumStack stack, uint match) { + if (match == MEDIUM_EXIT_CURRENT) { + stack.current = stack.parent1; + stack.parent1 = stack.parent2; + stack.parent2 = airMedium(); + } else if (match == MEDIUM_EXIT_PARENT1) { + stack.parent1 = stack.parent2; + stack.parent2 = airMedium(); + } else if (match == MEDIUM_EXIT_PARENT2) { + stack.parent2 = airMedium(); + } } // Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume // dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. -public Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { +public Medium makeDielectricMedium(uint mediumId, float ior, float3 tint, float transmission) { Medium m; m.ior = ior; - m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); - m.water = isWater; + m.extinction = mediumId == MEDIUM_ID_WATER + ? waterExtinction(tint) : volumeExtinction(tint, transmission); + m.mediumId = mediumId; return m; } diff --git a/shaders/pipelines/world/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index 48f990bc..40d54bec 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -36,13 +36,17 @@ public PathSegment tracePrimary(PathSegment seg, uint seed = seg.seed; bool showCelestial = seg.showCelestial; bool waterWaves = (worldPush.flags & 16u) != 0u; + // The local view published this frame: camera-visible transmission chains then keep the local-view + // domain and seed the continuity bit; unset falls back to interface-derived domains, same frame. + bool localViewPresent = (worldPush.flags & 4u) != 0u; nextRecord = PATH_NO_NEXT; { int bounce = seg.bounce; + // Replayed by Pass B as the camera ray, so its domain field is normalized rather than derived. PathSegment terminal = makePathSegment(ro, rd, throughput, medium, rayConeWidth, rayConeSpread, seed, bounce, - showCelestial); + showCelestial, SECONDARY_DOMAIN_WORLD, localViewPresent); traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); @@ -88,8 +92,13 @@ public PathSegment tracePrimary(PathSegment seg, float3 v = -rd; gv_albedo = diffAlb; gv_rough = rough; - gv_spec = makeSpecSurface(gv_hitCamRel, n, float3(payload.motionPrev), rough, - rrSpecularAlbedo(payload.f0, rough, dot(n, v))); + // A local-view surface's reflection probe belongs to the reflection domain — never + // its own domain, so the probe cannot see either player representation. World + // surfaces keep the world secondary domain; particles above always belong to it. + gv_spec = makeSpecSurface(gv_hitCamRel, n, n, n, float3(payload.motionPrev), rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v)), + payloadSurfaceLocalView() + ? CULL_REFLECTION : CULL_SECONDARY); } } @@ -127,13 +136,44 @@ public PathSegment tracePrimary(PathSegment seg, } float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + Medium entered = makeDielectricMedium(payloadMediumId(), max(payloadIor(), 1.0), payloadAlbedo(), transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + // Identity resolution decides whether this face is an optical event at all: entering and + // current-layer exits run Fresnel and refraction; a deeper or unmatched exit lets the ray pass + // straight through, at most dropping the matched layer (see medium.slang). + uint exitMatch = entering ? MEDIUM_EXIT_NO_MATCH : mediumResolveExit(medium, entered.mediumId); + bool opticalEvent = entering || exitMatch == MEDIUM_EXIT_CURRENT; + MediumStack transmittedMedium = medium; + bool transmissionAvailable = true; + float F = 0.0; + float3 transmittedDir = rd; + if (opticalEvent) { + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.parent1.ior; + F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); + transmittedDir = refract(rd, n, etaI / etaT); + if (entering) { + transmissionAvailable = mediumPush(transmittedMedium, entered); + } else { + mediumCommitExit(transmittedMedium, exitMatch); + } + } else if (mediumExitIsDeep(exitMatch)) { + mediumCommitExit(transmittedMedium, exitMatch); + } + // This interface's domains, read once here — before any guide probe below overwrites the global + // payload. Every continuation domain and guide mask in this branch derives from these: the + // transmission side keeps the interface's own secondary domain, the reflection side takes the + // reflection domain when the interface is local view. + bool interfaceLocalView = payloadSurfaceLocalView(); + uint interfaceSecondaryDomain = secondaryDomainForSurface(payload.flags); + uint interfaceSecondaryMask = secondaryMaskForDomain(interfaceSecondaryDomain); + // Camera-visible transmission keeps the local-view domain whenever the local view is published, + // even through world water and glass; the reflection side is never part of that chain. + uint transmissionSecondaryDomain = localViewPresent + ? SECONDARY_DOMAIN_LOCAL_VIEW : interfaceSecondaryDomain; + uint interfaceReflectionDomain = interfaceLocalView + ? SECONDARY_DOMAIN_REFLECTION : SECONDARY_DOMAIN_WORLD; if (bounce == 0) { gv_normal = n; @@ -145,16 +185,14 @@ public PathSegment tracePrimary(PathSegment seg, gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), - gv_rough, float3(F, F, F)); - if (dot(transmittedDir, transmittedDir) > 0.0) { - MediumStack guideMedium = medium; - if (entering) { - mediumPush(guideMedium, entered); - } else { - mediumPop(guideMedium); - } - resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - guideMedium, transmitBias, rayConeWidth, rayConeSpread, + gv_rough, float3(F, F, F), + secondaryMaskForDomain(interfaceReflectionDomain)); + if (dot(transmittedDir, transmittedDir) > 0.0 && transmissionAvailable) { + // Guide-only: keeps the baseline's deliberate particle visibility (CULL_PRIMARY) while + // also reaching the player geometry the matching radiance path sees. + resolveTransmissionGuide(CULL_PRIMARY | interfaceSecondaryMask, + hitPos, transmittedDir, geometricNormal, + transmittedMedium, transmitBias, rayConeWidth, rayConeSpread, !isWater && entering ? payloadAlbedo() : float3(1.0, 1.0, 1.0)); } @@ -162,21 +200,24 @@ public PathSegment tracePrimary(PathSegment seg, // Split once, write both post-interface continuations, and stop Pass A radiance traversal. // Pass B owns every radiance trace after this point. - bool splitEligible = dot(transmittedDir, transmittedDir) > 0.0 + bool splitEligible = opticalEvent && dot(transmittedDir, transmittedDir) > 0.0 && F > 0.0 && F < 1.0; + if (splitEligible && !transmissionAvailable) { + // The transmission branch fails closed on a full stack; only the reflection survives. + float3 reflectedDir = reflect(rd, n); + return makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), + reflectedDir, throughput * F, medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, interfaceReflectionDomain, false); + } if (splitEligible) { - MediumStack transmittedMedium = medium; - if (entering) { - mediumPush(transmittedMedium, entered); - } else { - mediumPop(transmittedMedium); - } float3 deferredDir = normalize(transmittedDir); PathSegment deferred = makePathSegment( offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, transmissionSecondaryDomain, localViewPresent); queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); nextRecord = splitRecord; float3 reflectedDir = reflect(rd, n); @@ -184,10 +225,20 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, - true); + true, interfaceReflectionDomain, false); return reflected; } + if (!opticalEvent) { + // A deep or unmatched exit is optically inert: continue straight with unchanged direction + // and weight, carrying at most the deep removal. It still extends a transmission chain. + return makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias), + rd, throughput, transmittedMedium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, transmissionSecondaryDomain, localViewPresent); + } + bool hasTransmission = dot(transmittedDir, transmittedDir) > 0.0; PathSegment continuation; if (!hasTransmission || F >= 1.0) { @@ -196,19 +247,22 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, interfaceReflectionDomain, false); } else { float3 deferredDir = normalize(transmittedDir); - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); + if (!transmissionAvailable) { + // Pure transmission with a full stack cannot proceed; fail closed with zero weight. + return makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), + deferredDir, float3(0.0, 0.0, 0.0), medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, transmissionSecondaryDomain, localViewPresent); } continuation = makePathSegment( offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), - deferredDir, throughput * (1.0 - F), medium, + deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, transmissionSecondaryDomain, localViewPresent); } return continuation; } @@ -233,7 +287,7 @@ void main() { uint seed = (dispatchIndex.x * 1973u + dispatchIndex.y * 9277u + 26699u) ^ (worldPush.frameIndex * 2654435761u); MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u - ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + ? makeDielectricMedium(MEDIUM_ID_WATER, WATER_IOR, worldPush.waterParams.xyz, 1.0) : airMedium()); uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; uint baseRecordCount = dimensions.x * dimensions.y; @@ -242,7 +296,7 @@ void main() { seed = pcg(seed); PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), - cameraMedium, 0.0, rayConeSpread, seed, 0, true); + cameraMedium, 0.0, rayConeSpread, seed, 0, true, SECONDARY_DOMAIN_WORLD, false); uint nextRecord; PathSegment terminal = tracePrimary( current, queue, splitRecord, nextRecord); diff --git a/shaders/pipelines/world/segment.slang b/shaders/pipelines/world/segment.slang index 1ff9d265..e0bcf994 100644 --- a/shaders/pipelines/world/segment.slang +++ b/shaders/pipelines/world/segment.slang @@ -1,5 +1,5 @@ -// PathSegment — a resumable continuation — plus its packed 48-byte buffer form. This is the record -// the primary pass writes and the indirect pass reads. Depends on medium. +// PathSegment — a resumable continuation — plus its packed 64-byte buffer form. This is the record +// the primary pass writes and the indirect pass reads. Depends on medium and trace. // Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and @@ -14,6 +14,7 @@ import world_common; import world_core; import medium; +import trace; public struct PathSegment { public float3 ro; @@ -25,11 +26,20 @@ public struct PathSegment { public uint seed; public int bounce; // interfaces already consumed, so RR start and the bounce cap stay global public bool showCelestial; + // Which secondary domain Pass B must resume this continuation in (SECONDARY_DOMAIN_*). Meaningless + // at bounce 0, which is always traced as the camera ray; packPathSegment normalizes it to WORLD + // there so the packed round-trip is an equality. + public uint secondaryDomain; + // True while this continuation extends an unbroken chain of camera-visible transmissions: medium + // transmissions keep it, any reflection-class or diffuse lobe clears it. Pass B keeps such a chain + // in the local-view domain when the local view is published. + public bool cameraTransmissionContinuity; }; public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, float rayConeWidth, float rayConeSpread, uint seed, - int bounce, bool showCelestial) { + int bounce, bool showCelestial, uint secondaryDomain, + bool cameraTransmissionContinuity) { PathSegment s; s.ro = ro; s.rd = rd; @@ -40,16 +50,24 @@ public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, Medi s.seed = seed; s.bounce = bounce; s.showCelestial = showCelestial; + s.secondaryDomain = secondaryDomain; + s.cameraTransmissionContinuity = cameraTransmissionContinuity; return s; } -// field is a uint, so Std430DataLayout gives this an exact 48-byte stride. +// The uint following the float3 packs into its tail lane and every later member is uint-sized, so +// Std430DataLayout gives this an exact 64-byte stride (12 + 13*4); RtComposite.PATH_RECORD_BYTES +// allocates the queue from the same number. public struct PackedPathSegment { public float3 ro; public uint rd; public uint throughput; public uint currentExtinction; - public uint outerExtinction; - public uint mediumIors; + public uint parent1Extinction; + public uint parent2Extinction; + public uint mediumIors01; // half2(current.ior, parent1.ior) + public uint mediumIor2; // half2(parent2.ior, unused) + public uint mediumIds01; // u20 current.mediumId | low u12 parent1.mediumId << 20 + public uint mediumId2; // high u8 parent1.mediumId | u20 parent2.mediumId << 8 public uint rayCone; public uint seed; public uint pathFlags; @@ -57,6 +75,13 @@ public struct PackedPathSegment { }; public static const uint PATH_NO_NEXT = 0xffffffffu; +// pathFlags: bits 0..3 bounce, bit 8 showCelestial, bits 9..10 secondary domain (SECONDARY_DOMAIN_*, +// the fourth encoding never packed), bit 11 camera-transmission continuity. Bits 4..7 and 12..31 free. +public static const uint PATH_BOUNCE_MASK = 15u; +public static const uint PATH_SHOW_CELESTIAL = 1u << 8; +public static const uint PATH_SECONDARY_DOMAIN_SHIFT = 9u; +public static const uint PATH_SECONDARY_DOMAIN_MASK = 3u << PATH_SECONDARY_DOMAIN_SHIFT; +public static const uint PATH_CAMERA_TRANSMISSION_CONTINUITY = 1u << 11; public float2 octEncode(float3 direction) { float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); @@ -112,35 +137,52 @@ public PackedPathSegment packPathSegment(PathSegment seg, uint nextRecord) { p.rd = packUnorm16x2(octEncode(seg.rd)); p.throughput = packRgb9e5(seg.throughput); p.currentExtinction = packRgb9e5(seg.medium.current.extinction); - p.outerExtinction = packRgb9e5(seg.medium.outer.extinction); - p.mediumIors = packHalf2(float2(seg.medium.current.ior, seg.medium.outer.ior)); + p.parent1Extinction = packRgb9e5(seg.medium.parent1.extinction); + p.parent2Extinction = packRgb9e5(seg.medium.parent2.extinction); + p.mediumIors01 = packHalf2(float2(seg.medium.current.ior, seg.medium.parent1.ior)); + p.mediumIor2 = packHalf2(float2(seg.medium.parent2.ior, 0.0)); + p.mediumIds01 = (seg.medium.current.mediumId & 0xfffffu) + | ((seg.medium.parent1.mediumId & 0xfffu) << 20u); + p.mediumId2 = ((seg.medium.parent1.mediumId >> 12u) & 0xffu) + | ((seg.medium.parent2.mediumId & 0xfffffu) << 8u); p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); p.seed = seg.seed; - p.pathFlags = (uint(seg.bounce) & 15u) - | (seg.showCelestial ? 1u << 8u : 0u) - | (seg.medium.current.water ? 1u << 9u : 0u) - | (seg.medium.outer.water ? 1u << 10u : 0u); + // The one normalization site: a bounce-0 record is always replayed as the camera ray, so its domain + // stores as WORLD and write/read-back stays a decidable equality. + uint domain = seg.bounce == 0 + ? SECONDARY_DOMAIN_WORLD : normalizeSecondaryDomain(seg.secondaryDomain); + p.pathFlags = (uint(seg.bounce) & PATH_BOUNCE_MASK) + | (seg.showCelestial ? PATH_SHOW_CELESTIAL : 0u) + | (domain << PATH_SECONDARY_DOMAIN_SHIFT) + | (seg.cameraTransmissionContinuity ? PATH_CAMERA_TRANSMISSION_CONTINUITY : 0u); p.nextRecord = nextRecord; return p; } public PathSegment unpackPathSegment(PackedPathSegment p) { - float2 iors = unpackHalf2(p.mediumIors); + float2 iors01 = unpackHalf2(p.mediumIors01); Medium current; - current.ior = iors.x; + current.ior = iors01.x; current.extinction = unpackRgb9e5(p.currentExtinction); - current.water = (p.pathFlags & (1u << 9u)) != 0u; - Medium outer; - outer.ior = iors.y; - outer.extinction = unpackRgb9e5(p.outerExtinction); - outer.water = (p.pathFlags & (1u << 10u)) != 0u; + current.mediumId = p.mediumIds01 & 0xfffffu; + Medium parent1; + parent1.ior = iors01.y; + parent1.extinction = unpackRgb9e5(p.parent1Extinction); + parent1.mediumId = (p.mediumIds01 >> 20u) | ((p.mediumId2 & 0xffu) << 12u); + Medium parent2; + parent2.ior = unpackHalf2(p.mediumIor2).x; + parent2.extinction = unpackRgb9e5(p.parent2Extinction); + parent2.mediumId = (p.mediumId2 >> 8u) & 0xfffffu; MediumStack medium; medium.current = current; - medium.outer = outer; + medium.parent1 = parent1; + medium.parent2 = parent2; float2 cone = unpackHalf2(p.rayCone); return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, - int(p.pathFlags & 15u), (p.pathFlags & (1u << 8u)) != 0u); + int(p.pathFlags & PATH_BOUNCE_MASK), (p.pathFlags & PATH_SHOW_CELESTIAL) != 0u, + (p.pathFlags & PATH_SECONDARY_DOMAIN_MASK) >> PATH_SECONDARY_DOMAIN_SHIFT, + (p.pathFlags & PATH_CAMERA_TRANSMISSION_CONTINUITY) != 0u); } // Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated diff --git a/shaders/pipelines/world/trace.slang b/shaders/pipelines/world/trace.slang index 6a4f5b6e..f1a2ddaf 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -7,8 +7,29 @@ import world_common; import world_core; import bindings; +// Ray inclusion masks, not cull classes: each is ANDed against an instance's TLAS visibility mask and +// the instance is visible when the result is non-zero. The CULL_ prefix is historical. public static const uint CULL_SECONDARY = 0x01u; public static const uint CULL_PRIMARY = 0x02u; +public static const uint CULL_LOCAL_VIEW_SECONDARY = 0x04u; +// Reflection domain: continuation rays and specular probes leaving a local-view surface through a +// reflection-class lobe (interface Fresnel reflection, delta specular, glossy VNDF). Scene geometry's +// 0xFF instance mask contains this bit while neither player representation's mask does, so a local-view +// reflection can never contain the player. +public static const uint CULL_REFLECTION = 0x08u; + +// The three legal values of a path record's packed secondary-domain field; the fourth two-bit encoding +// is never packed. WORLD doubles as the normalization target for camera-replayed records. +public static const uint SECONDARY_DOMAIN_WORLD = 0u; +public static const uint SECONDARY_DOMAIN_LOCAL_VIEW = 1u; +public static const uint SECONDARY_DOMAIN_REFLECTION = 2u; + +// Scatter classes a continuation lobe falls into; the pass-through of an optically inert medium face +// counts as transmission. +public static const uint SCATTER_REFLECTION = 0u; +public static const uint SCATTER_TRANSMISSION = 1u; +public static const uint SCATTER_DIFFUSE = 2u; + public static const uint TERRAIN_BUCKETS = 4u; public static const uint SBT_RADIANCE = 0u; public static const uint SBT_SHADOW = TERRAIN_BUCKETS; @@ -16,6 +37,47 @@ public static const uint SBT_STRIDE_BUCKET = 1u; public static const uint MISS_RADIANCE = 0u; public static const uint MISS_GUIDE = 1u; +public uint normalizeSecondaryDomain(uint domain) { + return domain == SECONDARY_DOMAIN_LOCAL_VIEW || domain == SECONDARY_DOMAIN_REFLECTION + ? domain : SECONDARY_DOMAIN_WORLD; +} + +public uint secondaryMaskForDomain(uint domain) { + uint normalized = normalizeSecondaryDomain(domain); + if (normalized == SECONDARY_DOMAIN_LOCAL_VIEW) return CULL_LOCAL_VIEW_SECONDARY; + if (normalized == SECONDARY_DOMAIN_REFLECTION) return CULL_REFLECTION; + return CULL_SECONDARY; +} + +// A continuation ray's domain, decided once per hit after the scatter lobe is chosen. An unbroken +// camera-visible transmission chain keeps the local-view domain across world interfaces (both player +// representations then resolve consistently through water and glass); otherwise world surfaces keep the +// world domain for every lobe, and a local-view surface sends reflection-class lobes into the +// reflection domain and every other lobe into its own local-view domain. +public uint continuationDomainForLobe(uint baseSurfaceDomain, uint scatterClass, + bool cameraTransmissionContinuity) { + if (scatterClass == SCATTER_TRANSMISSION && cameraTransmissionContinuity) { + return SECONDARY_DOMAIN_LOCAL_VIEW; + } + if (baseSurfaceDomain == SECONDARY_DOMAIN_LOCAL_VIEW) { + return scatterClass == SCATTER_REFLECTION + ? SECONDARY_DOMAIN_REFLECTION : SECONDARY_DOMAIN_LOCAL_VIEW; + } + return SECONDARY_DOMAIN_WORLD; +} + +// The base domain a secondary ray inherits from the surface it leaves. The flags word is an explicit +// parameter rather than a read of the global payload: the global is overwritten by the next trace, so a +// caller must name WHICH hit it derives from. +public uint secondaryDomainForSurface(uint payloadFlags) { + return (payloadFlags & PAYLOAD_SURFACE_LOCAL_VIEW) != 0u + ? SECONDARY_DOMAIN_LOCAL_VIEW : SECONDARY_DOMAIN_WORLD; +} + +public uint secondaryMaskForSurface(uint payloadFlags) { + return secondaryMaskForDomain(secondaryDomainForSurface(payloadFlags)); +} + public RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { RayDesc r; r.Origin = origin; @@ -96,7 +158,7 @@ public Payload makeShadowPayload() { return shadowPayload; } -public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { +public VisibilityResult visibility(uint rayMask, float3 origin, float3 dir, float tmax) { // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow // path uses only the albedo view as accumulated transmittance and hitT as the nearest-water crossing. Payload shadowPayload = makeShadowPayload(); @@ -106,7 +168,7 @@ public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { // needs only ordinary TraceRay and no invocation-reorder capability. TraceRay(topLevelAS, RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, - CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, MISS_GUIDE, + rayMask, SBT_SHADOW, SBT_STRIDE_BUCKET, MISS_GUIDE, makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); VisibilityResult result; result.transmittance = shadowPayload.flags == 0u diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index bc297ab8..a537d3fa 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -53,7 +53,7 @@ public struct WorldPush { public float3 camDelta; public uint spp; public float2 jitter; - public uint flags; // bit0 submerged, bit4 waves + public uint flags; // bit0 submerged, bit2 local view present, bit4 waves public uint maxBounces; // ---- Sky state. Only what the CPU alone can know: Minecraft's four eased celestial angles (the // 26.2 timeline drives them through a cubic-bezier ease and a datapack may replace the track, so @@ -88,6 +88,9 @@ public struct WorldPush { // near mid-grey at any scene brightness; the display pass divides it back out, so the two cancel // exactly and this cannot change the image. 1.0 disables it. public float preExposure; + // Per-ray shadow behaviour knobs. x = first-person world-stand-in shadow transmittance (any_hit's + // exactly-once neutral multiply); yzw reserved and zero. + public float4 shadowPolicy; }; // 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the @@ -168,7 +171,7 @@ public struct Payload { public float hitT; // >= 0 on hit, < 0 on miss. public half3 motionPrev; // per-vertex world displacement since last frame. public half3 f0; // specular F0. - public uint flags; // bits 0..1 material, bit 2 celestial, bit 3 water-entering, bits 4..6 emission source. + public uint flags; // bits 0..1 material, bit 2 celestial, bit 3 water-entering, bits 4..6 emission source, bit 7 emitter-in-list, bit 8 local-view surface, bits 9..28 medium identity, bit 29 ice surface. public uint roughMetal; // packHalf2x16(roughness, metalness) public uint emissionSss; // packHalf2x16(emission, sss) public uint iorTransmission; // packHalf2x16(IOR, transmission factor) @@ -179,6 +182,17 @@ public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; +// Set by world.rchit on an entity hit whose EntityGeom carries ENTITY_GEOM_LOCAL_VIEW: the hit surface +// belongs to the local-view representation. Bits 0..7 were full, so this takes the first free high bit — +// the payload does not grow and the cross-stage ABI is unchanged. +public static const uint PAYLOAD_SURFACE_LOCAL_VIEW = 1u << 8; +// Set by world.rchit on a dielectric hit: the 20-bit canonical identity in bits 9..28 of the +// medium behind the face (MEDIUM_ID_WATER for water, materialId + MEDIUM_ID_DIELECTRIC_BASE +// otherwise), consumed by the raygen medium stack. Zero — air — on every non-dielectric hit. +public static const uint PAYLOAD_MEDIUM_ID_SHIFT = 9u; +public static const uint PAYLOAD_MEDIUM_ID_MASK = 0xfffffu << PAYLOAD_MEDIUM_ID_SHIFT; +// Set by world.rchit when the hit material carries MATERIAL_FEATURE_ICE. +public static const uint PAYLOAD_SURFACE_ICE = 1u << 29; // Set by world.rchit on any dielectric hit (water or glass/ice): true when the incoming ray travels // against the prim's outward face normal (entering the volume), false when it exits. Derived from face // orientation rather than toggled, so a stray or missing face cannot corrupt the medium for the rest of @@ -204,6 +218,15 @@ public static const uint MATERIAL_WATER = 1u; public static const uint MATERIAL_PARTICLE = 2u; public static const uint MATERIAL_DIELECTRIC = 3u; +// Canonical participating-medium identities, carried per hit in Payload.flags (PAYLOAD_MEDIUM_ID_*) +// and per stack layer in the packed path record. Air and water are reserved; every other dielectric +// derives materialId + MEDIUM_ID_DIELECTRIC_BASE, so identity comparisons are integer-only and optical +// parameters never decide what a medium is. RtMaterialRegistry rejects tables too large to fit the +// 20-bit identity space. +public static const uint MEDIUM_ID_AIR = 0u; +public static const uint MEDIUM_ID_WATER = 1u; +public static const uint MEDIUM_ID_DIELECTRIC_BASE = 2u; + // Entity per-triangle record (48 B). The final lane mirrors TerrainPrim's integer material metadata. public struct Prim { public float4 normal; // xyz = geometric normal, w = per-primitive emission strength @@ -243,6 +266,8 @@ public static const uint MATERIAL_FEATURE_SPEC = 1u; public static const uint MATERIAL_FEATURE_NORMAL = 2u; public static const uint MATERIAL_FEATURE_HEURISTIC_EMISSION = 4u; public static const uint MATERIAL_FEATURE_STOCHASTIC_ALPHA = 16u; +// Ice-family terrain materials; mirrored into PAYLOAD_SURFACE_ICE by the closest hit. +public static const uint MATERIAL_FEATURE_ICE = 8u; // Final HDR emitting-surface luminance (look-package baseline or absolute JSON cd/m² override), baked in Java // at material-compile time (RtMaterialRegistry) and packed here as a 16-bit fraction of the max — every // emissive material carries a value (0 for non-emissive), not just resource-pack-overridden ones, so the @@ -279,6 +304,12 @@ public struct EntityGeom { public static const uint ENTITY_BIT = 0x800000u; public static const uint PARTICLE_BIT = 0x400000u; // particles share the entity cutout path public static const uint IDX_MASK = 0x3FFFFFu; // low 22 bits = geom-table index +// EntityGeom.reserved low word: per-instance semantic flags. InstanceCustomIndex has no free bit left +// (23/22 are taken and 0..21 are the index), so instance semantics ride in the geometry record instead: +// bit 0 marks the local-view representation, bit 1 the first-person world stand-in (whose TLAS instance +// is force-no-opaque so its shadow semi-transmittance any-hit runs). +public static const uint ENTITY_GEOM_LOCAL_VIEW = 1u << 0; +public static const uint ENTITY_GEOM_WORLD_STAND_IN = 1u << 1; public static const uint BUCKET_CUTOUT = 1u; public static const uint BUCKET_TRANSLUCENT = 2u; public static const uint BUCKET_WATER = 3u; diff --git a/shaders/pipelines/world/world_core.slang b/shaders/pipelines/world/world_core.slang index c0203601..7c08f62c 100644 --- a/shaders/pipelines/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -33,6 +33,13 @@ public uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } // travelling into the volume (vs. out of it) at this hit's face. public bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +// Set by world.rchit on an entity hit whose EntityGeom carries ENTITY_GEOM_LOCAL_VIEW — the hit surface +// belongs to the local-view representation, so rays leaving it take the local-view secondary domain. +public bool payloadSurfaceLocalView() { return (payload.flags & PAYLOAD_SURFACE_LOCAL_VIEW) != 0u; } +public bool payloadSurfaceIce() { return (payload.flags & PAYLOAD_SURFACE_ICE) != 0u; } +// The 20-bit canonical identity of the medium behind the current hit's dielectric face (see +// PAYLOAD_MEDIUM_ID_SHIFT); MEDIUM_ID_AIR on non-dielectric hits. +public uint payloadMediumId() { return (payload.flags & PAYLOAD_MEDIUM_ID_MASK) >> PAYLOAD_MEDIUM_ID_SHIFT; } // LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used // end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly // that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 0088a319..74fb69ed 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -616,6 +616,11 @@ public static final class Entities { intAtLeast("caustica.rt.beBuildsPerFrame", "entities.block-entities.builds-per-frame", 64, 0); public static final BooleanSetting REFIT_ENABLED = bool("caustica.rt.entityRefit", "entities.refit.enabled", true); + public static final BooleanSetting FIRST_PERSON_COMPAT_ENABLED = + bool("caustica.rt.firstPersonCompat", "entities.first-person-compat.enabled", false); + public static final FloatSetting FIRST_PERSON_SHADOW_TRANSMITTANCE = + clampedFloat("caustica.rt.firstPersonShadowTransmittance", + "entities.first-person-shadow-transmittance", 0.35f, 0.0f, 1.0f); private Entities() { } @@ -957,7 +962,10 @@ private static FloatSetting exposureScale(String key, String tomlPath, float fal } private static FloatSetting clampedFloat(String key, String tomlPath, float fallback, float min, float max) { - return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, v -> Math.clamp(v, min, max)); + // NaN is unordered, so Math.clamp would pass it straight through to the GPU; infinities clamp + // to the range ends like any other out-of-range value. + return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, + v -> Double.isNaN(v) ? fallback : Math.clamp(v, min, max)); } private static FloatSetting radians(String key, String tomlPath, float fallbackDegrees) { diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java index c00b9e6f..c54cf33a 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java @@ -1,6 +1,7 @@ package dev.comfyfluffy.caustica.client; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.compat.firstperson.FirstPersonModelBridge; import dev.comfyfluffy.caustica.rt.RtContext; import dev.comfyfluffy.caustica.rt.RtDeviceBringup; import dev.comfyfluffy.caustica.rt.RtComposite; @@ -15,14 +16,18 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.rendering.v1.InvalidateRenderStateCallback; +import net.fabricmc.loader.api.FabricLoader; public final class CausticaClient implements ClientModInitializer { + private static final String FIRST_PERSON_MODEL_MOD_ID = "firstperson"; private static boolean rtInitDone = false; @Override public void onInitializeClient() { CausticaMod.LOGGER.info("Caustica client initialized"); + registerFirstPersonModelBridge(); + // Class-init runs DebugScreenEntries.register(...) via its ID field; touching the class here // makes the entry discoverable in F3's entry list. Off by default -- the player opts in the // same way as any other optional vanilla entry (e.g. GPU utilization). @@ -82,6 +87,20 @@ public void onInitializeClient() { }); } + private static void registerFirstPersonModelBridge() { + // Guarding on the loader keeps the bridge class — and the mod types it links against — untouched + // when the mod is absent, which is the normal case and must stay silent. + if (!FabricLoader.getInstance().isModLoaded(FIRST_PERSON_MODEL_MOD_ID)) { + return; + } + try { + FirstPersonModelBridge.register(); + } catch (LinkageError e) { + CausticaMod.LOGGER.warn("FirstPerson Model is installed but its bridge failed to link; " + + "first-person ray-traced geometry stays disabled", e); + } + } + private static void shutdownRt() { WorldRenderScaler.INSTANCE.destroy(); RtUiOverlay.destroy(); // GUI redirect is not gated by rtInitDone; always release its TextureTarget diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 8fa9206f..da801be6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -48,6 +48,8 @@ public static OptionInstance[] runtimeOptions() { maxBounces(), entities(), particles(), + firstPersonCompat(), + firstPersonShadowTransmittance(), waterWaves(), dlssQuality() )); @@ -131,6 +133,23 @@ private static OptionInstance particles() { return bool("caustica.options.rt.particles", CausticaConfig.Rt.Entities.PARTICLES_ENABLED); } + private static OptionInstance firstPersonCompat() { + return bool("caustica.options.rt.firstPersonCompat", + CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED); + } + + private static OptionInstance firstPersonShadowTransmittance() { + FloatSetting setting = CausticaConfig.Rt.Entities.FIRST_PERSON_SHADOW_TRANSMITTANCE; + return new OptionInstance<>( + "caustica.options.rt.firstPersonShadowTransmittance", + OptionInstance.cachedConstantTooltip( + Component.translatable("caustica.options.rt.firstPersonShadowTransmittance.tooltip")), + (caption, percent) -> Options.genericValueLabel(caption, Component.literal(percent + "%")), + new OptionInstance.IntRange(0, 100), + Math.clamp(Math.round(setting.value() * 100.0f), 0, 100), + percent -> setting.set(percent / 100.0f)); + } + private static OptionInstance waterWaves() { return bool("caustica.options.rt.waterWaves", CausticaConfig.Rt.Composite.WATER_WAVES); } diff --git a/src/main/java/dev/comfyfluffy/caustica/compat/firstperson/FirstPersonModelBridge.java b/src/main/java/dev/comfyfluffy/caustica/compat/firstperson/FirstPersonModelBridge.java new file mode 100644 index 00000000..99d77099 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/compat/firstperson/FirstPersonModelBridge.java @@ -0,0 +1,86 @@ +package dev.comfyfluffy.caustica.compat.firstperson; + +import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.mixin.LevelRendererAccessor; +import dev.comfyfluffy.caustica.rt.entity.CameraSafetyDeclaration; +import dev.comfyfluffy.caustica.rt.entity.FirstPersonStateProvider; +import dev.comfyfluffy.caustica.rt.entity.FirstPersonStateRegistry; +import dev.tr7zw.firstperson.access.LivingEntityRenderStateAccess; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.world.entity.Entity; +import org.jetbrains.annotations.Nullable; + +/** + * Supplies Caustica with the first-person body state produced by the FirstPerson Model mod. + * + *

The mod appends one extra render state for the camera entity during vanilla's extract phase, taken + * with the entity temporarily displaced by its computed offset, and marks that state — and only that + * state — as the camera entity. Caustica therefore rebuilds no first-person geometry: it picks that + * state up and feeds it through the ordinary capture path, and the offset already baked into + * {@code x/y/z} places the instance correctly. + * + *

This class links against the mod, so it must only be touched once the loader has confirmed the mod + * is present. Nothing on the render path references it. + */ +public final class FirstPersonModelBridge implements FirstPersonStateProvider, CameraSafetyDeclaration { + private static final String PROVIDER_ID = "firstperson-model"; + private static final int PROVIDER_PRIORITY = 200; + + private boolean warnedAmbiguousCandidates; + + private FirstPersonModelBridge() { + } + + public static void register() { + FirstPersonModelBridge bridge = new FirstPersonModelBridge(); + FirstPersonStateRegistry.instance().register(PROVIDER_ID, PROVIDER_PRIORITY, bridge, bridge); + } + + @Nullable + @Override + public EntityRenderState provideState(Entity camera, float partialTick) { + LevelRenderer levelRenderer = Minecraft.getInstance().levelRenderer; + if (levelRenderer == null) { + return null; + } + LevelRenderState level = ((LevelRendererAccessor) levelRenderer).caustica$getLevelRenderState(); + if (level == null) { + return null; + } + + int cameraId = camera.getId(); + EntityRenderState found = null; + for (EntityRenderState state : level.entityRenderStates) { + // The mod's marker interface is mixed in at runtime, so the cast goes through the vanilla + // supertype rather than through AvatarRenderState. + if (!(state instanceof AvatarRenderState avatar) + || avatar.id != cameraId + || !((LivingEntityRenderStateAccess) state).isCameraEntity()) { + continue; + } + if (found != null) { + // Two marked states for one camera entity contradicts the mod's own invariant; picking + // either by list order would be a guess, so this frame yields nothing. + if (!warnedAmbiguousCandidates) { + warnedAmbiguousCandidates = true; + CausticaMod.LOGGER.warn("FirstPerson Model marked more than one render state for entity {};" + + " skipping the first-person instance", cameraId); + } + return null; + } + found = state; + } + return found; + } + + @Override + public boolean isCameraSafe(Entity camera, EntityRenderState state, float partialTick) { + // The mod hides the head whenever it marks a state as the camera entity, so a marked state never + // encloses the camera origin. Selection already rejected every unmarked state. + return true; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/LevelRendererAccessor.java b/src/main/java/dev/comfyfluffy/caustica/mixin/LevelRendererAccessor.java new file mode 100644 index 00000000..c18bde8c --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/LevelRendererAccessor.java @@ -0,0 +1,18 @@ +package dev.comfyfluffy.caustica.mixin; + +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +/** + * Exposes the level render state so optional first-person compatibility bridges can read the entity + * render states vanilla extracted this frame. {@code LevelExtractor.extract} clears and repopulates + * {@code entityRenderStates} before the render phase runs, so the list a bridge sees during Caustica's + * capture holds exactly this frame's states. + */ +@Mixin(LevelRenderer.class) +public interface LevelRendererAccessor { + @Accessor("levelRenderState") + LevelRenderState caustica$getLevelRenderState(); +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index d65090f8..9b41daad 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -104,7 +104,9 @@ public static boolean enabled() { // Hot addresses/frameIndex avoid unnecessary global-memory dereferences; WorldPushConstantsData is // generated from the same Slang module and owns this second ABI as well. debugView is no longer // part of it -- no world shader reads it anymore; debug views are a downstream compute pass. - private static final long PATH_RECORD_BYTES = 48L; + // Stride of segment.slang's PackedPathSegment (std430: float3 + 13 uints). The continuation queue + // below is allocated from it, so the two must move together. + private static final long PATH_RECORD_BYTES = 64L; private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); } @@ -1062,7 +1064,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo frameInvViewProj.set(frameProjection).mul(frameViewRotation).invert(); // flags: camera-in-water (so the path tracer starts in the water medium when the eye is // submerged, fixing the air→water first-segment orientation) and animated water normals. - // Bit 1 remains unused to avoid conflicting with stale external readers. + // Bit 1 remains unused to avoid conflicting with stale external readers; bit 2 is written + // below, only from this frame's local-view publication fact. int flags = 0; var level = Minecraft.getInstance().level; if (level != null) { @@ -1115,6 +1118,11 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtEntities.FrameEntities fe = RtEntities.INSTANCE.beginFrame(ctx, terrain.staticInstances(), terrain.blockX, terrain.blockY, terrain.blockZ, camX, camY, camZ, frameProjection, frameViewRotation); frameEntities = fe; + // Same-frame, no hysteresis: the shader's transmission-continuity chain keys off exactly + // this frame's publication fact. + if (fe.localViewPublished()) { + flags |= 0b100; + } // Block-breaking overlay: resolves each destroy-stage RenderType's texture into the // SAME bindless entity-texture array (destroy_stage_N.png is a standalone Sampler0 texture, // not a block-atlas sprite — see ModelBakery.BREAKING_LOCATIONS/DESTROY_TYPES), so any newly @@ -1156,7 +1164,9 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(), // Must be the SAME value the exposure resolve divides out this frame (it reads it // from the same RtExposure accessor), or the two stop cancelling. - exposure.preExposure() + exposure.preExposure(), + new Float4(CausticaConfig.Rt.Entities.FIRST_PERSON_SHADOW_TRANSMITTANCE.value(), + 0.0f, 0.0f, 0.0f) ).write(push); pushBuf.flush(0L, WORLD_PUSH_SIZE); // Upload any entity textures registered this frame into the bindless set before the trace. diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 7722b8fd..a7479841 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -38,6 +38,7 @@ public final class RtFrameStats { "terrain.drainCompletion", "terrain.snapshotDispatch", "terrain.publish", + "terrain.lightGridPublish", "entity.capture", "entity.capture.extract", "entity.capture.submit", @@ -89,7 +90,8 @@ public final class RtFrameStats { "entityPackedBytes", "entityPackedPaddingBytes", "entityRetainedGeometryBytes", "entityFrameListsWaits", "entityTableWaits", "entitySlotWaits", "entityGraphicsWaitNanos", "entityMotionFlushes", "entityTableFlushes", - "entityBlockEntityRetirements", "entitySlotRetirements", "entityTableRetirements"}, + "entityBlockEntityRetirements", "entitySlotRetirements", "entityTableRetirements", + "firstPersonInstances", "localViewInstances", "worldStandInInstances"}, true); private static final List GC_BEANS = ManagementFactory.getGarbageCollectorMXBeans(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java index c7c03cf6..dd7f3f57 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java @@ -57,6 +57,7 @@ import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_NO_DUPLICATE_ANY_HIT_INVOCATION_BIT_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_OPAQUE_BIT_KHR; +import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_INSTANCE_FORCE_NO_OPAQUE_BIT_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_INSTANCE_TRIANGLE_FACING_CULL_DISABLE_BIT_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_TYPE_INSTANCES_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_TYPE_TRIANGLES_KHR; @@ -910,14 +911,22 @@ private static VkAccelerationStructureBuildSizesInfoKHR queryTerrainBlasSizes(Vk * trace cull mask), and the base SBT hit-record offset. Terrain uses offset 0 so geometry index selects * the material bucket. Entities use {@link #SBT_ENTITY_OFFSET}; their fixed geometry index then selects * opaque or any-hit; the remaining two records in each four-record entity SBT block stay unused. + * {@code geometryFlags} carries extra per-instance VkGeometryInstanceFlags (e.g. force-no-opaque), + * OR-ed onto the fixed triangle-facing-cull-disable policy. */ - public record Instance(float[] transform3x4, long blasDeviceAddress, int customIndex, int mask, int sbtRecordOffset) { + public record Instance(float[] transform3x4, long blasDeviceAddress, int customIndex, int mask, + int sbtRecordOffset, int geometryFlags) { public Instance(float[] transform3x4, long blasDeviceAddress, int customIndex) { - this(transform3x4, blasDeviceAddress, customIndex, 0xFF, 0); + this(transform3x4, blasDeviceAddress, customIndex, 0xFF, 0, 0); } public Instance(float[] transform3x4, long blasDeviceAddress, int customIndex, int mask) { - this(transform3x4, blasDeviceAddress, customIndex, mask, 0); + this(transform3x4, blasDeviceAddress, customIndex, mask, 0, 0); + } + + public Instance(float[] transform3x4, long blasDeviceAddress, int customIndex, int mask, + int sbtRecordOffset) { + this(transform3x4, blasDeviceAddress, customIndex, mask, sbtRecordOffset, 0); } } @@ -1023,7 +1032,7 @@ private static void writeTlasInstances(List instances, long mapped, in record.instanceCustomIndex(instance.customIndex()) .mask(instance.mask()) .instanceShaderBindingTableRecordOffset(instance.sbtRecordOffset()) - .flags(VK_GEOMETRY_INSTANCE_TRIANGLE_FACING_CULL_DISABLE_BIT_KHR) + .flags(VK_GEOMETRY_INSTANCE_TRIANGLE_FACING_CULL_DISABLE_BIT_KHR | instance.geometryFlags()) .accelerationStructureReference(instance.blasDeviceAddress()); } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/CameraSafetyDeclaration.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/CameraSafetyDeclaration.java new file mode 100644 index 00000000..eb266ca6 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/CameraSafetyDeclaration.java @@ -0,0 +1,33 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.world.entity.Entity; + +/** + * Declares whether the provided first-person geometry is camera-safe. + *

+ * Camera-safe means the geometry will not wrap or intersect the camera origin when rendered. + * This declaration must be made per-frame, as safety depends on dynamic factors like part + * visibility and position offsets. + */ +public interface CameraSafetyDeclaration { + /** + * Returns {@code true} if the first-person geometry is safe to render for primary camera + * rays, {@code false} otherwise. + *

+ * If this returns {@code false}, throws an exception, or the provider does not implement + * this interface, the first-person instance will not be created. + *

+ * This is an observational query on the current render frame. Caustica calls it before it + * extracts the camera entity's ordinary body, so an implementation must not mutate the camera entity, + * any world entity, vanilla's render state list, any render state object or its fields, Caustica's + * config, or the provider registry — any such mutation would change the body's extraction result. + * + * @param camera the camera entity + * @param state the first-person render state to evaluate + * @param partialTick sub-tick interpolation fraction + * @return {@code true} if camera-safe, {@code false} otherwise + * @throws Exception if safety cannot be determined + */ + boolean isCameraSafe(Entity camera, EntityRenderState state, float partialTick) throws Exception; +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateProvider.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateProvider.java new file mode 100644 index 00000000..851565ab --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateProvider.java @@ -0,0 +1,32 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.world.entity.Entity; +import org.jetbrains.annotations.Nullable; + +/** + * Provides the first-person body render state for the camera entity. + *

+ * Implementations return a pre-extracted {@link EntityRenderState} that was produced by + * vanilla's or a mod's frame extraction. The returned state must be valid for the current + * frame and belong to the camera entity. Caustica does not perform position offsets, part + * hiding, or pose modifications — the provider must return a complete first-person state. + */ +public interface FirstPersonStateProvider { + /** + * Returns the first-person body render state for the camera entity, or {@code null} if + * unavailable this frame. + *

+ * This is an observational query on the current render frame. Caustica calls it before it + * extracts the camera entity's ordinary body, so an implementation must not mutate the camera entity, + * any world entity, vanilla's render state list, any render state object or its fields, Caustica's + * config, or the provider registry — any such mutation would change the body's extraction result. + * + * @param camera the camera entity (typically the local player) + * @param partialTick sub-tick interpolation fraction + * @return the first-person render state, or {@code null} if not available + * @throws Exception if state extraction fails + */ + @Nullable + EntityRenderState provideState(Entity camera, float partialTick) throws Exception; +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateRegistry.java new file mode 100644 index 00000000..e3d76c2a --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateRegistry.java @@ -0,0 +1,151 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registry for first-person state providers with deterministic selection and circuit-breaker semantics. + *

+ * Thread-safe for registration (can be called during mod init). Selection happens on render thread only. + */ +public final class FirstPersonStateRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger(FirstPersonStateRegistry.class); + private static final FirstPersonStateRegistry INSTANCE = new FirstPersonStateRegistry(); + + public static FirstPersonStateRegistry instance() { + return INSTANCE; + } + + private final Map providers = new ConcurrentHashMap<>(); + private final Set circuitBroken = new HashSet<>(); + private final Set warnedOwnershipProviders = new HashSet<>(); + private boolean warnedTie = false; + + private FirstPersonStateRegistry() { + } + + /** + * Registers a first-person state provider. + * + * @param id unique provider identifier + * @param priority integer priority (higher = preferred) + * @param provider the state provider + * @param safety camera safety declaration (may be same object as provider) + */ + public void register(String id, int priority, FirstPersonStateProvider provider, CameraSafetyDeclaration safety) { + if (id == null || provider == null || safety == null) { + throw new IllegalArgumentException("Provider ID, provider, and safety must not be null"); + } + providers.put(id, new ProviderEntry(priority, provider, safety)); + LOGGER.debug("Registered first-person provider '{}' with priority {}", id, priority); + } + + /** + * Selects the provider with the highest priority. Returns null if no providers registered, + * multiple providers tie for max priority, or all providers are circuit-broken. + * + * @return selected provider entry, or null + */ + @Nullable + public SelectedProvider selectProvider() { + if (providers.isEmpty()) { + return null; + } + + // Find max priority among non-circuit-broken providers + int maxPriority = Integer.MIN_VALUE; + String maxId = null; + ProviderEntry maxEntry = null; + int countAtMax = 0; + + for (Map.Entry entry : providers.entrySet()) { + String id = entry.getKey(); + if (circuitBroken.contains(id)) { + continue; + } + ProviderEntry pe = entry.getValue(); + // maxEntry guards the first candidate: a provider whose priority is exactly Integer.MIN_VALUE + // would otherwise never win the `>` comparison against the initial sentinel. + if (maxEntry == null || pe.priority > maxPriority) { + maxPriority = pe.priority; + maxId = id; + maxEntry = pe; + countAtMax = 1; + } else if (pe.priority == maxPriority) { + countAtMax++; + } + } + + if (maxId == null) { + // All providers circuit-broken or none available + return null; + } + + if (countAtMax > 1) { + // Tie: log once per session + if (!warnedTie) { + LOGGER.warn("Multiple first-person providers tied at priority {}; refusing to select. " + + "Assign distinct priorities to resolve.", maxPriority); + warnedTie = true; + } + return null; + } + + return new SelectedProvider(maxId, maxEntry.provider, maxEntry.safety); + } + + /** + * Marks a provider as circuit-broken for the remainder of this session. + * + * @param id provider identifier + * @param cause the exception that triggered the circuit break + */ + public void circuitBreak(String id, Throwable cause) { + if (circuitBroken.add(id)) { + LOGGER.warn("First-person provider '{}' circuit-broken due to exception; " + + "will not be selected for remainder of session", id, cause); + } + } + + /** + * Reports a state whose vanilla entity id does not belong to the camera entity. Warned at most once + * per provider per session; the provider stays selectable because a mismatch is a per-frame condition + * rather than a structural failure. + */ + public void warnOwnershipMismatch(String id, int expectedEntityId, int actualEntityId) { + if (warnedOwnershipProviders.add(id)) { + LOGGER.warn("First-person provider '{}' returned a state owned by entity {} but the camera " + + "entity is {}; discarding the first-person instance", id, actualEntityId, expectedEntityId); + } + } + + public static final class SelectedProvider { + public final String id; + public final FirstPersonStateProvider provider; + public final CameraSafetyDeclaration safety; + + SelectedProvider(String id, FirstPersonStateProvider provider, CameraSafetyDeclaration safety) { + this.id = id; + this.provider = provider; + this.safety = safety; + } + } + + private static final class ProviderEntry { + final int priority; + final FirstPersonStateProvider provider; + final CameraSafetyDeclaration safety; + + ProviderEntry(int priority, FirstPersonStateProvider provider, CameraSafetyDeclaration safety) { + this.priority = priority; + this.provider = provider; + this.safety = safety; + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java index 42e0c6bd..f06fe16b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -2,6 +2,7 @@ import com.mojang.blaze3d.vertex.PoseStack; import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.CausticaMod; import dev.comfyfluffy.caustica.mixin.ParticleEngineAccessor; import dev.comfyfluffy.caustica.mixin.ParticleGroupAccessor; import net.minecraft.client.Camera; @@ -15,6 +16,7 @@ import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.client.renderer.culling.Frustum; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; import net.minecraft.client.renderer.entity.state.EntityRenderState; import net.minecraft.client.renderer.state.level.CameraRenderState; import net.minecraft.client.renderer.state.level.QuadParticleRenderState; @@ -84,10 +86,15 @@ public static boolean enabled() { public static final int ENTITY_BIT = 0x800000; /** Custom-index flag (bit 22) marking a particle billboard instance (shares the entity geom table). */ public static final int PARTICLE_BIT = 0x400000; + /** {@code EntityGeom.reserved} low-word flags; must stay in lock-step with {@code world_common.slang}. */ + private static final int ENTITY_GEOM_LOCAL_VIEW = 1; + private static final int ENTITY_GEOM_WORLD_STAND_IN = 1 << 1; // TLAS visibility-mask bits, ANDed against the per-ray cull mask in world.rgen. Bit 0 = secondary rays - // (shadows / GI / reflections, CULL_SECONDARY); bit 1 = the primary camera ray (CULL_PRIMARY). + // leaving a world surface (shadows / GI / reflections, CULL_SECONDARY); bit 1 = the primary camera ray + // (CULL_PRIMARY); bit 2 = secondary rays leaving a local-view surface (CULL_LOCAL_VIEW_SECONDARY). private static final int MASK_SECONDARY = 0x01; private static final int MASK_PRIMARY = 0x02; + private static final int MASK_LOCAL_VIEW_SECONDARY = 0x04; /** Default mask: visible to every ray (terrain and ordinary entities use this). */ private static final int MASK_ALL = 0xFF; /** Particles are primary-ray-only: visible/lit by the camera path, invisible to shadows/GI/reflections. */ @@ -182,6 +189,9 @@ private static int beBuildsPerFrame() { // Reusable capture pipeline (single-threaded on the render thread). private final RtEntityCollector collector = new RtEntityCollector(); private final RtEntityCapture capture = new RtEntityCapture(); + // The first-person body is meshed before the ordinary capture and only replaces it once its geometry + // is known to be non-empty, so a failed attempt must leave the ordinary buffer untouched. + private final RtEntityCapture fpCapture = new RtEntityCapture(); private final PoseStack entityPoseStack = new PoseStack(); private final PoseStack blockEntityPoseStack = new PoseStack(); private CameraRenderState cameraState; @@ -223,6 +233,10 @@ void set(float cx, float cy, float cz, int rbx, int rby, int rbz) { private Int2ObjectOpenHashMap prevVerts = new Int2ObjectOpenHashMap<>(entityMapCapacity()); private Int2ObjectOpenHashMap curVerts = new Int2ObjectOpenHashMap<>(entityMapCapacity()); + private String lastFirstPersonProviderId = null; + /** Session-scoped so the per-frame budget warning is logged once rather than every frame. */ + private boolean warnedLocalViewBudget = false; + // This frame's glowing entities (see GlowEntity) + the camera-relative offset (camera pos - rebase // origin) their positions are captured against, for RtGlowOutlineFeature's raster pass. Rebuilt every frame. private final List glowBatches = new ArrayList<>(); @@ -350,9 +364,14 @@ private static final class EntityAccel { long retryYawFitAfter; } - /** This frame's terrain and dynamic instance segments, entity BLAS builds, and geometry-table address. */ + /** + * This frame's terrain and dynamic instance segments, entity BLAS builds, geometry-table address, + * and whether the camera entity's local-view representation was successfully published into the + * geometry table this frame. + */ public record FrameEntities(List baseInstances, List dynamicInstances, - List blas, long geomTableAddr, FrameUse use) { + List blas, long geomTableAddr, + boolean localViewPublished, FrameUse use) { } private record FrameUse(FrameLists lists, TableSlot table) { @@ -382,6 +401,10 @@ public record NameTagEntity(Component text, float x, float y, float z) { private record Motion(long dispAddr, float rigidX, float rigidY, float rigidZ) { } + /** A first-person body already meshed into {@link #fpCapture}, awaiting publication. */ + private record FirstPersonCapture(String providerId, int motionId, float x, float y, float z) { + } + private static final class MotionSlice { long mapped; long deviceAddress; @@ -585,6 +608,7 @@ private final class FrameBuild { TableSlot table; int count; // geometry-table entries / TLAS instances int logicalCount; // ordinary entities + block entities + individual particles + boolean localViewPublished; final GraphicsUseWaiter graphicsUseWaiter; @@ -608,12 +632,12 @@ boolean full() { public FrameEntities beginFrame(RtContext ctx, List base, int rbx, int rby, int rbz, double camX, double camY, double camZ, Matrix4f projection, Matrix4f viewRotation) { if (!enabled()) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return new FrameEntities(base, List.of(), List.of(), 0L, false, null); } Minecraft mc = Minecraft.getInstance(); ClientLevel level = mc.level; if (level == null) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return new FrameEntities(base, List.of(), List.of(), 0L, false, null); } float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false); setCamera(camX, camY, camZ, projection, viewRotation); @@ -641,7 +665,7 @@ public FrameEntities beginFrame(RtContext ctx, List base, int RtFrameStats.FRAME.count("entityRetainedGeometryBytes", retainedGeometryBytes); if (build.instances == null) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return new FrameEntities(base, List.of(), List.of(), 0L, false, null); } try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("entity.uploadFlush")) { build.motion.flushWrites(); @@ -651,7 +675,7 @@ public FrameEntities beginFrame(RtContext ctx, List base, int } } return new FrameEntities(base, build.instances, build.blas, build.geomTableAddr, - new FrameUse(build.lists, build.table)); + build.localViewPublished, new FrameUse(build.lists, build.table)); } /** Associate every resource returned for a successfully enqueued frame with its graphics completion. */ @@ -697,11 +721,38 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie } boolean firstPersonSelf = entity == cameraEntity && firstPerson; int mask = firstPersonSelf ? MASK_SECONDARY : MASK_ALL; + // The stand-in flag rides the geometry record and forces the TLAS instance non-opaque, so + // any_hit can apply the exactly-once shadow semi-transmittance to the first-person body. + int entityGeomFlags = firstPersonSelf ? ENTITY_GEOM_WORLD_STAND_IN : 0; float ix; float iy; float iz; int id = entity.getId(); EntityPrev prev = prevVerts.get(id); + + // First-person compatibility: a provider-supplied camera-safe body is the camera entity's + // local-view representation, and the ordinary capture below stays its world-space stand-in for + // shadows, GI and reflections. The two occupy disjoint secondary domains, so the stand-in's head + // can no longer seal off the visible first-person surfaces the way a single fully-visible + // instance did. + // + // The precheck runs BEFORE the provider is queried, because one iteration now emits two table + // entries. The table is sized exactly maxEntities() and writeTableEntry indexes it by + // build.count, so entering here with only one slot left would write one entry past the end. + // Short budget therefore degrades to the stand-in alone rather than publishing half a player. + boolean localViewEligible = firstPersonSelf + && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value(); + boolean localViewAdmitted = localViewEligible + && admitsLocalView(maxEntities(), build.logicalCount); + if (localViewAdmitted) { + FirstPersonCapture fpReady = captureFirstPerson(build, dispatcher, entity, partial, id); + if (fpReady != null) { + build.localViewPublished = localViewPresence(localViewEligible, localViewAdmitted, + true, publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz)); + } + } else if (localViewEligible) { + warnLocalViewBudgetExhausted(); + } capture.reset(prev != null ? prev.size / 3 : 0); try { EntityRenderState state; @@ -775,16 +826,23 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie boolean reused; long reuseStart = RtFrameStats.FRAME.startStage(); try { - reused = appendRigidReuse(ctx, build, motion, id, mask, ix - rbx, iy - rby, iz - rbz); + reused = appendRigidReuse(ctx, build, motion, id, mask, entityGeomFlags, + ix - rbx, iy - rby, iz - rbz); } finally { RtFrameStats.FRAME.endStage("entity.capture.rigidReuse", reuseStart); } if (!reused) { appendCapture(ctx, build, motion, id, ENTITY_BIT, mask, - translationTransform(ix - rbx, iy - rby, iz - rbz)); + translationTransform(ix - rbx, iy - rby, iz - rbz), entityGeomFlags); } build.logicalCount++; RtFrameStats.FRAME.count("entitiesCaptured", 1); + if (localViewEligible) { + // Counted where the instance actually lands, so this stays 0 on any path that captures + // nothing for the camera entity. Gated on eligibility too: with the toggle off there is no + // local view to stand in for, and the frame stats must match the baseline exactly. + RtFrameStats.FRAME.count("worldStandInInstances", 1); + } capturedThisFrame++; } Int2ObjectOpenHashMap oldPrev = prevVerts; @@ -810,6 +868,140 @@ private static float[] copyTranslatedVertices(FloatArrayList local, float tx, fl return placed; } + /** + * Mesh the camera entity's first-person body, sourced from the selected provider's state rather than + * Caustica's own extraction, into {@link #fpCapture}. Returns {@code null} when no instance can be + * produced this frame, in which case the caller falls back to the ordinary capture; any provider throw + * trips the session-scoped circuit breaker and falls back to baseline. + * + *

Capture and publication are split so that a failure at any step leaves no persistent trace: the + * ordinary capture that then runs must be byte-for-byte what it would have been. + */ + private FirstPersonCapture captureFirstPerson(FrameBuild build, EntityRenderDispatcher dispatcher, + Entity entity, float partial, int entityId) { + if (entityId < 0) { + return null; + } + int fpMotionId = -(entityId + 1); + + FirstPersonStateRegistry registry = FirstPersonStateRegistry.instance(); + FirstPersonStateRegistry.SelectedProvider selected = registry.selectProvider(); + if (selected == null) { + return null; + } + + EntityRenderState fpState; + boolean cameraSafe; + try { + fpState = selected.provider.provideState(entity, partial); + if (fpState == null) { + return null; + } + cameraSafe = selected.safety.isCameraSafe(entity, fpState, partial); + } catch (Throwable t) { + registry.circuitBreak(selected.id, t); + return null; + } + if (!cameraSafe) { + return null; + } + // Only the vanilla identity field is read; no mod-specific state is interpreted here. + if (fpState instanceof AvatarRenderState avatar && avatar.id != entityId) { + registry.warnOwnershipMismatch(selected.id, entityId, avatar.id); + return null; + } + + EntityPrev fpHistory = prevVerts.get(fpMotionId); + fpCapture.reset(fpHistory != null ? fpHistory.size / 3 : 0); + try { + collector.begin(fpCapture, true); + resetPoseStack(entityPoseStack); + dispatcher.submit(fpState, cameraState, 0.0, 0.0, 0.0, entityPoseStack, collector); + } catch (Throwable t) { + registry.circuitBreak(selected.id, t); + return null; + } finally { + collector.begin(null, false); + resetPoseStack(entityPoseStack); + } + if (fpCapture.isEmpty()) { + return null; + } + // The provider's state carries the mod's own positional offset, so this anchor is the mod's, not + // the player's real world position — Caustica reuses it without interpreting it. + return new FirstPersonCapture(selected.id, fpMotionId, + (float) fpState.x, (float) fpState.y, (float) fpState.z); + } + + /** + * Whether the camera entity may still publish BOTH representations. One iteration emits two geometry-table + * entries, and the table is sized exactly {@code capacity}, so admitting the pair with a single free slot + * would write one entry past the end. Package-private so the bounds test exercises this exact predicate + * instead of a copy of it. + */ + static boolean admitsLocalView(int capacity, int logicalCount) { + return capacity - logicalCount >= 2; + } + + /** + * Report the entity budget denying the local-view representation. Warned at most once per session, like + * the provider circuit-breaker: the condition recurs every frame, so an unsuppressed warning would flood + * the log. Without it the player simply sees their hands vanish with nothing explaining why. + */ + private void warnLocalViewBudgetExhausted() { + if (warnedLocalViewBudget) { + return; + } + warnedLocalViewBudget = true; + CausticaMod.LOGGER.warn("Entity budget left fewer than 2 free geometry-table slots; the camera " + + "entity falls back to its world stand-in alone and the first-person body is not drawn. " + + "Raise the RT entity limit to restore it."); + } + + /** + * The publication verdict behind the frame's localViewPresent signal: true only when the + * compatibility gate, the two-slot budget admission, the provider capture (which folds provider + * absence, missing state, camera-unsafe state, ownership mismatch and the circuit breaker into one + * readiness fact) and the geometry-table write ALL held this frame. Kept pure so the definition is + * unit-testable; captureEntities feeds it the real per-frame facts, and every degraded path leaves + * the signal false. + */ + static boolean localViewPresence(boolean compatEligible, boolean budgetAdmitted, + boolean captureReady, boolean instanceWritten) { + return compatEligible && budgetAdmitted && captureReady && instanceWritten; + } + + /** + * Publish the mesh {@link #captureFirstPerson} left in {@link #fpCapture} as the camera entity's + * local-view representation: visible to the primary camera ray and to secondary rays leaving a + * local-view surface, invisible to world secondary rays. Motion history lives in a disjoint negative key space + * ({@code -(entityId + 1)}); entity ids are assigned positive by vanilla, so a frame that falls back to + * the ordinary capture cannot diff against first-person history, or the other way round. + * + *

Returns whether the local-view instance landed in the geometry table — the publication fact the + * frame's presence signal is sourced from. + */ + private boolean publishFirstPerson(RtContext ctx, FrameBuild build, FirstPersonCapture ready, + int rbx, int rby, int rbz) { + EntityPrev fpHistory = prevVerts.get(ready.motionId()); + // A provider swap must not diff this frame's mesh against the previous provider's history, but the + // float[] backing is still worth reusing — drop the baseline, keep the buffer. + EntityPrev fpBaseline = ready.providerId().equals(lastFirstPersonProviderId) ? fpHistory : null; + Motion motion = uploadVertexMotion(ctx, build, fpCapture.verts, fpBaseline, + ready.x(), ready.y(), ready.z()); + curVerts.put(ready.motionId(), + storeEntityPrev(fpHistory, fpCapture.verts, ready.x(), ready.y(), ready.z())); + appendTransientCapture(ctx, build, fpCapture, motion, ENTITY_BIT, + MASK_PRIMARY | MASK_LOCAL_VIEW_SECONDARY, + translationTransform(ready.x() - rbx, ready.y() - rby, ready.z() - rbz), + ENTITY_GEOM_LOCAL_VIEW); + lastFirstPersonProviderId = ready.providerId(); + build.logicalCount++; + RtFrameStats.FRAME.count("firstPersonInstances", 1); + RtFrameStats.FRAME.count("localViewInstances", 1); + return true; + } + /** * Gather one entity's name tag (world position + text) into {@link #nameTagBatches}, unless a block is * in the way. {@code state.nameTagAttachment} is only non-null when {@code state.nameTag} is (both set @@ -1025,7 +1217,7 @@ private void captureParticles(RtContext ctx, FrameBuild build, Minecraft mc, flo } long dispAddr = uploadDisp(ctx, build, particleDisp); appendCapture(ctx, build, new Motion(dispAddr, 0f, 0f, 0f), - -1, PARTICLE_BIT, PARTICLE_MASK, IDENTITY); // one combined mesh, per-particle MV + -1, PARTICLE_BIT, PARTICLE_MASK, IDENTITY, 0); // one combined mesh, per-particle MV } /** Average (rebase-space) position of a captured particle's verts — approximates the particle center. */ @@ -1265,7 +1457,7 @@ private void emitBe(RtContext ctx, FrameBuild build, BeEntry e, float[] disp, in // passes null ⇒ dispAddr 0 ⇒ no MV. The disp buffer is a per-frame transient, so a BE that stops // animating reverts to MV 0 next frame. long dispAddr = uploadDisp(ctx, build, disp); - writeTableEntry(build, e.primAddr, e.indexAddr, e.uvAddr, dispAddr, 0f, 0f, 0f, e.bucketTris); + writeTableEntry(build, e.primAddr, e.indexAddr, e.uvAddr, dispAddr, 0f, 0f, 0f, e.bucketTris, 0); // Block-local mesh placed by a translate-only instance transform (blockPos − rebase), like terrain. float[] xform = {1, 0, 0, e.bx - rbx, 0, 1, 0, e.by - rby, 0, 0, 1, e.bz - rbz}; build.instances.add(new RtAccel.Instance(xform, e.accel.deviceAddress, @@ -1367,7 +1559,7 @@ private static void awaitGraphicsUse(FrameBuild build, TrackedGraphicsUse graphi * pose is non-rigid (animation), or the shading data changed under identical topology. */ private boolean appendRigidReuse(RtContext ctx, FrameBuild build, Motion motion, int entityId, int mask, - float placeX, float placeY, float placeZ) { + int entityGeomFlags, float placeX, float placeY, float placeZ) { EntityAccel ea = entityAccels.get(entityId); if (ea == null || ea.refAccel == null || ea.refVertCount != capture.verts.size() / 3 || ea.refIdxCount != capture.idx.size()) { @@ -1417,10 +1609,12 @@ private boolean appendRigidReuse(RtContext ctx, FrameBuild build, Motion motion, } build.lists.usedEntitySlots.add(ea.refSlot); writeTableEntry(build, ea.refPrimAddr, ea.refIndexAddr, ea.refUvAddr, - motion.dispAddr, motion.rigidX, motion.rigidY, motion.rigidZ, ea.refBucketTris); + motion.dispAddr, motion.rigidX, motion.rigidY, motion.rigidZ, ea.refBucketTris, + entityGeomFlags); build.instances.add(new RtAccel.Instance(placeTransform(localTransform, placeX, placeY, placeZ), ea.refAccel.deviceAddress, - ENTITY_BIT | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET)); + ENTITY_BIT | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET, + instanceGeometryFlags(entityGeomFlags))); build.count++; RtFrameStats.FRAME.count("entityReuse", 1); return true; @@ -1527,35 +1721,48 @@ private long shadeHash() { * {@code entityId} ≥ 0 → refit path (persistent updatable AS keyed by id); {@code < 0} (refit disabled) * → transient one-shot full BUILD. Used by the animated-entity pass; block entities use {@link #buildBe}. */ - private void appendCapture(RtContext ctx, FrameBuild build, float[] disp, int entityId, int instanceBit, int mask) { + private void appendCapture(RtContext ctx, FrameBuild build, float[] disp, int entityId, int instanceBit, int mask, + int entityGeomFlags) { beginBuildIfNeeded(ctx, build); appendCapture(ctx, build, new Motion(uploadDisp(ctx, build, disp), 0f, 0f, 0f), - entityId, instanceBit, mask, IDENTITY); + entityId, instanceBit, mask, IDENTITY, entityGeomFlags); } private void appendCapture(RtContext ctx, FrameBuild build, Motion motion, int entityId, int instanceBit, int mask, - float[] instanceTransform) { + float[] instanceTransform, int entityGeomFlags) { beginBuildIfNeeded(ctx, build); if (entityId >= 0) { - appendPackedEntity(ctx, build, motion, entityId, instanceBit, mask, instanceTransform); + appendPackedEntity(ctx, build, motion, entityId, instanceBit, mask, instanceTransform, entityGeomFlags); return; } + appendTransientCapture(ctx, build, capture, motion, instanceBit, mask, instanceTransform, entityGeomFlags); + } + + /** + * Transient one-shot path: upload {@code source} as a per-frame mesh + freshly built BLAS. Unlike + * {@link #appendPackedEntity} it owns no persistent slot, so the geometry it reads is an explicit + * parameter — the first-person instance submits into its own capture buffer (see {@link #fpCapture}). + */ + private void appendTransientCapture(RtContext ctx, FrameBuild build, RtEntityCapture source, Motion motion, + int instanceBit, int mask, float[] instanceTransform, + int entityGeomFlags) { + beginBuildIfNeeded(ctx, build); int asInput = org.lwjgl.vulkan.KHRAccelerationStructure.VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR; int storage = org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; - int vertCount = capture.verts.size() / 3; - RtEntityCapture.PackedGeometry packed = capture.packGeometry(); + int vertCount = source.verts.size() / 3; + RtEntityCapture.PackedGeometry packed = source.packGeometry(); int idxCount = packed.indices().size(); - EntityGeometryLayout layout = EntityGeometryLayout.create(capture.verts.size(), idxCount, - capture.uvList.size(), packed.primitives().size()); + EntityGeometryLayout layout = EntityGeometryLayout.create(source.verts.size(), idxCount, + source.uvList.size(), packed.primitives().size()); long required = Math.addExact(layout.totalBytes, EntityGeometryLayout.REGION_ALIGNMENT - 1L); RtBuffer geometry = allocBuffer(ctx, required, asInput | storage, true, "particle geometry"); layout = layout.shifted((-geometry.deviceAddress) & (EntityGeometryLayout.REGION_ALIGNMENT - 1L)); - MemoryUtil.memFloatBuffer(geometry.mapped + layout.positionOffset, capture.verts.size()) - .put(capture.verts.elements(), 0, capture.verts.size()); + MemoryUtil.memFloatBuffer(geometry.mapped + layout.positionOffset, source.verts.size()) + .put(source.verts.elements(), 0, source.verts.size()); MemoryUtil.memIntBuffer(geometry.mapped + layout.indexOffset, idxCount) .put(packed.indices().elements(), 0, idxCount); - MemoryUtil.memFloatBuffer(geometry.mapped + layout.uvOffset, capture.uvList.size()) - .put(capture.uvList.elements(), 0, capture.uvList.size()); + MemoryUtil.memFloatBuffer(geometry.mapped + layout.uvOffset, source.uvList.size()) + .put(source.uvList.elements(), 0, source.uvList.size()); MemoryUtil.memFloatBuffer(geometry.mapped + layout.primOffset, packed.primitives().size()) .put(packed.primitives().elements(), 0, packed.primitives().size()); geometry.flush(layout.positionOffset, layout.totalBytes - layout.positionOffset); @@ -1571,17 +1778,19 @@ private void appendCapture(RtContext ctx, FrameBuild build, Motion motion, int e build.pooledBlas.add(blas); writeTableEntry(build, primAddr, indexAddr, uvAddr, motion.dispAddr, - motion.rigidX, motion.rigidY, motion.rigidZ, packed.bucketTris()); + motion.rigidX, motion.rigidY, motion.rigidZ, packed.bucketTris(), entityGeomFlags); build.instances.add(new RtAccel.Instance(instanceTransform, blas.accel.deviceAddress, - instanceBit | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET)); + instanceBit | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET, + instanceGeometryFlags(entityGeomFlags))); build.buffers.add(geometry); build.count++; } /** Pack one changed entity's four logical geometry regions into its retired ring slot's backing. */ private void appendPackedEntity(RtContext ctx, FrameBuild build, Motion motion, int entityId, - int instanceBit, int mask, float[] instanceTransform) { + int instanceBit, int mask, float[] instanceTransform, + int entityGeomFlags) { int asInput = org.lwjgl.vulkan.KHRAccelerationStructure.VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR; int storage = org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; int vertCount = capture.verts.size() / 3; @@ -1649,9 +1858,10 @@ private void appendPackedEntity(RtContext ctx, FrameBuild build, Motion motion, } writeTableEntry(build, primAddr, indexAddr, uvAddr, motion.dispAddr, - motion.rigidX, motion.rigidY, motion.rigidZ, packed.bucketTris()); + motion.rigidX, motion.rigidY, motion.rigidZ, packed.bucketTris(), entityGeomFlags); build.instances.add(new RtAccel.Instance(instanceTransform, accel.deviceAddress, - instanceBit | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET)); + instanceBit | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET, + instanceGeometryFlags(entityGeomFlags))); EntityAccel ea = slot.owner; clearRefGeometry(ea); @@ -1710,9 +1920,15 @@ private long uploadDisp(RtContext ctx, FrameBuild build, FloatArrayList disp) { return slice.deviceAddress; } - /** Write one std430 EntityGeom entry, including bases for the two packed BLAS geometries. */ + /** + * Write one std430 EntityGeom entry, including bases for the two packed BLAS geometries. The + * {@code reserved} low word carries per-instance semantic flags read by world.rchit; its high word stays + * zero. {@code entityGeomFlags} is mandatory rather than defaulted so a new instance path cannot + * silently inherit 0 — a missed call site is a compile error. + */ private void writeTableEntry(FrameBuild build, long primAddr, long idxAddr, long uvAddr, long dispAddr, - float rigidX, float rigidY, float rigidZ, int[] bucketTris) { + float rigidX, float rigidY, float rigidZ, int[] bucketTris, + int entityGeomFlags) { if (bucketTris == null || bucketTris.length != RtAccel.ENTITY_BUCKETS) { throw new IllegalArgumentException("Missing entity BLAS bucket counts"); } @@ -1727,10 +1943,17 @@ private void writeTableEntry(FrameBuild build, long primAddr, long idxAddr, long MemoryUtil.memPutFloat(entry + 44, 0f); MemoryUtil.memPutInt(entry + 48, 0); MemoryUtil.memPutInt(entry + 52, bucketTris[RtAccel.ENTITY_BUCKET_OPAQUE]); - MemoryUtil.memPutInt(entry + 56, 0); + MemoryUtil.memPutInt(entry + 56, entityGeomFlags); MemoryUtil.memPutInt(entry + 60, 0); } + /** The world stand-in's TLAS instance runs any-hit for every geometry so its shadow policy applies. */ + private static int instanceGeometryFlags(int entityGeomFlags) { + return (entityGeomFlags & ENTITY_GEOM_WORLD_STAND_IN) != 0 + ? org.lwjgl.vulkan.KHRAccelerationStructure.VK_GEOMETRY_INSTANCE_FORCE_NO_OPAQUE_BIT_KHR + : 0; + } + /** Select the next per-entity slot, waiting on its exact last graphics use before mutable reuse. */ private EntitySlot selectEntityBuildSlot(RtContext ctx, FrameBuild build, int entityId) { EntityAccel ea = entityAccels.get(entityId); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 0b027736..7e733c56 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -45,7 +45,22 @@ public final class RtMaterialRegistry { public static final int FEATURE_SPEC = 1; public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; + public static final int FEATURE_ICE = 8; public static final int FEATURE_STOCHASTIC_ALPHA = 16; + // The ice family gets its dedicated shadow policy and guide cutoff from this sprite list — never + // from optical parameters, so a resource pack changing IORs cannot reclassify materials. + private static final Set ICE_SPRITES = Set.of( + Identifier.withDefaultNamespace("block/ice"), + Identifier.withDefaultNamespace("block/frosted_ice_0"), + Identifier.withDefaultNamespace("block/frosted_ice_1"), + Identifier.withDefaultNamespace("block/frosted_ice_2"), + Identifier.withDefaultNamespace("block/frosted_ice_3"), + Identifier.withDefaultNamespace("block/packed_ice"), + Identifier.withDefaultNamespace("block/blue_ice")); + // Largest header count whose every slot still packs as a 20-bit GPU medium identity: a dielectric's + // identity is materialId + 2 (closest_hit.rchit.slang), with 0 and 1 reserved for air and water. + // 2^20 - 3 keeps the derived identity at or below 0xFFFFE, so the 0xFFFFF sentinel is never allocated. + private static final int MAX_MEDIUM_IDENTITY_RECORDS = 1048573; // HDR radiance of a full (level-15-equivalent) emitter, modulated by albedo. Baked into every // emissive RtMaterialDesc.emissionStrength at compile time (compileDesc/compileEntityDesc), times // any resource-pack absolute emission.strength_cd_m2 override; see header() and RtMaterialOverrides. @@ -177,6 +192,9 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, RtBlockMaterials.Entry entry = entriesBySprite.get(sprite); int baseFeatures = entry.features() & (FEATURE_SPEC | FEATURE_NORMAL | FEATURE_HEURISTIC_EMISSION); + if (ICE_SPRITES.contains(sprite.contents().name())) { + baseFeatures |= FEATURE_ICE; + } SpriteStats stats = spriteStats.getOrDefault(sprite, SpriteStats.NEUTRAL); // The first sprite-wide (block == null) rule owns this sprite for every state, so its variants @@ -258,6 +276,10 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, int dynamicReserve = Math.max(64, Math.addExact(sprites.size(), Math.multiplyExact(entityResources.size(), 3))); int recordCapacity = Math.addExact(headers.size(), dynamicReserve); + if (recordCapacity > MAX_MEDIUM_IDENTITY_RECORDS) { + throw new IllegalStateException("RT material table exceeds the medium identity space: " + + recordCapacity + " records > " + MAX_MEDIUM_IDENTITY_RECORDS); + } long byteSize = Math.multiplyExact((long) recordCapacity, MaterialHeaderData.BYTE_SIZE); if (byteSize > Integer.MAX_VALUE) { throw new IllegalStateException("RT material table exceeds mapped-buffer limit: " + byteSize); diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 4533f086..3fcc8678 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -27,6 +27,12 @@ "caustica.options.rt.glow": "Entity Glow Outline", "caustica.options.rt.glow.tooltip": "Draw the vanilla Glowing-effect outline (through walls) around glowing entities.", + "caustica.options.rt.firstPersonCompat": "First-Person Body Compatibility", + "caustica.options.rt.firstPersonCompat.tooltip": "Enable compatibility with first-person body mods. Renders the first-person body separately from the player entity for correct visibility.", + + "caustica.options.rt.firstPersonShadowTransmittance": "First-Person Shadow Transmittance", + "caustica.options.rt.firstPersonShadowTransmittance.tooltip": "How much direct light passes through your own body's shadow in first person. 0% keeps the shadow fully opaque; 100% removes your body from shadows entirely.", + "caustica.options.rt.waterWaves": "Animated Water", "caustica.options.rt.waterWaves.tooltip": "Animate water-surface normals for moving wave highlights.", diff --git a/src/main/resources/caustica.mixins.json b/src/main/resources/caustica.mixins.json index f7c6e54e..67ece6e6 100644 --- a/src/main/resources/caustica.mixins.json +++ b/src/main/resources/caustica.mixins.json @@ -11,6 +11,7 @@ "GpuDeviceAccessor", "GlxMixin", "GuiRendererMixin", + "LevelRendererAccessor", "LevelRendererMixin", "LevelExtractorMixin", "MinecraftMixin", diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java index c2a664d6..aabdecc6 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -19,4 +19,29 @@ void invalidPeakNitsFallsBackToDefault() { setting.set(previous); } } + + @Test + void firstPersonShadowTransmittanceClampsAndRejectsNonFinites() { + CausticaConfig.FloatSetting setting = CausticaConfig.Rt.Entities.FIRST_PERSON_SHADOW_TRANSMITTANCE; + float previous = setting.value(); + try { + assertEquals(0.35f, setting.defaultValue().floatValue()); + + setting.set(1.5f); + assertEquals(1.0f, setting.value()); + setting.set(-2.0f); + assertEquals(0.0f, setting.value()); + + setting.set(Float.POSITIVE_INFINITY); + assertEquals(1.0f, setting.value()); + setting.set(Float.NEGATIVE_INFINITY); + assertEquals(0.0f, setting.value()); + + setting.set(Float.NaN); + assertEquals(0.35f, setting.value(), + "NaN must fall back to the default, never reach the GPU shadow policy"); + } finally { + setting.set(previous); + } + } } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/MediumStackReferenceModelExhaustiveTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/MediumStackReferenceModelExhaustiveTest.java new file mode 100644 index 00000000..f07dce03 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/MediumStackReferenceModelExhaustiveTest.java @@ -0,0 +1,185 @@ +package dev.comfyfluffy.caustica.rt; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Executable reference model for medium.slang's identity stack, exercised exhaustively. The model and + * the shader follow one semantic definition — enter pushes onto a depth-3 stack and fails closed when + * full, exit removes the nearest identity match from the top down (a deep match removes only that + * layer), an unmatched exit is a no-op, and air is a bottom sentinel that is never pushed. A change to + * either side must land in both. + */ +final class MediumStackReferenceModelExhaustiveTest { + + private static final int AIR = 0; + private static final int WATER = 1; + private static final int GLASS = 2; + private static final int ICE = 3; + private static final int[] IDENTITIES = {WATER, GLASS, ICE}; + + private record Snapshot(int current, int parent1, int parent2) {} + + private record Operation(boolean enter, int identity) {} + + /** The reference model: medium.slang's MediumStack over bare identities. */ + private static final class Stack { + private int current = AIR; + private int parent1 = AIR; + private int parent2 = AIR; + + boolean push(int entered) { + if (entered == AIR || parent2 != AIR) { + return false; + } + parent2 = parent1; + parent1 = current; + current = entered; + return true; + } + + boolean exitMatched(int exiting) { + if (exiting != AIR && current == exiting) { + current = parent1; + parent1 = parent2; + parent2 = AIR; + return true; + } + if (exiting != AIR && parent1 == exiting) { + parent1 = parent2; + parent2 = AIR; + return true; + } + if (exiting != AIR && parent2 == exiting) { + parent2 = AIR; + return true; + } + return false; + } + + int depth() { + return (current != AIR ? 1 : 0) + (parent1 != AIR ? 1 : 0) + (parent2 != AIR ? 1 : 0); + } + + Snapshot snapshot() { + return new Snapshot(current, parent1, parent2); + } + } + + @Test + void exhaustiveSequencesPreserveEveryInvariant() { + enumerate(new ArrayList<>(), 6); + } + + private static void enumerate(List prefix, int remaining) { + verify(prefix); + if (remaining == 0) { + return; + } + for (int identity : IDENTITIES) { + prefix.add(new Operation(true, identity)); + enumerate(prefix, remaining - 1); + prefix.set(prefix.size() - 1, new Operation(false, identity)); + enumerate(prefix, remaining - 1); + prefix.removeLast(); + } + } + + private static void verify(List sequence) { + Stack stack = new Stack(); + List expected = new ArrayList<>(); + for (Operation op : sequence) { + Snapshot before = stack.snapshot(); + if (op.enter()) { + boolean pushed = stack.push(op.identity()); + if (expected.size() == 3) { + assertFalse(pushed, "a full stack must fail the push closed"); + assertEquals(before, stack.snapshot(), "a failed push must not disturb any layer"); + } else { + assertTrue(pushed); + expected.add(op.identity()); + } + } else { + boolean matched = stack.exitMatched(op.identity()); + int nearest = expected.lastIndexOf(op.identity()); + if (nearest < 0) { + assertFalse(matched, "an unmatched exit must report no match"); + assertEquals(before, stack.snapshot(), "an unmatched exit must be a no-op"); + } else { + assertTrue(matched); + expected.remove(nearest); + } + } + assertMirrors(expected, stack); + } + } + + private static void assertMirrors(List expected, Stack stack) { + assertTrue(stack.depth() >= 0 && stack.depth() <= 3, "depth stays within 0..3"); + assertEquals(expected.size(), stack.depth()); + assertEquals(expected.isEmpty() ? AIR : expected.getLast(), stack.current, + "current is always the most recent unexited medium"); + assertEquals(expected.size() > 1 ? expected.get(expected.size() - 2) : AIR, stack.parent1); + assertEquals(expected.size() > 2 ? expected.get(expected.size() - 3) : AIR, stack.parent2); + assertFalse(stack.current == AIR && stack.parent1 != AIR, "air never sits above a real layer"); + assertFalse(stack.parent1 == AIR && stack.parent2 != AIR, "air never sits above a real layer"); + } + + @Test + void strictlyNestedSequencesEqualPlainLifo() { + for (int first : IDENTITIES) { + for (int second : IDENTITIES) { + for (int third : IDENTITIES) { + Stack stack = new Stack(); + assertTrue(stack.push(first)); + assertTrue(stack.push(second)); + assertTrue(stack.push(third)); + assertEquals(new Snapshot(third, second, first), stack.snapshot()); + assertTrue(stack.exitMatched(third)); + assertEquals(new Snapshot(second, first, AIR), stack.snapshot()); + assertTrue(stack.exitMatched(second)); + assertEquals(new Snapshot(first, AIR, AIR), stack.snapshot()); + assertTrue(stack.exitMatched(first)); + assertEquals(new Snapshot(AIR, AIR, AIR), stack.snapshot()); + } + } + } + } + + @Test + void nonNestedOverlapRecoversTheTrueSurroundingMedium() { + Stack stack = new Stack(); + assertTrue(stack.push(ICE)); + assertTrue(stack.push(GLASS)); + assertTrue(stack.exitMatched(ICE)); + assertEquals(new Snapshot(GLASS, AIR, AIR), stack.snapshot(), + "exiting the deeper ice keeps glass current with direction untouched"); + assertTrue(stack.exitMatched(GLASS)); + assertEquals(new Snapshot(AIR, AIR, AIR), stack.snapshot()); + } + + @Test + void noMatchExitIsIdentityAndOverflowFailsClosed() { + Stack stack = new Stack(); + assertTrue(stack.push(WATER)); + assertTrue(stack.push(GLASS)); + assertTrue(stack.push(GLASS)); + Snapshot full = stack.snapshot(); + assertFalse(stack.exitMatched(ICE)); + assertEquals(full, stack.snapshot()); + assertFalse(stack.push(ICE)); + assertEquals(full, stack.snapshot()); + assertFalse(stack.push(AIR)); + assertEquals(full, stack.snapshot()); + + assertTrue(stack.exitMatched(GLASS)); + assertEquals(new Snapshot(GLASS, WATER, AIR), stack.snapshot(), + "one exit removes one nesting level of a repeated identity"); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java new file mode 100644 index 00000000..91ef6247 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java @@ -0,0 +1,213 @@ +package dev.comfyfluffy.caustica.rt; + +import java.util.Random; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Java replica of segment.slang's PackedPathSegment layout for everything the 64-byte record carries + * beyond raw geometry: the three-layer medium stack (RGB9E5 extinction, fp16 IOR and u20 identity per + * layer) and the pathFlags word (bounce in bits 0..3, showCelestial at 8, the two-bit secondary domain + * at 9..10, camera-transmission continuity at 11). The shader and this replica follow one layout + * definition; a change to either must land in both. + */ +final class PathSegmentPackingRoundTripTest { + + private static final int DOMAIN_WORLD = 0; + private static final int DOMAIN_LOCAL_VIEW = 1; + private static final int DOMAIN_REFLECTION = 2; + + private static final int PATH_BOUNCE_MASK = 15; + private static final int PATH_SHOW_CELESTIAL = 1 << 8; + private static final int PATH_SECONDARY_DOMAIN_SHIFT = 9; + private static final int PATH_SECONDARY_DOMAIN_MASK = 3 << PATH_SECONDARY_DOMAIN_SHIFT; + private static final int PATH_CAMERA_TRANSMISSION_CONTINUITY = 1 << 11; + + /** float3 ro (12 bytes) followed by this many uint lanes — the std430 stride the queue uses. */ + private static final int PACKED_UINT_LANES = 13; + + @Test + void strideAndFlagBitsMatchTheFrozenLayout() { + assertEquals(64, 12 + 4 * PACKED_UINT_LANES); + assertEquals(0, PATH_BOUNCE_MASK & PATH_SHOW_CELESTIAL); + assertEquals(0, PATH_BOUNCE_MASK & PATH_SECONDARY_DOMAIN_MASK); + assertEquals(0, PATH_BOUNCE_MASK & PATH_CAMERA_TRANSMISSION_CONTINUITY); + assertEquals(0, PATH_SHOW_CELESTIAL & PATH_SECONDARY_DOMAIN_MASK); + assertEquals(0, PATH_SHOW_CELESTIAL & PATH_CAMERA_TRANSMISSION_CONTINUITY); + assertEquals(0, PATH_SECONDARY_DOMAIN_MASK & PATH_CAMERA_TRANSMISSION_CONTINUITY); + } + + private record Layer(float ior, float[] extinction, int mediumId) {} + + private record Stack(Layer current, Layer parent1, Layer parent2) {} + + private record Segment(int bounce, boolean showCelestial, int secondaryDomain, + boolean cameraTransmissionContinuity, Stack stack) {} + + private record Packed(int currentExtinction, int parent1Extinction, int parent2Extinction, + int mediumIors01, int mediumIor2, int mediumIds01, int mediumId2, + int pathFlags) {} + + private static int normalizeDomain(int domain) { + return domain == DOMAIN_LOCAL_VIEW || domain == DOMAIN_REFLECTION ? domain : DOMAIN_WORLD; + } + + private static Packed pack(Segment s) { + int domain = s.bounce() == 0 ? DOMAIN_WORLD : normalizeDomain(s.secondaryDomain()); + int pathFlags = (s.bounce() & PATH_BOUNCE_MASK) + | (s.showCelestial() ? PATH_SHOW_CELESTIAL : 0) + | (domain << PATH_SECONDARY_DOMAIN_SHIFT) + | (s.cameraTransmissionContinuity() ? PATH_CAMERA_TRANSMISSION_CONTINUITY : 0); + Stack stack = s.stack(); + return new Packed( + packRgb9e5(stack.current().extinction()), + packRgb9e5(stack.parent1().extinction()), + packRgb9e5(stack.parent2().extinction()), + packHalf2(stack.current().ior(), stack.parent1().ior()), + packHalf2(stack.parent2().ior(), 0.0f), + (stack.current().mediumId() & 0xFFFFF) + | ((stack.parent1().mediumId() & 0xFFF) << 20), + ((stack.parent1().mediumId() >>> 12) & 0xFF) + | ((stack.parent2().mediumId() & 0xFFFFF) << 8), + pathFlags); + } + + private static Segment unpack(Packed p) { + Layer current = new Layer(halfLow(p.mediumIors01()), unpackRgb9e5(p.currentExtinction()), + p.mediumIds01() & 0xFFFFF); + Layer parent1 = new Layer(halfHigh(p.mediumIors01()), unpackRgb9e5(p.parent1Extinction()), + (p.mediumIds01() >>> 20) | ((p.mediumId2() & 0xFF) << 12)); + Layer parent2 = new Layer(halfLow(p.mediumIor2()), unpackRgb9e5(p.parent2Extinction()), + (p.mediumId2() >>> 8) & 0xFFFFF); + return new Segment(p.pathFlags() & PATH_BOUNCE_MASK, + (p.pathFlags() & PATH_SHOW_CELESTIAL) != 0, + (p.pathFlags() & PATH_SECONDARY_DOMAIN_MASK) >>> PATH_SECONDARY_DOMAIN_SHIFT, + (p.pathFlags() & PATH_CAMERA_TRANSMISSION_CONTINUITY) != 0, + new Stack(current, parent1, parent2)); + } + + // ---- the quantizers the record uses, replicated from segment.slang ---- + + private static int packHalf2(float x, float y) { + return (Float.floatToFloat16(x) & 0xFFFF) | (Float.floatToFloat16(y) << 16); + } + + private static float halfLow(int packed) { + return Float.float16ToFloat((short) (packed & 0xFFFF)); + } + + private static float halfHigh(int packed) { + return Float.float16ToFloat((short) (packed >>> 16)); + } + + private static int packRgb9e5(float[] v) { + float r = clampRgb9e5(v[0]); + float g = clampRgb9e5(v[1]); + float b = clampRgb9e5(v[2]); + float maxChannel = Math.max(r, Math.max(g, b)); + int exponent = maxChannel < Math.scalb(1.0f, -16) + ? 0 : (int) Math.floor(Math.log(maxChannel) / Math.log(2.0)) + 16; + exponent = Math.min(exponent, 31); + float scale = Math.scalb(1.0f, exponent - 24); + int maxMantissa = (int) Math.floor(maxChannel / scale + 0.5f); + if (maxMantissa == 512 && exponent < 31) { + exponent++; + scale *= 2.0f; + } + int mr = Math.min((int) Math.floor(r / scale + 0.5f), 511); + int mg = Math.min((int) Math.floor(g / scale + 0.5f), 511); + int mb = Math.min((int) Math.floor(b / scale + 0.5f), 511); + return mr | (mg << 9) | (mb << 18) | (exponent << 27); + } + + private static float[] unpackRgb9e5(int p) { + float scale = Math.scalb(1.0f, (p >>> 27) - 24); + return new float[]{(p & 0x1FF) * scale, ((p >>> 9) & 0x1FF) * scale, ((p >>> 18) & 0x1FF) * scale}; + } + + private static float clampRgb9e5(float v) { + return Math.max(0.0f, Math.min(v, 65408.0f)); + } + + @Test + void roundTripPreservesDomainContinuityBounceAndStack() { + Random random = new Random(0x5eedcafe); + int[] ids = {0, 1, 2, 7, 4095, 4096, 65534, 65535, 65536, 1000000, 1048573, 1048574}; + for (int bounce : new int[]{0, 1, 2, 3, 8, 15}) { + for (int domain : new int[]{DOMAIN_WORLD, DOMAIN_LOCAL_VIEW, DOMAIN_REFLECTION}) { + for (boolean celestial : new boolean[]{false, true}) { + for (boolean continuity : new boolean[]{false, true}) { + Segment segment = new Segment(bounce, celestial, domain, continuity, + randomStack(random, ids)); + Segment back = unpack(pack(segment)); + + assertEquals(bounce, back.bounce()); + assertEquals(celestial, back.showCelestial()); + assertEquals(continuity, back.cameraTransmissionContinuity(), + "continuity survives every bounce, including camera replays"); + assertEquals(bounce == 0 ? DOMAIN_WORLD : domain, back.secondaryDomain(), + "camera replays normalize to WORLD, everything else round-trips"); + assertStackRoundTrip(segment.stack(), back.stack()); + } + } + } + } + } + + private static void assertStackRoundTrip(Stack in, Stack out) { + assertLayerRoundTrip(in.current(), out.current()); + assertLayerRoundTrip(in.parent1(), out.parent1()); + assertLayerRoundTrip(in.parent2(), out.parent2()); + } + + private static void assertLayerRoundTrip(Layer in, Layer out) { + assertEquals(in.mediumId(), out.mediumId(), "identities round-trip exactly"); + assertEquals(Float.float16ToFloat(Float.floatToFloat16(in.ior())), out.ior(), + "IOR round-trips through fp16 exactly"); + float[] quantized = unpackRgb9e5(packRgb9e5(in.extinction())); + for (int c = 0; c < 3; c++) { + assertEquals(quantized[c], out.extinction()[c], + "extinction round-trips to the reference quantizer's decode"); + } + } + + private static Stack randomStack(Random random, int[] ids) { + return new Stack(randomLayer(random, ids), randomLayer(random, ids), randomLayer(random, ids)); + } + + private static Layer randomLayer(Random random, int[] ids) { + float[] extinction = { + random.nextFloat() * 2.0f, random.nextFloat() * 2.0f, random.nextFloat() * 2.0f}; + return new Layer(1.0f + random.nextFloat(), extinction, ids[random.nextInt(ids.length)]); + } + + @Test + void theFourthDomainEncodingIsNeverPacked() { + Random random = new Random(0xd00d1e); + for (int raw = 0; raw < 8; raw++) { + for (int bounce : new int[]{0, 1, 15}) { + Segment segment = new Segment(bounce, false, raw, false, + randomStack(random, new int[]{0})); + int packedDomain = (pack(segment).pathFlags() & PATH_SECONDARY_DOMAIN_MASK) + >>> PATH_SECONDARY_DOMAIN_SHIFT; + assertTrue(packedDomain <= DOMAIN_REFLECTION, + "raw domain " + raw + " must pack to a legal encoding, got " + packedDomain); + } + } + } + + @Test + void layerOrderIsPreserved() { + Stack stack = new Stack( + new Layer(1.33f, new float[]{0.1f, 0.2f, 0.3f}, 1), + new Layer(1.31f, new float[]{0.4f, 0.5f, 0.6f}, 2), + new Layer(1.0f, new float[]{0.0f, 0.0f, 0.0f}, 0)); + Stack back = unpack(pack(new Segment(3, true, DOMAIN_LOCAL_VIEW, true, stack))).stack(); + assertEquals(1, back.current().mediumId()); + assertEquals(2, back.parent1().mediumId()); + assertEquals(0, back.parent2().mediumId()); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/entity/LocalViewPublicationStateTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/entity/LocalViewPublicationStateTest.java new file mode 100644 index 00000000..e5361019 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/LocalViewPublicationStateTest.java @@ -0,0 +1,42 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * The publication verdict behind the localViewPresent frame signal. Presence demands the full + * conjunction — compatibility toggle, two-slot budget admission, provider capture readiness (which + * itself folds provider absence, missing state, camera-unsafe state, ownership mismatch and the circuit + * breaker into one fact) and the completed geometry-table write. Any single failure leaves the signal + * clear the same frame, so the shader's transmission-continuity chain falls back to baseline behaviour + * without hysteresis. + */ +final class LocalViewPublicationStateTest { + + @Test + void presenceHoldsOnlyWhenEveryGateHeld() { + for (int bits = 0; bits < 16; bits++) { + boolean eligible = (bits & 1) != 0; + boolean admitted = (bits & 2) != 0; + boolean captured = (bits & 4) != 0; + boolean written = (bits & 8) != 0; + assertEquals(bits == 15, + RtEntities.localViewPresence(eligible, admitted, captured, written), + "gates " + Integer.toBinaryString(bits)); + } + } + + @Test + void everySpecFailureClassMapsToAClearedGate() { + assertFalse(RtEntities.localViewPresence(false, false, false, false), + "experimental toggle off, or the entity is not the first-person camera entity"); + assertFalse(RtEntities.localViewPresence(true, false, false, false), + "budget degradation left fewer than two free geometry-table slots"); + assertFalse(RtEntities.localViewPresence(true, true, false, false), + "provider absent, state missing, camera-unsafe, ownership mismatch or circuit breaker"); + assertFalse(RtEntities.localViewPresence(true, true, true, false), + "publication did not complete the geometry-table write"); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java new file mode 100644 index 00000000..9d8f27da --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java @@ -0,0 +1,66 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The two-slot admission rule guarding the geometry table. A camera entity that publishes both + * representations emits TWO table entries from ONE loop iteration, while the table is sized exactly + * maxEntities() and the loop's own {@code full()} guard is evaluated before the iteration begins. The + * admission predicate is what keeps the second write inside the buffer, so it is called directly here + * rather than restated — deleting or inverting it in production must fail these tests. + * + *

The surrounding capture path needs Minecraft entities, a render dispatcher, a provider registry and + * live Vulkan buffers, none of which a unit test can stand up; this pins the admission predicate and the + * write indices it implies, and the rest of P6/P9 stays a review item. + */ +final class RtLocalViewBudgetTest { + @Test + void admitsBothRepresentationsWithTwoSlotsLeft() { + assertTrue(RtEntities.admitsLocalView(64, 62)); + } + + @Test + void refusesTheLocalViewWithOnlyOneSlotLeft() { + assertFalse(RtEntities.admitsLocalView(64, 63), + "one free slot must degrade to the world stand-in, not write past the table"); + } + + @Test + void refusesTheLocalViewWhenAlreadyFull() { + assertFalse(RtEntities.admitsLocalView(64, 64)); + } + + /** + * The critical pair from design P6. At capacity minus two the iteration writes the last two indices; at + * capacity minus one it writes only the final index. Neither may reach {@code capacity}. + */ + @Test + void writeIndicesStayInsideTheTableAtBothCriticalPoints() { + int capacity = 64; + + assertEquals(capacity - 1, highestWriteIndex(capacity, capacity - 2)); + assertEquals(capacity - 1, highestWriteIndex(capacity, capacity - 1)); + } + + @Test + void writeIndicesStayInsideTheTableAcrossEveryOccupancy() { + int capacity = 64; + for (int logicalCount = 0; logicalCount < capacity; logicalCount++) { + assertTrue(highestWriteIndex(capacity, logicalCount) <= capacity - 1, + "occupancy " + logicalCount + " wrote past the geometry table"); + } + } + + /** + * Highest geometry-table index a camera-entity iteration writes, given the occupancy it starts from. + * writeTableEntry indexes by the pre-increment physical count, so an admitted pair writes + * {@code logicalCount} and {@code logicalCount + 1}; a refused local view writes only the stand-in. + */ + private static int highestWriteIndex(int capacity, int logicalCount) { + return RtEntities.admitsLocalView(capacity, logicalCount) ? logicalCount + 1 : logicalCount; + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java new file mode 100644 index 00000000..7afdea57 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java @@ -0,0 +1,98 @@ +package dev.comfyfluffy.caustica.rt.entity; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The visibility algebra behind coexisting player representations. An instance is visible to a ray when + * its TLAS mask AND the ray's domain is non-zero, so the whole feature reduces to which bits each side + * sets. The masks are read reflectively out of {@link RtEntities} rather than restated here: a test that + * declared its own copies would keep passing after someone changed the real ones. + */ +final class RtVisibilityDomainTest { + private static final int CULL_SECONDARY = 0x01; + private static final int CULL_PRIMARY = 0x02; + private static final int CULL_LOCAL_VIEW_SECONDARY = 0x04; + private static final int CULL_REFLECTION = 0x08; + + private static int mask(String name) throws ReflectiveOperationException { + Field field = RtEntities.class.getDeclaredField(name); + field.setAccessible(true); + return field.getInt(null); + } + + @Test + void maskConstantsMatchTheShaderDomainBits() throws ReflectiveOperationException { + assertEquals(CULL_SECONDARY, mask("MASK_SECONDARY")); + assertEquals(CULL_PRIMARY, mask("MASK_PRIMARY")); + assertEquals(CULL_LOCAL_VIEW_SECONDARY, mask("MASK_LOCAL_VIEW_SECONDARY")); + assertEquals(0xFF, mask("MASK_ALL")); + // The particle mask is primary-only; a new domain must not have widened it. + assertEquals(CULL_PRIMARY, mask("PARTICLE_MASK")); + } + + @Test + void visibilityMatchesTheFrozenDomainTable() throws ReflectiveOperationException { + int terrain = mask("MASK_ALL"); + int particle = mask("PARTICLE_MASK"); + int worldStandIn = mask("MASK_SECONDARY"); + int localView = mask("MASK_PRIMARY") | mask("MASK_LOCAL_VIEW_SECONDARY"); + + // One row per ray domain: camera, world secondary, local-view secondary, reflection. + assertVisibility(CULL_PRIMARY, terrain, true, particle, true, worldStandIn, false, localView, true); + assertVisibility(CULL_SECONDARY, terrain, true, particle, false, worldStandIn, true, localView, false); + assertVisibility(CULL_LOCAL_VIEW_SECONDARY, + terrain, true, particle, false, worldStandIn, false, localView, true); + assertVisibility(CULL_REFLECTION, + terrain, true, particle, false, worldStandIn, false, localView, false); + } + + /** + * The two representations never both answer one secondary ray. This disjointness is the entire + * mathematical basis for the player's shadow keeping its head while the visible body has no dark patch. + */ + @Test + void theTwoRepresentationsAreDisjointOnSecondaryRays() throws ReflectiveOperationException { + int worldStandIn = mask("MASK_SECONDARY"); + int localView = mask("MASK_PRIMARY") | mask("MASK_LOCAL_VIEW_SECONDARY"); + + assertEquals(0, worldStandIn & CULL_LOCAL_VIEW_SECONDARY, "stand-in must not answer local-view rays"); + assertEquals(0, localView & CULL_SECONDARY, "local view must not answer world secondary rays"); + assertTrue((worldStandIn & CULL_SECONDARY) != 0, "the stand-in owns the world secondary domain"); + assertTrue((localView & CULL_LOCAL_VIEW_SECONDARY) != 0, "local view owns its own secondary domain"); + } + + /** + * A reflection leaving a local-view surface must contain only scene geometry. Neither player + * representation nor particles may answer a reflection-domain ray — that purity is the reason the + * domain exists. + */ + @Test + void reflectionDomainSeesNoPlayerRepresentation() throws ReflectiveOperationException { + int worldStandIn = mask("MASK_SECONDARY"); + int localView = mask("MASK_PRIMARY") | mask("MASK_LOCAL_VIEW_SECONDARY"); + + assertEquals(0, worldStandIn & CULL_REFLECTION, "stand-in must not answer reflection rays"); + assertEquals(0, localView & CULL_REFLECTION, "local view must not answer reflection rays"); + assertEquals(0, mask("PARTICLE_MASK") & CULL_REFLECTION, "particles must not answer reflection rays"); + assertTrue((mask("MASK_ALL") & CULL_REFLECTION) != 0, "scene geometry answers reflection rays"); + } + + private static void assertVisibility(int domain, int mask0, boolean expected0, int mask1, boolean expected1, + int mask2, boolean expected2, int mask3, boolean expected3) { + assertCell(domain, mask0, expected0); + assertCell(domain, mask1, expected1); + assertCell(domain, mask2, expected2); + assertCell(domain, mask3, expected3); + } + + private static void assertCell(int domain, int instanceMask, boolean expected) { + assertEquals(expected, (instanceMask & domain) != 0, + () -> "instance mask 0x" + Integer.toHexString(instanceMask) + + " against domain 0x" + Integer.toHexString(domain)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistryCapacityTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistryCapacityTest.java new file mode 100644 index 00000000..65952282 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistryCapacityTest.java @@ -0,0 +1,33 @@ +package dev.comfyfluffy.caustica.rt.material; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Anchors RtMaterialRegistry's medium-identity capacity to the shader's 20-bit payload field (bits + * 9..28 in world_common.slang): every slot at or below the cap derives an identity that fits the + * space, and one more record trips rebuild's fail-closed guard before any buffer is allocated. + */ +final class RtMaterialRegistryCapacityTest { + + @Test + void capacityAnchorsTheTwentyBitIdentitySpace() throws Exception { + Field capacityField = RtMaterialRegistry.class.getDeclaredField("MAX_MEDIUM_IDENTITY_RECORDS"); + capacityField.setAccessible(true); + int capacity = capacityField.getInt(null); + + // 2^20 - 3, with air and water reserved: a dielectric's identity is materialId + 2. + assertEquals(1048573, capacity); + + // At full capacity the largest materialId is capacity - 1, so the derived identity peaks at + // capacity + 1 = 0xFFFFE, keeping the 0xFFFFF sentinel unallocated. + assertEquals(0xFFFFE, capacity + 1); + + // One record more than the cap trips rebuild's fail-closed guard before buffer allocation. + assertTrue(1048574 > capacity); + } +}