Skip to content

RectSLStudyGpuSmoke.kt

src/jvmMain/kotlin/afield/examples/rectsl/RectSLStudyGpuSmoke.kt · 132 lines

package afield.examples.rectsl

import afield.study.evidence.StudyEvidence
import afield.lang.rectsl.study.CpuRectSLRasterizer
import afield.lang.rectsl.study.RectSLRasterizer
import afield.lang.rectsl.study.RectSLStudy
import afield.study.source.renderAll
import afield.lang.stacksl.cpu.CpuRaster
import afield.lang.stacksl.study.StackSLStudyShader
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.math.abs

FIELD_TEST id: rectsl.study.gpu-agreement runner: backdoor-box graphicsContext: true tags: [rectsl, study, gpu, graphics] timeoutSec: 180 execution: launch waitSec: 40.0 expect: allowMissingOutput: true image: path: /tmp/rectsl-study-gpu-agreement.png

private val GPU_AGREEMENT_SNIPPET = """
import afield.graphics.vulkan.Renderer
import afield.document.context.fromContext
import afield.examples.rectsl.RectSLStudyGpuSmoke
import afield.lang.rectsl.study.RectSLGpuRasterizer
import java.util.concurrent.CompletableFuture

// The GPU study renderer waits on frames the main thread draws, so it runs off that thread.
val renderer: Renderer = fromContext()
val sheet = java.io.File("/tmp/rectsl-study-gpu-agreement.png").also { it.delete() }
val work = CompletableFuture.supplyAsync { RectSLStudyGpuSmoke.run(RectSLGpuRasterizer({ renderer }), sheet) }
loop { if (work.isDone) println(work.get()) }
""".trimIndent()

object RectSLStudyGpuSmoke

Proof that a RectSL study's GPU frames are the scene's frames: the same scenes drawn by afield.lang.rectsl.study.RectSLGpuRasterizer on a live renderer and by the CPU reference agree, and a few pixels are pinned outright — so a flip, a colour written without its sRGB decode, a target that does not blend, or a stale time fails here rather than in a picture a model is shown.

The two rasterize rectangle edges by different rules, so edges may differ by a pixel: agreement is "almost every pixel within two levels", and the pinned pixels sit well inside their rectangles. run writes the evidence sheet — GPU above, CPU below — only when every check passes.

object RectSLStudyGpuSmoke {
    private const val HEADER = "->wall 4 2 meters\n->duration 4\n->fps 20\n->background 0x000000\n"

val SCENES: List<Triple<String, String, List<Pair<List<Double>, Int>>>>

Scenes, each with pixels (wall x, y, time) whose shown colour is pinned.

    val SCENES: List<Triple<String, String, List<Pair<List<Double>, Int>>>> = listOf(
        Triple("up is up", HEADER + "\"sun\" rect  1 0.5 ->xy  0.6 ->square  0xff0000 solid ->fill ;",
            listOf(listOf(1.0, 0.5, 0.0) to 0xff0000, listOf(1.0, -0.5, 0.0) to 0x000000, listOf(-1.0, 0.5, 0.0) to 0x000000)),
        Triple("colours as typed", HEADER + "\"grey\" rect  0 0 ->xy  3 1.5 ->wh  0x808080 solid ->fill ;",
            listOf(listOf(0.0, 0.0, 0.0) to 0x808080)),
        // Blending is on the typed values, as in a design tool and the RectSL gallery: 3% of orange over a
        // dark sky is a faint wash (#0e182f), not the maroon box linear-light blending would make of it.
        Triple("faint is faint", "->wall 4 2 meters\n->duration 4\n->fps 20\n->background 0x07152f\n\"glow\" rect  0 0 ->xy  2 1 ->wh  0xff882d solid ->fill  0.03 ->alpha ;",
            listOf(listOf(0.0, 0.0, 0.0) to 0x0e182f, listOf(1.5, 0.8, 0.0) to 0x07152f)),
        Triple("blending", HEADER + "\"a\" rect  -0.4 0 ->xy  1.2 ->square  rgba 1 0 0 0.5 solid ->fill ;\n\"b\" rect  0.4 0 ->xy  1.2 ->square  rgba 0 0 1 0.5 solid ->fill ;",
            emptyList()),
        Triple("gradient and rotation", HEADER + "\"g\" rect  0 0 ->xy  3 1 ->wh  0xff2d55 0x2d7bff  -1.5 0  1.5 0  linear ->fill  0.3 ->rot ;",
            emptyList()),
        Triple("material", HEADER + "material ramp '(\n  uv x 0.5 * [1,1,1,0] * [0,0,0,1] +\n)\n\"t\" rect  0 0 ->xy  3.6 1.8 ->wh  0xffffff solid ->fill  ramp ->material ;",
            emptyList()),
        Triple("time", HEADER + "\"m\" rect  0 0 ->xy  0.5 ->square  0x33ff66 solid ->fill  '( 0=-1.5  4=1.5 ) linear ->x ;",
            listOf(listOf(-0.75, 0.0, 1.0) to 0x33ff66, listOf(0.75, 0.0, 3.0) to 0x33ff66, listOf(-0.75, 0.0, 3.0) to 0x000000))
    )

    private const val W = 128
    private const val H = 64
    private val TIMES = listOf(1.0, 3.0)

    private fun shown(raster: CpuRaster, x: Double, y: Double): Int {
        val col = ((x + 2) / 4 * raster.width).toInt().coerceIn(0, raster.width - 1)
        val row = ((1 - y) / 2 * raster.height).toInt().coerceIn(0, raster.height - 1)
        return StudyEvidence.presented(raster).toArgb()[row * raster.width + col] and 0xffffff
    }

    private fun levels(a: Int, b: Int) = maxOf(abs((a shr 16 and 0xff) - (b shr 16 and 0xff)), abs((a shr 8 and 0xff) - (b shr 8 and 0xff)), abs((a and 0xff) - (b and 0xff)))

fun run(rasterizer: RectSLRasterizer, sheet: File): String

Compare, pin, time and — if everything holds — write sheet. Returns a one-line summary; throws on a failure.

body — 43 lines, from line 89
    fun run(rasterizer: RectSLRasterizer, sheet: File): String {
        val rows = mutableListOf<Pair<IntArray, IntArray>>()
        val lines = SCENES.map { (name, source, pins) ->
            val drawable = RectSLStudy.drawable(source)
            val times = (TIMES + pins.map { it.first[2] }).distinct()
            val gpu = rasterizer.renderAll(drawable, W, H, times)
            check(rasterizer.description.startsWith("GPU")) { "expected a GPU render, got: ${rasterizer.description}" }
            val cpu = CpuRectSLRasterizer.renderAll(drawable, W, H, times)
            pins.forEach { (at, expected) ->
                val i = times.indexOf(at[2])
                val g = shown(gpu[i], at[0], at[1]); val c = shown(cpu[i], at[0], at[1])
                check(levels(g, expected) <= 1) { "$name: the GPU shows #%06x at (${at[0]}, ${at[1]}) t=${at[2]}, expected #%06x".format(g, expected) }
                check(levels(c, expected) <= 1) { "$name: the CPU reference shows #%06x at (${at[0]}, ${at[1]}) t=${at[2]}, expected #%06x".format(c, expected) }
            }
            val worst = times.indices.map { i ->
                val g = StudyEvidence.presented(gpu[i]).toArgb(); val c = StudyEvidence.presented(cpu[i]).toArgb()
                if (times[i] in TIMES) rows += g to c
                val within = g.indices.count { levels(g[it], c[it]) <= 2 }.toDouble() / g.size
                check(within > 0.97) { "$name at t=${times[i]}: only ${"%.1f".format(within * 100)}% of pixels agree to two levels" }
                within
            }.min()
            "$name ${"%.1f".format(worst * 100)}%"
        }
        val heavy = RectSLStudy.drawable(HEADER + "\"p\" rect 0.1 ->square  '( 0=-1.9  4=1.9 ) ->x  60 '( fc 0.9 * ->y  i 0.05 * ->delay-all-time  \"turbo\" f colormap solid ->fill ) array ;")
        val start = System.nanoTime()
        rasterizer.renderAll(heavy, 640, 320, List(24) { it / 6.0 })
        val seconds = (System.nanoTime() - start) / 1e9
        check(seconds < 10.0) { "24 frames of 640x320 took ${"%.1f".format(seconds)}s on the GPU" }
        writeSheet(rows, sheet)
        return "rectsl study GPU agreement: ${SCENES.size} scenes agree with the CPU reference and hold their pinned pixels (${lines.joinToString("; ")}); " +
            "24 frames of a 60-rectangle scene at 640x320 in ${"%.2f".format(seconds)}s on ${rasterizer.description}"
    }

    private fun writeSheet(rows: List<Pair<IntArray, IntArray>>, sheet: File) {
        val image = BufferedImage(rows.size * (W + 4), 2 * H + 4, BufferedImage.TYPE_INT_RGB)
        rows.forEachIndexed { i, (gpu, cpu) ->
            image.setRGB(i * (W + 4), 0, W, H, gpu, 0, W)
            image.setRGB(i * (W + 4), H + 4, W, H, cpu, 0, W)
        }
        sheet.absoluteFile.parentFile.mkdirs()
        ImageIO.write(image, "png", sheet)
    }
}