diff --git a/examples/jsm/loaders/SPZLoader.js b/examples/jsm/loaders/SPZLoader.js index 2c599edd25d352..509f75701e789a 100644 --- a/examples/jsm/loaders/SPZLoader.js +++ b/examples/jsm/loaders/SPZLoader.js @@ -5,14 +5,18 @@ import { } 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 MAX_SPLATS = 10000000; const SPZ_COLOR_SCALE = SH_C0 / 0.15; const FLAG_LOD = 0x80; -const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15 ]; +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. @@ -92,11 +96,7 @@ class SPZLoader extends Loader { loader.setWithCredentials( this.withCredentials ); loader.load( url, function ( buffer ) { - try { - - onLoad( scope.parse( buffer ) ); - - } catch ( e ) { + scope.parse( buffer, onLoad, function ( e ) { if ( onError ) { @@ -110,7 +110,7 @@ class SPZLoader extends Loader { scope.manager.itemError( url ); - } + } ); }, onProgress, onError ); @@ -119,26 +119,63 @@ class SPZLoader extends Loader { /** * Decompresses and parses the given `.spz` data. * - * @param {ArrayBuffer} buffer - The raw gzip-compressed SPZ file as an array buffer. - * @return {BufferGeometry} The parsed splat geometry. + * @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|undefined} The parsed splat geometry, or a promise for SPZ v4 data. */ - parse( buffer ) { + 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.` ); - if ( buffer.byteLength >= 8 ) { + } - const view = new DataView( buffer ); + const promise = getZSTDDecoder() + .then( ( zstd ) => this.parseRawSPZV4( new Uint8Array( buffer ), zstd ) ); - if ( view.getUint32( 0, true ) === SPZ_MAGIC && view.getUint32( 4, true ) >= 4 ) { + if ( onLoad !== undefined ) { - throw new Error( `THREE.SPZLoader: SPZ version ${ view.getUint32( 4, true ) } is not supported.` ); + 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; - const decompressed = gunzipSync( new Uint8Array( buffer ) ); + } + + throw e; - return this.parseRawSPZ( decompressed ); + } } @@ -160,7 +197,7 @@ class SPZLoader extends Loader { const magic = view.getUint32( 0, true ); const version = view.getUint32( 4, true ); const count = view.getUint32( 8, true ); - const shDegree = view.getUint8( 12 ); + const storedShDegree = view.getUint8( 12 ); const fractionalBits = view.getUint8( 13 ); const flags = view.getUint8( 14 ); @@ -176,26 +213,19 @@ class SPZLoader extends Loader { } - if ( count > MAX_SPLATS ) { + if ( storedShDegree >= SH_DEGREE_TO_VECTORS.length ) { - throw new Error( `THREE.SPZLoader: SPZ file contains too many splats (${ count }).` ); + throw new Error( `THREE.SPZLoader: Unsupported SPZ spherical harmonics degree ${ storedShDegree }.` ); } - if ( shDegree > 3 ) { - - throw new Error( `THREE.SPZLoader: Unsupported SPZ spherical harmonics degree ${ shDegree }.` ); - - } + // 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 ); - let offset = HEADER_SIZE_BYTES; - 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; + 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; @@ -205,65 +235,201 @@ class SPZLoader extends Loader { } - offset = readCenters( bytes, centers, offset, count, version, fractionalBits ); - - const alphaOffset = offset; + let offset = HEADER_SIZE_BYTES; + const positions = bytes.subarray( offset, offset + positionsSize ); + offset += positionsSize; + const alphas = bytes.subarray( offset, offset + count ); offset += count; - - const colorOffset = offset; + const colors = bytes.subarray( offset, offset + count * 3 ); offset += count * 3; - - const scaleOffset = offset; + 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 ); - const rotationOffset = offset; - const sphericalHarmonicsOffset = rotationOffset + rotationsSize; + if ( ( flags & FLAG_HAS_EXTENSIONS ) !== 0 ) { - // Copy the rotation section into an aligned Uint32Array so the hot loop - // avoids per-splat DataView reads (the section offset within the file is - // not guaranteed to be 4-byte aligned). - const packedRotations = version === 3 ? - new Uint32Array( bytes.buffer.slice( bytes.byteOffset + rotationOffset, bytes.byteOffset + rotationOffset + count * 4 ) ) : - null; + console.warn( 'THREE.SPZLoader: SPZ vendor extensions are not supported and will be skipped.' ); - const quaternion = _quaternion; + } - for ( let i = 0; i < count; i ++ ) { + 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; - const i3 = i * 3; - const i4 = i * 4; - const sx = SCALE_LUT[ bytes[ scaleOffset + i3 ] ]; - const sy = SCALE_LUT[ bytes[ scaleOffset + i3 + 1 ] ]; - const sz = SCALE_LUT[ bytes[ scaleOffset + i3 + 2 ] ]; + for ( let i = 0; i < numStreams; i ++ ) { - if ( version === 3 ) { + const entryOffset = tocByteOffset + i * 16; + const compressedSize = Number( view.getBigUint64( entryOffset, true ) ); + toc.push( { + compressedOffset, + compressedSize + } ); + compressedOffset += compressedSize; - readSmallestThreeQuaternion( packedRotations[ i ], quaternion ); + } + + const streams = []; + let streamIndex = 0; + + for ( let i = 0; i < streamSizes.length; i ++ ) { - } else { + const streamSize = streamSizes[ i ]; - readXYZQuaternion( bytes, rotationOffset + i3, quaternion ); + if ( streamSize === 0 ) { + + streams.push( new Uint8Array() ); + continue; } - writeCovariance( covariances, i * 6, sx, sy, sz, quaternion[ 0 ], quaternion[ 1 ], quaternion[ 2 ], quaternion[ 3 ] ); + 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 ) => { - colors[ i4 ] = COLOR_LUT[ bytes[ colorOffset + i3 ] ]; - colors[ i4 + 1 ] = COLOR_LUT[ bytes[ colorOffset + i3 + 1 ] ]; - colors[ i4 + 2 ] = COLOR_LUT[ bytes[ colorOffset + i3 + 2 ] ]; - colors[ i4 + 3 ] = bytes[ alphaOffset + i ]; + _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 ); } - readSphericalHarmonics( bytes, sphericalHarmonicsOffset, count, shDegree, sphericalHarmonics ); + writeCovariance( covariances, i * 6, sx, sy, sz, quaternion[ 0 ], quaternion[ 1 ], quaternion[ 2 ], quaternion[ 3 ] ); - return createGaussianSplatGeometry( centers, covariances, colors, sphericalHarmonics ); + 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 ) { +function readSphericalHarmonics( bytes, offset, count, degree, sphericalHarmonics, storedDegree = degree ) { if ( degree === 0 ) return; @@ -281,8 +447,13 @@ function readSphericalHarmonics( bytes, offset, count, degree, sphericalHarmonic } + 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 ]; @@ -290,7 +461,7 @@ function readSphericalHarmonics( bytes, offset, count, degree, sphericalHarmonic for ( let j = 0; j < band.components; j ++ ) { - band.bytes[ targetOffset + j ] = bytes[ offset ++ ]; + band.bytes[ targetOffset + j ] = bytes[ bandOffset ++ ]; } @@ -315,7 +486,7 @@ function readCenters( bytes, centers, offset, count, version, fractionalBits ) { } - return offset + count * 3 * 2; + return; } @@ -332,8 +503,6 @@ function readCenters( bytes, centers, offset, count, version, fractionalBits ) { } - return offset + count * 3 * 3; - } function readInt24( bytes, offset ) { diff --git a/examples/models/spz/lion.spz b/examples/models/spz/lion.v3.spz similarity index 100% rename from examples/models/spz/lion.spz rename to examples/models/spz/lion.v3.spz diff --git a/examples/models/spz/tomatoes.license.txt b/examples/models/spz/tomatoes.license.txt new file mode 100644 index 00000000000000..5ab7f2c5ab30a8 --- /dev/null +++ b/examples/models/spz/tomatoes.license.txt @@ -0,0 +1,6 @@ +Title: Scan was created on my DIY automated rig. +Author: Grail (https://superspl.at/user?id=grail) +Source: https://superspl.at/scene/2826d2c0 +License: CC Attribution (Creative Commons Attribution) +License URL: http://creativecommons.org/licenses/by/4.0/ +Requirements: Author must be credited. Commercial use is allowed. \ No newline at end of file diff --git a/examples/models/spz/tomatoes.v4.spz b/examples/models/spz/tomatoes.v4.spz new file mode 100644 index 00000000000000..87fc51c424f387 Binary files /dev/null and b/examples/models/spz/tomatoes.v4.spz differ diff --git a/examples/screenshots/webgpu_materials_arrays.jpg b/examples/screenshots/webgpu_materials_arrays.jpg index 696682a0dddb1d..ac76524bbbec90 100644 Binary files a/examples/screenshots/webgpu_materials_arrays.jpg and b/examples/screenshots/webgpu_materials_arrays.jpg differ diff --git a/examples/webgpu_gaussian_splatting.html b/examples/webgpu_gaussian_splatting.html index 785fae52dc4208..8656e1e2674d6e 100644 --- a/examples/webgpu_gaussian_splatting.html +++ b/examples/webgpu_gaussian_splatting.html @@ -71,7 +71,7 @@ }, lion: { name: 'Lion (SPZ)', - url: './models/spz/lion.spz', + url: './models/spz/lion.v3.spz', loader: SPZLoader, rotation: new THREE.Euler( Math.PI, 0, 0 ), cameraPosition: new THREE.Vector3( 0, 0.2, 1 ), @@ -82,6 +82,19 @@ license: 'CC Attribution', licenseUrl: 'http://creativecommons.org/licenses/by/4.0/' } + }, + tomatoes: { + name: 'Tomatoes (SPZ v4)', + url: './models/spz/tomatoes.v4.spz', + loader: SPZLoader, + cameraPosition: new THREE.Vector3( 0, 0.35, 1 ), + credit: { + author: 'Grail', + authorUrl: 'https://superspl.at/user/grail', + source: 'https://superspl.at/scene/2826d2c0', + license: 'CC Attribution', + licenseUrl: 'http://creativecommons.org/licenses/by/4.0/' + } } }; const sourceNames = Object.values( sources ).map( ( source ) => source.name ); diff --git a/examples/webgpu_materials_arrays.html b/examples/webgpu_materials_arrays.html index 3d6f90642e1c26..c9f449959c8bce 100644 --- a/examples/webgpu_materials_arrays.html +++ b/examples/webgpu_materials_arrays.html @@ -21,7 +21,7 @@ - Materials Arrays and Geometry Groups. + Materials Arrays and Geometry Groups. Inspired by paper polyhedra. @@ -41,136 +41,191 @@ import * as THREE from 'three/webgpu'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; - import { Inspector } from 'three/addons/inspector/Inspector.js'; let renderer, scene, camera, controls; - let planeMesh, boxMesh, boxMeshWireframe, planeMeshWireframe; let materials; - const api = { - webgpu: true - }; - - - init( ! api.webgpu ); + const _a = new THREE.Vector3(), _b = new THREE.Vector3(); - function init( forceWebGL = false ) { + init(); - if ( renderer ) { - - renderer.dispose(); - controls.dispose(); - document.body.removeChild( renderer.domElement ); - - } + function init() { // renderer - renderer = new THREE.WebGPURenderer( { - forceWebGL, - antialias: true, - } ); + + renderer = new THREE.WebGPURenderer( { antialias: true } ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setPixelRatio( window.devicePixelRatio ); + renderer.shadowMap.enabled = true; renderer.setAnimationLoop( animate ); renderer.inspector = new Inspector(); document.body.appendChild( renderer.domElement ); // scene + scene = new THREE.Scene(); - scene.background = new THREE.Color( 0x000000 ); + scene.background = new THREE.Color( 0x222222 ); // camera - camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 1, 100 ); - camera.position.set( 0, 0, 10 ); + + camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 0.1, 100 ); + camera.position.set( - 6, 7, 14 ); // controls + controls = new OrbitControls( camera, renderer.domElement ); + controls.target.set( 0, 0.5, 0 ); + controls.minDistance = 5; + controls.maxDistance = 50; + controls.enableDamping = true; + controls.update(); - // materials - materials = [ - new THREE.MeshBasicMaterial( { color: 0xff1493, side: THREE.DoubleSide } ), - new THREE.MeshBasicMaterial( { color: 0x0000ff, side: THREE.DoubleSide } ), - new THREE.MeshBasicMaterial( { color: 0x00ff00, side: THREE.DoubleSide } ), - ]; + // environment - // plane geometry - const planeGeometry = new THREE.PlaneGeometry( 1, 1, 4, 4 ); + const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x0e696c ); + scene.add( hemiLight ); - planeGeometry.clearGroups(); - const numFacesPerRow = 4; // Number of faces in a row (since each face is made of 2 triangles) + // lights - planeGeometry.addGroup( 0, 6 * numFacesPerRow, 0 ); - planeGeometry.addGroup( 6 * numFacesPerRow, 6 * numFacesPerRow, 1 ); - planeGeometry.addGroup( 12 * numFacesPerRow, 6 * numFacesPerRow, 2 ); + const dirLight = new THREE.DirectionalLight( 0xffffff, 6 ); + dirLight.position.set( 5, 10, 6 ); + dirLight.castShadow = true; + dirLight.shadow.camera.left = - 10; + dirLight.shadow.camera.right = 10; + dirLight.shadow.camera.top = 10; + dirLight.shadow.camera.bottom = - 10; + dirLight.shadow.camera.far = 20; + dirLight.shadow.mapSize.set( 2048, 2048 ); + dirLight.shadow.radius = 10; + scene.add( dirLight ); - // box geometry - const boxGeometry = new THREE.BoxGeometry( .75, .75, .75 ); + // scene.add( new THREE.CameraHelper( dirLight.shadow.camera ) ); - boxGeometry.clearGroups(); - boxGeometry.addGroup( 0, 6, 0 ); // front face - boxGeometry.addGroup( 6, 6, 0 ); // back face - boxGeometry.addGroup( 12, 6, 2 ); // top face - boxGeometry.addGroup( 18, 6, 2 ); // bottom face - boxGeometry.addGroup( 24, 6, 1 ); // left face - boxGeometry.addGroup( 30, 6, 1 ); // right face + // materials, one per paper color, shared across all solids - scene.background = forceWebGL ? new THREE.Color( 0x000000 ) : new THREE.Color( 0x222222 ); + const palette = [ 0xe4002b, 0xff7f11, 0xffd100, 0x00a651, 0x0072ce, 0x8a2be2 ]; - // meshes - planeMesh = new THREE.Mesh( planeGeometry, materials ); + materials = palette.map( color => new THREE.MeshStandardMaterial( { color, roughness: 0.8, side: THREE.DoubleSide } ) ); - const materialsWireframe = []; + const geometries = [ + makeHoleyGeometry( new THREE.TetrahedronGeometry( 1.2 ), 3 ), // 4 faces, 1 triangle each + makeHoleyGeometry( new THREE.BoxGeometry( 1.5, 1.5, 1.5 ), 6 ), // 6 faces, 2 triangles each (indexed) + makeHoleyGeometry( new THREE.OctahedronGeometry( 1.2 ), 3 ), // 8 faces, 1 triangle each + makeHoleyGeometry( new THREE.DodecahedronGeometry( 1.1 ), 9 ), // 12 faces, 3 triangles each + makeHoleyGeometry( new THREE.IcosahedronGeometry( 1.1 ), 3 ) // 20 faces, 1 triangle each + ]; - for ( let index = 0; index < materials.length; index ++ ) { + // table - const material = new THREE.MeshBasicMaterial( { color: materials[ index ].color, side: THREE.DoubleSide, wireframe: true } ); - materialsWireframe.push( material ); + const table = new THREE.Mesh( + new THREE.BoxGeometry( 17, 0.5, 17 ), + new THREE.MeshStandardMaterial( { color: 0x0e696c, roughness: 0.5 } ) + ); + table.position.y = - 0.25; + table.receiveShadow = true; + scene.add( table ); - } + // grid - planeMeshWireframe = new THREE.Mesh( planeGeometry, materialsWireframe ); - boxMeshWireframe = new THREE.Mesh( boxGeometry, materialsWireframe ); + const order = [ + 0, 1, 2, 3, + 3, 4, 0, 1, + 1, 2, 3, 4, + 4, 0, 1, 2 + ]; - boxMesh = new THREE.Mesh( boxGeometry, materials ); + for ( let i = 0; i < 16; i ++ ) { - planeMesh.position.set( - 1.5, - 1, 0 ); - boxMesh.position.set( 1.5, - 0.75, 0 ); - boxMesh.rotation.set( - Math.PI / 8, Math.PI / 4, Math.PI / 4 ); + const row = Math.floor( i / 4 ); + const col = i % 4; - planeMeshWireframe.position.set( - 1.5, 1, 0 ); - boxMeshWireframe.position.set( 1.5, 1.25, 0 ); - boxMeshWireframe.rotation.set( - Math.PI / 8, Math.PI / 4, Math.PI / 4 ); + const mesh = new THREE.Mesh( geometries[ order[ i ] ], materials ); + mesh.castShadow = true; - scene.add( planeMesh, planeMeshWireframe ); - scene.add( boxMesh, boxMeshWireframe ); + mesh.scale.setScalar( 0.5 + row * 0.25 ); + mesh.position.set( ( col - 1.5 ) * 3.5, 0, ( 1.5 - row ) * 3.5 ); + + mesh.position.y = - new THREE.Box3().setFromObject( mesh, true ).min.y - 0.01; - } + scene.add( mesh ); - function animate() { + } - boxMesh.rotation.y += 0.005; - boxMesh.rotation.x += 0.005; - boxMeshWireframe.rotation.y += 0.005; - boxMeshWireframe.rotation.x += 0.005; - renderer.render( scene, camera ); + // listeners + + window.addEventListener( 'resize', onWindowResize ); } + // rebuilds a polyhedron with a hole cut into each polygon face, like an open paper model + + function makeHoleyGeometry( geometry, verticesPerFace, holeScale = 0.6 ) { + + if ( geometry.index !== null ) geometry = geometry.toNonIndexed(); + + const position = geometry.getAttribute( 'position' ); + + const positions = []; + const holey = new THREE.BufferGeometry(); + + for ( let face = 0; face < position.count / verticesPerFace; face ++ ) { + + // collect the unique corner vertices of the face - // gui + const corners = []; + const keys = new Set(); - const gui = renderer.inspector.createParameters( 'Parameters' ); + for ( let i = 0; i < verticesPerFace; i ++ ) { - gui.add( api, 'webgpu' ).onChange( () => { + const v = new THREE.Vector3().fromBufferAttribute( position, face * verticesPerFace + i ); + const key = v.toArray().join( ',' ); - init( ! api.webgpu ); + if ( keys.has( key ) === false ) { - } ); + keys.add( key ); + corners.push( v ); - // listeners + } - window.addEventListener( 'resize', onWindowResize ); + } + + // sort the corners counterclockwise around the face centroid + + const centroid = corners.reduce( ( c, v ) => c.add( v ), new THREE.Vector3() ).divideScalar( corners.length ); + + const normal = new THREE.Vector3().crossVectors( _a.subVectors( corners[ 1 ], corners[ 0 ] ), _b.subVectors( corners[ 2 ], corners[ 0 ] ) ).normalize(); + const tangent = new THREE.Vector3().subVectors( corners[ 0 ], centroid ).normalize(); + const bitangent = new THREE.Vector3().crossVectors( normal, tangent ); + + const angleOf = ( v ) => Math.atan2( _b.subVectors( v, centroid ).dot( bitangent ), _b.dot( tangent ) ); + corners.sort( ( p, q ) => angleOf( p ) - angleOf( q ) ); + + // bridge each outer edge with its scaled-down inner edge + + const inner = corners.map( v => new THREE.Vector3().lerpVectors( centroid, v, holeScale ) ); + + holey.addGroup( positions.length / 3, corners.length * 6, face % materials.length ); + + for ( let i = 0; i < corners.length; i ++ ) { + + const j = ( i + 1 ) % corners.length; + + positions.push( + ...corners[ i ], ...corners[ j ], ...inner[ j ], + ...corners[ i ], ...inner[ j ], ...inner[ i ] + ); + + } + + } + + holey.setAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) ); + holey.computeVertexNormals(); + + return holey; + + } function onWindowResize() { @@ -184,6 +239,14 @@ } + function animate() { + + controls.update(); + + renderer.render( scene, camera ); + + } + diff --git a/test/unit/addons/loaders/SPZLoader.tests.js b/test/unit/addons/loaders/SPZLoader.tests.js index cb378a5a273b96..8ab4a22f0f615c 100644 --- a/test/unit/addons/loaders/SPZLoader.tests.js +++ b/test/unit/addons/loaders/SPZLoader.tests.js @@ -5,7 +5,16 @@ import { unpackSphericalHarmonicsBand } from '../utils/GaussianSplatTestUtils.js const EPS = 1e-6; const SPZ_MAGIC = 0x5053474e; -const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15 ]; +const SH_DEGREE_TO_VECTORS = [ 0, 3, 8, 15, 24 ]; +const ZSTD_STREAMS = { + positions: [ 40, 181, 47, 253, 36, 9, 73, 0, 0, 24, 0, 0, 224, 255, 255, 4, 0, 0, 217, 129, 78, 197 ], + alphas: [ 40, 181, 47, 253, 36, 1, 9, 0, 0, 64, 32, 92, 145, 170 ], + colors: [ 40, 181, 47, 253, 36, 3, 25, 0, 0, 128, 128, 128, 108, 76, 221, 124 ], + scales: [ 40, 181, 47, 253, 36, 3, 25, 0, 0, 160, 160, 160, 45, 81, 165, 163 ], + rotations: [ 40, 181, 47, 253, 36, 4, 33, 0, 0, 0, 0, 0, 192, 45, 193, 30, 7 ], + sh: [ 40, 181, 47, 253, 36, 9, 73, 0, 0, 129, 130, 131, 132, 133, 134, 135, 136, 137, 26, 198, 65, 35 ], + sh4: [ 40, 181, 47, 253, 36, 72, 173, 1, 0, 228, 2, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 200, 1, 0, 182, 65, 25, 114, 99, 11, 195 ] +}; function closeTo( assert, actual, expected, message ) { @@ -21,6 +30,16 @@ function writeInt24( view, offset, value ) { } +function parseSPZ( buffer ) { + + return new Promise( ( resolve, reject ) => { + + new SPZLoader().parse( buffer, resolve, reject ); + + } ); + +} + function createSPZBuffer( shDegree = 0 ) { const raw = new Uint8Array( 16 + 9 + 1 + 3 + 3 + 3 + SH_DEGREE_TO_VECTORS[ shDegree ] * 3 ); @@ -59,13 +78,45 @@ function createSPZBuffer( shDegree = 0 ) { } -function createUnsupportedSPZBuffer( version ) { +function createSPZV4Buffer( { version = 4, shDegree = 1, flags = 0, extensionBytes = new Uint8Array() } = {} ) { + + const streams = shDegree === 0 ? + [ ZSTD_STREAMS.positions, ZSTD_STREAMS.alphas, ZSTD_STREAMS.colors, ZSTD_STREAMS.scales, ZSTD_STREAMS.rotations ] : + [ ZSTD_STREAMS.positions, ZSTD_STREAMS.alphas, ZSTD_STREAMS.colors, ZSTD_STREAMS.scales, ZSTD_STREAMS.rotations, shDegree === 4 ? ZSTD_STREAMS.sh4 : ZSTD_STREAMS.sh ]; + const uncompressedSizes = shDegree === 0 ? + [ 9, 1, 3, 3, 4 ] : + [ 9, 1, 3, 3, 4, SH_DEGREE_TO_VECTORS[ shDegree ] * 3 ]; + const headerSize = 32; + const tocByteOffset = headerSize + extensionBytes.length; + const tocSize = streams.length * 16; + const compressedSize = streams.reduce( ( sum, stream ) => sum + stream.length, 0 ); + const raw = new Uint8Array( tocByteOffset + tocSize + compressedSize ); + const view = new DataView( raw.buffer ); + let offset = tocByteOffset + tocSize; - const raw = new ArrayBuffer( 32 ); - const view = new DataView( raw ); view.setUint32( 0, SPZ_MAGIC, true ); view.setUint32( 4, version, true ); - return raw; + view.setUint32( 8, 1, true ); + view.setUint8( 12, shDegree ); + view.setUint8( 13, 4 ); + view.setUint8( 14, flags ); + view.setUint8( 15, streams.length ); + view.setUint32( 16, tocByteOffset, true ); + raw.set( extensionBytes, headerSize ); + + for ( let i = 0; i < streams.length; i ++ ) { + + const stream = streams[ i ]; + const entryOffset = tocByteOffset + i * 16; + + view.setBigUint64( entryOffset, BigInt( stream.length ), true ); + view.setBigUint64( entryOffset + 8, BigInt( uncompressedSizes[ i ] ), true ); + raw.set( stream, offset ); + offset += stream.length; + + } + + return raw.buffer; } @@ -75,10 +126,9 @@ export default QUnit.module( 'Addons', () => { QUnit.module( 'SPZLoader', () => { - QUnit.test( 'parses SPZ v2 fixed-point data', ( assert ) => { + QUnit.test( 'parses SPZ v2 fixed-point data', async ( assert ) => { - const loader = new SPZLoader(); - const data = loader.parse( createSPZBuffer() ); + const data = await parseSPZ( createSPZBuffer() ); const covariances = data.getAttribute( 'covariance' ).array; @@ -92,19 +142,81 @@ export default QUnit.module( 'Addons', () => { } ); - QUnit.test( 'rejects SPZ version 4 and later', ( assert ) => { + QUnit.test( 'parses SPZ v4 ZSTD stream data', async ( assert ) => { + + const data = await parseSPZ( createSPZV4Buffer() ); + const covariances = data.getAttribute( 'covariance' ).array; + + assert.ok( data instanceof BufferGeometry, 'returns BufferGeometry' ); + assert.strictEqual( data.getAttribute( 'position' ).count, 1, 'count' ); + assert.deepEqual( Array.from( data.getAttribute( 'position' ).array ), [ 1.5, - 2, 0.25 ], 'fixed-point centers' ); + closeTo( assert, covariances[ 0 ], 1, 'covariance xx' ); + closeTo( assert, covariances[ 3 ], 1, 'covariance yy' ); + closeTo( assert, covariances[ 5 ], 1, 'covariance zz' ); + assert.deepEqual( Array.from( data.getAttribute( 'color' ).array ), [ 128, 128, 128, 64 ], 'degree-0 color and alpha' ); + assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), [ + 129, 130, 131, + 132, 133, 134, + 135, 136, 137 + ], 'SH1 coefficients' ); + + } ); + + QUnit.test( 'warns when skipping SPZ v4 vendor extensions', async ( assert ) => { + + const warn = console.warn; + const warnings = []; + + console.warn = ( message ) => { + + warnings.push( message ); + + }; + + try { + + await parseSPZ( createSPZV4Buffer( { + flags: 0x02, + extensionBytes: new Uint8Array( [ 0x02, 0x00, 0xbe, 0xad, 0x01, 0x00, 0x00, 0x00, 0xff ] ) + } ) ); + + } finally { + + console.warn = warn; + + } + + assert.strictEqual( warnings.length, 1, 'emits one warning' ); + assert.ok( /vendor extensions/.test( warnings[ 0 ] ), 'warning mentions extensions' ); + + } ); + + QUnit.test( 'parses SPZ v4 spherical harmonics degree 4 as degree 3 data', async ( assert ) => { + + const data = await parseSPZ( createSPZV4Buffer( { shDegree: 4 } ) ); + + 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' ); + assert.strictEqual( data.getAttribute( 'sphericalHarmonics4' ), undefined, 'SH4 coefficients are ignored' ); + + } ); - const loader = new SPZLoader(); + QUnit.test( 'rejects unsupported SPZ versions', async ( assert ) => { - 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' ); + await assert.rejects( parseSPZ( createSPZV4Buffer( { version: 5 } ) ), /SPZ version 5 is not supported/, 'SPZ v5' ); } ); - QUnit.test( 'parses SPZ spherical harmonics degree 1 data', ( assert ) => { + QUnit.test( 'parses SPZ spherical harmonics degree 1 data', async ( assert ) => { - const loader = new SPZLoader(); - const data = loader.parse( createSPZBuffer( 1 ) ); + const data = await parseSPZ( createSPZBuffer( 1 ) ); assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics1' ).array, 1, 1 ) ), [ 129, 130, 131, @@ -114,10 +226,9 @@ export default QUnit.module( 'Addons', () => { } ); - QUnit.test( 'parses SPZ spherical harmonics degree 2 data', ( assert ) => { + QUnit.test( 'parses SPZ spherical harmonics degree 2 data', async ( assert ) => { - const loader = new SPZLoader(); - const data = loader.parse( createSPZBuffer( 2 ) ); + const data = await parseSPZ( createSPZBuffer( 2 ) ); assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics2' ).array, 1, 2 ) ), [ 138, 139, 140, @@ -129,10 +240,9 @@ export default QUnit.module( 'Addons', () => { } ); - QUnit.test( 'parses SPZ spherical harmonics degree 3 data', ( assert ) => { + QUnit.test( 'parses SPZ spherical harmonics degree 3 data', async ( assert ) => { - const loader = new SPZLoader(); - const data = loader.parse( createSPZBuffer( 3 ) ); + const data = await parseSPZ( createSPZBuffer( 3 ) ); assert.deepEqual( Array.from( unpackSphericalHarmonicsBand( data.getAttribute( 'sphericalHarmonics3' ).array, 1, 3 ) ), [ 153, 154, 155,