Last active
March 1, 2026 08:28
-
-
Save PAMinerva/29ad2f1c05d48e0c9d0a7c4be83858e1 to your computer and use it in GitHub Desktop.
Fully llm-assisted porting to WebGPU of 16-GPUCloth.py by Matthias Müller
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <!-- | |
| GPU Cloth Simulation — WebGPU port | |
| Original: https://github.com/matthias-research/pages/blob/master/tenMinutePhysics/16-GPUCloth.py | |
| Original copyright (c) 2022 NVIDIA, MIT License | |
| www.youtube.com/c/TenMinutePhysics | |
| www.matthiasMueller.info/tenMinutePhysics | |
| WebGPU port: 2026 Alex P. Minerva | |
| Controls: | |
| P - toggle pause | |
| H - toggle hidden | |
| C - coloring solver (hybrid Gauss-Seidel / Jacobi) | |
| J - full Jacobi solver | |
| R - reset | |
| W/S/A/D - camera move forward/back/left/right | |
| Q/E - camera move up/down | |
| Scroll - zoom | |
| Left drag - rotate view | |
| Middle drag- pan | |
| Right drag - orbit | |
| Shift+drag - pull cloth | |
| --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <title>Parallel Cloth Simulation (WebGPU)</title> | |
| <style> | |
| body { margin:0; background:#000; overflow:hidden; } | |
| canvas { display:block; width:100vw; height:100vh; } | |
| #info { | |
| position:absolute; top:8px; left:8px; | |
| color:#fff; font:13px monospace; | |
| background:rgba(0,0,0,.45); padding:6px 10px; border-radius:4px; | |
| pointer-events:none; | |
| } | |
| #unsupported { | |
| display:none; position:absolute; top:50%; left:50%; | |
| transform:translate(-50%,-50%); | |
| color:#f88; font:18px sans-serif; text-align:center; | |
| } | |
| #loading { | |
| position:absolute; top:50%; left:50%; | |
| transform:translate(-50%,-50%); | |
| color:#fff; font:18px sans-serif; text-align:center; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 0.4; } | |
| 50% { opacity: 1; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="c"></canvas> | |
| <div id="info">P pause · H hide · C coloring · J jacobi · R reset · WASDQE cam · scroll zoom · shift+drag pull</div> | |
| <div id="unsupported">WebGPU is not supported in this browser.<br>Try Chrome ≥ 113 or Edge ≥ 113.</div> | |
| <div id="loading">Please wait<br>Loading simulation...</div> | |
| <!-- ═══════════════════════════════════════════════════════════════ | |
| WGSL SHADERS (all as <script type="x-wgsl"> for readability) | |
| ═══════════════════════════════════════════════════════════════ --> | |
| <!-- ── Shared uniform struct (must match JS layout exactly) ──────── --> | |
| <script id="sh-common" type="x-wgsl"> | |
| struct SimParams { | |
| dt : f32, | |
| gravX : f32, | |
| gravY : f32, | |
| gravZ : f32, | |
| sphereCX : f32, | |
| sphereCY : f32, | |
| sphereCZ : f32, | |
| sphereR : f32, | |
| jacobiScale : f32, | |
| numParticles : u32, | |
| firstConstraint : u32, | |
| numConstraintsInPass : u32, // number of constraints in current pass | |
| _pad0 : u32, | |
| _pad1 : u32, | |
| _pad2 : u32, | |
| _pad3 : u32, | |
| }; | |
| // Fixed-point scale for atomic accumulation (2^16) | |
| const FP_SCALE : f32 = 65536.0; | |
| const FP_INV_SCALE : f32 = 1.0 / 65536.0; | |
| </script> | |
| <!-- ── computeRestLengths ─────────────────────────────────────────── --> | |
| <script id="sh-restlengths" type="x-wgsl"> | |
| @group(0) @binding(0) var<storage, read> pos : array<vec4<f32>>; | |
| @group(0) @binding(1) var<storage, read> constIds : array<u32>; | |
| @group(0) @binding(2) var<storage, read_write> restLengths : array<f32>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let cNr = gid.x; | |
| let n = arrayLength(&restLengths); | |
| if (cNr >= n) { return; } | |
| let id0 = constIds[2u * cNr]; | |
| let id1 = constIds[2u * cNr + 1u]; | |
| let p0 = pos[id0].xyz; | |
| let p1 = pos[id1].xyz; | |
| restLengths[cNr] = length(p1 - p0); | |
| } | |
| </script> | |
| <!-- ── integrate (gravity + sphere + ground collision) ───────────── --> | |
| <script id="sh-integrate" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read_write> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read_write> prevPos : array<vec4<f32>>; | |
| @group(0) @binding(3) var<storage, read_write> vel : array<vec4<f32>>; | |
| @group(0) @binding(4) var<storage, read> invMass : array<f32>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let pNr = gid.x; | |
| if (pNr >= params.numParticles) { return; } | |
| prevPos[pNr] = pos[pNr]; | |
| let w = invMass[pNr]; | |
| if (w == 0.0) { return; } | |
| let grav = vec3<f32>(params.gravX, params.gravY, params.gravZ); | |
| let dt = params.dt; | |
| var v = vel[pNr].xyz; | |
| var p = pos[pNr].xyz; | |
| v = v + grav * dt; | |
| p = p + v * dt; | |
| // ── sphere collision ── | |
| let sc = vec3<f32>(params.sphereCX, params.sphereCY, params.sphereCZ); | |
| let sr = params.sphereR; | |
| let thickness = 0.001; | |
| let friction = 0.01; | |
| var d = length(p - sc); | |
| if (d < (sr + thickness)) { | |
| let pp = p * (1.0 - friction) + prevPos[pNr].xyz * friction; | |
| let r = pp - sc; | |
| d = length(r); | |
| p = sc + r * ((sr + thickness) / d); | |
| } | |
| // ── ground collision ── | |
| if (p.y < thickness) { | |
| let pp = p * (1.0 - friction) + prevPos[pNr].xyz * friction; | |
| p = vec3<f32>(pp.x, thickness, pp.z); | |
| } | |
| pos[pNr] = vec4<f32>(p, 0.0); | |
| vel[pNr] = vec4<f32>(v, 0.0); | |
| } | |
| </script> | |
| <!-- ── solveDistConstraints (coloring: direct pos update) ─────────── --> | |
| <script id="sh-solve-coloring" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read_write> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read> invMass : array<f32>; | |
| @group(0) @binding(3) var<storage, read> constIds : array<u32>; | |
| @group(0) @binding(4) var<storage, read> restLengths : array<f32>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let localIdx = gid.x; | |
| if (localIdx >= params.numConstraintsInPass) { return; } | |
| let cNr = params.firstConstraint + localIdx; | |
| let id0 = constIds[2u * cNr]; | |
| let id1 = constIds[2u * cNr + 1u]; | |
| let w0 = invMass[id0]; | |
| let w1 = invMass[id1]; | |
| let w = w0 + w1; | |
| if (w == 0.0) { return; } | |
| let p0 = pos[id0].xyz; | |
| let p1 = pos[id1].xyz; | |
| let dv = p1 - p0; | |
| let l = length(dv); | |
| let l0 = restLengths[cNr]; | |
| if (l < 1e-9) { return; } | |
| let n = dv / l; | |
| let dP = n * (l - l0) / w; | |
| // independent pass → direct write (no race condition by construction) | |
| pos[id0] = vec4<f32>(p0 + w0 * dP, 0.0); | |
| pos[id1] = vec4<f32>(p1 - w1 * dP, 0.0); | |
| } | |
| </script> | |
| <!-- ── solveDistConstraints (Jacobi: fixed-point atomic accumulate) ── | |
| Uses fixed-point arithmetic: scale floats to i32, atomicAdd, scale back. | |
| This correctly accumulates float values unlike bitcast approach. | |
| ──────────────────────────────────────────────────────────────── --> | |
| <script id="sh-solve-jacobi" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read> invMass : array<f32>; | |
| @group(0) @binding(3) var<storage, read> constIds : array<u32>; | |
| @group(0) @binding(4) var<storage, read> restLengths : array<f32>; | |
| @group(0) @binding(5) var<storage, read_write> corrX : array<atomic<i32>>; | |
| @group(0) @binding(6) var<storage, read_write> corrY : array<atomic<i32>>; | |
| @group(0) @binding(7) var<storage, read_write> corrZ : array<atomic<i32>>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let localIdx = gid.x; | |
| if (localIdx >= params.numConstraintsInPass) { return; } | |
| let cNr = params.firstConstraint + localIdx; | |
| let id0 = constIds[2u * cNr]; | |
| let id1 = constIds[2u * cNr + 1u]; | |
| let w0 = invMass[id0]; | |
| let w1 = invMass[id1]; | |
| let w = w0 + w1; | |
| if (w == 0.0) { return; } | |
| let p0 = pos[id0].xyz; | |
| let p1 = pos[id1].xyz; | |
| let dv = p1 - p0; | |
| let l = length(dv); | |
| let l0 = restLengths[cNr]; | |
| if (l < 1e-9) { return; } | |
| let n = dv / l; | |
| let dP = n * (l - l0) / w; | |
| let c0 = w0 * dP; // correction for id0 | |
| let c1 = -w1 * dP; // correction for id1 | |
| // Fixed-point accumulation: scale float to i32, atomicAdd | |
| atomicAdd(&corrX[id0], i32(c0.x * FP_SCALE)); | |
| atomicAdd(&corrY[id0], i32(c0.y * FP_SCALE)); | |
| atomicAdd(&corrZ[id0], i32(c0.z * FP_SCALE)); | |
| atomicAdd(&corrX[id1], i32(c1.x * FP_SCALE)); | |
| atomicAdd(&corrY[id1], i32(c1.y * FP_SCALE)); | |
| atomicAdd(&corrZ[id1], i32(c1.z * FP_SCALE)); | |
| } | |
| </script> | |
| <!-- ── addCorrections (Jacobi: read corrXYZ, apply to pos, zero corrXYZ) --> | |
| <script id="sh-add-corrections" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read_write> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read_write> corrX : array<atomic<i32>>; | |
| @group(0) @binding(3) var<storage, read_write> corrY : array<atomic<i32>>; | |
| @group(0) @binding(4) var<storage, read_write> corrZ : array<atomic<i32>>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let pNr = gid.x; | |
| if (pNr >= params.numParticles) { return; } | |
| // Read and zero the accumulated corrections, convert back from fixed-point | |
| let cx = f32(atomicExchange(&corrX[pNr], 0)) * FP_INV_SCALE; | |
| let cy = f32(atomicExchange(&corrY[pNr], 0)) * FP_INV_SCALE; | |
| let cz = f32(atomicExchange(&corrZ[pNr], 0)) * FP_INV_SCALE; | |
| let scale = params.jacobiScale; | |
| let p = pos[pNr].xyz; | |
| pos[pNr] = vec4<f32>(p + vec3<f32>(cx, cy, cz) * scale, 0.0); | |
| } | |
| </script> | |
| <!-- ── updateVel ─────────────────────────────────────────────────── --> | |
| <script id="sh-updatevel" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read> prevPos : array<vec4<f32>>; | |
| @group(0) @binding(3) var<storage, read_write> vel : array<vec4<f32>>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let pNr = gid.x; | |
| if (pNr >= params.numParticles) { return; } | |
| let invDt = 1.0 / params.dt; | |
| vel[pNr] = vec4<f32>((pos[pNr].xyz - prevPos[pNr].xyz) * invDt, 0.0); | |
| } | |
| </script> | |
| <!-- ── addNormals (fixed-point atomic accumulation) ───────────────── --> | |
| <script id="sh-addnormals" type="x-wgsl"> | |
| @group(0) @binding(0) var<storage, read> pos : array<vec4<f32>>; | |
| @group(0) @binding(1) var<storage, read> triIds : array<u32>; | |
| @group(0) @binding(2) var<storage, read_write> normX : array<atomic<i32>>; | |
| @group(0) @binding(3) var<storage, read_write> normY : array<atomic<i32>>; | |
| @group(0) @binding(4) var<storage, read_write> normZ : array<atomic<i32>>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let triNr = gid.x; | |
| let numTris = arrayLength(&triIds) / 3u; | |
| if (triNr >= numTris) { return; } | |
| let id0 = triIds[3u * triNr]; | |
| let id1 = triIds[3u * triNr + 1u]; | |
| let id2 = triIds[3u * triNr + 2u]; | |
| let e1 = pos[id1].xyz - pos[id0].xyz; | |
| let e2 = pos[id2].xyz - pos[id0].xyz; | |
| let n = cross(e1, e2); | |
| // Fixed-point accumulation for normals | |
| atomicAdd(&normX[id0], i32(n.x * FP_SCALE)); | |
| atomicAdd(&normY[id0], i32(n.y * FP_SCALE)); | |
| atomicAdd(&normZ[id0], i32(n.z * FP_SCALE)); | |
| atomicAdd(&normX[id1], i32(n.x * FP_SCALE)); | |
| atomicAdd(&normY[id1], i32(n.y * FP_SCALE)); | |
| atomicAdd(&normZ[id1], i32(n.z * FP_SCALE)); | |
| atomicAdd(&normX[id2], i32(n.x * FP_SCALE)); | |
| atomicAdd(&normY[id2], i32(n.y * FP_SCALE)); | |
| atomicAdd(&normZ[id2], i32(n.z * FP_SCALE)); | |
| } | |
| </script> | |
| <!-- ── normalizeNormals ───────────────────────────────────────────── --> | |
| <script id="sh-normalizenormals" type="x-wgsl"> | |
| @group(0) @binding(0) var<uniform> params : SimParams; | |
| @group(0) @binding(1) var<storage, read_write> normals : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read_write> normX : array<atomic<i32>>; | |
| @group(0) @binding(3) var<storage, read_write> normY : array<atomic<i32>>; | |
| @group(0) @binding(4) var<storage, read_write> normZ : array<atomic<i32>>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let pNr = gid.x; | |
| if (pNr >= params.numParticles) { return; } | |
| // Read and zero, convert from fixed-point | |
| let nx = f32(atomicExchange(&normX[pNr], 0)) * FP_INV_SCALE; | |
| let ny = f32(atomicExchange(&normY[pNr], 0)) * FP_INV_SCALE; | |
| let nz = f32(atomicExchange(&normZ[pNr], 0)) * FP_INV_SCALE; | |
| normals[pNr] = vec4<f32>(normalize(vec3<f32>(nx, ny, nz)), 0.0); | |
| } | |
| </script> | |
| <!-- ── Raycast: Möller–Trumbore per-triangle ───────────────────────── --> | |
| <script id="sh-raycast" type="x-wgsl"> | |
| struct RayParams { | |
| origX : f32, origY : f32, origZ : f32, _pad0 : f32, | |
| dirX : f32, dirY : f32, dirZ : f32, _pad1 : f32, | |
| }; | |
| @group(0) @binding(0) var<uniform> ray : RayParams; | |
| @group(0) @binding(1) var<storage, read> pos : array<vec4<f32>>; | |
| @group(0) @binding(2) var<storage, read> triIds : array<u32>; | |
| @group(0) @binding(3) var<storage, read_write> triDist : array<f32>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid : vec3<u32>) { | |
| let triNr = gid.x; | |
| let numTris = arrayLength(&triDist); | |
| if (triNr >= numTris) { return; } | |
| let noHit : f32 = 1e9; | |
| let orig = vec3<f32>(ray.origX, ray.origY, ray.origZ); | |
| let dir = vec3<f32>(ray.dirX, ray.dirY, ray.dirZ); | |
| let id0 = triIds[3u * triNr]; | |
| let id1 = triIds[3u * triNr + 1u]; | |
| let id2 = triIds[3u * triNr + 2u]; | |
| let p0 = pos[id0].xyz; | |
| let p1 = pos[id1].xyz; | |
| let p2 = pos[id2].xyz; | |
| let e1 = p1 - p0; | |
| let e2 = p2 - p0; | |
| let pv = cross(dir, e2); | |
| let det = dot(e1, pv); | |
| if (abs(det) < 1e-9) { | |
| triDist[triNr] = noHit; | |
| return; | |
| } | |
| let inv = 1.0 / det; | |
| let tv = orig - p0; | |
| let u = dot(tv, pv) * inv; | |
| if (u < 0.0 || u > 1.0) { | |
| triDist[triNr] = noHit; | |
| return; | |
| } | |
| let qv = cross(tv, e1); | |
| let v = dot(dir, qv) * inv; | |
| if (v < 0.0 || u + v > 1.0) { | |
| triDist[triNr] = noHit; | |
| return; | |
| } | |
| let d = dot(e2, qv) * inv; | |
| if (d <= 0.0) { | |
| triDist[triNr] = noHit; | |
| return; | |
| } | |
| triDist[triNr] = d; | |
| } | |
| </script> | |
| <!-- ── Render: vertex shader ─────────────────────────────────────── --> | |
| <script id="sh-vert" type="x-wgsl"> | |
| struct Camera { | |
| viewProj : mat4x4<f32>, | |
| eyePos : vec4<f32>, | |
| }; | |
| @group(0) @binding(0) var<uniform> cam : Camera; | |
| struct VertIn { | |
| @location(0) pos : vec3<f32>, | |
| @location(1) normal : vec3<f32>, | |
| }; | |
| struct VertOut { | |
| @builtin(position) clip : vec4<f32>, | |
| @location(0) worldPos : vec3<f32>, | |
| @location(1) normal : vec3<f32>, | |
| }; | |
| @vertex | |
| fn main(v : VertIn) -> VertOut { | |
| var out : VertOut; | |
| out.clip = cam.viewProj * vec4<f32>(v.pos, 1.0); | |
| out.worldPos = v.pos; | |
| out.normal = v.normal; | |
| return out; | |
| } | |
| </script> | |
| <!-- ── Render: fragment shader (cloth) ────────────────────────────── --> | |
| <script id="sh-frag-cloth" type="x-wgsl"> | |
| struct Camera { | |
| viewProj : mat4x4<f32>, | |
| eyePos : vec4<f32>, | |
| }; | |
| @group(0) @binding(0) var<uniform> cam : Camera; | |
| @fragment | |
| fn main( | |
| @location(0) worldPos : vec3<f32>, | |
| @location(1) normal : vec3<f32> | |
| ) -> @location(0) vec4<f32> { | |
| let lightPos = vec3<f32>(10.0, 10.0, 10.0); | |
| let N = normalize(normal); | |
| let L = normalize(lightPos - worldPos); | |
| let diff = max(abs(dot(N, L)), 0.15); // abs → two-sided | |
| let col = vec3<f32>(1.0, 0.15, 0.05) * diff; | |
| return vec4<f32>(col, 1.0); | |
| } | |
| </script> | |
| <!-- ── Render: fragment shader (wireframe with lighting) ────────── --> | |
| <script id="sh-frag-wire" type="x-wgsl"> | |
| struct Camera { | |
| viewProj : mat4x4<f32>, | |
| eyePos : vec4<f32>, | |
| }; | |
| @group(0) @binding(0) var<uniform> cam : Camera; | |
| @fragment | |
| fn main( | |
| @location(0) worldPos : vec3<f32>, | |
| @location(1) normal : vec3<f32> | |
| ) -> @location(0) vec4<f32> { | |
| let lightPos = vec3<f32>(10.0, 10.0, 10.0); | |
| let N = normalize(normal); | |
| let L = normalize(lightPos - worldPos); | |
| let diff = max(abs(dot(N, L)), 0.15); // abs → two-sided lighting | |
| let col = vec3<f32>(1.0, 0.0, 0.0) * diff; // red like Python version | |
| return vec4<f32>(col, 1.0); | |
| } | |
| </script> | |
| <!-- ── Render: vertex shader (sphere/ground geometry) ────────────── --> | |
| <script id="sh-vert-solid" type="x-wgsl"> | |
| struct Camera { | |
| viewProj : mat4x4<f32>, | |
| eyePos : vec4<f32>, | |
| }; | |
| @group(0) @binding(0) var<uniform> cam : Camera; | |
| struct VertIn { | |
| @location(0) pos : vec3<f32>, | |
| @location(1) normal : vec3<f32>, | |
| @location(2) color : vec3<f32>, | |
| }; | |
| struct VertOut { | |
| @builtin(position) clip : vec4<f32>, | |
| @location(0) color : vec3<f32>, | |
| @location(1) normal : vec3<f32>, | |
| @location(2) worldPos : vec3<f32>, | |
| }; | |
| @vertex | |
| fn main(v : VertIn) -> VertOut { | |
| var out : VertOut; | |
| out.clip = cam.viewProj * vec4<f32>(v.pos, 1.0); | |
| out.color = v.color; | |
| out.normal = v.normal; | |
| out.worldPos = v.pos; | |
| return out; | |
| } | |
| </script> | |
| <!-- ── Render: fragment shader (sphere/ground) ───────────────────── --> | |
| <script id="sh-frag-solid" type="x-wgsl"> | |
| @fragment | |
| fn main( | |
| @location(0) color : vec3<f32>, | |
| @location(1) normal : vec3<f32>, | |
| @location(2) worldPos : vec3<f32> | |
| ) -> @location(0) vec4<f32> { | |
| let lightPos = vec3<f32>(10.0, 10.0, 10.0); | |
| let N = normalize(normal); | |
| let L = normalize(lightPos - worldPos); | |
| let diff = max(dot(N, L), 0.1); | |
| return vec4<f32>(color * diff, 1.0); | |
| } | |
| </script> | |
| <!-- ═══════════════════════════════════════════════════════════════ | |
| MAIN JAVASCRIPT | |
| ═══════════════════════════════════════════════════════════════ --> | |
| <script> | |
| 'use strict'; | |
| // ─── Simulation constants ────────────────────────────────────────── | |
| const TARGET_FPS = 60; | |
| const NUM_SUBSTEPS = 30; | |
| const TIME_STEP = 1.0 / 60.0; | |
| const GRAVITY = [0, -10, 0]; | |
| const JACOBI_SCALE = 0.2; | |
| const CLOTH_NUM_X = 100; | |
| const CLOTH_NUM_Y = 100; | |
| const CLOTH_Y = 2.2; | |
| const CLOTH_SPACING = 0.05; | |
| const SPHERE_CENTER = [0, 1.5, 0]; | |
| const SPHERE_RADIUS = 0.5; | |
| // ─── Global state ───────────────────────────────────────────────── | |
| let paused = false; | |
| let hidden = false; | |
| let solveType = 0; // 0=coloring, 1=jacobi | |
| let frameNr = 0; | |
| let prevTime = performance.now(); | |
| // ─── Math helpers ───────────────────────────────────────────────── | |
| function vec3Add(a, b){ return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; } | |
| function vec3Sub(a, b){ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; } | |
| function vec3Scale(a, s){ return [a[0]*s, a[1]*s, a[2]*s]; } | |
| function vec3Dot(a, b){ return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; } | |
| function vec3Len(a){ return Math.sqrt(vec3Dot(a,a)); } | |
| function vec3Norm(a){ const l=vec3Len(a)||1; return vec3Scale(a,1/l); } | |
| function vec3Cross(a,b){ | |
| 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 quatFromAxisAngle(axis, angle){ | |
| const s = Math.sin(angle*0.5); | |
| return [axis[0]*s, axis[1]*s, axis[2]*s, Math.cos(angle*0.5)]; | |
| } | |
| function quatRotate(q, v){ | |
| const [qx,qy,qz,qw] = q; | |
| const [vx,vy,vz] = v; | |
| const tx = 2*(qy*vz - qz*vy); | |
| const ty = 2*(qz*vx - qx*vz); | |
| const tz = 2*(qx*vy - qy*vx); | |
| return [vx + qw*tx + qy*tz - qz*ty, | |
| vy + qw*ty + qz*tx - qx*tz, | |
| vz + qw*tz + qx*ty - qy*tx]; | |
| } | |
| // 4×4 matrix (column-major Float32Array) | |
| function mat4Perspective(fovY, aspect, near, far){ | |
| const f = 1.0 / Math.tan(fovY * 0.5 * Math.PI / 180); | |
| const m = new Float32Array(16); | |
| m[0] = f / aspect; | |
| m[5] = f; | |
| m[10] = (far + near) / (near - far); | |
| m[11] = -1; | |
| m[14] = 2 * far * near / (near - far); | |
| return m; | |
| } | |
| function mat4LookAt(eye, center, up){ | |
| const f = vec3Norm(vec3Sub(center, eye)); | |
| const r = vec3Norm(vec3Cross(f, up)); | |
| const u = vec3Cross(r, f); | |
| const m = new Float32Array(16); | |
| m[0]=r[0]; m[4]=r[1]; m[8] =r[2]; m[12]=-vec3Dot(r,eye); | |
| m[1]=u[0]; m[5]=u[1]; m[9] =u[2]; m[13]=-vec3Dot(u,eye); | |
| m[2]=-f[0];m[6]=-f[1];m[10]=-f[2]; m[14]= vec3Dot(f,eye); | |
| m[3]=0; m[7]=0; m[11]=0; m[15]=1; | |
| return m; | |
| } | |
| function mat4Mul(a, b){ | |
| const m = new Float32Array(16); | |
| for(let c=0;c<4;c++) for(let r=0;r<4;r++){ | |
| let s=0; for(let k=0;k<4;k++) s+=a[r+k*4]*b[k+c*4]; | |
| m[r+c*4]=s; | |
| } | |
| return m; | |
| } | |
| function mat4Identity(){ | |
| const m=new Float32Array(16); | |
| m[0]=m[5]=m[10]=m[15]=1; return m; | |
| } | |
| // ─── Camera ─────────────────────────────────────────────────────── | |
| class Camera { | |
| constructor(){ | |
| this.pos = [0, 1.5, 5]; | |
| this.forward = [0, 0, -1]; | |
| this.up = [0, 1, 0]; | |
| this.right = vec3Cross(this.forward, this.up); | |
| this.speed = 0.05; | |
| this.keys = {}; | |
| } | |
| rot(axis, angle, v){ return quatRotate(quatFromAxisAngle(axis, angle), v); } | |
| getViewProj(aspect){ | |
| const center = vec3Add(this.pos, this.forward); | |
| const view = mat4LookAt(this.pos, center, this.up); | |
| const proj = mat4Perspective(40, aspect, 0.01, 1000); | |
| return mat4Mul(proj, view); | |
| } | |
| mouseLook(dx, dy){ | |
| const s = 0.005; | |
| this.forward = this.rot(this.up, -dx*s, this.forward); | |
| this.forward = this.rot(this.right,-dy*s, this.forward); | |
| this.forward = vec3Norm(this.forward); | |
| this.right = vec3Norm(vec3Cross(this.forward, this.up)); | |
| this.right = [this.right[0], 0, this.right[2]]; | |
| this.right = vec3Norm(this.right); | |
| this.up = vec3Norm(vec3Cross(this.right, this.forward)); | |
| this.forward = vec3Cross(this.up, this.right); | |
| } | |
| mousePan(dx, dy){ | |
| const scale = vec3Len(this.pos) * 0.001; | |
| this.pos = vec3Sub(this.pos, vec3Scale(this.right, scale*dx)); | |
| this.pos = vec3Add(this.pos, vec3Scale(this.up, scale*dy)); | |
| } | |
| mouseOrbit(dx, dy, center){ | |
| const offset0 = vec3Sub(this.pos, center); | |
| const ox = vec3Dot(this.right, offset0); | |
| const oy = vec3Dot(this.forward, offset0); | |
| const oz = vec3Dot(this.up, offset0); | |
| const s = 0.01; | |
| this.forward = this.rot(this.up, -dx*s, this.forward); | |
| this.forward = this.rot(this.right,-dy*s, this.forward); | |
| this.up = this.rot(this.up, -dx*s, this.up); | |
| this.up = this.rot(this.right,-dy*s, this.up); | |
| this.right = vec3Norm(vec3Cross(this.forward, this.up)); | |
| this.right = [this.right[0], 0, this.right[2]]; | |
| this.right = vec3Norm(this.right); | |
| this.up = vec3Norm(vec3Cross(this.right, this.forward)); | |
| this.forward = vec3Cross(this.up, this.right); | |
| this.pos = vec3Add(center, vec3Scale(this.right, ox)); | |
| this.pos = vec3Add(this.pos, vec3Scale(this.forward, oy)); | |
| this.pos = vec3Add(this.pos, vec3Scale(this.up, oz)); | |
| } | |
| wheel(d){ this.pos = vec3Add(this.pos, vec3Scale(this.forward, d*this.speed*8)); } | |
| handleKeys(){ | |
| if(this.keys['w']) this.pos = vec3Add(this.pos, vec3Scale(this.forward, this.speed)); | |
| if(this.keys['s']) this.pos = vec3Sub(this.pos, vec3Scale(this.forward, this.speed)); | |
| if(this.keys['a']) this.pos = vec3Sub(this.pos, vec3Scale(this.right, this.speed)); | |
| if(this.keys['d']) this.pos = vec3Add(this.pos, vec3Scale(this.right, this.speed)); | |
| if(this.keys['q']) this.pos = vec3Add(this.pos, vec3Scale(this.up, this.speed)); | |
| if(this.keys['e']) this.pos = vec3Sub(this.pos, vec3Scale(this.up, this.speed)); | |
| } | |
| } | |
| // ─── Sphere geometry ────────────────────────────────────────────── | |
| function buildSphere(cx, cy, cz, r, stacks, slices){ | |
| const verts=[], norms=[], cols=[], idx=[]; | |
| for(let i=0;i<=stacks;i++){ | |
| const phi = Math.PI*i/stacks; | |
| for(let j=0;j<=slices;j++){ | |
| const theta = 2*Math.PI*j/slices; | |
| const x = Math.sin(phi)*Math.cos(theta); | |
| const y = Math.cos(phi); | |
| const z = Math.sin(phi)*Math.sin(theta); | |
| verts.push(cx+r*x, cy+r*y, cz+r*z); | |
| norms.push(x,y,z); | |
| cols.push(0.8,0.8,0.8); | |
| } | |
| } | |
| for(let i=0;i<stacks;i++) for(let j=0;j<slices;j++){ | |
| const a=(i*(slices+1)+j), b=a+slices+1; | |
| idx.push(a,b,a+1, b,b+1,a+1); | |
| } | |
| return { verts:new Float32Array(verts), norms:new Float32Array(norms), | |
| cols:new Float32Array(cols), idx:new Uint32Array(idx) }; | |
| } | |
| // ─── Ground geometry ────────────────────────────────────────────── | |
| function buildGround(){ | |
| const tiles=30, size=0.5, r=tiles*0.5*size; | |
| const verts=[], norms=[], cols=[], idx=[]; | |
| const sv=[[0,0],[0,1],[1,0],[0,1],[1,1],[1,0]]; | |
| let vi=0; | |
| for(let xi=0;xi<tiles;xi++) for(let zi=0;zi<tiles;zi++){ | |
| const x0=(-tiles*0.5+xi)*size, z0=(-tiles*0.5+zi)*size; | |
| const col=(xi+zi)%2===1 ? 0.8 : 0.4; | |
| for(const [sx,sz] of sv){ | |
| const px=x0+sx*size, pz=z0+sz*size; | |
| const pr=Math.sqrt(px*px+pz*pz); | |
| const d=Math.max(0,1-pr/r)*col; | |
| verts.push(px,0,pz); norms.push(0,1,0); cols.push(d,d,d); | |
| } | |
| // indices: 6 verts per tile already triangulated | |
| for(let k=0;k<6;k++) idx.push(vi+k); | |
| vi+=6; | |
| } | |
| return { verts:new Float32Array(verts), norms:new Float32Array(norms), | |
| cols:new Float32Array(cols), idx:new Uint32Array(idx) }; | |
| } | |
| // ─── WGSL source helper ─────────────────────────────────────────── | |
| function wgsl(id){ | |
| const common = document.getElementById('sh-common').textContent; | |
| const body = document.getElementById(id).textContent; | |
| // prepend common struct to shaders that use SimParams or FP_SCALE | |
| return (body.includes('SimParams') || body.includes('FP_SCALE')) | |
| ? common + '\n' + body | |
| : body; | |
| } | |
| // ─── GPU buffer helpers ─────────────────────────────────────────── | |
| function makeBuffer(device, data, usage, label=''){ | |
| const buf = device.createBuffer({ size: data.byteLength, usage: usage|GPUBufferUsage.COPY_DST, label }); | |
| device.queue.writeBuffer(buf, 0, data); | |
| return buf; | |
| } | |
| function makeEmptyBuffer(device, byteSize, usage, label=''){ | |
| return device.createBuffer({ size: Math.max(byteSize,4), usage, label }); | |
| } | |
| // ─── Cloth simulation class ─────────────────────────────────────── | |
| class ClothSim { | |
| constructor(device, numX, numY, yOffset, spacing, sphereCenter, sphereRadius){ | |
| this.device = device; | |
| if(numX%2===1) numX++; | |
| if(numY%2===1) numY++; | |
| this.numX = numX; this.numY = numY; | |
| this.numParticles = (numX+1)*(numY+1); | |
| // ── CPU arrays for init and drag ── | |
| const pos = new Float32Array(4 * this.numParticles); | |
| const invMass = new Float32Array(this.numParticles); | |
| for(let xi=0;xi<=numX;xi++) for(let yi=0;yi<=numY;yi++){ | |
| const id = xi*(numY+1)+yi; | |
| pos[4*id] = (-numX*0.5+xi)*spacing; | |
| pos[4*id+1] = yOffset; | |
| pos[4*id+2] = (-numY*0.5+yi)*spacing; | |
| pos[4*id+3] = 0; | |
| invMass[id] = 1.0; | |
| } | |
| this.cpuPos = pos.slice(); | |
| this.cpuInvMass = invMass.slice(); | |
| this.restPos = pos.slice(); | |
| // ── Constraints ── | |
| this.passSizes = [ | |
| (numX+1)*Math.floor(numY/2), | |
| (numX+1)*Math.floor(numY/2), | |
| Math.floor(numX/2)*(numY+1), | |
| Math.floor(numX/2)*(numY+1), | |
| 2*numX*numY + (numX+1)*(numY-1) + (numY+1)*(numX-1) | |
| ]; | |
| this.passIndependent = [true, true, true, true, false]; | |
| this.numDistConstraints = this.passSizes.reduce((a,b)=>a+b,0); | |
| const constIds = new Int32Array(2 * this.numDistConstraints); | |
| let ci = 0; | |
| // stretch y | |
| for(let p=0;p<2;p++) for(let xi=0;xi<=numX;xi++) for(let yi=0;yi<Math.floor(numY/2);yi++){ | |
| constIds[2*ci] = xi*(numY+1)+2*yi+p; | |
| constIds[2*ci+1]= xi*(numY+1)+2*yi+p+1; | |
| ci++; | |
| } | |
| // stretch x | |
| for(let p=0;p<2;p++) for(let xi=0;xi<Math.floor(numX/2);xi++) for(let yi=0;yi<=numY;yi++){ | |
| constIds[2*ci] = (2*xi+p)*(numY+1)+yi; | |
| constIds[2*ci+1]= (2*xi+p+1)*(numY+1)+yi; | |
| ci++; | |
| } | |
| // shear | |
| for(let xi=0;xi<numX;xi++) for(let yi=0;yi<numY;yi++){ | |
| constIds[2*ci] = xi*(numY+1)+yi; | |
| constIds[2*ci+1]= (xi+1)*(numY+1)+yi+1; | |
| ci++; | |
| constIds[2*ci] = (xi+1)*(numY+1)+yi; | |
| constIds[2*ci+1]= xi*(numY+1)+yi+1; | |
| ci++; | |
| } | |
| // bending y | |
| for(let xi=0;xi<=numX;xi++) for(let yi=0;yi<numY-1;yi++){ | |
| constIds[2*ci] = xi*(numY+1)+yi; | |
| constIds[2*ci+1]= xi*(numY+1)+yi+2; | |
| ci++; | |
| } | |
| // bending x | |
| for(let xi=0;xi<numX-1;xi++) for(let yi=0;yi<=numY;yi++){ | |
| constIds[2*ci] = xi*(numY+1)+yi; | |
| constIds[2*ci+1]= (xi+2)*(numY+1)+yi; | |
| ci++; | |
| } | |
| // ── Triangles ── | |
| this.numTris = 2*numX*numY; | |
| const triIds = new Uint32Array(3*this.numTris); | |
| let ti=0; | |
| for(let xi=0;xi<numX;xi++) for(let yi=0;yi<numY;yi++){ | |
| const id0=xi*(numY+1)+yi, id1=(xi+1)*(numY+1)+yi; | |
| const id2=(xi+1)*(numY+1)+yi+1, id3=xi*(numY+1)+yi+1; | |
| triIds[ti++]=id0; triIds[ti++]=id1; triIds[ti++]=id2; | |
| triIds[ti++]=id0; triIds[ti++]=id2; triIds[ti++]=id3; | |
| } | |
| this.hostTriIds = triIds; | |
| // Generate wireframe edge indices (each quad has 4 edges) | |
| // Edges: horizontal (id0-id1, id3-id2), vertical (id0-id3, id1-id2) | |
| const edgeSet = new Set(); | |
| const addEdge = (a, b) => { | |
| const key = a < b ? `${a}-${b}` : `${b}-${a}`; | |
| edgeSet.add(key); | |
| }; | |
| for(let xi=0;xi<numX;xi++) for(let yi=0;yi<numY;yi++){ | |
| const id0=xi*(numY+1)+yi, id1=(xi+1)*(numY+1)+yi; | |
| const id2=(xi+1)*(numY+1)+yi+1, id3=xi*(numY+1)+yi+1; | |
| addEdge(id0, id1); addEdge(id1, id2); | |
| addEdge(id2, id3); addEdge(id3, id0); | |
| addEdge(id0, id2); // diagonal | |
| } | |
| const edgeIds = new Uint32Array(edgeSet.size * 2); | |
| let ei = 0; | |
| for(const key of edgeSet){ | |
| const [a, b] = key.split('-').map(Number); | |
| edgeIds[ei++] = a; | |
| edgeIds[ei++] = b; | |
| } | |
| this.numEdges = edgeSet.size; | |
| this.hostEdgeIds = edgeIds; | |
| // ── GPU buffers ── | |
| const U = GPUBufferUsage; | |
| this.bufPos = makeBuffer(device, pos, U.STORAGE|U.COPY_SRC|U.VERTEX, 'pos'); | |
| this.bufPrevPos = makeBuffer(device, pos, U.STORAGE, 'prevPos'); | |
| this.bufRestPos = makeBuffer(device, pos, U.STORAGE, 'restPos'); | |
| this.bufVel = makeBuffer(device, new Float32Array(4*this.numParticles), U.STORAGE, 'vel'); | |
| this.bufInvMass = makeBuffer(device, invMass, U.STORAGE|U.COPY_DST, 'invMass'); | |
| this.bufConstIds = makeBuffer(device, constIds,U.STORAGE, 'constIds'); | |
| this.bufTriIds = makeBuffer(device, triIds, U.INDEX|U.STORAGE, 'triIds'); | |
| this.bufEdgeIds = makeBuffer(device, edgeIds, U.INDEX, 'edgeIds'); | |
| this.bufRestLens = makeEmptyBuffer(device, 4*this.numDistConstraints, U.STORAGE, 'restLens'); | |
| this.bufNormals = makeEmptyBuffer(device, 16*this.numParticles, U.STORAGE|U.VERTEX, 'normals'); | |
| // atomic correction / normal accumulation buffers (i32) | |
| const atomicBytes = 4*this.numParticles; | |
| this.bufCorrX = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'corrX'); | |
| this.bufCorrY = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'corrY'); | |
| this.bufCorrZ = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'corrZ'); | |
| this.bufNormX = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'normX'); | |
| this.bufNormY = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'normY'); | |
| this.bufNormZ = makeEmptyBuffer(device, atomicBytes, U.STORAGE|U.COPY_DST, 'normZ'); | |
| // Pre-allocate zero buffer for clearing | |
| this.zeroBuf = new Int32Array(this.numParticles); | |
| // uniform buffer (16 floats × 4 = 64 bytes) | |
| this.bufParams = makeEmptyBuffer(device, 64, U.UNIFORM|U.COPY_DST, 'params'); | |
| // GPU raycast buffers | |
| this.bufRayParams = makeEmptyBuffer(device, 32, U.UNIFORM|U.COPY_DST, 'rayParams'); // 8 floats | |
| this.bufTriDist = makeEmptyBuffer(device, 4*this.numTris, U.STORAGE|U.COPY_SRC, 'triDist'); | |
| this.bufTriDistRead = makeEmptyBuffer(device, 4*this.numTris, U.MAP_READ|U.COPY_DST, 'triDistRead'); | |
| // drag state | |
| this.dragParticleNr = -1; | |
| this.dragDepth = 0; | |
| this.dragInvMass = 0; | |
| // ── Build compute pipelines ── | |
| this._buildPipelines(); | |
| // ── Compute rest lengths (one-time) ── | |
| this._computeRestLengths(); | |
| console.log(`Cloth: ${this.numParticles} particles, ${this.numDistConstraints} constraints, ${this.numTris} tris`); | |
| } | |
| // ── Uniform upload ──────────────────────────────────────────── | |
| _writeParams(dt, firstConstraint=0, numConstraintsInPass=0){ | |
| const d = new Float32Array(16); | |
| d[0] = dt; | |
| d[1] = GRAVITY[0]; d[2]=GRAVITY[1]; d[3]=GRAVITY[2]; | |
| d[4] = SPHERE_CENTER[0]; d[5]=SPHERE_CENTER[1]; d[6]=SPHERE_CENTER[2]; | |
| d[7] = SPHERE_RADIUS; | |
| d[8] = JACOBI_SCALE; | |
| const ui = new Uint32Array(d.buffer); | |
| ui[9] = this.numParticles; | |
| ui[10] = firstConstraint; | |
| ui[11] = numConstraintsInPass; | |
| this.device.queue.writeBuffer(this.bufParams, 0, d); | |
| } | |
| // ── Pipeline builder ────────────────────────────────────────── | |
| _buildPipelines(){ | |
| const dev = this.device; | |
| const pipe = (code) => dev.createComputePipeline({ | |
| layout: 'auto', | |
| compute: { module: dev.createShaderModule({code}), entryPoint:'main' } | |
| }); | |
| this.pipeRestLens = pipe(wgsl('sh-restlengths')); | |
| this.pipeIntegrate = pipe(wgsl('sh-integrate')); | |
| this.pipeSolveColor = pipe(wgsl('sh-solve-coloring')); | |
| this.pipeSolveJacobi = pipe(wgsl('sh-solve-jacobi')); | |
| this.pipeAddCorr = pipe(wgsl('sh-add-corrections')); | |
| this.pipeUpdateVel = pipe(wgsl('sh-updatevel')); | |
| this.pipeAddNormals = pipe(wgsl('sh-addnormals')); | |
| this.pipeNormNormals = pipe(wgsl('sh-normalizenormals')); | |
| this.pipeRaycast = pipe(wgsl('sh-raycast')); | |
| } | |
| // ── Bind group helpers ──────────────────────────────────────── | |
| _bg(pipeline, bindings){ | |
| return this.device.createBindGroup({ | |
| layout: pipeline.getBindGroupLayout(0), | |
| entries: bindings.map((b,i)=>({ binding:i, resource:{ buffer:b } })) | |
| }); | |
| } | |
| // ── Dispatch helper ─────────────────────────────────────────── | |
| _dispatch(pass, pipeline, bg, count){ | |
| pass.setPipeline(pipeline); | |
| pass.setBindGroup(0, bg); | |
| pass.dispatchWorkgroups(Math.ceil(count/64)); | |
| } | |
| // ── Compute rest lengths (one-time init) ────────────────────── | |
| _computeRestLengths(){ | |
| const dev = this.device; | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| const bg = dev.createBindGroup({ | |
| layout: this.pipeRestLens.getBindGroupLayout(0), | |
| entries:[ | |
| {binding:0, resource:{buffer:this.bufPos}}, | |
| {binding:1, resource:{buffer:this.bufConstIds}}, | |
| {binding:2, resource:{buffer:this.bufRestLens}}, | |
| ] | |
| }); | |
| pass.setPipeline(this.pipeRestLens); | |
| pass.setBindGroup(0, bg); | |
| pass.dispatchWorkgroups(Math.ceil(this.numDistConstraints/64)); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| // ── Zero correction buffers ─────────────────────────────────── | |
| _zeroCorrBuffers(){ | |
| const dev = this.device; | |
| dev.queue.writeBuffer(this.bufCorrX, 0, this.zeroBuf); | |
| dev.queue.writeBuffer(this.bufCorrY, 0, this.zeroBuf); | |
| dev.queue.writeBuffer(this.bufCorrZ, 0, this.zeroBuf); | |
| } | |
| // ── Main simulate ───────────────────────────────────────────── | |
| simulate(currentSolveType){ | |
| const dev = this.device; | |
| const dt = TIME_STEP / NUM_SUBSTEPS; | |
| for(let step=0; step<NUM_SUBSTEPS; step++){ | |
| // integrate | |
| { | |
| this._writeParams(dt, 0, 0); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeIntegrate, | |
| this._bg(this.pipeIntegrate,[ | |
| this.bufParams, this.bufPos, this.bufPrevPos, | |
| this.bufVel, this.bufInvMass]), | |
| this.numParticles); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| if(currentSolveType === 0){ | |
| // coloring hybrid - each pass must be submitted separately | |
| let first = 0; | |
| for(let pNr=0; pNr<this.passSizes.length; pNr++){ | |
| const n = this.passSizes[pNr]; | |
| if(this.passIndependent[pNr]){ | |
| this._writeParams(dt, first, n); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeSolveColor, | |
| this._bg(this.pipeSolveColor,[ | |
| this.bufParams, this.bufPos, this.bufInvMass, | |
| this.bufConstIds, this.bufRestLens]), | |
| n); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } else { | |
| // Jacobi pass - zero buffers, solve, apply corrections | |
| this._zeroCorrBuffers(); | |
| this._writeParams(dt, first, n); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeSolveJacobi, | |
| this._bg(this.pipeSolveJacobi,[ | |
| this.bufParams, this.bufPos, this.bufInvMass, | |
| this.bufConstIds, this.bufRestLens, | |
| this.bufCorrX, this.bufCorrY, this.bufCorrZ]), | |
| n); | |
| this._dispatch(pass, this.pipeAddCorr, | |
| this._bg(this.pipeAddCorr,[ | |
| this.bufParams, this.bufPos, | |
| this.bufCorrX, this.bufCorrY, this.bufCorrZ]), | |
| this.numParticles); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| first += n; | |
| } | |
| } else { | |
| // full Jacobi | |
| this._zeroCorrBuffers(); | |
| this._writeParams(dt, 0, this.numDistConstraints); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeSolveJacobi, | |
| this._bg(this.pipeSolveJacobi,[ | |
| this.bufParams, this.bufPos, this.bufInvMass, | |
| this.bufConstIds, this.bufRestLens, | |
| this.bufCorrX, this.bufCorrY, this.bufCorrZ]), | |
| this.numDistConstraints); | |
| this._dispatch(pass, this.pipeAddCorr, | |
| this._bg(this.pipeAddCorr,[ | |
| this.bufParams, this.bufPos, | |
| this.bufCorrX, this.bufCorrY, this.bufCorrZ]), | |
| this.numParticles); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| // updateVel | |
| { | |
| this._writeParams(dt, 0, 0); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeUpdateVel, | |
| this._bg(this.pipeUpdateVel,[ | |
| this.bufParams, this.bufPos, this.bufPrevPos, this.bufVel]), | |
| this.numParticles); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| } | |
| } | |
| // ── Update normals ──────────────────────────────────────────── | |
| updateNormals(){ | |
| const dev = this.device; | |
| // zero normXYZ | |
| dev.queue.writeBuffer(this.bufNormX, 0, this.zeroBuf); | |
| dev.queue.writeBuffer(this.bufNormY, 0, this.zeroBuf); | |
| dev.queue.writeBuffer(this.bufNormZ, 0, this.zeroBuf); | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeAddNormals, | |
| this._bg(this.pipeAddNormals,[ | |
| this.bufPos, this.bufTriIds, | |
| this.bufNormX, this.bufNormY, this.bufNormZ]), | |
| this.numTris); | |
| this._writeParams(0, 0, 0); | |
| this._dispatch(pass, this.pipeNormNormals, | |
| this._bg(this.pipeNormNormals,[ | |
| this.bufParams, this.bufNormals, | |
| this.bufNormX, this.bufNormY, this.bufNormZ]), | |
| this.numParticles); | |
| pass.end(); | |
| dev.queue.submit([enc.finish()]); | |
| } | |
| // ── Reset ───────────────────────────────────────────────────── | |
| reset(){ | |
| const dev = this.device; | |
| dev.queue.writeBuffer(this.bufPos, 0, this.restPos); | |
| dev.queue.writeBuffer(this.bufPrevPos,0, this.restPos); | |
| dev.queue.writeBuffer(this.bufVel, 0, new Float32Array(4*this.numParticles)); | |
| this.dragParticleNr = -1; | |
| } | |
| // ── Drag: GPU raycast with Möller–Trumbore ─────────────────── | |
| async startDrag(orig, dir){ | |
| const dev = this.device; | |
| // Upload ray parameters to GPU | |
| const rayData = new Float32Array([ | |
| orig[0], orig[1], orig[2], 0, | |
| dir[0], dir[1], dir[2], 0 | |
| ]); | |
| dev.queue.writeBuffer(this.bufRayParams, 0, rayData); | |
| // Dispatch raycast compute shader | |
| const enc = dev.createCommandEncoder(); | |
| const pass = enc.beginComputePass(); | |
| this._dispatch(pass, this.pipeRaycast, | |
| this._bg(this.pipeRaycast, [ | |
| this.bufRayParams, this.bufPos, this.bufTriIds, this.bufTriDist | |
| ]), this.numTris); | |
| pass.end(); | |
| // Copy results back | |
| enc.copyBufferToBuffer(this.bufTriDist, 0, this.bufTriDistRead, 0, 4*this.numTris); | |
| dev.queue.submit([enc.finish()]); | |
| // Map and find minimum on CPU (much smaller than full position data) | |
| await this.bufTriDistRead.mapAsync(GPUMapMode.READ); | |
| const dists = new Float32Array(this.bufTriDistRead.getMappedRange().slice(0)); | |
| this.bufTriDistRead.unmap(); | |
| let minDist = 1e9, minTri = -1; | |
| for(let t=0; t<this.numTris; t++){ | |
| if(dists[t] < minDist){ minDist = dists[t]; minTri = t; } | |
| } | |
| if(minTri >= 0 && minDist < 1e8){ | |
| const tids = this.hostTriIds; | |
| this.dragParticleNr = tids[3*minTri]; | |
| this.dragDepth = minDist; | |
| this.dragInvMass = this.cpuInvMass[this.dragParticleNr]; | |
| // pin particle - write only single invMass value | |
| const zeroInvMass = new Float32Array([0]); | |
| dev.queue.writeBuffer(this.bufInvMass, this.dragParticleNr * 4, zeroInvMass); | |
| // move particle to drag point - write only single position | |
| const dp = vec3Add(orig, vec3Scale(dir, minDist)); | |
| const singlePos = new Float32Array([dp[0], dp[1], dp[2], 1.0]); | |
| dev.queue.writeBuffer(this.bufPos, this.dragParticleNr * 16, singlePos); | |
| } | |
| } | |
| drag(orig, dir){ | |
| if(this.dragParticleNr < 0) return; | |
| const dp = vec3Add(orig, vec3Scale(dir, this.dragDepth)); | |
| // Write only the single particle position (16 bytes = vec4) | |
| const singlePos = new Float32Array([dp[0], dp[1], dp[2], 1.0]); | |
| this.device.queue.writeBuffer(this.bufPos, this.dragParticleNr * 16, singlePos); | |
| } | |
| endDrag(){ | |
| if(this.dragParticleNr < 0) return; | |
| // Write only the single particle's invMass (4 bytes) | |
| const singleInvMass = new Float32Array([this.dragInvMass]); | |
| this.device.queue.writeBuffer(this.bufInvMass, this.dragParticleNr * 4, singleInvMass); | |
| this.dragParticleNr = -1; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // MAIN | |
| // ═══════════════════════════════════════════════════════════════════ | |
| async function main(){ | |
| const canvas = document.getElementById('c'); | |
| // ── WebGPU init ────────────────────────────────────────────── | |
| if(!navigator.gpu){ | |
| document.getElementById('unsupported').style.display='block'; | |
| return; | |
| } | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if(!adapter){ | |
| document.getElementById('unsupported').style.display='block'; | |
| return; | |
| } | |
| const device = await adapter.requestDevice(); | |
| const context = canvas.getContext('webgpu'); | |
| const fmt = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ device, format:fmt, alphaMode:'opaque' }); | |
| // resize handler | |
| function resize(){ | |
| canvas.width = canvas.clientWidth * devicePixelRatio; | |
| canvas.height = canvas.clientHeight * devicePixelRatio; | |
| context.configure({ device, format:fmt, alphaMode:'opaque' }); | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| // ── Create simulation ──────────────────────────────────────── | |
| const cloth = new ClothSim(device, | |
| CLOTH_NUM_X, CLOTH_NUM_Y, CLOTH_Y, CLOTH_SPACING, | |
| SPHERE_CENTER, SPHERE_RADIUS); | |
| // ── Camera ─────────────────────────────────────────────────── | |
| const camera = new Camera(); | |
| // ── Camera uniform buffer ───────────────────────────────────── | |
| const bufCam = device.createBuffer({ | |
| size:80, usage: GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST | |
| }); | |
| function updateCamBuffer(){ | |
| const aspect = canvas.width / canvas.height; | |
| const vp = camera.getViewProj(aspect); | |
| const data = new Float32Array(20); | |
| data.set(vp, 0); | |
| data[16]=camera.pos[0]; data[17]=camera.pos[1]; | |
| data[18]=camera.pos[2]; data[19]=1; | |
| device.queue.writeBuffer(bufCam, 0, data); | |
| } | |
| // ── Render pipelines ───────────────────────────────────────── | |
| const depthFmt = 'depth24plus'; | |
| function makeRenderPipeline(vertId, fragId, bufLayouts, primitive={}){ | |
| return device.createRenderPipeline({ | |
| layout: 'auto', | |
| vertex:{ | |
| module: device.createShaderModule({code: wgsl(vertId)}), | |
| entryPoint: 'main', | |
| buffers: bufLayouts | |
| }, | |
| fragment:{ | |
| module: device.createShaderModule({code: wgsl(fragId)}), | |
| entryPoint: 'main', | |
| targets:[{ format: fmt }] | |
| }, | |
| depthStencil:{ format:depthFmt, depthWriteEnabled:true, depthCompare:'less' }, | |
| primitive:{ topology:'triangle-list', cullMode:'none', ...primitive } | |
| }); | |
| } | |
| // Cloth: pos (vec3) + normal (vec3) — both from storage buffers used as vertex buffers | |
| const clothBufLayout = [ | |
| { arrayStride:16, attributes:[{shaderLocation:0, offset:0, format:'float32x3'}] }, | |
| { arrayStride:16, attributes:[{shaderLocation:1, offset:0, format:'float32x3'}] } | |
| ]; | |
| const pipeCloth = makeRenderPipeline('sh-vert','sh-frag-cloth', clothBufLayout); | |
| const pipeClothWire = makeRenderPipeline('sh-vert','sh-frag-wire', clothBufLayout, {topology:'line-list'}); | |
| // Solid (sphere + ground): interleaved pos(12)+norm(12)+col(12) = 36 bytes stride | |
| const solidBufLayout = [{ | |
| arrayStride: 36, | |
| attributes:[ | |
| {shaderLocation:0, offset: 0, format:'float32x3'}, | |
| {shaderLocation:1, offset:12, format:'float32x3'}, | |
| {shaderLocation:2, offset:24, format:'float32x3'}, | |
| ] | |
| }]; | |
| const pipeSolid = makeRenderPipeline('sh-vert-solid','sh-frag-solid', solidBufLayout); | |
| // ── Build static geometry ──────────────────────────────────── | |
| function buildInterleavedGeo(geo){ | |
| const n = geo.verts.length/3; | |
| const data = new Float32Array(n*9); | |
| for(let i=0;i<n;i++){ | |
| data[9*i+0]=geo.verts[3*i]; data[9*i+1]=geo.verts[3*i+1]; data[9*i+2]=geo.verts[3*i+2]; | |
| data[9*i+3]=geo.norms[3*i]; data[9*i+4]=geo.norms[3*i+1]; data[9*i+5]=geo.norms[3*i+2]; | |
| data[9*i+6]=geo.cols[3*i]; data[9*i+7]=geo.cols[3*i+1]; data[9*i+8]=geo.cols[3*i+2]; | |
| } | |
| return { | |
| vbuf: makeBuffer(device, data, GPUBufferUsage.VERTEX), | |
| ibuf: makeBuffer(device, geo.idx, GPUBufferUsage.INDEX), | |
| count: geo.idx.length | |
| }; | |
| } | |
| const sphereGeo = buildInterleavedGeo( | |
| buildSphere(SPHERE_CENTER[0],SPHERE_CENTER[1],SPHERE_CENTER[2],SPHERE_RADIUS,30,30)); | |
| const groundGeo = buildInterleavedGeo(buildGround()); | |
| // ── Depth texture ───────────────────────────────────────────── | |
| let depthTex = null; | |
| function ensureDepth(){ | |
| if(depthTex && depthTex.width===canvas.width && depthTex.height===canvas.height) return; | |
| if(depthTex) depthTex.destroy(); | |
| depthTex = device.createTexture({ | |
| size:[canvas.width, canvas.height], | |
| format: depthFmt, | |
| usage: GPUTextureUsage.RENDER_ATTACHMENT | |
| }); | |
| } | |
| // ── Render bind groups ──────────────────────────────────────── | |
| function bgForPipeline(pipeline, ...buffers){ | |
| return device.createBindGroup({ | |
| layout: pipeline.getBindGroupLayout(0), | |
| entries: buffers.map((b,i)=>({ binding:i, resource:{buffer:b} })) | |
| }); | |
| } | |
| // ── Mouse / keyboard ───────────────────────────────────────── | |
| let mouseBtn=-1, mouseX=0, mouseY=0, shiftDown=false; | |
| canvas.addEventListener('mousedown', e=>{ | |
| mouseBtn=e.button; mouseX=e.clientX; mouseY=e.clientY; | |
| shiftDown=e.shiftKey; | |
| if(shiftDown){ | |
| const ray=getMouseRay(e.clientX,e.clientY); | |
| cloth.startDrag(ray.orig, ray.dir).then(()=>{ paused=false; }); | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', e=>{ | |
| if(e.shiftKey || shiftDown) cloth.endDrag(); | |
| mouseBtn=-1; shiftDown=false; | |
| }); | |
| canvas.addEventListener('mousemove', e=>{ | |
| const dx=e.clientX-mouseX, dy=e.clientY-mouseY; | |
| mouseX=e.clientX; mouseY=e.clientY; | |
| if(shiftDown){ | |
| const ray=getMouseRay(e.clientX,e.clientY); | |
| cloth.drag(ray.orig, ray.dir); | |
| } else { | |
| if(mouseBtn===0) camera.mouseLook(dx,dy); | |
| else if(mouseBtn===1) camera.mousePan(dx,dy); | |
| else if(mouseBtn===2) camera.mouseOrbit(dx,dy,[0,1,0]); | |
| } | |
| }); | |
| canvas.addEventListener('wheel', e=>{ camera.wheel(-e.deltaY*0.01); e.preventDefault(); },{passive:false}); | |
| canvas.addEventListener('contextmenu',e=>e.preventDefault()); | |
| document.addEventListener('keydown', e=>{ | |
| camera.keys[e.key.toLowerCase()]=true; | |
| switch(e.key.toLowerCase()){ | |
| case 'p': paused=!paused; break; | |
| case 'h': hidden=!hidden; break; | |
| case 'c': solveType=0; break; | |
| case 'j': solveType=1; break; | |
| case 'r': cloth.reset(); break; | |
| } | |
| }); | |
| document.addEventListener('keyup', e=>{ camera.keys[e.key.toLowerCase()]=false; }); | |
| function getMouseRay(cx, cy){ | |
| const rect = canvas.getBoundingClientRect(); | |
| const px = (cx - rect.left) / rect.width; | |
| const py = (cy - rect.top) / rect.height; | |
| const ndcX = 2*px - 1; | |
| const ndcY = -2*py + 1; | |
| const aspect = canvas.width/canvas.height; | |
| const fovY = 40 * Math.PI/180; | |
| const tanH = Math.tan(fovY*0.5); | |
| // ray in view space | |
| const rdView = vec3Norm([ndcX*tanH*aspect, ndcY*tanH, -1]); | |
| // to world space | |
| const f = vec3Norm(camera.forward); | |
| const r = vec3Norm(camera.right); | |
| const u = vec3Norm(camera.up); | |
| const rdWorld = [ | |
| r[0]*rdView[0] + u[0]*rdView[1] + (-f[0])*rdView[2], | |
| r[1]*rdView[0] + u[1]*rdView[1] + (-f[1])*rdView[2], | |
| r[2]*rdView[0] + u[2]*rdView[1] + (-f[2])*rdView[2], | |
| ]; | |
| return { orig: camera.pos.slice(), dir: vec3Norm(rdWorld) }; | |
| } | |
| // ── Render loop ─────────────────────────────────────────────── | |
| function frame(){ | |
| requestAnimationFrame(frame); | |
| frameNr++; | |
| const now = performance.now(); | |
| // FPS | |
| if(frameNr % 30 === 0){ | |
| const fps = Math.floor(30000 / (now - prevTime)); | |
| document.title = `Cloth WebGPU — ${fps} fps [${solveType===0?'Coloring':'Jacobi'}]`; | |
| prevTime = now; | |
| } | |
| camera.handleKeys(); | |
| if(!paused) cloth.simulate(solveType); | |
| cloth.updateNormals(); | |
| // resize depth if needed | |
| ensureDepth(); | |
| updateCamBuffer(); | |
| const colorView = context.getCurrentTexture().createView(); | |
| const depthView = depthTex.createView(); | |
| const enc = device.createCommandEncoder(); | |
| const pass = enc.beginRenderPass({ | |
| colorAttachments:[{ | |
| view: colorView, | |
| clearValue: {r:0,g:0,b:0,a:1}, | |
| loadOp: 'clear', | |
| storeOp: 'store' | |
| }], | |
| depthStencilAttachment:{ | |
| view: depthView, | |
| depthClearValue: 1, | |
| depthLoadOp: 'clear', | |
| depthStoreOp: 'store' | |
| } | |
| }); | |
| // ground + sphere | |
| pass.setPipeline(pipeSolid); | |
| pass.setBindGroup(0, bgForPipeline(pipeSolid, bufCam)); | |
| pass.setVertexBuffer(0, groundGeo.vbuf); | |
| pass.setIndexBuffer(groundGeo.ibuf, 'uint32'); | |
| pass.drawIndexed(groundGeo.count); | |
| pass.setVertexBuffer(0, sphereGeo.vbuf); | |
| pass.setIndexBuffer(sphereGeo.ibuf, 'uint32'); | |
| pass.drawIndexed(sphereGeo.count); | |
| // cloth (wireframe) | |
| if(!hidden){ | |
| pass.setPipeline(pipeClothWire); | |
| pass.setBindGroup(0, bgForPipeline(pipeClothWire, bufCam)); | |
| pass.setVertexBuffer(0, cloth.bufPos); | |
| pass.setVertexBuffer(1, cloth.bufNormals); | |
| pass.setIndexBuffer(cloth.bufEdgeIds, 'uint32'); | |
| pass.drawIndexed(cloth.numEdges * 2); | |
| } | |
| pass.end(); | |
| device.queue.submit([enc.finish()]); | |
| } | |
| document.getElementById('loading').style.display = 'none'; | |
| requestAnimationFrame(frame); | |
| } | |
| main().catch(err => { | |
| console.error(err); | |
| document.getElementById('loading').style.display = 'none'; | |
| document.getElementById('unsupported').style.display='block'; | |
| document.getElementById('unsupported').textContent = 'Error: ' + err.message; | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment