diff --git a/.gitignore b/.gitignore index 76f39cb..8cbe65b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ bench/dist/ *.log .DS_Store + +bench/results/windows-local/ diff --git a/bench/src/benchmark/BenchmarkResolver.ts b/bench/src/benchmark/BenchmarkResolver.ts index df52ec1..99b7da2 100644 --- a/bench/src/benchmark/BenchmarkResolver.ts +++ b/bench/src/benchmark/BenchmarkResolver.ts @@ -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 { return this._upscaler.prepare(); } + + get isReady(): boolean { return this._upscaler.isReady; } + get outputTexture(): THREE.Texture { return this._upscaler.outputTexture; } diff --git a/bench/src/benchmark/api.ts b/bench/src/benchmark/api.ts index c273a56..081d242 100644 --- a/bench/src/benchmark/api.ts +++ b/bench/src/benchmark/api.ts @@ -80,7 +80,10 @@ class BrowserBenchmarkApi implements UpscalerBenchmarkApi { async reset(): Promise { 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; @@ -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( diff --git a/bench/src/candidates/CandidateUpscaler.ts b/bench/src/candidates/CandidateUpscaler.ts index e66d0a1..bfbd93b 100644 --- a/bench/src/candidates/CandidateUpscaler.ts +++ b/bench/src/candidates/CandidateUpscaler.ts @@ -172,6 +172,9 @@ export class CandidateUpscaler { private _historyIndex = 0; private _depthIndex = 0; private _initialized = false; + private _preparing: Promise | null = null; + private _generation = 0; + private _deviceLost = false; // GPU-side working set (owned raw textures + the three-visible output) private _output: StorageTexture | null = null; @@ -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 { return this.init(); } + + init(): Promise { + 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 { + 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); @@ -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): Promise => { + 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, @@ -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, @@ -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' @@ -338,7 +361,7 @@ export class CandidateUpscaler { ? ['constants', 'color', 'tonemap', 'candidate-accumulate'] : [], }); - this._exposurePass = new ComputePass( + this._exposurePass = await create( device, 'exposure', this._usesSourceResolver @@ -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 @@ -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', @@ -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', @@ -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, @@ -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, @@ -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, @@ -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, @@ -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; @@ -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(); diff --git a/bench/src/main.ts b/bench/src/main.ts index ed1d987..9edb8a4 100644 --- a/bench/src/main.ts +++ b/bench/src/main.ts @@ -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); } @@ -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); }); diff --git a/bench/src/probes/exposureCeiling.ts b/bench/src/probes/exposureCeiling.ts index 21169a4..8d316e7 100644 --- a/bench/src/probes/exposureCeiling.ts +++ b/bench/src/probes/exposureCeiling.ts @@ -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 { diff --git a/bench/src/probes/timerOverhead.ts b/bench/src/probes/timerOverhead.ts index c3c9d41..341c6e6 100644 --- a/bench/src/probes/timerOverhead.ts +++ b/bench/src/probes/timerOverhead.ts @@ -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); diff --git a/bench/src/types/benchmark.d.ts b/bench/src/types/benchmark.d.ts index 88c52d5..d818268 100644 --- a/bench/src/types/benchmark.d.ts +++ b/bench/src/types/benchmark.d.ts @@ -112,6 +112,8 @@ declare interface BenchmarkResolverDispatch { } declare interface BenchmarkResolver { + prepare(): Promise; + readonly isReady: boolean; readonly metadata: BenchmarkVariantMetadata; readonly outputTexture: unknown; readonly renderWidth: number; diff --git a/docs/README.md b/docs/README.md index 9963e82..4a55fd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 748c41b..e0914c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/compatibility.md b/docs/compatibility.md index a9a2678..21afa2d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md index af31cc1..75c85c4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -162,6 +162,7 @@ function resize() { }); } resize(); +await upscalePass.init(); addEventListener('resize', () => { renderer.setSize(innerWidth, innerHeight); resize(); }); const timer = new THREE.Timer(); @@ -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. @@ -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. diff --git a/docs/temporal-guides.md b/docs/temporal-guides.md index bf54180..e143e4d 100644 --- a/docs/temporal-guides.md +++ b/docs/temporal-guides.md @@ -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²) diff --git a/docs/windows-audit.md b/docs/windows-audit.md new file mode 100644 index 0000000..92d62ce --- /dev/null +++ b/docs/windows-audit.md @@ -0,0 +1,133 @@ +# Windows audit and improvement roadmap + +2026-10-06 — [issue #82](https://github.com/pmndrs/upscaler/issues/82), [draft PR #83](https://github.com/pmndrs/upscaler/pull/83). +Baseline: v0.4.0 / bfb343c. Startup implementation: 3d018e1. + +## Result and measurements + +Default temporal startup now creates eight async pipelines, spatial creates three, +and guides-only prepares two geometry pipelines. Debug and disabled shading-change +passes remain lazy. Two-instance split comparison fell from 20 synchronous creations +to eight shared async creations. Shader math and temporal exposure behavior are preserved. + +Machine: Windows 11 Home build 26200, NVIDIA RTX 3060 Laptop GPU +(driver 32.0.15.9200), Chrome 154.0.8037.98; Edge 154.0.4258.53 for cross-checks. +Three fresh-profile runs and matching reloads produced these Example 01 medians: + +| Metric | v0.4.0 cold | Async cold | v0.4.0 warm | Async warm | +| --- | ---: | ---: | ---: | ---: | +| GPU-main compute-pipeline API time | 1647.425 ms | 2.077 ms | 152.924 ms | 0.958 ms | +| Synchronous creations per page | 10 | 0 | 10 | 0 | +| Async creations per page | 0 | 8 | 0 | 8 | +| Largest rAF gap | 2563.6 ms | 1024.2 ms | 121.2 ms | 78.7 ms | + +Dawn traces place upscaler DXC compilation and +`CreatePipelineAsyncEvent::InitializeImpl` on `ThreadPoolForegroundWorker`. +The short async creation API calls remain on `CrGpuMain`. Render-shader DXC work +still appears on `CrGpuMain`, explaining residual first-frame stalls. + +This measures removal of compute compilation from the GPU main thread, not an +equivalent reduction in total compile work or steady-state GPU cost. First queue +submission is not first visible presentation; awaiting preparation changes that metric. +The exposure shader was the largest compute compile in the cold trace. Its 32×32 +sampling loop warrants a separate experiment, not an unmeasured math rewrite. + +Final startup runs used fresh profiles and ran sequentially on the same adapter. +Earlier overlapping source-snapshot runs produced shared-Vite-cache warnings and +are excluded from timing conclusions. Startup timings were recorded separately from +the short steady-state candidate smoke runs. + +Evidence: + +- [Startup samples](windows-evidence/startup-summary.json) +- [Chrome/Edge demo results](windows-evidence/demo-results.json) +- [Resize/DPR/control checks](windows-evidence/transitions.json) +- [Moments and real device-loss checks](windows-evidence/primitives.json) +- [Candidate GPU smoke](windows-evidence/candidate-smoke.json) +- [160-file source inventory](windows-evidence/inventory.json) + +The inventory labels review/test coverage; it does not claim every branch was exercised. +Screenshots and full traces are retained as local audit artifacts. Compact evidence is committed. + +## Verification and coverage + +The Windows validation runtime is isolated Node 22.23.3 / npm 11.5.1. +Lint, typecheck, library build, gallery build, packed-consumer build and portable +Vitest suites passed. The portable run contains 472 tests, including guides-only, retry and specialization +metadata cases. +[Ubuntu CI](https://github.com/pmndrs/upscaler/actions/runs/37443315942) passed the full +suite and build. Packed Chrome verification confirmed 16 ordered split frames, +two ping-pong textures, and zero monolithic fallbacks. + +| Area | Status | Evidence / limits | +| --- | --- | --- | +| Core lifecycle, cache, math, timers | Verified | Unit coverage for sharing, four-slot cap, failures/retry, disposal, jitter and optional activation; production GPU rendering. | +| TSL / temporal guides | Verified | Linked package consumer, stable node identity, ordered split dispatch, resize/rebuild checks. | +| MomentsPass / device loss | Verified | Real moments dispatch; disposable GPU device changes readiness true→false and explicit preparation rejects after destruction. | +| Production WGSL | Verified | Assembly/structure tests plus the production demo sweep. Shader math is unchanged. | +| Three candidate bundles | Verified smoke | Filter, structural and SPD bundles completed four ABBA runs each on Q1, ratio 2, 30 warmup and 30 sampled frames; zero browser validation errors. Not a new quality/performance ranking. | +| Other experimental shaders / probes | Untested GPU coverage | Source and applicable string/CLI tests reviewed; every variant/scenario was not rerun. | +| Packaging | Verified | Direct npm CLI invocation, old-npm archive-name fallback and Windows junction; build-only and packed GPU checks pass. | +| Release-workflow harness | Reproduced failure | Native Windows run had 60 failures in 63 existing cases; YAML parsing assumes LF, executable shims/PATH assume POSIX. Ubuntu full CI passes. | +| Browser discovery | Static concern | Existing GPU tools search Mac/Linux paths; explicit browser paths work on Windows. | +| Teardown | Static concern | One exploratory browser startup timed out. Windows process-tree/profile cleanup needs stress coverage. | +| Workflows | Static concern | Verification is Ubuntu-only. Publishing was not dispatched. | + +All 20 Chrome demos (01–16 and S1–S4) rendered in cold/warm captures without +upscaler validation errors, exceptions or device loss. Most cold pages requested a +missing favicon; that 404 is separated from rendering failures. + +Edge also rendered 01, 06, 07, 13, 15 and 16. Resize and DPR changes were exercised on +02, 13, 15, 16 and S4, with 32 available mode/debug selections cycled without errors. +This covers representative transitions, not every control in every demo. + +Demo 14 successfully loaded its remote model/HDRI. Its upscaler creates three async +spatial pipelines, but `three-gpu-pathtracer` still creates 12 synchronous compute +pipelines and emits a `Source`/TextureSource deprecation warning. S1/S2 also contain +substantial effect/raymarch render shaders; async library compute does not eliminate +their scene startup costs. S2 also reads upscaler GPU timings without opting into GPU timing; +its upscale/total HUD remains n/a despite available render timestamps. + +The local Homefig wiki index, Windows notes and TemporalResolver consumer were readable. +The newer `wiki/development/windows-shader-perf/` directory referenced by #82 was absent. +Older notes warned against unlimited compile concurrency; this library uses four slots +per device. Homefig's availability probe still constructs and disposes a throwaway +upscaler. Its migration should use the capability check and handle real preparation +failure separately. Homefig was not modified. + +## Prioritized improvements + +| Priority | Improvement | Evidence / impact | Proposed work and dependency | Acceptance | +| --- | --- | --- | --- | --- | +| P0 | Windows regression harness | Native release tests fail. | Normalize YAML newlines; adapt executable shims, Bash paths and PATH. Preserve Linux workflow semantics. | Intended Windows tests pass without hiding failures. | +| P0 | Windows CI | Ubuntu misses native command/path regressions. | Add Node 22/npm 11.5.1 Windows verification after harness portability; retain Ubuntu release emulation. | Fresh runner passes tests, types, lint, build and packed consumer. | +| P0 | Shared browser discovery | Ten GPU scripts duplicate Mac/Linux lists. | One Chrome/Edge resolver preserving explicit flag and CHROME_PATH precedence, including spaces. | Standard Windows installs work without manual paths. | +| P0 | Owned-process cleanup | Windows teardown differs; a startup timed out. | Verify owned process trees, wait for CDP shutdown, then retry removal of owned profiles only. | Repeated/interrupted runs leave no orphan servers/browsers, locked profiles or stale ports. | +| P1 | Homefig migration | Probe compiles unnecessarily; raw API now requires awaiting. | Replace throwaway probe; integrate readiness and preparation-error handling after #83 is released. | No probe compile; usable panes during preparation and actionable failures. | +| P1 | S2 timing HUD | Upscaler timing is off by default, while S2 reads its timing map. | Opt in to GPU timing explicitly and distinguish unsupported, warming and disabled states. | Upscale and total GPU costs populate on supported hardware. | +| P1 | Startup UI and metrics | Render compiles still stall; optional features can be pending. | Expose preparing/ready/error in hosts; measure first visible frame, preparation and GPU runtime separately. | Responsive controls and correctly labeled metrics. | +| P1 | Automated demo coverage | GPU coverage is still local. | Maintain this harness with image/readback assertions, isolated Vite caches and external-asset classifications. | Detect blank output, bad alpha/HDR, guide order and transition regressions. | +| P1 | Path-tracer upstream work | Demo 14 retains 12 dependency-origin sync creations. | Contribute async preparation and TextureSource cleanup to three-gpu-pathtracer. | Equivalent alpha/model output with no sync dependency compute calls. | +| P2 | Exposure compilation | Metering dominates compile and runs for manual/external exposure. | Measure splitting lightweight exposure publication from metering; preserve guide channels and host correction. | Lower compile cost with equivalent exposure/HDR/alpha/history readbacks. | +| P2 | Loop and variant cost | Large constant-bound loops and S2 render shaders need compiler evidence. | Inspect HLSL and timings before changing bounds/factoring; preserve deterministic WGSL. | Proven compile reduction, equivalent images and acceptable GPU runtime. | + +## Reproduction and limits + +Run with the CI Node/npm versions and an explicit browser executable: + +```powershell +node scripts/audit-windows.mjs --browser 'C:\Program Files\Google\Chrome\Application\chrome.exe' +node scripts/audit-windows.mjs --browser 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' --demos 01-hello,06-screenspace-gi,07-tsl-node,13-guides-node,15-transparent-canvas,16-spatial-node +node scripts/audit-windows.mjs --browser 'C:\Program Files\Google\Chrome\Application\chrome.exe' --demos 01-hello --runs 3 --trace +node scripts/audit-windows.mjs --browser 'C:\Program Files\Google\Chrome\Application\chrome.exe' --demos 02-fsr1-vs-fsr3,13-guides-node,15-transparent-canvas,16-spatial-node,s4-convergence --exercise +npm exec -- vitest run --exclude scripts/release-workflow.test.mjs +npm run verify:packed-guides:gpu -- --chrome 'C:\Program Files\Google\Chrome\Application\chrome.exe' +``` + +The exclusion is the explicitly reported portable subset; the native Windows full +suite did not pass. Use sequential baseline/updated runs, fresh profiles and isolated +Vite caches. Trace categories are `gpu.dawn`, `gpu.dawn.validation` and `toplevel`; +attribute GPU-main time only to labeled upscaler compute API events. + +Not covered: forced FXC, non-NVIDIA Windows adapters, all experimental variants, +long-duration loss/recovery, or a full Homefig integration. diff --git a/docs/windows-evidence/candidate-smoke.json b/docs/windows-evidence/candidate-smoke.json new file mode 100644 index 0000000..758fd38 --- /dev/null +++ b/docs/windows-evidence/candidate-smoke.json @@ -0,0 +1,122 @@ +[ + { + "variant": "source-filter-bundle-v1", + "name": "ratio-2-r1-A1-source-filter-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.1955200000000001, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-filter-bundle-v1", + "name": "ratio-2-r1-A2-source-filter-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.3562880000000002, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-filter-bundle-v1", + "name": "ratio-2-r1-B1-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.312256, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-filter-bundle-v1", + "name": "ratio-2-r1-B2-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.292288, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-structural-bundle-v1", + "name": "ratio-2-r1-A1-source-structural-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.5237119999999997, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-structural-bundle-v1", + "name": "ratio-2-r1-A2-source-structural-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.6706560000000001, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-structural-bundle-v1", + "name": "ratio-2-r1-B1-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.3056, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-structural-bundle-v1", + "name": "ratio-2-r1-B2-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.147904, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-spd-resolver-bundle-v1", + "name": "ratio-2-r1-A1-source-spd-resolver-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.766912, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-spd-resolver-bundle-v1", + "name": "ratio-2-r1-A2-source-spd-resolver-bundle-v1.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 2.013696, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-spd-resolver-bundle-v1", + "name": "ratio-2-r1-B1-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.23904, + "validation": [], + "browserValidation": [] + }, + { + "variant": "source-spd-resolver-bundle-v1", + "name": "ratio-2-r1-B2-local-baseline-through-e00-harness.json", + "status": "complete", + "scenario": "Q1", + "ratio": 2, + "computeMs": 1.242624, + "validation": [], + "browserValidation": [] + } +] \ No newline at end of file diff --git a/docs/windows-evidence/demo-results.json b/docs/windows-evidence/demo-results.json new file mode 100644 index 0000000..32d4fff --- /dev/null +++ b/docs/windows-evidence/demo-results.json @@ -0,0 +1,787 @@ +{ + "chromeVersion": "Chrome/154.0.8037.98", + "edgeVersion": "Edg/154.0.4258.53", + "chrome": [ + { + "demo": "01-hello", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 933, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "01-hello", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 721, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "02-fsr1-vs-fsr3", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 853, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "02-fsr1-vs-fsr3", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 617, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "03-split-compare", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1429, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "03-split-compare", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1231, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "04-aliasing-torture", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 821, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "04-aliasing-torture", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1037, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "05-transparency", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 906, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "05-transparency", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1246, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "06-screenspace-gi", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1203, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "06-screenspace-gi", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1083, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "07-tsl-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 747, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "07-tsl-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 717, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "08-tsl-compose", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 734, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "08-tsl-compose", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 553, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "09-kitchen-sink", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 2763, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "09-kitchen-sink", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 2348, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "10-ssgi-denoise", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 2346, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "10-ssgi-denoise", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1908, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "11-node-reactive", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 964, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "11-node-reactive", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 766, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "12-temporal-guides", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1016, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "12-temporal-guides", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1131, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "13-guides-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1189, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "13-guides-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1108, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "14-pathtracer-alpha", + "cache": "cold", + "syncComputeCalls": 12, + "asyncComputeCalls": 3, + "queueSubmissions": 228, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)", + "THREE.Source: \"Source\" has been renamed to \"TextureSource\". Please update your code to use \"THREE.TextureSource\" instead." + ] + }, + { + "demo": "14-pathtracer-alpha", + "cache": "warm", + "syncComputeCalls": 12, + "asyncComputeCalls": 3, + "queueSubmissions": 2650, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "THREE.Source: \"Source\" has been renamed to \"TextureSource\". Please update your code to use \"THREE.TextureSource\" instead." + ] + }, + { + "demo": "15-transparent-canvas", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1004, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "15-transparent-canvas", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 768, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "16-spatial-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 3, + "queueSubmissions": 761, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "16-spatial-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 3, + "queueSubmissions": 562, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s1-reinvest", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 56, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "s1-reinvest", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1200, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s2-fractal", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 841, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s2-fractal", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 828, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s3-how-low", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1280, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "s3-how-low", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 1623, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s4-convergence", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 373, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "s4-convergence", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 507, + "adapter": { + "vendor": "nvidia", + "architecture": "ampere", + "device": "", + "description": "" + }, + "issues": [] + } + ], + "edge": [ + { + "demo": "01-hello", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 305, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "01-hello", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 313, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "06-screenspace-gi", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 399, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "06-screenspace-gi", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 393, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "07-tsl-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 322, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "07-tsl-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 265, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "13-guides-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 353, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "13-guides-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 544, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "15-transparent-canvas", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 416, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "15-transparent-canvas", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 8, + "queueSubmissions": 380, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + }, + { + "demo": "16-spatial-node", + "cache": "cold", + "syncComputeCalls": 0, + "asyncComputeCalls": 3, + "queueSubmissions": 306, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [ + "Failed to load resource: the server responded with a status of 404 (Not Found)" + ] + }, + { + "demo": "16-spatial-node", + "cache": "warm", + "syncComputeCalls": 0, + "asyncComputeCalls": 3, + "queueSubmissions": 242, + "adapter": { + "vendor": "intel", + "architecture": "gen-12lp", + "device": "", + "description": "" + }, + "issues": [] + } + ] +} \ No newline at end of file diff --git a/docs/windows-evidence/inventory.json b/docs/windows-evidence/inventory.json new file mode 100644 index 0000000..51e2c5c --- /dev/null +++ b/docs/windows-evidence/inventory.json @@ -0,0 +1,802 @@ +[ + { + "path": ".github/workflows/ci.yml", + "status": "static concern", + "evidence": "Ubuntu verification passes; no Windows CI job exists. Publish workflow was not dispatched." + }, + { + "path": ".github/workflows/deploy-pages.yml", + "status": "static concern", + "evidence": "Ubuntu verification passes; no Windows CI job exists. Publish workflow was not dispatched." + }, + { + "path": ".github/workflows/publish.yml", + "status": "static concern", + "evidence": "Ubuntu verification passes; no Windows CI job exists. Publish workflow was not dispatched." + }, + { + "path": "bench/src/BenchPipeline.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/BenchScene.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/BenchUI.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/BenchmarkResolver.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/api.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/clock.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/collector.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/config.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/environment.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/scenarios.test.ts", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "bench/src/benchmark/scenarios.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/benchmark/variants.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/CandidateUpscaler.ts", + "status": "verified", + "evidence": "All three cumulative bundles completed Q1 ratio-2 ABBA GPU smoke without browser validation errors." + }, + { + "path": "bench/src/candidates/candidates.test.ts", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "bench/src/candidates/shaders/candidateDebug.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/shaders/candidateFilters.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/shaders/candidateInputs.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/shaders/candidateTemporal.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/shaders/shadingChangeRange.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/candidates/shadingChangeRange.test.ts", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "bench/src/main.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/probes/exposureCeiling.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/probes/timerOverhead.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "bench/src/types/benchmark.d.ts", + "status": "static concern", + "evidence": "Reviewed async lifecycle and candidate identities; representative GPU bundle runs recorded in report." + }, + { + "path": "examples/01-hello/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/01-hello/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/02-fsr1-vs-fsr3/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/02-fsr1-vs-fsr3/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/03-split-compare/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/03-split-compare/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/04-aliasing-torture/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/04-aliasing-torture/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/05-transparency/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/05-transparency/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/06-screenspace-gi/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/06-screenspace-gi/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/07-tsl-node/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/07-tsl-node/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/08-tsl-compose/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/08-tsl-compose/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/09-kitchen-sink/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/09-kitchen-sink/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/10-ssgi-denoise/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/10-ssgi-denoise/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/11-node-reactive/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/11-node-reactive/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/12-temporal-guides/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/12-temporal-guides/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/13-guides-node/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/13-guides-node/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/14-pathtracer-alpha/index.html", + "status": "static concern", + "evidence": "GPU render verified; synchronous compute and TextureSource deprecation originate in three-gpu-pathtracer." + }, + { + "path": "examples/14-pathtracer-alpha/main.ts", + "status": "static concern", + "evidence": "GPU render verified; synchronous compute and TextureSource deprecation originate in three-gpu-pathtracer." + }, + { + "path": "examples/15-transparent-canvas/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/15-transparent-canvas/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/16-spatial-node/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/16-spatial-node/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/package-consumer.test.mjs", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s1-reinvest/GpuMeter.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s1-reinvest/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s1-reinvest/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s1-reinvest/measure.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s1-reinvest/scene.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s2-fractal/fractal.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s2-fractal/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s2-fractal/main.ts", + "status": "static concern", + "evidence": "Renders correctly; reads upscaler GPU timings without opting in, so its upscale/total HUD remains n/a." + }, + { + "path": "examples/s2-fractal/stats.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s3-how-low/Loupe.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s3-how-low/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s3-how-low/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s3-how-low/scene.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/index.html", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/jitterDiagram.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/layers.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/main.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/narration.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/s4-convergence/scene.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/shared/UpscalePresenter.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/shared/boot.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/shared/matchPassResolution.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/shared/props.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/shared/ui.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "examples/vite.config.ts", + "status": "verified", + "evidence": "Chrome cold/warm demo render and browser-log capture; representative Edge and transition checks are documented separately." + }, + { + "path": "scripts/audit-windows.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/benchmark-contract.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/benchmark-contract.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/benchmark-scenarios.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/benchmark-scenarios.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/cli-flags.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/cli-flags.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/compare-rcas.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/local-processes.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/local-processes.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/measure-alpha-convergence.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-convergence.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-drift-lag.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-emitter-retention.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-exposure-ceiling.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-receding-disocclusion.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-shading-restart.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/measure-timer-overhead.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/npm-command.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/npm-pack-json.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/npm-pack-json.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/release-notes.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/release-notes.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/release-version.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/release-version.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/release-workflow.test.mjs", + "status": "reproduced failure", + "evidence": "60 existing native-Windows failures; POSIX shell, PATH and CRLF assumptions. Ubuntu CI passes." + }, + { + "path": "scripts/release.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/release.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/run-benchmark.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/verify-artifacts.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/verify-artifacts.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "scripts/verify-packed-guides.mjs", + "status": "static concern", + "evidence": "Reviewed browser discovery, command invocation, paths and teardown; not every measurement/release command was executed." + }, + { + "path": "scripts/verify-packed-guides.test.mjs", + "status": "verified", + "evidence": "Portable Vitest run passes; release-workflow suite is separately marked." + }, + { + "path": "src/MomentsPass.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/TemporalGuidesNode.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/UpscalePass.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/UpscalePass.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/Upscaler.initialization.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/Upscaler.jitter.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/Upscaler.timing.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/Upscaler.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/UpscalerNode.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/index.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/ComputePass.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/ComputePass.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/ConstantsBuffer.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/GpuTimer.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/GpuTimer.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/renderPipelineHooks.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/renderPipelineHooks.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/internal/threeWebGPU.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/halton.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/halton.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/jitter.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/jitter.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/resolution.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/resolution.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/viewOffset.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/math/viewOffset.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/accumulate.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/blit.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/common.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/debug.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/easu.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/generateReactive.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/luminancePyramid.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/moments.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/rcas.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/reconstruct.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/reconstructVariants.ts", + "status": "untested", + "evidence": "String/structure coverage passes; every experimental variant was not individually GPU-compiled in this audit." + }, + { + "path": "src/shaders/shaders.test.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/shadingChange.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/shaders/wgsl.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + }, + { + "path": "src/types.ts", + "status": "verified", + "evidence": "Portable unit tests, typecheck and production GPU demo sweep; module-specific limits remain documented." + } +] \ No newline at end of file diff --git a/docs/windows-evidence/primitives.json b/docs/windows-evidence/primitives.json new file mode 100644 index 0000000..addbf17 --- /dev/null +++ b/docs/windows-evidence/primitives.json @@ -0,0 +1,17 @@ +{ + "demo": "13-guides-node", + "run": 0, + "exercise": { + "resize": true, + "dpr": true, + "controls": [], + "primitives": { + "momentsReady": true, + "supported": true, + "readyBeforeLoss": true, + "readyAfterLoss": false, + "lossRejected": true + } + }, + "issues": [] +} \ No newline at end of file diff --git a/docs/windows-evidence/startup-summary.json b/docs/windows-evidence/startup-summary.json new file mode 100644 index 0000000..2b76274 --- /dev/null +++ b/docs/windows-evidence/startup-summary.json @@ -0,0 +1,146 @@ +[ + { + "variant": "async", + "run": 0, + "cache": "cold", + "mainApiMs": 2.077, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 3971, + "maxFrameGapMs": 1018.2000000000003 + }, + { + "variant": "async", + "run": 0, + "cache": "warm", + "mainApiMs": 0.957, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 500, + "maxFrameGapMs": 72.79999999999995 + }, + { + "variant": "async", + "run": 1, + "cache": "cold", + "mainApiMs": 1.901, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 2827, + "maxFrameGapMs": 1024.2000000000003 + }, + { + "variant": "async", + "run": 1, + "cache": "warm", + "mainApiMs": 1.034, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 572.3999999985099, + "maxFrameGapMs": 78.70000000000005 + }, + { + "variant": "async", + "run": 2, + "cache": "cold", + "mainApiMs": 2.268, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 2875.39999999851, + "maxFrameGapMs": 1060.5 + }, + { + "variant": "async", + "run": 2, + "cache": "warm", + "mainApiMs": 0.958, + "pipelines": 8, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 651.4000000059605, + "maxFrameGapMs": 151.5 + }, + { + "variant": "v0.4.0", + "run": 0, + "cache": "cold", + "mainApiMs": 1647.425, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 13979.39999999851, + "maxFrameGapMs": 2242.5 + }, + { + "variant": "v0.4.0", + "run": 0, + "cache": "warm", + "mainApiMs": 197.192, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 326.8999999985099, + "maxFrameGapMs": 224.2 + }, + { + "variant": "v0.4.0", + "run": 1, + "cache": "cold", + "mainApiMs": 1638.507, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 780.7999999970198, + "maxFrameGapMs": 2575.6000000000004 + }, + { + "variant": "v0.4.0", + "run": 1, + "cache": "warm", + "mainApiMs": 122.432, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 295.09999999403954, + "maxFrameGapMs": 90.89999999999998 + }, + { + "variant": "v0.4.0", + "run": 2, + "cache": "cold", + "mainApiMs": 1691.791, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 830.2999999970198, + "maxFrameGapMs": 2563.6000000000004 + }, + { + "variant": "v0.4.0", + "run": 2, + "cache": "warm", + "mainApiMs": 152.924, + "pipelines": 10, + "apiThreads": [ + "CrGpuMain" + ], + "firstSubmitMs": 361.20000000298023, + "maxFrameGapMs": 121.19999999999999 + } +] \ No newline at end of file diff --git a/docs/windows-evidence/transitions.json b/docs/windows-evidence/transitions.json new file mode 100644 index 0000000..505656c --- /dev/null +++ b/docs/windows-evidence/transitions.json @@ -0,0 +1,78 @@ +[ + { + "demo": "02-fsr1-vs-fsr3", + "run": 0, + "resize": true, + "dpr": true, + "controls": [ + "Bilinear (naive)", + "FSR1 spatial (EASU+RCAS)", + "FSR3 temporal", + "Off", + "Motion vectors", + "Disocclusion", + "Depth", + "Accumulation age", + "Locks", + "Exposure", + "Shading change" + ], + "issues": [] + }, + { + "demo": "13-guides-node", + "run": 0, + "resize": true, + "dpr": true, + "controls": [], + "issues": [] + }, + { + "demo": "15-transparent-canvas", + "run": 0, + "resize": true, + "dpr": true, + "controls": [ + "temporal", + "spatial", + "1.0x (native AA)", + "1.5x", + "2.0x", + "3.0x", + "Off", + "Motion vectors", + "Disocclusion", + "Accumulation age", + "Locks", + "grid", + "light", + "photo" + ], + "issues": [] + }, + { + "demo": "16-spatial-node", + "run": 0, + "resize": true, + "dpr": true, + "controls": [ + "pass() in-graph", + "texture() external" + ], + "issues": [] + }, + { + "demo": "s4-convergence", + "run": 0, + "resize": true, + "dpr": true, + "controls": [ + "1× (native AA)", + "1.5×", + "2×", + "3×", + "4×" + ], + "issues": [] + } +] \ No newline at end of file diff --git a/examples/01-hello/main.ts b/examples/01-hello/main.ts index fce93d1..a362f83 100644 --- a/examples/01-hello/main.ts +++ b/examples/01-hello/main.ts @@ -60,6 +60,8 @@ window.addEventListener('resize', () => { //* Loop — orbit the camera slowly so the temporal history keeps working. const timer = new THREE.Timer(); +await Promise.all([presenter.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/02-fsr1-vs-fsr3/main.ts b/examples/02-fsr1-vs-fsr3/main.ts index 2393b80..d2a496f 100644 --- a/examples/02-fsr1-vs-fsr3/main.ts +++ b/examples/02-fsr1-vs-fsr3/main.ts @@ -127,6 +127,8 @@ const timer = new THREE.Timer(); let acc = 0; let frames = 0; let fps = 0; +await Promise.all([presenter.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/03-split-compare/main.ts b/examples/03-split-compare/main.ts index 71213f2..2cb1138 100644 --- a/examples/03-split-compare/main.ts +++ b/examples/03-split-compare/main.ts @@ -113,6 +113,8 @@ window.addEventListener('resize', () => { //* Loop — draw both offscreen, then composite to the canvas. const timer = new THREE.Timer(); +await Promise.all([fsr.init(), native.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/04-aliasing-torture/main.ts b/examples/04-aliasing-torture/main.ts index 4d54b8b..2834831 100644 --- a/examples/04-aliasing-torture/main.ts +++ b/examples/04-aliasing-torture/main.ts @@ -101,6 +101,8 @@ function updateHud(): void { //* Loop — a slow dolly through the fences keeps sub-pixel motion continuous. const timer = new THREE.Timer(); +await Promise.all([presenter.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/05-transparency/main.ts b/examples/05-transparency/main.ts index ce33d02..0d85b21 100644 --- a/examples/05-transparency/main.ts +++ b/examples/05-transparency/main.ts @@ -210,6 +210,8 @@ window.addEventListener('resize', () => { //* Loop. const timer = new THREE.Timer(); +await Promise.all([presenter.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/06-screenspace-gi/main.ts b/examples/06-screenspace-gi/main.ts index ca77ca0..ea95f23 100644 --- a/examples/06-screenspace-gi/main.ts +++ b/examples/06-screenspace-gi/main.ts @@ -93,7 +93,6 @@ controls.autoRotateSpeed = 0.3; //* FSR3 — raw upscaler (this example drives the passes itself). const upscaler = new Upscaler({ renderer }); -upscaler.init(); velocity.setProjectionMatrix(upscaler.unjitteredProjectionMatrix); //* Present quad. @@ -233,7 +232,10 @@ function isBacked(tex: THREE.Texture | null | undefined): boolean { //* Loop. const timer = new THREE.Timer(); +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; timer.update(); const dt = Math.min(timer.getDelta(), 0.1); controls.update(); diff --git a/examples/12-temporal-guides/main.ts b/examples/12-temporal-guides/main.ts index 7bdc19c..aa8099c 100644 --- a/examples/12-temporal-guides/main.ts +++ b/examples/12-temporal-guides/main.ts @@ -45,7 +45,6 @@ const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerH //* Upscaler — raw driver (no UpscalePass: we need the split dispatch). const upscaler = new Upscaler({ renderer, gpuTiming: true }); -upscaler.init(); // Motion vectors must be jitter-free. velocity.setProjectionMatrix(upscaler.unjitteredProjectionMatrix); @@ -145,7 +144,10 @@ Object.assign(window as unknown as Record, { //* Loop — the split frame. const timer = new THREE.Timer(); +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; timer.update(); const dt = Math.min(timer.getDelta(), 0.1); const t = timer.getElapsed(); diff --git a/examples/14-pathtracer-alpha/main.ts b/examples/14-pathtracer-alpha/main.ts index 3b57b89..c134f81 100644 --- a/examples/14-pathtracer-alpha/main.ts +++ b/examples/14-pathtracer-alpha/main.ts @@ -114,7 +114,6 @@ controls.update(); //* Upscaler — spatial (FSR1: EASU + RCAS), no history, no motion vectors. const upscaler = new Upscaler({ renderer }); -upscaler.init(); //* Path tracer — sized by us, not by the canvas. // `synchronizeRenderSize` and `dynamicLowRes` both resize the accumulation @@ -212,7 +211,10 @@ Object.assign(window, { __pathtracerAlphaFrames: () => frames, }); +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; controls.update(); upscaler.settings.sharpness = settings.sharpness; diff --git a/examples/15-transparent-canvas/main.ts b/examples/15-transparent-canvas/main.ts index cbe879e..5bebdf2 100644 --- a/examples/15-transparent-canvas/main.ts +++ b/examples/15-transparent-canvas/main.ts @@ -122,6 +122,8 @@ const badge = document.getElementById('badge')!; //* Loop const timer = new THREE.Timer(); +await Promise.all([pass.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/README.md b/examples/README.md index f73619c..72daba3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -139,3 +139,5 @@ upstream (meshopt → Draco) mid-development, which broke a `main`-branch URL wi change on our side; a commit URL is immutable, so the example keeps needing exactly the decoder it ships with. Both `DRACOLoader` and `MeshoptDecoder` are attached anyway, so re-pointing `MODEL_URL` at another model in that data set needs no code change. + +Raw and presenter examples configure first and await `init()` before their animation loop. TSL factories stay synchronous and render their input while pipelines prepare. diff --git a/examples/s1-reinvest/main.ts b/examples/s1-reinvest/main.ts index a0537a5..63dc23f 100644 --- a/examples/s1-reinvest/main.ts +++ b/examples/s1-reinvest/main.ts @@ -104,7 +104,6 @@ placeOrbitCamera(); //* Side A — the raw upscaler (this example drives the passes itself). const upscaler = new Upscaler({ renderer }); -upscaler.init(); upscaler.settings.rcasDenoise = true; // the reduced-res effects are noisy // Motion vectors must be jitter-free. Side B renders without an MRT, so the // global velocity node is A's alone (see 03's note on sharing it). @@ -460,7 +459,10 @@ function isBacked(tex: THREE.Texture | null | undefined): boolean { //* Loop. const timer = new THREE.Timer(); let hudClock = 0; +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; timer.update(); const dt = Math.min(timer.getDelta(), 0.1); const { sides, tag } = cycle.frame(); diff --git a/examples/s2-fractal/main.ts b/examples/s2-fractal/main.ts index 0772a51..b7344a9 100644 --- a/examples/s2-fractal/main.ts +++ b/examples/s2-fractal/main.ts @@ -63,7 +63,6 @@ controls.enabled = false; //* Upscaler — raw driver: we produce color/depth/velocity ourselves. const upscaler = new Upscaler({ renderer }); -upscaler.init(); //* Raymarch uniforms, refreshed every frame between beginFrame/endFrame. const uInvProjection = uniform(new THREE.Matrix4()); // JITTERED inverse projection → primary rays @@ -342,7 +341,10 @@ let hasPrevious = false; let fpsAcc = 0; let fpsFrames = 0; let fps = 0; +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; timer.update(); const dt = Math.min(timer.getDelta(), 0.1); fpsAcc += dt; diff --git a/examples/s3-how-low/main.ts b/examples/s3-how-low/main.ts index 62373dd..36fd13d 100644 --- a/examples/s3-how-low/main.ts +++ b/examples/s3-how-low/main.ts @@ -289,6 +289,8 @@ function placeCamera(): void { //* Loop — draw the non-jittered producers first, then FSR (which jitters the //* camera internally and restores it in endFrame). const timer = new THREE.Timer(); +await Promise.all([fsr.init(), bilinear.init(), native.init()]); + renderer.setAnimationLoop(() => { timer.update(); const dt = Math.min(timer.getDelta(), 0.1); diff --git a/examples/s4-convergence/main.ts b/examples/s4-convergence/main.ts index 6bd24f3..72871ee 100644 --- a/examples/s4-convergence/main.ts +++ b/examples/s4-convergence/main.ts @@ -52,7 +52,6 @@ renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); //* Upscaler — raw driver, temporal path const upscaler = new Upscaler({ renderer }); -upscaler.init(); // Motion vectors must be jitter-free, or every frame's jitter reads as motion. velocity.setProjectionMatrix(upscaler.unjitteredProjectionMatrix); // MRT output count must match the render target's attachment count (CLAUDE.md #7). @@ -448,7 +447,10 @@ configure(); setLayer('output'); requestSteps(1); +await Promise.all([upscaler.init()]); + renderer.setAnimationLoop(() => { + if (!upscaler.isReady) return; // At most one pipeline frame per animation frame: three's velocity node // rolls its previous-camera matrices once per renderer frame, so two scene // renders in one tick would see a stale previous camera. diff --git a/scripts/audit-windows.mjs b/scripts/audit-windows.mjs new file mode 100644 index 0000000..2470aed --- /dev/null +++ b/scripts/audit-windows.mjs @@ -0,0 +1,229 @@ +/** + * Reproducible Chrome/Edge demo smoke capture. Use an explicit browser executable. + * Profiles and screenshots belong to this run; no personal browser profile is used. + */ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { parseFlags } from './cli-flags.mjs'; +import { removeTempDirectory, spawnVite, stopChild, waitForUrl } from './local-processes.mjs'; + +const options = parseFlags(process.argv.slice(2), ['browser', 'out', 'demos', 'port', 'cdp-port', 'runs', 'trace', 'exercise', 'help']); +if (options.help || !options.browser) { + console.log('Usage: node scripts/audit-windows.mjs --browser [--demos 01-hello,07-tsl-node] [--runs 3] [--trace] [--out directory]'); + process.exit(options.help ? 0 : 1); +} +const demos = String(options.demos ?? '01-hello,02-fsr1-vs-fsr3,03-split-compare,04-aliasing-torture,05-transparency,06-screenspace-gi,07-tsl-node,08-tsl-compose,09-kitchen-sink,10-ssgi-denoise,11-node-reactive,12-temporal-guides,13-guides-node,14-pathtracer-alpha,15-transparent-canvas,16-spatial-node,s1-reinvest,s2-fractal,s3-how-low,s4-convergence').split(','); +const out = resolve(String(options.out ?? 'bench/results/windows-local')); +const port = Number(options.port ?? 5301); +const cdpPort = Number(options['cdp-port'] ?? 9341); +const runs = Number(options.runs ?? 1); +await mkdir(out, { recursive: true }); + +class CDP { + id = 0; requests = new Map(); events = []; + constructor(socket) { + this.socket = socket; + socket.addEventListener('message', event => { + const message = JSON.parse(event.data); + if (message.id) { + const request = this.requests.get(message.id); + this.requests.delete(message.id); + if (message.error) request?.reject(new Error(JSON.stringify(message.error))); + else request?.resolve(message.result); + } else this.events.push(message); + }); + } + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }); + return new CDP(socket); + } + send(method, params = {}, timeoutMs = 45000) { + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.requests.delete(id); reject(new Error(method + ' timed out')); }, timeoutMs); + this.requests.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + async evaluate(expression) { + const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text); + return result.result.value; + } + close() { this.socket.close(); } +} + +const instrumentation = ` +(() => { + const a = window.__audit = { modules: [], pipelines: [], sync: 0, submits: 0, frames: 0, maxFrameGap: 0 }; + let previous = performance.now(); + function frame(now) { a.frames++; a.maxFrameGap = Math.max(a.maxFrameGap, now - previous); previous = now; requestAnimationFrame(frame); } + requestAnimationFrame(frame); + if (!window.GPUDevice) return; + const module = GPUDevice.prototype.createShaderModule; + GPUDevice.prototype.createShaderModule = function(d) { a.modules.push({ label: d.label, chars: d.code.length }); return module.call(this, d); }; + const sync = GPUDevice.prototype.createComputePipeline; + GPUDevice.prototype.createComputePipeline = function(d) { a.sync++; return sync.call(this, d); }; + const async = GPUDevice.prototype.createComputePipelineAsync; + GPUDevice.prototype.createComputePipelineAsync = function(d) { + const record = { label: d.label, start: performance.now(), end: null, error: null }; + a.pipelines.push(record); + return async.call(this, d).then(p => { record.end = performance.now(); return p; }, e => { record.error = String(e); record.end = performance.now(); throw e; }); + }; + const submit = GPUQueue.prototype.submit; + GPUQueue.prototype.submit = function(commands) { a.submits++; if (!a.firstSubmit) a.firstSubmit = performance.now(); return submit.call(this, commands); }; + const request = GPUAdapter.prototype.requestDevice; + GPUAdapter.prototype.requestDevice = function(d) { + a.adapter = { vendor: this.info?.vendor, architecture: this.info?.architecture, device: this.info?.device, description: this.info?.description }; + return request.call(this, d).then(device => { a.features = [...device.features]; device.lost.then(info => a.lost = { reason: info.reason, message: info.message }); return device; }); + }; +})(); +`; + +const server = spawnVite('examples/vite.config.ts', { hostname: '127.0.0.1', port }); +const report = { date: new Date().toISOString(), browser: String(options.browser), node: process.version, platform: process.platform, records: [] }; +try { + await waitForUrl('http://127.0.0.1:' + port, { child: server }); + for (let run = 0; run < runs; run++) { + for (const demo of demos) { + const profile = await mkdtemp(join(tmpdir(), 'upscaler-audit-')); + const browser = spawn(String(options.browser), ['--headless=new', '--enable-unsafe-webgpu', '--no-first-run', '--no-default-browser-check', + '--remote-debugging-port=' + cdpPort, '--user-data-dir=' + profile, '--window-size=960,640', 'about:blank'], { stdio: 'ignore', windowsHide: true }); + let browserClient; + try { + await waitForUrl('http://127.0.0.1:' + cdpPort + '/json/version', { child: browser }); + const version = await (await fetch('http://127.0.0.1:' + cdpPort + '/json/version')).json(); + browserClient = await CDP.connect(version.webSocketDebuggerUrl); + report.browserVersion = version.Browser; + { + const target = await (await fetch('http://127.0.0.1:' + cdpPort + '/json/new?about:blank', { method: 'PUT' })).json(); + const client = await CDP.connect(target.webSocketDebuggerUrl); + try { + await client.send('Page.enable'); await client.send('Runtime.enable'); await client.send('Log.enable'); + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: instrumentation }); + if (options.trace) { + const categories = ['gpu.dawn', 'gpu.dawn.validation', 'toplevel']; + report.traceCategories = categories; + await client.send('Tracing.start', { categories: categories.join(','), transferMode: 'ReportEvents' }); + } + for (const cache of ['cold', 'warm']) { + client.events = []; + await client.send('Page.navigate', { url: 'http://127.0.0.1:' + port + '/' + demo + '/' }); + const start = Date.now(); + let snapshot; + while (Date.now() - start < (demo === '14-pathtracer-alpha' ? 60000 : 20000)) { + await delay(500); + snapshot = await client.evaluate('({audit:window.__audit, fatal:document.getElementById("fatal")?.textContent, canvas:!!document.querySelector("canvas")})'); + if (snapshot?.audit?.submits > 30 && snapshot.audit.pipelines.every(p => p.end !== null)) break; + if (snapshot?.fatal) break; + } + await delay(1000); + snapshot = await client.evaluate('({audit:window.__audit, fatal:document.getElementById("fatal")?.textContent, canvas:!!document.querySelector("canvas")})'); + const prefix = demo + '-r' + run + '-' + cache; + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }); + await writeFile(join(out, prefix + '.png'), Buffer.from(screenshot.data, 'base64')); + const issues = client.events.filter(e => + e.method === 'Runtime.exceptionThrown' || + (e.method === 'Log.entryAdded' && ['error','warning'].includes(e.params.entry.level)) || + (e.method === 'Runtime.consoleAPICalled' && ['error','warning'].includes(e.params.type))); + const record = { demo, run, cache, elapsedMs: Date.now() - start, snapshot, issues }; + report.records.push(record); + await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ demo, run, cache, sync: snapshot.audit?.sync, async: snapshot.audit?.pipelines.length, submits: snapshot.audit?.submits, issues: issues.length, fatal: snapshot.fatal })); + } + if (options.exercise) { + for (const metrics of [ + { width: 800, height: 600, deviceScaleFactor: 2, mobile: false }, + { width: 960, height: 640, deviceScaleFactor: 1, mobile: false }, + ]) { + await client.send('Emulation.setDeviceMetricsOverride', metrics); + await delay(1500); + } + const controls = await client.evaluate(`(async () => { + const changed = []; + for (const select of document.querySelectorAll('select')) { + const original = select.value; + for (const option of select.options) { + select.value = option.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + changed.push(option.text); + await new Promise(r => setTimeout(r, 250)); + } + select.value = original; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + return changed; + })()`); + await delay(1000); + let primitives = null; + if (demo === '13-guides-node') { + const libraryUrl = '/@fs/' + resolve(import.meta.dirname, '../src/index.ts').replaceAll('\\', '/'); + primitives = await client.evaluate(`(async () => { + const lib = await import(${JSON.stringify(libraryUrl)}); + const api = window.__guidesNodeExample; + const upscaler = api.fsrNode.upscaler; + const moments = new lib.MomentsPass({ renderer: api.renderer }); + moments.configure({ width: upscaler.displayWidth, height: upscaler.displayHeight }); + await moments.init(); + moments.dispatch({ source: upscaler.outputTexture }); + await api.renderer.backend.device.queue.onSubmittedWorkDone(); + const momentsReady = moments.isReady; + moments.dispose(); + const adapter = await navigator.gpu.requestAdapter(); + const device = await adapter.requestDevice(); + const separate = new lib.Upscaler({ renderer: { backend: { device } } }); + const supported = lib.Upscaler.isSupported(device); + await separate.init(); + const readyBeforeLoss = separate.isReady; + device.destroy(); + await device.lost; + const readyAfterLoss = separate.isReady; + let lossRejected = false; + try { await separate.prepare(); } catch { lossRejected = true; } + separate.dispose(); + return { momentsReady, supported, readyBeforeLoss, readyAfterLoss, lossRejected }; + })()`); + } + const errors = client.events.filter(e => e.method === 'Runtime.exceptionThrown' || + (e.method === 'Log.entryAdded' && e.params.entry.level === 'error' && !e.params.entry.url?.endsWith('/favicon.ico')) || + (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error')); + report.records.push({ demo, run, exercise: { resize: true, dpr: true, controls, primitives }, issues: errors }); + console.log(JSON.stringify({ demo, run, exercise: true, controls: controls.length, errors: errors.length })); + } + if (options.trace) { + await client.send('Tracing.end'); + for (let i = 0; i < 100 && !client.events.some(e => e.method === 'Tracing.tracingComplete'); i++) await delay(100); + const events = client.events.filter(e => e.method === 'Tracing.dataCollected').flatMap(e => e.params.value); + await writeFile(join(out, demo + '-r' + run + '-trace.json'), JSON.stringify({ traceEvents: events })); + } + } catch (error) { + report.records.push({ demo, run, error: String(error) }); + await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2)); + console.error(demo, String(error)); + } finally { + client.close(); + await fetch('http://127.0.0.1:' + cdpPort + '/json/close/' + target.id); + } + } + } finally { + if (browserClient) { + await browserClient.send('Browser.close', {}, 2000).catch(() => {}); + browserClient.close(); + } + await stopChild(browser); + const ownedProfile = resolve(profile); + if (dirname(ownedProfile) === resolve(tmpdir()) && basename(ownedProfile).startsWith('upscaler-audit-')) + await removeTempDirectory(ownedProfile, 'audit profile'); + else console.error('Refusing to remove a profile outside this audit\'s temporary directory.'); + } + } + } +} finally { await stopChild(server); } +await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2)); diff --git a/scripts/npm-command.mjs b/scripts/npm-command.mjs new file mode 100644 index 0000000..db38209 --- /dev/null +++ b/scripts/npm-command.mjs @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** Invoke npm's JavaScript CLI directly, avoiding Windows .cmd shell requirements. */ +export function npmInvocation(args) { + const candidates = [ + process.env.npm_execpath, + join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), + join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js'), + join(dirname(process.execPath), '../../npm/bin/npm-cli.js'), + ]; + const cli = candidates.find(path => path && existsSync(path)); + if (cli) return [process.execPath, [cli, ...args]]; + if (process.platform !== 'win32') return ['npm', args]; + throw new Error('npm CLI not found; run this command through npm or install npm beside Node.'); +} diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index 0da6ade..2bb74be 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -1,3 +1,4 @@ +import { npmInvocation } from './npm-command.mjs'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { relative, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -55,8 +56,7 @@ assertCleanFiles(distFiles, 'dist'); //* Package Manifest === const packed = spawnSync( - 'npm', - ['pack', '--dry-run', '--ignore-scripts', '--json'], + ...npmInvocation(['pack', '--dry-run', '--ignore-scripts', '--json']), { cwd: root, encoding: 'utf8' }, ); if (packed.status !== 0) diff --git a/scripts/verify-packed-guides.mjs b/scripts/verify-packed-guides.mjs index 6e9f170..d021d8a 100644 --- a/scripts/verify-packed-guides.mjs +++ b/scripts/verify-packed-guides.mjs @@ -1,9 +1,11 @@ +import { npmInvocation } from './npm-command.mjs'; import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, symlinkSync, } from 'node:fs'; import { createServer } from 'node:net'; @@ -102,7 +104,8 @@ export function assertProbeResult(result) { } function run(command, args, options = {}) { - const result = spawnSync(command, args, { + const invocation = command === 'npm' ? npmInvocation(args) : [command, args]; + const result = spawnSync(...invocation, { cwd: ROOT, encoding: 'utf8', stdio: 'inherit', @@ -115,8 +118,7 @@ function run(command, args, options = {}) { function packLibrary(archiveDirectory) { const result = spawnSync( - 'npm', - ['pack', '--ignore-scripts', '--json', '--pack-destination', archiveDirectory], + ...npmInvocation(['pack', '--ignore-scripts', '--json', '--pack-destination', archiveDirectory]), { cwd: ROOT, encoding: 'utf8', @@ -127,14 +129,19 @@ function packLibrary(archiveDirectory) { const filename = parsePackJson(result.stdout)[0].filename; if (!filename) throw new Error('npm pack did not report an artifact filename.'); - return join(archiveDirectory, filename); + const archive = join(archiveDirectory, filename); + if (existsSync(archive)) return archive; + // npm 8 on Windows reports a scoped filename that differs from the actual tarball. + const tarballs = readdirSync(archiveDirectory).filter(name => name.endsWith('.tgz')); + if (tarballs.length !== 1) throw new Error('npm pack did not create one identifiable archive.'); + return join(archiveDirectory, tarballs[0]); } function unpackLibrary(archive, consumerDirectory) { run('tar', ['-xzf', archive, '-C', consumerDirectory]); const consumerModules = join(consumerDirectory, 'node_modules'); mkdirSync(consumerModules); - symlinkSync(join(ROOT, 'node_modules/three'), join(consumerModules, 'three'), 'dir'); + symlinkSync(join(ROOT, 'node_modules/three'), join(consumerModules, 'three'), process.platform === 'win32' ? 'junction' : 'dir'); const packageDirectory = join(consumerDirectory, 'package'); const manifest = JSON.parse(readFileSync(join(packageDirectory, 'package.json'), 'utf8')); @@ -302,8 +309,8 @@ async function waitForExample(client) { const ready = await evaluate( client, `Boolean( - window.__guidesNodeExample?.guidesNode?.upscaler && - window.__guidesNodeExample?.fsrNode?.upscaler + window.__guidesNodeExample?.guidesNode?.upscaler?.isReady && + window.__guidesNodeExample?.fsrNode?.upscaler?.isReady )`, ); if (ready) return; @@ -441,7 +448,9 @@ async function runGpuSmoke(outputDirectory, packageEntry, options) { await client.call('Page.navigate', { url: exampleUrl }); await waitForExample(client); const probe = await runProbe(client); - assertProbeResult(probe); + try { assertProbeResult(probe); } catch (error) { + throw new Error(String(error) + "\n" + JSON.stringify(probe)); + } const failures = browserLogFailures(logRecords); if (failures.length) diff --git a/src/MomentsPass.ts b/src/MomentsPass.ts index 0b874a1..4449fc6 100644 --- a/src/MomentsPass.ts +++ b/src/MomentsPass.ts @@ -39,6 +39,7 @@ export interface MomentsPassConfig { * ```ts * const moments = new MomentsPass({ renderer }); * moments.configure({ width, height, space: 'ycocg' }); + * await moments.init(); * moments.dispatch({ source: giTexture }); // per frame * // sample moments.moments (.rg) / moments.coarseMoments * ``` @@ -48,6 +49,9 @@ export class MomentsPass { private _device: GPUDevice | null = null; private _constants: ConstantsBuffer | null = null; private _pass: ComputePass | null = null; + private _preparing: Promise | null = null; + private _generation = 0; + private _lost = false; private _space: MomentsSpace = 'linear'; private _width = 0; private _height = 0; @@ -63,6 +67,29 @@ export class MomentsPass { this._renderer = options.renderer; } + /** Compile asynchronously. Configure first, then await before dispatch. */ + init(): Promise { + if (this._pass && !this._lost) return Promise.resolve(); + if (this._preparing) return this._preparing; + if (!this._device) { + this._device = getDevice(this._renderer); + this._constants = new ConstantsBuffer(this._device); + } + const generation = this._generation; + this._preparing = ComputePass.create(this._device, 'moments', MOMENTS_SHADER).then(pass => { + if (generation !== this._generation || this._lost) throw new Error('MomentsPass preparation cancelled.'); + this._pass = pass; + }).catch(error => { + if (generation === this._generation) this._preparing = null; + throw error; + }); + void this._device.lost.then(() => { if (generation === this._generation) this._lost = true; }); + return this._preparing; + } + + /** Mandatory pipeline readiness. */ + get isReady(): boolean { return this._pass !== null && !this._lost; } + /** * (Re)configures size and scalar space and allocates the output * textures. The space is a runtime flag, so one pipeline serves both. @@ -74,7 +101,7 @@ export class MomentsPass { this._constants = new ConstantsBuffer(this._device); } this._space = config.space ?? 'linear'; - this._pass ??= new ComputePass(this._device, 'moments', MOMENTS_SHADER); + this._width = Math.max(1, Math.floor(config.width)); this._height = Math.max(1, Math.floor(config.height)); this._constants!.setRenderSize(this._width, this._height); @@ -120,6 +147,7 @@ export class MomentsPass { * never filtered — any float format works) */ dispatch(inputs: { source: Texture }): void { + if (!this.isReady) throw new Error("@pmndrs/upscaler: await MomentsPass.init() before dispatch()."); if (!this._pass || !this._momentsGPU || !this._coarseGPU) { throw new Error('@pmndrs/upscaler: MomentsPass.configure() must run before dispatch().'); } @@ -148,6 +176,9 @@ export class MomentsPass { /** Releases the output textures and GPU resources. */ dispose(): void { + this._generation++; + this._preparing = null; + this._lost = false; this._destroyOutputs(); this._constants?.dispose(); this._constants = null; diff --git a/src/TemporalGuidesNode.ts b/src/TemporalGuidesNode.ts index 9896b16..8c8e376 100644 --- a/src/TemporalGuidesNode.ts +++ b/src/TemporalGuidesNode.ts @@ -113,7 +113,7 @@ export class TemporalGuidesNode extends TempNode { _acquireUpscaler(renderer: WebGPURenderer): Upscaler { if (!this._upscaler) { this._upscaler = new Upscaler({ renderer, gpuTiming: this._gpuTiming }); - this._upscaler.init(); + } this._linked = true; return this._upscaler; @@ -127,7 +127,7 @@ export class TemporalGuidesNode extends TempNode { //* buffer size; corrected to the depth input's real size in //* updateBefore (mirrors UpscalerNode's seed-then-correct flow). this._upscaler = new Upscaler({ renderer, gpuTiming: this._gpuTiming }); - this._upscaler.init(); + renderer.getDrawingBufferSize(this._size); this._configureStandalone(this._size.x, this._size.y); } @@ -151,7 +151,7 @@ export class TemporalGuidesNode extends TempNode { updateBefore(frame: any): any { const renderer = frame.renderer as WebGPURenderer; const upscaler = this._upscaler; - if (!upscaler) return; + if (!upscaler?.isReady) return; const depth = this._texture(this._depth); const velocity = this._texture(this._velocity); diff --git a/src/UpscalePass.ts b/src/UpscalePass.ts index 211d102..600204c 100644 --- a/src/UpscalePass.ts +++ b/src/UpscalePass.ts @@ -1,5 +1,5 @@ import * as THREE from 'three/webgpu'; -import { mrt, output, texture, velocity } from 'three/tsl'; +import { mix, mrt, output, texture, uniform, velocity } from 'three/tsl'; import { Upscaler } from './Upscaler'; import { getQualityModeRatio } from './math/resolution'; @@ -46,6 +46,7 @@ export class UpscalePass { private readonly _quadMaterial: THREE.NodeMaterial; private _rt: THREE.RenderTarget | null = null; + private readonly _readyUniform = uniform(0); private _path: UpscalePath = 'temporal'; private _reactive: THREE.Texture | null = null; private _reactiveOpaque: THREE.Texture | null = null; @@ -64,7 +65,7 @@ export class UpscalePass { ) { this._renderer = renderer; this.upscaler = new Upscaler({ renderer, gpuTiming: options.gpuTiming }); - this.upscaler.init(); + // Motion vectors must be jitter-free — hand the velocity node the // upscaler's unjittered projection (a stable instance, refreshed each @@ -87,6 +88,9 @@ export class UpscalePass { this._quad = new THREE.QuadMesh(this._quadMaterial); } + /** Prepare after configuring; await before drawing. */ + init(): Promise { return this.upscaler.init(); } + /** The render target the scene is drawn into (color [+ velocity], depth). */ get renderTarget(): THREE.RenderTarget | null { return this._rt; @@ -136,7 +140,7 @@ export class UpscalePass { this._rt.textures[0].name = 'output'; if (temporal) this._rt.textures[1].name = 'velocity'; - this._quadMaterial.colorNode = texture(this.upscaler.outputTexture); + this._quadMaterial.colorNode = mix(texture(this._rt.textures[0]), texture(this.upscaler.outputTexture), this._readyUniform); this._quadMaterial.needsUpdate = true; } @@ -157,13 +161,15 @@ export class UpscalePass { if (!rt) return; const temporal = this._path === 'temporal'; - this.upscaler.beginFrame(camera); + this._readyUniform.value = this.upscaler.isReady ? 1 : 0; + if (this.upscaler.isReady) this.upscaler.beginFrame(camera); this._renderer.setMRT(temporal ? this._mrtFull : this._mrtColorOnly); this._renderer.setRenderTarget(rt); this._renderer.render(scene, camera); this._renderer.setRenderTarget(null); this._renderer.setMRT(null); - this.upscaler.endFrame(camera); + if (this.upscaler.isReady) this.upscaler.endFrame(camera); + if (!this.upscaler.isReady) return; this.upscaler.dispatch( { @@ -185,7 +191,7 @@ export class UpscalePass { * reaches the canvas as `v` encoded for that color space — not tone mapped. */ present(): void { - if (this._path !== 'temporal' || this.upscaler.settings.debugView === DebugView.None) { + if (this._path !== 'temporal' || this.upscaler.activeDebugView === DebugView.None) { this._quad.render(this._renderer); return; } diff --git a/src/Upscaler.initialization.test.ts b/src/Upscaler.initialization.test.ts new file mode 100644 index 0000000..ebd223d --- /dev/null +++ b/src/Upscaler.initialization.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PerspectiveCamera, Texture } from 'three'; +import type { WebGPURenderer } from 'three/webgpu'; +import { Upscaler } from './Upscaler'; +import { DebugView } from './types'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} +function fixture() { + const lost = deferred(); + const jobs: Array<{ label: string; resolve(value: GPUComputePipeline): void; reject(error: unknown): void }> = []; + const resource = (descriptor: { size?: number; width?: number; height?: number } = {}) => ({ + width: descriptor.width ?? 16, height: descriptor.height ?? 16, sampleCount: 1, + format: 'rgba16float', createView: () => ({}), destroy: vi.fn(), + getMappedRange: () => new ArrayBuffer(descriptor.size ?? 4), unmap: vi.fn(), + getBindGroupLayout: () => ({}), + }); + const encoder = { + beginComputePass: () => ({ setPipeline() {}, setBindGroup() {}, dispatchWorkgroups() {}, end() {} }), + finish: () => ({}), + }; + const device = { + lost: lost.promise, features: new Set(), + limits: { + maxBindGroups: 4, maxBindingsPerBindGroup: 1000, maxSampledTexturesPerShaderStage: 16, + maxStorageTexturesPerShaderStage: 8, maxStorageBuffersPerShaderStage: 8, + maxUniformBuffersPerShaderStage: 12, maxSamplersPerShaderStage: 16, + maxUniformBufferBindingSize: 65536, maxComputeWorkgroupSizeX: 256, + maxComputeWorkgroupSizeY: 256, maxComputeInvocationsPerWorkgroup: 256, + maxComputeWorkgroupStorageSize: 16384, + }, + queue: { writeBuffer: vi.fn(), writeTexture: vi.fn(), submit: vi.fn() }, + createShaderModule: vi.fn(resource), + createComputePipelineAsync: vi.fn((descriptor: GPUComputePipelineDescriptor) => + new Promise((resolve, reject) => jobs.push({ label: descriptor.label!, resolve, reject }))), + createBuffer: resource, createSampler: resource, createTexture: resource, + createBindGroup: resource, createCommandEncoder: () => encoder, + }; + const renderer = { + backend: { device, get: () => ({ texture: resource() }) }, initTexture() {}, + } as unknown as WebGPURenderer; + const upscaler = new Upscaler({ renderer }); + const finish = async () => { + for (let i = 0; i < 30; i++) { + jobs.splice(0).forEach(job => job.resolve(resource() as unknown as GPUComputePipeline)); + await Promise.resolve(); + } + }; + return { upscaler, device, renderer, jobs, lost, finish }; +} + +describe('Upscaler preparation', () => { + beforeEach(() => { + vi.stubGlobal('GPUBufferUsage', { UNIFORM: 64, COPY_DST: 8, STORAGE: 128 }); + vi.stubGlobal('GPUTextureUsage', { TEXTURE_BINDING: 4, STORAGE_BINDING: 8, COPY_DST: 2 }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + it('checks capability without allocating, including absent optional timestamps', () => { + const { device } = fixture(); + expect(Upscaler.isSupported(device as unknown as GPUDevice)).toBe(true); + device.limits.maxComputeInvocationsPerWorkgroup = 32; + expect(Upscaler.isSupported(device as unknown as GPUDevice)).toBe(false); + expect(device.createShaderModule).not.toHaveBeenCalled(); + expect(device.createComputePipelineAsync).not.toHaveBeenCalled(); + }); + + it('allocates synchronously but refuses dispatch until mandatory pipelines resolve', async () => { + const { upscaler, finish } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'bilinear' }); + expect(upscaler.outputTexture).toBeDefined(); + expect(upscaler.isReady).toBe(false); + expect(() => upscaler.dispatch({ color: new Texture() }, new PerspectiveCamera())).toThrow('await init()'); + const ready = upscaler.init(); + await finish(); await ready; + expect(upscaler.isReady).toBe(true); + }); + + it('compiles only the configured path and preserves sharing across disposal', async () => { + const { upscaler, device, renderer, finish } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'spatial' }); + const ready = upscaler.init(); + await finish(); await ready; + expect(device.createComputePipelineAsync.mock.calls.map(([d]) => d.label).sort()) + .toEqual(['upscale-blit', 'upscale-easu', 'upscale-rcas']); + const other = new Upscaler({ renderer }); + other.configure({ displayWidth: 64, displayHeight: 64, path: 'spatial' }); + await other.init(); + upscaler.dispose(); + expect(other.isReady).toBe(true); + expect(device.createComputePipelineAsync).toHaveBeenCalledTimes(3); + }); + + it('does not revive a disposed instance when compilation resolves', async () => { + const { upscaler, finish } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'bilinear' }); + const ready = upscaler.init(); + const rejected = expect(ready).rejects.toThrow('cancelled'); + upscaler.dispose(); + await finish(); await rejected; + expect(upscaler.isReady).toBe(false); + }); + + it('keeps newly enabled optional features inactive until a later frame, freezing split settings', async () => { + const { upscaler, finish, jobs } = fixture(); + upscaler.settings.detectShadingChanges = false; + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'temporal' }); + const ready = upscaler.init(); await finish(); await ready; + const color = new Texture(), depth = new Texture(), velocity = new Texture(); + const camera = new PerspectiveCamera(); + upscaler.settings.debugView = DebugView.MotionVectors; + upscaler.dispatch({ color, depth, velocity }, camera); + expect(upscaler.activeDebugView).toBe(DebugView.None); + expect(jobs.some(job => job.label === 'upscale-debug')).toBe(true); + await finish(); + upscaler.beginFrame(camera); + const appliedJitter = { ...upscaler.jitter }; + upscaler.endFrame(camera); + upscaler.dispatchGuides({ depth, velocity }, camera); + expect(upscaler.jitter).toEqual(appliedJitter); + expect(upscaler.activeDebugView).toBe(DebugView.MotionVectors); + upscaler.settings.debugView = DebugView.None; + upscaler.dispatchUpscale({ color }, camera); + expect(upscaler.activeDebugView).toBe(DebugView.MotionVectors); + upscaler.dispatch({ color, depth, velocity }, camera); + expect(upscaler.activeDebugView).toBe(DebugView.None); + }); + + it('prepares only geometry for guides-only consumers', async () => { + const { upscaler, device, finish } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'guides' }); + const ready = upscaler.init(); await finish(); await ready; + expect(device.createComputePipelineAsync.mock.calls.map(([d]) => d.label).sort()) + .toEqual(['upscale-depth-clip', 'upscale-reconstruct']); + expect(upscaler.guides.dilatedDepth).toBeDefined(); + }); + + it('retries failures only through explicit preparation', async () => { + const { upscaler, device, finish, jobs } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'bilinear' }); + const rejected = expect(upscaler.init()).rejects.toThrow('injected failure'); + jobs.splice(0).forEach(job => job.reject(new Error('injected failure'))); + await rejected; + upscaler.configure({ displayWidth: 64, displayHeight: 64, path: 'bilinear' }); + await Promise.resolve(); + expect(device.createComputePipelineAsync).toHaveBeenCalledTimes(1); + const retry = upscaler.prepare(); await finish(); await retry; + expect(device.createComputePipelineAsync).toHaveBeenCalledTimes(2); + expect(upscaler.isReady).toBe(true); + }); + + it('invalidates readiness on device loss', async () => { + const { upscaler, finish, lost } = fixture(); + upscaler.configure({ displayWidth: 32, displayHeight: 32, path: 'bilinear' }); + const ready = upscaler.init(); await finish(); await ready; + lost.resolve({} as GPUDeviceLostInfo); await Promise.resolve(); + expect(upscaler.isReady).toBe(false); + }); +}); diff --git a/src/Upscaler.jitter.test.ts b/src/Upscaler.jitter.test.ts index 7c1c110..4185c26 100644 --- a/src/Upscaler.jitter.test.ts +++ b/src/Upscaler.jitter.test.ts @@ -20,6 +20,7 @@ function mockRenderer() { destroy: () => {}, }); const device = { + lost: new Promise(() => {}), features: new Set(), queue: { writeBuffer: vi.fn((_buffer: unknown, _offset: number, data: ArrayBuffer) => { @@ -29,7 +30,7 @@ function mockRenderer() { submit: vi.fn(), }, createShaderModule: resource, - createComputePipeline: resource, + createComputePipelineAsync: async () => resource(), createSampler: resource, createBuffer: resource, createTexture: resource, @@ -48,11 +49,12 @@ function mockRenderer() { return { renderer, writes }; } -function configured(path: UpscalePath = 'temporal', jitter = true) { +async function configured(path: UpscalePath = 'temporal', jitter = true) { const { renderer, writes } = mockRenderer(); const upscaler = new Upscaler({ renderer }); - upscaler.init(); + upscaler.configure({ displayWidth: 1280, displayHeight: 720, customUpscaleRatio: 2, path, jitter }); + await upscaler.init(); return { upscaler, writes }; } @@ -62,7 +64,7 @@ const camera = () => { return c; }; -describe('Upscaler jitter accessors', () => { +describe('Upscaler jitter accessors', async () => { beforeEach(() => { vi.stubGlobal('GPUBufferUsage', { MAP_READ: 1, COPY_SRC: 4, COPY_DST: 8, UNIFORM: 64, STORAGE: 128 }); vi.stubGlobal('GPUTextureUsage', { TEXTURE_BINDING: 4, STORAGE_BINDING: 8 }); @@ -73,8 +75,8 @@ describe('Upscaler jitter accessors', () => { vi.restoreAllMocks(); }); - it('follows generateJitterSequence over a full cycle, phase by phase', () => { - const { upscaler } = configured(); + it('follows generateJitterSequence over a full cycle, phase by phase', async () => { + const { upscaler } = await configured(); const cam = camera(); const sequence = generateJitterSequence(upscaler.jitterPhaseCount); let previous = sequence[0]; @@ -90,8 +92,8 @@ describe('Upscaler jitter accessors', () => { } }); - it('matches the offset beginFrame applies to the camera view', () => { - const { upscaler } = configured(); + it('matches the offset beginFrame applies to the camera view', async () => { + const { upscaler } = await configured(); const cam = camera(); upscaler.beginFrame(cam); expect(cam.view?.offsetX).toBe(upscaler.jitter.x); @@ -99,8 +101,8 @@ describe('Upscaler jitter accessors', () => { upscaler.endFrame(cam); }); - it('reports the pure jitter under an app-set view offset', () => { - const { upscaler } = configured(); + it('reports the pure jitter under an app-set view offset', async () => { + const { upscaler } = await configured(); const cam = camera(); // A tiled setup: this camera renders the right half of a 2× wider wall // at twice the render resolution, so one render pixel is 2 view units. @@ -115,8 +117,8 @@ describe('Upscaler jitter accessors', () => { expect(cam.view?.offsetX).toBe(1280); }); - it('agrees with the jitter staged into the constants buffer', () => { - const { upscaler, writes } = configured(); + it('agrees with the jitter staged into the constants buffer', async () => { + const { upscaler, writes } = await configured(); const cam = camera(); upscaler.beginFrame(cam); writes.length = 0; @@ -138,8 +140,8 @@ describe('Upscaler jitter accessors', () => { ]); }); - it('restarts the phase on resetHistory()', () => { - const { upscaler } = configured(); + it('restarts the phase on resetHistory()', async () => { + const { upscaler } = await configured(); const cam = camera(); for (let i = 0; i < 5; i++) { upscaler.beginFrame(cam); @@ -156,8 +158,8 @@ describe('Upscaler jitter accessors', () => { ['temporal with jitter: false', 'temporal', false], ['spatial', 'spatial', true], ['bilinear', 'bilinear', true], - ] as const)('reads (0, 0) and phase 0 on %s', (_label, path, jitter) => { - const { upscaler } = configured(path, jitter); + ] as const)('reads (0, 0) and phase 0 on %s', async (_label, path, jitter) => { + const { upscaler } = await configured(path, jitter); const cam = camera(); upscaler.beginFrame(cam); expect(upscaler.jitter).toEqual({ x: 0, y: 0 }); @@ -167,8 +169,8 @@ describe('Upscaler jitter accessors', () => { upscaler.endFrame(cam); }); - it('returns stable objects', () => { - const { upscaler } = configured(); + it('returns stable objects', async () => { + const { upscaler } = await configured(); const cam = camera(); const first = upscaler.jitter; upscaler.beginFrame(cam); diff --git a/src/Upscaler.timing.test.ts b/src/Upscaler.timing.test.ts index a117cba..7f9c88e 100644 --- a/src/Upscaler.timing.test.ts +++ b/src/Upscaler.timing.test.ts @@ -18,12 +18,13 @@ function mockRenderer(features: string[] = ['timestamp-query']) { }; }; const device = { + lost: new Promise(() => {}), features: new Set(features), queue: { writeBuffer: vi.fn(), submit: vi.fn() }, pushErrorScope: vi.fn(), popErrorScope: vi.fn(() => Promise.resolve(null)), createShaderModule: stub('createShaderModule'), - createComputePipeline: stub('createComputePipeline'), + createComputePipelineAsync: async (...args: Parameters>) => stub('createComputePipelineAsync')(...args), createSampler: stub('createSampler'), createBuffer: stub('createBuffer'), createQuerySet: stub('createQuerySet'), @@ -34,7 +35,7 @@ function mockRenderer(features: string[] = ['timestamp-query']) { const timerCalls = (calls: string[]) => calls.filter((kind) => kind === 'createQuerySet').length; -describe('Upscaler GPU timing switch', () => { +describe('Upscaler GPU timing switch', async () => { beforeEach(() => { vi.stubGlobal('GPUBufferUsage', { MAP_READ: 1, @@ -51,35 +52,35 @@ describe('Upscaler GPU timing switch', () => { vi.restoreAllMocks(); }); - it('allocates no timer resources and opens no error scopes when off', () => { + it('allocates no timer resources and opens no error scopes when off', async () => { const { renderer, calls, device } = mockRenderer(); const upscaler = new Upscaler({ renderer, gpuTiming: false }); - upscaler.init(); + await upscaler.init(); expect(timerCalls(calls)).toBe(0); expect(device.pushErrorScope).not.toHaveBeenCalled(); expect(upscaler.gpuTiming).toBe(false); expect(upscaler.gpuTimings.size).toBe(0); }); - it('is off by default', () => { + it('is off by default', async () => { const { renderer, calls } = mockRenderer(); const upscaler = new Upscaler({ renderer }); - upscaler.init(); + await upscaler.init(); expect(upscaler.gpuTiming).toBe(false); expect(timerCalls(calls)).toBe(0); }); - it('allocates the timer at init when on', () => { + it('allocates the timer at init when on', async () => { const { renderer, calls } = mockRenderer(); const upscaler = new Upscaler({ renderer, gpuTiming: true }); - upscaler.init(); + await upscaler.init(); expect(timerCalls(calls)).toBeGreaterThan(0); }); - it('allocates on enable and frees everything on disable', () => { + it('allocates on enable and frees everything on disable', async () => { const { renderer, calls, destroyed } = mockRenderer(); const upscaler = new Upscaler({ renderer, gpuTiming: false }); - upscaler.init(); + await upscaler.init(); upscaler.gpuTiming = true; const allocated = timerCalls(calls); @@ -90,12 +91,12 @@ describe('Upscaler GPU timing switch', () => { expect(upscaler.gpuTimings.size).toBe(0); }); - it('defers allocation to init when toggled on before it', () => { + it('defers allocation to init when toggled on before it', async () => { const { renderer, calls } = mockRenderer(); const upscaler = new Upscaler({ renderer, gpuTiming: false }); upscaler.gpuTiming = true; expect(calls).toEqual([]); - upscaler.init(); + await upscaler.init(); expect(timerCalls(calls)).toBeGreaterThan(0); }); }); diff --git a/src/Upscaler.ts b/src/Upscaler.ts index ea39340..c63fef6 100644 --- a/src/Upscaler.ts +++ b/src/Upscaler.ts @@ -189,6 +189,14 @@ export class Upscaler { private _historyIndex = 0; private _depthIndex = 0; private _initialized = false; + private _generation = 0; + private _deviceLost = false; + private readonly _passes = new Map(); + private readonly _requests = new Map>(); + private readonly _failures = new Map(); + private _effectiveSettings: RuntimeSettings = { ...this.settings }; + private _shadingActive = false; + private _debugActive = false; // GPU-side working set (owned raw textures + the three-visible output) private _output: StorageTexture | null = null; @@ -272,73 +280,159 @@ export class Upscaler { this._crossFrameReconstruct = options._crossFrameReconstruct ?? null; } - /** - * Compiles all compute pipelines. Call once after `renderer.init()`. - */ - init(): void { + /** Baseline device capability only; does not compile or allocate anything. */ + static isSupported(device: GPUDevice): boolean { + const limits = device.limits; + return limits.maxBindGroups >= 1 && + limits.maxBindingsPerBindGroup >= 13 && + limits.maxSampledTexturesPerShaderStage >= 9 && + limits.maxStorageTexturesPerShaderStage >= 3 && + limits.maxStorageBuffersPerShaderStage >= 2 && + limits.maxUniformBuffersPerShaderStage >= 1 && + limits.maxSamplersPerShaderStage >= 1 && + limits.maxUniformBufferBindingSize >= ConstantsBuffer.SIZE && + limits.maxComputeWorkgroupSizeX >= 8 && limits.maxComputeWorkgroupSizeY >= 8 && + limits.maxComputeInvocationsPerWorkgroup >= 64 && + limits.maxComputeWorkgroupStorageSize >= 1280; + } + + /** Prepare the configured path. Call after renderer.init(); await before raw dispatch. */ + init(): Promise { + return this.prepare(); + } + + /** Explicitly warms the current path and optional settings; also retries failed requests. */ + prepare(): Promise { + this._ensureResources(); + if (this._deviceLost) return Promise.reject(new Error("@pmndrs/upscaler: GPU device lost.")); + return this._queuePreparation(true); + } + + /** Mandatory passes are ready; newly requested optional passes may still be preparing. */ + get isReady(): boolean { + return this._initialized && !this._deviceLost && + this._mandatoryPasses().every(name => this._passes.has(name)); + } + + /** The debug view actually used by the most recent frame (None while preparing). */ + get activeDebugView(): DebugView { + return this._effectiveSettings.debugView; + } + + private _ensureResources(): void { if (this._initialized) return; const device = getDevice(this._renderer); this._device = device; + this._deviceLost = false; + const generation = this._generation; + void device.lost.then(() => { + if (generation === this._generation) this._deviceLost = true; + }); this._constants = new ConstantsBuffer(device); if (this._gpuTiming) this._timer = new GpuTimer(device); this._linearSampler = device.createSampler({ - label: 'upscale-linear-clamp', - magFilter: 'linear', - minFilter: 'linear', - addressModeU: 'clamp-to-edge', - addressModeV: 'clamp-to-edge', + label: 'upscale-linear-clamp', magFilter: 'linear', minFilter: 'linear', + addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge', }); + if (this._crossFrameReconstruct?.cameraCompensated) { + this._reprojectBuffer = device.createBuffer({ + label: 'upscale-reproject', size: 32, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + } + this._jitter = new JitterSequence(this._ratio); + this._initialized = true; + } - this._blitPass = new ComputePass(device, 'blit', BLIT_SHADER); - this._easuPass = new ComputePass(device, 'easu', EASU_SHADER); - this._rcasPass = new ComputePass(device, 'rcas', this._rcasShader); - this._spatialRcasPass = - this._spatialRcasShader === null - ? this._rcasPass - : new ComputePass(device, 'rcas', this._spatialRcasShader); - if (this._crossFrameReconstruct) { - this._reconstructPass = new ComputePass( - device, - 'reconstruct', - this._crossFrameReconstruct.shader, - ); - if (this._crossFrameReconstruct.cameraCompensated) { - this._reprojectBuffer = device.createBuffer({ - label: 'upscale-reproject', - size: 32, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - } - } else { - this._reconstructPass = new ComputePass(device, 'reconstruct', RECONSTRUCT_SHADER); - this._depthClipPass = new ComputePass( - device, - 'depth-clip', - this._depthClipShader ?? DEPTH_CLIP_SHADER, - ); + private _mandatoryPasses(): string[] { + if (this._path === 'bilinear') return ['blit']; + if (this._path === 'spatial') return ['easu', 'spatialRcas', 'blit']; + const geometry = this._crossFrameReconstruct ? ['reconstruct'] : ['reconstruct', 'depthClip']; + return this._path === 'guides' ? geometry : + [...geometry, 'accumulate', 'exposure', 'generateReactive', 'rcas', 'blit']; + } + + private _requestPass(name: string, retry: boolean): Promise { + if (this._passes.has(name)) return Promise.resolve(); + if (this._failures.has(name)) { + if (!retry) return Promise.reject(this._failures.get(name)); + this._failures.delete(name); + this._requests.delete(name); } - this._accumulatePass = new ComputePass(device, 'accumulate', ACCUMULATE_SHADER, { - shaderKey: 'baseline:accumulate', - assembledChunks: [], - }); - this._exposurePass = new ComputePass(device, 'exposure', LUMINANCE_PYRAMID_SHADER); - this._shadingChangePass = new ComputePass( - device, - 'shading-change', - this._shadingChangeShader ?? SHADING_CHANGE_SHADER, - ); - this._generateReactivePass = new ComputePass( - device, - 'gen-reactive', - GENERATE_REACTIVE_SHADER, - ); - this._debugPass = new ComputePass(device, 'debug', DEBUG_SHADER, { - shaderKey: 'baseline:debug', - assembledChunks: [], + const existing = this._requests.get(name); + if (existing) return existing; + const shaders: Record = { + blit: ['blit', BLIT_SHADER], easu: ['easu', EASU_SHADER], + rcas: ['rcas', this._rcasShader], + spatialRcas: ['rcas', this._spatialRcasShader ?? this._rcasShader], + reconstruct: ['reconstruct', this._crossFrameReconstruct?.shader ?? RECONSTRUCT_SHADER], + depthClip: ['depth-clip', this._depthClipShader ?? DEPTH_CLIP_SHADER], + accumulate: ['accumulate', ACCUMULATE_SHADER], + exposure: ['exposure', LUMINANCE_PYRAMID_SHADER], + generateReactive: ['gen-reactive', GENERATE_REACTIVE_SHADER], + shadingChange: ['shading-change', this._shadingChangeShader ?? SHADING_CHANGE_SHADER], + debug: ['debug', DEBUG_SHADER], + }; + const [label, code] = shaders[name]; + const generation = this._generation; + const request = ComputePass.create(this._device, label, code).then(pass => { + if (generation !== this._generation || this._deviceLost) { + throw new Error('@pmndrs/upscaler: preparation cancelled by disposal or device loss.'); + } + this._passes.set(name, pass); + switch (name) { + case 'blit': this._blitPass = pass; break; + case 'easu': this._easuPass = pass; break; + case 'rcas': this._rcasPass = pass; break; + case 'spatialRcas': this._spatialRcasPass = pass; break; + case 'reconstruct': this._reconstructPass = pass; break; + case 'depthClip': this._depthClipPass = pass; break; + case 'accumulate': this._accumulatePass = pass; break; + case 'exposure': this._exposurePass = pass; break; + case 'generateReactive': this._generateReactivePass = pass; break; + case 'shadingChange': this._shadingChangePass = pass; break; + case 'debug': this._debugPass = pass; break; + } + }).catch(error => { + if (generation === this._generation && !this._failures.has(name)) { + this._failures.set(name, error); + console.error('@pmndrs/upscaler: failed to prepare ' + label, error); + } + throw error; }); + this._requests.set(name, request); + return request; + } - this._jitter = new JitterSequence(this._ratio); - this._initialized = true; + private _queuePreparation(retry: boolean): Promise { + const names = this._mandatoryPasses(); + if (this._path === 'temporal') { + if (this.settings.detectShadingChanges) names.push('shadingChange'); + if (this.settings.debugView !== DebugView.None) names.push('debug'); + } + return Promise.all(names.filter(name => !this._passes.has(name)).map(name => this._requestPass(name, retry))).then(() => {}); + } + + private _startDispatch(): void { + if (!this.isReady) { + throw new Error('@pmndrs/upscaler: await init() after configure() before dispatch.'); + } + // Automatic preparation handles rejection here; explicit prepare() still rejects. + void this._queuePreparation(false).catch(() => {}); + const shading = this._path === 'temporal' && this.settings.detectShadingChanges && this._passes.has('shadingChange'); + const debug = this._path === 'temporal' && this.settings.debugView !== DebugView.None && this._passes.has('debug'); + if ((shading && !this._shadingActive) || (debug && !this._debugActive)) { + // The input may already have rendered with this frame's jitter. + // Invalidate history without changing that projection's sample. + this._pendingReset = true; + this._shadingMemoryStale = true; + } + this._shadingActive = shading; + this._debugActive = debug; + this._effectiveSettings = { + ...this.settings, detectShadingChanges: shading, + debugView: debug ? this.settings.debugView : DebugView.None, + }; } /** @@ -347,7 +441,7 @@ export class Upscaler { * @param config - Display size, quality mode/ratio, and pipeline path */ configure(config: UpscalerConfig): void { - if (!this._initialized) this.init(); + this._ensureResources(); // A new pass graph: samples still in flight from the old one must not // land in gpuTimings after it. @@ -376,6 +470,7 @@ export class Upscaler { this._jitter.setRatio(this._ratio); this._allocateTextures(); this.resetHistory(); + void this._queuePreparation(false).catch(() => {}); } //* Accessors @@ -604,6 +699,7 @@ export class Upscaler { * @param camera - The scene camera (near/far and projection type) */ dispatch(inputs: DispatchInputs, camera: JitterableCamera): void { + this._startDispatch(); if (this._path === 'guides') { throw new Error( "@pmndrs/upscaler: the 'guides' path has no upscale — drive it with dispatchGuides().", @@ -670,6 +766,7 @@ export class Upscaler { * @param camera - The scene camera (near/far and projection type) */ dispatchGuides(inputs: GuideDispatchInputs, camera: JitterableCamera): void { + this._startDispatch(); if (this._path !== 'temporal' && this._path !== 'guides') { throw new Error( `@pmndrs/upscaler: dispatchGuides() requires the temporal or guides path (got '${this._path}').`, @@ -713,6 +810,7 @@ export class Upscaler { * @param camera - The camera passed to {@link dispatchGuides} */ dispatchUpscale(inputs: DispatchInputs, camera: JitterableCamera): void { + if (!this.isReady) throw new Error("@pmndrs/upscaler: await init() before dispatchUpscale()."); if (this._path !== 'temporal') { throw new Error( `@pmndrs/upscaler: dispatchUpscale() requires the temporal path (got '${this._path}').`, @@ -751,6 +849,12 @@ export class Upscaler { /** Releases all GPU resources. */ dispose(): void { + this._generation++; + this._passes.clear(); + this._requests.clear(); + this._failures.clear(); + this._shadingActive = false; + this._debugActive = false; this._destroyTextures(); this._constants?.dispose(); this._reprojectBuffer?.destroy(); @@ -803,7 +907,7 @@ export class Upscaler { //* RCAS — sharpen in the caller's color domain const exposureView = this._exposure![0].createView(); - if (this.settings.sharpness > 0) { + if (this._effectiveSettings.sharpness > 0) { this._encodeRcas( encoder, this._easuOutput!.createView(), @@ -1054,7 +1158,7 @@ export class Upscaler { //* Shading Change — fused multi-scale block-mean detector (skipped //* entirely when the detector is off; accumulate then reads a zero dummy). let shadingSignalView = this._reactiveDummy!.createView(); - if (!this.settings.detectShadingChanges) this._shadingMemoryStale = true; + if (!this._effectiveSettings.detectShadingChanges) this._shadingMemoryStale = true; else { const shadingMemoryIn = this._shadingBlockMemory![this._historyIndex]; if (this._shadingMemoryStale) { @@ -1126,7 +1230,7 @@ export class Upscaler { accumulatePass.end(); //* Output — debug view, RCAS sharpen, or plain resolve - if (this.settings.debugView !== DebugView.None) { + if (this._effectiveSettings.debugView !== DebugView.None) { const debugBindGroup = this._debugPass.createBindGroup([ { buffer: this._constants.buffer }, this._dilatedMotion!.createView(), @@ -1150,7 +1254,7 @@ export class Upscaler { this._displayHeight, ); debugPass.end(); - } else if (this.settings.sharpness > 0) { + } else if (this._effectiveSettings.sharpness > 0) { this._encodeRcas( encoder, historyOut.createView(), @@ -1243,12 +1347,12 @@ export class Upscaler { // NDC delta -> UV delta: u = 0.5 + ndc.x/2, v = 0.5 - ndc.y/2. c.setMotionScale(0.5, -0.5); c.setDepthNearFar(camera.near, camera.far); - c.setSharpness(Math.min(1, Math.max(0, this.settings.sharpness))); - c.setMaxAccumulation(Math.max(1, this.settings.maxAccumulation)); - c.setExposure(this.settings.exposure); + c.setSharpness(Math.min(1, Math.max(0, this._effectiveSettings.sharpness))); + c.setMaxAccumulation(Math.max(1, this._effectiveSettings.maxAccumulation)); + c.setExposure(this._effectiveSettings.exposure); c.setDeltaTime(inputs.deltaTime ?? 1 / 60); c.setFrameIndex(this._frameIndex); - c.setDebugMode(this.settings.debugView); + c.setDebugMode(this._effectiveSettings.debugView); // The input-space flag only matters to the final output pass (blit // or RCAS) — earlier passes ignore it, so it is staged once here. @@ -1259,12 +1363,12 @@ export class Upscaler { } if ((camera as PerspectiveCamera).isPerspectiveCamera) flags |= FLAG_PERSPECTIVE; if (this._path === 'temporal') flags |= FLAG_INPUT_REINHARD; - if (this.settings.lockThinFeatures) flags |= FLAG_LOCKS; - if (this.settings.autoExposure) flags |= FLAG_AUTO_EXPOSURE; - if (this.settings.detectShadingChanges) flags |= FLAG_SHADING_CHANGE; + if (this._effectiveSettings.lockThinFeatures) flags |= FLAG_LOCKS; + if (this._effectiveSettings.autoExposure) flags |= FLAG_AUTO_EXPOSURE; + if (this._effectiveSettings.detectShadingChanges) flags |= FLAG_SHADING_CHANGE; if (inputs.reactive || inputs.reactiveOpaqueColor) flags |= FLAG_REACTIVE; if (inputs.exposureTexture) flags |= FLAG_EXTERNAL_EXPOSURE; - if (this.settings.rcasDenoise) flags |= FLAG_RCAS_DENOISE; + if (this._effectiveSettings.rcasDenoise) flags |= FLAG_RCAS_DENOISE; c.setFlags(flags); } diff --git a/src/UpscalerNode.ts b/src/UpscalerNode.ts index 99c10b5..5d9ffc8 100644 --- a/src/UpscalerNode.ts +++ b/src/UpscalerNode.ts @@ -2,6 +2,7 @@ import { Vector2, type Texture } from 'three'; import { NodeUpdateType, TSL, TempNode, type UniformNode, type WebGPURenderer } from 'three/webgpu'; import { convertToTexture, + mix, mrt, nodeObject, output, @@ -202,6 +203,7 @@ export class UpscalerNode extends TempNode<'vec4'> { private _configured = false; private _textureNode: ReturnType | null = null; private _lastTime = 0; + private readonly _readyUniform = uniform(0); constructor( colorNode: TextureNodeLike, @@ -255,7 +257,7 @@ export class UpscalerNode extends TempNode<'vec4'> { this._upscaler.gpuTiming = this._options.gpuTiming; } else { this._upscaler = new Upscaler({ renderer, gpuTiming: this._options.gpuTiming }); - this._upscaler.init(); + } // When we jitter, motion vectors must stay jitter-free — feed the // velocity node the upscaler's (stable) unjittered projection. When @@ -306,7 +308,7 @@ export class UpscalerNode extends TempNode<'vec4'> { if (!this._textureNode) { this._textureNode = passTexture(this as never, this._upscaler.outputTexture); } - return this._textureNode; + return mix(this._color as never, this._textureNode as never, this._readyUniform); } private _installJitterHooks( @@ -367,7 +369,10 @@ export class UpscalerNode extends TempNode<'vec4'> { // identity on a re-setup, so these must not be re-created per call. private readonly _pipelineHooks = { before: (): void => { - if (this._frameOpen || !this._configured) return; + if (this._frameOpen || !this._configured || !this._upscaler?.isReady) { + this._jitterUniform.value.set(0, 0); + return; + } this._frameOpen = true; this._upscaler!.beginFrame(this._camera as never); const jitter = this._upscaler!.jitter; @@ -408,7 +413,8 @@ export class UpscalerNode extends TempNode<'vec4'> { // eslint-disable-next-line @typescript-eslint/no-explicit-any updateBefore(frame: any): any { const renderer = frame.renderer as WebGPURenderer; - if (!this._upscaler) return; + this._readyUniform.value = this._upscaler?.isReady ? 1 : 0; + if (!this._upscaler?.isReady) return; const color = this._texture(this._color); if (!color) return; diff --git a/src/internal/ComputePass.test.ts b/src/internal/ComputePass.test.ts new file mode 100644 index 0000000..090689f --- /dev/null +++ b/src/internal/ComputePass.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ComputePass } from './ComputePass'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + return { promise, resolve, reject }; +} + +function mockDevice() { + const lost = deferred(); + const jobs: ReturnType>[] = []; + const device = { + lost: lost.promise, + createShaderModule: vi.fn(() => ({})), + createComputePipelineAsync: vi.fn(() => { + const job = deferred(); + jobs.push(job); + return job.promise; + }), + createBindGroup: vi.fn(value => value), + }; + return { device: device as unknown as GPUDevice, spy: device, jobs, lost }; +} +const pipeline = () => ({ getBindGroupLayout: vi.fn(() => ({})) }) as unknown as GPUComputePipeline; + +describe('async compute pipeline cache', () => { + it('shares in-flight pipelines by source and sorted constants, retaining per-pass metadata', async () => { + const { device, spy, jobs } = mockDevice(); + const a = ComputePass.create(device, 'a', 'source', { constants: { B: 2, A: 1 }, shaderKey: 'a' }); + const b = ComputePass.create(device, 'b', 'source', { constants: { A: 1, B: 2 }, shaderKey: 'b' }); + expect(spy.createComputePipelineAsync).toHaveBeenCalledTimes(1); + jobs[0].resolve(pipeline()); + const [first, second] = await Promise.all([a, b]); + expect(first.pipeline).toBe(second.pipeline); + expect(first.metadata.shaderKey).toBe('a'); + expect(second.metadata.shaderKey).toBe('b'); + first.createBindGroup([]); + expect(spy.createBindGroup).toHaveBeenCalledWith(expect.objectContaining({ + layout: first.pipeline.getBindGroupLayout(0), + })); + }); + + it('separates sources, specialization constants and devices, sharing modules by source', async () => { + const one = mockDevice(), two = mockDevice(); + const requests = [ + ComputePass.create(one.device, 'same', 'one', { constants: { A: 1 } }), + ComputePass.create(one.device, 'same', 'one', { constants: { A: 2 } }), + ComputePass.create(one.device, 'same', 'two'), + ComputePass.create(two.device, 'same', 'one', { constants: { A: 1 } }), + ]; + expect(one.spy.createShaderModule).toHaveBeenCalledTimes(2); + expect(two.spy.createShaderModule).toHaveBeenCalledTimes(1); + [...one.jobs, ...two.jobs].forEach(job => job.resolve(pipeline())); + const passes = await Promise.all(requests); + expect(new Set(passes.map(pass => pass.pipeline)).size).toBe(4); + }); + + it('keeps signed-zero specialization values distinct', async () => { + const { device, spy, jobs } = mockDevice(); + const positive = ComputePass.create(device, 'a', 'source', { constants: { A: 0 } }); + const negative = ComputePass.create(device, 'a', 'source', { constants: { A: -0 } }); + expect(spy.createComputePipelineAsync).toHaveBeenCalledTimes(2); + jobs.forEach(job => job.resolve(pipeline())); + const [a, b] = await Promise.all([positive, negative]); + expect(a.pipeline).not.toBe(b.pipeline); + expect(Object.is(b.metadata.constants.A, -0)).toBe(true); + }); + + it('snapshots metadata before awaiting compilation', async () => { + const { device, jobs } = mockDevice(); + const options = { constants: { A: 1 }, assembledChunks: ['original'] }; + const request = ComputePass.create(device, 'a', 'source', options); + options.constants.A = 2; + options.assembledChunks.push('changed'); + jobs[0].resolve(pipeline()); + const pass = await request; + expect(pass.metadata.constants).toEqual({ A: 1 }); + expect(pass.metadata.assembledChunks).toEqual(['original']); + }); + + it('limits compilation to four requests across callers', async () => { + const { device, spy, jobs } = mockDevice(); + const requests = Array.from({ length: 9 }, (_, i) => ComputePass.create(device, String(i), String(i))); + expect(spy.createComputePipelineAsync).toHaveBeenCalledTimes(4); + for (let i = 0; i < 9; i++) { + jobs[i].resolve(pipeline()); + await requests[i]; + } + expect(spy.createComputePipelineAsync).toHaveBeenCalledTimes(9); + }); + + it('evicts failures so an explicit retry can compile again', async () => { + const { device, spy, jobs } = mockDevice(); + const first = ComputePass.create(device, 'a', 'source'); + jobs[0].reject(new Error('bad shader')); + await expect(first).rejects.toThrow('bad shader'); + const second = ComputePass.create(device, 'a', 'source'); + jobs[1].resolve(pipeline()); + await second; + expect(spy.createComputePipelineAsync).toHaveBeenCalledTimes(2); + }); + + it('rejects queued and active work on loss and never reuses a lost device', async () => { + const { device, jobs, lost } = mockDevice(); + const requests = Array.from({ length: 6 }, (_, i) => ComputePass.create(device, String(i), String(i))); + const results = Promise.allSettled(requests); + lost.resolve({} as GPUDeviceLostInfo); + await Promise.resolve(); + jobs.forEach(job => job.resolve(pipeline())); + expect((await results).every(result => result.status === 'rejected')).toBe(true); + await expect(ComputePass.create(device, 'new', 'new')).rejects.toThrow('device lost'); + }); +}); diff --git a/src/internal/ComputePass.ts b/src/internal/ComputePass.ts index 17c591b..4cd9192 100644 --- a/src/internal/ComputePass.ts +++ b/src/internal/ComputePass.ts @@ -22,6 +22,77 @@ export interface ComputePassMetadata { assembledChunks: readonly string[]; } +interface DeviceCache { + modules: Map; + pipelines: Map>; + queue: Array<() => void>; + active: number; + lost: boolean; +} + +const caches = new WeakMap(); +const COMPILE_CONCURRENCY = 4; + +function deviceCache(device: GPUDevice): DeviceCache { + let cache = caches.get(device); + if (cache) return cache; + cache = { modules: new Map(), pipelines: new Map(), queue: [], active: 0, lost: false }; + caches.set(device, cache); + const current = cache; + void device.lost.then(() => { + current.lost = true; + current.modules.clear(); + current.pipelines.clear(); + // Drain queued tasks so their promises reject rather than hang forever. + for (const task of current.queue.splice(0)) task(); + }); + return cache; +} + +function compile(device: GPUDevice, label: string, code: string, options: ComputePassOptions): Promise { + const cache = deviceCache(device); + if (cache.lost) return Promise.reject(new Error('@pmndrs/upscaler: GPU device lost.')); + const constants = Object.fromEntries(Object.entries(options.constants ?? {}).sort(([a], [b]) => a.localeCompare(b))); + const key = JSON.stringify([code, 'main', 'auto', Object.entries(constants).map( + ([name, value]) => [name, typeof value, Object.is(value, -0) ? '-0' : String(value)], + )]); + const existing = cache.pipelines.get(key); + if (existing) return existing; + const pipeline = new Promise((resolve, reject) => { + const start = (): void => { + if (cache.lost) { reject(new Error('@pmndrs/upscaler: GPU device lost.')); return; } + cache.active++; + void (async () => { + try { + let module = cache.modules.get(code); + if (!module) { + module = device.createShaderModule({ label: `upscale-${label}`, code }); + cache.modules.set(code, module); + } + const result = await device.createComputePipelineAsync({ + label: `upscale-${label}`, layout: 'auto', + compute: { module, entryPoint: 'main', constants }, + }); + if (cache.lost) throw new Error('@pmndrs/upscaler: GPU device lost.'); + resolve(result); + } catch (error) { reject(error); } + finally { + cache.active--; + if (!cache.lost) cache.queue.shift()?.(); + } + })(); + }; + if (cache.active < COMPILE_CONCURRENCY) start(); + else cache.queue.push(start); + }); + cache.pipelines.set(key, pipeline); + void pipeline.catch(() => { + if (cache.pipelines.get(key) === pipeline) cache.pipelines.delete(key); + cache.modules.delete(code); + }); + return pipeline; +} + export class ComputePass { static readonly WORKGROUP_SIZE = 8; @@ -31,7 +102,15 @@ export class ComputePass { private readonly _device: GPUDevice; - constructor(device: GPUDevice, label: string, code: string, options: ComputePassOptions = {}) { + static async create(device: GPUDevice, label: string, code: string, options: ComputePassOptions = {}): Promise { + const snapshot = { + ...options, constants: { ...options.constants }, + assembledChunks: [...(options.assembledChunks ?? [])], + }; + return new ComputePass(device, label, await compile(device, label, code, snapshot), snapshot); + } + + private constructor(device: GPUDevice, label: string, pipeline: GPUComputePipeline, options: ComputePassOptions = {}) { this._device = device; this.label = label; this.metadata = Object.freeze({ @@ -39,18 +118,7 @@ export class ComputePass { constants: Object.freeze({ ...options.constants }), assembledChunks: Object.freeze([...(options.assembledChunks ?? [])]), }); - const module = device.createShaderModule({ label: `upscale-${label}`, code }); - // 'auto' layout keeps the TS side free of duplicated binding tables — - // the WGSL source is the single source of truth for bindings. - this.pipeline = device.createComputePipeline({ - label: `upscale-${label}`, - layout: 'auto', - compute: { - module, - entryPoint: 'main', - constants: options.constants, - }, - }); + this.pipeline = pipeline; } /** diff --git a/src/shaders/shaders.test.ts b/src/shaders/shaders.test.ts index 75f8eef..5d27333 100644 --- a/src/shaders/shaders.test.ts +++ b/src/shaders/shaders.test.ts @@ -142,6 +142,13 @@ describe('assembleShader', () => { expect(out.match(/fn shared/g)).toHaveLength(1); }); + it('assembles deterministically across repeated calls', () => { + for (const source of [BLIT_SHADER, EASU_SHADER, RCAS_SHADER, ACCUMULATE_SHADER, + RECONSTRUCT_SHADER, DEPTH_CLIP_SHADER, DEBUG_SHADER, LUMINANCE_PYRAMID_SHADER]) { + expect(assembleShader(source)).toBe(assembleShader(source)); + } + }); + it('drops empty parts', () => { expect(assembleShader('', 'fn a() {}', ' ')).toBe('fn a() {}\n'); }); @@ -579,17 +586,18 @@ describe('E00 benchmark foundation', () => { expect(summary.computeSum.samples).toEqual([2]); }); - it('threads optional pipeline constants and metadata', () => { + it('threads optional pipeline constants and metadata', async () => { let descriptor: GPUComputePipelineDescriptor | null = null; const pipeline = { getBindGroupLayout: () => ({}) } as unknown as GPUComputePipeline; const device = { createShaderModule: () => ({}), - createComputePipeline: (value: GPUComputePipelineDescriptor) => { + lost: new Promise(() => {}), + createComputePipelineAsync: (value: GPUComputePipelineDescriptor) => { descriptor = value; return pipeline; }, } as unknown as GPUDevice; - const pass = new ComputePass(device, 'test', '@compute fn main() {}', { + const pass = await ComputePass.create(device, 'test', '@compute fn main() {}', { constants: { SAMPLE_COUNT: 4 }, shaderKey: 'test-key', assembledChunks: ['common', 'body'],