How do you convert a glb with vertex colors to a SPS

Take a look at this playground:

I’m trying to work out how to import a glb file with vertex colors and get it to display the same as a Solid Particle System.

This test is just with a cube but I get similar issues with more complex data. See below.
When I just load the glb I get what I expect, a box with rgb colors on the corners.

When I convert it to an SPS the colors get mangled and it turns inside out

I’ve tried all manner of things to try and fix it, but I can never get the colors correct, there are ways to fix the normals but the colors I can never get working.

My actual model I don’t want to share access to, but it renders like this as an SPS

But like this it I don’t convert it.

If you are wondering why I’m doing this anyway, it’s to reduce the draw call count for an XR application that needs to display a lot of geometry. Making all the materials the same drastically reduces the draw count, but I need to modulate the albedo.

SPS requires that the vertex colors have 4 components, whereas they have only 3 for models coming from a glTF file.

Regarding the “inside out” problem, you can change the sideOrientation property of the mesh.

Here’s the PG with both fixes:

[EDIT] This PR will fix the “3 color components”, so after it’s merged, you won’t need the code lines 41-52 in my PG above:

1 Like

That’s amazing, thank you!

Follow up question.
Now I have vertex colors loading, when I hide the particle in XR the controller ray still hits the invisble surface where it used to be making it impossible to pick on anything underneath the first surface it hits.
If I don’t use vertex colors then it works as expected.

Any idea what is going on there?

How are you hiding the particle? If you’re setting the vertex color alpha to 0 to make it transparent, the geometry still exists at its original position and will still be picked by rays.

The proper way to hide an SPS particle from picking is:

particle.isVisible = false;
SPS.setParticles();

This collapses the particle’s vertices to (0,0,0), effectively removing it from ray intersection.

If you need a playground to test, could you share one that reproduces the picking issue?

Yes just as you suggest

particle.isVisible = false;
SPS.setParticles();

I’ve found a solution, I’ll try and produce a simplified version to post here.

@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.

Thanks for sharing this detailed analysis! The monkey-patching of AbstractMesh.prototype.intersects is quite fragile though and could have unexpected side effects on other parts of the engine.

Could you share a playground that reproduces the issue? The proper way to hide an SPS particle is particle.isVisible = false followed by SPS.setParticles() — this collapses the particle vertices to (0,0,0), forming degenerate triangles that are automatically rejected by the ray-triangle intersection algorithm (the determinant is exactly 0). This should work with or without vertex colors, and without needing any custom triangle predicate.

If you’re making the particle “invisible” by setting its color alpha to 0 instead, then yes, the geometry remains at its original position and will still be picked. The fix is to use particle.isVisible = false.

That said, if you confirm the issue persists even with particle.isVisible = false, I’d want to investigate further with a repro. And adding trianglePredicate support to
WebXRControllerPointerSelection could be a useful engine enhancement regardless (cc @RaananW for this one).

You may have missed my previous response. Yes I can confirm I’m only using particle.isVisible = false followed by SPS.setParticles() I was quite surprised when I hit this issue.

And yes it does seem to be rather fragile. It works fine on smaller mesh count models but I’m seeing freezes on ones that I can now use due to the reduced draw calls with lots of meshes. 2 steps forward 1 step back.

If this was handled correctly in engine I’m sure it would be much better.

I’ll try and setup a playground with picking, XR and nested vertex coloured meshes.

1 Like

Ok. I thought I had a way to reproduce it in the playground but it turns out I made a mistake when building up the test example.

I now have a working version with my own data that shows it should work in my actual application.

So…. I’ll have to pick through the code there and see why it behaves differently. It does appear like it should be working.

1 Like

Finally after a lot of false starts and going around the houses I tracked it down to the fact I wasn’t using the default xr controller pointer selection. I had rolled my own using ray picking.

All I needed to do in the end was add this to it

const particle = spsData.sps.particles[picked.idx];
if (!particle || !particle.isVisible) return null; // ignore hidden particles

So ignore everything I said previously :smiley: