Skip to content

Instantly share code, notes, and snippets.

@PAMinerva
Last active March 1, 2026 08:28
Show Gist options
  • Select an option

  • Save PAMinerva/29ad2f1c05d48e0c9d0a7c4be83858e1 to your computer and use it in GitHub Desktop.

Select an option

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
<!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