@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
@@ -44,7 +44,7 @@ import {
44
44
  import type { PointsLinesRecordPlan } from '../points-lines/record';
45
45
  import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
46
46
  import type { RenderRecordPhase } from '../render-contract';
47
- import type { MaterialSnapshot, RenderableSnapshot } from '../render-system-extract';
47
+ import type { DispatchEntry, MaterialSnapshot, RenderableSnapshot } from '../render-system-extract';
48
48
  import {
49
49
  PROBE_BLEND_RECORD_BYTE_SIZE,
50
50
  PROBE_BLEND_RECORD_STRIDE,
@@ -120,6 +120,8 @@ type MaterialPipelineLookup = {
120
120
  readonly topology: PrimitiveTopology;
121
121
  readonly indexFormat: 'uint16' | 'uint32';
122
122
  readonly variantSet: string | undefined;
123
+ readonly vertexEntry: string | undefined;
124
+ readonly fragmentEntry: string | undefined;
123
125
  readonly passKind: PassKind;
124
126
  readonly layoutProjection: import('@forgeax/engine-geometry').VertexLayoutProjection;
125
127
  readonly sampleCount: number;
@@ -365,8 +367,10 @@ export function recordGeometryDraws(
365
367
  submeshMaterial: MaterialSnapshot,
366
368
  entityKey: number,
367
369
  materialWorld: World,
370
+ materialShaderId?: string,
368
371
  ) => BindGroup,
369
372
  passKind: PassKind = 'forward',
373
+ selectedDispatch?: readonly DispatchEntry[],
370
374
  ): void {
371
375
  const {
372
376
  runtime,
@@ -381,6 +385,17 @@ export function recordGeometryDraws(
381
385
  viewBindGroup,
382
386
  viewBindGroupDynamicOffset = 0,
383
387
  } = c;
388
+ const materialPasses = new Map<number, Map<number, DispatchEntry[]>>();
389
+ for (const dispatch of selectedDispatch ?? []) {
390
+ let byMaterial = materialPasses.get(dispatch.renderableIndex);
391
+ if (byMaterial === undefined) {
392
+ byMaterial = new Map();
393
+ materialPasses.set(dispatch.renderableIndex, byMaterial);
394
+ }
395
+ const passes = byMaterial.get(dispatch.materialHandle);
396
+ if (passes === undefined) byMaterial.set(dispatch.materialHandle, [dispatch]);
397
+ else passes.push(dispatch);
398
+ }
384
399
  const diagnosticsEnabled = materialDiagnosticsEnabled();
385
400
  const colorFormatOverride = c.transparentColorFormat as GPUTextureFormat | undefined;
386
401
  // The prepared Standard topology is the frame authority. A bind-group
@@ -438,6 +453,8 @@ export function recordGeometryDraws(
438
453
  variantSet: string | undefined,
439
454
  colorFormat: GPUTextureFormat | undefined,
440
455
  layoutProjection: import('@forgeax/engine-geometry').VertexLayoutProjection,
456
+ vertexEntry?: string,
457
+ fragmentEntry?: string,
441
458
  ): MaterialShaderPipelineEntry | null => {
442
459
  // The fallback MRT is an extension of the built-in Standard PBR output
443
460
  // contract only. The render pass may carry a second attachment whenever
@@ -463,6 +480,8 @@ export function recordGeometryDraws(
463
480
  cached.topology === topology &&
464
481
  cached.indexFormat === indexFormat &&
465
482
  cached.variantSet === variantSet &&
483
+ cached.vertexEntry === vertexEntry &&
484
+ cached.fragmentEntry === fragmentEntry &&
466
485
  cached.passKind === passKind &&
467
486
  cached.layoutProjection.digest === layoutProjection.digest &&
468
487
  cached.sampleCount === sampleCount &&
@@ -474,10 +493,12 @@ export function recordGeometryDraws(
474
493
  }
475
494
  }
476
495
  const resolvedVariantSetResult =
477
- variantSet === undefined &&
478
- (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
496
+ runtime.getMaterialArtifact?.(materialShaderId) !== undefined
479
497
  ? ({ ok: true, value: undefined } as const)
480
- : variantSetFromVertexLayoutProjection(layoutProjection, variantSet);
498
+ : variantSet === undefined &&
499
+ (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
500
+ ? ({ ok: true, value: undefined } as const)
501
+ : variantSetFromVertexLayoutProjection(layoutProjection, variantSet);
481
502
  if (!resolvedVariantSetResult.ok) {
482
503
  runtime.errorRegistry.fire(resolvedVariantSetResult.error);
483
504
  return null;
@@ -508,6 +529,8 @@ export function recordGeometryDraws(
508
529
  undefined,
509
530
  undefined,
510
531
  reflectionFallbackFormats,
532
+ vertexEntry,
533
+ fragmentEntry,
511
534
  ) ?? null;
512
535
  const handle =
513
536
  profilePhase === undefined
@@ -528,6 +551,8 @@ export function recordGeometryDraws(
528
551
  topology,
529
552
  indexFormat,
530
553
  variantSet,
554
+ vertexEntry,
555
+ fragmentEntry,
531
556
  passKind,
532
557
  layoutProjection,
533
558
  sampleCount,
@@ -591,7 +616,8 @@ export function recordGeometryDraws(
591
616
  // decal submesh) is NOT skipped at the entity level; its opaque submeshes
592
617
  // draw here and the per-submesh loop below skips the transparent ones
593
618
  // (they are drawn in the blend sub-pass instead).
594
- if (splitLdrSprite && isEntityFullyTransparent(entry.source)) continue;
619
+ if (selectedDispatch === undefined && splitLdrSprite && isEntityFullyTransparent(entry.source))
620
+ continue;
595
621
 
596
622
  const pipelineTag: 'unlit' = 'unlit';
597
623
 
@@ -975,295 +1001,315 @@ export function recordGeometryDraws(
975
1001
  const materialHandle = submeshMaterial.materialHandle ?? 0;
976
1002
  if (materialHandles === undefined || !materialHandles.has(materialHandle)) continue;
977
1003
  }
978
- // feat-city-glb Bug 5 (per-submesh transparency): in the LDR split, a
979
- // transparent submesh is drawn in the blend sub-pass (non-sRGB view),
980
- // NOT here in the sRGB geometry pass. Skip it. Opaque submeshes of the
981
- // same (mixed) mesh still draw here. Single-material / fully-opaque
982
- // meshes are unaffected (their submesh materials are not transparent).
983
- if (splitLdrSprite && submeshMaterial.transparent === true) {
984
- continue;
985
- }
986
- // bug-20260610 layer 7d: per-submesh BG construction. Texture
987
- // views resolve from `matsForRebind[smIdx]` so the j-th submesh
988
- // sees its own materials[j] textures (baseColor / MR / normal /
989
- // emissive / occlusion). Pick slot j when materials.length covers
990
- // smIdx; otherwise fall back to slot 0 (count-mismatch already
991
- // filtered by extract; this guard handles the materials.length=1
992
- // single-material path mapped over multi-submesh meshes safely).
993
- // This BG drops entityKey: identical-texture-set submeshes
994
- // (whether on the same entity or different ones) share one BG via
995
- // the shaderId-outer `materialBgShared` cache. The 14 handle
996
- // objects form the WeakMap chain and fully discriminate the
997
- // binding state since sampler/textureView/buffer handle identities
998
- // are stable across frames.
999
- //
1000
- // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13:
1001
- // sprite materials now use the same per-submesh BG construction
1002
- // (the sprite-specific BG branch above is deleted; sprite per-
1003
- // submesh single-slot is still enforced via materialSlotIndices
1004
- // and the sprite-shaped paramSnapshot fills the PBR-shaped BGL
1005
- // bindings via fallback textures for the 4 unused slots).
1006
- // feat-city-glb Bug 5: per-submesh material BG assembly extracted to
1007
- // the shared `buildPerSubmeshMaterialBg` closure (also called by the
1008
- // LDR blend sub-pass). Resolves the shader's user-region textures
1009
- // (baseColor/MR/normal + a custom Nth texture), emissive/occlusion
1010
- // injection, and Skylight merge; deduped cross-entity via the
1011
- // shaderId-outer `materialBgShared` cache.
1012
- const materialSlot = materialSlotIndices[i]?.[matSlotIdx] ?? materialSlotIndices[i]?.[0] ?? 0;
1013
- const processValue = (
1014
- globalThis as {
1015
- process?: { env?: Record<string, string | undefined> };
1004
+ const draws: readonly (DispatchEntry | undefined)[] =
1005
+ selectedDispatch === undefined
1006
+ ? [undefined]
1007
+ : (materialPasses.get(entry.renderableIndex)?.get(submeshMaterial.materialHandle ?? 0) ??
1008
+ []);
1009
+ for (const selectedPass of draws) {
1010
+ const selectedShaderId =
1011
+ selectedPass === undefined
1012
+ ? submeshMaterial.materialShaderId
1013
+ : selectedPass.materialShaderId;
1014
+ const selectedRenderState =
1015
+ selectedPass === undefined ? submeshMaterial.renderState : selectedPass.renderState;
1016
+ const selectedStencil =
1017
+ (selectedPass === undefined ? entry.stencilReference : selectedPass.stencilReference) ??
1018
+ 0;
1019
+ if (selectedStencil !== lastStencilReference) {
1020
+ pass.setStencilReference(selectedStencil);
1021
+ lastStencilReference = selectedStencil;
1016
1022
  }
1017
- ).process;
1018
- if (
1019
- processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
1020
- (submeshMaterial.materialShaderId === 'forgeax::default-standard-pbr' ||
1021
- submeshMaterial.materialShaderId === 'forgeax::pbr-skin')
1022
- ) {
1023
- console.error(
1024
- `[render-material] draw receipt: ${JSON.stringify({
1025
- entityIndex: entry.renderableIndex,
1026
- materialSlot,
1027
- materialHandle: submeshMaterial.materialHandle,
1028
- clearcoat: submeshMaterial.paramSnapshot?.clearcoat,
1029
- shader: submeshMaterial.materialShaderId,
1030
- })}`,
1031
- );
1032
- }
1033
- perSubmeshBg =
1034
- profilePhase === undefined
1035
- ? resolveMaterialBindGroup(
1023
+ // feat-city-glb Bug 5 (per-submesh transparency): in the LDR split, a
1024
+ // transparent submesh is drawn in the blend sub-pass (non-sRGB view),
1025
+ // NOT here in the sRGB geometry pass. Skip it. Opaque submeshes of the
1026
+ // same (mixed) mesh still draw here. Single-material / fully-opaque
1027
+ // meshes are unaffected (their submesh materials are not transparent).
1028
+ if (
1029
+ splitLdrSprite &&
1030
+ (selectedPass === undefined
1031
+ ? submeshMaterial.transparent === true
1032
+ : selectedRenderState?.blend !== undefined)
1033
+ ) {
1034
+ continue;
1035
+ }
1036
+ // bug-20260610 layer 7d: per-submesh BG construction. Texture
1037
+ // views resolve from `matsForRebind[smIdx]` so the j-th submesh
1038
+ // sees its own materials[j] textures (baseColor / MR / normal /
1039
+ // emissive / occlusion). Pick slot j when materials.length covers
1040
+ // smIdx; otherwise fall back to slot 0 (count-mismatch already
1041
+ // filtered by extract; this guard handles the materials.length=1
1042
+ // single-material path mapped over multi-submesh meshes safely).
1043
+ // This BG drops entityKey: identical-texture-set submeshes
1044
+ // (whether on the same entity or different ones) share one BG via
1045
+ // the shaderId-outer `materialBgShared` cache. The 14 handle
1046
+ // objects form the WeakMap chain and fully discriminate the
1047
+ // binding state since sampler/textureView/buffer handle identities
1048
+ // are stable across frames.
1049
+ //
1050
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13:
1051
+ // sprite materials now use the same per-submesh BG construction
1052
+ // (the sprite-specific BG branch above is deleted; sprite per-
1053
+ // submesh single-slot is still enforced via materialSlotIndices
1054
+ // and the sprite-shaped paramSnapshot fills the PBR-shaped BGL
1055
+ // bindings via fallback textures for the 4 unused slots).
1056
+ // feat-city-glb Bug 5: per-submesh material BG assembly extracted to
1057
+ // the shared `buildPerSubmeshMaterialBg` closure (also called by the
1058
+ // LDR blend sub-pass). Resolves the shader's user-region textures
1059
+ // (baseColor/MR/normal + a custom Nth texture), emissive/occlusion
1060
+ // injection, and Skylight merge; deduped cross-entity via the
1061
+ // shaderId-outer `materialBgShared` cache.
1062
+ const materialSlot =
1063
+ materialSlotIndices[i]?.[matSlotIdx] ?? materialSlotIndices[i]?.[0] ?? 0;
1064
+ const processValue = (
1065
+ globalThis as {
1066
+ process?: { env?: Record<string, string | undefined> };
1067
+ }
1068
+ ).process;
1069
+ if (
1070
+ processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
1071
+ (submeshMaterial.materialShaderId === 'forgeax::default-standard-pbr' ||
1072
+ submeshMaterial.materialShaderId === 'forgeax::pbr-skin')
1073
+ ) {
1074
+ console.error(
1075
+ `[render-material] draw receipt: ${JSON.stringify({
1076
+ entityIndex: entry.renderableIndex,
1036
1077
  materialSlot,
1037
- submeshMaterial,
1038
- entry.source.entityKey,
1039
- entry.world ?? c.world,
1040
- )
1041
- : profileGeometrySegment(c, passKind, 'material-bind-groups', () =>
1042
- resolveMaterialBindGroup(
1078
+ materialHandle: submeshMaterial.materialHandle,
1079
+ clearcoat: submeshMaterial.paramSnapshot?.clearcoat,
1080
+ shader: submeshMaterial.materialShaderId,
1081
+ })}`,
1082
+ );
1083
+ }
1084
+ perSubmeshBg =
1085
+ profilePhase === undefined
1086
+ ? resolveMaterialBindGroup(
1043
1087
  materialSlot,
1044
1088
  submeshMaterial,
1045
1089
  entry.source.entityKey,
1046
1090
  entry.world ?? c.world,
1047
- ),
1091
+ selectedShaderId,
1092
+ )
1093
+ : profileGeometrySegment(c, passKind, 'material-bind-groups', () =>
1094
+ resolveMaterialBindGroup(
1095
+ materialSlot,
1096
+ submeshMaterial,
1097
+ entry.source.entityKey,
1098
+ entry.world ?? c.world,
1099
+ selectedShaderId,
1100
+ ),
1101
+ );
1102
+ materialGroup1DynamicOffsets[0] = materialSlot * MATERIAL_PER_ENTITY_STRIDE;
1103
+ pass.setBindGroup(1, perSubmeshBg, materialGroup1DynamicOffsets, 0, 1);
1104
+ const smTopology = sm.topology;
1105
+ const smMaterialShaderId =
1106
+ selectedPass !== undefined
1107
+ ? selectedShaderId
1108
+ : entry.source.skin !== undefined
1109
+ ? isStandardPbrSkinMaterialShader(submeshMaterial.materialShaderId)
1110
+ ? submeshMaterial.materialShaderId
1111
+ : SKIN_MATERIAL_SHADER_ID
1112
+ : submeshMaterial.materialShaderId;
1113
+ const probeBlendAvailable =
1114
+ probeBlendRecordAvailable && isStandardPbrMaterialShader(smMaterialShaderId);
1115
+ const isSpriteShader =
1116
+ smMaterialShaderId === 'forgeax::sprite' || smMaterialShaderId === 'forgeax::sprite-lit';
1117
+ // Preserve the dedicated sprite-pass state when the linear-LDR graph
1118
+ // routes a sprite through this generic geometry path. Negative scale
1119
+ // flips winding, so back-face culling would erase the quad.
1120
+ const basePipelineRenderState = isSpriteShader
1121
+ ? {
1122
+ ...selectedRenderState,
1123
+ depthWriteEnabled: false,
1124
+ depthCompare: 'less-equal' as const,
1125
+ cullMode: 'none' as const,
1126
+ blend: selectedRenderState?.blend ?? SPRITE_PREMULTIPLIED_ALPHA_BLEND,
1127
+ }
1128
+ : geometryRenderStateForTopology(smTopology, selectedRenderState);
1129
+ const pipelineRenderState = geometryRenderStateForPass(basePipelineRenderState, passKind);
1130
+ let smPipelineHandle: typeof pipelineState.unlitPipeline;
1131
+ let materialGroup2Contract: 'mesh' | 'skin' | 'cluster' | 'skin-cluster' | undefined;
1132
+ let materialPipelineEntry: MaterialShaderPipelineEntry | null = null;
1133
+ if (smMaterialShaderId === undefined || smMaterialShaderId === 'forgeax::default-unlit') {
1134
+ const unlitShaderId = smMaterialShaderId ?? 'forgeax::default-unlit';
1135
+ const unlitProjectionVariantResult = variantSetFromVertexLayoutProjection(
1136
+ entry.mesh.layoutProjection,
1137
+ undefined,
1138
+ );
1139
+ if (!unlitProjectionVariantResult.ok) {
1140
+ runtime.errorRegistry.fire(unlitProjectionVariantResult.error);
1141
+ continue;
1142
+ }
1143
+ const unlitHasColor =
1144
+ unlitProjectionVariantResult.value === 'VERTEX_COLOR_AVAILABLE=true';
1145
+ const unlitVariantSet = standardStorageVariantSet(
1146
+ runtime.device.caps.storageBuffer,
1147
+ unlitHasColor,
1148
+ );
1149
+ const unlitRsp = resolveMaterialPipeline(
1150
+ unlitShaderId,
1151
+ pipelineRenderState,
1152
+ smTopology,
1153
+ entry.mesh.indexFormat,
1154
+ unlitVariantSet,
1155
+ colorFormatOverride,
1156
+ entry.mesh.layoutProjection,
1157
+ selectedPass?.vertexEntry,
1158
+ selectedPass?.fragmentEntry,
1159
+ );
1160
+ materialPipelineEntry = unlitRsp;
1161
+ smPipelineHandle =
1162
+ unlitRsp?.pipeline ??
1163
+ (colorFormatOverride === undefined
1164
+ ? selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive)
1165
+ : null);
1166
+ } else if (smMaterialShaderId !== undefined) {
1167
+ // feat-20260609 M4.5 / w38 (D-11): the variantSet handed to
1168
+ // getMaterialShaderPipeline MUST mirror the boot-time
1169
+ // `definesKey` rule at createRenderer.ts:2483-2485 (sortedEntries
1170
+ // .every(v=>v===true) ? '' : 'A=v+...'). manifest variant.definesKey
1171
+ // is `''` for the all-true variant, so HDRP (both axes true) must
1172
+ // pass the canonical empty key to hit that variant via
1173
+ // findVariantByKey. Passing the expanded form would produce a
1174
+ // miss and silently fall back to the registered default WGSL,
1175
+ // creating a layout/binding mismatch under HDRP.
1176
+ //
1177
+ // URP path passes the explicit expanded form because the URP
1178
+ // variant's manifest definesKey IS that exact non-empty string
1179
+ // (CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true)
1180
+ // -- the canonical-empty rule only applies to the all-true case.
1181
+ const hasVertexColor = entry.mesh.layoutProjection.attributes.some(
1182
+ (attribute) => attribute.key === 'color',
1183
+ );
1184
+ // The all-true HDRP variant uses the canonical empty key only when
1185
+ // every variant axis is true. A plain mesh adds the geometry-owned
1186
+ // VERTEX_COLOR_AVAILABLE=false axis, so retain the two capability
1187
+ // axes explicitly or the selector would silently choose the URP
1188
+ // group(2) layout for an HDRP cluster bind group.
1189
+ const capabilityVariantSet = standardTopologyVariantSet(
1190
+ c.standardLighting,
1191
+ runtime.device.caps.storageBuffer,
1192
+ hasVertexColor,
1193
+ probeBlendAvailable,
1194
+ );
1195
+ const transmissionAvailable = requestsStandardTransmissionVariant(submeshMaterial);
1196
+ const materialCapabilityVariantSet =
1197
+ transmissionAvailable && !capabilityVariantSet.includes('TRANSMISSION_AVAILABLE=')
1198
+ ? `${capabilityVariantSet === '' ? '' : `${capabilityVariantSet}+`}TRANSMISSION_AVAILABLE=true`
1199
+ : capabilityVariantSet;
1200
+ // Sprite's base request must retain the boot-selected artifact. Only
1201
+ // SpriteInstances uses the explicit empty key for the
1202
+ // PER_INSTANCE_REGION=true variant; synthesizing the generic PBR
1203
+ // capability axes for a base sprite would invent a lazy module label
1204
+ // that has no boot seed and skip the first HDR draw.
1205
+ const variantSet = isSpriteShader
1206
+ ? smMaterialShaderId === 'forgeax::sprite' && entry.source.spriteInstances !== undefined
1207
+ ? ''
1208
+ : undefined
1209
+ : entry.variantSet === undefined
1210
+ ? materialCapabilityVariantSet
1211
+ : capabilityVariantSet === ''
1212
+ ? entry.variantSet
1213
+ : `${capabilityVariantSet}+${entry.variantSet}`;
1214
+ const cachedPipeline = resolveMaterialPipeline(
1215
+ smMaterialShaderId,
1216
+ pipelineRenderState,
1217
+ smTopology,
1218
+ entry.mesh.indexFormat,
1219
+ variantSet,
1220
+ colorFormatOverride,
1221
+ entry.mesh.layoutProjection,
1222
+ selectedPass?.vertexEntry,
1223
+ selectedPass?.fragmentEntry,
1224
+ );
1225
+ // feat-20260615-pipeline-spec-ssot M6-T1: cache miss resolves to
1226
+ // null uniformly across URP / HDRP / skin shaders. Charter P3
1227
+ // explicit failure: the pre-M6 URP-path silent fallback to the
1228
+ // boot-time `pipelineState.standardPipeline*` (M4.5-followup w43)
1229
+ // masked real PipelineSpecError build failures behind a
1230
+ // layout-compatible-but-wrong PSO. The per-submesh
1231
+ // `if (smPipelineHandle === null) continue` skip-draw (which
1232
+ // already covered HDRP-active and skin miss paths) is now the
1233
+ // single uniform recovery shape -- one frame of skip-draw on
1234
+ // first-touch, then the cached PSO flows in once the async
1235
+ // build resolves. The pre-loop skin-PSO probe still skips the
1236
+ // entire entry on first-submesh probe miss; this site only
1237
+ // fires on per-submesh topology variance miss.
1238
+ materialPipelineEntry = cachedPipeline;
1239
+ smPipelineHandle = cachedPipeline?.pipeline ?? null;
1240
+ } else {
1241
+ smPipelineHandle = selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive);
1242
+ }
1243
+
1244
+ if (smPipelineHandle === null) {
1245
+ if (diagnosticsEnabled) {
1246
+ console.error(
1247
+ `[render-material] draw skipped: ${JSON.stringify({
1248
+ entityKey: entry.source.entityKey,
1249
+ materialHandle: submeshMaterial.materialHandle,
1250
+ shader: smMaterialShaderId,
1251
+ reason: 'pipeline-unavailable',
1252
+ })}`,
1048
1253
  );
1049
- materialGroup1DynamicOffsets[0] = materialSlot * MATERIAL_PER_ENTITY_STRIDE;
1050
- pass.setBindGroup(1, perSubmeshBg, materialGroup1DynamicOffsets, 0, 1);
1051
- const smTopology = sm.topology;
1052
- const smMaterialShaderId =
1053
- entry.source.skin !== undefined
1054
- ? isStandardPbrSkinMaterialShader(submeshMaterial.materialShaderId)
1055
- ? submeshMaterial.materialShaderId
1056
- : SKIN_MATERIAL_SHADER_ID
1057
- : submeshMaterial.materialShaderId;
1058
- const probeBlendAvailable =
1059
- probeBlendRecordAvailable && isStandardPbrMaterialShader(smMaterialShaderId);
1060
- const isSpriteShader =
1061
- smMaterialShaderId === 'forgeax::sprite' || smMaterialShaderId === 'forgeax::sprite-lit';
1062
- // Preserve the dedicated sprite-pass state when the linear-LDR graph
1063
- // routes a sprite through this generic geometry path. Negative scale
1064
- // flips winding, so back-face culling would erase the quad.
1065
- const basePipelineRenderState = isSpriteShader
1066
- ? {
1067
- ...submeshMaterial.renderState,
1068
- depthWriteEnabled: false,
1069
- depthCompare: 'less-equal' as const,
1070
- cullMode: 'none' as const,
1071
- blend: submeshMaterial.renderState?.blend ?? SPRITE_PREMULTIPLIED_ALPHA_BLEND,
1072
1254
  }
1073
- : geometryRenderStateForTopology(smTopology, submeshMaterial.renderState);
1074
- const pipelineRenderState = geometryRenderStateForPass(basePipelineRenderState, passKind);
1075
- let smPipelineHandle: typeof pipelineState.unlitPipeline;
1076
- let materialGroup2Contract: 'mesh' | 'skin' | 'cluster' | 'skin-cluster' | undefined;
1077
- let materialPipelineEntry: MaterialShaderPipelineEntry | null = null;
1078
- if (smMaterialShaderId === undefined || smMaterialShaderId === 'forgeax::default-unlit') {
1079
- const unlitShaderId = smMaterialShaderId ?? 'forgeax::default-unlit';
1080
- const unlitProjectionVariantResult = variantSetFromVertexLayoutProjection(
1081
- entry.mesh.layoutProjection,
1082
- undefined,
1083
- );
1084
- if (!unlitProjectionVariantResult.ok) {
1085
- runtime.errorRegistry.fire(unlitProjectionVariantResult.error);
1086
1255
  continue;
1087
1256
  }
1088
- const unlitHasColor = unlitProjectionVariantResult.value === 'VERTEX_COLOR_AVAILABLE=true';
1089
- const unlitVariantSet = standardStorageVariantSet(
1090
- runtime.device.caps.storageBuffer,
1091
- unlitHasColor,
1092
- );
1093
- const unlitRsp = resolveMaterialPipeline(
1094
- unlitShaderId,
1095
- pipelineRenderState,
1096
- smTopology,
1097
- entry.mesh.indexFormat,
1098
- unlitVariantSet,
1099
- colorFormatOverride,
1100
- entry.mesh.layoutProjection,
1101
- );
1102
- materialPipelineEntry = unlitRsp;
1103
- smPipelineHandle =
1104
- unlitRsp?.pipeline ??
1105
- (colorFormatOverride === undefined
1106
- ? selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive)
1107
- : null);
1108
- } else if (smMaterialShaderId !== undefined) {
1109
- // feat-20260609 M4.5 / w38 (D-11): the variantSet handed to
1110
- // getMaterialShaderPipeline MUST mirror the boot-time
1111
- // `definesKey` rule at createRenderer.ts:2483-2485 (sortedEntries
1112
- // .every(v=>v===true) ? '' : 'A=v+...'). manifest variant.definesKey
1113
- // is `''` for the all-true variant, so HDRP (both axes true) must
1114
- // pass the canonical empty key to hit that variant via
1115
- // findVariantByKey. Passing the expanded form would produce a
1116
- // miss and silently fall back to the registered default WGSL,
1117
- // creating a layout/binding mismatch under HDRP.
1118
- //
1119
- // URP path passes the explicit expanded form because the URP
1120
- // variant's manifest definesKey IS that exact non-empty string
1121
- // (CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true)
1122
- // -- the canonical-empty rule only applies to the all-true case.
1123
- const hasVertexColor = entry.mesh.layoutProjection.attributes.some(
1124
- (attribute) => attribute.key === 'color',
1125
- );
1126
- // The all-true HDRP variant uses the canonical empty key only when
1127
- // every variant axis is true. A plain mesh adds the geometry-owned
1128
- // VERTEX_COLOR_AVAILABLE=false axis, so retain the two capability
1129
- // axes explicitly or the selector would silently choose the URP
1130
- // group(2) layout for an HDRP cluster bind group.
1131
- const capabilityVariantSet = standardTopologyVariantSet(
1132
- c.standardLighting,
1133
- runtime.device.caps.storageBuffer,
1134
- hasVertexColor,
1257
+ // The probe record is an ABI extension of the built-in Standard PBR
1258
+ // shaders only. RenderScene may retain a record on every projected
1259
+ // renderable, but unlit/custom shaders still use the one-binding
1260
+ // instances BGL and must never receive the probe bind group. Resolve
1261
+ // this per submesh so mixed-material meshes cannot cross the layouts.
1262
+ const instanceDraws = resolveGeometryInstanceBuffer(
1263
+ c,
1264
+ entry,
1265
+ identityInstanceDraws,
1135
1266
  probeBlendAvailable,
1136
1267
  );
1137
- const transmissionAvailable = requestsStandardTransmissionVariant(submeshMaterial);
1138
- const materialCapabilityVariantSet =
1139
- transmissionAvailable && !capabilityVariantSet.includes('TRANSMISSION_AVAILABLE=')
1140
- ? `${capabilityVariantSet === '' ? '' : `${capabilityVariantSet}+`}TRANSMISSION_AVAILABLE=true`
1141
- : capabilityVariantSet;
1142
- // Sprite's base request must retain the boot-selected artifact. Only
1143
- // SpriteInstances uses the explicit empty key for the
1144
- // PER_INSTANCE_REGION=true variant; synthesizing the generic PBR
1145
- // capability axes for a base sprite would invent a lazy module label
1146
- // that has no boot seed and skip the first HDR draw.
1147
- const variantSet = isSpriteShader
1148
- ? smMaterialShaderId === 'forgeax::sprite' && entry.source.spriteInstances !== undefined
1149
- ? ''
1150
- : undefined
1151
- : entry.variantSet === undefined
1152
- ? materialCapabilityVariantSet
1153
- : capabilityVariantSet === ''
1154
- ? entry.variantSet
1155
- : `${capabilityVariantSet}+${entry.variantSet}`;
1156
- const cachedPipeline = resolveMaterialPipeline(
1157
- smMaterialShaderId,
1158
- pipelineRenderState,
1159
- smTopology,
1160
- entry.mesh.indexFormat,
1161
- variantSet,
1162
- colorFormatOverride,
1163
- entry.mesh.layoutProjection,
1164
- );
1165
- // feat-20260615-pipeline-spec-ssot M6-T1: cache miss resolves to
1166
- // null uniformly across URP / HDRP / skin shaders. Charter P3
1167
- // explicit failure: the pre-M6 URP-path silent fallback to the
1168
- // boot-time `pipelineState.standardPipeline*` (M4.5-followup w43)
1169
- // masked real PipelineSpecError build failures behind a
1170
- // layout-compatible-but-wrong PSO. The per-submesh
1171
- // `if (smPipelineHandle === null) continue` skip-draw (which
1172
- // already covered HDRP-active and skin miss paths) is now the
1173
- // single uniform recovery shape -- one frame of skip-draw on
1174
- // first-touch, then the cached PSO flows in once the async
1175
- // build resolves. The pre-loop skin-PSO probe still skips the
1176
- // entire entry on first-submesh probe miss; this site only
1177
- // fires on per-submesh topology variance miss.
1178
- materialPipelineEntry = cachedPipeline;
1179
- smPipelineHandle = cachedPipeline?.pipeline ?? null;
1180
- } else {
1181
- smPipelineHandle = selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive);
1182
- }
1183
-
1184
- if (smPipelineHandle === null) {
1185
- if (diagnosticsEnabled) {
1268
+ if (instanceDraws === null) continue;
1269
+ // Standard clustered PBR variants declare the unified cluster/SSAO
1270
+ // group(2) layout. Unlit and other URP-layout materials must bind the
1271
+ // ordinary mesh group instead; choosing one group for the whole pass
1272
+ // makes Dawn reject an otherwise valid unlit pipeline and invalidates
1273
+ // the command buffer before it reaches the surface.
1274
+ if (!isSkinEntry) {
1275
+ materialGroup2Contract = materialPipelineEntry?.group2Contract;
1276
+ if (materialGroup2Contract === undefined) continue;
1277
+ const selectedGroup = selectMaterialGroup2(
1278
+ meshGroup2,
1279
+ meshBindGroup,
1280
+ materialGroup2Contract,
1281
+ );
1282
+ if (selectedGroup === null) continue;
1283
+ group2BindGroup = selectedGroup;
1284
+ }
1285
+ if (diagnosticsEnabled && submeshMaterial.textureHandles !== undefined) {
1186
1286
  console.error(
1187
- `[render-material] draw skipped: ${JSON.stringify({
1287
+ `[render-material] draw submitted: ${JSON.stringify({
1188
1288
  entityKey: entry.source.entityKey,
1189
1289
  materialHandle: submeshMaterial.materialHandle,
1190
1290
  shader: smMaterialShaderId,
1191
- reason: 'pipeline-unavailable',
1291
+ textureHandles: [...submeshMaterial.textureHandles.entries()].map(
1292
+ ([field, handle]) => ({
1293
+ field,
1294
+ handle,
1295
+ }),
1296
+ ),
1297
+ skin: isSkinEntry
1298
+ ? {
1299
+ resourcesReady: skinResources !== null,
1300
+ psoReady: skinPsoProbe !== null,
1301
+ paletteBufferReady: skinSlice?.buffer !== undefined,
1302
+ paletteByteOffset: skinSlice?.byteOffset,
1303
+ dynamicOffsets: [...group2DynamicOffsets],
1304
+ }
1305
+ : undefined,
1306
+ pipelineReady: true,
1192
1307
  })}`,
1193
1308
  );
1194
1309
  }
1195
- continue;
1196
- }
1197
- // The probe record is an ABI extension of the built-in Standard PBR
1198
- // shaders only. RenderScene may retain a record on every projected
1199
- // renderable, but unlit/custom shaders still use the one-binding
1200
- // instances BGL and must never receive the probe bind group. Resolve
1201
- // this per submesh so mixed-material meshes cannot cross the layouts.
1202
- const instanceDraws = resolveGeometryInstanceBuffer(
1203
- c,
1204
- entry,
1205
- identityInstanceDraws,
1206
- probeBlendAvailable,
1207
- );
1208
- if (instanceDraws === null) continue;
1209
- // Standard clustered PBR variants declare the unified cluster/SSAO
1210
- // group(2) layout. Unlit and other URP-layout materials must bind the
1211
- // ordinary mesh group instead; choosing one group for the whole pass
1212
- // makes Dawn reject an otherwise valid unlit pipeline and invalidates
1213
- // the command buffer before it reaches the surface.
1214
- if (!isSkinEntry) {
1215
- materialGroup2Contract = materialPipelineEntry?.group2Contract;
1216
- if (materialGroup2Contract === undefined) continue;
1217
- const selectedGroup = selectMaterialGroup2(
1218
- meshGroup2,
1219
- meshBindGroup,
1220
- materialGroup2Contract,
1221
- );
1222
- if (selectedGroup === null) continue;
1223
- group2BindGroup = selectedGroup;
1224
- }
1225
- if (diagnosticsEnabled && submeshMaterial.textureHandles !== undefined) {
1226
- console.error(
1227
- `[render-material] draw submitted: ${JSON.stringify({
1228
- entityKey: entry.source.entityKey,
1229
- materialHandle: submeshMaterial.materialHandle,
1230
- shader: smMaterialShaderId,
1231
- textureHandles: [...submeshMaterial.textureHandles.entries()].map(
1232
- ([field, handle]) => ({
1233
- field,
1234
- handle,
1235
- }),
1236
- ),
1237
- skin: isSkinEntry
1238
- ? {
1239
- resourcesReady: skinResources !== null,
1240
- psoReady: skinPsoProbe !== null,
1241
- paletteBufferReady: skinSlice?.buffer !== undefined,
1242
- paletteByteOffset: skinSlice?.byteOffset,
1243
- dynamicOffsets: [...group2DynamicOffsets],
1244
- }
1245
- : undefined,
1246
- pipelineReady: true,
1247
- })}`,
1248
- );
1249
- }
1250
- pass.setBindGroup(2, group2BindGroup, group2DynamicOffsets);
1310
+ pass.setBindGroup(2, group2BindGroup, group2DynamicOffsets);
1251
1311
 
1252
- if (profilePhase === undefined) {
1253
- recordOcclusionCandidate(() =>
1254
- submitSubmeshDraws(
1255
- pass,
1256
- bindingState,
1257
- smPipelineHandle,
1258
- instanceDraws,
1259
- entry.mesh.indexed,
1260
- sm.indexCount,
1261
- sm.vertexCount,
1262
- sm.indexOffset,
1263
- ),
1264
- );
1265
- } else {
1266
- profileGeometrySegment(c, passKind, 'draw-submit', () =>
1312
+ if (profilePhase === undefined) {
1267
1313
  recordOcclusionCandidate(() =>
1268
1314
  submitSubmeshDraws(
1269
1315
  pass,
@@ -1275,11 +1321,26 @@ export function recordGeometryDraws(
1275
1321
  sm.vertexCount,
1276
1322
  sm.indexOffset,
1277
1323
  ),
1278
- ),
1279
- );
1280
- }
1281
- if (diagnosticsEnabled && perSubmeshBg !== null) {
1282
- recordIblPipelineBinding(c, submeshMaterial, smPipelineHandle, perSubmeshBg);
1324
+ );
1325
+ } else {
1326
+ profileGeometrySegment(c, passKind, 'draw-submit', () =>
1327
+ recordOcclusionCandidate(() =>
1328
+ submitSubmeshDraws(
1329
+ pass,
1330
+ bindingState,
1331
+ smPipelineHandle,
1332
+ instanceDraws,
1333
+ entry.mesh.indexed,
1334
+ sm.indexCount,
1335
+ sm.vertexCount,
1336
+ sm.indexOffset,
1337
+ ),
1338
+ ),
1339
+ );
1340
+ }
1341
+ if (diagnosticsEnabled && perSubmeshBg !== null) {
1342
+ recordIblPipelineBinding(c, submeshMaterial, smPipelineHandle, perSubmeshBg);
1343
+ }
1283
1344
  }
1284
1345
  }
1285
1346
  }