Skip to content

PathTraceTestMain.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/PathTraceTestMain.kt · 213 lines

package afield.graphics.scene.fabricated

import afield.graphics.shader.Introspection
import afield.graphics.shader.ShaderKind
import afield.math.linalg.Vector3f
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.abs

FIELD_TEST id: four.pathtracing.contracts runner: jvm-main entrypoint: afield.graphics.scene.fabricated.PathTraceTestMain timeoutSec: 90 tags: [graphics, pathtracing, smoke] expect: exitCode: 0 stdoutContains: - "PASS path-tracing scene, BVH, importance distribution and shader contracts"

object PathTraceTestMain

Pure scene/importance-distribution contracts plus compilation/reflection of BOTH GPU variants.

@JvmStatic fun main(args: Array<String>)

body — 188 lines, from line 25
    @JvmStatic fun main(args: Array<String>) {
        val scene = PathTraceScene()
        val material = scene.material(PathTraceMaterial.metal(0.9f, 0.7f, 0.4f))
        scene.sphere(Vector3f(), 1f, material, 16, 8)
        val packed = scene.pack()
        check(packed.triangles.size == 224)
        check(packed.nodes.first().escape == packed.nodes.size)
        val covered = mutableSetOf<Int>()
        packed.nodes.forEachIndexed { index, node ->
            check(node.escape in index+1..packed.nodes.size)
            if (node.count > 0) {
                check(node.escape == index+1)
                for (id in node.first until node.first+node.count) {
                    check(covered.add(id))
                    packed.triangles[id].p.forEach { p ->
                        check(p.x in node.lo.x..node.hi.x && p.y in node.lo.y..node.hi.y && p.z in node.lo.z..node.hi.z)
                    }
                }
            } else {
                val left = packed.nodes[index+1]; val right = packed.nodes[left.escape]
                check(right.escape == node.escape)
                listOf(left,right).forEach { c ->
                    check(c.lo.x >= node.lo.x && c.lo.y >= node.lo.y && c.lo.z >= node.lo.z)
                    check(c.hi.x <= node.hi.x && c.hi.y <= node.hi.y && c.hi.z <= node.hi.z)
                }
            }
        }
        check(covered.size == packed.triangles.size)
        val bytes = ByteBuffer.allocate(packed.triangles.size*240).order(ByteOrder.nativeOrder())
        packed.writeTriangles(bytes)
        check(bytes.position() == bytes.capacity())
        check(bytes.getFloat(12).toInt() == material)
        check(runCatching { PathTraceMaterial(roughness = Float.NaN) }.isFailure)
        check(runCatching { PathTraceScene().pack() }.isFailure)
        run { val before = scene.degenerateCount; scene.triangle(Vector3f(),Vector3f(),Vector3f(),material); check(scene.degenerateCount == before + 1) }
        packed.triangles.forEach { t ->
            check(Vector3f(t.p[1]).sub(t.p[0]).cross(Vector3f(t.p[2]).sub(t.p[0])).dot(t.p[0]) > 0f) {
                "Sphere winding must point outward for medium transitions"
            }
        }
        val glassScene=PathTraceScene()
        glassScene.material(PathTraceMaterial.glass(attenuationColor=Vector3f(0.5f,0.25f,1f), attenuationDistance=2f))
        val glassBytes=ByteBuffer.allocate(PathTraceMaterial.STRIDE).order(ByteOrder.nativeOrder())
        // Use a triangle so packing exercises the same public builder path as a renderer.
        glassScene.triangle(Vector3f(),Vector3f(1f,0f,0f),Vector3f(0f,1f,0f),0)
        glassScene.pack().writeMaterials(glassBytes)
        check(glassBytes.position()==PathTraceMaterial.STRIDE && glassBytes.getFloat(32)==1f && glassBytes.getFloat(36)==1.5f)
        check(abs(glassBytes.getFloat(48)-0.3465736f)<1e-6 && glassBytes.getFloat(56)==0f)
        check(runCatching { PathTraceMaterial.glass(ior=0.9f) }.isFailure)
        check(runCatching { PathTraceMaterial.glass(attenuationDistance=0f) }.isFailure)
        check(runCatching { PathTraceMaterial.glass(attenuationColor=Vector3f(Float.NaN)) }.isFailure)
        check(runCatching { PathTraceMaterial.thinGlass().copy(roughness=0.1f) }.isFailure)
        val env = PathTraceEnvironment(4,2,FloatArray(32) { 1f })
        val total = env.distribution.indices.filter { it%2==1 }.sumOf { env.distribution[it].toDouble() }
        check(abs(total-1.0)<1e-6)
        check(env.distribution[14] == 1f)
        for (i in 0 until 8) check(abs(env.distribution[2*i+1]/(PI/2)-1/(4*PI)) < 1e-6)
        val brightPixels = FloatArray(64) { if (it < 4) 1000f else 0f }
        val bright = PathTraceEnvironment(4,4,brightPixels)
        check(bright.distribution[1] in 0.4f..0.8f)
        check(bright.distribution[3] > 0.05f && bright.distribution[7] > 0.05f) { "Filtered sun footprint must cross the longitude seam" }
        check(bright.distribution[9] > 0.05f) { "Filtered sun footprint must extend to the next row" }
        for (i in 0 until 16) {
            val previous = if (i == 0) 0f else bright.distribution[2*i-2]
            check(bright.distribution[2*i+1] == bright.distribution[2*i]-previous)
        }
        // Emitter selection table: power-weighted over emissive triangles, plus the per-triangle
        // inverse map the BSDF-hit MIS weight reads. Same two-float layout as the environment's.
        val lampScene = PathTraceScene()
        val dull = lampScene.material(PathTraceMaterial.diffuse(0.5f,0.5f,0.5f))
        val small = lampScene.material(PathTraceMaterial.emissive(6f,6f,6f))
        val wide = lampScene.material(PathTraceMaterial.emissive(1f,1f,1f))
        val masked = lampScene.material(PathTraceMaterial(emissionRed=9f, alphaMode=PathTraceMaterial.AlphaMode.MASK))
        val litGlass = lampScene.material(PathTraceMaterial.glass().copy(emissionRed=9f))
        lampScene.quad(Vector3f(-1f,0f,-1f),Vector3f(1f,0f,-1f),Vector3f(1f,0f,1f),Vector3f(-1f,0f,1f),dull)
        lampScene.triangle(Vector3f(0f,2f,0f),Vector3f(1f,2f,0f),Vector3f(0f,2f,1f),small)
        lampScene.triangle(Vector3f(0f,3f,0f),Vector3f(2f,3f,0f),Vector3f(0f,3f,2f),wide)
        lampScene.triangle(Vector3f(0f,4f,0f),Vector3f(1f,4f,0f),Vector3f(0f,4f,1f),masked)
        lampScene.triangle(Vector3f(0f,5f,0f),Vector3f(1f,5f,0f),Vector3f(0f,5f,1f),litGlass)
        val lamps = lampScene.pack()
        fun packedIds(material: Int) = lamps.triangles.indices.filter { lamps.triangles[it].material == material }
        check(lamps.emitterCount == 2) { "Emitter count ${lamps.emitterCount}" }
        check(lamps.emitterDistribution.size == 4 && lamps.emitterLookup.size == lamps.triangles.size*2)
        check(lamps.emitterByteSize == 16L && lamps.emitterLookupByteSize == lamps.triangles.size*8L)
        val tableIds = (0 until lamps.emitterCount).map { lamps.emitterDistribution[2*it+1].toInt() }
        check(tableIds.toSet() == (packedIds(small)+packedIds(wide)).toSet() && tableIds.size == tableIds.toSet().size) {
            "The table must hold exactly the sampler-visible emissive triangles, once each: $tableIds"
        }
        check(lamps.emitterDistribution[2*(lamps.emitterCount-1)] == 1f) { "Last cumulative must be exactly one" }
        var lampPrevious = 0f
        for (i in 0 until lamps.emitterCount) {
            val cumulative = lamps.emitterDistribution[2*i]
            check(cumulative >= lampPrevious)
            // The pdf comes from the uploaded interval, not the weight: a rounded CDF must stay normalized.
            check(lamps.emitterLookup[2*tableIds[i]] == cumulative - lampPrevious) {
                "Probability ${lamps.emitterLookup[2*tableIds[i]]} does not match interval ${cumulative-lampPrevious}"
            }
            lampPrevious = cumulative
        }
        // Area times emitted luminance: 0.5 x 6 against 2 x 1 is 3:2.
        check(abs(lamps.emitterLookup[2*packedIds(small).single()]-0.6f) < 1e-6) { "Area x luminance weighting" }
        check(abs(lamps.emitterLookup[2*packedIds(wide).single()]-0.4f) < 1e-6)
        check(abs(lamps.emitterLookup[2*packedIds(small).single()+1]-0.5f) < 1e-6) { "Stored triangle area" }
        check(abs(lamps.emitterLookup[2*packedIds(wide).single()+1]-2f) < 1e-6)
        (packedIds(dull)+packedIds(masked)+packedIds(litGlass)).forEach {
            check(lamps.emitterLookup[2*it] == 0f && lamps.emitterLookup[2*it+1] == 0f) {
                "Dark, stochastically masked and transmissive triangles must stay invisible to the direct sampler"
            }
        }
        // Both faces radiate, so a two-sided emitter of equal size and colour carries twice the power.
        val sidedScene = PathTraceScene()
        val oneSided = sidedScene.material(PathTraceMaterial.emissive(1f,1f,1f).copy(doubleSided=false))
        val twoSided = sidedScene.material(PathTraceMaterial.emissive(1f,1f,1f))
        sidedScene.triangle(Vector3f(0f,0f,0f),Vector3f(1f,0f,0f),Vector3f(0f,1f,0f),oneSided)
        sidedScene.triangle(Vector3f(0f,0f,1f),Vector3f(1f,0f,1f),Vector3f(0f,1f,1f),twoSided)
        val sided = sidedScene.pack()
        check(abs(sided.emitterLookup[2*sided.triangles.indices.single { sided.triangles[it].material==oneSided }]-1f/3f) < 1e-6)
        check(abs(sided.emitterLookup[2*sided.triangles.indices.single { sided.triangles[it].material==twoSided }]-2f/3f) < 1e-6)
        // A scene with no emitter at all still uploads a buffer, and the shader reads none of it.
        val darkScene = PathTraceScene()
        darkScene.triangle(Vector3f(),Vector3f(1f,0f,0f),Vector3f(0f,1f,0f),
            darkScene.material(PathTraceMaterial.diffuse(1f,1f,1f)))
        val dark = darkScene.pack()
        check(dark.emitterCount == 0 && dark.emitterByteSize == 8L && dark.emitterLookupByteSize == 8L)
        check(dark.emitterDistribution.all { it == 0f } && dark.emitterLookup.all { it == 0f })
        // Many equal emitters: monotone cumulative, exact normalization, every triangle present once.
        val shellScene = PathTraceScene()
        val shell = shellScene.material(PathTraceMaterial.emissive(0.5f,0.5f,0.5f))
        shellScene.sphere(Vector3f(), 1f, shell, 8, 4)
        val shellPacked = shellScene.pack()
        check(shellPacked.emitterCount == shellPacked.triangles.size)
        val shellProbabilities = (0 until shellPacked.emitterCount).map {
            shellPacked.emitterLookup[2*shellPacked.emitterDistribution[2*it+1].toInt()].toDouble()
        }
        check(abs(shellProbabilities.sum()-1.0) < 1e-6) { "Emitter probabilities sum to ${shellProbabilities.sum()}" }
        check((0 until shellPacked.emitterCount).all { it == 0 ||
            shellPacked.emitterDistribution[2*it] >= shellPacked.emitterDistribution[2*it-2] })
        check(shellPacked.emitterLookup.filterIndexed { i, _ -> i%2 == 1 }.all { it > 0f })
        println("PASS emitter table: ${lamps.emitterCount} of ${lamps.triangles.size} triangles sampled, " +
            "${shellPacked.emitterCount}-emitter shell normalized to ${shellProbabilities.sum()}")

        val directions = PathTracingRenderer.sobolWords
        check(directions.size == 100*32)
        fun sobol(index: Int, dimension: Int): Double {
            var i=index xor (index ushr 1); var bit=0; var word=0
            while (i!=0) { if ((i and 1)!=0) word=word xor directions[dimension*32+bit]; i=i ushr 1; bit++ }
            return Integer.toUnsignedLong(word)/4294967296.0
        }
        val reference=doubleArrayOf(0.0,0.5,0.75,0.25,0.375,0.875,0.625,0.125)
        reference.indices.forEach { check(sobol(it,0)==reference[it]) }
        // Cross-decision projections, not just the reused 2D pairs that formerly hid a noise floor.
        for ((a,b) in listOf(2 to 7, 4 to 9, 11 to 17, 32 to 97, 0 to 98, 1 to 99, 98 to 99)) {
            val counts=IntArray(16)
            repeat(1024) { counts[(sobol(it,a)*4).toInt()*4+(sobol(it,b)*4).toInt()]++ }
            check(counts.all { it in 48..80 }) { "Poor Sobol projection $a/$b: ${counts.toList()}" }
        }
        for (hardware in listOf(false,true)) {
            val errors = mutableListOf<String>()
            val shader = Introspection(PathTracingRenderer.shader(hardware), ShaderKind.COMPUTE_SHADER,
                errorHandler = { message, _ -> errors += message })
            check(shader.valid) { "Shader hardware=$hardware: ${errors.joinToString("\n")}" }
            check(shader.pushConstants.isEmpty()) { "Unexpected push allocation: ${shader.pushConstants}" }
            val sceneBuffers = shader.ubos.single { it.name == "SceneBuffers" }
            // Twelve 8-byte device addresses since 0c108313c (2026-09-27) appended `frameOutput`
            // after `emitterLookup`, which was the tail when this was pinned at eleven.
            check(sceneBuffers.members.map { it.offset } == listOf(0,8,16,24,32,40,48,56,64,72,80,88)) {
                "SceneBuffers pointer layout: ${sceneBuffers.members.map { it.name to it.offset }}"
            }
            check(sceneBuffers.members.map { it.name }.takeLast(3) == listOf("emitterTable","emitterLookup","frameOutput"))
            check(shader.ubos.map { it.name }.toSet() == setOf("Frame", "SceneBuffers"))
            val frameUbo = shader.ubos.single { it.name == "Frame" }
            // 352 since 6327c9c08 (2026-09-12) appended the participating medium's four vec4s
            // -- medium, mediumOptions, mediumLow, mediumHigh -- after `emitters`, which was the
            // tail when this was pinned at 288. 288 + 4*16 = 352, so the growth is exactly the
            // declared block and nothing silently repacked around it.
            // 384 since 0c108313c (2026-09-27) appended the frame-output pair -- `reconstruction`
            // (vec4) and `frameOutputEnabled` (ivec4) -- after `mediumHigh`: 352 + 2*16 = 384.
            check(frameUbo.size == 384) { "Frame size ${frameUbo.size}" }
            check(frameUbo.members.takeLast(3).map { it.name } == listOf("mediumHigh","reconstruction","frameOutputEnabled")) {
                "Frame tail: ${frameUbo.members.takeLast(3).map { it.name to it.offset }}"
            }
            check(frameUbo.members.takeLast(2).map { it.offset } == listOf(352,368))
            check(shader.accelerationStructures.size == if (hardware) 1 else 0)
            println("PASS path-tracing shader hardware=$hardware, SceneBuffers=${sceneBuffers.size}, Frame=${frameUbo.size}")
        }
        println("PASS path-tracing scene, BVH, importance distribution and shader contracts")
    }
}