From 6f411b5f218973dfdb25e1d8e444c76142dc1714 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Fri, 13 Nov 2026 16:11:29 -0400 Subject: [PATCH] feat(demo): three.js yard drawn as Meshes, a BatchedMesh and InstancedMeshes The same parts on WebGPURenderer in three draw modes, switchable at run time, with fps, draw calls and render time reported once a second. It shows what batching saves on a scene that would otherwise issue one draw, bind group and uniform upload per part. --- .../examples/webgpu_batched_mesh.ts | 250 ++++++++++++++++++ tools/demo/canvas-three/index.ts | 14 + 2 files changed, 264 insertions(+) create mode 100644 tools/demo/canvas-three/examples/webgpu_batched_mesh.ts diff --git a/tools/demo/canvas-three/examples/webgpu_batched_mesh.ts b/tools/demo/canvas-three/examples/webgpu_batched_mesh.ts new file mode 100644 index 000000000..a87ee35e6 --- /dev/null +++ b/tools/demo/canvas-three/examples/webgpu_batched_mesh.ts @@ -0,0 +1,250 @@ +import * as THREE from 'three/webgpu'; +import type { Canvas } from '@nativescript/canvas'; + +/** + * The same yard of parts drawn three ways on WebGPURenderer, to compare draw-call cost: + * - `meshes`: one Mesh per part (one draw, bind group and uniform upload each); + * - `batched`: one BatchedMesh with per-instance colours (one draw per visible part, no state + * changes between them); + * - `instanced`: one InstancedMesh per geometry (one draw per geometry). + */ +export type DrawMode = 'meshes' | 'batched' | 'instanced'; + +export interface BatchedMeshStats { + mode: DrawMode; + count: number; + fps: number; + draws: number; + triangles: number; + /** Median renderer.render() time over the last second, in ms. */ + renderMs: number; +} + +export interface BatchedMeshOptions { + /** Parts in the yard. */ + count?: number; + mode?: DrawMode; + /** Once a second. */ + onStats?: (stats: BatchedMeshStats) => void; +} + +export interface BatchedMeshDemo { + setMode(mode: DrawMode): void; + setCount(count: number): void; + dispose(): void; +} + +/** BatchedMesh needs every geometry indexed: the polyhedra are not. */ +function indexed(geometry: THREE.BufferGeometry) { + if (!geometry.index) { + const count = geometry.attributes.position.count; + const index = new Uint32Array(count); + for (let i = 0; i < count; i++) index[i] = i; + geometry.setIndex(new THREE.BufferAttribute(index, 1)); + } + return geometry; +} + +const GEOMETRIES = () => [new THREE.BoxGeometry(1, 1, 1), new THREE.SphereGeometry(0.6, 10, 8), new THREE.TorusKnotGeometry(0.4, 0.15, 32, 6), new THREE.CylinderGeometry(0.4, 0.5, 1, 10), new THREE.ConeGeometry(0.5, 1, 10), new THREE.IcosahedronGeometry(0.6, 0), new THREE.TorusGeometry(0.5, 0.2, 8, 16), new THREE.DodecahedronGeometry(0.6), new THREE.OctahedronGeometry(0.6), new THREE.CapsuleGeometry(0.3, 0.6, 2, 8), new THREE.TetrahedronGeometry(0.7), new THREE.RingGeometry(0.2, 0.6, 12)].map(indexed); + +const PALETTE = [0xf97316, 0x22d3ee, 0xa3e635, 0xe879f9, 0xfacc15, 0x60a5fa, 0xf87171, 0x34d399]; + +/** Where part `i` sits: a spiral that spreads outwards as the yard grows. */ +function placement(i: number, out: THREE.Object3D) { + const ring = Math.sqrt(i); + const angle = i * 2.399; + out.position.set(Math.cos(angle) * ring * 1.6, ((i % 7) - 3) * 2, Math.sin(angle) * ring * 1.6 - 10); + out.rotation.set(0, 0, 0); + out.scale.setScalar(0.8 + (i % 5) * 0.1); +} + +export async function webgpu_batched_mesh(canvas: Canvas, options: BatchedMeshOptions = {}): Promise { + let count = options.count ?? 2048; + let mode: DrawMode = options.mode ?? 'batched'; + + const scale = window.devicePixelRatio; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + canvas.width = width * scale; + canvas.height = height * scale; + + const renderer = new THREE.WebGPURenderer({ canvas: canvas as never, antialias: true }); + renderer.setPixelRatio(scale); + renderer.setSize(width, height, false); + await renderer.init(); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x101820); + const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 500); + camera.position.set(0, 14, 44); + camera.lookAt(0, 0, 0); + scene.add(new THREE.HemisphereLight(0xffffff, 0x404060, 1.2)); + const sun = new THREE.DirectionalLight(0xffffff, 1.5); + sun.position.set(10, 20, 10); + scene.add(sun); + + const geometries = GEOMETRIES(); + const materials = PALETTE.map((color) => new THREE.MeshStandardMaterial({ color, metalness: 0.3, roughness: 0.6 })); + // One material for the batched and instanced parts: their colour is per instance. + const shared = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 0.3, roughness: 0.6 }); + const colors = PALETTE.map((color) => new THREE.Color(color)); + + const dummy = new THREE.Object3D(); + const matrix = new THREE.Matrix4(); + let group: THREE.Group | null = null; + let batched: THREE.BatchedMesh | null = null; + let instanced: THREE.InstancedMesh[] = []; + /** `instanced` only: the index of part `i` inside its geometry's mesh. */ + let slot: Uint32Array = new Uint32Array(0); + + function clear() { + if (group) { + scene.remove(group); + group = null; + } + if (batched) { + scene.remove(batched); + batched.dispose(); + batched = null; + } + for (const mesh of instanced) { + scene.remove(mesh); + mesh.dispose(); + } + instanced = []; + } + + function build() { + clear(); + if (mode === 'meshes') { + group = new THREE.Group(); + for (let i = 0; i < count; i++) { + const mesh = new THREE.Mesh(geometries[i % geometries.length], materials[i % materials.length]); + placement(i, mesh); + group.add(mesh); + } + scene.add(group); + } else if (mode === 'batched') { + let vertices = 0; + let indices = 0; + for (const geometry of geometries) { + vertices += geometry.attributes.position.count; + indices += geometry.index ? geometry.index.count : 0; + } + batched = new THREE.BatchedMesh(count, vertices, indices, shared); + // Sorting per frame costs JS time per part; the parts are opaque, so skip it. + batched.sortObjects = false; + const ids = geometries.map((geometry) => batched!.addGeometry(geometry)); + for (let i = 0; i < count; i++) { + const id = batched.addInstance(ids[i % ids.length]); + placement(i, dummy); + dummy.updateMatrix(); + batched.setMatrixAt(id, dummy.matrix); + batched.setColorAt(id, colors[i % colors.length]); + } + scene.add(batched); + } else { + const per = geometries.map(() => 0); + slot = new Uint32Array(count); + for (let i = 0; i < count; i++) { + slot[i] = per[i % geometries.length]++; + } + instanced = geometries.map((geometry, g) => { + const mesh = new THREE.InstancedMesh(geometry, shared, Math.max(1, per[g])); + mesh.count = per[g]; + scene.add(mesh); + return mesh; + }); + for (let i = 0; i < count; i++) { + const mesh = instanced[i % geometries.length]; + placement(i, dummy); + dummy.updateMatrix(); + mesh.setMatrixAt(slot[i], dummy.matrix); + mesh.setColorAt(slot[i], colors[i % colors.length]); + } + for (const mesh of instanced) { + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + } + } + } + + /** Spins every part, the same work in each mode. */ + function animate(time: number) { + const t = time / 1000; + if (group) { + const children = group.children; + for (let i = 0; i < children.length; i++) { + children[i].rotation.set(t * 0.5, t + i, 0); + } + } else if (batched) { + for (let i = 0; i < count; i++) { + placement(i, dummy); + dummy.rotation.set(t * 0.5, t + i, 0); + dummy.updateMatrix(); + batched.setMatrixAt(i, dummy.matrix); + } + } else { + for (let i = 0; i < count; i++) { + placement(i, dummy); + dummy.rotation.set(t * 0.5, t + i, 0); + dummy.updateMatrix(); + instanced[i % geometries.length].setMatrixAt(slot[i], dummy.matrix); + } + for (const mesh of instanced) { + mesh.instanceMatrix.needsUpdate = true; + } + } + } + + let frames = 0; + let renders: number[] = []; + let lastStats = 0; + let disposed = false; + + build(); + renderer.setAnimationLoop((time: number) => { + if (disposed) return; + animate(time); + const r0 = performance.now(); + renderer.render(scene, camera); + renders.push(performance.now() - r0); + frames++; + if (lastStats === 0) lastStats = time; + if (time - lastStats >= 1000) { + const sorted = renders.sort((a, b) => a - b); + options.onStats?.({ + mode, + count, + fps: (frames * 1000) / (time - lastStats), + draws: renderer.info.render.drawCalls, + triangles: renderer.info.render.triangles, + renderMs: sorted[sorted.length >> 1] ?? 0, + }); + frames = 0; + renders = []; + lastStats = time; + } + }); + + return { + setMode(next) { + if (next !== mode) { + mode = next; + build(); + } + }, + setCount(next) { + if (next !== count) { + count = next; + build(); + } + }, + dispose() { + disposed = true; + renderer.setAnimationLoop(null); + clear(); + renderer.dispose(); + }, + }; +} diff --git a/tools/demo/canvas-three/index.ts b/tools/demo/canvas-three/index.ts index 4144960a0..1dbc3acd0 100644 --- a/tools/demo/canvas-three/index.ts +++ b/tools/demo/canvas-three/index.ts @@ -51,6 +51,7 @@ import { SimplexNoise } from 'three/examples/jsm/math/SimplexNoise'; // import { RGBELoader, GLTFLoader, OrbitControls, SimplexNoise, DRACOLoader } from 'three-stdlib'; import { tiny_poly_world } from './games/tiny_poly_world'; import { tiny_poly_world_webgpu } from './games/tiny_poly_world_webgpu'; +import { webgpu_batched_mesh, DrawMode } from './examples/webgpu_batched_mesh'; import { device } from '@nativescript/core/platform'; import { ghost_card } from './examples/ghost_card'; @@ -85,6 +86,7 @@ export class DemoSharedCanvasThree extends DemoSharedBase { //this.webgpu_backdrop(this.canvas); this.webgpu_1m_particles(this.canvas); //this.webgpu_cube(args.object); + //this.webgpu_batched_mesh(this.canvas); //this.webGPUGtlfLoader(this.canvas); //this.webgpu_tsl_galaxy(this.canvas); //webgl_materials_lightmap(this.canvas); @@ -354,6 +356,18 @@ export class DemoSharedCanvasThree extends DemoSharedBase { } } + /** The same parts drawn as Meshes, a BatchedMesh and InstancedMeshes, 10 s each, with the draw count logged. */ + async webgpu_batched_mesh(canvas: Canvas) { + const demo = await webgpu_batched_mesh(canvas, { + count: 2048, + mode: 'meshes', + onStats: (stats) => console.log(`${stats.mode}: ${stats.count} parts, ${stats.draws} draws, ${stats.fps.toFixed(1)} fps, render ${stats.renderMs.toFixed(1)} ms`), + }); + const modes: DrawMode[] = ['meshes', 'batched', 'instanced']; + let next = 1; + setInterval(() => demo.setMode(modes[next++ % modes.length]), 10000); + } + async webgpu_cube(canvas: Canvas) { var camera: THREE.PerspectiveCamera, scene: THREE.Scene, renderer: any; var geometry: THREE.BoxGeometry, material: THREE.MeshNormalMaterial, mesh: THREE.Mesh;