Skip to content

Viewer: shared materials, x-ray by material swap, merged component bodies - #33

Merged
midub merged 6 commits into
masterfrom
perf/viewer-materials
Oct 8, 2026
Merged

midub merged 6 commits into
masterfrom
perf/viewer-materials

Conversation

@midub

@midub midub commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Work package "viewer materials" of the performance work (x-ray toggle and first-frame shader builds on big boards). No profile change. Viewer only (packages/viewer), plus docs and CHANGELOG.

Problem

On testcase1-RevC-full every x-ray toggle cost 1.5–1.7 s of main-thread JS, and the first frame spent 1.5 s building node materials. BoardMaterials created one NodeMaterial per mesh (a fresh TSL graph with the layer's state offset as a constant), setXray flipped transparent/depthWrite/needsUpdate on all of them, so three rebuilt every node material on every toggle. And since three 0.186 adds an InstancedMesh's uuid to the render-object cache key, the 204 component InstancedMeshes (105 packages × body/pin-1) each got their own node build.

What this does

  • Shared materials (materials.ts). One node material per (source material, use) for the whole board, with a normal and an x-ray variant (the tint overlay has one, the same in both modes). The graph reads the state texel from the geometry (_state, STATE_ATTRIBUTE), so it holds nothing per mesh and three builds each shader once. Runtime models share by material content (materialKey: KiCad models bring their own copies of a small palette); shared materials are reference-counted and disposed with their last mesh.
  • No extra per-vertex copy for layers. The merged layer attribute now stores the global state texel (feature ID + layer offset) instead of the layer-local feature ID; picking reads it directly and FeatureRanges.scan takes the offset.
  • setXray swaps mesh.material; no material property changes, no needsUpdate. The x-ray variant is set up as today's x-ray mode (opacity mix(xrayOpacity, 1, strength), transparent, no depth writes, the mask's polygon offset on both variants, tints).
  • The other mode is prepared in idle time after the first frame, after runtime models arrive and after a layer is shown: each drawn mesh gets its other variant for one renderer.compileAsync(mesh, camera, scene) call, a mesh at a time in idle callbacks (several while the deadline allows). Per mesh, not the whole scene: compileAsync waits for each pipeline before building the next shader, and on SwiftShader that kept the first toggle at 59–138 ms; per mesh, all shaders are built shortly after load and pipelines compile in the background.
  • Merged component bodies (bodies.ts). The asset's bodies (placeholders, pin-1 markers, embedded user models) are baked into one static mesh per material and vertex layout, in board coordinates, with a per-vertex state texel; mirrored placements get their winding restored. Runtime models replacing placeholders drop those instances out of the merged index buffer (draw range); modelsShown = false / clearModels put them back. Picking tests each drawn instance's box, then its triangles. Component bounds are computed as before (transformed AABBs, float32 matrices), so framing and widgets don't move. Meshes whose copies would add more than 2¹⁸ vertices stay one InstancedMesh each (texel per instance). Runtime models stay instanced, on a shallow copy of the shared geometry with an instanced _state.

Numbers: testcase1-RevC-full (tc1-full.glb, 277 MB)

Method: perf-prof.mjs (adapted: node builds counted on the sync and async build paths, draw calls per toggle, an extra profile of the idle time after the first frame), headless Chromium (Playwright 1.63 image) on docker-server, WebGPU on SwiftShader, CDP CPU profiles at 500 µs. Unminified dev pages built in the same dev-image call: master = origin/master b6932ae viewer sources, branch = this PR. Main-thread JS = samples outside (idle)/(program). Wall-clock toggle times are SwiftShader rendering and mean nothing.

master this PR
Load to first frame, main-thread JS 3,436 ms 1,548–1,732 ms (5 runs)
Node builds at the first frame 1,610 ms 86–122 ms
Draw calls 223 21
Toggles on / off / on / off, main-thread JS 1,647 / 1,603 / 1,544 / 1,522 ms 9 / 3 / 6 / 1 ms (final run)
Node builds in the toggles 1,388 / 1,388 / 1,347 / 1,308 ms 0 / 0 / 0 / 0
Prewarm in idle time (node builds) – 5–29 ms

First-frame wall time on SwiftShader: master 28 s (223 pipelines compiled on the CPU), this PR 1.5–8 s (machine load varied; another session shared docker-server).

Numbers: KiCad royalblue54l-feather with runtime models (React demo)

A scratch Playwright spec (not committed) on the React demo site, master site (origin/master's packages/viewer) and this PR's site built in the same dev-image call; ?sample=royalblue54l-feather with real KiCad models from gitlab.com (LIVE_GITLAB=1): 41 of 52 components, 11 models (6 not in KiCad's libraries); iso view, 8 s idle, four toggles with CDP profiles.

master this PR
Draw calls 67 49
Toggles, main-thread JS (WebGPU) 440 / 695 / 448 / 414 ms 2 / 1 / 5 / 2 ms
Toggles, main-thread JS (WebGL2, navigator.gpu hidden) 2,273 / 2,676 / 4,044 / 4,547 ms ¹ 2 / 1 / 3 / 2 ms

¹ gitlab.com rate-limited that run (36 models).

Screenshots (described, not committed). Normal view: identical to master (0.007 % of pixels differ by more than 8/255: the "Converted in" timing text). X-ray: the board translucent, copper at 0.6, bodies at 0.15, runtime models translucent like the placeholders, as on master; 0.24 % of pixels differ by more than 8/255 and 0.005 % by more than 40/255, on bodies (header pins, ICs). Cause: translucent blend order. The merged body mesh is sorted as one object against the translucent layers, where master sorted each package's instanced mesh separately. Same board without runtime models on the dev page: top/bottom/iso identical, x-ray 0.68 % / 0.006 %. I tried merging per mounting side: top bodies are then drawn after all layers (physically better), but 20 % of x-ray pixels differ slightly from master, so I kept one merged mesh per material. WebGL2 vs WebGPU on this PR differ only at anti-aliased edges (0.5 %), so the per-instance state texel works on both backends.

Spec changes

None.

Tests

  • New materials.test.ts: sharing (same source, equal copies, other x-ray opacity, other material), the swap without material version changes, the variants' transparent/depthWrite/polygon offset, the overlay the same in both modes, assignment in x-ray mode, reference-counted release, materialKey.
  • New bodies.test.ts: merge into board coordinates with state texels, picking per instance, filtering (draw range, bounds, visibility) and restoring, mirrored matrices keep outward winding, batchBodies merging per material and layout and instancing large repeated meshes (source geometry untouched).
  • Updated: board-model (merged batches), dense (11k components in two merged meshes), runtime-models (placeholders replaced and restored in the merged batches; model batches instanced). vitest.config.ts: hookTimeout 60 s like testTimeout (the dense fixture's beforeAll hit 10 s once under the parallel pnpm test; merging adds no measurable time: fromGltf 277–312 ms vs master 270–301 ms for 11k components).
  • In the dev image: cargo fmt --check, clippy, cargo test --workspace and the conformance test (BOARDUI_REQUIRE_IPC_TESTCASES=1), the wasm build; pnpm install && pnpm lint && pnpm build && pnpm test: all pass. Demo e2e (both apps, as CI, with the IPC test cases): 56 passed, 14 skipped (the review/live specs); no screenshot changed beyond the tolerance, none updated.
  • GitHub CI: green.

Known gaps and follow-ups

  • Runtime models stay instanced: one render object, and one node build per variant, per model part (three keys instanced meshes by uuid). Fine for royalblue (49 draw calls); a board with hundreds of distinct models would still build one shader per part. Merging them on arrival is possible later.
  • Memory: merged copies of the asset's bodies (testcase1: 67k vertices, ~1 MB of positions and texels, plus u32 indices kept twice for filtering). Embedded user models are merged only while the copies stay within 2¹⁸ extra vertices per mesh.
  • Translucent sort order in x-ray changes slightly (above).
  • Mirrored placements (a reflection, spec §6.8: scale (1, −1, 1)) now render with outward faces; the instanced mesh drew them inside out with FrontSide culling. Rare in practice.
  • The prewarm covers meshes drawn at the time (in the frustum); others build their other variant at the first toggle (cheap, as materials are shared). On SwiftShader its pipelines take many seconds in the GPU process; main-thread cost stays small.
  • Merged layer meshes carry _state instead of _feature_id_0 (internal).

midub added 3 commits October 7, 2026 15:21
…nt bodies

One node material per source material and use for the whole board, with a normal and an
x-ray variant; the state texel comes from the geometry (merged layer meshes store the global
texel instead of the feature ID). setXray swaps the meshes' materials, and the other mode is
compiled in idle time after loading. The asset's component bodies are merged into one static
mesh per material (large repeated meshes stay instanced); runtime models stay instanced and
share their materials by content.

Work in progress.
@CLAassistant

CLAassistant commented Oct 7, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@midub
midub marked this pull request as ready for review October 7, 2026 16:26
@midub
midub merged commit 4fe9181 into master Oct 8, 2026
5 checks passed
@midub
midub deleted the perf/viewer-materials branch October 8, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants