Created
November 6, 2025 21:42
-
-
Save ayushmi/634dfe4b402d743aaf6f62bc1eea1245 to your computer and use it in GitHub Desktop.
10,000 robots with collision avoidance in WebGPU in HTML
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <title>swarmbots.wgpu — 10k robots</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <style> | |
| :root { color-scheme: dark; } | |
| body { margin:0; font:14px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b0e14; color:#e6e9ef; } | |
| #wrap { display:grid; grid-template-columns: 320px 1fr; min-height:100vh; } | |
| aside { padding:16px; border-right:1px solid #151a24; background:#0f1320; } | |
| h1 { font-size:16px; margin:0 0 8px 0; } | |
| .row { display:flex; align-items:center; gap:8px; margin:8px 0; } | |
| .row label { width:140px; color:#aab3c5; } | |
| .row input[type=range] { flex:1; } | |
| .val { min-width:52px; text-align:right; color:#9fc2ff; font-variant-numeric: tabular-nums; } | |
| .note { color:#8a94a7; font-size:12px; margin:8px 0 0; } | |
| .split { height:1px; background:#151a24; margin:12px 0; } | |
| #hud { position:fixed; right:12px; top:10px; font-size:12px; padding:6px 8px; background:#0e1220c7; border:1px solid #1a2130; border-radius:8px; backdrop-filter: blur(4px); } | |
| #stageWrap { position:relative; background:#07090e; } | |
| #stage { width:100%; height:100vh; display:block; } | |
| #hideUI:checked ~ aside, #hideUI:checked ~ #hud { display:none; } | |
| .btn { background:#1b2a4d; border:1px solid #24345e; color:#cfe0ff; padding:6px 10px; border-radius:8px; cursor:pointer; } | |
| .btn:active { transform: translateY(1px); } | |
| .mini { color:#9aa7bf; font-size:12px; margin-top:2px; } | |
| </style> | |
| </head> | |
| <body> | |
| <input id="hideUI" type="checkbox" hidden> | |
| <div id="wrap"> | |
| <aside> | |
| <h1>swarmbots.wgpu</h1> | |
| <div class="row"><label>DPR cap</label> | |
| <input id="dprCap" type="range" min="0.5" max="2" step="0.1" value="1"> | |
| <div class="val" id="dprCapVal">1.0</div> | |
| </div> | |
| <div class="row"><label>Render scale</label> | |
| <input id="resScale" type="range" min="0.5" max="1.0" step="0.05" value="1"> | |
| <div class="val" id="resScaleVal">1.00</div> | |
| </div> | |
| <div class="mini">Internal resolution = CSS size × min(devicePixelRatio, DPR cap) × render scale</div> | |
| <div class="split"></div> | |
| <div class="row"><label>Agents</label> | |
| <input id="agentCount" type="range" min="100" max="20000" step="100" value="10000"> | |
| <div class="val" id="agentCountVal">10000</div> | |
| </div> | |
| <div class="row"><label>cellSize</label> | |
| <input id="cellSize" type="range" min="8" max="48" step="1" value="20"> | |
| <div class="val" id="cellSizeVal">20</div> | |
| </div> | |
| <div class="row"><label>maxPerCell</label> | |
| <input id="maxPerCell" type="range" min="8" max="64" step="1" value="28"> | |
| <div class="val" id="maxPerCellVal">28</div> | |
| </div> | |
| <div class="split"></div> | |
| <div class="row"><label>Physics rate</label> | |
| <input id="physEvery" type="range" min="1" max="4" step="1" value="2"> | |
| <div class="val" id="physEveryVal">2</div> | |
| </div> | |
| <div class="mini">1 = update every frame (~60 Hz), 2 ≈ 30 Hz, 3 ≈ 20 Hz…</div> | |
| <div class="row"><label>Workgroup size</label> | |
| <select id="wgSize"> | |
| <option>64</option><option selected>128</option><option>256</option> | |
| </select> | |
| <button class="btn" id="applyWg">Apply</button> | |
| </div> | |
| <div class="split"></div> | |
| <div class="row"><label>Triangle size</label> | |
| <input id="triSize" type="range" min="2" max="12" step="0.5" value="6"> | |
| <div class="val" id="triSizeVal">6.0</div> | |
| </div> | |
| <div class="row"><label>Vel color strength</label> | |
| <input id="velColor" type="range" min="0" max="2" step="0.05" value="1.0"> | |
| <div class="val" id="velColorVal">1.00</div> | |
| </div> | |
| <div class="split"></div> | |
| <div class="row"><label>Overlays</label> | |
| <button class="btn" id="toggleUI">Hide / Show</button> | |
| </div> | |
| <div class="note">macOS tip: disable “Low Power Mode” for higher sustained FPS.</div> | |
| </aside> | |
| <div id="stageWrap"> | |
| <canvas id="stage"></canvas> | |
| </div> | |
| </div> | |
| <div id="hud">FPS: <span id="fps">0</span> · physics: <span id="hz">~30</span> · cells: <span id="cells">0</span></div> | |
| <script> | |
| (async function(){ | |
| if (!('gpu' in navigator)) { alert('WebGPU not available'); return; } | |
| // ---------- DOM ---------- | |
| const qs = id => document.getElementById(id); | |
| const canvas = qs('stage'); | |
| const fpsEl = qs('fps'), hzEl = qs('hz'), cellsEl = qs('cells'); | |
| const ui = { | |
| dprCap: qs('dprCap'), dprCapVal: qs('dprCapVal'), | |
| resScale: qs('resScale'), resScaleVal: qs('resScaleVal'), | |
| agentCount: qs('agentCount'), agentCountVal: qs('agentCountVal'), | |
| cellSize: qs('cellSize'), cellSizeVal: qs('cellSizeVal'), | |
| maxPerCell: qs('maxPerCell'), maxPerCellVal: qs('maxPerCellVal'), | |
| physEvery: qs('physEvery'), physEveryVal: qs('physEveryVal'), | |
| wgSize: qs('wgSize'), applyWg: qs('applyWg'), | |
| triSize: qs('triSize'), triSizeVal: qs('triSizeVal'), | |
| velColor: qs('velColor'), velColorVal: qs('velColorVal'), | |
| toggleUI: qs('toggleUI'), hideUI: qs('hideUI') | |
| }; | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| const device = await adapter.requestDevice(); | |
| const context = canvas.getContext('webgpu'); | |
| const format = navigator.gpu.getPreferredCanvasFormat(); | |
| // ---------- State ---------- | |
| let state = { | |
| cssW: 0, cssH: 0, dpr: 1, resScale: 1, | |
| agentCount: parseInt(ui.agentCount.value,10), | |
| cellSize: parseInt(ui.cellSize.value,10), | |
| maxPerCell: parseInt(ui.maxPerCell.value,10), | |
| physEvery: parseInt(ui.physEvery.value,10), | |
| wgSize: parseInt(ui.wgSize.value,10), | |
| triSize: parseFloat(ui.triSize.value), | |
| velColor: parseFloat(ui.velColor.value), | |
| }; | |
| // ---------- Buffers ---------- | |
| const U_PAD = 64; // uniform padding (16B aligned) | |
| const paramsBuf = device.createBuffer({ size: U_PAD, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); | |
| const screenBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); | |
| let agentsA, agentsB, gridCounts, gridIdx; | |
| let gridCols=0, gridRows=0, gridCells=0; | |
| function allocAgents(n){ | |
| const bytes = n * 16; // vec2 pos + vec2 vel (f32) | |
| agentsA = device.createBuffer({ size: bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); | |
| agentsB = device.createBuffer({ size: bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX }); | |
| // init positions/velocities | |
| const tmp = new Float32Array(n*4); | |
| for (let i=0;i<n;i++){ | |
| tmp[i*4+0] = Math.random()*state.cssW; | |
| tmp[i*4+1] = Math.random()*state.cssH; | |
| const ang = Math.random()*Math.PI*2; | |
| tmp[i*4+2] = Math.cos(ang)*20; | |
| tmp[i*4+3] = Math.sin(ang)*20; | |
| } | |
| device.queue.writeBuffer(agentsA, 0, tmp.buffer); | |
| } | |
| function allocGrid(){ | |
| gridCols = Math.max(1, Math.floor(state.cssW / state.cellSize)); | |
| gridRows = Math.max(1, Math.floor(state.cssH / state.cellSize)); | |
| gridCells = gridCols * gridRows; | |
| const countsBytes = gridCells * 4; // atomic<u32> | |
| const idxBytes = gridCells * state.maxPerCell * 4; // u32 agent indices | |
| gridCounts = device.createBuffer({ size: countsBytes, usage: GPUBufferUsage.STORAGE }); | |
| gridIdx = device.createBuffer({ size: idxBytes, usage: GPUBufferUsage.STORAGE }); | |
| cellsEl.textContent = gridCells.toString(); | |
| } | |
| // ---------- Shaders (with dynamic workgroup size & params) ---------- | |
| function commonWGSL(){ return /*wgsl*/` | |
| struct Agent { pos: vec2<f32>, vel: vec2<f32> }; | |
| struct Params { | |
| dt: f32, maxSpeed: f32, neighborR: f32, sepR: f32, | |
| alignW: f32, cohW: f32, sepW: f32, turnW: f32, | |
| cellSize: f32, gridCols: f32, gridRows: f32, agentCount: f32, | |
| triSize: f32, velColor: f32, _pad0: f32, _pad1: f32, | |
| }; | |
| @group(0) @binding(2) var<uniform> params: Params; | |
| `;} | |
| function clearWGSL(){ return /*wgsl*/` | |
| ${commonWGSL()} | |
| @group(0) @binding(3) var<storage, read_write> gridCounts : array<atomic<u32>>; | |
| @compute @workgroup_size(${state.wgSize}) | |
| fn main(@builtin(global_invocation_id) gid: vec3<u32>) { | |
| let idx = gid.x; | |
| if (idx < u32(params.gridCols * params.gridRows)) { | |
| atomicStore(&gridCounts[idx], 0u); | |
| } | |
| } | |
| `;} | |
| function binWGSL(){ return /*wgsl*/` | |
| ${commonWGSL()} | |
| @group(0) @binding(0) var<storage, read> agents : array<Agent>; | |
| @group(0) @binding(3) var<storage, read_write> gridCounts: array<atomic<u32>>; | |
| @group(0) @binding(4) var<storage, read_write> gridIdx : array<u32>; | |
| fn wrap(p: vec2<f32>, w: f32, h: f32) -> vec2<f32> { | |
| var x = p.x; var y = p.y; | |
| if (x < 0.0) { x += w; } else if (x >= w) { x -= w; } | |
| if (y < 0.0) { y += h; } else if (y >= h) { y -= h; } | |
| return vec2<f32>(x,y); | |
| } | |
| @compute @workgroup_size(${state.wgSize}) | |
| fn main(@builtin(global_invocation_id) gid: vec3<u32>) { | |
| let i = gid.x; | |
| if (i >= u32(params.agentCount)) { return; } | |
| let a = agents[i]; | |
| let col = clamp(i32(a.pos.x / params.cellSize), 0, i32(params.gridCols)-1); | |
| let row = clamp(i32(a.pos.y / params.cellSize), 0, i32(params.gridRows)-1); | |
| let cell = u32(row) * u32(params.gridCols) + u32(col); | |
| let slot = atomicAdd(&gridCounts[cell], 1u); | |
| if (slot < u32(${state.maxPerCell})) { | |
| gridIdx[cell * u32(${state.maxPerCell}) + slot] = i; | |
| } | |
| } | |
| `;} | |
| function updateWGSL(){ return /*wgsl*/` | |
| ${commonWGSL()} | |
| @group(0) @binding(0) var<storage, read> agentsIn : array<Agent>; | |
| @group(0) @binding(1) var<storage, read_write> agentsOut : array<Agent>; | |
| @group(0) @binding(3) var<storage, read_write> gridCounts: array<atomic<u32>>; | |
| @group(0) @binding(4) var<storage, read_write> gridIdx : array<u32>; | |
| @group(0) @binding(5) var<uniform> screen : vec2<f32>; | |
| fn shortest_delta(a: f32, b: f32, dim: f32) -> f32 { | |
| var d = b - a; | |
| if (d > dim*0.5) { d -= dim; } | |
| if (d < -dim*0.5) { d += dim; } | |
| return d; | |
| } | |
| @compute @workgroup_size(${state.wgSize}) | |
| fn main(@builtin(global_invocation_id) gid: vec3<u32>) { | |
| let i = gid.x; | |
| if (i >= u32(params.agentCount)) { return; } | |
| var me = agentsIn[i]; | |
| let w = screen.x; let h = screen.y; | |
| let cols = i32(params.gridCols); let rows = i32(params.gridRows); | |
| let c = clamp(i32(me.pos.x / params.cellSize), 0, cols-1); | |
| let r = clamp(i32(me.pos.y / params.cellSize), 0, rows-1); | |
| var align = vec2<f32>(0.0,0.0); | |
| var coh = vec2<f32>(0.0,0.0); | |
| var sep = vec2<f32>(0.0,0.0); | |
| var cntAlign:f32 = 0.0; var cntCoh:f32 = 0.0; var cntSep:f32 = 0.0; | |
| // 3x3 neighbors | |
| var dy:i32 = -1; | |
| loop { | |
| if (dy > 1) { break; } | |
| var dx:i32 = -1; | |
| loop { | |
| if (dx > 1) { break; } | |
| let cc = ( ( ( ( (r+dy + rows) % rows) * cols) + ((c+dx + cols) % cols) ) ); | |
| let cell = u32(cc); | |
| let n = atomicLoad(&gridCounts[cell]); | |
| let base = cell * u32(${state.maxPerCell}); | |
| var k:u32 = 0u; | |
| loop { | |
| if (k >= n || k >= u32(${state.maxPerCell})) { break; } | |
| let j = gridIdx[base + k]; | |
| if (j != i) { | |
| let other = agentsIn[j]; | |
| let dxw = shortest_delta(me.pos.x, other.pos.x, w); | |
| let dyw = shortest_delta(me.pos.y, other.pos.y, h); | |
| let d2 = dxw*dxw + dyw*dyw; | |
| // align | |
| if (d2 < params.neighborR*params.neighborR) { | |
| align += other.vel; cntAlign += 1.0; | |
| } | |
| // cohesion | |
| if (d2 < params.neighborR*params.neighborR) { | |
| coh += vec2<f32>(other.pos.x, other.pos.y); cntCoh += 1.0; | |
| } | |
| // separation | |
| if (d2 < params.sepR*params.sepR && d2 > 1.0) { | |
| let inv = inverseSqrt(d2); | |
| sep -= vec2<f32>(dxw, dyw) * inv; | |
| cntSep += 1.0; | |
| } | |
| } | |
| k = k + 1u; | |
| } | |
| dx = dx + 1; | |
| } | |
| dy = dy + 1; | |
| } | |
| var acc = vec2<f32>(0.0,0.0); | |
| if (cntAlign > 0.0) { | |
| let desired = normalize(align / cntAlign) * params.maxSpeed; | |
| acc += (desired - me.vel) * params.alignW; | |
| } | |
| if (cntCoh > 0.0) { | |
| let center = coh / cntCoh; | |
| let dir = normalize(center - me.pos); | |
| acc += dir * params.cohW; | |
| } | |
| if (cntSep > 0.0) { | |
| acc += normalize(sep / cntSep) * params.sepW; | |
| } | |
| // small turn toward center to reduce drift | |
| let center = vec2<f32>(w*0.5, h*0.5); | |
| acc += normalize(center - me.pos) * params.turnW; | |
| var v = me.vel + acc * params.dt; | |
| let speed = max(length(v), 1e-3); | |
| if (speed > params.maxSpeed) { v = v * (params.maxSpeed / speed); } | |
| var p = me.pos + v * params.dt; | |
| // wrap | |
| if (p.x < 0.0) { p.x += w; } else if (p.x >= w) { p.x -= w; } | |
| if (p.y < 0.0) { p.y += h; } else if (p.y >= h) { p.y -= h; } | |
| agentsOut[i].pos = p; | |
| agentsOut[i].vel = v; | |
| } | |
| `;} | |
| function vertexWGSL(){ return /*wgsl*/` | |
| ${commonWGSL()} | |
| @group(0) @binding(0) var<storage, read> agents : array<Agent>; | |
| @group(0) @binding(5) var<uniform> screen : vec2<f32>; | |
| struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) col: vec3<f32> }; | |
| @vertex | |
| fn main(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VSOut { | |
| let a = agents[iid]; | |
| // small triangle in agent-local space | |
| let s = params.triSize; | |
| var v = vec2<f32>(0.0,0.0); | |
| if (vid == 0u) { v = vec2<f32>( 0.0, -1.0)*s; } | |
| if (vid == 1u) { v = vec2<f32>(-0.6, 0.8)*s; } | |
| if (vid == 2u) { v = vec2<f32>( 0.6, 0.8)*s; } | |
| // face along velocity | |
| let dir = normalize(a.vel + vec2<f32>(1e-4,0.0)); | |
| let ang = atan2(dir.y, dir.x); | |
| let c = cos(ang); let sn = sin(ang); | |
| let rot = mat2x2<f32>(c,-sn,sn,c); | |
| let world = a.pos + (rot * v); | |
| let ndc = vec2<f32>( | |
| world.x / screen.x * 2.0 - 1.0, | |
| 1.0 - world.y / screen.y * 2.0 | |
| ); | |
| // simple vel-based color | |
| let speed = length(a.vel); | |
| let t = clamp(speed / params.maxSpeed, 0.0, 1.0) * params.velColor; | |
| let col = mix(vec3<f32>(0.3,0.6,1.0), vec3<f32>(1.0,0.6,0.2), t); | |
| var o: VSOut; | |
| o.pos = vec4<f32>(ndc, 0.0, 1.0); | |
| o.col = col; | |
| return o; | |
| } | |
| `;} | |
| const fragWGSL = /*wgsl*/` | |
| @fragment | |
| fn main(@location(0) col: vec3<f32>) -> @location(0) vec4<f32> { | |
| return vec4<f32>(col, 1.0); | |
| } | |
| `; | |
| // ---------- Pipelines ---------- | |
| let clearPipe, binPipe, updatePipe, renderPipe; | |
| let bindClear, bindBin, bindUpdate, bindRender; | |
| function makePipelines(){ | |
| const clearMod = device.createShaderModule({ code: clearWGSL() }); | |
| const binMod = device.createShaderModule({ code: binWGSL() }); | |
| const updMod = device.createShaderModule({ code: updateWGSL() }); | |
| const vsMod = device.createShaderModule({ code: vertexWGSL() }); | |
| const fsMod = device.createShaderModule({ code: fragWGSL }); | |
| clearPipe = device.createComputePipeline({ | |
| layout: 'auto', | |
| compute: { module: clearMod, entryPoint: 'main' } | |
| }); | |
| binPipe = device.createComputePipeline({ | |
| layout: 'auto', | |
| compute: { module: binMod, entryPoint: 'main' } | |
| }); | |
| updatePipe = device.createComputePipeline({ | |
| layout: 'auto', | |
| compute: { module: updMod, entryPoint: 'main' } | |
| }); | |
| renderPipe = device.createRenderPipeline({ | |
| layout: 'auto', | |
| vertex: { module: vsMod, entryPoint:'main' }, | |
| fragment: { module: fsMod, entryPoint:'main', targets:[{ format }] }, | |
| primitive: { topology:'triangle-list' } | |
| }); | |
| // bind groups (match shader layouts) | |
| bindClear = device.createBindGroup({ | |
| layout: clearPipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:3, resource:{ buffer: gridCounts } }, | |
| ], | |
| }); | |
| bindBin = device.createBindGroup({ | |
| layout: binPipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsA } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:3, resource:{ buffer: gridCounts } }, | |
| { binding:4, resource:{ buffer: gridIdx } }, | |
| ], | |
| }); | |
| bindUpdate = device.createBindGroup({ | |
| layout: updatePipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsA } }, | |
| { binding:1, resource:{ buffer: agentsB } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:3, resource:{ buffer: gridCounts } }, | |
| { binding:4, resource:{ buffer: gridIdx } }, | |
| { binding:5, resource:{ buffer: screenBuf } }, | |
| ], | |
| }); | |
| bindRender = device.createBindGroup({ | |
| layout: renderPipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsB } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:5, resource:{ buffer: screenBuf } }, | |
| ], | |
| }); | |
| } | |
| // ---------- Resize / DPR ---------- | |
| function resize(){ | |
| state.cssW = canvas.clientWidth || window.innerWidth; | |
| state.cssH = canvas.clientHeight || window.innerHeight; | |
| const dprCap = parseFloat(ui.dprCap.value); | |
| state.resScale = parseFloat(ui.resScale.value); | |
| state.dpr = Math.min(window.devicePixelRatio || 1, dprCap) * state.resScale; | |
| const w = Math.max(2, Math.floor(state.cssW * state.dpr)); | |
| const h = Math.max(2, Math.floor(state.cssH * state.dpr)); | |
| canvas.width = w; canvas.height = h; | |
| context.configure({ device, format, alphaMode:'opaque' }); | |
| device.queue.writeBuffer(screenBuf, 0, new Float32Array([state.cssW, state.cssH]).buffer); | |
| allocGrid(); if (agentsA && agentsB) makePipelines(); // call only when agent buffers exist// grid dims changed → rebuild | |
| } | |
| window.addEventListener('resize', ()=>{ resize(); }); | |
| // ---------- Uniforms ---------- | |
| function writeParams(){ | |
| // pack 16 floats (64 bytes) | |
| const dt = 1/60; | |
| const u = new Float32Array(16); | |
| u[0]=dt; u[1]=80; u[2]=22; u[3]=12; // dt, maxSpeed, neighborR, sepR | |
| u[4]=0.06; u[5]=0.03; u[6]=0.2; u[7]=0.005; // alignW, cohW, sepW, turnW | |
| u[8]=state.cellSize; u[9]=gridCols; u[10]=gridRows; u[11]=state.agentCount; | |
| u[12]=state.triSize; u[13]=state.velColor; u[14]=0; u[15]=0; | |
| device.queue.writeBuffer(paramsBuf, 0, u.buffer); | |
| } | |
| // ---------- Frame / passes ---------- | |
| function runCompute(encoder){ | |
| // 1) clear gridCounts | |
| { | |
| const pass = encoder.beginComputePass(); | |
| pass.setPipeline(clearPipe); | |
| pass.setBindGroup(0, bindClear); | |
| pass.dispatchWorkgroups(Math.ceil(gridCells / state.wgSize)); | |
| pass.end(); | |
| } | |
| // 2) bin agentsA into grid | |
| { | |
| const pass = encoder.beginComputePass(); | |
| pass.setPipeline(binPipe); | |
| pass.setBindGroup(0, bindBin); | |
| pass.dispatchWorkgroups(Math.ceil(state.agentCount / state.wgSize)); | |
| pass.end(); | |
| } | |
| // 3) update into agentsB | |
| { | |
| const pass = encoder.beginComputePass(); | |
| pass.setPipeline(updatePipe); | |
| pass.setBindGroup(0, bindUpdate); | |
| pass.dispatchWorkgroups(Math.ceil(state.agentCount / state.wgSize)); | |
| pass.end(); | |
| } | |
| } | |
| function render(encoder, view){ | |
| const pass = encoder.beginRenderPass({ | |
| colorAttachments:[{ view, loadOp:'clear', storeOp:'store', clearValue:{r:0.03,g:0.04,b:0.07,a:1} }] | |
| }); | |
| pass.setPipeline(renderPipe); | |
| pass.setBindGroup(0, bindRender); | |
| pass.draw(3, state.agentCount, 0, 0); | |
| pass.end(); | |
| } | |
| // ---------- UI wiring ---------- | |
| function syncLabels(){ | |
| ui.dprCapVal.textContent = parseFloat(ui.dprCap.value).toFixed(1); | |
| ui.resScaleVal.textContent = parseFloat(ui.resScale.value).toFixed(2); | |
| ui.agentCountVal.textContent = state.agentCount.toString(); | |
| ui.cellSizeVal.textContent = state.cellSize.toString(); | |
| ui.maxPerCellVal.textContent = state.maxPerCell.toString(); | |
| ui.physEveryVal.textContent = state.physEvery.toString(); | |
| ui.triSizeVal.textContent = state.triSize.toFixed(1); | |
| ui.velColorVal.textContent = state.velColor.toFixed(2); | |
| } | |
| ui.dprCap.oninput = ()=>{ syncLabels(); resize(); }; | |
| ui.resScale.oninput = ()=>{ syncLabels(); resize(); }; | |
| ui.agentCount.oninput = ()=>{ state.agentCount = parseInt(ui.agentCount.value,10); syncLabels(); allocAgents(state.agentCount); makePipelines(); }; | |
| ui.cellSize.oninput = ()=>{ state.cellSize = parseInt(ui.cellSize.value,10); syncLabels(); resize(); }; | |
| ui.maxPerCell.oninput = ()=>{ state.maxPerCell = parseInt(ui.maxPerCell.value,10); syncLabels(); allocGrid(); makePipelines(); }; | |
| ui.physEvery.oninput = ()=>{ state.physEvery = parseInt(ui.physEvery.value,10); syncLabels(); }; | |
| ui.applyWg.onclick = ()=>{ state.wgSize = parseInt(ui.wgSize.value,10); makePipelines(); }; | |
| ui.triSize.oninput = ()=>{ state.triSize = parseFloat(ui.triSize.value); syncLabels(); writeParams(); }; | |
| ui.velColor.oninput = ()=>{ state.velColor = parseFloat(ui.velColor.value); syncLabels(); writeParams(); }; | |
| ui.toggleUI.onclick = ()=>{ ui.hideUI.checked = !ui.hideUI.checked; }; | |
| // ---------- Boot ---------- | |
| // initial sizes | |
| resize(); | |
| allocAgents(state.agentCount); | |
| makePipelines(); | |
| writeParams(); | |
| let tick=0, last=performance.now(), frames=0, acc=0; | |
| function frame(){ | |
| const t = performance.now(); | |
| const dt = t - last; last = t; frames++; acc += dt; | |
| if (acc >= 500){ fpsEl.textContent = ((frames*1000)/acc).toFixed(0); frames=0; acc=0; } | |
| hzEl.textContent = state.physEvery===1 ? '~60' : (state.physEvery===2 ? '~30' : state.physEvery===3 ? '~20' : '~15'); | |
| writeParams(); | |
| const encoder = device.createCommandEncoder(); | |
| if ((tick++ % state.physEvery) === 0) { | |
| runCompute(encoder); | |
| // swap A/B | |
| [agentsA, agentsB] = [agentsB, agentsA]; | |
| // rebind after swap | |
| bindBin = device.createBindGroup({ | |
| layout: binPipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsA } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:3, resource:{ buffer: gridCounts } }, | |
| { binding:4, resource:{ buffer: gridIdx } }, | |
| ], | |
| }); | |
| bindUpdate = device.createBindGroup({ | |
| layout: updatePipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsA } }, | |
| { binding:1, resource:{ buffer: agentsB } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:3, resource:{ buffer: gridCounts } }, | |
| { binding:4, resource:{ buffer: gridIdx } }, | |
| { binding:5, resource:{ buffer: screenBuf } }, | |
| ], | |
| }); | |
| bindRender = device.createBindGroup({ | |
| layout: renderPipe.getBindGroupLayout(0), | |
| entries: [ | |
| { binding:0, resource:{ buffer: agentsB } }, | |
| { binding:2, resource:{ buffer: paramsBuf } }, | |
| { binding:5, resource:{ buffer: screenBuf } }, | |
| ], | |
| }); | |
| } | |
| const view = context.getCurrentTexture().createView(); | |
| render(encoder, view); | |
| device.queue.submit([encoder.finish()]); | |
| requestAnimationFrame(frame); | |
| } | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment