diff --git a/examples/files.json b/examples/files.json index 8698b75f0a973e..bff0b18c6edb3c 100644 --- a/examples/files.json +++ b/examples/files.json @@ -346,6 +346,7 @@ "webgpu_equirectangular", "webgpu_fog_height", "webgpu_furnace_test", + "webgpu_gaussian_splatting", "webgpu_generator_building", "webgpu_generator_city", "webgpu_geometry_loft", diff --git a/examples/jsm/Addons.js b/examples/jsm/Addons.js index 91614d94ed73a5..48d7bef4436230 100644 --- a/examples/jsm/Addons.js +++ b/examples/jsm/Addons.js @@ -92,11 +92,13 @@ export * from './loaders/EXRLoader.js'; export * from './loaders/FBXLoader.js'; export * from './loaders/FontLoader.js'; export * from './loaders/GCodeLoader.js'; +export * from './loaders/GLTFGaussianSplatLoaderExtension.js'; export * from './loaders/GLTFLoader.js'; export * from './loaders/HDRLoader.js'; export * from './loaders/HDRCubeTextureLoader.js'; export * from './loaders/IESLoader.js'; export * from './loaders/KMZLoader.js'; +export * from './loaders/KSPLATLoader.js'; export * from './loaders/KTX2Loader.js'; export * from './loaders/KTXLoader.js'; export * from './loaders/LDrawLoader.js'; @@ -114,6 +116,8 @@ export * from './loaders/PDBLoader.js'; export * from './loaders/PLYLoader.js'; export * from './loaders/PVRLoader.js'; export * from './loaders/RGBELoader.js'; +export * from './loaders/SPLATLoader.js'; +export * from './loaders/SPZLoader.js'; export * from './loaders/UltraHDRLoader.js'; export * from './loaders/STLLoader.js'; export * from './loaders/SVGLoader.js'; @@ -160,6 +164,7 @@ export * from './modifiers/SimplifyModifier.js'; export * from './modifiers/TessellateModifier.js'; export * from './objects/GroundedSkybox.js'; +export * from './objects/GaussianSplatMesh.js'; export * from './objects/Lensflare.js'; export * from './objects/MarchingCubes.js'; export * from './objects/Reflector.js'; diff --git a/examples/jsm/gpgpu/CountingSort.js b/examples/jsm/gpgpu/CountingSort.js new file mode 100644 index 00000000000000..0ef0bd8442972a --- /dev/null +++ b/examples/jsm/gpgpu/CountingSort.js @@ -0,0 +1,271 @@ +import { StorageBufferAttribute, DynamicDrawUsage } from 'three/webgpu'; +import { Fn, Loop, atomicAdd, atomicLoad, atomicStore, instanceIndex, storage, uint } from 'three/tsl'; + +/** + * A reusable GPU counting sort. + * + * This computes a stable-ish permutation of the integers `[0, count)` that orders them by an + * arbitrary, user supplied `uint` key ("bin") in the range `[0, binCount)`. It is a good fit for + * approximate ordering of large element counts (hundreds of thousands to millions) where an exact + * comparison sort such as a bitonic sort (see {@link BitonicSort}) would be too slow: a counting + * sort only requires a fixed number of passes (reset, histogram, prefix sum, scatter) regardless of + * `count`, at the cost of only being accurate to the resolution of `binCount` - elements that land + * in the same bin end up in an unspecified relative order. + * + * This class does not compute the sort key itself. Instead, a TSL function is supplied via + * {@link CountingSort#setBinNode} that maps the current `instanceIndex` to a bin, and an equivalent + * plain JavaScript function can be supplied to {@link CountingSort#computeCPU} for platforms without + * compute shader support (e.g. the WebGL backend of {@link WebGPURenderer}). + * + * ```js + * const sort = new CountingSort( count, { binCount: 4096 } ); + * sort.setBinNode( () => { + * + * // return a `Node` bin index for `instanceIndex`, e.g. derived from a depth value. + * + * } ); + * + * sort.compute( renderer ); + * + * // `sort.orderRead` now holds a storage buffer of `count` indices, ordered by bin. + * ``` + * + * @three_import import { CountingSort } from 'three/addons/gpgpu/CountingSort.js'; + */ +class CountingSort { + + /** + * Constructs a new counting sort. + * + * @param {number} count - The number of elements to sort. + * @param {Object} [options={}] - Options that modify the counting sort. + * @param {number} [options.binCount=4096] - The number of bins/buckets the sort key is quantized into. Larger values improve sort accuracy at the cost of a longer (but still single-pass) prefix sum. + * @param {number} [options.workgroupSize=256] - The workgroup size of the compute shaders executed during the sort. + */ + constructor( count, { binCount = 4096, workgroupSize = 256 } = {} ) { + + /** + * The number of elements to sort. + * + * @type {number} + */ + this.count = count; + + /** + * The number of bins/buckets the sort key is quantized into. + * + * @type {number} + */ + this.binCount = binCount; + + /** + * The workgroup size of the compute shaders executed during the sort. + * + * @type {number} + */ + this.workgroupSize = workgroupSize; + + const orderData = new Uint32Array( count ); + for ( let i = 0; i < count; i ++ ) orderData[ i ] = i; + + /** + * The buffer attribute holding the sorted order (a permutation of `[0, count)`). This is + * also the attribute that is kept up to date by {@link CountingSort#computeCPU}. + * + * @type {StorageBufferAttribute} + */ + this.orderAttribute = new StorageBufferAttribute( orderData, 1, Uint32Array ); + + const binAttribute = new StorageBufferAttribute( new Uint32Array( count ), 1, Uint32Array ); + const histogramAttribute = new StorageBufferAttribute( new Uint32Array( binCount ), 1, Uint32Array ); + const offsetAttribute = new StorageBufferAttribute( new Uint32Array( binCount ), 1, Uint32Array ); + + /** + * A read-only storage node for the sorted order buffer. + * + * @type {StorageBufferNode} + */ + this.orderRead = storage( this.orderAttribute, 'uint', count ).toReadOnly(); + + /** + * A writable storage node for the sorted order buffer. + * + * @type {StorageBufferNode} + */ + this.orderWrite = storage( this.orderAttribute, 'uint', count ); + + /** + * A read-only storage node holding each element's bin, computed during the histogram pass. + * + * @type {StorageBufferNode} + */ + this.binRead = storage( binAttribute, 'uint', count ).toReadOnly(); + + /** + * A writable storage node holding each element's bin. + * + * @type {StorageBufferNode} + */ + this.binWrite = storage( binAttribute, 'uint', count ); + + /** + * An atomic storage node used to accumulate the per-bin histogram. + * + * @type {StorageBufferNode} + */ + this.histogramAtomic = storage( histogramAttribute, 'uint', binCount ).toAtomic(); + + /** + * An atomic storage node used both for the exclusive prefix sum of the histogram and, during + * the scatter pass, as a per-bin write cursor. + * + * @type {StorageBufferNode} + */ + this.offsetAtomic = storage( offsetAttribute, 'uint', binCount ).toAtomic(); + + this._webGLBuffersEnabled = false; + + this._cpuBins = new Uint32Array( count ); + this._cpuCounts = new Uint32Array( binCount ); + this._cpuOffsets = new Uint32Array( binCount ); + + this._resetNode = null; + this._histogramNode = null; + this._prefixNode = null; + this._scatterNode = null; + + } + + /** + * Sets the TSL function used to compute the bin of the element currently referenced by + * `instanceIndex`, and (re)builds the compute nodes used by {@link CountingSort#compute}. + * + * @param {Function} binNode - A parameterless function returning a `Node` in `[0, binCount)`. + */ + setBinNode( binNode ) { + + const { binCount, workgroupSize, count } = this; + + this._resetNode = Fn( () => { + + atomicStore( this.histogramAtomic.element( instanceIndex ), uint( 0 ) ); + atomicStore( this.offsetAtomic.element( instanceIndex ), uint( 0 ) ); + + } )().compute( binCount, [ workgroupSize ] ).setName( 'CountingSortReset' ); + + this._histogramNode = Fn( () => { + + const bin = binNode().toVar( 'bin' ); + + this.binWrite.element( instanceIndex ).assign( bin ); + atomicAdd( this.histogramAtomic.element( bin ), uint( 1 ) ); + + } )().compute( count, [ workgroupSize ] ).setName( 'CountingSortHistogram' ); + + this._prefixNode = Fn( () => { + + const sum = uint( 0 ).toVar( 'sum' ); + + Loop( { start: 0, end: binCount, type: 'uint', name: 'bin', condition: '<' }, ( { bin } ) => { + + const binCountValue = atomicLoad( this.histogramAtomic.element( bin ) ).toVar( 'count' ); + atomicStore( this.offsetAtomic.element( bin ), sum ); + sum.addAssign( binCountValue ); + + } ); + + } )().compute( 1 ).setName( 'CountingSortPrefix' ); + + this._scatterNode = Fn( () => { + + const bin = this.binRead.element( instanceIndex ).toVar( 'bin' ); + const targetIndex = atomicAdd( this.offsetAtomic.element( bin ), uint( 1 ) ).toVar( 'targetIndex' ); + + this.orderWrite.element( targetIndex ).assign( instanceIndex ); + + } )().compute( count, [ workgroupSize ] ).setName( 'CountingSortScatter' ); + + } + + /** + * Executes a complete counting sort on the GPU, updating {@link CountingSort#orderRead}. + * + * @param {Renderer} renderer - The current scene's renderer. + */ + compute( renderer ) { + + renderer.compute( this._resetNode ); + renderer.compute( this._histogramNode ); + renderer.compute( this._prefixNode ); + renderer.compute( this._scatterNode ); + + } + + /** + * Executes a complete counting sort on the CPU, updating {@link CountingSort#orderAttribute}. + * Intended as a fallback for backends without compute shader support. + * + * @param {Function} binFn - A function taking an element index and returning its bin (a plain number in `[0, binCount)`). + */ + computeCPU( binFn ) { + + const { count, binCount } = this; + const order = this.orderAttribute.array; + const bins = this._cpuBins; + const counts = this._cpuCounts; + const offsets = this._cpuOffsets; + + counts.fill( 0 ); + + for ( let i = 0; i < count; i ++ ) { + + const bin = binFn( i ); + + bins[ i ] = bin; + counts[ bin ] ++; + + } + + let sum = 0; + + for ( let i = 0; i < binCount; i ++ ) { + + offsets[ i ] = sum; + sum += counts[ i ]; + + } + + for ( let i = 0; i < count; i ++ ) { + + order[ offsets[ bins[ i ] ] ++ ] = i; + + } + + this.orderAttribute.needsUpdate = true; + + if ( this.orderAttribute.pbo !== undefined ) { + + this.orderAttribute.pbo.needsUpdate = true; + + } + + } + + /** + * Enables the WebGL-specific storage buffer path (PBO + dynamic draw usage) for the order buffer. + * Only needed when {@link CountingSort#computeCPU} is used with the WebGL backend of {@link WebGPURenderer}. + */ + enableWebGLBuffers() { + + if ( this._webGLBuffersEnabled === true ) return; + + this.orderAttribute.setUsage( DynamicDrawUsage ); + this.orderRead.setPBO( true ); + + this._webGLBuffersEnabled = true; + + } + +} + +export { CountingSort }; diff --git a/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js b/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js new file mode 100644 index 00000000000000..c769580c7959b8 --- /dev/null +++ b/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js @@ -0,0 +1,255 @@ +import { + Group +} from 'three'; + +import { GaussianSplatMesh } from '../objects/GaussianSplatMesh.js'; +import { createGaussianSplatGeometry, writeColorBytesFromSH0, writeCovariance } from '../utils/GaussianSplatUtils.js'; + +const EXTENSION_NAME = 'KHR_gaussian_splatting'; +const POINTS = 0; +const ATTRIBUTES = { + POSITION: 'position' +}; + +/** + * A glTF loader plugin for `KHR_gaussian_splatting`. + * + * This plugin must be registered explicitly because {@link GaussianSplatMesh} + * requires {@link WebGPURenderer}. + * + * ```js + * const loader = new GLTFLoader(); + * loader.register( function ( parser ) { + * + * return new GLTFGaussianSplatLoaderExtension( parser ); + * + * } ); + * ``` + * + * @three_import import { GLTFGaussianSplatLoaderExtension } from 'three/addons/loaders/GLTFGaussianSplatLoaderExtension.js'; + */ +class GLTFGaussianSplatLoaderExtension { + + /** + * Constructs a new glTF gaussian splatting extension plugin. + * + * @param {GLTFParser} parser - The glTF parser. + */ + constructor( parser ) { + + this.name = EXTENSION_NAME; + this.parser = parser; + + } + + /** + * Loads a glTF mesh containing gaussian splat primitives. + * + * @param {number} meshIndex - The mesh index. + * @return {?Promise} The loaded mesh or `null` when the mesh does not use this extension. + */ + loadMesh( meshIndex ) { + + const parser = this.parser; + const meshDef = parser.json.meshes[ meshIndex ]; + const primitives = meshDef.primitives; + + if ( primitives.some( isGaussianSplatPrimitive ) === false ) return null; + + if ( primitives.every( isGaussianSplatPrimitive ) === false ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: Mixed gaussian and non-gaussian mesh primitives are not supported.' ); + + } + + return parser.loadGeometries( primitives ).then( function ( geometries ) { + + const meshes = []; + + for ( let i = 0, il = geometries.length; i < il; i ++ ) { + + const geometry = geometries[ i ]; + const primitive = primitives[ i ]; + + if ( primitive.mode !== POINTS ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: Gaussian splat primitives must use POINTS mode.' ); + + } + + const mesh = createGaussianSplatMesh( geometry, primitive ); + mesh.name = parser.createUniqueName( meshDef.name || ( 'mesh_' + meshIndex ) ); + + assignExtrasToUserData( mesh, meshDef ); + parser.associations.set( mesh, { + meshes: meshIndex, + primitives: i + } ); + + meshes.push( mesh ); + + } + + if ( meshes.length === 1 ) return meshes[ 0 ]; + + const group = new Group(); + assignExtrasToUserData( group, meshDef ); + parser.associations.set( group, { meshes: meshIndex } ); + + for ( let i = 0, il = meshes.length; i < il; i ++ ) { + + group.add( meshes[ i ] ); + + } + + return group; + + } ); + + } + +} + +function isGaussianSplatPrimitive( primitiveDef ) { + + return primitiveDef.extensions !== undefined && + primitiveDef.extensions[ EXTENSION_NAME ] !== undefined; + +} + +function createGaussianSplatMesh( geometry, primitiveDef ) { + + const extensionDef = primitiveDef.extensions[ EXTENSION_NAME ]; + + if ( extensionDef.kernel !== 'ellipse' ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: Unsupported KHR_gaussian_splatting kernel.' ); + + } + + if ( extensionDef.colorSpace === undefined ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting colorSpace is required.' ); + + } + + if ( extensionDef.projection !== undefined && extensionDef.projection !== 'perspective' ) { + + console.warn( 'THREE.GLTFGaussianSplatLoaderExtension: Unsupported KHR_gaussian_splatting projection. Results may be incorrect.' ); + + } + + if ( extensionDef.sortingMethod !== undefined && extensionDef.sortingMethod !== 'cameraDistance' ) { + + console.warn( 'THREE.GLTFGaussianSplatLoaderExtension: Unsupported KHR_gaussian_splatting sortingMethod. Results may be incorrect.' ); + + } + + const position = getGaussianSplatAttribute( geometry, primitiveDef, 'POSITION' ); + const scale = getGaussianSplatAttribute( geometry, primitiveDef, 'KHR_gaussian_splatting:SCALE' ); + const rotation = getGaussianSplatAttribute( geometry, primitiveDef, 'KHR_gaussian_splatting:ROTATION' ); + const opacity = getGaussianSplatAttribute( geometry, primitiveDef, 'KHR_gaussian_splatting:OPACITY' ); + const sh0 = getGaussianSplatAttribute( geometry, primitiveDef, 'KHR_gaussian_splatting:SH_DEGREE_0_COEF_0' ); + const count = position.count; + + if ( scale.count !== count || rotation.count !== count || opacity.count !== count || sh0.count !== count ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting attribute counts must match POSITION.' ); + + } + + for ( const semantic in primitiveDef.attributes ) { + + if ( /^KHR_gaussian_splatting:SH_DEGREE_[1-3]_COEF_/.test( semantic ) ) { + + console.warn( 'THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting spherical harmonics above degree 0 are ignored.' ); + break; + + } + + } + + const centers = new Float32Array( count * 3 ); + const covariances = new Float32Array( count * 6 ); + const colors = new Uint8Array( count * 4 ); + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + + centers[ i3 ] = position.getX( i ); + centers[ i3 + 1 ] = position.getY( i ); + centers[ i3 + 2 ] = position.getZ( i ); + + writeCovariance( + covariances, + i * 6, + scale.getX( i ), + scale.getY( i ), + scale.getZ( i ), + rotation.getX( i ), + rotation.getY( i ), + rotation.getZ( i ), + rotation.getW( i ) + ); + + writeColorBytesFromSH0( + colors, + i * 4, + sh0.getX( i ), + sh0.getY( i ), + sh0.getZ( i ), + opacity.getX( i ) + ); + + } + + const mesh = new GaussianSplatMesh( createGaussianSplatGeometry( centers, covariances, colors ) ); + + mesh.userData.gltfExtensions = mesh.userData.gltfExtensions || {}; + mesh.userData.gltfExtensions[ EXTENSION_NAME ] = Object.assign( {}, extensionDef ); + + return mesh; + +} + +function getGaussianSplatAttribute( geometry, primitiveDef, semantic ) { + + if ( primitiveDef.attributes[ semantic ] === undefined ) { + + throw new Error( `THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting requires ${ semantic }.` ); + + } + + const attributeName = ATTRIBUTES[ semantic ] || semantic.toLowerCase(); + const attribute = geometry.getAttribute( attributeName ); + + if ( attribute === undefined ) { + + throw new Error( `THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting attribute ${ semantic } was not loaded.` ); + + } + + return attribute; + +} + +function assignExtrasToUserData( object, gltfDef ) { + + if ( gltfDef.extras !== undefined ) { + + if ( typeof gltfDef.extras === 'object' ) { + + Object.assign( object.userData, gltfDef.extras ); + + } else { + + console.warn( 'THREE.GLTFGaussianSplatLoaderExtension: Ignoring primitive type .extras, ' + gltfDef.extras ); + + } + + } + +} + +export { GLTFGaussianSplatLoaderExtension }; diff --git a/examples/jsm/loaders/GLTFLoader.js b/examples/jsm/loaders/GLTFLoader.js index 467fcc5ce246f2..cd95b20593264b 100644 --- a/examples/jsm/loaders/GLTFLoader.js +++ b/examples/jsm/loaders/GLTFLoader.js @@ -104,7 +104,8 @@ import { clone } from '../utils/SkeletonUtils.js'; * - EXT_texture_avif * - EXT_texture_webp * - * The following glTF 2.0 extension is supported by an external user plugin: + * The following glTF 2.0 extensions are supported by separately registered plugins: + * - KHR_gaussian_splatting * - [KHR_materials_variants](https://github.com/takahirox/three-gltf-extensions) * - [MSFT_texture_dds](https://github.com/takahirox/three-gltf-extensions) * - [KHR_animation_pointer](https://github.com/needle-tools/three-animation-pointer) @@ -3884,7 +3885,7 @@ class GLTFParser { pending.push( parser.loadGeometries( primitives ) ); - return Promise.all( pending ).then( function ( results ) { + return Promise.all( pending ).then( async function ( results ) { const materials = results.slice( 0, results.length - 1 ); const geometries = results[ results.length - 1 ]; diff --git a/examples/jsm/loaders/KSPLATLoader.js b/examples/jsm/loaders/KSPLATLoader.js new file mode 100644 index 00000000000000..6cf7e5e7800834 --- /dev/null +++ b/examples/jsm/loaders/KSPLATLoader.js @@ -0,0 +1,376 @@ +import { + DataUtils, + FileLoader, + Loader +} from 'three'; + +import { createGaussianSplatGeometry, writeColorBytes, writeCovariance } from '../utils/GaussianSplatUtils.js'; + +const HEADER_SIZE_BYTES = 4096; +const SECTION_HEADER_SIZE_BYTES = 1024; +const CURRENT_VERSION_MAJOR = 0; +const CURRENT_VERSION_MINOR = 1; +const MAX_SPLATS = 10000000; +const SH_DEGREE_TO_COMPONENTS = [ 0, 9, 24, 45 ]; +const COMPRESSION_LEVELS = { + 0: { + bytesPerCenter: 12, + bytesPerScale: 12, + bytesPerRotation: 16, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 4, + scaleOffsetBytes: 12, + rotationOffsetBytes: 24, + colorOffsetBytes: 40, + scaleRange: 1 + }, + 1: { + bytesPerCenter: 6, + bytesPerScale: 6, + bytesPerRotation: 8, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 2, + scaleOffsetBytes: 6, + rotationOffsetBytes: 12, + colorOffsetBytes: 20, + scaleRange: 32767 + }, + 2: { + bytesPerCenter: 6, + bytesPerScale: 6, + bytesPerRotation: 8, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 1, + scaleOffsetBytes: 6, + rotationOffsetBytes: 12, + colorOffsetBytes: 20, + scaleRange: 32767 + } +}; + +/** + * A loader for GaussianSplats3D `.ksplat` files. + * + * This loader decodes the format into `BufferGeometry` for use with + * `GaussianSplatMesh`. Spherical harmonics payloads are skipped because the + * current renderer uses the stored degree-0 color. + * + * ```js + * const loader = new KSPLATLoader(); + * const data = await loader.loadAsync( './models/gsplat/example.ksplat' ); + * scene.add( new GaussianSplatMesh( data ) ); + * ``` + * + * @augments Loader + * @three_import import { KSPLATLoader } from 'three/addons/loaders/KSPLATLoader.js'; + */ +class KSPLATLoader extends Loader { + + /** + * Constructs a new Gaussian splat KSPLAT loader. + * + * @param {LoadingManager} [manager] - The loading manager. + */ + constructor( manager ) { + + super( manager ); + + } + + /** + * Starts loading from the given URL and passes the loaded splat data to + * the `onLoad()` callback. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} onProgress - Executed while the loading is in progress. + * @param {onErrorCallback} onError - Executed when errors occur. + */ + load( url, onLoad, onProgress, onError ) { + + const scope = this; + + const loader = new FileLoader( this.manager ); + loader.setPath( this.path ); + loader.setResponseType( 'arraybuffer' ); + loader.setRequestHeader( this.requestHeader ); + loader.setWithCredentials( this.withCredentials ); + loader.load( url, function ( buffer ) { + + try { + + onLoad( scope.parse( buffer ) ); + + } catch ( e ) { + + if ( onError ) { + + onError( e ); + + } else { + + console.error( e ); + + } + + scope.manager.itemError( url ); + + } + + }, onProgress, onError ); + + } + + /** + * Parses the given `.ksplat` data. + * + * @param {ArrayBuffer} buffer - The raw KSPLAT file as an array buffer. + * @return {BufferGeometry} The parsed splat geometry. + */ + parse( buffer ) { + + if ( buffer.byteLength < HEADER_SIZE_BYTES ) { + + throw new Error( 'THREE.KSPLATLoader: Invalid KSPLAT header.' ); + + } + + const bytes = new Uint8Array( buffer ); + const view = new DataView( buffer ); + const header = parseHeader( view ); + + if ( header.versionMajor !== CURRENT_VERSION_MAJOR || header.versionMinor < CURRENT_VERSION_MINOR ) { + + throw new Error( `THREE.KSPLATLoader: Unsupported KSPLAT version ${ header.versionMajor }.${ header.versionMinor }.` ); + + } + + if ( header.compressionLevel < 0 || header.compressionLevel > 2 ) { + + throw new Error( `THREE.KSPLATLoader: Unsupported KSPLAT compression level ${ header.compressionLevel }.` ); + + } + + if ( header.splatCount > MAX_SPLATS ) { + + throw new Error( `THREE.KSPLATLoader: KSPLAT file contains too many splats (${ header.splatCount }).` ); + + } + + const sectionHeadersOffset = HEADER_SIZE_BYTES; + const sectionDataOffset = HEADER_SIZE_BYTES + header.maxSectionCount * SECTION_HEADER_SIZE_BYTES; + + if ( bytes.byteLength < sectionDataOffset ) { + + throw new Error( 'THREE.KSPLATLoader: Invalid KSPLAT section headers.' ); + + } + + const compression = COMPRESSION_LEVELS[ header.compressionLevel ]; + const centers = new Float32Array( header.splatCount * 3 ); + const covariances = new Float32Array( header.splatCount * 6 ); + const colors = new Uint8Array( header.splatCount * 4 ); + let splatOffset = 0; + let sectionBase = sectionDataOffset; + + for ( let sectionIndex = 0; sectionIndex < header.maxSectionCount; sectionIndex ++ ) { + + const sectionHeaderOffset = sectionHeadersOffset + sectionIndex * SECTION_HEADER_SIZE_BYTES; + const section = parseSectionHeader( view, sectionHeaderOffset, compression ); + const shComponents = SH_DEGREE_TO_COMPONENTS[ section.sphericalHarmonicsDegree ]; + + if ( shComponents === undefined ) { + + throw new Error( `THREE.KSPLATLoader: Unsupported KSPLAT spherical harmonics degree ${ section.sphericalHarmonicsDegree }.` ); + + } + + const bytesPerSplat = compression.bytesPerCenter + compression.bytesPerScale + compression.bytesPerRotation + compression.bytesPerColor + + shComponents * compression.bytesPerSphericalHarmonicsComponent; + const bucketsMetaDataSizeBytes = section.partiallyFilledBucketCount * 4; + const bucketsStorageSizeBytes = section.bucketStorageSizeBytes * section.bucketCount + bucketsMetaDataSizeBytes; + const splatDataStorageSizeBytes = bytesPerSplat * section.maxSplatCount; + const storageSizeBytes = bucketsStorageSizeBytes + splatDataStorageSizeBytes; + + if ( sectionBase + storageSizeBytes > bytes.byteLength ) { + + throw new Error( 'THREE.KSPLATLoader: Invalid KSPLAT byte length.' ); + + } + + if ( section.splatCount > 0 ) { + + readSection( + view, + bytes, + section, + compression, + sectionBase, + bucketsMetaDataSizeBytes, + bucketsStorageSizeBytes, + bytesPerSplat, + splatOffset, + centers, + covariances, + colors + ); + + splatOffset += section.splatCount; + + } + + sectionBase += storageSizeBytes; + + } + + if ( splatOffset !== header.splatCount ) { + + throw new Error( 'THREE.KSPLATLoader: KSPLAT splat count mismatch.' ); + + } + + return createGaussianSplatGeometry( centers, covariances, colors ); + + } + +} + +function parseHeader( view ) { + + return { + versionMajor: view.getUint8( 0 ), + versionMinor: view.getUint8( 1 ), + maxSectionCount: view.getUint32( 4, true ), + sectionCount: view.getUint32( 8, true ), + maxSplatCount: view.getUint32( 12, true ), + splatCount: view.getUint32( 16, true ), + compressionLevel: view.getUint16( 20, true ) + }; + +} + +function parseSectionHeader( view, offset, compression ) { + + return { + splatCount: view.getUint32( offset, true ), + maxSplatCount: view.getUint32( offset + 4, true ), + bucketSize: view.getUint32( offset + 8, true ), + bucketCount: view.getUint32( offset + 12, true ), + bucketBlockSize: view.getFloat32( offset + 16, true ), + bucketStorageSizeBytes: view.getUint16( offset + 20, true ), + compressionScaleRange: view.getUint32( offset + 24, true ) || compression.scaleRange, + fullBucketCount: view.getUint32( offset + 32, true ), + partiallyFilledBucketCount: view.getUint32( offset + 36, true ), + sphericalHarmonicsDegree: view.getUint16( offset + 40, true ) + }; + +} + +function readSection( view, bytes, section, compression, sectionBase, bucketsMetaDataSizeBytes, bucketsStorageSizeBytes, bytesPerSplat, splatOffset, centers, covariances, colors ) { + + const bucketsBase = sectionBase + bucketsMetaDataSizeBytes; + const dataBase = sectionBase + bucketsStorageSizeBytes; + const fullBucketSplats = section.fullBucketCount * section.bucketSize; + const compressionScaleFactor = section.bucketBlockSize / 2 / section.compressionScaleRange; + let partialBucketIndex = section.fullBucketCount; + let partialBucketBase = fullBucketSplats; + + for ( let i = 0; i < section.splatCount; i ++ ) { + + const bucketIndex = getBucketIndex( view, section, sectionBase, i, fullBucketSplats, partialBucketIndex, partialBucketBase ); + + if ( bucketIndex.partialBucketIndex !== undefined ) { + + partialBucketIndex = bucketIndex.partialBucketIndex; + partialBucketBase = bucketIndex.partialBucketBase; + + } + + const rowOffset = dataBase + i * bytesPerSplat; + const outIndex = splatOffset + i; + const i3 = outIndex * 3; + + if ( compression.bytesPerCenter === 12 ) { + + centers[ i3 ] = view.getFloat32( rowOffset, true ); + centers[ i3 + 1 ] = view.getFloat32( rowOffset + 4, true ); + centers[ i3 + 2 ] = view.getFloat32( rowOffset + 8, true ); + + } else { + + const bucketBase = bucketsBase + bucketIndex.value * section.bucketStorageSizeBytes; + centers[ i3 ] = ( view.getUint16( rowOffset, true ) - section.compressionScaleRange ) * compressionScaleFactor + view.getFloat32( bucketBase, true ); + centers[ i3 + 1 ] = ( view.getUint16( rowOffset + 2, true ) - section.compressionScaleRange ) * compressionScaleFactor + view.getFloat32( bucketBase + 4, true ); + centers[ i3 + 2 ] = ( view.getUint16( rowOffset + 4, true ) - section.compressionScaleRange ) * compressionScaleFactor + view.getFloat32( bucketBase + 8, true ); + + } + + const sx = readCompressedFloat( view, rowOffset + compression.scaleOffsetBytes, compression.bytesPerScale ); + const sy = readCompressedFloat( view, rowOffset + compression.scaleOffsetBytes + compression.bytesPerScale / 3, compression.bytesPerScale ); + const sz = readCompressedFloat( view, rowOffset + compression.scaleOffsetBytes + compression.bytesPerScale / 3 * 2, compression.bytesPerScale ); + const qw = readCompressedFloat( view, rowOffset + compression.rotationOffsetBytes, compression.bytesPerRotation ); + const qx = readCompressedFloat( view, rowOffset + compression.rotationOffsetBytes + compression.bytesPerRotation / 4, compression.bytesPerRotation ); + const qy = readCompressedFloat( view, rowOffset + compression.rotationOffsetBytes + compression.bytesPerRotation / 4 * 2, compression.bytesPerRotation ); + const qz = readCompressedFloat( view, rowOffset + compression.rotationOffsetBytes + compression.bytesPerRotation / 4 * 3, compression.bytesPerRotation ); + + writeCovariance( covariances, outIndex * 6, sx, sy, sz, qx, qy, qz, qw ); + writeColorBytes( + colors, + outIndex * 4, + bytes[ rowOffset + compression.colorOffsetBytes ], + bytes[ rowOffset + compression.colorOffsetBytes + 1 ], + bytes[ rowOffset + compression.colorOffsetBytes + 2 ], + bytes[ rowOffset + compression.colorOffsetBytes + 3 ] + ); + + } + +} + +function getBucketIndex( view, section, sectionBase, splatIndex, fullBucketSplats, partialBucketIndex, partialBucketBase ) { + + if ( section.bucketCount === 0 ) { + + return { value: 0 }; + + } + + if ( splatIndex < fullBucketSplats ) { + + return { value: Math.floor( splatIndex / section.bucketSize ) }; + + } + + while ( partialBucketIndex < section.bucketCount ) { + + const partialIndex = partialBucketIndex - section.fullBucketCount; + const bucketLength = view.getUint32( sectionBase + partialIndex * 4, true ); + + if ( splatIndex < partialBucketBase + bucketLength ) { + + return { value: partialBucketIndex, partialBucketIndex, partialBucketBase }; + + } + + partialBucketIndex ++; + partialBucketBase += bucketLength; + + } + + throw new Error( 'THREE.KSPLATLoader: Invalid KSPLAT bucket data.' ); + +} + +function readCompressedFloat( view, offset, bytesPerVector ) { + + if ( bytesPerVector === 12 || bytesPerVector === 16 ) { + + return view.getFloat32( offset, true ); + + } + + return DataUtils.fromHalfFloat( view.getUint16( offset, true ) ); + +} + +export { KSPLATLoader }; diff --git a/examples/jsm/loaders/SPLATLoader.js b/examples/jsm/loaders/SPLATLoader.js new file mode 100644 index 00000000000000..a0087954d44361 --- /dev/null +++ b/examples/jsm/loaders/SPLATLoader.js @@ -0,0 +1,139 @@ +import { + FileLoader, + Loader +} from 'three'; + +import { createGaussianSplatGeometry, writeCovariance } from '../utils/GaussianSplatUtils.js'; + +const ROW_SIZE_BYTES = 32; + +/** + * A loader for standard fixed-width Gaussian splat `.splat` files. + * + * This loader decodes the format into `BufferGeometry` for use with + * `GaussianSplatMesh`. Each 32-byte row stores center, scale, color and + * rotation data for one splat. + * + * ```js + * const loader = new SPLATLoader(); + * const data = await loader.loadAsync( './models/gsplat/example.splat' ); + * scene.add( new GaussianSplatMesh( data ) ); + * ``` + * + * @augments Loader + * @three_import import { SPLATLoader } from 'three/addons/loaders/SPLATLoader.js'; + */ +class SPLATLoader extends Loader { + + /** + * Constructs a new Gaussian splat loader. + * + * @param {LoadingManager} [manager] - The loading manager. + */ + constructor( manager ) { + + super( manager ); + + } + + /** + * Starts loading from the given URL and passes the loaded splat data to + * the `onLoad()` callback. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} onProgress - Executed while the loading is in progress. + * @param {onErrorCallback} onError - Executed when errors occur. + */ + load( url, onLoad, onProgress, onError ) { + + const scope = this; + + const loader = new FileLoader( this.manager ); + loader.setPath( this.path ); + loader.setResponseType( 'arraybuffer' ); + loader.setRequestHeader( this.requestHeader ); + loader.setWithCredentials( this.withCredentials ); + loader.load( url, function ( buffer ) { + + try { + + onLoad( scope.parse( buffer ) ); + + } catch ( e ) { + + if ( onError ) { + + onError( e ); + + } else { + + console.error( e ); + + } + + scope.manager.itemError( url ); + + } + + }, onProgress, onError ); + + } + + /** + * Parses the given fixed-width `.splat` data. + * + * @param {ArrayBuffer} buffer - The raw `.splat` file as an array buffer. + * @return {BufferGeometry} The parsed splat geometry. + */ + parse( buffer ) { + + if ( buffer.byteLength % ROW_SIZE_BYTES !== 0 ) { + + throw new Error( 'THREE.SPLATLoader: Invalid .splat byte length.' ); + + } + + const count = buffer.byteLength / ROW_SIZE_BYTES; + const centers = new Float32Array( count * 3 ); + const covariances = new Float32Array( count * 6 ); + const colors = new Uint8Array( count * 4 ); + const view = new DataView( buffer ); + const bytes = new Uint8Array( buffer ); + + for ( let i = 0; i < count; i ++ ) { + + const rowOffset = i * ROW_SIZE_BYTES; + const i3 = i * 3; + const i4 = i * 4; + + centers[ i3 ] = view.getFloat32( rowOffset, true ); + centers[ i3 + 1 ] = view.getFloat32( rowOffset + 4, true ); + centers[ i3 + 2 ] = view.getFloat32( rowOffset + 8, true ); + + const sx = view.getFloat32( rowOffset + 12, true ); + const sy = view.getFloat32( rowOffset + 16, true ); + const sz = view.getFloat32( rowOffset + 20, true ); + + colors[ i4 ] = bytes[ rowOffset + 24 ]; + colors[ i4 + 1 ] = bytes[ rowOffset + 25 ]; + colors[ i4 + 2 ] = bytes[ rowOffset + 26 ]; + colors[ i4 + 3 ] = bytes[ rowOffset + 27 ]; + + // Standard .splat stores quaternion bytes as w, x, y, z. + const qw = ( bytes[ rowOffset + 28 ] - 128 ) / 128; + const qx = ( bytes[ rowOffset + 29 ] - 128 ) / 128; + const qy = ( bytes[ rowOffset + 30 ] - 128 ) / 128; + const qz = ( bytes[ rowOffset + 31 ] - 128 ) / 128; + + writeCovariance( covariances, i * 6, sx, sy, sz, qx, qy, qz, qw ); + + } + + return createGaussianSplatGeometry( centers, covariances, colors ); + + } + +} + +export { SPLATLoader }; diff --git a/examples/jsm/loaders/SPZLoader.js b/examples/jsm/loaders/SPZLoader.js new file mode 100644 index 00000000000000..dcb6a8974c13ac --- /dev/null +++ b/examples/jsm/loaders/SPZLoader.js @@ -0,0 +1,304 @@ +import { + DataUtils, + FileLoader, + Loader +} from 'three'; + +import { gunzipSync } from '../libs/fflate.module.js'; +import { SH_C0, createGaussianSplatGeometry, writeColorBytes, writeCovariance } from '../utils/GaussianSplatUtils.js'; + +const SPZ_MAGIC = 0x5053474e; +const HEADER_SIZE_BYTES = 16; +const MAX_SPLATS = 10000000; +const SPZ_COLOR_SCALE = SH_C0 / 0.15; +const FLAG_LOD = 0x80; +const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15 ]; + +/** + * A loader for compressed Gaussian splat `.spz` files. + * + * This loader decodes the format into `BufferGeometry` for use with + * `GaussianSplatMesh`. The current renderer supports degree-0 color only, so + * higher-order spherical harmonics are parsed only enough to skip their payload. + * + * ```js + * const loader = new SPZLoader(); + * const data = await loader.loadAsync( './models/gsplat/example.spz' ); + * scene.add( new GaussianSplatMesh( data ) ); + * ``` + * + * @augments Loader + * @three_import import { SPZLoader } from 'three/addons/loaders/SPZLoader.js'; + */ +class SPZLoader extends Loader { + + /** + * Constructs a new Gaussian splat SPZ loader. + * + * @param {LoadingManager} [manager] - The loading manager. + */ + constructor( manager ) { + + super( manager ); + + } + + /** + * Starts loading from the given URL and passes the loaded splat data to + * the `onLoad()` callback. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} onProgress - Executed while the loading is in progress. + * @param {onErrorCallback} onError - Executed when errors occur. + */ + load( url, onLoad, onProgress, onError ) { + + const scope = this; + + const loader = new FileLoader( this.manager ); + loader.setPath( this.path ); + loader.setResponseType( 'arraybuffer' ); + loader.setRequestHeader( this.requestHeader ); + loader.setWithCredentials( this.withCredentials ); + loader.load( url, function ( buffer ) { + + try { + + onLoad( scope.parse( buffer ) ); + + } catch ( e ) { + + if ( onError ) { + + onError( e ); + + } else { + + console.error( e ); + + } + + scope.manager.itemError( url ); + + } + + }, onProgress, onError ); + + } + + /** + * Decompresses and parses the given `.spz` data. + * + * @param {ArrayBuffer} buffer - The raw gzip-compressed SPZ file as an array buffer. + * @return {BufferGeometry} The parsed splat geometry. + */ + parse( buffer ) { + + const decompressed = gunzipSync( new Uint8Array( buffer ) ); + + return this.parseRawSPZ( decompressed ); + + } + + /** + * Parses raw SPZ data after gzip decompression. + * + * @param {Uint8Array} bytes - The decompressed SPZ data. + * @return {BufferGeometry} The parsed splat geometry. + */ + parseRawSPZ( bytes ) { + + if ( bytes.byteLength < HEADER_SIZE_BYTES ) { + + throw new Error( 'THREE.SPZLoader: Invalid SPZ header.' ); + + } + + const view = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength ); + const magic = view.getUint32( 0, true ); + const version = view.getUint32( 4, true ); + const count = view.getUint32( 8, true ); + const shDegree = view.getUint8( 12 ); + const fractionalBits = view.getUint8( 13 ); + const flags = view.getUint8( 14 ); + + if ( magic !== SPZ_MAGIC ) { + + throw new Error( 'THREE.SPZLoader: Invalid SPZ magic.' ); + + } + + if ( version < 1 || version > 3 ) { + + throw new Error( `THREE.SPZLoader: Unsupported SPZ version ${ version }.` ); + + } + + if ( count > MAX_SPLATS ) { + + throw new Error( `THREE.SPZLoader: SPZ file contains too many splats (${ count }).` ); + + } + + if ( shDegree > 3 ) { + + throw new Error( `THREE.SPZLoader: Unsupported SPZ spherical harmonics degree ${ shDegree }.` ); + + } + + let offset = HEADER_SIZE_BYTES; + const centers = new Float32Array( count * 3 ); + const covariances = new Float32Array( count * 6 ); + const colors = new Uint8Array( count * 4 ); + const positionsSize = count * 3 * ( version === 1 ? 2 : 3 ); + const rotationsSize = count * ( version === 3 ? 4 : 3 ); + const shSize = count * SH_DEGREE_TO_VECTORS[ shDegree ] * 3; + const lodSize = ( flags & FLAG_LOD ) !== 0 ? count * 6 : 0; + const expectedSize = HEADER_SIZE_BYTES + positionsSize + count + count * 3 + count * 3 + rotationsSize + shSize + lodSize; + + if ( bytes.byteLength !== expectedSize ) { + + throw new Error( 'THREE.SPZLoader: Invalid SPZ byte length.' ); + + } + + offset = readCenters( view, centers, offset, count, version, fractionalBits ); + + const alphaOffset = offset; + offset += count; + + const colorOffset = offset; + offset += count * 3; + + const scaleOffset = offset; + offset += count * 3; + + const rotationOffset = offset; + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + const sx = Math.exp( bytes[ scaleOffset + i3 ] / 16 - 10 ); + const sy = Math.exp( bytes[ scaleOffset + i3 + 1 ] / 16 - 10 ); + const sz = Math.exp( bytes[ scaleOffset + i3 + 2 ] / 16 - 10 ); + const rotation = version === 3 ? + readSmallestThreeQuaternion( view, rotationOffset + i * 4 ) : + readXYZQuaternion( bytes, rotationOffset + i * 3 ); + + writeCovariance( covariances, i * 6, sx, sy, sz, rotation[ 0 ], rotation[ 1 ], rotation[ 2 ], rotation[ 3 ] ); + writeColorBytes( + colors, + i * 4, + ( ( bytes[ colorOffset + i3 ] / 255 - 0.5 ) * SPZ_COLOR_SCALE + 0.5 ) * 255, + ( ( bytes[ colorOffset + i3 + 1 ] / 255 - 0.5 ) * SPZ_COLOR_SCALE + 0.5 ) * 255, + ( ( bytes[ colorOffset + i3 + 2 ] / 255 - 0.5 ) * SPZ_COLOR_SCALE + 0.5 ) * 255, + bytes[ alphaOffset + i ] + ); + + } + + return createGaussianSplatGeometry( centers, covariances, colors ); + + } + +} + +function readCenters( view, centers, offset, count, version, fractionalBits ) { + + if ( version === 1 ) { + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + const rowOffset = offset + i3 * 2; + + centers[ i3 ] = DataUtils.fromHalfFloat( view.getUint16( rowOffset, true ) ); + centers[ i3 + 1 ] = DataUtils.fromHalfFloat( view.getUint16( rowOffset + 2, true ) ); + centers[ i3 + 2 ] = DataUtils.fromHalfFloat( view.getUint16( rowOffset + 4, true ) ); + + } + + return offset + count * 3 * 2; + + } + + const fixedScale = 1 / ( 1 << fractionalBits ); + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + const rowOffset = offset + i * 9; + + centers[ i3 ] = readInt24( view, rowOffset ) * fixedScale; + centers[ i3 + 1 ] = readInt24( view, rowOffset + 3 ) * fixedScale; + centers[ i3 + 2 ] = readInt24( view, rowOffset + 6 ) * fixedScale; + + } + + return offset + count * 3 * 3; + +} + +function readInt24( view, offset ) { + + let value = view.getUint8( offset ) | ( view.getUint8( offset + 1 ) << 8 ) | ( view.getUint8( offset + 2 ) << 16 ); + + if ( ( value & 0x800000 ) !== 0 ) { + + value |= 0xff000000; + + } + + return value; + +} + +function readXYZQuaternion( bytes, offset ) { + + const qx = bytes[ offset ] / 127.5 - 1; + const qy = bytes[ offset + 1 ] / 127.5 - 1; + const qz = bytes[ offset + 2 ] / 127.5 - 1; + const qw = Math.sqrt( Math.max( 0, 1 - qx * qx - qy * qy - qz * qz ) ); + + return [ qx, qy, qz, qw ]; + +} + +function readSmallestThreeQuaternion( view, offset ) { + + const maxValue = Math.SQRT1_2; + const valueMask = ( 1 << 9 ) - 1; + const quaternion = [ 0, 0, 0, 0 ]; + const packed = view.getUint32( offset, true ); + const largestIndex = packed >>> 30; + let remainingValues = packed; + let sumSquares = 0; + + for ( let i = 3; i >= 0; i -- ) { + + if ( i === largestIndex ) continue; + + const value = remainingValues & valueMask; + const sign = ( remainingValues >>> 9 ) & 1; + remainingValues >>>= 10; + + quaternion[ i ] = maxValue * ( value / valueMask ); + + if ( sign !== 0 ) { + + quaternion[ i ] = - quaternion[ i ]; + + } + + sumSquares += quaternion[ i ] * quaternion[ i ]; + + } + + quaternion[ largestIndex ] = Math.sqrt( Math.max( 0, 1 - sumSquares ) ); + + return quaternion; + +} + +export { SPZLoader }; diff --git a/examples/jsm/objects/GaussianSplatMesh.js b/examples/jsm/objects/GaussianSplatMesh.js new file mode 100644 index 00000000000000..78ee14fe189268 --- /dev/null +++ b/examples/jsm/objects/GaussianSplatMesh.js @@ -0,0 +1,474 @@ +import { + BufferAttribute, + InstancedBufferGeometry, + Matrix4, + Mesh, + NodeMaterial, + StorageBufferAttribute, + Vector2, + Vector3 +} from 'three/webgpu'; + +import { + Discard, + Fn, + If, + atan, + cameraProjectionMatrix, + cos, + dot, + exp, + float, + highpModelViewMatrix, + instanceIndex, + max, + min, + positionGeometry, + screenSize, + sin, + sqrt, + storage, + uint, + uniform, + varyingProperty, + vec2, + vec3, + vec4 +} from 'three/tsl'; + +import { CountingSort } from '../gpgpu/CountingSort.js'; + +const BIN_COUNT = 4096; +const WORKGROUP_SIZE = 256; +const SORT_DIRECTION_THRESHOLD = 0.9995; +const SORT_POSITION_THRESHOLD = 0.0025; +const KERNEL_2D_SIZE = 0.3; +const MAX_SCREEN_SPACE_SPLAT_SIZE = 1024; +const CLIP_XY = 1.4; + +const _worldCenter = /*@__PURE__*/ new Vector3(); +const _viewCenter = /*@__PURE__*/ new Vector3(); +const _worldScale = /*@__PURE__*/ new Vector3(); +const _cameraPosition = /*@__PURE__*/ new Vector3(); +const _cameraDirection = /*@__PURE__*/ new Vector3(); +const _sortDepthRange = /*@__PURE__*/ new Vector2(); + +/** + * A minimal renderer for 3D Gaussian splat geometry. + * + * Note that this class can only be used with {@link WebGPURenderer}. The + * `forceWebGL` fallback of {@link WebGPURenderer} is supported, but + * {@link WebGLRenderer} is not. Import maps or package exports must resolve + * both `three/webgpu` and `three/tsl`. + * + * ```js + * const splats = new GaussianSplatMesh( geometry ); + * scene.add( splats ); + * ``` + * + * @augments Mesh + * @three_import import { GaussianSplatMesh } from 'three/addons/objects/GaussianSplatMesh.js'; + */ +class GaussianSplatMesh extends Mesh { + + /** + * Constructs a new Gaussian splat mesh. + * + * @param {BufferGeometry} splatGeometry - The splat geometry to render. + * @param {Object} [options] - Options. + * @param {boolean} [options.autoSort=true] - Whether to sort automatically in `onBeforeRender`. + */ + constructor( splatGeometry, { autoSort = true } = {} ) { + + const positionAttribute = splatGeometry.getAttribute( 'position' ); + const covarianceAttribute = splatGeometry.getAttribute( 'covariance' ); + const colorAttribute = splatGeometry.getAttribute( 'color' ); + const count = positionAttribute.count; + + if ( splatGeometry.boundingBox === null ) splatGeometry.computeBoundingBox(); + if ( splatGeometry.boundingSphere === null ) splatGeometry.computeBoundingSphere(); + + const geometry = createGeometry( count ); + const buffers = createStorageBuffers( count, positionAttribute.array, covarianceAttribute.array, colorAttribute.array ); + const sort = new CountingSort( count, { binCount: BIN_COUNT, workgroupSize: WORKGROUP_SIZE } ); + const material = createMaterial( buffers, sort ); + + super( geometry, material ); + + /** + * This flag can be used for type testing. + * + * @type {boolean} + * @readonly + * @default true + */ + this.isGaussianSplatMesh = true; + + this.type = 'GaussianSplatMesh'; + + /** + * The source splat geometry. + * + * @type {BufferGeometry} + */ + this.splatGeometry = splatGeometry; + + /** + * Whether to sort automatically in `onBeforeRender`. + * + * @type {boolean} + */ + this.autoSort = autoSort; + + this.frustumCulled = false; + + this._buffers = buffers; + this._sort = sort; + this._sortMatrix = uniform( new Matrix4() ); + this._sortDepthRange = uniform( new Vector2( 0, 1 ) ); + this._sortInitialized = false; + this._lastSortPosition = new Vector3( Infinity, Infinity, Infinity ); + this._lastSortDirection = new Vector3( 0, 0, - 1 ); + this._positionAttribute = positionAttribute; + + const centerRead = buffers.centerRead; + const sortMatrix = this._sortMatrix; + const sortDepthRange = this._sortDepthRange; + + sort.setBinNode( () => { + + const center = centerRead.element( instanceIndex ).xyz.toVar( 'center' ); + const viewCenter = sortMatrix.mul( vec4( center, 1 ) ).xyz.toVar( 'viewCenter' ); + const depth = viewCenter.z.negate().toVar( 'depth' ); + const range = max( sortDepthRange.y.sub( sortDepthRange.x ), 0.0001 ).toVar( 'range' ); + const normalized = depth.sub( sortDepthRange.x ).div( range ).clamp( 0, 1 ).toVar( 'normalized' ); + const depthBin = uint( normalized.mul( BIN_COUNT - 1 ) ).toVar( 'depthBin' ); + + return uint( BIN_COUNT - 1 ).sub( depthBin ); + + } ); + + this.onBeforeRender = ( renderer, scene, camera ) => { + + if ( this.autoSort === true ) { + + this.updateSort( renderer, camera ); + + } + + }; + + } + + /** + * Updates the draw order if the camera has moved enough to need a new sort. + * + * @param {Renderer} renderer - The renderer. + * @param {Camera} camera - The camera used for rendering. + * @return {boolean} Whether a sort was dispatched this call. + */ + updateSort( renderer, camera ) { + + if ( this._sortInitialized === false || this._needsSort( camera ) === true ) { + + this._updateSortUniforms( camera ); + + if ( renderer.backend && renderer.backend.isWebGLBackend === true ) { + + enableWebGLBuffers( this._buffers ); + this._sort.enableWebGLBuffers(); + this._sortCPU(); + + } else { + + this._sort.compute( renderer ); + + } + + this._sortInitialized = true; + + return true; + + } + + return false; + + } + + _needsSort( camera ) { + + _cameraPosition.setFromMatrixPosition( camera.matrixWorld ); + + const e = camera.matrixWorld.elements; + _cameraDirection.set( - e[ 8 ], - e[ 9 ], - e[ 10 ] ).normalize(); + + const positionChanged = _cameraPosition.distanceToSquared( this._lastSortPosition ) > SORT_POSITION_THRESHOLD * SORT_POSITION_THRESHOLD; + const directionChanged = _cameraDirection.dot( this._lastSortDirection ) < SORT_DIRECTION_THRESHOLD; + + if ( positionChanged === true || directionChanged === true ) { + + this._lastSortPosition.copy( _cameraPosition ); + this._lastSortDirection.copy( _cameraDirection ); + return true; + + } + + return false; + + } + + _updateSortUniforms( camera ) { + + this.updateWorldMatrix( true, false ); + + this._sortMatrix.value.multiplyMatrices( camera.matrixWorldInverse, this.matrixWorld ); + + _worldCenter.copy( this.splatGeometry.boundingSphere.center ).applyMatrix4( this.matrixWorld ); + _viewCenter.copy( _worldCenter ).applyMatrix4( camera.matrixWorldInverse ); + this.getWorldScale( _worldScale ); + + const radius = this.splatGeometry.boundingSphere.radius * Math.max( _worldScale.x, _worldScale.y, _worldScale.z ); + const depth = - _viewCenter.z; + const nearDepth = Math.max( camera.near, depth - radius ); + const farDepth = Math.max( nearDepth + 0.0001, depth + radius ); + + _sortDepthRange.set( nearDepth, farDepth ); + this._sortDepthRange.value.copy( _sortDepthRange ); + + } + + _sortCPU() { + + const centers = this._positionAttribute.array; + const matrix = this._sortMatrix.value.elements; + const nearDepth = this._sortDepthRange.value.x; + const range = Math.max( this._sortDepthRange.value.y - nearDepth, 0.0001 ); + const scale = ( BIN_COUNT - 1 ) / range; + + this._sort.computeCPU( ( i ) => { + + const i3 = i * 3; + const depth = - ( matrix[ 2 ] * centers[ i3 ] + matrix[ 6 ] * centers[ i3 + 1 ] + matrix[ 10 ] * centers[ i3 + 2 ] + matrix[ 14 ] ); + const depthBin = Math.min( BIN_COUNT - 1, Math.max( 0, Math.floor( ( depth - nearDepth ) * scale ) ) ); + + return BIN_COUNT - 1 - depthBin; + + } ); + + } + +} + +function createGeometry( count ) { + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( new Float32Array( [ + - 2, - 2, 0, + 2, - 2, 0, + 2, 2, 0, + - 2, 2, 0 + ] ), 3 ) ); + geometry.setIndex( [ 0, 1, 2, 0, 2, 3 ] ); + geometry.instanceCount = count; + + return geometry; + +} + +function createStorageBuffers( count, centers, covariances, colors ) { + + const centerData = new Float32Array( count * 4 ); + const covarianceAData = new Float32Array( count * 4 ); + const covarianceBData = new Float32Array( count * 4 ); + const colorData = new Float32Array( count * 4 ); + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + const i4 = i * 4; + const i6 = i * 6; + + centerData[ i4 ] = centers[ i3 ]; + centerData[ i4 + 1 ] = centers[ i3 + 1 ]; + centerData[ i4 + 2 ] = centers[ i3 + 2 ]; + + covarianceAData[ i4 ] = covariances[ i6 ]; + covarianceAData[ i4 + 1 ] = covariances[ i6 + 1 ]; + covarianceAData[ i4 + 2 ] = covariances[ i6 + 2 ]; + covarianceAData[ i4 + 3 ] = covariances[ i6 + 3 ]; + + covarianceBData[ i4 ] = covariances[ i6 + 4 ]; + covarianceBData[ i4 + 1 ] = covariances[ i6 + 5 ]; + + colorData[ i4 ] = colors[ i4 ] / 255; + colorData[ i4 + 1 ] = colors[ i4 + 1 ] / 255; + colorData[ i4 + 2 ] = colors[ i4 + 2 ] / 255; + colorData[ i4 + 3 ] = colors[ i4 + 3 ] / 255; + + } + + const centerAttribute = new StorageBufferAttribute( centerData, 4 ); + const covarianceAAttribute = new StorageBufferAttribute( covarianceAData, 4 ); + const covarianceBAttribute = new StorageBufferAttribute( covarianceBData, 4 ); + const colorAttribute = new StorageBufferAttribute( colorData, 4 ); + + return { + count, + webGLBuffersEnabled: false, + centerRead: storage( centerAttribute, 'vec4', count ).toReadOnly(), + covarianceARead: storage( covarianceAAttribute, 'vec4', count ).toReadOnly(), + covarianceBRead: storage( covarianceBAttribute, 'vec4', count ).toReadOnly(), + colorRead: storage( colorAttribute, 'vec4', count ).toReadOnly() + }; + +} + +function enableWebGLBuffers( buffers ) { + + if ( buffers.webGLBuffersEnabled === true ) return; + + buffers.centerRead.setPBO( true ); + buffers.covarianceARead.setPBO( true ); + buffers.covarianceBRead.setPBO( true ); + buffers.colorRead.setPBO( true ); + buffers.webGLBuffersEnabled = true; + +} + +function createMaterial( buffers, sort ) { + + const splatUv = varyingProperty( 'vec2', 'vSplatUv' ); + const splatColor = varyingProperty( 'vec4', 'vSplatColor' ); + + const vertexNode = Fn( () => { + + const splatIndex = sort.orderRead.element( instanceIndex ).toVar( 'splatIndex' ); + const center = buffers.centerRead.element( splatIndex ).xyz.toVar( 'center' ); + const covA = buffers.covarianceARead.element( splatIndex ).toVar( 'covA' ); + const covB = buffers.covarianceBRead.element( splatIndex ).toVar( 'covB' ); + const color = buffers.colorRead.element( splatIndex ).toVar( 'splatColor' ); + + splatUv.assign( positionGeometry.xy ); + + const viewCenter4 = highpModelViewMatrix.mul( vec4( center, 1 ) ).toVar( 'viewCenter4' ); + const viewCenter = viewCenter4.xyz.toVar( 'viewCenter' ); + const centerClip = cameraProjectionMatrix.mul( viewCenter4 ).toVar( 'centerClip' ); + + const m = highpModelViewMatrix; + const r0 = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x ).toVar( 'r0' ); + const r1 = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y ).toVar( 'r1' ); + const r2 = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z ).toVar( 'r2' ); + + const cov0 = vec3( covA.x, covA.y, covA.z ).toVar( 'cov0' ); + const cov1 = vec3( covA.y, covA.w, covB.x ).toVar( 'cov1' ); + const cov2 = vec3( covA.z, covB.x, covB.y ).toVar( 'cov2' ); + + const vc0 = vec3( dot( r0, cov0 ), dot( r0, cov1 ), dot( r0, cov2 ) ).toVar( 'vc0' ); + const vc1 = vec3( dot( r1, cov0 ), dot( r1, cov1 ), dot( r1, cov2 ) ).toVar( 'vc1' ); + const vc2 = vec3( dot( r2, cov0 ), dot( r2, cov1 ), dot( r2, cov2 ) ).toVar( 'vc2' ); + + const c00 = dot( vc0, r0 ).toVar( 'c00' ); + const c01 = dot( vc0, r1 ).toVar( 'c01' ); + const c02 = dot( vc0, r2 ).toVar( 'c02' ); + const c11 = dot( vc1, r1 ).toVar( 'c11' ); + const c12 = dot( vc1, r2 ).toVar( 'c12' ); + const c22 = dot( vc2, r2 ).toVar( 'c22' ); + + const z = min( viewCenter.z, - 0.01 ).toVar( 'z' ); + const invZ = float( 1 ).div( z ).toVar( 'invZ' ); + const invZ2 = invZ.mul( invZ ).toVar( 'invZ2' ); + const focal = screenSize.mul( 0.5 ).mul( vec2( cameraProjectionMatrix[ 0 ].x, cameraProjectionMatrix[ 1 ].y ) ).toVar( 'focal' ); + + const j00 = focal.x.negate().mul( invZ ).toVar( 'j00' ); + const j11 = focal.y.negate().mul( invZ ).toVar( 'j11' ); + const j02 = focal.x.mul( viewCenter.x ).mul( invZ2 ).toVar( 'j02' ); + const j12 = focal.y.mul( viewCenter.y ).mul( invZ2 ).toVar( 'j12' ); + + const aBase = j00.mul( j00 ).mul( c00 ) + .add( j00.mul( j02 ).mul( c02 ).mul( 2 ) ) + .add( j02.mul( j02 ).mul( c22 ) ) + .toVar( 'cov2dABase' ); + const b = j00.mul( j11 ).mul( c01 ) + .add( j00.mul( j12 ).mul( c02 ) ) + .add( j02.mul( j11 ).mul( c12 ) ) + .add( j02.mul( j12 ).mul( c22 ) ) + .toVar( 'cov2dB' ); + const cBase = j11.mul( j11 ).mul( c11 ) + .add( j11.mul( j12 ).mul( c12 ).mul( 2 ) ) + .add( j12.mul( j12 ).mul( c22 ) ) + .toVar( 'cov2dCBase' ); + const a = aBase.add( KERNEL_2D_SIZE ).toVar( 'cov2dA' ); + const c = cBase.add( KERNEL_2D_SIZE ).toVar( 'cov2dC' ); + const detBase = aBase.mul( cBase ).sub( b.mul( b ) ).toVar( 'detBase' ); + const det = a.mul( c ).sub( b.mul( b ) ).toVar( 'det' ); + const alphaScale = sqrt( max( detBase.div( max( det, 0.000001 ) ), 0 ) ).toVar( 'alphaScale' ); + + splatColor.assign( vec4( color.rgb, color.a.mul( alphaScale ) ) ); + + const halfTrace = a.add( c ).mul( 0.5 ).toVar( 'halfTrace' ); + const radius = sqrt( max( a.sub( c ).mul( 0.5 ).pow2().add( b.mul( b ) ), 0.0000001 ) ).toVar( 'radius' ); + const lambda1 = max( halfTrace.add( radius ), 0.0000001 ).toVar( 'lambda1' ); + const lambda2 = max( halfTrace.sub( radius ), 0.0000001 ).toVar( 'lambda2' ); + const axis1 = vec2( 1, 0 ).toVar( 'axis1' ); + + If( radius.greaterThan( 0.00001 ), () => { + + const angle = atan( b.mul( 2 ), a.sub( c ) ).mul( 0.5 ).toVar( 'angle' ); + axis1.assign( vec2( cos( angle ), sin( angle ) ) ); + + } ); + + const axis2 = vec2( axis1.y.negate(), axis1.x ).toVar( 'axis2' ); + + const scale1 = min( sqrt( lambda1 ), MAX_SCREEN_SPACE_SPLAT_SIZE ).toVar( 'scale1' ); + const scale2 = min( sqrt( lambda2 ), MAX_SCREEN_SPACE_SPLAT_SIZE ).toVar( 'scale2' ); + const offsetPixels = axis1.mul( positionGeometry.x ).mul( scale1 ).add( axis2.mul( positionGeometry.y ).mul( scale2 ) ).toVar( 'offsetPixels' ); + const offsetNdc = offsetPixels.mul( 2 ).div( screenSize ).toVar( 'offsetNdc' ); + const clip = centerClip.add( vec4( offsetNdc.mul( centerClip.w ), 0, 0 ) ).toVar( 'clip' ); + + const clipLimit = centerClip.w.mul( CLIP_XY ).toVar( 'clipLimit' ); + + If( viewCenter.z.greaterThanEqual( - 0.01 ) + .or( centerClip.z.lessThan( centerClip.w.negate() ) ) + .or( centerClip.z.greaterThan( centerClip.w ) ) + .or( centerClip.x.lessThan( clipLimit.negate() ) ) + .or( centerClip.x.greaterThan( clipLimit ) ) + .or( centerClip.y.lessThan( clipLimit.negate() ) ) + .or( centerClip.y.greaterThan( clipLimit ) ), () => { + + clip.assign( vec4( 2, 2, 2, 1 ) ); + + } ); + + return clip; + + } )(); + + const fragmentNode = Fn( () => { + + const r2 = dot( splatUv, splatUv ).toVar( 'r2' ); + + If( r2.greaterThan( 4 ), () => { + + Discard(); + + } ); + + return vec4( splatColor.rgb, exp( r2.mul( - 0.5 ) ).mul( splatColor.a ) ); + + } )(); + + const material = new NodeMaterial(); + material.vertexNode = vertexNode; + material.colorNode = fragmentNode; + material.transparent = true; + material.depthWrite = false; + material.depthTest = true; + material.forceSinglePass = true; + material.fog = false; + + return material; + +} + +export { GaussianSplatMesh }; diff --git a/examples/jsm/utils/GaussianSplatUtils.js b/examples/jsm/utils/GaussianSplatUtils.js new file mode 100644 index 00000000000000..a0ab56ad282b10 --- /dev/null +++ b/examples/jsm/utils/GaussianSplatUtils.js @@ -0,0 +1,218 @@ +import { + BufferAttribute, + BufferGeometry +} from 'three'; + +const SH_C0 = 0.2820947917738781; +const GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING = { + scale: [ 'scale_0', 'scale_1', 'scale_2' ], + rotation: [ 'rot_0', 'rot_1', 'rot_2', 'rot_3' ], + f_dc: [ 'f_dc_0', 'f_dc_1', 'f_dc_2' ], + opacity: [ 'opacity' ] +}; + +function clampByte( value ) { + + return Math.min( 255, Math.max( 0, Math.round( value ) ) ); + +} + +function sigmoid( value ) { + + return 1 / ( 1 + Math.exp( - value ) ); + +} + +function writeColorBytes( target, offset, r, g, b, a ) { + + target[ offset ] = clampByte( r ); + target[ offset + 1 ] = clampByte( g ); + target[ offset + 2 ] = clampByte( b ); + target[ offset + 3 ] = clampByte( a ); + +} + +function sh0ToLinear( coefficient ) { + + return coefficient * SH_C0 + 0.5; + +} + +function linearToSH0( color ) { + + return ( color - 0.5 ) / SH_C0; + +} + +function writeColorBytesFromSH0( target, offset, r, g, b, a ) { + + writeColorBytes( + target, + offset, + sh0ToLinear( r ) * 255, + sh0ToLinear( g ) * 255, + sh0ToLinear( b ) * 255, + a * 255 + ); + +} + +function writeCovariance( target, offset, sx, sy, sz, qx, qy, qz, qw ) { + + const length = Math.hypot( qx, qy, qz, qw ); + + if ( length === 0 ) { + + qx = 0; + qy = 0; + qz = 0; + qw = 1; + + } else { + + const invLength = 1 / length; + qx *= invLength; + qy *= invLength; + qz *= invLength; + qw *= invLength; + + } + + const x2 = qx + qx; + const y2 = qy + qy; + const z2 = qz + qz; + const xx = qx * x2; + const xy = qx * y2; + const xz = qx * z2; + const yy = qy * y2; + const yz = qy * z2; + const zz = qz * z2; + const wx = qw * x2; + const wy = qw * y2; + const wz = qw * z2; + + const r00 = 1 - ( yy + zz ); + const r01 = xy - wz; + const r02 = xz + wy; + const r10 = xy + wz; + const r11 = 1 - ( xx + zz ); + const r12 = yz - wx; + const r20 = xz - wy; + const r21 = yz + wx; + const r22 = 1 - ( xx + yy ); + + const sxx = sx * sx; + const syy = sy * sy; + const szz = sz * sz; + + target[ offset ] = r00 * r00 * sxx + r01 * r01 * syy + r02 * r02 * szz; + target[ offset + 1 ] = r00 * r10 * sxx + r01 * r11 * syy + r02 * r12 * szz; + target[ offset + 2 ] = r00 * r20 * sxx + r01 * r21 * syy + r02 * r22 * szz; + target[ offset + 3 ] = r10 * r10 * sxx + r11 * r11 * syy + r12 * r12 * szz; + target[ offset + 4 ] = r10 * r20 * sxx + r11 * r21 * syy + r12 * r22 * szz; + target[ offset + 5 ] = r20 * r20 * sxx + r21 * r21 * syy + r22 * r22 * szz; + +} + +function createGaussianSplatGeometry( centers, covariances, colors ) { + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( centers, 3 ) ); + geometry.setAttribute( 'covariance', new BufferAttribute( covariances, 6 ) ); + geometry.setAttribute( 'color', new BufferAttribute( colors, 4, true ) ); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; + +} + +function createGaussianSplatGeometryFromPLYGeometry( geometry, { + scaleAttribute = 'scale', + rotationAttribute = 'rotation', + sh0Attribute = 'f_dc', + opacityAttribute = 'opacity' +} = {} ) { + + if ( geometry === undefined || geometry.isBufferGeometry !== true ) { + + throw new Error( 'THREE.createGaussianSplatGeometryFromPLYGeometry: PLY geometry must be a BufferGeometry.' ); + + } + + const position = geometry.getAttribute( 'position' ); + const scale = geometry.getAttribute( scaleAttribute ); + const rotation = geometry.getAttribute( rotationAttribute ); + const sh0 = geometry.getAttribute( sh0Attribute ); + const opacity = geometry.getAttribute( opacityAttribute ); + + if ( position === undefined || scale === undefined || rotation === undefined || sh0 === undefined || opacity === undefined ) { + + throw new Error( 'THREE.createGaussianSplatGeometryFromPLYGeometry: PLY geometry requires position, scale, rotation, f_dc and opacity attributes.' ); + + } + + const count = position.count; + + if ( position.itemSize !== 3 || scale.itemSize !== 3 || rotation.itemSize !== 4 || sh0.itemSize !== 3 || opacity.itemSize !== 1 ) { + + throw new Error( 'THREE.createGaussianSplatGeometryFromPLYGeometry: Invalid Gaussian splat PLY attribute itemSize.' ); + + } + + if ( scale.count !== count || rotation.count !== count || sh0.count !== count || opacity.count !== count ) { + + throw new Error( 'THREE.createGaussianSplatGeometryFromPLYGeometry: Gaussian splat PLY attribute counts must match position.' ); + + } + + const centers = new Float32Array( count * 3 ); + const covariances = new Float32Array( count * 6 ); + const colors = new Uint8Array( count * 4 ); + + for ( let i = 0; i < count; i ++ ) { + + const i3 = i * 3; + centers[ i3 ] = position.getX( i ); + centers[ i3 + 1 ] = position.getY( i ); + centers[ i3 + 2 ] = position.getZ( i ); + + const sx = Math.exp( scale.getX( i ) ); + const sy = Math.exp( scale.getY( i ) ); + const sz = Math.exp( scale.getZ( i ) ); + + // GraphDECO/INRIA PLY stores quaternions as rot_0=w, rot_1=x, rot_2=y, rot_3=z. + const qw = rotation.getX( i ); + const qx = rotation.getY( i ); + const qy = rotation.getZ( i ); + const qz = rotation.getW( i ); + + writeCovariance( covariances, i * 6, sx, sy, sz, qx, qy, qz, qw ); + writeColorBytesFromSH0( + colors, + i * 4, + sh0.getX( i ), + sh0.getY( i ), + sh0.getZ( i ), + sigmoid( opacity.getX( i ) ) + ); + + } + + return createGaussianSplatGeometry( centers, covariances, colors ); + +} + +export { + GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING, + SH_C0, + clampByte, + createGaussianSplatGeometry, + createGaussianSplatGeometryFromPLYGeometry, + linearToSH0, + sh0ToLinear, + sigmoid, + writeColorBytes, + writeColorBytesFromSH0, + writeCovariance +}; diff --git a/examples/models/splat/millipede.license.txt b/examples/models/splat/millipede.license.txt new file mode 100644 index 00000000000000..191335a3016134 --- /dev/null +++ b/examples/models/splat/millipede.license.txt @@ -0,0 +1,6 @@ +Title: millipede spec. +Author: Fabian Plum (https://superspl.at/user?id=scant3d) +Source: https://superspl.at/scene/3d5482d4 +License: CC Attribution (Creative Commons Attribution) +License URL: http://creativecommons.org/licenses/by/4.0/ +Requirements: Author must be credited. Commercial use is allowed. \ No newline at end of file diff --git a/examples/models/splat/millipede.splat b/examples/models/splat/millipede.splat new file mode 100644 index 00000000000000..8144aa993ae148 Binary files /dev/null and b/examples/models/splat/millipede.splat differ diff --git a/examples/models/spz/lion.license.txt b/examples/models/spz/lion.license.txt new file mode 100644 index 00000000000000..e747d2c82ec5b9 --- /dev/null +++ b/examples/models/spz/lion.license.txt @@ -0,0 +1,6 @@ +Title: Lion +Author: Renaud (https://superspl.at/user?id=rohls) +Source: https://superspl.at/scene/56155c3f +License: CC Attribution (Creative Commons Attribution) +License URL: http://creativecommons.org/licenses/by/4.0/ +Requirements: Author must be credited. Commercial use is allowed. \ No newline at end of file diff --git a/examples/models/spz/lion.spz b/examples/models/spz/lion.spz new file mode 100644 index 00000000000000..f724b17abfcd4e Binary files /dev/null and b/examples/models/spz/lion.spz differ diff --git a/examples/screenshots/webgpu_gaussian_splatting.jpg b/examples/screenshots/webgpu_gaussian_splatting.jpg new file mode 100644 index 00000000000000..5b5741b8e02bd8 Binary files /dev/null and b/examples/screenshots/webgpu_gaussian_splatting.jpg differ diff --git a/examples/tags.json b/examples/tags.json index a487814c2264fb..4eb1abb82f0d2b 100644 --- a/examples/tags.json +++ b/examples/tags.json @@ -31,6 +31,7 @@ "webgl_geometry_colors_lookuptable": [ "vertex" ], "webgl_geometry_csg": [ "community", "csg", "bvh", "constructive", "solid", "geometry", "games", "level" ], "webgpu_geometry_loft": [ "sweep", "skin", "sections", "surface", "tsl", "procedural" ], + "webgpu_gaussian_splatting": [ "splat", "point cloud", "loader", "compute", "tsl" ], "webgl_geometry_nurbs": [ "curve", "surface" ], "webgl_geometry_spline_editor": [ "curve" ], "webgl_geometry_terrain": [ "fog" ], diff --git a/examples/webgpu_gaussian_splatting.html b/examples/webgpu_gaussian_splatting.html new file mode 100644 index 00000000000000..da5d78ee29713f --- /dev/null +++ b/examples/webgpu_gaussian_splatting.html @@ -0,0 +1,232 @@ + + + + three.js webgpu - gaussian splatting + + + + + + + + + + + +
+ + +
+ three.jsGaussian Splatting +
+ + WebGPU TSL Gaussian splat renderer by Ben Houston. + +
+ + + + + + diff --git a/test/e2e/clean-page.js b/test/e2e/clean-page.js index d4e4fe3bd4b26e..dc8ea72ab6b27f 100644 --- a/test/e2e/clean-page.js +++ b/test/e2e/clean-page.js @@ -8,7 +8,7 @@ /* Remove gui and fonts */ const style = document.createElement( 'style' ); - style.innerHTML = '#info, .three-inspector, button, input, body > div.lil-gui, body > div.lbl { display: none !important; }'; + style.innerHTML = '#info, #camera-info, .three-inspector, button, input, body > div.lil-gui, body > div.lbl { display: none !important; }'; document.querySelector( 'head' ).appendChild( style ); diff --git a/test/unit/UnitTestsAddons.html b/test/unit/UnitTestsAddons.html index 13580a385f28fb..56942b00973bbd 100644 --- a/test/unit/UnitTestsAddons.html +++ b/test/unit/UnitTestsAddons.html @@ -17,7 +17,9 @@ diff --git a/test/unit/addons/loaders/GLTFLoader.tests.js b/test/unit/addons/loaders/GLTFLoader.tests.js new file mode 100644 index 00000000000000..15d16fb01c6e34 --- /dev/null +++ b/test/unit/addons/loaders/GLTFLoader.tests.js @@ -0,0 +1,150 @@ +import { GLTFLoader } from '../../../../examples/jsm/loaders/GLTFLoader.js'; +import { GLTFGaussianSplatLoaderExtension } from '../../../../examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js'; + +const EPS = 1e-6; +const FLOAT = 5126; +const UNSIGNED_BYTE = 5121; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +function arrayBufferToBase64( buffer ) { + + let binary = ''; + const bytes = new Uint8Array( buffer ); + + for ( let i = 0; i < bytes.length; i ++ ) { + + binary += String.fromCharCode( bytes[ i ] ); + + } + + return btoa( binary ); + +} + +function createGaussianSplatGLTF() { + + const chunks = []; + const bufferViews = []; + const accessors = []; + let byteOffset = 0; + + function addAccessor( array, type, componentType, normalized = false, min = undefined, max = undefined ) { + + while ( byteOffset % 4 !== 0 ) { + + chunks.push( new Uint8Array( [ 0 ] ) ); + byteOffset ++; + + } + + const bytes = new Uint8Array( array.buffer, array.byteOffset, array.byteLength ); + const bufferView = bufferViews.push( { + buffer: 0, + byteOffset, + byteLength: bytes.byteLength + } ) - 1; + const accessor = { + bufferView, + componentType, + count: 1, + type + }; + + if ( normalized === true ) accessor.normalized = true; + if ( min !== undefined ) accessor.min = min; + if ( max !== undefined ) accessor.max = max; + + chunks.push( bytes ); + byteOffset += bytes.byteLength; + + return accessors.push( accessor ) - 1; + + } + + const position = addAccessor( new Float32Array( [ 1, 2, 3 ] ), 'VEC3', FLOAT, false, [ 1, 2, 3 ], [ 1, 2, 3 ] ); + const scale = addAccessor( new Float32Array( [ 2, 3, 4 ] ), 'VEC3', FLOAT ); + const rotation = addAccessor( new Int8Array( [ 0, 0, 0, 127 ] ), 'VEC4', 5120, true ); + const opacity = addAccessor( new Uint8Array( [ 128 ] ), 'SCALAR', UNSIGNED_BYTE, true ); + const sh0 = addAccessor( new Float32Array( [ 0, 0, 0 ] ), 'VEC3', FLOAT ); + const buffer = new Uint8Array( byteOffset ); + let offset = 0; + + for ( const chunk of chunks ) { + + buffer.set( chunk, offset ); + offset += chunk.byteLength; + + } + + return { + asset: { version: '2.0' }, + scene: 0, + scenes: [ { nodes: [ 0 ] } ], + nodes: [ { mesh: 0 } ], + meshes: [ { + primitives: [ { + mode: 0, + attributes: { + POSITION: position, + 'KHR_gaussian_splatting:SCALE': scale, + 'KHR_gaussian_splatting:ROTATION': rotation, + 'KHR_gaussian_splatting:OPACITY': opacity, + 'KHR_gaussian_splatting:SH_DEGREE_0_COEF_0': sh0 + }, + extensions: { + KHR_gaussian_splatting: { + kernel: 'ellipse', + colorSpace: 'srgb_rec709_display' + } + } + } ] + } ], + accessors, + bufferViews, + buffers: [ { + byteLength: buffer.byteLength, + uri: 'data:application/octet-stream;base64,' + arrayBufferToBase64( buffer.buffer ) + } ], + extensionsUsed: [ 'KHR_gaussian_splatting' ] + }; + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Loaders', () => { + + QUnit.module( 'GLTFLoader', () => { + + QUnit.test( 'loads KHR_gaussian_splatting primitives as GaussianSplatMesh', async ( assert ) => { + + const loader = new GLTFLoader(); + loader.register( function ( parser ) { + + return new GLTFGaussianSplatLoaderExtension( parser ); + + } ); + + const gltf = await loader.parseAsync( JSON.stringify( createGaussianSplatGLTF() ), '' ); + const mesh = gltf.scene.children[ 0 ]; + const covariances = mesh.splatGeometry.getAttribute( 'covariance' ).array; + + assert.ok( mesh.isGaussianSplatMesh, 'creates GaussianSplatMesh' ); + assert.deepEqual( Array.from( mesh.splatGeometry.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'loads centers' ); + closeTo( assert, covariances[ 0 ], 4, 'covariance xx' ); + closeTo( assert, covariances[ 3 ], 9, 'covariance yy' ); + closeTo( assert, covariances[ 5 ], 16, 'covariance zz' ); + assert.deepEqual( Array.from( mesh.splatGeometry.getAttribute( 'color' ).array ), [ 128, 128, 128, 128 ], 'loads degree-0 color and opacity' ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/loaders/KSPLATLoader.tests.js b/test/unit/addons/loaders/KSPLATLoader.tests.js new file mode 100644 index 00000000000000..897b7b77e17013 --- /dev/null +++ b/test/unit/addons/loaders/KSPLATLoader.tests.js @@ -0,0 +1,95 @@ +import { BufferGeometry } from 'three'; +import { KSPLATLoader } from '../../../../examples/jsm/loaders/KSPLATLoader.js'; + +const EPS = 1e-6; +const HEADER_SIZE_BYTES = 4096; +const SECTION_HEADER_SIZE_BYTES = 1024; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +function createKSPLATBuffer() { + + const compression = { + bytesPerSplat: 44, + scaleOffsetBytes: 12, + rotationOffsetBytes: 24, + colorOffsetBytes: 40, + bucketBytes: 0 + }; + const buffer = new ArrayBuffer( HEADER_SIZE_BYTES + SECTION_HEADER_SIZE_BYTES + compression.bytesPerSplat ); + const view = new DataView( buffer ); + const bytes = new Uint8Array( buffer ); + const sectionOffset = HEADER_SIZE_BYTES; + const dataOffset = HEADER_SIZE_BYTES + SECTION_HEADER_SIZE_BYTES; + + view.setUint8( 0, 0 ); + view.setUint8( 1, 1 ); + view.setUint32( 4, 1, true ); + view.setUint32( 8, 1, true ); + view.setUint32( 12, 1, true ); + view.setUint32( 16, 1, true ); + view.setUint16( 20, 0, true ); + + view.setUint32( sectionOffset, 1, true ); + view.setUint32( sectionOffset + 4, 1, true ); + view.setUint32( sectionOffset + 8, 0, true ); + view.setUint32( sectionOffset + 12, 0, true ); + view.setFloat32( sectionOffset + 16, 4, true ); + view.setUint16( sectionOffset + 20, compression.bucketBytes, true ); + view.setUint32( sectionOffset + 24, 32767, true ); + view.setUint32( sectionOffset + 32, 0, true ); + view.setUint32( sectionOffset + 36, 0, true ); + view.setUint16( sectionOffset + 40, 0, true ); + + view.setFloat32( dataOffset, 1, true ); + view.setFloat32( dataOffset + 4, 2, true ); + view.setFloat32( dataOffset + 8, 3, true ); + view.setFloat32( dataOffset + compression.scaleOffsetBytes, 2, true ); + view.setFloat32( dataOffset + compression.scaleOffsetBytes + 4, 3, true ); + view.setFloat32( dataOffset + compression.scaleOffsetBytes + 8, 4, true ); + view.setFloat32( dataOffset + compression.rotationOffsetBytes, 1, true ); + view.setFloat32( dataOffset + compression.rotationOffsetBytes + 4, 0, true ); + view.setFloat32( dataOffset + compression.rotationOffsetBytes + 8, 0, true ); + view.setFloat32( dataOffset + compression.rotationOffsetBytes + 12, 0, true ); + + bytes.set( [ 10, 20, 30, 40 ], dataOffset + compression.colorOffsetBytes ); + + return buffer; + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Loaders', () => { + + QUnit.module( 'KSPLATLoader', () => { + + QUnit.test( 'parses uncompressed KSPLAT data', ( assert ) => { + + const loader = new KSPLATLoader(); + const data = loader.parse( createKSPLATBuffer() ); + + const covariances = data.getAttribute( 'covariance' ).array; + + assert.ok( data instanceof BufferGeometry, 'returns BufferGeometry' ); + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'centers' ); + closeTo( assert, covariances[ 0 ], 4, 'covariance xx' ); + closeTo( assert, covariances[ 1 ], 0, 'covariance xy' ); + closeTo( assert, covariances[ 2 ], 0, 'covariance xz' ); + closeTo( assert, covariances[ 3 ], 9, 'covariance yy' ); + closeTo( assert, covariances[ 4 ], 0, 'covariance yz' ); + closeTo( assert, covariances[ 5 ], 16, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 10, 20, 30, 40 ], 'colors' ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/loaders/SPLATLoader.tests.js b/test/unit/addons/loaders/SPLATLoader.tests.js new file mode 100644 index 00000000000000..1ef03737b8ed8d --- /dev/null +++ b/test/unit/addons/loaders/SPLATLoader.tests.js @@ -0,0 +1,62 @@ +import { BufferGeometry } from 'three'; +import { SPLATLoader } from '../../../../examples/jsm/loaders/SPLATLoader.js'; + +const EPS = 1e-6; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +function createSplatBuffer() { + + const buffer = new ArrayBuffer( 32 ); + const view = new DataView( buffer ); + const bytes = new Uint8Array( buffer ); + + view.setFloat32( 0, 1, true ); + view.setFloat32( 4, 2, true ); + view.setFloat32( 8, 3, true ); + view.setFloat32( 12, 2, true ); + view.setFloat32( 16, 3, true ); + view.setFloat32( 20, 4, true ); + + bytes.set( [ 10, 20, 30, 40 ], 24 ); + bytes.set( [ 128, 128, 128, 128 ], 28 ); + + return buffer; + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Loaders', () => { + + QUnit.module( 'SPLATLoader', () => { + + QUnit.test( 'parses fixed-width .splat data', ( assert ) => { + + const loader = new SPLATLoader(); + const data = loader.parse( createSplatBuffer() ); + + const covariances = data.getAttribute( 'covariance' ).array; + + assert.ok( data instanceof BufferGeometry, 'returns BufferGeometry' ); + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'centers' ); + closeTo( assert, covariances[ 0 ], 4, 'covariance xx' ); + closeTo( assert, covariances[ 1 ], 0, 'covariance xy' ); + closeTo( assert, covariances[ 2 ], 0, 'covariance xz' ); + closeTo( assert, covariances[ 3 ], 9, 'covariance yy' ); + closeTo( assert, covariances[ 4 ], 0, 'covariance yz' ); + closeTo( assert, covariances[ 5 ], 16, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 10, 20, 30, 40 ], 'colors' ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/loaders/SPZLoader.tests.js b/test/unit/addons/loaders/SPZLoader.tests.js new file mode 100644 index 00000000000000..491942ea5c2614 --- /dev/null +++ b/test/unit/addons/loaders/SPZLoader.tests.js @@ -0,0 +1,80 @@ +import { BufferGeometry } from 'three'; +import { gzipSync } from '../../../../examples/jsm/libs/fflate.module.js'; +import { SPZLoader } from '../../../../examples/jsm/loaders/SPZLoader.js'; + +const EPS = 1e-6; +const SPZ_MAGIC = 0x5053474e; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +function writeInt24( view, offset, value ) { + + view.setUint8( offset, value & 0xff ); + view.setUint8( offset + 1, ( value >> 8 ) & 0xff ); + view.setUint8( offset + 2, ( value >> 16 ) & 0xff ); + +} + +function createSPZBuffer() { + + const raw = new Uint8Array( 16 + 9 + 1 + 3 + 3 + 3 ); + const view = new DataView( raw.buffer ); + let offset = 0; + + view.setUint32( 0, SPZ_MAGIC, true ); + view.setUint32( 4, 2, true ); + view.setUint32( 8, 1, true ); + view.setUint8( 12, 0 ); + view.setUint8( 13, 4 ); + view.setUint8( 14, 0 ); + view.setUint8( 15, 0 ); + offset = 16; + + writeInt24( view, offset, 24 ); + writeInt24( view, offset + 3, - 32 ); + writeInt24( view, offset + 6, 4 ); + offset += 9; + + raw[ offset ++ ] = 64; + raw.set( [ 128, 128, 128 ], offset ); + offset += 3; + raw.set( [ 160, 160, 160 ], offset ); + offset += 3; + raw.set( [ 128, 128, 128 ], offset ); + + return gzipSync( raw ).buffer; + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Loaders', () => { + + QUnit.module( 'SPZLoader', () => { + + QUnit.test( 'parses SPZ v2 fixed-point data', ( assert ) => { + + const loader = new SPZLoader(); + const data = loader.parse( createSPZBuffer() ); + + const covariances = data.getAttribute( 'covariance' ).array; + + assert.ok( data instanceof BufferGeometry, 'returns BufferGeometry' ); + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1.5, - 2, 0.25 ], 'fixed-point centers' ); + closeTo( assert, covariances[ 0 ], 1, 'covariance xx' ); + closeTo( assert, covariances[ 3 ], 1, 'covariance yy' ); + closeTo( assert, covariances[ 5 ], 1, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 128, 128, 128, 64 ], 'degree-0 color and alpha' ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/utils/GaussianSplatUtils.tests.js b/test/unit/addons/utils/GaussianSplatUtils.tests.js new file mode 100644 index 00000000000000..28397ae66dcf03 --- /dev/null +++ b/test/unit/addons/utils/GaussianSplatUtils.tests.js @@ -0,0 +1,144 @@ +import { + BufferAttribute, + BufferGeometry +} from 'three'; + +import { PLYLoader } from '../../../../examples/jsm/loaders/PLYLoader.js'; + +import { + GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING, + createGaussianSplatGeometry, + createGaussianSplatGeometryFromPLYGeometry, + linearToSH0, + sh0ToLinear, + sigmoid +} from '../../../../examples/jsm/utils/GaussianSplatUtils.js'; + +const EPS = 1e-6; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Utils', () => { + + QUnit.module( 'GaussianSplatUtils', () => { + + QUnit.test( 'converts degree-0 spherical harmonics and linear color', ( assert ) => { + + closeTo( assert, sh0ToLinear( 0 ), 0.5, 'zero coefficient maps to biased half' ); + closeTo( assert, linearToSH0( 0.5 ), 0, 'biased half maps to zero coefficient' ); + closeTo( assert, sh0ToLinear( linearToSH0( 0.25 ) ), 0.25, 'color round-trips through SH0' ); + + } ); + + QUnit.test( 'applies sigmoid activation', ( assert ) => { + + closeTo( assert, sigmoid( 0 ), 0.5, 'zero maps to half' ); + closeTo( assert, sigmoid( Math.log( 3 ) ), 0.75, 'logit maps to expected value' ); + + } ); + + QUnit.test( 'creates Gaussian splat geometry from packed arrays', ( assert ) => { + + const data = createGaussianSplatGeometry( + new Float32Array( [ 1, 2, 3 ] ), + new Float32Array( [ 4, 0, 0, 9, 0, 16 ] ), + new Uint8Array( [ 128, 128, 128, 128 ] ) + ); + + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'centers' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 128, 128, 128, 128 ], 'colors' ); + assert.ok( data.boundingBox !== null, 'computes bounding box' ); + assert.ok( data.boundingSphere !== null, 'computes bounding sphere' ); + + } ); + + QUnit.test( 'converts PLY geometry attributes into Gaussian splat geometry', ( assert ) => { + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( new Float32Array( [ 1, 2, 3 ] ), 3 ) ); + geometry.setAttribute( 'scale', new BufferAttribute( new Float32Array( [ Math.log( 2 ), Math.log( 3 ), Math.log( 4 ) ] ), 3 ) ); + geometry.setAttribute( 'rotation', new BufferAttribute( new Float32Array( [ 1, 0, 0, 0 ] ), 4 ) ); + geometry.setAttribute( 'f_dc', new BufferAttribute( new Float32Array( [ 0, 0, 0 ] ), 3 ) ); + geometry.setAttribute( 'opacity', new BufferAttribute( new Float32Array( [ 0 ] ), 1 ) ); + + const data = createGaussianSplatGeometryFromPLYGeometry( geometry ); + const covariances = data.getAttribute( 'covariance' ).array; + + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'centers' ); + closeTo( assert, covariances[ 0 ], 4, 'covariance xx' ); + closeTo( assert, covariances[ 1 ], 0, 'covariance xy' ); + closeTo( assert, covariances[ 2 ], 0, 'covariance xz' ); + closeTo( assert, covariances[ 3 ], 9, 'covariance yy' ); + closeTo( assert, covariances[ 4 ], 0, 'covariance yz' ); + closeTo( assert, covariances[ 5 ], 16, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 128, 128, 128, 128 ], 'degree-0 color and opacity' ); + + } ); + + QUnit.test( 'converts generic PLYLoader output into Gaussian splat geometry', ( assert ) => { + + const ply = [ + 'ply', + 'format ascii 1.0', + 'element vertex 1', + 'property float x', + 'property float y', + 'property float z', + 'property float scale_0', + 'property float scale_1', + 'property float scale_2', + 'property float rot_0', + 'property float rot_1', + 'property float rot_2', + 'property float rot_3', + 'property float f_dc_0', + 'property float f_dc_1', + 'property float f_dc_2', + 'property float opacity', + 'end_header', + `1 2 3 ${ Math.log( 2 ) } ${ Math.log( 3 ) } ${ Math.log( 4 ) } 1 0 0 0 0 0 0 0` + ].join( '\n' ); + + const loader = new PLYLoader(); + loader.setCustomPropertyNameMapping( GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING ); + + const geometry = loader.parse( ply ); + const data = createGaussianSplatGeometryFromPLYGeometry( geometry ); + const covariances = data.getAttribute( 'covariance' ).array; + + assert.strictEqual( geometry.getAttribute( 'scale' ).itemSize, 3, 'PLYLoader preserves scale custom properties' ); + assert.strictEqual( geometry.getAttribute( 'rotation' ).itemSize, 4, 'PLYLoader preserves rotation custom properties' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1, 2, 3 ], 'centers' ); + closeTo( assert, covariances[ 0 ], 4, 'covariance xx' ); + closeTo( assert, covariances[ 3 ], 9, 'covariance yy' ); + closeTo( assert, covariances[ 5 ], 16, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 128, 128, 128, 128 ], 'degree-0 color and opacity' ); + + } ); + + QUnit.test( 'rejects incomplete PLY geometry attributes', ( assert ) => { + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( new Float32Array( [ 1, 2, 3 ] ), 3 ) ); + + assert.throws( + () => createGaussianSplatGeometryFromPLYGeometry( geometry ), + /requires position, scale, rotation, f_dc and opacity attributes/, + 'missing custom attributes are rejected' + ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/three.addons.unit.js b/test/unit/three.addons.unit.js index 70cf8cd73e5b6d..dca422485f3209 100644 --- a/test/unit/three.addons.unit.js +++ b/test/unit/three.addons.unit.js @@ -2,9 +2,14 @@ //addons/utils import './addons/utils/BufferGeometryUtils.tests.js'; import './addons/utils/ColorUtils.tests.js'; +import './addons/utils/GaussianSplatUtils.tests.js'; import './addons/math/ColorSpaces.tests.js'; import './addons/curves/NURBSCurve.tests.js'; import './addons/loaders/FBXLoader.tests.js'; +import './addons/loaders/GLTFLoader.tests.js'; import './addons/loaders/HDRLoader.tests.js'; +import './addons/loaders/KSPLATLoader.tests.js'; +import './addons/loaders/SPLATLoader.tests.js'; +import './addons/loaders/SPZLoader.tests.js'; import './addons/loaders/USDLoader.tests.js'; import './addons/exporters/USDZExporter.tests.js';