import type { SplatMesh } from "@sparkjsdev/spark"; import { loadAsset } from "../engine/engine_loaders.js"; import { serializable } from "../engine/engine_serialization_decorator.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. if (value && 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; } 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; /** 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; awake() { // Initialize values: important to reset state so cloning works 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; } } onDisable(): void { if (this._splatMesh) { this._splatMesh.visible = false; } } 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 { // 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 }); const mesh = model?.scene as SplatMesh | undefined; if (!mesh) { this._loading = false; return null; } 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(() => { this._loading = false; }); await this._initialized; return mesh; } }