diff --git a/modules/shadertools/src/lib/shader-assembly/assemble-shaders.ts b/modules/shadertools/src/lib/shader-assembly/assemble-shaders.ts index 7580b6fb03..16ad064437 100644 --- a/modules/shadertools/src/lib/shader-assembly/assemble-shaders.ts +++ b/modules/shadertools/src/lib/shader-assembly/assemble-shaders.ts @@ -932,13 +932,13 @@ function relocateWGSLModuleBindingMatch( const location = registryLocation !== undefined ? registryLocation - : relocationState.nextHintedBindingLocation === null - ? allocateAutoBindingLocation(group, context.usedBindingsByGroup) - : allocateAutoBindingLocation( - group, - context.usedBindingsByGroup, - relocationState.nextHintedBindingLocation - ); + : allocateAutoBindingLocation( + group, + context.usedBindingsByGroup, + module.name, + relocationState.nextHintedBindingLocation ?? undefined, + context.bindingRegistry + ); validateModuleWGSLBinding(module.name, group, location, name); if ( registryLocation !== undefined && @@ -1122,9 +1122,19 @@ function registerUsedBindingLocation( function allocateAutoBindingLocation( group: number, usedBindingsByGroup: Map>, - preferredBindingLocation?: number + moduleName: string, + preferredBindingLocation?: number, + bindingRegistry?: Map ): number { const usedBindings = usedBindingsByGroup.get(group) || new Set(); + const registeredBindingLocations = new Set(); + const registryGroupPrefix = `${group}:`; + const registryModulePrefix = `${registryGroupPrefix}${moduleName}:`; + for (const [registryKey, location] of bindingRegistry || []) { + if (registryKey.startsWith(registryModulePrefix)) { + registeredBindingLocations.add(location); + } + } let nextBinding = preferredBindingLocation ?? (group === 0 @@ -1133,10 +1143,17 @@ function allocateAutoBindingLocation( ? Math.max(...usedBindings) + 1 : 0); - while (usedBindings.has(nextBinding)) { + while (usedBindings.has(nextBinding) || registeredBindingLocations.has(nextBinding)) { nextBinding++; } + // Active modules were reserved above; only stale, inactive modules can own this free slot. + for (const [registryKey, location] of bindingRegistry || []) { + if (location === nextBinding && registryKey.startsWith(registryGroupPrefix)) { + bindingRegistry?.delete(registryKey); + } + } + return nextBinding; } 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 f4e3c00448..1c2a93bd78 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 @@ -900,14 +900,15 @@ fn calculateDiffuseTransmissionIBL( ); let rotatedNormal = environmentRotation * -pbrInfo.n.xz; let oppositeNormal = vec3f(rotatedNormal.x, -pbrInfo.n.y, rotatedNormal.y); - let environmentColor = textureSample( + let environmentColor = textureSampleLevel( pbr_diffuseEnvSampler, pbr_diffuseEnvSamplerSampler, - oppositeNormal + oppositeNormal, + 0.0 ).rgb * max(pbrScene.environmentIntensity, 0.0); #else let environmentColor = SRGBtoLINEAR( - textureSample(pbr_diffuseEnvSampler, pbr_diffuseEnvSamplerSampler, -pbrInfo.n) + textureSampleLevel(pbr_diffuseEnvSampler, pbr_diffuseEnvSamplerSampler, -pbrInfo.n, 0.0) ).rgb; #endif let nonReflectedEnergy = vec3f(1.0) - clamp(pbrInfo.reflectance0, vec3f(0.0), vec3f(1.0)); diff --git a/modules/shadertools/test/lib/shader-assembly/assemble-wgsl-auto-bindings.spec.ts b/modules/shadertools/test/lib/shader-assembly/assemble-wgsl-auto-bindings.spec.ts index 7a73e01f4d..f3e9a9764c 100644 --- a/modules/shadertools/test/lib/shader-assembly/assemble-wgsl-auto-bindings.spec.ts +++ b/modules/shadertools/test/lib/shader-assembly/assemble-wgsl-auto-bindings.spec.ts @@ -428,6 +428,46 @@ struct Group2RegistryBUniforms { ` }; +const PERMUTED_GROUP_3_TEXTURE_MODULE: ShaderModule = { + name: 'permutedGroup3TextureModule', + bindingLayout: [ + {name: 'permutedMaterial', group: 3}, + {name: 'pbr_baseColorSampler', group: 3}, + {name: 'pbr_baseColorSamplerSampler', group: 3}, + {name: 'pbr_transmissionSampler', group: 3}, + {name: 'pbr_transmissionSamplerSampler', group: 3} + ], + source: /* wgsl */ `\ +struct PermutedMaterialUniforms { + value: f32 +}; + +@group(3) @binding(0) var permutedMaterial: PermutedMaterialUniforms; + +#if HAS_BASECOLORMAP +@group(3) @binding(auto) var pbr_baseColorSampler: texture_2d; +@group(3) @binding(auto) var pbr_baseColorSamplerSampler: sampler; +#endif + +#if HAS_TRANSMISSIONMAP +@group(3) @binding(auto) var pbr_transmissionSampler: texture_2d; +@group(3) @binding(auto) var pbr_transmissionSamplerSampler: sampler; +#endif +` +}; + +const GROUP_3_EXPLICIT_REGISTRY_MODULE: ShaderModule = { + name: 'group3ExplicitRegistryModule', + bindingLayout: [{name: 'group3ExplicitRegistryBinding', group: 3}], + source: /* wgsl */ `\ +struct Group3ExplicitRegistryUniforms { + value: f32 +}; + +@group(3) @binding(1) var group3ExplicitRegistryBinding: Group3ExplicitRegistryUniforms; +` +}; + const MULTILINE_EXPLICIT_MODULE: ShaderModule = { name: 'multilineExplicitModule', bindingLayout: [{name: 'multilineExplicit', group: 2}], @@ -1159,6 +1199,129 @@ test('assembleWGSLShader#keeps module auto allocations stable within one assembl t.end(); }); +test('assembleWGSLShader#keeps disjoint texture permutations compatible when combined', t => { + const shaderAssembler = new WGSLShaderAssembler(); + const assemblePermutation = (defines: Record) => + shaderAssembler.assembleWGSLShader({ + platformInfo: PLATFORM_INFO, + source: APP_WGSL, + modules: [PERMUTED_GROUP_3_TEXTURE_MODULE], + defines + }); + + const transmissionOnly = assemblePermutation({HAS_TRANSMISSIONMAP: true}); + const baseColorOnly = assemblePermutation({HAS_BASECOLORMAP: true}); + const combined = assemblePermutation({HAS_BASECOLORMAP: true, HAS_TRANSMISSIONMAP: true}); + + const getBindingLocation = ( + assembledShader: ReturnType, + name: string + ): number | undefined => + assembledShader.bindingTable.find(binding => binding.name === name)?.binding; + + t.equal(getBindingLocation(transmissionOnly, 'pbr_transmissionSampler'), 1); + t.equal(getBindingLocation(transmissionOnly, 'pbr_transmissionSamplerSampler'), 2); + t.equal(getBindingLocation(baseColorOnly, 'pbr_baseColorSampler'), 3); + t.equal(getBindingLocation(baseColorOnly, 'pbr_baseColorSamplerSampler'), 4); + t.equal(getBindingLocation(combined, 'pbr_transmissionSampler'), 1); + t.equal(getBindingLocation(combined, 'pbr_transmissionSamplerSampler'), 2); + t.equal(getBindingLocation(combined, 'pbr_baseColorSampler'), 3); + t.equal(getBindingLocation(combined, 'pbr_baseColorSamplerSampler'), 4); + + t.end(); +}); + +test('assembleWGSLShader#reclaims bindings from inactive runtime-generated modules', t => { + const shaderAssembler = new WGSLShaderAssembler(); + const maximumBindingsPerGroup = 16; + let highestBindingLocation = 0; + + for (let moduleIndex = 0; moduleIndex < maximumBindingsPerGroup * 4; moduleIndex++) { + const moduleName = `runtimeGeneratedMaterial${moduleIndex}`; + const textureName = `runtimeGeneratedTexture${moduleIndex}`; + const runtimeGeneratedModule: ShaderModule = { + name: moduleName, + bindingLayout: [ + {name: textureName, group: 3}, + {name: `${textureName}Sampler`, group: 3} + ], + source: /* wgsl */ `\ +@group(3) @binding(auto) var ${textureName}: texture_2d; +@group(3) @binding(auto) var ${textureName}Sampler: sampler; +` + }; + const assembledShader = shaderAssembler.assembleWGSLShader({ + platformInfo: { + ...PLATFORM_INFO, + limits: {maxBindingsPerBindGroup: maximumBindingsPerGroup} + }, + source: APP_WGSL, + modules: [PERMUTED_GROUP_3_TEXTURE_MODULE, runtimeGeneratedModule], + defines: {HAS_TRANSMISSIONMAP: true} + }); + const textureBindingLocation = assembledShader.bindingTable.find( + binding => binding.name === textureName + )?.binding; + const samplerBindingLocation = assembledShader.bindingTable.find( + binding => binding.name === `${textureName}Sampler` + )?.binding; + + highestBindingLocation = Math.max( + highestBindingLocation, + textureBindingLocation ?? 0, + samplerBindingLocation ?? 0 + ); + } + + t.ok( + highestBindingLocation < maximumBindingsPerGroup, + 'historical runtime-generated modules never consume the available bind-group slots' + ); + t.equal( + highestBindingLocation, + 4, + 'active material bindings stay stable while generated texture/sampler pairs reuse their slots' + ); + + t.end(); +}); + +test('assembleWGSLShader#scopes inactive reservations to automatic bindings in their group', t => { + const shaderAssembler = new WGSLShaderAssembler(); + + shaderAssembler.assembleWGSLShader({ + platformInfo: PLATFORM_INFO, + source: APP_WGSL, + modules: [PERMUTED_GROUP_3_TEXTURE_MODULE], + defines: {HAS_TRANSMISSIONMAP: true} + }); + + const explicitShader = shaderAssembler.assembleWGSLShader({ + platformInfo: PLATFORM_INFO, + source: APP_WGSL, + modules: [GROUP_3_EXPLICIT_REGISTRY_MODULE] + }); + const isolatedGroupShader = shaderAssembler.assembleWGSLShader({ + platformInfo: PLATFORM_INFO, + source: APP_WGSL, + modules: [GROUP_2_REGISTRY_A] + }); + + t.equal( + explicitShader.bindingTable.find(binding => binding.name === 'group3ExplicitRegistryBinding') + ?.binding, + 1, + 'inactive automatic reservations do not invalidate explicit bindings in a different shader' + ); + t.equal( + isolatedGroupShader.bindingTable.find(binding => binding.name === 'group2RegistryA')?.binding, + 0, + 'inactive locations in another binding group do not change automatic allocation' + ); + + t.end(); +}); + test('assembleWGSLShader#rejects application group 0 bindings above reserved range', t => { const shaderAssembler = new WGSLShaderAssembler(); diff --git a/modules/shadertools/test/modules/lighting/pbr-material.spec.ts b/modules/shadertools/test/modules/lighting/pbr-material.spec.ts index af656b8ae4..c81d6f2522 100644 --- a/modules/shadertools/test/modules/lighting/pbr-material.spec.ts +++ b/modules/shadertools/test/modules/lighting/pbr-material.spec.ts @@ -8,12 +8,40 @@ import { getShaderModuleUniformLayoutValidationResult, getShaderModuleUniforms, type PBRMaterialUniforms, - pbrMaterial + type PlatformInfo, + pbrMaterial, + pbrScene, + WGSLShaderAssembler } from '@luma.gl/shadertools'; +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; import test from 'test/utils/vitest-tape'; const FLOAT32_EPSILON = 1e-6; +const WEBGPU_PLATFORM: PlatformInfo = { + type: 'webgpu', + shaderLanguage: 'wgsl', + shaderLanguageVersion: 300, + gpu: 'test', + features: new Set() +}; + +const DIFFUSE_TRANSMISSION_UNIFORMITY_SHADER = /* wgsl */ ` +@vertex +fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4f { + return vec4f(f32(vertexIndex), 0.0, 0.0, 1.0); +} + +@fragment +fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f { + fragmentInputs.pbr_vPosition = position.xyz; + fragmentInputs.pbr_vNormal = vec3f(0.0, 0.0, 1.0); + fragmentInputs.pbr_vUV0 = position.xy * 0.01; + fragmentInputs.pbr_vUV1 = fragmentInputs.pbr_vUV0; + return pbr_filterColor(vec4f(1.0)); +} +`; + const CORE_UNIFORM_BUFFER_LAYOUT = { unlit: {offset: 0, size: 1}, baseColorMapEnabled: {offset: 1, size: 1}, @@ -173,6 +201,68 @@ function almostEqual(actualValue: number, expectedValue: number): boolean { return Math.abs(actualValue - expectedValue) <= FLOAT32_EPSILON; } +test('shadertools#pbrMaterial compiles texture-dependent diffuse-transmission IBL on WebGPU', async testCase => { + const diffuseTransmissionSource = + pbrMaterial.source?.match(/fn calculateDiffuseTransmissionIBL\([\s\S]*?\n}\n#endif/)?.[0] || ''; + + testCase.ok(diffuseTransmissionSource, 'diffuse-transmission IBL helper is present'); + testCase.equal( + (diffuseTransmissionSource.match(/\btextureSampleLevel\(/g) || []).length, + 2, + 'scene and legacy environment paths use derivative-free cubemap sampling' + ); + testCase.notOk( + /\btextureSample\(/.test(diffuseTransmissionSource), + 'data-dependent transmission branches do not require uniform implicit derivatives' + ); + + const device = await getWebGPUTestDevice(); + if (!device) { + testCase.comment('WebGPU unavailable; diffuse-transmission source assertions still run'); + testCase.end(); + return; + } + + for (const useSceneEnvironment of [false, true]) { + const shaderSource = new WGSLShaderAssembler().assembleWGSLShader({ + platformInfo: WEBGPU_PLATFORM, + source: DIFFUSE_TRANSMISSION_UNIFORMITY_SHADER, + modules: useSceneEnvironment ? [pbrScene, pbrMaterial] : [pbrMaterial], + defines: { + HAS_NORMALS: true, + HAS_UV: true, + HAS_TRANSMISSIONMAP: true, + USE_IBL: true, + USE_MATERIAL_EXTENSIONS: true, + USE_SCENE_ENVIRONMENT: useSceneEnvironment + } + }).source; + const environmentName = useSceneEnvironment ? 'scene' : 'legacy'; + const shader = device.createShader({ + id: `pbr-diffuse-transmission-${environmentName}-uniformity`, + source: shaderSource + }); + + try { + const compilationErrors = (await shader.getCompilationInfo()) + .filter(message => message.type === 'error') + .map(message => message.message); + + testCase.equal( + compilationErrors.length, + 0, + `${environmentName} IBL compiles with a texture-dependent transmission factor${ + compilationErrors.length ? `: ${compilationErrors.join('; ')}` : '' + }` + ); + } finally { + shader.destroy(); + } + } + + testCase.end(); +}); + test('shadertools#pbrMaterial exposes typed defaults and uniform names', testCase => { const pbrMaterialUniformTypecheck: Required = pbrMaterial.defaultUniforms; testCase.ok(pbrMaterialUniformTypecheck, 'pbrMaterial default uniforms are typed');