@Evgeni_Popov
So the fix was ultimately provided by composer in Cursor, so I don’t fully understand it. However here is what the model had to say about it. Hopefully it makes more sense to you. Maybe there is a simpler way to fix it too. Part of the solution was to fix the fix, by which I mean when I first got it working it broke GUI picking. So step 2 is to repair that.
1. Correct vertex range per particle (ModelShape._shape)
Problem: To decide if a triangle belongs to a hidden particle, you need each particle’s vertex span in the unified SPS buffer: start = particle._pos / 3, length = number of vertices in that shape. Babylon’s internal ModelShape stores that geometry on _shape, not shape. Using .shape was always undefined, so every particle was skipped in the loop and isVertexFromVisibleSpsParticle effectively never applied visibility—only the degenerate-triangle check could matter.
Fix: Read length from _shape (with a fallback), then map vertexIndex into [vStart, vEnd) and use particle.isVisible.
export function isVertexFromVisibleSpsParticle(vertexIndex, particles) {
if (!Number.isFinite(vertexIndex) || vertexIndex < 0 || !particles?.length) return true;
const vi = vertexIndex | 0;
for (let i = 0; i < particles.length; i++) {
const p = particles\[i\];
// ModelShape stores positions on \`\_shape\` (see @babylonjs/core Particles/solidParticle.js); \`shape\` is undefined.
const shapeLen = p.\_model?.\_shape?.length ?? p.\_model?.shape?.length;
if (shapeLen == null || shapeLen <= 0) continue;
const vStart = ((p.\_pos / 3) | 0);
const vEnd = vStart + shapeLen;
if (vi >= vStart && vi < vEnd) return p.isVisible !== false;
}
return true;
}
/\*\*
\* Full SPS pick filter: skip degenerate tris + tris whose corners belong to hidden particles.
\* Stored on \`spsData.pickTrianglePredicate\` after build.
\* @param {object} spsData - unified SPS payload (same reference as \`modelRoot.\_spsData\`)
\*/
export function createSpsPickTrianglePredicate(spsData) {
return (p0, p1, p2, \_ray, ia, ib, ic) => {
if (!allowPickNonDegenerateTriangle(p0, p1, p2)) return false;
const particles = spsData?.sps?.particles;
if (!particles?.length) return true;
return (
isVertexFromVisibleSpsParticle(ia, particles) &&
isVertexFromVisibleSpsParticle(ib, particles) &&
isVertexFromVisibleSpsParticle(ic, particles)
);
};
}
createSpsPickTrianglePredicate is what gets stored on spsData.pickTrianglePredicate at build time and used whenever the wrapper delegates to the “specific” predicate.
2. Same trianglePredicate for every mesh → scope SPS logic to the SPS mesh only
Problem: Babylon passes one trianglePredicate into picking for all meshes along the ray. Indices ia, ib, ic are local to the mesh being tested. GUI meshes often use small indices (0, 1, 2…), which overlap the beginning of the SPS vertex ranges. After fix (1), the SPS mapping became “real,” so those GUI indices were wrongly treated as SPS vertices—GUI clicks could fail.
Fix (two parts):
A) installSpsTrianglePickMeshContext — patch AbstractMesh.prototype.intersects once so, while a mesh is being tested, scene.__primalTrianglePickContext.mesh is that mesh (with restore in finally for nested/repeated calls).
export function installSpsTrianglePickMeshContext() {
if (spsTrianglePickMeshContextInstalled) return;
spsTrianglePickMeshContextInstalled = true;
const original = AbstractMesh.prototype.intersects;
AbstractMesh.prototype.intersects = function intersectsWithTriangleMeshContext(
ray,
fastCheck,
trianglePredicate,
onlyBoundingInfo,
worldToUse,
skipBoundingInfo
) {
const sc = this.\_scene;
let prev = null;
if (sc && trianglePredicate) {
if (!sc.\__primalTrianglePickContext) sc.\__primalTrianglePickContext = { mesh: null };
prev = sc.\__primalTrianglePickContext.mesh;
sc.\__primalTrianglePickContext.mesh = this;
}
try {
return original.call(this, ray, fastCheck, trianglePredicate, onlyBoundingInfo, worldToUse, skipBoundingInfo);
} finally {
if (sc && sc.\__primalTrianglePickContext) {
sc.\__primalTrianglePickContext.mesh = prev;
}
}
};
}
B) wrapModelRootPickTrianglePredicate — if the mesh under test is not the unified SPS mesh, only apply non-degenerate filtering; if it is the SPS mesh, run the full spsData.pickTrianglePredicate.
export function wrapModelRootPickTrianglePredicate(modelRootRef) {
return (p0, p1, p2, ray, ia, ib, ic) => {
const root = modelRootRef?.current;
const spsMesh = root?.\_spsData?.sps?.mesh;
const scene = root?.getScene?.() || spsMesh?.getScene?.();
const pickingMesh = scene?.\__primalTrianglePickContext?.mesh;
// Critical: only apply SPS particle visibility for the unified SPS mesh (GUI indices would otherwise collide).
if (spsMesh && pickingMesh && pickingMesh !== spsMesh) {
return allowPickNonDegenerateTriangle(p0, p1, p2);
}
try {
const specific = root?.\_spsData?.pickTrianglePredicate;
if (specific) return specific(p0, p1, p2, ray, ia, ib, ic);
} catch (e) {
console.warn('SPS pick triangle predicate failed:', e);
}
return allowPickNonDegenerateTriangle(p0, p1, p2);
};
}
The patch is installed at scene startup so any pick path gets mesh context:
// So \`wrapModelRootPickTrianglePredicate\` can tell SPS mesh from GUI (vertex indices are per-mesh).
installSpsTrianglePickMeshContext();
const newScene = new Scene(engine);
registerScene(newScene);
3. WebXR default laser uses pickWithRay without a triangle predicate
Problem: WebXRControllerPointerSelection calls scene.pickWithRay(ray, meshPredicate) and does not pass a trianglePredicate. So the drawn laser/cursor could still use the same bad hits as before (degenerate/hidden SPS triangles), even when your app code passed wrapModelRootPickTrianglePredicate elsewhere.
Fix: While WebXRState.IN_XR, replace the scene instance’s pickWithRay with a wrapper that ANDs any existing trianglePredicate with modelPickTrianglePredicate (from wrapModelRootPickTrianglePredicate). On NOT_IN_XR and on unmount, restore the backed-up function.
Install when entering XR:
// WebXRControllerPointerSelection picks with mesh predicate only; inject SPS triangle filter so the laser/cursor ignore hidden-part degenerate tris (same as app logic picks).
if (scenePickWithRayBackupRef.current == null) {
const original = scene.pickWithRay.bind(scene);
scenePickWithRayBackupRef.current = original;
const spsTri = modelPickTrianglePredicate;
scene.pickWithRay = (ray, predicate, fastCheck, trianglePredicate) => {
const merged = trianglePredicate
? (p0, p1, p2, r, ia, ib, ic) =>
trianglePredicate(p0, p1, p2, r, ia, ib, ic) &&
spsTri(p0, p1, p2, r, ia, ib, ic)
: spsTri;
return original(ray, predicate, fastCheck, merged);
};
}
Restore when session ends:
if (state === WebXRState.NOT_IN_XR) {
// Restore desktop autoClear optimisation (skybox covers everything on desktop)
if (scene) {
scene.autoClear = false;
scene.autoClearDepthAndStencil = false;
if (scenePickWithRayBackupRef.current) {
scene.pickWithRay = scenePickWithRayBackupRef.current;
scenePickWithRayBackupRef.current = null;
}
}
Unmount safety net:
if (sc && scenePickWithRayBackupRef.current) {
sc.pickWithRay = scenePickWithRayBackupRef.current;
scenePickWithRayBackupRef.current = null;
}
How the three fit together
| Fix |
Role |
(1) _shape + predicate |
Makes “which particle owns this vertex?” correct on the SPS mesh. |
| (2) Mesh context + wrapper |
Ensures that logic runs only for the SPS mesh, not GUI/other meshes. |
(3) XR pickWithRay wrap |
Ensures Babylon’s built-in XR laser path uses the same triangle rules as your picks. |
Together: correct mapping, no cross-mesh index confusion, and XR visuals aligned with picking.