@forgeax/engine-render 0.1.24 → 0.1.25

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 (135) hide show
  1. package/README.md +100 -1
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/surface-projection.d.ts +24 -0
  5. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  6. package/dist/assembly/material-shader-policy.d.ts +20 -0
  7. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  9. package/dist/assembly/webgpu-renderer.d.ts +1 -1
  10. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  12. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  13. package/dist/assets/asset-decoders.d.ts.map +1 -1
  14. package/dist/authoring.mjs +1 -1
  15. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  16. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  17. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  18. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  19. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  20. package/dist/chunk-HKXTW355.mjs.map +1 -0
  21. package/dist/{chunk-YKB5DQV7.mjs → chunk-JSEDTZYS.mjs} +87 -19
  22. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  23. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  24. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  25. package/dist/{chunk-Z3YYH2MB.mjs → chunk-N3RT2EUR.mjs} +94 -41
  26. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  27. package/dist/{chunk-YRUYGA4A.mjs → chunk-TCLDX7SZ.mjs} +559 -138
  28. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  29. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  30. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  31. package/dist/components/instances.d.ts +13 -31
  32. package/dist/components/instances.d.ts.map +1 -1
  33. package/dist/construct-renderer.mjs +865 -142
  34. package/dist/construct-renderer.mjs.map +1 -1
  35. package/dist/gpu-driven/production-raster.d.ts +6 -1
  36. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  37. package/dist/gpu-scene.d.ts +4 -0
  38. package/dist/gpu-scene.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.mjs +27 -9
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/instance-buffer-cache.d.ts +7 -1
  44. package/dist/instance-buffer-cache.d.ts.map +1 -1
  45. package/dist/instances.d.ts +101 -0
  46. package/dist/instances.d.ts.map +1 -0
  47. package/dist/internal.mjs +5 -5
  48. package/dist/materials.d.ts +63 -2
  49. package/dist/materials.d.ts.map +1 -1
  50. package/dist/pipeline-builder.d.ts.map +1 -1
  51. package/dist/pipeline-spec.d.ts.map +1 -1
  52. package/dist/record/frame-snapshot.d.ts +13 -2
  53. package/dist/record/frame-snapshot.d.ts.map +1 -1
  54. package/dist/record/frame.d.ts.map +1 -1
  55. package/dist/record/main-pass-geometry.d.ts +22 -3
  56. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  57. package/dist/record/render-context.d.ts +2 -1
  58. package/dist/record/render-context.d.ts.map +1 -1
  59. package/dist/record/shadow-pass.d.ts +11 -1
  60. package/dist/record/shadow-pass.d.ts.map +1 -1
  61. package/dist/recovery/render-system-candidate.d.ts +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +4 -0
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-system-extract-tail.d.ts +4 -0
  66. package/dist/render-system-extract-tail.d.ts.map +1 -1
  67. package/dist/render-system-extract.d.ts +25 -1
  68. package/dist/render-system-extract.d.ts.map +1 -1
  69. package/dist/render-system.d.ts +4 -1
  70. package/dist/render-system.d.ts.map +1 -1
  71. package/dist/scene/render-scene-types.d.ts +2 -0
  72. package/dist/scene/render-scene-types.d.ts.map +1 -1
  73. package/dist/scene/render-scene.d.ts +12 -1
  74. package/dist/scene/render-scene.d.ts.map +1 -1
  75. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  76. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  77. package/dist/temporal/index.mjs +5 -5
  78. package/package.json +20 -20
  79. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  80. package/src/__tests__/instances-store.unit.test.ts +46 -0
  81. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  82. package/src/__tests__/material-contract-inventory.unit.test.ts +5 -13
  83. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  84. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  85. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  86. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  87. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  88. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +42 -0
  89. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  90. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  91. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  92. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  93. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  94. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  95. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  96. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  97. package/src/assembly/factory.ts +2 -0
  98. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  99. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  100. package/src/assembly/material/surface-projection.ts +90 -0
  101. package/src/assembly/material-shader-policy.ts +88 -10
  102. package/src/assembly/recovery/renderer-recover.ts +1 -1
  103. package/src/assembly/webgpu-ready.ts +3 -16
  104. package/src/assembly/webgpu-renderer.ts +90 -158
  105. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  106. package/src/assets/asset-decoders.ts +27 -4
  107. package/src/components/instances.ts +14 -197
  108. package/src/gpu-driven/production-raster.ts +34 -1
  109. package/src/gpu-scene.ts +84 -29
  110. package/src/index.ts +1 -0
  111. package/src/instance-buffer-cache.ts +14 -1
  112. package/src/instances.ts +288 -0
  113. package/src/materials.ts +107 -12
  114. package/src/pipeline-builder.ts +14 -10
  115. package/src/pipeline-spec.ts +6 -14
  116. package/src/record/frame-snapshot.ts +19 -2
  117. package/src/record/frame.ts +31 -2
  118. package/src/record/main-pass-geometry.ts +498 -59
  119. package/src/record/main-pass-sprite-draws.ts +1 -1
  120. package/src/record/render-context.ts +4 -0
  121. package/src/record/shadow-pass.ts +46 -99
  122. package/src/recovery/render-system-candidate.ts +41 -4
  123. package/src/render-contract.ts +4 -0
  124. package/src/render-system-extract-tail.ts +107 -51
  125. package/src/render-system-extract.ts +53 -26
  126. package/src/render-system.ts +40 -5
  127. package/src/scene/render-scene-types.ts +2 -0
  128. package/src/scene/render-scene.ts +579 -36
  129. package/src/systems/skin-palette-allocator.ts +129 -7
  130. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  131. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  132. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  133. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  134. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  135. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
@@ -0,0 +1,288 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+ import type { RhiErrorCode } from '@forgeax/engine-rhi';
3
+
4
+ const MATRIX_STRIDE = 16;
5
+
6
+ export type InstanceCollectionId = number & {
7
+ readonly __forgeaxInstanceCollectionId: unique symbol;
8
+ };
9
+
10
+ export interface InstanceCollectionInfo {
11
+ readonly collectionId: InstanceCollectionId;
12
+ readonly count: number;
13
+ readonly revision: number;
14
+ }
15
+
16
+ export interface InstanceUploadRange {
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ export interface InstanceCollectionSnapshot extends InstanceCollectionInfo {
22
+ readonly transforms: Float32Array;
23
+ }
24
+
25
+ /** The renderer-owned submission lane selected for one collection. */
26
+ export type InstanceSubmissionLane =
27
+ | 'unresident'
28
+ | 'direct-storage'
29
+ | 'chunked-storage'
30
+ | 'direct-uniform'
31
+ | 'chunked-uniform'
32
+ | 'unavailable';
33
+
34
+ /** Backend identity carried by collection residency and failure facts. */
35
+ export type InstanceBackendKind = 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null' | 'unknown';
36
+
37
+ /** Closed record-stage failure vocabulary for collection residency. */
38
+ export type InstanceCollectionFailureCode = RhiErrorCode | 'queue-write-buffer-failed';
39
+
40
+ /** Typed producer facts retained when a collection cannot be admitted. */
41
+ export interface InstanceCollectionFailureFacts {
42
+ readonly requestedBytes: number;
43
+ readonly supportedBytes: number | undefined;
44
+ readonly backend: InstanceBackendKind;
45
+ readonly owner: 'renderer.instances';
46
+ readonly cause: string;
47
+ readonly recovery: string;
48
+ }
49
+
50
+ /** Detached, bounded collection evidence exposed by Renderer.inspect(). */
51
+ export interface InstanceCollectionInspection extends InstanceCollectionInfo {
52
+ readonly residentGeneration: number | undefined;
53
+ readonly lane: InstanceSubmissionLane;
54
+ readonly uploadRanges: readonly InstanceUploadRange[];
55
+ readonly uploadedBytes: number;
56
+ readonly requestedBytes: number;
57
+ readonly supportedBytes: number | undefined;
58
+ readonly backend: InstanceBackendKind;
59
+ readonly owner: 'renderer.instances';
60
+ readonly error:
61
+ | {
62
+ readonly code: InstanceCollectionFailureCode;
63
+ readonly expected: string;
64
+ readonly hint: string;
65
+ readonly detail: InstanceCollectionFailureFacts;
66
+ }
67
+ | undefined;
68
+ }
69
+
70
+ export interface InstanceResidency {
71
+ readonly frameNumber: number;
72
+ readonly residentGeneration: number | undefined;
73
+ readonly lane: InstanceSubmissionLane;
74
+ readonly uploadRanges: readonly InstanceUploadRange[];
75
+ readonly uploadedBytes: number;
76
+ readonly requestedBytes: number;
77
+ readonly supportedBytes: number | undefined;
78
+ readonly backend: InstanceBackendKind;
79
+ readonly error: InstanceCollectionInspection['error'];
80
+ }
81
+
82
+ function appendRange(ranges: InstanceUploadRange[], start: number, end: number): void {
83
+ if (start >= end) return;
84
+ // Updates are allowed to arrive in any order. Insert at the first range
85
+ // whose end reaches the new interval, then consume every range that starts
86
+ // before the merged end. This preserves the complete union for both
87
+ // adjacent and overlapping writes (for example [10, 12) then [8, 11)).
88
+ let index = 0;
89
+ while (index < ranges.length) {
90
+ const existing = ranges[index];
91
+ if (existing === undefined || existing.end >= start) break;
92
+ index += 1;
93
+ }
94
+ while (index < ranges.length) {
95
+ const existing = ranges[index];
96
+ if (existing === undefined || existing.start > end) break;
97
+ start = Math.min(start, existing.start);
98
+ end = Math.max(end, existing.end);
99
+ ranges.splice(index, 1);
100
+ }
101
+ ranges.splice(index, 0, { start, end });
102
+ }
103
+
104
+ interface CollectionRecord extends InstanceCollectionInfo {
105
+ readonly world: World;
106
+ readonly entity: number;
107
+ readonly transforms: Float32Array;
108
+ }
109
+
110
+ /** Rebuildable per-renderer projection. World remains the authoring authority. */
111
+ export class InstanceProjectionStore {
112
+ private nextId = 1;
113
+ private identities = new WeakMap<World, Map<number, InstanceCollectionId>>();
114
+ private readonly records = new Map<InstanceCollectionId, CollectionRecord>();
115
+
116
+ project(world: World, entity: number, transforms: ArrayLike<number>): InstanceCollectionSnapshot {
117
+ let entities = this.identities.get(world);
118
+ if (entities === undefined) {
119
+ entities = new Map();
120
+ this.identities.set(world, entities);
121
+ }
122
+ let collectionId = entities.get(entity);
123
+ if (collectionId === undefined) {
124
+ collectionId = this.nextId++ as InstanceCollectionId;
125
+ entities.set(entity, collectionId);
126
+ }
127
+ const previous = this.records.get(collectionId);
128
+ const unchanged =
129
+ previous !== undefined &&
130
+ previous.transforms.length === transforms.length &&
131
+ previous.transforms.every((value, index) => Object.is(value, transforms[index]));
132
+ const record: CollectionRecord = unchanged
133
+ ? previous
134
+ : {
135
+ collectionId,
136
+ world,
137
+ entity,
138
+ transforms: new Float32Array(transforms),
139
+ count: transforms.length / MATRIX_STRIDE,
140
+ revision: (previous?.revision ?? 0) + 1,
141
+ };
142
+ this.records.set(collectionId, record);
143
+ // Every consumer gets a complete snapshot. Reading never acknowledges an
144
+ // upload; each GPU resident records its own successfully uploaded revision.
145
+ return { ...this.info(record), transforms: record.transforms };
146
+ }
147
+
148
+ retain(ids: ReadonlySet<InstanceCollectionId>): void {
149
+ for (const [id, record] of this.records) {
150
+ if (ids.has(id)) continue;
151
+ this.identities.get(record.world)?.delete(record.entity);
152
+ this.records.delete(id);
153
+ }
154
+ }
155
+
156
+ /** Release one producer-owned collection when its render identity is removed. */
157
+ release(world: World, entity: number): void {
158
+ const entities = this.identities.get(world);
159
+ const collectionId = entities?.get(entity);
160
+ if (collectionId === undefined) return;
161
+ entities?.delete(entity);
162
+ this.records.delete(collectionId);
163
+ }
164
+
165
+ dispose(): void {
166
+ this.records.clear();
167
+ this.identities = new WeakMap();
168
+ }
169
+
170
+ /**
171
+ * Return detached residency facts for every live collection.
172
+ *
173
+ * @internal Joins CPU projection identity with the active device residency;
174
+ * callers only receive immutable PODs and never GPU handles.
175
+ */
176
+ _inspections(
177
+ residency: ReadonlyMap<InstanceCollectionId, InstanceResidency> | undefined,
178
+ frameNumber: number,
179
+ ): readonly InstanceCollectionInspection[] {
180
+ return Object.freeze(
181
+ [...this.records.values()].map((record) =>
182
+ this.projectInspection(record, residency?.get(record.collectionId), frameNumber),
183
+ ),
184
+ );
185
+ }
186
+
187
+ private info(record: CollectionRecord): InstanceCollectionInfo {
188
+ return Object.freeze({
189
+ collectionId: record.collectionId,
190
+ count: record.count,
191
+ revision: record.revision,
192
+ });
193
+ }
194
+
195
+ private projectInspection(
196
+ record: CollectionRecord,
197
+ residency: InstanceResidency | undefined,
198
+ frameNumber: number,
199
+ ): InstanceCollectionInspection {
200
+ return Object.freeze({
201
+ ...this.info(record),
202
+ residentGeneration: residency?.residentGeneration,
203
+ lane: residency?.lane ?? 'unresident',
204
+ uploadRanges: Object.freeze(
205
+ residency?.frameNumber === frameNumber
206
+ ? residency.uploadRanges.map((range) => ({ ...range }))
207
+ : [],
208
+ ),
209
+ uploadedBytes: residency?.frameNumber === frameNumber ? residency.uploadedBytes : 0,
210
+ requestedBytes: residency?.requestedBytes ?? record.count * MATRIX_STRIDE * 4,
211
+ supportedBytes: residency?.supportedBytes,
212
+ backend: residency?.backend ?? 'unknown',
213
+ owner: 'renderer.instances',
214
+ error: residency?.error,
215
+ });
216
+ }
217
+ }
218
+
219
+ /** @internal Renderer record owner reports the accepted collection lane. */
220
+ export function recordInstanceResidency(
221
+ residency: Map<InstanceCollectionId, InstanceResidency>,
222
+ input: {
223
+ readonly collectionId: InstanceCollectionId;
224
+ readonly frameNumber: number;
225
+ readonly residentGeneration: number;
226
+ readonly lane: Exclude<InstanceSubmissionLane, 'unresident' | 'unavailable'>;
227
+ readonly uploadRanges: readonly InstanceUploadRange[];
228
+ readonly uploadedBytes: number;
229
+ readonly requestedBytes: number;
230
+ readonly supportedBytes: number | undefined;
231
+ readonly backend: InstanceBackendKind;
232
+ },
233
+ ): void {
234
+ const previous = residency.get(input.collectionId);
235
+ const sameFrame =
236
+ previous !== undefined &&
237
+ previous.frameNumber === input.frameNumber &&
238
+ previous.error === undefined;
239
+ const uploadRanges = sameFrame
240
+ ? [...previous.uploadRanges].reduce(
241
+ (ranges, range) => {
242
+ appendRange(ranges, range.start, range.end);
243
+ return ranges;
244
+ },
245
+ input.uploadRanges.map((range) => ({ ...range })),
246
+ )
247
+ : input.uploadRanges.map((range) => ({ ...range }));
248
+ residency.set(input.collectionId, {
249
+ frameNumber: input.frameNumber,
250
+ residentGeneration: input.residentGeneration,
251
+ lane: input.lane,
252
+ uploadRanges,
253
+ uploadedBytes: sameFrame ? previous.uploadedBytes + input.uploadedBytes : input.uploadedBytes,
254
+ requestedBytes: input.requestedBytes,
255
+ supportedBytes: input.supportedBytes,
256
+ backend: input.backend,
257
+ error: undefined,
258
+ });
259
+ }
260
+
261
+ /** @internal Renderer record owner reports a typed admission failure. */
262
+ export function recordInstanceFailure(
263
+ residency: Map<InstanceCollectionId, InstanceResidency>,
264
+ input: {
265
+ readonly collectionId: InstanceCollectionId;
266
+ readonly code: InstanceCollectionFailureCode;
267
+ readonly expected: string;
268
+ readonly hint: string;
269
+ readonly facts: InstanceCollectionFailureFacts;
270
+ },
271
+ ): void {
272
+ residency.set(input.collectionId, {
273
+ frameNumber: -1,
274
+ residentGeneration: undefined,
275
+ lane: 'unavailable',
276
+ uploadRanges: [],
277
+ uploadedBytes: 0,
278
+ requestedBytes: input.facts.requestedBytes,
279
+ supportedBytes: input.facts.supportedBytes,
280
+ backend: input.facts.backend,
281
+ error: {
282
+ code: input.code,
283
+ expected: input.expected,
284
+ hint: input.hint,
285
+ detail: { ...input.facts },
286
+ },
287
+ });
288
+ }
package/src/materials.ts CHANGED
@@ -9,6 +9,10 @@ import type {
9
9
  MaterialValue,
10
10
  } from '@forgeax/engine-types';
11
11
  import { deriveStandardLayerPlan } from '@forgeax/engine-types';
12
+ import {
13
+ DEFAULT_STANDARD_SURFACE_MODULE,
14
+ projectStandardSurfacePasses,
15
+ } from './assembly/material/surface-projection';
12
16
 
13
17
  export type MaterialTransmissionValidationReason =
14
18
  | 'non-finite'
@@ -58,7 +62,6 @@ export const SPRITE_PREMULTIPLIED_ALPHA_BLEND: GPUBlendState = {
58
62
  alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
59
63
  };
60
64
 
61
- const STANDARD_MODULE = 'forgeax_material::standard';
62
65
  const UNLIT_MODULE = 'forgeax_material::unlit';
63
66
  const SPRITE_MODULE = 'forgeax_material::sprite';
64
67
 
@@ -389,6 +392,70 @@ interface StandardOpts {
389
392
  readonly queue?: number;
390
393
  }
391
394
 
395
+ /** Built-in Standard authoring surface with engine-owned parameter defaults. */
396
+ export interface DefaultStandardOptions extends StandardOpts {
397
+ readonly surfaceModule?: never;
398
+ readonly parameters?: never;
399
+ readonly values?: never;
400
+ }
401
+
402
+ /** Import-first Standard Surface authoring; the root parameter contract stays caller-owned. */
403
+ export interface CustomStandardSurfaceOptions {
404
+ readonly surfaceModule: string;
405
+ readonly parameters: readonly MaterialParameter[];
406
+ readonly values: Readonly<Record<string, MaterialValue>>;
407
+ readonly colorSpace?: MaterialColorSpace;
408
+ readonly renderState?: MaterialRenderState;
409
+ readonly castShadow?: boolean;
410
+ readonly queue?: number;
411
+ readonly baseColor?: never;
412
+ readonly metallic?: never;
413
+ readonly roughness?: never;
414
+ readonly metallicChannel?: never;
415
+ readonly roughnessChannel?: never;
416
+ readonly clearcoat?: never;
417
+ readonly clearcoatRoughness?: never;
418
+ readonly clearcoatTexture?: never;
419
+ readonly clearcoatRoughnessTexture?: never;
420
+ readonly clearcoatNormalTexture?: never;
421
+ readonly clearcoatNormalScale?: never;
422
+ readonly anisotropyStrength?: never;
423
+ readonly anisotropyRotation?: never;
424
+ readonly anisotropyTexture?: never;
425
+ readonly sheenColor?: never;
426
+ readonly sheenRoughness?: never;
427
+ readonly sheenColorTexture?: never;
428
+ readonly sheenRoughnessTexture?: never;
429
+ readonly iridescence?: never;
430
+ readonly iridescenceIor?: never;
431
+ readonly iridescenceThicknessMinimum?: never;
432
+ readonly iridescenceThicknessMaximum?: never;
433
+ readonly iridescenceTexture?: never;
434
+ readonly iridescenceThicknessTexture?: never;
435
+ readonly specular?: never;
436
+ readonly specularColor?: never;
437
+ readonly specularTexture?: never;
438
+ readonly specularColorTexture?: never;
439
+ readonly emissive?: never;
440
+ readonly emissiveIntensity?: never;
441
+ readonly emissiveTexture?: never;
442
+ readonly baseColorTexture?: never;
443
+ readonly metallicRoughnessTexture?: never;
444
+ readonly normalTexture?: never;
445
+ readonly occlusionTexture?: never;
446
+ readonly occlusionStrength?: never;
447
+ readonly alphaCutoff?: never;
448
+ readonly transmission?: never;
449
+ readonly transmissionTexture?: never;
450
+ readonly ior?: never;
451
+ readonly thickness?: never;
452
+ readonly thicknessTexture?: never;
453
+ readonly attenuationColor?: never;
454
+ readonly attenuationDistance?: never;
455
+ }
456
+
457
+ export type StandardOptions = DefaultStandardOptions | CustomStandardSurfaceOptions;
458
+
392
459
  function validateChannel(
393
460
  name: 'metallicChannel' | 'roughnessChannel',
394
461
  value: number | undefined,
@@ -413,7 +480,7 @@ function validateFiniteRange(
413
480
  }
414
481
  }
415
482
 
416
- function standard(opts: StandardOpts): MaterialAsset {
483
+ function standardDefault(opts: DefaultStandardOptions): MaterialAsset {
417
484
  const occlusionStrength = opts.occlusionStrength ?? 1;
418
485
  if (occlusionStrength < 0 || occlusionStrength > 1) {
419
486
  throw new Error(
@@ -516,16 +583,18 @@ function standard(opts: StandardOpts): MaterialAsset {
516
583
  for (const name of Object.keys(values)) {
517
584
  if (!declaredNames.has(name)) delete values[name];
518
585
  }
519
- const physical = deriveStandardLayerPlan(parameters).mode === 'physical';
520
- const passes: [MaterialPass, ...MaterialPass[]] = [
521
- pass('forward', STANDARD_MODULE, forwardRenderState, 'fs_main', opts.queue),
522
- ];
523
- if (!physical && transmission === 0) {
524
- passes.push(pass('deferred', STANDARD_MODULE, opts.renderState, 'fs_gbuffer', opts.queue));
525
- }
526
- if (opts.castShadow !== false) {
527
- passes.push(pass('shadow-caster', STANDARD_MODULE, shadowCasterRenderState(opts.renderState)));
528
- }
586
+ const layerPlan = deriveStandardLayerPlan(parameters);
587
+ const projected = projectStandardSurfacePasses({
588
+ surfaceModule: DEFAULT_STANDARD_SURFACE_MODULE,
589
+ values,
590
+ ...(forwardRenderState === undefined ? {} : { renderState: forwardRenderState }),
591
+ ...(opts.castShadow === undefined ? {} : { castShadow: opts.castShadow }),
592
+ ...(opts.queue === undefined ? {} : { queue: opts.queue }),
593
+ layerPlan,
594
+ });
595
+ const passes = (
596
+ transmission > 0 ? projected.filter((entry) => entry.name !== 'deferred') : projected
597
+ ) as [MaterialPass, ...MaterialPass[]];
529
598
  return {
530
599
  kind: 'material',
531
600
  colorSpace: 'linear',
@@ -535,6 +604,32 @@ function standard(opts: StandardOpts): MaterialAsset {
535
604
  };
536
605
  }
537
606
 
607
+ function standardCustom(opts: CustomStandardSurfaceOptions): MaterialAsset {
608
+ const layerPlan = deriveStandardLayerPlan(
609
+ opts.parameters.filter((parameter) => parameter.optional !== true),
610
+ );
611
+ return {
612
+ kind: 'material',
613
+ ...(opts.colorSpace === undefined || opts.colorSpace === 'srgb'
614
+ ? {}
615
+ : { colorSpace: opts.colorSpace }),
616
+ passes: projectStandardSurfacePasses({
617
+ surfaceModule: opts.surfaceModule,
618
+ values: opts.values,
619
+ ...(opts.renderState === undefined ? {} : { renderState: opts.renderState }),
620
+ ...(opts.castShadow === undefined ? {} : { castShadow: opts.castShadow }),
621
+ ...(opts.queue === undefined ? {} : { queue: opts.queue }),
622
+ layerPlan,
623
+ }),
624
+ parameters: opts.parameters,
625
+ values: opts.values,
626
+ };
627
+ }
628
+
629
+ export function standard(options: StandardOptions): MaterialAsset {
630
+ return options.surfaceModule === undefined ? standardDefault(options) : standardCustom(options);
631
+ }
632
+
538
633
  export const Materials = { unlit, standard, srgb } as const;
539
634
 
540
635
  export { SPRITE_MODULE };
@@ -277,7 +277,13 @@ export function buildPipelineForMaterialShader(
277
277
  const vsEntry = vertexEntry ?? (passKind === 'temporal' ? 'vs_temporal' : 'vs_main');
278
278
  const fsEntry =
279
279
  fragmentEntry ??
280
- (passKind === 'temporal' ? 'fs_temporal' : passKind === 'deferred' ? 'fs_gbuffer' : 'fs_main');
280
+ (passKind === 'temporal'
281
+ ? 'fs_temporal'
282
+ : passKind === 'deferred'
283
+ ? 'fs_gbuffer'
284
+ : passKind === 'shadow-caster'
285
+ ? 'fs_shadow'
286
+ : 'fs_main');
281
287
 
282
288
  let source = entry.source;
283
289
  if (defines !== undefined && Object.keys(defines).length > 0) {
@@ -345,15 +351,13 @@ export function buildPipelineForMaterialShader(
345
351
  vertex: {
346
352
  module: shaderModule,
347
353
  entryPoint: vsEntry,
348
- buffers:
349
- ctx.layoutKind === 'pbr-skin'
350
- ? [...ctx.vertexBuffers]
351
- : [
352
- {
353
- arrayStride: 12 * 4,
354
- attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' as const }],
355
- },
356
- ],
354
+ // Surface-backed Standard shadow entries evaluate the same material
355
+ // surface as Forward/Deferred, so their vertex stage consumes normal,
356
+ // UV, and tangent inputs in addition to position. Keep the complete
357
+ // derived mesh layout here; extra attributes remain legal for legacy
358
+ // position-only custom casters, while trimming to position would make
359
+ // the built-in Surface shadow shader fail WebGPU validation.
360
+ buffers: [...ctx.vertexBuffers],
357
361
  },
358
362
  // Vertex-only depth pass writes `fragment: undefined` (GPU derives
359
363
  // depth from gl_Position.z). A custom ShadowCaster shader with a
@@ -19,6 +19,7 @@
19
19
 
20
20
  import {
21
21
  deriveVertexBufferLayout,
22
+ deriveVertexBufferLayoutFromProjection,
22
23
  deriveVertexLayoutProjection,
23
24
  type VertexLayoutProjection,
24
25
  } from '@forgeax/engine-geometry';
@@ -407,20 +408,11 @@ export function buildPipelineDescriptor(
407
408
  ? { shaderUvSetCount: geometry.shaderUvSetCount }
408
409
  : {}),
409
410
  })
410
- : geometry.vertexLayoutProjection.attributes.length === 0
411
- ? []
412
- : [
413
- {
414
- arrayStride: geometry.vertexLayoutProjection.arrayStride,
415
- attributes: geometry.vertexLayoutProjection.attributes.map(
416
- ({ shaderLocation, offset, format }) => ({
417
- shaderLocation,
418
- offset,
419
- format,
420
- }),
421
- ),
422
- },
423
- ];
411
+ : deriveVertexBufferLayoutFromProjection(geometry.vertexLayoutProjection, {
412
+ ...(geometry.shaderUvSetCount !== undefined
413
+ ? { shaderUvSetCount: geometry.shaderUvSetCount }
414
+ : {}),
415
+ });
424
416
 
425
417
  const descriptor: Record<string, unknown> = {
426
418
  vertex: {
@@ -18,6 +18,7 @@ import type { DirectionalShadowQuality } from '../components/directional-shadow-
18
18
  import type { EnvironmentGeneration } from '../environment/generation';
19
19
  import type { EnvironmentLifecycle } from '../environment/lifecycle';
20
20
  import type { GpuBuffer } from '../gpu-resource';
21
+ import type { InstanceCollectionId, InstanceResidency } from '../instances';
21
22
  import type { IblBindingInspection } from '../mesh-material-bindings';
22
23
  import type { StandardLightingInspection } from '../pipeline/standard-lighting/inspection';
23
24
  import type { PointShadowInspection } from '../point-shadow-inspection';
@@ -52,6 +53,17 @@ export function worldEntityKey(worldId: number, entityKey: number): number {
52
53
  return worldId * 4294967296 + entityKey; // 2^32 = 4294967296
53
54
  }
54
55
 
56
+ /** Per-renderer projection identity, with entity keys for sprite/fold snapshots. */
57
+ export function instanceCollectionCacheKey(
58
+ worldId: number,
59
+ instance: { readonly cacheKey: number; readonly collectionId?: number },
60
+ ): number {
61
+ // Keep collection entries in a disjoint numeric namespace from the shared
62
+ // legacy sprite/fold cache. Fold keys occupy -1..-(2^32), while ordinary
63
+ // world/entity keys are non-negative; this range starts below both.
64
+ return -(0x1_0000_0000 + worldEntityKey(worldId, instance.collectionId ?? instance.cacheKey));
65
+ }
66
+
55
67
  /**
56
68
  * Cross-frame cache entry for a material bind group whose source material and
57
69
  * all resolved GPU resources are stable. The material snapshot identity is
@@ -289,8 +301,9 @@ export function makeZeroCameraFallbackSnapshot(): CameraSnapshot {
289
301
  * Owned by the `createRenderSystem` closure; advanced once per
290
302
  * `recordFrame` invocation. The `instanceBuffers` map holds the per-entity
291
303
  * GPU storage buffers for Instances-bearing entities (cache key = the
292
- * packed Entity u32 surfaced via `InstancesSnapshot.cacheKey`); entries
293
- * are recreated when the `archVersion` bumps or `byteLength` changes.
304
+ * renderer-owned collection id, with the packed Entity u32 retained only for
305
+ * legacy snapshots); entries are recreated when the `archVersion` bumps or
306
+ * `byteLength` changes.
294
307
  *
295
308
  * feat-20260518-pbr-direct-lighting-mvp M3 / w14 (AC-17 a): the
296
309
  * `warnedZeroLightStandard` flag latches the first-frame warning that
@@ -390,6 +403,10 @@ export interface RenderFrameState {
390
403
  currentDirectionalShadowView: TextureView | null;
391
404
  currentSpotShadowView: TextureView | null;
392
405
  readonly instanceBuffers: Map<number, InstanceBufferCacheEntry>;
406
+ /** Renderer-owned collection owner used for record-stage residency facts. */
407
+ readonly instanceResidency?: Map<InstanceCollectionId, InstanceResidency>;
408
+ /** Stable buffers for renderer-owned collections split by device cap. */
409
+ readonly instanceBufferChunks?: Map<string, InstanceBufferCacheEntry>;
393
410
  /** Per-object ProbeBlendRecord buffers; upload is one atomic 160B write. */
394
411
  readonly probeBlendBuffers: Map<number, ProbeBlendBufferCacheEntry>;
395
412
  /** One renderer-owned record array; each object selects its 256B-aligned lane. */
@@ -86,6 +86,7 @@ import {
86
86
  type DispatchCounts,
87
87
  type FrameObservationSource,
88
88
  getTextureIdentity,
89
+ instanceCollectionCacheKey,
89
90
  makeZeroCameraFallbackSnapshot,
90
91
  type ReflectionFallbackReadbackRequest,
91
92
  type RenderFrameState,
@@ -2993,8 +2994,14 @@ function cleanPerFrameCaches(
2993
2994
  // composites matching the write-side keys of #1-#3 — cross-world false eviction
2994
2995
  // is prevented because worldEntityKey(0, k) !== worldEntityKey(1, k).
2995
2996
  const validatedEntityKeys = new Set<number>(retainedEntityKeys);
2997
+ const validatedInstanceKeys = new Set<number>(retainedEntityKeys);
2996
2998
  for (const v of validated) {
2997
- validatedEntityKeys.add(worldEntityKey(v.source.worldId, v.source.entityKey));
2999
+ const entityKey = worldEntityKey(v.source.worldId, v.source.entityKey);
3000
+ validatedEntityKeys.add(entityKey);
3001
+ validatedInstanceKeys.add(entityKey);
3002
+ if (v.source.instances !== undefined) {
3003
+ validatedInstanceKeys.add(instanceCollectionCacheKey(v.source.worldId, v.source.instances));
3004
+ }
2998
3005
  }
2999
3006
 
3000
3007
  // Clean per-entity material BG cache: drop outer-Map entries whose
@@ -3018,7 +3025,7 @@ function cleanPerFrameCaches(
3018
3025
  // material-handle-based and cross-world collision is semantically
3019
3026
  // correct (same material renders in same fold bucket).
3020
3027
  for (const [key, entry] of frameState.instanceBuffers.entries()) {
3021
- if (!validatedEntityKeys.has(key)) {
3028
+ if (!validatedInstanceKeys.has(key)) {
3022
3029
  if (!entry.buffer.isDestroyed) {
3023
3030
  const r = entry.buffer.destroy();
3024
3031
  if (!r.ok) internals.errorRegistry.fire(r.error);
@@ -3027,6 +3034,28 @@ function cleanPerFrameCaches(
3027
3034
  }
3028
3035
  }
3029
3036
 
3037
+ if (frameState.instanceResidency !== undefined) {
3038
+ const liveCollections = new Set(validated.map((entry) => entry.source.instances?.collectionId));
3039
+ for (const id of frameState.instanceResidency.keys()) {
3040
+ if (!liveCollections.has(id)) frameState.instanceResidency.delete(id);
3041
+ }
3042
+ }
3043
+
3044
+ // Chunk keys are `${worldEntityKey}:${chunkStart}`. Keep the key opaque to
3045
+ // the authoring API, but retain the numeric owner prefix for eviction.
3046
+ if (frameState.instanceBufferChunks !== undefined) {
3047
+ for (const [key, entry] of frameState.instanceBufferChunks.entries()) {
3048
+ const separator = key.indexOf(':');
3049
+ const ownerKey = separator < 0 ? Number.NaN : Number(key.slice(0, separator));
3050
+ if (validatedInstanceKeys.has(ownerKey)) continue;
3051
+ if (!entry.buffer.isDestroyed) {
3052
+ const r = entry.buffer.destroy();
3053
+ if (!r.ok) internals.errorRegistry.fire(r.error);
3054
+ }
3055
+ frameState.instanceBufferChunks.delete(key);
3056
+ }
3057
+ }
3058
+
3030
3059
  if (frameState.morphBuffers !== undefined) {
3031
3060
  for (const [key, entry] of frameState.morphBuffers.entries()) {
3032
3061
  if (validatedEntityKeys.has(key)) continue;