Skip to content

RectSLStudyRender.kt

src/jvmMain/kotlin/afield/lang/rectsl/study/RectSLStudyRender.kt · 552 lines

package and imports — 41 lines, from line 1
package afield.lang.rectsl.study

import afield.study.source.renderAll
import afield.study.source.StudyFrames
import afield.runtime.lifecycle.Runloop
import afield.graphics.vulkan.Renderer
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.lang.rectsl.FitPolicy
import afield.lang.rectsl.MaterialBinding
import afield.lang.rectsl.Origin
import afield.lang.rectsl.PaintSpec
import afield.lang.rectsl.RECT_ANIMATION_VERTEX_SHADER
import afield.lang.rectsl.RectMesh
import afield.lang.rectsl.SampledRect
import afield.lang.rectsl.SceneProgram
import afield.lang.rectsl.StackSLRectMaterialCompileResult
import afield.lang.rectsl.Vec2d
import afield.lang.rectsl.lowerSampledScene
import afield.lang.rectsl.rectAnimationFragmentShader
import afield.lang.rectsl.writeTo
import afield.lang.stacksl.cpu.CpuRaster
import afield.lang.stacksl.cpu.GlslValue
import afield.lang.stacksl.cpu.StackSLCpu
import afield.lang.stacksl.cpu.newInvocation
import afield.lang.stacksl.cpu.parseGlslShader
import afield.math.linalg.Matrix4f
import afield.math.linalg.vec
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_UNORM
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

Drawing a RectSL scene for a study, on the GPU in a running window (RectSLGpuRasterizer) or by the CPU reference (CpuRectSLRasterizer), through the one shader setup in RectSLStudyShader so that both give the same frames.

object RectSLStudyShader

How a RectSL scene is drawn for a study, in the window and in every piece of evidence — the one place those two are made to agree.

A scene is drawn the way the RectSL gallery draws it — the lowered rectangle mesh through RECT_ANIMATION_VERTEX_SHADER and the scene's StackSL materials, into an 8-bit target — so what a study shows is what the published RectSL pictures show:

  • Colours are sRGB, as typed, and blend as typed. 0x808080 is the mid grey a colour picker shows, and a rectangle at 3% opacity is a faint wash, as in a design tool: blending happens on the typed values. A window's swapchain is sRGB and would blend in linear light instead, where 3% of a bright orange over a dark sky is a clearly visible box; so the window draws the scene into an 8-bit target of its own size and shows that (COMPOSITE_FRAGMENT), decoding it so the swapchain's encode gives back exactly the bytes drawn. A StackSL material's output is written the same way, multiplied by the rectangle's colour.
  • The wall has its background. The scene's ->background is drawn as the first rectangle, so a window whose aspect differs from the wall's shows the wall on black, fitted as ->fit says (contain).

Frames come back as CpuRasters of linear colour — the drawn bytes decoded — rows top to bottom, so the StackSL study's measurements and presented (clamp, then sRGB encode) apply unchanged, and give back the drawn bytes.

object RectSLStudyShader {

val VERTEX: String = RECT_ANIMATION_VERTEX_SHADER

The vertex shader: RectSL's own, unchanged.

val COMPOSITE_VERTEX: String = afield.lang.stacksl.study.StackSLStudyShader.VERTEX

A fullscreen triangle with fragUV running 0..1, y up — the window pass that shows the scene's target.

val COMPOSITE_FRAGMENT: String

Shows the scene's 8-bit target in an sRGB window: each texel decoded to linear light, which the swapchain encodes back to the same byte. The target's first row is the top of the picture and fragUV.y grows upwards, hence the flip.

    val COMPOSITE_FRAGMENT: String = """
        #version 450
        layout(location = 0) in vec2 fragUV;
        layout(location = 0) out vec4 outColor;
        layout(set = 0, binding = 0) uniform sampler2D rectslScene;

        void main() {
            vec3 c = texture(rectslScene, vec2(fragUV.x, 1.0 - fragUV.y)).rgb;
            outColor = vec4(mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)), 1.0);
        }
    """.trimIndent()

fun fragment(materials: List<StackSLRectMaterialCompileResult>): String

The fragment shader for a scene with these compiled materials.

    fun fragment(materials: List<StackSLRectMaterialCompileResult>): String = rectAnimationFragmentShader(materials)

fun linear(v: Double): Double

The sRGB transfer function's inverse: a drawn byte's value (0..1) as linear light.

    fun linear(v: Double): Double = if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)

fun mesh(scene: SceneProgram, time: Double, outlined: Set<String> = emptySet(), pixel: Double = 0.0): RectMesh

scene at time, with its background as the first rectangle — the mesh every study draw writes. The rectangles named in outlined get an outline drawn over everything, two pixels wide (in wall units): the live window's way of showing which rectangles the editor's line makes.

    fun mesh(scene: SceneProgram, time: Double, outlined: Set<String> = emptySet(), pixel: Double = 0.0): RectMesh {
        val sampled = scene.sample(time)
        val wall = scene.wall
        val centre = if (wall.origin == Origin.TopLeft) Vec2d(wall.width / 2, wall.height / 2) else Vec2d(0.0, 0.0)
        val background = SampledRect(
            id = "background", objectId = -1, center = centre, size = Vec2d(wall.width, wall.height), rotation = 0.0,
            scale = Vec2d(1.0, 1.0), alpha = 1.0, z = Double.NEGATIVE_INFINITY, fill = PaintSpec.Solid(scene.background),
            material = MaterialBinding(0), tags = emptySet()
        )
        val outlines = if (outlined.isEmpty() || pixel <= 0) emptyList()
            else sampled.rects.filter { it.id in outlined && it.alpha > 0.01 }.let { picked ->
                // A dark halo under a bright line, so the outline reads over light and dark alike.
                picked.flatMap { edges(it, 4.0 * pixel, OUTLINE_HALO) } + picked.flatMap { edges(it, 2.0 * pixel, OUTLINE) }
            }
        return lowerSampledScene(sampled.copy(rects = listOf(background) + sampled.rects + outlines))
    }

    private val OUTLINE = afield.graphics.color.Color4(0.36, 0.88, 1.0, 1.0)
    private val OUTLINE_HALO = afield.graphics.color.Color4(0.0, 0.0, 0.0, 0.6)

Four thin rectangles, thickness wide, along r's edges and turned with it.

    private fun edges(r: SampledRect, thickness: Double, colour: afield.graphics.color.Color4): List<SampledRect> {
        val hw = 0.5 * r.size.x * kotlin.math.abs(r.scale.x); val hh = 0.5 * r.size.y * kotlin.math.abs(r.scale.y)
        val c = kotlin.math.cos(r.rotation); val s = kotlin.math.sin(r.rotation)
        fun edge(x: Double, y: Double, w: Double, h: Double) = SampledRect(
            id = "outline-${r.id}", objectId = -1, center = Vec2d(r.center.x + c * x - s * y, r.center.y + s * x + c * y),
            size = Vec2d(w, h), rotation = r.rotation, scale = Vec2d(1.0, 1.0), alpha = 1.0, z = Double.POSITIVE_INFINITY,
            fill = PaintSpec.Solid(colour), material = MaterialBinding(0), tags = emptySet())
        return listOf(edge(0.0, hh, 2 * hw + thickness, thickness), edge(0.0, -hh, 2 * hw + thickness, thickness),
            edge(-hw, 0.0, thickness, 2 * hh + thickness), edge(hw, 0.0, thickness, 2 * hh + thickness))
    }

fun visible(scene: SceneProgram, width: Int, height: Int): DoubleArray

The part of the wall a width by height target shows, as its centre and size — cx, cy, w, h in wall units — under the scene's ->fit (see contain).

    fun visible(scene: SceneProgram, width: Int, height: Int): DoubleArray {
        val wall = scene.wall
        val target = width.toDouble() / max(1, height)
        val aspect = wall.width / wall.height
        val wider = target > aspect
        val (w, h) = when (scene.fit) {
            FitPolicy.Stretch -> wall.width to wall.height
            FitPolicy.Cover -> if (wider) wall.width to wall.width / target else wall.height * target to wall.height
            FitPolicy.Contain -> if (wider) wall.height * target to wall.height else wall.width to wall.width / target
        }
        val (cx, cy) = if (wall.origin == Origin.TopLeft) wall.width / 2 to wall.height / 2 else 0.0 to 0.0
        return doubleArrayOf(cx, cy, w, h)
    }

fun wallPoint(scene: SceneProgram, width: Int, height: Int, u: Double, v: Double): Vec2d

The wall point under (u, v) of a width by height target, u across from the left and v down from the top, both 0..1: where a click in the window lands in the scene.

    fun wallPoint(scene: SceneProgram, width: Int, height: Int, u: Double, v: Double): Vec2d {
        val (cx, cy, w, h) = visible(scene, width, height)
        val x = cx - w / 2 + u * w
        return Vec2d(x, if (scene.wall.origin == Origin.TopLeft) cy - h / 2 + v * h else cy + h / 2 - v * h)
    }

fun contain(scene: SceneProgram, width: Int, height: Int): Matrix4f

The world-to-clip transform that fits the wall into a width by height target the way the scene's ->fit asks: contain (the default) shows the whole wall centred with black around it, cover fills the target and crops the wall, stretch fills it and distorts. When the aspects agree — every study frame — all three are exactly the wall.

    fun contain(scene: SceneProgram, width: Int, height: Int): Matrix4f {
        val wall = scene.wall
        val (cx, cy, w, h) = visible(scene, width, height)
        return if (wall.origin == Origin.TopLeft) {
            Matrix4f().ortho((cx - w / 2).toFloat(), (cx + w / 2).toFloat(), (cy + h / 2).toFloat(), (cy - h / 2).toFloat(), -1f, 1f)
        } else {
            Matrix4f().ortho((cx - w / 2).toFloat(), (cx + w / 2).toFloat(), (cy - h / 2).toFloat(), (cy + h / 2).toFloat(), -1f, 1f)
        }
    }

fun frameSize(scene: SceneProgram, longSide: Int): Pair<Int, Int>

A frame's pixel size: longSide along the wall's longer side, the other from its aspect, both even and at least 2.

    fun frameSize(scene: SceneProgram, longSide: Int): Pair<Int, Int> {
        val aspect = scene.wall.width / scene.wall.height
        fun even(v: Double) = max(2, (v.roundToInt() / 2) * 2)
        return if (aspect >= 1) even(longSide.toDouble()) to even(longSide / aspect) else even(longSide * aspect) to even(longSide.toDouble())
    }
}

class RectSLDrawable

What a study draws: a compiled scene, the source it came from (the key a GPU pipeline is cached by), and its compiled materials.

class RectSLDrawable(
    val source: String,
    val scene: SceneProgram,
    val materials: List<StackSLRectMaterialCompileResult>,
    /** Which line made each rectangle, by id — what the live window outlines and picks by. */
    val origins: Map<String, afield.lang.rectsl.RectSLOrigin> = emptyMap()
)

typealias RectSLRasterizer = StudyFrames<RectSLDrawable>

Where a RectSL study's frames come from: the GPU when a window is running (RectSLGpuRasterizer), the CPU reference when it is not (CpuRectSLRasterizer). Both return linear colour, rows top to bottom, the wall filling the frame. A StudyFrames of a compiled scene.

object CpuRectSLRasterizer : RectSLRasterizer

The CPU reference: the lowered mesh rasterized quad by quad at pixel centres, blended as the GPU blends into its 8-bit target (source alpha over, on the typed values), shaded as RectSLStudyShader.fragment shades — the three built-in materials in Kotlin, a StackSL material through the StackSL GLSL interpreter — and decoded to linear light at the end, as the GPU's bytes are. It needs no graphics context, so tests and headless runs see the scene the window shows, a few levels apart at rectangle edges (it samples one point per pixel, with a top-left fill rule like the GPU's).

override val description

    override val description = "CPU reference (afield.lang.rectsl.study): exact geometry, one sample per pixel; launch the window for the GPU"

override val fast = true

    private const val MATERIAL_TEMPLATE = """
#version 450
layout(location = 0) in vec2 fragUV;
layout(location = 1) in vec2 rectInfo;
layout(location = 0) out vec4 outColor;

{{ stack.auto.program }}

void main() {
    outColor = program(vec4(fragUV, rectInfo.x, rectInfo.y));
}
"""

override fun render(input: RectSLDrawable, width: Int, height: Int, seconds: List<Double>, each: (index: Int, raster: CpuRaster) -> Unit) {

body — 63 lines, from line 232
    override fun render(input: RectSLDrawable, width: Int, height: Int, seconds: List<Double>, each: (index: Int, raster: CpuRaster) -> Unit) {
        val drawable = input
        val shaders = drawable.materials.filter { it.ok }.associate { m ->
            m.module.materialId to runCatching { parseGlslShader(StackSLCpu.expandStackAuto(MATERIAL_TEMPLATE, m.module.source)) }.getOrNull()
        }
        seconds.forEachIndexed { i, t -> each(i, frame(drawable.scene, shaders, width, height, t)) }
    }

    private class Quad(
        val x: DoubleArray, val y: DoubleArray,
        val colour: Array<DoubleArray>, val material: DoubleArray, val shaderData: DoubleArray,
        val minX: Int, val maxX: Int, val minY: Int, val maxY: Int
    )

    private fun frame(scene: SceneProgram, shaders: Map<Int, afield.lang.stacksl.cpu.GlslShader?>, width: Int, height: Int, time: Double): CpuRaster {
        val mesh = RectSLStudyShader.mesh(scene, time)
        val wall = scene.wall
        fun px(x: Double) = if (wall.origin == Origin.TopLeft) x / wall.width * width else (x + wall.width / 2) / wall.width * width
        fun py(y: Double) = if (wall.origin == Origin.TopLeft) y / wall.height * height else (wall.height / 2 - y) / wall.height * height
        val quads = (0 until mesh.vertices.size / 4).map { q ->
            val v = (0 until 4).map { mesh.vertices[q * 4 + it] }
            val xs = DoubleArray(4) { px(v[it].position.x.toDouble()) }
            val ys = DoubleArray(4) { py(v[it].position.y.toDouble()) }
            Quad(xs, ys,
                Array(4) { doubleArrayOf(v[it].color.x.toDouble(), v[it].color.y.toDouble(), v[it].color.z.toDouble(), v[it].color.w.toDouble()) },
                doubleArrayOf(v[0].material.x.toDouble(), v[0].material.y.toDouble(), v[0].material.z.toDouble(), v[0].material.w.toDouble()),
                doubleArrayOf(v[0].shaderData.x.toDouble(), v[0].shaderData.y.toDouble(), v[0].shaderData.z.toDouble(), v[0].shaderData.w.toDouble()),
                max(0, floor(xs.min()).toInt()), min(width - 1, floor(xs.max()).toInt()),
                max(0, floor(ys.min()).toInt()), min(height - 1, floor(ys.max()).toInt()))
        }
        val rgba = FloatArray(width * height * 4)
        IntStream.range(0, height).parallel().forEach { row ->
            val invocations = HashMap<Int, afield.lang.stacksl.cpu.GlslInvocation>()
            val cy = row + 0.5
            for (quad in quads) {
                if (row < quad.minY || row > quad.maxY) continue
                for (col in quad.minX..quad.maxX) {
                    val cx = col + 0.5
                    // Two triangles, (0 1 2) and (0 2 3), as the index buffer draws them. The half-open
                    // edge rule in barycentric gives a centre on their shared diagonal to exactly one.
                    val w = barycentric(quad, 0, 1, 2, cx, cy) ?: barycentric(quad, 0, 2, 3, cx, cy) ?: continue
                    val (a, b, c, weights) = w
                    val u = CORNER_U[a] * weights[0] + CORNER_U[b] * weights[1] + CORNER_U[c] * weights[2]
                    val vv = CORNER_V[a] * weights[0] + CORNER_V[b] * weights[1] + CORNER_V[c] * weights[2]
                    val colour = DoubleArray(4) { ch -> quad.colour[a][ch] * weights[0] + quad.colour[b][ch] * weights[1] + quad.colour[c][ch] * weights[2] }
                    val out = shade(quad, colour, u, vv, time, shaders, invocations) ?: continue
                    val base = (row * width + col) * 4
                    val alpha = out[3].coerceIn(0.0, 1.0)
                    // An 8-bit target stores each blend's result clamped to 0..1.
                    for (ch in 0 until 3) rgba[base + ch] = (out[ch] * alpha + rgba[base + ch] * (1 - alpha)).coerceIn(0.0, 1.0).toFloat()
                    rgba[base + 3] = (alpha + rgba[base + 3] * (1 - alpha)).toFloat()
                }
            }
        }
        // Rounded to the target's 8 bits and decoded, as the GPU's readback is.
        for (i in rgba.indices) if (i % 4 != 3) rgba[i] = RectSLStudyShader.linear(Math.round(rgba[i] * 255.0) / 255.0).toFloat()
        return CpuRaster(width, height, rgba)
    }

    private val CORNER_U = doubleArrayOf(0.0, 1.0, 1.0, 0.0)
    private val CORNER_V = doubleArrayOf(0.0, 0.0, 1.0, 1.0)

    private data class Weights(val a: Int, val b: Int, val c: Int, val w: DoubleArray)

Barycentric weights of (px, py) in triangle (a, b, c) of quad, or null outside it; either winding. A point exactly on an edge counts only if that edge faces left, or straight up (a half-open, top-left fill rule), so a rectangle covers as many pixels as it is wide wherever its edges land, and an edge two triangles share belongs to exactly one of them.

    private fun barycentric(quad: Quad, a: Int, b: Int, c: Int, px: Double, py: Double): Weights? {
        val ax = quad.x[a]; val ay = quad.y[a]; val bx = quad.x[b]; val by = quad.y[b]; val cx = quad.x[c]; val cy = quad.y[c]
        val area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
        if (kotlin.math.abs(area) < 1e-12) return null
        val w0 = ((bx - px) * (cy - py) - (by - py) * (cx - px)) / area
        val w1 = ((cx - px) * (ay - py) - (cy - py) * (ax - px)) / area
        val w2 = 1.0 - w0 - w1
        if (w0 < 0 || w1 < 0 || w2 < 0) return null
        if (w0 == 0.0 && !ownsEdge(bx, by, cx, cy, ax, ay)) return null
        if (w1 == 0.0 && !ownsEdge(cx, cy, ax, ay, bx, by)) return null
        if (w2 == 0.0 && !ownsEdge(ax, ay, bx, by, cx, cy)) return null
        return Weights(a, b, c, doubleArrayOf(w0, w1, w2))
    }

Whether edge p–q, opposite r, faces left or straight up (y grows downward), and so owns its points.

    private fun ownsEdge(px: Double, py: Double, qx: Double, qy: Double, rx: Double, ry: Double): Boolean {
        var nx = qy - py
        var ny = -(qx - px)
        if (nx * (rx - px) + ny * (ry - py) > 0) { nx = -nx; ny = -ny }
        return nx < 0 || (nx == 0.0 && ny < 0)
    }

    private fun shade(quad: Quad, colour: DoubleArray, u: Double, v: Double, time: Double,
                      shaders: Map<Int, afield.lang.stacksl.cpu.GlslShader?>, invocations: HashMap<Int, afield.lang.stacksl.cpu.GlslInvocation>): DoubleArray? {
        val id = (quad.material[0] + 0.5).toInt()
        return when (id) {
            0 -> colour
            1 -> {
                val edge = minOf(u, 1 - u, v, 1 - v)
                val softness = max(quad.shaderData[0], 0.0001)
                val t = (edge / softness).coerceIn(0.0, 1.0)
                doubleArrayOf(colour[0], colour[1], colour[2], colour[3] * t * t * (3 - 2 * t))
            }
            2 -> doubleArrayOf(u, v, (quad.material[2] * 0.137).let { it - floor(it) }, max(colour[3], 0.35))
            else -> {
                val shader = shaders[id] ?: return doubleArrayOf(1.0, 0.0, 1.0, 1.0)
                val invocation = invocations.getOrPut(id) { shader.newInvocation() }
                invocation.setGlobal("fragUV", GlslValue.vec2(u.toFloat(), v.toFloat()))
                invocation.setGlobal("rectInfo", GlslValue.vec2(quad.material[2].toFloat(), time.toFloat()))
                if (!invocation.runMain()) return null
                val stack = invocation.global("outColor")?.c ?: return null
                doubleArrayOf(stack[0] * colour[0], stack[1] * colour[1], stack[2] * colour[2], stack[3] * colour[3])
            }
        }
    }
}

class RectSLGpuRasterizer

A study's frames from the GPU: the scene drawn by a real Field 4 graphics module — RectSLStudyShader's shaders, the scene's materials — into an offscreen 8-bit target on a running renderer, as the window draws it, and read back; the bytes are decoded to linear light (see RectSLStudyShader).

For each time the scene is lowered on Field's main thread as the frame is recorded, two frames are allowed to finish, and a readback is queued. With no running renderer, a closed one, a window that has stopped drawing, a shader the GPU rejects, or a call from the main thread (which is the thread that draws), it renders on fallback and says why in description.

class RectSLGpuRasterizer(
    private val rendererOf: () -> Renderer?,
    private val fallback: RectSLRasterizer = CpuRectSLRasterizer,
    private val frameTimeoutMs: Long = 4000
) : RectSLRasterizer {

@Volatile override var description: String

    @Volatile override var description: String = "GPU (no frames rendered yet)"
        private set

override val fast: Boolean get() = true

    private val lock = Any()
    private val targets = LinkedHashMap<Pair<Int, Int>, RenderToTexture>()
    private val builtFrom = HashMap<Pair<Int, Int>, String>()
    private var targetsFor: Renderer? = null
    @Volatile private var scene: SceneProgram? = null
    @Volatile private var time = 0.0

    private class Unavailable(message: String) : RuntimeException(message)

companion object

    companion object {
        private const val MODULE = "rectsl-study-module"
        private const val MESH = "scene"
        private const val TARGET = "rectsl-study-target"
        private const val MAX_TARGETS = 4
    }

override fun render(input: RectSLDrawable, width: Int, height: Int, seconds: List<Double>, each: (index: Int, raster: CpuRaster) -> Unit) {

body — 166 lines, from line 386
    override fun render(input: RectSLDrawable, width: Int, height: Int, seconds: List<Double>, each: (index: Int, raster: CpuRaster) -> Unit) {
        val drawable = input
        val renderer = rendererOf()
        val reason = when {
            renderer == null -> "no renderer: launch the window box"
            renderer.isClosed -> "the window's renderer is closed"
            Runloop.isMainThread -> "called on the main thread, which is the one that draws"
            else -> null
        }
        if (reason != null) {
            description = "CPU reference ($reason)"
            return fallback.render(drawable, width, height, seconds, each)
        }
        var done = 0
        try {
            synchronized(lock) {
                gpuRender(renderer!!, drawable, width, height, seconds) { i, raster -> each(i, raster); done = i + 1 }
            }
            description = "GPU (${renderer!!.physicalDevice.deviceName})"
        } catch (e: Unavailable) {
            description = "CPU reference (GPU: ${e.message})"
            fallback.render(drawable, width, height, seconds.drop(done)) { i, raster -> each(done + i, raster) }
        }
    }

    private fun onMain(block: () -> Unit) {
        if (Runloop.isMainThread) block() else runBlocking { withContext(Runloop.runLoop.main) { block() } }
    }

    private fun gpuRender(renderer: Renderer, drawable: RectSLDrawable, width: Int, height: Int, seconds: List<Double>, each: (Int, CpuRaster) -> Unit) {
        val key = width to height
        var target: RenderToTexture? = null
        var failure: String? = null
        scene = drawable.scene
        time = seconds.firstOrNull() ?: 0.0
        onMain {
            try {
                val t = targetFor(renderer, key)
                if (builtFrom[key] == drawable.source && t.modules[MODULE]?.current?.valid == true) {
                    renderer.renderList.preTargets[TARGET] = t
                    target = t
                    return@onMain
                }
                var error: String? = null
                val built = Module(parameters(renderer) {
                    name = MODULE
                    vertex = RectSLStudyShader.VERTEX
                    fragment = RectSLStudyShader.fragment(drawable.materials)
                    blend = true
                    depthTest = false
                    primitiveType = triangles
                    +t
                    errorHandler = { message, _ -> error = message }
                }).pointer()
                if (!built.current.valid) throw Unavailable("the GPU shader did not build: ${error ?: "unknown error"}")
                val mesh = Mesh(renderer, built)
                val wall = drawable.scene
                mesh.push["worldToClip"] = RectSLStudyShader.contain(wall, width, height)
                mesh.push.bind("time") { time.toFloat() }
                mesh.push["wallWidth"] = wall.wall.width.toFloat()
                mesh.push["wallHeight"] = wall.wall.height.toFloat()
                mesh.update["rectsl-frame"] = sequence {
                    while (true) {
                        scene?.let { RectSLStudyShader.mesh(it, time).writeTo(mesh) }
                        yield(Unit)
                    }
                }
                built.current.meshes[MESH] = mesh
                val previous = t.modules[MODULE]
                t += MODULE to built
                previous?.takeIf { it !== built }?.let { old -> renderer.cleanup.add(Runnable { dispose(old) }) }
                renderer.renderList.preTargets[TARGET] = t
                builtFrom[key] = drawable.source
                target = t
            } catch (e: Unavailable) {
                failure = e.message
            } catch (e: Exception) {
                failure = "the GPU shader did not build: ${e.message}"
            }
        }
        failure?.let { throw Unavailable(it) }
        val t = target!!
        try {
            seconds.forEachIndexed { i, s ->
                time = s
                awaitFrames(renderer, 2)
                val bytes = try {
                    t.readPixelsAsync().get(frameTimeoutMs, TimeUnit.MILLISECONDS)
                } catch (e: java.util.concurrent.TimeoutException) {
                    throw Unavailable("the window stopped drawing (hidden or minimised?)")
                }
                each(i, toRaster(bytes, width, height))
            }
        } finally {
            onMain { renderer.renderList.preTargets.remove(TARGET) }
        }
    }

    private fun awaitFrames(renderer: Renderer, frames: Int) {
        val start = renderer.frameCounter
        val deadline = System.nanoTime() + frameTimeoutMs * 1_000_000
        while (renderer.frameCounter < start + frames) {
            if (renderer.isClosed) throw Unavailable("the window's renderer closed")
            if (System.nanoTime() > deadline) throw Unavailable("the window stopped drawing (hidden or minimised?)")
            Thread.sleep(1)
        }
    }

    private fun targetFor(renderer: Renderer, key: Pair<Int, Int>): RenderToTexture {
        if (targetsFor !== renderer) {
            targets.clear()
            builtFrom.clear()
            targetsFor = renderer
        }
        targets[key]?.let { return it }
        if (targets.size >= MAX_TARGETS) {
            val oldest = targets.keys.first()
            targets.remove(oldest)?.let { old -> renderer.cleanup.add(Runnable { disposeTarget(old) }) }
            builtFrom.remove(oldest)
        }
        // 8-bit, as the window's scene target and the RectSL gallery are: blending on the typed values.
        val created = RenderToTexture(renderer) {
            width = key.first
            height = key.second
            format = VK_FORMAT_R8G8B8A8_UNORM
            useDepth = false
            colorSampled = true
            colorTransferSrc = true
            clearColor = vec(0f, 0f, 0f, 0f)
            debugName = "rectsl-study-${key.first}x${key.second}"
        }
        targets[key] = created
        return created
    }

    private fun toRaster(bytes: ByteArray, width: Int, height: Int): CpuRaster {
        val floats = FloatArray(width * height * 4)
        require(bytes.size >= floats.size) { "readback held ${bytes.size} bytes, expected ${floats.size}" }
        val decode = FloatArray(256) { RectSLStudyShader.linear(it / 255.0).toFloat() }
        for (i in floats.indices) floats[i] = if (i % 4 == 3) (bytes[i].toInt() and 0xff) / 255f else decode[bytes[i].toInt() and 0xff]
        return CpuRaster(width, height, floats)
    }

    private fun dispose(module: ModulePointer) {
        runCatching {
            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()
        }
    }

    private fun disposeTarget(rtt: RenderToTexture) {
        runCatching {
            rtt.modules.values.forEach(::dispose)
            rtt.modules.clear()
            rtt.defaultSampler?.cleanup()
            rtt.imageViewArray.map { it.imageView }.distinctBy { it.vkImageView }.forEach { it.cleanup() }
            rtt.colorImageRefs.filterNotNull().distinctBy { it.vkImage }.forEach { it.cleanup() }
            rtt.commandBuffers.forEach { it.cleanup() }
            rtt.fences.forEach { it.cleanup() }
        }
    }
}