import type { SparkRenderer } from "@sparkjsdev/spark"; import { NeedleEngineModelLoader, registeredModelLoaderCallbacks } from "./engine_loaders.callbacks.js"; import type { Context } from "./engine_setup.js"; const GAUSSIAN_SPLAT_LOADER_NAME = "Gaussian Splat Loader"; /** Ensures a SparkRenderer exists in the scene. Required for SplatMesh rendering. */ function ensureSparkRenderer(context: Context, ctor: typeof import("@sparkjsdev/spark").SparkRenderer): SparkRenderer { let sparkRenderer: SparkRenderer | undefined; context.scene.traverse(obj => { if (obj instanceof ctor) sparkRenderer = obj as SparkRenderer; }); if (!sparkRenderer) { sparkRenderer = new ctor({ renderer: context.renderer }); context.scene.add(sparkRenderer); } return sparkRenderer; } /** * Register the engine's built-in custom model loaders (currently the Gaussian * Splat loader, backed by Spark). * * Called from {@link initNeedleLoader} rather than run as a bare module * side-effect. A side-effect-only `import "./engine_loaders.custom.js"` gets * tree-shaken away when an app bundles the engine — this file is not listed in * the package's `sideEffects` — which silently disabled splat loading (a * dropped `.ply` fell back to the GLTFLoader). A called export cannot be * eliminated. Idempotent, so it is safe to call on every engine init. * @internal */ export function registerBuiltinCustomLoaders() { if (registeredModelLoaderCallbacks.some(e => e.name === GAUSSIAN_SPLAT_LOADER_NAME)) return; NeedleEngineModelLoader.onCreateCustomModelLoader(cb => { switch (cb.mimetype) { case "model/ply": case "model/spz": case "model/splat": case "model/ksplat": case "model/sog": case "model/rad": { const mimetype = cb.mimetype; const context = cb.context; return { name: GAUSSIAN_SPLAT_LOADER_NAME, loadAsync: async (url: string, onProgress?: (event: ProgressEvent) => void) => { // Lazy-load Spark only when a splat is actually loaded, so it stays out of the // main bundle and downloads on demand as its own chunk. const spark = await import("@sparkjsdev/spark"); // Ensure the SparkRenderer is present for splat rendering ensureSparkRenderer(context, spark.SparkRenderer); const loader = new spark.SplatLoader(); // Pass the file type we already resolved (via the engine's mimetype // detection) to Spark explicitly. Spark's public load/loadAsync // re-derive the type from the URL, which fails for extension-less // `blob:` URLs (drag & drop) — its worker errors with "Unknown file // type" even though the format is known. Mapping mirrors Spark's own // getSplatFileTypeFromPath (`.sog` is the zipped SOG bundle). const fileType = ({ "model/ply": spark.SplatFileType.PLY, "model/spz": spark.SplatFileType.SPZ, "model/splat": spark.SplatFileType.SPLAT, "model/ksplat": spark.SplatFileType.KSPLAT, "model/sog": spark.SplatFileType.PCSOGSZIP, "model/rad": spark.SplatFileType.RAD, } as const)[mimetype]; const result = await new Promise | InstanceType>((resolve, reject) => { loader.loadInternal({ url, fileType, onLoad: resolve, onError: reject, onProgress: (event: ProgressEvent) => { if (onProgress && event.type === "progress") { const progress = event as { loaded?: number, total?: number }; onProgress(new ProgressEvent("progress", { loaded: progress.loaded ?? 0, total: progress.total ?? 0, lengthComputable: progress.total != null && progress.total > 0, })); } }, }); }); const opts = result instanceof spark.PackedSplats ? { packedSplats: result, lod: true } : { extSplats: result, lod: true }; const mesh = new spark.SplatMesh(opts); if (mimetype === "model/ply" || mimetype === "model/sog") { // .ply 3DGS files are trained in OpenCV (Y-down) space; .sog (PlayCanvas // SOG) stores that same raw convention (and PlayCanvas itself flips .sog // on load). Re-orient to three.js/OpenGL (Y-up): 180° about X. // .spz/.splat load upright. mesh.quaternion.set(1, 0, 0, 0); } return mesh; }, parse: () => { throw new Error("Not implemented"); }, } } } return null; }, { name: GAUSSIAN_SPLAT_LOADER_NAME }) }