Architecture and the reasoning behind it. For the physics, see
model.md.
The temperature field is GPU-native data, not a Scenery-rendered image that happens to contain a field.
Everything else follows from that. Fields are the primary objects: a scalar temperature field, a vector velocity field, a material field. Compute shaders evolve them, render pipelines draw them, and Scenery provides the pedagogical interface around them — buttons, sliders, probes, graphs, labels, accessibility, screen navigation.
The practical consequence is that resolution is a parameter rather than an
assumption. A 128 × 128 classroom grid and a 2048 × 2048 grid are the same code
with a different number in one constructor. Nothing above SimulationDomain knows
how many cells there are, and the scene graph contains exactly one node for
the field at every resolution.
HEAT MODEL
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Temperature Velocity Material
field field field
│ │ │
└───────────────┼───────────────┘
▼
WebGPU compute
(advect, diffuse, brush)
│
┌───────────┴───────────┐
▼ ▼
Updated field Derived fields
│ ∇T, q, |∇T|
└───────────┬───────────┘
▼
WebGPU rendering
(colour map, isotherms, arrows, tracers)
│
▼
Scenery UI layer
src/
init.ts assert.ts splash.ts brand.ts main.ts bootstrap chain (never reorder)
HeatTransferColors.ts ProfileColorProperty entries
HeatTransferConstants.ts every named numeric constant
HeatTransferNamespace.ts Namespace("heat-transfer")
i18n/ StringManager + en / es / fr
preferences/ resolution, field-status readout, query parameters
common/
field/ ← the field engine; no Scenery anywhere below here
SimulationDomain.ts the only place that knows the grid size
FieldTypes.ts boundary conditions, presets, layers, materials
Materials.ts material presets
ColorMap.ts one ramp, generated into both TS and WGSL
VelocityPresets.ts analytic divergence-free flows
kernels.ts the reference numerics, in plain TypeScript
FieldEngine.ts the interface the model talks to
FieldEngineBase.ts everything both backends do identically
createFieldEngine.ts backend selection
cpu/ reference backend + 2-D canvas renderer
gpu/ WebGPU backend, device acquisition, WGSL
model/FieldSimulationModel.ts the model every screen composes
view/ the shared Scenery layer
temperature/ conduction/ convection/ combined/ materials/
one folder per screen: Screen, model/, view/
FieldEngine is an interface, and there are two implementations:
WebGpuFieldEngine |
CpuFieldEngine |
|
|---|---|---|
| Storage | GPU textures | Float32Array |
| Evolution | WGSL compute passes | kernels.ts |
| Drawing | WGSL render passes | 2-D canvas API |
| Resolution | up to 2048² | capped at 128² |
| Role | the primary path | fallback, oracle, and test subject |
The CPU backend exists for three reasons, in order of importance:
- The simulation still runs where WebGPU does not — older browsers, locked down machines, software rendering.
- It is the executable specification of the physics. The WGSL shaders are
written to reproduce
kernels.tsstatement for statement — the function names are deliberately aligned (fetchCell/fetchScalar,bilinearSample/bilinearScalar) so that a change to the physics has to be made in the same two places. The unit tests pinkernels.tsdown, and therefore indirectly constrain the shaders. - It makes the architectural claim falsifiable. If the model can drive an array-of-floats backend and a texture backend without noticing the difference, then the field abstraction really is the interface, not the GPU.
Point 2 was checked directly during development: both engines were run in a browser from the same initial condition, and after equivalent numbers of steps their mean temperatures agreed to six significant figures (296.16625 K vs 296.16618 K). Two independent implementations conserving the same energy to that precision is the strongest available evidence that the shaders say what the kernels say.
One rule decides what lives where:
The CPU authors, the GPU evolves.
- The material field is written by user actions (uniform selection, brush strokes), so the CPU holds the authoritative copy and the backend uploads it.
- The velocity field is an analytic preset, computed on the CPU and uploaded.
- The temperature field is genuinely GPU-owned. It is the only thing that needs to come back.
That is why every read-out feature — probe, legend, cross-section graph, field
statistics — is implemented once, in FieldEngineBase, against a CPU mirror
of the temperature field. There is no duplicated sampling code, and the two
backends cannot disagree about what a probe reads.
copyTextureToBuffer + mapAsync every four frames, at most one readback in
flight. A synchronous read would stall the pipeline every frame; a probe that is a
few tens of milliseconds behind is imperceptible. Brush strokes are applied to the
mirror immediately as well as being dispatched to the texture — with identical
arithmetic — so painting still feels instant.
| Resource | Format | Notes |
|---|---|---|
temperature[2] |
r32float |
ping-pong pair; the only GPU-owned state |
velocity |
rg32float |
CPU-authored, read-only on the GPU |
material |
rgba32float |
(k_x, k_y, ρc_p, unused) |
particles |
storage buffer | (u, v, life, seed) per tracer |
temperature A ──advect──▶ temperature B ──diffuse──▶ temperature A ──▶ …
All substeps iterations are recorded into a single command buffer, so a
frame is one submission no matter how many substeps it takes. Brush strokes are a
further pass over the same pair, which is why painting heat is a write into the
GPU texture rather than a CPU upload of the whole field.
Explicit bind group layouts, not layout: "auto". Every field texture is a
32-bit float format, which can only be bound as unfilterable-float. An inferred
layout asks for a filterable float and fails validation on any device that does
not advertise the optional float32-filterable feature — that is, most of them.
For the same reason every fetch is a textureLoad and bilinear interpolation is
done by hand, in both the shaders and the kernels.
Uniform block layouts are hand-checked. WGSL's alignment rules put vec4 on
16-byte boundaries; the *_PARAMS_BYTES constants next to each shader record the
resulting sizes so the DataView writes and the struct declarations cannot drift
apart.
The field shader draws contours analytically:
let level = temperature / interval;
let distance = abs(fract(level - 0.5) - 0.5) / max(fwidth(level), 1e-5);
let line = 1.0 - smoothstep(0.0, 1.5, distance);fwidth gives the screen-space rate of change of T/interval, so dividing the
distance to the nearest contour by it produces a line exactly one pixel wide at
any zoom and any grid resolution, antialiased, for free. The CPU renderer cannot
do this and uses marching squares instead, with per-cell level pruning so that
most cells test zero or one level rather than all twenty.
Both are instanced draws with no vertex buffer. The arrow vertex shader reads the temperature and material textures directly, applies Fourier's law, and lays out a nine-vertex arrow in clip space; an arrow below the noise floor collapses to a degenerate triangle rather than branching. The tracer vertex shader reads the same storage buffer the particle compute pass writes, so tracer positions never make a round trip through the CPU.
initializeGpuContext runs once, during startup, while the splash screen is
still up. It is the only asynchronous step in the whole simulation; after it
resolves, building a field engine is an ordinary synchronous constructor call and
a screen's model factory — which SceneryStack invokes lazily and synchronously —
can just call it.
It checks three things, and any failure demotes the simulation to the CPU backend before a student sees anything:
- A device can be acquired. No
navigator.gpu, no adapter, a rejected device request. - Every shader compiles. WGSL compilation is asynchronous and
createShaderModulenever throws, sogetCompilationInfo()is the only reliable way to find out. A shader that fails on one driver would otherwise surface as a silently black canvas. - The canvas can be presented. This one was found by running the simulation
rather than by reasoning about it. The field reaches the scene graph as a
Scenery
Imagewrapping the engine's canvas, so the browser has to be able todrawImagea WebGPU-backed canvas into a 2-D one. That works on hardware — but not on every software rasterizer. Some configurations happily create a device, compile every shader, and run compute passes correctly while presenting nothing a 2-D context can read. The symptom is a completely blank field with no error anywhere, which is strictly worse than the CPU fallback. So the check clears a 4 × 4 canvas to red, copies it, and looks.
FieldNode is the whole of it: one Image over the engine's canvas, plus the
input that turns pointer and keyboard gestures into brush strokes in unit-square
coordinates.
Scenery's Image only ever advertises the Canvas and WebGL renderers for a canvas
source — never SVG, which would have to re-encode a data URL every frame — so
compositing costs one drawImage per frame regardless of grid size.
The frame loop, in FieldScreenView.step:
- advance the model, which advances the fields on the GPU
- run the visualization passes over the new state
invalidateImage()so Scenery repaints
Step 2 reads the overlay colours out of HeatTransferColors each frame and hands
them to the engine as a FieldRenderStyle. That is how the WGSL render passes
follow the active colour profile — including Projector Mode — without knowing that
colour profiles exist. The temperature ramp itself is deliberately not themed:
it is a quantitative encoding, and a legend that changed with the theme would lie.
Three systems, converted only in SimulationDomain and FieldNode:
grid (i, j) |
integer cell indices |
unit (u, v) |
normalized [0,1]², origin top-left, v down |
model (x, y) |
metres |
The FieldEngine interface speaks only unit coordinates and physical units,
never cells. That is what makes it resolution-agnostic in a way the type system
enforces.
FieldSimulationModel owns one engine and the reactive state that drives it. It
never touches a texture, a shader, or a typed array — it hands the engine
parameters and strokes and asks for samples.
Each screen composes it (composition, not inheritance) and adds nothing but a configuration:
this.field = new FieldSimulationModel({
advectionEnabled: true,
boundaryCondition: BoundaryCondition.PERIODIC,
defaultLayers: { temperature: true, velocity: true, … },
initialCondition: InitialCondition.HOT_SPOT,
initialFlowPreset: FlowPreset.CHANNEL,
resolution: preferences.resolutionProperty.value,
displaySize: FIELD_VIEW_SIZE * 2,
initiallyPlaying: true,
});Screens differ only in which Properties they expose and what that config enables. Nothing about the physics or the substrate is per-screen.
The one piece of genuinely screen-local state is the Heat Transfer screen's
transport balance, which lives on TransportControlPanel because it is a view
of the two multipliers the model actually holds. That is also why that screen
overrides reset().
Every layer checkbox changes which render pass runs over the current GPU state and nothing else. Turning on heat flux does not start computing heat flux — the gradient was always there — it starts drawing it.
This is why the Heat Transfer screen groups the layer checkboxes alone in their own panel, well away from the transport control. A student who notices that toggling four checkboxes never disturbs the field has understood something worth understanding, and the UI should not blur it.
The three required layers, per Baton/ACCESSIBILITY.md:
- PDOM names. Every interactive node carries an
accessibleNamefrom thea11ystring group. - Screen summaries. Each
ScreenViewregisters a*ScreenSummaryContentwhosecurrentDetailsContentis a liveDerivedPropertyover the field's coldest and hottest points — the non-visual counterpart of watching the colours change. - Keyboard. A wrapper
NodecarriespdomOrder(field first, Reset All last); the probe and cross-section handles useKeyboardDragListener.
The field itself needed something the standard controls do not cover: it is not a
slider, a combo box, or a draggable object but a continuous canvas you deposit
into. So it has a paint cursor — arrow keys move it, shift moves it in finer
steps, space or enter paints — shown as a crosshair whenever the field has focus,
exactly as the hover ring is shown to a pointer user. HeatBrushKeyboardHelpSection
documents it in the keyboard-help dialog.
WebGPU is not available under Vitest, so the suites exercise the CPU kernels and the model layer — which is the point of having written the kernels as the reference implementation.
| Suite | Covers |
|---|---|
kernels.test.ts |
energy conservation under each boundary condition, no overshoot at the stability limit, the direction of heat flow, harmonic-mean barriers, anisotropic flux, advective translation |
SimulationDomain.test.ts |
resolution independence: the plate stays the same size as the grid refines |
ColorMap.test.ts |
monotone ordering, WGSL generation matching the TS sampler |
VelocityPresets.test.ts |
bounded magnitude, divergence-free flows |
CpuFieldEngine.test.ts |
the FieldEngine interface end to end — written so it would pass unchanged against the GPU backend |
FieldSimulationModel.test.ts |
Property wiring, reset, Péclet, CPU clamping |
memory-leak.test.ts |
engines and models are collected after dispose() |
Two tests are worth calling out as things that caught real bugs:
carries heat downstream at the flow speedruns to a simulated duration rather than a step count, and asserts the blob travelledspeed × time. Written the obvious way — a fixed number of steps — it passed for the wrong reason and then failed once periodic boundaries were introduced, because the blob had gone all the way round.- The energy-conservation tests compare relative error. The totals are on the order of 10⁷ J/m in Float32, so an absolute tolerance either fails on round-off or is meaningless.
The field engine is not heat-specific. SimulationDomain, FieldEngine,
ColorMap, the ping-pong compute machinery, and the render passes are a general
GPU field framework that happens to implement heat transfer first. The same
infrastructure would carry electric potential, gravitational fields, wave
propagation, concentration fields, or a real fluid solver, with the physics
confined to kernels.ts and the compute shaders.
Nearer-term work, in rough order of value:
- Buoyancy coupling on the plume preset, so the flow is driven by the temperature field instead of prescribed alongside it.
- A GPU reduction for field statistics and the arrow scale, removing the last dependency of the render path on the CPU mirror.
- Convective and radiative loss as boundary options, which the boundary machinery is already shaped to accept.
- A full conductivity tensor rather than a diagonal one, giving flux that bends in a direction unrelated to either axis.