@forgeax/engine-shader 0.1.21 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +14 -5
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +33042 -55
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/ltc/provenance.d.ts +17 -0
  8. package/dist/ltc/provenance.d.ts.map +1 -0
  9. package/dist/ltc/tables.d.ts +22 -0
  10. package/dist/ltc/tables.d.ts.map +1 -0
  11. package/dist/material/artifact-types.d.ts +52 -0
  12. package/dist/material/artifact-types.d.ts.map +1 -1
  13. package/dist/material-schemas.d.ts +36 -0
  14. package/dist/material-schemas.d.ts.map +1 -1
  15. package/dist/register-default-sprite-lit.d.ts +3 -6
  16. package/dist/register-default-sprite-lit.d.ts.map +1 -1
  17. package/dist/types.d.ts +2 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +4 -4
  20. package/src/ShaderRegistry.ts +0 -17
  21. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +9 -3
  22. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +67 -122
  23. package/src/__tests__/deferred-lighting-ssao.test.ts +3 -3
  24. package/src/__tests__/direct-light-layout.unit.test.ts +40 -0
  25. package/src/__tests__/ibl-irradiance.unit.test.ts +16 -4
  26. package/src/__tests__/ibl-sampling.unit.test.ts +3 -4
  27. package/src/__tests__/lighting-directional.unit.test.ts +36 -0
  28. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +8 -2
  29. package/src/__tests__/lighting-punctual.unit.test.ts +40 -5
  30. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +1 -1
  31. package/src/__tests__/ltc-provenance.unit.test.ts +33 -0
  32. package/src/__tests__/manifest.fixture.json +33 -0
  33. package/src/__tests__/material-builtins.unit.test.ts +21 -3
  34. package/src/__tests__/material-contract.unit.test.ts +188 -14
  35. package/src/__tests__/physical-clearcoat.unit.test.ts +65 -0
  36. package/src/__tests__/probe-lighting-composition.unit.test.ts +39 -0
  37. package/src/__tests__/rect-area-brdf.unit.test.ts +36 -0
  38. package/src/__tests__/reflection-probe-sampling.unit.test.ts +24 -2
  39. package/src/__tests__/shader-manifest-transmission.unit.test.ts +8 -10
  40. package/src/__tests__/shader.unit.test.ts +54 -85
  41. package/src/__tests__/spot-cookie-composition.unit.test.ts +58 -0
  42. package/src/__tests__/spot-modifier-composition.unit.test.ts +19 -0
  43. package/src/__tests__/sprite-variants.unit.test.ts +4 -1
  44. package/src/__tests__/standard-webgl2-varying-budget.unit.test.ts +17 -0
  45. package/src/__tests__/surface-v1-negative.unit.test.ts +37 -0
  46. package/src/__tests__/surface-v1.unit.test.ts +83 -0
  47. package/src/__tests__/transmission-thickness-scale.unit.test.ts +2 -6
  48. package/src/__tests__/vertex-color-variant.unit.test.ts +16 -5
  49. package/src/brdf.wgsl +115 -1
  50. package/src/common.wgsl +53 -79
  51. package/src/default-standard-pbr-skin.wgsl +687 -144
  52. package/src/default-standard-pbr.wgsl +600 -247
  53. package/src/default_standard_surface.wgsl +88 -0
  54. package/src/hdrp-cluster-forward.wgsl +225 -0
  55. package/src/ibl-irradiance.wgsl +47 -9
  56. package/src/ibl-prefilter.wgsl +13 -3
  57. package/src/ibl-sampling.wgsl +2 -2
  58. package/src/ibl-shared.wgsl +9 -2
  59. package/src/index.ts +23 -0
  60. package/src/lighting-directional.wgsl +9 -117
  61. package/src/lighting-probe.wgsl +51 -0
  62. package/src/lighting-punctual.wgsl +21 -11
  63. package/src/lighting-rect-area.wgsl +162 -0
  64. package/src/lighting-spot-modifiers.wgsl +100 -0
  65. package/src/lighting-spot-projector.wgsl +31 -0
  66. package/src/ltc/provenance.ts +19 -0
  67. package/src/ltc/tables.ts +2831 -0
  68. package/src/material/artifact-types.ts +137 -0
  69. package/src/material/physical/anisotropy.wgsl +26 -0
  70. package/src/material/physical/clearcoat.wgsl +24 -0
  71. package/src/material/physical/iridescence.wgsl +23 -0
  72. package/src/material/physical/sheen.wgsl +17 -0
  73. package/src/material-schemas.ts +77 -30
  74. package/src/register-default-sprite-lit.ts +3 -6
  75. package/src/register-default-standard-pbr-skin.ts +2 -2
  76. package/src/sprite-lit.wgsl +3 -3
  77. package/src/sprite.wgsl +1 -1
  78. package/src/standard-cluster.wgsl +80 -78
  79. package/src/surface_v1.wgsl +26 -0
  80. package/src/types.ts +2 -1
  81. package/src/volume/volume-inject.wgsl +34 -36
  82. package/src/volume/volume-integrate.wgsl +48 -38
  83. package/dist/.tsbuildinfo +0 -1
@@ -15,9 +15,31 @@ describe('Standard reflection probe sampling contract', () => {
15
15
  });
16
16
 
17
17
  it('uses box projection and explicit Skylight fallback in the Standard shader', () => {
18
- expect(standardSource).toMatch(/reflection_probe/);
19
- expect(standardSource).toMatch(/box_project/);
18
+ expect(samplingSource).toMatch(/box_project/);
19
+ expect(standardSource).toMatch(/sampleReflectionProbeSpecular/);
20
+ expect(standardSource).toContain('skylight.intensity < 0.0');
20
21
  expect(standardSource).toMatch(/sampleIblSpecular/);
21
22
  expect(standardSource).toMatch(/skylight/);
22
23
  });
24
+
25
+ it('projects only the environment specular lobe for c=1 without removing diffuse light', () => {
26
+ expect(standardSource).toContain(
27
+ 'var reflectionFallback = specularIbl * (vec3<f32>(1.0) - coatF);',
28
+ );
29
+ expect(standardSource).toContain('kD * irradiance * diffuseAlbedo');
30
+ expect(standardSource).toContain(
31
+ 'output.reflectionFallback = vec4<f32>(reflectionFallback, 1.0);',
32
+ );
33
+ expect(standardSource).not.toContain('output.reflectionFallback = vec4<f32>(ambient, 1.0);');
34
+ });
35
+
36
+ it('keeps probe clearcoat on the selected probe source', () => {
37
+ expect(standardSource).toMatch(
38
+ /if \(skylight\.intensity < 0\.0\) \{[\s\S]*?clearcoatIbl = sampleReflectionProbeSpecular\(/,
39
+ );
40
+ });
41
+
42
+ it('keeps zero-intensity probes distinguishable from an absent Skylight', () => {
43
+ expect(standardSource).toContain('max(-skylight.intensity - 1.0, 0.0) * ao');
44
+ });
23
45
  });
@@ -14,16 +14,15 @@ function device(): ShaderRegistryDevice {
14
14
  };
15
15
  }
16
16
 
17
- function variant(definesKey: string, transmission: boolean) {
17
+ function variant(definesKey: string, cluster: boolean) {
18
18
  return {
19
19
  definesKey,
20
20
  defines: {
21
- CLUSTER_FORWARD_AVAILABLE: false,
21
+ CLUSTER_FORWARD_AVAILABLE: cluster,
22
22
  STORAGE_BUFFER_AVAILABLE: true,
23
- TRANSMISSION_AVAILABLE: transmission,
24
23
  VERTEX_COLOR_AVAILABLE: false,
25
24
  },
26
- composedWgsl: `standard-${transmission}`,
25
+ composedWgsl: `standard-${cluster}`,
27
26
  };
28
27
  }
29
28
 
@@ -47,19 +46,18 @@ async function load(materialShaders: readonly unknown[]) {
47
46
 
48
47
  describe('Standard transmission manifest validation', () => {
49
48
  const falseKey =
50
- 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+TRANSMISSION_AVAILABLE=false+VERTEX_COLOR_AVAILABLE=false';
49
+ 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
51
50
  const trueKey =
52
- 'CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true+TRANSMISSION_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
51
+ 'CLUSTER_FORWARD_AVAILABLE=true+STORAGE_BUFFER_AVAILABLE=true+VERTEX_COLOR_AVAILABLE=false';
53
52
 
54
- it('accepts one Standard row with both exact transmission states', async () => {
53
+ it('accepts one Standard row with the static capability/geometry states', async () => {
55
54
  const result = await load([standardRow([variant(falseKey, false), variant(trueKey, true)])]);
56
55
  expect(result.ok).toBe(true);
57
56
  });
58
57
 
59
- it('rejects a Standard row when either transmission state is missing', async () => {
58
+ it('does not require a runtime transmission axis for a Standard row', async () => {
60
59
  const result = await load([standardRow([variant(falseKey, false)])]);
61
- expect(result.ok).toBe(false);
62
- if (!result.ok) expect(result.error.code).toBe('manifest-malformed');
60
+ expect(result.ok).toBe(true);
63
61
  });
64
62
 
65
63
  it('rejects duplicate material rows and duplicate variant keys', async () => {
@@ -22,6 +22,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
22
22
  import type { ShaderError } from '../errors.js';
23
23
  import { err as errResult, ok as okResult } from '../errors.js';
24
24
  import { TONEMAP_LUMINANCE_EPSILON } from '../index.js';
25
+ import { STANDARD_PIPELINE_PARAM_SCHEMA } from '../material-schemas.js';
25
26
  import {
26
27
  registerDefaultStandardPbrSkin,
27
28
  type SkinCaps,
@@ -412,9 +413,45 @@ import {
412
413
  it('pads ibl-prefilter PrefilterUniforms to a 16-byte block', () => {
413
414
  const src = readSource('ibl-prefilter.wgsl');
414
415
  expect(src).toMatch(
415
- /struct\s+PrefilterUniforms\s*\{[\s\S]*?roughness:\s*f32,[\s\S]*?faceSize:\s*f32,[\s\S]*?_pad0:\s*f32,[\s\S]*?_pad1:\s*f32,/,
416
+ /struct\s+PrefilterUniforms\s*\{[\s\S]*?roughness:\s*f32,[\s\S]*?faceSize:\s*f32,[\s\S]*?sourceMipLevelCount:\s*f32,[\s\S]*?_pad1:\s*f32,/,
416
417
  );
417
418
  });
419
+
420
+ it('bounds prefilter mip selection to the allocated cube levels', () => {
421
+ const src = readSource('ibl-prefilter.wgsl');
422
+ expect(src).toContain('PREFILTER_MIP_LEVEL_COUNT');
423
+ expect(src).toMatch(/sourceMipLevelCount:\s*f32,/);
424
+ expect(src).toMatch(
425
+ /clamp\(\s*requestedMipLevel,\s*0\.0,\s*max\(\s*prefUniforms\.sourceMipLevelCount\s*-\s*1\.0,\s*0\.0\s*\),\s*\)/s,
426
+ );
427
+ expect(src).toMatch(/max\(4\.0\s*\*\s*HdotV,\s*0\.0001\)/);
428
+ });
429
+
430
+ it('evaluates the real Hammersley domain without a zero GGX denominator', () => {
431
+ const radicalInverseVdC = (bits: number) => {
432
+ let value = bits >>> 0;
433
+ value = ((value << 16) | (value >>> 16)) >>> 0;
434
+ value = (((value & 0x55555555) << 1) | ((value & 0xaaaaaaaa) >>> 1)) >>> 0;
435
+ value = (((value & 0x33333333) << 2) | ((value & 0xcccccccc) >>> 2)) >>> 0;
436
+ value = (((value & 0x0f0f0f0f) << 4) | ((value & 0xf0f0f0f0) >>> 4)) >>> 0;
437
+ value = (((value & 0x00ff00ff) << 8) | ((value & 0xff00ff00) >>> 8)) >>> 0;
438
+ return value * 2.3283064365386963e-10;
439
+ };
440
+ const sampleCount = 1024;
441
+ const roughness = 0;
442
+ let maximumXiY = 0;
443
+ let minimumDenominator = Number.POSITIVE_INFINITY;
444
+ for (let index = 0; index < sampleCount; index += 1) {
445
+ const xiY = radicalInverseVdC(index);
446
+ const denominator = 1 + (roughness ** 4 - 1) * xiY;
447
+ maximumXiY = Math.max(maximumXiY, xiY);
448
+ minimumDenominator = Math.min(minimumDenominator, denominator);
449
+ expect(Number.isFinite(denominator)).toBe(true);
450
+ }
451
+ expect(maximumXiY).toBeLessThan(1);
452
+ expect(minimumDenominator).toBeGreaterThan(0);
453
+ expect(minimumDenominator).toBeCloseTo(1 / sampleCount, 12);
454
+ });
418
455
  });
419
456
 
420
457
  describe('t41 (a) -- ibl-sampling exports sampleIblDiffuse + sampleIblSpecular', () => {
@@ -812,7 +849,7 @@ import {
812
849
  expect(src).toMatch(/#if\s+STORAGE_BUFFER_AVAILABLE\s*==\s*true[\s\S]*@group\(3\)/);
813
850
  });
814
851
 
815
- it('(b) default-standard-pbr.wgsl declares storage, cluster, and vertex-color variant axes', () => {
852
+ it('(b) default-standard-pbr.wgsl declares its complete capability variant-axis contract', () => {
816
853
  // feat-20260609-hdrp-cluster-fragment-ggx M1: added CLUSTER_FORWARD_AVAILABLE as a
817
854
  // second variant axis so the standard-pbr fragment can switch between URP-style
818
855
  // (single dirlight + ambient) and HDRP-style (cluster forward 256 punctual lights).
@@ -822,9 +859,12 @@ import {
822
859
  '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
823
860
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
824
861
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
862
+ '#pragma variant_axis PROBE_BLEND_AVAILABLE',
863
+ '#pragma variant_axis EXTENDED_LIGHTING_AVAILABLE',
825
864
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
826
865
  '#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
827
866
  '#pragma variant_axis PROJECTOR_AVAILABLE',
867
+ '#pragma variant_axis REFLECTION_FALLBACK_AVAILABLE',
828
868
  ]);
829
869
  });
830
870
 
@@ -948,42 +988,6 @@ import {
948
988
  // - R-10 paramSchema reuse assertion
949
989
  // - plan-strategy section 5.3 critical test point register
950
990
 
951
- // Inline summary of the paramSchema fields for verification — mirrors
952
- // default-standard-pbr.material.json paramSchema[] field-for-field.
953
- // feat-20260613 fix-issue-1 (D-8): channelMap split into 4 f32 selectors;
954
- // emissive / emissiveIntensity / occlusionStrength are schema-owned values;
955
- // normalScale and the engine-injection textures remain in the same contract.
956
- const EXPECTED_PARAM_NAMES = [
957
- 'baseColor',
958
- 'metallic',
959
- 'roughness',
960
- 'metallicChannel',
961
- 'roughnessChannel',
962
- 'aoChannel',
963
- 'extraChannel',
964
- 'emissive',
965
- 'emissiveIntensity',
966
- 'occlusionStrength',
967
- 'alphaCutoff',
968
- 'clearcoat',
969
- 'clearcoatRoughness',
970
- 'specularTint',
971
- 'normalScale',
972
- 'transmission',
973
- 'ior',
974
- 'thickness',
975
- 'attenuationColor',
976
- 'attenuationDistance',
977
- 'baseColorTexture',
978
- 'metallicRoughnessTexture',
979
- 'normalTexture',
980
- 'specularTintTexture',
981
- 'emissiveTexture',
982
- 'occlusionTexture',
983
- 'transmissionTexture',
984
- 'thicknessTexture',
985
- ] as const;
986
-
987
991
  const STUB_WGSL = '// stub composed pbr-skin wgsl\n';
988
992
  const STORAGE_CAPS: SkinCaps = { storageBuffer: true };
989
993
 
@@ -1040,56 +1044,23 @@ import {
1040
1044
  const registry = makeMockRegistry();
1041
1045
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1042
1046
  const entry = lookup(registry);
1043
- expect(entry.paramSchema).toHaveLength(28);
1047
+ expect(entry.paramSchema).toHaveLength(STANDARD_PIPELINE_PARAM_SCHEMA.length);
1044
1048
  });
1045
1049
 
1046
1050
  it('paramSchema names match default-standard-pbr schema field-for-field', () => {
1047
1051
  const registry = makeMockRegistry();
1048
1052
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1049
1053
  const entry = lookup(registry);
1050
- const names = entry.paramSchema.map((p) => p.name);
1051
- expect(names).toEqual(EXPECTED_PARAM_NAMES);
1054
+ expect(entry.paramSchema).toEqual(STANDARD_PIPELINE_PARAM_SCHEMA);
1052
1055
  });
1053
1056
 
1054
1057
  it('paramSchema types match expected value-level types', () => {
1055
1058
  const registry = makeMockRegistry();
1056
1059
  registerDefaultStandardPbrSkin(registry, STUB_WGSL, STORAGE_CAPS);
1057
1060
  const entry = lookup(registry);
1058
- const types = entry.paramSchema.map((p) => p.type);
1059
- expect(types).toEqual([
1060
- 'color',
1061
- 'f32',
1062
- 'f32',
1063
- // 4 channel selectors (D-8 split).
1064
- 'f32',
1065
- 'f32',
1066
- 'f32',
1067
- 'f32',
1068
- // emissive vec3 + emissiveIntensity + occlusionStrength.
1069
- 'vec3',
1070
- 'f32',
1071
- 'f32',
1072
- // alpha cutoff + clearcoat controls.
1073
- 'f32',
1074
- 'f32',
1075
- 'f32',
1076
- // specular tint, transmission, and attenuation controls.
1077
- 'vec3',
1078
- 'f32',
1079
- 'f32',
1080
- 'f32',
1081
- 'f32',
1082
- 'vec3',
1083
- 'f32',
1084
- 'texture2d',
1085
- 'texture2d',
1086
- 'texture2d',
1087
- 'texture2d',
1088
- 'texture2d',
1089
- 'texture2d',
1090
- 'texture2d',
1091
- 'texture2d',
1092
- ]);
1061
+ expect(entry.paramSchema.map((p) => p.type)).toEqual(
1062
+ STANDARD_PIPELINE_PARAM_SCHEMA.map((p) => p.type),
1063
+ );
1093
1064
  });
1094
1065
  });
1095
1066
 
@@ -1760,11 +1731,10 @@ import {
1760
1731
  expect(src).toMatch(/applyTBN/);
1761
1732
  });
1762
1733
 
1763
- it('default-standard-pbr.wgsl imports directional lighting and the shared Standard Cluster owner', () => {
1734
+ it('default-standard-pbr.wgsl imports directional lighting and the shared cluster owner', () => {
1764
1735
  const src = readSource('default-standard-pbr.wgsl');
1765
1736
  expect(src).toMatch(/#import\s+forgeax_pbr::lighting_directional::/);
1766
1737
  expect(src).toMatch(/#import\s+forgeax_standard::cluster::/);
1767
- expect(src).toMatch(/evaluateStandardClusterLights/);
1768
1738
  });
1769
1739
 
1770
1740
  it('default-standard-pbr.wgsl no longer defines fn evalDirectional / evalPoint / evalSpot inline', () => {
@@ -1775,7 +1745,7 @@ import {
1775
1745
  expect(codeOnly).not.toMatch(/fn\s+evalPunctualBody\s*\(/);
1776
1746
  });
1777
1747
 
1778
- it('built-in PBR shaders evaluate directional CSM once and reuse it for clearcoat', () => {
1748
+ it('built-in PBR shaders evaluate directional CSM for base and clearcoat contributions', () => {
1779
1749
  for (const file of ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl']) {
1780
1750
  const codeOnly = stripComments(readSource(file));
1781
1751
  expect(codeOnly.match(/evalDirectionalShadowFactor\s*\(/g)).toHaveLength(1);
@@ -1863,10 +1833,9 @@ import {
1863
1833
  // ground-truth table (research F1 section 6 - "extended Reinhard
1864
1834
  // matches Reinhard 2002 luminance variant"). The TS port shadowed
1865
1835
  // below is the formula manifest.
1866
- // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint + mode
1867
- // + ditherEnabled = 16 B). The shader-side struct in tonemap.wgsl
1868
- // keeps the same byte layout; the final output pass toggles only the
1869
- // ditherEnabled slot in a per-pass copy.
1836
+ // 2. TonemapParams std140 layout is 16 B (exposure + whitePoint
1837
+ // + 2 padding f32 = 16 B). The shader-side struct in tonemap.wgsl
1838
+ // keeps the same byte layout.
1870
1839
  // 3. TONEMAP_LUMINANCE_EPSILON is the shared TS / WGSL const
1871
1840
  // (D-O3, 1e-5).
1872
1841
  //
@@ -1960,10 +1929,10 @@ import {
1960
1929
  });
1961
1930
 
1962
1931
  describe('TonemapParams std140 layout', () => {
1963
- it('TonemapParams stride is 16 B (exposure + whitePoint + mode + ditherEnabled)', () => {
1932
+ it('TonemapParams stride is 16 B (4 f32 = exposure + whitePoint + 2 padding)', () => {
1964
1933
  // The host writes `Float32Array` of length 4 to the UBO; the shader
1965
1934
  // declares `struct TonemapParams { exposure: f32, whitePoint: f32,
1966
- // mode: u32, ditherEnabled: f32 }` — total = 16 B.
1935
+ // _pad0: f32, _pad1: f32 }` — total = 16 B.
1967
1936
  const stride = 4 * 4;
1968
1937
  expect(stride).toBe(16);
1969
1938
  });
@@ -0,0 +1,58 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ async function readShader(name: string): Promise<string> {
5
+ return readFile(new URL(`../${name}`, import.meta.url), 'utf8');
6
+ }
7
+
8
+ function extractSpotModifierFactors(source: string): string {
9
+ const start = source.indexOf('fn spotModifierFactors(');
10
+ const end = source.indexOf('\nfn evalSpotWithModifiers', start);
11
+ return source.slice(start, end).replace(/\s+/g, '');
12
+ }
13
+
14
+ describe('Cookie RGBA Spot modifier composition', () => {
15
+ it('rejects scalar Cookie sampling and applies linear RGB times alpha', async () => {
16
+ const [standard, skin, modifiers, clustered, common] = await Promise.all([
17
+ readShader('default-standard-pbr.wgsl'),
18
+ readShader('default-standard-pbr-skin.wgsl'),
19
+ readShader('lighting-spot-modifiers.wgsl'),
20
+ readShader('standard-cluster.wgsl'),
21
+ readShader('common.wgsl'),
22
+ ]);
23
+ expect(standard).not.toContain('lighting_spot_modifiers::{spotModifierFactors}');
24
+ expect(skin).not.toContain('lighting_spot_modifiers::{spotModifierFactors}');
25
+ expect(clustered).toContain('lighting_spot_modifiers::{spotModifierFactors}');
26
+ expect(standard).not.toContain('fn spotModifierFactors(');
27
+ expect(skin).not.toContain('fn spotModifierFactors(');
28
+ expect(modifiers).toContain(
29
+ 'textureSampleLevel(cookieTexture, spotModifierSampler, cookieUv, metadata.w, 0.0)',
30
+ );
31
+ expect(modifiers).toContain('cookieSample.rgb * cookieSample.a');
32
+ expect(modifiers).not.toMatch(/textureSampleLevel\(cookieTexture[^\n]*\)\.r/);
33
+ expect(modifiers).toContain('cookieMatrices[metadata.w]');
34
+ expect(modifiers).toContain('metadata.w != 0xffffffffu');
35
+ expect(modifiers).toContain('metadata.y == 0xffffffffu || (metadata.y & PROJECTOR_FLAG) == 0u');
36
+ expect(modifiers).toContain('let right = normalize(cross(forward, referenceUp));');
37
+ expect(modifiers).toContain('let depth = dot(outgoing, forward);');
38
+ expect(modifiers).toContain(
39
+ 'iesProfileTexture, spotModifierSampler, iesUv, metadata.z, 0.0).r',
40
+ );
41
+ expect(common).toContain('@group(0) @binding(15) var<uniform> cookieMatrices');
42
+ });
43
+
44
+ it('keeps skin and non-skin sampling and missing-resource identity aligned', async () => {
45
+ const [standard, skin, modifiers, clustered] = await Promise.all([
46
+ readShader('default-standard-pbr.wgsl'),
47
+ readShader('default-standard-pbr-skin.wgsl'),
48
+ readShader('lighting-spot-modifiers.wgsl'),
49
+ readShader('standard-cluster.wgsl'),
50
+ ]);
51
+ expect(extractSpotModifierFactors(standard)).toBe('');
52
+ expect(extractSpotModifierFactors(skin)).toBe('');
53
+ expect(modifiers).toContain('cookie : f32');
54
+ expect(modifiers).toContain('brdf * range * cone * ies * cookie * shadow');
55
+ expect(clustered).toContain('spotModifierFactors');
56
+ expect(clustered).toContain(') * modifier;');
57
+ });
58
+ });
@@ -0,0 +1,19 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('shared Spot modifier shader composition', () => {
5
+ it('names one shared evaluator with the frozen multiplication order', async () => {
6
+ const source = await readFile(
7
+ new URL('../lighting-spot-modifiers.wgsl', import.meta.url),
8
+ 'utf8',
9
+ );
10
+ expect(source).toContain('spotModifierProduct');
11
+ expect(source).toMatch(/brdf[\s\S]*range[\s\S]*cone[\s\S]*ies[\s\S]*cookie[\s\S]*shadow/);
12
+ });
13
+
14
+ it('keeps modifier sampling behind the extended-lighting topology', async () => {
15
+ const punctual = await readFile(new URL('../lighting-punctual.wgsl', import.meta.url), 'utf8');
16
+ expect(punctual).toContain('extendedLighting');
17
+ expect(punctual).not.toContain('modifierLight');
18
+ });
19
+ });
@@ -111,15 +111,18 @@ describe('w6 (b) -- pbr / unlit / sprite-adjacent shaders DO NOT pick up PER_INS
111
111
  });
112
112
  }
113
113
 
114
- it('default-standard-pbr.wgsl keeps its storage, cluster, and vertex-color axes (none touch PER_INSTANCE_REGION)', () => {
114
+ it('default-standard-pbr.wgsl keeps its material axes (none touch PER_INSTANCE_REGION)', () => {
115
115
  const axes = variantAxes(readWgsl('default-standard-pbr.wgsl'));
116
116
  expect(axes).toEqual([
117
117
  '#pragma variant_axis STORAGE_BUFFER_AVAILABLE',
118
118
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
119
119
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
120
+ '#pragma variant_axis PROBE_BLEND_AVAILABLE',
121
+ '#pragma variant_axis EXTENDED_LIGHTING_AVAILABLE',
120
122
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
121
123
  '#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
122
124
  '#pragma variant_axis PROJECTOR_AVAILABLE',
125
+ '#pragma variant_axis REFLECTION_FALLBACK_AVAILABLE',
123
126
  ]);
124
127
  });
125
128
 
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const shaderFiles = ['default-standard-pbr.wgsl', 'default-standard-pbr-skin.wgsl'] as const;
6
+
7
+ describe('Standard WebGL2 inter-stage varying budget', () => {
8
+ it.each(shaderFiles)('%s packs Surface position and view depth into the last varying', (file) => {
9
+ const source = readFileSync(resolve(import.meta.dirname, '..', file), 'utf8');
10
+
11
+ expect(source).toContain('@location(7) positionOSAndViewZ : vec4<f32>');
12
+ expect(source).not.toMatch(/@location\(15\)\s+positionOS\s*:/u);
13
+ expect(source).toContain('out.positionOSAndViewZ = vec4<f32>(in.pos, sceneViewZ(');
14
+ expect(source).toContain('in.positionOSAndViewZ.xyz');
15
+ expect(source).toContain('in.positionOSAndViewZ.w');
16
+ });
17
+ });
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const surfaceSource = readFileSync(
6
+ fileURLToPath(new URL('../surface_v1.wgsl', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+
10
+ describe('Surface v1 negative contract', () => {
11
+ it.each([
12
+ ['missing export', 'struct SurfaceInput {}'],
13
+ [
14
+ 'wrong signature',
15
+ 'fn evaluate_surface(input: SurfaceInput) -> vec4<f32> { return vec4<f32>(); }',
16
+ ],
17
+ ])('rejects %s', (_name, source) => {
18
+ expect(source).not.toMatch(
19
+ /fn\s+evaluate_surface\s*\(input\s*:\s*SurfaceInput\s*\)\s*->\s*SurfaceData\s*\{/,
20
+ );
21
+ });
22
+
23
+ it.each([
24
+ ['fragment entry', '@fragment fn fs_main() -> @location(0) vec4<f32> { return vec4<f32>(); }'],
25
+ ['resource binding', '@group(1) @binding(0) var surfaceTexture: texture_2d<f32>;'],
26
+ [
27
+ 'vertex mutation',
28
+ 'fn evaluate_surface(input: SurfaceInput) -> SurfaceData { input.positionWS = vec3<f32>(); }',
29
+ ],
30
+ ])('rejects %s', (_name, source) => {
31
+ expect(source).toMatch(/@(fragment|group|binding)|positionWS\s*=/);
32
+ });
33
+
34
+ it('keeps physical fields out of the versioned base ABI', () => {
35
+ expect(surfaceSource).not.toMatch(/clearcoat|clearcoatRoughness|anisotropy|sheen|iridescence/);
36
+ });
37
+ });
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const surfaceSource = readFileSync(
6
+ fileURLToPath(new URL('../surface_v1.wgsl', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+ const defaultSource = readFileSync(
10
+ fileURLToPath(new URL('../default_standard_surface.wgsl', import.meta.url)),
11
+ 'utf8',
12
+ );
13
+
14
+ const inputFields = [
15
+ ['positionOS', 'vec3<f32>'],
16
+ ['positionWS', 'vec3<f32>'],
17
+ ['geometricNormalWS', 'vec3<f32>'],
18
+ ['tangentWS', 'vec4<f32>'],
19
+ ['viewDirectionWS', 'vec3<f32>'],
20
+ ['uv0', 'vec2<f32>'],
21
+ ['uv1', 'vec2<f32>'],
22
+ ['vertexColor', 'vec4<f32>'],
23
+ ['frontFacing', 'bool'],
24
+ ] as const;
25
+
26
+ const dataFields = [
27
+ ['baseColor', 'vec3<f32>'],
28
+ ['normalWS', 'vec3<f32>'],
29
+ ['metallic', 'f32'],
30
+ ['roughness', 'f32'],
31
+ ['emissive', 'vec3<f32>'],
32
+ ['occlusion', 'f32'],
33
+ ['opacity', 'f32'],
34
+ ['alphaClipThreshold', 'f32'],
35
+ ] as const;
36
+
37
+ function assertOrderedFields(source: string, fields: readonly (readonly [string, string])[]) {
38
+ let previous = -1;
39
+ for (const [name, type] of fields) {
40
+ const index = new RegExp(`${name}\\s*:\\s*${type.replace(/[<>]/g, '\\$&')}`).exec(
41
+ source,
42
+ )?.index;
43
+ expect(index, `Surface ABI must contain ${name}: ${type}`).toBeGreaterThan(previous);
44
+ previous = index ?? previous;
45
+ }
46
+ }
47
+
48
+ describe('surface_v1 ABI', () => {
49
+ it('publishes one versioned base-only ABI in stable field order', () => {
50
+ expect(surfaceSource).toContain('#define_import_path forgeax_material::surface_v1');
51
+ expect(surfaceSource).toContain('struct SurfaceInput');
52
+ expect(surfaceSource).toContain('struct SurfaceData');
53
+ assertOrderedFields(surfaceSource, inputFields);
54
+ assertOrderedFields(surfaceSource, dataFields);
55
+ expect(surfaceSource).not.toMatch(/clearcoat|anisotropy|sheen|iridescence|specular/);
56
+ });
57
+
58
+ it('keeps authored Surface entry points free of stage and resource ownership', () => {
59
+ const authoredSource = `
60
+ #import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}
61
+ fn evaluate_surface(input : SurfaceInput) -> SurfaceData {
62
+ return SurfaceData(vec3<f32>(1.0), input.geometricNormalWS, 0.0, 0.5, vec3<f32>(0.0), 1.0, 1.0, 0.0);
63
+ }
64
+ `;
65
+ expect(authoredSource).toMatch(
66
+ /fn\s+evaluate_surface\s*\(input\s*:\s*SurfaceInput\s*\)\s*->\s*SurfaceData/,
67
+ );
68
+ expect(authoredSource.match(/fn\s+evaluate_surface\s*\(/g)).toHaveLength(1);
69
+ expect(authoredSource).not.toMatch(/@(fragment|vertex|compute)|@(group|binding)\s*\(/);
70
+ expect(surfaceSource).not.toMatch(/@(fragment|vertex|compute)|@(group|binding)\s*\(/);
71
+ });
72
+
73
+ it('keeps the default evaluator in the same Surface ABI family', () => {
74
+ expect(defaultSource).toContain(
75
+ '#define_import_path forgeax_material::default_standard_surface',
76
+ );
77
+ expect(defaultSource).toMatch(
78
+ /fn\s+evaluate_surface\s*\(input\s*:\s*SurfaceInput\s*\)\s*->\s*SurfaceData/,
79
+ );
80
+ expect(defaultSource).toContain('input.geometricNormalWS');
81
+ expect(defaultSource).not.toMatch(/@(fragment|vertex|compute)|@(group|binding)\s*\(/);
82
+ });
83
+ });
@@ -6,16 +6,12 @@ const shader = readFileSync(resolve(import.meta.dirname, '../default-standard-pb
6
6
 
7
7
  describe('standard transmission thickness scale contract', () => {
8
8
  it('converts local authored thickness through the combined local-to-world basis', () => {
9
- expect(shader).toContain('let localToWorld = entityWorld * instanceLocal;');
10
9
  expect(shader).toContain('@location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>');
11
- expect(shader).toContain('@location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>');
10
+ expect(shader).toContain('@location(13) @interpolate(flat) transmissionBasis1 : vec4<f32>');
11
+ expect(shader).not.toContain('@location(15)');
12
12
  expect(shader).toContain('let localToWorld0 = in.transmissionBasis0.xyz;');
13
13
  expect(shader).toContain('let localToWorld1 = vec3<f32>(');
14
14
  expect(shader).toContain('let localToWorld2 = vec3<f32>(');
15
- expect(shader).toContain('in.ndc.w,');
16
- expect(shader).not.toContain(
17
- 'let localToWorld = meshes[0].worldFromLocal * instances[in.instanceIdx].localFromInstance;',
18
- );
19
15
  expect(shader).toContain('let worldToLocal0 = vec3<f32>(');
20
16
  expect(shader).toContain('let worldRefractedDirection =');
21
17
  expect(shader).toMatch(
@@ -23,7 +23,7 @@ describe('M4 vertex-color shader contract', () => {
23
23
  /#ifdef\s+VERTEX_COLOR_AVAILABLE[\s\S]*?@location\(14\)\s+color\s*:\s*vec4<f32>/,
24
24
  );
25
25
  if (file !== 'unlit.wgsl') {
26
- expect(text).toMatch(/@location\(13\)\s+uv7\s*:\s*vec2<f32>/);
26
+ expect(text).toMatch(/@location\(12\)\s+uv6And7\s*:\s*vec4<f32>/);
27
27
  }
28
28
  });
29
29
 
@@ -38,15 +38,26 @@ describe('M4 vertex-color shader contract', () => {
38
38
 
39
39
  it.each(SOURCES)('%s applies vertex color to linear RGB and all alpha consumers', (file) => {
40
40
  const text = source(file);
41
- expect(text).toMatch(/baseColor\.rgb\s*\*\s*(?:baseSample|texSample)\.rgb\s*\*\s*\w+\.rgb/);
42
- expect(text).toMatch(/baseColor\.a\s*\*\s*(?:baseSample|texSample)\.a\s*\*\s*\w+\.a/);
41
+ if (file === 'unlit.wgsl') {
42
+ expect(text).toMatch(/baseColor\.rgb\s*\*\s*texSample\.rgb\s*\*\s*vertexColor\.rgb/);
43
+ expect(text).toMatch(/baseColor\.a\s*\*\s*texSample\.a\s*\*\s*vertexColor\.a/);
44
+ } else {
45
+ expect(text).toContain('#import forgeax_material::slot::surface::{evaluate_surface}');
46
+ }
43
47
  expect(text).toMatch(/alphaCutoff/);
44
48
  expect(text).toMatch(/fs_temporal/);
45
49
  });
46
50
 
47
- it('keeps the existing inter-stage uv7 location while reserving color at 14', () => {
51
+ it('canonical Standard surface applies vertex color to linear RGB and alpha', () => {
52
+ const text = readFileSync(new URL('../default_standard_surface.wgsl', import.meta.url), 'utf8');
53
+ expect(text).toMatch(/baseColor\.rgb\s*\*\s*baseSample\.rgb\s*\*\s*vertexColor\.rgb/);
54
+ expect(text).toMatch(/baseColor\.a\s*\*\s*baseSample\.a\s*\*\s*vertexColor\.a/);
55
+ });
56
+
57
+ it('packs the high UV pair while reserving color at 14', () => {
48
58
  const text = source('default-standard-pbr.wgsl');
49
- expect(text).toMatch(/@location\(13\)\s+uv7\s*:\s*vec2<f32>/);
59
+ expect(text).toMatch(/@location\(12\)\s+uv6And7\s*:\s*vec4<f32>/);
60
+ expect(text).toContain('in.uv6And7.zw');
50
61
  expect(text).toMatch(/@location\(14\)\s+color\s*:\s*vec4<f32>/);
51
62
  });
52
63
  });