diff --git a/examples/jsm/exporters/GLTFExporter.js b/examples/jsm/exporters/GLTFExporter.js index dd18fff8e84d59..d8ea987eeab1b9 100644 --- a/examples/jsm/exporters/GLTFExporter.js +++ b/examples/jsm/exporters/GLTFExporter.js @@ -19,8 +19,8 @@ import { RGBAFormat, RepeatWrapping, Scene, - Source, SRGBColorSpace, + TextureSource, CompressedTexture, Vector3, Quaternion, @@ -1033,7 +1033,7 @@ class GLTFWriter { const texture = reference.clone(); - texture.source = new Source( canvas ); + texture.source = new TextureSource( canvas ); texture.colorSpace = NoColorSpace; texture.channel = ( metalnessMap || roughnessMap ).channel; @@ -1095,7 +1095,7 @@ class GLTFWriter { context.putImageData( imageData, 0, 0 ); const texture = normalMap.clone(); - texture.source = new Source( canvas ); + texture.source = new TextureSource( canvas ); return texture; diff --git a/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js b/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js index e7b84bd5fd824d..7c115410b8543b 100644 --- a/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js +++ b/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js @@ -3,7 +3,7 @@ import { } from 'three'; import { GaussianSplatMesh } from '../objects/GaussianSplatMesh.js'; -import { createGaussianSplatGeometry, writeColorBytesFromSH0, writeCovariance } from '../utils/GaussianSplatUtils.js'; +import { SH_BAND_WORDS, createGaussianSplatGeometry, createPackedSphericalHarmonicsBand, writeColorBytesFromSH0, writeCovariance } from '../utils/GaussianSplatUtils.js'; const EXTENSION_NAME = 'KHR_gaussian_splatting'; const POINTS = 0; @@ -158,20 +158,10 @@ function createGaussianSplatMesh( geometry, primitiveDef ) { } - 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 Uint8ClampedArray( count * 4 ); + const sphericalHarmonics = createGLTFSphericalHarmonicsAttributes( geometry, primitiveDef, count ); for ( let i = 0; i < count; i ++ ) { @@ -204,7 +194,7 @@ function createGaussianSplatMesh( geometry, primitiveDef ) { } - const mesh = new GaussianSplatMesh( createGaussianSplatGeometry( centers, covariances, colors ) ); + const mesh = new GaussianSplatMesh( createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ) ); mesh.userData.gltfExtensions = mesh.userData.gltfExtensions || {}; mesh.userData.gltfExtensions[ EXTENSION_NAME ] = Object.assign( {}, extensionDef ); @@ -213,6 +203,81 @@ function createGaussianSplatMesh( geometry, primitiveDef ) { } +function createGLTFSphericalHarmonicsAttributes( geometry, primitiveDef, count ) { + + const sphericalHarmonics = {}; + + for ( let degree = 1; degree <= 3; degree ++ ) { + + const coefficientCount = degree * 2 + 1; + const attributes = []; + + for ( let coefficient = 0; coefficient < coefficientCount; coefficient ++ ) { + + const semantic = `KHR_gaussian_splatting:SH_DEGREE_${ degree }_COEF_${ coefficient }`; + const attribute = getOptionalGaussianSplatAttribute( geometry, primitiveDef, semantic ); + + if ( attribute !== undefined ) { + + if ( attribute.count !== count || attribute.itemSize !== 3 ) { + + throw new Error( `THREE.GLTFGaussianSplatLoaderExtension: Invalid ${ semantic } attribute.` ); + + } + + } + + attributes.push( attribute ); + + } + + if ( attributes.every( attribute => attribute === undefined ) ) break; + + if ( attributes.some( attribute => attribute === undefined ) ) { + + throw new Error( `THREE.GLTFGaussianSplatLoaderExtension: Incomplete KHR_gaussian_splatting SH degree ${ degree } coefficients.` ); + + } + + const band = createPackedSphericalHarmonicsBand( count, degree ); + const target = band.bytes; + const byteStride = SH_BAND_WORDS[ degree ] * 4; + + for ( let i = 0; i < count; i ++ ) { + + for ( let coefficient = 0; coefficient < coefficientCount; coefficient ++ ) { + + const attribute = attributes[ coefficient ]; + const targetOffset = i * byteStride + coefficient * 3; + + target[ targetOffset ] = attribute.getX( i ) * 128 + 128; + target[ targetOffset + 1 ] = attribute.getY( i ) * 128 + 128; + target[ targetOffset + 2 ] = attribute.getZ( i ) * 128 + 128; + + } + + } + + sphericalHarmonics[ `sh${ degree }` ] = band.packed; + + } + + for ( const semantic in primitiveDef.attributes ) { + + const match = semantic.match( /^KHR_gaussian_splatting:SH_DEGREE_([1-3])_COEF_/ ); + + if ( match !== null && sphericalHarmonics[ `sh${ match[ 1 ] }` ] === undefined ) { + + throw new Error( 'THREE.GLTFGaussianSplatLoaderExtension: KHR_gaussian_splatting spherical harmonics attributes must be contiguous.' ); + + } + + } + + return sphericalHarmonics; + +} + function getGaussianSplatAttribute( geometry, primitiveDef, semantic ) { if ( primitiveDef.attributes[ semantic ] === undefined ) { @@ -234,6 +299,16 @@ function getGaussianSplatAttribute( geometry, primitiveDef, semantic ) { } +function getOptionalGaussianSplatAttribute( geometry, primitiveDef, semantic ) { + + if ( primitiveDef.attributes[ semantic ] === undefined ) return undefined; + + const attributeName = ATTRIBUTES[ semantic ] || semantic.toLowerCase(); + + return geometry.getAttribute( attributeName ); + +} + function assignExtrasToUserData( object, gltfDef ) { if ( gltfDef.extras !== undefined ) { diff --git a/examples/jsm/loaders/KSPLATLoader.js b/examples/jsm/loaders/KSPLATLoader.js index bb4e84a6aa3f0b..4e32ab487150c2 100644 --- a/examples/jsm/loaders/KSPLATLoader.js +++ b/examples/jsm/loaders/KSPLATLoader.js @@ -4,7 +4,7 @@ import { Loader } from 'three'; -import { createGaussianSplatGeometry, writeColorBytes, writeCovariance } from '../utils/GaussianSplatUtils.js'; +import { SH_BAND_COMPONENTS, SH_BAND_WORDS, createGaussianSplatGeometry, createPackedSphericalHarmonicsBand, writeColorBytes, writeCovariance } from '../utils/GaussianSplatUtils.js'; const HEADER_SIZE_BYTES = 4096; const SECTION_HEADER_SIZE_BYTES = 1024; @@ -12,6 +12,12 @@ const CURRENT_VERSION_MAJOR = 0; const CURRENT_VERSION_MINOR = 1; const MAX_SPLATS = 10000000; const SH_DEGREE_TO_COMPONENTS = [ 0, 9, 24, 45 ]; +const SH_BAND_INDEX = [ + null, + [ 0, 3, 6, 1, 4, 7, 2, 5, 8 ], + [ 9, 14, 19, 10, 15, 20, 11, 16, 21, 12, 17, 22, 13, 18, 23 ], + [ 24, 31, 38, 25, 32, 39, 26, 33, 40, 27, 34, 41, 28, 35, 42, 29, 36, 43, 30, 37, 44 ] +]; const COMPRESSION_LEVELS = { 0: { bytesPerCenter: 12, @@ -52,8 +58,10 @@ const COMPRESSION_LEVELS = { * 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. + * `GaussianSplatMesh`. Higher-order spherical harmonics are exposed as optional + * `sphericalHarmonics1` through `sphericalHarmonics3` packed uint32 geometry + * attributes (`SH_BAND_WORDS[ degree ]` words per splat). Coefficients use the + * clamped-byte encoding `( value - 128 ) / 128`, four bytes per word. * * ```js * const loader = new KSPLATLoader(); @@ -170,6 +178,8 @@ class KSPLATLoader extends Loader { const centers = new Float32Array( header.splatCount * 3 ); const covariances = new Float32Array( header.splatCount * 6 ); const colors = new Uint8ClampedArray( header.splatCount * 4 ); + const sphericalHarmonics = {}; + const sphericalHarmonicsBytes = {}; let splatOffset = 0; let sectionBase = sectionDataOffset; @@ -212,7 +222,10 @@ class KSPLATLoader extends Loader { splatOffset, centers, covariances, - colors + colors, + sphericalHarmonics, + sphericalHarmonicsBytes, + header ); splatOffset += section.splatCount; @@ -229,7 +242,7 @@ class KSPLATLoader extends Loader { } - return createGaussianSplatGeometry( centers, covariances, colors ); + return createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ); } @@ -244,7 +257,9 @@ function parseHeader( view ) { sectionCount: view.getUint32( 8, true ), maxSplatCount: view.getUint32( 12, true ), splatCount: view.getUint32( 16, true ), - compressionLevel: view.getUint16( 20, true ) + compressionLevel: view.getUint16( 20, true ), + minSphericalHarmonicsCoeff: view.getFloat32( 36, true ) || - 1.5, + maxSphericalHarmonicsCoeff: view.getFloat32( 40, true ) || 1.5 }; } @@ -266,15 +281,18 @@ function parseSectionHeader( view, offset, compression ) { } -function readSection( view, bytes, section, compression, sectionBase, bucketsMetaDataSizeBytes, bucketsStorageSizeBytes, bytesPerSplat, splatOffset, centers, covariances, colors ) { +function readSection( view, bytes, section, compression, sectionBase, bucketsMetaDataSizeBytes, bucketsStorageSizeBytes, bytesPerSplat, splatOffset, centers, covariances, colors, sphericalHarmonics, sphericalHarmonicsBytes, header ) { const bucketsBase = sectionBase + bucketsMetaDataSizeBytes; const dataBase = sectionBase + bucketsStorageSizeBytes; const fullBucketSplats = section.fullBucketCount * section.bucketSize; const compressionScaleFactor = section.bucketBlockSize / 2 / section.compressionScaleRange; + const sphericalHarmonicsOffset = compression.colorOffsetBytes + compression.bytesPerColor; let partialBucketIndex = section.fullBucketCount; let partialBucketBase = fullBucketSplats; + ensureSphericalHarmonics( sphericalHarmonics, sphericalHarmonicsBytes, header.splatCount, section.sphericalHarmonicsDegree ); + for ( let i = 0; i < section.splatCount; i ++ ) { const bucketIndex = getBucketIndex( view, section, sectionBase, i, fullBucketSplats, partialBucketIndex, partialBucketBase ); @@ -323,6 +341,55 @@ function readSection( view, bytes, section, compression, sectionBase, bucketsMet bytes[ rowOffset + compression.colorOffsetBytes + 3 ] ); + for ( let degree = 1; degree <= section.sphericalHarmonicsDegree; degree ++ ) { + + writeKSPLATSphericalHarmonicsBand( + sphericalHarmonicsBytes[ `sh${ degree }` ], + outIndex, + SH_BAND_COMPONENTS[ degree ], + SH_BAND_WORDS[ degree ] * 4, + SH_BAND_INDEX[ degree ], + view, + rowOffset + sphericalHarmonicsOffset, + compression.bytesPerSphericalHarmonicsComponent, + header + ); + + } + + } + +} + +function ensureSphericalHarmonics( sphericalHarmonics, sphericalHarmonicsBytes, count, degree ) { + + for ( let i = 1; i <= degree; i ++ ) { + + if ( sphericalHarmonics[ `sh${ i }` ] === undefined ) { + + const band = createPackedSphericalHarmonicsBand( count, i ); + sphericalHarmonics[ `sh${ i }` ] = band.packed; + sphericalHarmonicsBytes[ `sh${ i }` ] = band.bytes; + + } + + } + +} + +function writeKSPLATSphericalHarmonicsBand( target, index, bandComponents, byteStride, componentIndexes, view, rowOffset, bytesPerComponent, header ) { + + const targetOffset = index * byteStride; + + for ( let i = 0; i < bandComponents; i ++ ) { + + target[ targetOffset + i ] = readCompressedSphericalHarmonic( + view, + rowOffset + componentIndexes[ i ] * bytesPerComponent, + bytesPerComponent, + header + ) * 128 + 128; + } } @@ -373,4 +440,24 @@ function readCompressedFloat( view, offset, bytesPerVector ) { } +function readCompressedSphericalHarmonic( view, offset, bytesPerComponent, header ) { + + if ( bytesPerComponent === 4 ) { + + return view.getFloat32( offset, true ); + + } + + if ( bytesPerComponent === 2 ) { + + return DataUtils.fromHalfFloat( view.getUint16( offset, true ) ); + + } + + const t = view.getUint8( offset ) / 255; + + return header.minSphericalHarmonicsCoeff + t * ( header.maxSphericalHarmonicsCoeff - header.minSphericalHarmonicsCoeff ); + +} + export { KSPLATLoader }; diff --git a/examples/jsm/loaders/SPZLoader.js b/examples/jsm/loaders/SPZLoader.js index 67b9eaca7f356b..2c599edd25d352 100644 --- a/examples/jsm/loaders/SPZLoader.js +++ b/examples/jsm/loaders/SPZLoader.js @@ -5,7 +5,7 @@ import { } from 'three'; import { gunzipSync } from '../libs/fflate.module.js'; -import { SH_C0, createGaussianSplatGeometry, writeCovariance } from '../utils/GaussianSplatUtils.js'; +import { SH_BAND_COMPONENTS, SH_BAND_WORDS, SH_C0, createGaussianSplatGeometry, createPackedSphericalHarmonicsBand, writeCovariance } from '../utils/GaussianSplatUtils.js'; const SPZ_MAGIC = 0x5053474e; const HEADER_SIZE_BYTES = 16; @@ -45,8 +45,10 @@ const _quaternion = [ 0, 0, 0, 0 ]; * 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. + * `GaussianSplatMesh`. Higher-order spherical harmonics are exposed as optional + * `sphericalHarmonics1` through `sphericalHarmonics3` packed uint32 geometry + * attributes (`SH_BAND_WORDS[ degree ]` words per splat). Coefficients use the + * clamped-byte encoding `( value - 128 ) / 128`, four bytes per word. * * ```js * const loader = new SPZLoader(); @@ -122,6 +124,18 @@ class SPZLoader extends Loader { */ parse( buffer ) { + if ( buffer.byteLength >= 8 ) { + + const view = new DataView( buffer ); + + if ( view.getUint32( 0, true ) === SPZ_MAGIC && view.getUint32( 4, true ) >= 4 ) { + + throw new Error( `THREE.SPZLoader: SPZ version ${ view.getUint32( 4, true ) } is not supported.` ); + + } + + } + const decompressed = gunzipSync( new Uint8Array( buffer ) ); return this.parseRawSPZ( decompressed ); @@ -158,7 +172,7 @@ class SPZLoader extends Loader { if ( version < 1 || version > 3 ) { - throw new Error( `THREE.SPZLoader: Unsupported SPZ version ${ version }.` ); + throw new Error( `THREE.SPZLoader: SPZ version ${ version } is not supported.` ); } @@ -178,6 +192,7 @@ class SPZLoader extends Loader { const centers = new Float32Array( count * 3 ); const covariances = new Float32Array( count * 6 ); const colors = new Uint8ClampedArray( count * 4 ); + const sphericalHarmonics = {}; const positionsSize = count * 3 * ( version === 1 ? 2 : 3 ); const rotationsSize = count * ( version === 3 ? 4 : 3 ); const shSize = count * SH_DEGREE_TO_VECTORS[ shDegree ] * 3; @@ -202,6 +217,7 @@ class SPZLoader extends Loader { offset += count * 3; const rotationOffset = offset; + const sphericalHarmonicsOffset = rotationOffset + rotationsSize; // Copy the rotation section into an aligned Uint32Array so the hot loop // avoids per-splat DataView reads (the section offset within the file is @@ -239,7 +255,46 @@ class SPZLoader extends Loader { } - return createGaussianSplatGeometry( centers, covariances, colors ); + readSphericalHarmonics( bytes, sphericalHarmonicsOffset, count, shDegree, sphericalHarmonics ); + + return createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ); + + } + +} + +function readSphericalHarmonics( bytes, offset, count, degree, sphericalHarmonics ) { + + if ( degree === 0 ) return; + + const bands = []; + + for ( let band = 1; band <= degree; band ++ ) { + + const packed = createPackedSphericalHarmonicsBand( count, band ); + sphericalHarmonics[ `sh${ band }` ] = packed.packed; + bands.push( { + bytes: packed.bytes, + components: SH_BAND_COMPONENTS[ band ], + stride: SH_BAND_WORDS[ band ] * 4 + } ); + + } + + for ( let i = 0; i < count; i ++ ) { + + for ( let bandIndex = 0; bandIndex < bands.length; bandIndex ++ ) { + + const band = bands[ bandIndex ]; + const targetOffset = i * band.stride; + + for ( let j = 0; j < band.components; j ++ ) { + + band.bytes[ targetOffset + j ] = bytes[ offset ++ ]; + + } + + } } diff --git a/examples/jsm/objects/GaussianSplatMesh.js b/examples/jsm/objects/GaussianSplatMesh.js index 78ee14fe189268..ab53fc74c4f713 100644 --- a/examples/jsm/objects/GaussianSplatMesh.js +++ b/examples/jsm/objects/GaussianSplatMesh.js @@ -23,6 +23,7 @@ import { instanceIndex, max, min, + normalize, positionGeometry, screenSize, sin, @@ -37,6 +38,11 @@ import { } from 'three/tsl'; import { CountingSort } from '../gpgpu/CountingSort.js'; +import { + SH_BAND_COMPONENTS, + SH_BAND_WORDS, + getSphericalHarmonicsDegree +} from '../utils/GaussianSplatUtils.js'; const BIN_COUNT = 4096; const WORKGROUP_SIZE = 256; @@ -52,6 +58,7 @@ const _worldScale = /*@__PURE__*/ new Vector3(); const _cameraPosition = /*@__PURE__*/ new Vector3(); const _cameraDirection = /*@__PURE__*/ new Vector3(); const _sortDepthRange = /*@__PURE__*/ new Vector2(); +const _worldMatrixInverse = /*@__PURE__*/ new Matrix4(); /** * A minimal renderer for 3D Gaussian splat geometry. @@ -74,7 +81,7 @@ class GaussianSplatMesh extends Mesh { /** * Constructs a new Gaussian splat mesh. * - * @param {BufferGeometry} splatGeometry - The splat geometry to render. + * @param {BufferGeometry} splatGeometry - The splat geometry to render. Higher-order spherical harmonics attributes must use packed `Uint32Array` words from {@link createGaussianSplatGeometry} (`SH_BAND_WORDS[ degree ]` words per splat, four clamped-byte coefficients per word). * @param {Object} [options] - Options. * @param {boolean} [options.autoSort=true] - Whether to sort automatically in `onBeforeRender`. */ @@ -83,15 +90,24 @@ class GaussianSplatMesh extends Mesh { const positionAttribute = splatGeometry.getAttribute( 'position' ); const covarianceAttribute = splatGeometry.getAttribute( 'covariance' ); const colorAttribute = splatGeometry.getAttribute( 'color' ); + const sphericalHarmonicsDegree = getSphericalHarmonicsDegree( splatGeometry ); 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 buffers = createStorageBuffers( count, positionAttribute.array, covarianceAttribute.array, colorAttribute.array, { + degree: sphericalHarmonicsDegree, + sh1: sphericalHarmonicsDegree >= 1 ? splatGeometry.getAttribute( 'sphericalHarmonics1' ).array : undefined, + sh2: sphericalHarmonicsDegree >= 2 ? splatGeometry.getAttribute( 'sphericalHarmonics2' ).array : undefined, + sh3: sphericalHarmonicsDegree >= 3 ? splatGeometry.getAttribute( 'sphericalHarmonics3' ).array : undefined + } ); + const localCameraPosition = uniform( new Vector3() ); + const sphericalHarmonicsComputeNode = createSphericalHarmonicsComputeNode( buffers, localCameraPosition ); const sort = new CountingSort( count, { binCount: BIN_COUNT, workgroupSize: WORKGROUP_SIZE } ); - const material = createMaterial( buffers, sort ); + const materialNodes = createMaterialNodes( buffers, sort, localCameraPosition ); + const material = createMaterial( materialNodes.vertexNode, materialNodes.fragmentNode ); super( geometry, material ); @@ -129,6 +145,13 @@ class GaussianSplatMesh extends Mesh { this._sortInitialized = false; this._lastSortPosition = new Vector3( Infinity, Infinity, Infinity ); this._lastSortDirection = new Vector3( 0, 0, - 1 ); + this._localCameraPosition = localCameraPosition; + this._sphericalHarmonicsComputeNode = sphericalHarmonicsComputeNode; + this._sphericalHarmonicsInitialized = false; + this._lastSphericalHarmonicsCameraMatrix = new Matrix4(); + this._lastSphericalHarmonicsWorldMatrix = new Matrix4(); + this._sphericalHarmonicsVertexNode = materialNodes.sphericalHarmonicsVertexNode; + this._precomputedSphericalHarmonicsVertexNode = materialNodes.vertexNode; this._positionAttribute = positionAttribute; const centerRead = buffers.centerRead; @@ -150,6 +173,19 @@ class GaussianSplatMesh extends Mesh { this.onBeforeRender = ( renderer, scene, camera ) => { + const vertexNode = renderer.backend && renderer.backend.isWebGLBackend === true ? + this._sphericalHarmonicsVertexNode : + this._precomputedSphericalHarmonicsVertexNode; + + if ( vertexNode !== null && material.vertexNode !== vertexNode ) { + + material.vertexNode = vertexNode; + material.needsUpdate = true; + + } + + this.updateSphericalHarmonics( renderer, camera ); + if ( this.autoSort === true ) { this.updateSort( renderer, camera ); @@ -160,6 +196,51 @@ class GaussianSplatMesh extends Mesh { } + /** + * Updates the view-dependent spherical harmonics colors if the camera or + * mesh transform has changed. + * + * @param {Renderer} renderer - The renderer. + * @param {Camera} camera - The camera used for rendering. + * @return {boolean} Whether a compute pass was dispatched this call. + */ + updateSphericalHarmonics( renderer, camera ) { + + if ( this._sphericalHarmonicsComputeNode === null ) return false; + + const isWebGLBackend = renderer.backend && renderer.backend.isWebGLBackend === true; + + if ( this._sphericalHarmonicsInitialized === true && + camera.matrixWorld.equals( this._lastSphericalHarmonicsCameraMatrix ) && + this.matrixWorld.equals( this._lastSphericalHarmonicsWorldMatrix ) && + ( isWebGLBackend === true || this._buffers.sphericalHarmonicsContributionRead !== undefined ) ) { + + return false; + + } + + if ( isWebGLBackend === true ) { + + enableWebGLBuffers( this._buffers ); + + } + + _worldMatrixInverse.copy( this.matrixWorld ).invert(); + this._localCameraPosition.value.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _worldMatrixInverse ); + + this._lastSphericalHarmonicsCameraMatrix.copy( camera.matrixWorld ); + this._lastSphericalHarmonicsWorldMatrix.copy( this.matrixWorld ); + this._sphericalHarmonicsInitialized = true; + + if ( isWebGLBackend === true ) return false; + + ensureSphericalHarmonicsContributionBuffer( this._buffers ); + renderer.compute( this._sphericalHarmonicsComputeNode ); + + return true; + + } + /** * Updates the draw order if the camera has moved enough to need a new sort. * @@ -275,12 +356,13 @@ function createGeometry( count ) { } -function createStorageBuffers( count, centers, covariances, colors ) { +function createStorageBuffers( count, centers, covariances, colors, sphericalHarmonics ) { const centerData = new Float32Array( count * 4 ); const covarianceAData = new Float32Array( count * 4 ); const covarianceBData = new Float32Array( count * 4 ); const colorData = new Float32Array( count * 4 ); + const sphericalHarmonicsDegree = sphericalHarmonics.degree; for ( let i = 0; i < count; i ++ ) { @@ -312,8 +394,9 @@ function createStorageBuffers( count, centers, covariances, colors ) { const covarianceBAttribute = new StorageBufferAttribute( covarianceBData, 4 ); const colorAttribute = new StorageBufferAttribute( colorData, 4 ); - return { + const buffers = { count, + sphericalHarmonicsDegree, webGLBuffersEnabled: false, centerRead: storage( centerAttribute, 'vec4', count ).toReadOnly(), covarianceARead: storage( covarianceAAttribute, 'vec4', count ).toReadOnly(), @@ -321,6 +404,34 @@ function createStorageBuffers( count, centers, covariances, colors ) { colorRead: storage( colorAttribute, 'vec4', count ).toReadOnly() }; + for ( let degree = 1; degree <= sphericalHarmonicsDegree; degree ++ ) { + + const words = SH_BAND_WORDS[ degree ]; + const attribute = new StorageBufferAttribute( sphericalHarmonics[ `sh${ degree }` ], 1 ); + + buffers[ `sphericalHarmonics${ degree }Attribute` ] = attribute; + buffers[ `sphericalHarmonics${ degree }Read` ] = storage( attribute, 'uint', count * words ).toReadOnly(); + buffers[ `sphericalHarmonics${ degree }Words` ] = words; + + } + + return buffers; + +} + +function ensureSphericalHarmonicsContributionBuffer( buffers ) { + + if ( buffers.sphericalHarmonicsContributionRead !== undefined ) return; + + // WebGPU stores one precomputed SH contribution per splat. The WebGL + // fallback evaluates SH in the vertex shader because its transform-feedback + // compute path cannot perform the packed buffer's indexed reads, so allocate + // this additional buffer lazily only when the WebGPU pre-pass runs. + const attribute = new StorageBufferAttribute( new Float32Array( buffers.count * 4 ), 4 ); + + buffers.sphericalHarmonicsContributionRead = storage( attribute, 'vec4', buffers.count ).toReadOnly(); + buffers.sphericalHarmonicsContributionWrite = storage( attribute, 'vec4', buffers.count ); + } function enableWebGLBuffers( buffers ) { @@ -331,22 +442,197 @@ function enableWebGLBuffers( buffers ) { buffers.covarianceARead.setPBO( true ); buffers.covarianceBRead.setPBO( true ); buffers.colorRead.setPBO( true ); + + for ( let degree = 1; degree <= buffers.sphericalHarmonicsDegree; degree ++ ) { + + buffers[ `sphericalHarmonics${ degree }Read` ].setPBO( true ); + + } + buffers.webGLBuffersEnabled = true; } -function createMaterial( buffers, sort ) { +function unpackSphericalHarmonicsCoefficients( buffer, splatIndex, words, componentCount ) { + + const coefficients = []; + let remaining = componentCount; + + for ( let word = 0; word < words && remaining > 0; word ++ ) { + + const packed = buffer.element( splatIndex.mul( words ).add( word ) ).toVar(); + const bytesInWord = Math.min( 4, remaining ); + + for ( let byteIndex = 0; byteIndex < bytesInWord; byteIndex ++ ) { + + const byte = packed.shiftRight( byteIndex * 8 ).bitAnd( 0xff ); + coefficients.push( float( byte ).sub( 128 ).div( 128 ) ); + + } + + remaining -= bytesInWord; + + } + + return coefficients; + +} + +function assembleSphericalHarmonicsVectors( coefficients, name ) { + + const vectors = []; + const vectorCount = coefficients.length / 3; + + for ( let i = 0; i < vectorCount; i ++ ) { + + const offset = i * 3; + vectors.push( vec3( + coefficients[ offset ], + coefficients[ offset + 1 ], + coefficients[ offset + 2 ] + ).toVar( `${ name }${ i }` ) ); + + } + + return vectors; + +} + +function accumulateSphericalHarmonics( vectors, weights ) { + + let result = vectors[ 0 ].mul( weights[ 0 ] ); + + for ( let i = 1; i < vectors.length; i ++ ) { + + result = result.add( vectors[ i ].mul( weights[ i ] ) ); + + } + + return result; + +} + +function applySphericalHarmonicsBand( buffer, splatIndex, words, componentCount, name, weights ) { + + const coefficients = unpackSphericalHarmonicsCoefficients( buffer, splatIndex, words, componentCount ); + const vectors = assembleSphericalHarmonicsVectors( coefficients, name ); + + return accumulateSphericalHarmonics( vectors, weights ); + +} + +function applySphericalHarmonics( rgb, center, localCameraPosition, splatIndex, buffers ) { + + const viewDirection = normalize( center.sub( localCameraPosition ) ).toVar( 'sphericalHarmonicsViewDirection' ); + const x = viewDirection.x; + const y = viewDirection.y; + const z = viewDirection.z; + + rgb.addAssign( applySphericalHarmonicsBand( + buffers.sphericalHarmonics1Read, + splatIndex, + buffers.sphericalHarmonics1Words, + SH_BAND_COMPONENTS[ 1 ], + 'sh1_', + [ + y.mul( - 0.4886025 ), + z.mul( 0.4886025 ), + x.mul( - 0.4886025 ) + ] + ) ); + + if ( buffers.sphericalHarmonicsDegree >= 2 ) { + + const xx = x.mul( x ).toVar( 'shXX' ); + const yy = y.mul( y ).toVar( 'shYY' ); + const zz = z.mul( z ).toVar( 'shZZ' ); + + rgb.addAssign( applySphericalHarmonicsBand( + buffers.sphericalHarmonics2Read, + splatIndex, + buffers.sphericalHarmonics2Words, + SH_BAND_COMPONENTS[ 2 ], + 'sh2_', + [ + x.mul( y ).mul( 1.0925484 ), + y.mul( z ).mul( - 1.0925484 ), + zz.mul( 2 ).sub( xx ).sub( yy ).mul( 0.3153915 ), + x.mul( z ).mul( - 1.0925484 ), + xx.sub( yy ).mul( 0.5462742 ) + ] + ) ); + + if ( buffers.sphericalHarmonicsDegree >= 3 ) { + + const xy = x.mul( y ).toVar( 'shXY' ); + + rgb.addAssign( applySphericalHarmonicsBand( + buffers.sphericalHarmonics3Read, + splatIndex, + buffers.sphericalHarmonics3Words, + SH_BAND_COMPONENTS[ 3 ], + 'sh3_', + [ + y.mul( xx.mul( 3 ).sub( yy ) ).mul( - 0.5900436 ), + xy.mul( z ).mul( 2.8906114 ), + y.mul( zz.mul( 4 ).sub( xx ).sub( yy ) ).mul( - 0.4570458 ), + z.mul( zz.mul( 2 ).sub( xx.mul( 3 ) ).sub( yy.mul( 3 ) ) ).mul( 0.3731763 ), + x.mul( zz.mul( 4 ).sub( xx ).sub( yy ) ).mul( - 0.4570458 ), + z.mul( xx.sub( yy ) ).mul( 1.4453057 ), + x.mul( xx.sub( yy.mul( 3 ) ) ).mul( - 0.5900436 ) + ] + ) ); + + } + + } + +} + +function createSphericalHarmonicsComputeNode( buffers, localCameraPosition ) { + + if ( buffers.sphericalHarmonicsDegree === 0 ) return null; + + return Fn( () => { + + const splatIndex = instanceIndex; + const center = buffers.centerRead.element( splatIndex ).xyz.toVar( 'center' ); + const rgb = vec3( 0 ).toVar( 'sphericalHarmonicsContribution' ); + + applySphericalHarmonics( rgb, center, localCameraPosition, splatIndex, buffers ); + buffers.sphericalHarmonicsContributionWrite.element( splatIndex ).assign( vec4( rgb, 0 ) ); + + } )().compute( buffers.count, [ WORKGROUP_SIZE ] ).setName( 'GaussianSplatSphericalHarmonics' ); + +} + +function createMaterialNodes( buffers, sort, localCameraPosition ) { const splatUv = varyingProperty( 'vec2', 'vSplatUv' ); const splatColor = varyingProperty( 'vec4', 'vSplatColor' ); - const vertexNode = Fn( () => { + const createVertexNode = ( usePrecomputedSphericalHarmonics ) => 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' ); + const rgb = color.rgb.toVar( 'splatRgb' ); + + if ( buffers.sphericalHarmonicsDegree > 0 ) { + + if ( usePrecomputedSphericalHarmonics === true ) { + + rgb.addAssign( buffers.sphericalHarmonicsContributionRead.element( splatIndex ).rgb ); + + } else { + + applySphericalHarmonics( rgb, center, localCameraPosition, splatIndex, buffers ); + + } + + } splatUv.assign( positionGeometry.xy ); @@ -403,7 +689,7 @@ function createMaterial( buffers, sort ) { 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 ) ) ); + splatColor.assign( vec4( rgb.clamp( 0, 1 ), 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' ); @@ -444,6 +730,9 @@ function createMaterial( buffers, sort ) { } )(); + const vertexNode = createVertexNode( true ); + const sphericalHarmonicsVertexNode = buffers.sphericalHarmonicsDegree > 0 ? createVertexNode( false ) : null; + const fragmentNode = Fn( () => { const r2 = dot( splatUv, splatUv ).toVar( 'r2' ); @@ -458,6 +747,12 @@ function createMaterial( buffers, sort ) { } )(); + return { vertexNode, sphericalHarmonicsVertexNode, fragmentNode }; + +} + +function createMaterial( vertexNode, fragmentNode ) { + const material = new NodeMaterial(); material.vertexNode = vertexNode; material.colorNode = fragmentNode; diff --git a/examples/jsm/tsl/display/ImportanceSampledEnvironment.js b/examples/jsm/tsl/display/ImportanceSampledEnvironment.js index 1a5260bbede3ff..8094959b5a83a7 100644 --- a/examples/jsm/tsl/display/ImportanceSampledEnvironment.js +++ b/examples/jsm/tsl/display/ImportanceSampledEnvironment.js @@ -8,7 +8,7 @@ */ import { If, dot, equirectUV, float, luminance, max, normalize, texture, uniform, vec2, vec4 } from 'three/tsl'; -import { ClampToEdgeWrapping, DataTexture, DataUtils, FloatType, HalfFloatType, LinearFilter, RedFormat, RepeatWrapping, Source, Vector2 } from 'three/webgpu'; +import { ClampToEdgeWrapping, DataTexture, DataUtils, FloatType, HalfFloatType, LinearFilter, RedFormat, RepeatWrapping, TextureSource, Vector2 } from 'three/webgpu'; import { D_GTR, F_Schlick, GeometryTerm, SmithG, equirectDirPdf, misPowerHeuristic } from '../utils/SpecularHelpers.js'; function colorToLuminance( r, g, b ) { @@ -45,7 +45,7 @@ function binarySearchFindClosestIndexOf( array, targetValue, offset = 0, count = function preprocessEnvMap( envMap ) { const map = envMap.clone(); - map.source = new Source( { ...map.image } ); + map.source = new TextureSource( { ...map.image } ); const { width, height, data } = map.image; let newData = data; diff --git a/examples/jsm/utils/GaussianSplatUtils.js b/examples/jsm/utils/GaussianSplatUtils.js index 22ef7339fcdb0f..fb1468234bb79d 100644 --- a/examples/jsm/utils/GaussianSplatUtils.js +++ b/examples/jsm/utils/GaussianSplatUtils.js @@ -4,6 +4,10 @@ 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' ], @@ -112,12 +116,157 @@ function writeCovariance( target, offset, sx, sy, sz, qx, qy, qz, qw ) { } -function createGaussianSplatGeometry( centers, covariances, colors ) { +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 ] ); + packed.fill( 0x80808080 ); + + return { + packed, + bytes: new Uint8ClampedArray( packed.buffer ) + }; + +} + +function createSphericalHarmonicsAttribute( values, count, degree ) { + + const words = SH_BAND_WORDS[ degree ]; + + if ( values instanceof Uint32Array === false ) { + + throw new Error( `THREE.createGaussianSplatGeometry: sphericalHarmonics${ degree } must use packed uint32 words.` ); + + } + + if ( values.length !== count * words ) { + + throw new Error( `THREE.createGaussianSplatGeometry: Invalid sphericalHarmonics${ degree } packed length.` ); + + } + + return new BufferAttribute( values, words ); + +} + +function getSphericalHarmonicsDegree( geometry ) { + + if ( geometry === undefined || geometry.isBufferGeometry !== true ) return 0; + + let degree = 0; + + for ( let i = 1; i <= 3; i ++ ) { + + const attribute = geometry.getAttribute( `sphericalHarmonics${ i }` ); + + if ( attribute === undefined ) break; + + if ( attribute.itemSize !== SH_BAND_WORDS[ i ] ) { + + throw new Error( `THREE.getSphericalHarmonicsDegree: Invalid sphericalHarmonics${ i } itemSize.` ); + + } + + if ( attribute.array instanceof Uint32Array === false ) { + + throw new Error( `THREE.getSphericalHarmonicsDegree: sphericalHarmonics${ i } must use packed uint32 words.` ); + + } + + degree = i; + + } + + for ( let i = degree + 1; i <= 3; i ++ ) { + + if ( geometry.getAttribute( `sphericalHarmonics${ i }` ) !== undefined ) { + + throw new Error( 'THREE.getSphericalHarmonicsDegree: Spherical harmonics attributes must be contiguous.' ); + + } + + } + + const position = geometry.getAttribute( 'position' ); + + if ( position !== undefined ) { + + for ( let i = 1; i <= degree; i ++ ) { + + if ( geometry.getAttribute( `sphericalHarmonics${ i }` ).count !== position.count ) { + + throw new Error( 'THREE.getSphericalHarmonicsDegree: Spherical harmonics attribute counts must match position.' ); + + } + + } + + } + + return degree; + +} + +/** + * Creates Gaussian splat geometry from packed attribute arrays. Higher-order + * spherical harmonics must be supplied as packed `Uint32Array` words + * (`SH_BAND_WORDS[ degree ]` words per splat, four clamped-byte coefficients + * per word using `( value - 128 ) / 128`). + * + * @param {Float32Array} centers - Splat centers. + * @param {Float32Array} covariances - Splat covariance matrices. + * @param {Uint8Array|Uint8ClampedArray} colors - RGBA colors. + * @param {Object} [sphericalHarmonics={}] - Optional packed SH band arrays. + * @return {BufferGeometry} The Gaussian splat geometry. + */ +function createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics = {} ) { 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 ) ); + + const count = centers.length / 3; + + for ( let i = 1; i <= 3; i ++ ) { + + const values = sphericalHarmonics[ `sh${ i }` ] || sphericalHarmonics[ `sphericalHarmonics${ i }` ]; + + if ( values !== undefined ) { + + geometry.setAttribute( `sphericalHarmonics${ i }`, createSphericalHarmonicsAttribute( values, count, i ) ); + + } + + } + + getSphericalHarmonicsDegree( geometry ); geometry.computeBoundingBox(); geometry.computeBoundingSphere(); @@ -129,6 +278,7 @@ function createGaussianSplatGeometryFromPLYGeometry( geometry, { scaleAttribute = 'scale', rotationAttribute = 'rotation', sh0Attribute = 'f_dc', + shRestAttribute = 'f_rest', opacityAttribute = 'opacity' } = {} ) { @@ -142,6 +292,7 @@ function createGaussianSplatGeometryFromPLYGeometry( geometry, { 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 ) { @@ -167,6 +318,17 @@ function createGaussianSplatGeometryFromPLYGeometry( geometry, { 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 ++ ) { @@ -195,17 +357,73 @@ function createGaussianSplatGeometryFromPLYGeometry( geometry, { 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 createGaussianSplatGeometry( centers, covariances, colors ); + 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, sigmoid, diff --git a/examples/models/spz/lion.spz b/examples/models/spz/lion.spz index f724b17abfcd4e..d154e3d9ee8e31 100644 Binary files a/examples/models/spz/lion.spz and b/examples/models/spz/lion.spz differ diff --git a/examples/screenshots/webgpu_gaussian_splatting.jpg b/examples/screenshots/webgpu_gaussian_splatting.jpg index 5b5741b8e02bd8..4c1163d7203b74 100644 Binary files a/examples/screenshots/webgpu_gaussian_splatting.jpg and b/examples/screenshots/webgpu_gaussian_splatting.jpg differ diff --git a/src/Three.Core.js b/src/Three.Core.js index 534fc053df051c..a67148a67ae2b9 100644 --- a/src/Three.Core.js +++ b/src/Three.Core.js @@ -24,7 +24,6 @@ export { Group } from './objects/Group.js'; export { VideoTexture } from './textures/VideoTexture.js'; export { VideoFrameTexture } from './textures/VideoFrameTexture.js'; export { FramebufferTexture } from './textures/FramebufferTexture.js'; -export { Source } from './textures/Source.js'; export { DataTexture } from './textures/DataTexture.js'; export { DataArrayTexture } from './textures/DataArrayTexture.js'; export { Data3DTexture } from './textures/Data3DTexture.js'; @@ -38,6 +37,7 @@ export { DepthTexture } from './textures/DepthTexture.js'; export { CubeDepthTexture } from './textures/CubeDepthTexture.js'; export { ExternalTexture } from './textures/ExternalTexture.js'; export { Texture } from './textures/Texture.js'; +export { TextureSource, Source } from './textures/TextureSource.js'; export * from './geometries/Geometries.js'; export * from './materials/Materials.js'; export { AnimationLoader } from './loaders/AnimationLoader.js'; diff --git a/src/core/RenderTarget.js b/src/core/RenderTarget.js index 660e6aa569f3d7..7260a13e2e5c1a 100644 --- a/src/core/RenderTarget.js +++ b/src/core/RenderTarget.js @@ -2,7 +2,7 @@ import { EventDispatcher } from './EventDispatcher.js'; import { Texture } from '../textures/Texture.js'; import { LinearFilter } from '../constants.js'; import { Vector4 } from '../math/Vector4.js'; -import { Source } from '../textures/Source.js'; +import { TextureSource } from '../textures/TextureSource.js'; /** * A render target is a buffer where the video card draws pixels for a scene @@ -438,7 +438,7 @@ class RenderTarget extends EventDispatcher { // ensure image object is not shared, see #20328 const image = Object.assign( {}, source.textures[ i ].image ); - this.textures[ i ].source = new Source( image ); + this.textures[ i ].source = new TextureSource( image ); } diff --git a/src/loaders/ObjectLoader.js b/src/loaders/ObjectLoader.js index 6a0bd5b0769c57..9be1220b963620 100644 --- a/src/loaders/ObjectLoader.js +++ b/src/loaders/ObjectLoader.js @@ -49,7 +49,7 @@ import { PerspectiveCamera } from '../cameras/PerspectiveCamera.js'; import { Scene } from '../scenes/Scene.js'; import { CubeTexture } from '../textures/CubeTexture.js'; import { Texture } from '../textures/Texture.js'; -import { Source } from '../textures/Source.js'; +import { TextureSource } from '../textures/TextureSource.js'; import { DataTexture } from '../textures/DataTexture.js'; import { ImageLoader } from './ImageLoader.js'; import { LoadingManager } from './LoadingManager.js'; @@ -548,14 +548,14 @@ class ObjectLoader extends Loader { } - images[ image.uuid ] = new Source( imageArray ); + images[ image.uuid ] = new TextureSource( imageArray ); } else { // load single image const deserializedImage = deserializeImage( image.url ); - images[ image.uuid ] = new Source( deserializedImage ); + images[ image.uuid ] = new TextureSource( deserializedImage ); } @@ -645,14 +645,14 @@ class ObjectLoader extends Loader { } - images[ image.uuid ] = new Source( imageArray ); + images[ image.uuid ] = new TextureSource( imageArray ); } else { // load single image const deserializedImage = await deserializeImage( image.url ); - images[ image.uuid ] = new Source( deserializedImage ); + images[ image.uuid ] = new TextureSource( deserializedImage ); } diff --git a/src/renderers/webgl/WebGLTextures.js b/src/renderers/webgl/WebGLTextures.js index b02d1542b8eccf..1ead5b07b4cdf5 100644 --- a/src/renderers/webgl/WebGLTextures.js +++ b/src/renderers/webgl/WebGLTextures.js @@ -14,7 +14,7 @@ function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, const _htmlTextures = new Set(); let _canvas; - const _sources = new WeakMap(); // maps WebglTexture objects to instances of Source + const _sources = new WeakMap(); // maps WebglTexture objects to instances of TextureSource // cordova iOS (as of 5.0) still uses UIWebView, which provides OffscreenCanvas, // also OffscreenCanvas.getContext("webgl"), but not OffscreenCanvas.getContext("2d")! @@ -720,7 +720,7 @@ function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, } - // create Source <-> WebGLTextures mapping if necessary + // create TextureSource <-> WebGLTextures mapping if necessary const source = texture.source; let webglTextures = _sources.get( source ); diff --git a/src/textures/DepthTexture.js b/src/textures/DepthTexture.js index 12f007341dfd96..354b841fb7e628 100644 --- a/src/textures/DepthTexture.js +++ b/src/textures/DepthTexture.js @@ -1,4 +1,4 @@ -import { Source } from './Source.js'; +import { TextureSource } from './TextureSource.js'; import { Texture } from './Texture.js'; import { NearestFilter, UnsignedIntType, DepthFormat, DepthStencilFormat } from '../constants.js'; @@ -82,7 +82,7 @@ class DepthTexture extends Texture { super.copy( source ); - this.source = new Source( Object.assign( {}, source.image ) ); // see #30540 + this.source = new TextureSource( Object.assign( {}, source.image ) ); // see #30540 this.compareFunction = source.compareFunction; return this; diff --git a/src/textures/Texture.js b/src/textures/Texture.js index 695b1a32184b44..4f535e78c54924 100644 --- a/src/textures/Texture.js +++ b/src/textures/Texture.js @@ -14,7 +14,7 @@ import { generateUUID } from '../math/MathUtils.js'; import { Vector2 } from '../math/Vector2.js'; import { Vector3 } from '../math/Vector3.js'; import { Matrix3 } from '../math/Matrix3.js'; -import { Source } from './Source.js'; +import { TextureSource } from './TextureSource.js'; import { warn } from '../utils.js'; let _textureId = 0; @@ -88,9 +88,9 @@ class Texture extends EventDispatcher { * where multiple textures render the same data but with different texture * transformations. * - * @type {Source} + * @type {TextureSource} */ - this.source = new Source( image ); + this.source = new TextureSource( image ); /** * An array holding user-defined mipmaps. diff --git a/src/textures/Source.js b/src/textures/TextureSource.js similarity index 78% rename from src/textures/Source.js rename to src/textures/TextureSource.js index a96b4e2f5730cb..3580ea88d745e5 100644 --- a/src/textures/Source.js +++ b/src/textures/TextureSource.js @@ -1,6 +1,6 @@ import { ImageUtils } from '../extras/ImageUtils.js'; import { generateUUID } from '../math/MathUtils.js'; -import { warn } from '../utils.js'; +import { warn, warnOnce } from '../utils.js'; let _sourceId = 0; @@ -10,10 +10,10 @@ let _sourceId = 0; * The main purpose of this class is to decouple the data definition from the texture * definition so the same data can be used with multiple texture instances. */ -class Source { +class TextureSource { /** - * Constructs a new video texture. + * Constructs a new texture source. * * @param {any} [data=null] - The data definition of a texture. */ @@ -26,12 +26,12 @@ class Source { * @readonly * @default true */ - this.isSource = true; + this.isTextureSource = true; /** * The ID of the source. * - * @name Source#id + * @name TextureSource#id * @type {number} * @readonly */ @@ -53,7 +53,7 @@ class Source { this.data = data; /** - * This property is only relevant when {@link Source#needsUpdate} is set to `true` and + * This property is only relevant when {@link TextureSource#needsUpdate} is set to `true` and * provides more control on how texture data should be processed. When `dataReady` is set * to `false`, the engine performs the memory allocation (if necessary) but does not transfer * the data into the GPU memory. @@ -64,7 +64,7 @@ class Source { this.dataReady = true; /** - * This starts at `0` and counts how many times {@link Source#needsUpdate} is set to `true`. + * This starts at `0` and counts how many times {@link TextureSource#needsUpdate} is set to `true`. * * @type {number} * @readonly @@ -227,4 +227,35 @@ function serializeImage( image ) { } -export { Source }; +/** + * @deprecated since r186. Use {@link TextureSource} instead. `Source` has been renamed to `TextureSource`. + */ +class Source extends TextureSource { + + /** + * Constructs a new texture source. + * + * @param {any} [data=null] - The data definition of a texture. + * @deprecated since r186. Use {@link TextureSource} instead. + */ + constructor( data = null ) { + + warnOnce( 'Source: "Source" has been renamed to "TextureSource". Please update your code to use "THREE.TextureSource" instead.' ); // @deprecated, r186 + + super( data ); + + /** + * This flag can be used for type testing. + * + * @deprecated since r186. Use {@link TextureSource#isTextureSource} instead. + * @type {boolean} + * @readonly + * @default true + */ + this.isSource = true; + + } + +} + +export { Source, TextureSource }; diff --git a/test/unit/addons/loaders/GLTFLoader.tests.js b/test/unit/addons/loaders/GLTFLoader.tests.js index 15d16fb01c6e34..d9b4ebea670033 100644 --- a/test/unit/addons/loaders/GLTFLoader.tests.js +++ b/test/unit/addons/loaders/GLTFLoader.tests.js @@ -1,5 +1,6 @@ import { GLTFLoader } from '../../../../examples/jsm/loaders/GLTFLoader.js'; import { GLTFGaussianSplatLoaderExtension } from '../../../../examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js'; +import { unpackSphericalHarmonicsBand } from '../utils/GaussianSplatTestUtils.js'; const EPS = 1e-6; const FLOAT = 5126; @@ -26,11 +27,12 @@ function arrayBufferToBase64( buffer ) { } -function createGaussianSplatGLTF() { +function createGaussianSplatGLTF( { sphericalHarmonicsDegree = 0 } = {} ) { const chunks = []; const bufferViews = []; const accessors = []; + const attributes = {}; let byteOffset = 0; function addAccessor( array, type, componentType, normalized = false, min = undefined, max = undefined ) { @@ -71,6 +73,29 @@ function createGaussianSplatGLTF() { 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 ); + + attributes.POSITION = position; + attributes[ 'KHR_gaussian_splatting:SCALE' ] = scale; + attributes[ 'KHR_gaussian_splatting:ROTATION' ] = rotation; + attributes[ 'KHR_gaussian_splatting:OPACITY' ] = opacity; + attributes[ 'KHR_gaussian_splatting:SH_DEGREE_0_COEF_0' ] = sh0; + + for ( let degree = 1; degree <= sphericalHarmonicsDegree; degree ++ ) { + + const coefficientCount = degree * 2 + 1; + + for ( let coefficient = 0; coefficient < coefficientCount; coefficient ++ ) { + + attributes[ `KHR_gaussian_splatting:SH_DEGREE_${ degree }_COEF_${ coefficient }` ] = addAccessor( + new Float32Array( [ ( coefficient + 1 ) / 128, ( coefficient + 2 ) / 128, ( coefficient + 3 ) / 128 ] ), + 'VEC3', + FLOAT + ); + + } + + } + const buffer = new Uint8Array( byteOffset ); let offset = 0; @@ -89,13 +114,7 @@ function createGaussianSplatGLTF() { 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 - }, + attributes, extensions: { KHR_gaussian_splatting: { kernel: 'ellipse', @@ -143,6 +162,26 @@ export default QUnit.module( 'Addons', () => { } ); + QUnit.test( 'loads KHR_gaussian_splatting spherical harmonics attributes', async ( assert ) => { + + const loader = new GLTFLoader(); + loader.register( function ( parser ) { + + return new GLTFGaussianSplatLoaderExtension( parser ); + + } ); + + const gltf = await loader.parseAsync( JSON.stringify( createGaussianSplatGLTF( { sphericalHarmonicsDegree: 1 } ) ), '' ); + const mesh = gltf.scene.children[ 0 ]; + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( mesh.splatGeometry.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), [ + 129, 130, 131, + 130, 131, 132, + 131, 132, 133 + ], 'loads SH1 coefficients' ); + + } ); + } ); } ); diff --git a/test/unit/addons/loaders/KSPLATLoader.tests.js b/test/unit/addons/loaders/KSPLATLoader.tests.js index 897b7b77e17013..a93aaf56c28c0f 100644 --- a/test/unit/addons/loaders/KSPLATLoader.tests.js +++ b/test/unit/addons/loaders/KSPLATLoader.tests.js @@ -1,9 +1,11 @@ import { BufferGeometry } from 'three'; import { KSPLATLoader } from '../../../../examples/jsm/loaders/KSPLATLoader.js'; +import { unpackSphericalHarmonicsBand } from '../utils/GaussianSplatTestUtils.js'; const EPS = 1e-6; const HEADER_SIZE_BYTES = 4096; const SECTION_HEADER_SIZE_BYTES = 1024; +const SH_DEGREE_TO_COMPONENTS = [ 0, 9, 24, 45 ]; function closeTo( assert, actual, expected, message ) { @@ -11,52 +13,62 @@ function closeTo( assert, actual, expected, message ) { } -function createKSPLATBuffer() { +function createKSPLATBuffer( sphericalHarmonicsDegree = 0 ) { - 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 degrees = Array.isArray( sphericalHarmonicsDegree ) ? sphericalHarmonicsDegree : [ sphericalHarmonicsDegree ]; + const bytesPerSplat = degrees.map( degree => 44 + SH_DEGREE_TO_COMPONENTS[ degree ] * 4 ); + const sectionHeadersSize = SECTION_HEADER_SIZE_BYTES * degrees.length; + const buffer = new ArrayBuffer( HEADER_SIZE_BYTES + sectionHeadersSize + bytesPerSplat.reduce( ( sum, size ) => sum + size, 0 ) ); 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.setUint32( 4, degrees.length, true ); + view.setUint32( 8, degrees.length, true ); + view.setUint32( 12, degrees.length, true ); + view.setUint32( 16, degrees.length, 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 ); + let dataOffset = HEADER_SIZE_BYTES + sectionHeadersSize; + + for ( let sectionIndex = 0; sectionIndex < degrees.length; sectionIndex ++ ) { + + const degree = degrees[ sectionIndex ]; + const sectionOffset = HEADER_SIZE_BYTES + sectionIndex * SECTION_HEADER_SIZE_BYTES; + + 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, 0, true ); + view.setUint32( sectionOffset + 24, 32767, true ); + view.setUint32( sectionOffset + 32, 0, true ); + view.setUint32( sectionOffset + 36, 0, true ); + view.setUint16( sectionOffset + 40, degree, true ); + + view.setFloat32( dataOffset, sectionIndex + 1, true ); + view.setFloat32( dataOffset + 4, 2, true ); + view.setFloat32( dataOffset + 8, 3, true ); + view.setFloat32( dataOffset + 12, 2, true ); + view.setFloat32( dataOffset + 16, 3, true ); + view.setFloat32( dataOffset + 20, 4, true ); + view.setFloat32( dataOffset + 24, 1, true ); + view.setFloat32( dataOffset + 28, 0, true ); + view.setFloat32( dataOffset + 32, 0, true ); + view.setFloat32( dataOffset + 36, 0, true ); + bytes.set( [ 10, 20, 30, 40 ], dataOffset + 40 ); + + for ( let i = 0; i < SH_DEGREE_TO_COMPONENTS[ degree ]; i ++ ) { + + view.setFloat32( dataOffset + 44 + i * 4, ( i + 1 ) / 128, true ); + + } + + dataOffset += bytesPerSplat[ sectionIndex ]; + + } return buffer; @@ -88,6 +100,27 @@ export default QUnit.module( 'Addons', () => { } ); + QUnit.test( 'parses uncompressed KSPLAT spherical harmonics data', ( assert ) => { + + const loader = new KSPLATLoader(); + const data = loader.parse( createKSPLATBuffer( 1 ) ); + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), [ 129, 132, 135, 130, 133, 136, 131, 134, 137 ], 'SH1 coefficients are remapped to RGB triplets' ); + + } ); + + QUnit.test( 'initializes missing KSPLAT spherical harmonics coefficients to zero', ( assert ) => { + + const loader = new KSPLATLoader(); + const data = loader.parse( createKSPLATBuffer( [ 0, 1 ] ) ); + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 2, 1 ) ), [ + 128, 128, 128, 128, 128, 128, 128, 128, 128, + 129, 132, 135, 130, 133, 136, 131, 134, 137 + ], 'missing SH1 coefficients remain neutral' ); + + } ); + } ); } ); diff --git a/test/unit/addons/loaders/SPZLoader.tests.js b/test/unit/addons/loaders/SPZLoader.tests.js index 491942ea5c2614..cb378a5a273b96 100644 --- a/test/unit/addons/loaders/SPZLoader.tests.js +++ b/test/unit/addons/loaders/SPZLoader.tests.js @@ -1,9 +1,11 @@ import { BufferGeometry } from 'three'; import { gzipSync } from '../../../../examples/jsm/libs/fflate.module.js'; import { SPZLoader } from '../../../../examples/jsm/loaders/SPZLoader.js'; +import { unpackSphericalHarmonicsBand } from '../utils/GaussianSplatTestUtils.js'; const EPS = 1e-6; const SPZ_MAGIC = 0x5053474e; +const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15 ]; function closeTo( assert, actual, expected, message ) { @@ -19,16 +21,16 @@ function writeInt24( view, offset, value ) { } -function createSPZBuffer() { +function createSPZBuffer( shDegree = 0 ) { - const raw = new Uint8Array( 16 + 9 + 1 + 3 + 3 + 3 ); + const raw = new Uint8Array( 16 + 9 + 1 + 3 + 3 + 3 + SH_DEGREE_TO_VECTORS[ shDegree ] * 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( 12, shDegree ); view.setUint8( 13, 4 ); view.setUint8( 14, 0 ); view.setUint8( 15, 0 ); @@ -45,11 +47,28 @@ function createSPZBuffer() { raw.set( [ 160, 160, 160 ], offset ); offset += 3; raw.set( [ 128, 128, 128 ], offset ); + offset += 3; + + for ( let i = 0, il = SH_DEGREE_TO_VECTORS[ shDegree ] * 3; i < il; i ++ ) { + + raw[ offset ++ ] = 129 + i; + + } return gzipSync( raw ).buffer; } +function createUnsupportedSPZBuffer( version ) { + + const raw = new ArrayBuffer( 32 ); + const view = new DataView( raw ); + view.setUint32( 0, SPZ_MAGIC, true ); + view.setUint32( 4, version, true ); + return raw; + +} + export default QUnit.module( 'Addons', () => { QUnit.module( 'Loaders', () => { @@ -73,6 +92,60 @@ export default QUnit.module( 'Addons', () => { } ); + QUnit.test( 'rejects SPZ version 4 and later', ( assert ) => { + + const loader = new SPZLoader(); + + assert.throws( () => loader.parse( createUnsupportedSPZBuffer( 4 ) ), /SPZ version 4 is not supported/, 'SPZ v4' ); + assert.throws( () => loader.parse( createUnsupportedSPZBuffer( 5 ) ), /SPZ version 5 is not supported/, 'SPZ v5' ); + + } ); + + QUnit.test( 'parses SPZ spherical harmonics degree 1 data', ( assert ) => { + + const loader = new SPZLoader(); + const data = loader.parse( createSPZBuffer( 1 ) ); + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), [ + 129, 130, 131, + 132, 133, 134, + 135, 136, 137 + ], 'SH1 coefficients' ); + + } ); + + QUnit.test( 'parses SPZ spherical harmonics degree 2 data', ( assert ) => { + + const loader = new SPZLoader(); + const data = loader.parse( createSPZBuffer( 2 ) ); + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics2' ).array, 1, 2 ) ), [ + 138, 139, 140, + 141, 142, 143, + 144, 145, 146, + 147, 148, 149, + 150, 151, 152 + ], 'SH2 coefficients' ); + + } ); + + QUnit.test( 'parses SPZ spherical harmonics degree 3 data', ( assert ) => { + + const loader = new SPZLoader(); + const data = loader.parse( createSPZBuffer( 3 ) ); + + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics3' ).array, 1, 3 ) ), [ + 153, 154, 155, + 156, 157, 158, + 159, 160, 161, + 162, 163, 164, + 165, 166, 167, + 168, 169, 170, + 171, 172, 173 + ], 'SH3 coefficients' ); + + } ); + } ); } ); diff --git a/test/unit/addons/utils/GaussianSplatTestUtils.js b/test/unit/addons/utils/GaussianSplatTestUtils.js new file mode 100644 index 00000000000000..7b0ef3eff95934 --- /dev/null +++ b/test/unit/addons/utils/GaussianSplatTestUtils.js @@ -0,0 +1,89 @@ +import { SH_BAND_COMPONENTS, SH_BAND_WORDS } from '../../../../examples/jsm/utils/GaussianSplatUtils.js'; + +/** + * Maps an SH scalar coefficient index within a band to its packed uint32 + * storage location. Coefficients are RGB-interleaved within each band + * (vector0.rgb, vector1.rgb, ...). + * + * @param {number} coefficient - Scalar coefficient index within the band. + * @return {{ word: number, shift: number }} Packed word index and bit shift. + */ +function getSphericalHarmonicsCoefficientLocation( coefficient ) { + + return { + word: coefficient >> 2, + shift: ( coefficient & 3 ) << 3 + }; + +} + +/** + * Packs clamped-byte SH coefficients into uint32 words (four bytes per word). + * Each coefficient uses the geometry encoding `( value - 128 ) / 128`. + * + * @param {Uint8Array|Uint8ClampedArray} source - Clamped-byte coefficients. + * @param {number} count - Splat count. + * @param {number} degree - Spherical harmonics band degree in `[1, 3]`. + * @return {Uint32Array} Packed words with `count * SH_BAND_WORDS[ degree ]` elements. + */ +function packSphericalHarmonicsBand( source, count, degree ) { + + const componentCount = SH_BAND_COMPONENTS[ degree ]; + const words = SH_BAND_WORDS[ degree ]; + const data = new Uint32Array( count * words ); + + for ( let i = 0; i < count; i ++ ) { + + const sourceBase = i * componentCount; + const targetBase = i * words; + + for ( let j = 0; j < componentCount; j ++ ) { + + const { word, shift } = getSphericalHarmonicsCoefficientLocation( j ); + data[ targetBase + word ] |= source[ sourceBase + j ] << shift; + + } + + } + + return data; + +} + +/** + * Unpacks uint32 SH words back to clamped-byte coefficients. + * + * @param {Uint32Array} packed - Packed words from {@link packSphericalHarmonicsBand}. + * @param {number} count - Splat count. + * @param {number} degree - Spherical harmonics band degree in `[1, 3]`. + * @return {Uint8ClampedArray} Clamped-byte coefficients. + */ +function unpackSphericalHarmonicsBand( packed, count, degree ) { + + const componentCount = SH_BAND_COMPONENTS[ degree ]; + const words = SH_BAND_WORDS[ degree ]; + const data = new Uint8ClampedArray( count * componentCount ); + + for ( let i = 0; i < count; i ++ ) { + + const sourceBase = i * words; + const targetBase = i * componentCount; + + for ( let j = 0; j < componentCount; j ++ ) { + + const { word, shift } = getSphericalHarmonicsCoefficientLocation( j ); + data[ targetBase + j ] = ( packed[ sourceBase + word ] >>> shift ) & 0xff; + + } + + } + + return data; + +} + +export { + getSphericalHarmonicsCoefficientLocation, + packSphericalHarmonicsBand, + unpackSphericalHarmonicsBand +}; diff --git a/test/unit/addons/utils/GaussianSplatUtils.tests.js b/test/unit/addons/utils/GaussianSplatUtils.tests.js index 28397ae66dcf03..44297809736140 100644 --- a/test/unit/addons/utils/GaussianSplatUtils.tests.js +++ b/test/unit/addons/utils/GaussianSplatUtils.tests.js @@ -7,12 +7,21 @@ 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, sigmoid } from '../../../../examples/jsm/utils/GaussianSplatUtils.js'; +import { + getSphericalHarmonicsCoefficientLocation, + packSphericalHarmonicsBand, + unpackSphericalHarmonicsBand +} from './GaussianSplatTestUtils.js'; const EPS = 1e-6; @@ -28,6 +37,27 @@ export default QUnit.module( 'Addons', () => { QUnit.module( 'GaussianSplatUtils', () => { + QUnit.test( 'maps spherical harmonics coefficients to packed uint words', ( assert ) => { + + assert.deepEqual( SH_BAND_WORDS, [ 0, 3, 4, 6 ], 'words cover each band with four bytes per uint' ); + + for ( let degree = 1; degree <= 3; degree ++ ) { + + assert.strictEqual( + SH_BAND_WORDS[ degree ], + Math.ceil( SH_BAND_COMPONENTS[ degree ] / 4 ), + `degree ${ degree } word count matches component count` + ); + + } + + assert.deepEqual( getSphericalHarmonicsCoefficientLocation( 0 ), { word: 0, shift: 0 }, 'first byte' ); + assert.deepEqual( getSphericalHarmonicsCoefficientLocation( 3 ), { word: 0, shift: 24 }, 'fourth byte' ); + assert.deepEqual( getSphericalHarmonicsCoefficientLocation( 4 ), { word: 1, shift: 0 }, 'fifth byte' ); + assert.deepEqual( getSphericalHarmonicsCoefficientLocation( 20 ), { word: 5, shift: 0 }, 'last SH3 byte' ); + + } ); + QUnit.test( 'converts degree-0 spherical harmonics and linear color', ( assert ) => { closeTo( assert, sh0ToLinear( 0 ), 0.5, 'zero coefficient maps to biased half' ); @@ -59,6 +89,58 @@ export default QUnit.module( 'Addons', () => { } ); + QUnit.test( 'creates Gaussian splat geometry with spherical harmonics attributes', ( assert ) => { + + const coefficients = new Uint8ClampedArray( [ 129, 130, 131, 132, 133, 134, 135, 136, 137 ] ); + const packedWords = packSphericalHarmonicsBand( coefficients, 1, 1 ); + const data = createGaussianSplatGeometry( + new Float32Array( [ 1, 2, 3 ] ), + new Float32Array( [ 4, 0, 0, 9, 0, 16 ] ), + new Uint8Array( [ 128, 128, 128, 128 ] ), + { + sh1: packedWords + } + ); + const packed = data.getAttribute( 'sphericalHarmonics1' ); + + assert.strictEqual( getSphericalHarmonicsDegree( data ), 1, 'degree' ); + assert.strictEqual( packed.itemSize, SH_BAND_WORDS[ 1 ], 'item size' ); + assert.ok( packed.array instanceof Uint32Array, 'stores packed uint32 words' ); + assert.strictEqual( packed.array, packedWords, 'reuses the packed buffer' ); + assert.deepEqual( + Array.from( unpackSphericalHarmonicsBand( packed.array, 1, 1 ) ), + Array.from( coefficients ), + 'coefficients round-trip through packed words' + ); + + } ); + + QUnit.test( 'requires packed uint32 spherical harmonics on geometry', ( assert ) => { + + assert.throws( () => { + + createGaussianSplatGeometry( + new Float32Array( [ 1, 2, 3 ] ), + new Float32Array( [ 4, 0, 0, 9, 0, 16 ] ), + new Uint8Array( [ 128, 128, 128, 128 ] ), + { sh1: new Float32Array( 9 ) } + ); + + }, /must use packed uint32 words/, 'rejects floating-point SH attributes' ); + + assert.throws( () => { + + createGaussianSplatGeometry( + new Float32Array( [ 1, 2, 3 ] ), + new Float32Array( [ 4, 0, 0, 9, 0, 16 ] ), + new Uint8Array( [ 128, 128, 128, 128 ] ), + { sh1: new Uint8ClampedArray( 9 ) } + ); + + }, /must use packed uint32 words/, 'rejects unpacked byte SH attributes' ); + + } ); + QUnit.test( 'converts PLY geometry attributes into Gaussian splat geometry', ( assert ) => { const geometry = new BufferGeometry(); @@ -124,6 +206,53 @@ export default QUnit.module( 'Addons', () => { } ); + 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(); diff --git a/test/unit/src/textures/Source.tests.js b/test/unit/src/textures/Source.tests.js deleted file mode 100644 index 77890156fe852f..00000000000000 --- a/test/unit/src/textures/Source.tests.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Source } from '../../../../src/textures/Source.js'; - -export default QUnit.module( 'Textures', () => { - - QUnit.module( 'Source', () => { - - // INSTANCING - QUnit.test( 'Instancing', ( assert ) => { - - const object = new Source(); - assert.ok( object, 'Can instantiate a Source.' ); - - } ); - - // PUBLIC - QUnit.test( 'isSource', ( assert ) => { - - const object = new Source(); - assert.ok( - object.isSource, - 'Source.isSource should be true' - ); - - } ); - - } ); - -} ); diff --git a/test/unit/src/textures/TextureSource.tests.js b/test/unit/src/textures/TextureSource.tests.js new file mode 100644 index 00000000000000..81aeb49fac36d1 --- /dev/null +++ b/test/unit/src/textures/TextureSource.tests.js @@ -0,0 +1,28 @@ +import { TextureSource } from '../../../../src/textures/TextureSource.js'; + +export default QUnit.module( 'Textures', () => { + + QUnit.module( 'TextureSource', () => { + + // INSTANCING + QUnit.test( 'Instancing', ( assert ) => { + + const object = new TextureSource(); + assert.ok( object, 'Can instantiate a TextureSource.' ); + + } ); + + // PUBLIC + QUnit.test( 'isTextureSource', ( assert ) => { + + const object = new TextureSource(); + assert.ok( + object.isTextureSource, + 'TextureSource.isTextureSource should be true' + ); + + } ); + + } ); + +} ); diff --git a/test/unit/three.source.unit.js b/test/unit/three.source.unit.js index de1a2212f5aae2..d545b5d81c438c 100644 --- a/test/unit/three.source.unit.js +++ b/test/unit/three.source.unit.js @@ -283,8 +283,8 @@ import './src/textures/DataArrayTexture.tests.js'; import './src/textures/DataTexture.tests.js'; import './src/textures/DepthTexture.tests.js'; import './src/textures/FramebufferTexture.tests.js'; -import './src/textures/Source.tests.js'; import './src/textures/Texture.tests.js'; +import './src/textures/TextureSource.tests.js'; import './src/textures/VideoTexture.tests.js';