PathTraceGpuSmoke.kt
src/jvmMain/kotlin/afield/graphics/scene/fabricated/PathTraceGpuSmoke.kt · 397 lines
package afield.graphics.scene.fabricated
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.util.Camera
import afield.math.linalg.Vector3f
import kotlinx.coroutines.delay
import kotlin.math.abs
GPU smoke tests and a demo entry point for PathTracingRenderer.
PathTraceGpuSmoke.run sets up known scenes (a constant HDR environment, emissive quads, a
white-furnace diffuse surface, and glass slabs) and reads back linear pixels from real GPU
dispatches to assert accumulation behaviour, lighting and camera resets, HDR material
correctness, Beer-Lambert attenuation, Fresnel, indirect clamping, and transparent-background
compositing. Where the device supports ray queries, both the compute-BVH and ray-query backends
run, and their pixel outputs are compared.
PathTraceGpuSmoke.runCamera covers the thin-lens model: pinhole sharpness, aperture blur,
focus-distance changes, pixel jitter, and accumulation invalidation on target or parameter change.
PathTraceDemo.install builds a three-sphere scene (diffuse, metallic, rough metal over a floor)
and returns a live PathTracingRenderer for interactive use from a box.
object PathTraceGpuSmoke
Real GPU readback assertions. Run from a Field box below a live renderer; no placeholder captures.
object PathTraceGpuSmoke {
suspend fun runSeams(renderer: Renderer)
A closed quad must have no pinholes along either diagonal, from either side. The camera-centred rays land exactly on shared edges where u/v rounding used to miss both triangles on NVIDIA. Black misses make this visible without temporal reuse.
suspend fun runSeams(renderer: Renderer) {
val backends = listOf(PathTracingRenderer.Backend.COMPUTE_BVH) +
if (renderer.device.supportsRayQuery) listOf(PathTracingRenderer.Backend.RAY_QUERY) else emptyList()
for (backend in backends) for (diagonal in 0..1) for (side in listOf(-1f, 1f)) {
val corners = listOf(Vector3f(-20f,-20f,0f),Vector3f(20f,-20f,0f),
Vector3f(20f,20f,0f),Vector3f(-20f,20f,0f))
val scene = PathTraceScene().apply {
val m = material(PathTraceMaterial.emissive(1f,1f,1f))
quad(corners[diagonal], corners[(diagonal+1)%4], corners[(diagonal+2)%4], corners[(diagonal+3)%4], m)
}
val camera = Camera(renderer).also { it.at.set(0f,0f,3f*side); it.target.set(0f,0f,0f); it.p.fov=0.7 }
PathTracingRenderer(renderer,scene,PathTraceEnvironment.constant(0f),48,32,camera,backend).use { t ->
t.maxBounces=1; t.pixelJitter=0f; t.paused=true; t.install()
t.materialModule.current.after["seam-step"] = afield.graphics.vulkan.Target { _, _ -> t.paused=true }
for (shift in listOf(0f,0.8f,0f,-0.8f,0.02f)) {
camera.at.x=shift; camera.target.x=shift; t.reset(); t.paused=false
val deadline=System.nanoTime()+30_000_000_000L
while (!t.paused) { check(System.nanoTime()<deadline) { "Seam dispatch timed out" }; delay(1) }
val pixels=t.readLinear()
val holes=(pixels.indices step 4).filter { i ->
(0..3).any { !pixels[i+it].isFinite() || abs(pixels[i+it]-1f)>1e-5 }
}
check(holes.isEmpty()) { "$backend diagonal=$diagonal side=$side shift=$shift: ${holes.size} quad pinholes, first=${holes.take(8).map { (it/4%48) to (it/4/48) }}" }
}
}
}
println("PASS shared-edge coverage: both diagonals, both sides, translated cameras on $backends")
}
suspend fun run(renderer: Renderer)
body — 225 lines, from line 61
suspend fun run(renderer: Renderer) {
val backends = mutableListOf(PathTracingRenderer.Backend.COMPUTE_BVH)
if (renderer.device.supportsRayQuery) backends += PathTracingRenderer.Backend.RAY_QUERY
val results = mutableListOf<FloatArray>()
for (backend in backends) {
val camera = Camera(renderer).also { it.at.set(0f,0f,3f); it.target.set(0f,0f,0f) }
val missScene = PathTraceScene().apply {
val m = material(PathTraceMaterial())
triangle(Vector3f(100f,0f,0f),Vector3f(101f,0f,0f),Vector3f(100f,1f,0f),m)
}
PathTracingRenderer(renderer,missScene,PathTraceEnvironment.constant(0.5f,1f,2f),63,47,camera,backend).use { tracer ->
tracer.indirectClamp = 0.001f // Directly visible HDR must not be clamped.
tracer.install()
awaitSamples(tracer,8)
tracer.paused = true
var pixels = tracer.readLinear()
for (i in pixels.indices step 4) {
check(abs(pixels[i]-0.5f)<1e-5 && abs(pixels[i+1]-1f)<1e-5 && abs(pixels[i+2]-2f)<1e-5)
check(pixels[i+3] == 1f)
}
val previous = tracer.samples
tracer.exposure = 2f
check(tracer.samples == previous)
tracer.environmentIntensity = 0.25f
tracer.paused = false
awaitReset(tracer,previous)
tracer.paused = true
pixels = tracer.readLinear()
check(abs(pixels[0]-0.125f)<1e-5 && abs(pixels[2]-0.5f)<1e-5) { "Lighting reset retained old radiance" }
tracer.paused = false
awaitSamples(tracer,10)
tracer.paused = true
val beforeCamera = tracer.samples
camera.at.x += 0.2f
tracer.paused = false
awaitReset(tracer,beforeCamera)
tracer.paused = true
check(tracer.samples < beforeCamera) { "Camera failed to reset accumulation" }
tracer.showEnvironment = false
tracer.background = afield.math.linalg.Vector4f(0.2f, 0.4f, 0.8f, 0.5f)
tracer.paused = false
kotlinx.coroutines.delay(100)
awaitSamples(tracer, 4)
tracer.paused = true
pixels = tracer.readLinear()
for (j in pixels.indices step 4) {
check(abs(pixels[j]-0.1f)<1e-6 && abs(pixels[j+1]-0.2f)<1e-6 && abs(pixels[j+2]-0.4f)<1e-6 && pixels[j+3]==0.5f)
}
tracer.background = afield.math.linalg.Vector4f(0f)
tracer.paused = false
// Observe a dispatch even if the old sample count was only one.
kotlinx.coroutines.delay(100)
awaitSamples(tracer, 4)
tracer.paused = true
check(tracer.readLinear().all { it == 0f }) { "Transparent background retained environment or old samples" }
println("PASS GPU $backend: constant HDR, 63x47 edge workgroups, accumulation, lighting/camera resets")
}
val scene = PathTraceScene()
val colors = listOf(floatArrayOf(2f,0f,0f),floatArrayOf(0f,3f,0f),floatArrayOf(0f,0f,4f),floatArrayOf(1f,2f,3f))
for (row in 0..1) for (col in 0..1) {
val color = colors[row*2+col]
val material = scene.material(PathTraceMaterial.emissive(color[0],color[1],color[2]))
val x = if (col==0) -20f else 0f
val y = if (row==0) 0f else -20f
scene.quad(Vector3f(x,y,0f),Vector3f(x+20,y,0f),Vector3f(x+20,y+20,0f),Vector3f(x,y+20,0f),material)
}
camera.at.set(0f,0f,3f)
PathTracingRenderer(renderer,scene,PathTraceEnvironment.constant(0f),64,48,camera,backend).use { tracer ->
tracer.indirectClamp=0.001f
tracer.maxBounces=1; tracer.install(); awaitSamples(tracer,8); tracer.paused=true
val pixels=tracer.readLinear(); results += pixels
check(pixels.all { it.isFinite() && it >= 0f })
for (row in 0..1) for (col in 0..1) {
val index=((12+row*24)*64+16+col*32)*4
for (channel in 0..2) check(abs(pixels[index+channel]-colors[row*2+col][channel])<1e-5) {
"GPU material/orientation mismatch row=$row col=$col channel=$channel: ${pixels[index+channel]}"
}
}
println("PASS GPU $backend: BVH traversal, triangle/material layout, HDR emission and image orientation")
}
// A white rough dielectric in a constant environment exercises BRDF/PDF/MIS code,
// unlike the exact miss and emissive tests above. Spatial noise must fall with accumulation.
val whiteScene = PathTraceScene().apply {
val white = material(PathTraceMaterial.plastic(1f,1f,1f,0.4f))
quad(Vector3f(-20f,-20f,0f),Vector3f(20f,-20f,0f),Vector3f(20f,20f,0f),Vector3f(-20f,20f,0f),white)
}
PathTracingRenderer(renderer,whiteScene,PathTraceEnvironment.constant(),64,48,camera,backend).use { tracer ->
tracer.indirectClamp=0.001f // All lighting on this isolated surface is direct.
tracer.maxBounces=2; tracer.install(); awaitSamples(tracer,8); tracer.paused=true
fun centralMoments(pixels: FloatArray): Pair<Double,Double> {
check(pixels.all { it.isFinite() && it >= 0f })
val values=(12 until 36).flatMap { y -> (16 until 48).map { x -> pixels[(y*64+x)*4].toDouble() } }
val mean=values.average()
return mean to values.sumOf { (it-mean)*(it-mean) }/values.size
}
val initial=centralMoments(tracer.readLinear())
tracer.samplesPerFrame=16; tracer.paused=false; awaitSamples(tracer,1024); tracer.paused=true
val converged=centralMoments(tracer.readLinear())
check(converged.first in 0.9..1.05) { "White-furnace energy out of range: $converged" }
check(converged.second < initial.second*0.25) { "Accumulation did not reduce variance: $initial -> $converged" }
tracer.showEnvironment = false
tracer.background = afield.math.linalg.Vector4f(0f)
tracer.paused = false
awaitReset(tracer, tracer.samples)
awaitSamples(tracer, 1024)
tracer.paused = true
val hidden = centralMoments(tracer.readLinear())
check(abs(hidden.first-converged.first)<0.01) { "Hiding background changed surface lighting: $converged -> $hidden" }
check(tracer.readLinear().filterIndexed { index, _ -> index % 4 == 3 }.all { it == 1f })
println("PASS GPU $backend: white-furnace mean=${converged.first}, variance ${initial.second} -> ${converged.second}")
}
}
val replacementScene = PathTraceScene().apply {
val m = material(PathTraceMaterial())
triangle(Vector3f(100f,0f,0f),Vector3f(101f,0f,0f),Vector3f(100f,1f,0f),m)
}
val first = PathTracingRenderer(renderer, replacementScene, PathTraceEnvironment.constant(), 16, 16).install()
first.blitToScreen()
val second = PathTracingRenderer(renderer, replacementScene, PathTraceEnvironment.constant(0.25f), 16, 16).install()
second.blitToScreen()
first.close()
check(renderer.computeList.others["path-tracer"] === second)
check(renderer.renderList.preTargets.containsKey("path-tracer-output"))
check(renderer.renderList.modules.containsKey("path-tracer-display"))
awaitSamples(second, 4)
second.paused = true
check(abs(second.readLinear()[0]-0.25f)<1e-6)
second.close()
check("path-tracer" !in renderer.computeList.others && "path-tracer-output" !in renderer.renderList.preTargets)
println("PASS named replacement, stale close, separate RTT presentation, hidden/transparent backgrounds and unchanged surface lighting")
for (backend in backends) {
runTransparency(renderer,backend)
PathTraceGltfGpuSmoke.run(renderer,backend)
}
if (results.size==2) {
for (i in results[0].indices) if (i%4!=3) check(abs(results[0][i]-results[1][i])<0.15f) { "Compute/ray query disagree at $i" }
println("PASS GPU backend agreement")
} else println("Ray-query device execution unavailable; shader compilation is checked separately")
println("PASS path-tracing GPU smoke")
}
private suspend fun runTransparency(renderer: Renderer, backend: PathTracingRenderer.Backend) {
fun slab(material: PathTraceMaterial, thickness: Float): PathTraceScene = PathTraceScene().apply {
val m=material(material)
quad(Vector3f(-100f,-100f,0f),Vector3f(100f,-100f,0f),Vector3f(100f,100f,0f),Vector3f(-100f,100f,0f),m)
quad(Vector3f(-100f,-100f,-thickness),Vector3f(-100f,100f,-thickness),
Vector3f(100f,100f,-thickness),Vector3f(100f,-100f,-thickness),m)
}
suspend fun measure(scene: PathTraceScene, camera: Camera, label: String, expected: List<Float>): FloatArray {
PathTracingRenderer(renderer,scene,PathTraceEnvironment.constant(),32,24,camera,backend).use { tracer ->
tracer.maxBounces=12; tracer.samplesPerFrame=16; tracer.install()
awaitSamples(tracer,1024); tracer.paused=true
val pixels=tracer.readLinear()
check(pixels.all { it.isFinite() && it>=0f }) { "Nonfinite or negative glass result: $label: ${pixels.withIndex().filter { !it.value.isFinite() || it.value < 0f }.take(8)}" }
val means=FloatArray(3) { c -> (0 until 32*24).sumOf { pixels[it*4+c].toDouble() }.toFloat()/(32*24) }
expected.forEachIndexed { c,e -> check(abs(means[c]-e)<0.015f) { "$label channel=$c expected=$e actual=${means[c]}" } }
println("PASS GPU $backend: $label RGB=${means.toList()}")
return pixels
}
}
val camera=Camera(renderer).also { it.at.set(0f,0f,3f); it.target.set(0f,0f,0f); it.p.fov=0.002 }
camera.at.set(0.37f,0.19f,3f); camera.target.set(0.37f,0.19f,0f)
// Keep this narrow view away from the oversized quad diagonal, where float precision
// can let a ray slip between triangles.
// Two index-matched interfaces put the environment at bounce two, with no stochastic
// Fresnel choice. This isolates the indirect clamp, its hue preservation and reset behavior.
PathTracingRenderer(renderer,slab(PathTraceMaterial.glass(ior=1f),1f),
PathTraceEnvironment.constant(2f,1f,0.5f),32,24,camera,backend).use { tracer ->
tracer.maxBounces=4; tracer.install(); awaitSamples(tracer,32); tracer.paused=true
for (invalid in listOf(-1f,Float.NaN,Float.POSITIVE_INFINITY)) {
check(runCatching { tracer.indirectClamp=invalid }.isFailure)
}
check(tracer.indirectClamp==0f)
suspend fun verifyClamp(limit: Float) {
val old=tracer.samples
tracer.indirectClamp=limit; tracer.paused=false
awaitReset(tracer,old); awaitSamples(tracer,32); tracer.paused=true
val scale=if (limit==0f) 1f else limit/(2f*0.2126f+0.7152f+0.5f*0.0722f)
val expected=floatArrayOf(2f*scale,scale,0.5f*scale,1f)
val pixels=tracer.readLinear()
for (i in pixels.indices) check(abs(pixels[i]-expected[i%4])<0.002f) {
"Indirect clamp=$limit channel=${i%4}: expected=${expected[i%4]} actual=${pixels[i]}"
}
}
verifyClamp(0.5f)
verifyClamp(0f)
println("PASS GPU $backend: indirect clamp, hue preservation, invalid limits and accumulation reset")
}
camera.at.set(0f,0f,3f); camera.target.set(0f,0f,0f)
measure(slab(PathTraceMaterial.glass(),1f),camera,"clear slab Fresnel and delta MIS",listOf(1f,1f,1f))
val tinted=PathTraceMaterial.glass(ior=1f,attenuationColor=Vector3f(0.5f,0.8f,1f))
measure(slab(tinted,1f),camera,"Beer-Lambert unit thickness",listOf(0.5f,0.8f,1f))
measure(slab(tinted,2f),camera,"Beer-Lambert double thickness",listOf(0.25f,0.64f,1f))
val pane=PathTraceScene().apply {
val m=material(PathTraceMaterial.thinGlass(tint=Vector3f(0.5f,0.8f,1f)))
quad(Vector3f(-100f,-100f,0f),Vector3f(100f,-100f,0f),Vector3f(100f,100f,0f),Vector3f(-100f,100f,0f),m)
}
measure(pane,camera,"thin pane Fresnel and tint",listOf(7f/13f,10.6f/13f,1f))
camera.at.set(0f,0f,-3f)
measure(pane,camera,"thin pane reverse face",listOf(7f/13f,10.6f/13f,1f))
camera.at.set(0f,0f,3f); camera.p.fov=0.3
for (roughness in listOf(0.15f,0.35f,0.6f)) {
PathTracingRenderer(renderer,slab(PathTraceMaterial.glass(roughness=roughness),1f),
PathTraceEnvironment.constant(),32,24,camera,backend).use { tracer ->
fun moments(p: FloatArray): Pair<Double,Double> {
check(p.all { it.isFinite() && it>=0f })
val values=(0 until 32*24).map { p[it*4].toDouble() }
val mean=values.average()
return mean to values.sumOf { (it-mean)*(it-mean) }/values.size
}
tracer.maxBounces=12; tracer.install(); awaitSamples(tracer,8); tracer.paused=true
val initial=moments(tracer.readLinear())
tracer.samplesPerFrame=16; tracer.paused=false; awaitSamples(tracer,2048); tracer.paused=true
val final=moments(tracer.readLinear())
check(final.first in 0.8..1.03) { "Rough glass energy: roughness=$roughness $final" }
check(final.second < initial.second*0.15) { "Rough glass did not converge: $initial -> $final" }
println("PASS GPU $backend: frosted roughness=$roughness mean=${final.first}, variance ${initial.second} -> ${final.second}")
}
}
camera.p.fov=0.002
camera.at.set(0f,0f,-0.5f); camera.target.set(0f,0f,0f)
measure(slab(PathTraceMaterial.glass(),1f),camera,"camera inside glass radiance scaling",listOf(2.25f,2.25f,2.25f))
camera.target.set(1.7320508f,0f,0.5f)
measure(slab(PathTraceMaterial.glass(),1f),camera,"total internal reflection",listOf(0f,0f,0f))
}
suspend fun runCamera(renderer: Renderer)
Thin-lens focus, coverage, jitter, and automatic reset checks on real GPU pixels.
body — 78 lines, from line 288
suspend fun runCamera(renderer: Renderer) {
val backends = listOf(PathTracingRenderer.Backend.COMPUTE_BVH) +
if (renderer.device.supportsRayQuery) listOf(PathTracingRenderer.Backend.RAY_QUERY) else emptyList()
for (backend in backends) {
val camera = Camera(renderer).also {
it.at.set(0f,0f,3f); it.target.set(0f,0f,0f); it.p.fov=Math.PI/2
}
val scene=PathTraceScene().apply {
val white=material(PathTraceMaterial.emissive(1f,1f,1f))
quad(Vector3f(0.035f,-20f,0f),Vector3f(20f,-20f,0f),
Vector3f(20f,20f,0f),Vector3f(0.035f,20f,0f),white)
}
PathTracingRenderer(renderer,scene,PathTraceEnvironment.constant(0f),48,32,camera,backend).use { tracer ->
tracer.maxBounces=1; tracer.samplesPerFrame=16; tracer.pixelJitter=0f
tracer.showEnvironment=false; tracer.background=afield.math.linalg.Vector4f(0f)
tracer.install()
suspend fun capture(change: () -> Unit = {}): FloatArray {
val old=tracer.samples
change()
tracer.paused=false
if (old>0) awaitReset(tracer,old)
awaitSamples(tracer,512); tracer.paused=true
return tracer.readLinear().also { pixels ->
check(pixels.all { it.isFinite() && it in 0f..1.00001f })
for (i in pixels.indices step 4) check(abs(pixels[i]-pixels[i+3])<1e-5) {
"Lens coverage must match premultiplied white radiance"
}
}
}
fun softPixels(p: FloatArray)=p.indices.count { it%4==0 && p[it] in 0.02f..0.98f }
val pinhole=capture()
check(softPixels(pinhole)==0)
val focused=capture { tracer.apertureRadius=1.2f }
check(focused.indices.all { abs(focused[it]-pinhole[it])<1e-5 }) {
"A wide aperture blurred the focus plane (including off-axis pixels)"
}
val defocused=capture { tracer.focusDistance=6f }
check(softPixels(defocused)>128) { "Changing focus did not blur the edge" }
check(abs(defocused.average()-focused.average())<0.02) { "Aperture changed exposure" }
val targetFocused=capture { tracer.focusDistance=null; camera.target.z=-3f }
check(targetFocused.indices.all { abs(targetFocused[it]-defocused[it])<0.03f })
val retargeted=capture { camera.target.z=0f }
check(retargeted.indices.all { abs(retargeted[it]-pinhole[it])<1e-5 }) {
"Changing only target distance failed to reset focus history"
}
val jittered=capture { tracer.apertureRadius=0f; tracer.pixelJitter=1f }
check(softPixels(jittered) in 1..64) { "Pixel jitter did not antialias the subpixel edge" }
val centred=capture { tracer.pixelJitter=0f }
check(centred.contentEquals(pinhole)) { "Turning jitter off retained old samples" }
val rolled=capture { camera.up.set(0f,-1f,0f) }
for(pixel in 0 until 48*32)for(channel in 0..3) {
check(abs(rolled[pixel*4+channel]-pinhole[(48*32-1-pixel)*4+channel])<1e-5f) {
"camera.up did not rotate the path-traced image by 180 degrees"
}
}
val projectedUp=capture { camera.up.set(0f,1f,.5f) }
check(projectedUp.contentEquals(pinhole)) {
"A forward component of camera.up changed the orthonormal camera basis"
}
for (bad in listOf(-1f,Float.NaN,Float.POSITIVE_INFINITY)) {
check(runCatching { tracer.apertureRadius=bad }.isFailure)
check(runCatching { tracer.focusDistance=bad }.isFailure)
check(runCatching { tracer.pixelJitter=bad }.isFailure)
}
check(runCatching { tracer.focusDistance=0f }.isFailure)
check(runCatching { tracer.pixelJitter=1.01f }.isFailure)
println("PASS GPU $backend camera: sharp focus plane, ${softPixels(defocused)} defocused pixels, " +
"${softPixels(jittered)} antialiased pixels, camera.up roll/projection, premultiplied coverage and focus/jitter resets")
}
}
}
private suspend fun awaitReset(tracer: PathTracingRenderer, old: Int) {
val deadline=System.nanoTime()+20_000_000_000L
while (tracer.samples>=old || tracer.samples==0) {
check(System.nanoTime()<deadline) { "Timed out waiting for accumulation reset" }; delay(1)
}
}
suspend fun awaitSamples(tracer: PathTracingRenderer, count: Int)
suspend fun awaitSamples(tracer: PathTracingRenderer, count: Int) {
val deadline=System.nanoTime()+60_000_000_000L
while (tracer.samples<count) {
check(System.nanoTime()<deadline) { "Timed out waiting for $count samples; got ${tracer.samples}" }; delay(5)
}
}
}
object PathTraceDemo
Small reusable material study; the environment file is optional, with a constant white map as fallback.
fun install(renderer: Renderer, environmentPath: String? = null, width: Int = 640, height: Int = 360): PathTracingRenderer
fun install(renderer: Renderer, environmentPath: String? = null, width: Int = 640, height: Int = 360): PathTracingRenderer =
install(renderer, environmentPath, width, height, PathTracingRenderer.Backend.AUTO)
fun install
fun install(
renderer: Renderer, environmentPath: String? = null, width: Int = 640, height: Int = 360,
backend: PathTracingRenderer.Backend
): PathTracingRenderer {
val scene=PathTraceScene()
val floor=scene.material(PathTraceMaterial.diffuse(0.48f,0.48f,0.48f))
val red=scene.material(PathTraceMaterial.plastic(0.65f,0.055f,0.025f,0.22f))
val gold=scene.material(PathTraceMaterial.metal(0.95f,0.7f,0.3f,0.12f))
val rough=scene.material(PathTraceMaterial.metal(0.55f,0.65f,0.8f,0.55f))
scene.quad(Vector3f(-15f,0f,-15f),Vector3f(-15f,0f,15f),Vector3f(15f,0f,15f),Vector3f(15f,0f,-15f),floor)
scene.sphere(Vector3f(-1.7f,0.8f,0f),0.8f,red,48,24)
scene.sphere(Vector3f(0f,0.8f,0f),0.8f,gold,48,24)
scene.sphere(Vector3f(1.7f,0.8f,0f),0.8f,rough,48,24)
val camera=Camera(renderer).also { it.at.set(3.5f,2.4f,7.5f); it.target.set(0f,0.65f,0f); it.p.fov=0.62 }
return PathTracingRenderer(renderer,scene,environmentPath?.let { PathTraceEnvironment.fromFile(it) }
?: PathTraceEnvironment.constant(),width,height,camera,backend).install()
}
}