Skip to content

alife_trail.kt

src/jvmMain/kotlin/afield/simulation/agents/alife_trail.kt · 418 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.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.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.Vector2f
import afield.math.linalg.vec
import afield.media.image.Img
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
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CompletableFuture
import kotlin.math.pow

A stigmergy field on the GPU: a four-channel trail texture that agents write into and read from, and that diffuses, decays and drifts on its own between their visits.

This is the medium of every "leave a mark, follow the marks" system: Physarum's chemoattractant, ant pheromone, the dust in a particle system that remembers where the crowd went. TrailField owns only the medium. Whatever writes into it is a deposit module you add with addDeposit, built on depositParameters; ShaderPhysarumSystem is one such writer, and its agent shader is the worked example of reading the field back.

One frame

The field is an RGBA16F PingPongRenderToTexture. Each frame, first, a full-screen evolve pass rebuilds the field from the previous frame: it samples the old trail at p - wind (bilinear, so sub-pixel wind drifts smoothly), mixes in the 3x3 mean by TrailFieldSettings.diffusion, and multiplies each channel by its decay. Then every deposit module draws, additively, into the same target, reading the previous frame through trailTex. The four channels are independent trails (four species, or one trail plus three other kinds of mark); each has its own half-life.

The field is toroidal: sampling and diffusion wrap at the edges.

Reading it from a shader

#include "trail/sample" gives trailAt(trailTex, px) (bilinear, wrapping, pixel coordinates with pixel centres at +0.5), trailGradient(trailTex, px, weights, h), and trailClip(px, dims) for placing a deposit point at a pixel. The same source is TrailShaders.SAMPLE for code that builds shader strings itself.

Reading it from Kotlin

read queues a copy of the raw floats and completes with an Img (four channels, unclamped); channel(c) of that is what Img.topShare takes.

data class TrailFieldSettings

Everything TrailField reads: size, per-channel half-lives, diffusion, wind and format. All but the size and format are read every frame.

data class TrailFieldSettings(
    /** Prefix for the modules and render-list entries this field registers. Two fields in one renderer need two names. */
    var name: String = "trail",
    /** Width of the field in pixels; agents built on it step in these pixels. */
    var width: Int = 1024,
    /** Height of the field in pixels. */
    var height: Int = 1024,
    /**
     * Per-channel half-life in frames: after this many frames with nothing deposited, a channel holds
     * half of what it had. Negative holds a channel for ever; zero clears it every frame. The CPU
     * Physarum's `decay = 0.92` per step is a half-life of about 8.3 frames.
     */
    var halfLifeFrames: Vec4 = vec(8.3f, 8.3f, 8.3f, 8.3f),
    /**
     * How much of each pixel is replaced by its 3x3 neighbourhood mean per frame, from 0 (no
     * diffusion) to 1 (a full box blur of radius one, the CPU Physarum's `blurRadius = 1`).
     */
    var diffusion: Float = 1f,
    /** Drift of the whole field in pixels per frame, applied by sampling upstream. Zero by default. */
    var windPx: Vector2f = Vector2f(0f, 0f),
    /** Colour format. A float format is needed: the trail is unbounded above and decays below 8-bit steps. */
    var format: Int = VK_FORMAT_R16G16B16A16_SFLOAT
)

object TrailShaders

The packaged GLSL behind TrailField, reachable from any shader as #include "trail/<name>".

object TrailShaders {

val SAMPLE: String

trail/sample: wrapping bilinear sampling, a gradient, and pixel-to-clip placement. Expects nothing to be declared beforehand; every function takes the sampler as an argument.

    val SAMPLE: String = """
        // trail/sample — reading a TrailField. Coordinates are pixels, pixel centres at +0.5; the field wraps.
        vec4 trailTexel(sampler2D t, ivec2 p) {
            ivec2 d = textureSize(t, 0);
            p = ((p % d) + d) % d;
            return texelFetch(t, p, 0);
        }

        vec4 trailAt(sampler2D t, vec2 px) {
            vec2 q = px - 0.5;
            ivec2 i = ivec2(floor(q));
            vec2 f = q - floor(q);
            vec4 a = trailTexel(t, i);
            vec4 b = trailTexel(t, i + ivec2(1, 0));
            vec4 c = trailTexel(t, i + ivec2(0, 1));
            vec4 e = trailTexel(t, i + ivec2(1, 1));
            return mix(mix(a, b, f.x), mix(c, e, f.x), f.y);
        }

        // gradient of dot(trail, weights), by central differences h pixels apart
        vec2 trailGradient(sampler2D t, vec2 px, vec4 weights, float h) {
            float dx = dot(trailAt(t, px + vec2(h, 0.0)) - trailAt(t, px - vec2(h, 0.0)), weights);
            float dy = dot(trailAt(t, px + vec2(0.0, h)) - trailAt(t, px - vec2(0.0, h)), weights);
            return vec2(dx, dy) / (2.0 * h);
        }

        // where a point drawn into the field must go so that it lands on pixel px. Field's render targets
        // use a flipped viewport, so clip y is negated: without it a deposit lands on the mirrored row.
        vec4 trailClip(vec2 px, ivec2 dims) {
            vec2 wrapped = mod(px, vec2(dims));
            vec2 clip = (wrapped / vec2(dims)) * 2.0 - 1.0;
            return vec4(clip.x, -clip.y, 0.0, 1.0);
        }
    """.trimIndent()

    private val sources = mapOf("sample" to SAMPLE)

fun has(name: String): Boolean = name.removeSuffix(".glsl") in sources

Whether trail/<name> names one of these sources, with or without .glsl.

fun source(name: String): String

The source of trail/<name>.

    fun source(name: String): String =
        sources[name.removeSuffix(".glsl")] ?: error("Unknown trail shader: $name (have ${sources.keys.sorted()})")
}

class TrailField

A GPU stigmergy field: a toroidal RGBA16F trail that diffuses, decays per channel and drifts, plus the plumbing for modules that deposit into it and shaders that sense it. See TrailFieldSettings for the overview.

Build one, add deposit modules, and register it once with installInto (or as a pre-target of your own). It is a HasImageView, so any module can sample it as a texture.

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

val settings = TrailFieldSettings().apply(configure)

The settings, read every frame, so changing one takes effect on the next frame.

val target

The ping-pong target holding the trail. The evolve pass and every deposit module render into it.

    val target = PingPongRenderToTexture(renderer) {
        width = settings.width
        height = settings.height
        format = settings.format
        colorFormats = listOf(settings.format)
        clearColor = vec(0, 0, 0, 0)
        colorStoreOp = VK_ATTACHMENT_STORE_OP_STORE
        colorSampled = true
        colorTransferSrc = true
        useDepth = false
    }

val sampler = TextureSampler(renderer, 1, false)

One sampler for every read of the trail; filtering is done in GLSL, so nothing depends on its mode.

val evolveModule: ModulePointer

The full-screen pass that carries the trail from one frame to the next: wind, diffusion, decay.

    private var clearFrames = 0

init

    init {
        require(settings.width > 0 && settings.height > 0) { "width and height must be positive" }
        evolveModule = Module(
            parameters(renderer) {
                +this@TrailField.target
                name = "${settings.name}-evolve"
                depthTest = false
                blend = false
                vertex = FULLSCREEN_TRIANGLE_VERTEX
                fragment = EVOLVE_FRAGMENT
                textureResolver = previousTrailResolver()
            }
        ).pointer()
        evolveModule.current.meshes["${settings.name}-evolve-quad"] = fullscreenTriangle(evolveModule).apply {
            push.bind("decay") { decayFactors() }
            push.bind("windPx") { settings.windPx }
            push.bind("diffusion") { settings.diffusion.coerceIn(0f, 1f) }
            push.bind("clearing") { if (clearFrames > 0) 1f else 0f }
        }
        target += "evolve" to evolveModule
    }

fun decayFactors(): Vec4

Per-channel multiplicative decay per frame, from TrailFieldSettings.halfLifeFrames.

    fun decayFactors(): Vec4 {
        val h = settings.halfLifeFrames
        fun f(v: Float) = when {
            v < 0f -> 1f
            v == 0f -> 0f
            else -> 0.5f.pow(1f / v)
        }
        return vec(f(h.x), f(h.y), f(h.z), f(h.w))
    }

fun depositParameters(name: String, configure: Parameters.() -> Unit

Parameters for a module that deposits into this field: rendering into target, additively, with the previous frame of the trail bound to any sampler named trailTex. Build the module on these, then hand it to addDeposit.

    fun depositParameters(name: String, configure: Parameters.() -> Unit = {}): Parameters = parameters(renderer) {
        +this@TrailField.target
        this.name = name
        depthTest = false
        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
        textureResolver = previousTrailResolver()
        configure()
    }

fun addDeposit(name: String, module: ModulePointer)

Add a deposit module; deposits draw after the evolve pass, in the order they were added.

    fun addDeposit(name: String, module: ModulePointer) {
        require(name != "evolve") { "'evolve' is the field's own pass" }
        target += name to module
    }

fun removeDeposit(name: String)

Remove a deposit module added with addDeposit.

    fun removeDeposit(name: String) {
        require(name != "evolve") { "'evolve' is the field's own pass" }
        target.modules.remove(name)
    }

fun clear()

Empty every channel. Takes effect over the next PingPongRenderToTexture.frameCount frames, so no history slot keeps old trail.

    fun clear() {
        clearFrames = target.frameCount
    }

fun previousTrailResolver(): (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>? = { tex ->

Resolver binding the trail's previous frame to a sampler named trailTex (and its latest to trailLatestTex), for modules built outside depositParameters that read the field.

    fun previousTrailResolver(): (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>? = { tex ->
        when (tex.name) {
            "trailTex" -> fromViews(listOf(target.otherImageView), tex.name, sampler)
            "trailLatestTex" -> fromViews(listOf(target.latestImageView), tex.name, sampler)
            else -> null
        }
    }

override val imageView: ImageView

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

override val imageViewArray: List<HasImageView>

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

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

    override fun render(command: CommandBuffer, index: Int) {
        target.render(command, index)
        if (clearFrames > 0) clearFrames--
    }

fun installInto(renderTarget: RenderTarget = renderer.renderList)

Register this field as a pre-target of renderTarget, so it steps once per frame.

    fun installInto(renderTarget: RenderTarget = renderer.renderList) {
        renderTarget.preTargets["${settings.name}-field"] = this
    }

fun previewInto

Draw the trail to target with an exposure tone map: channel c contributes palettec* (1 - exp(-exposure * trail.c)). Registers nothing else, so it can be called for a second view.

    fun previewInto(
        target: RenderTarget = renderer.renderList,
        name: String = "${settings.name}-preview",
        viewport: Rect? = null,
        exposure: () -> Float = { 0.15f },
        palette: List<Vec4> = DEFAULT_PALETTE
    ): ModulePointer {
        require(palette.size == 4) { "palette needs one colour per channel" }
        val module = Module(
            parameters(renderer) {
                this.name = name
                depthTest = false
                blend = false
                this.viewport = viewport
                vertex = PREVIEW_VERTEX
                fragment = PREVIEW_FRAGMENT
                textureResolver = previousTrailResolver()
            }
        ).pointer()
        module.current.meshes["$name-quad"] = fullscreenTriangle(module).apply {
            push.bind("exposure") { exposure() }
            push.bind("c0") { palette[0] }
            push.bind("c1") { palette[1] }
            push.bind("c2") { palette[2] }
            push.bind("c3") { palette[3] }
        }
        target += name to module
        return module
    }

fun read(): CompletableFuture<Img>

Read the trail's raw floats back after its next frame: an Img of width x height x 4, top row first as the field stores it, unclamped. Completes once that frame's GPU work finishes.

    fun read(): CompletableFuture<Img> = target.readPixelsAsync(0).thenApply { bytes ->
        require(settings.format == VK_FORMAT_R16G16B16A16_SFLOAT) { "read() decodes RGBA16F only" }
        val n = settings.width * settings.height * 4
        val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder())
        val data = FloatArray(n) { java.lang.Float.float16ToFloat(buffer.getShort(it * 2)) }
        Img(settings.width, settings.height, 4, data)
    }

companion object

    companion object {

val DEFAULT_PALETTE: List<Vec4>

Red, green, blue and a warm white: the four channels as previewInto colours them by default.

        val DEFAULT_PALETTE: List<Vec4> = listOf(
            vec(1.0f, 0.78f, 0.35f, 1f),
            vec(0.25f, 0.85f, 1.0f, 1f),
            vec(1.0f, 0.3f, 0.6f, 1f),
            vec(0.6f, 1.0f, 0.4f, 1f)
        )

internal val FULLSCREEN_TRIANGLE_VERTEX

body — 77 lines, from line 341
        internal val FULLSCREEN_TRIANGLE_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()

        private val EVOLVE_FRAGMENT = """
            #version 450

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

            layout(push_constant) uniform Push {
                vec4 decay;
                vec2 windPx;
                float diffusion;
                float clearing;
            } push;

            layout(location = 0) out vec4 outColor;

            ${TrailShaders.SAMPLE.prependIndent("            ").trimStart()}

            void main() {
                if (push.clearing > 0.5) { outColor = vec4(0.0); return; }
                vec2 px = gl_FragCoord.xy - push.windPx;
                vec4 centre = trailAt(trailTex, px);
                vec4 mean = vec4(0.0);
                for (int dy = -1; dy <= 1; dy++)
                    for (int dx = -1; dx <= 1; dx++)
                        mean += trailAt(trailTex, px + vec2(dx, dy));
                mean /= 9.0;
                outColor = mix(centre, mean, push.diffusion) * push.decay;
            }
        """.trimIndent()

        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 trailLatestTex;

            layout(push_constant) uniform Push {
                vec4 c0;
                vec4 c1;
                vec4 c2;
                vec4 c3;
                float exposure;
            } push;

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

            void main() {
                ivec2 dims = textureSize(trailLatestTex, 0);
                ivec2 pixel = clamp(ivec2(uv * vec2(dims)), ivec2(0), dims - 1);
                vec4 t = max(texelFetch(trailLatestTex, pixel, 0), vec4(0.0));
                vec4 k = 1.0 - exp(-push.exposure * t);
                vec3 c = k.x * push.c0.rgb + k.y * push.c1.rgb + k.z * push.c2.rgb + k.w * push.c3.rgb;
                outColor = vec4(c, 1.0);
            }
        """.trimIndent()
    }
}