馃悕馃悕馃悕
at main 527 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(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