Add first-person body compatibility and fix ray-visibility baseline defects - #42
Open
Overhatch wants to merge 13 commits into
Open
Add first-person body compatibility and fix ray-visibility baseline defects#42Overhatch wants to merge 13 commits into
Overhatch wants to merge 13 commits into
Conversation
FirstPerson Model renders a complete first-person body state that vanilla's pipeline never feeds into Caustica's ray tracer, so the player's own body stayed in vanilla lighting while the world around it was path-traced. This change brings that state into RT, in three parts. First, a mod-agnostic extension point: the mainline gains a provider registry and a per-frame camera-safety declaration, with no mod identity in the core. The highest integer priority wins; a tie fails closed for the frame with one WARN, and a false, missing, or throwing safety declaration is treated as unsafe. The first-person instance's motion identity is -(entityId + 1), disjoint from the vanilla body's positive id space, so history-map swaps need no explicit reset. Off by default, the feature short-circuits before the registry lookup: zero extraction, zero logging when disabled. Second, the FirstPerson Model bridge. The mod already appends a complete first-person state during vanilla's extraction pass, so the bridge picks that state out and hands it to the extension point. No geometry is rebuilt; the state's own x/y/z already carry the mod's computed offset, so the instance anchor is correct automatically. The dependency is compileOnly; the mod is neither bundled nor required at runtime. Third, the camera entity now uses the first-person state as its single instance with a fully visible mask when the provider delivers it, falling back to the baseline mask and anchor otherwise. Dual-instance layouts blacked out the torso: the offset layout let first-person surface rays hit the co-visible vanilla body, and co-locating the instances wrapped the torso in the vanilla head cube, measuring exactly (0,0,0) over 13%-33% of the frame. The single-instance layout eliminates the black region. Known issue: parts the provider hides do not participate in shadows, GI, or reflections, so the player's own shadow has no head. This is the documented cost of the single-instance layout, accepted in exchange for correct first-person surface lighting. Build / test: gradlew build, 52 testcases across 17 testsuites, all green. Smoke-tested in a Fabric 26.2 instance with FirstPerson Model 2.7.2: the first-person body participates in RT lighting with no black region. Dependencies: build.gradle adds a Modrinth maven repository and a compileOnly maven.modrinth:first-person-model dependency. CI's package job resolves it from api.modrinth.com on the runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promote "local-view representation" and "world-space representation" into a first-class renderer concept so both can coexist in one frame without poisoning each other's secondary rays. Adds a third ray inclusion mask (CULL_LOCAL_VIEW_SECONDARY) alongside the existing secondary/primary masks, a payload bit (PAYLOAD_SURFACE_LOCAL_VIEW, flags bit 8) that closest-hit sets from EntityGeom.reserved.x, and a single derivation entry point secondaryMaskForSurface(). The domain is an OUTGOING property of the surface a ray leaves: the indirect bounce loop latches it once per hit and carries it to the next iteration from exactly one assignment site, so a future continuation branch inherits the correct semantics instead of silently keeping a stale domain. visibility(), shadeReservoir() and resolveTransmissionGuide() take the mask as a leading parameter, making an omission a compile error rather than a silent fallback. SpecSurface carries the interface's domain because the reflection probe runs after the transmission chain has already overwritten the global payload. PathSegment gains pathFlags bit 11 so Pass B resumes a split dielectric continuation in the domain its interface belonged to; the packed 48-byte stride is unchanged. No instance sets ENTITY_GEOM_LOCAL_VIEW yet, so this is behaviourally equivalent to the baseline: the new bit is always zero, secondaryMaskForSurface always returns CULL_SECONDARY, and the transmission guide mask reduces to CULL_PRIMARY. The block_outline inline query is deliberately untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Give the geometry table a carrier for per-instance semantics and use it to tag the first-person body as the camera entity's local-view representation. EntityGeom.reserved's low word (offset +56) now holds instance flags; writeTableEntry takes entityGeomFlags as a mandatory parameter rather than a defaulted overload, so a new instance path cannot silently inherit zero. That choice paid for itself immediately: the compiler caught two call sites the manual sweep had missed. The high word stays zero. The local-view instance mask changes from 0xFF to MASK_PRIMARY | MASK_LOCAL_VIEW_SECONDARY (0x06), so it is visible to the camera ray and to secondary rays leaving a local-view surface, but invisible to world secondary rays. Block entities, rigid reuse, particles and ordinary entities all pass zero flags and keep their existing masks. The double-representation itself is not enabled yet: captureEntities still returns early after publishing the first-person body, so the camera entity continues to produce exactly one instance per frame. With the experiment toggle off nothing writes ENTITY_GEOM_LOCAL_VIEW at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the early return after publishing the first-person body, so the camera entity now falls through to the ordinary extraction path and produces both a local-view representation and a world-space stand-in. Their instance masks are disjoint on secondary rays (0x06 and 0x01), which is what lets the player's shadow keep its head while the visible first-person body has no dark patch. Emitting two instances from one loop iteration reopens a bounds problem the single-instance path could not have. The geometry table is sized exactly maxEntities() with no slack, writeTableEntry addresses it by the pre-increment build.count, and the loop's full() guard is evaluated once before the iteration starts. Entering the camera-entity iteration with one free slot would therefore write one entry past the end of the buffer. A two-slot precheck placed BEFORE the provider is queried closes it: a short budget degrades to the stand-in alone, byte-identical to the fallback path, rather than publishing half a player or writing out of bounds. Counting is split per D10: logicalCount increments twice because there are two physical table entries, while capturedThisFrame and entitiesCaptured increment once because there is one logical entity. The two increments the early-return branch used to perform are removed, since the ordinary path already does each. Adds unit tests for the visibility algebra and the admission arithmetic. The mask tests read the real constants reflectively rather than restating them, so they fail if the wiring changes. The full P6/P9 matrices need Minecraft entities, a provider registry and live Vulkan buffers, so the remainder stays a review item rather than a test that asserts nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both degradation and success were silent: a player whose entity budget ran out just saw their hands disappear, with nothing in the log or the frame stats to say why, and no way to confirm from outside whether the two representations were actually coexisting. Adds localViewInstances and worldStandInInstances counters next to the existing firstPersonInstances stat. worldStandInInstances is counted where the stand-in's instance actually lands, alongside entitiesCaptured, so it stays zero on any path that captures nothing for the camera entity rather than reporting an instance that was never emitted. The two-slot precheck failing now logs a warning, suppressed to once per session in the same style as the provider circuit-breaker, since the condition recurs every frame and would otherwise flood the log. With the experiment toggle off, none of this runs: the eligibility test still short-circuits on FIRST_PERSON_COMPAT_ENABLED, so no counter moves and no warning is emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RtTerrain.stream measures lightGrid.publishReady under the stage name terrain.lightGridPublish, but the name was missing from RtFrameStats' hardcoded stage whitelist. The config flag defaults to false, so the mismatch was completely silent until frame stats were enabled, at which point Profile.indexOf threw IllegalArgumentException on the first terrain tick and crashed on world entry. The historical CSV from 2026-08-03 had a terrain.lightGridPublishMs column, so the entry was dropped at some point rather than never existing; it is restored in its original column position, directly after terrain.publish.
A local-view surface's reflection-class continuations and specular probes now take a dedicated 0x08 domain that no player representation's instance mask contains, so a first-person reflection cannot contain the player itself. The bounce loop latches a base domain for its direct-light queries and selects each continuation's domain at a single point once the scatter lobe is known. Path records grow from 48 to 64 bytes: a three-layer medium stack carrying 16-bit identities (air and water reserved, other dielectrics shared for now), a two-bit continuation domain, and a camera-transmission continuity bit wired constant-false until the publication signal exists. Water-ness derives from the identity instead of packed booleans, and the Java-side queue allocation follows the new stride.
Every dielectric hit now publishes a canonical 16-bit medium identity through the payload (water reserved, others materialId + 2, table size validated at load), and the depth-3 stack resolves each exit against it: a current-layer match keeps the normal Fresnel exit, a deeper match removes only that layer while the ray passes straight through, an unmatched exit is optically inert, and a push onto a full stack fails the continuation closed instead of corrupting a layer. Non-nested overlaps - enter ice, enter glass, exit ice, exit glass - now recover the true surrounding medium, which is what produced the false TIR fragments when a held dielectric clipped through a world one. An exhaustive Java reference model mirrors the semantics over every enter/exit sequence up to length six and locks the invariants: bounded depth, current is the most recent unexited medium, nested sequences equal plain LIFO, and air stays a bottom sentinel. The ice material feature bit and its payload transcription land here as inert carriers; nothing sets or consumes them yet.
The first-person world stand-in no longer blocks shadow rays outright. Its TLAS instance is forced non-opaque so the shadow any-hit runs, multiplies the ray's transmittance by a configurable neutral factor exactly once per ray (an unused shadow-payload lane is the marker), and lets traversal continue - so the hard black blob in front of the feet becomes a soft partial shadow while opaque geometry behind the body still occludes fully in any intersection order. The factor ships as a video-settings percent slider, default 0.35, clamped to [0,1], read per frame through a new WorldPush.shadowPolicy lane. Ice-family terrain (classified by sprite name, never by optical parameters) replaces its colored Beer-Lambert shadow tint with a fixed neutral 0.15 per interface: one ice block attenuates to about 2 percent, so the separated second shadow and blue cast disappear while stacked ice keeps darkening. All other translucent materials keep their colored transmission byte for byte.
FrameEntities now carries whether the camera entity's local-view representation actually landed in the geometry table this frame, sourced from the exact write that counts localViewInstances - so the signal and the counter can never disagree. The verdict is a pure four-gate conjunction (compatibility toggle, two-slot budget admission, provider capture readiness, completed table write) locked by an exhaustive unit test; every degraded path leaves it false. RtComposite mirrors the fact into WorldPush.flags bit 2 in the same frame with no hysteresis. Nothing consumes the bit yet.
With the local view published, every camera-visible transmission continuation - the Pass A terminal and transmission split records, pure transmissions, and optically inert pass-throughs - carries a continuity bit and stays in the local-view domain even across world water and glass. The bounce loop updates continuity and domain together at its single assignment point: transmission lobes keep the chain, reflection-class and diffuse lobes end it and return to surface-derived domains. The visible player therefore resolves to one representation on both sides of the waterline instead of switching bodies wherever refraction crosses it. The transmission guide follows the same rule: published means the whole chain keeps the camera plus local-view union mask instead of re-deriving per crossing, unpublished keeps the baseline. An ice interface now ends the guide chain with a coherent interface tuple - depth, normal and motion all from the ice face - instead of reporting the destination behind it.
Math.clamp passes NaN straight through because it is unordered, so a NaN system property or config value could reach the GPU - for the new shadow transmittance that would poison every shadow ray's transmittance. Clamped settings now fall back to their default on NaN while infinities keep clamping to the range ends, and the shadow-transmittance setting locks the whole algebra in a test. Found by the final cross-review.
The 16-bit payload field (flags bits 9..24, registry cap 65533) overflowed on heavily modded instances: 112180 records > 65533 made RtMaterialRegistry.rebuild fail closed and RT fall back to vanilla. - PAYLOAD_MEDIUM_ID_MASK covers bits 9..28; PAYLOAD_SURFACE_ICE moves to bit 29 - PackedPathSegment layers widen to u20, parent1 split 12+8 across mediumIds01/mediumId2; the 64-byte record stride is unchanged - MAX_MEDIUM_IDENTITY_RECORDS becomes 1048573 (2^20-3), keeping the derived identity at or below 0xFFFFE so the 0xFFFFF sentinel is never allocated - Java test mirror updated in lock-step plus a new capacity boundary test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Safety and fallback behaviour
Verification