/ proof-of-concept / steelsim41
Sign in
proof-of-concept / steelsim41
steelsim41 / src / render / renderer.ts
299 lines · 13.4 KBBlameHistoryRaw
1// WebGPU rendering of the plate: a 2D color map or a lit 3D height field of
2// the displacement (plus the stress-free shape), colored by the displacement
3// or by the thickness. Both views paint the mount and the strike point on the
4// surface as rings; the 3D view also stands a post at the mount, so that it
5// stays visible when the ring is edge-on or lost on a steep bell, and stands a
6// microphone on the line to the listener.
8import { type Grid, cellMask, insideCell } from "../params.ts";
9import { lookAt, multiply, perspective } from "./mat4.ts";
10import shaderCode from "./shaders.wgsl?raw";
12export type ViewMode = "2d" | "3d";
13export type ColorMode = "field" | "thickness";
15export interface Marker {
16 x: number;
17 y: number;
18 r: number;
21/** Vertices of the mount post: 24 segments of two side triangles, plus a fan over each cap (see shaders.wgsl). */
22const MOUNT_VERTS = 24 * 6 + 2 * 24 * 3;
23/** Vertices of the microphone: a 16 by 24 surface of revolution, plus a fan closing its back (see shaders.wgsl). */
24const MIC_VERTS = 16 * 24 * 6 + 16 * 3;
25/** Its widest radius, as a fraction of the longer side of the plate's bounding box. */
26const MIC_RADIUS_FRACTION = 0.038;
28/**
29 * How the microphone that stands for the listener is drawn, given the true
30 * listener distance and the longer side of the plate's bounding box.
31 *
32 * A listener a meter from a 16 inch cymbal is four times as far away as the
33 * cymbal is wide, so no framing can hold both at true scale; and the plate
34 * already fills most of the view, which leaves only a thin annulus around it
35 * for the microphone to stand in. The drawn distance therefore follows the
36 * true one through a saturating map that stays inside that annulus, and the
37 * microphone shrinks a little as the listener recedes, for the same reason a
38 * distant object looks small. The direction is drawn faithfully; the distance
39 * itself is the field in the Listener group, and it is that field, not this
40 * drawing, that sets the 1/r spreading and the spread of the delays.
41 */
42export function drawnListener(dist: number, maxL: number): { at: number; radius: number } {
43 const f = dist / (dist + maxL); // 0 at the plate, approaching 1 far away
44 return { at: maxL * (0.26 + 0.36 * f), radius: MIC_RADIUS_FRACTION * maxL * (1.25 - 0.5 * f) };
47export class Renderer {
48 mode: ViewMode = "2d";
49 /** Vertical exaggeration of the 3D view. */
50 exaggeration = 1;
51 /** Color range |w| [m]; 0 means automatic (from the current maximum). */
52 colorRange = 0;
53 /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
54 showShape = true;
55 colorMode: ColorMode = "field";
56 /** Orbit about the plate's center: yaw about the vertical, pitch above the plane of the plate. */
57 camera = { yaw: 0.7, pitch: 0.5, dist: 0.95 };
58 /** Markers drawn in both views; read at every render. */
59 strike: Marker = { x: 0, y: 0, r: 0 };
60 mount: Marker | null = null;
61 /** Listener position in the plate's frame (z along the normal); the 3D view stands a microphone on that line. */
62 listener: [number, number, number] | null = null;
64 private device: GPUDevice;
65 private canvas: HTMLCanvasElement;
66 private ctx: GPUCanvasContext;
67 private format: GPUTextureFormat;
68 private uniform: GPUBuffer;
69 private pipe2d: GPURenderPipeline;
70 private pipe3d: GPURenderPipeline;
71 private pipe3dMount: GPURenderPipeline;
72 private pipe3dListener: GPURenderPipeline;
73 private bgLayout: GPUBindGroupLayout;
74 private index: GPUBuffer | null = null;
75 private indexCount = 0;
76 private depth: GPUTexture | null = null;
77 private grid: Grid | null = null;
78 private bindGroups = new Map<GPUBuffer, GPUBindGroup>();
80 private w0: GPUBuffer | null = null;
81 private w0Max = 0;
82 private nw: GPUBuffer | null = null;
83 private hn: GPUBuffer | null = null;
84 private cm: GPUBuffer | null = null;
85 private hRange: [number, number] = [0, 1];
87 /** The grid of the field currently set, or null before setField. */
88 get fieldGrid(): Grid | null {
89 return this.grid;
90 }
92 /** The renderer draws nothing until setField gives it a grid. */
93 constructor(device: GPUDevice, canvas: HTMLCanvasElement) {
94 this.device = device;
95 this.canvas = canvas;
96 this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
97 this.format = navigator.gpu.getPreferredCanvasFormat();
98 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
99 this.uniform = device.createBuffer({ size: 176, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // RU in shaders.wgsl: 44 f32
100 const module = device.createShaderModule({ code: shaderCode });
101 const vis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
102 this.bgLayout = device.createBindGroupLayout({
103 entries: [
104 { binding: 0, visibility: vis, buffer: { type: "uniform" } },
105 { binding: 1, visibility: vis, buffer: { type: "read-only-storage" } },
106 { binding: 2, visibility: vis, buffer: { type: "read-only-storage" } },
107 { binding: 3, visibility: vis, buffer: { type: "read-only-storage" } },
108 { binding: 4, visibility: vis, buffer: { type: "read-only-storage" } },
109 { binding: 5, visibility: vis, buffer: { type: "read-only-storage" } },
110 ],
111 });
112 const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] });
113 this.pipe2d = device.createRenderPipeline({
114 layout,
115 vertex: { module, entryPoint: "vs2d" },
116 fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
117 primitive: { topology: "triangle-list" },
118 });
119 const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
120 this.pipe3d = device.createRenderPipeline({
121 layout,
122 vertex: { module, entryPoint: "vs3d" },
123 fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
124 primitive: { topology: "triangle-list", cullMode: "none" },
125 depthStencil,
126 });
127 this.pipe3dMount = device.createRenderPipeline({
128 layout,
129 vertex: { module, entryPoint: "vs3dMount" },
130 fragment: { module, entryPoint: "fs3dMount", targets: [{ format: this.format }] },
131 primitive: { topology: "triangle-list", cullMode: "none" },
132 depthStencil,
133 });
134 this.pipe3dListener = device.createRenderPipeline({
135 layout,
136 vertex: { module, entryPoint: "vs3dListener" },
137 fragment: { module, entryPoint: "fs3dListener", targets: [{ format: this.format }] },
138 primitive: { topology: "triangle-list", cullMode: "none" },
139 depthStencil,
140 });
141 this.installControls();
142 }
144 /**
145 * Sets the grid and the per-node buffers the views read: the stress-free
146 * shape (added to the displayed field when showShape is on; w0Max is its
147 * largest magnitude, for the automatic color range), the node weights (zero
148 * outside the plate), and the thickness with its range for the thickness
149 * coloring. Called at every Start and for the preview of the design; the
150 * camera and the canvas controls persist.
151 */
152 setField(grid: Grid, w0: GPUBuffer, nw: GPUBuffer, hn: GPUBuffer, w0Max: number, hRange: [number, number]): void {
153 this.grid = grid;
154 this.w0 = w0;
155 this.w0Max = w0Max;
156 this.nw = nw;
157 this.hn = hn;
158 this.hRange = hRange;
159 this.bindGroups.clear();
160 const cmData = cellMask(grid);
161 this.cm?.destroy();
162 this.cm = this.device.createBuffer({ size: Math.max(16, cmData.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
163 this.device.queue.writeBuffer(this.cm, 0, cmData);
164 // Triangle indices of the height field (cells of the plate only).
165 const { nx, ny } = grid;
166 const idx = new Uint32Array((nx - 1) * (ny - 1) * 6);
167 let k = 0;
168 for (let j = 0; j < ny - 1; j++) {
169 for (let i = 0; i < nx - 1; i++) {
170 if (!insideCell(grid, i, j)) continue;
171 const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
172 idx[k++] = a; idx[k++] = b; idx[k++] = d;
173 idx[k++] = a; idx[k++] = d; idx[k++] = c;
174 }
175 }
176 this.indexCount = k;
177 this.index?.destroy();
178 this.index = this.device.createBuffer({ size: Math.max(16, idx.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
179 this.device.queue.writeBuffer(this.index, 0, idx);
180 }
182 private installControls(): void {
183 let dragging = false, lastX = 0, lastY = 0;
184 this.canvas.addEventListener("pointerdown", (e) => {
185 dragging = true; lastX = e.clientX; lastY = e.clientY;
186 this.canvas.setPointerCapture(e.pointerId);
187 });
188 this.canvas.addEventListener("pointerup", () => (dragging = false));
189 this.canvas.addEventListener("pointermove", (e) => {
190 if (!dragging || this.mode !== "3d") return;
191 this.camera.yaw += (e.clientX - lastX) * 0.01;
192 this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01));
193 lastX = e.clientX; lastY = e.clientY;
194 });
195 this.canvas.addEventListener("wheel", (e) => {
196 if (this.mode !== "3d") return;
197 e.preventDefault();
198 this.camera.dist *= Math.exp(e.deltaY * 0.001);
199 this.camera.dist = Math.max(0.2, Math.min(5, this.camera.dist));
200 }, { passive: false });
201 }
203 private resize(): void {
204 const dpr = window.devicePixelRatio || 1;
205 const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
206 const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
207 if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) {
208 this.canvas.width = w;
209 this.canvas.height = h;
210 this.depth?.destroy();
211 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
212 }
213 }
215 /** Renders the w field held in `wBuffer` (f32 per node). `wmax`, the largest |w|, drives the automatic color range. */
216 render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void {
217 if (!this.grid || !this.w0 || !this.nw || !this.hn || !this.cm || !this.index) return;
218 this.resize();
219 const aspect = this.canvas.width / this.canvas.height;
220 const auto = wmax + (this.showShape ? this.w0Max : 0);
221 const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6);
222 const u = new Float32Array(44);
223 const ui = new Uint32Array(u.buffer);
224 const eye = [
225 this.camera.dist * Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
226 this.camera.dist * Math.sin(this.camera.pitch),
227 this.camera.dist * Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw),
228 ];
229 const mvp = multiply(perspective(0.7, aspect, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 1, 0]));
230 u.set(mvp, 0);
231 ui[16] = this.grid.nx; ui[17] = this.grid.ny;
232 u[18] = this.grid.hx; u[19] = this.grid.hy;
233 u[20] = this.exaggeration; u[21] = range; u[22] = aspect;
234 ui[23] = this.mode === "3d" ? 1 : 0;
235 u[24] = this.strike.x; u[25] = this.strike.y; u[26] = this.strike.r;
236 u[27] = this.mount?.x ?? 0; u[28] = this.mount?.y ?? 0; u[29] = this.mount?.r ?? 0;
237 u[30] = 0.35; u[31] = 0.85; u[32] = 0.4; // light, from above and to one side
238 ui[33] = this.showShape ? 1 : 0;
239 ui[34] = this.colorMode === "thickness" ? 1 : 0;
240 ui[35] = this.mount ? 1 : 0;
241 u[36] = this.hRange[0]; u[37] = this.hRange[1];
242 // Only the direction of the listener is drawn; its distance is far outside the framing of the plate.
243 const L = this.listener;
244 const Ln = L ? Math.hypot(L[0], L[1], L[2]) : 0;
245 const drawListener = !!L && Ln > 0;
246 if (drawListener) { u[38] = L![0] / Ln; u[39] = L![1] / Ln; u[40] = L![2] / Ln; }
247 const mic = drawnListener(Ln, Math.max((this.grid.nx - 1) * this.grid.hx, (this.grid.ny - 1) * this.grid.hy));
248 u[41] = mic.at;
249 u[42] = mic.radius;
250 ui[43] = drawListener ? 1 : 0;
251 this.device.queue.writeBuffer(this.uniform, 0, u);
253 let bg = this.bindGroups.get(wBuffer);
254 if (!bg) {
255 bg = this.device.createBindGroup({
256 layout: this.bgLayout,
257 entries: [
258 { binding: 0, resource: { buffer: this.uniform } },
259 { binding: 1, resource: { buffer: wBuffer } },
260 { binding: 2, resource: { buffer: this.w0 } },
261 { binding: 3, resource: { buffer: this.nw } },
262 { binding: 4, resource: { buffer: this.hn } },
263 { binding: 5, resource: { buffer: this.cm } },
264 ],
265 });
266 this.bindGroups.set(wBuffer, bg);
267 }
268 const view = this.ctx.getCurrentTexture().createView();
269 const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 };
270 if (this.mode === "2d") {
271 const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] });
272 pass.setPipeline(this.pipe2d);
273 pass.setBindGroup(0, bg);
274 pass.draw(3);
275 pass.end();
276 } else {
277 const pass = encoder.beginRenderPass({
278 colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }],
279 depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
280 });
281 pass.setPipeline(this.pipe3d);
282 pass.setBindGroup(0, bg);
283 pass.setIndexBuffer(this.index, "uint32");
284 pass.drawIndexed(this.indexCount);
285 if (this.mount) {
286 // The post at the mount; the ring on the surface is painted by fs3d.
287 pass.setPipeline(this.pipe3dMount);
288 pass.setBindGroup(0, bg);
289 pass.draw(MOUNT_VERTS);
290 }
291 if (drawListener) {
292 pass.setPipeline(this.pipe3dListener);
293 pass.setBindGroup(0, bg);
294 pass.draw(MIC_VERTS);
295 }
296 pass.end();
297 }
298 }
↑↓ move↵openescclose