@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
@@ -59,6 +59,7 @@ import type {
59
59
  import { err, ok, RhiError, validateDrawArgs } from '@forgeax/engine-rhi';
60
60
  import { registerPropagateTransforms } from '@forgeax/engine-scene';
61
61
  import {
62
+ DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
62
63
  findVariantByKey,
63
64
  type MaterialShaderEntry,
64
65
  type MaterialShaderManifestEntry,
@@ -134,6 +135,7 @@ import {
134
135
  SPOT_LIGHT_STD430_BYTES,
135
136
  } from '../light-buffer-layout';
136
137
  import {
138
+ buildPbrMaterialUserRegionEntries,
137
139
  buildPbrPipelineLayouts,
138
140
  buildPbrSkinLayouts,
139
141
  isSkinnedShadowCasterVariant,
@@ -163,6 +165,7 @@ import {
163
165
  } from '../pipeline-spec';
164
166
  import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
165
167
  import type { FrameObservationOptions } from '../record/frame';
168
+ import { GpuTimingCapture, type VolumeTimingObservation } from '../record/gpu-timing';
166
169
  import type { BloomPersistentBundle } from '../record/render-context';
167
170
  import { SHADOW_CASTER_BUFFER_SIZE } from '../record/shadow-pass';
168
171
  import { POINTS_LINES_VIEW_BUFFER_SIZE, VIEW_UNIFORM_BUFFER_SIZE } from '../record/view-ubo';
@@ -185,6 +188,7 @@ import type {
185
188
  RenderProfile,
186
189
  RenderResult,
187
190
  RenderWorldLease,
191
+ VolumetricFogShaderSources,
188
192
  } from '../render-contract';
189
193
  import { STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
190
194
  import {
@@ -499,12 +503,15 @@ export function selectHdrpPbrPrewarmVariants(
499
503
  manifestEntry: MaterialShaderManifestEntry | undefined,
500
504
  storageBufferCapable: boolean,
501
505
  transmissionCapable: boolean,
506
+ projectorAvailable: boolean = true,
502
507
  ): readonly MaterialShaderManifestEntry['variants'][number][] {
503
508
  return (
504
509
  manifestEntry?.variants.filter(
505
510
  (variant) =>
506
511
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
507
512
  variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
513
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
514
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
508
515
  (transmissionCapable || variant.defines.TRANSMISSION_AVAILABLE !== true),
509
516
  ) ?? []
510
517
  );
@@ -518,6 +525,7 @@ export function selectHdrpPbrPrewarmVariants(
518
525
  export function selectStandardPbrTransmissionPrewarmVariants(
519
526
  manifestEntry: MaterialShaderManifestEntry | undefined,
520
527
  storageBufferCapable: boolean,
528
+ projectorAvailable: boolean = true,
521
529
  ): readonly MaterialShaderManifestEntry['variants'][number][] {
522
530
  if (manifestEntry === undefined) {
523
531
  throw new Error('Standard material shader manifest row is missing');
@@ -529,6 +537,8 @@ export function selectStandardPbrTransmissionPrewarmVariants(
529
537
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
530
538
  variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
531
539
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
540
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
541
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
532
542
  variant.defines.TRANSMISSION_AVAILABLE === transmissionAvailable,
533
543
  );
534
544
  if (declared === undefined) {
@@ -556,6 +566,7 @@ export function selectNoColorPbrVariant(
556
566
  manifestEntry: MaterialShaderManifestEntry | undefined,
557
567
  storageBufferCapable: boolean,
558
568
  variantSet: string | undefined,
569
+ projectorAvailable: boolean = true,
559
570
  ): MaterialShaderManifestEntry['variants'][number] | undefined {
560
571
  const hdrpRequested =
561
572
  variantSet === '' ||
@@ -565,6 +576,8 @@ export function selectNoColorPbrVariant(
565
576
  (variant) =>
566
577
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
567
578
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
579
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
580
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
568
581
  (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
569
582
  variant.defines.CLUSTER_FORWARD_AVAILABLE === hdrpRequested),
570
583
  );
@@ -623,6 +636,46 @@ export function resolveMaterialShaderVertexInputContract(
623
636
  : 'none';
624
637
  }
625
638
 
639
+ /**
640
+ * Decide whether a custom material's user-region can use the shared PBR
641
+ * bind-group layout. Resource names are not part of a WebGPU layout contract:
642
+ * the only safe reuse test is that every derived binding in the custom schema
643
+ * is an identical prefix of the canonical user-region descriptor, including
644
+ * binding kind and its full shape (sample type, view dimension, sampler mode,
645
+ * dynamic-offset flags, and visibility). A texture3d/array or comparison /
646
+ * storage resource must therefore take the per-shader layout path even when
647
+ * its field count is small.
648
+ */
649
+ export function isSharedMaterialUserRegionCompatible(
650
+ paramSchema: readonly ParamSchemaEntry[],
651
+ ): boolean {
652
+ const shared = buildPbrMaterialUserRegionEntries(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
653
+ const candidate = buildPbrMaterialUserRegionEntries(paramSchema);
654
+ if (candidate.length > shared.length) return false;
655
+ return candidate.every((entry, index) => {
656
+ const canonical = shared[index];
657
+ if (canonical === undefined) return false;
658
+ return (
659
+ JSON.stringify({
660
+ binding: entry.binding,
661
+ visibility: entry.visibility,
662
+ buffer: entry.buffer ?? null,
663
+ sampler: entry.sampler ?? null,
664
+ texture: entry.texture ?? null,
665
+ storageTexture: entry.storageTexture ?? null,
666
+ }) ===
667
+ JSON.stringify({
668
+ binding: canonical.binding,
669
+ visibility: canonical.visibility,
670
+ buffer: canonical.buffer ?? null,
671
+ sampler: canonical.sampler ?? null,
672
+ texture: canonical.texture ?? null,
673
+ storageTexture: canonical.storageTexture ?? null,
674
+ })
675
+ );
676
+ });
677
+ }
678
+
626
679
  export function allowsUnlitPreparedFallback(
627
680
  depthFormatOverride: GPUTextureFormat | null | undefined,
628
681
  vertexLayout: string | undefined,
@@ -727,10 +780,13 @@ export function resolveMaterialShaderVariantSet(
727
780
  }>,
728
781
  backendKind: RhiDevice['caps']['backendKind'],
729
782
  storageBuffer: boolean,
783
+ projectorAvailable: boolean = true,
730
784
  ): string | undefined {
731
785
  const declaredAxes = new Set(variants.flatMap((variant) => Object.keys(variant.defines)));
732
786
  const declaresCapabilityVariants =
733
- declaredAxes.has('WEBGL2_COMPAT') || declaredAxes.has('STORAGE_BUFFER_AVAILABLE');
787
+ declaredAxes.has('WEBGL2_COMPAT') ||
788
+ declaredAxes.has('STORAGE_BUFFER_AVAILABLE') ||
789
+ declaredAxes.has('PROJECTOR_AVAILABLE');
734
790
  if (!declaresCapabilityVariants) return requestedVariantSet;
735
791
 
736
792
  const requested: Record<string, boolean> = {};
@@ -755,7 +811,9 @@ export function resolveMaterialShaderVariantSet(
755
811
  ? requestedVariantSet === '' || (requested[axis] ?? false)
756
812
  : axis === 'TRANSMISSION_AVAILABLE'
757
813
  ? (requested[axis] ?? false)
758
- : (requested[axis] ?? true);
814
+ : axis === 'PROJECTOR_AVAILABLE'
815
+ ? projectorAvailable
816
+ : (requested[axis] ?? true);
759
817
  }
760
818
  const sorted = Object.entries(filtered).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
761
819
  return sorted.every(([, value]) => value)
@@ -951,7 +1009,10 @@ const COMPRESSION_FEATURES: GPUFeatureName[] = [
951
1009
  // 16; only request the elevated limit when the adapter can provide it.
952
1010
  const STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES = 17;
953
1011
 
954
- function deviceOptionsForAdapter(adapter: Pick<RhiAdapter, 'features' | 'limits'>):
1012
+ function deviceOptionsForAdapter(
1013
+ adapter: Pick<RhiAdapter, 'features' | 'limits'>,
1014
+ options?: Pick<RendererOptions, 'captureGpuTimings'>,
1015
+ ):
955
1016
  | {
956
1017
  requiredFeatures?: GPUFeatureName[];
957
1018
  requiredLimits?: GPUDeviceDescriptor['requiredLimits'];
@@ -961,6 +1022,9 @@ function deviceOptionsForAdapter(adapter: Pick<RhiAdapter, 'features' | 'limits'
961
1022
  const supportsTransmission =
962
1023
  (adapter.limits.maxSampledTexturesPerShaderStage ?? 0) >=
963
1024
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
1025
+ if (options?.captureGpuTimings === true && adapter.features.has('timestamp-query')) {
1026
+ requiredFeatures.push('timestamp-query');
1027
+ }
964
1028
  return {
965
1029
  ...(requiredFeatures.length > 0 ? { requiredFeatures } : {}),
966
1030
  ...(supportsTransmission
@@ -1020,7 +1084,7 @@ async function tryCreateWebGPURenderer(
1020
1084
  const adapter = adapterResult.value;
1021
1085
  // M4 w28: filter compression features from adapter.features (AC-07). The
1022
1086
  // same helper is used by recover() below so both paths share one profile.
1023
- const deviceOpts = deviceOptionsForAdapter(adapter);
1087
+ const deviceOpts = deviceOptionsForAdapter(adapter, options);
1024
1088
  const result = await adapter.requestDevice(deviceOpts);
1025
1089
  if (!result.ok) {
1026
1090
  return { kind: 'rhi-err', error: result.error };
@@ -1150,6 +1214,8 @@ interface WebGPURendererInternals {
1150
1214
  * has wired the controller; identity stable across grow events.
1151
1215
  */
1152
1216
  meshSsboState?: MeshSsboState;
1217
+ /** Device-bound, manifest-owned volumetric utility shader sources. */
1218
+ volumetricFogShaders?: VolumetricFogShaderSources | undefined;
1153
1219
  /** Host-owned producer features; created before device-bound assembly starts. */
1154
1220
  featureHost?: RenderFeatureHost;
1155
1221
  /** App-owned overlay capability read by the typed graph at frame time. */
@@ -1628,6 +1694,18 @@ async function makeWebGPURenderer(
1628
1694
  }
1629
1695
  if (pipelineState === null) return null;
1630
1696
  const paramSchema = lookup.value.paramSchema;
1697
+ // Reuse the shared BGL only when the complete derived resource shape is a
1698
+ // canonical prefix. Field names alone are not a layout contract: a
1699
+ // texture3d/array, comparison sampler, or storage buffer must never be
1700
+ // paired with the PBR texture2d/filtering descriptor by accident.
1701
+ if (isSharedMaterialUserRegionCompatible(paramSchema)) {
1702
+ perShaderMaterialLayoutCache.set(materialShaderId, {
1703
+ source: lookup.value.source,
1704
+ paramSchema,
1705
+ layout: null,
1706
+ });
1707
+ return null;
1708
+ }
1631
1709
  const spec: PipelineSpec = {
1632
1710
  shader: { id: materialShaderId, passKind: 'forward', variantSet: undefined },
1633
1711
  attachments: { colorFormats: [], depthFormat: undefined, sampleCount: 1 },
@@ -1674,6 +1752,8 @@ async function makeWebGPURenderer(
1674
1752
  [],
1675
1753
  internals.device.caps.backendKind,
1676
1754
  internals.device.caps.storageBuffer,
1755
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1756
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1677
1757
  );
1678
1758
  }
1679
1759
  const variants = manifestEntry.variants;
@@ -1682,13 +1762,17 @@ async function makeWebGPURenderer(
1682
1762
  byRequest = new Map();
1683
1763
  materialShaderVariantResolutionCache.set(variants, byRequest);
1684
1764
  }
1685
- const cacheKey = `${requestedVariantSet ?? '\u0000'}|${internals.device.caps.backendKind}|${internals.device.caps.storageBuffer ? '1' : '0'}`;
1765
+ const projectorAvailable =
1766
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1767
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
1768
+ const cacheKey = `${requestedVariantSet ?? '\u0000'}|${internals.device.caps.backendKind}|${internals.device.caps.storageBuffer ? '1' : '0'}|${projectorAvailable ? '1' : '0'}`;
1686
1769
  if (byRequest.has(cacheKey)) return byRequest.get(cacheKey);
1687
1770
  const resolved = resolveMaterialShaderVariantSet(
1688
1771
  requestedVariantSet,
1689
1772
  variants,
1690
1773
  internals.device.caps.backendKind,
1691
1774
  internals.device.caps.storageBuffer,
1775
+ projectorAvailable,
1692
1776
  );
1693
1777
  byRequest.set(cacheKey, resolved);
1694
1778
  return resolved;
@@ -1782,6 +1866,9 @@ async function makeWebGPURenderer(
1782
1866
  }
1783
1867
  temporalPostProcessesRegistered = true;
1784
1868
  },
1869
+ (sources: VolumetricFogShaderSources) => {
1870
+ internals.volumetricFogShaders = sources;
1871
+ },
1785
1872
  );
1786
1873
  const ready: Promise<Result<void, RhiError>> = buildPipeline().then(
1787
1874
  (state): Result<void, RhiError> => {
@@ -1827,6 +1914,8 @@ async function makeWebGPURenderer(
1827
1914
  pbrManifestEntry,
1828
1915
  internals.device.caps.storageBuffer,
1829
1916
  variantSet,
1917
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1918
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1830
1919
  );
1831
1920
  if (noColorVariant !== undefined && noColorVariant.composedWgsl !== standardEntry.wgsl) {
1832
1921
  return { ...standardEntry, wgsl: noColorVariant.composedWgsl };
@@ -2705,6 +2794,9 @@ async function makeWebGPURenderer(
2705
2794
  get meshSsboState() {
2706
2795
  return internals.meshSsboState;
2707
2796
  },
2797
+ get volumetricFogShaders() {
2798
+ return internals.volumetricFogShaders;
2799
+ },
2708
2800
  });
2709
2801
  if (
2710
2802
  internals.device.caps.backendKind === 'null' &&
@@ -2730,7 +2822,8 @@ async function makeWebGPURenderer(
2730
2822
  const transformReleases = new Map<World, () => void>();
2731
2823
  const attachmentOwner = {};
2732
2824
  let frameId = 0;
2733
- let latestReceipt: FrameReceipt | undefined;
2825
+ const issuedReceipts = new WeakSet<FrameReceipt>();
2826
+ const receiptTimings = new WeakMap<FrameReceipt, Promise<VolumeTimingObservation>>();
2734
2827
  let surfaceReleased = false;
2735
2828
 
2736
2829
  const renderer: RendererAssemblyImplementation = {
@@ -2794,6 +2887,7 @@ async function makeWebGPURenderer(
2794
2887
  ? {}
2795
2888
  : { presentationProof: internals.context.presentationProof }),
2796
2889
  });
2890
+ const volumetricFog = renderSystem.volumetricFog;
2797
2891
  return Object.freeze({
2798
2892
  state,
2799
2893
  surface: surfaceReleased ? 'released' : 'available',
@@ -2807,6 +2901,7 @@ async function makeWebGPURenderer(
2807
2901
  (internals.featureHost?.features ?? []).map((feature) => feature.identity),
2808
2902
  ),
2809
2903
  featureDiagnostics: internals.featureHost?.diagnostics() ?? Object.freeze([]),
2904
+ volumetricFog,
2810
2905
  frustumStats: Object.freeze({ ...renderSystem.frustumStats }),
2811
2906
  visibilityStats: Object.freeze({ ...renderSystem.visibilityStats }),
2812
2907
  renderScene: renderSystem.renderScene,
@@ -3234,8 +3329,23 @@ async function makeWebGPURenderer(
3234
3329
  // through onError separately; the facade Result is the synchronous
3235
3330
  // summary AI users can ignore or branch on).
3236
3331
  try {
3237
- const submitted = renderSystem.draw(worlds, drawOptions, renderReadLeases);
3332
+ const timingRequested =
3333
+ isFrameRequest &&
3334
+ (frameRequest?.profileFrame !== undefined ||
3335
+ internals.options?.captureGpuTimings === true);
3336
+ let timingCapture: GpuTimingCapture | undefined;
3337
+ let timingUnavailableReason = timingRequested
3338
+ ? 'timestamp capture is unavailable on this device'
3339
+ : 'timestamp capture was not requested for this frame';
3340
+ if (timingRequested) {
3341
+ const createdTiming = GpuTimingCapture.create(internals.device);
3342
+ if (createdTiming.ok) timingCapture = createdTiming.value;
3343
+ else
3344
+ timingUnavailableReason = `timestamp capture setup failed: ${createdTiming.error.code}`;
3345
+ }
3346
+ const submitted = renderSystem.draw(worlds, drawOptions, renderReadLeases, timingCapture);
3238
3347
  if (!submitted) {
3348
+ timingCapture?.discard();
3239
3349
  return err(
3240
3350
  new RendererContractFailureError(
3241
3351
  'draw',
@@ -3265,7 +3375,15 @@ async function makeWebGPURenderer(
3265
3375
  deviceGeneration: receiptGeneration,
3266
3376
  completed,
3267
3377
  });
3268
- latestReceipt = receipt;
3378
+ receiptTimings.set(
3379
+ receipt,
3380
+ timingCapture?.observation() ??
3381
+ Promise.resolve({
3382
+ status: 'unavailable',
3383
+ reason: timingUnavailableReason,
3384
+ }),
3385
+ );
3386
+ issuedReceipts.add(receipt);
3269
3387
  return ok(receipt);
3270
3388
  }
3271
3389
  return ok(undefined);
@@ -3290,10 +3408,8 @@ async function makeWebGPURenderer(
3290
3408
  request: FrameObservationRequest,
3291
3409
  ): Promise<RenderResult<FrameReceiptObservation, RenderError>> {
3292
3410
  if (
3293
- latestReceipt === undefined ||
3294
- latestReceipt !== receipt ||
3295
- latestReceipt.frameId !== receipt.frameId ||
3296
- latestReceipt.deviceGeneration !== receipt.deviceGeneration
3411
+ !issuedReceipts.has(receipt) ||
3412
+ receipt.deviceGeneration !== activeDeviceScope.generation
3297
3413
  ) {
3298
3414
  const currentGeneration = activeDeviceScope.generation;
3299
3415
  return err(
@@ -3316,11 +3432,19 @@ async function makeWebGPURenderer(
3316
3432
  }),
3317
3433
  );
3318
3434
  }
3435
+ const timings = request.include.includes('timings')
3436
+ ? await (receiptTimings.get(receipt) ??
3437
+ Promise.resolve({
3438
+ status: 'unavailable',
3439
+ reason: 'timing receipt was not retained',
3440
+ }))
3441
+ : undefined;
3319
3442
  return ok(
3320
3443
  Object.freeze({
3321
3444
  frameId: receipt.frameId,
3322
3445
  deviceGeneration: receipt.deviceGeneration,
3323
3446
  include: Object.freeze([...request.include]),
3447
+ ...(timings === undefined ? {} : { timings }),
3324
3448
  }),
3325
3449
  );
3326
3450
  },
@@ -3595,7 +3719,7 @@ async function makeWebGPURenderer(
3595
3719
  let device: RhiDevice;
3596
3720
  try {
3597
3721
  const deviceResult = await adapterResult.value.requestDevice(
3598
- deviceOptionsForAdapter(adapterResult.value),
3722
+ deviceOptionsForAdapter(adapterResult.value, internals.options),
3599
3723
  );
3600
3724
  if (!deviceResult.ok) {
3601
3725
  return err(new RecoverError('recover-device-unavailable'));
@@ -3964,6 +4088,11 @@ const RENDER_PROFILE_KEYS = new Set([
3964
4088
  'pbr',
3965
4089
  'ibl',
3966
4090
  'ssao',
4091
+ 'bloom',
4092
+ 'tone',
4093
+ 'antialias',
4094
+ 'sky',
4095
+ 'volumetricFog',
3967
4096
  'fallback',
3968
4097
  'postStages',
3969
4098
  ]);
@@ -3990,6 +4119,24 @@ function validateRenderProfile(profile: RenderProfile): string | undefined {
3990
4119
  for (const field of ['pbr', 'ibl', 'ssao'] as const) {
3991
4120
  if (typeof profile[field] !== 'boolean') return `RenderProfile.${field} must be boolean`;
3992
4121
  }
4122
+ if (profile.volumetricFog !== undefined) {
4123
+ if (profile.volumetricFog.quality !== 'low' && profile.volumetricFog.quality !== 'high')
4124
+ return "RenderProfile.volumetricFog.quality must be 'low' or 'high'";
4125
+ if (profile.volumetricFog.depth !== 48 && profile.volumetricFog.depth !== 64) {
4126
+ return 'RenderProfile.volumetricFog.depth must be 48 or 64';
4127
+ }
4128
+ if (profile.volumetricFog.tileSize !== 4 && profile.volumetricFog.tileSize !== 16) {
4129
+ return 'RenderProfile.volumetricFog.tileSize must be 4 or 16';
4130
+ }
4131
+ if (profile.volumetricFog.quality === 'low' && profile.volumetricFog.depth !== 48)
4132
+ return 'RenderProfile.volumetricFog.low must use depth 48';
4133
+ if (profile.volumetricFog.quality === 'high' && profile.volumetricFog.depth !== 64)
4134
+ return 'RenderProfile.volumetricFog.high must use depth 64';
4135
+ if (profile.volumetricFog.quality === 'low' && profile.volumetricFog.tileSize !== 16)
4136
+ return 'RenderProfile.volumetricFog.low must use tileSize 16';
4137
+ if (profile.volumetricFog.quality === 'high' && profile.volumetricFog.tileSize !== 4)
4138
+ return 'RenderProfile.volumetricFog.high must use tileSize 4';
4139
+ }
3993
4140
  const stages = STANDARD_POST_STAGE_NAMES;
3994
4141
  if (
3995
4142
  !Array.isArray(profile.postStages) ||
@@ -4767,6 +4914,14 @@ async function prepareMaterialShaders(
4767
4914
  if (!capCheck.ok) throw capCheck.error;
4768
4915
  storageBufferCapable = !webgl2Downlevel && capCheck.value;
4769
4916
  }
4917
+ // The shared PBR view/material ABI consumes the WebGPU minimum of 16
4918
+ // fragment sampled textures. The authored SpotLight projector is the
4919
+ // optional 17th texture; select its shader/BGL pair from the same limit so
4920
+ // low-limit adapters keep analytic lighting instead of failing layout
4921
+ // creation at boot.
4922
+ const projectorAvailable =
4923
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
4924
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
4770
4925
 
4771
4926
  // ── Step 1: manifest load ─────────────────────────────────────────────────
4772
4927
  const registry = getShader();
@@ -4808,6 +4963,12 @@ async function prepareMaterialShaders(
4808
4963
  if (msEntry.variants.some((v) => 'VERTEX_COLOR_AVAILABLE' in v.defines)) {
4809
4964
  variantDefines.VERTEX_COLOR_AVAILABLE = false;
4810
4965
  }
4966
+ if (
4967
+ msEntry.identifier === 'forgeax::default-standard-pbr' &&
4968
+ msEntry.variants.some((v) => 'PROJECTOR_AVAILABLE' in v.defines)
4969
+ ) {
4970
+ variantDefines.PROJECTOR_AVAILABLE = projectorAvailable;
4971
+ }
4811
4972
  if (
4812
4973
  msEntry.identifier === 'forgeax::default-standard-pbr' &&
4813
4974
  msEntry.variants.some((v) => 'TRANSMISSION_AVAILABLE' in v.defines)
@@ -4984,6 +5145,8 @@ async function buildReadyWebGPU(
4984
5145
  readonly motionBlur?: string;
4985
5146
  readonly taaResolve?: string;
4986
5147
  }) => void,
5148
+ /** Install manifest-owned volume utility sources only after all three modules compile. */
5149
+ setVolumetricFogShaderSources: (sources: VolumetricFogShaderSources) => void,
4987
5150
  ): Promise<PipelineState> {
4988
5151
  // bug-20260610: WebGL2 fallback path — read storage-buffer capability up
4989
5152
  // front so both Step 2 (shader-module compile, where we patch engine
@@ -5001,6 +5164,9 @@ async function buildReadyWebGPU(
5001
5164
  const capCheck = assertStorageBufferCap(capValue);
5002
5165
  storageBufferCapable = capCheck.ok ? !webgl2Downlevel && capCheck.value : !webgl2Downlevel;
5003
5166
  }
5167
+ const projectorAvailable =
5168
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5169
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
5004
5170
 
5005
5171
  // bug-20260612: choose swap-chain storage / view formats by backend.
5006
5172
  // Channel 2 (native WebGPU) follows navigator.gpu.getPreferredCanvasFormat();
@@ -5303,6 +5469,8 @@ async function buildReadyWebGPU(
5303
5469
  v.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5304
5470
  (!('VERTEX_COLOR_AVAILABLE' in v.defines) ||
5305
5471
  v.defines.VERTEX_COLOR_AVAILABLE === false) &&
5472
+ (!('PROJECTOR_AVAILABLE' in v.defines) ||
5473
+ v.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5306
5474
  (!('SKINNING_DISABLED' in v.defines) || v.defines.SKINNING_DISABLED === true),
5307
5475
  );
5308
5476
  const v = exact === undefined ? undefined : findVariantByKey(ms, exact.definesKey);
@@ -5346,6 +5514,8 @@ async function buildReadyWebGPU(
5346
5514
  variant.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5347
5515
  (!('VERTEX_COLOR_AVAILABLE' in variant.defines) ||
5348
5516
  variant.defines.VERTEX_COLOR_AVAILABLE === false) &&
5517
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
5518
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5349
5519
  (!('SKINNING_DISABLED' in variant.defines) ||
5350
5520
  variant.defines.SKINNING_DISABLED === true) &&
5351
5521
  (!('TRANSMISSION_AVAILABLE' in variant.defines) ||
@@ -5480,6 +5650,80 @@ async function buildReadyWebGPU(
5480
5650
  seedShaderModule(label, shaderResult.value);
5481
5651
  }
5482
5652
 
5653
+ // The volume graph is authored from ECS state and therefore cannot be
5654
+ // compiled while the static material manifest is assembled. Its pass
5655
+ // encoder nevertheless uses the same shared lazy shader adapter, so the
5656
+ // four utility modules must be prewarmed before the first synchronous
5657
+ // draw. The sources come from the shader manifest (the package-owned WGSL
5658
+ // files are the sole source of truth); compile all four first and only
5659
+ // then publish the labels and source bundle. A partial bundle would let a
5660
+ // later frame mix generations after a device recovery.
5661
+ if (rhiDevice.caps.compute && rhiDevice.caps.storageTexture) {
5662
+ const volumeSources = [
5663
+ ['inject', 'volume_inject', (source: string) => source.includes('fn volume_inject(')],
5664
+ ['temporal', 'volume_temporal', (source: string) => source.includes('fn volume_temporal(')],
5665
+ [
5666
+ 'integrate',
5667
+ 'volume_integrate',
5668
+ (source: string) => source.includes('fn volume_integrate('),
5669
+ ],
5670
+ ['composite', 'volume_composite', (source: string) => source.includes('fn volume_fs(')],
5671
+ ] as const;
5672
+ const resolved = volumeSources.map(([stage, label, matches]) => {
5673
+ const entry = manifestEntries.find((candidate) => matches(candidate.wgsl));
5674
+ return { stage, label, entry };
5675
+ });
5676
+ // Volumetric fog is an optional graph feature. Minimal hosts/tests may
5677
+ // intentionally ship only material/post-process entries; keep those
5678
+ // renderers bootable while still rejecting a partially declared volume
5679
+ // bundle (which would otherwise fail later as a lazy pending pipeline).
5680
+ const present = resolved.filter((candidate) => candidate.entry !== undefined).length;
5681
+ if (present === 0) {
5682
+ // No volume manifest entries: leave the source/module bundle unset and
5683
+ // let the topology capability gate keep volume work disabled.
5684
+ } else {
5685
+ const missing = resolved.find((candidate) => candidate.entry === undefined);
5686
+ if (missing !== undefined) {
5687
+ throw new RhiError({
5688
+ code: 'shader-compile-failed',
5689
+ expected: 'shader manifest contains all four volumetric fog utility entries',
5690
+ hint: `add the package-owned WGSL entry for '${missing.label}' to the engine shader manifest`,
5691
+ });
5692
+ }
5693
+ const compiled = [] as Array<{
5694
+ readonly stage: (typeof volumeSources)[number][0];
5695
+ readonly label: string;
5696
+ readonly source: string;
5697
+ readonly module: ShaderModule;
5698
+ }>;
5699
+ for (const { stage, label, entry } of resolved) {
5700
+ // `missing` above proves the narrowing for this loop; keep the guard
5701
+ // explicit so a future refactor cannot publish an undefined source.
5702
+ if (entry === undefined) throw new Error(`missing volumetric shader ${label}`);
5703
+ const shaderResult = await runShimStep(
5704
+ () =>
5705
+ asyncCreateShaderModule
5706
+ ? asyncCreateShaderModule(rhiDevice, { code: entry.wgsl, label })
5707
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: entry.wgsl, label }),
5708
+ 'shader-compile-failed',
5709
+ `volumetric fog shader module '${label}' compiled`,
5710
+ `inspect manifest WGSL for '${label}' and check device.features`,
5711
+ );
5712
+ if (!shaderResult.ok) throw shaderResult.error;
5713
+ compiled.push({ stage, label, source: entry.wgsl, module: shaderResult.value });
5714
+ }
5715
+ for (const { label, module } of compiled) seedShaderModule(label, module);
5716
+ setVolumetricFogShaderSources({
5717
+ inject: compiled.find((candidate) => candidate.stage === 'inject')?.source as string,
5718
+ temporal: compiled.find((candidate) => candidate.stage === 'temporal')?.source as string,
5719
+ integrate: compiled.find((candidate) => candidate.stage === 'integrate')
5720
+ ?.source as string,
5721
+ composite: compiled.find((candidate) => candidate.stage === 'composite')
5722
+ ?.source as string,
5723
+ });
5724
+ }
5725
+ }
5726
+
5483
5727
  const pbrShaderResult = await runShimStep(
5484
5728
  () =>
5485
5729
  asyncCreateShaderModule
@@ -5511,7 +5755,11 @@ async function buildReadyWebGPU(
5511
5755
  (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5512
5756
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
5513
5757
  const transmissionVariants = transmissionCapable
5514
- ? selectStandardPbrTransmissionPrewarmVariants(pbrManifestEntry, storageBufferCapable)
5758
+ ? selectStandardPbrTransmissionPrewarmVariants(
5759
+ pbrManifestEntry,
5760
+ storageBufferCapable,
5761
+ projectorAvailable,
5762
+ )
5515
5763
  : [];
5516
5764
 
5517
5765
  // HDRP uses the canonical all-true variant key (`''`) and the lazy
@@ -5549,6 +5797,7 @@ async function buildReadyWebGPU(
5549
5797
  pbrManifestEntry,
5550
5798
  storageBufferCapable,
5551
5799
  transmissionCapable,
5800
+ projectorAvailable,
5552
5801
  )) {
5553
5802
  const moduleLabel = `module-forgeax::default-standard-pbr#${hdrpVariant.definesKey}`;
5554
5803
  let hdrpModule = prewarmedHdrpModules.get(hdrpVariant.composedWgsl);
@@ -6042,7 +6291,13 @@ async function buildReadyWebGPU(
6042
6291
  // buildReadyWebGPU (above) so Step 2 (variant patch) and this layout
6043
6292
  // build read the same value.
6044
6293
  const pbrLayouts = runShimSyncStep(
6045
- () => ok(buildPbrPipelineLayouts(rhiDevice, { storageBuffer: storageBufferCapable })),
6294
+ () =>
6295
+ ok(
6296
+ buildPbrPipelineLayouts(rhiDevice, {
6297
+ storageBuffer: storageBufferCapable,
6298
+ projectorAvailable,
6299
+ }),
6300
+ ),
6046
6301
  'webgpu-runtime-error',
6047
6302
  'buildPbrPipelineLayouts succeeded',
6048
6303
  'check device.limits.maxBindingsPerBindGroup (need >=14) and maxBindGroupsPerPipelineLayout',
@@ -8589,6 +8844,7 @@ async function buildReadyWebGPU(
8589
8844
  colorAttachmentFormat: swapChainFormats.view,
8590
8845
  surfaceProfile: surfaceViewFormats ? 'dual-view' : 'raw-only',
8591
8846
  viewBindGroupLayout: viewBglResult.value,
8847
+ projectorAvailable,
8592
8848
  materialBindGroupLayout: materialBglResult.value,
8593
8849
  meshBindGroupLayout: meshArrayBglResult.value,
8594
8850
  viewUniformBuffer: viewUboResult.value,
@@ -155,6 +155,10 @@ export function validateSpotLightData(
155
155
  if (pcfKernelSize < 1 || pcfKernelSize % 2 === 0) {
156
156
  return err(new ShadowInvalidConfigError('pcfKernelSize', pcfKernelSize, 1));
157
157
  }
158
+ const shadowIntensity = (data.shadowIntensity as number | undefined) ?? 1;
159
+ if (!Number.isFinite(shadowIntensity) || shadowIntensity < 0 || shadowIntensity > 1) {
160
+ return err(new ShadowInvalidConfigError('shadowIntensity', shadowIntensity, 0, 1));
161
+ }
158
162
  return ok(undefined);
159
163
  }
160
164
 
@@ -21,8 +21,8 @@
21
21
  // spotCount === 0" (M5 / w25).
22
22
  //
23
23
  // feat-20260625-spot-light-shadow-mapping M1 w4: embedded castShadow (default true)
24
- // + 6 shadow fields (mapSize / depthBias / normalBias / nearPlane / farPlane /
25
- // pcfKernelSize) aligned with DirectionalLight vocabulary (plan-strategy D-6;
24
+ // + 7 shadow fields (mapSize / depthBias / normalBias / nearPlane / farPlane /
25
+ // pcfKernelSize / shadowIntensity) aligned with DirectionalLight vocabulary (plan-strategy D-6;
26
26
  // charter P4 consistent abstraction). Zero-config spawns cast spot shadows;
27
27
  // set castShadow:false to opt out (validate short-circuits on false, AC-03).
28
28
  // Shadow atlas cap of 4 is enforced at extract stage, not component layer (OOS-5).
@@ -37,6 +37,17 @@
37
37
 
38
38
  import { defineComponent } from '@forgeax/engine-ecs';
39
39
 
40
+ export interface SpotLightProjector {
41
+ readonly guid: string;
42
+ readonly generation: number;
43
+ readonly revision: number;
44
+ }
45
+
46
+ export interface SpotLightAuthoring {
47
+ /** Optional GUID-backed TextureAsset projector; absent keeps the normal spot path. */
48
+ readonly projector?: SpotLightProjector;
49
+ }
50
+
40
51
  /**
41
52
  * Cone-restricted spot light (KHR_lights_punctual `spot` type). Casts shadows
42
53
  * by default (castShadow defaults to true) — zero-config spawns project hard
@@ -71,6 +82,7 @@ import { defineComponent } from '@forgeax/engine-ecs';
71
82
  * nearPlane — shadow-camera near (default 0.1)
72
83
  * farPlane — shadow-camera far (default 50)
73
84
  * pcfKernelSize odd >= 1 — PCF kernel width (default 3)
85
+ * shadowIntensity in [0,1] — residual light in shadow (default 1)
74
86
  *
75
87
  * Atlas capacity is capped at 4 castShadow spot lights by the extract stage;
76
88
  * the 5th light onward keeps direct illumination but shadowAtlasTile = -1
@@ -114,11 +126,18 @@ export const SpotLight = defineComponent('SpotLight', {
114
126
  outerConeDeg: { type: 'f32', default: 45 },
115
127
  // Shadow opt-out gate: defaults to true so zero-config spawns cast shadows.
116
128
  castShadow: { type: 'bool', default: true },
117
- // 6 shadow fields aligned with DirectionalLight (plan-strategy D-6).
129
+ // 7 shadow fields aligned with the shared light shadow vocabulary.
118
130
  mapSize: { type: 'f32', default: 2048 },
119
131
  depthBias: { type: 'f32', default: 0.005 },
120
132
  normalBias: { type: 'f32', default: 0.05 },
121
133
  nearPlane: { type: 'f32', default: 0.1 },
122
134
  farPlane: { type: 'f32', default: 50 },
123
135
  pcfKernelSize: { type: 'f32', default: 3 },
136
+ // Three's SpotLight.shadow.intensity is the fraction of visibility applied
137
+ // to a shadowed contribution. Keep it in the author-facing [0,1] domain;
138
+ // extract/record transport this one fact to surface and volume consumers.
139
+ shadowIntensity: { type: 'f32', default: 1 },
140
+ // Shared TextureAsset identity; publication and residency remain owned by
141
+ // the existing asset and GPU resource owners.
142
+ projector: { type: 'shared<TextureAsset>', simulationTransient: true },
124
143
  });