Skip to content

StackSLCpu.kt

src/jvmMain/kotlin/afield/lang/stacksl/cpu/StackSLCpu.kt · 358 lines

package afield.lang.stacksl.cpu

import afield.lang.stacksl.StackSLCompileOptions
import afield.lang.stacksl.StackSLCompileResult
import afield.lang.stacksl.StackSLCompiler
import afield.lang.stacksl.StackSLSeverity
import afield.media.image.util.PngEncoder
import java.util.stream.IntStream

StackSL's CPU backend: the same GLSL a GPU would run, evaluated one pixel at a time on the JVM, so a StackSL program can be rendered, unit-tested, diffed and documented with no graphics context at all.

StackSLCpu is the entry point and CpuRaster is what comes back: RGBA floats with row 0 at the top, quantised the way a VK_FORMAT_R8G8B8A8_UNORM attachment does, and encodable straight to PNG. StackSLCpu.renderStill is the one-liner a box wants.

What the backend is exact about is the program. StackSLCpu.renderFragment takes a complete fragment shader as text and knows nothing about StackSL; the StackSL half compiles a program with StackSLCompiler and substitutes the result into a {{ stack.auto.<slot> }} template, which is the same tag the production expansion path in afield.graphics.shader.shaders fills. Both paths therefore receive the same string, and nothing about a word's behaviour is restated here: change what the emitter makes of fbm and this backend changes with it on the next call, with no edit. Tests pin the two copies that make that true: StackSLCpuExpansionTest compares the compile options against the production path's, and StackSLCpuStillTemplateTest compares the gallery's fragment template against the committed one in the gallery workspace.

It is a reference, not a renderer. It is an interpreter, so it is orders of magnitude short of a GPU, and StackSLCpu.renderStill states a cost for one program at one size. It is not bit-exact either: GLSL leaves sin, cos, exp, log, pow and atan implementation-defined, so a program that reaches one of them can only be compared under a tolerance. GlslValue has that argument in full, including the case where operations that are exact still diverge visibly.

class CpuRaster(val width: Int, val height: Int, val rgba: FloatArray)

A rendered raster: RGBA floats in row-major order, row 0 at the top, matching both a PNG and the framebuffer convention the gallery harness reads back under (see StackSLCpu.FRAG_UV_ORIGIN_IS_TOP_LEFT).

init

    init {
        require(rgba.size == width * height * 4) { "raster is ${rgba.size} floats, expected ${width * height * 4}" }
    }

fun at(x: Int, y: Int, channel: Int): Float = rgba[((y * width) + x) * 4 + channel]

Component channel (0..3) of the pixel at x, y.

fun toArgb(): IntArray

Quantise to 8-bit ARGB the way a VK_FORMAT_R8G8B8A8_UNORM attachment does: clamp to [0,1] then round to nearest, which is what the Vulkan spec requires for a float to unorm conversion. Truncating instead would bias every channel down by half a level and turn an exact match into a one-level miss across the whole image.

    fun toArgb(): IntArray = IntArray(width * height) { i ->
        val r = quantise(rgba[i * 4])
        val g = quantise(rgba[i * 4 + 1])
        val b = quantise(rgba[i * 4 + 2])
        val a = quantise(rgba[i * 4 + 3])
        (a shl 24) or (r shl 16) or (g shl 8) or b
    }

fun toSrgbArgb(): IntArray

Quantise to 8-bit ARGB the way a VK_FORMAT_R8G8B8A8_SRGB attachment does — the window swapchain's encoding, and the StackSL gallery's: clamp to [0,1], put red, green and blue through the sRGB transfer function (IEC 61966-2-1), leave alpha linear, then round. This is the picture a person sees; toArgb is the value the shader wrote, which is markedly darker (0.5 is 128 there and 188 here).

    fun toSrgbArgb(): IntArray = IntArray(width * height) { i ->
        val r = quantiseSrgb(rgba[i * 4])
        val g = quantiseSrgb(rgba[i * 4 + 1])
        val b = quantiseSrgb(rgba[i * 4 + 2])
        val a = quantise(rgba[i * 4 + 3])
        (a shl 24) or (r shl 16) or (g shl 8) or b
    }

fun toPngBytes(): ByteArray = PngEncoder.encode(toArgb(), width, height)

    private fun quantise(v: Float): Int = Math.round(v.coerceIn(0f, 1f) * 255f)

    private fun quantiseSrgb(v: Float): Int = Math.round(linearToSrgb(v) * 255.0).toInt()

companion object

    companion object {

fun linearToSrgb(linear: Float): Double

The sRGB transfer function (IEC 61966-2-1) of linear clamped to [0,1], unrounded. toSrgbArgb rounds it; a comparison that needs to know how close a value came to a rounding tie reads it directly.

        fun linearToSrgb(linear: Float): Double {
            val l = linear.coerceIn(0f, 1f).toDouble()
            return if (l <= 0.0031308) l * 12.92 else 1.055 * Math.pow(l, 1.0 / 2.4) - 0.055
        }
    }
}

object StackSLCpu

Render StackSL on the CPU.

The entry points come in two layers, and the layering is the point:

  • renderFragment takes a complete GLSL fragment shader as text and runs its main() once per pixel. It knows nothing about StackSL.
  • expandStackAuto and renderProgram add the StackSL half: compile a program with StackSLCompiler, substitute it into a {{ stack.auto.<slot> }} template, and hand the result to renderFragment.

So the CPU path and the GPU path receive the same string. Nothing about a word's behaviour is restated here; if the emitter changes what fbm compiles to, this backend changes with it on the next call, with no edit.

object StackSLCpu {

const val FRAG_UV_ORIGIN_IS_TOP_LEFT = false

Which framebuffer row fragUV.y == 0 lands on, for the gallery harness's fullscreen triangle.

The vertex shader is gl_Position = vec4(pos * 2.0 - 1.0, 0, 1); fragUV = pos; with pos in {0, 2}. It is tempting to stop at "in Vulkan, clip-space y = -1 is the top of the framebuffer" and conclude fragUV.y = 0 is the top row — that is what this constant said until 2026-08-30, and it is wrong. Every Field 4 render path also applies the negative-height viewport Y-flip (Renderer.setViewportAndScissor, RenderTarget.setViewportAndScissor, RenderToTexture.setViewportAndScissorArray), which inverts that: clip y = +1 maps to image row 0. So fragUV.y = 1 is the top row and fragUV.y grows upwards as a PNG's rows run downwards. See f44227393, which established the same for the readback and fixed TexturePreviewCache.capture.

This was not caught earlier because the oracle was wrong in the same direction: the committed gallery PNGs this backend is measured against were themselves inverted, so a flipped CPU raster agreed with them. Regenerating those figures is what surfaced it.

renderFragment reads this, so the convention is stated once and honoured rather than being an off-by-a-flip that only shows up as an upside-down image in one of 31 comparisons.

fun fragUvAt(x: Int, y: Int, width: Int, height: Int): GlslValue

fragUV for pixel (x, y) of a width by height raster, at the pixel centre — which is where a rasteriser evaluates a fullscreen triangle's interpolants.

Stated once and read by every render path, so a second one cannot quietly pick a different flip. That this flip was wrong here until 2026-08-30 while agreeing with an oracle that was wrong the same way (FRAG_UV_ORIGIN_IS_TOP_LEFT has the story) is the reason it is a function rather than three lines repeated per caller.

    fun fragUvAt(x: Int, y: Int, width: Int, height: Int): GlslValue {
        val vTopDown = (y + 0.5f) / height
        val v = if (FRAG_UV_ORIGIN_IS_TOP_LEFT) vTopDown else 1.0f - vTopDown
        return GlslValue.vec2((x + 0.5f) / width, v)
    }

fun stackAutoOptions(slot: String, sampleTexture: String = "source"): StackSLCompileOptions

The compile options a {{ stack.auto.<slot> }} expansion uses.

These are a deliberate, literal copy of the ones in afield.graphics.shader.shaders.compileStackAutoShaderFunctionReporting, which is the production expansion path. They cannot be shared without this package depending on afield.graphics.shader.shaders, and that file drags in the whole Field 4 graphics stack — which would defeat the purpose of a backend that runs with no graphics context. So the two copies are pinned against each other by StackSLCpuExpansionTest, which reads the option list out of shaders.kt's source text and compares it against this one. A blunt instrument — it only sees a literal argument list and breaks on reformatting — but it catches the one failure that is otherwise silent: an option added or changed on the production side, leaving this backend compiling programs under different rules from the shader it claims to mirror. (Red-first checked: flipping tolerant here turns that test red.)

    fun stackAutoOptions(slot: String, sampleTexture: String = "source"): StackSLCompileOptions =
        StackSLCompileOptions(
            prefix = slot,
            entrypoint = "main",
            useEntrypointAsRoot = false,
            tolerant = false,
            includeHeader = false,
            declareSamplers = false,
            externalizeConstants = false,
            sampleTextures = listOf(sampleTexture)
        )

fun compile(program: String, slot: String = "program", sampleTexture: String = "source"): StackSLCompileResult

Compile program exactly as a stack.auto slot named slot would.

    fun compile(program: String, slot: String = "program", sampleTexture: String = "source"): StackSLCompileResult =
        StackSLCompiler.compile(program, stackAutoOptions(slot, sampleTexture))

fun expandStackAuto

Substitute program's compiled GLSL into template at {{ stack.auto.<slot> }}.

Throws with the compiler's own error text if the program does not compile, which is what the production path does too — a shader built around a broken program is not a thing worth rendering.

    fun expandStackAuto(
        template: String,
        program: String,
        slot: String = "program",
        sampleTexture: String = "source"
    ): String {
        val result = compile(program, slot, sampleTexture)
        if (!result.ok) {
            val errors = result.diagnostics.filter { it.severity == StackSLSeverity.ERROR }
            throw IllegalArgumentException(
                "StackSL program does not compile:\n" + errors.joinToString("\n") { it.toString() }
            )
        }
        return substituteSlot(template, result.glsl, slot)
    }

fun substituteSlot(template: String, glsl: String, slot: String = "program"): String

Put glsl into template at {{ stack.auto.<slot> }}.

Separate from expandStackAuto because a wired program's GLSL comes out of StackSLWires.lift already compiled and lowered, and substituting it must not go back through the compiler with different options.

    fun substituteSlot(template: String, glsl: String, slot: String = "program"): String {
        val tag = Regex("""\{\{\s*stack\.auto\.${Regex.escape(slot)}\s*}}""")
        if (!tag.containsMatchIn(template)) {
            throw IllegalArgumentException("template has no {{ stack.auto.$slot }} slot")
        }
        // The lambda overload of `replace` treats its result literally, so the emitted GLSL goes
        // in byte for byte; the `String` overload would interpret `$` and `\` inside it.
        return tag.replace(template) { glsl }
    }

fun renderFragment

Run a whole fragment shader over a width by height raster.

uniforms are set once; perPixel can add or override globals for each pixel and is given the pixel's integer coordinates. fragUV is supplied automatically at pixel centres — (x + 0.5) / width, (y + 0.5) / height — which is where a rasteriser evaluates a fullscreen triangle's interpolants, and y grows downwards (FRAG_UV_ORIGIN_IS_TOP_LEFT).

Rows are rendered in parallel with one GlslInvocation each, because an invocation holds mutable frames and globals and is emphatically not thread-safe. The output is a plain array indexed by pixel, so there is no contention.

@param outName the out variable to read the pixel from; the harness templates write outColor @param onDiscard what to store for a pixel whose shader executed discard

    fun renderFragment(
        source: String,
        width: Int,
        height: Int,
        uniforms: Map<String, GlslValue> = emptyMap(),
        samplers: GlslSamplers = GlslSamplers.REFUSE,
        outName: String = "outColor",
        fragUvName: String = "fragUV",
        parallel: Boolean = true,
        perPixel: ((x: Int, y: Int, invocation: GlslInvocation) -> Unit)? = null,
        onDiscard: FloatArray = floatArrayOf(0f, 0f, 0f, 0f)
    ): CpuRaster {
        val shader = parseGlslShader(source)
        require("main" in shader.functionNames) { "fragment shader has no main()" }
        val rgba = FloatArray(width * height * 4)

        val rows = IntStream.range(0, height)
        val stream = if (parallel) rows.parallel() else rows
        stream.forEach { y ->
            val invocation = shader.newInvocation(uniforms, samplers)
            for (x in 0 until width) {
                // Honour the one stated convention; see [fragUvAt].
                invocation.setGlobal(fragUvName, fragUvAt(x, y, width, height))
                perPixel?.invoke(x, y, invocation)
                val ok = invocation.runMain()
                val base = (y * width + x) * 4
                if (!ok) {
                    onDiscard.copyInto(rgba, base, 0, 4)
                } else {
                    val out = invocation.global(outName)
                        ?: throw GlslEvalError("shader never wrote '$outName'")
                    for (ch in 0 until minOf(4, out.c.size)) rgba[base + ch] = out.c[ch]
                }
            }
        }
        return CpuRaster(width, height, rgba)
    }

fun stillTemplate(time: Float = 1.75f): String

The still template the docs gallery renders through, with time baked into input.w.

This is a duplicate of StackSLGalleryHarness.fragmentTemplate, which is private and lives in a file that drags in the whole Field 4 graphics stack — so it cannot be shared with a backend whose point is to need no graphics context. What keeps the copy honest is not discipline: StackSLCpuStillTemplateTest asserts this is byte-identical to the committed definitions_fragment_.glsl in the gallery workspace, which is what the GPU actually consumed. If the harness changes its template and the workspace is regenerated, that test goes red here.

gallery_hash12 is part of it: the harness feeds a per-pixel hash into input.z, so a program that reads z gets noise rather than a constant.

    fun stillTemplate(time: Float = 1.75f): String = """
        #version 450
        layout(location = 0) in vec2 fragUV;
        layout(location = 0) out vec4 outColor;

        float gallery_hash12(vec2 p) {
            vec3 p3 = fract(vec3(p.xyx) * 0.1031);
            p3 += dot(p3, p3.yzx + 33.33);
            return fract((p3.x + p3.y) * p3.z);
        }

        {{ stack.auto.program }}

        void main() {
            vec2 uv = fragUV;
            vec4 stackInput = vec4(uv, gallery_hash12(uv), ${"%.4f".format(time)});
            vec3 c = program(stackInput).rgb;
            if (any(isnan(c)) || any(isinf(c))) c = vec3(0.0);
            outColor = vec4(clamp(c, 0.0, 1.0), 1.0);
        }
    """.trimIndent()

fun renderStill(program: String, dim: Int = 256, time: Float = 1.75f, parallel: Boolean = true): CpuRaster

Render a StackSL program on the CPU, at dim square, exactly as the docs gallery renders it.

The one-liner a box wants: display(StackSLCpu.renderStill("uv 6 * fbm palette saturate").toPngBytes()) puts the picture in a box output with no graphics context, no window and no AWT.

Cost, so nobody is surprised: this is an interpreter, and 512x512 of a five-octave fbm takes single-digit seconds across ten cores — 5.9 s measured. 256 is a good size for a box.

Per core that is 1.3–1.6 x 10^4 pixel-shader invocations per second, and the aggregate on a ten-core machine is 4.4 x 10^4/s, which is 3.3x the single-core figure rather than 10x. This KDoc said 10^5/core until 2026-09-16 and was wrong by about 7x; the two halves of the claim were never consistent, and the wall-clock half was the true one. StackSLCpuThroughputMeasurements states the method and is where the numbers come from.

    fun renderStill(program: String, dim: Int = 256, time: Float = 1.75f, parallel: Boolean = true): CpuRaster =
        renderProgram(program, stillTemplate(time), dim, dim, parallel = parallel)

fun renderProgram

The one-call path: compile program, substitute it into template, render it.

pushParams fills a layout(push_constant) uniform Push { vec4 params; } pc; block if the template has one, which is how the gallery's animated template gets its time.

    fun renderProgram(
        program: String,
        template: String,
        width: Int,
        height: Int,
        slot: String = "program",
        sampleTexture: String = "source",
        pushParams: FloatArray? = null,
        samplers: GlslSamplers = GlslSamplers.REFUSE,
        parallel: Boolean = true
    ): CpuRaster {
        val expanded = expandStackAuto(template, program, slot, sampleTexture)
        val uniforms = buildMap {
            pushParams?.let {
                require(it.size == 4) { "pushParams must be 4 floats" }
                put("pc.params", GlslValue(GlslType.VEC4, it.copyOf()))
            }
        }
        return renderFragment(expanded, width, height, uniforms, samplers, parallel = parallel)
    }
}