Browse Source

BatchedMesh addon (#25059)

* BatchedMesh addon

* Update webgl_mesh_batch.html

Improve code style.

* Update webgl_mesh_batch.html

Formatting.

* Update BatchedMesh.js

Clean up.

---------

Co-authored-by: Michael Herzog <[email protected]>
Takahiro 1 year ago
parent
commit
5581ea6eb7

+ 1 - 0
examples/files.json

@@ -170,6 +170,7 @@
 		"webgl_materials_wireframe",
 		"webgl_math_obb",
 		"webgl_math_orientation_transform",
+		"webgl_mesh_batch",
 		"webgl_mirror",
 		"webgl_modifier_curve",
 		"webgl_modifier_curve_instanced",

+ 466 - 0
examples/jsm/objects/BatchedMesh.js

@@ -0,0 +1,466 @@
+import {
+	BufferAttribute,
+	BufferGeometry,
+	DataTexture,
+	FloatType,
+	MathUtils,
+	Matrix4,
+	Mesh,
+	RGBAFormat
+} from 'three';
+
+const _identityMatrix = new Matrix4();
+const _zeroMatrix = new Matrix4().set(
+	0, 0, 0, 0,
+	0, 0, 0, 0,
+	0, 0, 0, 0,
+	0, 0, 0, 0
+);
+
+// Custom shaders
+const batchingParsVertex = `
+#ifdef BATCHING
+	attribute float id;
+	uniform highp sampler2D batchingTexture;
+	uniform int batchingTextureSize;
+	mat4 getBatchingMatrix( const in float i ) {
+		float j = i * 4.0;
+		float x = mod( j, float( batchingTextureSize ) );
+		float y = floor( j / float( batchingTextureSize ) );
+		float dx = 1.0 / float( batchingTextureSize );
+		float dy = 1.0 / float( batchingTextureSize );
+		y = dy * ( y + 0.5 );
+		vec4 v1 = texture2D( batchingTexture, vec2( dx * ( x + 0.5 ), y ) );
+		vec4 v2 = texture2D( batchingTexture, vec2( dx * ( x + 1.5 ), y ) );
+		vec4 v3 = texture2D( batchingTexture, vec2( dx * ( x + 2.5 ), y ) );
+		vec4 v4 = texture2D( batchingTexture, vec2( dx * ( x + 3.5 ), y ) );
+		return mat4( v1, v2, v3, v4 );
+	}
+#endif
+`;
+
+const batchingbaseVertex = `
+#ifdef BATCHING
+	mat4 batchingMatrix = getBatchingMatrix( id );
+#endif
+`;
+
+const batchingnormalVertex = `
+#ifdef BATCHING
+	objectNormal = vec4( batchingMatrix * vec4( objectNormal, 0.0 ) ).xyz;
+	#ifdef USE_TANGENT
+		objectTangent = vec4( batchingMatrix * vec4( objectTangent, 0.0 ) ).xyz;
+	#endif
+#endif
+`;
+
+const batchingVertex = `
+#ifdef BATCHING
+	transformed = ( batchingMatrix * vec4( transformed, 1.0 ) ).xyz;
+#endif
+`;
+
+// @TODO: SkinnedMesh support?
+// @TODO: Future work if needed. Move into the core. Can be optimized more with WEBGL_multi_draw.
+
+class BatchedMesh extends Mesh {
+
+	constructor( maxGeometryCount, maxVertexCount, maxIndexCount = maxVertexCount * 2, material ) {
+
+		super( new BufferGeometry(), material );
+
+		this._vertexStarts = [];
+		this._vertexCounts = [];
+		this._indexStarts = [];
+		this._indexCounts = [];
+
+		this._visibles = [];
+		this._alives = [];
+
+		this._maxGeometryCount = maxGeometryCount;
+		this._maxVertexCount = maxVertexCount;
+		this._maxIndexCount = maxIndexCount;
+
+		this._geometryInitialized = false;
+		this._geometryCount = 0;
+		this._vertexCount = 0;
+		this._indexCount = 0;
+
+		// Local matrix per geometry by using data texture
+		// @TODO: Support uniform parameter per geometry
+
+		this._matrices = [];
+		this._matricesArray = null;
+		this._matricesTexture = null;
+		this._matricesTextureSize = null;
+
+		// @TODO: Calculate the entire binding box and make frustumCulled true
+		this.frustumCulled = false;
+
+		this._customUniforms = {
+			batchingTexture: { value: null },
+			batchingTextureSize: { value: 0 }
+		};
+
+		this._initMatricesTexture();
+		this._initShader();
+
+	}
+
+	_initMatricesTexture() {
+
+		// layout (1 matrix = 4 pixels)
+		//      RGBA RGBA RGBA RGBA (=> column1, column2, column3, column4)
+		//  with  8x8  pixel texture max   16 matrices * 4 pixels =  (8 * 8)
+		//       16x16 pixel texture max   64 matrices * 4 pixels = (16 * 16)
+		//       32x32 pixel texture max  256 matrices * 4 pixels = (32 * 32)
+		//       64x64 pixel texture max 1024 matrices * 4 pixels = (64 * 64)
+
+		let size = Math.sqrt( this._maxGeometryCount * 4 ); // 4 pixels needed for 1 matrix
+		size = MathUtils.ceilPowerOfTwo( size );
+		size = Math.max( size, 4 );
+
+		const matricesArray = new Float32Array( size * size * 4 ); // 4 floats per RGBA pixel
+		const matricesTexture = new DataTexture( matricesArray, size, size, RGBAFormat, FloatType );
+
+		this._matricesArray = matricesArray;
+		this._matricesTexture = matricesTexture;
+		this._matricesTextureSize = size;
+
+		this._customUniforms.batchingTexture.value = this._matricesTexture;
+		this._customUniforms.batchingTextureSize.value = this._matricesTextureSize;
+
+	}
+
+	_initShader() {
+
+		const material = this.material;
+		const currentOnBeforeCompile = material.onBeforeCompile;
+		const customUniforms = this._customUniforms;
+
+		material.onBeforeCompile = function onBeforeCompile( parameters, renderer ) {
+
+			// Is this replacement stable across any materials?
+			parameters.vertexShader = parameters.vertexShader
+				.replace(
+					'#include <skinning_pars_vertex>',
+					'#include <skinning_pars_vertex>\n'
+						+ batchingParsVertex
+				)
+				.replace(
+					'#include <skinnormal_vertex>',
+					'#include <skinnormal_vertex>\n'
+						+ batchingbaseVertex
+						+ batchingnormalVertex
+				)
+				.replace(
+					'#include <skinning_vertex>',
+					'#include <skinning_vertex>\n'
+						+ batchingVertex
+				);
+
+			for ( const uniformName in customUniforms ) {
+
+				parameters.uniforms[ uniformName ] = customUniforms[ uniformName ];
+
+			}
+
+			// for debug
+			// console.log( parameters.vertexShader, parameters.uniforms );
+
+			currentOnBeforeCompile.call( this, parameters, renderer );
+
+		};
+
+		material.defines = material.defines || {};
+		material.defines.BATCHING = false;
+
+	}
+
+	getGeometryCount() {
+
+		return this._geometryCount;
+
+	}
+
+	getVertexCount() {
+
+		return this._vertexCount;
+
+	}
+
+	getIndexCount() {
+
+		return this._indexCount;
+
+	}
+
+	applyGeometry( geometry ) {
+
+		// @TODO: geometry.groups support?
+		// @TODO: geometry.drawRange support?
+		// @TODO: geometry.mortphAttributes support?
+
+		if ( this._geometryCount >= this._maxGeometryCount ) {
+
+			// @TODO: Error handling
+
+		}
+
+		if ( this._geometryInitialized === false ) {
+
+			for ( const attributeName in geometry.attributes ) {
+
+				const srcAttribute = geometry.getAttribute( attributeName );
+				const { array, itemSize, normalized } = srcAttribute;
+
+				const dstArray = new array.constructor( this._maxVertexCount * itemSize );
+				const dstAttribute = new srcAttribute.constructor( dstArray, itemSize, normalized );
+				dstAttribute.setUsage( srcAttribute.usage );
+
+				this.geometry.setAttribute( attributeName, dstAttribute );
+
+			}
+
+			if ( geometry.getIndex() !== null ) {
+
+				const indexArray = this._maxVertexCount > 65536
+					? new Uint32Array( this._maxIndexCount )
+					: new Uint16Array( this._maxIndexCount );
+
+				this.geometry.setIndex( new BufferAttribute( indexArray, 1 ) );
+
+			}
+
+			const idArray = this._maxGeometryCount > 65536
+				? new Uint32Array( this._maxVertexCount )
+				: new Uint16Array( this._maxVertexCount );
+			// @TODO: What if attribute name 'id' is already used?
+			this.geometry.setAttribute( 'id', new BufferAttribute( idArray, 1 ) );
+
+			this._geometryInitialized = true;
+
+		} else {
+
+			// @TODO: Check if geometry has the same attributes set
+
+		}
+
+		const hasIndex = this.geometry.getIndex() !== null;
+		const dstIndex = this.geometry.getIndex();
+		const srcIndex = geometry.getIndex();
+
+		// Assuming geometry has position attribute
+		const srcPositionAttribute = geometry.getAttribute( 'position' );
+
+		this._vertexStarts.push( this._vertexCount );
+		this._vertexCounts.push( srcPositionAttribute.count );
+
+		if ( hasIndex ) {
+
+			this._indexStarts.push( this._indexCount );
+			this._indexCounts.push( srcIndex.count );
+
+		}
+
+		this._visibles.push( true );
+		this._alives.push( true );
+
+		// @TODO: Error handling if exceeding maxVertexCount or maxIndexCount
+
+		for ( const attributeName in geometry.attributes ) {
+
+			const srcAttribute = geometry.getAttribute( attributeName );
+			const dstAttribute = this.geometry.getAttribute( attributeName );
+
+			dstAttribute.array.set( srcAttribute.array, this._vertexCount * dstAttribute.itemSize );
+			dstAttribute.needsUpdate = true;
+
+		}
+
+		if ( hasIndex ) {
+
+			for ( let i = 0; i < srcIndex.count; i ++ ) {
+
+				dstIndex.setX( this._indexCount + i, this._vertexCount + srcIndex.getX( i ) );
+
+			}
+
+			this._indexCount += srcIndex.count;
+			dstIndex.needsUpdate = true;
+
+		}
+
+		const geometryId = this._geometryCount;
+		this._geometryCount ++;
+
+		const idAttribute = this.geometry.getAttribute( 'id' );
+
+		for ( let i = 0; i < srcPositionAttribute.count; i ++ ) {
+
+			idAttribute.setX( this._vertexCount + i, geometryId );
+
+		}
+
+		idAttribute.needsUpdate = true;
+
+		this._vertexCount += srcPositionAttribute.count;
+
+		this._matrices.push( new Matrix4() );
+		_identityMatrix.toArray( this._matricesArray, geometryId * 16 );
+		this._matricesTexture.needsUpdate = true;
+
+		return geometryId;
+
+	}
+
+	deleteGeometry( geometryId ) {
+
+		if ( geometryId >= this._alives.length || this._alives[ geometryId ] === false ) {
+
+			return this;
+
+		}
+
+		this._alives[ geometryId ] = false;
+		_zeroMatrix.toArray( this._matricesArray, geometryId * 16 );
+		this._matricesTexture.needsUpdate = true;
+
+		// User needs to call optimize() to pack the data.
+
+		return this;
+
+	}
+
+	optimize() {
+
+		// @TODO: Implement
+
+		return this;
+
+	}
+
+	setMatrixAt( geometryId, matrix ) {
+
+		// @TODO: Map geometryId to index of the arrays because
+		//        optimize() can make geometryId mismatch the index
+
+		if ( geometryId >= this._matrices.length || this._alives[ geometryId ] === false ) {
+
+			return this;
+
+		}
+
+		this._matrices[ geometryId ].copy( matrix );
+
+		if ( this._visibles[ geometryId ] === true ) {
+
+			matrix.toArray( this._matricesArray, geometryId * 16 );
+			this._matricesTexture.needsUpdate = true;
+
+		}
+
+		return this;
+
+	}
+
+	getMatrixAt( geometryId, matrix ) {
+
+		if ( geometryId >= this._matrices.length || this._alives[ geometryId ] === false ) {
+
+			return matrix;
+
+		}
+
+		return matrix.copy( this._matrices[ geometryId ] );
+
+	}
+
+	setVisibleAt( geometryId, visible ) {
+
+		if ( geometryId >= this._visibles.length || this._alives[ geometryId ] === false ) {
+
+			return this;
+
+		}
+
+		if ( this._visibles[ geometryId ] === visible ) {
+
+			return this;
+
+		}
+
+		if ( visible === true ) {
+
+			this._matrices[ geometryId ].toArray( this._matricesArray, geometryId * 16 );
+
+		} else {
+
+			_zeroMatrix.toArray( this._matricesArray, geometryId * 16 );
+
+		}
+
+		this._matricesTexture.needsUpdate = true;
+		this._visibles[ geometryId ] = visible;
+		return this;
+
+	}
+
+	getVisibleAt( geometryId ) {
+
+		if ( geometryId >= this._visibles.length || this._alives[ geometryId ] === false ) {
+
+			return false;
+
+		}
+
+		return this._visibles[ geometryId ];
+
+	}
+
+	copy( source ) {
+
+		super.copy( source );
+
+		// @TODO: Implement
+
+		return this;
+
+	}
+
+	toJSON( meta ) {
+
+		// @TODO: Implement
+
+		return super.toJSON( meta );
+
+	}
+
+	dispose() {
+
+		// Assuming the geometry is not shared with other meshes
+		this.geometry.dispose();
+
+		this._matricesTexture.dispose();
+		this._matricesTexture = null;
+		return this;
+
+	}
+
+	onBeforeRender( _renderer, _scene, _camera, _geometry, material/*, _group*/ ) {
+
+		material.defines.BATCHING = true;
+
+		// @TODO: Implement frustum culling for each geometry
+
+	}
+
+	onAfterRender( _renderer, _scene, _camera, _geometry, material/*, _group*/ ) {
+
+		material.defines.BATCHING = false;
+
+	}
+
+}
+
+export { BatchedMesh };

BIN
examples/screenshots/webgl_mesh_batch.jpg


+ 320 - 0
examples/webgl_mesh_batch.html

@@ -0,0 +1,320 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<title>three.js webgl - mesh - batch</title>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+	<link type="text/css" rel="stylesheet" href="main.css">
+	<style>
+		#info {
+			background-color: rgba(0,0,0,0.75);
+		}
+	</style>
+</head>
+<body>
+
+	<div id="info">
+
+		<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> webgl - mesh - batch
+
+	</div>
+
+	<script type="importmap">
+		{
+			"imports": {
+				"three": "../build/three.module.js",
+				"three/addons/": "./jsm/"
+			}
+		}
+	</script>
+
+	<script type="module">
+		import * as THREE from 'three';
+
+		import Stats from 'three/addons/libs/stats.module.js';
+		import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
+
+		import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+		import { BatchedMesh } from 'three/addons/objects/BatchedMesh.js';
+
+		let stats, gui, guiStatsEl;
+		let camera, controls, scene, renderer;
+		let geometries, mesh;
+		const ids = [];
+		const dummy = new THREE.Object3D();
+
+		//
+
+		const position = new THREE.Vector3();
+		const rotation = new THREE.Euler();
+		const quaternion = new THREE.Quaternion();
+		const scale = new THREE.Vector3();
+
+		//
+
+		const MAX_GEOMETRY_COUNT = 8192;
+
+		const Method = {
+			BATCHED: 'BATCHED',
+			NAIVE: 'NAIVE'
+		};
+
+		const api = {
+			method: Method.BATCHED,
+			count: 256,
+			dynamic: 16
+		};
+
+		init();
+		initGeometries();
+		initMesh();
+		animate();
+
+		//
+
+		function randomizeMatrix( matrix ) {
+
+			position.x = Math.random() * 40 - 20;
+			position.y = Math.random() * 40 - 20;
+			position.z = Math.random() * 40 - 20;
+
+			rotation.x = Math.random() * 2 * Math.PI;
+			rotation.y = Math.random() * 2 * Math.PI;
+			rotation.z = Math.random() * 2 * Math.PI;
+
+			quaternion.setFromEuler( rotation );
+
+			scale.x = scale.y = scale.z = 0.5 + ( Math.random() * 0.5 );
+
+			return matrix.compose( position, quaternion, scale );
+
+		}
+
+		function randomizeRotationSpeed( rotation ) {
+
+			rotation.x = Math.random() * 0.01;
+			rotation.y = Math.random() * 0.01;
+			rotation.z = Math.random() * 0.01;
+			return rotation;
+
+		}
+
+		function initGeometries() {
+
+			geometries = [
+				new THREE.ConeGeometry( 1.0, 2.0 ),
+				new THREE.BoxGeometry( 2.0, 2.0, 2.0 ),
+				new THREE.SphereGeometry( 1.0 ),
+			];
+
+		}
+
+		function createMaterial() {
+
+			return new THREE.MeshNormalMaterial();
+
+		}
+
+		function cleanup() {
+
+			if ( mesh ) {
+
+				mesh.parent.remove( mesh );
+
+				if ( mesh.dispose ) {
+
+					mesh.dispose();
+
+				}
+
+			}
+
+		}
+
+		function initMesh() {
+
+			cleanup();
+
+			if ( api.method === Method.BATCHED ) {
+
+				initBatchedMesh();
+
+			} else {
+
+				initRegularMesh();
+
+			}
+
+		}
+
+		function initRegularMesh() {
+
+			mesh = new THREE.Group();
+			const material = createMaterial();
+
+			for ( let i = 0; i < api.count; i ++ ) {
+
+				const child = new THREE.Mesh( geometries[ i % geometries.length ], material );
+				randomizeMatrix( child.matrix );
+				child.matrix.decompose( child.position, child.quaternion, child.scale );
+				child.userData.rotationSpeed = randomizeRotationSpeed( new THREE.Euler() );
+				mesh.add( child );
+
+			}
+
+			scene.add( mesh );
+
+		}
+
+		function initBatchedMesh() {
+
+			const geometryCount = api.count;
+			const vertexCount = api.count * 512;
+			const indexCount = api.count * 1024;
+
+			const matrix = new THREE.Matrix4();
+			mesh = new BatchedMesh( geometryCount, vertexCount, indexCount, createMaterial() );
+			mesh.userData.rotationSpeeds = [];
+			ids.length = 0;
+
+			for ( let i = 0; i < api.count; i ++ ) {
+
+				const id = mesh.applyGeometry( geometries[ i % geometries.length ] );
+				mesh.setMatrixAt( id, randomizeMatrix( matrix ) );
+				mesh.userData.rotationSpeeds.push( randomizeRotationSpeed( new THREE.Euler() ) );
+				ids.push( id );
+
+			}
+
+			scene.add( mesh );
+
+		}
+
+		function init() {
+
+			const width = window.innerWidth;
+			const height = window.innerHeight;
+
+			// camera
+
+			camera = new THREE.PerspectiveCamera( 70, width / height, 1, 100 );
+			camera.position.z = 30;
+
+			// renderer
+
+			renderer = new THREE.WebGLRenderer( { antialias: true } );
+			renderer.setPixelRatio( window.devicePixelRatio );
+			renderer.setSize( width, height );
+			document.body.appendChild( renderer.domElement );
+
+			// scene
+
+			scene = new THREE.Scene();
+			scene.background = new THREE.Color( 0xffffff );
+
+			// controls
+
+			controls = new OrbitControls( camera, renderer.domElement );
+			controls.autoRotate = true;
+			controls.autoRotateSpeed = 1.0;
+
+			// stats
+
+			stats = new Stats();
+			document.body.appendChild( stats.dom );
+
+			// gui
+
+			gui = new GUI();
+			gui.add( api, 'count', 1, MAX_GEOMETRY_COUNT ).step( 1 ).onChange( initMesh );
+			gui.add( api, 'dynamic', 0, MAX_GEOMETRY_COUNT ).step( 1 );
+			gui.add( api, 'method', Method ).onChange( initMesh );
+
+			guiStatsEl = document.createElement( 'li' );
+			guiStatsEl.classList.add( 'gui-stats' );
+
+			// listeners
+
+			window.addEventListener( 'resize', onWindowResize );
+
+		}
+
+		//
+
+		function onWindowResize() {
+
+			const width = window.innerWidth;
+			const height = window.innerHeight;
+
+			camera.aspect = width / height;
+			camera.updateProjectionMatrix();
+
+			renderer.setSize( width, height );
+
+		}
+
+		function animate() {
+
+			requestAnimationFrame( animate );
+
+			animateMeshes();
+
+			controls.update();
+			stats.update();
+
+			render();
+
+		}
+
+		function animateMeshes() {
+
+			const loopNum = Math.min( api.count, api.dynamic );
+
+			if ( api.method === Method.BATCHED ) {
+
+				for ( let i = 0; i < loopNum; i ++ ) {
+
+					const rotationSpeed = mesh.userData.rotationSpeeds[ i ];
+					const id = ids[ i ];
+
+					mesh.getMatrixAt( id, dummy.matrix );
+					dummy.matrix.decompose( dummy.position, dummy.quaternion, dummy.scale );
+					dummy.rotation.set(
+						dummy.rotation.x + rotationSpeed.x,
+						dummy.rotation.y + rotationSpeed.y,
+						dummy.rotation.z + rotationSpeed.z
+					);
+					dummy.updateMatrix();
+					mesh.setMatrixAt( id, dummy.matrix );
+
+				}
+
+			} else {
+
+				for ( let i = 0; i < loopNum; i ++ ) {
+
+					const child = mesh.children[ i ];
+					const rotationSpeed = child.userData.rotationSpeed;
+
+					child.rotation.set(
+						child.rotation.x + rotationSpeed.x,
+						child.rotation.y + rotationSpeed.y,
+						child.rotation.z + rotationSpeed.z
+					);
+
+				}
+
+			}
+
+		}
+
+		function render() {
+
+			renderer.render( scene, camera );
+
+		}
+
+	</script>
+
+</body>
+</html>