diff --git a/examples/jsm/Addons.js b/examples/jsm/Addons.js index 226a5b1d84ffd8..b8defa897ad8eb 100644 --- a/examples/jsm/Addons.js +++ b/examples/jsm/Addons.js @@ -149,6 +149,7 @@ export * from './misc/MorphAnimMesh.js'; export * from './misc/MorphBlendMesh.js'; export * from './misc/ProgressiveLightMap.js'; export * from './misc/RollerCoaster.js'; +export * from './misc/TileCreasedNormalsPlugin.js'; export * from './misc/TubePainter.js'; export * from './misc/Volume.js'; export * from './misc/VolumeSlice.js'; diff --git a/examples/jsm/misc/TileCreasedNormalsPlugin.js b/examples/jsm/misc/TileCreasedNormalsPlugin.js new file mode 100644 index 00000000000000..27cc4ca863a044 --- /dev/null +++ b/examples/jsm/misc/TileCreasedNormalsPlugin.js @@ -0,0 +1,270 @@ +import { BufferAttribute } from 'three'; + +/** + * A plugin for `3d-tiles-renderer` that computes creased vertex normals for the + * geometry of each loaded tile: smooth normals everywhere except where faces meet + * at an angle greater than the crease angle. Useful for photogrammetry tile sets + * like Google Photorealistic 3D Tiles which come without vertex normals. + * + * The normals are computed in a Web Worker so tile processing doesn't block the + * main thread. Tiles are displayed once their normals are ready. + * + * ```js + * tiles.registerPlugin( new TileCreasedNormalsPlugin( { creaseAngle: Math.PI / 6 } ) ); + * ``` + * + * @three_import import { TileCreasedNormalsPlugin } from 'three/addons/misc/TileCreasedNormalsPlugin.js'; + */ +class TileCreasedNormalsPlugin { + + /** + * Constructs a new plugin. + * + * @param {Object} [options] - The configuration options. + * @param {number} [options.creaseAngle=Math.PI/3] - The crease angle in radians. + */ + constructor( { creaseAngle = Math.PI / 3 } = {} ) { + + /** + * The crease angle in radians. + * + * @type {number} + */ + this.creaseAngle = creaseAngle; + + this._requestId = 0; + this._pending = new Map(); + + const workerCode = ` + + ${ computeCreasedNormals.toString() } + + onmessage = ( { data } ) => { + + const { id, positions, creaseAngle } = data; + const normals = computeCreasedNormals( positions, creaseAngle ); + postMessage( { id, positions, normals }, [ positions.buffer, normals.buffer ] ); + + }; + + `; + + this._worker = new Worker( URL.createObjectURL( new Blob( [ workerCode ] ) ) ); + this._worker.onmessage = ( { data } ) => { + + this._pending.get( data.id )( data ); + this._pending.delete( data.id ); + + }; + + } + + /** + * Called by the tiles renderer for each loaded tile model. The tile is + * displayed once the returned promise resolves. + * + * @param {Object3D} scene - The tile model. + * @return {Promise} A promise that resolves when all geometries have creased normals. + */ + processTileModel( scene ) { + + const promises = []; + + scene.traverse( ( mesh ) => { + + if ( mesh.geometry ) { + + promises.push( this._processMesh( mesh ) ); + + } + + } ); + + return Promise.all( promises ); + + } + + _processMesh( mesh ) { + + const geometry = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry; + const positions = geometry.attributes.position.array; + + const id = this._requestId ++; + this._worker.postMessage( { id, positions, creaseAngle: this.creaseAngle }, [ positions.buffer ] ); + + return new Promise( ( resolve ) => { + + this._pending.set( id, ( { positions, normals } ) => { + + geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); + geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) ); + mesh.geometry = geometry; + resolve(); + + } ); + + } ); + + } + + /** + * Called by the tiles renderer when the plugin is unregistered or the + * tiles renderer is disposed. + */ + dispose() { + + this._worker.terminate(); + + } + +} + +// Computes creased normals for non-indexed triangle positions. The function is +// self-contained so it can be serialized into the worker. +function computeCreasedNormals( positions, creaseAngle ) { + + const creaseDot = Math.cos( creaseAngle ); + const hashMultiplier = ( 1 + 1e-10 ) * 1e2; + + const vertexCount = positions.length / 3; + const faceCount = vertexCount / 3; + + // compute the normal of each face + const faceNormals = new Float64Array( faceCount * 3 ); + for ( let f = 0; f < faceCount; f ++ ) { + + const f9 = 9 * f; + const ax = positions[ f9 + 0 ], ay = positions[ f9 + 1 ], az = positions[ f9 + 2 ]; + const bx = positions[ f9 + 3 ], by = positions[ f9 + 4 ], bz = positions[ f9 + 5 ]; + const cx = positions[ f9 + 6 ], cy = positions[ f9 + 7 ], cz = positions[ f9 + 8 ]; + + const v1x = cx - bx, v1y = cy - by, v1z = cz - bz; + const v2x = ax - bx, v2y = ay - by, v2z = az - bz; + + const nx = v1y * v2z - v1z * v2y; + const ny = v1z * v2x - v1x * v2z; + const nz = v1x * v2y - v1y * v2x; + + const invLength = 1 / ( Math.sqrt( nx * nx + ny * ny + nz * nz ) || 1 ); + faceNormals[ 3 * f + 0 ] = nx * invLength; + faceNormals[ 3 * f + 1 ] = ny * invLength; + faceNormals[ 3 * f + 2 ] = nz * invLength; + + } + + // assign an id to each vertex, sharing the id between vertices with the same + // quantized position via an open-addressed hash table (slots hold id + 1, 0 means empty) + const vertexIds = new Int32Array( vertexCount ); + const quantized = new Int32Array( vertexCount * 3 ); + + let tableSize = 1; + while ( tableSize < vertexCount * 2 ) tableSize <<= 1; + const tableMask = tableSize - 1; + const table = new Int32Array( tableSize ); + + let uniqueCount = 0; + for ( let i = 0; i < vertexCount; i ++ ) { + + const i3 = 3 * i; + const qx = ~ ~ ( positions[ i3 + 0 ] * hashMultiplier ); + const qy = ~ ~ ( positions[ i3 + 1 ] * hashMultiplier ); + const qz = ~ ~ ( positions[ i3 + 2 ] * hashMultiplier ); + + let slot = ( Math.imul( qx, 73856093 ) ^ Math.imul( qy, 19349663 ) ^ Math.imul( qz, 83492791 ) ) & tableMask; + + while ( true ) { + + const id = table[ slot ]; + + if ( id === 0 ) { + + const q3 = 3 * uniqueCount; + quantized[ q3 + 0 ] = qx; + quantized[ q3 + 1 ] = qy; + quantized[ q3 + 2 ] = qz; + + table[ slot ] = uniqueCount + 1; + vertexIds[ i ] = uniqueCount ++; + break; + + } + + const q3 = 3 * ( id - 1 ); + + if ( quantized[ q3 + 0 ] === qx && quantized[ q3 + 1 ] === qy && quantized[ q3 + 2 ] === qz ) { + + vertexIds[ i ] = id - 1; + break; + + } + + slot = ( slot + 1 ) & tableMask; + + } + + } + + // bucket the faces surrounding each unique vertex position + const bucketOffsets = new Int32Array( uniqueCount + 1 ); + for ( let i = 0; i < vertexCount; i ++ ) bucketOffsets[ vertexIds[ i ] + 1 ] ++; + for ( let i = 0; i < uniqueCount; i ++ ) bucketOffsets[ i + 1 ] += bucketOffsets[ i ]; + + const bucketFaces = new Int32Array( vertexCount ); + const bucketCursors = bucketOffsets.slice( 0, uniqueCount ); + for ( let f = 0; f < faceCount; f ++ ) { + + const f3 = 3 * f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 0 ] ] ++ ] = f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 1 ] ] ++ ] = f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 2 ] ] ++ ] = f; + + } + + // average the normals of the faces surrounding each vertex if they are within the + // provided crease threshold + const normalArray = new Float32Array( vertexCount * 3 ); + for ( let f = 0; f < faceCount; f ++ ) { + + const f3 = 3 * f; + const nx = faceNormals[ f3 + 0 ]; + const ny = faceNormals[ f3 + 1 ]; + const nz = faceNormals[ f3 + 2 ]; + + for ( let n = 0; n < 3; n ++ ) { + + const i = f3 + n; + const id = vertexIds[ i ]; + + let sumX = 0, sumY = 0, sumZ = 0; + + for ( let k = bucketOffsets[ id ], end = bucketOffsets[ id + 1 ]; k < end; k ++ ) { + + const o3 = 3 * bucketFaces[ k ]; + const ox = faceNormals[ o3 + 0 ]; + const oy = faceNormals[ o3 + 1 ]; + const oz = faceNormals[ o3 + 2 ]; + + if ( nx * ox + ny * oy + nz * oz > creaseDot ) { + + sumX += ox; + sumY += oy; + sumZ += oz; + + } + + } + + const invLength = 1 / ( Math.sqrt( sumX * sumX + sumY * sumY + sumZ * sumZ ) || 1 ); + normalArray[ 3 * i + 0 ] = sumX * invLength; + normalArray[ 3 * i + 1 ] = sumY * invLength; + normalArray[ 3 * i + 2 ] = sumZ * invLength; + + } + + } + + return normalArray; + +} + +export { TileCreasedNormalsPlugin }; diff --git a/examples/jsm/utils/BufferGeometryUtils.js b/examples/jsm/utils/BufferGeometryUtils.js index cc30c10d8c80e6..4e1221c238634f 100644 --- a/examples/jsm/utils/BufferGeometryUtils.js +++ b/examples/jsm/utils/BufferGeometryUtils.js @@ -1314,105 +1314,172 @@ function mergeGroups( geometry ) { */ function toCreasedNormals( geometry, creaseAngle = Math.PI / 3 /* 60 degrees */ ) { + // BufferGeometry.toNonIndexed() warns if the geometry is non-indexed + // and returns the original geometry + const resultGeometry = geometry.index ? geometry.toNonIndexed() : geometry; + const posAttr = resultGeometry.attributes.position; + const vertexCount = posAttr.count; + + let positions; + + if ( posAttr.isBufferAttribute === true && posAttr.itemSize === 3 && posAttr.normalized === false ) { + + positions = posAttr.array; + + } else { + + // flatten the position buffer so the math below operates on plain numbers + positions = new Float64Array( vertexCount * 3 ); + + for ( let i = 0; i < vertexCount; i ++ ) { + + positions[ 3 * i + 0 ] = posAttr.getX( i ); + positions[ 3 * i + 1 ] = posAttr.getY( i ); + positions[ 3 * i + 2 ] = posAttr.getZ( i ); + + } + + } + const creaseDot = Math.cos( creaseAngle ); const hashMultiplier = ( 1 + 1e-10 ) * 1e2; + const faceCount = vertexCount / 3; + + // compute the normal of each face + const faceNormals = new Float64Array( faceCount * 3 ); + for ( let f = 0; f < faceCount; f ++ ) { + + const f9 = 9 * f; + const ax = positions[ f9 + 0 ], ay = positions[ f9 + 1 ], az = positions[ f9 + 2 ]; + const bx = positions[ f9 + 3 ], by = positions[ f9 + 4 ], bz = positions[ f9 + 5 ]; + const cx = positions[ f9 + 6 ], cy = positions[ f9 + 7 ], cz = positions[ f9 + 8 ]; - // reusable vectors - const verts = [ new Vector3(), new Vector3(), new Vector3() ]; - const tempVec1 = new Vector3(); - const tempVec2 = new Vector3(); - const tempNorm = new Vector3(); - const tempNorm2 = new Vector3(); + const v1x = cx - bx, v1y = cy - by, v1z = cz - bz; + const v2x = ax - bx, v2y = ay - by, v2z = az - bz; - // hashes a vector - function hashVertex( v ) { + const nx = v1y * v2z - v1z * v2y; + const ny = v1z * v2x - v1x * v2z; + const nz = v1x * v2y - v1y * v2x; - const x = ~ ~ ( v.x * hashMultiplier ); - const y = ~ ~ ( v.y * hashMultiplier ); - const z = ~ ~ ( v.z * hashMultiplier ); - return `${x},${y},${z}`; + const invLength = 1 / ( Math.sqrt( nx * nx + ny * ny + nz * nz ) || 1 ); + faceNormals[ 3 * f + 0 ] = nx * invLength; + faceNormals[ 3 * f + 1 ] = ny * invLength; + faceNormals[ 3 * f + 2 ] = nz * invLength; } - // BufferGeometry.toNonIndexed() warns if the geometry is non-indexed - // and returns the original geometry - const resultGeometry = geometry.index ? geometry.toNonIndexed() : geometry; - const posAttr = resultGeometry.attributes.position; - const vertexMap = {}; + // assign an id to each vertex, sharing the id between vertices with the same + // quantized position via an open-addressed hash table (slots hold id + 1, 0 means empty) + const vertexIds = new Int32Array( vertexCount ); + const quantized = new Int32Array( vertexCount * 3 ); - // find all the normals shared by commonly located vertices - for ( let i = 0, l = posAttr.count / 3; i < l; i ++ ) { + let tableSize = 1; + while ( tableSize < vertexCount * 2 ) tableSize <<= 1; + const tableMask = tableSize - 1; + const table = new Int32Array( tableSize ); + + let uniqueCount = 0; + for ( let i = 0; i < vertexCount; i ++ ) { const i3 = 3 * i; - const a = verts[ 0 ].fromBufferAttribute( posAttr, i3 + 0 ); - const b = verts[ 1 ].fromBufferAttribute( posAttr, i3 + 1 ); - const c = verts[ 2 ].fromBufferAttribute( posAttr, i3 + 2 ); + const qx = ~ ~ ( positions[ i3 + 0 ] * hashMultiplier ); + const qy = ~ ~ ( positions[ i3 + 1 ] * hashMultiplier ); + const qz = ~ ~ ( positions[ i3 + 2 ] * hashMultiplier ); - tempVec1.subVectors( c, b ); - tempVec2.subVectors( a, b ); + let slot = ( Math.imul( qx, 73856093 ) ^ Math.imul( qy, 19349663 ) ^ Math.imul( qz, 83492791 ) ) & tableMask; - // add the normal to the map for all vertices - const normal = new Vector3().crossVectors( tempVec1, tempVec2 ).normalize(); - for ( let n = 0; n < 3; n ++ ) { + while ( true ) { + + const id = table[ slot ]; + + if ( id === 0 ) { + + const q3 = 3 * uniqueCount; + quantized[ q3 + 0 ] = qx; + quantized[ q3 + 1 ] = qy; + quantized[ q3 + 2 ] = qz; + + table[ slot ] = uniqueCount + 1; + vertexIds[ i ] = uniqueCount ++; + break; + + } + + const q3 = 3 * ( id - 1 ); - const vert = verts[ n ]; - const hash = hashVertex( vert ); - if ( ! ( hash in vertexMap ) ) { + if ( quantized[ q3 + 0 ] === qx && quantized[ q3 + 1 ] === qy && quantized[ q3 + 2 ] === qz ) { - vertexMap[ hash ] = []; + vertexIds[ i ] = id - 1; + break; } - vertexMap[ hash ].push( normal ); + slot = ( slot + 1 ) & tableMask; } } - // average normals from all vertices that share a common location if they are within the - // provided crease threshold - const normalArray = new Float32Array( posAttr.count * 3 ); - const normAttr = new BufferAttribute( normalArray, 3, false ); - for ( let i = 0, l = posAttr.count / 3; i < l; i ++ ) { + // bucket the faces surrounding each unique vertex position + const bucketOffsets = new Int32Array( uniqueCount + 1 ); + for ( let i = 0; i < vertexCount; i ++ ) bucketOffsets[ vertexIds[ i ] + 1 ] ++; + for ( let i = 0; i < uniqueCount; i ++ ) bucketOffsets[ i + 1 ] += bucketOffsets[ i ]; - // get the face normal for this vertex - const i3 = 3 * i; - const a = verts[ 0 ].fromBufferAttribute( posAttr, i3 + 0 ); - const b = verts[ 1 ].fromBufferAttribute( posAttr, i3 + 1 ); - const c = verts[ 2 ].fromBufferAttribute( posAttr, i3 + 2 ); + const bucketFaces = new Int32Array( vertexCount ); + const bucketCursors = bucketOffsets.slice( 0, uniqueCount ); + for ( let f = 0; f < faceCount; f ++ ) { + + const f3 = 3 * f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 0 ] ] ++ ] = f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 1 ] ] ++ ] = f; + bucketFaces[ bucketCursors[ vertexIds[ f3 + 2 ] ] ++ ] = f; - tempVec1.subVectors( c, b ); - tempVec2.subVectors( a, b ); + } + + // average the normals of the faces surrounding each vertex if they are within the + // provided crease threshold + const normalArray = new Float32Array( vertexCount * 3 ); + for ( let f = 0; f < faceCount; f ++ ) { - tempNorm.crossVectors( tempVec1, tempVec2 ).normalize(); + const f3 = 3 * f; + const nx = faceNormals[ f3 + 0 ]; + const ny = faceNormals[ f3 + 1 ]; + const nz = faceNormals[ f3 + 2 ]; - // average all normals that meet the threshold and set the normal value for ( let n = 0; n < 3; n ++ ) { - const vert = verts[ n ]; - const hash = hashVertex( vert ); - const otherNormals = vertexMap[ hash ]; - tempNorm2.set( 0, 0, 0 ); + const i = f3 + n; + const id = vertexIds[ i ]; + + let sumX = 0, sumY = 0, sumZ = 0; + + for ( let k = bucketOffsets[ id ], end = bucketOffsets[ id + 1 ]; k < end; k ++ ) { - for ( let k = 0, lk = otherNormals.length; k < lk; k ++ ) { + const o3 = 3 * bucketFaces[ k ]; + const ox = faceNormals[ o3 + 0 ]; + const oy = faceNormals[ o3 + 1 ]; + const oz = faceNormals[ o3 + 2 ]; - const otherNorm = otherNormals[ k ]; - if ( tempNorm.dot( otherNorm ) > creaseDot ) { + if ( nx * ox + ny * oy + nz * oz > creaseDot ) { - tempNorm2.add( otherNorm ); + sumX += ox; + sumY += oy; + sumZ += oz; } } - tempNorm2.normalize(); - normAttr.setXYZ( i3 + n, tempNorm2.x, tempNorm2.y, tempNorm2.z ); + const invLength = 1 / ( Math.sqrt( sumX * sumX + sumY * sumY + sumZ * sumZ ) || 1 ); + normalArray[ 3 * i + 0 ] = sumX * invLength; + normalArray[ 3 * i + 1 ] = sumY * invLength; + normalArray[ 3 * i + 2 ] = sumZ * invLength; } } - resultGeometry.setAttribute( 'normal', normAttr ); + resultGeometry.setAttribute( 'normal', new BufferAttribute( normalArray, 3, false ) ); return resultGeometry; } diff --git a/examples/webgl_loader_3dtiles.html b/examples/webgl_loader_3dtiles.html index 74d4d09867ee9a..3d17209e97e529 100644 --- a/examples/webgl_loader_3dtiles.html +++ b/examples/webgl_loader_3dtiles.html @@ -74,7 +74,7 @@ import * as THREE from 'three'; import { DRACOLoader, DRACO_GLTF_CONFIG } from 'three/addons/loaders/DRACOLoader.js'; - import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js'; + import { TileCreasedNormalsPlugin } from 'three/addons/misc/TileCreasedNormalsPlugin.js'; import { TilesRenderer, GlobeControls, CAMERA_FRAME } from '3d-tiles-renderer'; import { CesiumIonAuthPlugin } from '3d-tiles-renderer/core/plugins'; import { GLTFExtensionsPlugin, TilesFadePlugin, UpdateOnChangePlugin } from '3d-tiles-renderer/three/plugins'; @@ -127,28 +127,10 @@ const DEG2RAD = Math.PI / 180; // tiles - class TileCreasedNormalsPlugin { - - processTileModel( scene ) { - - scene.traverse( ( mesh ) => { - - if ( mesh.geometry ) { - - mesh.geometry = toCreasedNormals( mesh.geometry, 30 * DEG2RAD ); - - } - - } ); - - } - - } - tiles = new TilesRenderer(); tiles.registerPlugin( new CesiumIonAuthPlugin( { apiToken: ION_KEY, assetId: '2275207', autoRefreshToken: true } ) ); tiles.registerPlugin( new GLTFExtensionsPlugin( { dracoLoader } ) ); - tiles.registerPlugin( new TileCreasedNormalsPlugin() ); + tiles.registerPlugin( new TileCreasedNormalsPlugin( { creaseAngle: 30 * DEG2RAD } ) ); tiles.registerPlugin( new TilesFadePlugin() ); tiles.registerPlugin( new UpdateOnChangePlugin() ); tiles.setCamera( camera ); diff --git a/src/renderers/common/RenderList.js b/src/renderers/common/RenderList.js index 272d6b1092e25b..2563fc19260360 100644 --- a/src/renderers/common/RenderList.js +++ b/src/renderers/common/RenderList.js @@ -192,6 +192,18 @@ class RenderList { */ this.occlusionQueryCount = 0; + /** + * The last object that was counted for occlusion query testing. Used to + * avoid counting an object more than once when it produces multiple render + * items (e.g. a mesh with multiple material groups), since such an object + * is covered by a single occlusion query. + * + * @private + * @type {?Object3D} + * @default null + */ + this._lastOcclusionObject = null; + } /** @@ -290,7 +302,12 @@ class RenderList { const renderItem = this.getNextRenderItem( object, geometry, material, groupOrder, z, group, clippingContext ); - if ( object.occlusionTest === true ) this.occlusionQueryCount ++; + if ( object.occlusionTest === true && this._lastOcclusionObject !== object ) { + + this.occlusionQueryCount ++; + this._lastOcclusionObject = object; + + } if ( material.transparent === true || material.transmission > 0 || ( material.transmissionNode && material.transmissionNode.isNode ) || @@ -415,6 +432,8 @@ class RenderList { } + this._lastOcclusionObject = null; + } }