UNPKG

three

Version:

JavaScript 3D library

562 lines (392 loc) 14.9 kB
import { DataUtils, FileLoader, Loader } from 'three'; import { gunzipSync } from '../libs/fflate.module.js'; import { ZSTDDecoder } from '../libs/zstddec.module.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; const SPZ_COLOR_SCALE = SH_C0 / 0.15; const FLAG_LOD = 0x80; const FLAG_HAS_EXTENSIONS = 0x02; const MAX_SUPPORTED_SH_DEGREE = 3; const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15, 24 ]; let _zstd; // Scales and colors are stored as single bytes, so all 256 possible outputs // of their decode functions can be precomputed once. const SCALE_LUT = new Float32Array( 256 ); const COLOR_LUT = new Uint8ClampedArray( 256 ); for ( let i = 0; i < 256; i ++ ) { SCALE_LUT[ i ] = Math.exp( i / 16 - 10 ); COLOR_LUT[ i ] = ( ( i / 255 - 0.5 ) * SPZ_COLOR_SCALE + 0.5 ) * 255; } // Quaternion components are 10-bit sign-magnitude values (bit 9 is the sign, // bits 0-8 are the magnitude scaled to [0, 1/sqrt(2)]), so all 1024 possible // decoded values can be precomputed, avoiding an unpredictable sign branch in // the hot loop. const QUAT_COMPONENT_LUT = new Float64Array( 1024 ); for ( let i = 0; i < 1024; i ++ ) { const value = Math.SQRT1_2 * ( ( i & 511 ) / 511 ); QUAT_COMPONENT_LUT[ i ] = ( i & 512 ) !== 0 ? - value : value; } const _quaternion = [ 0, 0, 0, 0 ]; /** * A loader for compressed Gaussian splat `.spz` files. * * This loader decodes the format into `BufferGeometry` for use with * `GaussianSplat`. 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(); * const data = await loader.loadAsync( './models/gsplat/example.spz' ); * scene.add( new GaussianSplat( data ) ); * ``` * * @augments Loader * @three_import import { SPZLoader } from 'three/addons/loaders/SPZLoader.js'; */ class SPZLoader extends Loader { /** * Constructs a new Gaussian splat SPZ loader. * * @param {LoadingManager} [manager] - The loading manager. */ constructor( manager ) { super( manager ); } /** * Starts loading from the given URL and passes the loaded splat data to * the `onLoad()` callback. * * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. * @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished. * @param {onProgressCallback} onProgress - Executed while the loading is in progress. * @param {onErrorCallback} onError - Executed when errors occur. */ load( url, onLoad, onProgress, onError ) { const scope = this; const loader = new FileLoader( this.manager ); loader.setPath( this.path ); loader.setResponseType( 'arraybuffer' ); loader.setRequestHeader( this.requestHeader ); loader.setWithCredentials( this.withCredentials ); loader.load( url, function ( buffer ) { scope.parse( buffer, onLoad, function ( e ) { if ( onError ) { onError( e ); } else { console.error( e ); } scope.manager.itemError( url ); } ); }, onProgress, onError ); } /** * Decompresses and parses the given `.spz` data. * * @param {ArrayBuffer} buffer - The raw SPZ file as an array buffer. * @param {function(BufferGeometry)} [onLoad] - Executed when the parsing process has been finished. * @param {onErrorCallback} [onError] - Executed when errors occur. * @return {BufferGeometry|Promise<BufferGeometry>|undefined} The parsed splat geometry, or a promise for SPZ v4 data. */ parse( buffer, onLoad, onError ) { try { if ( buffer.byteLength >= 8 ) { const view = new DataView( buffer ); const magic = view.getUint32( 0, true ); const version = view.getUint32( 4, true ); if ( magic === SPZ_MAGIC ) { if ( version !== 4 ) { throw new Error( `THREE.SPZLoader: SPZ version ${ version } is not supported.` ); } const promise = getZSTDDecoder() .then( ( zstd ) => this.parseRawSPZV4( new Uint8Array( buffer ), zstd ) ); if ( onLoad !== undefined ) { promise.then( onLoad ).catch( onError ); } return promise; } } const decompressed = gunzipSync( new Uint8Array( buffer ) ); const data = this.parseRawSPZ( decompressed ); if ( onLoad !== undefined ) onLoad( data ); return data; } catch ( e ) { if ( onError !== undefined ) { onError( e ); return; } throw e; } } /** * Parses raw SPZ data after gzip decompression. * * @param {Uint8Array} bytes - The decompressed SPZ data. * @return {BufferGeometry} The parsed splat geometry. */ parseRawSPZ( bytes ) { if ( bytes.byteLength < HEADER_SIZE_BYTES ) { throw new Error( 'THREE.SPZLoader: Invalid SPZ header.' ); } const view = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength ); const magic = view.getUint32( 0, true ); const version = view.getUint32( 4, true ); const count = view.getUint32( 8, true ); const storedShDegree = view.getUint8( 12 ); const fractionalBits = view.getUint8( 13 ); const flags = view.getUint8( 14 ); if ( magic !== SPZ_MAGIC ) { throw new Error( 'THREE.SPZLoader: Invalid SPZ magic.' ); } if ( version < 1 || version > 3 ) { throw new Error( `THREE.SPZLoader: SPZ version ${ version } is not supported.` ); } if ( storedShDegree >= SH_DEGREE_TO_VECTORS.length ) { throw new Error( `THREE.SPZLoader: Unsupported SPZ spherical harmonics degree ${ storedShDegree }.` ); } // Data beyond the supported degree is still present in the file and // accounted for below, it's just not decoded into an attribute. const shDegree = Math.min( storedShDegree, MAX_SUPPORTED_SH_DEGREE ); const positionsSize = count * 3 * ( version === 1 ? 2 : 3 ); const rotationsSize = count * ( version === 3 ? 4 : 3 ); const shSize = count * SH_DEGREE_TO_VECTORS[ storedShDegree ] * 3; const lodSize = ( flags & FLAG_LOD ) !== 0 ? count * 6 : 0; const expectedSize = HEADER_SIZE_BYTES + positionsSize + count + count * 3 + count * 3 + rotationsSize + shSize + lodSize; if ( bytes.byteLength !== expectedSize ) { throw new Error( 'THREE.SPZLoader: Invalid SPZ byte length.' ); } let offset = HEADER_SIZE_BYTES; const positions = bytes.subarray( offset, offset + positionsSize ); offset += positionsSize; const alphas = bytes.subarray( offset, offset + count ); offset += count; const colors = bytes.subarray( offset, offset + count * 3 ); offset += count * 3; const scales = bytes.subarray( offset, offset + count * 3 ); offset += count * 3; const rotations = bytes.subarray( offset, offset + rotationsSize ); offset += rotationsSize; const sphericalHarmonics = bytes.subarray( offset, offset + shSize ); return parseSPZAttributes( { positions, alphas, colors, scales, rotations, sphericalHarmonics, count, version, fractionalBits, shDegree, storedShDegree } ); } /** * Parses raw SPZ v4 data. * * @param {Uint8Array} bytes - The raw SPZ v4 data. * @param {ZSTDDecoder} zstd - The initialized ZSTD decoder. * @return {BufferGeometry} The parsed splat geometry. */ parseRawSPZV4( bytes, zstd ) { const view = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength ); const count = view.getUint32( 8, true ); const storedShDegree = view.getUint8( 12 ); const shDegree = Math.min( storedShDegree, MAX_SUPPORTED_SH_DEGREE ); const fractionalBits = view.getUint8( 13 ); const flags = view.getUint8( 14 ); const numStreams = view.getUint8( 15 ); const tocByteOffset = view.getUint32( 16, true ); if ( ( flags & FLAG_HAS_EXTENSIONS ) !== 0 ) { console.warn( 'THREE.SPZLoader: SPZ vendor extensions are not supported and will be skipped.' ); } const positionsSize = count * 3 * 3; const rotationsSize = count * 4; const shSize = count * SH_DEGREE_TO_VECTORS[ storedShDegree ] * 3; const streamSizes = [ positionsSize, count, count * 3, count * 3, rotationsSize, shSize ]; const toc = []; let compressedOffset = tocByteOffset + numStreams * 16; for ( let i = 0; i < numStreams; i ++ ) { const entryOffset = tocByteOffset + i * 16; const compressedSize = Number( view.getBigUint64( entryOffset, true ) ); toc.push( { compressedOffset, compressedSize } ); compressedOffset += compressedSize; } const streams = []; let streamIndex = 0; for ( let i = 0; i < streamSizes.length; i ++ ) { const streamSize = streamSizes[ i ]; if ( streamSize === 0 ) { streams.push( new Uint8Array() ); continue; } const stream = toc[ streamIndex ++ ]; const compressed = bytes.subarray( stream.compressedOffset, stream.compressedOffset + stream.compressedSize ); streams.push( zstd.decode( compressed, streamSize ) ); } return parseSPZAttributes( { positions: streams[ 0 ], alphas: streams[ 1 ], colors: streams[ 2 ], scales: streams[ 3 ], rotations: streams[ 4 ], sphericalHarmonics: streams[ 5 ], count, version: 4, fractionalBits, shDegree, storedShDegree } ); } } function getZSTDDecoder() { if ( _zstd === undefined ) { const decoder = new ZSTDDecoder(); _zstd = decoder.init().then( () => decoder ).catch( ( e ) => { _zstd = undefined; throw e; } ); } return _zstd; } function parseSPZAttributes( { positions, alphas, colors, scales, rotations, sphericalHarmonics, count, version, fractionalBits, shDegree, storedShDegree } ) { const centers = new Float32Array( count * 3 ); const covariances = new Float32Array( count * 6 ); const colorBytes = new Uint8ClampedArray( count * 4 ); const sphericalHarmonicsBands = {}; readCenters( positions, centers, 0, count, version, fractionalBits ); // The hot loop below avoids per-splat DataView reads by indexing into an // aligned Uint32Array. When the rotation section is already 4-byte aligned // (e.g. a freshly decoded ZSTD stream) it's read in place; otherwise it's // copied into a new, aligned buffer first. const packedRotations = version >= 3 ? ( rotations.byteOffset % 4 === 0 ? new Uint32Array( rotations.buffer, rotations.byteOffset, count ) : new Uint32Array( rotations.buffer.slice( rotations.byteOffset, rotations.byteOffset + count * 4 ) ) ) : null; const quaternion = _quaternion; for ( let i = 0; i < count; i ++ ) { const i3 = i * 3; const i4 = i * 4; const sx = SCALE_LUT[ scales[ i3 ] ]; const sy = SCALE_LUT[ scales[ i3 + 1 ] ]; const sz = SCALE_LUT[ scales[ i3 + 2 ] ]; if ( version >= 3 ) { readSmallestThreeQuaternion( packedRotations[ i ], quaternion ); } else { readXYZQuaternion( rotations, i3, quaternion ); } writeCovariance( covariances, i * 6, sx, sy, sz, quaternion[ 0 ], quaternion[ 1 ], quaternion[ 2 ], quaternion[ 3 ] ); colorBytes[ i4 ] = COLOR_LUT[ colors[ i3 ] ]; colorBytes[ i4 + 1 ] = COLOR_LUT[ colors[ i3 + 1 ] ]; colorBytes[ i4 + 2 ] = COLOR_LUT[ colors[ i3 + 2 ] ]; colorBytes[ i4 + 3 ] = alphas[ i ]; } readSphericalHarmonics( sphericalHarmonics, 0, count, shDegree, sphericalHarmonicsBands, storedShDegree ); return createGaussianSplatGeometry( centers, covariances, colorBytes, sphericalHarmonicsBands ); } function readSphericalHarmonics( bytes, offset, count, degree, sphericalHarmonics, storedDegree = degree ) { 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 } ); } const sourceStride = SH_DEGREE_TO_VECTORS[ storedDegree ] * 3; for ( let i = 0; i < count; i ++ ) { const sourceOffset = offset + i * sourceStride; let bandOffset = sourceOffset; 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[ bandOffset ++ ]; } } } } function readCenters( bytes, centers, offset, count, version, fractionalBits ) { if ( version === 1 ) { for ( let i = 0; i < count; i ++ ) { const i3 = i * 3; const rowOffset = offset + i3 * 2; centers[ i3 ] = DataUtils.fromHalfFloat( bytes[ rowOffset ] | ( bytes[ rowOffset + 1 ] << 8 ) ); centers[ i3 + 1 ] = DataUtils.fromHalfFloat( bytes[ rowOffset + 2 ] | ( bytes[ rowOffset + 3 ] << 8 ) ); centers[ i3 + 2 ] = DataUtils.fromHalfFloat( bytes[ rowOffset + 4 ] | ( bytes[ rowOffset + 5 ] << 8 ) ); } return; } const fixedScale = 1 / ( 1 << fractionalBits ); for ( let i = 0; i < count; i ++ ) { const i3 = i * 3; const rowOffset = offset + i * 9; centers[ i3 ] = readInt24( bytes, rowOffset ) * fixedScale; centers[ i3 + 1 ] = readInt24( bytes, rowOffset + 3 ) * fixedScale; centers[ i3 + 2 ] = readInt24( bytes, rowOffset + 6 ) * fixedScale; } } function readInt24( bytes, offset ) { // The left shift by 8 followed by an arithmetic right shift sign-extends // the 24-bit value. return ( ( bytes[ offset ] << 8 ) | ( bytes[ offset + 1 ] << 16 ) | ( bytes[ offset + 2 ] << 24 ) ) >> 8; } function readXYZQuaternion( bytes, offset, target ) { const qx = bytes[ offset ] / 127.5 - 1; const qy = bytes[ offset + 1 ] / 127.5 - 1; const qz = bytes[ offset + 2 ] / 127.5 - 1; target[ 0 ] = qx; target[ 1 ] = qy; target[ 2 ] = qz; target[ 3 ] = Math.sqrt( Math.max( 0, 1 - qx * qx - qy * qy - qz * qz ) ); } function readSmallestThreeQuaternion( packed, target ) { const largestIndex = packed >>> 30; // The three smallest components are packed from the lowest bits upward, // filling the non-largest indices in descending order: the low 10 bits go // to the highest remaining index, the top 10 bits to the lowest. const a = QUAT_COMPONENT_LUT[ packed & 1023 ]; const b = QUAT_COMPONENT_LUT[ ( packed >>> 10 ) & 1023 ]; const c = QUAT_COMPONENT_LUT[ ( packed >>> 20 ) & 1023 ]; switch ( largestIndex ) { case 0: target[ 1 ] = c; target[ 2 ] = b; target[ 3 ] = a; break; case 1: target[ 0 ] = c; target[ 2 ] = b; target[ 3 ] = a; break; case 2: target[ 0 ] = c; target[ 1 ] = b; target[ 3 ] = a; break; default: target[ 0 ] = c; target[ 1 ] = b; target[ 2 ] = a; break; } target[ largestIndex ] = Math.sqrt( Math.max( 0, 1 - ( a * a + b * b + c * c ) ) ); } export { SPZLoader };