Skip to content

Instantly share code, notes, and snippets.

@dgerrells
Created August 31, 2024 01:50
Show Gist options
  • Select an option

  • Save dgerrells/bbd74265cb45ca5c601d7d0d1590d594 to your computer and use it in GitHub Desktop.

Select an option

Save dgerrells/bbd74265cb45ca5c601d7d0d1590d594 to your computer and use it in GitHub Desktop.
simulate 20m particles
<!DOCTYPE html>
<html lang="en" style="user-select: none">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sabby</title>
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/UAParser.js/1.0.37/ua-parser.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.162.0/build/three.module.js"
}
}
</script>
<style>
body,
html,
canvas {
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
outline: none;
}
</style>
</head>
<body
style="
margin: 0;
width: 100vw;
height: 100vh;
background: black;
pointer-events: none;
overflow: hidden;
user-select: none;
"
>
<script type="module">
import * as THREE from "three";
const uap = new UAParser();
const isMobile = /Mobi/.test(navigator.userAgent);
const getQueryParam = (paramName) =>
new URL(window.location.href).searchParams.get(paramName);
let cores = ~~getQueryParam("cores");
if (cores === 0) {
cores =
uap.getResult().device.vendor === "Apple"
? 4
: navigator.hardwareConcurrency - 1;
}
const PARTICLE_COUNT = ~~getQueryParam("count") || 1_000_000;
const WORKER_COUNT = cores;
const WORKER_CHUNK_SIZE = Math.floor(PARTICLE_COUNT / WORKER_COUNT);
const simData = {
workerPool: [],
activeWorkers: 0,
width: window.innerWidth,
height: window.innerHeight,
particleGridA: null,
particleGridB: null,
activeParticleGrid: null,
scene: new THREE.Scene(),
camera: new THREE.OrthographicCamera(
window.innerWidth / -2,
window.innerWidth / 2,
window.innerHeight / 2,
window.innerHeight / -2,
0.1,
100
),
renderer: new THREE.WebGLRenderer(),
texture: null,
quad: null,
};
const particleStride = 6; // 6 floats x,y,dx,dy,sx,sy;
const particleByteStride = particleStride * 4; // 4 bytes per float
const sabViewParticles = new Float32Array(
new SharedArrayBuffer(PARTICLE_COUNT * particleByteStride)
);
// dt + screen width + screen height + touch count + mouse x + mouse y
const sabViewSimData = new Float32Array(new SharedArrayBuffer(4 * 64));
simData.camera.position.set(simData.width / 2, simData.height / 2, 1);
simData.renderer.setSize(simData.width, simData.height);
document.body.appendChild(simData.renderer.domElement);
simData.texture = new THREE.DataTexture(
new Uint8Array(simData.width * simData.height),
simData.width,
simData.height,
THREE.RedFormat,
THREE.UnsignedByteType,
THREE.UVMapping,
THREE.ClampToEdge,
THREE.ClampToEdge,
THREE.NearestFilter,
THREE.NearestFilter,
0
);
const baseC = isMobile ? 5 : 5;
const scaleC = isMobile ? 20 : 30;
const customMaterial = new THREE.ShaderMaterial({
uniforms: {
screen: { value: new THREE.Vector2(simData.width, simData.height) },
uTexture: { value: simData.texture },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D uTexture;
uniform vec2 screen;
varying vec2 vUv;
void main() {
float count = texture2D(uTexture, vUv).x;
vec4 col = vec4(0,0,0,1);
col.x = count * (${baseC}. + ${scaleC}.*gl_FragCoord.x / screen.x);
col.y = count * (${baseC}. + ${scaleC}.*gl_FragCoord.y / screen.y);
col.z = count * (${baseC}. + ${scaleC}.*(1.-gl_FragCoord.y / screen.y));
gl_FragColor = col;
}
`,
blending: THREE.AdditiveBlending,
depthTest: false,
});
//setup workers
simData.activeWorkers = WORKER_COUNT;
for (let i = 0; i < WORKER_COUNT; i++) {
const worker = new Worker("./sabby/worker.js");
worker.addEventListener("message", onWorkerMessage);
simData.workerPool.push(worker);
}
function resize() {
const { camera, scene, renderer, workerPool } = simData;
simData.width = window.innerWidth;
simData.height = window.innerHeight;
simData.texture = new THREE.DataTexture(
new Uint8Array(simData.width * simData.height),
simData.width,
simData.height,
THREE.RedFormat,
THREE.UnsignedByteType,
THREE.UVMapping,
THREE.ClampToEdge,
THREE.ClampToEdge,
THREE.NearestFilter,
THREE.NearestFilter,
0
);
customMaterial.uniforms.screen.value.set(simData.width, simData.height);
customMaterial.uniforms.uTexture.value = simData.texture;
scene.remove(simData.quad);
const geometry = new THREE.PlaneGeometry(simData.width, simData.height);
simData.quad = new THREE.Mesh(geometry, customMaterial);
simData.quad.position.set(simData.width / 2, simData.height / 2, 0);
scene.add(simData.quad);
sabViewSimData[1] = simData.width;
sabViewSimData[2] = simData.height;
renderer.setSize(simData.width, simData.height);
camera.position.set(simData.width / 2, simData.height / 2, 10);
camera.left = simData.width / -2;
camera.right = simData.width / 2;
camera.top = simData.height / 2;
camera.bottom = simData.height / -2;
camera.updateProjectionMatrix();
simData.particleGridA = new Uint8Array(
new SharedArrayBuffer(simData.width * simData.height)
);
simData.particleGridB = new Uint8Array(
new SharedArrayBuffer(simData.width * simData.height)
);
//init particles
for (let i = 0; i < PARTICLE_COUNT; i++) {
sabViewParticles[i * particleStride] = Math.random() * simData.width;
sabViewParticles[i * particleStride + 1] =
Math.random() * simData.height;
sabViewParticles[i * particleStride + 2] =
(Math.random() * 2 - 1) * 30;
sabViewParticles[i * particleStride + 3] =
(Math.random() * 2 - 1) * 30;
sabViewParticles[i * particleStride + 4] =
sabViewParticles[i * particleStride];
sabViewParticles[i * particleStride + 5] =
sabViewParticles[i * particleStride + 1];
}
// update workers
workerPool.forEach((worker, i) => {
worker.postMessage({
id: i,
sabViewParticles,
sabViewSimData,
particleOffsetStart: WORKER_CHUNK_SIZE * i,
particleOffsetEnd: WORKER_CHUNK_SIZE * i + WORKER_CHUNK_SIZE,
particleStride,
particleGridA: simData.particleGridA,
particleGridB: simData.particleGridB,
});
});
}
resize();
window.addEventListener("resize", resize);
window.addEventListener("mousemove", (e) => {
sabViewSimData[4] = e.clientX;
sabViewSimData[5] = simData.height - e.clientY;
});
window.addEventListener("mousedown", (e) => {
sabViewSimData[3] = 1;
sabViewSimData[4] = e.clientX;
sabViewSimData[5] = simData.height - e.clientY;
});
window.addEventListener("mouseup", (e) => {
sabViewSimData[3] = 0;
});
window.addEventListener("touchmove", (e) => {
e.preventDefault();
sabViewSimData[3] = e.targetTouches.length;
for (let i = 0; i < e.targetTouches.length; i++) {
const touch = e.targetTouches[i];
sabViewSimData[i * 2 + 4] = touch.clientX;
sabViewSimData[i * 2 + 4 + 1] = simData.height - touch.clientY;
}
});
window.addEventListener("touchstart", (e) => {
e.preventDefault();
sabViewSimData[3] = e.targetTouches.length;
for (let i = 0; i < e.targetTouches.length; i++) {
const touch = e.targetTouches[i];
sabViewSimData[i * 2 + 4] = touch.clientX;
sabViewSimData[i * 2 + 4 + 1] = simData.height - touch.clientY;
}
});
window.addEventListener("touchend", (e) => {
e.preventDefault();
sabViewSimData[3] = 0;
});
window.addEventListener("touchcancel", (e) => {
e.preventDefault();
sabViewSimData[3] = 0;
});
function onWorkerMessage() {
simData.activeWorkers--;
if (simData.activeWorkers !== 0) {
return;
}
requestAnimationFrame(runSimulation);
}
let lastTime = 1;
function runSimulation(currentTime) {
const dt = Math.min(0.1, (currentTime - lastTime) / 1000);
lastTime = currentTime;
sabViewSimData[0] = dt;
simData.activeWorkers = WORKER_COUNT;
simData.workerPool.forEach((worker, i) => {
worker.postMessage({});
});
simData.activeParticleGrid =
simData.activeParticleGrid === simData.particleGridA
? simData.particleGridB
: simData.particleGridA;
render(simData.activeParticleGrid);
}
function render(grid) {
simData.texture.image.data.set(grid);
simData.texture.needsUpdate = true;
simData.renderer.render(simData.scene, simData.camera);
grid.fill(0);
}
</script>
</body>
</html>
console.log("worker created");
const isMobile = /Mobi/.test(navigator.userAgent);
let lastSetupEvent = null;
let activeGrid = null;
// I don't make trash
let cacher = {
x: 0,
y: 0,
};
const fixedForce = isMobile ? 258300 * 8 : 2583000 * 15;
const simulate = (event) => {
const {
sabViewParticles,
sabViewSimData,
id,
particleOffsetStart,
particleOffsetEnd,
particleStride,
particleGridA,
particleGridB,
} = event.data;
activeGrid = activeGrid === particleGridA ? particleGridB : particleGridA;
const [delta, width, height, touchCount] = [
sabViewSimData[0],
sabViewSimData[1],
sabViewSimData[2],
sabViewSimData[3],
sabViewSimData[4],
sabViewSimData[5],
];
const start = particleOffsetStart;
const end = particleOffsetEnd;
const decay = 1 / (1 + delta * 1);
for (let i = start; i < end; i++) {
const pi = i * particleStride;
let x = sabViewParticles[pi];
let y = sabViewParticles[pi + 1];
let dx = sabViewParticles[pi + 2] * decay;
let dy = sabViewParticles[pi + 3] * decay;
let sx = sabViewParticles[pi + 4];
let sy = sabViewParticles[pi + 5];
if (touchCount > 0) {
for (let t = 0; t < touchCount; t++) {
const tx = sabViewSimData[4 + t * 2];
const ty = sabViewSimData[4 + t * 2 + 1];
forceInvSqr(tx, ty, x, y, fixedForce);
dx += cacher.x * delta * 3;
dy += cacher.y * delta * 3;
}
}
forceSqr(sx, sy, x, y, 0.5);
dx += cacher.x * delta * 1;
dy += cacher.y * delta * 1;
x += dx * delta;
y += dy * delta;
sabViewParticles[pi] = x;
sabViewParticles[pi + 1] = y;
sabViewParticles[pi + 2] = dx;
sabViewParticles[pi + 3] = dy;
if (x < 0 || x >= width) continue;
if (y < 0 || y >= height) continue;
const pCountIndex = (y | 0) * width + (x | 0);
activeGrid[pCountIndex]++;
}
postMessage({});
};
function clamp(n) {
n &= -(n >= 0);
return n | ((255 - n) >> 31);
}
function forceInvSqr(x1, y1, x2, y2, m = 25830000) {
const dx = x1 - x2;
const dy = y1 - y2;
const dist = Math.sqrt(dx * dx + dy * dy);
const dirX = dx / dist;
const dirY = dy / dist;
const force = Math.min(1200, m / (dist * dist));
cacher.x = force * dirX;
cacher.y = force * dirY;
}
function forceInvCube(x1, y1, x2, y2, m = 25830000) {
const dx = x1 - x2;
const dy = y1 - y2;
const dist = Math.sqrt(dx * dx + dy * dy);
const dirX = dx / dist;
const dirY = dy / dist;
const force = Math.min(12000, m / (dist * dist * dist));
cacher.x = force * dirX;
cacher.y = force * dirY;
}
function forceSqr(x1, y1, x2, y2, d = 999999) {
const dx = x1 - x2;
const dy = y1 - y2;
const dist = Math.sqrt(dx * dx + dy * dy);
if (d <= dist) {
const dirX = dx / dist;
const dirY = dy / dist;
const force = Math.min(12000, dist * dist);
cacher.x = force * dirX;
cacher.y = force * dirY;
return;
}
cacher.x = 0;
cacher.y = 0;
}
onmessage = (event) => {
if (!event.data.sabViewParticles) {
simulate(lastSetupEvent);
return;
}
lastSetupEvent = event;
postMessage({});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment