From 83844f24e57931ddfba7fe73622aa0a37d9fe727 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Wed, 24 Jun 2026 13:12:09 +0300 Subject: [PATCH 1/5] feat: add Gaussian Splatting support (.ply/.splat/.spz) Adds full-pipeline support for Gaussian splatting assets across import, editor preview, save/load, export and runtime: - extensions: register .ply/.splat/.spz as model assets (SPLAT loader is already provided by babylonjs-loaders) - guards: add isGaussianSplattingMesh and recognize GaussianSplattingMesh as an abstract mesh so it shows in the graph, is selectable and gets the mesh inspector (transforms) - import: skip the glTF x100 centimeters scaling for splats (metric space) - preview/assets-browser: accept splat files on drop and in the browser - save: include GaussianSplattingMesh; the splat data is embedded inline by Babylon's native serialize(), so drop the unused serialized material and quad geometry to avoid writing a useless .babylonbinarymeshdata file - load: restore uniqueId/parent for splat meshes, skipping the regular material/geometry reconciliation - export: skip geometry externalization/delay-loading for splat meshes - tools runtime: import the GaussianSplatting side-effect module so the .babylon loader's Mesh.Parse can reconstruct splat meshes inline --- editor/src/editor/layout/assets-browser.tsx | 3 +++ editor/src/editor/layout/preview.tsx | 3 +++ .../editor/layout/preview/import/import.ts | 10 +++++++-- editor/src/project/export/export.tsx | 20 +++++++++++++++++- editor/src/project/load/plugins/meshes.ts | 8 ++++--- editor/src/project/save/scene.ts | 21 ++++++++++++++++--- editor/src/tools/assets/extensions.ts | 3 ++- editor/src/tools/guards/nodes.ts | 10 +++++++++ tools/src/loading/loader.ts | 4 ++++ 9 files changed, 72 insertions(+), 10 deletions(-) diff --git a/editor/src/editor/layout/assets-browser.tsx b/editor/src/editor/layout/assets-browser.tsx index c46e29888..236c197e2 100644 --- a/editor/src/editor/layout/assets-browser.tsx +++ b/editor/src/editor/layout/assets-browser.tsx @@ -896,6 +896,9 @@ export class EditorAssetsBrowser extends Component; case ".material": diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index e6550581d..a598f723b 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -1349,6 +1349,9 @@ export class EditorPreview extends Component { if (pick.pickedPoint) { result?.meshes.forEach((m) => !m.parent && m.position.addInPlace(pick.pickedPoint!)); diff --git a/editor/src/editor/layout/preview/import/import.ts b/editor/src/editor/layout/preview/import/import.ts index b5bc3273d..cb10ffecb 100644 --- a/editor/src/editor/layout/preview/import/import.ts +++ b/editor/src/editor/layout/preview/import/import.ts @@ -23,7 +23,7 @@ import { } from "babylonjs"; import { UniqueNumber } from "../../../../tools/tools"; -import { isMesh } from "../../../../tools/guards/nodes"; +import { isGaussianSplattingMesh, isMesh } from "../../../../tools/guards/nodes"; import { isSprite } from "../../../../tools/guards/sprites"; import { isTexture } from "../../../../tools/guards/texture"; import { executeSimpleWorker } from "../../../../tools/worker"; @@ -80,9 +80,15 @@ export async function loadImportedSceneFile(scene: Scene, absolutePath: string) return null; } + // Gaussian splatting assets (.ply/.splat/.spz) are authored in metric space and don't follow the + // glTF "1 unit = 1 meter" convention, so we must not apply the x100 centimeters scaling to them. + const isGaussianSplatting = result.meshes.some((m) => isGaussianSplattingMesh(m)); + const root = result.meshes.find((m) => m.name === "__root__"); if (root) { - root.scaling.scaleInPlace(100); + if (!isGaussianSplatting) { + root.scaling.scaleInPlace(100); + } root.name = basename(absolutePath); // TODO: try cleaning the gltf to remove useless transform nodes. Also, does it make sens to clean the gltf for the user? diff --git a/editor/src/project/export/export.tsx b/editor/src/project/export/export.tsx index 786aae63d..e34a06b27 100644 --- a/editor/src/project/export/export.tsx +++ b/editor/src/project/export/export.tsx @@ -11,7 +11,7 @@ import { getCollisionMeshFor } from "../../tools/mesh/collision"; import { storeTexturesBaseSize } from "../../tools/material/texture"; import { extractNodeMaterialTextures } from "../../tools/material/extract"; import { createDirectoryIfNotExist, normalizedGlob } from "../../tools/fs"; -import { isCollisionMesh, isEditorCamera, isMesh } from "../../tools/guards/nodes"; +import { isCollisionMesh, isEditorCamera, isGaussianSplattingMesh, isMesh } from "../../tools/guards/nodes"; import { extractNodeParticleSystemSetTextures, extractParticleSystemTextures } from "../../tools/particles/extract"; import { taaPipelineCameraConfigurations } from "../../editor/rendering/taa"; @@ -221,6 +221,24 @@ async function _exportProject(editor: Editor, options: IExportProjectOptions): P } } + // Gaussian splatting meshes embed their splat data inline and rebuild their own quad geometry at + // parse time (the loader skips importing geometry for them), so they must not go through the + // geometry externalization/delay-loading pipeline. + if (instantiatedMesh && isGaussianSplattingMesh(instantiatedMesh)) { + let geometryIndex = -1; + do { + geometryIndex = data.geometries?.vertexData?.findIndex((g) => g.id === mesh.geometryId) ?? -1; + if (geometryIndex !== -1) { + data.geometries!.vertexData!.splice(geometryIndex, 1); + } + } while (geometryIndex !== -1); + + delete mesh.geometryId; + delete mesh.geometryUniqueId; + delete mesh.delayLoadingFile; + return; + } + const geometry = data.geometries?.vertexData?.find((v) => v.id === mesh.geometryId); if (geometry) { diff --git a/editor/src/project/load/plugins/meshes.ts b/editor/src/project/load/plugins/meshes.ts index fe9e4f6a9..eae01af0d 100644 --- a/editor/src/project/load/plugins/meshes.ts +++ b/editor/src/project/load/plugins/meshes.ts @@ -6,7 +6,7 @@ import { Scene, Constants, Matrix, Mesh, SceneLoader, MultiMaterial, Geometry } import { ISceneLoaderPluginOptions } from "../scene"; import { wait } from "../../../tools/tools"; -import { isCollisionMesh, isMesh } from "../../../tools/guards/nodes"; +import { isCollisionMesh, isGaussianSplattingMesh, isMesh } from "../../../tools/guards/nodes"; import { isMultiMaterial, isNodeMaterial } from "../../../tools/guards/material"; import { parsePhysicsAggregate } from "../../../tools/physics/serialization/aggregate"; import { configureSimultaneousLightsForMaterial, normalizeNodeMaterialUniqueIds } from "../../../tools/material/material"; @@ -40,7 +40,7 @@ export async function loadMeshes(meshesFiles: string[], scene: Scene, options: I } result.meshes.forEach((m) => { - if (!isMesh(m)) { + if (!isMesh(m) && !isGaussianSplattingMesh(m)) { return; } @@ -109,7 +109,9 @@ export async function loadMeshes(meshesFiles: string[], scene: Scene, options: I options.loadResult.meshes.push(m); - if (m.material) { + // Gaussian splatting meshes manage their own internal material recreated at parse time, + // so the regular material reconciliation below does not apply to them. + if (m.material && !isGaussianSplattingMesh(m)) { const material = isMultiMaterial(m.material) ? data.multiMaterials?.find((d) => d.id === m.material!.id) : data.materials?.find((d) => d.id === m.material!.id); diff --git a/editor/src/project/save/scene.ts b/editor/src/project/save/scene.ts index 2faf4a9b3..0cda201b7 100644 --- a/editor/src/project/save/scene.ts +++ b/editor/src/project/save/scene.ts @@ -19,7 +19,7 @@ import { isSpriteManagerNode, isSpriteMapNode } from "../../tools/guards/sprites import { serializePhysicsAggregate } from "../../tools/physics/serialization/aggregate"; import { isAnimationGroupFromSceneLink, isFromSceneLink } from "../../tools/scene/scene-link"; import { isGPUParticleSystem, isNodeParticleSystemSetMesh, isParticleSystem } from "../../tools/guards/particles"; -import { isAnyTransformNode, isClusteredLightContainer, isCollisionMesh, isEditorCamera, isMesh, isTransformNode } from "../../tools/guards/nodes"; +import { isAnyTransformNode, isClusteredLightContainer, isCollisionMesh, isEditorCamera, isGaussianSplattingMesh, isMesh, isTransformNode } from "../../tools/guards/nodes"; import { taaPipelineCameraConfigurations } from "../../editor/rendering/taa"; import { vlsPostProcessCameraConfigurations } from "../../editor/rendering/vls"; @@ -73,7 +73,7 @@ export async function saveScene(editor: Editor, projectPath: string, scenePath: const scene = editor.layout.preview.scene; const meshesToSave = scene.meshes.filter((mesh) => { - if ((!isMesh(mesh) && !isCollisionMesh(mesh)) || mesh._masterMesh || isFromSceneLink(mesh) || !isNodeVisibleInGraph(mesh)) { + if ((!isMesh(mesh) && !isCollisionMesh(mesh) && !isGaussianSplattingMesh(mesh)) || mesh._masterMesh || isFromSceneLink(mesh) || !isNodeVisibleInGraph(mesh)) { return false; } @@ -99,7 +99,7 @@ export async function saveScene(editor: Editor, projectPath: string, scenePath: // Write geometries and meshes await Promise.all( meshesToSave.map(async (mesh) => { - if ((!isMesh(mesh) && !isCollisionMesh(mesh)) || mesh._masterMesh || isFromSceneLink(mesh) || !isNodeVisibleInGraph(mesh)) { + if ((!isMesh(mesh) && !isCollisionMesh(mesh) && !isGaussianSplattingMesh(mesh)) || mesh._masterMesh || isFromSceneLink(mesh) || !isNodeVisibleInGraph(mesh)) { return; } @@ -131,6 +131,21 @@ export async function saveScene(editor: Editor, projectPath: string, scenePath: data.metadata = meshToSerialize.metadata; data.basePoseMatrix = meshToSerialize.getPoseMatrix().asArray(); + // Gaussian splatting meshes embed their splat data inline in the serialized JSON and recreate + // their own material and quad geometry when parsed (the loader skips importing geometry for + // them). So the serialized material and geometry references are unused: dropping them avoids + // writing/delay-loading a useless `.babylonbinarymeshdata` file for the splatting quad. + if (isGaussianSplattingMesh(meshToSerialize)) { + delete data.materials; + delete data.geometries; + + data.meshes?.forEach((m) => { + delete m.geometryId; + delete m.geometryUniqueId; + delete m.delayLoadingFile; + }); + } + // Handle case where the mesh is a collision mesh if (isCollisionMesh(meshToSerialize)) { data.isCollisionMesh = true; diff --git a/editor/src/tools/assets/extensions.ts b/editor/src/tools/assets/extensions.ts index 3bd2bd67e..6dfedc9f8 100644 --- a/editor/src/tools/assets/extensions.ts +++ b/editor/src/tools/assets/extensions.ts @@ -1,6 +1,7 @@ export const assetsImageExtensions = [".png", ".webp", ".jpg", ".bmp", ".jpeg"]; export const assetsAudioExtensions = [".mp3", ".wav", ".wave", ".ogg"]; export const assetsVideoExtensions = [".mp4", ".webm", ".ogg"]; -export const assetsModelExtensions = [".gltf", ".glb", ".obj", ".babylon", ".stl", ".3ds", ".fbx"]; +export const assetsGaussianSplattingExtensions = [".ply", ".splat", ".spz"]; +export const assetsModelExtensions = [".gltf", ".glb", ".obj", ".babylon", ".stl", ".3ds", ".fbx", ...assetsGaussianSplattingExtensions]; export const assetsAllSupportedExtensions = [...assetsImageExtensions, ...assetsAudioExtensions, ...assetsVideoExtensions, ...assetsModelExtensions]; diff --git a/editor/src/tools/guards/nodes.ts b/editor/src/tools/guards/nodes.ts index d441dfbd9..08e5e8ecc 100644 --- a/editor/src/tools/guards/nodes.ts +++ b/editor/src/tools/guards/nodes.ts @@ -15,6 +15,7 @@ import { HemisphericLight, Skeleton, ClusteredLightContainer, + GaussianSplattingMesh, } from "babylonjs"; import { EditorCamera } from "../../editor/nodes/camera"; @@ -35,12 +36,21 @@ export function isAbstractMesh(object: any): object is Mesh { case "GroundMesh": case "InstancedMesh": case "NodeParticleSystemSetMesh": + case "GaussianSplattingMesh": return true; } return false; } +/** + * Returns wether or not the given object is a GaussianSplattingMesh. + * @param object defines the reference to the object to test its class name. + */ +export function isGaussianSplattingMesh(object: any): object is GaussianSplattingMesh { + return object.getClassName?.() === "GaussianSplattingMesh"; +} + /** * Returns wether or not the given object is a Mesh. * @param object defines the reference to the object to test its class name. diff --git a/tools/src/loading/loader.ts b/tools/src/loading/loader.ts index 6befaa27b..a0f6d82a5 100644 --- a/tools/src/loading/loader.ts +++ b/tools/src/loading/loader.ts @@ -1,3 +1,7 @@ +// Registers the GaussianSplattingMesh parser (Mesh._GaussianSplattingMeshParser) so the .babylon scene +// loader can reconstruct Gaussian splatting meshes serialized inline by the editor. +import "@babylonjs/core/Meshes/GaussianSplatting/gaussianSplattingMesh"; + import { Scene } from "@babylonjs/core/scene"; import { Vector3 } from "@babylonjs/core/Maths/math.vector"; import { Constants } from "@babylonjs/core/Engines/constants"; From 4c79b07ecd5246fe1ce543d98227aa6050d77168 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Wed, 24 Jun 2026 13:59:57 +0300 Subject: [PATCH 2/5] fix: keep Gaussian splatting data in RAM on import so it can be serialized GaussianSplattingMesh frees its splat buffer right after uploading it to the GPU when keepInRam is false (the default). serialize() only writes splatsData when the buffer is still present, so saving/exporting a splat produced an empty mesh that failed to render on reload (glDrawElementsInstanced: vertex buffer not big enough). Pass `keepInRam: true` to the SPLAT loader on import so the data is retained and round-trips through save/load/export. The flag is serialized with the mesh, so reloaded and exported splats keep their data too. Ignored by non-splat loaders. --- .../src/editor/layout/preview/import/import.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/editor/src/editor/layout/preview/import/import.ts b/editor/src/editor/layout/preview/import/import.ts index cb10ffecb..c81a6d03a 100644 --- a/editor/src/editor/layout/preview/import/import.ts +++ b/editor/src/editor/layout/preview/import/import.ts @@ -72,6 +72,14 @@ export async function loadImportedSceneFile(scene: Scene, absolutePath: string) try { result = await ImportMeshAsync(basename(absolutePath), scene, { rootUrl: join(dirname(absolutePath), "/"), + // Keep Gaussian splatting data in RAM. Otherwise the splat buffer is freed right after being + // uploaded to the GPU, and `GaussianSplattingMesh.serialize()` (used when saving/exporting) would + // have nothing to write, producing an empty splat on reload. Ignored by non-splat loaders. + pluginOptions: { + splat: { + keepInRam: true, + }, + }, }); // result = await SceneLoader.ImportMeshAsync("", join(dirname(absolutePath), "/"), basename(absolutePath), scene); } catch (e) { @@ -98,7 +106,9 @@ export async function loadImportedSceneFile(scene: Scene, absolutePath: string) result.meshes.forEach((mesh) => { configureImportedNodeIds(mesh); - mesh.receiveShadows = true; + // Gaussian splatting meshes render through thin instances with a custom splat buffer and cannot be + // drawn by the standard shadow/depth passes, so they must not participate in shadows. + mesh.receiveShadows = !isGaussianSplattingMesh(mesh); if (mesh.skeleton) { mesh.skeleton.id = Tools.RandomId(); @@ -133,6 +143,11 @@ export async function loadImportedSceneFile(scene: Scene, absolutePath: string) } result.meshes.forEach((mesh) => { + // Gaussian splatting meshes can't be rendered into shadow maps (see above). + if (isGaussianSplattingMesh(mesh)) { + return; + } + shadowMap.renderList!.push(mesh); }); }); From 028b7c5882e4d964d43eda418d9e942258f71b71 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Wed, 24 Jun 2026 14:15:40 +0300 Subject: [PATCH 3/5] fix: correct splat orientation and exclude splats from shadow/outline passes - import: flip Y back on imported Gaussian splatting meshes so they appear upright. The SPLAT loader bakes a `scaling.y *= -1` to convert from the common Y-down splat convention, but plain .splat/.ply/.spz files carry no up-axis metadata and end up upside down in the editor's left-handed scene. - graph / configure / shadow-generators: never add Gaussian splatting meshes to shadow-map render lists (and strip them from lists persisted by older projects). Their thin-instance splat layout can't be drawn by the depth pass and triggered "glDrawElementsInstanced: vertex buffer not big enough". - mesh inspector: hide collision/physics/shadows/material sections for splats and skip the selection-outline pass (same depth-pass limitation). - layout: bump layout version to refresh persisted panel layout. --- editor/src/editor/layout/graph.tsx | 3 ++- .../src/editor/layout/inspector/mesh/mesh.tsx | 19 ++++++++++++++----- .../editor/layout/preview/import/import.ts | 7 +++++++ editor/src/project/add/configure.ts | 9 +++++++-- .../project/load/plugins/shadow-generators.ts | 8 ++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/editor/src/editor/layout/graph.tsx b/editor/src/editor/layout/graph.tsx index 664a05974..724506e14 100644 --- a/editor/src/editor/layout/graph.tsx +++ b/editor/src/editor/layout/graph.tsx @@ -58,6 +58,7 @@ import { isCollisionInstancedMesh, isCollisionMesh, isEditorCamera, + isGaussianSplattingMesh, isInstancedMesh, isLight, isMesh, @@ -499,7 +500,7 @@ export class EditorGraph extends Component } } - if (isAbstractMesh(node)) { + if (isAbstractMesh(node) && !isGaussianSplattingMesh(node)) { this.props.editor.layout.preview.scene.lights .map((light) => light.getShadowGenerator()) .forEach((generator) => generator?.getShadowMap()?.renderList?.push(node)); diff --git a/editor/src/editor/layout/inspector/mesh/mesh.tsx b/editor/src/editor/layout/inspector/mesh/mesh.tsx index 5073d7307..5aead3a03 100644 --- a/editor/src/editor/layout/inspector/mesh/mesh.tsx +++ b/editor/src/editor/layout/inspector/mesh/mesh.tsx @@ -29,7 +29,7 @@ import { registerUndoRedo } from "../../../../tools/undoredo"; import { waitNextAnimationFrame } from "../../../../tools/tools"; import { onNodeModifiedObservable } from "../../../../tools/observables"; import { updateIblShadowsRenderPipeline } from "../../../../tools/light/ibl"; -import { isAbstractMesh, isInstancedMesh, isMesh } from "../../../../tools/guards/nodes"; +import { isAbstractMesh, isGaussianSplattingMesh, isInstancedMesh, isMesh } from "../../../../tools/guards/nodes"; import { updateAllLights, updateLightShadowMapRefreshRate, updatePointLightShadowMapRenderListPredicate } from "../../../../tools/light/shadows"; import { applyMaterialAssetToObject } from "../../preview/import/material"; @@ -100,6 +100,10 @@ export class EditorMeshInspector extends Component