Contributing
Development Setup
# Install Rust (nightly recommended for WASM SIMD)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Clone and build
git clone <repo-url>
cd wgpu-triangle
make build
Code Quality
Pre-commit Hooks
The project has pre-commit hooks that run cargo fmt and cargo clippy before each commit. Make sure your code passes both:
Testing
# Run all tests (~560, offline)
make test
# Run a specific test
cargo test test_interpolate_linear
# Run tests with output
cargo test -- --nocapture
Test conventions:
- Tests are embedded in source files via
#[cfg(test)]modules - Mock external dependencies (HTTP, GPU)
- Test edge cases: empty inputs, invalid data, boundary values
- One assertion per test when practical
HTTP fixtures
Tests never touch the network. Anything that fetches (style, TileJSON,
tiles, sprites, glyphs) goes through fixture_http_client::for_tests(),
which serves committed responses from tests/fixtures/http/{host}/{path}.
API keys are dropped from the file names and replaced by FIXTURE_KEY in
the bodies. A request with no fixture gets a 404 and a warning naming the URL.
Re-record after a style or tile change, or when a test needs new tiles:
This fetches every URL live and rewrites the fixtures it touched. Delete
tests/fixtures/http first for a clean set. The glyphs and sprites are the
ones in the maps repository's assets/, copied here so a checkout needs
nothing else.
Benchmarks
Criterion.rs benchmarks cover the CPU-hot paths:
| Benchmark | Module |
|---|---|
mvt_decode |
Protobuf parsing |
tessellation |
Polygon triangulation |
collision |
Label placement |
expressions |
Style expression evaluation |
extraction |
Geometry extraction pipeline |
shape (nimbus-shaper) |
Complex-script shaping and SDF rasterisation, one label per script |
They read the committed tiles and style in benches/fixtures/
(cargo run --bin record-fixtures refreshes them).
Scene benchmark
bench_render renders fixed scenes headless from the HTTP fixtures and
measures each frame the way a host draws it (update_frame, encode, submit,
wait for the GPU):
| Scene | What it draws |
|---|---|
pan |
flat z14 Montpellier, panning back and forth |
zoom |
Montpellier, zooming between z12 and z17 |
buildings |
tilted z16 Montpellier, buildings and shadows, orbiting |
models |
the Eiffel Tower and the car (needs make models), orbiting |
globe |
the globe at z3, turning |
scripts |
z15 Cairo, Arabic labels through the complex shaper |
night |
tilted z15.5 Montpellier at midnight |
For each scene it reports p50, p95 and max of every phase (poll, extract,
tile_data, prepare, render, gpu_wait, whole frame), and memory: the
heap's peak and what stays live (a counting global allocator), allocations per
frame, the bytes the GPU device holds (Metal's currentAllocatedSize; wgpu
reports nothing on Metal), and the tiles cached. target/bench/scenes.json
holds it all.
make bench-scenes # all scenes, 120 frames each
SCENE=buildings,night FRAMES=300 make bench-scenes
make bench-baseline # save criterion + scene baselines
make bench-check # fail on a regression
The baselines live in benches/baselines/:
memory.jsonis committed. Memory is nearly deterministic, so it is checked tightly: 5% by default, twice that for the heap peak, which depends on how the worker threads interleave. Each figure is the median of three runs. A scene whose tolerance isnullis reported but not gated:zoomandscriptsload tiles while they are measured, and what they hold depends on when the tiles land.cpu-<host>.jsonis per machine and gitignored. Phases fail past 10% plus 0.05 ms at p50; criterion benchmarks past 10% on the mean.- Timing is not counted when either run was on a busy machine (load above half
the CPU count).
bench-baselinewill not save a CPU baseline then.
Re-run make bench-baseline after a change that is meant to move the numbers,
and commit memory.json with it.
Profiling
Instruments records any scene from the command line, and opens the trace:
SCENE=buildings make profile-cpu # Time Profiler
SCENE=models make profile-alloc # Allocations
SCENE=night make profile-gpu # Metal System Trace: per-pass GPU time
SCENE=pan make profile-heap # DHAT: dhat-heap.json, for dh_view
FRAMES sets how long it records (2000 by default). The profiling build
keeps its symbols in a .dSYM and is built with legacy symbol mangling, in
target/instruments: Instruments cannot demangle v0, nightly's default.
Coverage
# Generate LCOV coverage report
make coverage
# Run SonarQube analysis (requires local SonarQube instance)
make sonar
Target: ≥80% coverage, zero bugs, zero vulnerabilities, zero code smells.
Architecture Guidelines
- Extract-Prepare-Render: all new rendering features should follow this pattern
- Free functions for geometry emission to avoid borrow conflicts
- Tile-relative Mercator encoding for all vertex positions
- Storage buffers for per-label/per-tile data (not per-vertex attributes)
[profile.dev.package."*"] opt-level = 2: dependencies are optimized even in debug builds
Module Ownership
| Area | Key files |
|---|---|
| Rendering pipeline | src/renderer/ |
| Tile management | src/tile_loader/, src/tile_coords.rs |
| Style evaluation | src/style_eval/, src/style.rs, src/expressions/ |
| Text/icons | src/glyph_atlas.rs, src/text_shaper.rs, src/sprite.rs, src/collision.rs |
| Platform bridges | src/ffi.rs (iOS), src/web.rs (WASM) |
| Shaders | shaders/*.wgsl |