Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ In `shader.wgsl`, the pixel's total iteration count `i` (`0..uniforms.iter`) is

While dragging/zooming or waiting for the WASM worker to compute a new reference orbit:

1. `#fractal-bg` (a 2D canvas behind `#fractal`) is updated via `_updateBackgroundCanvas()` (`bgCtx.drawImage(this.canvas, 0, 0)`) whenever a render pass completes (`state.currentPass >= state.totalPasses`), including 1-pass low-resolution interactive previews.
1. `#fractal-bg` (a 2D canvas behind `#fractal`) is updated via `_updateBackgroundCanvas()` (`bgCtx.drawImage(this.canvas, 0, 0)`) whenever a render completes (`state.nextRow >= canvas.height`), including single-slice low-resolution interactive previews.
2. When the viewport transitions from a completed low-res preview to a multi-slice progressive high-res render (`this.canvas.width` / `height` resize), `#fractal-bg` retains the low-res preview underneath `#fractal` so there is never a black flash or stale-frame jump while progressive slices fill in.
3. Progressive high-res slices in `Fractious.frame()` are gated on `device.queue.onSubmittedWorkDone()` and a monotonically increasing `_renderGeneration` token so GPU submissions never saturate the browser compositor queue and user input can preempt in-flight progressive passes within a single frame.
4. Progressive slice height is adaptive: `Renderer.recordSliceTime()` learns per-tier GPU throughput (in worst-case ops per ms) from each slice's submit-to-done time, and `_sliceRows()` sizes the next slice to about `TARGET_SLICE_MS` (10ms). Slower measurements apply immediately, faster ones at most double the estimate, and slices are capped at `MAX_SLICE_BUDGETS` × the fixed worst-case budget to bound stalls when a slice runs into interior. Each full-res render records a `fractious:full-res` User Timing measure for traces.

---

Expand Down
2 changes: 1 addition & 1 deletion src/Fractious.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export class Fractious {
}
this.state.isRendering = isFullResRender;
this.interactionManager.updateUI();
this.state.currentPass = 0;
this.state.nextRow = 0;
this._scheduleFrame();
}

Expand Down
143 changes: 86 additions & 57 deletions src/Renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import postShaderCode from './renderer/post.wgsl?raw';
const INTERACTION_MAX_OPS = 20000000;
const PROGRESSIVE_MAX_OPS = 25000000;
const INTERACTION_SCALE_LIMIT = 0.5;
// Progressive slices are sized from measured GPU throughput to take about this long,
// so each frame does a useful amount of work while input stays responsive.
const TARGET_SLICE_MS = 10;
// Throughput is measured in worst-case ops (every pixel reaching max iterations), so a
// slice can cost more than predicted if it runs into interior. Cap slices at this many
// of the old fixed worst-case budgets to bound any single stall.
const MAX_SLICE_BUDGETS = 16;

export class Renderer {
constructor(canvas, bgCanvas) {
Expand Down Expand Up @@ -32,6 +39,9 @@ export class Renderer {
this.uniformBufferSize = 80;
this.uniformData = new ArrayBuffer(this.uniformBufferSize);
this.uniformDataView = new DataView(this.uniformData);
this.throughput = new Map(); // tier name -> worst-case ops per ms
this.pendingSlice = null;
this.progressiveStart = 0;
}

_showFatalError(message) {
Expand Down Expand Up @@ -256,48 +266,48 @@ export class Renderer {
};
}

_getSliceGeometry(state) {
if (state.totalPasses <= 1 || this.canvas.height <= 0) {
return {
yOffset: 0,
currentSliceHeight: this.canvas.height,
sliceScale: 1.0,
sliceOffset: 0.0,
};
}
const sliceHeight = Math.ceil(this.canvas.height / state.totalPasses);
const yOffset = state.currentPass * sliceHeight;
const currentSliceHeight = Math.min(
sliceHeight,
this.canvas.height - yOffset,
_isInteractive(state) {
return state.pointers.size > 0 || state.workerBusy || state.isPendingUpdate;
}

_isComplete(state) {
return state.nextRow >= this.canvas.height;
}

_sliceRows(config, state, tier) {
const remaining = this.canvas.height - state.nextRow;
if (this._isInteractive(state)) return remaining;
const rowOps = this.canvas.width * (config.iter || 1);
const budget = PROGRESSIVE_MAX_OPS * tier.opsMultiplier;
const opsPerMs = this.throughput.get(tier.name) || budget / TARGET_SLICE_MS;
const ops = Math.min(
opsPerMs * TARGET_SLICE_MS,
budget * MAX_SLICE_BUDGETS,
);
const sliceScale = currentSliceHeight / this.canvas.height;
return Math.min(remaining, Math.max(1, Math.floor(ops / rowOps)));
}

_getSliceGeometry(config, state, tier) {
const height = this.canvas.height;
const yOffset = state.nextRow;
const rows = height > 0 ? this._sliceRows(config, state, tier) : 0;
const sliceScale = rows / height;
// Invert Y coordinate mapping because WebGPU Scissor Rect Y starts at TOP (0),
// but WebGPU NDC Y starts at BOTTOM (-1.0).
const yOffsetBottom = this.canvas.height - yOffset - currentSliceHeight;
const sliceOffset =
-1.0 + (2.0 * yOffsetBottom + currentSliceHeight) / this.canvas.height;
return { yOffset, currentSliceHeight, sliceScale, sliceOffset };
const yOffsetBottom = height - yOffset - rows;
const sliceOffset = -1.0 + (2.0 * yOffsetBottom + rows) / height;
const ops = rows * this.canvas.width * (config.iter || 1);
return { yOffset, rows, sliceScale, sliceOffset, ops };
}

_calculatePassesAndResize(config, state, tier) {
const { dpr, width, height, currentPixels, workerBusy, isPendingUpdate } =
state;
_resizeCanvas(config, state) {
const { dpr, width, height, currentPixels } = state;
let targetScale = 1.0;

const isDragging = state.pointers.size > 0;

if (isDragging || workerBusy || isPendingUpdate) {
if (this._isInteractive(state)) {
const idealPixels = INTERACTION_MAX_OPS / (config.iter || 1);
targetScale = Math.sqrt(idealPixels / currentPixels);
targetScale = Math.min(INTERACTION_SCALE_LIMIT, targetScale);
state.totalPasses = 1;
} else {
const totalOps = currentPixels * config.iter;
state.totalPasses = Math.max(
1,
Math.ceil(totalOps / (PROGRESSIVE_MAX_OPS * tier.opsMultiplier)),
);
}

if (width > 0 && height > 0) {
Expand All @@ -316,18 +326,13 @@ export class Renderer {
),
);

// Clamp total progressive passes to the physical canvas height to avoid empty/redundant draw calls.
if (!(isDragging || workerBusy || isPendingUpdate)) {
state.totalPasses = Math.min(state.totalPasses, targetHeight);
}

if (
this.canvas.width !== targetWidth ||
this.canvas.height !== targetHeight
) {
this.canvas.width = targetWidth;
this.canvas.height = targetHeight;
state.currentPass = 0;
state.nextRow = 0;
}
}
}
Expand Down Expand Up @@ -378,13 +383,13 @@ export class Renderer {
_dispatchDrawCalls(state, tier, slice) {
const commandEncoder = this.device.createCommandEncoder();

if (state.currentPass < state.totalPasses) {
if (slice.rows > 0) {
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: this.offscreenTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: state.currentPass === 0 ? 'clear' : 'load',
loadOp: slice.yOffset === 0 ? 'clear' : 'load',
storeOp: 'store',
},
],
Expand All @@ -406,22 +411,20 @@ export class Renderer {
0,
1,
);
if (slice.currentSliceHeight > 0) {
passEncoder.setScissorRect(
0,
slice.yOffset,
this.canvas.width,
slice.currentSliceHeight,
);
}
passEncoder.setScissorRect(
0,
slice.yOffset,
this.canvas.width,
slice.rows,
);

if (tier.bindGroup) {
passEncoder.setBindGroup(0, tier.bindGroup);
passEncoder.draw(6);
}
passEncoder.end();

state.currentPass++;
state.nextRow += slice.rows;
}

const destTexture = this.context.getCurrentTexture();
Expand Down Expand Up @@ -450,7 +453,7 @@ export class Renderer {

_updateBackgroundCanvas(state) {
if (
state.currentPass >= state.totalPasses &&
this._isComplete(state) &&
this.canvas.width > 0 &&
this.canvas.height > 0 &&
this.bgCanvas
Expand Down Expand Up @@ -480,7 +483,7 @@ export class Renderer {
}

_handleScreenshot(state) {
if (state.screenshotRequested && state.currentPass >= state.totalPasses) {
if (state.screenshotRequested && this._isComplete(state)) {
state.screenshotRequested = false;
const d = new Date();
const timestamp =
Expand All @@ -499,7 +502,7 @@ export class Renderer {
}

_handleShare(state) {
if (state.shareRequested && state.currentPass >= state.totalPasses) {
if (state.shareRequested && this._isComplete(state)) {
state.shareRequested = false;

this.canvas.toBlob((blob) => {
Expand Down Expand Up @@ -548,10 +551,10 @@ export class Renderer {

render(config, state) {
const tier = this._getPrecisionTier(state.targetZoom);
this._calculatePassesAndResize(config, state, tier);
this._resizeCanvas(config, state);

if (
state.currentPass >= state.totalPasses &&
this._isComplete(state) &&
!state.screenshotRequested &&
!state.shareRequested
) {
Expand All @@ -562,24 +565,50 @@ export class Renderer {

config.zoom = state.targetZoom;

const slice = this._getSliceGeometry(state);
const slice = this._getSliceGeometry(config, state, tier);
this._updateUniforms(config, state, slice);

const drawSuccess = this._dispatchDrawCalls(state, tier, slice);
if (!drawSuccess) return false;
this._trackSlice(state, tier, slice);

this._updateBackgroundCanvas(state);
this._handleScreenshot(state);
this._handleShare(state);

return (
state.currentPass < state.totalPasses ||
!this._isComplete(state) ||
state.screenshotRequested ||
state.shareRequested
);
}

_trackSlice(state, tier, slice) {
if (slice.rows === 0 || this._isInteractive(state)) return;
const now = performance.now();
if (slice.yOffset === 0) this.progressiveStart = now;
this.pendingSlice = { tier: tier.name, ops: slice.ops, start: now };
if (this._isComplete(state)) {
performance.measure?.('fractious:full-res', {
start: this.progressiveStart,
end: now,
});
}
}

// Learns GPU throughput from how long each progressive slice takes. Slow slices take
// effect immediately; faster ones can at most double the estimate each time.
recordSliceTime(slice, elapsedMs) {
const previous = this.throughput.get(slice.tier) || Infinity;
const measured = slice.ops / Math.max(elapsedMs, 1);
this.throughput.set(slice.tier, Math.min(measured, previous * 2));
}

onSubmittedWorkDone() {
return this.device.queue.onSubmittedWorkDone();
const slice = this.pendingSlice;
this.pendingSlice = null;
return this.device.queue.onSubmittedWorkDone().then(() => {
if (slice) this.recordSliceTime(slice, performance.now() - slice.start);
});
}
}
67 changes: 67 additions & 0 deletions src/Renderer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { Renderer } from './Renderer.js';

const tier = { name: 'QS', opsMultiplier: 1.0 };
const config = { iter: 10000 };

function createRenderer(width = 1000, height = 1000) {
return new Renderer({ width, height }, null);
}

function progressiveState(nextRow = 0) {
return {
nextRow,
pointers: new Map(),
workerBusy: false,
isPendingUpdate: false,
};
}

describe('Renderer adaptive progressive slices', () => {
it('renders the whole canvas in one slice while interacting', () => {
const renderer = createRenderer();
const state = { ...progressiveState(), workerBusy: true };
expect(renderer._sliceRows(config, state, tier)).toBe(1000);
});

it('starts from the fixed worst-case budget before anything is measured', () => {
const renderer = createRenderer();
// 25M ops / (1000 px * 10000 iter) = 2.5 rows
expect(renderer._sliceRows(config, progressiveState(), tier)).toBe(2);
});

it('grows slices at most twofold per measurement and shrinks immediately', () => {
const renderer = createRenderer();
const slice = { tier: 'QS', ops: 20000000 };

renderer.recordSliceTime(slice, 10); // 2M ops/ms
renderer.recordSliceTime(slice, 0.1); // very fast, capped at 2x
expect(renderer.throughput.get('QS')).toBe(4000000);

renderer.recordSliceTime(slice, 100); // slow slice wins straight away
expect(renderer.throughput.get('QS')).toBe(200000);
});

it('sizes slices to the target time, capped by the stall limit and the rows left', () => {
const renderer = createRenderer();
renderer.throughput.set('QS', 4000000); // 40M ops per 10ms = 4 rows
expect(renderer._sliceRows(config, progressiveState(), tier)).toBe(4);

renderer.throughput.set('QS', 1e12); // capped at 16 * 25M ops = 40 rows
expect(renderer._sliceRows(config, progressiveState(), tier)).toBe(40);
expect(renderer._sliceRows(config, progressiveState(990), tier)).toBe(10);
});

it('computes slice geometry from the next row', () => {
const renderer = createRenderer(1000, 100);
renderer.throughput.set('QS', 1e12);
const slice = renderer._getSliceGeometry(
{ iter: 1000 },
progressiveState(0),
tier,
);
expect(slice).toMatchObject({ yOffset: 0, rows: 100, sliceScale: 1 });
expect(slice.sliceOffset).toBeCloseTo(0, 10);
expect(slice.ops).toBe(100 * 1000 * 1000);
});
});
3 changes: 1 addition & 2 deletions src/State.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ export function createDefaultState() {
screenshotRequested: false,
shareRequested: false,

currentPass: 0,
totalPasses: 1,
nextRow: 0,

dpr: 1,
width: 0,
Expand Down
Loading