@forgeax/engine-render 0.1.32 → 0.1.33

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 (250) hide show
  1. package/README.md +379 -33
  2. package/dist/assembly/analytic-fog-registration.d.ts +3 -0
  3. package/dist/assembly/analytic-fog-registration.d.ts.map +1 -0
  4. package/dist/assembly/fxaa-registration.d.ts +4 -0
  5. package/dist/assembly/fxaa-registration.d.ts.map +1 -0
  6. package/dist/assembly/lod-observation.d.ts +4 -0
  7. package/dist/assembly/lod-observation.d.ts.map +1 -0
  8. package/dist/assembly/motion-blur-registration.d.ts +3 -0
  9. package/dist/assembly/motion-blur-registration.d.ts.map +1 -0
  10. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  11. package/dist/assembly/temporal-post-process-prewarm.d.ts +13 -0
  12. package/dist/assembly/temporal-post-process-prewarm.d.ts.map +1 -0
  13. package/dist/assembly/webgpu-ready-contract.d.ts +4 -3
  14. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -1
  15. package/dist/assembly/webgpu-ready.d.ts +1 -1
  16. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  17. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  18. package/dist/bloom-admission.d.ts +13 -0
  19. package/dist/bloom-admission.d.ts.map +1 -0
  20. package/dist/{chunk-DRR7D3KX.mjs → chunk-3RTCJTH4.mjs} +12 -9
  21. package/dist/chunk-3RTCJTH4.mjs.map +1 -0
  22. package/dist/{chunk-S665NJNF.mjs → chunk-LEAXOMYJ.mjs} +3 -3
  23. package/dist/{chunk-S665NJNF.mjs.map → chunk-LEAXOMYJ.mjs.map} +1 -1
  24. package/dist/{chunk-6TFBXLRJ.mjs → chunk-NB2S7ZRX.mjs} +155 -11
  25. package/dist/chunk-NB2S7ZRX.mjs.map +1 -0
  26. package/dist/{chunk-LULCA3OF.mjs → chunk-NNQL7CB3.mjs} +4 -4
  27. package/dist/{chunk-LULCA3OF.mjs.map → chunk-NNQL7CB3.mjs.map} +1 -1
  28. package/dist/{chunk-4NNZQCL3.mjs → chunk-OO3DRYK7.mjs} +66 -200
  29. package/dist/chunk-OO3DRYK7.mjs.map +1 -0
  30. package/dist/{chunk-GWCEMDVR.mjs → chunk-PLAXOOX5.mjs} +232 -7
  31. package/dist/chunk-PLAXOOX5.mjs.map +1 -0
  32. package/dist/{chunk-JRIPCMYC.mjs → chunk-VOZWDM3L.mjs} +283 -193
  33. package/dist/chunk-VOZWDM3L.mjs.map +1 -0
  34. package/dist/{chunk-JCIRYQ4V.mjs → chunk-ZDQWGQW7.mjs} +10 -4
  35. package/dist/chunk-ZDQWGQW7.mjs.map +1 -0
  36. package/dist/components/atmosphere.d.ts +2 -0
  37. package/dist/components/atmosphere.d.ts.map +1 -1
  38. package/dist/components/camera.d.ts +20 -4
  39. package/dist/components/camera.d.ts.map +1 -1
  40. package/dist/components/mesh-filter.d.ts +6 -5
  41. package/dist/components/mesh-filter.d.ts.map +1 -1
  42. package/dist/components/motion-blur.d.ts +1 -0
  43. package/dist/components/motion-blur.d.ts.map +1 -1
  44. package/dist/construct-renderer.mjs +2865 -526
  45. package/dist/construct-renderer.mjs.map +1 -1
  46. package/dist/device/gpu-residency.d.ts +6 -0
  47. package/dist/device/gpu-residency.d.ts.map +1 -1
  48. package/dist/environment/analytic-fog.d.ts +6 -0
  49. package/dist/environment/analytic-fog.d.ts.map +1 -0
  50. package/dist/errors/render.d.ts +6 -2
  51. package/dist/errors/render.d.ts.map +1 -1
  52. package/dist/extract/camera.d.ts.map +1 -1
  53. package/dist/extract/contracts.d.ts +7 -0
  54. package/dist/extract/contracts.d.ts.map +1 -1
  55. package/dist/extract/environment.d.ts +2 -0
  56. package/dist/extract/environment.d.ts.map +1 -1
  57. package/dist/features/error-reporting.d.ts +13 -0
  58. package/dist/features/error-reporting.d.ts.map +1 -0
  59. package/dist/features/host.d.ts +5 -0
  60. package/dist/features/host.d.ts.map +1 -1
  61. package/dist/features/motion-blur/motion-blur-feature.d.ts +20 -2
  62. package/dist/features/motion-blur/motion-blur-feature.d.ts.map +1 -1
  63. package/dist/features/motion-blur/motion-blur-params.d.ts +21 -0
  64. package/dist/features/motion-blur/motion-blur-params.d.ts.map +1 -1
  65. package/dist/features/motion-blur/motion-blur-runtime.d.ts +54 -0
  66. package/dist/features/motion-blur/motion-blur-runtime.d.ts.map +1 -0
  67. package/dist/features/plan.d.ts +7 -4
  68. package/dist/features/plan.d.ts.map +1 -1
  69. package/dist/features/prepared-gpu-work.d.ts +9 -5
  70. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  71. package/dist/features/render-graph-compute.d.ts.map +1 -1
  72. package/dist/features/render-graph-contribution.d.ts.map +1 -1
  73. package/dist/gpu-driven/material-artifacts.d.ts.map +1 -1
  74. package/dist/gpu-driven/prepared-draw.d.ts +7 -0
  75. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -1
  76. package/dist/gpu-driven/production-raster.d.ts +3 -1
  77. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  78. package/dist/gpu-driven/view-gpu.d.ts +9 -0
  79. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  80. package/dist/gpu-scene.d.ts +6 -1
  81. package/dist/gpu-scene.d.ts.map +1 -1
  82. package/dist/index.d.ts +5 -2
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.mjs +5 -5
  85. package/dist/inspection-types.d.ts +43 -2
  86. package/dist/inspection-types.d.ts.map +1 -1
  87. package/dist/instances.d.ts +8 -1
  88. package/dist/instances.d.ts.map +1 -1
  89. package/dist/internal.mjs +6 -6
  90. package/dist/pipeline/standard-post.d.ts +28 -19
  91. package/dist/pipeline/standard-post.d.ts.map +1 -1
  92. package/dist/pipeline-spec.d.ts +2 -2
  93. package/dist/record/frame-snapshot.d.ts +3 -0
  94. package/dist/record/frame-snapshot.d.ts.map +1 -1
  95. package/dist/record/frame-targets.d.ts.map +1 -1
  96. package/dist/record/frame.d.ts +5 -2
  97. package/dist/record/frame.d.ts.map +1 -1
  98. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  99. package/dist/record/mesh-ssbo.d.ts +6 -5
  100. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  101. package/dist/record/render-context.d.ts +12 -28
  102. package/dist/record/render-context.d.ts.map +1 -1
  103. package/dist/record/shadow-pass.d.ts.map +1 -1
  104. package/dist/record/skybox-post-pass.d.ts +5 -3
  105. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  106. package/dist/record/typed-frame-graph.d.ts +1 -1
  107. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  108. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  109. package/dist/render-contract.d.ts +24 -4
  110. package/dist/render-contract.d.ts.map +1 -1
  111. package/dist/render-pipeline.d.ts +3 -1
  112. package/dist/render-pipeline.d.ts.map +1 -1
  113. package/dist/render-system-extract-tail.d.ts.map +1 -1
  114. package/dist/render-system-extract.d.ts +8 -1
  115. package/dist/render-system-extract.d.ts.map +1 -1
  116. package/dist/render-system.d.ts +7 -1
  117. package/dist/render-system.d.ts.map +1 -1
  118. package/dist/scene/render-scene.d.ts +2 -0
  119. package/dist/scene/render-scene.d.ts.map +1 -1
  120. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  121. package/dist/temporal/frame.d.ts +7 -0
  122. package/dist/temporal/frame.d.ts.map +1 -1
  123. package/dist/temporal/index.mjs +5 -5
  124. package/dist/temporal/view.d.ts +6 -1
  125. package/dist/temporal/view.d.ts.map +1 -1
  126. package/dist/typed-render-graph-primitives.d.ts +10 -7
  127. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  128. package/dist/volume/component.d.ts +0 -1
  129. package/dist/volume/component.d.ts.map +1 -1
  130. package/dist/volume/passes.d.ts.map +1 -1
  131. package/dist/volume/temporal.d.ts +3 -1
  132. package/dist/volume/temporal.d.ts.map +1 -1
  133. package/package.json +21 -21
  134. package/src/__tests__/analytic-fog-graph.unit.test.ts +199 -0
  135. package/src/__tests__/analytic-fog-optics.unit.test.ts +38 -0
  136. package/src/__tests__/bloom-exact-zero.unit.test.ts +112 -18
  137. package/src/__tests__/bloom-lifecycle.integration.test.ts +17 -4
  138. package/src/__tests__/dynamic-resolution-plugin.integration.test.ts +20 -0
  139. package/src/__tests__/environment-fog-lanes.integration.test.ts +14 -0
  140. package/src/__tests__/environment-selection.unit.test.ts +12 -0
  141. package/src/__tests__/factory-contract.integration.test.ts +331 -68
  142. package/src/__tests__/frame-camera-selection.unit.test.ts +39 -1
  143. package/src/__tests__/frame-plan-contract.unit.test.ts +2 -0
  144. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +2 -1
  145. package/src/__tests__/gpu-driven-shadow-views.dawn.test.ts +1 -1
  146. package/src/__tests__/gpu-driven-shadow-views.integration.test.ts +1 -1
  147. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +11 -0
  148. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +12 -0
  149. package/src/__tests__/gpu-scene.unit.test.ts +91 -0
  150. package/src/__tests__/instances-store.unit.test.ts +75 -0
  151. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +31 -0
  152. package/src/__tests__/motion-blur-compute.dawn.test.ts +583 -0
  153. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +21 -0
  154. package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +86 -0
  155. package/src/__tests__/public-surface.test-d.ts +8 -0
  156. package/src/__tests__/render-error-code-owner.test-d.ts +2 -0
  157. package/src/__tests__/render-feature-error-reporting.unit.test.ts +24 -0
  158. package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +23 -0
  159. package/src/__tests__/render-scene-temporal-retry.unit.test.ts +31 -0
  160. package/src/__tests__/renderer-frame-transaction.integration.test.ts +7 -1
  161. package/src/__tests__/shader-manifest-fixture.ts +4 -4
  162. package/src/__tests__/skinned-shadow-caster.test.ts +2 -0
  163. package/src/__tests__/standard-lighting-write.unit.test.ts +2 -1
  164. package/src/__tests__/standard-pipeline.dawn.test.ts +1 -1
  165. package/src/__tests__/standard-pipeline.integration.test.ts +18 -8
  166. package/src/__tests__/temporal-reset-matrix.unit.test.ts +10 -0
  167. package/src/__tests__/temporal-view.unit.test.ts +2 -1
  168. package/src/__tests__/typed-pipeline-topology.unit.test.ts +20 -4
  169. package/src/__tests__/volume-optics-oracle.unit.test.ts +3 -1
  170. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +177 -13
  171. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +447 -61
  172. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +27 -0
  173. package/src/__tests__/volumetric-fog-topology.unit.test.ts +19 -3
  174. package/src/assembly/analytic-fog-registration.ts +14 -0
  175. package/src/assembly/fxaa-registration.ts +17 -0
  176. package/src/assembly/lod-observation.ts +12 -0
  177. package/src/assembly/motion-blur-registration.ts +17 -0
  178. package/src/assembly/renderer-facade.ts +2 -0
  179. package/src/assembly/temporal-post-process-prewarm.ts +40 -0
  180. package/src/assembly/webgpu-ready-contract.ts +8 -3
  181. package/src/assembly/webgpu-ready.ts +181 -245
  182. package/src/assembly/webgpu-renderer.ts +16 -31
  183. package/src/bloom-admission.ts +21 -0
  184. package/src/components/__tests__/motion-blur.unit.test.ts +1 -0
  185. package/src/components/atmosphere.ts +4 -0
  186. package/src/components/camera.ts +66 -10
  187. package/src/components/mesh-filter.ts +11 -9
  188. package/src/components/motion-blur.ts +1 -0
  189. package/src/device/gpu-residency.ts +56 -12
  190. package/src/environment/analytic-fog.ts +44 -0
  191. package/src/environment/background.ts +2 -2
  192. package/src/errors/render.ts +14 -1
  193. package/src/extract/camera.ts +20 -7
  194. package/src/extract/contracts.ts +7 -0
  195. package/src/extract/environment.ts +4 -0
  196. package/src/features/error-reporting.ts +78 -0
  197. package/src/features/host.ts +25 -12
  198. package/src/features/motion-blur/__tests__/motion-blur-feature.unit.test.ts +111 -1
  199. package/src/features/motion-blur/__tests__/motion-blur-params.unit.test.ts +58 -3
  200. package/src/features/motion-blur/motion-blur-feature.ts +1519 -15
  201. package/src/features/motion-blur/motion-blur-params.ts +70 -2
  202. package/src/features/motion-blur/motion-blur-runtime.ts +176 -0
  203. package/src/features/plan.ts +25 -8
  204. package/src/features/prepared-gpu-work.ts +86 -35
  205. package/src/features/render-graph-compute.ts +13 -5
  206. package/src/features/render-graph-contribution.ts +2 -0
  207. package/src/gpu-driven/material-artifacts.ts +2 -7
  208. package/src/gpu-driven/prepared-draw.ts +15 -10
  209. package/src/gpu-driven/production-raster.ts +83 -7
  210. package/src/gpu-driven/view-gpu.ts +17 -0
  211. package/src/gpu-scene.ts +188 -21
  212. package/src/index.ts +32 -0
  213. package/src/inspection-types.ts +60 -2
  214. package/src/instances.ts +80 -1
  215. package/src/pipeline/standard-post.ts +307 -78
  216. package/src/pipeline-spec.ts +5 -5
  217. package/src/points-lines/__tests__/view-ubo.unit.test.ts +2 -1
  218. package/src/record/__tests__/temporal-payload.unit.test.ts +2 -1
  219. package/src/record/frame-snapshot.ts +5 -1
  220. package/src/record/frame-targets.ts +10 -6
  221. package/src/record/frame.ts +163 -22
  222. package/src/record/main-pass-geometry.ts +30 -1
  223. package/src/record/main-pass-sprite-draws.ts +6 -1
  224. package/src/record/mesh-ssbo.ts +43 -6
  225. package/src/record/render-context.ts +12 -28
  226. package/src/record/shadow-pass.ts +6 -1
  227. package/src/record/skybox-post-pass.ts +122 -196
  228. package/src/record/typed-frame-graph.ts +153 -9
  229. package/src/recovery/render-system-candidate.ts +22 -10
  230. package/src/render-contract.ts +23 -4
  231. package/src/render-pipeline.ts +6 -1
  232. package/src/render-system-extract-tail.ts +16 -0
  233. package/src/render-system-extract.ts +45 -13
  234. package/src/render-system.ts +255 -120
  235. package/src/scene/render-scene.ts +85 -4
  236. package/src/temporal/__tests__/temporal-frame-transaction.unit.test.ts +36 -0
  237. package/src/temporal/frame-coordinator.ts +8 -1
  238. package/src/temporal/frame.ts +20 -0
  239. package/src/temporal/view.ts +13 -2
  240. package/src/typed-render-graph-primitives.ts +121 -48
  241. package/src/volume/component.ts +0 -1
  242. package/src/volume/passes.ts +8 -4
  243. package/src/volume/resources.ts +1 -1
  244. package/src/volume/temporal.ts +11 -0
  245. package/dist/chunk-4NNZQCL3.mjs.map +0 -1
  246. package/dist/chunk-6TFBXLRJ.mjs.map +0 -1
  247. package/dist/chunk-DRR7D3KX.mjs.map +0 -1
  248. package/dist/chunk-GWCEMDVR.mjs.map +0 -1
  249. package/dist/chunk-JCIRYQ4V.mjs.map +0 -1
  250. package/dist/chunk-JRIPCMYC.mjs.map +0 -1
@@ -83,6 +83,10 @@ async function createBloomScenario() {
83
83
  antialias: ANTIALIAS_NONE,
84
84
  tonemap: TONEMAP_ACES_FILMIC,
85
85
  bloom: BLOOM_ENABLED,
86
+ bloomThreshold: 1,
87
+ bloomIntensity: 1,
88
+ bloomSoftKnee: 0.5,
89
+ bloomScatter: 0.7,
86
90
  },
87
91
  },
88
92
  )
@@ -103,10 +107,61 @@ async function createBloomScenario() {
103
107
  const updated = world.update();
104
108
  if (!updated.ok) throw updated.error;
105
109
  };
110
+ const setBloomIntensity = (bloomIntensity: number) => {
111
+ world.set(camera, LocalCamera, { bloomIntensity }).unwrap();
112
+ const updated = world.update();
113
+ if (!updated.ok) throw updated.error;
114
+ };
106
115
  const dispose = async () => {
107
116
  await renderer.dispose();
108
117
  };
109
- return { renderer, draw, setBloom, dispose, canvas };
118
+ return { renderer, draw, setBloom, setBloomIntensity, dispose, canvas };
119
+ }
120
+
121
+ async function createMotionBlurScenario() {
122
+ const renderer = await constructRenderer(
123
+ { width: 64, height: 64, getContext: () => null },
124
+ { rhi },
125
+ { shaderManifestUrl: renderLifecycleManifestUrl() },
126
+ );
127
+ if (!(await renderer.initialization).ok)
128
+ throw new Error('Motion Blur renderer initialization failed');
129
+ const world = new World();
130
+ const attached = renderer.attach(world);
131
+ if (!attached.ok) throw attached.error;
132
+ registerPropagateTransforms(world);
133
+ const camera = world
134
+ .spawn(
135
+ { component: Transform, data: { pos: [0, 0, 4] } },
136
+ {
137
+ component: LocalCamera,
138
+ data: {
139
+ fov: 1,
140
+ aspect: 1,
141
+ near: 0.1,
142
+ far: 100,
143
+ antialias: ANTIALIAS_NONE,
144
+ tonemap: TONEMAP_ACES_FILMIC,
145
+ },
146
+ },
147
+ )
148
+ .unwrap();
149
+ world
150
+ .addComponent(camera, {
151
+ component: MotionBlur,
152
+ data: { shutterAngle: 180, maxRadiusPixels: 8, sampleCount: 4, targetFps: 60 },
153
+ })
154
+ .unwrap();
155
+ const frameInput = () => ({
156
+ leases: [attached.value],
157
+ camera: { lease: attached.value },
158
+ environment: { lease: attached.value },
159
+ });
160
+ return {
161
+ renderer,
162
+ draw: () => renderer.draw(frameInput()),
163
+ dispose: () => renderer.dispose(),
164
+ };
110
165
  }
111
166
 
112
167
  describe('factory contract', () => {
@@ -1086,14 +1141,111 @@ describe('factory contract', () => {
1086
1141
  }
1087
1142
  const inspection = renderer.inspect();
1088
1143
  expect(inspection.perFramePassNames).toContain('motion-blur');
1144
+ // The same Rhi device/frame admitted the compute plan, graph, and both
1145
+ // production dispatches. This is the application-side admission proof;
1146
+ // Dawn/physical timing remains a separate backend gate.
1147
+ expect(inspection.capabilities).toMatchObject({
1148
+ compute: true,
1149
+ storageBuffer: true,
1150
+ storageTexture: true,
1151
+ rgba16floatRenderable: true,
1152
+ });
1153
+ expect(inspection.motionBlur).toMatchObject({ lane: 'compute', passCount: 2 });
1154
+ expect(inspection.perFramePassNames).toContain('motion-blur');
1155
+ expect(inspection.recoveryEvidence.graph).toMatchObject({ ready: true });
1156
+ expect(inspection.featureGraph?.compileSuccesses ?? 0).toBeGreaterThan(0);
1157
+ // The graph is present, but this is the first accepted temporal pair
1158
+ // after the effect was added. Inspection must expose the reset boundary
1159
+ // until a subsequent successful pair can provide a valid baseline.
1089
1160
  expect(inspection.motionBlur).toMatchObject({
1090
1161
  enabled: true,
1091
- status: 'active',
1162
+ status: 'reset',
1092
1163
  temporalDemand: 'scene-data-temporal-v1',
1093
1164
  });
1094
1165
  await renderer.dispose();
1095
1166
  });
1096
1167
 
1168
+ it('publishes invalid MotionBlur parameters through renderer inspection', async () => {
1169
+ const renderer = await constructRenderer(
1170
+ { width: 64, height: 64, getContext: () => null },
1171
+ { rhi },
1172
+ { shaderManifestUrl: renderLifecycleManifestUrl() },
1173
+ );
1174
+ expect((await renderer.initialization).ok).toBe(true);
1175
+ const errors: unknown[] = [];
1176
+ renderer.onError((error) => errors.push(error));
1177
+ const world = new World();
1178
+ const attached = renderer.attach(world);
1179
+ expect(attached.ok).toBe(true);
1180
+ if (!attached.ok) return;
1181
+ registerPropagateTransforms(world);
1182
+ const camera = world
1183
+ .spawn(
1184
+ { component: Transform, data: { pos: [0, 0, 4] } },
1185
+ {
1186
+ component: LocalCamera,
1187
+ data: { fov: 1, aspect: 1, near: 0.1, far: 100, antialias: ANTIALIAS_TAA },
1188
+ },
1189
+ { component: MotionBlur, data: { targetFps: 59.5 } },
1190
+ )
1191
+ .unwrap();
1192
+ const frameInput = () => ({
1193
+ leases: [attached.value],
1194
+ camera: { lease: attached.value },
1195
+ environment: { lease: attached.value },
1196
+ });
1197
+ expect(world.update().ok).toBe(true);
1198
+
1199
+ const draw = renderer.draw(frameInput());
1200
+ expect(draw.ok).toBe(false);
1201
+ if (!draw.ok) {
1202
+ expect(draw.error).toMatchObject({
1203
+ code: 'motion-blur-invalid-params',
1204
+ detail: { field: 'targetFps', value: 59.5, integer: true },
1205
+ });
1206
+ }
1207
+ expect(errors).toEqual(
1208
+ expect.arrayContaining([expect.objectContaining({ code: 'motion-blur-invalid-params' })]),
1209
+ );
1210
+ expect(renderer.inspect().motionBlur).toMatchObject({
1211
+ enabled: true,
1212
+ status: 'invalid',
1213
+ lastFailure: 'invalid-params',
1214
+ temporalDemand: 'scene-data-temporal-v1',
1215
+ });
1216
+ await renderer.dispose();
1217
+ void camera;
1218
+ });
1219
+
1220
+ it('projects a rejected Motion Blur submit while retaining its accepted baseline', async () => {
1221
+ const scenario = await createMotionBlurScenario();
1222
+ const submit = vi.spyOn(RhiNullQueue.prototype, 'submit');
1223
+ const failure = new RhiError({
1224
+ code: 'webgpu-runtime-error',
1225
+ expected: 'Motion Blur queue submission succeeds',
1226
+ hint: 'repair the queue and retry the same frame',
1227
+ });
1228
+ try {
1229
+ expect(scenario.draw().ok).toBe(true);
1230
+ expect(scenario.draw().ok).toBe(true);
1231
+ const accepted = scenario.renderer.inspect().motionBlur;
1232
+ expect(accepted).toMatchObject({ enabled: true });
1233
+ expect(accepted).not.toHaveProperty('lastFailure');
1234
+ submit.mockReturnValueOnce(err(failure));
1235
+ expect(scenario.draw().ok).toBe(false);
1236
+ expect(scenario.renderer.inspect().motionBlur).toMatchObject({
1237
+ enabled: true,
1238
+ lastFailure: 'submit-failure',
1239
+ });
1240
+ submit.mockRestore();
1241
+ expect(scenario.draw().ok).toBe(true);
1242
+ expect(scenario.renderer.inspect().motionBlur?.lastFailure).toBeUndefined();
1243
+ } finally {
1244
+ submit.mockRestore();
1245
+ await scenario.dispose();
1246
+ }
1247
+ });
1248
+
1097
1249
  it('retires factory-owned TAA textures and params on an off submit', async () => {
1098
1250
  const created = new Map<object, string>();
1099
1251
  const destroyed = new Set<object>();
@@ -1230,12 +1382,12 @@ describe('factory contract', () => {
1230
1382
  const onInspection = renderer.inspect().bloom;
1231
1383
  expect(onInspection.state).toBe('active');
1232
1384
  expect(onInspection.graphStatus).toBe('valid');
1233
- expect(onInspection.targetCount).toBe(4);
1234
- expect(onInspection.targetBytes).toBe((64 * 64 + 3 * 32 * 32) * 8);
1235
- expect(onInspection.passCount).toBe(4);
1236
- expect(onInspection.uploadCount).toBe(4);
1237
- expect(onInspection.bindGroupCount).toBe(4);
1238
- expect(onInspection.encodeCount).toBe(4);
1385
+ expect(onInspection.targetCount).toBe(10);
1386
+ expect(onInspection.targetBytes).toBe(54_560);
1387
+ expect(onInspection.passCount).toBe(10);
1388
+ expect(onInspection.uploadCount).toBe(10);
1389
+ expect(onInspection.bindGroupCount).toBe(10);
1390
+ expect(onInspection.encodeCount).toBe(10);
1239
1391
  expect(JSON.parse(JSON.stringify(onInspection))).toEqual(onInspection);
1240
1392
  const firstGeneration = onInspection.generation;
1241
1393
  expect(firstGeneration).toBeGreaterThan(0);
@@ -1266,10 +1418,10 @@ describe('factory contract', () => {
1266
1418
  const recoveredInspection = renderer.inspect().bloom;
1267
1419
  expect(recoveredInspection.state).toBe('active');
1268
1420
  expect(recoveredInspection.resourceCount).toBeGreaterThan(0);
1269
- expect(recoveredInspection.passCount).toBe(4);
1270
- expect(recoveredInspection.uploadCount).toBe(4);
1271
- expect(recoveredInspection.bindGroupCount).toBe(4);
1272
- expect(recoveredInspection.encodeCount).toBe(4);
1421
+ expect(recoveredInspection.passCount).toBe(10);
1422
+ expect(recoveredInspection.uploadCount).toBe(10);
1423
+ expect(recoveredInspection.bindGroupCount).toBe(10);
1424
+ expect(recoveredInspection.encodeCount).toBe(10);
1273
1425
  expect(recoveredInspection.generation).toBeGreaterThan(firstGeneration);
1274
1426
  await Promise.resolve();
1275
1427
  await Promise.resolve();
@@ -1290,25 +1442,25 @@ describe('factory contract', () => {
1290
1442
  try {
1291
1443
  expect(scenario.draw().ok).toBe(true);
1292
1444
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1293
- uploadCount: 4,
1294
- bindGroupCount: 4,
1295
- encodeCount: 4,
1445
+ uploadCount: 10,
1446
+ bindGroupCount: 10,
1447
+ encodeCount: 10,
1296
1448
  });
1297
1449
 
1298
1450
  submit.mockReturnValueOnce(err(failure));
1299
1451
  expect(scenario.draw().ok).toBe(false);
1300
1452
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1301
1453
  state: 'active',
1302
- uploadCount: 4,
1303
- bindGroupCount: 4,
1304
- encodeCount: 4,
1454
+ uploadCount: 10,
1455
+ bindGroupCount: 10,
1456
+ encodeCount: 10,
1305
1457
  });
1306
1458
 
1307
1459
  expect(scenario.draw().ok).toBe(true);
1308
1460
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1309
- uploadCount: 4,
1310
- bindGroupCount: 4,
1311
- encodeCount: 4,
1461
+ uploadCount: 10,
1462
+ bindGroupCount: 10,
1463
+ encodeCount: 10,
1312
1464
  });
1313
1465
  } finally {
1314
1466
  submit.mockRestore();
@@ -1316,6 +1468,26 @@ describe('factory contract', () => {
1316
1468
  }
1317
1469
  });
1318
1470
 
1471
+ it('uploads positive Bloom parameter changes without rebuilding the accepted bundle', async () => {
1472
+ const scenario = await createBloomScenario();
1473
+ try {
1474
+ expect(scenario.draw().ok).toBe(true);
1475
+ const first = scenario.renderer.inspect().bloom;
1476
+ scenario.setBloomIntensity(0.35);
1477
+ expect(scenario.draw().ok).toBe(true);
1478
+ const updated = scenario.renderer.inspect().bloom;
1479
+ expect(updated.state).toBe('active');
1480
+ expect(updated.generation).toBe(first.generation);
1481
+ expect(updated.resourceCount).toBe(first.resourceCount);
1482
+ expect(updated.targetCount).toBe(first.targetCount);
1483
+ expect(updated.targetBytes).toBe(first.targetBytes);
1484
+ expect(updated.uploadCount).toBe(10);
1485
+ expect(updated.passCount).toBe(10);
1486
+ } finally {
1487
+ await scenario.dispose();
1488
+ }
1489
+ });
1490
+
1319
1491
  it('aborts a Bloom pass write failure without publishing partial receipts', async () => {
1320
1492
  const originalWriteBuffer = RhiNullQueue.prototype.writeBuffer;
1321
1493
  const createBuffer = vi.spyOn(RhiNullDevice.prototype, 'createBuffer');
@@ -1324,30 +1496,30 @@ describe('factory contract', () => {
1324
1496
  const scenario = await createBloomScenario();
1325
1497
  const failure = new RhiError({
1326
1498
  code: 'webgpu-runtime-error',
1327
- expected: 'Bloom blur-H params upload succeeds',
1499
+ expected: 'Bloom downsample params upload succeeds',
1328
1500
  hint: 'repair the Bloom params buffer and retry the frame',
1329
1501
  });
1330
1502
  const errors: unknown[] = [];
1331
1503
  const unsubscribe = scenario.renderer.onError((error) => errors.push(error));
1332
1504
  try {
1333
1505
  expect(scenario.draw().ok).toBe(true);
1334
- const blurHParams = createBuffer.mock.calls.reduce<object | undefined>(
1506
+ const downsampleParams = createBuffer.mock.calls.reduce<object | undefined>(
1335
1507
  (found, args, index) => {
1336
- if (found !== undefined || args[0].label !== 'bloom-blur-h-params-ubo') return found;
1508
+ if (found !== undefined || args[0].label !== 'bloom-downsample-params-ubo') return found;
1337
1509
  const result = createBuffer.mock.results[index];
1338
1510
  if (result?.type !== 'return' || !result.value.ok) return undefined;
1339
1511
  return result.value.value as object;
1340
1512
  },
1341
1513
  undefined,
1342
1514
  );
1343
- expect(blurHParams).toBeDefined();
1515
+ expect(downsampleParams).toBeDefined();
1344
1516
  const lkg = scenario.renderer.inspect().bloom;
1345
- expect(lkg).toMatchObject({ uploadCount: 4, bindGroupCount: 4, encodeCount: 4 });
1517
+ expect(lkg).toMatchObject({ uploadCount: 10, bindGroupCount: 10, encodeCount: 10 });
1346
1518
  const submitCount = submit.mock.calls.length;
1347
1519
 
1348
1520
  let injected = false;
1349
1521
  writeBuffer.mockImplementation((buffer, ...args) => {
1350
- if (buffer === blurHParams && !injected) {
1522
+ if (buffer === downsampleParams && !injected) {
1351
1523
  injected = true;
1352
1524
  return err(failure);
1353
1525
  }
@@ -1359,23 +1531,23 @@ describe('factory contract', () => {
1359
1531
  expect(errors).toHaveLength(1);
1360
1532
  expect(errors[0]).toMatchObject({
1361
1533
  code: 'pass-encode-failed',
1362
- detail: { passName: 'bloom-blur-h', cause: failure },
1534
+ detail: { passName: 'bloom-downsample-0', cause: failure },
1363
1535
  });
1364
1536
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1365
1537
  state: 'active',
1366
1538
  generation: lkg.generation,
1367
- uploadCount: 4,
1368
- bindGroupCount: 4,
1369
- encodeCount: 4,
1539
+ uploadCount: 10,
1540
+ bindGroupCount: 10,
1541
+ encodeCount: 10,
1370
1542
  });
1371
1543
 
1372
1544
  writeBuffer.mockRestore();
1373
1545
  expect(scenario.draw().ok).toBe(true);
1374
1546
  expect(submit).toHaveBeenCalledTimes(submitCount + 1);
1375
1547
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1376
- uploadCount: 4,
1377
- bindGroupCount: 4,
1378
- encodeCount: 4,
1548
+ uploadCount: 10,
1549
+ bindGroupCount: 10,
1550
+ encodeCount: 10,
1379
1551
  });
1380
1552
  } finally {
1381
1553
  writeBuffer.mockRestore();
@@ -1388,18 +1560,17 @@ describe('factory contract', () => {
1388
1560
 
1389
1561
  const bloomConstructionFailures = [
1390
1562
  ['createSampler', 'bloom-sampler'],
1391
- ['createBindGroupLayout', 'bloom-bright-bgl'],
1392
- ['createBindGroupLayout', 'bloom-blur-bgl'],
1563
+ ['createBindGroupLayout', 'bloom-downsample-bgl'],
1564
+ ['createBindGroupLayout', 'bloom-upsample-bgl'],
1393
1565
  ['createBindGroupLayout', 'bloom-composite-bgl'],
1394
- ['createPipelineLayout', 'bloom-bright-pl'],
1395
- ['createPipelineLayout', 'bloom-blur-pl'],
1566
+ ['createPipelineLayout', 'bloom-downsample-pl'],
1567
+ ['createPipelineLayout', 'bloom-upsample-pl'],
1396
1568
  ['createPipelineLayout', 'bloom-composite-pl'],
1397
- ['createRenderPipeline', 'bloom-bright-pipeline'],
1398
- ['createRenderPipeline', 'bloom-blur-h-pipeline'],
1569
+ ['createRenderPipeline', 'bloom-downsample-pipeline'],
1570
+ ['createRenderPipeline', 'bloom-upsample-pipeline'],
1399
1571
  ['createRenderPipeline', 'bloom-composite-pipeline'],
1400
- ['createBuffer', 'bloom-bright-params-ubo'],
1401
- ['createBuffer', 'bloom-blur-h-params-ubo'],
1402
- ['createBuffer', 'bloom-blur-v-params-ubo'],
1572
+ ['createBuffer', 'bloom-downsample-params-ubo'],
1573
+ ['createBuffer', 'bloom-upsample-params-ubo'],
1403
1574
  ['createBuffer', 'bloom-composite-params-ubo'],
1404
1575
  ] as const;
1405
1576
 
@@ -1464,11 +1635,11 @@ describe('factory contract', () => {
1464
1635
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1465
1636
  state: 'active',
1466
1637
  graphStatus: 'valid',
1467
- targetCount: 4,
1468
- passCount: 4,
1469
- uploadCount: 4,
1470
- bindGroupCount: 4,
1471
- encodeCount: 4,
1638
+ targetCount: 10,
1639
+ passCount: 10,
1640
+ uploadCount: 10,
1641
+ bindGroupCount: 10,
1642
+ encodeCount: 10,
1472
1643
  });
1473
1644
  } finally {
1474
1645
  operation.mockRestore();
@@ -1485,7 +1656,7 @@ describe('factory contract', () => {
1485
1656
  const submit = vi.spyOn(RhiNullQueue.prototype, 'submit');
1486
1657
  const failure = new RhiError({
1487
1658
  code: 'webgpu-runtime-error',
1488
- expected: 'Bloom blur-V bind-group creation succeeds',
1659
+ expected: 'Bloom upsample bind-group creation succeeds',
1489
1660
  hint: 'repair the Bloom bind group and retry the frame',
1490
1661
  });
1491
1662
  let injected = false;
@@ -1496,7 +1667,7 @@ describe('factory contract', () => {
1496
1667
  scenario.canvas.width = 96;
1497
1668
  scenario.canvas.height = 64;
1498
1669
  createBindGroup.mockImplementation(function (this: RhiNullDevice, descriptor) {
1499
- if (!injected && descriptor.label === 'bloom-blur-v-bg') {
1670
+ if (!injected && descriptor.label === 'bloom-upsample-3-bg') {
1500
1671
  injected = true;
1501
1672
  return err(failure);
1502
1673
  }
@@ -1508,9 +1679,9 @@ describe('factory contract', () => {
1508
1679
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1509
1680
  state: 'active',
1510
1681
  generation: lkg.generation,
1511
- uploadCount: 4,
1512
- bindGroupCount: 4,
1513
- encodeCount: 4,
1682
+ uploadCount: 10,
1683
+ bindGroupCount: 10,
1684
+ encodeCount: 10,
1514
1685
  });
1515
1686
 
1516
1687
  createBindGroup.mockRestore();
@@ -1518,9 +1689,9 @@ describe('factory contract', () => {
1518
1689
  expect(submit).toHaveBeenCalledTimes(submitCount + 1);
1519
1690
  expect(scenario.renderer.inspect().bloom).toMatchObject({
1520
1691
  state: 'active',
1521
- uploadCount: 4,
1522
- bindGroupCount: 4,
1523
- encodeCount: 4,
1692
+ uploadCount: 10,
1693
+ bindGroupCount: 10,
1694
+ encodeCount: 10,
1524
1695
  });
1525
1696
  } finally {
1526
1697
  createBindGroup.mockRestore();
@@ -1557,10 +1728,10 @@ describe('factory contract', () => {
1557
1728
  expect(overlap.state).toBe('active');
1558
1729
  expect(overlap.graphStatus).toBe('valid');
1559
1730
  expect(overlap.enabled).toBe(true);
1560
- expect(overlap.passCount).toBe(4);
1561
- expect(overlap.uploadCount).toBe(4);
1562
- expect(overlap.bindGroupCount).toBe(4);
1563
- expect(overlap.encodeCount).toBe(4);
1731
+ expect(overlap.passCount).toBe(10);
1732
+ expect(overlap.uploadCount).toBe(10);
1733
+ expect(overlap.bindGroupCount).toBe(10);
1734
+ expect(overlap.encodeCount).toBe(10);
1564
1735
  expect(overlap.resourceCount).toBeGreaterThan(retiring.resourceCount);
1565
1736
 
1566
1737
  fence.resolve(undefined);
@@ -1569,9 +1740,9 @@ describe('factory contract', () => {
1569
1740
  const settled = scenario.renderer.inspect().bloom;
1570
1741
  expect(settled.state).toBe('active');
1571
1742
  expect(settled.graphStatus).toBe('valid');
1572
- expect(settled.passCount).toBe(4);
1573
- expect(settled.targetCount).toBe(4);
1574
- expect(settled.targetBytes).toBe((64 * 64 + 3 * 32 * 32) * 8);
1743
+ expect(settled.passCount).toBe(10);
1744
+ expect(settled.targetCount).toBe(10);
1745
+ expect(settled.targetBytes).toBe(54_560);
1575
1746
  expect(settled.resourceCount).toBeLessThan(overlap.resourceCount);
1576
1747
  } finally {
1577
1748
  onSubmittedWorkDone.mockRestore();
@@ -1797,7 +1968,7 @@ describe('factory contract', () => {
1797
1968
  const destroyCounts = new Map<object, number>();
1798
1969
  const failure = new RhiError({
1799
1970
  code: 'webgpu-runtime-error',
1800
- expected: 'Bloom blur-v params buffer construction succeeds',
1971
+ expected: 'Bloom upsample params buffer construction succeeds',
1801
1972
  hint: 'repair the injected construction failure and retry the same frame',
1802
1973
  });
1803
1974
  try {
@@ -1807,7 +1978,7 @@ describe('factory contract', () => {
1807
1978
  const lkg = scenario.renderer.inspect().bloom;
1808
1979
  scenario.setBloom('on');
1809
1980
  createBuffer.mockImplementation(function (this: RhiNullDevice, descriptor) {
1810
- if (descriptor.label === 'bloom-blur-v-params-ubo') {
1981
+ if (descriptor.label === 'bloom-upsample-params-ubo') {
1811
1982
  return err(failure);
1812
1983
  }
1813
1984
  const result = originalCreateBuffer.call(this, descriptor);
@@ -1832,12 +2003,12 @@ describe('factory contract', () => {
1832
2003
  expect(scenario.renderer.inspect().bloom.resourceCount).toBe(lkg.resourceCount);
1833
2004
  expect(scenario.renderer.inspect().bloom.passCount).toBe(0);
1834
2005
  const adoptedBeforeFailure = [...created.entries()].filter(
1835
- ([, label]) => label === 'bloom-bright-params-ubo' || label === 'bloom-blur-h-params-ubo',
2006
+ ([, label]) => label === 'bloom-downsample-params-ubo',
1836
2007
  );
1837
- expect(adoptedBeforeFailure).toHaveLength(2);
2008
+ expect(adoptedBeforeFailure).toHaveLength(1);
1838
2009
  expect(adoptedBeforeFailure.every(([handle]) => destroyCounts.get(handle) === 1)).toBe(true);
1839
2010
  expect(
1840
- [...created.values()].filter((label) => label === 'bloom-blur-v-params-ubo'),
2011
+ [...created.values()].filter((label) => label === 'bloom-upsample-params-ubo'),
1841
2012
  ).toHaveLength(0);
1842
2013
  createBuffer.mockRestore();
1843
2014
  destroyBuffer.mockRestore();
@@ -1902,6 +2073,7 @@ describe('factory contract', () => {
1902
2073
  near: 0.1,
1903
2074
  far: 100,
1904
2075
  antialias: ANTIALIAS_NONE,
2076
+ tonemap: TONEMAP_ACES_FILMIC,
1905
2077
  bloom: BLOOM_ENABLED,
1906
2078
  },
1907
2079
  },
@@ -1977,6 +2149,96 @@ describe('factory contract', () => {
1977
2149
  }
1978
2150
  });
1979
2151
 
2152
+ it('does not publish Bloom resources for an intensity-zero recovery candidate', async () => {
2153
+ const adapter = new RhiNullAdapter();
2154
+ const lost = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
2155
+ const firstDevice = (await adapter.requestDevice()).unwrap();
2156
+ const replacementDevice = (await adapter.requestDevice()).unwrap();
2157
+ const lostProxy = new Proxy(firstDevice, {
2158
+ get(target, property) {
2159
+ if (property === 'lost') return lost.promise;
2160
+ const value = Reflect.get(target, property, target);
2161
+ return typeof value === 'function' ? value.bind(target) : value;
2162
+ },
2163
+ });
2164
+ let requestCount = 0;
2165
+ const recoverableRhi = {
2166
+ ...rhi,
2167
+ requestAdapter: async () =>
2168
+ ok({
2169
+ features: adapter.features,
2170
+ limits: adapter.limits,
2171
+ requestDevice: async () => {
2172
+ requestCount += 1;
2173
+ return requestCount === 1 ? ok(lostProxy) : ok(replacementDevice);
2174
+ },
2175
+ } as never),
2176
+ } as never;
2177
+ const renderer = await constructRenderer(
2178
+ { width: 64, height: 64, getContext: () => null },
2179
+ { rhi: recoverableRhi },
2180
+ { shaderManifestUrl: renderLifecycleManifestUrl() },
2181
+ );
2182
+ const world = new World();
2183
+ const attached = renderer.attach(world);
2184
+ expect(attached.ok).toBe(true);
2185
+ if (!attached.ok) return;
2186
+ registerPropagateTransforms(world);
2187
+ world.spawn(
2188
+ { component: Transform, data: { pos: [0, 0, 4] } },
2189
+ {
2190
+ component: LocalCamera,
2191
+ data: {
2192
+ fov: 1,
2193
+ aspect: 1,
2194
+ near: 0.1,
2195
+ far: 100,
2196
+ antialias: ANTIALIAS_NONE,
2197
+ tonemap: TONEMAP_ACES_FILMIC,
2198
+ bloom: BLOOM_ENABLED,
2199
+ bloomIntensity: 0,
2200
+ },
2201
+ },
2202
+ );
2203
+ const input = () => ({
2204
+ leases: [attached.value],
2205
+ camera: { lease: attached.value },
2206
+ environment: { lease: attached.value },
2207
+ });
2208
+ const assertZero = (): void => {
2209
+ expect(renderer.inspect().bloom).toMatchObject({
2210
+ graphStatus: 'empty',
2211
+ enabled: false,
2212
+ targetCount: 0,
2213
+ targetBytes: 0,
2214
+ resourceCount: 0,
2215
+ passCount: 0,
2216
+ encodeCount: 0,
2217
+ bindGroupCount: 0,
2218
+ uploadCount: 0,
2219
+ residentChildBytes: 0,
2220
+ state: 'off',
2221
+ });
2222
+ };
2223
+ try {
2224
+ expect((await renderer.initialization).ok).toBe(true);
2225
+ expect(world.update().ok).toBe(true);
2226
+ expect(renderer.draw(input()).ok).toBe(true);
2227
+ assertZero();
2228
+ lost.reject({ reason: 'unknown', message: 'forced intensity-zero recovery loss' });
2229
+ await Promise.resolve();
2230
+ await Promise.resolve();
2231
+ expect(renderer.health().reason).toBe('device-lost');
2232
+ expect((await renderer.recover()).ok).toBe(true);
2233
+ assertZero();
2234
+ expect(world.update().ok).toBe(true);
2235
+ expect(renderer.draw(input()).ok).toBe(true);
2236
+ assertZero();
2237
+ } finally {
2238
+ await renderer.dispose();
2239
+ }
2240
+ });
2241
+
1980
2242
  it('abandons a post-process recovery candidate before promotion and retries once', async () => {
1981
2243
  const adapter = new RhiNullAdapter();
1982
2244
  const lost = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
@@ -2092,6 +2354,7 @@ describe('factory contract', () => {
2092
2354
  near: 0.1,
2093
2355
  far: 100,
2094
2356
  antialias: ANTIALIAS_NONE,
2357
+ tonemap: TONEMAP_ACES_FILMIC,
2095
2358
  bloom: BLOOM_DISABLED,
2096
2359
  },
2097
2360
  },
@@ -1,6 +1,6 @@
1
1
  import { World } from '@forgeax/engine-ecs';
2
2
  import { Transform } from '@forgeax/engine-scene';
3
- import { describe, expect, it } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
4
  import { Atmosphere, Camera, DirectionalLight } from '../components';
5
5
  import {
6
6
  extractFrame,
@@ -89,6 +89,44 @@ describe('FrameCamera entity selection', () => {
89
89
  });
90
90
 
91
91
  describe('Atmosphere sun extraction', () => {
92
+ it('routes a missing sun through World, then retries after ownership is fixed', () => {
93
+ const world = new World();
94
+ world.spawn({ component: Atmosphere, data: {} }).unwrap();
95
+ spawnCamera(world, 1);
96
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
97
+ try {
98
+ expect(() =>
99
+ extractFrame(world, prepareExtractContext(world, { renderables: 'none' })),
100
+ ).toThrow(
101
+ expect.objectContaining({ code: 'sun-cardinality', detail: { field: 'sun', value: 0 } }),
102
+ );
103
+ const firstRouted = consoleError.mock.calls[0]?.[1] as
104
+ | { code?: string; detail?: { value?: number } }
105
+ | undefined;
106
+ expect(consoleError.mock.calls[0]?.[0]).toBe(
107
+ '[RenderSystem.extract (environment-selection)]',
108
+ );
109
+ expect(firstRouted?.code).toBe('sun-cardinality');
110
+ expect(firstRouted?.detail?.value).toBe(0);
111
+
112
+ // This is the direct extraction boundary. App.onError owns frame-loop
113
+ // Result failures and renderer events; World routes this internal
114
+ // producer error to its documented console boundary.
115
+
116
+ const firstSun = world
117
+ .spawn({
118
+ component: DirectionalLight,
119
+ data: { direction: [0, -0.25, 1], color: [1, 1, 1], intensity: 1 },
120
+ })
121
+ .unwrap();
122
+ const recovered = extractFrame(world, prepareExtractContext(world, { renderables: 'none' }));
123
+ expect(recovered.lights.directional?.entity).toBe(firstSun);
124
+ expect(consoleError).toHaveBeenCalledTimes(1);
125
+ } finally {
126
+ consoleError.mockRestore();
127
+ }
128
+ });
129
+
92
130
  it('keeps DirectionalLight intensity out of SunCandidate color', () => {
93
131
  const world = new World();
94
132
  world.spawn({ component: Atmosphere, data: {} }).unwrap();
@@ -13,6 +13,8 @@ const atmosphereParameters: AtmosphereParameters = {
13
13
  mieCoefficient: 0.005,
14
14
  mieDirectionalG: 0.8,
15
15
  sunAngularRadius: 0.004675,
16
+ circumsolarStrength: 1,
17
+ circumsolarWidth: 1,
16
18
  };
17
19
 
18
20
  function unwrapFrame(result: ReturnType<typeof selectEnvironmentFrame>): EnvironmentFrame {
@@ -110,7 +110,8 @@ function camera(): CameraSnapshot {
110
110
  bloom: 'off',
111
111
  bloomThreshold: 1,
112
112
  bloomIntensity: 1,
113
- bloomBlurRadius: 4,
113
+ bloomSoftKnee: 0.5,
114
+ bloomScatter: 0.7,
114
115
  clearColor: [0, 0, 0, 0],
115
116
  };
116
117
  }