馃悕馃悕馃悕
at main 533 lines 16 kB view raw
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