From 37f5a90cac8187bd9287fc3c2e476fd4b35029e1 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Wed, 5 Aug 2026 00:17:27 +0800 Subject: [PATCH 01/13] Add FirstPerson Model first-person body to ray tracing 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) --- build.gradle | 12 ++ .../comfyfluffy/caustica/CausticaConfig.java | 2 + .../caustica/client/CausticaClient.java | 19 +++ .../caustica/client/RtVideoOptions.java | 6 + .../firstperson/FirstPersonModelBridge.java | 86 ++++++++++ .../caustica/mixin/LevelRendererAccessor.java | 18 +++ .../comfyfluffy/caustica/rt/RtFrameStats.java | 3 +- .../rt/entity/CameraSafetyDeclaration.java | 33 ++++ .../rt/entity/FirstPersonStateProvider.java | 32 ++++ .../rt/entity/FirstPersonStateRegistry.java | 151 ++++++++++++++++++ .../caustica/rt/entity/RtEntities.java | 140 +++++++++++++++- .../resources/assets/caustica/lang/en_us.json | 3 + src/main/resources/caustica.mixins.json | 1 + 13 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 src/main/java/dev/comfyfluffy/caustica/compat/firstperson/FirstPersonModelBridge.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/LevelRendererAccessor.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/entity/CameraSafetyDeclaration.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateProvider.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/entity/FirstPersonStateRegistry.java 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/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 0088a319..538f4d70 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -616,6 +616,8 @@ 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); private Entities() { } 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..c86f3800 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -48,6 +48,7 @@ public static OptionInstance[] runtimeOptions() { maxBounces(), entities(), particles(), + firstPersonCompat(), waterWaves(), dlssQuality() )); @@ -131,6 +132,11 @@ 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 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/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 7722b8fd..03115bbb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -89,7 +89,8 @@ public final class RtFrameStats { "entityPackedBytes", "entityPackedPaddingBytes", "entityRetainedGeometryBytes", "entityFrameListsWaits", "entityTableWaits", "entitySlotWaits", "entityGraphicsWaitNanos", "entityMotionFlushes", "entityTableFlushes", - "entityBlockEntityRetirements", "entitySlotRetirements", "entityTableRetirements"}, + "entityBlockEntityRetirements", "entitySlotRetirements", "entityTableRetirements", + "firstPersonInstances"}, true); private static final List GC_BEANS = ManagementFactory.getGarbageCollectorMXBeans(); 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..374efd07 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -15,6 +15,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; @@ -182,6 +183,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 +227,8 @@ 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; + // 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<>(); @@ -382,6 +388,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; @@ -702,6 +712,21 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie float iz; int id = entity.getId(); EntityPrev prev = prevVerts.get(id); + + // First-person compatibility: a provider-supplied first-person body replaces the ordinary + // capture for that frame rather than joining it. One instance, fully visible: it fills the + // camera view AND casts the shadows/GI the ordinary body would have. Keeping both would put + // the ordinary body's head — which the provider hides — around the camera, sealing the visible + // first-person surfaces off from every light. + if (firstPersonSelf && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value()) { + FirstPersonCapture fpReady = captureFirstPerson(build, dispatcher, entity, partial, id); + if (fpReady != null) { + publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz); + RtFrameStats.FRAME.count("entitiesCaptured", 1); + capturedThisFrame++; + continue; + } + } capture.reset(prev != null ? prev.size / 3 : 0); try { EntityRenderState state; @@ -810,6 +835,94 @@ 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); + } + + /** + * Publish the mesh {@link #captureFirstPerson} left in {@link #fpCapture} as this frame's only instance + * for the camera entity, visible to every ray. 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. + */ + private void 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_ALL, + translationTransform(ready.x() - rbx, ready.y() - rby, ready.z() - rbz)); + lastFirstPersonProviderId = ready.providerId(); + build.logicalCount++; + RtFrameStats.FRAME.count("firstPersonInstances", 1); + } + /** * 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 @@ -1540,22 +1653,33 @@ private void appendCapture(RtContext ctx, FrameBuild build, Motion motion, int e appendPackedEntity(ctx, build, motion, entityId, instanceBit, mask, instanceTransform); return; } + appendTransientCapture(ctx, build, capture, motion, instanceBit, mask, instanceTransform); + } + + /** + * 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) { + 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); diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 4533f086..d687fc83 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -27,6 +27,9 @@ "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.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", From 42deca6e7762749c5adc9b70a446820ae13ddb8b Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sat, 8 Aug 2026 20:39:39 +0800 Subject: [PATCH 02/13] Introduce ray visibility domains in the world shaders 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) --- .../pipelines/world/closest_hit.rchit.slang | 4 +++ shaders/pipelines/world/guides.slang | 23 ++++++++---- shaders/pipelines/world/indirect.rgen.slang | 35 +++++++++++++------ shaders/pipelines/world/lighting.slang | 6 ++-- shaders/pipelines/world/primary.rgen.slang | 31 ++++++++++------ shaders/pipelines/world/segment.slang | 15 ++++++-- shaders/pipelines/world/trace.slang | 15 ++++++-- shaders/pipelines/world/world_common.slang | 9 ++++- shaders/pipelines/world/world_core.slang | 3 ++ 9 files changed, 105 insertions(+), 36 deletions(-) diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index bf21f885..09201f58 100644 --- a/shaders/pipelines/world/closest_hit.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -323,6 +323,10 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); payloadSetDielectric(payload, material, entering); + // 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; } diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 7f135197..400aaa99 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,7 +166,7 @@ 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; @@ -169,7 +177,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, @@ -226,6 +234,9 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, } else { mediumPop(medium); } + // 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, isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS); diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 2df3dc6a..4ad0c70c 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -73,19 +73,24 @@ 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; + // The domain the next secondary ray belongs to. Re-derived from each hit below, so it tracks the + // surface a ray leaves rather than sticking to the path. + uint nextMask = seg.localViewSecondary ? CULL_LOCAL_VIEW_SECONDARY : CULL_SECONDARY; for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // 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 the secondary domain of the surface it leaves — CULL_SECONDARY for world + // surfaces (excludes particles, includes the stand-in) or CULL_LOCAL_VIEW_SECONDARY for the + // local-view representation (which the world stand-in is invisible to, and vice versa). #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 : nextMask, 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 : nextMask, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); #endif @@ -105,6 +110,13 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { break; } + // Latch this hit's outgoing domain before anything can overwrite the global payload, and carry it + // to the next iteration HERE — the single assignment point in the loop. Every shadow/reservoir ray + // cast from this vertex uses surfaceMask. Assigning nextMask per-branch instead would leave a new + // upstream continuation path silently inheriting the wrong domain without a rebase conflict. + uint surfaceMask = secondaryMaskForSurface(payload.flags); + nextMask = surfaceMask; + // 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 @@ -211,7 +223,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(surfaceMask, 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 +236,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(surfaceMask, r, hitPos, n, v, rd, albedo, + float3(0.0, 0.0, 0.0), 1.0, true, 0.0); } if (bounce >= maxBounces) { @@ -286,7 +298,7 @@ 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(surfaceMask, 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, @@ -317,8 +329,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(surfaceMask, 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,7 +341,8 @@ 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(surfaceMask, 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. 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/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index 48f990bc..6793f40f 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -40,9 +40,10 @@ public PathSegment tracePrimary(PathSegment seg, { 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, false); traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); @@ -88,8 +89,11 @@ 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))); + // An opaque/cutout local-view surface reflects in its own domain, so this cannot take + // the world-domain convenience overload. Particles above always belong to the world. + gv_spec = makeSpecSurface(gv_hitCamRel, n, n, n, float3(payload.motionPrev), rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v)), + secondaryMaskForSurface(payload.flags)); } } @@ -134,6 +138,10 @@ public PathSegment tracePrimary(PathSegment seg, 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; + // This interface's domain, read once here — before any guide probe below overwrites the global + // payload. Every continuation and guide mask in this branch derives from these two. + bool interfaceLocalView = payloadSurfaceLocalView(); + uint interfaceSecondaryMask = secondaryMaskForSurface(payload.flags); if (bounce == 0) { gv_normal = n; @@ -145,7 +153,7 @@ 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)); + gv_rough, float3(F, F, F), interfaceSecondaryMask); if (dot(transmittedDir, transmittedDir) > 0.0) { MediumStack guideMedium = medium; if (entering) { @@ -153,7 +161,10 @@ public PathSegment tracePrimary(PathSegment seg, } else { mediumPop(guideMedium); } - resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + // 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, guideMedium, transmitBias, rayConeWidth, rayConeSpread, !isWater && entering ? payloadAlbedo() : float3(1.0, 1.0, 1.0)); @@ -176,7 +187,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, interfaceLocalView); queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); nextRecord = splitRecord; float3 reflectedDir = reflect(rd, n); @@ -184,7 +195,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, - true); + true, interfaceLocalView); return reflected; } @@ -196,7 +207,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, interfaceLocalView); } else { float3 deferredDir = normalize(transmittedDir); if (entering) { @@ -208,7 +219,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true); + true, interfaceLocalView); } return continuation; } @@ -242,7 +253,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, 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..64c2978c 100644 --- a/shaders/pipelines/world/segment.slang +++ b/shaders/pipelines/world/segment.slang @@ -25,11 +25,14 @@ 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. Meaningless at bounce 0, which is + // always traced as the camera ray; normalized to false there so the packed round-trip is an equality. + public bool localViewSecondary; }; 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, bool localViewSecondary) { PathSegment s; s.ro = ro; s.rd = rd; @@ -40,6 +43,7 @@ public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, Medi s.seed = seed; s.bounce = bounce; s.showCelestial = showCelestial; + s.localViewSecondary = localViewSecondary; return s; } // field is a uint, so Std430DataLayout gives this an exact 48-byte stride. @@ -57,6 +61,9 @@ public struct PackedPathSegment { }; public static const uint PATH_NO_NEXT = 0xffffffffu; +// pathFlags bit 11. Occupied so far: bits 0..3 bounce, 8 showCelestial, 9/10 water. The 48-byte stride +// is unchanged and bits 4..7 and 12..31 stay free. +public static const uint PATH_LOCAL_VIEW_SECONDARY = 1u << 11; public float2 octEncode(float3 direction) { float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); @@ -119,7 +126,8 @@ public PackedPathSegment packPathSegment(PathSegment seg, uint nextRecord) { 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); + | (seg.medium.outer.water ? 1u << 10u : 0u) + | (seg.localViewSecondary ? PATH_LOCAL_VIEW_SECONDARY : 0u); p.nextRecord = nextRecord; return p; } @@ -140,7 +148,8 @@ public PathSegment unpackPathSegment(PackedPathSegment p) { 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 & 15u), (p.pathFlags & (1u << 8u)) != 0u, + (p.pathFlags & PATH_LOCAL_VIEW_SECONDARY) != 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..6dc8840b 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -7,8 +7,11 @@ 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; public static const uint TERRAIN_BUCKETS = 4u; public static const uint SBT_RADIANCE = 0u; public static const uint SBT_SHADOW = TERRAIN_BUCKETS; @@ -16,6 +19,14 @@ public static const uint SBT_STRIDE_BUCKET = 1u; public static const uint MISS_RADIANCE = 0u; public static const uint MISS_GUIDE = 1u; +// The 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 secondaryMaskForSurface(uint payloadFlags) { + return (payloadFlags & PAYLOAD_SURFACE_LOCAL_VIEW) != 0u + ? CULL_LOCAL_VIEW_SECONDARY : CULL_SECONDARY; +} + public RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { RayDesc r; r.Origin = origin; @@ -96,7 +107,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 +117,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..326a2c85 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -168,7 +168,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. public uint roughMetal; // packHalf2x16(roughness, metalness) public uint emissionSss; // packHalf2x16(emission, sss) public uint iorTransmission; // packHalf2x16(IOR, transmission factor) @@ -179,6 +179,10 @@ 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 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 @@ -279,6 +283,9 @@ 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. +public static const uint ENTITY_GEOM_LOCAL_VIEW = 1u << 0; 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..50e2823c 100644 --- a/shaders/pipelines/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -33,6 +33,9 @@ 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; } // 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 From 256d694ce6eb7d077fbcf4fbcdaa0f9d7d65d56b Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sat, 8 Aug 2026 21:30:18 +0800 Subject: [PATCH 03/13] Mark the local-view instance in the geometry table and TLAS mask 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) --- .../caustica/rt/entity/RtEntities.java | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) 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 374efd07..9d849214 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -85,10 +85,14 @@ 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 flag; must stay in lock-step with {@code world_common.slang}. */ + private static final int ENTITY_GEOM_LOCAL_VIEW = 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. */ @@ -806,7 +810,7 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie } if (!reused) { appendCapture(ctx, build, motion, id, ENTITY_BIT, mask, - translationTransform(ix - rbx, iy - rby, iz - rbz)); + translationTransform(ix - rbx, iy - rby, iz - rbz), 0); } build.logicalCount++; RtFrameStats.FRAME.count("entitiesCaptured", 1); @@ -901,8 +905,9 @@ private FirstPersonCapture captureFirstPerson(FrameBuild build, EntityRenderDisp } /** - * Publish the mesh {@link #captureFirstPerson} left in {@link #fpCapture} as this frame's only instance - * for the camera entity, visible to every ray. Motion history lives in a disjoint negative key space + * 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. */ @@ -916,8 +921,10 @@ private void publishFirstPerson(RtContext ctx, FrameBuild build, FirstPersonCapt 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_ALL, - translationTransform(ready.x() - rbx, ready.y() - rby, ready.z() - rbz)); + 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); @@ -1138,7 +1145,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. */ @@ -1378,7 +1385,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, @@ -1530,7 +1537,7 @@ 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, 0); build.instances.add(new RtAccel.Instance(placeTransform(localTransform, placeX, placeY, placeZ), ea.refAccel.deviceAddress, ENTITY_BIT | (build.count & 0x3FFFFF), mask, RtAccel.SBT_ENTITY_OFFSET)); @@ -1640,20 +1647,21 @@ 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); + appendTransientCapture(ctx, build, capture, motion, instanceBit, mask, instanceTransform, entityGeomFlags); } /** @@ -1662,7 +1670,8 @@ private void appendCapture(RtContext ctx, FrameBuild build, Motion motion, int e * 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 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; @@ -1695,7 +1704,7 @@ private void appendTransientCapture(RtContext ctx, FrameBuild build, RtEntityCap 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)); @@ -1705,7 +1714,8 @@ private void appendTransientCapture(RtContext ctx, FrameBuild build, RtEntityCap /** 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; @@ -1773,7 +1783,7 @@ 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)); @@ -1834,9 +1844,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"); } @@ -1851,7 +1867,7 @@ 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); } From 5f939f96b1e8a1154fa183dfce18ba6df83d878a Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sat, 8 Aug 2026 21:43:48 +0800 Subject: [PATCH 04/13] Let both player representations coexist in one frame 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) --- .../caustica/rt/entity/RtEntities.java | 21 ++--- .../rt/entity/RtLocalViewBudgetTest.java | 70 ++++++++++++++++ .../rt/entity/RtVisibilityDomainTest.java | 79 +++++++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java 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 9d849214..bd3dc87e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -717,18 +717,21 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie int id = entity.getId(); EntityPrev prev = prevVerts.get(id); - // First-person compatibility: a provider-supplied first-person body replaces the ordinary - // capture for that frame rather than joining it. One instance, fully visible: it fills the - // camera view AND casts the shadows/GI the ordinary body would have. Keeping both would put - // the ordinary body's head — which the provider hides — around the camera, sealing the visible - // first-person surfaces off from every light. - if (firstPersonSelf && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value()) { + // 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. + if (firstPersonSelf && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value() + && maxEntities() - build.logicalCount >= 2) { FirstPersonCapture fpReady = captureFirstPerson(build, dispatcher, entity, partial, id); if (fpReady != null) { publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz); - RtFrameStats.FRAME.count("entitiesCaptured", 1); - capturedThisFrame++; - continue; } } capture.reset(prev != null ? prev.size / 3 : 0); 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..4a81befa --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java @@ -0,0 +1,70 @@ +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 + * precheck is what keeps the second write inside the buffer, so its arithmetic is asserted here directly. + * + *

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 covers the admission arithmetic and the + * resulting write indices, and the rest of P6/P9 stays a review item. + */ +final class RtLocalViewBudgetTest { + /** Mirrors the precheck in {@code captureEntities}: room for the local view AND the stand-in. */ + private static boolean admitsLocalView(int capacity, int logicalCount) { + return capacity - logicalCount >= 2; + } + + @Test + void admitsBothRepresentationsWithTwoSlotsLeft() { + assertTrue(admitsLocalView(64, 62)); + } + + @Test + void refusesTheLocalViewWithOnlyOneSlotLeft() { + assertFalse(admitsLocalView(64, 63), + "one free slot must degrade to the world stand-in, not write past the table"); + } + + @Test + void refusesTheLocalViewWhenAlreadyFull() { + assertFalse(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 table 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 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..d88b3a79 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java @@ -0,0 +1,79 @@ +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 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 third 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"); + + // Rows follow design D1's self-consistency table: camera, world surface, local-view surface. + 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); + } + + /** + * 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"); + } + + 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)); + } +} From 384ef2425246665da43f3d33be3d5f23222777c2 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sat, 8 Aug 2026 21:56:55 +0800 Subject: [PATCH 05/13] Make the dual representation and its budget fallback observable 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) --- .../comfyfluffy/caustica/rt/RtFrameStats.java | 2 +- .../caustica/rt/entity/RtEntities.java | 42 ++++++++++++++++++- .../rt/entity/RtLocalViewBudgetTest.java | 24 +++++------ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 03115bbb..351cf0e0 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -90,7 +90,7 @@ public final class RtFrameStats { "entityFrameListsWaits", "entityTableWaits", "entitySlotWaits", "entityGraphicsWaitNanos", "entityMotionFlushes", "entityTableFlushes", "entityBlockEntityRetirements", "entitySlotRetirements", "entityTableRetirements", - "firstPersonInstances"}, + "firstPersonInstances", "localViewInstances", "worldStandInInstances"}, true); private static final List GC_BEANS = ManagementFactory.getGarbageCollectorMXBeans(); 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 bd3dc87e..fc0e1ae4 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; @@ -232,6 +233,8 @@ void set(float cx, float cy, float cz, int rbx, int rby, int rbz) { 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. @@ -727,12 +730,15 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie // 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. - if (firstPersonSelf && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value() - && maxEntities() - build.logicalCount >= 2) { + boolean localViewEligible = firstPersonSelf + && CausticaConfig.Rt.Entities.FIRST_PERSON_COMPAT_ENABLED.value(); + if (localViewEligible && admitsLocalView(maxEntities(), build.logicalCount)) { FirstPersonCapture fpReady = captureFirstPerson(build, dispatcher, entity, partial, id); if (fpReady != null) { publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz); } + } else if (localViewEligible) { + warnLocalViewBudgetExhausted(); } capture.reset(prev != null ? prev.size / 3 : 0); try { @@ -817,6 +823,12 @@ && maxEntities() - build.logicalCount >= 2) { } 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; @@ -907,6 +919,31 @@ private FirstPersonCapture captureFirstPerson(FrameBuild build, EntityRenderDisp (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."); + } + /** * 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 @@ -931,6 +968,7 @@ private void publishFirstPerson(RtContext ctx, FrameBuild build, FirstPersonCapt lastFirstPersonProviderId = ready.providerId(); build.logicalCount++; RtFrameStats.FRAME.count("firstPersonInstances", 1); + RtFrameStats.FRAME.count("localViewInstances", 1); } /** diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java index 4a81befa..9d8f27da 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtLocalViewBudgetTest.java @@ -10,32 +10,28 @@ * 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 - * precheck is what keeps the second write inside the buffer, so its arithmetic is asserted here directly. + * 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 covers the admission arithmetic and the - * resulting write indices, and the rest of P6/P9 stays a review item. + * 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 { - /** Mirrors the precheck in {@code captureEntities}: room for the local view AND the stand-in. */ - private static boolean admitsLocalView(int capacity, int logicalCount) { - return capacity - logicalCount >= 2; - } - @Test void admitsBothRepresentationsWithTwoSlotsLeft() { - assertTrue(admitsLocalView(64, 62)); + assertTrue(RtEntities.admitsLocalView(64, 62)); } @Test void refusesTheLocalViewWithOnlyOneSlotLeft() { - assertFalse(admitsLocalView(64, 63), + assertFalse(RtEntities.admitsLocalView(64, 63), "one free slot must degrade to the world stand-in, not write past the table"); } @Test void refusesTheLocalViewWhenAlreadyFull() { - assertFalse(admitsLocalView(64, 64)); + assertFalse(RtEntities.admitsLocalView(64, 64)); } /** @@ -60,11 +56,11 @@ void writeIndicesStayInsideTheTableAcrossEveryOccupancy() { } /** - * Highest geometry-table index a camera-entity iteration writes, given the table occupancy it starts - * from. writeTableEntry indexes by the pre-increment physical count, so an admitted pair writes + * 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 admitsLocalView(capacity, logicalCount) ? logicalCount + 1 : logicalCount; + return RtEntities.admitsLocalView(capacity, logicalCount) ? logicalCount + 1 : logicalCount; } } From bf84a92c709d452fd27e93d4fe7334ceca8cc3cd Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 05:00:29 +0800 Subject: [PATCH 06/13] Register terrain.lightGridPublish in the frame-stats whitelist 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. --- src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 351cf0e0..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", From dcafa79432c6cf67b242f88be65cc250640f1694 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 12:42:35 +0800 Subject: [PATCH 07/13] Add the reflection visibility domain and widen path records to 64 bytes 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. --- shaders/pipelines/world/guides.slang | 6 +- shaders/pipelines/world/indirect.rgen.slang | 61 +++-- shaders/pipelines/world/medium.slang | 49 ++-- shaders/pipelines/world/primary.rgen.slang | 42 ++-- shaders/pipelines/world/segment.slang | 92 +++++--- shaders/pipelines/world/trace.slang | 44 +++- .../comfyfluffy/caustica/rt/RtComposite.java | 4 +- .../rt/PathSegmentPackingRoundTripTest.java | 211 ++++++++++++++++++ .../rt/entity/RtVisibilityDomainTest.java | 23 +- 9 files changed, 436 insertions(+), 96 deletions(-) create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 400aaa99..9472b251 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -215,9 +215,9 @@ public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 tra } 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; + Medium entered = makeDielectricMedium(isWater ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, + max(payloadIor(), 1.0), payloadAlbedo(), transmission); + float etaT = entering ? entered.ior : medium.parent1.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. diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 4ad0c70c..3cbcda59 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -73,24 +73,34 @@ 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; - // The domain the next secondary ray belongs to. Re-derived from each hit below, so it tracks the - // surface a ray leaves rather than sticking to the path. - uint nextMask = seg.localViewSecondary ? CULL_LOCAL_VIEW_SECONDARY : CULL_SECONDARY; + // 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; + bool reflectionClassLobe = false; 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) { + nextDomain = continuationDomainForLobe(baseSurfaceDomain, reflectionClassLobe); + } // 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 world stand-in. - // Every later ray takes the secondary domain of the surface it leaves — CULL_SECONDARY for world - // surfaces (excludes particles, includes the stand-in) or CULL_LOCAL_VIEW_SECONDARY for the - // local-view representation (which the world stand-in is invisible to, and vice versa). + // 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 : nextMask, 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 : nextMask, ro, 0.0, rd, 10000.0, + traceRadiance(bounce == 0 ? CULL_PRIMARY : secondaryMaskForDomain(nextDomain), + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); #endif @@ -98,7 +108,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; } @@ -110,12 +120,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { break; } - // Latch this hit's outgoing domain before anything can overwrite the global payload, and carry it - // to the next iteration HERE — the single assignment point in the loop. Every shadow/reservoir ray - // cast from this vertex uses surfaceMask. Assigning nextMask per-branch instead would leave a new - // upstream continuation path silently inheriting the wrong domain without a rebase conflict. - uint surfaceMask = secondaryMaskForSurface(payload.flags); - nextMask = surfaceMask; + // 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); + reflectionClassLobe = false; // 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 @@ -165,9 +175,10 @@ 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); + Medium entered = makeDielectricMedium(isWater ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, + max(payloadIor(), 1.0), tint, transmission); float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.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); @@ -194,6 +205,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { mediumPop(medium); } } + reflectionClassLobe = chooseReflection; showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); @@ -223,7 +235,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(surfaceMask, 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; } @@ -236,7 +248,7 @@ 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(surfaceMask, r, hitPos, n, v, rd, albedo, + L += throughput * shadeReservoir(baseSurfaceMask, r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0); } @@ -298,12 +310,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(surfaceMask, 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) { @@ -329,7 +341,7 @@ 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(surfaceMask, r, hitPos, n, v, rd, diffAlb, F0, rough, + L += throughput * shadeReservoir(baseSurfaceMask, r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss); } @@ -341,12 +353,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(surfaceMask, hitPos - n * SURF_BIAS, + 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); } @@ -369,6 +381,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) { + reflectionClassLobe = true; 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/medium.slang b/shaders/pipelines/world/medium.slang index f682cfd3..c2a27c80 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,71 @@ 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. Three layers cover +// air->water->glass plus a held dielectric, 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. +// +// Air and water own fixed identities; MEDIUM_ID_GENERIC_DIELECTRIC covers every other dielectric. +// Identity comparisons are integer-only — optical parameters never decide what a medium IS. +public static const uint MEDIUM_ID_AIR = 0u; +public static const uint MEDIUM_ID_WATER = 1u; +public static const uint MEDIUM_ID_GENERIC_DIELECTRIC = 2u; + 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 16-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; + stack.parent2 = stack.parent1; + stack.parent1 = stack.current; stack.current = entered; } public void mediumPop(inout MediumStack stack) { - stack.current = stack.outer; - stack.outer = airMedium(); + stack.current = stack.parent1; + stack.parent1 = stack.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 6793f40f..26fb792b 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -43,7 +43,7 @@ public PathSegment tracePrimary(PathSegment seg, // 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, false); + showCelestial, SECONDARY_DOMAIN_WORLD, false); traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); @@ -89,11 +89,13 @@ public PathSegment tracePrimary(PathSegment seg, float3 v = -rd; gv_albedo = diffAlb; gv_rough = rough; - // An opaque/cutout local-view surface reflects in its own domain, so this cannot take - // the world-domain convenience overload. Particles above always belong to the world. + // 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)), - secondaryMaskForSurface(payload.flags)); + payloadSurfaceLocalView() + ? CULL_REFLECTION : CULL_SECONDARY); } } @@ -131,17 +133,22 @@ public PathSegment tracePrimary(PathSegment seg, } float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payloadAlbedo(), transmission); + Medium entered = makeDielectricMedium(isWater ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, + max(payloadIor(), 1.0), payloadAlbedo(), transmission); float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; + float etaT = entering ? entered.ior : medium.parent1.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; - // This interface's domain, read once here — before any guide probe below overwrites the global - // payload. Every continuation and guide mask in this branch derives from these two. + // 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 interfaceSecondaryMask = secondaryMaskForSurface(payload.flags); + uint interfaceSecondaryDomain = secondaryDomainForSurface(payload.flags); + uint interfaceSecondaryMask = secondaryMaskForDomain(interfaceSecondaryDomain); + uint interfaceReflectionDomain = interfaceLocalView + ? SECONDARY_DOMAIN_REFLECTION : SECONDARY_DOMAIN_WORLD; if (bounce == 0) { gv_normal = n; @@ -153,7 +160,8 @@ 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), interfaceSecondaryMask); + gv_rough, float3(F, F, F), + secondaryMaskForDomain(interfaceReflectionDomain)); if (dot(transmittedDir, transmittedDir) > 0.0) { MediumStack guideMedium = medium; if (entering) { @@ -187,7 +195,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceLocalView); + true, interfaceSecondaryDomain, false); queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); nextRecord = splitRecord; float3 reflectedDir = reflect(rd, n); @@ -195,7 +203,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, - true, interfaceLocalView); + true, interfaceReflectionDomain, false); return reflected; } @@ -207,7 +215,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceLocalView); + true, interfaceReflectionDomain, false); } else { float3 deferredDir = normalize(transmittedDir); if (entering) { @@ -219,7 +227,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceLocalView); + true, interfaceSecondaryDomain, false); } return continuation; } @@ -244,7 +252,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; @@ -253,7 +261,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, false); + 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 64c2978c..9ecee19b 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,14 +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. Meaningless at bounce 0, which is - // always traced as the camera ray; normalized to false there so the packed round-trip is an equality. - public bool localViewSecondary; + // 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, bool localViewSecondary) { + int bounce, bool showCelestial, uint secondaryDomain, + bool cameraTransmissionContinuity) { PathSegment s; s.ro = ro; s.rd = rd; @@ -43,17 +50,24 @@ public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, Medi s.seed = seed; s.bounce = bounce; s.showCelestial = showCelestial; - s.localViewSecondary = localViewSecondary; + 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; // u16 current.mediumId | u16 parent1.mediumId << 16 + public uint mediumId2; // u16 parent2.mediumId, high half unused public uint rayCone; public uint seed; public uint pathFlags; @@ -61,9 +75,13 @@ public struct PackedPathSegment { }; public static const uint PATH_NO_NEXT = 0xffffffffu; -// pathFlags bit 11. Occupied so far: bits 0..3 bounce, 8 showCelestial, 9/10 water. The 48-byte stride -// is unchanged and bits 4..7 and 12..31 stay free. -public static const uint PATH_LOCAL_VIEW_SECONDARY = 1u << 11; +// 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); @@ -119,37 +137,51 @@ 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 & 0xffffu) + | ((seg.medium.parent1.mediumId & 0xffffu) << 16u); + p.mediumId2 = seg.medium.parent2.mediumId & 0xffffu; 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) - | (seg.localViewSecondary ? PATH_LOCAL_VIEW_SECONDARY : 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 & 0xffffu; + Medium parent1; + parent1.ior = iors01.y; + parent1.extinction = unpackRgb9e5(p.parent1Extinction); + parent1.mediumId = p.mediumIds01 >> 16u; + Medium parent2; + parent2.ior = unpackHalf2(p.mediumIor2).x; + parent2.extinction = unpackRgb9e5(p.parent2Extinction); + parent2.mediumId = p.mediumId2 & 0xffffu; 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, - (p.pathFlags & PATH_LOCAL_VIEW_SECONDARY) != 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 6dc8840b..57b09f1b 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -12,6 +12,18 @@ import bindings; 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; + public static const uint TERRAIN_BUCKETS = 4u; public static const uint SBT_RADIANCE = 0u; public static const uint SBT_SHADOW = TERRAIN_BUCKETS; @@ -19,12 +31,38 @@ public static const uint SBT_STRIDE_BUCKET = 1u; public static const uint MISS_RADIANCE = 0u; public static const uint MISS_GUIDE = 1u; -// The domain a secondary ray inherits from the surface it leaves. The flags word is an explicit +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: world surfaces +// keep the world domain for every lobe, while 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, bool reflectionClass) { + if (baseSurfaceDomain == SECONDARY_DOMAIN_LOCAL_VIEW) { + return reflectionClass ? 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 secondaryMaskForSurface(uint payloadFlags) { +public uint secondaryDomainForSurface(uint payloadFlags) { return (payloadFlags & PAYLOAD_SURFACE_LOCAL_VIEW) != 0u - ? CULL_LOCAL_VIEW_SECONDARY : CULL_SECONDARY; + ? 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) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index d65090f8..8d33d42b 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(); } 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..f1d242e6 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java @@ -0,0 +1,211 @@ +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 u16 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() & 0xFFFF) | ((stack.parent1().mediumId() & 0xFFFF) << 16), + stack.parent2().mediumId() & 0xFFFF, + pathFlags); + } + + private static Segment unpack(Packed p) { + Layer current = new Layer(halfLow(p.mediumIors01()), unpackRgb9e5(p.currentExtinction()), + p.mediumIds01() & 0xFFFF); + Layer parent1 = new Layer(halfHigh(p.mediumIors01()), unpackRgb9e5(p.parent1Extinction()), + p.mediumIds01() >>> 16); + Layer parent2 = new Layer(halfLow(p.mediumIor2()), unpackRgb9e5(p.parent2Extinction()), + p.mediumId2() & 0xFFFF); + 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, 4096, 65534}; + 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/RtVisibilityDomainTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java index d88b3a79..7afdea57 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/entity/RtVisibilityDomainTest.java @@ -17,6 +17,7 @@ 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); @@ -30,7 +31,7 @@ void maskConstantsMatchTheShaderDomainBits() throws ReflectiveOperationException 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 third domain must not have widened it. + // The particle mask is primary-only; a new domain must not have widened it. assertEquals(CULL_PRIMARY, mask("PARTICLE_MASK")); } @@ -41,11 +42,13 @@ void visibilityMatchesTheFrozenDomainTable() throws ReflectiveOperationException int worldStandIn = mask("MASK_SECONDARY"); int localView = mask("MASK_PRIMARY") | mask("MASK_LOCAL_VIEW_SECONDARY"); - // Rows follow design D1's self-consistency table: camera, world surface, local-view surface. + // 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); } /** @@ -63,6 +66,22 @@ void theTwoRepresentationsAreDisjointOnSecondaryRays() throws ReflectiveOperatio 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); From a9a7353ea5df1154b7d8f4c7972413f8c083e806 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 13:03:40 +0800 Subject: [PATCH 08/13] Pair medium exits by identity instead of stack position 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. --- .../pipelines/world/closest_hit.rchit.slang | 21 +- shaders/pipelines/world/guides.slang | 47 +++-- shaders/pipelines/world/indirect.rgen.slang | 54 ++--- shaders/pipelines/world/medium.slang | 59 ++++-- shaders/pipelines/world/primary.rgen.slang | 80 +++++--- shaders/pipelines/world/world_common.slang | 20 +- shaders/pipelines/world/world_core.slang | 3 + .../rt/material/RtMaterialRegistry.java | 7 + ...diumStackReferenceModelExhaustiveTest.java | 185 ++++++++++++++++++ 9 files changed, 390 insertions(+), 86 deletions(-) create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/MediumStackReferenceModelExhaustiveTest.java diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index 09201f58..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,7 @@ 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; @@ -397,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; } @@ -434,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 9472b251..4602c248 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -215,24 +215,35 @@ public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 tra } float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, - max(payloadIor(), 1.0), payloadAlbedo(), transmission); - float etaT = entering ? entered.ior : medium.parent1.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. - 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) { - mediumPush(medium, entered); - } else { - mediumPop(medium); + 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); } // 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. diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 3cbcda59..31e46f4c 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -174,38 +174,48 @@ 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 ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, - max(payloadIor(), 1.0), tint, transmission); - 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); + // 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) { + 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; + reflectionClassLobe = chooseReflection; + 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); + } } } - reflectionClassLobe = chooseReflection; showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); diff --git a/shaders/pipelines/world/medium.slang b/shaders/pipelines/world/medium.slang index c2a27c80..85da076a 100644 --- a/shaders/pipelines/world/medium.slang +++ b/shaders/pipelines/world/medium.slang @@ -35,15 +35,15 @@ public float3 volumeExtinction(float3 tint, float transmission) { // // 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 the game rarely produces would be a bad trade. Three layers cover -// air->water->glass plus a held dielectric, 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. // -// Air and water own fixed identities; MEDIUM_ID_GENERIC_DIELECTRIC covers every other dielectric. -// Identity comparisons are integer-only — optical parameters never decide what a medium IS. -public static const uint MEDIUM_ID_AIR = 0u; -public static const uint MEDIUM_ID_WATER = 1u; -public static const uint MEDIUM_ID_GENERIC_DIELECTRIC = 2u; +// 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; @@ -79,16 +79,49 @@ public MediumStack makeMediumStack(Medium start) { return s; } -public void mediumPush(inout MediumStack stack, Medium entered) { +// 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 void mediumPop(inout MediumStack stack) { - stack.current = stack.parent1; - stack.parent1 = stack.parent2; - stack.parent2 = airMedium(); +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; +} + +// 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 diff --git a/shaders/pipelines/world/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index 26fb792b..be768d31 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -133,13 +133,31 @@ public PathSegment tracePrimary(PathSegment seg, } float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater ? MEDIUM_ID_WATER : MEDIUM_ID_GENERIC_DIELECTRIC, - max(payloadIor(), 1.0), payloadAlbedo(), transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.parent1.ior; - float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); + Medium entered = makeDielectricMedium(payloadMediumId(), max(payloadIor(), 1.0), + payloadAlbedo(), transmission); 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 @@ -162,18 +180,12 @@ public PathSegment tracePrimary(PathSegment seg, isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), gv_rough, float3(F, F, F), secondaryMaskForDomain(interfaceReflectionDomain)); - if (dot(transmittedDir, transmittedDir) > 0.0) { - MediumStack guideMedium = medium; - if (entering) { - mediumPush(guideMedium, entered); - } else { - mediumPop(guideMedium); - } + 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, - guideMedium, transmitBias, rayConeWidth, rayConeSpread, + transmittedMedium, transmitBias, rayConeWidth, rayConeSpread, !isWater && entering ? payloadAlbedo() : float3(1.0, 1.0, 1.0)); } @@ -181,15 +193,18 @@ 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), @@ -207,6 +222,16 @@ public PathSegment tracePrimary(PathSegment seg, 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. + return makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias), + rd, throughput, transmittedMedium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, interfaceSecondaryDomain, false); + } + bool hasTransmission = dot(transmittedDir, transmittedDir) > 0.0; PathSegment continuation; if (!hasTransmission || F >= 1.0) { @@ -218,14 +243,17 @@ public PathSegment tracePrimary(PathSegment seg, 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, interfaceSecondaryDomain, false); } 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, interfaceSecondaryDomain, false); } diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index 326a2c85..08382622 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -168,7 +168,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, bit 7 emitter-in-list, bit 8 local-view surface. + 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..24 medium identity, bit 25 ice surface. public uint roughMetal; // packHalf2x16(roughness, metalness) public uint emissionSss; // packHalf2x16(emission, sss) public uint iorTransmission; // packHalf2x16(IOR, transmission factor) @@ -183,6 +183,13 @@ public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; // 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 16-bit canonical identity 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 = 0xffffu << PAYLOAD_MEDIUM_ID_SHIFT; +// Set by world.rchit when the hit material carries MATERIAL_FEATURE_ICE. +public static const uint PAYLOAD_SURFACE_ICE = 1u << 25; // 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 @@ -208,6 +215,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 +// 16-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 @@ -247,6 +263,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 diff --git a/shaders/pipelines/world/world_core.slang b/shaders/pipelines/world/world_core.slang index 50e2823c..fac84361 100644 --- a/shaders/pipelines/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -36,6 +36,9 @@ public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_ // 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; } +// The 16-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/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 0b027736..1661e22b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -46,6 +46,9 @@ public final class RtMaterialRegistry { public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; public static final int FEATURE_STOCHASTIC_ALPHA = 16; + // Largest header count whose every slot still packs as a 16-bit GPU medium identity: a dielectric's + // identity is materialId + 2 (closest_hit.rchit.slang), with 0 and 1 reserved for air and water. + private static final int MAX_MEDIUM_IDENTITY_RECORDS = 65533; // 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. @@ -258,6 +261,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/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"); + } +} From 0602e14921305f2e8333e812ad035c62f9172ad7 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 13:35:58 +0800 Subject: [PATCH 09/13] Soften the first-person body shadow and give ice its own shadow policy 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. --- shaders/pipelines/world/any_hit.rahit.slang | 42 +++++++++++++++---- shaders/pipelines/world/world_common.slang | 8 +++- .../comfyfluffy/caustica/CausticaConfig.java | 3 ++ .../caustica/client/RtVideoOptions.java | 13 ++++++ .../comfyfluffy/caustica/rt/RtComposite.java | 4 +- .../caustica/rt/accel/RtAccel.java | 17 ++++++-- .../caustica/rt/entity/RtEntities.java | 32 ++++++++++---- .../rt/material/RtMaterialRegistry.java | 14 +++++++ .../resources/assets/caustica/lang/en_us.json | 3 ++ 9 files changed, 114 insertions(+), 22 deletions(-) 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/world_common.slang b/shaders/pipelines/world/world_common.slang index 08382622..9e011b47 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -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 @@ -302,8 +305,11 @@ 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. +// (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/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 538f4d70..bddee234 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -618,6 +618,9 @@ public static final class Entities { 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() { } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index c86f3800..da801be6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -49,6 +49,7 @@ public static OptionInstance[] runtimeOptions() { entities(), particles(), firstPersonCompat(), + firstPersonShadowTransmittance(), waterWaves(), dlssQuality() )); @@ -137,6 +138,18 @@ private static OptionInstance 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/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 8d33d42b..3b63fef6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -1158,7 +1158,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/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/RtEntities.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java index fc0e1ae4..694c3103 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -86,8 +86,9 @@ 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 flag; must stay in lock-step with {@code world_common.slang}. */ + /** {@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 // 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). @@ -714,6 +715,9 @@ 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; @@ -813,13 +817,14 @@ 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), 0); + translationTransform(ix - rbx, iy - rby, iz - rbz), entityGeomFlags); } build.logicalCount++; RtFrameStats.FRAME.count("entitiesCaptured", 1); @@ -1528,7 +1533,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()) { @@ -1578,10 +1583,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, 0); + 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; @@ -1748,7 +1755,8 @@ private void appendTransientCapture(RtContext ctx, FrameBuild build, RtEntityCap 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++; } @@ -1826,7 +1834,8 @@ private void appendPackedEntity(RtContext ctx, FrameBuild build, Motion motion, writeTableEntry(build, primAddr, indexAddr, uvAddr, motion.dispAddr, 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); @@ -1912,6 +1921,13 @@ private void writeTableEntry(FrameBuild build, long primAddr, long idxAddr, long 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 1661e22b..98f91aaa 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,18 @@ 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 16-bit GPU medium identity: a dielectric's // identity is materialId + 2 (closest_hit.rchit.slang), with 0 and 1 reserved for air and water. private static final int MAX_MEDIUM_IDENTITY_RECORDS = 65533; @@ -180,6 +191,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 diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index d687fc83..3fcc8678 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -30,6 +30,9 @@ "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.", From f8866a56d85972e5605d89dd50798eafecbf4b5d Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 14:31:24 +0800 Subject: [PATCH 10/13] Publish a same-frame local-view presence signal 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. --- shaders/pipelines/world/world_common.slang | 2 +- .../comfyfluffy/caustica/rt/RtComposite.java | 8 +++- .../caustica/rt/entity/RtEntities.java | 44 +++++++++++++++---- .../entity/LocalViewPublicationStateTest.java | 42 ++++++++++++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/entity/LocalViewPublicationStateTest.java diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index 9e011b47..51f6bc4e 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 diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 3b63fef6..9b41daad 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -1064,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) { @@ -1117,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 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 694c3103..f06fe16b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -364,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) { @@ -603,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; @@ -626,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); @@ -659,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(); @@ -669,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. */ @@ -736,10 +742,13 @@ private void captureEntities(RtContext ctx, FrameBuild build, Minecraft mc, Clie // 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(); - if (localViewEligible && admitsLocalView(maxEntities(), build.logicalCount)) { + boolean localViewAdmitted = localViewEligible + && admitsLocalView(maxEntities(), build.logicalCount); + if (localViewAdmitted) { FirstPersonCapture fpReady = captureFirstPerson(build, dispatcher, entity, partial, id); if (fpReady != null) { - publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz); + build.localViewPublished = localViewPresence(localViewEligible, localViewAdmitted, + true, publishFirstPerson(ctx, build, fpReady, rbx, rby, rbz)); } } else if (localViewEligible) { warnLocalViewBudgetExhausted(); @@ -949,14 +958,30 @@ private void warnLocalViewBudgetExhausted() { + "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 void publishFirstPerson(RtContext ctx, FrameBuild build, FirstPersonCapture ready, + 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 @@ -974,6 +999,7 @@ private void publishFirstPerson(RtContext ctx, FrameBuild build, FirstPersonCapt build.logicalCount++; RtFrameStats.FRAME.count("firstPersonInstances", 1); RtFrameStats.FRAME.count("localViewInstances", 1); + return true; } /** 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"); + } +} From cd6ac92f8604a2ed7a2590a3300622019ae98283 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 14:32:25 +0800 Subject: [PATCH 11/13] Keep camera-visible transmission chains on the local-view representation 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. --- shaders/pipelines/world/guides.slang | 24 ++++++++++++++++++--- shaders/pipelines/world/indirect.rgen.slang | 18 +++++++++++----- shaders/pipelines/world/primary.rgen.slang | 19 ++++++++++------ shaders/pipelines/world/trace.slang | 23 +++++++++++++++----- shaders/pipelines/world/world_core.slang | 1 + 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 4602c248..a985cdce 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -170,6 +170,13 @@ public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 tra 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); @@ -214,6 +221,15 @@ public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 tra worldPush.waterParams.w, waterFootprint); } + // 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, + float3(payload.motionPrev), interfaceNormal, 0.0, + float3(0.0, 0.0, 0.0), false); + return; + } + float transmission = clamp(payloadTransmission(), 0.0, 1.0); Medium entered = makeDielectricMedium(payloadMediumId(), max(payloadIor(), 1.0), payloadAlbedo(), transmission); @@ -245,9 +261,11 @@ public void resolveTransmissionGuide(uint rayMask, float3 surfacePos, float3 tra } else if (mediumExitIsDeep(exitMatch)) { mediumCommitExit(medium, exitMatch); } - // 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); + 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, isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS); diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 31e46f4c..2b180875 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -77,13 +77,20 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // iteration re-selects it below, at the loop's single continuation-domain assignment point. uint nextDomain = normalizeSecondaryDomain(seg.secondaryDomain); uint baseSurfaceDomain = SECONDARY_DOMAIN_WORLD; - bool reflectionClassLobe = false; + 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) { - nextDomain = continuationDomainForLobe(baseSurfaceDomain, reflectionClassLobe); + 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. @@ -125,7 +132,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // domain is selected separately, at the top of the next iteration, once the lobe is known. baseSurfaceDomain = secondaryDomainForSurface(payload.flags); uint baseSurfaceMask = secondaryMaskForDomain(baseSurfaceDomain); - reflectionClassLobe = false; + 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 @@ -188,6 +195,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; if (!opticalEvent) { + scatterClass = SCATTER_TRANSMISSION; if (mediumExitIsDeep(exitMatch)) { mediumCommitExit(medium, exitMatch); } @@ -199,7 +207,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float F = fresnelDielectric(cosI, etaI, etaT); float3 transmittedDir = refract(rd, n, etaI / etaT); bool chooseReflection = rndf(seed) < F; - reflectionClassLobe = chooseReflection; + scatterClass = chooseReflection ? SCATTER_REFLECTION : SCATTER_TRANSMISSION; if (chooseReflection) { rd = reflect(rd, n); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); @@ -391,7 +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) { - reflectionClassLobe = true; + 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/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index be768d31..40d54bec 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -36,6 +36,9 @@ 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; { @@ -43,7 +46,7 @@ public PathSegment tracePrimary(PathSegment seg, // 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, SECONDARY_DOMAIN_WORLD, false); + showCelestial, SECONDARY_DOMAIN_WORLD, localViewPresent); traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); @@ -165,6 +168,10 @@ public PathSegment tracePrimary(PathSegment seg, 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; @@ -210,7 +217,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceSecondaryDomain, false); + true, transmissionSecondaryDomain, localViewPresent); queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); nextRecord = splitRecord; float3 reflectedDir = reflect(rd, n); @@ -224,12 +231,12 @@ public PathSegment tracePrimary(PathSegment seg, if (!opticalEvent) { // A deep or unmatched exit is optically inert: continue straight with unchanged direction - // and weight, carrying at most the deep removal. + // 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, interfaceSecondaryDomain, false); + true, transmissionSecondaryDomain, localViewPresent); } bool hasTransmission = dot(transmittedDir, transmittedDir) > 0.0; @@ -249,13 +256,13 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, float3(0.0, 0.0, 0.0), medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceSecondaryDomain, false); + true, transmissionSecondaryDomain, localViewPresent); } continuation = makePathSegment( offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, interfaceSecondaryDomain, false); + true, transmissionSecondaryDomain, localViewPresent); } return continuation; } diff --git a/shaders/pipelines/world/trace.slang b/shaders/pipelines/world/trace.slang index 57b09f1b..f1a2ddaf 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -24,6 +24,12 @@ 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; @@ -43,12 +49,19 @@ public uint secondaryMaskForDomain(uint domain) { return CULL_SECONDARY; } -// A continuation ray's domain, decided once per hit after the scatter lobe is chosen: world surfaces -// keep the world domain for every lobe, while 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, bool reflectionClass) { +// 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 reflectionClass ? SECONDARY_DOMAIN_REFLECTION : SECONDARY_DOMAIN_LOCAL_VIEW; + return scatterClass == SCATTER_REFLECTION + ? SECONDARY_DOMAIN_REFLECTION : SECONDARY_DOMAIN_LOCAL_VIEW; } return SECONDARY_DOMAIN_WORLD; } diff --git a/shaders/pipelines/world/world_core.slang b/shaders/pipelines/world/world_core.slang index fac84361..7e6f6e41 100644 --- a/shaders/pipelines/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -36,6 +36,7 @@ public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_ // 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 16-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; } From 7049165b1f9c9b867544ad4be9eabccec3093953 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Sun, 9 Aug 2026 14:53:37 +0800 Subject: [PATCH 12/13] Reject NaN in clamped float settings 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. --- .../comfyfluffy/caustica/CausticaConfig.java | 5 +++- .../caustica/CausticaConfigTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index bddee234..74fb69ed 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -962,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/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); + } + } } From 26d23d445d17f9bf26fd9e14e2a604db185d1eba Mon Sep 17 00:00:00 2001 From: Overhatch Date: Mon, 10 Aug 2026 22:07:03 +0800 Subject: [PATCH 13/13] Extend the RT medium identity space from 16 to 20 bits 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 --- shaders/pipelines/world/medium.slang | 2 +- shaders/pipelines/world/segment.slang | 17 +++++----- shaders/pipelines/world/world_common.slang | 14 ++++---- shaders/pipelines/world/world_core.slang | 2 +- .../rt/material/RtMaterialRegistry.java | 5 +-- .../rt/PathSegmentPackingRoundTripTest.java | 16 +++++---- .../RtMaterialRegistryCapacityTest.java | 33 +++++++++++++++++++ 7 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistryCapacityTest.java diff --git a/shaders/pipelines/world/medium.slang b/shaders/pipelines/world/medium.slang index 85da076a..41e67a6e 100644 --- a/shaders/pipelines/world/medium.slang +++ b/shaders/pipelines/world/medium.slang @@ -48,7 +48,7 @@ public float3 volumeExtinction(float3 tint, float transmission) { public struct Medium { public float ior; public float3 extinction; - public uint mediumId; // canonical 16-bit identity (MEDIUM_ID_*) + public uint mediumId; // canonical 20-bit identity (MEDIUM_ID_*) }; // Water-specific behaviour — the wave-refraction caustic on submerged receivers, the water miss guard — diff --git a/shaders/pipelines/world/segment.slang b/shaders/pipelines/world/segment.slang index 9ecee19b..e0bcf994 100644 --- a/shaders/pipelines/world/segment.slang +++ b/shaders/pipelines/world/segment.slang @@ -66,8 +66,8 @@ public struct PackedPathSegment { public uint parent2Extinction; public uint mediumIors01; // half2(current.ior, parent1.ior) public uint mediumIor2; // half2(parent2.ior, unused) - public uint mediumIds01; // u16 current.mediumId | u16 parent1.mediumId << 16 - public uint mediumId2; // u16 parent2.mediumId, high half 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; @@ -141,9 +141,10 @@ public PackedPathSegment packPathSegment(PathSegment seg, uint nextRecord) { 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 & 0xffffu) - | ((seg.medium.parent1.mediumId & 0xffffu) << 16u); - p.mediumId2 = seg.medium.parent2.mediumId & 0xffffu; + 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; // The one normalization site: a bounce-0 record is always replayed as the camera ray, so its domain @@ -163,15 +164,15 @@ public PathSegment unpackPathSegment(PackedPathSegment p) { Medium current; current.ior = iors01.x; current.extinction = unpackRgb9e5(p.currentExtinction); - current.mediumId = p.mediumIds01 & 0xffffu; + current.mediumId = p.mediumIds01 & 0xfffffu; Medium parent1; parent1.ior = iors01.y; parent1.extinction = unpackRgb9e5(p.parent1Extinction); - parent1.mediumId = p.mediumIds01 >> 16u; + 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 & 0xffffu; + parent2.mediumId = (p.mediumId2 >> 8u) & 0xfffffu; MediumStack medium; medium.current = current; medium.parent1 = parent1; diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index 51f6bc4e..a537d3fa 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -171,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, bit 7 emitter-in-list, bit 8 local-view surface, bits 9..24 medium identity, bit 25 ice surface. + 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) @@ -186,13 +186,13 @@ public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; // 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 16-bit canonical identity 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. +// 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 = 0xffffu << PAYLOAD_MEDIUM_ID_SHIFT; +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 << 25; +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 @@ -222,7 +222,7 @@ public static const uint MATERIAL_DIELECTRIC = 3u; // 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 -// 16-bit identity space. +// 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; diff --git a/shaders/pipelines/world/world_core.slang b/shaders/pipelines/world/world_core.slang index 7e6f6e41..7c08f62c 100644 --- a/shaders/pipelines/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -37,7 +37,7 @@ public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_ // 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 16-bit canonical identity of the medium behind the current hit's dielectric face (see +// 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 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 98f91aaa..7e733c56 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -57,9 +57,10 @@ public final class RtMaterialRegistry { 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 16-bit GPU medium identity: a dielectric's + // 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. - private static final int MAX_MEDIUM_IDENTITY_RECORDS = 65533; + // 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. diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java index f1d242e6..91ef6247 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/PathSegmentPackingRoundTripTest.java @@ -9,7 +9,7 @@ /** * 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 u16 identity per + * 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. @@ -68,18 +68,20 @@ private static Packed pack(Segment s) { packRgb9e5(stack.parent2().extinction()), packHalf2(stack.current().ior(), stack.parent1().ior()), packHalf2(stack.parent2().ior(), 0.0f), - (stack.current().mediumId() & 0xFFFF) | ((stack.parent1().mediumId() & 0xFFFF) << 16), - stack.parent2().mediumId() & 0xFFFF, + (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() & 0xFFFF); + p.mediumIds01() & 0xFFFFF); Layer parent1 = new Layer(halfHigh(p.mediumIors01()), unpackRgb9e5(p.parent1Extinction()), - p.mediumIds01() >>> 16); + (p.mediumIds01() >>> 20) | ((p.mediumId2() & 0xFF) << 12)); Layer parent2 = new Layer(halfLow(p.mediumIor2()), unpackRgb9e5(p.parent2Extinction()), - p.mediumId2() & 0xFFFF); + (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, @@ -133,7 +135,7 @@ private static float clampRgb9e5(float v) { @Test void roundTripPreservesDomainContinuityBounceAndStack() { Random random = new Random(0x5eedcafe); - int[] ids = {0, 1, 2, 7, 4096, 65534}; + 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}) { 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); + } +}