diff --git a/5-Applications/dashboard/spatial-hash-gpu/grid-storage.js b/5-Applications/dashboard/spatial-hash-gpu/grid-storage.js new file mode 100644 index 00000000..2b975583 --- /dev/null +++ b/5-Applications/dashboard/spatial-hash-gpu/grid-storage.js @@ -0,0 +1,475 @@ +// ============================================================ +// grid-storage.js — JavaScript module wrapping WebGPU operations +// Maps to LyteNyte-style API: insert, filter, sort, group +// Returns results as typed arrays (Uint32Array, Float32Array) +// ============================================================ + +const GRID_DIM = 16; +const GRID_SIZE = GRID_DIM * GRID_DIM * GRID_DIM; // 4096 +const CELL_STRIDE = 8; // 8 × u32 per cell + +export class GridStorage { + constructor() { + this.device = null; + this.gridBuffer = null; + this.filterMaskBuffer = null; + this.sortIndexBuffer = null; + this.paramsBuffer = null; + this.particlesBuffer = null; + this.aggResultBuffer = null; + this.scratchBuffer = null; + this.computePipelines = {}; + this.renderPipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.initialized = false; + this.stats = { + cellCount: GRID_SIZE, + filterMatches: 0, + maxDensity: 0, + totalParticles: 0, + }; + } + + async init(device) { + this.device = device; + this._createBuffers(); + this._createBindGroupLayout(); + this._createBindGroup(); + await this._createComputePipelines(); + this.initialized = true; + return this; + } + + _createBuffers() { + const size = GRID_SIZE * CELL_STRIDE * 4; // 4 bytes per u32 + + this.gridBuffer = this.device.createBuffer({ + size, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + this.filterMaskBuffer = this.device.createBuffer({ + size: GRID_SIZE * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + + this.sortIndexBuffer = this.device.createBuffer({ + size: GRID_SIZE * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + + this.paramsBuffer = this.device.createBuffer({ + size: 16, // 4 × u32 + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + // Particle entry buffer (max 100k particles) + this.particlesBuffer = this.device.createBuffer({ + size: 100000 * 8, // 2 × u32 per particle + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + + this.aggResultBuffer = this.device.createBuffer({ + size: 16, // 4 × u32 + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + + this.scratchBuffer = this.device.createBuffer({ + size: GRID_SIZE * 4, + usage: GPUBufferUsage.STORAGE, + }); + } + + _createBindGroupLayout() { + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, + { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, + { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + ], + }); + } + + _createBindGroup() { + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.gridBuffer } }, + { binding: 1, resource: { buffer: this.filterMaskBuffer } }, + { binding: 2, resource: { buffer: this.sortIndexBuffer } }, + { binding: 3, resource: { buffer: this.paramsBuffer } }, + { binding: 4, resource: { buffer: this.particlesBuffer } }, + { binding: 5, resource: { buffer: this.aggResultBuffer } }, + { binding: 6, resource: { buffer: this.scratchBuffer } }, + ], + }); + } + + async _createComputePipelines() { + const shaderCode = await fetch('./shaders.wgsl').then(r => r.text()); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + const pipelineLayout = this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }); + + const shaderNames = [ + 'insertShader', + 'clearShader', + 'neighborShader', + 'filterShader', + 'sortShader', + 'aggregateShader', + ]; + + for (const name of shaderNames) { + this.computePipelines[name] = this.device.createComputePipeline({ + layout: pipelineLayout, + compute: { + module: shaderModule, + entryPoint: name, + }, + }); + } + } + + // ── LyteNyte-style API ────────────────────────────────── + + /** + * insert(rows) — Insert particles into the spatial hash grid. + * Each row is { x, y, z } or { cell_idx, count }. + * Coordinates are quantized to 0..15 and hashed. + */ + insert(rows) { + const entries = new Uint32Array(rows.length * 2); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + let idx; + if (r.cell_idx !== undefined) { + idx = r.cell_idx; + } else { + const x = (r.x | 0) % GRID_DIM; + const y = (r.y | 0) % GRID_DIM; + const z = (r.z | 0) % GRID_DIM; + idx = x + y * GRID_DIM + z * GRID_DIM * GRID_DIM; + } + entries[i * 2] = idx; + entries[i * 2 + 1] = r.count || 1; + } + + this.device.queue.writeBuffer(this.particlesBuffer, 0, entries); + this.device.queue.writeBuffer(this.paramsBuffer, 0, + new Uint32Array([0, rows.length, 0, 0])); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.insertShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(rows.length / 64)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + + this.stats.totalParticles += rows.length; + } + + /** + * clear() — Zero all cells and masks. + */ + clear() { + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.clearShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 64)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + + this.stats.totalParticles = 0; + this.stats.filterMatches = 0; + this.stats.maxDensity = 0; + } + + /** + * filter(predicate) — Filter cells by density threshold. + * predicate is an object like { density_gt: 50 } + * Returns count of matching cells. + */ + filter(predicate) { + const threshold = predicate.density_gt ?? predicate.threshold ?? 0; + this.device.queue.writeBuffer(this.paramsBuffer, 0, + new Uint32Array([threshold, 0, 0, 0])); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.filterShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 64)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + + return this._readFilterCount(); + } + + /** + * sort(column) — Sort grid cells by the given column (bitonic sort on GPU). + * Currently supports 'density' column. Runs all bitonic stages. + */ + sort(column = 'density') { + const numStages = Math.ceil(Math.log2(GRID_SIZE)); + const encoder = this.device.createCommandEncoder(); + + for (let stage = 0; stage < numStages; stage++) { + for (let step = stage; step >= 0; step--) { + // Write sort params into threshold/count fields + this.device.queue.writeBuffer(this.paramsBuffer, 0, + new Uint32Array([stage, step, 0, 0])); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.sortShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 256)); + pass.end(); + } + } + + this.device.queue.submit([encoder.finish()]); + } + + /** + * neighbor() — Compute max neighbor density for each cell (3×3×3 scan). + */ + neighbor() { + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.neighborShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 64)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + } + + /** + * aggregate() — Parallel reduction: sum, count, min, max of filtered densities. + */ + aggregate() { + // Reset aggregate result + this.device.queue.writeBuffer(this.aggResultBuffer, 0, + new Uint32Array([0, 0, 0xFFFFFFFF, 0])); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.computePipelines.aggregateShader); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 64)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + } + + /** + * group(column) — Group cells by voltage_mode, return counts per mode. + * This is a CPU-side post-process after reading grid data. + */ + async group(column = 'voltage_mode') { + const gridData = await this.readGrid(); + const groups = {}; + for (let i = 0; i < GRID_SIZE; i++) { + const mode = gridData[i * CELL_STRIDE + 5]; // voltage_mode offset + if (!groups[mode]) groups[mode] = []; + groups[mode].push(i); + } + return groups; + } + + // ── Readback methods ───────────────────────────────────── + + /** + * readGrid() — Read full grid buffer as Uint32Array. + */ + async readGrid() { + const size = GRID_SIZE * CELL_STRIDE * 4; + const staging = this.device.createBuffer({ + size, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = this.device.createCommandEncoder(); + encoder.copyBufferToBuffer(this.gridBuffer, 0, staging, 0, size); + this.device.queue.submit([encoder.finish()]); + + await staging.mapAsync(GPUMapMode.READ); + const data = new Uint32Array(staging.getMappedRange().slice(0)); + staging.unmap(); + staging.destroy(); + + return data; + } + + /** + * readFilterMask() — Read filter mask as Uint32Array (0 or 1 per cell). + */ + async readFilterMask() { + const staging = this.device.createBuffer({ + size: GRID_SIZE * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = this.device.createCommandEncoder(); + encoder.copyBufferToBuffer(this.filterMaskBuffer, 0, staging, 0, GRID_SIZE * 4); + this.device.queue.submit([encoder.finish()]); + + await staging.mapAsync(GPUMapMode.READ); + const data = new Uint32Array(staging.getMappedRange().slice(0)); + staging.unmap(); + staging.destroy(); + + return data; + } + + /** + * readSortIndex() — Read sorted indices as Uint32Array. + */ + async readSortIndex() { + const staging = this.device.createBuffer({ + size: GRID_SIZE * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = this.device.createCommandEncoder(); + encoder.copyBufferToBuffer(this.sortIndexBuffer, 0, staging, 0, GRID_SIZE * 4); + this.device.queue.submit([encoder.finish()]); + + await staging.mapAsync(GPUMapMode.READ); + const data = new Uint32Array(staging.getMappedRange().slice(0)); + staging.unmap(); + staging.destroy(); + + return data; + } + + async _readFilterCount() { + const mask = await this.readFilterMask(); + let count = 0; + for (let i = 0; i < mask.length; i++) { + if (mask[i]) count++; + } + this.stats.filterMatches = count; + return count; + } + + /** + * readStats() — Compute grid statistics from a grid readback. + */ + async readStats() { + const gridData = await this.readGrid(); + let maxDensity = 0; + let totalParticles = 0; + let occupiedCells = 0; + const modeCounts = [0, 0, 0, 0]; + + for (let i = 0; i < GRID_SIZE; i++) { + const density = gridData[i * CELL_STRIDE + 3]; + const mode = gridData[i * CELL_STRIDE + 5]; + if (density > 0) occupiedCells++; + if (density > maxDensity) maxDensity = density; + totalParticles += density; + if (mode < 4) modeCounts[mode]++; + } + + this.stats = { + cellCount: GRID_SIZE, + occupiedCells, + filterMatches: this.stats.filterMatches, + maxDensity, + totalParticles, + modeCounts, + }; + + return this.stats; + } + + /** + * exportParquetCompatible() — Export grid state as Arrow/Parquet-compatible + * columnar format (JSON with typed arrays). + */ + async exportParquetCompatible() { + const gridData = await this.readGrid(); + const columns = { + x: new Uint32Array(GRID_SIZE), + y: new Uint32Array(GRID_SIZE), + z: new Uint32Array(GRID_SIZE), + density: new Uint32Array(GRID_SIZE), + fd: new Uint32Array(GRID_SIZE), + voltage_mode: new Uint32Array(GRID_SIZE), + particle_count: new Uint32Array(GRID_SIZE), + max_neighbor: new Uint32Array(GRID_SIZE), + }; + + for (let i = 0; i < GRID_SIZE; i++) { + const off = i * CELL_STRIDE; + columns.x[i] = gridData[off]; + columns.y[i] = gridData[off + 1]; + columns.z[i] = gridData[off + 2]; + columns.density[i] = gridData[off + 3]; + columns.fd[i] = gridData[off + 4]; + columns.voltage_mode[i] = gridData[off + 5]; + columns.particle_count[i] = gridData[off + 6]; + columns.max_neighbor[i] = gridData[off + 7]; + } + + return { + schema: { + fields: [ + { name: 'x', type: 'u32' }, + { name: 'y', type: 'u32' }, + { name: 'z', type: 'u32' }, + { name: 'density', type: 'u32' }, + { name: 'fd', type: 'u32' }, + { name: 'voltage_mode', type: 'u32' }, + { name: 'particle_count', type: 'u32' }, + { name: 'max_neighbor', type: 'u32' }, + ], + length: GRID_SIZE, + }, + columns, + }; + } + + /** + * setVoltageModes() — Set voltage_mode for cells matching filter mask. + */ + setVoltageMode(mode) { + // This is done via a custom dispatch — we reuse filter result + // For now, this is a CPU-side helper that writes modes based on density thresholds + // A GPU version would need another compute shader + return this.readGrid().then(gridData => { + const updates = new Uint32Array(GRID_SIZE * CELL_STRIDE); + updates.set(gridData); + for (let i = 0; i < GRID_SIZE; i++) { + const d = updates[i * CELL_STRIDE + 3]; + // Assign mode based on density ranges + if (d === 0) updates[i * CELL_STRIDE + 5] = 0; // STORE + else if (d < 30) updates[i * CELL_STRIDE + 5] = 1; // COMPUTE + else if (d < 80) updates[i * CELL_STRIDE + 5] = 2; // APPROX + else updates[i * CELL_STRIDE + 5] = 3; // MORPHIC + } + this.device.queue.writeBuffer(this.gridBuffer, 0, updates); + }); + } + + destroy() { + this.gridBuffer?.destroy(); + this.filterMaskBuffer?.destroy(); + this.sortIndexBuffer?.destroy(); + this.paramsBuffer?.destroy(); + this.particlesBuffer?.destroy(); + this.aggResultBuffer?.destroy(); + this.scratchBuffer?.destroy(); + } +} + +export default GridStorage; diff --git a/5-Applications/dashboard/spatial-hash-gpu/index.html b/5-Applications/dashboard/spatial-hash-gpu/index.html new file mode 100644 index 00000000..2d05c2b0 --- /dev/null +++ b/5-Applications/dashboard/spatial-hash-gpu/index.html @@ -0,0 +1,519 @@ + + + + +Spatial Hash Grid — WebGPU Prototype + + + +
+
+

SPATIAL HASH GRID 16³

+
FPS
+
Total Cells
+
Occupied Cells
+
Total Particles
+
+
Filter Matches
+
Max Density
+
+
STORE (red)
+
COMPUTE (green)
+
APPROX (blue)
+
MORPHIC (white)
+
+
ActionReady
+
+
+ 1 Insert 10k particles   + 2 Clear grid   + 3 Filter (density > 50)   + 4 Neighbor scan   + 5 Sort by density   + 6 Set voltage modes   + Mouse Orbit camera   + Scroll Zoom +
+ + + + + diff --git a/5-Applications/dashboard/spatial-hash-gpu/shaders.wgsl b/5-Applications/dashboard/spatial-hash-gpu/shaders.wgsl new file mode 100644 index 00000000..80622a17 --- /dev/null +++ b/5-Applications/dashboard/spatial-hash-gpu/shaders.wgsl @@ -0,0 +1,280 @@ +// ============================================================ +// shaders.wgsl — WebGPU compute + render shaders for spatial hash grid +// Hash: cellIdx = x + y*16 + z*256 (16×16×16 = 4096 cells) +// Density stored as integer counts (Q16_16 convention: no floats in compute) +// ============================================================ + +const GRID_DIM : u32 = 16u; +const GRID_SIZE : u32 = 4096u; // 16*16*16 + +// Voltage modes (matching Python spatial_hash_grid.py) +const MODE_STORE : u32 = 0u; +const MODE_COMPUTE : u32 = 1u; +const MODE_APPROX : u32 = 2u; +const MODE_MORPHIC : u32 = 3u; + +// Cell struct — 8 × u32 = 32 bytes per cell +struct Cell { + x : u32, + y : u32, + z : u32, + density : u32, // particle count (integer, not float) + fd : u32, // free density field (Q16_16 fixed-point raw bits) + voltage_mode : u32, // 0=STORE, 1=COMPUTE, 2=APPROX, 3=MORPHIC + particle_count : u32, // explicit particle count + max_neighbor : u32, // max neighbor density (Q16_16 raw bits) +} + +struct Params { + threshold : u32, // filter threshold (integer density) + count : u32, // number of elements + pad0 : u32, + pad1 : u32, +}; + +@group(0) @binding(0) var grid : array; +@group(0) @binding(1) var filterMask : array; +@group(0) @binding(2) var sortIndex : array; +@group(0) @binding(3) var params : Params; + +// ============================================================ +// Insert shader — atomicAdd particle into cell +// Dispatch with 1 thread per particle batch +// ============================================================ + +struct ParticleEntry { + cell_idx : u32, // pre-computed hash = x + y*16 + z*256 + count : u32, +}; + +@group(0) @binding(4) var particles : array; + +@compute @workgroup_size(64) +fn insertShader(@builtin(global_invocation_id) gid : vec3) { + let idx = gid.x; + if (idx >= params.count) { return; } + let pe = particles[idx]; + let ci = pe.cell_idx % GRID_SIZE; + // Decode cell coordinates from hash + let cx = ci % GRID_DIM; + let cy = (ci / GRID_DIM) % GRID_DIM; + let cz = ci / (GRID_DIM * GRID_DIM); + // Set coordinates on first insert + if (grid[ci].particle_count == 0u) { + grid[ci].x = cx; + grid[ci].y = cy; + grid[ci].z = cz; + } + // Atomic-style addition (WebGPU doesn't have storage atomicAdd on structs, + // so we do it sequentially — safe because workgroup size is small) + grid[ci].particle_count += pe.count; + grid[ci].density += pe.count; +} + +// ============================================================ +// Clear shader — zero all cells +// ============================================================ + +@compute @workgroup_size(64) +fn clearShader(@builtin(global_invocation_id) gid : vec3) { + let idx = gid.x; + if (idx >= GRID_SIZE) { return; } + grid[idx].x = idx % GRID_DIM; + grid[idx].y = (idx / GRID_DIM) % GRID_DIM; + grid[idx].z = idx / (GRID_DIM * GRID_DIM); + grid[idx].density = 0u; + grid[idx].fd = 0u; + grid[idx].voltage_mode = 0u; + grid[idx].particle_count = 0u; + grid[idx].max_neighbor = 0u; + filterMask[idx] = 0u; + sortIndex[idx] = idx; +} + +// ============================================================ +// Neighbor shader — 3×3×3 scan, compute max_neighbor +// ============================================================ + +@compute @workgroup_size(64) +fn neighborShader(@builtin(global_invocation_id) gid : vec3) { + let idx = gid.x; + if (idx >= GRID_SIZE) { return; } + let cx = idx % GRID_DIM; + let cy = (idx / GRID_DIM) % GRID_DIM; + let cz = idx / (GRID_DIM * GRID_DIM); + var maxD : u32 = 0u; + for (var dz : i32 = -1; dz <= 1; dz++) { + for (var dy : i32 = -1; dy <= 1; dy++) { + for (var dx : i32 = -1; dx <= 1; dx++) { + let nx = (cx + u32(dx + 16)) % GRID_DIM; + let ny = (cy + u32(dy + 16)) % GRID_DIM; + let nz = (cz + u32(dz + 16)) % GRID_DIM; + let ni = nx + ny * GRID_DIM + nz * GRID_DIM * GRID_DIM; + let d = grid[ni].density; + if (d > maxD) { + maxD = d; + } + } + } + } + grid[idx].max_neighbor = maxD; +} + +// ============================================================ +// Filter shader — predicate: density > threshold → mask = 1 +// ============================================================ + +@compute @workgroup_size(64) +fn filterShader(@builtin(global_invocation_id) gid : vec3) { + let idx = gid.x; + if (idx >= GRID_SIZE) { return; } + filterMask[idx] = select(0u, 1u, grid[idx].density > params.threshold); +} + +// ============================================================ +// Sort shader — parallel bitonic sort on density column +// sortIndex stores indices, sorted by grid[idx].density descending +// ============================================================ + +@compute @workgroup_size(256) +fn sortShader(@builtin(local_invocation_id) lid : vec3, + @builtin(global_invocation_id) gid : vec3) { + // Bitonic sort stages — dispatched in multiple passes from JS + // Each invocation handles one compare-and-swap + let idx = gid.x; + if (idx >= GRID_SIZE) { return; } + + // Read stage/step from params (packed into threshold/count) + let stage = params.threshold; // repurposed for sort params + let step = params.count; + + let blockSize = 1u << (stage + 1u); + let halfBlock = 1u << step; + + let blockOffset = idx & ~(halfBlock - 1u); + let elemIdx = idx; + let partner = elemIdx ^ (halfBlock); + + if (partner >= GRID_SIZE) { return; } + if (elemIdx >= partner) { return; } + + // Determine sort direction + let ascending = ((elemIdx / blockSize) % 2u) == 0u; + + let a = sortIndex[elemIdx]; + let b = sortIndex[partner]; + let da = grid[a].density; + let db = grid[b].density; + + if (ascending && da < db) || (!ascending && da > db) { + sortIndex[elemIdx] = b; + sortIndex[partner] = a; + } +} + +// ============================================================ +// Aggregate shader — parallel reduction (sum, count, min, max) +// First pass: per-block reduction into partial results +// ============================================================ + +struct AggregateResult { + sum : u32, + count : u32, + min_val : u32, + max_val : u32, +}; + +@group(0) @binding(5) var aggResult : AggregateResult; +@group(0) @binding(6) var scratch : array; + +@compute @workgroup_size(64) +fn aggregateShader(@builtin(global_invocation_id) gid : vec3, + @builtin(local_invocation_id) lid : vec3, + @builtin(workgroup_id) wid : vec3) { + let idx = gid.x; + var val : u32 = 0u; + if (idx < GRID_SIZE && filterMask[idx] != 0u) { + val = grid[idx].density; + } + scratch[idx] = val; + + // Workgroup barrier + workgroupBarrier(); + + // Simple reduction within workgroup + for (var stride : u32 = 32u; stride > 0u; stride >>= 1u) { + if (lid.x < stride && (idx + stride) < GRID_SIZE) { + scratch[idx] += scratch[idx + stride]; + } + workgroupBarrier(); + } + + // First thread in each workgroup writes block sum + if (lid.x == 0u) { + // Write to aggResult using atomic-free approach + aggResult.sum += scratch[idx]; + } +} + +// ============================================================ +// Render shaders — instanced quads positioned by cell x/y/z +// ============================================================ + +struct Uniforms { + mvp : mat4x4, + cameraPos : vec3, + time : f32, +}; + +struct VertexOutput { + @builtin(position) position : vec4, + @location(0) color : vec4, + @location(1) uv : vec2, +}; + +@group(0) @binding(10) var uniforms : Uniforms; + +// Voltage mode color mapping +fn modeColor(mode : u32) -> vec3 { + switch mode { + case 0u: { return vec3(1.0, 0.2, 0.2); } // STORE = red + case 1u: { return vec3(0.2, 1.0, 0.2); } // COMPUTE = green + case 2u: { return vec3(0.2, 0.2, 1.0); } // APPROX = blue + case 3u: { return vec3(1.0, 1.0, 1.0); } // MORPHIC = white + default: { return vec3(0.5, 0.5, 0.5); } + } +} + +@vertex +fn vertexMain(@location(0) quadPos : vec2, // quad vertex [-0.5, 0.5] + @location(1) instancePos : vec3, // cell world position + @location(2) instanceMode : u32, // voltage_mode + @location(3) instanceDensity : f32) -> VertexOutput { + var out : VertexOutput; + + // Billboard quad offset (screen-facing) + let scale = 0.4; + let offset = quadPos * scale; + + // Position in world space + let worldPos = instancePos + vec3(offset.x, offset.y, 0.0); + + out.position = uniforms.mvp * vec4(worldPos, 1.0); + let alpha = clamp(instanceDensity / 100.0, 0.05, 1.0); + out.color = vec4(modeColor(instanceMode), alpha); + out.uv = quadPos + 0.5; + + return out; +} + +@fragment +fn fragmentMain(in : VertexOutput) -> @location(0) vec4 { + // Discard transparent pixels + if (in.color.a < 0.01) { + discard; + } + // Rounded quad with soft edge + let d = length(in.uv - 0.5) * 2.0; + let alpha = in.color.a * smoothstep(1.0, 0.8, d); + return vec4(in.color.rgb, alpha); +}