Skip to content

StackSLFeedback.kt

src/jvmMain/kotlin/afield/lang/stacksl/StackSLFeedback.kt · 655 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.lang.stacksl

StackSL with its own previous frame: Box.stackFeedback draws a box's stackSL program fullscreen every frame into a float ping-pong target, with the last frame bound as sampler2D state, so prev, sample, gather and the store words (0-> … 3->) read what the program drew a frame ago. StackSLFeedback is the running host; StackSLLowering is the panel it shows in the box's output — the lowered GLSL, its helpers, wires, stack depth and diagnostics. StackSLFeedbackTest covers the shader it builds and the panel.

import afield.app.protocol.Rect
import afield.document.context.fromContext
import afield.document.graph.Box
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.fullscreenTriangle
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.shader.fromViews
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.PreparesSampledImages
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.PingPongRenderToTexture
import afield.graphics.vulkan.targets.RenderToTexture
import afield.math.linalg.vec
import afield.runtime.lifecycle.inMain
import afield.ui.output.displayOpen
import afield.ui.visualization.ToHTML
import org.lwjgl.vulkan.VK10.VK_FILTER_LINEAR
import org.lwjgl.vulkan.VK10.VK_FORMAT_R16G16B16A16_SFLOAT
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_UNORM
import org.lwjgl.vulkan.VK10.VK_SAMPLER_ADDRESS_MODE_REPEAT
import java.util.WeakHashMap

class StackSLFeedbackSettings

Settings for a StackSLFeedback. All but the size can change while it runs.

class StackSLFeedbackSettings {

var name: String = "stacksl-feedback"

Names what the host puts in the window. Every feedback box in a document uses the same name by default, so launching one replaces whichever was showing — the boxes work as a preset menu.

var width: Int = 0

The state's size in pixels; 0 takes the window's. Fixed once the host is built.

var height: Int = 0

var fps: Double = 60.0

How many times a second the program steps. A feedback program's decays are per step (prev 0.965 * keeps 96.5% each frame), so its look depends on this rate, and the window may draw far faster than a browser does; 60 is what StackTrinket gets in a browser at 60 Hz. 0 steps on every frame the window draws.

var timeScale: Double = 1.0

Seconds of program time per second of wall time — what time and 0.1t count in.

var paused: Boolean = false

Hold the picture: no frame is drawn and time stops until this is false again.

var displayGamma: Double = 2.2

The exponent the window applies to what the program drew. A browser shows a WebGL canvas's numbers as sRGB, and Field's window expects linear light, so 2.2 shows a program the way the StackTrinket page and the StackSL gallery show it. The state itself is never changed by this.

var showLowering: Boolean = true

Show the StackSLLowering panel in the box's lowering output channel.

var viewport: Rect? = null

Where in the window the picture goes; null fills it.

val inputs: MutableMap<String, HasImageView> = linkedMapOf()

More pictures the program can read beside state, in order: the first is sample1 / gather1 / texelSize1, the second sample2, up to three. Any texture will do — a classmate's WebRTC stream (webrtcSource), a camera, another render target. Each is copied, the right way up and at the state's size, once per step, so 0 sample1 reads it at this pixel with y up, exactly as prev reads the last frame. See input.

fun input(name: String, texture: HasImageView)

Add texture as the next input, read by sample1, sample2, …; name is its GLSL sampler name.

    fun input(name: String, texture: HasImageView) {
        require(Regex("[A-Za-z_][A-Za-z0-9_]*").matches(name) && name != StackSLFeedbackShader.STATE) {
            "an input needs a GLSL name other than '${StackSLFeedbackShader.STATE}', got '$name'"
        }
        require(name in inputs || inputs.size < 3) { "at most three inputs: sample1, sample2 and sample3" }
        inputs[name] = texture
    }
}

class StackSLFeedback internal constructor

A StackSL program running against its own previous frame — the StackTrinket renderer.

Each frame draws program(vec4(uv, hash, seconds)) over a whole float16 image while the image drawn the frame before is bound as sampler2D state (linear, repeating), then shows the new one. Every channel, alpha included, is clamped to 0..1 and kept: four independent memories per pixel. hash is a fresh per-pixel value every frame, uv runs 0..1 with y up, and seconds counts from the last reset at StackSLFeedbackSettings.timeScale.

The program is read from source every frame. A changed program that compiles replaces the running one without touching the state; one that does not keeps the last good picture and its errors show in lowering. Build one with Box.stackFeedback.

class StackSLFeedback internal constructor(
    val renderer: Renderer,
    val settings: StackSLFeedbackSettings,
    /** Where the program comes from; read every frame. */
    var source: () -> String,
    /** Told about every compile, good or bad. */
    private val onCompiled: (StackSLLowering) -> Unit,
) : Target {

val width: Int = settings.width.takeIf { it > 0 } ?: renderer.swapChain.swapChainExtent.width()

Width of the state in pixels.

val height: Int

Height of the state in pixels.

    val height: Int = settings.height.takeIf { it > 0 } ?: renderer.swapChain.swapChainExtent.height()

val target

The two images: one is drawn while the other is state.

    val target = PingPongRenderToTexture(renderer) {
        width = this@StackSLFeedback.width
        height = this@StackSLFeedback.height
        format = VK_FORMAT_R16G16B16A16_SFLOAT
        colorFormats = listOf(VK_FORMAT_R16G16B16A16_SFLOAT)
        clearColor = vec(0, 0, 0, 1)
        useDepth = false
        generateColorMipmaps = false
        debugName = "StackSLFeedback ${settings.name}"
    }

    private val sampler = TextureSampler(
        renderer, 1, false, VK_FILTER_LINEAR,
        addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT,
        addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT,
        addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT
    )

var lowering: StackSLLowering?

The last compile, good or bad.

    @Volatile
    var lowering: StackSLLowering? = null
        private set

var running: String?

The program the running pipeline was built from.

    @Volatile
    var running: String? = null
        private set

var frame: Long

Frames drawn since the last reset.

    var frame: Long = 0
        private set

var seconds: Double

Program time, in seconds, since the last reset.

    var seconds: Double = 0.0
        private set

Two frames of black before frame 0, one into each image, so frame 0 reads a cleared state.

    private var blackFrames = 2
    private var lastNanos = 0L
    private var sinceStep = 0.0
    private var module: ModulePointer? = null

One upright copy per input, made the first time the input is used, and kept: nothing frees a target.

    private val copies = linkedMapOf<String, InputCopy>()
    private var pictureCopy: PictureCopy? = null

val picture: HasImageView

The picture as Field's other textures keep one: top row first, 8-bit display values (what the window shows, before its display gamma). Hand it to anything that samples a texture — webrtcPublish(feedback.picture, "stacktrinket") publishes the program to the class. The state itself is stored bottom row first and in float, which suits prev but nothing else. Made the first time it is asked for, and updated on every step after that.

    val picture: HasImageView
        get() = (pictureCopy ?: PictureCopy().also { pictureCopy = it }).target
    private var tried: String? = null
    private var building = false

override fun toString(): String

    override fun toString(): String = "stackFeedback '${settings.name}', ${width}×$height, frame $frame"

fun reset()

Clear both images to black and start time and the frame count again from zero.

    fun reset() {
        blackFrames = 2
        frame = 0
        seconds = 0.0
        lastNanos = 0L
        sinceStep = 0.0
    }

fun show(): ModulePointer

Put the picture in the window, replacing whatever was shown under the same name.

    fun show(): ModulePointer {
        val gamma = settings.displayGamma.toFloat()
        // The state is stored with uv.y = 0 in row 0 (see StackSLFeedbackShader.VERTEX); the blit
        // reads rows from the top, so read it bottom-up to put y up in the window.
        val blit = target.blitToScreen(name = settings.name, viewport = settings.viewport, sourceRect = Rect(0.0, 1.0, 1.0, -1.0)) {
            this.gamma = vec(gamma, gamma, gamma, 1f)
        }
        // blitToScreen installs the ping-pong itself as the source; this host is what must run, so
        // that pausing, the clear and the program swap happen around its frames.
        renderer.renderList.preTargets["${settings.name}-source"] = this
        return blit
    }

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

    override fun render(command: CommandBuffer, index: Int) {
        val now = System.nanoTime()
        val dt = if (lastNanos == 0L) 0.0 else (now - lastNanos) / 1e9
        lastNanos = now
        follow()
        if (settings.paused || module == null) return
        // Step at most settings.fps times a second; frames in between keep showing the last step.
        sinceStep += dt
        val interval = if (settings.fps > 0) 1.0 / settings.fps else 0.0
        if (blackFrames == 0 && sinceStep < interval) return
        val stepDt = if (interval > 0) interval else dt
        sinceStep = if (interval > 0) (sinceStep - interval).coerceIn(0.0, interval) else 0.0
        pushedFrame = if (blackFrames > 0) -1 else frame
        settings.inputs.keys.forEach { copies[it]?.target?.render(command, index) }
        target.render(command, index)
        pictureCopy?.target?.render(command, index)
        if (blackFrames > 0) {
            blackFrames--
        } else {
            frame++
            seconds += stepDt * settings.timeScale
        }
    }

What this frame pushes: -1 while clearing, otherwise the frame number.

    private var pushedFrame: Long = -1

Compile a new program off the frame, on the main loop; the old one draws until it is ready.

source — 57 lines, from line 239
    private fun follow() {
        val program = source()
        val names = settings.inputs.keys.toList()
        // A new set of inputs is a new shader too: its samplers are part of the compiled program.
        val wanted = program + "\u0000" + names.joinToString(",")
        if (wanted == tried || building) return
        building = true
        inMain {
            try {
                rebuild(program, names)
            } finally {
                tried = wanted
                building = false
            }
        }
    }

    private fun rebuild(program: String, inputs: List<String>) {
        val compiled = StackSLCompiler.compile(program, StackSLFeedbackShader.compileOptions(inputs))
        if (!compiled.ok) {
            publish(StackSLLowering(program, compiled, glslError = null, running = running != null))
            return
        }
        var error: String? = null
        val fresh = Module(parameters(renderer) {
            +target
            name = "${settings.name}-program"
            vertex = StackSLFeedbackShader.VERTEX
            fragment = StackSLFeedbackShader.fragment(compiled.glsl, inputs)
            blend = false
            depthTest = false
            depthWrite = false
            errorHandler = { message, _ -> error = message }
            textureResolver = { tex ->
                when (tex.name) {
                    StackSLFeedbackShader.STATE -> fromViews(listOf(target.otherImageView), tex.name, sampler)
                    in inputs -> fromViews(listOf(copyOf(tex.name).target), tex.name, sampler)
                    else -> null
                }
            }
        }).pointer()
        if (!fresh.current.valid) {
            renderer.cleanup.add(Runnable { dispose(fresh) })
            publish(StackSLLowering(program, compiled, glslError = error ?: "the GPU refused the shader", running = running != null))
            return
        }
        fresh += "${settings.name}-fullscreen" to fullscreenTriangle(fresh).apply {
            push.bind("params") { vec(seconds.toFloat(), pushedFrame.toFloat(), width.toFloat(), height.toFloat()) }
        }
        val previous = module
        target += "program" to fresh
        module = fresh
        running = program
        // A frame in flight may still be using the old pipeline: retire it once those frames finish.
        previous?.let { old -> renderer.cleanup.add(Runnable { dispose(old) }) }
        publish(StackSLLowering(program, compiled, glslError = null, running = true))
    }

The upright copy of input name, made on first use.

    private fun copyOf(name: String): InputCopy = copies.getOrPut(name) { InputCopy(name) }

Input name copied into a float image the size of the state, stored the way the state is (row 0 is uv.y = 0). Field's textures — a stream, a camera, a render target — keep their top row first, so sampled as they are at uv they would read upside down next to state.

    private inner class InputCopy(val name: String) {
        val target = RenderToTexture(renderer) {
            width = this@StackSLFeedback.width
            height = this@StackSLFeedback.height
            format = VK_FORMAT_R16G16B16A16_SFLOAT
            colorFormats = listOf(VK_FORMAT_R16G16B16A16_SFLOAT)
            useDepth = false
            generateColorMipmaps = false
            debugName = "StackSLFeedback ${settings.name} input $name"
        }
        // Reads whatever settings.inputs holds under this name now, so a re-run can swap the texture.
        private val live = object : HasImageViewArray, PreparesSampledImages {
            override val imageViewArray: List<HasImageView>
                get() = listOfNotNull(settings.inputs[name])

            override fun prepareSampledImagesFor(command: CommandBuffer) {
                (settings.inputs[name] as? PreparesSampledImages)?.prepareSampledImagesFor(command)
            }
        }

        init {
            val copy = Module(parameters(renderer) {
                +target
                this.name = "${settings.name}-input-$name"
                vertex = StackSLFeedbackShader.VERTEX
                fragment = StackSLFeedbackShader.COPY_FRAGMENT
                blend = false
                depthTest = false
                depthWrite = false
                textureResolver = { tex -> if (tex.name == "source") fromViews(live, tex.name, sampler) else null }
            }).pointer()
            copy += "${settings.name}-input-$name-fullscreen" to fullscreenTriangle(copy).apply {
                push.bind("encode") { if (settings.inputs[name]?.let(::isSrgb) == true) 1f else 0f }
            }
            target += "copy" to copy
        }
    }

picture: the newest state, copied top row first into 8-bit display values.

source — 46 lines, from line 344
    private inner class PictureCopy {
        val target = RenderToTexture(renderer) {
            width = this@StackSLFeedback.width
            height = this@StackSLFeedback.height
            format = VK_FORMAT_R8G8B8A8_UNORM
            colorFormats = listOf(VK_FORMAT_R8G8B8A8_UNORM)
            useDepth = false
            generateColorMipmaps = false
            debugName = "StackSLFeedback ${settings.name} picture"
        }

        init {
            val copy = Module(parameters(renderer) {
                +target
                this.name = "${settings.name}-picture"
                // The usual fullscreen triangle puts uv.y = 1 in row 0: top row first.
                vertex = StackSLFeedbackShader.PICTURE_VERTEX
                fragment = StackSLFeedbackShader.PICTURE_FRAGMENT
                blend = false
                depthTest = false
                depthWrite = false
                textureResolver = { tex ->
                    if (tex.name == StackSLFeedbackShader.STATE) fromViews(listOf(this@StackSLFeedback.target.latestImageView), tex.name, sampler) else null
                }
            }).pointer()
            copy += "${settings.name}-picture-fullscreen" to fullscreenTriangle(copy)
            target += "copy" to copy
        }
    }

    private fun publish(l: StackSLLowering) {
        lowering = l
        onCompiled(l)
    }

    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()
        }
    }
}

object StackSLFeedbackShader

The shaders a StackSLFeedback draws with: a fullscreen triangle, and a fragment shader that hands the compiled program vec4(uv, hash, seconds) with the previous frame bound as STATE.

object StackSLFeedbackShader {

const val STATE = "state"

The sampler the previous frame is bound as, and the texture StackSL's sample words read.

const val PREFIX = "program"

The GLSL name of the compiled program.

val compileOptions: StackSLCompileOptions = compileOptions(emptyList())

How a feedback program is compiled: sampling STATE, with its numbers as dangling wires.

fun compileOptions(inputs: List<String>)

compileOptions with inputs as the textures sample1, sample2, … read.

    fun compileOptions(inputs: List<String>) = StackSLCompileOptions(
        prefix = PREFIX,
        sampleTextures = listOf(STATE) + inputs,
        declareSamplers = false,
        externalizeConstants = true,
    )

val PICTURE_VERTEX: String

The fullscreen triangle as Field draws one elsewhere: fragUV.y = 1 lands in row 0.

    val PICTURE_VERTEX: String = """
        #version 450
        layout(location = 0) out vec2 fragUV;

        void main() {
            vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
            gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
            fragUV = pos;
        }
    """.trimIndent()

val PICTURE_FRAGMENT: String

Reads the state at fragUV; drawn with PICTURE_VERTEX it comes out top row first.

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

        void main() {
            outColor = vec4(clamp(texture($STATE, fragUV).rgb, 0.0, 1.0), 1.0);
        }
    """.trimIndent()

val COPY_FRAGMENT: String

Copies an input texture into an image stored like the state: drawn with VERTEX, it reads source with v flipped, because Field's textures keep their top row first. With encode set (an sRGB source, such as a WebRTC stream's browser paints) it re-encodes what sampling decoded, so the program reads the numbers the picture holds — the values a browser ShaderTrinket's iChannel gives, and the space state is in. Without it a stream reads darker, by the gamma.

    val COPY_FRAGMENT: String = """
        #version 450
        layout(location = 0) in vec2 fragUV;
        layout(location = 0) out vec4 outColor;
        layout(set = 0, binding = 0) uniform sampler2D source;
        layout(push_constant) uniform Push { float encode; } pc;

        vec3 srgbEncode(vec3 c) {
            c = clamp(c, 0.0, 1.0);
            return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
        }

        void main() {
            vec4 c = texture(source, vec2(fragUV.x, 1.0 - fragUV.y));
            if (pc.encode > 0.5) c.rgb = srgbEncode(c.rgb);
            outColor = c;
        }
    """.trimIndent()

val VERTEX: String

The fullscreen-triangle vertex shader: fragUV runs 0..1 with y up, and the image is written so that image row 0 holds uv.y = 0. That is the row texture(state, vec2(u, 0)) reads, so prev reads back this pixel. Field's render targets put clip y = +1 in row 0, so the usual gl_Position = pos * 2 - 1 would store the picture upside down relative to its own sampling and every feedback step would flip it; StackSLFeedback.show flips it back for the window.

    val VERTEX: String = """
        #version 450
        layout(location = 0) out vec2 fragUV;

        void main() {
            vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
            gl_Position = vec4(pos.x * 2.0 - 1.0, 1.0 - pos.y * 2.0, 0.0, 1.0);
            fragUV = pos;
        }
    """.trimIndent()

fun fragment(lowered: String, inputs: List<String> = emptyList()): String

The fragment shader around lowered, the GLSL the compiler made for a program. params is (seconds, frame, width, height); a negative frame draws black, which is how the state is cleared. The hash is StackTrinket's, so a program's grain looks as it does there.

    fun fragment(lowered: String, inputs: List<String> = emptyList()): String = """
        |#version 450
        |layout(location = 0) in vec2 fragUV;
        |layout(location = 0) out vec4 outColor;
        |
        |layout(set = 0, binding = 0) uniform sampler2D $STATE;
        |${inputs.mapIndexed { i, name -> "layout(set = 0, binding = ${i + 1}) uniform sampler2D $name;" }.joinToString("\n|")}
        |layout(push_constant) uniform Push { vec4 params; } pc;
        |
        |${lowered.trim()}
        |
        |void main() {
        |    if (pc.params.y < 0.0) { outColor = vec4(0.0, 0.0, 0.0, 1.0); return; }
        |    vec2 uv = fragUV;
        |    vec2 resolution = max(pc.params.zw, vec2(1.0));
        |    float hash = fract(sin(dot(uv * resolution + pc.params.y, vec2(12.9898, 78.233))) * 43758.5453);
        |    vec4 c = $PREFIX(vec4(uv, hash, pc.params.x));
        |    if (any(isnan(c)) || any(isinf(c))) c = vec4(0.0);
        |    outColor = clamp(c, 0.0, 1.0);
        |}
    """.trimMargin()
}

class StackSLLowering

What one compile of a feedback program came to, as StackTrinket's lowering panel shows it: whether it is drawing, the stack depth with the leftover values under the returned top, the helpers it pulled in, how many dangling wires (tunable numbers) it has, each diagnostic at its line and column; glslHtml is the lowered GLSL line by line. It is a ToHTML, so display("lowering", it) draws the summary.

class StackSLLowering(
    val source: String,
    val result: StackSLCompileResult,
    /** Why the GPU refused GLSL the compiler accepted, or null. */
    val glslError: String?,
    /** Whether a program is drawing — this one if it compiled, otherwise the last good one. */
    val running: Boolean,
) : ToHTML {

val ok: Boolean get() = result.ok && glslError == null

The program compiled and the GPU took it.

val depth: Int get() = root?.finalStackDepth ?: 0

How many values the program leaves on the stack; the top is what is drawn.

    private val root: StackSLFunctionAnalysis?
        get() = result.analysis.functions.firstOrNull { it.name == "<root>" } ?: result.analysis.functions.lastOrNull()

val guidance: String

StackTrinket's one-line reading of depth.

    val guidance: String
        get() {
            val leftovers = (depth - 1).coerceAtLeast(0)
            return "stack depth $depth after compile; $leftovers leftover ${if (leftovers == 1) "value" else "values"} under the returned top."
        }

val numberedGlsl: String

The lowered GLSL with three-digit line numbers, as the panel shows it.

    val numberedGlsl: String
        get() = result.glsl.trimEnd().lines().mapIndexed { i, line -> "${(i + 1).toString().padStart(3)} | $line" }.joinToString("\n")

override fun toHTML(): String

    override fun toHTML(): String = buildString {
        val status = when {
            ok -> "<span style='color:#9c9'>drawing</span>"
            running -> "<span style='color:#e96'>not compiled — the last good program is still drawing</span>"
            else -> "<span style='color:#e96'>not compiled</span>"
        }
        // Fixed width: an output panel sizes itself to its content.
        append("<div style='width:500px;max-width:500px;overflow:hidden;font-family:var(--field-font-code, var(--field-font, monospace));font-size:11px;line-height:1.45;color:#ccc'>")
        append("<div>$status · ${esc(guidance)}</div>")
        append("<div style='color:#888'>helpers ${esc(result.analysis.requiredHelpers.sorted().joinToString(", ").ifEmpty { "none" })}")
        append(" · wires ${result.analysis.danglingWires.size} · stack $depth/${(depth - 1).coerceAtLeast(0)}</div>")
        result.diagnostics.forEach { d ->
            val colour = if (d.severity == StackSLSeverity.ERROR) "#e96" else "#db6"
            val where = d.location?.let { "${it.line}:${it.column} " } ?: ""
            val near = d.token?.let { " near '${esc(it)}'" } ?: ""
            append("<div style='color:$colour'>${d.severity.name.lowercase()} $where${esc(d.message)}$near</div>")
        }
        glslError?.let { append("<div style='color:#e96'>GPU: ${esc(it)}</div>") }
        append("<div style='color:#888'>lowered GLSL: ${result.glsl.trimEnd().lines().size} lines, in the <i>glsl</i> panel</div>")
        append("</div>")
    }

val glslHtml: String

The numbered GLSL as HTML, scrolling inside a fixed 500 by 300 box. It is a panel of its own (STACKSL_GLSL_CHANNEL): a panel is sized to all of its content, folded or not, so inside the summary it held the summary open at full height.

    val glslHtml: String
        get() = "<pre style='margin:0;width:500px;height:300px;overflow:auto;white-space:pre;font-size:11px;line-height:1.4;color:#aaa'>${esc(numberedGlsl)}</pre>"

    private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;")
}

private val SRGB_FORMATS = setOf(
    org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_SRGB,
    org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_SRGB,
    org.lwjgl.vulkan.VK10.VK_FORMAT_A8B8G8R8_SRGB_PACK32,
)

Whether sampling texture decodes sRGB, by its view's format.

private fun isSrgb(texture: HasImageView): Boolean =
    runCatching { texture.imageView.imageViewData.format in SRGB_FORMATS }.getOrDefault(false)

const val STACKSL_LOWERING_CHANNEL = "lowering"

The output channel Box.stackFeedback shows its StackSLLowering in.

const val STACKSL_GLSL_CHANNEL = "glsl"

The output channel Box.stackFeedback shows the lowered GLSL in; it starts closed.

private val feedbackHosts = WeakHashMap<Box, StackSLFeedback>()

fun Box.stackFeedback

Draw this box's StackSL fullscreen with its previous frame as state: the StackTrinket renderer in one call. The box needs a renderer above it, and its program in the stackSL property.

// stackSL:  prev 0.965 *  p length 0.32 0.05 pulse  0.08t palette  *  +
stackFeedback()

The program is followed while it runs: edit stackSL and the new program takes over on the next frame that it compiles, without clearing the picture. After every compile the box's lowering output shows the stack depth, helpers, wires and diagnostics, and its glsl output (closed until opened) the lowered GLSL.

Calling this again — re-running the box — reuses the same images (nothing frees a render target, so a fresh pair every run would leak) and, with reset, clears them, so a run starts from black. Every feedback box shows under one name unless StackSLFeedbackSettings.name says otherwise, so launching one replaces the last. configure sets StackSLFeedbackSettings; the size settings only take effect the first time, or when they change.

fun Box.stackFeedback(
    renderer: Renderer = fromContext(),
    reset: Boolean = true,
    configure: StackSLFeedbackSettings.() -> Unit = {},
): StackSLFeedback {
    val box = this
    val settings = StackSLFeedbackSettings().apply(configure)
    val showLowering: (StackSLLowering) -> Unit = { l ->
        if (settings.showLowering) {
            box.display(STACKSL_LOWERING_CHANNEL, l)
            box.displayOpen(STACKSL_LOWERING_CHANNEL)
            box.display(STACKSL_GLSL_CHANNEL, l.glslHtml)
        }
    }
    val host = synchronized(feedbackHosts) {
        val existing = feedbackHosts[box]
        val reusable = existing != null && existing.renderer === renderer &&
            (settings.width <= 0 || settings.width == existing.width) &&
            (settings.height <= 0 || settings.height == existing.height) &&
            existing.settings.name == settings.name
        if (reusable) {
            existing.apply {
                this.settings.inputs.clear()
                this.settings.inputs.putAll(settings.inputs)
                this.settings.fps = settings.fps
                this.settings.timeScale = settings.timeScale
                this.settings.paused = settings.paused
                this.settings.displayGamma = settings.displayGamma
                this.settings.showLowering = settings.showLowering
                this.settings.viewport = settings.viewport
            }
        } else {
            StackSLFeedback(renderer, settings, { box.stackSL ?: "" }, showLowering).also { feedbackHosts[box] = it }
        }
    }
    if (reset) host.reset()
    host.lowering?.let(showLowering)
    host.show()
    return host
}