import type { SplatMesh } from "@sparkjsdev/spark"; import { applyGaussianSplatQuality, type GaussianSplatQuality } from "../engine/engine_loaders.custom.js"; import { loadAsset } from "../engine/engine_loaders.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { HideFlags } from "../engine/engine_types.js"; import { Behaviour } from "./Component.js"; /** * The {@link GaussianSplat} component loads and displays 3D Gaussian Splat models. * Supports `.ply`, `.spz`, and `.splat` file formats via SparkJS. * * **Properties:** * - `url` - URL to a gaussian splat file * - `opacity` - Global opacity multiplier (0-1) * * @example Load a splat file * ```ts * const splat = myObject.addComponent(GaussianSplat); * splat.url = "https://example.com/scene.spz"; * await splat.initialized; * ``` * * @summary Displays 3D Gaussian Splat models * @category Rendering * @group Components */ export class GaussianSplat extends Behaviour { /** URL to a gaussian splat file (.ply, .spz, .splat). Assigning a new value at runtime reloads the splat. */ @serializable(URL) set url(value: string | undefined) { if (value === this._url) return; this._url = value; // Reactive reload: a new URL replaces the currently displayed splat. Only after // awake — deserialization assigns the url BEFORE awake/onEnable, and loading here // would fetch+decode the file twice: awake's state reset wipes the loading flag, // so onEnable (which owns the initial load) starts a second load whose sibling // gets discarded after having cost the full download. if (value && this.__internalDidAwake && this.activeAndEnabled && !this._loading) this.load(value); } get url(): string | undefined { return this._url; } private _url?: string; /** Global opacity multiplier (0-1) */ @serializable() set opacity(value: number) { this._opacity = value; if (this._splatMesh) { this._splatMesh.opacity = value; // opacity is baked into Spark's accumulated splats at generation time — force a // regeneration so the change is visible without camera movement (Spark's claimed // auto-detection did not fire reliably in practice) this._splatMesh.updateVersion(); } } get opacity(): number { return this._opacity; } private _opacity: number = 1; /** Whether splats support raycasting */ @serializable() set raycastable(value: boolean) { this._raycastable = value; if (this._splatMesh) this._splatMesh.raycastable = value; } get raycastable(): boolean { return this._raycastable; } private _raycastable: boolean = true; /** Rendering/streaming quality preset. `"auto"` (default) resolves per device * (mobile → `"low"`, otherwise `"high"`) and ADAPTS at runtime: while the measured * frame rate stays below target the LOD density degrades in steps — but each step * must measurably improve the frame rate, otherwise it is reverted (visual quality * is never sacrificed when the bottleneck is elsewhere, e.g. a compositor-paced AR * session). Recovers when performance allows. An explicit `"low"`/`"medium"`/`"high"` * is fixed. Controls SH bands (view-dependent color), sort throttling, parallel * chunk decoders and LOD density — see {@link resolveGaussianSplatQuality} for the * concrete values. * NOTE: sort throttling and LOD density apply to the scene's shared splat renderer — * with multiple GaussianSplat components the last applied quality wins for those. */ @serializable() set quality(value: GaussianSplatQuality) { if (value === this._quality) return; this._quality = value; // reapply at runtime on the already-loaded mesh (and the shared renderer) if (this._splatMesh) applyGaussianSplatQuality(this.context, this._splatMesh, value); } get quality(): GaussianSplatQuality { return this._quality; } private _quality: GaussianSplatQuality = "auto"; /** The underlying SparkJS SplatMesh instance. Available after loading completes. */ get splatMesh(): SplatMesh | null { return this._splatMesh; } /** Whether the splat model has finished loading */ get isLoaded(): boolean { return this._splatMesh?.isInitialized === true; } /** Promise that resolves when the splat model has been loaded and initialized */ get initialized(): Promise { return this._initialized; } private _splatMesh: SplatMesh | null = null; private _initialized: Promise = Promise.resolve(); private _loading: boolean = false; /** Generation token: incremented by every `load()` call and by `awake()`. A load compares * its token after awaiting — a stale load (superseded by a newer one, or started before * `awake()` reset the component, e.g. via the `url` setter during deserialization) discards * its result instead of adding a second mesh. */ private _loadGeneration: number = 0; awake() { // Initialize values: important to reset state so cloning works. // NOTE: the mesh reference may have been copied from the instantiate source — // it belongs to the source component, so drop the reference without disposing. this._loadGeneration++; this._splatMesh = null; this._initialized = Promise.resolve(); this._loading = false; } onEnable(): void { if (this.url && !this._splatMesh && !this._loading) { this.load(this.url); } if (this._splatMesh) { this._splatMesh.visible = true; // Spark renders from an ACCUMULATED splat buffer that only regenerates on view // movement or a generator version change — without the bump a visibility change // doesn't take effect while the camera is still (visible is not auto-detected). this._splatMesh.updateVersion(); } } onDisable(): void { if (this._splatMesh) { this._splatMesh.visible = false; // see onEnable — required for the change to apply without camera movement this._splatMesh.updateVersion(); } } onDestroy(): void { if (this._splatMesh) { this._splatMesh.removeFromParent(); this._splatMesh.dispose(); this._splatMesh = null; } } /** Load a gaussian splat from a URL. Replaces any previously loaded splat. */ async load(url: string): Promise { const generation = ++this._loadGeneration; // keep the `url` property in sync with what is actually displayed — also required // so a clone (instantiate) can recreate the mesh from the serialized url this._url = url; // Clean up previous mesh if (this._splatMesh) { this._splatMesh.removeFromParent(); this._splatMesh.dispose(); this._splatMesh = null; } this._loading = true; // Load through the engine's core loader — routes to the Gaussian Splat loader // (engine_loaders.custom.ts), which builds the SplatMesh (LOD enabled), ensures a // SparkRenderer in the scene, and applies the .ply → three.js orientation fix. const model = await loadAsset(url, { context: this.context }); // superseded while awaiting (newer load, awake reset, or destroy) → discard if (generation !== this._loadGeneration || this.destroyed) { (model?.scene as SplatMesh | undefined)?.dispose(); return null; } const mesh = model?.scene as SplatMesh | undefined; if (!mesh) { this._loading = false; return null; } mesh.name = "Gaussian Splat"; // Runtime artifact: recreated from `url` by this component — never cloned by // `instantiate` (dead husk otherwise) and never serialized by the glTF exporter. mesh.hideFlags |= HideFlags.DontExport | HideFlags.DontInstantiate; mesh.opacity = this._opacity; mesh.raycastable = this.raycastable; this._splatMesh = mesh; this.gameObject.add(mesh); if (!this.activeAndEnabled) { mesh.visible = false; } this._initialized = mesh.initialized.then(() => { if (generation === this._loadGeneration) this._loading = false; }); await this._initialized; if (generation !== this._loadGeneration || this.destroyed) return null; // after initialization the pager exists (paged sources) — apply the quality preset // to the shared renderer and this mesh applyGaussianSplatQuality(this.context, mesh, this._quality); return mesh; } }