Skip to content

PathTraceInstrument.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/PathTraceInstrument.kt · 577 lines

package afield.graphics.scene.fabricated

import afield.document.graph.Box
import afield.ui.visualization.CellRole
import afield.ui.output.FieldMarkdown
import afield.ui.visualization.H2ValueSnapshot
import afield.runtime.lifecycle.RunsAt
import afield.ui.visualization.Table
import afield.ui.visualization.facts
import afield.runtime.lifecycle.newRoot
import afield.ui.visualization.registerH2Value
import afield.ui.visualization.registerValuePrinter
import afield.ui.visualization.showSignals
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import space.kscience.plotly.Plot
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.max
import kotlin.math.min

The path tracer's instrument: what it is doing, why it restarted, and how fast it is converging.

Answering any of that from a box by hand means a frames { repeat(180) { frame() } } loop with a println inside: it costs frames of its own, it misses a reset that lands between frame boundaries, and it cannot see the camera basis the tracer actually built. What is here instead is a .h printer, a .h2 drill-down and PathTraceConvergence, which the tracer appends to from its own render. Recompiling the integrator in place, the other thing such a loop gets written for, is PathTracingRenderer.reloadIntegrator.

The two renderings

registerPathTraceInstrument gives PathTracingRenderer both an afield.ui.visualization.registerValuePrinter and an afield.ui.visualization.registerH2Value entry, because the class is not a data class and reflection alone serves the two panels unevenly. H2Reflect reflects any type, so .h2 reaches every public tuning property without help; .h with no printer falls through to ValueVisualizer's formatFallbackObject, which prints the class name and an identity hash and nothing else — while the Field 4 types the tracer is built out of (Renderer, Camera, ComputeModulePointer, RenderToTexture, VulkanBuffer, …) all have printers in FourVisualizers.kt.

So the two renderings do different jobs, and the split is why the .h panel stays short:

  • .h is the glance. One header line and a handful of rows, with the rows that are at their defaults dropped — so a plain tracer shows five rows, and each of the five optional groups (a non-agreeing up, a lens, temporal reuse, fog, an attached convergence log) costs one more, to a maximum of ten. It is meant to fit in the gap above the next box.
  • .h2 is the drill-down. A strict superset of what reflection reaches on its own: the same values, grouped rather than flat, with the derived facts beside them and the sub-objects still reachable so each one lands on its own printer.

The up vector the tracer actually uses

The tracer does not use camera.up. updateFrame builds its own up as right × forward, i.e. the part of the declared up that is perpendicular to the view direction, normalised, and that is what reaches the Frame uniform. A declared up of (0, 3, 0), or one tilted 20° out of the view plane, gives a different vector from the one the image is built with, and no property tree can show it, because the value is a local inside a private method. PathTraceCameraBasis computes both and reports the angle between them, and the .h row appears only when they disagree.

status

data class PathTraceCameraBasis

The camera as the tracer resolves it, declared and effective side by side.

declaredUp is camera.up as written. effectiveUp is right × forward normalised — what PathTracingRenderer.updateFrame actually writes into the Frame uniform. They agree only when the declared up is already unit-length and perpendicular to the view direction.

data class PathTraceCameraBasis(
    val at: Vector3f,
    val target: Vector3f,
    val declaredUp: Vector3f,
    val effectiveUp: Vector3f,
    val right: Vector3f,
    val forward: Vector3f,
    val fovRadians: Double,
    val distanceToTarget: Float
) {

val upTiltDegrees: Double

Angle between declaredUp and effectiveUp, in degrees, or NaN when either is degenerate.

Clamped before acos because a dot product of two normalised floats reaches 1.0000001 often enough to matter, and acos of that is NaN — which would read as "degenerate" on a camera that is perfectly fine.

    val upTiltDegrees: Double
        get() {
            val a = Vector3f(declaredUp)
            val b = Vector3f(effectiveUp)
            if (a.lengthSquared() < 1e-12f || b.lengthSquared() < 1e-12f) return Double.NaN
            val cosine = a.normalize().dot(b.normalize()).toDouble()
            return Math.toDegrees(acos(max(-1.0, min(1.0, cosine))))
        }

val upAgrees: Boolean get() = upTiltDegrees.let { !it.isNaN() && it < 0.05 }

Whether the declared up is the one the image is built with, to within a twentieth of a degree.

data class PathTraceConvergenceSummary

Whatever a PathTraceConvergence has recorded, reduced to the five numbers worth a row.

data class PathTraceConvergenceSummary(
    val points: Int,
    val seconds: Double,
    val samples: Int,
    val resets: Int
) {

val samplesPerSecond: Double get() = if (seconds > 1e-9) samples / seconds else Double.NaN

Fresh samples per wall second over the recorded window, or NaN when nothing has elapsed.

data class PathTraceStatus

An immutable snapshot of everything the instrument shows, read once off a live tracer.

Immutable and plain: that is what makes the two renderings below testable without a Vulkan device, and it means the .h a box printed cannot change under it while the render thread carries on. tuning is ordered, holds every property the reflective .h2 was already showing, and is the group both renderings read for "the other twenty".

data class PathTraceStatus(
    val name: String,
    val backend: PathTracingRenderer.Backend,
    val width: Int,
    val height: Int,
    val triangleCount: Int,
    val samples: Int,
    val samplesPerFrame: Int,
    val paused: Boolean,
    val resetGeneration: Long,
    val lastResetReason: String,
    val camera: PathTraceCameraBasis,
    val tuning: Map<String, Any?>,
    val recorded: PathTraceConvergenceSummary? = null
)

fun PathTracingRenderer.status(): PathTraceStatus

Read this tracer's PathTraceStatus now.

Every read is a field read or a small vector allocation — nothing here builds a pipeline, touches the GPU or blocks on the render thread, so it is safe to call from a box, from a coroutine, or from inside a frame loop.

body — 55 lines, from line 144
fun PathTracingRenderer.status(): PathTraceStatus {
    val forward = camera.forward
    val right = camera.right
    return PathTraceStatus(
        name = name,
        backend = backend,
        width = width,
        height = height,
        triangleCount = triangleCount,
        samples = samples,
        samplesPerFrame = samplesPerFrame,
        paused = paused,
        resetGeneration = resetGeneration,
        lastResetReason = lastResetReason,
        camera = PathTraceCameraBasis(
            at = Vector3f(camera.at),
            target = Vector3f(camera.target),
            declaredUp = Vector3f(camera.up),
            effectiveUp = Vector3f(right).cross(forward).normalize(),
            right = right,
            forward = forward,
            fovRadians = camera.p.fov,
            distanceToTarget = camera.distanceToTarget()
        ),
        tuning = linkedMapOf(
            "maxBounces" to maxBounces,
            "samplesPerFrame" to samplesPerFrame,
            "pixelJitter" to pixelJitter,
            "apertureRadius" to apertureRadius,
            "focusDistance" to focusDistance,
            "exposure" to exposure,
            "indirectClamp" to indirectClamp,
            "rayEpsilon" to rayEpsilon,
            "environmentIntensity" to environmentIntensity,
            "environmentRotation" to environmentRotation,
            "showEnvironment" to showEnvironment,
            "background" to background,
            "temporalReuse" to temporalReuse,
            "temporalMaxSamples" to temporalMaxSamples,
            "temporalMotionSensitivity" to temporalMotionSensitivity,
            "temporalRoughnessSensitivity" to temporalRoughnessSensitivity,
            "temporalMaxBlurRadius" to temporalMaxBlurRadius,
            "fogDensity" to fogDensity,
            "fogStartDistance" to fogStartDistance,
            "fogColor" to fogColor,
            "fogBackground" to fogBackground,
            "emitterSampling" to emitterSampling,
            "mediumDensity" to mediumDensity,
            "mediumAlbedo" to mediumAlbedo,
            "mediumAnisotropy" to mediumAnisotropy,
            "mediumBounds" to mediumBounds
        ),
        recorded = convergence?.summary
    )
}

.h, the glance

private fun fmt(v: Float): String = when {
    v.isNaN() -> "NaN"
    v == v.toInt().toFloat() && abs(v) < 1e6 -> v.toInt().toString()
    abs(v) >= 1000f || (abs(v) < 0.001f && v != 0f) -> "%.3g".format(v)
    else -> "%.3f".format(v).trimEnd('0').trimEnd('.')
}

private fun fmt(v: Vector3f): String = "(${fmt(v.x)}, ${fmt(v.y)}, ${fmt(v.z)})"
private fun fmt(v: Vector4f): String = "(${fmt(v.x)}, ${fmt(v.y)}, ${fmt(v.z)}, ${fmt(v.w)})"

internal fun pathTraceStatusHtml(s: PathTraceStatus): String

What the .h printer draws, as a function of a PathTraceStatus alone.

Split out from the registration so it can be exercised without a Vulkan device — a hand-built status is a complete input. See PathTraceInstrumentTest.

Rows at their constructor default are dropped rather than shown as zeros. That is the whole reason the panel is short enough to sit above the next box: a plain tracer is five rows, and the optional groups (a non-agreeing up, a lens, temporal reuse, fog, a convergence log) cost a row each only once they apply — ten rows at the very most.

body — 48 lines, from line 223
internal fun pathTraceStatusHtml(s: PathTraceStatus): String {
    val c = s.camera
    val rate = s.recorded?.samplesPerSecond?.takeIf { !it.isNaN() }
    val rows = Table(listOf("label", "value"))
        .rows(
            listOfNotNull<List<Any?>>(
                listOf("image", "${s.width}×${s.height} · ${s.triangleCount} triangles"),
                listOf(
                    "samples",
                    "${s.samples} spp in generation ${s.resetGeneration}" +
                        (rate?.let { " · ${"%.1f".format(it)} spp/s" } ?: "") +
                        (if (s.paused) "  [paused]" else "")
                ),
                listOf("last reset", s.lastResetReason),
                listOf("camera", "${fmt(c.at)} → ${fmt(c.target)}  d=${fmt(c.distanceToTarget)}"),
                // Only when the declared up is not the one the image is built with. On a camera
                // where they agree this row is noise; on one where they do not it is the answer.
                if (c.upAgrees) null else listOf(
                    "up",
                    if (c.upTiltDegrees.isNaN()) "declared ${fmt(c.declaredUp)} is degenerate; tracer uses ${fmt(c.effectiveUp)}"
                    else "declared ${fmt(c.declaredUp)} ≠ effective ${fmt(c.effectiveUp)} (${"%.2f".format(c.upTiltDegrees)}° apart)"
                ),
                listOf("paths", "${s.tuning["maxBounces"]} bounces · ${s.samplesPerFrame} spp/frame · jitter ${fmt(s.tuning["pixelJitter"] as? Float ?: 0f)}"),
                (s.tuning["apertureRadius"] as? Float)?.takeIf { it > 0f }?.let {
                    listOf("lens", "aperture ${fmt(it)} · focus " + ((s.tuning["focusDistance"] as? Float)?.let(::fmt) ?: "camera target"))
                },
                (s.tuning["temporalReuse"] as? Boolean)?.takeIf { it }?.let {
                    listOf("temporal", "reuse on · max ${s.tuning["temporalMaxSamples"]} carried · blur ≤ ${fmt(s.tuning["temporalMaxBlurRadius"] as? Float ?: 0f)} px")
                },
                (s.tuning["fogDensity"] as? Float)?.takeIf { it > 0f }?.let {
                    listOf("fog", "density ${fmt(it)} from ${fmt(s.tuning["fogStartDistance"] as? Float ?: 0f)} · ${fmt(s.tuning["fogColor"] as? Vector3f ?: Vector3f())}")
                },
                s.recorded?.let { listOf("recorded", "${it.points} points over ${"%.1f".format(it.seconds)} s · ${it.resets} resets") }
            )
        )
        .role("label", CellRole.ACCENT)
        .also { it.showHeader = false }
    val query = if (s.backend == PathTracingRenderer.Backend.RAY_QUERY) "hardware ray queries" else "software BVH traversal"
    return FieldMarkdown.card(
        """
        {accent:PathTracingRenderer} {ok:'${s.name}'} {muted:${s.backend} · $query}

        ${rows.toHTML()}

        {muted:${s.tuning.size} tuning properties, the camera basis and the convergence log are in `.h2`}
        """
    )
}

.h2, the drill-down

internal fun pathTraceStatusMap(s: PathTraceStatus, objects: Map<String, Any?> = emptyMap()): LinkedHashMap<String, Any?>

What the .h2 adapter substitutes for a tracer, as a function of a PathTraceStatus and the tracer's own sub-objects.

Deliberately a superset of the reflective tree it replaces. H2Reflect was already showing all public properties, flat; this shows the same values grouped, adds the derived camera basis and the reset bookkeeping that are not properties at all, and keeps the four heavyweight sub-objects under objects so each still resolves onto its own FourVisualizers printer when clicked. objects is passed in rather than read here so this function stays deviceless.

body — 42 lines, from line 284
internal fun pathTraceStatusMap(s: PathTraceStatus, objects: Map<String, Any?> = emptyMap()): LinkedHashMap<String, Any?> {
    val c = s.camera
    return linkedMapOf(
        "name" to s.name,
        "backend" to "${s.backend} — " + if (s.backend == PathTracingRenderer.Backend.RAY_QUERY)
            "KHR ray queries are enabled on this device" else "portable compute BVH traversal",
        "image" to linkedMapOf<String, Any?>(
            "width" to s.width,
            "height" to s.height,
            "pixels" to s.width.toLong() * s.height,
            "triangles" to s.triangleCount,
            "paused" to s.paused
        ),
        "convergence" to linkedMapOf<String, Any?>(
            "samples" to s.samples,
            "samplesPerFrame" to s.samplesPerFrame,
            "resetGeneration" to s.resetGeneration,
            "lastResetReason" to s.lastResetReason,
            "recordedPoints" to (s.recorded?.points ?: 0),
            "recordedSeconds" to (s.recorded?.seconds ?: 0.0),
            "samplesPerSecond" to (s.recorded?.samplesPerSecond ?: Double.NaN),
            "recordedResets" to (s.recorded?.resets ?: 0)
        ),
        // Declared and effective together, in that order, because the point of the group is that
        // they can differ.
        "camera" to linkedMapOf<String, Any?>(
            "at" to c.at,
            "target" to c.target,
            "distanceToTarget" to c.distanceToTarget,
            "declaredUp" to c.declaredUp,
            "effectiveUp" to c.effectiveUp,
            "upTiltDegrees" to c.upTiltDegrees,
            "upAgrees" to c.upAgrees,
            "right" to c.right,
            "forward" to c.forward,
            "fovRadians" to c.fovRadians,
            "fovDegrees" to Math.toDegrees(c.fovRadians)
        ),
        "tuning" to LinkedHashMap(s.tuning),
        "objects" to LinkedHashMap(objects)
    )
}

the recorder

class PathTraceConvergence(val maxPoints: Int = 4096)

A per-frame convergence log: samples against wall time, plus every reset and why it happened.

Attach one to PathTracingRenderer.convergence (or call PathTracingRenderer.recordConvergence) and the tracer appends a point from its own render, on the render thread, for as long as it is attached. This is the thing four hand-written frames { repeat(180) { frame() } } loops were approximating, and it is strictly better than them in two ways: it costs no frames of its own, and it sees resets that a loop sampling samples at frame boundaries would miss.

Bounded, at a cost to resolution. Memory is capped at maxPoints; when the log fills, every other point is dropped and the sampling stride doubles. So a run of any length keeps the shape of its curve in fixed memory, at halving resolution, rather than either stopping or growing without limit. points is what is retained, not what happened.

Thread-safe: the render thread appends under a lock and every reader takes a copy, so a box can plot a log that is still filling.

init { require(maxPoints >= 8) { "A convergence log needs room for at least 8 points" } }

    private val lock = Any()
    private val nanos = ArrayList<Long>()
    private val counts = ArrayList<Int>()
    private val resetNanos = ArrayList<Long>()
    private val resetWhy = ArrayList<String>()
    private var origin = 0L
    private var stride = 1
    private var frames = 0L
    private var previousSamples = -1

How many reset marks are retained. The oldest are dropped, because the recent ones explain the picture on screen.

    private val maxResets = 256

internal fun record(tracer: PathTracingRenderer)

Called by PathTracingRenderer.render; see PathTracingRenderer.convergence.

    internal fun record(tracer: PathTracingRenderer) = record(tracer.samples, tracer.lastResetReason, System.nanoTime())

internal fun record(samples: Int, reason: String, atNanos: Long)

The seam record is really made of, so the bookkeeping can be tested without a device.

    internal fun record(samples: Int, reason: String, atNanos: Long) {
        synchronized(lock) {
            if (nanos.isEmpty() && resetNanos.isEmpty()) origin = atNanos
            // `render` appends here *after* `samples += samplesPerFrame`, so on an undisturbed frame
            // the count has strictly risen. Failing to rise is therefore exactly "accumulation was
            // discarded this frame", and it needs no cooperation from the tracer beyond the reason
            // string — including the camera-motion resets, which zero `samples` without bumping the
            // reset generation at all.
            //
            // `<=`, not `<`. A *sustained* reset — a camera being dragged resets on every frame it
            // moves — holds `samples` at `samplesPerFrame` rather than lowering it, so `<` saw a
            // two-hundred-frame drag as a single reset. Found by this file's own test.
            if (previousSamples >= 0 && samples <= previousSamples) {
                resetNanos.add(atNanos)
                resetWhy.add(reason)
                if (resetNanos.size > maxResets) { resetNanos.removeAt(0); resetWhy.removeAt(0) }
            }
            previousSamples = samples
            frames++
            if ((frames - 1) % stride != 0L) return
            nanos.add(atNanos)
            counts.add(samples)
            if (nanos.size >= maxPoints) decimate()
        }
    }

Keep every other point and double the stride. Halves resolution, keeps the span.

    private fun decimate() {
        var write = 0
        for (read in nanos.indices step 2) {
            nanos[write] = nanos[read]
            counts[write] = counts[read]
            write++
        }
        while (nanos.size > write) { nanos.removeAt(nanos.size - 1); counts.removeAt(counts.size - 1) }
        stride *= 2
    }

val seconds: List<Double> get() = synchronized(lock) { nanos.map { (it - origin) / 1e9 } }

Wall seconds since the first recorded frame, one per retained point.

val samples: List<Int> get() = synchronized(lock) { ArrayList(counts) }

Fresh samples per pixel, one per retained point, aligned with seconds.

val points: Int get() = synchronized(lock) { nanos.size }

How many points are retained. Less than the number of frames once decimate has run.

val framesPerPoint: Int get() = synchronized(lock) { stride }

The sampling stride: one point per this many frames. Doubles each time the log fills.

fun resetMarks(limit: Int = 12): List<Pair<String, Double>>

Reset marks as "reason" to seconds, the shape showSignals wants for marks.

Consecutive resets with the same reason collapse to the first of the run. Dragging a camera resets accumulation on every frame it moves, so without this a ten-second drag contributes six hundred identical vertical rules and the plot is a solid block. What you get instead is one mark reading camera motion where the drag began. The most recent limit marks are returned.

    fun resetMarks(limit: Int = 12): List<Pair<String, Double>> = synchronized(lock) {
        val out = ArrayList<Pair<String, Double>>()
        resetWhy.indices.forEach { i ->
            if (i == 0 || resetWhy[i] != resetWhy[i - 1]) out.add(resetWhy[i] to (resetNanos[i] - origin) / 1e9)
        }
        out.takeLast(limit)
    }

val resets: List<Pair<String, Double>>

Every reset kept, newest last, as "reason" to seconds. resetMarks is the plottable form.

    val resets: List<Pair<String, Double>>
        get() = synchronized(lock) { resetWhy.indices.map { resetWhy[it] to (resetNanos[it] - origin) / 1e9 } }

val summary: PathTraceConvergenceSummary?

Five numbers for the instrument, or null before the second point (one point spans no time).

    val summary: PathTraceConvergenceSummary?
        get() = synchronized(lock) {
            if (nanos.size < 2) return null
            PathTraceConvergenceSummary(
                points = nanos.size,
                seconds = (nanos.last() - nanos.first()) / 1e9,
                samples = counts.last(),
                resets = resetWhy.size
            )
        }

fun clear()

Forget everything and start again, keeping the attachment.

    fun clear() = synchronized(lock) {
        nanos.clear(); counts.clear(); resetNanos.clear(); resetWhy.clear()
        origin = 0L; stride = 1; frames = 0L; previousSamples = -1
    }

override fun toString(): String

    override fun toString(): String =
        "PathTraceConvergence($points points, 1 per $framesPerPoint frames, ${resetWhy.size} resets)"
}

fun PathTracingRenderer.recordConvergence(maxPoints: Int = 4096): PathTraceConvergence

Start logging convergence on this tracer, replacing any existing log, and hand it back.

fun PathTracingRenderer.recordConvergence(maxPoints: Int = 4096): PathTraceConvergence =
    PathTraceConvergence(maxPoints).also { convergence = it }

fun Box.showConvergence

Plot a PathTraceConvergence — samples against wall seconds, with a labelled dashed rule wherever accumulation restarted and why.

This is one call to showSignals and no new plotting code. Plotly is this repo's first choice for a chart and nothing about this figure asks for more than it offers: showSignals already takes named series, a shared x in seconds, and marks: List<Pair<String, Number>>, which it draws as full-height dashed rules carrying their label into the legend. That is exactly "samples against wall time, and the reason the last reset happened". Neither FLine geometry nor a raster path is involved.

Returns null, having displayed nothing, until there are two points — a single point spans no time and a one-point line plot is a blank panel with axes, which reads as a bug.

fun Box.showConvergence(
    log: PathTraceConvergence,
    title: String? = "path tracer convergence",
    channel: String = "output",
    marks: Int = 12
): Plot? {
    val x = log.seconds
    val y = log.samples
    if (x.size < 2) return null
    return showSignals(
        "samples/pixel" to y,
        x = x,
        channel = channel,
        title = title,
        xTitle = "seconds",
        yTitle = "samples per pixel",
        marks = log.resetMarks(marks).map { (reason, at) -> reason to (at as Number) }
    )
}

the reload result

class IntegratorReload internal constructor

What PathTracingRenderer.reloadIntegrator did: three outcomes, not a boolean.

ComputeModulePointer.reload answers false for two unrelated things — "this source is what is already compiled" and "this source does not compile, I kept the working pipeline" — so a caller wrapping it in check(...) throws on a no-op reload. Naming the third case is most of the value of this type; errors carries the compiler's own messages for the case that matters.

class IntegratorReload internal constructor(
    val status: Status,
    /** Characters of source offered. Not the source itself: an integrator is ~15 KB and this ends up in logs. */
    val sourceLength: Int,
    val errors: List<String>
) {

enum class Status

    enum class Status {

Byte-identical to what is already compiled. Nothing was rebuilt and nothing was reset.

        UNCHANGED,

A new pipeline is live, the superseded one is queued for release, and accumulation restarts.

        RELOADED,

It did not compile. The last working pipeline is still rendering; see errors.

        FAILED
    }

val reloaded: Boolean get() = status == Status.RELOADED

True only for Status.RELOADED — i.e. the image is about to change.

val ok: Boolean get() = status != Status.FAILED

True unless it failed to compile, so check(result.ok) does not fire on a no-op.

override fun toString(): String

    override fun toString(): String = when (status) {
        Status.UNCHANGED -> "integrator unchanged ($sourceLength chars, identical to the live pipeline)"
        Status.RELOADED -> "integrator reloaded ($sourceLength chars); accumulation restarts, camera and scene preserved"
        Status.FAILED -> "integrator failed to compile ($sourceLength chars); last working pipeline kept" +
            if (errors.isEmpty()) "" else ":\n" + errors.joinToString("\n")
    }

internal companion object

fun unchanged(source: String) = IntegratorReload(Status.UNCHANGED, source.length, emptyList())

fun reloaded(source: String) = IntegratorReload(Status.RELOADED, source.length, emptyList())

fun failed(source: String, errors: List<String>)

        fun failed(source: String, errors: List<String>) = IntegratorReload(Status.FAILED, source.length, errors.toList())
    }
}

registration

fun registerPathTraceInstrument()

Register both renderings. Discovered by di.kt's ClassGraph scan of @RunsAt, like registerFourVisualizers and registerH2DomainAdapters.

This lives in its own file rather than in FourVisualizers.kt or H2DomainAdapters.kt on purpose: the tracer's instrument is the tracer's business, and both of those files are edited by everybody.

@RunsAt(newRoot)
fun registerPathTraceInstrument() {
    registerValuePrinter<PathTracingRenderer> { tracer -> pathTraceStatusHtml(tracer.status()) }

    registerH2Value<PathTracingRenderer> { tracer ->
        val s = tracer.status()
        H2ValueSnapshot(
            value = pathTraceStatusMap(
                s,
                linkedMapOf(
                    "camera" to tracer.camera,
                    "output" to tracer.output,
                    "materialModule" to tracer.materialModule,
                    "renderer" to tracer.renderer
                )
            ),
            summary = "PathTracingRenderer '${s.name}' · ${s.backend} · ${s.width}×${s.height} · " +
                "${s.triangleCount} triangles · ${s.samples} spp in generation ${s.resetGeneration} " +
                "(${s.lastResetReason})",
            title = "PathTracingRenderer ${s.name}"
        )
    }
}