diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/+page.svelte b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/+page.svelte new file mode 100644 index 0000000..083aed7 --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/+page.svelte @@ -0,0 +1,98 @@ + + + + + + + diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/BunnyWorker.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/BunnyWorker.ts new file mode 100644 index 0000000..c406bff --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/BunnyWorker.ts @@ -0,0 +1,54 @@ +let lastTime = performance.now(); + +let positions: Float32Array; +let velocity: Float32Array; + +onmessage = function (e) { + const { positionsBuffer, velocityBuffer, gravity, count, bounds, type, from, to } = e.data; + + if (type == 'setup') { + // x,y + positions = new Float32Array(positionsBuffer); + + // vX, vY + velocity = new Float32Array(velocityBuffer); + } + + function update() { + // Example update: move instances along their velocity vector + const currentTime = performance.now(); + const delta = ((currentTime - lastTime) / 1000) * 200; // Convert to seconds + for (let i = from; i < to; i++) { + positions[i * 2] += velocity[i * 2]; // x += vx + positions[i * 2 + 1] += velocity[i * 2 + 1]; // y += vy + + velocity[i * 2 + 1] += gravity * delta; + + // roll new behaviour if bunny gets out of bounds + + if (positions[i * 2] > bounds.right) { + velocity[i * 2] *= -1; + positions[i * 2] = bounds.right; + } else if (positions[i * 2] < bounds.left) { + velocity[i * 2] *= -1; + positions[i * 2] = bounds.left; + } + + if (positions[i * 2 + 1] > bounds.top) { + velocity[i * 2 + 1] *= -0.85; + positions[i * 2 + 1] = bounds.top; + if (Math.random() > 0.5) { + velocity[i * 2 + 1] -= Math.random() * 6; + } + } else if (positions[i * 2 + 1] < bounds.bottom) { + velocity[i * 2 + 1] *= -1; + positions[i * 2 + 1] = bounds.top; + } + } + lastTime = currentTime * 1; + + // Schedule the next update + setTimeout(update, 0); // roughly 60fps + } + update(); +}; diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/bunny.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/bunny.ts new file mode 100644 index 0000000..08bb1e3 --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/bunny.ts @@ -0,0 +1,170 @@ +import { InstancedSpriteMesh, createSpritesheet } from '@threejs-kit/instanced-sprite-mesh'; +import { + DoubleSide, + Matrix4, + MeshBasicMaterial, + MeshStandardMaterial, + Vector2, + type Scene, + type Vector3Tuple, + type WebGLRenderer +} from 'three'; + +export const initBunnies = async (renderer: WebGLRenderer, scene: Scene, count: number) => { + const bunnies = [ + 'rabbitv3_ash', + 'rabbitv3_batman', + 'rabbitv3_bb8', + 'rabbitv3_frankenstein', + 'rabbitv3_neo', + 'rabbitv3_sonic', + 'rabbitv3_spidey', + 'rabbitv3_stormtrooper', + 'rabbitv3_superman', + 'rabbitv3_tron', + 'rabbitv3_wolverine', + 'rabbitv3' + ]; + + const bunnySpritesheet = createSpritesheet(); + + for (const bunny of bunnies) { + bunnySpritesheet.add( + `/pixi_bunnies/${bunny}.png`, + { + type: 'rowColumn', + width: 1, + height: 1 + }, + [{ name: bunny, frameRange: [0, 0] }] + ); + } + + const { spritesheet, texture } = await bunnySpritesheet.build(); + + const baseMaterial = new MeshBasicMaterial({ + transparent: true, + alphaTest: 0.01, + // needs to be double side for shading + side: DoubleSide, + map: texture + }); + + const sprite = new InstancedSpriteMesh(baseMaterial, count, renderer); + + sprite.fps = 9; + + sprite.hueShift.setGlobal({ + h: 0, + s: 1.1, + v: 1.9 + }); + + sprite.spritesheet = spritesheet; + scene.add(sprite); + + sprite.castShadow = true; + + // UPDATING AND MOVING SPRITES + let dirtyInstanceMatrix = false; + + const tempMatrix = new Matrix4(); + function updatePosition(id: number, [x, y, z]: Vector3Tuple) { + tempMatrix.makeScale(25, 32, 1); + tempMatrix.setPosition(x, bounds.top - y, z); + sprite.setMatrixAt(id, tempMatrix); + // dirtyInstanceMatrix = true; + } + + const gravity = 0.75; + + const positionX: number[] = new Array(count).fill(0); + const positionY: number[] = new Array(count).fill(0); + const zIndex: number[] = new Array(count).fill(0); + + const speedX: number[] = new Array(count).fill(0); + const speedY: number[] = new Array(count).fill(0); + + const { updateAgents } = setupRandomAgents(); + + const bounds = { + left: 0, + right: window.innerWidth, + bottom: 0, + top: window.innerHeight + }; + + function setupRandomAgents() { + for (let i = 0; i < count; i++) { + positionX[i] = 0; + positionY[i] = 0; + zIndex[i] = -Math.random() * 10; + + speedX[i] = Math.random() * 10; + speedY[i] = Math.random() * 10 - 5; + + sprite.animation.setAt(i, bunnies[Math.floor(Math.random() * bunnies.length)]); + } + + const updateAgents = (delta: number) => { + for (let i = 0; i < count; i++) { + delta = 1; + // timer + // apply gravity + + // apply velocity + positionX[i] += speedX[i] * delta; + positionY[i] += speedY[i] * delta; + speedY[i] += gravity * delta; + + // roll new behaviour if bunny gets out of bounds + + if (positionX[i] > bounds.right) { + speedX[i] *= -1; + positionX[i] = bounds.right; + } else if (positionX[i] < bounds.left) { + speedX[i] *= -1; + positionX[i] = bounds.left; + } + + if (positionY[i] > bounds.top) { + speedY[i] *= -0.85; + positionY[i] = bounds.top; + if (Math.random() > 0.5) { + speedY[i] -= Math.random() * 6; + } + } else if (positionY[i] < bounds.bottom) { + speedY[i] *= -1; + positionY[i] = bounds.top; + } + } + + for (let i = 0; i < count; i++) { + updatePosition(i, [positionX[i], positionY[i], zIndex[i]]); + } + }; + sprite.update(); + + return { updateAgents }; + } + + let initialized = false; + + const update = (delta: number) => { + updateAgents(delta); + + // sprite.update(); + + if (dirtyInstanceMatrix) { + sprite.instanceMatrix.needsUpdate = true; + dirtyInstanceMatrix = false; + } + + if (!initialized) { + sprite.update(); + initialized = true; + } + }; + + return { update, sprite, updatePosition }; +}; diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/demon.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/demon.ts new file mode 100644 index 0000000..5008be0 --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/demon.ts @@ -0,0 +1,166 @@ +import { InstancedSpriteMesh, createSpritesheet } from '@threejs-kit/instanced-sprite-mesh'; +import { + DoubleSide, + Matrix4, + MeshBasicMaterial, + type Scene, + type Vector3Tuple, + type WebGLRenderer +} from 'three'; + +export const initDemons = async (renderer: WebGLRenderer, scene: Scene, count: number) => { + const { texture, spritesheet, geometry } = await createSpritesheet() + .add( + '/textures/sprites/cacodaemon.png', + { + type: 'rowColumn', + width: 8, + height: 4 + }, + [ + { name: 'fly', frameRange: [0, 5] }, + { name: 'attack', frameRange: [8, 13] }, + { name: 'idle', frameRange: [16, 19] }, + { name: 'death', frameRange: [24, 31] } + ] + ) + .build({ + makeSlimGeometry: true, + slimOptions: { + vertices: 4, + alphaThreshold: 0.01 + } + }); + + const baseMaterial = new MeshBasicMaterial({ + transparent: true, + alphaTest: 0.01, + // needs to be double side for shading + side: DoubleSide, + map: texture + }); + + const sprite = new InstancedSpriteMesh(baseMaterial, count, renderer, { + geometry + }); + + sprite.fps = 9; + sprite.playmode.setAll('FORWARD'); + sprite.loop.setAll(true); + + sprite.hueShift.setGlobal({ + h: 0, + s: 1.1, + v: 1.9 + }); + + sprite.spritesheet = spritesheet; + scene.add(sprite); + + sprite.castShadow = true; + + // UPDATING AND MOVING SPRITES + let dirtyInstanceMatrix = false; + + const tempMatrix = new Matrix4(); + function updatePosition(id: number, [x, y, z]: Vector3Tuple) { + tempMatrix.makeScale(100, 100, 100); + tempMatrix.setPosition(x, bounds.top - y, z); + sprite.setMatrixAt(id, tempMatrix); + dirtyInstanceMatrix = true; + } + + const gravity = 0.75; + + const positionX: number[] = new Array(count).fill(0); + const positionY: number[] = new Array(count).fill(0); + const zIndex: number[] = new Array(count).fill(0); + + const speedX: number[] = new Array(count).fill(0); + const speedY: number[] = new Array(count).fill(0); + + const setRandomAnimationAt = (id: number) => { + const animations = ['fly', 'attack', 'idle', 'death']; + sprite.animation.setAt(id, animations[Math.floor(Math.random() * animations.length)]); + }; + + function setupRandomAgents() { + for (let i = 0; i < count; i++) { + positionX[i] = 0; + positionY[i] = 0; + zIndex[i] = -Math.random() * 10; + + speedX[i] = Math.random() * 10; + speedY[i] = Math.random() * 10 - 5; + setRandomAnimationAt(i); + // sprite.animation.setAt(i, bunnies[Math.floor(Math.random() * bunnies.length)]); + } + + const updateAgents = (delta: number) => { + for (let i = 0; i < count; i++) { + delta = 1; + // timer + // apply gravity + + // apply velocity + positionX[i] += speedX[i] * delta * 0.5; + positionY[i] += speedY[i] * delta * 0.1; + + // roll new behaviour if bunny gets out of bounds + + if (positionX[i] > bounds.right) { + speedX[i] *= -1; + positionX[i] = bounds.right; + setRandomAnimationAt(i); + sprite.flipX.setAt(i, true); + } else if (positionX[i] < bounds.left) { + speedX[i] *= -1; + positionX[i] = bounds.left; + setRandomAnimationAt(i); + sprite.flipX.setAt(i, false); + } + + if (positionY[i] > bounds.top) { + speedY[i] *= -0.85; + positionY[i] = bounds.top; + setRandomAnimationAt(i); + if (Math.random() > 0.5) { + speedY[i] -= Math.random() * 6; + } + } else if (positionY[i] < bounds.bottom) { + setRandomAnimationAt(i); + speedY[i] *= -1; + positionY[i] = bounds.top; + } + } + + for (let i = 0; i < count; i++) { + updatePosition(i, [positionX[i], positionY[i], zIndex[i]]); + } + }; + + return { updateAgents }; + } + + const { updateAgents } = setupRandomAgents(); + + const bounds = { + left: 0, + right: window.innerWidth, + bottom: 0, + top: window.innerHeight + }; + + const update = (delta: number) => { + updateAgents(delta); + + sprite.update(); + + if (dirtyInstanceMatrix) { + sprite.instanceMatrix.needsUpdate = true; + dirtyInstanceMatrix = false; + } + }; + + return { update, sprite }; +}; diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainBunnies.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainBunnies.ts new file mode 100644 index 0000000..feb5a7b --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainBunnies.ts @@ -0,0 +1,164 @@ +import Stats from 'three/examples/jsm/libs/stats.module.js'; +import { ThreePerf } from 'three-perf'; + +import { + AmbientLight, + Clock, + OrthographicCamera, + SRGBColorSpace, + Scene, + WebGLRenderer +} from 'three'; + +import { initBunnies } from './bunny'; + +export const clock = new Clock(true); + +export const initBunBench = async (count = 100000) => { + // GENERAL SCENE SETUP + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + const pixelRatio = window.devicePixelRatio || 1; + + const camera = new OrthographicCamera(0, screenWidth, screenHeight, 0, 1, 1000); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.zoom = 1; + camera.updateProjectionMatrix(); + const scene = new Scene(); + + const canvas = document.getElementById('three-canvas'); + if (!canvas) return; + const renderer = new WebGLRenderer({ + canvas, + powerPreference: 'high-performance' + }); + + renderer.outputColorSpace = SRGBColorSpace; + + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(pixelRatio); + + const stats = new Stats(); + document.body.appendChild(stats.dom); + + // Controls + // const controls = new OrbitControls(camera, renderer.domElement); + // controls.target.set(0, 3, 0); + // controls.update(); + + const bunnies = await initBunnies(renderer, scene, count); + + function sceneSetup() { + const ambient = new AmbientLight('#ddddff', 1.19); + scene.add(ambient); + } + + window.addEventListener('resize', onWindowResize); + + const perf = new ThreePerf({ + anchorX: 'left', + anchorY: 'bottom', + domElement: document.body, // or other canvas rendering wrapper + renderer: renderer // three js renderer instance you use for rendering + }); + + function onWindowResize() { + camera.left = 0; + camera.right = window.innerWidth; + camera.top = window.innerHeight; + camera.bottom = 0; + // camera.position.set(screenWidth / 2, screenHeight / 2, 10); + // camera.lookAt(screenWidth / 2, screenHeight / 2, 0); + camera.updateProjectionMatrix(); + + renderer.setSize(window.innerWidth, window.innerHeight); + } + + const bounds = { + left: 0, + right: window.innerWidth, + bottom: 0, + top: window.innerHeight + }; + + const url = new URL('./BunnyWorker.ts', import.meta.url); + + const numInstances = count; + const valuesPerInstance = 2; + const bytesPerValue = 4; // Float32 + + // x,y + const positionsBuffer = new SharedArrayBuffer(numInstances * valuesPerInstance * bytesPerValue); + const positionArray = new Float32Array(positionsBuffer); + + // vX, vY + const velocityBuffer = new SharedArrayBuffer(numInstances * valuesPerInstance * bytesPerValue); + const velocityArray = new Float32Array(velocityBuffer); + + const zIndex: number[] = new Array(count).fill(0); + for (let i = 0; i < count; i++) { + zIndex[i] = -Math.random() * 10; + + velocityArray[i * 2] = Math.random() * 10; + velocityArray[i * 2 + 1] = Math.random() * 10 - 5; + } + + let workersToSpawn = navigator.hardwareConcurrency + ? Math.floor(navigator.hardwareConcurrency / 2) + : 3; + + // workersToSpawn = 1; + console.log(`This device appears to have ${navigator.hardwareConcurrency} logical cores.`); + console.log(`We're spawning ${workersToSpawn} workers:`); + + let countHandledTotal = 0; + const countPerWorker = Math.ceil(count / workersToSpawn); + for (let i = 0; i < workersToSpawn; i++) { + const countToHandle = + countHandledTotal + countPerWorker < count ? countPerWorker : count - countHandledTotal; + + const from = countHandledTotal; + const to = from + countToHandle; + countHandledTotal += countToHandle; + console.log(`Worker ${i} handles ${countToHandle} instances - from ${from} to ${to}`); + + const worker = new Worker(url, { type: 'module', name: `bunnies_${i}` }); + worker.postMessage({ + count, + positionsBuffer, + velocityBuffer, + gravity: 0.75, + bounds, + type: 'setup', + from, + to + }); + } + + let f = 0; + sceneSetup(); + animate(); + function animate() { + f++; + requestAnimationFrame(animate); + stats.begin(); + perf.begin(); + // timer.update(); + // const delta = timer.getDelta(); + + bunnies.sprite.update(); + for (let i = 0; i < count; i++) { + const x = positionArray[i * 2]; + const y = positionArray[i * 2 + 1]; + + bunnies.updatePosition(i, [x, y, zIndex[i]]); + } + + bunnies.sprite.instanceMatrix.needsUpdate = true; + // bunnies.update(delta); + renderer.render(scene, camera); + perf.end(); + stats.end(); + } +}; diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainDemons.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainDemons.ts new file mode 100644 index 0000000..24748c3 --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/mainDemons.ts @@ -0,0 +1,88 @@ +import Stats from 'three/examples/jsm/libs/stats.module.js'; +import { ThreePerf } from 'three-perf'; + +import { + AmbientLight, + Clock, + OrthographicCamera, + SRGBColorSpace, + Scene, + WebGLRenderer +} from 'three'; +import { initDemons } from './demon'; + +export const clock = new Clock(true); + +export const initDemonBench = async (count = 100000) => { + // GENERAL SCENE SETUP + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + const pixelRatio = window.devicePixelRatio || 1; + + const camera = new OrthographicCamera(0, screenWidth, screenHeight, 0, 1, 1000); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.zoom = 1; + camera.updateProjectionMatrix(); + const scene = new Scene(); + + const canvas = document.getElementById('three-canvas'); + if (!canvas) return; + const renderer = new WebGLRenderer({ + canvas, + powerPreference: 'high-performance' + }); + + renderer.outputColorSpace = SRGBColorSpace; + + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(pixelRatio); + + const stats = new Stats(); + document.body.appendChild(stats.dom); + + const demons = await initDemons(renderer, scene, count); + + function sceneSetup() { + const ambient = new AmbientLight('#ddddff', 1.19); + scene.add(ambient); + } + + window.addEventListener('resize', onWindowResize); + + const perf = new ThreePerf({ + anchorX: 'left', + anchorY: 'bottom', + domElement: document.body, // or other canvas rendering wrapper + renderer: renderer // three js renderer instance you use for rendering + }); + + function onWindowResize() { + camera.left = 0; + camera.right = window.innerWidth; + camera.top = window.innerHeight; + camera.bottom = 0; + // camera.position.set(screenWidth / 2, screenHeight / 2, 10); + // camera.lookAt(screenWidth / 2, screenHeight / 2, 0); + camera.updateProjectionMatrix(); + + renderer.setSize(window.innerWidth, window.innerHeight); + } + + sceneSetup(); + animate(); + + function animate() { + requestAnimationFrame(animate); + stats.begin(); + perf.begin(); + // timer.update(); + // const delta = timer.getDelta(); + const delta = clock.getDelta(); + + demons.update(delta); + renderer.render(scene, camera); + perf.end(); + stats.end(); + } +}; diff --git a/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/util.ts b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/util.ts new file mode 100644 index 0000000..e4c1f0f --- /dev/null +++ b/apps/playground/src/routes/instanced-sprite-bunny-mark-workerized/util.ts @@ -0,0 +1,12 @@ +import { NearestFilter, SRGBColorSpace, TextureLoader } from 'three'; + +const loader = new TextureLoader(); + +export const loadTexture = (url: string) => { + const texture = loader.load(url); + texture.minFilter = NearestFilter; + texture.magFilter = NearestFilter; + texture.colorSpace = SRGBColorSpace; + + return texture; +}; diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index e63401f..8cb036e 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -2,7 +2,19 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [ + sveltekit(), + { + name: 'configure-response-headers', + configureServer: (server) => { + server.middlewares.use((_req, res, next) => { + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + next(); + }); + } + } + ], ssr: { noExternal: ['three'] },