Prism
← All primitives

SplatScene

Frontier

A real-time Gaussian-splat capture rendered in R3F via World Labs' Spark — photoreal 3D content (the kind AI tools like Marble export) running live in the browser.

No asset ships with this primitive — self-host a .spz/.ply/.splat under /public and pass it via the `url` prop (e.g. <SplatScene url="/splats/yours.spz" />). Needs WebGL2; render via next/dynamic ssr:false.

$npx shadcn@latest add https://prism.icglabs.co/r/splat-scene.json
Dependencies:three@react-three/fiber@react-three/drei@sparkjsdev/spark
View raw manifest →

components/prism/SplatScene.tsx
"use client";

import { Component, useEffect, useRef, useState } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";

/**
 * SplatScene — a real-time Gaussian-splat capture rendered in R3F via World Labs'
 * Spark (the maintained successor to the archived Luma viewer). Photoreal 3D
 * content (the kind AI tools like Marble export) running live in the browser.
 *
 * Built imperatively to match Spark's official vanilla-three example exactly:
 * a SparkRenderer + a SplatMesh added to the scene as siblings. Self-contained
 * fallback, `url` prop, honors prefers-reduced-motion.
 */
function Splats({ url, reduce }: { url: string; reduce: boolean }) {
  const scene = useThree((s) => s.scene);
  const gl = useThree((s) => s.gl);
  const meshRef = useRef<SplatMesh | null>(null);

  useEffect(() => {
    const spark = new SparkRenderer({ renderer: gl });
    scene.add(spark);

    const mesh = new SplatMesh({ url });
    // capture is recorded upside-down — quaternion (1,0,0,0) = 180° about X
    mesh.quaternion.set(1, 0, 0, 0);
    mesh.position.set(0, 0, 0);
    scene.add(mesh);
    meshRef.current = mesh;

    return () => {
      scene.remove(mesh);
      scene.remove(spark);
      meshRef.current = null;
    };
  }, [url, scene, gl]);

  useFrame((_, dt) => {
    if (meshRef.current && !reduce) meshRef.current.rotation.y += 0.35 * dt;
  });

  return null;
}

function SplatFallback({ label }: { label: string }) {
  return (
    <div
      className="relative grid h-full w-full place-items-center overflow-hidden rounded-2xl border border-line"
      style={{ background: "radial-gradient(120% 90% at 70% 10%, rgba(139,92,255,0.18), transparent 60%), #0c0c13" }}
    >
      <span className="relative z-10 text-sm text-muted">{label}</span>
    </div>
  );
}

/** Catches a failed splat load (e.g. missing asset) and shows the fallback. */
class CanvasBoundary extends Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { failed: boolean }
> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true };
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

export function SplatScene({ url = "/assets/splats/butterfly.spz" }: { url?: string }) {
  const [supported, setSupported] = useState<boolean | null>(null);
  const [reduce, setReduce] = useState(false);

  useEffect(() => {
    const c = document.createElement("canvas");
    setSupported(!!c.getContext("webgl2"));
    setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  }, []);

  if (supported === false) return <SplatFallback label="Gaussian splat needs WebGL2" />;
  if (supported === null) return <SplatFallback label="Loading splat…" />;

  return (
    <CanvasBoundary fallback={<SplatFallback label="Splat asset unavailable" />}>
      <Canvas
        dpr={[1, 1.5]}
        gl={{ antialias: false, powerPreference: "high-performance" }}
        camera={{ position: [0, 0, 3], fov: 50 }}
      >
        <Splats url={url} reduce={reduce} />
        <OrbitControls enablePan={false} enableZoom={false} autoRotate={!reduce} autoRotateSpeed={0.5} />
      </Canvas>
    </CanvasBoundary>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.