# Rendering performance

The renderer keeps the imported geometry, materials, baked lighting and direct camera response. Movement code and simulation rate were not changed by these renderer optimizations.

## Measurement

`scripts/profile-renderer.ts` replays the saved Boreas command fixture in the headless simulation and extracts 690 camera poses across the complete route. An isolated browser scene renders the same poses twice, after a warm-up covering the route. This measures rendering separately from input, simulation and HUD costs. It does not inject gameplay state or establish additional physics fidelity.

The measurements below used headless Microsoft Edge on the local RTX 3090 through ANGLE/D3D11, a 1600 × 900 CSS-pixel viewport, 1,380 measured frames and approximately 346 GPU timer queries. CPU time surrounds renderer submission; GPU time uses `EXT_disjoint_timer_query_webgl2`, excluding disjoint samples. Browser frame intervals were around 2.8–2.9 ms in both versions, so these results establish lower rendering cost, not a measured increase in displayed FPS or lower input-to-photon latency.

| Condition | CPU mean / p95 | GPU mean / p95 | Mean draws | Mean submitted triangles |
| --- | --- | --- | --- | --- |
| Original, DPR 1 | 0.497 / 1.000 ms | 0.538 / 1.223 ms | 221.3 | 887,359 |
| Optimized, DPR 1 native | 0.264 / 0.500 ms | 0.495 / 0.869 ms | 70.8 | 821,283 |
| Original, DPR 2 native | 0.492 / 1.000 ms | 1.249 / 1.993 ms | 221.3 | 887,359 |
| Optimized, DPR 2 native | 0.243 / 0.400 ms | 0.842 / 1.330 ms | 70.8 | 821,283 |
| Optimized, DPR 2 balanced | 0.248 / 0.500 ms | 0.536 / 1.005 ms | 70.8 | 821,283 |

The live geometry count fell from 755 to 159 for this route; texture and shader program counts stayed at 60 and 21. Renderer construction took 25–31 ms in the final runs, versus 57–58 ms before. Construction excludes first-use shader compilation and texture upload. Map-loading checks separately exercise those cold paths; their large first frame is not a sustained rendering measurement.

Source reports are `fixtures/renderer-profile-before.json`, `renderer-profile-before-hidpi.json`, `renderer-profile-after-branchless.json`, `renderer-profile-after-hidpi-native.json` and `renderer-profile-after-hidpi-balanced.json`. Earlier `after`/`after-repeat` reports retain an investigated GPU regression: a dynamic per-vertex lighting branch was slower. The final branchless lighting selection removed that regression; tolerances or timer results were not adjusted to conceal it.

## Changes and visual preservation

- Opaque prop instances share larger model/material batches, while conservative per-instance frustum spheres exclude wholly invisible instances. Original transforms and per-instance lighting offsets remain intact. A small bounds margin avoids edge clipping.
- Transparent model batches keep their original spatial grouping and ordering. No additional approximation to transparent sorting was introduced.
- Immutable model vertex/index buffers are shared across batches instead of decoded and uploaded repeatedly. Instance matrices and lighting offsets remain independent.
- Static objects no longer recalculate unchanged world matrices. Moving zone labels explicitly update their own matrices. Camera position and current mouse angles still apply every rendered frame, without easing.
- Visibility lists and instance-buffer uploads change only when the camera frustum or visible instance set changes. Model LOD 0, displacement resolution, shaders, texture sampling and source-unit scale remain unchanged.

Three matching route views at DPR 1 have **zero differing pixels** before versus after. At DPR 2, the start and late views are identical; the middle view has 22 differing pixels out of 5,760,000, with maximum channel difference 1/255. These results are recorded in `fixtures/renderer-pixel-comparison.json`; screenshots are under `docs/screenshots/performance/`. This compares this browser renderer before and after optimization, not the browser output against CSS.

## Stable rendering choices

| Setting | Drawing-buffer ratio | Anisotropy |
| --- | --- | --- |
| Native | Device pixel ratio, capped at 2 | Up to 8× |
| Balanced | Device pixel ratio, capped at 1 | Up to 8× |
| Performance | 0.75 × min(device pixel ratio, 1) | Up to 2× |

Balanced is the application's default. On a DPR-2 display it submits one quarter as many pixels as Native, preserving the CSS-pixel viewport and identical camera FOV. On a DPR-1 display Native and Balanced match. Performance deliberately trades sharpness and oblique texture filtering for lower rendering cost. The chosen setting is stable during a run; no automatic resolution adaptation changes the image mid-run. Rendering choices do not change collision, simulation commands, record keys, input sensitivity or timing.

Changing anisotropy can trigger a one-time texture upload while the menu is open. Native-to-Balanced changes retain anisotropy and avoid redundant texture reuploads.

## Generic map and lifecycle checks

`loadBspVisuals(slug)` loads one map; `loadBoreasVisuals()` remains a compatibility alias. Missing sky, fog, prop and lighting metadata have explicit defaults. Image/mesh loading has bounded concurrency. Renderer disposal releases geometry wrappers, materials, textures, cubemaps and marker resources and resets shared WebGL pixel-store flags before context reuse.

`scripts/bsp-loader-qa.ts` cycles Boreas → Utopia → Mesa → an empty map with optional data omitted → Boreas in one canvas at DPR 2. It asserts all three quality sizes and verifies a synthetic half-height sky sampling fixture. `fixtures/bsp-loader-qa.json` records zero console errors or warnings and all size/sampling assertions passing.

Utopia's packed sky has half-height side textures, a 1 × 1 bottom and authored texture scaling. WebGL cubemaps require equally sized square faces. The loader bakes the authored transform into clamped square sky images once, sampling color in linear space; already compatible Boreas and Mesa skies pass through unchanged. This removes invalid cube upload warnings without stretching the authored sky.

The whole-game UI, native controls, audio, map switching and practice checks are in `scripts/browser-classic-qa.ts` / `fixtures/browser-classic-results.json`: **10 checks pass, zero console errors or warnings**. This includes actual Pointer Lock and forward input on all three maps, quality and audio persistence, pause/resume audio lifecycle, all eight lab stations, and two complete map-switch cycles with stable per-map geometry/texture/program counts from matching fresh-spawn views. Live resource counts can identify growth across switches, but do not prove absence of every retained JavaScript or driver allocation. Finish-marker screenshots use separate inspection cameras; they do not establish complete runs on the additional maps.

The final marker-only follow-up (`SURF_QA_MARKERS=1`) uses the actual recorded CSS positions/angles 80, 50 and 30 ticks before each finish and at entry, adding the standing/crouched eye height. Its 15 views cover all map starts and four finish approaches per map. Visual inspection confirmed corrected Mesa start markings on the deck and visible gold finish signs/landing borders on the native approaches. `fixtures/browser-classic-marker-results.json` records each source frame and zero errors/warnings; images are under `docs/screenshots/classics/`. Those cameras are an inspection aid, not command-driven gameplay or an independent physics comparison.

A short active, stationary whole-game sample after native movement and restart used 1280 × 720 CSS pixels, DPR 2 and Balanced quality. Frame interval median/p95 was 2.8/2.9 ms for all three maps. The application's smoothed render-submission average during that sample was 0.501 ms (Boreas), 0.149 ms (Utopia), 0.196 ms (Mesa); corresponding smoothed physics cost averaged 0.020/0.015/0.018 ms per render frame, including frames with no simulation tick. This is a current spawn observation, not a whole-course benchmark or a before/after whole-game comparison.

## Additional-map route views

The same isolated renderer profiler also covers **500 uniformly sampled native CSS camera poses from start to finish on each additional map**, with two measured passes after warm-up. It uses 1600 × 900 pixels, DPR 1, Balanced quality and the same RTX 3090/Edge environment. Camera origins come from recorded positions plus 47/64-unit eye height inferred from the duck button; these views are inspection inputs, not command-driven playback and not playability evidence. The UI, audio and movement simulation do not run in this measurement.

| Map | CPU mean / p95 | GPU mean / p95 | Draws mean / p95 | Triangles mean / p95 |
| --- | --- | --- | --- | --- |
| Utopia | 0.080 / 0.200 ms | 0.104 / 0.146 ms | 12.97 / 15 | 59,001 / 59,299 |
| Mesa | 0.121 / 0.200 ms | 0.224 / 0.364 ms | 36.66 / 60 | 279,736 / 413,858 |

Each report includes 1,000 CPU/draw samples and 250 valid GPU queries, zero console errors or warnings, and start/middle/late screenshots. Utopia samples native frames 310–3868; Mesa samples 144–3395. These measurements do not identify a sustained rendering bottleneck in the middle or final sections on this GPU. They do not establish FPS on other hardware. Frame intervals remained approximately 2.8–2.9 ms and are not used to claim an FPS improvement.

The saved reports are `fixtures/renderer-profile-utopia-balanced.json` and `fixtures/renderer-profile-mesa-balanced.json`. Utopia's one-time renderer creation was 332 ms, primarily while normalizing its authored non-square sky faces; Mesa's was 28 ms. This occurs during map loading, outside simulation. No additional runtime change was made for this acceptable one-time cost.

To reproduce, set `SURF_PROFILE_QUALITY=balanced` and run `node --import tsx scripts/profile-renderer.ts utopia-balanced --slug utopia` (or substitute `mesa` in both places).

Exact Source PVS and GPU occlusion, model LOD selection, displacement tessellation, and independent CSS visual parity remain outside this renderer's evidence.

With the development server running, reproduce the renderer profile with `node --import tsx scripts/profile-renderer.ts current`. Set `SURF_PROFILE_DPR=2` and `SURF_PROFILE_QUALITY=balanced` for the HiDPI balanced comparison. Production builds are not required by these isolated development checks.
