Skip to content

StackSLStudyGpuSmoke.kt

src/jvmMain/kotlin/afield/examples/stacksl/StackSLStudyGpuSmoke.kt · 136 lines

package afield.examples.stacksl

import afield.study.evidence.StudyEvidence
import afield.lang.stacksl.cpu.AgreementTier
import afield.lang.stacksl.cpu.GalleryAgreement
import afield.lang.stacksl.cpu.StackSLCpu
import afield.lang.stacksl.cpu.StackSLCpuGallery
import afield.lang.stacksl.study.CpuStackSLRasterizer
import afield.lang.stacksl.study.StackSLRasterizer
import afield.lang.stacksl.study.StackSLStudyShader
import afield.lang.stacksl.study.renderAll
import java.io.File
import javax.imageio.ImageIO
import java.awt.image.BufferedImage

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

private val GPU_AGREEMENT_SNIPPET = """
import afield.graphics.vulkan.Renderer
import afield.document.context.fromContext
import afield.examples.stacksl.StackSLStudyGpuSmoke
import afield.lang.stacksl.study.StackSLGpuRasterizer
import java.util.concurrent.CompletableFuture

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

object StackSLStudyGpuSmoke

Proof that a StackSL study's GPU frames are the program's frames: the same programs rendered by afield.lang.stacksl.study.StackSLGpuRasterizer on a live renderer and by the CPU reference. Exact arithmetic, smooth-transcendental tiers, and the 2D fBm palette have hard bounds; the 4D fbm4 hash comparison remains diagnostic because tiny differences in its warped coordinates can cross hash lattice boundaries. uv saturate must be bit-identical, which catches a flipped image.

It also times an observation-sized render (four 256×256 frames of a five-octave volumetric program), since the reason the GPU path exists is that the CPU reference takes minutes for it.

run writes the evidence sheet — GPU above, CPU below — after the hard checks pass, retaining remaining hash21 differences as visible evidence without treating them as a portable bound.

object StackSLStudyGpuSmoke {

val PROGRAMS

Programs spanning the four single-pass agreement tiers.

    val PROGRAMS = listOf(
        "uv saturate",
        "uv 0.5 * 0.25 + [1,0.5,0.2] *",
        "p length 0.3 0.05 pulse white *",
        "p polar dup y 0.2t + >y cart length 0.6 * palette saturate",
        "uv 5 * 0.1t + fbm palette saturate",
        "p [3,3,0,0] * t [0,0,0,0.1] * + fbm4 palette saturate"
    )

    private const val DIM = 64
    private val TIMES = listOf(0.0, 1.75)

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

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

    fun run(rasterizer: StackSLRasterizer, sheet: File): String {
        val rows = mutableListOf<Pair<IntArray, IntArray>>()
        val lines = PROGRAMS.map { program ->
            val tier = AgreementTier.of(StackSLCpu.compile(program).glsl)
            val gpu = rasterizer.renderAll(program, DIM, TIMES)
            check(rasterizer.description.startsWith("GPU")) { "expected a GPU render, got: ${rasterizer.description}" }
            val cpu = CpuStackSLRasterizer.renderAll(program, DIM, TIMES)
            TIMES.indices.map { i ->
                // Both in 8-bit sRGB, as StackSLCpuGallery.compare measures and the window shows.
                val g = StudyEvidence.displayed(gpu[i]).toSrgbArgb()
                val c = StudyEvidence.displayed(cpu[i])
                rows += g to StackSLCpuGallery.galleryArgb(c)
                val agreement = StackSLCpuGallery.compare(program, tier, c, g, DIM)
                holds(program, tier, agreement)
                agreement
            }.maxBy { it.meanLevels }
        }
        val heavy = PROGRAMS.last()
        val start = System.nanoTime()
        rasterizer.renderAll(heavy, 256, listOf(0.0, 3.5, 7.0, 10.5))
        val seconds = (System.nanoTime() - start) / 1e9
        check(seconds < 10.0) { "four 256x256 frames took ${"%.1f".format(seconds)}s on the GPU; the point of the GPU path is that they do not" }
        writeSheet(rows, sheet)
        return "stacksl study GPU agreement: ${PROGRAMS.size} programs at ${TIMES.size} times; " +
            "exact, smooth, and 2D fBm tiers hold bounds; 4D fbm4 hash remains diagnostic; " +
            "four 256x256 frames of '$heavy' in ${"%.2f".format(seconds)}s on ${rasterizer.description}; " +
            PROGRAMS.zip(lines).joinToString("; ") { (program, agreement) ->
                val label = when {
                    agreement.tier != AgreementTier.HASH21_AMPLIFIED -> agreement.tier.label
                    program == "uv 5 * 0.1t + fbm palette saturate" -> "2D fBm"
                    else -> "${agreement.tier.label} (4D diagnostic)"
                }
                "$label mean=${"%.3f".format(agreement.meanLevels)} max=${agreement.maxLevels}"
            }
    }

The bounds StackSLCpuGalleryTest asserts between the CPU backend and committed GPU stills.

    private fun holds(program: String, tier: AgreementTier, a: GalleryAgreement) {
        if (program == "uv saturate") check(a.maxLevels == 0) { "uv saturate must be bit-identical on the GPU (a flip or an offset): $a" }
        when (tier) {
            AgreementTier.EXACT_OPS, AgreementTier.SMOOTH_TRANSCENDENTAL ->
                check(a.maxLevels <= 1) { "$tier programs agree to within a level: $a" }
            AgreementTier.HASH21_AMPLIFIED -> when (program) {
                "uv 5 * 0.1t + fbm palette saturate" -> check(a.meanLevels < 0.1 && a.maxLevels <= 1) {
                    "2D fBm should remain visually CPU/GPU-equivalent: $a"
                }
                // Keep fbm4 as evidence only until its 4D arithmetic is stabilized separately.
                else -> Unit
            }
            AgreementTier.HASH11_UNBOUNDED, AgreementTier.FEEDBACK -> Unit
        }
    }

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