2<html lang="en">
3<head>
4<meta charset="utf-8" />
5<meta name="viewport" content="width=device-width, initial-scale=1" />
6<title>Random points in a disk — how many do you need?</title>
7<meta name="description" content="Voxel-density heatmaps of N uniform random points in a disk: how many samples it takes before the disk looks smooth at a given resolution." />
8<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230b0e14'/%3E%3Ccircle cx='16' cy='16' r='11' fill='%23dd5136'/%3E%3C/svg%3E" />
9<style>
10 :root {
11 --bg: #0b0e14;
12 --panel: #141924;
13 --panel-2: #1b2130;
14 --line: #2a3243;
15 --fg: #e7ecf5;
16 --dim: #97a3b8;
17 --accent: #f08a2a;
18 }
19 * { box-sizing: border-box; }
20 body {
21 margin: 0;
22 background: var(--bg);
23 color: var(--fg);
24 font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
25 -webkit-font-smoothing: antialiased;
26 }
27 .wrap { max-width: 1140px; margin: 0 auto; padding: 28px 20px 56px; }
28 h1 { font-size: 1.5rem; margin: 0 0 .4rem; letter-spacing: -0.01em; }
29 .lede { color: var(--dim); margin: 0 0 1.6rem; max-width: 70ch; }
30 .lede code { color: var(--fg); background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
32 .card {
33 background: var(--panel);
34 border: 1px solid var(--line);
35 border-radius: 10px;
36 }
38 /* ---- controls ---- */
39 .controls { padding: 16px 18px; display: grid; gap: 18px; grid-template-columns: 2fr 1fr auto; align-items: end; }
40 @media (max-width: 720px) { .controls { grid-template-columns: 1fr; } }
41 label { display: block; font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin-bottom: 6px; }
42 .val { color: var(--fg); text-transform: none; letter-spacing: 0; font-size: .95rem; font-variant-numeric: tabular-nums; }
43 input[type=range] { width: 100%; accent-color: var(--accent); margin: 0; }
44 select, button {
45 font: inherit; color: var(--fg); background: var(--panel-2);
46 border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px;
47 }
48 select { width: 100%; }
49 button { cursor: pointer; white-space: nowrap; }
50 button:hover { border-color: var(--accent); }
51 .badge {
52 font-size: .72rem; letter-spacing: .06em; text-transform: uppercase;
53 padding: 3px 8px; border-radius: 20px; white-space: nowrap;
54 border: 1px solid var(--line); color: var(--dim);
55 }
56 .badge.on { border-color: #2e7d5b; color: #6ddba4; }
58 /* ---- panels ---- */
59 .panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 14px 0; }
60 @media (max-width: 900px) { .panels { grid-template-columns: 1fr; max-width: 380px; } }
61 .panel { padding: 14px; }
62 .panel h2 { font-size: .95rem; margin: 0 0 2px; }
63 .panel p { margin: 0 0 10px; font-size: .82rem; color: var(--dim); min-height: 2.6em; }
64 canvas {
65 width: 100%; aspect-ratio: 1; display: block; border-radius: 6px;
66 image-rendering: pixelated; image-rendering: crisp-edges;
67 background: #000;
68 }
69 .bar { height: 9px; border-radius: 3px; margin-top: 10px; border: 1px solid var(--line); }
70 .ticks { display: flex; justify-content: space-between; font-size: .72rem; color: var(--dim); font-variant-numeric: tabular-nums; margin-top: 3px; }
72 /* ---- stats ---- */
73 .stats { padding: 6px 4px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border-radius: 10px; overflow: hidden; padding: 0; }
74 @media (max-width: 820px) { .stats { grid-template-columns: repeat(2, 1fr); } }
75 .stat { background: var(--panel); padding: 12px 14px; }
76 .stat .k { font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
77 .stat .v { font-size: 1.15rem; font-variant-numeric: tabular-nums; margin-top: 2px; }
78 .stat .v small { font-size: .72rem; color: var(--dim); font-variant-numeric: normal; }
80 .note { color: var(--dim); font-size: .88rem; margin-top: 22px; max-width: 78ch; }
81 .note b { color: var(--fg); font-weight: 600; }
82 a { color: var(--accent); }
83</style>
84</head>
85<body>
86<div class="wrap">
88 <h1>How many random points does a disk need?</h1>
89 <p class="lede">
90 Draw <em>N</em> points uniformly at random from a disk and bin them into a grid of
91 voxels. Each voxel's count is a Poisson-ish random variable with mean
92 <code>μ</code> = points per voxel, so its relative noise is
93 <code>1/√μ</code> — independent of how big the disk is. Sweep <em>N</em> and the
94 resolution below to see when the sampled disk stops looking speckled and starts
95 looking like a disk.
96 </p>
98 <div class="card controls">
99 <div>
100 <label for="n">Points <span class="val" id="nVal"></span></label>
101 <input type="range" id="n" min="200" max="750" step="1" value="500" />
102 </div>
103 <div>
104 <label for="res">Resolution (voxels across diameter)</label>
105 <select id="res">
106 <option value="8">8 × 8</option>
107 <option value="16">16 × 16</option>
108 <option value="32">32 × 32</option>
109 <option value="64" selected>64 × 64</option>
110 <option value="128">128 × 128</option>
111 <option value="256">256 × 256</option>
112 <option value="512">512 × 512</option>
113 </select>
114 </div>
115 <div style="display:flex;align-items:center;gap:10px">
116 <button id="resample">New sample</button>
117 <span class="badge" id="backend" title="Points are binned by a WebGPU compute shader when available, otherwise on the CPU">CPU</span>
118 </div>
119 </div>
121 <div class="panels">
122 <div class="card panel">
123 <h2>Ideal</h2>
124 <p>Exact density: each voxel shaded by the fraction of it inside the disk.</p>
125 <canvas id="cIdeal"></canvas>
126 <div class="bar" id="barSeq"></div>
127 <div class="ticks"><span>0</span><span>0.8×</span><span>1.6× ideal</span></div>
128 </div>
129 <div class="card panel">
130 <h2>Sampled</h2>
131 <p>Counts from <span id="nInline"></span> random points, on the same color scale.</p>
132 <canvas id="cSampled"></canvas>
133 <div class="bar" id="barSeq2"></div>
134 <div class="ticks"><span>0</span><span>0.8×</span><span>1.6× ideal</span></div>
135 </div>
136 <div class="card panel">
137 <h2>Noise</h2>
138 <p>Error in units of the expected standard deviation, <span style="white-space:nowrap">(count − ideal) / √ideal</span>.</p>
139 <canvas id="cNoise"></canvas>
140 <div class="bar" id="barDiv"></div>
141 <div class="ticks"><span>−3σ</span><span>0</span><span>+3σ</span></div>
142 </div>
143 </div>
145 <div class="stats">
146 <div class="stat"><div class="k">Points per voxel μ</div><div class="v" id="sMu"></div></div>
147 <div class="stat"><div class="k">Measured noise</div><div class="v" id="sCv"></div></div>
148 <div class="stat"><div class="k">Predicted 1/√μ</div><div class="v" id="sPred"></div></div>
149 <div class="stat"><div class="k">Interior voxels</div><div class="v" id="sK"></div></div>
150 <div class="stat"><div class="k">Count range (interior)</div><div class="v" id="sRange"></div></div>
151 <div class="stat"><div class="k">Voxel width</div><div class="v" id="sVox"></div></div>
152 <div class="stat">
153 <div class="k">Points for <select id="target" style="width:auto;padding:1px 4px;font-size:.74rem;background:transparent;border:none;color:var(--accent)"><option value="0.10">10%</option><option value="0.05" selected>5%</option><option value="0.02">2%</option><option value="0.01">1%</option></select> noise</div>
154 <div class="v" id="sNeed"></div>
155 </div>
156 <div class="stat"><div class="k">Sample time</div><div class="v" id="sTime"></div></div>
157 </div>
159 <p class="note">
160 <b>Why this matters for MRI simulation.</b> Discretizing a continuous object into
161 isochromats is exactly this experiment: the signal in a voxel is a sum over the
162 isochromats that landed in it, so random placement injects noise of order
163 <code>1/√μ</code> on top of the physics. Statistics are computed only over
164 voxels lying <em>entirely</em> inside the disk — boundary voxels have a smaller
165 expected count and would otherwise inflate the measured spread. Note that the noise
166 panel looks like white noise at every setting: what improves with more points is not
167 its character but its size relative to the signal.
168 </p>
170</div>
172<script>
173const TAU = Math.PI * 2;
175// ---------- colormaps ----------
176const INFERNO = [
177 [0,0,4],[20,11,53],[66,10,104],[106,23,110],[147,38,103],
178 [188,55,84],[221,81,58],[249,142,9],[252,255,164],
179];
180const DIVERGING = [
181 [33,102,172],[103,169,207],[236,238,241],[239,138,98],[178,24,43],
182];
184function makeLut(stops) {
185 const lut = new Uint8Array(256 * 3);
186 const n = stops.length - 1;
187 for (let i = 0; i < 256; i++) {
188 const s = (i / 255) * n;
189 const j = Math.min(n - 1, Math.floor(s));
190 const f = s - j;
191 const a = stops[j], b = stops[j + 1];
192 lut[i * 3 + 0] = a[0] + (b[0] - a[0]) * f;
193 lut[i * 3 + 1] = a[1] + (b[1] - a[1]) * f;
194 lut[i * 3 + 2] = a[2] + (b[2] - a[2]) * f;
195 }
196 return lut;
197}
198const LUT_SEQ = makeLut(INFERNO);
199const LUT_DIV = makeLut(DIVERGING);
201function cssGradient(lut) {
202 const parts = [];
203 for (let i = 0; i <= 16; i++) {
204 const k = Math.round((i / 16) * 255) * 3;
205 parts.push(`rgb(${lut[k]},${lut[k+1]},${lut[k+2]}) ${(i / 16 * 100).toFixed(1)}%`);
206 }
207 return `linear-gradient(to right, ${parts.join(',')})`;
208}
210// ---------- geometry: exact-ish area fraction of each voxel inside the unit disk ----------
211// Domain is [-1,1]^2, so the grid spans the full diameter. Sub-sampled per voxel;
212// cached because it only depends on the resolution.
213const fracCache = new Map();
214function areaFractions(G) {
215 const hit = fracCache.get(G);
216 if (hit) return hit;
217 const S = G >= 256 ? 4 : 8; // sub-samples per axis
218 const frac = new Float32Array(G * G);
219 const h = 2 / G, sh = h / S;
220 for (let iy = 0; iy < G; iy++) {
221 const y0 = -1 + iy * h;
222 for (let ix = 0; ix < G; ix++) {
223 const x0 = -1 + ix * h;
224 let inside = 0;
225 for (let sy = 0; sy < S; sy++) {
226 const y = y0 + (sy + 0.5) * sh;
227 for (let sx = 0; sx < S; sx++) {
228 const x = x0 + (sx + 0.5) * sh;
229 if (x * x + y * y <= 1) inside++;
230 }
231 }
232 frac[iy * G + ix] = inside / (S * S);
233 }
234 }
235 fracCache.set(G, frac);
236 return frac;
237}
239// ---------- sampling ----------
240function sampleCounts(N, G) {
241 const counts = new Uint32Array(G * G);
242 const half = G / 2;
243 for (let i = 0; i < N; i++) {
244 const r = Math.sqrt(Math.random()); // sqrt for uniform area density
245 const t = TAU * Math.random();
246 let ix = (r * Math.cos(t) + 1) * half | 0;
247 let iy = (r * Math.sin(t) + 1) * half | 0;
248 if (ix >= G) ix = G - 1;
249 if (iy >= G) iy = G - 1;
250 counts[iy * G + ix]++;
251 }
252 return counts;
253}
255// ---------- drawing ----------
256const SCALE_MAX = 1.6; // top of the sequential color scale, in units of the ideal density
258function paint(canvas, G, valueAt) {
259 canvas.width = G;
260 canvas.height = G;
261 const ctx = canvas.getContext('2d');
262 const img = ctx.createImageData(G, G);
263 const d = img.data;
264 for (let p = 0; p < G * G; p++) {
265 const rgb = valueAt(p);
266 d[p * 4 + 0] = rgb[0];
267 d[p * 4 + 1] = rgb[1];
268 d[p * 4 + 2] = rgb[2];
269 d[p * 4 + 3] = 255;
270 }
271 ctx.putImageData(img, 0, 0);
272}
274const OUTSIDE = [22, 26, 35]; // neutral fill for voxels with no expected signal
275const rgbSeq = (t) => {
276 const k = Math.max(0, Math.min(255, Math.round(t * 255))) * 3;
277 return [LUT_SEQ[k], LUT_SEQ[k + 1], LUT_SEQ[k + 2]];
278};
279const rgbDiv = (t) => {
280 const k = Math.max(0, Math.min(255, Math.round(t * 255))) * 3;
281 return [LUT_DIV[k], LUT_DIV[k + 1], LUT_DIV[k + 2]];
282};
284// ---------- formatting ----------
285function fmtCount(n) {
286 if (n >= 1e6) {
287 const m = n / 1e6;
288 return (m >= 100 ? m.toFixed(0) : m.toFixed(m >= 10 ? 1 : 2)) + 'M';
289 }
290 if (n >= 1e4) return (n / 1e3).toFixed(0) + 'k';
291 return n.toLocaleString('en-US');
292}
293function fmtMu(mu) {
294 if (mu >= 1000) return fmtCount(Math.round(mu));
295 if (mu >= 10) return mu.toFixed(0);
296 if (mu >= 1) return mu.toFixed(2);
297 return mu.toFixed(3);
298}
299function fmtPct(x) {
300 if (!isFinite(x)) return '—';
301 if (x >= 1) return (x * 100).toFixed(0) + '%';
302 if (x >= 0.1) return (x * 100).toFixed(1) + '%';
303 return (x * 100).toFixed(2) + '%';
304}
306// ---------- GPU sampling ----------
307// The histogram is the whole cost of this demo, and it is embarrassingly parallel: one
308// compute shader draws the points and atomically bins them, so N can reach a billion.
309// The CPU path above stays as the fallback.
310const MAX_G = 512, MAX_BINS = MAX_G * MAX_G;
311const WG_SIZE = 256, WORKGROUPS = 1024; // 262144 grid-stride threads
312const LOCAL_BINS = 1024; // workgroup-private histogram, for G <= 32
314const WGSL = `
315struct Params { g: u32, n: u32, seed: u32, use_local: u32 }
316@group(0) @binding(0) var<uniform> P: Params;
317@group(0) @binding(1) var<storage, read_write> counts: array<atomic<u32>>;
319// Coarse grids would serialize on a handful of global counters, so each workgroup
320// accumulates privately and flushes once per bin at the end.
321var<workgroup> local_hist: array<atomic<u32>, ${LOCAL_BINS}>;
323// PCG (rxs-m-xs). Validated against the Poisson prediction before being trusted here:
324// a hash with visible structure would paint that structure straight into the heatmap.
325fn hash(x: u32) -> u32 {
326 var s = x * 747796405u + 2891336453u;
327 s = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
328 return (s >> 22u) ^ s;
329}
330fn pcg(state: ptr<function, u32>) -> u32 {
331 let old = *state;
332 *state = old * 747796405u + 2891336453u;
333 let word = ((old >> ((old >> 28u) + 4u)) ^ old) * 277803737u;
334 return (word >> 22u) ^ word;
335}
337const INV32: f32 = 2.3283064365386963e-10;
338const TAU: f32 = 6.283185307179586;
340@compute @workgroup_size(${WG_SIZE})
341fn main(@builtin(global_invocation_id) gid: vec3<u32>,
342 @builtin(local_invocation_id) lid: vec3<u32>,
343 @builtin(num_workgroups) nwg: vec3<u32>) {
344 let nbins = P.g * P.g;
345 let use_local = P.use_local == 1u;
347 if (use_local) {
348 for (var i = lid.x; i < nbins; i = i + ${WG_SIZE}u) {
349 atomicStore(&local_hist[i], 0u);
350 }
351 }
352 workgroupBarrier();
354 var state = hash(gid.x ^ hash(P.seed));
355 let stride = nwg.x * ${WG_SIZE}u;
356 let half = f32(P.g) * 0.5;
357 let top = f32(P.g) - 1.0;
359 for (var idx = gid.x; idx < P.n; idx = idx + stride) {
360 let r = sqrt(f32(pcg(&state)) * INV32); // sqrt for uniform area density
361 let t = TAU * f32(pcg(&state)) * INV32;
362 let ix = u32(clamp((r * cos(t) + 1.0) * half, 0.0, top));
363 let iy = u32(clamp((r * sin(t) + 1.0) * half, 0.0, top));
364 let bin = iy * P.g + ix;
365 if (use_local) { atomicAdd(&local_hist[bin], 1u); }
366 else { atomicAdd(&counts[bin], 1u); }
367 }
369 workgroupBarrier();
370 if (use_local) {
371 for (var i = lid.x; i < nbins; i = i + ${WG_SIZE}u) {
372 let v = atomicLoad(&local_hist[i]);
373 if (v > 0u) { atomicAdd(&counts[i], v); }
374 }
375 }
376}
377`;
379let gpu = null;
380let seed = 1;
382async function initGPU() {
383 if (!navigator.gpu) return null;
384 try {
385 const adapter = await navigator.gpu.requestAdapter();
386 if (!adapter) return null;
387 const device = await adapter.requestDevice();
388 const module = device.createShaderModule({ code: WGSL });
389 if (module.getCompilationInfo) {
390 const info = await module.getCompilationInfo();
391 const errs = info.messages.filter((m) => m.type === 'error');
392 if (errs.length) { console.error('WGSL compilation failed', errs); return null; }
393 }
394 const pipeline = device.createComputePipeline({
395 layout: 'auto', compute: { module, entryPoint: 'main' },
396 });
397 const U = GPUBufferUsage;
398 const params = device.createBuffer({ size: 16, usage: U.UNIFORM | U.COPY_DST });
399 const counts = device.createBuffer({ size: MAX_BINS * 4, usage: U.STORAGE | U.COPY_SRC | U.COPY_DST });
400 const stage = device.createBuffer({ size: MAX_BINS * 4, usage: U.COPY_DST | U.MAP_READ });
401 const bind = device.createBindGroup({
402 layout: pipeline.getBindGroupLayout(0),
403 entries: [
404 { binding: 0, resource: { buffer: params } },
405 { binding: 1, resource: { buffer: counts } },
406 ],
407 });
408 device.addEventListener('uncapturederror', (e) => console.error('WebGPU error', e.error));
409 device.lost.then(() => { gpu = null; showBackend(); });
410 return { device, pipeline, params, counts, stage, bind, host: new Uint32Array(MAX_BINS) };
411 } catch (e) {
412 console.warn('WebGPU unavailable', e);
413 return null;
414 }
415}
417async function gpuCounts(N, G) {
418 const g = gpu;
419 const bytes = G * G * 4;
420 g.device.queue.writeBuffer(g.params, 0,
421 new Uint32Array([G, N, seed, G * G <= LOCAL_BINS ? 1 : 0]));
422 const enc = g.device.createCommandEncoder();
423 enc.clearBuffer(g.counts, 0, bytes);
424 const pass = enc.beginComputePass();
425 pass.setPipeline(g.pipeline);
426 pass.setBindGroup(0, g.bind);
427 pass.dispatchWorkgroups(WORKGROUPS);
428 pass.end();
429 enc.copyBufferToBuffer(g.counts, 0, g.stage, 0, bytes);
430 g.device.queue.submit([enc.finish()]);
431 await g.stage.mapAsync(GPUMapMode.READ, 0, bytes);
432 g.host.set(new Uint32Array(g.stage.getMappedRange(0, bytes)));
433 g.stage.unmap();
434 return g.host; // only [0, G*G) is meaningful
435}
437// Falls back permanently to the CPU if the GPU path ever throws mid-session. Returns null
438// in that case rather than sampling: N may be far above what the CPU can take, so the
439// caller re-runs after showBackend() has clamped the slider back to the CPU range.
440async function getCounts(N, G) {
441 if (gpu) {
442 try { return await gpuCounts(N, G); }
443 catch (e) {
444 console.warn('GPU sampling failed, falling back to CPU', e);
445 gpu = null;
446 showBackend();
447 return null;
448 }
449 }
450 return sampleCounts(N, G);
451}
453// ---------- app ----------
454const el = (id) => document.getElementById(id);
455const nSlider = el('n'), resSel = el('res'), targetSel = el('target');
457document.getElementById('barSeq').style.background = cssGradient(LUT_SEQ);
458document.getElementById('barSeq2').style.background = cssGradient(LUT_SEQ);
459document.getElementById('barDiv').style.background = cssGradient(LUT_DIV);
461const sliderToN = (v) => {
462 const n = Math.pow(10, v / 100);
463 // round to 3 significant figures so the readout is stable while dragging
464 const mag = Math.pow(10, Math.floor(Math.log10(n)) - 2);
465 return Math.max(1, Math.round(n / mag) * mag);
466};
468function showN(N) {
469 el('nVal').textContent = '= ' + N.toLocaleString('en-US');
470 el('nInline').textContent = fmtCount(N);
471}
473// The CPU tops out around 30M points before a drag becomes unpleasant; the GPU
474// comfortably reaches a billion, which is where fine grids get interesting.
475const CPU_MAX = 750, GPU_MAX = 900; // slider units, = log10(N) * 100
477function showBackend() {
478 el('backend').textContent = gpu ? 'WebGPU' : 'CPU';
479 el('backend').className = 'badge' + (gpu ? ' on' : '');
480 const max = gpu ? GPU_MAX : CPU_MAX;
481 nSlider.max = String(max);
482 if (+nSlider.value > max) nSlider.value = String(max);
483}
485let lastElapsed = 0;
487async function draw() {
488 const N = sliderToN(+nSlider.value);
489 const G = +resSel.value;
491 showN(N);
493 const frac = areaFractions(G);
494 const t0 = performance.now();
495 const counts = await getCounts(N, G);
496 if (!counts) { queued = true; return; } // backend just changed; redraw within the new limits
497 const elapsed = lastElapsed = performance.now() - t0;
499 // Expected count in a fully interior voxel: N * (voxel area / disk area).
500 const mu = N * (4 / (G * G)) / Math.PI;
502 paint(el('cIdeal'), G, (p) => rgbSeq(frac[p] / SCALE_MAX));
503 paint(el('cSampled'), G, (p) => rgbSeq(counts[p] / mu / SCALE_MAX));
504 paint(el('cNoise'), G, (p) => {
505 const expected = mu * frac[p];
506 if (expected <= 0) return OUTSIDE;
507 const z = (counts[p] - expected) / Math.sqrt(expected);
508 return rgbDiv(z / 6 + 0.5); // ±3σ across the scale
509 });
511 // Statistics over voxels entirely inside the disk.
512 let k = 0, sum = 0, sumSq = 0, lo = Infinity, hi = -Infinity;
513 for (let p = 0; p < G * G; p++) {
514 if (frac[p] < 1) continue;
515 const c = counts[p];
516 k++; sum += c; sumSq += c * c;
517 if (c < lo) lo = c;
518 if (c > hi) hi = c;
519 }
520 const mean = k ? sum / k : 0;
521 const variance = k > 1 ? Math.max(0, sumSq / k - mean * mean) * k / (k - 1) : 0;
522 const cv = mean > 0 ? Math.sqrt(variance) / mean : Infinity;
524 el('sMu').innerHTML = fmtMu(mu) + ' <small>expected</small>';
525 el('sCv').textContent = k ? fmtPct(cv) : '—';
526 el('sPred').textContent = mu > 0 ? fmtPct(1 / Math.sqrt(mu)) : '—';
527 el('sK').innerHTML = k.toLocaleString('en-US') + ' <small>of ' + (G * G).toLocaleString('en-US') + '</small>';
528 el('sRange').textContent = k ? lo.toLocaleString('en-US') + ' – ' + hi.toLocaleString('en-US') : '—';
529 el('sVox').innerHTML = (2 / G).toPrecision(3).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') + ' <small>disk radii</small>';
531 const c = +targetSel.value;
532 const need = Math.PI * G * G / (4 * c * c);
533 el('sNeed').innerHTML = fmtCount(Math.round(need)) + ' <small>points</small>';
535 el('sTime').innerHTML = (elapsed < 10 ? elapsed.toFixed(1) : elapsed.toFixed(0))
536 + ' <small>ms · ' + (gpu ? 'GPU' : 'CPU') + '</small>';
537}
539// Sampling is async now, so serialize runs and collapse anything that arrives while one
540// is in flight down to a single trailing update.
541let running = false, queued = false;
542async function update() {
543 if (running) { queued = true; return; }
544 running = true;
545 try { await draw(); }
546 catch (e) { console.error(e); }
547 finally {
548 running = false;
549 if (queued) { queued = false; schedule(); }
550 }
551}
553// Coalesce rapid input into one update per frame; once a sample gets slow enough to
554// stutter a drag, wait for the slider to settle instead.
555let pending = false, timer = 0;
556function schedule() {
557 clearTimeout(timer);
558 if (lastElapsed > 120) {
559 timer = setTimeout(update, 180);
560 return;
561 }
562 if (pending) return;
563 pending = true;
564 requestAnimationFrame(() => { pending = false; update(); });
565}
567// The readout is cheap, so keep it live even while a heavy recompute is deferred.
568nSlider.addEventListener('input', () => { showN(sliderToN(+nSlider.value)); schedule(); });
569resSel.addEventListener('change', schedule);
570targetSel.addEventListener('change', schedule);
571el('resample').addEventListener('click', () => { seed++; update(); });
573// Draw immediately on the CPU so the page is never blank, then upgrade to the GPU.
574showBackend();
575update();
576initGPU().then((g) => {
577 if (!g) return;
578 gpu = g;
579 showBackend();
580 update();
581});
582</script>
583</body>
584</html>