Skip to content

IteratedSystem.kt

src/jvmMain/kotlin/afield/simulation/particles/IteratedSystem.kt · 215 lines

@file:Suppress("MemberVisibilityCanBePrivate")

package afield.simulation.particles

import afield.document.context.fromContext
import afield.document.graph.Box
import afield.graphics.render.ComputeMesh
import afield.graphics.render.ComputeModulePointer
import afield.graphics.render.computeBarrier
import afield.graphics.render.computeTask
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.FrameBufferGroup
import afield.graphics.vulkan.buffers.defaultBuffer
import afield.graphics.vulkan.targets.PointAccumulation
import afield.graphics.vulkan.targets.PointBlend
import afield.graphics.vulkan.targets.PointAccumulationSettings
import afield.ui.controls.ControlLaw
import afield.ui.output.controlGroup
import afield.ui.output.displayOpen

Iterated systems: many particles, each stepped by the same rule every frame and plotted after every step, so their visits pile up into the shape the rule keeps returning to — the chaos game behind an IFS fern, the flow of a strange attractor, a fractal flame.

class IteratedSystemSettings : PointAccumulationSettings()

Settings for an IteratedSystem: the PointAccumulation settings (viewport, background, fade, blend, tone map, exposure) plus how many particles there are and how they are stepped. particles and maxSteps are fixed when the system is built; the rest can change while it runs.

init

    init {
        name = "iterated-system"
    }

var particles: Int = 100_000

How many particles. Each is one GPU invocation, so a million is fine.

var steps: Int = 1

Steps per particle per frame. Every step is plotted.

var maxSteps: Int = 0

The most steps can be raised to while running; 0 means four, or steps if that is more.

var fps: Double = 0.0

Frames stepped per second; 0 steps every frame the renderer draws. Lower shows the build-up.

var dt: Double = 1.0

The dt the shader's step is given.

var seed: Int = 1

Changes every particle's random numbers.

class IteratedSystem

Hosts an iterated system whose rule is a compute shader built on #include "points/system": the shader defines initParticle, step, project, shouldDraw, color and pointSize, and the include's main() steps every particle IteratedSystemSettings.steps times a frame and writes a point per step. This class allocates the particle and point buffers, dispatches the shader in the box's compute module, and plots the points into a PointAccumulation it installs on the renderer.

var module = computeModuleHere {}
val fern = IteratedSystem(module) {
    viewport(-3.1, 3.1, 0.0, 10.4)
    particles = 200_000
    fade = 0.06
}

Every system uses the same module names unless IteratedSystemSettings.name says otherwise, so launching one replaces the one that was showing. reset starts every particle again and clears the picture.

class IteratedSystem(
    /** The compute module holding the system's shader, usually `computeModuleHere {}`. */
    val module: ComputeModulePointer,
    /** The renderer to run on; by default the one this box is running in. */
    val renderer: Renderer = fromContext(),
    configure: IteratedSystemSettings.() -> Unit = {}
) {

val settings = IteratedSystemSettings().apply(configure)

The settings after configure ran; change them to steer the running system.

val particles: Int = settings.particles

How many particles, fixed for the life of the system.

val maxSteps: Int

The room per particle in points, fixed for the life of the system.

    val maxSteps: Int = (settings.maxSteps.takeIf { it > 0 } ?: maxOf(settings.steps, 4)).coerceAtLeast(settings.steps)

val state: FrameBufferGroup

Two vec4s per particle: (pos, age) and data.

val points: FrameBufferGroup

maxSteps AccumulatedPoint records per particle, rewritten every stepped frame.

val accumulation: PointAccumulation

Where the points pile up.

val mesh: ComputeMesh

The dispatch in module that runs the shader.

var frame: Int

Frames stepped so far.

    var frame: Int = 0
        private set

    private var resetPending = true
    private var resetThisFrame = false
    private var stepping = true
    private var lastStepNanos = 0L

init

body — 63 lines, from line 114
    init {
        require(particles > 0) { "particles must be positive, got $particles" }
        require(settings.steps >= 1) { "steps must be at least 1, got ${settings.steps}" }
        // Nothing frees a GPU buffer or target once made, so a re-run of the same shape takes the
        // last one's back instead of allocating its own; resetPending starts the particles afresh.
        val shape = Shape(settings.name, particles, maxSteps, settings.width, settings.height, settings.format, settings.blend)
        val previous = synchronized(kept) { kept.getOrPut(renderer) { HashMap() }[shape] }
        if (previous != null) {
            state = previous.state
            points = previous.points
            accumulation = previous.accumulation
            accumulation.settings = settings
            accumulation.clear()
        } else {
            state = defaultBuffer(name = "${settings.name}-state") {
                size = (particles * 2).vec4s
                zeroInit()
            }.toBuffer(renderer)
            points = defaultBuffer(name = "${settings.name}-points") {
                size = (particles.toLong() * maxSteps * 2 * 16)
                zeroInit()
            }.toBuffer(renderer)
            accumulation = PointAccumulation(renderer, settings)
            accumulation.plot(points, particles * maxSteps)
            synchronized(kept) { kept.getOrPut(renderer) { HashMap() }[shape] = Resources(state, points, accumulation) }
        }
        accumulation.advance = { stepping }

        mesh = ComputeMesh(renderer, module)
        mesh.push.bind("particles") { state }
        mesh.push.bind("points") { points }
        mesh.push.bind("count") { particles }
        mesh.push.bind("steps") { settings.steps.coerceIn(1, maxSteps) }
        mesh.push.bind("maxSteps") { maxSteps }
        mesh.push.bind("frame") { frame }
        mesh.push.bind("seed") { settings.seed }
        mesh.push.bind("reset") { if (resetThisFrame) 1 else 0 }
        mesh.push.bind("dt") { settings.dt.toFloat() }
        mesh.x = (particles + 63) / 64
        mesh.y = 1
        mesh.z = 1

        // whether to step is decided once a frame, here, so the compute pass and the accumulation agree
        module += "${settings.name}-step" to computeTask { inside, cmd, push ->
            tick()
            if (stepping) mesh.compute(inside, cmd, push)
        }
        module += "${settings.name}-publish" to computeBarrier(graphics = true)
        renderer += "${settings.name}-compute" to module
        accumulation.installInto()
    }

    private data class Shape(
        val name: String, val particles: Int, val maxSteps: Int,
        val width: Int, val height: Int, val format: Int, val blend: PointBlend
    )

    private class Resources(val state: FrameBufferGroup, val points: FrameBufferGroup, val accumulation: PointAccumulation)

    private companion object {
        /** The resources each shape of system was last built with, per renderer. */
        val kept = java.util.WeakHashMap<Renderer, HashMap<Shape, Resources>>()
    }

fun reset()

Start every particle again with initParticle, and clear the picture.

    fun reset() {
        resetPending = true
        accumulation.clear()
    }

    private fun tick() {
        val now = System.nanoTime()
        val fps = settings.fps
        stepping = fps <= 0.0 || lastStepNanos == 0L || now - lastStepNanos >= (1e9 / fps).toLong()
        resetThisFrame = stepping && resetPending
        if (stepping) {
            resetPending = false
            lastStepNanos = now
            frame++
        }
    }
}

fun Box.showControls(system: IteratedSystem, channel: String = "controls")

The fern demo's toolbar for system, in this box's controls output: frames per second, steps per frame, fade and exposure, each steering the running system, and a reset button. The panel opens, as the demo's toolbar is always in view.

fun Box.showControls(system: IteratedSystem, channel: String = "controls") {
    val s = system.settings
    controlGroup(channel) {
        // 0 fps steps on every frame the renderer draws
        slider("fps", min = 0.0, max = 120.0, step = 1.0, initialValue = s.fps) { s.fps = it }
        slider("steps per frame", min = 1.0, max = system.maxSteps.toDouble(), step = 1.0,
            initialValue = s.steps.toDouble()) { s.steps = it.toInt() }
        slider("fade", min = 0.0, max = 1.0, step = 0.005, initialValue = s.fade) { s.fade = it }
        slider("exposure", min = 0.05, max = 20.0, initialValue = s.exposure, law = ControlLaw.LOGARITHMIC) { s.exposure = it }
        button("reset") { down -> if (down) system.reset() }
    }
    displayOpen(channel)
}