Skip to content

PathTraceScene.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/PathTraceScene.kt · 355 lines

package afield.graphics.scene.fabricated

import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import afield.math.linalg.Matrix4f
import org.lwjgl.stb.STBImage.*
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.util.tinyexr.TinyEXR
import java.nio.ByteBuffer
import kotlin.math.*

What the path tracer traces: triangles, materials, textures and an environment map, all as plain Kotlin with no device attached.

Nothing in this file needs a GPU, which is why it is separate from PathTracingRenderer.kt. A scene is assembled by calling triangle, quad, mesh or sphere, each of which appends to flat lists and returns the scene so calls chain; material and texture intern a value and hand back the integer index the triangles refer to it by. pack then flattens the result into the float and int arrays the shader expects, and building a BVH over them is the renderer's job, not this file's. The consequence for a test is that scene construction, indexing and packing are all checkable headlessly, which is what PathTraceTestMain does.

PathTraceMaterial is linear-light throughout, with no sRGB anywhere in the struct, and carries the transmission parameters as well as the metallic-roughness ones, so glass is a material rather than a separate path. It carries one geometric precondition, and breaking it shows up as a dark result: solid (non-thin-walled) glass assumes closed, outward-wound, non-overlapping meshes, because the attenuation distance is measured between surface crossings.

PathTraceEnvironment is both the sky and, usually, the main light. It builds the importance distribution the sampler draws from, which is why an environment is a snapshot like the geometry is: changing it changes a precomputed table. PathTraceTexture decodes through stb_image and TinyEXR and keeps its pixels linear, sRGB-decoding base colour while leaving metallic-roughness alone. That asymmetry is what glTF requires, and PathTraceGltfGpuSmoke checks it against known byte values.

data class PathTraceMaterial

Linear-light surface materials. Solid glass requires closed, outward-wound, non-overlapping meshes.

data class PathTraceMaterial(
    val red: Float = 0.8f, val green: Float = 0.8f, val blue: Float = 0.8f,
    val metallic: Float = 0f, val roughness: Float = 0.5f,
    val emissionRed: Float = 0f, val emissionGreen: Float = 0f, val emissionBlue: Float = 0f,
    val transmission: Boolean = false, val ior: Float = 1.5f, val thinWalled: Boolean = false,
    val attenuationRed: Float = 1f, val attenuationGreen: Float = 1f, val attenuationBlue: Float = 1f,
    val attenuationDistance: Float = 1f,
    val baseColorTexture: Int = -1, val metallicRoughnessTexture: Int = -1,
    val emissionTexture: Int = -1, val normalTexture: Int = -1,
    val opacity: Float = 1f, val alphaCutoff: Float = 0.5f,
    val alphaMode: AlphaMode = AlphaMode.OPAQUE, val doubleSided: Boolean = true,
    val normalScale: Float = 1f
) {

enum class AlphaMode { OPAQUE, MASK, BLEND }

init

    init {
        require(opacity.isFinite() && opacity in 0f..1f && alphaCutoff.isFinite() && alphaCutoff in 0f..1f)
        require(normalScale.isFinite() && normalScale >= 0f)
        require(listOf(baseColorTexture, metallicRoughnessTexture, emissionTexture, normalTexture).all { it >= -1 })
        require(listOf(red, green, blue, metallic, roughness).all { it.isFinite() && it in 0f..1f })
        require(listOf(emissionRed, emissionGreen, emissionBlue).all { it.isFinite() && it >= 0f })
        require(ior.isFinite() && ior >= 1f && (ior * ior).isFinite())
        require(attenuationDistance.isFinite() && attenuationDistance > 0f)
        require(listOf(attenuationRed, attenuationGreen, attenuationBlue).all { it.isFinite() && (if (thinWalled) it >= 0f else it > 0f) && it <= 1f })
        require(thinWalled || listOf(attenuationRed, attenuationGreen, attenuationBlue).all {
            (-ln(it) / attenuationDistance).isFinite()
        }) { "Absorption coefficient must be finite" }
        require(!transmission || metallic == 0f) { "Transmission is dielectric; metallic must be zero" }
        require(!thinWalled || (transmission && roughness == 0f)) { "Thin sheets currently require smooth glass" }
    }

companion object

internal const val STRIDE = 112

fun glass(ior: Float = 1.5f, roughness: Float = 0f,

Colour remaining after attenuationDistance world units inside the solid; roughness zero is ideal glass.

        fun glass(ior: Float = 1.5f, roughness: Float = 0f,
                  attenuationColor: Vector3f = Vector3f(1f), attenuationDistance: Float = 1f) =
            PathTraceMaterial(roughness = roughness, transmission = true, ior = ior,
                attenuationRed = attenuationColor.x, attenuationGreen = attenuationColor.y,
                attenuationBlue = attenuationColor.z, attenuationDistance = attenuationDistance)

fun thinGlass(ior: Float = 1.5f, tint: Vector3f = Vector3f(1f))

Zero-thickness pane: tint is transmission per crossing, with no displacement or medium boundary.

        fun thinGlass(ior: Float = 1.5f, tint: Vector3f = Vector3f(1f)) =
            PathTraceMaterial(roughness=0f, transmission=true, ior=ior, thinWalled=true,
                attenuationRed=tint.x, attenuationGreen=tint.y, attenuationBlue=tint.z)

fun diffuse(r: Float, g: Float, b: Float) = PathTraceMaterial(r, g, b, roughness = 1f)

fun plastic(r: Float, g: Float, b: Float, roughness: Float = 0.25f)

        fun plastic(r: Float, g: Float, b: Float, roughness: Float = 0.25f) = PathTraceMaterial(r, g, b, roughness = roughness)

fun metal(r: Float, g: Float, b: Float, roughness: Float = 0.2f)

        fun metal(r: Float, g: Float, b: Float, roughness: Float = 0.2f) = PathTraceMaterial(r, g, b, 1f, roughness)

fun emissive(r: Float, g: Float, b: Float)

        fun emissive(r: Float, g: Float, b: Float) = PathTraceMaterial(0f, 0f, 0f, emissionRed = r, emissionGreen = g, emissionBlue = b)
    }
}

class PathTraceScene

Build a static world-space triangle scene, then pass it to PathTracingRenderer.

internal data class Triangle(val p: List<Vector3f>, val n: List<Vector3f>, val material: Int,

    internal data class Triangle(val p: List<Vector3f>, val n: List<Vector3f>, val material: Int,
        val uv: List<FloatArray>? = null, val color: List<Vector4f>? = null)

internal val textureData = mutableListOf<PathTraceTexture>()

fun texture(image: PathTraceTexture): Int = textureData.size.also { textureData += image }

Add a linear RGBA image; returned index can be used in a material's texture fields.

companion object

    companion object {

fun fromGltf(path: String, transform: Matrix4f = Matrix4f()): PathTraceScene

Static glTF 2.0 scene. Textures and hierarchy are copied; importing requires no renderer.

        fun fromGltf(path: String, transform: Matrix4f = Matrix4f()): PathTraceScene =
            PathTraceScene().also { it.importGltf(path, transform) }
    }

internal val materials = mutableListOf<PathTraceMaterial>()

internal val triangles = mutableListOf<Triangle>()

fun material(value: PathTraceMaterial): Int = materials.size.also { materials += value }

var degenerateCount: Int

Triangles triangle skipped for zero area or a non-finite coordinate, over this scene's life.

    var degenerateCount: Int = 0
        private set

fun triangle(a: Vector3f, b: Vector3f, c: Vector3f, material: Int,

Append one triangle. A zero-area triangle, or one with a non-finite coordinate, is skipped and counted in degenerateCount rather than aborting the scene — Manifold's Booleans leave a few such slivers. A missing or zero-length vertex normal falls back to the face normal.

    fun triangle(a: Vector3f, b: Vector3f, c: Vector3f, material: Int,
                 na: Vector3f? = null, nb: Vector3f? = null, nc: Vector3f? = null): PathTraceScene {
        require(material in materials.indices) { "Unknown material $material" }
        val p = listOf(a, b, c).map(::Vector3f)
        val face = Vector3f(b).sub(a).cross(Vector3f(c).sub(a))
        if (!p.all { it.x.isFinite() && it.y.isFinite() && it.z.isFinite() } ||
            !(face.lengthSquared() > 1e-16f) || !face.lengthSquared().isFinite()) {
            degenerateCount++
            return this
        }
        face.normalize()
        val normals = listOf(na, nb, nc).map { n ->
            if (n != null && n.lengthSquared().isFinite() && n.lengthSquared() > 1e-16f) Vector3f(n).normalize() else Vector3f(face)
        }
        triangles += Triangle(p, normals, material)
        return this
    }

fun quad(a: Vector3f, b: Vector3f, c: Vector3f, d: Vector3f, material: Int): PathTraceScene

    fun quad(a: Vector3f, b: Vector3f, c: Vector3f, d: Vector3f, material: Int): PathTraceScene =
        triangle(a, b, c, material).triangle(a, c, d, material)

fun mesh(positions: List<Vector3f>, indices: IntArray, material: Int, normals: List<Vector3f>? = null): PathTraceScene

Indexed positions, with optional per-vertex normals; transforms should be applied before upload.

    fun mesh(positions: List<Vector3f>, indices: IntArray, material: Int, normals: List<Vector3f>? = null): PathTraceScene {
        require(indices.size % 3 == 0 && indices.all { it in positions.indices })
        require(normals == null || normals.size == positions.size)
        for (i in indices.indices step 3) {
            val a = indices[i]; val b = indices[i + 1]; val c = indices[i + 2]
            triangle(positions[a], positions[b], positions[c], material, normals?.get(a), normals?.get(b), normals?.get(c))
        }
        return this
    }

fun sphere(center: Vector3f, radius: Float, material: Int, segments: Int = 32, rings: Int = 16): PathTraceScene

    fun sphere(center: Vector3f, radius: Float, material: Int, segments: Int = 32, rings: Int = 16): PathTraceScene {
        require(radius.isFinite() && radius > 0 && segments in 3..512 && rings in 2..256)
        fun normal(y: Int, x: Int): Vector3f {
            val theta = PI * y / rings; val phi = 2 * PI * x / segments
            return Vector3f((sin(theta) * cos(phi)).toFloat(), cos(theta).toFloat(), (sin(theta) * sin(phi)).toFloat())
        }
        fun add(a: Vector3f, b: Vector3f, c: Vector3f) {
            triangle(Vector3f(a).mul(radius).add(center), Vector3f(b).mul(radius).add(center),
                Vector3f(c).mul(radius).add(center), material, a, b, c)
        }
        for (y in 0 until rings) for (x in 0 until segments) {
            val a = normal(y, x); val b = normal(y + 1, x); val c = normal(y + 1, x + 1); val d = normal(y, x + 1)
            if (y != 0) add(a, d, b)
            if (y != rings - 1) add(d, c, b)
        }
        return this
    }

internal data class Node(val lo: Vector3f, val hi: Vector3f, var first: Int = 0, var count: Int = 0, var escape: Int = 0)

    internal data class Node(val lo: Vector3f, val hi: Vector3f, var first: Int = 0, var count: Int = 0, var escape: Int = 0)

internal class Packed(val triangles: List<Triangle>, val nodes: List<Node>, val materials: List<PathTraceMaterial>, val textures: List<PathTraceTexture>)

    internal class Packed(val triangles: List<Triangle>, val nodes: List<Node>, val materials: List<PathTraceMaterial>, val textures: List<PathTraceTexture>) {
        private val textureOffsets = textures.runningFold(0) { offset, t -> offset + 2 + t.width * t.height }

val emitterDistribution: FloatArray

Two floats per emissive triangle: cumulative probability and triangle index, as in PathTraceEnvironment.

val emitterLookup: FloatArray

Two floats per triangle: its selection probability and area. Zero for anything the sampler cannot see.

val emitterCount: Int

init

body — 42 lines, from line 173
        init {
            require(triangles.size < 1 shl 24) { "Emitter table stores triangle indices as exact floats" }
            // Only geometry the direct sampler and a BSDF hit agree about. Stochastic coverage would
            // let the two strategies disagree about whether the emitter is there at all, and a
            // transmissive emitter is shaded on the glass path; both keep weight zero, which leaves
            // the BSDF-hit arm at MIS weight one and the estimate unchanged.
            fun visible(m: PathTraceMaterial) = m.alphaMode == PathTraceMaterial.AlphaMode.OPAQUE && !m.transmission
            // Each texture's mean RGB, for weighting a textured emitter by what it actually emits.
            val textureMeans = textures.map { t ->
                val sum = DoubleArray(3)
                for (i in 0 until t.width * t.height) for (c in 0..2) sum[c] += t.pixels[4 * i + c]
                DoubleArray(3) { sum[it] / (t.width * t.height) }
            }
            fun area(t: Triangle) = 0.5 * Vector3f(t.p[1]).sub(t.p[0]).cross(Vector3f(t.p[2]).sub(t.p[0])).length()
            // Emitted power: area times emitted luminance, doubled when both faces radiate.
            fun power(t: Triangle): Double {
                val m = materials[t.material]
                if (!visible(m)) return 0.0
                // The shader multiplies the factor by the emission texture, so weight by its mean too.
                val texture = textureMeans.getOrNull(m.emissionTexture)
                val luminance = if (texture == null) 0.2126 * m.emissionRed + 0.7152 * m.emissionGreen + 0.0722 * m.emissionBlue
                    else 0.2126 * m.emissionRed * texture[0] + 0.7152 * m.emissionGreen * texture[1] + 0.0722 * m.emissionBlue * texture[2]
                return luminance * area(t) * (if (m.doubleSided) 2.0 else 1.0)
            }
            val ids = triangles.indices.filter { power(triangles[it]) > 0.0 }
            emitterCount = ids.size
            emitterDistribution = FloatArray(maxOf(1, ids.size) * 2)
            emitterLookup = FloatArray(triangles.size * 2)
            val total = ids.sumOf { power(triangles[it]) }
            var sum = 0.0
            var previous = 0f
            ids.indices.forEach { i ->
                sum += power(triangles[ids[i]]) / total
                val cumulative = if (i == ids.lastIndex) 1f else sum.toFloat().coerceIn(previous, 1f)
                emitterDistribution[2*i] = cumulative
                emitterDistribution[2*i+1] = ids[i].toFloat()
                // Match the actual uploaded CDF intervals; the pdf must come from the table, not the weight.
                emitterLookup[2*ids[i]] = cumulative - previous
                emitterLookup[2*ids[i]+1] = area(triangles[ids[i]]).toFloat()
                previous = cumulative
            }
        }

val emitterByteSize: Long get() = emitterDistribution.size * 4L

val emitterLookupByteSize: Long get() = emitterLookup.size * 4L

fun writeEmitters(out: ByteBuffer) { emitterDistribution.forEach { out.putFloat(it) } }

fun writeEmitterLookup(out: ByteBuffer) { emitterLookup.forEach { out.putFloat(it) } }

val textureByteSize: Long get() = maxOf(1, textureOffsets.last()).toLong() * 16

fun writeTextures(out: ByteBuffer)

        fun writeTextures(out: ByteBuffer) {
            if (textures.isEmpty()) { repeat(4) { out.putFloat(0f) }; return }
            textures.forEach { t ->
                out.putFloat(t.width.toFloat()).putFloat(t.height.toFloat()).putFloat(t.wrapU.toFloat()).putFloat(t.wrapV.toFloat())
                out.putFloat(if (t.nearest) 1f else 0f); repeat(3) { out.putFloat(0f) }
                t.pixels.forEach { out.putFloat(it) }
            }
        }

fun writeTriangles(out: ByteBuffer)

        fun writeTriangles(out: ByteBuffer) { triangles.forEach { t ->
            t.p.forEachIndexed { i, v -> out.putFloat(v.x).putFloat(v.y).putFloat(v.z).putFloat(if (i == 0) t.material.toFloat() else 0f) }
            t.n.forEach { v -> out.putFloat(v.x).putFloat(v.y).putFloat(v.z).putFloat(0f) }
            for (corner in 0..2) for (i in 0..7) out.putFloat(t.uv?.get(corner)?.get(i) ?: 0f)
            for (corner in 0..2) {
                val c = t.color?.get(corner)
                out.putFloat(c?.x ?: 1f).putFloat(c?.y ?: 1f).putFloat(c?.z ?: 1f).putFloat(c?.w ?: 1f)
            }
        } }

fun writeNodes(out: ByteBuffer)

        fun writeNodes(out: ByteBuffer) { nodes.forEach { n ->
            out.putFloat(n.lo.x).putFloat(n.lo.y).putFloat(n.lo.z).putFloat(0f)
            out.putFloat(n.hi.x).putFloat(n.hi.y).putFloat(n.hi.z).putFloat(0f)
            out.putInt(n.first).putInt(n.count).putInt(n.escape).putInt(0)
        } }

fun writeMaterials(out: ByteBuffer)

        fun writeMaterials(out: ByteBuffer) { materials.forEach { m ->
            out.putFloat(m.red).putFloat(m.green).putFloat(m.blue).putFloat(m.metallic)
            out.putFloat(m.emissionRed).putFloat(m.emissionGreen).putFloat(m.emissionBlue).putFloat(m.roughness)
            out.putFloat(if (m.transmission) 1f else 0f).putFloat(m.ior).putFloat(if (m.thinWalled) 1f else 0f).putFloat(0f)
            // Solid absorption coefficients; thin-sheet transmission colour is stored directly.
            fun coefficient(c: Float) = if (m.thinWalled) c else -ln(c) / m.attenuationDistance
            out.putFloat(coefficient(m.attenuationRed)).putFloat(coefficient(m.attenuationGreen))
                .putFloat(coefficient(m.attenuationBlue)).putFloat(0f)
            for (index in listOf(m.baseColorTexture,m.metallicRoughnessTexture,m.emissionTexture,m.normalTexture)) {
                require(index == -1 || index in textures.indices) { "Unknown texture $index" }
                out.putInt(if (index < 0) -1 else textureOffsets[index])
            }
            out.putFloat(m.opacity).putFloat(m.alphaCutoff).putFloat(m.alphaMode.ordinal.toFloat()).putFloat(if (m.doubleSided) 1f else 0f)
            out.putFloat(m.normalScale); repeat(3) { out.putFloat(0f) }
        } }
    }

internal fun pack(): Packed

    internal fun pack(): Packed {
        require(triangles.isNotEmpty()) { "A path tracing scene needs at least one triangle" }
        val ordered = mutableListOf<Triangle>(); val nodes = mutableListOf<Node>()
        fun build(items: List<Triangle>) {
            val lo = Vector3f(Float.POSITIVE_INFINITY); val hi = Vector3f(Float.NEGATIVE_INFINITY)
            items.forEach { t -> t.p.forEach { p -> lo.min(p); hi.max(p) } }
            val node = Node(lo, hi); nodes += node
            if (items.size <= 4) { node.first = ordered.size; node.count = items.size; ordered += items }
            else {
                val extent = Vector3f(hi).sub(lo)
                val axis = if (extent.x >= extent.y && extent.x >= extent.z) 0 else if (extent.y >= extent.z) 1 else 2
                val sorted = items.sortedBy { t -> t.p.sumOf { p -> (if (axis == 0) p.x else if (axis == 1) p.y else p.z).toDouble() } }
                build(sorted.subList(0, sorted.size / 2)); build(sorted.subList(sorted.size / 2, sorted.size))
            }
            node.escape = nodes.size
        }
        build(triangles.toList())
        return Packed(ordered, nodes, materials.toList(), textureData.toList())
    }
}

class PathTraceEnvironment(val width: Int, val height: Int, rgba: FloatArray)

Linear RGBA equirectangular image, top row = north pole; longitude atan2(z,x).

internal val pixels = rgba.copyOf()

internal val distribution: FloatArray

init

    init {
        require(width > 0 && height > 0 && width.toLong() * height * 4 == pixels.size.toLong())
        require(pixels.all { it.isFinite() && it >= 0f })
        val weights = DoubleArray(width * height)
        val kernel = doubleArrayOf(0.125, 0.75, 0.125)
        // Integrate the bilinear footprint over a texel-sized UV cell: [1,6,1]/8 in each axis.
        // This changes only the sampling density, never the environment image itself.
        fun luminance(x: Int, y: Int): Double {
            val i = (y.coerceIn(0, height - 1) * width + Math.floorMod(x, width)) * 4
            return 0.2126 * pixels[i] + 0.7152 * pixels[i+1] + 0.0722 * pixels[i+2]
        }
        for (y in 0 until height) for (x in 0 until width) {
            var filtered = 0.0
            for (dy in -1..1) for (dx in -1..1)
                filtered += kernel[dy+1] * kernel[dx+1] * luminance(x+dx, y+dy)
            val solidAngle = 2 * PI / width * (cos(PI * y / height) - cos(PI * (y + 1) / height))
            weights[y * width + x] = filtered * solidAngle
        }
        val total = weights.sum()
        distribution = FloatArray(weights.size * 2)
        var sum = 0.0
        var previous = 0f
        weights.indices.forEach { i ->
            val y = i / width
            val uniform = (cos(PI*y/height) - cos(PI*(y+1)/height)) / (2 * width)
            sum += if (total > 0) weights[i] / total else uniform
            val cumulative = if (i == weights.lastIndex) 1f else sum.toFloat().coerceIn(previous, 1f)
            distribution[2*i] = cumulative
            // Match the actual uploaded CDF intervals, including any rounded-away tiny weights.
            // A separately sampled 5% uniform-sphere mixture supplies support everywhere.
            distribution[2*i+1] = cumulative - previous
            previous = cumulative
        }
    }

companion object

    companion object {

fun fromFile(path: String): PathTraceEnvironment

OpenEXR, Radiance .hdr and STB-supported LDR files; STB converts LDR input to linear floats.

        fun fromFile(path: String): PathTraceEnvironment = MemoryStack.stackPush().use { stack ->
            if (path.endsWith(".exr", ignoreCase = true)) return@use fromExr(path)
            val w = stack.mallocInt(1); val h = stack.mallocInt(1); val channels = stack.mallocInt(1)
            stbi_set_flip_vertically_on_load_thread(0)
            val data = stbi_loadf(path, w, h, channels, 4) ?: error("Cannot load environment $path: ${stbi_failure_reason()}")
            try { PathTraceEnvironment(w[0], h[0], FloatArray(w[0] * h[0] * 4).also { data.get(it) }) }
            finally { stbi_image_free(data) }
        }
        private fun fromExr(path: String): PathTraceEnvironment = MemoryStack.stackPush().use { stack ->
            val out = stack.callocPointer(1); val w = stack.mallocInt(1); val h = stack.mallocInt(1); val err = stack.callocPointer(1)
            val result = TinyEXR.nLoadEXRWithLayer(MemoryUtil.memAddress(out), MemoryUtil.memAddress(w), MemoryUtil.memAddress(h),
                MemoryUtil.memAddress(stack.UTF8(path)), MemoryUtil.NULL, MemoryUtil.memAddress(err))
            if (result != TinyEXR.TINYEXR_SUCCESS) {
                val message = if (err[0] != 0L) MemoryUtil.memUTF8(err[0]) else "unknown EXR error"
                if (err[0] != 0L) TinyEXR.nFreeEXRErrorMessage(err[0])
                error("Cannot load environment $path: $message")
            }
            try {
                val count = Math.multiplyExact(Math.multiplyExact(w[0], h[0]), 4)
                PathTraceEnvironment(w[0], h[0], FloatArray(count).also { MemoryUtil.memFloatBuffer(out[0], count).get(it) })
            } finally { MemoryUtil.nmemFree(out[0]) }
        }

fun constant(r: Float = 1f, g: Float = r, b: Float = r)

        fun constant(r: Float = 1f, g: Float = r, b: Float = r) = PathTraceEnvironment(1, 1, floatArrayOf(r, g, b, 1f))
    }
}

class PathTraceTexture(val width: Int, val height: Int, rgba: FloatArray,

CPU-owned linear RGBA texture. Wrap modes: 0 repeat, 1 clamp, 2 mirrored repeat.

class PathTraceTexture(val width: Int, val height: Int, rgba: FloatArray,
                       val wrapU: Int = 0, val wrapV: Int = 0, val nearest: Boolean = false) {

internal val pixels = rgba.copyOf()

init

    init {
        require(width > 0 && height > 0 && width.toLong()*height*4 == pixels.size.toLong())
        require(wrapU in 0..2 && wrapV in 0..2)
        require(pixels.all { it.isFinite() && it >= 0f })
    }
}