GSplat pipeline apparently failing when applying a transform

Hey everyone!

I’m currently attempting to use @playcanvas/react to render a splat (in SOG format) and programmatically move the splat and/or camera.
I can load and view the splat as expected, however if I try and apply any transform whatsoever to either the splat or the camera, then splat entirely disappears, never to return.

Here’s a minimal example of my component:

'use client';

import React from 'react';
import { Application, Entity } from '@playcanvas/react';
import { Camera, GSplat } from '@playcanvas/react/components';
import { useSplat } from '@playcanvas/react/hooks';

function Scene({ tilePos }: { tilePos: [number, number, number] }) {
  const { asset, loading, error } = useSplat('/assets/tile.sog');

  React.useEffect(() => {
    if (error) console.error('[SOG] failed to load:', error);
    if (loading) console.log('[SOG] loading…');
  }, [error, loading]);

  return (
    <>
      {/* Fixed camera */}
      <Entity name="camera-root" position={[0, 1.6, 0]}>
        <Entity name="camera">
          <Camera nearClip={0.01} farClip={10000} />
        </Entity>
      </Entity>

      {/* Splat */}
      {asset && (
        <Entity name="tile" position={tilePos} rotation={[0, -90, 0]}>
          <GSplat asset={asset} />
        </Entity>
      )}
    </>
  );
}

export default function PlayCanvasSplatNudgeTest() {
  const [tilePos, setTilePos] = React.useState<[number, number, number]>([0, 0, 0]);

  const nudge = (dx: number, dy: number, dz: number) => {
    setTilePos(([x, y, z]) => [x + dx, y + dy, z + dz]);
  };

  const reset = () => setTilePos([0, 0, 0]);

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <Application graphicsDeviceOptions={{ antialias: false }}>
        <Scene tilePos={tilePos} />
      </Application>

      <div style={overlayStyle}>
        <div style={labelStyle}>
          <span>Tile pos</span>
          <strong>
            [{tilePos.map((v) => v.toFixed(3)).join(', ')}]
          </strong>
        </div>

        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          <button style={buttonStyle} onClick={() => nudge(0.02, 0, 0)}>
            Nudge +X (2cm)
          </button>
          <button style={buttonStyle} onClick={() => nudge(-0.02, 0, 0)}>
            Nudge -X (2cm)
          </button>
          <button style={buttonStyle} onClick={() => nudge(0, 0, 0.02)}>
            Nudge +Z (2cm)
          </button>
          <button style={buttonStyle} onClick={reset}>
            Reset
          </button>
        </div>

        <small style={{ opacity: 0.85, marginTop: 6 }}>
          Diagnostic: if the splat disappears after a 2cm nudge, we’re dealing with a transform/update/culling bug in the GSplat pipeline rather than coordinate math.
        </small>
      </div>
    </div>
  );
}

I have also tried to move the tile/camera imperatively, for example (I think the syntax is correct, going from the docs but please correct me if I’m wrong!):

function Scene({ tilePos }: { tilePos: pc.Vec3 }) {
  const { asset, loading, error } = useSplat('/assets/tile.sog');
  const tileRef = useRef<pc.Entity | null>(null);

  // Move the tile imperatively in the app update loop
  useAppEvent('update', () => {
    const tile = tileRef.current;
    if (!tile) return;

    tile.setLocalPosition(tilePos);
    tile.setLocalEulerAngles(0, -90, 0); // your correct orientation
  });

  if (error) console.error('[SOG] failed to load:', error);
  if (loading) console.log('[SOG] loading…');

  return (
    <>
      {/* Fixed camera */}
      <Entity name="camera-root" position={[0, 1.6, 0]}>
        <Entity name="camera">
          <Camera nearClip={0.01} farClip={10000} />
        </Entity>
      </Entity>

      {/* Splat */}
      {asset && (
        <Entity name="tile" ref={tileRef}>
          <GSplat asset={asset} />
        </Entity>
      )}
    </>
  );
}

However no matter what I try, any transform at all makes the splat disappear. I have a ‘Reset’ button in this test. It correctly resets the camera/splat position to default, but once the splat has ‘gone’, it doesn’t come back.

This looks like a bug to me, but I’ve never used PlayCanvas before so would greatly appreciate any help before I jump to conclusions!

Thanks,
Olly