Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0c428be
2020
ComfyFluffy Jul 27, 2026
0648e81
Replace AgX/PQ-rolloff tonemap with baked ACES 2.0 LUTs; make HDR ful…
ComfyFluffy Jul 27, 2026
6d957d3
Remove legacy AgX/PQ-rolloff tonemap; migrate display.comp to Slang
ComfyFluffy Jul 27, 2026
d36f4b6
Refactor debug presentation after exposure
ComfyFluffy Jul 27, 2026
ec71863
Add exposure debug visualizations
ComfyFluffy Jul 27, 2026
e50ce9f
Harden exposure histogram metering
ComfyFluffy Jul 27, 2026
042f054
Add spatially weighted exposure metering
ComfyFluffy Jul 27, 2026
ea255ed
Add configurable exposure adaptation curve
ComfyFluffy Jul 27, 2026
6402172
exposure
ComfyFluffy Jul 28, 2026
fe87242
recreate swapchain on hdr switch
ComfyFluffy Jul 28, 2026
afb3ba1
gamma
ComfyFluffy Jul 28, 2026
0cf6a6e
acescg
ComfyFluffy Jul 29, 2026
6b92c46
exposure: EV100 + pre-exposure, F3 entry
ComfyFluffy Jul 29, 2026
19f9328
physical anchored lighting
ComfyFluffy Jul 29, 2026
ae31131
tune brightness
ComfyFluffy Jul 30, 2026
1019d0e
add lmts/exr
ComfyFluffy Jul 30, 2026
c8a7234
hdr metadata/custom lmt
ComfyFluffy Jul 30, 2026
3cdc33b
update
ComfyFluffy Jul 31, 2026
9da712e
tune moon
ComfyFluffy Jul 31, 2026
f0296a5
look package
ComfyFluffy Jul 31, 2026
b104906
bloom & sky
ComfyFluffy Jul 31, 2026
30030b3
opus new sky and bloom fix
ComfyFluffy Jul 31, 2026
7d7ed53
fix & horizon soften
ComfyFluffy Jul 31, 2026
7c80f8f
organize resources
ComfyFluffy Jul 31, 2026
f2fdf83
organize shader pipelines and reflect bindings
ComfyFluffy Jul 31, 2026
55217af
limit bloom & fix water
ComfyFluffy Jul 31, 2026
2ac6db1
fix exposure pre-exposure pipeline
ComfyFluffy Jul 31, 2026
c82facd
fix display pipeline review issues
ComfyFluffy Jul 31, 2026
332d3c7
simplify comments
ComfyFluffy Jul 31, 2026
88c6cc1
Simplify stepped HDR peak config
ComfyFluffy Jul 31, 2026
763f2a7
config intChoice
ComfyFluffy Aug 1, 2026
8d63f63
Remove exposure adaptation deadband
ComfyFluffy Aug 1, 2026
ab05af3
fix and simplify
ComfyFluffy Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
.gradle/
.idea/
.DS_Store
/build/
/buildSrc/build/
native/ngx_shim/build/
run/
out/
*.log
*.class
__pycache__/
*.pyc
.venv/
.uv-cache/
third_party/
bin/
/logs
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 49 additions & 44 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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}"
Expand Down Expand Up @@ -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" : ""
Expand All @@ -95,46 +96,40 @@ def resolveVulkanTool = { String name ->
abstract class CompileShaders extends DefaultTask {
@InputDirectory abstract DirectoryProperty getSrcDir()
@OutputDirectory abstract DirectoryProperty getOutDir()
@Input abstract Property<String> getGlslang()
@Input abstract Property<String> getSpirvVal()
@Input abstract Property<String> 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 <name>.<stage>.slang (world.rahit.slang → world.rahit.spv); module files
// Slang stages are named <name>.<stage>.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<String> 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<String> 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)
Expand All @@ -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): "
Expand All @@ -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)
}
}
Expand All @@ -188,30 +184,39 @@ 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")
}

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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> getSlangc()
@OutputDirectory abstract DirectoryProperty getOutDir()

@Inject abstract ExecOperations getExecOps()

private static final List<Map> 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<String, Integer>()
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..<indices.size()).toList()) {
throw new GradleException("${spec.source} descriptor set ${set} is not contiguous: ${indices}")
}
}

if (spec.prefix.toString().startsWith("OVERLAY_")) {
constants[spec.prefix as String] = locations.VALUE.index
def overlaySet = constants.putIfAbsent("OVERLAY_SET", locations.VALUE.set)
if (overlaySet != null && overlaySet != locations.VALUE.set) {
throw new GradleException("overlay shaders disagree on descriptor set: ${overlaySet} and ${locations.VALUE.set}")
}
} else if (spec.prefix == "WORLD") {
def ordinary = locations.findAll { suffix, ignored -> !(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")
}
}
Loading
Loading