ParticleSketch.kt
src/jvmMain/kotlin/afield/simulation/particles/ParticleSketch.kt · 649 lines
package and imports — 53 lines, from line 1
@file:Suppress("MemberVisibilityCanBePrivate")
package afield.simulation.particles
import afield.document.context.fromContext
import afield.document.graph.Box
import afield.graphics.color.rgba
import afield.graphics.render.ComputeMesh
import afield.graphics.render.ComputeModulePointer
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.computeMemoryBarrier
import afield.graphics.render.fullscreenTriangle
import afield.graphics.render.graphicsMemoryBarrier
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.shader.fromViews
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.FrameBufferGroup
import afield.graphics.vulkan.buffers.defaultBuffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.PointAccumulation
import afield.graphics.vulkan.targets.PointBlend
import afield.graphics.vulkan.targets.PointShaders
import afield.graphics.vulkan.util.GpuNeighbourGrid
import afield.graphics.vulkan.util.WindowPointer
import afield.math.linalg.Rectanglef
import afield.math.linalg.Vec4
import afield.math.linalg.Vector2f
import afield.math.linalg.vec
import afield.ui.output.controlGroup
import afield.ui.output.displayOpen
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_READ_BIT
import org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_WRITE_BIT
import org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT
import org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT
import org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT
import org.lwjgl.vulkan.VK13.VK_STRUCTURE_TYPE_DEPENDENCY_INFO
import org.lwjgl.vulkan.VK13.VK_STRUCTURE_TYPE_MEMORY_BARRIER_2
import org.lwjgl.vulkan.VK13.vkCmdPipelineBarrier2
import org.lwjgl.vulkan.VkDependencyInfo
import org.lwjgl.vulkan.VkMemoryBarrier2
import java.nio.ByteOrder
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
Particle sketching on the GPU: particles you paint into the renderer window by dragging, each
moved every frame by a compute shader written against #include "particles/sketch", drawn as
glowing dots with threads between them and trails behind them. The Field port of the madp
Particles demo; dev_docs/madp/particles.md has the mapping from its JS hooks.
class ParticleSketchSettings
The world a ParticleSketch runs in: madp's world object, set where madp's init() set it.
Everything here except name, capacity and maxStrokes can change while the sketch runs;
every frame reads it afresh.
class ParticleSketchSettings {
var name: String = "particles"
Names the buffers, passes and pointer handler; a second sketch under the same name replaces the first.
var capacity: Int = 16_384
The most particles alive at once. Painting past it replaces the oldest.
var maxStrokes: Int = 131_072
The most links and overlay shapes drawn in a frame; past it the rest are dropped.
var gravity: Vector2f = vec(0, 0)
Acceleration every particle starts each frame with, in pixels per second per second (y down).
var damping: Double = 0.995
The fraction of its velocity a particle keeps each frame, after the forces.
var maxSpeed: Double = Double.POSITIVE_INFINITY
The world's speed limit in pixels per second, applied after damping.
var background: Vec4 = rgba(2, 5, 7, 1.0)
The ground, as an sRGB colour: hexColor("#03060a"). madp's fadeColor is the same colour.
var particleColor: Vec4 = rgba(190, 255, 236, 0.92)
The colour a particle starts with before spawn sets one, sRGB with straight alpha.
var particleRadius: Double = 3.0
The radius a particle starts with before spawn sets one, in pixels.
var trail: Double = 0.0
madp's Trail: how much of the previous frame stays on screen, 0..1, per 60th of a second (madp's frame), whatever the renderer's frame rate. 0 clears every frame; 0.3 leaves short motion-blurred tails.
var pairRadius: Double = 0.0
How far apart two particles can be for eachPair to see them; 0 runs no pairs (madp's eachPair(r, ...)).
var paintSpacing: Double = 8.0
Pixels between the particles a drag lays down (madp's paint spacing).
var paintWithMouse: Boolean = true
Whether dragging in the renderer window paints particles.
var fps: Double = 60.0
Steps per second; 0 steps on every frame the renderer draws. madp runs at the browser's 60,
and its per-frame constants assume it: drag(0.02), damping and the trail are all "per
frame", so at the thousands of frames a second an unthrottled window can draw they would
stop everything dead. Between steps the picture holds still.
var paused: Boolean = false
Holds the simulation still; the picture keeps drawing, and painting still adds particles.
var seed: Int = 1
Changes every random number the shader draws.
class ParticleSketch
Hosts a particle sketch whose behaviour is a compute shader built on
#include "particles/sketch". The shader defines madp's hooks as GLSL —
spawn(at), eachPair(other, pair), update() and draw() — and this class runs them:
it keeps the particles in two GPU buffers that swap every frame, rebuilds a
GpuNeighbourGrid over them (72-pixel cells, as madp's spatial hash), steps every particle,
and draws links, dots and overlays into a PointAccumulation that fades by 1 - trail each
frame, shown over ParticleSketchSettings.background.
var module = computeModuleHere {}
val sketch = ParticleSketch(module) {
background = hexColor("#03060a")
damping = 0.998
maxSpeed = 190.0
pairRadius = 30.0
trail = 0.22
}
showControls(sketch)
The world starts empty. Drag in the renderer window to paint particles in, or call paint from code. Coordinates are the window's, in points: origin top left, y down, as on a canvas.
Re-running the box that made a sketch keeps its particles and lets the new code take them over, as madp does when you recompile; a sketch from a different box under the same name starts empty. reset empties it.
class ParticleSketch(
/** The compute module holding the sketch'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: ParticleSketchSettings.() -> Unit = {}
) {
val settings = ParticleSketchSettings().apply(configure)
The world, after configure ran; change it to steer the running sketch.
val capacity: Int = settings.capacity
The most particles alive at once, fixed for the life of the sketch.
private val resources: Resources
val particles: List<FrameBufferGroup> get() = resources.particles
The two particle buffers; latest is the one holding the current frame.
val accumulation: PointAccumulation get() = resources.accumulation
The picture: links, dots and overlays piling up with a fade.
val strokes: FrameBufferGroup get() = resources.strokes
This frame's links and overlays: a uint count, three of padding, then one Stroke
(three vec4s: points, colour, shape) for each.
val grid: GpuNeighbourGrid get() = resources.grid
The neighbour grid eachPair, flock, clump and neighbors search.
val count: Int get() = resources.count()
How many particles are alive.
val isEmpty: Boolean get()
True when there are no particles and none waiting to be added, as on a fresh start or after reset.
val isEmpty: Boolean get() = resources.count() == 0 && synchronized(resources.queue) { resources.queue.isEmpty() }
val time: Double get() = resources.time
Seconds the sketch has run, madp's t; it stops while paused.
val latest: FrameBufferGroup get() = resources.particles[resources.current]
The buffer holding the current frame's particles.
private val mesh: ComputeMesh
init
init {
require(capacity > 0) { "capacity must be positive, got $capacity" }
require(settings.maxStrokes > 0) { "maxStrokes must be positive, got ${settings.maxStrokes}" }
// Nothing frees a GPU buffer or target once made, so a re-run of the same shape takes the
// previous one's back; that is also what keeps the particles across a re-run.
val shape = Shape(settings.name, capacity, settings.maxStrokes)
val previous = synchronized(kept) { kept.getOrPut(renderer) { HashMap() }[shape] }
resources = previous ?: Resources(renderer, shape).also {
synchronized(kept) { kept.getOrPut(renderer) { HashMap() }[shape] = it }
}
if (resources.owner != null && resources.owner !== module) resources.reset()
resources.activate()
resources.owner = module
resources.settings = settings
mesh = ComputeMesh(renderer, module)
mesh.push.bind("src") { resources.particles[resources.current] }
mesh.push.bind("dst") { resources.particles[1 - resources.current] }
mesh.push.bind("strokes") { resources.strokes }
mesh.push.bind("spawns") { resources.spawns }
mesh.push.bind("world") { resources.world }
mesh.push.bind("gridValues") { resources.grid.values }
mesh.push.bind("cellStart") { resources.grid.cellStart }
mesh.push.bind("cellEnd") { resources.grid.cellEnd }
mesh.push.bind("phase") { resources.phase }
mesh.x = 1
mesh.y = 1
mesh.z = 1
module += "${settings.name}-sketch" to mesh
resources.install(module, mesh)
}
fun reset() = resources.reset()
Empty the world and the picture; t starts again from 0.
fun paint(x: Double, y: Double) = resources.enqueue(x, y)
Paint one particle at (x, y), as a click in the window does.
fun paint(x0: Double, y0: Double, x1: Double, y1: Double) = resources.paintAlong(x0, y0, x1, y1)
Paint along the line from (x0, y0) to (x1, y1) as a drag does: a particle every
ParticleSketchSettings.paintSpacing pixels, the first one spacing in from the start.
fun paint(points: List<Vector2f>)
Paint along a polyline of window points, as one drag through them.
fun paint(points: List<Vector2f>) {
if (points.isEmpty()) return
paint(points[0].x.toDouble(), points[0].y.toDouble())
for (i in 1 until points.size) {
paint(points[i - 1].x.toDouble(), points[i - 1].y.toDouble(), points[i].x.toDouble(), points[i].y.toDouble())
}
}
val size: Vector2f get() = vec(resources.worldWidth, resources.worldHeight)
The world's size in window points, as width/height read in the shader.
private data class Shape(val name: String, val capacity: Int, val maxStrokes: Int)
private companion object {
/** The resources each shape of sketch was last built with, per renderer. */
val kept = java.util.WeakHashMap<Renderer, HashMap<Shape, Resources>>()
const val LOCAL_SIZE = 64
const val PARTICLE_FLOATS = 24
const val STROKE_BYTES = 48
const val WORLD_BYTES = 128
const val SPAWNS_PER_FRAME = 4096
const val CELL_SIZE = 72f
const val CELLS = 64
const val GRID_MARGIN = 2 * CELL_SIZE
const val MAX_DT = 0.033
}
Everything a sketch keeps between re-runs, and the frame it runs. One per (renderer, name, capacity, stroke capacity).
source — 287 lines, from line 277
private class Resources(val renderer: Renderer, val shape: Shape) : Target {
var settings = ParticleSketchSettings()
var owner: ComputeModulePointer? = null
val particles: List<FrameBufferGroup> = List(2) { i ->
defaultBuffer(name = "${shape.name}-particles-$i") {
size = shape.capacity.toLong() * PARTICLE_FLOATS * 4
}.toBuffer(renderer)
}
val strokes: FrameBufferGroup = defaultBuffer(name = "${shape.name}-strokes") {
size = 16L + shape.maxStrokes.toLong() * STROKE_BYTES
}.toBuffer(renderer)
// Written by the CPU every frame, so one per frame in flight.
val world: FrameBufferGroup = defaultBuffer(name = "${shape.name}-world") {
size = WORLD_BYTES.toLong()
perFrame = true
}.toBuffer(renderer)
val spawns: FrameBufferGroup = defaultBuffer(name = "${shape.name}-spawns") {
size = SPAWNS_PER_FRAME * 8L
perFrame = true
}.toBuffer(renderer)
val grid = GpuNeighbourGrid(
capacity = shape.capacity,
cellsPerSide = CELLS,
domainMin = Vector2f(-GRID_MARGIN, -GRID_MARGIN),
domainSize = CELLS * CELL_SIZE,
renderer = renderer,
name = "${shape.name}-grid",
)
val accumulation = PointAccumulation(renderer) {
name = "${shape.name}-picture"
blend = PointBlend.ALPHA
fade = 1.0
}
private val gridTarget = grid.target({ particles[current] }, { count() }, PARTICLE_FLOATS, 0)
@Volatile var current = 0
@Volatile var spawned = 0L
@Volatile var time = 0.0
var phase = 0
private var frame = 0
private var lastNanos = 0L
@Volatile private var resetPending = false
val queue = ArrayDeque<Float>()
// the pointer and the window's size in points, read on the window's poll
private val brush = SpawnBrush()
val windowPointer = WindowPointer(renderer.window, "${shape.name}-pointer") { p ->
brush.spacing = settings.paintSpacing
brush.step(p.x.toDouble(), p.y.toDouble(), p.down && settings.paintWithMouse, p.inside, ::enqueue)
}
val worldWidth get() = windowPointer.width
val worldHeight get() = windowPointer.height
private var module: ComputeModulePointer? = null
private var mesh: ComputeMesh? = null
private val bounds = Rectanglef(0f, 0f, 0f, 0f)
private val sampler = TextureSampler(renderer, 1, false)
init {
buildDrawing()
}
/** Particles alive; 0 as soon as [reset] is asked for, though the frame applies it. */
fun count(): Int = if (resetPending) 0 else min(spawned, shape.capacity.toLong()).toInt()
fun install(module: ComputeModulePointer, mesh: ComputeMesh) {
this.module = module
this.mesh = mesh
}
fun reset() {
synchronized(queue) { queue.clear() }
resetPending = true
accumulation.clear()
}
fun enqueue(x: Double, y: Double) {
synchronized(queue) {
queue.addLast(x.toFloat())
queue.addLast(y.toFloat())
}
}
fun paintAlong(x0: Double, y0: Double, x1: Double, y1: Double) =
SpawnBrush.along(x0, y0, x1, y1, settings.paintSpacing, ::enqueue)
private lateinit var strokesUnder: Mesh
private lateinit var dots: Mesh
private lateinit var strokesOver: Mesh
private fun buildDrawing() {
strokesUnder = strokeLayer("${shape.name}-links", 0f)
dots = dotLayer("${shape.name}-dots")
strokesOver = strokeLayer("${shape.name}-overlays", 1f)
}
private fun targetSize() = vec(accumulation.width.toFloat(), accumulation.height.toFloat())
private fun strokeLayer(name: String, layer: Float): Mesh {
val m = Module(accumulation.pointParameters(name) {
vertex = ParticleShaders.source("strokes_vertex.glsl")
fragment = ParticleShaders.source("shape_fragment.glsl")
}).pointer()
val mesh = Mesh(renderer, m).resize(shape.maxStrokes * 6)
mesh.push.bind("strokes") { strokes }
mesh.push.bind("size") { vec(worldWidth, worldHeight) }
mesh.push.bind("targetSize") { targetSize() }
mesh.push.bind("capacity") { shape.maxStrokes }
mesh.push.bind("layer") { layer }
m += "$name-quads" to mesh
accumulation.add(name, m)
return mesh
}
private fun dotLayer(name: String): Mesh {
val m = Module(accumulation.pointParameters(name) {
vertex = ParticleShaders.source("dots_vertex.glsl")
fragment = ParticleShaders.source("shape_fragment.glsl")
}).pointer()
val mesh = Mesh(renderer, m).resize(shape.capacity * 6)
mesh.push.bind("particles") { particles[current] }
mesh.push.bind("size") { vec(worldWidth, worldHeight) }
mesh.push.bind("targetSize") { targetSize() }
mesh.push.bind("count") { count() }
m += "$name-quads" to mesh
accumulation.add(name, m)
return mesh
}
private val displayModule: ModulePointer by lazy { buildDisplay() }
/**
* Make this the sketch running under its name: its frame and its display. Resources kept
* for a re-run are activated again, because another shape of sketch under the same name
* may have taken both in between.
*/
fun activate() {
renderer.renderList.preTargets["${shape.name}-sketch"] = this
renderer.renderList += "${shape.name}-display" to displayModule
}
private fun buildDisplay(): ModulePointer {
val name = "${shape.name}-display"
val m = Module(
parameters(renderer) {
this.name = name
depthTest = false
depthWrite = false
blend = false
vertex = PointShaders.source("fullscreen_vertex.glsl")
fragment = ParticleShaders.source("display_fragment.glsl")
textureResolver = { tex ->
if (tex.name == "accumulation") fromViews(listOf(accumulation.latestImageView), tex.name, sampler) else null
}
}
).pointer()
m += "$name-quad" to fullscreenTriangle(m).apply {
push.bind("background") { settings.background }
}
return m
}
/** Takes the spawns queued since the last frame, at most a frame's worth. */
private fun drainQueue(): FloatArray = synchronized(queue) {
val n = min(queue.size / 2, SPAWNS_PER_FRAME)
FloatArray(n * 2) { queue.removeFirst() }
}
override fun render(command: CommandBuffer, index: Int) {
val module = module ?: return
val mesh = mesh ?: return
// the world is the window's size in points, which the first poll brings
if (!windowPointer.polled) return
val s = settings
if (resetPending) {
spawned = 0L
time = 0.0
resetPending = false
}
val now = System.nanoTime()
// Between steps nothing is recorded at all: the picture, and so the window, hold still.
if (s.fps > 0 && lastNanos != 0L && now - lastNanos < (1e9 / s.fps).toLong()) return
val frameSeconds = if (lastNanos == 0L) 1.0 / 60.0 else ((now - lastNanos) / 1e9).coerceIn(0.0, 1.0)
val dt = frameSeconds.coerceAtMost(MAX_DT)
lastNanos = now
val stepping = !s.paused
if (stepping) time += dt
frame++
var batch = drainQueue()
// More paint in one frame than there is room for: only the newest capacity's worth can
// survive, and two spawns writing one slot in the same dispatch would race, so the
// older ones are skipped (they still count, so ids and the ring stay as if painted).
val overflow = batch.size / 2 - shape.capacity
if (overflow > 0) {
spawned += overflow
batch = batch.copyOfRange(overflow * 2, batch.size)
}
val spawnCount = batch.size / 2
val spawnBase = (spawned % shape.capacity).toInt()
val spawnId = spawned + 1
spawned += spawnCount
val alive = count()
spawns.mapAndWrite { put(batch) }
world.mapAndWrite_bytes {
order(ByteOrder.nativeOrder())
val c = s.particleColor
putFloat(c.x); putFloat(c.y); putFloat(c.z); putFloat(c.w)
putFloat(worldWidth); putFloat(worldHeight)
putFloat(windowPointer.x); putFloat(windowPointer.y)
putFloat(s.gravity.x); putFloat(s.gravity.y)
putFloat(-GRID_MARGIN); putFloat(-GRID_MARGIN)
putFloat(time.toFloat())
putFloat(if (stepping) dt.toFloat() else 0f)
putFloat(s.damping.toFloat())
putFloat(s.maxSpeed.toFloat())
putFloat(s.pairRadius.toFloat())
putFloat(s.particleRadius.toFloat())
putFloat(1f / CELL_SIZE)
putInt(CELLS)
putInt(if (windowPointer.inside) 1 else 0)
putInt(if (windowPointer.down) 1 else 0)
putInt(alive)
putInt(shape.capacity)
putInt(spawnBase)
putInt(spawnCount)
putInt(spawnId.toInt())
putInt(shape.maxStrokes)
putInt(frame)
putInt(s.seed)
}
// last frame's draws read the buffers this frame's passes write
graphicsToComputeBarrier(command)
// 1. spawn: the paint queue becomes particles; the stroke list is cleared; draw() runs
phase = 0
mesh.x = ceil(max(spawnCount, 1) / LOCAL_SIZE.toDouble()).toInt()
module.render(command, bounds)
computeMemoryBarrier(command)
if (stepping && alive > 0) {
// 2. hash: the neighbour grid over every particle (it ends with its own barrier)
gridTarget.render(command, index)
// 3. step: eachPair, update, integrate -- from one buffer into the other
phase = 1
mesh.x = ceil(alive / LOCAL_SIZE.toDouble()).toInt()
module.render(command, bounds)
current = 1 - current
}
graphicsMemoryBarrier(command)
// 4. draw: fade, links, dots, overlays
// madp fades by 1 - trail once per frame at 60 frames a second; scaled to this step's
// length so the trail is as long in seconds whatever fps is. Wall-clock time, so a
// paused picture fades as madp's does.
val keep = s.trail.coerceIn(0.0, 1.0).pow(frameSeconds * 60.0)
accumulation.settings.fade = (1.0 - keep).coerceIn(0.0, 1.0)
accumulation.render(command, index)
}
private fun graphicsToComputeBarrier(command: CommandBuffer) {
MemoryStack.stackPush().use { stack ->
val m = VkMemoryBarrier2.calloc(1, stack)
m[0]
.sType(VK_STRUCTURE_TYPE_MEMORY_BARRIER_2)
.srcStageMask(VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT or VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT)
.srcAccessMask(VK_ACCESS_2_SHADER_READ_BIT)
.dstStageMask(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT)
.dstAccessMask(VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT)
val dep = VkDependencyInfo.calloc(stack).sType(VK_STRUCTURE_TYPE_DEPENDENCY_INFO).pMemoryBarriers(m)
vkCmdPipelineBarrier2(command.vkCommandBuffer, dep)
}
}
}
}
class SpawnBrush(var spacing: Double = 8.0)
Turns the pointer, read once a frame, into paint, as madp's canvas does with mouse events: a
press inside the window lays one particle, and moving with the button held lays one every
spacing pixels along the way (at least one per move). A drag that starts inside keeps painting
when it leaves the window; one that starts outside paints nothing.
class SpawnBrush(var spacing: Double = 8.0) {
private var painting = false
private var wasDown = false
private var lastX = 0.0
private var lastY = 0.0
fun step(x: Double, y: Double, down: Boolean, inside: Boolean, paint: (Double, Double) -> Unit) {
One frame's pointer: where it is, whether the button is held, whether it is over the window.
fun step(x: Double, y: Double, down: Boolean, inside: Boolean, paint: (Double, Double) -> Unit) {
val pressed = down && !wasDown
if (pressed && inside) {
painting = true
paint(x, y)
} else if (down && painting && (x != lastX || y != lastY)) {
along(lastX, lastY, x, y, spacing, paint)
} else if (!down) {
painting = false
}
wasDown = down
lastX = x
lastY = y
}
companion object
companion object {
fun along(x0: Double, y0: Double, x1: Double, y1: Double, spacing: Double, paint: (Double, Double) -> Unit) {
madp's paintAlong: particles every spacing pixels from just past (x0, y0) to
(x1, y1), at least one; a move shorter than 1e-4 pixels lays one at the end.
fun along(x0: Double, y0: Double, x1: Double, y1: Double, spacing: Double, paint: (Double, Double) -> Unit) {
val dx = x1 - x0
val dy = y1 - y0
val dist = sqrt(dx * dx + dy * dy)
if (dist < 1e-4) return paint(x1, y1)
val steps = max(1, floor(dist / spacing.coerceAtLeast(0.5)).toInt())
for (i in 1..steps) {
val f = i.toDouble() / steps
paint(x0 + dx * f, y0 + dy * f)
}
}
}
}
object ParticleShaders
The packaged GLSL behind ParticleSketch. #include "particles/sketch" in a compute shader
reaches sketch.glsl, the harness with the particle struct, the hooks and the verbs.
object ParticleShaders {
private val names = setOf(
"sketch.glsl", "strokes_vertex.glsl", "dots_vertex.glsl", "shape_fragment.glsl", "display_fragment.glsl"
)
fun has(name: String): Boolean = withExtension(name) in names
Whether particles/<name> names one of these sources, with or without .glsl.
fun source(name: String): String
The source of name (sketch, sketch.glsl, ...).
fun source(name: String): String {
val file = withExtension(name)
require(file in names) { "Unknown particles shader: $name (have ${names.sorted()})" }
return checkNotNull(javaClass.getResourceAsStream("/afield/four/particles/$file")) {
"Missing packaged particles shader: $file"
}.bufferedReader().use { it.readText() }
}
private fun withExtension(name: String) = if (name.endsWith(".glsl")) name else "$name.glsl"
}
fun Box.showControls(sketch: ParticleSketch, channel: String = "controls")
madp's toolbar for sketch, in this box's controls output: the trail, a pause, and a reset
that empties the world. The panel opens, as the demo's toolbar is always in view.
fun Box.showControls(sketch: ParticleSketch, channel: String = "controls") {
val s = sketch.settings
controlGroup(channel) {
slider("trail", min = 0.0, max = 0.98, step = 0.01, initialValue = s.trail) { s.trail = it }
checkbox("pause", initialValue = s.paused) { s.paused = it }
button("reset") { down -> if (down) sketch.reset() }
}
displayOpen(channel)
}