diff --git a/.gitignore b/.gitignore index 69b5d54a..e9c03ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .gradle/ .idea/ +.DS_Store /build/ /buildSrc/build/ native/ngx_shim/build/ @@ -7,6 +8,10 @@ run/ out/ *.log *.class +__pycache__/ +*.pyc +.venv/ +.uv-cache/ third_party/ bin/ /logs diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..6324d401 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..88593784 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Repository guidance + +- Keep planning documents and `todos.md` local-only. Do not add `docs/*PLAN*.md` or `todos.md` to Git. +- Comments and Javadocs must describe the current implementation and its active invariants only. +- Do not preserve implementation history, migration notes, completed phases, or superseded behavior in source comments. Git history owns that context. +- Do not reference internal plan steps, phase labels, milestone IDs, or numbered design-document sections from source comments. +- Prefer direct explanations of why the current code is required, especially API contracts, synchronization rules, units, and non-obvious constraints. +- Until release, modify only the English locale (`en_us.json`); leave every other locale unchanged. diff --git a/build.gradle b/build.gradle index 9d436efd..c23b48b0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ import javax.inject.Inject import org.gradle.process.ExecOperations import dev.comfyfluffy.caustica.build.GenerateShaderRecords +import dev.comfyfluffy.caustica.build.GenerateRtBindings plugins { id "fabric-loom" version "${loom_version}" @@ -76,9 +77,9 @@ processResources { } } -// Compile GLSL and Slang sources to SPIR-V at build time instead of tracking binaries. Requires -// glslangValidator, slangc, and spirv-val from the Vulkan SDK or PATH. -def shaderGenRoot = layout.buildDirectory.dir("generated/shaders") // classpath root -> /caustica/rt/*.spv +// Compile Slang sources to SPIR-V at build time instead of tracking binaries. Requires slangc and +// spirv-val from the Vulkan SDK or PATH. +def shaderGenRoot = layout.buildDirectory.dir("generated/shaders") // classpath root -> /caustica/shaders/*.spv def resolveVulkanTool = { String name -> def exe = org.gradle.internal.os.OperatingSystem.current().windows ? ".exe" : "" @@ -95,46 +96,40 @@ def resolveVulkanTool = { String name -> abstract class CompileShaders extends DefaultTask { @InputDirectory abstract DirectoryProperty getSrcDir() @OutputDirectory abstract DirectoryProperty getOutDir() - @Input abstract Property getGlslang() @Input abstract Property getSpirvVal() @Input abstract Property getSlangc() @Inject abstract ExecOperations getExecOps() - // world.rgen.slang → "world.rgen"; world.rahit → "world.rahit" (GLSL names pass through). - static String outBase(File src) { - return src.name.endsWith(".slang") ? src.name.substring(0, src.name.length() - 6) : src.name + // pipelines/display/main.comp.slang -> pipelines/display/main.comp. + static String outBase(File root, File src) { + def relative = root.toPath().relativize(src.toPath()).toString().replace('\\', '/') + return relative.endsWith(".slang") ? relative.substring(0, relative.length() - 6) : relative } @TaskAction void compile() { def outDirFile = outDir.get().asFile - // Slang stages are named ..slang (world.rahit.slang → world.rahit.spv); module files + // Slang stages are named ..slang (any_hit.rahit.slang -> any_hit.rahit.spv); module files // like world_common.slang match no stage glob and are picked up only via `import`. def stageIncludes = ["rgen", "rchit", "rahit", "rmiss", "rcall", "comp", "vert", "frag"] - .collectMany { stage -> ["**/*.${stage}", "**/*.${stage}.slang"] } + .collect { stage -> "**/*.${stage}.slang" } def validate = { File spv -> execOps.exec { commandLine spirvVal.get(), "--target-env", "vulkan1.2", spv.absolutePath } } - def compileOne = { File src, File spv, List defines -> - execOps.exec { - // -g (OpLine/OpSource debug info), not -gVS: the NonSemantic.Shader.DebugInfo form (-gVS) - // emits OpExtInstWithForwardRefsKHR (SPV_KHR_relaxed_extended_instruction), which trips a - // validation error unless the shaderRelaxedExtendedInstruction device feature is enabled. - // -g keeps source-level debug info for Nsight/RenderDoc without needing that extension. - commandLine([glslang.get(), "-V", "--target-env", "vulkan1.2", "-g"] + defines - + [src.absolutePath, "-o", spv.absolutePath]) - } - validate(spv) - } + def includeDirs = srcDir.get().asFileTree.matching { include "**/*.slang" }.files + .collect { it.parentFile }.unique().sort { it.absolutePath } def compileOneSlang = { File src, File spv, List extra -> + spv.parentFile.mkdirs() + def includes = [src.parentFile] + includeDirs.findAll { it != src.parentFile } execOps.exec { // Embed Slang source and line mappings in SPIR-V for RenderDoc/Nsight source debugging. commandLine([slangc.get(), src.absolutePath, "-target", "spirv", "-profile", "spirv_1_5", "-matrix-layout-column-major", - "-warnings-as-errors", "all", "-warnings-disable", "41012", "-g"] + extra + "-warnings-as-errors", "all", "-warnings-disable", "41012", "-g"] + + includes.collectMany { ["-I", it.absolutePath] } + extra + ["-o", spv.absolutePath]) } validate(spv) @@ -143,7 +138,7 @@ abstract class CompileShaders extends DefaultTask { include stageIncludes }.files.sort { it.absolutePath } def duplicateOutputs = shaderFiles - .groupBy { "${outBase(it)}.spv" } + .groupBy { "${outBase(srcDir.get().asFile, it)}.spv" } .findAll { entry -> entry.value.size() > 1 } if (!duplicateOutputs.isEmpty()) { throw new GradleException("Duplicate shader output name(s): " @@ -157,28 +152,29 @@ abstract class CompileShaders extends DefaultTask { } scratchDir.mkdirs() shaderFiles.each { src -> - def base = outBase(src) + def base = outBase(srcDir.get().asFile, src) def spv = new File(scratchDir, "${base}.spv") - if (src.name.endsWith(".slang")) { - def worldIndirectRaygen = base == "world.rgen" - if (worldIndirectRaygen) { - // Keep a capability-free TraceRay fallback for devices without EXT SER, and - // publish the reordered variant separately when the extension is available. - compileOneSlang(src, spv, []) - compileOneSlang(src, new File(scratchDir, "world_ser.rgen.spv"), - ["-DCAUSTICA_ENABLE_EXT_SER", "-capability", "spvShaderInvocationReorderEXT"]) - } else { - // Pass A intentionally uses ordinary TraceRay. - compileOneSlang(src, spv, []) - } + def worldIndirectRaygen = base == "pipelines/world/indirect.rgen" + if (worldIndirectRaygen) { + // Keep a capability-free TraceRay fallback for devices without EXT SER, and + // publish the reordered variant separately when the extension is available. + compileOneSlang(src, spv, []) + compileOneSlang(src, new File(scratchDir, "pipelines/world/indirect_ser.rgen.spv"), + ["-DCAUSTICA_ENABLE_EXT_SER", "-capability", "spvShaderInvocationReorderEXT"]) } else { - compileOne(src, spv, []) + // Pass A intentionally uses ordinary TraceRay. + compileOneSlang(src, spv, []) } } + if (outDirFile.exists() && !outDirFile.deleteDir()) { + throw new GradleException("failed to clear generated shaders under ${outDirFile}") + } outDirFile.mkdirs() - outDirFile.listFiles().findAll { it.isFile() && it.name.endsWith(".spv") }.each { it.delete() } - scratchDir.listFiles().findAll { it.isFile() }.each { src -> - java.nio.file.Files.move(src.toPath(), new File(outDirFile, src.name).toPath(), + scratchDir.eachFileRecurse(groovy.io.FileType.FILES) { src -> + def relative = scratchDir.toPath().relativize(src.toPath()) + def destination = outDirFile.toPath().resolve(relative).toFile() + destination.parentFile.mkdirs() + java.nio.file.Files.move(src.toPath(), destination.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) } } @@ -188,8 +184,7 @@ def compileShaders = tasks.register("compileShaders", CompileShaders) { group = "build" description = "Compiles shaders/** shader sources to SPIR-V and validates them." srcDir = file("shaders") - outDir = layout.buildDirectory.dir("generated/shaders/caustica/rt") - glslang = resolveVulkanTool("glslangValidator") + outDir = layout.buildDirectory.dir("generated/shaders/caustica/shaders") spirvVal = resolveVulkanTool("spirv-val") slangc = resolveVulkanTool("slangc") } @@ -197,21 +192,31 @@ def compileShaders = tasks.register("compileShaders", CompileShaders) { def generateShaderRecords = tasks.register("generateShaderRecords", GenerateShaderRecords) { group = "build" description = "Generates typed Java shader records and serializers from Slang reflection." - worldSourceDir = file("shaders/world") - probeSource = file("shaders/world/world_layout_probe.slang") + shaderRoot = file("shaders") + probeSource = file("shaders/layout/layout_probe.slang") slangc = resolveVulkanTool("slangc") spirvVal = resolveVulkanTool("spirv-val") outDir = layout.buildDirectory.dir("generated/sources/shaderRecords") } +def rtBindingsGenRoot = layout.buildDirectory.dir("generated/sources/rtBindings") +def generateRtBindings = tasks.register("generateRtBindings", GenerateRtBindings) { + group = "build" + description = "Generates Java descriptor bindings from Slang reflection." + shaderRoot = file("shaders") + slangc = resolveVulkanTool("slangc") + outDir = rtBindingsGenRoot +} + tasks.named("test", Test) { useJUnitPlatform() } sourceSets.main.java.srcDir(files(layout.buildDirectory.dir("generated/sources/shaderRecords")).builtBy(generateShaderRecords)) +sourceSets.main.java.srcDir(files(rtBindingsGenRoot).builtBy(generateRtBindings)) // Emit the .spv into the main resources so they land in the jar / on the runClient classpath at the -// same /caustica/rt/ path the loader (RtPipeline/RtBlendPipeline) reads. builtBy wires the dependency. +// same /caustica/shaders/ path the pipeline loaders read. builtBy wires the dependency. sourceSets.main.resources.srcDir(files(shaderGenRoot).builtBy(compileShaders)) def ngxNativeGenRoot = layout.buildDirectory.dir("generated/ngx-natives") diff --git a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy new file mode 100644 index 00000000..e609d048 --- /dev/null +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy @@ -0,0 +1,152 @@ +package dev.comfyfluffy.caustica.build + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations + +import javax.inject.Inject + +/** Generates the Java view of Vulkan descriptor locations from Slang reflection. */ +abstract class GenerateRtBindings extends DefaultTask { + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + abstract DirectoryProperty getShaderRoot() + + @Input abstract Property getSlangc() + @OutputDirectory abstract DirectoryProperty getOutDir() + + @Inject abstract ExecOperations getExecOps() + + private static final List PIPELINES = [ + [prefix: "WORLD", source: "pipelines/world/indirect.rgen.slang", resources: [ + 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", + CELESTIALS: "celestialsAtlas", SKY_VIEW: "skyViewLut", TRANSMITTANCE: "transmittanceLut", + 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"]], + [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", + SCENE: "sceneImage", EXPOSURE: "exposureImage", EXPOSURE_STATE: "exposureState"]], + [prefix: "EXPOSURE_HIST", source: "pipelines/exposure_hist/main.comp.slang", resources: [ + COLOR: "colorImage", BINS: "histBins", DEPTH: "depthImage", ALBEDO: "albedoImage"]], + [prefix: "EXPOSURE_RESOLVE", source: "pipelines/exposure_resolve/main.comp.slang", resources: [ + HIST_BINS: "histBins", IMAGE: "exposureImage", STATE: "stateBuf"]], + [prefix: "BLOOM", source: "pipelines/bloom/main.comp.slang", resources: [ + OUTPUT: "dstImage", SOURCE: "srcImage", EXPOSURE: "exposureImage"]], + [prefix: "SKY_LUT", source: "pipelines/sky_lut/view.comp.slang", resources: [ + TRANSMITTANCE_IMAGE: "transmittanceImage", MULTISCATTER_IMAGE: "multiScatterImage", + SKY_VIEW_IMAGE: "skyViewImage", TRANSMITTANCE_SAMPLER: "transmittanceLut", + MULTISCATTER_SAMPLER: "multiScatterLut"]], + [prefix: "PRESENT", source: "pipelines/hdr_composite/main.comp.slang", resources: [ + OUTPUT: "outputImage", SOURCE: "sourceImage"]], + [prefix: "OVERLAY_IMAGE", source: "pipelines/overlay_composite/glow.frag.slang", resources: [VALUE: "sourceImage"]], + [prefix: "OVERLAY_SAMPLER", source: "pipelines/name_tag/fragment.frag.slang", resources: [VALUE: "fontAtlas"]], + [prefix: "OVERLAY_TLAS", source: "pipelines/block_outline/fragment.frag.slang", resources: [VALUE: "tlas"]] + ] + + // Gradle decorates task classes; this must remain non-private for Groovy dispatch inside PIPELINES.each. + Map reflect(File source, File scratchDir) { + def stem = source.name.replaceAll(/\W+/, "-") + def reflectionFile = new File(scratchDir, "${stem}.json") + def spvFile = new File(scratchDir, "${stem}.spv") + def includeDirs = shaderRoot.get().asFileTree.matching { include "**/*.slang" }.files + .collect { it.parentFile }.unique().sort { it.absolutePath } + def includes = [source.parentFile] + includeDirs.findAll { it != source.parentFile } + execOps.exec { + commandLine([slangc.get(), source.absolutePath] + includes.collectMany { ["-I", it.absolutePath] } + [ + "-target", "spirv", "-profile", "spirv_1_5", "-matrix-layout-column-major", + "-warnings-as-errors", "all", "-warnings-disable", "41012", + "-reflection-json", reflectionFile.absolutePath, "-o", spvFile.absolutePath]) + } + new JsonSlurper().parse(reflectionFile) as Map + } + + @TaskAction + void generate() { + def constants = new LinkedHashMap() + def scratchDir = new File(temporaryDir, "reflection") + scratchDir.mkdirs() + + PIPELINES.each { spec -> + def reflection = reflect(new File(shaderRoot.get().asFile, spec.source as String), scratchDir) + def reflected = reflection.parameters.findAll { it.binding?.kind == "descriptorTableSlot" } + .collectEntries { [(it.name): it.binding] } + def missing = spec.resources.values().findAll { !reflected.containsKey(it) } + def unexpected = reflected.keySet().findAll { !spec.resources.containsValue(it) } + if (!missing.isEmpty() || !unexpected.isEmpty()) { + throw new GradleException("${spec.source} descriptor mismatch; missing=${missing}, unexpected=${unexpected}") + } + + def locations = spec.resources.collectEntries { suffix, resource -> + [(suffix): [index: reflected[resource].index as int, set: (reflected[resource].space ?: 0) as int]] + } + if (spec.prefix != "WORLD" && (locations.values()*.set as Set).size() != 1) { + throw new GradleException("${spec.source} resources span unexpected descriptor sets: ${locations}") + } + locations.values().groupBy { it.set }.each { set, bindings -> + def indices = bindings*.index.sort() + if (indices != (0.. !(suffix as String).startsWith("ENTITY_") && !(suffix as String).startsWith("MATERIAL_") } + def bindless = locations.findAll { suffix, ignored -> (suffix as String).startsWith("ENTITY_") || (suffix as String).startsWith("MATERIAL_") } + if ((ordinary.values()*.set as Set).size() != 1 || (bindless.values()*.set as Set).size() != 1 + || ordinary.values().first().set == bindless.values().first().set) { + throw new GradleException("world resources do not have distinct ordinary and bindless sets: ${locations}") + } + constants.WORLD_SET = ordinary.values().first().set + ordinary.each { suffix, location -> constants["WORLD_${suffix}"] = location.index } + def guides = ordinary.findAll { suffix, ignored -> (suffix as String).startsWith("G_") } + def storageImages = guides + ordinary.findAll { suffix, ignored -> suffix == "OUTPUT" } + def samplers = ordinary.findAll { suffix, ignored -> suffix != "TLAS" && !storageImages.containsKey(suffix) } + constants.WORLD_GUIDE_COUNT = guides.size() + constants.WORLD_SET_BINDING_COUNT = ordinary.values()*.index.max() + 1 + constants.WORLD_SET_STORAGE_IMAGE_COUNT = storageImages.size() + constants.WORLD_SET_SAMPLER_COUNT = samplers.size() + constants.WORLD_BINDLESS_SET = bindless.values().first().set + bindless.each { suffix, location -> constants["WORLD_${suffix}"] = location.index } + constants.WORLD_BINDLESS_COUNT = bindless.size() + } else { + constants["${spec.prefix}_SET"] = locations.values().first().set + locations.each { suffix, location -> constants["${spec.prefix}_${suffix}"] = location.index } + constants["${spec.prefix}_BINDING_COUNT"] = locations.values()*.index.max() + 1 + } + } + + def output = outDir.get().file("dev/comfyfluffy/caustica/rt/pipeline/RtBindings.java").asFile + output.parentFile.mkdirs() + output.setText("""// Generated from Slang descriptor reflection. Do not edit. +package dev.comfyfluffy.caustica.rt.pipeline; + +public final class RtBindings { +${constants.collect { name, value -> " public static final int ${name} = ${value};" }.join('\n')} + + private RtBindings() { + } +} +""", "UTF-8") + } +} 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 83af7738..ddeecdc1 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy @@ -20,7 +20,7 @@ import javax.inject.Inject abstract class GenerateShaderRecords extends DefaultTask { @InputDirectory @PathSensitive(PathSensitivity.RELATIVE) - abstract DirectoryProperty getWorldSourceDir() + abstract DirectoryProperty getShaderRoot() @InputFile @PathSensitive(PathSensitivity.RELATIVE) @@ -134,7 +134,20 @@ abstract class GenerateShaderRecords extends DefaultTask { } } - private static String generateJava(Map rootType, int byteSize, String className) { + private static String emitReadExpression(Map type, Map binding, String base) { + def address = at(base, (binding.offset ?: 0) as int) + if (type.kind != "scalar") { + throw new GradleException("generated readers currently support scalar fields only: ${type}") + } + def method = type.scalarType == "float32" ? "getFloat" + : (type.scalarType in ["int32", "uint32"] ? "getInt" + : (type.scalarType in ["int64", "uint64"] ? "getLong" : null)) + if (method == null) throw new GradleException("unsupported scalar reader: ${type.scalarType}") + "src.${method}(${address})" + } + + // NOT private: see the comment on extractPushConstantType -- same closure-dispatch issue. + static String generateJava(Map rootType, int byteSize, String className, boolean emitReader = false) { def fields = rootType.fields as List def arrays = fields.findAll { it.type.kind == "array" } def vectors = new LinkedHashSet() @@ -183,6 +196,19 @@ abstract class GenerateShaderRecords extends DefaultTask { } sb << " }\n\n" + if (emitReader) { + sb << " public static ${className} read(ByteBuffer src) {\n" + sb << " Objects.requireNonNull(src, \"src\");\n" + sb << " if (src.capacity() < BYTE_SIZE) throw new IllegalArgumentException(\"${className} buffer is too small: \" + src.capacity());\n" + sb << " return new ${className}(\n" + fields.eachWithIndex { field, i -> + sb << " ${emitReadExpression(field.type as Map, field.binding as Map, '0')}" + sb << (i + 1 == fields.size() ? "\n" : ",\n") + } + sb << " );\n" + sb << " }\n\n" + } + vectors.sort().each { name -> def count = Integer.parseInt(name.substring(name.length() - 1)) def primitive = name.startsWith("Float") ? "float" : "int" @@ -199,15 +225,48 @@ abstract class GenerateShaderRecords extends DefaultTask { sb.toString() } + // (reflection parameter name, expected Slang struct name, generated Java class name) for every + // plain push-constant struct probed directly (no structured-buffer array wrapper needed, unlike + // WorldPush/MaterialHeader -- see the two probeXxx blocks below main() in the probe file). + private static final List> PUSH_CONSTANT_PROBES = [ + ["pushConstantsLayoutProbe", "WorldPushConstants", "WorldPushConstantsData"], + ["exposureHistPushProbe", "ExposureHistPush", "ExposureHistPushData"], + ["exposureResolvePushProbe", "ExposureResolvePush", "ExposureResolvePushData"], + ["displayPushProbe", "DisplayPush", "DisplayPushData"], + ["debugPresentPushProbe", "DebugPresentPush", "DebugPresentPushData"], + ["bloomPushProbe", "BloomPush", "BloomPushData"], + ["pushAddrLayoutProbe", "PushAddr", "PushAddrData"], + ] + + // NOT private: Gradle decorates this abstract task with a generated subclass, and Groovy's + // dynamic method dispatch from inside the PUSH_CONSTANT_PROBES.each {} closure below fails to + // resolve private static methods through that generated subclass. + static Map extractPushConstantType(Object reflection, String probeName, String structName) { + def pushParameter = reflection.parameters.find { it.name == probeName } + if (pushParameter?.type?.elementType?.name != structName) { + throw new GradleException("Slang reflection omitted or misshaped ${probeName} (expected ${structName})") + } + pushParameter.type.elementType as Map + } + + static int extractPushConstantByteSize(Object reflection, String probeName) { + def pushParameter = reflection.parameters.find { it.name == probeName } + pushParameter.type.elementVarLayout.binding.size as int + } + @TaskAction void generate() { def reflectionFile = new File(temporaryDir, "shader-records-reflection.json") def probeSpv = new File(temporaryDir, "shader-layout-probe.spv") + def includeArgs = shaderRoot.get().asFileTree.matching { include "**/*.slang" }.files + .collect { it.parentFile }.unique().sort { it.absolutePath } + .collectMany { ["-I", it.absolutePath] } execOps.exec { - commandLine slangc.get(), probeSource.get().asFile.absolutePath, + commandLine([slangc.get(), probeSource.get().asFile.absolutePath] + includeArgs + + [ "-target", "spirv", "-profile", "spirv_1_5", "-matrix-layout-column-major", "-warnings-as-errors", "all", "-warnings-disable", "41012", - "-reflection-json", reflectionFile.absolutePath, "-o", probeSpv.absolutePath + "-reflection-json", reflectionFile.absolutePath, "-o", probeSpv.absolutePath]) } execOps.exec { commandLine spirvVal.get(), "--target-env", "vulkan1.2", probeSpv.absolutePath @@ -230,12 +289,13 @@ abstract class GenerateShaderRecords extends DefaultTask { Map materialHeaderType = materialProbeArray.type.elementType as Map int materialHeaderByteSize = materialProbeArray.type.uniformStride as int - def pushParameter = reflection.parameters.find { it.name == "pushConstantsLayoutProbe" } - if (pushParameter?.type?.elementType?.name != "WorldPushConstants") { - throw new GradleException("Slang reflection omitted pushConstantsLayoutProbe") + def exposureStateParameter = reflection.parameters.find { it.name == "exposureStateLayoutProbe" } + def exposureStateProbeArray = exposureStateParameter?.type?.resultType?.fields?.find { it.name == "values" } + if (exposureStateProbeArray?.type?.kind != "array" || exposureStateProbeArray.type.elementType?.name != "ExposureState") { + throw new GradleException("unexpected ExposureState reflection probe shape") } - Map pushConstantsType = pushParameter.type.elementType as Map - int pushConstantsByteSize = pushParameter.type.elementVarLayout.binding.size as int + Map exposureStateType = exposureStateProbeArray.type.elementType as Map + int exposureStateByteSize = exposureStateProbeArray.type.uniformStride as int def generatedRoot = outDir.get().asFile if (generatedRoot.exists() && !generatedRoot.deleteDir()) { @@ -245,9 +305,16 @@ abstract class GenerateShaderRecords extends DefaultTask { packageDir.mkdirs() new File(packageDir, "WorldPushData.java").setText( generateJava(worldType, worldByteSize, "WorldPushData"), "UTF-8") - new File(packageDir, "WorldPushConstantsData.java").setText( - generateJava(pushConstantsType, pushConstantsByteSize, "WorldPushConstantsData"), "UTF-8") new File(packageDir, "MaterialHeaderData.java").setText( generateJava(materialHeaderType, materialHeaderByteSize, "MaterialHeaderData"), "UTF-8") + new File(packageDir, "ExposureStateData.java").setText( + generateJava(exposureStateType, exposureStateByteSize, "ExposureStateData", true), "UTF-8") + + PUSH_CONSTANT_PROBES.each { probeName, structName, className -> + Map type = extractPushConstantType(reflection, probeName, structName) + int byteSize = extractPushConstantByteSize(reflection, probeName) + new File(packageDir, "${className}.java").setText( + generateJava(type, byteSize, className), "UTF-8") + } } } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ffb23fa1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "caustica-tools" +version = "0.1.0" +description = "Reproducible Python environment for Caustica's color and exposure tools" +requires-python = ">=3.14,<3.15" +dependencies = [ + "numpy>=2.5,<3", + "opencolorio>=2.5,<3", + "openexr>=3.4,<4", +] + +[tool.uv] +package = false diff --git a/shaders/common/display_common.slang b/shaders/common/display_common.slang new file mode 100644 index 00000000..54ef9fe1 --- /dev/null +++ b/shaders/common/display_common.slang @@ -0,0 +1,93 @@ +// Push-constant structs shared between the display-chain compute passes and +// layout/layout_probe.slang's build-time reflection probe (see GenerateShaderRecords.groovy). +// Each struct is imported into exactly one production shader AND probed here so its Java writer is +// generated from the compiler's reflected offsets, preventing Java/shader ABI drift when fields move. +// +// Each struct here needs a distinct name because reflection JSON parameters are named by struct type across the whole +// probe module, and per-entry-point SPIR-V allows only one push-constant block reachable from any +// single entry point, so the probe needs one small dedicated entry point per struct. + +public struct ExposureHistPush { + public uint stride; + public float centerWeightSigma; + public float centerWeightFloor; +}; + +public struct ExposureResolvePush { + public float key; + public float minEv; + public float maxEv; + // Adaptation time constants, in seconds, applied in EV space (see exposure_resolve's smoothing + // step). Named for what the SCENE did, not for which way the exposure multiplier moved: a darkening + // scene needs MORE exposure, so adaptDarken is the branch where target > prev. + public float adaptDarken; + public float adaptBrighten; + public float frameTimeSeconds; + public float evBias; + public float lowPercentile; + public float highPercentile; + public float skyWeightCap; + public float curveScene0; + public float curveCompensation0; + public float curveScene1; + public float curveCompensation1; + public float curveScene2; + public float curveCompensation2; + public float curveScene3; + public float curveCompensation3; + public float emissiveWeightCap; + // evOffset takes log2(metered stored luminance) to EV100: + // RtSceneUnits.EV100_OFFSET - log2(preExposure). preExposure is the scalar raygen already + // multiplied into the stored radiance (1.0 when pre-exposure is disabled), so the metered + // buffer holds L*preExposure and the resolve divides it back out to recover absolute scene EV -- + // and to keep minEv/maxEv bounding ABSOLUTE exposure rather than the residual. + public float evOffset; + public float preExposure; + // resetSeq invalidates GPU exposure history without a host write or fence. + public uint resetSeq; +}; + +// Persistent auto-exposure controller state. This shared definition is also reflected into +// ExposureStateData.java so host reads and initialization follow Slang's Std430 layout. +public struct ExposureState { + public float previous; + public uint initialized; + public float evScene; + public float evTarget; + public float evApplied; + public float clipLowFrac; + public float clipHighFrac; + public uint resetSeq; + public float meteringSkyScale; + public float meteringSkyFrac; + public float curveCompensation; + public float effectiveSlope; + public float meteringEmissiveScale; + public float meteringEmissiveFrac; +}; + +public struct DisplayPush { + public int hdrEnabled; // 0 = SDR only, 1 = also write the PQ HDR image + public float lutSize; // toneLut/hdrToneLut texels per axis; both LUTs share one size (asserted at load) + public float gamma; // post-display artistic gamma; below 1 brightens midtones + 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. + public float bloomStrength; +}; + +public struct BloomPush { + public int mode; // 0 prefilter+downsample, 1 downsample, 2 tent upsample (accumulate) + public float threshold; // exposed scene-linear ACEScg luminance + public float softKnee; // absolute exposed scene-linear knee width + public float radius; // upsample tent radius in SOURCE texels; resolution-independent +}; + +public struct DebugPresentPush { + public uint debugView; // 1..9; this pass is only dispatched for debugView != 0 + public float centerWeightSigma; + public float centerWeightFloor; +}; diff --git a/shaders/common/overlay_common.slang b/shaders/common/overlay_common.slang new file mode 100644 index 00000000..402ac401 --- /dev/null +++ b/shaders/common/overlay_common.slang @@ -0,0 +1,10 @@ +public struct OverlayPush { + public float4x4 curViewProj; + public float3 camOffset; + public float4 color; +}; + +public struct NameTagPush { + public float4x4 curViewProj; + public float3 camOffset; +}; diff --git a/shaders/display/display.comp b/shaders/display/display.comp deleted file mode 100644 index 43c455be..00000000 --- a/shaders/display/display.comp +++ /dev/null @@ -1,128 +0,0 @@ -#version 460 - -layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; - -layout(binding = 0, set = 0, rgba8) uniform writeonly image2D outputImage; // LDR world target image -layout(binding = 1, set = 0, rgba16f) uniform readonly image2D rtImage; // HDR RT radiance -layout(binding = 2, set = 0, r32f) uniform readonly image2D exposureImage; // 1x1 linear exposure scale -layout(binding = 3, set = 0, rgba16f) uniform writeonly image2D hdrImage; // PQ-encoded ([0,1], ST.2084) HDR display - -// HDR display parameters. hdrEnabled gates the parallel PQ write; the SDR AgX path always runs because -// vanilla main-target presentation still consumes LDR. -layout(push_constant) uniform Push { - int hdrEnabled; // 0 = SDR only, 1 = also write the PQ HDR image - float paperWhiteNits; // absolute nits SDR paper white maps to - float headroom; // highlight headroom above paper white (peakNits / paperWhiteNits, >= 1) -} pc; - -// Tonemap seam: map HDR RT radiance to displayable LDR before copying back to the main target. -// The path tracer emits true HDR radiance, so exposure is applied from the compositor-owned 1x1 image -// before the AgX view transform. DLSS-RR still consumes HDR pre-tonemap. - -const mat3 AGX_INSET = mat3( - 0.842479062253094, 0.042328242261012, 0.042375654905705, - 0.078433599999999, 0.878468636469772, 0.078433600000000, - 0.079223745147764, 0.079166127460543, 0.879142973793104 -); - -const mat3 AGX_OUTSET = mat3( - 1.196879005120170, -0.052896851757456, -0.052971635514443, - -0.098020881140137, 1.151903129904170, -0.098043450117124, - -0.099029744079720, -0.098961176844843, 1.151073672641160 -); - -const float AGX_MIN_EV = -12.47393; -const float AGX_MAX_EV = 4.026069; - -vec3 agxDefaultContrast(vec3 x) { - vec3 x2 = x * x; - vec3 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; -} - -vec3 applyLook(vec3 color) { - const float contrast = 1.04; // try 1.15, 1.25, 1.4 - const float saturation = 1.05; // try 1.05-1.15 if AgX feels grey - - color = clamp((color - 0.5) * contrast + 0.5, 0.0, 1.0); - - float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); - color = mix(vec3(luma), color, saturation); - - return color; -} - -vec3 agx(vec3 color) { - color = AGX_INSET * max(color, vec3(0.0)); - color = clamp(log2(max(color, vec3(1.0e-10))), AGX_MIN_EV, AGX_MAX_EV); - color = (color - AGX_MIN_EV) / (AGX_MAX_EV - AGX_MIN_EV); - color = agxDefaultContrast(color); - // color = applyLook(color); - color = AGX_OUTSET * color; - return clamp(color, 0.0, 1.0); -} - -vec3 tonemap(vec3 hdr, float exposure) { - return agx(hdr * exposure); -} - -const float PQ_M1 = 0.1593017578125; -const float PQ_M2 = 78.84375; -const float PQ_C1 = 0.8359375; -const float PQ_C2 = 18.8515625; -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); -} - -// VK_COLOR_SPACE_HDR10_ST2084_EXT mandates BT.2020 primaries as its container gamut (not just the ST.2084 -// transfer function) — but every color we compute (textures, tonemap) is authored/blended in BT.709/sRGB -// primaries. Feeding BT.709 numbers straight into a BT.2020-tagged buffer makes the display read them as -// (more saturated) BT.2020 coordinates, oversaturating everything. This matrix converts linear-light -// BT.709 -> BT.2020 right before the PQ encode, which is the container's actual gamut. (ITU-R BT.2087.) -const mat3 BT709_TO_BT2020 = mat3( - 0.6274039, 0.0690973, 0.0163916, - 0.3292830, 0.9195406, 0.0880132, - 0.0433131, 0.0113612, 0.8955953 -); - -// HDR display mapping: map exposed scene-linear radiance to absolute nits, then PQ-encode (ST.2084) for -// direct presentation to a PQ/HDR10 swapchain. SDR-range values (<= 1.0 after exposure) stay identity so -// paper white lands exactly at paperWhiteNits. Highlights above 1.0 roll off smoothly and asymptote to -// `headroom`, i.e. the brightest pixels approach peakNits. No SDR clamp here — that is the point. -vec3 tonemapHdr(vec3 hdr, float exposure) { - vec3 c = max(hdr * exposure, vec3(0.0)); - vec3 lo = min(c, vec3(1.0)); - vec3 hi = max(c - vec3(1.0), vec3(0.0)); - float k = max(pc.headroom - 1.0, 0.0); - vec3 rolled = (k > 0.0) ? (k * hi) / (k + hi) : vec3(0.0); // -> k as hi -> inf - vec3 paperReferred = lo + rolled; // 1.0 == paper white, max -> headroom - vec3 nits709 = paperReferred * pc.paperWhiteNits; - vec3 nits2020 = BT709_TO_BT2020 * nits709; - return vec3(pqEncode(nits2020.r), pqEncode(nits2020.g), pqEncode(nits2020.b)); -} - -void main() { - ivec2 pix = ivec2(gl_GlobalInvocationID.xy); - ivec2 size = imageSize(outputImage); - if (pix.x >= size.x || pix.y >= size.y) { - return; - } - - vec4 rt = imageLoad(rtImage, pix); - float exposure = max(imageLoad(exposureImage, ivec2(0)).r, 0.0); - vec3 ldr = tonemap(rt.rgb, exposure); - imageStore(outputImage, pix, vec4(ldr, 1.0)); - - if (pc.hdrEnabled != 0) { - imageStore(hdrImage, pix, vec4(tonemapHdr(rt.rgb, exposure), 1.0)); - } -} diff --git a/shaders/display/exposure_hist.comp b/shaders/display/exposure_hist.comp deleted file mode 100644 index 15390642..00000000 --- a/shaders/display/exposure_hist.comp +++ /dev/null @@ -1,40 +0,0 @@ -#version 460 - -layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; - -layout(binding = 0, set = 0, rgba16f) uniform readonly image2D colorImage; -layout(std430, binding = 1, set = 0) buffer Histogram { - uint bins[256]; -} hist; - -const float LOG_MIN = -12.0; -const float LOG_MAX = 12.0; -const float INV_LOG_RANGE = 1.0 / (LOG_MAX - LOG_MIN); - -// One 16x16 workgroup == 256 threads == 256 bins, so each thread owns exactly one bin for the -// zero-init and the final flush. Per-pixel atomics land in shared memory (one SM, fast) instead of -// the global buffer (contended across the whole dispatch — sky pixels alias to the same few bins), -// then only the non-empty bins pay a single global atomicAdd each. -shared uint localBins[256]; - -void main() { - uint localIdx = gl_LocalInvocationIndex; - localBins[localIdx] = 0u; - barrier(); - - ivec2 pix = ivec2(gl_GlobalInvocationID.xy); - ivec2 size = imageSize(colorImage); - if (pix.x < size.x && pix.y < size.y) { - vec3 rgb = max(imageLoad(colorImage, pix).rgb, vec3(0.0)); - float lum = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); - float logLum = clamp(log2(max(lum, 1.0e-5)), LOG_MIN, LOG_MAX); - uint bin = min(uint(floor((logLum - LOG_MIN) * INV_LOG_RANGE * 256.0)), 255u); - atomicAdd(localBins[bin], 1u); - } - - barrier(); - uint count = localBins[localIdx]; - if (count > 0u) { - atomicAdd(hist.bins[localIdx], count); - } -} diff --git a/shaders/display/exposure_resolve.comp b/shaders/display/exposure_resolve.comp deleted file mode 100644 index 7b446ebe..00000000 --- a/shaders/display/exposure_resolve.comp +++ /dev/null @@ -1,67 +0,0 @@ -#version 460 - -layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in; - -layout(std430, binding = 0, set = 0) readonly buffer Histogram { - uint bins[256]; -} hist; -layout(binding = 1, set = 0, r32f) uniform image2D exposureImage; -layout(std430, binding = 2, set = 0) buffer ExposureState { - float previous; - uint initialized; -} stateBuf; - -layout(push_constant) uniform Push { - uint pixelCount; - float key; - float minEv; - float maxEv; - float adaptUp; - float adaptDown; - float frameTimeSeconds; - float evBias; -} pc; - -const float LOG_MIN = -12.0; -const float LOG_MAX = 12.0; -const float LOG_STEP = (LOG_MAX - LOG_MIN) / 256.0; - -float binCenter(uint bin) { - return LOG_MIN + (float(bin) + 0.5) * LOG_STEP; -} - -void main() { - uint total = max(pc.pixelCount, 1u); - uint lowCount = uint(floor(float(total) * 0.50)); - uint highCount = max(lowCount + 1u, uint(ceil(float(total) * 0.95))); - - uint cumulative = 0u; - float weightedLogLum = 0.0; - uint weightedCount = 0u; - for (uint i = 0u; i < 256u; ++i) { - uint count = hist.bins[i]; - uint start = cumulative; - uint end = cumulative + count; - uint takeStart = max(start, lowCount); - uint takeEnd = min(end, highCount); - if (takeEnd > takeStart) { - uint take = takeEnd - takeStart; - weightedLogLum += binCenter(i) * float(take); - weightedCount += take; - } - cumulative = end; - } - - float avgLogLum = weightedCount > 0u ? weightedLogLum / float(weightedCount) : 0.0; - float target = pc.key * exp2(pc.evBias - avgLogLum); - target = clamp(target, exp2(pc.minEv), exp2(pc.maxEv)); - - float prev = stateBuf.initialized == 0u ? target : max(stateBuf.previous, 1.0e-4); - float rate = target > prev ? pc.adaptUp : pc.adaptDown; - float alpha = stateBuf.initialized == 0u ? 1.0 : 1.0 - exp(-pc.frameTimeSeconds / max(rate, 1.0e-4)); - float exposure = mix(prev, target, clamp(alpha, 0.0, 1.0)); - - stateBuf.previous = exposure; - stateBuf.initialized = 1u; - imageStore(exposureImage, ivec2(0), vec4(exposure, 0.0, 0.0, 0.0)); -} diff --git a/shaders/display/hdr_ui_composite.comp b/shaders/display/hdr_ui_composite.comp deleted file mode 100644 index be6a49d8..00000000 --- a/shaders/display/hdr_ui_composite.comp +++ /dev/null @@ -1,80 +0,0 @@ -#version 460 - -layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; - -// PQ-encoded ([0,1], ST.2084) HDR world image; the UI is composited into it in place, then blitted to the -// swapchain. Alpha blending is only correct in linear light, so this pass decodes to linear nits, blends, -// then re-encodes before storing back. -layout(binding = 0, set = 0, rgba16f) uniform image2D hdrImage; -// Vanilla UI overlay (rgba8). Vanilla GUI uses premultiplied TRANSLUCENT blend, so rgb = C*A (sRGB-encoded), -// a = A. Sampled (the overlay is an MC render target kept in GENERAL layout); same resolution as hdrImage. -layout(binding = 1, set = 0) uniform sampler2D overlayTex; - -layout(push_constant) uniform Push { - float paperWhiteNits; // absolute nits SDR paper white maps to -} pc; - -const float PQ_M1 = 0.1593017578125; -const float PQ_M2 = 78.84375; -const float PQ_C1 = 0.8359375; -const float PQ_C2 = 18.8515625; -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); -} - -float pqDecode(float e) { - float ep = pow(max(e, 0.0), 1.0 / PQ_M2); - float num = max(ep - PQ_C1, 0.0); - float den = max(PQ_C2 - PQ_C3 * ep, 1e-6); - return 10000.0 * pow(num / den, 1.0 / PQ_M1); -} - -vec3 srgbToLinear(vec3 c) { - bvec3 hi = greaterThan(c, vec3(0.04045)); - vec3 lo = c / 12.92; - vec3 hiV = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(lo, hiV, hi); -} - -// VK_COLOR_SPACE_HDR10_ST2084_EXT mandates BT.2020 primaries as its container gamut — hdrImage's PQ-encoded -// values are BT.2020 (display.comp converts before encoding), but the sRGB-authored UI is BT.709/sRGB. Both -// directions are needed here: decode the world to BT.709 to blend against the UI, then convert the blended -// result back to BT.2020 before re-encoding. (ITU-R BT.2087.) -const mat3 BT2020_TO_BT709 = mat3( - 1.6604910, -0.1245505, -0.0181508, - -0.5876411, 1.1329008, -0.1005789, - -0.0728499, -0.0083503, 1.1187297 -); -const mat3 BT709_TO_BT2020 = mat3( - 0.6274039, 0.0690973, 0.0163916, - 0.3292830, 0.9195406, 0.0880132, - 0.0433131, 0.0113612, 0.8955953 -); - -void main() { - ivec2 pix = ivec2(gl_GlobalInvocationID.xy); - ivec2 size = imageSize(hdrImage); - if (pix.x >= size.x || pix.y >= size.y) { - return; - } - - vec4 ui = texelFetch(overlayTex, pix, 0); // premultiplied: rgb = C*A (sRGB), a = A - float a = ui.a; - vec3 worldPq = imageLoad(hdrImage, pix).rgb; - if (a > 0.0) { - vec3 worldNits2020 = vec3(pqDecode(worldPq.r), pqDecode(worldPq.g), pqDecode(worldPq.b)); - vec3 worldNits709 = BT2020_TO_BT709 * worldNits2020; - // Un-premultiply to the sRGB-authored colour, decode to linear, place at paper white, alpha-over. - // UI is authored for SDR perception, so it lands near paper white — NOT pushed through the world's - // highlight range. - vec3 straight = clamp(ui.rgb / a, 0.0, 1.0); - vec3 uiNits709 = srgbToLinear(straight) * pc.paperWhiteNits; - vec3 blendedNits709 = worldNits709 * (1.0 - a) + uiNits709 * a; - vec3 blendedNits2020 = BT709_TO_BT2020 * blendedNits709; - worldPq = vec3(pqEncode(blendedNits2020.r), pqEncode(blendedNits2020.g), pqEncode(blendedNits2020.b)); - } - imageStore(hdrImage, pix, vec4(worldPq, 1.0)); -} diff --git a/shaders/display/sdr_present.comp b/shaders/display/sdr_present.comp deleted file mode 100644 index 157ce1c5..00000000 --- a/shaders/display/sdr_present.comp +++ /dev/null @@ -1,55 +0,0 @@ -#version 460 - -layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; - -// Destination PQ-encoded ([0,1], ST.2084) image, blitted to the PQ swapchain after this pass. -layout(binding = 0, set = 0, rgba16f) uniform image2D outImage; -// Minecraft's SDR main target (rgba8, sRGB-encoded), sampled in GENERAL layout. Holds the complete -// non-RT frame (menu panorama + GUI), which the HDR present path would otherwise raw-copy into the -// PQ swapchain and misdisplay (SDR bytes reinterpreted as PQ codes). -layout(binding = 1, set = 0) uniform sampler2D sdrTex; - -layout(push_constant) uniform Push { - float paperWhiteNits; // absolute nits SDR paper white maps to -} pc; - -const float PQ_M1 = 0.1593017578125; -const float PQ_M2 = 78.84375; -const float PQ_C1 = 0.8359375; -const float PQ_C2 = 18.8515625; -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); -} - -vec3 srgbToLinear(vec3 c) { - bvec3 hi = greaterThan(c, vec3(0.04045)); - vec3 lo = c / 12.92; - vec3 hiV = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(lo, hiV, hi); -} - -// VK_COLOR_SPACE_HDR10_ST2084_EXT mandates BT.2020 primaries as its container gamut, but the SDR main -// target is authored/composited in BT.709/sRGB primaries — convert before the PQ encode. (ITU-R BT.2087.) -const mat3 BT709_TO_BT2020 = mat3( - 0.6274039, 0.0690973, 0.0163916, - 0.3292830, 0.9195406, 0.0880132, - 0.0433131, 0.0113612, 0.8955953 -); - -void main() { - ivec2 pix = ivec2(gl_GlobalInvocationID.xy); - ivec2 size = imageSize(outImage); - if (pix.x >= size.x || pix.y >= size.y) { - return; - } - // Decode the sRGB-authored SDR frame to linear, place it at paper white (nits), convert to BT.2020 - // primaries, then PQ-encode. No highlight roll-off: SDR content tops out at 1.0 (= paper white), well - // below the HDR peak. - vec3 nits709 = srgbToLinear(texelFetch(sdrTex, pix, 0).rgb) * pc.paperWhiteNits; - vec3 nits2020 = BT709_TO_BT2020 * nits709; - vec3 pq = vec3(pqEncode(nits2020.r), pqEncode(nits2020.g), pqEncode(nits2020.b)); - imageStore(outImage, pix, vec4(pq, 1.0)); -} diff --git a/shaders/layout/layout_probe.slang b/shaders/layout/layout_probe.slang new file mode 100644 index 00000000..cecbe033 --- /dev/null +++ b/shaders/layout/layout_probe.slang @@ -0,0 +1,96 @@ +// Build-time reflection probe only. This file is not packaged as a runtime shader. +// Exposing WorldPush as a structured-buffer element makes Slang's JSON reflection include the +// complete Std430DataLayout: field types, byte offsets, total size, matrix mode, and array counts. +import world_common; +import display_common; + +struct WorldPushLayoutProbe { + WorldPush values[2]; // array stride is the complete, tail-padded WorldPush byte size +}; + +struct MaterialHeaderLayoutProbe { + MaterialHeader values[2]; // array stride is the complete, tail-padded MaterialHeader byte size +}; + +struct ExposureStateLayoutProbe { + ExposureState values[2]; // array stride is the complete, tail-padded ExposureState byte size +}; + +[[vk::binding(0, 0)]] StructuredBuffer worldPushLayoutProbe; +[[vk::binding(1, 0)]] StructuredBuffer materialHeaderLayoutProbe; +[[vk::binding(2, 0)]] StructuredBuffer exposureStateLayoutProbe; +[[vk::push_constant]] WorldPushConstants pushConstantsLayoutProbe; + +[shader("compute")] +[numthreads(1, 1, 1)] +void main(uint3 id : SV_DispatchThreadID) { + // Keep the parameter reachable without requiring a writable output resource. + if (id.x == 0xFFFFFFFFu) { + float sink = worldPushLayoutProbe[0].values[0].invViewProj[0][0] + + materialHeaderLayoutProbe[0].values[0].params.x + + exposureStateLayoutProbe[0].values[0].previous + + float(pushConstantsLayoutProbe.frameIndex); + } +} + +// Each display-chain push-constant struct (shaders/common/display_common.slang) gets its own tiny +// entry point below: SPIR-V permits only one push-constant block reachable per entry point, so +// probing four distinct push-constant structs in one module needs four distinct entry points, each +// touching exactly one. All four are still reported in the same reflection-json pass because +// reflection parameters are enumerated module-wide, not per-entry-point -- see the main() probe +// above for the equivalent structured-buffer case. + +[[vk::push_constant]] ExposureHistPush exposureHistPushProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probeExposureHistPush(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = float(exposureHistPushProbe.stride); + } +} + +[[vk::push_constant]] ExposureResolvePush exposureResolvePushProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probeExposureResolvePush(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = exposureResolvePushProbe.key + exposureResolvePushProbe.preExposure; + } +} + +[[vk::push_constant]] DisplayPush displayPushProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probeDisplayPush(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = float(displayPushProbe.hdrEnabled) + displayPushProbe.hdrPeakNits; + } +} + +[[vk::push_constant]] DebugPresentPush debugPresentPushProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probeDebugPresentPush(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = float(debugPresentPushProbe.debugView) + debugPresentPushProbe.centerWeightFloor; + } +} + +// The sky LUT compute passes push only the frame's WorldPush device address (see world_common.PushAddr). +[[vk::push_constant]] PushAddr pushAddrLayoutProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probePushAddr(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = float(pushAddrLayoutProbe.worldPushAddr); + } +} + +[[vk::push_constant]] BloomPush bloomPushProbe; +[shader("compute")] +[numthreads(1, 1, 1)] +void probeBloomPush(uint3 id : SV_DispatchThreadID) { + if (id.x == 0xFFFFFFFFu) { + float sink = float(bloomPushProbe.mode) + bloomPushProbe.radius; + } +} diff --git a/shaders/overlay/block_outline.frag b/shaders/overlay/block_outline.frag deleted file mode 100644 index 069f0883..00000000 --- a/shaders/overlay/block_outline.frag +++ /dev/null @@ -1,48 +0,0 @@ -#version 460 -#extension GL_EXT_ray_query : require - -// Per-fragment occlusion for the targeted block's wireframe edges: an inline rayQueryEXT test against the -// same TLAS the primary trace uses, from the camera to this fragment's world position. Occluded pixels -// (anything opaque in front of the edge, including the block's own near faces) are discarded — no depth -// buffer involved, since RT's own gDepth is at DLSS-RR's internal render resolution, not this pass's -// full display resolution (see RtWorldOverlay). - -layout(push_constant) uniform Push { - mat4 curViewProj; // 0, 64B (unused here; kept so both stages share one push range) - vec3 camOffset; // 64, padded to 16B — camera's position in the terrain's rebase space - vec4 color; // 80, 16B -} pc; - -layout(set = 0, binding = 0) uniform accelerationStructureEXT tlas; - -layout(location = 0) in vec3 vCamRel; // this fragment's position relative to the camera - -layout(location = 0) out vec4 outColor; - -// Same primary-camera-ray cull mask world.rgen's tracePath uses for bounce 0 (see CULL_PRIMARY there / -// RtEntities.MASK_PRIMARY): the local first-person player's own body is deliberately masked out of primary -// rays (RtEntities.captureEntities gives it MASK_SECONDARY = 0x01 instead of MASK_ALL), since vanilla never -// draws your own body in first person either. This ray originates at the camera like a primary ray, so it -// must use the SAME mask — 0xFF (every instance) would immediately self-intersect the first-person player's -// body sitting right at the origin and discard every fragment. -const uint CULL_PRIMARY = 0x02u; - -void main() { - float dist = length(vCamRel); - if (dist < 1.0e-4) { - outColor = pc.color; - return; - } - vec3 dir = vCamRel / dist; - float tMax = max(dist - 0.01, 0.001); - - rayQueryEXT rq; - rayQueryInitializeEXT(rq, tlas, gl_RayFlagsOpaqueEXT | gl_RayFlagsTerminateOnFirstHitEXT, - CULL_PRIMARY, pc.camOffset, 0.001, dir, tMax); - while (rayQueryProceedEXT(rq)) { - } - if (rayQueryGetIntersectionTypeEXT(rq, true) != gl_RayQueryCommittedIntersectionNoneEXT) { - discard; - } - outColor = pc.color; -} diff --git a/shaders/overlay/block_outline.vert b/shaders/overlay/block_outline.vert deleted file mode 100644 index aacde79b..00000000 --- a/shaders/overlay/block_outline.vert +++ /dev/null @@ -1,24 +0,0 @@ -#version 460 - -// Native LINE_LIST draw (see RtBlockOutlineFeature) — real width comes from the raster pipeline's -// dynamic line width (VK_DYNAMIC_STATE_LINE_WIDTH, vkCmdSetLineWidth), gated on the device's wideLines -// feature (RtDeviceBringup.wideLinesEnabled/maxLineWidth); without it Vulkan mandates lineWidth == 1.0. -// inPos is in the terrain's REBASE space (blockPos - terrain.blockX/Y/Z + local edge fraction), the same -// convention entity_glow.vert's captured vertices use — camOffset (the camera's position in that same -// rebased space) is subtracted here to get the camera-relative delta curViewProj expects. - -layout(push_constant) uniform Push { - mat4 curViewProj; // 0, 64B - vec3 camOffset; // 64, padded to 16B - vec4 color; // 80, 16B -} pc; - -layout(location = 0) in vec3 inPos; - -layout(location = 0) out vec3 vCamRel; - -void main() { - vec3 camRel = inPos - pc.camOffset; - vCamRel = camRel; - gl_Position = pc.curViewProj * vec4(camRel, 1.0); -} diff --git a/shaders/overlay/entity_glow.frag b/shaders/overlay/entity_glow.frag deleted file mode 100644 index aad0d06e..00000000 --- a/shaders/overlay/entity_glow.frag +++ /dev/null @@ -1,15 +0,0 @@ -#version 460 - -// Trivial unlit fill — the mask only needs coverage + the entity's flat outline colour; edge extraction -// happens later, in entity_glow_composite.comp. -layout(push_constant) uniform Push { - mat4 curViewProj; - vec3 camOffset; - vec4 color; -} pc; - -layout(location = 0) out vec4 outColor; - -void main() { - outColor = pc.color; -} diff --git a/shaders/overlay/entity_glow.vert b/shaders/overlay/entity_glow.vert deleted file mode 100644 index e62c34e9..00000000 --- a/shaders/overlay/entity_glow.vert +++ /dev/null @@ -1,19 +0,0 @@ -#version 460 - -// Entity-glow mask: re-rasterizes glowing entities' body meshes (RtEntities already keeps this frame's -// posed CPU-side vertex data around for BLAS refit) into a full-res, depth-less mask image at the exact -// same camera projection the RT world trace used (curViewProj/camOffset mirror world.rgen's WorldPush -// fields byte-for-byte in meaning), so the silhouette lands pixel-exact on the ray-traced entity. No -// depth test/attachment at all — like vanilla's Glowing outline, the mask (and therefore the outline -// RtGlowOutline derives from it) is meant to show through walls. -layout(push_constant) uniform Push { - mat4 curViewProj; // forward camera-relative view-projection (= RtComposite's frameProjection*frameViewRotation) - vec3 camOffset; // camera position in the same rebased space inPos is captured in - vec4 color; // this entity's vanilla outline colour (opaque team colour, or white) -} pc; - -layout(location = 0) in vec3 inPos; - -void main() { - gl_Position = pc.curViewProj * vec4(inPos - pc.camOffset, 1.0); -} diff --git a/shaders/overlay/entity_glow_composite.frag b/shaders/overlay/entity_glow_composite.frag deleted file mode 100644 index aae10277..00000000 --- a/shaders/overlay/entity_glow_composite.frag +++ /dev/null @@ -1,69 +0,0 @@ -#version 460 - -// Composites the entity-glow outline onto the main render target's colour attachment via fixed-function -// blending (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) — NOT a compute imageStore. Vanilla's Blaze3D texture (the -// main render target) is never created with VK_IMAGE_USAGE_STORAGE_BIT (confirmed via validation: -// VUID-VkWriteDescriptorSet-descriptorType-00339), so it can only be written as a render-pass/dynamic- -// rendering colour attachment, exactly like RtUiOverlay's own GUI-composite blit. The mask stays a mod- -// owned storage image (entity_glow's raster pass writes it), read here via plain imageLoad — no sampler. -layout(binding = 0, set = 0, rgba8) uniform readonly image2D maskImage; - -layout(location = 0) out vec4 outColor; - -const float EDGE_THRESHOLD = 0.02; - -float coverage(ivec2 p, ivec2 size) { - return imageLoad(maskImage, clamp(p, ivec2(0), size - ivec2(1))).a; -} - -void main() { - ivec2 pix = ivec2(gl_FragCoord.xy); - ivec2 size = imageSize(maskImage); - - // Interior silhouette pixels aren't part of the outline (vanilla's Glowing effect draws only the - // ~2px edge, never a flat fill) — only background pixels can become outline. - if (coverage(pix, size) > 0.5) { - outColor = vec4(0.0); - return; - } - - // Sobel gradient of the binary silhouette coverage: nonzero only within a ~1px band just outside - // the mask, which is exactly the outline band we want. - float tl = coverage(pix + ivec2(-1, -1), size); - float tc = coverage(pix + ivec2(0, -1), size); - float tr = coverage(pix + ivec2(1, -1), size); - float ml = coverage(pix + ivec2(-1, 0), size); - float mr = coverage(pix + ivec2(1, 0), size); - float bl = coverage(pix + ivec2(-1, 1), size); - float bc = coverage(pix + ivec2(0, 1), size); - float br = coverage(pix + ivec2(1, 1), size); - - float gx = -tl - 2.0 * ml - bl + tr + 2.0 * mr + br; - float gy = -tl - 2.0 * tc - tr + bl + 2.0 * bc + br; - float edge = clamp(length(vec2(gx, gy)), 0.0, 1.0); - if (edge <= EDGE_THRESHOLD) { - outColor = vec4(0.0); - return; - } - - // Colour the outline from the average of the covered neighbours (this pixel itself has none) — where - // two differently-coloured glowing entities are adjacent, the shared edge blends between them. - vec3 col = vec3(0.0); - float count = 0.0; - for (int dy = -1; dy <= 1; dy++) { - for (int dx = -1; dx <= 1; dx++) { - ivec2 p = clamp(pix + ivec2(dx, dy), ivec2(0), size - ivec2(1)); - vec4 s = imageLoad(maskImage, p); - if (s.a > 0.5) { - col += s.rgb; - count += 1.0; - } - } - } - if (count <= 0.0) { - outColor = vec4(0.0); - return; - } - col /= count; - outColor = vec4(col, edge); -} diff --git a/shaders/overlay/name_tag.frag b/shaders/overlay/name_tag.frag deleted file mode 100644 index c4794c0e..00000000 --- a/shaders/overlay/name_tag.frag +++ /dev/null @@ -1,20 +0,0 @@ -#version 460 - -// Samples one page of the vanilla font atlas (bound as a real combined-image-sampler — this is a plain -// forward-rendered quad, not bindless like the in-RT entity texture arrays). Font pages are almost always -// RGBA8_UNORM ("colored", per BitmapProvider.Glyph.isColored() = image.format().components() > 1 — a -// standard PNG glyph sheet decodes to 4 components even for a "grayscale-looking" font): RGB is the -// glyph's baked colour (constant white for the stock font), and coverage lives in ALPHA — .rgb is NOT -// coverage. The glyph's actual tint (and the name-tag background quad's colour+alpha) travels per-vertex -// instead, so only the alpha channel is sampled here. -layout(set = 0, binding = 0) uniform sampler2D fontAtlas; - -layout(location = 0) in vec2 inUv; -layout(location = 1) in vec4 inColor; - -layout(location = 0) out vec4 outColor; - -void main() { - float a = texture(fontAtlas, inUv).a; - outColor = vec4(inColor.rgb, inColor.a * a); -} diff --git a/shaders/overlay/name_tag.vert b/shaders/overlay/name_tag.vert deleted file mode 100644 index a36063db..00000000 --- a/shaders/overlay/name_tag.vert +++ /dev/null @@ -1,25 +0,0 @@ -#version 460 - -// Full-res, post-upscale name-tag billboards. Every visible tag's glyph quads (already billboarded to -// face the camera and positioned in the entity's rebased world space by RtNameTagFeature, on the CPU — -// see that class for why: unlike glow, this can't be baked into the entity's own rigid mesh, since the -// billboard rotates with the camera every frame) are merged into one vertex buffer per font-atlas page; -// this shader just finishes the camera-relative transform, mirroring world.rgen's WorldPush fields -// byte-for-byte in meaning (curViewProj/camOffset). -layout(push_constant) uniform Push { - mat4 curViewProj; - vec3 camOffset; -} pc; - -layout(location = 0) in vec3 inPos; -layout(location = 1) in vec2 inUv; -layout(location = 2) in vec4 inColor; - -layout(location = 0) out vec2 outUv; -layout(location = 1) out vec4 outColor; - -void main() { - gl_Position = pc.curViewProj * vec4(inPos - pc.camOffset, 1.0); - outUv = inUv; - outColor = inColor; -} diff --git a/shaders/overlay/overlay_fullscreen_triangle.vert b/shaders/overlay/overlay_fullscreen_triangle.vert deleted file mode 100644 index 14c66496..00000000 --- a/shaders/overlay/overlay_fullscreen_triangle.vert +++ /dev/null @@ -1,10 +0,0 @@ -#version 460 - -// Fullscreen triangle, no vertex buffer (the classic gl_VertexIndex trick: 3 vertices covering the whole -// clip-space square, the excess clipped by the viewport). Shared by every overlay composite pass that runs -// its filtering/passthrough logic once per pixel over a colour attachment: entity_glow_composite.frag, -// overlay_passthrough_composite.frag (block outline's own bridge + RtWorldOverlay's final SDR composite). -void main() { - vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); - gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/overlay/overlay_passthrough_composite.frag b/shaders/overlay/overlay_passthrough_composite.frag deleted file mode 100644 index 770a3b64..00000000 --- a/shaders/overlay/overlay_passthrough_composite.frag +++ /dev/null @@ -1,23 +0,0 @@ -#version 460 - -// Composites a mod-owned RGBA8 storage image onto the current colour attachment via fixed-function -// blending — a plain "over" operator, no filtering; the shader itself is blend-mode-agnostic (just an -// imageLoad passthrough), the FIXED-FUNCTION blend state is what actually does the compositing math, and -// it differs per caller (same reasoning as entity_glow_composite.frag/overlay_fullscreen_triangle.vert: the -// destination is always a real dynamic-rendering colour ATTACHMENT, never a storage image, so this can't be -// a compute imageStore): -// - RtBlockOutlineFeature's own mask composite: srcImage is its MSAA-resolved outline mask (the resolve -// already turned per-sample rasterizer coverage into a fractional STRAIGHT alpha), composited onto -// RtWorldOverlay's shared overlay buffer with RtOverlayPipelines.Blend.ALPHA (SRC_ALPHA/ -// ONE_MINUS_SRC_ALPHA — correct for a straight-alpha source). -// - RtWorldOverlay's own final SDR composite: srcImage is the shared overlay buffer itself, which ends up -// PREMULTIPLIED once more than one feature has drawn into it (see Blend.ALPHA's own doc comment) — -// composited onto vanilla's main render target with Blend.PREMULTIPLIED_ALPHA (ONE/ -// ONE_MINUS_SRC_ALPHA — using ALPHA here would double-multiply by alpha). -layout(binding = 0, set = 0, rgba8) uniform readonly image2D srcImage; - -layout(location = 0) out vec4 outColor; - -void main() { - outColor = imageLoad(srcImage, ivec2(gl_FragCoord.xy)); -} diff --git a/shaders/pipelines/block_outline/bindings.slang b/shaders/pipelines/block_outline/bindings.slang new file mode 100644 index 00000000..f7fe7c85 --- /dev/null +++ b/shaders/pipelines/block_outline/bindings.slang @@ -0,0 +1 @@ +[[vk::binding(0, 0)]] public RaytracingAccelerationStructure tlas; diff --git a/shaders/pipelines/block_outline/fragment.frag.slang b/shaders/pipelines/block_outline/fragment.frag.slang new file mode 100644 index 00000000..27094d45 --- /dev/null +++ b/shaders/pipelines/block_outline/fragment.frag.slang @@ -0,0 +1,29 @@ +import overlay_common; +import bindings; + +[[vk::push_constant]] OverlayPush pc; + +static const uint CULL_PRIMARY = 0x02u; + +struct FragmentInput { + [[vk::location(0)]] float3 cameraRelative; +}; + +[shader("fragment")] +float4 main(FragmentInput input) : SV_Target0 { + float distanceToEdge = length(input.cameraRelative); + if (distanceToEdge < 1.0e-4) return pc.color; + + RayDesc ray; + ray.Origin = pc.camOffset; + ray.TMin = 0.001; + ray.Direction = input.cameraRelative / distanceToEdge; + ray.TMax = max(distanceToEdge - 0.01, 0.001); + + RayQuery query; + query.TraceRayInline(tlas, RAY_FLAG_NONE, CULL_PRIMARY, ray); + while (query.Proceed()) { + } + if (query.CommittedStatus() != COMMITTED_NOTHING) discard; + return pc.color; +} diff --git a/shaders/pipelines/block_outline/vertex.vert.slang b/shaders/pipelines/block_outline/vertex.vert.slang new file mode 100644 index 00000000..374fb6a2 --- /dev/null +++ b/shaders/pipelines/block_outline/vertex.vert.slang @@ -0,0 +1,20 @@ +import overlay_common; + +[[vk::push_constant]] OverlayPush pc; + +struct VertexInput { + [[vk::location(0)]] float3 position; +}; + +struct VertexOutput { + float4 position : SV_Position; + [[vk::location(0)]] float3 cameraRelative; +}; + +[shader("vertex")] +VertexOutput main(VertexInput input) { + VertexOutput output; + output.cameraRelative = input.position - pc.camOffset; + output.position = mul(pc.curViewProj, float4(output.cameraRelative, 1.0)); + return output; +} diff --git a/shaders/pipelines/bloom/bindings.slang b/shaders/pipelines/bloom/bindings.slang new file mode 100644 index 00000000..bf7564f1 --- /dev/null +++ b/shaders/pipelines/bloom/bindings.slang @@ -0,0 +1,3 @@ +[[vk::binding(0, 0)]] [format("rgba16f")] public RWTexture2D dstImage; +[[vk::binding(1, 0)]] public Sampler2D srcImage; +[[vk::binding(2, 0)]] [format("r32f")] public RWTexture2D exposureImage; diff --git a/shaders/pipelines/bloom/main.comp.slang b/shaders/pipelines/bloom/main.comp.slang new file mode 100644 index 00000000..a441073a --- /dev/null +++ b/shaders/pipelines/bloom/main.comp.slang @@ -0,0 +1,144 @@ +// Scene-referred bloom: a downsample/upsample mip pyramid (Jimenez, "Next Generation Post Processing in +// Call of Duty: Advanced Warfare", SIGGRAPH 2014). One dispatch per pyramid step; the step's source and +// destination are bound as a dedicated descriptor set by RtBloomPipeline, so nothing here indexes a +// descriptor array. +// +// Why a pyramid and not the previous single wide Gaussian. That pass took nine taps per axis at a spacing +// of `radius` HALF-RES pixels (2.0 at 1080p, and it scaled with resolution, so ~2.7 at 1440p). Nine +// bilinear taps spaced two or more texels apart is a comb, not a filter: for a small bright source the +// output is nine shifted copies per axis, i.e. a visible 9x9 lattice of replicas — the reported "grid". +// A pyramid only ever samples one level below itself, so every tap is within a texel or two of its +// neighbours at that level and the kernel is dense at every scale it covers. +// +// The same structure fixes the "solid block" at high intensity. One Gaussian has one width and a hard +// support: a source far above the threshold saturates its entire footprint uniformly and clips to a flat +// slab with a visible boundary. The pyramid's response is the sum of levels, each twice as wide and +// (after the threshold) carrying comparable energy, which gives the wide, monotonically decaying skirt a +// real lens produces instead of a plateau with an edge. The Karis average on the first downsample +// (below) keeps a single blown-out pixel from dominating that skirt. +import display_common; +import bindings; + +[[vk::push_constant]] BloomPush pc; + +static const float3 ACESCG_LUMA = float3(0.2722287168, 0.6740817658, 0.0536895174); +static const float HALF_MAX = 65504.0; +static const float BLOOM_ROLLOFF_START = 25.0; +static const float BLOOM_CONTRIBUTION_CEILING = 50.0; + +static const int MODE_PREFILTER = 0; +static const int MODE_DOWNSAMPLE = 1; +static const int MODE_UPSAMPLE = 2; + +float3 tap(float2 uv, float2 offset) { + return srcImage.SampleLevel(uv + offset, 0.0).rgb; +} + +float rollOffBloomContribution(float contribution) { + if (contribution <= BLOOM_ROLLOFF_START) { + return contribution; + } + + // Match the linear response and slope at the shoulder, then approach the + // ceiling asymptotically so exceptionally bright sources cannot dominate. + float shoulder = BLOOM_CONTRIBUTION_CEILING - BLOOM_ROLLOFF_START; + return BLOOM_ROLLOFF_START + + shoulder * (1.0 - exp(-(contribution - BLOOM_ROLLOFF_START) / shoulder)); +} + +// Quadratic soft-knee threshold (Jimenez): below `threshold - knee` nothing passes, above +// `threshold + knee` the full excess passes, and the transition is C1 so a highlight sliding across the +// threshold does not pop. The result scales the ORIGINAL colour, so hue is untouched. +float3 prefilter(float3 color) { + color = clamp(color, float3(0.0), float3(HALF_MAX)); + float luminance = dot(color, ACESCG_LUMA); + float knee = max(pc.softKnee, 1.0e-6); + float soft = clamp(luminance - pc.threshold + knee, 0.0, 2.0 * knee); + soft = soft * soft / (4.0 * knee); + float contribution = max(luminance - pc.threshold, soft); + contribution = rollOffBloomContribution(contribution); + return color * (contribution / max(luminance, 1.0e-6)); +} + +// Karis weight: average in a domain where a single very bright sample cannot outvote its neighbours. +// Applied per 2x2 group (not per tap) so it stays a partial average and does not flatten real highlights. +float karisWeight(float3 color) { + return 1.0 / (1.0 + dot(color, ACESCG_LUMA)); +} + +// 13-tap downsample. The five overlapping 2x2 groups (one centred, four at the corners) reconstruct a +// smooth kernel with no sample landing on the destination lattice, which is what keeps the chain free of +// the aliasing pattern a plain 2x2 box downsample produces. +float3 downsample(float2 uv, float2 srcTexel, bool karis) { + float3 a = tap(uv, float2(-2.0, -2.0) * srcTexel); + float3 b = tap(uv, float2( 0.0, -2.0) * srcTexel); + float3 c = tap(uv, float2( 2.0, -2.0) * srcTexel); + float3 d = tap(uv, float2(-1.0, -1.0) * srcTexel); + float3 e = tap(uv, float2( 1.0, -1.0) * srcTexel); + float3 f = tap(uv, float2(-2.0, 0.0) * srcTexel); + float3 g = tap(uv, float2( 0.0, 0.0) * srcTexel); + float3 h = tap(uv, float2( 2.0, 0.0) * srcTexel); + float3 i = tap(uv, float2(-1.0, 1.0) * srcTexel); + float3 j = tap(uv, float2( 1.0, 1.0) * srcTexel); + float3 k = tap(uv, float2(-2.0, 2.0) * srcTexel); + float3 l = tap(uv, float2( 0.0, 2.0) * srcTexel); + float3 m = tap(uv, float2( 2.0, 2.0) * srcTexel); + + float3 group0 = (d + e + i + j) * 0.25; // centre 2x2 + float3 group1 = (a + b + g + f) * 0.25; + float3 group2 = (b + c + h + g) * 0.25; + float3 group3 = (f + g + l + k) * 0.25; + float3 group4 = (g + h + m + l) * 0.25; + + if (!karis) { + return group0 * 0.5 + (group1 + group2 + group3 + group4) * 0.125; + } + float w0 = 0.5 * karisWeight(group0); + float w1 = 0.125 * karisWeight(group1); + float w2 = 0.125 * karisWeight(group2); + float w3 = 0.125 * karisWeight(group3); + float w4 = 0.125 * karisWeight(group4); + float sum = w0 + w1 + w2 + w3 + w4; + return (group0 * w0 + group1 * w1 + group2 * w2 + group3 * w3 + group4 * w4) / max(sum, 1.0e-6); +} + +// 3x3 tent upsample, radius in SOURCE texels. Radius 1 is the standard filter; larger values widen every +// level of the skirt at once, which is the only knob the pyramid needs (the level count sets its reach). +float3 upsample(float2 uv, float2 srcTexel, float radius) { + float2 o = srcTexel * radius; + float3 sum = tap(uv, float2(-o.x, o.y)) + tap(uv, float2(0.0, o.y)) * 2.0 + tap(uv, float2(o.x, o.y)); + sum += tap(uv, float2(-o.x, 0.0)) * 2.0 + tap(uv, float2(0.0, 0.0)) * 4.0 + tap(uv, float2(o.x, 0.0)) * 2.0; + sum += tap(uv, float2(-o.x, -o.y)) + tap(uv, float2(0.0, -o.y)) * 2.0 + tap(uv, float2(o.x, -o.y)); + return sum * (1.0 / 16.0); +} + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 dispatchId : SV_DispatchThreadID) { + int2 pixel = int2(dispatchId.xy); + uint width, height; + dstImage.GetDimensions(width, height); + if (pixel.x >= int(width) || pixel.y >= int(height)) { + return; + } + uint srcWidth, srcHeight; + srcImage.GetDimensions(srcWidth, srcHeight); + float2 srcTexel = 1.0 / float2(float(srcWidth), float(srcHeight)); + float2 uv = (float2(pixel) + 0.5) / float2(float(width), float(height)); + + if (pc.mode == MODE_PREFILTER) { + // The pyramid works on EXPOSED values so the threshold is a display-referred decision (how far + // above mid-grey a highlight has to be), not one that drifts with the scene's absolute level. + float exposure = max(exposureImage[int2(0, 0)], 0.0); + float3 color = downsample(uv, srcTexel, true) * exposure; + dstImage[pixel] = float4(prefilter(color), 1.0); + } else if (pc.mode == MODE_DOWNSAMPLE) { + dstImage[pixel] = float4(downsample(uv, srcTexel, false), 1.0); + } else { + // In-place accumulate: this level already holds its own downsampled band, and the coarser level + // tented up onto it adds the next-wider band. Summing on the way down the pyramid is what turns + // the per-level bands into one continuous falloff. + float3 coarse = upsample(uv, srcTexel, max(pc.radius, 0.0)); + dstImage[pixel] = float4(dstImage[pixel].rgb + coarse, 1.0); + } +} diff --git a/shaders/pipelines/debug_present/bindings.slang b/shaders/pipelines/debug_present/bindings.slang new file mode 100644 index 00000000..35c90c41 --- /dev/null +++ b/shaders/pipelines/debug_present/bindings.slang @@ -0,0 +1,12 @@ +import display_common; + +[[vk::binding(0, 0)]] [format("rgba8")] public RWTexture2D outputImage; +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D gNormal; +[[vk::binding(2, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; +[[vk::binding(3, 0)]] [format("r32f")] public RWTexture2D gDepth; +[[vk::binding(4, 0)]] [format("rg16f")] public RWTexture2D gMotion; +[[vk::binding(5, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; +[[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; +[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D sceneImage; +[[vk::binding(8, 0)]] [format("r32f")] public RWTexture2D exposureImage; +[[vk::binding(9, 0)]] public StructuredBuffer exposureState; diff --git a/shaders/pipelines/debug_present/main.comp.slang b/shaders/pipelines/debug_present/main.comp.slang new file mode 100644 index 00000000..18144092 --- /dev/null +++ b/shaders/pipelines/debug_present/main.comp.slang @@ -0,0 +1,116 @@ +// Computes and presents debugView content as a final inspection pass after ordinary path-traced +// radiance, RR/fallback upscale, exposure metering, and the ACES display transform. Keeping debug +// coloring out of the occupancy-bound primary raygen avoids register pressure on the normal render path. +// +// Reads the guide buffers directly at their real render resolution and nearest-samples them into the +// display-resolution output. Debug mode deliberately leaves DLSS-RR and its jitter enabled, so this +// pass shows the actual guide inputs used by RR rather than changing the renderer being inspected. + +import display_common; +import bindings; + +[[vk::push_constant]] DebugPresentPush pc; + +static const float3 ACESCG_LUMA = float3(0.27222872, 0.67408177, 0.05368952); + +// OCIO cg-config-v4.0.0 ACES 2.0: ACEScg/AP1/D60 to Linear Rec.709/D65. Debug guide colors cross +// back to the rgba8 target here; semantic debug colors (normal, depth, motion, weight) are already +// authored as literal sRGB code values and bypass this conversion. +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 float3 ACESCG_TO_BT709_B = float3(-0.02400336, -0.12896897, 1.15297234); + +float linearToSrgbChannel(float value) { + value = clamp(value, 0.0, 1.0); + return value <= 0.0031308 + ? 12.92 * value + : 1.055 * pow(value, 1.0 / 2.4) - 0.055; +} + +float3 acesCgToSrgb(float3 color) { + float3 linearBt709 = float3( + dot(color, ACESCG_TO_BT709_R), + dot(color, ACESCG_TO_BT709_G), + dot(color, ACESCG_TO_BT709_B)); + return float3( + linearToSrgbChannel(linearBt709.r), + linearToSrgbChannel(linearBt709.g), + linearToSrgbChannel(linearBt709.b)); +} + +// Display-referred, deliberately discrete one-stop bands. `ev == 0` means the value entering ACES +// is 18% grey; cool colors are below it, warm colors above it. These values are literal SDR/sRGB +// debug colors written after the display transform, not scene radiance. +float3 exposureFalseColor(float ev) { + float band = floor(ev + 0.5); + if (band <= -6.0) return float3(0.01, 0.00, 0.03); + if (band == -5.0) return float3(0.12, 0.00, 0.25); + if (band == -4.0) return float3(0.10, 0.15, 0.75); + if (band == -3.0) return float3(0.00, 0.55, 1.00); + if (band == -2.0) return float3(0.00, 0.75, 0.55); + if (band == -1.0) return float3(0.10, 0.80, 0.15); + if (band == 0.0) return float3(0.46, 0.46, 0.46); + if (band == 1.0) return float3(0.78, 0.78, 0.05); + if (band == 2.0) return float3(1.00, 0.50, 0.00); + if (band == 3.0) return float3(1.00, 0.05, 0.02); + if (band == 4.0) return float3(1.00, 0.00, 0.70); + if (band == 5.0) return float3(1.00, 0.55, 0.80); + return float3(1.00, 1.00, 1.00); +} + +// Exactly the metering weight used by the histogram: local Gaussian centre weighting multiplied by +// the same frame-global sky/emissive scale computed by resolve from separate populations. +float meteringWeight(float2 uv, float depth, bool emissive) { + float2 centered = uv * 2.0 - 1.0; + float sigma = max(pc.centerWeightSigma, 0.01); + float gaussian = exp(-0.5 * dot(centered, centered) / (sigma * sigma)); + float centerWeight = lerp(clamp(pc.centerWeightFloor, 0.0, 1.0), 1.0, gaussian); + float populationWeight = depth <= 1.0e-6 ? exposureState[0].meteringSkyScale + : (emissive ? exposureState[0].meteringEmissiveScale : 1.0); + return centerWeight * populationWeight; +} + +[shader("compute")] +[numthreads(16, 16, 1)] +void main(uint3 dispatchId : SV_DispatchThreadID) { + int2 pix = int2(dispatchId.xy); + uint w, h; + outputImage.GetDimensions(w, h); + if (pix.x >= int(w) || pix.y >= int(h)) { + return; + } + + uint guideW, guideH; + gNormal.GetDimensions(guideW, guideH); + float2 uv = (float2(pix) + 0.5) / float2(w, h); + int2 guidePix = min(int2(uv * float2(guideW, guideH)), int2(guideW, guideH) - 1); + float2 motionToDisplay = float2(w, h) / float2(guideW, guideH); + + float4 normalRough = gNormal[guidePix]; + float3 dbg; + if (pc.debugView == 8u) { + float luminance = dot(max(sceneImage[pix].rgb, float3(0.0)), ACESCG_LUMA); + float exposedLuminance = luminance * max(exposureImage[int2(0, 0)], 0.0); + float evFromMidGrey = log2(max(exposedLuminance, 1.0e-6) / 0.18); + dbg = exposureFalseColor(evFromMidGrey); + } else if (pc.debugView == 9u) { + float weight = meteringWeight(uv, gDepth[guidePix], gAlbedo[guidePix].a > 0.5); + dbg = float3(weight, weight, weight); + } else if (pc.debugView == 1u) { + dbg = normalRough.xyz * 0.5 + 0.5; + } else if (pc.debugView == 2u) { + dbg = acesCgToSrgb(gAlbedo[guidePix].rgb); + } else if (pc.debugView == 3u) { + float depth = gDepth[guidePix]; + dbg = float3(depth, depth, depth); + } else if (pc.debugView == 4u) { + dbg = float3(normalRough.w, normalRough.w, normalRough.w); + } else if (pc.debugView == 6u) { + dbg = acesCgToSrgb(gSpecAlbedo[guidePix].rgb); + } else if (pc.debugView == 7u) { + dbg = float3(clamp(0.5 + gSpecMotion[guidePix] * motionToDisplay * 0.05, 0.0, 1.0), 0.5); + } else { + dbg = float3(clamp(0.5 + gMotion[guidePix] * motionToDisplay * 0.05, 0.0, 1.0), 0.5); + } + outputImage[pix] = float4(clamp(dbg, 0.0, 1.0), 1.0); +} diff --git a/shaders/pipelines/display/bindings.slang b/shaders/pipelines/display/bindings.slang new file mode 100644 index 00000000..1aa138f6 --- /dev/null +++ b/shaders/pipelines/display/bindings.slang @@ -0,0 +1,8 @@ +[[vk::binding(0, 0)]] [format("rgba8")] public RWTexture2D outputImage; +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D rtImage; +[[vk::binding(2, 0)]] [format("r32f")] public RWTexture2D exposureImage; +[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D hdrImage; +[[vk::binding(4, 0)]] public Sampler3D toneLut; +[[vk::binding(5, 0)]] public Sampler3D hdrToneLut; +[[vk::binding(6, 0)]] public Sampler3D lookLut; +[[vk::binding(7, 0)]] public Sampler2D bloomImage; diff --git a/shaders/pipelines/display/main.comp.slang b/shaders/pipelines/display/main.comp.slang new file mode 100644 index 00000000..b92d243d --- /dev/null +++ b/shaders/pipelines/display/main.comp.slang @@ -0,0 +1,164 @@ +// 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. + +import display_common; +import bindings; + +[[vk::push_constant]] DisplayPush pc; + +// MUST exactly match SHAPER_LO_STOPS/SHAPER_HI_STOPS in tools/bake_display_lut.py -- these are the two +// halves of one shaper function, one run at bake time (Python), one at sample time (here); if they +// drift the LUT fetch reads the wrong shelf of the LUT everywhere, silently. +static const float LUT_SHAPER_LO_STOPS = -12.0; +static const float LUT_SHAPER_HI_STOPS = 12.0; + +float3 shaperEncode(float3 linearColor) { + float3 stops = log2(max(linearColor, float3(1.0e-6))); + return clamp((stops - LUT_SHAPER_LO_STOPS) / (LUT_SHAPER_HI_STOPS - LUT_SHAPER_LO_STOPS), 0.0, 1.0); +} + +// Edge-aligned LUT: texel 0's centre is uvw=0.0 and texel (lutSize-1)'s centre is uvw=1.0, but a +// sampler's normalized [0,1] domain spans texel *centres* at 0.5/N .. 1-0.5/N by default. Remap into +// that convention; RtToneLut's CLAMP_TO_EDGE then holds the boundary texel for any exposed value +// outside the shaper's +/-12 EV range instead of wrapping. toneLut/hdrToneLut share pc.lutSize; +// the independently sized lookLut passes pc.lookLutSize. +float3 lutTexCoord(float3 uvw, float size) { + return uvw * ((size - 1.0) / size) + float3(0.5 / size); +} + +float3 shaperDecode(float3 shaped) { + return exp2(lerp( + float3(LUT_SHAPER_LO_STOPS), + float3(LUT_SHAPER_HI_STOPS), + clamp(shaped, 0.0, 1.0))); +} + +float3 applyLook(float3 exposedAcesCg) { + if (pc.lookEnabled == 0) { + return exposedAcesCg; + } + float3 uvw = shaperEncode(exposedAcesCg); + float3 shaped = lookLut.SampleLevel(lutTexCoord(uvw, pc.lookLutSize), 0.0).rgb; + return shaperDecode(shaped); +} + +float3 sampleBloom(int2 outputPixel, uint outputWidth, uint outputHeight) { + float2 uv = (float2(outputPixel) + 0.5) / float2(outputWidth, outputHeight); + return bloomImage.SampleLevel(uv, 0.0).rgb; +} + +// 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 +// colors preserve their channel ratios instead of clipping individual channels. +static const float3 BT709_LUMA = float3(0.2126, 0.7152, 0.0722); +static const float3 BT2020_LUMA = float3(0.2627, 0.6780, 0.0593); + +float3 luminanceGamma(float3 linearDisplayColor, float3 lumaCoefficients) { + float3 color = clamp(linearDisplayColor, 0.0, 1.0); + float luminance = dot(color, lumaCoefficients); + if (luminance <= 0.0) { + return color; + } + + float adjustedLuminance = pow(luminance, clamp(pc.gamma, 0.1, 5.0)); + float scale = adjustedLuminance / luminance; + float maxChannel = max(color.r, max(color.g, color.b)); + float gamutLimitedScale = 1.0 / max(maxChannel, 1.0e-8); + return color * min(scale, gamutLimitedScale); +} + +float srgbDecodeChannel(float code) { + return code <= 0.04045 ? code / 12.92 : pow((code + 0.055) / 1.055, 2.4); +} + +float srgbEncodeChannel(float linearValue) { + return linearValue <= 0.0031308 + ? 12.92 * linearValue + : 1.055 * pow(linearValue, 1.0 / 2.4) - 0.055; +} + +float3 srgbDecode(float3 code) { + return float3(srgbDecodeChannel(code.r), srgbDecodeChannel(code.g), srgbDecodeChannel(code.b)); +} + +float3 srgbEncode(float3 linearColor) { + return float3( + srgbEncodeChannel(linearColor.r), + srgbEncodeChannel(linearColor.g), + srgbEncodeChannel(linearColor.b)); +} + +float3 displayGammaSdr(float3 displayColor) { + float3 code = clamp(displayColor, 0.0, 1.0); + if (abs(pc.gamma - 1.0) < 1.0e-6) { + return code; + } + return clamp(srgbEncode(luminanceGamma(srgbDecode(code), BT709_LUMA)), 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; +static const float PQ_C2 = 18.8515625; +static const float PQ_C3 = 18.6875; + +float3 pqDecode(float3 code) { + float3 p = pow(clamp(code, 0.0, 1.0), float3(1.0 / PQ_M2)); + float3 y = max((p - PQ_C1) / max(PQ_C2 - PQ_C3 * p, float3(1.0e-6)), float3(0.0)); + return pow(y, float3(1.0 / PQ_M1)) * 10000.0; +} + +float3 pqEncode(float3 nits) { + float3 y = pow(max(nits, float3(0.0)) / 10000.0, float3(PQ_M1)); + return pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), float3(PQ_M2)); +} + +float3 displayGammaHdr(float3 pqColor) { + if (abs(pc.gamma - 1.0) < 1.0e-6) { + return clamp(pqColor, 0.0, 1.0); + } + float peak = max(pc.hdrPeakNits, 1.0); + float3 normalizedNits = clamp(pqDecode(pqColor) / peak, 0.0, 1.0); + float3 adjustedNits = peak * luminanceGamma(normalizedNits, BT2020_LUMA); + return clamp(pqEncode(adjustedNits), 0.0, 1.0); +} + +// 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. +float3 tonemap(float3 lookedAcesCg) { + float3 uvw = shaperEncode(lookedAcesCg); + return displayGammaSdr(toneLut.SampleLevel(lutTexCoord(uvw, pc.lutSize), 0.0).rgb); +} + +// Same shaper as tonemap() but sampling the HDR LUT, whose range is already PQ-encoded BT.2020 -- no +// separate encode or UI-brightness mapping needed. +float3 tonemapHdr(float3 lookedAcesCg) { + float3 uvw = shaperEncode(lookedAcesCg); + return displayGammaHdr(hdrToneLut.SampleLevel(lutTexCoord(uvw, pc.lutSize), 0.0).rgb); +} + +[shader("compute")] +[numthreads(16, 16, 1)] +void main(uint3 dispatchId : SV_DispatchThreadID) { + int2 pix = int2(dispatchId.xy); + uint w, h; + outputImage.GetDimensions(w, h); + if (pix.x >= int(w) || pix.y >= int(h)) { + return; + } + + float4 rt = rtImage[pix]; + float exposure = max(exposureImage[int2(0, 0)], 0.0); + float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0)); + exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0); + float3 lookedAcesCg = applyLook(exposedAcesCg); + outputImage[pix] = float4(tonemap(lookedAcesCg), 1.0); + + if (pc.hdrEnabled != 0) { + hdrImage[pix] = float4(tonemapHdr(lookedAcesCg), 1.0); + } +} diff --git a/shaders/pipelines/entity_glow/fragment.frag.slang b/shaders/pipelines/entity_glow/fragment.frag.slang new file mode 100644 index 00000000..22345120 --- /dev/null +++ b/shaders/pipelines/entity_glow/fragment.frag.slang @@ -0,0 +1,8 @@ +import overlay_common; + +[[vk::push_constant]] OverlayPush pc; + +[shader("fragment")] +float4 main() : SV_Target0 { + return pc.color; +} diff --git a/shaders/pipelines/entity_glow/vertex.vert.slang b/shaders/pipelines/entity_glow/vertex.vert.slang new file mode 100644 index 00000000..e3ef77bf --- /dev/null +++ b/shaders/pipelines/entity_glow/vertex.vert.slang @@ -0,0 +1,18 @@ +import overlay_common; + +[[vk::push_constant]] OverlayPush pc; + +struct VertexInput { + [[vk::location(0)]] float3 position; +}; + +struct VertexOutput { + float4 position : SV_Position; +}; + +[shader("vertex")] +VertexOutput main(VertexInput input) { + VertexOutput output; + output.position = mul(pc.curViewProj, float4(input.position - pc.camOffset, 1.0)); + return output; +} diff --git a/shaders/pipelines/exposure_hist/bindings.slang b/shaders/pipelines/exposure_hist/bindings.slang new file mode 100644 index 00000000..b3125260 --- /dev/null +++ b/shaders/pipelines/exposure_hist/bindings.slang @@ -0,0 +1,4 @@ +[[vk::binding(0, 0)]] [format("rgba16f")] public RWTexture2D colorImage; +[[vk::binding(1, 0)]] public RWStructuredBuffer histBins; +[[vk::binding(2, 0)]] [format("r32f")] public RWTexture2D depthImage; +[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D albedoImage; diff --git a/shaders/pipelines/exposure_hist/main.comp.slang b/shaders/pipelines/exposure_hist/main.comp.slang new file mode 100644 index 00000000..88d4918c --- /dev/null +++ b/shaders/pipelines/exposure_hist/main.comp.slang @@ -0,0 +1,78 @@ +// Strided log2-luminance histogram over the post-RR trace color. Metering after RR avoids the +// Jensen bias that Monte-Carlo noise introduces into the logarithmic average. + +import display_common; +import bindings; + +[[vk::push_constant]] ExposureHistPush pc; + +static const float LOG_MIN = -12.0; +static const float LOG_MAX = 12.0; +static const float INV_LOG_RANGE = 1.0 / (LOG_MAX - LOG_MIN); +static const float3 ACESCG_LUMA = float3(0.27222872, 0.67408177, 0.05368952); + +// One 16x16 workgroup == 256 threads == one thread per luminance bin. Each thread initializes and +// flushes all three population bins. Per-pixel atomics stay in shared memory; only non-empty bins +// pay global atomics. +groupshared uint localBins[768]; + +[shader("compute")] +[numthreads(16, 16, 1)] +void main(uint3 dispatchId : SV_DispatchThreadID, uint groupIndex : SV_GroupIndex) { + localBins[groupIndex] = 0u; + localBins[256u + groupIndex] = 0u; + localBins[512u + groupIndex] = 0u; + GroupMemoryBarrierWithGroupSync(); + + int2 pix = int2(dispatchId.xy * pc.stride); + uint w, h; + colorImage.GetDimensions(w, h); + if (pix.x < int(w) && pix.y < int(h)) { + float3 rgb = colorImage[pix].rgb; + // Invalid radiance is not a meaningful metering sample. Skipping it is safe because resolve + // derives its population from the bins instead of assuming image area. + if (all(isfinite(rgb))) { + rgb = max(rgb, float3(0.0)); + // The reconstructed scene image is linear ACEScg; meter it in the transport basis. + float lum = dot(rgb, ACESCG_LUMA); + float logLum = clamp(log2(max(lum, 1.0e-5)), LOG_MIN, LOG_MAX); + uint bin = min(uint(floor((logLum - LOG_MIN) * INV_LOG_RANGE * 256.0)), 255u); + + float2 uv = (float2(pix) + 0.5) / float2(w, h); + float2 centered = uv * 2.0 - 1.0; + float sigma = max(pc.centerWeightSigma, 0.01); + float gaussian = exp(-0.5 * dot(centered, centered) / (sigma * sigma)); + float weight = lerp(clamp(pc.centerWeightFloor, 0.0, 1.0), 1.0, gaussian); + uint fixedWeight = uint(round(weight * 256.0)); + + uint depthW, depthH; + depthImage.GetDimensions(depthW, depthH); + int2 guidePix = min(int2(uv * float2(depthW, depthH)), int2(depthW, depthH) - 1); + bool sky = depthImage[guidePix] <= 1.0e-6; + bool emissive = !sky && albedoImage[guidePix].a > 0.5; + uint populationOffset = sky ? 256u : (emissive ? 512u : 0u); + uint weightedBin = bin + populationOffset; + if (fixedWeight > 0u) { + uint unused; + InterlockedAdd(localBins[weightedBin], fixedWeight, unused); + } + } + } + + GroupMemoryBarrierWithGroupSync(); + uint surfaceCount = localBins[groupIndex]; + if (surfaceCount > 0u) { + uint unusedSurface; + InterlockedAdd(histBins[groupIndex], surfaceCount, unusedSurface); + } + uint skyCount = localBins[256u + groupIndex]; + if (skyCount > 0u) { + uint unusedSky; + InterlockedAdd(histBins[256u + groupIndex], skyCount, unusedSky); + } + uint emissiveCount = localBins[512u + groupIndex]; + if (emissiveCount > 0u) { + uint unusedEmissive; + InterlockedAdd(histBins[512u + groupIndex], emissiveCount, unusedEmissive); + } +} diff --git a/shaders/pipelines/exposure_resolve/bindings.slang b/shaders/pipelines/exposure_resolve/bindings.slang new file mode 100644 index 00000000..0f5f9568 --- /dev/null +++ b/shaders/pipelines/exposure_resolve/bindings.slang @@ -0,0 +1,5 @@ +import display_common; + +[[vk::binding(0, 0)]] public StructuredBuffer histBins; +[[vk::binding(1, 0)]] [format("r32f")] public RWTexture2D exposureImage; +[[vk::binding(2, 0)]] public RWStructuredBuffer stateBuf; diff --git a/shaders/pipelines/exposure_resolve/main.comp.slang b/shaders/pipelines/exposure_resolve/main.comp.slang new file mode 100644 index 00000000..39365a8e --- /dev/null +++ b/shaders/pipelines/exposure_resolve/main.comp.slang @@ -0,0 +1,188 @@ +// Single-invocation exposure controller: percentile-trimmed mean of log2 luminance -> key -> clamp -> +// exponential smoothing in EV space. ExposureState keeps temporal state and read-only diagnostics +// (evScene/evTarget/evApplied/clipLowFrac/clipHighFrac) on the GPU. The CPU advances resetSeq when +// scene continuity breaks; a mismatch snaps to this frame's target without a host write or fence. + +import display_common; +import bindings; + +[[vk::push_constant]] ExposureResolvePush pc; + +static const float LOG_MIN = -12.0; +static const float LOG_MAX = 12.0; +static const float LOG_STEP = (LOG_MAX - LOG_MIN) / 256.0; + +float binCenter(uint bin) { + return LOG_MIN + (float(bin) + 0.5) * LOG_STEP; +} + +float curveSegment(float x, float x0, float y0, float x1, float y1) { + return lerp(y0, y1, (x - x0) / (x1 - x0)); +} + +float curveCompensation(float sceneEv) { + if (sceneEv <= pc.curveScene0) return pc.curveCompensation0; + if (sceneEv < pc.curveScene1) { + return curveSegment(sceneEv, pc.curveScene0, pc.curveCompensation0, + pc.curveScene1, pc.curveCompensation1); + } + if (sceneEv < pc.curveScene2) { + return curveSegment(sceneEv, pc.curveScene1, pc.curveCompensation1, + pc.curveScene2, pc.curveCompensation2); + } + if (sceneEv < pc.curveScene3) { + return curveSegment(sceneEv, pc.curveScene2, pc.curveCompensation2, + pc.curveScene3, pc.curveCompensation3); + } + return pc.curveCompensation3; +} + +float curveEffectiveSlope(float sceneEv) { + if (sceneEv <= pc.curveScene0 || sceneEv >= pc.curveScene3) return 1.0; + float compensationSlope; + if (sceneEv < pc.curveScene1) { + compensationSlope = (pc.curveCompensation1 - pc.curveCompensation0) + / (pc.curveScene1 - pc.curveScene0); + } else if (sceneEv < pc.curveScene2) { + compensationSlope = (pc.curveCompensation2 - pc.curveCompensation1) + / (pc.curveScene2 - pc.curveScene1); + } else { + compensationSlope = (pc.curveCompensation3 - pc.curveCompensation2) + / (pc.curveScene3 - pc.curveScene2); + } + return 1.0 - compensationSlope; +} + +[shader("compute")] +[numthreads(1, 1, 1)] +void main() { + float surfacePopulation = 0.0; + float skyPopulation = 0.0; + float emissivePopulation = 0.0; + for (uint i = 0u; i < 256u; ++i) { + surfacePopulation += float(histBins[i]); + skyPopulation += float(histBins[256u + i]); + emissivePopulation += float(histBins[512u + i]); + } + float skyCap = clamp(pc.skyWeightCap, 0.0, 1.0); + float emissiveCap = clamp(pc.emissiveWeightCap, 0.0, 1.0); + float skyScale = 1.0; + float emissiveScale = 1.0; + // Preserve the previous behavior for views containing no ordinary surface: with no neutral + // population to meter against, suppressing a sky-only or lava-only frame would leave no signal. + // Otherwise solve the two final-share constraints exactly. If both raw populations exceed their + // caps, both constraints are active and ordinary surfaces occupy the remaining share. + if (surfacePopulation > 0.0) { + float rawTotal = surfacePopulation + skyPopulation + emissivePopulation; + bool capSky = skyPopulation > skyCap * rawTotal; + bool capEmissive = emissivePopulation > emissiveCap * rawTotal; + float weightedSky = skyPopulation; + float weightedEmissive = emissivePopulation; + if (capSky && capEmissive) { + float ordinaryShare = max(1.0 - skyCap - emissiveCap, 1.0e-6); + float cappedTotal = surfacePopulation / ordinaryShare; + weightedSky = skyCap * cappedTotal; + weightedEmissive = emissiveCap * cappedTotal; + } else if (capSky) { + weightedSky = skyCap <= 0.0 ? 0.0 + : skyCap * (surfacePopulation + emissivePopulation) / max(1.0 - skyCap, 1.0e-6); + } else if (capEmissive) { + weightedEmissive = emissiveCap <= 0.0 ? 0.0 + : emissiveCap * (surfacePopulation + skyPopulation) / max(1.0 - emissiveCap, 1.0e-6); + } + skyScale = skyPopulation > 0.0 ? min(1.0, weightedSky / skyPopulation) : 1.0; + emissiveScale = emissivePopulation > 0.0 + ? min(1.0, weightedEmissive / emissivePopulation) : 1.0; + } + float population = surfacePopulation + skyPopulation * skyScale + + emissivePopulation * emissiveScale; + 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); + float lowCount = min(floor(total * lowPercentile), total - 1.0); + float highCount = clamp(ceil(total * highPercentile), lowCount + 1.0, total); + + float cumulative = 0.0; + float weightedLogLum = 0.0; + float weightedCount = 0.0; + for (uint i = 0u; i < 256u; ++i) { + float count = float(histBins[i]) + float(histBins[256u + i]) * skyScale + + float(histBins[512u + i]) * emissiveScale; + float start = cumulative; + float end = cumulative + count; + float takeStart = max(start, lowCount); + float takeEnd = min(end, highCount); + if (takeEnd > takeStart) { + float take = takeEnd - takeStart; + weightedLogLum += binCenter(i) * take; + weightedCount += take; + } + cumulative = end; + } + + float avgLogLum = weightedCount > 0.0 ? weightedLogLum / weightedCount : 0.0; + // avgLogLum is log2 of the PRE-EXPOSED stored luminance. evScene is the absolute scene value on + // the EV100 scale used by the compensation curve's control points and diagnostics. + float evScene = avgLogLum + pc.evOffset; + float compensation = curveCompensation(evScene); + float effectiveSlope = curveEffectiveSlope(evScene); + // Residual: what the display pass must still multiply the stored (already pre-exposed) value by. + // Deriving it from avgLogLum rather than evScene is deliberate and exact -- stored * residual = + // key * 2^(evBias + compensation) regardless of preExposure, so the pre-exposure cancels. + float residualTarget = pc.key * exp2(pc.evBias - avgLogLum + compensation); + // Clamp and smooth in ABSOLUTE exposure space. The residual is near 1.0 by construction (it is + // whatever preExposure failed to anticipate), so clamping or smoothing it would bound the wrong + // quantity and would fight preExposure drifting between frames. + // Fail safe, not to white: a non-positive/non-finite value here can only mean the push layout + // desynced, and clamping it to a tiny epsilon would divide the residual up to infinity and blow + // the whole frame out. 1.0 safely disables pre-exposure for this frame. + float preExposure = (isfinite(pc.preExposure) && pc.preExposure > 0.0) ? pc.preExposure : 1.0; + float target = clamp(residualTarget * preExposure, exp2(pc.minEv), exp2(pc.maxEv)); + + bool initialized = stateBuf[0].initialized != 0u; + bool reset = !initialized || stateBuf[0].resetSeq != pc.resetSeq; + float targetEv = log2(max(target, 1.0e-12)); + if (reset) { + stateBuf[0].resetSeq = pc.resetSeq; + } + float prevEv = reset ? targetEv : log2(max(stateBuf[0].previous, 1.0e-12)); + + // Smooth in EV space, not in linear exposure space. Linear smoothing is + // wildly asymmetric for reasons that have nothing to do with the time constants: lerping toward a + // much LARGER target crosses most of the ratio in the first frame, while lerping toward a much + // SMALLER one decays through it geometrically. Measured in game, that made day->night snap in a + // frame and night->day take ~3.5s of white screen -- the exact opposite of how eyes work, and it + // survived any choice of adaptUp/adaptDown because the asymmetry was in the interpolation itself. + // In EV space a 15 EV swing takes the same 3.4*tau either way, so the time constants below are the + // only thing that sets the asymmetry, and they can express the real one: light adaptation is quick, + // dark adaptation is slow. + float exposureEv = prevEv; + if (!reset) { + float deltaEv = targetEv - prevEv; + float rate = deltaEv > 0.0 ? pc.adaptDarken : pc.adaptBrighten; + float alpha = 1.0 - exp(-pc.frameTimeSeconds / max(rate, 1.0e-4)); + exposureEv = prevEv + deltaEv * clamp(alpha, 0.0, 1.0); + } + float exposure = exp2(exposureEv); + + // previous stays ABSOLUTE: it is both the smoothing history and the value RtExposure reads back + // to choose next frame's preExposure. + stateBuf[0].previous = exposure; + stateBuf[0].initialized = 1u; + stateBuf[0].evScene = evScene; + stateBuf[0].evTarget = targetEv; + stateBuf[0].evApplied = exposureEv; + stateBuf[0].clipLowFrac = (float(histBins[0]) + float(histBins[256]) * skyScale + + float(histBins[512]) * emissiveScale) / total; + stateBuf[0].clipHighFrac = (float(histBins[255]) + float(histBins[511]) * skyScale + + float(histBins[767]) * emissiveScale) / total; + stateBuf[0].meteringSkyScale = skyScale; + stateBuf[0].meteringSkyFrac = skyPopulation * skyScale / total; + stateBuf[0].curveCompensation = compensation; + stateBuf[0].effectiveSlope = effectiveSlope; + stateBuf[0].meteringEmissiveScale = emissiveScale; + stateBuf[0].meteringEmissiveFrac = emissivePopulation * emissiveScale / total; + // The display pass consumes stored radiance, which raygen already scaled by preExposure, so it + // needs the residual rather than the absolute exposure. stored * residual == L * exposure. + exposureImage[int2(0, 0)] = exposure / preExposure; +} diff --git a/shaders/pipelines/hdr_composite/bindings.slang b/shaders/pipelines/hdr_composite/bindings.slang new file mode 100644 index 00000000..d742b6c1 --- /dev/null +++ b/shaders/pipelines/hdr_composite/bindings.slang @@ -0,0 +1,2 @@ +[[vk::binding(0, 0)]] [format("rgba16f")] public RWTexture2D outputImage; +[[vk::binding(1, 0)]] public Sampler2D sourceImage; diff --git a/shaders/pipelines/hdr_composite/main.comp.slang b/shaders/pipelines/hdr_composite/main.comp.slang new file mode 100644 index 00000000..9b2bb557 --- /dev/null +++ b/shaders/pipelines/hdr_composite/main.comp.slang @@ -0,0 +1,61 @@ +// Composites the sRGB-authored, premultiplied UI over the PQ/BT.2020 HDR world in linear nits. +import bindings; + +struct PresentPush { + float uiNits; +}; +[[vk::push_constant]] PresentPush pc; + +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); +} + +float pqDecode(float e) { + float ep = pow(max(e, 0.0), 1.0 / PQ_M2); + float num = max(ep - PQ_C1, 0.0); + float den = max(PQ_C2 - PQ_C3 * ep, 1.0e-6); + return 10000.0 * pow(num / den, 1.0 / PQ_M1); +} + +float srgbChannelToLinear(float c) { + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); +} + +float3 srgbToLinear(float3 c) { + return float3(srgbChannelToLinear(c.x), srgbChannelToLinear(c.y), srgbChannelToLinear(c.z)); +} + +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 +); + +[shader("compute")] +[numthreads(16, 16, 1)] +void main(uint3 tid : SV_DispatchThreadID) { + uint width, height; + outputImage.GetDimensions(width, height); + if (tid.x >= width || tid.y >= height) return; + + int2 pix = int2(tid.xy); + float4 ui = sourceImage.Load(int3(pix, 0)); + float alpha = ui.a; + float3 worldPq = outputImage[pix].rgb; + if (alpha > 0.0) { + float3 worldNits2020 = float3(pqDecode(worldPq.x), pqDecode(worldPq.y), pqDecode(worldPq.z)); + float3 straight = clamp(ui.rgb / alpha, 0.0, 1.0); + float3 uiNits709 = srgbToLinear(straight) * pc.uiNits; + float3 uiNits2020 = mul(BT709_TO_BT2020, uiNits709); + float3 blended = worldNits2020 * (1.0 - alpha) + uiNits2020 * alpha; + worldPq = float3(pqEncode(blended.x), pqEncode(blended.y), pqEncode(blended.z)); + } + outputImage[pix] = float4(worldPq, 1.0); +} diff --git a/shaders/pipelines/name_tag/bindings.slang b/shaders/pipelines/name_tag/bindings.slang new file mode 100644 index 00000000..dda08590 --- /dev/null +++ b/shaders/pipelines/name_tag/bindings.slang @@ -0,0 +1 @@ +[[vk::binding(0, 0)]] public Sampler2D fontAtlas; diff --git a/shaders/pipelines/name_tag/fragment.frag.slang b/shaders/pipelines/name_tag/fragment.frag.slang new file mode 100644 index 00000000..1fb01d98 --- /dev/null +++ b/shaders/pipelines/name_tag/fragment.frag.slang @@ -0,0 +1,12 @@ +import bindings; + +struct FragmentInput { + [[vk::location(0)]] float2 uv; + [[vk::location(1)]] float4 color; +}; + +[shader("fragment")] +float4 main(FragmentInput input) : SV_Target0 { + float alpha = fontAtlas.Sample(input.uv).a; + return float4(input.color.rgb, input.color.a * alpha); +} diff --git a/shaders/pipelines/name_tag/vertex.vert.slang b/shaders/pipelines/name_tag/vertex.vert.slang new file mode 100644 index 00000000..2433d3b7 --- /dev/null +++ b/shaders/pipelines/name_tag/vertex.vert.slang @@ -0,0 +1,24 @@ +import overlay_common; + +[[vk::push_constant]] NameTagPush pc; + +struct VertexInput { + [[vk::location(0)]] float3 position; + [[vk::location(1)]] float2 uv; + [[vk::location(2)]] float4 color; +}; + +struct VertexOutput { + float4 position : SV_Position; + [[vk::location(0)]] float2 uv; + [[vk::location(1)]] float4 color; +}; + +[shader("vertex")] +VertexOutput main(VertexInput input) { + VertexOutput output; + output.position = mul(pc.curViewProj, float4(input.position - pc.camOffset, 1.0)); + output.uv = input.uv; + output.color = input.color; + return output; +} diff --git a/shaders/pipelines/overlay_composite/bindings.slang b/shaders/pipelines/overlay_composite/bindings.slang new file mode 100644 index 00000000..a48a3911 --- /dev/null +++ b/shaders/pipelines/overlay_composite/bindings.slang @@ -0,0 +1 @@ +[[vk::binding(0, 0)]] public Texture2D sourceImage; diff --git a/shaders/pipelines/overlay_composite/glow.frag.slang b/shaders/pipelines/overlay_composite/glow.frag.slang new file mode 100644 index 00000000..aa70a0cb --- /dev/null +++ b/shaders/pipelines/overlay_composite/glow.frag.slang @@ -0,0 +1,46 @@ +import bindings; + +static const float EDGE_THRESHOLD = 0.02; + +float coverage(int2 pixel, int2 size) { + return sourceImage[clamp(pixel, int2(0), size - int2(1))].a; +} + +[shader("fragment")] +float4 main(float4 position : SV_Position) : SV_Target0 { + int2 pixel = int2(position.xy); + uint width, height; + sourceImage.GetDimensions(width, height); + int2 size = int2(width, height); + + if (coverage(pixel, size) > 0.5) return float4(0.0); + + float topLeft = coverage(pixel + int2(-1, -1), size); + float top = coverage(pixel + int2(0, -1), size); + float topRight = coverage(pixel + int2(1, -1), size); + float left = coverage(pixel + int2(-1, 0), size); + float right = coverage(pixel + int2(1, 0), size); + float bottomLeft = coverage(pixel + int2(-1, 1), size); + float bottom = coverage(pixel + int2(0, 1), size); + float bottomRight = coverage(pixel + int2(1, 1), size); + + float gx = -topLeft - 2.0 * left - bottomLeft + topRight + 2.0 * right + bottomRight; + float gy = -topLeft - 2.0 * top - topRight + bottomLeft + 2.0 * bottom + bottomRight; + float edge = clamp(length(float2(gx, gy)), 0.0, 1.0); + if (edge <= EDGE_THRESHOLD) return float4(0.0); + + float3 color = float3(0.0); + float count = 0.0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + int2 samplePixel = clamp(pixel + int2(dx, dy), int2(0), size - int2(1)); + float4 sampleValue = sourceImage[samplePixel]; + if (sampleValue.a > 0.5) { + color += sampleValue.rgb; + count += 1.0; + } + } + } + if (count <= 0.0) return float4(0.0); + return float4(color / count, edge); +} diff --git a/shaders/pipelines/overlay_composite/passthrough.frag.slang b/shaders/pipelines/overlay_composite/passthrough.frag.slang new file mode 100644 index 00000000..8cd7c093 --- /dev/null +++ b/shaders/pipelines/overlay_composite/passthrough.frag.slang @@ -0,0 +1,6 @@ +import bindings; + +[shader("fragment")] +float4 main(float4 position : SV_Position) : SV_Target0 { + return sourceImage[int2(position.xy)]; +} diff --git a/shaders/pipelines/overlay_composite/vertex.vert.slang b/shaders/pipelines/overlay_composite/vertex.vert.slang new file mode 100644 index 00000000..e5b12126 --- /dev/null +++ b/shaders/pipelines/overlay_composite/vertex.vert.slang @@ -0,0 +1,5 @@ +[shader("vertex")] +float4 main(uint vertexIndex : SV_VertexID) : SV_Position { + float2 position = float2((vertexIndex << 1u) & 2u, vertexIndex & 2u); + return float4(position * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/shaders/pipelines/sdr_present/bindings.slang b/shaders/pipelines/sdr_present/bindings.slang new file mode 100644 index 00000000..d742b6c1 --- /dev/null +++ b/shaders/pipelines/sdr_present/bindings.slang @@ -0,0 +1,2 @@ +[[vk::binding(0, 0)]] [format("rgba16f")] public RWTexture2D outputImage; +[[vk::binding(1, 0)]] public Sampler2D sourceImage; diff --git a/shaders/pipelines/sdr_present/main.comp.slang b/shaders/pipelines/sdr_present/main.comp.slang new file mode 100644 index 00000000..0fc2d31b --- /dev/null +++ b/shaders/pipelines/sdr_present/main.comp.slang @@ -0,0 +1,48 @@ +// Converts Minecraft's complete sRGB/BT.709 SDR frame to a PQ/BT.2020 image at paper white. +import bindings; + +struct PresentPush { + float uiNits; +}; +[[vk::push_constant]] PresentPush pc; + +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); +} + +float srgbChannelToLinear(float c) { + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); +} + +float3 srgbToLinear(float3 c) { + return float3(srgbChannelToLinear(c.x), srgbChannelToLinear(c.y), srgbChannelToLinear(c.z)); +} + +// Row-major mathematical notation; the build targets column-major storage, while mul(M, v) preserves +// the intended BT.709 -> BT.2020 transform. +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 +); + +[shader("compute")] +[numthreads(16, 16, 1)] +void main(uint3 tid : SV_DispatchThreadID) { + uint width, height; + outputImage.GetDimensions(width, height); + if (tid.x >= width || tid.y >= height) return; + + int2 pix = int2(tid.xy); + float3 nits709 = srgbToLinear(sourceImage.Load(int3(pix, 0)).rgb) * pc.uiNits; + float3 nits2020 = mul(BT709_TO_BT2020, nits709); + float3 pq = float3(pqEncode(nits2020.x), pqEncode(nits2020.y), pqEncode(nits2020.z)); + outputImage[pix] = float4(pq, 1.0); +} diff --git a/shaders/pipelines/sky_lut/bindings.slang b/shaders/pipelines/sky_lut/bindings.slang new file mode 100644 index 00000000..5bb39cd1 --- /dev/null +++ b/shaders/pipelines/sky_lut/bindings.slang @@ -0,0 +1,5 @@ +[[vk::binding(0, 0)]] [format("rgba16f")] public RWTexture2D transmittanceImage; +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D multiScatterImage; +[[vk::binding(2, 0)]] [format("rgba16f")] public RWTexture2D skyViewImage; +[[vk::binding(3, 0)]] public Sampler2D transmittanceLut; +[[vk::binding(4, 0)]] public Sampler2D multiScatterLut; diff --git a/shaders/pipelines/sky_lut/multiscatter.comp.slang b/shaders/pipelines/sky_lut/multiscatter.comp.slang new file mode 100644 index 00000000..6464a409 --- /dev/null +++ b/shaders/pipelines/sky_lut/multiscatter.comp.slang @@ -0,0 +1,71 @@ +// Multiple-scattering LUT bake (Hillaire 2020, §5.2). For each (altitude, cos sun zenith) texel, fire 64 +// directions over the sphere, raymarch each one for SECOND-order scattered luminance with an isotropic +// phase, and store the geometric series that closes the remaining orders: +// +// psi_ms = L_2nd / (1 - f_ms) +// +// where f_ms is the fraction of light that gets scattered once more before leaving. Storing the closed +// series rather than one extra order is what makes twilight and the night sky come out at the right level +// without an authored fill term — high orders dominate exactly when the sun is below the horizon, which +// is the case the previous single-scattering sky could not represent at all. +// +// Static (per look package: only the ground albedo enters from outside), so this runs once behind the +// transmittance LUT and is never rebuilt per frame. The 64x20 inner work is why: ~1.3M medium samples for +// the whole 32x32 table, trivial once and far too much per frame. +import world_common; +import sky; +import bindings; + +[[vk::push_constant]] PushAddr pcAddr; + +static const int SQRT_DIRS = 8; // SQRT_DIRS^2 == MULTISCATTER_DIRS + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 tid : SV_DispatchThreadID) { + if (tid.x >= uint(MULTISCATTER_LUT_W) || tid.y >= uint(MULTISCATTER_LUT_H)) { + return; + } + WorldPush push = ConstPtr(pcAddr.worldPushAddr)[0]; + float groundAlbedo = push.skyLook3.x; + + float2 uv = float2(float(tid.x), float(tid.y)) + / float2(float(MULTISCATTER_LUT_W - 1), float(MULTISCATTER_LUT_H - 1)); + float radiusKm; + float cosSunZenith; + multiScatterLutParams(uv, radiusKm, cosSunZenith); + + float3 origin = float3(0.0, radiusKm, 0.0); + float3 sunDir = float3(sqrt(max(1.0 - cosSunZenith * cosSunZenith, 0.0)), cosSunZenith, 0.0); + + float3 luminanceSum = float3(0.0); + float3 multiScatSum = float3(0.0); + for (int d = 0; d < MULTISCATTER_DIRS; d++) { + // Uniform sphere directions on a regular 8x8 grid of the (azimuth, cos polar) square. A fixed + // pattern, not a random one: the LUT is baked once and any stochastic noise in it would be frozen + // into every frame that samples it. + float randA = (float(d / SQRT_DIRS) + 0.5) / float(SQRT_DIRS); + float randB = (float(d % SQRT_DIRS) + 0.5) / float(SQRT_DIRS); + float theta = 2.0 * SKY_PI * randA; + float phi = acos(clamp(1.0 - 2.0 * randB, -1.0, 1.0)); + float sinPhi = sin(phi); + float3 dir = float3(cos(theta) * sinPhi, cos(phi), sin(theta) * sinPhi); + + // Unit illuminance, isotropic phase, no multiple-scattering lookup: this IS the term that lookup + // will later provide, so reading it here would be circular. + ScatteringResult r = integrateScatteredLuminance( + origin, dir, sunDir, float3(1.0), 0.0, + MULTISCATTER_STEPS, false, false, + transmittanceLut, multiScatterLut, groundAlbedo, true); + luminanceSum += r.luminance; + multiScatSum += r.multiScatAs1; + } + float inverse = 1.0 / float(MULTISCATTER_DIRS); + float3 secondOrder = luminanceSum * inverse; + float3 scatteredFraction = multiScatSum * inverse; + // Sum of the infinite series of further scattering events. The fraction is physically below 1 (some + // light is always absorbed or escapes), but clamp anyway: an fp32 round-trip landing on exactly 1 + // would divide by zero and poison the whole LUT with infinities. + float3 series = 1.0 / max(1.0 - scatteredFraction, float3(1.0e-4)); + multiScatterImage[tid.xy] = float4(secondOrder * series, 1.0); +} diff --git a/shaders/pipelines/sky_lut/transmittance.comp.slang b/shaders/pipelines/sky_lut/transmittance.comp.slang new file mode 100644 index 00000000..0ede010e --- /dev/null +++ b/shaders/pipelines/sky_lut/transmittance.comp.slang @@ -0,0 +1,42 @@ +// Transmittance LUT bake (Hillaire 2020, §5.1 / Bruneton 2008). For each (altitude, cos zenith) texel, +// integrate the optical depth from that point to the top of the atmosphere and store exp(-depth). +// +// Static: the medium never changes, so this runs once and every later pass reads it. Deliberately marched +// WITHOUT a ground test — a direction pointing below the horizon then accumulates the optical depth of a +// full chord through sea-level air, which comes out as transmittance zero. That is exactly the answer +// wanted for "the sun has set", so downstream passes need no separate planet-occlusion branch. +import world_common; +import sky; +import bindings; + +[[vk::push_constant]] PushAddr pcAddr; + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 tid : SV_DispatchThreadID) { + if (tid.x >= uint(TRANSMITTANCE_LUT_W) || tid.y >= uint(TRANSMITTANCE_LUT_H)) { + return; + } + // The parameterisation is defined on the closed [0,1] range, so the outermost texels must land on 0 + // and 1 exactly — a plain (i+0.5)/N would shrink the LUT's domain and bias every sample taken from it. + float2 uv = float2(float(tid.x), float(tid.y)) + / float2(float(TRANSMITTANCE_LUT_W - 1), float(TRANSMITTANCE_LUT_H - 1)); + float radiusKm; + float cosZenith; + transmittanceLutParams(uv, radiusKm, cosZenith); + + float3 origin = float3(0.0, radiusKm, 0.0); + float3 dir = float3(sqrt(max(1.0 - cosZenith * cosZenith, 0.0)), cosZenith, 0.0); + float tMax = raySphereFarthest(origin, dir, ATMOS_TOP_KM); + + float3 opticalDepth = float3(0.0); + if (tMax > 0.0) { + float dt = tMax / float(TRANSMITTANCE_STEPS); + for (int i = 0; i < TRANSMITTANCE_STEPS; i++) { + float3 p = origin + dir * ((float(i) + 0.5) * dt); + MediumSample medium = sampleMedium(length(p) - ATMOS_BOTTOM_KM); + opticalDepth += medium.extinction * dt; + } + } + transmittanceImage[tid.xy] = float4(exp(-opticalDepth), 1.0); +} diff --git a/shaders/pipelines/sky_lut/view.comp.slang b/shaders/pipelines/sky_lut/view.comp.slang new file mode 100644 index 00000000..24268ae9 --- /dev/null +++ b/shaders/pipelines/sky_lut/view.comp.slang @@ -0,0 +1,88 @@ +// Sky-view LUT bake, once per frame. Renders the whole sky dome — atmosphere in-scatter with real +// multiple scattering — into a 192x108 latlong-ish table per celestial body, in the light-relative +// parameterisation from sky.slang. world.rmiss answers every miss with two texture fetches, avoiding a +// repeated atmosphere march on primary and GI-bounce rays. +// +// One slice per body (sun rows first, then moon), each marched with its own direction and illuminance. +// Sampling adds the two physical lighting solutions without crossfading between them. +// +// Reads the same WorldPush BDA slot the trace does, so the LUT and the frame it shades can never disagree +// about where the sun is. +import world_common; +import sky; +import bindings; + +[[vk::push_constant]] PushAddr pcAddr; + +[shader("compute")] +[numthreads(8, 8, 1)] +void main(uint3 tid : SV_DispatchThreadID) { + if (tid.x >= uint(SKY_VIEW_LUT_W) || tid.y >= uint(SKY_VIEW_LUT_TOTAL_H)) { + return; + } + WorldPush push = ConstPtr(pcAddr.worldPushAddr)[0]; + SkyState sky = skyState(push); + + int body = int(tid.y) / SKY_VIEW_LUT_H; + int row = int(tid.y) - body * SKY_VIEW_LUT_H; + bool isSun = body == SKY_VIEW_BODY_SUN; + float3 worldLightDir = isSun ? sky.sunDir : sky.moonDir; + float3 illuminance = float3(isSun ? sky.sunIlluminance : sky.moonIlluminance); + float angularRadius = isSun ? sky.sunAngularRadius : sky.moonAngularRadius; + + // Sampling applies the half-texel correction; the bake must invert exactly that, so it works in the + // same sub-UV space rather than in the parameterisation's raw [0,1]. + float2 uv = (float2(float(tid.x), float(row)) + 0.5) + / float2(float(SKY_VIEW_LUT_W), float(SKY_VIEW_LUT_H)); + float viewZenithCos; + float lightViewCos; + skyViewLutParams(uv, sky.viewerRadiusKm, viewZenithCos, lightViewCos); + + float3 viewDir; + float3 lightDir; + skyViewLocalDirections(worldLightDir.y, viewZenithCos, lightViewCos, viewDir, lightDir); + + float3 origin = float3(0.0, sky.viewerRadiusKm, 0.0); + float3 luminance = integrateScatteredLuminance( + origin, viewDir, lightDir, illuminance, angularRadius, + SKY_VIEW_STEPS, true, true, + transmittanceLut, multiScatterLut, sky.groundAlbedo, true).luminance; + + // Horizon softening. The planet's surface is a real discontinuity in the model: a ray one arcminute + // above the tangent direction escapes the atmosphere, and one arcminute below it terminates on the + // ground a few km away. Measured at 370 m altitude under a 45 degree sun, that step is 9,977 to + // 3,155 cd/m² — 1.7 EV across a fraction of a degree — and everything below it is a nearly constant + // 2,400 cd/m² Lambertian plate. On screen that is a hard grey line across the distant view. + // + // Minecraft makes the discontinuity worse than it is on Earth. This ground is fictional: it is not the + // world's terrain, which is what the player would actually see there. Beyond render distance the rays + // simply escape, so the plate is drawn where a real view would show haze. And since the camera's + // altitude tracks world Y, so a player at sea level sits within metres of the shell, where the ground + // is reached almost immediately below the horizon and the in-scatter that would otherwise soften it + // never accumulates. + // + // So fade the ground in over `horizonSoften` degrees of dip instead of switching to it. The value the + // fade starts from is the ground-tangent direction marched with NO ground clip, which is exactly the + // limit the rows above the horizon converge to, making both sides meet at the same value. + float horizonCos = horizonZenithCos(sky.viewerRadiusKm); + float dip = acos(clamp(viewZenithCos, -1.0, 1.0)) - acos(clamp(horizonCos, -1.0, 1.0)); + if (dip > 0.0 && sky.horizonSoften > 0.0) { + float3 tangentDir; + float3 tangentLight; + skyViewLocalDirections(worldLightDir.y, horizonCos, lightViewCos, tangentDir, tangentLight); + float3 skyward = integrateScatteredLuminance( + origin, tangentDir, tangentLight, illuminance, angularRadius, + SKY_VIEW_STEPS, true, true, + transmittanceLut, multiScatterLut, sky.groundAlbedo, false).luminance; + // Interpolate in LOG luminance, not linearly. The two ends can be 5 EV apart at a low sun, and a + // linear blend spends most of its angular range near the bright end and then falls off a cliff in + // the last few degrees — trading one hard edge for a slightly softer one. In log space the same + // smoothstep spreads the drop evenly in stops, which is both what the eye integrates and what + // attenuation through haze actually does (extinction is multiplicative, so it is linear in log). + float3 low = log2(max(skyward, float3(1.0e-6))); + float3 high = log2(max(luminance, float3(1.0e-6))); + luminance = exp2(lerp(low, high, smoothstep(0.0, sky.horizonSoften, dip))); + } + + skyViewImage[tid.xy] = float4(max(luminance, float3(0.0)), 1.0); +} diff --git a/shaders/world/world.rahit.slang b/shaders/pipelines/world/any_hit.rahit.slang similarity index 88% rename from shaders/world/world.rahit.slang rename to shaders/pipelines/world/any_hit.rahit.slang index 09bb6522..67762c73 100644 --- a/shaders/world/world.rahit.slang +++ b/shaders/pipelines/world/any_hit.rahit.slang @@ -13,11 +13,7 @@ // crossing; radiance cutout paths do not touch either field. import world_common; import math; - -[[vk::push_constant]] WorldPushConstants pc; - -[[vk::binding(2, 0)]] Sampler2D blockAlbedoAtlas; -[[vk::binding(0, 1)]] Sampler2D entityAlbedoTex[]; +import bindings; static const float ALPHA_CUTOFF = 0.5; // terrain (matches the cutout block models) static const float ENTITY_ALPHA_CUTOFF = 0.1; // entities: only discard near-fully-transparent texels @@ -88,14 +84,17 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // model here so entity dielectrics transmit instead of becoming opaque shadow blockers. bool shadowRay = (RayFlags() & RAY_FLAG_SKIP_CLOSEST_HIT_SHADER) != 0u; if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_DIELECTRIC) { - float3 tint = lerp(float3(1.0, 1.0, 1.0), - srgbToLinear(texel.rgb) * epr.tint.rgb, texel.a); - payload.albedo *= half3(tint * clamp(materialHeader.params.w, 0.0, 1.0)); + float3 tint709 = lerp(float3(1.0), + srgbToLinear(texel.rgb) * srgbToLinear(epr.tint.rgb), texel.a); + float3 tint = bt709ToAcesCg(tint709); + packAlbedo(payload, unpackAlbedo(payload) * tint * clamp(materialHeader.params.w, 0.0, 1.0)); IgnoreHit(); } if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_WATER) { - float3 tint = srgbToLinear(texel.rgb) * epr.tint.rgb; - payload.albedo *= half3(lerp(float3(1.0, 1.0, 1.0), tint, WATER_SHADOW_TINT) + float3 tint = bt709ToAcesCg( + srgbToLinear(texel.rgb) * srgbToLinear(epr.tint.rgb)); + packAlbedo(payload, unpackAlbedo(payload) + * lerp(float3(1.0, 1.0, 1.0), tint, WATER_SHADOW_TINT) * clamp(materialHeader.params.w, 0.0, 1.0)); payload.hitT = payload.hitT < 0.0 ? RayTCurrent() : min(payload.hitT, RayTCurrent()); IgnoreHit(); @@ -111,7 +110,8 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // the biome water color, then keep walking so submerged terrain is lit by colored transmission. if (bucket == BUCKET_WATER) { TerrainPrim pr = ConstPtr(sec.primAddr)[tri]; - payload.albedo *= half3(lerp(float3(1.0, 1.0, 1.0), pr.tint.rgb, WATER_SHADOW_TINT)); + float3 tint = bt709ToAcesCg(srgbToLinear(pr.tint.rgb)); + packAlbedo(payload, unpackAlbedo(payload) * lerp(float3(1.0), tint, WATER_SHADOW_TINT)); // Record the NEAREST water crossing (any-hit order is arbitrary) in the shadow payload's hitT // lane. For an underwater shading point this is the exit point of its sun shadow ray, where // world.rgen evaluates the wave-refraction caustic. visibility() seeds the -1 sentinel. @@ -127,17 +127,18 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Modeled as Beer-Lambert absorption (matching the water medium in world.rgen): a per-channel // extinction derived from how dark the average is, scaled by the average alpha (how much of the // sprite is glass-colorant vs. see-through frame), so saturated panes darken transmitted light - // non-linearly. Multiplying into payload.albedo compounds correctly across stacked panes. + // non-linearly. Multiplying into the albedo view compounds correctly across stacked panes. if (bucket == BUCKET_TRANSLUCENT) { TerrainPrim pr = ConstPtr(sec.primAddr)[tri]; MaterialHeader materialHeader = ConstPtr(pc.materialTableAddr)[pr.materialId]; - float3 avgColor = max(materialHeader.average.rgb * pr.tint.rgb, - float3(1.0e-3, 1.0e-3, 1.0e-3)); + float3 avgColor = max(bt709ToAcesCg( + materialHeader.average.rgb * srgbToLinear(pr.tint.rgb)), float3(1.0e-3)); float3 colorExtinction = max(-log(avgColor), float3(0.0, 0.0, 0.0)); // The neutral floor is a flat per-hit dimming, NOT scaled by alpha: vanilla clear glass has a low // natural alpha, so folding it into the alpha-scaled term crushed it to near-zero for exactly the // white/clear-glass case it's meant to cover. - payload.albedo *= half3(exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION)); + packAlbedo(payload, unpackAlbedo(payload) + * exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION)); IgnoreHit(); } diff --git a/shaders/pipelines/world/bindings.slang b/shaders/pipelines/world/bindings.slang new file mode 100644 index 00000000..7a9ca062 --- /dev/null +++ b/shaders/pipelines/world/bindings.slang @@ -0,0 +1,26 @@ +// Complete descriptor and push-constant interface for the world ray-tracing pipeline. +// Every world stage imports this module instead of redeclaring its own subset. + +import world_common; + +[[vk::push_constant]] public WorldPushConstants pc; + +[[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; + +[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D gNormal; +[[vk::binding(4, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; +[[vk::binding(5, 0)]] [format("r32f")] public RWTexture2D gDepth; +[[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(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(0, 1)]] public Sampler2D entityAlbedoTex[]; +[[vk::binding(1, 1)]] public Sampler2D materialSurface0Tex[]; +[[vk::binding(2, 1)]] public Sampler2D materialNormalAoTex[]; +[[vk::binding(3, 1)]] public Sampler2D materialSurface1Tex[]; diff --git a/shaders/world/world.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang similarity index 88% rename from shaders/world/world.rchit.slang rename to shaders/pipelines/world/closest_hit.rchit.slang index d0bd4c18..bf21f885 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -8,15 +8,7 @@ // load we actually want. The cold WorldPush struct is dereferenced only for the breaking overlay. import world_common; import math; - -[[vk::push_constant]] WorldPushConstants pc; - -[[vk::binding(2, 0)]] Sampler2D blockAlbedoAtlas; -// Independent bindless arrays: per-draw entity albedo slots and compact canonical material-page bundles. -[[vk::binding(0, 1)]] Sampler2D entityAlbedoTex[]; -[[vk::binding(1, 1)]] Sampler2D materialSurface0Tex[]; -[[vk::binding(2, 1)]] Sampler2D materialNormalAoTex[]; -[[vk::binding(3, 1)]] Sampler2D materialSurface1Tex[]; +import bindings; void payloadSetPacked(inout Payload payload, uint material, float roughness, float metalness, float emission, float sss, float ior, float transmission, uint emissionSource) { @@ -159,14 +151,16 @@ Surface evaluateMaterial(MaterialHeader header, float2 atlasUv, float lod, float surface.metalness = surface0.g; surface.emission = surface0.b; surface.sss = allowSss ? surface0.a : 0.0; - surface.f0 = surface1.rgb; + // Canonical LabPBR F0 is authored in the asset's BT.709 RGB basis. Fresnel is evaluated in + // the transport basis, so coloured metal/custom F0 crosses the same boundary as albedo. + surface.f0 = clamp(bt709ToAcesCg(surface1.rgb), float3(0.0), float3(1.0)); } else { // Eligibility is compiled from block-state semantics; the primitive's exact light level // supplies strength, while the page supplies only the normalized spatial mask. surface.emission = surface0.b * fallbackEmission; } } - // Final HDR emission strength (EMISSIVE_STRENGTH baseline * any JSON multiplier), baked in Java at + // Final HDR emitting-surface luminance (look-package baseline or absolute JSON cd/m² override), baked in Java at // material-compile time and packed unconditionally — see MATERIAL_EMISSION_STRENGTH_SHIFT. 0 for // non-emissive materials, so this multiply is always safe (and a no-op) when nothing above ran. float strength = float((header.features >> MATERIAL_EMISSION_STRENGTH_SHIFT) @@ -187,11 +181,11 @@ Surface evaluateMaterial(MaterialHeader header, float2 atlasUv, float lod, float // whichever axis pair the normal is most aligned with (same idea as vanilla's // SheetedDecalTextureGenerator) and multiply-blends the matching destroy-stage crack texture in, // mirroring vanilla's crumbling blend (DST_COLOR*SRC_COLOR, doubled). -float3 applyBreaking(float3 albedo, uint rayCone, float3 rayOrigin, float3 rayDir, float hitT, float3 n) { +float3 applyBreaking(float3 albedo709, uint rayCone, float3 rayOrigin, float3 rayDir, float hitT, float3 n) { ConstPtr pcRef = ConstPtr(pc.worldPushAddr); uint breakCount = pcRef[0].breakCount; if (breakCount == 0u) { - return albedo; + return albedo709; } float3 hitPos = rayOrigin + rayDir * hitT; int3 blockPos = int3(floor(hitPos - n * 0.01)); @@ -204,11 +198,12 @@ float3 applyBreaking(float3 albedo, uint rayCone, float3 rayOrigin, float3 rayDi : (an.y >= an.z) ? local.xz : local.xy; float decalLod = rayConeUnitUvLod(rayCone, texSizePx(entityAlbedoTex[NonUniformResourceIndex(ps.w)])); - float3 crack = srgbToLinear(entityAlbedoTex[NonUniformResourceIndex(ps.w)].SampleLevel(decalUv, decalLod).rgb); - return clamp(crack * albedo, 0.0, 1.0); + float3 crack709 = srgbToLinear( + entityAlbedoTex[NonUniformResourceIndex(ps.w)].SampleLevel(decalUv, decalLod).rgb); + return clamp(crack709 * albedo709, 0.0, 1.0); } } - return albedo; + return albedo709; } [shader("closesthit")] @@ -242,8 +237,11 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) if (dot(pn, WorldRayDirection()) > 0.0) { pn = -pn; } - payload.albedo = half3(srgbToLinear(entityAlbedoTex[NonUniformResourceIndex(pslot)].SampleLevel(puv, particleLod).rgb) * pr.tint.rgb); - payload.normal = half3(pn); + float3 particleAlbedo709 = srgbToLinear( + entityAlbedoTex[NonUniformResourceIndex(pslot)].SampleLevel(puv, particleLod).rgb) + * srgbToLinear(pr.tint.rgb); + 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 @@ -297,16 +295,17 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float4 entityTexel = entityAlbedoTex[NonUniformResourceIndex(texSlot)].SampleLevel(euvCoord, entityLod); MaterialHeader header = ConstPtr(pc.materialTableAddr)[pr.materialId]; uint material = header.model; - float3 baseAlbedo = srgbToLinear(entityTexel.rgb) * pr.tint.rgb; - float3 albedo = material == MATERIAL_DIELECTRIC - ? lerp(float3(1.0, 1.0, 1.0), baseAlbedo, entityTexel.a) : baseAlbedo; + float3 baseAlbedo709 = srgbToLinear(entityTexel.rgb) * srgbToLinear(pr.tint.rgb); + float3 opticalColor709 = material == MATERIAL_DIELECTRIC + ? lerp(float3(1.0), baseAlbedo709, entityTexel.a) : baseAlbedo709; + float3 albedo = bt709ToAcesCg(opticalColor709); Surface surface = evaluateMaterial(header, euvCoord, entityLod, albedo, n, ep0, ep1, ep2, euv[e0], euv[e1], euv[e2], vdir, header.params.x, header.params.y, pr.normal.w, material == MATERIAL_OPAQUE); n = surface.normal; - payload.albedo = half3(albedo * surface.ao); - payload.normal = half3(n); + packAlbedo(payload, albedo * surface.ao); + packNormal(payload, n); payload.hitT = RayTCurrent(); // Per-vertex motion vector: interpolate the captured per-vertex displacement with the same // indices/barycentrics used for the UV above. Rotation and skeletal/lid animation use a @@ -342,14 +341,13 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // UNFLIPPED geometric normal is a reliable "which side of the dielectric" signal: a ray travelling // against it is entering the volume, one travelling with it is exiting. Captured before the // toward-viewer flip below and carried in the payload so raygen derives the medium from hit - // orientation instead of toggling parity per crossing (a single missing/extra face no longer - // desyncs the rest of the path). + // orientation instead of toggling parity per crossing, so one malformed face cannot desync the path. bool entering = dot(WorldRayDirection(), n) < 0.0; // Lever B: per-triangle corner UVs in primitive order. uvs[3*pid + k] is a contiguous, // directly-addressed load (no index buffer, no scattered vertex-UV gather), so it issues as soon as // pid is known and its latency overlaps the prim fetch instead of serialising behind an index load. - // The index buffer still exists for the BLAS build; the shading path just no longer reads it. + // The index buffer remains BLAS-only; shading reads the expanded UV buffer directly. ConstPtr uvs = ConstPtr(sec.uvAddr); float2 uv0 = uvs[3u * pid + 0u]; float2 uv1 = uvs[3u * pid + 1u]; @@ -375,17 +373,20 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // so a more opaque texel tints transmitted light more strongly. if (materialHeader.model == MATERIAL_DIELECTRIC) { float4 gtex = blockAlbedoAtlas.SampleLevel(uv, blockLod); - float3 gtexRgb = srgbToLinear(gtex.rgb); - float3 glassAlbedo = lerp(float3(1.0, 1.0, 1.0), gtexRgb * tint, gtex.a); + float3 gtex709 = srgbToLinear(gtex.rgb); + float3 tint709 = srgbToLinear(tint); + float3 opticalColor709 = lerp(float3(1.0), gtex709 * tint709, gtex.a); + float3 glassAlbedo = bt709ToAcesCg(opticalColor709); Surface glassSurface = evaluateMaterial(materialHeader, uv, blockLod, glassAlbedo, n, tp0, tp1, tp2, uv0, uv1, uv2, vdir, materialHeader.params.x, materialHeader.params.y, 0.0, false); n = glassSurface.normal; // Translucent blocks (glass, ice, …) are breakable too — apply the same overlay here, reusing // gtex (already sampled above) rather than re-fetching blockAlbedoAtlas. - payload.albedo = half3(applyBreaking(glassAlbedo * glassSurface.ao, - rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n)); - payload.normal = half3(n); + packAlbedo(payload, bt709ToAcesCg(applyBreaking( + opticalColor709 * glassSurface.ao, rayCone, WorldRayOrigin(), + WorldRayDirection(), RayTCurrent(), n))); + packNormal(payload, n); payload.hitT = RayTCurrent(); payload.motionPrev = half3(0.0h, 0.0h, 0.0h); payload.f0 = half3(glassSurface.f0); @@ -399,8 +400,10 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Water (tint.w == 1) carries the pure biome water tint (no grey water-texture multiply): raygen // shades the surface as a clear dielectric and only needs the tint to derive the per-channel // Beer–Lambert absorption. Opaque terrain uses textured albedo. - payload.albedo = half3(materialHeader.model == MATERIAL_WATER - ? tint : srgbToLinear(blockAlbedoAtlas.SampleLevel(uv, blockLod).rgb) * tint); + float3 tint709 = srgbToLinear(tint); + float3 albedo709 = srgbToLinear(blockAlbedoAtlas.SampleLevel(uv, blockLod).rgb) * tint709; + packAlbedo(payload, bt709ToAcesCg( + materialHeader.model == MATERIAL_WATER ? tint709 : albedo709)); payload.hitT = RayTCurrent(); payload.motionPrev = half3(0.0h, 0.0h, 0.0h); // static terrain: camera-only motion vector uint material = materialHeader.model == MATERIAL_WATER ? MATERIAL_WATER : MATERIAL_OPAQUE; @@ -410,17 +413,19 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float ew = pr.normal.w; bool nonSolid = ew >= 1.5; float emission = nonSolid ? ew - 2.0 : ew; // heuristic emission source (block light level) - Surface surface = evaluateMaterial(materialHeader, uv, blockLod, float3(payload.albedo), n, + Surface surface = evaluateMaterial(materialHeader, uv, blockLod, unpackAlbedo(payload), n, tp0, tp1, tp2, uv0, uv1, uv2, vdir, materialHeader.params.x, materialHeader.params.y, emission, nonSolid); n = surface.normal; - payload.albedo *= half(surface.ao); + packAlbedo(payload, unpackAlbedo(payload) * surface.ao); // Block-breaking overlay: opaque/cutout terrain (water skips — fluids aren't breakable). Evaluate - // it after normal mapping so decal projection uses the same shading orientation as the old path. + // it after normal mapping so decal projection uses the final shading orientation. if (material != MATERIAL_WATER) { - payload.albedo = half3(applyBreaking(float3(payload.albedo), rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n)); + packAlbedo(payload, bt709ToAcesCg(applyBreaking( + albedo709 * surface.ao, rayCone, WorldRayOrigin(), + WorldRayDirection(), RayTCurrent(), n))); } - payload.normal = half3(n); + packNormal(payload, n); payload.f0 = half3(surface.f0); payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, diff --git a/shaders/world/world_guide.rmiss.slang b/shaders/pipelines/world/guide.rmiss.slang similarity index 75% rename from shaders/world/world_guide.rmiss.slang rename to shaders/pipelines/world/guide.rmiss.slang index 39f71c62..25b18883 100644 --- a/shaders/world/world_guide.rmiss.slang +++ b/shaders/pipelines/world/guide.rmiss.slang @@ -6,7 +6,8 @@ import world_common; [shader("miss")] void main(inout Payload payload) { // Preserve hitT: guide payloads already seed the -1 miss sentinel, while shadow any-hit may have - // recorded the nearest water crossing here for underwater caustics. - payload.normal = half3(0.0h, 0.0h, 0.0h); + // recorded the nearest water crossing here for underwater caustics. Preserve the surface words too: + // this record serves shadow rays, whose accumulated transmittance lives in their albedo view. + packNormal(payload, float3(0.0, 0.0, 0.0)); payload.flags = 0u; } diff --git a/shaders/world/guides.slang b/shaders/pipelines/world/guides.slang similarity index 90% rename from shaders/world/guides.slang rename to shaders/pipelines/world/guides.slang index ced82482..7f135197 100644 --- a/shaders/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -7,6 +7,7 @@ import world_common; import world_core; +import bindings; import math; import medium; import water; @@ -14,6 +15,10 @@ import trace; public static float3 gv_normal = float3(0.0, 0.0, 0.0); public static float3 gv_albedo = float3(0.0, 0.0, 0.0); +// The diffuse-albedo guide's alpha lane is not consumed by DLSS-RR. Preserve whether the visible +// guide endpoint emits so downstream exposure metering can cap large lava/glowstone populations +// without another material-ID image. +public static bool gv_emissive = false; public static float gv_rough = 0.0; 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) @@ -119,9 +124,10 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, && (reflectedMaterial == MATERIAL_OPAQUE || reflectedMaterial == MATERIAL_PARTICLE) && payloadEmission() <= 0.0) { + float3 reflectedAlbedo = payloadAlbedo(); float3 reflectedDiffuse = reflectedMaterial == MATERIAL_PARTICLE - ? float3(payload.albedo) - : float3(payload.albedo) * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + ? reflectedAlbedo + : reflectedAlbedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); if (max(reflectedDiffuse.r, max(reflectedDiffuse.g, reflectedDiffuse.b)) > 0.001) { specAlbedo = surface.albedo * reflectedDiffuse; } @@ -140,13 +146,14 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, } public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, - float roughness, float3 diffuseAlbedo) { + float roughness, float3 diffuseAlbedo, bool emissive) { gv_hitCamRel = hitCamRel; gv_motionObjDisp = motionPrev; gv_motionUseRefracted = true; gv_normal = normal; gv_rough = roughness; gv_albedo = diffuseAlbedo; + gv_emissive = emissive; } // Deterministic ordinary guide behind the first transmitted interface. This is guide-only work: @@ -167,7 +174,7 @@ 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); + guideFilter * SKY_DIFF_ALBEDO, false); return; } @@ -176,18 +183,20 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { float endpointRoughness = material == MATERIAL_PARTICLE ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + float3 hitAlbedo = payloadAlbedo(); float3 endpointAlbedo = material == MATERIAL_PARTICLE - ? payload.albedo - : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + ? hitAlbedo + : hitAlbedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, - payload.normal, endpointRoughness, guideFilter * endpointAlbedo); + payloadNormal(), endpointRoughness, guideFilter * endpointAlbedo, + payloadEmission() > 0.0); return; } if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; bool isWater = material == MATERIAL_WATER; bool entering = payloadDielectricEntering(); - float3 geometricNormal = normalize(payload.normal); + float3 geometricNormal = normalize(payloadNormal()); float3 interfaceNormal = geometricNormal; if (isWater && (worldPush.flags & 16u) != 0u) { float waterFootprint = rayConeWidth @@ -199,18 +208,18 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float transmission = clamp(payloadTransmission(), 0.0, 1.0); Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payload.albedo, transmission); + payloadAlbedo(), transmission); float etaT = entering ? entered.ior : medium.outer.ior; float3 nextDirection = refract(direction, interfaceNormal, medium.current.ior / etaT); if (dot(nextDirection, nextDirection) <= 0.0) { // Never let a TIR reflection become ordinary diffuse/depth. setTransmissionGuide(interfacePos - worldPush.camOffset, isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), - interfaceNormal, 0.0, float3(0.0, 0.0, 0.0)); + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false); return; } if (!isWater && entering) { - guideFilter *= payload.albedo; + guideFilter *= payloadAlbedo(); } if (entering) { mediumPush(medium, entered); @@ -267,32 +276,9 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. gNormal[pix] = float4(gv_normal, gv_rough); - gAlbedo[pix] = float4(gv_albedo, 1.0); + gAlbedo[pix] = float4(gv_albedo, gv_emissive ? 1.0 : 0.0); gDepth[pix] = depth; gMotion[pix] = motion; gSpecAlbedo[pix] = float4(specAlbedo, 1.0); gSpecMotion[pix] = specMotion; } - -// 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining - -public void writeDebugView(int2 pix) { - float4 normalRough = gNormal[pix]; - float3 dbg; - if (pc.debugView == 1u) { - dbg = normalRough.xyz * 0.5 + 0.5; - } else if (pc.debugView == 2u) { - dbg = gAlbedo[pix].rgb; - } else if (pc.debugView == 3u) { - dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); - } else if (pc.debugView == 4u) { - dbg = float3(normalRough.w, normalRough.w, normalRough.w); - } else if (pc.debugView == 6u) { - dbg = gSpecAlbedo[pix].rgb; - } else if (pc.debugView == 7u) { - dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); - } else { - dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); - } - outImage[pix] = float4(dbg, 1.0); -} diff --git a/shaders/world/world.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang similarity index 88% rename from shaders/world/world.rgen.slang rename to shaders/pipelines/world/indirect.rgen.slang index eb7623e2..2df3dc6a 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -1,4 +1,4 @@ -// Indirect pass (pass B of the wavefront split — see docs/WAVEFRONT_PLAN.md). +// Indirect pass of the wavefront renderer. // // Resumes the continuations the primary pass queued and runs the bounce loop: NEE toward the sun, RIS // emitter lighting, thin-surface SSS, BSDF continuation and Russian roulette. It captures no guides and @@ -15,7 +15,7 @@ // // Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's // job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged -// image. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap happens at the display.comp seam. +// image. Output is scene-linear ACEScg HDR (R16G16B16A16_SFLOAT); display mapping happens afterward. // // The build emits an ordinary TraceRay fallback and an optional EXT SER variant from this source. import world_common; @@ -29,7 +29,11 @@ import trace; import trace_ser; #endif import lighting; +import sky; +import bindings; +// 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) { float3 L = float3(0.0, 0.0, 0.0); float3 ro = seg.ro; @@ -44,7 +48,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { int maxBounces = int(worldPush.maxBounces); int rrStart = maxBounces <= 3 ? 1 : 2; // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the - // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). + // 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. @@ -86,9 +90,17 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { #endif if (payload.hitT < 0.0) { - // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The + // A ray still in water should leave through a water interface. A miss instead means the + // streamed/open volume has no known exit; do not reinterpret that unknown region as air + // and reveal sky. + if (medium.current.water) { + break; + } + + // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into the payload's + // surface words as a full float3, so this is absolute scene units with nothing to undo. The // packed show-celestial flag decides whether this path segment may see the bright disc. - float3 sky = payload.albedo; + float3 sky = payloadSky(); L += throughput * sky; // escaped to sky break; } @@ -101,7 +113,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { throughput *= exp(-medium.current.extinction * payload.hitT); } - float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit + float3 n = payloadNormal(); // oriented toward the incoming ray by the closest-hit float3 hitPos = ro + rd * payload.hitT; rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); uint material = payloadMaterial(); @@ -125,7 +137,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } bool isWater = material == MATERIAL_WATER; bool entering = payloadDielectricEntering(); - float3 tint = payload.albedo; + float3 tint = payloadAlbedo(); float transmission = clamp(payloadTransmission(), 0.0, 1.0); // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken @@ -185,10 +197,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. if (material == MATERIAL_PARTICLE) { - float3 albedo = payload.albedo; + float3 albedo = payloadAlbedo(); - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; + float3 lightDir = celestialLight.dir; + float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { lightDir = sampleSquare(lightDir, lightHalfAngle, seed); } @@ -201,7 +213,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; + L += throughput * albedo * INV_PI * celestialLight.illuminance * ndl * vis; } } @@ -227,7 +239,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { continue; } - float3 albedo = payload.albedo; + float3 albedo = payloadAlbedo(); float sss = payloadSss(); // LabPBR SSS strength (0 when absent) float3 p = hitPos + n * SURF_BIAS; float3 v = -rd; // view direction (toward the camera / incoming ray) @@ -251,7 +263,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. - // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override + // Emission is already the full HDR luminance (look-package baseline or absolute cd/m² override // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). float emission = payloadEmission(); // One gate drives both the RIS call below and the direct-hit emission suppression here. They must @@ -264,11 +276,11 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular - // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae + // extent (celestialLight.halfAngle) so the shadow ray samples the light's quad — soft penumbrae // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular // half-vector. - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; + float3 lightDir = celestialLight.dir; + float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { lightDir = sampleSquare(lightDir, lightHalfAngle, seed); } @@ -292,7 +304,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking float3 F = fresnelSchlick(vdh, F0); brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular - L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; + L += throughput * brdf * celestialLight.illuminance * ndl * vis; } } @@ -327,7 +339,7 @@ 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); - L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; + L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * celestialLight.illuminance * visB; } } } @@ -388,9 +400,11 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { [shader("raygeneration")] void main() { worldPush = ConstPtr(pc.worldPushAddr)[0]; - if (pc.debugView != 0u) { - return; // pass A owns guide visualization output - } + // 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); uint2 dispatchIndex = DispatchRaysIndex().xy; uint2 dimensions = DispatchRaysDimensions().xy; int2 pix = int2(dispatchIndex); @@ -410,5 +424,13 @@ void main() { } recordIndex = packed.nextRecord; } - outImage[pix] = float4(frameRadiance / float(spp), 1.0); + // Pre-exposure is applied here and nowhere else: the entire path-traced result above is in absolute + // scene units, and only the fp16 store is normalized. + // + // outImage is rgba16f, so anything over 65504 would round to +inf and propagate as NaN + // through the denoiser. Pre-exposed, it only bites when a pixel would render >65504x mid-grey — the + // sun mid-transition, a firefly off a tiny emitter — and ACES 2.0 renders anything a few EV over + // 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); } diff --git a/shaders/world/lighting.slang b/shaders/pipelines/world/lighting.slang similarity index 99% rename from shaders/world/lighting.slang rename to shaders/pipelines/world/lighting.slang index 0789b4f8..4604e6bc 100644 --- a/shaders/world/lighting.slang +++ b/shaders/pipelines/world/lighting.slang @@ -5,6 +5,7 @@ import world_common; import world_core; +import bindings; import math; import trace; @@ -167,7 +168,7 @@ public float3 lightCrossUV(Light light) { return cross(lightHalfU(light), lightHalfV(light)); } -// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. +// The rect spans 2U x 2V, so its area is 4|U x V|. public float lightArea(Light light) { return 4.0 * length(lightCrossUV(light)); } diff --git a/shaders/world/math.slang b/shaders/pipelines/world/math.slang similarity index 90% rename from shaders/world/math.slang rename to shaders/pipelines/world/math.slang index f4fa270f..d5015581 100644 --- a/shaders/world/math.slang +++ b/shaders/pipelines/world/math.slang @@ -3,8 +3,9 @@ import world_common; import world_core; +import sky; -public float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } +public float luminance(float3 c) { return dot(c, ACESCG_LUMA); } // GGX normal distribution. // NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At @@ -135,14 +136,15 @@ 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 the -// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel -// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that -// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). +// 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) { - float3 right = normalize(cross(axis, worldPush.celestial.xyz)); - float3 up = cross(right, axis); + 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; @@ -174,10 +176,3 @@ public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space } - -public float3 srgbToLinear(float3 c) { - float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); - float3 isHi = step(float3(0.04045, 0.04045, 0.04045), c); - return lerp(lo, hi, isHi); -} diff --git a/shaders/world/medium.slang b/shaders/pipelines/world/medium.slang similarity index 93% rename from shaders/world/medium.slang rename to shaders/pipelines/world/medium.slang index 6f01f521..f682cfd3 100644 --- a/shaders/world/medium.slang +++ b/shaders/pipelines/world/medium.slang @@ -1,9 +1,9 @@ // Participating media: the tint-to-extinction mappings and the depth-2 medium stack the dielectric // interface pushes and pops. Depends on core only. -// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for -// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean -// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor +// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in the payload's albedo view +// for water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue +// ocean tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor // keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. import world_common; diff --git a/shaders/world/world_primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang similarity index 95% rename from shaders/world/world_primary.rgen.slang rename to shaders/pipelines/world/primary.rgen.slang index f55c74a1..48f990bc 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -1,4 +1,4 @@ -// Primary/guide pass (pass A of the wavefront split — see docs/WAVEFRONT_PLAN.md). +// Primary/guide pass of the wavefront renderer. // // Traces one camera radiance ray, captures DLSS-RR guides, and writes one or two resumable // continuations per pixel. At the first eligible dielectric it queues both post-interface branches and @@ -15,6 +15,7 @@ // latency-sensitive and does not amortize an SER reorder barrier. import world_common; import world_core; +import bindings; import math; import medium; import segment; @@ -49,6 +50,7 @@ public PathSegment tracePrimary(PathSegment seg, if (bounce == 0) { gv_normal = float3(0.0, 0.0, 0.0); gv_albedo = SKY_DIFF_ALBEDO; + gv_emissive = false; gv_rough = 1.0; gv_hitCamRel = rd * 1.0e6; gv_motionUseRefracted = false; @@ -58,7 +60,7 @@ public PathSegment tracePrimary(PathSegment seg, return terminal; } - float3 n = payload.normal; + float3 n = payloadNormal(); float3 hitPos = ro + rd * payload.hitT; uint material = payloadMaterial(); if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { @@ -68,15 +70,17 @@ public PathSegment tracePrimary(PathSegment seg, rough = exactSpecular ? 0.0 : rough; float metal = material == MATERIAL_PARTICLE ? 0.0 : clamp(payloadMetalness(), 0.0, 1.0); + float3 hitAlbedo = payloadAlbedo(); float3 diffAlb = material == MATERIAL_PARTICLE - ? float3(payload.albedo) : float3(payload.albedo) * (1.0 - metal); + ? hitAlbedo : hitAlbedo * (1.0 - metal); if (bounce == 0) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; + gv_emissive = payloadEmission() > 0.0; if (material == MATERIAL_PARTICLE) { - gv_albedo = payload.albedo; + gv_albedo = hitAlbedo; gv_rough = 1.0; gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, float3(0.0, 0.0, 0.0)); @@ -124,7 +128,7 @@ public PathSegment tracePrimary(PathSegment seg, float transmission = clamp(payloadTransmission(), 0.0, 1.0); Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payload.albedo, transmission); + payloadAlbedo(), transmission); float etaI = medium.current.ior; float etaT = entering ? entered.ior : medium.outer.ior; float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); @@ -135,6 +139,7 @@ public PathSegment tracePrimary(PathSegment seg, gv_normal = n; gv_rough = 0.0; gv_albedo = float3(0.0, 0.0, 0.0); + gv_emissive = false; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionUseRefracted = false; gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; @@ -151,7 +156,7 @@ public PathSegment tracePrimary(PathSegment seg, resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, guideMedium, transmitBias, rayConeWidth, rayConeSpread, !isWater && entering - ? float3(payload.albedo) : float3(1.0, 1.0, 1.0)); + ? payloadAlbedo() : float3(1.0, 1.0, 1.0)); } } @@ -244,7 +249,4 @@ void main() { queue[pixelIndex] = packPathSegment(terminal, nextRecord); writeGuides(pix, dir, jndc, size, rayConeSpread); - if (pc.debugView != 0u) { - writeDebugView(pix); - } } diff --git a/shaders/world/segment.slang b/shaders/pipelines/world/segment.slang similarity index 100% rename from shaders/world/segment.slang rename to shaders/pipelines/world/segment.slang diff --git a/shaders/world/shadow.rmiss.slang b/shaders/pipelines/world/shadow.rmiss.slang similarity index 100% rename from shaders/world/shadow.rmiss.slang rename to shaders/pipelines/world/shadow.rmiss.slang diff --git a/shaders/pipelines/world/sky.rmiss.slang b/shaders/pipelines/world/sky.rmiss.slang new file mode 100644 index 00000000..112bda7a --- /dev/null +++ b/shaders/pipelines/world/sky.rmiss.slang @@ -0,0 +1,168 @@ +// Ray miss = sky. Two sky-view LUT fetches (sun slice + moon slice) give the whole atmosphere; the disc +// sprites, the starfield and the night airglow floor are added on top. Everything physical lives in +// sky.slang, shared verbatim with the LUT bakes, so the sky a ray sees and the sky the LUT holds cannot +// diverge. +// +// Atmospheric scattering is amortized into the per-frame LUT. Analytic stars, airglow, and textured +// celestial discs are combined with that physical result here. +// +// Celestial discs are gated by a packed payload flag, which raygen sets per-ray from the path state: true +// on the primary ray and after every specular/dielectric bounce (so the sun/moon appear in water, glass +// and metal reflections/refractions), false after a diffuse vertex did sun/moon NEE — otherwise the disc +// would be counted twice against the direct light and show up as fireflies. The atmosphere and the stars +// are added on every miss at any bounce: stars are not a NEE light, so they never double-count. +import world_common; +import sky; +import bindings; + +// Vanilla celestials atlas (sun + moon-phase sprites), bound by RtComposite. Sampled with an explicit LOD +// (a miss shader has no derivatives). Keeping the real sprites means a resource pack's sun and moon still +// show up, which a procedural disc would silently drop. +// Drawn disc radiance is derived from illuminance and the solid angle the body is actually DRAWN at +// (L = E / Ω), deliberately not from its true surface luminance. Vanilla's sun quad subtends ~60x the real +// sun, i.e. ~3900x its solid angle; painting it at the physical 1.6e9 cd/m² would put ~3900x the sun's +// power into every path that sees it. Specular and dielectric bounces do see it and have already taken the +// sun through NEE, so that double-count would go from harmless to dominant, with fireflies off every +// glossy lobe. Deriving from illuminance keeps the drawn body's total power equal to the real one's at +// whatever size it is drawn, and costs nothing visually: the result still sits ~15 EV over an 18%-grey +// noon surface, which the ACES 2.0 output transform renders as pure white either way. +float3 discRadiance(float illuminance, float halfAngle) { + float side = 2.0 * tan(halfAngle); // the square spans this much on a side, gnomonically + return float3(illuminance / max(side * side, 1.0e-8)); +} + +// Dave Hoskins' hash13 (https://www.shadertoy.com/view/4djSRW): well-distributed and pattern-free. +float hash13(float3 p3) { + p3 = frac(p3 * 0.1031); + p3 += dot(p3, p3.zyx + 31.32); + return frac((p3.x + p3.y) * p3.z); +} + +// Rotate v about a unit axis by angle (Rodrigues). Used to wheel the starfield about the celestial pole. +float3 rotateAxis(float3 v, float3 axis, float ang) { + float c = cos(ang), s = sin(ang); + return v * c + cross(axis, v) * s + axis * dot(axis, v) * (1.0 - c); +} + +// Procedural starfield, a faithful port of vanilla's buildStars(): ~1500 discrete stars at random sphere +// directions, each a small SQUARE billboard of random size and rotation. A cube-map grid is the spatial +// accelerator — at most one star per cell, at a hashed position inset from the cell edges (so its square +// never crosses into a neighbour), drawn as a hash-rotated square of hashed size. The lookup direction is +// rotated about the celestial pole by -starAngle so the field wheels with world time like the real sky. +static const float STAR_CELLS = 21.0; // cube-face grid resolution; with the threshold gives ~1500 stars +static const float STAR_THRESHOLD = 0.86; // cell occupancy cutoff (higher = fewer stars) +static const float STAR_HALF = 0.045; // star half-size as a fraction of a cell (≈ vanilla 0.11° at centre) +float3 stars(float3 dir, SkyState state, float3 extinction) { + if (state.starLuminance <= 0.0 || dir.y <= 0.0) { + return float3(0.0); + } + // Negative angle: rotating the LOOKUP direction is the inverse of rotating star geometry, so + // -starAngle makes the field wheel the SAME way the sun and moon do. + float3 sdir = rotateAxis(dir, normalize(celestialAxis(state.noonTilt)), -state.starAngle); + // Cube-map face + 2D gnomonic uv → uniform square cells, no pole/equator distortion. + float3 a = abs(sdir); + float2 uv; + float face; + if (a.x >= a.y && a.x >= a.z) { uv = sdir.yz / a.x; face = sdir.x > 0.0 ? 0.0 : 1.0; } + else if (a.y >= a.z) { uv = sdir.xz / a.y; face = sdir.y > 0.0 ? 2.0 : 3.0; } + else { uv = sdir.xy / a.z; face = sdir.z > 0.0 ? 4.0 : 5.0; } + float2 g = uv * STAR_CELLS; + float3 id = float3(floor(g), face); + if (hash13(id) < STAR_THRESHOLD) { + return float3(0.0); // no star in this cell + } + float sz = STAR_HALF * (0.6 + 0.8 * hash13(id + 53.0)); + float inset = sz * 1.5; // keeps the rotated square clear of the cell edge + float2 center = inset + (1.0 - 2.0 * inset) * float2(hash13(id + 11.0), hash13(id + 23.0)); + float2 q = frac(g) - center; + float ang = hash13(id + 91.0) * 6.2831853; // random per-star rotation (vanilla zRot) + float cs = cos(ang), sn = sin(ang); + q = float2(cs * q.x - sn * q.y, sn * q.x + cs * q.y); // GLSL mat2(cs,-sn,sn,cs) * q, written out + float box = max(abs(q.x), abs(q.y)); // Chebyshev distance → square + float star = 1.0 - smoothstep(sz * 0.6, sz, box); // soft-edged square (helps DLSS stability) + if (star <= 0.0) { + return float3(0.0); + } + float bright = 0.55 + 0.45 * hash13(id + 71.0); // mild per-star brightness variation + // Per-star colour temperature: a hash picks a spot on a warm-orange to blue-white ramp, matching real + // star populations. Extinction reddens and dims them toward the horizon on the atmosphere's curve. + float3 starColor = lerp(float3(0.78, 0.60, 0.42), float3(0.50, 0.62, 0.95), hash13(id + 137.0)); + return star * bright * starColor * state.starLuminance * extinction; +} + +/** Atmosphere in-scatter for a world direction: both bodies' slices of this frame's sky-view LUT. */ +float3 skyDome(float3 dir, SkyState state) { + float2 sunUv = skyViewLutUv(state.viewerRadiusKm, dir.y, + lightViewCosine(dir, state.sunDir), SKY_VIEW_BODY_SUN); + float2 moonUv = skyViewLutUv(state.viewerRadiusKm, dir.y, + lightViewCosine(dir, state.moonDir), SKY_VIEW_BODY_MOON); + return skyViewLut.SampleLevel(sunUv, 0.0).rgb + skyViewLut.SampleLevel(moonUv, 0.0).rgb; +} + +[shader("miss")] +void main(inout Payload payload) { + WorldPush worldPush = ConstPtr(pc.worldPushAddr)[0]; + SkyState state = skyState(worldPush); + float3 dir = normalize(WorldRayDirection()); + bool showCelestial = (payload.flags & PAYLOAD_SHOW_CELESTIAL) != 0u; + + float3 color = skyDome(dir, state); + // Airglow plus integrated unresolved starlight: the real floor of a moonless night sky (~1e-3 cd/m²), + // isotropic because it is emitted by the upper atmosphere itself rather than scattered from a body. + // Multiple scattering is in the LUT, so this remains a flat, physically small constant. + color += state.airglowLuminance; + + // Extinction along the view ray: stars redden and dim into the horizon on exactly the curve the sky + // itself follows, because it is the same LUT that produced the sky. Zero angular radius — a star is a + // point, so it sets at the horizon line. + float3 extinction = transmittanceToSpace(transmittanceLut, state.viewerRadiusKm, dir, 0.0); + color += stars(dir, state, extinction); + + if (showCelestial) { + float2 local; + if (celestialSquareLocal(dir, state.sunDir, state.sunDiscHalfAngle, local)) { + // Tint from the body's CENTRE, not from this ray. The drawn quad spans ~33 degrees (vanilla's + // size, not the real sun's half degree), so per-ray extinction would redden its lower limb by + // several stops relative to its upper one — an accurate answer to a question about a body that + // is 60x too large. The centre keeps the sprite one colour, as vanilla draws it. + float3 sunExtinction = transmittanceToSpace(transmittanceLut, state.viewerRadiusKm, + state.sunDir, state.sunAngularRadius); + float2 uv = lerp(state.sunUv.xy, state.sunUv.zw, local * 0.5 + 0.5); + float3 texel = celestialsAtlas.SampleLevel(uv, 0.0).rgb; + // Vanilla's sun sprite bakes a soft glow gradient into its texels; scaling that raw to HDR + // makes a huge ring around the disc. Raising the texture luminance to a high power keeps only + // the bright core and collapses the painted halo to ~0 — the real halo comes from the Mie + // in-scatter in the LUT, which is where it belongs. + float core = pow(clamp(dot(texel, texel) * 0.45, 0.0, 1.0), 6.0); + color += discRadiance(state.sunIlluminance, state.sunDiscHalfAngle) * sunExtinction * core; + } + if (celestialSquareLocal(dir, state.moonDir, state.moonDiscHalfAngle, local)) { + float2 uv = lerp(state.moonUv.xy, state.moonUv.zw, local * 0.5 + 0.5); + float3 texel = srgbToLinear(celestialsAtlas.SampleLevel(uv, 0.0).rgb); + float3 moonExtinction = transmittanceToSpace(transmittanceLut, state.viewerRadiusKm, + state.moonDir, state.moonAngularRadius); + // The phase sprite already carries the lit shape; a contrast ramp suppresses its faint painted + // halo while keeping that shape. + float shape = smoothstep(0.0, 1.0, min(length(texel), 1.0)); + color += discRadiance(state.moonIlluminance, state.moonDiscHalfAngle) + * moonExtinction * texel * shape; + } + // No separate horizon gate on either body. A set sun or moon goes dark because its extinction is + // zero below the horizon, matching the NEE visibility test. + } + + // The sky goes out in absolute scene units at full fp32 — packSky writes the payload's three surface + // words as raw floats rather than halves, which is why the sun disc's ~1e5 cd/m² needs neither a + // pre-exposure scale nor a clamp to stay representable. The fp16 ceiling reappears only at world.rgen's + // outImage store, where the finite-half clamp is applied. packSky also + // overwrites the normal view, so there is no separate normal clear below. + packSky(payload, bt709ToAcesCg(max(color, float3(0.0)))); + payload.hitT = -1.0; + payload.motionPrev = half3(0.0h, 0.0h, 0.0h); + payload.f0 = half3(0.0h, 0.0h, 0.0h); + 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; +} diff --git a/shaders/pipelines/world/sky.slang b/shaders/pipelines/world/sky.slang new file mode 100644 index 00000000..6926b2fa --- /dev/null +++ b/shaders/pipelines/world/sky.slang @@ -0,0 +1,559 @@ +// Physically based sky: Hillaire 2020, "A Scalable and Production Ready Sky and Atmosphere Rendering +// Technique" (EGSR). Three LUTs, all produced by compute passes in this directory: +// +// sky_transmittance.comp 256x64 static transmittance from a point to space, by (altitude, cos zenith) +// sky_multiscatter.comp 32x32 static second-and-higher-order scattering, by (altitude, cos sun zenith) +// sky_view.comp 192x216 per-frame the sky dome itself, one 192x108 slice per celestial body +// +// This module holds everything the three passes and world.rmiss/world.rgen share, so the LUT and the +// shaded result can never be computed from different physics. +// +// The multiple-scattering LUT supplies twilight and anti-solar illumination directly, so the sky needs no +// authored fill or crossfade between lighting states. +// +// Everything is photometric: illuminance in lux goes in and luminance in cd/m² comes out. Colour is +// linear BT.709 inside this module and crosses to ACEScg exactly +// once, at the consumer's output. +// +// Lengths are KILOMETRES and coefficients 1/km throughout. The LUT parameterisations take square roots of +// differences of radii around 6360 vs 6460; in metres those differences lose most of their fp32 +// significand and the horizon row of the sky-view LUT visibly quantises. +module sky; + +import world_common; + +public static const float SKY_PI = 3.14159265359; + +// ---- Medium. Rayleigh/Mie/ozone coefficients are Hillaire's Earth reference values. +public static const float ATMOS_BOTTOM_KM = 6360.0; +public static const float ATMOS_TOP_KM = 6460.0; +public static const float3 RAYLEIGH_SCATTERING = float3(0.005802, 0.013558, 0.033100); +public static const float RAYLEIGH_SCALE_H_KM = 8.0; +public static const float MIE_SCATTERING = 0.003996; +public static const float MIE_EXTINCTION = 0.004440; // scattering + absorption +public static const float MIE_SCALE_H_KM = 1.2; +public static const float MIE_G = 0.8; +// Ozone absorbs without scattering, in a tent peaking at 25 km. Its green-heavy absorption is what keeps +// a noon zenith deep blue and turns twilight through purple rather than straight from orange to grey. +public static const float3 OZONE_ABSORPTION = float3(0.000650, 0.001881, 0.000085); +public static const float OZONE_CENTER_KM = 25.0; +public static const float OZONE_WIDTH_KM = 15.0; + +// ---- LUT dimensions. Mirrored in RtSkyLut.java; the two must agree exactly (each pass hard-codes its +// own extent for the bounds check, and the parameterisations below apply the half-texel correction). +public static const int TRANSMITTANCE_LUT_W = 256; +public static const int TRANSMITTANCE_LUT_H = 64; +public static const int MULTISCATTER_LUT_W = 32; +public static const int MULTISCATTER_LUT_H = 32; +public static const int SKY_VIEW_LUT_W = 192; +public static const int SKY_VIEW_LUT_H = 108; +// The sky-view image stacks one slice per celestial body: rows [0,108) are the sun's contribution and +// rows [108,216) the moon's. Two independent slices let moonlight and sunlight scatter through the same +// atmosphere without crossfading between the lighting solutions. +public static const int SKY_VIEW_BODY_COUNT = 2; +public static const int SKY_VIEW_LUT_TOTAL_H = SKY_VIEW_LUT_H * SKY_VIEW_BODY_COUNT; +public static const int SKY_VIEW_BODY_SUN = 0; +public static const int SKY_VIEW_BODY_MOON = 1; + +// Raymarch step counts. The sky-view LUT is amortised over the whole frame, so it can afford a count that +// leaves no visible banding at the horizon, which is where the parameterisation concentrates its texels. +public static const int TRANSMITTANCE_STEPS = 40; +public static const int SKY_VIEW_STEPS = 32; +public static const int MULTISCATTER_STEPS = 20; +public static const int MULTISCATTER_DIRS = 64; // 8x8 sphere directions per texel + +public struct MediumSample { + public float3 scattering; // Rayleigh + Mie (the part that redirects light) + public float3 extinction; // scattering + absorption (Mie absorption + ozone) + public float3 rayleigh; + public float mie; +}; + +public MediumSample sampleMedium(float altitudeKm) { + float rayleighDensity = exp(-max(altitudeKm, 0.0) / RAYLEIGH_SCALE_H_KM); + float mieDensity = exp(-max(altitudeKm, 0.0) / MIE_SCALE_H_KM); + float ozoneDensity = max(0.0, 1.0 - abs(altitudeKm - OZONE_CENTER_KM) / OZONE_WIDTH_KM); + + MediumSample m; + m.rayleigh = RAYLEIGH_SCATTERING * rayleighDensity; + m.mie = MIE_SCATTERING * mieDensity; + m.scattering = m.rayleigh + float3(m.mie); + m.extinction = m.rayleigh + float3(MIE_EXTINCTION * mieDensity) + + OZONE_ABSORPTION * ozoneDensity; + return m; +} + +public float rayleighPhase(float cosTheta) { + return 3.0 / (16.0 * SKY_PI) * (1.0 + cosTheta * cosTheta); +} + +// Cornette-Shanks, the phase function Hillaire's coefficients are fitted against. +public float miePhase(float cosTheta) { + float g = MIE_G; + float k = 3.0 / (8.0 * SKY_PI) * (1.0 - g * g) / (2.0 + g * g); + return k * (1.0 + cosTheta * cosTheta) / pow(max(1.0 + g * g - 2.0 * g * cosTheta, 1.0e-4), 1.5); +} + +public static const float ISOTROPIC_PHASE = 1.0 / (4.0 * SKY_PI); + +/** Nearest non-negative ray/sphere (centred at the origin) intersection distance, or -1 for a miss. */ +public float raySphereNearest(float3 origin, float3 dir, float radius) { + float b = dot(origin, dir); + float c = dot(origin, origin) - radius * radius; + float disc = b * b - c; + if (disc < 0.0) { + return -1.0; + } + disc = sqrt(disc); + float t0 = -b - disc; + float t1 = -b + disc; + if (t1 < 0.0) { + return -1.0; + } + return t0 < 0.0 ? t1 : t0; +} + +/** Farthest ray/sphere intersection distance (the exit point), or -1 when the ray misses entirely. */ +public float raySphereFarthest(float3 origin, float3 dir, float radius) { + float b = dot(origin, dir); + float c = dot(origin, origin) - radius * radius; + float disc = b * b - c; + if (disc < 0.0) { + return -1.0; + } + return -b + sqrt(disc); +} + +// ---- Transmittance LUT parameterisation (Bruneton). The mapping is by DISTANCE to the top of the +// atmosphere rather than by the angle itself, which is what keeps texels dense exactly where the optical +// depth changes fastest: the grazing directions around the horizon. +public float2 transmittanceLutUv(float radiusKm, float cosZenith) { + float h = sqrt(max(ATMOS_TOP_KM * ATMOS_TOP_KM - ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM, 0.0)); + float rho = sqrt(max(radiusKm * radiusKm - ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM, 0.0)); + float discriminant = radiusKm * radiusKm * (cosZenith * cosZenith - 1.0) + ATMOS_TOP_KM * ATMOS_TOP_KM; + float d = max(0.0, -radiusKm * cosZenith + sqrt(max(discriminant, 0.0))); + float dMin = ATMOS_TOP_KM - radiusKm; + float dMax = rho + h; + return float2((d - dMin) / max(dMax - dMin, 1.0e-6), rho / max(h, 1.0e-6)); +} + +public void transmittanceLutParams(float2 uv, out float radiusKm, out float cosZenith) { + float h = sqrt(max(ATMOS_TOP_KM * ATMOS_TOP_KM - ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM, 0.0)); + float rho = h * uv.y; + radiusKm = sqrt(rho * rho + ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM); + float dMin = ATMOS_TOP_KM - radiusKm; + float dMax = rho + h; + float d = dMin + uv.x * (dMax - dMin); + cosZenith = d == 0.0 ? 1.0 + : (h * h - rho * rho - d * d) / (2.0 * radiusKm * d); + cosZenith = clamp(cosZenith, -1.0, 1.0); +} + +// Sampling any of these LUTs must address texel CENTRES: the parameterisations above are defined on the +// closed [0,1] range, while a sampler's [0,1] spans centre-to-centre of the outermost texels. +public float2 unitToSubUv(float2 uv, float2 size) { + return (uv * (size - 1.0) + 0.5) / size; +} + +public float2 subUvToUnit(float2 uv, float2 size) { + return (uv * size - 0.5) / (size - 1.0); +} + +public float3 sampleTransmittance(Sampler2D lut, float radiusKm, float cosZenith) { + float2 uv = transmittanceLutUv(radiusKm, cosZenith); + uv = unitToSubUv(saturate(uv), float2(float(TRANSMITTANCE_LUT_W), float(TRANSMITTANCE_LUT_H))); + return lut.SampleLevel(uv, 0.0).rgb; +} + +/** + * Transmittance from the viewer to space along a world direction (world up = +Y), zero below the local + * horizon. + * + *

The horizon gate is NOT redundant with the LUT. The transmittance parameterisation runs from + * straight up to the ground-tangent ray and stops there, so every direction below the horizon clamps to + * that last column — about 5% in red at sea level, not zero. Sampling the raw LUT for a set sun therefore + * hands back ~6,000 lux of deep red light that never goes away, which would leave the world lit from + * below all night and keep the sun's disc drawn under the ground. (The raymarch has no such problem: its + * per-sample `lightVisibility` already gates on the same geometry.) + * + *

{@code angularRadius} is the light's own angular size, so a body sets over its own width instead of + * switching off at a mathematical line. + */ +public float3 transmittanceToSpace(Sampler2D lut, float viewerRadiusKm, float3 dir, float angularRadius) { + // cos of the zenith angle of the ground-tangent ray: the viewer's horizon, which dips further below + // horizontal the higher the viewer is. + float horizonCos = horizonZenithCos(viewerRadiusKm); + float softness = max(sin(angularRadius), 1.0e-4); + float aboveHorizon = smoothstep(horizonCos - softness, horizonCos + softness, dir.y); + return sampleTransmittance(lut, viewerRadiusKm, dir.y) * aboveHorizon; +} + +// ---- Multiple-scattering LUT parameterisation: linear in cos(sun zenith) and in altitude. The stored +// quantity is per unit illuminance, so one LUT serves the sun and the moon alike. +public float2 multiScatterLutUv(float radiusKm, float cosSunZenith) { + float2 uv = float2(cosSunZenith * 0.5 + 0.5, + (radiusKm - ATMOS_BOTTOM_KM) / max(ATMOS_TOP_KM - ATMOS_BOTTOM_KM, 1.0e-6)); + return saturate(uv); +} + +public void multiScatterLutParams(float2 uv, out float radiusKm, out float cosSunZenith) { + cosSunZenith = clamp(uv.x * 2.0 - 1.0, -1.0, 1.0); + radiusKm = lerp(ATMOS_BOTTOM_KM, ATMOS_TOP_KM, uv.y); + // Keep off the exact boundaries: a sample sitting precisely on the ground or on the outer shell makes + // the ray/sphere tests degenerate. The bottom uses the same fp32-derived floor as the viewer — a + // smaller nudge would be lost to the cancellation in `r*r - bottom*bottom` (see + // MIN_VIEWER_ALTITUDE_KM), leaving the LUT's ground row as garbage rather than as ground. + radiusKm = clamp(radiusKm, ATMOS_BOTTOM_KM + MIN_VIEWER_ALTITUDE_KM, + ATMOS_TOP_KM - MIN_VIEWER_ALTITUDE_KM); +} + +public float3 sampleMultiScatter(Sampler2D lut, float radiusKm, float cosSunZenith) { + float2 uv = multiScatterLutUv(radiusKm, cosSunZenith); + uv = unitToSubUv(uv, float2(float(MULTISCATTER_LUT_W), float(MULTISCATTER_LUT_H))); + return lut.SampleLevel(uv, 0.0).rgb; +} + +// Smallest viewer altitude the fp32 geometry can actually resolve, and the floor every consumer of a +// radius here clamps to. +// +// At radius == ATMOS_BOTTOM_KM the model degenerates: the horizon collapses onto the horizontal, the +// ray/ground test returns its tangent root at t == 0 so `tGround > 0` reads false, and the march runs +// straight through the planet instead of stopping at it. +// +// The floor has to be far larger than it looks, for two reasons that both come from working in km at +// planet scale: +// +// * one fp32 ULP at 6360 km is 0.488 m, so any altitude below half a metre is not representable at all +// — `6360.0001f` IS `6360.0f`, and a floor of a tenth of a metre silently does nothing; +// * every horizon test computes `r*r - bottom*bottom`, a subtraction of two numbers near 4.0e7 where +// the ULP is 4 km². The exact value is `2*bottom*h`, so at 0.5 m altitude that difference carries 26% +// error, at 5 m 0.6%, and only past a few tens of metres does it stop mattering. +// +// 50 m is ~100 ULP of margin, leaves the horizon dip accurate to 0.001 degrees, and costs nothing: it is +// 0.05% of the 100 km shell, and with RtComposite's 100-blocks-per-km scale it only raises cameras below +// about y = 68 — which it moves by a fifth of a degree of horizon dip, well under the width of the +// drawn sun. The alternative is reformulating every ray/sphere test to take altitude instead of radius +// (the exact `2*bottom*h + h*h` form); that is worth doing only if the viewer ever needs to sit within +// metres of the shell, which nothing here does. +public static const float MIN_VIEWER_ALTITUDE_KM = 0.05; + +/** Cosine of the zenith angle of the ground-tangent direction: the viewer's geometric horizon. */ +public float horizonZenithCos(float viewerRadiusKm) { + return -sqrt(max(1.0 - (ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM) + / (viewerRadiusKm * viewerRadiusKm), 0.0)); +} + +// ---- Sky-view LUT parameterisation (Hillaire). Azimuth is measured FROM the light, which the dome is +// symmetric about, so two angles describe the whole sphere. The elevation mapping is split at the +// geometric horizon and squared on each side, putting the most texels exactly where the sky's gradient is +// steepest, keeping the horizon continuous and well resolved. +public float2 skyViewLutUv(float viewerRadiusKm, float viewZenithCos, float lightViewCos, int body) { + float horizonSin = sqrt(max(viewerRadiusKm * viewerRadiusKm + - ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM, 0.0)) / max(viewerRadiusKm, 1.0e-6); + float horizonAngle = acos(clamp(horizonSin, -1.0, 1.0)); // angle from the DOWN axis to the horizon + float zenithHorizonAngle = SKY_PI - horizonAngle; // angle from the UP axis to the horizon + float viewZenithAngle = acos(clamp(viewZenithCos, -1.0, 1.0)); + + float v; + if (viewZenithAngle < zenithHorizonAngle) { + float coord = viewZenithAngle / max(zenithHorizonAngle, 1.0e-6); + coord = 1.0 - sqrt(max(1.0 - coord, 0.0)); + v = coord * 0.5; + } else { + float coord = (viewZenithAngle - zenithHorizonAngle) / max(horizonAngle, 1.0e-6); + v = sqrt(max(coord, 0.0)) * 0.5 + 0.5; + } + float u = sqrt(max(0.5 - 0.5 * lightViewCos, 0.0)); + + float2 uv = unitToSubUv(float2(u, saturate(v)), + float2(float(SKY_VIEW_LUT_W), float(SKY_VIEW_LUT_H))); + // Fold the slice offset in after the half-texel correction so a body's rows never bleed into its + // neighbour's: v is already inside [0.5/108, 1-0.5/108] of its own slice. + return float2(uv.x, (uv.y + float(body)) / float(SKY_VIEW_BODY_COUNT)); +} + +public void skyViewLutParams(float2 uv, float viewerRadiusKm, + out float viewZenithCos, out float lightViewCos) { + float2 unit = subUvToUnit(saturate(uv), float2(float(SKY_VIEW_LUT_W), float(SKY_VIEW_LUT_H))); + float horizonSin = sqrt(max(viewerRadiusKm * viewerRadiusKm + - ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM, 0.0)) / max(viewerRadiusKm, 1.0e-6); + float horizonAngle = acos(clamp(horizonSin, -1.0, 1.0)); + float zenithHorizonAngle = SKY_PI - horizonAngle; + + float viewZenithAngle; + if (unit.y < 0.5) { + float coord = 1.0 - unit.y * 2.0; + viewZenithAngle = zenithHorizonAngle * (1.0 - coord * coord); + } else { + float coord = unit.y * 2.0 - 1.0; + viewZenithAngle = zenithHorizonAngle + horizonAngle * coord * coord; + } + viewZenithCos = cos(viewZenithAngle); + lightViewCos = clamp(1.0 - 2.0 * unit.x * unit.x, -1.0, 1.0); +} + +/** + * The canonical frame the sky-view LUT is baked in: world up is +Y and the light's azimuth is placed on + * +X. Any world configuration maps onto it, because the dome is rotationally symmetric about up and + * mirror-symmetric about the light's meridian. + */ +public void skyViewLocalDirections(float lightZenithCos, float viewZenithCos, float lightViewCos, + out float3 viewDir, out float3 lightDir) { + float lightZenithSin = sqrt(max(1.0 - lightZenithCos * lightZenithCos, 0.0)); + lightDir = float3(lightZenithSin, lightZenithCos, 0.0); + float viewZenithSin = sqrt(max(1.0 - viewZenithCos * viewZenithCos, 0.0)); + float lightViewSin = sqrt(max(1.0 - lightViewCos * lightViewCos, 0.0)); + viewDir = float3(viewZenithSin * lightViewCos, viewZenithCos, viewZenithSin * lightViewSin); +} + +/** Cosine of the azimuth between a view direction and a light, both in world space (up = +Y). */ +public float lightViewCosine(float3 viewDir, float3 lightDir) { + float2 viewAzimuth = viewDir.xz; + float2 lightAzimuth = lightDir.xz; + float viewLen = length(viewAzimuth); + float lightLen = length(lightAzimuth); + if (viewLen < 1.0e-5 || lightLen < 1.0e-5) { + return 1.0; // straight up/down, or a light at the zenith: azimuth is undefined and irrelevant + } + return clamp(dot(viewAzimuth / viewLen, lightAzimuth / lightLen), -1.0, 1.0); +} + +public struct ScatteringResult { + public float3 luminance; // cd/m² + public float3 multiScatAs1; // per-unit-illuminance response used to build the multiscatter LUT +}; + +/** + * Single-scattering raymarch with an optional multiple-scattering lookup, following Hillaire's + * IntegrateScatteredLuminance. + * + *

{@code lightAngularRadius} softens the planet's shadow on the atmosphere over the light's finite + * angular size instead of switching it per sample, preventing the transition from landing on the + * primary march's sample lattice. + * + *

{@code clipGround} false marches straight past the planet to the far side of the shell and adds no + * ground bounce. Evaluated exactly along the ground-tangent direction that is the limit of the + * above-horizon sky, which is what {@code sky_view.comp} blends the below-horizon rows toward. + */ +public ScatteringResult integrateScatteredLuminance( + float3 originKm, float3 dir, float3 lightDir, float3 illuminance, + float lightAngularRadius, int steps, bool applyPhase, + bool useMultiScatter, Sampler2D transmittanceLut, Sampler2D multiScatterLut, + float groundAlbedo, bool clipGround) { + ScatteringResult result; + result.luminance = float3(0.0); + result.multiScatAs1 = float3(0.0); + + float tTop = raySphereFarthest(originKm, dir, ATMOS_TOP_KM); + if (tTop <= 0.0) { + return result; // outside the atmosphere looking away from it + } + float tGround = raySphereNearest(originKm, dir, ATMOS_BOTTOM_KM); + bool hitGround = clipGround && tGround > 0.0; + float tMax = hitGround ? tGround : tTop; + + float cosTheta = dot(dir, lightDir); + float phaseR = applyPhase ? rayleighPhase(cosTheta) : ISOTROPIC_PHASE; + float phaseM = applyPhase ? miePhase(cosTheta) : ISOTROPIC_PHASE; + float shadowSoftness = max(sin(lightAngularRadius), 1.0e-4); + + float3 throughput = float3(1.0); + float dt = tMax / float(steps); + for (int i = 0; i < steps; i++) { + float t = (float(i) + 0.5) * dt; + float3 p = originKm + dir * t; + float radius = length(p); + float3 up = p / max(radius, 1.0e-6); + float altitude = radius - ATMOS_BOTTOM_KM; + MediumSample medium = sampleMedium(altitude); + float3 stepTransmittance = exp(-medium.extinction * dt); + + // Planet shadow, softened over the light's angular radius: `clearance` is how far the light sits + // above this sample's own geometric horizon, which rises as the sample does — so high-altitude + // samples stay lit well after the ground below them is in shadow. That altitude dependence is + // what makes a real twilight glow, and it is the reason no artificial fill term is needed. + float horizonDipSin = sqrt(max(1.0 - (ATMOS_BOTTOM_KM * ATMOS_BOTTOM_KM) / (radius * radius), 0.0)); + float clearance = dot(up, lightDir) + horizonDipSin; + float lightVisibility = smoothstep(-shadowSoftness, shadowSoftness, clearance); + + float cosLightZenith = dot(up, lightDir); + float3 transmittanceToLight = sampleTransmittance(transmittanceLut, radius, cosLightZenith); + float3 inScatter = lightVisibility * transmittanceToLight + * (medium.rayleigh * phaseR + medium.mie * phaseM); + if (useMultiScatter) { + // Multiple scattering arrives from every direction, so it couples to the total scattering + // coefficient rather than to a phase function. + inScatter += sampleMultiScatter(multiScatterLut, radius, cosLightZenith) * medium.scattering; + } + + // Analytic integration of the constant-medium segment (Hillaire): exact for the step rather than + // a midpoint sample, which is what lets this run at 32 steps without banding. + float3 safeExtinction = max(medium.extinction, float3(1.0e-9)); + float3 scattered = illuminance * inScatter; + result.luminance += throughput * (scattered - scattered * stepTransmittance) / safeExtinction; + + float3 msIntegral = (medium.scattering - medium.scattering * stepTransmittance) / safeExtinction; + result.multiScatAs1 += throughput * msIntegral; + + throughput *= stepTransmittance; + } + + if (hitGround && groundAlbedo > 0.0) { + // Lambertian ground bounce. Almost always hidden behind terrain in a Minecraft world, but it is + // what keeps a below-horizon direction (over a void, or through the world edge) a plausible dim + // colour instead of black, with no separate below-horizon code path to seam against. + float3 p = originKm + dir * tMax; + float3 up = p / max(length(p), 1.0e-6); + float cosLightZenith = dot(up, lightDir); + float3 transmittanceToLight = sampleTransmittance(transmittanceLut, ATMOS_BOTTOM_KM, cosLightZenith); + float ndl = max(cosLightZenith, 0.0); + result.luminance += throughput * illuminance * transmittanceToLight * ndl + * (groundAlbedo / SKY_PI); + } + return result; +} + +// ---- Celestial geometry ------------------------------------------------------------------------ + +/** + * World direction of a celestial body from Minecraft's own eased angle (EnvironmentAttributes.SUN_ANGLE / + * MOON_ANGLE, radians) and the look package's noon tilt toward south. Angle 0 puts the body at its + * highest point. Matches vanilla's arc: east-west travel, tilted toward +Z at its peak. + */ +public float3 celestialDirection(float angleRad, float noonTiltRad) { + float peak = cos(angleRad); + return float3(-sin(angleRad), cos(noonTiltRad) * peak, sin(noonTiltRad) * peak); +} + +/** The pole the sun/moon arc about, and the axis the starfield wheels on. */ +public float3 celestialAxis(float noonTiltRad) { + return float3(0.0, -sin(noonTiltRad), cos(noonTiltRad)); +} + +/** + * Tangent frame for a celestial body's square, kept level with the horizon: {@code right} is horizontal + * and {@code up} lies in the vertical plane through the body. This keeps the sun and moon from rolling + * about their own centres as they cross the sky. The same frame + * builds the NEE shadow-ray square in math.slang, so the sampled light and the drawn body stay identical. + */ +public void celestialSquareFrame(float3 bodyDir, out float3 right, out float3 up) { + // Near the zenith the horizontal reference degenerates; the arc's tilt keeps the sun/moon well away + // from it in practice, so any stable fallback will do. + float3 reference = abs(bodyDir.y) > 0.9995 ? float3(0.0, 0.0, 1.0) : float3(0.0, 1.0, 0.0); + right = normalize(cross(reference, bodyDir)); + up = cross(bodyDir, right); +} + +/** + * Gnomonic projection of {@code dir} into a body's tangent square, in units of its half-angle: + * {@code local} within [-1,1]² is on the body. A square (not a disc) because vanilla draws both bodies as + * textured quads, and the sprite's own alpha then shapes what is actually visible. + */ +public bool celestialSquareLocal(float3 dir, float3 bodyDir, float halfAngle, out float2 local) { + local = float2(2.0, 2.0); + float c = dot(dir, bodyDir); + if (c <= 1.0e-3) { + return false; + } + float3 right; + float3 up; + celestialSquareFrame(bodyDir, right, up); + float3 planar = dir / c; // the point where the ray pierces the body's tangent plane + local = float2(dot(planar, right), dot(planar, up)) / tan(halfAngle); + return all(abs(local) < float2(1.0, 1.0)); +} + +// ---- Per-frame sky state ------------------------------------------------------------------------ +// +// WorldPush carries only what the CPU alone can know: Minecraft's four eased celestial angles, its star +// brightness, the moon phase, and the immutable look-package constants. Every direction, colour, level, +// and transmittance is derived here so atmospheric physics has one implementation. + +public struct SkyState { + public float3 sunDir; + public float3 moonDir; + public float sunIlluminance; // lux, top of atmosphere + public float moonIlluminance; // lux, already scaled by the visible phase + public float viewerRadiusKm; // planet centre to viewer + public float sunAngularRadius; // NEE sampling half-angle, also the planet-shadow softness + public float moonAngularRadius; + public float sunDiscHalfAngle; // drawn size, decoupled from the NEE radius (vanilla quads are huge) + public float moonDiscHalfAngle; + public float starAngle; + public float starLuminance; // cd/m², MC's star brightness times the look package's anchor + public float airglowLuminance; // cd/m², the real moonless-night floor (airglow + unresolved stars) + public float noonTilt; + public float groundAlbedo; + public float horizonSoften; // radians below the horizon over which the ground fades in + public float moonPhaseIndex; // 0 full .. 4 new + public float4 sunUv; + public float4 moonUv; +}; + +/** Minecraft's moon phases run 0 = full to 4 = new, then mirror back toward full through phase 7. */ +public float moonLitFraction(float moonPhaseIndex) { + return abs(moonPhaseIndex - 4.0) / 4.0; +} + +public SkyState skyState(WorldPush push) { + SkyState s; + s.noonTilt = push.skyLook1.x; + s.sunDir = celestialDirection(push.celestial.x, s.noonTilt); + s.moonDir = celestialDirection(push.celestial.y, s.noonTilt); + s.sunIlluminance = push.skyLook0.x; + // A fixed floor plus the visible phase: a new moon still casts a little directional light, which is + // a gameplay decision the look package owns rather than an astronomical one. + float phaseFixed = push.skyLook1.w; + s.moonIlluminance = push.skyLook0.y + * (phaseFixed + (1.0 - phaseFixed) * moonLitFraction(push.skyLook2.w)); + s.viewerRadiusKm = ATMOS_BOTTOM_KM + max(push.skyLook2.z, MIN_VIEWER_ALTITUDE_KM); + s.sunAngularRadius = push.skyLook1.y; + s.moonAngularRadius = push.skyLook1.z; + s.sunDiscHalfAngle = push.skyLook2.x; + s.moonDiscHalfAngle = push.skyLook2.y; + s.starAngle = push.celestial.z; + s.starLuminance = push.celestial.w * push.skyLook0.w; + s.airglowLuminance = push.skyLook0.z; + s.groundAlbedo = push.skyLook3.x; + s.horizonSoften = push.skyLook3.y; + s.moonPhaseIndex = push.skyLook2.w; + s.sunUv = push.sunUv; + s.moonUv = push.moonUv; + return s; +} + +public struct CelestialLight { + public float3 dir; + public float3 illuminance; // linear BT.709, lux at the viewer + public float halfAngle; +}; + +/** + * The single directional light NEE samples: whichever of the two bodies actually delivers more light + * after atmospheric extinction. + * + *

There is deliberately no crossfade between them. The extinction along a horizon-grazing path is + * enormous (roughly 70 air masses), so both bodies are already within a rounding error of zero at the + * moment the choice flips, making the handoff invisible without an authored smoothstep. + */ +public CelestialLight dominantCelestialLight(SkyState s, Sampler2D transmittanceLut) { + float3 sunLux = s.sunIlluminance + * transmittanceToSpace(transmittanceLut, s.viewerRadiusKm, s.sunDir, s.sunAngularRadius); + // Moonlight is sunlight reflected off a grey body: its colour is the sun's, not a cool tint. The blue + // cast of a moonlit night is the viewer's own scotopic response, which belongs in the look transform, + // not in the light's spectrum. + float3 moonLux = s.moonIlluminance + * transmittanceToSpace(transmittanceLut, s.viewerRadiusKm, s.moonDir, s.moonAngularRadius); + + CelestialLight light; + if (dot(sunLux, float3(0.2126, 0.7152, 0.0722)) >= dot(moonLux, float3(0.2126, 0.7152, 0.0722))) { + light.dir = s.sunDir; + light.illuminance = sunLux; + light.halfAngle = s.sunAngularRadius; + } else { + light.dir = s.moonDir; + light.illuminance = moonLux; + light.halfAngle = s.moonAngularRadius; + } + return light; +} diff --git a/shaders/world/trace.slang b/shaders/pipelines/world/trace.slang similarity index 84% rename from shaders/world/trace.slang rename to shaders/pipelines/world/trace.slang index 8da3f482..6a4f5b6e 100644 --- a/shaders/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -5,6 +5,7 @@ import world_common; import world_core; +import bindings; public static const uint CULL_SECONDARY = 0x01u; public static const uint CULL_PRIMARY = 0x02u; @@ -37,9 +38,12 @@ public float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, // information into a trace and is initialized to a neutral value here. public Payload makeRadiancePayload(uint flags, uint rayCone) { Payload p; - p.albedo = half3(0.0h, 0.0h, 0.0h); - p.normal = half3(0.0h, 0.0h, 0.0h); - p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative + // Zeroes the packed albedo/normal words directly: both the hit and the miss view read zero out of + // them (half 0 and float 0 are the same bit pattern), so this is neutral for either case. + p.surface0 = 0u; + p.surface1 = 0u; + p.surface2 = 0u; + p.hitT = -1.0; // miss sentinel: world.rmiss writes only the surface words, so a miss leaves this negative p.motionPrev = half3(0.0h, 0.0h, 0.0h); p.f0 = half3(0.0h, 0.0h, 0.0h); p.flags = flags; @@ -74,9 +78,12 @@ public void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tm public Payload makeShadowPayload() { Payload shadowPayload; - shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); + shadowPayload.surface0 = 0u; + shadowPayload.surface1 = 0u; + shadowPayload.surface2 = 0u; + // The albedo view is this ray's accumulated transmittance; the any-hits multiply into it. + packAlbedo(shadowPayload, float3(1.0, 1.0, 1.0)); shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit - shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); // world_guide.rmiss clears this sentinel. An accepted opaque hit skips closest-hit and therefore @@ -91,10 +98,10 @@ public Payload makeShadowPayload() { public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow - // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. + // path uses only the albedo view as accumulated transmittance and hitT as the nearest-water crossing. Payload shadowPayload = makeShadowPayload(); // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent - // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no + // and water tint that transmittance and pass through. Solid blocks terminate traversal. There is no // closest shader worth executing. The lightweight guide miss clears the flags sentinel so this path // needs only ordinary TraceRay and no invocation-reorder capability. TraceRay(topLevelAS, @@ -103,7 +110,7 @@ public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); VisibilityResult result; result.transmittance = shadowPayload.flags == 0u - ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); + ? unpackAlbedo(shadowPayload) : float3(0.0, 0.0, 0.0); result.waterHitT = shadowPayload.hitT; return result; } diff --git a/shaders/world/trace_ser.slang b/shaders/pipelines/world/trace_ser.slang similarity index 99% rename from shaders/world/trace_ser.slang rename to shaders/pipelines/world/trace_ser.slang index 043d0f64..89bffc44 100644 --- a/shaders/world/trace_ser.slang +++ b/shaders/pipelines/world/trace_ser.slang @@ -3,6 +3,7 @@ import world_common; import world_core; +import bindings; import trace; // Trace into a hit object, reorder threads by hit coherence, THEN invoke the hit/miss shader. This is diff --git a/shaders/world/water.slang b/shaders/pipelines/world/water.slang similarity index 100% rename from shaders/world/water.slang rename to shaders/pipelines/world/water.slang diff --git a/shaders/world/world_common.slang b/shaders/pipelines/world/world_common.slang similarity index 60% rename from shaders/world/world_common.slang rename to shaders/pipelines/world/world_common.slang index f7c034bd..bc297ab8 100644 --- a/shaders/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -28,7 +28,13 @@ public struct WorldPushConstants { public uint64_t lightGridSpanAddr; // packed weighted section spans referenced by cell headers public uint64_t pathQueueAddr; // packed primary -> indirect continuation records public uint frameIndex; - public uint debugView; +}; + +// Minimal push block for the compute passes that only need to dereference the frame's WorldPush: the sky +// LUT bakes read sun/moon/look state from the same BDA slot the trace does, so there is exactly one +// per-frame source of sky state and no way for the LUT and the frame it shades to disagree. +public struct PushAddr { + public uint64_t worldPushAddr; }; // Block-breaking overlay entry: xyz = breaking block position (rebased, same space as gl_WorldRay*), @@ -47,16 +53,27 @@ public struct WorldPush { public float3 camDelta; public uint spp; public float2 jitter; - public uint flags; // bit0 submerged, bit4 waves (bit1 was PBR-toggle, now unconditional) + public uint flags; // bit0 submerged, bit4 waves public uint maxBounces; - public float4 sunDir; // xyz true sun direction, w dayFactor 0..1 - public float4 lightDir; // xyz active NEE light dir, w square half-angle (rad) - public float4 lightRadiance; // xyz HDR light radiance, w star brightness - public float4 moonDir; // xyz moon direction, w moonPhase (0 full .. 4 new) - public float4 celestial; // xyz celestial rotation axis, w star angle (rad) + // ---- Sky state. Only what the CPU alone can know: Minecraft's four eased celestial angles (the + // 26.2 timeline drives them through a cubic-bezier ease and a datapack may replace the track, so + // they are read from the attribute probe rather than re-derived from the tick), its star brightness, + // the moon phase, and the look package's immutable constants. Every direction, colour, level and + // transmittance is derived in sky.slang from these — no atmosphere maths runs on the CPU any more. + public float4 celestial; // x sun angle (rad), y moon angle, z star angle, w MC star brightness 0..1 + // x sun illuminance (lux, top of atmosphere), y full-moon illuminance (lux), z night airglow + // (cd/m²), w star luminance anchor (cd/m²). + public float4 skyLook0; + // x noon south tilt (rad), y sun NEE half-angle (rad), z moon NEE half-angle (rad), + // w moon-phase fixed light fraction. + public float4 skyLook1; + // x drawn sun half-angle (rad), y drawn moon half-angle (rad), z viewer altitude (km), + // w moon phase index (0 full .. 4 new). + public float4 skyLook2; + public float4 skyLook3; // x ground albedo, yzw reserved public float4 sunUv; // vanilla sun sprite atlas rect (u0,v0,u1,v1) public float4 moonUv; // vanilla current-moon-phase sprite atlas rect - public float4 waterParams; // xyz camera-biome water tint, w wave time (s) + public float4 waterParams; // xyz linear ACEScg camera-biome water tint, w wave time (s) public float4 waterAnchor; // xy world-stable wave-domain anchor, z previous wave time public float4x4 curViewProj; // forward camera-relative view-projection public uint breakCount; @@ -66,20 +83,23 @@ 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) + // 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; }; -// 32-byte hot area-light record. Radiance uses packed R11G11B10 and the grid-relative owner section -// uses three unsigned 10-bit coordinates; the geometric normal is reconstructed from the half axes. +// 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the +// grid-relative owner section uses three unsigned 10-bit coordinates; the geometric normal is +// reconstructed from the half axes. // -// 32 divides the 64-byte cache line, so a record never straddles one. At the previous 48 bytes about -// half of them did, costing two transactions apiece — and RIS fetches these at random indices, M per -// shading vertex, so that is the layout's whole purpose. float3 forces 16-byte struct alignment and 32 -// is a multiple of it, so this lands with no tail padding. +// 32 divides the 64-byte cache line, so a record never straddles one. RIS fetches random records M times +// per shading vertex, making this alignment important. float3 forces 16-byte struct alignment and 32 is +// a multiple of it, so this lands with no tail padding. // -// The rectangle area is derived rather than stored: the rect spans 2U x 2V, so area = 4*|U x V|, which -// is exactly what the collector used to write (halfU = 0.5*(aHi-aLo)*e01 and rectArea = -// |e01 x e03|*(aHi-aLo)*(bHi-bLo)). lightGeometricNormal already needs that cross product, so the -// freed lane costs one length() the compiler shares with the normalize. +// The rectangle spans 2U x 2V, so its area is derived as 4*|U x V|. lightGeometricNormal already +// needs that cross product, allowing the compiler to share the length with normalization. public struct Light { public float3 pos; // rebased world centre, f32 (the RIS target divides by distance to it) public uint le; // bitcast R11G11B10 radiance @@ -114,20 +134,37 @@ public struct LightGridSpan { // TerrainPrim.flags bit 0: this emissive quad is in the light buffer (RtLightCollector membership). public static const uint TERRAIN_PRIM_IN_LIGHT_BUFFER = 1u; -// Largest finite half. The payload's half3 lanes carry HDR values (sky radiance most of all), so -// producers clamp to this instead of letting the conversion round up to +inf. +// Largest finite half. Producers of fp16 HDR storage clamp to this instead of letting the conversion +// round up to +inf and propagate as NaN — world.rgen's rgba16f outImage store most of all, which is the +// one place the whole path integral's absolute radiance meets a half. public static const float HALF_MAX = 65504.0; // Radiance-ray payload (location 0). Member order and types are a cross-stage ABI. // -// The three-component lanes are half3: they cost 6 bytes instead of 12, and payload storage is reserved -// per trace call site, so every byte here is paid twice per radiance trace (once for TraceRay, once for -// Invoke) and is preserved across the SER reorder. hitT stays f32 — it reaches 10000 blocks and the hit -// position is reconstructed from it, where half's ~4-block spacing at that magnitude would be visible. +// Three-component quantities are packed into half pairs rather than declared as float3: they cost 6 bytes +// instead of 12, and payload storage is reserved per trace call site, so every byte here is paid twice per +// radiance trace (once for TraceRay, once for Invoke) and is preserved across the SER reorder. hitT stays +// f32 — it reaches 10000 blocks and the hit position is reconstructed from it, where half's ~4-block +// spacing at that magnitude would be visible. public struct Payload { - // Sky radiance on a miss, so this carries HDR values well above 1; half tops out at 65504. - public half3 albedo; // hit: block albedo. miss: sky radiance. - public half3 normal; // hit: geometric normal, viewer-oriented. + // surface0..2 are a hand-packed UNION of the two things a radiance ray can come back with. Reach them + // only through unpackAlbedo/packAlbedo, unpackNormal/packNormal, unpackSky/packSky — the raw words + // have no meaning without knowing which case you are in. + // + // hit — half3 albedo + half3 normal + // miss — float3 sky radiance, full fp32 + // + // The miss case is the entire reason these are hand-packed. Sky is in absolute photometric units and + // the sun disc alone is ~3.6e5 cd/m², well past half's 65504 ceiling — a + // half3 could not carry it without first normalising by the frame's exposure, which made a storage + // format's limitation into a dependency on the exposure controller. A hit has no sky and a miss has + // neither albedo nor normal, so reinterpreting the same twelve bytes buys the range for free and keeps + // the payload exactly the size it was. + // + // The albedo lane doubles as the shadow ray's accumulated transmittance (see makeShadowPayload). + public uint surface0; // hit: half2(albedo.r, albedo.g) | miss: asuint(sky.r) + public uint surface1; // hit: half2(albedo.b, normal.x) | miss: asuint(sky.g) + public uint surface2; // hit: half2(normal.y, normal.z) | miss: asuint(sky.b) public float hitT; // >= 0 on hit, < 0 on miss. public half3 motionPrev; // per-vertex world displacement since last frame. public half3 f0; // specular F0. @@ -206,13 +243,15 @@ public static const uint MATERIAL_FEATURE_SPEC = 1u; public static const uint MATERIAL_FEATURE_NORMAL = 2u; public static const uint MATERIAL_FEATURE_HEURISTIC_EMISSION = 4u; public static const uint MATERIAL_FEATURE_STOCHASTIC_ALPHA = 16u; -// Final HDR emission strength (EMISSIVE_STRENGTH baseline * any JSON override multiplier), baked in Java +// Final HDR emitting-surface luminance (look-package baseline or absolute JSON cd/m² override), baked in Java // at material-compile time (RtMaterialRegistry) and packed here as a 16-bit fraction of the max — every // emissive material carries a value (0 for non-emissive), not just resource-pack-overridden ones, so the -// full features word's spare 16 bits (8..23) go to it instead of the 8 bits the old override-only field used. +// full features word's spare 16 bits (8..23) hold the encoded value. public static const uint MATERIAL_EMISSION_STRENGTH_SHIFT = 8u; public static const uint MATERIAL_EMISSION_STRENGTH_MASK = 65535u; -public static const float MATERIAL_MAX_EMISSION_STRENGTH = 32.0; +// Mirrors RtMaterialRegistry.MAX_EMISSION_STRENGTH — HALF_MAX, the ceiling emission can survive through +// Payload.emissionSss (half2) and Light.le (R11G11B10). Emission is measured in cd/m². +public static const float MATERIAL_MAX_EMISSION_STRENGTH = 65504.0; public static const uint MATERIAL_MAX_LOD_SHIFT = 24u; public static const uint MATERIAL_MAX_LOD_MASK = 255u; @@ -253,3 +292,63 @@ public uint packHalf2(float2 v) { public float2 unpackHalf2(uint p) { return float2(f16tof32(p & 0xFFFFu), f16tof32(p >> 16)); } + +// ---- Payload.surface0..2 accessors. Two views of the same twelve bytes; see the struct for why. +// +// The hit view straddles surface1 (albedo.b in its low half, normal.x in its high half), so the two +// setters are read-modify-write on that word. That is a couple of ALU ops on a value already in +// registers, and it buys a payload that did not have to grow. Each setter touches only its own halves, +// so writing one before the other is initialised is safe — nothing here does arithmetic on the half it +// is preserving. +public float3 unpackAlbedo(Payload p) { + return float3(unpackHalf2(p.surface0), unpackHalf2(p.surface1).x); +} + +public void packAlbedo(inout Payload p, float3 albedo) { + p.surface0 = packHalf2(albedo.xy); + p.surface1 = (p.surface1 & 0xFFFF0000u) | (f32tof16(albedo.z) & 0xFFFFu); +} + +public float3 unpackNormal(Payload p) { + return float3(unpackHalf2(p.surface1).y, unpackHalf2(p.surface2)); +} + +public void packNormal(inout Payload p, float3 normal) { + p.surface1 = (p.surface1 & 0x0000FFFFu) | (f32tof16(normal.x) << 16); + p.surface2 = packHalf2(normal.yz); +} + +// Miss view: full fp32, so absolute photometric sky needs no pre-exposure round trip through the payload +// and no clamp to keep it finite. The fp16 ceiling still applies at the outImage store in world.rgen, +// where the finite-half clamp guards every radiance source. +public float3 unpackSky(Payload p) { + return float3(asfloat(p.surface0), asfloat(p.surface1), asfloat(p.surface2)); +} + +public void packSky(inout Payload p, float3 sky) { + p.surface0 = asuint(sky.x); + p.surface1 = asuint(sky.y); + p.surface2 = asuint(sky.z); +} + +// The ray tracer's transport contract is scene-linear ACEScg (AP1/D60). Minecraft assets and +// captured vertex colours are authored as sRGB/BT.709 (D65), so colour inputs cross this OCIO-derived +// chromatic-adaptation + primary-conversion seam exactly once before they enter any BRDF, throughput, +// medium, light, guide, or radiance calculation. +public static const float3 ACESCG_LUMA = float3(0.27222872, 0.67408177, 0.05368952); +public static const float3 BT709_TO_ACESCG_R = float3(0.61309743, 0.33952314, 0.04737945); +public static const float3 BT709_TO_ACESCG_G = float3(0.07019372, 0.91635388, 0.01345240); +public static const float3 BT709_TO_ACESCG_B = float3(0.02061559, 0.10956977, 0.86981463); + +public float3 bt709ToAcesCg(float3 color) { + return float3( + dot(color, BT709_TO_ACESCG_R), + dot(color, BT709_TO_ACESCG_G), + dot(color, BT709_TO_ACESCG_B)); +} + +public float3 srgbToLinear(float3 color) { + float3 lo = color / 12.92; + float3 hi = pow((color + 0.055) / 1.055, float3(2.4)); + return lerp(lo, hi, step(float3(0.04045), color)); +} diff --git a/shaders/world/world_core.slang b/shaders/pipelines/world/world_core.slang similarity index 75% rename from shaders/world/world_core.slang rename to shaders/pipelines/world/world_core.slang index 62252f11..c0203601 100644 --- a/shaders/world/world_core.slang +++ b/shaders/pipelines/world/world_core.slang @@ -2,21 +2,8 @@ // Import first: everything below depends on pc, worldPush and payload. import world_common; - -[[vk::push_constant]] public WorldPushConstants pc; - -[[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; -// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. -[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; -// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written -// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what -// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D gNormal; // xyz world normal, w linear roughness -[[vk::binding(4, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; // rgb diffuse albedo -[[vk::binding(5, 0)]] [format("r32f")] public RWTexture2D gDepth; // HW reversed-Z depth -[[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; // exact-mirror reflected diffuse × reflectance; material reflectance otherwise -[[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; // reflection MVs for RR +import sky; +import bindings; // Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL // `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. @@ -33,6 +20,14 @@ public struct VisibilityResult { +// Views onto the module-level payload's hand-packed surface words (see Payload.surface0..2). Hit shaders +// take their payload as a parameter and call the world_common forms directly; these exist so raygen-side +// code reads the same as the payloadRoughness()/payloadEmission() accessors below it. +public float3 payloadAlbedo() { return unpackAlbedo(payload); } +public float3 payloadNormal() { return unpackNormal(payload); } +/** Sky radiance on a miss — absolute scene units, full fp32. Only meaningful when hitT < 0. */ +public float3 payloadSky() { return unpackSky(payload); } + public uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } // Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was // travelling into the volume (vs. out of it) at this hit's face. @@ -53,9 +48,16 @@ public float payloadTransmission() { return unpackHalf2(payload.iorTransmission) public static const float PI = 3.14159265359; public static const float INV_PI = 0.31830988618; -// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen -// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately -// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. +// The sky (atmosphere LUT + sun/moon discs + stars) is evaluated in world.rmiss. On a miss, raygen reads +// payloadSky() and accumulates that radiance. +// +// The single directional NEE light — whichever of the sun and moon actually delivers more light through +// the atmosphere — is DERIVED, not pushed: world.rgen fills this in from sky.dominantCelestialLight() once +// per dispatch, using the same transmittance LUT world.rmiss tints the visible discs with. That shared +// source is what guarantees the light on terrain and the sky's own sunset can never disagree; the previous +// arrangement kept a hand-maintained Java port of the atmosphere march for exactly this value. +// Illuminance here is ACEScg (the transport basis), converted once where it is assigned. +public static CelestialLight celestialLight = {}; public static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) public static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface diff --git a/shaders/world/world.rmiss.slang b/shaders/world/world.rmiss.slang deleted file mode 100644 index f7d648e3..00000000 --- a/shaders/world/world.rmiss.slang +++ /dev/null @@ -1,303 +0,0 @@ -// Ray miss = sky. Per-frame sky data comes from the WorldPush BDA buffer addressed by the 8-byte push -// constant. The shader writes full sky radiance (Rayleigh/Mie/ozone atmosphere + transmittance-tinted -// sun/moon discs + soft horizon + starfield) into payload.albedo; raygen consumes that value for both -// accumulated radiance and bounce-0 denoiser guides. -// -// Celestial discs (sun/moon) are gated by a packed payload flag, which raygen sets per-ray from the path -// state: true on the primary ray and after every specular/dielectric bounce (so the sun/moon show up in -// water/glass/metal reflections + refractions), false after a diffuse vertex did sun/moon NEE (so the -// disc isn't double-counted against the direct light → fireflies). The atmosphere gradient + stars are -// added on every miss (any bounce): stars aren't a NEE light, so they never double-count. -import world_common; - -[[vk::push_constant]] WorldPushConstants pc; - -// Vanilla celestials atlas (sun + moon-phase sprites), bound by RtComposite. Sampled with an explicit -// LOD (no derivatives in a miss shader). The sun/moon discs are drawn from its real texels. -[[vk::binding(9, 0)]] Sampler2D celestialsAtlas; - -static const float PI = 3.14159265359; -float pow2(float x) { return x * x; } - -// Near-white top-of-atmosphere disc radiances: the visible warm/red tint comes from multiplying by -// transmittanceToSpace(dir) at draw time — at noon the sun reads warm-white, at the horizon it reddens -// on exactly the sky's own sunset curve. -static const float3 SUN_DISC_RADIANCE = float3(24.0, 23.0, 21.5); -static const float3 MOON_DISC_RADIANCE = float3(1.7, 1.85, 2.2) / 4.0; // lit-side moon (HDR, bright at night exposure) -// Vibrance applied to the atmosphere in-scatter only (not discs/stars/night ambient): luminance- -// preserving, so GI energy is unchanged. -static const float SKY_SATURATION = 1.2; -// VISIBLE disc half-angles, matched to vanilla: the sun/moon quads are drawn at half-width 30/20 at -// distance 100 → half-angle = atan(0.30) / atan(0.20). Decoupled from the NEE light radius (≈0.6°). -static const float SUN_DISC_HALF_ANGLE = 0.29146; // atan(30/100), vanilla sun size -static const float MOON_DISC_HALF_ANGLE = 0.19740; // atan(20/100), vanilla moon size -// Faint night-sky ambient so the night isn't pure black where the atmosphere in-scatter falls to zero. -static const float3 NIGHT_ZENITH = float3(0.004, 0.008, 0.022) / 5.0; -static const float3 NIGHT_HORIZON = float3(0.015, 0.022, 0.045) / 5.0; - -// ---- Physically-based atmosphere (Nishita / O'Neil single scattering + ozone absorption). A view ray -// is marched through the atmospheric shell; at each step the transmittance toward the sun is integrated -// (a second, shorter march), giving Rayleigh (blue-sky / red-sunset) + Mie (sun halo) in-scattered -// radiance. World up = +Y; the camera sits a couple km above a spherical planet. Output is HDR in the -// same units as tracePath. -static const float PLANET_R = 6371000.0; -static const float ATMOS_R = 6471000.0; // 100 km shell -static const float3 RAY_BETA = float3(5.5e-6, 13.0e-6, 22.4e-6); // Rayleigh scattering at sea level -static const float MIE_BETA = 21.0e-6; // Mie scattering at sea level -static const float RAY_SCALE_H = 8000.0; // Rayleigh density scale height -static const float MIE_SCALE_H = 1200.0; // Mie density scale height -static const float MIE_G = 0.758; // Mie anisotropy (forward-scattering) -// Ozone: absorption only (no scattering), tent-shaped density peaking at 25 km. Its green-heavy -// absorption keeps the zenith deep blue at noon and turns twilight purple-orange. -static const float3 OZONE_BETA = float3(0.650e-6, 1.881e-6, 0.085e-6); -static const float OZONE_CENTER_H = 25000.0; -static const float OZONE_WIDTH_H = 15000.0; -static const float SUN_INTENSITY = 22.0; -static const int ATMOS_PRIMARY_STEPS = 16; -static const int ATMOS_LIGHT_STEPS = 8; - -float ozoneDensity(float h) { return max(0.0, 1.0 - abs(h - OZONE_CENTER_H) / OZONE_WIDTH_H); } - -// Smaller root of ray vs sphere of radius r centred at origin (no-hit handled by x > y). -float2 raySphere(float3 o, float3 d, float r) { - float b = dot(o, d); - float c = dot(o, o) - r * r; - float disc = b * b - c; - if (disc < 0.0) return float2(1.0e9, -1.0e9); - disc = sqrt(disc); - return float2(-b - disc, -b + disc); -} - -float3 atmosphere(float3 dir, float3 sunDir) { - float3 orig = float3(0.0, PLANET_R + 2000.0, 0.0); - float2 t = raySphere(orig, dir, ATMOS_R); - if (t.x > t.y) return float3(0.0, 0.0, 0.0); // ray misses the atmosphere entirely - float tStart = max(t.x, 0.0); - float tEnd = t.y; - float2 tGround = raySphere(orig, dir, PLANET_R); - if (tGround.x > 0.0) tEnd = min(tEnd, tGround.x); // clip the march at the planet surface - float segLen = (tEnd - tStart) / float(ATMOS_PRIMARY_STEPS); - - float mu = dot(dir, sunDir); - float phaseR = 3.0 / (16.0 * PI) * (1.0 + mu * mu); - float g = MIE_G; - float phaseM = 3.0 / (8.0 * PI) * ((1.0 - g * g) * (1.0 + mu * mu)) - / ((2.0 + g * g) * pow(max(1.0 + g * g - 2.0 * g * mu, 0.0), 1.5)); - - float3 sumR = float3(0.0, 0.0, 0.0), sumM = float3(0.0, 0.0, 0.0); - float odR = 0.0, odM = 0.0, odO = 0.0; // view-ray optical depth accumulators - float tCur = tStart; - for (int i = 0; i < ATMOS_PRIMARY_STEPS; i++) { - float3 p = orig + dir * (tCur + segLen * 0.5); - float h = length(p) - PLANET_R; - float hr = exp(-h / RAY_SCALE_H) * segLen; - float hm = exp(-h / MIE_SCALE_H) * segLen; - odR += hr; odM += hm; odO += ozoneDensity(h) * segLen; - - // March toward the sun to accumulate the light-ray optical depth (transmittance to space). - float2 tl = raySphere(p, sunDir, ATMOS_R); - float segLenL = tl.y / float(ATMOS_LIGHT_STEPS); - float tlCur = 0.0, odRL = 0.0, odML = 0.0, odOL = 0.0; - bool blocked = false; - for (int j = 0; j < ATMOS_LIGHT_STEPS; j++) { - float3 pl = p + sunDir * (tlCur + segLenL * 0.5); - float hl = length(pl) - PLANET_R; - if (hl < 0.0) { blocked = true; break; } // sun is below this point's horizon → in shadow - odRL += exp(-hl / RAY_SCALE_H) * segLenL; - odML += exp(-hl / MIE_SCALE_H) * segLenL; - odOL += ozoneDensity(hl) * segLenL; - tlCur += segLenL; - } - if (!blocked) { - float3 tau = RAY_BETA * (odR + odRL) + MIE_BETA * 1.1 * (odM + odML) - + OZONE_BETA * (odO + odOL); - float3 atten = exp(-tau); - sumR += hr * atten; - sumM += hm * atten; - } - tCur += segLen; - } - return SUN_INTENSITY * (sumR * RAY_BETA * phaseR + sumM * MIE_BETA * phaseM); -} - -// Extinction from the camera to space along `dir` (Rayleigh + Mie + ozone; the sun-below-horizon case -// needs no explicit planet test — a grazing/downward path picks up enormous optical depth, so the -// transmittance rolls to zero smoothly on its own). Tints the sun/moon discs at draw time, and is ported -// verbatim to RtComposite.writeSky so the NEE sunlight on terrain follows the identical sunset curve — -// the disc, the sky, and the light can never disagree. -float3 transmittanceToSpace(float3 dir) { - float3 orig = float3(0.0, PLANET_R + 2000.0, 0.0); - float2 t = raySphere(orig, dir, ATMOS_R); - if (t.y <= 0.0) return float3(1.0, 1.0, 1.0); - float segLen = t.y / float(ATMOS_LIGHT_STEPS); - float odR = 0.0, odM = 0.0, odO = 0.0; - for (int i = 0; i < ATMOS_LIGHT_STEPS; i++) { - float3 p = orig + dir * (segLen * (float(i) + 0.5)); - float h = length(p) - PLANET_R; - odR += exp(-h / RAY_SCALE_H) * segLen; - odM += exp(-h / MIE_SCALE_H) * segLen; - odO += ozoneDensity(h) * segLen; - } - return exp(-(RAY_BETA * odR + MIE_BETA * 1.1 * odM + OZONE_BETA * odO)); -} - -// Build the celestial body's square tangent frame: `right` along the arc-travel direction, `up` -// perpendicular within the sky. Shared by the visible disc and the NEE square sampling in raygen. -void celestialFrame(float3 dir, float3 celestialAxis, out float3 right, out float3 up) { - right = normalize(cross(dir, celestialAxis)); - up = cross(right, dir); -} - -// Gnomonic projection of `dir` into the body's tangent square, in units of the half-angle. local ∈ -// [-1,1]² is inside the square. Returns 0 behind the body. The square (not a cone) matches MC's quad. -float squareBody(float3 dir, float3 bodyDir, float halfAngle, float3 celestialAxis, out float2 local) { - float c = dot(dir, bodyDir); - if (c <= 1.0e-3) { local = float2(2.0, 2.0); return 0.0; } - float3 d = dir / c; // d·bodyDir == 1 (point on the tangent plane) - float3 r, u; - celestialFrame(bodyDir, celestialAxis, r, u); - float t = tan(halfAngle); - local = float2(dot(d, r), dot(d, u)) / t; - float m = max(abs(local.x), abs(local.y)); - return 1.0 - smoothstep(0.90, 1.0, m); // soft square edge -} - -// Dave Hoskins' hash13 (https://www.shadertoy.com/view/4djSRW): well-distributed and pattern-free. -float hash13(float3 p3) { - p3 = frac(p3 * 0.1031); - p3 += dot(p3, p3.zyx + 31.32); - return frac((p3.x + p3.y) * p3.z); -} - -// Rotate v about a unit axis by angle (Rodrigues). Used to wheel the starfield about the celestial pole. -float3 rotateAxis(float3 v, float3 axis, float ang) { - float c = cos(ang), s = sin(ang); - return v * c + cross(axis, v) * s + axis * dot(axis, v) * (1.0 - c); -} - -// Procedural starfield, a faithful port of vanilla's buildStars(): ~1500 discrete stars at random -// sphere directions, each a small SQUARE billboard of random size + random rotation. A cube-map grid is -// the spatial accelerator — at most one star per cell, placed at a hashed position INSET from the cell -// edges (so its square never crosses into a neighbour), drawn as a hash-rotated square of hashed size. -// The lookup direction is rotated about the celestial pole by -STAR_ANGLE so the field wheels with -// world time like the real sky. -static const float STAR_CELLS = 21.0; // cube-face grid resolution; with the threshold gives ~1500 stars -static const float STAR_THRESHOLD = 0.86; // cell occupancy cutoff (higher = fewer stars) -static const float STAR_HALF = 0.045; // star half-size as a fraction of a cell (≈ vanilla 0.11° at center) -float3 stars(float3 dir, float starBrightness, float VdotS, float4 celestial) { - if (starBrightness <= 0.0 || dir.y <= 0.0) return float3(0.0, 0.0, 0.0); - // Negative angle: rotating the LOOKUP direction is the inverse of rotating star geometry, so - // -starAngle makes the field wheel the SAME way as the sun/moon (whose world dirs are - // forward-rotated on the CPU). - float3 sdir = rotateAxis(dir, normalize(celestial.xyz), -celestial.w); - // Cube-map face + 2D gnomonic uv → uniform square cells, no pole/equator distortion. - float3 a = abs(sdir); - float2 uv; - float face; - if (a.x >= a.y && a.x >= a.z) { uv = sdir.yz / a.x; face = sdir.x > 0.0 ? 0.0 : 1.0; } - else if (a.y >= a.z) { uv = sdir.xz / a.y; face = sdir.y > 0.0 ? 2.0 : 3.0; } - else { uv = sdir.xy / a.z; face = sdir.z > 0.0 ? 4.0 : 5.0; } - float2 g = uv * STAR_CELLS; - float3 id = float3(floor(g), face); - if (hash13(id) < STAR_THRESHOLD) return float3(0.0, 0.0, 0.0); // no star in this cell - // Per-star randoms: size, centre (inset so the rotated square stays inside the cell), rotation. - float sz = STAR_HALF * (0.6 + 0.8 * hash13(id + 53.0)); - float inset = sz * 1.5; // keeps the rotated square clear of the cell edge - float2 center = inset + (1.0 - 2.0 * inset) * float2(hash13(id + 11.0), hash13(id + 23.0)); - float2 q = frac(g) - center; - float ang = hash13(id + 91.0) * 6.2831853; // random per-star rotation (vanilla zRot) - float cs = cos(ang), sn = sin(ang); - q = float2(cs * q.x - sn * q.y, sn * q.x + cs * q.y); // GLSL mat2(cs,-sn,sn,cs) * q, written out - float box = max(abs(q.x), abs(q.y)); // Chebyshev distance → square - float star = 1.0 - smoothstep(sz * 0.6, sz, box); // soft-edged square (helps DLSS stability) - if (star <= 0.0) return float3(0.0, 0.0, 0.0); - float bright = 0.55 + 0.45 * hash13(id + 71.0); // mild per-star brightness variation - star *= bright * min(dir.y * 3.0, 1.0) * max(0.0, 1.0 - pow(abs(VdotS) * 1.002, 100.0)); - // Per-star colour temperature: hash picks a spot on a warm-orange ↔ blue-white ramp (real star - // populations). Average luminance close to the old uniform constant so night exposure doesn't shift. - float3 starCol = lerp(float3(0.78, 0.60, 0.42), float3(0.50, 0.62, 0.95), hash13(id + 137.0)); - return 0.2 * star * starCol * starBrightness; -} - -[shader("miss")] -void main(inout Payload payload) { - WorldPush worldPush = ConstPtr(pc.worldPushAddr)[0]; - float3 dir = normalize(WorldRayDirection()); - float day = worldPush.sunDir.w; - float3 sd = worldPush.sunDir.xyz; - float SdotU = sd.y; - float VdotS = dot(dir, sd); - float sunUp = clamp((SdotU + 0.0625) / 0.125, 0.0, 1.0); // 0 well below horizon .. 1 above - float starBrightness = worldPush.lightRadiance.w; - bool showCelestial = (payload.flags & PAYLOAD_SHOW_CELESTIAL) != 0u; - - // Physically-based in-scattering: blue zenith, bright/desaturated horizon, warm Mie halo + red - // sunset all fall out of the Rayleigh/Mie/ozone march. At night the sun is below the horizon so the - // light march is blocked → near-zero; a faint night ambient + the rotating starfield sit underneath. - // - // Soft horizon: below-horizon rays used to march into the planet-surface clip, drawing a hard fixed - // line across the sky. Instead, march the ray flattened onto the horizon (same azimuth, y=0) so the - // gradient continues seamlessly downward, with an exponential falloff so it settles into darkness - // instead of a uniform bright band. - float3 mdir = dir; - float downFade = 1.0; - if (dir.y < 0.0) { - float2 flat2 = float2(dir.x, dir.z); - float fl = length(flat2); - mdir = fl > 1.0e-4 ? float3(flat2.x / fl, 0.0, flat2.y / fl) : float3(1.0, 0.0, 0.0); - downFade = exp(dir.y * 3.0); - } - float3 col = atmosphere(mdir, sd); - // Luminance-preserving vibrance on the in-scatter only (see SKY_SATURATION). - float lum = dot(col, float3(0.2126, 0.7152, 0.0722)); - col = max(lerp(float3(lum, lum, lum), col, SKY_SATURATION), float3(0.0, 0.0, 0.0)); - float up = clamp(dir.y, 0.0, 1.0); - float3 nightAmbient = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up); - col += nightAmbient * (1.0 - day); - col *= downFade; - col += stars(dir, starBrightness, VdotS, worldPush.celestial); - - // Sun & moon discs — sampled from the vanilla celestials atlas, gated to rays that haven't already - // accounted for the light via diffuse NEE. squareBody gives the body-local [-1,1] coord; the - // sprite's own texel coverage (alpha) shapes the disc. - if (showCelestial) { - float2 local; - squareBody(dir, sd, SUN_DISC_HALF_ANGLE, worldPush.celestial.xyz, local); - if (all(abs(local) < float2(1.0, 1.0))) { - float2 uv = lerp(worldPush.sunUv.xy, worldPush.sunUv.zw, local * 0.5 + 0.5); - float3 t = celestialsAtlas.SampleLevel(uv, 0.0).rgb; - // The vanilla sun sprite bakes a soft glow gradient into its texels; scaling that raw to HDR - // makes a huge ring. Raise the texture luminance to a high power — only the bright core - // survives, the halo collapses to ~0. - float core = pow(clamp(dot(t, t) * 0.45, 0.0, 1.0), 6.0); - // transmittanceToSpace reddens+dims the disc through the sunset on the sky's own curve. - col += SUN_DISC_RADIANCE * transmittanceToSpace(dir) * core * sunUp; - } - // Same horizon gate as the sun: with the soft-horizon change, below-horizon rays no longer clip - // at the planet, so without this the moon disc would stay visible after it set. - float moonUp = clamp((worldPush.moonDir.y + 0.0625) / 0.125, 0.0, 1.0); - squareBody(dir, worldPush.moonDir.xyz, MOON_DISC_HALF_ANGLE, worldPush.celestial.xyz, local); - if (all(abs(local) < float2(1.0, 1.0)) && moonUp > 0.0) { - float2 uv = lerp(worldPush.moonUv.xy, worldPush.moonUv.zw, local * 0.5 + 0.5); - float3 t = celestialsAtlas.SampleLevel(uv, 0.0).rgb; - // Moon: a smoothstep contrast ramp suppresses the faint halo while keeping the lit phase - // shape from the texture. The transmittance tint gives a warm amber moon at moonrise/set. - float m = smoothstep(0.0, 1.0, min(length(t), 1.0)) * 1.3; - col += MOON_DISC_RADIANCE * transmittanceToSpace(dir) * t * m * pow2(1.0 - day) * moonUp; - } - } - - // albedo is a half3 lane, so the sky's HDR radiance is clamped to half's finite range rather than - // allowed to round up to +inf and propagate as NaN through the path throughput. This is a guard, not - // a correction: the brightest term here is SUN_DISC_RADIANCE at 24.0, far under the 65504 ceiling. - payload.albedo = half3(clamp(col, float3(0.0, 0.0, 0.0), float3(HALF_MAX, HALF_MAX, HALF_MAX))); - payload.hitT = -1.0; - payload.normal = half3(0.0h, 0.0h, 0.0h); - payload.motionPrev = half3(0.0h, 0.0h, 0.0h); - payload.f0 = half3(0.0h, 0.0h, 0.0h); - 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; -} diff --git a/shaders/world/world_layout_probe.slang b/shaders/world/world_layout_probe.slang deleted file mode 100644 index 60bffc2b..00000000 --- a/shaders/world/world_layout_probe.slang +++ /dev/null @@ -1,27 +0,0 @@ -// Build-time reflection probe only. This file is not packaged as a runtime shader. -// Exposing WorldPush as a structured-buffer element makes Slang's JSON reflection include the -// complete Std430DataLayout: field types, byte offsets, total size, matrix mode, and array counts. -import world_common; - -struct WorldPushLayoutProbe { - WorldPush values[2]; // array stride is the complete, tail-padded WorldPush byte size -}; - -struct MaterialHeaderLayoutProbe { - MaterialHeader values[2]; // array stride is the complete, tail-padded MaterialHeader byte size -}; - -[[vk::binding(0, 0)]] StructuredBuffer worldPushLayoutProbe; -[[vk::binding(1, 0)]] StructuredBuffer materialHeaderLayoutProbe; -[[vk::push_constant]] WorldPushConstants pushConstantsLayoutProbe; - -[shader("compute")] -[numthreads(1, 1, 1)] -void main(uint3 id : SV_DispatchThreadID) { - // Keep the parameter reachable without requiring a writable output resource. - if (id.x == 0xFFFFFFFFu) { - float sink = worldPushLayoutProbe[0].values[0].invViewProj[0][0] - + materialHeaderLayoutProbe[0].values[0].params.x - + float(pushConstantsLayoutProbe.frameIndex); - } -} diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index aac067d3..0088a319 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -58,8 +58,8 @@ public static void ensureRegistered() { Object[] touch = { Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.EntityTextures.MAX_TEXTURES, Rt.DlssRr.ENABLED, Rt.Fg.ENABLED, - Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.FrameStats.ENABLED, - Rt.Hdr.ENABLED, Ngx.PATH, + Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Tonemap.GAMMA, Rt.FrameStats.ENABLED, + Rt.Screenshots.EXR_ENABLED, Rt.Hdr.ENABLED, Ngx.PATH, }; } @@ -83,31 +83,29 @@ public static synchronized void save() { private static void writeComments() { FILE.setComment("enabled", - " Caustica RT renderer configuration.\n" - + " A matching -Dcaustica.* system property overrides the value below."); + " Caustica ray-tracing settings. A matching -Dcaustica.* system property overrides a value here."); FILE.setComment("terrain", - " Render-thread terrain work is bounded by dispatch/result counts per streaming pass.\n" - + " Buffer fill and BLAS/OMM preparation run on workers. max-inflight-sections bounds\n" - + " the complete snapshot -> worker -> GPU build -> publication lifecycle."); + " Controls terrain loading. Higher limits can load terrain faster but use more CPU and GPU time."); FILE.setComment("frame-generation", - " DLSS Frame Generation. Default off; gated additionally by hardware/driver availability.\n" - + " multi-frame-count: frames generated per rendered frame (1 = 2x, 2 = 3x, ...), clamped\n" - + " at runtime to the driver's reported DLSSG.MultiFrameCountMax."); + " DLSS Frame Generation. Requires supported NVIDIA hardware and drivers.\n" + + " multi-frame-count sets generated frames per rendered frame (1 = 2x, 2 = 3x, ...)."); FILE.setComment("reflex", - " NVIDIA Reflex (VK_NV_low_latency2). Default off; gated additionally by device support.\n" - + " minimum-interval-us: 0 = no framerate cap (Reflex just paces submission)."); + " NVIDIA Reflex. Requires supported NVIDIA hardware and drivers.\n" + + " minimum-interval-us controls frame limiting; 0 disables the limit."); FILE.setComment("lights", - " RIS direct lighting from block emitters (torches, glowstone, lava, ...): per diffuse\n" - + " vertex, resample ris-candidates power-weighted proposals and spend one shadow ray on\n" - + " the survivor. ris-candidates = 0 disables it entirely (emitters just gather on direct\n" - + " hit, same as with no NEE). Power-weighted sampling and the local per-section light\n" - + " grid are always active whenever RIS is on. min-fill-ratio drops emissive footprints\n" - + " below that fraction of their bounding rectangle (speckle/sparse crossed planes), so\n" - + " only reasonably compact glows become lights. stats/dump/dump-radius are debug logging."); + " 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."); + FILE.setComment("exposure", + " Controls automatic exposure. manual-ev sets exposure in manual mode and adjusts it in auto mode.\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 (ST.2084/PQ). When enabled the swapchain is created in PQ automatically\n" - + " (falls back to SDR if the surface doesn't advertise it). paper-white-nits / peak-nits\n" - + " drive the scene-HDR -> display mapping."); + " HDR display output. Requires operating system and display support.\n" + + " ui-nits controls UI brightness; peak-nits must be 500, 1000, 2000, or 4000."); + FILE.setComment("screenshots", + " exr-enabled saves an ACEScg EXR beside the normal F2 PNG while ray tracing is active."); } private static Path resolveConfigPath() { @@ -537,12 +535,10 @@ public static final class Composite { clampedInt("caustica.rt.maxBounces", "composite.max-bounces", 4, 2, 8); public static final BooleanSetting WATER_WAVES = bool("caustica.rt.waterWaves", "composite.water-waves", true); - public static final FloatSetting SUN_ANGULAR_RADIUS = - radians("caustica.rt.sunAngularRadius", "composite.sun-angular-radius-deg", 0.6f); - public static final FloatSetting MOON_ANGULAR_RADIUS = - radians("caustica.rt.moonAngularRadius", "composite.moon-angular-radius-deg", 1.5f); - public static final FloatSetting SUN_NOON_SOUTH_TILT = - radians("caustica.rt.sunNoonSouthDeg", "composite.sun-noon-south-tilt-deg", 30.0f); + // Sun/moon angular radii and the noon south tilt moved into the versioned look package + // (look.json "sky"): they shape the sky alongside the exposure curve, the LMT and the + // photometric anchors that were already authored there, and splitting them across two + // sources meant a package could not fully describe its own look. public static final FloatSetting JITTER_SIGN_X = finiteFloat("caustica.rt.jitterSignX", "composite.jitter-sign-x", 1.0f); public static final FloatSetting JITTER_SIGN_Y = @@ -659,7 +655,14 @@ private Overlay() { public static final class DlssRr { public static final BooleanSetting ENABLED = bool("caustica.rt.dlssRr", "dlss-rr.enabled", true); public static final IntSetting PRESET = intValue("caustica.rt.dlssRr.preset", "dlss-rr.preset", 0); - public static final IntSetting QUALITY = intValue("caustica.rt.dlssRr.quality", "dlss-rr.quality", 0); + + // NVSDK_NGX_PerfQuality_Value. Per NVIDIA's DLSS-RR programming guide, Ray Reconstruction only + // supports Performance(0), Balanced(1), Quality(2), Ultra-Performance(3), and DLAA(5) — + // Ultra Quality(4) is not a valid PerfQualityValue for RR (its optimal-settings query returns a + // zeroed render size for it) and is deliberately excluded here. + public static final List QUALITY_STEPS = List.of(3, 0, 1, 2, 5); + public static final IntSetting QUALITY = + intChoice("caustica.rt.dlssRr.quality", "dlss-rr.quality", 0, QUALITY_STEPS); private DlssRr() { } @@ -677,9 +680,8 @@ private Fg() { /** * NVIDIA Reflex ({@code VK_NV_low_latency2}). Default off; gated additionally by device support. - * Phase 0 (extension + capability probe only, see {@code RtDeviceBringup}/{@code RtReflex}) — the - * per-frame sleep call + latency markers + the swapchain {@code VkSwapchainLatencyCreateInfoNV} the - * spec requires for {@code vkSetLatencySleepModeNV} to take effect land in a later phase. + * The renderer configures the swapchain latency mode, paces frames with {@code vkLatencySleepNV}, + * and emits simulation, render-submit, and present latency markers. */ public static final class Reflex { public static final BooleanSetting ENABLED = bool("caustica.rt.reflex", "reflex.enabled", false); @@ -693,33 +695,106 @@ private Reflex() { } public static final class Exposure { + // Control points are measured-EV100 : compensation-EV. + // Rendered median (log) = log2(key) + comp(evScene), so comp IS the rendered offset in EV + // from the noon reference. + // + // Fitted to measured in-game EV100 and the current emissive baseline: + // noon sand +17.45 -> -0.01 renders at key, the reference + // noon blue sky +16.50 -> -0.17 + // daylight shade +7.00 -> -1.82 + // lit night room +7.00 -> -1.82 (same measured luminance as daylight shade) + // night street +1.50 -> -3.01 + // starlit sky -8.00 -> -5.00 (floor) + // Effective slope is 0.79 / 0.78 / 0.83 across the three segments, compressing 25 EV of + // scene range to 5.0 EV of rendered difference. + // + // Daylight shade and a lit interior at night measure the SAME (~EV 7), so no luminance-only + // curve can separate them -- what does is the asymmetric temporal adaptation above, which + // holds a low exposure when you step from noon sun into shade. That is a real limit of this + // controller, not a tuning miss. public static final StringSetting MODE = string("caustica.rt.exposure.mode", "exposure.mode", "auto", Exposure::sanitizeMode); public static final FloatSetting MANUAL_EV = - finiteFloat("caustica.rt.exposure.manualEv", "exposure.manual-ev", 0.0f); + clampedFloat("caustica.rt.exposure.manualEv", "exposure.manual-ev", + 0.0f, -15.0f, 15.0f); public static final FloatSetting KEY = exposureScale("caustica.rt.exposure.key", "exposure.key", 0.18f); - public static final FloatSetting MIN_EV = - finiteFloat("caustica.rt.exposure.minEv", "exposure.min-ev", -1.5f); - public static final FloatSetting MAX_EV = - finiteFloat("caustica.rt.exposure.maxEv", "exposure.max-ev", 4.0f); - public static final FloatSetting ADAPT_UP = - exposureScale("caustica.rt.exposure.adaptUp", "exposure.adapt-up", 0.12f); - public static final FloatSetting ADAPT_DOWN = - exposureScale("caustica.rt.exposure.adaptDown", "exposure.adapt-down", 0.35f); + // Bounds on the ABSOLUTE exposure multiplier. Sized from what the curve above actually asks + // for at the measured scene extremes: -16.9 EV at noon sand, +3.5 EV at the starlit-sky + // floor. A clamp should be a guard rail, not the controller, so these sit just outside that. + // + // max-ev was +10 and blew out the frame: with 13 EV of headroom above what the curve wants, + // exposure ran away whenever the camera held something very dark, and anything bright + // entering the frame then arrived pre-blown. +5 keeps 1.5 EV over the curve's own demand. + // + // min-ev deliberately does NOT cover a zoomed-in sun (which asks for about -20.8): letting + // the whole frame go black because the sun is in shot is worse than clamping it. The sky + // metering cap already bounds the sun's share, so in practice this only engages on a + // near-full-screen sun. + /** + * Adaptation time constants in seconds, applied in EV space by the resolve. Named for what + * the SCENE did: walking into a dark cave is "darken" (exposure has to rise), stepping back + * out is "brighten". + * + *

Asymmetric on purpose, and in the direction human vision actually works — light + * adaptation takes seconds, dark adaptation takes minutes. Every shipping game compresses + * that, but keeping the sign right is what makes a sunrise read as a sunrise instead of as a + * lens. The names describe the scene change, not the inverse movement of the exposure multiplier. + */ + public static final FloatSetting ADAPT_DARKEN = + exposureScale("caustica.rt.exposure.adaptDarken", "exposure.adapt-darken", 2.0f); + 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); + public static final FloatSetting HIGH_PERCENTILE = + clampedFloat("caustica.rt.exposure.highPercentile", "exposure.high-percentile", 0.95f, 0.0f, 1.0f); + public static final IntSetting STRIDE = + clampedInt("caustica.rt.exposure.stride", "exposure.stride", 2, 1, 8); + public static final FloatSetting CENTER_WEIGHT_SIGMA = + clampedFloat("caustica.rt.exposure.centerWeightSigma", + "exposure.center-weight-sigma", 0.35f, 0.01f, 2.0f); + public static final FloatSetting CENTER_WEIGHT_FLOOR = + clampedFloat("caustica.rt.exposure.centerWeightFloor", + "exposure.center-weight-floor", 0.15f, 0.0f, 1.0f); + public static final FloatSetting SKY_WEIGHT_CAP = + clampedFloat("caustica.rt.exposure.skyWeightCap", + "exposure.sky-weight-cap", 0.25f, 0.0f, 1.0f); + public static final FloatSetting EMISSIVE_WEIGHT_CAP = + clampedFloat("caustica.rt.exposure.emissiveWeightCap", + "exposure.emissive-weight-cap", 0.10f, 0.0f, 1.0f); + /** + * Pre-exposure: raygen multiplies scene radiance by the previous frame's exposure before + * the fp16 write, and the display pass divides it back out, so stored values sit near + * {@code key} instead of spanning the ~26 EV physical photometric units require. The two + * cancel algebraically, so toggling this must not change the + * image; it exists as an A/B switch for exactly that check, and as an escape hatch + * if DLSS-RR ever proves sensitive to its history being at the previous frame's scale. + */ + public static final BooleanSetting PRE_EXPOSURE = + bool("caustica.rt.exposure.preExposure", "exposure.pre-exposure", true); private Exposure() { } public static float minEv() { - return Math.min(MIN_EV.value(), MAX_EV.value()); + return dev.comfyfluffy.caustica.rt.RtLookPackage.current().exposure().minEv(); } public static float maxEv() { - return Math.max(MIN_EV.value(), MAX_EV.value()); + return dev.comfyfluffy.caustica.rt.RtLookPackage.current().exposure().maxEv(); + } + + public static String curve() { + return dev.comfyfluffy.caustica.rt.RtLookPackage.current().exposure().curve(); } + /** + * Sanity bound on an exposure multiplier, not an artistic one. It must remain below the + * 3.8e-6 multiplier requested by {@code -18 EV}; min-ev/max-ev provides the artistic bound. + */ public static float clampScale(float value) { - return Math.clamp(value, 1.0e-4f, 1.0e4f); + return Math.clamp(value, 1.0e-8f, 1.0e8f); } private static String sanitizeMode(String value) { @@ -731,6 +806,16 @@ private static String sanitizeMode(String value) { } return "auto"; } + + } + + /** Scene-referred look transform and baked SDR/HDR ACES display transforms. */ + public static final class Tonemap { + public static final FloatSetting GAMMA = + clampedFloat("caustica.rt.tonemap.gamma", "tonemap.gamma", 1.0f, 0.1f, 5.0f); + + private Tonemap() { + } } /** Render-frame timing + hitch logging. See {@code RtFrameStats}. */ @@ -741,6 +826,15 @@ private FrameStats() { } } + /** Optional high-dynamic-range 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); + + private Screenshots() { + } + } + /** Startup Vulkan inventory + {@code VK_EXT_device_fault} reporting on device loss. See {@code VulkanDiagnostics}. */ public static final class Diagnostics { /** Heavy driver-side crash diagnostics: vendor diagnostics-config extensions (shader debug @@ -760,45 +854,62 @@ 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 nit values drive the scene-HDR → display mapping: SDR paper white maps to - * {@code paperWhiteNits}, and highlights roll off toward {@code peakNits}. + * 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. */ public static final class Hdr { public static final BooleanSetting ENABLED = bool("caustica.rt.hdr", "hdr.enabled", false); - public static final FloatSetting PAPER_WHITE_NITS = - clampedFloat("caustica.rt.hdr.paperWhiteNits", "hdr.paper-white-nits", 200.0f, 80.0f, 500.0f); - public static final FloatSetting PEAK_NITS = - clampedFloat("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000.0f, 80.0f, 5000.0f); - - // Snapshot of ENABLED as resolved at startup (system property / config file), before any - // in-session edit from the options screen. The swapchain's pixel format (PQ vs SDR) is fixed - // at surface-creation time, so flipping ENABLED later cannot change what's actually presented - // until a restart — every runtime/rendering check reads this frozen value via enabled(), - // never ENABLED directly, so the live toggle is a no-op for the current session. - private static final boolean ENABLED_AT_STARTUP = ENABLED.value(); + 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 IntSetting PEAK_NITS = + intChoice("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000, PEAK_NITS_STEPS); + + // 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. + private static volatile boolean SWAPCHAIN_PQ_AVAILABLE = false; + private static volatile boolean SWAPCHAIN_PQ_ACTIVE = false; private Hdr() { } - /** Whether the HDR display path (world HDR + PQ swapchain + UI overlay) is active this session. */ - public static boolean enabled() { - return ENABLED_AT_STARTUP; + public static void setSwapchainPqAvailable(boolean available) { + SWAPCHAIN_PQ_AVAILABLE = available; } - /** Whether {@link #ENABLED} has been changed since startup and needs a restart to take effect. */ - public static boolean pendingRestart() { - return ENABLED.value() != ENABLED_AT_STARTUP; + public static void setSwapchainPqActive(boolean active) { + SWAPCHAIN_PQ_ACTIVE = active; } - /** Absolute nits SDR paper white maps to in the PQ encode (ST.2084 is referenced to 10000 nits). */ - public static float paperWhiteNits() { - return PAPER_WHITE_NITS.value(); + /** + * Whether this session's surface can create a PQ swapchain, independent of which format the + * current swapchain uses. + */ + public static boolean swapchainPqAvailable() { + return SWAPCHAIN_PQ_AVAILABLE; } - /** Highlight headroom above paper white, in paper-white-referred units ({@code >= 1}). */ - public static float headroom() { - return Math.max(1.0f, PEAK_NITS.value() / Math.max(1.0f, PAPER_WHITE_NITS.value())); + /** Whether the currently configured swapchain is HDR10/PQ rather than native SDR. */ + public static boolean swapchainPqActive() { + return SWAPCHAIN_PQ_ACTIVE; + } + + /** + * Whether the HDR display path (world HDR + PQ swapchain + UI overlay) should be active this + * frame. The option invalidates the surface configuration after changing {@link #ENABLED}; + * the ordinary resize/configure path recreates the swapchain in SDR or PQ. + */ + public static boolean enabled() { + return SWAPCHAIN_PQ_ACTIVE && ENABLED.value(); } + + /** Absolute brightness assigned to SDR-authored UI in the PQ output. */ + public static float uiNits() { + return UI_NITS.value(); + } + } } @@ -829,6 +940,10 @@ private static IntSetting intAtLeast(String key, String tomlPath, int fallback, return new IntSetting(key, tomlPath, fallback, v -> Math.max(min, v)); } + private static IntSetting intChoice(String key, String tomlPath, int fallback, List choices) { + return new IntSetting(key, tomlPath, fallback, v -> choices.contains(v) ? v : fallback); + } + 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)); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java index eff0c5ea..c00b9e6f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java @@ -23,6 +23,12 @@ public final class CausticaClient implements ClientModInitializer { public void onInitializeClient() { CausticaMod.LOGGER.info("Caustica client initialized"); + // Class-init runs DebugScreenEntries.register(...) via its ID field; touching the class here + // makes the entry discoverable in F3's entry list. Off by default -- the player opts in the + // same way as any other optional vanilla entry (e.g. GPU utilization). + @SuppressWarnings("unused") + Object registerExposureDebugEntry = RtExposureDebugEntry.ID; + // 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 -> { @@ -41,7 +47,7 @@ public void onInitializeClient() { } } - // P2: once RT is up, keep section residency synced to vanilla's loaded chunks around + // Once RT is up, keep section residency synced to vanilla's loaded chunks around // the player — builds newly-in-range sections, frees out-of-range ones, per tick. if (rtInitDone) { RtContext ctx = RtContext.currentOrNull(); @@ -67,6 +73,7 @@ public void onInitializeClient() { // world-unique). Resource reloads do NOT fire this; that path is handled separately. InvalidateRenderStateCallback.EVENT.register(() -> { RtTerrain.requestFullClear(); + RtComposite.INSTANCE.resetExposureHistory(); RtComposite.INSTANCE.resetFailureLatch(); // F3+A doubles as manual RT recovery after a latched failure }); diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtExposureDebugEntry.java b/src/main/java/dev/comfyfluffy/caustica/client/RtExposureDebugEntry.java new file mode 100644 index 00000000..03e3b7d2 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtExposureDebugEntry.java @@ -0,0 +1,45 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.pipeline.RtExposure; +import net.minecraft.client.gui.components.debug.DebugScreenDisplayer; +import net.minecraft.client.gui.components.debug.DebugScreenEntries; +import net.minecraft.client.gui.components.debug.DebugScreenEntry; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.LevelChunk; +import org.jspecify.annotations.Nullable; + +/** + * F3 line for the RT auto-exposure controller. Off by default like any other optional entry + * (Vanilla's own per-player {@code debug-profile.json}, toggled through the F3 entry list) -- + * registration only makes it available, it does not turn it on. + * + *

{@code RtExposure.debugSummaryLine()} owns the displayed controller values. + */ +public final class RtExposureDebugEntry implements DebugScreenEntry { + public static final Identifier ID = DebugScreenEntries.register( + Identifier.fromNamespaceAndPath("caustica", "rt_exposure"), new RtExposureDebugEntry()); + + @Override + public void display(DebugScreenDisplayer displayer, @Nullable Level serverOrClientLevel, + @Nullable LevelChunk clientChunk, @Nullable LevelChunk serverChunk) { + RtComposite composite = RtComposite.INSTANCE; + if (composite.hasFailed()) { + return; // vanilla is rendering this frame; the exposure state is stale/irrelevant. + } + RtExposure exposure = composite.exposure(); + if (!exposure.ready()) { + return; // RT hasn't produced an exposure value yet (no world, or still bringing up). + } + String line = exposure.debugSummaryLine(); + if (line != null) { + displayer.addLine(line); + } + } + + @Override + public boolean isAllowed(boolean reducedDebugInfo) { + return !reducedDebugInfo; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtScreenshotExporter.java b/src/main/java/dev/comfyfluffy/caustica/client/RtScreenshotExporter.java new file mode 100644 index 00000000..041060b2 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtScreenshotExporter.java @@ -0,0 +1,66 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.rt.RtComposite; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Util; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Consumer; + +/** Adds a residual-exposed scene-linear ACEScg EXR beside vanilla's ordinary F2 PNG. */ +public final class RtScreenshotExporter { + private RtScreenshotExporter() { + } + + /** + * Exports the RT image and returns the exact filename vanilla should use for the paired PNG. + * Returns {@code null} only when a filename cannot be reserved, allowing the caller to fall back to + * vanilla's ordinary auto-naming path. + */ + public static String exportPaired(File workDir, Consumer callback) { + Path screenshotDirectory = workDir.toPath().resolve("screenshots"); + try { + Files.createDirectories(screenshotDirectory); + Path output = nextPairedPath(screenshotDirectory); + if (!RtComposite.INSTANCE.exportLatestResidualExposureExr(output)) { + return pngName(output); + } + File file = output.toFile().getAbsoluteFile(); + Component link = Component.literal(file.getName()) + .withStyle(ChatFormatting.UNDERLINE) + .withStyle(style -> style.withClickEvent(new ClickEvent.OpenFile(file))); + callback.accept(Component.literal("Saved residual-exposure ACEScg EXR: ").append(link)); + CausticaMod.LOGGER.info("Saved residual-exposure ACEScg screenshot to {}", file); + return pngName(output); + } catch (Exception e) { + CausticaMod.LOGGER.warn("Couldn't save residual-exposure ACEScg screenshot", e); + callback.accept(Component.literal("Couldn't save Caustica EXR: " + e.getMessage()) + .withStyle(ChatFormatting.RED)); + return null; + } + } + + private static String pngName(Path exrPath) { + String name = exrPath.getFileName().toString(); + return name.substring(0, name.length() - ".exr".length()) + ".png"; + } + + private static Path nextPairedPath(Path directory) { + String base = Util.getFilenameFormattedDateTime(); + int count = 1; + while (true) { + String suffix = count == 1 ? "" : "_" + count; + Path candidate = directory.resolve(base + suffix + ".exr"); + Path vanillaPng = directory.resolve(base + suffix + ".png"); + if (!Files.exists(candidate) && !Files.exists(vanillaPng)) { + return candidate; + } + count++; + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 3ef0b06f..8fa9206f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -6,8 +6,10 @@ import dev.comfyfluffy.caustica.CausticaConfig.FloatSetting; import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; import dev.comfyfluffy.caustica.CausticaConfig.StringSetting; +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.network.chat.Component; @@ -29,23 +31,33 @@ public final class RtVideoOptions { private RtVideoOptions() { } - /** Runtime-tunable RT options, in display order. Paired two-per-row by {@code OptionsList.addSmall}. */ + /** + * 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 + * PQ-capable ({@code CausticaConfig.Rt.Hdr.swapchainPqAvailable()}) — offering a toggle/sliders that + * can never do anything is worse than not showing them, and unlike most settings here this one is + * fixed by hardware/OS/compositor at surface-creation time. The current swapchain may still be native + * SDR; changing the toggle invalidates its configuration and recreates it in the selected format. + */ public static OptionInstance[] runtimeOptions() { - return new OptionInstance[] { + List> options = new ArrayList<>(List.of( exposureMode(), manualEv(), + gamma(), spp(), maxBounces(), - sunSize(), entities(), particles(), waterWaves(), - dlssQuality(), - hdrEnabled(), - hdrPaperWhite(), - hdrPeak(), - debugView(), - }; + dlssQuality() + )); + if (CausticaConfig.Rt.Hdr.swapchainPqAvailable()) { + options.add(hdrEnabled()); + options.add(hdrUiBrightness()); + options.add(hdrPeak()); + } + options.add(debugView()); + return options.toArray(OptionInstance[]::new); } private static OptionInstance exposureMode() { @@ -72,11 +84,23 @@ private static OptionInstance manualEv() { return Options.genericValueLabel(caption, Component.literal(sign + String.format(Locale.ROOT, "%.1f EV", ev))); }, - new OptionInstance.IntRange(-50, 50), - Math.clamp(Math.round(setting.value() * 10.0f), -50, 50), + new OptionInstance.IntRange(-150, 150), + Math.clamp(Math.round(setting.value() * 10.0f), -150, 150), tenths -> setting.set(tenths / 10.0f)); } + private static OptionInstance gamma() { + FloatSetting setting = CausticaConfig.Rt.Tonemap.GAMMA; + return new OptionInstance<>( + "caustica.options.rt.gamma", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.gamma.tooltip")), + (caption, hundredths) -> Options.genericValueLabel(caption, + Component.literal(String.format(Locale.ROOT, "%.2f", hundredths / 100.0f))), + new OptionInstance.IntRange(50, 150), + Math.clamp(Math.round(setting.value() * 100.0f), 50, 150), + hundredths -> setting.set(hundredths / 100.0f)); + } + private static OptionInstance spp() { IntSetting setting = CausticaConfig.Rt.Composite.SPP; return new OptionInstance<>( @@ -99,19 +123,6 @@ private static OptionInstance maxBounces() { setting::set); } - private static OptionInstance sunSize() { - // Stored in radians via the degrees->radians sanitizer; the slider works in tenths of a degree. - FloatSetting setting = CausticaConfig.Rt.Composite.SUN_ANGULAR_RADIUS; - int initialTenths = Math.clamp(Math.round((float) Math.toDegrees(setting.value()) * 10.0f), 1, 50); - return new OptionInstance<>( - "caustica.options.rt.sunSize", - OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.sunSize.tooltip")), - (caption, tenths) -> Options.genericValueLabel(caption, Component.literal(String.format("%.1f°", tenths / 10.0))), - new OptionInstance.IntRange(1, 50), - initialTenths, - tenths -> setting.set(tenths / 10.0f)); - } - private static OptionInstance entities() { return bool("caustica.options.rt.entities", CausticaConfig.Rt.Entities.ENABLED); } @@ -124,50 +135,61 @@ private static OptionInstance waterWaves() { return bool("caustica.options.rt.waterWaves", CausticaConfig.Rt.Composite.WATER_WAVES); } - // NVSDK_NGX_PerfQuality_Value, ordered performance -> quality for the slider. Per NVIDIA's DLSS-RR - // programming guide, Ray Reconstruction only supports Performance(0), Balanced(1), Quality(2), - // Ultra-Performance(3), and DLAA(5) — Ultra Quality(4) is not a valid PerfQualityValue for RR (its - // optimal-settings query returns a zeroed render size for it) and is deliberately excluded here. - private static final List DLSS_QUALITY_ORDER = List.of(3, 0, 1, 2, 5); - private static OptionInstance dlssQuality() { IntSetting setting = CausticaConfig.Rt.DlssRr.QUALITY; - int initialQuality = DLSS_QUALITY_ORDER.contains(setting.value()) ? setting.value() : 0; - int initialPosition = DLSS_QUALITY_ORDER.indexOf(initialQuality); + List steps = CausticaConfig.Rt.DlssRr.QUALITY_STEPS; + int initialQuality = steps.contains(setting.value()) ? setting.value() : 0; + int initialPosition = steps.indexOf(initialQuality); return new OptionInstance<>( "caustica.options.rt.dlssQuality", OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.dlssQuality.tooltip")), (caption, position) -> Options.genericValueLabel(caption, - Component.translatable("caustica.options.rt.dlssQuality." + DLSS_QUALITY_ORDER.get(position))), - new OptionInstance.IntRange(0, DLSS_QUALITY_ORDER.size() - 1), + Component.translatable("caustica.options.rt.dlssQuality." + steps.get(position))), + new OptionInstance.IntRange(0, steps.size() - 1), initialPosition, - position -> setting.set(DLSS_QUALITY_ORDER.get(position))); + position -> setting.set(steps.get(position))); } private static OptionInstance hdrEnabled() { - return bool("caustica.options.rt.hdr", CausticaConfig.Rt.Hdr.ENABLED); - } - - private static OptionInstance hdrPaperWhite() { - FloatSetting setting = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; + BooleanSetting setting = CausticaConfig.Rt.Hdr.ENABLED; + return OptionInstance.createBoolean( + "caustica.options.rt.hdr", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdr.tooltip")), + setting.value(), + enabled -> { + if (setting.value() != enabled) { + setting.set(enabled); + // Reuse the framebuffer-resize path at the next safe frame boundary. GpuSurface + // refuses configure() while an image is acquired, so doing it directly here is unsafe. + Minecraft.getInstance().invalidateSurfaceConfiguration(); + } + }); + } + + private static OptionInstance hdrUiBrightness() { + FloatSetting setting = CausticaConfig.Rt.Hdr.UI_NITS; return new OptionInstance<>( - "caustica.options.rt.hdrPaperWhite", - OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPaperWhite.tooltip")), + "caustica.options.rt.hdrUiBrightness", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrUiBrightness.tooltip")), (caption, nits) -> Options.genericValueLabel(caption, Component.literal(nits + " nits")), - new OptionInstance.IntRange(80, 1000), - Math.clamp(Math.round(setting.value()), 80, 1000), + new OptionInstance.IntRange(80, 500), + Math.clamp(Math.round(setting.value()), 80, 500), nits -> setting.set(nits.floatValue())); } + // Each step selects a baked ACES HDR mastering target. Changes take effect on the next frame. private static OptionInstance hdrPeak() { - FloatSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS; + 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); return new OptionInstance<>( "caustica.options.rt.hdrPeak", OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPeak.tooltip")), - (caption, nits) -> Options.genericValueLabel(caption, Component.literal(nits + " nits")), - new OptionInstance.IntRange(80, 10000), - Math.clamp(Math.round(setting.value()), 80, 10000), - nits -> setting.set(nits.floatValue())); + (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))); } private static OptionInstance debugView() { @@ -178,8 +200,8 @@ 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), Codec.INT), - Math.clamp(setting.value(), 0, 7), + new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), Codec.INT), + Math.clamp(setting.value(), 0, 9), setting::set); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/WorldRenderScaler.java b/src/main/java/dev/comfyfluffy/caustica/client/WorldRenderScaler.java index 4c8c191a..e69a46f2 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/WorldRenderScaler.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/WorldRenderScaler.java @@ -9,9 +9,8 @@ * {@link RtComposite}) runs once at the before-hand seam, before vanilla's pre-GUI depth clear, so the * hand and HUD draw at native resolution on top. * - *

This used to host the FSR/DLSS-SR low-res render-scale path; that has been removed — the RT - * renderer owns reconstruction via DLSS Ray Reconstruction. With {@code -Dcaustica.rt=false} this is an - * inert passthrough. + *

The RT renderer owns reconstruction through DLSS Ray Reconstruction. With + * {@code -Dcaustica.rt=false} this is an inert passthrough. */ public final class WorldRenderScaler { public static final WorldRenderScaler INSTANCE = new WorldRenderScaler(); diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GlxMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GlxMixin.java index 92ba4e67..65f0c974 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/GlxMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GlxMixin.java @@ -1,7 +1,6 @@ package dev.comfyfluffy.caustica.mixin; import com.mojang.blaze3d.platform.GLX; -import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; import java.util.Locale; import java.util.function.LongSupplier; @@ -12,7 +11,15 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -/** Selects the native Wayland window system required for Linux HDR presentation. */ +/** + * Always selects the native Wayland window system on Linux, unconditionally — not gated on whether HDR + * is currently enabled. GLFW's platform is chosen once, before any window/surface exists, which is before + * {@code CausticaConfig.Rt.Hdr.enabled()} can mean anything actionable and long before + * {@code VulkanGpuSurfaceMixin} can know whether the resulting surface is PQ-capable. Since HDR is now a + * live runtime toggle (see {@code CausticaConfig.Rt.Hdr.swapchainPqAvailable}), the window has to already + * be running on whatever backend can expose an HDR-capable surface before the toggle is ever flipped — + * there is no "switch to Wayland later" once GLFW has initialized on X11. + */ @Mixin(GLX.class) public abstract class GlxMixin { @Inject( @@ -22,7 +29,7 @@ public abstract class GlxMixin { target = "Lorg/lwjgl/glfw/GLFW;glfwInit()Z", shift = At.Shift.BEFORE)) private static void caustica$preferWaylandForHdr(CallbackInfoReturnable cir) { - if (!CausticaConfig.Rt.Hdr.enabled() || !caustica$isLinux()) { + if (!caustica$isLinux()) { return; } @@ -49,7 +56,7 @@ public abstract class GlxMixin { @Inject(method = "_initGlfw", at = @At("RETURN")) private static void caustica$logHdrWindowSystem(CallbackInfoReturnable cir) { - if (!CausticaConfig.Rt.Hdr.enabled() || !caustica$isLinux()) { + if (!caustica$isLinux()) { return; } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java index c2690529..cb5da73f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java @@ -14,7 +14,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** - * Reflex Phase 1b: the per-frame sleep call must run at the very start of the frame, before input + * The Reflex per-frame sleep call must run at the very start of the frame, before input * sampling/simulation. {@link Minecraft#runTick} is Minecraft's per-loop-iteration entry point (called once * per {@code while (running)} iteration in {@link Minecraft#run()}, right after * {@code RenderSystem.pollEvents()}), so its HEAD is the earliest hookable point in this codebase for that diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java deleted file mode 100644 index 4c989722..00000000 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.comfyfluffy.caustica.mixin; - -import com.llamalad7.mixinextras.injector.ModifyReturnValue; -import dev.comfyfluffy.caustica.CausticaConfig; -import net.minecraft.client.Options; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; - -/** - * Vanilla's video-settings screen already shows a red "restart required" banner - * ({@code Options.isRestartRequiredToApplyVideoSettings}) when the Graphics API or exclusive-fullscreen - * choice differs from what was active at startup. Our HDR toggle has the exact same constraint — the - * swapchain's pixel format is fixed at surface-creation time — so this folds it into the same check, - * reusing vanilla's existing banner instead of building a parallel one. - */ -@Mixin(Options.class) -public abstract class OptionsMixin { - @ModifyReturnValue(method = "isRestartRequiredToApplyVideoSettings", at = @At("RETURN")) - private boolean caustica$alsoRestartForHdr(boolean original) { - return original || CausticaConfig.Rt.Hdr.pendingRestart(); - } -} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java new file mode 100644 index 00000000..c1a7dd7d --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java @@ -0,0 +1,44 @@ +package dev.comfyfluffy.caustica.mixin; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import dev.comfyfluffy.caustica.CausticaConfig; +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. */ +@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( + File workDir, + @Nullable String forceName, + RenderTarget target, + int downscaleFactor, + Consumer callback, + CallbackInfo ci + ) { + 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(); + } + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java index 2fb587bb..eaf2334b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java @@ -11,6 +11,7 @@ import com.mojang.blaze3d.vulkan.init.VulkanFeature; import dev.comfyfluffy.caustica.CausticaMod; import dev.comfyfluffy.caustica.rt.RtDeviceBringup; +import dev.comfyfluffy.caustica.rt.RtHdr; import dev.comfyfluffy.caustica.rt.VulkanDiagnostics; import org.lwjgl.system.MemoryStack; import org.lwjgl.vulkan.VK12; @@ -102,6 +103,7 @@ public abstract class VulkanBackendMixin { } } VulkanDiagnostics.addDeviceFaultExtension(augmented, physicalDevice); + RtHdr.addDeviceExtension(augmented, physicalDevice); RtDeviceBringup.addExtensions(augmented, physicalDevice); args.set(0, augmented); @@ -144,7 +146,7 @@ public abstract class VulkanBackendMixin { } /** - * P0 verification — once the RT-augmented device is created, confirm the RT entry + * Once the RT-augmented device is created, confirm the RT entry * points loaded and log the RT/AS limits. {@code device} is the local assigned just * before {@code createVma} runs. */ diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java index 6a8797ff..a6564e0d 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java @@ -15,7 +15,9 @@ import dev.comfyfluffy.caustica.rt.RtReflex; import it.unimi.dsi.fastutil.longs.LongList; import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.KHRSurface; import org.lwjgl.vulkan.KHRSwapchain; +import org.lwjgl.vulkan.VK10; import org.lwjgl.vulkan.VkAllocationCallbacks; import org.lwjgl.vulkan.VkDevice; import org.lwjgl.vulkan.VkPresentIdKHR; @@ -26,6 +28,7 @@ import org.lwjgl.vulkan.VkSwapchainLatencyCreateInfoNV; 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.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -35,20 +38,19 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import java.nio.IntBuffer; import java.nio.LongBuffer; /** - * HDR Phase 0 capability logging + PQ swapchain selection. + * HDR capability logging and PQ swapchain selection. * *

The {@link VulkanGpuSurface} constructor holds both the live {@code VkSurfaceKHR} and the physical * device, so we enumerate the surface's formats/color spaces there (once) for diagnostics. * - *

When {@code caustica.rt.hdr.pqSwapchain} is on and the surface advertises HDR10_ST2084 (ST.2084/PQ, - * paired with whatever pixel format the surface offers for it — commonly a 10-bit UNORM, but this is - * discovered by scanning the surface's advertised formats rather than assumed), we steer Minecraft's - * swapchain to it: vanilla {@code pickSwapchainSurfaceFormat} only accepts SDR (color space 0, format 37/44) - * and {@code configure} hardcodes {@code imageColorSpace(0)}. We override the picked format and the - * color-space arg. Falls back to the vanilla SDR path when the flag is off or PQ is unavailable; default off. + *

When HDR is enabled and the surface advertises HDR10_ST2084 (paired with whatever pixel format the + * surface offers for it), we steer Minecraft's swapchain to it. When HDR is disabled, configure selects + * vanilla's native SDR pair. The options callback invalidates the surface configuration, so toggling HDR + * reuses the same swapchain recreation path as resize. */ @Mixin(VulkanGpuSurface.class) public abstract class VulkanGpuSurfaceMixin { @@ -71,6 +73,7 @@ public abstract class VulkanGpuSurfaceMixin { @Shadow @Final + @Mutable private int swapchainImageFormat; @Shadow @@ -99,6 +102,12 @@ public abstract class VulkanGpuSurfaceMixin { @Unique private int caustica$colorSpace = 0; + @Unique + private long caustica$metadataSwapchain; + + @Unique + private int caustica$metadataPeakNits = -1; + @Inject(method = "(Lcom/mojang/blaze3d/vulkan/VulkanDevice;J)V", at = @At("TAIL")) private void caustica$logHdrCapabilities(VulkanDevice device, long windowHandle, CallbackInfo ci) { try { @@ -108,28 +117,97 @@ public abstract class VulkanGpuSurfaceMixin { } } - /** - * Pick a PQ (HDR10_ST2084) surface format when requested + available, before vanilla's SDR-only selection - * runs. Scans for any format the surface pairs with that color space rather than assuming a specific one - * (IHVs commonly pair it with a 10-bit UNORM like A2R10G10B10, but this must not be hardcoded). Sets - * {@link #caustica$colorSpace} so {@code configure} can pass the matching color space. - */ + /** Discover PQ capability during construction and select PQ only when HDR starts enabled. */ @Inject(method = "pickSwapchainSurfaceFormat", at = @At("HEAD"), cancellable = true) private void caustica$pickPqFormat(VkSurfaceFormatKHR.Buffer formats, CallbackInfoReturnable cir) { - if (!CausticaConfig.Rt.Hdr.enabled()) { - return; + VkSurfaceFormatKHR pq = caustica$findPq(formats); + CausticaConfig.Rt.Hdr.setSwapchainPqAvailable(pq != null); + this.caustica$colorSpace = 0; + CausticaConfig.Rt.Hdr.setSwapchainPqActive(false); + if (CausticaConfig.Rt.Hdr.ENABLED.value() && pq != null) { + this.caustica$colorSpace = VK_COLOR_SPACE_HDR10_ST2084_EXT; + CausticaConfig.Rt.Hdr.setSwapchainPqActive(true); + CausticaMod.LOGGER.info("HDR: surface supports PQ (format={}, colorSpace=HDR10_ST2084); " + + "creating the initial swapchain in PQ", pq.format()); + cir.setReturnValue(pq); + } + } + + /** + * A framebuffer resize already recreates the swapchain through {@code configure}. Refresh the chosen + * (format,colorSpace) pair at that same boundary so the HDR option can use the identical path without + * recreating the window or Vulkan surface. Vanilla made swapchainImageFormat final because resize + * normally keeps it fixed; the mixin marks that field mutable specifically for this re-selection. + */ + @Inject(method = "configure", at = @At("HEAD")) + private void caustica$refreshFormatForConfigure(GpuSurface.Configuration config, CallbackInfo ci) { + try (MemoryStack stack = MemoryStack.stackPush()) { + IntBuffer count = stack.callocInt(1); + int countResult = KHRSurface.vkGetPhysicalDeviceSurfaceFormatsKHR( + this.device.vkDevice().getPhysicalDevice(), this.surface, count, null); + if (countResult != VK10.VK_SUCCESS || count.get(0) <= 0) { + CausticaMod.LOGGER.warn("HDR: failed to enumerate swapchain formats during recreation: {}", + countResult); + return; + } + VkSurfaceFormatKHR.Buffer formats = VkSurfaceFormatKHR.calloc(count.get(0), stack); + int formatsResult = KHRSurface.vkGetPhysicalDeviceSurfaceFormatsKHR( + this.device.vkDevice().getPhysicalDevice(), this.surface, count, formats); + if (formatsResult != VK10.VK_SUCCESS) { + CausticaMod.LOGGER.warn("HDR: failed to read swapchain formats during recreation: {}", + formatsResult); + return; + } + formats.limit(Math.min(formats.capacity(), count.get(0))); + + VkSurfaceFormatKHR pq = caustica$findPq(formats); + VkSurfaceFormatKHR sdr = caustica$findSdr(formats); + CausticaConfig.Rt.Hdr.setSwapchainPqAvailable(pq != null); + boolean usePq = CausticaConfig.Rt.Hdr.ENABLED.value() && pq != null; + if (!usePq && sdr == null && pq != null) { + // Extremely unusual, but safer than destroying the only viable presentation path. + CausticaMod.LOGGER.warn("HDR: surface exposes PQ but no compatible native-SDR format; " + + "keeping the PQ swapchain and embedding SDR content"); + usePq = true; + } + if (usePq) { + this.swapchainImageFormat = pq.format(); + this.caustica$colorSpace = VK_COLOR_SPACE_HDR10_ST2084_EXT; + } else { + if (sdr == null) { + CausticaMod.LOGGER.warn("HDR: surface exposes no compatible SDR or PQ format during recreation"); + return; + } + this.swapchainImageFormat = sdr.format(); + this.caustica$colorSpace = 0; + } + CausticaConfig.Rt.Hdr.setSwapchainPqActive(usePq); + CausticaMod.LOGGER.info("HDR: recreating swapchain as {} (format={}, colorSpace={})", + usePq ? "PQ" : "native SDR", this.swapchainImageFormat, + usePq ? "HDR10_ST2084" : "SRGB_NONLINEAR"); } + } + + @Unique + private static VkSurfaceFormatKHR caustica$findPq(VkSurfaceFormatKHR.Buffer formats) { for (int i = 0; i < formats.capacity(); i++) { VkSurfaceFormatKHR f = formats.get(i); if (f.colorSpace() == VK_COLOR_SPACE_HDR10_ST2084_EXT) { - this.caustica$colorSpace = VK_COLOR_SPACE_HDR10_ST2084_EXT; - CausticaMod.LOGGER.info("HDR: selecting PQ swapchain (format={}, colorSpace=HDR10_ST2084)", f.format()); - cir.setReturnValue(f); - return; + return f; + } + } + return null; + } + + @Unique + private static VkSurfaceFormatKHR caustica$findSdr(VkSurfaceFormatKHR.Buffer formats) { + for (int i = 0; i < formats.capacity(); i++) { + VkSurfaceFormatKHR f = formats.get(i); + if (f.colorSpace() == 0 && (f.format() == 37 || f.format() == 44)) { + return f; } } - CausticaMod.LOGGER.warn("HDR: PQ swapchain requested but HDR10_ST2084 was not advertised by the surface; " - + "using SDR (enable OS/display HDR; on Linux use a native Wayland session with HDR enabled in the compositor)"); + return null; } /** Replace the hardcoded {@code imageColorSpace(0)} with the PQ color space when one was selected. */ @@ -142,9 +220,9 @@ public abstract class VulkanGpuSurfaceMixin { } /** - * Reflex Phase 1a: chain {@code VkSwapchainLatencyCreateInfoNV{latencyModeEnable=true}} into the - * swapchain's pNext at creation. Per spec {@code vkSetLatencySleepModeNV} (not called yet — lands with - * the sleep loop) only takes effect on a swapchain created with this flag, so it has to be set here, + * Chain {@code VkSwapchainLatencyCreateInfoNV{latencyModeEnable=true}} into the swapchain's pNext at + * creation. {@code vkSetLatencySleepModeNV} only takes effect on a swapchain created with this flag, + * so it has to be set here, * before there's any other reason to touch swapchain creation. Preserves whatever pNext was already * there (currently nothing else chains one). The extra struct is stack-allocated and only needs to * survive this call — Vulkan reads pNext chains synchronously during {@code vkCreateSwapchainKHR}, it @@ -170,13 +248,14 @@ public abstract class VulkanGpuSurfaceMixin { } /** - * Reflex Phase 1b: (re)apply the sleep-mode config for the just-(re)configured swapchain. Per spec this + * Reapply the Reflex sleep-mode config for the configured swapchain. The configuration * is scoped to a specific swapchain object, so it must be re-called whenever {@code configure()} builds a * new one (e.g. resize) — {@link RtReflex#applySleepMode} is idempotent (no-op if unchanged), so calling * it unconditionally here is cheap. No-op when Reflex isn't enabled + device-supported. */ @Inject(method = "configure", at = @At("TAIL")) - private void caustica$applyReflexSleepMode(GpuSurface.Configuration config, CallbackInfo ci) { + private void caustica$applySwapchainExtensionState(GpuSurface.Configuration config, CallbackInfo ci) { + caustica$applyHdrMetadataIfNeeded(); if (RtDeviceBringup.reflexEnabled()) { RtReflex.INSTANCE.applySleepMode(this.device.vkDevice(), this.swapchain); } @@ -193,7 +272,7 @@ public abstract class VulkanGpuSurfaceMixin { } /** - * Reflex Phase 1b: PRESENT_START/END markers around the real frame's present, plus (when + * Emit PRESENT_START/END markers around the real frame's present and, when * {@code VK_KHR_present_id} is enabled) chaining a {@code VkPresentIdKHR} onto it so the marker's * {@code presentID} correlates with this exact present call. The FG-generated extra presents * ({@link RtFramePresenter}) are deliberately NOT marked/present-id'd — Reflex paces/measures the real @@ -233,9 +312,8 @@ public abstract class VulkanGpuSurfaceMixin { } /** - * Step C — world-only HDR present. When the RT renderer has a fresh PQ HDR image and the swapchain is - * PQ, blit that image straight into the swapchain instead of Minecraft's SDR main target. Replaces the - * vanilla blit entirely (the SDR target + its UI are bypassed for now; UI compositing is a later step). + * HDR present path. When the RT renderer has a fresh PQ image and the swapchain is PQ, composite the + * SDR-authored UI and blit the result directly into the swapchain instead of Minecraft's SDR main target. * *

Because this cancels {@code blitFromTexture} at HEAD, the normal {@code caustica$presentGeneratedFrames} * TAIL inject below never runs on HDR frames — so DLSS-FG's extra-present step is invoked explicitly here, @@ -244,6 +322,9 @@ 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. + caustica$applyHdrMetadataIfNeeded(); if (this.currentImageIndex < 0) { return; } @@ -270,6 +351,23 @@ public abstract class VulkanGpuSurfaceMixin { } } + @Unique + private void caustica$applyHdrMetadataIfNeeded() { + if (this.caustica$colorSpace != VK_COLOR_SPACE_HDR10_ST2084_EXT + || !RtHdr.metadataExtensionEnabled() || this.swapchain == 0L) { + return; + } + int peakNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value(); + if (this.caustica$metadataSwapchain == this.swapchain + && this.caustica$metadataPeakNits == peakNits) { + return; + } + if (RtHdr.applyMasteringMetadata(this.device.vkDevice(), this.swapchain, peakNits)) { + this.caustica$metadataSwapchain = this.swapchain; + this.caustica$metadataPeakNits = peakNits; + } + } + @Unique private static long caustica$vkImageView(GpuTextureView view) { return view instanceof com.mojang.blaze3d.vulkan.VulkanGpuTextureView v ? v.vkImageView() : 0L; diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java index 768bed82..a0966e0d 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java @@ -15,13 +15,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** - * HDR Phase 3 groundwork: enable {@code VK_EXT_swapchain_colorspace} at instance creation when the platform - * supports it. Minecraft's stock instance does not request it, so on a stock build - * {@code vkGetPhysicalDeviceSurfaceFormatsKHR} only ever reports {@code SRGB_NONLINEAR} — no extended/HDR - * color spaces are visible and the swapchain cannot be created in HDR10 (PQ). Adding this instance - * extension is the prerequisite that makes HDR color spaces queryable (surfaced by the Phase 0 capability - * log) and selectable by a later swapchain-ownership change. It is a no-op for present rendering: the - * extension only adds color-space enum values; Minecraft still creates its swapchain with color space 0. + * 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. * *

Gated on availability — requesting an unsupported instance extension would fail {@code vkCreateInstance} * and crash startup. diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 46e884eb..d65090f8 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -37,11 +37,14 @@ import org.lwjgl.system.MemoryUtil; import org.lwjgl.vulkan.KHRSynchronization2; import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkBufferImageCopy; import org.lwjgl.vulkan.VkCommandBuffer; import org.lwjgl.vulkan.VkDependencyInfo; import org.lwjgl.vulkan.VkImageBlit; import org.lwjgl.vulkan.VkImageCopy; +import org.lwjgl.vulkan.VkImageMemoryBarrier; import org.lwjgl.vulkan.VkImageMemoryBarrier2; +import org.lwjgl.vulkan.VkMemoryBarrier; import org.lwjgl.vulkan.VkMemoryBarrier2; import org.lwjgl.vulkan.VkSamplerCreateInfo; @@ -54,6 +57,9 @@ import dev.comfyfluffy.caustica.rt.material.RtEmissionSemantics; import dev.comfyfluffy.caustica.rt.material.RtMaterialOverrides; import dev.comfyfluffy.caustica.rt.material.RtMaterialRegistry; +import dev.comfyfluffy.caustica.rt.pipeline.RtDebugPresentPipeline; +import dev.comfyfluffy.caustica.rt.pipeline.RtBloomPipeline; +import dev.comfyfluffy.caustica.rt.pipeline.RtSkyLut; import dev.comfyfluffy.caustica.rt.pipeline.RtDisplayPipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtDlssFg; import dev.comfyfluffy.caustica.rt.pipeline.RtDlssRr; @@ -62,10 +68,13 @@ 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.RtToneLut; import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; import java.nio.ByteBuffer; import java.nio.LongBuffer; +import java.nio.file.Path; +import java.util.Objects; /** * On-screen composite. Each frame, ray-trace into a render-res storage image (+ guide buffers), use @@ -92,9 +101,9 @@ public static boolean enabled() { // owns or calculates a shader byte offset, struct size, array stride, or fixed-array capacity. private static final int WORLD_PUSH_SIZE = WorldPushData.BYTE_SIZE; // Real inline push constants (fast constant-bank reads), separate from the WorldPush BDA ring above. - // Hot addresses/frameIndex and raygen's debugView avoid unnecessary global-memory dereferences; - // WorldPushConstantsData is generated from the same Slang module and owns this second ABI as well. - private static final int GUIDE_COUNT = 6; // RR guide buffers bound at world-pipeline bindings 3..8 + // Hot addresses/frameIndex avoid unnecessary global-memory dereferences; WorldPushConstantsData is + // generated from the same Slang module and owns this second ABI as well. debugView is no longer + // part of it -- no world shader reads it anymore; debug views are a downstream compute pass. private static final long PATH_RECORD_BYTES = 48L; private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); @@ -112,14 +121,19 @@ private static boolean waterWaves() { return CausticaConfig.Rt.Composite.WATER_WAVES.value(); } - // Finite sun/moon angular sizes let NEE shadow rays sample the light disk (soft, contact-hardening - // penumbrae). Radii in degrees; the real sun/moon are ~0.27°, but a touch larger reads pleasantly. private static final int WATER_ANCHOR_MASK = 4095; + // The versioned look package owns every photometric anchor and the sky geometry. Its sun illuminance is the + // photometric solar constant at the top of the atmosphere; the shader's transmittance LUT brings that + // to ~117,000 lux under a zenith sun and reddens/dims it through sunset, and because world.rmiss tints + // the visible disc from the same LUT, the light on terrain and the sky's sunset are one number. + // + // world.rgen consumes it as ILLUMINANCE at normal incidence (lux) — the NEE term is brdf·E·ndl with no + // solid-angle factor, and the diffuse BRDF's 1/π turns 100,000 lux into + // 31,800 cd/m² white / 5,730 cd/m² 18%-grey noon surface. It is therefore independent of the sky + // 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[] MOON_IDS = createMoonIds(); - // Celestial rotation axis (the pole the sun/moon arc about): perpendicular to the east-west arc, - // tilted by SUN_NOON_SOUTH_TILT. Pushed so the sky shader can build the sun/moon square's tangent - // frame (right = travel direction) and wheel the starfield. = normalize(noonDir x sunriseDir). // 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). private static float jitterSignX() { @@ -130,26 +144,6 @@ private static float jitterSignY() { return CausticaConfig.Rt.Composite.JITTER_SIGN_Y.value(); } - private static float sunNoonTilt() { - return CausticaConfig.Rt.Composite.SUN_NOON_SOUTH_TILT.value(); - } - - private static float sunNoonY() { - return Mth.cos(sunNoonTilt()); - } - - private static float sunNoonZ() { - return Mth.sin(sunNoonTilt()); - } - - private static float celestialAxisY() { - return -sunNoonZ(); - } - - private static float celestialAxisZ() { - return sunNoonY(); - } - // Monotonic per-composite frame counter used for cache eviction, shader sampling, and diagnostics. private static volatile long frameCounter; @@ -179,11 +173,23 @@ public static long frameCounter() { private PushSlot[] pushRing; private int pushSlot; private RtDisplayPipeline displayPipeline; + private RtBloomPipeline bloomPipeline; + // Atmosphere LUTs (transmittance + multiple scattering + this frame's sky view). Device-lifetime; the + // two static tables are baked on the first frame that records the pass. + private RtSkyLut skyLut; + private RtDebugPresentPipeline debugPresentPipeline; + private RtToneLut sdrToneLut; + private RtToneLut hdrToneLut; + private RtToneLut lookLut; + private int loadedHdrLutNits = -1; private RtImage output; // 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 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. + private RtImage[] bloomLevels = new RtImage[0]; // Parallel PQ-encoded ([0,1], ST.2084) HDR display image. Written alongside displayImage when HDR is // enabled. When the PQ swapchain is active, the combined UI overlay is composited over this image, then // this image is blitted straight to the swapchain. @@ -325,6 +331,125 @@ public boolean hasFailed() { return this.failed; } + /** Read-only access to the auto-exposure controller, for diagnostics (F3 entry, frame stats log). */ + public RtExposure exposure() { + return exposure; + } + + /** + * Export the latest RT scene image at the exact input seam of the Look/LMT stage. + * + *

The GPU image stores {@code sceneLinear * preExposure} in fp16. This readback multiplies RGB by + * the display shader's current 1x1 {@code residualExposure}, in float32, then quantizes the resulting + * exposure-adjusted scene-linear image to fp16 EXR. Metadata keeps both factors so the original scene-linear + * values can be reconstructed with {@code RGB / (preExposure * residualExposure)}. + * + * @return {@code true} when a current RT frame was available and written + */ + public boolean exportLatestResidualExposureExr(Path outputPath) throws java.io.IOException { + RenderSystem.assertOnRenderThread(); + RtContext ctx = RtContext.currentOrNull(); + if (!enabled() || failed || ctx == null || rrOutput == null || exposure.image() == null + || displayW <= 0 || displayH <= 0 || pendingGraphicsUse != null) { + return false; + } + + long pixelCount = Math.multiplyExact((long) displayW, (long) displayH); + long rgbaBytes = Math.multiplyExact(pixelCount, 4L * Short.BYTES); + long totalBytes = Math.addExact(rgbaBytes, Float.BYTES); + if (pixelCount > Integer.MAX_VALUE / 4L) { + throw new IllegalArgumentException("EXR capture is too large for a Java array: " + + displayW + "x" + displayH); + } + + // All ordinary frame commands have been submitted before the F2 key is handled. Drain them before + // a private one-shot copy so rrOutput and the exposure image describe the same completed frame. + ctx.waitIdle(); + RtBuffer readback = ctx.createReadbackBuffer(totalBytes, "residual-exposure EXR readback"); + try { + ctx.submitSync(cmd -> recordExrReadback(ctx, cmd, readback, rgbaBytes)); + readback.invalidate(); + + float residualExposure = MemoryUtil.memGetFloat(readback.mapped + rgbaBytes); + RtExposure.CaptureMetadata exposureMetadata = exposure.captureMetadata(residualExposure); + short[] exposedRgba = new short[Math.toIntExact(pixelCount * 4L)]; + for (int sample = 0; sample < exposedRgba.length; sample++) { + short storedHalf = MemoryUtil.memGetShort(readback.mapped + (long) sample * Short.BYTES); + float value = Float.float16ToFloat(storedHalf); + if ((sample & 3) != 3) { + value *= residualExposure; + } + // Residual exposure is expected to keep this seam comfortably centred in fp16. Clamp only + // true outliers/infinities so a pathological light cannot poison a grading application. + value = Math.clamp(value, -65504.0f, 65504.0f); + exposedRgba[sample] = Float.floatToFloat16(value); + } + + RtOpenExrWriter.write(outputPath, displayW, displayH, exposedRgba, + new RtOpenExrWriter.Metadata( + exposureMetadata.preExposure(), + exposureMetadata.residualExposure(), + exposureMetadata.absoluteExposure(), + exposureMetadata.mode(), + exposureMetadata.evScene(), + exposureMetadata.evTarget(), + exposureMetadata.evApplied(), + LOOK.id() + "@" + LOOK.packageVersion(), + frameCounter)); + return true; + } finally { + readback.destroy(); + } + } + + private void recordExrReadback(RtContext ctx, VkCommandBuffer cmd, RtBuffer readback, long exposureOffset) { + try (MemoryStack stack = MemoryStack.stackPush(); + RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, + "residual-exposure EXR readback")) { + VkImageMemoryBarrier.Buffer imageBarriers = VkImageMemoryBarrier.calloc(2, stack); + imageBarriers.get(0).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_GENERAL).newLayout(VK10.VK_IMAGE_LAYOUT_GENERAL) + .srcAccessMask(VK10.VK_ACCESS_SHADER_WRITE_BIT | VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_TRANSFER_READ_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .image(rrOutput.image); + imageBarriers.get(0).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .levelCount(1).layerCount(1); + imageBarriers.get(1).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_GENERAL).newLayout(VK10.VK_IMAGE_LAYOUT_GENERAL) + .srcAccessMask(VK10.VK_ACCESS_SHADER_WRITE_BIT | VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_TRANSFER_READ_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .image(exposure.image().image); + imageBarriers.get(1).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .levelCount(1).layerCount(1); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, null, null, imageBarriers); + + VkBufferImageCopy.Buffer sceneCopy = VkBufferImageCopy.calloc(1, stack); + sceneCopy.get(0).bufferOffset(0L); + sceneCopy.get(0).imageSubresource().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT).layerCount(1); + sceneCopy.get(0).imageExtent().set(displayW, displayH, 1); + VK10.vkCmdCopyImageToBuffer(cmd, rrOutput.image, VK10.VK_IMAGE_LAYOUT_GENERAL, + readback.handle, sceneCopy); + + VkBufferImageCopy.Buffer exposureCopy = VkBufferImageCopy.calloc(1, stack); + exposureCopy.get(0).bufferOffset(exposureOffset); + exposureCopy.get(0).imageSubresource().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT).layerCount(1); + exposureCopy.get(0).imageExtent().set(1, 1, 1); + VK10.vkCmdCopyImageToBuffer(cmd, exposure.image().image, VK10.VK_IMAGE_LAYOUT_GENERAL, + readback.handle, exposureCopy); + + VkMemoryBarrier.Buffer hostBarrier = VkMemoryBarrier.calloc(1, stack); + hostBarrier.get(0).sType$Default().srcAccessMask(VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_HOST_READ_BIT); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, + VK10.VK_PIPELINE_STAGE_HOST_BIT, 0, hostBarrier, null, null); + } + } + /** * Whether the current frame must retain vanilla world rendering while RT resource state converges. * @@ -376,6 +501,11 @@ public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cam frameCaptured = true; } + /** Reset exposure filtering after an explicit render-state invalidation such as F3+A. */ + public void resetExposureHistory() { + exposure.requestReset(); + } + /** * The frame's forward camera-relative view-projection (jitter-free), exactly what {@code world.rgen} * traced with — overlay raster passes ({@code dev.comfyfluffy.caustica.rt.overlay}) reuse it so their content lands @@ -461,6 +591,46 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { if (displayPipeline == null) { displayPipeline = RtDisplayPipeline.create(ctx); } + if (bloomPipeline == null) { + bloomPipeline = RtBloomPipeline.create(ctx); + } + if (skyLut == null) { + // Normally already created by ensureWorld before the pipeline exists at all; this only + // fires if render() somehow runs before the tick-driven ensureResourcesReady has, which + // ensureWorld's own binding order otherwise guarantees never happens. + skyLut = RtSkyLut.create(ctx); + } + if (debugPresentPipeline == null) { + debugPresentPipeline = RtDebugPresentPipeline.create(ctx); + } + 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"); + 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 + // this would silently misalign one LUT's edge texels if that ever changed. + newHdrLut.destroy(); + throw new IllegalStateException("SDR/HDR tone LUT size mismatch: " + + sdrToneLut.size + " vs " + newHdrLut.size); + } + if (hdrToneLut != null) { + ctx.waitIdle(); // nits-step change is rare; no in-flight frame may sample the old LUT + hdrToneLut.destroy(); + } + hdrToneLut = newHdrLut; + loadedHdrLutNits = wantedHdrNits; + } + // 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. + if (lookLut == null) { + lookLut = RtToneLut.loadResource(ctx, LOOK.lmtResource()); + } // A resource reload re-stitches the block atlas. We've already torn down the world pipeline // (onResourceReloadStart) so nothing references the old atlas, but MC's deferred free keeps the // old view handle live for a few frames, then swaps in the new atlas (whose GPU upload may lag, @@ -473,6 +643,18 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { } } ensureOutput(ctx, width, height); + // ensureOutput's rebuild path (only taken on resize/RR-setting change) already rebinds + // displayPipeline's descriptor set; this covers the case ensureOutput early-returned but + // 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; + 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()); + 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, + exposure.stateBuffer()); // Cheap idempotent check every frame (not just on resize): if the exposure mode is switched // manual -> auto at runtime (video settings), the auto-mode histogram/state/pipeline must be // allocated before recordFrame's exposure.record() below needs them, or it throws. @@ -524,13 +706,22 @@ public void ensureResourcesReady(RtContext ctx) { private RtPipeline ensureWorld(RtContext ctx) { if (worldPipeline == null) { + // Must exist before bindWorldTextures below writes the sky-LUT descriptors. This is the + // earliest possible bind: ensureResourcesReady drives this from the client tick, ahead of the + // render()/composite path. bindWorldTextures only ever runs again on a + // resource reload, so a skyLut that is still null on this first call stays permanently unbound + // and every miss/raygen sky sample reads the pre-vkUpdateDescriptorSets undefined descriptor + // (VUID-vkCmdTraceRaysKHR-None-08114). + if (skyLut == null) { + skyLut = RtSkyLut.create(ctx); + } bindlessTextureCapacity = RtEntityTextures.maxTextures(); worldPipeline = RtPipeline.create(ctx, new String[]{ RtDeviceBringup.worldPrimaryRaygenShader(), RtDeviceBringup.worldRaygenShader()}, - new String[]{"world.rmiss.spv", "world_guide.rmiss.spv"}, - "world.rchit.spv", "world.rahit.spv", - WorldPushConstantsData.BYTE_SIZE, true, GUIDE_COUNT, bindlessTextureCapacity, true); + new String[]{"sky.rmiss.spv", "guide.rmiss.spv"}, + "closest_hit.rchit.spv", "any_hit.rahit.spv", + WorldPushConstantsData.BYTE_SIZE, bindlessTextureCapacity); // Per-frame world data lives in this BDA ring; the pipeline pushes its address and hot fields. if (pushRing == null) { pushRing = new PushSlot[PUSH_RING]; @@ -594,6 +785,12 @@ private void bindWorldTextures(RtContext ctx) { long celView = celestialsAtlasView(); if (worldPipeline.hasSkyAtlas()) { worldPipeline.setSkyAtlas(celView != 0L ? celView : atlasView, sampler); + // Atmosphere LUTs live for the device's lifetime, but the world pipeline's descriptor sets do + // not (a resource reload rebuilds it), so rebind them alongside the atlas. + if (skyLut != null) { + worldPipeline.setSkyLuts(skyLut.skyViewView(), skyLut.transmittanceView(), + skyLut.sampler()); + } } setCelestialUvAtlas(celView); // Atlas UVs and material IDs are one resource epoch. Drop old terrain as a unit rather than @@ -694,10 +891,14 @@ 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(); int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE; if (output != null && continuationQueue != null - && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() + && displayImage != null && hdrDisplayImage != null && rrOutput != null + && bloomLevels.length > 0 && exposure.ready() && displayW == width && displayH == height && renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) { return; @@ -709,6 +910,7 @@ private void ensureOutput(RtContext ctx, int width, int height) { if (hdrDisplayImage != null) { hdrDisplayImage.destroy(); } + destroyBloomLevels(); if (output != null) { output.destroy(); } @@ -731,8 +933,9 @@ private void ensureOutput(RtContext ctx, int width, int height) { renderSizeRrEnabled = rrEnabled; renderSizeRrQuality = rrQuality; - // RT traces into an HDR (R16G16B16A16_SFLOAT) target so radiance > 1 survives to the display - // mapping seam. displayImage stays R8G8B8A8 to match the main target it is copied into + // 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); long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH); @@ -744,6 +947,20 @@ private void ensureOutput(RtContext ctx, int width, int height) { 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. + 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()); + bloomLevels = new RtImage[bloomLevelCount]; + for (int level = 0; level < bloomLevelCount; level++) { + bloomLevels[level] = ctx.createStorageImage(bloomWidth, bloomHeight, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, + "RT bloom level " + level + " " + bloomWidth + "x" + bloomHeight); + 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); @@ -761,7 +978,23 @@ private void ensureOutput(RtContext ctx, int width, int height) { worldPipeline.setStorageImage(output.view); bindGuideImages(); } - displayPipeline.setImages(displayImage.view, rrOutput.view, exposure.image().view, hdrDisplayImage.view); + RtToneLut boundLookLut = lookLut; + 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()); + 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, + exposure.stateBuffer()); + } + + private void destroyBloomLevels() { + for (RtImage level : bloomLevels) { + if (level != null) { + level.destroy(); + } + } + bloomLevels = new RtImage[0]; } /** @@ -796,6 +1029,9 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // Reserve the graphics-use value that guards this frame's reusable TLAS and entity resources. RtGpuExecutor.GraphicsUse graphicsUse = gpuExecutor.beginGraphicsUse(encoder); RtGpuExecutor.GraphicsUseWaiter graphicsUseWaiter = gpuExecutor.graphicsUseWaiter(); + // 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); pendingGraphicsUse = graphicsUse; RtEntities.FrameEntities frameEntities = null; VkCommandBuffer cmd = encoder.allocateAndBeginTransientCommandBuffer(); @@ -804,8 +1040,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtTerrain terrain = RtTerrain.currentOrNull(); 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. - // Jitter is suppressed for the no-RR reference and for the debug guide views (raw inspection). - boolean rrPath = RtDlssRr.enabled() && debugView == 0; + // A debug view observes this ordinary path; it never changes jitter or disables RR. + boolean rrPath = RtDlssRr.enabled(); float jitterX = 0f; float jitterY = 0f; if (rrPath) { @@ -825,9 +1061,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo ByteBuffer push = MemoryUtil.memByteBuffer(pushBuf.mapped, WORLD_PUSH_SIZE); frameInvViewProj.set(frameProjection).mul(frameViewRotation).invert(); // flags: camera-in-water (so the path tracer starts in the water medium when the eye is - // submerged, fixing the air→water first-segment orientation) + W1 wave normals. Bit 1 used to - // gate a Lambertian fallback BRDF that nothing ever turned off; the GGX path is unconditional - // now, so that bit is unused rather than reassigned, to avoid a stale reader elsewhere. + // submerged, fixing the air→water first-segment orientation) and animated water normals. + // Bit 1 remains unused to avoid conflicting with stale external readers. int flags = 0; var level = Minecraft.getInstance().level; if (level != null) { @@ -842,10 +1077,10 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo } } if (waterWaves()) { - flags |= 0b10000; // W1: animated water wave normals + flags |= 0b10000; // animated water wave normals } - // W1/W2 water parameters: camera-biome tint plus wrapped animation time. Per-water-body tint + // Water parameters: camera-biome tint plus wrapped animation time. Per-water-body tint // comes from the primitive; this is the fallback for a camera already inside the medium. float wtr = 0.25f, wtg = 0.46f, wtb = 0.9f; // neutral ocean-ish default if no level/biome if (level != null) { @@ -863,8 +1098,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo ? previousWaterWaveTime : waterWaveTime; previousWaterWaveTime = waterWaveTime; waterWaveTimeValid = true; - Float4 waterParams = new Float4(wtr, wtg, wtb, waterWaveTime); - // W1 wave-domain anchor: the terrain rebase origin reduced mod 4096 (kept small for shader + Float4 waterParams = linearAcesCgFromSrgb(wtr, wtg, wtb, waterWaveTime); + // Wave-domain anchor: the terrain rebase origin reduced mod 4096 (kept small for shader // float precision). hitPos.xz (rebased) + anchor reconstructs a world-pinned coordinate, so the // ripple pattern stays fixed in the world as the player moves and the rebase origin shifts. Float4 waterAnchor = new Float4(terrain.blockX & WATER_ANCHOR_MASK, @@ -897,11 +1132,11 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo new Float2(jitterX, jitterY), flags, maxBounces(), - sky.sunDir(), - sky.lightDir(), - sky.lightRadiance(), - sky.moonDir(), sky.celestial(), + sky.look0(), + sky.look1(), + sky.look2(), + sky.look3(), sky.sunUv(), sky.moonUv(), waterParams, @@ -910,7 +1145,7 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo breaking.length, breaking, // RIS emitter NEE: candidate count (0 = emitter NEE off; the shader also requires - // lightCount > 0, so an empty buffer degrades to legacy gather). The light buffer + // lightCount > 0, so an empty buffer leaves only direct-hit emission). The light buffer // device addresses themselves are pc.light*Addr — every 64-bit address lives in the // push-constant block now, not here. new Float4(terrain.lightRebaseOffsetX(), terrain.lightRebaseOffsetY(), @@ -918,7 +1153,10 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo new Float4(terrain.lightGridOriginX(), terrain.lightGridOriginY(), terrain.lightGridOriginZ(), 16f), new Int4(terrain.lightGridDimX(), terrain.lightGridDimY(), terrain.lightGridDimZ(), 0), terrain.lightCount(), - CausticaConfig.Rt.Lights.RIS_CANDIDATES.value() + CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(), + // Must be the SAME value the exposure resolve divides out this frame (it reads it + // from the same RtExposure accessor), or the two stop cancelling. + exposure.preExposure() ).write(push); pushBuf.flush(0L, WORLD_PUSH_SIZE); // Upload any entity textures registered this frame into the bindless set before the trace. @@ -954,7 +1192,15 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo terrain.lightBufferAddress(), terrain.lightAliasBufferAddress(), terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(), terrain.lightGridSpanBufferAddress(), continuationQueue.deviceAddress, - (int) frameCounter, debugView).write(pushConstants); + (int) frameCounter).write(pushConstants); + // 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. + try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.skyLut")) { + skyLut.record(cmd, pushBuf.deviceAddress); + } + VulkanCommandEncoder.memoryBarrier(cmd, stack); // sky LUT writes visible to raygen/miss + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world primary trace"); RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.tracePrimary")) { active.trace(cmd, renderW, renderH, pushConstants, 0); @@ -976,9 +1222,10 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo } } - // When DLSS-RR did not produce the display-res image (disabled, debug view, or a runtime - // failure), bring the render-res trace up to display res with a linear blit so the display mapper - // always has a display-res RT image. With RR off render == display, so this is a 1:1 copy. + // When DLSS-RR did not produce the display-res image (disabled or a runtime failure), bring + // the render-res trace up to display res with a linear blit so the display mapper and + // downstream debug pass always have a valid display-res scene image. With RR off + // render == display, so this is a 1:1 copy. if (!rrDone) { VulkanCommandEncoder.memoryBarrier(cmd, stack); try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "fallback upscale"); @@ -997,16 +1244,42 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // 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); + exposure.record(ctx, cmd, stack, rrOutput, gDepth, gAlbedo); + exposure.recordStateReadback(cmd, stack); } VulkanCommandEncoder.memoryBarrier(cmd, stack); // exposure image visible to the display mapper + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "bloom"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.bloom")) { + RtLookPackage.Bloom bloom = LOOK.bloom(); + // The tent radius is in source texels, so it needs no resolution scaling: the pyramid's + // reach is set by its level count, and each level's texel already scales with the frame. + bloomPipeline.dispatch(cmd, bloomLevels, + bloom.thresholdSceneLinear(), bloom.softKneeFraction(), bloom.radius()); + } + 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(), - CausticaConfig.Rt.Hdr.paperWhiteNits(), CausticaConfig.Rt.Hdr.headroom()); + sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), loadedHdrLutNits, + true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length); } hdrWrittenThisFrame = CausticaConfig.Rt.Hdr.enabled(); + VulkanCommandEncoder.memoryBarrier(cmd, stack); // display output visible to debug composite + + if (debugView != 0) { + // 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 + // remains SDR for now; a PQ swapchain uses the existing SDR->PQ conversion path. + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "debug present"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.debugPresent")) { + debugPresentPipeline.dispatch(cmd, displayW, displayH, debugView, + CausticaConfig.Rt.Exposure.CENTER_WEIGHT_SIGMA.value(), + CausticaConfig.Rt.Exposure.CENTER_WEIGHT_FLOOR.value()); + } + hdrWrittenThisFrame = false; + } VulkanCommandEncoder.memoryBarrier(cmd, stack); try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "copy composite to main target"); @@ -1023,12 +1296,13 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // 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); } /** * Block-breaking overlay: mirrors vanilla's {@code ClientLevel.destructionProgress()} (populated - * by network packets, independent of the cancelled {@code LevelRenderer.render()} — see - * [[rt-native-overlay-tier1]]) into the push's {@code breaking[]} list, so {@code world.rchit} can blend + * by network packets, independent of the cancelled {@code LevelRenderer.render()}) into the push's + * {@code breaking[]} list, so {@code world.rchit} can blend * the matching destroy-stage crack texture into a hit terrain block's albedo. Each block's own * destroy-stage texture ({@code minecraft:textures/block/destroy_stage_N.png}, resolved via * {@link ModelBakery#DESTROY_TYPES}) is a standalone {@code Sampler0} texture, not a block-atlas sprite, @@ -1060,75 +1334,64 @@ private BreakEntry[] breakingEntries(RtTerrain terrain) { return count == result.length ? result : java.util.Arrays.copyOf(result, count); } - private record SkyPush(Float4 sunDir, Float4 lightDir, Float4 lightRadiance, Float4 moonDir, - Float4 celestial, Float4 sunUv, Float4 moonUv) {} + private record SkyPush(Float4 celestial, Float4 look0, Float4 look1, Float4 look2, Float4 look3, + Float4 sunUv, Float4 moonUv) {} private record CelestialUv(Float4 sun, Float4 moon) {} /** - * Derive the celestial light from Minecraft's time of day as typed values for {@link WorldPushData}. - * Celestial angles come from the camera's {@link EnvironmentAttributeProbe} (partial-tick - * interpolated). {@code caustica.rt.sunNoonSouthDeg} tilts the east-west arc toward south (+Z) at - * noon. + * This frame's sky state: Minecraft's four eased celestial angles, its star brightness, the moon + * phase, and the look package's sky constants. Nothing else. + * + *

Every direction, colour, level and atmospheric transmittance is derived in {@code sky.slang} + * from these values, keeping atmospheric evaluation in one implementation. + * + *

The angles come from the camera's {@link EnvironmentAttributeProbe} rather than from the tick: + * in 26.2 they are timeline tracks driven through a cubic-bezier ease, and a datapack can replace the + * track outright, so the probe is the only source that stays correct for a custom dimension. + * + *

The sky-view LUT's viewer altitude tracks the camera's real world height above sea level, not + * the look package's fixed reference altitude: a build-limit mod or a rocket/space mod climbing + * toward the 100 km shell should see the atmosphere actually thin out. The block-to-km scale is + * exaggerated 10x (100 blocks = 1 km, not the literal 1000) — vanilla's build range is under half a + * real km, which would put the whole playable height range within a rounding error of one LUT texel + * row; at 100:1 the same climb is a few km, enough to see the horizon and zenith actually shift. + * Clamped to [0, 99] km so an absurd Y (or one beyond the modelled 100 km shell) degrades to the + * shell edge instead of an LUT sample outside its baked domain. The shader applies its own lower + * floor — see {@code sky.MIN_VIEWER_ALTITUDE_KM}, which is set by what fp32 can resolve at planet + * radius, not by anything visual — so zero here is safe and means "at or below sea level". */ private SkyPush skyPush() { - float sunX, sunY, sunZ, dayFactor, lx, ly, lz, rr, rg, rb, lightRadius; - float moonX, moonY, moonZ, moonPhase, starAngle, starBrightness; Minecraft mc = Minecraft.getInstance(); float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false); var probe = mc.gameRenderer.mainCamera().attributeProbe(); - float sunAngle = probe.getValue(EnvironmentAttributes.SUN_ANGLE, partial) * (float) (Math.PI / 180.0); - float moonAngle = probe.getValue(EnvironmentAttributes.MOON_ANGLE, partial) * (float) (Math.PI / 180.0); - float sunNoon = Mth.cos(sunAngle); - sunX = -Mth.sin(sunAngle); sunY = sunNoonY() * sunNoon; sunZ = sunNoonZ() * sunNoon; - float moonNoon = Mth.cos(moonAngle); - moonX = -Mth.sin(moonAngle); moonY = sunNoonY() * moonNoon; moonZ = sunNoonZ() * moonNoon; - moonPhase = probe.getValue(EnvironmentAttributes.MOON_PHASE, partial).index(); // 0 full .. 4 new - // Stars: use Minecraft's actual celestial rotation + brightness (the same values vanilla's - // SkyRenderer uses), so the starfield wheels about the celestial pole tied to world time and - // fades in/out at dusk/dawn exactly like vanilla. STAR_ANGLE is in degrees -> radians. - starAngle = probe.getValue(EnvironmentAttributes.STAR_ANGLE, partial) * (float) (Math.PI / 180.0); - starBrightness = probe.getValue(EnvironmentAttributes.STAR_BRIGHTNESS, partial); - dayFactor = smoothstep(-0.08f, 0.10f, sunY); - float[] trans = new float[3]; - if (sunY > -0.05f) { - // Sun stays the NEE light through the whole sunset: its colour/intensity is the atmosphere's - // own transmittance (same Rayleigh+Mie+ozone march as the sky shader — see - // atmosphereTransmittance), so it whitens overhead and reddens+dims into the horizon on - // exactly the curve the visible sky follows. The old hand-tuned warmth ramp switched to the - // moon at sunY == 0 while the sun was still at ~16% strength, which read as a hard light pop - // at sunset/sunrise; transmittance is already near zero at the horizon, and the short - // smoothstep below carries the remainder to exactly zero before the moon takes over. - atmosphereTransmittance(sunX, sunY, sunZ, trans); - float fade = smoothstep(-0.05f, 0.005f, sunY); - float sunPeak = 21.0f; - lx = sunX; ly = sunY; lz = sunZ; - rr = sunPeak * trans[0] * fade; - rg = sunPeak * trans[1] * fade; - rb = sunPeak * trans[2] * fade; - lightRadius = CausticaConfig.Rt.Composite.SUN_ANGULAR_RADIUS.value(); - } else { - // Moon: dim cool light, ramping up from zero at the sun→moon handoff (sunY = -0.05, where - // the sun fade also reaches zero) so the switch is invisible. Scaled by the lit fraction so - // a new moon gives near-zero moonlight, and tinted by the same transmittance so a low moon - // is warm amber, silver once high (or zero while it is below the horizon). - atmosphereTransmittance(moonX, moonY, moonZ, trans); - float moonStrength = smoothstep(0.04f, 0.22f, -sunY); - float litFraction = 1.0f - Math.abs(moonPhase - 4.0f) / 4.0f; // 0 new .. 1 full - float moonPeak = 0.20f * (0.15f + 0.85f * litFraction); - lx = moonX; ly = moonY; lz = moonZ; - rr = 0.30f * moonPeak * moonStrength * trans[0]; - rg = 0.36f * moonPeak * moonStrength * trans[1]; - rb = 0.55f * moonPeak * moonStrength * trans[2]; - lightRadius = CausticaConfig.Rt.Composite.MOON_ANGULAR_RADIUS.value(); - } + int seaLevel = mc.level != null ? mc.level.getSeaLevel() : 0; + float viewerAltitudeKm = Math.clamp((float) ((camY - seaLevel) / 100.0), 0.0f, 99.0f); + float toRadians = (float) (Math.PI / 180.0); + float sunAngle = probe.getValue(EnvironmentAttributes.SUN_ANGLE, partial) * toRadians; + float moonAngle = probe.getValue(EnvironmentAttributes.MOON_ANGLE, partial) * toRadians; + // Stars use Minecraft's own celestial rotation and brightness (the values vanilla's SkyRenderer + // uses), so the field wheels about the celestial pole tied to world time and fades in and out at + // dusk/dawn exactly like vanilla's. + float starAngle = probe.getValue(EnvironmentAttributes.STAR_ANGLE, partial) * toRadians; + 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( - new Float4(sunX, sunY, sunZ, dayFactor), - new Float4(lx, ly, lz, lightRadius), - new Float4(rr, rg, rb, starBrightness), - new Float4(moonX, moonY, moonZ, moonPhase), - new Float4(0f, celestialAxisY(), celestialAxisZ(), starAngle), + new Float4(sunAngle, moonAngle, starAngle, starBrightness), + new Float4(lighting.sunIlluminanceLux(), lighting.moonIlluminanceLux(), + lighting.nightAirglowLuminanceCdM2(), lighting.starLuminanceCdM2()), + new Float4(sky.sunNoonSouthTiltDegrees() * toRadians, + sky.sunAngularRadiusDegrees() * toRadians, + sky.moonAngularRadiusDegrees() * toRadians, + lighting.moonPhaseFixedFraction()), + new Float4(sky.sunDiscHalfAngleDegrees() * toRadians, + sky.moonDiscHalfAngleDegrees() * toRadians, + viewerAltitudeKm, moonPhase), + new Float4(sky.groundAlbedo(), sky.horizonSoftenDegrees() * toRadians, 0f, 0f), uv.sun(), uv.moon()); } @@ -1178,43 +1441,23 @@ private void refreshCelestialUvCache(int moonPhase) { celestialUvMoonPhase = moonPhase; } - /** Hermite smoothstep matching GLSL semantics (0 below edge0, 1 above edge1). */ - private static float smoothstep(float edge0, float edge1, float x) { - float t = Math.clamp((x - edge0) / (edge1 - edge0), 0f, 1f); - return t * t * (3f - 2f * t); + private static Float4 linearAcesCgFromSrgb(double r, double g, double b, float w) { + return linearAcesCgFromBt709( + srgbToLinear(r), srgbToLinear(g), srgbToLinear(b), w); } - /** - * RGB transmittance from the camera to space along {@code dir} — a verbatim port of - * {@code world.rmiss}'s {@code transmittanceToSpace} (Rayleigh + Mie + ozone optical depth, 8-step - * march from 2 km altitude; constants must stay in lock-step with the shader). This is what colours - * the NEE sun/moonlight: because the sky shader tints its visible discs with the identical function, - * the light on terrain and the sky's sunset can never disagree. A direction below the geometric - * horizon accumulates enormous optical depth, so the result rolls to zero smoothly on its own — - * no explicit planet-shadow test needed. - */ - private static void atmosphereTransmittance(float dx, float dy, float dz, float[] out) { - final double planetR = 6371000.0, atmosR = 6471000.0; - final double[] rayBeta = {5.5e-6, 13.0e-6, 22.4e-6}; - final double mieBeta = 21.0e-6 * 1.1; - final double[] ozoneBeta = {0.650e-6, 1.881e-6, 0.085e-6}; - final double oy = planetR + 2000.0; - // Larger root of ray vs atmosphere sphere, origin (0, oy, 0). - double b = oy * dy; - double tEnd = -b + Math.sqrt(Math.max(b * b - (oy * oy - atmosR * atmosR), 0.0)); - double seg = tEnd / 8.0; - double odR = 0.0, odM = 0.0, odO = 0.0; - for (int i = 0; i < 8; i++) { - double t = seg * (i + 0.5); - double px = dx * t, py = oy + dy * t, pz = dz * t; - double h = Math.sqrt(px * px + py * py + pz * pz) - planetR; - odR += Math.exp(-h / 8000.0) * seg; - odM += Math.exp(-h / 1200.0) * seg; - odO += Math.max(0.0, 1.0 - Math.abs(h - 25000.0) / 15000.0) * seg; - } - for (int i = 0; i < 3; i++) { - out[i] = (float) Math.exp(-(rayBeta[i] * odR + mieBeta * odM + ozoneBeta[i] * odO)); - } + /** OCIO cg-config-v4.0.0 ACES 2.0: Linear Rec.709 (sRGB)/D65 to ACEScg/AP1/D60. */ + private static Float4 linearAcesCgFromBt709(double r, double g, double b, float w) { + return new Float4( + (float) (0.61309743 * r + 0.33952314 * g + 0.04737945 * b), + (float) (0.07019372 * r + 0.91635388 * g + 0.01345240 * b), + (float) (0.02061559 * r + 0.10956977 * g + 0.86981463 * b), + w); + } + + private static double srgbToLinear(double value) { + return value <= 0.04045 ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); } public void destroy() { @@ -1232,6 +1475,7 @@ public void destroy() { hdrDisplayImage.destroy(); hdrDisplayImage = null; } + destroyBloomLevels(); if (fgHudlessImage != null) { fgHudlessImage.destroy(); fgHudlessImage = null; @@ -1255,6 +1499,31 @@ public void destroy() { displayPipeline.destroy(); displayPipeline = null; } + if (bloomPipeline != null) { + bloomPipeline.destroy(); + bloomPipeline = null; + } + if (skyLut != null) { + skyLut.destroy(); + skyLut = null; + } + if (debugPresentPipeline != null) { + debugPresentPipeline.destroy(); + debugPresentPipeline = null; + } + if (sdrToneLut != null) { + sdrToneLut.destroy(); + sdrToneLut = null; + } + if (hdrToneLut != null) { + hdrToneLut.destroy(); + hdrToneLut = null; + } + if (lookLut != null) { + lookLut.destroy(); + lookLut = null; + } + loadedHdrLutNits = -1; if (hdrCompositePipeline != null) { hdrCompositePipeline.destroy(); hdrCompositePipeline = null; @@ -1416,7 +1685,7 @@ public void presentHdr(VulkanCommandEncoder enc, long swapchainImage, int swapW, VkDependencyInfo preDep = VkDependencyInfo.calloc(stack).sType$Default().pMemoryBarriers(pre); KHRSynchronization2.vkCmdPipelineBarrier2KHR(cmd, preDep); hdrCompositePipeline.setImages(hdrDisplayImage.view, overlayView, hdrUiSampler); - hdrCompositePipeline.dispatch(cmd, src.width, src.height, CausticaConfig.Rt.Hdr.paperWhiteNits()); + hdrCompositePipeline.dispatch(cmd, src.width, src.height, CausticaConfig.Rt.Hdr.uiNits()); } RtUiOverlay.markConsumed(); } @@ -1500,7 +1769,10 @@ private boolean ensureUiSampler(RtContext ctx) { * not produce an HDR image ({@link #isHdrPresentActive()} false). */ public boolean isPqSdrPresentActive() { - return CausticaConfig.Rt.Hdr.enabled() + // The conversion is needed only while the CURRENT swapchain is PQ and this frame has no HDR + // image (menus/loading, or the short interval after the toggle changed but before configure()). + // Once configure recreates a native-SDR swapchain, vanilla's ordinary blit is correct. + return CausticaConfig.Rt.Hdr.swapchainPqActive() && !isHdrPresentActive(); } @@ -1543,7 +1815,7 @@ public boolean presentSdrToPq(VulkanCommandEncoder enc, long swapchainImage, int KHRSynchronization2.vkCmdPipelineBarrier2KHR(cmd, preDep); sdrPresentPipeline.setImages(dst.view, sdrMainView, hdrUiSampler); - sdrPresentPipeline.dispatch(cmd, dst.width, dst.height, CausticaConfig.Rt.Hdr.paperWhiteNits()); + sdrPresentPipeline.dispatch(cmd, dst.width, dst.height, CausticaConfig.Rt.Hdr.uiNits()); // Swapchain UNDEFINED -> TRANSFER_DST, plus make the compute write visible to the blit read. VkImageMemoryBarrier2.Buffer toDst = VkImageMemoryBarrier2.calloc(1, stack).sType$Default(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java index 7165b9f0..079f9224 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java @@ -261,6 +261,12 @@ public RtBuffer createUploadBuffer(long size, String label) { Vma.VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT, 0L); } + /** Create a transient, persistently mapped buffer for synchronous GPU-to-CPU transfers. */ + public RtBuffer createReadbackBuffer(long size, String label) { + return createBuffer(size, VK10.VK_BUFFER_USAGE_TRANSFER_DST_BIT, true, label, false, + Vma.VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT, 0L); + } + private RtBuffer createBuffer(long size, int usage, boolean hostVisible, String label, boolean asyncShared, int hostAccessFlags, long addressAlignment) { if (addressAlignment < 0L diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index 616135f7..b9bcc97e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -198,9 +198,9 @@ public static boolean enabledByProperty() { RAY_QUERY_FEATURE); private enum SerBackend { - NONE("none", null, "world_primary.rgen.spv", "world.rgen.spv"), + NONE("none", null, "primary.rgen.spv", "indirect.rgen.spv"), EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - "world_primary.rgen.spv", "world_ser.rgen.spv"); + "primary.rgen.spv", "indirect_ser.rgen.spv"); final String label; final String extensionName; @@ -307,7 +307,7 @@ public static int computeQueueIndex() { * Reserve one additional physical queue at device-creation time. Minecraft's queue-family map only * requests handles for its graphics/compute/transfer queues; fetching a higher queue index without first * increasing the matching {@link VkDeviceQueueCreateInfo#queueCount()} would be invalid. Prefer a - * compute-only family, but add a previously-unused compute family when that leaves the Minecraft queues + * compute-only family, but add a dedicated compute family when that leaves the Minecraft queues * untouched and has a free physical slot. */ public static void reserveComputeQueue(VkDeviceCreateInfo deviceCreateInfo, diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 5310114a..7722b8fd 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -64,10 +64,16 @@ public final class RtFrameStats { "frame.prepareTlas", "frame.recordTlas", "frame.trace", + "frame.skyLut", + // Wavefront trace and downstream debug stages. + "frame.tracePrimary", + "frame.traceIndirect", "frame.exposure", "frame.dlssRr", "frame.upscale", + "frame.bloom", "frame.displayMap", + "frame.debugPresent", "frame.copyOutput" }, new String[] {"sectionsSnapshotted", "sectionCopies", "terrainBuildsCompleted", diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java index d60b7898..4ba56fdb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java @@ -1,26 +1,29 @@ package dev.comfyfluffy.caustica.rt; import java.nio.IntBuffer; +import java.util.List; import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.EXTHdrMetadata; import org.lwjgl.vulkan.KHRSurface; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkHdrMetadataEXT; import org.lwjgl.vulkan.VkPhysicalDevice; import org.lwjgl.vulkan.VkSurfaceFormatKHR; +import com.mojang.blaze3d.vulkan.VulkanPhysicalDevice; import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; /** - * HDR display support — capability detection/logging. This class does not change any rendering behavior; it - * only enumerates and logs what the swapchain surface can present so the swapchain-ownership code knows - * whether HDR10 (PQ) is actually available on this driver, window system, compositor, and monitor. + * 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. * - *

Important: extended color spaces (scRGB linear, HDR10 PQ, …) are only reported by - * {@code vkGetPhysicalDeviceSurfaceFormatsKHR} when the instance was created with - * {@code VK_EXT_swapchain_colorspace} enabled. Minecraft's instance does not enable it, so on a stock - * instance this enumeration is expected to show only {@code SRGB_NONLINEAR} (color space 0). Seeing only - * color space 0 here is the concrete signal that the later phase must add an instance-extension hook before - * any HDR swapchain is possible — that is exactly the gap this Phase 0 logging is meant to surface. + *

Extended color spaces are reported only when the instance enables + * {@code VK_EXT_swapchain_colorspace}. {@code VulkanInstanceMixin} enables it when available; this class + * then reports the surface formats and selects HDR10/PQ capability from the advertised pairs. */ public final class RtHdr { // VK_EXT_swapchain_colorspace color-space enum values (not all are in the LWJGL VK10 constants). @@ -41,18 +44,102 @@ public final class RtHdr { private static final int CS_EXTENDED_SRGB_NONLINEAR = 1000104014; private static volatile boolean surfaceLogged; + private static volatile boolean hdrMetadataExtensionEnabled; private RtHdr() { } + /** + * Opportunistically enables {@code VK_EXT_hdr_metadata}. It is a function-only device extension, so + * there is no feature struct to chain into device creation. HDR presentation still works when it is + * absent; only the mastering hints to the presentation engine are unavailable. + */ + public static void addDeviceExtension(List augmentedExtensions, VulkanPhysicalDevice physicalDevice) { + hdrMetadataExtensionEnabled = false; + String extension = EXTHdrMetadata.VK_EXT_HDR_METADATA_EXTENSION_NAME; + if (!physicalDevice.hasDeviceExtension(extension)) { + CausticaMod.LOGGER.warn("HDR: device [{}] does not support {}; static mastering metadata disabled", + physicalDevice.deviceName(), extension); + return; + } + if (!augmentedExtensions.contains(extension)) { + augmentedExtensions.add(extension); + } + hdrMetadataExtensionEnabled = true; + CausticaMod.LOGGER.info("HDR: enabling {} for PQ swapchain mastering metadata", extension); + } + + /** Whether {@code VK_EXT_hdr_metadata} was included in the device extension list. */ + public static boolean metadataExtensionEnabled() { + return hdrMetadataExtensionEnabled; + } + + /** + * 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. + */ + public static boolean applyMasteringMetadata(VkDevice device, long swapchain, int masteringPeakNits) { + if (!hdrMetadataExtensionEnabled || swapchain == 0L) { + return false; + } + MasteringMetadata values = masteringMetadata(masteringPeakNits); + try (MemoryStack stack = MemoryStack.stackPush()) { + VkHdrMetadataEXT.Buffer metadata = VkHdrMetadataEXT.calloc(1, stack); + VkHdrMetadataEXT entry = metadata.get(0).sType$Default(); + entry.displayPrimaryRed().set(values.red().x(), values.red().y()); + entry.displayPrimaryGreen().set(values.green().x(), values.green().y()); + entry.displayPrimaryBlue().set(values.blue().x(), values.blue().y()); + entry.whitePoint().set(values.white().x(), values.white().y()); + entry.maxLuminance(values.maxLuminance()); + entry.minLuminance(values.minLuminance()); + entry.maxContentLightLevel(values.maxContentLightLevel()); + entry.maxFrameAverageLightLevel(values.maxFrameAverageLightLevel()); + EXTHdrMetadata.vkSetHdrMetadataEXT(device, stack.longs(swapchain), metadata); + } + CausticaMod.LOGGER.info( + "HDR: set swapchain mastering metadata: Rec.2020/D65, min={} nits, peak/MaxCLL={} nits, MaxFALL=unknown", + values.minLuminance(), values.maxLuminance()); + return true; + } + + static MasteringMetadata masteringMetadata(int masteringPeakNits) { + if (masteringPeakNits <= 0) { + throw new IllegalArgumentException("masteringPeakNits must be positive"); + } + float peak = masteringPeakNits; + return new MasteringMetadata( + new Chromaticity(0.708f, 0.292f), + new Chromaticity(0.170f, 0.797f), + new Chromaticity(0.131f, 0.046f), + new Chromaticity(0.3127f, 0.3290f), + peak, 0.0001f, peak, 0.0f); + } + + record Chromaticity(float x, float y) { + } + + record MasteringMetadata( + Chromaticity red, + Chromaticity green, + Chromaticity blue, + Chromaticity white, + float maxLuminance, + float minLuminance, + float maxContentLightLevel, + float maxFrameAverageLightLevel) { + } + /** 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={} paperWhite={}nits peak={}nits -> {} (headroom={})", + "HDR config: enabled={} ui={}nits peak={}nits -> {}", CausticaConfig.Rt.Hdr.enabled(), - CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS.value(), CausticaConfig.Rt.Hdr.PEAK_NITS.value(), - CausticaConfig.Rt.Hdr.enabled() ? "HDR display path active" : "SDR display path", - CausticaConfig.Rt.Hdr.headroom()); + CausticaConfig.Rt.Hdr.UI_NITS.value(), CausticaConfig.Rt.Hdr.PEAK_NITS.value(), + CausticaConfig.Rt.Hdr.enabled() ? "HDR display path active" : "SDR display path"); } /** diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtLookPackage.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtLookPackage.java new file mode 100644 index 00000000..f0c002ec --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtLookPackage.java @@ -0,0 +1,314 @@ +package dev.comfyfluffy.caustica.rt; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Immutable, versioned calibration package for the scene-to-display pipeline. + * + *

The default package is deliberately a classpath asset rather than mutable TOML configuration: + * exposure shaping, its scene-referred LMT, and the photometric light anchors are one authored look + * and must move together. A future package selector can load another directory with the same schema + * without reintroducing independent knobs. + */ +public record RtLookPackage( + int schemaVersion, + String id, + int packageVersion, + Exposure exposure, + String lmtResource, + Bloom bloom, + Lighting lighting, + Sky sky) { + public static final int SCHEMA_VERSION = 4; + /** Mirrors RtBloomPipeline.MAX_LEVELS; validated here so a bad package fails at load, not at resize. */ + private static final int MAX_BLOOM_LEVELS = 8; + public static final String DEFAULT_ID = "default"; + public static final String DEFAULT_JSON = "/caustica/color/looks/default/look.json"; + private static final RtLookPackage DEFAULT = load(DEFAULT_JSON); + + public static RtLookPackage current() { + return DEFAULT; + } + + static RtLookPackage parse(JsonObject root, String jsonResource) { + int schemaVersion = requiredInt(root, "schemaVersion"); + if (schemaVersion != SCHEMA_VERSION) { + throw new IllegalArgumentException(jsonResource + ": unsupported look-package schema " + + schemaVersion); + } + String id = requiredString(root, "id"); + if (id.isBlank()) { + throw new IllegalArgumentException(jsonResource + ": id must not be blank"); + } + int packageVersion = requiredInt(root, "packageVersion"); + if (packageVersion < 1) { + throw new IllegalArgumentException(jsonResource + ": packageVersion must be at least 1"); + } + + JsonObject exposureJson = requiredObject(root, "exposure"); + Exposure exposure = new Exposure( + requiredFinite(exposureJson, "minEv"), + requiredFinite(exposureJson, "maxEv"), + requiredString(exposureJson, "curve")); + if (exposure.minEv() > exposure.maxEv()) { + throw new IllegalArgumentException(jsonResource + ": exposure.minEv must be <= maxEv"); + } + if (exposure.curve().isBlank()) { + throw new IllegalArgumentException(jsonResource + ": exposure.curve must not be blank"); + } + validateCurve(exposure.curve(), jsonResource); + + JsonObject lmt = requiredObject(root, "lmt"); + String lmtFile = requiredString(lmt, "resource"); + if (lmtFile.isBlank() || lmtFile.contains("/") || lmtFile.contains("\\") + || ".".equals(lmtFile) || "..".equals(lmtFile)) { + throw new IllegalArgumentException(jsonResource + ": lmt.resource must be a local file name"); + } + int slash = jsonResource.lastIndexOf('/'); + if (slash < 0) { + throw new IllegalArgumentException("look-package resource must be absolute: " + jsonResource); + } + String lmtResource = jsonResource.substring(0, slash + 1) + lmtFile; + + JsonObject bloomJson = requiredObject(root, "bloom"); + Bloom bloom = new Bloom( + requiredFinite(bloomJson, "strength"), + requiredFinite(bloomJson, "thresholdSceneLinear"), + requiredFinite(bloomJson, "softKneeFraction"), + requiredFinite(bloomJson, "radius"), + requiredInt(bloomJson, "levels")); + requireRange(bloom.strength(), 0.0f, 2.0f, jsonResource, "bloom.strength"); + requireRange(bloom.thresholdSceneLinear(), 0.0f, 65504.0f, + jsonResource, "bloom.thresholdSceneLinear"); + requireRange(bloom.softKneeFraction(), 0.0f, 1.0f, + jsonResource, "bloom.softKneeFraction"); + requireRange(bloom.radius(), 0.25f, 4.0f, jsonResource, "bloom.radius"); + if (bloom.levels() < 1 || bloom.levels() > MAX_BLOOM_LEVELS) { + throw new IllegalArgumentException(jsonResource + ": bloom.levels must be in [1," + + MAX_BLOOM_LEVELS + "]"); + } + + JsonObject lightingJson = requiredObject(root, "lighting"); + Lighting lighting = new Lighting( + positive(lightingJson, "sunIlluminanceLux", jsonResource), + positive(lightingJson, "moonIlluminanceLux", jsonResource), + positive(lightingJson, "blockEmissionLuminanceCdM2", jsonResource), + nonNegative(lightingJson, "nightAirglowLuminanceCdM2", jsonResource), + nonNegative(lightingJson, "starLuminanceCdM2", jsonResource), + nonNegative(lightingJson, "moonPhaseFixedFraction", jsonResource)); + if (lighting.moonPhaseFixedFraction() > 1.0f) { + throw new IllegalArgumentException(jsonResource + + ": lighting.moonPhaseFixedFraction must be in [0,1]"); + } + + JsonObject skyJson = requiredObject(root, "sky"); + Sky sky = new Sky( + requiredFinite(skyJson, "sunNoonSouthTiltDegrees"), + nonNegative(skyJson, "sunAngularRadiusDegrees", jsonResource, "sky"), + nonNegative(skyJson, "moonAngularRadiusDegrees", jsonResource, "sky"), + positive(skyJson, "sunDiscHalfAngleDegrees", jsonResource, "sky"), + positive(skyJson, "moonDiscHalfAngleDegrees", jsonResource, "sky"), + nonNegative(skyJson, "groundAlbedo", jsonResource, "sky"), + nonNegative(skyJson, "horizonSoftenDegrees", jsonResource, "sky")); + requireRange(sky.sunNoonSouthTiltDegrees(), -89.0f, 89.0f, + jsonResource, "sky.sunNoonSouthTiltDegrees"); + // The NEE radius only jitters the shadow ray, so it sets penumbra softness; the disc half-angle is + // how large the body is DRAWN, matching vanilla's quads (which are ~60x the real sun). Both are + // angles on the sky, so both stay well inside a quarter turn. + requireRange(sky.sunAngularRadiusDegrees(), 0.0f, 20.0f, + jsonResource, "sky.sunAngularRadiusDegrees"); + requireRange(sky.moonAngularRadiusDegrees(), 0.0f, 20.0f, + jsonResource, "sky.moonAngularRadiusDegrees"); + requireRange(sky.sunDiscHalfAngleDegrees(), 0.0f, 45.0f, + jsonResource, "sky.sunDiscHalfAngleDegrees"); + requireRange(sky.moonDiscHalfAngleDegrees(), 0.0f, 45.0f, + jsonResource, "sky.moonDiscHalfAngleDegrees"); + requireRange(sky.groundAlbedo(), 0.0f, 1.0f, jsonResource, "sky.groundAlbedo"); + // Beyond a quarter turn the fade would still be running at the nadir, leaving the lower hemisphere + // with no settled colour at all. + requireRange(sky.horizonSoftenDegrees(), 0.0f, 90.0f, + jsonResource, "sky.horizonSoftenDegrees"); + + return new RtLookPackage(schemaVersion, id, packageVersion, exposure, lmtResource, bloom, + lighting, sky); + } + + private static RtLookPackage load(String resource) { + try (InputStream stream = RtLookPackage.class.getResourceAsStream(resource)) { + if (stream == null) { + throw new IllegalStateException("missing look package " + resource); + } + try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + RtLookPackage value = parse(JsonParser.parseReader(reader).getAsJsonObject(), resource); + if (RtLookPackage.class.getResource(value.lmtResource()) == null) { + throw new IllegalStateException(resource + ": missing LMT " + value.lmtResource()); + } + return value; + } + } catch (IOException | RuntimeException e) { + throw new ExceptionInInitializerError(e); + } + } + + private static JsonObject requiredObject(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonObject()) { + throw new IllegalArgumentException("missing object " + name); + } + return value.getAsJsonObject(); + } + + private static String requiredString(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("missing string " + name); + } + return value.getAsString(); + } + + private static int requiredInt(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException("missing integer " + name); + } + return value.getAsInt(); + } + + private static float requiredFinite(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException("missing number " + name); + } + float result = value.getAsFloat(); + if (!Float.isFinite(result)) { + throw new IllegalArgumentException(name + " must be finite"); + } + return result; + } + + private static float positive(JsonObject object, String name, String resource) { + return positive(object, name, resource, "lighting"); + } + + private static float positive(JsonObject object, String name, String resource, String section) { + float value = requiredFinite(object, name); + if (value <= 0.0f) { + throw new IllegalArgumentException(resource + ": " + section + "." + name + + " must be positive"); + } + return value; + } + + private static float nonNegative(JsonObject object, String name, String resource) { + return nonNegative(object, name, resource, "lighting"); + } + + private static float nonNegative(JsonObject object, String name, String resource, String section) { + float value = requiredFinite(object, name); + if (value < 0.0f) { + throw new IllegalArgumentException(resource + ": " + section + "." + name + + " must be non-negative"); + } + return value; + } + + private static void requireRange(float value, float min, float max, String resource, String name) { + if (value < min || value > max) { + throw new IllegalArgumentException(resource + ": " + name + " must be in [" + + min + "," + max + "]"); + } + } + + private static void validateCurve(String spec, String resource) { + String[] points = spec.split(","); + if (points.length != 4) { + throw new IllegalArgumentException(resource + + ": exposure.curve must contain exactly four sceneEv:compensationEv points"); + } + float previousSceneEv = Float.NEGATIVE_INFINITY; + for (int i = 0; i < points.length; i++) { + String[] pair = points[i].trim().split(":", -1); + if (pair.length != 2) { + throw new IllegalArgumentException(resource + ": exposure.curve point " + (i + 1) + + " is not sceneEv:compensationEv"); + } + float sceneEv; + float compensationEv; + try { + sceneEv = Float.parseFloat(pair[0].trim()); + compensationEv = Float.parseFloat(pair[1].trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(resource + ": exposure.curve point " + (i + 1) + + " contains a non-number", e); + } + if (!Float.isFinite(sceneEv) || !Float.isFinite(compensationEv)) { + throw new IllegalArgumentException(resource + ": exposure.curve point " + (i + 1) + + " must be finite"); + } + if (sceneEv - previousSceneEv < 1.0e-4f) { + throw new IllegalArgumentException(resource + + ": exposure.curve scene EV points must be strictly increasing"); + } + previousSceneEv = sceneEv; + } + } + + public record Exposure(float minEv, float maxEv, String curve) { + } + + /** + * Bloom pyramid (see {@code RtBloomPipeline}). {@code radius} is the upsample tent radius in SOURCE + * texels, so it needs no resolution scaling; {@code levels} is how many octaves of skirt the effect + * reaches over, which is what sets its width. + */ + public record Bloom(float strength, float thresholdSceneLinear, float softKneeFraction, float radius, + int levels) { + } + + /** + * Photometric anchors. {@code nightAirglowLuminanceCdM2} is airglow plus unresolved starlight—the + * physical floor of a moonless night, approximately 1e-3 cd/m². Atmospheric multiple scattering is + * evaluated separately. + */ + public record Lighting( + float sunIlluminanceLux, + float moonIlluminanceLux, + float blockEmissionLuminanceCdM2, + float nightAirglowLuminanceCdM2, + float starLuminanceCdM2, + float moonPhaseFixedFraction) { + public float moonPhaseFraction() { + return 1.0f - moonPhaseFixedFraction; + } + } + + /** + * Sky geometry. These were {@code caustica.rt.*} system properties, which left the shape of the sky + * outside the versioned package that owns every other photometric decision; they belong with the + * exposure curve, the LMT and the light anchors already authored here. + */ + public record Sky( + float sunNoonSouthTiltDegrees, + /** Half-angle the NEE shadow ray samples about the body: sets penumbra softness only. */ + float sunAngularRadiusDegrees, + float moonAngularRadiusDegrees, + /** Half-angle the body is DRAWN at, matching vanilla's quads: atan(0.30) and atan(0.20). */ + float sunDiscHalfAngleDegrees, + float moonDiscHalfAngleDegrees, + float groundAlbedo, + /** + * Dip over which the atmosphere's ground fades in below the horizon. The surface is a real + * discontinuity in the model — ~20 EV per degree of elevation at sea level under a high sun, + * and up to ~50 at a low one — and Minecraft never shows the terrain that would justify it, so + * without this the horizon reads as a hard grey line. Zero restores the hard ground. + */ + float horizonSoftenDegrees) { + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriter.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriter.java new file mode 100644 index 00000000..217450c7 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriter.java @@ -0,0 +1,215 @@ +package dev.comfyfluffy.caustica.rt; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +/** + * Minimal uncompressed scanline OpenEXR writer for RGBA half-float screenshots. + * + *

Keeping this in Java makes F2 capture self-contained: neither Python nor {@code uv} is needed while + * Minecraft is running. The UV environment remains the reproducible workstation for inspecting and + * processing the resulting files. + */ +final class RtOpenExrWriter { + private static final int EXR_MAGIC = 20_000_630; + private static final int EXR_VERSION = 2; + private static final int HALF = 1; + private static final int NO_COMPRESSION = 0; + private static final DateTimeFormatter CAPTURE_DATE = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss"); + private static final int[] CHANNEL_COMPONENT = {3, 2, 1, 0}; // A, B, G, R (lexicographic channel order) + private static final String[] CHANNEL_NAMES = {"A", "B", "G", "R"}; + + private RtOpenExrWriter() { + } + + record Metadata( + float preExposure, + float residualExposure, + float absoluteExposure, + String exposureMode, + float evScene, + float evTarget, + float evApplied, + String look, + long frame + ) { + Metadata { + Objects.requireNonNull(exposureMode, "exposureMode"); + Objects.requireNonNull(look, "look"); + } + } + + /** + * Writes RGBA half values whose rows are in Vulkan image order (row zero is the bottom row). + * EXR scanline zero is the top row, so scanlines are reversed while writing. + */ + static void write(Path output, int width, int height, short[] rgba, Metadata metadata) throws IOException { + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(rgba, "rgba"); + Objects.requireNonNull(metadata, "metadata"); + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("EXR dimensions must be positive: " + width + "x" + height); + } + int pixelCount = Math.multiplyExact(width, height); + if (rgba.length != Math.multiplyExact(pixelCount, 4)) { + throw new IllegalArgumentException("Expected " + (pixelCount * 4) + " RGBA samples, got " + rgba.length); + } + + byte[] header = header(width, height, metadata); + long rowDataBytes = Math.multiplyExact((long) width, 8L); + long scanlineBlockBytes = Math.addExact(8L, rowDataBytes); + long firstScanlineOffset = Math.addExact(header.length, Math.multiplyExact((long) height, 8L)); + + Path parent = output.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try (OutputStream raw = Files.newOutputStream(output, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + BufferedOutputStream stream = new BufferedOutputStream(raw, 1 << 20)) { + stream.write(header); + for (int y = 0; y < height; y++) { + writeLongLe(stream, Math.addExact(firstScanlineOffset, Math.multiplyExact((long) y, scanlineBlockBytes))); + } + + byte[] row = new byte[Math.toIntExact(rowDataBytes)]; + for (int y = 0; y < height; y++) { + writeIntLe(stream, y); + writeIntLe(stream, row.length); + int sourceRow = height - 1 - y; + int cursor = 0; + for (int component : CHANNEL_COMPONENT) { + int source = (sourceRow * width * 4) + component; + for (int x = 0; x < width; x++, source += 4) { + short bits = rgba[source]; + row[cursor++] = (byte) bits; + row[cursor++] = (byte) (bits >>> 8); + } + } + stream.write(row); + } + } + } + + private static byte[] header(int width, int height, Metadata metadata) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024); + writeIntLe(bytes, EXR_MAGIC); + writeIntLe(bytes, EXR_VERSION); + + ByteArrayOutputStream channels = new ByteArrayOutputStream(); + for (String name : CHANNEL_NAMES) { + writeCString(channels, name); + writeIntLe(channels, HALF); + channels.write(0); // pLinear + channels.write(0); + channels.write(0); + channels.write(0); + writeIntLe(channels, 1); // xSampling + writeIntLe(channels, 1); // ySampling + } + channels.write(0); + attribute(bytes, "channels", "chlist", channels.toByteArray()); + attribute(bytes, "compression", "compression", new byte[]{NO_COMPRESSION}); + attribute(bytes, "dataWindow", "box2i", box2i(width, height)); + attribute(bytes, "displayWindow", "box2i", box2i(width, height)); + attribute(bytes, "lineOrder", "lineOrder", new byte[]{0}); + attribute(bytes, "pixelAspectRatio", "float", floats(1.0f)); + attribute(bytes, "screenWindowCenter", "v2f", floats(0.0f, 0.0f)); + attribute(bytes, "screenWindowWidth", "float", floats(1.0f)); + + // ACEScg/AP1 primaries and ACES white (D60). This is the standard EXR chromaticities attribute, + // so color-managed applications do not have to infer the working space from the filename. + attribute(bytes, "chromaticities", "chromaticities", floats( + 0.713f, 0.293f, + 0.165f, 0.830f, + 0.128f, 0.044f, + 0.32168f, 0.33767f)); + attribute(bytes, "adoptedNeutral", "v2f", floats(0.32168f, 0.33767f)); + + OffsetDateTime now = OffsetDateTime.now(); + stringAttribute(bytes, "capDate", CAPTURE_DATE.format(now)); + floatAttribute(bytes, "utcOffset", now.getOffset().getTotalSeconds()); + stringAttribute(bytes, "software", "Caustica"); + stringAttribute(bytes, "comments", + "Residual-exposed scene-linear ACEScg; before Look/LMT, ACES output transform, and UI"); + stringAttribute(bytes, "causticaColorSpace", "ACEScg (AP1/D60), scene-linear"); + stringAttribute(bytes, "causticaEncoding", + "RGB = sceneLinear * preExposure * residualExposure"); + stringAttribute(bytes, "causticaRecovery", "sceneLinear = RGB / causticaAbsoluteExposure"); + floatAttribute(bytes, "causticaPreExposure", metadata.preExposure()); + floatAttribute(bytes, "causticaResidualExposure", metadata.residualExposure()); + floatAttribute(bytes, "causticaAbsoluteExposure", metadata.absoluteExposure()); + stringAttribute(bytes, "causticaExposureMode", metadata.exposureMode()); + finiteFloatAttribute(bytes, "causticaEvScene", metadata.evScene()); + finiteFloatAttribute(bytes, "causticaEvTarget", metadata.evTarget()); + finiteFloatAttribute(bytes, "causticaEvApplied", metadata.evApplied()); + stringAttribute(bytes, "causticaLookIntent", metadata.look()); + stringAttribute(bytes, "causticaFrame", Long.toUnsignedString(metadata.frame())); + bytes.write(0); // end of header attributes + return bytes.toByteArray(); + } + + private static byte[] box2i(int width, int height) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(16); + writeIntLe(bytes, 0); + writeIntLe(bytes, 0); + writeIntLe(bytes, width - 1); + writeIntLe(bytes, height - 1); + return bytes.toByteArray(); + } + + private static byte[] floats(float... values) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(values.length * Float.BYTES); + for (float value : values) { + writeIntLe(bytes, Float.floatToRawIntBits(value)); + } + return bytes.toByteArray(); + } + + private static void floatAttribute(OutputStream output, String name, float value) throws IOException { + attribute(output, name, "float", floats(value)); + } + + private static void finiteFloatAttribute(OutputStream output, String name, float value) throws IOException { + if (Float.isFinite(value)) { + floatAttribute(output, name, value); + } + } + + private static void stringAttribute(OutputStream output, String name, String value) throws IOException { + attribute(output, name, "string", value.getBytes(StandardCharsets.UTF_8)); + } + + private static void attribute(OutputStream output, String name, String type, byte[] value) throws IOException { + writeCString(output, name); + writeCString(output, type); + writeIntLe(output, value.length); + output.write(value); + } + + private static void writeCString(OutputStream output, String value) throws IOException { + output.write(value.getBytes(StandardCharsets.US_ASCII)); + output.write(0); + } + + private static void writeIntLe(OutputStream output, int value) throws IOException { + output.write(value); + output.write(value >>> 8); + output.write(value >>> 16); + output.write(value >>> 24); + } + + private static void writeLongLe(OutputStream output, long value) throws IOException { + writeIntLe(output, (int) value); + writeIntLe(output, (int) (value >>> 32)); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtReflex.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtReflex.java index 43f44e5c..974c6fcb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtReflex.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtReflex.java @@ -18,10 +18,9 @@ import java.nio.LongBuffer; /** - * NVIDIA Reflex (Phase 1b): the per-frame sleep/pacing loop + latency markers, built on the timeline - * semaphore signaled by {@code vkLatencySleepNV}. Phase 0 (extension + capability probe, see - * {@link RtDeviceBringup}) and Phase 1a (swapchain {@code VkSwapchainLatencyCreateInfoNV}, see - * {@code VulkanGpuSurfaceMixin}) are prerequisites this builds on. + * NVIDIA Reflex per-frame sleep/pacing and latency markers, built on the timeline semaphore signaled + * by {@code vkLatencySleepNV}. {@link RtDeviceBringup} enables the device support and + * {@code VulkanGpuSurfaceMixin} configures each swapchain for latency mode. * *

{@code vkLatencySleepNV} does not itself block — per the extension, it schedules the driver to signal * a semaphore value once the paced frame-start time is reached, and the caller is expected to wait on that diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSceneUnits.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSceneUnits.java new file mode 100644 index 00000000..fc015a57 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSceneUnits.java @@ -0,0 +1,36 @@ +package dev.comfyfluffy.caustica.rt; + +/** + * The renderer's scene-value unit convention. + * + *

A scene value is luminance in cd/m² (nits): {@code 1.0} = 1 cd/m². This is a definition, + * not a tuning knob — nothing here should ever be exposed as config. Light constants are authored + * against it (sun ≈ 100,000 lux, full moon ≈ 0.25 lux, torch flame ≈ 15,000 cd/m²), which is what + * makes them individually checkable against published photometric figures instead of only + * meaningful relative to each other. + * + *

The renderer is RGB rather than spectral, so no radiometric-to-photometric conversion is + * modelled: "luminance" means the AP1/D60 Y of the stored ACEScg triple, consistent with + * {@code ACESCG_LUMA} in the metering shaders. + * + *

The EV100 metering scale, {@code RtLookPackage}'s sun/moon illuminance, block/star/night-sky + * luminance, exposure curve, and shader-derived disc and atmosphere levels all use this convention. + */ +public final class RtSceneUnits { + /** cd/m² that a scene value of {@code 1.0} represents. The unit definition; see class docs. */ + public static final float NITS_PER_UNIT = 1.0f; + + /** + * Additive offset taking {@code log2(sceneValue)} to EV100, the standard photographic scale: + * {@code EV100 = log2(L · S / K)} with ISO {@code S = 100} and reflected-light meter constant + * {@code K = 12.5}, i.e. {@code EV100 = log2(8 · L)} for {@code L} in cd/m². + * + *

Metering and the exposure compensation curve both run on this scale so the curve's control + * points can be read against published tables (sunny-16 ≈ EV 15, overcast ≈ EV 12, full moon + * ≈ EV −3) rather than against a number meaningful only inside this codebase. + */ + public static final float EV100_OFFSET = (float) (Math.log(8.0 * NITS_PER_UNIT) / Math.log(2.0)); + + private RtSceneUnits() { + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtUiOverlay.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtUiOverlay.java index 5f014d70..f842ef30 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtUiOverlay.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtUiOverlay.java @@ -21,7 +21,7 @@ import net.minecraft.client.renderer.RenderPipelines; /** - * HDR Phase 2 (step A) — transparent final-UI overlay. World-space overlay features and the vanilla GUI/HUD + * Transparent final-UI overlay. World-space overlay features and the vanilla GUI/HUD * are routed into one transparent {@code RGBA8} target, then that single image is composited back over the * world (from {@code GameRendererMixin}, right after {@code GuiRenderer.render} returns). In SDR this * reproduces vanilla; the point is to keep SDR-authored UI out of the world's HDR tonemap once HDR @@ -66,8 +66,8 @@ private RtUiOverlay() { } /** - * Runs regardless of HDR mode (the GUI redirect + composite-back reproduces vanilla exactly in SDR — - * GPU-verified during HDR Phase 2 step A) since {@code RtWorldOverlay}'s composite point is this same + * Runs regardless of HDR mode because the GUI redirect and composite-back reproduce vanilla in SDR, + * while {@code RtWorldOverlay}'s composite point is this same * seam and needs it to fire every frame. Active only once the game has finished loading: the composite * pipeline lazily compiles its shaders, which are not available during the loading screen (would crash * with "Couldn't find source for core/screenquad"). Gating the redirect here keeps the loading-screen diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtBuffer.java b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtBuffer.java index 9ff48187..b5f3c578 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtBuffer.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtBuffer.java @@ -60,4 +60,21 @@ public void flush(long offset, long length) { } Vma.vmaFlushAllocation(vma, allocation, offset, length); } + + /** Invalidate host reads after GPU writes; VMA treats coherent memory as a no-op. */ + public void invalidate() { + invalidate(0L, size); + } + + /** Invalidate a GPU-written byte range; VMA handles non-coherent atom alignment internally. */ + public void invalidate(long offset, long length) { + if (!hostVisible) { + throw new IllegalStateException("Cannot invalidate a non-host-visible buffer"); + } + if (offset < 0L || length < 0L || offset > size || length > size - offset) { + throw new IndexOutOfBoundsException("Invalidate range " + offset + ".." + (offset + length) + + " exceeds buffer size " + size); + } + Vma.vmaInvalidateAllocation(vma, allocation, offset, length); + } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtCuboidEmitter.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtCuboidEmitter.java index a4fef7e6..04059b25 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtCuboidEmitter.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtCuboidEmitter.java @@ -114,7 +114,7 @@ private void emitEightCornerCube(EightCornerCube cube, PoseStack.Pose pose, } } - /** A2's verified direct-polygon path retained for nonstandard but valid cube topology. */ + /** Direct-polygon path for nonstandard but valid cube topology. */ private void emitGenericCube(ModelPart.Cube cube, PoseStack.Pose pose, RtEntityCapture capture, int color) { Matrix4f matrix = pose.pose(); 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 727f5263..42e0c6bd 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -71,8 +71,8 @@ * *

Per-frame cost is real (per-entity capture + buffer uploads + a BLAS build); capped by {@code * -Dcaustica.rt.maxEntities}. Changed-entity geometry and refit scratch reuse the existing per-entity - * graphics-timeline-guarded ring; motion uploads suballocate from a guarded per-frame-slot arena. A generic - * size-bucketed recycling free-list was tried and measured slower per-call than trusting VMA's own allocator. + * graphics-timeline-guarded ring; motion uploads suballocate from a guarded per-frame-slot arena. Other + * transient allocations use VMA directly. */ public final class RtEntities { public static final RtEntities INSTANCE = new RtEntities(); @@ -846,7 +846,7 @@ private void captureNameTag(ClientLevel level, EntityRenderState state, float ix * Upload this entity's world-space motion-vector displacement. Captures are entity-local, so the delta * is {@code (anchorCur + vertexCur) - (anchorPrev + vertexPrev)}. If every vertex agrees, * store it as a rigid vector in the geometry-table entry; otherwise write a per-vertex {@code vec4} - * buffer directly, avoiding the old intermediate {@code float[]}. + * buffer directly. */ private Motion uploadVertexMotion(RtContext ctx, FrameBuild build, FloatArrayList cur, EntityPrev prev, float anchorX, float anchorY, float anchorZ) { 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 33d89416..5e7aff67 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java @@ -160,8 +160,8 @@ public void prepareAll(RtContext ctx, int materialPageCapacity, RtEmissionSemant normalCount++; } // Authored LabPBR owns emission whenever _s exists. Albedo inference is only compiled for - // sprites proven to occur on an emitting block state. A resource-pack emission.strength - // override only scales this once resolved (RtMaterialOverrides.Rule.apply) — it never + // sprites proven to occur on an emitting block state. A resource-pack + // emission.strength_cd_m2 override replaces the level once resolved — it never // changes which sprites get a mask compiled here. if ((features & RtMaterialRegistry.FEATURE_SPEC) == 0 && emissionSemantics.permits(sprite)) { features |= RtMaterialRegistry.FEATURE_HEURISTIC_EMISSION; @@ -533,7 +533,7 @@ private static Map discoverEntityMaterialResources( : RtMaterialRegistry.FEATURE_NORMAL, (a, b) -> a | b); } // Also register any non-block sprite a rule targets, even lacking _s/_n, so overrides - // (roughness/metalness/model, or an emission.strength multiplier once some other source + // (roughness/metalness/model, or an absolute emission.strength_cd_m2 once some other source // supplies emission) have a compiled Candidate/Entry to apply to. for (RtMaterialOverrides.Rule rule : overrides.rules()) { if (rule.block() != null || blockNames.contains(rule.sprite())) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java index 58cd106a..f93b640b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java @@ -9,14 +9,13 @@ * *

Every dielectric is a volume: {@code world.rgen} refracts at the interface and pushes a * participating medium whose extinction attenuates the segment inside. IOR is therefore the whole - * material description — it drives both the Snell bend and the Fresnel split, and getting it wrong is - * visible as the wrong amount of distortion. Everything translucent used to share one per-model - * constant, so ice refracted exactly like window glass. + * material description—it drives both the Snell bend and the Fresnel split, and determines the visible + * distortion for each material. * *

Sprite-keyed rather than block-keyed because the material registry compiles per sprite, and * resolved once per sprite so it adds no variants to the profile x model x emission cross product. A * resource pack that renames textures falls back to the soda-lime default, and a - * {@code caustica/materials/*.json} rule can set {@code transmission.ior} explicitly. + * {@code materials/*.json} rule can set {@code transmission.ior} explicitly. */ public final class RtDielectrics { private RtDielectrics() {} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialDesc.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialDesc.java index d24556dd..64e7961e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialDesc.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialDesc.java @@ -11,10 +11,10 @@ public record RtMaterialDesc( float transmission, EmissionSource emissionSource, /** - * Final HDR emission strength: {@code EMISSIVE_STRENGTH} (the material-compile-time baseline, - * see {@link RtMaterialRegistry}) times any resource-pack {@code emission.strength} multiplier. + * Final HDR emitting-surface luminance in cd/m²: the look-package block baseline, replaced by + * a resource-pack {@code emission.strength_cd_m2} value when present. * 0 when {@code emissionSource == NONE}. Applied uniformly regardless of source — LabPBR, - * heuristic-mask, or state-uniform all get the same baseline, an override just scales it. + * heuristic-mask, or state-uniform all get the same baseline unless absolutely overridden. */ float emissionStrength, EmissionSummary emissionSummary diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java index b244b8ee..8302576c 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java @@ -19,7 +19,7 @@ /** Optional resource-pack material properties compiled ahead of LabPBR and engine heuristics. */ public final class RtMaterialOverrides { - public static final int FORMAT = 1; + public static final int FORMAT = 2; public static final RtMaterialOverrides EMPTY = new RtMaterialOverrides(List.of()); private final List rules; @@ -30,7 +30,7 @@ private RtMaterialOverrides(List rules) { public static RtMaterialOverrides load() { Map resources = Minecraft.getInstance().getResourceManager().listResources( - "caustica/materials", id -> id.getPath().endsWith(".json")); + "materials", id -> id.getPath().endsWith(".json")); List> ordered = new ArrayList<>(resources.entrySet()); ordered.sort(Map.Entry.comparingByKey(Comparator.comparing(Identifier::toString))); List rules = new ArrayList<>(); @@ -58,8 +58,7 @@ static Rule parse(JsonObject root, Identifier source) { Integer model = null; if (root.has("model")) { - // Every dielectric is a volume now, so the old thin/volume names described nothing. "water" - // is the animated fluid surface (waves, caustics, biome-tint absorption); "dielectric" is + // "water" is the animated fluid surface (waves, caustics, biome-tint absorption); "dielectric" is // every other transparent material. model = switch (root.get("model").getAsString()) { case "opaque" -> RtMaterialRegistry.MODEL_OPAQUE; @@ -77,12 +76,16 @@ static Rule parse(JsonObject root, Identifier source) { roughness = optionalFloat(base, "roughness"); metalness = optionalFloat(base, "metalness"); } - Float emissionStrength = null; + Float emissionStrengthCdM2 = null; if (root.has("emission")) { JsonObject emission = root.getAsJsonObject("emission"); - emissionStrength = optionalFloat(emission, "strength"); + if (emission.has("strength")) { + throw new IllegalArgumentException( + "emission.strength was removed; use absolute emission.strength_cd_m2"); + } + emissionStrengthCdM2 = optionalFloat(emission, "strength_cd_m2"); if (emission.has("color_source") && !"albedo".equals(emission.get("color_source").getAsString())) { - throw new IllegalArgumentException("format 1 only supports emission color_source=albedo"); + throw new IllegalArgumentException("format 2 only supports emission color_source=albedo"); } } Float transmission = null; @@ -98,17 +101,19 @@ static Rule parse(JsonObject root, Identifier source) { if (ior != null && (!Float.isFinite(ior) || ior <= 0.0f)) { throw new IllegalArgumentException("transmission.ior must be positive"); } - if (emissionStrength != null && !Float.isFinite(emissionStrength)) { - throw new IllegalArgumentException("emission.strength must be finite"); + if (emissionStrengthCdM2 != null && !Float.isFinite(emissionStrengthCdM2)) { + throw new IllegalArgumentException("emission.strength_cd_m2 must be finite"); } - if (emissionStrength != null && (emissionStrength < 0.0f || emissionStrength > 5.0f)) { - float clamped = Math.max(0.0f, Math.min(5.0f, emissionStrength)); - CausticaMod.LOGGER.warn("RT material override {}: emission.strength {} out of range [0,5], clamping to {}", - source, emissionStrength, clamped); - emissionStrength = clamped; + if (emissionStrengthCdM2 != null + && (emissionStrengthCdM2 < 0.0f || emissionStrengthCdM2 > 65504.0f)) { + float clamped = Math.max(0.0f, Math.min(65504.0f, emissionStrengthCdM2)); + CausticaMod.LOGGER.warn("RT material override {}: emission.strength_cd_m2 {} out of range " + + "[0,65504], clamping to {}", + source, emissionStrengthCdM2, clamped); + emissionStrengthCdM2 = clamped; } return new Rule(source, sprite, block, model, roughness, metalness, ior, transmission, - emissionStrength); + emissionStrengthCdM2); } public List rules() { @@ -118,12 +123,11 @@ public List rules() { public record Rule(Identifier source, Identifier sprite, Identifier block, Integer model, Float roughness, Float metalness, Float ior, Float transmission, /** - * Multiplier on whatever emission the material naturally resolves to (LabPBR - * {@code _s}, heuristic mask, or state-uniform block light) — NOT a replacement. - * A material with no natural emission stays unlit no matter this value; this - * cannot make a block glow that wasn't already emissive. + * Absolute emitting-surface luminance in cd/m² for whatever emission mask the + * material naturally resolves to (LabPBR {@code _s}, heuristic mask, or + * state-uniform block light). A material with no natural emission stays unlit. */ - Float emissionStrength) { + Float emissionStrengthCdM2) { boolean matchesSprite(TextureAtlasSprite value) { return value != null && sprite.equals(value.contents().name()); } @@ -145,10 +149,11 @@ RtMaterialDesc apply(RtMaterialDesc base) { : (model != null ? defaultIor(nextModel) : base.ior()); float nextTransmission = transmission != null ? transmission : (model != null ? defaultTransmission(nextModel) : base.transmission()); - // A multiplier on the base's already-resolved strength (0 when emissionSource is NONE): - // this can brighten/dim an existing emitter but never light up a genuinely non-emissive one. - float nextEmissionStrength = emissionStrength != null - ? base.emissionStrength() * emissionStrength : base.emissionStrength(); + // An absolute emitting-surface luminance. It can replace the level of an existing + // LabPBR/heuristic/state emitter but does not create an emission mask where none exists. + float nextEmissionStrength = emissionStrengthCdM2 != null + && base.emissionSource() != RtMaterialDesc.EmissionSource.NONE + ? emissionStrengthCdM2 : base.emissionStrength(); return new RtMaterialDesc(nextModel, RtMaterialDesc.Source.OVERRIDE, base.features(), nextRoughness, nextMetalness, nextIor, nextTransmission, base.emissionSource(), nextEmissionStrength, base.emissionSummary()); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 69873d7e..0b027736 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -4,6 +4,7 @@ import dev.comfyfluffy.caustica.CausticaMod; import dev.comfyfluffy.caustica.mixin.SpriteContentsAccessor; import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtLookPackage; import dev.comfyfluffy.caustica.rt.accel.RtBuffer; import dev.comfyfluffy.caustica.rt.gen.MaterialHeaderData; import dev.comfyfluffy.caustica.rt.gen.MaterialHeaderData.Float4; @@ -45,14 +46,32 @@ public final class RtMaterialRegistry { public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; public static final int FEATURE_STOCHASTIC_ALPHA = 16; - // HDR radiance of a full (level-15-equivalent) emitter, modulated by albedo — the single knob - // (formerly duplicated as a literal in world.rgen.slang and RtLightCollector). Baked into every + // HDR radiance of a full (level-15-equivalent) emitter, modulated by albedo. Baked into every // emissive RtMaterialDesc.emissionStrength at compile time (compileDesc/compileEntityDesc), times - // any resource-pack emission.strength multiplier; see header()'s packing and RtMaterialOverrides. - private static final float EMISSIVE_STRENGTH = 5.0f; + // any resource-pack absolute emission.strength_cd_m2 override; see header() and RtMaterialOverrides. + // + // Photometric: cd/m² of the emitting surface, per {@link dev.comfyfluffy.caustica.rt.RtSceneUnits}. + // + // Anchored on LUMINOUS EXITANCE, not on flame luminance: a full-strength emitter face radiates about + // 1,000 lm/m², so one 1 m² block face is a ~1,000 lm lamp — a 75 W-equivalent bulb, which is what a + // glowstone block is meant to be in a room. Lambertian exitance M = π·L, so L = 1000/π = 318 cd/m². + // + // A flame really is far brighter per unit area than a glowstone block, so one baseline cannot be + // right for both; the mask supplies coverage, not intensity. Exitance is the correct thing to anchor + // because it is what the emitter contributes to the room, and it happens to land a torch's small + // emissive footprint near 40 lm—a candle to a small torch. + public static float defaultEmissionLuminanceCdM2() { + return RtLookPackage.current().lighting().blockEmissionLuminanceCdM2(); + } private static final int EMISSION_STRENGTH_SHIFT = 8; private static final int EMISSION_STRENGTH_MASK = 65535; - private static final float MAX_EMISSION_STRENGTH = 32.0f; + // Ceiling of the 16-bit fixed-point strength field, raised with the baseline above. HALF_MAX is the + // real transport ceiling downstream — Payload.emissionSss is a half2 lane and Light.le is packed + // R11G11B10 — so clamping here rather than higher keeps the encoded value representable end to end. + // The quantisation step is MAX/65535 ≈ 1 cd/m², i.e. 0.007% at the baseline. A resource pack's + // maximum 5x multiplier would reach 75,000 and clamps to this: a 0.19 EV reduction on something + // already several EV past display white, so invisible. + private static final float MAX_EMISSION_STRENGTH = 65504.0f; private static final int MAX_LOD_SHIFT = 24; private static final int MODEL_VARIANTS = 2; // ordinary opaque/cutout and transparent dielectric @@ -141,7 +160,7 @@ public void rebuild(RtContext ctx, RtBlockMaterials blockMaterials, RtMaterialOv false, true, RtMaterialDesc.EmissionSummary.NONE), whiteAverage(), fallbackEntry, null); int lavaId = headers.size(); // Lava's fluid mesher assigns this singleton id (no sprite resolve), so its light color comes from - // the lava_still albedo grid — a mean-color area light instead of the old branch's white lava. + // the lava_still albedo grid, producing a mean-color area light. add(headers, descriptions, grids, compileDesc(MODEL_OPAQUE, 0, RtMaterials.Profile.LAVA, true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, albedoGridFor(sprites, spriteStats, "block/lava_still")); @@ -460,7 +479,8 @@ private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.P } else { emissionSource = RtMaterialDesc.EmissionSource.NONE; } - float emissionStrength = emissionSource == RtMaterialDesc.EmissionSource.NONE ? 0.0f : EMISSIVE_STRENGTH; + float emissionStrength = emissionSource == RtMaterialDesc.EmissionSource.NONE + ? 0.0f : defaultEmissionLuminanceCdM2(); return new RtMaterialDesc(model, source, features, roughness, metalness, ior, transmission, emissionSource, emissionStrength, emissionSummary); } @@ -472,7 +492,8 @@ private static RtMaterialDesc compileEntityDesc(int features, boolean neutral, : (authored ? RtMaterialDesc.Source.LAB_PBR : RtMaterialDesc.Source.HEURISTIC); RtMaterialDesc.EmissionSource emissionSource = (features & FEATURE_SPEC) != 0 ? RtMaterialDesc.EmissionSource.LAB_PBR : RtMaterialDesc.EmissionSource.NONE; - float emissionStrength = emissionSource == RtMaterialDesc.EmissionSource.NONE ? 0.0f : EMISSIVE_STRENGTH; + float emissionStrength = emissionSource == RtMaterialDesc.EmissionSource.NONE + ? 0.0f : defaultEmissionLuminanceCdM2(); return new RtMaterialDesc(MODEL_OPAQUE, source, features, RtMaterials.ENTITY_ROUGH, 0.0f, 1.0f, 0.0f, emissionSource, emissionStrength, emissionSummary); } @@ -518,7 +539,7 @@ private static MaterialHeaderData header(RtMaterialDesc desc, float[] average, float albedoInvDu, float albedoInvDv) { int packedFeatures = desc.features() | (entry.maxLod() << MAX_LOD_SHIFT); // Packed unconditionally (0 for non-emissive materials): the shader multiplies surface.emission - // by this every time, regardless of source, so EMISSIVE_STRENGTH never needs its own copy there. + // by this every time, regardless of source, so the package baseline needs no shader copy. int strength = Math.round(Math.min(MAX_EMISSION_STRENGTH, desc.emissionStrength()) * (EMISSION_STRENGTH_MASK / MAX_EMISSION_STRENGTH)); packedFeatures |= strength << EMISSION_STRENGTH_SHIFT; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtBlockOutlineFeature.java b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtBlockOutlineFeature.java index eedb1987..de77799b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtBlockOutlineFeature.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtBlockOutlineFeature.java @@ -38,7 +38,7 @@ /** * The targeted block's wireframe outline: a real {@link VoxelShape} edge list (not just a full-cube * approximation), raster full-res post-upscale, occluded per-fragment via an inline {@code rayQueryEXT} - * test against the world TLAS (see {@code block_outline.frag}) instead of a depth buffer — RT's own + * test against the world TLAS (see {@code block_outline/fragment.frag.slang}) instead of a depth buffer — RT's own * {@code gDepth} is at DLSS-RR's internal render resolution, not this pass's full display resolution, and * outline pixels need to sit exactly on the depth surface. * @@ -51,18 +51,14 @@ *

A native {@code LINE_LIST} draw, real width via the device's {@code wideLines} feature + * {@code vkCmdSetLineWidth} (see {@link RtDeviceBringup#wideLinesEnabled()}/{@link * RtDeviceBringup#maxLineWidth()}) — clamped to whatever the device actually supports (Vulkan mandates - * exactly 1.0 without the feature, so this degrades gracefully rather than failing). A two-pass screen-space - * quad + coverage-mask approach was tried first (real geometry, no device-feature dependency, correct - * mitred joints) but was reverted as unnecessary complexity for what a native wide line already solves; - * revisit that approach only if wideLines turns out inadequate (unsupported hardware, joint artifacts at - * large widths, etc.) — see the memory note for what was tried. + * exactly 1.0 without the feature, so this degrades gracefully rather than failing). * *

Edge AA follows {@link RtGlowOutlineFeature}'s mask/composite split rather than drawing straight onto * {@code main}: the line list rasterizes at {@link RtDeviceBringup#overlayMsaaSamples()} into a transient * MSAA scratch attachment that dynamic rendering resolve-averages into a single-sample mask, then a tiny * composite pass alpha-blends that mask onto {@code main}. Since every line pixel is the same flat colour * (rgb = 0,0,0), per-sample coverage averages straight into a fractional alpha with no colour-bleed risk — - * the occlusion {@code discard} in {@code block_outline.frag} still runs once per fragment (not per sample, + * the occlusion {@code discard} in {@code block_outline/fragment.frag.slang} still runs once per fragment (not per sample, * no {@code sampleShading}), so occlusion itself stays pixel-rate; only the silhouette edges get antialiased. */ final class RtBlockOutlineFeature implements RtOverlayFeature { @@ -80,7 +76,7 @@ final class RtBlockOutlineFeature implements RtOverlayFeature { private RtOverlayPipelines.Pipeline pipeline; private RtOverlayPipelines.AccelStructureSet accelSet; private RtOverlayPipelines.Pipeline compositePipeline; - private RtOverlayPipelines.StorageImageSet compositeSet; + private RtOverlayPipelines.ReadOnlyImageSet compositeSet; private RtImage msaaImage; private RtImage resolvedMask; @@ -186,7 +182,7 @@ private void ensureResources(RtContext ctx, int width, int height) { this.ctxRef = ctx; if (pipeline == null) { accelSet = RtOverlayPipelines.accelStructureSet(ctx, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "block outline"); - pipeline = new RtOverlayPipelines.Spec("block_outline.vert.spv", "block_outline.frag.spv") + pipeline = new RtOverlayPipelines.Spec("block_outline/vertex.vert.spv", "block_outline/fragment.frag.spv") .vertex(RtOverlayPipelines.VertexFormat.POSITION) .topology(VK10.VK_PRIMITIVE_TOPOLOGY_LINE_LIST) // NONE (straight write), not ALPHA: ALPHA's blend factors (srcAlpha=ZERO, dstAlpha=ONE) @@ -200,8 +196,8 @@ private void ensureResources(RtContext ctx, int width, int height) { .push(PUSH_BYTES, VK10.VK_SHADER_STAGE_VERTEX_BIT | VK10.VK_SHADER_STAGE_FRAGMENT_BIT) .descriptorSetLayout(accelSet.layout) .build(ctx, "block outline"); - compositeSet = RtOverlayPipelines.storageImageSet(ctx, 1, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "block outline composite"); - compositePipeline = new RtOverlayPipelines.Spec("overlay_fullscreen_triangle.vert.spv", "overlay_passthrough_composite.frag.spv") + compositeSet = RtOverlayPipelines.readOnlyImageSet(ctx, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "block outline composite"); + compositePipeline = new RtOverlayPipelines.Spec("overlay_composite/vertex.vert.spv", "overlay_composite/passthrough.frag.spv") .blend(RtOverlayPipelines.Blend.ALPHA) .attachment(RtWorldOverlay.TARGET_FORMAT) .descriptorSetLayout(compositeSet.layout) @@ -221,7 +217,7 @@ private void ensureResources(RtContext ctx, int width, int height) { resolvedMask = ctx.createStorageImage(width, height, RtWorldOverlay.TARGET_FORMAT, "block outline resolved mask " + width + "x" + height, VK10.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT); } - compositeSet.bind(ctx, 0, resolvedMask.view); + compositeSet.bind(ctx, resolvedMask.view); } @Override diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtGlowOutlineFeature.java b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtGlowOutlineFeature.java index ca8c51c5..47cb4f7f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtGlowOutlineFeature.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtGlowOutlineFeature.java @@ -41,7 +41,7 @@ final class RtGlowOutlineFeature implements RtOverlayFeature { private RtContext ctx; private RtOverlayPipelines.Pipeline maskPipeline; private RtOverlayPipelines.Pipeline compositePipeline; - private RtOverlayPipelines.StorageImageSet compositeSet; + private RtOverlayPipelines.ReadOnlyImageSet compositeSet; private RtImage maskImage; // This frame's prepared draw data (valid between prepare() returning true and record()). @@ -120,13 +120,13 @@ public boolean prepare(RtContext ctx, RtOverlayFramePool pool, RtGpuExecutor.Gra private void ensureResources(RtContext ctx, int width, int height) { this.ctx = ctx; if (maskPipeline == null) { - maskPipeline = new RtOverlayPipelines.Spec("entity_glow.vert.spv", "entity_glow.frag.spv") + maskPipeline = new RtOverlayPipelines.Spec("entity_glow/vertex.vert.spv", "entity_glow/fragment.frag.spv") .vertex(RtOverlayPipelines.VertexFormat.POSITION) .attachment(MASK_FORMAT) .push(MASK_PUSH_BYTES, VK10.VK_SHADER_STAGE_VERTEX_BIT | VK10.VK_SHADER_STAGE_FRAGMENT_BIT) .build(ctx, "glow mask"); - compositeSet = RtOverlayPipelines.storageImageSet(ctx, 1, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "glow composite"); - compositePipeline = new RtOverlayPipelines.Spec("overlay_fullscreen_triangle.vert.spv", "entity_glow_composite.frag.spv") + compositeSet = RtOverlayPipelines.readOnlyImageSet(ctx, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "glow composite"); + compositePipeline = new RtOverlayPipelines.Spec("overlay_composite/vertex.vert.spv", "overlay_composite/glow.frag.spv") .blend(RtOverlayPipelines.Blend.ALPHA) .attachment(RtWorldOverlay.TARGET_FORMAT) .descriptorSetLayout(compositeSet.layout) @@ -139,7 +139,7 @@ private void ensureResources(RtContext ctx, int width, int height) { maskImage = ctx.createStorageImage(width, height, MASK_FORMAT, "glow outline mask " + width + "x" + height, VK10.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT); } - compositeSet.bind(ctx, 0, maskImage.view); + compositeSet.bind(ctx, maskImage.view); } @Override diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtNameTagFeature.java b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtNameTagFeature.java index cf7a337f..c71e17f1 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtNameTagFeature.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtNameTagFeature.java @@ -159,7 +159,7 @@ private void ensureResources(RtContext ctx) { } imageSetPool = RtOverlayPipelines.sampledImageSetPool(ctx, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, MAX_ATLAS_PAGES, "name tag"); sampler = RtOverlayPipelines.createNearestClampSampler(ctx, "name tag font atlas"); - pipeline = new RtOverlayPipelines.Spec("name_tag.vert.spv", "name_tag.frag.spv") + pipeline = new RtOverlayPipelines.Spec("name_tag/vertex.vert.spv", "name_tag/fragment.frag.spv") .vertex(RtOverlayPipelines.VertexFormat.POSITION_TEX_COLOR) .blend(RtOverlayPipelines.Blend.ALPHA) .attachment(RtWorldOverlay.TARGET_FORMAT) diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtOverlayPipelines.java b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtOverlayPipelines.java index 28bbbfcd..fde9fa7f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtOverlayPipelines.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtOverlayPipelines.java @@ -41,6 +41,9 @@ import dev.comfyfluffy.caustica.rt.RtGpuExecutor; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.OVERLAY_IMAGE; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.OVERLAY_SAMPLER; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.OVERLAY_TLAS; /** * Shared creation-time boilerplate for the world-overlay raster passes ({@link RtWorldOverlay}). Overlay @@ -53,7 +56,7 @@ * Blaze3D device bring-up) with one colour attachment, no depth, dynamic viewport/scissor. */ public final class RtOverlayPipelines { - private static final String SHADER_DIR = "/caustica/rt/"; + private static final String SHADER_DIR = "/caustica/shaders/pipelines/"; private RtOverlayPipelines() { } @@ -91,7 +94,7 @@ public enum Blend { * recipe, so the shared buffer ends up PREMULTIPLIED (`rgb = trueColour * accumulatedAlpha`) after * more than one layer, even though every individual draw's OWN fragment output was straight. Anyone * reading the shared buffer back as a SOURCE (not drawing straight colour onto it) must treat it as - * premultiplied — see {@link #PREMULTIPLIED_ALPHA} and {@code hdr_ui_composite.comp}'s + * premultiplied — see {@link #PREMULTIPLIED_ALPHA} and {@code hdr_composite/main.comp.slang}'s * un-premultiply step on the final combined UI image. */ ALPHA, @@ -324,38 +327,36 @@ private static VkVertexInputAttributeDescription.Buffer vertexAttributes(MemoryS } /** - * A single descriptor set of {@code count} storage images (bindings 0..count-1), with its layout and - * pool — enough for overlay composite passes that read a mod-owned mask/scratch image. (Vanilla-owned - * textures can never be bound here: Blaze3D never sets VK_IMAGE_USAGE_STORAGE_BIT — they are reachable - * only as colour attachments.) + * A single read-only image descriptor set for overlay composite passes. The source images are + * mod-owned render targets created with sampled-image usage; integer {@code Texture2D.Load} access + * keeps this descriptor sampler-free. */ - public static final class StorageImageSet { + public static final class ReadOnlyImageSet { public final long layout; private final long pool; public final long set; - private final long[] boundViews; + private long boundView; - private StorageImageSet(long layout, long pool, long set, int count) { + private ReadOnlyImageSet(long layout, long pool, long set) { this.layout = layout; this.pool = pool; this.set = set; - this.boundViews = new long[count]; } - /** Point binding {@code binding} at {@code view} (GENERAL layout); no-op when already bound. */ - public void bind(RtContext ctx, int binding, long view) { - if (boundViews[binding] == view) { + /** Point the overlay image binding at {@code view} (GENERAL layout); no-op when already bound. */ + public void bind(RtContext ctx, long view) { + if (boundView == view) { return; } try (MemoryStack stack = MemoryStack.stackPush()) { VkDescriptorImageInfo.Buffer info = VkDescriptorImageInfo.calloc(1, stack); info.get(0).imageView(view).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(1, stack); - writes.get(0).sType$Default().dstSet(set).dstBinding(binding) - .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(info); + writes.get(0).sType$Default().dstSet(set).dstBinding(OVERLAY_IMAGE) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE).pImageInfo(info); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } - boundViews[binding] = view; + boundView = view; } public void destroy(VkDevice vk) { @@ -364,22 +365,20 @@ public void destroy(VkDevice vk) { } } - public static StorageImageSet storageImageSet(RtContext ctx, int count, int stageFlags, String label) { + public static ReadOnlyImageSet readOnlyImageSet(RtContext ctx, int stageFlags, String label) { VkDevice vk = ctx.vk(); try (MemoryStack stack = MemoryStack.stackPush()) { LongBuffer p = stack.mallocLong(1); - VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(count, stack); - for (int i = 0; i < count; i++) { - binds.get(i).binding(i).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) - .descriptorCount(1).stageFlags(stageFlags); - } + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(1, stack); + binds.get(0).binding(OVERLAY_IMAGE).descriptorType(VK10.VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE) + .descriptorCount(1).stageFlags(stageFlags); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout(" + label + ")"); long dsl = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, label + " descriptor set layout"); VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(1, stack); - poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(count); + poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE).descriptorCount(1); VkDescriptorPoolCreateInfo dpci = VkDescriptorPoolCreateInfo.calloc(stack).sType$Default().maxSets(1).pPoolSizes(poolSizes); check(VK10.vkCreateDescriptorPool(vk, dpci, null, p), "vkCreateDescriptorPool(" + label + ")"); long pool = p.get(0); @@ -391,7 +390,7 @@ public static StorageImageSet storageImageSet(RtContext ctx, int count, int stag check(VK10.vkAllocateDescriptorSets(vk, dsai, pSet), "vkAllocateDescriptorSets(" + label + ")"); long set = pSet.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, set, label + " descriptor set"); - return new StorageImageSet(dsl, pool, set, count); + return new ReadOnlyImageSet(dsl, pool, set); } } @@ -423,7 +422,7 @@ public void bind(RtContext ctx, long view, long sampler) { VkDescriptorImageInfo.Buffer info = VkDescriptorImageInfo.calloc(1, stack); info.get(0).sampler(sampler).imageView(view).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(1, stack); - writes.get(0).sType$Default().dstSet(set).dstBinding(0) + writes.get(0).sType$Default().dstSet(set).dstBinding(OVERLAY_SAMPLER) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(info); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } @@ -441,7 +440,7 @@ public static SampledImageSet sampledImageSet(RtContext ctx, int stageFlags, Str try (MemoryStack stack = MemoryStack.stackPush()) { LongBuffer p = stack.mallocLong(1); VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(1, stack); - binds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + binds.get(0).binding(OVERLAY_SAMPLER).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1).stageFlags(stageFlags); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout(" + label + ")"); @@ -501,7 +500,7 @@ public long allocateAndBind(RtContext ctx, long view, long sampler) { VkDescriptorImageInfo.Buffer info = VkDescriptorImageInfo.calloc(1, stack); info.get(0).sampler(sampler).imageView(view).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(1, stack); - writes.get(0).sType$Default().dstSet(set).dstBinding(0) + writes.get(0).sType$Default().dstSet(set).dstBinding(OVERLAY_SAMPLER) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(info); VK10.vkUpdateDescriptorSets(vk, writes, null); return set; @@ -520,7 +519,7 @@ public static SampledImageSetPool sampledImageSetPool(RtContext ctx, int stageFl try (MemoryStack stack = MemoryStack.stackPush()) { LongBuffer p = stack.mallocLong(1); VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(1, stack); - binds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + binds.get(0).binding(OVERLAY_SAMPLER).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1).stageFlags(stageFlags); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout(" + label + ")"); @@ -541,7 +540,7 @@ public static SampledImageSetPool sampledImageSetPool(RtContext ctx, int stageFl /** * A ring of descriptor sets each holding one {@code VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR} * binding — for overlay passes that issue an inline {@code rayQueryEXT} occlusion test against the - * world TLAS (e.g. block outline). A ring (not a single set, unlike {@link StorageImageSet}/ + * world TLAS (e.g. block outline). A ring (not a single set, unlike {@link ReadOnlyImageSet}/ * {@link SampledImageSet}) is required because the TLAS handle changes most frames ({@code RtAccel * .TlasRing} cycles it every frame even when it doesn't grow) — rewriting a single set's binding while * an earlier frame's command buffer referencing that same set may still be executing on the GPU is the @@ -577,7 +576,7 @@ public long bind(RtContext ctx, long tlas, RtGpuExecutor.GraphicsUse graphicsUse .sType(KHRAccelerationStructure.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET_ACCELERATION_STRUCTURE_KHR) .pAccelerationStructures(stack.longs(tlas)); VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(1, stack); - write.get(0).sType$Default().pNext(asWrite.address()).dstSet(set).dstBinding(0) + write.get(0).sType$Default().pNext(asWrite.address()).dstSet(set).dstBinding(OVERLAY_TLAS) .descriptorCount(1).descriptorType(KHRAccelerationStructure.VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR); VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); } @@ -596,7 +595,7 @@ public static AccelStructureSet accelStructureSet(RtContext ctx, int stageFlags, try (MemoryStack stack = MemoryStack.stackPush()) { LongBuffer p = stack.mallocLong(1); VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(1, stack); - binds.get(0).binding(0).descriptorType(KHRAccelerationStructure.VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR) + binds.get(0).binding(OVERLAY_TLAS).descriptorType(KHRAccelerationStructure.VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR) .descriptorCount(1).stageFlags(stageFlags); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout(" + label + ")"); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtWorldOverlay.java b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtWorldOverlay.java index a0e112e3..778bc258 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtWorldOverlay.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/overlay/RtWorldOverlay.java @@ -42,12 +42,10 @@ * {@link RtOverlayFeature}; pipelines come from {@link RtOverlayPipelines}. * *

Routing every feature through one shared buffer instead of blending straight onto vanilla's SDR - * {@code main} is what keeps SDR/HDR presentation unified: {@link #record} now folds that buffer into + * {@code main} keeps SDR/HDR presentation unified: {@link #record} folds that buffer into * {@link RtUiOverlay}'s transparent overlay before the vanilla GUI renders, so the GUI remains topmost and - * the final present path only has one UI image to blend. (Block outline's own private MSAA-mask-resolve path - * predates this buffer and still runs before its result ever reaches {@code overlayImage} — an FXAA pass over - * the shared buffer was tried and removed as looking worse than expected; MSAA remains the only edge-AA - * mechanism today.) + * the final present path only has one UI image to blend. The block outline applies its private MSAA + * mask-resolve before its result reaches {@code overlayImage}; MSAA is the overlay edge-AA mechanism. */ public final class RtWorldOverlay { public static final RtWorldOverlay INSTANCE = new RtWorldOverlay(); @@ -66,7 +64,7 @@ public final class RtWorldOverlay { private RtContext ctxRef; private RtImage overlayImage; private RtOverlayPipelines.Pipeline uiCompositePipeline; - private RtOverlayPipelines.StorageImageSet uiCompositeSet; + private RtOverlayPipelines.ReadOnlyImageSet uiCompositeSet; private RtWorldOverlay() { } @@ -112,11 +110,11 @@ public void compositeIntoUiOverlay(RenderTarget main, RtGpuExecutor.GraphicsUse private void ensureOverlayBuffer(RtContext ctx, int width, int height) { this.ctxRef = ctx; if (uiCompositePipeline == null) { - uiCompositeSet = RtOverlayPipelines.storageImageSet(ctx, 1, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "world overlay UI composite"); + uiCompositeSet = RtOverlayPipelines.readOnlyImageSet(ctx, VK10.VK_SHADER_STAGE_FRAGMENT_BIT, "world overlay UI composite"); // PREMULTIPLIED_ALPHA, not ALPHA: overlayImage ends up holding premultiplied content once more // than one feature has drawn into it (see Blend.ALPHA's doc) — blending it into the shared UI // image with the straight-alpha recipe would double-multiply by alpha. - uiCompositePipeline = new RtOverlayPipelines.Spec("overlay_fullscreen_triangle.vert.spv", "overlay_passthrough_composite.frag.spv") + uiCompositePipeline = new RtOverlayPipelines.Spec("overlay_composite/vertex.vert.spv", "overlay_composite/passthrough.frag.spv") .blend(RtOverlayPipelines.Blend.PREMULTIPLIED_ALPHA) .attachment(TARGET_FORMAT) .descriptorSetLayout(uiCompositeSet.layout) @@ -129,7 +127,7 @@ private void ensureOverlayBuffer(RtContext ctx, int width, int height) { overlayImage = ctx.createStorageImage(width, height, TARGET_FORMAT, "world overlay " + width + "x" + height, VK10.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT); } - uiCompositeSet.bind(ctx, 0, overlayImage.view); + uiCompositeSet.bind(ctx, overlayImage.view); } private void record(RtContext ctx, List ready, long targetView, int width, int height) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtBloomPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtBloomPipeline.java new file mode 100644 index 00000000..18894e00 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtBloomPipeline.java @@ -0,0 +1,357 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import com.mojang.blaze3d.vulkan.VulkanCommandEncoder; +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.accel.RtImage; +import dev.comfyfluffy.caustica.rt.gen.BloomPushData; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkComputePipelineCreateInfo; +import org.lwjgl.vulkan.VkDescriptorImageInfo; +import org.lwjgl.vulkan.VkDescriptorPoolCreateInfo; +import org.lwjgl.vulkan.VkDescriptorPoolSize; +import org.lwjgl.vulkan.VkDescriptorSetAllocateInfo; +import org.lwjgl.vulkan.VkDescriptorSetLayoutBinding; +import org.lwjgl.vulkan.VkDescriptorSetLayoutCreateInfo; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkPipelineLayoutCreateInfo; +import org.lwjgl.vulkan.VkPipelineShaderStageCreateInfo; +import org.lwjgl.vulkan.VkPushConstantRange; +import org.lwjgl.vulkan.VkSamplerCreateInfo; +import org.lwjgl.vulkan.VkShaderModuleCreateInfo; +import org.lwjgl.vulkan.VkWriteDescriptorSet; + +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 dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; + +/** + * Scene-referred bloom as a downsample/upsample mip pyramid (Jimenez, SIGGRAPH 2014). The pyramid + * provides broad support without comb-spaced taps or a fixed-width highlight slab. + * + *

Each pyramid step is its own dispatch with its own descriptor set holding that step's destination + * storage image and source sampled image. Binding the pair per step, rather than indexing a descriptor + * array from a push constant, keeps the shader free of dynamic storage-image indexing — which would + * require {@code shaderStorageImageArrayDynamicIndexing}, a feature this device bring-up does not ask + * for. + * + *

Bloom stays outside the temporal reconstruction and the exposure histogram; only the display mapper + * consumes the finished pyramid, whose level 0 accumulates every band. + */ +public final class RtBloomPipeline { + private static final String SHADER = "/caustica/shaders/pipelines/bloom/main.comp.spv"; + /** Pyramid depth ceiling. Level 7 of a 4K pyramid is already 15x8 texels — nothing wider is useful. */ + public static final int MAX_LEVELS = 8; + private static final int MODE_PREFILTER = 0; + private static final int MODE_DOWNSAMPLE = 1; + private static final int MODE_UPSAMPLE = 2; + // One set per possible step: [0] prefilter into level 0, [i] downsample into level i (i >= 1), + // [MAX_LEVELS + i] upsample level i+1 onto level i. + private static final int SET_COUNT = MAX_LEVELS * 2; + + private final RtContext ctx; + private final long descriptorSetLayout; + private final long descriptorPool; + private final long[] descriptorSets; + private final long pipelineLayout; + private final long pipeline; + private final long sampler; + private long boundSourceView; + private long boundExposureView; + private long[] boundLevelViews = new long[0]; + private boolean destroyed; + + private RtBloomPipeline(RtContext ctx, long descriptorSetLayout, long descriptorPool, + long[] descriptorSets, long pipelineLayout, long pipeline, long sampler) { + this.ctx = ctx; + this.descriptorSetLayout = descriptorSetLayout; + this.descriptorPool = descriptorPool; + this.descriptorSets = descriptorSets; + this.pipelineLayout = pipelineLayout; + this.pipeline = pipeline; + this.sampler = sampler; + } + + public static RtBloomPipeline create(RtContext ctx) { + VkDevice vk = ctx.vk(); + try (MemoryStack stack = MemoryStack.stackPush()) { + VkDescriptorSetLayoutBinding.Buffer bindings = + VkDescriptorSetLayoutBinding.calloc(BLOOM_BINDING_COUNT, stack); + bindings.get(BLOOM_OUTPUT).binding(BLOOM_OUTPUT) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1) + .stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + bindings.get(BLOOM_SOURCE).binding(BLOOM_SOURCE) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1) + .stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + bindings.get(BLOOM_EXPOSURE).binding(BLOOM_EXPOSURE) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1) + .stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + + LongBuffer handle = stack.mallocLong(1); + VkDescriptorSetLayoutCreateInfo layoutInfo = + VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(bindings); + check(VK10.vkCreateDescriptorSetLayout(vk, layoutInfo, null, handle), + "vkCreateDescriptorSetLayout(rt bloom)"); + long descriptorSetLayout = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, + descriptorSetLayout, "bloom descriptor set layout"); + + VkDescriptorPoolSize.Buffer poolSize = VkDescriptorPoolSize.calloc(2, stack); + poolSize.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(SET_COUNT * 2); + poolSize.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(SET_COUNT); + VkDescriptorPoolCreateInfo poolInfo = VkDescriptorPoolCreateInfo.calloc(stack) + .sType$Default().maxSets(SET_COUNT).pPoolSizes(poolSize); + check(VK10.vkCreateDescriptorPool(vk, poolInfo, null, handle), + "vkCreateDescriptorPool(rt bloom)"); + long descriptorPool = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_POOL, + descriptorPool, "bloom descriptor pool"); + + LongBuffer setLayouts = stack.mallocLong(SET_COUNT); + for (int i = 0; i < SET_COUNT; i++) { + setLayouts.put(i, descriptorSetLayout); + } + VkDescriptorSetAllocateInfo allocateInfo = VkDescriptorSetAllocateInfo.calloc(stack) + .sType$Default().descriptorPool(descriptorPool).pSetLayouts(setLayouts); + LongBuffer setHandles = stack.mallocLong(SET_COUNT); + check(VK10.vkAllocateDescriptorSets(vk, allocateInfo, setHandles), + "vkAllocateDescriptorSets(rt bloom)"); + long[] descriptorSets = new long[SET_COUNT]; + for (int i = 0; i < SET_COUNT; i++) { + descriptorSets[i] = setHandles.get(i); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, + descriptorSets[i], "bloom descriptor set " + i); + } + + VkPushConstantRange.Buffer pushRange = VkPushConstantRange.calloc(1, stack); + pushRange.get(0).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT) + .offset(0).size(BloomPushData.BYTE_SIZE); + VkPipelineLayoutCreateInfo pipelineLayoutInfo = VkPipelineLayoutCreateInfo.calloc(stack) + .sType$Default().pSetLayouts(stack.longs(descriptorSetLayout)) + .pPushConstantRanges(pushRange); + check(VK10.vkCreatePipelineLayout(vk, pipelineLayoutInfo, null, handle), + "vkCreatePipelineLayout(rt bloom)"); + long pipelineLayout = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, + pipelineLayout, "bloom pipeline layout"); + + long module = loadModule(vk, stack); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, "bloom shader module"); + VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack) + .sType$Default().stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT) + .module(module).pName(stack.UTF8("main")); + VkComputePipelineCreateInfo.Buffer pipelineInfo = + VkComputePipelineCreateInfo.calloc(1, stack); + pipelineInfo.get(0).sType$Default().stage(stage).layout(pipelineLayout); + LongBuffer pipelineHandle = stack.mallocLong(1); + check(VK10.vkCreateComputePipelines(vk, VK10.VK_NULL_HANDLE, + pipelineInfo, null, pipelineHandle), "vkCreateComputePipelines(rt bloom)"); + VK10.vkDestroyShaderModule(vk, module, null); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, + pipelineHandle.get(0), "bloom compute pipeline"); + + // CLAMP_TO_EDGE, not border: a highlight touching the frame edge should bleed along the edge + // like a real lens, not fade into a black border that reads as a dark seam. + VkSamplerCreateInfo samplerInfo = VkSamplerCreateInfo.calloc(stack).sType$Default() + .magFilter(VK10.VK_FILTER_LINEAR) + .minFilter(VK10.VK_FILTER_LINEAR) + .mipmapMode(VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST) + .addressModeU(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeV(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeW(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .minLod(0.0f) + .maxLod(0.0f); + check(VK10.vkCreateSampler(vk, samplerInfo, null, handle), + "vkCreateSampler(rt bloom)"); + long sampler = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SAMPLER, sampler, "bloom linear sampler"); + + return new RtBloomPipeline(ctx, descriptorSetLayout, descriptorPool, descriptorSets, + pipelineLayout, pipelineHandle.get(0), sampler); + } + } + + public long sampler() { + return sampler; + } + + /** + * Deepest pyramid the given level-0 size supports: halving stops before a level would lose an axis. + * A level of 8 texels is already a whole-screen blur, so this rarely binds before {@link #MAX_LEVELS}. + */ + public static int levelsFor(int level0Width, int level0Height, int requested) { + int levels = 1; + int w = level0Width; + int h = level0Height; + while (levels < Math.min(requested, MAX_LEVELS) && w > 8 && h > 8) { + w = Math.max(1, w / 2); + h = Math.max(1, h / 2); + levels++; + } + return levels; + } + + /** + * Point every step's descriptor set at this frame's images. {@code levels[0]} is the half-resolution + * prefiltered level the display mapper reads; the rest are the pyramid. + */ + public void setImages(long sourceView, long exposureView, RtImage[] levels) { + if (boundSourceView == sourceView && boundExposureView == exposureView + && sameViews(levels)) { + return; + } + int levelCount = levels.length; + try (MemoryStack stack = MemoryStack.stackPush()) { + int stepCount = 1 + (levelCount - 1) * 2; // prefilter + downsamples + upsamples + VkDescriptorImageInfo.Buffer images = VkDescriptorImageInfo.calloc(stepCount * 3, stack); + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(stepCount * 3, stack); + int index = 0; + for (int step = 0; step < stepCount; step++) { + long dstView; + long srcView; + int set; + if (step == 0) { + set = 0; + dstView = levels[0].view; + srcView = sourceView; + } else if (step < levelCount) { + set = step; + dstView = levels[step].view; + srcView = levels[step - 1].view; + } else { + // Upsample steps run coarse-to-fine; the set index only has to be unique per step. + int dst = levelCount - 1 - (step - levelCount) - 1; + set = MAX_LEVELS + dst; + dstView = levels[dst].view; + srcView = levels[dst + 1].view; + } + index = writeSet(stack, images, writes, index, descriptorSets[set], + dstView, srcView, exposureView); + } + VkWriteDescriptorSet.Buffer used = VkWriteDescriptorSet.create(writes.address(), index); + VK10.vkUpdateDescriptorSets(ctx.vk(), used, null); + } + boundSourceView = sourceView; + boundExposureView = exposureView; + boundLevelViews = new long[levelCount]; + for (int i = 0; i < levelCount; i++) { + boundLevelViews[i] = levels[i].view; + } + } + + private boolean sameViews(RtImage[] levels) { + if (boundLevelViews.length != levels.length) { + return false; + } + for (int i = 0; i < levels.length; i++) { + if (boundLevelViews[i] != levels[i].view) { + return false; + } + } + return true; + } + + private int writeSet(MemoryStack stack, VkDescriptorImageInfo.Buffer images, + VkWriteDescriptorSet.Buffer writes, int index, long set, + long dstView, long srcView, long exposureView) { + images.get(index).imageView(dstView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(index).sType$Default().dstSet(set).dstBinding(BLOOM_OUTPUT) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .pImageInfo(VkDescriptorImageInfo.create(images.address(index), 1)); + index++; + images.get(index).imageView(srcView).sampler(sampler) + .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(index).sType$Default().dstSet(set).dstBinding(BLOOM_SOURCE) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .pImageInfo(VkDescriptorImageInfo.create(images.address(index), 1)); + index++; + images.get(index).imageView(exposureView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(index).sType$Default().dstSet(set).dstBinding(BLOOM_EXPOSURE) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .pImageInfo(VkDescriptorImageInfo.create(images.address(index), 1)); + return index + 1; + } + + /** + * Record the whole pyramid: prefilter into level 0, downsample to the top, then tent back down + * accumulating each band. A memory barrier separates every step — each one reads exactly what the + * previous wrote. + */ + public void dispatch(VkCommandBuffer cmd, RtImage[] levels, + float threshold, float softKneeFraction, float radius) { + float softKnee = threshold * softKneeFraction; + int levelCount = levels.length; + try (MemoryStack stack = MemoryStack.stackPush(); + RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "scene bloom")) { + VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); + recordStep(cmd, stack, descriptorSets[0], levels[0], MODE_PREFILTER, + threshold, softKnee, radius); + for (int level = 1; level < levelCount; level++) { + recordStep(cmd, stack, descriptorSets[level], levels[level], MODE_DOWNSAMPLE, + threshold, softKnee, radius); + } + for (int level = levelCount - 2; level >= 0; level--) { + recordStep(cmd, stack, descriptorSets[MAX_LEVELS + level], levels[level], MODE_UPSAMPLE, + threshold, softKnee, radius); + } + } + } + + private void recordStep(VkCommandBuffer cmd, MemoryStack stack, long set, RtImage dst, int mode, + float threshold, float softKnee, float radius) { + VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, + pipelineLayout, 0, stack.longs(set), null); + ByteBuffer push = stack.malloc(BloomPushData.BYTE_SIZE); + new BloomPushData(mode, threshold, softKnee, radius).write(push); + VK10.vkCmdPushConstants(cmd, pipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); + VK10.vkCmdDispatch(cmd, (dst.width + 7) / 8, (dst.height + 7) / 8, 1); + VulkanCommandEncoder.memoryBarrier(cmd, stack); + } + + public void destroy() { + if (destroyed) { + return; + } + VkDevice vk = ctx.vk(); + VK10.vkDestroyPipeline(vk, pipeline, null); + VK10.vkDestroySampler(vk, sampler, null); + VK10.vkDestroyPipelineLayout(vk, pipelineLayout, null); + VK10.vkDestroyDescriptorPool(vk, descriptorPool, null); + VK10.vkDestroyDescriptorSetLayout(vk, descriptorSetLayout, null); + destroyed = true; + } + + private static long loadModule(VkDevice vk, MemoryStack stack) { + byte[] bytes; + try (InputStream input = RtBloomPipeline.class.getResourceAsStream(SHADER)) { + if (input == null) { + throw new IllegalStateException("missing SPIR-V resource: " + SHADER); + } + bytes = input.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("failed to read SPIR-V resource: " + SHADER, e); + } + ByteBuffer code = MemoryUtil.memAlloc(bytes.length).put(bytes); + code.flip(); + try { + VkShaderModuleCreateInfo moduleInfo = VkShaderModuleCreateInfo.calloc(stack) + .sType$Default().pCode(code); + LongBuffer module = stack.mallocLong(1); + check(VK10.vkCreateShaderModule(vk, moduleInfo, null, module), + "vkCreateShaderModule(bloom)"); + return module.get(0); + } finally { + MemoryUtil.memFree(code); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDebugPresentPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDebugPresentPipeline.java new file mode 100644 index 00000000..c292ee5a --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDebugPresentPipeline.java @@ -0,0 +1,216 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkComputePipelineCreateInfo; +import org.lwjgl.vulkan.VkDescriptorBufferInfo; +import org.lwjgl.vulkan.VkDescriptorImageInfo; +import org.lwjgl.vulkan.VkDescriptorPoolCreateInfo; +import org.lwjgl.vulkan.VkDescriptorPoolSize; +import org.lwjgl.vulkan.VkDescriptorSetAllocateInfo; +import org.lwjgl.vulkan.VkDescriptorSetLayoutBinding; +import org.lwjgl.vulkan.VkDescriptorSetLayoutCreateInfo; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkPipelineLayoutCreateInfo; +import org.lwjgl.vulkan.VkPipelineShaderStageCreateInfo; +import org.lwjgl.vulkan.VkPushConstantRange; +import org.lwjgl.vulkan.VkShaderModuleCreateInfo; +import org.lwjgl.vulkan.VkWriteDescriptorSet; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.LongBuffer; + +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.accel.RtBuffer; +import dev.comfyfluffy.caustica.rt.gen.DebugPresentPushData; + +import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; + +/** + * Computes and presents {@code debugView} content as a downstream inspection pass after + * {@link RtDisplayPipeline}, separate from the primary raygen. It nearest-samples the real + * render-resolution guide buffers while RR remains enabled, keeping debug-only register pressure out + * of the primary raygen. Diagnostic output is written after exposure and ACES, so literal colors remain literal + * without perturbing the exposure controller's history. + */ +public final class RtDebugPresentPipeline { + private static final String SHADER_DIR = "/caustica/shaders/pipelines/debug_present/"; + private static final int PUSH_BYTES = DebugPresentPushData.BYTE_SIZE; + + private final RtContext ctx; + private final long descriptorSetLayout; + private final long descriptorPool; + private final long descriptorSet; + private final long pipelineLayout; + private final long pipeline; + private long boundOutputView; + private long boundNormalView; + private long boundAlbedoView; + private long boundDepthView; + private long boundMotionView; + private long boundSpecAlbedoView; + private long boundSpecMotionView; + private long boundSceneView; + private long boundExposureView; + private long boundExposureStateBuffer; + private boolean destroyed; + + private RtDebugPresentPipeline(RtContext ctx, long dsl, long pool, long set, long layout, long pipeline) { + this.ctx = ctx; + this.descriptorSetLayout = dsl; + this.descriptorPool = pool; + this.descriptorSet = set; + this.pipelineLayout = layout; + this.pipeline = pipeline; + } + + public static RtDebugPresentPipeline create(RtContext ctx) { + VkDevice vk = ctx.vk(); + try (MemoryStack stack = MemoryStack.stackPush()) { + // 0: output (SDR display target). 1..6: guide buffers. 7: post-RR scene image. + // 8: same-frame display exposure. 9: exposure state, including the sky metering scale. + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(DEBUG_PRESENT_BINDING_COUNT, stack); + for (int i = DEBUG_PRESENT_OUTPUT; i < DEBUG_PRESENT_EXPOSURE_STATE; i++) { + binds.get(i).binding(i).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + } + binds.get(DEBUG_PRESENT_EXPOSURE_STATE).binding(DEBUG_PRESENT_EXPOSURE_STATE) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + + VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); + LongBuffer p = stack.mallocLong(1); + check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout(rt debug present)"); + long dsl = p.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, "debug present descriptor set layout"); + + VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(2, stack); + poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(9); + poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER).descriptorCount(1); + VkDescriptorPoolCreateInfo dpci = VkDescriptorPoolCreateInfo.calloc(stack).sType$Default().maxSets(1).pPoolSizes(poolSizes); + check(VK10.vkCreateDescriptorPool(vk, dpci, null, p), "vkCreateDescriptorPool(rt debug present)"); + long pool = p.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_POOL, pool, "debug present descriptor pool"); + + VkDescriptorSetAllocateInfo dsai = VkDescriptorSetAllocateInfo.calloc(stack).sType$Default() + .descriptorPool(pool).pSetLayouts(stack.longs(dsl)); + LongBuffer pSet = stack.mallocLong(1); + check(VK10.vkAllocateDescriptorSets(vk, dsai, pSet), "vkAllocateDescriptorSets(rt debug present)"); + long set = pSet.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, set, "debug present descriptor set"); + + VkPushConstantRange.Buffer pushRange = VkPushConstantRange.calloc(1, stack); + pushRange.get(0).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT).offset(0).size(PUSH_BYTES); + VkPipelineLayoutCreateInfo plci = VkPipelineLayoutCreateInfo.calloc(stack).sType$Default() + .pSetLayouts(stack.longs(dsl)).pPushConstantRanges(pushRange); + check(VK10.vkCreatePipelineLayout(vk, plci, null, p), "vkCreatePipelineLayout(rt debug present)"); + long layout = p.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, "debug present pipeline layout"); + + long module = loadModule(vk, stack, "main.comp.spv"); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, "debug present shader module"); + VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default() + .stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main")); + VkComputePipelineCreateInfo.Buffer cpci = VkComputePipelineCreateInfo.calloc(1, stack); + cpci.get(0).sType$Default().stage(stage).layout(layout); + LongBuffer pPipeline = stack.mallocLong(1); + check(VK10.vkCreateComputePipelines(vk, VK10.VK_NULL_HANDLE, cpci, null, pPipeline), + "vkCreateComputePipelines(rt debug present)"); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, pPipeline.get(0), "debug present compute pipeline"); + VK10.vkDestroyShaderModule(vk, module, null); + + return new RtDebugPresentPipeline(ctx, dsl, pool, set, layout, pPipeline.get(0)); + } + } + + public void setImages(long outputImageView, long normalView, long albedoView, long depthView, + long motionView, long specAlbedoView, long specMotionView, + long sceneView, long exposureView, RtBuffer exposureState) { + if (boundOutputView == outputImageView && boundNormalView == normalView && boundAlbedoView == albedoView + && boundDepthView == depthView && boundMotionView == motionView + && boundSpecAlbedoView == specAlbedoView && boundSpecMotionView == specMotionView + && boundSceneView == sceneView && boundExposureView == exposureView + && boundExposureStateBuffer == exposureState.handle) { + return; + } + try (MemoryStack stack = MemoryStack.stackPush()) { + long[] views = {outputImageView, normalView, albedoView, depthView, motionView, + specAlbedoView, specMotionView, sceneView, exposureView}; + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(DEBUG_PRESENT_BINDING_COUNT, stack); + for (int i = 0; i < 9; i++) { + VkDescriptorImageInfo.Buffer info = VkDescriptorImageInfo.calloc(1, stack); + info.get(0).imageView(views[i]).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(i).sType$Default().dstSet(descriptorSet).dstBinding(i) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(info); + } + VkDescriptorBufferInfo.Buffer stateInfo = VkDescriptorBufferInfo.calloc(1, stack); + stateInfo.get(0).buffer(exposureState.handle).offset(0).range(exposureState.size); + writes.get(DEBUG_PRESENT_EXPOSURE_STATE).sType$Default().dstSet(descriptorSet) + .dstBinding(DEBUG_PRESENT_EXPOSURE_STATE) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER).pBufferInfo(stateInfo); + VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); + } + boundOutputView = outputImageView; + boundNormalView = normalView; + boundAlbedoView = albedoView; + boundDepthView = depthView; + boundMotionView = motionView; + boundSpecAlbedoView = specAlbedoView; + boundSpecMotionView = specMotionView; + boundSceneView = sceneView; + boundExposureView = exposureView; + boundExposureStateBuffer = exposureState.handle; + } + + public void dispatch(VkCommandBuffer cmd, int width, int height, int debugView, + float centerWeightSigma, float centerWeightFloor) { + try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "debug present 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(DebugPresentPushData.BYTE_SIZE); + new DebugPresentPushData(debugView, centerWeightSigma, centerWeightFloor).write(push); + VK10.vkCmdPushConstants(cmd, pipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); + VK10.vkCmdDispatch(cmd, (width + 15) / 16, (height + 15) / 16, 1); + } + } + + public void destroy() { + if (destroyed) { + return; + } + VkDevice vk = ctx.vk(); + VK10.vkDestroyPipeline(vk, pipeline, null); + VK10.vkDestroyPipelineLayout(vk, pipelineLayout, null); + VK10.vkDestroyDescriptorPool(vk, descriptorPool, null); + VK10.vkDestroyDescriptorSetLayout(vk, descriptorSetLayout, null); + destroyed = true; + } + + private static long loadModule(VkDevice vk, MemoryStack stack, String name) { + byte[] bytes; + try (InputStream in = RtDebugPresentPipeline.class.getResourceAsStream(SHADER_DIR + name)) { + if (in == null) { + throw new IllegalStateException("missing SPIR-V resource: " + SHADER_DIR + name); + } + bytes = in.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("failed to read SPIR-V resource: " + SHADER_DIR + name, e); + } + ByteBuffer code = MemoryUtil.memAlloc(bytes.length).put(bytes); + code.flip(); + try { + VkShaderModuleCreateInfo smci = VkShaderModuleCreateInfo.calloc(stack).sType$Default().pCode(code); + LongBuffer pModule = stack.mallocLong(1); + check(VK10.vkCreateShaderModule(vk, smci, null, pModule), "vkCreateShaderModule(" + name + ")"); + return pModule.get(0); + } finally { + MemoryUtil.memFree(code); + } + } +} 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 e4cf0ecd..76ea7964 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java @@ -25,14 +25,16 @@ import dev.comfyfluffy.caustica.rt.RtContext; import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.gen.DisplayPushData; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; -/** Compute pass that maps the display-res HDR RT image into an LDR image compatible with the main target. */ +/** Maps the display-res scene-linear ACEScg RT image to sRGB SDR and, when enabled, PQ/BT.2020 HDR. */ public final class RtDisplayPipeline { - private static final String SHADER_DIR = "/caustica/rt/"; - /** Push constants: int hdrEnabled, float paperWhiteNits, float headroom. */ - private static final int PUSH_BYTES = 3 * Integer.BYTES; + private static final String SHADER_DIR = "/caustica/shaders/pipelines/display/"; + /** Push constants: output/look LUT state plus gamma and HDR peak nits. */ + private static final int PUSH_BYTES = DisplayPushData.BYTE_SIZE; private final RtContext ctx; private final long descriptorSetLayout; @@ -44,6 +46,14 @@ public final class RtDisplayPipeline { private long boundRtView; private long boundExposureView; private long boundHdrView; + private long boundLutView; + private long boundLutSampler; + private long boundHdrLutView; + private long boundHdrLutSampler; + private long boundLookLutView; + private long boundLookLutSampler; + private long boundBloomView; + private long boundBloomSampler; private boolean destroyed; private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long layout, long pipeline) { @@ -58,14 +68,23 @@ private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long lay public static RtDisplayPipeline create(RtContext ctx) { VkDevice vk = ctx.vk(); try (MemoryStack stack = MemoryStack.stackPush()) { - VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(4, stack); - binds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(DISPLAY_BINDING_COUNT, stack); + binds.get(DISPLAY_OUTPUT).binding(DISPLAY_OUTPUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - binds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + binds.get(DISPLAY_RT_IMAGE).binding(DISPLAY_RT_IMAGE).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - binds.get(2).binding(2).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + binds.get(DISPLAY_EXPOSURE).binding(DISPLAY_EXPOSURE).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - binds.get(3).binding(3).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + binds.get(DISPLAY_HDR_OUTPUT).binding(DISPLAY_HDR_OUTPUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + // Baked ACES 2.0 display-transform LUTs; see RtToneLut. + binds.get(DISPLAY_SDR_TONE_LUT).binding(DISPLAY_SDR_TONE_LUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + binds.get(DISPLAY_HDR_TONE_LUT).binding(DISPLAY_HDR_TONE_LUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + binds.get(DISPLAY_LOOK_LUT).binding(DISPLAY_LOOK_LUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .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); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); @@ -74,8 +93,9 @@ public static RtDisplayPipeline create(RtContext ctx) { long dsl = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, "display descriptor set layout"); - VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(1, stack); + 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); 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); @@ -96,7 +116,7 @@ public static RtDisplayPipeline create(RtContext ctx) { long layout = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, "display pipeline layout"); - long module = loadModule(vk, stack, "display.comp.spv"); + long module = loadModule(vk, stack, "main.comp.spv"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, "display shader module"); VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default() .stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main")); @@ -112,9 +132,15 @@ public static RtDisplayPipeline create(RtContext ctx) { } } - public void setImages(long outputImageView, long rtImageView, long exposureImageView, long hdrImageView) { + 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) { if (boundOutputView == outputImageView && boundRtView == rtImageView - && boundExposureView == exposureImageView && boundHdrView == hdrImageView) { + && boundExposureView == exposureImageView && boundHdrView == hdrImageView + && boundLutView == lutView && boundLutSampler == lutSampler + && boundHdrLutView == hdrLutView && boundHdrLutSampler == hdrLutSampler + && boundLookLutView == lookLutView && boundLookLutSampler == lookLutSampler + && boundBloomView == bloomView && boundBloomSampler == bloomSampler) { return; } try (MemoryStack stack = MemoryStack.stackPush()) { @@ -126,36 +152,65 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage exposureInfo.get(0).imageView(exposureImageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkDescriptorImageInfo.Buffer hdrInfo = VkDescriptorImageInfo.calloc(1, stack); hdrInfo.get(0).imageView(hdrImageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); - - VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(4, stack); - writes.get(0).sType$Default().dstSet(descriptorSet).dstBinding(0) + VkDescriptorImageInfo.Buffer lutInfo = VkDescriptorImageInfo.calloc(1, stack); + lutInfo.get(0).imageView(lutView).sampler(lutSampler).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer hdrLutInfo = VkDescriptorImageInfo.calloc(1, stack); + hdrLutInfo.get(0).imageView(hdrLutView).sampler(hdrLutSampler).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer lookLutInfo = VkDescriptorImageInfo.calloc(1, stack); + lookLutInfo.get(0).imageView(lookLutView).sampler(lookLutSampler).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer bloomInfo = VkDescriptorImageInfo.calloc(1, stack); + bloomInfo.get(0).imageView(bloomView).sampler(bloomSampler) + .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) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(outputInfo); - writes.get(1).sType$Default().dstSet(descriptorSet).dstBinding(1) + writes.get(DISPLAY_RT_IMAGE).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_RT_IMAGE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(rtInfo); - writes.get(2).sType$Default().dstSet(descriptorSet).dstBinding(2) + writes.get(DISPLAY_EXPOSURE).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_EXPOSURE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(exposureInfo); - writes.get(3).sType$Default().dstSet(descriptorSet).dstBinding(3) + writes.get(DISPLAY_HDR_OUTPUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_HDR_OUTPUT) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(hdrInfo); + writes.get(DISPLAY_SDR_TONE_LUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_SDR_TONE_LUT) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(lutInfo); + writes.get(DISPLAY_HDR_TONE_LUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_HDR_TONE_LUT) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(hdrLutInfo); + writes.get(DISPLAY_LOOK_LUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_LOOK_LUT) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(lookLutInfo); + writes.get(DISPLAY_BLOOM).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_BLOOM) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .pImageInfo(bloomInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } boundOutputView = outputImageView; boundRtView = rtImageView; boundExposureView = exposureImageView; boundHdrView = hdrImageView; + boundLutView = lutView; + boundLutSampler = lutSampler; + boundHdrLutView = hdrLutView; + boundHdrLutSampler = hdrLutSampler; + boundLookLutView = lookLutView; + boundLookLutSampler = lookLutSampler; + boundBloomView = bloomView; + boundBloomSampler = bloomSampler; } /** - * Run the display mapping. The SDR AgX output is always written (binding 0). When {@code hdrEnabled}, the - * PQ-encoded HDR image (binding 3) is also written using the paper-white/headroom mapping. + * 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. */ - public void dispatch(VkCommandBuffer cmd, int width, int height, boolean hdrEnabled, float paperWhiteNits, float headroom) { + public void dispatch(VkCommandBuffer cmd, int width, int height, boolean hdrEnabled, int lutSize, + float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize, + float bloomStrength) { 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(PUSH_BYTES); - push.putInt(0, hdrEnabled ? 1 : 0); - push.putFloat(4, paperWhiteNits); - push.putFloat(8, headroom); + ByteBuffer push = stack.malloc(DisplayPushData.BYTE_SIZE); + new DisplayPushData(hdrEnabled ? 1 : 0, (float) lutSize, gamma, hdrPeakNits, + lookEnabled ? 1 : 0, (float) lookLutSize, bloomStrength).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 1a439466..5977a299 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java @@ -27,7 +27,7 @@ public static boolean enabled() { return CausticaConfig.Rt.DlssRr.ENABLED.value(); } - // DLSS feature flags. IsHDR (bit 0): color is linear HDR (rgba16f) — RR requires it ("HDR Color + // DLSS feature flags. IsHDR (bit 0): color is scene-linear ACEScg HDR (rgba16f) — RR requires it ("HDR Color // required"). MVLowRes (bit 1): motion vectors are at render/input resolution, not display — RR // requires it ("Low resolution Motion Vectors required"). DepthInverted (bit 3): the depth guide is // HW reversed-Z (near=1, far=0). AutoExposure (bit 6): in HDR mode DLSS needs the scene exposure 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 28f23998..3676f9d7 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java @@ -5,23 +5,58 @@ import dev.comfyfluffy.caustica.CausticaMod; import dev.comfyfluffy.caustica.rt.RtContext; import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.RtGpuExecutor; +import dev.comfyfluffy.caustica.rt.RtSceneUnits; +import dev.comfyfluffy.caustica.rt.RtLookPackage; import dev.comfyfluffy.caustica.rt.accel.RtBuffer; import dev.comfyfluffy.caustica.rt.accel.RtImage; +import dev.comfyfluffy.caustica.rt.gen.ExposureStateData; import org.lwjgl.system.MemoryStack; import org.lwjgl.system.MemoryUtil; import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkBufferCopy; +import org.lwjgl.vulkan.VkBufferMemoryBarrier; import org.lwjgl.vulkan.VkClearColorValue; import org.lwjgl.vulkan.VkCommandBuffer; import org.lwjgl.vulkan.VkImageSubresourceRange; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; + /** Owns the display exposure value shared by the RT compositor's display-mapping passes. */ public final class RtExposure { private RtImage image; private RtBuffer histogram; private RtBuffer state; + private ReadbackSlot[] stateReadbacks; + private int stateReadbackIndex = -1; + private ReadbackSlot pendingStateReadback; + private ExposureStateData completedState; private RtExposurePipeline pipeline; private boolean logged; private long lastFrameNanos; + private long lastDiagLogNanos; + private String cachedCurveSpec; + private ExposureCurve cachedCurve; + private boolean resetRequested = true; + private int resetSequence; + /** This frame's latched pre-exposure; see {@link #beginFrame(RtGpuExecutor.GraphicsUseWaiter)}. */ + private float framePreExposure = 1.0f; + + private static final long DIAG_LOG_INTERVAL_NANOS = 1_000_000_000L; + private static final int STATE_READBACK_RING = 6; + + private static final class ReadbackSlot { + final RtBuffer buffer; + final RtGpuExecutor.TrackedGraphicsUse graphicsUse = new RtGpuExecutor.TrackedGraphicsUse(); + boolean valid; + int resetSequence; + + ReadbackSlot(RtBuffer buffer) { + this.buffer = buffer; + } + } public RtImage image() { return image; @@ -31,20 +66,84 @@ public boolean ready() { return image != null; } + public RtBuffer stateBuffer() { + return state; + } + + /** Immutable exposure values attached to a residual-exposed EXR capture. */ + public record CaptureMetadata( + float preExposure, + float residualExposure, + float absoluteExposure, + String mode, + float evScene, + float evTarget, + float evApplied + ) { + } + + /** + * Snapshot the controller after the capture copy has completed. + * + *

{@code residualExposure} is read from the same 1x1 GPU image that the display shader samples. + * The absolute multiplier can therefore be reconstructed exactly as + * {@code preExposure * residualExposure}, even when auto exposure corrected a stale prediction. + */ + public CaptureMetadata captureMetadata(float residualExposure) { + if (!Float.isFinite(residualExposure) || residualExposure <= 0.0f) { + throw new IllegalArgumentException("Invalid residual exposure " + residualExposure); + } + Mode currentMode = mode(); + float pre = preExposure(); + float absolute = pre * residualExposure; + if (currentMode != Mode.AUTO || state == null || state.mapped == 0L) { + float ev = manualEv(); + return new CaptureMetadata(pre, residualExposure, absolute, currentMode.configName, + Float.NaN, ev, ev); + } + state.invalidate(); + ExposureStateData snapshot = readState(); + return new CaptureMetadata(pre, residualExposure, absolute, currentMode.configName, + snapshot.evScene(), snapshot.evTarget(), snapshot.evApplied()); + } + public void ensureResources(RtContext ctx) { if (image == null) { image = ctx.createStorageImage(1, 1, VK10.VK_FORMAT_R32_SFLOAT, "display exposure"); } + // The final debug pass always binds the state buffer, including in manual mode. Keep this tiny + // resource permanently available; histogram/pipeline allocation remains auto-only. + if (state == null) { + state = ctx.createBuffer(ExposureStateData.BYTE_SIZE, + VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK10.VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + true, "exposure state"); + resetAutoHistory(); + } if (mode() == Mode.AUTO) { + if (stateReadbacks == null) { + ReadbackSlot[] created = new ReadbackSlot[STATE_READBACK_RING]; + try { + for (int i = 0; i < created.length; i++) { + created[i] = new ReadbackSlot(ctx.createReadbackBuffer( + ExposureStateData.BYTE_SIZE, "exposure state readback " + i)); + } + } catch (Throwable t) { + for (ReadbackSlot slot : created) { + if (slot != null) { + slot.buffer.destroy(); + } + } + throw t; + } + stateReadbacks = created; + } if (histogram == null) { - histogram = ctx.createBuffer(256L * Integer.BYTES, + // Separate ordinary-surface/sky/emissive histograms let resolve enforce both + // population caps exactly without a second full-image dispatch. + histogram = ctx.createBuffer(768L * Integer.BYTES, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK10.VK_BUFFER_USAGE_TRANSFER_DST_BIT, false, "exposure histogram"); } - if (state == null) { - state = ctx.createBuffer(16, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, "exposure state"); - resetAutoHistory(); - } if (pipeline == null) { pipeline = RtExposurePipeline.create(ctx); } @@ -52,17 +151,20 @@ public void ensureResources(RtContext ctx) { logOnce(); } - public void record(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, RtImage traceColor) { + public void record(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, + RtImage traceColor, RtImage guideDepth, RtImage guideAlbedo) { if (image == null) { throw new IllegalStateException("RT exposure image not created"); } if (mode() == Mode.AUTO) { - recordAuto(ctx, cmd, stack, traceColor); + recordAuto(ctx, cmd, stack, traceColor, guideDepth, guideAlbedo); return; } try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure manual write")) { VkClearColorValue color = VkClearColorValue.calloc(stack); - color.float32(0, manualExposureScale()); + // Residual, not absolute: raygen already applied preExposure (which in manual mode IS + // manualExposureScale, making this exactly 1.0). See preExposure(). + color.float32(0, manualExposureScale() / Math.max(preExposure(), 1.0e-12f)); VkImageSubresourceRange.Buffer range = VkImageSubresourceRange.calloc(1, stack); range.get(0).aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) .baseMipLevel(0).levelCount(1).baseArrayLayer(0).layerCount(1); @@ -83,10 +185,22 @@ public void destroy() { state.destroy(); state = null; } + if (stateReadbacks != null) { + for (ReadbackSlot slot : stateReadbacks) { + slot.buffer.destroy(); + } + stateReadbacks = null; + } if (image != null) { image.destroy(); image = null; } + resetRequested = true; + resetSequence = 0; + stateReadbackIndex = -1; + pendingStateReadback = null; + completedState = null; + framePreExposure = 1.0f; } // Manual mode's exposure scale, also used as the auto-history seed (resetAutoHistory) so the very @@ -95,18 +209,133 @@ private float manualExposureScale() { return CausticaConfig.Rt.Exposure.clampScale((float) Math.pow(2.0, manualEv())); } - private void recordAuto(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, RtImage traceColor) { + private void recordAuto(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, + RtImage traceColor, RtImage guideDepth, RtImage guideAlbedo) { if (pipeline == null || histogram == null || state == null) { throw new IllegalStateException("RT auto exposure resources not created"); } - pipeline.setResources(traceColor.view, histogram, image.view, state); + pipeline.setResources(traceColor.view, guideDepth.view, guideAlbedo.view, + histogram, image.view, state); try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure histogram clear")) { VK10.vkCmdFillBuffer(cmd, histogram.handle, 0, histogram.size, 0); } VulkanCommandEncoder.memoryBarrier(cmd, stack); - pipeline.dispatchHistogram(cmd, traceColor.width, traceColor.height); + AutoConfig config = autoConfig(); + pipeline.dispatchHistogram(cmd, traceColor.width, traceColor.height, config); VulkanCommandEncoder.memoryBarrier(cmd, stack); - pipeline.dispatchResolve(cmd, Math.max(1, traceColor.width * traceColor.height), autoConfig(), frameTimeSeconds()); + pipeline.dispatchResolve(cmd, config, frameTimeSeconds()); + logDiagnosticsIfDue(); + } + + /** + * Copies the GPU-owned controller state into this frame's guarded host-readback slot. The slot is not + * 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) { + return; + } + VkBufferMemoryBarrier.Buffer toTransfer = VkBufferMemoryBarrier.calloc(1, stack); + toTransfer.get(0).sType$Default() + .srcAccessMask(VK10.VK_ACCESS_SHADER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_TRANSFER_READ_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .buffer(state.handle) + .offset(0L) + .size(ExposureStateData.BYTE_SIZE); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, null, toTransfer, null); + + VkBufferCopy.Buffer copy = VkBufferCopy.calloc(1, stack) + .srcOffset(0L).dstOffset(0L).size(ExposureStateData.BYTE_SIZE); + VK10.vkCmdCopyBuffer(cmd, state.handle, pendingStateReadback.buffer.handle, copy); + } + + /** Attach the readback copy only after the command buffer has been accepted for frame submission. */ + public void markStateReadbackUse(RtGpuExecutor.GraphicsUse graphicsUse) { + if (pendingStateReadback == null) { + return; + } + pendingStateReadback.resetSequence = resetSequence; + pendingStateReadback.valid = true; + pendingStateReadback.graphicsUse.mark(graphicsUse); + pendingStateReadback = null; + } + + /** + * Throttled log of the controller's internal EVs, gated + * behind the frame-stats toggle since that's the existing "I want renderer internals" switch. + * Uses the latest completed timeline-guarded readback. It can be a few frames stale without racing + * the GPU, which is sufficient for diagnostics. + */ + private void logDiagnosticsIfDue() { + if (!CausticaConfig.Rt.FrameStats.ENABLED.value() || completedState == null) { + return; + } + long now = System.nanoTime(); + if (lastDiagLogNanos != 0L && now - lastDiagLogNanos < DIAG_LOG_INTERVAL_NANOS) { + return; + } + lastDiagLogNanos = now; + ExposureStateData snapshot = completedState; + float evScene = snapshot.evScene(); + float evTarget = snapshot.evTarget(); + float evApplied = snapshot.evApplied(); + float clipLowFrac = snapshot.clipLowFrac(); + float clipHighFrac = snapshot.clipHighFrac(); + float skyScale = snapshot.meteringSkyScale(); + float skyFrac = snapshot.meteringSkyFrac(); + float curveCompensation = snapshot.curveCompensation(); + float effectiveSlope = snapshot.effectiveSlope(); + float emissiveScale = snapshot.meteringEmissiveScale(); + float emissiveFrac = snapshot.meteringEmissiveFrac(); + AutoConfig cfg = autoConfig(); + boolean pinnedLow = evTarget <= cfg.minEv() + 0.01f; + boolean pinnedHigh = evTarget >= cfg.maxEv() - 0.01f; + // evScene is EV100; evTarget/evApplied are log2 of the absolute + // exposure multiplier, i.e. pre-exposure already divided back out, so they stay comparable + // across frames regardless of what preExposure happened to be. + CausticaMod.LOGGER.info( + "RT exposure diag: evScene(EV100)={} evTarget={}{} evApplied={} preExposure={} " + + "clipLow={}% clipHigh={}% skyScale={} skyWeight={}% emissiveScale={} " + + "emissiveWeight={}% curveComp={} effectiveSlope={}", + fmt(evScene), fmt(evTarget), pinnedLow ? " (at minEv clamp)" : pinnedHigh ? " (at maxEv clamp)" : "", + fmt(evApplied), fmt(preExposure()), fmt(clipLowFrac * 100.0f), fmt(clipHighFrac * 100.0f), + fmt(skyScale), fmt(skyFrac * 100.0f), fmt(emissiveScale), + fmt(emissiveFrac * 100.0f), fmt(curveCompensation), fmt(effectiveSlope)); + } + + private static String fmt(float v) { + return String.format(java.util.Locale.ROOT, "%.2f", v); + } + + /** + * One-line summary for the F3 debug screen ({@code RtExposureDebugEntry}). Unlike + * {@link #logDiagnosticsIfDue()} this is not throttled and not gated on + * {@code CausticaConfig.Rt.FrameStats.ENABLED} -- F3 only calls it once the player has enabled + * that entry, and the game's own render cadence is throttle enough. Returns {@code null} when + * there is nothing meaningful to show yet (state buffer not created). + */ + public String debugSummaryLine() { + if (state == null) { + return null; + } + if (mode() != Mode.AUTO) { + return String.format(java.util.Locale.ROOT, "Exposure: manual %s EV", fmt(manualEv())); + } + ExposureStateData snapshot = completedState; + if (snapshot == null) { + return null; + } + float evScene = snapshot.evScene(); + float evTarget = snapshot.evTarget(); + float evApplied = snapshot.evApplied(); + AutoConfig cfg = autoConfig(); + String clamp = evTarget <= cfg.minEv() + 0.01f ? " (min clamp)" + : evTarget >= cfg.maxEv() - 0.01f ? " (max clamp)" : ""; + return String.format(java.util.Locale.ROOT, "Exposure: EV100 %s, applied %s EV%s", + fmt(evScene), fmt(evApplied), clamp); } private float frameTimeSeconds() { @@ -121,10 +350,19 @@ private void resetAutoHistory() { if (state == null || state.mapped == 0L) { return; } - MemoryUtil.memPutFloat(state.mapped, manualExposureScale()); - MemoryUtil.memPutInt(state.mapped + 4, 0); - state.flush(0L, 2L * Integer.BYTES); + // Under physical units this seed can be ~15 EV off for an auto-mode daylight scene, since + // manual-ev defaults to 0. That is a two-frame transient, not a bug: initialized == 0 makes the + // resolve snap to its computed target rather than smooth toward it, and the frame after that + // meters against a preExposure derived from it. Deliberately not special-cased -- a seed that + // guessed at scene brightness would be a second, unowned exposure model. + new ExposureStateData( + manualExposureScale(), 0, + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0, + 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f + ).write(stateDataBuffer()); + state.flush(0L, ExposureStateData.BYTE_SIZE); lastFrameNanos = 0L; + lastDiagLogNanos = 0L; } private void logOnce() { @@ -136,11 +374,18 @@ private void logOnce() { AutoConfig autoConfig = autoConfig(); String exposureText = mode == Mode.AUTO ? "auto(key=" + autoConfig.key + ", minEv=" + autoConfig.minEv + ", maxEv=" + autoConfig.maxEv - + ", adaptUp=" + autoConfig.adaptUp + ", adaptDown=" + autoConfig.adaptDown - + ", evBias=" + autoConfig.evBias + ")" + + ", adaptDarken=" + autoConfig.adaptDarken + ", adaptBrighten=" + autoConfig.adaptBrighten + + ", evBias=" + autoConfig.evBias + ", percentiles=" + autoConfig.lowPercentile + + ".." + autoConfig.highPercentile + ", stride=" + autoConfig.stride + + ", centerWeight=" + autoConfig.centerWeightSigma + "/" + autoConfig.centerWeightFloor + + ", skyCap=" + autoConfig.skyWeightCap + + ", emissiveCap=" + autoConfig.emissiveWeightCap + + ", curve=" + CausticaConfig.Rt.Exposure.curve() + ")" : Float.toString(manualExposureScale()); - CausticaMod.LOGGER.info("RT display exposure: mode={}, exposure={}, tonemap=agx, DLSS-RR exposure=NGX auto", - mode.configName, exposureText); + CausticaMod.LOGGER.info("RT display exposure: mode={}, exposure={}, " + + "tonemap=aces2.0(lookPackage={},gamma={}), DLSS-RR exposure=NGX auto", + mode.configName, exposureText, RtLookPackage.current().id(), + CausticaConfig.Rt.Tonemap.GAMMA.value()); } private static Mode mode() { @@ -151,17 +396,232 @@ private static float manualEv() { return CausticaConfig.Rt.Exposure.MANUAL_EV.value(); } - private static AutoConfig autoConfig() { + private AutoConfig autoConfig() { return new AutoConfig( CausticaConfig.Rt.Exposure.KEY.value(), CausticaConfig.Rt.Exposure.minEv(), CausticaConfig.Rt.Exposure.maxEv(), - CausticaConfig.Rt.Exposure.ADAPT_UP.value(), - CausticaConfig.Rt.Exposure.ADAPT_DOWN.value(), - manualEv()); + 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(), + CausticaConfig.Rt.Exposure.STRIDE.value(), + CausticaConfig.Rt.Exposure.CENTER_WEIGHT_SIGMA.value(), + CausticaConfig.Rt.Exposure.CENTER_WEIGHT_FLOOR.value(), + CausticaConfig.Rt.Exposure.SKY_WEIGHT_CAP.value(), + CausticaConfig.Rt.Exposure.EMISSIVE_WEIGHT_CAP.value(), + curveConfig(), + preExposure(), + resetSequence); } - record AutoConfig(float key, float minEv, float maxEv, float adaptUp, float adaptDown, float evBias) { + /** + * Latches this frame's pre-exposure. MUST be called once per frame before the world push + * constants are written, and must not be re-latched afterwards. + * + *

The raygen multiply and the resolve's divide have to use the same value or they + * stop cancelling and the frame comes out mis-scaled. Both read {@link #preExposure()}, but at + * different points in CPU time. The completed readback can be several frames old, so latching once + * ensures both consumers use one prediction; the residual absorbs whatever it failed to predict. + */ + public void beginFrame(RtGpuExecutor.GraphicsUseWaiter graphicsUseWaiter) { + Mode currentMode = mode(); + boolean reset = currentMode == Mode.AUTO && resetRequested; + if (reset) { + resetSequence++; + lastFrameNanos = 0L; + completedState = null; + resetRequested = false; + } + + pendingStateReadback = null; + if (currentMode == Mode.AUTO && stateReadbacks != null) { + stateReadbackIndex = (stateReadbackIndex + 1) % stateReadbacks.length; + ReadbackSlot slot = stateReadbacks[stateReadbackIndex]; + graphicsUseWaiter.await(slot.graphicsUse); + if (slot.valid && slot.resetSequence == resetSequence) { + slot.buffer.invalidate(); + completedState = ExposureStateData.read(MemoryUtil.memByteBuffer( + slot.buffer.mapped, ExposureStateData.BYTE_SIZE).order(ByteOrder.nativeOrder())); + } + pendingStateReadback = slot; + } + + // On a reset frame the previous world's exposure is a poor storage-scale prediction. Unity is + // neutral and the resolve removes it exactly; subsequent frames resume last-frame prediction. + framePreExposure = reset ? 1.0f : computePreExposure(); + } + + /** Request a GPU-side history reset on the next auto-exposure frame. */ + public void requestReset() { + resetRequested = true; + } + + /** + * The scalar raygen multiplies into scene radiance before the fp16 write, so stored values sit near + * {@code key} at any absolute + * scene brightness instead of spanning the ~26 EV that physical units require. + * + *

Correctness does not depend on this being current — the display pass divides by + * exactly the same latched value, so any pre-exposure cancels algebraically. Staleness only + * affects how well-centred the stored values are, which is why last frame's readback is fine and + * no fence is needed. 1.0 disables the mechanism. + */ + public float preExposure() { + return framePreExposure; + } + + private float computePreExposure() { + if (!CausticaConfig.Rt.Exposure.PRE_EXPOSURE.value()) { + return 1.0f; + } + // Manual mode has a known fixed absolute exposure, so pre-exposing by it makes the residual + // exactly 1.0 -- the best-centred choice available, and it needs no readback. + if (mode() != Mode.AUTO) { + return manualExposureScale(); + } + if (completedState == null) { + return 1.0f; + } + // Deliberately NOT Exposure.clampScale: its 1e-4 floor is a bound on the artistic exposure + // multiplier, and physical units put noon at ~3e-5 absolute, which that floor would + // 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; + } + + private ByteBuffer stateDataBuffer() { + return MemoryUtil.memByteBuffer(state.mapped, ExposureStateData.BYTE_SIZE).order(ByteOrder.nativeOrder()); + } + + private ExposureStateData readState() { + return ExposureStateData.read(stateDataBuffer()); + } + + record AutoConfig(float key, float minEv, float maxEv, float adaptDarken, float adaptBrighten, + float evBias, + float lowPercentile, float highPercentile, int stride, + float centerWeightSigma, float centerWeightFloor, float skyWeightCap, + float emissiveWeightCap, + ExposureCurve curve, float preExposure, int resetSequence) { + /** + * Offset taking the resolve's {@code log2(metered stored luminance)} to EV100. The metered + * buffer holds {@code L * preExposure}, so the pre-exposure has to come back out before the + * unit convention's offset applies. + */ + float evOffset() { + return RtSceneUnits.EV100_OFFSET - (float) (Math.log(Math.max(preExposure, 1.0e-12f)) / Math.log(2.0)); + } + } + + private ExposureCurve curveConfig() { + String spec = CausticaConfig.Rt.Exposure.curve(); + if (cachedCurve != null && Objects.equals(cachedCurveSpec, spec)) { + return cachedCurve; + } + ExposureCurve parsed; + try { + parsed = parseCurve(spec); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Invalid exposure curve in look package '" + + RtLookPackage.current().id() + "': " + spec, e); + } + cachedCurveSpec = spec; + cachedCurve = parsed; + return parsed; + } + + static ExposureCurve parseCurve(String spec) { + if (spec == null) { + throw new IllegalArgumentException("curve is null"); + } + if ("full".equalsIgnoreCase(spec.trim())) { + return new ExposureCurve(-6.0f, 0.0f, -3.0f, 0.0f, 0.0f, 0.0f, 4.0f, 0.0f); + } + String[] encodedPoints = spec.split(","); + if (encodedPoints.length != 4) { + throw new IllegalArgumentException("expected exactly four scene:compensation points"); + } + float[] scene = new float[4]; + float[] compensation = new float[4]; + for (int i = 0; i < encodedPoints.length; i++) { + String[] pair = encodedPoints[i].trim().split(":", -1); + if (pair.length != 2) { + throw new IllegalArgumentException("point " + (i + 1) + " is not scene:compensation"); + } + try { + scene[i] = Float.parseFloat(pair[0].trim()); + compensation[i] = Float.parseFloat(pair[1].trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("point " + (i + 1) + " contains a non-number", e); + } + if (!Float.isFinite(scene[i]) || !Float.isFinite(compensation[i])) { + throw new IllegalArgumentException("point " + (i + 1) + " is not finite"); + } + } + // Four elements: insertion sort avoids a temporary point-object list. + for (int i = 1; i < 4; i++) { + float sceneValue = scene[i]; + float compensationValue = compensation[i]; + int j = i - 1; + while (j >= 0 && scene[j] > sceneValue) { + scene[j + 1] = scene[j]; + compensation[j + 1] = compensation[j]; + j--; + } + scene[j + 1] = sceneValue; + compensation[j + 1] = compensationValue; + } + for (int i = 1; i < 4; i++) { + if (scene[i] - scene[i - 1] < 1.0e-4f) { + throw new IllegalArgumentException("scene EV points must be distinct"); + } + } + return new ExposureCurve(scene[0], compensation[0], scene[1], compensation[1], + scene[2], compensation[2], scene[3], compensation[3]); + } + + record ExposureCurve(float scene0, float compensation0, float scene1, float compensation1, + float scene2, float compensation2, float scene3, float compensation3) { + float compensationAt(float sceneEv) { + if (sceneEv <= scene0) { + return compensation0; + } + if (sceneEv < scene1) { + return interpolate(sceneEv, scene0, compensation0, scene1, compensation1); + } + if (sceneEv < scene2) { + return interpolate(sceneEv, scene1, compensation1, scene2, compensation2); + } + if (sceneEv < scene3) { + return interpolate(sceneEv, scene2, compensation2, scene3, compensation3); + } + return compensation3; + } + + float effectiveSlopeAt(float sceneEv) { + if (sceneEv <= scene0 || sceneEv >= scene3) { + return 1.0f; + } + if (sceneEv < scene1) { + return 1.0f - slope(scene0, compensation0, scene1, compensation1); + } + if (sceneEv < scene2) { + return 1.0f - slope(scene1, compensation1, scene2, compensation2); + } + return 1.0f - slope(scene2, compensation2, scene3, compensation3); + } + + private static float interpolate(float x, float x0, float y0, float x1, float y1) { + float t = (x - x0) / (x1 - x0); + return y0 + t * (y1 - y0); + } + + private static float slope(float x0, float y0, float x1, float y1) { + return (y1 - y0) / (x1 - x0); + } } private enum Mode { 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 f49b8c15..79cc5852 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java @@ -26,12 +26,15 @@ import dev.comfyfluffy.caustica.rt.RtContext; import dev.comfyfluffy.caustica.rt.RtDebugLabels; import dev.comfyfluffy.caustica.rt.accel.RtBuffer; +import dev.comfyfluffy.caustica.rt.gen.ExposureHistPushData; +import dev.comfyfluffy.caustica.rt.gen.ExposureResolvePushData; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; /** Compute pipelines for histogram auto-exposure over the RT HDR trace output. */ final class RtExposurePipeline { - private static final String SHADER_DIR = "/caustica/rt/"; + private static final String SHADER_DIR = "/caustica/shaders/pipelines/"; private final RtContext ctx; private final long histDescriptorSetLayout; @@ -46,6 +49,8 @@ final class RtExposurePipeline { private final long resolvePipeline; private long boundColorView; + private long boundDepthView; + private long boundAlbedoView; private long boundHistogramBufferForHist; private long boundHistogramBufferForResolve; private long boundExposureView; @@ -75,34 +80,38 @@ static RtExposurePipeline create(RtContext ctx) { try (MemoryStack stack = MemoryStack.stackPush()) { LongBuffer p = stack.mallocLong(1); - VkDescriptorSetLayoutBinding.Buffer histBinds = VkDescriptorSetLayoutBinding.calloc(2, stack); - histBinds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + VkDescriptorSetLayoutBinding.Buffer histBinds = VkDescriptorSetLayoutBinding.calloc(EXPOSURE_HIST_BINDING_COUNT, stack); + histBinds.get(EXPOSURE_HIST_COLOR).binding(EXPOSURE_HIST_COLOR).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - histBinds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) + histBinds.get(EXPOSURE_HIST_BINS).binding(EXPOSURE_HIST_BINS).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + histBinds.get(EXPOSURE_HIST_DEPTH).binding(EXPOSURE_HIST_DEPTH).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + histBinds.get(EXPOSURE_HIST_ALBEDO).binding(EXPOSURE_HIST_ALBEDO).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); VkDescriptorSetLayoutCreateInfo histDslci = VkDescriptorSetLayoutCreateInfo.calloc(stack) .sType$Default().pBindings(histBinds); check(VK10.vkCreateDescriptorSetLayout(vk, histDslci, null, p), "vkCreateDescriptorSetLayout(rt exposure hist)"); long histDsl = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, histDsl, "exposure histogram descriptor set layout"); - long histPool = createPool(vk, stack, 1, 1, "hist"); + long histPool = createPool(vk, stack, 3, 1, "hist"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_POOL, histPool, "exposure histogram descriptor pool"); long histSet = allocateSet(vk, stack, histPool, histDsl, "hist"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, histSet, "exposure histogram descriptor set"); - long histLayout = createPipelineLayout(vk, stack, histDsl, 0, "hist"); + long histLayout = createPipelineLayout(vk, stack, histDsl, ExposureHistPushData.BYTE_SIZE, "hist"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, histLayout, "exposure histogram pipeline layout"); - long histModule = loadModule(vk, stack, "exposure_hist.comp.spv"); + long histModule = loadModule(vk, stack, "exposure_hist/main.comp.spv"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, histModule, "exposure histogram shader module"); long histPipeline = createComputePipeline(vk, stack, histLayout, histModule, "hist"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, histPipeline, "exposure histogram pipeline"); VK10.vkDestroyShaderModule(vk, histModule, null); - VkDescriptorSetLayoutBinding.Buffer resolveBinds = VkDescriptorSetLayoutBinding.calloc(3, stack); - resolveBinds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) + VkDescriptorSetLayoutBinding.Buffer resolveBinds = VkDescriptorSetLayoutBinding.calloc(EXPOSURE_RESOLVE_BINDING_COUNT, stack); + resolveBinds.get(EXPOSURE_RESOLVE_HIST_BINS).binding(EXPOSURE_RESOLVE_HIST_BINS).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - resolveBinds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + resolveBinds.get(EXPOSURE_RESOLVE_IMAGE).binding(EXPOSURE_RESOLVE_IMAGE).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - resolveBinds.get(2).binding(2).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) + resolveBinds.get(EXPOSURE_RESOLVE_STATE).binding(EXPOSURE_RESOLVE_STATE).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); VkDescriptorSetLayoutCreateInfo resolveDslci = VkDescriptorSetLayoutCreateInfo.calloc(stack) .sType$Default().pBindings(resolveBinds); @@ -113,9 +122,9 @@ static RtExposurePipeline create(RtContext ctx) { RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_POOL, resolvePool, "exposure resolve descriptor pool"); long resolveSet = allocateSet(vk, stack, resolvePool, resolveDsl, "resolve"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, resolveSet, "exposure resolve descriptor set"); - long resolveLayout = createPipelineLayout(vk, stack, resolveDsl, 32, "resolve"); + long resolveLayout = createPipelineLayout(vk, stack, resolveDsl, ExposureResolvePushData.BYTE_SIZE, "resolve"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, resolveLayout, "exposure resolve pipeline layout"); - long resolveModule = loadModule(vk, stack, "exposure_resolve.comp.spv"); + long resolveModule = loadModule(vk, stack, "exposure_resolve/main.comp.spv"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, resolveModule, "exposure resolve shader module"); long resolvePipeline = createComputePipeline(vk, stack, resolveLayout, resolveModule, "resolve"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, resolvePipeline, "exposure resolve pipeline"); @@ -126,21 +135,33 @@ static RtExposurePipeline create(RtContext ctx) { } } - void setResources(long colorView, RtBuffer histogram, long exposureView, RtBuffer state) { - if (boundColorView != colorView || boundHistogramBufferForHist != histogram.handle) { + void setResources(long colorView, long depthView, long albedoView, + RtBuffer histogram, long exposureView, RtBuffer state) { + if (boundColorView != colorView || boundDepthView != depthView || boundAlbedoView != albedoView + || boundHistogramBufferForHist != histogram.handle) { try (MemoryStack stack = MemoryStack.stackPush()) { VkDescriptorImageInfo.Buffer colorInfo = VkDescriptorImageInfo.calloc(1, stack); colorInfo.get(0).imageView(colorView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkDescriptorBufferInfo.Buffer histInfo = VkDescriptorBufferInfo.calloc(1, stack); histInfo.get(0).buffer(histogram.handle).offset(0).range(histogram.size); - VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(2, stack); - writes.get(0).sType$Default().dstSet(histDescriptorSet).dstBinding(0) + VkDescriptorImageInfo.Buffer depthInfo = VkDescriptorImageInfo.calloc(1, stack); + depthInfo.get(0).imageView(depthView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer albedoInfo = VkDescriptorImageInfo.calloc(1, stack); + albedoInfo.get(0).imageView(albedoView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(EXPOSURE_HIST_BINDING_COUNT, stack); + writes.get(EXPOSURE_HIST_COLOR).sType$Default().dstSet(histDescriptorSet).dstBinding(EXPOSURE_HIST_COLOR) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(colorInfo); - writes.get(1).sType$Default().dstSet(histDescriptorSet).dstBinding(1) + writes.get(EXPOSURE_HIST_BINS).sType$Default().dstSet(histDescriptorSet).dstBinding(EXPOSURE_HIST_BINS) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER).pBufferInfo(histInfo); + writes.get(EXPOSURE_HIST_DEPTH).sType$Default().dstSet(histDescriptorSet).dstBinding(EXPOSURE_HIST_DEPTH) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(depthInfo); + writes.get(EXPOSURE_HIST_ALBEDO).sType$Default().dstSet(histDescriptorSet).dstBinding(EXPOSURE_HIST_ALBEDO) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(albedoInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } boundColorView = colorView; + boundDepthView = depthView; + boundAlbedoView = albedoView; boundHistogramBufferForHist = histogram.handle; } if (boundHistogramBufferForResolve != histogram.handle || boundExposureView != exposureView @@ -152,12 +173,12 @@ void setResources(long colorView, RtBuffer histogram, long exposureView, RtBuffe exposureInfo.get(0).imageView(exposureView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkDescriptorBufferInfo.Buffer stateInfo = VkDescriptorBufferInfo.calloc(1, stack); stateInfo.get(0).buffer(state.handle).offset(0).range(state.size); - VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(3, stack); - writes.get(0).sType$Default().dstSet(resolveDescriptorSet).dstBinding(0) + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(EXPOSURE_RESOLVE_BINDING_COUNT, stack); + writes.get(EXPOSURE_RESOLVE_HIST_BINS).sType$Default().dstSet(resolveDescriptorSet).dstBinding(EXPOSURE_RESOLVE_HIST_BINS) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER).pBufferInfo(histInfo); - writes.get(1).sType$Default().dstSet(resolveDescriptorSet).dstBinding(1) + writes.get(EXPOSURE_RESOLVE_IMAGE).sType$Default().dstSet(resolveDescriptorSet).dstBinding(EXPOSURE_RESOLVE_IMAGE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(exposureInfo); - writes.get(2).sType$Default().dstSet(resolveDescriptorSet).dstBinding(2) + writes.get(EXPOSURE_RESOLVE_STATE).sType$Default().dstSet(resolveDescriptorSet).dstBinding(EXPOSURE_RESOLVE_STATE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER).pBufferInfo(stateInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } @@ -167,29 +188,38 @@ void setResources(long colorView, RtBuffer histogram, long exposureView, RtBuffe } } - void dispatchHistogram(org.lwjgl.vulkan.VkCommandBuffer cmd, int width, int height) { + void dispatchHistogram(org.lwjgl.vulkan.VkCommandBuffer cmd, int width, int height, + RtExposure.AutoConfig config) { try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure histogram")) { VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, histPipeline); VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, histPipelineLayout, 0, stack.longs(histDescriptorSet), null); - VK10.vkCmdDispatch(cmd, (width + 15) / 16, (height + 15) / 16, 1); + ByteBuffer push = stack.malloc(ExposureHistPushData.BYTE_SIZE); + new ExposureHistPushData(config.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); } } - void dispatchResolve(org.lwjgl.vulkan.VkCommandBuffer cmd, int pixelCount, RtExposure.AutoConfig config, float frameTimeSeconds) { + 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); VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, resolvePipelineLayout, 0, stack.longs(resolveDescriptorSet), null); - ByteBuffer push = stack.malloc(32); - push.putInt(0, pixelCount); - push.putFloat(4, config.key()); - push.putFloat(8, config.minEv()); - push.putFloat(12, config.maxEv()); - push.putFloat(16, config.adaptUp()); - push.putFloat(20, config.adaptDown()); - push.putFloat(24, frameTimeSeconds); - push.putFloat(28, config.evBias()); + ByteBuffer push = stack.malloc(ExposureResolvePushData.BYTE_SIZE); + RtExposure.ExposureCurve curve = config.curve(); + new ExposureResolvePushData( + config.key(), config.minEv(), config.maxEv(), config.adaptDarken(), config.adaptBrighten(), + frameTimeSeconds, config.evBias(), config.lowPercentile(), config.highPercentile(), + config.skyWeightCap(), curve.scene0(), curve.compensation0(), curve.scene1(), + curve.compensation1(), curve.scene2(), curve.compensation2(), curve.scene3(), + curve.compensation3(), config.emissiveWeightCap(), config.evOffset(), config.preExposure(), + config.resetSequence() + ).write(push); VK10.vkCmdPushConstants(cmd, resolvePipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); VK10.vkCmdDispatch(cmd, 1, 1, 1); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtHdrCompositePipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtHdrCompositePipeline.java index ace15c60..f81267f4 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtHdrCompositePipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtHdrCompositePipeline.java @@ -27,6 +27,7 @@ import dev.comfyfluffy.caustica.rt.RtDebugLabels; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; /** * Compute pass that composites the vanilla UI overlay (premultiplied sRGB rgba8, sampled) over the @@ -35,8 +36,8 @@ * blitted to the PQ swapchain. */ public final class RtHdrCompositePipeline { - private static final String SHADER_DIR = "/caustica/rt/"; - private static final int PUSH_BYTES = Float.BYTES; // float paperWhiteNits + private static final String SHADER_DIR = "/caustica/shaders/pipelines/hdr_composite/"; + private static final int PUSH_BYTES = Float.BYTES; // float uiNits private final RtContext ctx; private final long descriptorSetLayout; @@ -61,10 +62,10 @@ private RtHdrCompositePipeline(RtContext ctx, long dsl, long pool, long set, lon public static RtHdrCompositePipeline create(RtContext ctx) { VkDevice vk = ctx.vk(); try (MemoryStack stack = MemoryStack.stackPush()) { - VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(2, stack); - binds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(PRESENT_BINDING_COUNT, stack); + binds.get(PRESENT_OUTPUT).binding(PRESENT_OUTPUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - binds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + binds.get(PRESENT_SOURCE).binding(PRESENT_SOURCE).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); @@ -96,7 +97,7 @@ public static RtHdrCompositePipeline create(RtContext ctx) { long layout = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, "hdr ui composite pipeline layout"); - long module = loadModule(vk, stack, "hdr_ui_composite.comp.spv"); + long module = loadModule(vk, stack, "main.comp.spv"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, "hdr ui composite shader module"); VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default() .stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main")); @@ -123,10 +124,10 @@ public void setImages(long hdrImageView, long overlayImageView, long sampler) { VkDescriptorImageInfo.Buffer overlayInfo = VkDescriptorImageInfo.calloc(1, stack); overlayInfo.get(0).imageView(overlayImageView).sampler(sampler).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); - VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(2, stack); - writes.get(0).sType$Default().dstSet(descriptorSet).dstBinding(0) + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(PRESENT_BINDING_COUNT, stack); + writes.get(PRESENT_OUTPUT).sType$Default().dstSet(descriptorSet).dstBinding(PRESENT_OUTPUT) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(hdrInfo); - writes.get(1).sType$Default().dstSet(descriptorSet).dstBinding(1) + writes.get(PRESENT_SOURCE).sType$Default().dstSet(descriptorSet).dstBinding(PRESENT_SOURCE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(overlayInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } @@ -135,12 +136,12 @@ public void setImages(long hdrImageView, long overlayImageView, long sampler) { boundSampler = sampler; } - public void dispatch(VkCommandBuffer cmd, int width, int height, float paperWhiteNits) { + public void dispatch(VkCommandBuffer cmd, int width, int height, float uiNits) { try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "hdr ui composite")) { 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(PUSH_BYTES); - push.putFloat(0, paperWhiteNits); + push.putFloat(0, uiNits); 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/RtPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java index f3946bc8..9694ffe6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java @@ -36,6 +36,7 @@ import dev.comfyfluffy.caustica.rt.accel.RtBuffer; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; import static org.lwjgl.vulkan.EXTOpacityMicromap.VK_PIPELINE_CREATE_RAY_TRACING_OPACITY_MICROMAP_BIT_EXT; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR; import static org.lwjgl.vulkan.KHRAccelerationStructure.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET_ACCELERATION_STRUCTURE_KHR; @@ -61,13 +62,7 @@ * supported by passing an array; {@code traceRayEXT}'s {@code missIndex} selects among them. */ public final class RtPipeline { - private static final String SHADER_DIR = "/caustica/rt/"; - /** Set 1: entity albedo plus three independently indexed canonical material-page arrays. */ - private static final int BINDLESS_BINDINGS = 4; - private static final int ENTITY_ALBEDO_BINDING = 0; - private static final int MATERIAL_SURFACE0_BINDING = 1; - private static final int MATERIAL_NORMAL_AO_BINDING = 2; - private static final int MATERIAL_SURFACE1_BINDING = 3; + private static final String SHADER_DIR = "/caustica/shaders/pipelines/world/"; // A ring of descriptor sets: setTlas waits for the selected slot's exact prior graphics use before // rewriting it. Ring depth is only a performance choice that avoids routine host waits. private static final int RING = 6; @@ -87,18 +82,18 @@ public final class RtPipeline { private final int hitGroupCount; private final int pushConstantSize; private final int pushConstantStages; - private final int firstExtraBinding; // Optional second descriptor set (set 1) holding entity albedo and canonical material-page arrays. // Only entity albedo is update-after-bind: its RenderType→slot registry is append-only. Material // pages are populated once at the resource-epoch boundary. 0 when created without bindless textures. private final long bindlessLayout; private final long bindlessPool; private final long bindlessSet; - private final int skyAtlasBinding; private boolean destroyed; - private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, RtBuffer sbt, long stride, int raygenCount, int missCount, int hitGroupCount, int pushConstantSize, int pushConstantStages, int firstExtraBinding, - long bindlessLayout, long bindlessPool, long bindlessSet, int skyAtlasBinding) { + private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, + RtBuffer sbt, long stride, int raygenCount, int missCount, int hitGroupCount, + int pushConstantSize, int pushConstantStages, long bindlessLayout, + long bindlessPool, long bindlessSet) { this.ctx = ctx; this.descriptorSetLayout = dsl; this.descriptorPool = pool; @@ -117,32 +112,29 @@ private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, this.hitGroupCount = hitGroupCount; this.pushConstantSize = pushConstantSize; this.pushConstantStages = pushConstantStages; - this.firstExtraBinding = firstExtraBinding; this.bindlessLayout = bindlessLayout; this.bindlessPool = bindlessPool; this.bindlessSet = bindlessSet; - this.skyAtlasBinding = skyAtlasBinding; } /** * Builds the RT pipeline. {@code rahit} (nullable) adds any-hit-capable triangle hit records. With the * world pipeline, the hit SBT region is laid out to match {@link RtAccel}'s terrain bucket/ray-type - * constants: radiance records first, shadow records second, then entity records. {@code extraStorageImages} - * adds that many raygen-visible storage images at bindings 3.. (the DLSS-RR guide buffers); - * write them with {@link #setExtraStorageImage}. + * constants: radiance records first, shadow records second, then entity records. The fixed world + * descriptor layout is declared in {@code shaders/rt_bindings.slang}. * *

{@code rgen} may hold several raygen shaders. They share this pipeline's descriptor set, miss * table and hit table; {@link #trace(VkCommandBuffer, int, int, ByteBuffer, int)} picks one per * dispatch by index. */ - public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, String rchit, String rahit, int pushConstantSize, boolean withBlockAlbedoAtlas, int extraStorageImages, int bindlessTextures, boolean skyAtlas) { + public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, String rchit, + String rahit, int pushConstantSize, int bindlessTextures) { VkDevice vk = ctx.vk(); boolean hasAhit = rahit != null; String label = "world RT pipeline"; if (bindlessTextures > 0) { long requiredCombinedSamplers = Math.addExact( - Math.multiplyExact((long) bindlessTextures, BINDLESS_BINDINGS), - withBlockAlbedoAtlas ? 1L : 0L); + Math.multiplyExact((long) bindlessTextures, WORLD_BINDLESS_COUNT), 1L); long deviceLimit = ctx.updateAfterBindCombinedImageSamplerLimit(); if (requiredCombinedSamplers > deviceLimit) { throw new UnsupportedOperationException("Configured bindless texture capacity " + bindlessTextures @@ -151,46 +143,43 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St } } try (MemoryStack stack = MemoryStack.stackPush()) { - int firstExtraBinding = withBlockAlbedoAtlas ? 3 : 2; - int materialBase = firstExtraBinding + extraStorageImages; - // Sky rewrite: the vanilla celestials atlas (sun + moon phases), sampled by world.rmiss to - // draw the sun/moon discs. Canonical material pages live in the bindless set, not set 0. - int skyBinding = skyAtlas ? materialBase : -1; - int skySamplers = skyAtlas ? 1 : 0; - int bindingCount = firstExtraBinding + extraStorageImages + skySamplers; - VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(bindingCount, stack); - binds.get(0).binding(0).descriptorType(VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR) + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc( + WORLD_SET_BINDING_COUNT, stack); + binds.get(WORLD_TLAS).binding(WORLD_TLAS).descriptorType(VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); - binds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + binds.get(WORLD_OUTPUT).binding(WORLD_OUTPUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); - if (withBlockAlbedoAtlas) { - int atlasStages = VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR | (hasAhit ? VK_SHADER_STAGE_ANY_HIT_BIT_KHR : 0); - binds.get(2).binding(2).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) - .descriptorCount(1).stageFlags(atlasStages); - } - for (int e = 0; e < extraStorageImages; e++) { - binds.get(firstExtraBinding + e).binding(firstExtraBinding + e).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + int atlasStages = VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR + | (hasAhit ? VK_SHADER_STAGE_ANY_HIT_BIT_KHR : 0); + 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++) { + binds.get(binding).binding(binding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); } - if (skyAtlas) { - binds.get(skyBinding).binding(skyBinding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) - .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR); - } + binds.get(WORLD_CELESTIALS).binding(WORLD_CELESTIALS) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR); + binds.get(WORLD_SKY_VIEW).binding(WORLD_SKY_VIEW) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR); + binds.get(WORLD_TRANSMITTANCE).binding(WORLD_TRANSMITTANCE) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1) + .stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR | VK_SHADER_STAGE_RAYGEN_BIT_KHR); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); LongBuffer p = stack.mallocLong(1); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout"); long dsl = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, label + " descriptor set layout"); - int combinedSamplers = (withBlockAlbedoAtlas ? 1 : 0) + skySamplers; - int poolSizeCount = 2 + (combinedSamplers > 0 ? 1 : 0); - VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(poolSizeCount, stack); + VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(3, stack); poolSizes.get(0).type(VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR).descriptorCount(RING); - // output image (binding 1) + the extra guide images share the storage-image type. - poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(RING * (1 + extraStorageImages)); - if (combinedSamplers > 0) { - poolSizes.get(2).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(RING * combinedSamplers); - } + poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(RING * WORLD_SET_STORAGE_IMAGE_COUNT); + poolSizes.get(2).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(RING * WORLD_SET_SAMPLER_COUNT); VkDescriptorPoolCreateInfo dpci = VkDescriptorPoolCreateInfo.calloc(stack).sType$Default().maxSets(RING).pPoolSizes(poolSizes); check(VK10.vkCreateDescriptorPool(vk, dpci, null, p), "vkCreateDescriptorPool"); long pool = p.get(0); @@ -214,16 +203,16 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St if (bindlessTextures > 0) { // Entity albedo and canonical material pages have independent index spaces. All arrays // use the configured capacity here; material pages occupy compact indices from zero. - int nb = BINDLESS_BINDINGS; + int nb = WORLD_BINDLESS_COUNT; VkDescriptorSetLayoutBinding.Buffer bl = VkDescriptorSetLayoutBinding.calloc(nb, stack); java.nio.IntBuffer bindFlags = stack.mallocInt(nb); for (int b = 0; b < nb; b++) { int stages = VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR; - if (b == ENTITY_ALBEDO_BINDING && hasAhit) stages |= VK_SHADER_STAGE_ANY_HIT_BIT_KHR; + if (b == WORLD_ENTITY_ALBEDO && hasAhit) stages |= VK_SHADER_STAGE_ANY_HIT_BIT_KHR; bl.get(b).binding(b).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(bindlessTextures).stageFlags(stages); int flags = VK12.VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT; - if (b == ENTITY_ALBEDO_BINDING) flags |= VK12.VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT; + if (b == WORLD_ENTITY_ALBEDO) flags |= VK12.VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT; bindFlags.put(b, flags); } VkDescriptorSetLayoutBindingFlagsCreateInfo bf = VkDescriptorSetLayoutBindingFlagsCreateInfo.calloc(stack).sType$Default() @@ -366,8 +355,9 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St MemoryUtil.memCopy(MemoryUtil.memAddress(handles) + (long) g * handleSize, sbt.mapped + g * stride, handleSize); } sbt.flush(); - return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, raygenCount, missCount, hitGroupCount, pushConstantSize, pcStages, firstExtraBinding, - bindlessLayout, bindlessPool, bindlessSet, skyBinding); + return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, + raygenCount, missCount, hitGroupCount, pushConstantSize, pcStages, + bindlessLayout, bindlessPool, bindlessSet); } } @@ -394,7 +384,8 @@ public void setTlas(long tlas, RtGpuExecutor.GraphicsUse graphicsUse, VkWriteDescriptorSetAccelerationStructureKHR asWrite = VkWriteDescriptorSetAccelerationStructureKHR.calloc(stack) .sType(VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET_ACCELERATION_STRUCTURE_KHR).pAccelerationStructures(stack.longs(tlas)); VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(1, stack); - write.get(0).sType$Default().pNext(asWrite.address()).dstSet(descriptorSets[currentSet]).dstBinding(0) + write.get(0).sType$Default().pNext(asWrite.address()).dstSet(descriptorSets[currentSet]) + .dstBinding(WORLD_TLAS) .descriptorCount(1).descriptorType(VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR); VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); } @@ -408,21 +399,24 @@ public void setStorageImage(long imageView) { imgInfo.get(0).imageView(imageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(RING, stack); for (int i = 0; i < RING; i++) { - write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(1) + write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(WORLD_OUTPUT) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(imgInfo); } VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); } } - /** Write an extra storage image (DLSS-RR guide buffer) into binding {@code firstExtraBinding + slot} across every ring slot. */ + /** Write one DLSS-RR guide image into its canonical world binding across every ring slot. */ public void setExtraStorageImage(int slot, long imageView) { + if (slot < 0 || slot >= WORLD_GUIDE_COUNT) { + throw new IllegalArgumentException("Guide slot out of range: " + slot); + } try (MemoryStack stack = MemoryStack.stackPush()) { VkDescriptorImageInfo.Buffer imgInfo = VkDescriptorImageInfo.calloc(1, stack); imgInfo.get(0).imageView(imageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(RING, stack); for (int i = 0; i < RING; i++) { - write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(firstExtraBinding + slot) + write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(WORLD_G_NORMAL + slot) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(imgInfo); } VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); @@ -436,7 +430,7 @@ public void setBlockAlbedoAtlas(long imageView, long sampler) { info.get(0).sampler(sampler).imageView(imageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(RING, stack); for (int i = 0; i < RING; i++) { - write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(2) + write.get(i).sType$Default().dstSet(descriptorSets[i]).dstBinding(WORLD_BLOCK_ALBEDO) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(info); } VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); @@ -445,17 +439,20 @@ public void setBlockAlbedoAtlas(long imageView, long sampler) { /** Bind the vanilla celestials atlas (sun + moon phases), sampled by world.rmiss for the discs. */ public void setSkyAtlas(long imageView, long sampler) { - writeAtlasBinding(skyAtlasBinding, imageView, sampler); + writeAtlasBinding(WORLD_CELESTIALS, imageView, sampler); } public boolean hasSkyAtlas() { - return skyAtlasBinding >= 0; + return true; + } + + /** 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); + writeAtlasBinding(WORLD_TRANSMITTANCE, transmittanceImageView, sampler); } private void writeAtlasBinding(int binding, long imageView, long sampler) { - if (binding < 0) { - return; - } try (MemoryStack stack = MemoryStack.stackPush()) { VkDescriptorImageInfo.Buffer info = VkDescriptorImageInfo.calloc(1, stack); info.get(0).sampler(sampler).imageView(imageView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); @@ -470,15 +467,15 @@ private void writeAtlasBinding(int binding, long imageView, long sampler) { /** Append or initialize one entity-albedo slot. Existing slots never change while frames are in flight. */ public void setEntityAlbedoTexture(int slot, long imageView, long sampler) { - setBindlessTexture(ENTITY_ALBEDO_BINDING, slot, imageView, sampler); + setBindlessTexture(WORLD_ENTITY_ALBEDO, slot, imageView, sampler); } /** Bind one compact canonical page bundle at a resource-epoch boundary. */ public void setMaterialPage(int page, long surface0View, long normalAoView, long surface1View, long sampler) { - setBindlessTexture(MATERIAL_SURFACE0_BINDING, page, surface0View, sampler); - setBindlessTexture(MATERIAL_NORMAL_AO_BINDING, page, normalAoView, sampler); - setBindlessTexture(MATERIAL_SURFACE1_BINDING, page, surface1View, sampler); + setBindlessTexture(WORLD_MATERIAL_SURFACE0, page, surface0View, sampler); + setBindlessTexture(WORLD_MATERIAL_NORMAL_AO, page, normalAoView, sampler); + setBindlessTexture(WORLD_MATERIAL_SURFACE1, page, surface1View, sampler); } private void setBindlessTexture(int binding, int slot, long imageView, long sampler) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSdrPresentPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSdrPresentPipeline.java index f1c45eca..bf242bdd 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSdrPresentPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSdrPresentPipeline.java @@ -27,6 +27,7 @@ import dev.comfyfluffy.caustica.rt.RtDebugLabels; import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; /** * Compute pass that converts Minecraft's SDR main target (rgba8, sRGB-encoded, sampled) to a PQ-encoded @@ -35,8 +36,8 @@ * descriptor shape as {@link RtHdrCompositePipeline}: binding 0 = storage out, binding 1 = sampled SDR in. */ public final class RtSdrPresentPipeline { - private static final String SHADER_DIR = "/caustica/rt/"; - private static final int PUSH_BYTES = Float.BYTES; // float paperWhiteNits + private static final String SHADER_DIR = "/caustica/shaders/pipelines/sdr_present/"; + private static final int PUSH_BYTES = Float.BYTES; // float uiNits private final RtContext ctx; private final long descriptorSetLayout; @@ -61,10 +62,10 @@ private RtSdrPresentPipeline(RtContext ctx, long dsl, long pool, long set, long public static RtSdrPresentPipeline create(RtContext ctx) { VkDevice vk = ctx.vk(); try (MemoryStack stack = MemoryStack.stackPush()) { - VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(2, stack); - binds.get(0).binding(0).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(PRESENT_BINDING_COUNT, stack); + binds.get(PRESENT_OUTPUT).binding(PRESENT_OUTPUT).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); - binds.get(1).binding(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + binds.get(PRESENT_SOURCE).binding(PRESENT_SOURCE).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); @@ -96,7 +97,7 @@ public static RtSdrPresentPipeline create(RtContext ctx) { long layout = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, "sdr present pipeline layout"); - long module = loadModule(vk, stack, "sdr_present.comp.spv"); + long module = loadModule(vk, stack, "main.comp.spv"); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, "sdr present shader module"); VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default() .stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main")); @@ -123,10 +124,10 @@ public void setImages(long outImageView, long sdrImageView, long sampler) { VkDescriptorImageInfo.Buffer sdrInfo = VkDescriptorImageInfo.calloc(1, stack); sdrInfo.get(0).imageView(sdrImageView).sampler(sampler).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); - VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(2, stack); - writes.get(0).sType$Default().dstSet(descriptorSet).dstBinding(0) + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(PRESENT_BINDING_COUNT, stack); + writes.get(PRESENT_OUTPUT).sType$Default().dstSet(descriptorSet).dstBinding(PRESENT_OUTPUT) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(outInfo); - writes.get(1).sType$Default().dstSet(descriptorSet).dstBinding(1) + writes.get(PRESENT_SOURCE).sType$Default().dstSet(descriptorSet).dstBinding(PRESENT_SOURCE) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).pImageInfo(sdrInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } @@ -135,12 +136,12 @@ public void setImages(long outImageView, long sdrImageView, long sampler) { boundSampler = sampler; } - public void dispatch(VkCommandBuffer cmd, int width, int height, float paperWhiteNits) { + public void dispatch(VkCommandBuffer cmd, int width, int height, float uiNits) { try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "sdr present")) { 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(PUSH_BYTES); - push.putFloat(0, paperWhiteNits); + push.putFloat(0, uiNits); 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/RtSkyLut.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSkyLut.java new file mode 100644 index 00000000..50132244 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSkyLut.java @@ -0,0 +1,320 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import com.mojang.blaze3d.vulkan.VulkanCommandEncoder; +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.accel.RtImage; +import dev.comfyfluffy.caustica.rt.gen.PushAddrData; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkComputePipelineCreateInfo; +import org.lwjgl.vulkan.VkDescriptorImageInfo; +import org.lwjgl.vulkan.VkDescriptorPoolCreateInfo; +import org.lwjgl.vulkan.VkDescriptorPoolSize; +import org.lwjgl.vulkan.VkDescriptorSetAllocateInfo; +import org.lwjgl.vulkan.VkDescriptorSetLayoutBinding; +import org.lwjgl.vulkan.VkDescriptorSetLayoutCreateInfo; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkPipelineLayoutCreateInfo; +import org.lwjgl.vulkan.VkPipelineShaderStageCreateInfo; +import org.lwjgl.vulkan.VkPushConstantRange; +import org.lwjgl.vulkan.VkSamplerCreateInfo; +import org.lwjgl.vulkan.VkShaderModuleCreateInfo; +import org.lwjgl.vulkan.VkWriteDescriptorSet; + +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 dev.comfyfluffy.caustica.rt.pipeline.RtBindings.*; + +/** + * The sky's three LUTs (Hillaire 2020) and the compute passes that bake them. See + * {@code shaders/pipelines/world/sky.slang} for the physics. + * + *

    + *
  • Transmittance 256x64 — point-to-space extinction by (altitude, cos zenith). Static.
  • + *
  • Multiple scattering 32x32 — the static closed series of second and higher scattering + * orders that lights twilight and the night sky.
  • + *
  • Sky view 192x216 — the dome itself, one 192x108 slice per celestial body, rebuilt every + * frame from the sun/moon angles in {@code WorldPush}.
  • + *
+ * + *

The two static LUTs are baked on the first frame rather than at construction: they read the ground + * albedo from the look package through the frame's {@code WorldPush} slot, so they need a frame's push + * buffer to exist. All three passes share one descriptor set and one pipeline layout — every pass + * declares all five bindings, and each uses the subset it needs. + * + *

The images are single-buffered. The bake and the trace that reads it run in the same submission with + * a barrier between, and the composite's end-of-frame barrier orders the next frame's overwrite against + * this frame's trace — the same argument that lets every other GPU-written per-frame image here be + * single-buffered. + */ +public final class RtSkyLut { + private static final String SHADER_DIR = "/caustica/shaders/pipelines/sky_lut/"; + // Keep in lock-step with the same-named constants in shaders/pipelines/world/sky.slang. + public static final int TRANSMITTANCE_WIDTH = 256; + public static final int TRANSMITTANCE_HEIGHT = 64; + public static final int MULTISCATTER_WIDTH = 32; + public static final int MULTISCATTER_HEIGHT = 32; + public static final int SKY_VIEW_WIDTH = 192; + public static final int SKY_VIEW_SLICE_HEIGHT = 108; + public static final int SKY_VIEW_BODY_COUNT = 2; + public static final int SKY_VIEW_HEIGHT = SKY_VIEW_SLICE_HEIGHT * SKY_VIEW_BODY_COUNT; + private static final int GROUP_SIZE = 8; + + private final RtContext ctx; + private final RtImage transmittance; + private final RtImage multiScatter; + private final RtImage skyView; + private final long sampler; + private final long descriptorSetLayout; + private final long descriptorPool; + private final long descriptorSet; + private final long pipelineLayout; + private final long transmittancePipeline; + private final long multiScatterPipeline; + private final long skyViewPipeline; + private boolean staticLutsBaked; + private boolean destroyed; + + private RtSkyLut(RtContext ctx, RtImage transmittance, RtImage multiScatter, RtImage skyView, + long sampler, long descriptorSetLayout, long descriptorPool, long descriptorSet, + long pipelineLayout, long transmittancePipeline, long multiScatterPipeline, + long skyViewPipeline) { + this.ctx = ctx; + this.transmittance = transmittance; + this.multiScatter = multiScatter; + this.skyView = skyView; + this.sampler = sampler; + this.descriptorSetLayout = descriptorSetLayout; + this.descriptorPool = descriptorPool; + this.descriptorSet = descriptorSet; + this.pipelineLayout = pipelineLayout; + this.transmittancePipeline = transmittancePipeline; + this.multiScatterPipeline = multiScatterPipeline; + this.skyViewPipeline = skyViewPipeline; + } + + public static RtSkyLut create(RtContext ctx) { + VkDevice vk = ctx.vk(); + RtImage transmittance = ctx.createStorageImage(TRANSMITTANCE_WIDTH, TRANSMITTANCE_HEIGHT, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "sky transmittance LUT"); + RtImage multiScatter = ctx.createStorageImage(MULTISCATTER_WIDTH, MULTISCATTER_HEIGHT, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "sky multiple-scattering LUT"); + RtImage skyView = ctx.createStorageImage(SKY_VIEW_WIDTH, SKY_VIEW_HEIGHT, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "sky view LUT"); + try (MemoryStack stack = MemoryStack.stackPush()) { + // CLAMP on both axes. The sky-view LUT's U axis is angle-from-the-light, which is folded on + // itself rather than wrapped, and its V axis stacks the two body slices — a REPEAT here would + // let the sun's rows bleed into the moon's at the seam. + VkSamplerCreateInfo samplerInfo = VkSamplerCreateInfo.calloc(stack).sType$Default() + .magFilter(VK10.VK_FILTER_LINEAR).minFilter(VK10.VK_FILTER_LINEAR) + .mipmapMode(VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST) + .addressModeU(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeV(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeW(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .minLod(0.0f).maxLod(0.0f); + LongBuffer handle = stack.mallocLong(1); + check(VK10.vkCreateSampler(vk, samplerInfo, null, handle), "vkCreateSampler(sky LUT)"); + long sampler = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SAMPLER, sampler, "sky LUT sampler"); + + VkDescriptorSetLayoutBinding.Buffer bindings = VkDescriptorSetLayoutBinding.calloc(SKY_LUT_BINDING_COUNT, stack); + for (int i = SKY_LUT_TRANSMITTANCE_IMAGE; i <= SKY_LUT_SKY_VIEW_IMAGE; i++) { + bindings.get(i).binding(i).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + } + for (int i = SKY_LUT_TRANSMITTANCE_SAMPLER; i < SKY_LUT_BINDING_COUNT; i++) { + bindings.get(i).binding(i).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + } + VkDescriptorSetLayoutCreateInfo layoutInfo = VkDescriptorSetLayoutCreateInfo.calloc(stack) + .sType$Default().pBindings(bindings); + check(VK10.vkCreateDescriptorSetLayout(vk, layoutInfo, null, handle), + "vkCreateDescriptorSetLayout(sky LUT)"); + long descriptorSetLayout = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, + descriptorSetLayout, "sky LUT descriptor set layout"); + + VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(2, stack); + poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(3); + poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(2); + VkDescriptorPoolCreateInfo poolInfo = VkDescriptorPoolCreateInfo.calloc(stack) + .sType$Default().maxSets(1).pPoolSizes(poolSizes); + check(VK10.vkCreateDescriptorPool(vk, poolInfo, null, handle), + "vkCreateDescriptorPool(sky LUT)"); + long descriptorPool = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_POOL, + descriptorPool, "sky LUT descriptor pool"); + + VkDescriptorSetAllocateInfo allocateInfo = VkDescriptorSetAllocateInfo.calloc(stack) + .sType$Default().descriptorPool(descriptorPool) + .pSetLayouts(stack.longs(descriptorSetLayout)); + LongBuffer setHandle = stack.mallocLong(1); + check(VK10.vkAllocateDescriptorSets(vk, allocateInfo, setHandle), + "vkAllocateDescriptorSets(sky LUT)"); + long descriptorSet = setHandle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET, + descriptorSet, "sky LUT descriptor set"); + + // Same inline address block the trace uses: each pass dereferences the frame's WorldPush + // through pcAddr.worldPushAddr exactly like the RT stages do. + VkPushConstantRange.Buffer pushRange = VkPushConstantRange.calloc(1, stack); + pushRange.get(0).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT) + .offset(0).size(PushAddrData.BYTE_SIZE); + VkPipelineLayoutCreateInfo pipelineLayoutInfo = VkPipelineLayoutCreateInfo.calloc(stack) + .sType$Default().pSetLayouts(stack.longs(descriptorSetLayout)) + .pPushConstantRanges(pushRange); + check(VK10.vkCreatePipelineLayout(vk, pipelineLayoutInfo, null, handle), + "vkCreatePipelineLayout(sky LUT)"); + long pipelineLayout = handle.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, + pipelineLayout, "sky LUT pipeline layout"); + + long transmittancePipeline = createComputePipeline(ctx, stack, pipelineLayout, + "transmittance.comp.spv", "sky transmittance pipeline"); + long multiScatterPipeline = createComputePipeline(ctx, stack, pipelineLayout, + "multiscatter.comp.spv", "sky multiple-scattering pipeline"); + long skyViewPipeline = createComputePipeline(ctx, stack, pipelineLayout, + "view.comp.spv", "sky view pipeline"); + + VkDescriptorImageInfo.Buffer images = VkDescriptorImageInfo.calloc(SKY_LUT_BINDING_COUNT, stack); + VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(SKY_LUT_BINDING_COUNT, stack); + long[] storageViews = {transmittance.view, multiScatter.view, skyView.view}; + int[] storageBindings = {SKY_LUT_TRANSMITTANCE_IMAGE, SKY_LUT_MULTISCATTER_IMAGE, + SKY_LUT_SKY_VIEW_IMAGE}; + for (int i = 0; i < storageViews.length; i++) { + int binding = storageBindings[i]; + images.get(binding).imageView(storageViews[i]).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(binding).sType$Default().dstSet(descriptorSet).dstBinding(binding) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .pImageInfo(VkDescriptorImageInfo.create(images.address(binding), 1)); + } + long[] sampledViews = {transmittance.view, multiScatter.view}; + int[] sampledBindings = {SKY_LUT_TRANSMITTANCE_SAMPLER, SKY_LUT_MULTISCATTER_SAMPLER}; + for (int i = 0; i < sampledViews.length; i++) { + int binding = sampledBindings[i]; + images.get(binding).imageView(sampledViews[i]).sampler(sampler) + .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + writes.get(binding).sType$Default().dstSet(descriptorSet).dstBinding(binding) + .descriptorCount(1) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .pImageInfo(VkDescriptorImageInfo.create(images.address(binding), 1)); + } + VK10.vkUpdateDescriptorSets(vk, writes, null); + + return new RtSkyLut(ctx, transmittance, multiScatter, skyView, sampler, descriptorSetLayout, + descriptorPool, descriptorSet, pipelineLayout, transmittancePipeline, + multiScatterPipeline, skyViewPipeline); + } + } + + public long sampler() { + return sampler; + } + + public long transmittanceView() { + return transmittance.view; + } + + public long skyViewView() { + return skyView.view; + } + + /** + * Record this frame's sky LUT work: the two static LUTs on the first frame, then the sky-view LUT. + * Must be recorded before the trace, with a barrier after (the caller's) — the miss and raygen stages + * sample all of these. + */ + public void record(VkCommandBuffer cmd, long worldPushAddress) { + try (MemoryStack stack = MemoryStack.stackPush(); + RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "sky LUTs")) { + VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, + pipelineLayout, 0, stack.longs(descriptorSet), null); + ByteBuffer push = stack.malloc(PushAddrData.BYTE_SIZE); + new PushAddrData(worldPushAddress).write(push); + VK10.vkCmdPushConstants(cmd, pipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); + + if (!staticLutsBaked) { + dispatch(cmd, stack, transmittancePipeline, TRANSMITTANCE_WIDTH, TRANSMITTANCE_HEIGHT); + // The multiple-scattering bake samples the transmittance LUT the previous dispatch just + // wrote, and the sky-view bake samples both. + dispatch(cmd, stack, multiScatterPipeline, MULTISCATTER_WIDTH, MULTISCATTER_HEIGHT); + staticLutsBaked = true; + } + dispatch(cmd, stack, skyViewPipeline, SKY_VIEW_WIDTH, SKY_VIEW_HEIGHT); + } + } + + private void dispatch(VkCommandBuffer cmd, MemoryStack stack, long pipeline, int width, int height) { + VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); + VK10.vkCmdDispatch(cmd, (width + GROUP_SIZE - 1) / GROUP_SIZE, + (height + GROUP_SIZE - 1) / GROUP_SIZE, 1); + VulkanCommandEncoder.memoryBarrier(cmd, stack); + } + + public void destroy() { + if (destroyed) { + return; + } + VkDevice vk = ctx.vk(); + VK10.vkDestroyPipeline(vk, skyViewPipeline, null); + VK10.vkDestroyPipeline(vk, multiScatterPipeline, null); + VK10.vkDestroyPipeline(vk, transmittancePipeline, null); + VK10.vkDestroyPipelineLayout(vk, pipelineLayout, null); + VK10.vkDestroyDescriptorPool(vk, descriptorPool, null); + VK10.vkDestroyDescriptorSetLayout(vk, descriptorSetLayout, null); + VK10.vkDestroySampler(vk, sampler, null); + skyView.destroy(); + multiScatter.destroy(); + transmittance.destroy(); + destroyed = true; + } + + private static long createComputePipeline(RtContext ctx, MemoryStack stack, long layout, + String shader, String label) { + VkDevice vk = ctx.vk(); + long module = loadModule(vk, stack, SHADER_DIR + shader); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, module, label + " module"); + VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack) + .sType$Default().stage(VK10.VK_SHADER_STAGE_COMPUTE_BIT) + .module(module).pName(stack.UTF8("main")); + VkComputePipelineCreateInfo.Buffer info = VkComputePipelineCreateInfo.calloc(1, stack); + info.get(0).sType$Default().stage(stage).layout(layout); + LongBuffer handle = stack.mallocLong(1); + check(VK10.vkCreateComputePipelines(vk, VK10.VK_NULL_HANDLE, info, null, handle), + "vkCreateComputePipelines(" + shader + ")"); + VK10.vkDestroyShaderModule(vk, module, null); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, handle.get(0), label); + return handle.get(0); + } + + private static long loadModule(VkDevice vk, MemoryStack stack, String resource) { + byte[] bytes; + try (InputStream input = RtSkyLut.class.getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("missing SPIR-V resource: " + resource); + } + bytes = input.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("failed to read SPIR-V resource: " + resource, e); + } + ByteBuffer code = MemoryUtil.memAlloc(bytes.length).put(bytes); + code.flip(); + try { + VkShaderModuleCreateInfo moduleInfo = VkShaderModuleCreateInfo.calloc(stack) + .sType$Default().pCode(code); + LongBuffer module = stack.mallocLong(1); + check(VK10.vkCreateShaderModule(vk, moduleInfo, null, module), + "vkCreateShaderModule(" + resource + ")"); + return module.get(0); + } finally { + MemoryUtil.memFree(code); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneLut.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneLut.java new file mode 100644 index 00000000..f250fa7d --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneLut.java @@ -0,0 +1,253 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import dev.comfyfluffy.caustica.rt.accel.RtBuffer; +import org.lwjgl.PointerBuffer; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.util.vma.Vma; +import org.lwjgl.util.vma.VmaAllocationCreateInfo; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkBufferImageCopy; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkImageCreateInfo; +import org.lwjgl.vulkan.VkImageMemoryBarrier; +import org.lwjgl.vulkan.VkImageViewCreateInfo; +import org.lwjgl.vulkan.VkSamplerCreateInfo; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.LongBuffer; + +/** + * A baked ACES color-pipeline 3D LUT (scene-referred look or display transform; see + * {@code tools/bake_display_lut.py}). RGBA16F, one mip, loaded whole from a classpath + * resource and uploaded once via a staging buffer — same shape as {@code RtMaterialPageTexture} + * but 3D and self-describing (the resource carries its own size + shaper range in a small header, + * see {@link #load}). + */ +public final class RtToneLut { + private static final int MAGIC = 0x54554C43; // "CLUT" little-endian + private static final int HEADER_BYTES = 4 + 4 + 4 + 4 + 4; // magic, version, size, loStops, hiStops + // Display shader contract. Reject incompatible resources at load time instead of silently sampling + // them with shaders/pipelines/display/main.comp.slang's fixed shaper. + private static final float SHADER_SHAPER_LO_STOPS = -12.0f; + private static final float SHADER_SHAPER_HI_STOPS = 12.0f; + + private final VkDevice vk; + private final long vma; + private final long image; + private final long allocation; + private final long view; + private final long sampler; + public final int size; + private boolean destroyed; + + private RtToneLut(VkDevice vk, long vma, long image, long allocation, long view, long sampler, + int size) { + this.vk = vk; + this.vma = vma; + this.image = image; + this.allocation = allocation; + this.view = view; + this.sampler = sampler; + this.size = size; + } + + public long view() { + return view; + } + + public long sampler() { + return sampler; + } + + /** Loads a display-transform resource from {@code /caustica/color/luts/}. */ + public static RtToneLut load(RtContext ctx, String resourceName) { + return loadResource(ctx, "/caustica/color/luts/" + resourceName); + } + + /** Loads an absolute classpath LUT resource, including an LMT owned by a look package. */ + public static RtToneLut loadResource(RtContext ctx, String path) { + if (path == null || !path.startsWith("/")) { + throw new IllegalArgumentException("LUT resource path must be absolute: " + path); + } + ByteBuffer data = readResource(path); + try { + data.order(ByteOrder.LITTLE_ENDIAN); + int magic = data.getInt(0); + if (magic != MAGIC) { + throw new IllegalStateException(path + ": bad magic 0x" + Integer.toHexString(magic)); + } + int version = data.getInt(4); + if (version != 1) { + throw new IllegalStateException(path + ": unsupported version " + version); + } + int size = data.getInt(8); + float loStops = data.getFloat(12); + float hiStops = data.getFloat(16); + if (size < 2) { + throw new IllegalStateException(path + ": invalid LUT size " + size); + } + if (loStops != SHADER_SHAPER_LO_STOPS || hiStops != SHADER_SHAPER_HI_STOPS) { + throw new IllegalStateException(path + ": LUT shaper " + loStops + ".." + hiStops + + " does not match display shader " + SHADER_SHAPER_LO_STOPS + ".." + + SHADER_SHAPER_HI_STOPS); + } + long texelCount = (long) size * size * size; + long expectedBytes = HEADER_BYTES + texelCount * 4L * 2L; // RGBA16F + if (data.remaining() != expectedBytes) { + throw new IllegalStateException(path + ": expected " + expectedBytes + " bytes, got " + + data.remaining() + " (size=" + size + ")"); + } + ByteBuffer texels = data.slice(HEADER_BYTES, (int) (expectedBytes - HEADER_BYTES)); + return upload(ctx, size, texels, path); + } finally { + MemoryUtil.memFree(data); + } + } + + private static RtToneLut upload(RtContext ctx, int size, ByteBuffer texels, String label) { + VkDevice vk = ctx.vk(); + long vma = ctx.vma(); + long createdImage = 0L; + long createdAllocation = 0L; + long createdView = 0L; + long createdSampler = 0L; + RtBuffer staging = null; + try (MemoryStack stack = MemoryStack.stackPush()) { + VkImageCreateInfo imageInfo = VkImageCreateInfo.calloc(stack).sType$Default() + .imageType(VK10.VK_IMAGE_TYPE_3D).format(VK10.VK_FORMAT_R16G16B16A16_SFLOAT) + .mipLevels(1).arrayLayers(1).samples(VK10.VK_SAMPLE_COUNT_1_BIT) + .tiling(VK10.VK_IMAGE_TILING_OPTIMAL) + .usage(VK10.VK_IMAGE_USAGE_SAMPLED_BIT | VK10.VK_IMAGE_USAGE_TRANSFER_DST_BIT) + .sharingMode(VK10.VK_SHARING_MODE_EXCLUSIVE) + .initialLayout(VK10.VK_IMAGE_LAYOUT_UNDEFINED); + imageInfo.extent().set(size, size, size); + VmaAllocationCreateInfo allocationInfo = VmaAllocationCreateInfo.calloc(stack) + .usage(Vma.VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE); + LongBuffer imageOut = stack.mallocLong(1); + PointerBuffer allocationOut = stack.mallocPointer(1); + RtContext.check(Vma.vmaCreateImage(vma, imageInfo, allocationInfo, imageOut, allocationOut, null), + "vmaCreateImage(tone lut " + label + ")"); + createdImage = imageOut.get(0); + createdAllocation = allocationOut.get(0); + RtDebugLabels.nameImage(ctx, createdImage, "tone LUT " + label); + + VkImageViewCreateInfo viewInfo = VkImageViewCreateInfo.calloc(stack).sType$Default() + .image(createdImage).viewType(VK10.VK_IMAGE_VIEW_TYPE_3D) + .format(VK10.VK_FORMAT_R16G16B16A16_SFLOAT); + viewInfo.subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .baseMipLevel(0).levelCount(1).baseArrayLayer(0).layerCount(1); + LongBuffer viewOut = stack.mallocLong(1); + RtContext.check(VK10.vkCreateImageView(vk, viewInfo, null, viewOut), + "vkCreateImageView(tone lut " + label + ")"); + createdView = viewOut.get(0); + RtDebugLabels.nameImageView(ctx, createdView, "tone LUT " + label + " view"); + + // Edge-aligned LUT: the shaper's [0,1] domain maps texel 0's centre to input 0 and texel + // (size-1)'s centre to input 1 (see tools/bake_display_lut.py). CLAMP_TO_EDGE holds the + // boundary texel for any exposed value outside the shaper's ±stops range instead of + // wrapping or reading black. + VkSamplerCreateInfo samplerInfo = VkSamplerCreateInfo.calloc(stack).sType$Default() + .magFilter(VK10.VK_FILTER_LINEAR).minFilter(VK10.VK_FILTER_LINEAR) + .mipmapMode(VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST) + .addressModeU(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeV(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .addressModeW(VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE) + .minLod(0f).maxLod(0f); + LongBuffer samplerOut = stack.mallocLong(1); + RtContext.check(VK10.vkCreateSampler(vk, samplerInfo, null, samplerOut), + "vkCreateSampler(tone lut " + label + ")"); + createdSampler = samplerOut.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SAMPLER, createdSampler, "tone LUT " + label + " sampler"); + + int totalBytes = texels.remaining(); + staging = ctx.createUploadBuffer(totalBytes, "tone lut " + label + " upload"); + ByteBuffer mapped = MemoryUtil.memByteBuffer(staging.mapped, totalBytes); + mapped.put(texels.duplicate()); + staging.flush(); + + long uploadImage = createdImage; + long uploadBuffer = staging.handle; + ctx.submitSync(cmd -> { + try (MemoryStack uploadStack = MemoryStack.stackPush()) { + VkImageMemoryBarrier.Buffer toTransfer = VkImageMemoryBarrier.calloc(1, uploadStack); + toTransfer.get(0).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_UNDEFINED) + .newLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) + .srcAccessMask(0).dstAccessMask(VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED).image(uploadImage); + toTransfer.get(0).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .baseMipLevel(0).levelCount(1).baseArrayLayer(0).layerCount(1); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, null, null, toTransfer); + + VkBufferImageCopy.Buffer copy = VkBufferImageCopy.calloc(1, uploadStack); + copy.get(0).bufferOffset(0).bufferRowLength(0).bufferImageHeight(0); + copy.get(0).imageSubresource().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .mipLevel(0).baseArrayLayer(0).layerCount(1); + copy.get(0).imageOffset().set(0, 0, 0); + copy.get(0).imageExtent().set(size, size, size); + VK10.vkCmdCopyBufferToImage(cmd, uploadBuffer, uploadImage, + VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, copy); + + // GENERAL, not SHADER_READ_ONLY_OPTIMAL, to match every other sampled/storage + // image in this codebase (see RtMaterialPageTexture, RtContext.createStorageImage) + // — the descriptor write below must use the same layout or validation flags a + // mismatch. + VkImageMemoryBarrier.Buffer toRead = VkImageMemoryBarrier.calloc(1, uploadStack); + toRead.get(0).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) + .newLayout(VK10.VK_IMAGE_LAYOUT_GENERAL) + .srcAccessMask(VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_SHADER_READ_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED).image(uploadImage); + toRead.get(0).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .baseMipLevel(0).levelCount(1).baseArrayLayer(0).layerCount(1); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, + VK10.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, null, null, toRead); + } + }); + } catch (Throwable t) { + if (createdSampler != 0L) VK10.vkDestroySampler(vk, createdSampler, null); + if (createdView != 0L) VK10.vkDestroyImageView(vk, createdView, null); + if (createdImage != 0L) Vma.vmaDestroyImage(vma, createdImage, createdAllocation); + throw t; + } finally { + if (staging != null) staging.destroy(); + } + return new RtToneLut(vk, vma, createdImage, createdAllocation, createdView, createdSampler, + size); + } + + public void destroy() { + if (destroyed) { + return; + } + VK10.vkDestroySampler(vk, sampler, null); + VK10.vkDestroyImageView(vk, view, null); + Vma.vmaDestroyImage(vma, image, allocation); + destroyed = true; + } + + private static ByteBuffer readResource(String path) { + try (InputStream in = RtToneLut.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("missing LUT resource: " + path); + } + byte[] bytes = in.readAllBytes(); + ByteBuffer buf = MemoryUtil.memAlloc(bytes.length); + buf.put(bytes); + buf.flip(); + return buf; + } catch (IOException e) { + throw new IllegalStateException("failed to read LUT resource: " + path, e); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightCollector.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightCollector.java index b2ee6d63..eaa7614f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightCollector.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightCollector.java @@ -15,9 +15,9 @@ *

One rectangle light per emissive quad. {@code emit()}/{@code emitQuad()} always write a quad * as two lockstep triangles (0,1,2)(0,2,3) over 4 consecutive verts with prim/cornerUv records in step, * so quad {@code k} is triangles {@code 2k, 2k+1} and its corners are verts {@code 4k..4k+3}. Unlike the - * old branch's disc, the light is the emissive footprint's bounding rectangle (half-axes in the + * The light is the emissive footprint's bounding rectangle (half-axes in the * record): it doesn't overshoot the emitter shape, and its (s,t) parameterization is the affine - * sprite-local UV map that the S3 exact-Le fetch needs. + * sprite-local UV map used for exact radiance lookup. * *

Radiance matches the closest-hit. Per-texel shaded emission is {@code albedo * mask * * emissionStrength}, where the mask source (LabPBR {@code _s} blue channel / heuristic mask x block @@ -30,7 +30,7 @@ * emissive sample, hence {@code Le_rect * rectArea == quadArea * mean(albedo*mask)}. * *

Membership. An in-buffer quad gets {@code TerrainPrim.flags} bit 0 set on both triangles, so - * the raygen can gate its direct-hit emission term (S1). Emitters too weak or too sparse (fill-ratio + * the raygen can gate its direct-hit emission term. Emitters too weak or too sparse (fill-ratio * gate) stay excluded and are always-gathered on path hits — bit-identical to the no-NEE path. */ final class RtLightCollector { @@ -55,8 +55,14 @@ private RtLightCollector() { /** * An emitter whose rectangle-mean radiance luminance is below this is too weak to bother sampling: * keep it out of the buffer (always-gathered on hits instead). + * + *

Expressed as a fraction of the emissive baseline rather than as an absolute radiance, because + * "too weak to sample" is a statement about this emitter relative to a full-strength one, not about + * cd/m². Expressing it relative to the configured baseline keeps material brightness and sampling + * eligibility independent. */ - private static final float LE_LUM_EPS = 0.005f; + private static final float LE_LUM_EPS = + 0.001f * RtMaterialRegistry.defaultEmissionLuminanceCdM2(); /** Samples per axis over the quad's (a,b) parameter square; matches the emission grid resolution. */ private static final int SCAN = RtEmissionGrid.SIZE; @@ -201,21 +207,30 @@ static void collectBucket(FloatArrayList out, FloatArrayList verts, FloatArrayLi // Rectangle-mean radiance: every emissive sample lies inside the rectangle, so // sum/rectSamples preserves the quad's total emissive power at rectArea. emissionStrength() - // is the material's final HDR strength (EMISSIVE_STRENGTH baseline * any JSON multiplier, + // is the material's final HDR luminance (look-package baseline or absolute JSON override, // baked in RtMaterialRegistry) — the single knob shared with world.rchit's direct-hit shading. - float tintR = p[pb + 4], tintG = p[pb + 5], tintB = p[pb + 6]; + // Texture-grid averages are already linear BT.709; captured vertex/biome tint is still + // sRGB-encoded. Combine in the authored basis, use its invariant Y for the membership gate, + // then store the emitter in the scene's linear ACEScg transport basis. + float tintR = srgbToLinear(p[pb + 4]); + float tintG = srgbToLinear(p[pb + 5]); + float tintB = srgbToLinear(p[pb + 6]); float scale = factor * desc.emissionStrength() / rectSamples; - float leR = sumR * scale * tintR; - float leG = sumG * scale * tintG; - float leB = sumB * scale * tintB; - float lum = 0.2126f * leR + 0.7152f * leG + 0.0722f * leB; + float le709R = sumR * scale * tintR; + float le709G = sumG * scale * tintG; + float le709B = sumB * scale * tintB; + float lum = 0.2126f * le709R + 0.7152f * le709G + 0.0722f * le709B; if (lum < LE_LUM_EPS || fill < minFillRatio) { continue; // excluded: always-gathered on path hits, no energy lost } + // Same OCIO-derived Linear Rec.709/D65 -> ACEScg/AP1/D60 matrix as world_common.slang. + float leR = 0.61309743f * le709R + 0.33952314f * le709G + 0.04737945f * le709B; + float leG = 0.07019372f * le709R + 0.91635388f * le709G + 0.01345240f * le709B; + float leB = 0.02061559f * le709R + 0.10956977f * le709G + 0.86981463f * le709B; float aC = 0.5f * (aLo + aHi); float bC = 0.5f * (bLo + bHi); - // Sprite-local UV frame of the rectangle (S3 exact-Le fetch): affine map from the light's + // Sprite-local UV frame of the rectangle: affine map from the light's // (s,t) in [-1,1]^2 to sprite-local UV, evaluated from the same bilinear corner map. float uvCu; float uvCv; @@ -267,11 +282,16 @@ private static float packHalf2(float x, float y) { return Float.intBitsToFloat(bits); } + private static float srgbToLinear(float value) { + return value <= 0.04045f ? value / 12.92f + : (float) Math.pow((value + 0.055f) / 1.055f, 2.4f); + } + /** - * Packed light record, 5 vec4s / 80 B (matches the S1 shader struct): + * Packed light record, 5 vec4s / 80 B (matches the shader struct): * {@code {pos.xyz, rectArea} {normal.xyz, materialId} {halfU.xyz, packHalf2(uvHu)} - * {halfV.xyz, packHalf2(uvHv)} {Le.rgb, packHalf2(uvCenter)}}. Positions/axes section-local here; - * publish adds the section-origin-minus-rebase offset to pos only. + * {halfV.xyz, packHalf2(uvHv)} {Le2020.rgb, packHalf2(uvCenter)}}. Positions/axes section-local + * here; publish adds the section-origin-minus-rebase offset to pos only. */ private static void append(FloatArrayList out, float px, float py, float pz, float area, diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java index c01bfb62..dbbdb6eb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java @@ -102,9 +102,10 @@ static Data build(List sections, int rebaseX, int rebaseY, int reb + crossZ * section.lights[source + 6] < 0.0f) { packedLights[destination + 7] = Float.intBitsToFloat(NORMAL_FLIP_BIT); } - double luminance = 0.2126 * unpackUnsignedFloat(packedLe & 0x7ff, 6) - + 0.7152 * unpackUnsignedFloat((packedLe >>> 11) & 0x7ff, 6) - + 0.0722 * unpackUnsignedFloat((packedLe >>> 22) & 0x3ff, 5); + // Collector output and the packed GPU record are linear ACEScg/AP1. + double luminance = 0.27222872 * unpackUnsignedFloat(packedLe & 0x7ff, 6) + + 0.67408177 * unpackUnsignedFloat((packedLe >>> 11) & 0x7ff, 6) + + 0.05368952 * unpackUnsignedFloat((packedLe >>> 22) & 0x3ff, 5); double power = Math.max(0.0, section.lights[source + 3] * luminance); powers[lightIndex] = power; sectionPower += power; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java index a3f6176a..cd08d2e2 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java @@ -15,7 +15,7 @@ import java.util.concurrent.ConcurrentLinkedQueue; /** - * Mutable GPU section-table state owned by the render thread. M0 centralizes the table buffer, slot + * Mutable GPU section-table state owned by the render thread. This class centralizes the table buffer, slot * registry, and published static-instance list here while {@link RtTerrain} retains publication order. */ final class RtSectionTable { diff --git a/src/main/resources/assets/caustica/lang/de_de.json b/src/main/resources/assets/caustica/lang/de_de.json index 217cdf4a..0a65e140 100644 --- a/src/main/resources/assets/caustica/lang/de_de.json +++ b/src/main/resources/assets/caustica/lang/de_de.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Path-Bounces", "caustica.options.rt.maxBounces.tooltip": "Maximale Anzahl sekundärer Pathtracing-Bounces nach dem Primärtreffer. Höhere Werte erfassen mehr indirektes Licht, kosten aber mehr Leistung.", - "caustica.options.rt.sunSize": "Sonnengröße", - "caustica.options.rt.sunSize.tooltip": "Winkelradius der Sonnen- und Mondscheibe. Größere Scheiben erzeugen weichere Halbschatten.", - "caustica.options.rt.entities": "Raytracing-Entitäten", "caustica.options.rt.entities.tooltip": "Entitäten und Blockentitäten in die Raytracing-Szene einbeziehen.", diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 323cf7cf..4533f086 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -9,15 +9,15 @@ "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.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.", + "caustica.options.rt.spp": "Samples Per Pixel", "caustica.options.rt.spp.tooltip": "Path-tracing samples taken per pixel each frame. Higher is cleaner but more demanding.", "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.sunSize": "Sun Size", - "caustica.options.rt.sunSize.tooltip": "Angular radius of the sun and moon disk. Larger discs cast softer shadow penumbrae.", - "caustica.options.rt.entities": "Ray-Traced Entities", "caustica.options.rt.entities.tooltip": "Include entities and block entities in the ray-traced scene.", @@ -31,10 +31,10 @@ "caustica.options.rt.waterWaves.tooltip": "Animate water-surface normals for moving wave highlights.", "caustica.options.rt.hdr": "HDR Output", - "caustica.options.rt.hdr.tooltip": "Present in HDR (ST.2084/PQ) instead of SDR, when the display supports it. Takes effect after restarting the game.", + "caustica.options.rt.hdr.tooltip": "Recreate the swapchain for HDR (ST.2084/PQ) or native SDR output. Applied at the next frame boundary.", - "caustica.options.rt.hdrPaperWhite": "HDR Paper White", - "caustica.options.rt.hdrPaperWhite.tooltip": "Absolute brightness (in nits) that SDR-equivalent scene brightness maps to on an HDR display.", + "caustica.options.rt.hdrUiBrightness": "HDR UI Brightness", + "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.", @@ -48,7 +48,7 @@ "caustica.options.rt.dlssQuality.5": "DLAA", "caustica.options.rt.debugView": "Debug View", - "caustica.options.rt.debugView.tooltip": "Visualize an intermediate ray-tracing buffer instead of the final image.", + "caustica.options.rt.debugView.tooltip": "Inspect ray-tracing buffers and exposure diagnostics after the normal frame has rendered.", "caustica.options.rt.debugView.0": "Off", "caustica.options.rt.debugView.1": "Normals", "caustica.options.rt.debugView.2": "Albedo", @@ -56,5 +56,7 @@ "caustica.options.rt.debugView.4": "Roughness", "caustica.options.rt.debugView.5": "Motion", "caustica.options.rt.debugView.6": "Specular", - "caustica.options.rt.debugView.7": "Specular Motion" + "caustica.options.rt.debugView.7": "Specular Motion", + "caustica.options.rt.debugView.8": "Exposure False Color", + "caustica.options.rt.debugView.9": "Metering Weight" } diff --git a/src/main/resources/assets/caustica/lang/es_es.json b/src/main/resources/assets/caustica/lang/es_es.json index 52734542..0520e264 100644 --- a/src/main/resources/assets/caustica/lang/es_es.json +++ b/src/main/resources/assets/caustica/lang/es_es.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Rebotes de camino", "caustica.options.rt.maxBounces.tooltip": "Número máximo de rebotes secundarios de trazado de caminos tras el impacto primario. Un valor más alto captura más luz indirecta, pero cuesta más.", - "caustica.options.rt.sunSize": "Tamaño del sol", - "caustica.options.rt.sunSize.tooltip": "Radio angular del disco del sol y la luna. Los discos más grandes proyectan penumbras más suaves.", - "caustica.options.rt.entities": "Entidades con trazado de rayos", "caustica.options.rt.entities.tooltip": "Incluye entidades y entidades de bloque en la escena con trazado de rayos.", diff --git a/src/main/resources/assets/caustica/lang/fr_fr.json b/src/main/resources/assets/caustica/lang/fr_fr.json index 6abf493c..af5c51b4 100644 --- a/src/main/resources/assets/caustica/lang/fr_fr.json +++ b/src/main/resources/assets/caustica/lang/fr_fr.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Rebonds de chemin", "caustica.options.rt.maxBounces.tooltip": "Nombre maximal de rebonds secondaires de path tracing après le premier impact. Une valeur plus élevée capture plus de lumière indirecte, mais coûte plus cher.", - "caustica.options.rt.sunSize": "Taille du soleil", - "caustica.options.rt.sunSize.tooltip": "Rayon angulaire du disque du soleil et de la lune. Des disques plus grands créent des pénombres plus douces.", - "caustica.options.rt.entities": "Entités en lancer de rayons", "caustica.options.rt.entities.tooltip": "Inclut les entités et entités de bloc dans la scène en lancer de rayons.", diff --git a/src/main/resources/assets/caustica/lang/it_it.json b/src/main/resources/assets/caustica/lang/it_it.json index 42867f48..77959750 100644 --- a/src/main/resources/assets/caustica/lang/it_it.json +++ b/src/main/resources/assets/caustica/lang/it_it.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Rimbalzi del percorso", "caustica.options.rt.maxBounces.tooltip": "Numero massimo di rimbalzi secondari del path tracing dopo il primo colpo. Valori più alti catturano più luce indiretta, ma costano di più.", - "caustica.options.rt.sunSize": "Dimensione del sole", - "caustica.options.rt.sunSize.tooltip": "Raggio angolare del disco del sole e della luna. Dischi più grandi proiettano penombre più morbide.", - "caustica.options.rt.entities": "Entità con ray tracing", "caustica.options.rt.entities.tooltip": "Include entità ed entità blocco nella scena con ray tracing.", diff --git a/src/main/resources/assets/caustica/lang/ja_jp.json b/src/main/resources/assets/caustica/lang/ja_jp.json index 19c1ba72..0fb66b23 100644 --- a/src/main/resources/assets/caustica/lang/ja_jp.json +++ b/src/main/resources/assets/caustica/lang/ja_jp.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "パスのバウンス数", "caustica.options.rt.maxBounces.tooltip": "最初のヒット後に行う二次パストレーシングバウンスの最大数。高いほど間接光を多く捉えますが、負荷も増えます。", - "caustica.options.rt.sunSize": "太陽のサイズ", - "caustica.options.rt.sunSize.tooltip": "太陽と月の円盤の角半径。大きいほど影の半影が柔らかくなります。", - "caustica.options.rt.entities": "レイトレーシング対象エンティティ", "caustica.options.rt.entities.tooltip": "エンティティとブロックエンティティをレイトレーシングシーンに含めます。", diff --git a/src/main/resources/assets/caustica/lang/ko_kr.json b/src/main/resources/assets/caustica/lang/ko_kr.json index ad7507d5..fa12f335 100644 --- a/src/main/resources/assets/caustica/lang/ko_kr.json +++ b/src/main/resources/assets/caustica/lang/ko_kr.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "경로 바운스", "caustica.options.rt.maxBounces.tooltip": "첫 번째 충돌 이후의 보조 패스 트레이싱 바운스 최대 수입니다. 높을수록 더 많은 간접광을 포착하지만 비용이 증가합니다.", - "caustica.options.rt.sunSize": "태양 크기", - "caustica.options.rt.sunSize.tooltip": "태양과 달 원반의 각반경입니다. 원반이 클수록 그림자의 반그림자가 더 부드러워집니다.", - "caustica.options.rt.entities": "레이 트레이싱 엔티티", "caustica.options.rt.entities.tooltip": "레이 트레이싱 장면에 엔티티와 블록 엔티티를 포함합니다.", diff --git a/src/main/resources/assets/caustica/lang/pt_br.json b/src/main/resources/assets/caustica/lang/pt_br.json index ca988a1d..204449b8 100644 --- a/src/main/resources/assets/caustica/lang/pt_br.json +++ b/src/main/resources/assets/caustica/lang/pt_br.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Rebatimentos de caminho", "caustica.options.rt.maxBounces.tooltip": "Número máximo de rebatimentos secundários de path tracing após o primeiro acerto. Valores maiores capturam mais luz indireta, mas custam mais.", - "caustica.options.rt.sunSize": "Tamanho do sol", - "caustica.options.rt.sunSize.tooltip": "Raio angular do disco do sol e da lua. Discos maiores projetam penumbras mais suaves.", - "caustica.options.rt.entities": "Entidades com traçado de raios", "caustica.options.rt.entities.tooltip": "Inclui entidades e entidades de bloco na cena com traçado de raios.", diff --git a/src/main/resources/assets/caustica/lang/ru_ru.json b/src/main/resources/assets/caustica/lang/ru_ru.json index 5d35807e..8a425232 100644 --- a/src/main/resources/assets/caustica/lang/ru_ru.json +++ b/src/main/resources/assets/caustica/lang/ru_ru.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "Отскоки пути", "caustica.options.rt.maxBounces.tooltip": "Максимальное число вторичных отскоков path tracing после первичного попадания. Более высокие значения захватывают больше непрямого света, но обходятся дороже.", - "caustica.options.rt.sunSize": "Размер солнца", - "caustica.options.rt.sunSize.tooltip": "Угловой радиус диска солнца и луны. Более крупные диски дают более мягкие полутени.", - "caustica.options.rt.entities": "Трассируемые сущности", "caustica.options.rt.entities.tooltip": "Включает сущности и блочные сущности в сцену с трассировкой лучей.", diff --git a/src/main/resources/assets/caustica/lang/zh_cn.json b/src/main/resources/assets/caustica/lang/zh_cn.json index 2f8ef27a..ae608b02 100644 --- a/src/main/resources/assets/caustica/lang/zh_cn.json +++ b/src/main/resources/assets/caustica/lang/zh_cn.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "路径反弹", "caustica.options.rt.maxBounces.tooltip": "主命中之后的二级路径追踪反弹最大次数。数值越高能捕捉更多间接光,但成本也越高。", - "caustica.options.rt.sunSize": "太阳大小", - "caustica.options.rt.sunSize.tooltip": "太阳和月亮圆盘的角半径。圆盘越大,投射的半影越柔和。", - "caustica.options.rt.entities": "光追实体", "caustica.options.rt.entities.tooltip": "将实体和方块实体加入光追场景。", diff --git a/src/main/resources/assets/caustica/lang/zh_tw.json b/src/main/resources/assets/caustica/lang/zh_tw.json index 7059e741..6d3cfd53 100644 --- a/src/main/resources/assets/caustica/lang/zh_tw.json +++ b/src/main/resources/assets/caustica/lang/zh_tw.json @@ -15,9 +15,6 @@ "caustica.options.rt.maxBounces": "路徑反彈", "caustica.options.rt.maxBounces.tooltip": "主要命中之後的次級路徑追蹤反彈最大次數。數值越高能捕捉更多間接光,但成本也越高。", - "caustica.options.rt.sunSize": "太陽大小", - "caustica.options.rt.sunSize.tooltip": "太陽和月亮圓盤的角半徑。圓盤越大,投射的半影越柔和。", - "caustica.options.rt.entities": "光追實體", "caustica.options.rt.entities.tooltip": "將實體和方塊實體加入光追場景。", diff --git a/src/main/resources/assets/caustica/caustica/materials/copper_torch.json b/src/main/resources/assets/caustica/materials/copper_torch.json similarity index 66% rename from src/main/resources/assets/caustica/caustica/materials/copper_torch.json rename to src/main/resources/assets/caustica/materials/copper_torch.json index 485a2ff2..1d8df483 100644 --- a/src/main/resources/assets/caustica/caustica/materials/copper_torch.json +++ b/src/main/resources/assets/caustica/materials/copper_torch.json @@ -1,9 +1,9 @@ { - "format": 1, + "format": 2, "match": { "sprite": "minecraft:block/copper_torch" }, "emission": { - "strength": 3.0 + "strength_cd_m2": 6000.0 } } diff --git a/src/main/resources/assets/caustica/caustica/materials/soul_torch.json b/src/main/resources/assets/caustica/materials/soul_torch.json similarity index 65% rename from src/main/resources/assets/caustica/caustica/materials/soul_torch.json rename to src/main/resources/assets/caustica/materials/soul_torch.json index 01230792..6a6590fc 100644 --- a/src/main/resources/assets/caustica/caustica/materials/soul_torch.json +++ b/src/main/resources/assets/caustica/materials/soul_torch.json @@ -1,9 +1,9 @@ { - "format": 1, + "format": 2, "match": { "sprite": "minecraft:block/soul_torch" }, "emission": { - "strength": 3.0 + "strength_cd_m2": 6000.0 } } diff --git a/src/main/resources/assets/caustica/caustica/materials/torch.json b/src/main/resources/assets/caustica/materials/torch.json similarity index 64% rename from src/main/resources/assets/caustica/caustica/materials/torch.json rename to src/main/resources/assets/caustica/materials/torch.json index 6ee45531..3e3452b9 100644 --- a/src/main/resources/assets/caustica/caustica/materials/torch.json +++ b/src/main/resources/assets/caustica/materials/torch.json @@ -1,9 +1,9 @@ { - "format": 1, + "format": 2, "match": { "sprite": "minecraft:block/torch" }, "emission": { - "strength": 3.0 + "strength_cd_m2": 6000.0 } } diff --git a/src/main/resources/caustica.mixins.json b/src/main/resources/caustica.mixins.json index 35d1de18..f7c6e54e 100644 --- a/src/main/resources/caustica.mixins.json +++ b/src/main/resources/caustica.mixins.json @@ -16,11 +16,11 @@ "MinecraftMixin", "MinecraftReloadMixin", "ModelPartAccessor", - "OptionsMixin", "OptionsSubScreenAccessor", "ParticleEngineAccessor", "RenderSetupAccessor", "RenderTypeAccessor", + "ScreenshotMixin", "TextureAtlasAccessor", "ParticleGroupAccessor", "SpriteContentsAccessor", diff --git a/src/main/resources/caustica/color/looks/default/lmt.bin b/src/main/resources/caustica/color/looks/default/lmt.bin new file mode 100644 index 00000000..410cfb5f Binary files /dev/null and b/src/main/resources/caustica/color/looks/default/lmt.bin differ diff --git a/src/main/resources/caustica/color/looks/default/look.json b/src/main/resources/caustica/color/looks/default/look.json new file mode 100644 index 00000000..cce93778 --- /dev/null +++ b/src/main/resources/caustica/color/looks/default/look.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 4, + "id": "default", + "packageVersion": 4, + "exposure": { + "minEv": -15.0, + "maxEv": -2.0, + "curve": "-2:-3, 2:-2.0, 8:0.0, 15:1.0" + }, + "lmt": { + "resource": "lmt.bin" + }, + "bloom": { + "strength": 0.02, + "thresholdSceneLinear": 2.0, + "softKneeFraction": 0.25, + "radius": 1.0, + "levels": 6 + }, + "lighting": { + "sunIlluminanceLux": 128000.0, + "moonIlluminanceLux": 5.0, + "blockEmissionLuminanceCdM2": 2000.0, + "nightAirglowLuminanceCdM2": 0.002, + "starLuminanceCdM2": 10.0, + "moonPhaseFixedFraction": 0.1 + }, + "sky": { + "sunNoonSouthTiltDegrees": 30.0, + "sunAngularRadiusDegrees": 0.6, + "moonAngularRadiusDegrees": 1.5, + "sunDiscHalfAngleDegrees": 16.7, + "moonDiscHalfAngleDegrees": 11.31, + "groundAlbedo": 0.1, + "horizonSoftenDegrees": 15.0 + } +} diff --git a/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_1000nit.bin b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_1000nit.bin new file mode 100644 index 00000000..676aae2e Binary files /dev/null and b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_1000nit.bin differ diff --git a/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_2000nit.bin b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_2000nit.bin new file mode 100644 index 00000000..d4207e22 Binary files /dev/null and b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_2000nit.bin differ diff --git a/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_4000nit.bin b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_4000nit.bin new file mode 100644 index 00000000..fcadc696 Binary files /dev/null and b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_4000nit.bin differ diff --git a/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_500nit.bin b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_500nit.bin new file mode 100644 index 00000000..cb30e464 Binary files /dev/null and b/src/main/resources/caustica/color/luts/hdr_aces2_rec2020_500nit.bin differ diff --git a/src/main/resources/caustica/color/luts/sdr_aces2_rec709.bin b/src/main/resources/caustica/color/luts/sdr_aces2_rec709.bin new file mode 100644 index 00000000..adf916e7 Binary files /dev/null and b/src/main/resources/caustica/color/luts/sdr_aces2_rec709.bin differ diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java new file mode 100644 index 00000000..c2a664d6 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -0,0 +1,22 @@ +package dev.comfyfluffy.caustica; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class CausticaConfigTest { + @Test + void invalidPeakNitsFallsBackToDefault() { + CausticaConfig.IntSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS; + int previous = setting.value(); + try { + setting.set(2000); + assertEquals(2000, setting.value()); + + setting.set(900); + assertEquals(1000, setting.value()); + } finally { + setting.set(previous); + } + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java new file mode 100644 index 00000000..1651f70f --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java @@ -0,0 +1,34 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class RtHdrTest { + private static final float EPSILON = 0.000001f; + + @Test + void buildsRec2020D65MetadataAtTheSelectedAcesMasteringPeak() { + RtHdr.MasteringMetadata metadata = RtHdr.masteringMetadata(1000); + + assertChromaticity(metadata.red(), 0.708f, 0.292f); + assertChromaticity(metadata.green(), 0.170f, 0.797f); + assertChromaticity(metadata.blue(), 0.131f, 0.046f); + assertChromaticity(metadata.white(), 0.3127f, 0.3290f); + assertEquals(1000.0f, metadata.maxLuminance(), EPSILON); + assertEquals(0.0001f, metadata.minLuminance(), EPSILON); + assertEquals(1000.0f, metadata.maxContentLightLevel(), EPSILON); + assertEquals(0.0f, metadata.maxFrameAverageLightLevel(), EPSILON); + } + + @Test + void rejectsAnInvalidMasteringPeak() { + assertThrows(IllegalArgumentException.class, () -> RtHdr.masteringMetadata(0)); + } + + private static void assertChromaticity(RtHdr.Chromaticity actual, float x, float y) { + assertEquals(x, actual.x(), EPSILON); + assertEquals(y, actual.y(), EPSILON); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtLookPackageTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtLookPackageTest.java new file mode 100644 index 00000000..d0b9c1be --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtLookPackageTest.java @@ -0,0 +1,61 @@ +package dev.comfyfluffy.caustica.rt; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class RtLookPackageTest { + private static final String VALID = """ + {"schemaVersion":4,"id":"test","packageVersion":1, + "exposure":{"minEv":-15,"maxEv":-2,"curve":"-2:-3,2:-2,8:0,15:1"}, + "lmt":{"resource":"lmt.bin"}, + "bloom":{"strength":0.08,"thresholdSceneLinear":1,"softKneeFraction":0.5,"radius":1, + "levels":6}, + "lighting":{"sunIlluminanceLux":128000,"moonIlluminanceLux":5, + "blockEmissionLuminanceCdM2":2000,"nightAirglowLuminanceCdM2":0.002, + "starLuminanceCdM2":10,"moonPhaseFixedFraction":0.1}, + "sky":{"sunNoonSouthTiltDegrees":30,"sunAngularRadiusDegrees":0.6, + "moonAngularRadiusDegrees":1.5,"sunDiscHalfAngleDegrees":16.7, + "moonDiscHalfAngleDegrees":11.31,"groundAlbedo":0.1,"horizonSoftenDegrees":15}} + """; + + @Test + void acceptsACompleteCurrentSchemaPackage() { + RtLookPackage look = parse(VALID); + assertEquals(6, look.bloom().levels()); + assertEquals(30.0f, look.sky().sunNoonSouthTiltDegrees()); + assertEquals(0.1f, look.sky().groundAlbedo()); + } + + @Test + void rejectsUnknownSchema() { + assertThrows(IllegalArgumentException.class, () -> parse(VALID.replace("\"schemaVersion\":4", + "\"schemaVersion\":3"))); + } + + @Test + void rejectsInvalidPhysicalRanges() { + assertThrows(IllegalArgumentException.class, () -> parse(VALID.replace("\"minEv\":-15", + "\"minEv\":2"))); + // A pyramid deeper than the bloom pipeline allocates would index past its descriptor sets. + assertThrows(IllegalArgumentException.class, () -> parse(VALID.replace("\"levels\":6", + "\"levels\":9"))); + assertThrows(IllegalArgumentException.class, () -> parse(VALID.replace("\"groundAlbedo\":0.1", + "\"groundAlbedo\":1.5"))); + // A fade still running at the nadir leaves the lower hemisphere with no settled colour. + assertThrows(IllegalArgumentException.class, () -> parse( + VALID.replace("\"horizonSoftenDegrees\":15", "\"horizonSoftenDegrees\":120"))); + } + + @Test + void rejectsAMissingSkySection() { + assertThrows(IllegalArgumentException.class, () -> parse(VALID.replace("\"sky\":", "\"nope\":"))); + } + + private static RtLookPackage parse(String json) { + return RtLookPackage.parse(JsonParser.parseString(json).getAsJsonObject(), + "/caustica/color/looks/test/look.json"); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriterTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriterTest.java new file mode 100644 index 00000000..8ab82862 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtOpenExrWriterTest.java @@ -0,0 +1,102 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtOpenExrWriterTest { + @TempDir + Path temp; + + @Test + void writesValidUncompressedHalfScanlinesWithTopRowFirst() throws IOException { + // Vulkan row order: bottom row first, RGBA interleaved. + short[] pixels = halves( + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16); + Path output = temp.resolve("capture.exr"); + RtOpenExrWriter.write(output, 2, 2, pixels, new RtOpenExrWriter.Metadata( + 0.25f, 1.5f, 0.375f, "auto", 12.0f, -1.5f, -1.75f, "none", 42L)); + + ByteBuffer file = ByteBuffer.wrap(Files.readAllBytes(output)).order(ByteOrder.LITTLE_ENDIAN); + assertEquals(20_000_630, file.getInt()); + assertEquals(2, file.getInt()); + + Map attributes = readAttributes(file); + assertEquals("float", attributes.get("causticaResidualExposure").type()); + assertEquals(1.5f, attributes.get("causticaResidualExposure").value().getFloat(0)); + assertEquals(0.375f, attributes.get("causticaAbsoluteExposure").value().getFloat(0)); + assertEquals("ACEScg (AP1/D60), scene-linear", + StandardCharsets.UTF_8.decode(attributes.get("causticaColorSpace").value().duplicate()).toString()); + assertEquals(32, attributes.get("chromaticities").value().remaining()); + + long firstOffset = file.getLong(); + long secondOffset = file.getLong(); + assertEquals(file.position(), firstOffset); + assertEquals(firstOffset + 24, secondOffset); + + assertScanline(file, 0, halves(12, 16, 11, 15, 10, 14, 9, 13)); + assertScanline(file, 1, halves(4, 8, 3, 7, 2, 6, 1, 5)); + assertEquals(file.limit(), file.position()); + } + + private static Map readAttributes(ByteBuffer file) { + Map result = new HashMap<>(); + while (file.get(file.position()) != 0) { + String name = cString(file); + String type = cString(file); + int size = file.getInt(); + ByteBuffer value = file.slice(file.position(), size).order(ByteOrder.LITTLE_ENDIAN); + file.position(file.position() + size); + result.put(name, new Attribute(type, value)); + } + file.get(); + assertTrue(result.containsKey("channels")); + assertTrue(result.containsKey("dataWindow")); + return result; + } + + private static void assertScanline(ByteBuffer file, int expectedY, short[] expectedChannelMajor) { + assertEquals(expectedY, file.getInt()); + assertEquals(expectedChannelMajor.length * Short.BYTES, file.getInt()); + short[] actual = new short[expectedChannelMajor.length]; + file.asShortBuffer().get(actual); + file.position(file.position() + actual.length * Short.BYTES); + assertArrayEquals(expectedChannelMajor, actual); + } + + private static String cString(ByteBuffer bytes) { + int start = bytes.position(); + int end = start; + while (bytes.get(end) != 0) { + end++; + } + byte[] encoded = new byte[end - start]; + bytes.get(encoded); + bytes.get(); + return new String(encoded, StandardCharsets.US_ASCII); + } + + private static short[] halves(float... values) { + short[] result = new short[values.length]; + for (int i = 0; i < values.length; i++) { + result[i] = Float.floatToFloat16(values[i]); + } + return result; + } + + private record Attribute(String type, ByteBuffer value) { + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java index 6f7f02e5..99c92ca8 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java @@ -33,17 +33,18 @@ void reflectedMaterialHeaderMatchesHotAbi() { } @Test - void reflectedWorldPushConstantsIncludeLightBuffersAndDebugView() { - // 10 uint64_t addresses (world/table/material, 5 light buffers, path queue) + 2 uint. + void reflectedWorldPushConstantsIncludeLightBuffersAndFrameIndex() { + // 10 uint64_t addresses (world/table/material, 5 light buffers, path queue) + frameIndex + // plus four bytes of reflected trailing struct padding. assertEquals(88, WorldPushConstantsData.BYTE_SIZE); ByteBuffer data = ByteBuffer.allocateDirect(WorldPushConstantsData.BYTE_SIZE) .order(ByteOrder.nativeOrder()); - new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11, 12).write(data); + new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11).write(data); assertEquals(4L, data.getLong(24)); // materialTableAddr assertEquals(5L, data.getLong(32)); // lightBufAddr assertEquals(9L, data.getLong(64)); // lightGridSpanAddr (last of the light-buffer addresses) assertEquals(10L, data.getLong(72)); // pathQueueAddr assertEquals(11, data.getInt(80)); // frameIndex - assertEquals(12, data.getInt(84)); // debugView + assertEquals(0, data.getInt(84)); // reflected trailing padding is deterministically zeroed } } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java index 05006eb3..632d48fa 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java @@ -13,26 +13,25 @@ final class RtMaterialOverridesTest { @Test void parsesVersionedExtensibleMaterialProperties() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"block":"minecraft:blue_stained_glass", + {"format":2,"match":{"block":"minecraft:blue_stained_glass", "sprite":"minecraft:block/blue_stained_glass"},"model":"dielectric", "base":{"roughness":0.06,"metalness":0.0}, - "emission":{"strength":2.0,"color_source":"albedo"}, + "emission":{"strength_cd_m2":2000.0,"color_source":"albedo"}, "transmission":{"factor":1.0,"ior":1.52}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/glass.json")); + """).getAsJsonObject(), Identifier.parse("test:materials/glass.json")); assertEquals(Identifier.parse("minecraft:block/blue_stained_glass"), rule.sprite()); assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, rule.model()); assertEquals(1.52f, rule.ior()); - assertEquals(2.0f, rule.emissionStrength()); + assertEquals(2000.0f, rule.emissionStrengthCdM2()); RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, RtMaterialDesc.Source.LAB_PBR, RtMaterialRegistry.FEATURE_SPEC, 0.8f, 0.0f, 1.0f, 0.0f, RtMaterialDesc.EmissionSource.LAB_PBR, 5.0f, new RtMaterialDesc.EmissionSummary(0.2f, 0.1f, 0.05f, 0.1f, 0.5f)); RtMaterialDesc applied = rule.apply(base); assertEquals(RtMaterialDesc.Source.OVERRIDE, applied.source()); - // emission.strength is a multiplier on the already-resolved strength: it scales LabPBR's own - // emission rather than replacing it, so the source and summary stay exactly base's. + // strength_cd_m2 replaces the level but keeps LabPBR's mask/source/summary. assertEquals(RtMaterialDesc.EmissionSource.LAB_PBR, applied.emissionSource()); - assertEquals(10.0f, applied.emissionStrength()); + assertEquals(2000.0f, applied.emissionStrength()); assertEquals(base.emissionSummary(), applied.emissionSummary()); assertEquals(0.06f, applied.roughness()); assertEquals(1.0f, applied.transmission()); @@ -44,25 +43,25 @@ void transmissionIorOverridesTheBuiltInIndex() { RtMaterialDesc.Source.HEURISTIC, 0, 0.0025f, 0.0f, RtDielectrics.GLASS_IOR, 1.0f, RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"somemod:block/crystal"}, + {"format":2,"match":{"sprite":"somemod:block/crystal"}, "model":"dielectric","transmission":{"ior":2.417}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + """).getAsJsonObject(), Identifier.parse("test:materials/crystal.json")); RtMaterialDesc applied = rule.apply(glassBase); assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, applied.model()); assertEquals(2.417f, applied.ior()); // Omitting ior on a rule that does not change the model leaves the base index alone. var silent = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"somemod:block/crystal"},"base":{"roughness":0.5}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + {"format":2,"match":{"sprite":"somemod:block/crystal"},"base":{"roughness":0.5}} + """).getAsJsonObject(), Identifier.parse("test:materials/crystal.json")); assertEquals(RtDielectrics.GLASS_IOR, silent.apply(glassBase).ior()); } @Test void waterModelKeepsItsOwnIndexWhenSelectedByName() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"somemod:block/pool"},"model":"water"} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/pool.json")); + {"format":2,"match":{"sprite":"somemod:block/pool"},"model":"water"} + """).getAsJsonObject(), Identifier.parse("test:materials/pool.json")); RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, RtMaterialDesc.Source.HEURISTIC, 0, 0.8f, 0.0f, 1.0f, 0.0f, RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); @@ -75,8 +74,8 @@ void waterModelKeepsItsOwnIndexWhenSelectedByName() { @Test void emissionStrengthCannotForceEmissionOntoANonEmissiveMaterial() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:block/stone"}, - "emission":{"strength":5.0}} + {"format":2,"match":{"sprite":"minecraft:block/stone"}, + "emission":{"strength_cd_m2":5000.0}} """).getAsJsonObject(), Identifier.parse("test:boost.json")); RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, RtMaterialDesc.Source.HEURISTIC, 0, 0.8f, 0.0f, 1.0f, 0.0f, @@ -91,31 +90,39 @@ void emissionStrengthCannotForceEmissionOntoANonEmissiveMaterial() { @Test void rejectsUnknownVersionsAndOutOfRangePhysicalValues() { assertThrows(IllegalArgumentException.class, () -> RtMaterialOverrides.parse( - JsonParser.parseString("{\"format\":2,\"match\":{\"sprite\":\"minecraft:block/stone\"}}") + JsonParser.parseString("{\"format\":1,\"match\":{\"sprite\":\"minecraft:block/stone\"}}") .getAsJsonObject(), Identifier.parse("test:bad.json"))); assertThrows(IllegalArgumentException.class, () -> RtMaterialOverrides.parse( - JsonParser.parseString("{\"format\":1,\"match\":{\"sprite\":\"minecraft:block/stone\"}," + JsonParser.parseString("{\"format\":2,\"match\":{\"sprite\":\"minecraft:block/stone\"}," + "\"base\":{\"metalness\":2}}") .getAsJsonObject(), Identifier.parse("test:bad.json"))); } @Test - void clampsOutOfRangeEmissionStrengthInsteadOfThrowing() { + void clampsOutOfRangeEmissionLuminanceInsteadOfThrowing() { var rule = RtMaterialOverrides.parse( - JsonParser.parseString("{\"format\":1,\"match\":{\"sprite\":\"minecraft:block/stone\"}," - + "\"emission\":{\"strength\":5.1}}") + JsonParser.parseString("{\"format\":2,\"match\":{\"sprite\":\"minecraft:block/stone\"}," + + "\"emission\":{\"strength_cd_m2\":70000}}") .getAsJsonObject(), Identifier.parse("test:clamp.json")); - assertEquals(5.0f, rule.emissionStrength()); + assertEquals(65504.0f, rule.emissionStrengthCdM2()); + } + + @Test + void rejectsLegacyMultiplierField() { + assertThrows(IllegalArgumentException.class, () -> RtMaterialOverrides.parse( + JsonParser.parseString("{\"format\":2,\"match\":{\"sprite\":\"minecraft:block/stone\"}," + + "\"emission\":{\"strength\":3}}") + .getAsJsonObject(), Identifier.parse("test:legacy.json"))); } @Test void spriteWideRulesApplyToCompiledEntityResources() { var entityRule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:entity/zombie/zombie"}, + {"format":2,"match":{"sprite":"minecraft:entity/zombie/zombie"}, "base":{"roughness":0.7}} """).getAsJsonObject(), Identifier.parse("test:entity.json")); var blockRule = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:entity/zombie/zombie", + {"format":2,"match":{"sprite":"minecraft:entity/zombie/zombie", "block":"minecraft:stone"}} """).getAsJsonObject(), Identifier.parse("test:block.json")); diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureCurveTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureCurveTest.java new file mode 100644 index 00000000..7b39f82d --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureCurveTest.java @@ -0,0 +1,51 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class RtExposureCurveTest { + private static final float EPSILON = 1.0e-5f; + + @Test + void fullPresetReproducesLegacyFullAdaptation() { + RtExposure.ExposureCurve curve = RtExposure.parseCurve("full"); + + assertEquals(0.0f, curve.compensationAt(-20.0f), EPSILON); + assertEquals(0.0f, curve.compensationAt(1.5f), EPSILON); + assertEquals(1.0f, curve.effectiveSlopeAt(1.5f), EPSILON); + } + + @Test + void pointsAreSortedAndInterpolatedPiecewise() { + RtExposure.ExposureCurve curve = + RtExposure.parseCurve("4:0.4, 0:0, -6:-2.0, -3:-0.8"); + + assertEquals(-6.0f, curve.scene0(), EPSILON); + assertEquals(4.0f, curve.scene3(), EPSILON); + assertEquals(-0.4f, curve.compensationAt(-1.5f), EPSILON); + assertEquals(1.0f - (0.8f / 3.0f), curve.effectiveSlopeAt(-1.5f), EPSILON); + } + + @Test + void endpointCompensationIsConstantOutsideAuthoredDomain() { + RtExposure.ExposureCurve curve = + RtExposure.parseCurve("-6:-2.0, -3:-0.8, 0:0, 4:0.4"); + + assertEquals(-2.0f, curve.compensationAt(-10.0f), EPSILON); + assertEquals(0.4f, curve.compensationAt(8.0f), EPSILON); + assertEquals(1.0f, curve.effectiveSlopeAt(-10.0f), EPSILON); + assertEquals(1.0f, curve.effectiveSlopeAt(8.0f), EPSILON); + } + + @Test + void malformedOrDuplicatePointsAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> RtExposure.parseCurve("-6:-2, -3:-0.8, 0:0")); + assertThrows(IllegalArgumentException.class, + () -> RtExposure.parseCurve("-6:-2, -3:-0.8, 0:0, 0:0.4")); + assertThrows(IllegalArgumentException.class, + () -> RtExposure.parseCurve("-6:-2, -3:nope, 0:0, 4:0.4")); + } +} diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..eb561617 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Developer tooling for Caustica.""" diff --git a/tools/bake_display_lut.py b/tools/bake_display_lut.py new file mode 100644 index 00000000..a06c055e --- /dev/null +++ b/tools/bake_display_lut.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Bakes the RT renderer's ACES display-transform LUTs and imports its packaged LMT. + +The renderer feeds these LUTs scene-linear ACEScg (AP1/D60) radiance, already multiplied by the +auto-exposure scalar (RtExposure). The default look package's scene-referred LMT runs first, followed by ACES +2.0 output transform, gamut mapping, tone scale, and display transfer function. + +One SDR LUT (BT.709, sRGB OETF) plus one HDR LUT per REC2020 mastering-nits target ACES 2.0 ships +(500/1000/2000/4000 -- see HDR_REC2020_NITS). Hdr.PEAK_NITS selects one of the baked HDR LUTs. +The package LMT is a separate log-to-log scene-referred table, so it does not duplicate all five +output LUTs. + +Requires: pip install opencolorio numpy (tested with opencolorio 2.5.2 / numpy 2.5.1, Python 3.14) + +Usage: + python tools/bake_display_lut.py + python tools/bake_display_lut.py --import-lmt path/to/resolve-export.cube + +Regenerate whenever SHAPER_LO/HI, LUT_SIZE, or the OCIO config/view below changes. The baked +.bin files are committed binary resources. The display transforms live in +src/main/resources/caustica/color/luts/; the sole LMT lives beside the default package JSON. +""" +import argparse +import hashlib +import struct +import sys +from pathlib import Path + +import numpy as np +import PyOpenColorIO as OCIO + +# OCIO 2.2+ ships this config compiled into the library -- no external config file/network fetch +# needed. This is the renderer's pinned ACES 2.0 color configuration. +OCIO_BUILTIN_CONFIG = "cg-config-v4.0.0_aces-v2.0_ocio-v2.5" +SOURCE_SPACE = "ACEScg" # matches the renderer's scene-linear ACEScg/AP1/D60 working space + +# Log2 shaper range, in stops relative to linear 1.0. Matches LOG_MIN/LOG_MAX in +# shaders/display/exposure_hist.comp and exposure_resolve.comp -- same renderer quantity metered +# in both places, so the same bounds. Input is exposed scene-linear (may exceed 1.0 for +# unclipped-highlight emitters), so headroom above 0 stops matters, not just below. +SHAPER_LO_STOPS = -12.0 +SHAPER_HI_STOPS = 12.0 + +LUT_SIZE = 65 # samples per axis; N^3 total +OUT_DIR = Path(__file__).resolve().parent.parent / "src/main/resources/caustica/color/luts" +LOOK_PACKAGE_DIR = ( + Path(__file__).resolve().parent.parent / "src/main/resources/caustica/color/looks/default" +) + +# ACES 2.0's built-in BT.2020 transforms use these fixed HDR mastering targets. +HDR_REC2020_NITS = [500, 1000, 2000, 4000] + +LUTS = [ + dict( + name="sdr_aces2_rec709", + display_view=("sRGB - Display", "ACES 2.0 - SDR 100 nits (Rec.709)"), + note="SDR output, BT.709 display code values (renderer's existing rgba8 gamma-encoded " + "presentation path expects sRGB-OETF-encoded BT.709, same as the AgX path it replaces).", + ), +] + [ + dict( + name=f"hdr_aces2_rec2020_{nits}nit", + # Composed directly from BuiltinTransform pieces (verified bit-exact against the config's + # own Display/View path for 1000nit, the only nits value pre-wired as a named View) rather + # than via getProcessor(display, view, ...): ACES2065-1_to_CIE-XYZ-D65 is the ACES 2 output + # transform itself; CIE-XYZ-D65_to_REC.2100-PQ is the final display encode, matching + # VK_COLOR_SPACE_HDR10_ST2084_EXT's container exactly, so no separate gamut step or + # pqEncode() needed at sample time. + builtin_chain=[ + ("colorspace", ("ACEScg", "ACES2065-1")), + ("builtin", f"ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - HDR-{nits}nit-REC2020_2.0"), + ("builtin", "DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ"), + ], + note=f"HDR output, {nits} nit peak, BT.2020 primaries + ST.2084/PQ-encoded code values.", + ) + for nits in HDR_REC2020_NITS +] + + +def shaper_axis(size: int) -> np.ndarray: + t = np.linspace(0.0, 1.0, size, dtype=np.float64) + stops = SHAPER_LO_STOPS + t * (SHAPER_HI_STOPS - SHAPER_LO_STOPS) + return np.exp2(stops) + + +def make_processor(cfg: "OCIO.Config", spec: dict): + if "display_view" in spec: + display, view = spec["display_view"] + return cfg.getProcessor(SOURCE_SPACE, display, view, OCIO.TRANSFORM_DIR_FORWARD).getDefaultCPUProcessor() + grp = OCIO.GroupTransform() + for kind, arg in spec["builtin_chain"]: + if kind == "colorspace": + src, dst = arg + grp.appendTransform(OCIO.ColorSpaceTransform(src=src, dst=dst)) + elif kind == "builtin": + grp.appendTransform(OCIO.BuiltinTransform(style=arg)) + else: + raise ValueError(f"unknown chain step kind: {kind}") + return cfg.getProcessor(grp).getDefaultCPUProcessor() + + +def read_shaper_cube(path: Path) -> tuple[int, np.ndarray, str | None]: + """Read a normalized .cube as a log-shaper-to-log-shaper scene look. + + A .cube file does not carry reliable color-space semantics. Imported creative curves are therefore + defined over the renderer's normalized -12..+12 EV shaper coordinates, not over linear ACEScg values: + applying a conventional [0,1] cube directly to scene-linear input would clamp all values above 1.0. + The file order (R fastest, then G, then B) already matches the 3D Vulkan image layout used by write_lut. + """ + size = None + title = None + domain_min = np.zeros(3, dtype=np.float64) + domain_max = np.ones(3, dtype=np.float64) + rows: list[list[float]] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + fields = line.split() + directive = fields[0].upper() + if directive == "TITLE": + title = line[len(fields[0]):].strip().strip('"') + elif directive == "LUT_3D_SIZE": + if len(fields) != 2: + raise ValueError(f"{path}:{line_number}: LUT_3D_SIZE requires one integer") + size = int(fields[1]) + elif directive == "LUT_1D_SIZE": + raise ValueError(f"{path}:{line_number}: 1D LUTs are not supported") + elif directive in ("DOMAIN_MIN", "DOMAIN_MAX"): + if len(fields) != 4: + raise ValueError(f"{path}:{line_number}: {directive} requires three values") + domain = np.asarray([float(value) for value in fields[1:]], dtype=np.float64) + if directive == "DOMAIN_MIN": + domain_min = domain + else: + domain_max = domain + else: + if len(fields) != 3: + raise ValueError(f"{path}:{line_number}: unknown directive or malformed RGB row") + try: + rows.append([float(value) for value in fields]) + except ValueError as exc: + raise ValueError(f"{path}:{line_number}: unknown directive {fields[0]!r}") from exc + + if size is None or size < 2: + raise ValueError(f"{path}: missing or invalid LUT_3D_SIZE") + expected_rows = size ** 3 + if len(rows) != expected_rows: + raise ValueError(f"{path}: expected {expected_rows} RGB rows for {size}^3, got {len(rows)}") + if not np.allclose(domain_min, 0.0) or not np.allclose(domain_max, 1.0): + raise ValueError( + f"{path}: imported shaper cubes must use DOMAIN_MIN 0 0 0 and DOMAIN_MAX 1 1 1") + + rgb = np.asarray(rows, dtype=np.float32).reshape(size, size, size, 3) + if not np.isfinite(rgb).all(): + raise ValueError(f"{path}: LUT contains non-finite values") + if float(rgb.min()) < 0.0 or float(rgb.max()) > 1.0: + raise ValueError(f"{path}: shaper-domain LUT output must stay within [0,1]") + return size, rgb, title + + +def bake_one(cfg: "OCIO.Config", spec: dict, size: int) -> np.ndarray: + axis = shaper_axis(size) # same axis reused for R, G, B -- the shaper is a per-channel diagonal + # Grid shape (N,N,N,3) with R fastest-varying (x), G next (y), B slowest (z). This matches + # VkBufferImageCopy's row-major layout for a 3D image of extent (N,N,N): x is the contiguous + # texel run, so keep that axis == index 0 of the meshgrid arrays below, i.e. last numpy axis + # before the channel axis. See decodeToneLut()'s texCoord order in display.comp. + b, g, r = np.meshgrid(axis, axis, axis, indexing="ij") # b,g,r all shape (N,N,N) + grid = np.stack([r, g, b], axis=-1).astype(np.float32) # (N,N,N,3), fastest axis = r = x + + cpu = make_processor(cfg, spec) + + flat = grid.reshape(-1, 3).copy() + cpu.applyRGB(flat) + flat = np.clip(flat, 0.0, 1.0) + return flat.reshape(size, size, size, 3) + + +def write_lut(path: Path, size: int, rgb: np.ndarray) -> None: + # RGBA16F texel data (alpha unused, kept 1.0 for a well-defined value + simpler Vulkan format + # matching: R16G16B16_SFLOAT support is patchy, RGBA16F is universal). Header is self-describing + # so the Java loader doesn't need a second source of truth for size/shaper range. + rgba = np.concatenate([rgb, np.ones((size, size, size, 1), dtype=np.float32)], axis=-1) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + f.write(b"CLUT") + f.write(struct.pack(" None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--import-lmt", + type=Path, + metavar="PATH", + help="replace the default look package's LMT from a normalized log-shaper .cube, then exit", + ) + args = parser.parse_args() + + if args.import_lmt is not None: + source_path = args.import_lmt + size, rgb, title = read_shaper_cube(source_path) + digest = hashlib.sha256(source_path.read_bytes()).hexdigest() + print( + f"importing default-package LMT: {size}^3 normalized log-shaper cube" + f"{f' ({title})' if title else ''}; source SHA-256={digest}" + ) + write_lut(LOOK_PACKAGE_DIR / "lmt.bin", size, rgb) + return + + cfg = OCIO.Config.CreateFromBuiltinConfig(OCIO_BUILTIN_CONFIG) + for spec in LUTS: + print(f"baking {spec['name']}: {spec['note']}") + rgb = bake_one(cfg, spec, LUT_SIZE) + write_lut(OUT_DIR / f"{spec['name']}.bin", LUT_SIZE, rgb) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/inspect_exr.py b/tools/inspect_exr.py new file mode 100644 index 00000000..683a2114 --- /dev/null +++ b/tools/inspect_exr.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Inspect a Caustica F2 EXR and print its exposure/recovery metadata.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import OpenEXR + + +CAUSTICA_KEYS = ( + "causticaColorSpace", + "causticaEncoding", + "causticaRecovery", + "causticaPreExposure", + "causticaResidualExposure", + "causticaAbsoluteExposure", + "causticaExposureMode", + "causticaEvScene", + "causticaEvTarget", + "causticaEvApplied", + "causticaLookIntent", + "causticaFrame", +) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Print dimensions, channels, and Caustica metadata from an OpenEXR screenshot." + ) + parser.add_argument("image", type=Path) + args = parser.parse_args() + + with OpenEXR.File(str(args.image), separate_channels=True, header_only=True) as image: + header = image.header() + window = header["dataWindow"] + width = int(window[1][0] - window[0][0] + 1) + height = int(window[1][1] - window[0][1] + 1) + print(f"{args.image}: {width}x{height}") + print("channels:", ", ".join(channel.name for channel in header["channels"])) + for key in CAUSTICA_KEYS: + if key in header: + print(f"{key}: {header[key]}") + + +if __name__ == "__main__": + main() diff --git a/tools/test_bake_display_lut.py b/tools/test_bake_display_lut.py new file mode 100644 index 00000000..aba7c75e --- /dev/null +++ b/tools/test_bake_display_lut.py @@ -0,0 +1,67 @@ +import struct +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from tools import bake_display_lut as baker + + +class BakedLookResourceTest(unittest.TestCase): + def test_default_package_lmt_has_expected_header_and_finite_payload(self): + path = baker.LOOK_PACKAGE_DIR / "lmt.bin" + data = path.read_bytes() + magic, version, size, lo_stops, hi_stops = struct.unpack_from("<4sIIff", data) + self.assertEqual(magic, b"CLUT", path) + self.assertEqual(version, 1, path) + self.assertEqual(size, 65, path) + self.assertEqual(lo_stops, baker.SHAPER_LO_STOPS, path) + self.assertEqual(hi_stops, baker.SHAPER_HI_STOPS, path) + payload = np.frombuffer(data, dtype=np.float16, offset=20) + self.assertEqual(payload.size, size ** 3 * 4, path) + self.assertTrue(np.isfinite(payload).all(), path) + self.assertGreaterEqual(float(payload.min()), 0.0, path) + self.assertLessEqual(float(payload.max()), 1.0, path) + rgba = payload.reshape(size, size, size, 4) + axis = np.linspace(0.0, 1.0, size) + b, g, r = np.meshgrid(axis, axis, axis, indexing="ij") + identity = np.stack([r, g, b], axis=-1) + self.assertGreater(float(np.max(np.abs(rgba[..., :3] - identity))), 0.005, path) + + +class CubeImportTest(unittest.TestCase): + def test_reads_normalized_cube_in_r_fastest_order(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "curve.cube" + rows = [ + "0 0 0", "1 0 0", + "0 1 0", "1 1 0", + "0 0 1", "1 0 1", + "0 1 1", "1 1 1", + ] + path.write_text( + 'TITLE "Test Curve"\nLUT_3D_SIZE 2\n' + "\n".join(rows) + "\n", + encoding="utf-8", + ) + + size, rgb, title = baker.read_shaper_cube(path) + + self.assertEqual(size, 2) + self.assertEqual(title, "Test Curve") + np.testing.assert_array_equal(rgb[0, 0, 1], np.array([1, 0, 0], dtype=np.float32)) + np.testing.assert_array_equal(rgb[1, 1, 0], np.array([0, 1, 1], dtype=np.float32)) + + def test_rejects_non_normalized_domain(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "linear.cube" + path.write_text( + "LUT_3D_SIZE 2\nDOMAIN_MAX 16 16 16\n" + ("0 0 0\n" * 8), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "must use DOMAIN_MIN"): + baker.read_shaper_cube(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..78bb6eb3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,72 @@ +version = 1 +revision = 2 +requires-python = "==3.14.*" + +[[package]] +name = "caustica-tools" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencolorio" }, + { name = "openexr" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.5,<3" }, + { name = "opencolorio", specifier = ">=2.5,<3" }, + { name = "openexr", specifier = ">=3.4,<4" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "opencolorio" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/ea/9d930df6740f9b09b0b342f40a5ef165da5050141e496081ef80b302e566/opencolorio-2.5.2.tar.gz", hash = "sha256:fecebd0914089b0c8238c55648f8eb2ccd2702ab4b2eea53856a0e368ded8262", size = 13946157, upload-time = "2026-05-13T18:49:47.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/96/ac466b577fe45334381848ef5cebbcaa1ad7c716a2f225627b53362f0a0d/opencolorio-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:43957589095a3fa5cbfa1faa3d7da39da6c898b9a2d98207adc5ab92734e10b3", size = 6358779, upload-time = "2026-05-13T18:49:28.176Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/22d9fc33dafd72f40f9320db119e68c6ca8e5031702f334ffa8f7814cb0b/opencolorio-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:142eafd6c31aa7da3c97d2af5ce0403ccd2859be1252336ef9a2aea9a5082dce", size = 5713109, upload-time = "2026-05-13T18:49:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/d8bff675d02fada4b8b68fb5c2334de11f8829ccc3afd45aa42e75d237a0/opencolorio-2.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:91a831f3ebcb1b3a67320a022f927c6a1811df68bad8d2c9730462b76b113b88", size = 6264662, upload-time = "2026-05-13T18:49:31.611Z" }, + { url = "https://files.pythonhosted.org/packages/dc/45/cd3b391843bb4dd2fbe4b8e324472cbbdcb5d198abfda0d81948a83532bb/opencolorio-2.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5f8c408f6dfb8561f6b30471eb0591742921edbc8b9e4caaa2b92cebe580c38", size = 6827981, upload-time = "2026-05-13T18:49:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/8e/90/6e8f3a0bba909754112514fce6f2baf9d5ba99fff71cc2bea00e621e6bba/opencolorio-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ede691d5263d3fc3c796bfff29f07042ac724d25675b031dd305ff5461366f", size = 6943716, upload-time = "2026-05-13T18:49:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/cd/db/5327b1e96d4bdfd5793976f8626edc9c81497b95f50b67f5a5f8a11ce6f0/opencolorio-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:076536f95c02911dcd32a5fd7033e5c3261d88a1aca7e40f4fd6093b99622f4b", size = 4032919, upload-time = "2026-05-13T18:49:36.219Z" }, +] + +[[package]] +name = "openexr" +version = "3.4.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c4/11e2377b53c195136e76186c98ea1756a92d4fba01f241bc8741868d6833/openexr-3.4.13.tar.gz", hash = "sha256:ae5135e39ff0b9086a56e02f3d8b5cf52c5ca7cfcde5e57080b1c12c82e3ec8a", size = 25618192, upload-time = "2026-06-19T19:13:46.099Z" }