Forráskód Böngészése

WebGPURenderer: RenderBundle (#28347)

* wip

* add demo

* add gpu metrics

* fix bundeType condition

* cleanup

* refactor and cleanup

* support postprocess and multisample

* update

* cache and pbr on bundle example

* wip static mode

* update

* update

* revert shared

* ci

* circular dep

* move the logic to the renderContext

* add screenshot for ci

* cleanup

* new RenderBundle API

* TODO: Need to handle FBO too

* cleanup

* more cleanup

* fix deepscan

* fix framebuffer

* update example

* update scene too

* reuse correct scene for update matrices

* introduce renderBundle.needsUpdate and rename to private _renderBundle()

* cleanup

* improve example

* fix capsule constructor

* remove confusing gui in example

* Adding RenderBundles and Group.static

---------

Co-authored-by: sunag <[email protected]>
Renaud Rohlinger 1 éve
szülő
commit
82b78e710c

+ 2 - 1
examples/files.json

@@ -386,7 +386,8 @@
 		"webgpu_instancing_morph",
 		"webgpu_instancing_morph",
 		"webgpu_texturegrad",
 		"webgpu_texturegrad",
 		"webgpu_volume_cloud",
 		"webgpu_volume_cloud",
-		"webgpu_volume_perlin"
+		"webgpu_volume_perlin",
+		"webgpu_renderbundle"
 	],
 	],
 	"webaudio": [
 	"webaudio": [
 		"webaudio_orientation",
 		"webaudio_orientation",

+ 18 - 0
examples/jsm/renderers/common/RenderBundle.js

@@ -0,0 +1,18 @@
+class RenderBundle {
+
+	constructor( scene, camera ) {
+
+		this.scene = scene;
+		this.camera = camera;
+
+	}
+
+	clone() {
+
+		return Object.assign( new this.constructor(), this );
+
+	}
+
+}
+
+export default RenderBundle;

+ 38 - 0
examples/jsm/renderers/common/RenderBundles.js

@@ -0,0 +1,38 @@
+import ChainMap from './ChainMap.js';
+import RenderBundle from './RenderBundle.js';
+
+class RenderBundles {
+
+	constructor() {
+
+		this.lists = new ChainMap();
+
+	}
+
+	get( scene, camera ) {
+
+		const lists = this.lists;
+		const keys = [ scene, camera ];
+
+		let list = lists.get( keys );
+
+		if ( list === undefined ) {
+
+			list = new RenderBundle( scene, camera );
+			lists.set( keys, list );
+
+		}
+
+		return list;
+
+	}
+
+	dispose() {
+
+		this.lists = new ChainMap();
+
+	}
+
+}
+
+export default RenderBundles;

+ 9 - 0
examples/jsm/renderers/common/RenderList.js

@@ -57,6 +57,7 @@ class RenderList {
 
 
 		this.opaque = [];
 		this.opaque = [];
 		this.transparent = [];
 		this.transparent = [];
+		this.bundles = [];
 
 
 		this.lightsNode = new LightsNode( [] );
 		this.lightsNode = new LightsNode( [] );
 		this.lightsArray = [];
 		this.lightsArray = [];
@@ -71,6 +72,8 @@ class RenderList {
 
 
 		this.opaque.length = 0;
 		this.opaque.length = 0;
 		this.transparent.length = 0;
 		this.transparent.length = 0;
+		this.bundles.length = 0;
+
 		this.lightsArray.length = 0;
 		this.lightsArray.length = 0;
 
 
 		this.occlusionQueryCount = 0;
 		this.occlusionQueryCount = 0;
@@ -135,6 +138,12 @@ class RenderList {
 
 
 	}
 	}
 
 
+	pushBundle( group ) {
+
+		this.bundles.push( group );
+
+	}
+
 	pushLight( light ) {
 	pushLight( light ) {
 
 
 		this.lightsArray.push( light );
 		this.lightsArray.push( light );

+ 127 - 1
examples/jsm/renderers/common/Renderer.js

@@ -15,6 +15,7 @@ import ClippingContext from './ClippingContext.js';
 import { Scene, Frustum, Matrix4, Vector2, Vector3, Vector4, DoubleSide, BackSide, FrontSide, SRGBColorSpace, NoColorSpace, NoToneMapping, LinearFilter, LinearSRGBColorSpace, RenderTarget, HalfFloatType, RGBAFormat } from 'three';
 import { Scene, Frustum, Matrix4, Vector2, Vector3, Vector4, DoubleSide, BackSide, FrontSide, SRGBColorSpace, NoColorSpace, NoToneMapping, LinearFilter, LinearSRGBColorSpace, RenderTarget, HalfFloatType, RGBAFormat } from 'three';
 import { NodeMaterial } from '../../nodes/Nodes.js';
 import { NodeMaterial } from '../../nodes/Nodes.js';
 import QuadMesh from '../../objects/QuadMesh.js';
 import QuadMesh from '../../objects/QuadMesh.js';
+import RenderBundles from './RenderBundles.js';
 
 
 const _scene = new Scene();
 const _scene = new Scene();
 const _drawingBufferSize = new Vector2();
 const _drawingBufferSize = new Vector2();
@@ -87,6 +88,7 @@ class Renderer {
 		this._bindings = null;
 		this._bindings = null;
 		this._objects = null;
 		this._objects = null;
 		this._pipelines = null;
 		this._pipelines = null;
+		this._bundles = null;
 		this._renderLists = null;
 		this._renderLists = null;
 		this._renderContexts = null;
 		this._renderContexts = null;
 		this._textures = null;
 		this._textures = null;
@@ -111,6 +113,7 @@ class Renderer {
 
 
 		this._renderObjectFunction = null;
 		this._renderObjectFunction = null;
 		this._currentRenderObjectFunction = null;
 		this._currentRenderObjectFunction = null;
+		this._currentRenderBundle = null;
 
 
 		this._handleObjectFunction = this._renderObjectDirect;
 		this._handleObjectFunction = this._renderObjectDirect;
 
 
@@ -171,6 +174,7 @@ class Renderer {
 			this._bindings = new Bindings( backend, this._nodes, this._textures, this._attributes, this._pipelines, this.info );
 			this._bindings = new Bindings( backend, this._nodes, this._textures, this._attributes, this._pipelines, this.info );
 			this._objects = new RenderObjects( this, this._nodes, this._geometries, this._pipelines, this._bindings, this.info );
 			this._objects = new RenderObjects( this, this._nodes, this._geometries, this._pipelines, this._bindings, this.info );
 			this._renderLists = new RenderLists();
 			this._renderLists = new RenderLists();
+			this._bundles = new RenderBundles();
 			this._renderContexts = new RenderContexts();
 			this._renderContexts = new RenderContexts();
 
 
 			//
 			//
@@ -326,6 +330,81 @@ class Renderer {
 
 
 	}
 	}
 
 
+	_renderBundle( bundle, sceneRef, lightsNode ) {
+
+		const { object, camera, renderList } = bundle;
+
+		const renderContext = this._currentRenderContext;
+		const renderContextData = this.backend.get( renderContext );
+
+		//
+
+		const renderBundle = this._bundles.get( object, camera );
+
+		const renderBundleData = this.backend.get( renderBundle );
+		if ( renderBundleData.renderContexts === undefined ) renderBundleData.renderContexts = new Set();
+
+		//
+
+		const renderBundleNeedsUpdate = renderBundleData.renderContexts.has( renderContext ) === false || object.needsUpdate === true;
+
+		renderBundleData.renderContexts.add( renderContext );
+
+		if ( renderBundleNeedsUpdate ) {
+
+			if ( renderContextData.renderObjects === undefined || object.needsUpdate === true ) {
+
+				const nodeFrame = this._nodes.nodeFrame;
+
+				renderContextData.renderObjects = [];
+				renderContextData.renderBundles = [];
+				renderContextData.scene = sceneRef;
+				renderContextData.camera = camera;
+				renderContextData.renderId = nodeFrame.renderId;
+
+				renderContextData.registerBundlesPhase = true;
+
+			}
+
+			this._currentRenderBundle = renderBundle;
+
+			const opaqueObjects = renderList.opaque;
+
+			if ( opaqueObjects.length > 0 ) this._renderObjects( opaqueObjects, camera, sceneRef, lightsNode );
+
+			this._currentRenderBundle = null;
+
+			//
+
+			object.needsUpdate = false;
+
+		} else {
+
+			const renderContext = this._currentRenderContext;
+			const renderContextData = this.backend.get( renderContext );
+
+			for ( let i = 0, l = renderContextData.renderObjects.length; i < l; i ++ ) {
+
+				const renderObject = renderContextData.renderObjects[ i ];
+
+				this._nodes.updateBefore( renderObject );
+
+				//
+
+				renderObject.object.modelViewMatrix.multiplyMatrices( camera.matrixWorldInverse, renderObject.object.matrixWorld );
+				renderObject.object.normalMatrix.getNormalMatrix( renderObject.object.modelViewMatrix );
+
+				this._nodes.updateForRender( renderObject );
+				this._bindings.updateForRender( renderObject );
+
+				this.backend.draw( renderObject, this.info );
+
+			}
+
+		}
+
+	}
+
 	render( scene, camera ) {
 	render( scene, camera ) {
 
 
 		if ( this._initialized === false ) {
 		if ( this._initialized === false ) {
@@ -456,7 +535,6 @@ class Renderer {
 
 
 		if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
 		if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
 
 
-
 		//
 		//
 
 
 		let viewport = this._viewport;
 		let viewport = this._viewport;
@@ -564,8 +642,10 @@ class Renderer {
 
 
 		const opaqueObjects = renderList.opaque;
 		const opaqueObjects = renderList.opaque;
 		const transparentObjects = renderList.transparent;
 		const transparentObjects = renderList.transparent;
+		const bundles = renderList.bundles;
 		const lightsNode = renderList.lightsNode;
 		const lightsNode = renderList.lightsNode;
 
 
+		if ( bundles.length > 0 ) this._renderBundles( bundles, sceneRef, lightsNode );
 		if ( opaqueObjects.length > 0 ) this._renderObjects( opaqueObjects, camera, sceneRef, lightsNode );
 		if ( opaqueObjects.length > 0 ) this._renderObjects( opaqueObjects, camera, sceneRef, lightsNode );
 		if ( transparentObjects.length > 0 ) this._renderObjects( transparentObjects, camera, sceneRef, lightsNode );
 		if ( transparentObjects.length > 0 ) this._renderObjects( transparentObjects, camera, sceneRef, lightsNode );
 
 
@@ -1194,6 +1274,25 @@ class Renderer {
 
 
 		}
 		}
 
 
+		if ( object.static === true ) {
+
+			const baseRenderList = renderList;
+
+			// replace render list
+			renderList = this._renderLists.get( object, camera );
+
+			renderList.begin();
+
+			baseRenderList.pushBundle( {
+				object,
+				camera,
+				renderList,
+			} );
+
+			renderList.finish();
+
+		}
+
 		const children = object.children;
 		const children = object.children;
 
 
 		for ( let i = 0, l = children.length; i < l; i ++ ) {
 		for ( let i = 0, l = children.length; i < l; i ++ ) {
@@ -1204,6 +1303,16 @@ class Renderer {
 
 
 	}
 	}
 
 
+	_renderBundles( bundles, sceneRef, lightsNode ) {
+
+		for ( const bundle of bundles ) {
+
+			this._renderBundle( bundle, sceneRef, lightsNode );
+
+		}
+
+	}
+
 	_renderObjects( renderList, camera, scene, lightsNode ) {
 	_renderObjects( renderList, camera, scene, lightsNode ) {
 
 
 		// process renderable objects
 		// process renderable objects
@@ -1397,8 +1506,25 @@ class Renderer {
 
 
 		//
 		//
 
 
+		if ( this._currentRenderBundle !== null && this._currentRenderBundle.needsUpdate === true ) {
+
+			const renderObjectData = this.backend.get( renderObject );
+
+			renderObjectData.bundleEncoder = undefined;
+			renderObjectData.lastPipelineGPU = undefined;
+
+		}
+
 		this.backend.draw( renderObject, this.info );
 		this.backend.draw( renderObject, this.info );
 
 
+		if ( this._currentRenderBundle !== null ) {
+
+			const renderContextData = this.backend.get( this._currentRenderContext );
+
+			renderContextData.renderObjects.push( renderObject );
+
+		}
+
 	}
 	}
 
 
 	_createObjectPipeline( object, material, scene, camera, lightsNode, passId ) {
 	_createObjectPipeline( object, material, scene, camera, lightsNode, passId ) {

+ 38 - 2
examples/jsm/renderers/webgpu/WebGPUBackend.js

@@ -453,6 +453,13 @@ class WebGPUBackend extends Backend {
 		const renderContextData = this.get( renderContext );
 		const renderContextData = this.get( renderContext );
 		const occlusionQueryCount = renderContext.occlusionQueryCount;
 		const occlusionQueryCount = renderContext.occlusionQueryCount;
 
 
+		if ( renderContextData.renderBundles !== undefined && renderContextData.renderBundles.length > 0 ) {
+
+			renderContextData.registerBundlesPhase = false;
+			renderContextData.currentPass.executeBundles( renderContextData.renderBundles );
+
+		}
+
 		if ( occlusionQueryCount > renderContextData.occlusionQueryIndex ) {
 		if ( occlusionQueryCount > renderContextData.occlusionQueryIndex ) {
 
 
 			renderContextData.currentPass.endOcclusionQuery();
 			renderContextData.currentPass.endOcclusionQuery();
@@ -791,9 +798,22 @@ class WebGPUBackend extends Backend {
 		const pipelineGPU = this.get( pipeline ).pipeline;
 		const pipelineGPU = this.get( pipeline ).pipeline;
 		const currentSets = contextData.currentSets;
 		const currentSets = contextData.currentSets;
 
 
-		// pipeline
+		const renderObjectData = this.get( renderObject );
+
+		const { bundleEncoder, renderBundle, lastPipelineGPU } = renderObjectData;
+
+		const renderContextData = this.get( context );
+
+		if ( renderContextData.registerBundlesPhase === true && bundleEncoder !== undefined && lastPipelineGPU === pipelineGPU ) {
+
+			renderContextData.renderBundles.push( renderBundle );
+			return;
+
+		}
 
 
-		const passEncoderGPU = contextData.currentPass;
+		const passEncoderGPU = this.renderer._currentRenderBundle ? this.createBundleEncoder( context, renderObject ) : contextData.currentPass;
+
+		// pipeline
 
 
 		if ( currentSets.pipeline !== pipelineGPU ) {
 		if ( currentSets.pipeline !== pipelineGPU ) {
 
 
@@ -905,6 +925,16 @@ class WebGPUBackend extends Backend {
 
 
 		}
 		}
 
 
+		
+		if ( this.renderer._currentRenderBundle ) {
+
+			const renderBundle = passEncoderGPU.finish();
+			renderObjectData.lastPipelineGPU = pipelineGPU;
+			renderObjectData.renderBundle = renderBundle;
+			renderObjectData.bundleEncoder = passEncoderGPU;
+
+		}
+
 	}
 	}
 
 
 	// cache key
 	// cache key
@@ -1160,6 +1190,12 @@ class WebGPUBackend extends Backend {
 
 
 	}
 	}
 
 
+	createBundleEncoder( renderContext, renderObject ) {
+
+		return this.pipelineUtils.createBundleEncoder( renderContext, renderObject );
+
+	}
+
 	// bindings
 	// bindings
 
 
 	createBindings( bindings ) {
 	createBindings( bindings ) {

+ 52 - 13
examples/jsm/renderers/webgpu/utils/WebGPUPipelineUtils.js

@@ -23,6 +23,27 @@ class WebGPUPipelineUtils {
 
 
 	}
 	}
 
 
+	_getSampleCount( renderObjectContext ) {
+
+		let sampleCount = this.backend.utils.getSampleCount( renderObjectContext );
+
+		if ( sampleCount > 1 ) {
+
+			// WebGPU only supports power-of-two sample counts and 2 is not a valid value
+			sampleCount = Math.pow( 2, Math.floor( Math.log2( sampleCount ) ) );
+
+			if ( sampleCount === 2 ) {
+
+				sampleCount = 4;
+
+			}
+
+		}
+
+		return sampleCount;
+
+	}
+
 	createRenderPipeline( renderObject, promises ) {
 	createRenderPipeline( renderObject, promises ) {
 
 
 		const { object, material, geometry, pipeline } = renderObject;
 		const { object, material, geometry, pipeline } = renderObject;
@@ -102,22 +123,11 @@ class WebGPUPipelineUtils {
 		const primitiveState = this._getPrimitiveState( object, geometry, material );
 		const primitiveState = this._getPrimitiveState( object, geometry, material );
 		const depthCompare = this._getDepthCompare( material );
 		const depthCompare = this._getDepthCompare( material );
 		const depthStencilFormat = utils.getCurrentDepthStencilFormat( renderObject.context );
 		const depthStencilFormat = utils.getCurrentDepthStencilFormat( renderObject.context );
-		let sampleCount = utils.getSampleCount( renderObject.context );
-
-		if ( sampleCount > 1 ) {
-
-			// WebGPU only supports power-of-two sample counts and 2 is not a valid value
-			sampleCount = Math.pow( 2, Math.floor( Math.log2( sampleCount ) ) );
-
-			if ( sampleCount === 2 ) {
 
 
-				sampleCount = 4;
-
-			}
-
-		}
+		const sampleCount = this._getSampleCount( renderObject.context );
 
 
 		const pipelineDescriptor = {
 		const pipelineDescriptor = {
+			label: 'renderPipeline',
 			vertex: Object.assign( {}, vertexModule, { buffers: vertexBuffers } ),
 			vertex: Object.assign( {}, vertexModule, { buffers: vertexBuffers } ),
 			fragment: Object.assign( {}, fragmentModule, { targets } ),
 			fragment: Object.assign( {}, fragmentModule, { targets } ),
 			primitive: primitiveState,
 			primitive: primitiveState,
@@ -162,6 +172,35 @@ class WebGPUPipelineUtils {
 
 
 	}
 	}
 
 
+	createBundleEncoder( renderContext, renderObject ) {
+
+		const backend = this.backend;
+		const { utils, device } = backend;
+
+		const renderContextData = backend.get( renderContext );
+		const renderObjectData = backend.get( renderObject );
+
+		const depthStencilFormat = utils.getCurrentDepthStencilFormat( renderContext );
+		const colorFormat = utils.getCurrentColorFormat( renderContext );
+		const sampleCount = this._getSampleCount( renderObject.context );
+
+		const descriptor = {
+			label: 'renderBundleEncoder',
+			colorFormats: [ colorFormat ],
+			depthStencilFormat,
+			sampleCount
+		};
+
+		const bundleEncoder = device.createRenderBundleEncoder( descriptor );
+
+		renderObjectData.bundleEncoder = bundleEncoder;
+		renderContextData.currentSets = { attributes: {} };
+		renderContextData._renderBundleViewport = renderContext.width + '_' + renderContext.height;
+
+		return bundleEncoder;
+
+	}
+
 	createComputePipeline( pipeline, bindings ) {
 	createComputePipeline( pipeline, bindings ) {
 
 
 		const backend = this.backend;
 		const backend = this.backend;

BIN
examples/screenshots/webgpu_renderbundle.jpg


+ 318 - 0
examples/webgpu_renderbundle.html

@@ -0,0 +1,318 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<title>three.js webgpu - renderbundle</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> webgpu - renderbundle
+		<br />
+		(WebGL uses 10 times fewer meshes to prevent performance issues.)
+
+	</div>
+
+	<div id="backend" style="position: absolute; top: 200px; left: 0; color: #fff; background-color: rgba(0,0,0,0.75); padding: 5px;">
+		Draw Calls: 0
+	</div>
+
+	<script type="importmap">
+		{
+			"imports": {
+				"three": "../build/three.module.js",
+				"three/addons/": "./jsm/",
+				"three/nodes": "./jsm/nodes/Nodes.js",
+				"stats-gl": "https://cdn.jsdelivr.net/npm/[email protected]/dist/main.js"
+			}
+		}
+	</script>
+
+	<script type="module">
+		import * as THREE from 'three';
+
+		import Stats from 'stats-gl';
+		import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
+		import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+		import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
+		import { MeshToonNodeMaterial } from 'three/nodes';
+
+		let camera, scene, renderer;
+		let controls, stats;
+		let gui;
+		let geometries, group;
+
+		let renderTimeAverages = [];
+		//
+
+		const position = new THREE.Vector3();
+		const rotation = new THREE.Euler();
+		const quaternion = new THREE.Quaternion();
+		const scale = new THREE.Vector3();
+
+		//
+
+		const MAX_GEOMETRY_COUNT = 4000;
+
+		const api = {
+			webgpu: true,
+			renderBundle: true,
+			count: MAX_GEOMETRY_COUNT,
+			opacity: 1,
+		};
+
+
+		init( ! api.webgpu );
+
+		//
+
+		function randomizeMatrix( matrix ) {
+
+			position.x = Math.random() * 80 - 40;
+			position.y = Math.random() * 80 - 40;
+			position.z = Math.random() * 80 - 40;
+
+			rotation.x = Math.random() * 2 * Math.PI;
+			rotation.y = Math.random() * 2 * Math.PI;
+			rotation.z = Math.random() * 2 * Math.PI;
+
+			quaternion.setFromEuler( rotation );
+
+			const factorScale = api.webgpu ? 1 : 2.0;
+			scale.x = scale.y = scale.z = 0.35 * factorScale + ( Math.random() * 0.5 * factorScale );
+
+			return matrix.compose( position, quaternion, scale );
+
+		}
+
+		function randomizeRotationSpeed( rotation ) {
+
+			rotation.x = Math.random() * .05;
+			rotation.y = Math.random() * .05;
+			rotation.z = Math.random() * .05;
+			return rotation;
+
+		}
+
+		function initGeometries() {
+
+			geometries = [
+				new THREE.ConeGeometry( 1.0, 2.0, 3, 1 ),
+				new THREE.BoxGeometry( 2.0, 2.0, 2.0 ),
+				new THREE.PlaneGeometry( 2.0, 2, 1, 1 ),
+				new THREE.CapsuleGeometry( ),
+				new THREE.CircleGeometry( 1.0, 3 ),
+				new THREE.CylinderGeometry( 1.0, 1.0, 2.0, 3, 1 ),
+				new THREE.DodecahedronGeometry( 1.0, 0 ),
+				new THREE.IcosahedronGeometry( 1.0, 0 ),
+				new THREE.OctahedronGeometry( 1.0, 0 ),
+				new THREE.PolyhedronGeometry( [ 0, 0, 0 ], [ 0, 0, 0 ], 1, 0 ),
+				new THREE.RingGeometry( 1.0, 1.5, 3 ),
+				new THREE.SphereGeometry( 1.0, 3, 2 ),
+				new THREE.TetrahedronGeometry( 1.0, 0 ),
+				new THREE.TorusGeometry( 1.0, 0.5, 3, 3 ),
+				new THREE.TorusKnotGeometry( 1.0, 0.5, 20, 3, 1, 1 ),
+			];
+
+		}
+
+
+
+
+		function cleanup() {
+
+			if ( group ) {
+
+				group.parent.remove( group );
+
+				if ( group.dispose ) {
+
+					group.dispose();
+
+				}
+
+			}
+
+		}
+
+		function initMesh( count ) {
+
+			cleanup();
+			initRegularMesh( count );
+
+		}
+
+
+		function initRegularMesh( count ) {
+
+			group = new THREE.Group();
+			group.static = api.renderBundle;
+
+			for ( let i = 0; i < count; i ++ ) {
+
+				const material = new MeshToonNodeMaterial( {
+					color: new THREE.Color( Math.random() * 0xffffff ),
+					side: THREE.DoubleSide,
+				} );
+
+				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() );
+				child.frustumCulled = false;
+				group.add( child );
+
+			}
+
+			scene.add( group );
+
+		}
+
+		async function init( forceWebGL = false ) {
+
+			const count = api.count / ( api.webgpu ? 1 : 10 );
+
+			renderTimeAverages = [];
+
+			if ( renderer ) {
+
+				renderer.dispose();
+				controls.dispose();
+				document.body.removeChild( stats.dom );
+				document.body.removeChild( renderer.domElement );
+
+			}
+
+			// camera
+
+			const aspect = window.innerWidth / window.innerHeight;
+
+			camera = new THREE.PerspectiveCamera( 70, aspect, 1, 100 );
+			camera.position.z = 50;
+
+			// renderer
+
+			renderer = new WebGPURenderer( { antialias: true, forceWebGL } );
+			renderer.setPixelRatio( window.devicePixelRatio );
+			renderer.setSize( window.innerWidth, window.innerHeight );
+
+			renderer.setAnimationLoop( animate );
+
+			// scene
+
+			scene = new THREE.Scene();
+			scene.background = new THREE.Color( 0xc1c1c1 );
+
+			const light = new THREE.DirectionalLight( 0xffffff, 3.4 );
+			scene.add( light );
+
+			document.body.appendChild( renderer.domElement );
+
+			await renderer.init();
+
+
+			initGeometries();
+			initMesh( count );
+
+			controls = new OrbitControls( camera, renderer.domElement );
+			controls.autoRotate = true;
+			controls.autoRotateSpeed = 1.0;
+
+			// stats
+
+			stats = new Stats( {
+				precision: 3,
+				horizontal: false
+			} );
+			// stats.init( renderer );
+			document.body.appendChild( stats.dom );
+			stats.dom.style.position = 'absolute';
+
+			// gui
+
+			gui = new GUI();
+			gui.add( api, 'renderBundle' ).onChange( () => {
+
+				init( ! api.webgpu );
+
+			} );
+
+			gui.add( api, 'webgpu' ).onChange( () => {
+
+				init( ! api.webgpu );
+
+			} );
+
+			// 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 );
+				group.needsUpdate = true;
+		
+			}
+
+			async function animate() {
+
+				animateMeshes();
+
+				controls.update();
+
+				const renderTimeAverage = performance.now();
+				renderer.render( scene, camera );
+
+				// push only the last 60 render times
+				renderTimeAverages.push( performance.now() - renderTimeAverage );
+				if ( renderTimeAverages.length > 60 ) renderTimeAverages.shift();
+		
+				const average = renderTimeAverages.reduce( ( a, b ) => a + b, 0 ) / renderTimeAverages.length;
+				stats.update();
+
+				document.getElementById( 'backend' ).innerText = `Average Render Time ${api.renderBundle ? '(Bundle)' : ''}: ` + average.toFixed( 2 ) + 'ms';
+
+			}
+
+			function animateMeshes() {
+
+				const count = api.count / ( api.webgpu ? 1 : 10 );
+				const countDynamic = api.dynamic / ( api.webgpu ? 1 : 10 );
+
+				const loopNum = Math.min( count, countDynamic );
+
+
+				for ( let i = 0; i < loopNum; i ++ ) {
+
+					const child = group.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
+					);
+
+				}
+
+			}
+
+}
+	</script>
+
+</body>
+</html>

+ 1 - 0
test/e2e/puppeteer.js

@@ -144,6 +144,7 @@ const exceptionList = [
 	'webgpu_custom_fog',
 	'webgpu_custom_fog',
 	'webgpu_instancing_morph',
 	'webgpu_instancing_morph',
 	'webgpu_mesh_batch',
 	'webgpu_mesh_batch',
+	'webgpu_renderbundle',
 	'webgpu_texturegrad',
 	'webgpu_texturegrad',
 
 
 	// WebGPU idleTime and parseTime too low
 	// WebGPU idleTime and parseTime too low