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