GaussianSplatViewer.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. "use client";
  2. import { useEffect, useRef, useState, type ReactNode } from "react";
  3. import * as SPLAT from "gsplat";
  4. type Vec3 = [number, number, number];
  5. interface SplatEntry {
  6. src: string;
  7. position?: Vec3;
  8. rotation?: Vec3;
  9. scale?: Vec3;
  10. visible?: boolean;
  11. }
  12. interface ViewerProps {
  13. width?: string;
  14. height?: string;
  15. backgroundColor?: string;
  16. controls?: boolean;
  17. autoRotate?: boolean;
  18. autoRotateSpeed?: number;
  19. cameraPosition?: Vec3 | null;
  20. cameraTarget?: Vec3 | null;
  21. fov?: number | null;
  22. progressBarColor?: string;
  23. progressTrackColor?: string;
  24. progressTextColor?: string;
  25. progressBackgroundColor?: string;
  26. /** Custom loading indicator — overrides the default progress bar */
  27. loadingIndicator?: ReactNode;
  28. /** Splat file URLs to load */
  29. splats?: SplatEntry[];
  30. children?: ReactNode;
  31. }
  32. interface ProgressIndicatorProps {
  33. progress: number;
  34. backgroundColor?: string;
  35. textColor?: string;
  36. barColor?: string;
  37. trackColor?: string;
  38. }
  39. function ProgressIndicator({
  40. progress,
  41. backgroundColor = "#0a0a0a",
  42. textColor = "#666",
  43. barColor = "#fff",
  44. trackColor = "#1e1e1e",
  45. }: ProgressIndicatorProps) {
  46. const pct = Math.round(progress * 100);
  47. return (
  48. <div
  49. style={{
  50. position: "absolute",
  51. inset: 0,
  52. display: "flex",
  53. flexDirection: "column",
  54. alignItems: "center",
  55. justifyContent: "center",
  56. background: backgroundColor,
  57. gap: 12,
  58. }}
  59. >
  60. <span
  61. aria-live="polite"
  62. style={{
  63. color: textColor,
  64. fontFamily: "ui-monospace, monospace",
  65. fontSize: 13,
  66. letterSpacing: "0.04em",
  67. }}
  68. >
  69. Loading splat... {pct}%
  70. </span>
  71. <div
  72. role="progressbar"
  73. aria-valuenow={pct}
  74. aria-valuemin={0}
  75. aria-valuemax={100}
  76. aria-label="Loading progress"
  77. style={{
  78. width: 200,
  79. height: 2,
  80. background: trackColor,
  81. borderRadius: 1,
  82. overflow: "hidden",
  83. }}
  84. >
  85. <div
  86. style={{
  87. width: `${pct}%`,
  88. height: "100%",
  89. background: barColor,
  90. borderRadius: 1,
  91. transition: "width 150ms ease-out",
  92. }}
  93. />
  94. </div>
  95. </div>
  96. );
  97. }
  98. /** Convert a vertical FOV (degrees) to a focal length given a canvas height. */
  99. function fovToFocalLength(fovDeg: number, height: number): number {
  100. return height / (2 * Math.tan((fovDeg * Math.PI) / 360));
  101. }
  102. /** Convert euler angles (degrees, XYZ order) to a Quaternion. */
  103. function eulerToQuaternion(euler: Vec3): SPLAT.Quaternion {
  104. const [ex, ey, ez] = euler.map((d) => (d * Math.PI) / 180) as [
  105. number,
  106. number,
  107. number,
  108. ];
  109. const cx = Math.cos(ex / 2),
  110. sx = Math.sin(ex / 2);
  111. const cy = Math.cos(ey / 2),
  112. sy = Math.sin(ey / 2);
  113. const cz = Math.cos(ez / 2),
  114. sz = Math.sin(ez / 2);
  115. return new SPLAT.Quaternion(
  116. sx * cy * cz + cx * sy * sz,
  117. cx * sy * cz - sx * cy * sz,
  118. cx * cy * sz + sx * sy * cz,
  119. cx * cy * cz - sx * sy * sz,
  120. );
  121. }
  122. /**
  123. * Container that manages a WebGL canvas and loads gaussian splats
  124. * using Hugging Face's gsplat.js — a standalone WebGL renderer (no Three.js).
  125. *
  126. * This is an experimental demo component, kept inline in the example app.
  127. */
  128. export function GaussianSplatViewer({
  129. width = "100%",
  130. height = "100%",
  131. backgroundColor = "#000000",
  132. controls: enableControls = true,
  133. autoRotate = false,
  134. autoRotateSpeed = 1,
  135. cameraPosition = null,
  136. cameraTarget = null,
  137. fov = null,
  138. progressBarColor,
  139. progressTrackColor,
  140. progressTextColor,
  141. progressBackgroundColor,
  142. children,
  143. loadingIndicator,
  144. splats,
  145. }: ViewerProps) {
  146. const containerRef = useRef<HTMLDivElement>(null);
  147. const cleanupRef = useRef<(() => void) | null>(null);
  148. const [isLoading, setIsLoading] = useState(true);
  149. const [progress, setProgress] = useState(0);
  150. const [error, setError] = useState<string | null>(null);
  151. useEffect(() => {
  152. if (!containerRef.current) return;
  153. const container = containerRef.current;
  154. let cancelled = false;
  155. async function init() {
  156. try {
  157. // Clean up previous viewer
  158. if (cleanupRef.current) {
  159. cleanupRef.current();
  160. cleanupRef.current = null;
  161. }
  162. const scene = new SPLAT.Scene();
  163. const camera = new SPLAT.Camera();
  164. const renderer = new SPLAT.WebGLRenderer();
  165. // Apply camera position
  166. if (cameraPosition) {
  167. camera.position = new SPLAT.Vector3(
  168. cameraPosition[0],
  169. cameraPosition[1],
  170. cameraPosition[2],
  171. );
  172. }
  173. // Apply FOV by converting to focal length
  174. const rect = container.getBoundingClientRect();
  175. if (fov) {
  176. const fl = fovToFocalLength(fov, rect.height);
  177. camera.data.fx = fl;
  178. camera.data.fy = fl;
  179. }
  180. // Size the canvas to the container
  181. renderer.setSize(rect.width, rect.height);
  182. // Style and append the canvas — absolute positioning prevents overflow
  183. renderer.canvas.style.position = "absolute";
  184. renderer.canvas.style.top = "0";
  185. renderer.canvas.style.left = "0";
  186. renderer.canvas.style.width = "100%";
  187. renderer.canvas.style.height = "100%";
  188. renderer.canvas.style.display = "block";
  189. container.appendChild(renderer.canvas);
  190. // Only create orbit controls if enabled
  191. let controls: SPLAT.OrbitControls | null = null;
  192. if (enableControls) {
  193. controls = new SPLAT.OrbitControls(camera, renderer.canvas);
  194. // Apply camera target (look-at)
  195. if (cameraTarget) {
  196. controls.setCameraTarget(
  197. new SPLAT.Vector3(
  198. cameraTarget[0],
  199. cameraTarget[1],
  200. cameraTarget[2],
  201. ),
  202. );
  203. }
  204. }
  205. // Handle resize (debounced to avoid layout thrashing)
  206. let resizeTimer: ReturnType<typeof setTimeout>;
  207. const onResize = () => {
  208. clearTimeout(resizeTimer);
  209. resizeTimer = setTimeout(() => {
  210. const r = container.getBoundingClientRect();
  211. renderer.setSize(r.width, r.height);
  212. // Update focal length on resize to maintain FOV
  213. if (fov) {
  214. const fl = fovToFocalLength(fov, r.height);
  215. camera.data.fx = fl;
  216. camera.data.fy = fl;
  217. }
  218. }, 100);
  219. };
  220. window.addEventListener("resize", onResize);
  221. // Load splat files with progress tracking
  222. if (splats && splats.length > 0) {
  223. const totalSplats = splats.length;
  224. for (let i = 0; i < totalSplats; i++) {
  225. if (cancelled) return;
  226. const entry = splats[i]!;
  227. const splatObject = await SPLAT.Loader.LoadAsync(
  228. entry.src,
  229. scene,
  230. (p: number) => {
  231. if (!cancelled) {
  232. const overallProgress = (i + p) / totalSplats;
  233. // Use Math.max to prevent progress bar from jumping backwards
  234. setProgress((prev) => Math.max(prev, overallProgress));
  235. }
  236. },
  237. );
  238. // Apply per-splat transforms
  239. if (entry.position) {
  240. splatObject.position = new SPLAT.Vector3(
  241. entry.position[0],
  242. entry.position[1],
  243. entry.position[2],
  244. );
  245. }
  246. if (entry.rotation) {
  247. splatObject.rotation = eulerToQuaternion(entry.rotation);
  248. }
  249. if (entry.scale) {
  250. splatObject.scale = new SPLAT.Vector3(
  251. entry.scale[0],
  252. entry.scale[1],
  253. entry.scale[2],
  254. );
  255. }
  256. if (entry.visible !== undefined) {
  257. splatObject.visible = entry.visible;
  258. }
  259. }
  260. }
  261. if (cancelled) {
  262. controls?.dispose();
  263. renderer.dispose();
  264. if (renderer.canvas.parentElement) {
  265. renderer.canvas.parentElement.removeChild(renderer.canvas);
  266. }
  267. window.removeEventListener("resize", onResize);
  268. return;
  269. }
  270. setIsLoading(false);
  271. setError(null);
  272. // Render loop with optional auto-rotation
  273. let animationId: number;
  274. let lastTime = performance.now();
  275. const frame = () => {
  276. const now = performance.now();
  277. const dt = (now - lastTime) / 1000;
  278. lastTime = now;
  279. if (autoRotate) {
  280. // Rotate the camera around the camera target (not world origin)
  281. const speed = autoRotateSpeed * 0.5;
  282. const angle = speed * dt;
  283. const pos = camera.position;
  284. const target = cameraTarget
  285. ? new SPLAT.Vector3(
  286. cameraTarget[0],
  287. cameraTarget[1],
  288. cameraTarget[2],
  289. )
  290. : new SPLAT.Vector3(0, 0, 0);
  291. const dx = pos.x - target.x;
  292. const dz = pos.z - target.z;
  293. const cos = Math.cos(angle);
  294. const sin = Math.sin(angle);
  295. camera.position = new SPLAT.Vector3(
  296. target.x + dx * cos - dz * sin,
  297. pos.y,
  298. target.z + dx * sin + dz * cos,
  299. );
  300. }
  301. controls?.update();
  302. renderer.render(scene, camera);
  303. animationId = requestAnimationFrame(frame);
  304. };
  305. animationId = requestAnimationFrame(frame);
  306. // Store cleanup function
  307. cleanupRef.current = () => {
  308. cancelAnimationFrame(animationId);
  309. clearTimeout(resizeTimer);
  310. window.removeEventListener("resize", onResize);
  311. controls?.dispose();
  312. renderer.dispose();
  313. if (renderer.canvas.parentElement) {
  314. renderer.canvas.parentElement.removeChild(renderer.canvas);
  315. }
  316. };
  317. } catch (err) {
  318. if (!cancelled) {
  319. setError(
  320. err instanceof Error ? err.message : "Failed to initialize viewer",
  321. );
  322. setIsLoading(false);
  323. }
  324. }
  325. }
  326. init();
  327. return () => {
  328. cancelled = true;
  329. if (cleanupRef.current) {
  330. cleanupRef.current();
  331. cleanupRef.current = null;
  332. }
  333. };
  334. }, [
  335. splats,
  336. enableControls,
  337. autoRotate,
  338. autoRotateSpeed,
  339. cameraPosition,
  340. cameraTarget,
  341. fov,
  342. ]);
  343. return (
  344. <div
  345. style={{
  346. width,
  347. height,
  348. position: "relative",
  349. overflow: "hidden",
  350. background: backgroundColor,
  351. }}
  352. >
  353. <div
  354. ref={containerRef}
  355. style={{ position: "relative", width: "100%", height: "100%" }}
  356. />
  357. {isLoading &&
  358. (loadingIndicator ?? (
  359. <ProgressIndicator
  360. progress={progress}
  361. backgroundColor={progressBackgroundColor}
  362. barColor={progressBarColor}
  363. trackColor={progressTrackColor}
  364. textColor={progressTextColor}
  365. />
  366. ))}
  367. {error && (
  368. <div
  369. role="alert"
  370. style={{
  371. position: "absolute",
  372. inset: 0,
  373. display: "flex",
  374. alignItems: "center",
  375. justifyContent: "center",
  376. background: backgroundColor,
  377. color: "#ff4444",
  378. fontFamily: "ui-monospace, monospace",
  379. fontSize: 12,
  380. padding: 20,
  381. textAlign: "center",
  382. }}
  383. >
  384. {error}
  385. </div>
  386. )}
  387. {children}
  388. </div>
  389. );
  390. }