3dgs & ply support (#7602)

## Summary

integrated sparkjs https://sparkjs.dev/, built by [world labs
](https://www.worldlabs.ai/) to support 3dgs.

- Add 3D Gaussian Splatting (3DGS) support using @sparkjsdev/spark
library
- Add PLY file format support with multiple rendering engines
- Support new file formats: `.ply`, `.spz`, `.splat`, `.ksplat`
- Add PLY Engine setting with three options: `threejs` (mesh), `fastply`
(optimized ASCII point clouds), `sparkjs` (3DGS)
- Add `FastPLYLoader` for 4-5x faster ASCII PLY parsing
- Add `original(Advanced)` material mode for point cloud rendering with
THREE.Points

3dgs generated by https://marble.worldlabs.ai/

test ply file from:
1. made by https://github.com/PozzettiAndrea/ComfyUI-DepthAnythingV3
2. threejs offically repo

## Screenshots


https://github.com/user-attachments/assets/44e64d3e-b58d-4341-9a70-a9aa64801220



https://github.com/user-attachments/assets/76b0dfba-0c12-4f64-91cb-bfc5d672294d



https://github.com/user-attachments/assets/2a8bfe81-1fb2-44c4-8787-dff325369c61



https://github.com/user-attachments/assets/e4beecee-d7a2-40c9-97f7-79b09c60312d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7602-3dgs-ply-support-2cd6d73d3650814098fcea86cfaf747d)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-12-20 16:04:16 -05:00
committed by GitHub
parent 212d19e2fa
commit 3c4b99ed84
25 changed files with 1110 additions and 27 deletions

View File

@@ -198,6 +198,17 @@ useExtensionService().registerExtension({
type: 'boolean',
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
defaultValue: 'threejs',
experimental: true
}
],
commands: [
@@ -238,7 +249,10 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput('.gltf,.glb,.obj,.fbx,.stl', false)
const fileInput = createFileInput(
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
false
)
node.properties['Resource Folder'] = ''
@@ -301,6 +315,8 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
@@ -409,6 +425,8 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},

View File

@@ -577,6 +577,14 @@ class Load3d {
this.loadingPromise = null
}
isSplatModel(): boolean {
return this.modelManager.containsSplatMesh()
}
isPlyModel(): boolean {
return this.modelManager.originalModel instanceof THREE.BufferGeometry
}
clearModel(): void {
this.animationManager.dispose()
this.modelManager.clearModel()

View File

@@ -1,18 +1,24 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
@@ -20,9 +26,12 @@ export class LoaderManager implements LoaderManagerInterface {
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
plyLoader: PLYLoader
fastPlyLoader: FastPLYLoader
private modelManager: ModelManagerInterface
private eventManager: EventManagerInterface
private currentLoadId: number = 0
constructor(
modelManager: ModelManagerInterface,
@@ -36,6 +45,8 @@ export class LoaderManager implements LoaderManagerInterface {
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.plyLoader = new PLYLoader()
this.fastPlyLoader = new FastPLYLoader()
}
init(): void {}
@@ -43,6 +54,8 @@ export class LoaderManager implements LoaderManagerInterface {
dispose(): void {}
async loadModel(url: string, originalFileName?: string): Promise<void> {
const loadId = ++this.currentLoadId
try {
this.eventManager.emitEvent('modelLoadingStart', null)
@@ -72,7 +85,11 @@ export class LoaderManager implements LoaderManagerInterface {
return
}
let model = await this.loadModelInternal(url, fileExtension)
const model = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
return
}
if (model) {
await this.modelManager.setupModel(model)
@@ -80,9 +97,11 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
if (loadId === this.currentLoadId) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}
}
@@ -187,8 +206,132 @@ export class LoaderManager implements LoaderManagerInterface {
}
})
break
case 'ply':
model = await this.loadPLY(path, filename)
break
case 'spz':
case 'splat':
case 'ksplat':
model = await this.loadSplat(path, filename)
break
}
return model
}
private async fetchModelData(path: string, filename: string) {
const route =
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}
private async loadSplat(
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await this.fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
this.modelManager.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
private async loadPLY(
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
if (plyEngine === 'sparkjs') {
return this.loadSplat(path, filename)
}
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
const arrayBuffer = await this.fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
let plyGeometry: THREE.BufferGeometry
if (isASCII && plyEngine === 'fastply') {
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
} else {
this.plyLoader.setPath(path)
plyGeometry = this.plyLoader.parse(arrayBuffer)
}
this.modelManager.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const materialMode = this.modelManager.materialMode
// Use Points rendering for pointCloud mode (better for point clouds)
if (materialMode === 'pointCloud') {
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
this.modelManager.originalMaterials.set(
plyPoints as unknown as THREE.Mesh,
pointMaterial
)
const plyGroup = new THREE.Group()
plyGroup.add(plyPoints)
return plyGroup
}
// Use Mesh rendering for other modes
let plyMaterial: THREE.Material
if (hasVertexColors) {
plyMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
} else {
plyMaterial = this.modelManager.standardMaterial.clone()
plyMaterial.side = THREE.DoubleSide
}
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
const plyGroup = new THREE.Group()
plyGroup.add(plyMesh)
return plyGroup
}
}

View File

@@ -1,3 +1,4 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
@@ -98,6 +99,145 @@ export class SceneModelManager implements ModelManagerInterface {
})
}
private handlePLYModeSwitch(mode: MaterialMode): void {
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
return
}
const plyGeometry = this.originalModel.clone()
const hasVertexColors = plyGeometry.attributes.color !== undefined
// Find and remove ALL MainModel instances by name to ensure deletion
const oldMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
oldMainModels.push(obj)
}
})
// Remove and dispose all found MainModels
oldMainModels.forEach((oldModel) => {
oldModel.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
child.geometry?.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose())
} else {
child.material?.dispose()
}
}
})
this.scene.remove(oldModel)
})
this.currentModel = null
let newModel: THREE.Object3D
if (mode === 'pointCloud') {
// Use Points rendering for point cloud mode
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(plyGeometry, pointMaterial)
newModel = new THREE.Group()
newModel.add(points)
} else {
// Use Mesh rendering for other modes
let meshMaterial: THREE.Material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: this.standardMaterial.clone()
if (
!hasVertexColors &&
meshMaterial instanceof THREE.MeshStandardMaterial
) {
meshMaterial.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
this.originalMaterials.set(mesh, meshMaterial)
newModel = new THREE.Group()
newModel.add(mesh)
// Apply the requested material mode
if (mode === 'normal') {
mesh.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide
})
} else if (mode === 'wireframe') {
mesh.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true
})
}
}
// Double check: remove any remaining MainModel before adding new one
const remainingMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
remainingMainModels.push(obj)
}
})
remainingMainModels.forEach((obj) => this.scene.remove(obj))
this.currentModel = newModel
newModel.name = 'MainModel'
// Setup the new model
if (mode === 'pointCloud') {
this.scene.add(newModel)
} else {
const box = new THREE.Box3().setFromObject(newModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
newModel.scale.multiplyScalar(scale)
box.setFromObject(newModel)
box.getCenter(center)
box.getSize(size)
newModel.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(newModel)
}
this.eventManager.emitEvent('materialModeChange', mode)
}
setMaterialMode(mode: MaterialMode): void {
if (!this.currentModel || mode === this.materialMode) {
return
@@ -105,6 +245,12 @@ export class SceneModelManager implements ModelManagerInterface {
this.materialMode = mode
// Handle PLY files specially - they need to be recreated for mode switch
if (this.originalModel instanceof THREE.BufferGeometry) {
this.handlePLYModeSwitch(mode)
return
}
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
@@ -186,6 +332,7 @@ export class SceneModelManager implements ModelManagerInterface {
})
break
case 'original':
case 'pointCloud':
const originalMaterial = this.originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
@@ -272,12 +419,25 @@ export class SceneModelManager implements ModelManagerInterface {
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
model.name = 'MainModel'
this.scene.add(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model
model.name = 'MainModel'
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
const isSplatModel = this.containsSplatMesh(model)
if (isSplatModel) {
// SplatMesh handles its own rendering, just add to scene
this.scene.add(model)
// Set a default camera distance for splat models
this.setupCamera(new THREE.Vector3(5, 5, 5))
return
}
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
@@ -308,6 +468,17 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupCamera(size)
}
containsSplatMesh(model?: THREE.Object3D | null): boolean {
const target = model ?? this.currentModel
if (!target) return false
if (target instanceof SplatMesh) return true
let found = false
target.traverse((child) => {
if (child instanceof SplatMesh) found = true
})
return found
}
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
this.originalModel = model
}

View File

@@ -7,7 +7,12 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
export type MaterialMode =
| 'original'
| 'pointCloud'
| 'normal'
| 'wireframe'
| 'depth'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
@@ -186,5 +191,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
'.glb',
'.obj',
'.fbx',
'.stl'
'.stl',
'.spz',
'.splat',
'.ply',
'.ksplat'
])

View File

@@ -0,0 +1,33 @@
import * as THREE from 'three'
import { parseASCIIPLY } from '@/scripts/metadata/ply'
/**
* Fast ASCII PLY Loader
* Optimized for simple ASCII PLY files with position and color data
* 4-5x faster than Three.js PLYLoader for ASCII files
*/
export class FastPLYLoader {
parse(arrayBuffer: ArrayBuffer): THREE.BufferGeometry {
const plyData = parseASCIIPLY(arrayBuffer)
if (!plyData) {
throw new Error('Failed to parse PLY data')
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.BufferAttribute(plyData.positions, 3)
)
if (plyData.colors) {
geometry.setAttribute(
'color',
new THREE.BufferAttribute(plyData.colors, 3)
)
}
return geometry
}
}

View File

@@ -54,6 +54,8 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},