/ concept-collection / acoustic-scattering-2d
Sign in
concept-collection / acoustic-scattering-2d
acoustic-scattering-2d / src / render / field.ts
288 lines · 9.0 KBBlameHistoryRaw
1/**
2 * Drawing the pressure field, straight out of the buffer the solver wrote.
3 *
4 * There is no readback in the display path: the fragment shader reads the
5 * solver's storage buffer directly, so a frame costs one draw call and no
6 * GPU->CPU round trip. (The host does read the field back occasionally, but
7 * only to decide the colour scale — see the autoscale in main.ts.)
8 *
9 * Two things are drawn at once. The pressure goes through a diverging
10 * colormap about zero, symmetric because the field is signed and a wave is
11 * not more interesting on one side of zero than the other. The medium is
12 * blended in underneath as a grey wash proportional to how far the local
13 * sound speed departs from the background, which shows a hard scatterer as a
14 * distinct shape and a smooth one as a soft cloud without either needing its
15 * own kind of drawing.
16 *
17 * Sampling is bilinear rather than nearest, so a 256-point grid on a 700-pixel
18 * canvas looks like a wave rather than like a grid.
19 */
20import type { ColormapFunc } from './colormaps.ts';
22const SHADER = `
23struct View {
24 nx: u32,
25 ny: u32,
26 scale: f32,
27 cref: f32,
28 cdev: f32,
29 medium: f32,
30 micX: f32,
31 micY: f32,
32 micOn: f32,
33};
35@group(0) @binding(0) var<uniform> view: View;
36@group(0) @binding(1) var<storage, read> field: array<f32>;
37@group(0) @binding(2) var<storage, read> speed: array<f32>;
38@group(0) @binding(3) var cmap: texture_2d<f32>;
39@group(0) @binding(4) var cmapSampler: sampler;
41struct VSOut {
42 @builtin(position) pos: vec4f,
43 @location(0) uv: vec2f,
44};
46@vertex
47fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
48 // One oversized triangle covering the viewport.
49 var xy = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
50 var out: VSOut;
51 let p = xy[vi];
52 out.pos = vec4f(p, 0.0, 1.0);
53 // uv (0,0) at the top-left corner of the domain.
54 out.uv = vec2f((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5);
55 return out;
58fn idx(ix: i32, iy: i32) -> u32 {
59 let cx = clamp(ix, 0, i32(view.nx) - 1);
60 let cy = clamp(iy, 0, i32(view.ny) - 1);
61 return u32(cx + i32(view.nx) * cy);
64fn bilinear(gx: f32, gy: f32, isField: bool) -> f32 {
65 let x0 = i32(floor(gx));
66 let y0 = i32(floor(gy));
67 let fx = gx - f32(x0);
68 let fy = gy - f32(y0);
69 var v00: f32; var v10: f32; var v01: f32; var v11: f32;
70 if (isField) {
71 v00 = field[idx(x0, y0)];
72 v10 = field[idx(x0 + 1, y0)];
73 v01 = field[idx(x0, y0 + 1)];
74 v11 = field[idx(x0 + 1, y0 + 1)];
75 } else {
76 v00 = speed[idx(x0, y0)];
77 v10 = speed[idx(x0 + 1, y0)];
78 v01 = speed[idx(x0, y0 + 1)];
79 v11 = speed[idx(x0 + 1, y0 + 1)];
80 }
81 return mix(mix(v00, v10, fx), mix(v01, v11, fx), fy);
84@fragment
85fn fs(in: VSOut) -> @location(0) vec4f {
86 // Row 0 of the buffer is the bottom of the picture: y increases upward in
87 // the grid, downward on the screen.
88 let gx = in.uv.x * f32(view.nx) - 0.5;
89 let gy = (1.0 - in.uv.y) * f32(view.ny) - 0.5;
91 let p = bilinear(gx, gy, true);
92 let t = clamp(0.5 + 0.5 * p / max(view.scale, 1e-20), 0.0, 1.0);
93 var rgb = textureSample(cmap, cmapSampler, vec2f(t, 0.5)).rgb;
95 if (view.medium > 0.0) {
96 let c = bilinear(gx, gy, false);
97 let m = clamp(abs(c - view.cref) / max(view.cdev, 1e-20), 0.0, 1.0);
98 rgb = mix(rgb, vec3f(0.35, 0.35, 0.38), view.medium * m);
99 }
101 // The microphone, as a ring sized in grid cells so it stays the same size
102 // on screen whatever the canvas is scaled to.
103 if (view.micOn > 0.0) {
104 let d = length(vec2f(gx - view.micX, gy - view.micY));
105 let r = 0.022 * f32(view.nx);
106 let w = 0.005 * f32(view.nx);
107 if (abs(d - r) < w) {
108 rgb = mix(rgb, vec3f(1.0, 1.0, 1.0), 0.9);
109 } else if (d < 0.006 * f32(view.nx)) {
110 rgb = mix(rgb, vec3f(1.0, 1.0, 1.0), 0.9);
111 }
112 }
113 return vec4f(rgb, 1.0);
115`;
117export interface FieldViewOptions {
118 device: GPUDevice;
119 canvas: HTMLCanvasElement;
120 nx: number;
121 ny: number;
124export class FieldView {
125 readonly canvas: HTMLCanvasElement;
127 #device: GPUDevice;
128 #context: GPUCanvasContext;
129 #pipeline: GPURenderPipeline;
130 #layout: GPUBindGroupLayout;
131 #uniform: GPUBuffer;
132 #uniformData = new ArrayBuffer(48);
133 #sampler: GPUSampler;
134 #cmapTexture: GPUTexture;
135 #bindGroup: GPUBindGroup | null = null;
136 #nx: number;
137 #ny: number;
139 constructor(opts: FieldViewOptions) {
140 const { device, canvas } = opts;
141 this.#device = device;
142 this.canvas = canvas;
143 this.#nx = opts.nx;
144 this.#ny = opts.ny;
146 const context = canvas.getContext('webgpu');
147 if (!context) throw new Error('this canvas has no WebGPU context');
148 this.#context = context;
149 const format = navigator.gpu.getPreferredCanvasFormat();
150 context.configure({ device, format, alphaMode: 'opaque' });
152 this.#layout = device.createBindGroupLayout({
153 label: 'field-view',
154 entries: [
155 { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
156 {
157 binding: 1,
158 visibility: GPUShaderStage.FRAGMENT,
159 buffer: { type: 'read-only-storage' },
160 },
161 {
162 binding: 2,
163 visibility: GPUShaderStage.FRAGMENT,
164 buffer: { type: 'read-only-storage' },
165 },
166 { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
167 { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
168 ],
169 });
171 const module = device.createShaderModule({ code: SHADER, label: 'field-view' });
172 this.#pipeline = device.createRenderPipeline({
173 label: 'field-view',
174 layout: device.createPipelineLayout({ bindGroupLayouts: [this.#layout] }),
175 vertex: { module, entryPoint: 'vs' },
176 fragment: { module, entryPoint: 'fs', targets: [{ format }] },
177 primitive: { topology: 'triangle-list' },
178 });
180 this.#uniform = device.createBuffer({
181 label: 'field-view-uniform',
182 size: this.#uniformData.byteLength,
183 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
184 });
185 this.#sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
186 this.#cmapTexture = device.createTexture({
187 label: 'colormap',
188 size: [256, 1],
189 format: 'rgba8unorm',
190 usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
191 });
192 }
194 /** Point the view at the buffers of a (new) simulation. */
195 setSource(pressure: GPUBuffer, speed: GPUBuffer, nx: number, ny: number): void {
196 this.#nx = nx;
197 this.#ny = ny;
198 this.#bindGroup = this.#device.createBindGroup({
199 layout: this.#layout,
200 entries: [
201 { binding: 0, resource: { buffer: this.#uniform } },
202 { binding: 1, resource: { buffer: pressure } },
203 { binding: 2, resource: { buffer: speed } },
204 { binding: 3, resource: this.#cmapTexture.createView() },
205 { binding: 4, resource: this.#sampler },
206 ],
207 });
208 }
210 setColormap(cmap: ColormapFunc): void {
211 const data = new Uint8Array(256 * 4);
212 for (let i = 0; i < 256; i++) {
213 const [r, g, b] = cmap(i / 255);
214 data[4 * i] = r;
215 data[4 * i + 1] = g;
216 data[4 * i + 2] = b;
217 data[4 * i + 3] = 255;
218 }
219 this.#device.queue.writeTexture(
220 { texture: this.#cmapTexture },
221 data,
222 { bytesPerRow: 256 * 4 },
223 { width: 256, height: 1 },
224 );
225 }
227 /**
228 * Draw one frame. `scale` is the pressure the colormap saturates at, in
229 * both directions; `cref`/`cdev` describe the medium wash, and `medium` is
230 * how strongly to apply it (0 turns it off).
231 */
232 draw(opts: {
233 scale: number;
234 cref: number;
235 cdev: number;
236 medium: number;
237 /** Microphone position, in grid-index coordinates. */
238 mic?: { ix: number; iy: number } | null;
239 }): void {
240 if (!this.#bindGroup) return;
241 const u32 = new Uint32Array(this.#uniformData);
242 const f32 = new Float32Array(this.#uniformData);
243 u32[0] = this.#nx;
244 u32[1] = this.#ny;
245 f32[2] = opts.scale;
246 f32[3] = opts.cref;
247 f32[4] = opts.cdev;
248 f32[5] = opts.medium;
249 f32[6] = opts.mic?.ix ?? 0;
250 f32[7] = opts.mic?.iy ?? 0;
251 f32[8] = opts.mic ? 1 : 0;
252 this.#device.queue.writeBuffer(this.#uniform, 0, this.#uniformData);
254 const encoder = this.#device.createCommandEncoder({ label: 'field-view' });
255 const pass = encoder.beginRenderPass({
256 colorAttachments: [
257 {
258 view: this.#context.getCurrentTexture().createView(),
259 clearValue: { r: 0, g: 0, b: 0, a: 1 },
260 loadOp: 'clear',
261 storeOp: 'store',
262 },
263 ],
264 });
265 pass.setPipeline(this.#pipeline);
266 pass.setBindGroup(0, this.#bindGroup);
267 pass.draw(3);
268 pass.end();
269 this.#device.queue.submit([encoder.finish()]);
270 }
272 /** Match the canvas's backing store to its CSS size. */
273 resize(): void {
274 const dpr = Math.min(window.devicePixelRatio || 1, 2);
275 const rect = this.canvas.getBoundingClientRect();
276 const w = Math.max(1, Math.round(rect.width * dpr));
277 const h = Math.max(1, Math.round(rect.height * dpr));
278 if (this.canvas.width !== w || this.canvas.height !== h) {
279 this.canvas.width = w;
280 this.canvas.height = h;
281 }
282 }
284 destroy(): void {
285 this.#uniform.destroy();
286 this.#cmapTexture.destroy();
287 }
moveopenescclose