Labs · Experiment 6 of 7 · Performance, The game
Baking a city offline for a faster start
- Started
- Updated
- Status
- shipped
- Verdict
- Mixed
If a browser game cuts its ground to the terrain on every start, can that work be done once on a server and downloaded instead, and does it actually make an iPad start faster?
Why
Slopcouver builds the city in the browser from open data every time it starts. On an iPad that takes about a minute to the first frame. One step, "cutting" the city's flat ground layers (roads, sidewalks, parks) to the shape of the terrain, ran on every cold start even though its inputs only change when the data changes. The game already saved cut tiles in the browser's storage, so a second visit was faster, but a first visit, a cleared cache, or a new release paid the full cost.
Hypothesis
- The game's own ground code can run unchanged outside the browser (in Node) and produce exactly the tiles Safari would cut.
- Downloading those tiles instead of cutting them shortens the iPad's cold start, measured as the ground step and the time to first frame.
Method
- Run the builders in Node: a small harness maps the game's imports to the vendored three.js (r187), stubs the page's host framework, runs the game's ground worker in Node worker threads, and provides an in-memory IndexedDB. The game's own
setOnTerrainsaves its cut tiles into it: exactly the records an iPad stores. - Cross-engine check: run the same modules in macOS's JavaScriptCore shell (Safari's engine) and compare every mesh and tile byte for byte with Node's V8.
- Formats: first, exact tiles (unique vertices plus an index, gzip); then a compact format: positions quantised on one city-wide grid (1/64 m across, 1/256 m up), octahedral normals, planar UVs dropped and rebuilt, then meshoptimizer's vertex and index codecs (meshoptimizer 1.3.0, MIT; the decoder already ships with three.js), packed into bundles of many tiles.
- The test: physical iPad Pro 11-inch (M5), Safari, phone tier, starting at the Stanley Park card. Interleaved A/B: A cuts as before, B reads the bake. Every cold run on a never-used origin so nothing is cached. Three cold runs per arm per session.
- Hardware and time: a Mac for the bake (the whole city in about 97 s), the iPad for timing. No cost.
Results
1. Node and Safari cut identical tiles, but disagreed on the name
- All 201 core city-grid meshes are bit-identical in V8 and JavaScriptCore, and all 884 core tile records cut in both engines have the same bytes.
- But the key each tile is saved under hashed the bridge decks' lengths, and
Math.hypotis not correctly rounded: the two engines differ in the last bit for 120 deck numbers. So Safari computed a different key and would never have found a tile baked in Node. Switching toMath.sqrt(dx*dx + dz*dz), which is exact in every engine, made the key identical everywhere. The iPad then used every baked tile, which confirms the fix on the device.
2. The first form saved nothing
| iPad, 6 Nov | First frame (cold ×3) | Ground cut step (cold ×3) | Warm |
|---|---|---|---|
| A: cut in the browser | 63.9 / 73.1 / 72.5 s | 5.1 / 6.9 / 7.0 s | 3.3 s (from browser storage) |
| B: 192 baked tiles, one file each | 71.4 / 73.8 / 76.5 s | 6.9 / 6.8 / 7.3 s | 5.2 s (HTTP cache) |
3. Fewer, smaller files
| Encoding of the Stanley start (190 tiles) | On the wire | Max error | Decode (Node) |
|---|---|---|---|
| Exact (unique vertices + index), gzip | 7.43 MB | 0 | ~80 ms |
| Quantised + delta, gzip | 3.63 MB | 7.8 mm across, 2.0 mm up | ~60 ms |
| Quantised + meshopt codecs + gzip (chosen) | 3.03 MB | 7.8 mm, 2.0 mm | ~15 ms |
4. The compact bundles on the iPad (shipped form)
| iPad, 8 Nov, cold | Run 1 | Run 2 | Run 3 | Median |
|---|---|---|---|---|
| First frame, A (cut) | 62.5 | 53.3 | 55.0 | 55.0 s |
| First frame, B (baked) | 48.8 | 52.6 | 51.8 | 51.8 s |
| Ground cut step, A | 5.59 | 6.97 | 6.88 | 6.88 s |
| Ground step, B | 3.22 | 3.27 | 3.33 | 3.27 s |
| City-grid stage, A | 11.8 | 13.4 | 13.3 | 13.3 s |
| City-grid stage, B | 9.4 | 9.7 | 9.8 | 9.7 s |
- Regions built during play read from the bake too: a jump to UBC read its whole ground layer instead of cutting it; Kitsilano's 21 tiles in 1.6 s against 4.1 s.
- On a slow link the bake loses. Throttled to "4G" (9 Mbps, 60 ms) on a fast desktop CPU: cut 53.6 / 53.6 s against baked 57.4 / 59.2 s. So the loader measures its first bundle; if the rest would take over 3 s, it cuts those tiles instead.
- Whole city: 5,982 tile records in 980 bundles, 74.2 MB gzipped (1.6 GB as cut), worst error 7.8 mm across and 2.0 mm up.
5. What it doesn't fix
The first frame stays near 50 s because the ground was never the main cost. A profile of the whole start puts the traffic network (a road graph built with millions of box tests) and the late "ground" stage of about 30 small parts ahead of it. The next design bakes whole parts (their meshes and the data the rest of the start reads from them), aiming at a start under 15 s; that work is designed and its first slice is being built.
Limitations and threats to validity
- Small samples: three cold runs per arm per session. Sessions drift (shader compile went from 6.1 to 9.1 s over one session), and the first-frame saving (−3.2 s) is inside the run-to-run spread. The ground-step saving (−3.6 s) is well outside it.
- One start location, one tier, one device. Two of 19 tiles in one region didn't match the bake because that region's flat layer depends on which regions loaded first; they were cut instead (the key prevents a wrong tile, not an unused one).
- Beach sand and street details are still cut in the browser (they depend on tier and season).
Verdict
Supported (1): with one exact-arithmetic fix, Node and Safari produce byte-identical ground.
Not supported (2) in the first form: one file per tile saved nothing on the iPad.
Supported (2) in the compact form: the ground step halves (6.88 → 3.27 s median) and the first frame is 3.2 s earlier, within noise. Shipped in the game's Release 141.
Practical application
- For the game: faster, steadier starts on phones and tablets; the ground step went from 5.3–7.5 s to 4.1–4.3 s across runs.
- For any browser app that builds geometry from data: precompute in the same code, not a port; check byte equality across JavaScript engines (watch
Math.hypot,atan2and other functions that aren't correctly rounded); ship few, compact bundles, not many small files; and keep the in-browser path as a fallback for slow links. - What it takes to run: the bake is regenerated with each data release (about 1.5 minutes on a laptop) and checked against the release tree before deploy.
Reproducibility
In the Slopcouver repository (private for now): app/tools/bake/ (Node harness, bake-ground.mjs, quantize.mjs, check-bake, quant-check.mjs, the JavaScriptCore check jsc/check.sh).
(cd app/tools/bake && npm ci) # pins meshoptimizer 1.3.0
node app/tools/bake/bake-ground.mjs --pack <copy of cities/vancouver> --out <folder> # compact format by default; decodes and bound-checks every record
node app/tools/bake/check-bake.mjs <release app dir> <index-stream.json> # the bake matches this code and pack
Credits: three.js (MIT), meshoptimizer by Arseny Kapoulkine (MIT). City data: City of Vancouver Open Data (OGL–Vancouver), OpenStreetMap contributors (ODbL).