diff --git a/apps/typegpu-docs/src/examples/threejs/compute-particles/index.html b/apps/typegpu-docs/src/examples/threejs/compute-particles/index.html new file mode 100644 index 0000000000..581d6789f8 --- /dev/null +++ b/apps/typegpu-docs/src/examples/threejs/compute-particles/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/threejs/compute-particles/index.ts b/apps/typegpu-docs/src/examples/threejs/compute-particles/index.ts new file mode 100644 index 0000000000..5a38a5850d --- /dev/null +++ b/apps/typegpu-docs/src/examples/threejs/compute-particles/index.ts @@ -0,0 +1,242 @@ +/* + * Based on: https://github.com/mrdoob/three.js/blob/master/examples/webgpu_compute_particles.html + */ +import * as THREE from 'three/webgpu'; +import * as TSL from 'three/tsl'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import * as t3 from '@typegpu/three'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { randf } from '@typegpu/noise'; + +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const renderer = new THREE.WebGPURenderer({ canvas, antialias: true }); +renderer.setPixelRatio(window.devicePixelRatio); +renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); +renderer.setClearColor(0X000000); +await renderer.init(); + +const particleCount = 200000; +let isOrbitControlsActive = false; + +const gravity = t3.uniform(-0.00098, d.f32); +const bounce = t3.uniform(0.8, d.f32); +const friction = t3.uniform(0.99, d.f32); +const size = t3.uniform(0.12, d.f32); +const clickPosition = t3.uniform(new THREE.Vector3(), d.vec3f); + +const camera = new THREE.PerspectiveCamera( + 50, + canvas.clientWidth / canvas.clientHeight, + 0.1, + 1000, +); +camera.position.set(0, 10, 20); + +const scene = new THREE.Scene(); + +const positions = t3.instancedArray(particleCount, d.vec3f); +const velocities = t3.instancedArray(particleCount, d.vec3f); +const colors = t3.instancedArray(particleCount, d.vec3f); +const separation = 0.2; +const amount = Math.sqrt(particleCount); +const offset = amount / 2; + +const computeInit = t3.toTSL(() => { + 'use gpu'; + const instanceIdx = t3.instanceIndex.$; + const position = positions.$[instanceIdx]; + const color = colors.$[instanceIdx]; + + const x = instanceIdx % d.u32(amount); + const z = instanceIdx / amount; + + position.x = (offset - d.f32(x)) * separation; + position.z = (offset - d.f32(z)) * separation; + positions.$[instanceIdx] = d.vec3f(position); + + randf.seed(d.f32(instanceIdx / amount)); + color.x = randf.sample(); + color.y = randf.sample(); + colors.$[instanceIdx] = d.vec3f(color); +}).compute(particleCount).setName('Init Particles TypeGPU'); +renderer.compute(computeInit); + +const computeAccessor = t3.toTSL(() => { + 'use gpu'; + const instanceIdx = t3.instanceIndex.$; + let position = positions.$[instanceIdx]; + let velocity = velocities.$[instanceIdx]; + + velocity.y += gravity.$; + position = position.add(velocity); + velocity = velocity.mul(friction.$); + + if (position.y < 0) { + position.y = 0; + velocity.y = -velocity.y * bounce.$; + velocity = velocity.mul(d.vec3f(0.9, 1, 0.9)); + } + + positions.$[instanceIdx] = d.vec3f(position); + velocities.$[instanceIdx] = d.vec3f(velocity); +}).compute(particleCount).setName('Update Particles TypeGPU'); + +const material = new THREE.SpriteNodeMaterial(); +material.colorNode = t3.toTSL(() => { + 'use gpu'; + return d.vec4f( + t3.uv().$.mul(colors.$[t3.instanceIndex.$].xy), + 0, + 1, + ); +}); +material.positionNode = positions.node.toAttribute(); +material.scaleNode = size.node; +material.opacityNode = TSL.shapeCircle(); +material.alphaToCoverage = true; +material.transparent = true; + +const particles = new THREE.Sprite(material); +particles.count = particleCount; +particles.frustumCulled = false; +scene.add(particles); + +const helper = new THREE.GridHelper(90, 45, 0x303030, 0x303030); +scene.add(helper); + +const geometry = new THREE.PlaneGeometry(200, 200); +geometry.rotateX(-Math.PI / 2); + +const plane = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ visible: false }), +); +scene.add(plane); + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +const computeHit = t3.toTSL(() => { + 'use gpu'; + const instanceIdx = t3.instanceIndex.$; + const position = positions.$[instanceIdx]; + let velocity = velocities.$[instanceIdx]; + + const dist = std.distance(position, clickPosition.$); + const dir = std.normalize(position.sub(clickPosition.$)); + const distArea = std.max(0, 3 - dist); + + const power = distArea * 0.01; + randf.seed(d.f32(instanceIdx / amount)); + const relativePower = power * (1.5 * randf.sample() + 0.5); + + velocity = velocity.add(dir.mul(relativePower)); + velocities.$[instanceIdx] = d.vec3f(velocity); +}).compute(particleCount).setName('Hit Particles TypeGPU'); + +function onMove(event: PointerEvent) { + if (isOrbitControlsActive) return; + + const rect = canvas.getBoundingClientRect(); + pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + + raycaster.setFromCamera(pointer, camera); + + const intersects = raycaster.intersectObject(plane, false); + + if (intersects.length > 0) { + const { point } = intersects[0]; + + clickPosition.node.value.copy(point); + clickPosition.node.value.y = -1; + + renderer.compute(computeHit); + } +} + +canvas.addEventListener('pointermove', onMove); + +const cameraControls = new OrbitControls(camera, canvas); +cameraControls.enableDamping = true; +cameraControls.minDistance = 5; +cameraControls.maxDistance = 200; +cameraControls.target.set(0, -8, 0); +cameraControls.update(); + +cameraControls.addEventListener('start', () => { + isOrbitControlsActive = true; +}); +cameraControls.addEventListener('end', () => { + isOrbitControlsActive = false; +}); + +cameraControls.touches = { + ONE: null, + TWO: THREE.TOUCH.DOLLY_PAN, +}; + +const resizeObserver = new ResizeObserver(() => { + camera.aspect = canvas.clientWidth / canvas.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); +}); +resizeObserver.observe(canvas); + +const animate = () => { + cameraControls.update(); + + renderer.compute(computeAccessor); + renderer.render(scene, camera); +}; + +renderer.setAnimationLoop(animate); + +// #region Example controls and cleanup +export const controls = { + 'gravity': { + initial: -0.00098, + min: -0.00098, + max: 0, + step: 0.0001, + onSliderChange: (value: number) => { + gravity.node.value = value; + }, + }, + 'bounce': { + initial: 0.8, + min: 0.1, + max: 2, + step: 0.02, + onSliderChange: (value: number) => { + bounce.node.value = value; + }, + }, + 'friction': { + initial: 0.99, + min: 0.5, + max: 0.99, + step: 0.01, + onSliderChange: (value: number) => { + friction.node.value = value; + }, + }, + 'size': { + initial: 0.12, + min: 0.05, + max: 0.5, + step: 0.01, + onSliderChange: (value: number) => { + size.node.value = value; + }, + }, +}; + +export function onCleanup() { + renderer.dispose(); + resizeObserver.unobserve(canvas); +} +// #endregion diff --git a/apps/typegpu-docs/src/examples/threejs/compute-particles/meta.json b/apps/typegpu-docs/src/examples/threejs/compute-particles/meta.json new file mode 100644 index 0000000000..44c1e0bb24 --- /dev/null +++ b/apps/typegpu-docs/src/examples/threejs/compute-particles/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Three.js - compute / particles", + "category": "threejs", + "tags": ["experimental"] +} diff --git a/apps/typegpu-docs/src/examples/threejs/compute-particles/thumbnail.png b/apps/typegpu-docs/src/examples/threejs/compute-particles/thumbnail.png new file mode 100644 index 0000000000..df9bd6ee2f Binary files /dev/null and b/apps/typegpu-docs/src/examples/threejs/compute-particles/thumbnail.png differ