requested = args.get(0);
var augmented = new ArrayList<>(requested);
- for (String extension : CAUSTICA_WANTED_EXTENSIONS) {
+ for (String extension : FFX_WANTED_EXTENSIONS) {
if (augmented.contains(extension)) {
continue;
}
@@ -102,6 +96,10 @@ public abstract class VulkanBackendMixin {
extension, physicalDevice.deviceName());
}
}
+ if ("NVIDIA".equals(physicalDevice.vendorName())) {
+ NgxRuntime.INSTANCE.negotiateRequiredExtensions(true, augmented,
+ physicalDevice::hasDeviceExtension);
+ }
VulkanDiagnostics.addDeviceFaultExtension(augmented, physicalDevice);
RtHdr.addDeviceExtension(augmented, physicalDevice);
RtDeviceBringup.addExtensions(augmented, physicalDevice);
diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java
index a6564e0d..3d495672 100644
--- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java
+++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java
@@ -322,8 +322,8 @@ public abstract class VulkanGpuSurfaceMixin {
*/
@Inject(method = "blitFromTexture", at = @At("HEAD"), cancellable = true)
private void caustica$presentHdr(CommandEncoderBackend commandEncoder, GpuTextureView textureView, CallbackInfo ci) {
- // The mastering peak is a live option and selects a different baked ACES output LUT without forcing
- // swapchain recreation. Refresh the metadata once when that selected LUT changes.
+ // The mastering peak is a live option. ACES selects the nearest packaged LUT while analytical modes
+ // use the exact configured peak; neither requires swapchain recreation. Refresh metadata on change.
caustica$applyHdrMetadataIfNeeded();
if (this.currentImageIndex < 0) {
return;
@@ -357,7 +357,7 @@ public abstract class VulkanGpuSurfaceMixin {
|| !RtHdr.metadataExtensionEnabled() || this.swapchain == 0L) {
return;
}
- int peakNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value();
+ int peakNits = CausticaConfig.Rt.Hdr.effectivePeakNits();
if (this.caustica$metadataSwapchain == this.swapchain
&& this.caustica$metadataPeakNits == peakNits) {
return;
diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java
index a0966e0d..3728ba9b 100644
--- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java
+++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java
@@ -3,6 +3,7 @@
import com.llamalad7.mixinextras.sugar.Local;
import com.mojang.blaze3d.vulkan.VulkanInstance;
import dev.comfyfluffy.caustica.CausticaMod;
+import dev.comfyfluffy.caustica.ngx.NgxRuntime;
import dev.comfyfluffy.caustica.rt.VulkanDiagnostics;
import java.util.Set;
import org.lwjgl.vulkan.VkInstanceCreateInfo;
@@ -15,10 +16,9 @@
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
- * Enables {@code VK_EXT_swapchain_colorspace} at instance creation when the platform supports it. The
- * extension exposes extended/HDR color spaces to {@code vkGetPhysicalDeviceSurfaceFormatsKHR}, allowing
- * {@code VulkanGpuSurfaceMixin} to select an HDR10/PQ swapchain pair. The extension only adds color-space
- * enum values; swapchain creation still explicitly chooses the active pair.
+ * Adds Caustica's supported Vulkan instance extensions before instance creation. Swapchain colorspace
+ * exposes HDR color spaces to {@code vkGetPhysicalDeviceSurfaceFormatsKHR}; NGX requirements come from the
+ * selected shim so its instance and device contracts stay in sync.
*
* Gated on availability — requesting an unsupported instance extension would fail {@code vkCreateInstance}
* and crash startup.
@@ -41,6 +41,8 @@ public abstract class VulkanInstanceMixin {
} else {
CausticaMod.LOGGER.warn("Instance extension {} unavailable; HDR color spaces will not be queryable on this platform", SWAPCHAIN_COLORSPACE);
}
+ NgxRuntime.INSTANCE.negotiateRequiredExtensions(false, this.enabledExtensions,
+ availableExtensions::contains);
}
@ModifyArg(
diff --git a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java
index bcfea2a2..164a5924 100644
--- a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java
+++ b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java
@@ -18,8 +18,10 @@
* Vulkan handles (as {@code long} addresses).
*/
public final class NgxLibrary {
+ private static final int ABI_VERSION = 1;
private static final Linker LINKER = Linker.nativeLinker();
+ private final MethodHandle abiVersion;
private final MethodHandle requiredExtensions;
private final MethodHandle init;
private final MethodHandle dlssAvailable;
@@ -39,6 +41,8 @@ public final class NgxLibrary {
private final MethodHandle lastResult;
private NgxLibrary(SymbolLookup lookup) {
+ this.abiVersion = handle(lookup, "ngxshim_abi_version",
+ FunctionDescriptor.of(ValueLayout.JAVA_INT));
// int ngxshim_required_extensions(int wantDevice, char* outBuf, int bufLen)
this.requiredExtensions = handle(lookup, "ngxshim_required_extensions",
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT));
@@ -79,8 +83,8 @@ private NgxLibrary(SymbolLookup lookup) {
this.createDlssd = handle(lookup, "ngxshim_create_dlssd",
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT,
ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
- // int ngxshim_evaluate_dlssd(cmd, feature, [color/depth/mv/diffAlbedo/specAlbedo/normals/specMotion/specHit/out: view,img,fmt]*9, rw,rh,dw,dh, jx,jy,mvsx,mvsy, reset, frameMs, matrices)
- this.evaluateDlssd = handle(lookup, "ngxshim_evaluate_dlssd",
+ // int ngxshim_evaluate_dlssd_v2(cmd, feature, [color/depth/mv/diffAlbedo/specAlbedo/normals/specMotion/particle/responsivity/out: view,img,fmt]*10, rw,rh,dw,dh, jx,jy,mvsx,mvsy, reset, frameMs, matrices)
+ this.evaluateDlssd = handle(lookup, "ngxshim_evaluate_dlssd_v2",
FunctionDescriptor.of(ValueLayout.JAVA_INT,
ValueLayout.JAVA_LONG, ValueLayout.ADDRESS,
ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
@@ -92,6 +96,7 @@ private NgxLibrary(SymbolLookup lookup) {
ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
+ ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT,
ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT,
ValueLayout.JAVA_INT, ValueLayout.JAVA_FLOAT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
@@ -124,13 +129,18 @@ private NgxLibrary(SymbolLookup lookup) {
this.release = handle(lookup, "ngxshim_release",
FunctionDescriptor.ofVoid(ValueLayout.ADDRESS));
this.shutdown = handle(lookup, "ngxshim_shutdown",
- FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG));
+ FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG));
this.lastResult = handle(lookup, "ngxshim_last_result",
FunctionDescriptor.of(ValueLayout.JAVA_INT));
}
public static NgxLibrary load(Path dll) {
- return new NgxLibrary(SymbolLookup.libraryLookup(dll, Arena.global()));
+ NgxLibrary library = new NgxLibrary(SymbolLookup.libraryLookup(dll, Arena.global()));
+ int actual = library.abiVersion();
+ if (actual != ABI_VERSION) {
+ throw new IllegalStateException("ngxshim ABI mismatch: expected " + ABI_VERSION + ", got " + actual);
+ }
+ return library;
}
private static MethodHandle handle(SymbolLookup lookup, String name, FunctionDescriptor desc) {
@@ -139,13 +149,19 @@ private static MethodHandle handle(SymbolLookup lookup, String name, FunctionDes
desc);
}
- // For exports added later than the core ABI (e.g. DLSSG): a stale locally-built ngxshim.dll (the DLL is
- // not rebuilt by gradle, only copied) must still load so DLSS-RR keeps working — the newer feature just
- // reports unavailable. Returns null when the symbol is absent.
+ // Optional feature exports may be absent while the core shim ABI remains compatible.
private static MethodHandle optionalHandle(SymbolLookup lookup, String name, FunctionDescriptor desc) {
return lookup.find(name).map(sym -> LINKER.downcallHandle(sym, desc)).orElse(null);
}
+ private int abiVersion() {
+ try {
+ return (int) this.abiVersion.invokeExact();
+ } catch (Throwable t) {
+ throw new RuntimeException("ngxshim_abi_version failed", t);
+ }
+ }
+
public int requiredExtensions(boolean wantDevice, MemorySegment outBuf, int bufLen) {
try {
return (int) this.requiredExtensions.invokeExact(wantDevice ? 1 : 0, outBuf, bufLen);
@@ -257,7 +273,8 @@ public int evaluateDlssd(long cmd, MemorySegment feature,
long specularAlbedoView, long specularAlbedoImage, int specularAlbedoFormat,
long normalsView, long normalsImage, int normalsFormat,
long specularMotionView, long specularMotionImage, int specularMotionFormat,
- long specularHitDistanceView, long specularHitDistanceImage, int specularHitDistanceFormat,
+ long particleMaskView, long particleMaskImage, int particleMaskFormat,
+ long responsivityMaskView, long responsivityMaskImage, int responsivityMaskFormat,
long outputView, long outputImage, int outputFormat,
int renderWidth, int renderHeight, int displayWidth, int displayHeight,
float jitterX, float jitterY, float mvScaleX, float mvScaleY,
@@ -272,13 +289,14 @@ public int evaluateDlssd(long cmd, MemorySegment feature,
specularAlbedoView, specularAlbedoImage, specularAlbedoFormat,
normalsView, normalsImage, normalsFormat,
specularMotionView, specularMotionImage, specularMotionFormat,
- specularHitDistanceView, specularHitDistanceImage, specularHitDistanceFormat,
+ particleMaskView, particleMaskImage, particleMaskFormat,
+ responsivityMaskView, responsivityMaskImage, responsivityMaskFormat,
outputView, outputImage, outputFormat,
renderWidth, renderHeight, displayWidth, displayHeight,
jitterX, jitterY, mvScaleX, mvScaleY, reset, frameTimeMs,
worldToViewMatrix, viewToClipMatrix);
} catch (Throwable t) {
- throw new RuntimeException("ngxshim_evaluate_dlssd failed", t);
+ throw new RuntimeException("ngxshim_evaluate_dlssd_v2 failed", t);
}
}
@@ -359,9 +377,9 @@ public void release(MemorySegment feature) {
}
}
- public void shutdown(long vkDevice) {
+ public int shutdown(long vkDevice) {
try {
- this.shutdown.invokeExact(vkDevice);
+ return (int) this.shutdown.invokeExact(vkDevice);
} catch (Throwable t) {
throw new RuntimeException("ngxshim_shutdown failed", t);
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java
index a5cf77ba..27f5bd8f 100644
--- a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java
+++ b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java
@@ -1,15 +1,13 @@
package dev.comfyfluffy.caustica.ngx;
-import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vulkan.VulkanDevice;
import dev.comfyfluffy.caustica.CausticaConfig;
import dev.comfyfluffy.caustica.CausticaMod;
-import dev.comfyfluffy.caustica.mixin.GpuDeviceAccessor;
-
import net.fabricmc.loader.api.FabricLoader;
import org.lwjgl.system.MemoryStack;
+import org.lwjgl.vulkan.VK;
import org.lwjgl.vulkan.VK10;
import org.lwjgl.vulkan.VkInstance;
@@ -25,7 +23,11 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
+import java.util.function.Predicate;
import java.util.stream.Stream;
/**
@@ -43,18 +45,27 @@ public final class NgxRuntime {
private NgxLibrary lib;
private boolean initialized;
private boolean failed;
+ private long initializedDevice;
+ private boolean instanceExtensionsNegotiated;
+ private boolean deviceExtensionsNegotiated;
+ private boolean extensionNegotiationFailed;
private NgxRuntime() {
}
/**
* Ensure NGX is loaded and initialized for {@code device}, returning the shared {@link NgxLibrary}, or
- * {@code null} if it is unavailable. Idempotent; latches failure so it isn't retried every frame
- * (cleared by {@link #shutdown()} so a fresh device can re-init).
+ * {@code null} if it is unavailable. Idempotent; latches initialization failure so it is not retried
+ * every frame. Extension negotiation remains fail-closed for the current Vulkan instance.
*/
public synchronized NgxLibrary acquire(VulkanDevice device) {
if (initialized) {
- return lib;
+ if (initializedDevice == device.vkDevice().address()) {
+ return lib;
+ }
+ if (!shutdown()) {
+ return null;
+ }
}
if (failed) {
return null;
@@ -62,9 +73,11 @@ public synchronized NgxLibrary acquire(VulkanDevice device) {
try {
init(device);
initialized = true;
+ initializedDevice = device.vkDevice().address();
return lib;
} catch (Throwable t) {
failed = true;
+ initializedDevice = 0L;
lib = null;
CausticaMod.LOGGER.error("NGX init failed; DLSS features disabled", t);
return null;
@@ -75,27 +88,85 @@ public synchronized boolean isInitialized() {
return initialized;
}
- /** The shared library once {@link #acquire} has succeeded, else {@code null}. */
- public NgxLibrary library() {
- return lib;
+ /** Allow an explicit render-state recovery action to retry a failed shared NGX initialization. */
+ public synchronized void resetFailureLatch() {
+ if (!initialized && !extensionNegotiationFailed) {
+ failed = false;
+ }
+ }
+
+ /** Query the shim before Vulkan creation and add every NGX-required extension only as one valid set. */
+ public synchronized void negotiateRequiredExtensions(boolean deviceExtensions,
+ Collection requested,
+ Predicate supported) {
+ if (!deviceExtensions && !initialized) {
+ instanceExtensionsNegotiated = false;
+ deviceExtensionsNegotiated = false;
+ extensionNegotiationFailed = false;
+ failed = false;
+ }
+ if (extensionNegotiationFailed) {
+ return;
+ }
+ String scope = deviceExtensions ? "device" : "instance";
+ try {
+ if (!PLATFORM_NATIVES.supported()) {
+ throw new IllegalStateException("NGX natives are not bundled for "
+ + PLATFORM_NATIVES.platformDir());
+ }
+ Path shim = locateShim();
+ if (shim == null) {
+ throw new IllegalStateException(PLATFORM_NATIVES.shimName() + " is unavailable");
+ }
+ if (lib == null) {
+ lib = NgxLibrary.load(shim);
+ }
+ List required = queryRequiredExtensions(lib, deviceExtensions);
+ List missing = required.stream().filter(extension -> !supported.test(extension)).toList();
+ if (!missing.isEmpty()) {
+ throw new IllegalStateException("required " + scope + " extensions are unavailable: " + missing);
+ }
+ for (String extension : required) {
+ if (requested.add(extension)) {
+ CausticaMod.LOGGER.info("Enabling {} extension {} required by NGX", scope, extension);
+ }
+ }
+ if (deviceExtensions) {
+ deviceExtensionsNegotiated = true;
+ } else {
+ instanceExtensionsNegotiated = true;
+ }
+ } catch (Throwable t) {
+ extensionNegotiationFailed = true;
+ failed = true;
+ lib = null;
+ CausticaMod.LOGGER.warn("NGX {} extension negotiation failed; DLSS features disabled", scope, t);
+ }
}
/**
- * Shut down NGX. Call only at device teardown, after every feature has been released. Resolves the
- * device from the current render backend; no-op if NGX was never initialized.
+ * Shut down NGX. Call only at device teardown, after every feature has been released. Uses the
+ * device captured at initialization; no-op if NGX was never initialized. Returns false while NGX
+ * retains native device ownership and the Vulkan device must stay alive.
*/
- public synchronized void shutdown() {
- if (lib != null && initialized
- && ((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device) {
+ public synchronized boolean shutdown() {
+ if (lib != null && initialized && initializedDevice != 0L) {
try {
- lib.shutdown(device.vkDevice().address());
+ int result = lib.shutdown(initializedDevice);
+ if (ngxFailed(result)) {
+ throw new IllegalStateException("ngxshim_shutdown returned 0x"
+ + Integer.toHexString(result));
+ }
} catch (Throwable t) {
- CausticaMod.LOGGER.warn("NGX shutdown failed", t);
+ CausticaMod.LOGGER.warn("NGX shutdown failed; native ownership is retained until restart", t);
+ return false;
}
}
initialized = false;
- failed = false;
+ failed = extensionNegotiationFailed;
+ initializedDevice = 0L;
lib = null;
+ return true;
}
/** NVSDK_NGX_Result: failure when the top 12 bits == 0xBAD. Shared by all NGX feature wrappers. */
@@ -104,6 +175,9 @@ public static boolean ngxFailed(int result) {
}
private void init(VulkanDevice device) {
+ if (extensionNegotiationFailed || !instanceExtensionsNegotiated || !deviceExtensionsNegotiated) {
+ throw new IllegalStateException("NGX Vulkan extensions were not negotiated before device creation");
+ }
if (!PLATFORM_NATIVES.supported()) {
throw new IllegalStateException("NGX natives are not bundled for " + PLATFORM_NATIVES.platformDir());
}
@@ -132,13 +206,17 @@ private void init(VulkanDevice device) {
VkInstance instance = device.vkDevice().getPhysicalDevice().getInstance();
try (Arena arena = Arena.ofConfined()) {
+ long gipa = VK.getFunctionProvider().getFunctionAddress("vkGetInstanceProcAddr");
+ if (gipa == 0L) {
+ throw new IllegalStateException("vkGetInstanceProcAddr is unavailable");
+ }
long gdpa;
try (MemoryStack stack = MemoryStack.stackPush()) {
gdpa = VK10.vkGetInstanceProcAddr(instance, stack.ASCII("vkGetDeviceProcAddr"));
}
int rc = lib.init(0L, wideString(arena, dataPath.toString()),
instance.address(), device.vkDevice().getPhysicalDevice().address(), device.vkDevice().address(),
- 0L, gdpa, wideString(arena, nativesDir == null ? "" : nativesDir.toString()));
+ gipa, gdpa, wideString(arena, nativesDir == null ? "" : nativesDir.toString()));
if (ngxFailed(rc)) {
throw new IllegalStateException("ngxshim_init failed: 0x" + Integer.toHexString(rc)
+ " last=0x" + Integer.toHexString(lib.lastResult()));
@@ -147,6 +225,29 @@ private void init(VulkanDevice device) {
CausticaMod.LOGGER.info("NGX initialized (shim {})", shim);
}
+ private static List queryRequiredExtensions(NgxLibrary library, boolean deviceExtensions) {
+ final int capacity = 8192;
+ try (Arena arena = Arena.ofConfined()) {
+ MemorySegment buffer = arena.allocate(capacity, 1);
+ int count = library.requiredExtensions(deviceExtensions, buffer, capacity);
+ if (count < 0) {
+ throw new IllegalStateException("ngxshim_required_extensions returned " + count);
+ }
+ byte[] bytes = buffer.toArray(ValueLayout.JAVA_BYTE);
+ int length = 0;
+ while (length < bytes.length && bytes[length] != 0) {
+ length++;
+ }
+ List extensions = new String(bytes, 0, length, StandardCharsets.UTF_8).lines()
+ .map(String::strip).filter(name -> !name.isEmpty()).toList();
+ if (extensions.size() != count) {
+ throw new IllegalStateException("NGX extension list was truncated or malformed: expected "
+ + count + " names, got " + extensions.size());
+ }
+ return extensions;
+ }
+ }
+
private static Path locateShim() {
String override = CausticaConfig.Ngx.PATH.get();
if (override != null && !override.isBlank()) {
@@ -189,11 +290,26 @@ private static boolean extractBundledNative(String name, Path dst) throws IOExce
}
private static void extractBundledFeatureLibraries(Path dir) throws IOException {
+ Set current = new HashSet<>();
for (String name : PLATFORM_NATIVES.exactFeatureNames()) {
- extractBundledNative(name, dir.resolve(name));
+ if (extractBundledNative(name, dir.resolve(name))) {
+ current.add(name);
+ }
}
for (String name : bundledFeatureLibraryNames()) {
- extractBundledNative(name, dir.resolve(name));
+ if (extractBundledNative(name, dir.resolve(name))) {
+ current.add(name);
+ }
+ }
+ List stale;
+ try (Stream files = Files.list(dir)) {
+ stale = files.filter(Files::isRegularFile)
+ .filter(path -> PLATFORM_NATIVES.isFeatureLibrary(path.getFileName().toString()))
+ .filter(path -> !current.contains(path.getFileName().toString()))
+ .toList();
+ }
+ for (Path path : stale) {
+ Files.deleteIfExists(path);
}
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java
index d65090f8..cf2a4324 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java
@@ -5,14 +5,18 @@
import com.mojang.blaze3d.textures.GpuTexture;
import com.mojang.blaze3d.textures.GpuTextureView;
import com.mojang.blaze3d.vulkan.VulkanCommandEncoder;
+import com.mojang.blaze3d.vulkan.VulkanGpuSampler;
import com.mojang.blaze3d.vulkan.VulkanGpuTexture;
import com.mojang.blaze3d.vulkan.VulkanGpuTextureView;
import dev.comfyfluffy.caustica.CausticaConfig;
import dev.comfyfluffy.caustica.CausticaMod;
import dev.comfyfluffy.caustica.client.CausticaJitter;
+import dev.comfyfluffy.caustica.client.CaptureSession;
+import dev.comfyfluffy.caustica.client.UltraScreenshot;
import dev.comfyfluffy.caustica.mixin.CommandEncoderAccessor;
import dev.comfyfluffy.caustica.rt.gen.WorldPushConstantsData;
import dev.comfyfluffy.caustica.rt.gen.WorldPushData;
+import dev.comfyfluffy.caustica.rt.gen.SharcPushConstantsData;
import dev.comfyfluffy.caustica.rt.gen.WorldPushData.BreakEntry;
import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Float2;
import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Float3;
@@ -20,6 +24,9 @@
import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Int4;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BiomeColors;
+import net.minecraft.client.renderer.EndFlashState;
+import net.minecraft.client.renderer.fog.FogData;
+import net.minecraft.client.renderer.texture.AbstractTexture;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.client.resources.model.ModelBakery;
@@ -30,6 +37,7 @@
import net.minecraft.util.Mth;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.MoonPhase;
+import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.material.FluidState;
import org.joml.Matrix4f;
import org.joml.Matrix4fc;
@@ -68,7 +76,10 @@
import dev.comfyfluffy.caustica.rt.pipeline.RtSdrPresentPipeline;
import dev.comfyfluffy.caustica.rt.pipeline.RtExposure;
import dev.comfyfluffy.caustica.rt.pipeline.RtPipeline;
+import dev.comfyfluffy.caustica.rt.pipeline.RtPathSamplerData;
import dev.comfyfluffy.caustica.rt.pipeline.RtToneLut;
+import dev.comfyfluffy.caustica.rt.pipeline.RtSharcResolvePipeline;
+import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping;
import dev.comfyfluffy.caustica.rt.terrain.RtTerrain;
import java.nio.ByteBuffer;
@@ -92,6 +103,8 @@
*/
public final class RtComposite {
public static final RtComposite INSTANCE = new RtComposite();
+ /** Debug value that exposes the path-traced image before DLSS-RR/reconstruction. */
+ public static final int RAW_DEBUG_VIEW = CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW;
public static boolean enabled() {
return CausticaConfig.Rt.ENABLED.value();
@@ -105,12 +118,19 @@ public static boolean enabled() {
// 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;
+ private static final int PATH_SEGMENTS_PER_PIXEL = RtPathSamplerData.PATH_BRANCH_COUNT;
+ private static final int PATH_PIXEL_AXIS_LIMIT = 1 << 16;
+ private static final long PATH_SAMPLE_INDEX_LIMIT = 1L << Integer.SIZE;
private static int debugView() {
return CausticaConfig.Rt.Composite.DEBUG_VIEW.value();
}
+ private static boolean rawDebugView() {
+ return debugView() == RAW_DEBUG_VIEW;
+ }
+
private static int spp() {
- return CausticaConfig.Rt.Composite.SPP.value();
+ return CaptureSession.effectiveSpp(CausticaConfig.Rt.Composite.SPP.value());
}
private static int maxBounces() {
@@ -133,6 +153,8 @@ private static boolean waterWaves() {
// package's angular radii, which only jitter the shadow ray and so only set penumbra softness.
private static final RtLookPackage LOOK = RtLookPackage.current();
private static final Identifier SUN_ID = Identifier.withDefaultNamespace("sun");
+ private static final Identifier END_FLASH_ID = Identifier.withDefaultNamespace("end_flash");
+ private static final Identifier END_SKY_ID = Identifier.withDefaultNamespace("textures/environment/end_sky.png");
private static final Identifier[] MOON_IDS = createMoonIds();
// Sign of the sub-pixel jitter as reported to DLSS-RR + applied to the primary ray, mirroring the
// validated DLSS-SR convention (Vulkan flipped clip space wants Y negated).
@@ -152,6 +174,26 @@ public static long frameCounter() {
}
private RtPipeline worldPipeline;
+ private RtPipeline sharcQueryPipeline;
+ private RtPipeline sharcUpdatePipeline;
+ private RtSharcResolvePipeline sharcResolvePipeline;
+ private RtSharcCache sharcCache;
+ private int sharcResourceExponent = -1;
+ private boolean sharcUsesSer;
+ private Object sharcWorldIdentity;
+ private Object sharcDimensionIdentity;
+ private int sharcTerrainX;
+ private int sharcTerrainY;
+ private int sharcTerrainZ;
+ private long sharcMaterialEpoch = -1L;
+ private long sharcSettingsSignature = Long.MIN_VALUE;
+ private int sharcRenderWidth = -1;
+ private int sharcRenderHeight = -1;
+ private double sharcLastCameraX;
+ private double sharcLastCameraY;
+ private double sharcLastCameraZ;
+ private boolean sharcLastCameraValid;
+ private SharcSkyState sharcLastSkyState;
// Set at the HEAD of Minecraft.reloadResourcePacks() (mixin): a resource reload recreates the block
// atlas + entity textures. We tear down the world pipeline there (drops all descriptor references) and
// rebuild it once the NEW atlas is in place — detected by the atlas view handle changing away from
@@ -186,6 +228,11 @@ public static long frameCounter() {
// Packed primary -> indirect continuations. Pass A is fixed at one sample and owns two records per
// render pixel (base + optional transmission); Pass B resamples them at the configured SPP.
private RtBuffer continuationQueue;
+ private RtPathSamplerData pathSamplerData;
+ private long pathSampleCursor;
+ private int pathSampleEpoch;
+ private boolean pathSamplerResetPending = true;
+ private long pathSamplingPolicySignature = Long.MIN_VALUE;
private RtImage displayImage;
// Bloom pyramid, finest first: level 0 is half display resolution and each level halves again. The
// display mapper reads level 0, which the upsample sweep leaves holding the sum of every band.
@@ -219,6 +266,7 @@ private static final class PushSlot {
this.buffer = buffer;
}
}
+
// Menu/non-RT present: converts the SDR main target (sRGB) to PQ-encoded at paper white so menus,
// the title panorama and the loading screen present correctly to the PQ swapchain instead of being
// raw-copied (misdisplayed). Lazily created; the image is sized to the swapchain.
@@ -237,13 +285,17 @@ private static final class PushSlot {
private final Matrix4f fgPrevToClip = new Matrix4f();
private final Matrix4f fgMatTmp = new Matrix4f();
// Guide buffers (first-hit attributes for DLSS-RR): normal+roughness, albedo, depth, motion,
- // specular albedo, and reflection motion.
+ // specular albedo, reflection motion, DLSSD responsivity, primary-sky display classification,
+ // and particle classification.
private RtImage gNormal;
private RtImage gAlbedo;
private RtImage gDepth;
private RtImage gMotion;
private RtImage gSpecAlbedo;
private RtImage gSpecMotion;
+ private RtImage gResponsivity;
+ private RtImage gParticleMask;
+ private RtImage gSkyClassification;
// Display-res RT image the display mapper reads: DLSS-RR writes it (render -> display denoise+upscale), or a
// linear blit of `output` fills it when RR is off/unavailable (the no-RR reference / fallback).
private RtImage rrOutput;
@@ -286,6 +338,17 @@ private static final class PushSlot {
private double camY;
private double camZ;
private boolean frameCaptured;
+ private boolean captureCameraFrozen;
+ private boolean captureWorldPushFrozen;
+ private int captureFlags;
+ private Float4 captureWaterParams;
+ private Float4 captureWaterAnchor;
+ private BreakEntry[] captureBreaking;
+ private SkyPush captureSky;
+ private RtAccel.PreparedTlas captureTlas;
+ private boolean freshRtFrame;
+ private boolean freshDlssRrFrame;
+ private int jitterPhaseCount;
private long celestialUvAtlasHandle;
private int celestialUvMoonPhase = -1;
private float sunU0;
@@ -296,6 +359,18 @@ private static final class PushSlot {
private float moonV0;
private float moonU1 = 1f;
private float moonV1 = 1f;
+ private float endFlashU0;
+ private float endFlashV0;
+ private float endFlashU1 = 1f;
+ private float endFlashV1 = 1f;
+ private int frameSkyboxMode = RtSkyMath.SKYBOX_OVERWORLD;
+ private boolean frameSkyboxValid;
+ private float frameSkyColorR;
+ private float frameSkyColorG;
+ private float frameSkyColorB;
+ private float frameSkyColorA = 1.0f;
+ private boolean endFlashStateValid;
+ private boolean previousEndFlashActive;
// Per-frame TLAS resources, rebuilt in place from a small ring of persistent slots (see
// RtAccel.TlasRing — replaces the old create-and-defer-destroy-per-frame churn whose VMA slow path
@@ -466,6 +541,10 @@ public boolean requiresVanillaWorldFallback() {
if (worldPipeline == null || !materialBindingsReady) {
return true;
}
+ EndSkyBinding endSky = endSkyBinding();
+ if (endSky.view() == 0L || endSky.sampler() == 0L) {
+ return true;
+ }
if (materialEpochTraceGate) {
return true;
}
@@ -489,21 +568,182 @@ public void resetFailureLatch() {
failed = false;
CausticaMod.LOGGER.info("RT failure latch cleared by render-state invalidation; retrying RT");
}
+ RtDlssRr.INSTANCE.resetFailureLatch();
}
- /** Capture the frame's camera for the next composite. Called from GameRendererMixin. */
- public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cameraX, double cameraY, double cameraZ) {
+ /** Capture one coherent camera, dimension-sky, and vanilla sky-color snapshot for the next composite. */
+ public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cameraX, double cameraY, double cameraZ,
+ FogData vanillaFogData) {
+ if (CaptureSession.active() && captureCameraFrozen) {
+ frameCaptured = true;
+ return;
+ }
frameProjection.set(projection);
frameViewRotation.set(viewRotation);
camX = cameraX;
camY = cameraY;
camZ = cameraZ;
+ Minecraft mc = Minecraft.getInstance();
+ int skybox = RtSkyMath.skyboxMode(mc.level == null
+ ? DimensionType.Skybox.OVERWORLD : mc.level.dimensionType().skybox());
+ if (frameSkyboxValid && frameSkyboxMode != skybox) {
+ RtDlssRr.INSTANCE.requestHistoryReset();
+ }
+ frameSkyboxMode = skybox;
+ frameSkyboxValid = true;
+ captureSkyColor(vanillaFogData);
frameCaptured = true;
+ captureCameraFrozen = CaptureSession.active();
+ }
+
+ /** Read vanilla's resolved sky color for End-sky compositing without modifying the fog pipeline. */
+ private void captureSkyColor(FogData vanillaFogData) {
+ float skyR = 0.0f;
+ float skyG = 0.0f;
+ float skyB = 0.0f;
+ float skyA = 1.0f;
+ if (vanillaFogData != null && vanillaFogData.color != null) {
+ var color = vanillaFogData.color;
+ skyR = RtSkyMath.srgbToLinear(finiteColor(color.x()));
+ skyG = RtSkyMath.srgbToLinear(finiteColor(color.y()));
+ skyB = RtSkyMath.srgbToLinear(finiteColor(color.z()));
+ skyA = finiteColor(color.w());
+ }
+ frameSkyColorR = skyR;
+ frameSkyColorG = skyG;
+ frameSkyColorB = skyB;
+ frameSkyColorA = skyA;
+ }
+
+ private static float finiteColor(float value) {
+ return Float.isFinite(value) ? Math.clamp(value, 0.0f, 1.0f) : 0.0f;
+ }
+
+ /** Freeze renderer-owned scene inputs for a finite multi-frame capture. */
+ public void beginCaptureSession() {
+ captureCameraFrozen = false;
+ captureWorldPushFrozen = false;
+ exposure.beginCapture();
+ captureBreaking = null;
+ captureSky = null;
+ captureTlas = null;
+ }
+
+ public void endCaptureSession() {
+ captureCameraFrozen = false;
+ captureWorldPushFrozen = false;
+ exposure.endCapture();
+ captureBreaking = null;
+ captureSky = null;
+ captureTlas = null;
+ }
+
+ public boolean producedFreshDlssRrFrame() {
+ return freshRtFrame && freshDlssRrFrame;
+ }
+
+ public int currentJitterPhaseCount() {
+ return jitterPhaseCount;
+ }
+
+ /** F4 may retain the current renderer only after a valid RT frame and exposure image exist. */
+ public boolean readyForUltraScreenshot() {
+ return freshRtFrame && !failed && worldPipeline != null && materialBindingsReady
+ && !reloadRebindRequested && output != null && continuationQueue != null
+ && pathSamplerData != null
+ && displayPipeline != null && displayImage != null && hdrDisplayImage != null
+ && rrOutput != null && exposure.ready() && RtTerrain.currentOrNull() != null;
}
/** Reset exposure filtering after an explicit render-state invalidation such as F3+A. */
public void resetExposureHistory() {
- exposure.requestReset();
+ requestTemporalReset();
+ }
+
+ /** Clear every temporal input before a controlled renderer comparison or explicit scene invalidation. */
+ public void requestTemporalReset() {
+ requestTemporalReset(true);
+ }
+
+ /** Reset reconstruction while optionally retaining the valid exposure image and latch. */
+ public void requestTemporalReset(boolean resetExposureHistory) {
+ pathSamplerResetPending = true;
+ resetTemporalConsumers(resetExposureHistory);
+ }
+
+ private void resetTemporalConsumers(boolean resetExposureHistory) {
+ CausticaJitter.INSTANCE.reset();
+ RtDlssRr.INSTANCE.requestHistoryReset();
+ if (resetExposureHistory) {
+ exposure.requestReset();
+ }
+ requestSharcReset();
+ mvHasPrev = false;
+ waterWaveTimeValid = false;
+ fgReset = true;
+ }
+
+ private void refreshPathSamplingPolicy(int frameSpp) {
+ long reservation = pathSamplesPerFrame(frameSpp);
+ long signature = pathSamplingPolicySignature(frameSpp);
+ if (pathSamplingPolicySignature != signature) {
+ pathSamplingPolicySignature = signature;
+ pathSamplerResetPending = true;
+ // SPP and estimator-shape changes invalidate reconstruction but not the exposure estimate.
+ resetTemporalConsumers(false);
+ }
+ if (!pathSamplerResetPending && pathSampleCursor > PATH_SAMPLE_INDEX_LIMIT - reservation) {
+ pathSamplerResetPending = true;
+ resetTemporalConsumers(false);
+ }
+ if (pathSamplerResetPending) {
+ pathSampleCursor = 0L;
+ pathSampleEpoch++;
+ if (pathSampleEpoch == 0) {
+ pathSampleEpoch = 1;
+ }
+ pathSamplerResetPending = false;
+ }
+ }
+
+ private long pathSamplingPolicySignature(int frameSpp) {
+ int bounceCount = maxBounces();
+ if (bounceCount < 0 || bounceCount > RtPathSamplerData.MAX_SUPPORTED_BOUNCE) {
+ throw new IllegalStateException("Path sampler does not support max-bounces=" + bounceCount);
+ }
+ int risCandidates = CausticaConfig.Rt.Lights.RIS_CANDIDATES.value();
+ if (risCandidates < 0 || risCandidates > RtPathSamplerData.MAX_RIS_CANDIDATES) {
+ throw new IllegalStateException("Path sampler does not support RIS candidates=" + risCandidates);
+ }
+
+ long signature = 17L;
+ signature = signature * 31L + RtPathSamplerData.ALGORITHM_VERSION;
+ signature = signature * 31L + frameSpp;
+ signature = signature * 31L + bounceCount;
+ signature = signature * 31L + risCandidates;
+ signature = signature * 31L + (CausticaConfig.Rt.Sharc.ENABLED.value() ? 1L : 0L);
+ return signature;
+ }
+
+ private static long pathSamplesPerFrame(int frameSpp) {
+ if (frameSpp < 1) {
+ throw new IllegalArgumentException("Path-tracing SPP must be positive: " + frameSpp);
+ }
+ long reservation = frameSpp;
+ if (reservation > PATH_SAMPLE_INDEX_LIMIT) {
+ throw new IllegalArgumentException("Path-tracing SPP exhausts the 32-bit sample domain: " + frameSpp);
+ }
+ return reservation;
+ }
+
+ private int reservePathSamples(int frameSpp) {
+ long reservation = pathSamplesPerFrame(frameSpp);
+ if (pathSampleCursor > PATH_SAMPLE_INDEX_LIMIT - reservation) {
+ throw new IllegalStateException("Path sample cursor was not reset before 32-bit exhaustion");
+ }
+ int base = (int) pathSampleCursor;
+ pathSampleCursor += reservation;
+ return base;
}
/**
@@ -530,6 +770,10 @@ public void beginFrame() {
}
RtFrameStats.FRAME.beginIfInactive();
hdrWrittenThisFrame = false;
+ freshRtFrame = false;
+ freshDlssRrFrame = false;
+ jitterPhaseCount = 0;
+ UltraScreenshot.INSTANCE.beginFrame(Minecraft.getInstance());
}
/** This frame's completion token, valid until {@link #finishGraphicsUse()} signals it. */
@@ -569,13 +813,14 @@ public boolean composite(GpuTexture nativeColor, int width, int height) {
if (ctx == null) {
return false;
}
- ctx.gpuExecutor().throwIfFailed();
// Count-bounded terrain streaming (dispatch/drain/build kick) runs here once per render frame — before
// the ready gate below, because it is what MAKES terrain ready during the initial fill.
try {
- RtTerrain.frame(ctx);
- } catch (Throwable t) {
ctx.gpuExecutor().throwIfFailed();
+ if (!CaptureSession.active()) {
+ RtTerrain.frame(ctx);
+ }
+ } catch (Throwable t) {
failed = true;
CausticaMod.LOGGER.error("RT terrain streaming failed; reverting to vanilla path", t);
return false;
@@ -606,10 +851,13 @@ public boolean composite(GpuTexture nativeColor, int width, int height) {
if (sdrToneLut == null) {
sdrToneLut = RtToneLut.load(ctx, "sdr_aces2_rec709.bin");
}
- // The mastering target is live, so track it each frame.
- int wantedHdrNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value();
- if (hdrToneLut == null || loadedHdrLutNits != wantedHdrNits) {
- RtToneLut newHdrLut = RtToneLut.load(ctx, "hdr_aces2_rec2020_" + wantedHdrNits + "nit.bin");
+ // The display peak is live. ACES 2.0 has four packaged mastering targets, so bind the
+ // nearest one; analytical HDR modes use the exact configured peak in their push constants.
+ int requestedHdrNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value();
+ int wantedHdrLutNits = CausticaConfig.Rt.Hdr.nearestAcesLutNits(requestedHdrNits);
+ if (hdrToneLut == null || loadedHdrLutNits != wantedHdrLutNits) {
+ RtToneLut newHdrLut = RtToneLut.load(ctx,
+ "hdr_aces2_rec2020_" + wantedHdrLutNits + "nit.bin");
if (newHdrLut.size != sdrToneLut.size) {
// display.comp's lutSize push constant is shared by both LUT samples (see
// lutTexCoord()); bake_display_lut.py currently always sizes both the same, but
@@ -623,11 +871,10 @@ public boolean composite(GpuTexture nativeColor, int width, int height) {
hdrToneLut.destroy();
}
hdrToneLut = newHdrLut;
- loadedHdrLutNits = wantedHdrNits;
+ loadedHdrLutNits = wantedHdrLutNits;
}
- // The scene-referred LMT is part of the immutable versioned look package and shared by
- // both SDR and HDR output transforms. It cannot be switched independently from the
- // package's exposure and photometric anchors.
+ // The package LMT feeds only the ACES 2.0 SDR and HDR output transforms. Analytical
+ // mappers consume the exposed scene signal directly and own their display rendering.
if (lookLut == null) {
lookLut = RtToneLut.loadResource(ctx, LOOK.lmtResource());
}
@@ -648,9 +895,17 @@ public boolean composite(GpuTexture nativeColor, int width, int height) {
// hdrToneLut/lookLut may have been hot-swapped just above; setImages is a no-op if the bound
// views already match, so this is cheap on every other frame.
RtToneLut boundLookLut = lookLut;
+ EndSkyBinding endSky = requireEndSkyBinding();
+ long fallbackAtlasView = blockAlbedoAtlasView();
+ long celestialsView = celestialsAtlasView();
+ long atlasSamplerHandle = atlasSampler(ctx);
displayPipeline.setImages(displayImage.view, rrOutput.view, exposure.image().view, hdrDisplayImage.view,
sdrToneLut.view(), sdrToneLut.sampler(), hdrToneLut.view(), hdrToneLut.sampler(),
- boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler());
+ boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler(),
+ gSkyClassification.view,
+ endSky.view(), endSky.sampler(),
+ celestialsView != 0L ? celestialsView : fallbackAtlasView,
+ atlasSamplerHandle);
bloomPipeline.setImages(rrOutput.view, exposure.image().view, bloomLevels);
debugPresentPipeline.setImages(displayImage.view, gNormal.view, gAlbedo.view, gDepth.view,
gMotion.view, gSpecAlbedo.view, gSpecMotion.view, rrOutput.view, exposure.image().view,
@@ -666,15 +921,19 @@ public boolean composite(GpuTexture nativeColor, int width, int height) {
return false;
}
refreshMaterialBindingsIfNeeded(ctx);
+ syncSharcResources(ctx);
+ int frameSpp = spp();
+ refreshPathSamplingPolicy(frameSpp);
updateMotion();
- recordFrame(ctx, active, nativeColor);
+ recordFrame(ctx, active, nativeColor, frameSpp);
if (!loggedActive) {
loggedActive = true;
CausticaMod.LOGGER.info("RT composite active (terrain): {}x{}, RT output replaces the world target", width, height);
}
return true;
+ } catch (EndSkyUnavailableException e) {
+ return false;
} catch (Throwable t) {
- ctx.gpuExecutor().throwIfFailed();
failed = true;
CausticaMod.LOGGER.error("RT composite failed; reverting to vanilla path", t);
return false;
@@ -698,6 +957,8 @@ public void ensureResourcesReady(RtContext ctx) {
}
try {
ensureWorld(ctx);
+ } catch (EndSkyUnavailableException e) {
+ CausticaMod.LOGGER.debug("RT resource bring-up waiting for the vanilla End sky texture");
} catch (Throwable t) {
failed = true;
CausticaMod.LOGGER.error("RT resource bring-up failed; reverting to vanilla path", t);
@@ -730,6 +991,11 @@ private RtPipeline ensureWorld(RtContext ctx) {
VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, "rt world push " + i));
}
}
+ if (pathSamplerData == null) {
+ pathSamplerData = RtPathSamplerData.create(ctx);
+ CausticaMod.LOGGER.info("Initialized canonical path sampler v{}",
+ RtPathSamplerData.ALGORITHM_VERSION);
+ }
if (output != null) {
worldPipeline.setStorageImage(output.view);
bindGuideImages();
@@ -742,6 +1008,217 @@ private RtPipeline ensureWorld(RtContext ctx) {
return worldPipeline;
}
+ private boolean sharcRequested() {
+ return CausticaConfig.Rt.Sharc.ENABLED.value();
+ }
+
+ private boolean sharcActive() {
+ return sharcRequested() && debugView() == 0 && RtSharcSupport.available()
+ && sharcCache != null && sharcQueryPipeline != null
+ && sharcUpdatePipeline != null && sharcResolvePipeline != null;
+ }
+
+ /** User-facing effective state for the dedicated SHaRC options page. */
+ public String sharcStatus() {
+ if (!RtSharcSupport.available()) {
+ return RtSharcSupport.status();
+ }
+ if (!sharcRequested()) {
+ return "off";
+ }
+ if (debugView() != 0) {
+ return "paused while a renderer debug view is selected";
+ }
+ if (sharcActive()) {
+ return CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value()
+ ? "active - primary-surface debug" : "active - secondary paths";
+ }
+ return sharcResourcesPresent() ? "initializing" : "ready - activates while rendering";
+ }
+
+ /** Request a timeline-safe clear; harmless while the lazy SHaRC cache is not allocated. */
+ public void requestSharcReset() {
+ if (sharcCache != null) {
+ sharcCache.requestReset();
+ }
+ }
+
+ private boolean sharcResourcesPresent() {
+ return sharcCache != null || sharcQueryPipeline != null
+ || sharcUpdatePipeline != null || sharcResolvePipeline != null;
+ }
+
+ private void syncSharcResources(RtContext ctx) {
+ boolean present = sharcCache != null || sharcQueryPipeline != null
+ || sharcUpdatePipeline != null || sharcResolvePipeline != null;
+ if (!sharcRequested() || !RtSharcSupport.available()) {
+ if (present) {
+ ctx.waitIdle();
+ destroySharcResources();
+ }
+ return;
+ }
+ RtTerrain terrain = RtTerrain.currentOrNull();
+ if (worldPipeline == null || output == null || gNormal == null || !materialBindingsReady || terrain == null) {
+ return;
+ }
+ int exponent = CausticaConfig.Rt.Sharc.CACHE_EXPONENT.value();
+ boolean ser = RtDeviceBringup.serExtEnabled();
+ boolean recreate = !present || sharcResourceExponent != exponent || sharcUsesSer != ser
+ || sharcRenderWidth != renderW || sharcRenderHeight != renderH;
+ if (!recreate) {
+ return;
+ }
+ if (present) {
+ ctx.waitIdle();
+ destroySharcResources();
+ }
+ try {
+ String query = ser ? "indirect_sharc_ser_query.rgen.spv" : "indirect_sharc_query.rgen.spv";
+ String update = ser ? "indirect_sharc_ser_update.rgen.spv" : "indirect_sharc_update.rgen.spv";
+ sharcQueryPipeline = RtPipeline.create(ctx, new String[]{query},
+ new String[]{"sky.rmiss.spv", "guide.rmiss.spv"},
+ "closest_hit.rchit.spv", "any_hit.rahit.spv",
+ SharcPushConstantsData.BYTE_SIZE, bindlessTextureCapacity);
+ sharcUpdatePipeline = RtPipeline.create(ctx, new String[]{update},
+ new String[]{"sky.rmiss.spv", "guide.rmiss.spv"},
+ "closest_hit.rchit.spv", "any_hit.rahit.spv",
+ SharcPushConstantsData.BYTE_SIZE, bindlessTextureCapacity);
+ sharcResolvePipeline = RtSharcResolvePipeline.create(ctx);
+ sharcCache = RtSharcCache.create(ctx, exponent);
+ long sampler = atlasSampler(ctx);
+ long atlas = blockAlbedoAtlasView();
+ bindSharcPipeline(sharcQueryPipeline, sampler, atlas);
+ bindSharcPipeline(sharcUpdatePipeline, sampler, atlas);
+ RtEntityTextures.INSTANCE.uploadAll(sampler, worldPipeline, sharcQueryPipeline, sharcUpdatePipeline);
+ sharcResourceExponent = sharcCache.exponent();
+ sharcUsesSer = ser;
+ sharcRenderWidth = renderW;
+ sharcRenderHeight = renderH;
+ sharcWorldIdentity = Minecraft.getInstance().level;
+ sharcDimensionIdentity = Minecraft.getInstance().level.dimension();
+ sharcTerrainX = terrain.blockX;
+ sharcTerrainY = terrain.blockY;
+ sharcTerrainZ = terrain.blockZ;
+ sharcMaterialEpoch = RtMaterialRegistry.INSTANCE.epoch();
+ sharcSettingsSignature = sharcSettingsSignature();
+ sharcLastCameraValid = false;
+ sharcLastSkyState = null;
+ sharcCache.requestReset();
+ CausticaMod.LOGGER.info("SHaRC 1.8 directional resources enabled: exponent={}, capacity={}, SER={}",
+ sharcResourceExponent, sharcCache.capacity(), ser);
+ } catch (Throwable t) {
+ try {
+ destroySharcResources();
+ } catch (Throwable cleanupFailure) {
+ t.addSuppressed(cleanupFailure);
+ }
+ RtSharcSupport.fail("resource or pipeline creation failed", t);
+ }
+ }
+
+ private void bindSharcPipeline(RtPipeline pipeline, long sampler, long atlasView) {
+ pipeline.setStorageImage(output.view);
+ bindGuideImages(pipeline);
+ pipeline.setBlockAlbedoAtlas(atlasView, sampler);
+ pipeline.setEntityAlbedoTexture(0, atlasView, sampler);
+ RtBlockMaterials.INSTANCE.bindPages(sampler, pipeline);
+ long celestials = celestialsAtlasView();
+ pipeline.setSkyAtlas(celestials != 0L ? celestials : atlasView, sampler);
+ EndSkyBinding endSky = requireEndSkyBinding();
+ pipeline.setEndSkyTexture(endSky.view(), endSky.sampler());
+ if (skyLut != null) {
+ pipeline.setSkyLuts(skyLut.skyViewView(), skyLut.transmittanceView(), skyLut.sampler());
+ }
+ }
+
+ private void destroySharcResources() {
+ if (sharcResolvePipeline != null) {
+ sharcResolvePipeline.destroy();
+ sharcResolvePipeline = null;
+ }
+ if (sharcUpdatePipeline != null) {
+ sharcUpdatePipeline.destroy();
+ sharcUpdatePipeline = null;
+ }
+ if (sharcQueryPipeline != null) {
+ sharcQueryPipeline.destroy();
+ sharcQueryPipeline = null;
+ }
+ if (sharcCache != null) {
+ sharcCache.destroy();
+ sharcCache = null;
+ }
+ sharcResourceExponent = -1;
+ sharcUsesSer = false;
+ sharcWorldIdentity = null;
+ sharcDimensionIdentity = null;
+ sharcMaterialEpoch = -1L;
+ sharcSettingsSignature = Long.MIN_VALUE;
+ sharcRenderWidth = -1;
+ sharcRenderHeight = -1;
+ sharcLastCameraValid = false;
+ sharcLastSkyState = null;
+ }
+
+ private long sharcSettingsSignature() {
+ long signature = 17L;
+ signature = signature * 31L + spp();
+ signature = signature * 31L + maxBounces();
+ signature = signature * 31L + (waterWaves() ? 1L : 0L);
+ signature = signature * 31L + CausticaConfig.Rt.Lights.RIS_CANDIDATES.value();
+ signature = signature * 31L + (CausticaConfig.Rt.Sharc.ANTI_FIREFLY.value() ? 1L : 0L);
+ signature = signature * 31L + (CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value() ? 1L : 0L);
+ signature = signature * 31L + CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.value();
+ signature = signature * 31L + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.value();
+ signature = signature * 31L + CausticaConfig.Rt.Sharc.STALE_FRAMES.value();
+ signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.SCENE_SCALE.value());
+ signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.RADIANCE_SCALE.value());
+ signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.value());
+ signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.value());
+ signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.value());
+ return signature;
+ }
+
+ private void updateSharcResetPolicy(RtTerrain terrain, SkyPush sky) {
+ if (sharcCache == null) return;
+ var level = Minecraft.getInstance().level;
+ Object dimension = level != null ? level.dimension() : null;
+ if (sharcWorldIdentity != level || !Objects.equals(sharcDimensionIdentity, dimension)
+ || sharcTerrainX != terrain.blockX || sharcTerrainY != terrain.blockY || sharcTerrainZ != terrain.blockZ
+ || sharcMaterialEpoch != RtMaterialRegistry.INSTANCE.epoch()
+ || sharcSettingsSignature != sharcSettingsSignature()
+ || sharcRenderWidth != renderW || sharcRenderHeight != renderH) {
+ sharcCache.requestReset();
+ }
+ if (!sharcLastCameraValid || !Double.isFinite(camX) || !Double.isFinite(camY) || !Double.isFinite(camZ)) {
+ if (sharcLastCameraValid) sharcCache.requestReset();
+ } else {
+ double dx = camX - sharcLastCameraX;
+ double dy = camY - sharcLastCameraY;
+ double dz = camZ - sharcLastCameraZ;
+ if (dx * dx + dy * dy + dz * dz > 64.0 * 64.0) sharcCache.requestReset();
+ }
+ SharcSkyState skyState = SharcSkyState.from(sky);
+ if (hardSkyDiscontinuity(sharcLastSkyState, skyState)) {
+ sharcCache.requestReset();
+ }
+ sharcWorldIdentity = level;
+ sharcDimensionIdentity = dimension;
+ sharcTerrainX = terrain.blockX;
+ sharcTerrainY = terrain.blockY;
+ sharcTerrainZ = terrain.blockZ;
+ sharcMaterialEpoch = RtMaterialRegistry.INSTANCE.epoch();
+ sharcSettingsSignature = sharcSettingsSignature();
+ sharcRenderWidth = renderW;
+ sharcRenderHeight = renderH;
+ sharcLastCameraX = camX;
+ sharcLastCameraY = camY;
+ sharcLastCameraZ = camZ;
+ sharcLastCameraValid = Double.isFinite(camX) && Double.isFinite(camY) && Double.isFinite(camZ);
+ sharcLastSkyState = skyState;
+ }
+
private void refreshPipelineShapeIfNeeded(RtContext ctx) {
if (worldPipeline == null || reloadRebindRequested) {
return;
@@ -751,6 +1228,7 @@ private void refreshPipelineShapeIfNeeded(RtContext ctx) {
return;
}
ctx.waitIdle();
+ destroySharcResources();
worldPipeline.destroy();
worldPipeline = null;
bindlessTextureCapacity = 0;
@@ -764,9 +1242,9 @@ private void refreshPipelineShapeIfNeeded(RtContext ctx) {
* the shared material registry, and invalidates old-epoch geometry before tracing resumes.
*/
private void bindWorldTextures(RtContext ctx) {
+ EndSkyBinding endSky = requireEndSkyBinding();
long sampler = atlasSampler(ctx);
long atlasView = blockAlbedoAtlasView();
- boundBlockAlbedoAtlasHandle = atlasView; // remember what we bound so a reload can detect the new atlas
worldPipeline.setBlockAlbedoAtlas(atlasView, sampler);
// Bindless slot 0 = fallback texture (the block atlas) so an entity whose texture can't be
// resolved samples something defined rather than an unbound (partially-bound) descriptor.
@@ -778,7 +1256,6 @@ private void bindWorldTextures(RtContext ctx) {
worldPipeline.setEntityAlbedoTexture(0, atlasView, sampler);
RtBlockMaterials.INSTANCE.bindPages(worldPipeline, sampler);
RtMaterialRegistry.INSTANCE.rebuild(ctx, RtBlockMaterials.INSTANCE, materialOverrides);
- materialBindingsReady = true;
// Sky rewrite: bind the vanilla celestials atlas (sun + moon phases) for world.rmiss. The view
// handle is stable across frames; the shader only samples it inside the sun/moon discs (sky
// directions), so the block-atlas fallback is never read if the celestials atlas isn't ready.
@@ -792,11 +1269,14 @@ private void bindWorldTextures(RtContext ctx) {
skyLut.sampler());
}
}
+ worldPipeline.setEndSkyTexture(endSky.view(), endSky.sampler());
setCelestialUvAtlas(celView);
// Atlas UVs and material IDs are one resource epoch. Drop old terrain as a unit rather than
// incrementally displaying old UVs/IDs against the new atlas/table.
RtTerrain.requestFullClear();
materialEpochTraceGate = true;
+ boundBlockAlbedoAtlasHandle = atlasView;
+ materialBindingsReady = true;
}
private void refreshMaterialBindingsIfNeeded(RtContext ctx) {
@@ -837,6 +1317,11 @@ public void onResourceReloadStart() {
RtContext ctx = RtContext.currentOrNull();
if (ctx != null) {
ctx.waitIdle();
+ destroySharcResources();
+ if (displayPipeline != null) {
+ displayPipeline.destroy();
+ displayPipeline = null;
+ }
if (worldPipeline != null) {
worldPipeline.destroy();
worldPipeline = null;
@@ -848,15 +1333,24 @@ public void onResourceReloadStart() {
/** Bind the guide buffers into the world pipeline's extra storage-image slots. */
private void bindGuideImages() {
- if (worldPipeline == null || gNormal == null) {
+ bindGuideImages(worldPipeline);
+ bindGuideImages(sharcQueryPipeline);
+ bindGuideImages(sharcUpdatePipeline);
+ }
+
+ private void bindGuideImages(RtPipeline pipeline) {
+ if (pipeline == null || gNormal == null) {
return;
}
- worldPipeline.setExtraStorageImage(0, gNormal.view);
- worldPipeline.setExtraStorageImage(1, gAlbedo.view);
- worldPipeline.setExtraStorageImage(2, gDepth.view);
- worldPipeline.setExtraStorageImage(3, gMotion.view);
- worldPipeline.setExtraStorageImage(4, gSpecAlbedo.view);
- worldPipeline.setExtraStorageImage(5, gSpecMotion.view);
+ pipeline.setExtraStorageImage(0, gNormal.view);
+ pipeline.setExtraStorageImage(1, gAlbedo.view);
+ pipeline.setExtraStorageImage(2, gDepth.view);
+ pipeline.setExtraStorageImage(3, gMotion.view);
+ pipeline.setExtraStorageImage(4, gSpecAlbedo.view);
+ pipeline.setExtraStorageImage(5, gSpecMotion.view);
+ pipeline.setExtraStorageImage(6, gResponsivity.view);
+ pipeline.setExtraStorageImage(7, gParticleMask.view);
+ pipeline.setExtraStorageImage(8, gSkyClassification.view);
}
private void destroyGuideImages() {
@@ -884,6 +1378,18 @@ private void destroyGuideImages() {
gSpecMotion.destroy();
gSpecMotion = null;
}
+ if (gResponsivity != null) {
+ gResponsivity.destroy();
+ gResponsivity = null;
+ }
+ if (gParticleMask != null) {
+ gParticleMask.destroy();
+ gParticleMask = null;
+ }
+ if (gSkyClassification != null) {
+ gSkyClassification.destroy();
+ gSkyClassification = null;
+ }
if (rrOutput != null) {
rrOutput.destroy();
rrOutput = null;
@@ -891,10 +1397,10 @@ private void destroyGuideImages() {
}
private void ensureOutput(RtContext ctx, int width, int height) {
- // Debug presentation is downstream of the ordinary frame graph and must not change the image
- // being inspected. In particular, toggling it must not rebuild at native resolution or disable
- // the RR path whose render-resolution guide inputs the debug pass visualizes.
- boolean rrEnabled = RtDlssRr.enabled();
+ // The raw debug view is a deliberate pre-reconstruction reference. It must trace at display
+ // resolution and must not create/use the RR path, otherwise it would only be another reconstructed image.
+ boolean rrRequested = RtDlssRr.enabled() && !rawDebugView();
+ boolean rrEnabled = rrRequested && !RtDlssRr.INSTANCE.hasFailed();
int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE;
if (output != null && continuationQueue != null
&& displayImage != null && hdrDisplayImage != null && rrOutput != null
@@ -903,7 +1409,20 @@ private void ensureOutput(RtContext ctx, int width, int height) {
&& renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) {
return;
}
- ctx.waitIdle(); // resize is rare; no in-flight frame may use the old image/descriptor
+ int[] optimal = rrEnabled ? RtDlssRr.INSTANCE.queryOptimalRenderSize(width, height) : null;
+ boolean useRr = optimal != null;
+ int activeRrQuality = useRr ? rrQuality : Integer.MIN_VALUE;
+ if (output != null && displayW == width && displayH == height
+ && renderSizeRrEnabled == useRr && renderSizeRrQuality == activeRrQuality
+ && continuationQueue != null && displayImage != null && hdrDisplayImage != null
+ && rrOutput != null && bloomLevels.length > 0 && exposure.ready()) {
+ return;
+ }
+ ctx.waitIdle(); // resize is rare; no in-flight frame may use the old images or descriptors
+ if (output != null) {
+ RtDlssRr.INSTANCE.requestHistoryReset();
+ }
+ destroySharcResources();
if (displayImage != null) {
displayImage.destroy();
}
@@ -922,34 +1441,24 @@ private void ensureOutput(RtContext ctx, int width, int height) {
displayW = width;
displayH = height;
- // The path tracer + its guide buffers run at render res; DLSS-RR (or a fallback blit) upscales
- // to display res. With RR off there is no reconstruction pass, so trace at 1:1 for a faithful reference.
- // With RR on, ask NGX what render resolution its chosen quality mode actually expects rather
- // than assuming a fixed ratio: different quality modes (and driver versions) use different
- // ratios, and DLSSD's own optimal-settings query is the source of truth for what it will accept.
- int[] optimal = rrEnabled ? RtDlssRr.INSTANCE.queryOptimalRenderSize(width, height) : null;
- renderW = optimal != null ? optimal[0] : width;
- renderH = optimal != null ? optimal[1] : height;
- renderSizeRrEnabled = rrEnabled;
- renderSizeRrQuality = rrQuality;
-
- // RT traces and DLSS-RR reconstruct scene-linear ACEScg in an HDR R16G16B16A16_SFLOAT target,
- // so radiance > 1 and wide-gamut colour survive to the display seam. displayImage stays
- // R8G8B8A8 to match the main target it is copied into
- // (vkCmdCopyImage requires texel-size-compatible formats).
- output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH);
+ renderW = useRr ? optimal[0] : width;
+ renderH = useRr ? optimal[1] : height;
+ renderSizeRrEnabled = useRr;
+ renderSizeRrQuality = activeRrQuality;
+
+ output = ctx.createStorageImage(renderW, renderH,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH);
long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH);
long continuationBytes = Math.multiplyExact(
- Math.multiplyExact(pixelRecords, 2L), PATH_RECORD_BYTES);
+ Math.multiplyExact(pixelRecords, (long) PATH_SEGMENTS_PER_PIXEL), PATH_RECORD_BYTES);
continuationQueue = ctx.createBuffer(continuationBytes,
VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false,
- "path continuation queue " + renderW + "x" + renderH + "x2");
- displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height);
- // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active.
- hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height);
- // Bloom pyramid. Level 0 is half display resolution (the prefilter's 13-tap already covers a 5x5
- // display-pixel footprint, so nothing is lost by starting there); each further level halves again
- // until the look package's level count or the smallest useful size is reached.
+ "path continuation queue " + renderW + "x" + renderH + "x" + PATH_SEGMENTS_PER_PIXEL);
+ displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM,
+ "RT display image " + width + "x" + height);
+ hdrDisplayImage = ctx.createStorageImage(width, height,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height);
+
int bloomWidth = Math.max(1, (width + 1) / 2);
int bloomHeight = Math.max(1, (height + 1) / 2);
int bloomLevelCount = RtBloomPipeline.levelsFor(bloomWidth, bloomHeight, LOOK.bloom().levels());
@@ -961,27 +1470,52 @@ private void ensureOutput(RtContext ctx, int width, int height) {
bloomWidth = Math.max(1, bloomWidth / 2);
bloomHeight = Math.max(1, bloomHeight / 2);
}
- // Guide buffers match the trace (render) resolution; DLSS-RR consumes them at render res.
- gNormal = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide normal roughness " + renderW + "x" + renderH);
- gAlbedo = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide diffuse albedo " + renderW + "x" + renderH);
- gDepth = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R32_SFLOAT, "guide linear depth " + renderW + "x" + renderH);
- gMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT, "guide motion " + renderW + "x" + renderH);
- gSpecAlbedo = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide specular albedo " + renderW + "x" + renderH);
- gSpecMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT, "guide specular motion " + renderW + "x" + renderH);
- // Display-res RT image the display mapper reads. Always present (DLSS-RR target, or blit-upscale fallback).
- rrOutput = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "DLSS-RR output " + width + "x" + height);
+
+ gNormal = ctx.createStorageImage(renderW, renderH,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide normal roughness " + renderW + "x" + renderH);
+ gAlbedo = ctx.createStorageImage(renderW, renderH,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide diffuse albedo " + renderW + "x" + renderH);
+ gDepth = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R32_SFLOAT,
+ "guide linear depth " + renderW + "x" + renderH);
+ gMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT,
+ "guide motion " + renderW + "x" + renderH);
+ gSpecAlbedo = ctx.createStorageImage(renderW, renderH,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide specular albedo " + renderW + "x" + renderH);
+ gSpecMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT,
+ "guide specular motion " + renderW + "x" + renderH);
+ gResponsivity = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16_SFLOAT,
+ "guide responsivity " + renderW + "x" + renderH);
+ gParticleMask = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R8_UINT,
+ "guide particle mask " + renderW + "x" + renderH);
+ gSkyClassification = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16_SFLOAT,
+ "guide primary-sky classification " + renderW + "x" + renderH);
+ rrOutput = ctx.createStorageImage(width, height,
+ VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "DLSS-RR output " + width + "x" + height);
exposure.ensureResources(ctx);
- mvHasPrev = false; // recreated images -> first MV frame is zero
+ mvHasPrev = false;
waterWaveTimeValid = false;
if (worldPipeline != null) {
worldPipeline.setStorageImage(output.view);
bindGuideImages();
}
- RtToneLut boundLookLut = lookLut;
+ EndSkyBinding endSky = requireEndSkyBinding();
+ long fallbackAtlasView = blockAlbedoAtlasView();
+ long celestialsView = celestialsAtlasView();
+ bindPresentationDescriptors(lookLut, endSky, fallbackAtlasView,
+ celestialsView, atlasSampler(ctx));
+ }
+
+ private void bindPresentationDescriptors(RtToneLut boundLookLut, EndSkyBinding endSky,
+ long fallbackAtlasView, long celestialsView,
+ long atlasSamplerHandle) {
displayPipeline.setImages(displayImage.view, rrOutput.view, exposure.image().view, hdrDisplayImage.view,
sdrToneLut.view(), sdrToneLut.sampler(), hdrToneLut.view(), hdrToneLut.sampler(),
- boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler());
+ boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler(),
+ gSkyClassification.view,
+ endSky.view(), endSky.sampler(),
+ celestialsView != 0L ? celestialsView : fallbackAtlasView,
+ atlasSamplerHandle);
bloomPipeline.setImages(rrOutput.view, exposure.image().view, bloomLevels);
debugPresentPipeline.setImages(displayImage.view, gNormal.view, gAlbedo.view, gDepth.view,
gMotion.view, gSpecAlbedo.view, gSpecMotion.view, rrOutput.view, exposure.image().view,
@@ -1022,7 +1556,7 @@ private void updateMotion() {
mvHasPrev = true;
}
- private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColor) {
+ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColor, int frameSpp) {
long dstImage = vkImage(nativeColor);
var encoder = (VulkanCommandEncoder) ((CommandEncoderAccessor) RenderSystem.getDevice().createCommandEncoder()).caustica$getBackend();
RtGpuExecutor gpuExecutor = ctx.gpuExecutor();
@@ -1032,20 +1566,36 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
// Reuse a completed readback slot, then latch one pre-exposure value for both raygen and resolve.
// This belongs after the timeline snapshot and before any world push data is written.
exposure.beginFrame(graphicsUseWaiter);
+ if (renderW > PATH_PIXEL_AXIS_LIMIT || renderH > PATH_PIXEL_AXIS_LIMIT) {
+ throw new IllegalStateException("Path sampler requires render dimensions at or below 65536: "
+ + renderW + "x" + renderH);
+ }
+ int pathSampleBase = reservePathSamples(frameSpp);
+ RtPathSamplerData samplerData = Objects.requireNonNull(pathSamplerData,
+ "Path sampler data must exist before recording an RT frame");
+ long pathSampleAddress = samplerData.deviceAddress();
+ if (pathSampleAddress == 0L) {
+ throw new IllegalStateException("Path sampler data lost its device address");
+ }
pendingGraphicsUse = graphicsUse;
RtEntities.FrameEntities frameEntities = null;
+ boolean rrProduced = false;
VkCommandBuffer cmd = encoder.allocateAndBeginTransientCommandBuffer();
RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_COMMAND_BUFFER, cmd.address(), "composite command buffer");
int debugView = debugView();
RtTerrain terrain = RtTerrain.currentOrNull();
+ boolean sharcOn = sharcActive() && terrain != null;
try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope frameLabel = RtDebugLabels.scope(ctx, cmd, "composite frame")) {
- // RR drives the upscale: trace + jitter at render res, DLSS-RR denoises+upscales to display.
- // A debug view observes this ordinary path; it never changes jitter or disables RR.
- boolean rrPath = RtDlssRr.enabled();
+ // RR drives the ordinary upscale. Raw debug is the explicit exception: it traces at full
+ // display resolution, uses no jitter, and never enters DLSS-RR or the debug-present compositor.
+ boolean rawDebug = rawDebugView();
+ boolean rrPath = RtDlssRr.enabled() && !RtDlssRr.INSTANCE.hasFailed() && !rawDebug;
+ float mipMapBias = rrPath ? RtDlssRr.recommendedMipMapBias(renderW, displayW) : 0.0f;
float jitterX = 0f;
float jitterY = 0f;
if (rrPath) {
- CausticaJitter.INSTANCE.prepare(renderW, renderH, displayW);
+ CausticaJitter.INSTANCE.prepare(renderW, renderH, displayW, displayH);
+ jitterPhaseCount = CausticaJitter.INSTANCE.currentPhaseCount();
jitterX = CausticaJitter.INSTANCE.jitterPixelsX() * jitterSignX();
jitterY = CausticaJitter.INSTANCE.jitterPixelsY() * jitterSignY();
}
@@ -1119,8 +1669,29 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
// 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
// resolved slot rides along with the uploadPending() call right below.
- BreakEntry[] breaking = breakingEntries(terrain);
- SkyPush sky = skyPush();
+ BreakEntry[] breaking;
+ SkyPush sky;
+ if (CaptureSession.active() && captureWorldPushFrozen) {
+ flags = captureFlags;
+ waterParams = captureWaterParams;
+ waterAnchor = captureWaterAnchor;
+ breaking = captureBreaking;
+ sky = captureSky;
+ } else {
+ breaking = breakingEntries(terrain);
+ sky = skyPush();
+ if (CaptureSession.active()) {
+ captureFlags = flags;
+ captureWaterParams = waterParams;
+ captureWaterAnchor = waterAnchor;
+ captureBreaking = breaking;
+ captureSky = sky;
+ captureWorldPushFrozen = true;
+ }
+ }
+ if (sharcOn) {
+ updateSharcResetPolicy(terrain, sky);
+ }
new WorldPushData(
frameInvViewProj,
new Float3((float) (camX - terrain.blockX), (float) (camY - terrain.blockY),
@@ -1128,7 +1699,7 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
(int) frameCounter,
mvPushMatrix,
new Float3(mvCamDeltaX, mvCamDeltaY, mvCamDeltaZ),
- spp(),
+ frameSpp,
new Float2(jitterX, jitterY),
flags,
maxBounces(),
@@ -1154,13 +1725,28 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
new Int4(terrain.lightGridDimX(), terrain.lightGridDimY(), terrain.lightGridDimZ(), 0),
terrain.lightCount(),
CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(),
+ mipMapBias,
// 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(),
+ pathSampleBase,
+ pathSampleEpoch,
+ pathSampleAddress,
+ sky.skybox(),
+ sky.skyFlags(),
+ sky.skyColor(),
+ sky.skyParams(),
+ sky.endFlashUv()
).write(push);
pushBuf.flush(0L, WORLD_PUSH_SIZE);
// Upload any entity textures registered this frame into the bindless set before the trace.
- RtEntityTextures.INSTANCE.uploadPending(active, atlasSampler(ctx));
+ long textureSampler = atlasSampler(ctx);
+ if (sharcQueryPipeline != null && sharcUpdatePipeline != null) {
+ RtEntityTextures.INSTANCE.uploadPending(textureSampler, active,
+ sharcQueryPipeline, sharcUpdatePipeline);
+ } else {
+ RtEntityTextures.INSTANCE.uploadPending(active, textureSampler);
+ }
// Build the entity BLAS, the TLAS that references it and the terrain BLAS, then the trace.
// Barriers separate each stage; the graphics-use timeline guards resource reuse.
if (!fe.blas().isEmpty()) {
@@ -1169,17 +1755,31 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
}
VulkanCommandEncoder.memoryBarrier(cmd, stack); // entity BLAS writes visible to the TLAS build
}
- RtAccel.PreparedTlas frameTlas;
- try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.prepareTlas")) {
- frameTlas = RtAccel.prepareTlas(ctx, fe.baseInstances(), fe.dynamicInstances(), tlasRing,
- graphicsUse);
+ RtAccel.PreparedTlas frameTlas = captureTlas;
+ boolean buildTlas = frameTlas == null;
+ if (buildTlas) {
+ try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.prepareTlas")) {
+ frameTlas = RtAccel.prepareTlas(ctx, fe.baseInstances(), fe.dynamicInstances(), tlasRing,
+ graphicsUse);
+ }
+ if (CaptureSession.active()) {
+ captureTlas = frameTlas;
+ }
+ } else {
+ RtAccel.markTlasUsed(frameTlas, graphicsUse);
}
active.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter);
+ if (sharcOn) {
+ sharcUpdatePipeline.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter);
+ sharcQueryPipeline.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter);
+ }
currentTlasHandle = frameTlas.accel.handle;
- try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.recordTlas")) {
- RtAccel.recordTlasBuild(ctx, cmd, frameTlas);
+ if (buildTlas) {
+ try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.recordTlas")) {
+ RtAccel.recordTlasBuild(ctx, cmd, frameTlas);
+ }
+ VulkanCommandEncoder.memoryBarrier(cmd, stack); // TLAS build visible to the trace
}
- VulkanCommandEncoder.memoryBarrier(cmd, stack); // TLAS build visible to the trace
// Push the BDA ring slot's address plus the small hot subset used directly by the shaders.
// Every 64-bit device address the trace needs lives here, not behind worldPushAddr: the
@@ -1193,6 +1793,25 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(),
terrain.lightGridSpanBufferAddress(), continuationQueue.deviceAddress,
(int) frameCounter).write(pushConstants);
+ long sharcFrameAddress = 0L;
+ ByteBuffer sharcPushConstants = null;
+ int sharcTileSize = 0;
+ if (sharcOn) {
+ sharcTileSize = RtSharcCache.updateTileSize();
+ sharcFrameAddress = sharcCache.beginFrame(frameCounter,
+ (float) (camX - terrain.blockX), (float) (camY - terrain.blockY),
+ (float) (camZ - terrain.blockZ), graphicsUseWaiter);
+ sharcPushConstants = stack.malloc(SharcPushConstantsData.BYTE_SIZE);
+ new SharcPushConstantsData(pushBuf.deviceAddress, terrain.tableAddress(), fe.geomTableAddr(),
+ RtMaterialRegistry.INSTANCE.tableAddress(), terrain.lightBufferAddress(),
+ terrain.lightAliasBufferAddress(), terrain.lightLocalAliasBufferAddress(),
+ terrain.lightGridCellBufferAddress(), terrain.lightGridSpanBufferAddress(),
+ continuationQueue.deviceAddress, (int) frameCounter, sharcFrameAddress,
+ sharcTileSize, renderW, renderH,
+ CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.value(),
+ CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value() ? 1 : 0)
+ .write(sharcPushConstants);
+ }
// Sky LUTs, from the same WorldPush slot the trace is about to read: the sky the LUT holds and
// the sky the frame shades are built from one set of angles, not two. Recorded here (after the
// push flush, before the trace) so the miss shader's very first fetch sees this frame's dome.
@@ -1206,9 +1825,35 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
active.trace(cmd, renderW, renderH, pushConstants, 0);
}
VulkanCommandEncoder.memoryBarrier(cmd, stack); // continuation/guide writes visible to pass B
- try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace");
- RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) {
- active.trace(cmd, renderW, renderH, pushConstants, 1);
+ if (sharcOn) {
+ sharcCache.recordPendingClear(cmd, stack);
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC sparse update");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcUpdate")) {
+ sharcUpdatePipeline.trace(cmd, (renderW + sharcTileSize - 1) / sharcTileSize,
+ (renderH + sharcTileSize - 1) / sharcTileSize, sharcPushConstants, 0);
+ }
+ if (sharcCache.queryReady()) {
+ sharcCache.updateToResolveBarrier(cmd, stack);
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC resolve");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcResolve")) {
+ sharcResolvePipeline.dispatch(cmd, sharcFrameAddress, sharcCache.capacity());
+ }
+ sharcCache.resolveToQueryBarrier(cmd, stack);
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC query");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcQuery")) {
+ sharcQueryPipeline.trace(cmd, renderW, renderH, sharcPushConstants, 0);
+ }
+ } else {
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace (SHaRC warmup)");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) {
+ active.trace(cmd, renderW, renderH, pushConstants, 1);
+ }
+ }
+ } else {
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) {
+ active.trace(cmd, renderW, renderH, pushConstants, 1);
+ }
}
VulkanCommandEncoder.memoryBarrier(cmd, stack); // RT writes visible to DLSS reads
// DLSS-RR denoise + upscale. The RT pass wrote noisy color (render res) + guides;
@@ -1217,7 +1862,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "DLSS-RR evaluate");
RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.dlssRr")) {
rrDone = RtDlssRr.INSTANCE.evaluate(cmd.address(), output, gDepth, gMotion, gAlbedo,
- gSpecAlbedo, gNormal, gSpecMotion, rrOutput, renderW, renderH, displayW, displayH,
+ gSpecAlbedo, gNormal, gSpecMotion, gParticleMask, gResponsivity, rrOutput,
+ renderW, renderH, displayW, displayH,
-jitterX, -jitterY, frameViewRotation, frameProjection);
}
}
@@ -1242,10 +1888,12 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
// the histogram's log-luminance average biased by Monte-Carlo noise (Jensen's inequality
// on the concave log()), so the computed exposure drifted with SPP; rrOutput is stable
// regardless of SPP, keeping exposure consistent.
- try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure");
- RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.exposure")) {
- exposure.record(ctx, cmd, stack, rrOutput, gDepth, gAlbedo);
- exposure.recordStateReadback(cmd, stack);
+ if (!exposure.captureFrozen()) {
+ try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure");
+ RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.exposure")) {
+ exposure.record(ctx, cmd, stack, rrOutput, gDepth, gAlbedo);
+ exposure.recordStateReadback(cmd, stack);
+ }
}
VulkanCommandEncoder.memoryBarrier(cmd, stack); // exposure image visible to the display mapper
@@ -1260,14 +1908,19 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "map RT to display");
RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.displayMap")) {
- displayPipeline.dispatch(cmd, displayW, displayH, CausticaConfig.Rt.Hdr.enabled(),
- sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), loadedHdrLutNits,
- true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length);
+ int displayPeakNits = CausticaConfig.Rt.Hdr.effectivePeakNits();
+ displayPipeline.dispatch(cmd, displayW, displayH, RtToneMapping.current(),
+ sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), displayPeakNits,
+ true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length,
+ frameInvViewProj, sky.skybox(), sky.skyFlags(),
+ sky.skyColor().x(), sky.skyColor().y(), sky.skyColor().z(), sky.skyColor().w(),
+ sky.skyParams().y(), sky.skyParams().z(), sky.skyParams().w(),
+ sky.endFlashUv().x(), sky.endFlashUv().y(), sky.endFlashUv().z(), sky.endFlashUv().w());
}
hdrWrittenThisFrame = CausticaConfig.Rt.Hdr.enabled();
VulkanCommandEncoder.memoryBarrier(cmd, stack); // display output visible to debug composite
- if (debugView != 0) {
+ if (debugView != 0 && !rawDebug) {
// Debug content is composited only after the real scene has completed trace, RR/fallback,
// exposure, and display mapping. It therefore observes the renderer without perturbing
// exposure history or feeding literal diagnostic colors through ACES. Debug presentation
@@ -1288,15 +1941,21 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo
dstImage, VK10.VK_IMAGE_LAYOUT_GENERAL, copyRegion(stack, displayW, displayH));
}
VulkanCommandEncoder.memoryBarrier(cmd, stack);
+ rrProduced = rrDone;
}
- if (VK10.vkEndCommandBuffer(cmd) != VK10.VK_SUCCESS) {
- throw new IllegalStateException("vkEndCommandBuffer(rt composite) failed");
- }
- encoder.execute(cmd); // deferred into the frame's submission — correct for per-frame work
- // Do not attach a merely reserved token: failed recording may never signal it. Once execute succeeds,
- // every owner in this frame's manifest is protected through the final overlay consumer.
- RtEntities.INSTANCE.markGraphicsUse(frameEntities, graphicsUse);
- exposure.markStateReadbackUse(graphicsUse);
+ if (VK10.vkEndCommandBuffer(cmd) != VK10.VK_SUCCESS) {
+ throw new IllegalStateException("vkEndCommandBuffer(rt composite) failed");
+ }
+ encoder.execute(cmd); // deferred into the frame's submission — correct for per-frame work
+ freshRtFrame = true;
+ freshDlssRrFrame = rrProduced;
+ // Do not attach a merely reserved token: failed recording may never signal it. Once execute succeeds,
+ // every owner in this frame's manifest is protected through the final overlay consumer.
+ RtEntities.INSTANCE.markGraphicsUse(frameEntities, graphicsUse);
+ exposure.markStateReadbackUse(graphicsUse);
+ if (sharcOn) {
+ sharcCache.commitFrameUse(graphicsUse);
+ }
}
/**
@@ -1334,10 +1993,54 @@ private BreakEntry[] breakingEntries(RtTerrain terrain) {
return count == result.length ? result : java.util.Arrays.copyOf(result, count);
}
- private record SkyPush(Float4 celestial, Float4 look0, Float4 look1, Float4 look2, Float4 look3,
- Float4 sunUv, Float4 moonUv) {}
+ static final float SHARC_SKY_ANGLE_JUMP_RADIANS = 0.1f;
+ static final float SHARC_SKY_VALUE_JUMP = 0.25f;
+
+ record SharcSkyState(int skybox, int skyFlags, float sunAngle, float moonAngle, float starAngle,
+ float starBrightness, int moonPhase, float skyR, float skyG, float skyB) {
+ private static SharcSkyState from(SkyPush sky) {
+ return new SharcSkyState(sky.skybox(), sky.skyFlags(), sky.celestial().x(), sky.celestial().y(),
+ sky.celestial().z(), sky.celestial().w(), Math.round(sky.look3().w()),
+ sky.skyColor().x(), sky.skyColor().y(), sky.skyColor().z());
+ }
+ }
+
+ static boolean hardSkyDiscontinuity(SharcSkyState previous, SharcSkyState current) {
+ if (previous == null) {
+ return false;
+ }
+ if (previous.skybox() != current.skybox() || previous.skyFlags() != current.skyFlags()
+ || previous.moonPhase() != current.moonPhase()) {
+ return true;
+ }
+ return angularDistance(previous.sunAngle(), current.sunAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS
+ || angularDistance(previous.moonAngle(), current.moonAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS
+ || angularDistance(previous.starAngle(), current.starAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS
+ || finiteDistance(previous.starBrightness(), current.starBrightness()) > SHARC_SKY_VALUE_JUMP
+ || finiteDistance(previous.skyR(), current.skyR()) > SHARC_SKY_VALUE_JUMP
+ || finiteDistance(previous.skyG(), current.skyG()) > SHARC_SKY_VALUE_JUMP
+ || finiteDistance(previous.skyB(), current.skyB()) > SHARC_SKY_VALUE_JUMP;
+ }
+
+ private static float angularDistance(float first, float second) {
+ if (!Float.isFinite(first) || !Float.isFinite(second)) {
+ return Float.POSITIVE_INFINITY;
+ }
+ float fullTurn = (float) (Math.PI * 2.0);
+ float difference = Math.abs(first - second) % fullTurn;
+ return Math.min(difference, fullTurn - difference);
+ }
+
+ private static float finiteDistance(float first, float second) {
+ return Float.isFinite(first) && Float.isFinite(second)
+ ? Math.abs(first - second) : Float.POSITIVE_INFINITY;
+ }
+
+ private record SkyPush(int skybox, int skyFlags, Float4 skyColor, Float4 skyParams,
+ Float4 endFlashUv, Float4 celestial, Float4 look0, Float4 look1, Float4 look2,
+ Float4 look3, Float4 sunUv, Float4 moonUv) {}
- private record CelestialUv(Float4 sun, Float4 moon) {}
+ private record CelestialUv(Float4 sun, Float4 moon, Float4 endFlash) {}
/**
* This frame's sky state: Minecraft's four eased celestial angles, its star brightness, the moon
@@ -1364,6 +2067,35 @@ private record CelestialUv(Float4 sun, Float4 moon) {}
private SkyPush skyPush() {
Minecraft mc = Minecraft.getInstance();
float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false);
+ int mode = frameSkyboxMode;
+ EndFlashState endFlash = mode == RtSkyMath.SKYBOX_END && mc.level != null
+ ? mc.level.endFlashState() : null;
+ float endFlashIntensity = endFlash == null ? 0.0f : finiteColor(endFlash.getIntensity(partial));
+ boolean endFlashActive = mode == RtSkyMath.SKYBOX_END && endFlashIntensity > 1.0e-4f;
+ if (!endFlashStateValid || previousEndFlashActive != endFlashActive) {
+ if (endFlashStateValid) {
+ RtDlssRr.INSTANCE.requestHistoryReset();
+ }
+ previousEndFlashActive = endFlashActive;
+ endFlashStateValid = true;
+ }
+ float endFlashX = endFlash == null || !Float.isFinite(endFlash.getXAngle())
+ ? 0.0f : endFlash.getXAngle() * (float) (Math.PI / 180.0);
+ float endFlashY = endFlash == null || !Float.isFinite(endFlash.getYAngle())
+ ? 0.0f : endFlash.getYAngle() * (float) (Math.PI / 180.0);
+ Float4 skyColor = new Float4(frameSkyColorR, frameSkyColorG, frameSkyColorB, frameSkyColorA);
+ Float4 skyParams = new Float4(0.0f, endFlashIntensity, endFlashX, endFlashY);
+ RtLookPackage.Sky sky = LOOK.sky();
+ RtLookPackage.Lighting lighting = LOOK.lighting();
+ if (mode != RtSkyMath.SKYBOX_OVERWORLD) {
+ CelestialUv uv = celestialUv(0.0f);
+ return new SkyPush(
+ mode, endFlashActive ? RtSkyMath.SKY_FLAG_END_FLASH : 0, skyColor, skyParams,
+ uv.endFlash(),
+ new Float4(0f, 0f, 0f, 0f), new Float4(0f, 0f, 0f, 0f),
+ new Float4(0f, 0f, 0f, 0f), new Float4(0f, 0f, 0f, 0f),
+ new Float4(0f, 0f, 0f, 0f), uv.sun(), uv.moon());
+ }
var probe = mc.gameRenderer.mainCamera().attributeProbe();
int seaLevel = mc.level != null ? mc.level.getSeaLevel() : 0;
float viewerAltitudeKm = Math.clamp((float) ((camY - seaLevel) / 100.0), 0.0f, 99.0f);
@@ -1377,10 +2109,9 @@ private SkyPush skyPush() {
float starBrightness = probe.getValue(EnvironmentAttributes.STAR_BRIGHTNESS, partial);
float moonPhase = probe.getValue(EnvironmentAttributes.MOON_PHASE, partial).index(); // 0 full .. 4 new
- RtLookPackage.Sky sky = LOOK.sky();
- RtLookPackage.Lighting lighting = LOOK.lighting();
CelestialUv uv = celestialUv(moonPhase);
return new SkyPush(
+ mode, 0, skyColor, skyParams, uv.endFlash(),
new Float4(sunAngle, moonAngle, starAngle, starBrightness),
new Float4(lighting.sunIlluminanceLux(), lighting.moonIlluminanceLux(),
lighting.nightAirglowLuminanceCdM2(), lighting.starLuminanceCdM2()),
@@ -1411,7 +2142,8 @@ private CelestialUv celestialUv(float moonPhaseIndex) {
}
return new CelestialUv(
new Float4(sunU0, sunV0, sunU1, sunV1),
- new Float4(moonU0, moonV0, moonU1, moonV1));
+ new Float4(moonU0, moonV0, moonU1, moonV1),
+ new Float4(endFlashU0, endFlashV0, endFlashU1, endFlashV1));
}
private void setCelestialUvAtlas(long atlasHandle) {
@@ -1422,11 +2154,13 @@ private void setCelestialUvAtlas(long atlasHandle) {
celestialUvMoonPhase = -1;
sunU0 = 0f; sunV0 = 0f; sunU1 = 1f; sunV1 = 1f;
moonU0 = 0f; moonV0 = 0f; moonU1 = 1f; moonV1 = 1f;
+ endFlashU0 = 0f; endFlashV0 = 0f; endFlashU1 = 1f; endFlashV1 = 1f;
}
private void refreshCelestialUvCache(int moonPhase) {
sunU0 = 0f; sunV0 = 0f; sunU1 = 1f; sunV1 = 1f;
moonU0 = 0f; moonV0 = 0f; moonU1 = 1f; moonV1 = 1f;
+ endFlashU0 = 0f; endFlashV0 = 0f; endFlashU1 = 1f; endFlashV1 = 1f;
try {
if (celestialUvAtlasHandle != 0L) {
TextureAtlas atlas = Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.CELESTIALS);
@@ -1434,6 +2168,9 @@ private void refreshCelestialUvCache(int moonPhase) {
sunU0 = sun.getU0(); sunV0 = sun.getV0(); sunU1 = sun.getU1(); sunV1 = sun.getV1();
TextureAtlasSprite moon = atlas.getSprite(MOON_IDS[moonPhase]);
moonU0 = moon.getU0(); moonV0 = moon.getV0(); moonU1 = moon.getU1(); moonV1 = moon.getV1();
+ TextureAtlasSprite endFlash = atlas.getSprite(END_FLASH_ID);
+ endFlashU0 = endFlash.getU0(); endFlashV0 = endFlash.getV0();
+ endFlashU1 = endFlash.getU1(); endFlashV1 = endFlash.getV1();
}
} catch (Exception ignored) {
// celestials atlas not yet loaded — keep full-range UVs (fallback texture is the block atlas)
@@ -1460,13 +2197,12 @@ private static double srgbToLinear(double value) {
: Math.pow((value + 0.055) / 1.055, 2.4);
}
- public void destroy() {
+ /** Destroy compositor resources and report whether the native RR feature released its device ownership. */
+ public boolean destroy() {
// Teardown runs after the device is idle (CLIENT_STOPPING waits), so the TLAS ring's slots are no
// longer in flight and can be freed immediately.
tlasRing.destroy();
- if (RtDlssRr.enabled()) {
- RtDlssRr.INSTANCE.destroy();
- }
+ boolean rrReleased = RtDlssRr.INSTANCE.destroy();
if (displayImage != null) {
displayImage.destroy();
displayImage = null;
@@ -1484,7 +2220,7 @@ public void destroy() {
fgHdrHudlessImage.destroy();
fgHdrHudlessImage = null;
}
- RtWorldOverlay.INSTANCE.destroy(); // overlay features/pipelines/scratch live on the same device lifetime
+ RtWorldOverlay.INSTANCE.destroy();
if (output != null) {
output.destroy();
output = null;
@@ -1493,6 +2229,14 @@ public void destroy() {
continuationQueue.destroy();
continuationQueue = null;
}
+ if (pathSamplerData != null) {
+ pathSamplerData.destroy();
+ pathSamplerData = null;
+ }
+ pathSampleCursor = 0L;
+ pathSampleEpoch = 0;
+ pathSamplerResetPending = true;
+ pathSamplingPolicySignature = Long.MIN_VALUE;
destroyGuideImages();
exposure.destroy();
if (displayPipeline != null) {
@@ -1552,6 +2296,7 @@ public void destroy() {
fgInterpW = -1;
fgInterpH = -1;
fgInterpFormat = Integer.MIN_VALUE;
+ destroySharcResources();
if (worldPipeline != null) {
worldPipeline.destroy();
worldPipeline = null;
@@ -1575,6 +2320,7 @@ public void destroy() {
}
atlasSampler = 0L;
}
+ return rrReleased;
}
private long atlasSampler(RtContext ctx) {
@@ -1921,6 +2667,36 @@ public void captureFgHudless(RenderTarget main) {
encoder.execute(cmd);
}
+ /** The End sky is a standalone Minecraft texture, not a sprite in the celestials atlas. */
+ private record EndSkyBinding(long view, long sampler) {}
+
+ private static final class EndSkyUnavailableException extends RuntimeException {
+ private EndSkyUnavailableException() {
+ super("Minecraft End sky texture has no Vulkan view/sampler");
+ }
+ }
+
+ private static EndSkyBinding requireEndSkyBinding() {
+ EndSkyBinding binding = endSkyBinding();
+ if (binding.view() == 0L || binding.sampler() == 0L) {
+ throw new EndSkyUnavailableException();
+ }
+ return binding;
+ }
+
+ private static EndSkyBinding endSkyBinding() {
+ try {
+ AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(END_SKY_ID);
+ if (!(texture.getTextureView() instanceof VulkanGpuTextureView view)
+ || !(texture.getSampler() instanceof VulkanGpuSampler sampler)) {
+ return new EndSkyBinding(0L, 0L);
+ }
+ return new EndSkyBinding(view.vkImageView(), sampler.vkSampler());
+ } catch (Throwable ignored) {
+ return new EndSkyBinding(0L, 0L);
+ }
+ }
+
/**
* HDR counterpart of {@link #captureFgHudless} — copies {@code src} (this frame's {@code hdrDisplayImage},
* before the combined UI overlay is blended in) into {@link #fgHdrHudlessImage} for {@link
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java
index 079f9224..3d6353f9 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java
@@ -66,11 +66,13 @@ public final class RtContext {
private final int shaderGroupHandleAlignment;
private final int maxShaderGroupStride;
private final int accelerationStructureScratchAlignment;
+ private final int maxPushConstantsSize;
private final long updateAfterBindCombinedImageSamplerLimit;
private long commandPool;
private RtContext(VulkanDevice device, long vma, int handleSize, int baseAlign, int handleAlign,
- int maxSbtStride, int scratchAlign, long updateAfterBindCombinedImageSamplerLimit) {
+ int maxSbtStride, int scratchAlign, int maxPushConstantsSize,
+ long updateAfterBindCombinedImageSamplerLimit) {
this.device = device;
this.vk = device.vkDevice();
this.vma = vma;
@@ -82,6 +84,7 @@ private RtContext(VulkanDevice device, long vma, int handleSize, int baseAlign,
this.shaderGroupHandleAlignment = handleAlign;
this.maxShaderGroupStride = maxSbtStride;
this.accelerationStructureScratchAlignment = scratchAlign;
+ this.maxPushConstantsSize = maxPushConstantsSize;
this.updateAfterBindCombinedImageSamplerLimit = updateAfterBindCombinedImageSamplerLimit;
this.gpuExecutor = new RtGpuExecutor(this);
}
@@ -157,14 +160,17 @@ private static RtContext create(VulkanDevice device) {
CausticaMod.LOGGER.info(
"RT portability limits: SBT handleAlignment={}, baseAlignment={}, maxStride={}; "
- + "AS scratchAlignment={}; update-after-bind combined-sampler limit={}",
+ + "AS scratchAlignment={}; maxPushConstantsSize={}; "
+ + "update-after-bind combined-sampler limit={}",
rtProps.shaderGroupHandleAlignment(), rtProps.shaderGroupBaseAlignment(),
Integer.toUnsignedLong(rtProps.maxShaderGroupStride()),
- asProps.minAccelerationStructureScratchOffsetAlignment(), combinedImageSamplerLimit);
+ asProps.minAccelerationStructureScratchOffsetAlignment(), limits.maxPushConstantsSize(),
+ combinedImageSamplerLimit);
return new RtContext(device, pVma.get(0), rtProps.shaderGroupHandleSize(), rtProps.shaderGroupBaseAlignment(),
rtProps.shaderGroupHandleAlignment(), rtProps.maxShaderGroupStride(),
- asProps.minAccelerationStructureScratchOffsetAlignment(), combinedImageSamplerLimit);
+ asProps.minAccelerationStructureScratchOffsetAlignment(), limits.maxPushConstantsSize(),
+ combinedImageSamplerLimit);
}
}
@@ -216,6 +222,11 @@ public int maxShaderGroupStride() {
return maxShaderGroupStride;
}
+ /** Device-reported limit for one Vulkan push-constant range, in bytes. */
+ public int maxPushConstantsSize() {
+ return maxPushConstantsSize;
+ }
+
/** Conservative combined-image-sampler limit for a descriptor set using update-after-bind. */
public long updateAfterBindCombinedImageSamplerLimit() {
return updateAfterBindCombinedImageSamplerLimit;
@@ -552,9 +563,13 @@ private void ensurePool() {
}
public static void check(int rc, String what) {
+ check(instance != null ? instance.device : null, rc, what);
+ }
+
+ public static void check(VulkanDevice owner, int rc, String what) {
if (rc != VK10.VK_SUCCESS) {
- if (rc == VK10.VK_ERROR_DEVICE_LOST && instance != null) {
- VulkanDiagnostics.reportDeviceLost(instance.device, what);
+ if (rc == VK10.VK_ERROR_DEVICE_LOST && owner != null) {
+ VulkanDiagnostics.reportDeviceLost(owner, what);
}
throw new IllegalStateException(what + " failed: " + rc);
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java
index b9bcc97e..1cd426f2 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java
@@ -24,6 +24,7 @@
import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT;
import org.lwjgl.vulkan.VkPhysicalDeviceFeatures;
import org.lwjgl.vulkan.VkPhysicalDeviceVulkan12Features;
+import org.lwjgl.vulkan.VkPhysicalDeviceVulkan11Features;
import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapFeaturesEXT;
import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapPropertiesEXT;
import org.lwjgl.vulkan.VkPhysicalDevicePresentIdFeaturesKHR;
@@ -146,6 +147,9 @@ public static boolean enabledByProperty() {
private static final VulkanPNextStruct PRESENT_ID_FEATURES_STRUCT = new VulkanPNextStruct(
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR,
VkPhysicalDevicePresentIdFeaturesKHR.SIZEOF);
+ private static final VulkanPNextStruct VULKAN_11_FEATURES_STRUCT = new VulkanPNextStruct(
+ VK12.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES,
+ VkPhysicalDeviceVulkan11Features.SIZEOF);
private static final VulkanFeature BUFFER_DEVICE_ADDRESS_FEATURE = new VulkanFeature(
VulkanBackend.VK12_FEATURES_STRUCT, "bufferDeviceAddress",
@@ -162,6 +166,11 @@ public static boolean enabledByProperty() {
private static final VulkanFeature SAMPLED_IMAGE_UPDATE_AFTER_BIND_FEATURE = new VulkanFeature(
VulkanBackend.VK12_FEATURES_STRUCT, "descriptorBindingSampledImageUpdateAfterBind",
VkPhysicalDeviceVulkan12Features.DESCRIPTORBINDINGSAMPLEDIMAGEUPDATEAFTERBIND);
+ private static final VulkanFeature SHADER_INT16_FEATURE = new VulkanFeature(
+ VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt16", VkPhysicalDeviceFeatures.SHADERINT16);
+ private static final VulkanFeature SHADER_FLOAT16_FEATURE = new VulkanFeature(
+ VulkanBackend.VK12_FEATURES_STRUCT, "shaderFloat16",
+ VkPhysicalDeviceVulkan12Features.SHADERFLOAT16);
private static final VulkanFeature SHADER_INT64_FEATURE = new VulkanFeature(
VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt64", VkPhysicalDeviceFeatures.SHADERINT64);
private static final VulkanFeature ACCELERATION_STRUCTURE_FEATURE = new VulkanFeature(
@@ -184,6 +193,12 @@ public static boolean enabledByProperty() {
PRESENT_ID_FEATURES_STRUCT, "presentId", VkPhysicalDevicePresentIdFeaturesKHR.PRESENTID);
private static final VulkanFeature WIDE_LINES_FEATURE = new VulkanFeature(
VulkanBackend.VK10_FEATURES_STRUCT, "wideLines", VkPhysicalDeviceFeatures.WIDELINES);
+ private static final VulkanFeature SHARC_BUFFER_INT64_ATOMICS_FEATURE = new VulkanFeature(
+ VulkanBackend.VK12_FEATURES_STRUCT, "shaderBufferInt64Atomics",
+ VkPhysicalDeviceVulkan12Features.SHADERBUFFERINT64ATOMICS);
+ private static final VulkanFeature SHARC_STORAGE_BUFFER_16_FEATURE = new VulkanFeature(
+ VULKAN_11_FEATURES_STRUCT, "storageBuffer16BitAccess",
+ VkPhysicalDeviceVulkan11Features.STORAGEBUFFER16BITACCESS);
private static final List REQUIRED_RT_FEATURES = List.of(
BUFFER_DEVICE_ADDRESS_FEATURE,
@@ -191,6 +206,8 @@ public static boolean enabledByProperty() {
SAMPLED_IMAGE_NON_UNIFORM_FEATURE,
DESCRIPTOR_PARTIALLY_BOUND_FEATURE,
SAMPLED_IMAGE_UPDATE_AFTER_BIND_FEATURE,
+ SHADER_INT16_FEATURE,
+ SHADER_FLOAT16_FEATURE,
SHADER_INT64_FEATURE,
ACCELERATION_STRUCTURE_FEATURE,
RAY_TRACING_PIPELINE_FEATURE,
@@ -217,7 +234,8 @@ private enum SerBackend {
}
private record FeatureSupport(List missingRequired, SerBackend serBackend,
- boolean omm, boolean presentId, boolean wideLines) {
+ boolean omm, boolean presentId, boolean wideLines,
+ boolean sharcInt64Atomics, boolean sharcStorageBuffer16) {
boolean supportsRt() {
return missingRequired.isEmpty();
}
@@ -458,6 +476,12 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD
}
WIDE_LINES_FEATURE.struct().findOrCreateStructInPNextChain(available, stack);
+ boolean querySharc = RtSharcSupport.packaged();
+ if (querySharc) {
+ SHARC_BUFFER_INT64_ATOMICS_FEATURE.struct().findOrCreateStructInPNextChain(available, stack);
+ SHARC_STORAGE_BUFFER_16_FEATURE.struct().findOrCreateStructInPNextChain(available, stack);
+ }
+
VK12.vkGetPhysicalDeviceFeatures2(physicalDevice.vkPhysicalDevice(), available);
List missing = new ArrayList<>();
@@ -471,7 +495,9 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD
return new FeatureSupport(missing, supportedSer,
queryOmm && OMM_FEATURE.get(available),
queryPresentId && PRESENT_ID_FEATURE.get(available),
- WIDE_LINES_FEATURE.get(available));
+ WIDE_LINES_FEATURE.get(available),
+ querySharc && SHARC_BUFFER_INT64_ATOMICS_FEATURE.get(available),
+ querySharc && SHARC_STORAGE_BUFFER_16_FEATURE.get(available));
}
}
@@ -521,6 +547,8 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) {
reflexEnabled = false;
presentIdEnabled = false;
wideLinesEnabled = false;
+ RtSharcSupport.setDeviceFeaturesEnabled(false, false, false);
+ RtSharcSupport.clearFailure();
maxLineWidth = 1.0f;
String missingExtension = firstUnsupportedExtension(physicalDevice);
if (missingExtension != null) {
@@ -546,6 +574,14 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) {
// Core features merge into vanilla's VK10/VK12 structs; extension features create their matching
// pNext structs. Every boolean here was verified by queryFeatureSupport above.
features.addAll(REQUIRED_RT_FEATURES);
+ boolean sharcPackaged = RtSharcSupport.packaged();
+ boolean sharcFeatures = sharcPackaged && support.sharcInt64Atomics
+ && support.sharcStorageBuffer16;
+ // SHaRC's optional feature set is atomic: a partial set follows the ordinary RT path.
+ if (sharcFeatures) {
+ features.add(SHARC_BUFFER_INT64_ATOMICS_FEATURE);
+ features.add(SHARC_STORAGE_BUFFER_16_FEATURE);
+ }
// Bindless entity textures: a runtime-sized sampler2D[] indexed non-uniformly in the hit shader,
// with partially-bound + update-after-bind slots (a growing per-RenderType registry). Core on the
// VK 1.4 device; just needs enabling alongside bufferDeviceAddress on the same struct.
@@ -595,6 +631,11 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) {
rtRequested = true;
serBackend = support.serBackend;
+ RtSharcSupport.setDeviceFeaturesEnabled(sharcFeatures, sharcFeatures, sharcFeatures);
+ if (sharcPackaged && !sharcFeatures) {
+ CausticaMod.LOGGER.info("Optional SHaRC unavailable; keeping the ordinary RT path: {}",
+ RtSharcSupport.status());
+ }
List optionalExtensions = supportedOptionalExtensions(physicalDevice, support);
CausticaMod.LOGGER.info(
"Ray tracing: enabling {}{}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, SER={}"
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java
index 7722b8fd..729cc964 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java
@@ -67,6 +67,9 @@ public final class RtFrameStats {
"frame.skyLut",
// Wavefront trace and downstream debug stages.
"frame.tracePrimary",
+ "frame.sharcUpdate",
+ "frame.sharcResolve",
+ "frame.sharcQuery",
"frame.traceIndirect",
"frame.exposure",
"frame.dlssRr",
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java
index 4ba56fdb..39f8f075 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java
@@ -19,7 +19,7 @@
* HDR display support — capability detection/logging plus static mastering metadata for PQ swapchains.
* Surface enumeration tells the swapchain-ownership code whether HDR10 is available on the current driver,
* window system, compositor, and monitor; {@code VK_EXT_hdr_metadata}, when supported, describes the
- * Rec.2020/D65 ACES virtual mastering display to that presentation stack.
+ * selected Rec.2020/D65 virtual mastering display to that presentation stack.
*
* Extended color spaces are reported only when the instance enables
* {@code VK_EXT_swapchain_colorspace}. {@code VulkanInstanceMixin} enables it when available; this class
@@ -77,10 +77,10 @@ public static boolean metadataExtensionEnabled() {
/**
* Assigns SMPTE ST 2086 / CTA-861.3 static metadata to one PQ swapchain.
*
- *
The ACES HDR output LUT is a Rec.2020/D65 virtual master capped at one of the baked mastering
- * peaks, so that peak is both the mastering-display maximum and MaxCLL. MaxFALL cannot be known without
- * analysing every rendered frame; Vulkan explicitly permits unknown fields to be zero, which is more
- * truthful than inventing a scene-average value.
+ *
The active HDR output transform supplies the Rec.2020/D65 virtual master peak, so metadata matches
+ * the actual displayed transform. ACES 2.0 uses the nearest packaged LUT peak; analytical modes use the
+ * exact configured peak. MaxFALL cannot be known without analysing every rendered frame; Vulkan explicitly
+ * permits unknown fields to be zero, which is more truthful than inventing a scene-average value.
*/
public static boolean applyMasteringMetadata(VkDevice device, long swapchain, int masteringPeakNits) {
if (!hdrMetadataExtensionEnabled || swapchain == 0L) {
@@ -136,9 +136,10 @@ record MasteringMetadata(
/** Logs the resolved HDR config once (cheap; safe to call repeatedly — guarded by the surface log). */
public static void logConfig() {
CausticaMod.LOGGER.info(
- "HDR config: enabled={} ui={}nits peak={}nits -> {}",
+ "HDR config: enabled={} ui={}nits requestedPeak={}nits effectivePeak={}nits -> {}",
CausticaConfig.Rt.Hdr.enabled(),
CausticaConfig.Rt.Hdr.UI_NITS.value(), CausticaConfig.Rt.Hdr.PEAK_NITS.value(),
+ CausticaConfig.Rt.Hdr.effectivePeakNits(),
CausticaConfig.Rt.Hdr.enabled() ? "HDR display path active" : "SDR display path");
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java
new file mode 100644
index 00000000..1118d901
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java
@@ -0,0 +1,239 @@
+package dev.comfyfluffy.caustica.rt;
+
+import dev.comfyfluffy.caustica.CausticaConfig;
+import dev.comfyfluffy.caustica.rt.accel.RtBuffer;
+import dev.comfyfluffy.caustica.rt.gen.SharcFrameData;
+import dev.comfyfluffy.caustica.rt.gen.SharcFrameData.Float3;
+import org.lwjgl.system.MemoryStack;
+import org.lwjgl.system.MemoryUtil;
+import org.lwjgl.vulkan.VkBufferMemoryBarrier2;
+import org.lwjgl.vulkan.VkCommandBuffer;
+import org.lwjgl.vulkan.VkDependencyInfo;
+
+import java.nio.ByteBuffer;
+
+import static org.lwjgl.vulkan.KHRSynchronization2.VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR;
+import static org.lwjgl.vulkan.KHRSynchronization2.vkCmdPipelineBarrier2KHR;
+import static org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
+import static org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_TRANSFER_DST_BIT;
+import static org.lwjgl.vulkan.VK10.VK_QUEUE_FAMILY_IGNORED;
+import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_READ_BIT;
+import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_WRITE_BIT;
+import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_TRANSFER_WRITE_BIT;
+import static org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT;
+import static org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_TRANSFER_BIT;
+
+/** Persistent directional-SH tables plus a timeline-safe mapped SHaRC frame ring. */
+public final class RtSharcCache {
+ public static final int MIN_EXPONENT = 16;
+ public static final int MAX_EXPONENT = 23;
+ public static final int RING = 6;
+ private static final int QUERY_WARMUP_FRAMES = 16;
+ private static final int ACCUMULATION_STRIDE = 32;
+ private static final int RESOLVED_STRIDE = 24;
+ private static final float SHARC_WORLD_LIMIT = 1.0e6f;
+ public static final long MAX_TABLE_BYTES = 768L * 1024L * 1024L;
+
+ private final RtBuffer hashEntries;
+ private final RtBuffer accumulation;
+ private final RtBuffer resolved;
+ private final RtBuffer[] tables;
+ private final RtBuffer[] queryTables;
+ private final RtBuffer[] frames;
+ private final RtGpuExecutor.TrackedGraphicsUse[] frameUses;
+ private final int exponent;
+ private final int capacity;
+ private int slot = -1;
+ private boolean pendingClear = true;
+ private int framesSinceReset;
+ private Float3 previousCamera;
+ private boolean destroyed;
+
+ private RtSharcCache(RtBuffer hashEntries, RtBuffer accumulation, RtBuffer resolved,
+ RtBuffer[] frames, int exponent, int capacity) {
+ this.hashEntries = hashEntries;
+ this.accumulation = accumulation;
+ this.resolved = resolved;
+ this.tables = new RtBuffer[]{hashEntries, accumulation, resolved};
+ this.queryTables = new RtBuffer[]{hashEntries, resolved};
+ this.frames = frames;
+ this.frameUses = new RtGpuExecutor.TrackedGraphicsUse[RING];
+ for (int i = 0; i < RING; i++) frameUses[i] = new RtGpuExecutor.TrackedGraphicsUse();
+ this.exponent = exponent;
+ this.capacity = capacity;
+ }
+
+ public static RtSharcCache create(RtContext ctx, int requestedExponent) {
+ int exponent = clampExponent(requestedExponent);
+ int capacity = 1 << exponent;
+ long tableBytes = tableBytesForExponent(exponent);
+ if (tableBytes > MAX_TABLE_BYTES) {
+ throw new IllegalArgumentException("SHaRC cache exponent " + exponent + " requires "
+ + tableBytes + " bytes, above the " + MAX_TABLE_BYTES + " byte safety limit");
+ }
+
+ int usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
+ RtBuffer hash = null;
+ RtBuffer accum = null;
+ RtBuffer packed = null;
+ RtBuffer[] frameRing = new RtBuffer[RING];
+ try {
+ hash = ctx.createBuffer((long) capacity * 8L, usage, false, "SHaRC hash entries");
+ accum = ctx.createBuffer((long) capacity * ACCUMULATION_STRIDE, usage, false,
+ "SHaRC directional-SH accumulation");
+ packed = ctx.createBuffer((long) capacity * RESOLVED_STRIDE, usage, false,
+ "SHaRC directional-SH resolved");
+ for (int i = 0; i < RING; i++) {
+ frameRing[i] = ctx.createBuffer(SharcFrameData.BYTE_SIZE, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,
+ true, "SHaRC frame " + i);
+ MemoryUtil.memSet(frameRing[i].mapped, 0, SharcFrameData.BYTE_SIZE);
+ frameRing[i].flush(0L, SharcFrameData.BYTE_SIZE);
+ }
+ return new RtSharcCache(hash, accum, packed, frameRing, exponent, capacity);
+ } catch (Throwable t) {
+ if (hash != null) hash.destroy();
+ if (accum != null) accum.destroy();
+ if (packed != null) packed.destroy();
+ for (RtBuffer frame : frameRing) if (frame != null) frame.destroy();
+ throw t;
+ }
+ }
+
+ public static int clampExponent(int requestedExponent) {
+ return Math.clamp(requestedExponent, MIN_EXPONENT, MAX_EXPONENT);
+ }
+
+ public static long tableBytesForExponent(int requestedExponent) {
+ int exponent = clampExponent(requestedExponent);
+ int capacity = 1 << exponent;
+ try {
+ return Math.addExact(Math.multiplyExact((long) capacity, 8L),
+ Math.addExact(Math.multiplyExact((long) capacity, ACCUMULATION_STRIDE),
+ Math.multiplyExact((long) capacity, RESOLVED_STRIDE)));
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException("SHaRC cache size overflow for exponent " + exponent, e);
+ }
+ }
+
+ /** Estimated persistent SHaRC buffer footprint, including the mapped frame ring. */
+ public static long memoryBytesForExponent(int requestedExponent) {
+ int exponent = clampExponent(requestedExponent);
+ try {
+ return Math.addExact(tableBytesForExponent(exponent),
+ Math.multiplyExact((long) RING, SharcFrameData.BYTE_SIZE));
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException("SHaRC memory estimate overflow for exponent " + exponent, e);
+ }
+ }
+
+ public int exponent() {
+ return exponent;
+ }
+
+ public int capacity() {
+ return capacity;
+ }
+
+ public static int updateTileSize() {
+ return CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.value();
+ }
+
+ static float sanitizeCameraCoordinate(float value) {
+ return Float.isFinite(value) && Math.abs(value) <= SHARC_WORLD_LIMIT ? value : 0.0f;
+ }
+
+ public void requestReset() {
+ pendingClear = true;
+ framesSinceReset = 0;
+ previousCamera = null;
+ }
+
+ public boolean queryReady() {
+ return framesSinceReset >= QUERY_WARMUP_FRAMES;
+ }
+
+ /** Advance the frame ring after waiting for its exact prior graphics use. */
+ public long beginFrame(long frameIndex, float cameraX, float cameraY, float cameraZ,
+ RtGpuExecutor.GraphicsUseWaiter waiter) {
+ slot = (slot + 1) % RING;
+ waiter.await(frameUses[slot]);
+ Float3 camera = new Float3(sanitizeCameraCoordinate(cameraX),
+ sanitizeCameraCoordinate(cameraY), sanitizeCameraCoordinate(cameraZ));
+ Float3 prior = previousCamera == null || pendingClear ? camera : previousCamera;
+ ByteBuffer mapped = MemoryUtil.memByteBuffer(frames[slot].mapped, SharcFrameData.BYTE_SIZE);
+ new SharcFrameData(hashEntries.deviceAddress, accumulation.deviceAddress, resolved.deviceAddress,
+ camera, capacity, prior, (int) frameIndex,
+ CausticaConfig.Rt.Sharc.SCENE_SCALE.value(),
+ CausticaConfig.Rt.Sharc.RADIANCE_SCALE.value(),
+ CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.value(),
+ CausticaConfig.Rt.Sharc.STALE_FRAMES.value(),
+ CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.value(),
+ CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.value(),
+ CausticaConfig.Rt.Sharc.ANTI_FIREFLY.value() ? 1 : 0).write(mapped);
+ frames[slot].flush(0L, SharcFrameData.BYTE_SIZE);
+ previousCamera = camera;
+ framesSinceReset = Math.min(framesSinceReset + 1, QUERY_WARMUP_FRAMES + 1);
+ return frames[slot].deviceAddress;
+ }
+
+ public void commitFrameUse(RtGpuExecutor.GraphicsUse graphicsUse) {
+ frameUses[slot].mark(graphicsUse);
+ // A clear becomes authoritative only after the command buffer was accepted for submission. If
+ // recording or submission fails after recordPendingClear(), leave it pending so the next frame
+ // cannot accidentally reuse the old tables.
+ pendingClear = false;
+ }
+
+ /** Clear all persistent tables before the sparse update when a reset was requested. */
+ public void recordPendingClear(VkCommandBuffer cmd, MemoryStack stack) {
+ if (!pendingClear) return;
+ org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, hashEntries.handle, 0L, hashEntries.size, 0);
+ org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, accumulation.handle, 0L, accumulation.size, 0);
+ org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, resolved.handle, 0L, resolved.size, 0);
+ barrier(cmd, stack, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT,
+ VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR,
+ VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT);
+ }
+
+ public void updateToResolveBarrier(VkCommandBuffer cmd, MemoryStack stack) {
+ barrier(cmd, stack, VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR,
+ VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT,
+ VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
+ VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT);
+ }
+
+ public void resolveToQueryBarrier(VkCommandBuffer cmd, MemoryStack stack) {
+ barrier(cmd, stack, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
+ VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT,
+ VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR,
+ VK_ACCESS_2_SHADER_READ_BIT, queryTables);
+ }
+
+ private void barrier(VkCommandBuffer cmd, MemoryStack stack, long srcStage, long srcAccess,
+ long dstStage, long dstAccess) {
+ barrier(cmd, stack, srcStage, srcAccess, dstStage, dstAccess, tables);
+ }
+
+ private void barrier(VkCommandBuffer cmd, MemoryStack stack, long srcStage, long srcAccess,
+ long dstStage, long dstAccess, RtBuffer[] buffers) {
+ VkBufferMemoryBarrier2.Buffer barriers = VkBufferMemoryBarrier2.calloc(buffers.length, stack);
+ for (int i = 0; i < buffers.length; i++) {
+ barriers.get(i).sType$Default().srcStageMask(srcStage).srcAccessMask(srcAccess)
+ .dstStageMask(dstStage).dstAccessMask(dstAccess)
+ .srcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED).dstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
+ .buffer(buffers[i].handle).offset(0L).size(buffers[i].size);
+ }
+ VkDependencyInfo dependency = VkDependencyInfo.calloc(stack).sType$Default()
+ .pBufferMemoryBarriers(barriers);
+ vkCmdPipelineBarrier2KHR(cmd, dependency);
+ }
+
+ public void destroy() {
+ if (destroyed) return;
+ hashEntries.destroy();
+ accumulation.destroy();
+ resolved.destroy();
+ for (RtBuffer frame : frames) frame.destroy();
+ destroyed = true;
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java
new file mode 100644
index 00000000..3c919dc0
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java
@@ -0,0 +1,102 @@
+package dev.comfyfluffy.caustica.rt;
+
+import dev.comfyfluffy.caustica.CausticaMod;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+/** Optional-build and device-capability gate for the pinned NVIDIA SHaRC 1.8 shader family. */
+public final class RtSharcSupport {
+ public static final String VERSION = "1.8.0.0";
+ public static final String COMMIT = "e19ccacd511f42a3df6f850052d508c13c9e9737";
+
+ private static final Properties METADATA = loadMetadata();
+ private static final boolean ARTIFACTS_PRESENT = verifyArtifacts();
+ private static volatile boolean shaderBufferInt64Atomics;
+ private static volatile boolean shaderFloat16;
+ private static volatile boolean storageBuffer16BitAccess;
+ private static volatile String failure;
+
+ private RtSharcSupport() {
+ }
+
+ /** True only when this jar was built with the exact SDK and contains SHaRC artifacts/license metadata. */
+ public static boolean packaged() {
+ return "true".equalsIgnoreCase(METADATA.getProperty("artifacts"))
+ && VERSION.equals(METADATA.getProperty("version"))
+ && COMMIT.equalsIgnoreCase(METADATA.getProperty("commit"))
+ && "true".equalsIgnoreCase(METADATA.getProperty("directionalSh"))
+ && ARTIFACTS_PRESENT;
+ }
+
+ /** Called during Vulkan device creation after the optional feature bits were queried. */
+ public static void setDeviceFeaturesEnabled(boolean int64Atomics, boolean float16, boolean storage16) {
+ shaderBufferInt64Atomics = int64Atomics;
+ shaderFloat16 = float16;
+ storageBuffer16BitAccess = storage16;
+ }
+
+ /** Latched runtime failure disables only SHaRC; ordinary Caustica RT continues. */
+ public static void fail(String reason, Throwable cause) {
+ failure = reason;
+ if (cause == null) {
+ CausticaMod.LOGGER.warn("SHaRC disabled: {}", reason);
+ } else {
+ CausticaMod.LOGGER.warn("SHaRC disabled: " + reason, cause);
+ }
+ }
+
+ public static void clearFailure() {
+ failure = null;
+ }
+
+ public static boolean available() {
+ return packaged() && RtDeviceBringup.rtRequested()
+ && shaderBufferInt64Atomics && shaderFloat16 && storageBuffer16BitAccess
+ && failure == null;
+ }
+
+ public static String status() {
+ if (!packaged()) return "unavailable (jar has no SHaRC artifacts)";
+ if (!RtDeviceBringup.rtRequested()) return "unavailable (ray tracing device not enabled)";
+ if (!shaderBufferInt64Atomics) return "unavailable (shaderBufferInt64Atomics unsupported)";
+ if (!shaderFloat16) return "unavailable (shaderFloat16 unsupported)";
+ if (!storageBuffer16BitAccess) return "unavailable (storageBuffer16BitAccess unsupported)";
+ return failure == null ? "available (SHaRC " + VERSION + ")" : "unavailable (" + failure + ")";
+ }
+
+ private static Properties loadMetadata() {
+ Properties properties = new Properties();
+ try (InputStream in = RtSharcSupport.class.getResourceAsStream("/caustica/sharc.properties")) {
+ if (in != null) properties.load(in);
+ } catch (IOException e) {
+ CausticaMod.LOGGER.warn("Could not read SHaRC build metadata", e);
+ }
+ return properties;
+ }
+
+ /** Validate the fixed SHaRC resource set once when the class is initialized, never per frame. */
+ private static boolean verifyArtifacts() {
+ String[] resources = {
+ "/caustica/shaders/pipelines/world/indirect_sharc_query.rgen.spv",
+ "/caustica/shaders/pipelines/world/indirect_sharc_ser_query.rgen.spv",
+ "/caustica/shaders/pipelines/world/indirect_sharc_update.rgen.spv",
+ "/caustica/shaders/pipelines/world/indirect_sharc_ser_update.rgen.spv",
+ "/caustica/shaders/sharc/sharc_resolve.comp.spv",
+ "/META-INF/licenses/nvidia/NVIDIA-SHARC-SDK.txt"
+ };
+ for (String resource : resources) {
+ try (InputStream in = RtSharcSupport.class.getResourceAsStream(resource)) {
+ if (in == null || in.read() < 0) {
+ CausticaMod.LOGGER.warn("Missing SHaRC packaged resource: {}", resource);
+ return false;
+ }
+ } catch (IOException e) {
+ CausticaMod.LOGGER.warn("Could not validate SHaRC packaged resource: " + resource, e);
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java
new file mode 100644
index 00000000..793483c1
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java
@@ -0,0 +1,32 @@
+package dev.comfyfluffy.caustica.rt;
+
+import net.minecraft.world.level.dimension.DimensionType;
+
+/** Shared CPU-side mapping for Minecraft's dimension sky modes and fog color conversion. */
+public final class RtSkyMath {
+ public static final int SKYBOX_NONE = 0;
+ public static final int SKYBOX_OVERWORLD = 1;
+ public static final int SKYBOX_END = 2;
+ public static final int SKY_FLAG_END_FLASH = 1;
+
+ private RtSkyMath() {
+ }
+
+ public static int skyboxMode(DimensionType.Skybox skybox) {
+ if (skybox == DimensionType.Skybox.NONE) {
+ return SKYBOX_NONE;
+ }
+ if (skybox == DimensionType.Skybox.END) {
+ return SKYBOX_END;
+ }
+ return SKYBOX_OVERWORLD;
+ }
+
+ /** Minecraft fog colors are authored as sRGB values; the RT sky payload is linear BT.709. */
+ public static float srgbToLinear(float value) {
+ value = Math.clamp(value, 0.0f, 1.0f);
+ return value <= 0.04045f
+ ? value / 12.92f
+ : (float) Math.pow((value + 0.055f) / 1.055f, 2.4f);
+ }
+}
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..b6f18092 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java
@@ -928,14 +928,16 @@ public static final class PreparedTlas {
private final RtBuffer scratch;
private final int instanceCount;
private final String label;
+ private final TlasRing.Slot slot;
private PreparedTlas(RtAccel accel, RtBuffer instanceBuffer, RtBuffer scratch, int instanceCount,
- String label) {
+ String label, TlasRing.Slot slot) {
this.accel = accel;
this.instanceBuffer = instanceBuffer;
this.scratch = scratch;
this.instanceCount = instanceCount;
this.label = label;
+ this.slot = slot;
}
}
@@ -1009,7 +1011,12 @@ public static PreparedTlas prepareTlas(RtContext ctx, List baseInstanc
}
slot.graphicsUse.mark(graphicsUse);
return new PreparedTlas(slot.accel, slot.instanceBuffer, slot.scratch, count,
- "frame TLAS " + count + " instances");
+ "frame TLAS " + count + " instances", slot);
+ }
+
+ /** Extends a retained TLAS slot's lifetime through another graphics submission without rebuilding it. */
+ public static void markTlasUsed(PreparedTlas tlas, GraphicsUse graphicsUse) {
+ tlas.slot.graphicsUse.mark(graphicsUse);
}
// Wrap the mapped Vulkan array in LWJGL structs so its generated accessors own the native ABI/bitfields.
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..de42f102 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java
@@ -187,7 +187,7 @@ private static int beBuildsPerFrame() {
private CameraRenderState cameraState;
// Particle capture: a VertexConsumer adapter that funnels MC's billboard quads into `capture` (the
// shared entity mesh). We extract each live particle into `particleScratch`, accumulate per-vertex
- // motion-vector displacements in `particleDisp`, and key the previous-frame center off particle
+ // motion-vector displacements in `particleDisp`, and key previous captured positions off particle
// identity in `particlePrev` (rebuilt each frame → prunes dead particles).
private final RtParticleCapture particleCapture = new RtParticleCapture(capture);
private final QuadParticleRenderState particleScratch = new QuadParticleRenderState();
@@ -196,15 +196,17 @@ private static int beBuildsPerFrame() {
private IdentityHashMap particleCur = new IdentityHashMap<>();
private final float[] particleCenterScratch = new float[3];
- /** Previous frame's particle center (rebase-space) + that frame's rebase origin, for the MV diff. */
+ /** Previous frame's particle vertices (rebase-space) + that frame's rebase origin, for the MV diff. */
private static final class ParticlePrev {
- float cx, cy, cz;
+ float[] vertices = new float[0];
int rbx, rby, rbz;
- void set(float cx, float cy, float cz, int rbx, int rby, int rbz) {
- this.cx = cx;
- this.cy = cy;
- this.cz = cz;
+ void set(float[] current, int vertBefore, int vertAfter, int rbx, int rby, int rbz) {
+ int count = (vertAfter - vertBefore) * 3;
+ if (vertices.length != count) {
+ vertices = new float[count];
+ }
+ System.arraycopy(current, vertBefore * 3, vertices, 0, count);
this.rbx = rbx;
this.rby = rby;
this.rbz = rbz;
@@ -216,6 +218,8 @@ void set(float cx, float cy, float cz, int rbx, int rby, int rbz) {
private int tableSlot;
private final FrameLists[] frameLists = new FrameLists[FRAME_LIST_RING];
+ private boolean captureSession;
+ private FrameEntities captureSnapshot;
// Previous frame's captured entity-local vertex positions + its interpolated world anchor, keyed by
// entity id. Maps are swapped/reused each frame: entries not seen this frame fall out, while visible
@@ -607,13 +611,16 @@ 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 (captureSnapshot != null) {
+ return captureSnapshot;
+ }
if (!enabled()) {
- return new FrameEntities(base, List.of(), List.of(), 0L, null);
+ return retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null));
}
Minecraft mc = Minecraft.getInstance();
ClientLevel level = mc.level;
if (level == null) {
- return new FrameEntities(base, List.of(), List.of(), 0L, null);
+ return retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null));
}
float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false);
setCamera(camX, camY, camZ, projection, viewRotation);
@@ -641,7 +648,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 retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null));
}
try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("entity.uploadFlush")) {
build.motion.flushWrites();
@@ -650,8 +657,28 @@ public FrameEntities beginFrame(RtContext ctx, List base, int
RtFrameStats.FRAME.count("entityTableFlushes", 1);
}
}
- return new FrameEntities(base, build.instances, build.blas, build.geomTableAddr,
+ FrameEntities frame = new FrameEntities(base, build.instances, build.blas, build.geomTableAddr,
new FrameUse(build.lists, build.table));
+ return retainCaptureSnapshot(frame);
+ }
+
+ public void beginCaptureSession() {
+ captureSession = true;
+ captureSnapshot = null;
+ }
+
+ public void endCaptureSession() {
+ captureSession = false;
+ captureSnapshot = null;
+ }
+
+ private FrameEntities retainCaptureSnapshot(FrameEntities frame) {
+ if (captureSession) {
+ // BLAS work belongs to the first frame. Later frames reuse the same guarded resources.
+ captureSnapshot = new FrameEntities(frame.baseInstances, frame.dynamicInstances, List.of(),
+ frame.geomTableAddr, frame.use);
+ }
+ return frame;
}
/** Associate every resource returned for a successfully enqueued frame with its graphics completion. */
@@ -929,9 +956,11 @@ private static float[] buildDisp(float[] cur, int curSize, float[] prev, float s
* Capture this frame's billboard particles as ONE combined mesh + BLAS (cutout, camera-only receiver),
* with per-particle motion vectors. We iterate the LIVE {@code Particle} objects (via accessor mixins)
* rather than the public packed render state, because only the live objects carry stable identity —
- * needed to diff each particle's center against last frame for the MV. Each particle is extracted into
+ * needed to diff each particle's captured vertices against last frame for the MV. Each particle is
+ * extracted into
* {@link #particleScratch} (its billboard quad), funneled through {@link #particleCapture} into the
- * shared {@code capture}, and its quad center cached by identity in {@link #particlePrev}. Per-layer
+ * shared {@code capture}, and its captured positions cached by identity in {@link #particlePrev}.
+ * Per-layer
* texture slot comes from the layer's atlas (block/item/particle) via the bindless registry. One
* {@code PARTICLE_BIT} instance with mask {@link #PARTICLE_MASK} (primary-ray only).
*/
@@ -1006,7 +1035,7 @@ private void captureParticles(RtContext ctx, FrameBuild build, Minecraft mc, flo
capture.alphaBuckets.size(abb);
continue;
}
- appendParticleMv(p, particleCenterScratch, vertBefore, vertAfter, rbx, rby, rbz, cur);
+ appendParticleMv(p, vertBefore, vertAfter, rbx, rby, rbz, cur);
build.logicalCount++;
particlesCaptured++;
}
@@ -1044,27 +1073,32 @@ private void particleCenter(int vertBefore, int vertAfter, float[] out) {
}
/**
- * Compute one particle's motion-vector displacement (its quad center vs. last frame's, keyed by
- * identity) and write it for each of the particle's vertices into {@link #particleDisp}. All four
- * billboard verts share the center displacement (per-particle-rigid MV).
+ * Compute one particle's per-vertex motion-vector displacement against its last captured geometry,
+ * keyed by identity, and write it into {@link #particleDisp}. A new particle or a changed vertex
+ * layout has no reliable correspondence and therefore gets zero motion for that frame.
*/
- private void appendParticleMv(Particle p, float[] center, int vertBefore, int vertAfter,
+ private void appendParticleMv(Particle p, int vertBefore, int vertAfter,
int rbx, int rby, int rbz, IdentityHashMap cur) {
ParticlePrev prev = particlePrev.remove(p);
- // World displacement = (curCenter − prevCenter) + (rebaseCur − rebasePrev). New particle ⇒ 0 (no MV).
- float dx = prev == null ? 0f : (center[0] - prev.cx) + (rbx - prev.rbx);
- float dy = prev == null ? 0f : (center[1] - prev.cy) + (rby - prev.rby);
- float dz = prev == null ? 0f : (center[2] - prev.cz) + (rbz - prev.rbz);
+ // World displacement is current-minus-previous vertex position plus the rebase-origin delta.
+ float[] vertices = capture.verts.elements();
+ int count = vertAfter - vertBefore;
+ boolean matched = prev != null && prev.vertices.length == count * 3;
+ float rebasedDx = prev == null ? 0f : rbx - prev.rbx;
+ float rebasedDy = prev == null ? 0f : rby - prev.rby;
+ float rebasedDz = prev == null ? 0f : rbz - prev.rbz;
for (int i = vertBefore; i < vertAfter; i++) {
- particleDisp.add(dx);
- particleDisp.add(dy);
- particleDisp.add(dz);
+ int current = i * 3;
+ int old = (i - vertBefore) * 3;
+ particleDisp.add(matched ? vertices[current] - prev.vertices[old] + rebasedDx : 0f);
+ particleDisp.add(matched ? vertices[current + 1] - prev.vertices[old + 1] + rebasedDy : 0f);
+ particleDisp.add(matched ? vertices[current + 2] - prev.vertices[old + 2] + rebasedDz : 0f);
particleDisp.add(0f);
}
if (prev == null) {
prev = new ParticlePrev();
}
- prev.set(center[0], center[1], center[2], rbx, rby, rbz);
+ prev.set(vertices, vertBefore, vertAfter, rbx, rby, rbz);
cur.put(p, prev);
}
@@ -1230,7 +1264,7 @@ private BeEntry buildBe(RtContext ctx, FrameBuild build, BlockEntity be, long ha
return e;
}
- /** FNV-1a hash of the currently captured mesh (positions + indices + per-prim data) for rebuild detection. */
+ /** FNV-1a hash of the currently captured mesh (positions, indices, UVs, and per-prim data) for rebuild detection. */
private long meshHash() {
long h = 1469598103934665603L;
float[] v = capture.verts.elements();
@@ -1238,6 +1272,12 @@ private long meshHash() {
for (int i = 0; i < vn; i++) {
h = (h ^ (Float.floatToRawIntBits(v[i]) & 0xffffffffL)) * 1099511628211L;
}
+ float[] uv = capture.uvList.elements();
+ int un = capture.uvList.size();
+ h = (h ^ (un & 0xffffffffL)) * 1099511628211L;
+ for (int i = 0; i < un; i++) {
+ h = (h ^ (Float.floatToRawIntBits(uv[i]) & 0xffffffffL)) * 1099511628211L;
+ }
int[] x = capture.idx.elements();
int xn = capture.idx.size();
for (int i = 0; i < xn; i++) {
@@ -1716,6 +1756,12 @@ private void writeTableEntry(FrameBuild build, long primAddr, long idxAddr, long
if (bucketTris == null || bucketTris.length != RtAccel.ENTITY_BUCKETS) {
throw new IllegalArgumentException("Missing entity BLAS bucket counts");
}
+ // An immutable capture reuses this table across its whole accumulation phase. Publish the frozen
+ // geometry as stationary so reconstruction does not reapply the first frame's live displacement.
+ if (captureSession) {
+ dispAddr = 0L;
+ rigidX = rigidY = rigidZ = 0f;
+ }
long entry = build.tableBase + (long) build.count * TABLE_ENTRY_BYTES;
MemoryUtil.memPutLong(entry, primAddr);
MemoryUtil.memPutLong(entry + 8, idxAddr);
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java
index 2f22e068..f433c830 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java
@@ -177,15 +177,38 @@ private int slotForView(long view) {
/** Write any newly-registered entity textures into the pipeline's bindless set (before the trace). */
public void uploadPending(RtPipeline pipeline, long sampler) {
+ uploadPending(sampler, pipeline);
+ }
+
+ /** Write newly registered textures into every pipeline that shares this texture epoch. */
+ public void uploadPending(long sampler, RtPipeline... pipelines) {
if (pending.isEmpty()) {
return;
}
for (Pending p : pending) {
- pipeline.setEntityAlbedoTexture(p.slot(), p.view(), sampler);
+ for (RtPipeline pipeline : pipelines) {
+ if (pipeline != null) {
+ pipeline.setEntityAlbedoTexture(p.slot(), p.view(), sampler);
+ }
+ }
}
pending.clear();
}
+ /** Populate all slots into a newly created pipeline before the pending queue is cleared. */
+ public void uploadAll(long sampler, RtPipeline... pipelines) {
+ for (Map.Entry entry : viewSlotCache.entrySet()) {
+ long view = entry.getKey();
+ int slot = entry.getValue();
+ for (RtPipeline pipeline : pipelines) {
+ if (pipeline != null) {
+ pipeline.setEntityAlbedoTexture(slot, view, sampler);
+ }
+ }
+ }
+ uploadPending(sampler, pipelines);
+ }
+
/** Drop the registry (call when the world pipeline / bindless set is recreated, or textures reload). */
public void reset() {
reset(maxTextures());
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java
index 5e7aff67..cde89028 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java
@@ -296,9 +296,18 @@ public void prepareAll(RtContext ctx, int materialPageCapacity, RtEmissionSemant
}
public void bindPages(RtPipeline pipeline, long sampler) {
+ bindPages(sampler, pipeline);
+ }
+
+ /** Bind the same resource-epoch material pages into every world-compatible pipeline. */
+ public void bindPages(long sampler, RtPipeline... pipelines) {
for (Page page : pages) {
- pipeline.setMaterialPage(page.index(), page.surface0().view(), page.normalAo().view(),
- page.surface1().view(), sampler);
+ for (RtPipeline pipeline : pipelines) {
+ if (pipeline != null) {
+ pipeline.setMaterialPage(page.index(), page.surface0().view(), page.normalAo().view(),
+ page.surface1().view(), sampler);
+ }
+ }
}
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java
index 76ea7964..1214e8b8 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java
@@ -17,6 +17,7 @@
import org.lwjgl.vulkan.VkPushConstantRange;
import org.lwjgl.vulkan.VkShaderModuleCreateInfo;
import org.lwjgl.vulkan.VkWriteDescriptorSet;
+import org.joml.Matrix4fc;
import java.io.IOException;
import java.io.InputStream;
@@ -54,6 +55,11 @@ public final class RtDisplayPipeline {
private long boundLookLutSampler;
private long boundBloomView;
private long boundBloomSampler;
+ private long boundSkyClassificationView;
+ private long boundEndSkyView;
+ private long boundEndSkySampler;
+ private long boundCelestialsView;
+ private long boundCelestialsSampler;
private boolean destroyed;
private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long layout, long pipeline) {
@@ -66,6 +72,10 @@ private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long lay
}
public static RtDisplayPipeline create(RtContext ctx) {
+ if (ctx.maxPushConstantsSize() < PUSH_BYTES) {
+ throw new IllegalStateException("Caustica display pipeline requires at least " + PUSH_BYTES
+ + " push-constant bytes; device reports " + ctx.maxPushConstantsSize());
+ }
VkDevice vk = ctx.vk();
try (MemoryStack stack = MemoryStack.stackPush()) {
VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(DISPLAY_BINDING_COUNT, stack);
@@ -86,6 +96,12 @@ public static RtDisplayPipeline create(RtContext ctx) {
.descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT);
binds.get(DISPLAY_BLOOM).binding(DISPLAY_BLOOM).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
.descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT);
+ binds.get(DISPLAY_SKY_CLASSIFICATION).binding(DISPLAY_SKY_CLASSIFICATION).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE)
+ .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT);
+ binds.get(DISPLAY_END_SKY).binding(DISPLAY_END_SKY).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
+ .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT);
+ binds.get(DISPLAY_CELESTIALS).binding(DISPLAY_CELESTIALS).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
+ .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT);
VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds);
LongBuffer p = stack.mallocLong(1);
@@ -94,8 +110,8 @@ public static RtDisplayPipeline create(RtContext ctx) {
RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, "display descriptor set layout");
VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(2, stack);
- poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(4);
- poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(4);
+ poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(5);
+ poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(6);
VkDescriptorPoolCreateInfo dpci = VkDescriptorPoolCreateInfo.calloc(stack).sType$Default().maxSets(1).pPoolSizes(poolSizes);
check(VK10.vkCreateDescriptorPool(vk, dpci, null, p), "vkCreateDescriptorPool(rt display)");
long pool = p.get(0);
@@ -134,13 +150,18 @@ public static RtDisplayPipeline create(RtContext ctx) {
public void setImages(long outputImageView, long rtImageView, long exposureImageView, long hdrImageView,
long lutView, long lutSampler, long hdrLutView, long hdrLutSampler,
- long lookLutView, long lookLutSampler, long bloomView, long bloomSampler) {
+ long lookLutView, long lookLutSampler, long bloomView, long bloomSampler,
+ long skyClassificationView, long endSkyView, long endSkySampler,
+ long celestialsView, long celestialsSampler) {
if (boundOutputView == outputImageView && boundRtView == rtImageView
&& boundExposureView == exposureImageView && boundHdrView == hdrImageView
&& boundLutView == lutView && boundLutSampler == lutSampler
&& boundHdrLutView == hdrLutView && boundHdrLutSampler == hdrLutSampler
&& boundLookLutView == lookLutView && boundLookLutSampler == lookLutSampler
- && boundBloomView == bloomView && boundBloomSampler == bloomSampler) {
+ && boundBloomView == bloomView && boundBloomSampler == bloomSampler
+ && boundSkyClassificationView == skyClassificationView && boundEndSkyView == endSkyView
+ && boundEndSkySampler == endSkySampler && boundCelestialsView == celestialsView
+ && boundCelestialsSampler == celestialsSampler) {
return;
}
try (MemoryStack stack = MemoryStack.stackPush()) {
@@ -161,6 +182,14 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage
VkDescriptorImageInfo.Buffer bloomInfo = VkDescriptorImageInfo.calloc(1, stack);
bloomInfo.get(0).imageView(bloomView).sampler(bloomSampler)
.imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL);
+ VkDescriptorImageInfo.Buffer skyClassificationInfo = VkDescriptorImageInfo.calloc(1, stack);
+ skyClassificationInfo.get(0).imageView(skyClassificationView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL);
+ VkDescriptorImageInfo.Buffer endSkyInfo = VkDescriptorImageInfo.calloc(1, stack);
+ endSkyInfo.get(0).imageView(endSkyView).sampler(endSkySampler)
+ .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL);
+ VkDescriptorImageInfo.Buffer celestialsInfo = VkDescriptorImageInfo.calloc(1, stack);
+ celestialsInfo.get(0).imageView(celestialsView).sampler(celestialsSampler)
+ .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL);
VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(DISPLAY_BINDING_COUNT, stack);
writes.get(DISPLAY_OUTPUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_OUTPUT)
@@ -180,6 +209,14 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage
writes.get(DISPLAY_BLOOM).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_BLOOM)
.descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
.pImageInfo(bloomInfo);
+ writes.get(DISPLAY_SKY_CLASSIFICATION).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_SKY_CLASSIFICATION)
+ .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(skyClassificationInfo);
+ writes.get(DISPLAY_END_SKY).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_END_SKY)
+ .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
+ .pImageInfo(endSkyInfo);
+ writes.get(DISPLAY_CELESTIALS).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_CELESTIALS)
+ .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
+ .pImageInfo(celestialsInfo);
VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null);
}
boundOutputView = outputImageView;
@@ -194,23 +231,52 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage
boundLookLutSampler = lookLutSampler;
boundBloomView = bloomView;
boundBloomSampler = bloomSampler;
+ boundSkyClassificationView = skyClassificationView;
+ boundEndSkyView = endSkyView;
+ boundEndSkySampler = endSkySampler;
+ boundCelestialsView = celestialsView;
+ boundCelestialsSampler = celestialsSampler;
}
/**
- * Run the display mapping through the baked ACES 2.0 LUTs: SDR
- * (binding 0) always writes; the PQ-encoded HDR image (binding 3) also writes when
- * {@code hdrEnabled}. The HDR LUT is baked for a fixed mastering-nits peak (see
- * {@code CausticaConfig.Rt.Hdr.PEAK_NITS_STEPS}), selected host-side by which LUT resource is bound.
+ * Run the selected display transforms. SDR always writes; the PQ-encoded HDR image also writes
+ * when {@code hdrEnabled}. ACES 2.0 uses the bound display LUTs, while analytical modes execute
+ * directly in the display shader.
*/
- public void dispatch(VkCommandBuffer cmd, int width, int height, boolean hdrEnabled, int lutSize,
- float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize,
- float bloomStrength) {
+ public void dispatch(VkCommandBuffer cmd, int width, int height, RtToneMapping.Settings toneMapping,
+ int lutSize, float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize,
+ float bloomStrength, Matrix4fc invViewProj, int skybox, int skyFlags,
+ float skyR, float skyG, float skyB, float skyA,
+ float endFlashIntensity, float endFlashX, float endFlashY,
+ float flashU0, float flashV0, float flashU1, float flashV1) {
try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "display compute")) {
VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipeline);
VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, stack.longs(descriptorSet), null);
ByteBuffer push = stack.malloc(DisplayPushData.BYTE_SIZE);
- new DisplayPushData(hdrEnabled ? 1 : 0, (float) lutSize, gamma, hdrPeakNits,
- lookEnabled ? 1 : 0, (float) lookLutSize, bloomStrength).write(push);
+ RtToneMapping.Parameters sdr = toneMapping.sdrParameters();
+ RtToneMapping.Parameters hdr = toneMapping.hdrParameters();
+ new DisplayPushData(
+ toneMapping.hdrEnabled() ? 1 : 0,
+ (float) lutSize,
+ gamma,
+ hdrPeakNits,
+ lookEnabled ? 1 : 0,
+ (float) lookLutSize,
+ bloomStrength,
+ toneMapping.sdrMode(),
+ toneMapping.hdrMode(),
+ toneMapping.paperWhiteNits(),
+ toneMapping.headroom(),
+ sdr.param0(), sdr.param1(), sdr.param2(), sdr.param3(),
+ sdr.param4(), sdr.param5(), sdr.param6(), sdr.param7(),
+ hdr.param0(), hdr.param1(), hdr.param2(), hdr.param3(),
+ hdr.param4(), hdr.param5(), hdr.param6(), hdr.param7(),
+ new DisplayPushData.Float4(skyR, skyG, skyB, skyA),
+ invViewProj,
+ skybox,
+ skyFlags,
+ new DisplayPushData.Float4(0.0f, endFlashIntensity, endFlashX, endFlashY),
+ new DisplayPushData.Float4(flashU0, flashV0, flashU1, flashV1)).write(push);
VK10.vkCmdPushConstants(cmd, pipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push);
VK10.vkCmdDispatch(cmd, (width + 15) / 16, (height + 15) / 16, 1);
}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java
index 5977a299..6145c610 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java
@@ -4,6 +4,7 @@
import com.mojang.blaze3d.vulkan.VulkanDevice;
import dev.comfyfluffy.caustica.CausticaConfig;
import dev.comfyfluffy.caustica.CausticaMod;
+import dev.comfyfluffy.caustica.client.CaptureSession;
import dev.comfyfluffy.caustica.rt.RtContext;
import dev.comfyfluffy.caustica.rt.accel.RtImage;
import dev.comfyfluffy.caustica.mixin.GpuDeviceAccessor;
@@ -19,7 +20,8 @@
/**
* DLSS Ray Reconstruction backend for the RT renderer. Runs the DLSSD (Ray Reconstruction) feature
* over path-traced color + guide buffers (normals/roughness, diffuse/specular albedo, depth, motion
- * vectors, reflection motion vectors), denoising and upscaling (render res → display res) in one pass.
+ * vectors, reflection motion vectors, sky responsivity, and particle classification), denoising and
+ * upscaling (render res → display res) in one pass.
*/
public final class RtDlssRr {
public static final RtDlssRr INSTANCE = new RtDlssRr();
@@ -45,13 +47,65 @@ private static int renderPreset() {
}
public static int quality() {
- return CausticaConfig.Rt.DlssRr.QUALITY.value();
+ return CaptureSession.effectiveDlssQuality(CausticaConfig.Rt.DlssRr.QUALITY.value());
+ }
+
+ public boolean hasFailed() {
+ return failed;
+ }
+
+ /** NVIDIA's recommended texture LOD offset for the active DLSS render/display resolution pair. */
+ public static float recommendedMipMapBias(int renderWidth, int displayWidth) {
+ if (renderWidth <= 0 || displayWidth <= 0) {
+ return 0.0f;
+ }
+ double bias = Math.log((double) renderWidth / (double) displayWidth) / Math.log(2.0) - 1.0;
+ return Double.isFinite(bias) ? (float) bias : 0.0f;
+ }
+
+ public void resetFailureLatch() {
+ boolean canRetry = true;
+ if (!isNull(feature)) {
+ try {
+ VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull();
+ if (device == null) {
+ canRetry = false;
+ } else {
+ releaseFeature(device);
+ }
+ } catch (Throwable t) {
+ canRetry = false;
+ CausticaMod.LOGGER.warn("DLSS-RR feature reset could not release the old native handle", t);
+ }
+ }
+ if (!canRetry) {
+ failed = true;
+ featureInvalid = true;
+ return;
+ }
+ failed = false;
+ featureInvalid = false;
+ requestHistoryReset();
+ NgxRuntime.INSTANCE.resetFailureLatch();
+ }
+
+ /**
+ * Request a reset on the next successful DLSSD evaluation. Callers must reserve this for a hard
+ * temporal discontinuity, such as a dimension/skybox transition, output or feature recreation, an
+ * explicit render-state invalidation, or recovery from a failed feature. Ordinary lighting and setting
+ * transitions keep history so DLSSD can smooth them without a visible reconstruction flash.
+ */
+ public void requestHistoryReset() {
+ resetHistory = true;
+ lastFrameNanos = 0L;
}
private NgxLibrary lib;
private MemorySegment feature = MemorySegment.NULL;
+ private VulkanDevice featureDevice;
private boolean initialized;
private boolean failed;
+ private boolean featureInvalid;
private boolean loggedAvailable;
private int featureRenderWidth = -1;
@@ -79,7 +133,8 @@ public boolean isReady() {
*/
public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion,
RtImage diffuseAlbedo, RtImage specularAlbedo, RtImage normals,
- RtImage specularMotion, RtImage out,
+ RtImage specularMotion, RtImage particleMask, RtImage responsivityMask,
+ RtImage out,
int renderWidth, int renderHeight, int displayWidth, int displayHeight,
float jitterX, float jitterY, Matrix4fc worldToView, Matrix4fc viewToClip) {
if (!isReady()) {
@@ -105,18 +160,19 @@ public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion,
specularAlbedo.view, specularAlbedo.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT,
normals.view, normals.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT,
specularMotion.view, specularMotion.image, VK10.VK_FORMAT_R16G16_SFLOAT,
- 0L, 0L, 0,
+ particleMask.view, particleMask.image, VK10.VK_FORMAT_R8_UINT,
+ responsivityMask.view, responsivityMask.image, VK10.VK_FORMAT_R16_SFLOAT,
out.view, out.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT,
renderWidth, renderHeight, displayWidth, displayHeight,
// jitter in render pixels; MVs are already in render-pixel units, so MV scale = 1.
jitterX, jitterY, 1.0f, 1.0f, resetHistory ? 1 : 0, frameMs,
worldToViewMatrix, viewToClipMatrix);
}
- resetHistory = false;
if (NgxRuntime.ngxFailed(rc)) {
throw new IllegalStateException("ngxshim_evaluate_dlssd failed: 0x" + Integer.toHexString(rc)
+ " last=0x" + Integer.toHexString(lib.lastResult()));
}
+ resetHistory = false;
return true;
} catch (Throwable t) {
failed = true;
@@ -129,38 +185,78 @@ public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion,
* Asks NGX what render resolution the current quality mode expects for the given display size.
* Returns {@code null} only when RR is off (or already disabled from an earlier failure elsewhere)
* — in that state there is no feature to query and the caller should trace at full resolution.
- * Once RR is active, a failed query (stale shim, old driver, bad NGX result) throws instead of
- * silently falling back, so a broken render/display sync is never masked.
+ * A failed query (stale shim, old driver, or bad NGX result) disables only RR; the compositor
+ * traces at display resolution and uses its normal non-RR blit path.
*/
public int[] queryOptimalRenderSize(int displayWidth, int displayHeight) {
if (!enabled() || failed) {
return null;
}
- if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) {
+ try {
+ if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) {
+ disableForQuery("Vulkan device backend is unavailable", null);
+ return null;
+ }
+ ensureInitialized(device);
+ if (!lib.hasQueryOptimalDlssd()) {
+ disableForQuery("ngxshim is missing ngxshim_query_optimal_dlssd (stale native shim)", null);
+ return null;
+ }
+ try (Arena arena = Arena.ofConfined()) {
+ MemorySegment outWidth = arena.allocate(ValueLayout.JAVA_INT);
+ MemorySegment outHeight = arena.allocate(ValueLayout.JAVA_INT);
+ MemorySegment outSharpness = arena.allocate(ValueLayout.JAVA_FLOAT);
+ int rc = lib.queryOptimalDlssd(displayWidth, displayHeight, quality(), outWidth, outHeight, outSharpness);
+ if (NgxRuntime.ngxFailed(rc)) {
+ disableForQuery("ngxshim_query_optimal_dlssd failed: 0x" + Integer.toHexString(rc), null);
+ return null;
+ }
+ int renderWidth = outWidth.get(ValueLayout.JAVA_INT, 0);
+ int renderHeight = outHeight.get(ValueLayout.JAVA_INT, 0);
+ if (!validRenderSize(renderWidth, renderHeight, displayWidth, displayHeight)) {
+ disableForQuery("ngxshim_query_optimal_dlssd returned invalid render size "
+ + renderWidth + "x" + renderHeight, null);
+ return null;
+ }
+ return new int[] { renderWidth, renderHeight };
+ }
+ } catch (Throwable t) {
+ disableForQuery("ngxshim_query_optimal_dlssd threw", t);
return null;
}
- ensureInitialized(device);
- if (!lib.hasQueryOptimalDlssd()) {
- throw new IllegalStateException("ngxshim is missing ngxshim_query_optimal_dlssd (stale native shim)");
- }
- try (Arena arena = Arena.ofConfined()) {
- MemorySegment outWidth = arena.allocate(ValueLayout.JAVA_INT);
- MemorySegment outHeight = arena.allocate(ValueLayout.JAVA_INT);
- MemorySegment outSharpness = arena.allocate(ValueLayout.JAVA_FLOAT);
- int rc = lib.queryOptimalDlssd(displayWidth, displayHeight, quality(), outWidth, outHeight, outSharpness);
- if (NgxRuntime.ngxFailed(rc)) {
- throw new IllegalStateException("ngxshim_query_optimal_dlssd failed: 0x" + Integer.toHexString(rc));
- }
- int renderWidth = outWidth.get(ValueLayout.JAVA_INT, 0);
- int renderHeight = outHeight.get(ValueLayout.JAVA_INT, 0);
- if (renderWidth <= 0 || renderHeight <= 0) {
- throw new IllegalStateException(
- "ngxshim_query_optimal_dlssd returned invalid render size " + renderWidth + "x" + renderHeight);
+ }
+
+ private void disableForQuery(String reason, Throwable cause) {
+ failed = true;
+ featureInvalid = !isNull(feature);
+ if (featureInvalid) {
+ try {
+ VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull();
+ if (device != null) {
+ releaseFeature(device);
+ featureInvalid = false;
+ }
+ } catch (Throwable t) {
+ CausticaMod.LOGGER.warn("DLSS-RR query failure could not release the live native feature", t);
}
- return new int[] { renderWidth, renderHeight };
+ }
+ requestHistoryReset();
+ if (cause == null) {
+ CausticaMod.LOGGER.warn("DLSS-RR disabled; using full-resolution RT fallback: {}", reason);
+ } else {
+ CausticaMod.LOGGER.warn("DLSS-RR disabled; using full-resolution RT fallback: " + reason, cause);
}
}
+ private static boolean validRenderSize(int renderWidth, int renderHeight, int displayWidth, int displayHeight) {
+ if (displayWidth <= 0 || displayHeight <= 0 || renderWidth <= 0 || renderHeight <= 0
+ || renderWidth > displayWidth || renderHeight > displayHeight) {
+ return false;
+ }
+ long aspectDelta = Math.abs((long) renderWidth * displayHeight - (long) displayWidth * renderHeight);
+ return aspectDelta <= Math.max(displayWidth, displayHeight);
+ }
+
/**
* Ensure NGX is initialized and an RR feature exists for the given resolutions, creating it into
* the supplied recording command buffer. Returns false (and disables itself) on any failure so the
@@ -170,16 +266,19 @@ public boolean ensureFeature(long cmd, int renderWidth, int renderHeight, int di
if (!enabled() || failed) {
return false;
}
- if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) {
- return false;
- }
try {
+ if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) {
+ failed = true;
+ requestHistoryReset();
+ CausticaMod.LOGGER.warn("DLSS-RR disabled; Vulkan device backend is unavailable");
+ return false;
+ }
ensureInitialized(device);
int quality = quality();
int preset = renderPreset();
if (featureRenderWidth != renderWidth || featureRenderHeight != renderHeight
|| featureDisplayWidth != displayWidth || featureDisplayHeight != displayHeight
- || featureQuality != quality || featurePreset != preset
+ || featureQuality != quality || featurePreset != preset || featureInvalid
|| isNull(feature)) {
releaseFeature(device);
feature = lib.createDlssd(cmd, renderWidth, renderHeight, displayWidth, displayHeight,
@@ -194,6 +293,7 @@ public boolean ensureFeature(long cmd, int renderWidth, int renderHeight, int di
featureDisplayHeight = displayHeight;
featureQuality = quality;
featurePreset = preset;
+ featureDevice = device;
resetHistory = true; // a fresh feature has no temporal history
CausticaMod.LOGGER.info("DLSS-RR feature created: {}x{} -> {}x{} (quality {}, preset {})",
renderWidth, renderHeight, displayWidth, displayHeight, quality, preset);
@@ -230,25 +330,67 @@ private void ensureInitialized(VulkanDevice device) {
/**
* Release the RR feature. Does NOT shut down NGX — that is the shared {@link NgxRuntime}'s job at device
* teardown ({@code NgxRuntime.shutdown()} in {@code CausticaClient.shutdownRt}), so FG can keep using NGX.
+ * Returns false while the native feature remains owned and the Vulkan device must stay alive.
*/
- public void destroy() {
- if (((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device) {
- releaseFeature(device);
+ public boolean destroy() {
+ try {
+ if (!isNull(feature)) {
+ VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull();
+ if (device == null) {
+ throw new IllegalStateException("DLSS-RR feature owner device is unavailable");
+ }
+ releaseFeature(device);
+ }
+ } catch (Throwable t) {
+ CausticaMod.LOGGER.warn("DLSS-RR teardown failed; native ownership is retained until restart", t);
}
initialized = false;
- lib = null;
+ if (isNull(feature)) {
+ lib = null;
+ featureDevice = null;
+ featureInvalid = false;
+ failed = false;
+ resetHistory = false;
+ lastFrameNanos = 0L;
+ loggedAvailable = false;
+ } else {
+ failed = true;
+ featureInvalid = true;
+ }
+ return isNull(feature);
+ }
+
+ private static VulkanDevice currentDeviceOrNull() {
+ RtContext ctx = RtContext.currentOrNull();
+ if (ctx != null) {
+ return ctx.device();
+ }
+ if (RenderSystem.getDevice() instanceof GpuDeviceAccessor accessor
+ && accessor.caustica$getBackend() instanceof VulkanDevice device) {
+ return device;
+ }
+ return null;
}
private void releaseFeature(VulkanDevice device) {
if (!isNull(feature)) {
+ VulkanDevice owner = featureDevice != null ? featureDevice : device;
+ if (owner == null) {
+ throw new IllegalStateException("DLSS-RR feature owner device is unavailable");
+ }
+ if (lib == null) {
+ throw new IllegalStateException("DLSS-RR feature library is unavailable");
+ }
RtContext ctx = RtContext.currentOrNull();
- if (ctx != null && ctx.device() == device) {
+ if (ctx != null && ctx.device() == owner) {
ctx.waitIdle();
} else {
- VK10.vkDeviceWaitIdle(device.vkDevice());
+ RtContext.check(owner, VK10.vkDeviceWaitIdle(owner.vkDevice()),
+ "vkDeviceWaitIdle before DLSS-RR release");
}
lib.release(feature);
feature = MemorySegment.NULL;
+ featureDevice = null;
}
featureRenderWidth = -1;
featureRenderHeight = -1;
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java
index 3676f9d7..1b396847 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java
@@ -41,8 +41,12 @@ public final class RtExposure {
private ExposureCurve cachedCurve;
private boolean resetRequested = true;
private int resetSequence;
+ private Mode previousMode;
/** This frame's latched pre-exposure; see {@link #beginFrame(RtGpuExecutor.GraphicsUseWaiter)}. */
private float framePreExposure = 1.0f;
+ /** Immutable pre-exposure used by every frame in one finite screenshot capture. */
+ private float capturePreExposure = 1.0f;
+ private boolean captureFrozen;
private static final long DIAG_LOG_INTERVAL_NANOS = 1_000_000_000L;
private static final int STATE_READBACK_RING = 6;
@@ -70,6 +74,22 @@ public RtBuffer stateBuffer() {
return state;
}
+ /** Freeze the current display exposure for a finite multi-frame capture. */
+ public void beginCapture() {
+ capturePreExposure = framePreExposure;
+ captureFrozen = true;
+ }
+
+ /** Release the capture latch after the screenshot readback has been scheduled. */
+ public void endCapture() {
+ captureFrozen = false;
+ capturePreExposure = 1.0f;
+ }
+
+ public boolean captureFrozen() {
+ return captureFrozen;
+ }
+
/** Immutable exposure values attached to a residual-exposed EXR capture. */
public record CaptureMetadata(
float preExposure,
@@ -79,8 +99,7 @@ public record CaptureMetadata(
float evScene,
float evTarget,
float evApplied
- ) {
- }
+ ) {}
/**
* Snapshot the controller after the capture copy has completed.
@@ -153,6 +172,9 @@ public void ensureResources(RtContext ctx) {
public void record(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack,
RtImage traceColor, RtImage guideDepth, RtImage guideAlbedo) {
+ if (captureFrozen) {
+ return;
+ }
if (image == null) {
throw new IllegalStateException("RT exposure image not created");
}
@@ -201,6 +223,9 @@ public void destroy() {
pendingStateReadback = null;
completedState = null;
framePreExposure = 1.0f;
+ capturePreExposure = 1.0f;
+ captureFrozen = false;
+ previousMode = null;
}
// Manual mode's exposure scale, also used as the auto-history seed (resetAutoHistory) so the very
@@ -232,7 +257,7 @@ private void recordAuto(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack,
* consumed until its graphics timeline value completes, so the host never races the live storage buffer.
*/
public void recordStateReadback(VkCommandBuffer cmd, MemoryStack stack) {
- if (mode() != Mode.AUTO || pendingStateReadback == null) {
+ if (captureFrozen || mode() != Mode.AUTO || pendingStateReadback == null) {
return;
}
VkBufferMemoryBarrier.Buffer toTransfer = VkBufferMemoryBarrier.calloc(1, stack);
@@ -382,9 +407,13 @@ private void logOnce() {
+ ", emissiveCap=" + autoConfig.emissiveWeightCap
+ ", curve=" + CausticaConfig.Rt.Exposure.curve() + ")"
: Float.toString(manualExposureScale());
+ RtToneMapping.Settings toneMapping = RtToneMapping.current();
CausticaMod.LOGGER.info("RT display exposure: mode={}, exposure={}, "
- + "tonemap=aces2.0(lookPackage={},gamma={}), DLSS-RR exposure=NGX auto",
- mode.configName, exposureText, RtLookPackage.current().id(),
+ + "tonemap=sdr:{},hdr:{}(lookPackage={},paperWhiteNits={},gamma={}), DLSS-RR exposure=NGX auto",
+ mode.configName, exposureText,
+ RtToneMapping.SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get()).canonicalName(),
+ RtToneMapping.HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get()).canonicalName(),
+ RtLookPackage.current().id(), toneMapping.paperWhiteNits(),
CausticaConfig.Rt.Tonemap.GAMMA.value());
}
@@ -397,6 +426,9 @@ private static float manualEv() {
}
private AutoConfig autoConfig() {
+ PercentileWindow percentiles = PercentileWindow.sanitize(
+ CausticaConfig.Rt.Exposure.LOW_PERCENTILE.value(),
+ CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.value());
return new AutoConfig(
CausticaConfig.Rt.Exposure.KEY.value(),
CausticaConfig.Rt.Exposure.minEv(),
@@ -404,8 +436,8 @@ private AutoConfig autoConfig() {
CausticaConfig.Rt.Exposure.ADAPT_DARKEN.value(),
CausticaConfig.Rt.Exposure.ADAPT_BRIGHTEN.value(),
manualEv(),
- CausticaConfig.Rt.Exposure.LOW_PERCENTILE.value(),
- CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.value(),
+ percentiles.low(),
+ percentiles.high(),
CausticaConfig.Rt.Exposure.STRIDE.value(),
CausticaConfig.Rt.Exposure.CENTER_WEIGHT_SIGMA.value(),
CausticaConfig.Rt.Exposure.CENTER_WEIGHT_FLOOR.value(),
@@ -426,7 +458,14 @@ private AutoConfig autoConfig() {
* ensures both consumers use one prediction; the residual absorbs whatever it failed to predict.
*/
public void beginFrame(RtGpuExecutor.GraphicsUseWaiter graphicsUseWaiter) {
+ if (captureFrozen) {
+ return;
+ }
Mode currentMode = mode();
+ if (modeTransitionRequiresReset(previousMode, currentMode)) {
+ requestReset();
+ }
+ previousMode = currentMode;
boolean reset = currentMode == Mode.AUTO && resetRequested;
if (reset) {
resetSequence++;
@@ -469,7 +508,7 @@ public void requestReset() {
* no fence is needed. 1.0 disables the mechanism.
*/
public float preExposure() {
- return framePreExposure;
+ return captureFrozen ? capturePreExposure : framePreExposure;
}
private float computePreExposure() {
@@ -489,7 +528,7 @@ private float computePreExposure() {
// truncate -- silently de-centring exactly the case pre-exposure exists to handle. The
// controller's own minEv/maxEv already bound this value; here we only reject garbage.
float previous = completedState.previous();
- return Float.isFinite(previous) && previous > 0.0f ? previous : 1.0f;
+ return sanitizePreExposure(previous);
}
private ByteBuffer stateDataBuffer() {
@@ -512,10 +551,52 @@ record AutoConfig(float key, float minEv, float maxEv, float adaptDarken, float
* unit convention's offset applies.
*/
float evOffset() {
- return RtSceneUnits.EV100_OFFSET - (float) (Math.log(Math.max(preExposure, 1.0e-12f)) / Math.log(2.0));
+ return ev100Offset(preExposure);
}
}
+ static float ev100Offset(float preExposure) {
+ float safePreExposure = sanitizePreExposure(preExposure);
+ return RtSceneUnits.EV100_OFFSET
+ - (float) (Math.log(safePreExposure) / Math.log(2.0));
+ }
+
+ private static float sanitizePreExposure(float value) {
+ return Float.isFinite(value) && value > 0.0f ? value : 1.0f;
+ }
+
+ record PercentileWindow(float low, float high) {
+ private static final float DEFAULT_LOW = 0.50f;
+ private static final float DEFAULT_HIGH = 0.95f;
+
+ static PercentileWindow sanitize(float low, float high) {
+ low = sanitizeValue(low, DEFAULT_LOW);
+ high = sanitizeValue(high, DEFAULT_HIGH);
+ if (high < low) {
+ float swap = low;
+ low = high;
+ high = swap;
+ }
+ if (!(low < high)) {
+ if (low >= 1.0f) {
+ low = Math.nextDown(1.0f);
+ high = 1.0f;
+ } else {
+ high = Math.nextUp(low);
+ }
+ }
+ return new PercentileWindow(low, high);
+ }
+
+ private static float sanitizeValue(float value, float fallback) {
+ return Float.isFinite(value) ? Math.clamp(value, 0.0f, 1.0f) : fallback;
+ }
+ }
+
+ static boolean modeTransitionRequiresReset(Mode previous, Mode current) {
+ return previous != null && previous != current && current == Mode.AUTO;
+ }
+
private ExposureCurve curveConfig() {
String spec = CausticaConfig.Rt.Exposure.curve();
if (cachedCurve != null && Objects.equals(cachedCurveSpec, spec)) {
@@ -624,7 +705,7 @@ private static float slope(float x0, float y0, float x1, float y1) {
}
}
- private enum Mode {
+ enum Mode {
MANUAL("manual"),
AUTO("auto");
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java
index 79cc5852..1f61b77a 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java
@@ -35,6 +35,8 @@
/** Compute pipelines for histogram auto-exposure over the RT HDR trace output. */
final class RtExposurePipeline {
private static final String SHADER_DIR = "/caustica/shaders/pipelines/";
+ private static final int HISTOGRAM_WORKGROUP_SIZE = 16;
+ private static final long MAX_WEIGHTED_SAMPLES = 0xffff_ffffL / 256L;
private final RtContext ctx;
private final long histDescriptorSetLayout;
@@ -195,16 +197,64 @@ void dispatchHistogram(org.lwjgl.vulkan.VkCommandBuffer cmd, int width, int heig
VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, histPipelineLayout, 0,
stack.longs(histDescriptorSet), null);
ByteBuffer push = stack.malloc(ExposureHistPushData.BYTE_SIZE);
- new ExposureHistPushData(config.stride(), config.centerWeightSigma(), config.centerWeightFloor())
+ int stride = effectiveStride(width, height, config.stride());
+ new ExposureHistPushData(stride, config.centerWeightSigma(), config.centerWeightFloor())
.write(push);
VK10.vkCmdPushConstants(cmd, histPipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push);
- int stride = config.stride();
- int sampleWidth = (width + stride - 1) / stride;
- int sampleHeight = (height + stride - 1) / stride;
- VK10.vkCmdDispatch(cmd, (sampleWidth + 15) / 16, (sampleHeight + 15) / 16, 1);
+ VK10.vkCmdDispatch(cmd, dispatchGroups(width, stride), dispatchGroups(height, stride), 1);
}
}
+ static int safeStride(int stride) {
+ return Math.max(stride, 1);
+ }
+
+ static int effectiveStride(int width, int height, int requested) {
+ int stride = safeStride(requested);
+ int maxDimension = Math.max(Math.max(width, height), 1);
+ while (weightedSampleCount(width, height, stride) > MAX_WEIGHTED_SAMPLES
+ && stride < maxDimension) {
+ int next = stride > maxDimension / 2 ? maxDimension : stride * 2;
+ if (next == stride) {
+ break;
+ }
+ stride = next;
+ }
+ int low = safeStride(requested);
+ int high = stride;
+ while (low < high) {
+ int middle = low + (high - low) / 2;
+ if (weightedSampleCount(width, height, middle) <= MAX_WEIGHTED_SAMPLES) {
+ high = middle;
+ } else {
+ low = middle + 1;
+ }
+ }
+ return low;
+ }
+
+ private static long weightedSampleCount(int width, int height, int stride) {
+ long columns = sampledExtent(width, stride);
+ long rows = sampledExtent(height, stride);
+ if (columns > Long.MAX_VALUE / rows) {
+ return Long.MAX_VALUE;
+ }
+ return columns * rows;
+ }
+
+ static int sampledExtent(int extent, int stride) {
+ long positiveExtent = Math.max((long) extent, 1L);
+ long safeDivisor = safeStride(stride);
+ long samples = (positiveExtent + safeDivisor - 1L) / safeDivisor;
+ return (int) Math.min(Integer.MAX_VALUE, Math.max(samples, 1L));
+ }
+
+ static int dispatchGroups(int extent, int stride) {
+ long samples = sampledExtent(extent, stride);
+ long groups = (samples + HISTOGRAM_WORKGROUP_SIZE - 1L) / HISTOGRAM_WORKGROUP_SIZE;
+ return (int) Math.min(Integer.MAX_VALUE, Math.max(groups, 1L));
+ }
+
void dispatchResolve(org.lwjgl.vulkan.VkCommandBuffer cmd, RtExposure.AutoConfig config, float frameTimeSeconds) {
try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure resolve")) {
VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, resolvePipeline);
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java
new file mode 100644
index 00000000..34b7e7c7
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java
@@ -0,0 +1,140 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import dev.comfyfluffy.caustica.rt.RtContext;
+import dev.comfyfluffy.caustica.rt.accel.RtBuffer;
+import org.lwjgl.system.MemoryUtil;
+import org.lwjgl.vulkan.VK10;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Immutable GPU data for Caustica's canonical shuffled-scrambled Sobol path sampler.
+ *
+ * The resource stores compact Joe-Kuo Sobol nibble lookups for dimensions 1..3; dimension 0 is
+ * evaluated analytically as the bit-reversed Gray code. It also stores independently generated
+ * randomization roots for every continuation branch, bounce, and semantic low-dimensional group. The
+ * independent per-coordinate digital-shift roots are the estimator's unbiased randomization; the
+ * nested-uniform index shuffle and coordinate scramble improve projection quality without being the
+ * proof foundation. This is sample-indexed direction data, never a spatial tile.
+ */
+public final class RtPathSamplerData {
+ public static final int ALGORITHM_VERSION = 3;
+
+ static final int DIMENSIONS = RtSobolDirectionNumbers.DIMENSIONS;
+ static final int NIBBLE_BLOCKS = 8;
+ static final int NIBBLE_VALUES = 16;
+ static final int WORDS_PER_DIMENSION = NIBBLE_BLOCKS * NIBBLE_VALUES;
+ static final int FIRST_TABLE_DIMENSION = 1;
+ static final int TABLE_DIMENSION_COUNT = DIMENSIONS - FIRST_TABLE_DIMENSION;
+
+ public static final int PATH_BRANCH_COUNT = 2;
+ public static final int MAX_SUPPORTED_BOUNCE = 8;
+ static final int BOUNCE_COUNT = MAX_SUPPORTED_BOUNCE + 1;
+ public static final int MAX_RIS_CANDIDATES = 32;
+
+ static final int GROUP_COUNT = 3 + 1 + MAX_RIS_CANDIDATES * 2 + 2;
+ static final int ROOTS_PER_GROUP = 1 + DIMENSIONS * 2;
+
+ static final int DIRECTION_TABLE_OFFSET = 0;
+ static final int ROOT_TABLE_OFFSET = DIRECTION_TABLE_OFFSET
+ + TABLE_DIMENSION_COUNT * WORDS_PER_DIMENSION;
+ static final int ROOT_WORD_COUNT = PATH_BRANCH_COUNT * BOUNCE_COUNT * GROUP_COUNT * ROOTS_PER_GROUP;
+ static final int WORD_COUNT = ROOT_TABLE_OFFSET + ROOT_WORD_COUNT;
+ static final long BYTE_SIZE = (long) WORD_COUNT * Integer.BYTES;
+ static final int ADDRESS_ALIGNMENT = 16;
+
+ private static final int[][] DIRECTIONS = RtSobolDirectionNumbers.createDirections();
+
+ private final RtBuffer buffer;
+
+ private RtPathSamplerData(RtBuffer buffer) {
+ this.buffer = buffer;
+ }
+
+ /** Create the one required sampler resource. Failure propagates and disables RT through RtComposite. */
+ public static RtPathSamplerData create(RtContext ctx) {
+ Objects.requireNonNull(ctx, "ctx");
+ SecureRandom random = new SecureRandom();
+ int[] roots = new int[ROOT_WORD_COUNT];
+ for (int index = 0; index < roots.length; index++) {
+ roots[index] = random.nextInt();
+ }
+ return create(ctx, roots);
+ }
+
+ private static RtPathSamplerData create(RtContext ctx, int[] randomizationRoots) {
+ Objects.requireNonNull(ctx, "ctx");
+ int[] roots = checkedRootCopy(randomizationRoots);
+ RtBuffer buffer = ctx.createAlignedBuffer(BYTE_SIZE, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true,
+ "canonical shuffled-scrambled Sobol path sampler", ADDRESS_ALIGNMENT);
+ try {
+ requireUsableBuffer(buffer.mapped, buffer.deviceAddress);
+
+ int[] words = buildResourceWords(roots);
+ long address = buffer.mapped;
+ for (int word : words) {
+ MemoryUtil.memPutInt(address, word);
+ address += Integer.BYTES;
+ }
+ buffer.flush();
+ return new RtPathSamplerData(buffer);
+ } catch (RuntimeException | Error failure) {
+ try {
+ buffer.destroy();
+ } catch (RuntimeException | Error destroyFailure) {
+ failure.addSuppressed(destroyFailure);
+ }
+ throw failure;
+ }
+ }
+
+ public long deviceAddress() {
+ return buffer.deviceAddress;
+ }
+
+ public void destroy() {
+ buffer.destroy();
+ }
+
+ static void requireUsableBuffer(long mappedAddress, long deviceAddress) {
+ if (mappedAddress == 0L) {
+ throw new IllegalStateException("Path sampler data buffer is not host mapped");
+ }
+ if (deviceAddress == 0L) {
+ throw new IllegalStateException("Path sampler data buffer has no device address");
+ }
+ }
+
+ static int[] buildResourceWords(int[] randomizationRoots) {
+ int[] roots = checkedRootCopy(randomizationRoots);
+ int[] words = new int[WORD_COUNT];
+ for (int dimension = FIRST_TABLE_DIMENSION; dimension < DIMENSIONS; dimension++) {
+ int tableDimension = dimension - FIRST_TABLE_DIMENSION;
+ int dimensionOffset = DIRECTION_TABLE_OFFSET + tableDimension * WORDS_PER_DIMENSION;
+ for (int block = 0; block < NIBBLE_BLOCKS; block++) {
+ int blockOffset = dimensionOffset + block * NIBBLE_VALUES;
+ for (int nibble = 0; nibble < NIBBLE_VALUES; nibble++) {
+ int value = 0;
+ for (int bit = 0; bit < 4; bit++) {
+ if ((nibble & (1 << bit)) != 0) {
+ value ^= DIRECTIONS[dimension][block * 4 + bit];
+ }
+ }
+ words[blockOffset + nibble] = value;
+ }
+ }
+ }
+ System.arraycopy(roots, 0, words, ROOT_TABLE_OFFSET, roots.length);
+ return words;
+ }
+
+ private static int[] checkedRootCopy(int[] roots) {
+ if (roots == null || roots.length != ROOT_WORD_COUNT) {
+ throw new IllegalArgumentException("Path sampler requires exactly " + ROOT_WORD_COUNT
+ + " randomization roots");
+ }
+ return Arrays.copyOf(roots, ROOT_WORD_COUNT);
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java
index 9694ffe6..a30e0659 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java
@@ -154,7 +154,7 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St
binds.get(WORLD_BLOCK_ALBEDO).binding(WORLD_BLOCK_ALBEDO)
.descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
.descriptorCount(1).stageFlags(atlasStages);
- for (int binding = WORLD_G_NORMAL; binding <= WORLD_G_SPEC_MOTION; binding++) {
+ for (int binding = WORLD_G_NORMAL; binding <= WORLD_G_SKY_CLASSIFICATION; binding++) {
binds.get(binding).binding(binding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE)
.descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR);
}
@@ -168,6 +168,9 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St
.descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
.descriptorCount(1)
.stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR | VK_SHADER_STAGE_RAYGEN_BIT_KHR);
+ binds.get(WORLD_END_SKY).binding(WORLD_END_SKY)
+ .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
+ .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR);
VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds);
LongBuffer p = stack.mallocLong(1);
check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout");
@@ -446,6 +449,11 @@ public boolean hasSkyAtlas() {
return true;
}
+ /** Bind Minecraft's standalone End sky texture for dimension-specific ray misses. */
+ public void setEndSkyTexture(long imageView, long sampler) {
+ writeAtlasBinding(WORLD_END_SKY, imageView, sampler);
+ }
+
/** Bind this frame's atmosphere LUTs (see {@link RtSkyLut}); both share the LUT's own sampler. */
public void setSkyLuts(long skyViewImageView, long transmittanceImageView, long sampler) {
writeAtlasBinding(WORLD_SKY_VIEW, skyViewImageView, sampler);
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java
new file mode 100644
index 00000000..18f23f81
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java
@@ -0,0 +1,120 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import dev.comfyfluffy.caustica.rt.RtContext;
+import dev.comfyfluffy.caustica.rt.RtDebugLabels;
+import org.lwjgl.system.MemoryStack;
+import org.lwjgl.system.MemoryUtil;
+import org.lwjgl.vulkan.VkCommandBuffer;
+import org.lwjgl.vulkan.VkComputePipelineCreateInfo;
+import org.lwjgl.vulkan.VkPipelineLayoutCreateInfo;
+import org.lwjgl.vulkan.VkPipelineShaderStageCreateInfo;
+import org.lwjgl.vulkan.VkPushConstantRange;
+import org.lwjgl.vulkan.VkShaderModuleCreateInfo;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.LongBuffer;
+
+import static dev.comfyfluffy.caustica.rt.RtContext.check;
+import static org.lwjgl.vulkan.VK10.VK_NULL_HANDLE;
+import static org.lwjgl.vulkan.VK10.VK_PIPELINE_BIND_POINT_COMPUTE;
+import static org.lwjgl.vulkan.VK10.VK_SHADER_STAGE_COMPUTE_BIT;
+import static org.lwjgl.vulkan.VK10.vkCmdBindPipeline;
+import static org.lwjgl.vulkan.VK10.vkCmdDispatch;
+import static org.lwjgl.vulkan.VK10.vkCmdPushConstants;
+import static org.lwjgl.vulkan.VK10.vkCreateComputePipelines;
+import static org.lwjgl.vulkan.VK10.vkCreatePipelineLayout;
+import static org.lwjgl.vulkan.VK10.vkCreateShaderModule;
+import static org.lwjgl.vulkan.VK10.vkDestroyPipeline;
+import static org.lwjgl.vulkan.VK10.vkDestroyPipelineLayout;
+import static org.lwjgl.vulkan.VK10.vkDestroyShaderModule;
+
+/** Descriptor-free directional-SH resolve pass. */
+public final class RtSharcResolvePipeline {
+ private static final String SHADER = "/caustica/shaders/sharc/sharc_resolve.comp.spv";
+ private static final int PUSH_BYTES = Long.BYTES;
+
+ private final RtContext ctx;
+ private final long layout;
+ private final long pipeline;
+ private boolean destroyed;
+
+ private RtSharcResolvePipeline(RtContext ctx, long layout, long pipeline) {
+ this.ctx = ctx;
+ this.layout = layout;
+ this.pipeline = pipeline;
+ }
+
+ public static RtSharcResolvePipeline create(RtContext ctx) {
+ long layout = 0L;
+ long module = 0L;
+ long pipeline = 0L;
+ try (MemoryStack stack = MemoryStack.stackPush()) {
+ VkPushConstantRange.Buffer range = VkPushConstantRange.calloc(1, stack)
+ .stageFlags(VK_SHADER_STAGE_COMPUTE_BIT).offset(0).size(PUSH_BYTES);
+ VkPipelineLayoutCreateInfo layoutInfo = VkPipelineLayoutCreateInfo.calloc(stack).sType$Default()
+ .pPushConstantRanges(range);
+ LongBuffer p = stack.mallocLong(1);
+ check(vkCreatePipelineLayout(ctx.vk(), layoutInfo, null, p),
+ "vkCreatePipelineLayout(SHaRC resolve)");
+ layout = p.get(0);
+ RtDebugLabels.name(ctx, org.lwjgl.vulkan.VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout,
+ "SHaRC resolve layout");
+ module = loadModule(ctx, stack);
+ VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default()
+ .stage(VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main"));
+ VkComputePipelineCreateInfo.Buffer createInfo = VkComputePipelineCreateInfo.calloc(1, stack);
+ createInfo.get(0).sType$Default().stage(stage).layout(layout);
+ check(vkCreateComputePipelines(ctx.vk(), VK_NULL_HANDLE, createInfo, null, p),
+ "vkCreateComputePipelines(SHaRC resolve)");
+ pipeline = p.get(0);
+ vkDestroyShaderModule(ctx.vk(), module, null);
+ module = 0L;
+ RtDebugLabels.name(ctx, org.lwjgl.vulkan.VK10.VK_OBJECT_TYPE_PIPELINE, pipeline,
+ "SHaRC resolve");
+ return new RtSharcResolvePipeline(ctx, layout, pipeline);
+ } catch (Throwable t) {
+ if (module != 0L) vkDestroyShaderModule(ctx.vk(), module, null);
+ if (pipeline != 0L) vkDestroyPipeline(ctx.vk(), pipeline, null);
+ if (layout != 0L) vkDestroyPipelineLayout(ctx.vk(), layout, null);
+ throw t;
+ }
+ }
+
+ public void dispatch(VkCommandBuffer cmd, long frameAddress, int capacity) {
+ try (MemoryStack stack = MemoryStack.stackPush();
+ RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC resolve")) {
+ vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline);
+ ByteBuffer push = stack.malloc(PUSH_BYTES).putLong(0, frameAddress);
+ vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, push);
+ vkCmdDispatch(cmd, (capacity + 255) / 256, 1, 1);
+ }
+ }
+
+ public void destroy() {
+ if (destroyed) return;
+ vkDestroyPipeline(ctx.vk(), pipeline, null);
+ vkDestroyPipelineLayout(ctx.vk(), layout, null);
+ destroyed = true;
+ }
+
+ private static long loadModule(RtContext ctx, MemoryStack stack) {
+ byte[] bytes;
+ try (InputStream in = RtSharcResolvePipeline.class.getResourceAsStream(SHADER)) {
+ if (in == null) throw new IllegalStateException("missing SHaRC SPIR-V resource: " + SHADER);
+ bytes = in.readAllBytes();
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to read " + SHADER, e);
+ }
+ ByteBuffer code = MemoryUtil.memAlloc(bytes.length).put(bytes).flip();
+ try {
+ VkShaderModuleCreateInfo info = VkShaderModuleCreateInfo.calloc(stack).sType$Default().pCode(code);
+ LongBuffer p = stack.mallocLong(1);
+ check(vkCreateShaderModule(ctx.vk(), info, null, p), "vkCreateShaderModule(SHaRC resolve)");
+ return p.get(0);
+ } finally {
+ MemoryUtil.memFree(code);
+ }
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java
new file mode 100644
index 00000000..a0454ed0
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java
@@ -0,0 +1,70 @@
+/*
+ * Sobol direction-number data notice
+ *
+ * Copyright (c) 2008, Frances Y. Kuo and Stephen Joe
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are permitted
+ * provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this list of conditions
+ * and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions
+ * and the following disclaimer in the documentation and/or other materials provided with the
+ * distribution.
+ * 3. Neither the names of the copyright holders nor the names of the University of New South Wales and
+ * the University of Waikato and its contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
+ * OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+/** Exact expansion of the first four Joe-Kuo D(6) Sobol dimensions. */
+final class RtSobolDirectionNumbers {
+ static final int DIMENSIONS = 4;
+
+ // Rows 2..4 from new-joe-kuo-6.21201. Dimension 1 is defined analytically below.
+ private static final int[][] PARAMETERS = {
+ {},
+ {1, 0, 1},
+ {2, 1, 1, 3},
+ {3, 1, 1, 3, 1}
+ };
+
+ private RtSobolDirectionNumbers() {
+ }
+
+ static int[][] createDirections() {
+ int[][] directions = new int[DIMENSIONS][Integer.SIZE];
+ for (int bit = 0; bit < Integer.SIZE; bit++) {
+ directions[0][bit] = 1 << (Integer.SIZE - 1 - bit);
+ }
+ for (int dimension = 1; dimension < DIMENSIONS; dimension++) {
+ int[] parameters = PARAMETERS[dimension];
+ int degree = parameters[0];
+ int coefficient = parameters[1];
+ for (int bit = 1; bit <= degree; bit++) {
+ directions[dimension][bit - 1] = parameters[bit + 1] << (Integer.SIZE - bit);
+ }
+ for (int bit = degree + 1; bit <= Integer.SIZE; bit++) {
+ int value = directions[dimension][bit - degree - 1]
+ ^ (directions[dimension][bit - degree - 1] >>> degree);
+ for (int k = 1; k < degree; k++) {
+ if (((coefficient >>> (degree - 1 - k)) & 1) != 0) {
+ value ^= directions[dimension][bit - k - 1];
+ }
+ }
+ directions[dimension][bit - 1] = value;
+ }
+ }
+ return directions;
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java
new file mode 100644
index 00000000..0d3978ab
--- /dev/null
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java
@@ -0,0 +1,366 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import dev.comfyfluffy.caustica.CausticaConfig;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Central registry of stable tone-mapper mode IDs, config names, and aliases. Owns only the
+ * mode table and the immutable {@link Settings} record read by the display dispatch. Does not
+ * own Vulkan resources, shader compilation, pipeline lifetime, config file I/O, or Minecraft widgets.
+ *
+ *
ACES 2.0 is the SDR and HDR default and remains mode 0 for the reference LUT path. Unknown
+ * config values fall back to that default. The
+ * integer IDs are mirrored by the display shader's mode switch.
+ */
+public final class RtToneMapping {
+ private static final List SDR_CONFIG_NAMES =
+ Arrays.stream(SdrMode.values()).map(SdrMode::canonicalName).toList();
+ private static final List HDR_CONFIG_NAMES =
+ Arrays.stream(HdrMode.values()).map(HdrMode::canonicalName).toList();
+ private static volatile Settings cachedSettings;
+
+ private RtToneMapping() {
+ }
+
+ /** Stable SDR tone-mapper modes. IDs mirror the display shader's mode switch. */
+ public enum SdrMode {
+ ACES_2_0(0, "aces2.0", "aces-2.0", "aces2"),
+ AGX(1, "agx"),
+ PBR_NEUTRAL(2, "pbr-neutral"),
+ REINHARD(3, "reinhard"),
+ ACES(4, "aces"),
+ LOTTES(5, "lottes"),
+ UNCHARTED_2(6, "uncharted2", "uncharted-2"),
+ GT(7, "gt", "uchimura"),
+ PSYCHOV24(
+ 8,
+ "psychov24",
+ "psychovisual",
+ "psycho-visual",
+ "psychov",
+ "psychov11",
+ "psychov23",
+ "psychov24-experimental");
+
+ private final int id;
+ private final String canonicalName;
+ private final List aliases;
+
+ SdrMode(int id, String canonicalName, String... aliases) {
+ this.id = id;
+ this.canonicalName = canonicalName;
+ this.aliases = List.of(aliases);
+ }
+
+ public int id() {
+ return id;
+ }
+
+ public String canonicalName() {
+ return canonicalName;
+ }
+
+ /** Case-insensitive parse with whitespace trimming; unknown values use the ACES 2.0 default. */
+ public static SdrMode parse(String value) {
+ SdrMode known = find(value);
+ return known != null ? known : ACES_2_0;
+ }
+
+ /** Returns whether the value is a canonical name or a committed compatibility alias. */
+ public static boolean isKnown(String value) {
+ return find(value) != null;
+ }
+
+ private static SdrMode find(String value) {
+ if (value != null) {
+ String trimmed = value.trim();
+ for (SdrMode mode : values()) {
+ if (mode.canonicalName.equalsIgnoreCase(trimmed)) {
+ return mode;
+ }
+ for (String alias : mode.aliases) {
+ if (alias.equalsIgnoreCase(trimmed)) {
+ return mode;
+ }
+ }
+ }
+ }
+ return null;
+ }
+ }
+
+ /** Stable HDR tone-mapper modes. IDs mirror the display shader's mode switch. */
+ public enum HdrMode {
+ ACES_2_0(0, "aces2.0", "aces-2.0", "aces2"),
+ BT2390(3, "bt2390", "bt-2390", "bt.2390", "standard", "standard-hdr"),
+ PSYCHOV24(
+ 2,
+ "psychov24",
+ "psychovisual",
+ "psycho-visual",
+ "psychov",
+ "psychov11",
+ "psychov23",
+ "psychov24-experimental");
+
+ private final int id;
+ private final String canonicalName;
+ private final List aliases;
+
+ HdrMode(int id, String canonicalName, String... aliases) {
+ this.id = id;
+ this.canonicalName = canonicalName;
+ this.aliases = List.of(aliases);
+ }
+
+ public int id() {
+ return id;
+ }
+
+ public String canonicalName() {
+ return canonicalName;
+ }
+
+ /** Case-insensitive parse with whitespace trimming; unknown values use the ACES 2.0 default. */
+ public static HdrMode parse(String value) {
+ HdrMode known = find(value);
+ return known != null ? known : ACES_2_0;
+ }
+
+ /** Returns whether the value is a canonical name or a committed compatibility alias. */
+ public static boolean isKnown(String value) {
+ return find(value) != null;
+ }
+
+ private static HdrMode find(String value) {
+ if (value != null) {
+ String trimmed = value.trim();
+ for (HdrMode mode : values()) {
+ if (mode.canonicalName.equalsIgnoreCase(trimmed)) {
+ return mode;
+ }
+ for (String alias : mode.aliases) {
+ if (alias.equalsIgnoreCase(trimmed)) {
+ return mode;
+ }
+ }
+ }
+ }
+ return null;
+ }
+ }
+
+ /** Immutable snapshot of the current display tone-mapping settings, read every display dispatch. */
+ public record Settings(
+ boolean hdrEnabled,
+ int sdrMode,
+ int hdrMode,
+ float paperWhiteNits,
+ float headroom,
+ Parameters sdrParameters,
+ Parameters hdrParameters) {
+ }
+
+ /** Eight mode-dependent scalars mirrored by the display shader's push-constant parameter blocks. */
+ public record Parameters(
+ float param0,
+ float param1,
+ float param2,
+ float param3,
+ float param4,
+ float param5,
+ float param6,
+ float param7) {
+ public static final Parameters NONE =
+ new Parameters(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
+ }
+
+ /** Immutable canonical SDR mode names in enum order, for the Video Settings selection slider. */
+ public static List sdrConfigNames() {
+ return SDR_CONFIG_NAMES;
+ }
+
+ /** Immutable canonical HDR mode names in enum order, for the Video Settings selection slider. */
+ public static List hdrConfigNames() {
+ return HDR_CONFIG_NAMES;
+ }
+
+ /** Read the current sanitized config values, reusing the immutable snapshot while unchanged. */
+ public static Settings current() {
+ SdrMode sdrMode = SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get());
+ HdrMode hdrMode = HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get());
+ boolean hdrEnabled = CausticaConfig.Rt.Hdr.enabled();
+ float paperWhiteNits = CausticaConfig.Rt.Hdr.paperWhiteNits();
+ float headroom = CausticaConfig.Rt.Hdr.headroom();
+ Settings cached = cachedSettings;
+ if (cached != null
+ && cached.hdrEnabled() == hdrEnabled
+ && cached.sdrMode() == sdrMode.id()
+ && cached.hdrMode() == hdrMode.id()
+ && same(cached.paperWhiteNits(), paperWhiteNits)
+ && same(cached.headroom(), headroom)
+ && matchesSdrParameters(cached.sdrParameters(), sdrMode)
+ && matchesHdrParameters(cached.hdrParameters(), hdrMode)) {
+ return cached;
+ }
+
+ Settings fresh = new Settings(
+ hdrEnabled,
+ sdrMode.id(),
+ hdrMode.id(),
+ paperWhiteNits,
+ headroom,
+ sdrParameters(sdrMode),
+ hdrParameters(hdrMode));
+ cachedSettings = fresh;
+ return fresh;
+ }
+
+ private static boolean matchesSdrParameters(Parameters parameters, SdrMode mode) {
+ return switch (mode) {
+ case ACES_2_0 -> parameters == Parameters.NONE;
+ case AGX -> same(parameters.param0(), CausticaConfig.Rt.Sdr.AGX_CONTRAST.value())
+ && same(parameters.param1(), CausticaConfig.Rt.Sdr.AGX_SATURATION.value());
+ case PBR_NEUTRAL -> same(parameters.param0(), CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION.value())
+ && same(parameters.param1(), CausticaConfig.Rt.Sdr.PBR_DESATURATION.value());
+ case REINHARD -> same(parameters.param0(), CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT.value());
+ case ACES -> same(parameters.param0(), CausticaConfig.Rt.Sdr.ACES_EXPOSURE.value());
+ case LOTTES -> same(parameters.param0(), CausticaConfig.Rt.Sdr.LOTTES_CONTRAST.value())
+ && same(parameters.param1(), CausticaConfig.Rt.Sdr.LOTTES_SHOULDER.value())
+ && same(parameters.param2(), CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX.value())
+ && same(parameters.param3(), CausticaConfig.Rt.Sdr.LOTTES_MID_IN.value())
+ && same(parameters.param4(), CausticaConfig.Rt.Sdr.LOTTES_MID_OUT.value());
+ case UNCHARTED_2 -> same(parameters.param0(), CausticaConfig.Rt.Sdr.UNCHARTED_A.value())
+ && same(parameters.param1(), CausticaConfig.Rt.Sdr.UNCHARTED_B.value())
+ && same(parameters.param2(), CausticaConfig.Rt.Sdr.UNCHARTED_C.value())
+ && same(parameters.param3(), CausticaConfig.Rt.Sdr.UNCHARTED_D.value())
+ && same(parameters.param4(), CausticaConfig.Rt.Sdr.UNCHARTED_E.value())
+ && same(parameters.param5(), CausticaConfig.Rt.Sdr.UNCHARTED_F.value())
+ && same(parameters.param6(), CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT.value());
+ case GT -> same(parameters.param0(), CausticaConfig.Rt.Sdr.GT_CONTRAST.value())
+ && same(parameters.param1(), CausticaConfig.Rt.Sdr.GT_LINEAR_START.value())
+ && same(parameters.param2(), CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH.value())
+ && same(parameters.param3(), CausticaConfig.Rt.Sdr.GT_BLACK_CURVE.value())
+ && same(parameters.param4(), CausticaConfig.Rt.Sdr.GT_BLACK_LIFT.value());
+ case PSYCHOV24 -> matchesPsychoParameters(parameters,
+ CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY.value());
+ };
+ }
+
+ private static boolean matchesHdrParameters(Parameters parameters, HdrMode mode) {
+ return switch (mode) {
+ case ACES_2_0, BT2390 -> parameters == Parameters.NONE;
+ case PSYCHOV24 -> matchesPsychoParameters(parameters,
+ CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY.value());
+ };
+ }
+
+ private static boolean matchesPsychoParameters(Parameters parameters, float compression,
+ float gamutCompression, float highlights,
+ float shadows, float contrast, float purity) {
+ return same(parameters.param0(), compression)
+ && same(parameters.param1(), gamutCompression)
+ && same(parameters.param2(), highlights)
+ && same(parameters.param3(), shadows)
+ && same(parameters.param4(), contrast)
+ && same(parameters.param5(), purity)
+ && same(parameters.param6(), 0.0f)
+ && same(parameters.param7(), 0.0f);
+ }
+
+ private static boolean same(float left, float right) {
+ return Float.floatToIntBits(left) == Float.floatToIntBits(right);
+ }
+
+ private static Parameters sdrParameters(SdrMode mode) {
+ return switch (mode) {
+ case ACES_2_0 -> Parameters.NONE;
+ case AGX -> new Parameters(
+ CausticaConfig.Rt.Sdr.AGX_CONTRAST.value(),
+ CausticaConfig.Rt.Sdr.AGX_SATURATION.value(),
+ 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
+ case PBR_NEUTRAL -> new Parameters(
+ CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION.value(),
+ CausticaConfig.Rt.Sdr.PBR_DESATURATION.value(),
+ 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
+ case REINHARD -> new Parameters(
+ CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT.value(),
+ 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
+ case ACES -> new Parameters(
+ CausticaConfig.Rt.Sdr.ACES_EXPOSURE.value(),
+ 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
+ case LOTTES -> new Parameters(
+ CausticaConfig.Rt.Sdr.LOTTES_CONTRAST.value(),
+ CausticaConfig.Rt.Sdr.LOTTES_SHOULDER.value(),
+ CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX.value(),
+ CausticaConfig.Rt.Sdr.LOTTES_MID_IN.value(),
+ CausticaConfig.Rt.Sdr.LOTTES_MID_OUT.value(),
+ 0.0f, 0.0f, 0.0f);
+ case UNCHARTED_2 -> new Parameters(
+ CausticaConfig.Rt.Sdr.UNCHARTED_A.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_B.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_C.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_D.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_E.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_F.value(),
+ CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT.value(),
+ 0.0f);
+ case GT -> new Parameters(
+ CausticaConfig.Rt.Sdr.GT_CONTRAST.value(),
+ CausticaConfig.Rt.Sdr.GT_LINEAR_START.value(),
+ CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH.value(),
+ CausticaConfig.Rt.Sdr.GT_BLACK_CURVE.value(),
+ CausticaConfig.Rt.Sdr.GT_BLACK_LIFT.value(),
+ 0.0f, 0.0f, 0.0f);
+ case PSYCHOV24 -> psychoParameters(
+ CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST.value(),
+ CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY.value());
+ };
+ }
+
+ private static Parameters hdrParameters(HdrMode mode) {
+ return switch (mode) {
+ case ACES_2_0, BT2390 -> Parameters.NONE;
+ case PSYCHOV24 -> psychoParameters(
+ CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST.value(),
+ CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY.value());
+ };
+ }
+
+ private static Parameters psychoParameters(
+ float compression,
+ float gamutCompression,
+ float highlights,
+ float shadows,
+ float contrast,
+ float purity) {
+ return new Parameters(
+ compression,
+ gamutCompression,
+ highlights,
+ shadows,
+ contrast,
+ purity,
+ 0.0f,
+ 0.0f);
+ }
+}
diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java
index b89fce85..28e6f250 100644
--- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java
+++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java
@@ -179,9 +179,9 @@ private void submitUpload(RtContext ctx, long requestId, RtLightHierarchy.Data d
cursor = upload.mapped + layout.globalAliasOffset;
writeAliases(cursor, data.globalAliases());
RtLightGrid.Data grid = layout.hasGrid ? data.grid() : null;
+ cursor = upload.mapped + layout.localAliasOffset;
+ writeAliases(cursor, data.localAliases());
if (grid != null) {
- cursor = upload.mapped + layout.localAliasOffset;
- writeAliases(cursor, data.localAliases());
cursor = upload.mapped + layout.cellOffset;
for (int i = 0; i < grid.cellOffsets().length; i++) {
MemoryUtil.memPutInt(cursor, grid.cellOffsets()[i]);
@@ -198,6 +198,9 @@ private void submitUpload(RtContext ctx, long requestId, RtLightHierarchy.Data d
MemoryUtil.memPutFloat(cursor + 12, grid.spanAccept()[i]);
cursor += 16;
}
+ } else {
+ // RIS reads the discarded local chain unconditionally; a zero span keeps that load in-bounds.
+ MemoryUtil.memSet(upload.mapped + layout.spanOffset, 0, 16);
}
upload.flush();
@@ -379,9 +382,9 @@ private static PublishedState empty(long generation) {
long lightAddress() { return address(layout.lightOffset); }
long globalAliasAddress() { return address(layout.globalAliasOffset); }
- long localAliasAddress() { return layout.hasGrid ? address(layout.localAliasOffset) : 0L; }
+ long localAliasAddress() { return lightCount > 0 ? address(layout.localAliasOffset) : 0L; }
long cellAddress() { return layout.hasGrid ? address(layout.cellOffset) : 0L; }
- long spanAddress() { return layout.hasGrid ? address(layout.spanOffset) : 0L; }
+ long spanAddress() { return lightCount > 0 ? address(layout.spanOffset) : 0L; }
private long address(long offset) {
return arena != null ? arena.deviceAddress + offset : 0L;
@@ -408,14 +411,17 @@ static Layout of(RtLightHierarchy.Data data, boolean includeGrid) {
cursor = align16(Math.addExact(cursor, data.lightBytes()));
long globalAliases = cursor;
cursor = align16(Math.addExact(cursor, data.globalAliases().bytes()));
- long localAliases = 0L, cells = 0L, spans = 0L;
+ long localAliases = cursor;
+ cursor = align16(Math.addExact(cursor, data.localAliases().bytes()));
+ long cells = 0L, spans;
if (includeGrid) {
- localAliases = cursor;
- cursor = align16(Math.addExact(cursor, data.localAliases().bytes()));
cells = cursor;
cursor = align16(Math.addExact(cursor, data.grid().cellBytes()));
spans = cursor;
cursor = align16(Math.addExact(cursor, data.grid().spanBytes()));
+ } else {
+ spans = cursor;
+ cursor = align16(Math.addExact(cursor, 16L));
}
return new Layout(lights, globalAliases, localAliases, cells, spans, cursor, includeGrid);
}
diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json
index 4533f086..e77f5316 100644
--- a/src/main/resources/assets/caustica/lang/en_us.json
+++ b/src/main/resources/assets/caustica/lang/en_us.json
@@ -1,4 +1,17 @@
{
+ "key.category.caustica.controls": "Caustica",
+ "key.caustica.ultra_screenshot": "Ultra Screenshot",
+ "caustica.status.ultraScreenshot.cancelled": "Ultra screenshot cancelled",
+ "caustica.status.ultraScreenshot.requiresWorld": "Ultra screenshot requires an active world",
+ "caustica.status.ultraScreenshot.requiresDlssRr": "Ultra screenshot requires ray tracing, DLSS Ray Reconstruction, and Debug View Off",
+ "caustica.status.ultraScreenshot.busy": "Another capture mode is active",
+ "caustica.status.screenshot.busy": "Screenshot capture is already in progress",
+ "caustica.status.ultraScreenshot.started": "Ultra screenshot: DLAA at %s SPP",
+ "caustica.status.ultraScreenshot.invalidated": "Ultra screenshot cancelled because render state changed",
+ "caustica.status.ultraScreenshot.failed": "Ultra screenshot cancelled because rendering failed",
+ "caustica.status.ultraScreenshot.timedOut": "Ultra screenshot cancelled after 30 seconds without a fresh DLSS frame",
+ "caustica.status.ultraScreenshot.resized": "Ultra screenshot cancelled because resolution changed",
+
"caustica.options.rt.header": "Ray Tracing",
"caustica.options.rt.exposureMode": "Exposure",
@@ -8,6 +21,12 @@
"caustica.options.rt.manualEv": "Exposure EV",
"caustica.options.rt.manualEv.tooltip": "Exposure compensation in stops. In Manual, this is the fixed exposure. In Auto, this biases the auto exposure brighter or darker.",
+ "caustica.options.rt.exposureLowPercentile": "Shadow Percentile",
+ "caustica.options.rt.exposureLowPercentile.tooltip": "Ignores darker histogram samples below this percentile.",
+ "caustica.options.rt.exposureHighPercentile": "Highlight Percentile",
+ "caustica.options.rt.exposureHighPercentile.tooltip": "Ignores brighter histogram samples above this percentile.",
+ "caustica.options.rt.preExposure": "Pre-Exposure",
+ "caustica.options.rt.preExposure.tooltip": "Scale ray-traced radiance before the fp16 write and remove the same scale before display mapping.",
"caustica.options.rt.gamma": "Gamma",
"caustica.options.rt.gamma.tooltip": "Post-transform luminance gamma. Values below 1.00 brighten shadows and midtones while preserving black, white, and color ratios.",
@@ -18,6 +37,10 @@
"caustica.options.rt.maxBounces": "Path Bounces",
"caustica.options.rt.maxBounces.tooltip": "Maximum number of secondary path-tracing bounces after the primary hit. Higher captures more indirect light but costs more.",
+ "caustica.options.rt.risCandidates": "RIS Light Candidates",
+ "caustica.options.rt.risCandidates.tooltip": "Emitter candidates tested per diffuse vertex. Higher values reduce torch and glow-light noise but cost more ray-tracing work; zero disables RIS.",
+ "caustica.options.rt.risCandidates.off": "Off",
+
"caustica.options.rt.entities": "Ray-Traced Entities",
"caustica.options.rt.entities.tooltip": "Include entities and block entities in the ray-traced scene.",
@@ -37,7 +60,90 @@
"caustica.options.rt.hdrUiBrightness.tooltip": "Absolute brightness (in nits) assigned to SDR-authored UI on an HDR display.",
"caustica.options.rt.hdrPeak": "HDR Peak Brightness",
- "caustica.options.rt.hdrPeak.tooltip": "Absolute brightness (in nits) highlights roll off toward. Set to your display's peak HDR brightness.",
+ "caustica.options.rt.hdrPeak.tooltip": "Absolute brightness (in nits) highlights roll off toward. Set to your display's peak HDR brightness; the control moves in 50-nit increments.",
+ "caustica.options.rt.toneMappingMenu": "Exposure & Tone Mapping…",
+ "caustica.options.rt.toneMappingMenu.tooltip": "Open exposure, output mapping, and active tone-mapper controls.",
+ "caustica.options.rt.toneMapping.title": "Exposure & Tone Mapping",
+ "caustica.options.rt.toneMapping.resetHint": "Ctrl+Shift+click an option to reset it to default",
+ "caustica.options.rt.toneMapping.section.exposure": "Exposure",
+ "caustica.options.rt.toneMapping.section.sdrOutput": "SDR Output",
+ "caustica.options.rt.toneMapping.section.hdrOutput": "HDR Output",
+ "caustica.options.rt.toneMapping.section.activeMapper": "%s Settings",
+ "caustica.options.rt.sdrToneMapper": "SDR Tone Mapper",
+ "caustica.options.rt.sdrToneMapper.tooltip": "Selects the SDR display transform. ACES 2.0 is the default; PsychoV24 and the analytical operators are opt-in.",
+ "caustica.options.rt.hdrToneMapper": "HDR Tone Mapper",
+ "caustica.options.rt.hdrToneMapper.tooltip": "Selects the HDR10/PQ display transform. ACES 2.0 is the default; PsychoV24 is opt-in and BT.2390 is the standards-based alternative.",
+ "caustica.options.rt.toneMapper.aces2.0": "ACES 2.0",
+ "caustica.options.rt.toneMapper.bt2390": "BT.2390",
+ "caustica.options.rt.toneMapper.agx": "AgX",
+ "caustica.options.rt.toneMapper.pbr-neutral": "PBR Neutral",
+ "caustica.options.rt.toneMapper.reinhard": "Reinhard",
+ "caustica.options.rt.toneMapper.aces": "ACES (Narkowicz Fit)",
+ "caustica.options.rt.toneMapper.lottes": "Lottes",
+ "caustica.options.rt.toneMapper.uncharted2": "Uncharted 2",
+ "caustica.options.rt.toneMapper.gt": "GT / Uchimura",
+ "caustica.options.rt.toneMapper.psychov24": "PsychoV24",
+ "caustica.options.rt.hdrPaperWhite": "HDR Paper White",
+ "caustica.options.rt.hdrPaperWhite.tooltip": "Absolute brightness in nits assigned to scene paper white before HDR headroom.",
+ "caustica.options.rt.agxContrast": "AgX Contrast",
+ "caustica.options.rt.agxContrast.tooltip": "Contrast adjustment after the reference AgX transform.",
+ "caustica.options.rt.agxSaturation": "AgX Saturation",
+ "caustica.options.rt.agxSaturation.tooltip": "Saturation adjustment after the reference AgX transform.",
+ "caustica.options.rt.pbrStartCompression": "Compression Start",
+ "caustica.options.rt.pbrStartCompression.tooltip": "Luminance at which PBR Neutral begins highlight compression.",
+ "caustica.options.rt.pbrDesaturation": "Desaturation",
+ "caustica.options.rt.pbrDesaturation.tooltip": "Desaturates compressed highlights toward neutral.",
+ "caustica.options.rt.reinhardWhitePoint": "Reinhard White Point",
+ "caustica.options.rt.reinhardWhitePoint.tooltip": "Luminance mapped to display white by extended Reinhard.",
+ "caustica.options.rt.acesInputScale": "ACES Input Scale",
+ "caustica.options.rt.acesInputScale.tooltip": "Input scale for the compact ACES fitted operator.",
+ "caustica.options.rt.lottesContrast": "Lottes Contrast",
+ "caustica.options.rt.lottesContrast.tooltip": "Contrast exponent for the Lottes filmic curve.",
+ "caustica.options.rt.lottesShoulder": "Lottes Shoulder",
+ "caustica.options.rt.lottesShoulder.tooltip": "Shoulder exponent for the Lottes filmic curve.",
+ "caustica.options.rt.lottesHdrMax": "Lottes HDR Maximum",
+ "caustica.options.rt.lottesHdrMax.tooltip": "HDR maximum used to normalize the Lottes curve.",
+ "caustica.options.rt.lottesMidIn": "Lottes Mid In",
+ "caustica.options.rt.lottesMidIn.tooltip": "Input midpoint anchor for the Lottes curve.",
+ "caustica.options.rt.lottesMidOut": "Lottes Mid Out",
+ "caustica.options.rt.lottesMidOut.tooltip": "Output midpoint anchor for the Lottes curve.",
+ "caustica.options.rt.unchartedShoulderStrength": "Uncharted Shoulder",
+ "caustica.options.rt.unchartedShoulderStrength.tooltip": "Shoulder strength in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedLinearStrength": "Uncharted Linear Strength",
+ "caustica.options.rt.unchartedLinearStrength.tooltip": "Linear strength in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedLinearAngle": "Uncharted Linear Angle",
+ "caustica.options.rt.unchartedLinearAngle.tooltip": "Linear angle in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedToeStrength": "Uncharted Toe Strength",
+ "caustica.options.rt.unchartedToeStrength.tooltip": "Toe strength in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedToeNumerator": "Uncharted Toe Numerator",
+ "caustica.options.rt.unchartedToeNumerator.tooltip": "Toe numerator in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedToeDenominator": "Uncharted Toe Denominator",
+ "caustica.options.rt.unchartedToeDenominator.tooltip": "Toe denominator in the Uncharted 2 curve.",
+ "caustica.options.rt.unchartedWhitePoint": "Uncharted White Point",
+ "caustica.options.rt.unchartedWhitePoint.tooltip": "White point used to normalize Uncharted 2.",
+ "caustica.options.rt.gtContrast": "GT Contrast",
+ "caustica.options.rt.gtContrast.tooltip": "Contrast parameter for the GT/Uchimura curve.",
+ "caustica.options.rt.gtLinearStart": "GT Linear Start",
+ "caustica.options.rt.gtLinearStart.tooltip": "Start of the linear section in the GT curve.",
+ "caustica.options.rt.gtLinearLength": "GT Linear Length",
+ "caustica.options.rt.gtLinearLength.tooltip": "Length of the linear section in the GT curve.",
+ "caustica.options.rt.gtBlackCurve": "GT Black Curve",
+ "caustica.options.rt.gtBlackCurve.tooltip": "Black toe curvature in the GT curve.",
+ "caustica.options.rt.gtBlackLift": "GT Black Lift",
+ "caustica.options.rt.gtBlackLift.tooltip": "Black-level lift in the GT curve.",
+ "caustica.options.rt.psychov24Compression": "Compression",
+ "caustica.options.rt.psychov24Compression.tooltip": "PsychoV24 display-range compression power.",
+ "caustica.options.rt.psychov24Compression.auto": "Automatic",
+ "caustica.options.rt.psychov24GamutCompression": "Gamut Compression",
+ "caustica.options.rt.psychov24GamutCompression.tooltip": "Compresses out-of-gamut PsychoV24 colors toward the display gamut.",
+ "caustica.options.rt.psychov24Highlights": "Highlights",
+ "caustica.options.rt.psychov24Highlights.tooltip": "Grades PsychoV24 values above the adaptation anchor.",
+ "caustica.options.rt.psychov24Shadows": "Shadows",
+ "caustica.options.rt.psychov24Shadows.tooltip": "Grades PsychoV24 values below the adaptation anchor.",
+ "caustica.options.rt.psychov24Contrast": "Contrast",
+ "caustica.options.rt.psychov24Contrast.tooltip": "PsychoV24 cone-response contrast.",
+ "caustica.options.rt.psychov24Purity": "Color Purity",
+ "caustica.options.rt.psychov24Purity.tooltip": "PsychoV24 adaptive chroma purity.",
"caustica.options.rt.dlssQuality": "DLSS Quality",
"caustica.options.rt.dlssQuality.tooltip": "DLSS Ray Reconstruction quality mode. Lower-quality modes render fewer pixels and upscale more aggressively for higher framerates; higher-quality modes render more pixels for a sharper image. Only affects the image when DLSS Ray Reconstruction is enabled.",
@@ -58,5 +164,42 @@
"caustica.options.rt.debugView.6": "Specular",
"caustica.options.rt.debugView.7": "Specular Motion",
"caustica.options.rt.debugView.8": "Exposure False Color",
- "caustica.options.rt.debugView.9": "Metering Weight"
+ "caustica.options.rt.debugView.9": "Metering Weight",
+ "caustica.options.rt.debugView.10": "Raw Path Trace",
+
+ "caustica.options.rt.sharcMenu.open": "SHaRC Settings...",
+ "caustica.options.rt.sharcMenu.title": "SHaRC Settings",
+ "caustica.options.rt.sharcMenu.runtimeHeader": "Runtime Controls",
+ "caustica.options.rt.sharcMenu.statusHeader": "Status",
+ "caustica.options.rt.sharcMenu.status.runtime": "SHaRC: %s",
+ "caustica.options.rt.sharcMenu.status.memory": "SHaRC memory estimate: %s MiB",
+ "caustica.options.rt.sharcMenu.status.layout": "Compiled layout: directional SH; primary debug is opt-in",
+ "caustica.options.rt.sharcMenu.actionsHeader": "Cache Actions",
+ "caustica.options.rt.sharcMenu.clear": "Clear SHaRC Cache",
+ "caustica.options.rt.sharcMenu.defaults": "Restore Safe Defaults",
+
+ "caustica.options.rt.sharcEnabled": "SHaRC Cache",
+ "caustica.options.rt.sharcEnabled.tooltip": "Use the optional NVIDIA SHaRC 1.8 directional-SH cache for eligible diffuse indirect lighting.",
+ "caustica.options.rt.sharcCacheExponent": "SHaRC Cache Size",
+ "caustica.options.rt.sharcCacheExponent.tooltip": "Number of SHaRC hash-grid entries as a power of two. Larger values consume more GPU memory and clear the cache when changed.",
+ "caustica.options.rt.sharcPrimarySurfaceDebug": "Primary Surface Debug",
+ "caustica.options.rt.sharcPrimarySurfaceDebug.tooltip": "Developer comparison mode that permits SHaRC on the camera-visible terminal surface. Off keeps the primary surface live.",
+ "caustica.options.rt.sharcAntiFirefly": "Anti-Firefly Weighting",
+ "caustica.options.rt.sharcAntiFirefly.tooltip": "Apply confidence-based weighting only to SHaRC cache updates; the live path estimator is unchanged.",
+ "caustica.options.rt.sharcUpdateTileSize": "Update Tile Size",
+ "caustica.options.rt.sharcUpdateTileSize.tooltip": "Pixels covered by one sparse SHaRC update ray. Larger tiles reduce update cost but refresh fewer entries per frame.",
+ "caustica.options.rt.sharcAccumulationFrames": "Accumulation Frames",
+ "caustica.options.rt.sharcAccumulationFrames.tooltip": "Temporal accumulation window used by the SHaRC cache.",
+ "caustica.options.rt.sharcStaleFrames": "Stale Frame Limit",
+ "caustica.options.rt.sharcStaleFrames.tooltip": "Frames without a new sample before SHaRC may evict an entry.",
+ "caustica.options.rt.sharcSceneScale": "Scene Scale",
+ "caustica.options.rt.sharcSceneScale.tooltip": "World scale used when selecting SHaRC spatial hash-grid levels.",
+ "caustica.options.rt.sharcRadianceScale": "Radiance Scale",
+ "caustica.options.rt.sharcRadianceScale.tooltip": "Quantization scale at the directional-radiance encoding boundary.",
+ "caustica.options.rt.sharcRoughnessThreshold": "Roughness Threshold",
+ "caustica.options.rt.sharcRoughnessThreshold.tooltip": "Additional minimum linear roughness for diffuse SHaRC ownership. Zero preserves the mirror cutoff.",
+ "caustica.options.rt.sharcGridLogarithmBase": "Grid Logarithm Base",
+ "caustica.options.rt.sharcGridLogarithmBase.tooltip": "Base used to select the SHaRC hash-grid level from distance.",
+ "caustica.options.rt.sharcGridLevelBias": "Grid Level Bias",
+ "caustica.options.rt.sharcGridLevelBias.tooltip": "Bias applied to the selected SHaRC hash-grid level."
}
diff --git a/src/main/resources/caustica.mixins.json b/src/main/resources/caustica.mixins.json
index f7c6e54e..9765d859 100644
--- a/src/main/resources/caustica.mixins.json
+++ b/src/main/resources/caustica.mixins.json
@@ -10,18 +10,24 @@
"GameRendererMixin",
"GpuDeviceAccessor",
"GlxMixin",
+ "GuiMixin",
"GuiRendererMixin",
+ "KeyboardHandlerMixin",
+ "KeyboardInputMixin",
"LevelRendererMixin",
"LevelExtractorMixin",
"MinecraftMixin",
"MinecraftReloadMixin",
+ "MouseHandlerMixin",
"ModelPartAccessor",
+ "OptionsMixin",
"OptionsSubScreenAccessor",
"ParticleEngineAccessor",
"RenderSetupAccessor",
"RenderTypeAccessor",
"ScreenshotMixin",
"TextureAtlasAccessor",
+ "TextureAtlasMixin",
"ParticleGroupAccessor",
"SpriteContentsAccessor",
"VideoSettingsScreenMixin",
diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java
index c2a664d6..79cd9024 100644
--- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java
+++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java
@@ -3,20 +3,128 @@
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;
final class CausticaConfigTest {
@Test
- void invalidPeakNitsFallsBackToDefault() {
+ void peakNitsUsesThe50NitGrid() {
CausticaConfig.IntSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS;
int previous = setting.value();
try {
- setting.set(2000);
- assertEquals(2000, setting.value());
+ setting.set(1050);
+ assertEquals(1050, setting.value());
- setting.set(900);
- assertEquals(1000, setting.value());
+ setting.set(1055);
+ assertEquals(1050, setting.value());
} finally {
setting.set(previous);
}
}
+
+ @Test
+ void acesUsesTheNearestPackagedPeak() {
+ assertEquals(500, CausticaConfig.Rt.Hdr.nearestAcesLutNits(500));
+ assertEquals(500, CausticaConfig.Rt.Hdr.nearestAcesLutNits(750));
+ assertEquals(1000, CausticaConfig.Rt.Hdr.nearestAcesLutNits(900));
+ assertEquals(4000, CausticaConfig.Rt.Hdr.nearestAcesLutNits(5000));
+ }
+
+ @Test
+ void registersToneMappingSettingsForConfigRoundTrips() {
+ CausticaConfig.ensureRegistered();
+ assertTrue(hasSetting("caustica.rt.sdr.toneMapper"));
+ assertTrue(hasSetting("caustica.rt.hdr.toneMapper"));
+ }
+
+ @Test
+ void analyticalToneControlsRejectNonfiniteValues() {
+ var sdrContrast = CausticaConfig.Rt.Sdr.AGX_CONTRAST;
+ var hdrPaperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS;
+ float previousSdrContrast = sdrContrast.value();
+ float previousHdrPaperWhite = hdrPaperWhite.value();
+ try {
+ sdrContrast.set(Float.NaN);
+ hdrPaperWhite.set(Float.POSITIVE_INFINITY);
+ assertEquals(sdrContrast.defaultValue(), sdrContrast.value());
+ assertEquals(hdrPaperWhite.defaultValue(), hdrPaperWhite.value());
+ } finally {
+ sdrContrast.set(previousSdrContrast);
+ hdrPaperWhite.set(previousHdrPaperWhite);
+ }
+ }
+
+ @Test
+ void risCandidatesPreserveTheUpstreamDefaultAndClampAllRuntimeInputs() {
+ CausticaConfig.IntSetting setting = CausticaConfig.Rt.Lights.RIS_CANDIDATES;
+ int previous = setting.value();
+ try {
+ assertEquals(8, setting.defaultValue());
+ setting.set(-1);
+ assertEquals(0, setting.value());
+ setting.set(64);
+ assertEquals(32, setting.value());
+ } finally {
+ setting.set(previous);
+ }
+ }
+
+ @Test
+ void samplingDefaultsMatchTheRendererProfile() {
+ assertEquals(8, CausticaConfig.Rt.Lights.RIS_CANDIDATES.defaultValue());
+ assertEquals(4, CausticaConfig.Rt.Composite.MAX_BOUNCES.defaultValue());
+ }
+
+ @Test
+ void registersSamplingSettingsForConfigRoundTrips() {
+ CausticaConfig.ensureRegistered();
+ assertTrue(hasSetting("caustica.rt.risCandidates"));
+ }
+
+ @Test
+ void dlssPresetDefaultsToSdkSelection() {
+ assertEquals(0, CausticaConfig.Rt.DlssRr.PRESET.defaultValue());
+ }
+
+ @Test
+ void registersSharcSettingsForConfigRoundTrips() {
+ CausticaConfig.ensureRegistered();
+ assertTrue(hasSetting("caustica.rt.sharc.enabled"));
+ }
+
+ @Test
+ void sharcDefaultsMatchTheValidatedRuntimeProfile() {
+ assertTrue(CausticaConfig.Rt.Sharc.ENABLED.defaultValue());
+ assertEquals(22, CausticaConfig.Rt.Sharc.CACHE_EXPONENT.defaultValue());
+ assertTrue(CausticaConfig.Rt.Sharc.ANTI_FIREFLY.defaultValue());
+ assertFalse(CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.defaultValue());
+ assertEquals(3, CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.defaultValue());
+ assertEquals(384, CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.defaultValue());
+ assertEquals(128, CausticaConfig.Rt.Sharc.STALE_FRAMES.defaultValue());
+ assertEquals(32.0f, CausticaConfig.Rt.Sharc.SCENE_SCALE.defaultValue());
+ assertEquals(1000.0f, CausticaConfig.Rt.Sharc.RADIANCE_SCALE.defaultValue());
+ assertEquals(3.0f, CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.defaultValue());
+ assertEquals(0.0f, CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.defaultValue());
+ assertEquals(0.0f, CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.defaultValue());
+ }
+
+ @Test
+ void paperWhiteCannotExceedTheSelectedPeak() {
+ var paperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS;
+ var peak = CausticaConfig.Rt.Hdr.PEAK_NITS;
+ float previousPaperWhite = paperWhite.value();
+ int previousPeak = peak.value();
+ try {
+ paperWhite.set(200.0f);
+ peak.set(50);
+ assertEquals(50.0f, CausticaConfig.Rt.Hdr.paperWhiteNits());
+ assertEquals(1.0f, CausticaConfig.Rt.Hdr.headroom());
+ } finally {
+ paperWhite.set(previousPaperWhite);
+ peak.set(previousPeak);
+ }
+ }
+ private static boolean hasSetting(String key) {
+ return CausticaConfig.settings().stream().anyMatch(setting -> setting.key().equals(key));
+ }
}
diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java
new file mode 100644
index 00000000..4c659d62
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java
@@ -0,0 +1,34 @@
+package dev.comfyfluffy.caustica.client;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+final class CaptureProgressTest {
+ @Test
+ void completesOnlyAfterTheDerivedFreshFrameCount() {
+ CaptureProgress progress = new CaptureProgress();
+ for (int i = 1; i < 32; i++) {
+ assertEquals(CaptureProgress.Result.WAITING, progress.acceptFreshFrame(32, 3840, 2160));
+ }
+ assertEquals(CaptureProgress.Result.COMPLETE, progress.acceptFreshFrame(32, 3840, 2160));
+ assertEquals(32, progress.freshFrames());
+ assertEquals(32, progress.targetFrames());
+ assertEquals(CaptureProgress.Result.WAITING, progress.acceptFreshFrame(32, 3840, 2160));
+ assertEquals(32, progress.freshFrames());
+ }
+
+ @Test
+ void rejectsPhaseOrResolutionChangesInsteadOfMixingFrames() {
+ CaptureProgress progress = new CaptureProgress();
+ progress.acceptFreshFrame(32, 3840, 2160);
+ assertEquals(CaptureProgress.Result.DIMENSIONS_CHANGED,
+ progress.acceptFreshFrame(33, 3840, 2160));
+ progress.reset();
+ progress.acceptFreshFrame(32, 3840, 2160);
+ assertEquals(CaptureProgress.Result.DIMENSIONS_CHANGED,
+ progress.acceptFreshFrame(32, 2560, 1440));
+ assertEquals(CaptureProgress.Result.INVALID_PHASE,
+ new CaptureProgress().acceptFreshFrame(0, 3840, 2160));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java
new file mode 100644
index 00000000..abc2cc28
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java
@@ -0,0 +1,95 @@
+package dev.comfyfluffy.caustica.client;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class CaptureSessionPolicyTest {
+ @AfterEach
+ void clearScreenshotLease() {
+ CaptureSession.discardScreenshotsForShutdown();
+ }
+
+ @Test
+ void pausesOnlyAnUnsharedIntegratedServer() {
+ assertTrue(CaptureSession.shouldPauseIntegratedServer(true, false));
+ assertFalse(CaptureSession.shouldPauseIntegratedServer(true, true));
+ assertFalse(CaptureSession.shouldPauseIntegratedServer(false, false));
+ }
+
+ @Test
+ void screenshotLeaseBlocksOverlappingWritesUntilCompletion() {
+ long f4 = CaptureSession.acquireScreenshot(true);
+ assertNotEquals(0L, f4);
+ assertTrue(CaptureSession.screenshotIsUltra(f4));
+ assertTrue(CaptureSession.acquireScreenshot(false) == 0L);
+
+ CaptureSession.releaseScreenshot(f4);
+ assertFalse(CaptureSession.screenshotIsUltra(f4));
+ }
+
+ @Test
+ void lateScreenshotCallbackCannotReleaseAReplacement() {
+ long old = CaptureSession.acquireScreenshot(true);
+ CaptureSession.discardScreenshotsForShutdown();
+ long current = CaptureSession.acquireScreenshot(true);
+
+ CaptureSession.releaseScreenshot(old);
+ assertNotEquals(0L, current);
+ assertTrue(CaptureSession.screenshotIsUltra(current));
+ }
+
+ @Test
+ void captureFailureRetriesTheConfiguredRendererAfterRestore() {
+ assertFalse(UltraScreenshot.shouldRecoverRenderer(false, false));
+ assertTrue(UltraScreenshot.shouldRecoverRenderer(true, false));
+ assertTrue(UltraScreenshot.shouldRecoverRenderer(false, true));
+ assertTrue(UltraScreenshot.shouldRecoverRenderer(true, true));
+
+ List steps = new ArrayList<>();
+ int configuredQuality = 2;
+ AtomicInteger effectiveQuality = new AtomicInteger(UltraScreenshot.DLAA_QUALITY);
+ Throwable failure = UltraScreenshot.restoreRendererState(
+ true,
+ () -> {
+ steps.add("end-capture");
+ effectiveQuality.set(configuredQuality);
+ },
+ () -> {
+ steps.add("retry-renderer");
+ assertEquals(configuredQuality, effectiveQuality.get());
+ },
+ () -> steps.add("reset-temporal"));
+
+ assertNull(failure);
+ assertEquals(List.of("end-capture", "retry-renderer", "reset-temporal"), steps);
+ }
+
+ @Test
+ void captureRecoveryFailureRemainsReportedAndStillResetsTemporalState() {
+ List steps = new ArrayList<>();
+ IllegalStateException releaseFailure = new IllegalStateException("native feature retained");
+
+ Throwable failure = UltraScreenshot.restoreRendererState(
+ true,
+ () -> steps.add("end-capture"),
+ () -> {
+ steps.add("retry-renderer");
+ throw releaseFailure;
+ },
+ () -> steps.add("reset-temporal"));
+
+ assertSame(releaseFailure, failure);
+ assertEquals(List.of("end-capture", "retry-renderer", "reset-temporal"), steps);
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java
new file mode 100644
index 00000000..d2fdbfe5
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java
@@ -0,0 +1,16 @@
+package dev.comfyfluffy.caustica.client;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class CausticaClientTest {
+ @Test
+ void nativeOwnershipMustFullyReleaseBeforeDeviceDestruction() {
+ assertFalse(CausticaClient.teardownRequiresRestart(true, true));
+ assertTrue(CausticaClient.teardownRequiresRestart(false, true));
+ assertTrue(CausticaClient.teardownRequiresRestart(true, false));
+ assertTrue(CausticaClient.teardownRequiresRestart(false, false));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java
new file mode 100644
index 00000000..e96850b3
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java
@@ -0,0 +1,32 @@
+package dev.comfyfluffy.caustica.client;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+final class CausticaJitterTest {
+ @Test
+ void dlaaUsesTheActualNativeResolutionAndThirtyTwoPhases() {
+ assertEquals(32, CausticaJitter.jitterPhaseCount(3840, 2160, 3840, 2160));
+ }
+
+ @Test
+ void phaseCountUsesTheLargestActualAxisRatio() {
+ assertEquals(72, CausticaJitter.jitterPhaseCount(1280, 720, 3840, 1080));
+ assertEquals(72, CausticaJitter.jitterPhaseCount(1920, 360, 3840, 1080));
+ }
+
+ @Test
+ void resetRestartsTheSequence() {
+ CausticaJitter jitter = CausticaJitter.INSTANCE;
+ jitter.reset();
+ jitter.prepare(1920, 1080, 1920, 1080);
+ float firstX = jitter.jitterPixelsX();
+ float firstY = jitter.jitterPixelsY();
+ jitter.prepare(1920, 1080, 1920, 1080);
+ jitter.reset();
+ jitter.prepare(1920, 1080, 1920, 1080);
+ assertEquals(firstX, jitter.jitterPixelsX());
+ assertEquals(firstY, jitter.jitterPixelsY());
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java b/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java
new file mode 100644
index 00000000..99ee12a2
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java
@@ -0,0 +1,20 @@
+package dev.comfyfluffy.caustica.mixin;
+
+import org.junit.jupiter.api.Test;
+import org.lwjgl.glfw.GLFW;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class KeyboardHandlerMixinTest {
+ @Test
+ void captureAllowsReleasesAndOnlyTheInitialUltraTogglePress() {
+ assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(false, GLFW.GLFW_PRESS, false));
+ assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_RELEASE, false));
+ assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_PRESS, true));
+
+ assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_PRESS, false));
+ assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_REPEAT, false));
+ assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_REPEAT, true));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java
index 1651f70f..a2b955eb 100644
--- a/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java
@@ -9,7 +9,7 @@ final class RtHdrTest {
private static final float EPSILON = 0.000001f;
@Test
- void buildsRec2020D65MetadataAtTheSelectedAcesMasteringPeak() {
+ void buildsRec2020D65MetadataAtTheSelectedMasteringPeak() {
RtHdr.MasteringMetadata metadata = RtHdr.masteringMetadata(1000);
assertChromaticity(metadata.red(), 0.708f, 0.292f);
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java
new file mode 100644
index 00000000..f9f82464
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java
@@ -0,0 +1,36 @@
+package dev.comfyfluffy.caustica.rt;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class RtSharcSkyResetTest {
+ @Test
+ void ordinaryMotionAndAngleWrapDoNotReset() {
+ RtComposite.SharcSkyState before = state(0.02f, 0.03f, 0.04f, 0.5f, 2);
+ assertFalse(RtComposite.hardSkyDiscontinuity(before, state(0.021f, 0.031f, 0.041f, 0.501f, 2)));
+
+ float fullTurn = (float) (Math.PI * 2.0);
+ assertFalse(RtComposite.hardSkyDiscontinuity(
+ state(fullTurn - 0.01f, 0.0f, 0.0f, 0.5f, 2),
+ state(0.01f, 0.0f, 0.0f, 0.5f, 2)));
+ }
+
+ @Test
+ void hardCelestialAndLightingChangesReset() {
+ RtComposite.SharcSkyState before = state(0.0f, 0.0f, 0.0f, 0.5f, 2);
+ assertTrue(RtComposite.hardSkyDiscontinuity(before,
+ state(RtComposite.SHARC_SKY_ANGLE_JUMP_RADIANS + 0.01f, 0.0f, 0.0f, 0.5f, 2)));
+ assertTrue(RtComposite.hardSkyDiscontinuity(before, state(0.0f, 0.0f, 0.0f, 0.5f, 3)));
+ assertTrue(RtComposite.hardSkyDiscontinuity(before,
+ new RtComposite.SharcSkyState(0, 1, 0.0f, 0.0f, 0.0f, 0.5f, 2,
+ 0.2f, 0.3f, 0.4f)));
+ }
+
+ private static RtComposite.SharcSkyState state(
+ float sun, float moon, float stars, float brightness, int moonPhase) {
+ return new RtComposite.SharcSkyState(0, 0, sun, moon, stars, brightness, moonPhase,
+ 0.2f, 0.3f, 0.4f);
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java
new file mode 100644
index 00000000..2cb1803b
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java
@@ -0,0 +1,19 @@
+package dev.comfyfluffy.caustica.rt;
+
+import dev.comfyfluffy.caustica.rt.gen.SharcFrameData;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+final class RtSharcTest {
+ @Test
+ void clampsTheTableExponentAndAccountsForTheFrameRing() {
+ assertEquals(64L * 65536L, RtSharcCache.tableBytesForExponent(16));
+ assertEquals(RtSharcCache.tableBytesForExponent(16)
+ + (long) RtSharcCache.RING * SharcFrameData.BYTE_SIZE,
+ RtSharcCache.memoryBytesForExponent(16));
+ assertEquals(RtSharcCache.MIN_EXPONENT, RtSharcCache.clampExponent(1));
+ assertEquals(RtSharcCache.MAX_EXPONENT, RtSharcCache.clampExponent(99));
+ assertEquals(20, RtSharcCache.clampExponent(20));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java
new file mode 100644
index 00000000..fc1ecaf5
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java
@@ -0,0 +1,40 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+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;
+
+final class RtDisplayShaderContractTest {
+ private static final Path DISPLAY_SHADER = Path.of(System.getProperty("user.dir"),
+ "shaders", "pipelines", "display", "main.comp.slang");
+
+ @Test
+ void acesAndAnalyticalModesUseTheirOwnedSceneSignals() throws IOException {
+ String source = Files.readString(DISPLAY_SHADER).replaceAll("\\s+", " ");
+
+ assertTrue(source.contains("float3 exposedAcesCg = sceneLinearAcesCg * exposure;"));
+ assertTrue(source.contains("exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0);"));
+ assertTrue(source.contains("if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { "
+ + "lookedAcesCg = applyLook(exposedAcesCg); }"));
+ assertTrue(source.contains("? tonemap(lookedAcesCg) : localSdrToneMap(exposedAcesCg);"));
+ assertTrue(source.contains("? float4(tonemapHdr(lookedAcesCg), 1.0) : float4(displayGammaHdr(localHdrToneMap(exposedAcesCg)), 1.0);"));
+ assertFalse(source.contains("localSdrToneMap(lookedAcesCg)"));
+ assertFalse(source.contains("localHdrToneMap(lookedAcesCg)"));
+ assertEquals(1, occurrences(source, "applyLook(exposedAcesCg)"));
+ }
+
+ private static int occurrences(String text, String needle) {
+ int count = 0;
+ int offset = 0;
+ while ((offset = text.indexOf(needle, offset)) >= 0) {
+ count++;
+ offset += needle.length();
+ }
+ return count;
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java
new file mode 100644
index 00000000..cb59dfc3
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java
@@ -0,0 +1,16 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+final class RtDlssRrTest {
+ @Test
+ void recommendedMipMapBiasUsesNvidiaResolutionFormula() {
+ assertEquals(-1.0f, RtDlssRr.recommendedMipMapBias(1920, 1920), 1.0e-6f);
+ assertEquals(-2.0f, RtDlssRr.recommendedMipMapBias(960, 1920), 1.0e-6f);
+ assertEquals(-1.5849625f, RtDlssRr.recommendedMipMapBias(1280, 1920), 1.0e-5f);
+ assertEquals(0.0f, RtDlssRr.recommendedMipMapBias(0, 1920), 0.0f);
+ assertEquals(0.0f, RtDlssRr.recommendedMipMapBias(1920, 0), 0.0f);
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java
new file mode 100644
index 00000000..c54dae55
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java
@@ -0,0 +1,31 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.comfyfluffy.caustica.rt.RtSceneUnits;
+import org.junit.jupiter.api.Test;
+
+final class RtExposureEv100Test {
+ @Test
+ void ev100OffsetRemovesTheLatchedPreExposureScale() {
+ assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(1.0f), 1.0e-6f);
+ assertEquals(RtSceneUnits.EV100_OFFSET - 2.0f, RtExposure.ev100Offset(4.0f), 1.0e-6f);
+ }
+
+ @Test
+ void invalidPreExposureFallsBackToTheNeutralScale() {
+ assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(0.0f), 1.0e-6f);
+ assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(-1.0f), 1.0e-6f);
+ assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(Float.NaN), 1.0e-6f);
+ }
+
+ @Test
+ void onlyEnteringAutoInvalidatesHistory() {
+ assertTrue(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.MANUAL, RtExposure.Mode.AUTO));
+ assertFalse(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.AUTO, RtExposure.Mode.MANUAL));
+ assertFalse(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.AUTO, RtExposure.Mode.AUTO));
+ assertFalse(RtExposure.modeTransitionRequiresReset(null, RtExposure.Mode.AUTO));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java
new file mode 100644
index 00000000..f8f0d635
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java
@@ -0,0 +1,71 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.comfyfluffy.caustica.CausticaConfig;
+import org.junit.jupiter.api.Test;
+
+final class RtExposurePercentileTest {
+ @Test
+ void defaultsAndConfigValuesStayInTheUnitInterval() {
+ var window = RtExposure.PercentileWindow.sanitize(0.50f, 0.95f);
+ assertEquals(0.50f, window.low(), 1.0e-6f);
+ assertEquals(0.95f, window.high(), 1.0e-6f);
+
+ var low = CausticaConfig.Rt.Exposure.LOW_PERCENTILE;
+ var high = CausticaConfig.Rt.Exposure.HIGH_PERCENTILE;
+ float previousLow = low.value();
+ float previousHigh = high.value();
+ try {
+ low.set(-0.5f);
+ high.set(2.0f);
+ assertEquals(0.0f, low.value(), 1.0e-6f);
+ assertEquals(1.0f, high.value(), 1.0e-6f);
+ low.set(Float.NaN);
+ high.set(Float.POSITIVE_INFINITY);
+ assertEquals(0.50f, low.value(), 1.0e-6f);
+ assertEquals(0.95f, high.value(), 1.0e-6f);
+ } finally {
+ low.set(previousLow);
+ high.set(previousHigh);
+ }
+ }
+
+ @Test
+ void reversedAndEqualWindowsAreNormalizedToAUsableRange() {
+ var reversed = RtExposure.PercentileWindow.sanitize(0.90f, 0.10f);
+ assertEquals(0.10f, reversed.low(), 1.0e-6f);
+ assertEquals(0.90f, reversed.high(), 1.0e-6f);
+
+ var equalLow = RtExposure.PercentileWindow.sanitize(0.0f, 0.0f);
+ assertEquals(0.0f, equalLow.low());
+ assertTrue(equalLow.high() > equalLow.low());
+
+ var equalHigh = RtExposure.PercentileWindow.sanitize(1.0f, 1.0f);
+ assertTrue(equalHigh.low() < equalHigh.high());
+ assertEquals(1.0f, equalHigh.high());
+ }
+
+ @Test
+ void nonfiniteWindowValuesUseTheDocumentedDefaults() {
+ var window = RtExposure.PercentileWindow.sanitize(Float.NaN, Float.NEGATIVE_INFINITY);
+ assertEquals(0.50f, window.low(), 1.0e-6f);
+ assertEquals(0.95f, window.high(), 1.0e-6f);
+ }
+
+ @Test
+ void histogramDispatchGuardsStrideAndSmallOrOverflowingExtents() {
+ assertEquals(1, RtExposurePipeline.safeStride(0));
+ assertEquals(1, RtExposurePipeline.safeStride(-4));
+ assertEquals(1, RtExposurePipeline.sampledExtent(0, 0));
+ assertEquals(2, RtExposurePipeline.sampledExtent(16, 8));
+ assertEquals(3, RtExposurePipeline.sampledExtent(17, 8));
+ assertEquals(1, RtExposurePipeline.dispatchGroups(0, 0));
+ assertEquals(2, RtExposurePipeline.dispatchGroups(256, 8));
+ assertEquals(1, RtExposurePipeline.dispatchGroups(Integer.MAX_VALUE, Integer.MAX_VALUE));
+ assertEquals(1, RtExposurePipeline.effectiveStride(3840, 2160, 1));
+ assertEquals(2, RtExposurePipeline.effectiveStride(7680, 4320, 1));
+ assertEquals(2, RtExposurePipeline.effectiveStride(7680, 4320, 2));
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java
new file mode 100644
index 00000000..7b79750f
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java
@@ -0,0 +1,105 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+final class RtPathSamplingTest {
+ private static final int[][] DIRECTIONS = RtSobolDirectionNumbers.createDirections();
+
+ @Test
+ void grayCodeSobolValuesMatchReference() {
+ int[][] expected = {
+ {0x00000000, 0x80000000, 0xc0000000, 0x40000000,
+ 0x60000000, 0xe0000000, 0xa0000000, 0x20000000},
+ {0x00000000, 0x80000000, 0x40000000, 0xc0000000,
+ 0x60000000, 0xe0000000, 0x20000000, 0xa0000000},
+ {0x00000000, 0x80000000, 0x40000000, 0xc0000000,
+ 0xa0000000, 0x20000000, 0xe0000000, 0x60000000},
+ {0x00000000, 0x80000000, 0x40000000, 0xc0000000,
+ 0xe0000000, 0x60000000, 0xa0000000, 0x20000000}
+ };
+ for (int dimension = 0; dimension < expected.length; dimension++) {
+ for (int sample = 0; sample < expected[dimension].length; sample++) {
+ assertEquals(expected[dimension][sample], directSobolBits(sample, dimension));
+ }
+ }
+ }
+
+ @Test
+ void resourceLayoutIsDeterministicAndCompact() {
+ int[] roots = roots(0x51a7cafeL);
+ int[] first = RtPathSamplerData.buildResourceWords(roots);
+ int[] second = RtPathSamplerData.buildResourceWords(roots);
+ assertArrayEquals(first, second);
+ assertEquals(0, RtPathSamplerData.DIRECTION_TABLE_OFFSET);
+ assertEquals(RtPathSamplerData.ROOT_TABLE_OFFSET + RtPathSamplerData.ROOT_WORD_COUNT,
+ RtPathSamplerData.WORD_COUNT);
+ assertArrayEquals(roots, Arrays.copyOfRange(first,
+ RtPathSamplerData.ROOT_TABLE_OFFSET, RtPathSamplerData.WORD_COUNT));
+ }
+
+ @Test
+ void resourceLookupMatchesDirectSobolEvaluation() {
+ int[] words = RtPathSamplerData.buildResourceWords(roots(0x51a7cafeL));
+ Random random = new Random(0x5e0e1ceL);
+ for (int iteration = 0; iteration < 2048; iteration++) {
+ int sample = random.nextInt();
+ int dimension = random.nextInt(DIRECTIONS.length);
+ assertEquals(directSobolBits(sample, dimension), resourceSobolBits(words, sample, dimension));
+ }
+ }
+
+ @Test
+ void invalidResourcesAndAddressesFailClosed() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RtPathSamplerData.buildResourceWords(new int[1]));
+ assertThrows(IllegalArgumentException.class,
+ () -> RtPathSamplerData.buildResourceWords(null));
+ assertThrows(IllegalStateException.class,
+ () -> RtPathSamplerData.requireUsableBuffer(0L, 1L));
+ assertThrows(IllegalStateException.class,
+ () -> RtPathSamplerData.requireUsableBuffer(1L, 0L));
+ }
+
+ private static int directSobolBits(int sampleIndex, int dimension) {
+ int gray = sampleIndex ^ (sampleIndex >>> 1);
+ int value = 0;
+ for (int bit = 0; bit < Integer.SIZE; bit++) {
+ if ((gray & (1 << bit)) != 0) {
+ value ^= DIRECTIONS[dimension][bit];
+ }
+ }
+ return value;
+ }
+
+ private static int resourceSobolBits(int[] words, int sampleIndex, int dimension) {
+ int gray = sampleIndex ^ (sampleIndex >>> 1);
+ if (dimension == 0) {
+ return Integer.reverse(gray);
+ }
+ int base = RtPathSamplerData.DIRECTION_TABLE_OFFSET
+ + (dimension - RtPathSamplerData.FIRST_TABLE_DIMENSION)
+ * RtPathSamplerData.WORDS_PER_DIMENSION;
+ int value = 0;
+ for (int block = 0; block < RtPathSamplerData.NIBBLE_BLOCKS; block++) {
+ value ^= words[base + block * RtPathSamplerData.NIBBLE_VALUES
+ + ((gray >>> (block * 4)) & 15)];
+ }
+ return value;
+ }
+
+ private static int[] roots(long seed) {
+ Random random = new Random(seed);
+ int[] roots = new int[RtPathSamplerData.ROOT_WORD_COUNT];
+ for (int index = 0; index < roots.length; index++) {
+ roots[index] = random.nextInt();
+ }
+ return roots;
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java
new file mode 100644
index 00000000..942b372d
--- /dev/null
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java
@@ -0,0 +1,109 @@
+package dev.comfyfluffy.caustica.rt.pipeline;
+
+import dev.comfyfluffy.caustica.CausticaConfig;
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+final class RtToneMappingTest {
+ @Test
+ void defaultConfigUsesAces20ForSdrAndHdr() {
+ assertEquals(RtToneMapping.SdrMode.ACES_2_0, RtToneMapping.SdrMode.parse(null));
+ assertEquals(RtToneMapping.SdrMode.ACES_2_0, RtToneMapping.SdrMode.parse("unknown"));
+ assertEquals(RtToneMapping.HdrMode.ACES_2_0, RtToneMapping.HdrMode.parse(null));
+ assertEquals(RtToneMapping.HdrMode.ACES_2_0, RtToneMapping.HdrMode.parse("unknown"));
+ assertEquals("aces2.0", CausticaConfig.Rt.Sdr.TONE_MAPPER.defaultValue());
+ assertEquals("aces2.0", CausticaConfig.Rt.Hdr.TONE_MAPPER.defaultValue());
+ assertEquals(1.0f, CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.defaultValue());
+ assertEquals(1.0f, CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.defaultValue());
+ assertEquals(0.0f, CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.defaultValue());
+ assertEquals(0.50f, CausticaConfig.Rt.Exposure.LOW_PERCENTILE.defaultValue());
+ assertEquals(0.95f, CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.defaultValue());
+ }
+
+ @Test
+ void psychov24AndCompatibilityAliasesSelectTheSameMode() {
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychov24"));
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychovisual"));
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psycho-visual"));
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychov"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psychovisual"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psycho-visual"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psychov"));
+ assertEquals(RtToneMapping.HdrMode.BT2390,
+ RtToneMapping.HdrMode.parse(" bt.2390 "));
+ }
+
+ @Test
+ void legacyPsychoNamesRemainCompatibilityAliases() {
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychov11"));
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychov23"));
+ assertEquals(RtToneMapping.SdrMode.PSYCHOV24,
+ RtToneMapping.SdrMode.parse("psychov24-experimental"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psychov11"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psychov23"));
+ assertEquals(RtToneMapping.HdrMode.PSYCHOV24,
+ RtToneMapping.HdrMode.parse("psychov24-experimental"));
+ }
+
+ @Test
+ void modeIdsAreUniqueAndStable() {
+ assertEquals(0, RtToneMapping.SdrMode.ACES_2_0.id());
+ assertEquals(8, RtToneMapping.SdrMode.PSYCHOV24.id());
+ assertEquals(0, RtToneMapping.HdrMode.ACES_2_0.id());
+ assertEquals(3, RtToneMapping.HdrMode.BT2390.id());
+ assertEquals(2, RtToneMapping.HdrMode.PSYCHOV24.id());
+ assertFalse(RtToneMapping.hdrConfigNames().contains("caustica"));
+ }
+
+ @Test
+ void psychov24LeavesUnusedPushConstantsZero() {
+ String previousSdr = CausticaConfig.Rt.Sdr.TONE_MAPPER.get();
+ String previousHdr = CausticaConfig.Rt.Hdr.TONE_MAPPER.get();
+ try {
+ CausticaConfig.Rt.Sdr.TONE_MAPPER.set("psychov24");
+ CausticaConfig.Rt.Hdr.TONE_MAPPER.set("psychov24");
+ RtToneMapping.Settings settings = RtToneMapping.current();
+ assertEquals(0.0f, settings.sdrParameters().param6());
+ assertEquals(0.0f, settings.sdrParameters().param7());
+ assertEquals(0.0f, settings.hdrParameters().param6());
+ assertEquals(0.0f, settings.hdrParameters().param7());
+ } finally {
+ CausticaConfig.Rt.Sdr.TONE_MAPPER.set(previousSdr);
+ CausticaConfig.Rt.Hdr.TONE_MAPPER.set(previousHdr);
+ RtToneMapping.current();
+ }
+ }
+
+ @Test
+ void unchangedSnapshotIsReusedAndModeChangesInvalidateIt() {
+ String previous = CausticaConfig.Rt.Sdr.TONE_MAPPER.get();
+ try {
+ CausticaConfig.Rt.Sdr.TONE_MAPPER.set("aces2.0");
+ RtToneMapping.Settings first = RtToneMapping.current();
+ assertSame(first, RtToneMapping.current());
+
+ CausticaConfig.Rt.Sdr.TONE_MAPPER.set("agx");
+ RtToneMapping.Settings changed = RtToneMapping.current();
+ assertNotSame(first, changed);
+ assertSame(changed, RtToneMapping.current());
+ } finally {
+ CausticaConfig.Rt.Sdr.TONE_MAPPER.set(previous);
+ RtToneMapping.current();
+ }
+ }
+}