From ea005cc2c788b06199317d85fcf16e8c38f2a4a1 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 6 Aug 2026 16:47:43 -0400 Subject: [PATCH 1/3] feat(anari): add interactive ray tracing budget --- docs/api-guide/engine/anari-rendering.md | 35 +- docs/api-reference/anari/anari-rendering.md | 55 +- docs/api-reference/experimental/README.md | 13 +- docs/whats-new.md | 4 +- examples/showcase/anari/app.ts | 33 +- examples/showcase/anari/index.html | 8 + examples/showcase/anari/playground-scene.ts | 16 +- examples/showcase/anari/playground.html | 8 + examples/showcase/anari/playground.ts | 19 + .../anari/src/anari-ray-tracing-runtime.ts | 8 +- modules/anari/src/anari-scene-adapter.ts | 21 +- modules/anari/src/anari-types.ts | 14 + modules/anari/src/schemas.ts | 8 +- modules/anari/test/anari-device.node.spec.ts | 24 + modules/anari/test/anari-schemas.node.spec.ts | 38 + .../src/engine/ray-tracing-scene-renderer.ts | 1238 ++++++++++++++--- .../src/engine/ray-tracing-scene-shaders.ts | 227 ++- .../experimental/src/engine/scene-renderer.ts | 11 + .../engine/ray-tracing-scene-renderer.spec.ts | 36 +- .../ray-tracing-scene-shaders.node.spec.ts | 82 +- 20 files changed, 1679 insertions(+), 219 deletions(-) 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..ac86916f51 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,15 @@ 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; /** Optional analytic primitive supplied by a format-specific scene adapter. */ export type RayTracingScenePrimitive = { @@ -41,6 +50,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 +73,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 +155,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,59 +190,223 @@ 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; + } 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, @@ -154,18 +415,39 @@ export class RayTracingSceneRenderer { }) ); - 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; - 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: resources.accumulatedFrameCount + } satisfies RayTracingStatistics }; + return statistics as SceneRenderStatistics; } destroyFrame(frameIdentifier: string): void { @@ -173,12 +455,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 +478,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 +510,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 +517,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 +643,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 +734,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 +747,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 +784,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 +825,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 +965,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 +988,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 +1015,7 @@ export class RayTracingSceneRenderer { } }); return { - encode: ({computePass, getBuffer, getTextureView}) => { + encode: ({computePass, getBuffer, getTextureView, parameters}) => { computation.setBindings({ uniforms: getBuffer(uniforms), primitives: getBuffer(primitives), @@ -442,12 +1024,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 +1080,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 +1106,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 +1167,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 +1247,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 +1328,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,8 +1420,15 @@ 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; @@ -760,8 +1444,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; @@ -774,5 +1458,225 @@ function makeUniformData(props: { unsignedData[37] = props.leafCapacity; unsignedData[38] = props.primitiveCapacity; unsignedData[39] = 0; + 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 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..1e7f55cde0 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; @@ -133,7 +149,8 @@ fn makeRandom(seed: u32) -> f32 { fn makeCameraRay(pixel: vec2, sampleIndex: u32) -> Ray { let frameIndex = u32(uniforms.settings.y); 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 = u32(uniforms.settings.y) % 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..bda13129e6 100644 --- a/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts +++ b/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts @@ -120,6 +120,21 @@ 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' + ); if (supportsRawValidationErrorScopes) { device.handle.pushErrorScope('validation'); @@ -179,7 +194,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..768441b021 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'); + 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)'); + } + }); }); From e672172a7998a17a2acfbf1d5ab72ca512784bbc Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 6 Aug 2026 19:39:30 -0400 Subject: [PATCH 2/3] fix(anari): address interactive ray tracing review --- .../src/engine/ray-tracing-scene-renderer.ts | 19 ++++++++-- .../src/engine/ray-tracing-scene-shaders.ts | 4 +-- .../engine/ray-tracing-scene-renderer.spec.ts | 35 ++++++++++++++++++- .../ray-tracing-scene-shaders.node.spec.ts | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/modules/experimental/src/engine/ray-tracing-scene-renderer.ts b/modules/experimental/src/engine/ray-tracing-scene-renderer.ts index ac86916f51..c7bd958785 100644 --- a/modules/experimental/src/engine/ray-tracing-scene-renderer.ts +++ b/modules/experimental/src/engine/ray-tracing-scene-renderer.ts @@ -29,6 +29,7 @@ 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 = { @@ -325,6 +326,9 @@ export class RayTracingSceneRenderer { resources.triangleCount = primitiveData.triangleCount; if (transformChanged) { resources.accelerationNeedsUpdate = true; + if (!(options.temporalReprojection ?? true)) { + resources.historyNeedsReset = true; + } } else if (primitiveChanged) { resources.historyNeedsReset = true; } @@ -411,7 +415,8 @@ export class RayTracingSceneRenderer { primitiveCapacity: resources.primitiveCapacity, leafCapacity: resources.leafCapacity, lightCount: resources.lightCount, - accumulatedFrameCount + accumulatedFrameCount, + frameIndex: resources.frameIndex }) ); @@ -428,6 +433,7 @@ export class RayTracingSceneRenderer { resources.phaseIndex = (activePhaseIndex + 1) % resources.phaseCount; resources.frameIndex++; resources.accumulatedFrameCount = progressive ? accumulatedFrameCount + 1 : 0; + const samplesPerPixel = getSamplesPerPixel(options); const statistics = { surfaceCount: options.surfaces.length, @@ -444,7 +450,9 @@ export class RayTracingSceneRenderer { sampledPixelCoverage: 1 / activePhaseCount, frameTimeMilliseconds: resources.averageFrameTimeMilliseconds ?? resources.targetFrameTimeMilliseconds, - accumulatedSamples: resources.accumulatedFrameCount + accumulatedSamples: progressive + ? Math.min(resources.accumulatedFrameCount * samplesPerPixel, MAXIMUM_HISTORY_SAMPLES) + : samplesPerPixel } satisfies RayTracingStatistics }; return statistics as SceneRenderStatistics; @@ -1434,6 +1442,7 @@ function makeUniformData(props: { leafCapacity: number; lightCount: number; accumulatedFrameCount: number; + frameIndex: number; }): Float32Array { const data = new Float32Array(UNIFORM_FLOAT_COUNT); const unsignedData = new Uint32Array(data.buffer); @@ -1457,7 +1466,7 @@ 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; @@ -1656,6 +1665,10 @@ function clampResolutionScale(value: number, minimum: number, maximum: 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, diff --git a/modules/experimental/src/engine/ray-tracing-scene-shaders.ts b/modules/experimental/src/engine/ray-tracing-scene-shaders.ts index 1e7f55cde0..3204a21082 100644 --- a/modules/experimental/src/engine/ray-tracing-scene-shaders.ts +++ b/modules/experimental/src/engine/ray-tracing-scene-shaders.ts @@ -147,7 +147,7 @@ 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 + uniforms.displayPhase.z * 3181u + 17u; @@ -439,7 +439,7 @@ fn evaluateDirectLighting(ray: Ray, hit: RayHit) -> vec3 { directLightCount, requestedShadowSamples == 0u || uniforms.settings.w <= 0.5 ); - let rotatingLightOffset = u32(uniforms.settings.y) % max(directLightCount, 1u); + let rotatingLightOffset = uniforms.acceleration.w % max(directLightCount, 1u); var directLightIndex = 0u; for (var lightIndex = 0u; lightIndex < uniforms.dimensions.w; lightIndex++) { 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 bda13129e6..b2b4d5c002 100644 --- a/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts +++ b/modules/experimental/test/engine/ray-tracing-scene-renderer.spec.ts @@ -135,6 +135,11 @@ test('RayTracingSceneRenderer builds and traverses an instance BVH within WebGPU 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'); @@ -146,6 +151,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() @@ -153,13 +181,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, 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 768441b021..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 @@ -108,7 +108,7 @@ describe('graph-accelerated ray tracing shaders', () => { 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'); + 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'); }); From c236cf12905c943ce597aa36fd8ada103196a420 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 6 Aug 2026 20:20:33 -0400 Subject: [PATCH 3/3] test(experimental): stabilize ray trace accumulation --- .../experimental/test/engine/ray-tracing-scene-renderer.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 b2b4d5c002..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 };