Skip to content

alife_fire.kt

src/jvmMain/kotlin/afield/simulation/agents/alife_fire.kt · 749 lines

@file:afield.runtime.completion.CompletionCategory("ALife")
@file:Suppress("MemberVisibilityCanBePrivate")

package afield.simulation.agents

import afield.app.protocol.Rect
import afield.document.context.fromContext
import afield.document.graph.Box
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.Parameters
import afield.graphics.render.fullscreenTriangle
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.shader.Introspected
import afield.graphics.shader.attachShader
import afield.graphics.shader.fromViews
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.ImageView
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.PingPongRenderToTexture
import afield.graphics.vulkan.targets.RenderTarget
import afield.math.linalg.Vec4
import afield.math.linalg.vec
import org.lwjgl.vulkan.VK10.VK_ATTACHMENT_STORE_OP_STORE
import org.lwjgl.vulkan.VK10.VK_BLEND_FACTOR_ONE
import org.lwjgl.vulkan.VK10.VK_BLEND_OP_ADD
import org.lwjgl.vulkan.VK10.VK_FORMAT_R16G16B16A16_SFLOAT

Fire spreading through a forest on the GPU: a fixed scatter of trees, fire that you light by drawing into a texture, and four kinds of fire, one per colour channel, that compete for the same trees.

The rule

Each texel is a tree or bare ground, decided once by a hash of its position and ShaderForestFireSettings.randomSeed, so the forest is the same every run with the same seed. A tree is ready, burning or recovering, and which it is lives in one number per kind of fire: 1 the frame it catches, then falling by that kind's ShaderForestFireSettings.refractoryDecayPerFrame each frame. Above ShaderForestFireSettings.activeFireThreshold it is burning; below ShaderForestFireSettings.readyThreshold it can catch again; in between it is recovering and cannot. A ready tree catches when a burning neighbour's fire, times ShaderForestFireSettings.spreadGain, reaches ShaderForestFireSettings.ignitionThreshold and a random draw against ShaderForestFireSettings.spreadProbability lets it across, or when the ignition texture at that texel reaches the threshold. If several kinds could take it, the strongest wins, and catching one kind sets the other three to zero.

Because trees recover, this is an excitable medium rather than a fire that leaves ash: a front passes, the forest behind it recovers, and a second front can follow the first. A front broken by bare ground curls at the break, and steady ignition makes a train of rings.

What is in each texture

ShaderForestFireSystem.state holds the four fire levels in RGBA; it is what imageView returns. ShaderForestFireSystem.fireInput is where ignition is drawn, RGBA one kind per channel, carried forward and faded by ShaderForestFireSettings.ignitionDecayPerFrame. ShaderForestFireSystem.ink is a colour that burning trees pass on to their neighbours, so that what you draw into it travels with the fire. The preview colours each kind red, green, blue or magenta, tinted by the ink.

Lighting it

Nothing burns until something is lit. Draw into ShaderForestFireSystem.fireInput with a module built on ShaderForestFireSystem.fireInputParameters, which blends additively: a fragment writing vec4(1, 0, 0, 0) lights the red kind there. As with ShaderDlaSystem, constructing the system inside a box seeds that box's fragment tab with the spreading rule, so the rule arrives as source you can edit.

data class FireChannels

Four numbers, one per kind of fire, red to alpha.

data class FireChannels(
    /** Kind 0, drawn red. */
    val r: Float = 0f,
    /** Kind 1, drawn green. */
    val g: Float = 0f,
    /** Kind 2, drawn blue. */
    val b: Float = 0f,
    /** Kind 3, drawn magenta. */
    val a: Float = 0f
) {

operator fun times(scale: Float) = FireChannels(r * scale, g * scale, b * scale, a * scale)

All four scaled.

fun toVec4(): Vec4 = vec(r, g, b, a)

As a vec4, for a push constant.

companion object

    companion object {

fun kind(index: Int, strength: Float = 1f): FireChannels

strength in the channel of kind index, 0 to 3, and zero in the others.

        fun kind(index: Int, strength: Float = 1f): FireChannels = when (index) {
            0 -> FireChannels(r = strength)
            1 -> FireChannels(g = strength)
            2 -> FireChannels(b = strength)
            3 -> FireChannels(a = strength)
            else -> throw IllegalArgumentException("fire kind index must be in 0..3, got $index")
        }
    }
}

data class ShaderForestFireSettings

Everything ShaderForestFireSystem reads. All but the sizes and format are bound as lambdas, so changing one while it runs takes effect on the next frame; changing the forest itself (treeDensity, treeScalePx, treeJitter, randomSeed) regrows it in place.

body — 52 lines, from line 110
data class ShaderForestFireSettings(
    /** Prefix for module, mesh and pre-target names. Two systems in one renderer need two names. */
    var name: String = "forest-fire",
    /** Forest width in texels. */
    var width: Int = 1024,
    /** Forest height in texels. */
    var height: Int = 1024,
    /** Format of all three textures. */
    var format: Int = VK_FORMAT_R16G16B16A16_SFLOAT,
    /**
     * Clear colour of every texture. Transparent black, because alpha is not opacity here: it is the
     * fourth kind of fire, and an opaque clear would light all of it at once.
     */
    var clearColor: Vec4 = vec(0, 0, 0, 0),
    /** The fraction of texels that are trees. Below about 0.4 fire stops crossing the gaps. */
    var treeDensity: Float = 0.72f,
    /** Size in texels of the coarse cells the forest is hashed on, so trees grow in clumps. */
    var treeScalePx: Float = 3f,
    /** How much per-texel noise is mixed into the clumps, 0 to 1. */
    var treeJitter: Float = 0.28f,
    /** How far, in texels, a tree looks for burning neighbours. */
    var spreadStepPx: Float = 1f,
    /** Gain on a burning neighbour's fire before it is compared with [ignitionThreshold]. */
    var spreadGain: Float = 1.02f,
    /**
     * The chance, each frame, that one burning neighbour lights a ready tree. At 1 every front
     * moves one texel a frame in all eight directions and comes out square; below 1 it moves by
     * chance, slower on average, and rounds itself into a ragged circle.
     */
    var spreadProbability: Float = 0.5f,
    /** Gain on the ignition texture before it is compared with [ignitionThreshold]. */
    var paintGain: Float = 1.0f,
    /** The signal at or above which a ready tree catches. */
    var ignitionThreshold: Float = 0.33f,
    /** The fire level at or above which a tree is burning. */
    var activeFireThreshold: Float = 0.82f,
    /** The fire level at or below which a tree can catch again. */
    var readyThreshold: Float = 0.03f,
    /**
     * How fast each kind's fire level falls per frame. With the default thresholds red burns for 18
     * frames and is ready again after 97; alpha takes two and a half times as long.
     */
    var refractoryDecayPerFrame: FireChannels = FireChannels(0.010f, 0.008f, 0.006f, 0.004f),
    /** How fast the ignition texture fades per frame, per kind. */
    var ignitionDecayPerFrame: FireChannels = FireChannels(0.002f, 0.002f, 0.002f, 0.002f),
    /** How fast the ink fades per frame, per channel. */
    var inkDecayPerFrame: FireChannels = FireChannels(0.004f, 0.004f, 0.004f, 0.004f),
    /** The fraction of a burning neighbour's ink a burning tree takes on. */
    var inkSpreadGain: Float = 0.92f,
    /** Seed for the forest. */
    var randomSeed: Long = 1L
)

class ShaderForestFireSystem

A forest-fire excitable medium: ignition, fire state and ink, each a full-screen pass into its own ping-pong target. Build one, call installInto, then light it with a module from fireInputParameters. The file overview above describes the rule and the encoding.

class ShaderForestFireSystem(
    /** The renderer to build the targets and modules on; by default the one this box is running in. */
    val renderer: Renderer = fromContext(),
    configure: ShaderForestFireSettings.() -> Unit = {}
) : Target, HasImageView, HasImageViewArray {

val settings = ShaderForestFireSettings().apply(configure)

The settings this system was built with, after configure ran, and the handle for changing it live.

val fireInput = pingPong()

Where ignition is drawn, one kind of fire per channel. Add modules to it with addFireInput.

val state = pingPong()

The four fire levels per tree. This is what imageView returns.

val ink = pingPong()

Colour carried from tree to tree by the fire. Draw into it with addInk.

val fireInputPreserveModule: ModulePointer

The pass that carries fireInput forward a frame and fades it.

val stateUpdateModule: ModulePointer

The spreading rule. The box adopts this one.

val inkPreserveModule: ModulePointer

The pass that carries ink forward a frame and fades it.

val inkPropagateModule: ModulePointer

The pass that hands ink from burning trees to their burning neighbours. Always runs last on ink.

    private val fireInputSampler = TextureSampler(renderer, 1, false)
    private val stateSampler = TextureSampler(renderer, 1, false)
    private val inkSampler = TextureSampler(renderer, 1, false)

the first frame starts from nothing, whatever the targets were cleared to

    private var clearFireInputRequested = true
    private var clearStateRequested = true
    private var clearInkRequested = true
    private var frame = 0L

init

body — 66 lines, from line 207
    init {
        require(settings.width > 0) { "width must be positive" }
        require(settings.height > 0) { "height must be positive" }
        require(settings.treeScalePx > 0f) { "treeScalePx must be positive" }

        fireInputPreserveModule = fullscreenPass(fireInput, "fire-input-preserve", PRESERVE_FRAGMENT, blend = false) { tex ->
            if (tex.name == "sourceTex") fromViews(listOf(fireInput.otherImageView), tex.name, fireInputSampler) else null
        }
        fireInputPreserveModule.quad {
            push.bind("decay") { settings.ignitionDecayPerFrame.toVec4() }
            push.bind("clearRequested") { if (clearFireInputRequested) 1f else 0f }
        }
        fireInput += "preserve" to fireInputPreserveModule

        // the state reads its own previous frame and the ignition this frame has just drawn
        stateUpdateModule = fullscreenPass(state, "state-update", STATE_UPDATE_FRAGMENT, blend = false) { tex ->
            when (tex.name) {
                "sourceTex" -> fromViews(listOf(state.otherImageView), tex.name, stateSampler)
                "ignitionTex" -> fromViews(listOf(fireInput.latestImageView), tex.name, fireInputSampler)
                else -> null
            }
        }
        stateUpdateModule.quad {
            push.bind("refractoryDecay") { settings.refractoryDecayPerFrame.toVec4() }
            push.bind("spreadStepPx") { settings.spreadStepPx }
            push.bind("spreadGain") { settings.spreadGain }
            push.bind("paintGain") { settings.paintGain }
            push.bind("ignitionThreshold") { settings.ignitionThreshold }
            push.bind("activeFireThreshold") { settings.activeFireThreshold }
            push.bind("readyThreshold") { settings.readyThreshold }
            push.bind("treeDensity") { settings.treeDensity }
            push.bind("treeScalePx") { settings.treeScalePx }
            push.bind("treeJitter") { settings.treeJitter }
            push.bind("seed") { settings.randomSeed.toInt() }
            push.bind("clearRequested") { if (clearStateRequested) 1f else 0f }
            push.bind("spreadProbability") { settings.spreadProbability }
            push.bind("frame") { frame.toInt() }
        }
        state += "update" to stateUpdateModule

        inkPreserveModule = fullscreenPass(ink, "ink-preserve", PRESERVE_FRAGMENT, blend = false) { tex ->
            if (tex.name == "sourceTex") fromViews(listOf(ink.otherImageView), tex.name, inkSampler) else null
        }
        inkPreserveModule.quad {
            push.bind("decay") { settings.inkDecayPerFrame.toVec4() }
            push.bind("clearRequested") { if (clearInkRequested) 1f else 0f }
        }
        ink += "preserve" to inkPreserveModule

        inkPropagateModule = fullscreenPass(ink, "ink-propagate", INK_PROPAGATE_FRAGMENT, blend = true) { tex ->
            when (tex.name) {
                "sourceTex" -> fromViews(listOf(ink.otherImageView), tex.name, inkSampler)
                "stateTex" -> fromViews(listOf(state.latestImageView), tex.name, stateSampler)
                else -> null
            }
        }
        inkPropagateModule.quad {
            push.bind("decay") { settings.inkDecayPerFrame.toVec4() }
            push.bind("activeFireThreshold") { settings.activeFireThreshold }
            push.bind("spreadStepPx") { settings.spreadStepPx }
            push.bind("inkSpreadGain") { settings.inkSpreadGain }
        }
        ink += "propagate" to inkPropagateModule

        Box.context.get()?.attachShader(stateUpdateModule)
    }

override val imageView: ImageView

The fire levels, so a system can be sampled by another module the way a loaded image would be.

    override val imageView: ImageView
        get() = state.imageView

override val imageViewArray: List<HasImageView>

The same texture, in the list form the array-sampling path asks for.

    override val imageViewArray: List<HasImageView>
        get() = state.imageViewArray

override fun render(command: CommandBuffer, index: Int)

Runs ignition, the spreading rule and the ink, in that order. Called by whatever the system is registered with; see installInto.

    override fun render(command: CommandBuffer, index: Int) {
        frame++
        fireInput.render(command, index)
        state.render(command, index)
        ink.render(command, index)
        clearFireInputRequested = false
        clearStateRequested = false
        clearInkRequested = false
    }

fun clearFireInput()

Erase all drawn ignition on the next frame.

    fun clearFireInput() {
        clearFireInputRequested = true
    }

fun clearState()

Put every tree out and make it ready on the next frame.

    fun clearState() {
        clearStateRequested = true
    }

fun clearInk()

Erase the ink on the next frame.

    fun clearInk() {
        clearInkRequested = true
    }

fun reset()

clearFireInput, clearState and clearInk together.

    fun reset() {
        clearFireInputRequested = true
        clearStateRequested = true
        clearInkRequested = true
    }

fun addFireInput(name: String, module: ModulePointer)

Add a module that draws into fireInput; it runs after the carry-forward pass, every frame.

    fun addFireInput(name: String, module: ModulePointer) {
        fireInput += name to module
    }

fun addFireInput(name: String, module: Module)

addFireInput for a module that has not been given a pointer yet.

    fun addFireInput(name: String, module: Module) {
        addFireInput(name, module.pointer())
    }

fun addInk(name: String, module: ModulePointer)

Add a module that draws into ink; it runs before the propagate pass, so the fire carries what it draws.

    fun addInk(name: String, module: ModulePointer) {
        ink += name to module
        val propagate = ink.modules.remove("propagate")
        if (propagate != null) ink.modules["propagate"] = propagate
    }

fun addInk(name: String, module: Module)

addInk for a module that has not been given a pointer yet.

    fun addInk(name: String, module: Module) {
        addInk(name, module.pointer())
    }

fun fireInputParameters

Parameters for a module that draws into fireInput: additive blending, so writing vec4(1, 0, 0, 0) adds red fire and a negative value erases it (the target is floating point), and the previous ignition available as sourceTex. Hand the result to moduleHere(...) in a box, then to addFireInput.

    fun fireInputParameters(
        name: String,
        configure: Parameters.() -> Unit = {}
    ): Parameters = additiveParameters(fireInput, name, configure) { tex ->
        if (tex.name == "sourceTex") fromViews(listOf(fireInput.otherImageView), tex.name, fireInputSampler) else null
    }

fun inkParameters

Parameters for a module that draws into ink, additive, with the previous ink as sourceTex.

    fun inkParameters(
        name: String,
        configure: Parameters.() -> Unit = {}
    ): Parameters = additiveParameters(ink, name, configure) { tex ->
        if (tex.name == "sourceTex") fromViews(listOf(ink.otherImageView), tex.name, inkSampler) else null
    }

fun installInto

Wire the simulation into a render target as a pre-target and, by default, show it with blitToScreen. Returns the preview module, or null with withPreview = false.

    fun installInto(
        renderTarget: RenderTarget = renderer.renderList,
        withPreview: Boolean = true,
        previewName: String = "${settings.name}-preview",
        previewViewport: Rect? = null
    ): ModulePointer? {
        renderTarget.preTargets["${settings.name}-simulation"] = this
        return if (withPreview) blitToScreen(target = renderTarget, name = previewName, viewport = previewViewport) else null
    }

fun blitToScreen

Draw the forest to a target: each kind of fire in its colour, bright while burning and dim while recovering, tinted by the ink. viewport is where it lands.

    fun blitToScreen(
        target: RenderTarget = renderer.renderList,
        name: String = "${settings.name}-preview",
        viewport: Rect? = null
    ): ModulePointer {
        val module = Module(
            parameters(renderer) {
                this.name = name
                depthTest = false
                blend = false
                this.viewport = viewport
                vertex = PREVIEW_VERTEX
                fragment = PREVIEW_FRAGMENT
                textureResolver = { tex ->
                    when (tex.name) {
                        "stateTex" -> fromViews(listOf(state.latestImageView), tex.name, stateSampler)
                        "inkTex" -> fromViews(listOf(ink.latestImageView), tex.name, inkSampler)
                        else -> null
                    }
                }
            }
        ).pointer()
        module.quad(name) {
            push.bind("activeFireThreshold") { settings.activeFireThreshold }
            push.bind("readyThreshold") { settings.readyThreshold }
        }
        target += name to module
        return module
    }

fun blitFireInputToScreen

Draw the raw ignition texture.

    fun blitFireInputToScreen(
        target: RenderTarget = renderer.renderList,
        name: String = "${settings.name}-fire-input-preview",
        viewport: Rect? = null,
        sourceRect: Rect? = null
    ): ModulePointer = fireInput.debugBlitToScreen(renderer = renderer, target = target, name = name, viewport = viewport, sourceRect = sourceRect)

fun blitInkToScreen

Draw the raw ink texture.

    fun blitInkToScreen(
        target: RenderTarget = renderer.renderList,
        name: String = "${settings.name}-ink-preview",
        viewport: Rect? = null,
        sourceRect: Rect? = null
    ): ModulePointer = ink.debugBlitToScreen(renderer = renderer, target = target, name = name, viewport = viewport, sourceRect = sourceRect)

A plain blitToScreen on one of the ping-pongs would register it as a second pre-target, so it would advance twice a frame outside render(); debugBlitToScreen draws the latest frame without registering anything.

source — 56 lines, from line 426
    private fun pingPong() = PingPongRenderToTexture(renderer) {
        width = settings.width
        height = settings.height
        format = settings.format
        colorFormats = listOf(settings.format)
        clearColor = settings.clearColor
        colorStoreOp = VK_ATTACHMENT_STORE_OP_STORE
        colorSampled = true
        useDepth = false
    }

    private fun Parameters.additive() {
        blend = true
        srcColor = VK_BLEND_FACTOR_ONE
        dstColor = VK_BLEND_FACTOR_ONE
        colorBlendOp = VK_BLEND_OP_ADD
        srcAlpha = VK_BLEND_FACTOR_ONE
        dstAlpha = VK_BLEND_FACTOR_ONE
        alphaBlendOp = VK_BLEND_OP_ADD
    }

    private fun fullscreenPass(
        target: PingPongRenderToTexture,
        suffix: String,
        fragmentSource: String,
        blend: Boolean,
        resolver: (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>?
    ): ModulePointer = Module(
        parameters(renderer) {
            +target
            name = "${settings.name}-$suffix"
            depthTest = false
            if (blend) additive() else this.blend = false
            vertex = FULLSCREEN_TRIANGLE_VERTEX
            fragment = fragmentSource
            textureResolver = resolver
        }
    ).pointer()

    private fun additiveParameters(
        target: PingPongRenderToTexture,
        name: String,
        configure: Parameters.() -> Unit,
        resolver: (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>?
    ): Parameters = parameters(renderer) {
        +target
        this.name = name
        depthTest = false
        additive()
        textureResolver = resolver
        configure()
    }

    private fun ModulePointer.quad(name: String = current.parameters.name, bind: Mesh.() -> Unit) {
        current.meshes["$name-quad"] = fullscreenTriangle(this).apply(bind)
    }

companion object

declaration — 92 lines, from line 483
    companion object {
        private val FULLSCREEN_TRIANGLE_VERTEX = """
            #version 450

            vec2 fullscreenVertex(uint index) {
                if (index == 0u) return vec2(-1.0, -1.0);
                if (index == 1u) return vec2(3.0, -1.0);
                return vec2(-1.0, 3.0);
            }

            void main() {
                gl_Position = vec4(fullscreenVertex(uint(gl_VertexIndex)), 0.0, 1.0);
            }
        """.trimIndent()

        private val PRESERVE_FRAGMENT = """
            #version 450

            layout(set = 0, binding = 0) uniform sampler2D sourceTex;

            layout(push_constant) uniform Push {
                vec4 decay;
                float clearRequested;
            } push;

            layout(location = 0) out vec4 outColor;

            void main() {
                ivec2 dims = textureSize(sourceTex, 0);
                vec4 previous = texelFetch(sourceTex, clamp(ivec2(gl_FragCoord.xy), ivec2(0), dims - 1), 0);
                outColor = push.clearRequested > 0.5 ? vec4(0.0) : clamp(previous - push.decay, vec4(0.0), vec4(1.0));
            }
        """.trimIndent()

        private val INK_PROPAGATE_FRAGMENT = """
            #version 450

            layout(set = 0, binding = 0) uniform sampler2D sourceTex;
            layout(set = 0, binding = 1) uniform sampler2D stateTex;

            layout(push_constant) uniform Push {
                vec4 decay;
                float activeFireThreshold;
                float spreadStepPx;
                float inkSpreadGain;
            } push;

            layout(location = 0) out vec4 outColor;

            float max4(vec4 v) {
                return max(max(v.x, v.y), max(v.z, v.w));
            }

            float burning(ivec2 p) {
                float denom = max(1.0 - push.activeFireThreshold, 0.0001);
                return clamp((max4(texelFetch(stateTex, p, 0)) - push.activeFireThreshold) / denom, 0.0, 1.0);
            }

            vec4 inkFrom(ivec2 pixel, ivec2 dims, float weight) {
                ivec2 p = clamp(pixel, ivec2(0), dims - 1);
                return texelFetch(sourceTex, p, 0) * (burning(p) * weight);
            }

            // added to the preserve pass's output, so the result is the larger of the faded ink here
            // and the strongest ink handed on by a burning neighbour
            void main() {
                ivec2 dims = textureSize(sourceTex, 0);
                ivec2 pixel = clamp(ivec2(gl_FragCoord.xy), ivec2(0), dims - 1);
                if (burning(pixel) <= 0.0) {
                    outColor = vec4(0.0);
                    return;
                }

                vec4 previous = texelFetch(sourceTex, pixel, 0);
                vec4 decayed = max(previous - push.decay, vec4(0.0));

                int s = max(int(round(push.spreadStepPx)), 1);
                float axial = push.inkSpreadGain;
                float diagonal = push.inkSpreadGain * 0.7071;
                vec4 carried = previous;
                carried = max(carried, inkFrom(pixel + ivec2(s, 0), dims, axial));
                carried = max(carried, inkFrom(pixel + ivec2(-s, 0), dims, axial));
                carried = max(carried, inkFrom(pixel + ivec2(0, s), dims, axial));
                carried = max(carried, inkFrom(pixel + ivec2(0, -s), dims, axial));
                carried = max(carried, inkFrom(pixel + ivec2(s, s), dims, diagonal));
                carried = max(carried, inkFrom(pixel + ivec2(-s, s), dims, diagonal));
                carried = max(carried, inkFrom(pixel + ivec2(s, -s), dims, diagonal));
                carried = max(carried, inkFrom(pixel + ivec2(-s, -s), dims, diagonal));

                outColor = max(carried - decayed, vec4(0.0));
            }
        """.trimIndent()

The preview stretches the whole texture over its viewport, so it samples by uv, not by pixel.

source — 53 lines, from line 577
        private val PREVIEW_VERTEX = """
            #version 450

            layout(location = 0) out vec2 uv;

            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));
                uv = p * 0.5 + 0.5;
                gl_Position = vec4(p, 0.0, 1.0);
            }
        """.trimIndent()

        private val PREVIEW_FRAGMENT = """
            #version 450

            layout(set = 0, binding = 0) uniform sampler2D stateTex;
            layout(set = 0, binding = 1) uniform sampler2D inkTex;

            layout(push_constant) uniform Push {
                float activeFireThreshold;
                float readyThreshold;
            } push;

            layout(location = 0) in vec2 uv;
            layout(location = 0) out vec4 outColor;

            float max4(vec4 v) {
                return max(max(v.x, v.y), max(v.z, v.w));
            }

            vec3 fireKindColor(vec4 s) {
                return s.r * vec3(1.0, 0.18, 0.05) + s.g * vec3(0.15, 1.0, 0.18)
                    + s.b * vec3(0.15, 0.45, 1.0) + s.a * vec3(1.0, 0.2, 0.95);
            }

            void main() {
                ivec2 dims = textureSize(stateTex, 0);
                ivec2 pixel = clamp(ivec2(uv * vec2(dims)), ivec2(0), dims - 1);
                vec4 state = texelFetch(stateTex, pixel, 0);
                vec4 ink = texelFetch(inkTex, pixel, 0);

                float level = max4(state);
                float burning = clamp((level - push.activeFireThreshold) / max(1.0 - push.activeFireThreshold, 0.0001), 0.0, 1.0);
                float recovering = clamp((level - push.readyThreshold) / max(push.activeFireThreshold - push.readyThreshold, 0.0001), 0.0, 1.0) * (1.0 - burning);

                vec3 base = fireKindColor(state);
                vec3 inkColor = clamp(ink.rgb + ink.a * vec3(1.0, 0.15, 0.95), vec3(0.0), vec3(1.0));
                vec3 fire = mix(base, inkColor, clamp(max4(ink), 0.0, 1.0));

                vec3 color = fire * (0.12 * recovering + burning) + base * 0.03 * recovering;
                outColor = vec4(color, max(burning, recovering));
            }
        """.trimIndent()

val STATE_UPDATE_FRAGMENT

The spreading rule as GLSL, the source the box's fragment tab is seeded with.

body — 117 lines, from line 632
        val STATE_UPDATE_FRAGMENT = """
            #version 450

            layout(set = 0, binding = 0) uniform sampler2D sourceTex;
            layout(set = 0, binding = 1) uniform sampler2D ignitionTex;

            layout(push_constant) uniform Push {
                vec4 refractoryDecay;
                float spreadStepPx;
                float spreadGain;
                float paintGain;
                float ignitionThreshold;
                float activeFireThreshold;
                float readyThreshold;
                float treeDensity;
                float treeScalePx;
                float treeJitter;
                int seed;
                float clearRequested;
                float spreadProbability;
                int frame;
            } push;

            layout(location = 0) out vec4 outColor;

            uint hash(uint x) {
                x ^= x >> 16u; x *= 0x7feb352du;
                x ^= x >> 15u; x *= 0x846ca68bu;
                x ^= x >> 16u;
                return x;
            }

            float random01(uvec2 p, uint salt) {
                return float(hash(p.x ^ hash(p.y ^ hash(uint(push.seed) ^ salt)))) / 4294967295.0;
            }

            // clumps on a coarse grid, roughened per texel by treeJitter
            bool isTree(ivec2 pixel) {
                uvec2 coarse = uvec2(floor(vec2(pixel) / max(push.treeScalePx, 1.0)));
                float mixed = mix(random01(coarse, 1u), random01(uvec2(pixel), 2u), clamp(push.treeJitter, 0.0, 1.0));
                return mixed <= push.treeDensity;
            }

            float max4(vec4 v) {
                return max(max(v.x, v.y), max(v.z, v.w));
            }

            // how strongly each kind is burning, 0 at activeFireThreshold, 1 at full
            vec4 burning(ivec2 p, ivec2 dims) {
                vec4 s = texelFetch(sourceTex, clamp(p, ivec2(0), dims - 1), 0);
                return clamp((s - vec4(push.activeFireThreshold)) / max(1.0 - push.activeFireThreshold, 0.0001), vec4(0.0), vec4(1.0));
            }

            // a burning neighbour's fire, if this frame's draw lets it across to this tree
            vec4 reaching(ivec2 pixel, ivec2 offset, ivec2 dims, uint salt) {
                uvec2 u = uvec2(pixel);
                float draw = random01(u, hash(uint(push.frame) * 8u + salt) | 4u);
                return draw < push.spreadProbability ? burning(pixel + offset, dims) : vec4(0.0);
            }

            // the kind with the strongest signal at or above the threshold, among those allowed; -1 for none
            int strongest(vec4 signal, bvec4 allowed) {
                int winner = -1;
                float best = -1.0;
                for (int k = 0; k < 4; ++k) {
                    if (allowed[k] && signal[k] >= push.ignitionThreshold && signal[k] > best) {
                        best = signal[k];
                        winner = k;
                    }
                }
                return winner;
            }

            vec4 caught(int kind) {
                vec4 v = vec4(0.0);
                v[kind] = 1.0;
                return v;
            }

            void main() {
                ivec2 dims = textureSize(sourceTex, 0);
                ivec2 pixel = clamp(ivec2(gl_FragCoord.xy), ivec2(0), dims - 1);

                if (push.clearRequested > 0.5 || !isTree(pixel)) {
                    outColor = vec4(0.0);
                    return;
                }

                vec4 previous = texelFetch(sourceTex, pixel, 0);
                vec4 decayed = max(previous - push.refractoryDecay, vec4(0.0));

                // drawn ignition takes any tree, burning or not
                int lit = strongest(texelFetch(ignitionTex, pixel, 0) * push.paintGain, bvec4(true));
                if (lit >= 0) {
                    outColor = caught(lit);
                    return;
                }

                if (max4(previous) >= push.activeFireThreshold) {
                    outColor = decayed;
                    return;
                }

                // each of the eight neighbours gets its own draw against spreadProbability
                int s = max(int(round(push.spreadStepPx)), 1);
                vec4 neighbours = max(max(max(reaching(pixel, ivec2(s, 0), dims, 0u), reaching(pixel, ivec2(-s, 0), dims, 1u)),
                                          max(reaching(pixel, ivec2(0, s), dims, 2u), reaching(pixel, ivec2(0, -s), dims, 3u))),
                                      0.7071 * max(max(reaching(pixel, ivec2(s, s), dims, 4u), reaching(pixel, ivec2(-s, s), dims, 5u)),
                                                   max(reaching(pixel, ivec2(s, -s), dims, 6u), reaching(pixel, ivec2(-s, -s), dims, 7u))));

                // a neighbour's fire takes a tree only if that kind has recovered there
                int spread = strongest(neighbours * push.spreadGain, lessThanEqual(decayed, vec4(push.readyThreshold)));
                outColor = spread >= 0 ? caught(spread) : decayed;
            }
        """.trimIndent()
    }
}