Skip to content

Instantly share code, notes, and snippets.

@brandon-fryslie
Created April 2, 2026 23:51
Show Gist options
  • Select an option

  • Save brandon-fryslie/6f30910e2f302567d10dd635254c3cb1 to your computer and use it in GitHub Desktop.

Select an option

Save brandon-fryslie/6f30910e2f302567d10dd635254c3cb1 to your computer and use it in GitHub Desktop.
webgpu + weber
import '../styles/main.css';
import type { SimMode, Simulation, AppState, ThemeColors, RGBThemeColors, ParamDef, ParamSection, ShapeParamDef, XRCameraOverride, DepthRef, ModeParamsMap, ShapeName } from './types';
// WGSL shader imports — Vite loads these as raw strings
import SHADER_BOIDS_COMPUTE from './shaders/boids.compute.wgsl?raw';
import SHADER_BOIDS_RENDER from './shaders/boids.render.wgsl?raw';
import SHADER_NBODY_COMPUTE from './shaders/nbody.compute.wgsl?raw';
import SHADER_NBODY_RENDER from './shaders/nbody.render.wgsl?raw';
import SHADER_FLUID_FORCES_ADVECT from './shaders/fluid.forces.wgsl?raw';
import SHADER_FLUID_DIFFUSE from './shaders/fluid.diffuse.wgsl?raw';
import SHADER_FLUID_PRESSURE from './shaders/fluid.pressure.wgsl?raw';
import SHADER_FLUID_DIVERGENCE from './shaders/fluid.divergence.wgsl?raw';
import SHADER_FLUID_GRADIENT from './shaders/fluid.gradient.wgsl?raw';
import SHADER_FLUID_RENDER from './shaders/fluid.render.wgsl?raw';
import SHADER_PARAMETRIC_COMPUTE from './shaders/parametric.compute.wgsl?raw';
import SHADER_PARAMETRIC_RENDER from './shaders/parametric.render.wgsl?raw';
import SHADER_GRID from './shaders/grid.wgsl?raw';
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 1: CONSTANTS, DEFAULTS, PRESETS
// ═══════════════════════════════════════════════════════════════════════════════
const DEFAULTS: ModeParamsMap = {
boids: {
count: 1000, separationRadius: 25, alignmentRadius: 50, cohesionRadius: 50,
maxSpeed: 2.0, maxForce: 0.05, visualRange: 100
},
physics: {
count: 500, G: 1.0, softening: 0.5, damping: 0.999, distribution: 'random'
},
fluid: {
resolution: 256, viscosity: 0.1, diffusionRate: 0.001, forceStrength: 100,
dyeMode: 'rainbow', jacobiIterations: 40
},
parametric: {
shape: 'torus', uRes: 64, vRes: 64, scale: 1.0, twist: 0.0, rotationSpeed: 0.5,
p1: 1.0, p2: 0.4, p3: 0, p4: 0
}
};
const PRESETS: Record<SimMode, Record<string, Record<string, number | string>>> = {
boids: {
'Default': { ...DEFAULTS.boids },
'Tight Flock': { count: 3000, separationRadius: 10, alignmentRadius: 30, cohesionRadius: 80, maxSpeed: 3.0, maxForce: 0.08, visualRange: 60 },
'Dispersed': { count: 2000, separationRadius: 60, alignmentRadius: 100, cohesionRadius: 20, maxSpeed: 1.5, maxForce: 0.03, visualRange: 200 },
'Massive': { count: 20000, separationRadius: 15, alignmentRadius: 40, cohesionRadius: 40, maxSpeed: 2.5, maxForce: 0.04, visualRange: 80 },
'Slow Dance': { count: 500, separationRadius: 40, alignmentRadius: 80, cohesionRadius: 100, maxSpeed: 0.5, maxForce: 0.01, visualRange: 150 },
},
physics: {
'Default': { ...DEFAULTS.physics },
'Galaxy': { count: 3000, G: 0.5, softening: 1.0, damping: 0.998, distribution: 'disk' },
'Collapse': { count: 2000, G: 10.0, softening: 0.1, damping: 0.995, distribution: 'shell' },
'Gentle': { count: 1000, G: 0.1, softening: 2.0, damping: 0.9999, distribution: 'random' },
},
fluid: {
'Default': { ...DEFAULTS.fluid },
'Thick': { resolution: 256, viscosity: 0.8, diffusionRate: 0.005, forceStrength: 200, dyeMode: 'rainbow', jacobiIterations: 40 },
'Turbulent': { resolution: 512, viscosity: 0.01, diffusionRate: 0.0001, forceStrength: 300, dyeMode: 'rainbow', jacobiIterations: 60 },
'Ink Drop': { resolution: 256, viscosity: 0.3, diffusionRate: 0.0, forceStrength: 50, dyeMode: 'single', jacobiIterations: 40 },
},
parametric: {
'Default': { shape: 'torus', uRes: 64, vRes: 64, scale: 1.0, twist: 0.0, rotationSpeed: 0.5, p1: 1.0, p2: 0.4, p3: 0, p4: 0 },
'Fat Ring': { shape: 'torus', uRes: 96, vRes: 96, scale: 1.0, twist: 0.0, rotationSpeed: 0.3, p1: 0.8, p2: 0.7, p3: 0, p4: 0 },
'Twisted Mobius': { shape: 'mobius', uRes: 128, vRes: 32, scale: 1.5, twist: 2.0, rotationSpeed: 0.4, p1: 1.5, p2: 3.0, p3: 0, p4: 0 },
'Spiky Trefoil': { shape: 'trefoil', uRes: 128, vRes: 32, scale: 1.2, twist: 0.0, rotationSpeed: 0.6, p1: 0.1, p2: 0.4, p3: 0, p4: 0 },
'Egg': { shape: 'sphere', uRes: 64, vRes: 64, scale: 1.0, twist: 0.0, rotationSpeed: 0.3, p1: 1.0, p2: 1.5, p3: 0, p4: 0 },
},
};
const PARAM_DEFS: Record<SimMode, ParamSection[]> = {
boids: [
{ section: 'Flock', params: [
{ key: 'count', label: 'Count', min: 100, max: 30000, step: 100, requiresReset: true },
{ key: 'visualRange', label: 'Visual Range', min: 10, max: 500, step: 5 },
]},
{ section: 'Forces', params: [
{ key: 'separationRadius', label: 'Separation', min: 1, max: 100, step: 1 },
{ key: 'alignmentRadius', label: 'Alignment', min: 1, max: 200, step: 1 },
{ key: 'cohesionRadius', label: 'Cohesion', min: 1, max: 200, step: 1 },
{ key: 'maxSpeed', label: 'Max Speed', min: 0.1, max: 10.0, step: 0.1 },
{ key: 'maxForce', label: 'Max Force', min: 0.001, max: 0.5, step: 0.001 },
]},
],
physics: [
{ section: 'Simulation', params: [
{ key: 'count', label: 'Bodies', min: 10, max: 10000, step: 10, requiresReset: true },
{ key: 'G', label: 'Gravity (G)', min: 0.01, max: 100.0, step: 0.01 },
{ key: 'softening', label: 'Softening', min: 0.01, max: 10.0, step: 0.01 },
{ key: 'damping', label: 'Damping', min: 0.9, max: 1.0, step: 0.001 },
]},
{ section: 'Initial State', params: [
{ key: 'distribution', label: 'Distribution', type: 'dropdown', options: ['random', 'disk', 'shell'] },
]},
],
fluid: [
{ section: 'Grid', params: [
{ key: 'resolution', label: 'Resolution', type: 'dropdown', options: [64, 128, 256, 512], requiresReset: true },
]},
{ section: 'Physics', params: [
{ key: 'viscosity', label: 'Viscosity', min: 0.0, max: 1.0, step: 0.01 },
{ key: 'diffusionRate', label: 'Diffusion', min: 0.0, max: 0.01, step: 0.0001 },
{ key: 'forceStrength', label: 'Force', min: 1, max: 500, step: 1 },
{ key: 'jacobiIterations', label: 'Iterations', min: 10, max: 80, step: 5 },
]},
{ section: 'Appearance', params: [
{ key: 'dyeMode', label: 'Dye Mode', type: 'dropdown', options: ['rainbow', 'single', 'temperature'] },
]},
],
parametric: [
{ section: 'Shape', params: [
{ key: 'shape', label: 'Equation', type: 'dropdown', options: ['torus', 'klein', 'mobius', 'sphere', 'trefoil'] },
{ key: 'uRes', label: 'U Segments', min: 8, max: 256, step: 8 },
{ key: 'vRes', label: 'V Segments', min: 8, max: 256, step: 8 },
]},
{ section: 'Shape Parameters', id: 'shape-params-section', params: [], dynamic: true },
{ section: 'Transform', params: [
{ key: 'scale', label: 'Scale', min: 0.1, max: 5.0, step: 0.1 },
{ key: 'twist', label: 'Twist', min: 0.0, max: 6.28, step: 0.01 },
{ key: 'rotationSpeed', label: 'Rotation', min: 0.0, max: 5.0, step: 0.1 },
]},
],
};
const COLOR_THEMES: Record<string, ThemeColors> = {
'Dracula': { primary: '#BD93F9', secondary: '#FF79C6', accent: '#50FA7B', bg: '#282A36', fg: '#F8F8F2' },
'Nord': { primary: '#88C0D0', secondary: '#81A1C1', accent: '#A3BE8C', bg: '#2E3440', fg: '#D8DEE9' },
'Monokai': { primary: '#AE81FF', secondary: '#F82672', accent: '#A5E22E', bg: '#272822', fg: '#D6D6D6' },
'Rose Pine': { primary: '#C4A7E7', secondary: '#EBBCBA', accent: '#9CCFD8', bg: '#191724', fg: '#E0DEF4' },
'Gruvbox': { primary: '#85A598', secondary: '#F9BD2F', accent: '#B7BB26', bg: '#282828', fg: '#FBF1C7' },
'Solarized': { primary: '#268BD2', secondary: '#2AA198', accent: '#849900', bg: '#002B36', fg: '#839496' },
'Tokyo Night': { primary: '#BB9AF7', secondary: '#7AA2F7', accent: '#9ECE6A', bg: '#1A1B26', fg: '#A9B1D6' },
'Catppuccin': { primary: '#F5C2E7', secondary: '#CBA6F7', accent: '#ABE9B3', bg: '#181825', fg: '#CDD6F4' },
'Atom One': { primary: '#61AFEF', secondary: '#C678DD', accent: '#62F062', bg: '#282C34', fg: '#ABB2BF' },
'Flexoki': { primary: '#205EA6', secondary: '#24837B', accent: '#65800B', bg: '#100F0F', fg: '#FFFCF0' },
};
function hexToRgb(hex: string): number[] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
}
function getThemeColors(): RGBThemeColors {
const t = COLOR_THEMES[state.colorTheme] || COLOR_THEMES['Dracula'];
return {
primary: hexToRgb(t.primary),
secondary: hexToRgb(t.secondary),
accent: hexToRgb(t.accent),
bg: hexToRgb(t.bg),
fg: hexToRgb(t.fg),
clearColor: { r: hexToRgb(t.bg)[0], g: hexToRgb(t.bg)[1], b: hexToRgb(t.bg)[2], a: 1 },
};
}
// Dynamic access to mode-specific params — casts for TypeScript's correlated types limitation
function modeParams(mode: SimMode): Record<string, number | string> {
return state[mode] as unknown as Record<string, number | string>;
}
const state: AppState = {
mode: 'boids',
colorTheme: 'Dracula',
xrEnabled: false,
paused: false,
boids: { ...DEFAULTS.boids },
physics: { ...DEFAULTS.physics },
fluid: { ...DEFAULTS.fluid },
parametric: { ...DEFAULTS.parametric },
camera: { distance: 5.0, fov: 60, rotX: 0.3, rotY: 0.0, panX: 0, panY: 0 },
mouse: { down: false, x: 0, y: 0, dx: 0, dy: 0, worldX: 0, worldY: 0, worldZ: 0 },
};
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 2: WGSL SHADERS
// ═══════════════════════════════════════════════════════════════════════════════
const FLUID_GRID_RES = 96; // tessellation resolution for 3D fluid mesh
// All shape equations baked into one shader — shapeId uniform selects which runs.
// p1–p4 are per-shape parameters passed as uniforms (no recompilation on change).
// Shape ID mapping for the shader's switch statement
const SHAPE_IDS: Record<ShapeName, number> = { torus: 0, klein: 1, mobius: 2, sphere: 3, trefoil: 4 };
// Per-shape parameter definitions: label + default value for p1–p4
const SHAPE_PARAMS: Partial<Record<ShapeName, Record<string, ShapeParamDef>>> = {
torus: { p1: { label: 'Major Radius', val: 1.0, min: 0.2, max: 3.0, step: 0.05 },
p2: { label: 'Minor Radius', val: 0.4, min: 0.05, max: 1.5, step: 0.05 } },
klein: { p1: { label: 'Bulge', val: 1.0, min: 0.2, max: 3.0, step: 0.05 } },
mobius: { p1: { label: 'Width', val: 1.0, min: 0.1, max: 3.0, step: 0.05 },
p2: { label: 'Half-Twists', val: 1.0, min: 0.5, max: 5.0, step: 0.5 } },
sphere: { p1: { label: 'XY Stretch', val: 1.0, min: 0.1, max: 3.0, step: 0.05 },
p2: { label: 'Z Stretch', val: 1.0, min: 0.1, max: 3.0, step: 0.05 } },
trefoil: { p1: { label: 'Tube Radius', val: 0.3, min: 0.05, max: 1.0, step: 0.05 },
p2: { label: 'Knot Scale', val: 0.3, min: 0.1, max: 1.0, step: 0.05 } },
};
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 3: MATH UTILITIES
// ═══════════════════════════════════════════════════════════════════════════════
const mat4 = {
identity() {
return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);
},
perspective(fovY: number, aspect: number, near: number, far: number) {
const f = 1.0 / Math.tan(fovY * 0.5);
const rangeInv = 1.0 / (near - far);
const out = new Float32Array(16);
out[0] = f / aspect;
out[5] = f;
out[10] = far * rangeInv;
out[11] = -1;
out[14] = near * far * rangeInv;
return out;
},
lookAt(eye: number[], target: number[], up: number[]) {
const zAxis = normalize3(sub3(eye, target));
const xAxis = normalize3(cross3(up, zAxis));
const yAxis = cross3(zAxis, xAxis);
return new Float32Array([
xAxis[0], yAxis[0], zAxis[0], 0,
xAxis[1], yAxis[1], zAxis[1], 0,
xAxis[2], yAxis[2], zAxis[2], 0,
-dot3(xAxis, eye), -dot3(yAxis, eye), -dot3(zAxis, eye), 1
]);
},
multiply(a: ArrayLike<number>, b: ArrayLike<number>) {
const out = new Float32Array(16);
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
out[j * 4 + i] = a[i] * b[j * 4] + a[4 + i] * b[j * 4 + 1] +
a[8 + i] * b[j * 4 + 2] + a[12 + i] * b[j * 4 + 3];
}
}
return out;
},
rotateX(m: ArrayLike<number>, angle: number) {
const c = Math.cos(angle), s = Math.sin(angle);
const r = mat4.identity();
r[5] = c; r[6] = s; r[9] = -s; r[10] = c;
return mat4.multiply(m, r);
},
rotateY(m: Float32Array, angle: number): Float32Array {
const c = Math.cos(angle), s = Math.sin(angle);
const r = mat4.identity();
r[0] = c; r[2] = -s; r[8] = s; r[10] = c;
return mat4.multiply(m, r);
},
rotateZ(m: ArrayLike<number>, angle: number) {
const c = Math.cos(angle), s = Math.sin(angle);
const r = mat4.identity();
r[0] = c; r[1] = s; r[4] = -s; r[5] = c;
return mat4.multiply(m, r);
},
translate(m: ArrayLike<number>, x: number, y: number, z: number) {
const t = mat4.identity();
t[12] = x; t[13] = y; t[14] = z;
return mat4.multiply(m, t);
},
};
function normalize3(v: number[]): number[] {
const len = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
return len > 0 ? [v[0]/len, v[1]/len, v[2]/len] : [0, 0, 0];
}
function cross3(a: number[], b: number[]): number[] {
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
}
function sub3(a: number[], b: number[]): number[] { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function dot3(a: number[], b: number[]): number { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function getOrbitCamera() {
const cam = state.camera;
const eye = [
cam.distance * Math.cos(cam.rotX) * Math.sin(cam.rotY),
cam.distance * Math.sin(cam.rotX),
cam.distance * Math.cos(cam.rotX) * Math.cos(cam.rotY)
];
return {
eye,
view: mat4.lookAt(eye, [cam.panX, cam.panY, 0], [0, 1, 0]),
proj: null // set per frame based on canvas aspect
};
}
// When set by XR frame loop, overrides orbit camera and depth texture for all rendering
let xrCameraOverride: XRCameraOverride | null = null;
let xrDepthView: GPUTextureView | null = null;
// Helper: get a depth texture view. In XR, use the XR-provided one.
// In desktop, manage a per-simulation depth texture that matches the canvas.
function getDepthView(simDepthRef: DepthRef): GPUTextureView {
if (xrDepthView) return xrDepthView;
// Desktop path: create/resize depth texture to match canvas
if (!simDepthRef.tex || simDepthRef.tex.width !== canvas.width || simDepthRef.tex.height !== canvas.height) {
if (simDepthRef.tex) simDepthRef.tex.destroy();
simDepthRef.tex = device.createTexture({
size: [canvas.width, canvas.height],
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
}
return simDepthRef.tex.createView();
}
function getCameraUniformData(aspect: number) {
const tc = getThemeColors();
const data = new Float32Array(48);
if (xrCameraOverride) {
// Use XR-provided matrices (correct FOV, stereo offset, world-locked)
data.set(xrCameraOverride.viewMatrix, 0);
data.set(xrCameraOverride.projMatrix, 16);
data.set(xrCameraOverride.eye, 32);
} else {
// Desktop: use orbit camera
const cam = getOrbitCamera();
const fovRad = state.camera.fov * Math.PI / 180;
const proj = mat4.perspective(fovRad, aspect, 0.01, 100.0);
data.set(cam.view, 0);
data.set(proj, 16);
data.set(cam.eye, 32);
}
// Theme colors (always appended)
data.set(tc.primary, 36);
data.set(tc.secondary, 40);
data.set(tc.accent, 44);
return data;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 4: WEBGPU INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════════
let device!: GPUDevice;
let canvas!: HTMLCanvasElement;
let context!: GPUCanvasContext;
let canvasFormat!: GPUTextureFormat;
async function initWebGPU(): Promise<boolean> {
const fallbackEl = document.getElementById('fallback')!;
const showFallback = (msg: string): void => {
fallbackEl.querySelector('p')!.textContent = msg;
fallbackEl.classList.add('visible');
};
if (!navigator.gpu) {
showFallback('navigator.gpu not found. This browser may not support WebGPU, or it may need to be enabled in settings.');
return false;
}
let adapter;
try {
adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance', xrCompatible: true });
} catch (e) {
showFallback(`requestAdapter() failed: ${(e as Error).message}`);
return false;
}
if (!adapter) {
showFallback('requestAdapter() returned null. WebGPU may be available but no suitable GPU adapter was found.');
return false;
}
try {
device = await adapter.requestDevice();
} catch (e) {
showFallback(`requestDevice() failed: ${(e as Error).message}`);
return false;
}
device.lost.then((info) => {
console.error('WebGPU device lost:', info.message);
if (info.reason !== 'destroyed') {
initWebGPU().then(ok => { if (ok) { initGrid(); ensureSimulation(); requestAnimationFrame(frame); } });
}
});
canvas = document.getElementById('gpu-canvas') as HTMLCanvasElement;
context = canvas.getContext('webgpu') as GPUCanvasContext;
canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format: canvasFormat, alphaMode: 'opaque' });
return true;
}
// ═══════════════════════════════════════════════════════════════════════════════
// ═══ SHARED GRID RENDERER ═══
let gridPipeline!: GPURenderPipeline;
let gridBG!: GPUBindGroup;
let gridCameraBuffer!: GPUBuffer;
let gridTimeBuffer!: GPUBuffer;
let gridTime = 0;
function initGrid() {
gridCameraBuffer = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
gridTimeBuffer = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const gridModule = device.createShaderModule({ code: SHADER_GRID });
const gridBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
]
});
gridPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [gridBGL] }),
vertex: { module: gridModule, entryPoint: 'vs_main' },
fragment: {
module: gridModule, entryPoint: 'fs_main',
targets: [{
format: canvasFormat,
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
}
}]
},
primitive: { topology: 'triangle-list' },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
});
gridBG = device.createBindGroup({ layout: gridBGL, entries: [
{ binding: 0, resource: { buffer: gridCameraBuffer } },
{ binding: 1, resource: { buffer: gridTimeBuffer } },
]});
}
function renderGrid(pass: GPURenderPassEncoder, aspect: number): void {
gridTime += 0.016;
device.queue.writeBuffer(gridCameraBuffer, 0, getCameraUniformData(aspect));
device.queue.writeBuffer(gridTimeBuffer, 0, new Float32Array([gridTime]));
pass.setPipeline(gridPipeline);
pass.setBindGroup(0, gridBG);
pass.draw(6);
}
// SECTION 5: SIMULATION MODULES
// ═══════════════════════════════════════════════════════════════════════════════
const simulations: Partial<Record<SimMode, Simulation>> = {};
// --- 5a: BOIDS ---
function createBoidsSimulation() {
const count = state.boids.count;
const particleBytes = count * 32; // vec3f pos (12) + pad(4) + vec3f vel (12) + pad(4) = 32
// Initialize particles randomly in a cube
const initData = new Float32Array(count * 8);
const boundSize = 2.0;
for (let i = 0; i < count; i++) {
const off = i * 8;
initData[off] = (Math.random() - 0.5) * boundSize * 2;
initData[off + 1] = (Math.random() - 0.5) * boundSize * 2;
initData[off + 2] = (Math.random() - 0.5) * boundSize * 2;
// pad
initData[off + 4] = (Math.random() - 0.5) * 0.5;
initData[off + 5] = (Math.random() - 0.5) * 0.5;
initData[off + 6] = (Math.random() - 0.5) * 0.5;
// pad
}
const bufferA = device.createBuffer({ size: particleBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Float32Array(bufferA.getMappedRange()).set(initData);
bufferA.unmap();
const bufferB = device.createBuffer({ size: particleBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
// SimParams: dt, sepR, aliR, cohR, maxSpeed, maxForce, visualRange, count, boundSize,
// attractorX, attractorY, attractorZ, attractorActive = 13 values → 64 bytes
const paramsBuffer = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const cameraBuffer = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const computeModule = device.createShaderModule({ code: SHADER_BOIDS_COMPUTE_EDIT || SHADER_BOIDS_COMPUTE });
const renderModule = device.createShaderModule({ code: SHADER_BOIDS_RENDER_EDIT || SHADER_BOIDS_RENDER });
const computeBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeBGL] }),
compute: { module: computeModule, entryPoint: 'main' }
});
const renderBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
]
});
const renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [renderBGL] }),
vertex: { module: renderModule, entryPoint: 'vs_main' },
fragment: {
module: renderModule, entryPoint: 'fs_main',
targets: [{ format: canvasFormat }]
},
primitive: { topology: 'triangle-list' },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
});
// Create bind groups for ping-pong
const computeBG = [
device.createBindGroup({ layout: computeBGL, entries: [
{ binding: 0, resource: { buffer: bufferA } },
{ binding: 1, resource: { buffer: bufferB } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
device.createBindGroup({ layout: computeBGL, entries: [
{ binding: 0, resource: { buffer: bufferB } },
{ binding: 1, resource: { buffer: bufferA } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
];
const renderBG = [
device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: bufferA } },
{ binding: 1, resource: { buffer: cameraBuffer } },
]}),
device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: bufferB } },
{ binding: 1, resource: { buffer: cameraBuffer } },
]}),
];
let pingPong = 0;
const depthRef: DepthRef = {};
return {
compute(encoder: GPUCommandEncoder) {
const p = state.boids;
const m = state.mouse;
const fullParams = new Float32Array(16);
fullParams[0] = 0.016;
fullParams[1] = p.separationRadius / 50;
fullParams[2] = p.alignmentRadius / 50;
fullParams[3] = p.cohesionRadius / 50;
fullParams[4] = p.maxSpeed;
fullParams[5] = p.maxForce;
fullParams[6] = p.visualRange / 50;
// [7] = count (u32, set below)
fullParams[8] = 2.0; // boundSize
fullParams[9] = m.worldX;
fullParams[10] = m.worldY;
fullParams[11] = m.worldZ;
fullParams[12] = m.down ? 1.0 : 0.0;
new Uint32Array(fullParams.buffer)[7] = count;
device.queue.writeBuffer(paramsBuffer, 0, fullParams);
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, computeBG[pingPong]);
pass.dispatchWorkgroups(Math.ceil(count / 64));
pass.end();
pingPong = 1 - pingPong;
},
render(encoder: GPUCommandEncoder, textureView: GPUTextureView, viewport: number[] | null) {
const dv = getDepthView(depthRef);
const aspect = viewport ? (viewport[2] / viewport[3]) : (canvas.width / canvas.height);
device.queue.writeBuffer(cameraBuffer, 0, getCameraUniformData(aspect));
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.02, g: 0.02, b: 0.025, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: dv,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
});
if (viewport) {
pass.setViewport(viewport[0], viewport[1], viewport[2], viewport[3], 0, 1);
}
renderGrid(pass, aspect);
pass.setPipeline(renderPipeline);
pass.setBindGroup(0, renderBG[pingPong]);
pass.draw(3, count);
pass.end();
},
getCount() { return count; },
destroy() {
bufferA.destroy(); bufferB.destroy();
paramsBuffer.destroy(); cameraBuffer.destroy();
if (depthRef.tex) depthRef.tex.destroy();
}
};
}
// --- 5b: N-BODY PHYSICS ---
function createPhysicsSimulation() {
const count = state.physics.count;
const bodyBytes = count * 32; // pos(12) + mass(4) + vel(12) + pad(4) = 32
const initData = new Float32Array(count * 8);
const dist = state.physics.distribution;
for (let i = 0; i < count; i++) {
const off = i * 8;
let x, y, z, vx = 0, vy = 0, vz = 0;
if (dist === 'disk') {
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * 2;
x = Math.cos(angle) * r;
y = (Math.random() - 0.5) * 0.1;
z = Math.sin(angle) * r;
// Orbital velocity
const speed = 0.5 / Math.sqrt(r + 0.1);
vx = -Math.sin(angle) * speed;
vz = Math.cos(angle) * speed;
} else if (dist === 'shell') {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 1.5 + Math.random() * 0.1;
x = r * Math.sin(phi) * Math.cos(theta);
y = r * Math.sin(phi) * Math.sin(theta);
z = r * Math.cos(phi);
} else {
x = (Math.random() - 0.5) * 4;
y = (Math.random() - 0.5) * 4;
z = (Math.random() - 0.5) * 4;
}
initData[off] = x; initData[off + 1] = y; initData[off + 2] = z;
initData[off + 3] = 0.5 + Math.random() * 2.0; // mass
initData[off + 4] = vx; initData[off + 5] = vy; initData[off + 6] = vz;
}
const bufferA = device.createBuffer({ size: bodyBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Float32Array(bufferA.getMappedRange()).set(initData);
bufferA.unmap();
const bufferB = device.createBuffer({ size: bodyBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const paramsBuffer = device.createBuffer({ size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // dt, G, soft, damp, count, pad*3, attractor*4
const cameraBuffer = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const computeModule = device.createShaderModule({ code: SHADER_NBODY_COMPUTE_EDIT || SHADER_NBODY_COMPUTE });
const renderModule = device.createShaderModule({ code: SHADER_NBODY_RENDER_EDIT || SHADER_NBODY_RENDER });
const computeBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeBGL] }),
compute: { module: computeModule, entryPoint: 'main' }
});
// Attractor uniform for render shader (x, y, z, active = 16 bytes)
const attractorBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const renderBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
]
});
const renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [renderBGL] }),
vertex: { module: renderModule, entryPoint: 'vs_main' },
fragment: {
module: renderModule, entryPoint: 'fs_main',
targets: [{
format: canvasFormat,
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
}
}]
},
primitive: { topology: 'triangle-list' },
});
const computeBG = [
device.createBindGroup({ layout: computeBGL, entries: [
{ binding: 0, resource: { buffer: bufferA } },
{ binding: 1, resource: { buffer: bufferB } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
device.createBindGroup({ layout: computeBGL, entries: [
{ binding: 0, resource: { buffer: bufferB } },
{ binding: 1, resource: { buffer: bufferA } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
];
const renderBG = [
device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: bufferA } },
{ binding: 1, resource: { buffer: cameraBuffer } },
{ binding: 2, resource: { buffer: attractorBuffer } },
]}),
device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: bufferB } },
{ binding: 1, resource: { buffer: cameraBuffer } },
{ binding: 2, resource: { buffer: attractorBuffer } },
]}),
];
let pingPong = 0;
const depthRef: DepthRef = {};
return {
compute(encoder: GPUCommandEncoder) {
const p = state.physics;
const m = state.mouse;
const paramsData = new ArrayBuffer(48);
const f32 = new Float32Array(paramsData);
const u32 = new Uint32Array(paramsData);
f32[0] = 0.016; f32[1] = p.G * 0.001; f32[2] = p.softening; f32[3] = p.damping;
u32[4] = count;
f32[8] = m.worldX; f32[9] = m.worldY; f32[10] = m.worldZ;
f32[11] = m.down ? 1.0 : 0.0;
device.queue.writeBuffer(paramsBuffer, 0, new Uint8Array(paramsData));
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, computeBG[pingPong]);
pass.dispatchWorkgroups(Math.ceil(count / 64));
pass.end();
pingPong = 1 - pingPong;
},
render(encoder: GPUCommandEncoder, textureView: GPUTextureView, viewport: number[] | null) {
const dv = getDepthView(depthRef);
const aspect = viewport ? (viewport[2] / viewport[3]) : (canvas.width / canvas.height);
const m = state.mouse;
device.queue.writeBuffer(cameraBuffer, 0, getCameraUniformData(aspect));
device.queue.writeBuffer(attractorBuffer, 0, new Float32Array([
m.worldX, m.worldY, m.worldZ, m.down ? 1.0 : 0.0
]));
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.02, g: 0.02, b: 0.025, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: dv,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
});
if (viewport) {
pass.setViewport(viewport[0], viewport[1], viewport[2], viewport[3], 0, 1);
}
renderGrid(pass, aspect);
pass.setPipeline(renderPipeline);
pass.setBindGroup(0, renderBG[pingPong]);
pass.draw(6, count);
pass.end();
},
getCount() { return count; },
destroy() {
bufferA.destroy(); bufferB.destroy();
paramsBuffer.destroy(); cameraBuffer.destroy(); attractorBuffer.destroy();
}
};
}
// --- 5c: FLUID DYNAMICS ---
function createFluidSimulation() {
const res = state.fluid.resolution;
const cellCount = res * res;
const velBytes = cellCount * 8; // vec2f per cell
const scalarBytes = cellCount * 4; // f32 per cell
const dyeBytes = cellCount * 16; // vec4f per cell
// All buffers get COPY_SRC so we can copy results back to canonical A buffers
const BUF = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
const velA = device.createBuffer({ size: velBytes, usage: BUF });
const velB = device.createBuffer({ size: velBytes, usage: BUF });
const pressA = device.createBuffer({ size: scalarBytes, usage: BUF });
const pressB = device.createBuffer({ size: scalarBytes, usage: BUF });
const divergenceBuf = device.createBuffer({ size: scalarBytes, usage: BUF });
const dyeA = device.createBuffer({ size: dyeBytes, usage: BUF });
const dyeB = device.createBuffer({ size: dyeBytes, usage: BUF });
const paramsBuffer = device.createBuffer({ size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const cameraBuffer = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
// Seed initial dye with theme-colored splats
const initDye = new Float32Array(cellCount * 4);
const tc = getThemeColors();
const splats = [
{ x: 0.3, y: 0.3, r: tc.primary[0], g: tc.primary[1], b: tc.primary[2] },
{ x: 0.7, y: 0.7, r: tc.secondary[0], g: tc.secondary[1], b: tc.secondary[2] },
{ x: 0.5, y: 0.5, r: tc.accent[0], g: tc.accent[1], b: tc.accent[2] },
{ x: 0.2, y: 0.7, r: tc.primary[0] * 0.8, g: tc.accent[1], b: tc.secondary[2] },
{ x: 0.8, y: 0.3, r: tc.accent[0], g: tc.primary[1], b: tc.secondary[2] },
];
// Also seed initial velocity for motion
const initVel = new Float32Array(cellCount * 2);
for (let y = 0; y < res; y++) {
for (let x = 0; x < res; x++) {
const i = y * res + x;
const fx = x / res, fy = y / res;
for (const s of splats) {
const dx = fx - s.x, dy = fy - s.y;
const d2 = dx * dx + dy * dy;
const splat = Math.exp(-d2 / (2 * 0.02));
initDye[i * 4] += s.r * splat;
initDye[i * 4 + 1] += s.g * splat;
initDye[i * 4 + 2] += s.b * splat;
initDye[i * 4 + 3] += splat;
}
// Swirl velocity
const cx = fx - 0.5, cy = fy - 0.5;
initVel[i * 2] = -cy * 3.0;
initVel[i * 2 + 1] = cx * 3.0;
}
}
device.queue.writeBuffer(dyeA, 0, initDye);
device.queue.writeBuffer(velA, 0, initVel);
// Compile shaders
const forcesAdvectModule = device.createShaderModule({ code: SHADER_FLUID_FORCES_ADVECT_EDIT || SHADER_FLUID_FORCES_ADVECT });
const diffuseModule = device.createShaderModule({ code: SHADER_FLUID_DIFFUSE_EDIT || SHADER_FLUID_DIFFUSE });
const pressureModule = device.createShaderModule({ code: SHADER_FLUID_PRESSURE_EDIT || SHADER_FLUID_PRESSURE });
const divergenceModule = device.createShaderModule({ code: SHADER_FLUID_DIVERGENCE_EDIT || SHADER_FLUID_DIVERGENCE });
const gradientModule = device.createShaderModule({ code: SHADER_FLUID_GRADIENT_EDIT || SHADER_FLUID_GRADIENT });
const renderModule = device.createShaderModule({ code: SHADER_FLUID_RENDER_EDIT || SHADER_FLUID_RENDER });
// Forces + Advect pipeline: reads vel+dye from A, writes to B
const faBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const faPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [faBGL] }),
compute: { module: forcesAdvectModule, entryPoint: 'main' }
});
// Always reads A → writes B
const faBG = device.createBindGroup({ layout: faBGL, entries: [
{ binding: 0, resource: { buffer: velA } }, { binding: 1, resource: { buffer: velB } },
{ binding: 2, resource: { buffer: dyeA } }, { binding: 3, resource: { buffer: dyeB } },
{ binding: 4, resource: { buffer: paramsBuffer } },
]});
// Diffuse pipeline: ping-pong velocity
const diffBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const diffPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [diffBGL] }),
compute: { module: diffuseModule, entryPoint: 'main' }
});
const diffBGs = [
device.createBindGroup({ layout: diffBGL, entries: [
{ binding: 0, resource: { buffer: velA } }, { binding: 1, resource: { buffer: velB } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
device.createBindGroup({ layout: diffBGL, entries: [
{ binding: 0, resource: { buffer: velB } }, { binding: 1, resource: { buffer: velA } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]}),
];
// Divergence pipeline: reads vel, writes divergence
const divBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const divPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [divBGL] }),
compute: { module: divergenceModule, entryPoint: 'main' }
});
// Always reads velA (we copy back to A before this step)
const divBG = device.createBindGroup({ layout: divBGL, entries: [
{ binding: 0, resource: { buffer: velA } },
{ binding: 1, resource: { buffer: divergenceBuf } },
{ binding: 2, resource: { buffer: paramsBuffer } },
]});
// Pressure pipeline: ping-pong pressure
const pressBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const pressPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [pressBGL] }),
compute: { module: pressureModule, entryPoint: 'main' }
});
const pressBGs = [
device.createBindGroup({ layout: pressBGL, entries: [
{ binding: 0, resource: { buffer: pressA } }, { binding: 1, resource: { buffer: pressB } },
{ binding: 2, resource: { buffer: divergenceBuf } }, { binding: 3, resource: { buffer: paramsBuffer } },
]}),
device.createBindGroup({ layout: pressBGL, entries: [
{ binding: 0, resource: { buffer: pressB } }, { binding: 1, resource: { buffer: pressA } },
{ binding: 2, resource: { buffer: divergenceBuf } }, { binding: 3, resource: { buffer: paramsBuffer } },
]}),
];
// Gradient subtract pipeline: reads vel + pressure, writes corrected vel
const gradBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const gradPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [gradBGL] }),
compute: { module: gradientModule, entryPoint: 'main' }
});
// Always reads velA + pressA → writes velB (we copy back to A before divergence,
// and copy pressure back to A after pressure solve)
const gradBG = device.createBindGroup({ layout: gradBGL, entries: [
{ binding: 0, resource: { buffer: velA } }, { binding: 1, resource: { buffer: velB } },
{ binding: 2, resource: { buffer: pressA } }, { binding: 3, resource: { buffer: paramsBuffer } },
]});
// Render pipeline — tessellated grid with height displacement
const fluidRenderParamsBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(fluidRenderParamsBuffer, 0, new Float32Array([res, FLUID_GRID_RES, 1.5, 0]));
const renderBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
]
});
const renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [renderBGL] }),
vertex: { module: renderModule, entryPoint: 'vs_main' },
fragment: {
module: renderModule, entryPoint: 'fs_main',
targets: [{ format: canvasFormat }]
},
primitive: { topology: 'triangle-list' },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
});
const renderBG = device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: dyeA } },
{ binding: 1, resource: { buffer: fluidRenderParamsBuffer } },
{ binding: 2, resource: { buffer: cameraBuffer } },
]});
const workgroups = Math.ceil(res / 8);
const depthRef: DepthRef = {};
// Buffer management strategy: always keep canonical data in A buffers.
// Each stage reads A → writes B, then we copy B → A.
// For Jacobi iterations, we ping-pong and copy final result back to A.
// This costs some copy bandwidth but makes bind group management trivial.
return {
compute(encoder: GPUCommandEncoder) {
const p = state.fluid;
const dyeModeNum = p.dyeMode === 'rainbow' ? 0 : p.dyeMode === 'single' ? 1 : 2;
const paramsData = new Float32Array([
0.5, p.viscosity, p.diffusionRate, p.forceStrength,
res, state.mouse.x, state.mouse.y, state.mouse.dx,
state.mouse.dy, state.mouse.down ? 1.0 : 0.0, dyeModeNum, 0
]);
device.queue.writeBuffer(paramsBuffer, 0, paramsData);
// 1. Forces + advect: velA/dyeA → velB/dyeB
{
const pass = encoder.beginComputePass();
pass.setPipeline(faPipeline);
pass.setBindGroup(0, faBG);
pass.dispatchWorkgroups(workgroups, workgroups);
pass.end();
}
// Copy results back to A
encoder.copyBufferToBuffer(velB, 0, velA, 0, velBytes);
encoder.copyBufferToBuffer(dyeB, 0, dyeA, 0, dyeBytes);
// 2. Diffuse velocity (Jacobi iterations, ping-pong A↔B)
// After even iterations: last write is to A. After odd: last write is to B.
let velPong = 0; // 0 = A is current
for (let i = 0; i < p.jacobiIterations; i++) {
const pass = encoder.beginComputePass();
pass.setPipeline(diffPipeline);
pass.setBindGroup(0, diffBGs[velPong]);
pass.dispatchWorkgroups(workgroups, workgroups);
pass.end();
velPong = 1 - velPong;
}
// Ensure result is in A
if (velPong === 1) {
encoder.copyBufferToBuffer(velB, 0, velA, 0, velBytes);
}
// 3. Compute divergence from velA
{
const pass = encoder.beginComputePass();
pass.setPipeline(divPipeline);
pass.setBindGroup(0, divBG);
pass.dispatchWorkgroups(workgroups, workgroups);
pass.end();
}
// 4. Pressure solve (Jacobi iterations, ping-pong A↔B)
let pressPong = 0;
for (let i = 0; i < p.jacobiIterations; i++) {
const pass = encoder.beginComputePass();
pass.setPipeline(pressPipeline);
pass.setBindGroup(0, pressBGs[pressPong]);
pass.dispatchWorkgroups(workgroups, workgroups);
pass.end();
pressPong = 1 - pressPong;
}
// Ensure result is in A
if (pressPong === 1) {
encoder.copyBufferToBuffer(pressB, 0, pressA, 0, scalarBytes);
}
// 5. Gradient subtract: velA + pressA → velB, then copy to velA
{
const pass = encoder.beginComputePass();
pass.setPipeline(gradPipeline);
pass.setBindGroup(0, gradBG);
pass.dispatchWorkgroups(workgroups, workgroups);
pass.end();
}
encoder.copyBufferToBuffer(velB, 0, velA, 0, velBytes);
// Canonical data is now in A buffers for rendering
},
render(encoder: GPUCommandEncoder, textureView: GPUTextureView, viewport: number[] | null) {
const dv = getDepthView(depthRef);
const aspect = viewport ? (viewport[2] / viewport[3]) : (canvas.width / canvas.height);
device.queue.writeBuffer(cameraBuffer, 0, getCameraUniformData(aspect));
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.02, g: 0.02, b: 0.025, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: dv,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
});
if (viewport) {
pass.setViewport(viewport[0], viewport[1], viewport[2], viewport[3], 0, 1);
}
renderGrid(pass, aspect);
pass.setPipeline(renderPipeline);
pass.setBindGroup(0, renderBG);
pass.draw(6, FLUID_GRID_RES * FLUID_GRID_RES);
pass.end();
},
getCount() { return res + 'x' + res; },
destroy() {
velA.destroy(); velB.destroy();
pressA.destroy(); pressB.destroy();
divergenceBuf.destroy();
dyeA.destroy(); dyeB.destroy();
paramsBuffer.destroy(); fluidRenderParamsBuffer.destroy();
cameraBuffer.destroy();
if (depthRef.tex) depthRef.tex.destroy();
}
};
}
// --- 5d: PARAMETRIC SHAPES ---
function createParametricSimulation() {
// Pre-allocate at max resolution so uRes/vRes changes don't need buffer recreation
const MAX_RES = 256;
const maxVertices = MAX_RES * MAX_RES;
const vertexBytes = maxVertices * 32; // vec3f pos (12) + pad(4) + vec3f normal (12) + pad(4)
const maxIndices = (MAX_RES - 1) * (MAX_RES - 1) * 6;
const vertexBuffer = device.createBuffer({ size: vertexBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX });
const indexBuffer = device.createBuffer({ size: maxIndices * 4, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
// Params: uRes, vRes, scale, twist, time, shapeId, p1-p4, pokeX/Y/Z, pokeActive = 14 values → 64 bytes
const computeParamsBuffer = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const cameraBuffer = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const modelBuffer = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
let time = 0;
let lastURes = 0, lastVRes = 0;
let currentIndexCount = 0;
// Regenerate index buffer on CPU when resolution changes (cheap, no pipeline rebuild)
function updateIndexBuffer(uRes: number, vRes: number) {
if (uRes === lastURes && vRes === lastVRes) return;
lastURes = uRes; lastVRes = vRes;
currentIndexCount = (uRes - 1) * (vRes - 1) * 6;
const indices = new Uint32Array(currentIndexCount);
let idx = 0;
for (let vi = 0; vi < vRes - 1; vi++) {
for (let ui = 0; ui < uRes - 1; ui++) {
const tl = vi * uRes + ui;
const tr = vi * uRes + ui + 1;
const bl = (vi + 1) * uRes + ui;
const br = (vi + 1) * uRes + ui + 1;
indices[idx++] = tl; indices[idx++] = bl; indices[idx++] = tr;
indices[idx++] = tr; indices[idx++] = bl; indices[idx++] = br;
}
}
device.queue.writeBuffer(indexBuffer, 0, indices);
}
// Single compute pipeline (all shapes in one shader, selected by uniform)
const computeModule = device.createShaderModule({ code: SHADER_PARAMETRIC_COMPUTE_EDIT || SHADER_PARAMETRIC_COMPUTE });
const computeBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
]
});
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeBGL] }),
compute: { module: computeModule, entryPoint: 'main' }
});
const computeBG = device.createBindGroup({ layout: computeBGL, entries: [
{ binding: 0, resource: { buffer: vertexBuffer } },
{ binding: 1, resource: { buffer: computeParamsBuffer } },
]});
// Render pipeline
const renderModule = device.createShaderModule({ code: SHADER_PARAMETRIC_RENDER_EDIT || SHADER_PARAMETRIC_RENDER });
const renderBGL = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
]
});
const renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [renderBGL] }),
vertex: { module: renderModule, entryPoint: 'vs_main' },
fragment: {
module: renderModule, entryPoint: 'fs_main',
targets: [{ format: canvasFormat }]
},
primitive: { topology: 'triangle-list', cullMode: 'none' },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
});
const renderBG = device.createBindGroup({ layout: renderBGL, entries: [
{ binding: 0, resource: { buffer: vertexBuffer } },
{ binding: 1, resource: { buffer: cameraBuffer } },
{ binding: 2, resource: { buffer: modelBuffer } },
]});
const depthRef: DepthRef = {};
return {
compute(encoder: GPUCommandEncoder) {
const p = state.parametric;
time += 0.016 * p.rotationSpeed;
const uRes = p.uRes;
const vRes = p.vRes;
updateIndexBuffer(uRes, vRes);
const m = state.mouse;
const paramsData = new ArrayBuffer(64);
const u32 = new Uint32Array(paramsData);
const f32 = new Float32Array(paramsData);
u32[0] = uRes; u32[1] = vRes;
f32[2] = p.scale; f32[3] = p.twist; f32[4] = time;
u32[5] = SHAPE_IDS[p.shape] || 0;
f32[6] = p.p1; f32[7] = p.p2; f32[8] = p.p3; f32[9] = p.p4;
f32[10] = m.worldX; f32[11] = m.worldY; f32[12] = m.worldZ;
f32[13] = m.down ? 1.0 : 0.0;
device.queue.writeBuffer(computeParamsBuffer, 0, new Uint8Array(paramsData));
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, computeBG);
pass.dispatchWorkgroups(Math.ceil(uRes / 8), Math.ceil(vRes / 8));
pass.end();
},
render(encoder: GPUCommandEncoder, textureView: GPUTextureView, viewport: number[] | null) {
if (currentIndexCount === 0) return;
const dv = getDepthView(depthRef);
const aspect = viewport ? (viewport[2] / viewport[3]) : (canvas.width / canvas.height);
device.queue.writeBuffer(cameraBuffer, 0, getCameraUniformData(aspect));
const model = mat4.rotateX(mat4.rotateY(mat4.identity(), time), time * 0.3);
device.queue.writeBuffer(modelBuffer, 0, model as Float32Array<ArrayBuffer>);
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.02, g: 0.02, b: 0.025, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: dv,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
});
if (viewport) {
pass.setViewport(viewport[0], viewport[1], viewport[2], viewport[3], 0, 1);
}
renderGrid(pass, aspect);
pass.setPipeline(renderPipeline);
pass.setBindGroup(0, renderBG);
pass.setIndexBuffer(indexBuffer, 'uint32');
pass.drawIndexed(currentIndexCount);
pass.end();
},
getCount() { return `${state.parametric.uRes}x${state.parametric.vRes} (${state.parametric.shape})`; },
destroy() {
vertexBuffer.destroy(); indexBuffer.destroy();
computeParamsBuffer.destroy(); cameraBuffer.destroy(); modelBuffer.destroy();
if (depthRef.tex) depthRef.tex.destroy();
}
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 6: UI & CONTROLS
// ═══════════════════════════════════════════════════════════════════════════════
function buildControls() {
for (const [modeStr, sections] of Object.entries(PARAM_DEFS)) {
const mode = modeStr as SimMode;
const container = document.getElementById(`params-${mode}`)!;
const presetsDiv = document.createElement('div');
presetsDiv.className = 'presets';
for (const presetName of Object.keys(PRESETS[mode])) {
const btn = document.createElement('button');
btn.className = 'preset-btn' + (presetName === 'Default' ? ' active' : '');
btn.textContent = presetName;
btn.dataset.preset = presetName;
btn.dataset.mode = mode;
btn.addEventListener('click', () => applyPreset(mode, presetName));
presetsDiv.appendChild(btn);
}
container.appendChild(presetsDiv);
for (const section of sections) {
const secDiv = document.createElement('div');
secDiv.className = 'param-section';
const title = document.createElement('div');
title.className = 'param-section-title';
title.textContent = section.section;
secDiv.appendChild(title);
// Dynamic sections (shape params) get populated later
if (section.dynamic) {
secDiv.id = section.id ?? '';
container.appendChild(secDiv);
continue;
}
for (const param of section.params) {
buildParamRow(secDiv, mode, param);
}
container.appendChild(secDiv);
}
}
}
function buildParamRow(container: HTMLElement, mode: SimMode, param: ParamDef) {
const row = document.createElement('div');
row.className = 'control-row';
const label = document.createElement('span');
label.className = 'control-label';
label.textContent = param.label;
row.appendChild(label);
if (param.type === 'dropdown') {
const select = document.createElement('select');
select.dataset.mode = mode;
select.dataset.key = param.key;
for (const opt of param.options ?? []) {
const option = document.createElement('option');
option.value = String(opt);
option.textContent = String(opt);
select.appendChild(option);
}
select.value = String(modeParams(mode)[param.key]);
select.addEventListener('change', () => {
const val = Number.isNaN(Number(select.value)) ? select.value : Number(select.value);
modeParams(mode)[param.key] = val;
if (param.requiresReset) resetCurrentSim();
// When shape changes, set default shape params and rebuild UI
if (param.key === 'shape') {
applyShapeDefaults(String(val));
rebuildShapeParams();
}
updateAll();
});
row.appendChild(select);
} else {
const input = document.createElement('input');
input.type = 'range';
input.min = String(param.min);
input.max = String(param.max);
input.step = String(param.step);
input.value = String(modeParams(mode)[param.key]);
input.dataset.mode = mode;
input.dataset.key = param.key;
const valueSpan = document.createElement('span');
valueSpan.className = 'control-value';
valueSpan.textContent = formatValue(Number(modeParams(mode)[param.key]), param.step ?? 1);
input.addEventListener('input', () => {
const val = Number(input.value);
modeParams(mode)[param.key] = val;
valueSpan.textContent = formatValue(val, param.step ?? 1);
if (param.requiresReset) {
input.dataset.needsReset = '1';
}
updateAll();
});
input.addEventListener('change', () => {
if (input.dataset.needsReset === '1') {
input.dataset.needsReset = '0';
resetCurrentSim();
}
});
row.appendChild(input);
row.appendChild(valueSpan);
}
container.appendChild(row);
return row;
}
// Set shape-specific param defaults when switching shapes
function applyShapeDefaults(shape: string) {
const sp = SHAPE_PARAMS[shape as ShapeName] ?? {};
state.parametric.p1 = sp.p1 ? sp.p1.val : 0;
state.parametric.p2 = sp.p2 ? sp.p2.val : 0;
state.parametric.p3 = sp.p3 ? sp.p3.val : 0;
state.parametric.p4 = sp.p4 ? sp.p4.val : 0;
}
// Rebuild the dynamic "Shape Parameters" section based on current shape
function rebuildShapeParams() {
const container = document.getElementById('shape-params-section');
if (!container) return;
// Remove all rows but keep the title
while (container.children.length > 1) {
container.removeChild(container.lastChild!);
}
const shape = state.parametric.shape;
const sp = SHAPE_PARAMS[shape] ?? {};
for (const [key, def] of Object.entries(sp)) {
buildParamRow(container, 'parametric', {
key, label: def.label, min: def.min, max: def.max, step: def.step
});
}
}
function formatValue(val: number, step: number) {
if (step >= 1) return String(Math.round(val));
const decimals = Math.max(0, -Math.floor(Math.log10(step)));
return val.toFixed(decimals);
}
function applyPreset(mode: SimMode, presetName: string) {
const preset = PRESETS[mode][presetName];
Object.assign(modeParams(mode), preset);
// Update all sliders/dropdowns for this mode
const container = document.getElementById(`params-${mode}`)!;
container.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach(input => {
const key = input.dataset.key!;
if (key in preset) {
input.value = String(preset[key]);
const valueSpan = input.parentElement?.querySelector('.control-value');
if (valueSpan) {
const paramDef = findParamDef(mode, key);
valueSpan.textContent = formatValue(Number(preset[key]), paramDef ? paramDef.step ?? 1 : 1);
}
}
});
container.querySelectorAll<HTMLSelectElement>('select').forEach(sel => {
const key = sel.dataset.key!;
if (key in preset) sel.value = String(preset[key]);
});
// Highlight active preset button
container.querySelectorAll<HTMLButtonElement>('.preset-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.preset === presetName);
});
// Rebuild dynamic shape params when parametric preset changes shape
if (mode === 'parametric') {
rebuildShapeParams();
}
resetCurrentSim();
updateAll();
}
function findParamDef(mode: SimMode, key: string): ParamDef | null {
for (const section of PARAM_DEFS[mode]) {
for (const param of section.params) {
if (param.key === key) return param;
}
}
return null;
}
function setupTabs() {
document.querySelectorAll<HTMLElement>('.mode-tab').forEach(tab => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode as SimMode;
state.mode = mode;
document.querySelectorAll<HTMLElement>('.mode-tab').forEach(t => t.classList.toggle('active', t === tab));
document.querySelectorAll<HTMLElement>('.param-group').forEach(g => g.classList.toggle('active', g.dataset.mode === mode));
ensureSimulation();
updateAll();
});
});
}
function setupGlobalControls() {
document.getElementById('btn-pause')!.addEventListener('click', () => {
state.paused = !state.paused;
document.getElementById('btn-pause')!.textContent = state.paused ? 'Resume' : 'Pause';
document.getElementById('btn-pause')!.classList.toggle('active', state.paused);
});
document.getElementById('btn-reset')!.addEventListener('click', () => {
resetCurrentSim();
});
// Copy prompt
document.getElementById('copy-btn')!.addEventListener('click', () => {
const text = document.getElementById('prompt-text')!.textContent ?? '';
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn')!;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
});
// Reset All — clear localStorage and reload
document.getElementById('btn-reset-all')!.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
location.reload();
});
// XR button setup
setupXRButton();
}
function buildThemeSelector() {
const container = document.getElementById('theme-presets')!;
for (const name of Object.keys(COLOR_THEMES)) {
const theme = COLOR_THEMES[name];
const btn = document.createElement('button');
btn.className = 'preset-btn' + (name === state.colorTheme ? ' active' : '');
btn.textContent = name;
btn.dataset.theme = name;
// Color swatch hint
btn.style.borderLeftWidth = '3px';
btn.style.borderLeftColor = theme.primary;
btn.addEventListener('click', () => {
state.colorTheme = name;
container.querySelectorAll<HTMLButtonElement>('.preset-btn').forEach(b =>
b.classList.toggle('active', b.dataset.theme === name));
// Reset all simulations to pick up new colors
for (const mode of Object.keys(simulations) as SimMode[]) {
if (simulations[mode]) { simulations[mode]!.destroy(); simulations[mode] = undefined; }
}
ensureSimulation();
updateAll();
});
container.appendChild(btn);
}
}
// Compute camera eye position and basis vectors from orbit state
function getCameraBasis() {
const cam = state.camera;
const cosRx = Math.cos(cam.rotX), sinRx = Math.sin(cam.rotX);
const cosRy = Math.cos(cam.rotY), sinRy = Math.sin(cam.rotY);
const eye = [cam.distance * cosRx * sinRy, cam.distance * sinRx, cam.distance * cosRx * cosRy];
const forward = normalize3(sub3([0, 0, 0], eye));
const worldUp = [0, 1, 0];
const right = normalize3(cross3(forward, worldUp));
const up = cross3(right, forward);
return { eye, forward, right, up };
}
// Build a ray from screen coords (0-1) through the camera
function screenRay(mx: number, my: number) {
const cam = state.camera;
const fovRad = cam.fov * Math.PI / 180;
const aspect = canvas.width / canvas.height;
const { eye, forward, right, up } = getCameraBasis();
const halfFov = Math.tan(fovRad * 0.5);
const ndcX = (mx * 2 - 1) * halfFov * aspect;
const ndcY = (my * 2 - 1) * halfFov;
const dir = normalize3([
forward[0] + right[0] * ndcX + up[0] * ndcY,
forward[1] + right[1] * ndcX + up[1] * ndcY,
forward[2] + right[2] * ndcX + up[2] * ndcY,
]);
return { eye, dir };
}
// Unproject screen coords to a world-space point on a plane through the origin,
// perpendicular to the view direction.
function screenToWorld(mx: number, my: number) {
const { dir } = screenRay(mx, my);
// Intersect with a plane at origin perpendicular to the view
const spread = state.camera.distance * 0.5;
return [dir[0] * spread, dir[1] * spread, dir[2] * spread];
}
// Unproject screen coords onto the fluid plane (y=0, x/z from -2 to 2).
// Returns [u, v] in 0-1 range, or null if ray misses.
function screenToFluidUV(mx: number, my: number) {
const { eye, dir } = screenRay(mx, my);
if (Math.abs(dir[1]) < 0.0001) return null;
const t = -eye[1] / dir[1];
if (t < 0) return null;
const hitX = eye[0] + dir[0] * t;
const hitZ = eye[2] + dir[2] * t;
// Fluid plane goes from (-2, 0, -2) to (2, 0, 2) → UV 0-1
return [
Math.max(0, Math.min(1, (hitX + 2) / 4)),
Math.max(0, Math.min(1, (hitZ + 2) / 4)),
];
}
function setupMouseControls() {
const c = canvas;
let dragging = false;
let interacting = false; // ctrl/meta held = sim interaction mode
c.addEventListener('pointerdown', (e) => {
dragging = true;
interacting = e.ctrlKey || e.metaKey;
const rect = c.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = 1.0 - (e.clientY - rect.top) / rect.height;
state.mouse.dx = 0;
state.mouse.dy = 0;
if (interacting) {
state.mouse.down = true;
const wp = screenToWorld(mx, my);
state.mouse.worldX = wp[0];
state.mouse.worldY = wp[1];
state.mouse.worldZ = wp[2];
// Set initial position in correct coord system for fluid
if (state.mode === 'fluid') {
const uv = screenToFluidUV(mx, my);
if (uv) { state.mouse.x = uv[0]; state.mouse.y = uv[1]; }
} else {
state.mouse.x = mx; state.mouse.y = my;
}
} else {
state.mouse.x = mx; state.mouse.y = my;
}
e.preventDefault();
});
c.addEventListener('pointermove', (e) => {
if (!dragging) return;
const rect = c.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = 1.0 - (e.clientY - rect.top) / rect.height;
// Re-check modifier keys mid-drag
const interact = interacting || e.ctrlKey || e.metaKey;
if (interact) {
// Sim interaction (ctrl+drag)
state.mouse.down = true;
const wp = screenToWorld(mx, my);
state.mouse.worldX = wp[0];
state.mouse.worldY = wp[1];
state.mouse.worldZ = wp[2];
// For fluid: ray-cast onto y=0 plane for camera-correct coordinates
if (state.mode === 'fluid') {
const uv = screenToFluidUV(mx, my);
if (uv) {
state.mouse.dx = (uv[0] - state.mouse.x) * 10;
state.mouse.dy = (uv[1] - state.mouse.y) * 10;
state.mouse.x = uv[0];
state.mouse.y = uv[1];
}
} else {
state.mouse.dx = (mx - state.mouse.x) * 10;
state.mouse.dy = (my - state.mouse.y) * 10;
state.mouse.x = mx;
state.mouse.y = my;
}
} else {
// Orbit camera (plain drag — all modes)
state.camera.rotY += e.movementX * 0.005;
state.camera.rotX += e.movementY * 0.005;
state.camera.rotX = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45, state.camera.rotX));
state.mouse.down = false;
}
});
c.addEventListener('pointerup', () => {
dragging = false;
interacting = false;
state.mouse.down = false;
state.mouse.dx = 0;
state.mouse.dy = 0;
});
c.addEventListener('contextmenu', (e) => e.preventDefault());
c.addEventListener('wheel', (e) => {
state.camera.distance *= (1 + e.deltaY * 0.001);
state.camera.distance = Math.max(0.5, Math.min(50, state.camera.distance));
e.preventDefault();
}, { passive: false });
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 7: PROMPT GENERATOR
// ═══════════════════════════════════════════════════════════════════════════════
const MODE_LABELS = {
boids: 'boids/flocking',
physics: 'N-body gravitational',
fluid: 'fluid dynamics',
parametric: 'parametric shape',
};
function updatePrompt() {
const mode = state.mode;
const params = modeParams(mode);
const defaultParams = DEFAULTS[mode] as unknown as Record<string, number | string>;
const parts: (string | null)[] = [];
for (const [key, val] of Object.entries(params)) {
if (val !== defaultParams[key]) {
parts.push(describeParam(mode, key, val));
}
}
let prompt = `WebGPU ${MODE_LABELS[mode]} simulation`;
if (state.colorTheme !== 'Dracula') {
prompt += ` (${state.colorTheme} theme)`;
}
if (parts.length > 0) {
prompt += ` with ${parts.filter(Boolean).join(', ')}`;
}
prompt += '.';
document.getElementById('prompt-text')!.textContent = prompt;
}
function describeParam(_mode: string, key: string, val: number | string): string | null {
const n = Number(val);
const descriptions: Record<string, () => string | null> = {
count: () => `${val} particles`,
separationRadius: () => n < 15 ? `tight separation (${val})` : n > 50 ? `wide separation (${val})` : `separation radius ${val}`,
alignmentRadius: () => `alignment range ${val}`,
cohesionRadius: () => n > 80 ? `strong cohesion (${val})` : `cohesion range ${val}`,
maxSpeed: () => n > 4 ? `high speed (${val})` : n < 1 ? `slow movement (${val})` : `speed ${val}`,
maxForce: () => n > 0.1 ? `strong steering (${val})` : `steering force ${val}`,
visualRange: () => `visual range ${val}`,
G: () => n > 5 ? `strong gravity (G=${val})` : n < 0.5 ? `weak gravity (G=${val})` : `G=${val}`,
softening: () => `softening ${val}`,
damping: () => n < 0.995 ? `high damping (${val})` : `damping ${val}`,
distribution: () => `${val} distribution`,
resolution: () => `${val}x${val} grid`,
viscosity: () => n > 0.5 ? `thick fluid (viscosity ${val})` : n < 0.05 ? `thin fluid (viscosity ${val})` : `viscosity ${val}`,
diffusionRate: () => `diffusion ${val}`,
forceStrength: () => n > 200 ? `strong forces (${val})` : `force strength ${val}`,
dyeMode: () => `${val} dye`,
jacobiIterations: () => `${val} solver iterations`,
shape: () => `${val} shape`,
uRes: () => `${val} U segments`,
vRes: () => `${val} V segments`,
scale: () => `scale ${val}`,
twist: () => n > 0 ? `twist ${n.toFixed(2)} rad` : null,
rotationSpeed: () => n > 2 ? `fast rotation (${val})` : n === 0 ? `no rotation` : `rotation speed ${val}`,
};
const fn = descriptions[key] as (() => string | null) | undefined;
return fn ? fn() : `${key}: ${val}`;
}
function updateAll() {
updatePrompt();
updateStats();
updateShaderPanel();
saveState();
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 7b: SHADER DEBUG PANEL
// ═══════════════════════════════════════════════════════════════════════════════
// Maps simulation mode → named shader sources
function getShaderSources(mode: SimMode): Record<string, string> {
const sources = {
boids: {
'Compute (Flocking)': SHADER_BOIDS_COMPUTE,
'Render (Vert+Frag)': SHADER_BOIDS_RENDER,
},
physics: {
'Compute (Gravity)': SHADER_NBODY_COMPUTE,
'Render (Vert+Frag)': SHADER_NBODY_RENDER,
},
fluid: {
'Forces + Advect': SHADER_FLUID_FORCES_ADVECT,
'Diffuse': SHADER_FLUID_DIFFUSE,
'Divergence': SHADER_FLUID_DIVERGENCE,
'Pressure Solve': SHADER_FLUID_PRESSURE,
'Gradient Sub': SHADER_FLUID_GRADIENT,
'Render': SHADER_FLUID_RENDER,
},
parametric: {
'Compute (All Shapes)': SHADER_PARAMETRIC_COMPUTE,
'Render (Phong)': SHADER_PARAMETRIC_RENDER,
},
};
return sources[mode] || {};
}
let shaderPanelOpen = false;
let activeShaderTab: string | null = null;
let currentShaderSources: Record<string, string> = {};
let originalShaderSources: Record<string, string> = {};
function setupShaderPanel() {
const toggle = document.getElementById('shader-toggle')!;
const panel = document.getElementById('shader-panel')!;
toggle.addEventListener('click', () => {
shaderPanelOpen = !shaderPanelOpen;
panel.classList.toggle('open', shaderPanelOpen);
toggle.classList.toggle('active', shaderPanelOpen);
if (shaderPanelOpen) refreshShaderTabs();
});
document.getElementById('shader-compile')!.addEventListener('click', compileEditedShader);
document.getElementById('shader-reset')!.addEventListener('click', resetEditedShader);
// Tab key inserts spaces in editor instead of moving focus
document.getElementById('shader-editor')!.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const ta = e.target as HTMLTextAreaElement;
const start = ta.selectionStart;
ta.value = ta.value.substring(0, start) + ' ' + ta.value.substring(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + 2;
}
});
}
function refreshShaderTabs() {
const sources = getShaderSources(state.mode);
originalShaderSources = { ...sources };
// Preserve edits if mode hasn't changed
if (!currentShaderSources._mode || currentShaderSources._mode !== state.mode) {
currentShaderSources = { ...sources, _mode: state.mode };
}
const tabsEl = document.getElementById('shader-tabs')!;
tabsEl.innerHTML = '';
const names = Object.keys(sources);
activeShaderTab = activeShaderTab && names.includes(activeShaderTab) ? activeShaderTab : names[0];
for (const name of names) {
const tab = document.createElement('button');
tab.className = 'shader-tab' + (name === activeShaderTab ? ' active' : '');
tab.textContent = name;
tab.addEventListener('click', () => {
// Save current editor content before switching
saveEditorContent();
activeShaderTab = name;
tabsEl.querySelectorAll('.shader-tab').forEach(t => t.classList.toggle('active', t.textContent === name));
loadEditorContent();
});
tabsEl.appendChild(tab);
}
loadEditorContent();
}
function saveEditorContent() {
if (activeShaderTab) {
currentShaderSources[activeShaderTab] = (document.getElementById('shader-editor') as HTMLTextAreaElement).value;
}
}
function loadEditorContent() {
const editor = document.getElementById('shader-editor') as HTMLTextAreaElement;
editor.value = currentShaderSources[activeShaderTab!] || '';
document.getElementById('shader-status')!.textContent = '';
document.getElementById('shader-status')!.className = 'shader-success';
}
function updateShaderPanel() {
if (shaderPanelOpen) {
// Re-check if mode changed
if (currentShaderSources._mode !== state.mode) {
refreshShaderTabs();
}
}
}
function compileEditedShader() {
saveEditorContent();
const code = currentShaderSources[activeShaderTab!];
const statusEl = document.getElementById('shader-status')!;
// Attempt to create a shader module to validate
try {
const module = device.createShaderModule({ code });
// Check for compilation errors via getCompilationInfo
module.getCompilationInfo().then(info => {
const errors = info.messages.filter(m => m.type === 'error');
if (errors.length > 0) {
statusEl.className = 'shader-error';
statusEl.textContent = errors.map(e => `Line ${e.lineNum}: ${e.message}`).join('; ');
statusEl.title = errors.map(e => `Line ${e.lineNum}: ${e.message}`).join('\n');
} else {
statusEl.className = 'shader-success';
statusEl.textContent = 'Compiled OK — reset simulation to apply';
statusEl.title = '';
// Update the global shader source so next init uses it
applyShaderEdit(state.mode, activeShaderTab!, code);
}
});
} catch (e) {
statusEl.className = 'shader-error';
statusEl.textContent = (e as Error).message;
statusEl.title = (e as Error).message;
}
}
function resetEditedShader() {
if (activeShaderTab && originalShaderSources[activeShaderTab]) {
currentShaderSources[activeShaderTab] = originalShaderSources[activeShaderTab];
loadEditorContent();
// Also revert the global source
applyShaderEdit(state.mode, activeShaderTab, originalShaderSources[activeShaderTab]);
document.getElementById('shader-status')!.className = 'shader-success';
document.getElementById('shader-status')!.textContent = 'Shader reset to original';
}
}
// Apply edited shader code to the appropriate global variable
function applyShaderEdit(mode: SimMode, tabName: string, code: string) {
const mapping = {
boids: {
'Compute (Flocking)': () => { SHADER_BOIDS_COMPUTE_EDIT = code; },
'Render (Vert+Frag)': () => { SHADER_BOIDS_RENDER_EDIT = code; },
},
physics: {
'Compute (Gravity)': () => { SHADER_NBODY_COMPUTE_EDIT = code; },
'Render (Vert+Frag)': () => { SHADER_NBODY_RENDER_EDIT = code; },
},
fluid: {
'Forces + Advect': () => { SHADER_FLUID_FORCES_ADVECT_EDIT = code; },
'Diffuse': () => { SHADER_FLUID_DIFFUSE_EDIT = code; },
'Divergence': () => { SHADER_FLUID_DIVERGENCE_EDIT = code; },
'Pressure Solve': () => { SHADER_FLUID_PRESSURE_EDIT = code; },
'Gradient Sub': () => { SHADER_FLUID_GRADIENT_EDIT = code; },
'Render': () => { SHADER_FLUID_RENDER_EDIT = code; },
},
parametric: {
'Compute (Mesh Gen)': () => { SHADER_PARAMETRIC_COMPUTE_EDIT = code; },
'Render (Phong)': () => { SHADER_PARAMETRIC_RENDER_EDIT = code; },
},
};
const modeMapping = mapping[mode] as Record<string, () => void> | undefined;
const fn = modeMapping?.[tabName];
if (fn) fn();
}
// Editable shader overrides — when set, simulations use these instead of originals
let SHADER_BOIDS_COMPUTE_EDIT: string | null = null;
let SHADER_BOIDS_RENDER_EDIT: string | null = null;
let SHADER_NBODY_COMPUTE_EDIT: string | null = null;
let SHADER_NBODY_RENDER_EDIT: string | null = null;
let SHADER_FLUID_FORCES_ADVECT_EDIT: string | null = null;
let SHADER_FLUID_DIFFUSE_EDIT: string | null = null;
let SHADER_FLUID_DIVERGENCE_EDIT: string | null = null;
let SHADER_FLUID_PRESSURE_EDIT: string | null = null;
let SHADER_FLUID_GRADIENT_EDIT: string | null = null;
let SHADER_FLUID_RENDER_EDIT: string | null = null;
let SHADER_PARAMETRIC_COMPUTE_EDIT: string | null = null;
let SHADER_PARAMETRIC_RENDER_EDIT: string | null = null;
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 8: WEBXR
// ═══════════════════════════════════════════════════════════════════════════════
let xrSession: XRSession | null = null;
let xrRefSpace: XRReferenceSpace | null = null;
let xrBinding: XRGPUBinding | null = null;
let xrLayer: XRProjectionLayer | null = null;
let xrFallbackDepth: GPUTexture | null = null;
function setupXRButton() {
const btn = document.getElementById('btn-xr') as HTMLButtonElement;
if (!navigator.xr) {
btn.textContent = 'VR Not Available';
return;
}
navigator.xr.isSessionSupported('immersive-vr').then((supported: boolean) => {
if (supported) {
btn.disabled = false;
btn.addEventListener('click', toggleXR);
} else {
btn.textContent = 'VR Not Supported';
}
}).catch(() => { btn.textContent = 'VR Check Failed'; });
}
async function toggleXR() {
if (xrSession) {
xrSession.end();
return;
}
const btn = document.getElementById('btn-xr')!;
btn.textContent = 'Starting...';
try {
console.log('[XR] Requesting session...');
const sessionConfigs = [
{ requiredFeatures: ['webgpu', 'layers', 'local-floor'] },
{ requiredFeatures: ['webgpu', 'layers'], optionalFeatures: ['local-floor'] },
{ requiredFeatures: ['webgpu'], optionalFeatures: ['layers', 'local-floor'] },
{ optionalFeatures: ['webgpu', 'layers', 'local-floor'] },
{},
];
let refSpaceType = 'local';
for (let ci = 0; ci < sessionConfigs.length; ci++) {
try {
console.log('[XR] Trying session config', ci, JSON.stringify(sessionConfigs[ci]));
xrSession = await navigator.xr!.requestSession('immersive-vr', sessionConfigs[ci]);
// Figure out which ref space we got
const features = sessionConfigs[ci].requiredFeatures || [];
const optFeatures = sessionConfigs[ci].optionalFeatures || [];
if (features.includes('local-floor') || optFeatures.includes('local-floor')) {
try {
xrRefSpace = await xrSession.requestReferenceSpace('local-floor');
refSpaceType = 'local-floor';
} catch (_) {
xrRefSpace = await xrSession.requestReferenceSpace('local');
}
} else {
xrRefSpace = await xrSession.requestReferenceSpace('local');
}
console.log('[XR] Session created with config', ci, ', refSpace:', refSpaceType);
break;
} catch (e) {
console.warn('[XR] Config', ci, 'failed:', (e as Error).message);
xrSession = null;
}
}
if (!xrSession) throw new Error('All session configurations failed');
// Check if layers feature was actually granted
console.log('[XR] Session enabledFeatures:', xrSession.enabledFeatures);
console.log('[XR] Creating XRGPUBinding...');
xrBinding = new XRGPUBinding(xrSession, device);
console.log('[XR] XRGPUBinding created');
// Inspect what the binding actually supports
console.log('[XR] XRGPUBinding methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(xrBinding)));
console.log('[XR] XRGPUBinding has createProjectionLayer:', typeof xrBinding.createProjectionLayer);
console.log('[XR] XRGPUBinding has getPreferredColorFormat:', typeof xrBinding.getPreferredColorFormat);
console.log('[XR] XRGPUBinding has createQuadLayer:', typeof (xrBinding as unknown as Record<string, unknown>).createQuadLayer);
if (xrBinding.getPreferredColorFormat) {
console.log('[XR] Preferred color format:', xrBinding.getPreferredColorFormat());
}
console.log('[XR] Creating projection layer, canvasFormat:', canvasFormat);
// Try multiple configurations — Safari visionOS is picky about formats
const preferredFormat = xrBinding.getPreferredColorFormat();
const nsf = xrBinding.nativeProjectionScaleFactor;
console.log('[XR] nativeProjectionScaleFactor:', nsf);
const layerConfigs: XRGPUProjectionLayerInit[] = [
{ colorFormat: preferredFormat, scaleFactor: nsf },
{ colorFormat: preferredFormat, scaleFactor: nsf, depthStencilFormat: 'depth24plus' },
{ colorFormat: preferredFormat },
{ colorFormat: preferredFormat, depthStencilFormat: 'depth24plus' },
];
for (let ci = 0; ci < layerConfigs.length; ci++) {
try {
console.log('[XR] Trying layer config', ci, ':', JSON.stringify(layerConfigs[ci]));
xrLayer = xrBinding.createProjectionLayer(layerConfigs[ci]);
console.log('[XR] Projection layer created with config', ci);
break;
} catch (e2) {
console.warn('[XR] Config', ci, 'failed:', (e2 as Error).message);
xrLayer = null;
}
}
if (!xrLayer) throw new Error('All projection layer configurations failed');
// Safari with 'webgpu' feature accepts layers[] even without explicit 'layers' feature
xrSession.updateRenderState({ layers: [xrLayer] });
console.log('[XR] Render state updated');
btn.textContent = 'Exit VR';
state.xrEnabled = true;
xrSession.requestAnimationFrame(xrFrame);
xrSession.addEventListener('end', () => {
console.log('[XR] Session ended');
xrSession = null;
xrRefSpace = null;
xrBinding = null;
xrLayer = null;
state.xrEnabled = false;
btn.textContent = 'Enter VR';
requestAnimationFrame(frame);
});
} catch (e) {
console.error('[XR] Failed to start session:', e);
console.error('[XR] Error stack:', (e as Error).stack);
btn.textContent = `XR Error: ${(e as Error).message}`;
if (xrSession) { try { xrSession.end(); } catch (_) {} }
xrSession = null;
setTimeout(() => { btn.textContent = 'Enter VR'; }, 4000);
}
}
let xrFrameCount = 0;
function xrFrame(_time: DOMHighResTimeStamp, xrFrameData: XRFrame) {
if (!xrSession) return;
xrSession.requestAnimationFrame(xrFrame);
xrFrameCount++;
try {
const pose = xrFrameData.getViewerPose(xrRefSpace!);
if (!pose) {
if (xrFrameCount < 5) console.log('[XR] Frame', xrFrameCount, ': no pose');
return;
}
const sim = simulations[state.mode];
if (!sim) {
if (xrFrameCount < 5) console.log('[XR] Frame', xrFrameCount, ': no simulation');
return;
}
if (xrFrameCount <= 3) {
console.log('[XR] Frame', xrFrameCount, ': views=', pose.views.length);
for (let i = 0; i < pose.views.length; i++) {
const v = pose.views[i];
console.log('[XR] view', i, 'eye:', v.eye, 'projMatrix[0]:', v.projectionMatrix[0]);
}
}
const encoder = device.createCommandEncoder();
if (!state.paused) sim.compute(encoder);
for (let vi = 0; vi < pose.views.length; vi++) {
const view = pose.views[vi];
if (xrFrameCount <= 3) console.log('[XR] getViewSubImage for view', vi);
// Safari uses getViewSubImage(layer, view), Chrome uses getSubImage(layer, view)
const binding = xrBinding!;
const subImage = binding.getViewSubImage
? binding.getViewSubImage(xrLayer!, view)
: binding.getSubImage!(xrLayer!, view);
if (!subImage) {
if (xrFrameCount <= 3) console.log('[XR] subImage is null!');
continue;
}
if (xrFrameCount <= 3) {
const ct = subImage.colorTexture;
const dt = subImage.depthStencilTexture;
console.log('[XR] color texture:', ct.width, 'x', ct.height, 'format:', ct.format, 'usage:', ct.usage);
console.log('[XR] depth texture:', dt ? (dt.width + 'x' + dt.height + ' format:' + dt.format) : 'none');
console.log('[XR] viewport:', subImage.viewport.x, subImage.viewport.y, subImage.viewport.width, subImage.viewport.height);
}
const viewMatrix = new Float32Array(view.transform.inverse.matrix);
const projMatrix = new Float32Array(view.projectionMatrix);
const pos = view.transform.position;
xrCameraOverride = {
viewMatrix,
projMatrix,
eye: [pos.x, pos.y, pos.z],
};
// Use getViewDescriptor() for correct texture view (array slice, mip level)
const viewDesc = subImage.getViewDescriptor ? subImage.getViewDescriptor() : {};
if (xrFrameCount <= 3) console.log('[XR] viewDescriptor:', JSON.stringify(viewDesc));
const textureView = subImage.colorTexture.createView(viewDesc);
if (subImage.depthStencilTexture) {
xrDepthView = subImage.depthStencilTexture.createView(viewDesc);
} else {
const ct = subImage.colorTexture;
if (!xrFallbackDepth || xrFallbackDepth.width !== ct.width || xrFallbackDepth.height !== ct.height) {
if (xrFallbackDepth) xrFallbackDepth.destroy();
xrFallbackDepth = device.createTexture({
size: [ct.width, ct.height],
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
}
xrDepthView = xrFallbackDepth.createView();
}
// Pass viewport unclamped — texture array slices are sized for the full viewport
const vp = subImage.viewport;
const viewport = [vp.x, vp.y, vp.width, vp.height];
if (xrFrameCount <= 3) {
console.log('[XR] viewport:', viewport);
console.log('[XR] eye pos:', pos.x.toFixed(4), pos.y.toFixed(4), pos.z.toFixed(4));
}
sim.render(encoder, textureView, viewport);
}
xrCameraOverride = null;
xrDepthView = null;
if (xrFrameCount <= 3) console.log('[XR] Submitting command buffer, frame', xrFrameCount);
device.queue.submit([encoder.finish()]);
if (xrFrameCount <= 3) console.log('[XR] Frame', xrFrameCount, 'complete');
} catch (e) {
console.error('[XR] Frame error at frame', xrFrameCount, ':', e);
console.error('[XR] Stack:', (e as Error).stack);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 9: RENDER LOOP & ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════════
let frameCount = 0;
let fpsTime = 0;
let currentFps = 0;
function ensureSimulation() {
const mode = state.mode;
if (!simulations[mode]) {
const factories = {
boids: createBoidsSimulation,
physics: createPhysicsSimulation,
fluid: createFluidSimulation,
parametric: createParametricSimulation,
};
simulations[mode] = factories[mode]();
}
}
function resetCurrentSim() {
const mode = state.mode;
if (simulations[mode]) {
simulations[mode]!.destroy();
delete simulations[mode];
}
ensureSimulation();
}
function updateStats() {
document.getElementById('stat-fps')!.textContent = `FPS: ${currentFps}`;
const sim = simulations[state.mode];
const count = sim ? sim.getCount() : '--';
document.getElementById('stat-count')!.textContent =
state.mode === 'fluid' ? `Grid: ${count}` : `Particles: ${count}`;
}
function resizeCanvas() {
const container = document.getElementById('canvas-container')!;
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(container.clientWidth * dpr);
const h = Math.floor(container.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function frame(now: DOMHighResTimeStamp) {
if (state.xrEnabled) return; // XR has its own loop
requestAnimationFrame(frame);
resizeCanvas();
// FPS calculation
frameCount++;
if (now - fpsTime >= 1000) {
currentFps = frameCount;
frameCount = 0;
fpsTime = now;
updateStats();
}
const sim = simulations[state.mode];
if (!sim) return;
const encoder = device.createCommandEncoder();
if (!state.paused) {
sim.compute(encoder);
}
const textureView = context.getCurrentTexture().createView();
sim.render(encoder, textureView, null);
device.queue.submit([encoder.finish()]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION 10: STATE PERSISTENCE
// ═══════════════════════════════════════════════════════════════════════════════
const STORAGE_KEY = 'shader-playground-state';
function saveState() {
try {
// Only persist user-configurable state, not transient mouse/runtime data
const toSave = {
mode: state.mode,
colorTheme: state.colorTheme,
boids: state.boids,
physics: state.physics,
fluid: state.fluid,
parametric: state.parametric,
camera: state.camera,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) { /* ignore quota errors */ }
}
function loadState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
const parsed = JSON.parse(saved);
// Merge carefully — don't clobber transient fields or break structure
if (parsed.mode && parsed.mode in DEFAULTS) state.mode = parsed.mode as SimMode;
if (parsed.colorTheme && COLOR_THEMES[parsed.colorTheme]) state.colorTheme = parsed.colorTheme;
if (parsed.boids) Object.assign(state.boids, parsed.boids);
if (parsed.physics) Object.assign(state.physics, parsed.physics);
if (parsed.fluid) Object.assign(state.fluid, parsed.fluid);
if (parsed.parametric) Object.assign(state.parametric, parsed.parametric);
if (parsed.camera) Object.assign(state.camera, parsed.camera);
} catch (e) { /* ignore parse errors — start fresh */ }
}
function syncUIFromState() {
// Activate correct tab
document.querySelectorAll<HTMLElement>('.mode-tab').forEach(t =>
t.classList.toggle('active', t.dataset.mode === state.mode));
document.querySelectorAll<HTMLElement>('.param-group').forEach(g =>
g.classList.toggle('active', g.dataset.mode === state.mode));
// Sync all sliders and dropdowns
for (const modeStr of Object.keys(PARAM_DEFS)) {
const mode = modeStr as SimMode;
const container = document.getElementById(`params-${mode}`)!;
const params = modeParams(mode);
container.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach(input => {
const key = input.dataset.key!;
if (key && key in params) {
input.value = String(params[key]);
const valueSpan = input.parentElement?.querySelector('.control-value');
if (valueSpan) {
const paramDef = findParamDef(mode, key);
valueSpan.textContent = formatValue(Number(params[key]), paramDef ? paramDef.step ?? 0.01 : 0.01);
}
}
});
container.querySelectorAll<HTMLSelectElement>('select').forEach(sel => {
const key = sel.dataset.key!;
if (key && key in params) sel.value = String(params[key]);
});
}
// Sync theme buttons
document.querySelectorAll<HTMLButtonElement>('#theme-presets .preset-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.theme === state.colorTheme));
// Rebuild shape params for current parametric shape
rebuildShapeParams();
}
async function main() {
const ok = await initWebGPU();
if (!ok) return;
initGrid();
loadState();
buildControls();
buildThemeSelector();
setupTabs();
setupGlobalControls();
setupMouseControls();
setupShaderPanel();
syncUIFromState();
resizeCanvas();
ensureSimulation();
updateAll();
const resizeObserver = new ResizeObserver(() => resizeCanvas());
resizeObserver.observe(document.getElementById('canvas-container')!);
requestAnimationFrame(frame);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment