@forgeax/engine-render 0.1.19 → 0.1.20

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 (174) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/assembly/factory.d.ts +16 -5
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
  5. package/dist/chunk-4XINDLAY.mjs.map +1 -0
  6. package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
  7. package/dist/chunk-CDTWTHZR.mjs.map +1 -0
  8. package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
  9. package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
  10. package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
  11. package/dist/chunk-TBQJOK2L.mjs.map +1 -0
  12. package/dist/components/light-helpers.d.ts.map +1 -1
  13. package/dist/components/spot-light.d.ts +12 -0
  14. package/dist/components/spot-light.d.ts.map +1 -1
  15. package/dist/construct-renderer.mjs +2281 -164
  16. package/dist/construct-renderer.mjs.map +1 -1
  17. package/dist/device/gpu-residency.d.ts +19 -2
  18. package/dist/device/gpu-residency.d.ts.map +1 -1
  19. package/dist/errors/render.d.ts +69 -1
  20. package/dist/errors/render.d.ts.map +1 -1
  21. package/dist/gpu-usage.d.ts +2 -0
  22. package/dist/gpu-usage.d.ts.map +1 -1
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.mjs +3 -3
  26. package/dist/internal.mjs +1 -1
  27. package/dist/light-buffer-layout.d.ts +20 -12
  28. package/dist/light-buffer-layout.d.ts.map +1 -1
  29. package/dist/pbr-pipeline.d.ts +6 -3
  30. package/dist/pbr-pipeline.d.ts.map +1 -1
  31. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  32. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  33. package/dist/pipeline/standard-profile.d.ts +13 -0
  34. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  35. package/dist/record/frame-lighting.d.ts +2 -0
  36. package/dist/record/frame-lighting.d.ts.map +1 -1
  37. package/dist/record/frame-snapshot.d.ts +37 -1
  38. package/dist/record/frame-snapshot.d.ts.map +1 -1
  39. package/dist/record/frame-targets.d.ts.map +1 -1
  40. package/dist/record/frame.d.ts +3 -2
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/gpu-timing.d.ts +32 -0
  43. package/dist/record/gpu-timing.d.ts.map +1 -0
  44. package/dist/record/main-pass-material.d.ts +1 -1
  45. package/dist/record/main-pass-material.d.ts.map +1 -1
  46. package/dist/record/render-context.d.ts +5 -1
  47. package/dist/record/render-context.d.ts.map +1 -1
  48. package/dist/record/shadow-pass.d.ts.map +1 -1
  49. package/dist/record/typed-frame-graph.d.ts +11 -3
  50. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  51. package/dist/render-contract.d.ts +66 -1
  52. package/dist/render-contract.d.ts.map +1 -1
  53. package/dist/render-data.d.ts +10 -6
  54. package/dist/render-data.d.ts.map +1 -1
  55. package/dist/render-pipeline.d.ts +36 -0
  56. package/dist/render-pipeline.d.ts.map +1 -1
  57. package/dist/render-system-extract.d.ts +55 -3
  58. package/dist/render-system-extract.d.ts.map +1 -1
  59. package/dist/render-system.d.ts +7 -1
  60. package/dist/render-system.d.ts.map +1 -1
  61. package/dist/renderer-inspect.d.ts +29 -0
  62. package/dist/renderer-inspect.d.ts.map +1 -0
  63. package/dist/scene/render-scene.d.ts.map +1 -1
  64. package/dist/temporal/frame.d.ts +44 -0
  65. package/dist/temporal/frame.d.ts.map +1 -0
  66. package/dist/temporal/index.mjs +3 -3
  67. package/dist/typed-render-graph-primitives.d.ts +6 -1
  68. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  69. package/dist/typed-shadow-atlas.d.ts +10 -0
  70. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  71. package/dist/typed-shadow-passes.d.ts.map +1 -1
  72. package/dist/volume/capability.d.ts +57 -0
  73. package/dist/volume/capability.d.ts.map +1 -0
  74. package/dist/volume/component.d.ts +61 -0
  75. package/dist/volume/component.d.ts.map +1 -0
  76. package/dist/volume/extract.d.ts +11 -0
  77. package/dist/volume/extract.d.ts.map +1 -0
  78. package/dist/volume/inspection.d.ts +38 -0
  79. package/dist/volume/inspection.d.ts.map +1 -0
  80. package/dist/volume/passes.d.ts +38 -0
  81. package/dist/volume/passes.d.ts.map +1 -0
  82. package/dist/volume/recovery.d.ts +38 -0
  83. package/dist/volume/recovery.d.ts.map +1 -0
  84. package/dist/volume/resources.d.ts +57 -0
  85. package/dist/volume/resources.d.ts.map +1 -0
  86. package/dist/volume/temporal.d.ts +35 -0
  87. package/dist/volume/temporal.d.ts.map +1 -0
  88. package/package.json +20 -20
  89. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  90. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  91. package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
  92. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
  93. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  94. package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
  95. package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
  96. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  97. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  98. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  99. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  100. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  101. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  102. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  103. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  104. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  105. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  106. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  107. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  108. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  109. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  110. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  111. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  112. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  113. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  114. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  115. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  116. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  117. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  118. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  119. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  120. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  121. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  122. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  123. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  124. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  125. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  126. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  127. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  128. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  129. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  130. package/src/assembly/factory.ts +271 -15
  131. package/src/components/light-helpers.ts +4 -0
  132. package/src/components/spot-light.ts +22 -3
  133. package/src/device/gpu-residency.ts +168 -58
  134. package/src/errors/render.ts +119 -1
  135. package/src/gpu-usage.ts +2 -0
  136. package/src/index.ts +38 -1
  137. package/src/light-buffer-layout.ts +59 -26
  138. package/src/pbr-pipeline.ts +33 -10
  139. package/src/pipeline/standard-forward-lane.ts +20 -0
  140. package/src/pipeline/standard-pipeline.ts +20 -0
  141. package/src/pipeline/standard-profile.ts +23 -0
  142. package/src/record/frame-lighting.ts +103 -89
  143. package/src/record/frame-snapshot.ts +41 -1
  144. package/src/record/frame-targets.ts +6 -2
  145. package/src/record/frame.ts +908 -5
  146. package/src/record/gpu-timing.ts +224 -0
  147. package/src/record/main-pass-material.ts +6 -2
  148. package/src/record/render-context.ts +5 -0
  149. package/src/record/shadow-pass.ts +87 -38
  150. package/src/record/typed-frame-graph.ts +143 -8
  151. package/src/render-contract.ts +72 -0
  152. package/src/render-data.ts +50 -22
  153. package/src/render-pipeline.ts +44 -0
  154. package/src/render-system-extract.ts +329 -3
  155. package/src/render-system.ts +79 -0
  156. package/src/renderer-inspect.ts +66 -0
  157. package/src/scene/render-scene.ts +2 -0
  158. package/src/temporal/frame.ts +128 -0
  159. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
  160. package/src/typed-render-graph-primitives.ts +32 -2
  161. package/src/typed-shadow-atlas.ts +17 -0
  162. package/src/typed-shadow-passes.ts +6 -5
  163. package/src/volume/capability.ts +173 -0
  164. package/src/volume/component.ts +110 -0
  165. package/src/volume/extract.ts +21 -0
  166. package/src/volume/inspection.ts +78 -0
  167. package/src/volume/passes.ts +680 -0
  168. package/src/volume/recovery.ts +86 -0
  169. package/src/volume/resources.ts +236 -0
  170. package/src/volume/temporal.ts +89 -0
  171. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  172. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  173. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  174. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -0,0 +1,224 @@
1
+ import type { Buffer, QuerySet, RhiCommandEncoder, RhiDevice, RhiError } from '@forgeax/engine-rhi';
2
+ import { ok, type Result } from '@forgeax/engine-types';
3
+ import {
4
+ GPU_BUFFER_USAGE_COPY_DST,
5
+ GPU_BUFFER_USAGE_COPY_SRC,
6
+ GPU_BUFFER_USAGE_MAP_READ,
7
+ GPU_BUFFER_USAGE_QUERY_RESOLVE,
8
+ } from '../gpu-usage';
9
+
10
+ const MAX_VOLUME_PASSES = 4;
11
+ const QUERY_COUNT = MAX_VOLUME_PASSES * 2;
12
+ const QUERY_RESULT_BYTES = 8;
13
+ const READBACK_BYTES = QUERY_COUNT * QUERY_RESULT_BYTES;
14
+ const PASS_NAMES = [
15
+ 'volume-inject',
16
+ 'volume-integrate',
17
+ 'volume-temporal',
18
+ 'volume-composite',
19
+ ] as const;
20
+
21
+ export type VolumeTimingObservation =
22
+ | {
23
+ readonly status: 'ready';
24
+ readonly unit: 'ms';
25
+ readonly totalMs: number;
26
+ readonly passes: readonly { readonly name: string; readonly milliseconds: number }[];
27
+ }
28
+ | {
29
+ readonly status: 'unavailable';
30
+ readonly reason: string;
31
+ };
32
+
33
+ function unavailable(reason: string): VolumeTimingObservation {
34
+ return Object.freeze({ status: 'unavailable', reason });
35
+ }
36
+
37
+ /**
38
+ * Renderer-owned timestamp capture for the authored volume graph. Query
39
+ * resources are private to one accepted submit and never cross the receipt
40
+ * boundary. This publishes raw renderer timings; a benchmark runner owns
41
+ * runner class, frame-count, and threshold qualification.
42
+ */
43
+ export class GpuTimingCapture {
44
+ readonly #device: RhiDevice;
45
+ readonly #querySet: QuerySet;
46
+ readonly #resolveBuffer: Buffer;
47
+ readonly #readbackBuffer: Buffer;
48
+ readonly #periodNanoseconds: number;
49
+ readonly #started = new Map<string, number>();
50
+ readonly #finished = new Map<string, number>();
51
+ #resolved = false;
52
+ #submitted = false;
53
+ #discarded = false;
54
+ #disposed = false;
55
+ #observation: Promise<VolumeTimingObservation> | undefined;
56
+
57
+ private constructor(
58
+ device: RhiDevice,
59
+ querySet: QuerySet,
60
+ resolveBuffer: Buffer,
61
+ readbackBuffer: Buffer,
62
+ periodNanoseconds: number,
63
+ ) {
64
+ this.#device = device;
65
+ this.#querySet = querySet;
66
+ this.#resolveBuffer = resolveBuffer;
67
+ this.#readbackBuffer = readbackBuffer;
68
+ this.#periodNanoseconds = periodNanoseconds;
69
+ }
70
+
71
+ static create(device: RhiDevice): Result<GpuTimingCapture | undefined, RhiError> {
72
+ const period = device.caps.timestampPeriodNanoseconds;
73
+ if (!device.caps.timestampQuery || period === null || !Number.isFinite(period) || period <= 0) {
74
+ return ok(undefined);
75
+ }
76
+ const querySet = device.createQuerySet({
77
+ label: 'volume-timing',
78
+ type: 'timestamp',
79
+ count: QUERY_COUNT,
80
+ });
81
+ if (!querySet.ok) return querySet;
82
+ const resolveBuffer = device.createBuffer({
83
+ label: 'volume-timing.resolve',
84
+ size: READBACK_BYTES,
85
+ usage: GPU_BUFFER_USAGE_QUERY_RESOLVE | GPU_BUFFER_USAGE_COPY_SRC,
86
+ });
87
+ if (!resolveBuffer.ok) {
88
+ device.destroyQuerySet(querySet.value);
89
+ return resolveBuffer;
90
+ }
91
+ const readbackBuffer = device.createBuffer({
92
+ label: 'volume-timing.readback',
93
+ size: READBACK_BYTES,
94
+ usage: GPU_BUFFER_USAGE_MAP_READ | GPU_BUFFER_USAGE_COPY_DST,
95
+ });
96
+ if (!readbackBuffer.ok) {
97
+ device.destroyBuffer(resolveBuffer.value);
98
+ device.destroyQuerySet(querySet.value);
99
+ return readbackBuffer;
100
+ }
101
+ return ok(
102
+ new GpuTimingCapture(
103
+ device,
104
+ querySet.value,
105
+ resolveBuffer.value,
106
+ readbackBuffer.value,
107
+ period,
108
+ ),
109
+ );
110
+ }
111
+
112
+ beforePass(name: string, encoder: RhiCommandEncoder): void {
113
+ const index = PASS_NAMES.indexOf(name as (typeof PASS_NAMES)[number]);
114
+ if (index < 0 || this.#started.has(name)) return;
115
+ this.#started.set(name, index);
116
+ encoder.writeTimestamp(this.#querySet, index * 2);
117
+ }
118
+
119
+ afterPass(name: string, encoder: RhiCommandEncoder): void {
120
+ const index = this.#started.get(name);
121
+ if (index === undefined || this.#finished.has(name)) return;
122
+ this.#finished.set(name, index);
123
+ encoder.writeTimestamp(this.#querySet, index * 2 + 1);
124
+ }
125
+
126
+ resolve(encoder: RhiCommandEncoder): Result<void, RhiError> {
127
+ if (this.#started.size === 0) {
128
+ this.#resolved = true;
129
+ return ok(undefined);
130
+ }
131
+ const resolved = encoder.resolveQuerySet(
132
+ this.#querySet,
133
+ 0,
134
+ QUERY_COUNT,
135
+ this.#resolveBuffer,
136
+ 0,
137
+ );
138
+ if (!resolved.ok) return resolved;
139
+ encoder.copyBufferToBuffer(this.#resolveBuffer, this.#readbackBuffer, READBACK_BYTES);
140
+ this.#resolved = true;
141
+ return ok(undefined);
142
+ }
143
+
144
+ markSubmitted(): void {
145
+ if (!this.#discarded) this.#submitted = true;
146
+ }
147
+
148
+ discard(): void {
149
+ if (this.#discarded) return;
150
+ this.#discarded = true;
151
+ this.#resolved = true;
152
+ this.#submitted = false;
153
+ this.#dispose();
154
+ }
155
+
156
+ observation(): Promise<VolumeTimingObservation> {
157
+ if (this.#observation !== undefined) return this.#observation;
158
+ if (this.#discarded || !this.#resolved || !this.#submitted || this.#started.size === 0) {
159
+ this.#observation = Promise.resolve(
160
+ unavailable('timestamp capture did not reach an accepted volume submit'),
161
+ );
162
+ this.#dispose();
163
+ return this.#observation;
164
+ }
165
+ this.#observation = this.#readbackBuffer
166
+ .mapAsync(GPU_BUFFER_USAGE_MAP_READ)
167
+ .then((mapped) => {
168
+ if (!mapped.ok) return unavailable(`timestamp readback failed: ${mapped.error.code}`);
169
+ const range = mapped.value.getMappedRange();
170
+ if (!range.ok) {
171
+ mapped.value.unmap();
172
+ return unavailable(`timestamp mapping failed: ${range.error.code}`);
173
+ }
174
+ const values = new DataView(range.value);
175
+ const decoded = [...this.#finished.entries()]
176
+ .sort((a, b) => a[1] - b[1])
177
+ .map(([name, index]) => ({
178
+ name,
179
+ begin: values.getBigUint64(index * 2 * QUERY_RESULT_BYTES, true),
180
+ end: values.getBigUint64((index * 2 + 1) * QUERY_RESULT_BYTES, true),
181
+ }));
182
+ if (decoded.some(({ end, begin }) => end < begin)) {
183
+ mapped.value.unmap();
184
+ this.#dispose();
185
+ return unavailable('timestamp query returned an end before its begin');
186
+ }
187
+ const passes = decoded.map(({ name, begin, end }) => ({
188
+ name,
189
+ milliseconds: (Number(end - begin) * this.#periodNanoseconds) / 1_000_000,
190
+ }));
191
+ const firstBegin = decoded.reduce(
192
+ (first, sample) => (sample.begin < first ? sample.begin : first),
193
+ decoded[0]?.begin ?? 0n,
194
+ );
195
+ const lastEnd = decoded.reduce(
196
+ (last, sample) => (sample.end > last ? sample.end : last),
197
+ decoded[0]?.end ?? 0n,
198
+ );
199
+ const totalMs = (Number(lastEnd - firstBegin) * this.#periodNanoseconds) / 1_000_000;
200
+ mapped.value.unmap();
201
+ this.#dispose();
202
+ if (
203
+ !Number.isFinite(totalMs) ||
204
+ passes.some((pass) => !Number.isFinite(pass.milliseconds))
205
+ ) {
206
+ return unavailable('timestamp query returned a non-finite duration');
207
+ }
208
+ return Object.freeze({ status: 'ready', unit: 'ms', totalMs, passes });
209
+ })
210
+ .catch((cause: unknown) => {
211
+ this.#dispose();
212
+ return unavailable(`timestamp readback failed: ${String(cause)}`);
213
+ });
214
+ return this.#observation;
215
+ }
216
+
217
+ #dispose(): void {
218
+ if (this.#disposed) return;
219
+ this.#disposed = true;
220
+ this.#device.destroyBuffer(this.#resolveBuffer);
221
+ this.#device.destroyBuffer(this.#readbackBuffer);
222
+ this.#device.destroyQuerySet(this.#querySet);
223
+ }
224
+ }
@@ -414,10 +414,14 @@ const DEFAULT_STANDARD_TEXTURE_COORDINATES = new Float32Array([
414
414
 
415
415
  /** Derives the logical-content UV scale for an uploaded material texture. */
416
416
  export function materialTextureUvScale(
417
- texture: Pick<TextureAsset, 'width' | 'height' | 'format'> | undefined,
417
+ texture: Pick<TextureAsset, 'shape' | 'format'> | undefined,
418
418
  ): readonly [number, number] {
419
419
  if (texture === undefined) return [1, 1];
420
- return deriveTextureExtent(texture.format, texture.width, texture.height).uvScale;
420
+ return deriveTextureExtent(
421
+ texture.format,
422
+ texture.shape.extent.width,
423
+ texture.shape.extent.height,
424
+ ).uvScale;
421
425
  }
422
426
 
423
427
  function materialTextureForField(
@@ -57,6 +57,7 @@ import type {
57
57
  RenderDebugOverlay,
58
58
  RenderPipelineContext,
59
59
  RenderRecordPhase,
60
+ VolumetricFogShaderSources,
60
61
  } from '../render-contract';
61
62
  import type {
62
63
  DispatchEntry,
@@ -236,6 +237,8 @@ export interface PipelineState {
236
237
  /** Surface route committed by configureSurface for inspection provenance. */
237
238
  readonly surfaceProfile?: 'dual-view' | 'raw-only';
238
239
  readonly viewBindGroupLayout: BindGroupLayout;
240
+ /** Device capability gate for the optional SpotLight projector binding. */
241
+ readonly projectorAvailable?: boolean;
239
242
  readonly materialBindGroupLayout: BindGroupLayout;
240
243
  readonly meshBindGroupLayout: BindGroupLayout;
241
244
  readonly viewUniformBuffer: Buffer;
@@ -302,6 +305,8 @@ export interface RenderSystemInternals extends RenderSystemRuntime {
302
305
  label: string,
303
306
  ) => RhiRenderPipeline | null)
304
307
  | undefined;
308
+ /** Composed renderer utility sources installed atomically at build-ready. */
309
+ volumetricFogShaders?: VolumetricFogShaderSources | undefined;
305
310
  }
306
311
 
307
312
  export interface _StandardForwardSceneView {
@@ -7,7 +7,7 @@ import {
7
7
  type RhiQueue,
8
8
  type RhiRenderPassEncoder,
9
9
  } from '@forgeax/engine-rhi';
10
- import type { PassSelector } from '@forgeax/engine-types';
10
+ import type { MaterialRenderState, PassSelector } from '@forgeax/engine-types';
11
11
  import { GpuBuffer } from '../gpu-resource';
12
12
  import {
13
13
  GPU_BUFFER_USAGE_COPY_DST,
@@ -81,6 +81,7 @@ function ensureTypedShadowViewBg(
81
81
  const { runtime, frameState, pipelineState } = c;
82
82
  const shadowSampler = pipelineState.perPassResources.shadowSampler;
83
83
  if (shadowSampler === null) return null;
84
+ const projectorAvailable = pipelineState.projectorAvailable !== false;
84
85
  try {
85
86
  return getOrCreateFromChain(
86
87
  frameState.viewBindGroupCache,
@@ -94,6 +95,9 @@ function ensureTypedShadowViewBg(
94
95
  pipelineState.shadowParamsBuffer,
95
96
  pipelineState.shadowCasterCascadeBuffer,
96
97
  pipelineState.shadowFallbackTextureView,
98
+ ...(projectorAvailable
99
+ ? [pipelineState.defaultWhiteTextureView, pipelineState.defaultSampler]
100
+ : []),
97
101
  pipelineState.pointsLinesViewBuffer ?? pipelineState.viewUniformBuffer,
98
102
  ],
99
103
  variant,
@@ -165,6 +169,21 @@ function ensureTypedShadowViewBg(
165
169
  },
166
170
  },
167
171
  },
172
+ ...(projectorAvailable
173
+ ? [
174
+ {
175
+ binding: 11,
176
+ resource: {
177
+ kind: 'textureView' as const,
178
+ value: pipelineState.defaultWhiteTextureView,
179
+ },
180
+ },
181
+ {
182
+ binding: 12,
183
+ resource: { kind: 'sampler' as const, value: pipelineState.defaultSampler },
184
+ },
185
+ ]
186
+ : []),
168
187
  ],
169
188
  });
170
189
  if (!created.ok) throw created.error;
@@ -181,11 +200,26 @@ function ensureTypedShadowViewBg(
181
200
  }
182
201
  }
183
202
 
184
- function shadowShaderMap(c: _InternalRenderPipelineContext): ReadonlyMap<number, string> {
185
- const shaders = new Map<number, string>();
203
+ interface ShadowDispatch {
204
+ readonly materialShaderId: string;
205
+ readonly renderState: MaterialRenderState | undefined;
206
+ }
207
+
208
+ type ShadowDispatchMap = ReadonlyMap<number, ReadonlyMap<number, ShadowDispatch>>;
209
+
210
+ function shadowShaderMap(c: _InternalRenderPipelineContext): ShadowDispatchMap {
211
+ const shaders = new Map<number, Map<number, ShadowDispatch>>();
186
212
  for (const entry of c.dispatch) {
187
213
  if (entry.tags.LightMode === 'ShadowCaster' && entry.materialShaderId !== undefined) {
188
- shaders.set(entry.renderableIndex, entry.materialShaderId);
214
+ let byMaterial = shaders.get(entry.renderableIndex);
215
+ if (byMaterial === undefined) {
216
+ byMaterial = new Map<number, ShadowDispatch>();
217
+ shaders.set(entry.renderableIndex, byMaterial);
218
+ }
219
+ byMaterial.set(entry.materialHandle, {
220
+ materialShaderId: entry.materialShaderId,
221
+ renderState: entry.renderState,
222
+ });
189
223
  }
190
224
  }
191
225
  return shaders;
@@ -488,8 +522,8 @@ export function buildMatchedMaterialHandlesByRenderable(
488
522
  * groups (@group 0/1) are already set by the caller; this loop owns @group
489
523
  * 2/3 + the vertex/index/pipeline de-dup state. Receives the explicit
490
524
  * `_InternalRenderPipelineContext` (`c`) plus the caller-resolved shadow
491
- * pipeline, mesh bind group, pass-selector match set, and per-renderable
492
- * custom-caster shader map so no cross-function mutable state is introduced.
525
+ * pipeline, mesh bind group, pass-selector match set, and per-material
526
+ * ShadowCaster dispatch map so no cross-function mutable state is introduced.
493
527
  */
494
528
  function recordShadowCasterDraws(
495
529
  c: _InternalRenderPipelineContext,
@@ -497,7 +531,7 @@ function recordShadowCasterDraws(
497
531
  shadowPipeline: RenderPipeline,
498
532
  shadowMeshBindGroup: BindGroup,
499
533
  matchedIndices: Set<number> | null,
500
- shadowShaderByRenderableIdx: ReadonlyMap<number, string>,
534
+ shadowDispatchByRenderableIdx: ShadowDispatchMap,
501
535
  ): void {
502
536
  const { runtime, pipelineState, validatedOrdered } = c;
503
537
  // M-3 / w12: vertexBuffer/indexBuffer state locals migrate to GpuBuffer
@@ -520,41 +554,14 @@ function recordShadowCasterDraws(
520
554
  // feat-20260609 M2: skip entities that don't match the pass selector.
521
555
  if (matchedIndices !== null && !matchedIndices.has(entry.renderableIndex)) continue;
522
556
 
523
- // bug-20260619-csm RC-3 (AC-10, D-3): resolve the per-entity shadow
524
- // PSO from its ShadowCaster shader id. Default casters keep the
525
- // vertex-only `forgeax::default-shadow-caster` PSO bound above; a
526
- // material with a custom ShadowCaster shader (cutout alpha-test) gets
527
- // its own fragment-carrying PSO so `discard` runs in the depth pass.
528
- const entryShadowShaderId = shadowShaderByRenderableIdx.get(entry.renderableIndex);
529
- let entryShadowPipeline = shadowPipeline;
530
557
  if (entry.source.skin !== undefined) {
531
558
  const skinPipeline = skinnedShadowPipeline(c, entry);
532
559
  if (skinPipeline === null) continue;
533
- entryShadowPipeline = skinPipeline;
534
- } else if (
535
- entryShadowShaderId !== undefined &&
536
- entryShadowShaderId !== SHADOW_CASTER_SHADER_ID
537
- ) {
538
- // Custom ShadowCaster PSO; same cache path as the default above
539
- // (passKind 'shadow-caster'). On a cache miss (async build in
540
- // flight / build failure) fall back to the default PSO so the
541
- // caster still writes depth rather than dropping its draw.
542
- entryShadowPipeline =
543
- runtime.getMaterialShaderPipeline?.(
544
- entryShadowShaderId,
545
- false, // isHdr — shadow depth pass is always LDR
546
- undefined, // renderState
547
- 'triangle-list', // topology — shadow PSO targets triangle-list
548
- undefined, // indexFormat
549
- undefined, // variantSet — shadow caster has no variant axes
550
- 'shadow-caster', // passKind
551
- ) ?? shadowPipeline;
552
- }
553
- if (entryShadowPipeline !== shadowLastPipeline && entryShadowPipeline !== null) {
554
- shadowPass.setPipeline(entryShadowPipeline);
555
- shadowLastPipeline = entryShadowPipeline;
560
+ if (skinPipeline !== shadowLastPipeline) {
561
+ shadowPass.setPipeline(skinPipeline);
562
+ shadowLastPipeline = skinPipeline;
563
+ }
556
564
  }
557
-
558
565
  // feat-20260604-mesh-topology-debug-draw M5 / w14 (AC-09, D-A6): the
559
566
  // shadow caster PSO is triangle-list; it only projects triangle faces.
560
567
  // line-list / line-strip / point-list meshes have no surface to cast a
@@ -708,6 +715,48 @@ function recordShadowCasterDraws(
708
715
  if (sm.topology !== 'triangle-list' && sm.topology !== 'triangle-strip') {
709
716
  continue;
710
717
  }
718
+
719
+ // Resolve the ShadowCaster PSO for the material bound to this submesh.
720
+ // Built-in materials normally use the shared vertex-only caster, but an
721
+ // authored `cullMode:'none'`/`frontFace` must still reach that PSO so
722
+ // two-sided casters (such as the Three.js teapot) populate the atlas
723
+ // from both faces. Custom ShadowCaster shaders retain their own path.
724
+ const submeshMaterial = entry.source.materials[sm.materialSlot] ?? entry.source.material;
725
+ const shadowDispatch = shadowDispatchByRenderableIdx
726
+ .get(entry.renderableIndex)
727
+ ?.get(submeshMaterial.materialHandle ?? 0);
728
+ const entryShadowShaderId = shadowDispatch?.materialShaderId;
729
+ const entryShadowRenderState = shadowDispatch?.renderState;
730
+ // A skinned caster's pipeline was selected above together with its
731
+ // two-binding mesh/palette group. Keep that pairing for every submesh;
732
+ // resetting to the static shadow pipeline here would bind the skin
733
+ // group against `pbr-pl` and Dawn rejects the draw at submit time.
734
+ let entryShadowPipeline =
735
+ entry.source.skin !== undefined ? shadowLastPipeline : shadowPipeline;
736
+ if (
737
+ entry.source.skin === undefined &&
738
+ entryShadowShaderId !== undefined &&
739
+ (entryShadowShaderId !== 'forgeax::default-shadow-caster' ||
740
+ entryShadowRenderState !== undefined)
741
+ ) {
742
+ // On a cache miss (async build in flight / build failure), fall back
743
+ // to the default PSO so the caster still writes depth rather than
744
+ // dropping its draw.
745
+ entryShadowPipeline =
746
+ runtime.getMaterialShaderPipeline?.(
747
+ entryShadowShaderId,
748
+ false, // isHdr — shadow depth pass is always LDR
749
+ entryShadowRenderState,
750
+ 'triangle-list', // topology — shadow PSO targets triangle-list
751
+ undefined, // indexFormat
752
+ undefined, // variantSet — shadow caster has no variant axes
753
+ 'shadow-caster', // passKind
754
+ ) ?? shadowPipeline;
755
+ }
756
+ if (entryShadowPipeline !== shadowLastPipeline && entryShadowPipeline !== null) {
757
+ shadowPass.setPipeline(entryShadowPipeline);
758
+ shadowLastPipeline = entryShadowPipeline;
759
+ }
711
760
  if (entry.mesh.indexed) {
712
761
  shadowPass.drawIndexed(sm.indexCount, shadowInstanceCount, sm.indexOffset, 0, 0);
713
762
  } else {