A realtime 2D wave simulation running entirely on the GPU via Metal compute and render shaders. Touch the screen to inject ripples and watch them propagate, reflect off boundaries, interfere with each other, and gradually decay all rendered with a stylised ocean-water aesthetic at up to 120 Hz.
- Preview
- Architecture Overview
- Simulation Pipeline
- The Wave Equation
- Viscosity Model
- Damping
- Boundary Conditions
- Disturbance Injection
- Rendering Pipeline
- Configurable Parameters
- File Reference
┌──────────────────────────────────────────────────────────────┐
│ ContentView (SwiftUI) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ WaveSimulationView (UIViewRepresentable) │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ InteractiveMTKView (MTKView subclass) │ │ │
│ │ │ ┌────────────────────────────────────────────┐ │ │ │
│ │ │ │ WaveRenderer (MTKViewDelegate) │ │ │ │
│ │ │ │ ┌─────────────┐ ┌──────────────────────┐ │ │ │ │
│ │ │ │ │ Compute │ │ Render │ │ │ │ │
│ │ │ │ │ Pipelines │ │ Pipeline │ │ │ │ │
│ │ │ │ │ • waveStep │ │ • waveVertex │ │ │ │ │
│ │ │ │ │ • disturb │ │ • waveFragment │ │ │ │ │
│ │ │ │ └─────────────┘ └──────────────────────┘ │ │ │ │
│ │ │ └────────────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
| Layer | Responsibility |
|---|---|
| ContentView | SwiftUI host. Places WaveSimulationView over any background and overlays the control panel. |
| WaveSimulationView | Bridges SwiftUI ↔ UIKit. Creates the MTKView, wires up the renderer, and forwards touch events. |
| InteractiveMTKView | MTKView subclass that converts UITouch locations to normalised UV coordinates with pressure. |
| WaveRenderer | MTKViewDelegate. Owns the Metal device, command queue, compute/render pipelines, simulation textures, and the frame loop. |
| WaveShaders.metal | All GPU code: simulation kernels (waveStep, applyDisturbances) and rendering shaders (waveVertex, waveFragment). |
On every display refresh (draw(in:)), the renderer:
- Measures frame delta from
CACurrentMediaTime(). - Consumes pending disturbances from the touch queue (thread-safe via
NSLock). - Encodes the disturbance compute pass to inject impulses into the simulation textures.
- Runs N simulation steps via the fixed-timestep accumulator.
- Encodes the render pass — a full-screen quad textured with the current wave height field.
- Presents the drawable.
The simulation runs at a locked 120 Hz physics rate (
accumulator += frameDelta
while accumulator >= Δt and steps < maxSteps:
encode waveStep
rotate textures
accumulator -= Δt
steps += 1
maxSimulationStepsPerFrame = 10— prevents spiral-of-death when the frame rate drops.- If max steps are hit, the accumulator is reset to zero (drops time rather than accumulating debt).
Three single-channel R32Float textures hold the wave height field:
| Texture | Role |
|---|---|
previousTexture |
Height at time |
currentTexture |
Height at time |
nextTexture |
Write target for time |
After each simulation step, the textures rotate:
The grid resolution is derived from the drawable size divided by pixelSize (default 3.0), giving a chunky, pixel-art style grid.
The simulation solves the 2D wave equation with damping:
Where:
| Symbol | Meaning |
|---|---|
| Wave height (displacement) | |
| Wave propagation speed | |
| Damping coefficient | |
| Laplacian — spatial curvature of the surface |
Time is discretised using the Verlet (leapfrog) integration scheme. Given height values at two consecutive timesteps:
Where the propagation factor
The CFL (Courant–Friedrichs–Lewy) condition requires
Rather than the basic 5-point cross stencil, the simulation uses a weighted 9-point stencil for isotropic accuracy. The stencil weights are:
This is computed by decomposing into cardinal and diagonal components:
Why 9-point? The standard 5-point stencil has directional bias — waves travel faster along the grid axes than diagonals. The 9-point stencil dramatically reduces this anisotropy, producing rounder wavefronts.
The difference between the diagonal and cardinal Laplacians captures high-frequency anisotropic content:
This is blended into the acceleration by the dispersion parameter
At
Viscosity smooths the velocity field by blending each cell's velocity with the average of its 4-connected neighbours:
Where
- The
$\Delta t \cdot 12$ scaling makes the viscosity framerate-independent. - Clamping
$\beta$ to$0.5$ prevents over-smoothing that would cause instability. - At
$\nu = 0$ , each cell is independent. At higher values, neighbouring cells synchronise, damping high-frequency content while preserving large-scale motion.
Energy dissipation is modelled as exponential drag on the velocity:
This is framerate-independent. At
The edges use an absorbing boundary with configurable partial reflection. For each cell, the attenuation factor
Where:
| Symbol | Meaning |
|---|---|
| Distance from cell to nearest edge | |
| Boundary width fraction | |
| Reflection coefficient ( |
|
| Interior factor ( |
Both velocity and height are multiplied by
The smoothstep ramp prevents hard discontinuities at the boundary edge.
When the user touches the screen, disturbances are injected via the applyDisturbances compute kernel. Each disturbance
The
The total impulse
These are injected into the Verlet pair:
Why split? In a Verlet scheme, velocity is implicit:
- Adding the same
$d_{\text{pos}}$ to both textures shifts the surface without creating velocity. - Adding
$+\frac{1}{2}v_{\text{kick}}$ to current and$-\frac{1}{2}v_{\text{kick}}$ to previous creates a net velocity of$v_{\text{kick}}$ :
The 82/18 split biases toward velocity injection, which creates more natural-looking expanding ripples rather than a static bump.
A full-screen triangle pair (6 vertices, 2 triangles) covers the entire viewport:
| Vertex | Position (clip space) | UV |
|---|---|---|
| 0 | ||
| 1 | ||
| 2 | ||
| 3 | ||
| 4 | ||
| 5 |
The fragment shader reads the wave height field and produces a stylised ocean surface.
The shader samples the wave height at 5 points (center + 4 cardinal neighbours) using nearest-neighbour filtering (preserving the pixel-art grid).
The height gradient is converted to a 3D surface normal for lighting:
The
A directional light and view-from-above camera:
Diffuse:
Fresnel (Schlick-like, power 4):
Specular with adaptive gloss:
Calm areas → tight highlights (
The wave height drives a blend between deep and shallow water tones. All three colors are user-configurable via ColorPicker controls in the UI:
| Color | Default RGB | Description |
|---|---|---|
| Deep ocean navy | ||
| Ocean teal | ||
| Desaturated sky blue |
White foam appears on wave crests and areas of high curvature:
A subtle grid overlay emphasises the pixel-art aesthetic:
This brightens the edges of each simulation cell by 2% and darkens the centers by 2%, creating a visible grid without harsh lines.
The fragment shader outputs transparent black for calm surface areas. Wave activity is computed as:
If
Otherwise, the alpha fades smoothly:
This allows the SwiftUI view behind WaveSimulationView to show through — you can place any background (image, gradient, solid color) and ripples will composite over it.
The render pipeline uses premultiplied alpha blending:
sourceRGBBlendFactor = .one
destinationRGBBlendFactor = .oneMinusSourceAlpha
This avoids dark fringing at ripple edges and is the standard compositing mode.
All parameters are exposed in the WaveParameters struct and controlled via the UI. Defaults are tuned for realistic ocean physics. Press Defaults in the control panel to restore them.
| Parameter | Default | Range | Effect |
|---|---|---|---|
waveSpeed |
|
Propagation speed. Higher = faster expanding ripples. Clamped by CFL. | |
damping |
|
Energy loss rate ( |
|
viscosity |
|
Velocity smoothing ( |
|
dispersion |
|
High-frequency chop ( |
|
edgeReflection |
|
Boundary reflection coefficient ( |
|
edgeWidth |
|
Absorption zone width ( |
|
brushRadius |
|
Touch radius in normalised UV space ( |
|
impulse |
|
Touch strength multiplier ( |
|
pixelSize |
— | Screen pixels per simulation cell (controls grid resolution). |
Three colors are configurable via ColorPicker controls:
| Color | Default RGB | Role |
|---|---|---|
| Deep | Base color for wave troughs — deep ocean navy | |
| Shallow | Base color for wave crests — ocean teal | |
| Sky | Fresnel reflection tint — desaturated sky blue |
| File | Lines | Description |
|---|---|---|
WaveShaders.metal |
~246 | GPU kernels and shaders — all simulation and rendering logic |
WaveRenderer.swift |
~385 | Metal setup, frame loop, compute/render encoding, texture management |
WaveSimulationView.swift |
~124 | SwiftUI↔UIKit bridge, MTKView configuration, touch handling |
ContentView.swift |
~240 | SwiftUI host with control panel, sliders, color pickers |
PixelWaveApp.swift |
~10 | App entry point |
