@forgeax/engine-render 0.1.25 → 0.1.26

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 (86) hide show
  1. package/README.md +18 -0
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/assembly.d.ts +4 -17
  5. package/dist/assembly/material/assembly.d.ts.map +1 -1
  6. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -1
  7. package/dist/assembly/material/surface-projection.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-renderer.d.ts +0 -1
  9. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  10. package/dist/authoring.mjs +1 -1
  11. package/dist/{chunk-JSEDTZYS.mjs → chunk-GF523LHF.mjs} +6 -6
  12. package/dist/chunk-GF523LHF.mjs.map +1 -0
  13. package/dist/{chunk-TCLDX7SZ.mjs → chunk-TMIASV2N.mjs} +403 -457
  14. package/dist/chunk-TMIASV2N.mjs.map +1 -0
  15. package/dist/{chunk-N3RT2EUR.mjs → chunk-YUQXSEHG.mjs} +87 -53
  16. package/dist/chunk-YUQXSEHG.mjs.map +1 -0
  17. package/dist/construct-renderer.mjs +153 -13
  18. package/dist/construct-renderer.mjs.map +1 -1
  19. package/dist/extract/contracts.d.ts +6 -6
  20. package/dist/extract/contracts.d.ts.map +1 -1
  21. package/dist/index.mjs +1 -1
  22. package/dist/internal.mjs +1 -1
  23. package/dist/pipeline-spec-types.d.ts +2 -0
  24. package/dist/pipeline-spec-types.d.ts.map +1 -1
  25. package/dist/pipeline-spec.d.ts.map +1 -1
  26. package/dist/record/frame.d.ts.map +1 -1
  27. package/dist/record/main-pass-geometry.d.ts +2 -2
  28. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  29. package/dist/record/main-pass-material.d.ts +20 -2
  30. package/dist/record/main-pass-material.d.ts.map +1 -1
  31. package/dist/record/main-pass-sprite-draws.d.ts +3 -3
  32. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  33. package/dist/record/main-pass.d.ts.map +1 -1
  34. package/dist/record/material-uniforms.d.ts +4 -0
  35. package/dist/record/material-uniforms.d.ts.map +1 -0
  36. package/dist/record/render-context.d.ts +2 -2
  37. package/dist/record/render-context.d.ts.map +1 -1
  38. package/dist/record/shadow-pass.d.ts +2 -3
  39. package/dist/record/shadow-pass.d.ts.map +1 -1
  40. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  41. package/dist/render-system-extract-tail.d.ts +2 -0
  42. package/dist/render-system-extract-tail.d.ts.map +1 -1
  43. package/dist/render-system-extract.d.ts +21 -10
  44. package/dist/render-system-extract.d.ts.map +1 -1
  45. package/dist/render-system.d.ts.map +1 -1
  46. package/dist/temporal/index.mjs +2 -2
  47. package/package.json +20 -20
  48. package/src/__tests__/fixtures/ai-weapon-spirit/README.md +20 -0
  49. package/src/__tests__/fixtures/ai-weapon-spirit/low-poly-toon.wgsl +178 -0
  50. package/src/__tests__/fixtures/ai-weapon-spirit/material.json +97 -0
  51. package/src/__tests__/fixtures/ai-weapon-spirit/painterly-surface.wgsl +55 -0
  52. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +3 -3
  53. package/src/__tests__/gpu-driven-view.browser.test.ts +11 -3
  54. package/src/__tests__/material-contract-inventory.unit.test.ts +43 -0
  55. package/src/__tests__/{material-static-dynamic-route.unit.test.ts → material-layer-projection.unit.test.ts} +30 -47
  56. package/src/__tests__/material-program-dispatch.unit.test.ts +48 -0
  57. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +2 -2
  58. package/src/__tests__/skinned-shadow-caster.test.ts +14 -14
  59. package/src/__tests__/surface-variant-provenance.integration.test.ts +1 -1
  60. package/src/assembly/factory.ts +0 -3
  61. package/src/assembly/material/assembly.ts +8 -40
  62. package/src/assembly/material/standard-layer-projection.ts +3 -1
  63. package/src/assembly/material/surface-projection.ts +2 -1
  64. package/src/assembly/webgpu-renderer.ts +17 -9
  65. package/src/extract/contracts.ts +6 -6
  66. package/src/materials.ts +3 -3
  67. package/src/pipeline-spec-types.ts +2 -0
  68. package/src/pipeline-spec.ts +7 -3
  69. package/src/record/frame.ts +7 -1
  70. package/src/record/main-pass-geometry.ts +340 -279
  71. package/src/record/main-pass-material.ts +87 -2
  72. package/src/record/main-pass-sprite-draws.ts +65 -168
  73. package/src/record/main-pass.ts +24 -246
  74. package/src/record/material-uniforms.ts +186 -0
  75. package/src/record/render-context.ts +4 -0
  76. package/src/record/shadow-pass.ts +86 -28
  77. package/src/recovery/render-system-candidate.ts +5 -1
  78. package/src/render-system-extract-tail.ts +51 -11
  79. package/src/render-system-extract.ts +73 -63
  80. package/src/render-system.ts +19 -0
  81. package/dist/assembly/material/pipeline-projection.d.ts +0 -30
  82. package/dist/assembly/material/pipeline-projection.d.ts.map +0 -1
  83. package/dist/chunk-JSEDTZYS.mjs.map +0 -1
  84. package/dist/chunk-N3RT2EUR.mjs.map +0 -1
  85. package/dist/chunk-TCLDX7SZ.mjs.map +0 -1
  86. package/src/assembly/material/pipeline-projection.ts +0 -63
@@ -1,3 +1,4 @@
1
+ import { probeVideoHighPerfUpload } from '@forgeax/engine-graphics-extras';
1
2
  import {
2
3
  type BindGroup,
3
4
  type BindGroupEntry,
@@ -24,6 +25,11 @@ import {
24
25
  resolveGeometryInstanceBuffer,
25
26
  resolveGeometryInstancesBindGroup,
26
27
  } from './main-pass-geometry';
28
+ import {
29
+ buildPerSubmeshMaterialBg,
30
+ type PerSubmeshMaterialBgDeps,
31
+ prepareMaterialSkylight,
32
+ } from './main-pass-material';
27
33
  import {
28
34
  getOrCreateFromChain,
29
35
  MESH_PER_ENTITY_STRIDE,
@@ -31,7 +37,7 @@ import {
31
37
  MESH_UBO_FULL_ARRAY_BYTES,
32
38
  } from './mesh-ssbo';
33
39
  import type { _InternalRenderPipelineContext } from './render-context';
34
- import { STANDARD_PBR_UBO_SIZE } from './render-context';
40
+ import { MATERIAL_PER_ENTITY_STRIDE, STANDARD_PBR_UBO_SIZE } from './render-context';
35
41
  import { POINTS_LINES_VIEW_BYTES, pointShadowViewOffset, VIEW_UNIFORM_BYTES } from './view-ubo';
36
42
 
37
43
  export const SHADOW_CASTER_SLOT_STRIDE = 256;
@@ -247,11 +253,10 @@ function ensureTypedShadowViewBg(
247
253
  }
248
254
 
249
255
  interface ShadowDispatch {
250
- readonly materialHandle: number;
256
+ readonly vertexEntry: string | undefined;
257
+ readonly fragmentEntry: string | undefined;
251
258
  readonly materialShaderId: string;
252
259
  readonly renderState: MaterialRenderState | undefined;
253
- readonly materialSlot: number | undefined;
254
- readonly paramSnapshot: DispatchEntry['paramSnapshot'];
255
260
  }
256
261
 
257
262
  type ShadowDispatchMap = ReadonlyMap<number, ReadonlyMap<number, ShadowDispatch>>;
@@ -265,20 +270,14 @@ export function shadowShaderMap(c: _InternalRenderPipelineContext): ShadowDispat
265
270
  byMaterial = new Map<number, ShadowDispatch>();
266
271
  shaders.set(entry.renderableIndex, byMaterial);
267
272
  }
268
- const candidateSlots =
269
- c.materialSlotIndices[entry.renderableIndex] ?? c.materialSlotIndices.flat();
270
- const materialSlot = candidateSlots.find(
271
- (slot) => c.materialSlots[slot]?.materialHandle === entry.materialHandle,
272
- );
273
273
  byMaterial.set(entry.materialHandle, {
274
- materialHandle: entry.materialHandle,
274
+ vertexEntry: entry.vertexEntry,
275
+ fragmentEntry: entry.fragmentEntry,
275
276
  materialShaderId:
276
277
  entry.materialShaderId === 'forgeax::default-standard-pbr'
277
278
  ? SHADOW_CASTER_SHADER_ID
278
279
  : entry.materialShaderId,
279
280
  renderState: entry.renderState,
280
- materialSlot,
281
- paramSnapshot: entry.paramSnapshot,
282
281
  });
283
282
  }
284
283
  }
@@ -379,13 +378,15 @@ export function encodeDirectionalShadowPass(
379
378
  `view-shadow-directional-${cascadeIndex}`,
380
379
  );
381
380
  const materialBg = ensureSpotShadowMaterialBg(c);
382
- if (pipeline === null || viewBg === null || materialBg === null) return;
383
- c.frameState.directionalShadowCacheRecorded = true;
381
+ if (pipeline === null || viewBg === null || materialBg === null) {
382
+ c.frameState.directionalShadowCacheRecorded = false;
383
+ return;
384
+ }
384
385
  pass.setViewport(viewport.x, viewport.y, viewport.w, viewport.h, 0, 1);
385
386
  pass.setPipeline(pipeline);
386
387
  pass.setBindGroup(0, viewBg, [0, 0]);
387
388
  pass.setBindGroup(1, materialBg, [0]);
388
- recordShadowCasterDraws(
389
+ const complete = recordShadowCasterDraws(
389
390
  c,
390
391
  pass,
391
392
  pipeline,
@@ -393,6 +394,8 @@ export function encodeDirectionalShadowPass(
393
394
  buildMatchedRenderableIndices(c.dispatch, { LightMode: ['ShadowCaster'] }),
394
395
  shadowShaderMap(c),
395
396
  );
397
+ c.frameState.directionalShadowCacheRecorded =
398
+ cascadeIndex === 0 ? complete : c.frameState.directionalShadowCacheRecorded && complete;
396
399
  }
397
400
 
398
401
  export function encodePointShadowPass(
@@ -577,7 +580,7 @@ function recordShadowCasterDraws(
577
580
  shadowMeshBindGroup: BindGroup,
578
581
  matchedIndices: Set<number> | null,
579
582
  shadowDispatchByRenderableIdx: ShadowDispatchMap,
580
- ): void {
583
+ ): boolean {
581
584
  const { runtime, pipelineState, validatedOrdered } = c;
582
585
  // M-3 / w12: vertexBuffer/indexBuffer state locals migrate to GpuBuffer
583
586
  // (the wrapper) -- the de-dup compare uses wrapper identity (one wrapper
@@ -591,6 +594,8 @@ function recordShadowCasterDraws(
591
594
  // bound by the setPipeline call above; the loop switches to a custom
592
595
  // ShadowCaster PSO when a material supplies one.
593
596
  let shadowLastPipeline: RenderPipeline = shadowPipeline;
597
+ let materialDeps: PerSubmeshMaterialBgDeps | undefined;
598
+ let complete = true;
594
599
 
595
600
  for (let i = 0; i < validatedOrdered.length; i++) {
596
601
  const entry = validatedOrdered[i];
@@ -601,7 +606,10 @@ function recordShadowCasterDraws(
601
606
 
602
607
  if (entry.source.skin !== undefined) {
603
608
  const skinPipeline = skinnedShadowPipeline(c, entry);
604
- if (skinPipeline === null) continue;
609
+ if (skinPipeline === null) {
610
+ complete = false;
611
+ continue;
612
+ }
605
613
  if (skinPipeline !== shadowLastPipeline) {
606
614
  shadowPass.setPipeline(skinPipeline);
607
615
  shadowLastPipeline = skinPipeline;
@@ -686,21 +694,17 @@ function recordShadowCasterDraws(
686
694
  ?.get(submeshMaterial.materialHandle ?? 0);
687
695
  const entryShadowShaderId = shadowDispatch?.materialShaderId;
688
696
  const entryShadowRenderState = shadowDispatch?.renderState;
689
- // A skinned caster's pipeline was selected above together with its
690
- // two-binding mesh/palette group. Keep that pairing for every submesh;
691
- // resetting to the static shadow pipeline here would bind the skin
692
- // group against `pbr-pl` and Dawn rejects the draw at submit time.
693
- let entryShadowPipeline =
697
+ // Canonical skin casters retain their palette layout. Authored casters
698
+ // select their own program below with the same geometry layout.
699
+ let entryShadowPipeline: RenderPipeline | null =
694
700
  entry.source.skin !== undefined ? shadowLastPipeline : shadowPipeline;
695
701
  if (
696
- entry.source.skin === undefined &&
697
702
  entryShadowShaderId !== undefined &&
698
703
  (entryShadowShaderId !== 'forgeax::default-shadow-caster' ||
699
- entryShadowRenderState !== undefined)
704
+ (entryShadowRenderState !== undefined && entry.source.skin === undefined))
700
705
  ) {
701
- // On a cache miss (async build in flight / build failure), fall back
702
- // to the default PSO so the caster still writes depth rather than
703
- // dropping its draw.
706
+ // Pending or failed authored programs must not draw using a different
707
+ // caster. Retry preparation on the next frame with the same program.
704
708
  entryShadowPipeline =
705
709
  runtime.getMaterialShaderPipeline?.(
706
710
  entryShadowShaderId,
@@ -710,12 +714,65 @@ function recordShadowCasterDraws(
710
714
  undefined, // indexFormat
711
715
  undefined, // variantSet — shadow caster has no variant axes
712
716
  'shadow-caster', // passKind
713
- ) ?? shadowPipeline;
717
+ undefined, // meshAttributes
718
+ 1,
719
+ undefined, // colorFormatOverride
720
+ undefined, // shaderUvSetCount
721
+ undefined, // depthFormatOverride
722
+ undefined, // vertexLayout
723
+ entry.mesh.layoutProjection,
724
+ undefined, // shaderModuleMode
725
+ entry.source.skin === undefined ? 'pbr' : 'pbr-skin',
726
+ undefined, // additionalColorFormats
727
+ shadowDispatch?.vertexEntry,
728
+ shadowDispatch?.fragmentEntry,
729
+ ) ?? null;
730
+ }
731
+ if (entryShadowPipeline === null) {
732
+ complete = false;
733
+ continue;
714
734
  }
715
735
  if (entryShadowPipeline !== shadowLastPipeline && entryShadowPipeline !== null) {
716
736
  shadowPass.setPipeline(entryShadowPipeline);
717
737
  shadowLastPipeline = entryShadowPipeline;
718
738
  }
739
+ if (entryShadowShaderId !== undefined && entryShadowShaderId !== SHADOW_CASTER_SHADER_ID) {
740
+ materialDeps ??= {
741
+ runtime,
742
+ pipelineState,
743
+ world: c.world,
744
+ store: c.store,
745
+ materialSlice: STANDARD_PBR_UBO_SIZE,
746
+ videoHighPerfAvailable: probeVideoHighPerfUpload(runtime.device),
747
+ skylightResources: prepareMaterialSkylight(c).skylightResources,
748
+ resolveRenderTargetTextureSource: runtime.resolveRenderTargetTextureSource,
749
+ materialBgShared: c.frameState.materialBgShared,
750
+ materialBgAssemblyCache: c.materialBgAssemblyCache,
751
+ frameState: c.frameState,
752
+ bindGroupCounts: c.bindGroupCounts,
753
+ };
754
+ const materialSlot =
755
+ c.materialSlotIndices[i]?.[sm.materialSlot] ?? c.materialSlotIndices[i]?.[0];
756
+ if (materialSlot === undefined) {
757
+ throw new RhiError({
758
+ code: 'rhi-descriptor-invalid',
759
+ expected: 'a prepared material slot for each shadow submesh',
760
+ hint: 'repair material slot preparation before recording shadows',
761
+ });
762
+ }
763
+ const materialGroup = buildPerSubmeshMaterialBg(
764
+ materialDeps,
765
+ submeshMaterial,
766
+ entry.source.entityKey,
767
+ entry.world ?? c.world,
768
+ entryShadowShaderId,
769
+ );
770
+ shadowPass.setBindGroup(1, materialGroup, [materialSlot * MATERIAL_PER_ENTITY_STRIDE]);
771
+ } else {
772
+ const materialGroup = ensureSpotShadowMaterialBg(c);
773
+ if (materialGroup === null) continue;
774
+ shadowPass.setBindGroup(1, materialGroup, [0]);
775
+ }
719
776
  for (const instanceDraw of shadowInstanceDraws) {
720
777
  shadowPass.setBindGroup(3, instanceDraw.instanceBindGroup);
721
778
  if (entry.mesh.indexed) {
@@ -726,6 +783,7 @@ function recordShadowCasterDraws(
726
783
  }
727
784
  }
728
785
  }
786
+ return complete;
729
787
  }
730
788
 
731
789
  /**
@@ -639,7 +639,11 @@ export function createRenderSystemRecovery(
639
639
  seed.frame.lights.pointShadow.length > 0 ||
640
640
  seed.frame.lights.spot.some((spot) => spot.castShadow === true) ||
641
641
  (seed.frame.lights.lightViewProj?.length ?? 0) > 0,
642
- splitLdrSprite: computeSplitLdrSprite(candidateValidated, camera.tonemap !== 'none'),
642
+ splitLdrSprite: computeSplitLdrSprite(
643
+ candidateValidated,
644
+ camera.tonemap !== 'none',
645
+ seed.frame.dispatch,
646
+ ),
643
647
  reflectionFallbackAvailable: candidateFrameState.reflectionFallbackDemand === true,
644
648
  });
645
649
  const candidatePostProcessIds = new Set<string>([
@@ -15,6 +15,7 @@ import {
15
15
  import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
16
16
  import { box3, frustum, type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
17
17
  import { AssetGuid } from '@forgeax/engine-pack/guid';
18
+ import type { MaterialCookProgramContext } from '@forgeax/engine-pack/material-cook';
18
19
  import { RhiError } from '@forgeax/engine-rhi';
19
20
  import { GlobalTransform, MorphWeights, Transform } from '@forgeax/engine-scene';
20
21
  import type { MaterialShaderArtifact } from '@forgeax/engine-shader';
@@ -148,6 +149,7 @@ import {
148
149
  materialColorParameterSchema,
149
150
  materialNormalScale,
150
151
  materialParametersToParamSchema,
152
+ materialProgramKeysForMaterial,
151
153
  materialTextureFields,
152
154
  materialTextureRef,
153
155
  pipelineRenderState,
@@ -174,6 +176,7 @@ export function extractFrames(
174
176
  pipelineState?: ExtractPipelineSurface | null,
175
177
  materialSnapshotCachesByWorld?: MaterialSnapshotCachesByWorld,
176
178
  options: {
179
+ readonly materialContext?: MaterialCookProgramContext;
177
180
  readonly cull?: 'normal' | 'none';
178
181
  readonly renderables?: 'full' | 'none';
179
182
  readonly renderableEntitiesByWorld?: readonly (ReadonlySet<number> | undefined)[];
@@ -251,6 +254,9 @@ export function extractFrames(
251
254
  return cache;
252
255
  })(),
253
256
  }),
257
+ ...(options.materialContext === undefined
258
+ ? {}
259
+ : { materialContext: options.materialContext }),
254
260
  cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
255
261
  renderables: options.renderables ?? 'full',
256
262
  ...(options.renderableEntitiesByWorld?.[wi] === undefined
@@ -1559,6 +1565,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1559
1565
  // extraction. Shared handles are immutable inputs for the frame, while
1560
1566
  // dispatch entries still need to be rebuilt for each entity.
1561
1567
  const materialSnapshotCache: MaterialSnapshotCache = new Map();
1568
+ const skinnedMaterialSnapshotCache: MaterialSnapshotCache = new Map();
1562
1569
  // tweak-20260611 M1: MeshRenderer renderable archetype walk routes
1563
1570
  // through one World-owned Query. K-2 sniffing scheme B
1564
1571
  // (`row.get(X) !== undefined` edge sniff) replaces the prior
@@ -1927,7 +1934,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1927
1934
  layerVal,
1928
1935
  materialSnap.paramSnapshot,
1929
1936
  0,
1930
- materialSnap.materialShaderId,
1937
+ materialSnap.materialProgramKeys,
1931
1938
  );
1932
1939
  }
1933
1940
  } else if (handleRaw === 0 || assets === undefined || assets === null) {
@@ -1956,7 +1963,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1956
1963
  layerVal,
1957
1964
  materialSnap.paramSnapshot,
1958
1965
  0,
1959
- materialSnap.materialShaderId,
1966
+ materialSnap.materialProgramKeys,
1960
1967
  );
1961
1968
  }
1962
1969
  } else {
@@ -2020,7 +2027,26 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2020
2027
  }
2021
2028
  const resolved = resolvedResult.value;
2022
2029
  const allPasses = resolved.passes;
2023
- const firstPassShader = runtimeMaterialShaderIdForMaterial(asset, allPasses, assets);
2030
+ const materialContext =
2031
+ context.materialContext === undefined
2032
+ ? undefined
2033
+ : {
2034
+ ...context.materialContext,
2035
+ geometry: hasSkin ? ('skinned' as const) : context.materialContext.geometry,
2036
+ };
2037
+ const materialProgramKeys = materialProgramKeysForMaterial(
2038
+ asset,
2039
+ assets,
2040
+ materialContext,
2041
+ );
2042
+ const authoredFirstPassShader = runtimeMaterialShaderIdForMaterial(
2043
+ allPasses,
2044
+ undefined,
2045
+ );
2046
+ const firstPassShader = runtimeMaterialShaderIdForMaterial(
2047
+ allPasses,
2048
+ materialProgramKeys,
2049
+ );
2024
2050
  const pv = materialValuesToLinearRuntime(
2025
2051
  resolved.values,
2026
2052
  materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
@@ -2049,7 +2075,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2049
2075
 
2050
2076
  const materialParamSchema = materialParametersToParamSchema(
2051
2077
  resolved.parameters ?? [],
2052
- firstPassShader,
2078
+ authoredFirstPassShader,
2053
2079
  );
2054
2080
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
2055
2081
  // bidirectional Skin <-> pbr-skin material fail-fast at extract.
@@ -2338,6 +2364,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2338
2364
  }),
2339
2365
  normalScale: normalScalePv,
2340
2366
  materialShaderId: firstPassShader,
2367
+ materialProgramKeys,
2341
2368
  materialHandle: handleRaw,
2342
2369
  renderState: pipelineRenderState(allPasses[0]?.renderState),
2343
2370
  paramSnapshot: paramSnap,
@@ -2399,7 +2426,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2399
2426
  layerVal,
2400
2427
  paramSnap,
2401
2428
  0,
2402
- firstPassShader,
2429
+ materialProgramKeys,
2403
2430
  );
2404
2431
  }
2405
2432
  }
@@ -2610,15 +2637,22 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2610
2637
  if (assets !== undefined && assets !== null) {
2611
2638
  for (let mi = 1; mi < materialHandles.length; mi++) {
2612
2639
  const subHandle = materialHandles[mi] ?? 0;
2613
- const cachedSubmaterial = materialSnapshotCache.get(subHandle);
2640
+ const subCache = hasSkin ? skinnedMaterialSnapshotCache : materialSnapshotCache;
2641
+ const cachedSubmaterial = subCache.get(subHandle);
2614
2642
  materialsArr.push(
2615
2643
  cachedSubmaterial?.snapshot ??
2616
2644
  resolveMaterialSnapshot(
2617
2645
  subHandle,
2618
2646
  world,
2619
2647
  assets,
2620
- materialSnapshotCache,
2621
- persistentMaterialSnapshotCache,
2648
+ subCache,
2649
+ hasSkin ? undefined : persistentMaterialSnapshotCache,
2650
+ context.materialContext === undefined
2651
+ ? undefined
2652
+ : {
2653
+ ...context.materialContext,
2654
+ geometry: hasSkin ? 'skinned' : context.materialContext.geometry,
2655
+ },
2622
2656
  ),
2623
2657
  );
2624
2658
  }
@@ -2628,8 +2662,14 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2628
2662
  const subHandle = materialHandles[mi] ?? 0;
2629
2663
  if (subHandle === handleRaw) continue;
2630
2664
  const subEntry =
2631
- materialSnapshotCache.get(subHandle) ??
2632
- readPersistentMaterialSnapshot(persistentMaterialSnapshotCache, subHandle, assets);
2665
+ (hasSkin ? skinnedMaterialSnapshotCache : materialSnapshotCache).get(subHandle) ??
2666
+ (hasSkin
2667
+ ? undefined
2668
+ : readPersistentMaterialSnapshot(
2669
+ persistentMaterialSnapshotCache,
2670
+ subHandle,
2671
+ assets,
2672
+ ));
2633
2673
  appendMaterialDispatchEntries(
2634
2674
  pendingDispatch,
2635
2675
  subEntry?.passes ?? (subHandle === 0 ? [DEFAULT_FORWARD_PASS] : []),
@@ -2639,7 +2679,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2639
2679
  layerVal,
2640
2680
  materialsArr[mi]?.paramSnapshot,
2641
2681
  0,
2642
- materialsArr[mi]?.materialShaderId,
2682
+ materialsArr[mi]?.materialProgramKeys,
2643
2683
  );
2644
2684
  }
2645
2685
  }
@@ -1,3 +1,4 @@
1
+ import type { MaterialCookProgramContext } from '@forgeax/engine-pack/material-cook';
1
2
  // @forgeax/engine-runtime - RenderSystem Extract stage (D-S2 + plan-strategy R-15
2
3
  // fallback split). Pure ECS query phase: walks Camera / DirectionalLight /
3
4
  // the merged-MeshRenderer renderable archetype and produces SoA-free snapshot
@@ -81,7 +82,9 @@
81
82
 
82
83
  import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
83
84
  import {
85
+ materialParametersToParamSchema as projectMaterialParametersToParamSchema,
84
86
  resolveAssetHandle,
87
+ selectMaterialPassProgram,
85
88
  walkMaterialPassesOverSharedRefs,
86
89
  } from '@forgeax/engine-assets-runtime';
87
90
  import type { Component, ComponentSchema, EntityHandle, Query, World } from '@forgeax/engine-ecs';
@@ -909,6 +912,7 @@ export interface MaterialSnapshot {
909
912
  * discriminator (M4-T06).
910
913
  */
911
914
  readonly materialShaderId?: string | undefined;
915
+ readonly materialProgramKeys?: Readonly<Record<string, string>> | undefined;
912
916
  /**
913
917
  * Stable source handle for the material asset. Record uses this only for
914
918
  * same-frame assembly reuse; GPU bind-group validity still comes from the
@@ -1015,12 +1019,9 @@ export interface MaterialSnapshot {
1015
1019
  * from the legacy `shadingModel` discriminant (feat-20260625 M2 D-3,
1016
1020
  * finalised in w15; `shadingModel` field removed in tweak-20260701 M1).
1017
1021
  *
1018
- * Extract derives this from the first pass's `renderState.blend !==
1019
- * undefined` (post-feat-20260626-collapse: blend presence is the
1020
- * single SSOT for "this material is transparent on the geometry
1021
- * pipeline cache key"). Multi-pass materials whose mix of opaque +
1022
- * transparent passes need finer routing should split into separate
1023
- * MaterialAsset entries (the normal forgeax pattern).
1022
+ * This describes the primary-pass snapshot for consumers that need a
1023
+ * material summary. Actual draw routing and the LDR split read the selected
1024
+ * DispatchEntry.renderState, so mixed opaque/transparent Passes remain valid.
1024
1025
  *
1025
1026
  * Type is `boolean | undefined` (derived): `undefined` means "no
1026
1027
  * passes / unknown"; consumers must read `=== true` / `!== true` to
@@ -1125,7 +1126,7 @@ export function appendMaterialDispatchEntries(
1125
1126
  layer: number,
1126
1127
  paramSnapshot: Readonly<Record<string, number | number[] | string>> | undefined,
1127
1128
  passIndexOffset = 0,
1128
- materialShaderIdOverride?: string,
1129
+ materialProgramKeys?: Readonly<Record<string, string>>,
1129
1130
  ): void {
1130
1131
  const matchedPasses = selectPasses(passes, {});
1131
1132
  for (let pIdx = 0; pIdx < matchedPasses.length; pIdx++) {
@@ -1138,9 +1139,9 @@ export function appendMaterialDispatchEntries(
1138
1139
  };
1139
1140
  const authoredShaderId = runtimeMaterialShaderId(pass.program.module, pass.name);
1140
1141
  const dispatchShaderId =
1141
- authoredShaderId === 'forgeax::default-shadow-caster'
1142
- ? authoredShaderId
1143
- : (materialShaderIdOverride ?? authoredShaderId);
1142
+ materialProgramKeys === undefined ? authoredShaderId : materialProgramKeys[pass.name];
1143
+ if (materialProgramKeys !== undefined && dispatchShaderId === undefined)
1144
+ throw new Error(`Missing published material program for Pass ${pass.name}`);
1144
1145
  pendingDispatch.push({
1145
1146
  entityIndex: entity,
1146
1147
  materialHandle,
@@ -1155,13 +1156,6 @@ export function appendMaterialDispatchEntries(
1155
1156
  defines: undefined,
1156
1157
  vertexEntry: pass.program.vertexEntry,
1157
1158
  fragmentEntry: pass.program.fragmentEntry,
1158
- // A cooked Standard publication supplies one specialization key for its
1159
- // forward/deferred artifact, but its ShadowCaster pass still owns the
1160
- // engine depth-only shader. Keep the pass-local canonical identity so a
1161
- // specialized Standard source (which only declares fs_main/fs_gbuffer)
1162
- // is never asked to build an fs_shadow pipeline. Authored non-Standard
1163
- // ShadowCaster modules retain the publication override and continue to
1164
- // use their producer-owned source.
1165
1159
  materialShaderId: dispatchShaderId,
1166
1160
  paramSnapshot,
1167
1161
  ...(passState.stencilReference !== undefined && {
@@ -1419,13 +1413,50 @@ export function runtimeMaterialShaderId(
1419
1413
  * owns the composed material artifact identity; the authored pass module is
1420
1414
  * the built-in fallback used before that publication is ready.
1421
1415
  */
1422
- export function runtimeMaterialShaderIdForMaterial(
1416
+ export function materialProgramKeysForMaterial(
1423
1417
  material: MaterialAsset,
1424
- passes: readonly MaterialPass[],
1425
1418
  assets: AssetRegistry,
1419
+ context: MaterialCookProgramContext | undefined,
1420
+ ): Readonly<Record<string, string>> | undefined {
1421
+ const projection = assets.getMaterialProjectionForPayload(material);
1422
+ if (projection === undefined) return undefined;
1423
+ if (context === undefined)
1424
+ throw new Error('Published material selection requires renderer-owned compiler context');
1425
+ return Object.fromEntries(
1426
+ projection.passes.map((pass) => {
1427
+ const mode = String(
1428
+ (pass.renderState?.tags as Record<string, unknown> | undefined)?.LightMode ?? pass.name,
1429
+ );
1430
+ const selectedContext: MaterialCookProgramContext = {
1431
+ ...context,
1432
+ pass: /shadow/i.test(mode) ? 'shadow' : /depth/i.test(mode) ? 'depth' : 'forward',
1433
+ pipeline: /deferred|gbuffer/i.test(mode) ? 'deferred' : context.pipeline,
1434
+ };
1435
+ return [
1436
+ pass.name,
1437
+ selectMaterialPassProgram(projection, pass.name, selectedContext).specializationKey,
1438
+ ];
1439
+ }),
1440
+ );
1441
+ }
1442
+
1443
+ export function runtimeMaterialShaderIdForMaterial(
1444
+ passes: readonly MaterialPass[],
1445
+ programs: Readonly<Record<string, string>> | undefined,
1426
1446
  ): string | undefined {
1427
- const authored = runtimeMaterialShaderId(passes[0]?.program.module, passes[0]?.name);
1428
- return assets.getMaterialProjectionForPayload(material)?.specializationKey ?? authored;
1447
+ const pass =
1448
+ passes.find(
1449
+ (pass) =>
1450
+ !/shadow|depth/i.test(
1451
+ String(
1452
+ (pass.renderState?.tags as Record<string, unknown> | undefined)?.LightMode ?? pass.name,
1453
+ ),
1454
+ ),
1455
+ ) ?? passes[0];
1456
+ if (pass === undefined) return undefined;
1457
+ return programs === undefined
1458
+ ? runtimeMaterialShaderId(pass.program.module, pass.name)
1459
+ : programs[pass.name];
1429
1460
  }
1430
1461
 
1431
1462
  export function pipelineRenderState(
@@ -1732,49 +1763,21 @@ export function isEngineInjectedTextureField(
1732
1763
  return isStandardPbrMaterialShader(shaderId) && ENGINE_INJECTED_TEXTURE_FIELDS.has(fieldName);
1733
1764
  }
1734
1765
 
1766
+ /**
1767
+ * Project one material's authoring parameters into the runtime ABI.
1768
+ *
1769
+ * Engine-owned Standard roots are compiled against the canonical superset
1770
+ * schema even when the authoring asset omits optional physical fields. A
1771
+ * compact authored schema would move later UBO members (for example `ior`)
1772
+ * into the wrong slots. Custom material roots keep their own published
1773
+ * parameter projection.
1774
+ */
1735
1775
  export function materialParametersToParamSchema(
1736
1776
  parameters: readonly MaterialParameter[],
1737
- shaderId: string | undefined,
1777
+ shaderId?: string,
1738
1778
  ): readonly ParamSchemaEntry[] {
1739
- // Engine-owned Standard modules are compiled once against the canonical
1740
- // boot schema. Materials.standard() exposes a smaller authoring projection,
1741
- // but using that compact projection as the runtime UBO schema would shift
1742
- // every field after an omitted physical entry.
1743
- if (isCanonicalStandardPbrMaterialShader(shaderId)) {
1744
- return STANDARD_PIPELINE_PARAM_SCHEMA;
1745
- }
1746
- const engineInjectedTextureFields = isStandardPbrMaterialShader(shaderId)
1747
- ? ENGINE_INJECTED_TEXTURE_FIELDS
1748
- : undefined;
1749
- return parameters.flatMap((parameter): ParamSchemaEntry[] => {
1750
- // Boolean material values are runtime-only and have no UBO representation.
1751
- if (parameter.type === 'bool') return [];
1752
- if (parameter.type === 'texture' || parameter.type === 'texture_cube') {
1753
- if (engineInjectedTextureFields?.has(parameter.name) === true) return [];
1754
- return [
1755
- {
1756
- name: parameter.name,
1757
- type: parameter.type === 'texture_cube' ? 'texture_cube' : 'texture2d',
1758
- default: parameter.default,
1759
- },
1760
- ];
1761
- }
1762
- const numericDefault =
1763
- typeof parameter.default === 'number'
1764
- ? parameter.default
1765
- : Array.isArray(parameter.default) &&
1766
- parameter.default.every((entry) => typeof entry === 'number')
1767
- ? parameter.default
1768
- : undefined;
1769
- const numericEntry = {
1770
- name: parameter.name,
1771
- type: parameter.type,
1772
- ...(parameter.colorSpace === undefined ? {} : { colorSpace: parameter.colorSpace }),
1773
- };
1774
- return numericDefault === undefined
1775
- ? [numericEntry]
1776
- : [{ ...numericEntry, default: numericDefault }];
1777
- });
1779
+ if (isCanonicalStandardPbrMaterialShader(shaderId)) return STANDARD_PIPELINE_PARAM_SCHEMA;
1780
+ return projectMaterialParametersToParamSchema(parameters);
1778
1781
  }
1779
1782
 
1780
1783
  /**
@@ -1921,6 +1924,7 @@ export function resolveMaterialSnapshot(
1921
1924
  assetsRef: AssetRegistry,
1922
1925
  materialSnapshotCache?: MaterialSnapshotCache,
1923
1926
  persistentMaterialSnapshotCache?: MaterialSnapshotCache,
1927
+ materialContext?: MaterialCookProgramContext,
1924
1928
  ): MaterialSnapshot {
1925
1929
  if (handleRaw === 0) return defaultMaterialSnapshot(handleRaw);
1926
1930
  const cached = materialSnapshotCache?.get(handleRaw);
@@ -1952,7 +1956,9 @@ export function resolveMaterialSnapshot(
1952
1956
  if (!resolvedResult.ok) return defaultMaterialSnapshot(handleRaw);
1953
1957
  const resolved = resolvedResult.value;
1954
1958
  const allPasses = resolved.passes;
1955
- const firstPassShader = runtimeMaterialShaderIdForMaterial(asset, allPasses, assetsRef);
1959
+ const materialProgramKeys = materialProgramKeysForMaterial(asset, assetsRef, materialContext);
1960
+ const authoredFirstPassShader = runtimeMaterialShaderIdForMaterial(allPasses, undefined);
1961
+ const firstPassShader = runtimeMaterialShaderIdForMaterial(allPasses, materialProgramKeys);
1956
1962
  const pv = materialValuesToLinearRuntime(
1957
1963
  resolved.values,
1958
1964
  materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assetsRef),
@@ -1979,7 +1985,7 @@ export function resolveMaterialSnapshot(
1979
1985
  }
1980
1986
  const materialParamSchema = materialParametersToParamSchema(
1981
1987
  resolved.parameters ?? [],
1982
- firstPassShader,
1988
+ authoredFirstPassShader,
1983
1989
  );
1984
1990
  // feat-20260614 M8 (D-19): texture / sampler values are embedded GUIDs
1985
1991
  // (dash-form strings) after loadByGuid. Resolve each to a user-tier column
@@ -2047,6 +2053,7 @@ export function resolveMaterialSnapshot(
2047
2053
  }),
2048
2054
  normalScale: normalScalePv,
2049
2055
  materialShaderId: firstPassShader,
2056
+ materialProgramKeys,
2050
2057
  materialHandle: handleRaw,
2051
2058
  renderState: pipelineRenderState(allPasses[0]?.renderState),
2052
2059
  paramSnapshot: paramSnap,
@@ -2548,6 +2555,7 @@ export interface ExtractFramesOwner {
2548
2555
  }
2549
2556
 
2550
2557
  export interface PreparedExtractContext {
2558
+ readonly materialContext?: MaterialCookProgramContext | undefined;
2551
2559
  readonly assets: AssetRegistry | null | undefined;
2552
2560
  readonly pipelineState: ExtractPipelineSurface | null | undefined;
2553
2561
  readonly materialSnapshotCache: MaterialSnapshotCache | undefined;
@@ -2748,6 +2756,7 @@ export function tonemapParams(camera: CameraSnapshot): Uint8Array {
2748
2756
  export function prepareExtractContext(
2749
2757
  world: World,
2750
2758
  options: {
2759
+ readonly materialContext?: MaterialCookProgramContext;
2751
2760
  readonly assets?: AssetRegistry | null;
2752
2761
  readonly pipelineState?: ExtractPipelineSurface | null;
2753
2762
  readonly materialSnapshotCache?: MaterialSnapshotCache;
@@ -2765,6 +2774,7 @@ export function prepareExtractContext(
2765
2774
  ): PreparedExtractContext {
2766
2775
  const renderables = options.renderables ?? 'full';
2767
2776
  return {
2777
+ materialContext: options.materialContext,
2768
2778
  assets: options.assets,
2769
2779
  pipelineState: options.pipelineState,
2770
2780
  materialSnapshotCache: options.materialSnapshotCache,
@@ -2586,6 +2586,25 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
2586
2586
  internals.getPipelineState(),
2587
2587
  persistentRenderScene.materialSnapshotCacheStore(),
2588
2588
  {
2589
+ ...(internals.device.caps.backendKind === 'null'
2590
+ ? {}
2591
+ : {
2592
+ materialContext: {
2593
+ backend:
2594
+ internals.device.caps.backendKind === 'wgpu-webgl2'
2595
+ ? ('webgl2' as const)
2596
+ : internals.device.caps.backendKind,
2597
+ capability: internals.device.caps.storageBuffer
2598
+ ? ('storage-buffer' as const)
2599
+ : ('uniform-fallback' as const),
2600
+ pipeline: 'forward' as const,
2601
+ geometry: 'mesh' as const,
2602
+ pass: 'forward' as const,
2603
+ profile: 'forgeax-material-wgsl-v1' as const,
2604
+ toolchain: 'naga-oil' as const,
2605
+ instrumentation: 'none' as const,
2606
+ },
2607
+ }),
2589
2608
  cull: 'none',
2590
2609
  renderables: request === 'none' ? 'none' : 'full',
2591
2610
  ...(request === 'none' || request === 'full'