242 lines
7.2 KiB
JavaScript
242 lines
7.2 KiB
JavaScript
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();
|