Skip to content

RectSLGalleryHarness.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/RectSLGalleryHarness.kt · 1210 lines

package and imports — 46 lines, from line 1
package afield.graphics.scene.fabricated

import afield.graphics.vulkan.Renderer
import afield.graphics.render.TexturePreviewCache
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.vulkan.targets.RenderToTexture
import afield.app.diagnostics.logging.lprintln
import afield.runtime.kotlin.KotlinInterface2
import afield.runtime.kotlin.KotlinScriptEngine
import afield.lang.rectsl.RECT_ANIMATION_VERTEX_SHADER
import afield.lang.rectsl.RectLangException
import afield.lang.rectsl.RectSLGlsl
import afield.lang.rectsl.RectSLSdfLook
import afield.lang.rectsl.RectSLSdfOptions
import afield.lang.rectsl.RectSLSdfShader
import afield.lang.rectsl.SceneProgram
import afield.lang.rectsl.compileRectSL
import afield.lang.rectsl.prewarmRectSLEscapes
import afield.lang.rectsl.rectMaterialShaderCode
import afield.lang.rectsl.worldToClip
import afield.lang.rectsl.writeFrameToMesh
import afield.math.functions.Functions
import afield.math.functions.fitBest
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import org.lwjgl.stb.STBImage
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_UNORM
import org.lwjgl.vulkan.VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
import java.io.File
import java.nio.file.Files
import java.util.Base64
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin

The RectSL gallery on the docs site, rendered offscreen: the StackSLGalleryHarness shape, applied to programs that describe a whole scene rather than one fullscreen fragment.

A RectSL program carries a wall, a duration, an fps, and its rects, tracks, groups and StackSL materials, so the render path here is the one afield.lang.rectsl.RectSLRenderRunner already uses: compile the program, lower each sampled frame to a triangle mesh, and draw it through RECT_ANIMATION_VERTEX_SHADER plus a StackSL-compiled material fragment into an offscreen RenderToTexture.

Both entry points hold one renderer and one graphics context open and walk the program list, which is the "one Field document, many Field 4 renders" pattern. A fresh target is built per example, because each program's wall has its own aspect ratio; the StackSL gallery can reuse one fixed 512² target instead.

renderGallery reads back a single representative still per program. renderAnimations sweeps the program's own timeline frame by frame and encodes each animate: true program to an mp4, since the docs runner can screenshot the native GPU window but never record it, which leaves the readback and the ffmpeg encode to the harness. The programs themselves are editable Field content under this harness's WORKSPACE_DIR, not literals in this file.

object RectSLGalleryHarness

Renders the RectSL gallery stills and animations offscreen, one scene program at a time.

object RectSLGalleryHarness {

    private const val OUTPUT_LONG = 512      // longest output side in px
    private const val WAIT_FRAMES = 12       // build pipeline + first render
    private const val SETTLE_FRAMES = 2      // renderer frames per new time
    private const val ANIM_FPS = 30
    private const val ANIM_MAX_FRAMES = 150  // cap ~5s per clip regardless of program
    private const val MODULE_NAME = "rectsl-gallery-module"
    private const val MESH_NAME = "rectsl-gallery-mesh"
    private const val PRE_TARGET_NAME = "rectsl-gallery-rtt"
    private val DATA_IMAGE_RE = Regex("base64,([^']+)")

const val WORKSPACE_DIR = "public/docs/assets/rectsl_gallery"

Repo-relative workspace whose *_rectsl_.rectsl files are the examples.

The gallery entry embedded as the section hero image.

    private const val HERO_NAME = "orbit-petals"

data class Example

One gallery entry loaded from the workspace: name is the box slug (and the PNG/mp4 stem rectsl-<name>), source its RectSL program. stillTime is the timeline moment sampled for the still. animate marks programs that also get an mp4.

    data class Example(
        val name: String,
        val source: String,
        val stillTime: Double? = null,
        val animate: Boolean = false
    )

    private fun defaultWorkspaceDir(): File = File(System.getProperty("user.dir"), WORKSPACE_DIR)

fun loadExamples(workspaceDir: File = defaultWorkspaceDir()): List<Example>

Load every example from the workspace: one box per <name>_rectsl_.rectsl file, ordered by the docOrder in the sibling <name>_metadata_.yaml (which also carries optional animate: / stillTime:). The programs live in the workspace, not in this file.

    fun loadExamples(workspaceDir: File = defaultWorkspaceDir()): List<Example> {
        val files = workspaceDir.listFiles { f -> f.name.endsWith("_rectsl_.rectsl") }
            ?: error("RectSL gallery workspace not found: ${workspaceDir.absolutePath}")
        require(files.isNotEmpty()) { "RectSL gallery workspace has no *_rectsl_.rectsl files: ${workspaceDir.absolutePath}" }
        return files
            .map { f ->
                val name = f.name.removeSuffix("_rectsl_.rectsl")
                val meta = File(workspaceDir, "${name}_metadata_.yaml").takeIf { it.exists() }?.readLines() ?: emptyList()
                fun value(key: String): String? =
                    meta.firstNotNullOfOrNull { Regex("""^\s*$key:\s*(.+?)\s*$""").find(it)?.groupValues?.get(1) }
                val order = value("docOrder")?.toIntOrNull() ?: Int.MAX_VALUE
                Example(
                    name = name,
                    source = f.readText().trim(),
                    stillTime = value("stillTime")?.toDoubleOrNull(),
                    animate = value("animate")?.toBooleanStrictOrNull() ?: false
                ) to order
            }
            .sortedWith(compareBy({ it.second }, { it.first.name }))
            .map { it.first }
    }

suspend fun renderGallery(outputDir: File, renderer: Renderer): File

Render every workspace example into <outputDir>/rectsl-<name>.png (a single representative frame), reusing a single renderer. Returns the hero PNG.

    suspend fun renderGallery(outputDir: File, renderer: Renderer): File {
        outputDir.mkdirs()
        val examples = loadExamples()
        var heroFile: File? = null
        examples.forEach { example ->
            val file = File(outputDir, "rectsl-${example.name}.png")
            runCatching {
                val program = compile(example)
                val stillTime = example.stillTime ?: (program.duration * 0.5)
                renderStill(renderer, program, stillTime, file, example.name)
            }
                .onSuccess {
                    lprintln("info,doc", "rectsl gallery wrote ${file.name} (${file.length()} bytes)")
                    if (example.name == HERO_NAME) heroFile = file
                }
                .onFailure { t -> lprintln("error,doc", "rectsl gallery example '${example.name}' failed: ${t.message}") }
        }
        return heroFile
            ?: File(outputDir, "rectsl-${examples.first().name}.png").takeIf { it.exists() }
            ?: error("rectsl gallery produced no images")
    }

suspend fun renderAnimations(outputDir: File, renderer: Renderer): List<File>

Render an mp4 for every animate: true workspace example into <outputDir>/rectsl-<name>.mp4, reusing one renderer. The scene is sampled over its own timeline; each frame is read back and handed to ffmpeg.

    suspend fun renderAnimations(outputDir: File, renderer: Renderer): List<File> {
        outputDir.mkdirs()
        val examples = loadExamples().filter { it.animate }
        require(examples.isNotEmpty()) {
            "no animated RectSL examples — set `animate: true` in a box's metadata.yaml under $WORKSPACE_DIR"
        }
        val ffmpeg = resolveFfmpeg()
        val written = mutableListOf<File>()
        examples.forEach { example ->
            runCatching { renderOneAnimation(renderer, compile(example), example.name, outputDir, ffmpeg) }
                .onSuccess { written += it; lprintln("info,doc", "rectsl anim wrote ${it.name} (${it.length()} bytes)") }
                .onFailure { t -> lprintln("error,doc", "rectsl anim '${example.name}' failed: ${t.message}") }
        }
        return written
    }

suspend fun renderFitToTarget(outputDir: File, renderer: Renderer): File

A dedicated illustration of the hardest bridge idea (#5): a RectSL distribution is a Functions graph, so the host can fit it to a target and RectSL just samples the winner. We fit a two-lobe target with fitBest, bind the seed/target/fitted curves by name into a real RectSL program, and render three arrays — target (reference), seed (before), fitted (after) — so the gold "after" row visibly lands on the target silhouette that the muddy "before" row misses. The layout is the ordinary array + sample path; only the curve was searched for by the host.

    suspend fun renderFitToTarget(outputDir: File, renderer: Renderer): File {
        outputDir.mkdirs()
        val (target, seed, fitted) = with(Functions) {
            // A two-lobe silhouette that is awkward to hand-key; the seed has the
            // same structure but deliberately wrong lobe positions/widths/heights.
            val target = gaussian(0.30, 0.11) + gaussian(0.72, 0.13) * 0.80
            val seed = gaussian(0.45, 0.20) + gaussian(0.60, 0.20) * 0.50
            val run = fitBest(target, seed, restarts = 12)
            lprintln("info,doc", "rectsl fit-to-target RMSE ${"%.4f".format(run.initialError)} -> ${"%.4f".format(run.finalError)}")
            Triple(target, seed, run.fitted)
        }
        val program = compileForRender(
            "fit-to-target",
            FIT_SCENE_SOURCE,
            mapOf("target" to target, "seed" to seed, "fit" to fitted)
        )
        val file = File(outputDir, "rectsl-fit-to-target.png")
        renderStill(renderer, program, 0.0, file, "fit-to-target")
        lprintln("info,doc", "rectsl fit-to-target wrote ${file.name} (${file.length()} bytes)")
        return file
    }

val FIT_SCENE_SOURCE: String

The scene rendered by renderFitToTarget; target/seed/fit are host-bound curves.

    val FIT_SCENE_SOURCE: String = """
        #rectsl
        ->wall 22 12 abstract
        ->duration 1
        ->fps 1

        "trow" rect  0 -4 ->xy  0.5 1 ->wh  0x2e5a86 solid ->fill
          40 '( ic 0.55 * 0 ->offset  target f sample 4 * 0.12 +  ->h ) array ;

        "srow" rect  0 0 ->xy  0.5 1 ->wh  0x8a5a2b solid ->fill
          40 '( ic 0.55 * 0 ->offset  seed f sample 4 * 0.12 +  ->h ) array ;

        "frow" rect  0 4 ->xy  0.5 1 ->wh  0xffd23c solid ->fill
          40 '( ic 0.55 * 0 ->offset  fit f sample {{ 4.0 }} * 0.12 +  ->h ) array ;
    """.trimIndent()

the signed distance field, on the GPU

val SDF_SCENE_SOURCE: String

The scene the SDF looks are demonstrated on.

Chosen so that every preset has something to show rather than a still life: six copies of one animated blade, close enough together that a smooth union fuses them, sweeping fast enough through the middle of the wall that a 0.14s shutter is a visible streak, and rotating so the outline and the per-pixel normals both have a curved-looking edge to describe. It is an ordinary array over an ordinary template — nothing here is SDF-specific, which is the point.

    val SDF_SCENE_SOURCE: String = """
        #rectsl
        ->wall 9 5 abstract
        ->duration 4
        ->fps 30

        "blade" rect  0.85 ->square  0x2f9fe8 solid ->fill
          '( 0=0  4=6.28318 ) ->rot
          '( 0=-2.4  2=2.4  4=-2.4 ) tcb ->x
          '( 0=-0.7  1=0.7  2=-0.7  3=0.7  4=-0.7 ) tcb ->y
          6 '( ic 0.8 * 0 ->offset ) array
        ;
    """.trimIndent()

val SDF_PATTERN_SOURCE: String

A scene whose motion comes from the pattern language, so the differential covers the two tracks emitSdf cannot lower symbolically and samples instead (both pattern queries; ->degrade's 64-bit hash lowers exactly now).

RectSLSdfBakeTest measures what the bake costs on the CPU — since 2026-08-30 nothing at all for a rhythm, whose edges are bisected onto the sampler's own rather than snapped to the bake grid. This scene checks the other half: that the shader faithfully evaluates the chain that was baked. It is therefore probed only at instants well away from any pattern edge, so that anything the differential finds is the shader's and not the bake's.

    val SDF_PATTERN_SOURCE: String = """
        #rectsl
        ->wall 12 6 abstract
        ->duration 4
        ->fps 30
        ->cps 1

        "step" rect  0.8 ->square
          "1 2.5 4 5.5" ->x
          '( 0=-1.2  4=1.2 ) ->y
          12 '( ic 0.42 * 3.25 - 0 ->offset ) array
          0.45 ->degrade
        ;
    """.trimIndent()

Instants at least two bake intervals away from any quarter-cycle pattern edge.

    private val SDF_PATTERN_TIMES: List<Double> = listOf(0.125, 0.375, 1.625, 2.875)

val SDF_CARVE_SOURCE: String

The field that gets subtracted by the carve look.

It rides the blades' own ->y track so the hole is actually in the band rather than passing under it — the first version travelled along y = 0 and only clipped a corner, which made a boolean op look like a rendering artefact.

    val SDF_CARVE_SOURCE: String = """
        #rectsl
        ->wall 9 5 abstract
        ->duration 4

        "hole" rect  1.3 ->square
          '( 0=2.4  2=-2.4  4=2.4 ) tcb ->x
          '( 0=-0.7  1=0.7  2=-0.7  3=0.7  4=-0.7 ) tcb ->y
          '( 0=0  4=-3.14159 ) ->rot
        ;
    """.trimIndent()

Wall units either side of zero the fine probe pass resolves; see measureSdfAgreement.

    private const val PROBE_NEAR_RANGE = 1.0

Range of the coarse pass, which has to cover the whole wall's far field.

    private const val PROBE_FAR_RANGE = 32.0

    private const val SDF_MODULE_NAME = "rectsl-sdf-module"
    private const val SDF_MESH_NAME = "rectsl-sdf-mesh"
    private const val SDF_PRE_TARGET_NAME = "rectsl-sdf-rtt"

data class SdfAgreement

What measureSdfAgreement found, so the caller can print it and assert on it.

    data class SdfAgreement(
        val label: String,
        val comparisons: Int,
        val maxAbsError: Double,
        val meanAbsError: Double,
        /** The 24-bit step at this pass's range — no measurement here can beat it. */
        val quantisation: Double,
        /**
         * Largest deviation of a single decoded coordinate from the fitted
         * pixel→wall line. This is the *coordinate* probe's own quantisation, not
         * an error in the mapping: the fitted line averages hundreds of samples, so
         * it is roughly √N better than this number. Reported because if the mapping
         * were not linear — a flipped axis, a half-pixel offset, a clamped
         * encoding — this is where it would show, and it did once.
         */
        val coordinateResidual: Double
    ) {

override fun toString(): String

        override fun toString(): String =
            ("$label: %d point(s), max |GPU-CPU| = %.3e, mean = %.3e " +
                "(24-bit step %.3e), coordinate residual %.3e")
                .format(comparisons, maxAbsError, meanAbsError, quantisation, coordinateResidual)
    }

suspend fun renderSdf(outputDir: File, renderer: Renderer): File

Everything the SDF page needs, in one graphics context: a still per look, an animated clip, the raster-vs-SDF counterfactual, and the GPU↔CPU differential. Returns the hero still.

The differential runs first and throws if the compiled shader and the CPU sampler disagree, so a wrong field cannot quietly become a pretty picture — which is the failure mode every one of these render harnesses has had.

body — 73 lines, from line 349
    suspend fun renderSdf(outputDir: File, renderer: Renderer): File {
        outputDir.mkdirs()
        val program = compileForRender("sdf-scene", SDF_SCENE_SOURCE)
        val carveProgram = compileForRender("sdf-carve", SDF_CARVE_SOURCE)

        // The pattern scene first: it is the one whose tracks the emitter admits it
        // approximated, so it is the one most likely to be wrong.
        val patternProgram = compileForRender("sdf-pattern", SDF_PATTERN_SOURCE)
        val patternAgreement = measureSdfAgreement(renderer, patternProgram, SDF_PATTERN_TIMES)
            .map { it.copy(label = "baked ${it.label}") }
        patternAgreement.forEach { lprintln("info,doc", "rectsl sdf $it") }
        val bakedNear = patternAgreement.first { it.label.contains("near") }
        require(bakedNear.maxAbsError < 1e-5) {
            "on a pattern-driven scene the shader disagrees with the CPU sampler by " +
                "${bakedNear.maxAbsError} wall units away from any pattern edge, so the baked hold " +
                "chain is not what the shader evaluates:\n" + patternAgreement.joinToString("\n")
        }

        val agreement = measureSdfAgreement(renderer, program) + patternAgreement
        agreement.forEach { lprintln("info,doc", "rectsl sdf ${it}") }
        val near = agreement.first { it.label.startsWith("near") }
        require(near.maxAbsError < 1e-5) {
            "the compiled SDF shader and the CPU sampler disagree by ${near.maxAbsError} wall " +
                "units near the surface, which is past the 1e-6-ish float/double gap the emitter " +
                "documents:\n" + agreement.joinToString("\n")
        }
        val far = agreement.first { it.label.startsWith("far") }
        require(far.maxAbsError < 4.0 * far.quantisation) {
            "the far field disagrees by ${far.maxAbsError}, more than the coarse pass's own " +
                "${far.quantisation} encoding step can explain:\n" + agreement.joinToString("\n")
        }

        // Breadth: the two scenes above are two shapes of program, and the emitter has
        // many more branches than that.
        val gallery = measureGallerySdfAgreement(renderer)
        val worstGallery = gallery.maxByOrNull { it.maxAbsError }
        require(worstGallery == null || worstGallery.maxAbsError < 1e-5) {
            "a gallery scene disagrees with the CPU sampler by ${worstGallery?.maxAbsError}:\n" +
                gallery.joinToString("\n")
        }

        File(outputDir, "rectsl-sdf-agreement.txt").writeText(
            (agreement + gallery).joinToString("\n", postfix = "\n") { it.toString() }
        )

        val written = mutableListOf<File>()
        RectSLSdfLook.PRESETS.forEach { look ->
            val file = File(outputDir, "rectsl-sdf-${look.name}.png")
            val field = RectSLGlsl.emitSdf(program, "scene", look.emitOptions())
            val carve = if (look.carves) {
                RectSLGlsl.emitSdf(carveProgram, "carveScene", RectSLSdfOptions(namePrefix = "carve"))
            } else {
                null
            }
            renderSdfStill(
                renderer, program,
                fragment = RectSLSdfShader.fragment(field, look, carve),
                time = SDF_STILL_TIME,
                outputFile = file,
                label = "sdf-${look.name}"
            )
            written += file
            lprintln("info,doc", "rectsl sdf wrote ${file.name} (${file.length()} bytes)")
        }

        renderSdfAnimation(outputDir, renderer, program)
        renderSdfCounterfactual(outputDir, renderer, program)
        renderSdfResolutionPair(outputDir, renderer, program)

        return written.firstOrNull { it.name.contains("relief") }
            ?: written.firstOrNull()
            ?: error("rectsl sdf produced no images")
    }

The instant every look still is sampled at: mid-sweep, where the blades overlap.

    private const val SDF_STILL_TIME = 0.95

One clip of the motion-blur look, so the docs can show that the streak tracks the motion rather than being a fixed smear. Same encode path as the raster animations.

    private suspend fun renderSdfAnimation(outputDir: File, renderer: Renderer, program: SceneProgram): File {
        val look = RectSLSdfLook.preset("motion-blur")
        val field = RectSLGlsl.emitSdf(program, "scene", look.emitOptions())
        val fragment = RectSLSdfShader.fragment(field, look)
        val (width, height) = outputDims(program)
        val fps = min(program.fps.roundToInt().coerceAtLeast(1), ANIM_FPS)
        val frameCount = min(max(1, ceil(program.duration * fps).toInt()), ANIM_MAX_FRAMES)
        val framesDir = Files.createTempDirectory("rectsl-sdf-anim").toFile()
        val session = SdfSession(renderer, program, fragment, width, height, "sdf-anim")
        try {
            renderer.renderList.preTargets[SDF_PRE_TARGET_NAME] = session.rtt
            session.currentTime = 0.0
            waitForRendererFrames(renderer, WAIT_FRAMES)
            for (i in 0 until frameCount) {
                session.currentTime = if (frameCount > 1) program.duration * i / frameCount else 0.0
                waitForRendererFrames(renderer, SETTLE_FRAMES)
                captureRttTo(
                    renderer, session.rtt, File(framesDir, "frame_%05d.png".format(i)),
                    "sdf-anim", width, height
                )
            }
            val mp4 = File(outputDir, "rectsl-sdf-motion-blur.mp4")
            encodeMp4(resolveFfmpeg(), framesDir, "frame_%05d.png", fps, mp4)
            require(mp4.length() > 0L) { "ffmpeg wrote an empty mp4 for the SDF clip" }
            lprintln("info,doc", "rectsl sdf wrote ${mp4.name} (${mp4.length()} bytes)")
            return mp4
        } finally {
            framesDir.deleteRecursively()
            renderer.renderList.preTargets.remove(SDF_PRE_TARGET_NAME)
            session.dispose()
        }
    }

The counterfactual, computed rather than asserted: the same scene at the same instant through the ordinary raster path and through the SDF, side by side in one image.

This is the honest way to show what the field drops. The left half has per-rect fill, StackSL materials and z order; the right half has none of them, because min is union and a union has no layers — and in exchange it has an outline and a glow that the left half cannot express at any resolution.

    private suspend fun renderSdfCounterfactual(outputDir: File, renderer: Renderer, program: SceneProgram): File {
        val (width, height) = outputDims(program)
        val rasterFile = File.createTempFile("rectsl-sdf-raster", ".png")
        val sdfFile = File.createTempFile("rectsl-sdf-field", ".png")
        try {
            renderStill(renderer, program, SDF_STILL_TIME, rasterFile, "sdf-counterfactual-raster")
            val look = RectSLSdfLook.preset("glow")
            val field = RectSLGlsl.emitSdf(program, "scene", look.emitOptions())
            renderSdfStill(
                renderer, program,
                fragment = RectSLSdfShader.fragment(field, look),
                time = SDF_STILL_TIME,
                outputFile = sdfFile,
                label = "sdf-counterfactual-field"
            )
            val out = File(outputDir, "rectsl-sdf-counterfactual.png")
            out.delete()
            hstack(resolveFfmpeg(), rasterFile, sdfFile, out)
            require(out.length() > 0L) { "ffmpeg wrote an empty counterfactual PNG" }
            lprintln(
                "info,doc",
                "rectsl sdf wrote ${out.name} (${out.length()} bytes, ${2 * width + 8}x$height)"
            )
            return out
        } finally {
            rasterFile.delete()
            sdfFile.delete()
        }
    }

"Antialiased at any zoom" is the one claim a single still cannot make, so make it with two.

Both halves show the same wall region at the same output size. The left is rendered at the gallery's ordinary resolution and then magnified 4× with nearest-neighbour, so its edge is whatever the rasteriser committed to. The right is rendered at 4× and cropped, so its edge was re-resolved from the field. Nothing about the scene, the shader or the look changed between them — only how many fragments asked the question.

    private suspend fun renderSdfResolutionPair(outputDir: File, renderer: Renderer, program: SceneProgram): File {
        val look = RectSLSdfLook.preset("outline")
        val field = RectSLGlsl.emitSdf(program, "scene", look.emitOptions())
        val fragment = RectSLSdfShader.fragment(field, look)
        val (width, height) = outputDims(program)
        val zoom = 4
        val coarse = File.createTempFile("rectsl-sdf-coarse", ".png")
        val fine = File.createTempFile("rectsl-sdf-fine", ".png")
        try {
            renderSdfStill(renderer, program, fragment, SDF_STILL_TIME, coarse, "sdf-zoom-coarse", width, height)
            renderSdfStill(
                renderer, program, fragment, SDF_STILL_TIME, fine, "sdf-zoom-fine",
                width * zoom, height * zoom
            )
            val out = File(outputDir, "rectsl-sdf-zoom.png")
            out.delete()
            // Left: the 1x render's centre ninth, blown up with no interpolation.
            // Right: the same wall region straight out of the 4x render.
            ffmpeg(
                resolveFfmpeg(), "compositing ${out.name}",
                "-i", coarse.absolutePath,
                "-i", fine.absolutePath,
                "-filter_complex",
                "[0:v]crop=${width / zoom}:${height / zoom}:${width * 3 / (2 * zoom)}:${height * 3 / (2 * zoom)}," +
                    "scale=$width:$height:flags=neighbor,pad=iw+8:ih:0:0:color=0x202429[a];" +
                    "[1:v]crop=$width:$height:${width * (zoom - 1) / 2}:${height * (zoom - 1) / 2}[b];" +
                    "[a][b]hstack=inputs=2",
                "-frames:v", "1",
                out.absolutePath
            )
            require(out.length() > 0L) { "ffmpeg wrote an empty zoom comparison" }
            lprintln("info,doc", "rectsl sdf wrote ${out.name} (${out.length()} bytes)")
            return out
        } finally {
            coarse.delete()
            fine.delete()
        }
    }

suspend fun measureSdfAgreement

Ask the compiled shader what the field is, and compare it against the CPU sampler that the raster path draws from.

How it avoids the usual trap: the probe shader (RectSLSdfShader.probeFragment) also reports p.x and p.y, so the pixel→wall mapping is recovered from the shader's own answers — a linear fit over every column and row — instead of being re-derived on the CPU, where a flipped axis or a half-pixel is exactly the kind of mistake that makes a differential agree with itself. The coordinate channels are encoded over the wall's size, never over the distance range, so narrowing the distance range cannot saturate them.

Two passes per instant, differing only in how finely the distance is encoded. The fine pass resolves [-1, 1] wall units to 1.2e-7 and compares only inside that band — which is where every geometric claim lives, and is fine enough that what it measures is the shader's own float arithmetic rather than the readback. The coarse pass covers [-32, 32] at 3.8e-6 so the far field cannot be silently wrong; it cannot resolve better than that, and its numbers should be read as "consistent to the encoding", not as an error.

The blended union is deliberately not measured here: smin is not associative, so agreeing with a CPU port would mean re-deriving the emitter's chunked union tree on the CPU, and a differential that has to replicate the structure it is checking proves less than the algebraic bound RectSLSdfLookTest already holds it to.

body — 94 lines, from line 582
    suspend fun measureSdfAgreement(
        renderer: Renderer,
        program: SceneProgram,
        times: List<Double> = listOf(0.0, 0.37, 0.95, 2.05, 3.6),
        ranges: List<Pair<String, Double>> = listOf("near" to PROBE_NEAR_RANGE, "far" to PROBE_FAR_RANGE)
    ): List<SdfAgreement> {
        val field = RectSLGlsl.emitSdf(program, "scene")
        val fragment = RectSLSdfShader.probeFragment(field)
        val (width, height) = outputDims(program)
        val session = SdfSession(renderer, program, fragment, width, height, "sdf-probe")
        val comparisons = IntArray(ranges.size)
        val maxErr = DoubleArray(ranges.size)
        val sumErr = DoubleArray(ranges.size)
        var maxResidual = 0.0
        try {
            renderer.renderList.preTargets[SDF_PRE_TARGET_NAME] = session.rtt
            waitForRendererFrames(renderer, WAIT_FRAMES)
            for (time in times) {
                session.currentTime = time
                // Channels 1 and 2 are the shader's own world coordinates, encoded
                // over the wall rather than over probeRange, so they are read once
                // per instant and shared by both distance passes.
                session.probeChannel = 1.0
                waitForRendererFrames(renderer, SETTLE_FRAMES)
                val xs = decodeProbe(renderer, session, width, height, program.wall.width, "probe-x")
                session.probeChannel = 2.0
                waitForRendererFrames(renderer, SETTLE_FRAMES)
                val ys = decodeProbe(renderer, session, width, height, program.wall.height, "probe-y")

                // p.x depends only on the column and p.y only on the row, both
                // linearly. Fitting recovers the mapping far better than the 24-bit
                // quantisation of any single pixel.
                val xFit = fitLinear(width) { col -> xs[col] }
                val yFit = fitLinear(height) { row -> ys[row * width] }
                val coordStep = max(
                    RectSLSdfShader.quantisation(program.wall.width),
                    RectSLSdfShader.quantisation(program.wall.height)
                )
                val residual = maxOf(
                    (0 until width).maxOf { abs(xFit(it.toDouble()) - xs[it]) },
                    (0 until height).maxOf { abs(yFit(it.toDouble()) - ys[it * width]) }
                )
                require(residual < 2.0 * coordStep) {
                    "the pixel->wall mapping is not the straight line it must be: residual " +
                        "$residual against a 24-bit step of $coordStep. Either an axis is " +
                        "non-linear or the coordinate encoding is clamping."
                }
                maxResidual = max(maxResidual, residual)

                val sampled = program.sample(time).rects.filter { it.alpha > 0.0 }
                ranges.forEachIndexed { index, (_, range) ->
                    session.probeRange = range
                    session.probeChannel = 0.0
                    waitForRendererFrames(renderer, SETTLE_FRAMES)
                    val ds = decodeProbe(renderer, session, width, height, range, "probe-d")
                    var row = 2
                    while (row < height - 2) {
                        var col = 2
                        while (col < width - 2) {
                            val p = afield.lang.rectsl.Vec2d(xFit(col.toDouble()), yFit(row.toDouble()))
                            val cpu = sampled.minOfOrNull { boxDistance(p, it) } ?: 1e9
                            // Only where the encoding was not clamped: at the rail the
                            // shader's answer says "at least this far", not "this far".
                            if (abs(cpu) < range * 0.9) {
                                val err = abs(ds[row * width + col] - cpu)
                                maxErr[index] = max(maxErr[index], err)
                                sumErr[index] += err
                                comparisons[index]++
                            }
                            col += 7
                        }
                        row += 7
                    }
                }
            }
            return ranges.mapIndexed { index, (rangeName, range) ->
                require(comparisons[index] > 500) {
                    "the $rangeName probe pass compared only ${comparisons[index]} points — the " +
                        "encoding range is probably wrong, so this measured nothing"
                }
                SdfAgreement(
                    label = "$rangeName (±$range wall units)",
                    comparisons = comparisons[index],
                    maxAbsError = maxErr[index],
                    meanAbsError = sumErr[index] / comparisons[index],
                    quantisation = RectSLSdfShader.quantisation(range),
                    coordinateResidual = maxResidual
                )
            }
        } finally {
            renderer.renderList.preTargets.remove(SDF_PRE_TARGET_NAME)
            session.dispose()
        }
    }

suspend fun measureGallerySdfAgreement

The same differential, over a diverse subset of the real gallery rather than the two scenes this page draws.

Two hand-written scenes cover two shapes of program. The gallery covers groups, nested transforms, named easings, time warps, ->face-travel headings, and rect counts from one to 183 — and every one of those is a distinct branch of emitRect, so a differential that never sees them is checking a fraction of the emitter. Rather than pick favourites, this walks the gallery in name order and keeps a scene whenever it brings a new feature signature (does it have groups, does it name an easing, does it force a bake, and which order of magnitude of rect count), which is a reproducible way to get breadth without rendering all 35.

Near pass only: the fine encoding is where the geometric claims live, and the far pass has already been shown to sit at its own quantisation floor.

body — 72 lines, from line 694
    suspend fun measureGallerySdfAgreement(
        renderer: Renderer,
        maxScenes: Int = 8,
        times: List<Double> = listOf(0.0, 0.41, 1.7)
    ): List<SdfAgreement> {
        val seen = mutableSetOf<String>()
        val chosen = mutableListOf<Pair<String, SceneProgram>>()
        for (example in loadExamples()) {
            if (chosen.size >= maxScenes) break
            val program = runCatching { compileForRender(example.name, example.source) }.getOrNull() ?: continue
            // Skip the very largest: the CPU side of the comparison is O(pixels x rects)
            // and the point here is breadth of *shape*, not of size.
            if (program.nodes.size > 80) continue
            val field = RectSLGlsl.emitSdf(program, "scene")
            val signature = listOf(
                program.nodes.any { it is afield.lang.rectsl.GroupNode },
                field.globals.keys.any { it.startsWith("rectslEase_") },
                !field.ok,
                when {
                    field.rectCount <= 1 -> "one"
                    field.rectCount < 10 -> "few"
                    field.rectCount < 50 -> "many"
                    else -> "lots"
                }
            ).joinToString("/")
            if (seen.add(signature)) {
                chosen += example.name to program
                lprintln("info,doc", "rectsl sdf gallery differential: ${example.name} [$signature]")
            }
        }
        require(chosen.size >= 4) { "expected several distinct gallery shapes, chose ${chosen.size}" }

        val out = mutableListOf<SdfAgreement>()
        for ((name, program) in chosen) {
            val rows = measureSdfAgreement(
                renderer, program,
                times = times.map { it.coerceAtMost(program.duration) },
                ranges = listOf("near" to PROBE_NEAR_RANGE)
            )
            rows.forEach { row ->
                val labelled = row.copy(label = "gallery/$name ${row.label}")
                lprintln("info,doc", "rectsl sdf $labelled")
                out += labelled
            }
        }
        return out
    }

    private suspend fun renderSdfStill(
        renderer: Renderer,
        program: SceneProgram,
        fragment: String,
        time: Double,
        outputFile: File,
        label: String,
        outWidth: Int = -1,
        outHeight: Int = -1
    ) {
        val (defaultWidth, defaultHeight) = outputDims(program)
        val width = if (outWidth > 0) outWidth else defaultWidth
        val height = if (outHeight > 0) outHeight else defaultHeight
        val session = SdfSession(renderer, program, fragment, width, height, label)
        try {
            session.currentTime = time
            renderer.renderList.preTargets[SDF_PRE_TARGET_NAME] = session.rtt
            waitForRendererFrames(renderer, WAIT_FRAMES)
            captureRttTo(renderer, session.rtt, outputFile, label, width, height)
        } finally {
            renderer.renderList.preTargets.remove(SDF_PRE_TARGET_NAME)
            session.dispose()
        }
    }

The SDF twin of Session: the same offscreen target and the same readback, but a fullscreen triangle with no vertex attributes and no per-frame mesh rewrite. That absence is the whole difference between the two paths — the raster session re-lowers the scene to triangles every frame, this one uploads three vertices once and moves a float.

source — 60 lines, from line 774
    private class SdfSession(
        renderer: Renderer,
        program: SceneProgram,
        fragment: String,
        width: Int,
        height: Int,
        label: String
    ) {
        var currentTime: Double = 0.0
        var probeChannel: Double = 0.0
        var probeRange: Double = PROBE_NEAR_RANGE
        val rtt: RenderToTexture
        private val module: ModulePointer

        init {
            rtt = RenderToTexture(renderer) {
                this.width = width
                this.height = height
                // UNORM, not SRGB: the probe's bytes have to survive the readback
                // unchanged, and the look shaders write the colour they mean.
                this.format = VK_FORMAT_R8G8B8A8_UNORM
                this.useDepth = false
                this.colorSampled = true
                this.colorTransferSrc = true
                this.clearColor = afield.math.linalg.vec(0f, 0f, 0f, 1f)
                this.debugName = "rectsl-$label"
            }
            module = Module(parameters(renderer) {
                name = SDF_MODULE_NAME
                vertex = RectSLSdfShader.VERTEX
                this.fragment = fragment
                blend = false
                depthTest = false
                primitiveType = triangles
                +rtt
            }).pointer()
            val mesh = Mesh(renderer, module)
            mesh.resize(3, 0, module) // fullscreen triangle, no vertex attributes
            mesh.push.bind("time") { currentTime.toFloat() }
            mesh.push["wallWidth"] = program.wall.width.toFloat()
            mesh.push["wallHeight"] = program.wall.height.toFloat()
            mesh.push.bind("probeChannel") { probeChannel.toFloat() }
            mesh.push.bind("probeRange") { probeRange.toFloat() }
            module.current.meshes[SDF_MESH_NAME] = mesh
            rtt += SDF_MODULE_NAME to module
        }

        fun dispose() {
            runCatching {
                rtt.modules.remove(SDF_MODULE_NAME)
                module.current.meshes.values.toSet().forEach { mesh ->
                    (mesh.v.values + mesh.vn.values).toSet().forEach { it.cleanup() }
                    mesh.t?.cleanup()
                }
                module.current.meshes.clear()
                module.current.cleanup()
            }.onFailure { lprintln("error,doc", "rectsl sdf module dispose failed: ${it.message}") }
            disposeRtt(rtt)
        }
    }

Read the target back and unpack the 24-bit payload of every pixel.

source — 44 lines, from line 836
    private fun decodeProbe(
        renderer: Renderer,
        session: SdfSession,
        width: Int,
        height: Int,
        range: Double,
        label: String
    ): DoubleArray {
        val png = readbackPng(renderer, session.rtt, label, width, height)
        val out = DoubleArray(width * height)
        val buffer = MemoryUtil.memAlloc(png.size)
        try {
            buffer.put(png).flip()
            MemoryStack.stackPush().use { stack ->
                val w = stack.mallocInt(1)
                val h = stack.mallocInt(1)
                val ch = stack.mallocInt(1)
                // STB rather than ImageIO on purpose: this runs inside a live GLFW
                // process, and pulling AWT up on macOS to read four bytes is not worth
                // the risk. LWJGL's decoder is already loaded.
                val decoded = STBImage.stbi_load_from_memory(buffer, w, h, ch, 4)
                    ?: error("could not decode the RectSL SDF probe readback for '$label'")
                try {
                    require(w[0] == width && h[0] == height) {
                        "probe readback was ${w[0]}x${h[0]}, expected ${width}x$height"
                    }
                    for (i in 0 until width * height) {
                        val off = i * 4
                        out[i] = RectSLSdfShader.unpack24(
                            decoded[off].toInt() and 0xFF,
                            decoded[off + 1].toInt() and 0xFF,
                            decoded[off + 2].toInt() and 0xFF,
                            range
                        )
                    }
                } finally {
                    STBImage.stbi_image_free(decoded)
                }
            }
        } finally {
            MemoryUtil.memFree(buffer)
        }
        return out
    }

Ordinary least squares through n samples of an exactly-linear sequence.

    private fun fitLinear(n: Int, value: (Int) -> Double): (Double) -> Double {
        require(n >= 2) { "need at least two samples to fit a line" }
        val meanX = (n - 1) / 2.0
        var meanY = 0.0
        for (i in 0 until n) meanY += value(i)
        meanY /= n
        var num = 0.0
        var den = 0.0
        for (i in 0 until n) {
            val dx = i - meanX
            num += dx * (value(i) - meanY)
            den += dx * dx
        }
        val slope = num / den
        val intercept = meanY - slope * meanX
        return { x -> intercept + slope * x }
    }

Exact distance to one sampled rect — the CPU side of the differential.

    private fun boxDistance(p: afield.lang.rectsl.Vec2d, r: afield.lang.rectsl.SampledRect): Double {
        val halfX = abs(0.5 * r.size.x * r.scale.x)
        val halfY = abs(0.5 * r.size.y * r.scale.y)
        val qx = p.x - r.center.x
        val qy = p.y - r.center.y
        val c = cos(r.rotation)
        val s = sin(r.rotation)
        val lx = c * qx + s * qy
        val ly = -s * qx + c * qy
        val dx = abs(lx) - halfX
        val dy = abs(ly) - halfY
        return hypot(max(dx, 0.0), max(dy, 0.0)) + min(max(dx, dy), 0.0)
    }

Two PNGs side by side with a hairline gap, via the ffmpeg the mp4s already use.

    private fun hstack(ffmpeg: String, left: File, right: File, output: File) {
        output.parentFile?.mkdirs()
        output.delete()
        ffmpeg(
            ffmpeg, "compositing ${output.name}",
            "-i", left.absolutePath,
            "-i", right.absolutePath,
            "-filter_complex",
            "[0:v]pad=iw+8:ih:0:0:color=0x202429[a];[a][1:v]hstack=inputs=2",
            "-frames:v", "1",
            output.absolutePath
        )
    }

Run ffmpeg with -y, and fail loudly with its own log rather than silently.

    private fun ffmpeg(ffmpeg: String, what: String, vararg args: String) {
        val process = ProcessBuilder(listOf(ffmpeg, "-y") + args)
            .redirectErrorStream(true).start()
        val log = process.inputStream.bufferedReader().readText()
        if (!process.waitFor(120, TimeUnit.SECONDS)) {
            process.destroyForcibly()
            error("ffmpeg timed out $what")
        }
        require(process.exitValue() == 0) {
            "ffmpeg failed (${process.exitValue()}) $what:\n${log.takeLast(800)}"
        }
    }

render paths

source — 51 lines, from line 946
    private suspend fun renderStill(
        renderer: Renderer,
        program: SceneProgram,
        time: Double,
        outputFile: File,
        label: String
    ) {
        val (width, height) = outputDims(program)
        val session = Session(renderer, program, width, height)
        try {
            session.currentTime = time
            renderer.renderList.preTargets[PRE_TARGET_NAME] = session.rtt
            waitForRendererFrames(renderer, WAIT_FRAMES)
            captureTo(renderer, session, outputFile, label, width, height)
        } finally {
            renderer.renderList.preTargets.remove(PRE_TARGET_NAME)
            session.dispose()
        }
    }

    private suspend fun renderOneAnimation(
        renderer: Renderer,
        program: SceneProgram,
        name: String,
        outputDir: File,
        ffmpeg: String
    ): File {
        val (width, height) = outputDims(program)
        val fps = min(program.fps.roundToInt().coerceAtLeast(1), ANIM_FPS)
        val frameCount = min(max(1, ceil(program.duration * fps).toInt()), ANIM_MAX_FRAMES)
        val session = Session(renderer, program, width, height)
        val framesDir = Files.createTempDirectory("rectsl-anim-$name").toFile()
        try {
            renderer.renderList.preTargets[PRE_TARGET_NAME] = session.rtt
            session.currentTime = 0.0
            waitForRendererFrames(renderer, WAIT_FRAMES)
            for (i in 0 until frameCount) {
                session.currentTime = if (frameCount > 1) program.duration * i / frameCount else 0.0
                waitForRendererFrames(renderer, SETTLE_FRAMES)
                captureTo(renderer, session, File(framesDir, "frame_%05d.png".format(i)), name, width, height)
            }
            val mp4 = File(outputDir, "rectsl-$name.mp4")
            encodeMp4(ffmpeg, framesDir, "frame_%05d.png", fps, mp4)
            require(mp4.length() > 0L) { "ffmpeg wrote an empty mp4 for '$name'" }
            return mp4
        } finally {
            framesDir.deleteRecursively()
            renderer.renderList.preTargets.remove(PRE_TARGET_NAME)
            session.dispose()
        }
    }

one program's render state (target + module + mesh)

The offscreen target, fullscreen-style rect-scene module, and mesh for a single program. Mirrors RectSLRenderRunner.renderJpgSequence's setup but captures through TexturePreviewCache (the readback path proven headless by the StackSL gallery) instead of saveJpgAsync, and lets the caller step currentTime.

source — 62 lines, from line 1006
    private class Session(
        renderer: Renderer,
        program: SceneProgram,
        width: Int,
        height: Int
    ) {
        var currentTime: Double = 0.0
        val rtt: RenderToTexture
        private val module: ModulePointer

        init {
            val shaderCode = rectMaterialShaderCode(program)
            require(shaderCode.ok) {
                "RectSL materials did not compile:\n" + shaderCode.diagnostics.joinToString("\n")
            }
            rtt = RenderToTexture(renderer) {
                this.width = width
                this.height = height
                this.format = VK_FORMAT_R8G8B8A8_UNORM
                this.useDepth = false
                this.colorSampled = true
                this.colorTransferSrc = true
                this.clearColor = afield.math.linalg.vec(0f, 0f, 0f, 1f)
                this.debugName = "rectsl-gallery"
            }
            module = Module(parameters(renderer) {
                name = MODULE_NAME
                vertex = RECT_ANIMATION_VERTEX_SHADER
                fragment = shaderCode.exampleFragmentShader
                blend = true
                depthTest = false
                primitiveType = triangles
                +rtt
            }).pointer()
            val mesh = Mesh(renderer, module)
            mesh.push["worldToClip"] = program.wall.worldToClip()
            mesh.push.bind("time") { currentTime.toFloat() }
            mesh.push["wallWidth"] = program.wall.width.toFloat()
            mesh.push["wallHeight"] = program.wall.height.toFloat()
            mesh.update["rectsl-frame"] = sequence {
                while (true) {
                    program.writeFrameToMesh(currentTime, mesh)
                    yield(Unit)
                }
            }
            module.current.meshes[MESH_NAME] = mesh
            rtt += MODULE_NAME to module
        }

        fun dispose() {
            runCatching {
                rtt.modules.remove(MODULE_NAME)
                module.current.meshes.values.toSet().forEach { mesh ->
                    (mesh.v.values + mesh.vn.values).toSet().forEach { it.cleanup() }
                    mesh.t?.cleanup()
                }
                module.current.meshes.clear()
                module.current.cleanup()
            }.onFailure { lprintln("error,doc", "rectsl gallery module dispose failed: ${it.message}") }
            disposeRtt(rtt)
        }
    }

shared plumbing

    private suspend fun compile(example: Example): SceneProgram = compileForRender(example.name, example.source)

A dedicated Kotlin engine for pre-warming {{ … }} escapes. It must NOT be the render box's own engine: that one is busy executing the render code that calls us, so compiling a new snippet on it re-enters a single-threaded REPL and deadlocks. A fresh, idle engine compiles fine while the box engine runs.

    private val escapeEngine: KotlinScriptEngine by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        KotlinInterface2(filePrefix = "RECTSL_ESCAPE_RENDER_")
    }

Compile a program for rendering, pre-warming any {{ … }} escapes off the runloop first (see prewarmRectSLEscapes) so the render never triggers a blocking K2 compile inside the non-reentrant graphics runloop.

    private suspend fun compileForRender(
        name: String,
        source: String,
        curves: Map<String, Functions.Fn> = emptyMap()
    ): SceneProgram {
        val precompiled = if ("{{" in source) prewarmRectSLEscapes(source, escapeEngine) else emptyMap()
        val result = compileRectSL(source, curves, precompiledEscapes = precompiled)
        return result.programOrNull?.takeIf { result.ok }
            ?: throw RectLangException("RectSL '$name' did not compile:\n${result.diagnostics.joinToString("\n")}")
    }

Longest side OUTPUT_LONG; the other derived from the wall aspect, both even.

    private fun outputDims(program: SceneProgram): Pair<Int, Int> {
        val aspect = program.wall.width / program.wall.height
        val (w, h) = if (aspect >= 1.0) OUTPUT_LONG.toDouble() to OUTPUT_LONG / aspect
        else OUTPUT_LONG * aspect to OUTPUT_LONG.toDouble()
        fun even(v: Double) = max(2, (v.roundToInt() / 2) * 2)
        return even(w) to even(h)
    }

    private suspend fun captureTo(
        renderer: Renderer,
        session: Session,
        outputFile: File,
        label: String,
        width: Int,
        height: Int
    ) = captureRttTo(renderer, session.rtt, outputFile, label, width, height)

Read back one offscreen target as PNG bytes on disk. Shared by both sessions.

    private fun captureRttTo(
        renderer: Renderer,
        rtt: RenderToTexture,
        outputFile: File,
        label: String,
        width: Int,
        height: Int
    ) {
        outputFile.parentFile?.mkdirs()
        outputFile.delete()
        outputFile.writeBytes(readbackPng(renderer, rtt, label, width, height))
        require(outputFile.length() > 0L) { "RectSL gallery readback wrote an empty image for '$label'" }
    }

The same readback, kept in memory — the SDF differential never wants a file.

source — 79 lines, from line 1131
    private fun readbackPng(
        renderer: Renderer,
        rtt: RenderToTexture,
        label: String,
        width: Int,
        height: Int
    ): ByteArray {
        val html = TexturePreviewCache.capture(
            renderer = renderer,
            vkImage = rtt.colorImageHandles[0],
            format = rtt.colorFormats[0],
            srcWidth = width,
            srcHeight = height,
            currentLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
            maxDim = max(width, height)
        ) ?: error("could not read back the RectSL gallery texture for '$label'")
        val encoded = DATA_IMAGE_RE.find(html)?.groupValues?.get(1)
            ?: error("could not parse the RectSL gallery texture data for '$label'")
        return Base64.getDecoder().decode(encoded)
    }

    private fun resolveFfmpeg(): String {
        val candidates = listOfNotNull(
            System.getenv("FFMPEG"),
            "/opt/homebrew/bin/ffmpeg",
            "/usr/local/bin/ffmpeg",
            "/usr/bin/ffmpeg"
        )
        return candidates.firstOrNull { File(it).canExecute() } ?: "ffmpeg"
    }

    private fun encodeMp4(ffmpeg: String, framesDir: File, pattern: String, fps: Int, output: File) {
        output.parentFile?.mkdirs()
        output.delete()
        val process = ProcessBuilder(
            ffmpeg, "-y",
            "-framerate", fps.toString(),
            "-i", pattern,
            "-c:v", "libx264",
            "-pix_fmt", "yuv420p",
            "-crf", "20",
            "-movflags", "+faststart",
            output.absolutePath
        ).directory(framesDir).redirectErrorStream(true).start()
        val log = process.inputStream.bufferedReader().readText()
        if (!process.waitFor(120, TimeUnit.SECONDS)) {
            process.destroyForcibly()
            error("ffmpeg timed out encoding ${output.name}")
        }
        require(process.exitValue() == 0) {
            "ffmpeg failed (${process.exitValue()}) for ${output.name}:\n${log.takeLast(600)}"
        }
    }

    private suspend fun waitForRendererFrames(renderer: Renderer, frames: Int) {
        val target = renderer.frameCounter + frames
        val deadline = System.nanoTime() + 15_000_000_000L
        while (renderer.frameCounter < target) {
            if (System.nanoTime() > deadline) {
                error("timed out waiting for renderer frames: current=${renderer.frameCounter} target=$target")
            }
            delay(8)
            yield()
        }
    }

    private fun disposeRtt(rtt: RenderToTexture) {
        runCatching {
            rtt.defaultSampler?.cleanup()
            rtt.imageViewArray.map { it.imageView }.distinctBy { it.vkImageView }.forEach { it.cleanup() }
            rtt.colorImageRefs.filterNotNull().distinctBy { it.vkImage }.forEach { it.cleanup() }
            listOfNotNull(rtt.depthSampledImageView, rtt.depthImageView).distinctBy { it.vkImageView }
                .forEach { it.cleanup() }
            rtt.depthImage?.cleanup()
            rtt.commandBuffers.forEach { it.cleanup() }
            rtt.fences.forEach { it.cleanup() }
        }.onFailure { lprintln("error,doc", "rectsl gallery RTT dispose failed: ${it.message}") }
    }
}