This commit is contained in:
John Gatward
2026-03-18 15:24:08 +00:00
parent 213cf5f535
commit 3cb8d5a14e
17 changed files with 1525 additions and 331 deletions

241
projects/flocking/script.js Normal file
View File

@@ -0,0 +1,241 @@
import init, { SimulationWasm } from './pkg/flocking.js';
const BOIDS_PER_1920X1080 = 250;
const MIN_INITIAL_BOIDS = 100;
const MAX_INITIAL_BOIDS = 1000;
const BOID_STEP = 10;
let wasm = null;
let sim = null;
let canvas = null;
let ctx = null;
let controls = null;
let controlsToggle = null;
let controlsResizeObserver = null;
let frameCount = 0;
let lastFpsUpdate = Date.now();
let displayWidth = 1;
let displayHeight = 1;
let resizeAnimationFrame = 0;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getInitialBoidCount(width, height) {
const areaScale = (width * height) / (1920 * 1080);
const targetCount = Math.round(BOIDS_PER_1920X1080 * areaScale);
const clampedCount = clamp(targetCount, MIN_INITIAL_BOIDS, MAX_INITIAL_BOIDS);
return Math.round(clampedCount / BOID_STEP) * BOID_STEP;
}
function syncBoidCountToViewport(width, height) {
const targetCount = getInitialBoidCount(width, height);
let currentCount = sim.get_boid_count();
while (currentCount < targetCount) {
sim.add_boid();
currentCount = sim.get_boid_count();
}
while (currentCount > targetCount) {
sim.remove_boid();
currentCount = sim.get_boid_count();
}
updateBoidCount();
}
function updateControlsCollapsedState(isCollapsed) {
if (controls === null || controlsToggle === null) {
return;
}
controls.classList.toggle('collapsed', isCollapsed);
controls.setAttribute('aria-expanded', String(!isCollapsed));
controlsToggle.setAttribute('aria-expanded', String(!isCollapsed));
controlsToggle.setAttribute('aria-label', isCollapsed ? 'Expand controls' : 'Collapse controls');
controlsToggle.textContent = isCollapsed ? '' : '';
}
function sizeCanvasToViewport() {
const dpr = window.devicePixelRatio || 1;
const nextDisplayWidth = Math.max(1, Math.floor(canvas.clientWidth));
const nextDisplayHeight = Math.max(1, Math.floor(canvas.clientHeight));
const nextBufferWidth = Math.max(1, Math.round(nextDisplayWidth * dpr));
const nextBufferHeight = Math.max(1, Math.round(nextDisplayHeight * dpr));
if (canvas.width !== nextBufferWidth || canvas.height !== nextBufferHeight) {
canvas.width = nextBufferWidth;
canvas.height = nextBufferHeight;
}
displayWidth = nextDisplayWidth;
displayHeight = nextDisplayHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
document.getElementById('canvasSize').textContent = `${displayWidth}x${displayHeight}`;
return { width: displayWidth, height: displayHeight };
}
function scheduleResize() {
if (resizeAnimationFrame !== 0) {
return;
}
resizeAnimationFrame = window.requestAnimationFrame(() => {
resizeAnimationFrame = 0;
const { width, height } = sizeCanvasToViewport();
if (sim !== null) {
sim.resize(width, height);
syncBoidCountToViewport(width, height);
}
});
}
function setupResizeObserver() {
controlsResizeObserver = new ResizeObserver(() => {
scheduleResize();
});
controlsResizeObserver.observe(document.getElementById('container'));
controlsResizeObserver.observe(controls);
}
async function run() {
wasm = await init();
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
controls = document.getElementById('controls');
controlsToggle = document.getElementById('controlsToggle');
updateControlsCollapsedState(false);
const { width, height } = sizeCanvasToViewport();
sim = new SimulationWasm(width, height, getInitialBoidCount(width, height));
setupControls();
setupResizeObserver();
updateBoidCount();
window.addEventListener('resize', scheduleResize);
animate();
}
function setupControls() {
const alignSlider = document.getElementById('alignSlider');
const cohesionSlider = document.getElementById('cohesionSlider');
const separationSlider = document.getElementById('separationSlider');
const addBtn = document.getElementById('addBtn');
const removeBtn = document.getElementById('removeBtn');
controlsToggle.addEventListener('click', () => {
const isCollapsed = !controls.classList.contains('collapsed');
updateControlsCollapsedState(isCollapsed);
scheduleResize();
});
alignSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_align_mult(value);
document.getElementById('alignValue').textContent = value.toFixed(2);
});
cohesionSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_cohesion_mult(value);
document.getElementById('cohesionValue').textContent = value.toFixed(2);
});
separationSlider.addEventListener('input', (e) => {
const value = Number.parseFloat(e.target.value);
sim.set_separation_mult(value);
document.getElementById('separationValue').textContent = value.toFixed(2);
});
addBtn.addEventListener('click', () => {
sim.add_boid();
updateBoidCount();
});
removeBtn.addEventListener('click', () => {
sim.remove_boid();
updateBoidCount();
});
}
function updateBoidCount() {
document.getElementById('boidCount').textContent = sim.get_boid_count();
}
function getBoidView() {
const ptr = sim.boid_buffer_ptr();
const len = sim.boid_buffer_len();
return new Float32Array(wasm.memory.buffer, ptr, len);
}
function animate() {
ctx.fillStyle = '#121212';
ctx.fillRect(0, 0, displayWidth, displayHeight);
sim.step();
const boidData = getBoidView();
const stride = sim.boid_buffer_stride();
for (let i = 0; i < boidData.length; i += stride) {
const x = boidData[i];
const y = boidData[i + 1];
const vx = boidData[i + 2];
const vy = boidData[i + 3];
const r = boidData[i + 4];
const g = boidData[i + 5];
const b = boidData[i + 6];
const a = boidData[i + 7];
const speedSq = vx * vx + vy * vy;
let dx;
let dy;
if (speedSq > 1e-6) {
const invSpeed = 1 / Math.sqrt(speedSq);
dx = vx * invSpeed;
dy = vy * invSpeed;
} else {
dx = 0;
dy = -1;
}
const size = 5;
const headMult = 3;
const px = -dy;
const py = dx;
const tipX = x + dx * size * headMult;
const tipY = y + dy * size * headMult;
const baseX = x - dx * size;
const baseY = y - dy * size;
const leftX = baseX + px * size;
const leftY = baseY + py * size;
const rightX = baseX - px * size;
const rightY = baseY - py * size;
ctx.strokeStyle = `rgba(${r},${g},${b},${a / 255})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(leftX, leftY);
ctx.lineTo(rightX, rightY);
ctx.closePath();
ctx.stroke();
}
frameCount += 1;
const now = Date.now();
if (now - lastFpsUpdate >= 1000) {
document.getElementById('fps').textContent = frameCount;
frameCount = 0;
lastFpsUpdate = now;
}
requestAnimationFrame(animate);
}
await run();