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