DirectLightingQualitySmoke.kt
src/jvmMain/kotlin/afield/graphics/render/lighting/DirectLightingQualitySmoke.kt · 151 lines
package afield.graphics.render.lighting
import afield.document.graph.Box
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.util.Options
import afield.graphics.vulkan.util.Window
import afield.math.linalg.Vector3f
import org.json.JSONObject
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.math.*
import kotlin.system.exitProcess
object DirectLightingQualitySmoke
Full-resolution, linear-light quality checks and actual GPU documentation captures. Separate from the small estimator acceptance suite so its statistical tests stay inexpensive.
@JvmStatic fun main(args: Array<String>)
body — 132 lines, from line 19
@JvmStatic fun main(args: Array<String>) {
Options.enableVR=false; Options.enableHDR=false; Options.doVulkanSDK()
val directory=File("build/lighting-experiment/quality").also { it.mkdirs() }
val window=Window("Field · direct lighting")
val renderer=Renderer(window)
val box=Box("lighting-quality")
box.setPropertyRaw("renderer",renderer,doResync=false)
box.setPropertyRaw("window",window,doResync=false)
try {
Box.withBoxContextNow(box) {
DirectLightingDemo(renderer,1280,720).use { demo ->
val light=demo.lighting
fun pump(frames: Int=1) { repeat(frames) { window.pollEvents();renderer.render() } }
fun save(name: String,pixels: FloatArray) = save(directory,name,pixels,light.width,light.height)
light.samples=64
light.mode=DirectLighting.Mode.UNSHADOWED;pump();save("unshadowed",light.readLinear())
light.mode=DirectLighting.Mode.RAY_QUERY;pump();save("shadow",light.readLinear())
demo.animate(2.0);pump();save("moved-light",light.readLinear())
for(view in listOf(DirectLighting.View.NORMALS,DirectLighting.View.VISIBILITY)) {
light.view=view;pump();save(view.name.lowercase(),light.readLinear())
}
light.view=DirectLighting.View.RADIANCE
val settings=RtxdiSettings({ demo.bundle.scene.camera.modelView() },{ demo.bundle.scene.projection.evaluateUniform() })
fun emitters(time: Double=0.0)=List(16) { i ->
val a=i*2.0*PI/16+time
IdentifiedAreaLight(100+i,AreaEmitter(Vector3f((cos(a)*3+sin(time)*1.5).toFloat(),4.5f,(sin(a)*2).toFloat()),
Vector3f(0.6f,0f,0f),Vector3f(0f,0f,0.6f),Vector3f(180f/16)))
}
settings.commitLights(emitters())
light.enableRtxdi(RtxdiProgram.load(),settings).getOrThrow()
light.mode=DirectLighting.Mode.RTXDI_REFERENCE;light.samples=256
val reference=FloatArray(light.width*light.height*4)
repeat(16) {
pump();val sample=light.readLinear()
for(i in reference.indices)reference[i]+=sample[i]/16f
}
save("rtxdi-reference",reference)
fun mse(value: FloatArray): Double {
var error=0.0;var count=0
for(i in value.indices step 4)if(reference[i+3]>=0)for(c in 0..2) {
error+=(value[i+c]-reference[i+c]).toDouble().pow(2);count++
}
return error/count
}
fun mean(value: FloatArray): Double {
var total=0.0;var count=0
for(i in value.indices step 4)if(reference[i+3]>=0)for(c in 0..2) { total+=value[i+c];count++ }
return total/count
}
light.mode=DirectLighting.Mode.RTXDI;light.samples=16
settings.temporal=false;settings.spatialSamples=0;pump();save("rtxdi-initial",light.readLinear())
settings.temporal=true;settings.spatialSamples=8;settings.denoisePasses=3;pump(32)
val raw=light.readLinear();val reconstructed=light.readPresentedLinear()
check(reconstructed.all { it.isFinite() })
save("rtxdi-full",raw);save("rtxdi-reconstructed",reconstructed)
check(mse(reconstructed)<mse(raw)*0.3) { "Reconstruction does not materially reduce pixel error" }
check(abs(mean(reconstructed)-mean(reference))/mean(reference)<0.02) { "Reconstruction changes scene energy by over 2%" }
val average=FloatArray(reference.size)
repeat(64) {
pump();val sample=light.readLinear()
for(i in average.indices)average[i]+=sample[i]/64f
}
check(mse(average)<mse(raw)*0.35) { "Raw time average does not converge toward the reference" }
val report=JSONObject().put("width",light.width).put("height",light.height)
.put("referenceSamples",4096).put("candidates",light.samples).put("spatialNeighbors",8)
.put("denoisePasses",3).put("rawMse",mse(raw)).put("reconstructedMse",mse(reconstructed))
.put("raw64FrameMeanMse",mse(average)).put("referenceMean",mean(reference))
.put("reconstructedMean",mean(reconstructed))
// Diagnostics and the raw view bypass reconstruction exactly.
for(view in listOf(DirectLighting.View.RAW_RADIANCE,DirectLighting.View.HISTORY_REJECTION,
DirectLighting.View.LIGHT_ID,DirectLighting.View.RESERVOIR_M,DirectLighting.View.RESERVOIR_WEIGHT,DirectLighting.View.MOTION)) {
light.view=view;pump()
check(light.readLinear().contentEquals(light.readPresentedLinear())) { "Filter altered $view" }
save("rtxdi-${view.name.lowercase().replace('_','-')}",light.readLinear())
}
light.view=DirectLighting.View.RADIANCE
settings.denoisePasses=0;pump()
check(light.readLinear().contentEquals(light.readPresentedLinear()))
settings.denoisePasses=3
// Filtering must not blur the source term of a fractional effect blend.
settings.temporal=false;settings.spatialSamples=0;settings.animateSeed=false
light.effectWeight=1f;pump();val full=light.readPresentedLinear()
light.effectWeight=0f;pump();val source=light.readPresentedLinear()
check(source.contentEquals(light.readLinear()))
light.effectWeight=0.35f;pump();val partial=light.readPresentedLinear()
check(partial.indices.filter { it%4!=3 }.all { i -> abs(partial[i]-(source[i]*0.65f+full[i]*0.35f))<1e-5 })
light.effectWeight=Float.MIN_VALUE;pump()
val tiny=light.readPresentedLinear()
check(tiny.all { it.isFinite() })
check(tiny.indices.filter { it%4!=3 }.all { i -> abs(tiny[i]-source[i])<1e-6 })
light.effectWeight=1f;settings.temporal=true;settings.spatialSamples=8;settings.animateSeed=true
// No retained image history: the very first frame after deleting lights is dark.
settings.commitLights(emptyList());pump()
val dark=light.readPresentedLinear()
check((dark.indices step 4).filter { dark[it+3]>=0 }.all { i -> (0..2).all { dark[i+it]==0f } })
settings.commitLights(emitters());settings.resetHistory("restore scene");pump(32)
light.clearRtxdiTimings();pump(96)
val timings=JSONObject()
light.rtxdiTimings.forEach { (stage,all) ->
val values=all.sorted()
if(values.isNotEmpty())timings.put(stage,JSONObject().put("count",values.size)
.put("p50",values[values.size/2]).put("p95",values[(values.size*0.95).toInt()]))
}
report.put("gpuMilliseconds",timings).put("workingBytes",light.workingBytes)
if("--clip" in args) {
File(directory,"clip").mkdirs()
repeat(180) { frame ->
settings.commitLights(emitters(frame/30.0))
pump();save("clip/%04d".format(frame),light.readPresentedLinear())
}
}
File(directory,"measurements.json").writeText(report.toString(2))
println(report.toString(2))
}
}
println("DIRECT LIGHTING QUALITY CHECK DONE")
} finally { renderer.cleanup();window.cleanup() }
exitProcess(0)
}
private fun save(directory: File,name: String,pixels: FloatArray,width: Int,height: Int) {
val image=BufferedImage(width,height,BufferedImage.TYPE_INT_RGB)
for(y in 0 until height)for(x in 0 until width) {
fun channel(c: Int): Int {
val value=pixels[(y*width+x)*4+c].toDouble().coerceAtLeast(0.0)
return (255*(value/(1+value)).pow(1/2.2)).roundToInt().coerceIn(0,255)
}
image.setRGB(x,y,(channel(0) shl 16) or (channel(1) shl 8) or channel(2))
}
ImageIO.write(image,"png",File(directory,"$name.png"))
}
}