diff --git a/docs/api-reference/experimental/deferred-scene-renderer.md b/docs/api-reference/experimental/deferred-scene-renderer.md index db20909519..487d3a2e98 100644 --- a/docs/api-reference/experimental/deferred-scene-renderer.md +++ b/docs/api-reference/experimental/deferred-scene-renderer.md @@ -51,13 +51,15 @@ const options: SceneRenderOptions = { console.log('Deferred-compatible scene:', supportsDeferredScene(options)); const statistics = renderer.render(options); +device.submit(); renderer.destroy(); ``` `supportsDeferredScene(options)` reports the current scene's compatibility before rendering. Calling it is optional: `DeferredSceneRenderer.render(options)` performs the same check and -selects forward rendering automatically when required. +selects forward rendering automatically when required. Rendering records the frame; submit the +device's command queue before presenting or destroying renderer-owned resources. ## Supported deferred scenes @@ -72,7 +74,9 @@ present: - A `BLEND` material or base-color alpha that infers blending. - Any supplied environment-lighting texture. - Transmission, nonzero thickness, clearcoat, sheen, iridescence, or anisotropy. -- A nondefault index of refraction, specular intensity, or specular color. +- A nondefault index of refraction, specular intensity, specular color, or authored specular map. +- Spot lights, whose direction and cone angles require forward shading. +- More than one directional light, which exceeds the deferred lighting pass's single-light layout. - An unlit material. - `debugNormals` or `debugDepth` output. diff --git a/docs/api-reference/experimental/pbr-environment.md b/docs/api-reference/experimental/pbr-environment.md index 314d47a236..d941c10f80 100644 --- a/docs/api-reference/experimental/pbr-environment.md +++ b/docs/api-reference/experimental/pbr-environment.md @@ -40,6 +40,7 @@ renderer.render({ camera, environment }); +device.submit(); renderer.destroy(); environment.destroy(); @@ -69,6 +70,7 @@ const environment = preparePBREnvironment(device, { }); renderer.render({...options, environment}); +device.submit(); environment.destroy(); ``` @@ -123,6 +125,7 @@ environment.intensity = 0.8; environment.rotation = Math.PI / 2; renderer.render({...options, environment}); +device.submit(); ``` ## Color encoding and HDR @@ -200,6 +203,7 @@ renderer.render({ brdfLUTTexture: loadedEnvironment.brdfLutTexture.texture } }); +device.submit(); ``` Choose `loadPBREnvironment()` for existing prefiltered assets. Choose `PBREnvironmentGenerator` diff --git a/docs/api-reference/experimental/scene-renderer.md b/docs/api-reference/experimental/scene-renderer.md index 109c380328..f1033ad289 100644 --- a/docs/api-reference/experimental/scene-renderer.md +++ b/docs/api-reference/experimental/scene-renderer.md @@ -65,6 +65,7 @@ const options: SceneRenderOptions = { const renderer = new SceneRenderer(device); const statistics = renderer.render(options); +device.submit(); // {surfaceCount: 1, instanceCount: 2, drawCount: 1, triangleCount: 2} console.log(statistics); @@ -76,6 +77,10 @@ Every matrix in `transforms` places the same geometry/material pair in world spa uploads the matrices to instanced vertex attributes and keeps all placements for one surface in one draw. A surface with no transforms is not drawn. +`render()` records its render pass but does not submit the device's command queue. Call +`device.submit()` after encoding the frame, and always before destroying the renderer or any +borrowed resources. Applications combining multiple passes may submit once after the final pass. + ## Scene descriptors ### `SceneSurface` @@ -111,11 +116,13 @@ const deformingSurface: SceneSurface = { }; renderer.render({...options, surfaces: [deformingSurface]}); +device.submit(); deformingSurface.skin = {jointMatrices: nextJointMatrices}; deformingSurface.morphWeights = [0.7]; renderer.render({...options, surfaces: [deformingSurface]}); +device.submit(); ``` The existing shadertools `skin` module uploads the supplied joint palette, and the existing @@ -214,6 +221,7 @@ renderer.render({ toneMapMode: PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL, outputColorSpace: 'srgb' }); +device.submit(); ``` An `rgba16float` framebuffer defaults to linear, untonemapped output, preserving radiance above @@ -238,6 +246,7 @@ const environment: SceneEnvironment = { }; renderer.render({...options, environment}); +device.submit(); ``` All three textures must be present before IBL is enabled. An incomplete environment leaves IBL @@ -260,6 +269,7 @@ const generatedEnvironment = generator.prepare({ }); renderer.render({...options, environment: generatedEnvironment}); +device.submit(); ``` [`PBREnvironmentGenerator`](/docs/api-reference/experimental/pbr-environment) integrates all six @@ -324,6 +334,7 @@ renderer.render({ surfaces: [opaqueBackgroundSurface, glassSurface], transmission: true }); +device.submit(); ``` Physical transmission is distinct from alpha blending. Keep a genuinely opaque glTF transmission diff --git a/docs/capabilities.mdx b/docs/capabilities.mdx index 195379342d..459bf1a4ed 100644 --- a/docs/capabilities.mdx +++ b/docs/capabilities.mdx @@ -404,15 +404,16 @@ Try [Lightstorm Megacity](/examples/showcase/lightstorm-megacity), | Metallic-roughness materials | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Apply supported PBR textures, material factors, normals, and emissive surfaces. | | Specular and index of refraction | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Support documented glTF specular and index-of-refraction material extensions. | | Clearcoat and sheen | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Apply documented clearcoat and sheen material lobes in compatible renderers. | -| Iridescence and anisotropy | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Use supported material approximations for thin-film color and directional highlights. | -| Transmission and volume | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Transmission remains approximate; full scene-color refraction is not implemented. | +| Iridescence and anisotropy | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Render thin-film interference and directional anisotropic highlights through the shared PBR shader. | +| Transmission and volume | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Shared scene renderers refract captured scene color; the standalone glTF rendering fallback remains approximate. | +| Chromatic dispersion | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Separate visible wavelengths when physical transmission uses authored `KHR_materials_dispersion`. | | Authored UV transforms | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Preserve supported texture offset, rotation, and scale semantics. | | Authored normals and tangents | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Preserve supported vertex attributes; complete renderer-to-renderer fidelity varies. | | Punctual-light parsing | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Parse supported authored directional, point, and spot light definitions. | | Shared animation clips | Available | WebGPU + WebGL2 | `@luma.gl/engine` | Play, blend, crossfade, loop, and interpolate compatible imported tracks. | | Selected animation pointers | Evolving | WebGPU + WebGL2 | `@luma.gl/gltf` | Update supported `KHR_animation_pointer` transforms, material factors, and UV properties. | -| Existing joint-driven skinning | Evolving | WebGPU + WebGL2 | `@luma.gl/shadertools` | Reuse established skin shaders; higher joint counts and multiple skins remain incomplete. | -| Morph-target animation | Opportunity | WebGPU + WebGL2 | `@luma.gl/gltf` | Morph-weight playback is not wired through the shared rendering and animation paths. | +| Existing joint-driven skinning | Available | WebGPU + WebGL2 | `@luma.gl/shadertools` | Reuse established skin shaders and automatically bind mesh-local glTF joint palettes. | +| Morph-target animation | Available | WebGPU + WebGL2 | `@luma.gl/gltf` | Animate POSITION, NORMAL, and TANGENT morph targets through shared glTF, engine, and retained-scene paths. | | Imported GPU instancing | Opportunity | WebGPU + WebGL2 | `@luma.gl/gltf` | `EXT_mesh_gpu_instancing` is not yet translated into retained instance batches. | | Imported node visibility | Opportunity | WebGPU + WebGL2 | `@luma.gl/gltf` | `KHR_node_visibility` does not yet have a supported runtime integration. | diff --git a/examples/showcase/anari/gltf-to-anari.ts b/examples/showcase/anari/gltf-to-anari.ts index c9ad98b9c5..5c623d7f7d 100644 --- a/examples/showcase/anari/gltf-to-anari.ts +++ b/examples/showcase/anari/gltf-to-anari.ts @@ -156,7 +156,11 @@ function makeImportedLights( gltf: GLTFPostprocessed, state: GLTFTranslationState ): JSONLightDeclaration[] { - return parseGLTFLights(gltf, {useByteColors: false}).flatMap(light => { + const activeNodeIdentifiers = new Set(Object.keys(state.nodeIdentifiers)); + return parseGLTFLights(gltf, { + nodeIdentifiers: activeNodeIdentifiers, + useByteColors: false + }).flatMap(light => { if (light.type === 'ambient') { return []; } diff --git a/modules/anari/test/gltf-review-light-import.node.spec.ts b/modules/anari/test/gltf-review-light-import.node.spec.ts new file mode 100644 index 0000000000..ddab90bef7 --- /dev/null +++ b/modules/anari/test/gltf-review-light-import.node.spec.ts @@ -0,0 +1,66 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {readFile} from 'node:fs/promises'; +import {parse} from '@loaders.gl/core'; +import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf'; +import {ANARISceneSchema} from '@luma.gl/anari/schemas'; +import {parseGLTFLights} from '@luma.gl/gltf'; +import {describe, expect, test} from 'vitest'; +import {makeANARIJSONSceneFromGLTF} from '../../../examples/showcase/anari/gltf-to-anari'; + +describe('selected-scene glTF punctual-light ownership', () => { + test('filters inactive-scene lights without losing selected-scene hierarchy', async () => { + const assetData = await readFile(new URL('../../../test/data/box.glb', import.meta.url)); + const source = postProcessGLTF(await parse(assetData, GLTFLoader, {gltf: {loadImages: false}})); + const selectedRoot = source.scene?.nodes?.[0] || source.scenes[0]?.nodes?.[0]; + expect(selectedRoot).toBeDefined(); + if (!selectedRoot) { + return; + } + + const documentWithLights = source as typeof source & { + lights?: Array>; + }; + documentWithLights.lights = [ + {type: 'point', color: [0.2, 0.4, 0.6], intensity: 3}, + {type: 'spot', color: [1, 0, 0], intensity: 17} + ]; + + const selectedLightNode = { + id: 'selected-scene-light', + translation: [1, 2, 3], + extensions: {KHR_lights_punctual: {light: 0}} + } as (typeof source.nodes)[number]; + const inactiveLightNode = { + id: 'inactive-scene-light', + translation: [9, 8, 7], + extensions: {KHR_lights_punctual: {light: 1}} + } as (typeof source.nodes)[number]; + selectedRoot.children = [...(selectedRoot.children || []), selectedLightNode]; + source.nodes.push(selectedLightNode, inactiveLightNode); + source.scenes.push({ + id: 'inactive-light-scene', + nodes: [inactiveLightNode] + } as (typeof source.scenes)[number]); + + expect(parseGLTFLights(source, {useByteColors: false})).toHaveLength(2); + expect( + parseGLTFLights(source, { + nodeIdentifiers: new Set([selectedLightNode.id]), + useByteColors: false + }) + ).toHaveLength(1); + + const retainedScene = await makeANARIJSONSceneFromGLTF(source, 'SELECTED LIGHT SCENE'); + const authoredLights = (retainedScene.lights || []).filter(light => + light['@@id'].startsWith('source-') + ); + expect(authoredLights).toHaveLength(1); + expect(authoredLights[0]['@@type']).toBe('point'); + expect(authoredLights[0].color).toEqual([0.2, 0.4, 0.6]); + expect(authoredLights[0].intensity).toBe(3); + expect(ANARISceneSchema.safeParse(retainedScene).success).toBe(true); + }); +}); diff --git a/modules/engine/src/animation/morph-targets.ts b/modules/engine/src/animation/morph-targets.ts index 9e89c1d8f9..95312e9810 100644 --- a/modules/engine/src/animation/morph-targets.ts +++ b/modules/engine/src/animation/morph-targets.ts @@ -13,6 +13,29 @@ export type MorphTargetAttributes = { TANGENT?: Float32Array; }; +/** Decodes one immutable vertex attribute into its shader-facing floating-point values. */ +export function decodeMorphTargetAttribute(attribute: GeometryAttribute): Float32Array { + const values = attribute.value; + if (values instanceof Float32Array) { + return values; + } + + const decoded = new Float32Array(values.length); + const maximum = getNormalizedAttributeMaximum(values); + const signed = + values instanceof Int8Array || values instanceof Int16Array || values instanceof Int32Array; + for (let componentIndex = 0; componentIndex < values.length; componentIndex++) { + const value = Number(values[componentIndex]); + decoded[componentIndex] = + attribute['normalized'] && maximum + ? signed + ? Math.max(value / maximum, -1) + : value / maximum + : value; + } + return decoded; +} + /** Applies weighted morph deltas without modifying the immutable source vertex attributes. */ export function applyMorphTargets( baseAttributes: Readonly, @@ -72,9 +95,9 @@ export function updateMorphTargetBuffers( ): void { const baseAttributes: MorphTargetAttributes = {}; for (const attributeName of ['POSITION', 'NORMAL', 'TANGENT'] as const) { - const values = geometry.attributes[attributeName]?.value; - if (values instanceof Float32Array) { - baseAttributes[attributeName] = values; + const attribute = geometry.attributes[attributeName]; + if (attribute) { + baseAttributes[attributeName] = decodeMorphTargetAttribute(attribute); } } @@ -89,7 +112,7 @@ export function updateMorphTargetBuffers( const values = morphedAttributes[attributeName]; const source = attributes[attributeName]; if (values && source) { - attributes[attributeName] = {...source, value: values}; + attributes[attributeName] = {...source, value: encodeMorphTargetAttribute(source, values)}; } } @@ -124,6 +147,38 @@ export function updateMorphTargetBuffers( } } +function encodeMorphTargetAttribute( + attribute: GeometryAttribute, + values: Float32Array +): GeometryAttribute['value'] { + if (attribute.value instanceof Float32Array) { + return values; + } + + const encoded = attribute.value.slice(); + const maximum = getNormalizedAttributeMaximum(encoded); + const signed = + encoded instanceof Int8Array || encoded instanceof Int16Array || encoded instanceof Int32Array; + for (let componentIndex = 0; componentIndex < values.length; componentIndex++) { + const value = values[componentIndex]; + encoded[componentIndex] = + attribute['normalized'] && maximum + ? Math.round(Math.max(signed ? -1 : 0, Math.min(1, value)) * maximum) + : value; + } + return encoded; +} + +function getNormalizedAttributeMaximum(values: GeometryAttribute['value']): number { + if (values instanceof Int8Array) return 127; + if (values instanceof Uint8Array || values instanceof Uint8ClampedArray) return 255; + if (values instanceof Int16Array) return 32767; + if (values instanceof Uint16Array) return 65535; + if (values instanceof Int32Array) return 2147483647; + if (values instanceof Uint32Array) return 4294967295; + return 0; +} + function normalizeMorphDirections(values: Float32Array, componentCount: number): void { for (let offset = 0; offset < values.length; offset += componentCount) { const length = Math.hypot(values[offset], values[offset + 1], values[offset + 2]); diff --git a/modules/engine/src/index.ts b/modules/engine/src/index.ts index 7f54e3cf79..8f3778d958 100644 --- a/modules/engine/src/index.ts +++ b/modules/engine/src/index.ts @@ -20,7 +20,11 @@ export {AnimationClip} from './animation/animation-clip'; export type {AnimationActionProps, AnimationLoopMode} from './animation/animation-mixer'; export {AnimationAction, AnimationMixer} from './animation/animation-mixer'; export type {MorphTargetAttributes} from './animation/morph-targets'; -export {applyMorphTargets, updateMorphTargetBuffers} from './animation/morph-targets'; +export { + applyMorphTargets, + decodeMorphTargetAttribute, + updateMorphTargetBuffers +} from './animation/morph-targets'; export type {SkinJointMatricesProps} from './animation/skin'; export {updateSkinJointMatrices} from './animation/skin'; export {Timeline} from './animation/timeline'; diff --git a/modules/engine/test/animation/morph-review-correctness.node.spec.ts b/modules/engine/test/animation/morph-review-correctness.node.spec.ts new file mode 100644 index 0000000000..f381d1e755 --- /dev/null +++ b/modules/engine/test/animation/morph-review-correctness.node.spec.ts @@ -0,0 +1,94 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { + decodeMorphTargetAttribute, + Geometry, + makeInterleavedGeometry, + type Model, + updateMorphTargetBuffers +} from '@luma.gl/engine'; +import {describe, expect, test} from 'vitest'; + +describe('normalized morph-target vertex attributes', () => { + test('decodes unsigned, signed minimum, and non-normalized integer source values', () => { + expect( + Array.from( + decodeMorphTargetAttribute({ + size: 3, + value: new Uint16Array([0, 32768, 65535]), + normalized: true + }) + ) + ).toEqual([0, expect.closeTo(32768 / 65535, 5), 1]); + + expect( + Array.from( + decodeMorphTargetAttribute({ + size: 4, + value: new Int8Array([-128, -127, 0, 127]), + normalized: true + }) + ) + ).toEqual([-1, -1, 0, 1]); + + expect( + Array.from( + decodeMorphTargetAttribute({ + size: 3, + value: new Uint16Array([2, 7, 1024]), + normalized: false + }) + ) + ).toEqual([2, 7, 1024]); + }); + + test('morphs preexisting normalized integer buffers without changing their packed layout', () => { + const positions = new Uint16Array([16384, 32768, 49151]); + const normals = new Int8Array([0, 0, 127]); + const geometry = new Geometry({ + topology: 'triangle-list', + attributes: { + POSITION: {size: 3, value: positions, normalized: true}, + NORMAL: {size: 3, value: normals, normalized: true} + } + }); + const sourcePacked = makeInterleavedGeometry(geometry); + const writes: Uint8Array[] = []; + const packedBuffer = { + write(values: ArrayBufferView) { + writes.push( + new Uint8Array( + values.buffer.slice(values.byteOffset, values.byteOffset + values.byteLength) + ) + ); + } + }; + const model = { + _gpuGeometry: {attributes: {geometry: packedBuffer}}, + bufferAttributes: {geometry: packedBuffer} + } as unknown as Model; + + updateMorphTargetBuffers( + model, + geometry, + [ + { + POSITION: new Float32Array([0.25, 0, 0]), + NORMAL: new Float32Array([1, 0, 0]) + } + ], + [1] + ); + + expect(writes).toHaveLength(1); + expect(writes[0].byteLength).toBe(sourcePacked.attributes['geometry']!.value.byteLength); + const packedValues = new DataView(writes[0].buffer); + expect(packedValues.getUint16(0, true)).toBeCloseTo(32768, -1); + expect(packedValues.getInt8(8)).toBeCloseTo(90, -1); + expect(packedValues.getInt8(10)).toBeCloseTo(90, -1); + expect(Array.from(positions)).toEqual([16384, 32768, 49151]); + expect(Array.from(normals)).toEqual([0, 0, 127]); + }); +}); diff --git a/modules/experimental/src/engine/deferred-scene-renderer.ts b/modules/experimental/src/engine/deferred-scene-renderer.ts index 446ab7db0a..97059ca9c4 100644 --- a/modules/experimental/src/engine/deferred-scene-renderer.ts +++ b/modules/experimental/src/engine/deferred-scene-renderer.ts @@ -35,6 +35,19 @@ export function supportsDeferredScene(options: SceneRenderOptions): boolean { if (options.renderMode && options.renderMode !== 'default') { return false; } + let directionalLightCount = 0; + let pointLightCount = 0; + for (const light of options.lights || []) { + if (light.type === 'spot') { + return false; + } + if (light.type === 'directional' && ++directionalLightCount > 1) { + return false; + } + if (light.type === 'point' && ++pointLightCount > MAX_DEFERRED_POINT_LIGHTS) { + return false; + } + } if ( options.environment?.diffuseTexture || options.environment?.specularTexture || @@ -45,6 +58,7 @@ export function supportsDeferredScene(options: SceneRenderOptions): boolean { return options.surfaces.every(surface => { const uniforms = surface.material.uniforms || {}; + const bindings = surface.material.bindings || {}; return ( getSceneAlphaMode(surface.material) !== 'BLEND' && !uniforms.unlit && @@ -56,7 +70,11 @@ export function supportsDeferredScene(options: SceneRenderOptions): boolean { !(uniforms.sheenColorFactor || []).some(component => component > 0) && (uniforms.ior === undefined || uniforms.ior === 1.5) && (uniforms.specularIntensityFactor === undefined || uniforms.specularIntensityFactor === 1) && - (uniforms.specularColorFactor || [1, 1, 1]).every(component => component === 1) + (uniforms.specularColorFactor || [1, 1, 1]).every(component => component === 1) && + !uniforms.specularColorMapEnabled && + !uniforms.specularIntensityMapEnabled && + !bindings.pbr_specularColorSampler && + !bindings.pbr_specularIntensitySampler ); }); } @@ -124,7 +142,7 @@ export class DeferredSceneRenderer extends SceneRenderer { makeDeferredPointLightBufferData(lights.pointLights, MAX_DEFERRED_POINT_LIGHTS) ); this.lightingRenderer.resize([width, height]); - this.lightingRenderer.renderToScreen({ + const lightingOptions = { sourceTexture: gBuffer.colorTexture, bindings: { depthTexture: gBuffer.depthTexture, @@ -146,7 +164,25 @@ export class DeferredSceneRenderer extends SceneRenderer { pointLightCount: lights.pointLights.length } } - }); + }; + + if (options.framebuffer) { + const lightingTexture = this.lightingRenderer.renderToTexture(lightingOptions); + if (lightingTexture) { + const presentationModel = this.lightingRenderer.textureModel; + presentationModel.setProps({backgroundTexture: lightingTexture}); + presentationModel.predraw(this.device.commandEncoder); + const presentationPass = this.device.beginRenderPass({ + id: `scene-${options.id}-deferred-resolve`, + framebuffer: options.framebuffer, + clearDepth: false + }); + presentationModel.draw(presentationPass); + presentationPass.end(); + } + } else { + this.lightingRenderer.renderToScreen(lightingOptions); + } return scene.statistics; } @@ -254,7 +290,6 @@ function getDeferredSceneLights( break; } case 'point': - case 'spot': if (pointLights.length < MAX_DEFERRED_POINT_LIGHTS) { const position = viewMatrix.transformAsPoint(light.position); pointLights.push({ diff --git a/modules/experimental/src/engine/pbr-model.ts b/modules/experimental/src/engine/pbr-model.ts index 860d046c74..8b477a9f2d 100644 --- a/modules/experimental/src/engine/pbr-model.ts +++ b/modules/experimental/src/engine/pbr-model.ts @@ -294,7 +294,8 @@ export function createPBRModel(device: Device, options: CreatePBRModelOptions): shaderModules.findIndex(candidate => candidate.name === module.name) === moduleIndex ); const geometryDefines = getPBRGeometryDefines(options.geometry); - if (geometryDefines['HAS_SKIN'] && !modules.some(module => module.name === skin.name)) { + const hasSkin = Boolean(options.defines?.['HAS_SKIN'] ?? geometryDefines['HAS_SKIN']); + if (hasSkin && !modules.some(module => module.name === skin.name)) { modules.push(skin); } diff --git a/modules/experimental/src/engine/scene-renderer.ts b/modules/experimental/src/engine/scene-renderer.ts index 997348df5f..74b7ad57ff 100644 --- a/modules/experimental/src/engine/scene-renderer.ts +++ b/modules/experimental/src/engine/scene-renderer.ts @@ -9,7 +9,8 @@ import { type Framebuffer, type RenderPass, Texture, - type TextureFormatColor + type TextureFormatColor, + textureFormatDecoder } from '@luma.gl/core'; import { type Geometry, @@ -243,7 +244,7 @@ export class SceneRenderer { const renderPass = this.device.beginRenderPass({ id: `scene-${options.id}`, framebuffer: options.framebuffer, - clearColor: [background[0], background[1], background[2], background[3] ?? 1], + clearColor: getPresentedSceneBackground(this.device, options, background), clearDepth: 1 }); scene.statistics.drawCount = this.drawPreparedScene(scene, renderPass); @@ -306,7 +307,7 @@ export class SceneRenderer { surfaceTransmissionTexture ); updateInstanceTransforms(compiledSurface, surface.transforms); - if (surface.skin && getPBRGeometryDefines(surface.geometry)['HAS_SKIN']) { + if (hasUsableSkin(surface)) { compiledSurface.model.shaderInputs.setProps({skin: surface.skin}); } updateMorphAttributes(compiledSurface, surface); @@ -427,7 +428,7 @@ export class SceneRenderer { } const overrides = this.getSurfaceModelOptions(surface, options); - const hasSkin = getPBRGeometryDefines(surface.geometry)['HAS_SKIN']; + const hasSkin = hasUsableSkin(surface); const model = createPBRModel(this.device, { id: `${surface.id}-model`, geometry: surface.geometry, @@ -464,7 +465,8 @@ export class SceneRenderer { DEBUG_NORMALS: options.renderMode === 'debugNormals', DEBUG_DEPTH: options.renderMode === 'debugDepth', ...surface.material.defines, - ...overrides.defines + ...overrides.defines, + HAS_SKIN: hasSkin } }); @@ -595,6 +597,7 @@ function getSceneSurfaceSignature( instanceCount: surface.transforms.length, alphaMode, doubleSided: Boolean(surface.material.doubleSided), + skin: hasUsableSkin(surface), defines: Object.entries(surface.material.defines || {}).sort(([first], [second]) => first.localeCompare(second) ), @@ -662,11 +665,7 @@ function setSceneShaderInputs( }, pbrScene: { exposure: options.exposure ?? 1, - toneMapMode: - options.toneMapMode ?? - (getSceneColorFormat(model.device, options) === 'rgba16float' - ? PBR_TONE_MAP_MODE.NONE - : PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL), + toneMapMode: getSceneToneMapMode(model.device, options), environmentIntensity: options.environment?.intensity ?? 1, environmentRotation: options.environment?.rotation ?? 0, environmentMipCount: options.environment?.specularTexture?.mipLevels ?? 1, @@ -703,6 +702,12 @@ function isTransmissiveSurface(surface: SceneSurface): boolean { return (surface.material.uniforms?.transmissionFactor ?? 0) > 0; } +function hasUsableSkin(surface: SceneSurface): boolean { + return Boolean( + surface.skin?.jointMatrices?.length && getPBRGeometryDefines(surface.geometry)['HAS_SKIN'] + ); +} + function getTransmissionCaptureIdentifier(frameIdentifier: string): string { return `${frameIdentifier}::linear-transmission-capture`; } @@ -712,8 +717,25 @@ function getTransmissionTextureFormat(device: Device): 'rgba16float' | 'rgba8uno return capabilities.render && capabilities.filter ? 'rgba16float' : 'rgba8unorm'; } -function getSceneColorFormat(device: Device, options: SceneRenderOptions): string { - return options.framebuffer?.colorAttachments[0]?.texture.format || device.preferredColorFormat; +function getSceneColorFormat(device: Device, options: SceneRenderOptions): TextureFormatColor { + return ( + (options.framebuffer?.colorAttachments[0]?.texture.format as TextureFormatColor | undefined) || + device.preferredColorFormat + ); +} + +function isFloatingPointColorFormat(format: TextureFormatColor): boolean { + const formatInformation = textureFormatDecoder.getInfo(format); + return Boolean(formatInformation.dataType?.startsWith('float') || format.endsWith('ufloat')); +} + +function getSceneToneMapMode(device: Device, options: SceneRenderOptions): number { + return ( + options.toneMapMode ?? + (isFloatingPointColorFormat(getSceneColorFormat(device, options)) + ? PBR_TONE_MAP_MODE.NONE + : PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL) + ); } function getSceneOutputEncoding(device: Device, options: SceneRenderOptions): number { @@ -721,7 +743,73 @@ function getSceneOutputEncoding(device: Device, options: SceneRenderOptions): nu return options.outputColorSpace === 'srgb' ? 1 : 0; } const format = getSceneColorFormat(device, options); - return format === 'rgba16float' || format.endsWith('-srgb') ? 0 : 1; + return isFloatingPointColorFormat(format) || format.endsWith('-srgb') ? 0 : 1; +} + +function getPresentedSceneBackground( + device: Device, + options: SceneRenderOptions, + background: readonly number[] +): [number, number, number, number] { + const exposure = Math.max(options.exposure ?? 1, 0); + let color: [number, number, number] = [ + Math.max(background[0], 0) * exposure, + Math.max(background[1], 0) * exposure, + Math.max(background[2], 0) * exposure + ]; + + switch (getSceneToneMapMode(device, options)) { + case PBR_TONE_MAP_MODE.REINHARD: + color = color.map(channel => channel / (1 + channel)) as [number, number, number]; + break; + + case PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL: + color = toneMapSceneBackgroundNeutral(color); + break; + + case PBR_TONE_MAP_MODE.ACES: + color = color.map(channel => + Math.min( + Math.max( + (channel * (2.51 * channel + 0.03)) / (channel * (2.43 * channel + 0.59) + 0.14), + 0 + ), + 1 + ) + ) as [number, number, number]; + break; + } + + if (getSceneOutputEncoding(device, options) !== 0) { + color = color.map(channel => + channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055 + ) as [number, number, number]; + } + + return [...color, background[3] ?? 1]; +} + +function toneMapSceneBackgroundNeutral(color: [number, number, number]): [number, number, number] { + const darkestChannel = Math.min(...color); + const offset = + darkestChannel < 0.08 ? darkestChannel - 6.25 * darkestChannel * darkestChannel : 0.04; + const offsetColor = color.map(channel => channel - offset) as [number, number, number]; + const peak = Math.max(...offsetColor); + const compressionStart = 0.76; + + if (peak < compressionStart) { + return offsetColor; + } + + const compressionRange = 1 - compressionStart; + const compressedPeak = + 1 - (compressionRange * compressionRange) / (peak + compressionRange - compressionStart); + const peakScale = compressedPeak / Math.max(peak, 0.0001); + const desaturation = 1 - 1 / (0.15 * (peak - compressedPeak) + 1); + + return offsetColor.map( + channel => channel * peakScale * (1 - desaturation) + compressedPeak * desaturation + ) as [number, number, number]; } function getSceneRenderSize(device: Device, options: SceneRenderOptions): [number, number] { diff --git a/modules/experimental/src/lugraph/lu-graph-degree-internals.ts b/modules/experimental/src/lugraph/lu-graph-degree-internals.ts index 7d47af5436..6f5a0ae980 100644 --- a/modules/experimental/src/lugraph/lu-graph-degree-internals.ts +++ b/modules/experimental/src/lugraph/lu-graph-degree-internals.ts @@ -1,6 +1,7 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph. import {type Binding} from '@luma.gl/core'; import {Computation} from '@luma.gl/engine'; diff --git a/modules/experimental/src/lugraph/lu-graph-degree.ts b/modules/experimental/src/lugraph/lu-graph-degree.ts index c6ba3c4e0d..a6b7984e71 100644 --- a/modules/experimental/src/lugraph/lu-graph-degree.ts +++ b/modules/experimental/src/lugraph/lu-graph-degree.ts @@ -1,6 +1,7 @@ // luma.gl // SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph. import type {Buffer} from '@luma.gl/core'; import {DynamicBuffer} from '@luma.gl/engine'; diff --git a/modules/experimental/test/engine/deferred-scene-renderer.node.spec.ts b/modules/experimental/test/engine/deferred-scene-renderer.node.spec.ts index 7642f38797..c3e492fc28 100644 --- a/modules/experimental/test/engine/deferred-scene-renderer.node.spec.ts +++ b/modules/experimental/test/engine/deferred-scene-renderer.node.spec.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +import type {Texture} from '@luma.gl/core'; import {Geometry} from '@luma.gl/engine'; import { DeferredSceneRenderer, + MAX_DEFERRED_POINT_LIGHTS, type SceneMaterial, type SceneRenderOptions, supportsDeferredScene @@ -93,6 +95,69 @@ describe('DeferredSceneRenderer', () => { ).toBe(false); }); + test('uses forward rendering for specular extension maps even with default factors', () => { + const texture = {} as Texture; + + for (const material of [ + {id: 'specular-color-binding', bindings: {pbr_specularColorSampler: texture}}, + {id: 'specular-intensity-binding', bindings: {pbr_specularIntensitySampler: texture}}, + {id: 'specular-color-flag', uniforms: {specularColorMapEnabled: true}}, + {id: 'specular-intensity-flag', uniforms: {specularIntensityMapEnabled: true}} + ] satisfies SceneMaterial[]) { + expect(supportsDeferredScene(makeOptions(material)), material.id).toBe(false); + } + }); + + test('preserves directional and spot light semantics through forward fallback', () => { + const options = makeOptions({id: 'opaque'}); + + expect( + supportsDeferredScene({ + ...options, + lights: [ + {type: 'ambient', intensity: 0.25}, + {type: 'directional', direction: [0, 0, -1]}, + {type: 'point', position: [0, 1, 0]} + ] + }) + ).toBe(true); + + expect( + supportsDeferredScene({ + ...options, + lights: [ + {type: 'directional', direction: [0, 0, -1]}, + {type: 'directional', direction: [1, 0, -1]} + ] + }) + ).toBe(false); + + expect( + supportsDeferredScene({ + ...options, + lights: [ + { + type: 'spot', + position: [0, 1, 0], + direction: [0, -1, 0], + innerConeAngle: 0.2, + outerConeAngle: 0.4 + } + ] + }) + ).toBe(false); + + expect( + supportsDeferredScene({ + ...options, + lights: Array.from({length: MAX_DEFERRED_POINT_LIGHTS + 1}, (_, index) => ({ + type: 'point', + position: [index, 0, 0] + })) + }) + ).toBe(false); + }); + test('assembles canonical instanced PBR G-buffer shader interfaces', () => { const shader = new WGSLShaderAssembler().assembleWGSLShader({ platformInfo: WEBGPU_PLATFORM, diff --git a/modules/experimental/test/engine/deferred-scene-renderer.spec.ts b/modules/experimental/test/engine/deferred-scene-renderer.spec.ts index eee38dae26..b123e2fc65 100644 --- a/modules/experimental/test/engine/deferred-scene-renderer.spec.ts +++ b/modules/experimental/test/engine/deferred-scene-renderer.spec.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +import {Buffer, Texture} from '@luma.gl/core'; import {Geometry} from '@luma.gl/engine'; import { DeferredSceneRenderer, @@ -60,6 +61,19 @@ test('DeferredSceneRenderer resolves generic instanced PBR surfaces on WebGPU', width: 32, height: 32 }; + const offscreenTexture = device.createTexture({ + id: 'deferred-offscreen-color', + width: 32, + height: 32, + format: 'rgba8unorm', + usage: Texture.RENDER | Texture.COPY_SRC + }); + const offscreenFramebuffer = device.createFramebuffer({ + id: 'deferred-offscreen-framebuffer', + width: 32, + height: 32, + colorAttachments: [offscreenTexture] + }); try { const deferredStatistics = renderer.render(options); @@ -71,6 +85,36 @@ test('DeferredSceneRenderer resolves generic instanced PBR surfaces on WebGPU', ); testCase.equal(deferredStatistics.instanceCount, 2, 'deferred capture preserves placements'); + const offscreenStatistics = renderer.render({ + ...options, + id: 'deferred-offscreen-frame', + framebuffer: offscreenFramebuffer + }); + device.submit(); + testCase.equal(offscreenStatistics.drawCount, 1, 'deferred resolve honors caller framebuffer'); + + if (device.info.gpu !== 'software' && device.info.gpuType !== 'cpu' && !device.info.fallback) { + const layout = offscreenTexture.computeMemoryLayout({width: 1, height: 1}); + const readback = device.createBuffer({ + byteLength: layout.byteLength, + usage: Buffer.COPY_DST | Buffer.MAP_READ + }); + try { + offscreenTexture.readBuffer({x: 16, y: 16, width: 1, height: 1}, readback); + const pixel = await readback.readAsync(0, layout.byteLength); + testCase.ok( + pixel[0] > 0 || pixel[1] > 0 || pixel[2] > 0, + 'deferred lighting writes visible color into the supplied offscreen target' + ); + } finally { + readback.destroy(); + } + } else { + testCase.comment( + 'software WebGPU resolves the offscreen target without unsupported MAP_READ' + ); + } + surface.material.uniforms = {...surface.material.uniforms, transmissionFactor: 0.4}; const forwardStatistics = renderer.render(options); device.submit(); @@ -82,6 +126,8 @@ test('DeferredSceneRenderer resolves generic instanced PBR surfaces on WebGPU', testCase.equal(forwardStatistics.instanceCount, 2, 'forward fallback preserves placements'); } finally { renderer.destroy(); + offscreenFramebuffer.destroy(); + offscreenTexture.destroy(); } testCase.end(); }); diff --git a/modules/experimental/test/engine/scene-reference-pbr.spec.ts b/modules/experimental/test/engine/scene-reference-pbr.spec.ts index 60229a7269..259a56028d 100644 --- a/modules/experimental/test/engine/scene-reference-pbr.spec.ts +++ b/modules/experimental/test/engine/scene-reference-pbr.spec.ts @@ -119,6 +119,79 @@ test('SceneRenderer applies exposure, exact sRGB encoding, and selectable refere testCase.end(); }); +test('SceneRenderer presents clear backgrounds identically to physical fragments', async testCase => { + let testedDeviceCount = 0; + + for (const device of await getReferenceTestDevices()) { + if (isSoftwareBackedWebGL(device)) { + testCase.comment('software WebGL cannot reliably compile full-suite reference PBR variants'); + continue; + } + + testedDeviceCount++; + const renderer = new SceneRenderer(device); + const target = makeRenderTarget(device, 'rgba8unorm'); + const background: [number, number, number, number] = [0.2, 0.35, 0.6, 1]; + const matchingSurface: SceneSurface = { + id: `${device.type}-matching-background-surface`, + geometry: makeFullscreenGeometry(), + material: { + id: `${device.type}-matching-background-material`, + uniforms: {unlit: true, baseColorFactor: background} + }, + transforms: [new Matrix4()] + }; + const options = makeRenderOptions(device, [], target.framebuffer); + options.background = background; + + try { + for (const [exposure, toneMapMode, outputColorSpace] of [ + [1, 0, 'srgb'], + [2, 1, 'srgb'], + [1.5, 2, 'srgb'], + [2, 3, 'srgb'], + [2, 0, 'linear'] + ] as [number, number, 'linear' | 'srgb'][]) { + options.exposure = exposure; + options.toneMapMode = toneMapMode; + options.outputColorSpace = outputColorSpace; + options.surfaces = []; + testCase.equal(renderer.render(options).drawCount, 0, 'clears the uncovered target'); + device.submit(); + + if (supportsPixelReadback(device)) { + const clearPixel = await readUnsignedPixel(target.color, 16, 16); + options.surfaces = [matchingSurface]; + testCase.equal(renderer.render(options).drawCount, 1, 'draws the matching unlit surface'); + device.submit(); + const shadedPixel = await readUnsignedPixel(target.color, 16, 16); + + for (let channelIndex = 0; channelIndex < 4; channelIndex++) { + testCase.ok( + Math.abs(clearPixel[channelIndex] - shadedPixel[channelIndex]) <= 2, + `${device.type} matches background channel ${channelIndex} for exposure ${exposure}, tone map ${toneMapMode}, and ${outputColorSpace} output` + ); + } + } else { + options.surfaces = [matchingSurface]; + testCase.equal( + renderer.render(options).drawCount, + 1, + 'software WebGPU renders matching color' + ); + device.submit(); + } + } + } finally { + renderer.destroy(); + target.destroy(); + } + } + + testCase.ok(testedDeviceCount > 0, 'at least one portable background backend runs'); + testCase.end(); +}); + test('SceneRenderer preserves linear HDR radiance and captures chromatic physical dispersion', async testCase => { let testedDeviceCount = 0; diff --git a/modules/experimental/test/engine/scene-renderer.node.spec.ts b/modules/experimental/test/engine/scene-renderer.node.spec.ts index e67cd81ee1..47271f9924 100644 --- a/modules/experimental/test/engine/scene-renderer.node.spec.ts +++ b/modules/experimental/test/engine/scene-renderer.node.spec.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {readFileSync} from 'node:fs'; -import type {RenderPass, Texture} from '@luma.gl/core'; +import type {RenderPass, Texture, TextureFormatColor} from '@luma.gl/core'; import {Geometry} from '@luma.gl/engine'; import { createPBRMaterial, @@ -20,7 +20,7 @@ import { type SceneRenderOptions, type SceneSurface } from '@luma.gl/experimental'; -import {pbrMaterial, pbrScene, WGSLShaderAssembler} from '@luma.gl/shadertools'; +import {PBR_TONE_MAP_MODE, pbrMaterial, pbrScene, WGSLShaderAssembler} from '@luma.gl/shadertools'; import {getNullTestDevice} from '@luma.gl/test-utils'; import {Matrix4} from '@math.gl/core'; import {describe, expect, test} from 'vitest'; @@ -75,14 +75,19 @@ describe('scene rendering package architecture', () => { }); class InspectableSceneRenderer extends SceneRenderer { - readonly draws: {id: string; scene: PreparedScene}[] = []; + readonly draws: {id: string; scene: PreparedScene; clearColor?: number[]}[] = []; inspect(options: SceneRenderOptions) { return this.prepareScene(options); } protected override drawPreparedScene(scene: PreparedScene, renderPass: RenderPass): number { - this.draws.push({id: renderPass.id, scene}); + const clearColor = renderPass.props.clearColor; + this.draws.push({ + id: renderPass.id, + scene, + clearColor: clearColor ? Array.from(clearColor) : undefined + }); return super.drawPreparedScene(scene, renderPass); } } @@ -267,9 +272,135 @@ describe('shared PBR material factories', () => { expect(shader.source).not.toContain('pbrScene.environmentMipCount'); expect(shader.source).not.toContain('pbr_transmissionFramebufferSampler'); }); + + test('samples generated linear IBL directly while retaining legacy sRGB decoding', () => { + const sceneShader = new WGSLShaderAssembler().assembleWGSLShader({ + platformInfo: WEBGPU_PLATFORM, + source: PBR_MODEL_WGSL_SHADER, + modules: [pbrMaterial, pbrScene], + defines: { + HAS_NORMALS: true, + USE_IBL: true, + USE_SCENE_ENVIRONMENT: true, + MANUAL_SRGB: true + } + }); + const legacyShader = new WGSLShaderAssembler().assembleWGSLShader({ + platformInfo: WEBGPU_PLATFORM, + source: PBR_MODEL_WGSL_SHADER, + modules: [pbrMaterial], + defines: {HAS_NORMALS: true, USE_IBL: true, MANUAL_SRGB: true} + }); + + expect(sceneShader.source).toContain('let brdf = brdfSample.rgb;'); + expect(sceneShader.source).toContain('let diffuseLight = diffuseSample.rgb;'); + expect(sceneShader.source).toContain('let specularLight = specularSample.rgb;'); + expect(sceneShader.source).not.toContain('let brdf = SRGBtoLINEAR(brdfSample).rgb;'); + + expect(legacyShader.source).toContain('let brdf = SRGBtoLINEAR(brdfSample).rgb;'); + expect(legacyShader.source).toContain('let diffuseLight = SRGBtoLINEAR(diffuseSample).rgb;'); + expect(legacyShader.source).toContain('let specularLight = SRGBtoLINEAR(specularSample).rgb;'); + + expect(pbrMaterial.fs).toContain('vec3 brdf = brdfSample.rgb;'); + expect(pbrMaterial.fs).toContain('vec3 diffuseLight = diffuseSample.rgb;'); + expect(pbrMaterial.fs).toContain('vec3 specularLight = specularSample.rgb;'); + expect(pbrMaterial.fs).toContain('vec3 brdf = SRGBtoLINEAR(brdfSample).rgb;'); + }); }); describe('SceneRenderer', () => { + test('defaults every floating-point attachment to linear, untonemapped HDR output', async () => { + const device = await getNullTestDevice(); + const renderer = new InspectableSceneRenderer(device); + const surface: SceneSurface = { + id: 'hdr-format-surface', + geometry: makeGeometry(), + material: {id: 'hdr-format-material'}, + transforms: [new Matrix4()] + }; + + for (const [format, toneMapMode, outputEncoding] of [ + ['rgba16float', PBR_TONE_MAP_MODE.NONE, 0], + ['rgba32float', PBR_TONE_MAP_MODE.NONE, 0], + ['rg11b10ufloat', PBR_TONE_MAP_MODE.NONE, 0], + ['rgb9e5ufloat', PBR_TONE_MAP_MODE.NONE, 0], + ['rgba8unorm', PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL, 1], + ['rgba8unorm-srgb', PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL, 0] + ] as [TextureFormatColor, number, number][]) { + const texture = device.createTexture({width: 4, height: 4, format}); + const framebuffer = device.createFramebuffer({ + width: 4, + height: 4, + colorAttachments: [texture] + }); + framebuffer.colorAttachments.push(texture.view); + + try { + const options = {...makeOptions([surface]), id: `format-${format}`, framebuffer}; + const model = renderer.inspect(options).surfaces[0].model; + + expect(model.shaderInputs.getUniformValues().pbrScene, format).toMatchObject({ + toneMapMode, + outputEncoding + }); + } finally { + renderer.destroyFrame(`format-${format}`); + framebuffer.destroy(); + texture.destroy(); + } + } + + renderer.destroy(); + }); + + test('presents clear backgrounds exactly like fragments while keeping transmission linear', async () => { + const device = await getNullTestDevice(); + const renderer = new InspectableSceneRenderer(device); + const surface: SceneSurface = { + id: 'background-transmission-surface', + geometry: makeGeometry(), + material: { + id: 'background-transmission-material', + uniforms: {transmissionFactor: 0.5} + }, + transforms: [new Matrix4()] + }; + const options = makeOptions([surface]); + options.background = [0.125, 0.25, 0.5, 0.75]; + options.exposure = 2; + options.toneMapMode = PBR_TONE_MAP_MODE.NONE; + options.outputColorSpace = 'srgb'; + + try { + renderer.render(options); + + expect(renderer.draws).toHaveLength(2); + expect(renderer.draws[0].clearColor).toEqual([0.125, 0.25, 0.5, 0.75]); + expect(renderer.draws[1].clearColor?.[0]).toBeCloseTo(0.5370987, 5); + expect(renderer.draws[1].clearColor?.[1]).toBeCloseTo(0.735357, 5); + expect(renderer.draws[1].clearColor?.[2]).toBeCloseTo(1, 5); + expect(renderer.draws[1].clearColor?.[3]).toBe(0.75); + + options.transmission = false; + options.outputColorSpace = 'linear'; + for (const [toneMapMode, expectedRed] of [ + [PBR_TONE_MAP_MODE.NONE, 0.25], + [PBR_TONE_MAP_MODE.REINHARD, 0.2], + [PBR_TONE_MAP_MODE.KHRONOS_PBR_NEUTRAL, 0.19924786], + [PBR_TONE_MAP_MODE.ACES, 0.37411095] + ]) { + options.toneMapMode = toneMapMode; + renderer.render(options); + expect(renderer.draws.at(-1)?.clearColor?.[0], `tone mapper ${toneMapMode}`).toBeCloseTo( + expectedRed, + 5 + ); + } + } finally { + renderer.destroy(); + } + }); + test('retains one instanced draw across uniform-only material changes', async () => { const device = await getNullTestDevice(); const renderer = new InspectableSceneRenderer(device); diff --git a/modules/experimental/test/engine/scene-renderer.spec.ts b/modules/experimental/test/engine/scene-renderer.spec.ts index 4498396347..d9eeb6b61b 100644 --- a/modules/experimental/test/engine/scene-renderer.spec.ts +++ b/modules/experimental/test/engine/scene-renderer.spec.ts @@ -252,14 +252,68 @@ test('PBREnvironmentGenerator integrates cubemap roughness mips and renders port }, transforms: [new Matrix4()] }; - const options = makePhysicalRenderOptions(device, [surface]); - options.environment = environment; - testCase.equal( - renderer.render(options).drawCount, - 1, - `${device.type} shades generated IBL` - ); - device.submit(); + const environmentOutput = device.createTexture({ + id: `${device.type}-environment-output`, + width: 32, + height: 32, + format: 'rgba8unorm', + usage: Texture.RENDER | Texture.COPY_SRC + }); + const environmentDepth = device.createTexture({ + width: 32, + height: 32, + format: 'depth24plus', + usage: Texture.RENDER + }); + const environmentFramebuffer = device.createFramebuffer({ + width: 32, + height: 32, + colorAttachments: [environmentOutput], + depthStencilAttachment: environmentDepth + }); + try { + const options = makePhysicalRenderOptions(device, [surface], environmentFramebuffer); + options.environment = environment; + testCase.equal( + renderer.render(options).drawCount, + 1, + `${device.type} shades generated IBL` + ); + device.submit(); + + if (supportsPhysicalPixelReadback(device)) { + const ordinaryEnvironmentColor = await readPhysicalTestPixel(environmentOutput, 16, 16); + surface.material.defines = {MANUAL_SRGB: true}; + renderer.render(options); + device.submit(); + const manualSRGBEnvironmentColor = await readPhysicalTestPixel( + environmentOutput, + 16, + 16 + ); + + for (let channelIndex = 0; channelIndex < 3; channelIndex++) { + testCase.ok( + Math.abs( + ordinaryEnvironmentColor[channelIndex] - manualSRGBEnvironmentColor[channelIndex] + ) <= 2, + `${device.type} never decodes generated linear IBL channel ${channelIndex} twice` + ); + } + } else { + surface.material.defines = {MANUAL_SRGB: true}; + testCase.equal( + renderer.render(options).drawCount, + 1, + 'software WebGPU keeps IBL linear' + ); + device.submit(); + } + } finally { + environmentFramebuffer.destroy(); + environmentOutput.destroy(); + environmentDepth.destroy(); + } const srgbEnvironment = generator.prepare({ source, diff --git a/modules/experimental/test/engine/scene-review-deformation.node.spec.ts b/modules/experimental/test/engine/scene-review-deformation.node.spec.ts new file mode 100644 index 0000000000..3d02fa213f --- /dev/null +++ b/modules/experimental/test/engine/scene-review-deformation.node.spec.ts @@ -0,0 +1,71 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Geometry} from '@luma.gl/engine'; +import {SceneRenderer, type SceneRenderOptions, type SceneSurface} from '@luma.gl/experimental'; +import {getNullTestDevice} from '@luma.gl/test-utils'; +import {Matrix4} from '@math.gl/core'; +import {describe, expect, test} from 'vitest'; + +class InspectableSceneRenderer extends SceneRenderer { + inspect(options: SceneRenderOptions) { + return this.prepareScene(options); + } +} + +describe('reviewed scene skin palette specialization', () => { + test('keeps joint-bearing geometry unskinned until a nonempty palette is available', async () => { + const device = await getNullTestDevice(); + const renderer = new InspectableSceneRenderer(device); + const surface: SceneSurface = { + id: 'optional-skin-surface', + geometry: new Geometry({ + topology: 'triangle-list', + attributes: { + POSITION: {size: 3, value: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])}, + JOINTS_0: {size: 4, value: new Uint16Array(12)}, + WEIGHTS_0: { + size: 4, + value: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]) + } + } + }), + material: {id: 'optional-skin-material'}, + transforms: [new Matrix4()] + }; + const options: SceneRenderOptions = { + id: 'optional-skin-frame', + surfaces: [surface], + camera: { + viewMatrix: new Matrix4(), + projectionMatrix: new Matrix4(), + position: [0, 0, 5] + }, + width: 8, + height: 8 + }; + + try { + const unskinnedModel = renderer.inspect(options).surfaces[0].model; + expect(unskinnedModel.shaderInputs.getModules().map(module => module.name)).not.toContain( + 'skin' + ); + + surface.skin = {jointMatrices: new Float32Array(new Matrix4())}; + const skinnedModel = renderer.inspect(options).surfaces[0].model; + expect(skinnedModel).not.toBe(unskinnedModel); + expect(skinnedModel.shaderInputs.getModules().map(module => module.name)).toContain('skin'); + expect(skinnedModel.shaderInputs.getUniformValues()['skin'].jointMatrix[0]).toBe(1); + + surface.skin = {jointMatrices: new Float32Array()}; + const emptyPaletteModel = renderer.inspect(options).surfaces[0].model; + expect(emptyPaletteModel).not.toBe(skinnedModel); + expect(emptyPaletteModel.shaderInputs.getModules().map(module => module.name)).not.toContain( + 'skin' + ); + } finally { + renderer.destroy(); + } + }); +}); diff --git a/modules/gltf/src/gltf/gltf-skin.ts b/modules/gltf/src/gltf/gltf-skin.ts index 2adab4ed74..2332338844 100644 --- a/modules/gltf/src/gltf/gltf-skin.ts +++ b/modules/gltf/src/gltf/gltf-skin.ts @@ -107,9 +107,13 @@ function makeSkinBindings(props: GLTFSkinControllerProps): GLTFSkinBinding[] { } const sourceMesh = sourceNode.mesh; - const meshNode = node.children.find( - child => child instanceof GroupNode && child.id === (sourceMesh.name || sourceMesh.id) - ); + const ownedMesh = node.userData['gltfMesh']; + const meshNode = + ownedMesh instanceof GroupNode + ? ownedMesh + : node.children.find( + child => child instanceof GroupNode && child.id === (sourceMesh.name || sourceMesh.id) + ); if (!(meshNode instanceof GroupNode)) { continue; } diff --git a/modules/gltf/src/parsers/parse-gltf-lights.ts b/modules/gltf/src/parsers/parse-gltf-lights.ts index af227757aa..414199cc6d 100644 --- a/modules/gltf/src/parsers/parse-gltf-lights.ts +++ b/modules/gltf/src/parsers/parse-gltf-lights.ts @@ -9,6 +9,8 @@ import { } from '@luma.gl/shadertools'; export type ParseGLTFLightsOptions = { + /** Restricts authored lights to nodes belonging to the selected scene hierarchy. */ + nodeIdentifiers?: ReadonlySet; /** When true, parsed light colors are converted into luma.gl's legacy byte-style range. */ useByteColors?: boolean; }; @@ -34,7 +36,10 @@ export function parseGLTFLights( const lightIndex = (node as GLTFNodePostprocessed & {light?: number}).light ?? node.extensions?.KHR_lights_punctual?.light; - if (typeof lightIndex !== 'number') { + if ( + typeof lightIndex !== 'number' || + (options.nodeIdentifiers && !options.nodeIdentifiers.has(node.id)) + ) { // eslint-disable-next-line no-continue continue; } diff --git a/modules/gltf/src/parsers/parse-gltf.ts b/modules/gltf/src/parsers/parse-gltf.ts index bf27c11956..62dcae5027 100644 --- a/modules/gltf/src/parsers/parse-gltf.ts +++ b/modules/gltf/src/parsers/parse-gltf.ts @@ -17,7 +17,8 @@ import { MaterialFactory, ModelNode, type ModelProps, - type MorphTargetAttributes + type MorphTargetAttributes, + decodeMorphTargetAttribute } from '@luma.gl/engine'; import {pbrMaterial} from '@luma.gl/shadertools'; import {createGLTFMaterial, createGLTFModel} from '../gltf/create-gltf-model'; @@ -111,6 +112,8 @@ export function parseGLTF( const gltfNodeIdToNodeMap = new Map(); // Step 1/2: Generate a GroupNode for each gltf node. (1:1 mapping). const assignedMorphMeshes = new Set(); + const assignedMeshes = new Set(); + const independentlySkinnedMeshes = new Set(); gltf.nodes.forEach((gltfNode, idx) => { const newNode = createNodeForGLTFNode(device, gltfNode, combinedOptions); gltfNodeIndexToNodeMap.set(idx, newNode); @@ -148,14 +151,33 @@ export function parseGLTF( throw new Error(`Cannot find mesh child ${gltfNode.mesh.id} of node ${idx}`); } const node = gltfNodeIndexToNodeMap.get(idx)!; - node.add(mesh); + const sharedMesh = gltfMeshIdToNodeMap.get(sourceMesh.id); + const needsIndependentSkin = + assignedMeshes.has(sourceMesh.id) && + (gltfNode.skin !== undefined || independentlySkinnedMeshes.has(sourceMesh.id)); + const ownedMesh = + needsIndependentSkin && mesh === sharedMesh + ? createNodeForGLTFMesh( + device, + sourceMesh, + gltf, + gltfMaterialIdToMaterialMap, + combinedOptions + ) + : mesh; + node.add(ownedMesh); + node.userData['gltfMesh'] = ownedMesh; + assignedMeshes.add(sourceMesh.id); + if (gltfNode.skin !== undefined) { + independentlySkinnedMeshes.add(sourceMesh.id); + } if (hasMorphTargets) { assignedMorphMeshes.add(sourceMesh.id); const targetCount = sourceMesh.primitives.find(primitive => primitive.targets?.length)?.targets?.length || 0; const weights = gltfNode.weights || sourceMesh.weights || new Array(targetCount).fill(0); - node.userData['morphMeshes'] = [mesh]; + node.userData['morphMeshes'] = [ownedMesh]; setGLTFMorphWeights(node, weights); } } @@ -284,9 +306,8 @@ function createNodeForGLTFPrimitive({ typeof accessorReference === 'number' ? gltf.accessors[accessorReference] : accessorReference; - const values = accessor?.value; - if (values instanceof Float32Array) { - attributes[attributeName] = values; + if (accessor?.value && ArrayBuffer.isView(accessor.value)) { + attributes[attributeName] = decodeMorphTargetAttribute(accessor as GeometryAttribute); } } return attributes; @@ -330,7 +351,16 @@ function createGeometry(id: string, gltfPrimitive: any, topology: PrimitiveTopol for (const [attributeName, attribute] of Object.entries(gltfPrimitive.attributes)) { const {components, size, value, normalized} = attribute as GeometryAttribute; - attributes[attributeName] = {size: size ?? components, value, normalized}; + const isMorphAttribute = + attributeName === 'POSITION' || attributeName === 'NORMAL' || attributeName === 'TANGENT'; + const shouldDecode = Boolean(gltfPrimitive.targets?.length && isMorphAttribute); + attributes[attributeName] = { + size: size ?? components, + value: shouldDecode + ? decodeMorphTargetAttribute({value, normalized} as GeometryAttribute) + : value, + normalized: shouldDecode ? false : normalized + }; } return new Geometry({ diff --git a/modules/gltf/test/gltf/gltf-review-correctness.node.spec.ts b/modules/gltf/test/gltf/gltf-review-correctness.node.spec.ts new file mode 100644 index 0000000000..62fa6397ba --- /dev/null +++ b/modules/gltf/test/gltf/gltf-review-correctness.node.spec.ts @@ -0,0 +1,164 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {readFile} from 'node:fs/promises'; +import {parse} from '@loaders.gl/core'; +import {GLTFLoader, postProcessGLTF, type GLTFPostprocessed} from '@loaders.gl/gltf'; +import {ModelNode} from '@luma.gl/engine'; +import {createScenegraphsFromGLTF} from '@luma.gl/gltf'; +import {NullDevice} from '@luma.gl/test-utils'; +import {describe, expect, test} from 'vitest'; + +async function loadFixture(name: 'SimpleSkin.gltf' | 'AnimatedMorphCube.glb') { + const source = await readFile( + new URL(`../../../../examples/showcase/anari/public/gltf/${name}`, import.meta.url) + ); + return postProcessGLTF(await parse(source, GLTFLoader, {gltf: {loadImages: false}})); +} + +function destroyScenes(scenegraphs: ReturnType): void { + for (const scene of scenegraphs.scenes) { + scene.destroy(); + } +} + +describe('reviewed glTF deformation edge cases', () => { + test('gives every source node sharing a skinned SimpleSkin mesh its own model and palette', async () => { + const source = await loadFixture('SimpleSkin.gltf'); + const originalNode = source.nodes[0]; + const alternateSkin = { + ...source.skins![0], + id: 'independent-source-skin', + joints: [...source.skins![0].joints].reverse() + }; + source.skins!.push(alternateSkin); + const duplicateNode = { + ...originalNode, + id: 'independently-skinned-mesh', + name: 'independently-skinned-mesh', + translation: [3, 0, 0], + skin: alternateSkin + } as GLTFPostprocessed['nodes'][number]; + const duplicateNodeIndex = source.nodes.push(duplicateNode) - 1; + source.scenes[0].nodes.push(duplicateNode); + + const device = new NullDevice({}); + const scenegraphs = createScenegraphsFromGLTF(device, source); + try { + const originalSkin = scenegraphs.skins.getBinding(0); + const duplicateSkin = scenegraphs.skins.getBinding(duplicateNodeIndex); + expect(originalSkin?.models).toHaveLength(1); + expect(duplicateSkin?.models).toHaveLength(1); + expect(duplicateSkin?.skinIndex).toBe(1); + expect(duplicateSkin?.models[0]).not.toBe(originalSkin?.models[0]); + expect(duplicateSkin?.jointMatrices).not.toBe(originalSkin?.jointMatrices); + expect(duplicateSkin?.jointMatrices[12]).not.toBe(originalSkin?.jointMatrices[12]); + + const originalUniforms = + originalSkin!.models[0].model.shaderInputs.getUniformValues()['skin']; + const duplicateUniforms = + duplicateSkin!.models[0].model.shaderInputs.getUniformValues()['skin']; + expect(originalUniforms.jointMatrix[12]).not.toBe(duplicateUniforms.jointMatrix[12]); + + scenegraphs.animator.setTime(500); + expect( + originalSkin!.models[0].model.shaderInputs.getUniformValues()['skin'].jointMatrix[12] + ).not.toBe( + duplicateSkin!.models[0].model.shaderInputs.getUniformValues()['skin'].jointMatrix[12] + ); + } finally { + destroyScenes(scenegraphs); + device.destroy(); + } + }); + + test('binds the generated SimpleSkin mesh when a source child has the same display name', async () => { + const source = await loadFixture('SimpleSkin.gltf'); + const mesh = source.nodes[0].mesh!; + const duplicateNameChild = { + id: 'same-name-source-node', + name: mesh.name || mesh.id, + children: [] + } as GLTFPostprocessed['nodes'][number]; + source.nodes.push(duplicateNameChild); + source.nodes[0].children = [duplicateNameChild]; + + const device = new NullDevice({}); + const scenegraphs = createScenegraphsFromGLTF(device, source); + try { + const binding = scenegraphs.skins.getBinding(0); + expect(binding?.models).toHaveLength(1); + expect(binding?.models[0]).toBeInstanceOf(ModelNode); + expect(binding?.node.children[0].id).toBe(mesh.name || mesh.id); + expect(binding?.models[0].model.shaderInputs.getUniformValues()['skin']).toBeDefined(); + } finally { + destroyScenes(scenegraphs); + device.destroy(); + } + }); + + test('decodes quantized AnimatedMorphCube bases before creating animated GPU geometry', async () => { + const source = await loadFixture('AnimatedMorphCube.glb'); + const primitive = source.meshes[0].primitives[0]; + const positionAccessor = primitive.attributes['POSITION']; + const normalAccessor = primitive.attributes['NORMAL']; + const tangentAccessor = primitive.attributes['TANGENT']; + const sourcePositions = Uint16Array.from(positionAccessor.value, component => + Math.round(Math.max(0, Math.min(1, component * 0.25 + 0.5)) * 65535) + ); + const sourceNormals = Int8Array.from(normalAccessor.value, component => + Math.round(Math.max(-1, Math.min(1, component)) * 127) + ); + const sourceTangents = Int16Array.from(tangentAccessor.value, component => + Math.round(Math.max(-1, Math.min(1, component)) * 32767) + ); + Object.assign(positionAccessor, {value: sourcePositions, normalized: true}); + Object.assign(normalAccessor, {value: sourceNormals, normalized: true}); + Object.assign(tangentAccessor, {value: sourceTangents, normalized: true}); + + const device = new NullDevice({}); + const scenegraphs = createScenegraphsFromGLTF(device, source); + try { + let modelNode: ModelNode | undefined; + scenegraphs.scenes[0].traverse(node => { + if (node instanceof ModelNode && node.userData['morphTargets']) { + modelNode = node; + } + }); + expect(modelNode).toBeDefined(); + const state = modelNode!.userData['morphTargets'] as { + geometry: {attributes: Record}; + }; + for (const attributeName of ['POSITION', 'NORMAL', 'TANGENT']) { + expect(state.geometry.attributes[attributeName].value).toBeInstanceOf(Float32Array); + expect(state.geometry.attributes[attributeName].normalized).toBe(false); + } + expect((state.geometry.attributes['POSITION'].value as Float32Array)[0]).toBeCloseTo( + sourcePositions[0] / 65535, + 5 + ); + + const vertexBuffer = modelNode!.model._gpuGeometry!.attributes['geometry']; + const previousBytes = new Uint8Array(await vertexBuffer.readAsync()); + const weightChannel = scenegraphs.animations[0].channels.find( + channel => channel.type === 'node' && channel.path === 'weights' + ); + expect(weightChannel).toBeDefined(); + if (weightChannel?.type === 'node') { + const firstValues = weightChannel.sampler.output[0]; + const changedKeyframe = weightChannel.sampler.output.findIndex(values => + values.some((value, index) => value !== firstValues[index]) + ); + scenegraphs.animator.setTime(weightChannel.sampler.input[changedKeyframe] * 1000); + } + expect(Array.from(await vertexBuffer.readAsync())).not.toEqual(Array.from(previousBytes)); + expect(positionAccessor.value).toBe(sourcePositions); + expect(normalAccessor.value).toBe(sourceNormals); + expect(tangentAccessor.value).toBe(sourceTangents); + } finally { + destroyScenes(scenegraphs); + device.destroy(); + } + }); +}); diff --git a/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-glsl.ts b/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-glsl.ts index aa4b79eb76..292f1d0216 100644 --- a/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-glsl.ts +++ b/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-glsl.ts @@ -352,15 +352,24 @@ vec3 getIBLContribution(PBRInfo pbrInfo, vec3 n, vec3 reflection) #endif float lod = pbrInfo.perceptualRoughness * maximumMipLevel; // retrieve a scale and bias to F0. See [1], Figure 3 - vec3 brdf = SRGBtoLINEAR(texture(pbr_brdfLUT, - vec2(pbrInfo.NdotV, 1.0 - pbrInfo.perceptualRoughness))).rgb; - vec3 diffuseLight = SRGBtoLINEAR(texture(pbr_diffuseEnvSampler, environmentNormal)).rgb; + vec4 brdfSample = texture(pbr_brdfLUT, + vec2(pbrInfo.NdotV, 1.0 - pbrInfo.perceptualRoughness)); + vec4 diffuseSample = texture(pbr_diffuseEnvSampler, environmentNormal); #ifdef USE_TEX_LOD - vec3 specularLight = - SRGBtoLINEAR(textureLod(pbr_specularEnvSampler, environmentReflection, lod)).rgb; + vec4 specularSample = textureLod(pbr_specularEnvSampler, environmentReflection, lod); #else - vec3 specularLight = SRGBtoLINEAR(texture(pbr_specularEnvSampler, environmentReflection)).rgb; + vec4 specularSample = texture(pbr_specularEnvSampler, environmentReflection); +#endif + +#ifdef USE_SCENE_ENVIRONMENT + vec3 brdf = brdfSample.rgb; + vec3 diffuseLight = diffuseSample.rgb; + vec3 specularLight = specularSample.rgb; +#else + vec3 brdf = SRGBtoLINEAR(brdfSample).rgb; + vec3 diffuseLight = SRGBtoLINEAR(diffuseSample).rgb; + vec3 specularLight = SRGBtoLINEAR(specularSample).rgb; #endif vec3 diffuse = diffuseLight * pbrInfo.diffuseColor; diff --git a/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-wgsl.ts b/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-wgsl.ts index 0549224e14..922a297383 100644 --- a/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-wgsl.ts +++ b/modules/shadertools/src/modules/lighting/pbr-material/pbr-material-wgsl.ts @@ -513,40 +513,41 @@ fn getIBLContribution(pbrInfo: PBRInfo, n: vec3f, reflection: vec3f) -> vec3f #endif let lod = pbrInfo.perceptualRoughness * maximumMipLevel; // retrieve a scale and bias to F0. See [1], Figure 3 - let brdf = SRGBtoLINEAR( - textureSampleLevel( - pbr_brdfLUT, - pbr_brdfLUTSampler, - vec2f(pbrInfo.NdotV, 1.0 - pbrInfo.perceptualRoughness), - 0.0 - ) - ).rgb; - let diffuseLight = - SRGBtoLINEAR( - textureSampleLevel( - pbr_diffuseEnvSampler, - pbr_diffuseEnvSamplerSampler, - environmentNormal, - 0.0 - ) - ).rgb; - var specularLight = SRGBtoLINEAR( - textureSampleLevel( - pbr_specularEnvSampler, - pbr_specularEnvSamplerSampler, - environmentReflection, - 0.0 - ) - ).rgb; + let brdfSample = textureSampleLevel( + pbr_brdfLUT, + pbr_brdfLUTSampler, + vec2f(pbrInfo.NdotV, 1.0 - pbrInfo.perceptualRoughness), + 0.0 + ); + let diffuseSample = textureSampleLevel( + pbr_diffuseEnvSampler, + pbr_diffuseEnvSamplerSampler, + environmentNormal, + 0.0 + ); + var specularSample = textureSampleLevel( + pbr_specularEnvSampler, + pbr_specularEnvSamplerSampler, + environmentReflection, + 0.0 + ); #ifdef USE_TEX_LOD - specularLight = SRGBtoLINEAR( - textureSampleLevel( - pbr_specularEnvSampler, - pbr_specularEnvSamplerSampler, - environmentReflection, - lod - ) - ).rgb; + specularSample = textureSampleLevel( + pbr_specularEnvSampler, + pbr_specularEnvSamplerSampler, + environmentReflection, + lod + ); +#endif + +#ifdef USE_SCENE_ENVIRONMENT + let brdf = brdfSample.rgb; + let diffuseLight = diffuseSample.rgb; + let specularLight = specularSample.rgb; +#else + let brdf = SRGBtoLINEAR(brdfSample).rgb; + let diffuseLight = SRGBtoLINEAR(diffuseSample).rgb; + let specularLight = SRGBtoLINEAR(specularSample).rgb; #endif let diffuse = diffuseLight * pbrInfo.diffuseColor * pbrMaterial.scaleIBLAmbient.x; diff --git a/test/examples/framework-capabilities.node.spec.ts b/test/examples/framework-capabilities.node.spec.ts index a88bbd7b66..c6de885757 100644 --- a/test/examples/framework-capabilities.node.spec.ts +++ b/test/examples/framework-capabilities.node.spec.ts @@ -330,7 +330,8 @@ describe('framework capabilities documentation', () => { /sheen/i, /iridescen(?:ce|t)/i, /anisotrop(?:y|ic)/i, - /transmission[\s\S]{0,100}approximat(?:ion|ions|e)/i, + /transmission[^\n]*captured\s+scene\s+color/i, + /standalone\s+glTF\s+rendering\s+fallback[^\n]*approximate/i, /@luma\.gl\/arrow[\s\S]{0,80}private/i ]) { expect(capabilitiesSource, `The shared asset overview must explain ${capability}`).toMatch( @@ -338,6 +339,10 @@ describe('framework capabilities documentation', () => { ); } + expect(capabilitiesSource).toMatch(/Morph-target animation\s*\|\s*Available/i); + expect(capabilitiesSource).toMatch(/Existing joint-driven skinning\s*\|\s*Available/i); + expect(capabilitiesSource).toMatch(/Chromatic dispersion\s*\|\s*Available/i); + expect(anariGuide).not.toMatch(/\bnot\s+skinning\s+or\s+animations\b/i); expect(anariGuide).toMatch(/both\s+UV\s+sets[\s\S]{0,80}KHR_texture_transform/i); expect(anariGuide).toMatch( diff --git a/test/examples/renderer-review-correctness.node.spec.ts b/test/examples/renderer-review-correctness.node.spec.ts new file mode 100644 index 0000000000..fb807a45b0 --- /dev/null +++ b/test/examples/renderer-review-correctness.node.spec.ts @@ -0,0 +1,38 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {describe, expect, test} from 'vitest'; + +const RENDERER_REFERENCE_PAGES = [ + 'scene-renderer.md', + 'deferred-scene-renderer.md', + 'pbr-environment.md' +]; + +describe('renderer reference frame submission', () => { + for (const pageName of RENDERER_REFERENCE_PAGES) { + test(`${pageName} submits encoded work before destroying borrowed resources`, () => { + const page = readFileSync( + path.join(process.cwd(), 'docs/api-reference/experimental', pageName), + 'utf8' + ); + const snippets = [...page.matchAll(/```(?:ts|typescript)\n([\s\S]*?)```/g)].map( + match => match[1] + ); + const lifecycleExamples = snippets.filter( + snippet => + /renderer\.render\(/.test(snippet) && /(?:renderer|environment)\.destroy\(/.test(snippet) + ); + + expect(lifecycleExamples.length).toBeGreaterThan(0); + for (const snippet of lifecycleExamples) { + const submitPosition = snippet.indexOf('device.submit()'); + expect(submitPosition).toBeGreaterThan(snippet.indexOf('renderer.render(')); + expect(submitPosition).toBeLessThan(snippet.search(/(?:renderer|environment)\.destroy\(/)); + } + }); + } +});