Skip to content

fix(FastModelPicker): getFullPick concurrency + vitest - #777

Open
ShaMan123 wants to merge 7 commits into
ThatOpen:mainfrom
ShaMan123:fix/getFullPick#775
Open

ShaMan123 wants to merge 7 commits into
ThatOpen:mainfrom
ShaMan123:fix/getFullPick#775

Conversation

@ShaMan123

@ShaMan123 ShaMan123 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Description

fixes #775

getFullPick used to call getItemAt to resolve localId. That seemed right for code reuse, but it introduces a bug: resolving localId is async (a round trip to the fragments thread), and during that time mutable deps such as the camera can change, making point and normal evaluation wrong.

Fixing it meant looking at how the picker renders, which raised a second question: the id, depth and normal passes each walked the scene and rendered it again, so a single getFullPick cost K + 2 renderer.render calls (K = loaded models) and 3 blocking GPU readbacks. This PR fixes the drift and consolidates the three passes into one render.

During work I discovered I bug fixed in fix(): respect local clipping planes (local clipping planes were not respected, global clipping planes were added twice).

I also noticed:

  • getModelAt, getPointAt and getNormalAt are not used anywhere and can be dropped (they were the targets of the dead discussion around refactoring them to being sync methods - since the initial approach was to fix by moving to sync)
  • debugMode should be private or a getter/setter

Picker logic

  • Everything is decoded up front, against a camera snapshot. Each pick renders through a private THREE.Camera copied from the world camera, and the decoders unproject through that copy, so a camera that moves while localId is in flight can no longer drift point, normal or distance.
  • One render per pick instead of K + 2. A single private renderPick(ndc, request, target, buffers) renders the BIM scene once into a multiple-render-target with one RGBA8 attachment per output (id, depth, normal). One depth test now decides all three outputs, so they always describe the same fragment by construction.
  • The config object selects what is read back, not what is rendered. Rasterizing one pixel, the only per-output cost left is the readback, so getPointAt reads one attachment and getFullPick reads three. Per-request shader variants were considered and rejected: they would cost a program compile each.
  • Materials are no longer swapped on fragments' meshes. The pick shader is installed as scene.overrideMaterial and the model byte is set per draw from material.onBeforeRender. Because the meshes keep their own material arrays, per-item draw groups, hidden items and highlight slots behave exactly as they do on screen, and the id shader can no longer leak onto a mesh (the failure mode behind Delta models become unpickable after editor.edit() because FastModelPicker hides parent model root #746).
  • One scene walk decides what is drawn. It tags each model's shells with that model's byte, resolving ownership from the nearest model root above the mesh (so a delta model nested under its parent keeps its own byte, without the traverseOwn / _ownMeshes bookkeeping), and hides everything else. Invisible subtrees are skipped, leaving fragments' own tile visibility untouched.
  • Line, Points and Sprite objects are now hidden during the pick. Previously only isMesh objects were, so a non-BIM line or point under the cursor (annotation lines, for example) wrote its own colour into the pick buffers and decoded to a wrong item and a bogus point.
  • The projection is narrowed to the cursor pixel (P' = M · P, the classic pick matrix) and the target is 1×1. Frustum culling then skips every mesh whose bounds miss that pixel, and the pixel centre is exactly the cursor. This removes the scissor box, the device-pixel-ratio workaround and the render-target resize listener, and it samples the cursor position exactly rather than the centre of the pixel under it. The world camera is never modified, and custom projections still work.
  • Scene and renderer state is restored in a finally, so a throwing render (context loss, for example) can no longer leave hidden objects or an override material behind.
  • Shadow maps no longer refresh during a pick. render() refreshes them on every call; the pick now suppresses that and restores a refresh the app had requested for its next frame.
  • Decoding is pure functions (decodeId, decodeDepth, decodeNormal, unprojectToWorld) that take a pixel and a frame, testable without a GL context. An unreachable zero-length-normal guard was dropped along the way: byte quantization cannot land all three channels within 1/255 of the origin.
  • The debug overlay renders the whole viewport on demand while debugMode is on, instead of the picker keeping a viewport-sized target alive for every pick.

Net effect on the file: 1210 → 880 lines, with runIdPass, renderIdPass, renderPickPass, renderWithTileMaterial, renderDepthPass, renderNormalPass, applyIdMaterial, restoreOriginalMaterials, traverseOwn and getModelRoots gone, and the three shaders merged into one.

Test setup

  • vitest 5 and vite 7.3.6 / vite-plugin-dts 4.5.4, matching engine_fragment#294, with jest removed.
  • Default environment is node; a file that needs a DOM opts in with // @vitest-environment happy-dom, which also brings in vitest-canvas-mock and the @vitest/web-worker shim via vitest.setup.ts.
  • tests/canvas-snapshot.ts records what is drawn to a 2D canvas and renders it as one character per pixel, so the debug overlay can be snapshotted and eyeballed.
  • 34 tests. fast-model-picker.ts is at 100% statements, lines and functions (99.16% branches; the one remaining branch has no source location, a v8 remapping artifact).

Verification

Unit tests cover: one render per getFullPick; only the requested attachments read back; the scene and renderer fully restored even when render throws; non-BIM Line / Points hidden; nested delta models drawn with their own byte; the pick camera narrowed to the cursor with depth untouched; MAX_MODELS; allowOverride; the guards; and the original camera-drift regression. Two snapshots pin the decoded output of a synthetic frame and the debug overlay (which also pins the WebGL→canvas Y-flip).

The shader itself needs a real GPU, so old and new were run side by side in headless Chrome against fragments' own worker raycast (Apple M2 Max, 1280×713, school_arq + school_str, 160 cursor positions), with identical results at pixel ratio 1 and 2:

Before After
render() calls per getFullPick 2–4 1
Main thread blocked per pick (median) 5.3 ms 1.3 ms
Hit point vs. raycast (median) 38 mm 0.09 mm
Hit point vs. raycast, orthographic camera (median) 57 mm 0.02 mm
Same item as raycast 127/131 128/131
Non-BIM Points in front of the model wrong item, point 380 m off correct item and point

Known divergence from the raycaster

3 of 131 positions resolve to a different item than the worker raycast. All 3 sit on a 50 mm mullion that fragments currently draws at a reduced LOD — a line mesh carrying no id attribute, which the pick pass hides, because drawing it would read the missing attribute as (0, 0, 0, 1) and write a false hit on item 0 while occluding the shell behind it.

Triaged on school_arq at NDC (-0.787, -0.074):

  • The item is not hidden: getVisible([141085]) returns [true].
  • At 55 m the raycast reports it across ~3 px; the pick reports it at no offset within ±3 px, returning the door 3 m behind instead.
  • Moving the camera to 4 m along the same view direction, close enough for fragments to draw that mullion as a shell, makes pick and raycast agree pixel for pixel.
  • Frustum culling is not involved: disabling it changes nothing.

So the rule today is what can be picked is what is drawn, which is recorded in the class docs and pinned by a test.

Proposal: full compatibility with raycaster - identify everything the pick draws

FastModelPicker exists to avoid ray casting, so it should be a drop-in replacement for it. Today it isn't, in two places:

  • LOD segments are hidden. Fragments draws small or distant items as screen-space lines (LODMesh) that carry no id attribute, so the pick hides them and returns whatever is behind. This is the 3/131 divergence above.
  • Non-BIM objects are hidden. Helpers, grids and annotation lines don't write to the pick, so the pick sees through them. SimpleRaycaster makes up for it by raycasting world.meshes with three's Raycaster on every castRay and keeping the closer hit.

This proposal gives both an id in the existing encoding, so every pixel the pick draws decodes to an answer and no fallback resolver is needed. It replaces the earlier "placeholder id + fallback resolver" idea (see Alternatives considered).

Encoding

The id output keeps its layout: R names the owner, GBA holds a 24-bit id + 1, and 0 means nothing.

Drawn R GBA Decodes as Fallback
Model shell model byte (1..254) itemId + 1, per-vertex id attribute { modelId, itemId } none
Model LOD segment model byte (1..254) itemId + 1, per-instance id attribute { modelId, itemId } none
Non-BIM object 255 index + 1 into a per-pick object list { object } none
Non-BIM object the pick can't draw faithfully hidden, as today three's Raycaster, over those objects only
Nothing 0 0 null none

MAX_MODELS is 254, so 255 is free. 24 bits allow 16,777,215 non-BIM objects per pick.

LOD segments

A per-draw id isn't enough here. A LOD draw is a whole tile: one InstancedBufferGeometry with one instance per segment, packing segments from many items. The main thread also has no segment → item map. tile.ids is only allocated for shell tiles, and LOD visibility and highlight data are bare ranges.

The worker has the map. LOD tiles go through the same setupTileSampleAttributes as shells, with the sample's location and itemId(sample) in scope; only the objectClass === SHELL check keeps it from writing ids. A segment's index is its vertex location / 2, which is how LodHelper.setLodFilter already addresses instances.

Fragments change

  1. constructTile: allocate ids for LINE tiles too, at 4 bytes per segment (positionCount / 6 * 4).
  2. setupTileSampleAttributes: for LINE samples, write the same big-endian itemId + 1 into every segment of the sample.
  3. LODManager.createMesh: bind request.itemIds as an InstancedBufferAttribute(ids, 4) named id.
  4. MeshManager feeds request.itemIds into model.visibleItems one byte at a time, so for shell tiles that set already holds byte values rather than item ids. LOD tiles would start feeding it the same way, so fix it in the same change: decode 4 bytes per entry, and don't drop an item when one of several tiles drawing it is deleted.

Picker change

  • A LOD pick material. LOD geometry is a template quad that LodShaders.vertex stretches into a screen-space line, so scene.overrideMaterial can't draw it. During the pick each LODMesh gets a pick variant with allowOverride = false: the LOD line expansion, vId = id, the itemFilter == 0 early-out for hidden items, and the shared pick outputs. LODMesh has a single material and one full-range group, so swapping it has none of the draw-group problems that ruled material swaps out for shells. It is restored in the same finally as the rest of the scene state.
  • lodSize follows the pick target. LODMesh.onBeforeRender writes renderer.getSize() into material[0].lodSize. Under the narrowed pick projection one viewport pixel spans the whole target, so the variant resets lodSize to the target size ((1, 1) for a pick) in its own onBeforeRender, which three calls after the object's. Without that, the line shrinks to a fraction of a pixel.
  • No normal. LOD draws write normal alpha 0, which decodeNormal already decodes as null.

Cost. ids adds 4 bytes per LOD segment, next to 24 bytes of positions. LINE tiles already allocate a faceIdBuffer (8 bytes per segment) and send a Float32 face-id payload (24 bytes per segment) that the LOD path throws away. Skipping both for LINE tiles more than pays for ids.

Non-BIM objects

  • Ids are assigned per pick. The scene walk that tags shells with their model byte also appends each eligible non-BIM object to a list. Its index + 1 is set per draw as a uniform, with R = 255. Nothing is stored on the objects, and the list is rebuilt every pick, like the model bytes.
  • The pick draws only the objects the caller asks for. Today castRay(position, items) ignores non-BIM objects outside items (default world.meshes). If the pick drew every object in the scene, an unlisted grid could hide the model. So the pick takes the same list and hides everything else.
  • Only objects the pick shader can reproduce are drawn. Under overrideMaterial, anything whose on-screen shape comes from its own vertex shader lands in the wrong place. Eligible: Mesh, Line, LineSegments and LineLoop with a built-in material, no onBeforeCompile, no displacement map, not instanced, skinned or morphed, and depth-tested. Everything else stays hidden as today, including Sprite, Points (point size), Line2 / LineMaterial, InstancedMesh, SkinnedMesh, ShaderMaterial and depthTest: false helpers.
  • SimpleRaycaster raycasts only what the pick couldn't draw. That is usually nothing, so the per-cast Raycaster over world.meshes goes away in the common case.
  • Shader. Per-draw uniforms choose where the id comes from (the attribute for shells and LOD, a uniform for non-BIM) and whether a normal exists. Lines, and meshes without a normal attribute, write normal alpha 0. These are uniforms rather than defines, so they add no shader variants.

What it closes and what it doesn't

  • LOD item. A LOD pixel now decodes to the item its line stands for. This should bring the 3/131 mullion positions in line with the worker raycast; to be confirmed by rerunning the 160-position headless Chrome comparison.
  • LOD point. The point lands on the line that stands in for the item (a bounding-box edge, or a custom LOD wire), not on the real surface. It will be close, but not the 0.09 mm median that shells get. There is no normal.
  • LOD coverage. A 2 px line can report an item where the real geometry covers less than a pixel. That follows the rule already documented: what can be picked is what is drawn.
  • Non-BIM results carry object, point, normal and distance, but not face, faceIndex, uv or instanceId.
  • Items fragments doesn't draw (hidden through per-item visibility, for example) stay unpickable, even though the worker raycast may still report them.

Alternatives considered

  • Placeholder ids plus a fallback resolver (the earlier version of this proposal). LOD pixels decode to { modelId, itemId: null } and trigger a raycast scoped to that model; non-BIM pixels decode to 255 and trigger three's Raycaster. It leaves fragments untouched, but every LOD hit still pays a worker round trip and every non-BIM hit a main-thread raycast.
  • A per-tile id plus gl_InstanceID, resolved to an item on the worker. It still needs a new fragments API, and has to split 24 bits between tile and instance. The per-segment ids buffer is less code and reuses the existing decoder unchanged.

Things to decide

  • Result shape. getFullPick needs a way to return non-BIM hits: a discriminated union ({ kind: "item", … } | { kind: "object", … }), or an optional object field. Related: should SimpleRaycaster raycast the single returned object to fill in face, faceIndex and uv, so castRay's return type stays the same?
  • Where the LOD pick shader lives. A copy of LodShaders.vertex in the picker would drift from fragments. Recommended: fragments exports the line expansion as a shared chunk, or a ready-made LodPickMaterial, used by both materials.
  • LOD picker precision. - go with the suggested approach or fallback to raycasting.
  • Points and Sprite. Keep them hidden with a raycast fallback, or give them their own pick variants (point size, billboarding) in a follow-up.
  • Order of work. The fragments change can ship first: the new buffer does nothing until the picker reads id on LOD meshes. The picker change would follow in its own PR, separate from this one.

Follow-up: synchronous picking via synchronous localId resolution

This is separate from the id buffer and can ship before or after it. It helps shell hits as much as LOD hits.

The id buffer holds itemId, not localId, so LOD hits get their localId the way shell hits do today: one getLocalIdsFromItemIds round trip to the worker per pick. Writing localId into the buffer instead isn't an option. Snapping needs itemId to fetch an item's geometry directly, and the pick keeps only 24 bits for the id. That bounds itemId, which is limited by the item count, but not localId, which is an IFC id.

On the worker the mapping is two flatbuffer lookups: meshes.meshesItems(itemId), then localIds(index). Fragments could send it to the main thread as a Uint32Array indexed by itemId:

  • Memory. 4 bytes per item, about 2 MB for a 500k-item model.
  • Lifetime. Built once per model and dropped on dispose. Edits load a separate delta model rather than changing a loaded one, so a delta model gets its own table.
  • API. A synchronous lookup on FragmentsModel, next to the async getLocalIdsFromItemIds, which stays for compatibility.
  • Picker. itemIdToLocalId becomes synchronous, so a pick no longer waits on the worker at all. This is a latency gain, not a correctness fix: fix(FastModelPicker): getFullPick concurrency + vitest #777 already decodes everything before the lookup starts. It would also make synchronous picker methods possible, if they are ever wanted.
  • Stale comment. The comment above the id bytes in setupTileSampleAttributes says a main-thread itemIdToLocalIdMap already keeps this synchronous. No such table exists. This follow-up would make the comment true; otherwise the comment should be corrected.
  • To decide: eager or lazy. Send the table when a model loads, or on its first lookup so that models nobody picks don't pay for it. Lazy means the first pick on each model still makes one round trip.

Additional context

Provided in #775.

Commits in this PR:

  1. fix(FastModelPicker): getFullPick data eval — the fix
  2. vitest setup — vitest/vite toolchain, config and setup file, jest removed, can and probably should be its own PR ci(): vitest setup #805.
  3. fix(): dedicated circular deps - needed for tests to run.
  4. add test - cover the fix
  5. perf(): a single render pass — the consolidation, its tests and the canvas-snapshot helper
  6. revert to async - revert breaking signature changes safeguarded by moving decoding ownership to pick
  7. fix(): respect local clipping planes - fixes a latent bug: local clipping planes were not respected, global clipping planes were added twice.

What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Before submitting the PR, please make sure you do the following:

  • Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • Follow the Conventional Commits v1.0.0 standard for PR naming (e.g. feat(examples): add hello-world example).
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123).
  • Ideally, include relevant tests that fail without this PR but pass with it.

🤖 Generated with the help of Claude Code

@ShaMan123 ShaMan123 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ready for review

@ShaMan123
ShaMan123 force-pushed the fix/getFullPick#775 branch from 79569ef to 82f4570 Compare July 26, 2026 06:19

@ShaMan123 ShaMan123 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 commits:

  1. the fix
  2. introduce vitest, fix (dedicated) circular deps, remove jest
  3. add test

@ShaMan123 ShaMan123 changed the title fix(FastModelPicker): getFullPick data eval fix(FastModelPicker): getFullPick data eval + vitest Jul 26, 2026
@ShaMan123

Copy link
Copy Markdown
Contributor Author

Working on this PR made me think:
Do the 3 render passes do the same work? Isn't it wasteful to traverse the entire tree again?
Wouldn't it be more performant to run a single traversal rendering to 3 separate render targets? The function could accept an options object instructing which pass it needs to render.
Also, additional passes could be added easily in the future if needed.

@ShaMan123 ShaMan123 closed this Jul 26, 2026
@ShaMan123 ShaMan123 reopened this Sep 15, 2026
@ShaMan123
ShaMan123 marked this pull request as draft September 15, 2026 08:57
@ShaMan123 ShaMan123 changed the title fix(FastModelPicker): getFullPick data eval + vitest fix(FastModelPicker): getFullPick concurrency + vitest Sep 15, 2026
@ShaMan123
ShaMan123 marked this pull request as ready for review September 16, 2026 04:41
@ShaMan123
ShaMan123 force-pushed the fix/getFullPick#775 branch 2 times, most recently from 99ec925 to 71f61f7 Compare September 16, 2026 05:47

@ShaMan123 ShaMan123 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready for review.
Please read the description and provide feedback regarding these points:

  • should I cherry-pick vitest setup to a separate PR? I think I should #805. Once that merges I can rebase and drop the commit.
  • Proposal: should the picker resolve the unknown hits via ray casting and keep the methods async? I think it should since it makes the picker what it is - a performance optimization, not a hack with edge cases. If so I will restore the async signatures (now possible due to the picker consolidation refactor) and follow up on the rest in a dedicated PR. those methods are noise - unused and should can be removed IMO.

@ShaMan123 ShaMan123 mentioned this pull request Sep 16, 2026
@ShaMan123

Copy link
Copy Markdown
Contributor Author

finalized description

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.

RayCaster returns an inconsistent point when the camera moves during the pick

1 participant