@forgeax/engine-runtime 0.1.19 → 0.1.21

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 (91) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  9. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  12. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  13. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  14. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  15. package/dist/createRenderer.d.ts.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.mjs +3 -1
  18. package/dist/index.mjs.map +1 -1
  19. package/package.json +30 -30
  20. package/src/__tests__/active-camera.unit.test.ts +6 -8
  21. package/src/__tests__/asset-registry.recursive.spec.ts +13 -19
  22. package/src/__tests__/asset.unit.test.ts +28 -25
  23. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  24. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  25. package/src/__tests__/components.test-d.ts +10 -3
  26. package/src/__tests__/components.unit.test.ts +13 -7
  27. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  28. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  29. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  30. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  31. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +11 -4
  32. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  33. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  35. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  38. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  39. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  40. package/src/__tests__/errors.unit.test.ts +71 -20
  41. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  42. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  43. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  44. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  45. package/src/__tests__/geometry.unit.test.ts +7 -10
  46. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  47. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  48. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +6 -3
  49. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  50. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  51. package/src/__tests__/lights.unit.test.ts +75 -2214
  52. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  53. package/src/__tests__/loader-name-survival.test.ts +2 -3
  54. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  55. package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
  56. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  57. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  58. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  59. package/src/__tests__/pbr-pipeline.unit.test.ts +29 -5
  60. package/src/__tests__/pipeline.unit.test.ts +66 -221
  61. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  62. package/src/__tests__/point-light-shadow.unit.test.ts +67 -9
  63. package/src/__tests__/render-error-exhaustive.test-d.ts +92 -9
  64. package/src/__tests__/render-system-extract.test.ts +4 -6
  65. package/src/__tests__/render-system-mega.test.ts +30 -41
  66. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +8 -7
  67. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +6 -4
  68. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  69. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  70. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  71. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  72. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  73. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  74. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  75. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  76. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  77. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +140 -58
  79. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +6 -2
  80. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  81. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  82. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
  83. package/src/__tests__/ssao-bgl.test.ts +10 -16
  84. package/src/__tests__/ssao-passes.test.ts +19 -2
  85. package/src/__tests__/systems.unit.test.ts +59 -33
  86. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  87. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  88. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  89. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  90. package/src/createRenderer.ts +4 -0
  91. package/src/index.ts +1 -2
@@ -131,7 +131,7 @@ function makeMockRuntime(capsOverride: Partial<RhiCaps> = {}): {
131
131
 
132
132
  function fakeHdrpBuffers(): HdrpBuffers {
133
133
  return {
134
- storageBuffer: true,
134
+ device: {} as HdrpBuffers['device'],
135
135
  lightDataBuffer: mockBuffer('hdrp-light-data'),
136
136
  lightDataBytes: 16384,
137
137
  clusterGridBuffer: mockBuffer('hdrp-cluster-grid'),
@@ -150,15 +150,9 @@ function fakeHdrpBuffers(): HdrpBuffers {
150
150
  }
151
151
 
152
152
  describe('HDRP GPU membership producer bind-group contract', () => {
153
- it('preserves the 256-light storage count while keeping the 128-light uniform default', () => {
154
- const uniformPayload = new Uint32Array(
155
- packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256),
156
- );
157
- const storagePayload = new Uint32Array(
158
- packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256, 256),
159
- );
160
- expect(uniformPayload[3]).toBe(128);
161
- expect(storagePayload[3]).toBe(256);
153
+ it('preserves the full 256-light Standard count in the cluster UBO', () => {
154
+ const payload = new Uint32Array(packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256));
155
+ expect(payload[3]).toBe(256);
162
156
  });
163
157
 
164
158
  it('keeps enabled SSAO intensity in the zero-punctual-light uniform path', () => {
@@ -209,12 +203,12 @@ describe('HDRP GPU membership producer bind-group contract', () => {
209
203
 
210
204
  describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
211
205
  it('descriptor entries.length === 7 (5 cluster + 2 ssao)', () => {
212
- const desc = createHdrpBindGroupLayoutDescriptor(true);
206
+ const desc = createHdrpBindGroupLayoutDescriptor();
213
207
  expect(desc.entries?.length).toBe(7);
214
208
  });
215
209
 
216
210
  it('binding 7 is texture_2d<f32> with FRAGMENT visibility (ssaoBlurred)', () => {
217
- const desc = createHdrpBindGroupLayoutDescriptor(true);
211
+ const desc = createHdrpBindGroupLayoutDescriptor();
218
212
  const b7 = desc.entries?.find((e) => e.binding === 7);
219
213
  expect(b7).toBeDefined();
220
214
  expect(b7?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -223,7 +217,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
223
217
  });
224
218
 
225
219
  it('binding 8 is sampler with FRAGMENT visibility (ssaoSampler)', () => {
226
- const desc = createHdrpBindGroupLayoutDescriptor(true);
220
+ const desc = createHdrpBindGroupLayoutDescriptor();
227
221
  const b8 = desc.entries?.find((e) => e.binding === 8);
228
222
  expect(b8).toBeDefined();
229
223
  expect(b8?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -231,13 +225,13 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
231
225
  });
232
226
 
233
227
  it('binding 9 is absent (scope-amend-webgl2-ubo: intensity folded into binding 6)', () => {
234
- const desc = createHdrpBindGroupLayoutDescriptor(true);
228
+ const desc = createHdrpBindGroupLayoutDescriptor();
235
229
  const bindings = desc.entries?.map((e) => e.binding) ?? [];
236
230
  expect(bindings).not.toContain(9);
237
231
  });
238
232
 
239
233
  it('existing bindings 0/3/4/5/6 remain unchanged', () => {
240
- const desc = createHdrpBindGroupLayoutDescriptor(true);
234
+ const desc = createHdrpBindGroupLayoutDescriptor();
241
235
  const b0 = desc.entries?.find((e) => e.binding === 0);
242
236
  expect(b0?.buffer?.hasDynamicOffset).toBe(true);
243
237
  for (const b of [3, 4, 5, 6]) {
@@ -247,7 +241,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
247
241
  });
248
242
 
249
243
  it('all bindings exactly { 0, 3, 4, 5, 6, 7, 8 }', () => {
250
- const desc = createHdrpBindGroupLayoutDescriptor(true);
244
+ const desc = createHdrpBindGroupLayoutDescriptor();
251
245
  const bindings = new Set(desc.entries?.map((e) => e.binding));
252
246
  expect(bindings).toEqual(new Set([0, 3, 4, 5, 6, 7, 8]));
253
247
  });
@@ -63,7 +63,14 @@ interface SpyCtx {
63
63
  }
64
64
 
65
65
  function makeDispatchSpyCtx(
66
- opts: { ssaoCalcPipeline?: unknown; ssaoBlurPipeline?: unknown; ssaoBgl?: unknown } = {},
66
+ opts: {
67
+ ssaoCalcPipeline?: unknown;
68
+ ssaoBlurPipeline?: unknown;
69
+ ssaoBgl?: unknown;
70
+ fxaaPipeline?: unknown;
71
+ fxaaBindGroupLayout?: unknown;
72
+ fxaaSampler?: unknown;
73
+ } = {},
67
74
  ): SpyCtx {
68
75
  const spy: DispatchSpy = {
69
76
  beginRenderPassCalls: [],
@@ -138,10 +145,12 @@ function makeDispatchSpyCtx(
138
145
  removeEventListener: vi.fn(),
139
146
  };
140
147
 
148
+ const fxaaParamsBuffer = { __label: 'fxaa-params' };
141
149
  const runtime = {
142
150
  device,
143
151
  errorRegistry,
144
152
  shaderCache: { get: vi.fn() },
153
+ getPostProcessParamsBuffer: vi.fn(() => fxaaParamsBuffer),
145
154
  } as unknown as RenderSystemRuntime;
146
155
 
147
156
  // Pre-seed ssao-buffers cache so the record closure resolves a stable
@@ -162,6 +171,13 @@ function makeDispatchSpyCtx(
162
171
  ssaoFilteringSampler: null,
163
172
  ssaoDepthSampler: null,
164
173
  ssaoFallbackRawView: null,
174
+ // FXAA is part of the same Standard post chain. Keep the fixture's
175
+ // fullscreen resources complete so recordAll exercises the real FXAA
176
+ // bind-group path instead of returning early at its capability gate.
177
+ fxaaPipeline: 'fxaaPipeline' in opts ? opts.fxaaPipeline : { __label: 'fxaa-pipeline' },
178
+ fxaaBindGroupLayout:
179
+ 'fxaaBindGroupLayout' in opts ? opts.fxaaBindGroupLayout : { __label: 'fxaa-bgl' },
180
+ fxaaSampler: 'fxaaSampler' in opts ? opts.fxaaSampler : { __label: 'fxaa-sampler' },
165
181
  },
166
182
  };
167
183
 
@@ -215,7 +231,6 @@ function makeDispatchSpyCtx(
215
231
  meshBindGroup: null,
216
232
  frameState: {
217
233
  perFrameGraph: mockPerFrameGraph,
218
- isHdrpActive: true,
219
234
  installedPipelineConfig: { ssao: { enabled: true, intensity: 1.0 } },
220
235
  // Post-process bind group identity cache (bloom / fxaa / ssao). Keyed on
221
236
  // the graph-resolved views so resize rebuilds automatically.
@@ -673,6 +688,7 @@ function makePostProcessRecordCtx(): {
673
688
  draw: vi.fn(),
674
689
  end: vi.fn(),
675
690
  };
691
+ const fxaaParamsBuffer = { label: 'fxaa-params' };
676
692
  const device = {
677
693
  createBindGroup: vi.fn((desc: PostProcessBindGroupCreate) => {
678
694
  bindGroupCreates.push(desc);
@@ -711,6 +727,7 @@ function makePostProcessRecordCtx(): {
711
727
  runtime: {
712
728
  device: { ...device, caps: { storageBuffer: true } },
713
729
  errorRegistry: { fire: vi.fn() },
730
+ getPostProcessParamsBuffer: vi.fn(() => fxaaParamsBuffer),
714
731
  },
715
732
  store: { getCubemapGpuView: vi.fn() },
716
733
  encoder: {
@@ -120,6 +120,7 @@ import { INSTANCE_STORAGE_STRIDE_FLOATS } from '../../../render/src/record/mesh-
120
120
  import { selectSwapChainFormat } from '../../../render/src/render-system';
121
121
  import { createSkinPaletteAllocator } from '../../../render/src/systems/skin-palette-allocator';
122
122
  import type { TransparentEntry } from '../../../render/src/systems/transparent-sort-config';
123
+ import { createTemporalFrameTransaction } from '../../../render/src/temporal/frame';
123
124
  import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
124
125
  import { drawWithOwners } from './renderer-test-utils';
125
126
 
@@ -447,9 +448,9 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
447
448
  ];
448
449
  }
449
450
 
450
- describe('Camera schema (19 fields: 17 f32 + clearColor array + autoAspect bool after w9 + tonemap-mvp + fxaa + bloom + clearColor + aspect-sync extensions)', () => {
451
- it('Camera.schema has 19 fields (17 f32 + clearColor array + autoAspect bool: perspective quartet + projection + ortho quartet + tonemap trio + antialias + bloom quartet + clearColor + autoAspect)', () => {
452
- expect(Object.keys(Camera.fields).length).toBe(20);
451
+ describe('Camera schema (21 fields: 17 f32 + clearColor array + autoAspect bool + target after render-target extension)', () => {
452
+ it('Camera.schema has 21 fields (17 f32 + clearColor array + autoAspect bool + target: perspective quartet + projection + ortho quartet + tonemap trio + antialias + bloom quartet + clearColor + autoAspect)', () => {
453
+ expect(Object.keys(Camera.fields).length).toBe(21);
453
454
  expect(componentFieldType(Camera, 'fov')).toBe('f32');
454
455
  expect(componentFieldType(Camera, 'aspect')).toBe('f32');
455
456
  expect(componentFieldType(Camera, 'near')).toBe('f32');
@@ -475,6 +476,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
475
476
  // feat-20260617-host-engine-contract-and-video-cutscene / M3: aspect-sync
476
477
  // opt-out flag (bool column tier).
477
478
  expect(componentFieldType(Camera, 'autoAspect')).toBe('bool');
479
+ expect(componentFieldType(Camera, 'target')).toBe('shared<RenderTarget>');
478
480
  });
479
481
  });
480
482
 
@@ -573,6 +575,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
573
575
  directionalCount: 0,
574
576
  point: [],
575
577
  spot: [],
578
+ directionalShadowError: undefined,
576
579
  lightSpaceMatrix: undefined,
577
580
  shadowMapSize: undefined,
578
581
  pointShadow: [],
@@ -762,8 +765,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
762
765
  [],
763
766
  {
764
767
  frameNumber: 0,
768
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
769
+ temporalFrameInput: undefined,
765
770
  compiledFrameGraph: null,
766
771
  compiledFrameGraphTopologyKey: null,
772
+ compiledFrameGraphGeneration: 0,
767
773
  retiredCompiledFrameGraphs: new Set(),
768
774
  instanceBuffers: new Map(),
769
775
  transientInstanceBuffers: [],
@@ -784,8 +790,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
784
790
  installedPipelineHandle: 0,
785
791
  activePipeline: urpPipeline,
786
792
  installedPipelineConfig: undefined,
787
- isHdrpActive: false,
788
- hdrpOncePerFrameFired: new Set(),
793
+ standardOncePerFrameFired: new Set(),
789
794
  },
790
795
  { unlit: 0 },
791
796
  { createBindGroup: 0, keys: [] },
@@ -817,8 +822,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
817
822
  [],
818
823
  {
819
824
  frameNumber: 0,
825
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
826
+ temporalFrameInput: undefined,
820
827
  compiledFrameGraph: null,
821
828
  compiledFrameGraphTopologyKey: null,
829
+ compiledFrameGraphGeneration: 0,
822
830
  retiredCompiledFrameGraphs: new Set(),
823
831
  instanceBuffers: new Map(),
824
832
  transientInstanceBuffers: [],
@@ -839,8 +847,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
839
847
  installedPipelineHandle: 0,
840
848
  activePipeline: urpPipeline,
841
849
  installedPipelineConfig: undefined,
842
- isHdrpActive: false,
843
- hdrpOncePerFrameFired: new Set(),
850
+ standardOncePerFrameFired: new Set(),
844
851
  },
845
852
  { unlit: 0 },
846
853
  { createBindGroup: 0, keys: [] },
@@ -909,8 +916,9 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
909
916
  // --- from fxaa-pipeline.test.ts ---
910
917
 
911
918
  describe('feat-20260528-fxaa-post-processing M2 w8: FXAA pipeline construction (success path)', () => {
912
- it('D-2: FXAA BGL has exactly 2 entries: @binding(0) texture_2d<f32> + @binding(1) sampler, no UBO', () => {
913
- // plan-strategy D-2 specifies 2-entry BGL with no UBO entry.
919
+ it('D-2: FXAA BGL has exactly 3 entries: texture + sampler + dither policy UBO', () => {
920
+ // FXAA owns a per-pass output-policy UBO so pipeline asset config can
921
+ // disable dither without rebuilding the shader or graph topology.
914
922
  // The sampler entry uses { type: 'filtering' } (linear sampling).
915
923
  const bglEntries = [
916
924
  {
@@ -923,15 +931,21 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
923
931
  visibility: 2, // GPU_SHADER_STAGE_FRAGMENT = 0x2
924
932
  sampler: { type: 'filtering' },
925
933
  },
934
+ {
935
+ binding: 2,
936
+ visibility: 2,
937
+ buffer: { type: 'uniform' },
938
+ },
926
939
  ];
927
- expect(bglEntries).toHaveLength(2);
940
+ expect(bglEntries).toHaveLength(3);
928
941
  expect(bglEntries[0]?.binding).toBe(0);
929
942
  expect(bglEntries[0]?.texture).toBeDefined();
930
943
  expect(bglEntries[1]?.binding).toBe(1);
931
944
  expect(bglEntries[1]?.sampler).toBeDefined();
932
- // D-2 explicitly: no UBO entry (no buffer-type binding).
945
+ expect(bglEntries[2]?.binding).toBe(2);
946
+ expect(bglEntries[2]?.buffer).toEqual({ type: 'uniform' });
933
947
  const hasBuffer = bglEntries.some((e) => 'buffer' in (e as Record<string, unknown>));
934
- expect(hasBuffer).toBe(false);
948
+ expect(hasBuffer).toBe(true);
935
949
  });
936
950
 
937
951
  it('D-3: FXAA pipeline target format = swap-chain storage format (non-srgb, helper Channel 2 truth)', () => {
@@ -1915,10 +1929,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1915
1929
  return 'mesh-ssbo-capacity';
1916
1930
  case 'mesh-ssbo-ceiling-reached':
1917
1931
  return 'mesh-ssbo-ceiling';
1918
- case 'hdrp-light-budget-exceeded':
1919
- return 'hdrp-budget';
1920
- case 'hdrp-index-list-overflow':
1921
- return 'hdrp-overflow';
1932
+ case 'standard-light-budget-exceeded':
1933
+ return 'standard-budget';
1934
+ case 'standard-cluster-index-overflow':
1935
+ return 'standard-overflow';
1922
1936
  }
1923
1937
  }
1924
1938
  expect(exhaustive('skin-joint-count-exceeded')).toBe('joint-count');
@@ -2081,8 +2095,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2081
2095
  );
2082
2096
  });
2083
2097
 
2084
- it('imports View from forgeax_view::common', () => {
2085
- expect(source).toEqual(expect.stringContaining('#import forgeax_view::common::{View'));
2098
+ it('imports View from forgeax_view::common as the sole view ABI', () => {
2099
+ expect(source).toEqual(expect.stringContaining('#import forgeax_view::common::View'));
2100
+ expect(source).not.toContain('FogRay');
2101
+ expect(source).not.toContain('fogRayFromNdc');
2086
2102
  });
2087
2103
 
2088
2104
  it('imports fullscreen_triangle from forgeax_view::common', () => {
@@ -3179,6 +3195,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3179
3195
  directionalCount: 0,
3180
3196
  point: [],
3181
3197
  spot: [],
3198
+ directionalShadowError: undefined,
3182
3199
  lightSpaceMatrix: undefined,
3183
3200
  shadowMapSize: undefined,
3184
3201
  pointShadow: [],
@@ -3361,11 +3378,12 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3361
3378
  }
3362
3379
 
3363
3380
  function makePipelineState(
3364
- internals: { _fakes: Record<string, unknown> },
3381
+ internals: { _fakes: Record<string, unknown>; device?: unknown },
3365
3382
  hdrAlreadyAllocated: boolean,
3366
3383
  ): unknown {
3367
3384
  const f = internals._fakes;
3368
3385
  return {
3386
+ device: internals.device,
3369
3387
  meshes: new Map(),
3370
3388
  format: 'bgra8unorm',
3371
3389
  colorAttachmentFormat: 'bgra8unorm-srgb',
@@ -3450,8 +3468,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3450
3468
  [],
3451
3469
  {
3452
3470
  frameNumber: 0,
3471
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3472
+ temporalFrameInput: undefined,
3453
3473
  compiledFrameGraph: null,
3454
3474
  compiledFrameGraphTopologyKey: null,
3475
+ compiledFrameGraphGeneration: 0,
3455
3476
  retiredCompiledFrameGraphs: new Set(),
3456
3477
  instanceBuffers: new Map(),
3457
3478
  transientInstanceBuffers: [],
@@ -3472,8 +3493,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3472
3493
  installedPipelineHandle: 0,
3473
3494
  activePipeline: urpPipeline,
3474
3495
  installedPipelineConfig: undefined,
3475
- isHdrpActive: false,
3476
- hdrpOncePerFrameFired: new Set(),
3496
+ standardOncePerFrameFired: new Set(),
3477
3497
  },
3478
3498
  { unlit: 0 },
3479
3499
  { createBindGroup: 0, keys: [] },
@@ -3569,8 +3589,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3569
3589
  [],
3570
3590
  {
3571
3591
  frameNumber: 0,
3592
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3593
+ temporalFrameInput: undefined,
3572
3594
  compiledFrameGraph: null,
3573
3595
  compiledFrameGraphTopologyKey: null,
3596
+ compiledFrameGraphGeneration: 0,
3574
3597
  retiredCompiledFrameGraphs: new Set(),
3575
3598
  instanceBuffers,
3576
3599
  warnedZeroLightStandard: false,
@@ -3590,8 +3613,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3590
3613
  installedPipelineHandle: 0,
3591
3614
  activePipeline: urpPipeline,
3592
3615
  installedPipelineConfig: undefined,
3593
- isHdrpActive: false,
3594
- hdrpOncePerFrameFired: new Set(),
3616
+ standardOncePerFrameFired: new Set(),
3595
3617
  },
3596
3618
  { unlit: 0 },
3597
3619
  { createBindGroup: 0, keys: [] },
@@ -3642,8 +3664,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3642
3664
  [],
3643
3665
  {
3644
3666
  frameNumber: 0,
3667
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3668
+ temporalFrameInput: undefined,
3645
3669
  compiledFrameGraph: null,
3646
3670
  compiledFrameGraphTopologyKey: null,
3671
+ compiledFrameGraphGeneration: 0,
3647
3672
  retiredCompiledFrameGraphs: new Set(),
3648
3673
  instanceBuffers,
3649
3674
  warnedZeroLightStandard: false,
@@ -3663,8 +3688,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3663
3688
  installedPipelineHandle: 0,
3664
3689
  activePipeline: urpPipeline,
3665
3690
  installedPipelineConfig: undefined,
3666
- isHdrpActive: false,
3667
- hdrpOncePerFrameFired: new Set(),
3691
+ standardOncePerFrameFired: new Set(),
3668
3692
  },
3669
3693
  { unlit: 0 },
3670
3694
  { createBindGroup: 0, keys: [] },
@@ -8323,10 +8347,10 @@ type ExtractFramesWithPipeline = (
8323
8347
  }
8324
8348
 
8325
8349
  // ── feat-20260612-skin-palette-per-frame-upload M3 / m3-1: record-stage ──
8326
- // dynOffset[1] truth-value + skin BG cache miss=1 + hit=N-1 stats counter.
8350
+ // dynOffset[1]/[2] truth-value + skin BG cache miss=1 + hit=N-1 stats counter.
8327
8351
  //
8328
8352
  // Why this block exists:
8329
- // M3 lifts the record-stage `group2DynamicOffsets` second slot from a
8353
+ // M3 lifts the record-stage `group2DynamicOffsets` palette slots from a
8330
8354
  // hard-coded `0` (PR #353 stub) to the per-entity `entry.source.skin
8331
8355
  // .byteOffset` cursor that M2 m2-6 wired into the extract output. The
8332
8356
  // acceptanceCheck calls for the literal text `group2DynamicOffsets[1]
@@ -8338,8 +8362,8 @@ type ExtractFramesWithPipeline = (
8338
8362
  // What the assertions cover:
8339
8363
  // (a) The test-local offset oracle mirrors the production main-pass
8340
8364
  // geometry contract. With skinByteOffset !== undefined the returned
8341
- // tuple's [1] slot equals the supplied byteOffset (the contract:
8342
- // `group2DynamicOffsets[1] === byteOffset`); with skinByteOffset ===
8365
+ // tuple's [1] and [2] slots equal the supplied byteOffset (the
8366
+ // temporal contract); with skinByteOffset ===
8343
8367
  // undefined it returns a length-1 tuple (non-skin paths preserved).
8344
8368
  // (b) N=3 sequential lookups
8345
8369
  // against the same `(meshStorageBuffer, skinPaletteAllocator
@@ -8366,7 +8390,9 @@ type ExtractFramesWithPipeline = (
8366
8390
  skinByteOffset: number | undefined,
8367
8391
  ): readonly number[] => {
8368
8392
  const meshOffset = meshSlotIdx * MESH_PER_ENTITY_STRIDE;
8369
- return skinByteOffset === undefined ? [meshOffset] : [meshOffset, skinByteOffset];
8393
+ return skinByteOffset === undefined
8394
+ ? [meshOffset]
8395
+ : [meshOffset, skinByteOffset, skinByteOffset];
8370
8396
  };
8371
8397
 
8372
8398
  // Skin path: second slot must equal the per-entity byteOffset cursor
@@ -8375,12 +8401,12 @@ type ExtractFramesWithPipeline = (
8375
8401
  // (first allocateSlice), cursor=256 (second 1-joint slice), cursor
8376
8402
  // =16320 (the worst-case 255-joint slice end).
8377
8403
  const skinAt0 = computeSkinGroup2DynOffsets(0, 0);
8378
- expect(skinAt0).toHaveLength(2);
8404
+ expect(skinAt0).toHaveLength(3);
8379
8405
  // group2DynamicOffsets[1] === byteOffset (literal assertion text per acceptanceCheck)
8380
8406
  expect(skinAt0[1]).toBe(0);
8381
8407
 
8382
8408
  const skinAt256 = computeSkinGroup2DynOffsets(1, 256);
8383
- expect(skinAt256).toHaveLength(2);
8409
+ expect(skinAt256).toHaveLength(3);
8384
8410
  // group2DynamicOffsets[1] === byteOffset
8385
8411
  expect(skinAt256[1]).toBe(256);
8386
8412
  // mesh slot continues to follow the 256-byte stride: meshSlotIdx * 256.
@@ -8388,7 +8414,7 @@ type ExtractFramesWithPipeline = (
8388
8414
 
8389
8415
  const byteOffsetWorst = 254 * 256;
8390
8416
  const skinAtEnd = computeSkinGroup2DynOffsets(2, byteOffsetWorst);
8391
- expect(skinAtEnd).toHaveLength(2);
8417
+ expect(skinAtEnd).toHaveLength(3);
8392
8418
  // group2DynamicOffsets[1] === byteOffset
8393
8419
  expect(skinAtEnd[1]).toBe(byteOffsetWorst);
8394
8420
  expect(skinAtEnd[0]).toBe(2 * 256);
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ const TEXTURE_BINDING = 0x04;
4
+ const COPY_DST = 0x08;
5
+ const COPY_SRC = 0x01;
6
+ const STORAGE_BINDING = 0x80;
7
+ const MAP_READ = 0x0001;
8
+ const COMPUTE_STAGE = 1;
9
+
10
+ type ProbeResult =
11
+ | { readonly status: 'ready'; readonly samples: readonly (readonly number[])[] }
12
+ | { readonly status: 'unavailable'; readonly reason: string };
13
+
14
+ async function probe(): Promise<ProbeResult> {
15
+ if (!('gpu' in navigator)) return { status: 'unavailable', reason: 'webgpu-unavailable' };
16
+ const adapter = await navigator.gpu.requestAdapter();
17
+ if (adapter === null) return { status: 'unavailable', reason: 'adapter-unavailable' };
18
+ const device = await adapter.requestDevice();
19
+ const texture = device.createTexture({
20
+ size: { width: 2, height: 2, depthOrArrayLayers: 2 },
21
+ dimension: '3d',
22
+ format: 'rgba8unorm',
23
+ usage: TEXTURE_BINDING | COPY_DST,
24
+ });
25
+ const arrayTexture = device.createTexture({
26
+ size: { width: 2, height: 2, depthOrArrayLayers: 2 },
27
+ dimension: '2d',
28
+ format: 'rgba8unorm',
29
+ usage: TEXTURE_BINDING | COPY_DST,
30
+ });
31
+ const samples = new Uint8Array(256 * 2 * 2);
32
+ for (let point = 0; point < 8; point += 1) {
33
+ const z = Math.floor(point / 4);
34
+ const y = Math.floor((point % 4) / 2);
35
+ const x = point % 2;
36
+ const offset = z * 512 + y * 256 + x * 4;
37
+ samples[offset] = point + 1;
38
+ samples[offset + 3] = 255;
39
+ }
40
+ const layout = { offset: 0, bytesPerRow: 256, rowsPerImage: 2 };
41
+ const extent = { width: 2, height: 2, depthOrArrayLayers: 2 };
42
+ device.queue.writeTexture({ texture: arrayTexture }, samples, layout, extent);
43
+ device.queue.writeTexture({ texture }, samples, layout, extent);
44
+ const output = device.createBuffer({ size: 256, usage: STORAGE_BINDING | COPY_SRC });
45
+ const readback = device.createBuffer({ size: 256, usage: MAP_READ | COPY_DST });
46
+ const module = device.createShaderModule({
47
+ code: `
48
+ @group(0) @binding(0) var array_tex: texture_2d_array<f32>;
49
+ @group(0) @binding(1) var volume_tex: texture_3d<f32>;
50
+ @group(0) @binding(2) var<storage, read_write> output: array<vec4<u32>>;
51
+ fn to_byte(value: f32) -> u32 {
52
+ return u32(round(value * 255.0));
53
+ }
54
+ @compute @workgroup_size(1)
55
+ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
56
+ let point = id.x;
57
+ let coord = vec3<i32>(i32(point % 2u), i32((point / 2u) % 2u), i32(point / 4u));
58
+ let array_value = textureLoad(array_tex, coord.xy, coord.z, 0);
59
+ let volume_value = textureLoad(volume_tex, coord, 0);
60
+ output[point] = vec4<u32>(to_byte(array_value.r), 0u, 0u, 0u);
61
+ output[point + 8u] = vec4<u32>(to_byte(volume_value.r), 0u, 0u, 0u);
62
+ }
63
+ `,
64
+ });
65
+ const bindGroupLayout = device.createBindGroupLayout({
66
+ entries: [
67
+ { binding: 0, visibility: COMPUTE_STAGE, texture: { viewDimension: '2d-array' } },
68
+ { binding: 1, visibility: COMPUTE_STAGE, texture: { viewDimension: '3d' } },
69
+ {
70
+ binding: 2,
71
+ visibility: COMPUTE_STAGE,
72
+ buffer: { type: 'storage' },
73
+ },
74
+ ],
75
+ });
76
+ const pipeline = device.createComputePipeline({
77
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
78
+ compute: { module, entryPoint: 'main' },
79
+ });
80
+ const bindGroup = device.createBindGroup({
81
+ layout: bindGroupLayout,
82
+ entries: [
83
+ { binding: 0, resource: arrayTexture.createView({ dimension: '2d-array' }) },
84
+ { binding: 1, resource: texture.createView({ dimension: '3d' }) },
85
+ { binding: 2, resource: { buffer: output } },
86
+ ],
87
+ });
88
+ const encoder = device.createCommandEncoder();
89
+ const pass = encoder.beginComputePass();
90
+ pass.setPipeline(pipeline);
91
+ pass.setBindGroup(0, bindGroup);
92
+ pass.dispatchWorkgroups(8);
93
+ pass.end();
94
+ encoder.copyBufferToBuffer(output, 0, readback, 0, 256);
95
+ device.queue.submit([encoder.finish()]);
96
+ await readback.mapAsync(MAP_READ);
97
+ const actual = new Uint8Array(readback.getMappedRange()).slice();
98
+ readback.unmap();
99
+ const observed = Array.from({ length: 8 }, (_, point) => [
100
+ actual[point * 16] ?? 0,
101
+ actual[(point + 8) * 16] ?? 0,
102
+ ]);
103
+ const expected = Array.from({ length: 8 }, (_, point) => point + 1);
104
+ if (
105
+ observed.some(
106
+ ([arraySample, volumeSample], point) =>
107
+ arraySample !== expected[point] || volumeSample !== expected[point],
108
+ )
109
+ ) {
110
+ return { status: 'unavailable', reason: 'gpu-readback-mismatch' };
111
+ }
112
+ return { status: 'ready', samples: observed };
113
+ }
114
+
115
+ describe('browser texture dimensions', () => {
116
+ it('returns an observable WebGPU status instead of silently accepting a bad view', async () => {
117
+ const result = await probe();
118
+ expect(['ready', 'unavailable']).toContain(result.status);
119
+ if (result.status === 'unavailable') expect(result.reason.length).toBeGreaterThan(0);
120
+ if (result.status === 'ready') {
121
+ expect(result.samples).toHaveLength(8);
122
+ expect(
123
+ result.samples.map(([arraySample, volumeSample]) => [arraySample, volumeSample]),
124
+ ).toEqual(Array.from({ length: 8 }, (_, point) => [point + 1, point + 1]));
125
+ }
126
+ });
127
+ });
@@ -126,7 +126,11 @@ function wireFetch(
126
126
  {
127
127
  guid: GUID_TEX,
128
128
  kind: 'texture',
129
- payload: { width: 4, height: 4, colorSpace },
129
+ payload: {
130
+ shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
131
+ colorSpace,
132
+ mips: { kind: 'none' },
133
+ },
130
134
  refs: [],
131
135
  artifacts: {
132
136
  body: {
@@ -1,6 +1,7 @@
1
1
  // view-ubo-layout.browser.test.ts - feat-20260518-pbr-direct-lighting-mvp
2
- // feat-20260531-skybox-env-background M2 / w4 (TDD red): VIEW_UBO_BYTES
3
- // 112 -> 176 -> 240 layout (worldViewProj 64 B + lightDir 16 B + lightColor
2
+ // feat-20260827-directional-csm-pcss-quality M1: VIEW_UBO_BYTES remains 960
3
+ // while the View slot remains 1024 B. The prefix layout is unchanged and the
4
+ // directional shadow carrier reuses the existing tail pad.
4
5
  // 16 B + cameraPos 16 B + lightSpaceMatrix 64 B + inverseViewProj 64 B;
5
6
  // std140 vec3 padded to 16-byte boundaries).
6
7
  //
@@ -44,14 +45,16 @@ const VIEW_UBO_OFFSETS = {
44
45
  lightSpaceMatrix: 112,
45
46
  inverseViewProj: 176,
46
47
  } as const;
47
- const VIEW_UBO_BYTES = 240;
48
+ const VIEW_UBO_BYTES = 960;
49
+ const DIRECTIONAL_FILTER_OFFSET = 512;
50
+ const SPOT_LIGHT_VIEW_PROJ_OFFSET = 528;
48
51
 
49
52
  describe('w4 view UBO 240 B std140 layout (AC-06, browser)', () => {
50
- it.skipIf(!browserReady)('host write payload is exactly 240 bytes (60 floats)', () => {
51
- // The host (render-system-record.ts) builds a Float32Array(60) and
53
+ it.skipIf(!browserReady)('host write payload is exactly 960 bytes (240 floats)', () => {
54
+ // The host (view-ubo.ts) builds a Float32Array(240) and
52
55
  // emits a single queue.writeBuffer. Asserting the size lock here keeps
53
56
  // the host shape and shader ABI in agreement before the GPU sees it.
54
- const payload = new Float32Array(60);
57
+ const payload = new Float32Array(240);
55
58
  expect(payload.byteLength).toBe(VIEW_UBO_BYTES);
56
59
  });
57
60
 
@@ -63,17 +66,17 @@ describe('w4 view UBO 240 B std140 layout (AC-06, browser)', () => {
63
66
  expect(VIEW_UBO_OFFSETS.lightSpaceMatrix).toBe(112);
64
67
  expect(VIEW_UBO_OFFSETS.inverseViewProj).toBe(176);
65
68
  // Buffer total = inverseViewProj offset + mat4 (64 B) = 240.
66
- expect(VIEW_UBO_OFFSETS.inverseViewProj + 64).toBe(VIEW_UBO_BYTES);
69
+ expect(VIEW_UBO_OFFSETS.inverseViewProj + 64).toBe(240);
67
70
  });
68
71
 
69
72
  it.skipIf(!browserReady)(
70
73
  'host packs lightDir / lightColor / cameraPos at canonical std140 slots',
71
74
  () => {
72
75
  // Canonical packing order documented in plan-strategy D-4: the host
73
- // builds a 60-float buffer mirroring the shader-side `View` struct
76
+ // builds a 240-float buffer mirroring the shader-side `View` struct
74
77
  // field order. AC-06: existing field offsets unchanged -- tail append
75
78
  // only.
76
- const payload = new Float32Array(60);
79
+ const payload = new Float32Array(240);
77
80
  // worldViewProj = identity for this assertion (mat4 in [0..16))
78
81
  payload[0] = 1; // m00
79
82
  payload[5] = 1; // m11
@@ -133,7 +136,7 @@ describe('w4 view UBO 240 B std140 layout (AC-06, browser)', () => {
133
136
  // calculation.
134
137
  const inverseProjF32Index = VIEW_UBO_OFFSETS.inverseViewProj / 4;
135
138
  expect(inverseProjF32Index).toBe(44);
136
- const payload = new Float32Array(60);
139
+ const payload = new Float32Array(240);
137
140
  // Place an identity mat4 at inverseViewProj slot [44..59]
138
141
  payload[44] = 1;
139
142
  payload[49] = 1;
@@ -145,6 +148,13 @@ describe('w4 view UBO 240 B std140 layout (AC-06, browser)', () => {
145
148
  expect(payload[59]).toBe(1);
146
149
  });
147
150
 
151
+ it.skipIf(!browserReady)('shadow tail keeps the directional carrier before spot matrices', () => {
152
+ expect(DIRECTIONAL_FILTER_OFFSET).toBe(126 * 4 + 8);
153
+ expect(SPOT_LIGHT_VIEW_PROJ_OFFSET).toBe(132 * 4);
154
+ expect(SPOT_LIGHT_VIEW_PROJ_OFFSET - DIRECTIONAL_FILTER_OFFSET).toBe(16);
155
+ expect(SPOT_LIGHT_VIEW_PROJ_OFFSET + 4 * 4 * 16).toBe(VIEW_UBO_BYTES - 176);
156
+ });
157
+
148
158
  it.skipIf(!browserReady)(
149
159
  'existing field offsets unchanged by tail append (AC-06 rear-append guarantee)',
150
160
  () => {