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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
bench/dist/
*.log
.DS_Store

bench/results/windows-local/
6 changes: 5 additions & 1 deletion bench/src/benchmark/BenchmarkResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ class BenchmarkResolverAdapter implements BenchmarkResolver {
) {
this.metadata = metadata;
this._upscaler = upscaler;
this._upscaler.init();

if (typeof metadata.settings.rcasDenoise === 'boolean')
this._upscaler.settings.rcasDenoise = metadata.settings.rcasDenoise;
}

prepare(): Promise<void> { return this._upscaler.prepare(); }

get isReady(): boolean { return this._upscaler.isReady; }

get outputTexture(): THREE.Texture {
return this._upscaler.outputTexture;
}
Expand Down
6 changes: 5 additions & 1 deletion bench/src/benchmark/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ class BrowserBenchmarkApi implements UpscalerBenchmarkApi {

async reset(): Promise<void> {
const { pipeline, bench, config, scenario, camera } = this._context;
if (pipeline) await pipeline.drainTiming();
if (pipeline) {
await pipeline.resolver.prepare();
await pipeline.drainTiming();
}
const canvas = this._context.renderer.domElement;
const resized =
canvas.width !== config.dimensions.width || canvas.height !== config.dimensions.height;
Expand Down Expand Up @@ -202,6 +205,7 @@ class BrowserBenchmarkApi implements UpscalerBenchmarkApi {
pipeline.setHostPreExposure(scenarioFrame.hostPreExposure);
if (scenarioFrame.resetHistory) pipeline.reset(bench.scene, camera, frame);

await pipeline.resolver.prepare();
await pipeline.prepareTiming();
pipeline.advanceAutomatedFrame(frame);
pipeline.renderInput(
Expand Down
59 changes: 42 additions & 17 deletions bench/src/candidates/CandidateUpscaler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ export class CandidateUpscaler {
private _historyIndex = 0;
private _depthIndex = 0;
private _initialized = false;
private _preparing: Promise<void> | null = null;
private _generation = 0;
private _deviceLost = false;

// GPU-side working set (owned raw textures + the three-visible output)
private _output: StorageTexture | null = null;
Expand Down Expand Up @@ -272,9 +275,22 @@ export class CandidateUpscaler {
/**
* Compiles all compute pipelines. Call once after `renderer.init()`.
*/
init(): void {
if (this._initialized) return;
get isReady(): boolean { return this._initialized && !this._deviceLost; }

prepare(): Promise<void> { return this.init(); }

init(): Promise<void> {
if (this._deviceLost) return Promise.reject(new Error("Candidate GPU device lost."));
if (this._initialized) return Promise.resolve();
if (this._preparing) return this._preparing;
this._preparing = this._compile();
return this._preparing;
}

private async _compile(): Promise<void> {
const generation = this._generation;
const device = getDevice(this._renderer);
void device.lost.then(() => { if (generation === this._generation) this._deviceLost = true; });
this._device = device;
this._constants = new ConstantsBuffer(device);
this._timer = new GpuTimer(device);
Expand All @@ -286,8 +302,15 @@ export class CandidateUpscaler {
addressModeV: 'clamp-to-edge',
});

this._blitPass = new ComputePass(device, 'blit', BLIT_SHADER);
this._easuPass = new ComputePass(
this._jitter = new JitterSequence(this._ratio);
const create = async (...args: Parameters<typeof ComputePass.create>): Promise<ComputePass> => {
const pass = await ComputePass.create(...args);
if (generation !== this._generation || this._deviceLost) throw new Error('Candidate preparation cancelled.');
return pass;
};

this._blitPass = await create(device, 'blit', BLIT_SHADER);
this._easuPass = await create(
device,
'easu',
this._candidateBundle ? EASU_SOURCE_APPROX_SHADER : EASU_SHADER,
Expand All @@ -298,8 +321,8 @@ export class CandidateUpscaler {
}
: {},
);
this._rcasPass = new ComputePass(device, 'rcas', this._rcasShader);
this._reconstructPass = new ComputePass(
this._rcasPass = await create(device, 'rcas', this._rcasShader);
this._reconstructPass = await create(
device,
'reconstruct',
this._candidateBundle ? PREPARE_INPUTS_SOURCE_SHADER : RECONSTRUCT_SHADER,
Expand All @@ -326,7 +349,7 @@ export class CandidateUpscaler {
: this._candidateBundle
? ACCUMULATE_SOURCE_FILTER_SHADER
: ACCUMULATE_SHADER;
this._accumulatePass = new ComputePass(device, 'accumulate', accumulateShader, {
this._accumulatePass = await create(device, 'accumulate', accumulateShader, {
shaderKey: this._candidateBundle
? this._usesSourceResolver
? 'fsr315-source-resolver-v1'
Expand All @@ -338,7 +361,7 @@ export class CandidateUpscaler {
? ['constants', 'color', 'tonemap', 'candidate-accumulate']
: [],
});
this._exposurePass = new ComputePass(
this._exposurePass = await create(
device,
'exposure',
this._usesSourceResolver
Expand All @@ -355,8 +378,8 @@ export class CandidateUpscaler {
}
: {},
);
this._shadingChangePass = new ComputePass(device, 'shading-change', SHADING_CHANGE_SHADER);
this._generateReactivePass = new ComputePass(
this._shadingChangePass = await create(device, 'shading-change', SHADING_CHANGE_SHADER);
this._generateReactivePass = await create(
device,
'gen-reactive',
this._usesStructuralInputs
Expand Down Expand Up @@ -384,7 +407,7 @@ export class CandidateUpscaler {
: this._candidateBundle
? DEBUG_SOURCE_FILTER_SHADER
: DEBUG_SHADER;
this._debugPass = new ComputePass(device, 'debug', debugShader, {
this._debugPass = await create(device, 'debug', debugShader, {
shaderKey: this._candidateBundle
? `${this._candidateBundle}:debug-v1`
: 'baseline:debug',
Expand All @@ -393,7 +416,7 @@ export class CandidateUpscaler {
: [],
});
if (this._candidateBundle) {
this._depthClipPass = new ComputePass(device, 'depth-clip', DEPTH_CLIP_SOURCE_SHADER, {
this._depthClipPass = await create(device, 'depth-clip', DEPTH_CLIP_SOURCE_SHADER, {
shaderKey: this._usesStructuralInputs
? 'fsr315-depth-clip-structural-v1'
: 'fsr315-depth-clip-filter-v1',
Expand All @@ -404,7 +427,7 @@ export class CandidateUpscaler {
});
}
if (this._usesStructuralInputs) {
this._prepareReactivityPass = new ComputePass(
this._prepareReactivityPass = await create(
device,
'prepare-reactivity',
PREPARE_REACTIVITY_SOURCE_SHADER,
Expand All @@ -415,7 +438,7 @@ export class CandidateUpscaler {
);
}
if (this._usesSourceResolver) {
this._shadingSpdPass = new ComputePass(
this._shadingSpdPass = await create(
device,
'shading-spd',
SHADING_CHANGE_SPD_SOURCE_SHADER,
Expand All @@ -424,7 +447,7 @@ export class CandidateUpscaler {
assembledChunks: ['constants', 'shading-change-spd'],
},
);
this._shadingResolvePass = new ComputePass(
this._shadingResolvePass = await create(
device,
'shading-resolve',
SHADING_CHANGE_RESOLVE_SOURCE_SHADER,
Expand All @@ -433,7 +456,7 @@ export class CandidateUpscaler {
assembledChunks: ['constants', 'shading-change-resolve'],
},
);
this._lumaInstabilityPass = new ComputePass(
this._lumaInstabilityPass = await create(
device,
'luma-instability',
LUMA_INSTABILITY_SOURCE_SHADER,
Expand All @@ -454,7 +477,7 @@ export class CandidateUpscaler {
* @param config - Display size, quality mode/ratio, and pipeline path
*/
configure(config: UpscalerConfig): void {
if (!this._initialized) this.init();
if (!this._preparing) void this.init().catch(error => console.error(error));

this._path = config.path ?? 'temporal';
this._jitterEnabled = config.jitter ?? true;
Expand Down Expand Up @@ -764,6 +787,8 @@ export class CandidateUpscaler {

/** Releases all GPU resources. */
dispose(): void {
this._generation++;
this._preparing = null;
this._destroyTextures();
this._constants?.dispose();
this._timer?.dispose();
Expand Down
2 changes: 2 additions & 0 deletions bench/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function resizeBenchmark(dimensions: BenchmarkDimensions): void {

if (automated) {
resizeBenchmark(config.dimensions);
await pipeline?.resolver.prepare();
await pipeline?.prepareEffectReadiness(camera);
pipeline?.reset(bench.scene, camera, 0);
}
Expand Down Expand Up @@ -265,6 +266,7 @@ if (!automated && pipeline && controls) {
detectShadingChanges: state.detectShadingChanges,
debugView: state.debugView,
});
if (!pipeline.resolver.isReady) return;
pipeline.render(bench.scene, camera, dt, interactiveFrame++);
updateStats(dt);
});
Expand Down
1 change: 1 addition & 0 deletions bench/src/probes/exposureCeiling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ LEVELS.forEach((level, column) => {

const pass = new UpscalePass(renderer);
pass.configure({ displayWidth: DISPLAY, displayHeight: DISPLAY, ratio: RATIO });
await pass.init();
const nativeTarget = new THREE.RenderTarget(DISPLAY, DISPLAY, { type: THREE.FloatType });

function halfToFloat(bits: number): number {
Expand Down
1 change: 1 addition & 0 deletions bench/src/probes/timerOverhead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ async function run(request: ProbeRequest) {
ratio: request.ratio,
path: request.path,
});
await pass.init();
// Inputs for dispatch mode: one real scene render into the pass's target.
bench.update(0, false);
pass.draw(scene, camera, 1 / 60);
Expand Down
2 changes: 2 additions & 0 deletions bench/src/types/benchmark.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ declare interface BenchmarkResolverDispatch {
}

declare interface BenchmarkResolver {
prepare(): Promise<void>;
readonly isReady: boolean;
readonly metadata: BenchmarkVariantMetadata;
readonly outputTexture: unknown;
readonly renderWidth: number;
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Maintained, for contributors:

- [Contributing](contributing.md): the dev loop, the bench, verifying on a real GPU,
and how releases are cut.
- [Windows audit and roadmap](windows-audit.md): async startup evidence, browser/demo coverage, native tooling gaps and prioritized follow-ups.
- [Architecture](architecture.md): the layers, the pass graph, color domains and the
shared constants buffer.
- [`src/shaders/README.md`](../src/shaders/README.md): the per-pass audit against
Expand Down
17 changes: 17 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,20 @@ structural tests in `src/shaders/shaders.test.ts` enforce most of this without a
Adoption record: [`bench/docs/NEXT-STEPS.md`](../bench/docs/NEXT-STEPS.md).
Parity decisions: [`bench/docs/PARITY-DECISIONS.md`](../bench/docs/PARITY-DECISIONS.md).
Findings worth publishing: [PAPER-NOTES](research/PAPER-NOTES.md).

## Pipeline preparation and ownership

ComputePass.create() compiles asynchronously through a device-scoped four-slot queue.
A WeakMap shares modules by source and pipelines by source, entry point, auto-layout
policy, and sorted constants. Each instance retains its own textures, uniforms,
bind groups, temporal state, and timing resources. Disposal cannot invalidate another
instance's cached pipeline. Device loss clears cached resources and invalidates readiness.

Configuration allocates independently of compilation. Bilinear, spatial, guides,
and temporal paths prepare their own required sets. Debug and disabled shading-change
passes remain lazy. Exposure stays mandatory on temporal frames because its published
value includes manual/external exposure and host pre-exposure as well as metering.

Optional activation happens at dispatch boundaries. A split frame uses one effective
settings snapshot from its guides stage through its upscale stage. Async completions
are guarded by an instance generation so disposed instances cannot regain readiness.
3 changes: 2 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ re-verified on r186.1.

- **WebGPU backend required.** There is no WebGL fallback, and none is planned: the
pipeline is WGSL compute. If three falls back to WebGL, the upscaler throws at
`init()`. Await `renderer.init()` first.
`init()`. Await `renderer.init()` first, configure the desired path, then await
`upscaler.init()`. Raw dispatch no longer compiles synchronously.
- **Browsers:** Chrome/Edge 113+. Other engines work to the extent their WebGPU
implementation does; the project's GPU verification runs on Chrome.
- **`timestamp-query`** is optional and only used when GPU timing is opted into
Expand Down
26 changes: 25 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ function resize() {
});
}
resize();
await upscalePass.init();
addEventListener('resize', () => { renderer.setSize(innerWidth, innerHeight); resize(); });

const timer = new THREE.Timer();
Expand Down Expand Up @@ -199,12 +200,13 @@ import { mrt, output, velocity } from 'three/tsl';
import { Upscaler, QualityMode } from '@pmndrs/upscaler';

const upscaler = new Upscaler({ renderer });
upscaler.init(); // compiles the pipelines; renderer.init() must have resolved
// Configure first so only the selected path is prepared.
upscaler.configure({
displayWidth, displayHeight, // physical pixels
qualityMode: QualityMode.Quality, // or customUpscaleRatio, or renderWidth/renderHeight
path: 'temporal',
});
await upscaler.init();

// Render target at RENDER resolution. Attachment names route the MRT outputs and the
// attachment count must equal the MRT output count.
Expand Down Expand Up @@ -295,3 +297,25 @@ latest timed frame (labels such as `reconstruct`, `exposure`, `shadingChange`,
`accumulate`, `rcas`). It holds only the passes that frame ran, so summing it gives the
frame's upscale cost. It's empty while timing is off, where the device lacks
`timestamp-query`, and for the first frame or so after timing starts.

## Asynchronous preparation

Configure sizes and path, then await `init()` before raw dispatch. Configuration
allocates textures synchronously and queues only that path's pipelines. Await
initialization again when switching to a path whose pipelines are not ready.
`isReady` describes mandatory-pass readiness, not every optional setting.

`prepare()` explicitly warms the current settings and retries failed compilation.
A newly enabled debug view or shading-change detector otherwise prepares on first
dispatch and stays inactive until ready. Activation invalidates affected history
without changing the input frame's jitter. Settings are frozen across split dispatch.

TSL nodes prepare automatically, show the input color while pending, and avoid
jitter until mandatory pipelines are ready. Failed compilation is reported once;
explicit initialization rejects and node fallback remains usable. `UpscalePass`
also presents its reduced-resolution input during path preparation. Its raw
`outputTexture` is ready for use after awaiting initialization.

`Upscaler.isSupported(device)` checks baseline device limits without compiling
or allocating. Timestamp queries are optional. Support does not guarantee shader
compilation, device health, or allocation at an arbitrary output size.
1 change: 1 addition & 0 deletions docs/temporal-guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ import { MomentsPass } from '@pmndrs/upscaler';

const moments = new MomentsPass({ renderer });
moments.configure({ width, height, space: 'ycocg' }); // or 'linear' (default)
await moments.init();
moments.dispatch({ source: giTexture }); // per frame, any float texture
// moments.moments rgba16float, source size: .rg = (s, s²) per texel
// moments.coarseMoments rgba16float, ⌈size / 4⌉: .rg = 4×4 block means of (s, s²)
Expand Down
Loading
Loading