1// WebGPU rendering of the displacement field: a 2D color map or a lit 3D height field.
3import { type Grid, type PhysParams, holePoint, insideCell, pushPoint, strikePoint } 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 /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
16 showShape = true;
17 camera = { yaw: 0.6, pitch: 0.5, dist: 1.0 };
19 private device: GPUDevice;
20 private canvas: HTMLCanvasElement;
21 private ctx: GPUCanvasContext;
22 private format: GPUTextureFormat;
23 private uniform: GPUBuffer;
24 private pipe2d: GPURenderPipeline;
25 private pipe3d: GPURenderPipeline;
26 private bgLayout: GPUBindGroupLayout;
27 private index: GPUBuffer | null = null;
28 private indexCount = 0;
29 private depth: GPUTexture | null = null;
30 private grid: Grid | null = null;
31 private params: PhysParams;
32 private bindGroups = new Map<GPUBuffer, GPUBindGroup>();
34 private w0: GPUBuffer | null = null;
35 private w0Max = 0;
36 private nw: GPUBuffer | null = null;
38 /** The grid of the field currently set, or null before setField. */
39 get fieldGrid(): Grid | null {
40 return this.grid;
41 }
43 /** The renderer draws nothing until setField gives it a grid; `params` is read at every render for the markers. */
44 constructor(device: GPUDevice, canvas: HTMLCanvasElement, params: PhysParams) {
45 this.device = device;
46 this.canvas = canvas;
47 this.params = params;
48 this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
49 this.format = navigator.gpu.getPreferredCanvasFormat();
50 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
51 this.uniform = device.createBuffer({ size: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
52 const module = device.createShaderModule({ code: shaderCode });
53 this.bgLayout = device.createBindGroupLayout({
54 entries: [
55 { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
56 { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
57 { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
58 { binding: 3, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
59 ],
60 });
61 const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] });
62 this.pipe2d = device.createRenderPipeline({
63 layout,
64 vertex: { module, entryPoint: "vs2d" },
65 fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
66 primitive: { topology: "triangle-list" },
67 });
68 this.pipe3d = device.createRenderPipeline({
69 layout,
70 vertex: { module, entryPoint: "vs3d" },
71 fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
72 primitive: { topology: "triangle-list", cullMode: "none" },
73 depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
74 });
75 this.installControls();
76 }
78 /**
79 * Sets the grid and the per-node buffers the views read: the stress-free
80 * shape (added to the displayed field when showShape is on; w0Max is its
81 * largest magnitude, for the automatic color range) and the node weights
82 * (zero outside the sheet). Called at every Start and for the preview of
83 * the initial shape; the camera and the canvas controls persist.
84 */
85 setField(grid: Grid, w0: GPUBuffer, nw: GPUBuffer, w0Max = 0): void {
86 this.grid = grid;
87 this.w0 = w0;
88 this.w0Max = w0Max;
89 this.nw = nw;
90 this.bindGroups.clear();
91 // Triangle indices of the height field (cells of the sheet only).
92 const { nx, ny } = grid;
93 const idx = new Uint32Array((nx - 1) * (ny - 1) * 6);
94 let k = 0;
95 for (let j = 0; j < ny - 1; j++) {
96 for (let i = 0; i < nx - 1; i++) {
97 if (!insideCell(grid, i, j)) continue;
98 const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
99 idx[k++] = a; idx[k++] = b; idx[k++] = d;
100 idx[k++] = a; idx[k++] = d; idx[k++] = c;
101 }
102 }
103 this.indexCount = k;
104 this.index?.destroy();
105 this.index = this.device.createBuffer({ size: Math.max(16, idx.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
106 this.device.queue.writeBuffer(this.index, 0, idx);
107 }
109 private installControls(): void {
110 let dragging = false, lastX = 0, lastY = 0;
111 this.canvas.addEventListener("pointerdown", (e) => {
112 dragging = true; lastX = e.clientX; lastY = e.clientY;
113 this.canvas.setPointerCapture(e.pointerId);
114 });
115 this.canvas.addEventListener("pointerup", () => (dragging = false));
116 this.canvas.addEventListener("pointermove", (e) => {
117 if (!dragging || this.mode !== "3d") return;
118 this.camera.yaw += (e.clientX - lastX) * 0.01;
119 this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01));
120 lastX = e.clientX; lastY = e.clientY;
121 });
122 this.canvas.addEventListener("wheel", (e) => {
123 if (this.mode !== "3d") return;
124 e.preventDefault();
125 this.camera.dist *= Math.exp(e.deltaY * 0.001);
126 this.camera.dist = Math.max(0.2, Math.min(5, this.camera.dist));
127 }, { passive: false });
128 }
130 private resize(): void {
131 const dpr = window.devicePixelRatio || 1;
132 const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
133 const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
134 if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) {
135 this.canvas.width = w;
136 this.canvas.height = h;
137 this.depth?.destroy();
138 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
139 }
140 }
142 /** Renders the w field held in `wBuffer` (f32 per node). `wmax`, the largest |w|, drives the automatic color range. */
143 render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void {
144 if (!this.grid || !this.w0 || !this.nw || !this.index) return;
145 this.resize();
146 const aspect = this.canvas.width / this.canvas.height;
147 const auto = wmax + (this.showShape ? this.w0Max : 0);
148 const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6);
149 const u = new Float32Array(36);
150 const ui = new Uint32Array(u.buffer);
151 const eye = [
152 this.camera.dist * Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
153 this.camera.dist * Math.sin(this.camera.pitch),
154 this.camera.dist * Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw),
155 ];
156 const mvp = multiply(perspective(0.7, aspect, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 1, 0]));
157 u.set(mvp, 0);
158 ui[16] = this.grid.nx; ui[17] = this.grid.ny;
159 u[18] = this.grid.hx; u[19] = this.grid.hy;
160 u[20] = this.exaggeration; u[21] = range; u[22] = aspect;
161 ui[23] = this.mode === "3d" ? 1 : 0;
162 const p = this.params;
163 [u[24], u[25]] = holePoint(p);
164 if (p.excitation === "strike") {
165 const [sx, sy] = strikePoint(p);
166 u[26] = sx; u[27] = sy; u[28] = p.contactRadius;
167 } else {
168 [u[26], u[27]] = pushPoint(p);
169 u[28] = p.fingerRadius;
170 }
171 u[29] = 0.4; u[30] = 0.5; u[31] = 1.0;
172 ui[32] = this.showShape ? 1 : 0;
173 ui[33] = p.shape === "ellipse" ? 1 : 0;
174 this.device.queue.writeBuffer(this.uniform, 0, u);
176 let bg = this.bindGroups.get(wBuffer);
177 if (!bg) {
178 bg = this.device.createBindGroup({
179 layout: this.bgLayout,
180 entries: [
181 { binding: 0, resource: { buffer: this.uniform } },
182 { binding: 1, resource: { buffer: wBuffer } },
183 { binding: 2, resource: { buffer: this.w0 } },
184 { binding: 3, resource: { buffer: this.nw } },
185 ],
186 });
187 this.bindGroups.set(wBuffer, bg);
188 }
189 const view = this.ctx.getCurrentTexture().createView();
190 const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 };
191 if (this.mode === "2d") {
192 const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] });
193 pass.setPipeline(this.pipe2d);
194 pass.setBindGroup(0, bg);
195 pass.draw(3);
196 pass.end();
197 } else {
198 const pass = encoder.beginRenderPass({
199 colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }],
200 depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
201 });
202 pass.setPipeline(this.pipe3d);
203 pass.setBindGroup(0, bg);
204 pass.setIndexBuffer(this.index, "uint32");
205 pass.drawIndexed(this.indexCount);
206 pass.end();
207 }
208 }
209}