馃悕馃悕馃悕
1
2$css(`
3 .proj-shift-container {
4 display: flex;
5 flex-direction: row;
6 }
7
8 .overlay {
9 user-select: none;
10 position: absolute;
11 z-index: 1;
12 top: 0;
13 left: 0;
14 pointer-events: none;
15 }
16`);
17
18import { greek } from "/code/math/math.js";
19
20import "/code/math/constants.js";
21import "/code/math/vector.js";
22
23import { translatedProjectiveShift } from "/code/math/proj_shift.js";
24
25export async function main(target) {
26 let showTrajectory = false;
27
28 const c = $complex.cartesian;
29 const v2 = $vector.v2;
30
31 function computeTrajectory(cParam, maxIters, escapeThreshold) {
32 let z = c.of(0,0);
33 const trajectory = [c.copy(z)];
34
35 for (let i = 0; i < maxIters; i++) {
36 if (c.magSq(z) > escapeThreshold * escapeThreshold) {
37 break;
38 }
39 z = c.add(c.mul(z,z), cParam);
40 trajectory.push(c.copy(z));
41 }
42
43 return trajectory;
44 }
45
46 const renderStack = $div("full");
47 renderStack.dataset.name = "renderer";
48 renderStack.style.position = "relative";
49
50 const gpuModule = await $mod("gpu/webgpu", renderStack);
51
52 const canvas = gpuModule.canvas;
53 const context = gpuModule.context;
54
55 canvas.setAttribute("aria-label",
56 "Interactive visualization of the mandelbrot set.");
57 canvas.setAttribute("role", "application");
58 canvas.setAttribute("aria-keyshortcuts", "f");
59
60 const compShader = await $gpu.loadShader("brot", {
61 "pixel_mapping" : "pixel_to_complex"
62 });
63 const blitShader = await $gpu.loadShader("blit");
64
65 if (!compShader || !blitShader) return;
66
67 const uniforms = compShader.bufferDefinitions["0,0"];
68
69 uniforms.vars.zoom.value = 1.0;
70 uniforms.vars.center_x.value = -0.75;
71 uniforms.vars.center_y.value = 0.0;
72
73 const controls = await $prepMod("control/panel",
74 ["Parameters", uniforms.getControlSettings(render)]
75 );
76
77 const computePipeline = $gpu.device.createComputePipeline({
78 layout: "auto",
79 compute: {
80 module: compShader.module,
81 entryPoint: "main"
82 }
83 });
84
85 const renderPipeline = $gpu.device.createRenderPipeline({
86 layout: "auto",
87 vertex: {
88 module: blitShader.module,
89 entryPoint: "vert"
90 },
91 fragment: {
92 module: blitShader.module,
93 entryPoint: "frag",
94 targets: [ { format: $gpu.canvasFormat } ]
95 },
96 primitive: {
97 topology: "triangle-list"
98 }
99 });
100
101 const overlay = $svgElement("svg");
102 overlay.classList = "full overlay";
103
104 overlay.setAttribute("aria-label",
105 "Overlay visualizing the trajectory \
106 starting from the point under the cursor.")
107
108 renderStack.appendChild(overlay);
109
110 function showControls() {
111 if (!topmost.isConnected) {
112 topmost = renderStack;
113 }
114 else if (topmost.querySelector(".control-panel")) return;
115
116 return ["show controls", async () => {
117 const split = await $mod("layout/split",
118 renderStack.parentNode,
119 [{
120 content: [controls, renderStack],
121 percents: [20, 80]
122 }]
123 );
124 topmost = split.topmost;
125 }];
126 }
127
128 function toggleTrajectory() {
129 if (showTrajectory) return ["hide trajectory", () => {showTrajectory = false}];
130 return ["show trajectory", () => {showTrajectory = true}];
131 }
132
133 function exitRenderer() {
134 // relying on showControls' topmost check to have occurred before this can be called,
135 // which is true bc that happens while the context menu is built.
136 // this may not remain true if a hotkey is added for exiting w/o opening the menu
137 const target = topmost.parentNode;
138 target.replaceChildren();
139 $mod("layout/nothing", target);
140 }
141
142 function saveFrame() {
143 canvas.toBlob(blob => {
144 const url = URL.createObjectURL(blob);
145 const a = $element("a");
146 a.href = url;
147 a.download = `ps_web_${Date.now()}.png`;
148 a.click();
149 URL.revokeObjectURL(url);
150 });
151 }
152
153 function saveTrajectory() {
154 if (!showTrajectory) return;
155
156 return ["save trajectory", () => {
157 const svgString = new XMLSerializer().serializeToString(overlay);
158 const blob = new Blob([svgString], { type: 'image/svg+xml' });
159 const url = URL.createObjectURL(blob);
160 const a = $element("a");
161 a.href = url;
162 a.download = `ps_traj_${Date.now()}.svg`;
163 a.click();
164 URL.revokeObjectURL(url);
165 }];
166 }
167
168 renderStack.$preventCollapse = true;
169
170 renderStack.addEventListener("keydown", (e) => {
171 if (e.key === "f") {
172 if (document.fullscreenElement) {
173 document.exitFullscreen();
174 }
175 else {
176 renderStack.requestFullscreen();
177 }
178 }
179 });
180
181 const split = await $mod("layout/split",
182 target,
183 [{
184 content: [controls, renderStack],
185 percents: [20, 80]
186 }]
187 );
188 let topmost = split.topmost;
189
190 let width = canvas.clientWidth;
191 let height = canvas.clientHeight;
192 canvas.width = width;
193 canvas.height = height;
194
195 let outputTexture = $gpu.device.createTexture({
196 size: [width, height],
197 format: "rgba8unorm",
198 usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
199 });
200
201 const sampler = $gpu.device.createSampler({ magFilter: "nearest", minfilter: "nearest" });
202
203 let computeBindGroup = $gpu.device.createBindGroup({
204 layout: computePipeline.getBindGroupLayout(0),
205 entries: [
206 { binding: 0, resource: { buffer: uniforms.gpuBuffer } },
207 { binding: 1, resource: outputTexture.createView() }
208 ]
209 });
210
211 let renderBindGroup = $gpu.device.createBindGroup({
212 layout: renderPipeline.getBindGroupLayout(0),
213 entries: [
214 { binding: 0, resource: sampler },
215 { binding: 1, resource: outputTexture.createView() }
216 ]
217 });
218
219 let canRender = true;
220
221 async function offscreenRender(scalingFactor) {
222 const dims = v2.of(width * scalingFactor, height * scalingFactor);
223
224 const texture = $gpu.device.createTexture({
225 size: [dims.x, dims.y],
226 format: "rgba8unorm",
227 usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
228 });
229
230 const orig_height = uniforms.vars.height.value;
231 const orig_width = uniforms.vars.width.value;
232
233 uniforms.vars.height.value *= scalingFactor;
234 uniforms.vars.width.value *= scalingFactor;
235
236 computeBindGroup = $gpu.device.createBindGroup({
237 layout: computePipeline.getBindGroupLayout(0),
238 entries: [
239 { binding: 0, resource: { buffer: uniforms.gpuBuffer } },
240 { binding: 1, resource: texture.createView() }
241 ]
242 });
243
244 renderBindGroup = $gpu.device.createBindGroup({
245 layout: renderPipeline.getBindGroupLayout(0),
246 entries: [
247 { binding: 0, resource: sampler },
248 { binding: 1, resource: texture.createView() }
249 ]
250 });
251
252 const offscreen = $gpu.getOffscreenContext(dims);
253
254 render(offscreen.context, dims);
255
256 uniforms.vars.height.value = orig_height;
257 uniforms.vars.width.value = orig_width;
258
259 const blob = await offscreen.canvas.convertToBlob({ type: "image/png" });
260
261 const url = URL.createObjectURL(blob);
262 const a = $element("a");
263 a.href = url;
264 a.download = `ps_web_${Date.now()}.png`;
265 a.click();
266 URL.revokeObjectURL(url);
267 texture.destroy();
268
269 computeBindGroup = $gpu.device.createBindGroup({
270 layout: computePipeline.getBindGroupLayout(0),
271 entries: [
272 { binding: 0, resource: { buffer: uniforms.gpuBuffer } },
273 { binding: 1, resource: outputTexture.createView() }
274 ]
275 });
276
277 renderBindGroup = $gpu.device.createBindGroup({
278 layout: renderPipeline.getBindGroupLayout(0),
279 entries: [
280 { binding: 0, resource: sampler },
281 { binding: 1, resource: outputTexture.createView() }
282 ]
283 });
284
285 }
286
287 function resize() {
288 width = canvas.clientWidth;
289 height = canvas.clientHeight;
290
291 overlay.setAttribute("viewBox", `0 0 ${width} ${height}`);
292 overlay.setAttribute("width", width);
293 overlay.setAttribute("height", height);
294
295 canvas.width = width;
296 canvas.height = height;
297
298 const uniforms = compShader.bufferDefinitions["0,0"];
299
300 uniforms.vars.width.value = width;
301 uniforms.vars.height.value = height;
302
303 if (width * height <= 0) {
304 canRender = false;
305 return;
306 }
307
308 canRender = true;
309
310 outputTexture.destroy();
311
312 outputTexture = $gpu.device.createTexture({
313 size: [width, height],
314 format: "rgba8unorm",
315 usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
316 });
317
318 computeBindGroup = $gpu.device.createBindGroup({
319 layout: computePipeline.getBindGroupLayout(0),
320 entries: [
321 { binding: 0, resource: { buffer: uniforms.gpuBuffer } },
322 { binding: 1, resource: outputTexture.createView() }
323 ]
324 });
325
326 renderBindGroup = $gpu.device.createBindGroup({
327 layout: renderPipeline.getBindGroupLayout(0),
328 entries: [
329 { binding: 0, resource: sampler },
330 { binding: 1, resource: outputTexture.createView() }
331 ]
332 });
333
334 render();
335 }
336
337 const resizeObserver = new ResizeObserver(resize);
338 resizeObserver.observe(canvas);
339
340 function render(targetContext = context, dims = null) {
341
342 if (!canRender) {
343 console.warn("Cannot render; aborting render.");
344 return;
345 }
346
347 dims = dims || v2.of(width, height);
348
349 const uniforms = compShader.bufferDefinitions["0,0"];
350 uniforms.updateBuffers();
351
352 const commandEncoder = $gpu.device.createCommandEncoder();
353
354 const computePass = commandEncoder.beginComputePass();
355 computePass.setPipeline(computePipeline);
356 computePass.setBindGroup(0, computeBindGroup);
357 computePass.dispatchWorkgroups(
358 Math.ceil(dims.x / 16),
359 Math.ceil(dims.y / 16),
360 1
361 );
362 computePass.end();
363
364 const renderPass = commandEncoder.beginRenderPass({
365 colorAttachments: [
366 {
367 view: targetContext.getCurrentTexture().createView(),
368 loadOp: "clear",
369 storeOp: "store"
370 }
371 ]
372 });
373
374 renderPass.setPipeline(renderPipeline);
375 renderPass.setBindGroup(0, renderBindGroup);
376 renderPass.draw(6); // 1 quad, 2 tris
377 renderPass.end();
378
379 $gpu.device.queue.submit([commandEncoder.finish()]);
380 }
381
382 let isDragging = false;
383 let lastMouse = v2.of(0,0);
384
385 canvas.addEventListener("pointerdown", (e) => {
386 if (e.button !== 0) return;
387 isDragging = true;
388 lastMouse = v2.fromMouse(e, canvas);
389 canvas.style.cursor = "all-scroll";
390 });
391
392 canvas.addEventListener("pointermove", (e) => {
393 const pMouse = v2.fromMouse(e, canvas);
394
395 const dims = v2.of(width, height);
396 const center = c.of(uniforms.vars.center_x.value, uniforms.vars.center_y.value);
397 const scale = 1.0 / uniforms.vars.zoom.value;
398
399 const cMouse = c.fromPixel(pMouse, dims, center, scale);
400
401 const trajectory = computeTrajectory(
402 cMouse,
403 Math.min(uniforms.vars.iterations.value, 500),
404 uniforms.vars.escape_distance.value
405 );
406
407 // Clear existing trajectory
408 const existingPath = overlay.querySelector(".trajectory-path");
409 if (existingPath) {
410 existingPath.remove();
411 }
412
413 if (showTrajectory && trajectory.length > 1) {
414 const path = $svgElement("path");
415 path.setAttribute("class", "trajectory-path");
416 path.setAttribute("fill", "none");
417 path.setAttribute("stroke", "lime");
418 path.setAttribute("stroke-width", "2");
419 path.setAttribute("opacity", "1.0");
420
421 let pathData = "";
422 for (let i = 0; i < trajectory.length; i++) {
423 const pixel = c.toPixel(trajectory[i], dims, center, scale);
424
425 // Skip points outside visible area
426 if (pixel.x < -10 || pixel.x > width + 10 ||
427 pixel.y < -10 || pixel.y > height + 10) {
428 //continue;
429 }
430
431 if (pathData === "") {
432 pathData = `M ${pixel.x} ${pixel.y}`;
433 } else {
434 pathData += ` L ${pixel.x} ${pixel.y}`;
435 }
436 }
437
438 if (pathData !== "") {
439 path.setAttribute("d", pathData);
440 overlay.appendChild(path);
441 }
442 }
443
444
445 if (!isDragging) return;
446
447 e.preventDefault();
448 e.stopPropagation();
449 e.stopImmediatePropagation();
450
451 const delta = v2.sub(pMouse, lastMouse);
452
453 // Convert pixel delta to complex plane delta
454 const cDelta = v2.scale(delta, -scale / height);
455
456 uniforms.vars.center_x.value += cDelta.x;
457 uniforms.vars.center_y.value += cDelta.y;
458
459 lastMouse = pMouse;
460
461 render();
462 });
463
464 renderStack.$contextMenu = {
465 items: [
466 showControls,
467 saveTrajectory,
468 ["save frame", saveFrame],
469 ["save 4x", () => offscreenRender(2)],
470 ["save 9x", () => offscreenRender(3)],
471 ["save 16x", () => offscreenRender(4)],
472 toggleTrajectory,
473 ["exit", exitRenderer]
474 ]
475 };
476
477
478 canvas.addEventListener("pointerup", () => {
479 if (!isDragging) return;
480 isDragging = false;
481 canvas.style.cursor = "crosshair";
482 });
483
484 canvas.addEventListener("pointerleave", () => {
485 if (!isDragging) return;
486 isDragging = false;
487 canvas.style.cursor = "crosshair";
488 });
489
490 canvas.addEventListener("wheel", (e) => {
491 e.preventDefault();
492
493 const pMouse = v2.fromMouse(e, canvas);
494
495 const dims = v2.of(width, height);
496 const center = c.of(uniforms.vars.center_x.value, uniforms.vars.center_y.value);
497 const scale = 1.0 / uniforms.vars.zoom.value;
498 const cMouse = c.fromPixel(pMouse, dims, center, scale);
499
500 const isTrackpad = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL;
501 const zoomFactorDiff = isTrackpad ? 0.01 : 0.1;
502
503 // negative deltaY means zoom in
504 const zoomFactor = e.deltaY > 0 ? 1.0 - zoomFactorDiff : 1.0 + zoomFactorDiff;
505
506 uniforms.vars.zoom.value *= zoomFactor;
507
508 // Adjust center to keep mouse position fixed in complex plane
509 const newScale = 1.0 / uniforms.vars.zoom.value;
510 const newCenterX = cMouse.re - (pMouse.x - width * 0.5) * newScale / height;
511 const newCenterY = cMouse.im - (pMouse.y - height * 0.5) * newScale / height;
512
513 uniforms.vars.center_x.value = newCenterX;
514 uniforms.vars.center_y.value = newCenterY;
515
516 render();
517 });
518
519 canvas.style.cursor = "crosshair";
520
521 render();
522
523
524 return { replace: true };
525}
526
527