@forgeax/engine-render 0.1.24 → 0.1.25

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 (135) hide show
  1. package/README.md +100 -1
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/surface-projection.d.ts +24 -0
  5. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  6. package/dist/assembly/material-shader-policy.d.ts +20 -0
  7. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  9. package/dist/assembly/webgpu-renderer.d.ts +1 -1
  10. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  12. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  13. package/dist/assets/asset-decoders.d.ts.map +1 -1
  14. package/dist/authoring.mjs +1 -1
  15. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  16. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  17. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  18. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  19. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  20. package/dist/chunk-HKXTW355.mjs.map +1 -0
  21. package/dist/{chunk-YKB5DQV7.mjs → chunk-JSEDTZYS.mjs} +87 -19
  22. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  23. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  24. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  25. package/dist/{chunk-Z3YYH2MB.mjs → chunk-N3RT2EUR.mjs} +94 -41
  26. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  27. package/dist/{chunk-YRUYGA4A.mjs → chunk-TCLDX7SZ.mjs} +559 -138
  28. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  29. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  30. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  31. package/dist/components/instances.d.ts +13 -31
  32. package/dist/components/instances.d.ts.map +1 -1
  33. package/dist/construct-renderer.mjs +865 -142
  34. package/dist/construct-renderer.mjs.map +1 -1
  35. package/dist/gpu-driven/production-raster.d.ts +6 -1
  36. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  37. package/dist/gpu-scene.d.ts +4 -0
  38. package/dist/gpu-scene.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.mjs +27 -9
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/instance-buffer-cache.d.ts +7 -1
  44. package/dist/instance-buffer-cache.d.ts.map +1 -1
  45. package/dist/instances.d.ts +101 -0
  46. package/dist/instances.d.ts.map +1 -0
  47. package/dist/internal.mjs +5 -5
  48. package/dist/materials.d.ts +63 -2
  49. package/dist/materials.d.ts.map +1 -1
  50. package/dist/pipeline-builder.d.ts.map +1 -1
  51. package/dist/pipeline-spec.d.ts.map +1 -1
  52. package/dist/record/frame-snapshot.d.ts +13 -2
  53. package/dist/record/frame-snapshot.d.ts.map +1 -1
  54. package/dist/record/frame.d.ts.map +1 -1
  55. package/dist/record/main-pass-geometry.d.ts +22 -3
  56. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  57. package/dist/record/render-context.d.ts +2 -1
  58. package/dist/record/render-context.d.ts.map +1 -1
  59. package/dist/record/shadow-pass.d.ts +11 -1
  60. package/dist/record/shadow-pass.d.ts.map +1 -1
  61. package/dist/recovery/render-system-candidate.d.ts +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +4 -0
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-system-extract-tail.d.ts +4 -0
  66. package/dist/render-system-extract-tail.d.ts.map +1 -1
  67. package/dist/render-system-extract.d.ts +25 -1
  68. package/dist/render-system-extract.d.ts.map +1 -1
  69. package/dist/render-system.d.ts +4 -1
  70. package/dist/render-system.d.ts.map +1 -1
  71. package/dist/scene/render-scene-types.d.ts +2 -0
  72. package/dist/scene/render-scene-types.d.ts.map +1 -1
  73. package/dist/scene/render-scene.d.ts +12 -1
  74. package/dist/scene/render-scene.d.ts.map +1 -1
  75. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  76. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  77. package/dist/temporal/index.mjs +5 -5
  78. package/package.json +20 -20
  79. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  80. package/src/__tests__/instances-store.unit.test.ts +46 -0
  81. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  82. package/src/__tests__/material-contract-inventory.unit.test.ts +5 -13
  83. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  84. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  85. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  86. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  87. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  88. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +42 -0
  89. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  90. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  91. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  92. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  93. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  94. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  95. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  96. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  97. package/src/assembly/factory.ts +2 -0
  98. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  99. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  100. package/src/assembly/material/surface-projection.ts +90 -0
  101. package/src/assembly/material-shader-policy.ts +88 -10
  102. package/src/assembly/recovery/renderer-recover.ts +1 -1
  103. package/src/assembly/webgpu-ready.ts +3 -16
  104. package/src/assembly/webgpu-renderer.ts +90 -158
  105. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  106. package/src/assets/asset-decoders.ts +27 -4
  107. package/src/components/instances.ts +14 -197
  108. package/src/gpu-driven/production-raster.ts +34 -1
  109. package/src/gpu-scene.ts +84 -29
  110. package/src/index.ts +1 -0
  111. package/src/instance-buffer-cache.ts +14 -1
  112. package/src/instances.ts +288 -0
  113. package/src/materials.ts +107 -12
  114. package/src/pipeline-builder.ts +14 -10
  115. package/src/pipeline-spec.ts +6 -14
  116. package/src/record/frame-snapshot.ts +19 -2
  117. package/src/record/frame.ts +31 -2
  118. package/src/record/main-pass-geometry.ts +498 -59
  119. package/src/record/main-pass-sprite-draws.ts +1 -1
  120. package/src/record/render-context.ts +4 -0
  121. package/src/record/shadow-pass.ts +46 -99
  122. package/src/recovery/render-system-candidate.ts +41 -4
  123. package/src/render-contract.ts +4 -0
  124. package/src/render-system-extract-tail.ts +107 -51
  125. package/src/render-system-extract.ts +53 -26
  126. package/src/render-system.ts +40 -5
  127. package/src/scene/render-scene-types.ts +2 -0
  128. package/src/scene/render-scene.ts +579 -36
  129. package/src/systems/skin-palette-allocator.ts +129 -7
  130. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  131. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  132. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  133. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  134. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  135. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
@@ -0,0 +1,132 @@
1
+ import { AssetGuid, type PackageId } from '@forgeax/engine-pack/guid';
2
+ import type { MaterialAsset } from '@forgeax/engine-types';
3
+ import { deriveStandardLayerPlan } from '@forgeax/engine-types';
4
+
5
+ type SurfacePack = {
6
+ packageId: PackageId;
7
+ build: () =>
8
+ | { readonly ok: true; readonly value: Readonly<Record<string, MaterialAsset>> }
9
+ | Promise<{ readonly ok: true; readonly value: Readonly<Record<string, MaterialAsset>> }>;
10
+ };
11
+
12
+ type ViteImportMeta = ImportMeta & {
13
+ glob<T>(
14
+ patterns: readonly string[],
15
+ options: { readonly eager: true; readonly import: 'default' },
16
+ ): Readonly<Record<string, T>>;
17
+ };
18
+
19
+ const packModules = Object.values(
20
+ (import.meta as ViteImportMeta).glob<SurfacePack>(
21
+ [
22
+ '../../../../templates/game-3d/assets/materials.pack.ts',
23
+ '../../../../apps/preview/assets/surface-standard-evidence.pack.ts',
24
+ ],
25
+ {
26
+ eager: true,
27
+ import: 'default',
28
+ },
29
+ ),
30
+ );
31
+ if (packModules.length !== 2)
32
+ throw new Error('surface-evidence-fixture: required pack module missing');
33
+
34
+ const builtPacks = packModules.map((pack) => {
35
+ const built = pack.build();
36
+ if (built instanceof Promise || !built.ok) {
37
+ throw new Error('surface-evidence-fixture: materials pack did not produce outputs');
38
+ }
39
+ return { packageId: pack.packageId, values: built.value };
40
+ });
41
+ const materials = Object.assign({}, ...builtPacks.map((pack) => pack.values)) as Record<
42
+ string,
43
+ MaterialAsset | undefined
44
+ >;
45
+
46
+ function guidForSourceKey(sourceKey: string): AssetGuid {
47
+ const owner = builtPacks.find((pack) => Object.hasOwn(pack.values, sourceKey));
48
+ if (owner === undefined) {
49
+ throw new Error(`surface-evidence-fixture: source key '${sourceKey}' is not published`);
50
+ }
51
+ return AssetGuid.derive(owner.packageId, sourceKey);
52
+ }
53
+
54
+ const rustedIron = materials['material/rusted-iron'];
55
+
56
+ const defaultBase = materials['material/ground'];
57
+ const defaultPhysical = materials['material/painted'];
58
+ const customPhysical = materials['material/rusted-iron-custom-physical'];
59
+ if (
60
+ rustedIron === undefined ||
61
+ defaultBase === undefined ||
62
+ defaultPhysical === undefined ||
63
+ customPhysical === undefined
64
+ )
65
+ throw new Error('surface-evidence-fixture: required material row missing');
66
+
67
+ export const SURFACE_CLOSURE = 'forgeax_material::surface_v1';
68
+ export const SURFACE_SOURCE_CLOSURE = [
69
+ 'templates/game-3d/assets/materials.pack.ts',
70
+ 'templates/game-3d/assets/shaders/rusted-iron.wgsl',
71
+ ] as const;
72
+ export const SURFACE_SOURCE_PATH = 'templates/game-3d/assets/shaders/rusted-iron.wgsl';
73
+ export const SURFACE_MATERIAL_SOURCE_KEY = 'material/rusted-iron';
74
+ export const EVIDENCE_SOURCE_SHA =
75
+ (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env
76
+ ?.VITE_FORGEAX_EVIDENCE_SOURCE_SHA ?? 'unavailable';
77
+
78
+ export const SURFACE_CASES = [
79
+ {
80
+ id: 'default-base',
81
+ material: defaultBase,
82
+ materialGuid: AssetGuid.format(guidForSourceKey('material/ground')),
83
+ surfaceModule: defaultBase.passes?.[0]?.program.moduleSlots?.surface ?? '',
84
+ physical: false,
85
+ },
86
+ {
87
+ id: 'custom-base',
88
+ material: rustedIron,
89
+ materialGuid: AssetGuid.format(guidForSourceKey('material/rusted-iron')),
90
+ surfaceModule: rustedIron.passes?.[0]?.program.moduleSlots?.surface ?? '',
91
+ physical: false,
92
+ },
93
+ {
94
+ id: 'default-physical',
95
+ material: defaultPhysical,
96
+ materialGuid: '019fb7ce-3200-7000-8000-000000000002',
97
+ surfaceModule: defaultPhysical.passes?.[0]?.program.moduleSlots?.surface ?? '',
98
+ physical: true,
99
+ },
100
+ {
101
+ id: 'custom-physical',
102
+ material: customPhysical,
103
+ materialGuid: '019fb7ce-3f00-7000-8000-000000000001',
104
+ surfaceModule: customPhysical.passes?.[0]?.program.moduleSlots?.surface ?? '',
105
+ physical: true,
106
+ },
107
+ ] as const;
108
+
109
+ export function layerPlanFor(surfaceCase: (typeof SURFACE_CASES)[number]) {
110
+ return deriveStandardLayerPlan(
111
+ surfaceCase.physical ? (surfaceCase.material.parameters ?? []) : [],
112
+ );
113
+ }
114
+
115
+ export function baseColorFor(
116
+ surfaceCase: (typeof SURFACE_CASES)[number],
117
+ ): readonly [number, number, number] {
118
+ const values = surfaceCase.material.values ?? {};
119
+ const value = values.baseColor ?? values.ironColor;
120
+ if (!Array.isArray(value) || value.length < 3) {
121
+ throw new Error(`surface-evidence-fixture: missing base color for ${surfaceCase.id}`);
122
+ }
123
+ return [Number(value[0]), Number(value[1]), Number(value[2])];
124
+ }
125
+
126
+ export function passFor(surfaceCase: (typeof SURFACE_CASES)[number]): 'forward' | 'deferred' {
127
+ return layerPlanFor(surfaceCase).mode === 'physical' ? 'forward' : 'deferred';
128
+ }
129
+
130
+ export function publicationGenerationFor(_surfaceCase: (typeof SURFACE_CASES)[number]): number {
131
+ return 1;
132
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ projectStandardSurfacePasses,
4
+ type StandardSurfaceProjectionOptions,
5
+ } from '../assembly/material/surface-projection';
6
+
7
+ type FullCustomFixture = StandardSurfaceProjectionOptions & {
8
+ readonly surfaceKind: 'full-custom';
9
+ readonly declaredPasses: readonly ('forward' | 'deferred' | 'shadow-caster')[];
10
+ };
11
+
12
+ describe('full-custom Surface provenance', () => {
13
+ it('does not synthesize an undeclared Deferred pass or fallback identity', () => {
14
+ const options: FullCustomFixture = {
15
+ surfaceModule: 'game_3d::full_custom_surface',
16
+ values: { tint: [1, 0.1, 0.05, 1] },
17
+ surfaceKind: 'full-custom',
18
+ declaredPasses: ['forward', 'shadow-caster'],
19
+ };
20
+
21
+ const passes = projectStandardSurfacePasses(options);
22
+
23
+ expect(passes.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
24
+ expect(passes.map((pass) => pass.program.module)).toEqual([
25
+ 'forgeax_material::standard',
26
+ 'forgeax_material::standard',
27
+ ]);
28
+ for (const pass of passes) {
29
+ expect(pass.program.moduleSlots?.surface).toBe(options.surfaceModule);
30
+ expect((pass.renderState?.tags as Record<string, string>).SurfaceKind).toBe('full-custom');
31
+ }
32
+ });
33
+ });
@@ -651,5 +651,5 @@ describe('volumetric fog budget and recovery contract', () => {
651
651
  activeLedger: steadyLedger,
652
652
  });
653
653
  await renderer.dispose();
654
- });
654
+ }, 30_000);
655
655
  });
@@ -65,7 +65,9 @@ export {
65
65
  isSharedMaterialUserRegionCompatible,
66
66
  normalizeMaterialShaderVariantSet,
67
67
  projectMaterialPipeline,
68
+ resolveMaterialShaderBackendArtifactKey,
68
69
  resolveMaterialShaderBindingContract,
70
+ resolveMaterialShaderUvSetCount,
69
71
  resolveMaterialShaderVariantSet,
70
72
  resolveMaterialShaderVertexInputContract,
71
73
  routeMaterialPipeline,
@@ -0,0 +1,18 @@
1
+ import { deriveStandardLayerPlan } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { projectStandardSurfacePasses } from '../surface-projection.js';
4
+
5
+ describe('StandardLayerPlan pass projection', () => {
6
+ it('keeps a physical Standard material on Forward and shadow passes', () => {
7
+ const plan = deriveStandardLayerPlan([
8
+ { name: 'clearcoat', type: 'f32' },
9
+ { name: 'clearcoatRoughness', type: 'f32' },
10
+ ]);
11
+ const passes = projectStandardSurfacePasses({
12
+ surfaceModule: 'game::surface',
13
+ values: {},
14
+ layerPlan: plan,
15
+ });
16
+ expect(passes.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
17
+ });
18
+ });
@@ -0,0 +1,62 @@
1
+ import { deriveStandardLayerPlan } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ projectStandardSurfacePasses,
5
+ type StandardSurfaceProjectionOptions,
6
+ } from '../surface-projection';
7
+
8
+ type SurfaceFixture = StandardSurfaceProjectionOptions & {
9
+ readonly geometryVariant: 'rigid' | 'skinned' | 'instanced';
10
+ readonly lightingLane: 'direct' | 'clustered';
11
+ readonly alphaClip: boolean;
12
+ };
13
+
14
+ const fixture = (geometryVariant: SurfaceFixture['geometryVariant']): SurfaceFixture => ({
15
+ surfaceModule: 'game_3d::rusted_iron_surface',
16
+ values: { ironColor: [0.42, 0.47, 0.5, 1] },
17
+ geometryVariant,
18
+ lightingLane: geometryVariant === 'instanced' ? 'clustered' : 'direct',
19
+ alphaClip: geometryVariant === 'skinned',
20
+ });
21
+
22
+ function tagsFor(pass: ReturnType<typeof projectStandardSurfacePasses>[number]) {
23
+ return pass.renderState?.tags as Record<string, string>;
24
+ }
25
+
26
+ describe('Standard Surface pass projection', () => {
27
+ it.each([
28
+ 'rigid',
29
+ 'skinned',
30
+ 'instanced',
31
+ ] as const)('projects one %s Surface closure into every Standard pass', (geometryVariant) => {
32
+ const options = fixture(geometryVariant);
33
+ const passes = projectStandardSurfacePasses(options);
34
+
35
+ expect(passes.map((pass) => pass.name)).toEqual(['forward', 'deferred', 'shadow-caster']);
36
+ for (const pass of passes) {
37
+ expect(pass.program.moduleSlots?.surface).toBe(options.surfaceModule);
38
+ expect(tagsFor(pass)).toMatchObject({
39
+ SurfaceModule: options.surfaceModule,
40
+ GeometryVariant: geometryVariant,
41
+ LightingLane: options.lightingLane,
42
+ AlphaClip: options.alphaClip ? 'enabled' : 'disabled',
43
+ });
44
+ }
45
+ });
46
+
47
+ it('keeps base Standard on Deferred while physical Standard stays Forward-only', () => {
48
+ const base = projectStandardSurfacePasses({
49
+ ...fixture('rigid'),
50
+ layerPlan: deriveStandardLayerPlan([]),
51
+ });
52
+ const physical = projectStandardSurfacePasses({
53
+ ...fixture('rigid'),
54
+ layerPlan: deriveStandardLayerPlan([
55
+ { name: 'clearcoat', type: 'f32' },
56
+ { name: 'clearcoatRoughness', type: 'f32' },
57
+ ]),
58
+ });
59
+ expect(base.map((pass) => pass.name)).toEqual(['forward', 'deferred', 'shadow-caster']);
60
+ expect(physical.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
61
+ });
62
+ });
@@ -0,0 +1,90 @@
1
+ import { DEFAULT_STANDARD_SURFACE_MODULE } from '@forgeax/engine-shader';
2
+ import type {
3
+ MaterialPass,
4
+ MaterialRenderState,
5
+ MaterialValue,
6
+ StandardLayerPlan,
7
+ } from '@forgeax/engine-types';
8
+
9
+ export const STANDARD_MATERIAL_MODULE = 'forgeax_material::standard';
10
+ export { DEFAULT_STANDARD_SURFACE_MODULE };
11
+
12
+ export type StandardSurfacePassName = 'forward' | 'deferred' | 'shadow-caster';
13
+ export type StandardSurfaceGeometryVariant = 'rigid' | 'skinned' | 'instanced';
14
+ export type StandardSurfaceLightingLane = 'direct' | 'clustered';
15
+ export type StandardSurfaceKind = 'standard' | 'full-custom';
16
+
17
+ export interface StandardSurfaceProjectionOptions {
18
+ readonly surfaceModule: string;
19
+ readonly values: Readonly<Record<string, MaterialValue | null>>;
20
+ readonly renderState?: MaterialRenderState;
21
+ readonly castShadow?: boolean;
22
+ readonly queue?: number;
23
+ readonly geometryVariant?: StandardSurfaceGeometryVariant;
24
+ readonly lightingLane?: StandardSurfaceLightingLane;
25
+ readonly alphaClip?: boolean;
26
+ readonly surfaceKind?: StandardSurfaceKind;
27
+ readonly declaredPasses?: readonly StandardSurfacePassName[];
28
+ readonly layerPlan?: StandardLayerPlan;
29
+ }
30
+
31
+ function pass(
32
+ name: StandardSurfacePassName,
33
+ options: StandardSurfaceProjectionOptions,
34
+ fragmentEntry: string,
35
+ ): MaterialPass {
36
+ const authoredState = options.renderState ?? {};
37
+ const authoredRecord = authoredState as Readonly<Record<string, unknown>>;
38
+ const tags = (authoredRecord.tags ?? {}) as Readonly<Record<string, string>>;
39
+ const lightMode =
40
+ name === 'shadow-caster' ? 'ShadowCaster' : name === 'forward' ? 'Forward' : 'Deferred';
41
+ return {
42
+ name,
43
+ program: {
44
+ module: STANDARD_MATERIAL_MODULE,
45
+ fragmentEntry,
46
+ moduleSlots: { surface: options.surfaceModule },
47
+ },
48
+ renderState: {
49
+ ...authoredState,
50
+ tags:
51
+ options.surfaceModule === DEFAULT_STANDARD_SURFACE_MODULE
52
+ ? { LightMode: lightMode, ...tags }
53
+ : {
54
+ ...tags,
55
+ LightMode: lightMode,
56
+ SurfaceModule: options.surfaceModule,
57
+ SurfaceKind: options.surfaceKind ?? 'standard',
58
+ GeometryVariant: options.geometryVariant ?? 'rigid',
59
+ LightingLane: options.lightingLane ?? 'direct',
60
+ AlphaClip: options.alphaClip === true ? 'enabled' : 'disabled',
61
+ },
62
+ ...(options.queue === undefined ? {} : { queue: options.queue }),
63
+ },
64
+ };
65
+ }
66
+
67
+ /** Project one custom or default Surface into the existing Standard pass family. */
68
+ export function projectStandardSurfacePasses(
69
+ options: StandardSurfaceProjectionOptions,
70
+ ): [MaterialPass, ...MaterialPass[]] {
71
+ const authoredPasses = options.declaredPasses;
72
+ const passNames: StandardSurfacePassName[] =
73
+ options.surfaceKind === 'full-custom' && authoredPasses !== undefined
74
+ ? [...authoredPasses]
75
+ : (['forward'] as StandardSurfacePassName[]);
76
+ if (authoredPasses === undefined || options.surfaceKind !== 'full-custom') {
77
+ const blended = options.renderState?.blend !== undefined;
78
+ if (!blended && options.layerPlan?.mode !== 'physical') passNames.push('deferred');
79
+ if (options.castShadow !== false) passNames.push('shadow-caster');
80
+ }
81
+ const entries = passNames.map((name) =>
82
+ pass(
83
+ name,
84
+ options,
85
+ name === 'forward' ? 'fs_main' : name === 'deferred' ? 'fs_gbuffer' : 'fs_shadow',
86
+ ),
87
+ );
88
+ const passes = entries as [MaterialPass, ...MaterialPass[]];
89
+ return passes;
90
+ }
@@ -12,6 +12,7 @@ import {
12
12
  type MaterialShaderManifestEntry,
13
13
  type ShaderCatalog,
14
14
  type ShaderCatalogDevice,
15
+ STANDARD_PIPELINE_PARAM_SCHEMA,
15
16
  standardPhysicalTextureFields,
16
17
  } from '@forgeax/engine-shader';
17
18
  import type { AssetGuid as AssetGuidType, ParamSchemaEntry } from '@forgeax/engine-types';
@@ -282,11 +283,10 @@ export async function prepareMaterialShaders(
282
283
  const capValue = (rhiDevice.limits as Readonly<Record<string, number>>)
283
284
  .maxStorageBuffersPerShaderStage;
284
285
  const webgl2Downlevel = rhiDevice.caps.backendKind === 'wgpu-webgl2';
285
- let storageBufferCapable = !webgl2Downlevel;
286
- if (typeof capValue === 'number') {
286
+ const storageBufferCapable = rhiDevice.caps.storageBuffer;
287
+ if (storageBufferCapable && typeof capValue === 'number') {
287
288
  const capCheck = assertStorageBufferCap(capValue);
288
289
  if (!capCheck.ok) throw capCheck.error;
289
- storageBufferCapable = !webgl2Downlevel && capCheck.value;
290
290
  }
291
291
  const extendedLightingShaderAvailable = deriveExtendedLightingCapability(rhiDevice).admitted;
292
292
  const projectorAvailable =
@@ -314,9 +314,10 @@ export async function prepareMaterialShaders(
314
314
  // engine-entry compile path, NOT by installMaterialArtifact.
315
315
  if (msEntry.identifier.startsWith('forgeax::engine-')) continue;
316
316
  // buildVariantKey logic: sorted key=value pairs joined with +; all-true = ''.
317
- const variantDefines: Record<string, boolean> = {
318
- STORAGE_BUFFER_AVAILABLE: storageBufferCapable,
319
- };
317
+ const variantDefines: Record<string, boolean> = {};
318
+ if (msEntry.variants.some((variant) => 'STORAGE_BUFFER_AVAILABLE' in variant.defines)) {
319
+ variantDefines.STORAGE_BUFFER_AVAILABLE = storageBufferCapable;
320
+ }
320
321
  // User material manifests may declare WEBGL2_COMPAT alongside the shared
321
322
  // mesh-storage axis. The fallback backend must select that authored
322
323
  // branch at boot; otherwise it asks for the impossible bare
@@ -450,11 +451,12 @@ export async function prepareMaterialShaders(
450
451
  (candidate) => candidate.identifier === shadowCasterIdentifier,
451
452
  );
452
453
  if (manifestEntry !== undefined) {
453
- // shadow_caster has no authored material parameters. M3 / w12-w13
454
- // (D-2 / D-12): derive([]) is the graceful empty-schema path.
454
+ // shadow_caster evaluates the same default Surface as Standard. Keep
455
+ // its generated MaterialParameters contract aligned with the Standard
456
+ // pipeline even when a legacy manifest omitted the material entry.
455
457
  registry.installMaterialArtifact(shadowCasterIdentifier, {
456
458
  source: manifestEntry.composedWgsl,
457
- paramSchema: [],
459
+ paramSchema: STANDARD_PIPELINE_PARAM_SCHEMA,
458
460
  });
459
461
  const shaderGuid = ENGINE_SHADER_GUIDS.get(shadowCasterIdentifier);
460
462
  if (shaderGuid !== undefined) {
@@ -462,7 +464,7 @@ export async function prepareMaterialShaders(
462
464
  kind: 'shader' as const,
463
465
  name: shadowCasterIdentifier,
464
466
  source: manifestEntry.composedWgsl,
465
- paramSchema: [] as readonly import('@forgeax/engine-types').ParamSchemaEntry[],
467
+ paramSchema: STANDARD_PIPELINE_PARAM_SCHEMA,
466
468
  };
467
469
  // feat-20260614 M8 (D-17): catalogue by GUID; no handle minted.
468
470
  assets.catalog(shaderGuid, shaderAsset);
@@ -659,6 +661,82 @@ export function resolveMaterialShaderVertexInputContract(
659
661
  : 'none';
660
662
  }
661
663
 
664
+ /**
665
+ * Resolve the number of UV sets declared by one material artifact.
666
+ *
667
+ * Runtime-published Surface programs do not necessarily have a manifest
668
+ * reflection row, so the authored vertex input is the fallback source of
669
+ * truth. The renderer then feeds this count into the geometry alias
670
+ * projection, which clamps missing mesh UV sets to the last available set.
671
+ */
672
+ export function resolveMaterialShaderUvSetCount(
673
+ source: string | undefined,
674
+ reflectedCount?: number,
675
+ ): number | undefined {
676
+ if (reflectedCount !== undefined) return reflectedCount;
677
+ if (source === undefined) return undefined;
678
+
679
+ const withoutComments = source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/.*$/gmu, '');
680
+ const inputStructs = [
681
+ ...withoutComments.matchAll(/struct\s+([A-Za-z_]\w*)\s*\{([^}]*)\}/gu),
682
+ ].filter((match) => /@location\s*\(/u.test(match[2] ?? ''));
683
+ for (const inputStruct of inputStructs) {
684
+ const name = inputStruct[1];
685
+ const body = inputStruct[2];
686
+ if (name === undefined || body === undefined) continue;
687
+ const firstParameter = new RegExp(
688
+ `@vertex\\s*fn\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${name}\\b`,
689
+ 'u',
690
+ );
691
+ if (!firstParameter.test(withoutComments)) continue;
692
+ let uvSetCount = 0;
693
+ for (const location of body.matchAll(/@location\s*\(\s*(\d+)\s*\)\s+([A-Za-z_]\w*)/gu)) {
694
+ const field = location[2];
695
+ if (field === 'uv') {
696
+ uvSetCount = Math.max(uvSetCount, 1);
697
+ continue;
698
+ }
699
+ const uvIndex = field?.match(/^uv(\d+)_?$/u)?.[1];
700
+ if (uvIndex !== undefined) uvSetCount = Math.max(uvSetCount, Number(uvIndex) + 1);
701
+ }
702
+ return uvSetCount > 0 ? uvSetCount : undefined;
703
+ }
704
+ return undefined;
705
+ }
706
+
707
+ /**
708
+ * Select a published material artifact that matches a downlevel backend.
709
+ *
710
+ * Cooked Surface publications carry immutable backend variants in their
711
+ * metadata. WebGL2 must select the uniform-fallback specialization before
712
+ * pipeline construction so its shader, bind-group layout, and cache key stay
713
+ * capability-consistent.
714
+ */
715
+ export function resolveMaterialShaderBackendArtifactKey(
716
+ materialShaderId: string,
717
+ backendKind: 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null',
718
+ artifact:
719
+ | {
720
+ readonly metadata?: Readonly<Record<string, unknown>>;
721
+ }
722
+ | undefined,
723
+ ): string {
724
+ if (backendKind !== 'wgpu-webgl2') return materialShaderId;
725
+ const variants = artifact?.metadata?.variants;
726
+ if (!Array.isArray(variants)) return materialShaderId;
727
+ const fallback = variants.find((variant): variant is { specializationKey: string } => {
728
+ if (typeof variant !== 'object' || variant === null) return false;
729
+ const candidate = variant as Record<string, unknown>;
730
+ return (
731
+ candidate.backend === 'webgl2' &&
732
+ candidate.capability === 'uniform-fallback' &&
733
+ typeof candidate.specializationKey === 'string' &&
734
+ candidate.specializationKey.length > 0
735
+ );
736
+ });
737
+ return fallback?.specializationKey ?? materialShaderId;
738
+ }
739
+
662
740
  export function allowsUnlitPreparedFallback(
663
741
  depthFormatOverride: GPUTextureFormat | null | undefined,
664
742
  vertexLayout: string | undefined,
@@ -632,7 +632,7 @@ export function createRendererRecovery(deps: RendererRecoveryDependencies): Rend
632
632
  releaseCandidate();
633
633
  return err(new RecoverError('recover-device-unavailable'));
634
634
  }
635
- const graphPreparation = renderSystem.prepareRecoveryGraphCandidate({
635
+ const graphPreparation = await renderSystem.prepareRecoveryGraphCandidate({
636
636
  internals: candidateInternals,
637
637
  pipelineState: candidatePipelineState,
638
638
  });
@@ -54,7 +54,6 @@ import {
54
54
  } from '../ibl/skylight-bind-group';
55
55
  import type { BloomInspection } from '../inspection-types';
56
56
  import type { DeviceScope, RhiErrorListenerRegistry } from '../lifecycle';
57
- import { assertStorageBufferCap } from '../light-buffer-layout';
58
57
  import {
59
58
  buildPbrPipelineLayouts,
60
59
  buildPbrSkinLayouts,
@@ -218,22 +217,10 @@ export async function buildReadyWebGPU(
218
217
  setVolumetricFogShaderSources: (sources: VolumetricFogShaderSources) => void,
219
218
  ): Promise<PipelineState> {
220
219
  const extendedLightingShaderAvailable = deriveExtendedLightingCapability(rhiDevice).admitted;
221
- // bug-20260610: WebGL2 fallback path read storage-buffer capability up
222
- // front so both Step 2 (shader-module compile, where we patch engine
223
- // entries to the STORAGE_BUFFER_AVAILABLE=false variant) and Step 3
224
- // (buildPbrPipelineLayouts, which switches between read-only-storage
225
- // and uniform BGL entries) walk the same axis. Step 0 already ran in
226
- // `prepareMaterialShaders`; this is an idempotent re-read driven by
227
- // `device.limits.maxStorageBuffersPerShaderStage`, which is 0 on the
228
- // wgpu-wasm WebGL2 backend (downlevel_webgl2_defaults).
229
- const capValue = (rhiDevice.limits as Readonly<Record<string, number>>)
230
- .maxStorageBuffersPerShaderStage;
220
+ // Shader layouts and record-stage buffer usage share the RHI capability.
221
+ // Backend names and numeric limits must not independently re-enable storage.
222
+ const storageBufferCapable = rhiDevice.caps.storageBuffer;
231
223
  const webgl2Downlevel = rhiDevice.caps.backendKind === 'wgpu-webgl2';
232
- let storageBufferCapable = !webgl2Downlevel;
233
- if (typeof capValue === 'number') {
234
- const capCheck = assertStorageBufferCap(capValue);
235
- storageBufferCapable = capCheck.ok ? !webgl2Downlevel && capCheck.value : !webgl2Downlevel;
236
- }
237
224
  const directionalPcssAvailable =
238
225
  rhiDevice.caps.backendKind === 'webgpu' || rhiDevice.caps.backendKind === 'wgpu-native';
239
226
  const projectorAvailable =