diff --git a/README.md b/README.md
index bab9f9b..28fdfd4 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
# Lucent
**A real-time, progressive path tracer that runs entirely on your GPU.**
-TypeScript · WebGL2 · GLSL ES 3.0 · zero runtime dependencies · ~46 kB gzipped
+TypeScript · WebGL2 · GLSL ES 3.0 · zero runtime dependencies · ~49 kB gzipped
**[Open the live demo →](https://clark719664.github.io/lucent/)**
@@ -63,7 +63,9 @@ The glTF models above are in the in-app gallery and stream from the [Khronos glT
- **Denoiser**: an SVGF-style edge-avoiding à-trous filter ([Dammertz 2010](https://jo.dreggn.org/home/2010_atrous.pdf), [Schied 2017](https://research.nvidia.com/publication/2017-07_spatiotemporal-variance-guided-filtering-real-time-reconstruction-path-traced)). The trace pass writes albedo, normal and depth guides at the first non-mirror vertex, so reflections and refractions keep their detail. Lighting is demodulated by albedo, filtered with a variance-guided luminance edge-stop, then multiplied back; the filter's reach shrinks as samples accumulate and it fades out by 1024 spp.
- **Temporal reprojection**: when the camera moves, every sample projects its primary hit into the previous view and reuses that pixel's history if depth and normal agree. How much history a pixel may keep depends on how glossy it is, so mirrors and glass never smear.
-- **Adaptive preview**: resolution drops (never below half, upsampled with Catmull-Rom) only when frames take longer than 40 ms, and samples per frame adapt to the GPU when the camera is still.
+- **GPU budget**: work is scheduled against a share of wall-clock time instead of "as fast as possible". A token bucket earns GPU time as the clock runs and each frame only starts the samples it can afford, so a 25% budget means about 25% even when one sample costs more than a frame. The cost of a sample comes from GPU timer queries where the browser has them, and from a synchronous re-measure every 2 s where it does not.
+- **Presets and a system scan**: Eco, Balanced, High and Max bundle the budget with frame-rate cap, resolution, bounces and a stop-at sample count. *Scan system* times a few samples of the current scene, scales the cost to the full canvas and picks the preset; it runs once on a first visit and the result is kept in local storage. Once the stop count is reached the page stops drawing entirely, and it can pause whenever the window loses focus.
+- **Adaptive preview**: resolution drops (never below half, upsampled with Catmull-Rom) only when frames take longer than 40 ms.
- **HUD** with samples per pixel, samples per second and camera paths per second, all measured.
**Acceleration**
@@ -74,7 +76,7 @@ The glTF models above are in the in-app gallery and stream from the [Khronos glT
**Engineering**
- Strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), ESLint, Vitest.
-- 69 unit tests: the glTF parser (GLB containers, hierarchies, strips, interleaved and sparse data, extensions), the RGBE decoder, alias tables, the environment pdf integrating to one, the sheen table against Monte Carlo, BVH invariants and BVH-vs-brute-force agreement, mesh generators, material and scene packing.
+- 79 unit tests: the GPU-budget scheduler (held to its budget in simulation for per-sample costs from 0.5 to 120 ms), preset recommendation, the glTF parser (GLB containers, hierarchies, strips, interleaved and sparse data, extensions), the RGBE decoder, alias tables, the environment pdf integrating to one, the sheen table against Monte Carlo, BVH invariants and BVH-vs-brute-force agreement, mesh generators, material and scene packing.
- A `#include` preprocessor for GLSL that rewrites driver error messages back to chunk names and line numbers.
- GitHub Actions for lint → typecheck → test → build, and a Pages deployment.
@@ -90,6 +92,8 @@ Measured with `Renderer.benchmark()`, which waits for the GPU with a 1-pixel rea
| A Beautiful Game | 1,499,072 | 49 M | 19.0 |
| Showroom | 9,682 | 47 M | 19.6 |
+Those figures are raw speed (the benchmark ignores the budget). With the budget in force, Windows' per-process GPU counters on the same machine read 44% for Balanced (50% budget), 26% for Eco (25%), and 0.3% once the stop count is reached, while the page keeps running.
+
Showroom is slower than models with twenty times its triangles because sunlit glass and a mirror-finish gold knot keep paths alive for every bounce. One denoiser run costs 2–10 ms at this size. The 1.5 M-triangle chess set builds its BVH in about 3 s on the worker, then packs and uploads in under a second.
## Run it
diff --git a/src/main.ts b/src/main.ts
index 62c79a0..674a81c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -12,7 +12,9 @@ import { installDropZone, pickFiles } from './ui/drop';
import { GalleryView } from './ui/gallery-view';
import { Hud } from './ui/hud';
import { OrbitControls } from './ui/orbit';
-import { Panel } from './ui/panel';
+import { gpuName, Panel } from './ui/panel';
+import { loadPrefs, savePrefs, type StoredScan } from './ui/prefs';
+import { PERFORMANCE_PRESETS, pickPerformance, PRESET_LABELS, recommend, type PerformanceSettings } from './perf';
import './ui/styles.css';
const PRESET_THUMBS = ['showroom', 'cornell', 'material', 'nocturne'];
@@ -52,6 +54,9 @@ function main(): void {
const params = new URLSearchParams(location.search);
const sceneIndex = Math.max(0, Math.min(PRESETS.length - 1, Number(params.get('scene') ?? 0) || 0));
let scene = PRESETS[sceneIndex]!();
+ const prefs = loadPrefs();
+ if (prefs) Object.assign(renderer.settings, prefs.settings);
+ let lastScan: StoredScan | null = prefs?.scan ?? null;
renderer.settings.exposure = scene.exposure;
renderer.applySettings();
renderer.loadScene(scene);
@@ -256,6 +261,12 @@ function main(): void {
onResolutionChange: () => renderer.resize(),
onReset: () => renderer.reset(),
onScreenshot: () => void saveScreenshot(),
+ onPresetChange: (name) => {
+ applyPerformance(PERFORMANCE_PRESETS[name]);
+ notify(`${PRESET_LABELS[name]}: GPU budget ${Math.round(renderer.settings.gpuBudget * 100)}%`);
+ },
+ onScan: () => void runScan(),
+ onPerformanceChange: () => persist(),
});
const toggle = h('button', { class: 'toggle-panel', title: 'Show panel (H)', 'aria-label': 'Show panel', onClick: () => setPanelHidden(false) }, '☰');
@@ -308,18 +319,80 @@ function main(): void {
const ro = new ResizeObserver(() => renderer.resize());
ro.observe(canvas);
- // Don't burn GPU while the tab is hidden, but don't override an explicit pause either.
- let pausedByVisibility = false;
- document.addEventListener('visibilitychange', () => {
- if (document.hidden) {
- pausedByVisibility = !renderer.isPaused;
+ // Don't burn GPU while the tab is hidden (or, if asked, unfocused), but don't override an explicit pause either.
+ let pausedAutomatically = false;
+ const autoPause = (pause: boolean) => {
+ if (pause && !renderer.isPaused) {
+ pausedAutomatically = true;
renderer.setPaused(true);
- } else if (pausedByVisibility) {
- pausedByVisibility = false;
+ } else if (!pause && pausedAutomatically) {
+ pausedAutomatically = false;
renderer.setPaused(false);
}
panel.syncPauseButton();
+ };
+ document.addEventListener('visibilitychange', () => autoPause(document.hidden));
+ window.addEventListener('blur', () => {
+ if (renderer.settings.pauseWhenUnfocused) autoPause(true);
});
+ window.addEventListener('focus', () => autoPause(false));
+
+ const persist = () => savePrefs({ settings: pickPerformance(renderer.settings), scan: lastScan });
+
+ /** Applies cost settings; a preset or scan result replaces all of them at once. */
+ const applyPerformance = (p: PerformanceSettings) => {
+ Object.assign(renderer.settings, p);
+ renderer.applySettings();
+ renderer.resize();
+ renderer.reset();
+ panel.refresh();
+ persist();
+ };
+
+ const scanNote = (s: StoredScan | null) =>
+ s
+ ? `Last scan: ${s.gpu}, ${s.msPerSample.toFixed(1)} ms per sample at ${s.width} × ${s.height} on ${s.scene}, so ${PRESET_LABELS[s.preset]}.`
+ : 'Scan system measures this GPU and picks a preset.';
+ panel.setScanNote(scanNote(lastScan));
+
+ /**
+ * Times a few samples of the current scene (at half resolution on a slow GPU,
+ * so it never stalls long), scales that to the full canvas, and applies the
+ * recommended preset.
+ */
+ const runScan = async () => {
+ if (canvas.clientWidth < 64 || canvas.clientHeight < 64) {
+ notify('The view is too small to measure; make the window larger and scan again.', 5000);
+ return;
+ }
+ notify('Scanning your GPU…', 0);
+ // Lets the toast paint before the GPU is kept busy; a timer also fires while the page is hidden.
+ await new Promise((r) => setTimeout(r, 50));
+ // Probe at half resolution first; only measure at full resolution if one sample there is cheap
+ // (per-draw overhead makes a scaled-up half-resolution figure read high).
+ const measureAt = (scale: number) => {
+ renderer.settings.resolutionScale = scale;
+ renderer.applySettings();
+ renderer.resize();
+ return renderer.benchmark(1);
+ };
+ renderer.settings.maxBounces = PERFORMANCE_PRESETS.balanced.maxBounces;
+ const probe = measureAt(0.5);
+ const scanScale = probe.traceMs * 4 < 250 ? 1 : 0.5;
+ const first = scanScale === 1 ? measureAt(1) : probe;
+ const count = Math.max(1, Math.min(32, Math.round(400 / Math.max(first.traceMs, 1))));
+ const b = renderer.benchmark(count);
+ const fullWidth = Math.round(b.width / scanScale);
+ const fullHeight = Math.round(b.height / scanScale);
+ const msPerSample = (b.traceMs / count) * ((fullWidth * fullHeight) / (b.width * b.height));
+ const rec = recommend({ msPerSample, width: fullWidth, height: fullHeight });
+ lastScan = { msPerSample, width: fullWidth, height: fullHeight, gpu: gpuName(renderer.caps.renderer), preset: rec.preset, scene: scene.name };
+ applyPerformance(rec.settings);
+ panel.setScanNote(scanNote(lastScan));
+ const s = rec.settings;
+ notify(`${lastScan.gpu}: ${msPerSample.toFixed(1)} ms per full-resolution sample, so ${PRESET_LABELS[rec.preset]} ` +
+ `(resolution ${Math.round(s.resolutionScale * 100)}%, GPU budget ${Math.round(s.gpuBudget * 100)}%, stop at ${s.maxSamples || 'never'} spp)`, 7000);
+ };
const requested = params.get('model');
const initialModel = MODELS.find((m) => m.id === requested);
@@ -327,21 +400,34 @@ function main(): void {
if (import.meta.env.DEV) {
// Console handle for debugging and for capturing README screenshots.
- Object.assign(window, { lucent: { renderer, scene: () => scene, openModel, openPreset, selectEnvironment, MODELS } });
+ Object.assign(window, { lucent: { renderer, scene: () => scene, openModel, openPreset, selectEnvironment, runScan, MODELS } });
}
+ // First visit: measure this GPU instead of guessing, once the canvas has a real size
+ // (a page opened in a hidden or collapsed view lays out at zero).
+ const scanWhenVisible = () => {
+ if (canvas.clientWidth >= 64 && canvas.clientHeight >= 64) void runScan();
+ else setTimeout(scanWhenVisible, 500);
+ };
+ if (!prefs) setTimeout(scanWhenVisible, 500);
+
let lastStats = 0;
+ let lastRender = 0;
const loop = (now: number) => {
- try {
- renderer.render();
- } catch (err) {
- showFatal(root, err);
- return;
+ const cap = renderer.settings.fpsCap;
+ if (cap <= 0 || now - lastRender >= 1000 / cap - 2) {
+ lastRender = now;
+ try {
+ renderer.render();
+ } catch (err) {
+ showFatal(root, err);
+ return;
+ }
}
if (now - lastStats > 250) {
const st = renderer.stats();
panel.updateStats(st);
- hud.update(st);
+ hud.update(st, renderer.isPaused);
// Visual convergence indicator: ~1k samples reads as "done" for most scenes.
const target = renderer.settings.maxSamples || 1024;
const p = Math.min(1, st.samples / target);
diff --git a/src/perf.ts b/src/perf.ts
new file mode 100644
index 0000000..3c0c5ce
--- /dev/null
+++ b/src/perf.ts
@@ -0,0 +1,122 @@
+/**
+ * Performance controls. A progressive path tracer will happily keep a GPU at
+ * 100% forever, so Lucent schedules its work against a budget: the share of
+ * wall-clock time the GPU may spend rendering. Presets bundle the budget with
+ * the other knobs that set cost, and a short measured scan picks one.
+ */
+
+export type PresetName = 'eco' | 'balanced' | 'high' | 'max';
+
+export interface PerformanceSettings {
+ /** Share of wall-clock time the GPU may spend rendering, 0.05–1. */
+ gpuBudget: number;
+ /** Redraws per second at most; 0 follows the display. */
+ fpsCap: number;
+ /** Internal resolution as a fraction of the canvas' device-pixel size. */
+ resolutionScale: number;
+ maxBounces: number;
+ /** Stop accumulating at this many samples per pixel; 0 never stops. */
+ maxSamples: number;
+ /** Stop rendering while the window is in the background. */
+ pauseWhenUnfocused: boolean;
+}
+
+export const PERFORMANCE_PRESETS: Readonly> = {
+ eco: { gpuBudget: 0.25, fpsCap: 30, resolutionScale: 0.5, maxBounces: 4, maxSamples: 1024, pauseWhenUnfocused: true },
+ balanced: { gpuBudget: 0.5, fpsCap: 60, resolutionScale: 0.75, maxBounces: 6, maxSamples: 2048, pauseWhenUnfocused: true },
+ high: { gpuBudget: 0.8, fpsCap: 60, resolutionScale: 1, maxBounces: 8, maxSamples: 4096, pauseWhenUnfocused: true },
+ max: { gpuBudget: 1, fpsCap: 0, resolutionScale: 1, maxBounces: 12, maxSamples: 0, pauseWhenUnfocused: false },
+};
+
+export const PRESET_LABELS: Readonly> = {
+ eco: 'Eco',
+ balanced: 'Balanced',
+ high: 'High',
+ max: 'Max',
+};
+
+const PRESET_ORDER: readonly PresetName[] = ['eco', 'balanced', 'high', 'max'];
+
+/** Copies just the performance fields out of a larger settings object. */
+export function pickPerformance(s: PerformanceSettings): PerformanceSettings {
+ const { gpuBudget, fpsCap, resolutionScale, maxBounces, maxSamples, pauseWhenUnfocused } = s;
+ return { gpuBudget, fpsCap, resolutionScale, maxBounces, maxSamples, pauseWhenUnfocused };
+}
+
+/** The preset these settings equal, or `custom` once any knob has been moved. */
+export function matchPreset(s: PerformanceSettings): PresetName | 'custom' {
+ const keys = Object.keys(PERFORMANCE_PRESETS.eco) as (keyof PerformanceSettings)[];
+ return PRESET_ORDER.find((name) => keys.every((k) => PERFORMANCE_PRESETS[name][k] === s[k])) ?? 'custom';
+}
+
+export interface ScanResult {
+ /** Measured GPU time of one sample per pixel at full resolution. */
+ msPerSample: number;
+ width: number;
+ height: number;
+}
+
+export interface Recommendation {
+ preset: PresetName;
+ settings: PerformanceSettings;
+ /** The scan's cost scaled to 1920 × 1080, which is what the tier is judged on. */
+ msAt1080p: number;
+}
+
+/**
+ * Picks a preset from a measured scan: the tier comes from the cost of a
+ * 1080p sample, then the resolution is lowered (in 25% steps, never below the
+ * tier's own) until one sample fits that tier's frame-time target, so camera
+ * moves stay responsive on this screen.
+ */
+export function recommend(scan: ScanResult): Recommendation {
+ const pixels = Math.max(1, scan.width * scan.height);
+ const msAt1080p = (scan.msPerSample * (1920 * 1080)) / pixels;
+ const preset: PresetName = msAt1080p <= 30 ? 'high' : msAt1080p <= 90 ? 'balanced' : 'eco';
+ const targetMs = preset === 'high' ? 20 : preset === 'balanced' ? 25 : 33;
+ const base = PERFORMANCE_PRESETS[preset];
+ // Cost scales with pixel count, i.e. with the square of the resolution scale.
+ const fit = Math.sqrt(targetMs / Math.max(scan.msPerSample, 1e-3));
+ const scale = Math.min(base.resolutionScale, Math.max(0.25, Math.floor(fit * 4) / 4));
+ return { preset, settings: { ...base, resolutionScale: scale }, msAt1080p };
+}
+
+/**
+ * Token bucket for GPU time. Wall-clock time earns `budget` milliseconds of
+ * GPU work per millisecond; work spends it. A frame may only start work it can
+ * afford, so with a 25% budget the average load settles near 25% even when a
+ * single sample costs more than a frame (those frames simply wait).
+ */
+export class GpuBudget {
+ private credit = 0;
+ private last = -1;
+
+ constructor(public budget: number) {}
+
+ /**
+ * Earns credit for the time since the previous call and returns what may be
+ * spent now. `burstMs` caps savings, so a long idle does not end in a stall.
+ */
+ allowance(now: number, burstMs: number): number {
+ // A long gap means the page was throttled or hidden; do not bank it.
+ const dt = this.last < 0 ? 0 : Math.min(now - this.last, 250);
+ this.last = now;
+ this.credit = Math.min(this.credit + dt * this.budget, Math.max(burstMs, 1));
+ return this.credit;
+ }
+
+ spend(ms: number): void {
+ this.credit -= ms;
+ }
+
+ reset(): void {
+ this.credit = 0;
+ this.last = -1;
+ }
+}
+
+/** Samples the current allowance pays for, given what one sample costs. */
+export function affordableSamples(allowanceMs: number, msPerSample: number, cap = 32): number {
+ if (!(msPerSample > 0) || allowanceMs < msPerSample) return 0;
+ return Math.min(cap, Math.floor(allowanceMs / msPerSample));
+}
diff --git a/src/renderer.ts b/src/renderer.ts
index a81d0ad..3338711 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -11,6 +11,7 @@ import {
import { preprocess, type ShaderSources } from './gl/preprocess';
import type { BVH } from './math/bvh';
import { cross, fromSpherical, normalize, scale, sub, type Vec3 } from './math/vec3';
+import { affordableSamples, GpuBudget, PERFORMANCE_PRESETS } from './perf';
import { bakeSky, buildEnvSampler, type HDRImage } from './scene/environment';
import { packScene, pickDistance, type Camera, type Ground, type PackedScene, type Scene } from './scene/scene';
import { SHEEN_LUT_SIZE, sheenAlbedoLUT } from './scene/sheen';
@@ -44,8 +45,14 @@ export interface RenderSettings {
tonemap: Tonemap;
/** Internal resolution as a fraction of the canvas' device-pixel size. */
resolutionScale: number;
- /** Samples traced per animation frame; 0 adapts it to keep frames near 30 ms. */
+ /** Samples traced per frame; 0 lets the GPU budget decide. */
samplesPerFrame: number;
+ /** Share of wall-clock time the GPU may spend rendering, 0.05–1. */
+ gpuBudget: number;
+ /** Redraws per second at most; 0 follows the display. */
+ fpsCap: number;
+ /** Read by the app: stop rendering while the window is in the background. */
+ pauseWhenUnfocused: boolean;
/** Luminance cap for indirect contributions; 0 disables. */
fireflyClamp: number;
/** Stop accumulating after this many samples; 0 means never. */
@@ -58,14 +65,19 @@ export interface RenderSettings {
temporal: boolean;
}
+const BALANCED = PERFORMANCE_PRESETS.balanced;
+
export const DEFAULT_SETTINGS: RenderSettings = {
- maxBounces: 6,
+ maxBounces: BALANCED.maxBounces,
exposure: 1,
tonemap: 'aces',
- resolutionScale: 1,
+ resolutionScale: BALANCED.resolutionScale,
samplesPerFrame: 0,
+ gpuBudget: BALANCED.gpuBudget,
+ fpsCap: BALANCED.fpsCap,
+ pauseWhenUnfocused: BALANCED.pauseWhenUnfocused,
fireflyClamp: 8,
- maxSamples: 0,
+ maxSamples: BALANCED.maxSamples,
denoise: true,
adaptiveResolution: true,
temporal: true,
@@ -85,6 +97,14 @@ export interface RenderStats {
viewHeight: number;
interactive: boolean;
denoised: boolean;
+ /** The sample limit has been reached; the GPU is idle. */
+ done: boolean;
+ /** Share of the last second the GPU spent on Lucent's work, or null before it is known. */
+ gpuLoad: number | null;
+ /** True when `gpuLoad` comes from GPU timer queries rather than per-sample estimates. */
+ gpuLoadMeasured: boolean;
+ /** Current estimate of one sample's GPU cost at the traced resolution. */
+ msPerSample: number;
triangles: number;
bvhNodes: number;
textures: number;
@@ -190,9 +210,16 @@ export class Renderer {
private elapsedAtPause = 0;
private lastMotion = -Infinity;
private lastFrameTime = 0;
+ private readonly budget = new GpuBudget(DEFAULT_SETTINGS.gpuBudget);
+ /** Measured GPU cost of one sample at the current traced resolution; 0 = unknown. */
+ private msPerSample = 0;
+ private lastSyncMeasure = 0;
+ private readonly timer: { ext: { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number }; pending: { query: WebGLQuery; samples: number }[] } | null;
+ private activeQuery: { query: WebGLQuery; samples: number } | null = null;
+ private gpuWindow = { time: 0, busyMs: 0, load: null as number | null };
+ private displayKey = '';
private interactive = false;
private previewScale = 1;
- private autoSpp = 1;
private denoisedFrame = -1;
private denoised: RenderTarget | null = null;
@@ -205,6 +232,8 @@ export class Renderer {
this.variance = new Program(gl, fullscreenVert, preprocess('variance.frag', VARIANCE_SOURCES), VARIANCE_SOURCES);
this.atrous = new Program(gl, fullscreenVert, preprocess('atrous.frag', ATROUS_SOURCES), ATROUS_SOURCES);
this.resample = new Program(gl, fullscreenVert, resampleFrag);
+ const timerExt = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number } | null;
+ this.timer = timerExt ? { ext: timerExt, pending: [] } : null;
const vao = gl.createVertexArray();
if (!vao) throw new Error('Failed to create VAO');
this.vao = vao;
@@ -279,6 +308,7 @@ export class Renderer {
this.uploadLighting(scene);
this.uploadCamera(scene.camera, false);
this.reset();
+ this.msPerSample = 0;
}
/** Resamples every referenced image into one sRGB and one linear texture array. */
@@ -426,6 +456,7 @@ export class Renderer {
this.trace.i1('uMaxBounces', this.settings.maxBounces);
this.trace.f1('uClamp', this.settings.fireflyClamp);
this.denoisedFrame = -1;
+ this.msPerSample = 0;
}
/** Resets accumulation; cheap, so callers can be liberal. */
@@ -460,6 +491,7 @@ export class Renderer {
this.accum = [new RenderTarget(gl, w, h, guides), new RenderTarget(gl, w, h, guides)];
this.filtered = [new RenderTarget(gl, w, h), new RenderTarget(gl, w, h)];
this.reset();
+ this.msPerSample = 0;
return true;
}
@@ -500,13 +532,23 @@ export class Renderer {
const rw = Math.max(1, Math.round(this.width * s));
const rh = Math.max(1, Math.round(this.height * s));
if (rw !== this.renderWidth || rh !== this.renderHeight) {
+ // Cost scales with pixel count, so the estimate survives a preview resize.
+ this.msPerSample *= (rw * rh) / (this.renderWidth * this.renderHeight);
this.renderWidth = rw;
this.renderHeight = rh;
this.invalidateView();
}
}
- /** Traces this frame's samples, filters, then presents. Returns true if any tracing was done. */
+ /** True once the sample limit is reached: nothing is traced until something changes. */
+ get isDone(): boolean {
+ return this.settings.maxSamples > 0 && this.frame >= this.settings.maxSamples;
+ }
+
+ /**
+ * Traces the samples this frame's GPU budget pays for, filters, then presents
+ * if anything visible changed. Returns true if any tracing was done.
+ */
render(): boolean {
if (!this.accum || !this.filtered || !this.packed) return false;
const gl = this.gl;
@@ -514,22 +556,11 @@ export class Renderer {
const dt = this.lastFrameTime ? now - this.lastFrameTime : 0;
this.lastFrameTime = now;
this.updatePreview(now, dt);
+ this.pollTimers();
- const { maxSamples, samplesPerFrame } = this.settings;
- const auto = samplesPerFrame <= 0;
- let spp = this.interactive ? 1 : auto ? this.autoSpp : samplesPerFrame;
- if (maxSamples > 0) spp = Math.min(spp, maxSamples - this.frame);
- const canTrace = !this.paused && spp > 0;
const rw = this.renderWidth;
const rh = this.renderHeight;
-
- if (canTrace) {
- if (auto && !this.interactive && dt > 0) {
- if (dt < 24) this.autoSpp = Math.min(32, this.autoSpp + 1);
- else if (dt > 40) this.autoSpp = Math.max(1, Math.floor(this.autoSpp * 0.75));
- }
- this.traceSamples(spp, rw, rh);
- }
+ const traced = !this.paused && !this.isDone ? this.traceBudgeted(now, rw, rh) : 0;
const latest = this.accum[0];
const denoise = this.settings.denoise && this.frame > 0 && denoiseWeight(this.frame) > 0;
@@ -537,22 +568,29 @@ export class Renderer {
this.runDenoiser(latest, rw, rh);
this.denoisedFrame = this.frame;
}
-
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
- gl.viewport(0, 0, this.width, this.height);
- const d = this.display;
- d.use();
- gl.bindVertexArray(this.vao);
- this.bindUnits([latest.textures[0]!, latest.textures[1]!, (this.denoised ?? this.filtered[0]).texture]);
- d.i1('uColor', 0);
- d.i1('uAlbedo', 1);
- d.i1('uDenoised', 2);
- d.f1('uDenoiseWeight', denoise ? denoiseWeight(this.frame) : 0);
- d.f2('uRenderSize', rw, rh);
- d.f2('uViewSize', this.width, this.height);
- d.f1('uExposure', this.settings.exposure);
- d.i1('uTonemap', this.settings.tonemap === 'aces' ? 0 : this.settings.tonemap === 'reinhard' ? 1 : 2);
- gl.drawArrays(gl.TRIANGLES, 0, 3);
+ this.endTimer();
+
+ const weight = denoise ? denoiseWeight(this.frame) : 0;
+ const { exposure, tonemap } = this.settings;
+ const key = [this.seed, this.frame, rw, rh, this.width, this.height, weight, exposure, tonemap].join('|');
+ if (key !== this.displayKey) {
+ this.displayKey = key;
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ gl.viewport(0, 0, this.width, this.height);
+ const d = this.display;
+ d.use();
+ gl.bindVertexArray(this.vao);
+ this.bindUnits([latest.textures[0]!, latest.textures[1]!, (this.denoised ?? this.filtered[0]).texture]);
+ d.i1('uColor', 0);
+ d.i1('uAlbedo', 1);
+ d.i1('uDenoised', 2);
+ d.f1('uDenoiseWeight', weight);
+ d.f2('uRenderSize', rw, rh);
+ d.f2('uViewSize', this.width, this.height);
+ d.f1('uExposure', exposure);
+ d.i1('uTonemap', tonemap === 'aces' ? 0 : tonemap === 'reinhard' ? 1 : 2);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ }
const w = this.sampleWindow;
if (now - w.time > 1000) {
@@ -563,7 +601,102 @@ export class Renderer {
w.samples = 0;
w.paths = 0;
}
- return canTrace;
+ const g = this.gpuWindow;
+ if (now - g.time > 1000) {
+ g.load = g.time > 0 ? Math.min(1, g.busyMs / (now - g.time)) : null;
+ g.time = now;
+ g.busyMs = 0;
+ }
+ return traced > 0;
+ }
+
+ /**
+ * Spends this frame's share of the GPU budget on samples. The cost of a
+ * sample comes from timer queries when the browser offers them; otherwise it
+ * is re-measured with a synchronous readback every couple of seconds.
+ */
+ private traceBudgeted(now: number, rw: number, rh: number): number {
+ const { samplesPerFrame, maxSamples, gpuBudget, fpsCap } = this.settings;
+ const remaining = maxSamples > 0 ? maxSamples - this.frame : Infinity;
+ if (samplesPerFrame > 0) {
+ const n = Math.min(samplesPerFrame, remaining);
+ this.beginTimer(n);
+ this.traceSamples(n, rw, rh);
+ return n;
+ }
+
+ this.budget.budget = gpuBudget;
+ const frameMs = 1000 / (fpsCap > 0 ? fpsCap : 60);
+ if (this.msPerSample <= 0 || (!this.timer && now - this.lastSyncMeasure > 2000)) {
+ const ms = this.measureSample(rw, rh);
+ this.msPerSample = this.msPerSample > 0 ? this.msPerSample * 0.6 + ms * 0.4 : ms;
+ this.lastSyncMeasure = now;
+ this.budget.allowance(now, 0);
+ this.budget.spend(ms);
+ this.gpuWindow.busyMs += ms;
+ return 1;
+ }
+
+ const allowance = this.budget.allowance(now, Math.max(frameMs * gpuBudget * 2, this.msPerSample * 1.05));
+ // Moving the camera always gets a sample; the budget is repaid once it stops.
+ const n = Math.min(this.interactive ? 1 : affordableSamples(allowance, this.msPerSample), remaining);
+ if (n <= 0) return 0;
+ this.beginTimer(n);
+ this.traceSamples(n, rw, rh);
+ this.budget.spend(n * this.msPerSample);
+ if (!this.timer) this.gpuWindow.busyMs += n * this.msPerSample;
+ return n;
+ }
+
+ /** Traces one sample and waits for the GPU (1-pixel readback) to time it. */
+ private measureSample(rw: number, rh: number): number {
+ this.syncGPU();
+ const t0 = performance.now();
+ this.traceSamples(1, rw, rh);
+ this.syncGPU();
+ return performance.now() - t0;
+ }
+
+ private syncGPU(): void {
+ const gl = this.gl;
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.accum![0].framebuffer);
+ gl.readBuffer(gl.COLOR_ATTACHMENT0);
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.FLOAT, new Float32Array(4));
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ }
+
+ private beginTimer(samples: number): void {
+ if (!this.timer || this.activeQuery || this.timer.pending.length >= 4) return;
+ const query = this.gl.createQuery();
+ if (!query) return;
+ this.gl.beginQuery(this.timer.ext.TIME_ELAPSED_EXT, query);
+ this.activeQuery = { query, samples };
+ }
+
+ private endTimer(): void {
+ if (!this.timer || !this.activeQuery) return;
+ this.gl.endQuery(this.timer.ext.TIME_ELAPSED_EXT);
+ this.timer.pending.push(this.activeQuery);
+ this.activeQuery = null;
+ }
+
+ /** Folds finished timer queries into the per-sample cost and the measured GPU load. */
+ private pollTimers(): void {
+ if (!this.timer) return;
+ const gl = this.gl;
+ const pending = this.timer.pending;
+ while (pending.length > 0) {
+ const { query, samples } = pending[0]!;
+ if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
+ const disjoint = gl.getParameter(this.timer.ext.GPU_DISJOINT_EXT) as boolean;
+ if (!disjoint) {
+ const ms = (gl.getQueryParameter(query, gl.QUERY_RESULT) as number) / 1e6;
+ this.gpuWindow.busyMs += ms;
+ if (samples > 0) this.msPerSample = this.msPerSample > 0 ? this.msPerSample * 0.8 + (ms / samples) * 0.2 : ms / samples;
+ }
+ gl.deleteQuery(query);
+ pending.shift();
+ }
}
private traceSamples(count: number, rw: number, rh: number): void {
@@ -628,6 +761,7 @@ export class Renderer {
this.traceSamples(samples, this.width, this.height);
sync(this.accum[0]);
const traceMs = performance.now() - t0;
+ this.msPerSample = traceMs / samples;
t0 = performance.now();
this.runDenoiser(this.accum[0], this.width, this.height);
sync(this.denoised!);
@@ -697,6 +831,10 @@ export class Renderer {
stats(): RenderStats {
return {
+ done: this.isDone,
+ gpuLoad: this.paused || this.isDone ? 0 : this.gpuWindow.load,
+ gpuLoadMeasured: this.timer !== null,
+ msPerSample: this.msPerSample,
samples: this.frame,
elapsedMs: this.paused ? this.elapsedAtPause : performance.now() - this.startTime,
msamplesPerSec: this.sampleWindow.pathRate,
diff --git a/src/ui/hud.ts b/src/ui/hud.ts
index 9d1d748..b782663 100644
--- a/src/ui/hud.ts
+++ b/src/ui/hud.ts
@@ -1,19 +1,25 @@
import type { RenderStats } from '../renderer';
import { formatCount, formatDuration, h } from './dom';
-/** Corner readout of convergence and measured throughput; visible even with the panel hidden. */
+/** Corner readout of convergence, measured throughput and GPU load; visible even with the panel hidden. */
export class Hud {
readonly root = h('div', { class: 'hud', 'aria-live': 'off' });
- update(st: RenderStats): void {
- const items: (string | HTMLElement)[] = [
- `${formatCount(st.samples)} spp`,
- `${st.sppPerSec >= 100 ? st.sppPerSec.toFixed(0) : st.sppPerSec.toFixed(1)} spp/s`,
- `${st.msamplesPerSec.toFixed(1)} Mpaths/s`,
- formatDuration(st.elapsedMs),
- ];
+ update(st: RenderStats, paused: boolean): void {
+ const items: (string | HTMLElement)[] = [`${formatCount(st.samples)} spp`];
+ const idle = paused || st.done;
+ if (!idle) {
+ items.push(
+ `${st.sppPerSec >= 100 ? st.sppPerSec.toFixed(0) : st.sppPerSec.toFixed(1)} spp/s`,
+ `${st.msamplesPerSec.toFixed(1)} Mpaths/s`,
+ );
+ }
+ if (st.gpuLoad !== null) items.push(`GPU ${st.gpuLoadMeasured ? '' : '~'}${Math.round(st.gpuLoad * 100)}%`);
+ items.push(formatDuration(st.elapsedMs));
if (st.interactive) items.push(h('span', { class: 'hud-tag' }, `preview ${Math.round((st.width / st.viewWidth) * 100)}%`));
- if (st.denoised) items.push(h('span', { class: 'hud-tag' }, 'denoised'));
+ if (st.denoised && !idle) items.push(h('span', { class: 'hud-tag' }, 'denoised'));
+ if (st.done) items.push(h('span', { class: 'hud-tag' }, 'done'));
+ else if (paused) items.push(h('span', { class: 'hud-tag' }, 'paused'));
this.root.replaceChildren(...items.map((i) => (typeof i === 'string' ? h('span', {}, i) : i)));
}
}
diff --git a/src/ui/panel.ts b/src/ui/panel.ts
index 8e4ac65..8d07540 100644
--- a/src/ui/panel.ts
+++ b/src/ui/panel.ts
@@ -1,4 +1,5 @@
import { HDRIS } from '../gallery';
+import { matchPreset, pickPerformance, PRESET_LABELS, type PresetName } from '../perf';
import type { Renderer, RenderStats, Tonemap } from '../renderer';
import type { Scene } from '../scene/scene';
import { formatCount, formatDuration, h } from './dom';
@@ -15,6 +16,10 @@ export interface PanelEvents {
onResolutionChange: () => void;
onReset: () => void;
onScreenshot: () => void;
+ onPresetChange: (preset: PresetName) => void;
+ onScan: () => void;
+ /** Any performance knob moved; settings are already updated. */
+ onPerformanceChange: () => void;
}
interface SliderSpec {
@@ -45,6 +50,9 @@ export class Panel {
private readonly statsEl: HTMLElement;
private readonly sliders: { spec: SliderSpec; input: HTMLInputElement; value: HTMLElement }[] = [];
private readonly toggles: { spec: ToggleSpec; input: HTMLInputElement }[] = [];
+ private readonly choices: { get: () => string; select: HTMLSelectElement }[] = [];
+ private readonly presetSelect: HTMLSelectElement;
+ private readonly scanNote: HTMLElement;
private readonly sceneName: HTMLElement;
private readonly sceneDesc: HTMLElement;
private readonly pauseBtn: HTMLButtonElement;
@@ -104,6 +112,23 @@ export class Panel {
this.slider({ label: 'Sky intensity', min: 0, max: 4, step: 0.05, get: () => sky().intensity, set: (v) => (sky().intensity = v), format: (v) => v.toFixed(2), onInput: events.onLightingChange }),
);
+ this.presetSelect = h('select', {
+ class: 'select',
+ 'aria-label': 'Performance preset',
+ onChange: (e: Event) => {
+ const v = (e.target as HTMLSelectElement).value;
+ if (v !== 'custom') events.onPresetChange(v as PresetName);
+ },
+ },
+ ...(Object.keys(PRESET_LABELS) as PresetName[]).map((k) => h('option', { value: k }, PRESET_LABELS[k])),
+ h('option', { value: 'custom' }, 'Custom'),
+ );
+ this.scanNote = h('p', { class: 'hint' });
+ const perfChanged = () => {
+ this.syncPreset();
+ events.onPerformanceChange();
+ };
+
this.root = h('aside', { class: 'panel' },
h('header', { class: 'panel-header' },
h('div', { class: 'brand' },
@@ -128,6 +153,21 @@ export class Panel {
),
),
+ this.section('Performance',
+ h('div', { class: 'row' },
+ this.presetSelect,
+ h('button', { class: 'btn btn-fit', title: 'Measure this GPU and apply the recommended settings', onClick: () => events.onScan() }, 'Scan system'),
+ ),
+ this.scanNote,
+ this.slider({ label: 'GPU budget', min: 10, max: 100, step: 5, get: () => Math.round(s.gpuBudget * 100), set: (v) => (s.gpuBudget = v / 100), format: (v) => `${v}%`, onInput: perfChanged }),
+ this.choice('Frame rate', [['30', '30 fps'], ['60', '60 fps'], ['0', 'Display rate']], () => String(s.fpsCap), (v) => (s.fpsCap = Number(v)), perfChanged),
+ this.choice('Stop at', [['512', '512 spp'], ['1024', '1,024 spp'], ['2048', '2,048 spp'], ['4096', '4,096 spp'], ['8192', '8,192 spp'], ['0', 'Never']],
+ () => String(s.maxSamples), (v) => (s.maxSamples = Number(v)), perfChanged),
+ this.slider({ label: 'Resolution', min: 0.25, max: 2, step: 0.25, get: () => s.resolutionScale, set: (v) => (s.resolutionScale = v), format: (v) => `${Math.round(v * 100)}%`, onInput: () => { events.onResolutionChange(); perfChanged(); } }),
+ this.slider({ label: 'Max bounces', min: 1, max: 24, step: 1, get: () => s.maxBounces, set: (v) => (s.maxBounces = v), onInput: () => { events.onSettingsChange(); perfChanged(); } }),
+ this.toggle({ label: 'Pause when the window loses focus', get: () => s.pauseWhenUnfocused, set: (v) => (s.pauseWhenUnfocused = v), onChange: perfChanged }),
+ ),
+
this.section('Environment',
this.envSelect,
this.hdriFields,
@@ -148,10 +188,7 @@ export class Panel {
this.toggle({ label: 'Denoiser', get: () => s.denoise, set: (v) => (s.denoise = v), onChange: () => undefined }),
this.toggle({ label: 'Reuse samples while moving', get: () => s.temporal, set: (v) => (s.temporal = v), onChange: () => undefined }),
this.toggle({ label: 'Lower resolution when slow', get: () => s.adaptiveResolution, set: (v) => (s.adaptiveResolution = v), onChange: () => undefined }),
- this.slider({ label: 'Max bounces', min: 1, max: 24, step: 1, get: () => s.maxBounces, set: (v) => (s.maxBounces = v), onInput: events.onSettingsChange }),
this.slider({ label: 'Firefly clamp', min: 0, max: 50, step: 0.5, get: () => s.fireflyClamp, set: (v) => (s.fireflyClamp = v), format: (v) => (v === 0 ? 'off' : v.toFixed(1)), onInput: events.onSettingsChange }),
- this.slider({ label: 'Samples / frame', min: 0, max: 16, step: 1, get: () => s.samplesPerFrame, set: (v) => (s.samplesPerFrame = v), format: (v) => (v === 0 ? 'auto' : String(v)), onInput: () => undefined }),
- this.slider({ label: 'Resolution', min: 0.25, max: 2, step: 0.25, get: () => s.resolutionScale, set: (v) => (s.resolutionScale = v), format: (v) => `${Math.round(v * 100)}%`, onInput: events.onResolutionChange }),
),
this.section('Display',
@@ -194,6 +231,8 @@ export class Panel {
value.textContent = spec.format ? spec.format(v) : String(v);
}
for (const { spec, input } of this.toggles) input.checked = spec.get();
+ for (const { get, select } of this.choices) select.value = get();
+ this.syncPreset();
const hdri = this.scene.env.map !== null;
this.sunSection.hidden = hdri;
this.hdriFields.hidden = !hdri;
@@ -217,6 +256,8 @@ export class Panel {
['Samples / pixel', formatCount(st.samples)],
['Elapsed', formatDuration(st.elapsedMs)],
['Throughput', `${st.msamplesPerSec.toFixed(1)} Mpaths/s`],
+ ['GPU load', st.gpuLoad === null ? 'measuring' : `${Math.round(st.gpuLoad * 100)}% ${st.gpuLoadMeasured ? '(timer query)' : '(estimated)'}`],
+ ['Cost / sample', st.msPerSample > 0 ? `${st.msPerSample.toFixed(1)} ms` : 'measuring'],
['Resolution', res],
['Triangles', formatCount(st.triangles)],
['BVH nodes', formatCount(st.bvhNodes)],
@@ -259,6 +300,28 @@ export class Panel {
);
}
+ /** Shows the measured scan result (or a hint before the first scan). */
+ setScanNote(text: string): void {
+ this.scanNote.textContent = text;
+ }
+
+ private syncPreset(): void {
+ this.presetSelect.value = matchPreset(pickPerformance(this.renderer.settings));
+ }
+
+ private choice(label: string, options: [string, string][], get: () => string, set: (v: string) => void, onChange: () => void): HTMLElement {
+ const select = h('select', {
+ class: 'select',
+ onChange: (e: Event) => {
+ set((e.target as HTMLSelectElement).value);
+ onChange();
+ },
+ }, ...options.map(([value, text]) => h('option', { value }, text)));
+ select.value = get();
+ this.choices.push({ get, select });
+ return h('label', { class: 'field' }, h('span', {}, label), select);
+ }
+
private toggle(spec: ToggleSpec): HTMLElement {
const input = h('input', {
type: 'checkbox',
diff --git a/src/ui/prefs.ts b/src/ui/prefs.ts
new file mode 100644
index 0000000..0aa5cef
--- /dev/null
+++ b/src/ui/prefs.ts
@@ -0,0 +1,43 @@
+import { PERFORMANCE_PRESETS, type PerformanceSettings, type PresetName } from '../perf';
+
+const KEY = 'lucent.performance.v1';
+
+export interface StoredScan {
+ msPerSample: number;
+ width: number;
+ height: number;
+ gpu: string;
+ preset: PresetName;
+ scene: string;
+}
+
+export interface Prefs {
+ settings: PerformanceSettings;
+ scan: StoredScan | null;
+}
+
+const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
+
+/** Saved performance settings, or null on first visit (or when storage is unavailable or corrupt). */
+export function loadPrefs(): Prefs | null {
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return null;
+ const p = JSON.parse(raw) as Partial;
+ const s = p.settings;
+ const base = PERFORMANCE_PRESETS.balanced;
+ if (!s || !(Object.keys(base) as (keyof PerformanceSettings)[]).every((k) => typeof s[k] === typeof base[k])) return null;
+ if (![s.gpuBudget, s.fpsCap, s.resolutionScale, s.maxBounces, s.maxSamples].every(isNumber)) return null;
+ return { settings: s, scan: p.scan ?? null };
+ } catch {
+ return null;
+ }
+}
+
+export function savePrefs(prefs: Prefs): void {
+ try {
+ localStorage.setItem(KEY, JSON.stringify(prefs));
+ } catch {
+ // Private mode or blocked storage: settings simply will not persist.
+ }
+}
diff --git a/src/ui/styles.css b/src/ui/styles.css
index 10fa22c..691b1db 100644
--- a/src/ui/styles.css
+++ b/src/ui/styles.css
@@ -151,6 +151,8 @@ input[type='range']:focus-visible { outline: 2px solid var(--accent-2); outline-
.btn-accent { background: rgba(255, 184, 107, 0.14); border-color: rgba(255, 184, 107, 0.35); color: var(--accent); }
.btn-accent:hover { background: rgba(255, 184, 107, 0.24); }
.row { display: flex; gap: 6px; }
+.row > .select { flex: 1; min-width: 0; width: auto; }
+.btn.btn-fit { flex: none; white-space: nowrap; }
.toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; line-height: 1.3; }
.toggle input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
diff --git a/test/perf.test.ts b/test/perf.test.ts
new file mode 100644
index 0000000..880c565
--- /dev/null
+++ b/test/perf.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest';
+import { affordableSamples, GpuBudget, matchPreset, PERFORMANCE_PRESETS, pickPerformance, recommend } from '../src/perf';
+
+/** Runs the scheduler for `seconds` of 60 Hz frames and returns the share of time spent on GPU work. */
+function simulate(budget: number, msPerSample: number, seconds = 10): number {
+ const b = new GpuBudget(budget);
+ const frame = 1000 / 60;
+ let busy = 0;
+ for (let now = 0; now < seconds * 1000; now += frame) {
+ const allowance = b.allowance(now, Math.max(frame * budget * 2, msPerSample * 1.05));
+ const n = affordableSamples(allowance, msPerSample);
+ b.spend(n * msPerSample);
+ busy += n * msPerSample;
+ }
+ return busy / (seconds * 1000);
+}
+
+describe('GPU budget', () => {
+ it('holds the average load near the budget for cheap and expensive samples', () => {
+ for (const budget of [0.1, 0.25, 0.5, 0.8]) {
+ for (const cost of [0.5, 4, 16, 40, 120]) {
+ expect(simulate(budget, cost)).toBeCloseTo(budget, 1);
+ }
+ }
+ });
+
+ it('can use the whole GPU when the budget is 100%', () => {
+ expect(simulate(1, 3)).toBeGreaterThan(0.95);
+ });
+
+ it('does not bank credit across a long stall', () => {
+ const b = new GpuBudget(0.5);
+ b.allowance(0, 20);
+ expect(b.allowance(10_000, 20)).toBeLessThanOrEqual(20);
+ });
+
+ it('affords whole samples only', () => {
+ expect(affordableSamples(9.9, 5)).toBe(1);
+ expect(affordableSamples(4.9, 5)).toBe(0);
+ expect(affordableSamples(1000, 1)).toBe(32);
+ expect(affordableSamples(10, 0)).toBe(0);
+ });
+});
+
+describe('presets', () => {
+ it('round-trip through matchPreset and mark edits as custom', () => {
+ for (const [name, p] of Object.entries(PERFORMANCE_PRESETS)) expect(matchPreset({ ...p })).toBe(name);
+ expect(matchPreset({ ...PERFORMANCE_PRESETS.balanced, gpuBudget: 0.55 })).toBe('custom');
+ });
+
+ it('pickPerformance drops unrelated settings', () => {
+ const picked = pickPerformance({ ...PERFORMANCE_PRESETS.eco, exposure: 2 } as typeof PERFORMANCE_PRESETS.eco);
+ expect(picked).toEqual(PERFORMANCE_PRESETS.eco);
+ });
+
+ it('get cheaper from max to eco', () => {
+ const order = ['max', 'high', 'balanced', 'eco'] as const;
+ for (let i = 1; i < order.length; i++) {
+ const a = PERFORMANCE_PRESETS[order[i - 1]!];
+ const b = PERFORMANCE_PRESETS[order[i]!];
+ expect(b.gpuBudget).toBeLessThan(a.gpuBudget);
+ expect(b.resolutionScale).toBeLessThanOrEqual(a.resolutionScale);
+ expect(b.maxBounces).toBeLessThan(a.maxBounces);
+ }
+ });
+});
+
+describe('recommend', () => {
+ it('picks the tier from the cost of a 1080p sample', () => {
+ expect(recommend({ msPerSample: 10, width: 1920, height: 1080 }).preset).toBe('high');
+ expect(recommend({ msPerSample: 60, width: 1920, height: 1080 }).preset).toBe('balanced');
+ expect(recommend({ msPerSample: 200, width: 1920, height: 1080 }).preset).toBe('eco');
+ // The same GPU on a 4K canvas pays four times per sample but is still the same tier.
+ expect(recommend({ msPerSample: 40, width: 3840, height: 2160 }).msAt1080p).toBeCloseTo(10);
+ });
+
+ it('lowers resolution until one sample fits the tier target, in quarter steps', () => {
+ const r = recommend({ msPerSample: 80, width: 2560, height: 1440 });
+ expect(r.preset).toBe('balanced');
+ // sqrt(25 / 80) = 0.56 → 0.5
+ expect(r.settings.resolutionScale).toBe(0.5);
+ expect(r.settings.gpuBudget).toBe(PERFORMANCE_PRESETS.balanced.gpuBudget);
+ const slow = recommend({ msPerSample: 5000, width: 1920, height: 1080 });
+ expect(slow.settings.resolutionScale).toBe(0.25);
+ });
+
+ it('never raises resolution above the preset', () => {
+ expect(recommend({ msPerSample: 1, width: 1920, height: 1080 }).settings.resolutionScale).toBe(1);
+ expect(recommend({ msPerSample: 100, width: 1280, height: 720 }).settings.resolutionScale).toBeLessThanOrEqual(0.5);
+ });
+});