Read ping, write pong, then swap
Simulation fields use paired textures. A Slab holds two textures, ping and pong, with usage flags for shader read, shader write, and render target, and storage mode private—GPU-only. After a pass reads ping and writes pong, swap exchanges the two. The pattern makes data hazards boring, and boring is good at sixty frames per second. Most of the seventeen-stage graph obeys that invariant, which is why adding a pass is safer when the new kernel respects the same ownership model.
All simulation fields use the rg16Float format—two sixteen-bit float channels, four bytes per texel—while the drawable is bgra8Unorm with four-times MSAA and sampleCount four. Half-float storage halves bandwidth and working set relative to full float, with arithmetic performed in thirty-two-bit float inside kernels. The only CPU-visible resources are the three uniform buffers with shared storage and the vertex and index buffers. Everything else stays on the GPU where the projector’s frame budget needs it.
The pressure solve is the deliberate exception on Apple Silicon: red and black cells update in place on alternating parity sweeps through dispatchInPlace, which is how RBGS earns its keep without an extra slab for every iteration. The Jacobi fallback keeps the ordinary ping-pong ownership model. Maintainers who add passes should treat that exception as documented special case, not as permission to invent new write-after-read hazards elsewhere in the graph.
GPU memory
Names stay. Roles flip.
Two fixed textures. A pass samples one and writes the other; swap() then trades the roles.
01 · PassSample READ into the kernel, then write the result into WRITE — never the same texture.
Sample READ into a pass, write to WRITE, then swap() flips the roles. Semaphore(3) caps in-flight frames.
Compile once, dispatch often
All Metal objects are obtained through a single MetalDevice singleton, which owns the MTLDevice, the MTLCommandQueue, the default MTLLibrary, and an NSCache-backed pipeline cache. Pipeline state objects for compute and render are created lazily and memoised by a string cache key, so repeated passes incur no recompilation tax mid-session. That cache is why the seventeen-stage graph can afford to be explicit: each stage names a pipeline without paying for a fresh compile on every frame.
MetalDevice also exposes a GPUDevice protocol, allowing the concrete device to be replaced by a mock in unit tests—a design decision that decouples simulation logic from hardware initialisation. Thin ComputeShader and RenderShader wrappers sit between Renderer and Metal so a pass remains explicit while dispatch arithmetic and full-screen rendering stay consistent. ComputeShader derives threadgroup size from the pipeline’s own limits with a one-dimensional, width-only grouping: width equals the minimum of threadExecutionWidth and maxTotalThreadsPerThreadgroup, height one, depth one.
That one-dimensional grouping is a conservative portability choice. It favours broad device compatibility and simple dispatch arithmetic over hand-tuned two-dimensional tiling, at the cost that kernels with two-dimensional neighbourhood access may not maximise memory locality relative to a tuned tile. RenderShader draws a full-screen quad—two triangles, six vertices—for the fallback simulation path and the final visualization pass. The implementation chapter names the trade-off so rebuilders know why the wrappers look simple on purpose.
Pipeline cache
Lookup. Hit or miss. Dispatch.
MetalDevice memoises pipeline states so the seventeen-stage graph never recompiles hot.
01 · AskA pass asks MetalDevice for a pipeline by name — never builds one inline.
Pass asks MetalDevice: hit reuses a memoised PSO; miss compiles once, then dispatches.
Allocate once, stretch thereafter
Simulation slabs are allocated exactly once, on the first drawableSizeWillChange callback through initSurfaces and initBuffers, at a resolution derived from the view bounds. On subsequent resizes the slabs are not reallocated; the view size updates, but the fixed-resolution fields are stretched to the drawable through UV sampling. This is an explicit trade-off between reallocation overhead and strict resolution fidelity—preserving fluid state across window, resize, and fullscreen transitions while accepting a fixed grid that may not match the display aspect ratio one-to-one.
Host-space inputs are scaled to texture space each frame as texPos equals pointPos times texSize over viewSize, with viewSize recomputed from the view bounds so interaction coordinates remain coherent even while the underlying slabs stay put. UnitMapping reconciles texture-index space, normalised UV space, view point-space, and physical units for PM5 and rowing physics. A fixed one-hundred pixels-per-metre length and width scale and an approximately 1.82 pixels-per-(m/s) velocity scale—ten over five-point-five—are the defaults, both overridable by a manual calibration measurement when a session needs geometric fidelity beyond the fixed assumption.
Domain & resize
Fixed grid. Live window.
Fluid state survives resize because the slabs never move — only the view mapping does.
01 · AllocateFirst resize builds the simulation grid once. After that, resolution stays fixed.
Slabs allocate once; later resizes stretch via UV. UnitMapping keeps host input coherent.
Python remains a producer, not a second renderer
As noted in the sensing chapter’s fan-in design, the Python bridge exists to normalize and emit sensing packets into the host vocabulary. It does not own the Metal field and does not invent a parallel simulation authority. When ROWSIM_INPUT_UDP is set, the macOS host selects UdpInputProvider; otherwise NativeMapperInputProvider owns the default path, with PM5 BLE wired alongside. Configuration surfaces such as ROWSIM_HOST, ROWSIM_PORT, LIBMAPPER_INTERFACE, and mapper debug flags remain outside the renderer.
That boundary keeps research tooling outside the real-time critical path while still letting researchers attach new streams. Bridge timing depends on poll-and-flush cadence and UDP scheduling; the host still converges every packet to the main-thread sink before uniforms are written. UDPManager’s parser grammar supports canonical and compatibility prefixes, which improves bridge interoperability while increasing schema complexity for maintainers who add packet families later.
Engineering implication: native and Python PM5 stacks are protocol-compatible but not timing-identical. Experiments should log which stack was active. The publication treats that logging requirement as part of implementation honesty, not as an afterthought to be remembered only when a session fails to reproduce.
Python bridge
One legal path.
Research tooling may emit packets. Only the host may write the fluid field.
01 · EmitPython normalizes sensors and emits host-shaped packets. It never opens a Metal texture.
Python → UDP → host → Metal. One legal path; the host is the only writer into the fluid core.
Explicit branches beat silent fallbacks
Compile-time architecture selection means Apple Silicon and Intel paths are documented as different machines with different capabilities, not as the same binary quietly degrading. Bloom and particle stages exist on the compute branch and are omitted on the fragment fallback. Ripple injection radius—sixteen on compute versus eight on fragment—coupling multiplier, and velocity cap also differ between branches, so the two paths are not bit-identical. The architecture branch is not a manifest field; it is inferred from the recorded host.
Half-float storage can accumulate quantisation artefacts over long sessions; denoise floors mitigate but do not eliminate that. No automated GPU performance telemetry is collected; reported costs remain structural. Solids enforcement is present on both paths after remediation work, while bloom and particle divergence remains intentional. The risk register’s ongoing guardrails—uniform ABI synchronization, branch-aware expectations, driveLength naming, timestamp normalization, and constants-backed settings—exist to keep those decisions from regressing.
The GPUDevice protocol, pipeline cache, uniform ring, and semaphore are the decisions that strengthen the work for rebuilders. Naming branch divergence, fixed-grid resize behaviour, and measurement limits keeps the thesis from pretending that every machine paints the same wake. Reproducibility here means inspectable ownership and honest path labels, not a claim that every installation binary is numerically identical.
Reproducibility
Name the branch.
Compile-time paths are honest machines — not one binary quietly degrading.
01 · SiliconApple Silicon compute: RBGS pressure, bloom, particles, ripple radius 16.
Apple Silicon vs Intel: different solver, bloom, particles, and ripple radius. Infer the branch from the host.