diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd78a5b..983a2e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: paths: - ".github/workflows/**" - "build.gradle" + - "buildSrc/**" - "settings.gradle" - "gradle.properties" - "gradle/**" @@ -15,10 +16,12 @@ on: - "native/**" - "shaders/**" - "src/**" + - "THIRD_PARTY_NOTICES.md" pull_request: paths: - ".github/workflows/**" - "build.gradle" + - "buildSrc/**" - "settings.gradle" - "gradle.properties" - "gradle/**" @@ -27,10 +30,13 @@ on: - "native/**" - "shaders/**" - "src/**" + - "THIRD_PARTY_NOTICES.md" env: - DLSS_SDK_REF: v310.7.0 + DLSS_SDK_REF: a291cc7d2cc642a51566f3dfd5376f635cd1b284 DLSS_SDK_PATH: third_party/DLSS + SHARC_SDK_REF: e19ccacd511f42a3df6f850052d508c13c9e9737 + SHARC_SDK_PATH: third_party/SHARC NGX_SHIM_CONFIG: release NGX_VENDOR_CONFIG: rel VULKAN_SDK_VERSION: 1.4.350.0 @@ -138,6 +144,13 @@ jobs: ref: ${{ env.DLSS_SDK_REF }} path: ${{ env.DLSS_SDK_PATH }} + - name: Checkout SHARC SDK + uses: actions/checkout@v4 + with: + repository: NVIDIA-RTX/SHARC + ref: ${{ env.SHARC_SDK_REF }} + path: ${{ env.SHARC_SDK_PATH }} + - name: Setup Java uses: actions/setup-java@v4 with: @@ -174,6 +187,8 @@ jobs: - name: Build bundled jar env: DLSS_SDK: ${{ github.workspace }}/${{ env.DLSS_SDK_PATH }} + SHARC_SDK: ${{ github.workspace }}/${{ env.SHARC_SDK_PATH }} + SHARC_LICENSE_ACCEPTED: "true" run: > bash ./gradlew build -PngxPlatforms=windows-x64,linux-x64 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d45012be..259d71fc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,6 +4,70 @@ Caustica's project-owned code is licensed under `LGPL-3.0-or-later`. This file documents third-party components and license boundaries that are not changed by Caustica's license. +## PsychoV24 Test24 adaptation + +The PsychoV24 Test24 tone-mapping adaptation in +`shaders/pipelines/display/psychov24.slang` is derived from RenoDX commit +`fc85b7b15585050442ba35412597ecefc9e04cea`. + +Copyright (C) 2026 Carlos Lopez. SPDX-License-Identifier: MIT. + +The adaptation remains subject to the MIT license. The complete license text is +available at : + +Copyright (c) 2026 Carlos Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## PsychoV30 Test30 reference source + +`shaders/pipelines/display/test30.hlsl` is a verbatim copy of the RenoDX +PsychoV Test30 tone-mapping shader, kept as the pinned reference for the +Caustica Slang adaptation. It is not compiled by Caustica builds, which +compile `*.slang` sources only; its relative include resolves against the +RenoDX tree. + +Copyright (C) 2026 Carlos Lopez. SPDX-License-Identifier: MIT. + +The reference remains subject to the MIT license. The complete license text is +available at : + +Copyright (c) 2026 Carlos Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## NVIDIA DLSS / NGX SDK Caustica can build and distribute release artifacts that include NVIDIA DLSS/NGX @@ -13,7 +77,7 @@ not licensed under the LGPL. The NVIDIA SDK components remain subject to the NVIDIA RTX SDKs license: - + The LGPL license grant for Caustica does not grant rights to NVIDIA SDK components. Redistribution and use of those components must comply with @@ -30,3 +94,34 @@ Bundled NVIDIA SDK runtime libraries may include files matching: Caustica's `ngxshim` native library is project-owned glue code and follows Caustica's project license unless otherwise noted. + +## Joe-Kuo Sobol direction numbers + +`RtSobolDirectionNumbers.java` expands the first four dimensions of the +`new-joe-kuo-6.21201` data set by Frances Y. Kuo and Stephen Joe (2008). + +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 copyright notice, conditions, +and disclaimer are retained. Neither the copyright holders nor the +University of New South Wales or University of Waikato may be used to endorse +derived products without prior written permission. + +The data is provided without warranty; the copyright holders are not liable +for damages arising from its use. The complete notice is retained in the +source file. + +## NVIDIA SHaRC SDK + +Caustica can build release artifacts with NVIDIA SHaRC SDK 1.8.0.0 shader +inputs and runtime resources. SHaRC is proprietary software provided by NVIDIA +Corporation and is not licensed under Caustica's LGPL-3.0-or-later license. + +The SHaRC SDK remains subject to the NVIDIA RTX SDKs license: + + + +The complete accepted SHaRC license is packaged in release artifacts at +`META-INF/licenses/nvidia/NVIDIA-SHARC-SDK.txt`. The LGPL license grant for +Caustica does not grant rights to the SHaRC SDK. diff --git a/build.gradle b/build.gradle index c23b48b0..e65c1474 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ import javax.inject.Inject import org.gradle.process.ExecOperations import dev.comfyfluffy.caustica.build.GenerateShaderRecords import dev.comfyfluffy.caustica.build.GenerateRtBindings +import java.security.MessageDigest plugins { id "fabric-loom" version "${loom_version}" @@ -81,6 +82,88 @@ processResources { // spirv-val from the Vulkan SDK or PATH. def shaderGenRoot = layout.buildDirectory.dir("generated/shaders") // classpath root -> /caustica/shaders/*.spv +// NVIDIA SHaRC is an optional, separately licensed shader input. The ordinary build remains stock when +// SHARC_SDK is absent; an explicitly configured SDK must be the pinned 1.8.0.0 checkout so the shader ABI +// cannot silently drift from the generated Java records and runtime cache layout. +def sharcVersion = "1.8.0.0" +def sharcCommit = "e19ccacd511f42a3df6f850052d508c13c9e9737" +def sharcHashes = [ + "include/SharcCommon.h": "d4b6e2765828a4b1c71bbf40609b2dbe72b4aec8ff4d80ccda21a83ab634ecc4", + "include/SharcTypes.h": "cde3f200e4e84f029968dffd9eede5228f2ae0b0bbc166d2babb1aa88dc044b3", + "include/HashGridCommon.h": "5a6d67186a88e61f0d47518f5986021f309f2897ccb482ab9e011a7a08749e7f", + "include/HashGridTypes.h": "34012bcffff0f2545c108a7c9dcb223298a70ebc2c377a86fbddfd1ecd76d614", + "License.md": "5575ad921bfe6027dc830887ff51ceb4097e31c2d538b6932cd4bbdf5c1899a9" +] +def configuredSharcSdk = (findProperty("sharcSdk") ?: System.getenv("SHARC_SDK"))?.toString()?.trim() +def sharcLicenseProperty = findProperty("acceptSharcLicense") +def sharcLicenseAccepted = sharcLicenseProperty != null + ? sharcLicenseProperty.toString().toBoolean() + : (System.getenv("SHARC_LICENSE_ACCEPTED")?.toBoolean() ?: false) +def sha256File = { File input -> + def digest = MessageDigest.getInstance("SHA-256") + input.withInputStream { stream -> + byte[] buffer = new byte[64 * 1024] + int read + while ((read = stream.read(buffer)) >= 0) { + if (read > 0) digest.update(buffer, 0, read) + } + } + digest.digest().collect { String.format("%02x", it & 0xff) }.join() +} +def sha256TextFile = { File input -> + def normalized = input.getText("UTF-8").replace("\r\n", "\n").replace("\r", "\n") + def digest = MessageDigest.getInstance("SHA-256") + digest.update(normalized.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + digest.digest().collect { String.format("%02x", it & 0xff) }.join() +} +def sharcSdkRoot = configuredSharcSdk ? file(configuredSharcSdk) : null +def validateSharcSdk = { File sdk -> + if (sdk == null) return + if (!sharcLicenseAccepted) { + throw new GradleException("SHARC_SDK is set, but the NVIDIA SHaRC 1.8 license was not explicitly accepted. " + + "Pass -PacceptSharcLicense=true after reviewing ${new File(sdk, 'License.md')}") + } + if (!sdk.isDirectory()) { + throw new GradleException("SHARC_SDK must point to the pinned git checkout ${sharcCommit}: ${sdk}") + } + def head = new ProcessBuilder("git", "-C", sdk.absolutePath, "rev-parse", "HEAD") + .redirectErrorStream(true).start() + def headText = head.inputStream.text.trim() + if (head.waitFor() != 0 || !headText.equalsIgnoreCase(sharcCommit)) { + throw new GradleException("SHARC_SDK HEAD ${headText} is not pinned to ${sharcCommit}") + } + def status = new ProcessBuilder("git", "-C", sdk.absolutePath, "status", "--porcelain", "--untracked-files=all") + .redirectErrorStream(true).start() + def statusText = status.inputStream.text.trim() + if (status.waitFor() != 0 || !statusText.isEmpty()) { + throw new GradleException(status.exitValue() == 0 + ? "SHARC_SDK checkout is dirty: ${statusText}" + : "Could not read SHARC_SDK checkout status: ${statusText}") + } + def common = new File(sdk, "include/SharcCommon.h") + if (!common.isFile()) throw new GradleException("Missing pinned SHaRC header: ${common}") + def commonText = common.getText("UTF-8") + ["SHARC_VERSION_MAJOR": "1", "SHARC_VERSION_MINOR": "8", "SHARC_VERSION_BUILD": "0", + "SHARC_VERSION_REVISION": "0"].each { name, value -> + if (!(commonText =~ /#define\s+${name}\s+${value}(?:\s|$)/)) { + throw new GradleException("SHARC_SDK ${common} does not declare ${name}=${value}") + } + } + sharcHashes.each { relative, expected -> + def input = new File(sdk, relative) + if (!input.isFile()) throw new GradleException("Missing pinned SHaRC input: ${input}") + def actual = sha256TextFile(input) + if (!actual.equalsIgnoreCase(expected)) { + throw new GradleException("SHARC_SDK hash mismatch for ${relative}: ${actual} != ${expected}") + } + } +} +validateSharcSdk(sharcSdkRoot) +def sharcSdkContentFingerprint = sharcSdkRoot == null ? "" : sharcHashes.collect { relative, expected -> + "${relative}:${sha256TextFile(new File(sharcSdkRoot, relative))}" +}.join("|") +def sharcLicenseRawHash = sharcSdkRoot == null ? "" : sha256File(new File(sharcSdkRoot, "License.md")) + def resolveVulkanTool = { String name -> def exe = org.gradle.internal.os.OperatingSystem.current().windows ? ".exe" : "" def sdk = System.getenv("VULKAN_SDK") @@ -93,11 +176,87 @@ def resolveVulkanTool = { String name -> return "${name}${exe}" // fall back to PATH; exec fails with a clear message if absent } +def locateExecutable = { String command -> + def direct = file(command) + if (direct.isFile()) return direct + def windows = org.gradle.internal.os.OperatingSystem.current().windows + def names = windows && !command.toLowerCase(java.util.Locale.ROOT).endsWith(".exe") + ? [command, "${command}.exe"] : [command] + def path = System.getenv("PATH") ?: "" + for (String entry : path.split(java.io.File.pathSeparator)) { + if (entry.isEmpty()) continue + for (String name : names) { + def candidate = new File(entry, name) + if (candidate.isFile()) return candidate + } + } + return null +} + +def locateVulkanTool = { String command -> locateExecutable(command) } + +// Keep executable paths as task inputs for diagnostics, and hash their contents so replacing a +// compiler or validator at the same path invalidates generated outputs. +def toolContentFingerprint = { String command -> + def executable = locateVulkanTool(command) + if (executable == null) return "unresolved:${command}" + return "${executable.absolutePath}:${sha256File(executable)}" +} + +def runIdentityCommand = { File executable, List arguments -> + if (executable == null || !executable.isFile()) { + return [status: "missing", output: ""] + } + def commandLine = new ArrayList() + commandLine.add(executable.absolutePath) + commandLine.addAll(arguments.collect { it.toString() }) + def process = new ProcessBuilder(commandLine) + .redirectErrorStream(true).start() + def output = process.inputStream.getText("UTF-8").trim() + def exitCode = process.waitFor() + [status: exitCode == 0 ? "ok" : "failed-${exitCode}", output: output] +} + +def slangcTool = resolveVulkanTool("slangc") +def spirvValTool = resolveVulkanTool("spirv-val") +def minimumSlangVersion = [2026, 4] +def validateSlangCompiler = { + File executable = locateVulkanTool(slangcTool) + if (executable == null || !executable.isFile()) { + throw new GradleException("slangc was not found. Set VULKAN_SDK to a Vulkan SDK containing Slang ${minimumSlangVersion[0]}.${minimumSlangVersion[1]} or newer.") + } + def result = runIdentityCommand(executable, ["-version"]) + if (result.status != "ok") { + throw new GradleException("Could not query slangc at ${executable}: ${result.output}") + } + def match = result.output =~ /(\d{4})\.(\d+)/ + if (!match.find()) { + throw new GradleException("Could not parse slangc version from ${executable}: ${result.output}") + } + int major = match.group(1).toInteger() + int minor = match.group(2).toInteger() + if (major < minimumSlangVersion[0] || (major == minimumSlangVersion[0] && minor < minimumSlangVersion[1])) { + throw new GradleException("slangc ${major}.${minor} at ${executable} is too old; Caustica requires Slang ${minimumSlangVersion[0]}.${minimumSlangVersion[1]} or newer. " + + "Point VULKAN_SDK at the compatible SDK so its slangc and spirv-val are selected together.") + } +} +def validateShaderToolchain = tasks.register("validateShaderToolchain") { + group = "verification" + description = "Checks that the selected Slang compiler supports Caustica's shader pointer layout syntax." + inputs.property("slangc", slangcTool) + inputs.property("slangcContentHash", toolContentFingerprint(slangcTool)) + doLast { validateSlangCompiler() } +} + abstract class CompileShaders extends DefaultTask { @InputDirectory abstract DirectoryProperty getSrcDir() @OutputDirectory abstract DirectoryProperty getOutDir() @Input abstract Property getSpirvVal() @Input abstract Property getSlangc() + @Input abstract Property getSpirvValContentHash() + @Input abstract Property getSlangcContentHash() + @Input abstract Property getSharcSdk() + @Input abstract Property getSharcSdkContentHash() @Inject abstract ExecOperations getExecOps() @@ -136,6 +295,7 @@ abstract class CompileShaders extends DefaultTask { } def shaderFiles = srcDir.get().asFileTree.matching { include stageIncludes + exclude "sharc/**" }.files.sort { it.absolutePath } def duplicateOutputs = shaderFiles .groupBy { "${outBase(srcDir.get().asFile, it)}.spv" } @@ -166,6 +326,45 @@ abstract class CompileShaders extends DefaultTask { compileOneSlang(src, spv, []) } } + File sdk = sharcSdk.getOrElse("").trim() ? new File(sharcSdk.get()) : null + if (sdk != null) { + // The pinned SDK header has a false-return path whose out parameter is not initialized. Slang + // diagnoses that under warnings-as-errors. The source/header hashes above validate the original; + // this disposable copy only repairs the compiler warning and is never packaged. + File compilerInclude = new File(temporaryDir, "sharc-include") + project.copy { from(new File(sdk, "include")); into(compilerInclude) } + File common = new File(compilerInclude, "SharcCommon.h") + String commonText = common.getText("UTF-8") + int functionStart = commonText.indexOf("bool SharcGetCachedRadiance") + int functionBrace = functionStart >= 0 ? commonText.indexOf("{", functionStart) : -1 + if (functionBrace < 0) throw new GradleException("Pinned SHaRC header lacks SharcGetCachedRadiance") + common.setText(commonText.substring(0, functionBrace + 1) + + "\n radiance = float3(0.0f, 0.0f, 0.0f);" + + commonText.substring(functionBrace + 1), "UTF-8") + def sharcIncludes = ["-I", new File(srcDir.get().asFile, "pipelines/world").absolutePath, + "-I", new File(srcDir.get().asFile, "sharc").absolutePath, + "-I", compilerInclude.absolutePath] + def sharcCapabilities = ["-capability", "spvInt64Atomics"] + File indirectSource = new File(srcDir.get().asFile, "pipelines/world/indirect.rgen.slang") + def variantArgs = ["-DCAUSTICA_SHARC_VARIANT=1", "-DSHARC_ENABLE_SH_ENCODING=1"] + sharcCapabilities + sharcIncludes + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_query.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_QUERY=1"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_ser_query.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_QUERY=1", "-DCAUSTICA_ENABLE_EXT_SER", + "-capability", "spvShaderInvocationReorderEXT"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_update.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_UPDATE=1"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_ser_update.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_UPDATE=1", "-DCAUSTICA_ENABLE_EXT_SER", + "-capability", "spvShaderInvocationReorderEXT"]) + compileOneSlang(new File(srcDir.get().asFile, "sharc/sharc_resolve.comp.slang"), + new File(scratchDir, "sharc/sharc_resolve.comp.spv"), + ["-DSHARC_ENABLE_SH_ENCODING=1"] + sharcCapabilities + sharcIncludes) + } if (outDirFile.exists() && !outDirFile.deleteDir()) { throw new GradleException("failed to clear generated shaders under ${outDirFile}") } @@ -185,8 +384,13 @@ def compileShaders = tasks.register("compileShaders", CompileShaders) { description = "Compiles shaders/** shader sources to SPIR-V and validates them." srcDir = file("shaders") outDir = layout.buildDirectory.dir("generated/shaders/caustica/shaders") - spirvVal = resolveVulkanTool("spirv-val") - slangc = resolveVulkanTool("slangc") + spirvVal = spirvValTool + spirvValContentHash = toolContentFingerprint(spirvValTool) + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) + sharcSdk = sharcSdkRoot?.absolutePath ?: "" + sharcSdkContentHash = sharcSdkContentFingerprint + dependsOn(validateShaderToolchain) } def generateShaderRecords = tasks.register("generateShaderRecords", GenerateShaderRecords) { @@ -194,9 +398,12 @@ def generateShaderRecords = tasks.register("generateShaderRecords", GenerateShad description = "Generates typed Java shader records and serializers from Slang reflection." shaderRoot = file("shaders") probeSource = file("shaders/layout/layout_probe.slang") - slangc = resolveVulkanTool("slangc") - spirvVal = resolveVulkanTool("spirv-val") + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) + spirvVal = spirvValTool + spirvValContentHash = toolContentFingerprint(spirvValTool) outDir = layout.buildDirectory.dir("generated/sources/shaderRecords") + dependsOn(validateShaderToolchain) } def rtBindingsGenRoot = layout.buildDirectory.dir("generated/sources/rtBindings") @@ -204,8 +411,10 @@ def generateRtBindings = tasks.register("generateRtBindings", GenerateRtBindings group = "build" description = "Generates Java descriptor bindings from Slang reflection." shaderRoot = file("shaders") - slangc = resolveVulkanTool("slangc") + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) outDir = rtBindingsGenRoot + dependsOn(validateShaderToolchain) } tasks.named("test", Test) { @@ -219,6 +428,49 @@ sourceSets.main.java.srcDir(files(rtBindingsGenRoot).builtBy(generateRtBindings) // same /caustica/shaders/ path the pipeline loaders read. builtBy wires the dependency. sourceSets.main.resources.srcDir(files(shaderGenRoot).builtBy(compileShaders)) +def sharcMetadataRoot = layout.buildDirectory.dir("generated/sharc-metadata") +def generateSharcMetadata = tasks.register("generateSharcMetadata") { + group = "build" + description = "Publishes SHaRC 1.8 capability metadata only for an explicitly accepted SDK build." + inputs.property("sharcSdk", sharcSdkRoot?.absolutePath ?: "") + inputs.property("sharcCommit", sharcCommit) + inputs.property("sharcVersion", sharcVersion) + inputs.property("sharcHashes", sharcHashes.toString()) + inputs.property("sharcSdkContentHash", sharcSdkContentFingerprint) + inputs.property("sharcLicenseRawHash", sharcLicenseRawHash) + outputs.dir(sharcMetadataRoot) + doLast { + def root = sharcMetadataRoot.get().asFile + delete(root) + if (sharcSdkRoot == null) return + def metadata = new File(root, "caustica/sharc.properties") + metadata.parentFile.mkdirs() + metadata.setText("""version=${sharcVersion} +commit=${sharcCommit} +sharcCommonSha256=${sharcHashes['include/SharcCommon.h']} +sharcTypesSha256=${sharcHashes['include/SharcTypes.h']} +hashGridCommonSha256=${sharcHashes['include/HashGridCommon.h']} +hashGridTypesSha256=${sharcHashes['include/HashGridTypes.h']} +licenseCanonicalTextSha256=${sharcHashes['License.md']} +licensePackagedSha256=${sharcLicenseRawHash} +artifacts=true +directionalSh=true +queryVariants=ordinary,ext-ser +updateVariants=ordinary,ext-ser +resolveVariants=directional-sh +shaderBufferInt64Atomics=true +shaderFloat16=true +storageBuffer16BitAccess=true +""", "UTF-8") + copy { + from(new File(sharcSdkRoot, "License.md")) + into(new File(root, "META-INF/licenses/nvidia")) + rename { "NVIDIA-SHARC-SDK.txt" } + } + } +} +sourceSets.main.resources.srcDir(files(sharcMetadataRoot).builtBy(generateSharcMetadata)) + def ngxNativeGenRoot = layout.buildDirectory.dir("generated/ngx-natives") def requestedTaskNames = gradle.startParameter.taskNames.collect { it.toLowerCase(java.util.Locale.ROOT) } def isRunClientInvocation = requestedTaskNames.any { it == "runclient" || it.endsWith(":runclient") } @@ -300,10 +552,179 @@ if (requestedNgxPlatformsRaw.size() == 1 && requestedNgxPlatformsRaw[0].equalsIg def dlssSdkEnv = providers.environmentVariable("DLSS_SDK") def dlssSdkRootProvider = dlssSdkEnv.map { file(it) } +def expectedDlssSdkCommit = "a291cc7d2cc642a51566f3dfd5376f635cd1b284" +def expectedDlssSdkHeaderHashes = [ + "include/nvsdk_ngx_defs.h": "ea23f33497cd274860d1c25a97644fce807dcb0037c594547203343103fad03e", + "include/nvsdk_ngx_defs_dlssd.h": "d2fde340db2189c89bce093bc1edd7b3579df48decac329218a98a9c5fd46018", + "include/nvsdk_ngx_defs_dlssg.h": "5e76e5cf0397f0b093887d0392b427a6b3e3f722cec5f5a4795357edeb6de4ba", + "include/nvsdk_ngx_defs_vk.h": "0a24d0861ace7d6b9362a67b7f08bea1b33ea123c5ce730b19133de8a891d031" +] +def expectedDlssSdkPayloadHashes = [ + "windows-x64": [ + staticLibrary: ["lib/Windows_x86_64/x64/nvsdk_ngx_d.lib", + "31e82b4ec3242ec6e5b42c73e1f5f5e260338a6ee213bd64444f6c2fa364aa84"] + ], + "linux-x64": [ + staticLibrary: ["lib/Linux_x86_64/libnvsdk_ngx.a", + "dae18dce6fdbab45f7304b14901c93c41073d7cc31d555d6cb1fa145958937a6"] + ] +] +def expectedDlssSdkRuntimeHashes = [ + "windows-x64": [ + "rel": [ + "nvngx_dlssd.dll": "f4e97624f70fbb769acb11ebd751b512ecc9463d4bd6aef04896d3956e6084a0", + "nvngx_dlssg.dll": "135eaf0733c1e37381a8c28abcf7a862404a54132b81787c04e35d09efc5e36f" + ], + "dev": [ + "nvngx_dlssd.dll": "fd83687c98d00754ae8e26b5daa7dabfed04557113c69c9eddfe4b6f3acaf426", + "nvngx_dlssg.dll": "0d33b5de65d60a943c33bd096d574297302b214a1c5baa6bcb74bc1150a608de" + ] + ], + "linux-x64": [ + "rel": [ + "libnvidia-ngx-dlssd.so": "efd465933bf9a40b65f3c6d61aa079f4a4b188004e9c2b432e3375783b0029f3", + "libnvidia-ngx-dlssg.so": "676cfeace1bf675a281cf234df619f24cef16a1259f36119ebdf01138468a057" + ], + "dev": [ + "libnvidia-ngx-dlssd.so": "910997cf8e1b2a1c2e9a5474e9881e7fff0ffcf25284523ca853ac47b0f9b14b", + "libnvidia-ngx-dlssg.so": "1275c6409a508027438b938487042e67fd4a116c397232ca19984372b81163bb" + ] + ] +] + +def validateDlssSdk = tasks.register("validateDlssSdk") { + group = "verification" + description = "Validates the pinned DLSS headers and native payloads used by the selected NGX bundle." + inputs.property("dlssSdk", dlssSdkEnv.orElse("")) + inputs.property("dlssCommit", expectedDlssSdkCommit) + inputs.property("dlssHeaderHashes", expectedDlssSdkHeaderHashes.toString()) + inputs.property("dlssPayloadHashes", expectedDlssSdkPayloadHashes.toString()) + inputs.property("dlssRuntimeHashes", expectedDlssSdkRuntimeHashes.toString()) + inputs.property("ngxVendorConfig", ngxVendorConfig) + inputs.property("ngxPlatforms", selectedNgxNativePlatforms*.name.join(",")) + outputs.upToDateWhen { false } + doLast { + File sdk = dlssSdkRootProvider.orNull + if (sdk == null || !sdk.isDirectory()) { + throw new GradleException("DLSS_SDK must point to the pinned SDK checkout ${expectedDlssSdkCommit}.") + } + def runGit = { List arguments -> + def command = ["git", "-C", sdk.absolutePath] + arguments + def process = new ProcessBuilder(command).redirectErrorStream(true).start() + def output = process.inputStream.getText("UTF-8").trim() + [status: process.waitFor(), output: output] + } + def head = runGit(["rev-parse", "HEAD"]) + if (head.status != 0 || !head.output.equalsIgnoreCase(expectedDlssSdkCommit)) { + throw new GradleException("DLSS_SDK HEAD ${head.output} is not pinned to ${expectedDlssSdkCommit}") + } + def status = runGit(["status", "--porcelain", "--untracked-files=all"]) + if (status.status != 0 || !status.output.isEmpty()) { + throw new GradleException(status.status == 0 + ? "DLSS_SDK checkout is dirty: ${status.output}" + : "Could not read DLSS_SDK checkout status: ${status.output}") + } + def requireHash = { String label, File input, String expected, boolean textInput -> + if (!input.isFile()) throw new GradleException("Missing pinned ${label}: ${input}") + def actual = textInput ? sha256TextFile(input) : sha256File(input) + if (!actual.equalsIgnoreCase(expected)) { + throw new GradleException("DLSS_SDK hash mismatch for ${label}: ${actual} != ${expected}") + } + } + expectedDlssSdkHeaderHashes.each { relative, expected -> + requireHash(relative, new File(sdk, relative), expected, true) + } + selectedNgxNativePlatforms.each { platform -> + def staticLibrary = expectedDlssSdkPayloadHashes[platform.name].staticLibrary + requireHash("${platform.name} static library", new File(sdk, staticLibrary[0]), staticLibrary[1], false) + File vendorRoot = platform.vendorRoot(sdk, ngxVendorConfig) + expectedDlssSdkRuntimeHashes[platform.name][ngxVendorConfig].each { baseName, expected -> + File runtime = new File(vendorRoot, baseName) + if (!runtime.isFile() && platform.name == "linux-x64") { + def candidates = fileTree(vendorRoot) { include "${baseName}.*" }.files.sort { it.name } + if (candidates.size() != 1) { + throw new GradleException("Expected one ${baseName} runtime in ${vendorRoot}, found ${candidates*.name}") + } + runtime = candidates[0] + } + requireHash("${platform.name} ${runtime.name}", runtime, expected, false) + } + } + } +} + +def ngxShimBuildType = ngxShimConfig == "debug" ? "Debug" : "Release" +def ngxShimBuildRoot = file("build/cmake/ngx_shim/${ngxShimConfig}") +def ngxShimBinaryName = currentNgxPlatform == "windows-x64" ? "ngxshim.dll" : "libngxshim.so" +def ngxShimBinary = new File(ngxShimOutDir, ngxShimBinaryName) +abstract class BuildNgxShim extends DefaultTask { + @InputDirectory abstract DirectoryProperty getSourceDir() + @Input abstract Property getBuildRoot() + @Input abstract Property getBuildType() + @Input abstract Property getCmakeExecutable() + @Input abstract Property getDlssSdk() + @Input abstract Property getVulkanSdk() + @Input abstract ListProperty getConfigureArgs() + @OutputFile abstract RegularFileProperty getOutputFile() + + @Inject abstract ExecOperations getExecOps() + + @TaskAction + void build() { + String dlssSdkPath = dlssSdk.get() + String vulkanSdkPath = vulkanSdk.get() + if (!dlssSdkPath || !new File(dlssSdkPath).isDirectory()) { + throw new GradleException("DLSS_SDK must point to the pinned SDK checkout before building the NGX shim.") + } + if (!vulkanSdkPath || !new File(vulkanSdkPath).isDirectory()) { + throw new GradleException("VULKAN_SDK must point to the Vulkan SDK before building the NGX shim.") + } + File buildRoot = new File(buildRoot.get()) + if (buildRoot.exists() && !buildRoot.deleteDir()) { + throw new GradleException("Could not clear the NGX CMake build directory: ${buildRoot}") + } + String cmake = cmakeExecutable.get() + def configure = ["-S", sourceDir.get().asFile.absolutePath, "-B", buildRoot.absolutePath] + configure.addAll(configureArgs.get()) + getExecOps().exec { + commandLine([cmake] + configure) + environment "DLSS_SDK", dlssSdkPath + environment "VULKAN_SDK", vulkanSdkPath + } + getExecOps().exec { + commandLine([cmake, "--build", buildRoot.absolutePath, "--config", buildType.get(), "--target", "ngxshim", "--parallel"]) + environment "DLSS_SDK", dlssSdkPath + environment "VULKAN_SDK", vulkanSdkPath + } + File output = outputFile.get().asFile + if (!output.isFile()) { + throw new GradleException("NGX shim build succeeded but did not produce ${output}") + } + } +} + +def cmakeForNgxShim = locateExecutable((findProperty("releaseCmake") ?: "cmake").toString()) +def buildNgxShim = tasks.register("buildNgxShim", BuildNgxShim) { + group = "build" + description = "Rebuilds the NGX shim from the current native source before packaging it." + dependsOn(validateDlssSdk) + sourceDir.set(layout.projectDirectory.dir("native/ngx_shim")) + buildRoot.set(ngxShimBuildRoot.absolutePath) + buildType.set(ngxShimBuildType) + cmakeExecutable.set(cmakeForNgxShim?.absolutePath ?: "cmake") + dlssSdk.set(dlssSdkRootProvider.map { it.absolutePath }.orElse("")) + vulkanSdk.set(providers.environmentVariable("VULKAN_SDK").orElse("")) + configureArgs.set(org.gradle.internal.os.OperatingSystem.current().windows + ? ["-G", "Visual Studio 17 2022", "-A", "x64"] + : ["-DCMAKE_BUILD_TYPE=${ngxShimBuildType}"]) + outputFile.set(ngxShimBinary) + inputs.files(fileTree("native/ngx_shim")) +} def bundleNgxNatives = tasks.register("bundleNgxNatives") { group = "build" description = "Bundles NGX shim and ${ngxVendorConfig} DLSS native libraries into the mod resources." + dependsOn(buildNgxShim) inputs.property "ngxShimConfig", ngxShimConfig inputs.property "ngxVendorConfig", ngxVendorConfig diff --git a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy index e609d048..2a83baf4 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy @@ -22,6 +22,7 @@ abstract class GenerateRtBindings extends DefaultTask { abstract DirectoryProperty getShaderRoot() @Input abstract Property getSlangc() + @Input abstract Property getSlangcContentHash() @OutputDirectory abstract DirectoryProperty getOutDir() @Inject abstract ExecOperations getExecOps() @@ -31,12 +32,16 @@ abstract class GenerateRtBindings extends DefaultTask { TLAS: "topLevelAS", OUTPUT: "outImage", BLOCK_ALBEDO: "blockAlbedoAtlas", G_NORMAL: "gNormal", G_ALBEDO: "gAlbedo", G_DEPTH: "gDepth", G_MOTION: "gMotion", G_SPEC_ALBEDO: "gSpecAlbedo", G_SPEC_MOTION: "gSpecMotion", + G_RESPONSIVITY: "gResponsivity", G_PARTICLE_MASK: "gParticleMask", + G_SKY_CLASSIFICATION: "gSkyClassification", CELESTIALS: "celestialsAtlas", SKY_VIEW: "skyViewLut", TRANSMITTANCE: "transmittanceLut", + END_SKY: "endSkyTexture", ENTITY_ALBEDO: "entityAlbedoTex", MATERIAL_SURFACE0: "materialSurface0Tex", MATERIAL_NORMAL_AO: "materialNormalAoTex", MATERIAL_SURFACE1: "materialSurface1Tex"]], [prefix: "DISPLAY", source: "pipelines/display/main.comp.slang", resources: [ OUTPUT: "outputImage", RT_IMAGE: "rtImage", EXPOSURE: "exposureImage", HDR_OUTPUT: "hdrImage", - SDR_TONE_LUT: "toneLut", HDR_TONE_LUT: "hdrToneLut", LOOK_LUT: "lookLut", BLOOM: "bloomImage"]], + SDR_TONE_LUT: "toneLut", HDR_TONE_LUT: "hdrToneLut", LOOK_LUT: "lookLut", BLOOM: "bloomImage", + SKY_CLASSIFICATION: "skyClassificationImage", END_SKY: "endSkyTexture", CELESTIALS: "celestialsAtlas"]], [prefix: "DEBUG_PRESENT", source: "pipelines/debug_present/main.comp.slang", resources: [ OUTPUT: "outputImage", G_NORMAL: "gNormal", G_ALBEDO: "gAlbedo", G_DEPTH: "gDepth", G_MOTION: "gMotion", G_SPEC_ALBEDO: "gSpecAlbedo", G_SPEC_MOTION: "gSpecMotion", diff --git a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy index ddeecdc1..74cb18cc 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy @@ -27,7 +27,9 @@ abstract class GenerateShaderRecords extends DefaultTask { abstract RegularFileProperty getProbeSource() @Input abstract Property getSlangc() + @Input abstract Property getSlangcContentHash() @Input abstract Property getSpirvVal() + @Input abstract Property getSpirvValContentHash() @OutputDirectory abstract DirectoryProperty getOutDir() @Inject abstract ExecOperations getExecOps() @@ -297,6 +299,17 @@ abstract class GenerateShaderRecords extends DefaultTask { Map exposureStateType = exposureStateProbeArray.type.elementType as Map int exposureStateByteSize = exposureStateProbeArray.type.uniformStride as int + def structuredProbe = { String probeName, String structName -> + def probeParameter = reflection.parameters.find { it.name == probeName } + def array = probeParameter?.type?.resultType?.fields?.find { it.name == "values" } + if (array?.type?.kind != "array" || array.type.elementType?.name != structName) { + throw new GradleException("unexpected ${structName} reflection probe shape") + } + [type: array.type.elementType as Map, byteSize: array.type.uniformStride as int] + } + def sharcPush = structuredProbe("sharcPushLayoutProbe", "SharcPushConstants") + def sharcFrame = structuredProbe("sharcFrameLayoutProbe", "SharcFrame") + def generatedRoot = outDir.get().asFile if (generatedRoot.exists() && !generatedRoot.deleteDir()) { throw new GradleException("failed to clear generated shader record sources under ${generatedRoot}") @@ -310,6 +323,11 @@ abstract class GenerateShaderRecords extends DefaultTask { new File(packageDir, "ExposureStateData.java").setText( generateJava(exposureStateType, exposureStateByteSize, "ExposureStateData", true), "UTF-8") + new File(packageDir, "SharcPushConstantsData.java").setText( + generateJava(sharcPush.type, sharcPush.byteSize, "SharcPushConstantsData"), "UTF-8") + new File(packageDir, "SharcFrameData.java").setText( + generateJava(sharcFrame.type, sharcFrame.byteSize, "SharcFrameData"), "UTF-8") + PUSH_CONSTANT_PROBES.each { probeName, structName, className -> Map type = extractPushConstantType(reflection, probeName, structName) int byteSize = extractPushConstantByteSize(reflection, probeName) diff --git a/docs/developer_guide.md b/docs/developer_guide.md index a28b092a..7b594187 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -3,9 +3,14 @@ ## Windows 1. Install the Vulkan SDK from . - The installer sets `VULKAN_SDK` automatically. -2. Download the DLSS SDK from . - Extract it, then set `DLSS_SDK` to the folder you extracted. + The installer sets `VULKAN_SDK` automatically. Caustica requires Slang + 2026.4 or newer for its raw-device-pointer layout declarations; on Windows, + Vulkan SDK 1.4.350.0 is a known-good baseline. `VULKAN_SDK` must point to + that compatible SDK so `slangc` and `spirv-val` come from the same install. +2. Clone the DLSS SDK from and check out commit + `a291cc7d2cc642a51566f3dfd5376f635cd1b284`. Set `DLSS_SDK` to that clean Git + checkout. Gradle validates the commit, clean status, headers, static library, + and runtime libraries before building or packaging the NGX shim. To set it permanently for your Windows user account, run PowerShell with: @@ -20,14 +25,20 @@ $env:DLSS_SDK = "C:\path\to\dlss-sdk" ``` -3. Configure and build the native shim: +3. To include NVIDIA SHaRC, use a clean SHaRC 1.8 checkout at commit + `e19ccacd511f42a3df6f850052d508c13c9e9737`, pass its path as + `-PsharcSdk=C:\path\to\SHARC-1.8.0.0`, and pass + `-PacceptSharcLicense=true` after reviewing its license. Without that + explicit SDK and acceptance, Gradle builds the ordinary RT variants only. + +4. Configure and build the native shim directly when working on its C++ code: ```powershell cmake -S native/ngx_shim -B build/cmake/ngx_shim/release -DCMAKE_BUILD_TYPE=Release cmake --build build/cmake/ngx_shim/release --config Release ``` -4. Run the client: +5. Run the client: ```powershell $env:JAVA_TOOL_OPTIONS = "-Xmx8G -XX:+UseCompactObjectHeaders -XX:+AlwaysPreTouch -XX:+UseStringDeduplication -XX:+UseZGC" @@ -36,15 +47,18 @@ $env:JAVA_TOOL_OPTIONS = "-Xmx8G -XX:+UseCompactObjectHeaders -XX:+AlwaysPreTouc ## Linux -Set `DLSS_SDK` and `VULKAN_SDK` before configuring CMake: +Use the same clean pinned DLSS checkout and Vulkan SDK described above, then +set `DLSS_SDK` and `VULKAN_SDK` before configuring CMake: ```bash export DLSS_SDK=/path/to/dlss-sdk export VULKAN_SDK=/path/to/vulkan-sdk ``` -`DLSS_SDK` must contain the NGX headers and static library. `VULKAN_SDK` must -contain Vulkan headers. +Gradle validates the exact DLSS commit, clean status, headers, selected static +library, and selected runtime payloads. `VULKAN_SDK` must provide Slang 2026.4 +or newer plus `spirv-val`. To include SHaRC, also pass the pinned checkout as +`-PsharcSdk=/path/to/SHARC-1.8.0.0 -PacceptSharcLicense=true`. Then configure and build the native shim: diff --git a/native/ngx_shim/ngx_shim.cpp b/native/ngx_shim/ngx_shim.cpp index 0ce33f1c..6e121ddf 100644 --- a/native/ngx_shim/ngx_shim.cpp +++ b/native/ngx_shim/ngx_shim.cpp @@ -44,6 +44,7 @@ static const char* kProjectId = "b6f1e9c2-7a44-4d1e-9b3a-1f2c3d4e5a6b"; static NVSDK_NGX_Parameter* g_capabilityParams = nullptr; static VkDevice g_device = VK_NULL_HANDLE; +static bool g_initialized = false; static int g_lastResult = 0; // Logging sink wired into NVSDK_NGX_FeatureCommonInfo so the (closed) NGX core/SDK pipes its own @@ -87,6 +88,11 @@ extern "C" { #define NGX_SHIM_EXPORT __attribute__((visibility("default"))) #endif +// Increment whenever the flat C ABI changes incompatibly. Java checks this before using any export. +NGX_SHIM_EXPORT int ngxshim_abi_version() { + return 1; +} + // Last NVSDK_NGX_Result observed, for diagnostics from the Java side. NGX_SHIM_EXPORT int ngxshim_last_result() { return g_lastResult; @@ -140,6 +146,8 @@ NGX_SHIM_EXPORT int ngxshim_init(unsigned long long appId, const wchar_t* dataPa appId, (void*) dataPath, (void*) instance, (void*) physicalDevice, (void*) device, getInstanceProcAddr, getDeviceProcAddr, (void*) featureDllPath); g_device = device; + g_initialized = false; + g_capabilityParams = nullptr; NVSDK_NGX_FeatureCommonInfo info; std::memset(&info, 0, sizeof(info)); @@ -166,13 +174,24 @@ NGX_SHIM_EXPORT int ngxshim_init(unsigned long long appId, const wchar_t* dataPa NGX_LOG("init: Init_with_ProjectID r=0x%08x", (unsigned) r); if (NVSDK_NGX_FAILED(r)) { NGX_LOG("init: FAILED init, returning 0x%08x", (unsigned) r); + g_device = VK_NULL_HANDLE; return (int) r; } + g_initialized = true; NGX_LOG("init: calling NVSDK_NGX_VULKAN_GetCapabilityParameters"); r = NVSDK_NGX_VULKAN_GetCapabilityParameters(&g_capabilityParams); g_lastResult = (int) r; NGX_LOG("init: GetCapabilityParameters r=0x%08x g_capabilityParams=%p", (unsigned) r, (void*) g_capabilityParams); + if (NVSDK_NGX_FAILED(r)) { + if (g_capabilityParams) { + NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + g_capabilityParams = nullptr; + } + NVSDK_NGX_VULKAN_Shutdown1(g_device); + g_initialized = false; + g_device = VK_NULL_HANDLE; + } return (int) r; } @@ -367,15 +386,15 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, } NVSDK_NGX_Result r = NVSDK_NGX_Result_Success; - if (renderPreset != 0) { - unsigned int preset = (unsigned int) renderPreset; - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_DLAA, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Quality, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Balanced, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Performance, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraPerformance, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraQuality, preset); - } + // The capability block is shared across feature instances, so write every hint on every create; + // zero restores the DLL's default when the caller requests the Default preset. + unsigned int preset = (unsigned int) renderPreset; + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_DLAA, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Quality, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Balanced, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Performance, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraPerformance, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraQuality, preset); NVSDK_NGX_DLSSD_Create_Params createParams; std::memset(&createParams, 0, sizeof(createParams)); @@ -403,6 +422,13 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, } DlssFeature* feature = (DlssFeature*) std::malloc(sizeof(DlssFeature)); + if (!feature) { + NGX_LOG("create_dlssd: wrapper allocation failed; releasing handle=%p", (void*) handle); + if (handle) { + NVSDK_NGX_VULKAN_ReleaseFeature(handle); + } + return nullptr; + } feature->handle = handle; feature->params = params; feature->ownsParams = false; // shared capability block, freed at shutdown @@ -410,21 +436,22 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, return feature; } -// Records a DLSS Ray Reconstruction evaluation. Guide buffers: HDR color, linear depth, motion +// Records a DLSS Ray Reconstruction evaluation. Guide buffers: HDR color, hardware depth, motion // vectors, diffuse albedo, specular albedo, world-space normals (roughness packed in normals.w), -// and reflection motion vectors. Specular hit distance remains in the ABI/resources for debug and -// easy A/B, but is disabled by leaving pInSpecularHitDistance null. +// and reflection motion vectors. Particle classification and responsivity are optional render-resolution +// guides consumed by DLSSD to avoid reusing history for dynamic pixels. // Output is the only read-write (storage) resource. All non-output images use the color aspect; -// depth is a linear value carried in a color image, not a depth-aspect attachment. -NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, +// depth is a non-linear, reversed-Z hardware value carried in a color image, not a depth-aspect attachment. +NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd_v2(VkCommandBuffer cmd, void* feature, VkImageView colorView, VkImage colorImage, int colorFormat, VkImageView depthView, VkImage depthImage, int depthFormat, VkImageView mvView, VkImage mvImage, int mvFormat, VkImageView diffuseAlbedoView, VkImage diffuseAlbedoImage, int diffuseAlbedoFormat, VkImageView specularAlbedoView, VkImage specularAlbedoImage, int specularAlbedoFormat, - VkImageView normalsView, VkImage normalsImage, int normalsFormat, - VkImageView specularMotionView, VkImage specularMotionImage, int specularMotionFormat, - VkImageView specularHitDistanceView, VkImage specularHitDistanceImage, int specularHitDistanceFormat, + VkImageView normalsView, VkImage normalsImage, int normalsFormat, + VkImageView specularMotionView, VkImage specularMotionImage, int specularMotionFormat, + VkImageView particleMaskView, VkImage particleMaskImage, int particleMaskFormat, + VkImageView responsivityMaskView, VkImage responsivityMaskImage, int responsivityMaskFormat, VkImageView outputView, VkImage outputImage, int outputFormat, unsigned int renderWidth, unsigned int renderHeight, unsigned int displayWidth, unsigned int displayHeight, @@ -439,12 +466,10 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, NGX_LOG("evaluate_dlssd: null feature, returning -1"); return -1; } - NGX_LOG("evaluate_dlssd: handle=%p params=%p color=%p depth=%p mv=%p diffuse=%p specular=%p normals=%p specMotion=%p output=%p", + NGX_LOG("evaluate_dlssd: handle=%p params=%p color=%p depth=%p mv=%p diffuse=%p specular=%p normals=%p specMotion=%p particles=%p responsivity=%p output=%p", (void*) f->handle, (void*) f->params, (void*) colorView, (void*) depthView, (void*) mvView, - (void*) diffuseAlbedoView, (void*) specularAlbedoView, (void*) normalsView, (void*) specularMotionView, (void*) outputView); - (void) specularHitDistanceView; - (void) specularHitDistanceImage; - (void) specularHitDistanceFormat; + (void*) diffuseAlbedoView, (void*) specularAlbedoView, (void*) normalsView, (void*) specularMotionView, + (void*) particleMaskView, (void*) responsivityMaskView, (void*) outputView); NVSDK_NGX_Resource_VK color = makeImageResource(colorView, colorImage, colorFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK depth = makeImageResource(depthView, depthImage, depthFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); @@ -453,6 +478,8 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, NVSDK_NGX_Resource_VK specularAlbedo = makeImageResource(specularAlbedoView, specularAlbedoImage, specularAlbedoFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK normals = makeImageResource(normalsView, normalsImage, normalsFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK specularMotion = makeImageResource(specularMotionView, specularMotionImage, specularMotionFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); + NVSDK_NGX_Resource_VK particleMask = makeImageResource(particleMaskView, particleMaskImage, particleMaskFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); + NVSDK_NGX_Resource_VK responsivityMask = makeImageResource(responsivityMaskView, responsivityMaskImage, responsivityMaskFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK output = makeImageResource(outputView, outputImage, outputFormat, displayWidth, displayHeight, VK_IMAGE_ASPECT_COLOR_BIT, true); NVSDK_NGX_VK_DLSSD_Eval_Params eval; @@ -465,6 +492,10 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, eval.pInSpecularAlbedo = &specularAlbedo; eval.pInNormals = &normals; eval.pInMotionVectorsReflections = &specularMotion; + eval.pInIsParticleMask = &particleMask; + eval.pInResponsivityMask = &responsivityMask; + eval.InResponsivityMaskSubrectBase.X = 0; + eval.InResponsivityMaskSubrectBase.Y = 0; eval.pInSpecularHitDistance = nullptr; // HW depth needs the projection so DLSS can linearize it (jitter-free; NGX left-multiply layout). eval.pInWorldToViewMatrix = worldToViewMatrix; @@ -638,15 +669,29 @@ NGX_SHIM_EXPORT void ngxshim_release(void* feature) { NGX_LOG("release: exit"); } -NGX_SHIM_EXPORT void ngxshim_shutdown(VkDevice device) { +NGX_SHIM_EXPORT int ngxshim_shutdown(VkDevice device) { NGX_LOG("shutdown: enter device=%p g_device=%p g_capabilityParams=%p", (void*) device, (void*) g_device, (void*) g_capabilityParams); if (g_capabilityParams) { - NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + NVSDK_NGX_Result r = NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + g_lastResult = (int) r; + if (NVSDK_NGX_FAILED(r)) { + NGX_LOG("shutdown: DestroyParameters failed r=0x%08x", (unsigned) r); + return (int) r; + } g_capabilityParams = nullptr; } - NVSDK_NGX_VULKAN_Shutdown1(device ? device : g_device); + if (g_initialized) { + NVSDK_NGX_Result r = NVSDK_NGX_VULKAN_Shutdown1(device ? device : g_device); + g_lastResult = (int) r; + if (NVSDK_NGX_FAILED(r)) { + NGX_LOG("shutdown: Shutdown1 failed r=0x%08x", (unsigned) r); + return (int) r; + } + } + g_initialized = false; g_device = VK_NULL_HANDLE; NGX_LOG("shutdown: exit"); + return g_lastResult; } } // extern "C" diff --git a/shaders/common/display_common.slang b/shaders/common/display_common.slang index 54ef9fe1..42f8a9f9 100644 --- a/shaders/common/display_common.slang +++ b/shaders/common/display_common.slang @@ -73,10 +73,36 @@ public struct DisplayPush { public float hdrPeakNits; // mastering peak baked into the currently bound HDR ACES LUT public int lookEnabled; // 0 = identity, 1 = apply the scene-referred ACES look LUT public float lookLutSize; // lookLut texels per axis (independent of the output-transform LUT size) - // Scene-referred blurred highlight signal added before the LMT. The bloom pyramid's level 0 holds the - // SUM of every band, so RtComposite folds the 1/levelCount normalisation into this value: the authored - // look-package strength then means the same thing whichever level count the resolution supports. + // Scene-referred blurred highlight signal added before display rendering. The bloom pyramid's level 0 + // holds the SUM of every band, so RtComposite folds the 1/levelCount normalisation into this value: the + // authored look-package strength then means the same thing whichever level count the resolution supports. public float bloomStrength; + public int sdrMode; // 0 = ACES 2.0 LUT; positive values select local analytical operators + public int hdrMode; // 0 = ACES 2.0 LUT; 2 = PsychoV24; 3 = BT.2390 + public float paperWhiteNits; + public float headroom; + public float sdrParam0; + public float sdrParam1; + public float sdrParam2; + public float sdrParam3; + public float sdrParam4; + public float sdrParam5; + public float sdrParam6; + public float sdrParam7; + public float hdrParam0; + public float hdrParam1; + public float hdrParam2; + public float hdrParam3; + public float hdrParam4; + public float hdrParam5; + public float hdrParam6; + public float hdrParam7; + public float4 skyColor; // captured vanilla sky/clear color in linear BT.709 + public float4x4 invViewProj; // unjittered camera-relative inverse projection + public uint skybox; // SKYBOX_NONE, SKYBOX_OVERWORLD, or SKYBOX_END + public uint skyFlags; // SKY_FLAG_END_FLASH when the vanilla End flash is active + public float4 skyParams; // reserved, flash intensity, flash X angle, flash Y angle + public float4 endFlashUv; // celestials-atlas UV rect for end_flash }; public struct BloomPush { diff --git a/shaders/layout/layout_probe.slang b/shaders/layout/layout_probe.slang index cecbe033..365ce002 100644 --- a/shaders/layout/layout_probe.slang +++ b/shaders/layout/layout_probe.slang @@ -3,6 +3,7 @@ // complete Std430DataLayout: field types, byte offsets, total size, matrix mode, and array counts. import world_common; import display_common; +import sharc_types; struct WorldPushLayoutProbe { WorldPush values[2]; // array stride is the complete, tail-padded WorldPush byte size @@ -16,9 +17,19 @@ struct ExposureStateLayoutProbe { ExposureState values[2]; // array stride is the complete, tail-padded ExposureState byte size }; +struct SharcPushLayoutProbe { + SharcPushConstants values[2]; +}; + +struct SharcFrameLayoutProbe { + SharcFrame values[2]; +}; + [[vk::binding(0, 0)]] StructuredBuffer worldPushLayoutProbe; [[vk::binding(1, 0)]] StructuredBuffer materialHeaderLayoutProbe; [[vk::binding(2, 0)]] StructuredBuffer exposureStateLayoutProbe; +[[vk::binding(3, 0)]] StructuredBuffer sharcPushLayoutProbe; +[[vk::binding(4, 0)]] StructuredBuffer sharcFrameLayoutProbe; [[vk::push_constant]] WorldPushConstants pushConstantsLayoutProbe; [shader("compute")] @@ -29,6 +40,9 @@ void main(uint3 id : SV_DispatchThreadID) { float sink = worldPushLayoutProbe[0].values[0].invViewProj[0][0] + materialHeaderLayoutProbe[0].values[0].params.x + exposureStateLayoutProbe[0].values[0].previous + + float(sharcPushLayoutProbe[0].values[0].sharcUpdateTileSize) + + float(sharcFrameLayoutProbe[0].values[0].capacity) + + worldPushLayoutProbe[0].values[0].mipMapBias + float(pushConstantsLayoutProbe.frameIndex); } } diff --git a/shaders/pipelines/display/bindings.slang b/shaders/pipelines/display/bindings.slang index 1aa138f6..e10a1ab1 100644 --- a/shaders/pipelines/display/bindings.slang +++ b/shaders/pipelines/display/bindings.slang @@ -6,3 +6,6 @@ [[vk::binding(5, 0)]] public Sampler3D hdrToneLut; [[vk::binding(6, 0)]] public Sampler3D lookLut; [[vk::binding(7, 0)]] public Sampler2D bloomImage; +[[vk::binding(8, 0)]] [format("r16f")] public RWTexture2D skyClassificationImage; +[[vk::binding(9, 0)]] public Sampler2D endSkyTexture; +[[vk::binding(10, 0)]] public Sampler2D celestialsAtlas; diff --git a/shaders/pipelines/display/main.comp.slang b/shaders/pipelines/display/main.comp.slang index b92d243d..96b5ac09 100644 --- a/shaders/pipelines/display/main.comp.slang +++ b/shaders/pipelines/display/main.comp.slang @@ -1,10 +1,11 @@ -// Maps the display-res scene-linear ACEScg RT image to sRGB SDR and, when enabled, PQ/BT.2020 HDR, -// via baked ACES 2.0 display-transform LUTs (see tools/bake_display_lut.py). Tonemap seam: the path tracer and DLSS-RR use scene-linear -// ACEScg; exposure is applied from the compositor-owned 1x1 image right here, followed by the selected -// scene-referred ACES look (LMT), then the shared ACES 2.0 output transform for each display. +// Maps the display-res scene-linear ACEScg RT image to sRGB SDR and, when enabled, PQ/BT.2020 HDR. +// The path tracer and DLSS-RR use scene-linear ACEScg; exposure is applied from the compositor-owned +// 1x1 image here. ACES 2.0 uses the baked display-transform LUTs and the package's scene-referred look +// (LMT); local analytical modes own their complete display rendering. import display_common; import bindings; +import tone_mapping; [[vk::push_constant]] DisplayPush pc; @@ -49,6 +50,28 @@ float3 sampleBloom(int2 outputPixel, uint outputWidth, uint outputHeight) { return bloomImage.SampleLevel(uv, 0.0).rgb; } +float skyAtRenderPixel(int2 renderPixel) { + uint renderWidth, renderHeight; + skyClassificationImage.GetDimensions(renderWidth, renderHeight); + int2 maxPixel = int2(renderWidth, renderHeight) - int2(1, 1); + return clamp(skyClassificationImage[clamp(renderPixel, int2(0, 0), maxPixel)], 0.0, 1.0); +} + +// Upscale the primary-sky classification with the same bilinear footprint as the display pixel. The +// separate display classification marks only a visible primary sky miss; transmitted sky remains a surface feature. +float skyCoverage(int2 pixel, uint displayWidth, uint displayHeight) { + uint renderWidth, renderHeight; + skyClassificationImage.GetDimensions(renderWidth, renderHeight); + float2 sampleCoord = (float2(pixel) + 0.5) * float2(renderWidth, renderHeight) + / float2(displayWidth, displayHeight) - 0.5; + int2 base = int2(floor(sampleCoord)); + float2 fraction = fract(sampleCoord); + return skyAtRenderPixel(base) * (1.0 - fraction.x) * (1.0 - fraction.y) + + skyAtRenderPixel(base + int2(1, 0)) * fraction.x * (1.0 - fraction.y) + + skyAtRenderPixel(base + int2(0, 1)) * (1.0 - fraction.x) * fraction.y + + skyAtRenderPixel(base + int2(1, 1)) * fraction.x * fraction.y; +} + // Artistic gamma after the view/display transform. Apply the curve to display luminance and scale // RGB uniformly instead of exponentiating each channel: this lifts shadows/midtones without pulling // chromaticities toward white. The uniform scale is limited at the display boundary so saturated @@ -84,6 +107,66 @@ float3 srgbDecode(float3 code) { return float3(srgbDecodeChannel(code.r), srgbDecodeChannel(code.g), srgbDecodeChannel(code.b)); } +static const float3 BT709_TO_ACESCG_R = float3(0.61309743, 0.33952314, 0.04737945); +static const float3 BT709_TO_ACESCG_G = float3(0.07019372, 0.91635388, 0.01345240); +static const float3 BT709_TO_ACESCG_B = float3(0.02061559, 0.10956977, 0.86981463); + +float3 bt709ToAcesCg(float3 bt709) { + return float3( + dot(bt709, BT709_TO_ACESCG_R), + dot(bt709, BT709_TO_ACESCG_G), + dot(bt709, BT709_TO_ACESCG_B)); +} + +float2 endSkyUv(float3 dir) { + float3 a = abs(dir); + float t = 100.0 / max(max(a.x, a.y), a.z); + float3 p = dir * t; + if (a.x >= a.y && a.x >= a.z) { + if (dir.x > 0.0) return (float2(p.y, p.z) + 100.0) * (16.0 / 200.0); + return (float2(-p.y, p.z) + 100.0) * (16.0 / 200.0); + } + if (a.y >= a.z) { + if (dir.y > 0.0) return (float2(p.x, -p.z) + 100.0) * (16.0 / 200.0); + return (float2(p.x, p.z) + 100.0) * (16.0 / 200.0); + } + if (dir.z > 0.0) return (float2(p.x, p.y) + 100.0) * (16.0 / 200.0); + return (float2(p.x, -p.y) + 100.0) * (16.0 / 200.0); +} + +float3 nativeEndFlash(float3 dir) { + if ((pc.skyFlags & 1u) == 0u) return float3(0.0); + static const float PI = 3.14159265359; + float y = PI - pc.skyParams.w; + float x = -0.5 * PI - pc.skyParams.z; + float cy = cos(y), sy = sin(y), cx = cos(x), sx = sin(x); + float3 right = float3(cy, 0.0, -sy); + float3 up = float3(sy * sx, cx, cy * sx); + float3 forward = float3(sy * cx, -sx, cy * cx); + float3 center = up * 100.0; + float denom = dot(dir, up); + if (abs(denom) < 1.0e-5) return float3(0.0); + float hitDistance = dot(center, up) / denom; + if (hitDistance <= 0.0) return float3(0.0); + float3 q = dir * hitDistance - center; + float2 local = float2(dot(q, right), dot(q, forward)) / 60.0; + if (max(abs(local.x), abs(local.y)) > 1.0) return float3(0.0); + float2 uv = lerp(pc.endFlashUv.xy, pc.endFlashUv.zw, local * 0.5 + 0.5); + float4 texel = celestialsAtlas.SampleLevel(uv, 0.0); + float intensity = pc.skyParams.y; + return srgbDecode(texel.rgb) * texel.a * intensity * intensity; +} + +float3 nativeEndSky(float2 uv) { + float2 ndc = uv * 2.0 - 1.0; + float4 nearH = mul(pc.invViewProj, float4(ndc, 1.0, 1.0)); + float4 farH = mul(pc.invViewProj, float4(ndc, 0.0, 1.0)); + float3 dir = normalize(farH.xyz / max(farH.w, 1.0e-6) - nearH.xyz / max(nearH.w, 1.0e-6)); + float4 texel = endSkyTexture.SampleLevel(endSkyUv(dir), 0.0); + float3 endSkyColor = srgbDecode(texel.rgb) * (40.0 / 255.0); + return lerp(pc.skyColor.rgb, endSkyColor, clamp(texel.a, 0.0, 1.0)) + nativeEndFlash(dir); +} + float3 srgbEncode(float3 linearColor) { return float3( srgbEncodeChannel(linearColor.r), @@ -99,6 +182,55 @@ float3 displayGammaSdr(float3 displayColor) { return clamp(srgbEncode(luminanceGamma(srgbDecode(code), BT709_LUMA)), 0.0, 1.0); } +// Local analytical operators were authored against scene-linear BT.709. The transport and the +// ACES 2.0 LUTs use ACEScg/AP1, so this conversion is explicit at the opt-in local seam; the default +// ACES 2.0 path never takes it. +static const float3 ACESCG_TO_BT709_R = float3(1.70505095, -0.62179214, -0.08325887); +static const float3 ACESCG_TO_BT709_G = float3(-0.13025641, 1.14080477, -0.01054832); +static const int PSYCHOV24_SDR_MODE = 8; +static const float3 ACESCG_TO_BT709_B = float3(-0.02400336, -0.12896897, 1.15297234); +float3 acescgToBt709Signed(float3 acesCg) { + return float3( + dot(acesCg, ACESCG_TO_BT709_R), + dot(acesCg, ACESCG_TO_BT709_G), + dot(acesCg, ACESCG_TO_BT709_B)); +} + +ToneMappingParameters sdrToneParameters() { + ToneMappingParameters p; + p.param0 = pc.sdrParam0; + p.param1 = pc.sdrParam1; + p.param2 = pc.sdrParam2; + p.param3 = pc.sdrParam3; + p.param4 = pc.sdrParam4; + p.param5 = pc.sdrParam5; + p.param6 = pc.sdrParam6; + p.param7 = pc.sdrParam7; + return p; +} + +ToneMappingParameters hdrToneParameters() { + ToneMappingParameters p; + p.param0 = pc.hdrParam0; + p.param1 = pc.hdrParam1; + p.param2 = pc.hdrParam2; + p.param3 = pc.hdrParam3; + p.param4 = pc.hdrParam4; + p.param5 = pc.hdrParam5; + p.param6 = pc.hdrParam6; + p.param7 = pc.hdrParam7; + return p; +} + +float3 localSdrToneMap(float3 exposedAcesCg) { + float3 bt709Signed = acescgToBt709Signed(exposedAcesCg); + ToneMappingParameters p = sdrToneParameters(); + float3 mapped = pc.sdrMode == PSYCHOV24_SDR_MODE + ? toneMapPsychoV24Sdr(bt709Signed, p) + : applyClassicToneMapper(pc.sdrMode, max(bt709Signed, float3(0.0)), p); + return displayGammaSdr(srgbEncode(clamp(mapped, 0.0, 1.0))); +} + static const float PQ_M1 = 0.1593017578125; static const float PQ_M2 = 78.84375; static const float PQ_C1 = 0.8359375; @@ -126,6 +258,15 @@ float3 displayGammaHdr(float3 pqColor) { return clamp(pqEncode(adjustedNits), 0.0, 1.0); } +float3 localHdrToneMap(float3 exposedAcesCg) { + float3 bt709Signed = acescgToBt709Signed(exposedAcesCg); + ToneMappingParameters p = hdrToneParameters(); + if (pc.hdrMode == 2) { + return toneMapPsychoV24Hdr(bt709Signed, pc.paperWhiteNits, pc.headroom, p); + } + return toneMapBt2390Hdr(max(bt709Signed, float3(0.0)), pc.paperWhiteNits, pc.headroom); +} + // Exposure -> log2 shaper -> trilinear LUT fetch. The LUT already bakes in ACES 2.0's gamut mapping to // BT.709 and the sRGB OETF, so its output goes straight to outputImage. Single mip (RtToneLut), so an // explicit level-0 SampleLevel is exact, not an approximation. @@ -141,6 +282,34 @@ float3 tonemapHdr(float3 lookedAcesCg) { return displayGammaHdr(hdrToneLut.SampleLevel(lutTexCoord(uvw, pc.lutSize), 0.0).rgb); } +// Fixed screen-space rank order for final SDR quantization. The permutation is stable across frames so +// the dither cannot enter DLSS-RR history or shimmer with camera jitter; the half-step thresholds make +// each rank's rounding decision unbiased over the tile. +static const float SKY_DITHER_RANK_8X8[64] = { + 0.281250, 0.968750, 0.375000, 0.859375, 0.250000, 0.937500, 0.406250, 0.812500, + 0.593750, 0.187500, 0.734375, 0.046875, 0.578125, 0.218750, 0.703125, 0.015625, + 0.453125, 0.765625, 0.328125, 0.921875, 0.484375, 0.796875, 0.343750, 0.890625, + 0.640625, 0.093750, 0.546875, 0.156250, 0.671875, 0.078125, 0.515625, 0.125000, + 0.265625, 0.953125, 0.421875, 0.828125, 0.296875, 0.984375, 0.390625, 0.843750, + 0.562500, 0.234375, 0.687500, 0.000000, 0.609375, 0.203125, 0.718750, 0.031250, + 0.468750, 0.781250, 0.359375, 0.875000, 0.437500, 0.750000, 0.312500, 0.906250, + 0.656250, 0.062500, 0.500000, 0.140625, 0.625000, 0.109375, 0.531250, 0.171875 +}; + +float3 ditherSkySdr(float3 displayColor, int2 pixel) { + uint2 tilePixel = uint2(pixel) & uint2(7u, 7u); + float rank = SKY_DITHER_RANK_8X8[int(tilePixel.y * 8u + tilePixel.x)]; + float threshold = rank + 0.5 / 64.0; + float3 code = clamp(displayColor, 0.0, 1.0) * 255.0; + float3 lower = floor(code); + float3 fraction = code - lower; + float3 roundUp = float3( + fraction.r > threshold ? 1.0 : 0.0, + fraction.g > threshold ? 1.0 : 0.0, + fraction.b > threshold ? 1.0 : 0.0); + return (lower + roundUp) / 255.0; +} + [shader("compute")] [numthreads(16, 16, 1)] void main(uint3 dispatchId : SV_DispatchThreadID) { @@ -153,12 +322,33 @@ void main(uint3 dispatchId : SV_DispatchThreadID) { float4 rt = rtImage[pix]; float exposure = max(exposureImage[int2(0, 0)], 0.0); - float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0)); + float3 sceneLinearAcesCg = max(rt.rgb, float3(0.0)); + float coverage = 0.0; + if (pc.skybox == 2u || (pc.hdrEnabled == 0 && pc.skybox == 1u)) { + coverage = skyCoverage(pix, w, h); + } + if (pc.skybox == 2u) { + sceneLinearAcesCg = lerp(sceneLinearAcesCg, + bt709ToAcesCg(max(nativeEndSky((float2(pix) + 0.5) / float2(w, h)), float3(0.0))), + clamp(coverage, 0.0, 1.0)); + } + float3 exposedAcesCg = sceneLinearAcesCg * exposure; exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0); - float3 lookedAcesCg = applyLook(exposedAcesCg); - outputImage[pix] = float4(tonemap(lookedAcesCg), 1.0); + float3 lookedAcesCg = exposedAcesCg; + if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { + lookedAcesCg = applyLook(exposedAcesCg); + } + float3 sdrColor = pc.sdrMode == 0 + ? tonemap(lookedAcesCg) + : localSdrToneMap(exposedAcesCg); + if (pc.hdrEnabled == 0 && pc.skybox == 1u && coverage >= 0.999) { + sdrColor = ditherSkySdr(sdrColor, pix); + } + outputImage[pix] = float4(sdrColor, 1.0); if (pc.hdrEnabled != 0) { - hdrImage[pix] = float4(tonemapHdr(lookedAcesCg), 1.0); + hdrImage[pix] = pc.hdrMode == 0 + ? float4(tonemapHdr(lookedAcesCg), 1.0) + : float4(displayGammaHdr(localHdrToneMap(exposedAcesCg)), 1.0); } } diff --git a/shaders/pipelines/display/psychov24.slang b/shaders/pipelines/display/psychov24.slang new file mode 100644 index 00000000..3b2a591f --- /dev/null +++ b/shaders/pipelines/display/psychov24.slang @@ -0,0 +1,514 @@ +/* + * Adapted from RenoDX PsychoV Test24. + * Pinned source commit: fc85b7b15585050442ba35412597ecefc9e04cea + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + * + * Caustica profile: fixed 0.18 adaptation/background anchors, SDR peak 1.0, + * HDR peak from the configured headroom, and gamut hue restoration disabled. + */ + +static const float PSYCHO24_EPSILON = 1.0e-6; +static const float PSYCHO24_TWO_PI = 6.2831853071795864769; +static const float PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7; +static const float PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.0; +static const float PSYCHO24_HEADROOM_RATIO_FALLBACK = 1.0; +static const float PSYCHO24_MIN_AUTO_COMPRESSION = 1.0; +static const float PSYCHO24_MIN_MANUAL_COMPRESSION = 1.0e-6; +static const float PSYCHO24_AUTO_COMPRESSION_SENTINEL = 0.0; +static const float PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.0; +static const float PSYCHO24_SHADOW_GRADE_RANGE_STOPS = 4.0; + +static const float3 PSYCHO24_LMS_WEIGHTS = float3(0.68990272, 0.34832189, 0.0371597069161); + +static const float3x3 PSYCHO24_BT709_TO_LMS_WEIGHTED = float3x3( + 0.199799812973072, 0.481032356328408, 0.0526887528474167, + 0.0314129769005886, 0.246385149505685, 0.0390828120286937, + 0.000577043431732311, 0.00199210614746666, 0.0189439379660955); + +static const float3x3 PSYCHO24_BT2020_TO_LMS_WEIGHTED = float3x3( + 0.270896642297020, 0.422251540658867, 0.0403727391345614, + 0.0207641953210756, 0.256739246826207, 0.0393774962470899, + 0.000366210725727965, 0.0000124019842897904, 0.0211344748353781); + +static const float3x3 PSYCHO24_LMS_WEIGHTED_TO_BT709 = float3x3( + 7.20046392938217, -14.1316973475976, 9.12814468829434, + -0.898217764419057, 5.89038676696474, -9.65411232470627, + -0.124875582354962, -0.188961374460433, 53.5244932911426); + +static const float3x3 PSYCHO24_XYZ_TO_LMS_WEIGHTED = float3x3( + 0.1842387318611145, 0.58448493480682373, -0.023942500352859497, + -0.13482454419136047, 0.40594476461410522, 0.035885117948055267, + 0.00099319696892052889, -0.0010141372913494706, 0.019818264991044998); +static const float2 PSYCHO24_D65_XY = float2(0.31272, 0.32903); + +float psycho24DivideSafe(float numerator, float denominator, float fallback) { + return denominator == 0.0 ? fallback : numerator / denominator; +} + +float3 psycho24DivideSafe(float3 numerator, float3 denominator, float3 fallback) { + return float3( + denominator.x == 0.0 ? fallback.x : numerator.x / denominator.x, + denominator.y == 0.0 ? fallback.y : numerator.y / denominator.y, + denominator.z == 0.0 ? fallback.z : numerator.z / denominator.z); +} + +float3 psycho24LmsFromBt709(float3 bt709) { + return mul(PSYCHO24_BT709_TO_LMS_WEIGHTED, bt709) / PSYCHO24_LMS_WEIGHTS; +} + +float3 psycho24Bt709FromLms(float3 lms) { + return mul(PSYCHO24_LMS_WEIGHTED_TO_BT709, lms * PSYCHO24_LMS_WEIGHTS); +} + +float3 psycho24MbFromWeighted(float3 weightedLms) { + float y = max(weightedLms.x + weightedLms.y, 0.0); + float inverseY = psycho24DivideSafe(1.0, y, 0.0); + return float3(weightedLms.x * inverseY, weightedLms.z * inverseY, y); +} + +float3 psycho24WeightedFromMb(float3 mb) { + return float3(mb.x, 1.0 - mb.x, mb.y) * mb.z; +} + +float3 psycho24ToAdaptiveRelativeWeightedLms(float3 lmsInput, float3 adaptiveStateLms) { + return psycho24DivideSafe(lmsInput * PSYCHO24_LMS_WEIGHTS, + adaptiveStateLms, float3(0.0)); +} + +float3 psycho24FromAdaptiveRelativeWeightedLms(float3 relativeWeightedLms, + float3 adaptiveStateLms) { + return relativeWeightedLms * max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); +} + +float3 psycho24UnweighLms(float3 weightedLms) { + return weightedLms / PSYCHO24_LMS_WEIGHTS; +} + +float2 psycho24Cie1702WhiteChromaticity() { + float2 xy = PSYCHO24_D65_XY; + float3 xyz = float3(xy.x / xy.y, 1.0, (1.0 - xy.x - xy.y) / xy.y); + return psycho24MbFromWeighted(mul(PSYCHO24_XYZ_TO_LMS_WEIGHTED, xyz)).xy; +} + +static const float PSYCHO24_CIE1702_RAY_T_MAX = 1.0e20; +static const float PSYCHO24_MB_NEAR_WHITE_EPSILON = 1.0e-14; +static const float PSYCHO24_INTERVAL_MAX = 3.402823466e+38; +static const int PSYCHO24_CIE1702_EDGE_COUNT = 7; +static const float2 PSYCHO24_CIE1702_HALFSPACE_NORMALS[PSYCHO24_CIE1702_EDGE_COUNT] = { + float2(-0.043889, -0.006807), + float2(-0.007821, -0.008564), + float2(-0.000604, -0.007942), + float2(0.0, -0.080835), + float2(0.953597, 0.307020), + float2(-0.060969, 0.019752), + float2(-0.106895, 0.004035) +}; +static const float PSYCHO24_CIE1702_HALFSPACE_NUMERATORS[PSYCHO24_CIE1702_EDGE_COUNT] = { + 0.0065035249, + 0.00104900495, + 0.000207697044, + 0.00165556648, + 0.252472349, + 0.0241967351, + 0.0199621232 +}; + +float psycho24RayExitCie1702(float2 origin, float2 direction) { + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return PSYCHO24_CIE1702_RAY_T_MAX; + } + + float2 whiteToOrigin = psycho24Cie1702WhiteChromaticity() - origin; + float tBest = PSYCHO24_CIE1702_RAY_T_MAX; + bool hitAny = false; + for (int i = 0; i < PSYCHO24_CIE1702_EDGE_COUNT; ++i) { + float2 normal = PSYCHO24_CIE1702_HALFSPACE_NORMALS[i]; + float denominator = dot(normal, direction); + float numerator = PSYCHO24_CIE1702_HALFSPACE_NUMERATORS[i] + + dot(normal, whiteToOrigin); + float t = denominator > 1.0e-8 + ? numerator * rcp(denominator) + : PSYCHO24_CIE1702_RAY_T_MAX; + tBest = min(tBest, t); + hitAny = hitAny || denominator > 1.0e-8; + } + return hitAny ? max(tBest, 0.0) : PSYCHO24_CIE1702_RAY_T_MAX; +} + +float psycho24RayExitCie1702FromWhite(float2 direction) { + return psycho24RayExitCie1702(psycho24Cie1702WhiteChromaticity(), direction); +} + +float psycho24Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +bool psycho24RaySegmentHit2D(float2 origin, float2 direction, float2 a, float2 b, + out float tHit) { + tHit = 0.0; + float2 edge = b - a; + float denominator = psycho24Cross2(direction, edge); + if (abs(denominator) <= 1.0e-20) { + return false; + } + float2 aToOrigin = a - origin; + float t = psycho24Cross2(aToOrigin, edge) / denominator; + float u = psycho24Cross2(aToOrigin, direction) / denominator; + if (t < 0.0 || u < 0.0 || u > 1.0) { + return false; + } + tHit = t; + return true; +} + +float3 psycho24RgbToWeightedPrimary(float3x3 rgbToLmsWeighted, uint primaryIndex) { + return float3(rgbToLmsWeighted[0][primaryIndex], + rgbToLmsWeighted[1][primaryIndex], rgbToLmsWeighted[2][primaryIndex]); +} + +float2 psycho24MbFromWeightedPrimary(float3 weightedPrimary) { + return psycho24MbFromWeighted(weightedPrimary).xy; +} + +void psycho24MakeRgbTriangleInMbAdaptiveWeighted(float3x3 rgbToLmsWeighted, + float3 adaptiveStateLms, out float2 r, out float2 g, out float2 b) { + r = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 0) + / adaptiveStateLms); + g = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 1) + / adaptiveStateLms); + b = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 2) + / adaptiveStateLms); +} + +float psycho24RayMaxTRgbTriangleInMb(float2 origin, float2 direction, + float2 r, float2 g, float2 b, out bool hasSolution) { + hasSolution = false; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return 0.0; + } + + float tBest = PSYCHO24_INTERVAL_MAX; + float tHit; + bool hitAny = false; + if (psycho24RaySegmentHit2D(origin, direction, r, g, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + if (psycho24RaySegmentHit2D(origin, direction, g, b, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + if (psycho24RaySegmentHit2D(origin, direction, b, r, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + hasSolution = hitAny; + return hitAny ? max(tBest, 0.0) : 0.0; +} + +float psycho24NeutwoPeakClip(float x, float peak, float clip) { + float peakSafe = max(peak, 0.0); + float clipSafe = max(clip, peakSafe); + float x2 = x * x; + float clip2 = clipSafe * clipSafe; + float peak2 = peakSafe * peakSafe; + float denominatorSquared = mad(x2, clip2 - peak2, clip2 * peak2); + return clipSafe * peakSafe * x * rsqrt(max(denominatorSquared, 1.0e-20)); +} + +float psycho24NeutwoScaleFromRayT(float tPeak, float tClip) { + float tPeakSafe = max(tPeak, 0.0); + float tClipSafe = max(tClip, tPeakSafe); + return saturate(psycho24NeutwoPeakClip(1.0, tPeakSafe, tClipSafe)); +} + +float psycho24SoftCompressionActivationFromRayT(float tPeak) { + float outside = 1.0 - saturate(tPeak); + return psycho24DivideSafe(outside, outside + 0.08, 0.0); +} + +float3 psycho24ClampWeightedLmsToCie1702(float3 weightedInput) { + float3 weightedClamped = max(weightedInput, float3(0.0)); + float3 mb = psycho24MbFromWeighted(weightedClamped); + float y = mb.z; + if (!(y > 1.0e-20)) { + return float3(weightedClamped.x, weightedClamped.y, 0.0); + } + + float2 white = psycho24Cie1702WhiteChromaticity(); + float2 direction = mb.xy - white; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return weightedClamped; + } + float tClip = psycho24RayExitCie1702FromWhite(direction); + float2 lsOut = white + direction * min(1.0, tClip); + return psycho24WeightedFromMb(float3(lsOut, y)); +} + +float3 psycho24GamutCompressWeightedLmsCore(float3 weightedInput, + float3x3 boundRgbToLmsWeighted, float3 adaptiveStateLms, float strength) { + float3 weightedClamped = psycho24ClampWeightedLmsToCie1702(max(weightedInput, float3(0.0))); + float3 mb = psycho24MbFromWeighted(weightedClamped); + float y = mb.z; + if (!(y > 1.0e-20)) { + return float3(weightedClamped.x, weightedClamped.y, 0.0); + } + + float2 white = psycho24Cie1702WhiteChromaticity(); + float2 direction = mb.xy - white; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return weightedClamped; + } + + float2 boundR; + float2 boundG; + float2 boundB; + psycho24MakeRgbTriangleInMbAdaptiveWeighted( + boundRgbToLmsWeighted, adaptiveStateLms, boundR, boundG, boundB); + bool hasPeak; + float tPeak = psycho24RayMaxTRgbTriangleInMb( + white, direction, boundR, boundG, boundB, hasPeak); + float tClip = psycho24RayExitCie1702FromWhite(direction); + if (!hasPeak) { + tPeak = tClip; + } + + float tHard = saturate(tPeak); + float tSoft = psycho24NeutwoScaleFromRayT(min(tPeak, tClip), tClip); + float softMix = saturate(strength) * psycho24SoftCompressionActivationFromRayT(tPeak); + float tFinal = lerp(tHard, tSoft, softMix); + return psycho24WeightedFromMb(float3(white + tFinal * direction, y)); +} + +float psycho24YfFromLms(float3 lms) { + float3 weighted = lms * PSYCHO24_LMS_WEIGHTS; + return max(weighted.x + weighted.y, PSYCHO24_EPSILON); +} + +float psycho24QuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +float psycho24HighlightsScalarV4(float x, float highlights, float adaptedAnchorYf) { + if (highlights == 1.0) { + return x; + } + float t = 0.0; + if (x > adaptedAnchorYf) { + float referenceRangeLog2 = log2(PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE + / max(adaptedAnchorYf, PSYCHO24_EPSILON)); + t = saturate(log2(x / max(adaptedAnchorYf, PSYCHO24_EPSILON)) + / max(referenceRangeLog2, PSYCHO24_EPSILON)); + } + t = psycho24QuinticUnitRamp(t); + float ratio = max(x / max(adaptedAnchorYf, PSYCHO24_EPSILON), PSYCHO24_EPSILON); + if (highlights > 1.0) { + return lerp(x, adaptedAnchorYf * pow(ratio, highlights), t); + } + float b = adaptedAnchorYf * pow(ratio, 2.0 - highlights); + return psycho24DivideSafe(x * x, lerp(x, b, t), x); +} + +float psycho24ShadowsScalarV4(float x, float shadows, float adaptedAnchorYf) { + if (shadows == 1.0) { + return x; + } + float ratio = max(psycho24DivideSafe(x, adaptedAnchorYf, 0.0), 0.0); + float baseTerm = x * adaptedAnchorYf; + float baseScale = psycho24DivideSafe(baseTerm, ratio, 0.0); + float shadowFloor = adaptedAnchorYf * exp2(-PSYCHO24_SHADOW_GRADE_RANGE_STOPS); + float t = 1.0; + if (x > shadowFloor) { + t = saturate(log2(x / max(adaptedAnchorYf, PSYCHO24_EPSILON)) + / log2(shadowFloor / max(adaptedAnchorYf, PSYCHO24_EPSILON))); + } + t = psycho24QuinticUnitRamp(t); + if (shadows > 1.0) { + float raised = x * (1.0 + psycho24DivideSafe(baseTerm, + pow(max(ratio, PSYCHO24_EPSILON), shadows), 0.0)); + float reference = x * (1.0 + baseScale); + return x + (raised - reference) * t; + } + float lowered = x * (1.0 - psycho24DivideSafe(baseTerm, + pow(max(ratio, PSYCHO24_EPSILON), 2.0 - shadows), 0.0)); + float reference = x * (1.0 - baseScale); + return x + (lowered - reference) * t; +} + +float psycho24AutoCompressionFromCenteredReferenceRange(float anchorOutYf, float peakYf) { + float peakOverAnchor = psycho24DivideSafe(max(peakYf, PSYCHO24_EPSILON), + max(anchorOutYf, PSYCHO24_EPSILON), PSYCHO24_HEADROOM_RATIO_FALLBACK); + peakOverAnchor = max(peakOverAnchor, 1.0 + PSYCHO24_EPSILON); + float referenceOneSideRangeLog10 = PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 + / PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT; + float actualAboveAdaptationRangeLog10 = max(log10(peakOverAnchor), PSYCHO24_EPSILON); + return max(referenceOneSideRangeLog10 / actualAboveAdaptationRangeLog10, + PSYCHO24_MIN_AUTO_COMPRESSION); +} + +float3 psycho24ApplyAdaptiveMbPurity(float3 lmsInput, float3 adaptiveNeutralLms, + float purityDelta) { + if (abs(purityDelta - 1.0) <= 1.0e-5) { + return lmsInput; + } + float3 relativeWeighted = psycho24ToAdaptiveRelativeWeightedLms(lmsInput, adaptiveNeutralLms); + float3 mb = psycho24MbFromWeighted(relativeWeighted); + float3 mbNeutral = psycho24MbFromWeighted(PSYCHO24_LMS_WEIGHTS); + float2 mbScaled = lerp(mbNeutral.xy, mb.xy, purityDelta); + return psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + psycho24WeightedFromMb(float3(mbScaled, mb.z)), adaptiveNeutralLms)); +} + +static const uint PSYCHO24_MANUAL_HUE_COUNT = 23u; +static const float PSYCHO24_MANUAL_HUE_POSITION[PSYCHO24_MANUAL_HUE_COUNT] = { + 0.01071375, 0.10705012, 0.12795984, 0.15335225, 0.18766853, 0.22076293, + 0.24936653, 0.27634237, 0.29474511, 0.31129214, 0.35078118, 0.39136371, + 0.47262991, 0.49426816, 0.54698948, 0.60705013, 0.68311772, 0.81129214, + 0.91306421, 0.93498424, 0.94625976, 0.96664602, 0.97262991 +}; +static const float PSYCHO24_MANUAL_HUE_X[PSYCHO24_MANUAL_HUE_COUNT] = { + 0.517681, 0.675575, 0.691365, 0.691365, 0.665049, 0.680839, + 0.661513, 0.654523, 0.648355, 0.640461, 0.556250, 0.519408, + 0.450987, 0.435197, 0.424671, 0.464145, 0.516776, 0.608882, + 0.690461, 0.606250, 0.553618, 0.514145, 0.482566 +}; + +float psycho24SampleManualHueLinearity(float sourceHuePhase) { + sourceHuePhase -= floor(sourceHuePhase); + uint lowerIndex = PSYCHO24_MANUAL_HUE_COUNT - 1u; + for (uint i = 0u; i < PSYCHO24_MANUAL_HUE_COUNT; ++i) { + if (sourceHuePhase >= PSYCHO24_MANUAL_HUE_POSITION[i]) { + lowerIndex = i; + } + } + uint upperIndex = (lowerIndex + 1u) % PSYCHO24_MANUAL_HUE_COUNT; + float lowerPosition = PSYCHO24_MANUAL_HUE_POSITION[lowerIndex]; + float upperPosition = upperIndex == 0u + ? PSYCHO24_MANUAL_HUE_POSITION[0] + 1.0 + : PSYCHO24_MANUAL_HUE_POSITION[upperIndex]; + if (upperIndex == 0u && sourceHuePhase < lowerPosition) { + sourceHuePhase += 1.0; + } + float t = saturate(psycho24DivideSafe(sourceHuePhase - lowerPosition, + upperPosition - lowerPosition, 0.0)); + return lerp(PSYCHO24_MANUAL_HUE_X[lowerIndex], PSYCHO24_MANUAL_HUE_X[upperIndex], t); +} + +float3 psycho24ApplyManualHueDirection(float3 compressedLms, float3 directionSourceLms, + float3 adaptiveStateLms, float toWhiteProgress) { + float3 compressedRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + compressedLms, adaptiveStateLms); + float3 sourceRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + directionSourceLms, adaptiveStateLms); + float3 compressedMb = psycho24MbFromWeighted(compressedRelativeWeighted); + float3 sourceMb = psycho24MbFromWeighted(sourceRelativeWeighted); + float2 adaptedNeutralMb = psycho24MbFromWeighted(PSYCHO24_LMS_WEIGHTS).xy; + float2 compressedOffset = compressedMb.xy - adaptedNeutralMb; + float2 sourceOffset = sourceMb.xy - adaptedNeutralMb; + float compressedRadius2 = dot(compressedOffset, compressedOffset); + float sourceRadius2 = dot(sourceOffset, sourceOffset); + if (compressedRadius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON + || sourceRadius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { + return compressedLms; + } + + float sourceHuePhase = atan2(sourceOffset.y, sourceOffset.x) / PSYCHO24_TWO_PI; + sourceHuePhase -= floor(sourceHuePhase); + float amount = lerp(1.0, psycho24SampleManualHueLinearity(sourceHuePhase), + saturate(toWhiteProgress)); + float compressedRadius = sqrt(compressedRadius2); + float2 compressedDirection = compressedOffset / compressedRadius; + float2 sourceDirection = sourceOffset * rsqrt(sourceRadius2); + float2 outputDirection = lerp(compressedDirection, sourceDirection, amount); + float outputDirection2 = dot(outputDirection, outputDirection); + if (outputDirection2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { + return compressedLms; + } + outputDirection *= rsqrt(outputDirection2); + float3 restoredMb = float3(adaptedNeutralMb + outputDirection * compressedRadius, + compressedMb.z); + return psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + psycho24WeightedFromMb(restoredMb), adaptiveStateLms)); +} + +float3 psycho24CopySign(float3 magnitude, float3 source) { + static const uint PSYCHO24_FLOAT_SIGN = 0x80000000u; + static const uint PSYCHO24_FLOAT_MAGNITUDE = 0x7fffffffu; + uint3 signBits = asuint(source) & PSYCHO24_FLOAT_SIGN; + uint3 magnitudeBits = asuint(magnitude) & PSYCHO24_FLOAT_MAGNITUDE; + return asfloat(signBits | magnitudeBits); +} + +float3 psycho24Core(float3 bt709LinearInput, float peakValue, float compression, + float gamutCompression, int gamutCompressionMode, float highlights, float shadows, + float contrast, float purity) { + float3 lmsIn = psycho24LmsFromBt709(bt709LinearInput); + float3 lmsPeak = psycho24LmsFromBt709(float3(peakValue)); + float3 adaptiveStateLms = psycho24LmsFromBt709(float3(0.18)); + float3 anchorIn = max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); + float3 anchorOut = max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); + float contrastPower = max(contrast, PSYCHO24_EPSILON); + + float3 gradedLms = abs(lmsIn); + float gradedYf = psycho24YfFromLms(gradedLms); + float adaptedAnchorYf = psycho24YfFromLms(anchorIn); + float gradedYfOut = psycho24HighlightsScalarV4(gradedYf, highlights, adaptedAnchorYf); + gradedYfOut = psycho24ShadowsScalarV4(gradedYfOut, shadows, adaptedAnchorYf); + gradedLms *= psycho24DivideSafe(gradedYfOut, gradedYf, 1.0); + + float purityDelta = psycho24DivideSafe(max(purity, PSYCHO24_EPSILON), contrastPower, 1.0); + float3 contrastInput = psycho24ApplyAdaptiveMbPurity(gradedLms, anchorIn, purityDelta); + float3 contrastRatio = max(contrastInput / anchorIn, float3(PSYCHO24_EPSILON)); + float3 contrastLms = anchorOut * pow(contrastRatio, float3(contrastPower)); + + float compressionPower = compression; + if (compression == PSYCHO24_AUTO_COMPRESSION_SENTINEL) { + compressionPower = psycho24AutoCompressionFromCenteredReferenceRange( + psycho24YfFromLms(anchorOut), psycho24YfFromLms(lmsPeak)); + } + compressionPower = max(compressionPower, PSYCHO24_MIN_MANUAL_COMPRESSION); + + float3 anchorOverPeak = anchorOut / max(lmsPeak, float3(PSYCHO24_EPSILON)); + float3 compressionSlopeNorm = 1.0 + - pow(max(anchorOverPeak, float3(PSYCHO24_EPSILON)), float3(compressionPower)); + float3 compressionInput = pow(max(contrastLms / anchorOut, float3(PSYCHO24_EPSILON)), + float3(compressionPower) / max(compressionSlopeNorm, float3(PSYCHO24_EPSILON))); + float3 compressionWhiteOffset = pow(max(lmsPeak / anchorOut, float3(PSYCHO24_EPSILON)), + float3(compressionPower)) - float3(1.0); + float3 compressionRolloff = pow(compressionInput + / max(compressionInput + compressionWhiteOffset, float3(PSYCHO24_EPSILON)), + float3(1.0 / compressionPower)); + float3 compressedLms = lmsPeak * compressionRolloff; + float toWhiteProgress = max(compressionRolloff.x, + max(compressionRolloff.y, compressionRolloff.z)); + + float3 hueRestoredLms = psycho24ApplyManualHueDirection( + compressedLms, contrastInput, adaptiveStateLms, toWhiteProgress); + float3 displayScaled = psycho24CopySign(hueRestoredLms, lmsIn); + float3 displayScaledRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + displayScaled, adaptiveStateLms); + if (gamutCompression != 0.0) { + if (gamutCompressionMode == 0) { + displayScaledRelativeWeighted = psycho24GamutCompressWeightedLmsCore( + displayScaledRelativeWeighted, PSYCHO24_BT709_TO_LMS_WEIGHTED, + adaptiveStateLms, gamutCompression); + } else if (gamutCompressionMode == 1) { + displayScaledRelativeWeighted = psycho24GamutCompressWeightedLmsCore( + displayScaledRelativeWeighted, PSYCHO24_BT2020_TO_LMS_WEIGHTED, + adaptiveStateLms, gamutCompression); + } + } + + float3 outputLms = psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + displayScaledRelativeWeighted, adaptiveStateLms)); + return psycho24Bt709FromLms(outputLms); +} + +public float3 psychoV24(float3 bt709LinearInput, float peakValue, float compression, + float gamutCompression, int gamutCompressionMode, float highlights, float shadows, + float contrast, float purity) { + return psycho24Core(bt709LinearInput, peakValue, compression, gamutCompression, + gamutCompressionMode, highlights, shadows, contrast, purity); +} diff --git a/shaders/pipelines/display/test30.hlsl b/shaders/pipelines/display/test30.hlsl new file mode 100644 index 00000000..07c11df0 --- /dev/null +++ b/shaders/pipelines/display/test30.hlsl @@ -0,0 +1,1544 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ + +#include "../../color/rgb.hlsl" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// PsychoV30: selected Mean-A2 / physiological-Yf response +// ========================================================= +// +// Signal contract +// --------------- +// Input and output are direct linear-light BT.709 RGB with D65 white. +// `peak_value` expresses display peak in reference-white-relative units. +// The target volume is the normalized linear BT.709 RGB cube for mode 0 or +// the normalized linear BT.2020 RGB cube for every other mode. Output remains +// represented as linear BT.709 even when the constrained target is BT.2020. +// +// Scientific basis and engineering stages +// --------------------------------------- +// - RGB is transformed to the Stockman/CVRL two-degree LMS basis. +// - The achromatic coordinate is physiological Yf from the +// Stockman-Sharpe LMS-to-XfYfZf transform: +// +// Yf = cL * L + cM * M +// +// Yf is the relative observer coordinate formed by weighted L and M cone +// responses. +// - Purity is direct LMS interpolation toward the adapting neutral while +// retaining the adaptation-relative Yf coordinate. It does not require +// MacLeod-Boynton coordinates or short-wave weighting. +// - CIE 170-2 weighted MacLeod-Boynton chromaticity is isolated to the signed +// fallback's source-boundary continuation. Its metric remains a successor +// candidate for replacement by a coordinate consistent with the A2 path. +// - Adaptation-relative cone ratios are consistent with the early-cone +// background-normalization framework discussed by Stockman and Brainard. +// - The finite endpoint, Mean-A2 direction, and locked-direction target-cube +// projection are the rendering-response and device-mapping stages. +// +// Positive-cone response +// ---------------------- +// Let q_i = LMS_i / anchor_in_i, P_i = peak_value * D65_LMS_i, +// p = contrast * cone_response_exponent, and +// k_i = pow(anchor_out_i / P_i, h). Test30 evaluates: +// +// beta_i = p * h / (1 - k_i) +// e_i = 1 / (1 + (1 / k_i - 1) * pow(q_i, -beta_i)) +// u_i = pow(e_i, 1 / h) +// +// The conceptual response is P_i * u_i. The reciprocal form remains finite +// when the corresponding positive power overflows. It preserves +// anchor_in -> anchor_out, has +// adaptation-point logarithmic slope p, approaches zero as q -> 0, and +// approaches selected peak white as q -> infinity. +// +// Mean-A2 direction +// ----------------- +// A2 denotes this shader's internal orthonormal cone-opponent plane. For +// normalized cone load u: +// +// X = (uL - uM) / sqrt(2) +// C0 = (uL + uM + uS) / sqrt(3) +// Z = (2 * uS - uL - uM) / sqrt(6) +// +// Source A2 direction comes from adaptation-relative q; response A2 direction +// comes from peak-relative post-G u. Normalizing and adding the two directions +// gives their exact angular bisector. Test30 retains the response A2 radius +// and C0, replacing direction only. +// +// Exact target solve +// ------------------ +// With D65 Yf fractions alphaL + alphaM = 1, the normalized physiological +// coordinate represented by (X, C0, Z) is: +// +// A = C0 / sqrt(3) + (alphaL - alphaM) * X / sqrt(2) +// - Z / sqrt(6) +// +// Target RGB is affine in A, X, and Z. Locking the authored A2 direction and +// scaling (X, Z) by s makes all lower/upper RGB-cube planes and the response +// Yf ceiling linear inequalities in (C0, s). The feasible set is a convex +// polygon. Segment projection uses +// +// distance^2 = delta_C0^2 + (X^2 + Z^2) * delta_s^2 +// +// which is exactly Euclidean distance in the original (X, C0, Z) coordinate +// under the locked direction. Full compression analytically finds the nearest +// point inside this fixed-direction model's four-edge feasible polygon. +// +// Cone states containing zero or negative values use the separately documented +// signed linear-A2 fallback with analytic target RGB-cube ray support. +// +// PsychoV research record +// ======================= +// +// This record is carried forward through PsychoV tests so each successor keeps +// the scientific rationale, source attribution, selected implementation, and +// next research directions beside the shader that ships. Test30 extends the +// Test17-Test25 record with Mean-A2 response authoring and an exact +// fixed-direction device-cube projection. +// +// Research objective and system boundary +// -------------------------------------- +// PsychoV studies two coupled systems: +// +// 1. Observer-side organization: receptor coordinates, adaptation-relative +// cone state, achromatic and opponent coordinates, response shaping, and +// visibility/gain mechanisms supported by vision research. +// 2. Device-hull mapping: a joint tone, direction, and target-volume solve +// constrained by display primaries, white, reference-white scale, and peak. +// +// Test30's selected rendering pipeline is: +// +// linear-light BT.709 +// -> Stockman/CVRL LMS +// -> scalar physiological-Yf grading +// -> adaptation-relative LMS purity +// -> adaptation-relative common cone power +// -> anchor-matched finite per-cone G +// -> Mean-A2 direction with post-G radius and C0 +// -> exact fixed-direction target RGB-cube/Yf projection +// -> linear-light BT.709 representation +// +// The caller supplies the current adaptation and desired output-background +// anchors. The runtime signal is reference-white-relative. Absolute retinal +// scale, local/temporal adaptation estimation, visibility thresholds, and +// cortical gain form explicit successor-test research directions below. +// +// 1) Receptor basis and observer coordinates +// ------------------------------------------ +// Brainard's Colorimetry chapter supplies the cone-stage/color-match +// foundation. Stockman and Brainard build on that receptor basis for +// first-site and second-site adaptation. Test30 transforms linear-light +// BT.709 through XYZ to the Stockman/CVRL two-degree LMS fit. +// +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// The published Stockman-Sharpe fundamentals include standard prereceptoral +// lens and macular filtering for an average, mainly foveal two-degree observer. +// CVRL documents the ocular-media and macular-pigment filters, their strong +// short-wavelength absorption, and their observer variation. Successor tests +// can expose age, field size, eccentricity, lens, and macular assumptions when +// personalized observer transforms become an input. +// +// Sources: +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// Test30's selected positive-cone path carries physiological Yf: +// +// physiological Yf = cL * L + cM * M +// +// where cL and cM come directly from the Yf row of the base +// Stockman-Sharpe LMS-to-XfYfZf transform. The selected purity and response +// stages operate directly in LMS and Yf and do not use an S-cone weight. +// +// The signed fallback separately retains CIE 170-2 weighted +// MacLeod-Boynton chromaticity for source-boundary continuation: +// +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// +// MacLeod-Boynton (1979) supplies the classic weighted-cone chromaticity +// construction. CVRL/CIE physiological data and repository constants supply +// the exact coefficients used here. Psychtoolbox documents a practical +// CIE-based LMS-to-MacLeod-Boynton implementation. Webster and Leonard use a +// modified MB framework for adaptation norms. Mantiuk et al. describe a +// practical LMS scaling whose L+M sum carries an achromatic coordinate. +// +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// MacLeod & Boynton, JOSA 1979, doi:10.1364/JOSA.69.001183 +// Webster & Leonard, JOSA A 2008, doi:10.1364/JOSAA.25.002817 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation +// ------------------------ +// Stockman and Brainard express first-site L-cone contrast as +// +// C_L = delta_L / (L_b + L_0) +// +// with corresponding M- and S-cone forms. Equivalently, the background sets +// the cone gain: +// +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// +// Test30 receives caller-authored adaptation LMS as `anchor_in` and uses +// q_i = LMS_i / anchor_in_i as its static background-relative state. This +// preserves the architecture of cone-specific normalization while keeping +// adaptation policy in the caller. A successor with image/retinal context can +// estimate L_b, M_b, S_b and semi-saturation L_0, M_0, S_0 over space and time. +// +// Stockman et al. describe first-site regulation across light levels and the +// transition toward bleaching-supported high-light sensitivity regulation. +// Source: JOV 2006, doi:10.1167/6.11.5. +// +// Webster and Leonard distinguish a response norm, the adapting level that +// leaves white judgments unbiased, from a perceptual norm, the stimulus that +// appears white. Their experiments found close tracking between these norms. +// PsychoV uses adapted-background reference for the directly carried cone +// state and retains response/perceptual norms as higher-level interpretations +// of the current neutral coding state. +// Source: JOSA A 2008, doi:10.1364/JOSAA.25.002817. +// +// CVRL documents observing-condition and chromatic-adaptation dependence in +// physiological luminosity functions, while cone spectral sensitivities stay +// stable through ordinary adaptation levels. This supports carrying Yf with +// the current adapted observer state. +// Source: http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise extension +// ---------------------------- +// Cone-mediated detection reaches a quantal/transduction-noise regime before +// rod-dominated vision. Approximate De Vries-Rose behavior gives threshold +// cone contrast a log-log slope near -0.5 against retinal illuminance. Higher +// adaptation levels approach Weber-like behavior, where threshold contrast is +// approximately constant relative to background. A calibrated successor can +// use retinal illuminance and cone-specific noise to attenuate scene +// differences below this visibility floor before postreceptoral processing. +// +// Stockman and Brainard discuss the range where cone-contrast coordinates +// approach Weber behavior. Angueyra and Rieke measure primate-cone +// phototransduction noise and its contribution to the dim-light threshold. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// Angueyra & Rieke, Nature Neuroscience 2013, doi:10.1038/nn.3534 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// +// 2b) High-light bleaching extension +// ----------------------------------- +// A retinal-illuminance-calibrated successor can represent steady-state cone +// pigment availability with the Rushton-Henry form +// +// p_available(I) = 1 / (1 + I / I0) +// +// and the complementary bleached fraction +// +// p_bleached(I) = I / (I + I0), I0 approximately 10^4.3 Td. +// +// Physiological placement follows adaptation-state definition and precedes +// postreceptoral opponent response and pooled gain. A rendering realization +// can apply availability to cone excursions around the adapted-white anchor, +// approaching equal white at the carried achromatic level as availability +// approaches zero. Test30's selected highlight endpoint is the finite-G +// equation documented above; the bleaching equations remain a calibrated +// successor path tied to retinal units. +// +// Sources: +// Stockman et al., JOV 2006, doi:10.1167/6.11.5 +// Stockman et al., JOV 2018, doi:10.1167/18.6.12 +// Rushton & Henry, Vision Research 1968, +// doi:10.1016/0042-6989(68)90040-0 +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// +// 3) Background-normalized opponent organization +// ------------------------------------------------ +// Test30 applies adaptation-relative purity directly in LMS, then constructs +// A2 as an orthonormal decomposition of the three adaptation/peak-normalized +// cone loads. A2 supplies an exact Euclidean metric and sixfold cone-axis +// geometry for Mean-A2 direction authoring and target projection. The signed +// fallback still uses weighted MacLeod-Boynton chromaticity for one +// source-boundary trace; this is not part of the selected positive path and +// should be revisited alongside a fitted ACC/DKL or A2-consistent fallback. +// +// 4) Saturating response research +// ------------------------------- +// Michaelis-Menten/Naka-Rushton response families provide receptor and +// early-cortical contrast models; supersaturating forms capture additional +// cortical response shapes. Peirce analyzes how saturating and supersaturating +// contrast response functions affect visual-cortex interpretation. +// Source: Peirce, JOV 2007, doi:10.1167/7.6.13. +// +// Test30 selects the anchor-preserving finite per-cone G above. Its reciprocal +// parameterization fixes the caller's input/output anchor, logarithmic slope, +// and selected peak endpoint. This creates a controlled rendering response for +// direct comparison with future fitted receptor or cortical response models. +// +// 5) ON/OFF response research +// --------------------------- +// Retinal ON and OFF channels separate increments and decrements around an +// adapted background. Schiller reviews their parallel visual-system roles. +// Yu, Turner, Baudin, and Rieke show that cone adaptation and downstream +// nonlinearities can combine unexpectedly for natural-image structure, +// motivating natural-image validation of any explicit polarity split. +// +// Rahimi-Nasrabadi et al. validate an ONOFF image algorithm on calibrated +// grayscale images and propose color extension through a scalar lightness +// dimension. PsychoV's scalar-Yf highlight/shadow grade follows the analogous +// engineering principle of applying polarity-shaped grades to one achromatic +// coordinate while retaining cone ratios. +// +// Sources: +// Schiller, Trends Neurosci 1992, +// doi:10.1016/0166-2236(92)90017-3 +// Yu et al., eLife 2022, doi:10.7554/eLife.70611 +// Rahimi-Nasrabadi et al., Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692 +// +// Test30's automatic finite-G curve uses a centered static log-range prior. +// A successor ON/OFF stage can fit separate increment/decrement responses and +// preserve the same adaptation anchor and device-hull coupling. +// +// 6) Pooled divisive gain research +// -------------------------------- +// Divisive normalization models pooled neural response as a channel drive +// divided by a semi-saturated measure of neighboring/population activity. +// This supplies a research path for coupled achromatic/opponent energy, +// spatial context, and contrast-dependent gain after polarity processing. +// +// Sources: +// Heeger, Visual Neuroscience 1992, +// doi:10.1017/S0952523800009640 +// Carandini & Heeger, Nature Reviews Neuroscience 2012, +// doi:10.1038/nrn3136 +// Bun & Horwitz, Color Research & Application 2023, +// doi:10.1002/col.22903 +// +// A successor implementation can add fitted pooling neighborhoods and +// semi-saturation constants after a selected opponent/ON-OFF stage. Test30 +// supplies a static per-pixel response baseline for that comparison. +// +// 7) Unified device-hull tone and gamut mapping +// --------------------------------------------- +// Display mapping is constrained by the complete target RGB volume. In +// normalized target coordinates this is +// +// 0 <= R,G,B <= 1. +// +// Lower and upper channel planes, faces, edges, corners, and neutral-axis +// capacity participate in one device-hull problem. High-purity directions can +// reach a target face at a lower achromatic level than D65, so a joint solve +// trades radial opponent distance and achromatic coordinate according to the +// selected metric. ITU-R BT.2408 supplies the practical HDR Reference White +// framing that keeps reference/diffuse white distinct from display peak. +// Source: https://www.itu.int/pub/R-REP-BT.2408 +// +// Test30 fixes the Mean-A2 authored direction and projects exactly in the full +// orthonormal (X,C0,Z) metric over the resulting convex target-cube/Yf polygon. +// This extends Test25's numerical ray support into an analytic nearest-point +// solve for the selected direction. BT.709 and BT.2020 modes share the same +// D65 cone normalization and use their respective complete RGB cubes. +// +// A successor sectional solve can search multiple directions within the +// active cone-axis sextant, include a fitted postreceptoral metric, and compare +// face/edge/interior candidates. Mean-A2 remains the preferred authored +// trajectory candidate and Test30 remains the exact fixed-direction baseline. +// +// 7a) Hue-objective research inside the hull solve +// ------------------------------------------------ +// Mizokami et al. and O'Neil et al. study a functional account of the Abney +// effect based on an equivalent Gaussian spectral peak. For short and medium +// wavelengths, the equivalent-peak parameter can provide a hue objective as +// purity changes. A future spectral precomputation can map weighted-LMS/MB +// chromaticity to mu_eq and evaluate mu_eq alongside A2/ACC direction during +// target-hull optimization while carrying Yf separately. +// +// Sources: +// Mizokami et al., JOV 2006, doi:10.1167/6.9.12 +// O'Neil et al., JOSA A 2012, doi:10.1364/JOSAA.29.00A165 +// +// 7b) Simultaneous-range auto-compression +// --------------------------------------- +// `compression == 0` uses a static centered simultaneous-range reference: +// +// side_range = reference_range_log10 / 2 +// h = max(side_range / log10(peak_Yf / anchor_Yf), 1) +// +// Kunkel and Reinhard report approximately 3.7 log10 units under their adapted +// test conditions. Jiang and Fairchild directly measured bright/dark +// simultaneous range on an Apple Pro Display XDR: approximately 3.3 log10 for +// the average observer and 3.47 for one observer at 1600 cd/m^2 with a +// 3.4-degree stimulus. Their fitted maxima were approximately 3.24 at +// 452 cd/m^2 and 3.40 at 1600 cd/m^2. These condition-dependent measurements +// motivate future display-, surround-, field-size-, and glare-aware range +// selection. Test30 keeps 3.7 as its static baseline for direct continuity +// with Test22-Test25. +// +// Sources: +// Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251 +// Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401 +// +static const float PSYCHO30_EPSILON = 1e-6f; +static const float PSYCHO30_EPSILON2 = PSYCHO30_EPSILON * PSYCHO30_EPSILON; +static const float PSYCHO30_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO30_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO30_LARGE_SUPPORT = 1e20f; +// Kunkel/Reinhard report approximately 3.7 log10 units under their adapted +// simultaneous-range test conditions. Test30 treats half that total range as +// the range above adaptation and half as the range below adaptation. +// Jiang/Fairchild report stimulus- and display-dependent simultaneous values. +static const float PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO30_SHADOW_GRADE_RANGE_STOPS = 4.f; + +static const float3x3 PSYCHO30_BT709_TO_LMS_MAT = mul( + renodx::color::STOCKMAN_CVRL_XYZ_TO_LMS_2DEG_FIT, + renodx::color::BT709_TO_XYZ_MAT); +static const float3x3 PSYCHO30_LMS_TO_BT709_MAT = mul( + renodx::color::XYZ_TO_BT709_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3x3 PSYCHO30_LMS_TO_BT2020_MAT = mul( + renodx::color::XYZ_TO_BT2020_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); + +static const float3 PSYCHO30_SOURCE_YF_COEFFICIENTS = mul( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_BT709_TO_LMS_MAT); +static const float3 PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS = max( + PSYCHO30_SOURCE_YF_COEFFICIENTS, + float3(0.f, 0.f, 0.f)); +static const float3 PSYCHO30_SOURCE_YF_WEIGHTS = + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS + / max( + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.x + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.y + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.z, + PSYCHO30_EPSILON); + +// BT.709 and BT.2020 share D65. These alpha values partition normalized Yf +// between the L and M cone loads and sum to one. +static const float3 PSYCHO30_D65_WHITE_LMS = mul( + PSYCHO30_BT709_TO_LMS_MAT, + float3(1.f, 1.f, 1.f)); +static const float PSYCHO30_D65_WHITE_YF = dot( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_D65_WHITE_LMS); +static const float PSYCHO30_D65_ALPHA_L = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * PSYCHO30_D65_WHITE_LMS.x + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_M = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * PSYCHO30_D65_WHITE_LMS.y + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_DELTA = + PSYCHO30_D65_ALPHA_L - PSYCHO30_D65_ALPHA_M; +// Direct target basis at fixed normalized physiological coordinate A: +// +// target_rgb = A + X * A2_X_RGB + Z * A2_Z_RGB +// +// These are the symbolic inverse orthonormal-cone transform followed by the +// selected LMS-to-RGB matrix; they avoid reconstructing LMS per pixel. +static const float3 PSYCHO30_BT709_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT709_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Anchor-preserving, slope-normalized finite endpoint. In scalar form, with +// q=x/anchor, k=(anchor/peak)^h, beta=h/(1-k): +// +// F(x) = peak * [1 + (1/k - 1) * q^(-beta)]^(-1/h) +// +// Thus F(anchor)=anchor, dF/dx at the anchor is one, F(0)=0, and the positive +// asymptote is `peak`. MeanA2Response fuses the common cone power into beta. +float psycho30_FiniteEndpoint( + float x, + float anchor, + float peak, + float h) { + bool uniform_response = h == 1.f; + float anchor_power = uniform_response + ? anchor / peak + : pow(anchor / peak, h); + anchor_power = max(anchor_power, 1e-37f); + float slope_normalization = max(1.f - anchor_power, PSYCHO30_EPSILON); + float normalized_input = max(x / anchor, 0.f); + if (!(normalized_input > 0.f)) return 0.f; + + float encoded = rcp( + 1.f + + (rcp(anchor_power) - 1.f) + * pow(normalized_input, -h / slope_normalization)); + return peak + * (uniform_response + ? encoded + : pow(max(encoded, 0.f), rcp(h))); +} + +// Automatic h centers the chosen simultaneous log10 range around adaptation: +// +// h = max((reference_range / 2) / log10(peak_yf / anchor_yf), 1) +// +// Manual positive h is passed through unchanged by the public entry point. +float psycho30_AutoCompressionPower(float anchor_yf, float peak_yf) { + float above_adaptation_range = log10(peak_yf / anchor_yf); + return max( + (PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 * 0.5f) + / above_adaptation_range, + 1.f); +} + +// Preserve positive source-total bookkeeping while retaining the source RGB +// direction as far as its first lower RGB-cube boundary. This keeps finite +// signed/wide-gamut inputs defined by one direction-preserving boundary trace. +float3 psycho30_AnchorSourcePositiveTotalToYf(float3 source_rgb) { + float source_total = dot( + max(source_rgb, float3(0.f, 0.f, 0.f)), + PSYCHO30_SOURCE_YF_WEIGHTS); + if (!(source_total > PSYCHO30_EPSILON) + || isnan(source_total) + || isinf(source_total)) { + return float3(0.f, 0.f, 0.f); + } + + [branch] + if (all(source_rgb >= float3(0.f, 0.f, 0.f))) { + return mul(PSYCHO30_BT709_TO_LMS_MAT, source_rgb); + } + + float3 residual = source_rgb - source_total; + float3 lower_fraction = renodx::math::Select( + residual < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + source_total / max(-residual, float3(PSYCHO30_EPSILON, PSYCHO30_EPSILON, PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float boundary_fraction = min(1.f, renodx::math::Min(lower_fraction)); + float3 bounded_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + source_total + residual * boundary_fraction); + float bounded_yf = renodx::color::yf::from::LMS(bounded_lms); + return bounded_yf > PSYCHO30_EPSILON + && !isnan(bounded_yf) + && !isinf(bounded_yf) + ? bounded_lms + * (source_total * PSYCHO30_D65_WHITE_YF / bounded_yf) + : PSYCHO30_D65_WHITE_LMS * source_total; +} + +float psycho30_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +float psycho30_HighlightsScalar( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + t = saturate( + log2(x / adapted_anchor_yf) + / log2( + PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE + / adapted_anchor_yf)); + } + t = psycho30_GradeQuinticUnitRamp(t); + + float ratio = max( + x / adapted_anchor_yf, + PSYCHO30_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float compressed = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe( + x * x, + lerp(x, compressed, t), + x); +} + +float psycho30_ShadowsScalar( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max(x / adapted_anchor_yf, 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = adapted_anchor_yf + * exp2(-PSYCHO30_SHADOW_GRADE_RANGE_STOPS); + float t = x > shadow_floor + ? saturate( + log2(x / adapted_anchor_yf) + / log2(shadow_floor / adapted_anchor_yf)) + : 1.f; + t = psycho30_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), shadows), 0.f)); + return x + (raised - x * (1.f + base_scale)) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), 2.f - shadows), 0.f)); + return x + (lowered - x * (1.f - base_scale)) * t; +} + +// Direct LMS interpolation toward the adapting neutral at fixed +// adaptation-relative physiological Yf. The selected purity path does not +// require MacLeod-Boynton coordinates or an S-cone weight. +float3 psycho30_ApplyAdaptiveLMSPurity( + float3 input_lms, + float3 adaptive_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return input_lms; + + float relative_yf = max( + renodx::color::yf::from::LMS(input_lms / adaptive_lms), + 0.f); + if (!(relative_yf > 0.f)) return float3(0.f, 0.f, 0.f); + + float neutral_scale = relative_yf + / renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + return lerp( + adaptive_lms * neutral_scale, + input_lms, + purity_delta); +} + +// Signed-fallback MacLeod-Boynton coordinate helpers. The selected positive +// path does not call this block. +float2 psycho30_AdaptiveNeutralMB() { + float lm_weight_sum = + renodx::color::CIE1702_MB_CIE_WEIGHTS.x + + renodx::color::CIE1702_MB_CIE_WEIGHTS.y; + return float2( + renodx::color::CIE1702_MB_CIE_WEIGHTS.x, + renodx::color::CIE1702_MB_CIE_WEIGHTS.z) + / lm_weight_sum; +} + +float3 psycho30_LMSFromYfOpponent( + float yf, + float rg, + float bv, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float denominator = + (yf - (anchor_lms.x - anchor_lms.y) * rg) + / lm_anchor_mix; + float3 relative_weighted = float3( + neutral_mb.x * denominator + rg, + (1.f - neutral_mb.x) * denominator - rg, + neutral_mb.y * denominator + bv); + return relative_weighted * anchor_lms + / renodx::color::CIE1702_MB_CIE_WEIGHTS; +} + +float3 psycho30_LMSFromPhysicalYfMB( + float yf, + float2 mb, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 offset = mb - neutral_mb; + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float anchor_delta = anchor_lms.x - anchor_lms.y; + float relative_denominator = renodx::math::DivideSafe( + yf, + lm_anchor_mix + anchor_delta * offset.x, + 0.f); + return psycho30_LMSFromYfOpponent( + yf, + relative_denominator * offset.x, + relative_denominator * offset.y, + anchor_lms); +} + +float3 psycho30_TargetRGBFromLMS( + float3 lms, + int target_gamut_mode) { + float3 target_rgb; + [branch] + if (target_gamut_mode == 0) { + target_rgb = mul(PSYCHO30_LMS_TO_BT709_MAT, lms); + } else { + target_rgb = mul(PSYCHO30_LMS_TO_BT2020_MAT, lms); + } + return target_rgb; +} + +float psycho30_TargetNeutralYfLimit( + float target_rgb_peak, + float3 anchor_lms, + int target_gamut_mode) { + float anchor_yf = renodx::color::yf::from::LMS(anchor_lms); + if (!(anchor_yf > PSYCHO30_EPSILON)) return 0.f; + float3 rgb_per_yf = psycho30_TargetRGBFromLMS( + anchor_lms, + target_gamut_mode) + / anchor_yf; + float max_rgb_per_yf = renodx::math::Max(rgb_per_yf); + return all(rgb_per_yf >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && max_rgb_per_yf > PSYCHO30_EPSILON + ? target_rgb_peak / max_rgb_per_yf + : 0.f; +} + +// Weighted MacLeod-Boynton chromaticity is retained only for the signed +// fallback's source-boundary continuation. +float2 psycho30_MBFromRelativeLMS( + float3 relative_lms, + out uint valid) { + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float yf = renodx::color::yf::from::LMS(relative_lms); + valid = yf > PSYCHO30_EPSILON + && !isnan(yf) + && !isinf(yf) + && !any(isnan(relative_lms)) + && !any(isinf(relative_lms)) + ? 1u + : 0u; + if (valid == 0u) { + return psycho30_AdaptiveNeutralMB(); + } + float inverse_yf = rcp(yf); + return float2( + relative_lms.x * weights.x * inverse_yf, + relative_lms.z * weights.z * inverse_yf); +} + +float3 psycho30_ApplySignedConeResponseFallback( + float3 source_relative_lms, + float response_power) { + if (abs(response_power - 1.f) <= PSYCHO30_EPSILON) { + return source_relative_lms; + } + return sign(source_relative_lms) + * pow( + abs(source_relative_lms), + float3(response_power, response_power, response_power)); +} + +// Build the selected response coordinate directly from normalized response u: +// source q authors one A2 direction, finite-G u authors the other direction +// and supplies radius, C0, and normalized physiological Yf. Equal normalized +// direction weights form the exact angular midpoint when both are defined. +float3 psycho30_MeanA2Response( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + float3 peak_lms, + float response_power, + float response_h, + out float response_yf, + out uint valid) { + float3 source_q = input_lms / anchor_in_lms; + valid = all(source_q > float3(0.f, 0.f, 0.f)) ? 1u : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + bool uniform_response = response_h == 1.f; + float3 anchor_power; + [branch] + if (uniform_response) { + anchor_power = anchor_out_lms / peak_lms; + } else { + anchor_power = pow( + anchor_out_lms / peak_lms, + float3(response_h, response_h, response_h)); + } + anchor_power = max( + anchor_power, + float3(1e-37f, 1e-37f, 1e-37f)); + float3 slope_normalization = max( + float3(1.f, 1.f, 1.f) - anchor_power, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)); + float3 input_exponent = response_power * response_h / slope_normalization; + float3 encoded = rcp( + float3(1.f, 1.f, 1.f) + + (rcp(anchor_power) - float3(1.f, 1.f, 1.f)) + * pow(source_q, -input_exponent)); + float3 response_u; + [branch] + if (uniform_response) { + response_u = encoded; + } else { + float inverse_response_h = rcp(response_h); + response_u = pow( + max(encoded, float3(0.f, 0.f, 0.f)), + float3( + inverse_response_h, + inverse_response_h, + inverse_response_h)); + } + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) + * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) + * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = source_a2 * inverse_source_radius + + response_a2 * inverse_response_radius; + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + float3 desired_ortho = float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); + return desired_ortho; +} + +float2 psycho30_ClosestPointOnScaleSegment( + float desired_c0, + float desired_rho2, + float2 segment_start, + float2 segment_end) { + float2 segment = segment_end - segment_start; + float denominator = segment.x * segment.x + + desired_rho2 * segment.y * segment.y; + if (!(denominator > PSYCHO30_EPSILON2)) return segment_start; + float numerator = (desired_c0 - segment_start.x) * segment.x + + desired_rho2 * (1.f - segment_start.y) * segment.y; + float t = saturate(numerator / denominator); + return segment_start + segment * t; +} + +// Exact nearest point for the fixed authored A2 direction. The RGB cube and +// A<=response_yf ceiling become a four-edge convex polygon in (C0, radial +// scale). `desired_rho2` in the segment metric preserves ordinary Euclidean +// distance in (X,C0,Z). +// For radial target RGB r, n=max(-r), and p=max(r), feasibility is exactly: +// +// scale * n <= A <= 1 - scale * p +// 0 <= A <= min(response_yf, 1) +float3 psycho30_YfCeilingSolve( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float max_a = saturate(response_yf); + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float3 radial_rgb; + [branch] + if (target_gamut_mode == 0) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + float3 desired_target_rgb = desired_a + radial_rgb; + if (desired_a >= 0.f + && desired_a <= max_a + && all(desired_target_rgb >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && all(desired_target_rgb <= float3( + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON))) { + return desired_coord; + } + + float desired_rho2 = dot(desired_coord.xz, desired_coord.xz); + if (!(desired_rho2 > PSYCHO30_EPSILON2)) { + return float3( + 0.f, + clamp(desired_coord.y, 0.f, sqrt(3.f) * max_a), + 0.f); + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (!(positive_pressure > 0.f) + || !(negative_pressure > 0.f)) { + valid = 0u; + return float3(0.f, 0.f, 0.f); + } + + float inverse_positive = rcp(positive_pressure); + float inverse_negative = rcp(negative_pressure); + float inverse_pressure_sum = rcp( + positive_pressure + negative_pressure); + float apex_a = negative_pressure * inverse_pressure_sum; + float upper_a = min(max_a, apex_a); + float max_a_scale = min( + max_a * inverse_negative, + (1.f - max_a) * inverse_positive); + float upper_scale = min( + max_a * inverse_negative, + inverse_pressure_sum); + float2 vertex0 = float2(0.f, 0.f); + float2 vertex1 = float2(sqrt(3.f) * max_a, 0.f); + float2 vertex2 = float2( + sqrt(3.f) * (max_a - radial_yf * max_a_scale), + max_a_scale); + float2 vertex3 = float2( + sqrt(3.f) * (upper_a - radial_yf * upper_scale), + upper_scale); + float2 best_c0_scale = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex1); + float2 best_delta = best_c0_scale - float2(desired_coord.y, 1.f); + float best_cost = best_delta.x * best_delta.x + + desired_rho2 * best_delta.y * best_delta.y; + + float2 candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex1, + vertex2); + float2 candidate_delta = candidate - float2(desired_coord.y, 1.f); + float candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex2, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + } + + float3 solved_coord = float3( + desired_coord.x * max(best_c0_scale.y, 0.f), + best_c0_scale.x, + desired_coord.z * max(best_c0_scale.y, 0.f)); + valid = !any(isnan(solved_coord)) && !any(isinf(solved_coord)) ? 1u : 0u; + return valid != 0u ? solved_coord : float3(0.f, 0.f, 0.f); +} + +float2 psycho30_LinearA2Opponent( + float3 lms, + float3 anchor_lms) { + float3 q = lms / anchor_lms; + return float2( + (q.x - q.y) * rsqrt(2.f), + (2.f * q.z - q.x - q.y) * rsqrt(6.f)); +} + +float3 psycho30_LMSFromLinearA2Opponent( + float2 opponent, + float physical_yf, + float3 anchor_lms) { + float difference = sqrt(2.f) * opponent.x; + float a_l = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * anchor_lms.x; + float a_m = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * anchor_lms.y; + float q_m = (physical_yf - a_l * difference) / (a_l + a_m); + float q_l = q_m + difference; + float q_s = 0.5f * (sqrt(6.f) * opponent.y + q_l + q_m); + return float3(q_l, q_m, q_s) * anchor_lms; +} + +float psycho30_LinearA2TargetSupport( + float2 direction, + float clip_magnitude, + float physical_yf, + float3 anchor_lms, + int target_gamut_mode, + float target_rgb_peak) { + if (!(clip_magnitude > PSYCHO30_EPSILON)) return 0.f; + + float3 neutral_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + float2(0.f, 0.f), + physical_yf, + anchor_lms), + target_gamut_mode); + if (any(isnan(neutral_target)) + || any(isinf(neutral_target)) + || any(neutral_target < float3(0.f, 0.f, 0.f)) + || any(neutral_target > float3( + target_rgb_peak, + target_rgb_peak, + target_rgb_peak))) { + return 0.f; + } + + float3 unit_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + direction, + physical_yf, + anchor_lms), + target_gamut_mode); + float3 delta_target = unit_target - neutral_target; + if (any(isnan(delta_target)) || any(isinf(delta_target))) return 0.f; + + float3 upper_support = renodx::math::Select( + delta_target > float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON), + (float3(target_rgb_peak, target_rgb_peak, target_rgb_peak) - neutral_target) + / max( + delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float3 lower_support = renodx::math::Select( + delta_target < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + neutral_target + / max( + -delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + return max( + min( + clip_magnitude, + min( + renodx::math::Min(upper_support), + renodx::math::Min(lower_support))), + 0.f); +} + +float psycho30_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho30_RaySegmentRadius( + float2 origin, + float2 direction, + float2 a, + float2 b) { + float2 edge = b - a; + float denominator = psycho30_Cross2(direction, edge); + if (abs(denominator) <= PSYCHO30_EPSILON) return PSYCHO30_LARGE_SUPPORT; + float2 ao = a - origin; + float t = psycho30_Cross2(ao, edge) / denominator; + float u = psycho30_Cross2(ao, direction) / denominator; + return t >= 0.f && u >= 0.f && u <= 1.f + ? t + : PSYCHO30_LARGE_SUPPORT; +} + +float psycho30_TransformedSourceClipLinearA2Magnitude( + float2 source_mb, + float response_power, + float3 response_anchor_ratio, + float physical_yf, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 source_offset = source_mb - neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + if (!(source_radius2 > PSYCHO30_EPSILON2)) return 0.f; + + float2 vertices[3]; + [unroll] + for (int channel = 0; channel < 3; ++channel) { + float3 primary_lms = float3( + PSYCHO30_BT709_TO_LMS_MAT[0][channel], + PSYCHO30_BT709_TO_LMS_MAT[1][channel], + PSYCHO30_BT709_TO_LMS_MAT[2][channel]); + uint primary_valid; + vertices[channel] = psycho30_MBFromRelativeLMS( + primary_lms / anchor_lms, + primary_valid); + } + float2 source_direction = source_offset * rsqrt(source_radius2); + float source_boundary_radius = min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[0], + vertices[1]), + min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[1], + vertices[2]), + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[2], + vertices[0]))); + if (!(source_boundary_radius < PSYCHO30_LARGE_SUPPORT)) return 0.f; + + float2 boundary_mb = neutral_mb + + source_direction * max(source_boundary_radius, 0.f); + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float m_fraction = 1.f - boundary_mb.x; + if (!(boundary_mb.x > PSYCHO30_EPSILON) + || !(m_fraction > PSYCHO30_EPSILON) + || !(boundary_mb.y > PSYCHO30_EPSILON)) { + return 0.f; + } + float inverse_m_fraction = rcp(m_fraction); + float2 response_ratio = exp2( + log2(max( + float2( + boundary_mb.x * weights.y * inverse_m_fraction / weights.x, + boundary_mb.y * weights.y * inverse_m_fraction / weights.z), + float2(PSYCHO30_EPSILON, PSYCHO30_EPSILON))) + * response_power); + response_ratio *= float2( + response_anchor_ratio.x / response_anchor_ratio.y, + response_anchor_ratio.z / response_anchor_ratio.y); + float lm_ratio = (weights.x / weights.y) * response_ratio.x; + float sm_ratio = (weights.z / weights.y) * response_ratio.y; + float inverse_denominator = rcp(1.f + lm_ratio); + float2 response_boundary_mb = float2( + lm_ratio * inverse_denominator, + sm_ratio * inverse_denominator); + float3 boundary_lms = psycho30_LMSFromPhysicalYfMB( + physical_yf, + response_boundary_mb, + anchor_lms); + return length(psycho30_LinearA2Opponent(boundary_lms, anchor_lms)); +} + +float psycho30_NeutwoWithClip( + float x, + float peak, + float clip, + float h) { + x = max(x, 0.f); + peak = max(peak, 0.f); + if (!(peak > PSYCHO30_EPSILON)) return 0.f; + clip = max(clip, peak); + if (clip <= peak * (1.f + PSYCHO30_EPSILON)) return min(x, peak); + float q = saturate(x / clip); + float k = saturate(peak / clip); + float qh = pow(max(q, 0.f), h); + float kh = max(pow(max(k, PSYCHO30_EPSILON), h), 1e-37f); + float denominator = pow( + max(qh * (1.f - kh) + kh, 1e-37f), + rcp(h)); + return peak * q / max(denominator, PSYCHO30_EPSILON); +} + +// Defined-domain fallback for signed adaptation-relative LMS containing a zero +// or negative cone value. +// It uses sign-preserving cone power, linear A2 direction authoring, scalar Yf +// compression, weighted-MB source-boundary continuation, and analytic +// intersections with all lower and upper selected-target RGB-cube planes. +// This is an engineering continuity and full-strength target containment path. +float3 psycho30_LinearA2Fallback( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + int target_gamut_mode, + float target_rgb_peak, + float response_power, + float response_h, + float target_compression_strength) { + float3 source_q = input_lms / anchor_in_lms; + float3 response_lms = anchor_out_lms + * psycho30_ApplySignedConeResponseFallback( + source_q, + response_power); + + float2 source_opponent = psycho30_LinearA2Opponent( + input_lms, + anchor_in_lms); + float2 response_opponent = psycho30_LinearA2Opponent( + response_lms, + anchor_in_lms); + float source_radius2 = dot(source_opponent, source_opponent); + float response_radius2 = dot(response_opponent, response_opponent); + float3 authored_lms = response_lms; + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 midpoint = source_opponent * inverse_source_radius + + response_opponent * inverse_response_radius; + float midpoint_length2 = dot(midpoint, midpoint); + if (midpoint_length2 > PSYCHO30_EPSILON2) { + authored_lms = psycho30_LMSFromLinearA2Opponent( + midpoint * rsqrt(midpoint_length2) * response_radius, + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_in_lms); + } + } + + float neutral_yf_limit = psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + target_gamut_mode); + if (!(neutral_yf_limit > PSYCHO30_EPSILON)) { + return float3(0.f, 0.f, 0.f); + } + float anchor_out_yf = renodx::color::yf::from::LMS(anchor_out_lms); + float target_yf = psycho30_FiniteEndpoint( + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_out_yf, + neutral_yf_limit, + response_h); + + uint authored_mb_valid; + float2 authored_mb = psycho30_MBFromRelativeLMS( + authored_lms / anchor_in_lms, + authored_mb_valid); + if (authored_mb_valid == 0u) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float3 desired_lms = psycho30_LMSFromPhysicalYfMB( + target_yf, + authored_mb, + anchor_in_lms); + float2 desired_opponent = psycho30_LinearA2Opponent( + desired_lms, + anchor_in_lms); + float desired_magnitude2 = dot(desired_opponent, desired_opponent); + if (!(desired_magnitude2 > PSYCHO30_EPSILON2)) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float inverse_desired_magnitude = rsqrt(desired_magnitude2); + float desired_magnitude = desired_magnitude2 * inverse_desired_magnitude; + float2 direction = desired_opponent * inverse_desired_magnitude; + float source_clip_magnitude = max( + psycho30_TransformedSourceClipLinearA2Magnitude( + psycho30_MBFromRelativeLMS(source_q, authored_mb_valid), + response_power, + anchor_out_lms / anchor_in_lms, + target_yf, + anchor_in_lms), + desired_magnitude); + float target_support = psycho30_LinearA2TargetSupport( + direction, + source_clip_magnitude, + target_yf, + anchor_in_lms, + target_gamut_mode, + target_rgb_peak); + float compressed_magnitude = min( + psycho30_NeutwoWithClip( + desired_magnitude, + target_support, + max(source_clip_magnitude, target_support), + response_h), + target_support); + return psycho30_LMSFromLinearA2Opponent( + direction * lerp(desired_magnitude, compressed_magnitude, target_compression_strength), + target_yf, + anchor_in_lms); +} + +float3 psychotm_test30( + // Direct linear-light BT.709 RGB. + // Configuration values are trusted; only the input color is sanitized. + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, // display peak / reference white + float exposure = 1.f, // linear-light multiplier + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // factor in common cone power p + float purity_scale = 1.f, // adaptation-relative LMS purity + float bleaching_intensity = 1.f, // positional compatibility placeholder + float clip_point = 100.f, // positional compatibility placeholder + float hue_restore = 1.f, // positional compatibility placeholder + float encoded_response_power = 1.f, // positional compatibility placeholder + int white_curve_mode = 0, // positional compatibility placeholder + float cone_response_exponent = 1.f, // second factor in cone power p + float3 current_adaptive_state_bt709 = 0.18f, // input anchor + float3 current_background_state_bt709 = 0.18f, // output anchor + float gamut_compression = 1.f, // target-projection strength + int gamut_compression_mode = 1, // 0 = BT.709, nonzero = BT.2020 + float adaptive_normalization = 1.f, // positional compatibility placeholder + float compression = 0.f) { // positive manual h; 0 = auto + // ------------------------------------------------------------------------- + // Source signal and signed-domain policy. + // ------------------------------------------------------------------------- + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 anchored_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(anchored_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Observer-basis controls: scalar physiological-Yf grading followed by + // adaptation-relative LMS purity. These precede the finite cone response. + // ------------------------------------------------------------------------- + float3 graded_lms = anchored_lms; + [branch] + if (highlights != 1.f || shadows != 1.f) { + graded_lms = abs(anchored_lms); + float graded_yf = max( + renodx::color::yf::from::LMS(graded_lms), + PSYCHO30_EPSILON); + float adapted_anchor_yf = renodx::color::yf::from::LMS(anchor_in_lms); + float graded_yf_out = psycho30_HighlightsScalar( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho30_ShadowsScalar( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, anchored_lms); + } + + float response_scale = cone_response_exponent; + float response_power = contrast * response_scale; + float purity_delta = renodx::math::DivideSafe( + purity_scale, + contrast, + 1.f); + float3 response_input_lms = psycho30_ApplyAdaptiveLMSPurity( + graded_lms, + anchor_in_lms, + purity_delta); + + // ------------------------------------------------------------------------- + // Positive finite-G response and Mean-A2 direction authoring. + // ------------------------------------------------------------------------- + float target_rgb_peak = peak_value; + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * target_rgb_peak; + float response_h = compression; + [branch] + if (compression == PSYCHO30_AUTO_COMPRESSION_SENTINEL) { + response_h = psycho30_AutoCompressionPower( + renodx::color::yf::from::LMS(anchor_out_lms), + psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + gamut_compression_mode)); + } + + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2Response( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + target_peak_lms, + response_power, + response_h, + response_yf, + response_valid); + [branch] + if (response_valid == 0u) { + // Signed cone states use the separate defined-domain path. + float3 fallback_lms = psycho30_LinearA2Fallback( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + gamut_compression_mode, + target_rgb_peak, + response_power, + response_h, + gamut_compression); + float3 fallback_bt709 = mul( + PSYCHO30_LMS_TO_BT709_MAT, + fallback_lms); + return !any(isnan(fallback_bt709)) && !any(isinf(fallback_bt709)) + ? fallback_bt709 + : float3(0.f, 0.f, 0.f); + } + + // ------------------------------------------------------------------------- + // Device mapping: exact fixed-direction projection into the selected + // normalized RGB cube with the post-response physiological-Yf ceiling. + // ------------------------------------------------------------------------- + float target_compression_weight = gamut_compression; + float3 selected_coord = desired_coord; + if (target_compression_weight != 0.f) { + uint solve_valid; + float3 solved_coord = psycho30_YfCeilingSolve( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = target_compression_weight == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + target_compression_weight); + } + + // Direct inverse A2/Yf basis to linear BT.709. This is algebraically the + // normalized cone-coordinate inverse plus LMS-to-BT.709 matrix product. + float output_a = selected_coord.y * rsqrt(3.f) + + PSYCHO30_D65_ALPHA_DELTA + * selected_coord.x * rsqrt(2.f) + - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value + * (output_a + + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ \ No newline at end of file diff --git a/shaders/pipelines/display/tone_mapping.slang b/shaders/pipelines/display/tone_mapping.slang new file mode 100644 index 00000000..696b49ad --- /dev/null +++ b/shaders/pipelines/display/tone_mapping.slang @@ -0,0 +1,307 @@ +import psychov24; + + +static const float3 LUMA_BT709 = float3(0.2126, 0.7152, 0.0722); +static const float3 LUMA_BT2020 = float3(0.2627, 0.6780, 0.0593); + +// Row-major mathematical notation; the build targets column-major storage, while mul(M, v) +// preserves the intended color-space transforms. +static const float3x3 BT709_TO_BT2020 = float3x3( + 0.6274039, 0.3292830, 0.0433131, + 0.0690973, 0.9195406, 0.0113612, + 0.0163916, 0.0880132, 0.8955953 +); +static const float3x3 PSYCHO24_BT709_TO_BT2020 = float3x3( + 0.6274039149, 0.3292830288, 0.04331305996, + 0.0690972954, 0.9195403457, 0.01136231795, + 0.01639143936, 0.08801331371, 0.8955952525 +); + +static const float3x3 BT2020_TO_BT709 = float3x3( + 1.6604910, -0.5876411, -0.0728499, + -0.1245505, 1.1328999, -0.0083494, + -0.0181508, -0.1005789, 1.1187297 +); + +static const float PQ_M1 = 0.1593017578125; +static const float PQ_M2 = 78.84375; +static const float PQ_C1 = 0.8359375; +static const float PQ_C2 = 18.8515625; +static const float PQ_C3 = 18.6875; + +float pqEncode(float nits) { + float y = pow(max(nits, 0.0) / 10000.0, PQ_M1); + return pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2); +} +float3 pqEncodeNits(float3 nits) { + return float3(pqEncode(nits.r), pqEncode(nits.g), pqEncode(nits.b)); +} + +float safeDiv(float a, float b, float fallback) { + return abs(b) <= 1.0e-12 ? fallback : a / b; +} + +float3 safeDiv(float3 a, float3 b, float3 fallback) { + const float eps = 1.0e-12; + return float3(abs(b.x) <= eps ? fallback.x : a.x / b.x, + abs(b.y) <= eps ? fallback.y : a.y / b.y, + abs(b.z) <= eps ? fallback.z : a.z / b.z); +} + +float luminanceBt709(float3 color) { + return dot(color, LUMA_BT709); +} + + +static const float3x3 AGX_INSET = float3x3( + 0.842479062253094, 0.078433599999999, 0.079223745147764, + 0.042328242261012, 0.878468636469772, 0.079166127460543, + 0.042375654905705, 0.078433600000000, 0.879142973793104 +); + +static const float3x3 AGX_OUTSET = float3x3( + 1.196879005120170, -0.098020881140137, -0.099029744079720, + -0.052896851757456, 1.151903129904170, -0.098961176844843, + -0.052971635514443, -0.098043450117124, 1.151073672641160 +); + +static const float AGX_MIN_EV = -12.47393; +static const float AGX_MAX_EV = 4.026069; + +// AgX's default contrast curve is the polynomial used by the selectable AgX operator. +float3 agxDefaultContrast(float3 x) { + float3 x2 = x * x; + float3 x4 = x2 * x2; + return 15.5 * x4 * x2 + - 40.14 * x4 * x + + 31.96 * x4 + - 6.868 * x2 * x + + 0.4298 * x2 + + 0.1191 * x + - 0.00232; +} + +float3 applyLookAdjustments(float3 color, float contrast, float saturation) { + if (abs(contrast - 1.0) <= 1.0e-6 && abs(saturation - 1.0) <= 1.0e-6) { + return color; + } + color = clamp((color - 0.5) * max(contrast, 0.0) + 0.5, 0.0, 1.0); + float luma = luminanceBt709(color); + return clamp(lerp(float3(luma), color, max(saturation, 0.0)), 0.0, 1.0); +} + +float3 agx(float3 color, float contrast, float saturation) { + color = mul(AGX_INSET, max(color, float3(0.0))); + color = clamp(log2(max(color, float3(1.0e-10))), AGX_MIN_EV, AGX_MAX_EV); + color = (color - AGX_MIN_EV) / (AGX_MAX_EV - AGX_MIN_EV); + color = agxDefaultContrast(color); + color = mul(AGX_OUTSET, color); + return applyLookAdjustments(clamp(color, 0.0, 1.0), contrast, saturation); +} + +// Khronos PBR Neutral analytical operator. +float3 pbrNeutralTonemap(float3 color, float startCompression, float desaturation) { + startCompression = clamp(startCompression, 0.0, 0.99); + desaturation = max(desaturation, 0.0); + color = max(color, float3(0.0)); + float x = min(color.r, min(color.g, color.b)); + float offset = x < 0.08 ? x - 6.25 * x * x : 0.04; + color -= offset; + + float peak = max(color.r, max(color.g, color.b)); + if (peak < startCompression) { + return color; + } + + float d = 1.0 - startCompression; + float newPeak = 1.0 - d * d / max(peak + d - startCompression, 1.0e-6); + color *= safeDiv(newPeak, peak, 1.0); + + float g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0); + return lerp(color, float3(newPeak), g); +} + +float3 luminanceTonemap(float3 color, float lMapped, float lSource) { + return color * safeDiv(lMapped, lSource, 1.0); +} + +// Extended Reinhard photographic operator. +float3 reinhardExtendedTonemap(float3 color, float whitePoint) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + float lWhite = max(whitePoint, 1.0e-3); + float lWhite2 = lWhite * lWhite; + float lm = l * (1.0 + l / lWhite2) / (1.0 + l); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Krzysztof Narkowicz's compact ACES fitted curve, applied to BT.709 luminance. +float3 acesNarkowiczTonemap(float3 color, float inputScale) { + color *= max(inputScale, 0.0); + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + float lm = (l * (2.51 * l + 0.03)) / max(l * (2.43 * l + 0.59) + 0.14, 1.0e-6); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Timothy Lottes's parameterized filmic curve. +float3 lottesTonemap( + float3 color, float contrast, float shoulder, float maximum, + float middleIn, float middleOut) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + float a = max(contrast, 1.0e-3); + float d = max(shoulder, 1.0e-3); + float hdrMax = max(maximum, 1.0e-3); + float midIn = max(middleIn, 1.0e-3); + float midOut = max(middleOut, 1.0e-3); + float hdrPow = pow(hdrMax, a * d); + float midPow = pow(midIn, a * d); + float denom = max((hdrPow - midPow) * midOut, 1.0e-6); + float b = (-pow(midIn, a) + pow(hdrMax, a) * midOut) / denom; + float c = (hdrPow * pow(midIn, a) - pow(hdrMax, a) * midPow * midOut) / denom; + + float lm = pow(l, a) / max(pow(l, a * d) * b + c, 1.0e-6); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// John Hable's Uncharted 2 filmic curve. +float hablePartial(float x, float a, float b, float c, float d, float e, float f) { + return ((x * (a * x + c * b) + d * e) / max(x * (a * x + b) + d * f, 1.0e-6)) - e / f; +} + +float3 uncharted2Tonemap( + float3 color, float a, float b, float c, float d, float e, float f, + float whitePoint) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + a = max(a, 1.0e-4); + b = max(b, 1.0e-4); + c = max(c, 0.0); + d = max(d, 1.0e-4); + e = max(e, 0.0); + f = max(f, 1.0e-4); + float w = max(whitePoint, 1.0e-3); + float whiteScale = max(hablePartial(w, a, b, c, d, e, f), 1.0e-6); + float lm = hablePartial(l, a, b, c, d, e, f) / whiteScale; + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Hajime Uchimura's GT tone-mapping curve. +float3 gtTonemap( + float3 color, float contrast, float linearStart, float linearLength, + float blackCurve, float blackLift) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + const float p = 1.0; + float a = max(contrast, 1.0e-3); + float m = clamp(linearStart, 1.0e-4, 0.99); + linearLength = max(linearLength, 1.0e-4); + float c = max(blackCurve, 1.0e-4); + float b = blackLift; + + float l0 = ((p - m) * linearLength) / a; + float s0 = m + l0; + float s1 = m + a * l0; + float c2 = (a * p) / max(p - s1, 1.0e-6); + float cp = -c2 / p; + + float w0 = 1.0 - smoothstep(0.0, m, l); + float w2 = step(m + l0, l); + float w1 = 1.0 - w0 - w2; + + float toe = m * pow(max(l / m, 0.0), c) + b; + float shoulder = p - (p - s1) * exp(cp * (l - s0)); + float lm = toe * w0 + l * w1 + shoulder * w2; + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + + +public struct ToneMappingParameters { + public float param0; + public float param1; + public float param2; + public float param3; + public float param4; + public float param5; + public float param6; + public float param7; +} + +public float3 applyClassicToneMapper(int mode, float3 color, ToneMappingParameters p) { + if (mode == 1) { + return agx(color, p.param0, p.param1); + } else if (mode == 2) { + return pbrNeutralTonemap(color, p.param0, p.param1); + } else if (mode == 3) { + return reinhardExtendedTonemap(color, p.param0); + } else if (mode == 4) { + return acesNarkowiczTonemap(color, p.param0); + } else if (mode == 5) { + return lottesTonemap(color, p.param0, p.param1, p.param2, p.param3, p.param4); + } else if (mode == 6) { + return uncharted2Tonemap(color, p.param0, p.param1, p.param2, p.param3, + p.param4, p.param5, p.param6); + } else if (mode == 7) { + return gtTonemap(color, p.param0, p.param1, p.param2, p.param3, p.param4); + } + return color; +} + +public float3 toneMapPsychoV24Sdr(float3 color, ToneMappingParameters p) { + return psychoV24(color, 1.0, + p.param0, p.param1, 0, p.param2, p.param3, p.param4, p.param5); +} + +public float3 toneMapPsychoV24Hdr(float3 paperReferred709, float paperWhiteNits, + float headroom, ToneMappingParameters p) { + float3 mapped709 = psychoV24(paperReferred709, headroom, + p.param0, p.param1, 1, p.param2, p.param3, p.param4, p.param5); + float3 mapped2020 = max(mul(PSYCHO24_BT709_TO_BT2020, mapped709), float3(0.0)); + return pqEncodeNits(mapped2020 * paperWhiteNits); +} + +// BT.2390 PQ EETF. This is the standard's luma-Y' variant: the 1:1 central region is preserved, +// the Hermite knee rolls the PQ luma toward the target display peak, and a zero-black target is used +// because Caustica has no ambient-black control. The EETF is deliberately not applied independently +// to RGB channels; BT.2390 warns that doing so can introduce color and saturation shifts. +float bt2390EetfPq(float encodedLuminance, float targetPeakNits) { + float maxLum = pqEncode(clamp(targetPeakNits, 1.0, 10000.0)); + float kneeStart = clamp(1.5 * maxLum - 0.5, 0.0, 1.0); + float inputPq = clamp(encodedLuminance, 0.0, 1.0); + float e2 = inputPq; + if (inputPq < kneeStart || kneeStart >= 0.999999) { + return e2; + } + + float t = clamp((inputPq - kneeStart) / max(1.0 - kneeStart, 1.0e-6), 0.0, 1.0); + float t2 = t * t; + float t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * kneeStart + + (t3 - 2.0 * t2 + t) * (1.0 - kneeStart) + + (-2.0 * t3 + 3.0 * t2) * maxLum; + // E3 = E2 + b * (1 - E2)^4; b is zero for the renderer's zero-black target. + return clamp(e2, 0.0, 1.0); +} + +public float3 toneMapBt2390Hdr(float3 paperReferred709, float paperWhiteNits, float headroom) { + float3 paperReferred = max(paperReferred709, float3(0.0)); + float3 nits2020 = max(mul(BT709_TO_BT2020, paperReferred * paperWhiteNits), float3(0.0)); + float3 encoded = clamp(pqEncodeNits(nits2020), float3(0.0), float3(1.0)); + float inputLuma = dot(encoded, LUMA_BT2020); + float mappedLuma = bt2390EetfPq(inputLuma, paperWhiteNits * headroom); + return clamp(luminanceTonemap(encoded, mappedLuma, inputLuma), float3(0.0), float3(1.0)); +} diff --git a/shaders/pipelines/exposure_hist/main.comp.slang b/shaders/pipelines/exposure_hist/main.comp.slang index 88d4918c..8478d2b6 100644 --- a/shaders/pipelines/exposure_hist/main.comp.slang +++ b/shaders/pipelines/exposure_hist/main.comp.slang @@ -24,7 +24,8 @@ void main(uint3 dispatchId : SV_DispatchThreadID, uint groupIndex : SV_GroupInde localBins[512u + groupIndex] = 0u; GroupMemoryBarrierWithGroupSync(); - int2 pix = int2(dispatchId.xy * pc.stride); + uint stride = max(pc.stride, 1u); + int2 pix = int2(dispatchId.xy * stride); uint w, h; colorImage.GetDimensions(w, h); if (pix.x < int(w) && pix.y < int(h)) { diff --git a/shaders/pipelines/exposure_resolve/main.comp.slang b/shaders/pipelines/exposure_resolve/main.comp.slang index 39365a8e..ae7ce026 100644 --- a/shaders/pipelines/exposure_resolve/main.comp.slang +++ b/shaders/pipelines/exposure_resolve/main.comp.slang @@ -96,6 +96,17 @@ void main() { } float population = surfacePopulation + skyPopulation * skyScale + emissivePopulation * emissiveScale; + if (population <= 0.0 || !isfinite(population)) { + float previous = (stateBuf[0].initialized != 0u && isfinite(stateBuf[0].previous) + && stateBuf[0].previous > 0.0) ? stateBuf[0].previous : 1.0; + stateBuf[0].resetSeq = pc.resetSeq; + stateBuf[0].previous = previous; + stateBuf[0].initialized = 1u; + float preExposure = (isfinite(pc.preExposure) && pc.preExposure > 0.0) + ? pc.preExposure : 1.0; + exposureImage[int2(0, 0)] = previous / preExposure; + return; + } float total = max(population, 1.0); float lowPercentile = clamp(min(pc.lowPercentile, pc.highPercentile), 0.0, 1.0); float highPercentile = clamp(max(pc.lowPercentile, pc.highPercentile), 0.0, 1.0); diff --git a/shaders/pipelines/world/any_hit.rahit.slang b/shaders/pipelines/world/any_hit.rahit.slang index 67762c73..04ce6c0a 100644 --- a/shaders/pipelines/world/any_hit.rahit.slang +++ b/shaders/pipelines/world/any_hit.rahit.slang @@ -64,7 +64,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) int texSlot = int(epr.tint.w + 0.5); float4 texel = entityAlbedoTex[NonUniformResourceIndex(texSlot)].SampleLevel(uv, 0.0); bool stochasticAlpha = false; - MaterialHeader materialHeader; + MaterialHeader materialHeader = {}; if (instanceKind == ENTITY_BIT) { materialHeader = ConstPtr(pc.materialTableAddr)[epr.materialId]; stochasticAlpha = (materialHeader.features & MATERIAL_FEATURE_STOCHASTIC_ALPHA) != 0u; diff --git a/shaders/pipelines/world/bindings.slang b/shaders/pipelines/world/bindings.slang index 7a9ca062..8199c836 100644 --- a/shaders/pipelines/world/bindings.slang +++ b/shaders/pipelines/world/bindings.slang @@ -3,7 +3,12 @@ import world_common; +#ifdef CAUSTICA_SHARC_VARIANT +import sharc_types; +[[vk::push_constant]] public SharcPushConstants pc; +#else [[vk::push_constant]] public WorldPushConstants pc; +#endif [[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; [[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; @@ -14,11 +19,15 @@ import world_common; [[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; [[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; [[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; +[[vk::binding(9, 0)]] [format("r16f")] public RWTexture2D gResponsivity; +[[vk::binding(10, 0)]] [format("r8ui")] public RWTexture2D gParticleMask; +[[vk::binding(11, 0)]] [format("r16f")] public RWTexture2D gSkyClassification; [[vk::binding(2, 0)]] public Sampler2D blockAlbedoAtlas; -[[vk::binding(9, 0)]] public Sampler2D celestialsAtlas; -[[vk::binding(10, 0)]] public Sampler2D skyViewLut; -[[vk::binding(11, 0)]] public Sampler2D transmittanceLut; +[[vk::binding(12, 0)]] public Sampler2D celestialsAtlas; +[[vk::binding(13, 0)]] public Sampler2D skyViewLut; +[[vk::binding(14, 0)]] public Sampler2D transmittanceLut; +[[vk::binding(15, 0)]] public Sampler2D endSkyTexture; [[vk::binding(0, 1)]] public Sampler2D entityAlbedoTex[]; [[vk::binding(1, 1)]] public Sampler2D materialSurface0Tex[]; diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index bf21f885..cb46ea9c 100644 --- a/shaders/pipelines/world/closest_hit.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -46,6 +46,11 @@ float2 texSizePx(Sampler2D t) { return float2(w, h); } +float causticaMipMapBias() { + float bias = ConstPtr(pc.worldPushAddr)[0].mipMapBias; + return bias == bias && abs(bias) <= 16.0 ? bias : 0.0; +} + float rayConeHitWidth(uint rayCone) { float2 cone = unpackHalf2(rayCone); return max(cone.x + cone.y * max(RayTCurrent(), 0.0), RAY_CONE_MIN_WIDTH); @@ -64,12 +69,12 @@ float rayConeTextureLod(uint rayCone, float2 textureSizePx, float3 p0, float3 p1 max(edgeTexelsPerWorld(p1, p2, uv1, uv2, textureSizePx), edgeTexelsPerWorld(p2, p0, uv2, uv0, textureSizePx))); float footprint = max(rayConeHitWidth(rayCone) * texelsPerWorld, RAY_CONE_MIN_TEXEL_FOOTPRINT); - return clamp(log2(footprint), 0.0, RAY_CONE_MAX_LOD); + return clamp(log2(footprint) + causticaMipMapBias(), 0.0, RAY_CONE_MAX_LOD); } float rayConeUnitUvLod(uint rayCone, float2 textureSizePx) { float footprint = max(rayConeHitWidth(rayCone) * max(textureSizePx.x, textureSizePx.y), RAY_CONE_MIN_TEXEL_FOOTPRINT); - return clamp(log2(footprint), 0.0, RAY_CONE_MAX_LOD); + return clamp(log2(footprint) + causticaMipMapBias(), 0.0, RAY_CONE_MAX_LOD); } // LabPBR _n decode (shared): rotate the tangent-space normal into world space via a TBN built from the @@ -243,9 +248,9 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) packAlbedo(payload, bt709ToAcesCg(particleAlbedo709)); packNormal(payload, pn); payload.hitT = RayTCurrent(); - // Per-particle motion vector: interpolate the captured per-vertex displacement (uniform across - // the billboard's verts) with the same indices/barycentrics as the UV. dispAddr == 0 falls back - // to rigidDisp, which particles write as zero. + // Per-particle motion vector: interpolate captured per-vertex displacement with the same + // indices/barycentrics as the UV. dispAddr == 0 falls back to rigidDisp, which particles write + // as zero. if (g.dispAddr != 0) { ConstPtr pd = ConstPtr(g.dispAddr); payload.motionPrev = half3(pbary.x * pd[p0].xyz + pbary.y * pd[p1].xyz + pbary.z * pd[p2].xyz); @@ -255,6 +260,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payload.f0 = half3(0.0h, 0.0h, 0.0h); payloadSetPacked(payload, MATERIAL_PARTICLE, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, EMISSION_SOURCE_NONE); + payload.flags |= PAYLOAD_SHARC_DYNAMIC; return; } // Dynamic entities: instances with this custom-index flag bit carry real captured ModelPart @@ -323,6 +329,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); payloadSetDielectric(payload, material, entering); + payload.flags |= PAYLOAD_SHARC_DYNAMIC; return; } diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 7f135197..a2a93141 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -20,6 +20,9 @@ public static float3 gv_albedo = float3(0.0, 0.0, 0.0); // without another material-ID image. public static bool gv_emissive = false; public static float gv_rough = 0.0; +public static bool gv_primarySky = false; +public static bool gv_primaryParticle = false; +public static bool gv_transmittedSky = false; public static float3 gv_hitCamRel = float3(0.0, 0.0, 0.0); // primary-surface hit position relative to the current camera public static bool gv_motionUseRefracted = false; // true when the MV tracks the refracted hit (its own reprojection delta) // World-space displacement of the motion-guide surface since the previous frame (0 for static @@ -70,6 +73,15 @@ public float2 projectPrevNdc(float3 worldPos, out bool valid) { return valid ? clip.xy / clip.w : float2(0.0, 0.0); } +// Project an infinite sky direction into the previous camera. W=0 intentionally removes camera +// translation: an infinitely distant sky does not move when the camera walks, but it does move when +// the camera rotates. +public float2 projectPrevSkyNdc(float3 direction, out bool valid) { + float4 clip = mul(worldPush.prevViewProj, float4(direction, 0.0)); + valid = clip.w > 0.0 && clip.w == clip.w && abs(clip.w) <= 1.0e20; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); +} + // Previous-frame screen position of a planar reflection. The reflected image of a world point P seen // in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V // along a straight line, so the reflection appears at proj(V). Project the mirror image directly. @@ -146,7 +158,9 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, } public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, - float roughness, float3 diffuseAlbedo, bool emissive) { + float roughness, float3 diffuseAlbedo, bool emissive, bool particle) { + gv_transmittedSky = false; + gv_primaryParticle = particle; gv_hitCamRel = hitCamRel; gv_motionObjDisp = motionPrev; gv_motionUseRefracted = true; @@ -174,7 +188,8 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, - guideFilter * SKY_DIFF_ALBEDO, false); + guideFilter * SKY_DIFF_ALBEDO, false, false); + gv_transmittedSky = true; return; } @@ -189,7 +204,7 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, : hitAlbedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, payloadNormal(), endpointRoughness, guideFilter * endpointAlbedo, - payloadEmission() > 0.0); + payloadEmission() > 0.0, material == MATERIAL_PARTICLE); return; } if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; @@ -215,7 +230,7 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, // Never let a TIR reflection become ordinary diffuse/depth. setTransmissionGuide(interfacePos - worldPush.camOffset, isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), - interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false); + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false, false); return; } if (!isWater && entering) { @@ -242,36 +257,44 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer - // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. + // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag. A primary sky miss + // is a far-plane guide, not a finite hit point: publish exact reversed-Z far depth. // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. - float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - - // Motion vector: reproject this guide point through the previous frame's view-projection. - // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is - // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is - // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. - // For a dynamic surface, previous-frame position is the current point minus its world-space - // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, - float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Transmitted content is its own feature: compare its previous and current projections rather than - // subtracting the primary interface's jittered NDC. - float4 curClipMotion = gv_motionUseRefracted - ? mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)) - : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform - // Guard both perspective divides the way the depth above does. A guide point can land behind either - // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the - // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large - // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no - // motion and let RR fall back instead of handing it garbage. float2 motion; - if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { - if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; - motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; - } else { + float depth; + if (gv_transmittedSky) { + // A transmitted sky has no stable destination correspondence behind its foreground interface. + // Keep its ordinary motion invalid so RR cannot smear glass-to-sky pixels into splotches. + depth = 0.0; motion = float2(0.0, 0.0); + } else if (gv_primarySky) { + depth = 0.0; + bool previousSkyValid; + float2 previousSkyNdc = projectPrevSkyNdc(primaryDir, previousSkyValid); + motion = previousSkyValid + ? (previousSkyNdc - jndc) * 0.5 * size + : float2(0.0, 0.0); + } else { + float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); + bool clipWFinite = curClip.w > 0.0 && curClip.w == curClip.w && abs(curClip.w) <= 1.0e20; + float projectedDepth = clipWFinite ? curClip.z / curClip.w : 0.0; + bool depthValid = clipWFinite + && projectedDepth == projectedDepth && abs(projectedDepth) <= 1.0e20; + depth = depthValid ? clamp(projectedDepth, 0.0, 1.0) : 0.0; + + // The primary ray was cast through the jittered NDC (jndc). Subtracting it keeps the + // motion guide jitter-free, which is required because MVJittered is not enabled. + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float2 curNdc = jndc; + if (gv_motionUseRefracted) { + curNdc = depthValid ? curClip.xy / curClip.w : float2(0.0, 0.0); + } + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || depthValid)) { + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); + } } // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. @@ -281,4 +304,14 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f gMotion[pix] = motion; gSpecAlbedo[pix] = float4(specAlbedo, 1.0); gSpecMotion[pix] = specMotion; + // Primary and transmitted sky have no stable surface correspondence; responsivity tells DLSSD not + // to reuse their history. Transmission replaces the ordinary guide tuple, so both sky branches must + // classify identically with the depth/motion invalidation above. Display needs a separate primary-sky + // classification because transmitted sky remains a visible foreground surface there. + // Particle classification prevents dynamic particle pixels from being accumulated as opaque terrain. + // Only a visible primary miss is eligible for native End-sky replacement. A transmitted sky stays + // a surface feature so the display pass cannot overwrite the foreground glass/water layer. + gResponsivity[pix] = (gv_primarySky || gv_transmittedSky) ? 1.0 : 0.0; + gSkyClassification[pix] = gv_primarySky ? 1.0 : 0.0; + gParticleMask[pix] = gv_primaryParticle ? 1u : 0u; } diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 2df3dc6a..9b942966 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -20,6 +20,9 @@ // The build emits an ordinary TraceRay fallback and an optional EXT SER variant from this source. import world_common; import world_core; +#ifdef CAUSTICA_SHARC_VARIANT +import sharc_types; +#endif import math; import medium; import segment; @@ -32,17 +35,22 @@ import lighting; import sky; import bindings; +#ifdef CAUSTICA_SHARC_VARIANT +#include "sharc_bridge.slang" +static SharcParameters sharcParameters; +static SharcFrame sharcFrame; +#endif + // Atmospheric transmittance LUT (RtSkyLut), also bound to world.rmiss at the same binding: raygen reads it // to colour the NEE sun/moonlight, the miss shader reads it to tint the visible discs and stars. -public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { +public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathBranch) { float3 L = float3(0.0, 0.0, 0.0); float3 ro = seg.ro; float3 rd = seg.rd; float3 throughput = seg.throughput; - // Pass A is fixed at one sample per pixel. Decorrelate each Pass B resample here so configured SPP - // produces independent lighting/BSDF paths from the shared terminal continuation. - uint seed = seg.seed ^ sampleIndex * 2246822519u; - seed = pcg(seed); + // Pass A is fixed at one sample per pixel. Pass B advances one global Sobol index per continuation + // sample; optional groups are keyed from the same immutable transport sampler only when sampled. + PathSampler transportSampler = makePathSampler(pix, sampleIndex, pathBranch); float rayConeWidth = seg.rayConeWidth; float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); @@ -50,13 +58,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the // candidate count is non-zero. Otherwise emitters contribute only when a path hits them. bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; - // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until - // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. - // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share - // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u - ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; - proposalSeed = pcg(proposalSeed); + // Independent light-proposal samples use their own stream. Screen-space coherent selection is + // intentionally deferred until there is a real presampled RIS tile buffer. // The medium the segment starts in. For the camera segment that is water when the eye is submerged // (so the first ray already carries the right relative index and absorption); for a segment split off // a dielectric it is whatever the parent was travelling through. @@ -73,6 +76,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // remains active with its full configured candidate count at every hit. Pass A's primary/interface // prefix is outside this SSS quality budget. int indirectDepth = 0; +#ifdef CAUSTICA_SHARC_UPDATE + SharcState sharcState; + SharcInit(sharcState); +#endif for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. @@ -83,10 +90,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); + showCelestial, false, rayConeWidth, rayConeSpread, pathPhaseHint); #else traceRadiance(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, false, rayConeWidth, rayConeSpread); #endif if (payload.hitT < 0.0) { @@ -102,9 +109,15 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // packed show-celestial flag decides whether this path segment may see the bright disc. float3 sky = payloadSky(); L += throughput * sky; // escaped to sky +#ifdef CAUSTICA_SHARC_UPDATE + causticaSharcUpdateMiss(sharcParameters, sharcState, sky, + (sharcFrame.sharcFlags & 1u) != 0u); +#endif break; } + beginPathBounce(transportSampler, uint(bounce)); + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies // to every hit reached while inside a volume dielectric (its own exit face, or whatever content // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so @@ -166,7 +179,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - bool chooseReflection = rndf(seed) < F; + bool chooseReflection = pathSample(transportSampler, PATH_DIM_INTERFACE) < F; if (chooseReflection) { rd = reflect(rd, n); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); @@ -185,11 +198,20 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { + if (pathSample(transportSampler, PATH_DIM_RR) > q) { break; } throughput /= q; } +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 1.0); +#endif + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif continue; } @@ -202,7 +224,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 lightDir = celestialLight.dir; float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + PathSampler celestialSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_CELESTIAL); + lightDir = sampleSquare(lightDir, lightHalfAngle, celestialSampler, PATH_DIM_CELESTIAL_U); } // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow // rays exclude particles anyway, but this keeps the origin sane when the light is behind the @@ -221,9 +244,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // sampler as terrain/entities, but two-sided: a billboard has no back face, so light // striking either side of the quad should still land (matches the sun/moon NEE above). if (risOn) { + PathSampler risSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_RIS_BASE); float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, seed, proposalSeed); + true, 0.0, risSampler); L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0); } @@ -233,9 +257,19 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } throughput *= albedo; ro = hitPos + n * SURF_BIAS; - rd = cosineDir(n, seed); + PathSampler diffuseSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_DIFFUSE); + rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 0.0); +#endif + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif continue; } @@ -256,6 +290,26 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; +#ifdef CAUSTICA_SHARC_VARIANT + bool sharcFiniteSurface = causticaFinite3(hitPos, CAUSTICA_SHARC_WORLD_LIMIT) + && causticaFinite3(n, 1.0e6) && causticaFinite3(diffAlb, 1.0e6) + && causticaFinite3(v, 1.0e6); + bool sharcDynamicSurface = (payload.flags & PAYLOAD_SHARC_DYNAMIC) != 0u; + // Only stable diffuse secondary surfaces own/query cache entries. Primary pixels, specular-visible + // paths, dynamic geometry, metals, and malformed material data remain live. The explicit debug bit + // is limited to the camera-terminal segment so it cannot accidentally make reflected paths cacheable. + bool sharcPrimaryDebug = seg.bounce == 0 && hitDepth == 0 + && (pc.sharcDebugFlags & 1u) != 0u; + bool sharcPathEligible = !showCelestial || sharcPrimaryDebug; + bool sharcDepthEligible = sharcPathEligible && (hitDepth > 0 || sharcPrimaryDebug); + bool sharcDiffuseEligible = sharcDepthEligible && sharcFiniteSurface + && !sharcDynamicSurface && rough >= 0.0 && rough <= 1.0 + && metal >= 0.0 && metal <= 0.1 + && rough > max(MIRROR_ALPHA_MAX, pc.sharcRoughnessThreshold) + && all(diffAlb >= float3(0.0, 0.0, 0.0)) + && luminance(diffAlb) > 1.0e-4 && dot(n, v) > 1.0e-4; +#endif + // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial @@ -270,8 +324,19 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where // RIS is skipped must also gather emission directly or that light is lost outright. bool gateEmitter = risOn && payloadEmitterInList(); +#ifdef CAUSTICA_SHARC_VARIANT + float3 sharcCacheableDirectLighting = float3(0.0, 0.0, 0.0); + float3 sharcLiveDirectLighting = float3(0.0, 0.0, 0.0); + float3 sharcMaterialEmissive = float3(0.0, 0.0, 0.0); +#endif if (emission > 0.0 && (!gateEmitter || showCelestial)) { +#ifdef CAUSTICA_SHARC_VARIANT + float3 emissionLighting = albedo * emission; + L += throughput * emissionLighting; + sharcMaterialEmissive = emissionLighting; +#else L += throughput * albedo * emission; +#endif } // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert @@ -282,7 +347,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 lightDir = celestialLight.dir; float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + PathSampler celestialSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_CELESTIAL); + lightDir = sampleSquare(lightDir, lightHalfAngle, celestialSampler, PATH_DIM_CELESTIAL_U); } float ndl = max(0.0, dot(n, lightDir)); if (ndl > 0.0) { @@ -295,6 +361,26 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { +#ifdef CAUSTICA_SHARC_VARIANT + float3 diffuseBrdf = diffAlb * INV_PI; + float3 h = normalize(lightDir + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); + float3 directScale = celestialLight.illuminance * ndl * vis; + float3 diffuseLighting = diffuseBrdf * directScale; + float3 specularLighting = ((D * G) * fresnelSchlick(vdh, F0) + / (4.0 * ndv * ndl)) * directScale; + sharcCacheableDirectLighting += diffuseLighting; + sharcLiveDirectLighting += specularLighting; +#ifdef CAUSTICA_SHARC_UPDATE + L += throughput * (diffuseLighting + specularLighting); +#else + L += throughput * specularLighting; +#endif +#else float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) float3 h = normalize(lightDir + v); float ndh = max(0.0, dot(n, h)); @@ -305,6 +391,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 F = fresnelSchlick(vdh, F0); brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular L += throughput * brdf * celestialLight.illuminance * ndl * vis; +#endif } } @@ -315,10 +402,18 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // sss=0 there (falls back to plain front-only RIS). if (risOn) { float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; + PathSampler risSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_RIS_BASE); Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - seed, proposalSeed); + risSampler); +#ifdef CAUSTICA_SHARC_VARIANT + float3 risLighting = shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, + activeSss); + L += throughput * risLighting; + sharcLiveDirectLighting += risLighting; +#else L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss); +#endif } // Thin-surface SSS transmission. Light entering from the back face scatters through toward the @@ -339,7 +434,14 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } if (max(visB.r, max(visB.g, visB.b)) > 0.0) { float cosT = dot(lightDir, rd); +#ifdef CAUSTICA_SHARC_VARIANT + float3 sssLighting = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl + * celestialLight.illuminance * visB; + L += throughput * sssLighting; + sharcLiveDirectLighting += sssLighting; +#else L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * celestialLight.illuminance * visB; +#endif } } } @@ -347,6 +449,14 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // Preserve all local lighting at the terminal hit, but do not sample a continuation that the // bounce loop cannot trace. if (bounce >= maxBounces) { +#ifdef CAUSTICA_SHARC_QUERY + L += throughput * sharcCacheableDirectLighting; +#endif +#ifdef CAUSTICA_SHARC_UPDATE + causticaSharcPropagate(sharcParameters, sharcState, + sharcCacheableDirectLighting + sharcLiveDirectLighting + sharcMaterialEmissive, + (sharcFrame.sharcFlags & 1u) != 0u); +#endif break; } @@ -355,7 +465,36 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 ? 1.0 : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); - if (rndf(seed) < ps) { + bool sampledSpecular = pathSample(transportSampler, PATH_DIM_LOBE) < ps; +#ifdef CAUSTICA_SHARC_UPDATE + float3 sharcPropagatedLighting = sharcCacheableDirectLighting + + sharcLiveDirectLighting + sharcMaterialEmissive; + if (!sampledSpecular && sharcDiffuseEligible) { + SharcHitData sharcHit = causticaMakeSharcHit(hitPos, n, diffAlb, + sharcMaterialEmissive, v, 0.0); + if (!causticaSharcUpdateHit(sharcParameters, sharcState, sharcHit, + sharcCacheableDirectLighting, sharcLiveDirectLighting, + sharcCacheableDirectLighting + sharcLiveDirectLighting, + (sharcFrame.sharcFlags & 1u) != 0u, + pathSample(pathSamplerWithGroup(transportSampler, PATH_GROUP_SHARC), + PATH_DIM_SHARC_UPDATE))) { + break; + } + } else { + causticaSharcPropagate(sharcParameters, sharcState, sharcPropagatedLighting, + (sharcFrame.sharcFlags & 1u) != 0u); + } +#endif + if (sampledSpecular) { +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, + causticaSharcDirectionalityWeight(exactSpecular, false, rough)); +#endif +#endif +#ifdef CAUSTICA_SHARC_QUERY + L += throughput * sharcCacheableDirectLighting; +#endif float3 l; if (exactSpecular) { // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the @@ -363,7 +502,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { l = reflect(rd, n); throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; } else { - float3 h = sampleGGXVNDF(n, v, rough, seed); + PathSampler specularSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_SPECULAR); + float3 h = sampleGGXVNDF(n, v, rough, specularSampler, PATH_DIM_GGX_U); l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal float ndl2 = dot(n, l); if (ndl2 <= 0.0) { @@ -378,21 +518,44 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { rd = l; showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc } else { +#ifdef CAUSTICA_SHARC_QUERY + float3 cachedRadiance; + if (sharcDiffuseEligible + && causticaSharcTryQuery(sharcParameters, hitPos, n, diffAlb, v, payload.hitT, + cachedRadiance)) { + L += throughput * cachedRadiance; + break; + } + // A cold/invalid cache restores the live diffuse direct term and keeps tracing unbiased. + L += throughput * sharcCacheableDirectLighting; +#endif throughput *= diffAlb / (1.0 - ps); ro = p; - rd = cosineDir(n, seed); + PathSampler diffuseSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_DIFFUSE); + rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 0.0); +#endif +#endif } // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { + if (pathSample(transportSampler, PATH_DIM_RR) > q) { break; } throughput /= q; } +#ifdef CAUSTICA_SHARC_UPDATE + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif } return L; } @@ -400,11 +563,51 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { [shader("raygeneration")] void main() { worldPush = ConstPtr(pc.worldPushAddr)[0]; +#ifdef CAUSTICA_SHARC_VARIANT + sharcFrame = ConstPtr(pc.sharcFrameAddr)[0]; + sharcParameters = causticaSharcParameters(sharcFrame.hashEntriesAddr, sharcFrame.accumulationAddr, + sharcFrame.resolvedAddr, sharcFrame.capacity, sharcFrame.cameraPosition, + sharcFrame.sceneScale, sharcFrame.radianceScale, + sharcFrame.gridLogarithmBase, sharcFrame.gridLevelBias); +#endif // Derive this dispatch's directional light once. The same transmittance LUT drives terrain lighting // and the visible sun/moon, keeping atmospheric tint consistent across both consumers. celestialLight = dominantCelestialLight(skyState(worldPush), transmittanceLut); // The path integral is ACEScg end to end; sky.slang works in linear BT.709, so this is the one seam. celestialLight.illuminance = bt709ToAcesCg(celestialLight.illuminance); +#ifdef CAUSTICA_SHARC_UPDATE + uint2 tileIndex = DispatchRaysIndex().xy; + uint tileSize = max(pc.sharcUpdateTileSize, 1u); + uint tileArea = max(tileSize * tileSize, 1u); + uint tileSeedInput = tileIndex.x * 1973u + tileIndex.y * 9277u + 26699u; + uint tileSeed = pcg(tileSeedInput); + uint tileOffset = (tileSeed % tileArea + sharcFrame.frameIndex % tileArea) % tileArea; + uint2 updatePixel = tileIndex * tileSize + + uint2(tileOffset % tileSize, tileOffset / tileSize); + uint2 renderDimensions = uint2(max(pc.sharcRenderWidth, 1u), max(pc.sharcRenderHeight, 1u)); + if (any(updatePixel >= renderDimensions)) return; + float2 updateSize = float2(renderDimensions); + float2 updateUv = (float2(updatePixel) + 0.5) / updateSize; + float2 updateNdc = (updateUv + worldPush.jitter / updateSize) * 2.0 - 1.0; + float4 updateNearH = mul(worldPush.invViewProj, float4(updateNdc.x, updateNdc.y, 1.0, 1.0)); + float4 updateFarH = mul(worldPush.invViewProj, float4(updateNdc.x, updateNdc.y, 0.0, 1.0)); + float3 updateNear = updateNearH.xyz / updateNearH.w; + float3 updateFar = updateFarH.xyz / updateFarH.w; + float3 updateOrigin = updateNear + worldPush.camOffset; + float3 updateDirection = normalize(updateFar - updateNear); + uint updateSeedInput = tileSeed ^ (updatePixel.x * 1973u + updatePixel.y * 9277u + 26699u) + ^ (sharcFrame.frameIndex * 2654435761u); + uint updateSeed = pcg(updateSeedInput); + MediumStack updateMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + PathSegment updateSegment = makePathSegment(updateOrigin, updateDirection, + float3(1.0, 1.0, 1.0), updateMedium, 0.0, + primaryRayConeSpread(updateNdc, updateSize, updateDirection), updateSeed, 0, true); + tracePath(updateSegment, updatePixel, 0u, 0u); + return; +#endif +#if !defined(CAUSTICA_SHARC_UPDATE) uint2 dispatchIndex = DispatchRaysIndex().xy; uint2 dimensions = DispatchRaysDimensions().xy; int2 pix = int2(dispatchIndex); @@ -417,7 +620,7 @@ void main() { PackedPathSegment packed = queue[recordIndex]; PathSegment segment = unpackPathSegment(packed); for (uint s = 0u; s < spp; ++s) { - frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); + frameRadiance += tracePath(segment, uint2(pix), s, leaf); } if (packed.nextRecord == PATH_NO_NEXT) { break; @@ -433,4 +636,5 @@ void main() { // white as white, so it costs nothing visible. float3 stored = frameRadiance * (worldPush.preExposure / float(spp)); outImage[pix] = float4(clamp(stored, float3(0.0), float3(HALF_MAX)), 1.0); +#endif } diff --git a/shaders/pipelines/world/lighting.slang b/shaders/pipelines/world/lighting.slang index 4604e6bc..b31067ea 100644 --- a/shaders/pipelines/world/lighting.slang +++ b/shaders/pipelines/world/lighting.slang @@ -90,8 +90,8 @@ public float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, f } // Select one light from the emitted-power distribution in O(1). -public void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); +public void selectGlobalLight(in PathSampler risSampler, uint dimension, out uint lightIndex) { + float aliasSample = pathSample(risSampler, dimension) * float(worldPush.lightCount); uint column = min(uint(aliasSample), worldPush.lightCount - 1u); if (pc.lightAliasAddr != 0) { LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; @@ -117,12 +117,18 @@ public bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoo * uint(worldPush.lightGridDims.x) + uint(coord.x); cell = ConstPtr(pc.lightGridCellAddr)[linear]; cellCoord = coord; - return cell.spanCount > 0u; + if (cell.spanCount == 0u) { + // The discarded branch-free local chain still reads one span. Sparse trailing cells may carry + // the one-past-end prefix offset, so redirect empty cells to the first valid span. + cell.spanOffset = 0u; + return false; + } + return true; } -public void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, +public void selectSectionLight(uint firstLight, uint lightCount, in PathSampler risSampler, uint dimension, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(lightCount); + float aliasSample = pathSample(risSampler, dimension) * float(lightCount); uint column = min(uint(aliasSample), lightCount - 1u); LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; bool self = aliasSample - float(column) < alias.accept; @@ -194,31 +200,33 @@ public float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCo return localProbability * localPdf + (1.0 - localProbability) * globalPdf; } -public void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, +public void selectLightGridSpanLight(LightGridCell cell, in PathSampler risSampler, uint dimension, out uint lightIndex) { ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); - float aliasSample = rndf(proposalSeed) * float(cell.spanCount); + float aliasSample = pathSample(risSampler, dimension) * float(cell.spanCount); uint column = min(uint(aliasSample), cell.spanCount - 1u); LightGridSpan span = spans[cell.spanOffset + column]; bool self = aliasSample - float(column) < span.accept; uint firstLight = self ? span.firstLight : span.aliasFirstLight; uint lightCount = self ? (span.packedLightCounts & 0xffffu) : (span.packedLightCounts >> 16u); - selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); + selectSectionLight(firstLight, lightCount, risSampler, dimension + 1u, lightIndex); } // Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section // by emitted power and section distance, then its section-local power alias. Every light in // the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. // Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single -// Light48 load: section power cancels with the section-local light PDF. -public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, - out uint lightIndex) { - if (useLocal) { - selectLightGridSpanLight(cell, proposalSeed, lightIndex); - } else { - selectGlobalLight(proposalSeed, lightIndex); - } +// Light load: section power cancels with the section-local light PDF. +public void selectLightGridLight(LightGridCell cell, bool useLocal, in PathSampler risSampler, + uint dimension, out uint lightIndex) { + // Keep both BDA chains unconditional: NVIDIA Ampere drivers can miscompile branch-dependent indexed + // PhysicalStorageBuffer loads in this candidate loop. Non-empty light hierarchies always publish the + // span and local-alias regions together, and RIS does not call this function for an empty hierarchy. + uint localIndex, globalIndex; + selectLightGridSpanLight(cell, risSampler, dimension, localIndex); + selectGlobalLight(risSampler, dimension, globalIndex); + lightIndex = useLocal ? localIndex : globalIndex; } // ---- RIS cost profile. Temporary probes pinned the candidate walk out of the shader to bound what @@ -228,12 +236,12 @@ public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint p // ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost // was chasing depth, not divergence, and it lived at secondary vertices. A presampled candidate pool // would attack the same 5.9ms structurally, but see the note on -// proposalSeed in tracePath for why it should share the pool rather than the seed. +// RIS stream in tracePath for why it should share the pool rather than the main sample stream. // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen // sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, inout uint seed, inout uint proposalSeed) { + float rough, bool twoSided, float sss, in PathSampler risSampler) { Reservoir r = resEmpty(); LightGridCell gridCell; int3 gridCellCoord; @@ -241,13 +249,13 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 if (pc.lightGridCellAddr != 0) { // Stochastically blend across hard section boundaries. Every cell proposal retains full global // support, so conditioning on this independently jittered lookup remains unbiased. - gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) + gridLookup += (pathSample3(risSampler, PATH_DIM_RIS_GRID_X) - 0.5) * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); uint candidateCount = worldPush.risCandidates; r.M = float(candidateCount); - // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six + // Deterministically stratify the proposal mixture. At M=8 this schedules exactly six // local and two global candidates, with every lane taking the same branch for a given candidate. // Counts that are not divisible by four use the nearest practical split with at least one global // candidate; M=1 is global-only so the estimator retains full support. @@ -257,14 +265,31 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 float localProbability = float(localCandidateCount) / float(candidateCount); for (uint c = 0u; c < candidateCount; c++) { uint li; + // Proposal/replacement and rectangle sampling use two independently shuffled low-dimensional + // groups. Candidate identity is part of each substream key, so no candidate aliases another. + PathSampler candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u); uint globalsBefore = (c * globalCandidateCount) / candidateCount; uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; bool useLocal = hasGridCell && globalsAfter == globalsBefore; - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(pc.lightBufAddr)[li]; + selectLightGridLight(gridCell, useLocal, candidateSampler, PATH_DIM_RIS_SELECT, li); + // Load the 32-byte record as two explicit float4 values. This preserves every packed bit while + // avoiding the struct-typed indexed PhysicalStorageBuffer load shape that miscompiles on Ampere. + uint64_t lightAddr = pc.lightBufAddr + uint64_t(li) * uint64_t(32); + float4 posLe = ConstPtr(lightAddr)[0]; + uint4 packed = asuint(ConstPtr(lightAddr + uint64_t(16))[0]); + Light lg; + lg.pos = posLe.xyz; + lg.le = asuint(posLe.w); + lg.halfUxy = packed.x; + lg.halfUzVx = packed.y; + lg.halfVyz = packed.z; + lg.section = packed.w; // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). - float s = rndf(seed) * 2.0 - 1.0; - float t = rndf(seed) * 2.0 - 1.0; + candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u + 1u); + float s = pathSample(candidateSampler, PATH_DIM_RIS_LIGHT_U) * 2.0 - 1.0; + float t = pathSample(candidateSampler, PATH_DIM_RIS_LIGHT_V) * 2.0 - 1.0; float3 sp = lg.pos + worldPush.lightRebase.xyz + s * lightHalfU(lg) + t * lightHalfV(lg); float3 le = lightRadiance(lg); @@ -279,7 +304,9 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); float w = phat / max(sourcePdf, 1.0e-20); r.wSum += w; - if (rndf(seed) * r.wSum < w) { // weighted reservoir update + candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u); + if (pathSample(candidateSampler, PATH_DIM_RIS_REPLACE) * r.wSum < w) { // weighted reservoir update r.pos = sp; r.lnrm = lightNormal; r.le = le; diff --git a/shaders/pipelines/world/math.slang b/shaders/pipelines/world/math.slang index d5015581..d7b53c34 100644 --- a/shaders/pipelines/world/math.slang +++ b/shaders/pipelines/world/math.slang @@ -93,17 +93,180 @@ public float fresnelDielectric(float cosI, float etaI, float etaT) { return 0.5 * (rs * rs + rp * rp); } -// PCG hash RNG. +// PCG remains available for non-path-sampling scheduling. Stochastic estimator decisions use PathSampler. public uint pcg(inout uint s) { s = s * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; } -public float rndf(inout uint s) { - // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first - // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` - // tests (most seriously the F == 1 total-internal-reflection branch). - return float(pcg(s) >> 8u) * (1.0 / 16777216.0); +public float rndf(inout uint seed) { + return float(pcg(seed) >> 8u) * (1.0 / 16777216.0); +} + +public static const uint PATH_DIM_INTERFACE = 0u; +public static const uint PATH_DIM_LOBE = 1u; +public static const uint PATH_DIM_RR = 2u; +public static const uint PATH_DIM_GGX_U = 0u; +public static const uint PATH_DIM_GGX_V = 1u; +public static const uint PATH_DIM_DIFFUSE_U = 0u; +public static const uint PATH_DIM_DIFFUSE_V = 1u; +public static const uint PATH_DIM_RIS_GRID_X = 0u; +public static const uint PATH_DIM_RIS_GRID_Y = 1u; +public static const uint PATH_DIM_RIS_GRID_Z = 2u; +public static const uint PATH_DIM_RIS_SELECT = 0u; +public static const uint PATH_DIM_RIS_REPLACE = 2u; +public static const uint PATH_DIM_RIS_LIGHT_U = 0u; +public static const uint PATH_DIM_RIS_LIGHT_V = 1u; +public static const uint PATH_DIM_SHARC_UPDATE = 0u; +public static const uint PATH_DIM_CELESTIAL_U = 0u; + +public static const uint PATH_GROUP_TRANSPORT_CORE = 0u; +public static const uint PATH_GROUP_TRANSPORT_SPECULAR = 1u; +public static const uint PATH_GROUP_TRANSPORT_DIFFUSE = 2u; +public static const uint PATH_GROUP_RIS_BASE = 3u; +public static const uint PATH_GROUP_RIS_CANDIDATE_FIRST = 4u; +public static const uint PATH_GROUP_SHARC = 68u; +public static const uint PATH_GROUP_CELESTIAL = 69u; +public static const uint PATH_GROUP_COUNT = 70u; +public static const uint PATH_BRANCH_COUNT = 2u; +public static const uint PATH_BOUNCE_COUNT = 9u; +public static const uint PATH_SOBOL_DIMENSIONS = 4u; +public static const uint PATH_SOBOL_FIRST_TABLE_DIMENSION = 1u; +public static const uint PATH_SOBOL_TABLE_DIMENSION_COUNT = + PATH_SOBOL_DIMENSIONS - PATH_SOBOL_FIRST_TABLE_DIMENSION; +public static const uint PATH_SOBOL_WORDS_PER_DIMENSION = 128u; +public static const uint PATH_DIRECTION_TABLE_OFFSET = 0u; +public static const uint PATH_ROOT_TABLE_OFFSET = PATH_DIRECTION_TABLE_OFFSET + + PATH_SOBOL_TABLE_DIMENSION_COUNT * PATH_SOBOL_WORDS_PER_DIMENSION; +public static const uint PATH_ROOTS_PER_GROUP = 9u; +public static const uint PATH_ROOT_INDEX_SHUFFLE = 0u; +public static const uint PATH_ROOT_COORDINATE_SCRAMBLE_BASE = 1u; +public static const uint PATH_ROOT_DIGITAL_SHIFT_BASE = 5u; +public static const uint PATH_EPOCH_KEY = 0x9e3779b9u; +public static const uint PATH_STATE_BOUNCE_MASK = 0x1eu; +public static const uint PATH_STATE_GROUP_MASK = 0xfe0u; + +public struct PathSampler { + public uint globalSample; + public uint pixelEpochKey; + public uint shuffledIndex; + // branch, bounce, and group occupy bits 0, 1..4, and 5..11 of the resource-bounded state. + public uint pathState; +} + +public uint pathHash(uint value) { + uint x = value; + x ^= x >> 16u; + x *= 0x21f0aaadu; + x ^= x >> 15u; + x *= 0xf35a2d97u; + x ^= x >> 15u; + return x; +} + +public uint pathReverseBits(uint value) { + // The pinned Slang toolchain lowers this intrinsic to SPIR-V OpBitReverse. + return reversebits(value); +} + +public uint pathNestedUniformPermutation(uint value, uint seed) { + uint x = pathReverseBits(value); + x ^= x * 0x3d20adeau; + x += seed; + x *= (seed >> 16u) | 1u; + x ^= x * 0x05526c56u; + x ^= x * 0x53a22864u; + return pathReverseBits(x); +} + +public uint pathRootBase(in PathSampler sampler) { + uint state = sampler.pathState; + return PATH_ROOT_TABLE_OFFSET + + (((state & 1u) * PATH_BOUNCE_COUNT + ((state >> 1u) & 15u)) * PATH_GROUP_COUNT + + (state >> 5u)) * PATH_ROOTS_PER_GROUP; +} + +public void rekeyPathSampler(inout PathSampler sampler) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint rootBase = pathRootBase(sampler); + uint indexSeed = pathHash(data[rootBase + PATH_ROOT_INDEX_SHUFFLE] + ^ sampler.pixelEpochKey); + sampler.shuffledIndex = pathNestedUniformPermutation(sampler.globalSample, indexSeed); +} + +public PathSampler makePathSampler(uint2 pixel, uint localSample, uint pathBranch) { + uint pixelCode = pixel.x | (pixel.y << 16u); + PathSampler sampler; + sampler.globalSample = worldPush.pathSampleBase + localSample; + sampler.pixelEpochKey = pixelCode ^ worldPush.pathSampleEpoch * PATH_EPOCH_KEY; + sampler.shuffledIndex = sampler.globalSample; + sampler.pathState = pathBranch | (PATH_GROUP_TRANSPORT_CORE << 5u); + return sampler; +} + +public void beginPathBounce(inout PathSampler sampler, uint bounce) { + sampler.pathState = (sampler.pathState & ~PATH_STATE_BOUNCE_MASK) | (bounce << 1u); + rekeyPathSampler(sampler); +} + +public PathSampler pathSamplerWithGroup(in PathSampler parent, uint group) { + PathSampler sampler = parent; + sampler.pathState = (sampler.pathState & ~PATH_STATE_GROUP_MASK) | (group << 5u); + rekeyPathSampler(sampler); + return sampler; +} + +public uint pathSobolBitsFromGray(ConstPtr data, uint gray, uint dimension) { + if (dimension == 0u) return pathReverseBits(gray); + uint tableDimension = dimension - PATH_SOBOL_FIRST_TABLE_DIMENSION; + uint base = PATH_DIRECTION_TABLE_OFFSET + tableDimension * PATH_SOBOL_WORDS_PER_DIMENSION; + return data[base + ((gray >> 0u) & 15u)] + ^ data[base + 16u + ((gray >> 4u) & 15u)] + ^ data[base + 32u + ((gray >> 8u) & 15u)] + ^ data[base + 48u + ((gray >> 12u) & 15u)] + ^ data[base + 64u + ((gray >> 16u) & 15u)] + ^ data[base + 80u + ((gray >> 20u) & 15u)] + ^ data[base + 96u + ((gray >> 24u) & 15u)] + ^ data[base + 112u + ((gray >> 28u) & 15u)]; +} + +public float pathSampleValue(ConstPtr data, in PathSampler sampler, uint rootBase, uint gray, + uint dimension) { + uint bits = pathSobolBitsFromGray(data, gray, dimension); + uint scrambleSeed = pathHash( + data[rootBase + PATH_ROOT_COORDINATE_SCRAMBLE_BASE + dimension] + ^ sampler.pixelEpochKey); + bits = pathNestedUniformPermutation(bits, scrambleSeed); + uint digitalShift = pathHash(data[rootBase + PATH_ROOT_DIGITAL_SHIFT_BASE + dimension] + ^ sampler.pixelEpochKey); + bits ^= digitalShift; + return float(bits >> 8u) * (1.0 / 16777216.0); +} + +public float pathSample(in PathSampler sampler, uint dimension) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + return pathSampleValue(data, sampler, pathRootBase(sampler), + sampleIndex ^ (sampleIndex >> 1u), dimension); +} + +public float2 pathSample2(in PathSampler sampler, uint dimensionBase) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + uint gray = sampleIndex ^ (sampleIndex >> 1u); + uint rootBase = pathRootBase(sampler); + return float2(pathSampleValue(data, sampler, rootBase, gray, dimensionBase), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 1u)); +} + +public float3 pathSample3(in PathSampler sampler, uint dimensionBase) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + uint gray = sampleIndex ^ (sampleIndex >> 1u); + uint rootBase = pathRootBase(sampler); + return float3(pathSampleValue(data, sampler, rootBase, gray, dimensionBase), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 1u), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 2u)); } public float3 primaryRayDir(float2 ndc) { @@ -124,9 +287,10 @@ public float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { // Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the // importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation // throughput is just *= albedo with no PI/cos bookkeeping left over. -public float3 cosineDir(float3 n, inout uint s) { - float u1 = rndf(s); - float u2 = rndf(s); +public float3 cosineDir(float3 n, in PathSampler sampler, uint dimensionBase) { + float2 samples = pathSample2(sampler, dimensionBase); + float u1 = samples.x; + float u2 = samples.y; float r = sqrt(u1); float phi = 6.2831853 * u2; float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); @@ -136,25 +300,21 @@ public float3 cosineDir(float3 n, inout uint s) { return normalize(local.x * t + local.y * b + local.z * n); } -// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's sun/moon -// are square quads, so the NEE shadow ray samples a square (not a cone) — the same square, in the same -// horizon-levelled tangent frame (sky.celestialSquareFrame), that world.rmiss draws the visible body in. -// Averaged over frames by DLSS-RR this yields soft penumbrae that widen with occluder distance -// (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -public float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { +public float3 sampleSquare(float3 axis, float halfAngle, in PathSampler sampler, uint dimensionBase) { float3 right; float3 up; celestialSquareFrame(axis, right, up); float t = tan(halfAngle); - float u = (rndf(s) * 2.0 - 1.0) * t; - float v = (rndf(s) * 2.0 - 1.0) * t; + float2 samples = pathSample2(sampler, dimensionBase); + float u = (samples.x * 2.0 - 1.0) * t; + float v = (samples.y * 2.0 - 1.0) * t; return normalize(axis + u * right + v * up); } // Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the // sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the // importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { +public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, in PathSampler sampler, uint dimensionBase) { float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); float3 b = normalize(cross(n, t)); t = cross(b, n); @@ -164,8 +324,9 @@ public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { float lensq = Vh.x * Vh.x + Vh.y * Vh.y; float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); float3 T2 = cross(Vh, T1); - float u1 = rndf(s); - float u2 = rndf(s); + float2 samples = pathSample2(sampler, dimensionBase); + float u1 = samples.x; + float u2 = samples.y; float r = sqrt(u1); float phi = 6.2831853 * u2; float p1 = r * cos(phi); diff --git a/shaders/pipelines/world/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index 48f990bc..3265d89e 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -43,8 +43,12 @@ public PathSegment tracePrimary(PathSegment seg, PathSegment terminal = makePathSegment(ro, rd, throughput, medium, rayConeWidth, rayConeSpread, seed, bounce, showCelestial); + if (bounce == 0) { + gv_primaryParticle = false; + gv_transmittedSky = false; + } traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, bounce == 0, rayConeWidth, rayConeSpread); if (payload.hitT < 0.0) { if (bounce == 0) { @@ -52,6 +56,8 @@ public PathSegment tracePrimary(PathSegment seg, gv_albedo = SKY_DIFF_ALBEDO; gv_emissive = false; gv_rough = 1.0; + gv_primarySky = true; + gv_transmittedSky = false; gv_hitCamRel = rd * 1.0e6; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); @@ -76,6 +82,9 @@ public PathSegment tracePrimary(PathSegment seg, if (bounce == 0) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; + gv_primarySky = false; + gv_primaryParticle = material == MATERIAL_PARTICLE; + gv_transmittedSky = false; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; gv_emissive = payloadEmission() > 0.0; @@ -140,6 +149,8 @@ public PathSegment tracePrimary(PathSegment seg, gv_rough = 0.0; gv_albedo = float3(0.0, 0.0, 0.0); gv_emissive = false; + gv_primarySky = false; + gv_transmittedSky = false; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionUseRefracted = false; gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; diff --git a/shaders/pipelines/world/sharc_types.slang b/shaders/pipelines/world/sharc_types.slang new file mode 100644 index 00000000..f25d181c --- /dev/null +++ b/shaders/pipelines/world/sharc_types.slang @@ -0,0 +1,45 @@ +// SHaRC-only ABI types. Keeping this module out of world_common keeps the ordinary +// path shader's interface independent of the optional SDK build. +module sharc_types; + +import world_common; + +public struct SharcPushConstants { + public uint64_t worldPushAddr; + public uint64_t tableAddr; + public uint64_t entityTableAddr; + public uint64_t materialTableAddr; + public uint64_t lightBufAddr; + public uint64_t lightAliasAddr; + public uint64_t lightLocalAliasAddr; + public uint64_t lightGridCellAddr; + public uint64_t lightGridSpanAddr; + public uint64_t pathQueueAddr; + public uint frameIndex; + public uint64_t sharcFrameAddr; + public uint sharcUpdateTileSize; + public uint sharcRenderWidth; + public uint sharcRenderHeight; + // Additional minimum linear roughness for diffuse cache ownership. + public float sharcRoughnessThreshold; + // bit 0: developer comparison mode that permits primary-terminal cache insertion/query. + public uint sharcDebugFlags; +}; + +public struct SharcFrame { + public uint64_t hashEntriesAddr; + public uint64_t accumulationAddr; + public uint64_t resolvedAddr; + public float3 cameraPosition; + public uint capacity; + public float3 cameraPositionPrev; + public uint frameIndex; + public float sceneScale; + public float radianceScale; + public uint accumulationFrameNum; + public uint staleFrameNumMax; + public float gridLogarithmBase; + public float gridLevelBias; + // bit 0: enable the confidence-based anti-firefly adjustment during updates + public uint sharcFlags; +}; diff --git a/shaders/pipelines/world/sky.rmiss.slang b/shaders/pipelines/world/sky.rmiss.slang index 112bda7a..cfb27a34 100644 --- a/shaders/pipelines/world/sky.rmiss.slang +++ b/shaders/pipelines/world/sky.rmiss.slang @@ -99,11 +99,85 @@ float3 skyDome(float3 dir, SkyState state) { return skyViewLut.SampleLevel(sunUv, 0.0).rgb + skyViewLut.SampleLevel(moonUv, 0.0).rgb; } +// SkyRenderer.buildEndSky() draws six 200x200 quads around the camera. This is the ray equivalent of +// those faces, including the per-face UV orientation and the 0..16 repeat range of end_sky.png. +float2 endSkyUv(float3 dir) { + float3 a = abs(dir); + float t = 100.0 / max(max(a.x, a.y), a.z); + float3 p = dir * t; + if (a.x >= a.y && a.x >= a.z) { + if (dir.x > 0.0) return (float2(p.y, p.z) + 100.0) * (16.0 / 200.0); + return (float2(-p.y, p.z) + 100.0) * (16.0 / 200.0); + } + if (a.y >= a.z) { + if (dir.y > 0.0) return (float2(p.x, -p.z) + 100.0) * (16.0 / 200.0); + return (float2(p.x, p.z) + 100.0) * (16.0 / 200.0); + } + if (dir.z > 0.0) return (float2(p.x, p.y) + 100.0) * (16.0 / 200.0); + return (float2(p.x, -p.y) + 100.0) * (16.0 / 200.0); +} + +float3 endFlashRadiance(float3 dir, WorldPush worldPush) { + if ((worldPush.skyFlags & SKY_FLAG_END_FLASH) == 0u) { + return float3(0.0); + } + // Matches SkyRenderer.renderEndFlash's Y(180-y) * X(-90-x) pose, translation (0,100,0), + // and 60x60 quad scale. + static const float PI = 3.14159265359; + float y = PI - worldPush.skyParams.w; + float x = -0.5 * PI - worldPush.skyParams.z; + float cy = cos(y), sy = sin(y), cx = cos(x), sx = sin(x); + float3 right = float3(cy, 0.0, -sy); + float3 up = float3(sy * sx, cx, cy * sx); + float3 forward = float3(sy * cx, -sx, cy * cx); + float3 center = up * 100.0; + float denom = dot(dir, up); + if (abs(denom) < 1.0e-5) return float3(0.0); + float hitDistance = dot(center, up) / denom; + if (hitDistance <= 0.0) return float3(0.0); + float3 q = dir * hitDistance - center; + float2 local = float2(dot(q, right), dot(q, forward)) / 60.0; + if (max(abs(local.x), abs(local.y)) > 1.0) return float3(0.0); + float2 uv = lerp(worldPush.endFlashUv.xy, worldPush.endFlashUv.zw, local * 0.5 + 0.5); + float4 texel = celestialsAtlas.SampleLevel(uv, 0.0); + float intensity = worldPush.skyParams.y; + return srgbToLinear(texel.rgb) * texel.a * intensity * intensity; +} + [shader("miss")] void main(inout Payload payload) { WorldPush worldPush = ConstPtr(pc.worldPushAddr)[0]; SkyState state = skyState(worldPush); float3 dir = normalize(WorldRayDirection()); + + if (worldPush.skybox == SKYBOX_NONE) { + packSky(payload, bt709ToAcesCg(max(worldPush.skyColor.rgb, float3(0.0)))); + payload.hitT = -1.0; + payload.flags = 0u; + payload.roughMetal = packHalf2(float2(1.0, 0.0)); + payload.emissionSss = packHalf2(float2(0.0, 0.0)); + payload.iorTransmission = packHalf2(float2(1.0, 0.0)); + payload.rayCone = 0u; + return; + } + + if (worldPush.skybox == SKYBOX_END) { + float4 texel = endSkyTexture.SampleLevel(endSkyUv(dir), 0.0); + float3 endSkyColor = srgbToLinear(texel.rgb) * (40.0 / 255.0); + float3 color = lerp(worldPush.skyColor.rgb, endSkyColor, clamp(texel.a, 0.0, 1.0)); + if ((payload.flags & PAYLOAD_PRIMARY_SKY) != 0u) { + color += endFlashRadiance(dir, worldPush); + } + packSky(payload, bt709ToAcesCg(max(color, float3(0.0)))); + payload.hitT = -1.0; + payload.flags = 0u; + payload.roughMetal = packHalf2(float2(1.0, 0.0)); + payload.emissionSss = packHalf2(float2(0.0, 0.0)); + payload.iorTransmission = packHalf2(float2(1.0, 0.0)); + payload.rayCone = 0u; + return; + } + bool showCelestial = (payload.flags & PAYLOAD_SHOW_CELESTIAL) != 0u; float3 color = skyDome(dir, state); diff --git a/shaders/pipelines/world/trace.slang b/shaders/pipelines/world/trace.slang index 6a4f5b6e..7da52092 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -57,8 +57,10 @@ public Payload makeRadiancePayload(uint flags, uint rayCone) { // Ordinary radiance trace for latency-sensitive, coherent work such as Pass A. It invokes hit/miss // shaders directly and therefore requires no invocation-reorder capability. public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread) { - payload = makeRadiancePayload(showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u, + bool showCelestial, bool primarySky, float rayConeWidth, float rayConeSpread) { + uint flags = (showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u) + | (primarySky ? PAYLOAD_PRIMARY_SKY : 0u); + payload = makeRadiancePayload(flags, packHalf2(float2(rayConeWidth, rayConeSpread))); TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, diff --git a/shaders/pipelines/world/trace_ser.slang b/shaders/pipelines/world/trace_ser.slang index 89bffc44..2956cb87 100644 --- a/shaders/pipelines/world/trace_ser.slang +++ b/shaders/pipelines/world/trace_ser.slang @@ -18,9 +18,10 @@ import trace; // across the one point where SER must spill every live value. Rebuilding costs a few constant moves and // leaves only these two words spanning the reorder. public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread, + bool showCelestial, bool primarySky, float rayConeWidth, float rayConeSpread, uint pathPhaseHint) { - uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint flags = (showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u) + | (primarySky ? PAYLOAD_PRIMARY_SKY : 0u); uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); Payload tracePayload = makeRadiancePayload(flags, rayCone); HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index bc297ab8..e910fe3b 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -10,6 +10,11 @@ module world_common; public typealias ConstPtr = Ptr; public typealias DevicePtr = Ptr; +public static const uint SKYBOX_NONE = 0u; +public static const uint SKYBOX_OVERWORLD = 1u; +public static const uint SKYBOX_END = 2u; +public static const uint SKY_FLAG_END_FLASH = 1u; + // Push constant block. The hot inline lanes avoid dereferencing WorldPush for values that are read in // hit shaders or used for raygen control flow — every 64-bit device address lives here for exactly that // reason, rather than behind the worldPushAddr indirection. tableAddr/entityTableAddr/materialTableAddr @@ -83,11 +88,28 @@ public struct WorldPush { public int4 lightGridDims; // xyz dense grid dimensions, w reserved public uint lightCount; // packed Light records in pc.lightBufAddr public uint risCandidates; // RIS candidate count M per diffuse vertex (0 = emitter NEE off) + // NVIDIA DLSS mip-map bias: log2(render resolution / display resolution) - 1 while DLSS is active. + // Native rendering keeps this at zero; closest-hit consumes it in explicit SampleLevel LOD. + public float mipMapBias; // Pre-exposure scalar applied to radiance at the outImage write // ONLY, so all light transport above stays in absolute scene units. Keeps stored fp16 values // near mid-grey at any scene brightness; the display pass divides it back out, so the two cancel // exactly and this cannot change the image. 1.0 disables it. public float preExposure; + // Canonical sample progression. pathSampleBase advances once per per-pixel continuation sample; + // the two possible continuation leaves have independent branch roots instead of skipping Sobol indices. + // pathSampleEpoch changes whenever temporal history and the sequence cursor reset. Every valid RT + // dispatch has a nonzero pathSampleAddr -- initialization failure falls back outside RT. + public uint pathSampleBase; + public uint pathSampleEpoch; + public uint64_t pathSampleAddr; + // Dimension-specific native sky state. Appended so the existing transport ABI stays stable for + // every field above; generated WorldPushData owns the padding and offsets. + public uint skybox; // SKYBOX_NONE, SKYBOX_OVERWORLD, or SKYBOX_END + public uint skyFlags; // SKY_FLAG_END_FLASH when the vanilla End flash is active + public float4 skyColor; // captured vanilla sky/clear color, converted to linear BT.709 + public float4 skyParams; // reserved, flash intensity, flash X angle, flash Y angle + public float4 endFlashUv; // celestials-atlas UV rect for end_flash }; // 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the @@ -176,6 +198,10 @@ public struct Payload { }; public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; +// Marks the visible camera ray so the native End sky can be composited after DLSS-RR history. +public static const uint PAYLOAD_PRIMARY_SKY = 256u; +// Dynamic/entity and particle hits are view/animation dependent and must never own persistent SHaRC voxels. +public static const uint PAYLOAD_SHARC_DYNAMIC = 512u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; diff --git a/shaders/sharc/sharc_bridge.slang b/shaders/sharc/sharc_bridge.slang new file mode 100644 index 00000000..946d1426 --- /dev/null +++ b/shaders/sharc/sharc_bridge.slang @@ -0,0 +1,343 @@ +// Caustica-owned adapter for NVIDIA SHaRC 1.8. +// +// The NVIDIA headers are an external, separately licensed build input. They are deliberately not copied +// into this source tree. This adapter keeps the cache in BDA buffers and makes local/direct-light +// ownership explicit: current-vertex lighting stays live, while only propagated radiance is cached. +#ifndef CAUSTICA_SHARC_BRIDGE +#define CAUSTICA_SHARC_BRIDGE 1 + +#ifndef SHARC_UPDATE +#define SHARC_UPDATE 0 +#endif +#ifndef SHARC_QUERY +#define SHARC_QUERY 0 +#endif +#ifdef CAUSTICA_SHARC_UPDATE +#undef SHARC_UPDATE +#define SHARC_UPDATE 1 +#endif +#ifdef CAUSTICA_SHARC_QUERY +#undef SHARC_QUERY +#define SHARC_QUERY 1 +#endif + +#define SHARC_ENABLE_GLSL 0 +#ifndef SHARC_ENABLE_SH_ENCODING +#define SHARC_ENABLE_SH_ENCODING 1 +#endif +#define HASH_GRID_ENABLE_64_BIT_ATOMICS 1 +#define HASH_GRID_USE_NORMALS 1 +#define HASH_GRID_COMPACT 0 +#define SHARC_PROPAGATION_DEPTH 4 +#define SHARC_MATERIAL_DEMODULATION 1 +#define SHARC_SEPARATE_EMISSIVE 1 +#define SHARC_ENABLE_CACHE_RESAMPLING 1 +#define SHARC_ENABLE_RESPONSIVE_LIGHTING 0 +#ifndef SHARC_SAMPLE_NUM_THRESHOLD +#define SHARC_SAMPLE_NUM_THRESHOLD 3 +#endif +#define CAUSTICA_SHARC_MIN_SEGMENT_RATIO 1.0 +#define SHARC_LINEAR_PROBE_WINDOW_SIZE \ + ((entryIndex + 1u < sharcParameters.hashGridData.capacity) \ + ? min(8u, sharcParameters.hashGridData.capacity - (entryIndex + 1u)) : 0u) +#define CAUSTICA_SHARC_WORLD_LIMIT 1.0e6 + +typealias SharcRWPtr = Ptr; +#define RW_STRUCTURED_BUFFER(name, type) SharcRWPtr name +#define BUFFER_AT_OFFSET(name, offset) name[offset] + +#include "SharcCommon.h" + +bool causticaFinite3(float3 value, float limit) { + return all(value == value) && all(abs(value) <= float3(limit, limit, limit)); +} + +float causticaSharcDirectionalityWeight(bool isDelta, bool isDiffuse, float perceptualRoughness) { + if (isDiffuse) return 0.0; + if (isDelta) return 1.0; + if (perceptualRoughness != perceptualRoughness || abs(perceptualRoughness) > 1.0) return 0.0; + float smoothness = saturate(1.0 - perceptualRoughness); + return smoothness * smoothness; +} + +float3 causticaSafeDirection(float3 direction) { + float lengthSquared = dot(direction, direction); + return causticaFinite3(direction, 1.0e30) && lengthSquared > 1.0e-12 && lengthSquared < 1.0e30 + ? direction * rsqrt(lengthSquared) : float3(0.0, 0.0, 1.0); +} + +bool causticaCacheSampleFinite(float3 radiance, float3 sampleWeight) { + return causticaFinite3(radiance, HALF_MAX) && all(radiance >= float3(0.0)) + && causticaFinite3(sampleWeight, HALF_MAX) && all(sampleWeight >= float3(0.0)); +} + +bool causticaSharcGridGeometry(SharcParameters parameters, float3 positionWorld, + out float voxelSize) { + voxelSize = 0.0; + if (!causticaFinite3(positionWorld, CAUSTICA_SHARC_WORLD_LIMIT)) return false; + float3 biasedPosition = positionWorld + + float3(HASH_GRID_POSITION_BIAS, HASH_GRID_POSITION_BIAS, HASH_GRID_POSITION_BIAS); + uint level = HashGridGetLevel(biasedPosition, parameters.hashGridParameters); + voxelSize = HashGridGetVoxelSize(level, parameters.hashGridParameters); + if (!causticaFinite3(float3(voxelSize, voxelSize, voxelSize), CAUSTICA_SHARC_WORLD_LIMIT) + || voxelSize <= 0.0) return false; + float3 gridPosition = floor(biasedPosition / voxelSize); + return causticaFinite3(gridPosition, 65535.0); +} + +float3 causticaSharcResolvePreviousCamera(SharcParameters parameters, float3 cameraPositionPrev) { + float base = parameters.hashGridParameters.logarithmBase; + float minVoxelSize = base / (parameters.hashGridParameters.sceneScale + * pow(base, parameters.hashGridParameters.levelBias)); + bool currentSafe = causticaFinite3(float3(minVoxelSize, minVoxelSize, minVoxelSize), + CAUSTICA_SHARC_WORLD_LIMIT) + && minVoxelSize > 0.0 + && causticaFinite3(floor(parameters.hashGridParameters.cameraPosition / minVoxelSize), 65535.0); + bool previousSafe = causticaFinite3(cameraPositionPrev, CAUSTICA_SHARC_WORLD_LIMIT) + && currentSafe + && causticaFinite3(floor(cameraPositionPrev / minVoxelSize), 65535.0); + return previousSafe ? cameraPositionPrev : parameters.hashGridParameters.cameraPosition; +} + +bool causticaValidCacheIndex(SharcParameters parameters, uint index) { + return index != HASH_GRID_INVALID_CACHE_INDEX && index < parameters.hashGridData.capacity; +} + +bool causticaCacheSampleUsable(SharcParameters parameters, float3 radiance, float3 sampleWeight) { + if (!causticaCacheSampleFinite(radiance, sampleWeight)) return false; + float3 weighted = radiance * sampleWeight; + return causticaFinite3(weighted * parameters.radianceScale, 1.0e8); +} + +float3 causticaSharcAntiFireflyWeight(SharcParameters parameters, HashGridIndex cacheIndex, + float3 sampleValue, float3 sampleWeight) { + float scalarWeight = max(SharcLuma(sampleWeight), 1.0); + if (!causticaFinite3(sampleWeight, HALF_MAX) || any(sampleWeight < float3(0.0)) + || !causticaFinite3(sampleValue, HALF_MAX) || any(sampleValue < float3(0.0)) + || !causticaFinite3(float3(scalarWeight, scalarWeight, scalarWeight), HALF_MAX) + || scalarWeight <= 2.0) return sampleWeight; + + SharcVoxelData previous = SharcGetVoxelData(parameters.resolvedBuffer, cacheIndex); + float previousSamples = previous.accumulatedSampleNum; + if (previousSamples > 2.0 && previousSamples == previousSamples + && abs(previousSamples) <= HALF_MAX) { + float previousLuma = max(SharcRadianceLuma(previous.accumulatedRadiance), 1.0); + float currentLuma = max(SharcLuma(sampleValue * sampleWeight), 1.0); + if (causticaFinite3(float3(previousLuma, currentLuma, 0.0), HALF_MAX) && currentLuma > 0.0) { + float confidenceT = saturate((previousSamples - 2.0) / 10.0); + sampleWeight *= saturate(lerp(5.0, 10.0, confidenceT) * previousLuma / currentLuma); + } + } else { + sampleWeight /= sqrt(scalarWeight); + } + return sampleWeight; +} + +void causticaSharcAddVoxelData(SharcParameters parameters, HashGridIndex cacheIndex, + float3 sampleValue, float3 sampleWeight, + float3 sampleDirection, float sampleDirectionWeight, + uint sampleData, bool antiFirefly) { + if (!causticaValidCacheIndex(parameters, cacheIndex) + || !causticaCacheSampleFinite(sampleValue, sampleWeight)) return; + if (antiFirefly) { + sampleWeight = causticaSharcAntiFireflyWeight(parameters, cacheIndex, + sampleValue, sampleWeight); + } + if (causticaCacheSampleUsable(parameters, sampleValue, sampleWeight)) { + SharcAddVoxelData(parameters, cacheIndex, sampleValue, SharcSampleWeight(sampleWeight), + sampleDirection, sampleDirectionWeight, sampleData); + } +} + +SharcHitData causticaMakeSharcHit(float3 positionWorld, float3 normalWorld, float3 albedo, + float3 emissive, float3 outgoingDirectionToPreviousVertex, + float directionalityWeight) { + SharcHitData hit; + hit.positionWorld = positionWorld; + hit.normalWorld = causticaSafeDirection(normalWorld); + hit.materialDemodulation = max(albedo, float3(1.0e-3)); + hit.emissive = emissive; +#if SHARC_ENABLE_SH_ENCODING + hit.radianceDirectionWorld = causticaSafeDirection(outgoingDirectionToPreviousVertex); + hit.radianceDirectionWeight = clamp(directionalityWeight, 0.0, 1.0); +#endif + return hit; +} + +bool causticaSharcTryQuery(SharcParameters parameters, float3 positionWorld, float3 normalWorld, + float3 diffuseAlbedo, float3 outgoingDirection, float segmentLength, + out float3 radiance) { + radiance = float3(0.0, 0.0, 0.0); + if (!causticaFinite3(positionWorld, CAUSTICA_SHARC_WORLD_LIMIT) + || !causticaFinite3(normalWorld, 1.0e6) + || !causticaFinite3(diffuseAlbedo, 1.0e6) + || !causticaFinite3(outgoingDirection, 1.0e6) + || !causticaFinite3(float3(segmentLength, segmentLength, segmentLength), CAUSTICA_SHARC_WORLD_LIMIT) + || all(diffuseAlbedo <= float3(1.0e-4)) + || dot(causticaSafeDirection(normalWorld), causticaSafeDirection(outgoingDirection)) <= 1.0e-4) { + return false; + } + float voxelSize; + if (!causticaSharcGridGeometry(parameters, positionWorld, voxelSize)) return false; + float minSegment = voxelSize * CAUSTICA_SHARC_MIN_SEGMENT_RATIO; + if (!causticaFinite3(float3(voxelSize, minSegment, segmentLength), CAUSTICA_SHARC_WORLD_LIMIT) + || segmentLength < minSegment) return false; + SharcHitData hit = causticaMakeSharcHit(positionWorld, normalWorld, diffuseAlbedo, + float3(0.0, 0.0, 0.0), outgoingDirection, 0.0); + if (!SharcGetCachedRadiance(parameters, hit, radiance, false)) return false; + if (!causticaFinite3(radiance, HALF_MAX) || any(radiance < float3(0.0)) + || !any(radiance > float3(1.0e-6))) { + radiance = float3(0.0); + return false; + } + return true; +} + +void causticaSharcPropagate(SharcParameters parameters, inout SharcState state, + float3 propagatedDirectLighting, bool antiFirefly) { +#if SHARC_UPDATE + for (uint i = 0u; i < state.pathLength; ++i) { + float3 sampleWeight = float3(state.sampleWeights[i]); + if (!causticaValidCacheIndex(parameters, state.cacheIndices[i]) + || !causticaCacheSampleFinite(propagatedDirectLighting, sampleWeight)) continue; + float3 previousDirection = float3(0.0, 0.0, 1.0); + float previousWeight = 0.0; +#if SHARC_ENABLE_SH_ENCODING + previousDirection = state.radianceDirections[i]; + previousWeight = state.radianceDirectionWeights[i]; +#endif + causticaSharcAddVoxelData(parameters, state.cacheIndices[i], propagatedDirectLighting, + sampleWeight, previousDirection, previousWeight, 0u, antiFirefly); + } +#endif +} + +void causticaSharcUpdateMiss(SharcParameters parameters, inout SharcState state, + float3 radiance, bool antiFirefly) { +#if SHARC_UPDATE + causticaSharcPropagate(parameters, state, radiance, antiFirefly); +#endif +} + +bool causticaSharcUpdateHit(SharcParameters parameters, inout SharcState state, + SharcHitData hit, float3 cacheableDirectLighting, + float3 liveDirectLighting, float3 propagatedDirectLighting, + bool antiFirefly, float random) { +#if SHARC_UPDATE + if (!causticaFinite3(hit.positionWorld, CAUSTICA_SHARC_WORLD_LIMIT) + || !causticaFinite3(hit.normalWorld, 1.0e6) + || !causticaFinite3(hit.materialDemodulation, 1.0e6) + || !causticaFinite3(cacheableDirectLighting, HALF_MAX) + || !causticaFinite3(liveDirectLighting, HALF_MAX) + || !causticaFinite3(propagatedDirectLighting, HALF_MAX) + || any(cacheableDirectLighting < float3(0.0)) + || any(liveDirectLighting < float3(0.0)) + || any(propagatedDirectLighting < float3(0.0))) return false; + float voxelSize; + if (!causticaSharcGridGeometry(parameters, hit.positionWorld, voxelSize)) { + causticaSharcPropagate(parameters, state, propagatedDirectLighting + hit.emissive, antiFirefly); + return false; + } + HashGridKey key; + HashGridIndex index = HashGridInsertEntry(parameters.hashGridData, hit.positionWorld, + hit.normalWorld, parameters.hashGridParameters, key); + if (!causticaValidCacheIndex(parameters, index)) { + causticaSharcPropagate(parameters, state, propagatedDirectLighting + hit.emissive, antiFirefly); + return false; + } + float3 demodulation = max(hit.materialDemodulation, float3(1.0e-3)); + float3 direction = SharcGetRadianceDirection(hit); + float directionWeight = SharcGetRadianceDirectionWeight(hit); + bool continueTracing = true; + float3 propagatedRadiance = propagatedDirectLighting + hit.emissive; +#if SHARC_ENABLE_CACHE_RESAMPLING + uint resamplingDepth = uint(round(lerp(float(SHARC_RESAMPLING_DEPTH_MIN), + float(SHARC_PROPAGATION_DEPTH), random))); + if (resamplingDepth <= state.pathLength) { + SharcVoxelData voxel = SharcGetVoxelData(parameters.resolvedBuffer, index); + if (voxel.accumulatedSampleNum > SHARC_SAMPLE_NUM_THRESHOLD) { + float3 cachedRadiance = SharcDecodeRadiance(voxel.accumulatedRadiance, direction); +#if SHARC_MATERIAL_DEMODULATION + cachedRadiance *= hit.materialDemodulation; +#endif +#if SHARC_SEPARATE_EMISSIVE + cachedRadiance += hit.emissive; +#endif + // The cache owns diffuse transport; current-vertex specular/RIS/SSS remains live. + float3 resampledRadiance = cachedRadiance + liveDirectLighting; +#if !SHARC_SEPARATE_EMISSIVE + resampledRadiance += hit.emissive; +#endif + if (causticaFinite3(resampledRadiance, HALF_MAX) && all(resampledRadiance >= float3(0.0))) { + propagatedRadiance = resampledRadiance; + continueTracing = false; + } + } + } +#endif + causticaSharcPropagate(parameters, state, propagatedRadiance, antiFirefly); + if (!continueTracing) return false; + float3 demodulatedDirect = cacheableDirectLighting / demodulation; + if (!causticaCacheSampleUsable(parameters, demodulatedDirect, float3(1.0))) return false; + causticaSharcAddVoxelData(parameters, index, demodulatedDirect, float3(1.0), + direction, directionWeight, 1u, antiFirefly); + uint shiftCount = min(state.pathLength, uint(SHARC_PROPAGATION_DEPTH - 1)); + for (uint i = shiftCount; i > 0u; --i) { + state.cacheIndices[i] = state.cacheIndices[i - 1u]; + state.sampleWeights[i] = state.sampleWeights[i - 1u]; +#if SHARC_ENABLE_SH_ENCODING + state.radianceDirections[i] = state.radianceDirections[i - 1u]; + state.radianceDirectionWeights[i] = state.radianceDirectionWeights[i - 1u]; +#endif + } + state.cacheIndices[0] = index; + state.sampleWeights[0] = SharcSampleWeight(1.0 / demodulation); +#if SHARC_ENABLE_SH_ENCODING + state.radianceDirections[0] = direction; + state.radianceDirectionWeights[0] = directionWeight; +#endif + state.pathLength = min(state.pathLength + 1u, uint(SHARC_PROPAGATION_DEPTH)); +#endif + return true; +} + +bool causticaSharcSetThroughput(SharcParameters parameters, inout SharcState state, + float3 segmentThroughput) { +#if SHARC_UPDATE + if (!causticaCacheSampleUsable(parameters, segmentThroughput, float3(1.0))) { + state.pathLength = 0u; + return false; + } + SharcSetThroughput(state, segmentThroughput); +#endif + return true; +} + +SharcParameters causticaSharcParameters(uint64_t hashEntriesAddress, uint64_t accumulationAddress, + uint64_t resolvedAddress, uint capacity, float3 cameraPosition, + float sceneScale, float radianceScale, + float gridLogarithmBase, float gridLevelBias) { + SharcParameters parameters; + float3 safeCameraPosition = causticaFinite3(cameraPosition, CAUSTICA_SHARC_WORLD_LIMIT) + ? cameraPosition : float3(0.0, 0.0, 0.0); + parameters.hashGridParameters.cameraPosition = safeCameraPosition; + float safeLogarithmBase = gridLogarithmBase == gridLogarithmBase + && abs(gridLogarithmBase) <= 16.0 && gridLogarithmBase >= 1.01 ? gridLogarithmBase : 2.0; + float safeSceneScale = sceneScale == sceneScale + && abs(sceneScale) <= 100.0 && sceneScale >= 1.0 ? sceneScale : 1.0; + float safeLevelBias = gridLevelBias == gridLevelBias + && abs(gridLevelBias) <= 16.0 ? gridLevelBias : 0.0; + float safeRadianceScale = radianceScale == radianceScale + && abs(radianceScale) <= 1000.0 && radianceScale >= 50.0 ? radianceScale : 1000.0; + parameters.hashGridParameters.logarithmBase = safeLogarithmBase; + parameters.hashGridParameters.sceneScale = safeSceneScale; + parameters.hashGridParameters.levelBias = safeLevelBias; + parameters.hashGridData.capacity = capacity; + parameters.hashGridData.hashEntriesBuffer = SharcRWPtr(hashEntriesAddress); + parameters.radianceScale = safeRadianceScale; + parameters.accumulationBuffer = SharcRWPtr(accumulationAddress); + parameters.resolvedBuffer = SharcRWPtr(resolvedAddress); + return parameters; +} + +#endif diff --git a/shaders/sharc/sharc_resolve.comp.slang b/shaders/sharc/sharc_resolve.comp.slang new file mode 100644 index 00000000..9fe10894 --- /dev/null +++ b/shaders/sharc/sharc_resolve.comp.slang @@ -0,0 +1,36 @@ +// SHaRC 1.8 directional-SH resolve. The full cache is dispatched; invalid slots exit in the shader. +#define SHARC_UPDATE 0 +#define SHARC_QUERY 0 + +import world_common; +import sharc_types; +#include "sharc_bridge.slang" + +struct SharcResolvePush { + uint64_t sharcFrameAddr; +}; + +[[vk::push_constant]] SharcResolvePush resolvePush; + +[shader("compute")] +[numthreads(256, 1, 1)] +void main(uint3 id : SV_DispatchThreadID) { + SharcFrame frame = ConstPtr(resolvePush.sharcFrameAddr)[0]; + uint entryIndex = id.x; + if (entryIndex >= frame.capacity) return; + + HashGridKey hashKey = SharcRWPtr(frame.hashEntriesAddr)[entryIndex]; + if (hashKey == HASH_GRID_INVALID_HASH_KEY) return; + + SharcParameters parameters = causticaSharcParameters( + frame.hashEntriesAddr, frame.accumulationAddr, frame.resolvedAddr, frame.capacity, + frame.cameraPosition, frame.sceneScale, frame.radianceScale, + frame.gridLogarithmBase, frame.gridLevelBias); + SharcResolveParameters resolve; + resolve.cameraPositionPrev = causticaSharcResolvePreviousCamera(parameters, frame.cameraPositionPrev); + resolve.accumulationFrameNum = frame.accumulationFrameNum; + resolve.responsiveFrameNum = 1u; + resolve.staleFrameNumMax = frame.staleFrameNumMax; + resolve.frameIndex = frame.frameIndex; + SharcResolveEntry(entryIndex, parameters, resolve); +} diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 0088a319..da361697 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -56,9 +56,13 @@ public static void reloadFromSystemProperties() { public static void ensureRegistered() { @SuppressWarnings("unused") Object[] touch = { - Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, + Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Sharc.ENABLED, + Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, Rt.Lights.RIS_CANDIDATES, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.EntityTextures.MAX_TEXTURES, Rt.DlssRr.ENABLED, Rt.Fg.ENABLED, - Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Tonemap.GAMMA, Rt.FrameStats.ENABLED, + Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Exposure.LOW_PERCENTILE, Rt.Exposure.HIGH_PERCENTILE, + Rt.Exposure.PRE_EXPOSURE, Rt.Tonemap.GAMMA, + Rt.Sdr.TONE_MAPPER, Rt.Hdr.TONE_MAPPER, + Rt.FrameStats.ENABLED, Rt.Screenshots.EXR_ENABLED, Rt.Hdr.ENABLED, Ngx.PATH, }; } @@ -96,14 +100,18 @@ private static void writeComments() { " Controls direct lighting from glowing blocks such as torches, glowstone, and lava.\n" + " Set ris-candidates to 0 to disable it. stats, dump, and dump-radius are debugging options."); FILE.setComment("tonemap", - " Controls the final image. gamma: 1 is neutral; lower values brighten midtones."); + " Controls the final image. ACES 2.0 is the SDR and HDR default;\n" + + " PsychoV24 and the analytical operators are opt-in, with BT.2390 as the standards-based HDR\n" + + " alternative. gamma: 1 is neutral; lower values brighten midtones."); FILE.setComment("exposure", " Controls automatic exposure. manual-ev sets exposure in manual mode and adjusts it in auto mode.\n" + + " low/high-percentile define the histogram window; pre-exposure keeps stored radiance near mid-grey.\n" + " adapt-darken and adapt-brighten control adjustment speed in seconds.\n" + " sky-weight-cap and emissive-weight-cap limit how much bright areas affect exposure."); FILE.setComment("hdr", " HDR display output. Requires operating system and display support.\n" - + " ui-nits controls UI brightness; peak-nits must be 500, 1000, 2000, or 4000."); + + " ui-nits controls UI brightness; peak-nits uses 50-nit increments from 50 to 5000.\n" + + " ACES 2.0 uses the nearest baked HDR mastering target; analytical HDR modes use the exact value."); FILE.setComment("screenshots", " exr-enabled saves an ACEScg EXR beside the normal F2 PNG while ray tracing is active."); } @@ -529,6 +537,8 @@ private Rt() { } public static final class Composite { + /** Debug value that exposes the full-resolution path-traced image before reconstruction. */ + public static final int RAW_DEBUG_VIEW = 10; public static final IntSetting DEBUG_VIEW = intValue("caustica.rt.debugView", "composite.debug-view", 0); public static final IntSetting SPP = intAtLeast("caustica.rt.spp", "composite.spp", 1, 1); public static final IntSetting MAX_BOUNCES = @@ -548,6 +558,38 @@ private Composite() { } } + /** Runtime-safe controls for the optional, separately packaged SHaRC directional cache. */ + public static final class Sharc { + public static final BooleanSetting ENABLED = bool("caustica.rt.sharc.enabled", "sharc.enabled", true); + public static final IntSetting CACHE_EXPONENT = + clampedInt("caustica.rt.sharc.cacheExponent", "sharc.cache-exponent", 22, 16, 23); + public static final BooleanSetting ANTI_FIREFLY = bool( + "caustica.rt.sharc.antiFirefly", "sharc.anti-firefly", true); + /** Developer comparison mode; production keeps camera-visible primary surfaces live. */ + public static final BooleanSetting PRIMARY_SURFACE_DEBUG = bool( + "caustica.rt.sharc.primarySurfaceDebug", "sharc.primary-surface-debug", false); + public static final IntSetting UPDATE_TILE_SIZE = + clampedInt("caustica.rt.sharc.updateTileSize", "sharc.update-tile-size", 3, 2, 64); + public static final IntSetting ACCUMULATION_FRAMES = + clampedInt("caustica.rt.sharc.accumulationFrames", "sharc.accumulation-frames", 384, 1, 1024); + public static final IntSetting STALE_FRAMES = + clampedInt("caustica.rt.sharc.staleFrames", "sharc.stale-frames", 128, 8, 1024); + public static final FloatSetting SCENE_SCALE = finiteClampedFloat( + "caustica.rt.sharc.sceneScale", "sharc.scene-scale", 32.0f, 1.0f, 100.0f); + public static final FloatSetting RADIANCE_SCALE = finiteClampedFloat( + "caustica.rt.sharc.radianceScale", "sharc.radiance-scale", 1000.0f, 50.0f, 1000.0f); + public static final FloatSetting GRID_LOGARITHM_BASE = finiteClampedFloat( + "caustica.rt.sharc.gridLogarithmBase", "sharc.grid-logarithm-base", 3.0f, 1.01f, 16.0f); + public static final FloatSetting GRID_LEVEL_BIAS = finiteClampedFloat( + "caustica.rt.sharc.gridLevelBias", "sharc.grid-level-bias", 0.0f, -16.0f, 16.0f); + /** Additional minimum linear roughness for SHaRC diffuse ownership; zero preserves the mirror cutoff. */ + public static final FloatSetting ROUGHNESS_THRESHOLD = finiteClampedFloat( + "caustica.rt.sharc.roughnessThreshold", "sharc.roughness-threshold", 0.0f, 0.0f, 1.0f); + + private Sharc() { + } + } + public static final class Terrain { // External keys retain their historical "per-tick" names for config compatibility; terrain // streaming is render-pass driven and these Java names reflect the actual scheduling unit. @@ -571,7 +613,7 @@ private Terrain() { /** RIS block-emitter lights. {@code ris-candidates = 0} disables everything. */ public static final class Lights { public static final IntSetting RIS_CANDIDATES = - intAtLeast("caustica.rt.risCandidates", "lights.ris-candidates", 8, 0); + clampedInt("caustica.rt.risCandidates", "lights.ris-candidates", 8, 0, 32); public static final FloatSetting MIN_FILL_RATIO = finiteFloat("caustica.rt.lightMinFillRatio", "lights.min-fill-ratio", 0.25f); public static final BooleanSetting STATS = bool("caustica.rt.lightStats", "lights.stats", false); @@ -746,9 +788,9 @@ public static final class Exposure { public static final FloatSetting ADAPT_BRIGHTEN = exposureScale("caustica.rt.exposure.adaptBrighten", "exposure.adapt-brighten", 0.4f); public static final FloatSetting LOW_PERCENTILE = - clampedFloat("caustica.rt.exposure.lowPercentile", "exposure.low-percentile", 0.50f, 0.0f, 1.0f); + percentile("caustica.rt.exposure.lowPercentile", "exposure.low-percentile", 0.50f); public static final FloatSetting HIGH_PERCENTILE = - clampedFloat("caustica.rt.exposure.highPercentile", "exposure.high-percentile", 0.95f, 0.0f, 1.0f); + percentile("caustica.rt.exposure.highPercentile", "exposure.high-percentile", 0.95f); public static final IntSetting STRIDE = clampedInt("caustica.rt.exposure.stride", "exposure.stride", 2, 1, 8); public static final FloatSetting CENTER_WEIGHT_SIGMA = @@ -798,10 +840,11 @@ public static float clampScale(float value) { } private static String sanitizeMode(String value) { - if ("auto".equalsIgnoreCase(value)) { + String trimmed = value == null ? "" : value.trim(); + if ("auto".equalsIgnoreCase(trimmed)) { return "auto"; } - if ("manual".equalsIgnoreCase(value)) { + if ("manual".equalsIgnoreCase(trimmed)) { return "manual"; } return "auto"; @@ -818,6 +861,94 @@ private Tonemap() { } } + /** Selectable SDR operators. ACES 2.0 is the default baked-LUT path. */ + public static final class Sdr { + public static final StringSetting TONE_MAPPER = + string("caustica.rt.sdr.toneMapper", "sdr.tone-mapper", "aces2.0", + Sdr::sanitizeToneMapper); + public static final FloatSetting AGX_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.agx.contrast", "sdr.agx.contrast", 1.0f, 0.0f, 2.0f); + public static final FloatSetting AGX_SATURATION = + finiteClampedFloat("caustica.rt.sdr.agx.saturation", "sdr.agx.saturation", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PBR_START_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.pbrNeutral.startCompression", + "sdr.pbr-neutral.start-compression", 0.76f, 0.0f, 0.99f); + public static final FloatSetting PBR_DESATURATION = + finiteClampedFloat("caustica.rt.sdr.pbrNeutral.desaturation", + "sdr.pbr-neutral.desaturation", 0.15f, 0.0f, 1.0f); + public static final FloatSetting REINHARD_WHITE_POINT = + finiteClampedFloat("caustica.rt.sdr.reinhard.whitePoint", + "sdr.reinhard.white-point", 4.0f, 1.0f, 20.0f); + public static final FloatSetting ACES_EXPOSURE = + finiteClampedFloat("caustica.rt.sdr.aces.exposure", + "sdr.aces.exposure", 1.0f, 0.0f, 4.0f); + public static final FloatSetting LOTTES_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.lottes.contrast", + "sdr.lottes.contrast", 1.0f, 0.1f, 5.0f); + public static final FloatSetting LOTTES_SHOULDER = + finiteClampedFloat("caustica.rt.sdr.lottes.shoulder", + "sdr.lottes.shoulder", 1.0f, 0.1f, 5.0f); + public static final FloatSetting LOTTES_HDR_MAX = + finiteClampedFloat("caustica.rt.sdr.lottes.hdrMax", + "sdr.lottes.hdr-max", 16.0f, 1.0f, 64.0f); + public static final FloatSetting LOTTES_MID_IN = + finiteClampedFloat("caustica.rt.sdr.lottes.midIn", + "sdr.lottes.mid-in", 0.18f, 0.01f, 1.0f); + public static final FloatSetting LOTTES_MID_OUT = + finiteClampedFloat("caustica.rt.sdr.lottes.midOut", + "sdr.lottes.mid-out", 0.18f, 0.01f, 1.0f); + public static final FloatSetting UNCHARTED_A = + finiteClampedFloat("caustica.rt.sdr.uncharted2.a", "sdr.uncharted2.a", 0.15f, 0.01f, 1.0f); + public static final FloatSetting UNCHARTED_B = + finiteClampedFloat("caustica.rt.sdr.uncharted2.b", "sdr.uncharted2.b", 0.50f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_C = + finiteClampedFloat("caustica.rt.sdr.uncharted2.c", "sdr.uncharted2.c", 0.10f, 0.0f, 1.0f); + public static final FloatSetting UNCHARTED_D = + finiteClampedFloat("caustica.rt.sdr.uncharted2.d", "sdr.uncharted2.d", 0.20f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_E = + finiteClampedFloat("caustica.rt.sdr.uncharted2.e", "sdr.uncharted2.e", 0.02f, 0.0f, 1.0f); + public static final FloatSetting UNCHARTED_F = + finiteClampedFloat("caustica.rt.sdr.uncharted2.f", "sdr.uncharted2.f", 0.30f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_WHITE_POINT = + finiteClampedFloat("caustica.rt.sdr.uncharted2.whitePoint", + "sdr.uncharted2.white-point", 11.2f, 1.0f, 32.0f); + public static final FloatSetting GT_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.gt.contrast", "sdr.gt.contrast", 1.0f, 0.1f, 4.0f); + public static final FloatSetting GT_LINEAR_START = + finiteClampedFloat("caustica.rt.sdr.gt.linearStart", "sdr.gt.linear-start", 0.22f, 0.01f, 0.99f); + public static final FloatSetting GT_LINEAR_LENGTH = + finiteClampedFloat("caustica.rt.sdr.gt.linearLength", "sdr.gt.linear-length", 0.40f, 0.01f, 4.0f); + public static final FloatSetting GT_BLACK_CURVE = + finiteClampedFloat("caustica.rt.sdr.gt.blackCurve", "sdr.gt.black-curve", 1.33f, 0.1f, 4.0f); + public static final FloatSetting GT_BLACK_LIFT = + finiteClampedFloat("caustica.rt.sdr.gt.blackLift", "sdr.gt.black-lift", 0.0f, -0.5f, 0.5f); + public static final FloatSetting PSYCHOV24_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.psychov24.compression", + "sdr.psychov24.compression", 1.0f, 0.0f, 8.0f); + public static final FloatSetting PSYCHOV24_GAMUT_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.psychov24.gamutCompression", + "sdr.psychov24.gamut-compression", 1.0f, 0.0f, 1.0f); + public static final FloatSetting PSYCHOV24_HIGHLIGHTS = + finiteClampedFloat("caustica.rt.sdr.psychov24.highlights", + "sdr.psychov24.highlights", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_SHADOWS = + finiteClampedFloat("caustica.rt.sdr.psychov24.shadows", + "sdr.psychov24.shadows", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.psychov24.contrast", + "sdr.psychov24.contrast", 1.0f, 0.1f, 3.0f); + public static final FloatSetting PSYCHOV24_PURITY = + finiteClampedFloat("caustica.rt.sdr.psychov24.purity", + "sdr.psychov24.purity", 1.0f, 0.0f, 3.0f); + private Sdr() { + } + + private static String sanitizeToneMapper(String value) { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.SdrMode.parse(value).canonicalName(); + } + + } + /** Render-frame timing + hitch logging. See {@code RtFrameStats}. */ public static final class FrameStats { public static final BooleanSetting ENABLED = bool("caustica.rt.frameStats", "frame-stats.enabled", false); @@ -826,7 +957,7 @@ private FrameStats() { } } - /** Optional high-dynamic-range screenshot output paired with vanilla's F2 PNG. */ + /** Optional scene-linear HDR screenshot output paired with vanilla's F2 PNG. */ public static final class Screenshots { public static final BooleanSetting EXR_ENABLED = bool("caustica.rt.screenshots.exr", "screenshots.exr-enabled", false); @@ -854,18 +985,46 @@ private Diagnostics() { * HDR display output. When enabled the swapchain is created in PQ (ST.2084/HDR10 — the display-ready * encoding both HDR10 swapchains and DLSS Frame Generation require; whatever pixel format the surface * pairs with that color space, commonly a 10-bit UNORM), falling back to SDR if the surface doesn't - * advertise it. The ACES LUT owns scene-to-display mapping; {@code uiNits} places SDR-authored UI - * in that PQ output, while {@code peakNits} selects the LUT's mastering target. + * advertise it. The selected display transform owns scene-to-display mapping; {@code uiNits} places + * SDR-authored UI in that PQ output, while {@code peakNits} controls the display peak. ACES 2.0 is + * the default and selects the nearest baked HDR LUT; analytical modes use the exact configured peak. */ public static final class Hdr { public static final BooleanSetting ENABLED = bool("caustica.rt.hdr", "hdr.enabled", false); public static final FloatSetting UI_NITS = clampedFloat("caustica.rt.hdr.uiNits", "hdr.ui-nits", 200.0f, 80.0f, 500.0f); - - // ACES HDR LUTs are available only for these mastering targets. - public static final List PEAK_NITS_STEPS = List.of(500, 1000, 2000, 4000); + public static final FloatSetting PAPER_WHITE_NITS = + finiteClampedFloat("caustica.rt.hdr.paperWhiteNits", "hdr.paper-white-nits", 200.0f, 80.0f, 500.0f); + public static final StringSetting TONE_MAPPER = + string("caustica.rt.hdr.toneMapper", "hdr.tone-mapper", "aces2.0", + Hdr::sanitizeToneMapper); + public static final FloatSetting PSYCHOV24_COMPRESSION = + finiteClampedFloat("caustica.rt.hdr.psychov24.compression", + "hdr.psychov24.compression", 0.0f, 0.0f, 8.0f); + public static final FloatSetting PSYCHOV24_GAMUT_COMPRESSION = + finiteClampedFloat("caustica.rt.hdr.psychov24.gamutCompression", + "hdr.psychov24.gamut-compression", 1.0f, 0.0f, 1.0f); + public static final FloatSetting PSYCHOV24_HIGHLIGHTS = + finiteClampedFloat("caustica.rt.hdr.psychov24.highlights", + "hdr.psychov24.highlights", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_SHADOWS = + finiteClampedFloat("caustica.rt.hdr.psychov24.shadows", + "hdr.psychov24.shadows", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_CONTRAST = + finiteClampedFloat("caustica.rt.hdr.psychov24.contrast", + "hdr.psychov24.contrast", 1.0f, 0.1f, 3.0f); + public static final FloatSetting PSYCHOV24_PURITY = + finiteClampedFloat("caustica.rt.hdr.psychov24.purity", + "hdr.psychov24.purity", 1.0f, 0.0f, 3.0f); + public static final int PEAK_NITS_MIN = 50; + public static final int PEAK_NITS_MAX = 5000; + public static final int PEAK_NITS_STEP = 50; + // ACES 2.0 HDR LUTs are baked only for these mastering targets. Analytical HDR modes do + // not depend on this list and can use every 50-nit peak exposed by the control. + public static final List ACES_LUT_NITS = List.of(500, 1000, 2000, 4000); public static final IntSetting PEAK_NITS = - intChoice("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000, PEAK_NITS_STEPS); + quantizedInt("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000, + PEAK_NITS_MIN, PEAK_NITS_MAX, PEAK_NITS_STEP); // Surface capability and current swapchain state are separate: HDR controls remain available // while the swapchain is native SDR, so enabling HDR can recreate it in PQ. @@ -910,6 +1069,43 @@ public static float uiNits() { return UI_NITS.value(); } + public static float paperWhiteNits() { + // Keep an invalid persisted paper-white value from exceeding the selected display peak. + // The raw setting remains intact so raising the peak restores the user's requested value. + return Math.min(PAPER_WHITE_NITS.value(), PEAK_NITS.value()); + } + + /** Highlight headroom above paper white, in paper-white-referred units. */ + public static float headroom() { + return Math.max(1.0f, PEAK_NITS.value() / Math.max(1.0f, paperWhiteNits())); + } + + /** Selects the nearest packaged ACES 2.0 HDR LUT for the requested display peak. */ + public static int nearestAcesLutNits(int requestedNits) { + int nearest = ACES_LUT_NITS.get(0); + int nearestDistance = Math.abs(requestedNits - nearest); + for (int candidate : ACES_LUT_NITS) { + int distance = Math.abs(requestedNits - candidate); + if (distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + } + return nearest; + } + + /** Peak represented by the currently active HDR transform and its presentation metadata. */ + public static int effectivePeakNits() { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.parse(TONE_MAPPER.get()) + == dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.ACES_2_0 + ? nearestAcesLutNits(PEAK_NITS.value()) + : PEAK_NITS.value(); + } + + private static String sanitizeToneMapper(String value) { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.parse(value).canonicalName(); + } + } } @@ -944,6 +1140,12 @@ private static IntSetting intChoice(String key, String tomlPath, int fallback, L return new IntSetting(key, tomlPath, fallback, v -> choices.contains(v) ? v : fallback); } + private static IntSetting quantizedInt(String key, String tomlPath, int fallback, + int min, int max, int step) { + return new IntSetting(key, tomlPath, fallback, + v -> Math.clamp(Math.round(v / (float) step) * step, min, max)); + } + private static IntSetting clampedInt(String key, String tomlPath, int fallback, int min, int max) { return new IntSetting(key, tomlPath, fallback, v -> Math.clamp(v, min, max)); } @@ -956,10 +1158,21 @@ private static FloatSetting exposureScale(String key, String tomlPath, float fal return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, v -> Math.clamp(v, 1.0e-4, 1.0e4)); } + private static FloatSetting percentile(String key, String tomlPath, float fallback) { + return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, + v -> Double.isFinite(v) ? Math.clamp(v, 0.0, 1.0) : fallback); + } + private static FloatSetting clampedFloat(String key, String tomlPath, float fallback, float min, float max) { return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, v -> Math.clamp(v, min, max)); } + private static FloatSetting finiteClampedFloat(String key, String tomlPath, float fallback, + float min, float max) { + return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, + v -> Double.isFinite(v) ? Math.clamp(v, min, max) : fallback); + } + private static FloatSetting radians(String key, String tomlPath, float fallbackDegrees) { return new FloatSetting(key, tomlPath, fallbackDegrees, Math::toRadians, Math::toDegrees, v -> Double.isFinite(v) ? v : 0.0); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java b/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java new file mode 100644 index 00000000..409308c6 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java @@ -0,0 +1,48 @@ +package dev.comfyfluffy.caustica.client; + +/** Counts only fresh renderer frames in one fixed phase and resolution. */ +final class CaptureProgress { + enum Result { + WAITING, + COMPLETE, + INVALID_PHASE, + DIMENSIONS_CHANGED + } + + private int freshFrames; + private int targetFrames; + private int width; + private int height; + + Result acceptFreshFrame(int phaseCount, int frameWidth, int frameHeight) { + if (phaseCount <= 0) { + return Result.INVALID_PHASE; + } + if (targetFrames == 0) { + targetFrames = phaseCount; + width = frameWidth; + height = frameHeight; + } else if (phaseCount != targetFrames || frameWidth != width || frameHeight != height) { + return Result.DIMENSIONS_CHANGED; + } + if (freshFrames >= targetFrames) { + return Result.WAITING; + } + return ++freshFrames == targetFrames ? Result.COMPLETE : Result.WAITING; + } + + void reset() { + freshFrames = 0; + targetFrames = 0; + width = 0; + height = 0; + } + + int freshFrames() { + return freshFrames; + } + + int targetFrames() { + return targetFrames; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java b/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java new file mode 100644 index 00000000..2467fc98 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java @@ -0,0 +1,234 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.entity.RtEntities; +import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.server.IntegratedServer; + +/** Owns the single immutable renderer snapshot used by finite capture modes. */ +public final class CaptureSession { + public enum Owner { + ULTRA_SCREENSHOT + } + + private static Owner owner; + private static Object levelIdentity; + private static int settingsSignature; + private static boolean remoteSnapshot; + private static long nextScreenshotToken; + private static long screenshotToken; + private static boolean screenshotIsUltra; + private static final ThreadLocal SCREENSHOT_THREAD_TOKEN = new ThreadLocal<>(); + + private CaptureSession() { + } + + public static boolean begin(Minecraft minecraft, Owner requestedOwner) { + if (owner != null || requestedOwner == null || minecraft.level == null || minecraft.player == null) { + return false; + } + Object nextLevelIdentity = minecraft.level; + int nextSettingsSignature = settingsSignature(minecraft); + boolean nextRemoteSnapshot = !shouldPauseIntegratedServer(minecraft); + boolean entitiesStarted = false; + boolean compositeStarted = false; + try { + if (minecraft.gameMode != null) { + minecraft.gameMode.stopDestroyBlock(); + } + entitiesStarted = true; + RtEntities.INSTANCE.beginCaptureSession(); + compositeStarted = true; + RtComposite.INSTANCE.beginCaptureSession(); + KeyMapping.releaseAll(); + owner = requestedOwner; + levelIdentity = nextLevelIdentity; + settingsSignature = nextSettingsSignature; + remoteSnapshot = nextRemoteSnapshot; + return true; + } catch (Throwable failure) { + Throwable cleanupFailure = null; + if (compositeStarted) { + try { + RtComposite.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (entitiesStarted) { + try { + RtEntities.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (nextRemoteSnapshot) { + try { + RtTerrain.requestFullClear(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + throwUnchecked(failure); + return false; + } + } + + public static void end() { + if (owner == null) { + return; + } + boolean rebuildRemoteScene = remoteSnapshot; + Throwable failure = null; + try { + RtComposite.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + try { + RtEntities.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + if (rebuildRemoteScene) { + try { + RtTerrain.requestFullClear(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + } + owner = null; + levelIdentity = null; + remoteSnapshot = false; + if (failure != null) { + throwUnchecked(failure); + } + } + + public static boolean active() { + return owner != null; + } + + public static boolean ownedBy(Owner expected) { + return owner == expected; + } + + public static boolean valid(Minecraft minecraft) { + return active() + && minecraft.level != null + && minecraft.player != null + && minecraft.level == levelIdentity + && minecraft.gui.screen() == null + && settingsSignature == settingsSignature(minecraft); + } + + public static boolean shouldPause(Minecraft minecraft) { + return active() && shouldPauseIntegratedServer(minecraft); + } + + /** Serializes asynchronous manual PNG writes and rejects callbacks from an older renderer instance. */ + public static synchronized long acquireScreenshot(boolean ultra) { + if (screenshotToken != 0L) { + return 0L; + } + screenshotToken = ++nextScreenshotToken; + screenshotIsUltra = ultra; + return screenshotToken; + } + + public static synchronized boolean screenshotIsUltra(long token) { + return token != 0L && token == screenshotToken && screenshotIsUltra; + } + + /** Releases the lease from the final vanilla PNG callback or a cancelled F4 capture. */ + public static synchronized void releaseScreenshot(long token) { + if (token != 0L && token == screenshotToken) { + screenshotToken = 0L; + screenshotIsUltra = false; + } + } + + public static synchronized void discardScreenshotsForShutdown() { + screenshotToken = 0L; + screenshotIsUltra = false; + SCREENSHOT_THREAD_TOKEN.remove(); + } + + public static long screenshotThreadToken() { + Long token = SCREENSHOT_THREAD_TOKEN.get(); + return token == null ? 0L : token; + } + + public static void bindScreenshotThreadToken(long token) { + SCREENSHOT_THREAD_TOKEN.set(token); + } + + public static void clearScreenshotThreadToken(long token) { + if (screenshotThreadToken() == token) { + SCREENSHOT_THREAD_TOKEN.remove(); + } + } + + /** Runtime-only SPP override; the configured setting is never mutated or serialized. */ + public static int effectiveSpp(int configured) { + return owner == Owner.ULTRA_SCREENSHOT ? UltraScreenshot.SCREENSHOT_SPP : configured; + } + + /** Runtime-only DLSS quality override; quality 5 is NVIDIA's DLAA mode. */ + public static int effectiveDlssQuality(int configured) { + return owner == Owner.ULTRA_SCREENSHOT ? UltraScreenshot.DLAA_QUALITY : configured; + } + + private static boolean shouldPauseIntegratedServer(Minecraft minecraft) { + if (!minecraft.hasSingleplayerServer()) { + return false; + } + IntegratedServer server = minecraft.getSingleplayerServer(); + return server != null && shouldPauseIntegratedServer(true, server.isPublished()); + } + + static boolean shouldPauseIntegratedServer(boolean hasIntegratedServer, boolean publishedToLan) { + return hasIntegratedServer && !publishedToLan; + } + + private static int settingsSignature(Minecraft minecraft) { + CausticaConfig.ensureRegistered(); + int hash = 1; + for (CausticaConfig.RuntimeSetting setting : CausticaConfig.settings()) { + Object value = setting.get(); + hash = 31 * hash + setting.key().hashCode(); + hash = 31 * hash + (value == null ? 0 : value.hashCode()); + } + hash = 31 * hash + minecraft.options.fov().get().hashCode(); + hash = 31 * hash + minecraft.options.renderDistance().get().hashCode(); + hash = 31 * hash + minecraft.options.entityDistanceScaling().get().hashCode(); + hash = 31 * hash + minecraft.options.biomeBlendRadius().get().hashCode(); + hash = 31 * hash + minecraft.options.gamma().get().hashCode(); + hash = 31 * hash + minecraft.options.getCameraType().hashCode(); + return hash; + } + + private static Throwable appendFailure(Throwable first, Throwable next) { + if (first == null) { + return next; + } + first.addSuppressed(next); + return first; + } + + private static void throwUnchecked(Throwable failure) { + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + if (failure instanceof Error error) { + throw error; + } + throw new RuntimeException(failure); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java index c00b9e6f..078b6dc5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java @@ -15,9 +15,11 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.rendering.v1.InvalidateRenderStateCallback; +import net.minecraft.network.chat.Component; public final class CausticaClient implements ClientModInitializer { private static boolean rtInitDone = false; + private static boolean rtRestartRequired = false; @Override public void onInitializeClient() { @@ -32,9 +34,12 @@ public void onInitializeClient() { // The GpuDevice exists well before the first tick, so a one-shot at tick start // runs on the render thread with the device idle between frames. ClientTickEvents.START_CLIENT_TICK.register(client -> { + if (rtRestartRequired) { + return; + } if (!VanillaRenderController.rtRuntimeWorkRequested()) { if (rtInitDone) { - shutdownRt(); + shutdownRt(false); } return; } @@ -57,7 +62,9 @@ public void onInitializeClient() { // material flags resolve from the first section (PBR on join, no re-extract). No-op // until we're in a world with the block atlas loaded, or once already created. RtComposite.INSTANCE.ensureResourcesReady(ctx); - RtTerrain.update(ctx); + if (!CaptureSession.active()) { + RtTerrain.update(ctx); + } // Log DLSS-FG availability once when frame generation is enabled (capability query only; // the present-loop integration that consumes it is built separately). if (dev.comfyfluffy.caustica.rt.pipeline.RtDlssFg.enabled()) { @@ -72,17 +79,26 @@ public void onInitializeClient() { // world. Fixes stale geometry persisting across an End→Overworld switch (coords alone aren't // world-unique). Resource reloads do NOT fire this; that path is handled separately. InvalidateRenderStateCallback.EVENT.register(() -> { + UltraScreenshot.INSTANCE.abort( + Component.translatable("caustica.status.ultraScreenshot.invalidated")); RtTerrain.requestFullClear(); RtComposite.INSTANCE.resetExposureHistory(); RtComposite.INSTANCE.resetFailureLatch(); // F3+A doubles as manual RT recovery after a latched failure }); ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { - shutdownRt(); + shutdownRt(true); }); } - private static void shutdownRt() { + private static void shutdownRt(boolean finalClientStop) { + if (finalClientStop) { + UltraScreenshot.INSTANCE.shutdown(); + } else { + // A temporary RT teardown must not release a pending vanilla PNG write. Its callback + // remains the owner of the shared lease, preventing a second capture after RT restarts. + UltraScreenshot.INSTANCE.stopRenderer(); + } WorldRenderScaler.INSTANCE.destroy(); RtUiOverlay.destroy(); // GUI redirect is not gated by rtInitDone; always release its TextureTarget if (!rtInitDone) { @@ -100,7 +116,7 @@ private static void shutdownRt() { if (ctx != null) { RtEntities.INSTANCE.shutdown(); } - RtComposite.INSTANCE.destroy(); + boolean rrReleased = RtComposite.INSTANCE.destroy(); RtEntityTextures.INSTANCE.reset(); RtBlockMaterials.INSTANCE.destroy(); dev.comfyfluffy.caustica.rt.pipeline.RtDlssFg.INSTANCE.destroy(); @@ -108,11 +124,26 @@ private static void shutdownRt() { dev.comfyfluffy.caustica.rt.RtFramePresenter.INSTANCE.destroy(ctx.device()); dev.comfyfluffy.caustica.rt.RtReflex.INSTANCE.destroy(ctx.device().vkDevice()); } - // Shut NGX down once, after every feature (RR + FG) has been released above. - dev.comfyfluffy.caustica.ngx.NgxRuntime.INSTANCE.shutdown(); - if (ctx != null) { + // NGX and the Vulkan device may be destroyed only after every native feature released its owner. + boolean ngxReleased = rrReleased + && dev.comfyfluffy.caustica.ngx.NgxRuntime.INSTANCE.shutdown(); + boolean restartRequired = teardownRequiresRestart(rrReleased, ngxReleased); + if (ctx != null && !restartRequired) { ctx.destroy(); } rtInitDone = false; + if (restartRequired) { + rtRestartRequired = true; + CausticaMod.LOGGER.error("RT teardown retained native NGX ownership; restart is required"); + } + } + + static boolean teardownRequiresRestart(boolean rrReleased, boolean ngxReleased) { + return !rrReleased || !ngxReleased; + } + + /** Native teardown ownership failures keep RT off until process restart. */ + public static boolean rtRestartRequired() { + return rtRestartRequired; } } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java index b8cac74b..f4c96848 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java @@ -12,6 +12,7 @@ public final class CausticaJitter { public static final CausticaJitter INSTANCE = new CausticaJitter(); private int frameIndex; + private int phaseCount; private float pixelsX; private float pixelsY; @@ -19,13 +20,18 @@ private CausticaJitter() { } /** Advance one frame. Call once per frame before the level projection is built. */ - public void prepare(int renderWidth, int renderHeight, int displayWidth) { - int phaseCount = jitterPhaseCount(renderWidth, displayWidth); - int index = (this.frameIndex++ % phaseCount) + 1; // Halton(0) is degenerate + public void prepare(int renderWidth, int renderHeight, int displayWidth, int displayHeight) { + this.phaseCount = jitterPhaseCount(renderWidth, renderHeight, displayWidth, displayHeight); + int index = (this.frameIndex++ % this.phaseCount) + 1; // Halton(0) is degenerate this.pixelsX = halton(index, 2) - 0.5f; this.pixelsY = halton(index, 3) - 0.5f; } + /** Advance using a square display scale for callers that do not have the display height. */ + public void prepare(int renderWidth, int renderHeight, int displayWidth) { + prepare(renderWidth, renderHeight, displayWidth, displayWidth); + } + /** Jitter offset in render-pixel space, applied to the primary ray and reported to RR evaluate. */ public float jitterPixelsX() { return this.pixelsX; @@ -35,8 +41,22 @@ public float jitterPixelsY() { return this.pixelsY; } - private static int jitterPhaseCount(int renderWidth, int displayWidth) { - float ratio = (float) displayWidth / Math.max(1, renderWidth); + /** Reset the sequence and clear the current offset before a temporal A/B comparison. */ + public void reset() { + this.frameIndex = 0; + this.phaseCount = 0; + this.pixelsX = 0.0f; + this.pixelsY = 0.0f; + } + + public int currentPhaseCount() { + return this.phaseCount; + } + + static int jitterPhaseCount(int renderWidth, int renderHeight, int displayWidth, int displayHeight) { + float ratioX = (float) displayWidth / Math.max(1, renderWidth); + float ratioY = (float) displayHeight / Math.max(1, renderHeight); + float ratio = Math.max(ratioX, ratioY); return Math.max(32, (int) Math.ceil(8.0f * ratio * ratio)); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java new file mode 100644 index 00000000..c096513c --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java @@ -0,0 +1,18 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaMod; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +/** Player-facing Caustica key mappings grouped in Minecraft's Controls screen. */ +public final class CausticaKeyMappings { + public static final KeyMapping.Category CATEGORY = KeyMapping.Category.register( + Identifier.fromNamespaceAndPath(CausticaMod.MOD_ID, "controls")); + + private CausticaKeyMappings() { + } + + public static KeyMapping[] all() { + return new KeyMapping[] {UltraScreenshot.KEY}; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java b/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java new file mode 100644 index 00000000..827d2d60 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java @@ -0,0 +1,178 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.CausticaConfig.FloatSetting; +import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.RtSharcCache; +import java.util.List; +import java.util.Locale; +import net.minecraft.client.Minecraft; +import net.minecraft.client.OptionInstance; +import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.OptionsSubScreen; +import net.minecraft.network.chat.Component; + +/** Dedicated SHaRC controls for the directional-SH runtime that this jar actually packages. */ +public final class RtSharcOptionsScreen extends OptionsSubScreen { + private final Screen parentScreen; + + public RtSharcOptionsScreen(Screen parentScreen) { + super(parentScreen, Minecraft.getInstance().options, + Component.translatable("caustica.options.rt.sharcMenu.title")); + this.parentScreen = parentScreen; + } + + @Override + protected void addOptions() { + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.runtimeHeader")); + list.addSmall(sharcEnabled(), cacheExponent()); + list.addSmall(primarySurfaceDebug(), antiFirefly()); + list.addSmall(updateTileSize(), accumulationFrames()); + list.addSmall(staleFrames(), sceneScale()); + list.addSmall(radianceScale(), roughnessThreshold()); + list.addSmall(gridLogarithmBase(), gridLevelBias()); + + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.statusHeader")); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.runtime", RtComposite.INSTANCE.sharcStatus())))); + long memoryMiB = RtSharcCache.memoryBytesForExponent(CausticaConfig.Rt.Sharc.CACHE_EXPONENT.value()) + / (1024L * 1024L); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.memory", memoryMiB)))); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.layout")))); + + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.actionsHeader")); + list.addSmall(List.of( + Button.builder(Component.translatable("caustica.options.rt.sharcMenu.clear"), button -> { + RtComposite.INSTANCE.requestSharcReset(); + Minecraft.getInstance().setScreenAndShow(new RtSharcOptionsScreen(parentScreen)); + }).build(), + Button.builder(Component.translatable("caustica.options.rt.sharcMenu.defaults"), button -> { + restoreDefaults(); + Minecraft.getInstance().setScreenAndShow(new RtSharcOptionsScreen(parentScreen)); + }).build())); + } + + @Override + public void removed() { + CausticaConfig.save(); + super.removed(); + } + + private static OptionInstance sharcEnabled() { + return bool("caustica.options.rt.sharcEnabled", CausticaConfig.Rt.Sharc.ENABLED); + } + + private static OptionInstance cacheExponent() { + IntSetting setting = CausticaConfig.Rt.Sharc.CACHE_EXPONENT; + return integer("caustica.options.rt.sharcCacheExponent", 16, 23, setting.value(), setting::set, + value -> "2^" + value); + } + + private static OptionInstance primarySurfaceDebug() { + return bool("caustica.options.rt.sharcPrimarySurfaceDebug", + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG); + } + + private static OptionInstance antiFirefly() { + return bool("caustica.options.rt.sharcAntiFirefly", CausticaConfig.Rt.Sharc.ANTI_FIREFLY); + } + + private static OptionInstance updateTileSize() { + IntSetting setting = CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE; + return integer("caustica.options.rt.sharcUpdateTileSize", 2, 64, setting.value(), setting::set, + value -> value + "x" + value); + } + + private static OptionInstance accumulationFrames() { + IntSetting setting = CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES; + return integer("caustica.options.rt.sharcAccumulationFrames", 1, 1024, setting.value(), setting::set, + Object::toString); + } + + private static OptionInstance staleFrames() { + IntSetting setting = CausticaConfig.Rt.Sharc.STALE_FRAMES; + return integer("caustica.options.rt.sharcStaleFrames", 8, 1024, setting.value(), setting::set, + Object::toString); + } + + private static OptionInstance sceneScale() { + FloatSetting setting = CausticaConfig.Rt.Sharc.SCENE_SCALE; + return integer("caustica.options.rt.sharcSceneScale", 100, 10000, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance radianceScale() { + FloatSetting setting = CausticaConfig.Rt.Sharc.RADIANCE_SCALE; + return integer("caustica.options.rt.sharcRadianceScale", 50, 1000, + Math.round(setting.value()), value -> setting.set((float) value), Object::toString); + } + + private static OptionInstance roughnessThreshold() { + FloatSetting setting = CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD; + return integer("caustica.options.rt.sharcRoughnessThreshold", 0, 100, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance gridLogarithmBase() { + FloatSetting setting = CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE; + return integer("caustica.options.rt.sharcGridLogarithmBase", 101, 1600, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance gridLevelBias() { + FloatSetting setting = CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS; + return integer("caustica.options.rt.sharcGridLevelBias", -160, 160, + Math.round(setting.value() * 10.0f), value -> setting.set(value / 10.0f), + value -> String.format(Locale.ROOT, "%.1f", value / 10.0f)); + } + + private static OptionInstance bool(String key, CausticaConfig.BooleanSetting setting) { + return OptionInstance.createBoolean(key, + OptionInstance.cachedConstantTooltip(Component.translatable(key + ".tooltip")), + setting.value(), setting::set); + } + + private static OptionInstance integer(String key, int minimum, int maximum, int initial, + java.util.function.IntConsumer setter, + java.util.function.Function formatter) { + return new OptionInstance<>(key, + OptionInstance.cachedConstantTooltip(Component.translatable(key + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, Component.literal(formatter.apply(value))), + new OptionInstance.IntRange(minimum, maximum), Math.clamp(initial, minimum, maximum), + setter::accept); + } + + private static Button disabledButton(Component label) { + Button button = Button.builder(label, ignored -> { }).build(); + button.active = false; + return button; + } + + private static void restoreDefaults() { + CausticaConfig.Rt.Sharc.ENABLED.set(CausticaConfig.Rt.Sharc.ENABLED.defaultValue()); + CausticaConfig.Rt.Sharc.CACHE_EXPONENT.set(CausticaConfig.Rt.Sharc.CACHE_EXPONENT.defaultValue()); + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.set( + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.defaultValue()); + CausticaConfig.Rt.Sharc.ANTI_FIREFLY.set(CausticaConfig.Rt.Sharc.ANTI_FIREFLY.defaultValue()); + CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.set(CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.defaultValue()); + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.set( + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.defaultValue()); + CausticaConfig.Rt.Sharc.STALE_FRAMES.set(CausticaConfig.Rt.Sharc.STALE_FRAMES.defaultValue()); + CausticaConfig.Rt.Sharc.SCENE_SCALE.set(CausticaConfig.Rt.Sharc.SCENE_SCALE.defaultValue()); + CausticaConfig.Rt.Sharc.RADIANCE_SCALE.set(CausticaConfig.Rt.Sharc.RADIANCE_SCALE.defaultValue()); + CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.set( + CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.defaultValue()); + CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.set(CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.defaultValue()); + CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.set( + CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.defaultValue()); + RtComposite.INSTANCE.requestSharcReset(); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java b/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java new file mode 100644 index 00000000..55bf6406 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java @@ -0,0 +1,165 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.RtVideoOptions.ResettableControl; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Options; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.OptionsSubScreen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +/** + * Focused exposure and display-mapping page. The complete exposure surface is always available, + * while the curve section is rebuilt whenever the active output path's tone mapper changes so + * inactive algorithms never clutter the page. + */ +public final class RtToneMappingOptionsScreen extends OptionsSubScreen { + private static final long VISIBLE_STATE_DEBOUNCE_NANOS = 100_000_000L; + + private final List resettableControls = new ArrayList<>(); + private String visibleState; + private String pendingVisibleState; + private long pendingVisibleStateSince; + private ResettableControl toneMapperControl; + private boolean toneMapperDragging; + + public RtToneMappingOptionsScreen(Screen lastScreen, Options options) { + super( + lastScreen, + options, + Component.translatable("caustica.options.rt.toneMapping.title")); + } + + @Override + protected void addOptions() { + resettableControls.clear(); + toneMapperControl = null; + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.resetHint") + .withStyle(ChatFormatting.GRAY)); + + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.section.exposure")); + addSmall(RtVideoOptions.exposureOptions()); + + boolean hdr = requestedHdr(); + list.addHeader(Component.translatable( + hdr + ? "caustica.options.rt.toneMapping.section.hdrOutput" + : "caustica.options.rt.toneMapping.section.sdrOutput")); + toneMapperControl = hdr + ? RtVideoOptions.hdrToneMapper() + : RtVideoOptions.sdrToneMapper(); + addBig(toneMapperControl); + if (hdr) { + addSmall(RtVideoOptions.hdrDisplayOptions()); + } + + ResettableControl[] mapperOptions = + RtVideoOptions.activeToneMapperOptions(hdr); + if (mapperOptions.length > 0) { + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.section.activeMapper", + RtVideoOptions.activeToneMapperName(hdr))); + addSmall(mapperOptions); + } + visibleState = currentVisibleState(); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (event.button() == 0 && toneMapperControl != null) { + AbstractWidget widget = list.findOption(toneMapperControl.option()); + toneMapperDragging = widget != null + && widget.visible + && widget.active + && widget.isMouseOver(event.x(), event.y()); + } + if (event.button() == 0 && event.hasControlDown() && event.hasShiftDown()) { + for (ResettableControl control : resettableControls) { + AbstractWidget widget = list.findOption(control.option()); + if (widget != null + && widget.visible + && widget.active + && widget.isMouseOver(event.x(), event.y())) { + control.resetToDefault(); + list.resetOption(control.option()); + CausticaConfig.save(); + return true; + } + } + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + boolean handled = super.mouseReleased(event); + if (event.button() == 0) { + toneMapperDragging = false; + if (pendingVisibleState != null) { + pendingVisibleStateSince = System.nanoTime(); + } + } + return handled; + } + + @Override + public void tick() { + super.tick(); + String nextState = currentVisibleState(); + if (!nextState.equals(visibleState)) { + long now = System.nanoTime(); + if (!nextState.equals(pendingVisibleState)) { + pendingVisibleState = nextState; + pendingVisibleStateSince = now; + } else if (!toneMapperDragging + && now - pendingVisibleStateSince >= VISIBLE_STATE_DEBOUNCE_NANOS) { + list.applyUnsavedChanges(); + CausticaConfig.save(); + // OptionsSubScreen keeps this layout instance across rebuildWidgets(). + // Clear its frames as well as the screen widget list, otherwise every + // mapper change leaves another full options list stacked underneath. + layout.removeChildren(); + rebuildWidgets(); + pendingVisibleState = null; + } + } else { + pendingVisibleState = null; + } + } + + @Override + public void removed() { + super.removed(); + CausticaConfig.save(); + } + + private static boolean requestedHdr() { + return CausticaConfig.Rt.Hdr.ENABLED.value(); + } + + private static String currentVisibleState() { + boolean hdr = requestedHdr(); + return (hdr ? "hdr:" : "sdr:") + + (hdr + ? CausticaConfig.Rt.Hdr.TONE_MAPPER.get() + : CausticaConfig.Rt.Sdr.TONE_MAPPER.get()); + } + + private void addBig(ResettableControl control) { + resettableControls.add(control); + list.addBig(control.option()); + } + + private void addSmall(ResettableControl[] controls) { + for (ResettableControl control : controls) { + resettableControls.add(control); + } + list.addSmall(RtVideoOptions.optionInstances(controls)); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 8fa9206f..f0df43e4 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -6,12 +6,17 @@ import dev.comfyfluffy.caustica.CausticaConfig.FloatSetting; import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; import dev.comfyfluffy.caustica.CausticaConfig.StringSetting; +import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping; +import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; import java.util.ArrayList; import java.util.List; import java.util.Locale; import net.minecraft.client.Minecraft; import net.minecraft.client.OptionInstance; import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; /** @@ -28,9 +33,28 @@ * {@code quality} changes (see {@code RtDlssRr.ensureFeature}), so it is safe to expose here. */ public final class RtVideoOptions { + /** A tone-mapping submenu option paired with the source-defined default reset value. */ + public record ResettableControl(OptionInstance option, Runnable reset) { + public void resetToDefault() { + reset.run(); + } + } + private RtVideoOptions() { } + private static ResettableControl control(OptionInstance option, T defaultValue) { + return new ResettableControl(option, () -> option.set(defaultValue)); + } + + static OptionInstance[] optionInstances(ResettableControl[] controls) { + OptionInstance[] options = new OptionInstance[controls.length]; + for (int i = 0; i < controls.length; i++) { + options[i] = controls[i].option(); + } + return options; + } + /** * Runtime-tunable RT options, in display order. Paired two-per-row by {@code OptionsList.addSmall}. * The HDR entries are omitted entirely (not just disabled) when this session's swapchain isn't @@ -43,9 +67,13 @@ public static OptionInstance[] runtimeOptions() { List> options = new ArrayList<>(List.of( exposureMode(), manualEv(), + exposureLowPercentile(), + exposureHighPercentile(), + preExposure(), gamma(), spp(), maxBounces(), + risCandidates(), entities(), particles(), waterWaves(), @@ -54,12 +82,131 @@ public static OptionInstance[] runtimeOptions() { if (CausticaConfig.Rt.Hdr.swapchainPqAvailable()) { options.add(hdrEnabled()); options.add(hdrUiBrightness()); + options.add(hdrPaperWhite()); options.add(hdrPeak()); } - options.add(debugView()); return options.toArray(OptionInstance[]::new); } + /** Exposure and display controls shown by {@link RtToneMappingOptionsScreen}. */ + public static ResettableControl[] exposureOptions() { + return new ResettableControl[] { + control(exposureMode(), CausticaConfig.Rt.Exposure.MODE.defaultValue()), + control(manualEv(), Math.clamp(Math.round(CausticaConfig.Rt.Exposure.MANUAL_EV.defaultValue() * 10.0f), -150, 150)), + control(gamma(), Math.clamp(Math.round(CausticaConfig.Rt.Tonemap.GAMMA.defaultValue() * 100.0f), 50, 150)), + }; + } + + public static ResettableControl sdrToneMapper() { + StringSetting setting = CausticaConfig.Rt.Sdr.TONE_MAPPER; + return new ResettableControl( + toneMapper("caustica.options.rt.sdrToneMapper", RtToneMapping.sdrConfigNames(), setting), + () -> setting.set(setting.defaultValue())); + } + + public static ResettableControl hdrToneMapper() { + StringSetting setting = CausticaConfig.Rt.Hdr.TONE_MAPPER; + return new ResettableControl( + toneMapper("caustica.options.rt.hdrToneMapper", RtToneMapping.hdrConfigNames(), setting), + () -> setting.set(setting.defaultValue())); + } + + private static OptionInstance toneMapper(String captionKey, List values, StringSetting setting) { + int currentIndex = Math.clamp(values.indexOf(setting.get()), 0, values.size() - 1); + return new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, index) -> Component.translatable( + "caustica.options.rt.toneMapper." + values.get(Math.clamp(index, 0, values.size() - 1))), + new OptionInstance.IntRange(0, values.size() - 1), + currentIndex, + index -> setting.set(values.get(Math.clamp(index, 0, values.size() - 1)))); + } + + /** HDR display controls shown in the tone-mapping submenu with reset-to-default support. */ + public static ResettableControl[] hdrDisplayOptions() { + return new ResettableControl[] { + control( + hdrPaperWhite(), + Math.clamp(Math.round(CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS.defaultValue()), 80, 500)), + control( + hdrPeak(), + Math.clamp( + Math.round(CausticaConfig.Rt.Hdr.PEAK_NITS.defaultValue() + / (float) CausticaConfig.Rt.Hdr.PEAK_NITS_STEP), + CausticaConfig.Rt.Hdr.PEAK_NITS_MIN / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP, + CausticaConfig.Rt.Hdr.PEAK_NITS_MAX / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP)), + }; + } + + /** Controls for exactly the selected mapper; ACES 2.0 and BT.2390 have no extra parameters. */ + public static ResettableControl[] activeToneMapperOptions(boolean hdr) { + if (hdr) { + return switch (RtToneMapping.HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get())) { + case ACES_2_0, BT2390 -> new ResettableControl[0]; + case PSYCHOV24 -> psychoV24Options( + CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION, + CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION, + CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS, + CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS, + CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST, + CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY); + }; + } + return switch (RtToneMapping.SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get())) { + case ACES_2_0 -> new ResettableControl[0]; + case AGX -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.agxContrast", CausticaConfig.Rt.Sdr.AGX_CONTRAST, 100, 0, 200, 2), + scaledFloatControl("caustica.options.rt.agxSaturation", CausticaConfig.Rt.Sdr.AGX_SATURATION, 100, 0, 300, 2), + }; + case PBR_NEUTRAL -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.pbrStartCompression", CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION, 100, 0, 99, 2), + scaledFloatControl("caustica.options.rt.pbrDesaturation", CausticaConfig.Rt.Sdr.PBR_DESATURATION, 100, 0, 100, 2), + }; + case REINHARD -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.reinhardWhitePoint", CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT, 10, 10, 200, 1), + }; + case ACES -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.acesInputScale", CausticaConfig.Rt.Sdr.ACES_EXPOSURE, 100, 0, 400, 2), + }; + case LOTTES -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.lottesContrast", CausticaConfig.Rt.Sdr.LOTTES_CONTRAST, 100, 10, 500, 2), + scaledFloatControl("caustica.options.rt.lottesShoulder", CausticaConfig.Rt.Sdr.LOTTES_SHOULDER, 100, 10, 500, 2), + scaledFloatControl("caustica.options.rt.lottesHdrMax", CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX, 10, 10, 640, 1), + scaledFloatControl("caustica.options.rt.lottesMidIn", CausticaConfig.Rt.Sdr.LOTTES_MID_IN, 100, 1, 100, 2), + scaledFloatControl("caustica.options.rt.lottesMidOut", CausticaConfig.Rt.Sdr.LOTTES_MID_OUT, 100, 1, 100, 2), + }; + case UNCHARTED_2 -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.unchartedShoulderStrength", CausticaConfig.Rt.Sdr.UNCHARTED_A, 100, 1, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedLinearStrength", CausticaConfig.Rt.Sdr.UNCHARTED_B, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedLinearAngle", CausticaConfig.Rt.Sdr.UNCHARTED_C, 100, 0, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedToeStrength", CausticaConfig.Rt.Sdr.UNCHARTED_D, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedToeNumerator", CausticaConfig.Rt.Sdr.UNCHARTED_E, 100, 0, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedToeDenominator", CausticaConfig.Rt.Sdr.UNCHARTED_F, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedWhitePoint", CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT, 10, 10, 320, 1), + }; + case GT -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.gtContrast", CausticaConfig.Rt.Sdr.GT_CONTRAST, 100, 10, 400, 2), + scaledFloatControl("caustica.options.rt.gtLinearStart", CausticaConfig.Rt.Sdr.GT_LINEAR_START, 100, 1, 99, 2), + scaledFloatControl("caustica.options.rt.gtLinearLength", CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH, 100, 1, 400, 2), + scaledFloatControl("caustica.options.rt.gtBlackCurve", CausticaConfig.Rt.Sdr.GT_BLACK_CURVE, 100, 10, 400, 2), + scaledFloatControl("caustica.options.rt.gtBlackLift", CausticaConfig.Rt.Sdr.GT_BLACK_LIFT, 100, -50, 50, 2), + }; + case PSYCHOV24 -> psychoV24Options( + CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION, + CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION, + CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS, + CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS, + CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST, + CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY); + }; + } + + public static Component activeToneMapperName(boolean hdr) { + String name = hdr ? CausticaConfig.Rt.Hdr.TONE_MAPPER.get() : CausticaConfig.Rt.Sdr.TONE_MAPPER.get(); + return Component.translatable("caustica.options.rt.toneMapper." + name); + } + private static OptionInstance exposureMode() { StringSetting setting = CausticaConfig.Rt.Exposure.MODE; return new OptionInstance<>( @@ -89,6 +236,31 @@ private static OptionInstance manualEv() { tenths -> setting.set(tenths / 10.0f)); } + private static OptionInstance exposureLowPercentile() { + return percentile("caustica.options.rt.exposureLowPercentile", + CausticaConfig.Rt.Exposure.LOW_PERCENTILE); + } + + private static OptionInstance exposureHighPercentile() { + return percentile("caustica.options.rt.exposureHighPercentile", + CausticaConfig.Rt.Exposure.HIGH_PERCENTILE); + } + + private static OptionInstance percentile(String captionKey, FloatSetting setting) { + return new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, percent) -> Options.genericValueLabel(caption, + Component.literal(percent + "%")), + new OptionInstance.IntRange(0, 100), + Math.clamp(Math.round(setting.value() * 100.0f), 0, 100), + percent -> setting.set(percent / 100.0f)); + } + + private static OptionInstance preExposure() { + return bool("caustica.options.rt.preExposure", CausticaConfig.Rt.Exposure.PRE_EXPOSURE); + } + private static OptionInstance gamma() { FloatSetting setting = CausticaConfig.Rt.Tonemap.GAMMA; return new OptionInstance<>( @@ -123,6 +295,30 @@ private static OptionInstance maxBounces() { setting::set); } + private static OptionInstance risCandidates() { + IntSetting setting = CausticaConfig.Rt.Lights.RIS_CANDIDATES; + return new OptionInstance<>( + "caustica.options.rt.risCandidates", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.risCandidates.tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + value == 0 + ? Component.translatable("caustica.options.rt.risCandidates.off") + : Component.literal(value + " candidates")), + new OptionInstance.IntRange(0, 32), + Math.clamp(setting.value(), 0, 32), + value -> { + if (setting.value() == value) { + return; + } + setting.set(value); + // Meshing omits emitter records while RIS is disabled; rebuild residency when the + // setting changes so the selected light population reaches the next render. DLSS-RR + // intentionally keeps its history here: this is a gradual lighting change, not a + // camera, dimension, resolution, or feature discontinuity. + RtTerrain.requestFullClear(); + }); + } + private static OptionInstance entities() { return bool("caustica.options.rt.entities", CausticaConfig.Rt.Entities.ENABLED); } @@ -177,22 +373,118 @@ private static OptionInstance hdrUiBrightness() { nits -> setting.set(nits.floatValue())); } - // Each step selects a baked ACES HDR mastering target. Changes take effect on the next frame. + private static OptionInstance hdrPaperWhite() { + FloatSetting setting = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; + return new OptionInstance<>( + "caustica.options.rt.hdrPaperWhite", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPaperWhite.tooltip")), + (caption, nits) -> Options.genericValueLabel(caption, Component.literal(nits + " nits")), + new OptionInstance.IntRange(80, 500), + Math.clamp(Math.round(setting.value()), 80, 500), + nits -> setting.set(nits.floatValue())); + } + + // Each position is one 50-nit increment. ACES 2.0 selects the nearest baked LUT; analytical HDR + // modes use the exact selected peak. Changes take effect on the next frame. private static OptionInstance hdrPeak() { IntSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS; - List steps = CausticaConfig.Rt.Hdr.PEAK_NITS_STEPS; - int initialPeak = steps.contains(setting.value()) ? setting.value() : 1000; - int initialPosition = steps.indexOf(initialPeak); + int minPosition = CausticaConfig.Rt.Hdr.PEAK_NITS_MIN / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP; + int maxPosition = CausticaConfig.Rt.Hdr.PEAK_NITS_MAX / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP; + int initialPosition = Math.clamp( + Math.round(setting.value() / (float) CausticaConfig.Rt.Hdr.PEAK_NITS_STEP), + minPosition, + maxPosition); return new OptionInstance<>( "caustica.options.rt.hdrPeak", OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPeak.tooltip")), - (caption, position) -> Options.genericValueLabel(caption, Component.literal(steps.get(position) + " nits")), - new OptionInstance.IntRange(0, steps.size() - 1), - Math.max(initialPosition, 0), - position -> setting.set(steps.get(position))); + (caption, position) -> Options.genericValueLabel(caption, + Component.literal(position * CausticaConfig.Rt.Hdr.PEAK_NITS_STEP + " nits")), + new OptionInstance.IntRange(minPosition, maxPosition), + initialPosition, + position -> setting.set(position * CausticaConfig.Rt.Hdr.PEAK_NITS_STEP)); + } + + private static ResettableControl[] psychoV24Options( + FloatSetting compression, + FloatSetting gamutCompression, + FloatSetting highlights, + FloatSetting shadows, + FloatSetting contrast, + FloatSetting purity) { + return new ResettableControl[] { + psychoCompressionControl(compression), + percentageControl("caustica.options.rt.psychov24GamutCompression", gamutCompression), + percentageControl("caustica.options.rt.psychov24Highlights", highlights, 0, 300), + percentageControl("caustica.options.rt.psychov24Shadows", shadows, 0, 300), + percentageControl("caustica.options.rt.psychov24Contrast", contrast, 10, 300), + percentageControl("caustica.options.rt.psychov24Purity", purity, 0, 300), + }; + } + + private static ResettableControl psychoCompressionControl(FloatSetting setting) { + String captionKey = "caustica.options.rt.psychov24Compression"; + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + value == 0 + ? Component.translatable(captionKey + ".auto") + : Component.literal(String.format(Locale.ROOT, "%.2f", value / 100.0f))), + new OptionInstance.IntRange(0, 800), + Math.clamp(Math.round(setting.value() * 100.0f), 0, 800), + value -> setting.set(value / 100.0f)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * 100.0f), 0, 800)); + } + + private static ResettableControl scaledFloatControl( + String captionKey, FloatSetting setting, int scale, int min, int max, int decimals) { + return scaledFloatControl(captionKey, setting, scale, min, max, decimals, ""); + } + + private static ResettableControl scaledFloatControl( + String captionKey, FloatSetting setting, int scale, int min, int max, int decimals, String suffix) { + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + Component.literal(String.format(Locale.ROOT, "%." + decimals + "f%s", + value / (float) scale, suffix))), + new OptionInstance.IntRange(min, max), + Math.clamp(Math.round(setting.value() * scale), min, max), + value -> setting.set(value / (float) scale)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * scale), min, max)); + } + + private static ResettableControl percentageControl(String captionKey, FloatSetting setting) { + return percentageControl(captionKey, setting, 0, 100); + } + + private static ResettableControl percentageControl( + String captionKey, FloatSetting setting, int min, int max) { + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, Component.literal(value + "%")), + new OptionInstance.IntRange(min, max), + Math.clamp(Math.round(setting.value() * 100.0f), min, max), + value -> setting.set(value / 100.0f)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * 100.0f), min, max)); + } + + /** Launcher paired with Debug View on the main Video Settings page. */ + public static Button toneMappingButton(Screen parent, Runnable beforeOpen) { + return Button.builder( + Component.translatable("caustica.options.rt.toneMappingMenu"), + button -> { + beforeOpen.run(); + Minecraft minecraft = Minecraft.getInstance(); + minecraft.setScreenAndShow(new RtToneMappingOptionsScreen(parent, minecraft.options)); + }) + .tooltip(Tooltip.create(Component.translatable("caustica.options.rt.toneMappingMenu.tooltip"))) + .build(); } - private static OptionInstance debugView() { + public static OptionInstance debugView() { IntSetting setting = CausticaConfig.Rt.Composite.DEBUG_VIEW; return new OptionInstance<>( "caustica.options.rt.debugView", @@ -200,8 +492,9 @@ private static OptionInstance debugView() { // CycleButton (used for Enum values) already prepends "caption: " itself (DisplayState. // NAME_AND_VALUE), so this must return only the value's text, not caption + value again. (caption, value) -> Component.translatable("caustica.options.rt.debugView." + value), - new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), Codec.INT), - Math.clamp(setting.value(), 0, 9), + new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW), Codec.INT), + Math.clamp(setting.value(), 0, CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW), setting::set); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java b/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java new file mode 100644 index 00000000..6a4532f5 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java @@ -0,0 +1,264 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.pipeline.RtDlssRr; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +/** One-shot DLAA screenshot capture over one complete low-discrepancy jitter phase. */ +public final class UltraScreenshot { + public static final UltraScreenshot INSTANCE = new UltraScreenshot(); + public static final KeyMapping KEY = new KeyMapping( + "key.caustica.ultra_screenshot", GLFW.GLFW_KEY_F4, CausticaKeyMappings.CATEGORY); + + static final int DLAA_QUALITY = 5; + static final int SCREENSHOT_SPP = 8; + private static final long FRESH_FRAME_TIMEOUT_NANOS = 30_000_000_000L; + + private final CaptureProgress progress = new CaptureProgress(); + private long lastFreshFrameNanos; + private int width; + private int height; + private long captureLease; + + private UltraScreenshot() { + } + + public boolean active() { + return CaptureSession.ownedBy(CaptureSession.Owner.ULTRA_SCREENSHOT); + } + + public void toggle(Minecraft minecraft) { + if (active()) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.cancelled")); + return; + } + if (minecraft.level == null || minecraft.player == null) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.requiresWorld")); + return; + } + if (minecraft.gui.screen() != null) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + if (!CausticaConfig.Rt.ENABLED.value() || !CausticaConfig.Rt.DlssRr.ENABLED.value() + || CausticaConfig.Rt.Composite.DEBUG_VIEW.value() != 0 + || !RtComposite.INSTANCE.readyForUltraScreenshot()) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.requiresDlssRr")); + return; + } + long lease = CaptureSession.acquireScreenshot(true); + if (lease == 0L) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + try { + if (!CaptureSession.begin(minecraft, CaptureSession.Owner.ULTRA_SCREENSHOT)) { + CaptureSession.releaseScreenshot(lease); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + } catch (Throwable t) { + CaptureSession.releaseScreenshot(lease); + CausticaMod.LOGGER.error("Ultra screenshot session start failed", t); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + captureLease = lease; + try { + progress.reset(); + width = minecraft.getWindow().getWidth(); + height = minecraft.getWindow().getHeight(); + lastFreshFrameNanos = System.nanoTime(); + // Reset jitter and reconstruction history while retaining the valid exposure image owned by the session. + RtComposite.INSTANCE.requestTemporalReset(false); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.started", SCREENSHOT_SPP)); + } catch (Throwable t) { + CausticaMod.LOGGER.error("Ultra screenshot setup failed", t); + restore(); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + } + } + + /** Per-render validation, including frames where the level compositor did not produce an image. */ + public void beginFrame(Minecraft minecraft) { + if (!active()) { + return; + } + if (!CaptureSession.valid(minecraft)) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.invalidated")); + return; + } + if (minecraft.getWindow().getWidth() != width + || minecraft.getWindow().getHeight() != height) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.resized")); + return; + } + if (!RtDlssRr.enabled() || RtDlssRr.INSTANCE.hasFailed() || RtComposite.INSTANCE.hasFailed()) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + if (System.nanoTime() - lastFreshFrameNanos > FRESH_FRAME_TIMEOUT_NANOS) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.timedOut")); + } + } + + /** Called from {@code GameRenderer.render} at TAIL, after the final world, hand, and UI image exists. */ + public void frameRendered(Minecraft minecraft) { + if (!active() || !RtComposite.INSTANCE.producedFreshDlssRrFrame()) { + return; + } + CaptureProgress.Result result = progress.acceptFreshFrame( + RtComposite.INSTANCE.currentJitterPhaseCount(), + minecraft.getWindow().getWidth(), minecraft.getWindow().getHeight()); + if (result == CaptureProgress.Result.INVALID_PHASE) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + if (result == CaptureProgress.Result.DIMENSIONS_CHANGED) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.resized")); + return; + } + lastFreshFrameNanos = System.nanoTime(); + if (result != CaptureProgress.Result.COMPLETE) { + return; + } + + long lease = captureLease; + if (!CaptureSession.screenshotIsUltra(lease)) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + + // The wrapped callback owns the lease after grab queues the asynchronous GPU copy. + boolean screenshotSubmitted = false; + try { + CaptureSession.bindScreenshotThreadToken(lease); + restoreForOutput(); + Screenshot.grab(minecraft, false); + screenshotSubmitted = true; + } catch (Throwable t) { + CausticaMod.LOGGER.error("Ultra screenshot capture failed", t); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + } finally { + CaptureSession.clearScreenshotThreadToken(lease); + if (!screenshotSubmitted) { + CaptureSession.releaseScreenshot(lease); + } + captureLease = 0L; + } + } + + public void abort(Component reason) { + if (!active()) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + restore(); + notify(minecraft, reason); + } + + /** Ends an active renderer-owned capture. */ + public void stopRenderer() { + if (active()) { + restore(); + } + } + + public void shutdown() { + stopRenderer(); + captureLease = 0L; + CaptureSession.discardScreenshotsForShutdown(); + } + + private void cancel(Minecraft minecraft, Component reason) { + if (!active()) { + return; + } + restore(); + notify(minecraft, reason); + } + + private void restore() { + restoreState(false); + } + + private void restoreForOutput() { + Throwable failure = restoreState(true); + if (failure != null) { + throw new IllegalStateException("Ultra screenshot cleanup failed", failure); + } + } + + private Throwable restoreState(boolean retainLease) { + boolean recoverRenderer = shouldRecoverRenderer( + RtDlssRr.INSTANCE.hasFailed(), RtComposite.INSTANCE.hasFailed()); + Throwable failure = restoreRendererState( + recoverRenderer, + CaptureSession::end, + RtComposite.INSTANCE::resetFailureLatch, + RtComposite.INSTANCE::requestTemporalReset); + progress.reset(); + lastFreshFrameNanos = 0L; + width = 0; + height = 0; + if (!retainLease && captureLease != 0L) { + CaptureSession.releaseScreenshot(captureLease); + captureLease = 0L; + } + if (failure != null) { + CausticaMod.LOGGER.error("Ultra screenshot cleanup failed", failure); + } + return failure; + } + + static Throwable restoreRendererState(boolean recoverRenderer, + Runnable endCapture, + Runnable resetFailure, + Runnable resetTemporal) { + Throwable failure = null; + try { + endCapture.run(); + } catch (Throwable t) { + failure = t; + } + if (recoverRenderer) { + try { + // CaptureSession.end restores the configured RR quality before ordinary rendering retries. + resetFailure.run(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + } + try { + resetTemporal.run(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + return failure; + } + + static boolean shouldRecoverRenderer(boolean rrFailed, boolean compositeFailed) { + return rrFailed || compositeFailed; + } + + private static Throwable appendFailure(Throwable first, Throwable next) { + if (first == null) { + return next; + } + first.addSuppressed(next); + return first; + } + + private static void notify(Minecraft minecraft, Component message) { + if (minecraft.player != null) { + minecraft.player.sendOverlayMessage(message); + } + } + +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java b/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java index 26eb31e9..c09f1e18 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java @@ -31,7 +31,7 @@ public void beginFrame(RenderTarget mainTarget) { this.worldSkipped = false; this.baseReady = false; this.inactiveReason = null; - this.rtActive = RtComposite.enabled(); + this.rtActive = rtRuntimeWorkRequested(); if (!Boolean.valueOf(this.rtActive).equals(this.lastLoggedRtActive)) { this.lastLoggedRtActive = this.rtActive; @@ -105,7 +105,7 @@ public boolean shouldCompositeRt() { /** Runtime work switch for per-frame RT work; mirrors {@link RtComposite#enabled()}. */ public static boolean rtRuntimeWorkRequested() { - return RtComposite.enabled(); + return !CausticaClient.rtRestartRequired() && RtComposite.enabled(); } public void markRtCompositeResult(boolean success) { diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java index 15a7593b..9bd991b6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java @@ -5,6 +5,7 @@ import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vulkan.VulkanDevice; +import dev.comfyfluffy.caustica.client.UltraScreenshot; import dev.comfyfluffy.caustica.client.VanillaRenderController; import dev.comfyfluffy.caustica.client.WorldRenderScaler; import dev.comfyfluffy.caustica.rt.RtComposite; @@ -12,6 +13,7 @@ import dev.comfyfluffy.caustica.rt.RtUiOverlay; import dev.comfyfluffy.caustica.rt.overlay.RtWorldOverlay; import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.SubmitNodeStorage; import net.minecraft.client.renderer.feature.FeatureRenderDispatcher; @@ -62,6 +64,7 @@ public abstract class GameRendererMixin { @Inject(method = "render(Lnet/minecraft/client/DeltaTracker;Z)V", at = @At("TAIL")) private void caustica$endRtFrameStats(DeltaTracker deltaTracker, boolean advanceGameTime, CallbackInfo ci) { RtComposite.INSTANCE.endFrame(); + UltraScreenshot.INSTANCE.frameRendered(Minecraft.getInstance()); } @Inject(method = "render(Lnet/minecraft/client/DeltaTracker;Z)V", @@ -138,9 +141,9 @@ public abstract class GameRendererMixin { return projection; } - var cameraState = this.gameRenderState().levelRenderState.cameraRenderState; - RtComposite.INSTANCE.captureFrame(projection, cameraState.viewRotationMatrix, - cameraState.pos.x, cameraState.pos.y, cameraState.pos.z); + var cameraState = this.gameRenderState().levelRenderState.cameraRenderState; + RtComposite.INSTANCE.captureFrame(projection, cameraState.viewRotationMatrix, + cameraState.pos.x, cameraState.pos.y, cameraState.pos.z, cameraState.fogData); VanillaRenderController.INSTANCE.markProjectionCaptured(); return projection; } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java new file mode 100644 index 00000000..fbb9eb12 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java @@ -0,0 +1,20 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Routes unshared single-player captures through Minecraft's normal pause decision. */ +@Mixin(Gui.class) +public abstract class GuiMixin { + @Inject(method = "isPausing", at = @At("HEAD"), cancellable = true) + private void caustica$pauseLocalCapture(CallbackInfoReturnable cir) { + if (CaptureSession.shouldPause(Minecraft.getInstance())) { + cir.setReturnValue(true); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java new file mode 100644 index 00000000..e0d41a09 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java @@ -0,0 +1,33 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import dev.comfyfluffy.caustica.client.UltraScreenshot; +import net.minecraft.client.KeyboardHandler; +import net.minecraft.client.Minecraft; +import net.minecraft.client.input.KeyEvent; +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Suppresses keyboard mutations during capture while preserving releases and the F4 cancel action. */ +@Mixin(KeyboardHandler.class) +public abstract class KeyboardHandlerMixin { + @Inject(method = "keyPress", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureKeys(long window, int action, KeyEvent event, CallbackInfo ci) { + Minecraft minecraft = Minecraft.getInstance(); + boolean ultraToggle = action == GLFW.GLFW_PRESS + && !minecraft.options.keyDebugModifier.isDown() + && UltraScreenshot.KEY.matches(event); + if (shouldSuppressCaptureKey(CaptureSession.active(), action, ultraToggle)) { + ci.cancel(); + } + } + + static boolean shouldSuppressCaptureKey(boolean captureActive, int action, boolean ultraToggle) { + return captureActive + && action != GLFW.GLFW_RELEASE + && !(action == GLFW.GLFW_PRESS && ultraToggle); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java new file mode 100644 index 00000000..caad5f35 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java @@ -0,0 +1,23 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.player.ClientInput; +import net.minecraft.client.player.KeyboardInput; +import net.minecraft.world.entity.player.Input; +import net.minecraft.world.phys.Vec2; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Prevents a remote capture camera from continuing to drive player movement packets. */ +@Mixin(KeyboardInput.class) +public abstract class KeyboardInputMixin extends ClientInput { + @Inject(method = "tick", at = @At("TAIL")) + private void caustica$freezeCaptureMovement(CallbackInfo ci) { + if (CaptureSession.active()) { + keyPresses = Input.EMPTY; + moveVector = Vec2.ZERO; + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java index cb5da73f..126d577e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java @@ -1,17 +1,26 @@ package dev.comfyfluffy.caustica.mixin; import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.vulkan.VulkanDevice; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.comfyfluffy.caustica.client.CaptureSession; +import dev.comfyfluffy.caustica.client.UltraScreenshot; import dev.comfyfluffy.caustica.rt.RtReflex; import dev.comfyfluffy.caustica.rt.RtUiOverlay; import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; + +import java.io.File; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** * The Reflex per-frame sleep call must run at the very start of the frame, before input @@ -30,9 +39,57 @@ public abstract class MinecraftMixin { @Inject(method = "close", at = @At("HEAD")) private void caustica$destroyUiOverlayBeforeRendererShutdown(CallbackInfo ci) { + UltraScreenshot.INSTANCE.shutdown(); RtUiOverlay.destroy(); } + @Inject(method = "handleGlobalKeyPress", at = @At("HEAD"), cancellable = true) + private void caustica$handleCaptureKeys(InputConstants.Key key, boolean controlDown, + CallbackInfoReturnable cir) { + Minecraft minecraft = (Minecraft) (Object) this; + if (!minecraft.options.keyDebugModifier.isDown() && UltraScreenshot.KEY.matches(key)) { + UltraScreenshot.INSTANCE.toggle(minecraft); + cir.setReturnValue(true); + } + } + + /** Rejects panorama capture before vanilla mutates the camera, window, or render target. */ + @WrapMethod(method = "grabPanoramixScreenshot(Ljava/io/File;)Lnet/minecraft/network/chat/Component;") + private Component caustica$guardPanorama(File directory, Operation original) { + if (CaptureSession.active()) { + return Component.translatable("caustica.status.ultraScreenshot.busy"); + } + return original.call(directory); + } + + @Inject(method = "startAttack", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureAttack(CallbackInfoReturnable cir) { + if (CaptureSession.active()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "continueAttack", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureAttackHold(boolean leftClick, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "startUseItem", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureUse(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "pickBlockOrEntity", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCapturePick(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + @Inject(method = "runTick", at = @At("HEAD")) private void caustica$reflexSleepAndSimStart(boolean advanceGameTime, CallbackInfo ci) { VulkanDevice device = caustica$reflexDevice(); diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java index 19b9a512..c2d02eeb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java @@ -1,6 +1,8 @@ package dev.comfyfluffy.caustica.mixin; import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.client.UltraScreenshot; +import net.minecraft.network.chat.Component; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -24,6 +26,7 @@ public class MinecraftReloadMixin { @Inject(method = "reloadResourcePacks()Ljava/util/concurrent/CompletableFuture;", at = @At("HEAD")) private void caustica$rtReloadStart(CallbackInfoReturnable> cir) { + UltraScreenshot.INSTANCE.abort(Component.translatable("caustica.status.ultraScreenshot.invalidated")); RtComposite.INSTANCE.onResourceReloadStart(); } } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java new file mode 100644 index 00000000..32dfeb37 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java @@ -0,0 +1,26 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.MouseHandler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Keeps the capture camera immutable without blocking GLFW event processing. */ +@Mixin(MouseHandler.class) +public abstract class MouseHandlerMixin { + @Inject(method = "onScroll", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureScroll(long window, double horizontal, double vertical, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "turnPlayer", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureCamera(double frameTime, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java new file mode 100644 index 00000000..04226804 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java @@ -0,0 +1,30 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CausticaKeyMappings; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Options; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Arrays; + +/** Adds Caustica's capture key to the vanilla Controls screen. */ +@Mixin(Options.class) +public abstract class OptionsMixin { + @Shadow @Final @Mutable public KeyMapping[] keyMappings; + + @Inject(method = "", at = @At(value = "FIELD", + target = "Lnet/minecraft/client/Options;keyMappings:[Lnet/minecraft/client/KeyMapping;", + shift = At.Shift.AFTER)) + private void caustica$addKeyMappings(CallbackInfo ci) { + KeyMapping[] additions = CausticaKeyMappings.all(); + int originalLength = keyMappings.length; + keyMappings = Arrays.copyOf(keyMappings, originalLength + additions.length); + System.arraycopy(additions, 0, keyMappings, originalLength, additions.length); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java index c1a7dd7d..7f6068b3 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java @@ -1,44 +1,98 @@ package dev.comfyfluffy.caustica.mixin; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.platform.NativeImage; import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.CaptureSession; import dev.comfyfluffy.caustica.client.RtScreenshotExporter; import net.minecraft.client.Screenshot; import net.minecraft.network.chat.Component; import org.jspecify.annotations.Nullable; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import java.io.File; import java.util.function.Consumer; -/** Hooks only vanilla's auto-named F2 capture; named panorama/debug captures remain PNG-only. */ +/** Preserves the F2 EXR pair while preventing vanilla readbacks during the renderer-owned F4 snapshot. */ @Mixin(Screenshot.class) public abstract class ScreenshotMixin { - @Inject( - method = "grab(Ljava/io/File;Ljava/lang/String;Lcom/mojang/blaze3d/pipeline/RenderTarget;ILjava/util/function/Consumer;)V", - at = @At("HEAD"), - cancellable = true - ) - private static void caustica$exportResidualExposureExr( + @WrapMethod( + method = "grab(Ljava/io/File;Ljava/lang/String;Lcom/mojang/blaze3d/pipeline/RenderTarget;ILjava/util/function/Consumer;)V") + private static void caustica$guardNamedPng( File workDir, @Nullable String forceName, RenderTarget target, int downscaleFactor, Consumer callback, - CallbackInfo ci + Operation original ) { - if (forceName == null && downscaleFactor == 1 - && CausticaConfig.Rt.Screenshots.EXR_ENABLED.value()) { - String pairedPngName = RtScreenshotExporter.exportPaired(workDir, callback); - if (pairedPngName != null) { - // Re-enter vanilla's named path with our reserved PNG name. The non-null name bypasses - // this hook on the nested call and makes both outputs use exactly one basename. - Screenshot.grab(workDir, pairedPngName, target, downscaleFactor, callback); - ci.cancel(); + if (CaptureSession.active()) { + callback.accept(Component.translatable("caustica.status.screenshot.busy")); + return; + } + long token = CaptureSession.screenshotThreadToken(); + boolean inherited = CaptureSession.screenshotIsUltra(token); + if (!inherited) { + token = CaptureSession.acquireScreenshot(false); + if (token == 0L) { + callback.accept(Component.translatable("caustica.status.screenshot.busy")); + return; + } + } + long callbackToken = token; + Consumer leasedCallback = result -> { + try { + callback.accept(result); + } finally { + CaptureSession.releaseScreenshot(callbackToken); + } + }; + try { + String screenshotName = forceName; + if (screenshotName == null && downscaleFactor == 1 + && CausticaConfig.Rt.Screenshots.EXR_ENABLED.value()) { + screenshotName = RtScreenshotExporter.exportPaired(workDir, callback); + } + original.call(workDir, screenshotName, target, downscaleFactor, leasedCallback); + } catch (Throwable t) { + CaptureSession.releaseScreenshot(callbackToken); + throw t; + } + } + + @WrapMethod( + method = "takeScreenshot(Lcom/mojang/blaze3d/pipeline/RenderTarget;Ljava/util/function/Consumer;)V") + private static void caustica$guardAutomaticPng( + RenderTarget target, + Consumer callback, + Operation original + ) { + if (CaptureSession.active()) { + return; + } + long token = CaptureSession.screenshotThreadToken(); + boolean inherited = CaptureSession.screenshotIsUltra(token); + if (!inherited) { + token = CaptureSession.acquireScreenshot(false); + if (token == 0L) { + return; + } + } + long callbackToken = token; + Consumer leasedCallback = image -> { + try { + callback.accept(image); + } finally { + CaptureSession.releaseScreenshot(callbackToken); } + }; + try { + original.call(target, leasedCallback); + } catch (Throwable t) { + CaptureSession.releaseScreenshot(callbackToken); + throw t; } } } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java new file mode 100644 index 00000000..34ab6bfe --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java @@ -0,0 +1,19 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.renderer.texture.TextureAtlas; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Stops animated scene sprites while a capture owns one immutable scene. */ +@Mixin(TextureAtlas.class) +public abstract class TextureAtlasMixin { + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureAnimations(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java index b0bc35f0..4fe44c06 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java @@ -1,12 +1,16 @@ package dev.comfyfluffy.caustica.mixin; import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.RtSharcOptionsScreen; import dev.comfyfluffy.caustica.client.RtVideoOptions; import java.util.ArrayList; import java.util.List; +import net.minecraft.client.Minecraft; import net.minecraft.client.OptionInstance; import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.OptionsList; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.options.VideoSettingsScreen; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; @@ -69,6 +73,19 @@ private static OptionInstance[] qualityOptions(Options options) { } list.addHeader(CAUSTICA$RT_HEADER); list.addSmall(RtVideoOptions.runtimeOptions()); + Minecraft minecraft = Minecraft.getInstance(); + list.addSmall( + RtVideoOptions.debugView().createButton(minecraft.options), + RtVideoOptions.toneMappingButton( + (Screen) (Object) this, + () -> { + list.applyUnsavedChanges(); + CausticaConfig.save(); + })); + list.addSmall(List.of(Button.builder( + Component.translatable("caustica.options.rt.sharcMenu.open"), button -> + minecraft.setScreenAndShow(new RtSharcOptionsScreen((Screen) (Object) this))) + .build())); } @Inject(method = "removed", at = @At("TAIL")) diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java index eaf2334b..905a47cf 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java @@ -10,6 +10,7 @@ import com.mojang.blaze3d.vulkan.VulkanPhysicalDevice; import com.mojang.blaze3d.vulkan.init.VulkanFeature; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.ngx.NgxRuntime; import dev.comfyfluffy.caustica.rt.RtDeviceBringup; import dev.comfyfluffy.caustica.rt.RtHdr; import dev.comfyfluffy.caustica.rt.VulkanDiagnostics; @@ -55,16 +56,9 @@ public abstract class VulkanBackendMixin { new VulkanFeature(VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt16", VkPhysicalDeviceFeatures.SHADERINT16), new VulkanFeature(VulkanBackend.VK12_FEATURES_STRUCT, "shaderFloat16", VkPhysicalDeviceVulkan12Features.SHADERFLOAT16)); - private static final List CAUSTICA_WANTED_EXTENSIONS = List.of( - // FFX (FSR) + private static final List FFX_WANTED_EXTENSIONS = List.of( "VK_KHR_get_memory_requirements2", - "VK_KHR_dedicated_allocation", - // NGX (DLSS) — NVIDIA-only; skipped on other vendors. (The NGX instance - // extension VK_KHR_get_physical_device_properties2 needs an instance hook; - // DLSS relies on it being core/enabled at instance level.) - "VK_NVX_binary_import", - "VK_NVX_image_view_handle", - "VK_KHR_push_descriptor"); + "VK_KHR_dedicated_allocation"); private static final Set loggedMissingSdkFeatures = new HashSet<>(); @@ -90,7 +84,7 @@ public abstract class VulkanBackendMixin { Collection 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(); + } + } +}