Describe the bug 📝
If the camera is moving while the castRay promise is resolving the RaycastResult.point will drift from the actual hit position.
Investigation has proved that the methods used by castRay use the mutable camera freely in an async block:
OBC.Raycasters → SimpleRaycaster.castRay → FastModelPicker.getFullPick
|
async getFullPick(position?: THREE.Vector2): Promise<{ |
|
modelId: string; |
|
localId: number; |
|
/** |
|
* Internal item index (FlatBuffer `sample.item()`). Exposed so |
|
* SnapResolver and other internal consumers can hit fragments' |
|
* itemId-keyed fast paths (`boxes.sampleOf`) without paying a |
|
* second worker round-trip to translate back from localId. |
|
*/ |
|
itemId: number; |
|
point: THREE.Vector3; |
|
normal: THREE.Vector3 | null; |
|
distance: number; |
|
} | null> { |
|
const item = await this.getItemAt(position); |
|
if (!item) return null; |
|
const point = await this.getPointAt(position); |
|
if (!point) return null; |
|
const normal = await this.getNormalAt(position); |
|
const distance = point.distanceTo(this.world.camera.three.position); |
|
return { ...item, point, normal, distance }; |
|
} |
Summary
getFullPick(position) resolves a pick over three sequential asynchronous passes, and each pass reads the live this.world.camera.three:
getItemAt → renderPickPass (id) // item, from camera state A
→ await getLocalIdsFromItemIds(...) // ← async boundary: a frame can run here
getPointAt → renderWithTileMaterial (depth) // point, from camera state B (> A)
→ unprojectToWorld(pos, depth, world.camera.three) // unproject with state B
getNormalAt → renderWithTileMaterial (normal)
distance = point.distanceTo(world.camera.three.position) // state C
The GPU readbacks are synchronous, but getItemAt awaits an item-id resolution between the id pass and the depth pass. If the camera moves during that await (rapid wheel zoom, camera-controls damping/inertia — anything that advances the camera on the app's render loop), the passes render from different camera states:
item is picked at camera state A;
point is unprojected from the depth pass at a later state B;
distance uses a later state still.
So the returned point is not consistent with the returned item, and it does not lie on the ray the caller cast — it's reprojected through the moved camera.
Impact
Any consumer that positions something at castRay().point while the camera is moving gets a wrong world position — the point lands off the cursor / off the picked surface. We hit this anchoring an orbit pivot at the pointer-down pick during a fast zoom: the anchor jumps off the clicked geometry. With a static camera everything is correct.
Root cause
getFullPick re-reads this.world.camera.three in every pass instead of latching one camera for the duration of the pick.
Expected
A single castRay / getFullPick call returns item, point, normal, distance all consistent with one camera state (the state at the start of the call), regardless of camera motion during the async passes.
Suggested Fix
This is a real caveat of design - state must be immutable during an async operation.
Does it occur in other parts of the code? It is a real design issue since frags are threaded.
I am not sure what the correct fix is.
Could getPointAt resolve in the same sync context as getItemAt? That would be the best fix. Are the method separate due to reusability of code or due to real reasons. I have not investigated any further.
The quick fix is to freeze the camera and pass it around so that it is immutable.
Reproduction ▶️
No response
Steps to reproduce 🔢
- Load any model into a
SimpleWorld with an OrthoPerspectiveCamera (default Raycasters / FastModelPicker).
- Call
raycaster.castRay({ position }) at a fixed position while continuously moving the camera (e.g. a wheel-zoom loop, or controls.dolly(...) each frame).
- Compare
result.point against the ray raycaster.three built from position and the pre-pick camera: result.point is off the ray, and the error grows with camera speed. Static camera → correct.
Deterministic variant: inside getFullPick, translate the camera by a known delta between the getItemAt and getPointAt awaits — result.point shifts by the corresponding reprojection, proving the passes use different camera states.
const result = (await this.rayCaster.castRay({ position: ndc })) as
| RaycastResult
| undefined;
// zoom or drag rapidly
if(!result) return
const { point } = result;
const { controls } = this.world.camera;
controls.setOrbitPoint(point.x, point.y, point.z); // drifts with the camera
System Info 💻
Used Package Manager 📦
npm
Error Trace/Logs 📃
No response
Validations ✅
Describe the bug 📝
If the camera is moving while the
castRaypromise is resolving theRaycastResult.pointwill drift from the actual hit position.Investigation has proved that the methods used by
castRayuse the mutable camera freely in an async block:OBC.Raycasters → SimpleRaycaster.castRay → FastModelPicker.getFullPickengine_components/packages/core/src/core/FastModelPicker/src/fast-model-picker.ts
Lines 286 to 307 in 5c2dd9d
Summary
getFullPick(position)resolves a pick over three sequential asynchronous passes, and each pass reads the livethis.world.camera.three:The GPU readbacks are synchronous, but
getItemAtawaits an item-id resolution between the id pass and the depth pass. If the camera moves during that await (rapid wheel zoom, camera-controls damping/inertia — anything that advances the camera on the app's render loop), the passes render from different camera states:itemis picked at camera state A;pointis unprojected from the depth pass at a later state B;distanceuses a later state still.So the returned
pointis not consistent with the returneditem, and it does not lie on the ray the caller cast — it's reprojected through the moved camera.Impact
Any consumer that positions something at
castRay().pointwhile the camera is moving gets a wrong world position — the point lands off the cursor / off the picked surface. We hit this anchoring an orbit pivot at the pointer-down pick during a fast zoom: the anchor jumps off the clicked geometry. With a static camera everything is correct.Root cause
getFullPickre-readsthis.world.camera.threein every pass instead of latching one camera for the duration of the pick.Expected
A single
castRay/getFullPickcall returnsitem,point,normal,distanceall consistent with one camera state (the state at the start of the call), regardless of camera motion during the async passes.Suggested Fix
This is a real caveat of design - state must be immutable during an async operation.
Does it occur in other parts of the code? It is a real design issue since frags are threaded.
I am not sure what the correct fix is.
Could
getPointAtresolve in the same sync context asgetItemAt? That would be the best fix. Are the method separate due to reusability of code or due to real reasons. I have not investigated any further.The quick fix is to freeze the camera and pass it around so that it is immutable.
Reproduction▶️
No response
Steps to reproduce 🔢
SimpleWorldwith anOrthoPerspectiveCamera(defaultRaycasters/FastModelPicker).raycaster.castRay({ position })at a fixedpositionwhile continuously moving the camera (e.g. a wheel-zoom loop, orcontrols.dolly(...)each frame).result.pointagainst the rayraycaster.threebuilt frompositionand the pre-pick camera:result.pointis off the ray, and the error grows with camera speed. Static camera → correct.Deterministic variant: inside
getFullPick, translate the camera by a known delta between thegetItemAtandgetPointAtawaits —result.pointshifts by the corresponding reprojection, proving the passes use different camera states.System Info 💻
Used Package Manager 📦
npm
Error Trace/Logs 📃
No response
Validations ✅