From 187e9bb7c305bcc8c64d275f2816b612a59b73a3 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:26:09 +0200 Subject: [PATCH 01/10] feat(sdk): lambert, phong, toon, normal and matcap draw on the WebGL2 path (#527) --- packages/sdk-browser/src/host/surfaceGate.ts | 26 +++++++-- .../sdk-browser/src/scene/surfaceModel.ts | 22 +++++++- .../src/webgl/cluster/compatibility.test.ts | 10 ++-- .../src/webgl/cluster/materialBinding.test.ts | 13 +++++ .../src/webgl/cluster/materialBinding.ts | 2 + .../src/webgl/cluster/materialUniforms.ts | 2 +- .../sdk-browser/src/webgl/cluster/rectGlsl.ts | 10 +++- .../sdk-browser/src/webgl/cluster/shaders.ts | 10 ++-- .../src/webgl/cluster/surfaceModels.test.ts | 55 +++++++++++++++++++ 9 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts diff --git a/packages/sdk-browser/src/host/surfaceGate.ts b/packages/sdk-browser/src/host/surfaceGate.ts index 59c89c28d1..4075881fad 100644 --- a/packages/sdk-browser/src/host/surfaceGate.ts +++ b/packages/sdk-browser/src/host/surfaceGate.ts @@ -10,7 +10,6 @@ import type { HostAttribute, HostAttributes, HostMaterials } from './resources.ts'; import type { HostMap, HostShadedMaterial } from './shadedMaterial.ts'; -import { metalRough } from '../scene/surfaceModel.ts'; import { blendingOf, blendingRefusal } from '../scene/materialBlending.ts'; import { HOST_MAPPING_UV, HOST_NORMAL_MAP_TANGENT_SPACE } from './surfaceConstants.ts'; import { texelsReason } from '../visibility/types.ts'; @@ -32,6 +31,20 @@ const textureReason = (texture: HostMap) => { * its own, not as one view interleaved into a shared one. */ const ownBuffer = (attribute: HostAttribute | undefined) => attribute?.kind === 'attribute'; +/** The families the WebGL2 program draws: the physical ones, the unlit basic and depth, and the + * others mapped onto the one model (`../scene/surfaceModel.ts`), each shaded as WebGPU does. */ +const DRAWN_FAMILIES = new Set([ + 'standard', + 'physical', + 'basic', + 'depth', + 'lambert', + 'phong', + 'toon', + 'normal', + 'matcap', +]); + /** * Names material input the autonomous WebGL2 program cannot preserve before it submits a draw. * A transmissive physical material is accepted only where `transmissive` says the draw reads @@ -44,8 +57,7 @@ export function clusterMaterialReason( ) { if (Array.isArray(material)) return 'material arrays are unsupported'; const host = material as HostShadedMaterial; - if (!metalRough(host) && host.family !== 'basic' && host.family !== 'depth') - return `material ${host.family} is unsupported`; + if (!DRAWN_FAMILIES.has(host.family)) return `material ${host.family} is unsupported`; // The draws' own refusal (`drawnBlending`): a mode admitted here is one every path draws. const refusal = blendingRefusal(blendingOf(host.blending), isTransmissive(material)); if (refusal) return `material ${host.family}: ${refusal} (blending ${host.blending})`; @@ -88,11 +100,13 @@ export function clusterMaterialReason( return 'textured material has no UV attribute'; if (maps.some((texture) => texture?.channel === 1) && !ownBuffer(attributes.uv1)) return 'texture channel 1 has no UV1 attribute'; - if (metalRough(host) && !ownBuffer(attributes.normal)) - return 'lit material has no normal attribute'; + // Every family but basic and depth reads the normal: to light, to show it, or to find its matcap. + if (host.family !== 'basic' && host.family !== 'depth' && !ownBuffer(attributes.normal)) + return `${host.family} material has no normal attribute`; if (host.vertexColors && !ownBuffer(attributes.color)) return 'vertex-colour material has no color attribute'; - for (const texture of maps) { + // A matcap's image is read by the normal, never by a UV: checked as a map, not asked a UV. + for (const texture of [...maps, host.matcap]) { const reason = textureReason(texture); if (reason) return reason; } diff --git a/packages/sdk-browser/src/scene/surfaceModel.ts b/packages/sdk-browser/src/scene/surfaceModel.ts index 2397cdac80..44f992a1c3 100644 --- a/packages/sdk-browser/src/scene/surfaceModel.ts +++ b/packages/sdk-browser/src/scene/surfaceModel.ts @@ -62,6 +62,11 @@ export const litModel = (host: HostShadedMaterial, model: number) => export const shininessRoughness = (shininess: number) => Math.sqrt(2 / (Math.max(0, shininess) + 2)); +/** The toon cosine at `nl` = N·L, and the matcap coordinate of a view-space normal `n`: the one text + * both languages read, WGSL here and GLSL in `SURFACE_MODEL_GLSL`. */ +const TOON_BANDS = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; +const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; + /** * What a declared lamp gives a pixel of a diffuse or toon surface, read by `declaredLight` through * the private `surfaceModel` the resolve sets from the surface flag. Toon keeps the lamp's energy — @@ -72,7 +77,7 @@ var surfaceModel:u32; fn modelLight(rgb:vec3f,metal:f32,N:vec3f,L:vec3f,energy:f32,ao:f32)->vec3f{ let diffuse=rgb*(1.0-metal)*${INVERSE_PI}*energy*ao; let nl=dot(N,L); - if(surfaceModel==${MODEL_FLAG.toon}u){return diffuse*mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5));} + if(surfaceModel==${MODEL_FLAG.toon}u){return diffuse*${TOON_BANDS};} return diffuse*max(nl,0.0); }`; @@ -88,4 +93,17 @@ fn viewNormal(N:vec3f)->vec3f{ let up=normalize(vec3f(uni.viewProj[0].y,uni.viewProj[1].y,uni.viewProj[2].y)); return vec3f(dot(N,right),dot(N,up),dot(N,cross(right,up))); } -fn matcapUv(N:vec3f)->vec2f{let n=viewNormal(N);return vec2f(n.x*0.495+0.5,0.5-n.y*0.495);}`; +fn matcapUv(N:vec3f)->vec2f{let n=viewNormal(N);return ${matcapAt('vec2f')};}`; + +/** + * The same models in the WebGL2 cluster program (`../webgl/cluster/shaders.ts`), whose normals are + * already in view space: `surfaceModel` holds the rank of `SURFACE_MODEL`, a diffuse or toon + * surface takes `modelLight` for each lamp in place of the physical lobes, and a matcap reads its + * image at `matcapUv`. + */ +export const SURFACE_MODEL_GLSL = ` +uniform int surfaceModel; +bool bandedModel(){return surfaceModel==${SURFACE_MODEL.diffuse}||surfaceModel==${SURFACE_MODEL.toon};} +vec3 modelLight(vec3 rgb,float metal,vec3 N,vec3 L,float energy,float ao){vec3 diffuse=rgb*(1.0-metal)*${INVERSE_PI}*energy*ao; +float nl=dot(N,L);if(surfaceModel==${SURFACE_MODEL.toon})return diffuse*${TOON_BANDS};return diffuse*max(nl,0.0);} +vec2 matcapUv(vec3 n){return ${matcapAt('vec2')};}`; diff --git a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts index db8ec4b492..9ffe4c6a09 100644 --- a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts @@ -17,19 +17,17 @@ test('an untextured Basic material needs no unused UV or normal attribute', () = assert.equal(clusterMaterialReason(G.basicSurface(), { position }), undefined); }); -test('a Depth material draws on the autonomous path; another unlit family is still refused', () => { +test('a Depth material draws on the autonomous path; a family no path draws is refused', () => { assert.equal(clusterMaterialReason(new G.GraphSurface('depth'), { position }), undefined); - assert.match( - clusterMaterialReason(new G.GraphSurface('normal'), { position })!, - /normal is unsupported/, - ); + const shader = { family: 'shader' } as unknown as G.GraphSurface; + assert.equal(clusterMaterialReason(shader, { position }), 'material shader is unsupported'); }); test('unsupported mutations refuse the autonomous draw before it becomes partial', () => { const material = G.standardSurface(); assert.equal( clusterMaterialReason(material, { position }), - 'lit material has no normal attribute', + 'standard material has no normal attribute', ); material.transparent = true; assert.equal( diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 224a738f59..5c1f27e1cf 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -136,3 +136,16 @@ test('Into the effect chain, a surface covers its pixel as the display path show // A mode no path draws is refused here as by the display path, never drawn uncovered. assert.throws(() => flagOf(transparent(99), 'covering', true), /a surface declares a blending/); }); + +test('Each family reaches the WebGL2 program in the model it reads on WebGPU (#527)', () => { + const models = { standard: 0, phong: 0, basic: 0, lambert: 1, toon: 2, normal: 3, matcap: 4 }; + for (const [family, model] of Object.entries(models)) + assert.equal(flagOf(new G.GraphSurface(family as 'standard'), 'surfaceModel'), model, family); + // A matcap's image is bound as the base map, the one the program reads at the normal. + const masks = new Map(); + const { binding } = recorder(); + (binding.uniforms as unknown as Record).i1 = (_: number, n: string, v: number) => + void masks.set(n, v); + bindClusterMaterial(binding, new G.GraphSurface('matcap', { matcap: texture() }), true); + assert.equal(masks.get('mapMask'), 1); +}); diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 8e936a431e..3efa7e2821 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -130,6 +130,8 @@ export function bindClusterMaterial( backSide = side === undefined ? mat.backSide : side === 'back'; // A depth material shows the frame's depth ramp in place of its colour (`beginFrame`). uniforms.i1(35, 'depthShaded', mat.model === SURFACE_MODEL.depth ? 1 : 0); + // The model a non-physical family reads in (`SURFACE_MODEL_GLSL`): lambert, toon, normal, matcap. + uniforms.i1(48, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); // A diagnostic view's surface is shown as it is, never through the fog // (`../../host/pageDiagnostics.ts`). uniforms.i1(38, 'fogFree', (material as { fog?: boolean }).fog === false ? 1 : 0); diff --git a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts index 174d66027b..efb1dbc4d4 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts @@ -1,5 +1,5 @@ export class WebglClusterMaterialUniforms { - private values = new Float64Array(48).fill(Number.NaN); + private values = new Float64Array(49).fill(Number.NaN); private gl: WebGL2RenderingContext; private at: (name: string) => WebGLUniformLocation | null; constructor(gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null) { diff --git a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts index 63e23a214e..27220706cb 100644 --- a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts +++ b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts @@ -1,5 +1,6 @@ import { LTC_SIZE, ltcTable } from '../../../../sdk-core/src/lighting/ltcTable.ts'; import { INVERSE_TWO_PI } from '../../lighting/shaderConstants.ts'; +import { SURFACE_MODEL } from '../../scene/surfaceModel.ts'; /** The WebGL2 program's rank of a rectangle in `lightData`, after its ambient (3). */ export const WEBGL_RECT_KIND = 4; @@ -11,7 +12,8 @@ export const LTC_UNIT = 8; * (`../../lighting/direct/rectLightWgsl.ts`), operation for operation, on the same fitted table (`ltcTable.ts`), * read here as a `2·LTC_SIZE × LTC_SIZE` float texture — texel `(2x + k, y)` is the table's cell * `(x, y)`, vec4 `k`. The diffuse is the exact clipped form factor of the rectangle; the - * specular, the rectangle moved by the fitted M⁻¹ and integrated the same way. No cast shadow, + * specular, the rectangle moved by the fitted M⁻¹ and integrated the same way; a diffuse or toon + * surface takes the diffuse alone through `modelLight` (`../../scene/surfaceModel.ts`). No cast shadow, * as on the WebGPU path: the contract refuses one for a rectangle. * * A light's four vec4 hold its centre and range, its emitting normal and rank, its colour and @@ -28,10 +30,12 @@ vec4 ltcLookup(float rough,float NdotV,int k){vec2 at=vec2(clamp(rough,0.0,1.0), ivec2 i=min(ivec2(at),ivec2(LTC_SIZE-2));vec2 f=at-vec2(i);vec4 low=mix(ltcTexel(i.x,i.y,k),ltcTexel(i.x+1,i.y,k),f.x); return mix(low,mix(ltcTexel(i.x,i.y+1,k),ltcTexel(i.x+1,i.y+1,k),f.x),f.y);} vec3 ltcCorner(vec3 q,vec3 T1,vec3 T2,vec3 N,vec4 m){float x=dot(q,T1),z=dot(q,N);return vec3(m.x*x+m.y*z,dot(q,T2),m.z*x+m.w*z);} -vec3 rectLight(vec4 positionRange,vec3 n,vec4 axis,vec4 colorIntensity,vec3 N,vec3 V,vec3 P,vec3 base,float metal,float rough){ +vec3 rectLight(vec4 positionRange,vec3 n,vec4 axis,vec4 colorIntensity,vec3 N,vec3 V,vec3 P,vec3 base,float metal,float rough,float ao){ vec3 C=positionRange.xyz,U=axis.xyz,W=normalize(cross(U,n))*axis.w;if(dot(P-C,n)<=0.0)return vec3(0.0); float window=rangeWindow(length(C-P),positionRange.w); -vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;float E=colorIntensity.w*PI*polygonFormFactor(a,b,c,d,N).w*window;if(E<=0.0)return vec3(0.0); +vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;vec4 F=polygonFormFactor(a,b,c,d,N);float E=colorIntensity.w*PI*F.w*window;if(E<=0.0)return vec3(0.0); +if(surfaceModel==${SURFACE_MODEL.diffuse})return modelLight(base,metal,N,N,E,ao)*colorIntensity.rgb; +if(surfaceModel==${SURFACE_MODEL.toon})return modelLight(base,metal,N,F.xyz,colorIntensity.w*PI*polygonFormFactor(a,b,c,d,F.xyz).w*window,ao)*colorIntensity.rgb; float NdotV=clamp(dot(N,V),1e-4,1.0);vec3 side=V-N*dot(N,V),other=cross(N,abs(N.x)>0.9?vec3(0.0,1.0,0.0):vec3(1.0,0.0,0.0)); vec3 T1=normalize(dot(side,side)<1e-10?other:side),T2=cross(N,T1);vec4 m=ltcLookup(rough,NdotV,0),t=ltcLookup(rough,NdotV,1); float lobe=polygonFormFactor(ltcCorner(a,T1,T2,N,m),ltcCorner(b,T1,T2,N,m),ltcCorner(c,T1,T2,N,m),ltcCorner(d,T1,T2,N,m),vec3(0.0,0.0,1.0)).w; diff --git a/packages/sdk-browser/src/webgl/cluster/shaders.ts b/packages/sdk-browser/src/webgl/cluster/shaders.ts index 79fe8db39d..333c8349f1 100644 --- a/packages/sdk-browser/src/webgl/cluster/shaders.ts +++ b/packages/sdk-browser/src/webgl/cluster/shaders.ts @@ -6,6 +6,7 @@ import { INVERSE_PI, PI, ROUGHNESS_FLOOR } from '../../lighting/shaderConstants. import { FOG_GLSL } from '../../lighting/fogShader.ts'; import { LINE_CLIP_GLSL, LINE_DASH_GLSL } from '../../visibility/shader/lineWgsl.ts'; import { SPRITE_GLSL } from '../../visibility/shader/spriteWgsl.ts'; +import { SURFACE_MODEL, SURFACE_MODEL_GLSL } from '../../scene/surfaceModel.ts'; // An instanced mesh places each copy by its own matrix before the mesh's: the position first, // then the normal, scaled back by the matrix's axes before it is turned — the reference's order. @@ -71,6 +72,7 @@ float attenuation(float distance,float range,float decay){float falloff=1.0/max( if(range>0.0){float r=distance/range,r2=r*r,s=clamp(1.0-r2*r2,0.0,1.0);falloff*=s*s;}return falloff;} float spotFactor(float cosine,float inner,float outer){return inner<=outer?(cosine>=outer?1.0:0.0):smoothstep(outer,inner,cosine);} ${OUTPUT_TRANSFER_GLSL} +${SURFACE_MODEL_GLSL} ${RECT_LIGHT_GLSL} ${PROBE_IRRADIANCE_GLSL} ${FOG_GLSL} @@ -84,14 +86,14 @@ vec3 shade(vec3 N,vec3 V,vec3 base,float metal,float rough,float ao){vec3 diffus vec3 direct=vec3(0.0),specular=vec3(0.0),irradiance=vec3(0.0);for(int i=0;i=lightCount)break; vec4 positionRange=lightData[i*4],directionKind=lightData[i*4+1],colorIntensity=lightData[i*4+2],cone=lightData[i*4+3]; int kind=int(directionKind.w);if(kind==3){irradiance+=colorIntensity.rgb;continue;} -if(kind==${WEBGL_RECT_KIND}){direct+=rectLight(positionRange,directionKind.xyz,cone,colorIntensity,N,V,viewPosition,base,metal,rough);continue;} +if(kind==${WEBGL_RECT_KIND}){direct+=rectLight(positionRange,directionKind.xyz,cone,colorIntensity,N,V,viewPosition,base,metal,rough,ao);continue;} vec3 L,color=colorIntensity.rgb;if(kind==0)L=directionKind.xyz;else{vec3 toLight=positionRange.xyz-viewPosition;L=normalize(toLight); if(kind==2){float s=spotFactor(dot(L,directionKind.xyz),cone.x,cone.y);if(s<=0.0)continue;color=color*s;} color*=attenuation(length(toLight),positionRange.w,cone.z);} -vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} +if(bandedModel()){direct+=modelLight(base,metal,N,L,1.0,ao)*color;continue;}vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} irradiance+=probeIrradiance(N);return(direct+irradiance*(INVERSE_PI*diffuse)*ao)+specular;} ${TRANSMISSION_GLSL} -void main(){if(!lineDash(texcoord0.x,dash))discard;viewPosition=-toEye;vec4 base=baseFactor;if((mapMask&1)!=0)base*=texture(baseMap,mapUv(baseUv,sourceUv(mapChannels.x)));if(hasVertexColor)base*=vertexColor;if(base.a { + for (const family of FAMILIES) { + const surface = new G.GraphSurface(family); + assert.equal(clusterMaterialReason(surface, { position, normal }), undefined, family); + assert.equal( + clusterMaterialReason(surface, { position }), + `${family} material has no normal attribute`, + ); + } +}); + +test("A matcap's image is checked as a map, and asks for no UV: the normal reads it", () => { + const image = G.dataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1, G.HOST_FORMAT_RGBA); + const matcap = (texture: unknown) => new G.GraphSurface('matcap', { matcap: texture }); + assert.equal(clusterMaterialReason(matcap(image), { position, normal }), undefined); + const empty = G.dataTexture(new Uint8Array(4), 1, 1, G.HOST_FORMAT_RGBA); + empty.image = undefined as never; + assert.equal( + clusterMaterialReason(matcap(empty), { position, normal }), + 'texture image is unavailable', + ); +}); + +test('The two languages read one toon band and one matcap coordinate', () => { + const band = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; + assert.ok(SURFACE_MODEL_LIGHT_WGSL.includes(`diffuse*${band}`)); + assert.ok(SURFACE_MODEL_GLSL.includes(`diffuse*${band}`)); + assert.ok(SURFACE_MODEL_SHADE_WGSL.includes('return vec2f(n.x*0.495+0.5,0.5-n.y*0.495);')); + assert.ok(SURFACE_MODEL_GLSL.includes('return vec2(n.x*0.495+0.5,0.5-n.y*0.495);')); + // Each lamp gives a diffuse or toon surface the model's lobe alone, never the specular one. + assert.match( + CLUSTER_FRAGMENT, + /if\(bandedModel\(\)\)\{direct\+=modelLight\([^;]+;continue;\}vec3 E=/, + ); + // A matcap reads its base map at the view normal; a normal surface shows it, after the fog. + assert.match(CLUSTER_FRAGMENT, /surfaceModel==4\?matcapUv\(normalize\(viewNormal\)\)/); + assert.match(CLUSTER_FRAGMENT, /rgb=fogged\(rgb\);[^]*if\(surfaceModel==3\)rgb=N\*0\.5\+0\.5;/); +}); From c72ee51615395b0c97e382e75a02a74abc1b74ad Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:26:10 +0200 Subject: [PATCH 02/10] fix(sdk): click-to-pick framed closer so its ring fills the view on both backends (#527) --- scripts/docs-examples-draw.test.ts | 9 +++++---- site/examples/click-to-pick.html | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/docs-examples-draw.test.ts b/scripts/docs-examples-draw.test.ts index e26f673939..4cbaf51813 100644 --- a/scripts/docs-examples-draw.test.ts +++ b/scripts/docs-examples-draw.test.ts @@ -48,8 +48,8 @@ test('a sparse example is declared by name and backend under the tenth; every ot assert.ok(share > 0 && share < 0.1, id); } // The shares declared under those measured on 2026-09-24, and the examples never declared: the - // three a refused WebGL2 session left blank, and save-the-scene, whose ground fills its frame - // (#717). They keep the tenth on both backends. + // three a refused WebGL2 session left blank, save-the-scene, whose ground fills its frame + // (#717), and click-to-pick, framed closer (#527). They keep the tenth on both backends. const named = [ 'a-staircase-from-one-step', 'a-cloud-of-points', @@ -58,13 +58,14 @@ test('a sparse example is declared by name and backend under the tenth; every ot 'fly-over-a-model-town', 'from-a-grain-to-a-planet', 'snow-of-sprites', + 'click-to-pick', ]; for (const id of named) assert.ok(ids.has(id), id); // A new declaration is asserted here too, with its literal share. for (const id of Object.keys(SPARSE)) assert.ok(named.includes(id), id); const least = (gpu: boolean) => named.map((id) => leastDrawn(id, gpu)); - assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1]); - assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); + assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); + assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); }); test('the proof expects Jolt from the pages whose own source turns physics on, and from no other', async () => { diff --git a/site/examples/click-to-pick.html b/site/examples/click-to-pick.html index 5838737025..ecea6eea50 100644 --- a/site/examples/click-to-pick.html +++ b/site/examples/click-to-pick.html @@ -38,7 +38,8 @@ mesh.name = names[i]; world.scene.add(mesh); }); - world.camera.position.set(0, 4, 6); + // Close enough that the ring fills the view: six shapes on a wide grid are a small target. + world.camera.position.set(0, 3.2, 4.8); world.camera.lookAt(0, 0, 0); controls({}); From a87171b81e7057552fc00b66f3d09d5aa0bc0212 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:26:10 +0200 Subject: [PATCH 03/10] fix(sdk): the camera-controls proof waits for the pointer lock and spends the move the head drops (#527) --- .../src/camera/controls/firstPerson.test.ts | 18 ++++++++++++++++++ .../renders/native-camera-controls.browser.ts | 13 +++++++++---- tests/browser/support/cameraControlsPage.ts | 12 ++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts index 91f521b466..6188fdc2cd 100644 --- a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts +++ b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts @@ -115,3 +115,21 @@ test('first person turns 0.002 radians per pixel until `lookSpeed` is set', () = controls.update(0); assert.equal(round(Math.atan2(facing(camera)[0], -facing(camera)[2])), round(0.2)); }); + +test("the examples proof's look: after the lock, one dropped move, then four out and four back return the head (#527)", () => { + const { camera, surface, controls } = steered(createFirstPersonCameraControls); + const looking = () => [...facing(camera)].map((v) => round(v)); + controls.update(0); + const home = looking(); + surface.fire('pointerdown', { pointerId: 1, button: 0, clientX: 0, clientY: 0 }); + surface.key('pointerlockchange', {}); + const move = (movementX: number) => + surface.fire('pointermove', { pointerId: 1, movementX, movementY: 0 }); + move(1); + for (let step = 0; step < 4; step++) move(20); + controls.update(0); + assert.notDeepEqual(looking(), home, 'the four moves out turn the head'); + for (let step = 0; step < 4; step++) move(-20); + controls.update(0); + assert.deepEqual(looking(), home); +}); diff --git a/tests/browser/renders/native-camera-controls.browser.ts b/tests/browser/renders/native-camera-controls.browser.ts index 56dea78d1e..5a1111f7ca 100644 --- a/tests/browser/renders/native-camera-controls.browser.ts +++ b/tests/browser/renders/native-camera-controls.browser.ts @@ -13,6 +13,7 @@ import { endGesture, loadedModules, openProbe, + pointerLocked, stepGesture, } from '../support/cameraControlsPage.ts'; import type { Page } from 'playwright'; @@ -76,9 +77,9 @@ try { assert.ok(result.changes > 0, `${kind} emitted no change`); assert.ok(result.bytes > 0, `${kind} drew nothing`); assert.notDeepEqual(moved, home, `${kind} did not move the camera`); - // The gesture and its reverse leave the very image the pose started from. - assert.equal(result.differences, 0, `${kind} did not come back to its pose`); + // The gesture and its reverse leave the pose it started from, then the very image. assert.deepEqual(result.pose, home, `${kind} did not come back to its pose`); + assert.equal(result.differences, 0, `${kind} did not come back to its image`); }; await run('controls', async () => { await drag(page, 120, 0); @@ -118,12 +119,16 @@ try { }); // A locked pointer turns the head on every move, button or not, so the look here is one // press, out and back, rather than two drags that would each reposition the cursor first. + // Once the lock is granted the head drops the first move by design, the cursor's jump (#385): + // one move is spent on it, then four out and four back all count. await run('firstPersonControls', async () => { await page.mouse.move(centre.x, centre.y); await page.mouse.down(); - await page.mouse.move(centre.x + 80, centre.y, { steps: 4 }); + await page.evaluate(pointerLocked); + await page.mouse.move(centre.x + 1, centre.y); + await page.mouse.move(centre.x + 81, centre.y, { steps: 4 }); const moved = await page.evaluate(stepGesture, 0); - await page.mouse.move(centre.x, centre.y, { steps: 4 }); + await page.mouse.move(centre.x + 1, centre.y, { steps: 4 }); await page.mouse.up(); await page.evaluate(stepGesture, 0); await hold(page, 'w'); diff --git a/tests/browser/support/cameraControlsPage.ts b/tests/browser/support/cameraControlsPage.ts index 55ad20ba2a..193f43a8cb 100644 --- a/tests/browser/support/cameraControlsPage.ts +++ b/tests/browser/support/cameraControlsPage.ts @@ -102,6 +102,18 @@ export async function endGesture() { return { differences, pose: probe.pose(), changes: probe.changes, bytes: image.length }; } +/** Resolves once the pointer lock a press asked for is granted, at once if it already is; a + * refused lock rejects. Waited for as the event, never by a fixed delay. */ +export function pointerLocked() { + return new Promise((granted, refused) => { + if (document.pointerLockElement) return granted(); + document.addEventListener('pointerlockchange', () => granted(), { once: true }); + document.addEventListener('pointerlockerror', () => refused(new Error('lock refused')), { + once: true, + }); + }); +} + /** Every module the page fetched, for the proof that no addon of the host is among them. */ export function loadedModules() { return performance.getEntriesByType('resource').map((entry) => entry.name); From 6d4e128dd2c126ca34109e1415a7b31e4ffbc964 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:26:10 +0200 Subject: [PATCH 04/10] test(sdk): the default-backend proof names the machine that drew nothing (#527) --- tests/browser/renders/default-backend.browser.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/browser/renders/default-backend.browser.ts b/tests/browser/renders/default-backend.browser.ts index 9388f32f76..a487c328f5 100644 --- a/tests/browser/renders/default-backend.browser.ts +++ b/tests/browser/renders/default-backend.browser.ts @@ -142,8 +142,15 @@ try { JSON.stringify(withoutGpu.cases.default.metrics), ); // The canvas is not empty: a twentieth of it at least differs from the cleared background. - for (const run of [withGpu, withoutGpu]) - assert.ok(run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, JSON.stringify(run.drawn)); + // Each machine named in its own verdict, with the frame's counters: a blank side says which (#527). + for (const [webgpu, run] of [ + [true, withGpu], + [false, withoutGpu], + ] as const) + assert.ok( + run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, + `${machineLabel(webgpu)}: ${JSON.stringify({ drawn: run.drawn, metrics: run.cases.default.metrics })}`, + ); assert.deepEqual(errors, []); const side = (name: string, run: MachineResult) => [ name, From 973ae888b4eb023f526ca18244fb4d9c207afb99 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:34:02 +0200 Subject: [PATCH 05/10] refactor(sdk): one family predicate, the depth ramp keyed on the model, and a drift test of the model's call sites (#527) --- .../sdk-browser/src/camera/depthRamp.test.ts | 4 +- packages/sdk-browser/src/host/surfaceGate.ts | 27 ++---- .../src/lighting/fogWiring.test.ts | 3 +- .../sdk-browser/src/scene/surfaceModel.ts | 11 ++- .../src/webgl/cluster/compatibility.test.ts | 6 -- .../src/webgl/cluster/materialBinding.test.ts | 30 +++--- .../src/webgl/cluster/materialBinding.ts | 9 +- .../src/webgl/cluster/materialUniforms.ts | 5 +- .../sdk-browser/src/webgl/cluster/rectGlsl.ts | 5 +- .../sdk-browser/src/webgl/cluster/shaders.ts | 8 +- .../src/webgl/cluster/surfaceModels.test.ts | 91 +++++++++++++------ 11 files changed, 118 insertions(+), 81 deletions(-) diff --git a/packages/sdk-browser/src/camera/depthRamp.test.ts b/packages/sdk-browser/src/camera/depthRamp.test.ts index 1002898d09..2a54c3d5db 100644 --- a/packages/sdk-browser/src/camera/depthRamp.test.ts +++ b/packages/sdk-browser/src/camera/depthRamp.test.ts @@ -54,7 +54,9 @@ function resolveRamp(weights: Float32Array, clip: { z: number; w: number }) { const glsl = rampExpression( CLUSTER_FRAGMENT, - /if\(depthShaded\)rgb=vec3\(clamp\((.*?),0\.0,1\.0\)\);/, + new RegExp( + `if\\(surfaceModel==${SURFACE_MODEL.depth}\\)rgb=vec3\\(clamp\\((.*?),0\\.0,1\\.0\\)\\);`, + ), ); /** The WebGL2 fragment's line, on its view distance `toEye.z`. */ const fragmentOf = new Function('depthRamp', 'toEye', `return ${glsl};`) as ( diff --git a/packages/sdk-browser/src/host/surfaceGate.ts b/packages/sdk-browser/src/host/surfaceGate.ts index 4075881fad..2ac1670640 100644 --- a/packages/sdk-browser/src/host/surfaceGate.ts +++ b/packages/sdk-browser/src/host/surfaceGate.ts @@ -11,6 +11,7 @@ import type { HostAttribute, HostAttributes, HostMaterials } from './resources.ts'; import type { HostMap, HostShadedMaterial } from './shadedMaterial.ts'; import { blendingOf, blendingRefusal } from '../scene/materialBlending.ts'; +import { readsNormal } from '../scene/surfaceModel.ts'; import { HOST_MAPPING_UV, HOST_NORMAL_MAP_TANGENT_SPACE } from './surfaceConstants.ts'; import { texelsReason } from '../visibility/types.ts'; import { declaresCompileHook } from './materialHook.ts'; @@ -31,20 +32,6 @@ const textureReason = (texture: HostMap) => { * its own, not as one view interleaved into a shared one. */ const ownBuffer = (attribute: HostAttribute | undefined) => attribute?.kind === 'attribute'; -/** The families the WebGL2 program draws: the physical ones, the unlit basic and depth, and the - * others mapped onto the one model (`../scene/surfaceModel.ts`), each shaded as WebGPU does. */ -const DRAWN_FAMILIES = new Set([ - 'standard', - 'physical', - 'basic', - 'depth', - 'lambert', - 'phong', - 'toon', - 'normal', - 'matcap', -]); - /** * Names material input the autonomous WebGL2 program cannot preserve before it submits a draw. * A transmissive physical material is accepted only where `transmissive` says the draw reads @@ -57,7 +44,9 @@ export function clusterMaterialReason( ) { if (Array.isArray(material)) return 'material arrays are unsupported'; const host = material as HostShadedMaterial; - if (!DRAWN_FAMILIES.has(host.family)) return `material ${host.family} is unsupported`; + // Every family the one model reads is drawn, each shaded as WebGPU shades it. + const normals = readsNormal(host); + if (normals === undefined) return `material ${host.family} is unsupported`; // The draws' own refusal (`drawnBlending`): a mode admitted here is one every path draws. const refusal = blendingRefusal(blendingOf(host.blending), isTransmissive(material)); if (refusal) return `material ${host.family}: ${refusal} (blending ${host.blending})`; @@ -100,14 +89,14 @@ export function clusterMaterialReason( return 'textured material has no UV attribute'; if (maps.some((texture) => texture?.channel === 1) && !ownBuffer(attributes.uv1)) return 'texture channel 1 has no UV1 attribute'; - // Every family but basic and depth reads the normal: to light, to show it, or to find its matcap. - if (host.family !== 'basic' && host.family !== 'depth' && !ownBuffer(attributes.normal)) + if (normals && !ownBuffer(attributes.normal)) return `${host.family} material has no normal attribute`; if (host.vertexColors && !ownBuffer(attributes.color)) return 'vertex-colour material has no color attribute'; - // A matcap's image is read by the normal, never by a UV: checked as a map, not asked a UV. - for (const texture of [...maps, host.matcap]) { + for (const texture of maps) { const reason = textureReason(texture); if (reason) return reason; } + // A matcap's image is read by the normal, never by a UV: checked as a map, not asked a UV. + return textureReason(host.matcap); } diff --git a/packages/sdk-browser/src/lighting/fogWiring.test.ts b/packages/sdk-browser/src/lighting/fogWiring.test.ts index e3dc5895fc..a63f4ffd8d 100644 --- a/packages/sdk-browser/src/lighting/fogWiring.test.ts +++ b/packages/sdk-browser/src/lighting/fogWiring.test.ts @@ -15,6 +15,7 @@ import { BLEND_SHADER, BLEND_VIEW_WGSL } from '../webgpu/blend/shader.ts'; import { BLEND_VIEW_SIZE } from '../webgpu/blend/uniforms.ts'; import { WATER_COMPOSITE_SHADER } from '../webgpu/water/compositeWgsl.ts'; import { CLUSTER_FRAGMENT } from '../webgl/cluster/shaders.ts'; +import { SURFACE_MODEL } from '../scene/surfaceModel.ts'; import { SHADE_SHADER as SURFACE_SHADE } from '../visibility/shader/shadeWgsl.ts'; test('the opaque resolve fogs its lit sum at the pixel, from the eye in display.yzw', () => { @@ -54,6 +55,6 @@ test('the WebGL2 program fogs every surface before its display curve, a depth or const fogAt = CLUSTER_FRAGMENT.indexOf('\nif(!fogFree)rgb=fogged(rgb);'); assert.ok(fogAt > 0); // A depth material's ramp is written over the fogged colour. - assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf('if(depthShaded)rgb=')); + assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf(`if(surfaceModel==${SURFACE_MODEL.depth})rgb=`)); assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf('if(toneMapped)rgb=toneMap(rgb);')); }); diff --git a/packages/sdk-browser/src/scene/surfaceModel.ts b/packages/sdk-browser/src/scene/surfaceModel.ts index 44f992a1c3..bb671d3510 100644 --- a/packages/sdk-browser/src/scene/surfaceModel.ts +++ b/packages/sdk-browser/src/scene/surfaceModel.ts @@ -58,13 +58,22 @@ export const litModel = (host: HostShadedMaterial, model: number) => metalRough(host) || host.family === 'phong'; +/** Whether a family's colour reads its normal — to light it, to show it or to find its matcap —, + * every family but basic and depth; undefined for a family no model reads, which no path draws. */ +const READS_NORMAL = new Map(); +for (const family of ['standard', 'physical', 'lambert', 'phong', 'toon', 'normal', 'matcap']) + READS_NORMAL.set(family, true); +READS_NORMAL.set('basic', false).set('depth', false); +export const readsNormal = ({ family }: HostShadedMaterial): boolean | undefined => + READS_NORMAL.get(family); + /** The roughness a Blinn–Phong exponent reads as, `√(2 / (n + 2))`: its lobe's width. */ export const shininessRoughness = (shininess: number) => Math.sqrt(2 / (Math.max(0, shininess) + 2)); /** The toon cosine at `nl` = N·L, and the matcap coordinate of a view-space normal `n`: the one text * both languages read, WGSL here and GLSL in `SURFACE_MODEL_GLSL`. */ -const TOON_BANDS = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; +export const TOON_BANDS = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; /** diff --git a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts index 9ffe4c6a09..3486e5d579 100644 --- a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts @@ -17,12 +17,6 @@ test('an untextured Basic material needs no unused UV or normal attribute', () = assert.equal(clusterMaterialReason(G.basicSurface(), { position }), undefined); }); -test('a Depth material draws on the autonomous path; a family no path draws is refused', () => { - assert.equal(clusterMaterialReason(new G.GraphSurface('depth'), { position }), undefined); - const shader = { family: 'shader' } as unknown as G.GraphSurface; - assert.equal(clusterMaterialReason(shader, { position }), 'material shader is unsupported'); -}); - test('unsupported mutations refuse the autonomous draw before it becomes partial', () => { const material = G.standardSurface(); assert.equal( diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 5c1f27e1cf..6d2f3f68ab 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -9,6 +9,8 @@ import { bindClusterMaterial } from './materialBinding.ts'; import { importHostTexture } from '../../host/textureImport.ts'; import { pageDiagnostics } from '../../host/pageDiagnostics.ts'; import { CLUSTER_FRAGMENT } from './shaders.ts'; +import { LAST_MATERIAL_SLOT } from './materialUniforms.ts'; +import { SURFACE_MODEL } from '../../scene/surfaceModel.ts'; import { HOST_BLENDING_ADDITIVE, HOST_BLENDING_MULTIPLY, @@ -86,12 +88,6 @@ const flagOf = (material: G.GraphSurface, name: string, linear = false) => { return flags.get(name); }; -test('A Depth material, and it alone, shows the frame depth ramp', () => { - assert.equal(flagOf(new G.GraphSurface('depth'), 'depthShaded'), 1); - assert.equal(flagOf(G.standardSurface(), 'depthShaded'), 0); - assert.equal(flagOf(G.basicSurface(), 'depthShaded'), 0); -}); - test("A diagnostic view's surfaces, and they alone, stay out of the fog", () => { // The two surfaces a diagnostic view paints on the WebGL2 path: they show a number. const triangles = pageDiagnostics.triangleMaterial(0) as unknown as G.GraphSurface; @@ -138,14 +134,22 @@ test('Into the effect chain, a surface covers its pixel as the display path show }); test('Each family reaches the WebGL2 program in the model it reads on WebGPU (#527)', () => { - const models = { standard: 0, phong: 0, basic: 0, lambert: 1, toon: 2, normal: 3, matcap: 4 }; - for (const [family, model] of Object.entries(models)) + const { standard, diffuse, toon, normal, matcap, depth } = SURFACE_MODEL; + const models = { standard, phong: standard, basic: standard, lambert: diffuse, toon, normal }; + for (const [family, model] of Object.entries({ ...models, matcap, depth })) assert.equal(flagOf(new G.GraphSurface(family as 'standard'), 'surfaceModel'), model, family); // A matcap's image is bound as the base map, the one the program reads at the normal. - const masks = new Map(); + assert.equal(flagOf(new G.GraphSurface('matcap', { matcap: texture() }), 'mapMask'), 1); +}); + +test('The uniform cache holds every slot the binder writes, the last one included', () => { + let last = -1; const { binding } = recorder(); - (binding.uniforms as unknown as Record).i1 = (_: number, n: string, v: number) => - void masks.set(n, v); - bindClusterMaterial(binding, new G.GraphSurface('matcap', { matcap: texture() }), true); - assert.equal(masks.get('mapMask'), 1); + const widths = { f1: 1, i1: 1, f2: 2, i2: 2, f3: 3, f4: 4, i4: 4 }; + for (const [name, width] of Object.entries(widths)) + (binding.uniforms as unknown as Record)[name] = (index: number) => + void (last = Math.max(last, index + width - 1)); + binding.linear = true; + bindClusterMaterial(binding, G.standardSurface(), true); + assert.equal(last, LAST_MATERIAL_SLOT); }); diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 3efa7e2821..397f50a7d4 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -9,7 +9,7 @@ import type { Side } from '../../../../sdk-core/src/index.ts'; import type { WebglClusterTextures } from './textures.ts'; import { drawnModeOf, type WebglClusterState } from './state.ts'; import type { Matrix3UniformCache } from './uniforms.ts'; -import type { WebglClusterMaterialUniforms } from './materialUniforms.ts'; +import { LAST_MATERIAL_SLOT, type WebglClusterMaterialUniforms } from './materialUniforms.ts'; export type Material = Exclude; const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'aoMap', 'emissiveMap'] as const; @@ -128,10 +128,9 @@ export function bindClusterMaterial( uniforms.f2(45, 'sprite', sprite[0], sprite[1]); const doubleSided = side === undefined ? mat.doubleSided : false, backSide = side === undefined ? mat.backSide : side === 'back'; - // A depth material shows the frame's depth ramp in place of its colour (`beginFrame`). - uniforms.i1(35, 'depthShaded', mat.model === SURFACE_MODEL.depth ? 1 : 0); - // The model a non-physical family reads in (`SURFACE_MODEL_GLSL`): lambert, toon, normal, matcap. - uniforms.i1(48, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); + // The model a non-physical family reads in (`SURFACE_MODEL_GLSL`): lambert, toon, normal and + // matcap, and depth, which shows the frame's depth ramp in place of its colour (`beginFrame`). + uniforms.i1(LAST_MATERIAL_SLOT, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); // A diagnostic view's surface is shown as it is, never through the fog // (`../../host/pageDiagnostics.ts`). uniforms.i1(38, 'fogFree', (material as { fog?: boolean }).fog === false ? 1 : 0); diff --git a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts index efb1dbc4d4..8a375b5301 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts @@ -1,5 +1,8 @@ +/** The binder's highest slot, `surfaceModel` (`materialBinding.ts`): one cached value per slot. */ +export const LAST_MATERIAL_SLOT = 48; + export class WebglClusterMaterialUniforms { - private values = new Float64Array(49).fill(Number.NaN); + private values = new Float64Array(LAST_MATERIAL_SLOT + 1).fill(Number.NaN); private gl: WebGL2RenderingContext; private at: (name: string) => WebGLUniformLocation | null; constructor(gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null) { diff --git a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts index 27220706cb..f551d1af0f 100644 --- a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts +++ b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts @@ -29,13 +29,14 @@ vec4 ltcTexel(int x,int y,int k){return texelFetch(ltcTable,ivec2(x*2+k,y),0);} vec4 ltcLookup(float rough,float NdotV,int k){vec2 at=vec2(clamp(rough,0.0,1.0),sqrt(clamp(1.0-NdotV,0.0,1.0)))*float(LTC_SIZE-1); ivec2 i=min(ivec2(at),ivec2(LTC_SIZE-2));vec2 f=at-vec2(i);vec4 low=mix(ltcTexel(i.x,i.y,k),ltcTexel(i.x+1,i.y,k),f.x); return mix(low,mix(ltcTexel(i.x,i.y+1,k),ltcTexel(i.x+1,i.y+1,k),f.x),f.y);} +float formEnergy(float intensity,vec4 form,float window){return intensity*PI*form.w*window;} vec3 ltcCorner(vec3 q,vec3 T1,vec3 T2,vec3 N,vec4 m){float x=dot(q,T1),z=dot(q,N);return vec3(m.x*x+m.y*z,dot(q,T2),m.z*x+m.w*z);} vec3 rectLight(vec4 positionRange,vec3 n,vec4 axis,vec4 colorIntensity,vec3 N,vec3 V,vec3 P,vec3 base,float metal,float rough,float ao){ vec3 C=positionRange.xyz,U=axis.xyz,W=normalize(cross(U,n))*axis.w;if(dot(P-C,n)<=0.0)return vec3(0.0); float window=rangeWindow(length(C-P),positionRange.w); -vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;vec4 F=polygonFormFactor(a,b,c,d,N);float E=colorIntensity.w*PI*F.w*window;if(E<=0.0)return vec3(0.0); +vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;vec4 F=polygonFormFactor(a,b,c,d,N);float E=formEnergy(colorIntensity.w,F,window);if(E<=0.0)return vec3(0.0); if(surfaceModel==${SURFACE_MODEL.diffuse})return modelLight(base,metal,N,N,E,ao)*colorIntensity.rgb; -if(surfaceModel==${SURFACE_MODEL.toon})return modelLight(base,metal,N,F.xyz,colorIntensity.w*PI*polygonFormFactor(a,b,c,d,F.xyz).w*window,ao)*colorIntensity.rgb; +if(surfaceModel==${SURFACE_MODEL.toon})return modelLight(base,metal,N,F.xyz,formEnergy(colorIntensity.w,polygonFormFactor(a,b,c,d,F.xyz),window),ao)*colorIntensity.rgb; float NdotV=clamp(dot(N,V),1e-4,1.0);vec3 side=V-N*dot(N,V),other=cross(N,abs(N.x)>0.9?vec3(0.0,1.0,0.0):vec3(1.0,0.0,0.0)); vec3 T1=normalize(dot(side,side)<1e-10?other:side),T2=cross(N,T1);vec4 m=ltcLookup(rough,NdotV,0),t=ltcLookup(rough,NdotV,1); float lobe=polygonFormFactor(ltcCorner(a,T1,T2,N,m),ltcCorner(b,T1,T2,N,m),ltcCorner(c,T1,T2,N,m),ltcCorner(d,T1,T2,N,m),vec3(0.0,0.0,1.0)).w; diff --git a/packages/sdk-browser/src/webgl/cluster/shaders.ts b/packages/sdk-browser/src/webgl/cluster/shaders.ts index 333c8349f1..e99614bc40 100644 --- a/packages/sdk-browser/src/webgl/cluster/shaders.ts +++ b/packages/sdk-browser/src/webgl/cluster/shaders.ts @@ -51,7 +51,7 @@ export const CLUSTER_FRAGMENT = `#version 300 es precision highp float;const float PI=${PI},INVERSE_PI=${INVERSE_PI};const int MAX_LIGHTS=64; in vec3 toEye;in vec3 viewNormal;vec3 viewPosition;in vec2 texcoord0;in vec2 texcoord1;in vec4 vertexColor;out vec4 outColor; uniform vec4 baseFactor;uniform float metalFactor,roughFactor,alphaCutoff,aoStrength;uniform vec2 normalScale; -uniform vec3 emissiveFactor;uniform vec2 depthRamp,dash;uniform bool depthShaded,fogFree,lit,flatShaded,toneMapped,srgbDestination,hasNormalMap,hasVertexColor,sharedMetalRough;uniform int mapMask,faceSides; +uniform vec3 emissiveFactor;uniform vec2 depthRamp,dash;uniform bool fogFree,lit,flatShaded,toneMapped,srgbDestination,hasNormalMap,hasVertexColor,sharedMetalRough;uniform int mapMask,faceSides; uniform sampler2D baseMap,roughMap,metalMap,normalMap,aoMap,emissiveMap; uniform mat3 baseUv,roughUv,metalUv,normalUv,aoUv,emissiveUv; uniform mat4 projectionMatrix;uniform int lightCount;uniform ivec4 mapChannels;uniform ivec2 extraChannels;layout(std140) uniform ClusterLights{vec4 lightData[256];}; @@ -93,10 +93,10 @@ color*=attenuation(length(toLight),positionRange.w,cone.z);} if(bandedModel()){direct+=modelLight(base,metal,N,L,1.0,ao)*color;continue;}vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} irradiance+=probeIrradiance(N);return(direct+irradiance*(INVERSE_PI*diffuse)*ao)+specular;} ${TRANSMISSION_GLSL} -void main(){if(!lineDash(texcoord0.x,dash))discard;viewPosition=-toEye;vec4 base=baseFactor;if((mapMask&1)!=0)base*=texture(baseMap,mapUv(baseUv,surfaceModel==${SURFACE_MODEL.matcap}?matcapUv(normalize(viewNormal)):sourceUv(mapChannels.x)));if(hasVertexColor)base*=vertexColor;if(base.a { - for (const family of FAMILIES) { +test('Every family the one model reads draws on WebGL2, reading its normals; another is refused', () => { + for (const family of ['depth', 'lambert', 'phong', 'toon', 'normal', 'matcap'] as const) { const surface = new G.GraphSurface(family); assert.equal(clusterMaterialReason(surface, { position, normal }), undefined, family); - assert.equal( - clusterMaterialReason(surface, { position }), - `${family} material has no normal attribute`, - ); + const refusal = family === 'depth' ? undefined : `${family} material has no normal attribute`; + assert.equal(clusterMaterialReason(surface, { position }), refusal, family); } + const shader = { family: 'shader' } as unknown as G.GraphSurface; + assert.equal(clusterMaterialReason(shader, { position }), 'material shader is unsupported'); + // A name the prototype of an object carries is no family either. + const named = { family: 'constructor' } as unknown as G.GraphSurface; + assert.equal(clusterMaterialReason(named, { position }), 'material constructor is unsupported'); }); test("A matcap's image is checked as a map, and asks for no UV: the normal reads it", () => { const image = G.dataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1, G.HOST_FORMAT_RGBA); - const matcap = (texture: unknown) => new G.GraphSurface('matcap', { matcap: texture }); - assert.equal(clusterMaterialReason(matcap(image), { position, normal }), undefined); - const empty = G.dataTexture(new Uint8Array(4), 1, 1, G.HOST_FORMAT_RGBA); - empty.image = undefined as never; - assert.equal( - clusterMaterialReason(matcap(empty), { position, normal }), - 'texture image is unavailable', - ); + const matcap = new G.GraphSurface('matcap', { matcap: image }); + assert.equal(clusterMaterialReason(matcap, { position, normal }), undefined); + image.image = undefined as never; + assert.equal(clusterMaterialReason(matcap, { position, normal }), 'texture image is unavailable'); }); -test('The two languages read one toon band and one matcap coordinate', () => { - const band = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; - assert.ok(SURFACE_MODEL_LIGHT_WGSL.includes(`diffuse*${band}`)); - assert.ok(SURFACE_MODEL_GLSL.includes(`diffuse*${band}`)); - assert.ok(SURFACE_MODEL_SHADE_WGSL.includes('return vec2f(n.x*0.495+0.5,0.5-n.y*0.495);')); - assert.ok(SURFACE_MODEL_GLSL.includes('return vec2(n.x*0.495+0.5,0.5-n.y*0.495);')); - // Each lamp gives a diffuse or toon surface the model's lobe alone, never the specular one. +/** The arguments of every `modelLight` call in `code`, split at their top-level commas. */ +function modelLightCalls(code: string) { + const calls: string[][] = []; + for (let at = code.indexOf('modelLight('); at >= 0; at = code.indexOf('modelLight(', at + 1)) { + if (/(?:fn |vec3 )$/.test(code.slice(0, at))) continue; // the definition itself + const args = ['']; + for (let i = at + 'modelLight('.length, depth = 0; depth >= 0; i++) { + const c = code[i]; + depth += c === '(' ? 1 : c === ')' ? -1 : 0; + if (depth < 0) break; + if (c === ',' && depth === 0) args.push(''); + else args[args.length - 1] += c; + } + calls.push(args); + } + return calls; +} + +test('The lamp and rectangle branches of both languages call the one model alike', async () => { + const source = (file: string) => + readFile(new URL(`../../lighting/direct/${file}`, import.meta.url), 'utf8'); + const wgsl = modelLightCalls( + (await source('lightingWgsl.ts')) + (await source('rectLightWgsl.ts')), + ); + const glsl = modelLightCalls(CLUSTER_FRAGMENT); + // A lamp, a rectangle's diffuse and its toon bands: three sites a language cannot lose. + assert.equal(wgsl.length, 3); + assert.equal(glsl.length, 3); + for (const call of [...wgsl, ...glsl]) { + assert.equal(call.length, 6, call.join()); + assert.deepEqual([call[1], call[2], call[5]], ['metal', 'N', 'ao'], call.join()); + } + // The rectangle's diffuse takes its irradiance whole, at a unit N·L, in both. + const whole = (calls: string[][]) => calls.filter((call) => call[3] === 'N' && call[4] === 'E'); + assert.equal(whole(wgsl).length, 1); + assert.equal(whole(glsl).length, 1); + // Both read one toon band, and a lamp takes the model on a diffuse or toon surface alone. + assert.ok(SURFACE_MODEL_LIGHT_WGSL.includes(`diffuse*${TOON_BANDS}`)); + assert.ok(SURFACE_MODEL_GLSL.includes(`diffuse*${TOON_BANDS}`)); + const { diffuse, toon, matcap, normal: shown } = SURFACE_MODEL; + assert.ok(SURFACE_MODEL_GLSL.includes(`surfaceModel==${diffuse}||surfaceModel==${toon}`)); assert.match( CLUSTER_FRAGMENT, /if\(bandedModel\(\)\)\{direct\+=modelLight\([^;]+;continue;\}vec3 E=/, ); // A matcap reads its base map at the view normal; a normal surface shows it, after the fog. - assert.match(CLUSTER_FRAGMENT, /surfaceModel==4\?matcapUv\(normalize\(viewNormal\)\)/); - assert.match(CLUSTER_FRAGMENT, /rgb=fogged\(rgb\);[^]*if\(surfaceModel==3\)rgb=N\*0\.5\+0\.5;/); + assert.ok(CLUSTER_FRAGMENT.includes(`surfaceModel==${matcap}?matcapUv(surfaceNormal)`)); + const fogAt = CLUSTER_FRAGMENT.indexOf('rgb=fogged(rgb);'); + assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf(`if(surfaceModel==${shown})rgb=N*0.5+0.5;`)); }); From c64938d4b7c052cc6e8f74b01fa528746a2c7ba5 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:40:04 +0200 Subject: [PATCH 06/10] fix(sdk): a matcap reads its finest level, the model takes the freed slot, a released lock rejects (#527) --- .../sdk-browser/src/webgl/cluster/materialBinding.ts | 4 ++-- .../sdk-browser/src/webgl/cluster/materialUniforms.ts | 5 +++-- packages/sdk-browser/src/webgl/cluster/shaders.ts | 2 +- .../sdk-browser/src/webgl/cluster/surfaceModels.test.ts | 9 +++++++-- tests/browser/support/cameraControlsPage.ts | 7 ++++++- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 397f50a7d4..0b18a1bb94 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -9,7 +9,7 @@ import type { Side } from '../../../../sdk-core/src/index.ts'; import type { WebglClusterTextures } from './textures.ts'; import { drawnModeOf, type WebglClusterState } from './state.ts'; import type { Matrix3UniformCache } from './uniforms.ts'; -import { LAST_MATERIAL_SLOT, type WebglClusterMaterialUniforms } from './materialUniforms.ts'; +import type { WebglClusterMaterialUniforms } from './materialUniforms.ts'; export type Material = Exclude; const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'aoMap', 'emissiveMap'] as const; @@ -130,7 +130,7 @@ export function bindClusterMaterial( backSide = side === undefined ? mat.backSide : side === 'back'; // The model a non-physical family reads in (`SURFACE_MODEL_GLSL`): lambert, toon, normal and // matcap, and depth, which shows the frame's depth ramp in place of its colour (`beginFrame`). - uniforms.i1(LAST_MATERIAL_SLOT, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); + uniforms.i1(35, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); // A diagnostic view's surface is shown as it is, never through the fog // (`../../host/pageDiagnostics.ts`). uniforms.i1(38, 'fogFree', (material as { fog?: boolean }).fog === false ? 1 : 0); diff --git a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts index 8a375b5301..693a98d553 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts @@ -1,5 +1,6 @@ -/** The binder's highest slot, `surfaceModel` (`materialBinding.ts`): one cached value per slot. */ -export const LAST_MATERIAL_SLOT = 48; +/** The binder's highest slot, the linear program's `covering` (`materialBinding.ts`): one cached + * value per slot. */ +export const LAST_MATERIAL_SLOT = 47; export class WebglClusterMaterialUniforms { private values = new Float64Array(LAST_MATERIAL_SLOT + 1).fill(Number.NaN); diff --git a/packages/sdk-browser/src/webgl/cluster/shaders.ts b/packages/sdk-browser/src/webgl/cluster/shaders.ts index e99614bc40..57e223a054 100644 --- a/packages/sdk-browser/src/webgl/cluster/shaders.ts +++ b/packages/sdk-browser/src/webgl/cluster/shaders.ts @@ -93,7 +93,7 @@ color*=attenuation(length(toLight),positionRange.w,cone.z);} if(bandedModel()){direct+=modelLight(base,metal,N,L,1.0,ao)*color;continue;}vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} irradiance+=probeIrradiance(N);return(direct+irradiance*(INVERSE_PI*diffuse)*ao)+specular;} ${TRANSMISSION_GLSL} -void main(){if(!lineDash(texcoord0.x,dash))discard;viewPosition=-toEye;vec3 surfaceNormal=normalize(viewNormal);vec4 base=baseFactor;if((mapMask&1)!=0)base*=texture(baseMap,mapUv(baseUv,surfaceModel==${SURFACE_MODEL.matcap}?matcapUv(surfaceNormal):sourceUv(mapChannels.x)));if(hasVertexColor)base*=vertexColor;if(base.a((granted, refused) => { if (document.pointerLockElement) return granted(); - document.addEventListener('pointerlockchange', () => granted(), { once: true }); + // A change that leaves the pointer free is a lost lock, never a granted one. + document.addEventListener( + 'pointerlockchange', + () => (document.pointerLockElement ? granted() : refused(new Error('lock released'))), + { once: true }, + ); document.addEventListener('pointerlockerror', () => refused(new Error('lock refused')), { once: true, }); From f161c4bb9cae69251a327947c3e223d3cc799726 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 07:51:36 +0200 Subject: [PATCH 07/10] refactor(sdk): one family table for the model and the normal it reads, the model's diffuse text shared by both languages, a matcap's unread base map asks no UV, the lock wait drops both listeners (#527) --- packages/sdk-browser/src/host/surfaceGate.ts | 11 +++-- .../sdk-browser/src/scene/surfaceModel.ts | 48 +++++++++++-------- .../src/webgl/cluster/materialBinding.test.ts | 13 ++++- .../src/webgl/cluster/surfaceModels.test.ts | 3 ++ tests/browser/support/cameraControlsPage.ts | 22 +++++++-- 5 files changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/sdk-browser/src/host/surfaceGate.ts b/packages/sdk-browser/src/host/surfaceGate.ts index 2ac1670640..6ab0258cf1 100644 --- a/packages/sdk-browser/src/host/surfaceGate.ts +++ b/packages/sdk-browser/src/host/surfaceGate.ts @@ -11,7 +11,7 @@ import type { HostAttribute, HostAttributes, HostMaterials } from './resources.ts'; import type { HostMap, HostShadedMaterial } from './shadedMaterial.ts'; import { blendingOf, blendingRefusal } from '../scene/materialBlending.ts'; -import { readsNormal } from '../scene/surfaceModel.ts'; +import { SURFACE_MODEL, hostSurfaceModel, readsNormal } from '../scene/surfaceModel.ts'; import { HOST_MAPPING_UV, HOST_NORMAL_MAP_TANGENT_SPACE } from './surfaceConstants.ts'; import { texelsReason } from '../visibility/types.ts'; import { declaresCompileHook } from './materialHook.ts'; @@ -76,9 +76,11 @@ export function clusterMaterialReason( if (declaresCompileHook(host)) return `material ${host.family} carries a shader hook`; if (!ownBuffer(attributes.position)) return 'position attribute is unsupported'; // The same six maps the import reads, in the same order: a basic material declares none of the - // lit ones, so the list is the host's own properties, not a second rule. + // lit ones, so the list is the host's own properties, not a second rule. A matcap's base is its + // image, read by the normal, never by a UV: checked as a map below, not asked a UV. + const matcap = hostSurfaceModel(host) === SURFACE_MODEL.matcap; const maps = [ - host.map, + matcap ? undefined : host.map, host.metalnessMap, host.roughnessMap, host.normalMap, @@ -97,6 +99,5 @@ export function clusterMaterialReason( const reason = textureReason(texture); if (reason) return reason; } - // A matcap's image is read by the normal, never by a UV: checked as a map, not asked a UV. - return textureReason(host.matcap); + if (matcap) return textureReason(host.matcap); } diff --git a/packages/sdk-browser/src/scene/surfaceModel.ts b/packages/sdk-browser/src/scene/surfaceModel.ts index bb671d3510..ccf56163d2 100644 --- a/packages/sdk-browser/src/scene/surfaceModel.ts +++ b/packages/sdk-browser/src/scene/surfaceModel.ts @@ -37,15 +37,27 @@ export const AS_IS_FLAG = 3; export const shownAsIs = (model: number | undefined) => model === SURFACE_MODEL.normal || model === SURFACE_MODEL.depth; +/** Each family the one model reads: its model, and whether its colour reads its normal — to light + * it, to show it or to find its matcap —; a family absent here is one no path draws. */ +const FAMILIES = new Map([ + ['standard', [SURFACE_MODEL.standard, true]], + ['physical', [SURFACE_MODEL.standard, true]], + ['phong', [SURFACE_MODEL.standard, true]], + ['basic', [SURFACE_MODEL.standard, false]], + ['lambert', [SURFACE_MODEL.diffuse, true]], + ['toon', [SURFACE_MODEL.toon, true]], + ['normal', [SURFACE_MODEL.normal, true]], + ['matcap', [SURFACE_MODEL.matcap, true]], + ['depth', [SURFACE_MODEL.depth, false]], +]); + /** The model a surface declares by its family; the physical model otherwise. */ -export function hostSurfaceModel({ family }: HostShadedMaterial): number { - if (family === 'lambert') return SURFACE_MODEL.diffuse; - if (family === 'toon') return SURFACE_MODEL.toon; - if (family === 'normal') return SURFACE_MODEL.normal; - if (family === 'matcap') return SURFACE_MODEL.matcap; - if (family === 'depth') return SURFACE_MODEL.depth; - return SURFACE_MODEL.standard; -} +export const hostSurfaceModel = ({ family }: HostShadedMaterial): number => + FAMILIES.get(family)?.[0] ?? SURFACE_MODEL.standard; + +/** Whether a family reads its normal; undefined for a family no model reads. */ +export const readsNormal = ({ family }: HostShadedMaterial): boolean | undefined => + FAMILIES.get(family)?.[1]; /** Whether a surface carries the metal-rough parameters: a standard or a physical one. */ export const metalRough = ({ family }: HostShadedMaterial) => @@ -58,15 +70,6 @@ export const litModel = (host: HostShadedMaterial, model: number) => metalRough(host) || host.family === 'phong'; -/** Whether a family's colour reads its normal — to light it, to show it or to find its matcap —, - * every family but basic and depth; undefined for a family no model reads, which no path draws. */ -const READS_NORMAL = new Map(); -for (const family of ['standard', 'physical', 'lambert', 'phong', 'toon', 'normal', 'matcap']) - READS_NORMAL.set(family, true); -READS_NORMAL.set('basic', false).set('depth', false); -export const readsNormal = ({ family }: HostShadedMaterial): boolean | undefined => - READS_NORMAL.get(family); - /** The roughness a Blinn–Phong exponent reads as, `√(2 / (n + 2))`: its lobe's width. */ export const shininessRoughness = (shininess: number) => Math.sqrt(2 / (Math.max(0, shininess) + 2)); @@ -74,6 +77,9 @@ export const shininessRoughness = (shininess: number) => /** The toon cosine at `nl` = N·L, and the matcap coordinate of a view-space normal `n`: the one text * both languages read, WGSL here and GLSL in `SURFACE_MODEL_GLSL`. */ export const TOON_BANDS = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; +/** `modelLight`'s diffuse lobe and its Lambert cosine, the text both languages read. */ +const MODEL_DIFFUSE = `rgb*(1.0-metal)*${INVERSE_PI}*energy*ao`; +const LAMBERT = 'max(nl,0.0)'; const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; /** @@ -84,10 +90,10 @@ const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; export const SURFACE_MODEL_LIGHT_WGSL = ` var surfaceModel:u32; fn modelLight(rgb:vec3f,metal:f32,N:vec3f,L:vec3f,energy:f32,ao:f32)->vec3f{ - let diffuse=rgb*(1.0-metal)*${INVERSE_PI}*energy*ao; + let diffuse=${MODEL_DIFFUSE}; let nl=dot(N,L); if(surfaceModel==${MODEL_FLAG.toon}u){return diffuse*${TOON_BANDS};} - return diffuse*max(nl,0.0); + return diffuse*${LAMBERT}; }`; /** @@ -113,6 +119,6 @@ fn matcapUv(N:vec3f)->vec2f{let n=viewNormal(N);return ${matcapAt('vec2f')};}`; export const SURFACE_MODEL_GLSL = ` uniform int surfaceModel; bool bandedModel(){return surfaceModel==${SURFACE_MODEL.diffuse}||surfaceModel==${SURFACE_MODEL.toon};} -vec3 modelLight(vec3 rgb,float metal,vec3 N,vec3 L,float energy,float ao){vec3 diffuse=rgb*(1.0-metal)*${INVERSE_PI}*energy*ao; -float nl=dot(N,L);if(surfaceModel==${SURFACE_MODEL.toon})return diffuse*${TOON_BANDS};return diffuse*max(nl,0.0);} +vec3 modelLight(vec3 rgb,float metal,vec3 N,vec3 L,float energy,float ao){vec3 diffuse=${MODEL_DIFFUSE}; +float nl=dot(N,L);if(surfaceModel==${SURFACE_MODEL.toon})return diffuse*${TOON_BANDS};return diffuse*${LAMBERT};} vec2 matcapUv(vec3 n){return ${matcapAt('vec2')};}`; diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 6d2f3f68ab..0bd48752d9 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -135,8 +135,17 @@ test('Into the effect chain, a surface covers its pixel as the display path show test('Each family reaches the WebGL2 program in the model it reads on WebGPU (#527)', () => { const { standard, diffuse, toon, normal, matcap, depth } = SURFACE_MODEL; - const models = { standard, phong: standard, basic: standard, lambert: diffuse, toon, normal }; - for (const [family, model] of Object.entries({ ...models, matcap, depth })) + const models = { + standard, + phong: standard, + basic: standard, + lambert: diffuse, + toon, + normal, + matcap, + depth, + }; + for (const [family, model] of Object.entries(models)) assert.equal(flagOf(new G.GraphSurface(family as 'standard'), 'surfaceModel'), model, family); // A matcap's image is bound as the base map, the one the program reads at the normal. assert.equal(flagOf(new G.GraphSurface('matcap', { matcap: texture() }), 'mapMask'), 1); diff --git a/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts b/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts index 3db03a6217..63976525a0 100644 --- a/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts @@ -34,6 +34,9 @@ test("A matcap's image is checked as a map, and asks for no UV: the normal reads const image = G.dataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1, G.HOST_FORMAT_RGBA); const matcap = new G.GraphSurface('matcap', { matcap: image }); assert.equal(clusterMaterialReason(matcap, { position, normal }), undefined); + // A base map beside it is never read, the import binding the matcap in its place: no UV asked. + const both = new G.GraphSurface('matcap', { matcap: image, map: image }); + assert.equal(clusterMaterialReason(both, { position, normal }), undefined); image.image = undefined as never; assert.equal(clusterMaterialReason(matcap, { position, normal }), 'texture image is unavailable'); }); diff --git a/tests/browser/support/cameraControlsPage.ts b/tests/browser/support/cameraControlsPage.ts index 61bd3e7034..15e089e4df 100644 --- a/tests/browser/support/cameraControlsPage.ts +++ b/tests/browser/support/cameraControlsPage.ts @@ -107,15 +107,27 @@ export async function endGesture() { export function pointerLocked() { return new Promise((granted, refused) => { if (document.pointerLockElement) return granted(); + // The first of the two events settles the wait and removes both listeners. + const settled = new AbortController(); + const { signal } = settled; // A change that leaves the pointer free is a lost lock, never a granted one. document.addEventListener( 'pointerlockchange', - () => (document.pointerLockElement ? granted() : refused(new Error('lock released'))), - { once: true }, + () => { + settled.abort(); + if (document.pointerLockElement) granted(); + else refused(new Error('lock released')); + }, + { signal }, + ); + document.addEventListener( + 'pointerlockerror', + () => { + settled.abort(); + refused(new Error('lock refused')); + }, + { signal }, ); - document.addEventListener('pointerlockerror', () => refused(new Error('lock refused')), { - once: true, - }); }); } From 175403247d307657788325d03f35a955ff5cb032 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:01:33 +0200 Subject: [PATCH 08/10] fix(sdk): an unlit normal or matcap surface reads no occlusion or relief map and asks no UV for one, as on WebGPU (#527) --- packages/sdk-browser/src/host/surfaceGate.ts | 13 ++++++++----- .../src/webgl/cluster/materialBinding.test.ts | 3 +++ .../src/webgl/cluster/materialBinding.ts | 6 ++++-- .../src/webgl/cluster/surfaceModels.test.ts | 5 +++++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/sdk-browser/src/host/surfaceGate.ts b/packages/sdk-browser/src/host/surfaceGate.ts index 6ab0258cf1..3a70b86e38 100644 --- a/packages/sdk-browser/src/host/surfaceGate.ts +++ b/packages/sdk-browser/src/host/surfaceGate.ts @@ -11,7 +11,7 @@ import type { HostAttribute, HostAttributes, HostMaterials } from './resources.ts'; import type { HostMap, HostShadedMaterial } from './shadedMaterial.ts'; import { blendingOf, blendingRefusal } from '../scene/materialBlending.ts'; -import { SURFACE_MODEL, hostSurfaceModel, readsNormal } from '../scene/surfaceModel.ts'; +import { SURFACE_MODEL, hostSurfaceModel, litModel, readsNormal } from '../scene/surfaceModel.ts'; import { HOST_MAPPING_UV, HOST_NORMAL_MAP_TANGENT_SPACE } from './surfaceConstants.ts'; import { texelsReason } from '../visibility/types.ts'; import { declaresCompileHook } from './materialHook.ts'; @@ -77,14 +77,17 @@ export function clusterMaterialReason( if (!ownBuffer(attributes.position)) return 'position attribute is unsupported'; // The same six maps the import reads, in the same order: a basic material declares none of the // lit ones, so the list is the host's own properties, not a second rule. A matcap's base is its - // image, read by the normal, never by a UV: checked as a map below, not asked a UV. - const matcap = hostSurfaceModel(host) === SURFACE_MODEL.matcap; + // image, read by the normal, never by a UV: checked as a map below, not asked a UV. An unlit + // model reads no relief, and only a basic one its occlusion (`bindClusterMaterial`). + const model = hostSurfaceModel(host), + matcap = model === SURFACE_MODEL.matcap, + lit = litModel(host, model); const maps = [ matcap ? undefined : host.map, host.metalnessMap, host.roughnessMap, - host.normalMap, - host.aoMap, + lit ? host.normalMap : undefined, + lit || model === SURFACE_MODEL.standard ? host.aoMap : undefined, host.emissiveMap, ]; if (maps.some(Boolean) && !ownBuffer(attributes.uv)) diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 0bd48752d9..81eaddca33 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -149,6 +149,9 @@ test('Each family reaches the WebGL2 program in the model it reads on WebGPU (#5 assert.equal(flagOf(new G.GraphSurface(family as 'standard'), 'surfaceModel'), model, family); // A matcap's image is bound as the base map, the one the program reads at the normal. assert.equal(flagOf(new G.GraphSurface('matcap', { matcap: texture() }), 'mapMask'), 1); + // Only a basic surface reads the occlusion of an unlit model; a matcap reads none, as on WebGPU. + assert.equal(flagOf(new G.GraphSurface('basic', { aoMap: texture() }), 'mapMask'), 16); + assert.equal(flagOf(new G.GraphSurface('matcap', { aoMap: texture() }), 'mapMask'), 0); }); test('The uniform cache holds every slot the binder writes, the last one included', () => { diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 0b18a1bb94..683494026b 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -59,10 +59,12 @@ export function bindClusterMaterial( const { uniforms, matrices, textures, state, linear } = binding; const source = material as { opacity: number }, mat = visMaterial(material); - // An unlit material keeps its occlusion map and strength on the host object alone: its map is + // A basic material keeps its occlusion map and strength on the host object alone: its map is // imported here, as the boundary imports every other, into the engine record the binding reads. + // The other unlit models — normal, matcap, depth — read no occlusion, as on WebGPU. const basic = material as { aoMap?: HostTexture | null; aoMapIntensity?: number }, - aoMap = mat.aoMap ?? (!mat.lit && basic.aoMap ? importHostTexture(basic.aoMap) : undefined), + unlitBasic = !mat.lit && (mat.model ?? SURFACE_MODEL.standard) === SURFACE_MODEL.standard, + aoMap = mat.aoMap ?? (unlitBasic && basic.aoMap ? importHostTexture(basic.aoMap) : undefined), aoIntensity = mat.aoMap ? mat.aoIntensity : (basic.aoMapIntensity ?? 1); uniforms.f4( 0, diff --git a/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts b/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts index 63976525a0..de2d07eb9c 100644 --- a/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts @@ -37,6 +37,11 @@ test("A matcap's image is checked as a map, and asks for no UV: the normal reads // A base map beside it is never read, the import binding the matcap in its place: no UV asked. const both = new G.GraphSurface('matcap', { matcap: image, map: image }); assert.equal(clusterMaterialReason(both, { position, normal }), undefined); + // Nor does the relief or occlusion an unlit model never reads: the import drops them. + for (const family of ['normal', 'matcap'] as const) { + const unread = new G.GraphSurface(family, { normalMap: image, aoMap: image }); + assert.equal(clusterMaterialReason(unread, { position, normal }), undefined, family); + } image.image = undefined as never; assert.equal(clusterMaterialReason(matcap, { position, normal }), 'texture image is unavailable'); }); From ccea3c46fde0d8d9051fef75b1c9f6829e68df34 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:06:41 +0200 Subject: [PATCH 09/10] revert(sdk): the WebGL2 materials leave this branch, they ship with #772 (#527) --- .../sdk-browser/src/camera/depthRamp.test.ts | 4 +- packages/sdk-browser/src/host/surfaceGate.ts | 25 ++--- .../src/lighting/fogWiring.test.ts | 3 +- .../sdk-browser/src/scene/surfaceModel.ts | 57 ++-------- .../src/webgl/cluster/compatibility.test.ts | 10 +- .../src/webgl/cluster/materialBinding.test.ts | 41 +------ .../src/webgl/cluster/materialBinding.ts | 11 +- .../src/webgl/cluster/materialUniforms.ts | 6 +- .../sdk-browser/src/webgl/cluster/rectGlsl.ts | 11 +- .../sdk-browser/src/webgl/cluster/shaders.ts | 14 +-- .../src/webgl/cluster/surfaceModels.test.ts | 103 ------------------ 11 files changed, 52 insertions(+), 233 deletions(-) delete mode 100644 packages/sdk-browser/src/webgl/cluster/surfaceModels.test.ts diff --git a/packages/sdk-browser/src/camera/depthRamp.test.ts b/packages/sdk-browser/src/camera/depthRamp.test.ts index 2a54c3d5db..1002898d09 100644 --- a/packages/sdk-browser/src/camera/depthRamp.test.ts +++ b/packages/sdk-browser/src/camera/depthRamp.test.ts @@ -54,9 +54,7 @@ function resolveRamp(weights: Float32Array, clip: { z: number; w: number }) { const glsl = rampExpression( CLUSTER_FRAGMENT, - new RegExp( - `if\\(surfaceModel==${SURFACE_MODEL.depth}\\)rgb=vec3\\(clamp\\((.*?),0\\.0,1\\.0\\)\\);`, - ), + /if\(depthShaded\)rgb=vec3\(clamp\((.*?),0\.0,1\.0\)\);/, ); /** The WebGL2 fragment's line, on its view distance `toEye.z`. */ const fragmentOf = new Function('depthRamp', 'toEye', `return ${glsl};`) as ( diff --git a/packages/sdk-browser/src/host/surfaceGate.ts b/packages/sdk-browser/src/host/surfaceGate.ts index 3a70b86e38..59c89c28d1 100644 --- a/packages/sdk-browser/src/host/surfaceGate.ts +++ b/packages/sdk-browser/src/host/surfaceGate.ts @@ -10,8 +10,8 @@ import type { HostAttribute, HostAttributes, HostMaterials } from './resources.ts'; import type { HostMap, HostShadedMaterial } from './shadedMaterial.ts'; +import { metalRough } from '../scene/surfaceModel.ts'; import { blendingOf, blendingRefusal } from '../scene/materialBlending.ts'; -import { SURFACE_MODEL, hostSurfaceModel, litModel, readsNormal } from '../scene/surfaceModel.ts'; import { HOST_MAPPING_UV, HOST_NORMAL_MAP_TANGENT_SPACE } from './surfaceConstants.ts'; import { texelsReason } from '../visibility/types.ts'; import { declaresCompileHook } from './materialHook.ts'; @@ -44,9 +44,8 @@ export function clusterMaterialReason( ) { if (Array.isArray(material)) return 'material arrays are unsupported'; const host = material as HostShadedMaterial; - // Every family the one model reads is drawn, each shaded as WebGPU shades it. - const normals = readsNormal(host); - if (normals === undefined) return `material ${host.family} is unsupported`; + if (!metalRough(host) && host.family !== 'basic' && host.family !== 'depth') + return `material ${host.family} is unsupported`; // The draws' own refusal (`drawnBlending`): a mode admitted here is one every path draws. const refusal = blendingRefusal(blendingOf(host.blending), isTransmissive(material)); if (refusal) return `material ${host.family}: ${refusal} (blending ${host.blending})`; @@ -76,31 +75,25 @@ export function clusterMaterialReason( if (declaresCompileHook(host)) return `material ${host.family} carries a shader hook`; if (!ownBuffer(attributes.position)) return 'position attribute is unsupported'; // The same six maps the import reads, in the same order: a basic material declares none of the - // lit ones, so the list is the host's own properties, not a second rule. A matcap's base is its - // image, read by the normal, never by a UV: checked as a map below, not asked a UV. An unlit - // model reads no relief, and only a basic one its occlusion (`bindClusterMaterial`). - const model = hostSurfaceModel(host), - matcap = model === SURFACE_MODEL.matcap, - lit = litModel(host, model); + // lit ones, so the list is the host's own properties, not a second rule. const maps = [ - matcap ? undefined : host.map, + host.map, host.metalnessMap, host.roughnessMap, - lit ? host.normalMap : undefined, - lit || model === SURFACE_MODEL.standard ? host.aoMap : undefined, + host.normalMap, + host.aoMap, host.emissiveMap, ]; if (maps.some(Boolean) && !ownBuffer(attributes.uv)) return 'textured material has no UV attribute'; if (maps.some((texture) => texture?.channel === 1) && !ownBuffer(attributes.uv1)) return 'texture channel 1 has no UV1 attribute'; - if (normals && !ownBuffer(attributes.normal)) - return `${host.family} material has no normal attribute`; + if (metalRough(host) && !ownBuffer(attributes.normal)) + return 'lit material has no normal attribute'; if (host.vertexColors && !ownBuffer(attributes.color)) return 'vertex-colour material has no color attribute'; for (const texture of maps) { const reason = textureReason(texture); if (reason) return reason; } - if (matcap) return textureReason(host.matcap); } diff --git a/packages/sdk-browser/src/lighting/fogWiring.test.ts b/packages/sdk-browser/src/lighting/fogWiring.test.ts index a63f4ffd8d..e3dc5895fc 100644 --- a/packages/sdk-browser/src/lighting/fogWiring.test.ts +++ b/packages/sdk-browser/src/lighting/fogWiring.test.ts @@ -15,7 +15,6 @@ import { BLEND_SHADER, BLEND_VIEW_WGSL } from '../webgpu/blend/shader.ts'; import { BLEND_VIEW_SIZE } from '../webgpu/blend/uniforms.ts'; import { WATER_COMPOSITE_SHADER } from '../webgpu/water/compositeWgsl.ts'; import { CLUSTER_FRAGMENT } from '../webgl/cluster/shaders.ts'; -import { SURFACE_MODEL } from '../scene/surfaceModel.ts'; import { SHADE_SHADER as SURFACE_SHADE } from '../visibility/shader/shadeWgsl.ts'; test('the opaque resolve fogs its lit sum at the pixel, from the eye in display.yzw', () => { @@ -55,6 +54,6 @@ test('the WebGL2 program fogs every surface before its display curve, a depth or const fogAt = CLUSTER_FRAGMENT.indexOf('\nif(!fogFree)rgb=fogged(rgb);'); assert.ok(fogAt > 0); // A depth material's ramp is written over the fogged colour. - assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf(`if(surfaceModel==${SURFACE_MODEL.depth})rgb=`)); + assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf('if(depthShaded)rgb=')); assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf('if(toneMapped)rgb=toneMap(rgb);')); }); diff --git a/packages/sdk-browser/src/scene/surfaceModel.ts b/packages/sdk-browser/src/scene/surfaceModel.ts index ccf56163d2..2397cdac80 100644 --- a/packages/sdk-browser/src/scene/surfaceModel.ts +++ b/packages/sdk-browser/src/scene/surfaceModel.ts @@ -37,27 +37,15 @@ export const AS_IS_FLAG = 3; export const shownAsIs = (model: number | undefined) => model === SURFACE_MODEL.normal || model === SURFACE_MODEL.depth; -/** Each family the one model reads: its model, and whether its colour reads its normal — to light - * it, to show it or to find its matcap —; a family absent here is one no path draws. */ -const FAMILIES = new Map([ - ['standard', [SURFACE_MODEL.standard, true]], - ['physical', [SURFACE_MODEL.standard, true]], - ['phong', [SURFACE_MODEL.standard, true]], - ['basic', [SURFACE_MODEL.standard, false]], - ['lambert', [SURFACE_MODEL.diffuse, true]], - ['toon', [SURFACE_MODEL.toon, true]], - ['normal', [SURFACE_MODEL.normal, true]], - ['matcap', [SURFACE_MODEL.matcap, true]], - ['depth', [SURFACE_MODEL.depth, false]], -]); - /** The model a surface declares by its family; the physical model otherwise. */ -export const hostSurfaceModel = ({ family }: HostShadedMaterial): number => - FAMILIES.get(family)?.[0] ?? SURFACE_MODEL.standard; - -/** Whether a family reads its normal; undefined for a family no model reads. */ -export const readsNormal = ({ family }: HostShadedMaterial): boolean | undefined => - FAMILIES.get(family)?.[1]; +export function hostSurfaceModel({ family }: HostShadedMaterial): number { + if (family === 'lambert') return SURFACE_MODEL.diffuse; + if (family === 'toon') return SURFACE_MODEL.toon; + if (family === 'normal') return SURFACE_MODEL.normal; + if (family === 'matcap') return SURFACE_MODEL.matcap; + if (family === 'depth') return SURFACE_MODEL.depth; + return SURFACE_MODEL.standard; +} /** Whether a surface carries the metal-rough parameters: a standard or a physical one. */ export const metalRough = ({ family }: HostShadedMaterial) => @@ -74,14 +62,6 @@ export const litModel = (host: HostShadedMaterial, model: number) => export const shininessRoughness = (shininess: number) => Math.sqrt(2 / (Math.max(0, shininess) + 2)); -/** The toon cosine at `nl` = N·L, and the matcap coordinate of a view-space normal `n`: the one text - * both languages read, WGSL here and GLSL in `SURFACE_MODEL_GLSL`. */ -export const TOON_BANDS = 'mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5))'; -/** `modelLight`'s diffuse lobe and its Lambert cosine, the text both languages read. */ -const MODEL_DIFFUSE = `rgb*(1.0-metal)*${INVERSE_PI}*energy*ao`; -const LAMBERT = 'max(nl,0.0)'; -const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; - /** * What a declared lamp gives a pixel of a diffuse or toon surface, read by `declaredLight` through * the private `surfaceModel` the resolve sets from the surface flag. Toon keeps the lamp's energy — @@ -90,10 +70,10 @@ const matcapAt = (vec2: string) => `${vec2}(n.x*0.495+0.5,0.5-n.y*0.495)`; export const SURFACE_MODEL_LIGHT_WGSL = ` var surfaceModel:u32; fn modelLight(rgb:vec3f,metal:f32,N:vec3f,L:vec3f,energy:f32,ao:f32)->vec3f{ - let diffuse=${MODEL_DIFFUSE}; + let diffuse=rgb*(1.0-metal)*${INVERSE_PI}*energy*ao; let nl=dot(N,L); - if(surfaceModel==${MODEL_FLAG.toon}u){return diffuse*${TOON_BANDS};} - return diffuse*${LAMBERT}; + if(surfaceModel==${MODEL_FLAG.toon}u){return diffuse*mix(0.7,1.0,smoothstep(0.69,0.71,nl*0.5+0.5));} + return diffuse*max(nl,0.0); }`; /** @@ -108,17 +88,4 @@ fn viewNormal(N:vec3f)->vec3f{ let up=normalize(vec3f(uni.viewProj[0].y,uni.viewProj[1].y,uni.viewProj[2].y)); return vec3f(dot(N,right),dot(N,up),dot(N,cross(right,up))); } -fn matcapUv(N:vec3f)->vec2f{let n=viewNormal(N);return ${matcapAt('vec2f')};}`; - -/** - * The same models in the WebGL2 cluster program (`../webgl/cluster/shaders.ts`), whose normals are - * already in view space: `surfaceModel` holds the rank of `SURFACE_MODEL`, a diffuse or toon - * surface takes `modelLight` for each lamp in place of the physical lobes, and a matcap reads its - * image at `matcapUv`. - */ -export const SURFACE_MODEL_GLSL = ` -uniform int surfaceModel; -bool bandedModel(){return surfaceModel==${SURFACE_MODEL.diffuse}||surfaceModel==${SURFACE_MODEL.toon};} -vec3 modelLight(vec3 rgb,float metal,vec3 N,vec3 L,float energy,float ao){vec3 diffuse=${MODEL_DIFFUSE}; -float nl=dot(N,L);if(surfaceModel==${SURFACE_MODEL.toon})return diffuse*${TOON_BANDS};return diffuse*${LAMBERT};} -vec2 matcapUv(vec3 n){return ${matcapAt('vec2')};}`; +fn matcapUv(N:vec3f)->vec2f{let n=viewNormal(N);return vec2f(n.x*0.495+0.5,0.5-n.y*0.495);}`; diff --git a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts index 3486e5d579..db8ec4b492 100644 --- a/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/compatibility.test.ts @@ -17,11 +17,19 @@ test('an untextured Basic material needs no unused UV or normal attribute', () = assert.equal(clusterMaterialReason(G.basicSurface(), { position }), undefined); }); +test('a Depth material draws on the autonomous path; another unlit family is still refused', () => { + assert.equal(clusterMaterialReason(new G.GraphSurface('depth'), { position }), undefined); + assert.match( + clusterMaterialReason(new G.GraphSurface('normal'), { position })!, + /normal is unsupported/, + ); +}); + test('unsupported mutations refuse the autonomous draw before it becomes partial', () => { const material = G.standardSurface(); assert.equal( clusterMaterialReason(material, { position }), - 'standard material has no normal attribute', + 'lit material has no normal attribute', ); material.transparent = true; assert.equal( diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts index 81eaddca33..224a738f59 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.test.ts @@ -9,8 +9,6 @@ import { bindClusterMaterial } from './materialBinding.ts'; import { importHostTexture } from '../../host/textureImport.ts'; import { pageDiagnostics } from '../../host/pageDiagnostics.ts'; import { CLUSTER_FRAGMENT } from './shaders.ts'; -import { LAST_MATERIAL_SLOT } from './materialUniforms.ts'; -import { SURFACE_MODEL } from '../../scene/surfaceModel.ts'; import { HOST_BLENDING_ADDITIVE, HOST_BLENDING_MULTIPLY, @@ -88,6 +86,12 @@ const flagOf = (material: G.GraphSurface, name: string, linear = false) => { return flags.get(name); }; +test('A Depth material, and it alone, shows the frame depth ramp', () => { + assert.equal(flagOf(new G.GraphSurface('depth'), 'depthShaded'), 1); + assert.equal(flagOf(G.standardSurface(), 'depthShaded'), 0); + assert.equal(flagOf(G.basicSurface(), 'depthShaded'), 0); +}); + test("A diagnostic view's surfaces, and they alone, stay out of the fog", () => { // The two surfaces a diagnostic view paints on the WebGL2 path: they show a number. const triangles = pageDiagnostics.triangleMaterial(0) as unknown as G.GraphSurface; @@ -132,36 +136,3 @@ test('Into the effect chain, a surface covers its pixel as the display path show // A mode no path draws is refused here as by the display path, never drawn uncovered. assert.throws(() => flagOf(transparent(99), 'covering', true), /a surface declares a blending/); }); - -test('Each family reaches the WebGL2 program in the model it reads on WebGPU (#527)', () => { - const { standard, diffuse, toon, normal, matcap, depth } = SURFACE_MODEL; - const models = { - standard, - phong: standard, - basic: standard, - lambert: diffuse, - toon, - normal, - matcap, - depth, - }; - for (const [family, model] of Object.entries(models)) - assert.equal(flagOf(new G.GraphSurface(family as 'standard'), 'surfaceModel'), model, family); - // A matcap's image is bound as the base map, the one the program reads at the normal. - assert.equal(flagOf(new G.GraphSurface('matcap', { matcap: texture() }), 'mapMask'), 1); - // Only a basic surface reads the occlusion of an unlit model; a matcap reads none, as on WebGPU. - assert.equal(flagOf(new G.GraphSurface('basic', { aoMap: texture() }), 'mapMask'), 16); - assert.equal(flagOf(new G.GraphSurface('matcap', { aoMap: texture() }), 'mapMask'), 0); -}); - -test('The uniform cache holds every slot the binder writes, the last one included', () => { - let last = -1; - const { binding } = recorder(); - const widths = { f1: 1, i1: 1, f2: 2, i2: 2, f3: 3, f4: 4, i4: 4 }; - for (const [name, width] of Object.entries(widths)) - (binding.uniforms as unknown as Record)[name] = (index: number) => - void (last = Math.max(last, index + width - 1)); - binding.linear = true; - bindClusterMaterial(binding, G.standardSurface(), true); - assert.equal(last, LAST_MATERIAL_SLOT); -}); diff --git a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts index 683494026b..8e936a431e 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialBinding.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialBinding.ts @@ -59,12 +59,10 @@ export function bindClusterMaterial( const { uniforms, matrices, textures, state, linear } = binding; const source = material as { opacity: number }, mat = visMaterial(material); - // A basic material keeps its occlusion map and strength on the host object alone: its map is + // An unlit material keeps its occlusion map and strength on the host object alone: its map is // imported here, as the boundary imports every other, into the engine record the binding reads. - // The other unlit models — normal, matcap, depth — read no occlusion, as on WebGPU. const basic = material as { aoMap?: HostTexture | null; aoMapIntensity?: number }, - unlitBasic = !mat.lit && (mat.model ?? SURFACE_MODEL.standard) === SURFACE_MODEL.standard, - aoMap = mat.aoMap ?? (unlitBasic && basic.aoMap ? importHostTexture(basic.aoMap) : undefined), + aoMap = mat.aoMap ?? (!mat.lit && basic.aoMap ? importHostTexture(basic.aoMap) : undefined), aoIntensity = mat.aoMap ? mat.aoIntensity : (basic.aoMapIntensity ?? 1); uniforms.f4( 0, @@ -130,9 +128,8 @@ export function bindClusterMaterial( uniforms.f2(45, 'sprite', sprite[0], sprite[1]); const doubleSided = side === undefined ? mat.doubleSided : false, backSide = side === undefined ? mat.backSide : side === 'back'; - // The model a non-physical family reads in (`SURFACE_MODEL_GLSL`): lambert, toon, normal and - // matcap, and depth, which shows the frame's depth ramp in place of its colour (`beginFrame`). - uniforms.i1(35, 'surfaceModel', mat.model ?? SURFACE_MODEL.standard); + // A depth material shows the frame's depth ramp in place of its colour (`beginFrame`). + uniforms.i1(35, 'depthShaded', mat.model === SURFACE_MODEL.depth ? 1 : 0); // A diagnostic view's surface is shown as it is, never through the fog // (`../../host/pageDiagnostics.ts`). uniforms.i1(38, 'fogFree', (material as { fog?: boolean }).fog === false ? 1 : 0); diff --git a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts index 693a98d553..174d66027b 100644 --- a/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts +++ b/packages/sdk-browser/src/webgl/cluster/materialUniforms.ts @@ -1,9 +1,5 @@ -/** The binder's highest slot, the linear program's `covering` (`materialBinding.ts`): one cached - * value per slot. */ -export const LAST_MATERIAL_SLOT = 47; - export class WebglClusterMaterialUniforms { - private values = new Float64Array(LAST_MATERIAL_SLOT + 1).fill(Number.NaN); + private values = new Float64Array(48).fill(Number.NaN); private gl: WebGL2RenderingContext; private at: (name: string) => WebGLUniformLocation | null; constructor(gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null) { diff --git a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts index f551d1af0f..63e23a214e 100644 --- a/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts +++ b/packages/sdk-browser/src/webgl/cluster/rectGlsl.ts @@ -1,6 +1,5 @@ import { LTC_SIZE, ltcTable } from '../../../../sdk-core/src/lighting/ltcTable.ts'; import { INVERSE_TWO_PI } from '../../lighting/shaderConstants.ts'; -import { SURFACE_MODEL } from '../../scene/surfaceModel.ts'; /** The WebGL2 program's rank of a rectangle in `lightData`, after its ambient (3). */ export const WEBGL_RECT_KIND = 4; @@ -12,8 +11,7 @@ export const LTC_UNIT = 8; * (`../../lighting/direct/rectLightWgsl.ts`), operation for operation, on the same fitted table (`ltcTable.ts`), * read here as a `2·LTC_SIZE × LTC_SIZE` float texture — texel `(2x + k, y)` is the table's cell * `(x, y)`, vec4 `k`. The diffuse is the exact clipped form factor of the rectangle; the - * specular, the rectangle moved by the fitted M⁻¹ and integrated the same way; a diffuse or toon - * surface takes the diffuse alone through `modelLight` (`../../scene/surfaceModel.ts`). No cast shadow, + * specular, the rectangle moved by the fitted M⁻¹ and integrated the same way. No cast shadow, * as on the WebGPU path: the contract refuses one for a rectangle. * * A light's four vec4 hold its centre and range, its emitting normal and rank, its colour and @@ -29,14 +27,11 @@ vec4 ltcTexel(int x,int y,int k){return texelFetch(ltcTable,ivec2(x*2+k,y),0);} vec4 ltcLookup(float rough,float NdotV,int k){vec2 at=vec2(clamp(rough,0.0,1.0),sqrt(clamp(1.0-NdotV,0.0,1.0)))*float(LTC_SIZE-1); ivec2 i=min(ivec2(at),ivec2(LTC_SIZE-2));vec2 f=at-vec2(i);vec4 low=mix(ltcTexel(i.x,i.y,k),ltcTexel(i.x+1,i.y,k),f.x); return mix(low,mix(ltcTexel(i.x,i.y+1,k),ltcTexel(i.x+1,i.y+1,k),f.x),f.y);} -float formEnergy(float intensity,vec4 form,float window){return intensity*PI*form.w*window;} vec3 ltcCorner(vec3 q,vec3 T1,vec3 T2,vec3 N,vec4 m){float x=dot(q,T1),z=dot(q,N);return vec3(m.x*x+m.y*z,dot(q,T2),m.z*x+m.w*z);} -vec3 rectLight(vec4 positionRange,vec3 n,vec4 axis,vec4 colorIntensity,vec3 N,vec3 V,vec3 P,vec3 base,float metal,float rough,float ao){ +vec3 rectLight(vec4 positionRange,vec3 n,vec4 axis,vec4 colorIntensity,vec3 N,vec3 V,vec3 P,vec3 base,float metal,float rough){ vec3 C=positionRange.xyz,U=axis.xyz,W=normalize(cross(U,n))*axis.w;if(dot(P-C,n)<=0.0)return vec3(0.0); float window=rangeWindow(length(C-P),positionRange.w); -vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;vec4 F=polygonFormFactor(a,b,c,d,N);float E=formEnergy(colorIntensity.w,F,window);if(E<=0.0)return vec3(0.0); -if(surfaceModel==${SURFACE_MODEL.diffuse})return modelLight(base,metal,N,N,E,ao)*colorIntensity.rgb; -if(surfaceModel==${SURFACE_MODEL.toon})return modelLight(base,metal,N,F.xyz,formEnergy(colorIntensity.w,polygonFormFactor(a,b,c,d,F.xyz),window),ao)*colorIntensity.rgb; +vec3 a=C-U-W-P,b=C+U-W-P,c=C+U+W-P,d=C-U+W-P;float E=colorIntensity.w*PI*polygonFormFactor(a,b,c,d,N).w*window;if(E<=0.0)return vec3(0.0); float NdotV=clamp(dot(N,V),1e-4,1.0);vec3 side=V-N*dot(N,V),other=cross(N,abs(N.x)>0.9?vec3(0.0,1.0,0.0):vec3(1.0,0.0,0.0)); vec3 T1=normalize(dot(side,side)<1e-10?other:side),T2=cross(N,T1);vec4 m=ltcLookup(rough,NdotV,0),t=ltcLookup(rough,NdotV,1); float lobe=polygonFormFactor(ltcCorner(a,T1,T2,N,m),ltcCorner(b,T1,T2,N,m),ltcCorner(c,T1,T2,N,m),ltcCorner(d,T1,T2,N,m),vec3(0.0,0.0,1.0)).w; diff --git a/packages/sdk-browser/src/webgl/cluster/shaders.ts b/packages/sdk-browser/src/webgl/cluster/shaders.ts index 57e223a054..79fe8db39d 100644 --- a/packages/sdk-browser/src/webgl/cluster/shaders.ts +++ b/packages/sdk-browser/src/webgl/cluster/shaders.ts @@ -6,7 +6,6 @@ import { INVERSE_PI, PI, ROUGHNESS_FLOOR } from '../../lighting/shaderConstants. import { FOG_GLSL } from '../../lighting/fogShader.ts'; import { LINE_CLIP_GLSL, LINE_DASH_GLSL } from '../../visibility/shader/lineWgsl.ts'; import { SPRITE_GLSL } from '../../visibility/shader/spriteWgsl.ts'; -import { SURFACE_MODEL, SURFACE_MODEL_GLSL } from '../../scene/surfaceModel.ts'; // An instanced mesh places each copy by its own matrix before the mesh's: the position first, // then the normal, scaled back by the matrix's axes before it is turned — the reference's order. @@ -51,7 +50,7 @@ export const CLUSTER_FRAGMENT = `#version 300 es precision highp float;const float PI=${PI},INVERSE_PI=${INVERSE_PI};const int MAX_LIGHTS=64; in vec3 toEye;in vec3 viewNormal;vec3 viewPosition;in vec2 texcoord0;in vec2 texcoord1;in vec4 vertexColor;out vec4 outColor; uniform vec4 baseFactor;uniform float metalFactor,roughFactor,alphaCutoff,aoStrength;uniform vec2 normalScale; -uniform vec3 emissiveFactor;uniform vec2 depthRamp,dash;uniform bool fogFree,lit,flatShaded,toneMapped,srgbDestination,hasNormalMap,hasVertexColor,sharedMetalRough;uniform int mapMask,faceSides; +uniform vec3 emissiveFactor;uniform vec2 depthRamp,dash;uniform bool depthShaded,fogFree,lit,flatShaded,toneMapped,srgbDestination,hasNormalMap,hasVertexColor,sharedMetalRough;uniform int mapMask,faceSides; uniform sampler2D baseMap,roughMap,metalMap,normalMap,aoMap,emissiveMap; uniform mat3 baseUv,roughUv,metalUv,normalUv,aoUv,emissiveUv; uniform mat4 projectionMatrix;uniform int lightCount;uniform ivec4 mapChannels;uniform ivec2 extraChannels;layout(std140) uniform ClusterLights{vec4 lightData[256];}; @@ -72,7 +71,6 @@ float attenuation(float distance,float range,float decay){float falloff=1.0/max( if(range>0.0){float r=distance/range,r2=r*r,s=clamp(1.0-r2*r2,0.0,1.0);falloff*=s*s;}return falloff;} float spotFactor(float cosine,float inner,float outer){return inner<=outer?(cosine>=outer?1.0:0.0):smoothstep(outer,inner,cosine);} ${OUTPUT_TRANSFER_GLSL} -${SURFACE_MODEL_GLSL} ${RECT_LIGHT_GLSL} ${PROBE_IRRADIANCE_GLSL} ${FOG_GLSL} @@ -86,17 +84,17 @@ vec3 shade(vec3 N,vec3 V,vec3 base,float metal,float rough,float ao){vec3 diffus vec3 direct=vec3(0.0),specular=vec3(0.0),irradiance=vec3(0.0);for(int i=0;i=lightCount)break; vec4 positionRange=lightData[i*4],directionKind=lightData[i*4+1],colorIntensity=lightData[i*4+2],cone=lightData[i*4+3]; int kind=int(directionKind.w);if(kind==3){irradiance+=colorIntensity.rgb;continue;} -if(kind==${WEBGL_RECT_KIND}){direct+=rectLight(positionRange,directionKind.xyz,cone,colorIntensity,N,V,viewPosition,base,metal,rough,ao);continue;} +if(kind==${WEBGL_RECT_KIND}){direct+=rectLight(positionRange,directionKind.xyz,cone,colorIntensity,N,V,viewPosition,base,metal,rough);continue;} vec3 L,color=colorIntensity.rgb;if(kind==0)L=directionKind.xyz;else{vec3 toLight=positionRange.xyz-viewPosition;L=normalize(toLight); if(kind==2){float s=spotFactor(dot(L,directionKind.xyz),cone.x,cone.y);if(s<=0.0)continue;color=color*s;} color*=attenuation(length(toLight),positionRange.w,cone.z);} -if(bandedModel()){direct+=modelLight(base,metal,N,L,1.0,ao)*color;continue;}vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} +vec3 E=clamp(dot(N,L),0.0,1.0)*color;specular+=E*specularLobe(L,V,N,f0,rough);direct+=E*(INVERSE_PI*diffuse);} irradiance+=probeIrradiance(N);return(direct+irradiance*(INVERSE_PI*diffuse)*ao)+specular;} ${TRANSMISSION_GLSL} -void main(){if(!lineDash(texcoord0.x,dash))discard;viewPosition=-toEye;vec3 surfaceNormal=normalize(viewNormal);vec4 base=baseFactor;if((mapMask&1)!=0)base*=surfaceModel==${SURFACE_MODEL.matcap}?textureLod(baseMap,mapUv(baseUv,matcapUv(surfaceNormal)),0.0):texture(baseMap,mapUv(baseUv,sourceUv(mapChannels.x)));if(hasVertexColor)base*=vertexColor;if(base.a { - for (const family of ['depth', 'lambert', 'phong', 'toon', 'normal', 'matcap'] as const) { - const surface = new G.GraphSurface(family); - assert.equal(clusterMaterialReason(surface, { position, normal }), undefined, family); - const refusal = family === 'depth' ? undefined : `${family} material has no normal attribute`; - assert.equal(clusterMaterialReason(surface, { position }), refusal, family); - } - const shader = { family: 'shader' } as unknown as G.GraphSurface; - assert.equal(clusterMaterialReason(shader, { position }), 'material shader is unsupported'); - // A name the prototype of an object carries is no family either. - const named = { family: 'constructor' } as unknown as G.GraphSurface; - assert.equal(clusterMaterialReason(named, { position }), 'material constructor is unsupported'); -}); - -test("A matcap's image is checked as a map, and asks for no UV: the normal reads it", () => { - const image = G.dataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1, G.HOST_FORMAT_RGBA); - const matcap = new G.GraphSurface('matcap', { matcap: image }); - assert.equal(clusterMaterialReason(matcap, { position, normal }), undefined); - // A base map beside it is never read, the import binding the matcap in its place: no UV asked. - const both = new G.GraphSurface('matcap', { matcap: image, map: image }); - assert.equal(clusterMaterialReason(both, { position, normal }), undefined); - // Nor does the relief or occlusion an unlit model never reads: the import drops them. - for (const family of ['normal', 'matcap'] as const) { - const unread = new G.GraphSurface(family, { normalMap: image, aoMap: image }); - assert.equal(clusterMaterialReason(unread, { position, normal }), undefined, family); - } - image.image = undefined as never; - assert.equal(clusterMaterialReason(matcap, { position, normal }), 'texture image is unavailable'); -}); - -/** The arguments of every `modelLight` call in `code`, split at their top-level commas. */ -function modelLightCalls(code: string) { - const calls: string[][] = []; - for (let at = code.indexOf('modelLight('); at >= 0; at = code.indexOf('modelLight(', at + 1)) { - if (/(?:fn |vec3 )$/.test(code.slice(0, at))) continue; // the definition itself - const args = ['']; - for (let i = at + 'modelLight('.length, depth = 0; depth >= 0; i++) { - const c = code[i]; - depth += c === '(' ? 1 : c === ')' ? -1 : 0; - if (depth < 0) break; - if (c === ',' && depth === 0) args.push(''); - else args[args.length - 1] += c; - } - calls.push(args); - } - return calls; -} - -test('The lamp and rectangle branches of both languages call the one model alike', async () => { - const source = (file: string) => - readFile(new URL(`../../lighting/direct/${file}`, import.meta.url), 'utf8'); - const wgsl = modelLightCalls( - (await source('lightingWgsl.ts')) + (await source('rectLightWgsl.ts')), - ); - const glsl = modelLightCalls(CLUSTER_FRAGMENT); - // A lamp, a rectangle's diffuse and its toon bands: three sites a language cannot lose. - assert.equal(wgsl.length, 3); - assert.equal(glsl.length, 3); - for (const call of [...wgsl, ...glsl]) { - assert.equal(call.length, 6, call.join()); - assert.deepEqual([call[1], call[2], call[5]], ['metal', 'N', 'ao'], call.join()); - } - // The rectangle's diffuse takes its irradiance whole, at a unit N·L, in both. - const whole = (calls: string[][]) => calls.filter((call) => call[3] === 'N' && call[4] === 'E'); - assert.equal(whole(wgsl).length, 1); - assert.equal(whole(glsl).length, 1); - // Both read one toon band, and a lamp takes the model on a diffuse or toon surface alone. - assert.ok(SURFACE_MODEL_LIGHT_WGSL.includes(`diffuse*${TOON_BANDS}`)); - assert.ok(SURFACE_MODEL_GLSL.includes(`diffuse*${TOON_BANDS}`)); - const { diffuse, toon, matcap, normal: shown } = SURFACE_MODEL; - assert.ok(SURFACE_MODEL_GLSL.includes(`surfaceModel==${diffuse}||surfaceModel==${toon}`)); - assert.match( - CLUSTER_FRAGMENT, - /if\(bandedModel\(\)\)\{direct\+=modelLight\([^;]+;continue;\}vec3 E=/, - ); - // A matcap reads its base map at the view normal, at its finest level as the surface pass does - // (zero gradients); a normal surface shows it, after the fog. - assert.ok( - CLUSTER_FRAGMENT.includes( - `surfaceModel==${matcap}?textureLod(baseMap,mapUv(baseUv,matcapUv(surfaceNormal)),0.0):`, - ), - ); - const fogAt = CLUSTER_FRAGMENT.indexOf('rgb=fogged(rgb);'); - assert.ok(fogAt < CLUSTER_FRAGMENT.indexOf(`if(surfaceModel==${shown})rgb=N*0.5+0.5;`)); -}); From d172bb00b1c8ec4002fee5c1474f93b58b61a6d0 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:12:11 +0200 Subject: [PATCH 10/10] refactor(sdk): the proof's look folds into the dropped-move test, one settle for the lock wait, the verdict labelled from the run (#527) --- .../src/camera/controls/firstPerson.test.ts | 33 ++++++------------- .../renders/default-backend.browser.ts | 7 ++-- tests/browser/support/cameraControlsPage.ts | 29 +++++----------- 3 files changed, 21 insertions(+), 48 deletions(-) diff --git a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts index 6188fdc2cd..42d22161d8 100644 --- a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts +++ b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts @@ -39,19 +39,24 @@ test('first person turns the head with the pointer and lets the lock go on dispo assert.equal(surface.listeners(), 0); }); -test('the first move after the lock is granted is dropped: the cursor jump never turns the head', () => { +test('the first move after the lock is granted is dropped: the cursor jump never turns the head, and a turn and its reverse return the head', () => { const { camera, surface, controls } = steered(createFirstPersonCameraControls); + const looking = () => [...facing(camera)].map((v) => round(v)); controls.lookSpeed = Math.PI / 400; controls.update(0); + const home = looking(); surface.fire('pointerdown', { pointerId: 1, button: 0, clientX: 0, clientY: 0 }); surface.key('pointerlockchange', {}); surface.fire('pointermove', { pointerId: 1, movementX: -900, movementY: 700 }); surface.fire('pointermove', { pointerId: 1, movementX: 200, movementY: 0 }); controls.update(0); - assert.deepEqual( - [...facing(camera)].map((v) => round(v)), - [1, 0, 0], - ); + assert.deepEqual(looking(), [1, 0, 0]); + // The examples proof's look (#527): every move after the dropped one counts, so the reverse, + // in four steps, lands on the very head the lock started from. + for (let step = 0; step < 4; step++) + surface.fire('pointermove', { pointerId: 1, movementX: -50, movementY: 0 }); + controls.update(0); + assert.deepEqual(looking(), home); }); test('first person stops a downward look at `minPitch`', () => { @@ -115,21 +120,3 @@ test('first person turns 0.002 radians per pixel until `lookSpeed` is set', () = controls.update(0); assert.equal(round(Math.atan2(facing(camera)[0], -facing(camera)[2])), round(0.2)); }); - -test("the examples proof's look: after the lock, one dropped move, then four out and four back return the head (#527)", () => { - const { camera, surface, controls } = steered(createFirstPersonCameraControls); - const looking = () => [...facing(camera)].map((v) => round(v)); - controls.update(0); - const home = looking(); - surface.fire('pointerdown', { pointerId: 1, button: 0, clientX: 0, clientY: 0 }); - surface.key('pointerlockchange', {}); - const move = (movementX: number) => - surface.fire('pointermove', { pointerId: 1, movementX, movementY: 0 }); - move(1); - for (let step = 0; step < 4; step++) move(20); - controls.update(0); - assert.notDeepEqual(looking(), home, 'the four moves out turn the head'); - for (let step = 0; step < 4; step++) move(-20); - controls.update(0); - assert.deepEqual(looking(), home); -}); diff --git a/tests/browser/renders/default-backend.browser.ts b/tests/browser/renders/default-backend.browser.ts index a487c328f5..cd0d3bcb49 100644 --- a/tests/browser/renders/default-backend.browser.ts +++ b/tests/browser/renders/default-backend.browser.ts @@ -143,13 +143,10 @@ try { ); // The canvas is not empty: a twentieth of it at least differs from the cleared background. // Each machine named in its own verdict, with the frame's counters: a blank side says which (#527). - for (const [webgpu, run] of [ - [true, withGpu], - [false, withoutGpu], - ] as const) + for (const run of [withGpu, withoutGpu]) assert.ok( run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, - `${machineLabel(webgpu)}: ${JSON.stringify({ drawn: run.drawn, metrics: run.cases.default.metrics })}`, + `${machineLabel(run.cases.default.webgpu)}: ${JSON.stringify({ drawn: run.drawn, metrics: run.cases.default.metrics })}`, ); assert.deepEqual(errors, []); const side = (name: string, run: MachineResult) => [ diff --git a/tests/browser/support/cameraControlsPage.ts b/tests/browser/support/cameraControlsPage.ts index 15e089e4df..f7310dbd87 100644 --- a/tests/browser/support/cameraControlsPage.ts +++ b/tests/browser/support/cameraControlsPage.ts @@ -107,27 +107,16 @@ export async function endGesture() { export function pointerLocked() { return new Promise((granted, refused) => { if (document.pointerLockElement) return granted(); - // The first of the two events settles the wait and removes both listeners. + // The first of the two events settles the wait and removes both listeners; a change that + // leaves the pointer free is a lost lock, never a granted one. const settled = new AbortController(); - const { signal } = settled; - // A change that leaves the pointer free is a lost lock, never a granted one. - document.addEventListener( - 'pointerlockchange', - () => { - settled.abort(); - if (document.pointerLockElement) granted(); - else refused(new Error('lock released')); - }, - { signal }, - ); - document.addEventListener( - 'pointerlockerror', - () => { - settled.abort(); - refused(new Error('lock refused')); - }, - { signal }, - ); + const settle = () => { + settled.abort(); + if (document.pointerLockElement) granted(); + else refused(new Error('lock refused or released')); + }; + document.addEventListener('pointerlockchange', settle, { signal: settled.signal }); + document.addEventListener('pointerlockerror', settle, { signal: settled.signal }); }); }