@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
@@ -6,7 +6,7 @@ import {
6
6
  type RhiRenderPassEncoder,
7
7
  type TextureView,
8
8
  } from '@forgeax/engine-rhi';
9
- import type { Handle, PassKind, PassSelector } from '@forgeax/engine-types';
9
+ import type { PassKind, PassSelector } from '@forgeax/engine-types';
10
10
  import { createHdrpUnifiedBindGroup, getOrCreateHdrpBuffers } from '../hdrp-buffers';
11
11
  import { getOrCreateIblCache } from '../ibl/IblPipelineCache';
12
12
  import type { SkylightBindGroupResources } from '../ibl/skylight-bind-group';
@@ -19,20 +19,17 @@ import { resolveReflectionProbeBinding } from './frame-lighting';
19
19
  import { getOpaqueResourceIdentity } from './frame-snapshot';
20
20
  import { recordGeometryDraws } from './main-pass-geometry';
21
21
  import {
22
- applyMaterialTextureUvScales,
23
- applyParamSnapshotToUbo,
24
22
  buildPerSubmeshMaterialBg as buildPerSubmeshMaterialBgImpl,
25
- detectNineSliceScaleTooSmall,
26
23
  type PerSubmeshMaterialBgDeps,
27
- residentTextureView,
28
- writePbrMaterialUboPayload,
24
+ prepareMaterialSkylight,
29
25
  } from './main-pass-material';
30
26
  import { recordSpritePass } from './main-pass-sprite-draws';
31
27
  import type { _InternalRenderPipelineContext } from './render-context';
32
- import { MATERIAL_PER_ENTITY_STRIDE, STANDARD_PBR_UBO_SIZE } from './render-context';
28
+ import { STANDARD_PBR_UBO_SIZE } from './render-context';
33
29
  import {
34
30
  buildMatchedMaterialHandlesByRenderable,
35
31
  buildMatchedRenderableIndices,
32
+ filterDispatchBySelector,
36
33
  } from './shadow-pass';
37
34
 
38
35
  export function standardReflectionProbeIndex(
@@ -41,8 +38,6 @@ export function standardReflectionProbeIndex(
41
38
  return resolveReflectionProbeBinding(selection).probeIndex;
42
39
  }
43
40
 
44
- const ZERO_SKYLIGHT_PAYLOAD = new Float32Array([0, 0, 0, 0, 0, 0, 0, 1]);
45
-
46
41
  function materialDiagnosticsEnabled(): boolean {
47
42
  if (typeof globalThis !== 'object' || globalThis === null || !('process' in globalThis)) {
48
43
  return false;
@@ -138,8 +133,6 @@ export function recordMainPass(
138
133
  meshBindGroup,
139
134
  frameState,
140
135
  bindGroupCounts,
141
- skylight,
142
- skylightCount,
143
136
  skyboxActive,
144
137
  splitLdrSprite,
145
138
  msaaActive,
@@ -289,6 +282,13 @@ export function recordMainPass(
289
282
  const selectorMatchedMaterials =
290
283
  selector !== undefined ? buildMatchedMaterialHandlesByRenderable(dispatch, selector) : null;
291
284
  const matchedMaterials = matchedMaterialsForRecordMode(c, selectorMatchedMaterials, recordMode);
285
+ const selectedDispatch =
286
+ dispatch.length === 0
287
+ ? undefined
288
+ : filterDispatchBySelector(
289
+ dispatch,
290
+ selector ?? { LightMode: [passKind === 'deferred' ? 'Deferred' : 'Forward'] },
291
+ );
292
292
  const recordContext =
293
293
  recordMode === undefined
294
294
  ? c
@@ -355,78 +355,7 @@ export function recordMainPass(
355
355
  // (single `skylightResources` resolved once below); only the first 7
356
356
  // material entries are rebuilt per-entity with the correct
357
357
  // per-entity textureView at binding=2.
358
- const skylightFallback = pipelineState.skylightFallback;
359
- if (skylightFallback === null) {
360
- throw new RhiError({
361
- code: 'webgpu-runtime-error',
362
- expected: 'pipelineState.skylightFallback != null when PBR pipeline is active',
363
- hint: 'createRenderer must allocate skylightFallback alongside the PBR pipeline (D-5 round-4)',
364
- });
365
- }
366
- // feat-20260520-skylight-ibl-cubemap M4 round-4 / t60 (D-5 round-4):
367
- // select active vs fallback Skylight resources by `skylightCount` from
368
- // the extract stage. Active path reaches into the per-device
369
- // `IblPipelineCache` slots (irradianceView / prefilterView / brdfLutView)
370
- // populated by the internal equirect-to-cubemap projection; fallback uses the
371
- // 1x1-zero identity bundle that converges ambient to 0 (D-4 physical
372
- // convergence -- no `if (hasSkylight)` shader branch).
373
- // The samplers are reused from `skylightFallback.sampler` for both
374
- // paths (linear / clamp-to-edge is correct for IBL cube + 2D LUT
375
- // sampling either way). The intensity uniform is rewritten per-frame
376
- // when active so `sampleIblSpecular * intensity` carries the user's
377
- // Skylight.intensity value; fallback keeps intensity=0 (createSkylightFallback
378
- // seed) so ambient = 0 even when the same buffer is shared.
379
- let activeViews: { irr: TextureView; pref: TextureView; brdf: TextureView } | undefined;
380
- // Per-frame Skylight uniform: std140 32 B = [intensity, colorR, colorG,
381
- // colorB, rotation quaternion]. Default to all-zero so a transition from "has Skylight" ->
382
- // "no Skylight" does not leak the prior frame's ambient (intensity 0
383
- // muzzles everything, including the white fallback irradiance cube).
384
- runtime.device.queue.writeBuffer(skylightFallback.intensityBuffer, 0, ZERO_SKYLIGHT_PAYLOAD);
385
- if (skylight !== undefined && skylightCount >= 1) {
386
- // A Skylight exists. Write its intensity + color regardless of whether
387
- // a cubemap is bound: with a cubemap the IBL views below light the
388
- // ambient; WITHOUT one, the white fallback irradiance cube + this color
389
- // give an instant solid-color ambient (downstream integration #4) with
390
- // no async precompute. The white fallback only contributes when a
391
- // Skylight is present because the zero-payload above sets intensity 0
392
- // when no Skylight exists.
393
- const [cr, cg, cb] = skylight.color;
394
- const [qx, qy, qz, qw] = skylight.rotation;
395
- const uniformPayload = new Float32Array([skylight.intensity, cr, cg, cb, qx, qy, qz, qw]);
396
- runtime.device.queue.writeBuffer(skylightFallback.intensityBuffer, 0, uniformPayload);
397
- const cache = getOrCreateIblCache(runtime.deviceScope);
398
- if (
399
- cache.irradianceView !== undefined &&
400
- cache.prefilterView !== undefined &&
401
- cache.brdfLutView !== undefined
402
- ) {
403
- activeViews = {
404
- irr: cache.irradianceView,
405
- pref: cache.prefilterView,
406
- brdf: cache.brdfLutView,
407
- };
408
- }
409
- }
410
- const skylightResources =
411
- activeViews !== undefined
412
- ? {
413
- irradianceView: activeViews.irr,
414
- irradianceSampler: skylightFallback.sampler,
415
- prefilterView: activeViews.pref,
416
- prefilterSampler: skylightFallback.sampler,
417
- brdfLutView: activeViews.brdf,
418
- brdfLutSampler: skylightFallback.sampler,
419
- intensityBuffer: skylightFallback.intensityBuffer,
420
- }
421
- : {
422
- irradianceView: skylightFallback.irradianceView,
423
- irradianceSampler: skylightFallback.sampler,
424
- prefilterView: skylightFallback.prefilterView,
425
- prefilterSampler: skylightFallback.sampler,
426
- brdfLutView: skylightFallback.brdfLutView,
427
- brdfLutSampler: skylightFallback.sampler,
428
- intensityBuffer: skylightFallback.intensityBuffer,
429
- };
358
+ const { skylightResources, activeViews } = prepareMaterialSkylight(c);
430
359
  if (materialDiagnosticsEnabled()) {
431
360
  const activeCache =
432
361
  activeViews === undefined ? undefined : getOrCreateIblCache(runtime.deviceScope);
@@ -438,7 +367,9 @@ export function recordMainPass(
438
367
  deviceGeneration: runtime.deviceScope.generation,
439
368
  active: activeViews === undefined ? 'fallback' : 'active',
440
369
  cache: {
441
- identity: getOpaqueResourceIdentity((activeCache ?? skylightFallback) as object),
370
+ identity: getOpaqueResourceIdentity(
371
+ (activeCache ?? pipelineState.skylightFallback) as object,
372
+ ),
442
373
  generation: runtime.deviceScope.generation,
443
374
  prefilterMipCount: prefilterViewMipCount,
444
375
  prefilterViewMipCount,
@@ -563,173 +494,16 @@ export function recordMainPass(
563
494
  submeshMaterial: MaterialSnapshot,
564
495
  entityKey: number,
565
496
  materialWorld: World = world,
497
+ materialShaderId: string | undefined = submeshMaterial.materialShaderId,
566
498
  ): BindGroup =>
567
499
  buildPerSubmeshMaterialBgImpl(
568
500
  perSubmeshMaterialBgDeps,
569
501
  submeshMaterial,
570
502
  entityKey,
571
503
  materialWorld,
504
+ materialShaderId,
572
505
  );
573
506
 
574
- const cachedMaterialUboPayload = c.materialUboPayloadCache;
575
- let materialUboPayload: Uint8Array;
576
- if (
577
- cachedMaterialUboPayload?.materialSlots === materialSlots &&
578
- cachedMaterialUboPayload.materialSlotCount === materialSlotCount
579
- ) {
580
- materialUboPayload = cachedMaterialUboPayload.payload;
581
- } else {
582
- materialUboPayload = new Uint8Array(materialSlotCount * MATERIAL_PER_ENTITY_STRIDE);
583
- const slotPayload = new Uint8Array(STANDARD_PBR_UBO_SIZE);
584
- const slotPayloadF32 = new Float32Array(
585
- slotPayload.buffer,
586
- slotPayload.byteOffset,
587
- slotPayload.byteLength / 4,
588
- );
589
- for (let materialSlot = 0; materialSlot < materialSlots.length; materialSlot += 1) {
590
- const mat = materialSlots[materialSlot];
591
- const entry = validatedOrdered[materialSlotOwners[materialSlot] ?? -1];
592
- if (mat === undefined || entry === undefined) continue;
593
-
594
- // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13 (D-2):
595
- // single unified Material UBO write path. Sprite materials now flow
596
- // through the same `buildPbrMaterialUboPayload` baseline +
597
- // `applyParamSnapshotToUbo` generic std140 overlay every other
598
- // paramSchema-driven material uses. Extract folds the sprite-specific
599
- // user inputs into the UBO-aligned paramSnapshot vec4 entries
600
- // (colorTint / region / pivotAndSize / slicesAndMode); the writer
601
- // walks `derive(paramSchema).uboLayout.entries` and writes each at
602
- // its std140 offset. The legacy sprite-specific UBO builder + the
603
- // sprite-vs-PBR branch are gone (AC-03).
604
- writePbrMaterialUboPayload(slotPayloadF32, mat);
605
- // Schema-driven paramSnapshot overlay generalised in feat-20260625
606
- // M1 / w3: the writer walks `derive(paramSchema).uboLayout.entries`
607
- // and writes each numeric field at its std140 offset (plan-strategy
608
- // section 2 D-2). The engine's stock PBR material ships
609
- // `paramSnapshot: undefined`, so this is a no-op on the default
610
- // PBR path -- the explicit field writes in buildPbrMaterialUboPayload
611
- // already cover every byte. User shaders carrying a paramSnapshot
612
- // (including the post-ablation sprite path) get their fields
613
- // written at the derive-computed offsets; R-H gate keeps the
614
- // helper snapshot-only, no asset get.
615
- const materialShaderId = mat.materialShaderId;
616
- const schema =
617
- mat.materialParamSchema ??
618
- (materialShaderId !== undefined ? runtime.getParamSchema?.(materialShaderId) : undefined);
619
- applyParamSnapshotToUbo(slotPayloadF32, schema, mat.paramSnapshot);
620
- const processValue = (
621
- globalThis as {
622
- process?: { env?: Record<string, string | undefined> };
623
- }
624
- ).process;
625
- if (
626
- processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
627
- (materialShaderId === 'forgeax::default-standard-pbr' ||
628
- materialShaderId === 'forgeax::pbr-skin')
629
- ) {
630
- console.error(
631
- `[render-material] UBO receipt: ${JSON.stringify({
632
- entityIndex: entry.renderableIndex,
633
- materialHandle: mat.materialHandle,
634
- materialShaderId,
635
- clearcoat: slotPayloadF32[32],
636
- clearcoatRoughness: slotPayloadF32[33],
637
- clearcoatNormalScale: slotPayloadF32[34],
638
- paramSnapshot: mat.paramSnapshot,
639
- })}`,
640
- );
641
- }
642
- applyMaterialTextureUvScales(slotPayloadF32, mat, world);
643
- // Missing-texture detection: structural debug-pink fallback overrides
644
- // the baseColor/colorTint slot when a bound baseColorTexture handle
645
- // resolves to no GPU view. Runs for every textured material path
646
- // (sprite / sprite-lit / standard-pbr / pbr-skin / unlit) — the bound
647
- // texture would otherwise silently fall back to the 1x1 white view in
648
- // the per-submesh BG (main-pass-material.ts), rendering flat with no
649
- // warn / RhiError. Mirroring the telemetry here makes a missing/failed
650
- // GLB texture immediately diagnosable instead of a silent flat render
651
- // (feat-future-pbr-missing-texture-fallback-explicit; feedback
652
- // 2026-07-04-glb-pbr-textures-not-applied-flat-render).
653
- //
654
- // Reads only `mat.baseColorTexture` + the GPU view registry (plan R-H
655
- // gate: no asset.get<MaterialAsset> reach-back). The debug-pink write
656
- // lands on f32[0..2], which is baseColor.rgb for the PBR/skin UBO and
657
- // colorTint.rgb for the sprite UBO — same offset, so one override
658
- // covers both.
659
- {
660
- const matHandleRaw = mat.baseColorTexture as Handle<'TextureAsset', 'shared'> | undefined;
661
- if (matHandleRaw !== undefined) {
662
- const view = residentTextureView(entry.world ?? world, store, runtime, matHandleRaw);
663
- if (view === undefined) {
664
- const rawId: number = matHandleRaw;
665
- if (!frameState.warnedMissingBaseColorTextureHandles.has(rawId)) {
666
- frameState.warnedMissingBaseColorTextureHandles.add(rawId);
667
- console.warn(
668
- `[forgeax] baseColor texture ${rawId} missing GPU view, rendering debug pink (shader=${materialShaderId ?? '<none>'} entityIndex=${entry.renderableIndex})`,
669
- );
670
- }
671
- runtime.errorRegistry.fire(
672
- new RhiError({
673
- code: 'asset-not-registered',
674
- expected: 'material baseColor TextureAsset uploaded to GPU',
675
- hint: 'register + uploadTexture the baseColor texture before draw([world], { cameraOwner: 0, resourceOwner: 0 }); rendering falls back to debug pink until then',
676
- detail: { assetHandle: rawId },
677
- }),
678
- );
679
- // Debug pink override on slot 0 baseColor/colorTint.rgb (alpha preserved).
680
- slotPayloadF32[0] = 1.0;
681
- slotPayloadF32[1] = 0.4;
682
- slotPayloadF32[2] = 0.7;
683
- }
684
- }
685
- }
686
-
687
- materialUboPayload.set(slotPayload, materialSlot * MATERIAL_PER_ENTITY_STRIDE);
688
- }
689
-
690
- // Material UBO slots can share one snapshot identity, but nine-slice
691
- // validity depends on each entity's transform. Preserve diagnostics per
692
- // renderable instead of inheriting the first owner of a shared slot.
693
- for (const entry of validatedOrdered) {
694
- const mat = entry.source.material;
695
- const materialShaderId = mat.materialShaderId;
696
- if (materialShaderId !== 'forgeax::sprite' && materialShaderId !== 'forgeax::sprite-lit')
697
- continue;
698
- const slicesAndMode = mat.paramSnapshot?.slicesAndMode as readonly number[] | undefined;
699
- if (slicesAndMode === undefined || slicesAndMode.length < 4) continue;
700
- const slicesArr: readonly [number, number, number, number] = [
701
- slicesAndMode[0] ?? 0,
702
- slicesAndMode[1] ?? 0,
703
- slicesAndMode[2] ?? 0,
704
- slicesAndMode[3] ?? 0,
705
- ];
706
- if (slicesArr[0] === 0 && slicesArr[1] === 0 && slicesArr[2] === 0 && slicesArr[3] === 0)
707
- continue;
708
- detectNineSliceScaleTooSmall(
709
- entry.source.transform.world,
710
- slicesArr,
711
- entry.renderableIndex,
712
- frameState.warnedNineSliceScaleEntities,
713
- runtime.metrics,
714
- );
715
- }
716
- c.materialUboPayloadCache = {
717
- materialSlots,
718
- materialSlotCount,
719
- payload: materialUboPayload,
720
- };
721
- }
722
-
723
- // All material slots are staged before the first geometry draw. One
724
- // stride-shaped upload preserves every dynamic offset while avoiding one
725
- // queue call per material; the RHI still validates alignment and bounds at
726
- // this single owner-level write boundary.
727
- const materialUboUpload = runtime.device.queue.writeBuffer(
728
- pipelineState.materialUniformBuffer.buffer,
729
- 0,
730
- materialUboPayload,
731
- );
732
- if (!materialUboUpload.ok) throw materialUboUpload.error;
733
507
  // Static material resources are a property of the frame-local material
734
508
  // slot, not of each submesh draw. Resolve them once here so the geometry
735
509
  // loop only selects a prepared binding. Video fields remain entity-bound:
@@ -752,11 +526,13 @@ export function recordMainPass(
752
526
  material: MaterialSnapshot,
753
527
  entityKey: number,
754
528
  materialWorld: World = world,
529
+ materialShaderId: string | undefined = material.materialShaderId,
755
530
  ): BindGroup =>
756
- material.materialShaderId !== POINTS_LINES_MATERIAL_SHADER_ID
531
+ materialShaderId === material.materialShaderId &&
532
+ materialShaderId !== POINTS_LINES_MATERIAL_SHADER_ID
757
533
  ? (preparedMaterialBindGroups[materialSlot] ??
758
- buildPerSubmeshMaterialBg(material, entityKey, materialWorld))
759
- : buildPerSubmeshMaterialBg(material, entityKey, materialWorld);
534
+ buildPerSubmeshMaterialBg(material, entityKey, materialWorld, materialShaderId))
535
+ : buildPerSubmeshMaterialBg(material, entityKey, materialWorld, materialShaderId);
760
536
 
761
537
  pass.setBindGroup(0, viewBindGroup as BindGroup, [viewBindGroupDynamicOffset, 0]);
762
538
 
@@ -777,6 +553,7 @@ export function recordMainPass(
777
553
  meshBindGroup,
778
554
  resolveMaterialBindGroup,
779
555
  passKind,
556
+ selectedDispatch,
780
557
  );
781
558
  };
782
559
  if (c.profilePhase === undefined) {
@@ -808,6 +585,7 @@ export function recordMainPass(
808
585
  resolveMaterialBindGroup,
809
586
  skylightResources,
810
587
  graphPass,
588
+ selectedDispatch,
811
589
  );
812
590
  }
813
591
  } // end if (validatedOrdered.length > 0) -- Case E falls through to pass.end()
@@ -0,0 +1,186 @@
1
+ import { RhiError } from '@forgeax/engine-rhi';
2
+ import type { Handle } from '@forgeax/engine-types';
3
+ import {
4
+ applyMaterialTextureUvScales,
5
+ applyParamSnapshotToUbo,
6
+ detectNineSliceScaleTooSmall,
7
+ residentTextureView,
8
+ writePbrMaterialUboPayload,
9
+ } from './main-pass-material';
10
+ import type { _InternalRenderPipelineContext } from './render-context';
11
+ import { MATERIAL_PER_ENTITY_STRIDE, STANDARD_PBR_UBO_SIZE } from './render-context';
12
+
13
+ /** Stage the root parameter storage before any shadow or color pass consumes it. */
14
+ export function uploadMaterialUniforms(c: _InternalRenderPipelineContext): void {
15
+ const {
16
+ runtime,
17
+ world,
18
+ store,
19
+ pipelineState,
20
+ validatedOrdered,
21
+ frameState,
22
+ materialSlots,
23
+ materialSlotOwners,
24
+ materialSlotCount,
25
+ } = c;
26
+ if (materialSlotCount === 0) return;
27
+ const cachedMaterialUboPayload = c.materialUboPayloadCache;
28
+ let materialUboPayload: Uint8Array;
29
+ if (
30
+ cachedMaterialUboPayload?.materialSlots === materialSlots &&
31
+ cachedMaterialUboPayload.materialSlotCount === materialSlotCount
32
+ ) {
33
+ materialUboPayload = cachedMaterialUboPayload.payload;
34
+ } else {
35
+ materialUboPayload = new Uint8Array(materialSlotCount * MATERIAL_PER_ENTITY_STRIDE);
36
+ const slotPayload = new Uint8Array(STANDARD_PBR_UBO_SIZE);
37
+ const slotPayloadF32 = new Float32Array(
38
+ slotPayload.buffer,
39
+ slotPayload.byteOffset,
40
+ slotPayload.byteLength / 4,
41
+ );
42
+ for (let materialSlot = 0; materialSlot < materialSlots.length; materialSlot += 1) {
43
+ const mat = materialSlots[materialSlot];
44
+ const entry = validatedOrdered[materialSlotOwners[materialSlot] ?? -1];
45
+ if (mat === undefined || entry === undefined) continue;
46
+
47
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13 (D-2):
48
+ // single unified Material UBO write path. Sprite materials now flow
49
+ // through the same `buildPbrMaterialUboPayload` baseline +
50
+ // `applyParamSnapshotToUbo` generic std140 overlay every other
51
+ // paramSchema-driven material uses. Extract folds the sprite-specific
52
+ // user inputs into the UBO-aligned paramSnapshot vec4 entries
53
+ // (colorTint / region / pivotAndSize / slicesAndMode); the writer
54
+ // walks `derive(paramSchema).uboLayout.entries` and writes each at
55
+ // its std140 offset. The legacy sprite-specific UBO builder + the
56
+ // sprite-vs-PBR branch are gone (AC-03).
57
+ writePbrMaterialUboPayload(slotPayloadF32, mat);
58
+ // Schema-driven paramSnapshot overlay generalised in feat-20260625
59
+ // M1 / w3: the writer walks `derive(paramSchema).uboLayout.entries`
60
+ // and writes each numeric field at its std140 offset (plan-strategy
61
+ // section 2 D-2). The engine's stock PBR material ships
62
+ // `paramSnapshot: undefined`, so this is a no-op on the default
63
+ // PBR path -- the explicit field writes in buildPbrMaterialUboPayload
64
+ // already cover every byte. User shaders carrying a paramSnapshot
65
+ // (including the post-ablation sprite path) get their fields
66
+ // written at the derive-computed offsets; R-H gate keeps the
67
+ // helper snapshot-only, no asset get.
68
+ const materialShaderId = mat.materialShaderId;
69
+ const schema =
70
+ mat.materialParamSchema ??
71
+ (materialShaderId !== undefined ? runtime.getParamSchema?.(materialShaderId) : undefined);
72
+ applyParamSnapshotToUbo(slotPayloadF32, schema, mat.paramSnapshot);
73
+ const processValue = (
74
+ globalThis as {
75
+ process?: { env?: Record<string, string | undefined> };
76
+ }
77
+ ).process;
78
+ if (
79
+ processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
80
+ (materialShaderId === 'forgeax::default-standard-pbr' ||
81
+ materialShaderId === 'forgeax::pbr-skin')
82
+ ) {
83
+ console.error(
84
+ `[render-material] UBO receipt: ${JSON.stringify({
85
+ entityIndex: entry.renderableIndex,
86
+ materialHandle: mat.materialHandle,
87
+ materialShaderId,
88
+ clearcoat: slotPayloadF32[32],
89
+ clearcoatRoughness: slotPayloadF32[33],
90
+ clearcoatNormalScale: slotPayloadF32[34],
91
+ paramSnapshot: mat.paramSnapshot,
92
+ })}`,
93
+ );
94
+ }
95
+ applyMaterialTextureUvScales(slotPayloadF32, mat, world);
96
+ // Missing-texture detection: structural debug-pink fallback overrides
97
+ // the baseColor/colorTint slot when a bound baseColorTexture handle
98
+ // resolves to no GPU view. Runs for every textured material path
99
+ // (sprite / sprite-lit / standard-pbr / pbr-skin / unlit) — the bound
100
+ // texture would otherwise silently fall back to the 1x1 white view in
101
+ // the per-submesh BG (main-pass-material.ts), rendering flat with no
102
+ // warn / RhiError. Mirroring the telemetry here makes a missing/failed
103
+ // GLB texture immediately diagnosable instead of a silent flat render
104
+ // (feat-future-pbr-missing-texture-fallback-explicit; feedback
105
+ // 2026-07-04-glb-pbr-textures-not-applied-flat-render).
106
+ //
107
+ // Reads only `mat.baseColorTexture` + the GPU view registry (plan R-H
108
+ // gate: no asset.get<MaterialAsset> reach-back). The debug-pink write
109
+ // lands on f32[0..2], which is baseColor.rgb for the PBR/skin UBO and
110
+ // colorTint.rgb for the sprite UBO — same offset, so one override
111
+ // covers both.
112
+ {
113
+ const matHandleRaw = mat.baseColorTexture as Handle<'TextureAsset', 'shared'> | undefined;
114
+ if (matHandleRaw !== undefined) {
115
+ const view = residentTextureView(entry.world ?? world, store, runtime, matHandleRaw);
116
+ if (view === undefined) {
117
+ const rawId: number = matHandleRaw;
118
+ if (!frameState.warnedMissingBaseColorTextureHandles.has(rawId)) {
119
+ frameState.warnedMissingBaseColorTextureHandles.add(rawId);
120
+ console.warn(
121
+ `[forgeax] baseColor texture ${rawId} missing GPU view, rendering debug pink (shader=${materialShaderId ?? '<none>'} entityIndex=${entry.renderableIndex})`,
122
+ );
123
+ }
124
+ runtime.errorRegistry.fire(
125
+ new RhiError({
126
+ code: 'asset-not-registered',
127
+ expected: 'material baseColor TextureAsset uploaded to GPU',
128
+ hint: 'register + uploadTexture the baseColor texture before draw([world], { cameraOwner: 0, resourceOwner: 0 }); rendering falls back to debug pink until then',
129
+ detail: { assetHandle: rawId },
130
+ }),
131
+ );
132
+ // Debug pink override on slot 0 baseColor/colorTint.rgb (alpha preserved).
133
+ slotPayloadF32[0] = 1.0;
134
+ slotPayloadF32[1] = 0.4;
135
+ slotPayloadF32[2] = 0.7;
136
+ }
137
+ }
138
+ }
139
+
140
+ materialUboPayload.set(slotPayload, materialSlot * MATERIAL_PER_ENTITY_STRIDE);
141
+ }
142
+
143
+ // Material UBO slots can share one snapshot identity, but nine-slice
144
+ // validity depends on each entity's transform. Preserve diagnostics per
145
+ // renderable instead of inheriting the first owner of a shared slot.
146
+ for (const entry of validatedOrdered) {
147
+ const mat = entry.source.material;
148
+ const materialShaderId = mat.materialShaderId;
149
+ if (materialShaderId !== 'forgeax::sprite' && materialShaderId !== 'forgeax::sprite-lit')
150
+ continue;
151
+ const slicesAndMode = mat.paramSnapshot?.slicesAndMode as readonly number[] | undefined;
152
+ if (slicesAndMode === undefined || slicesAndMode.length < 4) continue;
153
+ const slicesArr: readonly [number, number, number, number] = [
154
+ slicesAndMode[0] ?? 0,
155
+ slicesAndMode[1] ?? 0,
156
+ slicesAndMode[2] ?? 0,
157
+ slicesAndMode[3] ?? 0,
158
+ ];
159
+ if (slicesArr[0] === 0 && slicesArr[1] === 0 && slicesArr[2] === 0 && slicesArr[3] === 0)
160
+ continue;
161
+ detectNineSliceScaleTooSmall(
162
+ entry.source.transform.world,
163
+ slicesArr,
164
+ entry.renderableIndex,
165
+ frameState.warnedNineSliceScaleEntities,
166
+ runtime.metrics,
167
+ );
168
+ }
169
+ c.materialUboPayloadCache = {
170
+ materialSlots,
171
+ materialSlotCount,
172
+ payload: materialUboPayload,
173
+ };
174
+ }
175
+
176
+ // All material slots are staged before the first geometry draw. One
177
+ // stride-shaped upload preserves every dynamic offset while avoiding one
178
+ // queue call per material; the RHI still validates alignment and bounds at
179
+ // this single owner-level write boundary.
180
+ const materialUboUpload = runtime.device.queue.writeBuffer(
181
+ pipelineState.materialUniformBuffer.buffer,
182
+ 0,
183
+ materialUboPayload,
184
+ );
185
+ if (!materialUboUpload.ok) throw materialUboUpload.error;
186
+ }
@@ -183,6 +183,8 @@ export interface RenderSystemRuntime {
183
183
  shaderModuleMode?: RenderFeatureShaderModuleMode,
184
184
  layoutKindOverride?: 'pbr' | 'pbr-skin',
185
185
  additionalColorFormats?: readonly GPUTextureFormat[],
186
+ vertexEntry?: string,
187
+ fragmentEntry?: string,
186
188
  ) => RhiRenderPipeline | null;
187
189
  readonly getMaterialShaderPipelineEntry?: (
188
190
  materialShaderId: string,
@@ -202,6 +204,8 @@ export interface RenderSystemRuntime {
202
204
  shaderModuleMode?: RenderFeatureShaderModuleMode,
203
205
  layoutKindOverride?: 'pbr' | 'pbr-skin',
204
206
  additionalColorFormats?: readonly GPUTextureFormat[],
207
+ vertexEntry?: string,
208
+ fragmentEntry?: string,
205
209
  ) => MaterialShaderPipelineEntry | null;
206
210
  /** Reflection-derived UV count included in the material PipelineSpec cache key. */
207
211
  readonly getMaterialShaderUvSetCount?: (materialShaderId: string) => number | undefined;