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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Lucent

**A real-time, progressive path tracer that runs entirely on your GPU.**<br>
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/)**

Expand Down Expand Up @@ -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**
Expand All @@ -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.

Expand All @@ -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
Expand Down
116 changes: 101 additions & 15 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) }, '☰');
Expand Down Expand Up @@ -308,40 +319,115 @@ 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);
if (initialModel) void openModel(initialModel);

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);
Expand Down
122 changes: 122 additions & 0 deletions src/perf.ts
Original file line number Diff line number Diff line change
@@ -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<Record<PresetName, PerformanceSettings>> = {
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<Record<PresetName, string>> = {
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));
}
Loading
Loading