StackSLCpuGallery.kt
src/jvmMain/kotlin/afield/lang/stacksl/cpu/StackSLCpuGallery.kt · 264 lines
package afield.lang.stacksl.cpu
import java.io.File
Compares StackSL's CPU renderer against the GPU: 31 committed gallery programs, each with its fragment template and the 512x512 PNG a real GPU drew for it.
public/docs/assets/stacksl_gallery/ holds, per gallery entry <name>, the program
(<name>_stackSL_.stacksl), its fragment shader with the literal {{ stack.auto.program }} slot
(<name>_fragment_.glsl), and its docOrder / animate / timeSpeed / feedback settings
(<name>_metadata_.yaml). public/docs/assets/stacksl/stacksl-<name>.png is the GPU's still.
The fragment file is a template, not expanded GLSL, so a CPU render that reads it goes through the
live StackSLCompiler, and both backends are compared against today's emitter. 29 of the 31
templates are byte-identical (fragmentTemplate(1.75f) in StackSLGalleryHarness); the other 2
are the feedback template.
The ground truth is 8-bit. The harness renders into VK_FORMAT_R8G8B8A8_SRGB and clamps to
[0,1] first, so the GPU's answer is known to one sRGB level. Agreement is therefore measured in
8-bit sRGB levels, with the CPU side encoded the same way (StackSLCpuGallery.galleryArgb), and
"exact" means "quantises to the same byte".
class GalleryEntry
One gallery entry as it exists on disk.
class GalleryEntry(
val name: String,
val program: String,
val fragmentTemplate: String,
val docOrder: Int,
val animate: Boolean,
val feedback: Boolean,
/** The committed GPU still, or `null` if there is none. */
val gpuPng: File?
)
class GalleryAgreement
What the two backends did to each other, per program, in 8-bit levels.
Everything is measured over the three colour channels of every pixel; alpha is excluded because
the harness's main() writes a constant 1.0 there and comparing a constant is decoration.
maxLevels is the number that matters and the number a global epsilon would hide: a program can
have a mean under a hundredth of a level and still contain a handful of pixels that differ by
200, because floor() inside a noise lattice turns a one-ulp disagreement into a different
lattice cell. withinOneFraction is the honest summary for that case, and worstPixel says
where to look.
class GalleryAgreement(
val name: String,
val tier: AgreementTier,
val meanLevels: Double,
val maxLevels: Int,
/** Fraction of pixels whose three channels are all within one 8-bit level. */
val withinOneFraction: Double,
/** Fraction of pixels that quantise to exactly the same three bytes. */
val exactFraction: Double,
val worstPixel: Pair<Int, Int>,
val pixels: Int,
/** How many pixels have a channel more than two 8-bit levels apart. */
val overTwoPixels: Int = 0
) {
override fun toString(): String
override fun toString(): String =
"%-22s %-11s mean=%7.4f max=%3d within1=%7.4f%% exact=%7.4f%% worst=(%d,%d)".format(
name, tier.label, meanLevels, maxLevels, withinOneFraction * 100, exactFraction * 100,
worstPixel.first, worstPixel.second
)
}
enum class AgreementTier(val label: String, val why: String)
Why a program is or is not entitled to agree tightly, decided from the emitted GLSL text
rather than from a hand-kept list, so a program that gains an fbm gets reclassified without
anyone remembering to.
The ordering matters: a program is put in the weakest tier any of its ingredients earns.
enum class AgreementTier(val label: String, val why: String) {
Only operations IEEE-754 specifies exactly: + - * /, floor, fract, abs, min,
max, clamp, mix, step, dot, matrix products. Two conforming implementations must
produce the same bits unless one of them contracts a multiply-add, so a disagreement here
is a real defect and is asserted as such.
EXACT_OPS("exact-ops", "arithmetic only — IEEE-754 exact, so agreement is required"),
Reaches sin, cos, exp, log, pow, atan or sqrt. GLSL leaves the accuracy of
every one of those implementation-defined, so no CPU implementation can be bit-exact
with a GPU even in principle. But they are all smooth, so the error stays the size of the
last bits of their argument: a tolerance of a level or two is the honest claim.
SMOOTH_TRANSCENDENTAL("transcendental", "sin/cos/exp/pow are implementation-defined in GLSL but smooth"),
Reaches _hash21 (and therefore noise/fbm). _hash21 is multiply/add/fract only and
so exact operation by operation — but it is called on floor(p), and _fbm walks p
through five mat2 warps before that floor. When accumulated rounding puts p on the
other side of an integer, the lattice cell changes and the value jumps by O(1). The mean
stays tiny and a few pixels differ enormously; that is chaos, not error, and it is reported
as a distribution rather than bounded by an epsilon.
HASH21_AMPLIFIED("hash21-chaos", "exact per op, but `fract`/`floor` amplify one ulp to O(1)"),
Reaches _hash11, which is fract(sin(p * 127.1) * 43758.5453123): an
implementation-defined sin multiplied by 43758 and then fracted. Both mechanisms at
once, and the worse one is unavoidable — this cannot agree even in principle.
HASH11_UNBOUNDED("hash11-chaos", "`fract(sin(x) * 43758)` — implementation-defined AND amplifying"),
Samples a texture, so it has no still-image ground truth without the ping-pong history.
FEEDBACK("feedback", "samples the previous frame; needs iterated state, not a single pass");
companion object
companion object {
private val transcendentals = Regex("""\b(sin|cos|tan|asin|acos|atan|exp|exp2|log|log2|pow|sqrt|inversesqrt|normalize|length|distance|smoothstep)\s*\(""")
fun of(emittedGlsl: String): AgreementTier
Classify from the emitted GLSL of a program.
fun of(emittedGlsl: String): AgreementTier = when {
Regex("""\btexture\s*\(""").containsMatchIn(emittedGlsl) -> FEEDBACK
emittedGlsl.contains("_hash11(") -> HASH11_UNBOUNDED
// `_hash31`/`_hash41` are the volumetric family's (fbm3, fbm4, worley3, …) versions of the
// same fract-lattice construction in more dimensions, and amplify the same way.
emittedGlsl.contains("_hash21(") || emittedGlsl.contains("_hash31(") || emittedGlsl.contains("_hash41(") -> HASH21_AMPLIFIED
transcendentals.containsMatchIn(emittedGlsl) -> SMOOTH_TRANSCENDENTAL
else -> EXACT_OPS
}
}
}
object StackSLCpuGallery
Load the gallery from a checkout and compare the CPU backend against the committed GPU stills.
This is a library object, not a test and not a main: the ratchet test drives it into a temp
directory and the contact-sheet tool drives it into the worktree, and neither duplicates the
loading, the tiering or the statistics.
object StackSLCpuGallery {
const val WORKSPACE_DIR = "public/docs/assets/stacksl_gallery"
Where the programs and templates live, relative to a checkout root.
const val GPU_STILL_DIR = "public/docs/assets/stacksl"
Where the committed GPU stills live, relative to a checkout root.
const val DIM = 512
The side of the committed GPU stills; StackSLGalleryHarness.OUTPUT_DIM.
fun load(repoRoot: File): List<GalleryEntry>
Load every entry, in the docs' own order.
fun load(repoRoot: File): List<GalleryEntry> {
val workspace = File(repoRoot, WORKSPACE_DIR)
val files = workspace.listFiles { f: File -> f.name.endsWith("_stackSL_.stacksl") }
?: error("StackSL gallery workspace not found: ${workspace.absolutePath}")
require(files.isNotEmpty()) { "no *_stackSL_.stacksl files under ${workspace.absolutePath}" }
return files.map { f ->
val name = f.name.removeSuffix("_stackSL_.stacksl")
val meta = File(workspace, "${name}_metadata_.yaml").takeIf { it.exists() }?.readLines() ?: emptyList()
fun value(key: String): String? =
meta.firstNotNullOfOrNull { Regex("""^\s*$key:\s*(.+?)\s*$""").find(it)?.groupValues?.get(1) }
val template = File(workspace, "${name}_fragment_.glsl")
require(template.exists()) { "no committed fragment template for gallery entry '$name'" }
val png = File(repoRoot, "$GPU_STILL_DIR/stacksl-$name.png").takeIf { it.exists() }
GalleryEntry(
name = name,
program = f.readText().trim(),
fragmentTemplate = template.readText(),
docOrder = value("docOrder")?.toIntOrNull() ?: Int.MAX_VALUE,
animate = value("animate")?.toBooleanStrictOrNull() ?: false,
feedback = value("feedback")?.toBooleanStrictOrNull() ?: false,
gpuPng = png
)
}.sortedWith(compareBy({ it.docOrder }, { it.name }))
}
fun render(entry: GalleryEntry, dim: Int = DIM, parallel: Boolean = true): CpuRaster
Render one entry on the CPU at dim square, through the committed template.
fun render(entry: GalleryEntry, dim: Int = DIM, parallel: Boolean = true): CpuRaster =
StackSLCpu.renderProgram(
program = entry.program,
template = entry.fragmentTemplate,
width = dim,
height = dim,
// The feedback template has a push-constant time; the still template bakes 1.75 in.
pushParams = if (entry.feedback || entry.animate) floatArrayOf(0f, 0f, 0f, 0f) else null,
samplers = if (entry.feedback) blackSampler else GlslSamplers.REFUSE,
parallel = parallel
)
fun tierOf(entry: GalleryEntry): AgreementTier
The tier an entry falls in, from its emitted GLSL.
A feedback: true metadata flag and a texture() call in the emitted GLSL are two
independent facts, and they are checked to agree — a program that samples but is not marked
feedback would be rendered against a still that the GPU never drew.
fun tierOf(entry: GalleryEntry): AgreementTier {
val emitted = StackSLCpu.compile(entry.program).glsl
val tier = AgreementTier.of(emitted)
require((tier == AgreementTier.FEEDBACK) == entry.feedback) {
"gallery entry '${entry.name}' is marked feedback=${entry.feedback} but its emitted GLSL " +
(if (tier == AgreementTier.FEEDBACK) "does" else "does not") + " sample a texture"
}
return tier
}
fun galleryArgb(cpu: CpuRaster): IntArray = cpu.toSrgbArgb()
A CPU raster as the gallery's GPU target stores it: 8-bit sRGB
(StackSLGalleryHarness.GALLERY_FORMAT). Anything compared with or drawn beside a committed
still goes through here, so the two sides are never in different encodings.
fun compare(name: String, tier: AgreementTier, cpu: CpuRaster, gpuArgb: IntArray, gpuWidth: Int): GalleryAgreement
Compare a CPU raster against a committed GPU PNG.
Both sides are reduced to 8-bit sRGB before comparing, because that is all the GPU side has.
fun compare(name: String, tier: AgreementTier, cpu: CpuRaster, gpuArgb: IntArray, gpuWidth: Int): GalleryAgreement {
require(cpu.width == gpuWidth) { "$name: CPU raster is ${cpu.width} wide, GPU still is $gpuWidth" }
val cpuArgb = galleryArgb(cpu)
require(cpuArgb.size == gpuArgb.size) { "$name: ${cpuArgb.size} CPU pixels vs ${gpuArgb.size} GPU pixels" }
var total = 0L
var max = 0
var worst = 0
var within = 0
var exact = 0
var overTwo = 0
for (i in cpuArgb.indices) {
val a = cpuArgb[i]
val b = gpuArgb[i]
val dr = Math.abs(((a shr 16) and 0xff) - ((b shr 16) and 0xff))
val dg = Math.abs(((a shr 8) and 0xff) - ((b shr 8) and 0xff))
val db = Math.abs((a and 0xff) - (b and 0xff))
total += (dr + dg + db).toLong()
val worstHere = maxOf(dr, dg, db)
if (worstHere > max) { max = worstHere; worst = i }
if (worstHere <= 1) within++
if (worstHere == 0) exact++
if (worstHere > 2) overTwo++
}
val n = cpuArgb.size
return GalleryAgreement(
name = name,
tier = tier,
meanLevels = total.toDouble() / (n * 3.0),
maxLevels = max,
withinOneFraction = within.toDouble() / n,
exactFraction = exact.toDouble() / n,
worstPixel = (worst % gpuWidth) to (worst / gpuWidth),
pixels = n,
overTwoPixels = overTwo
)
}
val blackSampler = GlslSamplers { _, _ -> GlslValue.vec4(0f, 0f, 0f, 1f) }
A sampler that reports black with alpha 1.
Only used to make a feedback program renderable for the contact sheet's sake — the first frame of a ping-pong whose history is a cleared black target, which is exactly what the GPU harness starts from. It is not a claim that the result matches the committed still, and the feedback entries are excluded from every asserted statistic for that reason.