f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 1// WebGPU rendering of the displacement field: a 2D color map or a lit 3D height field.
3import { type Grid, type PhysParams } from "../params.ts";
4import { lookAt, multiply, perspective } from "./mat4.ts";
5import shaderCode from "./shaders.wgsl?raw";
7export type ViewMode = "2d" | "3d";
9export class Renderer {
10 mode: ViewMode = "2d";
11 /** Vertical exaggeration of the 3D view. */
12 exaggeration = 4;
13 /** Color range |w| [m]; 0 means automatic (from the current maximum). */
14 colorRange = 0;
15 camera = { yaw: 0.6, pitch: 0.5, dist: 1.0 };
17 private device: GPUDevice;
18 private canvas: HTMLCanvasElement;
19 private ctx: GPUCanvasContext;
20 private format: GPUTextureFormat;
21 private uniform: GPUBuffer;
22 private pipe2d: GPURenderPipeline;
23 private pipe3d: GPURenderPipeline;
24 private bgLayout: GPUBindGroupLayout;
25 private index: GPUBuffer;
26 private indexCount: number;
27 private depth: GPUTexture | null = null;
28 private grid: Grid;
29 private params: PhysParams;
30 private bindGroups = new Map<GPUBuffer, GPUBindGroup>();
32 constructor(device: GPUDevice, canvas: HTMLCanvasElement, grid: Grid, params: PhysParams) {
33 this.device = device;
34 this.canvas = canvas;
35 this.grid = grid;
36 this.params = params;
37 this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
38 this.format = navigator.gpu.getPreferredCanvasFormat();
39 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
40 this.uniform = device.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
41 const module = device.createShaderModule({ code: shaderCode });
42 this.bgLayout = device.createBindGroupLayout({
43 entries: [
44 { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
45 { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
46 ],
47 });
48 const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] });
49 this.pipe2d = device.createRenderPipeline({
50 layout,
51 vertex: { module, entryPoint: "vs2d" },
52 fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
53 primitive: { topology: "triangle-list" },
54 });
55 this.pipe3d = device.createRenderPipeline({
56 layout,
57 vertex: { module, entryPoint: "vs3d" },
58 fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
59 primitive: { topology: "triangle-list", cullMode: "none" },
60 depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
61 });
62 // Triangle indices of the height field.
63 const { nx, ny } = grid;
64 const idx = new Uint32Array((nx - 1) * (ny - 1) * 6);
65 let k = 0;
66 for (let j = 0; j < ny - 1; j++) {
67 for (let i = 0; i < nx - 1; i++) {
68 const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
69 idx[k++] = a; idx[k++] = b; idx[k++] = d;
70 idx[k++] = a; idx[k++] = d; idx[k++] = c;
71 }
72 }
73 this.indexCount = idx.length;
74 this.index = device.createBuffer({ size: idx.byteLength, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
75 device.queue.writeBuffer(this.index, 0, idx);
76 this.installControls();
77 }
79 private installControls(): void {
80 let dragging = false, lastX = 0, lastY = 0;
81 this.canvas.addEventListener("pointerdown", (e) => {
82 dragging = true; lastX = e.clientX; lastY = e.clientY;
83 this.canvas.setPointerCapture(e.pointerId);
84 });
85 this.canvas.addEventListener("pointerup", () => (dragging = false));
86 this.canvas.addEventListener("pointermove", (e) => {
87 if (!dragging || this.mode !== "3d") return;
88 this.camera.yaw += (e.clientX - lastX) * 0.01;
89 this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01));
90 lastX = e.clientX; lastY = e.clientY;
91 });
92 this.canvas.addEventListener("wheel", (e) => {
93 if (this.mode !== "3d") return;
94 e.preventDefault();
95 this.camera.dist *= Math.exp(e.deltaY * 0.001);
96 this.camera.dist = Math.max(0.2, Math.min(5, this.camera.dist));
97 }, { passive: false });
98 }
100 private resize(): void {
101 const dpr = window.devicePixelRatio || 1;
102 const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
103 const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
104 if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) {
105 this.canvas.width = w;
106 this.canvas.height = h;
107 this.depth?.destroy();
108 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
109 }
110 }
112 /** Renders the w field held in `wBuffer` (f32 per node). `wmax` drives the automatic color range. */
113 render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void {
114 this.resize();
115 const aspect = this.canvas.width / this.canvas.height;
116 const range = this.colorRange > 0 ? this.colorRange : Math.max(wmax, 1e-6);
117 const u = new Float32Array(32);
118 const ui = new Uint32Array(u.buffer);
119 const eye = [
120 this.camera.dist * Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
121 this.camera.dist * Math.sin(this.camera.pitch),
122 this.camera.dist * Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw),
123 ];
124 const mvp = multiply(perspective(0.7, aspect, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 1, 0]));
125 u.set(mvp, 0);
126 ui[16] = this.grid.nx; ui[17] = this.grid.ny;
127 u[18] = this.grid.hx; u[19] = this.grid.hy;
128 u[20] = this.exaggeration; u[21] = range; u[22] = aspect;
129 ui[23] = this.mode === "3d" ? 1 : 0;
130 const p = this.params;
131 u[24] = -p.Lx / 2 + p.holeInset; u[25] = p.Ly / 2 - p.holeInset;
132 u[26] = p.Lx / 2; u[27] = -p.Ly / 2;
133 u[28] = p.fingerRadius;
134 u[29] = 0.4; u[30] = 0.5; u[31] = 1.0;
135 this.device.queue.writeBuffer(this.uniform, 0, u);
137 let bg = this.bindGroups.get(wBuffer);
138 if (!bg) {
139 bg = this.device.createBindGroup({
140 layout: this.bgLayout,
141 entries: [
142 { binding: 0, resource: { buffer: this.uniform } },
143 { binding: 1, resource: { buffer: wBuffer } },
144 ],
145 });
146 this.bindGroups.set(wBuffer, bg);
147 }
148 const view = this.ctx.getCurrentTexture().createView();
149 const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 };
150 if (this.mode === "2d") {
151 const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] });
152 pass.setPipeline(this.pipe2d);
153 pass.setBindGroup(0, bg);
154 pass.draw(3);
155 pass.end();
156 } else {
157 const pass = encoder.beginRenderPass({
158 colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }],
159 depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
160 });
161 pass.setPipeline(this.pipe3d);
162 pass.setBindGroup(0, bg);
163 pass.setIndexBuffer(this.index, "uint32");
164 pass.drawIndexed(this.indexCount);
165 pass.end();
166 }
167 }
168}