One writer on the CPU, many workers on the GPU
Only the main thread may change simulation state. There is no separate CPU render thread. Each frame, the system calls MTKViewDelegate.draw(in:) on the main thread. That call records Metal commands; the GPU executes them later. Uniforms, texture swaps, and oar-trail buffers are all updated on the main thread before those commands are submitted. Parallel work happens on the GPU. Decision-making stays on the main thread.
Sensors arrive on their own queues—mapper polling, PM5 Bluetooth, UDP—but none of them touch the fluid state directly. Each provider normalises its event and hops back to the main thread first (DispatchQueue.main.async, a main-queue UDP socket, or a locked BLE snapshot delivered through the router). Until that hop finishes, the simulation does not change.
In practice every input path ends at the same place: the main-thread sink that feeds the next draw. The queue diagram shows that pattern as arrows from every producer into one box.
At most three frames ahead
A DispatchSemaphore with value three (MaxBuffers) limits how far the CPU may run ahead of the GPU. draw(in:) waits on the semaphore; the command buffer’s completion handler releases it. If the GPU is slow, the CPU waits instead of stacking unbounded work and memory.
Uniforms use a ring of three GPUUniforms buffers. Frame N writes buffer N mod 3, so it never overwrites data the GPU may still be reading. Field textures use a separate ping-pong pair. Together, the semaphore and the ring keep “who owns this memory?” boring on the hot path.
The point is not clever concurrency. It is bounded latency: the CPU can prepare the next frame without corrupting the one still on the GPU. The hard thinking belongs in the seventeen-stage graph, not in racing threads.
Faster path on Apple Silicon, honest fallback elsewhere
On Apple Silicon, simulation stages run as compute kernels and the pressure solve uses red–black Gauss–Seidel. On non-arm64 machines, the same stages run as fragment shaders with a Jacobi solve. The choice is compile-time (useComputePipeline under arch(arm64)), not a hidden runtime guess. Both paths are real architecture, not a quiet quality tier.
The last step—compositing fluid, boat, and overlays into the on-screen image—is always a fragment pass. Bloom and particles exist only on the compute path and are skipped on the fallback. Target frame rate follows the same split: 60 fps on arm64, 30 fps on the fallback, so weaker machines are not asked to do work they cannot finish.
Even ripple tuning differs between paths (injection radius, coupling, velocity cap). The two builds are not pixel-identical. The publication records that instead of pretending one binary covers every machine.
A fixed order each frame
Every frame runs the same pipeline from drawMetal3. First, if obstacles changed, hull, cutout, and painted solids are baked into one obstacle texture. Velocity and density then move with the flow (semi-Lagrangian advection). During a power stroke, interaction and oar-trail forces add Gaussian splats into velocity, density, and bloom—trail points are batched five per dispatch so the host can emit dense geometry without exploding work.
Next come ripples (disturbance, wave travel, then ripple→velocity), then vorticity confinement to keep swirls from dying to numerical blur. Divergence builds the right-hand side for pressure; the pressure solve runs (sixteen red/black pairs on compute, or twenty Jacobi sweeps on fallback); subtracting the pressure gradient makes the velocity field incompressible. Solids are enforced again so blocked cells stay clean.
On the compute path only, bloom (advect, decay, downsample, blur) and optional particles run before the final image. The fallback skips those two stages and still draws the frame. The figure below condenses this into twelve teaching nodes; dashed nodes are the compute-only ones.
Bloom and particles (stages 15–16) run only on Apple Silicon compute.
One frame
What runs, in order, every frame.
Play through the pipeline. Solid nodes run on every machine; dashed nodes (Bloom, Particles) run only on Apple Silicon.
01 · ObstacleMoveBake the boat hull, cutout, and painted solids into one obstacle map.
Condensed per-frame order. Dashed nodes (Bloom, Particles) run only on Apple Silicon.
What actually costs the frame
The expensive stretches are the pressure solve, the bloom blur (thirteen taps, both directions), and optional particles. On Apple Silicon, the long pole is pressure, then bloom. On the fallback, bloom and particles are gone, so the long pole ends at the project step after pressure. When those stages overrun, the in-flight semaphore makes the next CPU frame wait—cost appears as hitch, not as a growing backlog.
This chapter does not claim measured GPU timings; none are checked into the repo yet. What it does claim is the structure you can read in source: which stages exist, which machines run them, and where the work concentrates. Timers can be added later without changing that graph.
Where time goes
The stages that usually dominate the frame.
Dashed rings mark the heavy pair: Pressure then Project. Bloom (not shown) adds more cost on Apple Silicon; the fallback stops after Project.
01 · AdvectMoveMove velocity and density with the current flow.
Simplified spine of one frame. Rings mark Pressure → Project, the usual bottleneck. Bloom and particles omitted.