From 37f5a90cac8187bd9287fc3c2e476fd4b35029e1 Mon Sep 17 00:00:00 2001 From: Overhatch Date: Wed, 5 Aug 2026 00:17:27 +0800 Subject: [PATCH] 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",