diff --git a/docs/api-guide/engine/anari-rendering.md b/docs/api-guide/engine/anari-rendering.md
index 3789f7ddf9..5a31ee8513 100644
--- a/docs/api-guide/engine/anari-rendering.md
+++ b/docs/api-guide/engine/anari-rendering.md
@@ -217,7 +217,13 @@ const renderer = anariDevice.newRenderer('raytrace', {
samplesPerPixel: 1,
maxBounces: 1,
progressive: true,
- shadows: true
+ shadows: true,
+ resolutionScale: 0.5,
+ minimumResolutionScale: 0.25,
+ adaptiveResolution: true,
+ targetFrameTimeMilliseconds: 33.3,
+ temporalReprojection: true,
+ shadowSamplesPerFrame: 1
});
frame.setParameter('renderer', renderer).commitParameters();
@@ -231,6 +237,17 @@ evaluates direct lights, progressively accumulates unchanged primary-ray samples
when the canvas is configured for it. Generated quads, cylinders, and cones use their existing
triangle geometry.
+Ray tracing starts at half the display width and height, reducing its initial pixel workload to one
+quarter of full resolution. Adaptive quality can lower that scale to `0.25`, interleave sampled
+pixels across animation frames, and rotate one shadowed direct light per frame to approach the
+default `33.3` millisecond frame budget. The fullscreen resolve upsamples the retained HDR image.
+Temporal reprojection follows camera and stable instance motion while rejecting incompatible depth,
+normal, and color history; camera cuts, topology changes, light-count changes, and resolution
+changes reset invalid history. Set `shadowSamplesPerFrame: 0` to evaluate every direct light in
+one frame. Adaptive timing uses smoothed animation-frame intervals and does not require GPU timestamp
+queries. The acceleration graph runs only when retained transforms or geometry change, so camera-only
+and lighting-only frames do not rebuild the BVH.
+
The BVH indexes objects and instances: triangles within a surviving mesh are still tested linearly.
Its deterministic source-order topology is not Morton-sorted, and no separate per-mesh triangle BVH
is built. The ray-tracing pass uses five storage buffers; the BVH builder uses eight, fitting the
@@ -667,12 +684,17 @@ console.log({
distinctSurfaces: statistics.surfaceCount,
visiblePlacements: statistics.instanceCount,
drawCalls: statistics.drawCount,
- renderedTriangles: statistics.triangleCount
+ renderedTriangles: statistics.triangleCount,
+ rayTracing: statistics.rayTracing
});
```
Use these numbers to verify batching behavior. If `instanceCount` is high but `drawCount` is similarly high, check whether each placement accidentally creates its own surface instead of reusing a retained surface.
+Ray-traced frames additionally report their internal resolution and effective scale, sampled-pixel
+coverage, smoothed frame time, and accumulated sample count. Other renderer subtypes omit
+`statistics.rayTracing`.
+
The renderer also supports capability discovery:
```ts
@@ -732,8 +754,9 @@ for the runtime contract and ownership details.
| T0: renderer and graph foundation | Lazy subtype registration, retained-scene adapters, the shared experimental `RayTracingSceneRenderer`, explicit WebGPU command-graph resources, and application-owned submission. | Implemented. |
| T1: direct rays and shadows | Transformed analytic spheres, mesh triangles, tessellated analytic shapes, perspective/orthographic cameras, direct lights, hard shadow rays, progressive primary-ray sampling, and HDR presentation. | Implemented with WebGPU compute rather than hardware ray tracing. |
| T2a: GPU object acceleration | World-space instance bounds, graph-owned complete-binary `GPUBVH` construction and refitting, nearest-hit object traversal, early-exit shadow rays, and default-CORE storage limits. | Implemented; surviving mesh triangles remain linear. |
-| T2b: large-scene acceleration | Measured Morton/radix spatial ordering, shared per-mesh triangle BVHs, dirty-only hierarchy updates, and explicit traversal/build diagnostics. | Planned. |
-| T2c: dynamic scene extraction | Skeletal/morph geometry extraction, bounded deforming-mesh updates, and shared animated instance acceleration. | Planned. |
+| T2b: interactive frame budgeting | Half-resolution defaults, bounded adaptive quality, interleaved pixel coverage, retained-identity temporal reprojection, rotating shadow samples, and dirty-only object acceleration. | Implemented; frame pacing uses CPU animation intervals. |
+| T2c: large-scene acceleration | Measured Morton/radix spatial ordering, shared per-mesh triangle BVHs, and explicit traversal/build diagnostics. | Planned. |
+| T2d: dynamic scene extraction | Skeletal/morph geometry extraction, bounded deforming-mesh updates, and shared animated instance acceleration. | Planned. |
| T3: indirect transport and denoising | Advanced PBR texture, alpha, and transmission parity; multi-bounce material transport/path tracing, convergence controls, and denoising; primary-ray progressive accumulation already exists in T1. | Planned. |
| T4: ray marching and volumes | Signed-distance-field ray marching, retained spatial fields, 3D textures, transfer functions, and ANARI volume objects. | Planned. |
| T5: hybrid composition and diagnostics | Raster/ray composition, reusable graph timings, renderer capability reporting, and debug visualization channels. | Planned. |
@@ -928,7 +951,9 @@ Object subtypes match the private package: `triangle`, `sphere`, `cylinder`, `co
geometry; `matte` and `physicallyBased` materials; `ambient`, `directional`, `point`, and `spot`
lights; `perspective` and `orthographic` cameras; and optional renderer presets for `default`,
`deferred`, `raytrace`, `debugNormals`, and `debugDepth`. Ray-tracing presets additionally accept
-`samplesPerPixel`, `maxBounces`, `progressive`, and `shadows`.
+`samplesPerPixel`, `maxBounces`, `progressive`, `shadows`, `resolutionScale`,
+`minimumResolutionScale`, `adaptiveResolution`, `targetFrameTimeMilliseconds`,
+`temporalReprojection`, and `shadowSamplesPerFrame`.
### Generate compact triangle meshes and starfields
diff --git a/docs/api-reference/anari/anari-rendering.md b/docs/api-reference/anari/anari-rendering.md
index 53a7b3e7a3..13a7f00682 100644
--- a/docs/api-reference/anari/anari-rendering.md
+++ b/docs/api-reference/anari/anari-rendering.md
@@ -103,6 +103,12 @@ type ANARIRendererParameters = {
maxBounces?: number;
progressive?: boolean;
shadows?: boolean;
+ resolutionScale?: number;
+ minimumResolutionScale?: number;
+ adaptiveResolution?: boolean;
+ targetFrameTimeMilliseconds?: number;
+ temporalReprojection?: boolean;
+ shadowSamplesPerFrame?: number;
bloomIntensity?: number;
bloomThreshold?: number;
bloomRadius?: number;
@@ -120,6 +126,12 @@ type ANARIRendererParameters = {
| `maxBounces` | Not applied | Reserved ray-tracing bounce limit; the current implementation evaluates direct lighting only. |
| `progressive` | `true` | Accumulate ray-traced samples across unchanged frames. |
| `shadows` | `true` | Trace hard shadow rays toward direct lights in the `raytrace` renderer. |
+| `resolutionScale` | `0.5` | Initial ray-tracing width and height as a fraction of the display resolution. |
+| `minimumResolutionScale` | `0.25` | Lowest internal resolution scale available to adaptive ray tracing. |
+| `adaptiveResolution` | `true` | Adjust internal resolution and sampled-pixel coverage toward the target frame budget. |
+| `targetFrameTimeMilliseconds` | `33.3` | Target animation-frame interval used by adaptive ray-tracing quality. |
+| `temporalReprojection` | `true` | Reuse compatible retained history while the camera or stable scene instances move. |
+| `shadowSamplesPerFrame` | `1` | Maximum rotating direct-light shadow samples evaluated per pixel in one frame; `0` evaluates all direct lights. |
| `bloomIntensity` | `0` | Bloom amount; positive values allocate and run the bloom postprocessing path. |
| `bloomThreshold` | `0.62` | Brightness threshold for bloom extraction. |
| `bloomRadius` | `7` | Bloom blur radius. |
@@ -171,7 +183,13 @@ const renderer = anariDevice.newRenderer('raytrace', {
samplesPerPixel: 1,
maxBounces: 1,
progressive: true,
- shadows: true
+ shadows: true,
+ resolutionScale: 0.5,
+ minimumResolutionScale: 0.25,
+ adaptiveResolution: true,
+ targetFrameTimeMilliseconds: 33.3,
+ temporalReprojection: true,
+ shadowSamplesPerFrame: 1
});
frame.setParameter('renderer', renderer).commitParameters();
@@ -186,14 +204,24 @@ point, and spot lights; and presents the result through a fullscreen pass. An `r
preserves HDR radiance. The trace pass uses five storage buffers and the BVH builder uses eight,
remaining within default WebGPU CORE limits.
-When `progressive` is enabled, unchanged frames accumulate additional primary-ray samples. Camera,
-scene, light, material, renderer, and frame-size changes reset the accumulation history. The
-source-order BVH accelerates object and instance selection; triangles within an intersected mesh
-are still tested linearly. Hardware ray tracing, Morton-sorted hierarchy construction, and per-mesh
-triangle BVHs are not implemented. Skeletal skinning, morph-target displacement, material textures,
-alpha/transmission, and advanced PBR shading remain on the forward/deferred renderer paths. Indirect
-multi-bounce path tracing, denoising, and volumes are also unsupported. `maxBounces` is accepted for
-forward compatibility but does not enable indirect bounces.
+The default half-resolution internal target traces one quarter as many pixels as the output canvas.
+When adaptive quality is enabled, the renderer can reduce scale to `0.25`, spread interleaved pixel
+coverage across frames, and rotate one shadowed direct light per frame. It approaches the configured
+frame budget using smoothed animation-frame intervals; no GPU timestamp feature is required. The
+fullscreen presentation pass upsamples the internal HDR result.
+
+Progressive history is reprojected through previous camera matrices and stable ANARI instance/group/
+surface identities. Depth and normal validation plus bounded neighborhood color clamping reject
+incompatible history; camera cuts, changed topology/materials, changed light counts, and target
+resizing invalidate it. GPU acceleration updates are encoded only for changed geometry or transforms,
+while camera-only and lighting-only frames reuse the retained BVH.
+
+The source-order BVH accelerates object and instance selection; triangles within an intersected
+mesh are still tested linearly. Hardware ray tracing, Morton-sorted hierarchy construction, and
+per-mesh triangle BVHs are not implemented. Skeletal skinning, morph-target displacement, material
+textures, alpha/transmission, and advanced PBR shading remain on the forward/deferred renderer
+paths. Indirect multi-bounce path tracing, denoising, and volumes are also unsupported.
+`maxBounces` is accepted for forward compatibility but does not enable indirect bounces.
Applications can also
[register custom renderer runtimes](/docs/api-reference/anari/anari-device#registering-renderer-runtimes).
@@ -281,6 +309,14 @@ type ANARIFrameStatistics = {
instanceCount: number;
drawCount: number;
triangleCount: number;
+ rayTracing?: {
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ sampledPixelCoverage: number;
+ frameTimeMilliseconds: number;
+ accumulatedSamples: number;
+ };
};
```
@@ -290,6 +326,7 @@ type ANARIFrameStatistics = {
| `instanceCount` | Number of direct and instanced surface placements. |
| `drawCount` | Number of successful model draws, normally one per distinct raster surface or one ray-tracing presentation draw. |
| `triangleCount` | Sum of mesh triangles across all placements; analytic ray-traced spheres contribute zero. |
+| `rayTracing` | Optional internal resolution, effective scale, sampled-pixel coverage, smoothed frame time, and accumulated samples; present only for the `raytrace` renderer. |
`frame.statistics` is initialized with zeroes and updated by each `frame.render()` call.
diff --git a/docs/api-reference/experimental/README.md b/docs/api-reference/experimental/README.md
index b13c6e3fac..7a74516cc6 100644
--- a/docs/api-reference/experimental/README.md
+++ b/docs/api-reference/experimental/README.md
@@ -41,10 +41,15 @@ forward renderer.
passes derive world-space instance bounds, build and refit the existing
[`GPUBVH`](/docs/api-reference/experimental/gpu-primitives/gpu-bvh), and traverse its complete
binary hierarchy for nearest-hit rays and early-exit shadows. `RayTracingSceneRenderOptions` add
-analytic sphere metadata, perspective/orthographic camera selection, progressive primary-ray
-accumulation, and HDR presentation. The ray pass uses five storage buffers and the existing BVH
-builder uses eight, fitting default WebGPU CORE limits. Applications retain command-submission
-ownership.
+analytic sphere metadata, perspective/orthographic camera selection, adaptive half-resolution
+rendering, interleaved pixel phases, retained-identity temporal reprojection, bounded rotating
+shadow samples, progressive accumulation, and upsampled HDR presentation. The default `0.5`
+resolution scale can decrease to `0.25` toward a `33.3` millisecond smoothed animation-frame
+budget; GPU timestamp queries are not required. Acceleration passes run only when geometry or
+instance transforms change. Shared scene statistics optionally expose internal dimensions,
+effective scale, sampled-pixel coverage, frame timing, and accumulated samples. The ray pass uses
+five storage buffers and the existing BVH builder uses eight, fitting default WebGPU CORE limits.
+Applications retain command-submission ownership.
The source-order BVH accelerates objects and instances, not individual mesh triangles. Hardware ray
tracing, spatial sorting, per-mesh BVHs, indirect path tracing, denoising, and volume rendering are
diff --git a/docs/whats-new.md b/docs/whats-new.md
index 921a23680c..26bdef6cd1 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -43,7 +43,7 @@ Target Release Date: Q3, 2026
**@luma.gl/anari (Experimental)**
- **[Retained physically based rendering](/docs/api-guide/engine/anari-rendering)** - The private ANARI-inspired workspace maps committed handles, staged parameters, instances, cameras, lights, and 17 material texture slots onto shared forward and WebGPU deferred scene renderers, including automatic opaque-scene capture for transmissive materials.
-- **Pluggable GPU-compute ray tracing** - `ANARIDevice.registerRenderer()` registers lazy custom runtimes, while the WebGPU-only `raytrace` subtype adapts committed scenes to the shared experimental `RayTracingSceneRenderer` for GPU-built object/instance BVHs, accelerated analytic sphere/mesh selection, early-exit direct-light shadows, progressive sampling, and HDR presentation within default WebGPU CORE limits.
+- **Pluggable interactive GPU-compute ray tracing** - `ANARIDevice.registerRenderer()` registers lazy custom runtimes, while the WebGPU-only `raytrace` subtype adapts committed scenes to the shared experimental `RayTracingSceneRenderer` for GPU-built object/instance BVHs, adaptive half-resolution rendering, interleaved pixel phases, stable-instance temporal reprojection, bounded rotating shadows, progressive sampling, and upsampled HDR presentation within default WebGPU CORE limits.
- **[Optional glTF animation integration](/docs/api-reference/anari/anari-animation)** - The isolated `@luma.gl/anari/gltf` entry point binds imported node hierarchies, material and sampler pointers, and morph-weight tracks to retained objects while committing each changed object at most once per frame.
- **Source-faithful retained assets** - JSON scenes preserve indexed geometry, both UV sets, tangents, RGBA vertex colors, joint attributes, morph targets, authored samplers, punctual lights, and `OPAQUE`/`MASK`/`BLEND` modes; programmatic renderer parameters can additionally supply caller-owned image-based-lighting textures.
@@ -51,7 +51,7 @@ Target Release Date: Q3, 2026
- **[Shared physical scene rendering](/docs/api-reference/experimental/scene-renderer)** - `SceneRenderer` renders format-independent physically based surfaces on WebGL and WebGPU with reusable instanced geometry, staged material updates, explicit joint palettes, morph deformation, punctual lights, and caller-provided image-based-lighting textures.
- **[Deferred physical scene rendering](/docs/api-reference/experimental/deferred-scene-renderer)** - `DeferredSceneRenderer` reuses the same scene descriptors through a four-target HDR G-buffer and lighting resolve that fits the default 32-byte WebGPU CORE limit, automatically falling back to the shared forward renderer for unsupported scenes.
-- **Shared GPU-accelerated software ray tracing** - `RayTracingSceneRenderer` composes world-space instance bounds, existing `GPUBVH` construction/refitting, nearest-hit traversal, early-exit shadow rays, progressive accumulation, and HDR presentation through one WebGPU compute/command graph. The tracing pass uses five storage buffers, the BVH builder uses eight, mesh triangles remain linearly refined, and command submission stays application-owned.
+- **Shared interactive GPU-accelerated ray tracing** - `RayTracingSceneRenderer` composes world-space instance bounds, dirty-only `GPUBVH` construction/refitting, nearest-hit traversal, bounded direct-light shadows, adaptive internal resolution, interleaved frame-budget coverage, stable-identity temporal reprojection, progressive accumulation, and upsampled HDR presentation through WebGPU compute/command graphs. Frame pacing uses ordinary animation intervals, the tracing pass uses five storage buffers, the BVH builder uses eight, mesh triangles remain linearly refined, and command submission stays application-owned.
- **[Generated physical lighting environments](/docs/api-reference/experimental/pbr-environment)** - `PBREnvironmentGenerator` and `preparePBREnvironment()` integrate equirectangular source textures into GGX-prefiltered specular cubemap mip chains, diffuse irradiance cubemaps, and split-sum BRDF lookup textures on both WebGL and WebGPU.
- **Scene-color transmission and volume attenuation** - The shared forward renderer captures opaque scene color automatically for transmissive surfaces, then applies screen-space refraction, roughness, Fresnel response, index of refraction, thickness, and Beer-Lambert attenuation while preserving physically opaque output.
- **`HTMLTexture`** - Experimental copied texture binding source copies HTML-in-Canvas DOM subtrees into GPU textures while the browser API is still experimental.
diff --git a/examples/showcase/anari/app.ts b/examples/showcase/anari/app.ts
index 6332e4495d..0a22fd31f1 100644
--- a/examples/showcase/anari/app.ts
+++ b/examples/showcase/anari/app.ts
@@ -56,6 +56,16 @@ const DEFAULT_RENDERER_PARAMETERS: ANARIRendererParameters = {
fogColor: [0.018, 0.025, 0.065],
fogDensity: 0.00024
};
+const DEFAULT_RAY_TRACING_PARAMETERS: ANARIRendererParameters = {
+ resolutionScale: 0.5,
+ minimumResolutionScale: 0.25,
+ adaptiveResolution: true,
+ targetFrameTimeMilliseconds: 33.3,
+ temporalReprojection: true,
+ shadowSamplesPerFrame: 1,
+ progressive: true,
+ shadows: true
+};
export default class ANARIShowcase extends AnimationLoopTemplate {
static info = '';
@@ -94,7 +104,7 @@ export default class ANARIShowcase extends AnimationLoopTemplate {
raytrace: this.anari.newRenderer('raytrace', {
...DEFAULT_RENDERER_PARAMETERS,
bloomIntensity: 0,
- shadows: true
+ ...DEFAULT_RAY_TRACING_PARAMETERS
}),
debugNormals: this.anari.newRenderer('debugNormals', {
background: [0.027, 0.033, 0.06, 1]
@@ -167,6 +177,25 @@ export default class ANARIShowcase extends AnimationLoopTemplate {
if (elapsedSeconds - this.lastStatisticsUpdate > 0.3) {
setElementText('instance-count', statistics.instanceCount.toLocaleString());
setElementText('draw-count', statistics.drawCount.toLocaleString());
+ const rayTracing = statistics.rayTracing;
+ const resolutionTelemetry = document.getElementById('ray-tracing-resolution-telemetry');
+ const frameTelemetry = document.getElementById('ray-tracing-frame-telemetry');
+ if (resolutionTelemetry) {
+ resolutionTelemetry.hidden = !rayTracing;
+ }
+ if (frameTelemetry) {
+ frameTelemetry.hidden = !rayTracing;
+ }
+ if (rayTracing) {
+ setElementText(
+ 'ray-tracing-resolution',
+ `${rayTracing.internalWidth} × ${rayTracing.internalHeight} · ${Math.round(rayTracing.resolutionScale * 100)}%`
+ );
+ setElementText(
+ 'ray-tracing-frame',
+ `${rayTracing.frameTimeMilliseconds.toFixed(1)} ms · ${Math.round(rayTracing.sampledPixelCoverage * 100)}% · ${rayTracing.accumulatedSamples} spp`
+ );
+ }
this.lastStatisticsUpdate = elapsedSeconds;
}
}
@@ -291,7 +320,7 @@ export default class ANARIShowcase extends AnimationLoopTemplate {
...DEFAULT_RENDERER_PARAMETERS,
...rendererParameters,
bloomIntensity: 0,
- shadows: true
+ ...DEFAULT_RAY_TRACING_PARAMETERS
})
.commitParameters();
this.frame.setParameter('world', scene.world).commitParameters();
diff --git a/examples/showcase/anari/index.html b/examples/showcase/anari/index.html
index 6c5ded6c29..5e7fddc5b2 100644
--- a/examples/showcase/anari/index.html
+++ b/examples/showcase/anari/index.html
@@ -386,6 +386,14 @@
Draw calls
—
+
+
Backend
—
diff --git a/examples/showcase/anari/playground-scene.ts b/examples/showcase/anari/playground-scene.ts
index 57b86802da..04f5087178 100644
--- a/examples/showcase/anari/playground-scene.ts
+++ b/examples/showcase/anari/playground-scene.ts
@@ -240,6 +240,17 @@ const DEFAULT_RENDERER_DECLARATION: JSONRendererDeclaration = {
fogDensity: 0.00024
};
+const DEFAULT_RAY_TRACING_PARAMETERS: ANARIRendererParameters = {
+ resolutionScale: 0.5,
+ minimumResolutionScale: 0.25,
+ adaptiveResolution: true,
+ targetFrameTimeMilliseconds: 33.3,
+ temporalReprojection: true,
+ shadowSamplesPerFrame: 1,
+ progressive: true,
+ shadows: true
+};
+
const MATERIAL_TEXTURE_NAMES: readonly JSONMaterialTextureName[] = [
'baseColorTexture',
'normalTexture',
@@ -545,7 +556,10 @@ export function createANARIJSONScene(
scene.renderer || DEFAULT_RENDERER_DECLARATION;
const rendererSubtype = options.rendererSubtype || sceneRendererSubtype;
assertSubtype('renderer', rendererSubtype, RENDERER_SUBTYPES);
- const renderer = device.newRenderer(rendererSubtype, rendererParameters);
+ const renderer = device.newRenderer(rendererSubtype, {
+ ...(rendererSubtype === 'raytrace' ? DEFAULT_RAY_TRACING_PARAMETERS : {}),
+ ...rendererParameters
+ });
const frame = device.newFrame({world, camera, renderer});
const animations =
scene.clips?.length || skins.size > 0
diff --git a/examples/showcase/anari/playground.html b/examples/showcase/anari/playground.html
index 776e2a4a42..7fa2cb8605 100644
--- a/examples/showcase/anari/playground.html
+++ b/examples/showcase/anari/playground.html
@@ -600,6 +600,14 @@
TRIANGLES
—
+
+
DRAG TO ORBIT · SCROLL TO ZOOM · ⌘ ENTER TO APPLY
diff --git a/examples/showcase/anari/playground.ts b/examples/showcase/anari/playground.ts
index c80437f053..2a9b2cb870 100644
--- a/examples/showcase/anari/playground.ts
+++ b/examples/showcase/anari/playground.ts
@@ -85,6 +85,25 @@ export default class ANARIPlayground extends AnimationLoopTemplate {
setElementText('scene-instance-count', statistics.instanceCount.toLocaleString());
setElementText('scene-draw-count', statistics.drawCount.toLocaleString());
setElementText('scene-triangle-count', statistics.triangleCount.toLocaleString());
+ const rayTracing = statistics.rayTracing;
+ const resolutionTelemetry = document.getElementById('scene-ray-tracing-resolution-statistic');
+ const frameTelemetry = document.getElementById('scene-ray-tracing-frame-statistic');
+ if (resolutionTelemetry) {
+ resolutionTelemetry.hidden = !rayTracing;
+ }
+ if (frameTelemetry) {
+ frameTelemetry.hidden = !rayTracing;
+ }
+ if (rayTracing) {
+ setElementText(
+ 'scene-ray-tracing-resolution',
+ `${rayTracing.internalWidth} × ${rayTracing.internalHeight} · ${Math.round(rayTracing.resolutionScale * 100)}%`
+ );
+ setElementText(
+ 'scene-ray-tracing-frame',
+ `${rayTracing.frameTimeMilliseconds.toFixed(1)} ms · ${Math.round(rayTracing.sampledPixelCoverage * 100)}% · ${rayTracing.accumulatedSamples} spp`
+ );
+ }
this.lastStatisticsUpdate = elapsedSeconds;
}
}
diff --git a/modules/anari/src/anari-ray-tracing-runtime.ts b/modules/anari/src/anari-ray-tracing-runtime.ts
index 03b7862bbe..6a14ee4bad 100644
--- a/modules/anari/src/anari-ray-tracing-runtime.ts
+++ b/modules/anari/src/anari-ray-tracing-runtime.ts
@@ -41,7 +41,13 @@ export class ANARIRayTracingRuntime implements ANARIRendererRuntime {
samplesPerPixel: renderer.getParameter('samplesPerPixel'),
maxBounces: renderer.getParameter('maxBounces'),
progressive: renderer.getParameter('progressive'),
- shadows: renderer.getParameter('shadows')
+ shadows: renderer.getParameter('shadows'),
+ resolutionScale: renderer.getParameter('resolutionScale'),
+ minimumResolutionScale: renderer.getParameter('minimumResolutionScale'),
+ adaptiveResolution: renderer.getParameter('adaptiveResolution'),
+ targetFrameTimeMilliseconds: renderer.getParameter('targetFrameTimeMilliseconds'),
+ temporalReprojection: renderer.getParameter('temporalReprojection'),
+ shadowSamplesPerFrame: renderer.getParameter('shadowSamplesPerFrame')
});
}
diff --git a/modules/anari/src/anari-scene-adapter.ts b/modules/anari/src/anari-scene-adapter.ts
index 9b24125ed8..bdf334825e 100644
--- a/modules/anari/src/anari-scene-adapter.ts
+++ b/modules/anari/src/anari-scene-adapter.ts
@@ -40,6 +40,7 @@ import type {ANARIGeometryParameters, ANARIVector3} from './anari-types';
type SurfacePlacement = {
surface: ANARISurface;
transform: readonly number[];
+ instanceId: string;
};
type CachedGeometry = {
@@ -313,6 +314,7 @@ export class ANARISceneAdapter {
geometryVersion: cachedGeometry.structuralVersion,
material: makeSceneMaterial(material),
transforms: placements.map(placement => placement.transform),
+ instanceIds: placements.map(placement => placement.instanceId),
...(surface.getParameter('skin') ? {skin: surface.getParameter('skin')} : {}),
...(geometry.getParameter('morphTargets')
? {
@@ -453,8 +455,15 @@ export function getFrameSize(frame: ANARIFrame, device: Device): [number, number
function collectSurfacePlacements(world: ANARIWorld): SurfacePlacement[] {
const placements: SurfacePlacement[] = [];
const parameters = world.getParameters();
+ const directSurfaceOccurrences = new Map();
for (const surface of resolveObjectArray(parameters.surface, parameters.surfaces)) {
- placements.push({surface, transform: IDENTITY_MATRIX});
+ const occurrence = directSurfaceOccurrences.get(surface.id) || 0;
+ directSurfaceOccurrences.set(surface.id, occurrence + 1);
+ placements.push({
+ surface,
+ transform: IDENTITY_MATRIX,
+ instanceId: occurrence === 0 ? surface.id : `${surface.id}:${occurrence}`
+ });
}
for (const instance of resolveObjectArray(parameters.instance, parameters.instances)) {
@@ -465,6 +474,7 @@ function collectSurfacePlacements(world: ANARIWorld): SurfacePlacement[] {
function collectInstancePlacements(instance: ANARIInstance, placements: SurfacePlacement[]): void {
const parameters = instance.getParameters();
+ const placementOccurrences = new Map();
const groups =
parameters.group instanceof ANARIArray
? parameters.group.data
@@ -479,7 +489,14 @@ function collectInstancePlacements(instance: ANARIInstance, placements: SurfaceP
}
const groupParameters = group.getParameters();
for (const surface of resolveObjectArray(groupParameters.surface, groupParameters.surfaces)) {
- placements.push({surface, transform: parameters.transform || IDENTITY_MATRIX});
+ const placementId = `${instance.id}:${group.id}:${surface.id}`;
+ const occurrence = placementOccurrences.get(placementId) || 0;
+ placementOccurrences.set(placementId, occurrence + 1);
+ placements.push({
+ surface,
+ transform: parameters.transform || IDENTITY_MATRIX,
+ instanceId: occurrence === 0 ? placementId : `${placementId}:${occurrence}`
+ });
}
}
}
diff --git a/modules/anari/src/anari-types.ts b/modules/anari/src/anari-types.ts
index 7930d8348a..3bb4559ee7 100644
--- a/modules/anari/src/anari-types.ts
+++ b/modules/anari/src/anari-types.ts
@@ -226,6 +226,12 @@ export type ANARIRendererParameters = {
maxBounces?: number;
progressive?: boolean;
shadows?: boolean;
+ resolutionScale?: number;
+ minimumResolutionScale?: number;
+ adaptiveResolution?: boolean;
+ targetFrameTimeMilliseconds?: number;
+ temporalReprojection?: boolean;
+ shadowSamplesPerFrame?: number;
bloomIntensity?: number;
bloomThreshold?: number;
bloomRadius?: number;
@@ -245,6 +251,14 @@ export type ANARIFrameStatistics = {
instanceCount: number;
drawCount: number;
triangleCount: number;
+ rayTracing?: {
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ sampledPixelCoverage: number;
+ frameTimeMilliseconds: number;
+ accumulatedSamples: number;
+ };
};
export type ANARIObjectInfo = {
diff --git a/modules/anari/src/schemas.ts b/modules/anari/src/schemas.ts
index 1d42c0d50d..7bd2bb4336 100644
--- a/modules/anari/src/schemas.ts
+++ b/modules/anari/src/schemas.ts
@@ -299,7 +299,13 @@ const raytraceRendererProperties = {
samplesPerPixel: z.number().int().positive().optional(),
maxBounces: z.number().int().nonnegative().optional(),
progressive: z.boolean().optional(),
- shadows: z.boolean().optional()
+ shadows: z.boolean().optional(),
+ resolutionScale: positiveNumberSchema.max(1).optional(),
+ minimumResolutionScale: positiveNumberSchema.max(1).optional(),
+ adaptiveResolution: z.boolean().optional(),
+ targetFrameTimeMilliseconds: positiveNumberSchema.optional(),
+ temporalReprojection: z.boolean().optional(),
+ shadowSamplesPerFrame: z.number().int().nonnegative().optional()
};
export const ANARIRendererSchema = z
diff --git a/modules/anari/test/anari-device.node.spec.ts b/modules/anari/test/anari-device.node.spec.ts
index 8339c934f5..3ba3567055 100644
--- a/modules/anari/test/anari-device.node.spec.ts
+++ b/modules/anari/test/anari-device.node.spec.ts
@@ -2,6 +2,7 @@ import test from 'test/utils/vitest-tape';
import {NullDevice} from '@luma.gl/test-utils';
import {Matrix4} from '@math.gl/core';
import {ANARIDevice, type ANARIRendererRuntimeFactory} from '@luma.gl/anari';
+import {ANARISceneAdapter} from '../src/anari-scene-adapter';
test('ANARI objects expose committed rather than staged parameters', testContext => {
const device = new ANARIDevice(new NullDevice({}));
@@ -299,6 +300,29 @@ test('ANARI renderer batches repeated group instances by surface', testContext =
testContext.equal(statistics.drawCount, 1, 'repeated groups use one instanced draw');
testContext.ok(statistics.triangleCount > 0, 'generated sphere triangles are counted');
+ const adapter = new ANARISceneAdapter();
+ const surfaces = adapter.makeRenderOptions(frame)?.surfaces;
+ testContext.deepEqual(
+ surfaces?.[0]?.instanceIds,
+ instances.map(instance => `${instance.id}:${group.id}:${surface.id}`),
+ 'shared scene placements retain stable instance, group, and surface identities'
+ );
+
+ world
+ .setParameters({surface: [surface, surface], instance: [instances[1], instances[0]]})
+ .commitParameters();
+ testContext.deepEqual(
+ adapter.makeRenderOptions(frame)?.surfaces[0]?.instanceIds,
+ [
+ surface.id,
+ `${surface.id}:1`,
+ `${instances[1].id}:${group.id}:${surface.id}`,
+ `${instances[0].id}:${group.id}:${surface.id}`
+ ],
+ 'direct duplicates remain distinct and reordered instances preserve their retained identities'
+ );
+ adapter.destroy();
+
frame.destroy();
device.destroy();
testContext.end();
diff --git a/modules/anari/test/anari-schemas.node.spec.ts b/modules/anari/test/anari-schemas.node.spec.ts
index d180b38d01..afbb23f05b 100644
--- a/modules/anari/test/anari-schemas.node.spec.ts
+++ b/modules/anari/test/anari-schemas.node.spec.ts
@@ -34,6 +34,12 @@ test('ANARI renderer schemas validate graph-based ray tracing settings', testCon
maxBounces: 2,
progressive: true,
shadows: true,
+ resolutionScale: 0.5,
+ minimumResolutionScale: 0.25,
+ adaptiveResolution: true,
+ targetFrameTimeMilliseconds: 33.3,
+ temporalReprojection: true,
+ shadowSamplesPerFrame: 1,
exposure: 1.4
};
@@ -57,10 +63,42 @@ test('ANARI renderer schemas validate graph-based ray tracing settings', testCon
ANARIRendererSchema.safeParse({...renderer, progressive: 1}).success,
'progressive accumulation must be enabled with a boolean'
);
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, resolutionScale: 0}).success,
+ 'the internal ray-tracing resolution must be greater than zero'
+ );
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, resolutionScale: 1.1}).success,
+ 'the internal ray-tracing resolution cannot exceed the output resolution'
+ );
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, minimumResolutionScale: -0.1}).success,
+ 'adaptive resolution requires a positive minimum scale'
+ );
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, targetFrameTimeMilliseconds: 0}).success,
+ 'the target frame-time budget must be positive'
+ );
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, shadowSamplesPerFrame: 1.5}).success,
+ 'per-frame shadow work must be a nonnegative integer'
+ );
+ testContext.ok(
+ ANARIRendererSchema.safeParse({...renderer, shadowSamplesPerFrame: 0}).success,
+ 'zero keeps the backwards-compatible all-lights shadow path'
+ );
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({...renderer, temporalReprojection: 1}).success,
+ 'temporal reprojection must be enabled with a boolean'
+ );
testContext.notOk(
ANARIRendererSchema.safeParse({'@@type': 'default', samplesPerPixel: 4}).success,
'ray tracing controls are not silently accepted by forward renderers'
);
+ testContext.notOk(
+ ANARIRendererSchema.safeParse({'@@type': 'deferred', resolutionScale: 0.5}).success,
+ 'adaptive ray-tracing controls are not silently accepted by deferred renderers'
+ );
testContext.ok(
JSON.stringify(ANARI_SCENE_JSON_SCHEMA).includes('raytrace'),
'the generated JSON Schema advertises the raytrace renderer subtype'
diff --git a/modules/experimental/src/engine/ray-tracing-scene-renderer.ts b/modules/experimental/src/engine/ray-tracing-scene-renderer.ts
index 644b912ef0..c7bd958785 100644
--- a/modules/experimental/src/engine/ray-tracing-scene-renderer.ts
+++ b/modules/experimental/src/engine/ray-tracing-scene-renderer.ts
@@ -5,9 +5,13 @@
import {Buffer, type Device, Texture} from '@luma.gl/core';
import {Computation, type Geometry, Model} from '@luma.gl/engine';
import type {Light} from '@luma.gl/shadertools';
-import {Matrix4} from '@math.gl/core';
+import {Matrix4, type NumericArray} from '@math.gl/core';
import {GPUBVH} from '../gpu-primitives/gpu-bvh';
-import {GPUCommandGraph, type CompiledGPUCommandGraph} from '../gpu-primitives/gpu-command-graph';
+import {
+ GPUCommandGraph,
+ type CompiledGPUCommandGraph,
+ type GraphDataView
+} from '../gpu-primitives/gpu-command-graph';
import {createTransientView, getViewBinding} from '../gpu-primitives/graph-data-view-utils';
import {
RAY_TRACING_BOUNDS_SHADER,
@@ -16,10 +20,16 @@ import {
} from './ray-tracing-scene-shaders';
import type {SceneRenderOptions, SceneRenderStatistics, SceneSurface} from './scene-renderer';
-const PRIMITIVE_FLOAT_COUNT = 48;
+const PRIMITIVE_FLOAT_COUNT = 64;
const TRIANGLE_FLOAT_COUNT = 24;
const LIGHT_FLOAT_COUNT = 16;
-const UNIFORM_FLOAT_COUNT = 40;
+const UNIFORM_FLOAT_COUNT = 68;
+const DEFAULT_RESOLUTION_SCALE = 0.5;
+const DEFAULT_MINIMUM_RESOLUTION_SCALE = 0.25;
+const DEFAULT_TARGET_FRAME_TIME_MILLISECONDS = 33.3;
+const RESOLUTION_SCALES = [0.25, 0.375, 0.5, 0.75, 1] as const;
+const FRAME_BUDGET_COOLDOWN_MILLISECONDS = 250;
+const MAXIMUM_HISTORY_SAMPLES = 64;
/** Optional analytic primitive supplied by a format-specific scene adapter. */
export type RayTracingScenePrimitive = {
@@ -41,6 +51,18 @@ export type RayTracingSceneRenderOptions = SceneRenderOptions & {
progressive?: boolean;
/** Traces direct-light shadow rays when enabled. */
shadows?: boolean;
+ /** Initial internal ray-tracing resolution relative to the display resolution. */
+ resolutionScale?: number;
+ /** Lowest internal resolution available to adaptive frame budgeting. */
+ minimumResolutionScale?: number;
+ /** Adjusts ray workload toward the requested frame budget when enabled. */
+ adaptiveResolution?: boolean;
+ /** Target animation-frame duration used by the adaptive scheduler. */
+ targetFrameTimeMilliseconds?: number;
+ /** Reprojects compatible history while the camera or instances move. */
+ temporalReprojection?: boolean;
+ /** Maximum non-ambient shadowed light samples traced per pixel in one frame. */
+ shadowSamplesPerFrame?: number;
};
type RayTracingScene = {
@@ -52,17 +74,79 @@ type RayTracingScene = {
triangleCount: number;
};
+type RayTracingGeometryLayout = {
+ triangleStart: number;
+ triangleCount: number;
+ bounds: readonly [number, number, number, number];
+};
+
+type RayTracingTopology = {
+ triangles: Float32Array;
+ geometryLayouts: Map;
+};
+
+type RayTracingPrimitiveData = {
+ primitives: Float32Array;
+ primitiveCount: number;
+ triangleCount: number;
+ previousTransforms: Map;
+};
+
+type RayTracingQualityOptions = {
+ resolutionScale: number;
+ minimumResolutionScale: number;
+ adaptiveResolution: boolean;
+ targetFrameTimeMilliseconds: number;
+};
+
+type RayTracingTraceGraphParameters = {
+ dispatchWidth: number;
+};
+
type RayTracingFrameResources = {
- width: number;
- height: number;
+ displayWidth: number;
+ displayHeight: number;
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ requestedResolutionScale: number;
+ minimumResolutionScale: number;
+ adaptiveResolution: boolean;
+ targetFrameTimeMilliseconds: number;
+ phaseCount: number;
+ phaseIndex: number;
+ lastRenderTimeMilliseconds?: number;
+ averageFrameTimeMilliseconds?: number;
+ overBudgetFrameCount: number;
+ underBudgetFrameCount: number;
+ lastBudgetAdjustmentTimeMilliseconds: number;
uniformBuffer: Buffer;
primitiveBuffer: Buffer;
triangleBuffer: Buffer;
lightBuffer: Buffer;
+ nodeMinimaBuffer: Buffer;
+ nodeMaximaBuffer: Buffer;
+ nodeChildrenBuffer: Buffer;
+ leafIdsBuffer: Buffer;
+ bvhCountBuffer: Buffer;
+ bvhOverflowBuffer: Buffer;
historyTexture: Texture;
- graph: CompiledGPUCommandGraph;
- sceneRevision: string;
+ historyMetadataTexture: Texture;
+ accelerationGraph: CompiledGPUCommandGraph;
+ traceGraph: CompiledGPUCommandGraph;
+ topologyRevision: string;
+ primitiveRevision: string;
+ transformRevision: string;
+ lightRevision: string;
renderRevision: string;
+ geometryLayouts: Map;
+ previousTransforms: Map;
+ previousTransformsNeedCommit: boolean;
+ previousViewProjection: Matrix4;
+ previousCameraPosition: readonly number[];
+ historyNeedsReset: boolean;
+ accelerationNeedsUpdate: boolean;
+ frameIndex: number;
accumulatedFrameCount: number;
primitiveCount: number;
primitiveCapacity: number;
@@ -72,15 +156,29 @@ type RayTracingFrameResources = {
};
type CompiledRayGeometry = {
- triangleStart: number;
+ triangles: Float32Array;
triangleCount: number;
bounds: readonly [number, number, number, number];
};
+type RayTracingSceneSurface = SceneSurface & {
+ instanceIds?: readonly string[];
+};
+
+type RayTracingStatistics = {
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ sampledPixelCoverage: number;
+ frameTimeMilliseconds: number;
+ accumulatedSamples: number;
+};
+
/** Shared WebGPU software ray tracer consuming the canonical retained-scene contract. */
export class RayTracingSceneRenderer {
private readonly device: Device;
private readonly frames = new Map();
+ private readonly geometryCache = new Map();
constructor(device: Device) {
if (device.type !== 'webgpu') {
@@ -93,79 +191,271 @@ export class RayTracingSceneRenderer {
const [defaultWidth, defaultHeight] = this.device
.getDefaultCanvasContext()
.getDrawingBufferSize();
- const width = options.width ?? defaultWidth;
- const height = options.height ?? defaultHeight;
+ const displayWidth = options.width ?? defaultWidth;
+ const displayHeight = options.height ?? defaultHeight;
const lights = options.lights ?? [];
- const sceneRevision = getSceneRevision(options);
+ const quality = getQualityOptions(options);
+ const viewProjection = new Matrix4(options.camera.projectionMatrix).multiplyRight(
+ options.camera.viewMatrix
+ );
+ const inverseViewProjection = new Matrix4(viewProjection).invert();
+ const topologyRevision = getTopologyRevision(options);
+ const primitiveRevision = getPrimitiveRevision(options);
+ const transformRevision = getTransformRevision(options);
+ const lightRevision = getLightRevision(lights);
let resources = this.frames.get(options.id);
- if (resources && (resources.width !== width || resources.height !== height)) {
- this.destroyFrame(options.id);
- resources = undefined;
- }
+ const currentTimeMilliseconds = getTimestampMilliseconds();
+
+ if (!resources) {
+ const topology = makeRayTracingTopology(
+ options.surfaces,
+ options.primitives ?? {},
+ this.geometryCache
+ );
+ const primitiveData = makePrimitiveData(
+ options.surfaces,
+ options.primitives ?? {},
+ topology.geometryLayouts,
+ new Map()
+ );
+ const scene = makeRayTracingScene(primitiveData, topology.triangles, lights);
+ resources = this.createFrameResources({
+ frameIdentifier: options.id,
+ displayWidth,
+ displayHeight,
+ scene,
+ topology,
+ primitiveData,
+ quality,
+ viewProjection,
+ cameraPosition: options.camera.position
+ });
+ resources.topologyRevision = topologyRevision;
+ resources.primitiveRevision = primitiveRevision;
+ resources.transformRevision = transformRevision;
+ resources.lightRevision = lightRevision;
+ this.frames.set(options.id, resources);
+ } else {
+ updateFrameTiming(resources, currentTimeMilliseconds);
+ if (updateQualityOptions(resources, quality)) {
+ resources.historyNeedsReset = true;
+ }
+ updateAdaptiveBudget(resources, currentTimeMilliseconds);
+
+ const topologyChanged = resources.topologyRevision !== topologyRevision;
+ const primitiveChanged = resources.primitiveRevision !== primitiveRevision;
+ const transformChanged = resources.transformRevision !== transformRevision;
+ const lightsChanged = resources.lightRevision !== lightRevision;
+ let topology: RayTracingTopology | undefined;
+ let primitiveData: RayTracingPrimitiveData | undefined;
+ let lightData: Float32Array | undefined;
+
+ if (topologyChanged) {
+ topology = makeRayTracingTopology(
+ options.surfaces,
+ options.primitives ?? {},
+ this.geometryCache
+ );
+ }
+ if (topologyChanged || primitiveChanged) {
+ primitiveData = makePrimitiveData(
+ options.surfaces,
+ options.primitives ?? {},
+ topology?.geometryLayouts ?? resources.geometryLayouts,
+ resources.previousTransforms
+ );
+ } else if (resources.previousTransformsNeedCommit) {
+ primitiveData = makePrimitiveData(
+ options.surfaces,
+ options.primitives ?? {},
+ resources.geometryLayouts,
+ resources.previousTransforms
+ );
+ }
+ if (lightsChanged) {
+ lightData = makeLightData(lights);
+ }
- if (!resources || resources.sceneRevision !== sceneRevision) {
- const scene = makeRayTracingScene(options.surfaces, lights, options.primitives ?? {});
if (
- resources &&
- (resources.primitiveBuffer.byteLength < scene.primitives.byteLength ||
- resources.triangleBuffer.byteLength < scene.triangles.byteLength ||
- resources.lightBuffer.byteLength < scene.lights.byteLength)
+ (primitiveData &&
+ resources.primitiveBuffer.byteLength < primitiveData.primitives.byteLength) ||
+ (topology && resources.triangleBuffer.byteLength < topology.triangles.byteLength) ||
+ (lightData && resources.lightBuffer.byteLength < lightData.byteLength)
) {
+ topology ??= makeRayTracingTopology(
+ options.surfaces,
+ options.primitives ?? {},
+ this.geometryCache
+ );
+ primitiveData ??= makePrimitiveData(
+ options.surfaces,
+ options.primitives ?? {},
+ topology.geometryLayouts,
+ resources.previousTransforms
+ );
+ const scene = makeRayTracingScene(primitiveData, topology.triangles, lights);
this.destroyFrame(options.id);
- resources = undefined;
- }
- if (!resources) {
- resources = this.createFrameResources(options.id, width, height, scene);
+ resources = this.createFrameResources({
+ frameIdentifier: options.id,
+ displayWidth,
+ displayHeight,
+ scene,
+ topology,
+ primitiveData,
+ quality,
+ viewProjection,
+ cameraPosition: options.camera.position
+ });
+ resources.topologyRevision = topologyRevision;
+ resources.primitiveRevision = primitiveRevision;
+ resources.transformRevision = transformRevision;
+ resources.lightRevision = lightRevision;
this.frames.set(options.id, resources);
} else {
- resources.primitiveBuffer.write(scene.primitives);
- resources.triangleBuffer.write(scene.triangles);
- resources.lightBuffer.write(scene.lights);
+ if (topology) {
+ resources.triangleBuffer.write(topology.triangles);
+ resources.geometryLayouts = topology.geometryLayouts;
+ resources.historyNeedsReset = true;
+ resources.accelerationNeedsUpdate = true;
+ }
+ if (primitiveData) {
+ resources.primitiveBuffer.write(primitiveData.primitives);
+ resources.previousTransforms = primitiveData.previousTransforms;
+ resources.previousTransformsNeedCommit = transformChanged;
+ resources.primitiveCount = primitiveData.primitiveCount;
+ resources.triangleCount = primitiveData.triangleCount;
+ if (transformChanged) {
+ resources.accelerationNeedsUpdate = true;
+ if (!(options.temporalReprojection ?? true)) {
+ resources.historyNeedsReset = true;
+ }
+ } else if (primitiveChanged) {
+ resources.historyNeedsReset = true;
+ }
+ }
+ if (lightData) {
+ const lightCountChanged = resources.lightCount !== lights.length;
+ resources.lightBuffer.write(lightData);
+ resources.lightCount = lights.length;
+ if (lightCountChanged || !(options.temporalReprojection ?? true)) {
+ resources.historyNeedsReset = true;
+ }
+ }
+ resources.topologyRevision = topologyRevision;
+ resources.primitiveRevision = primitiveRevision;
+ resources.transformRevision = transformRevision;
+ resources.lightRevision = lightRevision;
}
- resources.sceneRevision = sceneRevision;
- resources.primitiveCount = scene.primitiveCount;
- resources.lightCount = scene.lightCount;
- resources.triangleCount = scene.triangleCount;
- resources.accumulatedFrameCount = 0;
}
- const inverseViewProjection = new Matrix4(options.camera.projectionMatrix)
- .multiplyRight(options.camera.viewMatrix)
- .invert();
+ if (!resources.lastRenderTimeMilliseconds) {
+ resources.lastRenderTimeMilliseconds = currentTimeMilliseconds;
+ }
+ const internalDimensions = getInternalDimensions(
+ displayWidth,
+ displayHeight,
+ resources.resolutionScale
+ );
+ if (
+ resources.displayWidth !== displayWidth ||
+ resources.displayHeight !== displayHeight ||
+ resources.internalWidth !== internalDimensions.width ||
+ resources.internalHeight !== internalDimensions.height
+ ) {
+ this.recreateTraceResources(
+ options.id,
+ resources,
+ displayWidth,
+ displayHeight,
+ internalDimensions.width,
+ internalDimensions.height
+ );
+ }
+
const renderRevision = getRenderRevision(options, inverseViewProjection);
if (resources.renderRevision !== renderRevision) {
resources.renderRevision = renderRevision;
- resources.accumulatedFrameCount = 0;
+ resources.historyNeedsReset = true;
+ }
+ if (
+ (options.temporalReprojection ?? true) &&
+ isCameraCut(
+ resources.previousViewProjection,
+ viewProjection,
+ resources.previousCameraPosition,
+ options.camera.position
+ )
+ ) {
+ resources.historyNeedsReset = true;
}
const progressive = options.progressive ?? true;
+ if (resources.historyNeedsReset) {
+ resources.accumulatedFrameCount = 0;
+ }
+ const activePhaseCount = resources.historyNeedsReset ? 1 : resources.phaseCount;
+ const activePhaseIndex = resources.historyNeedsReset
+ ? 0
+ : resources.phaseIndex % activePhaseCount;
const accumulatedFrameCount = progressive ? resources.accumulatedFrameCount : 0;
resources.uniformBuffer.write(
makeUniformData({
options,
inverseViewProjection,
- width,
- height,
+ previousViewProjection: resources.previousViewProjection,
+ previousCameraPosition: resources.previousCameraPosition,
+ displayWidth,
+ displayHeight,
+ internalWidth: resources.internalWidth,
+ internalHeight: resources.internalHeight,
+ resolutionScale: resources.resolutionScale,
+ phaseIndex: activePhaseIndex,
+ phaseCount: activePhaseCount,
primitiveCount: resources.primitiveCount,
primitiveCapacity: resources.primitiveCapacity,
leafCapacity: resources.leafCapacity,
lightCount: resources.lightCount,
- accumulatedFrameCount
+ accumulatedFrameCount,
+ frameIndex: resources.frameIndex
})
);
- resources.graph.encode(this.device.commandEncoder, {parameters: undefined});
+ if (resources.accelerationNeedsUpdate) {
+ resources.accelerationGraph.encode(this.device.commandEncoder, {parameters: undefined});
+ resources.accelerationNeedsUpdate = false;
+ }
+ resources.traceGraph.encode(this.device.commandEncoder, {
+ parameters: {dispatchWidth: Math.ceil(resources.internalWidth / activePhaseCount)}
+ });
+ resources.previousViewProjection = new Matrix4(viewProjection);
+ resources.previousCameraPosition = Array.from(options.camera.position);
+ resources.historyNeedsReset = false;
+ resources.phaseIndex = (activePhaseIndex + 1) % resources.phaseCount;
+ resources.frameIndex++;
resources.accumulatedFrameCount = progressive ? accumulatedFrameCount + 1 : 0;
+ const samplesPerPixel = getSamplesPerPixel(options);
- return {
+ const statistics = {
surfaceCount: options.surfaces.length,
instanceCount: options.surfaces.reduce(
(count, surface) => count + surface.transforms.length,
0
),
drawCount: 1,
- triangleCount: resources.triangleCount
+ triangleCount: resources.triangleCount,
+ rayTracing: {
+ internalWidth: resources.internalWidth,
+ internalHeight: resources.internalHeight,
+ resolutionScale: resources.resolutionScale,
+ sampledPixelCoverage: 1 / activePhaseCount,
+ frameTimeMilliseconds:
+ resources.averageFrameTimeMilliseconds ?? resources.targetFrameTimeMilliseconds,
+ accumulatedSamples: progressive
+ ? Math.min(resources.accumulatedFrameCount * samplesPerPixel, MAXIMUM_HISTORY_SAMPLES)
+ : samplesPerPixel
+ } satisfies RayTracingStatistics
};
+ return statistics as SceneRenderStatistics;
}
destroyFrame(frameIdentifier: string): void {
@@ -173,12 +463,20 @@ export class RayTracingSceneRenderer {
if (!resources) {
return;
}
- resources.graph.destroy();
+ resources.accelerationGraph.destroy();
+ resources.traceGraph.destroy();
resources.uniformBuffer.destroy();
resources.primitiveBuffer.destroy();
resources.triangleBuffer.destroy();
resources.lightBuffer.destroy();
+ resources.nodeMinimaBuffer.destroy();
+ resources.nodeMaximaBuffer.destroy();
+ resources.nodeChildrenBuffer.destroy();
+ resources.leafIdsBuffer.destroy();
+ resources.bvhCountBuffer.destroy();
+ resources.bvhOverflowBuffer.destroy();
resources.historyTexture.destroy();
+ resources.historyMetadataTexture.destroy();
this.frames.delete(frameIdentifier);
}
@@ -188,12 +486,18 @@ export class RayTracingSceneRenderer {
}
}
- private createFrameResources(
- frameIdentifier: string,
- width: number,
- height: number,
- scene: RayTracingScene
- ): RayTracingFrameResources {
+ private createFrameResources(props: {
+ frameIdentifier: string;
+ displayWidth: number;
+ displayHeight: number;
+ scene: RayTracingScene;
+ topology: RayTracingTopology;
+ primitiveData: RayTracingPrimitiveData;
+ quality: RayTracingQualityOptions;
+ viewProjection: Matrix4;
+ cameraPosition: Readonly;
+ }): RayTracingFrameResources {
+ const {frameIdentifier, scene} = props;
const uniformBuffer = this.device.createBuffer({
id: `${frameIdentifier}-ray-tracing-uniforms`,
byteLength: UNIFORM_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT,
@@ -214,13 +518,6 @@ export class RayTracingSceneRenderer {
data: scene.lights,
usage: Buffer.STORAGE | Buffer.COPY_DST
});
- const historyTexture = this.device.createTexture({
- id: `${frameIdentifier}-ray-tracing-history`,
- width,
- height,
- format: 'rgba16float',
- usage: Texture.SAMPLE | Texture.COPY_DST
- });
const primitiveCapacity = Math.max(
1,
Math.floor(
@@ -228,30 +525,123 @@ export class RayTracingSceneRenderer {
)
);
const leafCapacity = 2 ** Math.ceil(Math.log2(primitiveCapacity));
- const graph = this.createCommandGraph({
+ const nodeCount = leafCapacity * 2 - 1;
+ const nodeMinimaBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-node-minima`,
+ byteLength: nodeCount * 3 * Float32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const nodeMaximaBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-node-maxima`,
+ byteLength: nodeCount * 3 * Float32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const nodeChildrenBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-node-children`,
+ byteLength: nodeCount * 2 * Uint32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const leafIdsBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-leaf-ids`,
+ byteLength: leafCapacity * Uint32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const bvhCountBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-bvh-count`,
+ byteLength: Uint32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const bvhOverflowBuffer = this.device.createBuffer({
+ id: `${frameIdentifier}-ray-tracing-bvh-overflow`,
+ byteLength: Uint32Array.BYTES_PER_ELEMENT,
+ usage: Buffer.STORAGE
+ });
+ const internalDimensions = getInternalDimensions(
+ props.displayWidth,
+ props.displayHeight,
+ props.quality.resolutionScale
+ );
+ const historyTexture = this.createHistoryTexture(
+ frameIdentifier,
+ 'history',
+ internalDimensions.width,
+ internalDimensions.height
+ );
+ const historyMetadataTexture = this.createHistoryTexture(
+ frameIdentifier,
+ 'history-metadata',
+ internalDimensions.width,
+ internalDimensions.height
+ );
+ const accelerationGraph = this.createAccelerationGraph({
frameIdentifier,
- width,
- height,
uniformBuffer,
primitiveBuffer,
primitiveCapacity,
leafCapacity,
+ nodeMinimaBuffer,
+ nodeMaximaBuffer,
+ nodeChildrenBuffer,
+ leafIdsBuffer,
+ bvhCountBuffer,
+ bvhOverflowBuffer
+ });
+ const traceGraph = this.createTraceGraph({
+ frameIdentifier,
+ internalWidth: internalDimensions.width,
+ internalHeight: internalDimensions.height,
+ uniformBuffer,
+ primitiveBuffer,
triangleBuffer,
lightBuffer,
- historyTexture
+ nodeMinimaBuffer,
+ nodeMaximaBuffer,
+ historyTexture,
+ historyMetadataTexture
});
return {
- width,
- height,
+ displayWidth: props.displayWidth,
+ displayHeight: props.displayHeight,
+ internalWidth: internalDimensions.width,
+ internalHeight: internalDimensions.height,
+ resolutionScale: props.quality.resolutionScale,
+ requestedResolutionScale: props.quality.resolutionScale,
+ minimumResolutionScale: props.quality.minimumResolutionScale,
+ adaptiveResolution: props.quality.adaptiveResolution,
+ targetFrameTimeMilliseconds: props.quality.targetFrameTimeMilliseconds,
+ phaseCount: 1,
+ phaseIndex: 0,
+ overBudgetFrameCount: 0,
+ underBudgetFrameCount: 0,
+ lastBudgetAdjustmentTimeMilliseconds: 0,
uniformBuffer,
primitiveBuffer,
triangleBuffer,
lightBuffer,
+ nodeMinimaBuffer,
+ nodeMaximaBuffer,
+ nodeChildrenBuffer,
+ leafIdsBuffer,
+ bvhCountBuffer,
+ bvhOverflowBuffer,
historyTexture,
- graph,
- sceneRevision: '',
+ historyMetadataTexture,
+ accelerationGraph,
+ traceGraph,
+ topologyRevision: '',
+ primitiveRevision: '',
+ transformRevision: '',
+ lightRevision: '',
renderRevision: '',
+ geometryLayouts: props.topology.geometryLayouts,
+ previousTransforms: props.primitiveData.previousTransforms,
+ previousTransformsNeedCommit: false,
+ previousViewProjection: new Matrix4(props.viewProjection),
+ previousCameraPosition: Array.from(props.cameraPosition),
+ historyNeedsReset: true,
+ accelerationNeedsUpdate: true,
+ frameIndex: 0,
accumulatedFrameCount: 0,
primitiveCount: scene.primitiveCount,
primitiveCapacity,
@@ -261,20 +651,80 @@ export class RayTracingSceneRenderer {
};
}
- private createCommandGraph(props: {
+ private recreateTraceResources(
+ frameIdentifier: string,
+ resources: RayTracingFrameResources,
+ displayWidth: number,
+ displayHeight: number,
+ internalWidth: number,
+ internalHeight: number
+ ): void {
+ resources.traceGraph.destroy();
+ resources.historyTexture.destroy();
+ resources.historyMetadataTexture.destroy();
+ resources.historyTexture = this.createHistoryTexture(
+ frameIdentifier,
+ 'history',
+ internalWidth,
+ internalHeight
+ );
+ resources.historyMetadataTexture = this.createHistoryTexture(
+ frameIdentifier,
+ 'history-metadata',
+ internalWidth,
+ internalHeight
+ );
+ resources.traceGraph = this.createTraceGraph({
+ frameIdentifier,
+ internalWidth,
+ internalHeight,
+ uniformBuffer: resources.uniformBuffer,
+ primitiveBuffer: resources.primitiveBuffer,
+ triangleBuffer: resources.triangleBuffer,
+ lightBuffer: resources.lightBuffer,
+ nodeMinimaBuffer: resources.nodeMinimaBuffer,
+ nodeMaximaBuffer: resources.nodeMaximaBuffer,
+ historyTexture: resources.historyTexture,
+ historyMetadataTexture: resources.historyMetadataTexture
+ });
+ resources.displayWidth = displayWidth;
+ resources.displayHeight = displayHeight;
+ resources.internalWidth = internalWidth;
+ resources.internalHeight = internalHeight;
+ resources.phaseIndex = 0;
+ resources.historyNeedsReset = true;
+ }
+
+ private createHistoryTexture(
+ frameIdentifier: string,
+ suffix: string,
+ width: number,
+ height: number
+ ): Texture {
+ return this.device.createTexture({
+ id: `${frameIdentifier}-ray-tracing-${suffix}`,
+ width,
+ height,
+ format: 'rgba16float',
+ usage: Texture.SAMPLE | Texture.COPY_SRC | Texture.COPY_DST
+ });
+ }
+
+ private createAccelerationGraph(props: {
frameIdentifier: string;
- width: number;
- height: number;
uniformBuffer: Buffer;
primitiveBuffer: Buffer;
primitiveCapacity: number;
leafCapacity: number;
- triangleBuffer: Buffer;
- lightBuffer: Buffer;
- historyTexture: Texture;
+ nodeMinimaBuffer: Buffer;
+ nodeMaximaBuffer: Buffer;
+ nodeChildrenBuffer: Buffer;
+ leafIdsBuffer: Buffer;
+ bvhCountBuffer: Buffer;
+ bvhOverflowBuffer: Buffer;
}): CompiledGPUCommandGraph {
const graph = new GPUCommandGraph(this.device, {
- id: `scene-${props.frameIdentifier}-ray-tracing`
+ id: `scene-${props.frameIdentifier}-ray-tracing-acceleration`
});
const uniforms = graph.importBuffer(
{
@@ -292,37 +742,6 @@ export class RayTracingSceneRenderer {
},
props.primitiveBuffer
);
- const triangles = graph.importBuffer(
- {
- id: 'triangles',
- byteLength: props.triangleBuffer.byteLength,
- usage: props.triangleBuffer.usage
- },
- props.triangleBuffer
- );
- const lights = graph.importBuffer(
- {id: 'lights', byteLength: props.lightBuffer.byteLength, usage: props.lightBuffer.usage},
- props.lightBuffer
- );
- const history = graph.importTexture(
- {
- id: 'history',
- format: 'rgba16float',
- width: props.width,
- height: props.height,
- usage: Texture.SAMPLE | Texture.COPY_DST
- },
- props.historyTexture
- );
- const output = graph.createTransientTexture({
- id: 'output',
- format: 'rgba16float',
- width: props.width,
- height: props.height,
- usage: Texture.STORAGE | Texture.SAMPLE | Texture.COPY_SRC
- });
- const historyView = graph.createTextureView(history);
- const outputView = graph.createTextureView(output);
const primitiveMinima = createTransientView(
graph,
'primitive-minima',
@@ -336,10 +755,34 @@ export class RayTracingSceneRenderer {
props.primitiveCapacity
);
const nodeCount = props.leafCapacity * 2 - 1;
- const nodeMinima = createTransientView(graph, 'node-minima', 'float32x3', nodeCount);
- const nodeMaxima = createTransientView(graph, 'node-maxima', 'float32x3', nodeCount);
- const nodeChildren = createTransientView(graph, 'node-children', 'uint32x2', nodeCount);
- const leafIds = createTransientView(graph, 'leaf-ids', 'uint32', props.leafCapacity);
+ const nodeMinima = createImportedView(
+ graph,
+ 'node-minima',
+ props.nodeMinimaBuffer,
+ 'float32x3',
+ nodeCount
+ );
+ const nodeMaxima = createImportedView(
+ graph,
+ 'node-maxima',
+ props.nodeMaximaBuffer,
+ 'float32x3',
+ nodeCount
+ );
+ const nodeChildren = createImportedView(
+ graph,
+ 'node-children',
+ props.nodeChildrenBuffer,
+ 'uint32x2',
+ nodeCount
+ );
+ const leafIds = createImportedView(
+ graph,
+ 'leaf-ids',
+ props.leafIdsBuffer,
+ 'uint32',
+ props.leafCapacity
+ );
const acceleration = new GPUBVH({
id: `${props.frameIdentifier}-ray-tracing-bvh`,
minima: primitiveMinima,
@@ -349,8 +792,8 @@ export class RayTracingSceneRenderer {
nodeMaxima,
nodeChildren,
leafIds,
- count: createTransientView(graph, 'bvh-count', 'uint32', 1),
- overflow: createTransientView(graph, 'bvh-overflow', 'uint32', 1)
+ count: createImportedView(graph, 'bvh-count', props.bvhCountBuffer, 'uint32', 1),
+ overflow: createImportedView(graph, 'bvh-overflow', props.bvhOverflowBuffer, 'uint32', 1)
});
graph.addComputePass({
@@ -390,6 +833,136 @@ export class RayTracingSceneRenderer {
});
acceleration.addToGraph(graph);
+ return graph.compile();
+ }
+
+ private createTraceGraph(props: {
+ frameIdentifier: string;
+ internalWidth: number;
+ internalHeight: number;
+ uniformBuffer: Buffer;
+ primitiveBuffer: Buffer;
+ triangleBuffer: Buffer;
+ lightBuffer: Buffer;
+ nodeMinimaBuffer: Buffer;
+ nodeMaximaBuffer: Buffer;
+ historyTexture: Texture;
+ historyMetadataTexture: Texture;
+ }): CompiledGPUCommandGraph {
+ const graph = new GPUCommandGraph(this.device, {
+ id: `scene-${props.frameIdentifier}-ray-tracing-trace`
+ });
+ const uniforms = graph.importBuffer(
+ {
+ id: 'uniforms',
+ byteLength: props.uniformBuffer.byteLength,
+ usage: props.uniformBuffer.usage
+ },
+ props.uniformBuffer
+ );
+ const primitives = graph.importBuffer(
+ {
+ id: 'primitives',
+ byteLength: props.primitiveBuffer.byteLength,
+ usage: props.primitiveBuffer.usage
+ },
+ props.primitiveBuffer
+ );
+ const triangles = graph.importBuffer(
+ {
+ id: 'triangles',
+ byteLength: props.triangleBuffer.byteLength,
+ usage: props.triangleBuffer.usage
+ },
+ props.triangleBuffer
+ );
+ const lights = graph.importBuffer(
+ {id: 'lights', byteLength: props.lightBuffer.byteLength, usage: props.lightBuffer.usage},
+ props.lightBuffer
+ );
+ const nodeCount = Math.max(
+ 1,
+ Math.floor(props.nodeMinimaBuffer.byteLength / (3 * Float32Array.BYTES_PER_ELEMENT))
+ );
+ const nodeMinima = createImportedView(
+ graph,
+ 'node-minima',
+ props.nodeMinimaBuffer,
+ 'float32x3',
+ nodeCount
+ );
+ const nodeMaxima = createImportedView(
+ graph,
+ 'node-maxima',
+ props.nodeMaximaBuffer,
+ 'float32x3',
+ nodeCount
+ );
+ const history = graph.importTexture(
+ {
+ id: 'history',
+ format: 'rgba16float',
+ width: props.internalWidth,
+ height: props.internalHeight,
+ usage: Texture.SAMPLE | Texture.COPY_SRC | Texture.COPY_DST
+ },
+ props.historyTexture
+ );
+ const historyMetadata = graph.importTexture(
+ {
+ id: 'history-metadata',
+ format: 'rgba16float',
+ width: props.internalWidth,
+ height: props.internalHeight,
+ usage: Texture.SAMPLE | Texture.COPY_SRC | Texture.COPY_DST
+ },
+ props.historyMetadataTexture
+ );
+ const output = graph.createTransientTexture({
+ id: 'output',
+ format: 'rgba16float',
+ width: props.internalWidth,
+ height: props.internalHeight,
+ usage: Texture.STORAGE | Texture.SAMPLE | Texture.COPY_SRC | Texture.COPY_DST
+ });
+ const outputMetadata = graph.createTransientTexture({
+ id: 'output-metadata',
+ format: 'rgba16float',
+ width: props.internalWidth,
+ height: props.internalHeight,
+ usage: Texture.STORAGE | Texture.COPY_SRC | Texture.COPY_DST
+ });
+ const historyView = graph.createTextureView(history);
+ const historyMetadataView = graph.createTextureView(historyMetadata);
+ const outputView = graph.createTextureView(output);
+ const outputMetadataView = graph.createTextureView(outputMetadata);
+
+ graph.addCopyPass({
+ id: `${props.frameIdentifier}-prefill-ray-tracing-history`,
+ resources: [
+ {texture: historyView, usage: 'copy-source'},
+ {texture: outputView, usage: 'copy-destination'},
+ {texture: historyMetadataView, usage: 'copy-source'},
+ {texture: outputMetadataView, usage: 'copy-destination'}
+ ],
+ compile: () => ({
+ encode: ({commandEncoder, getTexture}) => {
+ commandEncoder.copyTextureToTexture({
+ sourceTexture: getTexture(historyView),
+ destinationTexture: getTexture(outputView),
+ width: props.internalWidth,
+ height: props.internalHeight
+ });
+ commandEncoder.copyTextureToTexture({
+ sourceTexture: getTexture(historyMetadataView),
+ destinationTexture: getTexture(outputMetadataView),
+ width: props.internalWidth,
+ height: props.internalHeight
+ });
+ }
+ })
+ });
+
graph.addComputePass({
id: `${props.frameIdentifier}-trace-rays`,
resources: [
@@ -400,7 +973,9 @@ export class RayTracingSceneRenderer {
{buffer: nodeMinima, usage: 'storage-read'},
{buffer: nodeMaxima, usage: 'storage-read'},
{texture: historyView, usage: 'sampled'},
- {texture: outputView, usage: 'storage-write'}
+ {texture: historyMetadataView, usage: 'sampled'},
+ {texture: outputView, usage: 'storage-write'},
+ {texture: outputMetadataView, usage: 'storage-write'}
],
compile: ({device}) => {
const computation = new Computation(device, {
@@ -421,11 +996,26 @@ export class RayTracingSceneRenderer {
location: 6,
sampleType: 'unfilterable-float'
},
+ {
+ name: 'historyMetadata',
+ type: 'texture',
+ group: 0,
+ location: 7,
+ sampleType: 'unfilterable-float'
+ },
{
name: 'outputImage',
type: 'storage',
group: 0,
- location: 7,
+ location: 8,
+ access: 'write-only',
+ format: 'rgba16float'
+ },
+ {
+ name: 'outputMetadata',
+ type: 'storage',
+ group: 0,
+ location: 9,
access: 'write-only',
format: 'rgba16float'
}
@@ -433,7 +1023,7 @@ export class RayTracingSceneRenderer {
}
});
return {
- encode: ({computePass, getBuffer, getTextureView}) => {
+ encode: ({computePass, getBuffer, getTextureView, parameters}) => {
computation.setBindings({
uniforms: getBuffer(uniforms),
primitives: getBuffer(primitives),
@@ -442,12 +1032,14 @@ export class RayTracingSceneRenderer {
nodeMinima: getViewBinding(nodeMinima, getBuffer),
nodeMaxima: getViewBinding(nodeMaxima, getBuffer),
historyImage: getTextureView(historyView),
- outputImage: getTextureView(outputView)
+ historyMetadata: getTextureView(historyMetadataView),
+ outputImage: getTextureView(outputView),
+ outputMetadata: getTextureView(outputMetadataView)
});
computation.dispatch(
computePass,
- Math.ceil(props.width / 8),
- Math.ceil(props.height / 8),
+ Math.ceil(parameters.dispatchWidth / 8),
+ Math.ceil(props.internalHeight / 8),
1
);
},
@@ -496,15 +1088,23 @@ export class RayTracingSceneRenderer {
id: `${props.frameIdentifier}-remember-ray-tracing`,
resources: [
{texture: outputView, usage: 'copy-source'},
- {texture: historyView, usage: 'copy-destination'}
+ {texture: historyView, usage: 'copy-destination'},
+ {texture: outputMetadataView, usage: 'copy-source'},
+ {texture: historyMetadataView, usage: 'copy-destination'}
],
compile: () => ({
encode: ({commandEncoder, getTexture}) => {
commandEncoder.copyTextureToTexture({
sourceTexture: getTexture(outputView),
destinationTexture: getTexture(historyView),
- width: props.width,
- height: props.height
+ width: props.internalWidth,
+ height: props.internalHeight
+ });
+ commandEncoder.copyTextureToTexture({
+ sourceTexture: getTexture(outputMetadataView),
+ destinationTexture: getTexture(historyMetadataView),
+ width: props.internalWidth,
+ height: props.internalHeight
});
}
})
@@ -514,29 +1114,60 @@ export class RayTracingSceneRenderer {
}
}
-function getSceneRevision(options: RayTracingSceneRenderOptions): string {
- const surfaceRevisions = options.surfaces.map(surface => [
- surface.id,
- surface.geometry.id,
- surface.geometryVersion,
- surface.material.id,
- surface.material.version,
- surface.transforms.map(transform => Array.from(transform)),
- surface.morphWeights,
- options.primitives?.[surface.id]
- ]);
- return JSON.stringify([surfaceRevisions, options.lights]);
+function getTopologyRevision(options: RayTracingSceneRenderOptions): string {
+ return JSON.stringify(
+ options.surfaces.map(surface => [
+ surface.id,
+ surface.geometry.id,
+ surface.geometryVersion,
+ surface.transforms.length,
+ surface.morphWeights,
+ options.primitives?.[surface.id]
+ ])
+ );
+}
+
+function getPrimitiveRevision(options: RayTracingSceneRenderOptions): string {
+ return JSON.stringify(
+ options.surfaces.map(surface => [
+ surface.id,
+ surface.material.id,
+ surface.material.version,
+ surface.material.uniforms,
+ surface.transforms.map(transform => Array.from(transform)),
+ (surface as RayTracingSceneSurface).instanceIds,
+ options.primitives?.[surface.id]
+ ])
+ );
+}
+
+function getTransformRevision(options: RayTracingSceneRenderOptions): string {
+ return JSON.stringify(
+ options.surfaces.map(surface => [
+ surface.id,
+ surface.transforms.map(transform => Array.from(transform)),
+ (surface as RayTracingSceneSurface).instanceIds
+ ])
+ );
+}
+
+function getLightRevision(lights: readonly Light[]): string {
+ return JSON.stringify(lights);
}
function getRenderRevision(
options: RayTracingSceneRenderOptions,
inverseViewProjection: Matrix4
): string {
- // Scene adapters may recommit an unchanged camera every animation tick.
+ // Scene adapters may recommit an unchanged camera every animation tick. Temporal mode keeps
+ // ordinary camera motion out of this reset key and rejects incompatible history in the shader.
+ const cameraRevision =
+ (options.temporalReprojection ?? true)
+ ? undefined
+ : [Array.from(inverseViewProjection), Array.from(options.camera.position)];
return JSON.stringify([
options.cameraProjection,
- Array.from(inverseViewProjection),
- Array.from(options.camera.position),
+ cameraRevision,
options.background,
options.exposure,
options.fogColor,
@@ -544,37 +1175,73 @@ function getRenderRevision(
options.samplesPerPixel,
options.maxBounces,
options.progressive,
- options.shadows
+ options.shadows,
+ options.temporalReprojection,
+ options.shadowSamplesPerFrame
]);
}
-function makeRayTracingScene(
+function makeRayTracingTopology(
surfaces: readonly SceneSurface[],
- lights: readonly Light[],
- primitives: Readonly>
-): RayTracingScene {
- const primitiveValues: number[] = [];
+ primitives: Readonly>,
+ geometryCache: Map
+): RayTracingTopology {
const triangleValues: number[] = [];
- const compiledGeometries = new Map();
+ const geometryLayouts = new Map();
+
+ for (const surface of surfaces) {
+ if (primitives[surface.id]?.type === 'sphere') {
+ continue;
+ }
+ const geometryIdentifier = getGeometryIdentifier(surface);
+ if (geometryLayouts.has(geometryIdentifier)) {
+ continue;
+ }
+ const compiledGeometry = compileRayGeometry(surface, geometryCache);
+ const triangleStart = triangleValues.length / TRIANGLE_FLOAT_COUNT;
+ triangleValues.push(...compiledGeometry.triangles);
+ geometryLayouts.set(geometryIdentifier, {
+ triangleStart,
+ triangleCount: compiledGeometry.triangleCount,
+ bounds: compiledGeometry.bounds
+ });
+ }
+
+ return {
+ triangles: makeStorageData(triangleValues, TRIANGLE_FLOAT_COUNT),
+ geometryLayouts
+ };
+}
+
+function makePrimitiveData(
+ surfaces: readonly SceneSurface[],
+ primitives: Readonly>,
+ geometryLayouts: Map,
+ previousTransforms: Map
+): RayTracingPrimitiveData {
+ const primitiveValues: number[] = [];
+ const nextPreviousTransforms = new Map();
let triangleCount = 0;
for (const surface of surfaces) {
const primitive = primitives[surface.id];
const sphereRadius = primitive?.type === 'sphere' ? primitive.radius : 0;
- const compiledGeometry =
- sphereRadius > 0
- ? undefined
- : compileRayGeometry(surface, compiledGeometries, triangleValues);
- const bounds = compiledGeometry?.bounds ?? [0, 0, 0, sphereRadius];
+ const geometryLayout =
+ sphereRadius > 0 ? undefined : geometryLayouts.get(getGeometryIdentifier(surface));
+ const bounds = geometryLayout?.bounds ?? [0, 0, 0, sphereRadius];
const materialUniforms = surface.material.uniforms;
const baseColor = materialUniforms?.baseColorFactor ?? [0.8, 0.8, 0.8, 1];
const emissive = materialUniforms?.emissiveFactor ?? [0, 0, 0];
const emissiveStrength = materialUniforms?.emissiveStrength ?? 1;
const metallicRoughness = materialUniforms?.metallicRoughnessValues ?? [0, 0.5];
+ const instanceIds = (surface as RayTracingSceneSurface).instanceIds;
- for (const sourceTransform of surface.transforms) {
- const transform = new Matrix4(sourceTransform);
+ for (let transformIndex = 0; transformIndex < surface.transforms.length; transformIndex++) {
+ const transform = new Matrix4(surface.transforms[transformIndex]);
const inverseTransform = new Matrix4(transform).invert();
+ const instanceIdentifier = instanceIds?.[transformIndex] ?? String(transformIndex);
+ const placementIdentifier = `${surface.id}:${instanceIdentifier}`;
+ const previousTransform = previousTransforms.get(placementIdentifier) ?? transform;
primitiveValues.push(
...transform,
...inverseTransform,
@@ -588,49 +1255,63 @@ function makeRayTracingScene(
metallicRoughness[0],
metallicRoughness[1],
sphereRadius,
- compiledGeometry?.triangleStart ?? 0,
- compiledGeometry?.triangleCount ?? 0,
+ geometryLayout?.triangleStart ?? 0,
+ geometryLayout?.triangleCount ?? 0,
bounds[0],
bounds[1],
bounds[2],
- bounds[3]
+ bounds[3],
+ ...previousTransform
);
- triangleCount += compiledGeometry?.triangleCount ?? 0;
+ nextPreviousTransforms.set(placementIdentifier, new Matrix4(transform));
+ triangleCount += geometryLayout?.triangleCount ?? 0;
}
}
return {
primitives: makeStorageData(primitiveValues, PRIMITIVE_FLOAT_COUNT),
- triangles: makeStorageData(triangleValues, TRIANGLE_FLOAT_COUNT),
- lights: makeLightData(lights),
primitiveCount: primitiveValues.length / PRIMITIVE_FLOAT_COUNT,
+ triangleCount,
+ previousTransforms: nextPreviousTransforms
+ };
+}
+
+function makeRayTracingScene(
+ primitiveData: RayTracingPrimitiveData,
+ triangles: Float32Array,
+ lights: readonly Light[]
+): RayTracingScene {
+ return {
+ primitives: primitiveData.primitives,
+ triangles,
+ lights: makeLightData(lights),
+ primitiveCount: primitiveData.primitiveCount,
lightCount: lights.length,
- triangleCount
+ triangleCount: primitiveData.triangleCount
};
}
function compileRayGeometry(
surface: SceneSurface,
- compiledGeometries: Map,
- triangleValues: number[]
+ geometryCache: Map
): CompiledRayGeometry {
- const engineGeometry = surface.geometry;
- const geometryIdentifier = `${engineGeometry.id}:${surface.geometryVersion ?? 0}`;
- const cachedGeometry = compiledGeometries.get(geometryIdentifier);
+ const geometryIdentifier = getGeometryIdentifier(surface);
+ const cachedGeometry = geometryCache.get(geometryIdentifier);
if (cachedGeometry) {
return cachedGeometry;
}
+ const engineGeometry = surface.geometry;
const positions = engineGeometry.attributes['POSITION']?.value;
const normals = engineGeometry.attributes['NORMAL']?.value;
if (!positions || !normals) {
throw new Error('Ray tracing scene geometry requires positions and normals.');
}
+ const triangleValues: number[] = [];
const bounds = getGeometryBounds(engineGeometry);
const indices = engineGeometry.indices?.value;
const vertexCount = indices?.length ?? positions.length / 3;
- const triangleStart = triangleValues.length / TRIANGLE_FLOAT_COUNT;
for (let vertexIndex = 0; vertexIndex + 2 < vertexCount; vertexIndex += 3) {
for (let cornerIndex = 0; cornerIndex < 3; cornerIndex++) {
const positionIndex =
@@ -655,14 +1336,18 @@ function compileRayGeometry(
}
const compiledGeometry: CompiledRayGeometry = {
- triangleStart,
- triangleCount: triangleValues.length / TRIANGLE_FLOAT_COUNT - triangleStart,
+ triangles: new Float32Array(triangleValues),
+ triangleCount: triangleValues.length / TRIANGLE_FLOAT_COUNT,
bounds
};
- compiledGeometries.set(geometryIdentifier, compiledGeometry);
+ geometryCache.set(geometryIdentifier, compiledGeometry);
return compiledGeometry;
}
+function getGeometryIdentifier(surface: SceneSurface): string {
+ return `${surface.geometry.id}:${surface.geometryVersion ?? 0}`;
+}
+
function getGeometryBounds(geometry: Geometry): readonly [number, number, number, number] {
const positions = geometry.attributes['POSITION']?.value;
if (!positions || positions.length === 0) {
@@ -743,13 +1428,21 @@ function makeStorageData(values: number[], minimumFloatCount: number): Float32Ar
function makeUniformData(props: {
options: RayTracingSceneRenderOptions;
inverseViewProjection: Matrix4;
- width: number;
- height: number;
+ previousViewProjection: Matrix4;
+ previousCameraPosition: readonly number[];
+ displayWidth: number;
+ displayHeight: number;
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ phaseIndex: number;
+ phaseCount: number;
primitiveCount: number;
primitiveCapacity: number;
leafCapacity: number;
lightCount: number;
accumulatedFrameCount: number;
+ frameIndex: number;
}): Float32Array {
const data = new Float32Array(UNIFORM_FLOAT_COUNT);
const unsignedData = new Uint32Array(data.buffer);
@@ -760,8 +1453,8 @@ function makeUniformData(props: {
data.set(props.options.camera.position, 16);
data[19] = props.options.cameraProjection === 'orthographic' ? 1 : 0;
data.set(background, 20);
- unsignedData[24] = props.width;
- unsignedData[25] = props.height;
+ unsignedData[24] = props.internalWidth;
+ unsignedData[25] = props.internalHeight;
unsignedData[26] = props.primitiveCount;
unsignedData[27] = props.lightCount;
data[28] = props.options.exposure ?? 1.35;
@@ -773,6 +1466,230 @@ function makeUniformData(props: {
unsignedData[36] = props.leafCapacity - 1;
unsignedData[37] = props.leafCapacity;
unsignedData[38] = props.primitiveCapacity;
- unsignedData[39] = 0;
+ unsignedData[39] = props.frameIndex;
+ unsignedData[40] = props.displayWidth;
+ unsignedData[41] = props.displayHeight;
+ unsignedData[42] = props.phaseIndex;
+ unsignedData[43] = props.phaseCount;
+ data[44] = props.resolutionScale;
+ data[45] = 1 / props.phaseCount;
+ data[46] = props.options.shadowSamplesPerFrame ?? 1;
+ data[47] = (props.options.temporalReprojection ?? true) ? 1 : 0;
+ data.set(props.previousViewProjection, 48);
+ data.set(props.previousCameraPosition, 64);
+ data[67] = 1;
return data;
}
+
+function createImportedView(
+ graph: GPUCommandGraph,
+ identifier: string,
+ buffer: Buffer,
+ format: Format,
+ length: number
+): GraphDataView {
+ const handle = graph.importBuffer(
+ {id: identifier, byteLength: buffer.byteLength, usage: buffer.usage},
+ buffer
+ );
+ return graph.createDataView(handle, {format, length});
+}
+
+function getQualityOptions(options: RayTracingSceneRenderOptions): RayTracingQualityOptions {
+ const adaptiveResolution = options.adaptiveResolution ?? true;
+ const minimumResolutionScale = clampResolutionScale(
+ options.minimumResolutionScale ?? DEFAULT_MINIMUM_RESOLUTION_SCALE,
+ 0.125,
+ 1
+ );
+ const requestedResolutionScale = clampResolutionScale(
+ options.resolutionScale ?? DEFAULT_RESOLUTION_SCALE,
+ minimumResolutionScale,
+ 1
+ );
+ return {
+ resolutionScale: adaptiveResolution
+ ? getClosestResolutionScale(requestedResolutionScale, minimumResolutionScale)
+ : requestedResolutionScale,
+ minimumResolutionScale,
+ adaptiveResolution,
+ targetFrameTimeMilliseconds: Math.max(
+ 1,
+ options.targetFrameTimeMilliseconds ?? DEFAULT_TARGET_FRAME_TIME_MILLISECONDS
+ )
+ };
+}
+
+function updateQualityOptions(
+ resources: RayTracingFrameResources,
+ quality: RayTracingQualityOptions
+): boolean {
+ if (
+ resources.minimumResolutionScale === quality.minimumResolutionScale &&
+ resources.adaptiveResolution === quality.adaptiveResolution &&
+ resources.targetFrameTimeMilliseconds === quality.targetFrameTimeMilliseconds &&
+ resources.requestedResolutionScale === quality.resolutionScale
+ ) {
+ return false;
+ }
+ resources.resolutionScale = quality.resolutionScale;
+ resources.requestedResolutionScale = quality.resolutionScale;
+ resources.minimumResolutionScale = quality.minimumResolutionScale;
+ resources.adaptiveResolution = quality.adaptiveResolution;
+ resources.targetFrameTimeMilliseconds = quality.targetFrameTimeMilliseconds;
+ resources.phaseCount = 1;
+ resources.phaseIndex = 0;
+ resources.overBudgetFrameCount = 0;
+ resources.underBudgetFrameCount = 0;
+ return true;
+}
+
+function updateFrameTiming(
+ resources: RayTracingFrameResources,
+ currentTimeMilliseconds: number
+): void {
+ const previousTimeMilliseconds = resources.lastRenderTimeMilliseconds;
+ resources.lastRenderTimeMilliseconds = currentTimeMilliseconds;
+ if (previousTimeMilliseconds === undefined) {
+ return;
+ }
+ const frameTimeMilliseconds = currentTimeMilliseconds - previousTimeMilliseconds;
+ if (frameTimeMilliseconds <= 0 || frameTimeMilliseconds > 1000) {
+ return;
+ }
+ resources.averageFrameTimeMilliseconds =
+ resources.averageFrameTimeMilliseconds === undefined
+ ? frameTimeMilliseconds
+ : resources.averageFrameTimeMilliseconds * 0.8 + frameTimeMilliseconds * 0.2;
+}
+
+function updateAdaptiveBudget(
+ resources: RayTracingFrameResources,
+ currentTimeMilliseconds: number
+): void {
+ if (!resources.adaptiveResolution || resources.averageFrameTimeMilliseconds === undefined) {
+ return;
+ }
+ const frameTimeMilliseconds = resources.averageFrameTimeMilliseconds;
+ const targetFrameTimeMilliseconds = resources.targetFrameTimeMilliseconds;
+ if (frameTimeMilliseconds > targetFrameTimeMilliseconds * 1.1) {
+ resources.overBudgetFrameCount++;
+ resources.underBudgetFrameCount = 0;
+ } else if (frameTimeMilliseconds < targetFrameTimeMilliseconds * 0.75) {
+ resources.underBudgetFrameCount++;
+ resources.overBudgetFrameCount = 0;
+ } else {
+ resources.overBudgetFrameCount = 0;
+ resources.underBudgetFrameCount = 0;
+ }
+ if (
+ currentTimeMilliseconds - resources.lastBudgetAdjustmentTimeMilliseconds <
+ FRAME_BUDGET_COOLDOWN_MILLISECONDS
+ ) {
+ return;
+ }
+
+ if (resources.overBudgetFrameCount >= 3) {
+ const lowerResolutionScale = getAdjacentResolutionScale(
+ resources.resolutionScale,
+ resources.minimumResolutionScale,
+ -1
+ );
+ if (lowerResolutionScale < resources.resolutionScale) {
+ resources.resolutionScale = lowerResolutionScale;
+ } else {
+ resources.phaseCount = Math.min(4, resources.phaseCount * 2);
+ resources.phaseIndex = 0;
+ }
+ resources.overBudgetFrameCount = 0;
+ resources.lastBudgetAdjustmentTimeMilliseconds = currentTimeMilliseconds;
+ return;
+ }
+
+ if (resources.underBudgetFrameCount >= 20) {
+ if (resources.phaseCount > 1) {
+ resources.phaseCount = Math.max(1, resources.phaseCount / 2);
+ resources.phaseIndex = 0;
+ } else {
+ resources.resolutionScale = getAdjacentResolutionScale(
+ resources.resolutionScale,
+ resources.minimumResolutionScale,
+ 1
+ );
+ }
+ resources.underBudgetFrameCount = 0;
+ resources.lastBudgetAdjustmentTimeMilliseconds = currentTimeMilliseconds;
+ }
+}
+
+function getInternalDimensions(
+ displayWidth: number,
+ displayHeight: number,
+ resolutionScale: number
+): {width: number; height: number} {
+ return {
+ width: Math.max(1, Math.ceil(displayWidth * resolutionScale)),
+ height: Math.max(1, Math.ceil(displayHeight * resolutionScale))
+ };
+}
+
+function getClosestResolutionScale(
+ requestedResolutionScale: number,
+ minimumResolutionScale: number
+): number {
+ const scales = getAvailableResolutionScales(minimumResolutionScale);
+ return scales.reduce((closestScale, scale) =>
+ Math.abs(scale - requestedResolutionScale) < Math.abs(closestScale - requestedResolutionScale)
+ ? scale
+ : closestScale
+ );
+}
+
+function getAdjacentResolutionScale(
+ currentResolutionScale: number,
+ minimumResolutionScale: number,
+ direction: -1 | 1
+): number {
+ const scales = getAvailableResolutionScales(minimumResolutionScale);
+ const currentIndex = scales.findIndex(scale => scale >= currentResolutionScale - 0.0001);
+ const index = currentIndex < 0 ? scales.length - 1 : currentIndex;
+ return scales[Math.max(0, Math.min(scales.length - 1, index + direction))];
+}
+
+function getAvailableResolutionScales(minimumResolutionScale: number): number[] {
+ const scales = RESOLUTION_SCALES.filter(scale => scale >= minimumResolutionScale);
+ return scales.length > 0 ? [...scales] : [minimumResolutionScale];
+}
+
+function clampResolutionScale(value: number, minimum: number, maximum: number): number {
+ return Math.max(minimum, Math.min(maximum, Number.isFinite(value) ? value : minimum));
+}
+
+function getSamplesPerPixel(options: RayTracingSceneRenderOptions): number {
+ return Math.max(1, Math.min(16, Math.floor(options.samplesPerPixel ?? 1)));
+}
+
+function isCameraCut(
+ previousViewProjection: Matrix4,
+ viewProjection: Matrix4,
+ previousCameraPosition: readonly number[],
+ cameraPosition: Readonly
+): boolean {
+ let maximumMatrixDifference = 0;
+ for (let index = 0; index < 16; index++) {
+ maximumMatrixDifference = Math.max(
+ maximumMatrixDifference,
+ Math.abs(Number(previousViewProjection[index]) - Number(viewProjection[index]))
+ );
+ }
+ const cameraDistance = Math.hypot(
+ Number(previousCameraPosition[0] ?? 0) - Number(cameraPosition[0] ?? 0),
+ Number(previousCameraPosition[1] ?? 0) - Number(cameraPosition[1] ?? 0),
+ Number(previousCameraPosition[2] ?? 0) - Number(cameraPosition[2] ?? 0)
+ );
+ return maximumMatrixDifference > 0.75 || cameraDistance > 4;
+}
+
+function getTimestampMilliseconds(): number {
+ return globalThis.performance?.now() ?? Date.now();
+}
diff --git a/modules/experimental/src/engine/ray-tracing-scene-shaders.ts b/modules/experimental/src/engine/ray-tracing-scene-shaders.ts
index e6fccc7880..3204a21082 100644
--- a/modules/experimental/src/engine/ray-tracing-scene-shaders.ts
+++ b/modules/experimental/src/engine/ray-tracing-scene-shaders.ts
@@ -11,6 +11,10 @@ struct RayTracingUniforms {
settings: vec4,
fog: vec4,
acceleration: vec4,
+ displayPhase: vec4,
+ temporal: vec4,
+ previousViewProjection: mat4x4,
+ previousCameraPosition: vec4,
};
struct RayPrimitive {
@@ -20,6 +24,7 @@ struct RayPrimitive {
emissive: vec4,
properties: vec4,
bounds: vec4,
+ previousTransform: mat4x4,
};
`;
@@ -109,6 +114,12 @@ struct RayHit {
primitiveIndex: u32,
};
+struct HistoricalRaySample {
+ color: vec3,
+ sampleCount: f32,
+ valid: bool,
+};
+
@group(0) @binding(0) var uniforms: RayTracingUniforms;
@group(0) @binding(1) var primitives: array;
@group(0) @binding(2) var triangles: array;
@@ -116,12 +127,17 @@ struct RayHit {
@group(0) @binding(4) var nodeMinima: array;
@group(0) @binding(5) var nodeMaxima: array;
@group(0) @binding(6) var historyImage: texture_2d;
-@group(0) @binding(7) var outputImage: texture_storage_2d;
+@group(0) @binding(7) var historyMetadata: texture_2d;
+@group(0) @binding(8) var outputImage: texture_storage_2d;
+@group(0) @binding(9) var outputMetadata: texture_storage_2d;
const RAY_EPSILON = 0.0005;
const RAY_INFINITY = 1.0e20;
const PI = 3.141592653589793;
const BVH_STACK_CAPACITY = 32u;
+const MAXIMUM_HISTORY_SAMPLES = 64.0;
+const MINIMUM_HISTORY_NORMAL_ALIGNMENT = 0.75;
+const MAXIMUM_HISTORY_RELATIVE_DEPTH_DIFFERENCE = 0.06;
fn makeRandom(seed: u32) -> f32 {
var value = seed * 747796405u + 2891336453u;
@@ -131,9 +147,10 @@ fn makeRandom(seed: u32) -> f32 {
}
fn makeCameraRay(pixel: vec2, sampleIndex: u32) -> Ray {
- let frameIndex = u32(uniforms.settings.y);
+ let frameIndex = uniforms.acceleration.w;
let pixelIndex = pixel.y * uniforms.dimensions.x + pixel.x;
- let seed = pixelIndex * 1973u + frameIndex * 9277u + sampleIndex * 26699u + 17u;
+ let seed = pixelIndex * 1973u + frameIndex * 9277u + sampleIndex * 26699u +
+ uniforms.displayPhase.z * 3181u + 17u;
let offset = vec2(makeRandom(seed), makeRandom(seed + 101u));
let coordinates = (vec2(pixel) + offset) / vec2(uniforms.dimensions.xy);
let clipCoordinates = vec2(coordinates.x * 2.0 - 1.0, 1.0 - coordinates.y * 2.0);
@@ -410,6 +427,20 @@ fn evaluateDirectLighting(ray: Ray, hit: RayHit) -> vec3 {
let roughness = clamp(primitive.properties.x, 0.04, 1.0);
let reflectance = mix(vec3(0.04), baseColor, metallic);
var result = primitive.emissive.rgb;
+ var directLightCount = 0u;
+ for (var lightIndex = 0u; lightIndex < uniforms.dimensions.w; lightIndex++) {
+ if (u32(lights[lightIndex].directionType.w) != 0u) {
+ directLightCount++;
+ }
+ }
+ let requestedShadowSamples = u32(max(uniforms.temporal.z, 0.0));
+ let shadowSampleCount = select(
+ min(requestedShadowSamples, directLightCount),
+ directLightCount,
+ requestedShadowSamples == 0u || uniforms.settings.w <= 0.5
+ );
+ let rotatingLightOffset = uniforms.acceleration.w % max(directLightCount, 1u);
+ var directLightIndex = 0u;
for (var lightIndex = 0u; lightIndex < uniforms.dimensions.w; lightIndex++) {
let light = lights[lightIndex];
@@ -420,6 +451,13 @@ fn evaluateDirectLighting(ray: Ray, hit: RayHit) -> vec3 {
continue;
}
+ let rotatingLightIndex = (directLightIndex + directLightCount - rotatingLightOffset) %
+ max(directLightCount, 1u);
+ directLightIndex++;
+ if (rotatingLightIndex >= shadowSampleCount) {
+ continue;
+ }
+
var lightDirection = normalize(-light.directionType.xyz);
var lightDistance = RAY_INFINITY;
var attenuation = 1.0;
@@ -457,7 +495,8 @@ fn evaluateDirectLighting(ray: Ray, hit: RayHit) -> vec3 {
let specularPower = mix(128.0, 4.0, roughness);
let specular = fresnel * pow(normalHalf, specularPower) * (specularPower + 2.0) / (2.0 * PI);
let diffuse = baseColor * (1.0 - metallic) / PI;
- result += (diffuse + specular) * lightColor * normalLight * attenuation;
+ let lightSampleWeight = f32(directLightCount) / f32(max(shadowSampleCount, 1u));
+ result += (diffuse + specular) * lightColor * normalLight * attenuation * lightSampleWeight;
}
if (uniforms.fog.w > 0.0) {
@@ -467,18 +506,127 @@ fn evaluateDirectLighting(ray: Ray, hit: RayHit) -> vec3 {
return result;
}
+fn rejectHistoricalRaySample() -> HistoricalRaySample {
+ return HistoricalRaySample(vec3(0.0), 0.0, false);
+}
+
+fn clampHistoricalRayColor(
+ historyPixel: vec2,
+ historicalColor: vec3,
+ currentColor: vec3
+) -> vec3 {
+ let maximumPixel = vec2(uniforms.dimensions.xy) - vec2(1);
+ var minimumColor = currentColor;
+ var maximumColor = currentColor;
+ for (var verticalOffset = -1; verticalOffset <= 1; verticalOffset++) {
+ for (var horizontalOffset = -1; horizontalOffset <= 1; horizontalOffset++) {
+ let neighborhoodPixel = clamp(
+ historyPixel + vec2(horizontalOffset, verticalOffset),
+ vec2(0),
+ maximumPixel
+ );
+ let neighborhoodColor = textureLoad(historyImage, neighborhoodPixel, 0);
+ if (neighborhoodColor.a > 0.0) {
+ minimumColor = min(minimumColor, neighborhoodColor.rgb);
+ maximumColor = max(maximumColor, neighborhoodColor.rgb);
+ }
+ }
+ }
+ let neighborhoodRadius = max((maximumColor - minimumColor) * 0.5, vec3(0.04));
+ return clamp(historicalColor, currentColor - neighborhoodRadius, currentColor + neighborhoodRadius);
+}
+
+fn getHistoricalRaySample(
+ pixel: vec2,
+ ray: Ray,
+ hit: RayHit,
+ currentColor: vec3
+) -> HistoricalRaySample {
+ if (uniforms.settings.y <= 0.0) {
+ return rejectHistoricalRaySample();
+ }
+
+ var historyPixel = vec2(pixel);
+ var previousDistance = distance(
+ ray.origin + ray.direction * min(hit.distance, 65504.0),
+ uniforms.cameraPosition.xyz
+ );
+ if (hit.distance < RAY_INFINITY && uniforms.temporal.w > 0.5) {
+ let primitive = primitives[hit.primitiveIndex];
+ let hitPosition = ray.origin + ray.direction * hit.distance;
+ let localHitPosition = primitive.inverseTransform * vec4(hitPosition, 1.0);
+ let previousHitPosition = (primitive.previousTransform * localHitPosition).xyz;
+ let previousClipPosition = uniforms.previousViewProjection *
+ vec4(previousHitPosition, 1.0);
+ if (previousClipPosition.w <= RAY_EPSILON) {
+ return rejectHistoricalRaySample();
+ }
+
+ let previousNormalizedPosition = previousClipPosition.xy / previousClipPosition.w;
+ let previousTextureCoordinates = vec2(
+ previousNormalizedPosition.x * 0.5 + 0.5,
+ 0.5 - previousNormalizedPosition.y * 0.5
+ );
+ if (any(previousTextureCoordinates < vec2(0.0)) ||
+ any(previousTextureCoordinates >= vec2(1.0))) {
+ return rejectHistoricalRaySample();
+ }
+
+ historyPixel = min(
+ vec2(previousTextureCoordinates * vec2(uniforms.dimensions.xy)),
+ vec2(uniforms.dimensions.xy) - vec2(1)
+ );
+ previousDistance = distance(previousHitPosition, uniforms.previousCameraPosition.xyz);
+ }
+
+ let historicalMetadata = textureLoad(historyMetadata, historyPixel, 0);
+ if (hit.distance >= RAY_INFINITY) {
+ if (historicalMetadata.a > RAY_EPSILON) {
+ return rejectHistoricalRaySample();
+ }
+ } else {
+ if (historicalMetadata.a <= RAY_EPSILON ||
+ dot(normalize(historicalMetadata.xyz), hit.normal) < MINIMUM_HISTORY_NORMAL_ALIGNMENT) {
+ return rejectHistoricalRaySample();
+ }
+ let relativeDepthDifference = abs(historicalMetadata.a - previousDistance) /
+ max(previousDistance, RAY_EPSILON);
+ if (relativeDepthDifference > MAXIMUM_HISTORY_RELATIVE_DEPTH_DIFFERENCE) {
+ return rejectHistoricalRaySample();
+ }
+ }
+
+ let historicalColor = textureLoad(historyImage, historyPixel, 0);
+ if (historicalColor.a <= 0.0) {
+ return rejectHistoricalRaySample();
+ }
+ return HistoricalRaySample(
+ clampHistoricalRayColor(historyPixel, historicalColor.rgb, currentColor),
+ min(historicalColor.a, MAXIMUM_HISTORY_SAMPLES),
+ true
+ );
+}
+
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) invocation: vec3) {
- let pixel = invocation.xy;
+ let phaseCount = max(uniforms.displayPhase.w, 1u);
+ let phaseOffset = (uniforms.displayPhase.z + invocation.y) % phaseCount;
+ let pixel = vec2(invocation.x * phaseCount + phaseOffset, invocation.y);
if (pixel.x >= uniforms.dimensions.x || pixel.y >= uniforms.dimensions.y) {
return;
}
let sampleCount = clamp(u32(uniforms.settings.z), 1u, 16u);
+ let primaryRay = makeCameraRay(pixel, 0u);
+ let primaryHit = intersectScene(primaryRay, RAY_INFINITY);
var accumulatedColor = vec3(0.0);
for (var sampleIndex = 0u; sampleIndex < sampleCount; sampleIndex++) {
- let ray = makeCameraRay(pixel, sampleIndex);
- let hit = intersectScene(ray, RAY_INFINITY);
+ var ray = primaryRay;
+ var hit = primaryHit;
+ if (sampleIndex > 0u) {
+ ray = makeCameraRay(pixel, sampleIndex);
+ hit = intersectScene(ray, RAY_INFINITY);
+ }
var color = uniforms.background.rgb;
if (hit.distance < RAY_INFINITY) {
color = evaluateDirectLighting(ray, hit);
@@ -487,12 +635,28 @@ fn main(@builtin(global_invocation_id) invocation: vec3) {
}
var color = accumulatedColor / f32(sampleCount) * uniforms.settings.x;
- let frameIndex = uniforms.settings.y;
- if (frameIndex > 0.0) {
- let historicalColor = textureLoad(historyImage, vec2(pixel), 0).rgb;
- color = (historicalColor * frameIndex + color) / (frameIndex + 1.0);
+ let historicalSample = getHistoricalRaySample(pixel, primaryRay, primaryHit, color);
+ var totalSampleCount = f32(sampleCount);
+ if (historicalSample.valid) {
+ totalSampleCount = min(
+ historicalSample.sampleCount + f32(sampleCount),
+ MAXIMUM_HISTORY_SAMPLES
+ );
+ let currentWeight = f32(sampleCount) / totalSampleCount;
+ color = mix(historicalSample.color, color, currentWeight);
}
- textureStore(outputImage, vec2(pixel), vec4(color, 1.0));
+ let primaryHitPosition = primaryRay.origin +
+ primaryRay.direction * min(primaryHit.distance, 65504.0);
+ let metadata = select(
+ vec4(0.0),
+ vec4(
+ primaryHit.normal,
+ min(distance(primaryHitPosition, uniforms.cameraPosition.xyz), 65504.0)
+ ),
+ primaryHit.distance < RAY_INFINITY
+ );
+ textureStore(outputImage, vec2(pixel), vec4(color, totalSampleCount));
+ textureStore(outputMetadata, vec2(pixel), metadata);
}
`;
@@ -501,24 +665,51 @@ export function getRayTracingScenePresentationShader(highDynamicRange: boolean):
return /* wgsl */ `
@group(0) @binding(0) var image: texture_2d;
+struct PresentationVertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) textureCoordinates: vec2,
+};
+
@vertex
-fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4 {
+fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> PresentationVertexOutput {
let positions = array, 3>(
vec2(-1.0, -1.0),
vec2(3.0, -1.0),
vec2(-1.0, 3.0)
);
- return vec4(positions[vertexIndex], 0.0, 1.0);
+ let position = positions[vertexIndex];
+ var output: PresentationVertexOutput;
+ output.position = vec4(position, 0.0, 1.0);
+ output.textureCoordinates = vec2(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5);
+ return output;
+}
+
+fn sampleRayTracingImage(textureCoordinates: vec2) -> vec3 {
+ let dimensions = textureDimensions(image);
+ let maximumPixel = vec2(dimensions) - vec2(1);
+ let samplePosition = clamp(
+ textureCoordinates * vec2(dimensions) - vec2(0.5),
+ vec2(0.0),
+ vec2(maximumPixel)
+ );
+ let firstPixel = vec2(floor(samplePosition));
+ let secondPixel = min(firstPixel + vec2(1), maximumPixel);
+ let fraction = fract(samplePosition);
+ let topLeft = textureLoad(image, firstPixel, 0).rgb;
+ let topRight = textureLoad(image, vec2(secondPixel.x, firstPixel.y), 0).rgb;
+ let bottomLeft = textureLoad(image, vec2(firstPixel.x, secondPixel.y), 0).rgb;
+ let bottomRight = textureLoad(image, secondPixel, 0).rgb;
+ return mix(mix(topLeft, topRight, fraction.x), mix(bottomLeft, bottomRight, fraction.x), fraction.y);
}
@fragment
-fn fragmentMain(@builtin(position) position: vec4) -> @location(0) vec4 {
- let radiance = textureLoad(image, vec2(position.xy), 0);
+fn fragmentMain(@location(0) textureCoordinates: vec2) -> @location(0) vec4 {
+ let radiance = sampleRayTracingImage(textureCoordinates);
if (${highDynamicRange}) {
- return radiance;
+ return vec4(radiance, 1.0);
}
- let mappedColor = vec3(1.0) - exp(-radiance.rgb);
- return vec4(pow(max(mappedColor, vec3(0.0)), vec3(1.0 / 2.2)), radiance.a);
+ let mappedColor = vec3(1.0) - exp(-radiance);
+ return vec4(pow(max(mappedColor, vec3(0.0)), vec3(1.0 / 2.2)), 1.0);
}
`;
}
diff --git a/modules/experimental/src/engine/scene-renderer.ts b/modules/experimental/src/engine/scene-renderer.ts
index d8662b332c..9f5e02a36f 100644
--- a/modules/experimental/src/engine/scene-renderer.ts
+++ b/modules/experimental/src/engine/scene-renderer.ts
@@ -72,6 +72,8 @@ export type SceneSurface = {
material: SceneMaterial;
/** Column-major world matrices; all placements remain in a single instanced draw. */
transforms: readonly Readonly[];
+ /** Optional stable placement identities aligned one-to-one with transforms. */
+ instanceIds?: readonly string[];
/** Optional adapter-owned joint palette for geometry with JOINTS_0 and WEIGHTS_0. */
skin?: SkinProps;
/** Immutable glTF-style displacement attributes for each morph target. */
@@ -146,6 +148,15 @@ export type SceneRenderStatistics = {
instanceCount: number;
drawCount: number;
triangleCount: number;
+ /** Optional interactive quality and history diagnostics for ray-traced frames. */
+ rayTracing?: {
+ internalWidth: number;
+ internalHeight: number;
+ resolutionScale: number;
+ sampledPixelCoverage: number;
+ frameTimeMilliseconds: number;
+ accumulatedSamples: number;
+ };
};
type CompiledSceneSurface = {
diff --git a/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts b/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts
index 1f5d69309b..457925ad11 100644
--- a/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts
+++ b/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts
@@ -89,6 +89,7 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU
samplesPerPixel: 2,
progressive: true,
shadows: true,
+ adaptiveResolution: false,
width: 32,
height: 32
};
@@ -120,6 +121,26 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU
);
testCase.equal(initialStatistics.triangleCount, 1, 'analytic spheres avoid triangle expansion');
testCase.equal(initialStatistics.drawCount, 1, 'the graph uses one fullscreen presentation');
+ testCase.equal(
+ initialStatistics.rayTracing?.internalWidth,
+ 16,
+ 'the default ray workload traces half the display width'
+ );
+ testCase.equal(
+ initialStatistics.rayTracing?.internalHeight,
+ 16,
+ 'the default ray workload traces half the display height'
+ );
+ testCase.equal(
+ initialStatistics.rayTracing?.sampledPixelCoverage,
+ 1,
+ 'new history is fully initialized before sparse scheduling can begin'
+ );
+ testCase.equal(
+ initialStatistics.rayTracing?.accumulatedSamples,
+ 2,
+ 'ray-tracing telemetry reports samples per pixel rather than encoded frames'
+ );
if (supportsRawValidationErrorScopes) {
device.handle.pushErrorScope('validation');
@@ -131,6 +152,29 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU
3,
'unchanged instances reuse the compiled graph and progressive history'
);
+ testCase.equal(
+ accumulatedStatistics.rayTracing?.accumulatedSamples,
+ 4,
+ 'progressive telemetry accumulates the requested samples per pixel'
+ );
+
+ const nonReprojectedStatistics = renderer.render({...options, temporalReprojection: false});
+ device.submit();
+ testCase.equal(
+ nonReprojectedStatistics.rayTracing?.accumulatedSamples,
+ 2,
+ 'disabling temporal reprojection starts a fresh progressive history'
+ );
+ const accumulatedNonReprojectedStatistics = renderer.render({
+ ...options,
+ temporalReprojection: false
+ });
+ device.submit();
+ testCase.equal(
+ accumulatedNonReprojectedStatistics.rayTracing?.accumulatedSamples,
+ 4,
+ 'unchanged transforms can still accumulate without temporal reprojection'
+ );
sphereSurface.transforms = [
new Matrix4()
@@ -138,13 +182,18 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU
.rotateY(Math.PI / 3)
.scale([0.75, 1.3, 0.5])
];
- const reducedStatistics = renderer.render(options);
+ const reducedStatistics = renderer.render({...options, temporalReprojection: false});
device.submit();
testCase.equal(
reducedStatistics.instanceCount,
2,
'refit invalidates inactive leaves when the instance count shrinks'
);
+ testCase.equal(
+ reducedStatistics.rayTracing?.accumulatedSamples,
+ 2,
+ 'moving transforms reset history when reprojection is disabled'
+ );
sphereSurface.transforms = [
...sphereSurface.transforms,
@@ -179,7 +228,26 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU
testCase.equal(
resizedStatistics.instanceCount,
3,
- 'resizing recompiles the complete GPU graph'
+ 'resizing recreates the trace graph without dropping scene instances'
+ );
+ testCase.equal(
+ resizedStatistics.rayTracing?.internalWidth,
+ 8,
+ 'resizing preserves the default half-resolution ray workload'
+ );
+
+ const fullResolutionStatistics = renderer.render({
+ ...options,
+ width: 16,
+ height: 16,
+ resolutionScale: 1,
+ adaptiveResolution: false
+ });
+ device.submit();
+ testCase.equal(
+ fullResolutionStatistics.rayTracing?.internalWidth,
+ 16,
+ 'callers can opt back into full-resolution ray dispatch'
);
if (supportsRawValidationErrorScopes) {
diff --git a/modules/experimental/test/engine/ray-tracing-scene-shaders.node.spec.ts b/modules/experimental/test/engine/ray-tracing-scene-shaders.node.spec.ts
index 7ec52e46a9..e8e7b70ef7 100644
--- a/modules/experimental/test/engine/ray-tracing-scene-shaders.node.spec.ts
+++ b/modules/experimental/test/engine/ray-tracing-scene-shaders.node.spec.ts
@@ -5,6 +5,7 @@
import {describe, expect, test} from 'vitest';
import {WgslReflect} from 'wgsl_reflect';
import {
+ getRayTracingScenePresentationShader,
RAY_TRACING_BOUNDS_SHADER,
RAY_TRACING_SCENE_SHADER
} from '../../src/engine/ray-tracing-scene-shaders';
@@ -17,12 +18,26 @@ describe('graph-accelerated ray tracing shaders', () => {
expect(reflection.uniforms.map(({name, binding}) => ({name, binding}))).toEqual([
{name: 'uniforms', binding: 0}
]);
- expect(reflection.uniforms[0].size).toBe(160);
+ expect(reflection.uniforms[0].size).toBe(272);
+ expect(reflection.uniforms[0].members?.map(({name, offset}) => ({name, offset}))).toEqual([
+ {name: 'inverseViewProjection', offset: 0},
+ {name: 'cameraPosition', offset: 64},
+ {name: 'background', offset: 80},
+ {name: 'dimensions', offset: 96},
+ {name: 'settings', offset: 112},
+ {name: 'fog', offset: 128},
+ {name: 'acceleration', offset: 144},
+ {name: 'displayPhase', offset: 160},
+ {name: 'temporal', offset: 176},
+ {name: 'previousViewProjection', offset: 192},
+ {name: 'previousCameraPosition', offset: 256}
+ ]);
expect(reflection.storage.map(({name, binding}) => ({name, binding}))).toEqual([
{name: 'primitives', binding: 1},
{name: 'primitiveMinima', binding: 2},
{name: 'primitiveMaxima', binding: 3}
]);
+ expect(reflection.storage[0].format?.size).toBe(256);
expect(reflection.uniforms.length + reflection.storage.length).toBe(4);
expect(RAY_TRACING_BOUNDS_SHADER).toContain('@workgroup_size(128)');
expect(RAY_TRACING_BOUNDS_SHADER).toContain('length(firstRow)');
@@ -35,13 +50,15 @@ describe('graph-accelerated ray tracing shaders', () => {
test('keeps ray traversal below the WebGPU core storage-binding limit', () => {
const reflection = new WgslReflect(RAY_TRACING_SCENE_SHADER);
- const storageBuffers = reflection.storage.filter(({name}) => name !== 'outputImage');
+ const storageBuffers = reflection.storage.filter(
+ ({name}) => name !== 'outputImage' && name !== 'outputMetadata'
+ );
expect(reflection.entry.compute.map(entry => entry.name)).toEqual(['main']);
expect(reflection.uniforms.map(({name, binding}) => ({name, binding}))).toEqual([
{name: 'uniforms', binding: 0}
]);
- expect(reflection.uniforms[0].size).toBe(160);
+ expect(reflection.uniforms[0].size).toBe(272);
expect(storageBuffers.map(({name, binding}) => ({name, binding}))).toEqual([
{name: 'primitives', binding: 1},
{name: 'triangles', binding: 2},
@@ -50,14 +67,22 @@ describe('graph-accelerated ray tracing shaders', () => {
{name: 'nodeMaxima', binding: 5}
]);
expect(storageBuffers).toHaveLength(5);
- expect(reflection.storage.find(({name}) => name === 'outputImage')?.binding).toBe(7);
+ expect(reflection.storage.find(({name}) => name === 'outputImage')?.binding).toBe(8);
+ expect(reflection.storage.find(({name}) => name === 'outputMetadata')?.binding).toBe(9);
+ expect(reflection.textures.map(({name, binding}) => ({name, binding}))).toEqual([
+ {name: 'historyImage', binding: 6},
+ {name: 'historyMetadata', binding: 7}
+ ]);
expect(
reflection.uniforms.length + reflection.storage.length + reflection.textures.length
- ).toBe(8);
+ ).toBe(10);
expect(RAY_TRACING_SCENE_SHADER).toContain('@workgroup_size(8, 8, 1)');
expect(RAY_TRACING_SCENE_SHADER).toContain('@binding(6) var historyImage');
- expect(RAY_TRACING_SCENE_SHADER).toContain('@binding(7) var outputImage');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('@binding(7) var historyMetadata');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('@binding(8) var outputImage');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('@binding(9) var outputMetadata');
expect(RAY_TRACING_SCENE_SHADER).toContain('acceleration: vec4');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('previousTransform: mat4x4');
});
test('traverses implicit BVH children and terminates shadow rays on the first hit', () => {
@@ -75,4 +100,49 @@ describe('graph-accelerated ray tracing shaders', () => {
'for (var primitiveIndex = 0u; primitiveIndex < uniforms.dimensions.z; primitiveIndex++)'
);
});
+
+ test('traces rotating sparse phases and bounds shadow-light sampling', () => {
+ expect(RAY_TRACING_SCENE_SHADER).toContain(
+ 'let phaseOffset = (uniforms.displayPhase.z + invocation.y) % phaseCount'
+ );
+ expect(RAY_TRACING_SCENE_SHADER).toContain('invocation.x * phaseCount + phaseOffset');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('let requestedShadowSamples');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('requestedShadowSamples == 0u');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('let rotatingLightOffset = uniforms.acceleration.w');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('rotatingLightIndex >= shadowSampleCount');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('let lightSampleWeight');
+ });
+
+ test('reprojects per-instance radiance and rejects invalid history', () => {
+ expect(RAY_TRACING_SCENE_SHADER).toContain('primitive.previousTransform * localHitPosition');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('uniforms.previousViewProjection');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('uniforms.previousCameraPosition.xyz');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('MINIMUM_HISTORY_NORMAL_ALIGNMENT');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('MAXIMUM_HISTORY_RELATIVE_DEPTH_DIFFERENCE');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('clampHistoricalRayColor');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('historicalColor.a');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('vec4(color, totalSampleCount)');
+ expect(RAY_TRACING_SCENE_SHADER).toContain('textureStore(outputMetadata');
+ });
+
+ test('manually reconstructs full-resolution HDR and SDR presentation without a sampler', () => {
+ for (const highDynamicRange of [false, true]) {
+ const presentationShader = getRayTracingScenePresentationShader(highDynamicRange);
+ const reflection = new WgslReflect(presentationShader);
+
+ expect(reflection.entry.vertex.map(({name}) => name)).toEqual(['vertexMain']);
+ expect(reflection.entry.fragment.map(({name}) => name)).toEqual(['fragmentMain']);
+ expect(reflection.textures.map(({name, binding}) => ({name, binding}))).toEqual([
+ {name: 'image', binding: 0}
+ ]);
+ expect(reflection.uniforms).toHaveLength(0);
+ expect(presentationShader).toContain('textureDimensions(image)');
+ expect(presentationShader).toContain('let topLeft = textureLoad');
+ expect(presentationShader).toContain('let topRight = textureLoad');
+ expect(presentationShader).toContain('let bottomLeft = textureLoad');
+ expect(presentationShader).toContain('let bottomRight = textureLoad');
+ expect(presentationShader).toContain('vec4(radiance, 1.0)');
+ expect(presentationShader).not.toContain('@binding(1)');
+ }
+ });
});