diff --git a/demos/interactive_demo.html b/demos/interactive_demo.html new file mode 100644 index 0000000..c8f3957 --- /dev/null +++ b/demos/interactive_demo.html @@ -0,0 +1,213 @@ + + + + + Galaxy Stars - GridWise Visualization + + + + +
+ +
+
+ + +
10K
+
+ +
+ + + +
+ +
Click: attract • Shift: repel
+
+ + + + diff --git a/demos/interactive_demo.mjs b/demos/interactive_demo.mjs new file mode 100644 index 0000000..cdb1eaf --- /dev/null +++ b/demos/interactive_demo.mjs @@ -0,0 +1,499 @@ +import { OneSweepSort } from "../onesweep.mjs"; +import { DLDFScan } from "../scandldf.mjs"; +import { BinOpAdd } from "../binop.mjs"; + + +if (!navigator.gpu) { + showError("WebGPU is not available in this browser. Try Chrome 113+ or Edge 113+."); + throw new Error("WebGPU not supported: navigator.gpu is unavailable"); +} + +const adapter = await navigator.gpu.requestAdapter(); +if (!adapter) { + showError("No WebGPU-compatible GPU adapter was found on this device."); + throw new Error("WebGPU adapter unavailable: requestAdapter() returned null"); +} + +const device = await adapter.requestDevice({ + requiredLimits: { + maxComputeWorkgroupStorageSize: 32768, + }, + requiredFeatures: adapter.features.has("subgroups") ? ["subgroups"] : [], +}); + +if (!device) { + showError("WebGPU device creation failed. The GPU may be in use or unavailable."); + throw new Error("WebGPU device creation failed: requestDevice() returned null"); +} +device.lost.then((info) => { + const msg = `WebGPU device was lost (reason: ${info.reason}): ${info.message}`; + console.error(msg); + showError("GPU device lost please reload the page. " + info.message); +}); + + + +const canvas = document.getElementById("canvas"); +const ctx = canvas.getContext("2d"); + +function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; +} +resizeCanvas(); +window.addEventListener("resize", resizeCanvas); + +class Particle { + constructor(x, y, colorIndex, size) { + this.x = x; + this.y = y; + this.originalX = x; + this.originalY = y; + this.vx = (Math.random() - 0.5) * 0.5; + this.vy = (Math.random() - 0.5) * 0.5; + this.targetX = x; + this.targetY = y; + this.colorIndex = colorIndex; + this.size = size; + this.baseSize = size; + } + + update() { + if (!isOperating) { + const dx = mouseX - this.x; + const dy = mouseY - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (mouseDown && dist < 150 && dist > 0.1) { + const force = (150 - dist) / 150 * (attractMode ? 0.4 : -0.4); + this.vx += (dx / dist) * force; + this.vy += (dy / dist) * force; + } + + this.vx *= 0.99; + this.vy *= 0.99; + + this.x += this.vx; + this.y += this.vy; + + if (this.x < 0 || this.x > canvas.width) this.vx *= -0.5; + if (this.y < 0 || this.y > canvas.height) this.vy *= -0.5; + this.x = Math.max(0, Math.min(canvas.width, this.x)); + this.y = Math.max(0, Math.min(canvas.height, this.y)); + } else { + this.x += (this.targetX - this.x) * 0.08; + this.y += (this.targetY - this.y) * 0.08; + } + } + + draw() { + const color = COLORS[this.colorIndex]; + const glowSize = this.size * 2.5; + + const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, glowSize); + gradient.addColorStop(0, `hsla(${color.h}, ${color.s}%, ${color.l}%, 0.9)`); + gradient.addColorStop(0.5, `hsla(${color.h}, ${color.s}%, ${color.l}%, 0.4)`); + gradient.addColorStop(1, `hsla(${color.h}, ${color.s}%, ${color.l}%, 0)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(this.x, this.y, glowSize, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size * 0.6, 0, Math.PI * 2); + ctx.fill(); + } +} + +const COLORS = [ + { h: 350, s: 85, l: 60 }, + { h: 30, s: 90, l: 55 }, + { h: 180, s: 80, l: 55 }, + { h: 280, s: 85, l: 60 }, + { h: 200, s: 80, l: 60 }, + { h: 60, s: 90, l: 60 }, + { h: 120, s: 75, l: 55 }, + { h: 240, s: 85, l: 60 }, +]; + +let particles = []; +let particleCount = 10000; +let isOperating = false; +let currentOperation = null; +let mouseX = -1000; +let mouseY = -1000; +let mouseDown = false; +let attractMode = true; + +const starSlider = document.getElementById("starSlider"); +const starCountDisplay = document.getElementById("starCount"); +const sortBtn = document.getElementById("sortBtn"); +const scanBtn = document.getElementById("scanBtn"); +const reduceBtn = document.getElementById("reduceBtn"); + +function generateParticles(count) { + particles = []; + + for (let i = 0; i < count; i++) { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height; + const colorIndex = Math.floor(Math.random() * COLORS.length); + const size = 0.4 + Math.random() * 1.2; + + const particle = new Particle(x, y, colorIndex, size); + particle.originalX = x; + particle.originalY = y; + particles.push(particle); + } + + return particles; +} + +function render() { + ctx.fillStyle = "rgba(10, 10, 15, 0.25)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + for (const particle of particles) { + particle.update(); + particle.draw(); + } + + requestAnimationFrame(render); +} + + +function showError(msg) { + const el = document.getElementById("errorDisplay"); + el.textContent = msg; + el.style.display = "block"; + setTimeout(() => { el.style.display = "none"; }, 6000); +} + +async function performSort() { + if (currentOperation) { + clearTimeout(currentOperation); + } + + isOperating = true; + + try { + const sizeKeys = new Uint32Array(particles.length); + const indexPayload = new Uint32Array(particles.length); + for (let i = 0; i < particles.length; i++) { + sizeKeys[i] = Math.floor(particles[i].size * 1000); + indexPayload[i] = i; + } + + const sorter = new OneSweepSort({ + device: device, + datatype: "u32", + type: "keyvalue", + direction: "ascending", + inputLength: sizeKeys.length, + copyOutputToTemp: true, + }); + + const inputBuffer = device.createBuffer({ + size: sizeKeys.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + const outputBuffer = device.createBuffer({ + size: sizeKeys.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + const payloadInOut = device.createBuffer({ + size: indexPayload.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + const payloadTemp = device.createBuffer({ + size: indexPayload.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + device.queue.writeBuffer(inputBuffer, 0, sizeKeys); + device.queue.writeBuffer(payloadInOut, 0, indexPayload); + + await sorter.execute({ + keysInOut: inputBuffer, + keysTemp: outputBuffer, + payloadInOut: payloadInOut, + payloadTemp: payloadTemp, + }); + + + const mappableBuffer = device.createBuffer({ + size: indexPayload.byteLength, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(payloadTemp, 0, mappableBuffer, 0, indexPayload.byteLength); + device.queue.submit([encoder.finish()]); + + await mappableBuffer.mapAsync(GPUMapMode.READ); + const sortedIndices = new Uint32Array(mappableBuffer.getMappedRange().slice()); + mappableBuffer.unmap(); + mappableBuffer.destroy(); + const padding = 100; + const usableWidth = canvas.width - padding * 2; + const usableHeight = canvas.height - padding * 2; + + for (let rank = 0; rank < sortedIndices.length; rank++) { + const origIdx = sortedIndices[rank]; + const progress = rank / particles.length; + particles[origIdx].targetX = padding + progress * usableWidth; + particles[origIdx].targetY = padding + (Math.random() * 0.5 + 0.25) * usableHeight; + particles[origIdx].vx = 0; + particles[origIdx].vy = 0; + } + + inputBuffer.destroy(); + outputBuffer.destroy(); + payloadInOut.destroy(); + payloadTemp.destroy(); + + currentOperation = setTimeout(() => { + particles.forEach((particle) => { + particle.targetX = particle.originalX; + particle.targetY = particle.originalY; + }); + + currentOperation = setTimeout(() => { + isOperating = false; + currentOperation = null; + }, 8000); + }, 8000); + + } catch (error) { + console.error("Sort error:", error); + showError("GPU sort failed: " + error.message); + isOperating = false; + currentOperation = null; + } +} + +async function performScan() { + if (currentOperation) { + clearTimeout(currentOperation); + } + + isOperating = true; + + try { + const sizeValues = new Uint32Array(particles.map(p => Math.floor(p.size * 1000))); + + const scanner = new DLDFScan({ + device: device, + binop: new BinOpAdd({ datatype: "u32" }), + type: "inclusive", + datatype: "u32", + }); + + const inputBuffer = device.createBuffer({ + size: sizeValues.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + + const outputBuffer = device.createBuffer({ + size: sizeValues.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + device.queue.writeBuffer(inputBuffer, 0, sizeValues); + + await scanner.execute({ + inputBuffer: inputBuffer, + outputBuffer: outputBuffer, + }); + + const mappableBuffer = device.createBuffer({ + size: sizeValues.byteLength, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(outputBuffer, 0, mappableBuffer, 0, sizeValues.byteLength); + device.queue.submit([encoder.finish()]); + + await mappableBuffer.mapAsync(GPUMapMode.READ); + const scannedValues = new Uint32Array(mappableBuffer.getMappedRange().slice()); + mappableBuffer.unmap(); + mappableBuffer.destroy(); + + const maxValue = scannedValues[scannedValues.length - 1] || 1; + + particles.forEach((particle, i) => { + const normalized = scannedValues[i] / maxValue; + const wavePhase = normalized * Math.PI * 12; + particle.targetX = normalized * canvas.width; + particle.targetY = canvas.height / 2 + Math.sin(wavePhase) * (canvas.height * 0.3); + particle.vx = 0; + particle.vy = 0; + }); + + inputBuffer.destroy(); + outputBuffer.destroy(); + + currentOperation = setTimeout(() => { + particles.forEach((particle) => { + particle.targetX = particle.originalX; + particle.targetY = particle.originalY; + }); + + currentOperation = setTimeout(() => { + isOperating = false; + currentOperation = null; + }, 8000); + }, 8000); + + } catch (error) { + console.error("Scan error:", error); + showError("GPU scan failed: " + error.message); + isOperating = false; + currentOperation = null; + } +} + +async function performReduce() { + if (currentOperation) { + clearTimeout(currentOperation); + } + + isOperating = true; + + try { + const sizeValues = new Uint32Array(particles.map(p => Math.floor(p.size * 1000))); + const reducer = new DLDFScan({ + device: device, + binop: new BinOpAdd({ datatype: "u32" }), + type: "reduce", + datatype: "u32", + }); + + const inputBuffer = device.createBuffer({ + size: sizeValues.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + + const outputBuffer = device.createBuffer({ + size: 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + device.queue.writeBuffer(inputBuffer, 0, sizeValues); + + await reducer.execute({ + inputBuffer: inputBuffer, + outputBuffer: outputBuffer, + }); + + const mappableBuffer = device.createBuffer({ + size: 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(outputBuffer, 0, mappableBuffer, 0, 4); + device.queue.submit([encoder.finish()]); + + await mappableBuffer.mapAsync(GPUMapMode.READ); + const reduceResult = new Uint32Array(mappableBuffer.getMappedRange().slice()); + mappableBuffer.unmap(); + mappableBuffer.destroy(); + + inputBuffer.destroy(); + outputBuffer.destroy(); + + const totalSize = reduceResult[0]; + const meanSize = totalSize / sizeValues.length; + const maxRadius = Math.min(canvas.width, canvas.height) * 0.45; + const scaledRadius = maxRadius * Math.min(meanSize / 700, 1.5); + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + + particles.forEach((particle, i) => { + const progress = i / particles.length; + const angle = (i * 137.5) * (Math.PI / 180); + const radius = Math.sqrt(progress) * scaledRadius; + + particle.targetX = centerX + Math.cos(angle) * radius; + particle.targetY = centerY + Math.sin(angle) * radius; + particle.vx = 0; + particle.vy = 0; + }); + currentOperation = setTimeout(() => { + particles.forEach((particle) => { + particle.targetX = particle.originalX; + particle.targetY = particle.originalY; + }); + + currentOperation = setTimeout(() => { + isOperating = false; + currentOperation = null; + }, 8000); + }, 8000); + + } catch (error) { + console.error("Reduce error:", error); + showError("GPU reduce failed: " + error.message); + isOperating = false; + currentOperation = null; + } +} + +function formatStarCount(count) { + if (count >= 1000) { + return (count / 1000).toFixed(0) + "K"; + } + return count; +} + +canvas.addEventListener("mousemove", (e) => { + mouseX = e.clientX; + mouseY = e.clientY; +}); + +canvas.addEventListener("mouseleave", () => { + mouseX = -1000; + mouseY = -1000; +}); + +starSlider.addEventListener("input", (e) => { + particleCount = parseInt(e.target.value); + starCountDisplay.textContent = formatStarCount(particleCount); +}); + +starSlider.addEventListener("change", (e) => { + particleCount = parseInt(e.target.value); + + if (currentOperation) { + clearTimeout(currentOperation); + currentOperation = null; + } + + isOperating = false; + + setTimeout(() => { + generateParticles(particleCount); + }, 100); +}); + +canvas.addEventListener("mousedown", (e) => { + mouseDown = true; + attractMode = !e.shiftKey; +}); + +canvas.addEventListener("mouseup", () => { + mouseDown = false; +}); + +sortBtn.addEventListener("click", performSort); +scanBtn.addEventListener("click", performScan); +reduceBtn.addEventListener("click", performReduce); + +generateParticles(particleCount); +render(); diff --git a/docs/_includes/header.html b/docs/_includes/header.html index b84349a..748396c 100644 --- a/docs/_includes/header.html +++ b/docs/_includes/header.html @@ -42,6 +42,15 @@ Regression Tests + + + diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 8d8c9c4..e2eaf96 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -1,5 +1,11 @@