Skip to content

AlifeShaderSystemsSmoke.kt

src/jvmMain/kotlin/afield/examples/four/AlifeShaderSystemsSmoke.kt · 298 lines

package afield.examples.four

import afield.document.graph.Box
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.TexturePreviewCache
import afield.graphics.render.fullscreenTriangle
import afield.graphics.render.pointer
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.targets.PingPongRenderToTexture
import afield.graphics.vulkan.util.Options
import afield.graphics.vulkan.util.Window
import afield.math.linalg.Vector2f
import afield.math.linalg.vec
import afield.simulation.agents.ShaderForestFireSystem
import afield.simulation.agents.ShaderLatticeBoltzmannSystem
import org.lwjgl.vulkan.VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
import java.io.File
import java.util.Base64
import javax.imageio.ImageIO
import kotlin.math.pow
import kotlin.system.exitProcess

FIELD_TEST id: alife.shader-systems.smoke requiresDisplay: true runner: jvm-main entrypoint: afield.examples.four.AlifeShaderSystemsSmoke tags: [smoke, four, alife, fluid, lattice-boltzmann, forest-fire, gpu, image] timeoutSec: 300 expect: exitCode: 0 stdoutContains: - "alife shader systems tests PASSED"

object AlifeShaderSystemsSmoke

Runs ShaderLatticeBoltzmannSystem and ShaderForestFireSystem on a real renderer and reads their textures back, the way the lattice_boltzmann_cylinder and forest_fire starting points drive them: a module built on the system's own controlsParameters / fireInputParameters, added with addControls / addFireInput, drawn by a fullscreenTriangle.

Those transcripts are compile-only in CI because they open a window. This is what evaluates the calls they teach, and what would notice the failure each system's history actually had: the fluid's wind accelerating the whole tunnel to its speed limit, a drawn control that never reaches the fluid, a fire that never leaves the point it was lit at, a clear that does not clear.

Every number here is read through TexturePreviewCache, which blits to 8-bit sRGB, so values are decoded back to linear before they are compared with anything absolute.

object AlifeShaderSystemsSmoke {
    private val DATA_IMAGE_RE = Regex("base64,([^']+)")
    private val outputDir = File(System.getProperty("afield.alife.out") ?: "build/alife-shader-systems")
    private var failures = 0

fun main(args: Array<String>)

body — 121 lines, from line 56
    @JvmStatic
    fun main(args: Array<String>) {
        Options.enableVR = false
        Options.doVulkanSDK()
        outputDir.mkdirs()

        val window = Window("Field · alife shader systems")
        val renderer = Renderer(window)

        // Each system adopts its rule into the box it is built in, as it would in a document, so
        // each gets a box of its own: a second system built in the same box would find the first
        // one's shader tabs there and try to run its own textures through them.
        fun hostBox(name: String) = Box(name).apply {
            this.name = name
            setPropertyRaw("renderer", renderer, doResync = false)
            setPropertyRaw("window", window, doResync = false)
        }

        try {
            Box.withBoxContextNow(hostBox("alife-smoke-lbm")) { check("lattice Boltzmann") { latticeBoltzmann(renderer, window) } }
            Box.withBoxContextNow(hostBox("alife-smoke-fire")) { check("forest fire") { forestFire(renderer, window) } }
        } catch (t: Throwable) {
            println("FAIL: ${t.message}")
            t.printStackTrace()
            failures++
        } finally {
            runCatching { renderer.cleanup() }
            runCatching { window.cleanup() }
        }

        println(if (failures == 0) "alife shader systems tests PASSED — readbacks in ${outputDir.absolutePath}"
                else "alife shader systems tests FAILED ($failures)")
        exitProcess(if (failures == 0) 0 else 1)
    }

    private fun latticeBoltzmann(renderer: Renderer, window: Window) {
        val wind = 0.08f
        val lbm = ShaderLatticeBoltzmannSystem(renderer) {
            name = "smoke-lbm"
            width = 200
            height = 120
            baseVelocity = Vector2f(wind, 0f)
            relaxation = 1.85f
            driveBlend = 0f
            stepsPerFrame = 8
        }
        lbm.installInto(withPreview = false)

        // the starting point's cylinder box, built in Kotlin: a solid disc, walls, an inlet strip
        lbm.clearControls()
        val cylinder = Module(lbm.controlsParameters("smoke-cylinder") {
            vertex = FULLSCREEN_VERTEX
            fragment = CYLINDER_FRAGMENT
        }).pointer()
        quad(cylinder) {
            push.bind("center") { vec(50f, 60f) }
            push.bind("radius") { 10f }
            push.bind("height") { 120f }
        }
        lbm.addControls("smoke-cylinder", cylinder)

        pump(renderer, window, 300)

        val controls = capture(renderer, lbm.controls, 0, "lbm-controls")
        expect("the drawn disc is solid in the controls texture", controls.red(50, 60) > 0.9)
        expect("away from the disc and walls the controls are empty", controls.red(150, 30) < 0.05)

        // attachment 2 is (f8, density, velocity.x, velocity.y); velocity.x is in blue
        val velocity = capture(renderer, lbm.fluid, 2, "lbm-velocity")
        val upstream = velocity.blue(20, 30)
        val wake = velocity.blue(64, 60)
        println(" -- velocity.x upstream ${"%.4f".format(upstream)}, behind the cylinder ${"%.4f".format(wake)}")
        expect("the wind still blows at about its own speed upstream (not accelerated to the limit)",
            upstream in wind * 0.5..wind * 1.5)
        expect("the flow is slowed behind the cylinder", wake < upstream * 0.6)
        expect("the cylinder itself is at rest", velocity.blue(50, 60) < 0.01)

        lbm.clearControls()
        cylinder.current.meshes.clear()
        pump(renderer, window, 3)
        expect("clearControls() erases the disc", capture(renderer, lbm.controls, 0, "lbm-controls-cleared").red(50, 60) < 0.05)
    }

    private fun forestFire(renderer: Renderer, window: Window) {
        val fire = ShaderForestFireSystem(renderer) {
            name = "smoke-fire"
            width = 256
            height = 256
            treeDensity = 0.6f
        }
        fire.installInto(withPreview = false)

        val starter = Module(fire.fireInputParameters("smoke-starter") {
            vertex = FULLSCREEN_VERTEX
            fragment = STARTER_FRAGMENT
        }).pointer()
        quad(starter) {
            push.bind("red") { vec(128f, 128f) }
            push.bind("radius") { 4f }
        }
        fire.addFireInput("smoke-starter", starter)

        pump(renderer, window, 240)

        val state = capture(renderer, fire.state, 0, "fire-state")
        val lit = state.count { r, _, _ -> r > 0.03 }
        val far = state.count { r, _, _ -> r > 0.03 } - state.countWithin(128, 128, 20) { r, _, _ -> r > 0.03 }
        val others = state.count { _, g, b -> g > 0.03 || b > 0.03 } + state.countAlpha { it > 0.03 }
        println(" -- fire: $lit texels on fire or recovering, $far of them more than 20 texels from the point, $others of another kind")
        expect("the fire spreads well beyond the point it was lit at", far > 1000)
        // an opaque clear of the ignition texture once lit the alpha kind in every tree at once, which
        // left nothing ready for the red point to spread into
        expect("only the kind that was lit burns", others == 0)

        starter.current.meshes.clear()
        fire.clearFireInput()
        fire.clearState()
        pump(renderer, window, 3)
        val cleared = capture(renderer, fire.state, 0, "fire-state-cleared")
        expect("clearState() and clearFireInput() put every tree out", cleared.count { r, g, b -> r > 0.03 || g > 0.03 || b > 0.03 } + cleared.countAlpha { it > 0.03 } == 0)
    }

plumbing

source — 41 lines, from line 180
    private fun quad(module: ModulePointer, bind: afield.graphics.render.Mesh.() -> Unit) {
        module.current.meshes["quad"] = fullscreenTriangle(module).apply(bind)
    }

    private fun pump(renderer: Renderer, window: Window, frames: Int) {
        repeat(frames) {
            window.pollEvents()
            renderer.render()
        }
    }

    private fun capture(renderer: Renderer, target: PingPongRenderToTexture, attachment: Int, name: String): Shot {
        val html = TexturePreviewCache.capture(
            renderer = renderer,
            vkImage = target.colorImageHandles[attachment],
            format = target.colorFormats[attachment],
            srcWidth = target.width,
            srcHeight = target.height,
            currentLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
            maxDim = maxOf(target.width, target.height)
        ) ?: error("could not read back '$name'")
        val encoded = DATA_IMAGE_RE.find(html)?.groupValues?.get(1) ?: error("could not parse the readback of '$name'")
        val file = File(outputDir, "$name.png")
        file.writeBytes(Base64.getDecoder().decode(encoded))
        return Shot(name, file)
    }

    private fun check(what: String, body: () -> Unit) {
        try {
            body()
        } catch (t: Throwable) {
            println("FAIL: $what — ${t.message}")
            t.printStackTrace()
            failures++
        }
    }

    private fun expect(what: String, ok: Boolean) {
        println(if (ok) "ok: $what" else "FAIL: $what")
        if (!ok) failures++
    }

One readback, decoded to linear light per channel.

    private class Shot(val name: String, file: File) {
        private val image = ImageIO.read(file) ?: error("could not re-read $name")
        val width = image.width
        val height = image.height

        private fun channel(x: Int, y: Int, shift: Int) = srgbToLinear((image.getRGB(x, y) shr shift and 0xff) / 255.0)
        fun red(x: Int, y: Int) = channel(x, y, 16)
        fun green(x: Int, y: Int) = channel(x, y, 8)
        fun blue(x: Int, y: Int) = channel(x, y, 0)
        /** Alpha is data here (the fourth kind of fire), and is stored linearly. */
        fun alpha(x: Int, y: Int) = (image.getRGB(x, y) ushr 24 and 0xff) / 255.0

        fun count(test: (Double, Double, Double) -> Boolean): Int =
            (0 until height).sumOf { y -> (0 until width).count { x -> test(red(x, y), green(x, y), blue(x, y)) } }

        fun countAlpha(test: (Double) -> Boolean): Int =
            (0 until height).sumOf { y -> (0 until width).count { x -> test(alpha(x, y)) } }

        fun countWithin(cx: Int, cy: Int, radius: Int, test: (Double, Double, Double) -> Boolean): Int =
            (0 until height).sumOf { y ->
                (0 until width).count { x ->
                    (x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius && test(red(x, y), green(x, y), blue(x, y))
                }
            }
    }

    private fun srgbToLinear(c: Double) = if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)

    private val FULLSCREEN_VERTEX = """
        #version 450

        void main() {
            vec2 p = gl_VertexIndex == 0 ? vec2(-1.0, -1.0) : (gl_VertexIndex == 1 ? vec2(3.0, -1.0) : vec2(-1.0, 3.0));
            gl_Position = vec4(p, 0.0, 1.0);
        }
    """.trimIndent()

the cylinder box of the lattice_boltzmann_cylinder starting point

    private val CYLINDER_FRAGMENT = """
        #version 450

        layout(push_constant) uniform Push {
            vec2 center;
            float radius;
            float height;
        } push;

        layout(location = 0) out vec4 outColor;

        void main() {
            vec2 p = gl_FragCoord.xy;
            outColor = vec4(0.0);
            if (distance(p, push.center) < push.radius) outColor.r = 1.0;
            if (p.y < 3.0 || p.y > push.height - 3.0) outColor.r = 1.0;
            if (p.x < 4.0 && mod(floor(p.y / 12.0), 2.0) == 0.0) outColor.a = 1.0;
        }
    """.trimIndent()

one permanent red ignition point, as the forest_fire starting point's starter draws two

    private val STARTER_FRAGMENT = """
        #version 450

        layout(push_constant) uniform Push {
            vec2 red;
            float radius;
        } push;

        layout(location = 0) out vec4 outColor;

        void main() {
            outColor = vec4(0.0);
            if (distance(gl_FragCoord.xy, push.red) < push.radius) outColor.r = 1.0;
        }
    """.trimIndent()
}