Skip to content

PathTracingRenderer.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/PathTracingRenderer.kt · 817 lines

package afield.graphics.scene.fabricated

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.vulkan.buffers.*
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.Fence
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.render.Module
import afield.graphics.texture.*
import afield.graphics.vulkan.util.Camera
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.KHRAccelerationStructure.VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR
import org.lwjgl.vulkan.VK12.VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
import org.lwjgl.vulkan.VK13.*
import org.lwjgl.vulkan.VkBufferCopy
import org.lwjgl.vulkan.VkDependencyInfo
import org.lwjgl.vulkan.VkMemoryBarrier2
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.tan

The path tracer itself: BVH upload, the two traversal backends, accumulation, and the history reprojection that keeps an image usable while a camera moves.

This is the device-facing half of the path tracing stack, and the only file in it that owns Vulkan resources. The scene it traces is built elsewhere, by PathTraceScene.kt, which holds the geometry, materials and environment as plain arrays, and PathTraceGltf.kt, which fills them from a glTF file. It arrives here as an immutable snapshot that is packed into device buffers once. That split is why editing a material means constructing a new tracer: there is no path from a changed Kotlin value back into the buffers the shader reads.

Two traversal backends compile from the same source. COMPUTE_BVH walks a stackless software BVH and runs on any Vulkan device Field supports, MoltenVK included; RAY_QUERY uses VK_KHR_ray_query hardware traversal where the device advertises it. They share the lighting, material evaluation and accumulation code, so an image should not depend on which one ran, and PathTraceGpuSmoke checks that it does not.

The output is a fixed-resolution linear HDR render target rather than something presentable. Exposure and Reinhard tone mapping happen in a separate blitToScreen pass, which keeps the accumulated radiance readable: readLinear is what the smoke tests measure, and a tone-mapped buffer would have nothing useful in it.

For the same material with pictures, see public/docs/path_tracing.md.

class PathTracingRenderer

Progressive GPU path tracer: stackless software BVH on every Field Vulkan device, optional KHR ray queries. Construct/install/close on Field's render thread, like other Field 4 modules. Scene and environment are snapshots; recreate the tracer to edit them. Camera motion resets or optionally reprojects history; lighting edits always reset accumulation. Output is a fixed-resolution linear HDR RTT. Call blitToScreen separately for exposure + Reinhard presentation. Installing under the same name retires the previous tracer; no coroutine lifetime bookkeeping is needed.

class PathTracingRenderer(
    val renderer: Renderer,
    scene: PathTraceScene,
    environment: PathTraceEnvironment,
    val width: Int = 640,
    val height: Int = 360,
    var camera: Camera = Camera(renderer),
    requestedBackend: Backend = Backend.AUTO,
    val name: String = "path-tracer",
    target: RenderToTexture? = null,
    moduleFactory: (Parameters) -> ComputeModulePointer = { ComputeModule(it).pointer() }
) : AutoCloseable, Target {

enum class Backend { AUTO, COMPUTE_BVH, RAY_QUERY }

val backend: Backend

var samplesPerFrame: Int

    var samplesPerFrame: Int = 1
        set(value) { require(value in 1..64); field = value }

var pixelJitter: Float

Subpixel sample width in pixels: one covers the pixel, zero always samples its centre.

    var pixelJitter: Float = 1f
        set(value) { require(value.isFinite() && value in 0f..1f); if (value != field) reset(); field = value }

var apertureRadius: Float

Circular thin-lens radius in world units. Zero gives a pinhole camera with everything in focus.

    var apertureRadius: Float = 0f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }

var focusDistance: Float?

Focus-plane distance along camera.forward in world units; null follows camera.target.

    var focusDistance: Float? = null
        set(value) { require(value == null || (value.isFinite() && value > 0f)); if (value != field) reset(); field = value }

var temporalReuse: Boolean

Reproject compatible surface history on camera motion. Approximate, bounded, and off by default.

    var temporalReuse: Boolean = false
        set(value) { if (value != field) reset(); field = value }

var temporalMaxSamples: Int

Maximum old samples carried across a camera move; glossy surfaces or a changing view may retain fewer.

    var temporalMaxSamples: Int = 16
        set(value) { require(value in 1..256); if (value != field) reset(); field = value }

var temporalMotionSensitivity: Float

Camera-angle penalty: one uses conservative view rejection; zero ignores view-angle changes.

    var temporalMotionSensitivity: Float = 1f
        set(value) { require(value.isFinite() && value in 0f..1f); if (value != field) reset(); field = value }

var temporalRoughnessSensitivity: Float

Roughness penalty: one rejects polished surfaces and reduces glossy history; zero removes both restrictions.

    var temporalRoughnessSensitivity: Float = 1f
        set(value) { require(value.isFinite() && value in 0f..1f); if (value != field) reset(); field = value }

var temporalMaxBlurRadius: Float

Largest estimated defocus radius in pixels allowed to retain surface history during motion.

    var temporalMaxBlurRadius: Float = 0.5f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }

var fogDensity: Float

Exponential depth-fog density per world unit. Zero disables fog.

    var fogDensity: Float = 0f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }

var fogStartDistance: Float

Clear distance from the camera, in world units, before depth fog starts.

    var fogStartDistance: Float = 0f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }
    private var fogColorValue = Vector3f(0.5f, 0.5f, 0.5f)

var fogColor: Vector3f

Linear HDR fog colour; returned by copy. Depth fog preserves image alpha.

    var fogColor: Vector3f
        get() = Vector3f(fogColorValue)
        set(value) {
            require(value.x.isFinite() && value.y.isFinite() && value.z.isFinite())
            require(value.x >= 0f && value.y >= 0f && value.z >= 0f)
            if (value != fogColorValue) reset()
            fogColorValue = Vector3f(value)
        }

var fogBackground: Boolean

Treat background misses as infinitely distant when fog is enabled; transparent backgrounds stay transparent.

    var fogBackground: Boolean = true
        set(value) { if (value != field) reset(); field = value }

var mediumDensity: Float

Extinction of the participating medium, per world unit. Zero disables it, and is the default.

This is not fogDensity, and it does not replace it. fogDensity blends the finished radiance of a sample toward fogColor using the primary hit distance alone: a screen effect with a depth term, which cannot lighten anything, and through which light travelling to a surface is never attenuated. This property is the physical thing. Extinction is sampled along every ray segment; a real collision scatters into a new direction drawn from mediumAnisotropy's phase function; light connections from surfaces and from scattering points are attenuated over their own length; and the two estimators are combined with the same power heuristic emitterSampling uses. So a lamp inside it gets a volumetric glow, a beam through it is visible from the side, and geometry between a lamp and the camera casts a shadow into the haze instead of only onto the floor.

Both mechanisms still work, independently and unchanged. Enabling both attenuates whatever the camera sees first twice over, which is almost certainly not what you want; pick one.

Extinction is achromatic on purpose — one scalar, so a distance sample stays a single decision and no per-channel distance pdf exists anywhere. Colour comes from mediumAlbedo.

Cost is one distance sample per segment, plus at each real collision the same two shadow rays a surface interaction costs. A collision spends one of maxBounces, so a medium dense enough to scatter several times along a path needs the bounce budget raised to match or it will darken. Changing this value resets accumulation.

    var mediumDensity: Float = 0f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }
    private var mediumAlbedoValue = Vector3f(1f, 1f, 1f)

var mediumAlbedo: Vector3f

Single-scattering albedo: the fraction of mediumDensity that scatters rather than absorbs, per channel. Returned by copy.

One is purely scattering and conserves energy exactly — cloud, milk, marble. Zero is pure absorption, and reduces the medium to Beer–Lambert attenuation along every path, with no glow and no in-scattering at all. In between, a channel that absorbs more than its neighbours tints both what is seen through the medium and the light scattered out of it, and those two tints are complementary — which is the whole reason this is per channel.

    var mediumAlbedo: Vector3f
        get() = Vector3f(mediumAlbedoValue)
        set(value) {
            require(value.x.isFinite() && value.y.isFinite() && value.z.isFinite())
            require(value.x in 0f..1f && value.y in 0f..1f && value.z in 0f..1f)
            if (value != mediumAlbedoValue) reset()
            mediumAlbedoValue = Vector3f(value)
        }

var mediumAnisotropy: Float

Henyey–Greenstein anisotropy of the phase function, in (-1, 1). Zero scatters isotropically; positive is forward scattering, which is what turns a light source in haze into a halo rather than an even glow; negative is backscattering, which is what a headlight in fog does to the driver behind it.

This is the parameter to move when you want to change the medium's character rather than how much of it there is. Changing this value resets accumulation.

    var mediumAnisotropy: Float = 0f
        set(value) { require(value.isFinite() && value > -1f && value < 1f); if (value != field) reset(); field = value }
    private var mediumBoundsValue: Pair<Vector3f, Vector3f>? = null

var mediumBounds: Pair<Vector3f, Vector3f>?

The axis-aligned box the medium occupies, as low to high, or null to fill all space. Returned by copy.

Null is not the harmless default it looks like. A medium filling all space has zero transmittance to infinity, so an environment map becomes unreachable: every connection to the sky returns nothing, and every ray is certain to collide before it escapes. That is correct physics for an infinite medium, and it renders as a black void. Give the medium bounds unless the only lights in the scene are emissive geometry inside them.

    var mediumBounds: Pair<Vector3f, Vector3f>?
        get() = mediumBoundsValue?.let { Vector3f(it.first) to Vector3f(it.second) }
        set(value) {
            if (value != null) {
                require(listOf(value.first, value.second).all { it.x.isFinite() && it.y.isFinite() && it.z.isFinite() }) {
                    "Medium bounds must be finite"
                }
                require(value.second.x > value.first.x && value.second.y > value.first.y &&
                    value.second.z > value.first.z) {
                    "Medium bounds must enclose a positive volume: ${value.first} to ${value.second}"
                }
            }
            if (value != mediumBoundsValue) reset()
            mediumBoundsValue = value?.let { Vector3f(it.first) to Vector3f(it.second) }
        }

var maxBounces: Int

    var maxBounces: Int = 5
        set(value) { require(value in 1..12); if (value != field) reset(); field = value }

var environmentIntensity: Float

    var environmentIntensity: Float = 1f
        set(value) { require(value.isFinite() && value >= 0f); if (value != field) reset(); field = value }

var environmentRotation: Float

    var environmentRotation: Float = 0f
        set(value) { require(value.isFinite()); if (value != field) reset(); field = value }

var showEnvironment: Boolean

Hide only primary-ray misses. Lighting, reflections and refraction still use the environment.

    var showEnvironment: Boolean = true
        set(value) { if (field != value) reset(); field = value }
    private var backgroundValue = Vector4f(0f, 0f, 0f, 1f)

var background: Vector4f

Linear, straight RGBA background; alpha zero gives a transparent background for compositing.

    var background: Vector4f
        get() = Vector4f(backgroundValue)
        set(value) {
            require(value.x.isFinite() && value.y.isFinite() && value.z.isFinite() && value.w.isFinite())
            require(value.x >= 0f && value.y >= 0f && value.z >= 0f && value.w in 0f..1f)
            if (backgroundValue != value) reset()
            backgroundValue = Vector4f(value)
        }

var indirectClamp: Float

Maximum summed indirect luminance per path sample, in linear HDR units before exposure. Zero disables this biased firefly filter (the default). RGB is scaled together; camera-visible emission, background and first-surface lighting are preserved. Deeper glass transmission and caustics can darken. Changing this value resets accumulation.

    var indirectClamp: Float = 0f
        set(value) { require(value.isFinite() && value >= 0f); if (field != value) reset(); field = value }

var emitterSampling: Boolean

Sample emissive triangles directly, in addition to the environment, and combine that estimate with the emission a BSDF sample finds using the same power heuristic. This is unbiased: a converged image matches one rendered with it off. It costs one extra shadow ray per non-delta surface interaction, and it is what makes a small, bright lamp converge at all. Emitters behind stochastic alpha coverage, and emissive glass, stay BSDF-only. Turning it off reaches the previous estimator exactly. Changing this value resets accumulation.

    var emitterSampling: Boolean = true
        set(value) { if (value != field) reset(); field = value }

val emitterCount: Int get() = packed.emitterCount

Emissive triangles this scene offers the direct sampler; zero leaves emission to BSDF hits.

var rayEpsilon: Float

    var rayEpsilon: Float = 0.0001f
        set(value) { require(value.isFinite() && value > 0f); if (value != field) reset(); field = value }

var exposure: Float

Exposure affects display only and never discards samples.

    var exposure: Float = 1f
        set(value) { require(value.isFinite() && value >= 0f); field = value }

var samples: Int

Fresh samples per pixel since the last view change/reset, excluding reprojected history.

    @Volatile
    var samples: Int = 0
        private set

@Volatile var paused: Boolean = false

var convergence: PathTraceConvergence?

Optional per-frame convergence log. Attach one and every render appends a (wall time, samples) point plus any reset it observes, which is what a frames { repeat(n) { frame() } } loop with a println in it was doing by hand. Detach by setting it to null; the tracer holds no other reference to it.

    @Volatile
    var convergence: PathTraceConvergence? = null
    private val resetVersion = AtomicLong(0)
    @Volatile private var resetReason: String = "not yet rendered"

The reason updateFrame should give when it notices a new pipeline, set by whoever caused it.

A shader swap is observed a frame later than it is requested: reloadIntegrator replaces the module, and updateFrame is what compares pipeline against previousPipeline and discards accumulation. So the requester cannot name the reset itself — its own reset would be overwritten by updateFrame's a frame later, which is exactly what the first live run of this reported (reason='property edit', generation bumped twice). Handing the reason forward keeps the generation count at one per reset and the reason specific.

    @Volatile
    private var pendingPipelineReason: String? = null
    private var seenReset = -1L
    private var previousCamera = FloatArray(0)
    private var sequenceIndex = 0
    private var historyWriteIndex = 0
    private var historyBuffers = emptyList<VulkanBuffer>()
    private var previousFrameCamera = Array(4) { Vector4f() }
    private val disposals = mutableListOf<() -> Unit>()
    private val packed = scene.pack()
    private val envWidth = environment.width
    private val envHeight = environment.height
    private val accumulation: VulkanBuffer

val materialModule: ComputeModulePointer

val materialMesh: ComputeMesh

val output: RenderToTexture

Premultiplied linear HDR RGBA; alpha is primary visibility coverage, not sample count.

    private val resolve: ModulePointer
    private var blit: ModulePointer? = null
    private val ownedUniforms = mutableListOf<BufferParameters>()
    private val sceneBuffers = linkedMapOf<String, VulkanBuffer>()
    private var previousPipeline = 0L
    private val outputPass = Target { command, index -> if (!closed && samples > 0) output.render(command, index) }
    private var installed = false
    private var closed = false

val triangleCount: Int get() = packed.triangles.size

var frameOutput: PathTraceFrameOutput?

Optional unaccumulated radiance and matching primary guides. The tracer owns their lifetime.

    var frameOutput: PathTraceFrameOutput? = null
        private set

fun enableFrameOutput(): PathTraceFrameOutput

Create this tracer's frameOutput, or return the one it already has; the first call resets accumulation. Reconstruction guides currently require opaque, non-transmissive materials, so a scene with glass or partial coverage has to stay on the progressive output.

    fun enableFrameOutput(): PathTraceFrameOutput {
        check(!closed)
        require(packed.materials.all { !it.transmission && it.alphaMode == PathTraceMaterial.AlphaMode.OPAQUE }) {
            "Reconstruction guides currently require opaque materials; retain progressive output for glass/coverage"
        }
        return frameOutput ?: PathTraceFrameOutput(this).also { frameOutput = it; reset("frame output enabled") }
    }

init

body — 113 lines, from line 318
    init {
        require(width in 1..8192 && height in 1..8192 && width.toLong() * height <= 16_777_216)
        require(requestedBackend != Backend.RAY_QUERY || renderer.device.supportsRayQuery) { "This device has no enabled KHR ray queries; use AUTO or COMPUTE_BVH" }
        backend = if (requestedBackend != Backend.COMPUTE_BVH && renderer.device.supportsRayQuery) Backend.RAY_QUERY else Backend.COMPUTE_BVH
        try {
            disposals += {
                ownedUniforms.mapNotNull { it.result }.flatMap { it.buffers }.distinct().forEach { it.cleanup() }
                ownedUniforms.clear()
            }
            val triangles = upload(packed.triangles.size * 240L, packed::writeTriangles)
            val nodes = upload(packed.nodes.size * 48L, packed::writeNodes)
            val materials = upload(packed.materials.size * PathTraceMaterial.STRIDE.toLong(), packed::writeMaterials)
            val distribution = upload(environment.distribution.size * 4L) { it.asFloatBuffer().put(environment.distribution) }
            val emitterTable = upload(packed.emitterByteSize, packed::writeEmitters)
            val emitterLookup = upload(packed.emitterLookupByteSize, packed::writeEmitterLookup)
            val textures = upload(packed.textureByteSize, packed::writeTextures)
            val directions = upload(sobolWords.size * 4L) { it.asIntBuffer().put(sobolWords) }
            accumulation = own(VulkanBuffer(renderer, width.toLong() * height * 16,
                VK_BUFFER_USAGE_STORAGE_BUFFER_BIT or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, needsDeviceAddress = true)) { cleanup() }
            val bytes = MemoryUtil.memAlloc(environment.pixels.size * 4)
            val texture = try {
                bytes.asFloatBuffer().put(environment.pixels)
                own(LoadedTexture.fromRawRGBA("$name-environment", envWidth, envHeight, bytes, renderer,
                    VK_FORMAT_R32G32B32A32_SFLOAT)) { cleanup() }
            } finally { MemoryUtil.memFree(bytes) }
            texture.transferNow()
            val sampler = own(TextureSampler(renderer, 1, false,
                addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT)) { cleanup() }
            val provider = object : HasImageViewArray { override val imageViewArray = listOf<HasImageView>(texture) }
            val tlas = if (backend == Backend.RAY_QUERY) {
                val vertices = own(VulkanBuffer(renderer, packed.triangles.size * 36L,
                    VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
                    VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, needsDeviceAddress = true)) { cleanup() }
                vertices.mapAndWrite_bytes { packed.triangles.forEach { t -> t.p.forEach { p -> putFloat(p.x).putFloat(p.y).putFloat(p.z) } } }
                val blas = own(AccelerationStructure(renderer, "$name-blas")) { cleanup() }
                blas.buildBottomLevelNow(listOf(AccelerationStructureTriangles(vertices,
                    maxVertex = packed.triangles.size * 3 - 1, primitiveCount = packed.triangles.size)))
                own(AccelerationStructure(renderer, "$name-tlas")) { cleanup() }.also { it.buildTopLevelNow(listOf(RayTracingInstance(blas))) }
            } else null
            output = target ?: RenderToTexture(renderer) {
                this.width = this@PathTracingRenderer.width; this.height = this@PathTracingRenderer.height
                format = VK_FORMAT_R32G32B32A32_SFLOAT; useDepth = false
                debugName = "${this@PathTracingRenderer.name}-output"
            }
            require(output.renderer === renderer && output.width == width && output.height == height)
            require(output.colorFormats.size == 1 && output.colorFormats.single() in listOf(VK_FORMAT_R32G32B32A32_SFLOAT, VK_FORMAT_R16G16B16A16_SFLOAT)) {
                "Path tracer output needs one floating-point HDR color attachment"
            }
            val errors = mutableListOf<String>()
            materialModule = moduleFactory(Parameters(renderer,
                name = name, compute = shader(backend == Backend.RAY_QUERY),
                computeSourceName = "pathtracing/trace.comp",
                // The CPU may prepare the next frame while a previous dispatch still reads its uniforms.
                uboResolver = { it.apply { perFrame = true; ownedUniforms += this } },
                textureResolver = { if (it.name == "environmentMap") provider to sampler else null }, accelerationStructureResolver = { tlas },
                errorHandler = { message, _ -> errors += message }
            ))
            val module = materialModule.current
            disposals += { materialModule.current.cleanup() }
            check(module.valid) { "Path tracer shader failed: ${errors.joinToString("\n")}" }
            materialMesh = ComputeMesh(renderer, materialModule).also { it.x = (width + 7) / 8; it.y = (height + 7) / 8 }
            materialModule += "trace" to materialMesh
            materialMesh.push.setMembers(module.ci.pushConstants.flatMap { it.members })
            sceneBuffers.putAll(mapOf("triangles" to triangles, "nodes" to nodes, "materials" to materials,
                "accumulation" to accumulation, "distribution" to distribution, "directions" to directions, "textures" to textures,
                "emitterTable" to emitterTable, "emitterLookup" to emitterLookup))
            val display = own(Module(parameters(renderer) {
                name = "${this@PathTracingRenderer.name}-resolve"
                +output
                vertex = DISPLAY_VERTEX; fragment = RESOLVE_FRAGMENT
                depthTest = false; depthWrite = false; blend = false
                errorHandler = { message, _ -> errors += message }
            })) { cleanup() }
            check(display.valid) { "Path tracer display shader failed: ${errors.joinToString("\n")}" }
            resolve = display.pointer()
            val screen = Mesh(renderer, resolve)
            screen.resize(3, 0, resolve)
            screen.push.setMembers(display.fi.pushConstants.single().members)
            screen.push["accumulation"] = accumulation
            screen.push["width"] = width; screen.push["height"] = height
            resolve.current.meshes["screen"] = screen
            output.modules["$name-resolve"] = resolve

        } catch (failure: Throwable) {
            disposals.asReversed().forEach { runCatching(it) }; disposals.clear()
            throw failure
        }
    }

    private fun <T> own(value: T, dispose: T.() -> Unit): T = value.also { disposals += { it.dispose() } }
    private fun upload(size: Long, writer: (ByteBuffer) -> Unit): VulkanBuffer {
        val destination = own(VulkanBuffer(renderer, size,
            VK_BUFFER_USAGE_STORAGE_BUFFER_BIT or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or VK_BUFFER_USAGE_TRANSFER_DST_BIT,
            VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, needsDeviceAddress = true)) { cleanup() }
        val staging = VulkanBuffer(renderer, size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
        val command = CommandBuffer(renderer.commandPool, primary = true, oneTimeSubmit = true)
        val fence = Fence(renderer.device, signaled = false)
        try {
            staging.mapAndWrite_bytes { writer(this) }
            command.beginRecording()
            MemoryStack.stackPush().use { stack ->
                vkCmdCopyBuffer(command.vkCommandBuffer, staging.buffer, destination.buffer, VkBufferCopy.calloc(1, stack).size(size))
            }
            memoryBarrier(command, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT,
                VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT)
            command.endRecording()
            MemoryStack.stackPush().use { renderer.graphQueue.submit(it.pointers(command.vkCommandBuffer), null, null, null, fence) }
            fence.fenceWait()
        } finally { fence.cleanup(); command.cleanup(); staging.cleanup() }
        return destination
    }

fun install(): PathTracingRenderer

Register compute and RTT passes. Reusing a name retires the previous tracer.

    fun install(): PathTracingRenderer {
        check(!closed)
        if (!installed) {
            val previous = renderer.computeList.others[name]
            require(previous == null || previous is PathTracingRenderer) { "Scene name '$name' belongs to another kind of target" }
            previous?.close()
            renderer.computeList.others[name] = this
            renderer.renderList.preTargets["$name-output"] = outputPass
            installed = true
        }
        return this
    }

override fun render(command: CommandBuffer, index: Int)

    override fun render(command: CommandBuffer, index: Int) {
        if (paused || closed) return
        frameOutput?.let {
            require(apertureRadius == 0f && mediumDensity == 0f && fogDensity == 0f && (showEnvironment || background.w == 1f)) {
                "Path reconstruction requires a pinhole, opaque surface signal without fog/participating media"
            }
        }
        updateFrame()
        memoryBarrier(command, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT,
            VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT)
        materialModule.current.render(command, afield.math.linalg.Rectanglef())
        graphicsMemoryBarrier(command, accumulation)
        if (temporalReuse) historyWriteIndex = 1 - historyWriteIndex
        sequenceIndex += samplesPerFrame
        blit?.enable()
        samples += samplesPerFrame
        convergence?.record(this)
    }

fun blitToScreen(blend: Boolean = true): ModulePointer

Optional second pass: sample the RTT, apply exposure/Reinhard, and composite on screen.

    fun blitToScreen(blend: Boolean = true): ModulePointer {
        check(!closed)
        blit?.let { return it }
        val sampler = own(TextureSampler(renderer, 1, false)) { cleanup() }
        val display = own(Module(parameters(renderer) {
            name = "${this@PathTracingRenderer.name}-display"
            vertex = DISPLAY_VERTEX; fragment = DISPLAY_FRAGMENT
            depthTest = false; depthWrite = false; this.blend = blend
            srcColor = VK_BLEND_FACTOR_ONE; dstColor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA
            srcAlpha = VK_BLEND_FACTOR_ONE; dstAlpha = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA
            textureResolver = { if (it.name == "sourceTex") output to sampler else null }
        })) { cleanup() }
        check(display.valid) { "Path tracer presentation shader failed" }
        val pointer = display.pointer()
        val screen = Mesh(renderer, pointer)
        screen.resize(3, 0, pointer)
        screen.push.setMembers(display.fi.pushConstants.single().members)
        screen.push.bind("exposure") { exposure }
        screen.push.bind("aspectRatio") {
            val extent = renderer.swapChain.swapChainExtent
            (extent.width().toFloat() / extent.height().coerceAtLeast(1)) / (width.toFloat() / height)
        }
        pointer.current.meshes["screen"] = screen
        if (samples == 0) pointer.disable()
        renderer += "$name-display" to pointer
        blit = pointer
        return pointer
    }

fun reset(reason: String = "property edit")

Discard accumulated samples and start the estimate again.

reason is recorded for lastResetReason and is the only argument — it has a default, so the twenty-odd property setters above, which all call a bare reset(), keep working and all report the same honest "property edit". Pass something specific when you know better; a camera move, a lens edit and an exhausted Sobol sequence are named by updateFrame, and reloadIntegrator names itself.

    fun reset(reason: String = "property edit") {
        resetReason = reason
        resetVersion.incrementAndGet()
    }

val resetGeneration: Long get() = resetVersion.get()

How many times accumulation has been discarded. samples counts within one generation, so a generation that has not advanced is the proof that a sample count is comparable to an earlier one — which four hand-written printf loops in one live session were trying to establish.

val lastResetReason: String get() = resetReason

Why accumulation was last discarded. See reset.

fun integratorSource(): String

The integrator source this tracer compiles: the packaged trace.comp with whatever HARDWARE_QUERY state this backend needs. The starting point for reloadIntegrator.

    fun integratorSource(): String = withHardwareQuery(packagedIntegrator(), backend == Backend.RAY_QUERY)

fun integratorSource(file: File): String

integratorSource rebuilt from file — the working-copy trace.comp you are editing, which the packaged resource will not reflect until the next Gradle build. The backend define is applied for you, so a file that already carries one, or lacks one, both come out right.

    fun integratorSource(file: File): String = withHardwareQuery(file.readText(), backend == Backend.RAY_QUERY)

fun reloadIntegrator(source: String = integratorSource()): IntegratorReload

Recompile the integrator on this running tracer, keeping the scene, the environment, the camera pose and every tuning property.

This is the whole-integrator counterpart of pathTracingHere, which puts only the material function in a box's editable compute property and reloads it through the ordinary Reload Compute Shader action. Reach for that one when the material is what you are changing; reach for this when you are editing trace.comp itself.

Failure semantics are the module layer's, unchanged: a source that does not compile keeps the last working pipeline and the image carries on from where it was. What this adds is telling you which non-success you got — ComputeModulePointer.reload answers false both for "identical source, nothing to do" and for "it did not compile", so a caller wrapping it in check(...) throws on a no-op. See IntegratorReload.

On success the superseded ComputeModule is retired through Renderer.cleanup, after the frames still reading it have retired. That is not optional: init registers a disposal which reads materialModule.current at close time, so close() retires the module that is current then and the superseded one is this method's to release.

Accumulation resets itself — updateFrame notices the new pipeline and discards history, which is also where the superseded per-frame uniform buffers are retired. samples is set to 0 here, at once, so a wait on the sample count after a reload can only be satisfied by the new integrator. The reason is named here and the generation advances once, in that next frame.

    fun reloadIntegrator(source: String = integratorSource()): IntegratorReload {
        check(!closed) { "This tracer is closed" }
        val old = materialModule.current
        if (old.valid && source == old.parameters.compute) return IntegratorReload.unchanged(source)
        val errors = mutableListOf<String>()
        // computeIntrospection = null forces re-reflection. A different integrator can declare
        // different buffers or push constants, and a retained Introspection would bind the old ABI
        // to the new shader.
        val next = old.parameters.copy(compute = source, computeIntrospection = null)
        val changed = materialModule.reload(next) { message, cause ->
            errors += if (cause == null) message else "$message (${cause.message ?: cause::class.simpleName})"
        }
        if (!changed) return IntegratorReload.failed(source, errors)
        renderer.cleanup += Runnable { old.cleanup() }
        // `resetReason` now, so a synchronous read is already right; `pendingPipelineReason` so
        // `updateFrame`'s own reset a frame later says the same thing instead of "property edit".
        resetReason = "integrator reloaded"
        pendingPipelineReason = "integrator reloaded"
        samples = 0
        return IntegratorReload.reloaded(source)
    }

fun reloadIntegrator(file: File): IntegratorReload = reloadIntegrator(integratorSource(file))

reloadIntegrator from a working-copy trace.comp; see integratorSource.

source — 109 lines, from line 582
    private fun updateFrame() {
        val forward = camera.forward; val right = camera.right
        val up = Vector3f(right).cross(forward).normalize()
        require(camera.p.fov.isFinite() && camera.p.fov > 0.0 && camera.p.fov < Math.PI) {
            "Camera field of view must lie between zero and pi radians"
        }
        val scale = tan(camera.p.fov * 0.5).toFloat()
        val focus = focusDistance ?: camera.distanceToTarget()
        require(apertureRadius == 0f || (focus.isFinite() && focus > 0f)) {
            "Depth of field needs a positive focus distance or a camera target away from the eye"
        }
        require(scale.isFinite() && scale > 0f) { "Camera field of view must lie between zero and pi radians" }
        val values = floatArrayOf(camera.at.x, camera.at.y, camera.at.z, forward.x, forward.y, forward.z,
            right.x, right.y, right.z, up.x, up.y, up.z, scale, if (apertureRadius > 0f) focus else 0f)
        require(values.all { it.isFinite() }) { "Path tracer camera contains non-finite values" }
        val pipeline = materialModule.current.pipeline
        if (pipeline != previousPipeline) {
            // A `pathTracingHere` material reload arrives here with no reason set; an integrator
            // reload sets one. Either way this is the single increment for a shader swap.
            //
            // Except on the very first frame, where `previousPipeline` is still its initial zero
            // and this branch is the pipeline being *seen*, not swapped. The live demo reported
            // "last reset: shader reloaded" on a tracer that had never reloaded anything.
            reset(pendingPipelineReason ?: if (previousPipeline == 0L) "first frame" else "shader reloaded")
            pendingPipelineReason = null
            previousPipeline = pipeline
            // Full reload allocates new UBOs. Retire our superseded allocations after in-flight frames.
            val active = materialModule.current.uniforms.values.filterIsInstance<Module.Uniform>().map { it.buffer }.toSet()
            val unused = ownedUniforms.filter { it.result != null && it.result !in active }
            if (unused.isNotEmpty()) {
                ownedUniforms.removeAll(unused.toSet())
                val release = unused.mapNotNull { it.result }.flatMap { it.buffers }.distinct()
                renderer.cleanup += Runnable { release.forEach { it.cleanup() } }
            }
        }
        val revision = resetVersion.get()
        val cameraChanged = !values.contentEquals(previousCamera)
        val viewChanged = previousCamera.size != values.size || (0..12).any { values[it] != previousCamera[it] }
        // A focus-only edit is not camera motion. Never blend two different lens configurations.
        val focusOnlyChange = cameraChanged && !viewChanged
        val hardReset = previousCamera.isEmpty() || seenReset != revision || focusOnlyChange ||
            sequenceIndex > 16_000_000 - samplesPerFrame
        val historyMode = when {
            !temporalReuse -> -1
            hardReset -> 0
            cameraChanged -> 2
            else -> 1
        }
        // Only these three discard samples without going through `reset()`, so they are the only
        // reasons `lastResetReason` cannot already know. `seenReset != revision` means an explicit
        // `reset()` is pending and has already named itself — do not overwrite it.
        if ((hardReset || cameraChanged) && seenReset == revision) {
            resetReason = when {
                previousCamera.isEmpty() -> "first frame"
                focusOnlyChange -> "focus or lens edit"
                sequenceIndex > 16_000_000 - samplesPerFrame -> "Sobol sequence exhausted"
                else -> "camera motion"
            }
        }
        if (hardReset || cameraChanged) samples = 0
        if (hardReset || (cameraChanged && !temporalReuse && frameOutput == null)) sequenceIndex = 0
        previousCamera = values; seenReset = revision
        if (temporalReuse && historyBuffers.isEmpty()) {
            // Two disjoint histories: this dispatch only reads the previous one and writes the other.
            historyBuffers = List(2) {
                own(VulkanBuffer(renderer, width.toLong() * height * 64,
                    VK_BUFFER_USAGE_STORAGE_BUFFER_BIT or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, needsDeviceAddress = true)) { cleanup() }
            }
        }
        val buffers = materialModule.current.liveUniformStructures.getValue("SceneBuffers")
        sceneBuffers.forEach { (name, buffer) -> buffers[name] = buffer }
        // In disabled mode the shader never dereferences these placeholders.
        buffers["historyRead"] = historyBuffers.getOrNull(1-historyWriteIndex) ?: accumulation
        buffers["historyWrite"] = historyBuffers.getOrNull(historyWriteIndex) ?: accumulation
        buffers["frameOutput"] = frameOutput?.buffer ?: accumulation
        val frame = materialModule.current.liveUniformStructures.getValue("Frame")
        frame["reconstruction"] = frameOutput?.let { Vector4f(it.jitter.x, it.jitter.y, it.near, it.far) } ?: Vector4f(0f)
        frame["frameOutputEnabled"] = Int4(if (frameOutput != null) 1 else 0, if (hardReset) 1 else 0, 0, 0)
        frame["previousOrigin"] = previousFrameCamera[0]
        frame["previousRight"] = previousFrameCamera[1]
        frame["previousUp"] = previousFrameCamera[2]
        frame["previousForward"] = previousFrameCamera[3]
        previousFrameCamera = arrayOf(Vector4f(camera.at, apertureRadius),
            Vector4f(right.mul(scale * width / height), if (apertureRadius > 0f) focus else 1f),
            Vector4f(up.mul(scale), 0f), Vector4f(forward, 0f))
        frame["origin"] = previousFrameCamera[0]
        frame["right"] = previousFrameCamera[1]
        frame["up"] = previousFrameCamera[2]
        frame["forward"] = previousFrameCamera[3]
        frame["temporal"] = Int4(historyMode, sequenceIndex, temporalMaxSamples, 0)
        frame["historyPolicy"] = Vector4f(temporalMotionSensitivity, temporalRoughnessSensitivity, temporalMaxBlurRadius, 0f)
        frame["fog"] = Vector4f(fogColorValue, fogDensity)
        frame["fogOptions"] = Vector4f(fogStartDistance, if (fogBackground) 1f else 0f, 0f, 0f)
        frame["medium"] = Vector4f(mediumAlbedoValue, mediumDensity)
        val bounds = mediumBoundsValue
        frame["mediumOptions"] = Vector4f(mediumAnisotropy, if (bounds != null) 1f else 0f, 0f, 0f)
        // The shader reads these only when the bounded flag is set; a placeholder keeps the
        // uniform initialised rather than carrying whatever the previous frame left there.
        frame["mediumLow"] = Vector4f(bounds?.first ?: Vector3f(0f), 0f)
        frame["mediumHigh"] = Vector4f(bounds?.second ?: Vector3f(0f), 0f)
        frame["image"] = Int4(width, height, samples, samplesPerFrame)
        frame["scene"] = Int4(packed.nodes.size, envWidth, envHeight, maxBounces)
        frame["lighting"] = Vector4f(environmentIntensity, environmentRotation, rayEpsilon, if (showEnvironment) 1f else 0f)
        frame["sampling"] = Vector4f(indirectClamp, pixelJitter, apertureRadius, if (apertureRadius > 0f) focus else 1f)
        frame["emitters"] = Int4(if (emitterSampling) packed.emitterCount else 0, 0, 0, 0)
        frame["background"] = Vector4f(backgroundValue.x * backgroundValue.w, backgroundValue.y * backgroundValue.w,
            backgroundValue.z * backgroundValue.w, backgroundValue.w)
    }

fun readLinear(): FloatArray

Synchronous GPU readback for export/verification. Call on the render thread, outside frame recording.

    fun readLinear(): FloatArray {
        check(!closed && samples > 0) { "Render at least one frame before readback" }
        return readBuffer(accumulation)
    }

internal fun readHistorySampleCounts(): FloatArray

    internal fun readHistorySampleCounts(): FloatArray {
        check(temporalReuse && samples > 0)
        val values = readBuffer(historyBuffers[1-historyWriteIndex])
        return FloatArray(width*height) { values[it*16+15] }
    }

    private fun readBuffer(source: VulkanBuffer): FloatArray {
        val staging = VulkanBuffer(renderer, source.requestedSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
        val command = CommandBuffer(renderer.commandPool, primary = true, oneTimeSubmit = true)
        val fence = Fence(renderer.device, signaled = false)
        try {
            command.beginRecording()
            memoryBarrier(command, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT,
                VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_ACCESS_2_TRANSFER_READ_BIT)
            MemoryStack.stackPush().use { stack ->
                val region = VkBufferCopy.calloc(1, stack).size(source.requestedSize)
                vkCmdCopyBuffer(command.vkCommandBuffer, source.buffer, staging.buffer, region)
            }
            memoryBarrier(command, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT,
                VK_PIPELINE_STAGE_2_HOST_BIT, VK_ACCESS_2_HOST_READ_BIT)
            command.endRecording()
            MemoryStack.stackPush().use { renderer.graphQueue.submit(it.pointers(command.vkCommandBuffer), null, null, null, fence) }
            fence.fenceWait()
            return FloatArray((source.requestedSize/4).toInt()).also { data -> staging.mapAndRead { get(data) } }
        } finally { fence.cleanup(); command.cleanup(); staging.cleanup() }
    }

override fun close()

Detach now; retire GPU resources through Field's frames-in-flight cleanup queue.

    override fun close() {
        if (closed) return
        closed = true
        frameOutput?.close()
        if (installed) {
            renderer.computeList.others.remove(name, this)
            renderer.renderList.preTargets.remove("$name-output", outputPass)
        }
        blit?.let { renderer.renderList.modules.remove("$name-display", it) }
        output.modules.remove("$name-resolve", resolve)
        val release = disposals.asReversed().toList(); disposals.clear()
        renderer.cleanup += Runnable { release.forEach { it() } }
    }

    private class Int4(val x: Int, val y: Int, val z: Int, val w: Int) : WritesToBuffer {
        override fun writeTo(b: ByteBuffer, baseOffset: Int) {
            b.putInt(baseOffset, x).putInt(baseOffset+4, y).putInt(baseOffset+8, z).putInt(baseOffset+12, w)
        }
    }

companion object

internal val sobolWords: IntArray by lazy

        internal val sobolWords: IntArray by lazy {
            val data = PathTracingRenderer::class.java.getResourceAsStream("/afield/four/pathtracing/sobol-100x32.bin")!!.use { it.readBytes() }
            check(data.size == 100 * 32 * 4)
            IntArray(100 * 32).also { ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(it) }
        }

internal fun shader(hardware: Boolean): String

        internal fun shader(hardware: Boolean): String = withHardwareQuery(packagedIntegrator(), hardware)

fun packagedIntegrator(): String

The integrator as this build packages it: trace.comp off the classpath, no define applied.

        fun packagedIntegrator(): String =
            PathTracingRenderer::class.java.getResource("/afield/four/pathtracing/trace.comp")!!.readText()

fun withHardwareQuery(source: String, hardware: Boolean): String

Put source into the HARDWARE_QUERY state a Backend needs: defined for Backend.RAY_QUERY, absent for Backend.COMPUTE_BVH. This is the one step that stood between a live tracer and its own integrator — it existed only inside two private call sites, so a box replacing the integrator had to retype it and guess the polarity.

Any existing define is stripped first, so the transform is idempotent and safe to apply to source that has already been through it — the order _pathTracingHere uses.

body — 50 lines, from line 767
        fun withHardwareQuery(source: String, hardware: Boolean): String {
            val bare = source.replace("#define HARDWARE_QUERY\n", "").replace("#define HARDWARE_QUERY", "")
            return if (hardware) bare.replace("#version 460", "#version 460\n#define HARDWARE_QUERY") else bare
        }
        private fun memoryBarrier(cmd: CommandBuffer, srcStage: Long, srcAccess: Long, dstStage: Long, dstAccess: Long) {
            MemoryStack.stackPush().use { stack ->
                val barrier = VkMemoryBarrier2.calloc(1, stack).sType(VK_STRUCTURE_TYPE_MEMORY_BARRIER_2)
                    .srcStageMask(srcStage).srcAccessMask(srcAccess).dstStageMask(dstStage).dstAccessMask(dstAccess)
                val dependency = VkDependencyInfo.calloc(stack).sType(VK_STRUCTURE_TYPE_DEPENDENCY_INFO).pMemoryBarriers(barrier)
                vkCmdPipelineBarrier2(cmd.vkCommandBuffer, dependency)
            }
        }
        private const val DISPLAY_VERTEX = """
            #version 460
            layout(location=0) out vec2 uv;
            void main() {
                vec2 p=vec2((gl_VertexIndex<<1)&2,gl_VertexIndex&2);
                gl_Position=vec4(p*2.0-1.0,0,1); uv=vec2(p.x,1.0-p.y);
            }
        """
        private const val RESOLVE_FRAGMENT = """
            #version 460
            #extension GL_EXT_buffer_reference : require
            layout(buffer_reference,std430,buffer_reference_align=16) readonly buffer Accumulation { vec4 items[]; };
            layout(push_constant) uniform Resolve { Accumulation accumulation; int width; int height; } pc;
            layout(location=0) in vec2 uv;
            layout(location=0) out vec4 color;
            void main() {
                ivec2 p=clamp(ivec2(uv*vec2(pc.width,pc.height)),ivec2(0),ivec2(pc.width-1,pc.height-1));
                color=pc.accumulation.items[p.y*pc.width+p.x];
            }
        """
        private const val DISPLAY_FRAGMENT = """
            #version 460
            layout(set=0,binding=0) uniform sampler2D sourceTex;
            layout(push_constant) uniform Display { float exposure; float aspectRatio; } pc;
            layout(location=0) in vec2 uv;
            layout(location=0) out vec4 color;
            void main() {
                vec2 sampleUV=uv-0.5;
                if (pc.aspectRatio>1.0) sampleUV.x*=pc.aspectRatio; else sampleUV.y/=pc.aspectRatio;
                sampleUV+=0.5;
                if (any(lessThan(sampleUV,vec2(0))) || any(greaterThan(sampleUV,vec2(1)))) { color=vec4(0); return; }
                vec4 sampleColor=texture(sourceTex,sampleUV);
                vec3 hdr=max(sampleColor.rgb/max(sampleColor.a,1e-20)*pc.exposure,vec3(0));
                color=vec4(hdr/(1.0+hdr)*sampleColor.a,sampleColor.a);
            }
        """
    }
}