StackSLGalleryHarness.kt
src/jvmMain/kotlin/afield/graphics/scene/fabricated/StackSLGalleryHarness.kt · 654 lines
package afield.graphics.scene.fabricated
import afield.document.graph.Box
import afield.graphics.vulkan.Renderer
import afield.graphics.render.TexturePreviewCache
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.shader.ShaderKind
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.vulkan.targets.PingPongRenderToTexture
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.TextureSampler
import afield.app.diagnostics.logging.lprintln
import afield.lang.stacksl.StackSLCompileOptions
import afield.lang.stacksl.StackSLCompiler
import afield.lang.stacksl.StackSLSeverity
import afield.graphics.shader.preprocess
import afield.graphics.shader.stackAutoShaderSources
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import org.lwjgl.vulkan.VK10.VK_FILTER_LINEAR
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_SRGB
import org.lwjgl.vulkan.VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
import org.lwjgl.vulkan.VK10.VK_SAMPLER_MIPMAP_MODE_LINEAR
import java.io.File
import java.nio.file.Files
import java.util.Base64
import java.util.concurrent.TimeUnit
The StackSL gallery on the docs site: one fullscreen fragment per example program, rendered
offscreen into a fixed 512² (OUTPUT_DIM) target.
It holds one renderer, one offscreen RenderToTexture and one fragment slot open, then walks
the StackSL programs in a canonical_graphics-style workspace (WORKSPACE_DIR). Each program is
injected through the ordinary {{ stack.auto.program }} template path, the same live-coding
mechanism the StackSL pages describe, and the fullscreen module is rebuilt under a single name,
so a new module replaces the previous one in the target's module map. The resulting texture is
then read back.
renderGallery reads back a single still per program. renderAnimations drives a time push
constant across frames and encodes each animate: true program to an mp4, since the docs runner
can screenshot the native GPU window but never record it, which leaves the readback and the
ffmpeg encode to the harness. Both are "one Field document, many Field 4 renders": a whole
page's assets come out of a single graphics context rather than a JVM restart apiece. The
example programs are editable Field content in the workspace, not literals in this file.
Both targets use GALLERY_FORMAT, an sRGB format, for the same reason the window's swapchain is
B8G8R8A8_SRGB: the fragment writes linear colour and the attachment encodes it on write, so
the bytes read back are the bytes a viewer sees in a live window. A UNORM attachment stores
the linear values unencoded, and reads back markedly darker than the same program looks in a
window. The feedback programs sample their own previous frame, and an sRGB ping-pong hands that
sample back decoded, so they still compute in linear light; that is also
PingPongRenderToTexture's default.
object StackSLGalleryHarness
Renders the StackSL gallery stills and animations offscreen, one fullscreen program at a time.
object StackSLGalleryHarness {
const val OUTPUT_DIM = 512
const val GALLERY_FORMAT = VK_FORMAT_R8G8B8A8_SRGB
The colour format of every gallery target: the swapchain's encoding, in RGBA order so
TexturePreviewCache.capture copies it straight to the PNG without a blit or a swap.
private const val WAIT_FRAMES = 12
private const val ANIM_FPS = 24
private const val ANIM_FRAMES = 96 // ~4s per clip
private const val ANIM_SETTLE_FRAMES = 2 // renderer frames to let each new time render
private const val MODULE_NAME = "stacksl-gallery-module"
private const val MESH_NAME = "stacksl-gallery-mesh"
private const val PRE_TARGET_NAME = "stacksl-gallery-rtt"
private const val PRE_TARGET_NAME_FEEDBACK = "stacksl-gallery-feedback"
private const val FEEDBACK_WARMUP_FRAMES = 60
Which frame of the feedback sweep becomes the still. The still runs the same warmup + time sweep as the mp4 and captures this frame, so the PNG is a real mid-clip frame of the very animation it sits next to.
private const val FEEDBACK_STILL_FRAME = ANIM_FRAMES / 2
private const val SLOT = "program"
private val DATA_IMAGE_RE = Regex("base64,([^']+)")
const val WORKSPACE_DIR = "public/docs/assets/stacksl_gallery"
Repo-relative workspace whose *_stackSL_.stacksl files are the examples.
The gallery entry embedded as the section hero image.
private const val HERO_NAME = "fbm-palette"
data class Example
One gallery entry loaded from the workspace: name is the box slug (and the
PNG/mp4 stem stacksl-<name>), program its StackSL source, time is baked
into input.w for the still. animate marks time-driven programs that also
get an mp4, and timeSpeed scales how fast input.w sweeps in that clip.
data class Example(
val name: String,
val program: String,
val time: Float = 1.75f,
val animate: Boolean = false,
val timeSpeed: Float = 1.0f,
val feedback: Boolean = false
)
private fun defaultWorkspaceDir(): File = File(System.getProperty("user.dir"), WORKSPACE_DIR)
fun loadExamples(workspaceDir: File = defaultWorkspaceDir()): List<Example>
Load every example from the canonical_graphics-style workspace: one box per
<name>_stackSL_.stacksl file, ordered by the docOrder in the sibling
<name>_metadata_.yaml (which also carries optional animate:/timeSpeed:).
The programs live in the workspace, not in this file.
fun loadExamples(workspaceDir: File = defaultWorkspaceDir()): List<Example> {
val files = workspaceDir.listFiles { f -> f.name.endsWith("_stackSL_.stacksl") }
?: error("StackSL gallery workspace not found: ${workspaceDir.absolutePath}")
require(files.isNotEmpty()) { "StackSL gallery workspace has no *_stackSL_.stacksl files: ${workspaceDir.absolutePath}" }
return files
.map { f ->
val name = f.name.removeSuffix("_stackSL_.stacksl")
val meta = File(workspaceDir, "${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 order = value("docOrder")?.toIntOrNull() ?: Int.MAX_VALUE
val animate = value("animate")?.toBooleanStrictOrNull() ?: false
val timeSpeed = value("timeSpeed")?.toFloatOrNull() ?: 1.0f
val feedback = value("feedback")?.toBooleanStrictOrNull() ?: false
Example(
name = name,
program = f.readText().trim(),
animate = animate,
timeSpeed = timeSpeed,
feedback = feedback
) to order
}
.sortedWith(compareBy({ it.second }, { it.first.name }))
.map { it.first }
}
suspend fun renderGallery(outputDir: File, renderer: Renderer): File
Render every workspace example into <outputDir>/stacksl-<name>.png,
reusing a single renderer + target. Returns the hero PNG for the docs fence.
body — 46 lines, from line 149
suspend fun renderGallery(outputDir: File, renderer: Renderer): File {
outputDir.mkdirs()
val examples = loadExamples()
val rtt = newTarget(renderer)
renderer.renderList.preTargets[PRE_TARGET_NAME] = rtt
val templateBox = Box("stacksl-gallery-preprocess")
var heroFile: File? = null
try {
examples.forEach { example ->
val file = File(outputDir, "stacksl-${example.name}.png")
runCatching {
// Feedback programs sample `source`, and only the ping-pong
// template declares that sampler — the plain still template
// cannot host them (glslang rejects the assembled shader and
// the target stays at its clear color). So they take the same
// evolving path their mp4 does.
if (example.feedback) {
renderFeedbackStill(renderer, templateBox, example, file)
} else {
buildProgramModule(renderer, rtt, templateBox, example.program, fragmentTemplate(example.time), example.name)
waitForRendererFrames(renderer, WAIT_FRAMES)
captureTo(renderer, rtt, file, example.name)
}
requireNotAllBlack(file, example.name)
}
.onSuccess {
lprintln("info,doc", "stacksl gallery wrote ${file.name} (${file.length()} bytes)")
if (example.name == HERO_NAME) heroFile = file
}
.onFailure { t ->
// Never leave a rejected still on disk: a black or partial PNG
// that survives a failed run looks exactly like a good asset to
// every downstream consumer. Better a missing file than a fake.
if (file.delete()) lprintln("error,doc", "stacksl gallery discarded ${file.name}")
lprintln("error,doc", "stacksl gallery example '${example.name}' failed: ${t.message}")
}
}
} finally {
teardown(renderer, rtt)
}
return heroFile
?: File(outputDir, "stacksl-${examples.first().name}.png").takeIf { it.exists() }
?: error("stacksl gallery produced no images")
}
suspend fun renderAnimations(outputDir: File, renderer: Renderer): List<File>
Render an mp4 for every animate: true workspace example into
<outputDir>/stacksl-<name>.mp4, reusing one renderer + target. The shader
is built once per program; a time push constant sweeps over ANIM_FRAMES
frames, each read back and handed to ffmpeg.
suspend fun renderAnimations(outputDir: File, renderer: Renderer): List<File> {
outputDir.mkdirs()
val examples = loadExamples().filter { it.animate }
require(examples.isNotEmpty()) {
"no animated StackSL examples — set `animate: true` in a box's metadata.yaml under $WORKSPACE_DIR"
}
val ffmpeg = resolveFfmpeg()
val templateBox = Box("stacksl-anim-preprocess")
val written = mutableListOf<File>()
// Time-driven clips share one plain target (shader rebuilt per program).
val timeDriven = examples.filter { !it.feedback }
if (timeDriven.isNotEmpty()) {
val rtt = newTarget(renderer)
renderer.renderList.preTargets[PRE_TARGET_NAME] = rtt
try {
timeDriven.forEach { example ->
runCatching { renderOneAnimation(renderer, rtt, templateBox, example, outputDir, ffmpeg) }
.onSuccess { written += it; lprintln("info,doc", "stacksl anim wrote ${it.name} (${it.length()} bytes)") }
.onFailure { t -> lprintln("error,doc", "stacksl anim '${example.name}' failed: ${t.message}") }
}
} finally {
teardown(renderer, rtt)
}
}
// Feedback clips each need their own ping-pong target (evolving state).
examples.filter { it.feedback }.forEach { example ->
runCatching { renderFeedbackAnimation(renderer, templateBox, example, outputDir, ffmpeg) }
.onSuccess { written += it; lprintln("info,doc", "stacksl feedback anim wrote ${it.name} (${it.length()} bytes)") }
.onFailure { t -> lprintln("error,doc", "stacksl feedback anim '${example.name}' failed: ${t.message}") }
}
return written
}
Mutable time cell shared with the push-constant lambda for a feedback run.
private class FeedbackTime {
var value = 0f
}
Set up one feedback (ping-pong) program and run body against it.
The fragment samples the previous frame through source, so the image
evolves on its own each renderer frame — a PingPongRenderToTexture keeps
the previous frame readable while the current one is written. The program is
built once, warmed for FEEDBACK_WARMUP_FRAMES so the feedback develops
structure, and then handed to body together with the time cell driving
pc.params.x. Both the mp4 and the still go through here, so they cannot
drift apart.
private suspend fun <T> withFeedbackProgram(
renderer: Renderer,
templateBox: Box,
example: Example,
body: suspend (PingPongRenderToTexture, FeedbackTime) -> T
): T {
val pp = PingPongRenderToTexture(renderer, frameCount = 2) {
width = OUTPUT_DIM
height = OUTPUT_DIM
format = GALLERY_FORMAT
useDepth = false
colorSampled = true
colorTransferSrc = true
clearColor = afield.math.linalg.vec(0f, 0f, 0f, 1f)
debugName = "stacksl-feedback"
}
renderer.renderList.preTargets[PRE_TARGET_NAME_FEEDBACK] = pp
val sampler = TextureSampler(renderer, 1, true, VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_LINEAR)
// `source` resolves to the previous ping-pong frame each time it is read.
val sourceProvider = object : HasImageViewArray {
override val imageViewArray: List<HasImageView> get() = listOf(pp.otherImageView)
}
val time = FeedbackTime()
try {
val mesh = buildProgramModule(
renderer, pp, templateBox, example.program, feedbackFragmentTemplate(), example.name,
source = sourceProvider to sampler
)
mesh.push.bind("params") { afield.math.linalg.vec(time.value, 0f, 0f, 0f) }
waitForRendererFrames(renderer, FEEDBACK_WARMUP_FRAMES) // let the feedback develop structure
return body(pp, time)
} finally {
renderer.renderList.preTargets.remove(PRE_TARGET_NAME_FEEDBACK)
pp.modules[MODULE_NAME]?.let { disposeModule(it) }
pp.modules.clear()
runCatching { sampler.cleanup() }
disposeRtt(pp)
}
}
Read back the ping-pong frame that is currently readable (the previous one).
private suspend fun captureFeedbackTo(
renderer: Renderer,
pp: PingPongRenderToTexture,
outputFile: File,
label: String
) = captureImageTo(renderer, pp.otherImageView.imageView.image, pp.colorFormats[0], outputFile, label)
Advance the feedback sweep to frame upTo inclusive, rendering each step.
private suspend fun sweepFeedback(renderer: Renderer, example: Example, time: FeedbackTime, upTo: Int) {
for (i in 0..upTo) {
time.value = i.toFloat() / ANIM_FPS * example.timeSpeed
waitForRendererFrames(renderer, ANIM_SETTLE_FRAMES)
}
}
Capture a single still for a feedback program: run the same warmup and time
sweep the mp4 does and read back frame FEEDBACK_STILL_FRAME. The plain
fragmentTemplate cannot host these programs at all — it declares no
source sampler — so the still needs the ping-pong path too.
private suspend fun renderFeedbackStill(
renderer: Renderer,
templateBox: Box,
example: Example,
outputFile: File
) = withFeedbackProgram(renderer, templateBox, example) { pp, time ->
sweepFeedback(renderer, example, time, FEEDBACK_STILL_FRAME)
captureFeedbackTo(renderer, pp, outputFile, example.name)
}
Render a feedback (ping-pong) clip: every frame of the sweep is read back and
encoded, so the mp4 shows the image feeding back into itself. The still
produced by renderFeedbackStill is frame FEEDBACK_STILL_FRAME of exactly
this sequence.
source — 51 lines, from line 331
private suspend fun renderFeedbackAnimation(
renderer: Renderer,
templateBox: Box,
example: Example,
outputDir: File,
ffmpeg: String
): File = withFeedbackProgram(renderer, templateBox, example) { pp, time ->
val framesDir = Files.createTempDirectory("stacksl-feedback-${example.name}").toFile()
try {
for (i in 0 until ANIM_FRAMES) {
time.value = i.toFloat() / ANIM_FPS * example.timeSpeed
waitForRendererFrames(renderer, ANIM_SETTLE_FRAMES)
captureFeedbackTo(renderer, pp, File(framesDir, "frame_%05d.png".format(i)), example.name)
}
val mp4 = File(outputDir, "stacksl-${example.name}.mp4")
encodeMp4(ffmpeg, framesDir, "frame_%05d.png", ANIM_FPS, mp4)
require(mp4.length() > 0L) { "ffmpeg wrote an empty mp4 for '${example.name}'" }
mp4
} finally {
framesDir.deleteRecursively()
}
}
private suspend fun renderOneAnimation(
renderer: Renderer,
rtt: RenderToTexture,
templateBox: Box,
example: Example,
outputDir: File,
ffmpeg: String
): File {
var currentTime = 0f
val mesh = buildProgramModule(renderer, rtt, templateBox, example.program, animatedFragmentTemplate(), example.name)
mesh.push.bind("params") { afield.math.linalg.vec(currentTime, 0f, 0f, 0f) }
waitForRendererFrames(renderer, WAIT_FRAMES) // build pipeline + first render
val framesDir = Files.createTempDirectory("stacksl-anim-${example.name}").toFile()
try {
for (i in 0 until ANIM_FRAMES) {
currentTime = i.toFloat() / ANIM_FPS * example.timeSpeed
waitForRendererFrames(renderer, ANIM_SETTLE_FRAMES)
captureTo(renderer, rtt, File(framesDir, "frame_%05d.png".format(i)), example.name)
}
val mp4 = File(outputDir, "stacksl-${example.name}.mp4")
encodeMp4(ffmpeg, framesDir, "frame_%05d.png", ANIM_FPS, mp4)
require(mp4.length() > 0L) { "ffmpeg wrote an empty mp4 for '${example.name}'" }
return mp4
} finally {
framesDir.deleteRecursively()
}
}
shared plumbing
private fun newTarget(renderer: Renderer): RenderToTexture = RenderToTexture(renderer) {
width = OUTPUT_DIM
height = OUTPUT_DIM
format = GALLERY_FORMAT
useDepth = false
colorSampled = true
colorTransferSrc = true
clearColor = afield.math.linalg.vec(0f, 0f, 0f, 1f)
debugName = "stacksl-gallery"
}
private fun teardown(renderer: Renderer, rtt: RenderToTexture) {
renderer.renderList.preTargets.remove(PRE_TARGET_NAME)
rtt.modules[MODULE_NAME]?.let { disposeModule(it) }
rtt.modules.clear()
disposeRtt(rtt)
}
Compile-check program, expand it through {{ stack.auto.program }} into
fragmentTemplate, and (re)register a fullscreen module under one name so it
replaces the previous one in the target — the live-coding "modules swap by
name" behaviour. Returns the fullscreen Mesh.
source — 65 lines, from line 409
private fun buildProgramModule(
renderer: Renderer,
rtt: RenderToTexture,
templateBox: Box,
program: String,
fragmentTemplate: String,
label: String,
source: Pair<HasImageViewArray, TextureSampler>? = null
): Mesh {
// Match the `{{ stack.auto.program }}` expansion, which defaults sample
// words to the `source` sampler — so sampling (feedback) programs compile.
val compiled = StackSLCompiler.compile(
program,
StackSLCompileOptions(prefix = SLOT, includeHeader = false, declareSamplers = false, sampleTextures = listOf("source"))
)
require(compiled.ok) {
"StackSL '$label' did not compile:\n" +
compiled.diagnostics.filter { it.severity == StackSLSeverity.ERROR }.joinToString("\n")
}
templateBox.stackAutoShaderSources[SLOT] = program
val fragment = preprocess(templateBox, fragmentTemplate, ShaderKind.FRAGMENT_SHADER)
val previous = rtt.modules[MODULE_NAME]
val module = Module(parameters(renderer) {
name = MODULE_NAME
vertex = FULLSCREEN_VERTEX
this.fragment = fragment
blend = false
depthTest = false
+rtt
if (source != null) {
val userResolver = textureResolver
textureResolver = { tex -> if (tex.name == "source") source else userResolver(tex) }
}
}).pointer()
val mesh = Mesh(renderer, module)
mesh.resize(3, 0, module) // fullscreen triangle, no vertex attributes
module.current.meshes[MESH_NAME] = mesh
rtt += MODULE_NAME to module
previous?.let { if (it !== module) disposeModule(it) }
return mesh
}
private suspend fun captureTo(renderer: Renderer, rtt: RenderToTexture, outputFile: File, label: String) =
captureImageTo(renderer, rtt.colorImageHandles[0], rtt.colorFormats[0], outputFile, label)
private suspend fun captureImageTo(renderer: Renderer, vkImage: Long, format: Int, outputFile: File, label: String) {
val html = TexturePreviewCache.capture(
renderer = renderer,
vkImage = vkImage,
format = format,
srcWidth = OUTPUT_DIM,
srcHeight = OUTPUT_DIM,
currentLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
maxDim = OUTPUT_DIM
) ?: error("could not read back the StackSL gallery texture for '$label'")
val encoded = DATA_IMAGE_RE.find(html)?.groupValues?.get(1)
?: error("could not parse the StackSL gallery texture data for '$label'")
outputFile.parentFile?.mkdirs()
outputFile.delete()
outputFile.writeBytes(Base64.getDecoder().decode(encoded))
require(outputFile.length() > 0L) { "StackSL gallery readback wrote an empty image for '$label'" }
}
A gallery still that is entirely black is never a legitimate asset. When a fragment shader fails to build, the target simply keeps its clear color and the readback still "succeeds" — which is how two black PNGs were committed without any example ever reporting a failure. Check the pixels, not the byte count.
source — 105 lines, from line 482
private fun requireNotAllBlack(file: File, label: String) {
val image = javax.imageio.ImageIO.read(file)
?: error("could not re-read the StackSL still for '$label': ${file.absolutePath}")
for (y in 0 until image.height) {
for (x in 0 until image.width) {
if (image.getRGB(x, y) and 0x00ffffff != 0) return
}
}
error(
"StackSL still for '$label' is entirely black (${image.width}x${image.height}) — " +
"the program almost certainly never drew; check that its fragment template " +
"declares every sampler the program reads"
)
}
private fun resolveFfmpeg(): String {
val candidates = listOfNotNull(
System.getenv("FFMPEG"),
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/usr/bin/ffmpeg"
)
return candidates.firstOrNull { File(it).canExecute() } ?: "ffmpeg"
}
private fun encodeMp4(ffmpeg: String, framesDir: File, pattern: String, fps: Int, output: File) {
output.parentFile?.mkdirs()
output.delete()
val process = ProcessBuilder(
ffmpeg, "-y",
"-framerate", fps.toString(),
"-i", pattern,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "20",
"-movflags", "+faststart",
output.absolutePath
).directory(framesDir).redirectErrorStream(true).start()
val log = process.inputStream.bufferedReader().readText()
if (!process.waitFor(120, TimeUnit.SECONDS)) {
process.destroyForcibly()
error("ffmpeg timed out encoding ${output.name}")
}
require(process.exitValue() == 0) {
"ffmpeg failed (${process.exitValue()}) for ${output.name}:\n${log.takeLast(600)}"
}
}
private suspend fun waitForRendererFrames(renderer: Renderer, frames: Int) {
val target = renderer.frameCounter + frames
val deadline = System.nanoTime() + 15_000_000_000L
while (renderer.frameCounter < target) {
if (System.nanoTime() > deadline) {
error("timed out waiting for renderer frames: current=${renderer.frameCounter} target=$target")
}
delay(8)
yield()
}
}
private fun disposeModule(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()
}.onFailure { lprintln("error,doc", "stacksl gallery module dispose failed: ${it.message}") }
}
private fun disposeRtt(rtt: RenderToTexture) {
runCatching {
rtt.defaultSampler?.cleanup()
rtt.imageViewArray.map { it.imageView }.distinctBy { it.vkImageView }.forEach { it.cleanup() }
rtt.colorImageRefs.filterNotNull().distinctBy { it.vkImage }.forEach { it.cleanup() }
listOfNotNull(rtt.depthSampledImageView, rtt.depthImageView).distinctBy { it.vkImageView }
.forEach { it.cleanup() }
rtt.depthImage?.cleanup()
rtt.commandBuffers.forEach { it.cleanup() }
rtt.fences.forEach { it.cleanup() }
}.onFailure { lprintln("error,doc", "stacksl gallery RTT dispose failed: ${it.message}") }
}
private fun fragmentTemplate(time: Float): 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()
Same as fragmentTemplate but drives input.w from a push constant so the
shader is built once and animated by updating pc.params.x per frame. The
block is named Push (not pc) to avoid glslang's block/instance clash.
private fun animatedFragmentTemplate(): String = """
#version 450
layout(location = 0) in vec2 fragUV;
layout(location = 0) out vec4 outColor;
layout(push_constant) uniform Push { vec4 params; } pc;
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), pc.params.x);
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()
Feedback: source is the previous ping-pong frame; the program samples it
(sample, prev, ema, …) so the image evolves each frame. No push time.
private fun feedbackFragmentTemplate(): 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 { vec4 params; } pc;
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), pc.params.x);
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()
private val FULLSCREEN_VERTEX = """
#version 450
layout(location = 0) out vec2 fragUV;
void main() {
// Fullscreen triangle from gl_VertexIndex, no vertex inputs.
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()
}