diff --git a/examples/jsm/loaders/PLYGaussianSplatLoader.js b/examples/jsm/loaders/PLYGaussianSplatLoader.js new file mode 100644 index 00000000000000..7d60c425992186 --- /dev/null +++ b/examples/jsm/loaders/PLYGaussianSplatLoader.js @@ -0,0 +1,386 @@ +import { + FileLoader, + Loader +} from 'three'; + +import { PLYLoader } from './PLYLoader.js'; +import { + SH_BAND_COMPONENTS, + SH_BAND_WORDS, + createGaussianSplatGeometry, + createPackedSphericalHarmonicsBand, + sigmoid, + writeColorBytesFromSH0, + writeCovariance +} from '../utils/GaussianSplatUtils.js'; + +// f_rest component count, indexed by spherical harmonics degree. +const SH_DEGREE_TO_COMPONENTS = [ 0, 9, 24, 45 ]; + +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' ] +}; + +// Property names a Gaussian splat PLY must declare in its header. Checked +// up front against the header text rather than the parsed geometry, since +// PLYLoader still creates a (garbage-filled) attribute for a custom property +// name that's missing from the file instead of omitting it. +const REQUIRED_PLY_PROPERTIES = [ + 'x', 'y', 'z', + ...GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.scale, + ...GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.rotation, + ...GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.f_dc, + ...GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.opacity +]; + +const _headerPattern = /^ply([\s\S]*?)end_header/; +const _propertyPattern = /^property\s+\S+\s+(\S+)\s*$/; +const _restPropertyPattern = /^f_rest_\d+$/; + +/** + * A loader for Gaussian splat PLY files, e.g. as exported by the original + * GraphDECO/INRIA 3D Gaussian Splatting implementation. + * + * PLY itself is a generic format, so the caller would normally have to know + * the file's spherical harmonics (SH) degree ahead of time to configure + * `PLYLoader` with the right custom property mapping before parsing. This + * loader avoids that by scanning the plain-text PLY header for `f_rest_N` + * properties first, since SH degree maps to a fixed, closed table of + * `f_rest` counts (0/9/24/45 -> degree 0/1/2/3), and configuring an + * internal `PLYLoader` accordingly before converting the result into + * Gaussian splat geometry. + * + * ```js + * const loader = new PLYGaussianSplatLoader(); + * const geometry = await loader.loadAsync( './models/gsplat/point_cloud.ply' ); + * scene.add( new GaussianSplatMesh( geometry ) ); + * ``` + * + * @augments Loader + * @three_import import { PLYGaussianSplatLoader } from 'three/addons/loaders/PLYGaussianSplatLoader.js'; + */ +class PLYGaussianSplatLoader extends Loader { + + /** + * Constructs a new Gaussian splat PLY loader. + * + * @param {LoadingManager} [manager] - The loading manager. + */ + constructor( manager ) { + + super( manager ); + + } + + /** + * Starts loading from the given URL and passes the loaded Gaussian splat + * geometry 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 ( data ) { + + try { + + onLoad( scope.parse( data ) ); + + } catch ( e ) { + + if ( onError ) { + + onError( e ); + + } else { + + console.error( e ); + + } + + scope.manager.itemError( url ); + + } + + }, onProgress, onError ); + + } + + /** + * Parses the given Gaussian splat PLY data and returns the resulting + * Gaussian splat geometry. + * + * This scans the PLY header for the file's spherical harmonics degree, + * so unlike a plain `PLYLoader`, no prior setup is required. + * + * @param {ArrayBuffer|string} data - The raw PLY data, as an array buffer or string. + * @return {BufferGeometry} The parsed Gaussian splat geometry. + */ + parse( data ) { + + const degree = detectSphericalHarmonicsDegree( data ); + + const plyLoader = new PLYLoader( this.manager ); + plyLoader.setCustomPropertyNameMapping( getPropertyMapping( degree ) ); + + return convertPLYGeometry( plyLoader.parse( data ) ); + + } + +} + +// Scans the PLY header text for its vertex properties, verifying the +// required Gaussian splat properties are present and mapping the number of +// "f_rest_N" properties found to a spherical harmonics degree. PLY headers +// are always plain ASCII text that fully precedes the vertex data, so this +// can run before the file is parsed by the generic PLYLoader. +function detectSphericalHarmonicsDegree( data ) { + + const headerText = typeof data === 'string' ? data : decodeHeaderText( new Uint8Array( data ) ); + const headerMatch = _headerPattern.exec( headerText ); + + if ( headerMatch === null ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: Missing PLY header.' ); + + } + + const propertyNames = new Set(); + let restComponentCount = 0; + + for ( const line of headerMatch[ 1 ].split( /\r\n|\r|\n/ ) ) { + + const propertyMatch = _propertyPattern.exec( line.trim() ); + + if ( propertyMatch === null ) continue; + + propertyNames.add( propertyMatch[ 1 ] ); + + if ( _restPropertyPattern.test( propertyMatch[ 1 ] ) ) restComponentCount ++; + + } + + if ( REQUIRED_PLY_PROPERTIES.some( name => ! propertyNames.has( name ) ) ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: PLY file requires position, scale, rotation, f_dc and opacity properties.' ); + + } + + const degree = SH_DEGREE_TO_COMPONENTS.indexOf( restComponentCount ); + + if ( degree === - 1 ) { + + throw new Error( `THREE.PLYGaussianSplatLoader: Unsupported number of f_rest spherical harmonics coefficients (${ restComponentCount }).` ); + + } + + return degree; + +} + +// Decodes only the bytes up to and including "end_header" as text, falling +// back to the full buffer if that marker isn't found, so this doesn't pay +// the cost of decoding the (potentially large) binary vertex data as text. +function decodeHeaderText( bytes ) { + + const marker = 'end_header'; + const scanLength = Math.min( bytes.length, 1024 * 1024 ); + let headerEnd = - 1; + + for ( let i = 0; i <= scanLength - marker.length; i ++ ) { + + let matches = true; + + for ( let j = 0; j < marker.length; j ++ ) { + + if ( bytes[ i + j ] !== marker.charCodeAt( j ) ) { + + matches = false; + break; + + } + + } + + if ( matches ) { + + headerEnd = i + marker.length; + break; + + } + + } + + const end = headerEnd === - 1 ? bytes.length : headerEnd; + + return new TextDecoder().decode( bytes.subarray( 0, end ) ); + +} + +// Builds the PLYLoader custom-property mapping for a given SH degree, +// grouping the raw "f_rest_N" scalar properties into one combined +// "f_rest" attribute when the degree calls for it. +function getPropertyMapping( sphericalHarmonicsDegree ) { + + const restComponentCount = SH_DEGREE_TO_COMPONENTS[ sphericalHarmonicsDegree ]; + + const mapping = { + scale: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.scale, + rotation: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.rotation, + f_dc: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.f_dc, + opacity: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.opacity + }; + + if ( restComponentCount > 0 ) { + + mapping.f_rest = Array.from( { length: restComponentCount }, ( _, i ) => `f_rest_${ i }` ); + + } + + return mapping; + +} + +// Converts the generic PLYLoader output - using the Gaussian splat custom +// property mapping above - into Gaussian splat geometry. +function convertPLYGeometry( geometry ) { + + const position = geometry.getAttribute( 'position' ); + const scale = geometry.getAttribute( 'scale' ); + const rotation = geometry.getAttribute( 'rotation' ); + const sh0 = geometry.getAttribute( 'f_dc' ); + const shRest = geometry.getAttribute( 'f_rest' ); + const opacity = geometry.getAttribute( 'opacity' ); + + if ( position === undefined || scale === undefined || rotation === undefined || sh0 === undefined || opacity === undefined ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: PLY file requires position, scale, rotation, f_dc and opacity properties.' ); + + } + + const count = position.count; + + if ( position.itemSize !== 3 || scale.itemSize !== 3 || rotation.itemSize !== 4 || sh0.itemSize !== 3 || opacity.itemSize !== 1 ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: Invalid Gaussian splat PLY property itemSize.' ); + + } + + if ( scale.count !== count || rotation.count !== count || sh0.count !== count || opacity.count !== count ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: Gaussian splat PLY property counts must match position.' ); + + } + + const centers = new Float32Array( count * 3 ); + const covariances = new Float32Array( count * 6 ); + const colors = new Uint8ClampedArray( count * 4 ); + const sphericalHarmonicsDegree = getRestSphericalHarmonicsDegree( shRest ); + const sphericalHarmonics = {}; + const sphericalHarmonicsBytes = {}; + + for ( let degree = 1; degree <= sphericalHarmonicsDegree; degree ++ ) { + + const band = createPackedSphericalHarmonicsBand( count, degree ); + sphericalHarmonics[ `sh${ degree }` ] = band.packed; + sphericalHarmonicsBytes[ `sh${ degree }` ] = band.bytes; + + } + + 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 ) ) + ); + + if ( sphericalHarmonicsDegree > 0 ) { + + writeSphericalHarmonicsFromRest( sphericalHarmonicsBytes, i, shRest ); + + } + + } + + return createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ); + +} + +function getRestSphericalHarmonicsDegree( shRest ) { + + if ( shRest === undefined ) return 0; + + const degree = SH_DEGREE_TO_COMPONENTS.indexOf( shRest.itemSize ); + + if ( degree === - 1 ) { + + throw new Error( 'THREE.PLYGaussianSplatLoader: Unsupported number of f_rest spherical harmonics coefficients.' ); + + } + + return degree; + +} + +function writeSphericalHarmonicsFromRest( sphericalHarmonicsBytes, index, shRest ) { + + const stride = shRest.itemSize / 3; + const source = shRest.array; + const sourceOffset = index * shRest.itemSize; + + for ( let degree = 1; degree <= 3; degree ++ ) { + + const target = sphericalHarmonicsBytes[ `sh${ degree }` ]; + + if ( target === undefined ) break; + + const bandOffset = degree === 1 ? 0 : degree === 2 ? 3 : 8; + const byteStride = SH_BAND_WORDS[ degree ] * 4; + const targetOffset = index * byteStride; + + for ( let j = 0; j < SH_BAND_COMPONENTS[ degree ]; j ++ ) { + + const coefficient = Math.floor( j / 3 ); + const channel = j % 3; + target[ targetOffset + j ] = source[ sourceOffset + bandOffset + coefficient + channel * stride ] * 128 + 128; + + } + + } + +} + +export { PLYGaussianSplatLoader }; diff --git a/examples/jsm/utils/GaussianSplatUtils.js b/examples/jsm/utils/GaussianSplatUtils.js index f24ed66110911e..9d4752a92b01ce 100644 --- a/examples/jsm/utils/GaussianSplatUtils.js +++ b/examples/jsm/utils/GaussianSplatUtils.js @@ -8,16 +8,9 @@ import { } from 'three'; const SH_C0 = 0.2820947917738781; -const SH_DEGREE_TO_COMPONENTS = [ 0, 9, 24, 45 ]; const SH_BAND_COMPONENTS = [ 0, 9, 15, 21 ]; // GPU upload packs four clamped-byte coefficients per uint32 word. const SH_BAND_WORDS = [ 0, 3, 4, 6 ]; -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' ] -}; const _covarianceMatrix = new Matrix3(); const _covarianceMatrixTranspose = new Matrix3(); @@ -89,33 +82,6 @@ function writeCovariance( target, offset, sx, sy, sz, qx, qy, qz, qw ) { } -function getGaussianSplatPLYPropertyMapping( sphericalHarmonicsDegree = 0 ) { - - const restComponentCount = SH_DEGREE_TO_COMPONENTS[ sphericalHarmonicsDegree ]; - - if ( restComponentCount === undefined ) { - - throw new Error( `THREE.getGaussianSplatPLYPropertyMapping: Unsupported spherical harmonics degree ${ sphericalHarmonicsDegree }.` ); - - } - - const mapping = { - scale: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.scale, - rotation: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.rotation, - f_dc: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.f_dc, - opacity: GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING.opacity - }; - - if ( restComponentCount > 0 ) { - - mapping.f_rest = Array.from( { length: restComponentCount }, ( _, i ) => `f_rest_${ i }` ); - - } - - return mapping; - -} - function createPackedSphericalHarmonicsBand( count, degree ) { const packed = new Uint32Array( count * SH_BAND_WORDS[ degree ] ); @@ -247,155 +213,12 @@ function createGaussianSplatGeometry( centers, covariances, colors, sphericalHar } -function createGaussianSplatGeometryFromPLYGeometry( geometry, { - scaleAttribute = 'scale', - rotationAttribute = 'rotation', - sh0Attribute = 'f_dc', - shRestAttribute = 'f_rest', - 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 shRest = geometry.getAttribute( shRestAttribute ); - 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 Uint8ClampedArray( count * 4 ); - const sphericalHarmonicsDegree = getPLYRestSphericalHarmonicsDegree( shRest ); - const sphericalHarmonics = {}; - const sphericalHarmonicsBytes = {}; - - for ( let degree = 1; degree <= sphericalHarmonicsDegree; degree ++ ) { - - const band = createPackedSphericalHarmonicsBand( count, degree ); - sphericalHarmonics[ `sh${ degree }` ] = band.packed; - sphericalHarmonicsBytes[ `sh${ degree }` ] = band.bytes; - - } - - 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 ) ) - ); - - if ( sphericalHarmonicsDegree > 0 ) { - - writeSphericalHarmonicsFromPLYRest( sphericalHarmonicsBytes, i, shRest ); - - } - - } - - return createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ); - -} - -function getPLYRestSphericalHarmonicsDegree( shRest ) { - - if ( shRest === undefined ) return 0; - - const degree = SH_DEGREE_TO_COMPONENTS.indexOf( shRest.itemSize ); - - if ( degree === - 1 ) { - - throw new Error( 'THREE.createGaussianSplatGeometryFromPLYGeometry: Unsupported number of f_rest spherical harmonics coefficients.' ); - - } - - return degree; - -} - -function writeSphericalHarmonicsFromPLYRest( sphericalHarmonicsBytes, index, shRest ) { - - const stride = shRest.itemSize / 3; - const source = shRest.array; - const sourceOffset = index * shRest.itemSize; - - for ( let degree = 1; degree <= 3; degree ++ ) { - - const target = sphericalHarmonicsBytes[ `sh${ degree }` ]; - - if ( target === undefined ) break; - - const bandOffset = degree === 1 ? 0 : degree === 2 ? 3 : 8; - const byteStride = SH_BAND_WORDS[ degree ] * 4; - const targetOffset = index * byteStride; - - for ( let j = 0; j < SH_BAND_COMPONENTS[ degree ]; j ++ ) { - - const coefficient = Math.floor( j / 3 ); - const channel = j % 3; - target[ targetOffset + j ] = source[ sourceOffset + bandOffset + coefficient + channel * stride ] * 128 + 128; - - } - - } - -} - export { - GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING, SH_BAND_COMPONENTS, SH_BAND_WORDS, SH_C0, - SH_DEGREE_TO_COMPONENTS, createGaussianSplatGeometry, - createGaussianSplatGeometryFromPLYGeometry, createPackedSphericalHarmonicsBand, - getGaussianSplatPLYPropertyMapping, getSphericalHarmonicsDegree, linearToSH0, sh0ToLinear, diff --git a/test/unit/addons/loaders/PLYGaussianSplatLoader.tests.js b/test/unit/addons/loaders/PLYGaussianSplatLoader.tests.js new file mode 100644 index 00000000000000..00798be1e67de6 --- /dev/null +++ b/test/unit/addons/loaders/PLYGaussianSplatLoader.tests.js @@ -0,0 +1,150 @@ +import { BufferGeometry } from 'three'; + +import { PLYGaussianSplatLoader } from '../../../../examples/jsm/loaders/PLYGaussianSplatLoader.js'; +import { unpackSphericalHarmonicsBand } from '../utils/GaussianSplatTestUtils.js'; + +const EPS = 1e-6; + +function closeTo( assert, actual, expected, message ) { + + assert.ok( Math.abs( actual - expected ) < EPS, `${ message }: ${ actual } ~= ${ expected }` ); + +} + +function createGaussianSplatPLY( shRestCoefficientCount = 0 ) { + + const properties = [ + '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' + ]; + + for ( let i = 0; i < shRestCoefficientCount; i ++ ) { + + properties.push( `property float f_rest_${ i }` ); + + } + + const values = [ + 1, 2, 3, + Math.log( 2 ), Math.log( 3 ), Math.log( 4 ), + 1, 0, 0, 0, + 0, 0, 0, + 0 + ]; + + for ( let i = 0; i < shRestCoefficientCount; i ++ ) { + + values.push( i / 128 ); + + } + + return [ + 'ply', + 'format ascii 1.0', + 'element vertex 1', + ...properties, + 'end_header', + values.join( ' ' ) + ].join( '\n' ); + +} + +export default QUnit.module( 'Addons', () => { + + QUnit.module( 'Loaders', () => { + + QUnit.module( 'PLYGaussianSplatLoader', () => { + + QUnit.test( 'parses a Gaussian splat PLY with no spherical harmonics', ( assert ) => { + + const loader = new PLYGaussianSplatLoader(); + const data = loader.parse( createGaussianSplatPLY() ); + const covariances = data.getAttribute( 'covariance' ).array; + + assert.ok( data instanceof BufferGeometry, 'returns BufferGeometry' ); + 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( 'detects spherical harmonics degree from the header and converts f_rest', ( assert ) => { + + const loader = new PLYGaussianSplatLoader(); + const data = loader.parse( createGaussianSplatPLY( 9 ) ); + + assert.deepEqual( + Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), + [ 128, 131, 134, 129, 132, 135, 130, 133, 136 ], + 'channel-blocked coefficients are remapped to RGB triplets' + ); + + } ); + + QUnit.test( 'requires no pre-setup for each supported spherical harmonics degree', ( assert ) => { + + const loader = new PLYGaussianSplatLoader(); + + for ( const [ degree, count ] of [[ 0, 0 ], [ 1, 9 ], [ 2, 24 ], [ 3, 45 ]] ) { + + const data = loader.parse( createGaussianSplatPLY( count ) ); + assert.strictEqual( data.getAttribute( 'position' ).count, 1, `degree ${ degree } parses` ); + + } + + } ); + + QUnit.test( 'rejects an unsupported number of f_rest coefficients', ( assert ) => { + + const loader = new PLYGaussianSplatLoader(); + + assert.throws( + () => loader.parse( createGaussianSplatPLY( 5 ) ), + /Unsupported number of f_rest spherical harmonics coefficients/, + 'non-table f_rest counts are rejected' + ); + + } ); + + QUnit.test( 'rejects PLY data missing required Gaussian splat properties', ( assert ) => { + + const ply = [ + 'ply', + 'format ascii 1.0', + 'element vertex 1', + 'property float x', + 'property float y', + 'property float z', + 'end_header', + '1 2 3' + ].join( '\n' ); + + const loader = new PLYGaussianSplatLoader(); + + assert.throws( + () => loader.parse( ply ), + /requires position, scale, rotation, f_dc and opacity properties/, + 'missing properties are rejected' + ); + + } ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/utils/GaussianSplatUtils.tests.js b/test/unit/addons/utils/GaussianSplatUtils.tests.js index 44297809736140..365a580b47a119 100644 --- a/test/unit/addons/utils/GaussianSplatUtils.tests.js +++ b/test/unit/addons/utils/GaussianSplatUtils.tests.js @@ -1,17 +1,7 @@ import { - BufferAttribute, - BufferGeometry -} from 'three'; - -import { PLYLoader } from '../../../../examples/jsm/loaders/PLYLoader.js'; - -import { - GAUSSIAN_SPLAT_PLY_PROPERTY_MAPPING, SH_BAND_COMPONENTS, SH_BAND_WORDS, createGaussianSplatGeometry, - createGaussianSplatGeometryFromPLYGeometry, - getGaussianSplatPLYPropertyMapping, getSphericalHarmonicsDegree, linearToSH0, sh0ToLinear, @@ -141,131 +131,6 @@ export default QUnit.module( 'Addons', () => { } ); - 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( 'converts PLY f_rest attributes into spherical harmonics', ( 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', - 'property float f_rest_0', - 'property float f_rest_1', - 'property float f_rest_2', - 'property float f_rest_3', - 'property float f_rest_4', - 'property float f_rest_5', - 'property float f_rest_6', - 'property float f_rest_7', - 'property float f_rest_8', - 'end_header', - `1 2 3 ${ Math.log( 2 ) } ${ Math.log( 3 ) } ${ Math.log( 4 ) } 1 0 0 0 0 0 0 0 ${ Array.from( { length: 9 }, ( _, i ) => i / 128 ).join( ' ' ) }` - ].join( '\n' ); - - const loader = new PLYLoader(); - loader.setCustomPropertyNameMapping( getGaussianSplatPLYPropertyMapping( 1 ) ); - - const geometry = loader.parse( ply ); - const data = createGaussianSplatGeometryFromPLYGeometry( geometry ); - - assert.deepEqual( - Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), - [ 128, 131, 134, 129, 132, 135, 130, 133, 136 ], - 'channel-blocked coefficients are remapped to RGB triplets' - ); - - } ); - - 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 b7f857b6b5c174..ec36511f48a17a 100644 --- a/test/unit/three.addons.unit.js +++ b/test/unit/three.addons.unit.js @@ -9,6 +9,7 @@ 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/PLYGaussianSplatLoader.tests.js'; import './addons/loaders/SPLATLoader.tests.js'; import './addons/loaders/SPZLoader.tests.js'; import './addons/loaders/USDLoader.tests.js';