@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
@@ -7,7 +7,10 @@ import { addTypedFullscreenPass } from '../../typed-render-graph-primitives';
7
7
  import type { RenderFeaturePlan, RenderFeaturePlanContext } from '../plan';
8
8
  import type { RenderFeature, RenderFeatureExtractContext } from '../types';
9
9
  import {
10
+ effectiveMotionBlurSampleCount,
11
+ MOTION_BLUR_PARAMS_BYTE_SIZE,
10
12
  type MotionBlurParams,
13
+ motionBlurExposureScale,
11
14
  motionBlurTemporalDemand,
12
15
  validateMotionBlurParams,
13
16
  } from './motion-blur-params';
@@ -32,26 +35,1516 @@ export function addMotionBlurPass(
32
35
  export interface MotionBlurFeatureFrame {
33
36
  readonly params: MotionBlurParams | undefined;
34
37
  readonly demanded: boolean;
38
+ /** Raw accepted-submit interval; it is never replaced by ECS clamp time. */
39
+ readonly frameDeltaSeconds: number;
40
+ /** Temporal reset already admitted by the shared renderer transaction. */
41
+ readonly reset: boolean;
42
+ }
43
+
44
+ export interface MotionBlurFeatureInput {
45
+ readonly params: Partial<MotionBlurParams> | undefined;
46
+ readonly frameDeltaSeconds?: number;
47
+ readonly reset?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Compute owner for the long-vector lane. The summary is one 32-byte record
52
+ * per 16x16 tile and the reconstruction is fused: a long vector reuses an
53
+ * even-pixel (half-resolution) source lattice and performs depth-guided
54
+ * acceptance in the same dispatch. There is no scatter, atomic, or
55
+ * radius-squared neighbourhood work.
56
+ */
57
+ export const MOTION_BLUR_COMPUTE_WGSL = `struct MotionBlurComputeParams {
58
+ shutterAngle : f32,
59
+ maxRadiusPixels : f32,
60
+ sampleCount : u32,
61
+ reset : u32,
62
+ targetFps : f32,
63
+ exposureScale : f32,
64
+ frameDeltaSeconds : f32,
65
+ flags : u32,
66
+ };
67
+
68
+ struct MotionBlurTileSummary {
69
+ // The two motion lanes retain the source pixel that supplied the direction.
70
+ // Those anchors let a bounded midpoint tap hit a sparse source without
71
+ // adding a color read or growing the 32-byte per-tile summary.
72
+ maxMotionAndLength : vec4<f32>,
73
+ secondaryMotionAndDepth : vec4<f32>,
74
+ };
75
+
76
+ struct TemporalSample {
77
+ motion : vec2<f32>,
78
+ depth : f32,
79
+ reactive : f32,
80
+ validDepth : bool,
81
+ motionValid : bool,
82
+ };
83
+
84
+ struct MotionBlurSupport {
85
+ primaryMotion : vec2<f32>,
86
+ secondaryMotion : vec2<f32>,
87
+ primaryAnchor : vec2<i32>,
88
+ secondaryAnchor : vec2<i32>,
89
+ primaryRadius : f32,
90
+ secondaryRadius : f32,
91
+ valid : bool,
92
+ longVector : bool,
93
+ };
94
+
95
+ struct MotionBlurTap {
96
+ pixel : vec2<i32>,
97
+ fallbackPixel : vec2<i32>,
98
+ valid : bool,
99
+ fallbackValid : bool,
100
+ };
101
+
102
+ struct MotionBlurFilter {
103
+ color0 : vec4<f32>,
104
+ color1 : vec4<f32>,
105
+ color2 : vec4<f32>,
106
+ color3 : vec4<f32>,
107
+ depth0 : f32,
108
+ depth1 : f32,
109
+ depth2 : f32,
110
+ depth3 : f32,
111
+ coverage0 : f32,
112
+ coverage1 : f32,
113
+ coverage2 : f32,
114
+ coverage3 : f32,
115
+ receiver : vec2<i32>,
116
+ motion : vec2<f32>,
117
+ radius : f32,
118
+ receiverMask : u32,
119
+ validMask : u32,
120
+ valid : bool,
121
+ };
122
+
123
+ struct MotionBlurCacheSample {
124
+ color : vec4<f32>,
125
+ coverage : f32,
126
+ valid : bool,
127
+ };
128
+
129
+ struct MotionBlurSource {
130
+ pixel : vec2<i32>,
131
+ motion : vec2<f32>,
132
+ depth : f32,
133
+ radius : f32,
134
+ coverage0 : f32,
135
+ coverage1 : f32,
136
+ coverage2 : f32,
137
+ coverage3 : f32,
138
+ receiverMask : u32,
139
+ valid : bool,
140
+ };
141
+
142
+ @group(0) @binding(0) var currentColor : texture_2d<f32>;
143
+ @group(0) @binding(1) var sceneTemporal : texture_2d<f32>;
144
+ @group(0) @binding(2) var<storage, read_write> tileSummary : array<MotionBlurTileSummary>;
145
+ @group(0) @binding(3) var outputColor : texture_storage_2d<rgba16float, write>;
146
+ @group(0) @binding(4) var<uniform> params : MotionBlurComputeParams;
147
+
148
+ // Reconstruct a bounded part of a partially covered opaque edge and fund it
149
+ // by attenuating the matching empty-background trail. The fixed factor keeps
150
+ // the work and the energy adjustment independent of the blur radius.
151
+ const EDGE_FILL_FACTOR : f32 = 0.99;
152
+
153
+ // One 8x8 workgroup covers a 16x16 tile. Each invocation reads a 2x2
154
+ // footprint, then the workgroup reduction combines the values without
155
+ // write contention or a serial 256-pixel loop.
156
+ var<workgroup> tileMotionScratch : array<vec2<f32>, 64>;
157
+ var<workgroup> tileLengthScratch : array<f32, 64>;
158
+ var<workgroup> tileSourceScratch : array<vec2<i32>, 64>;
159
+ var<workgroup> tileSecondaryMotionScratch : array<vec2<f32>, 64>;
160
+ var<workgroup> tileSecondaryLengthScratch : array<f32, 64>;
161
+ var<workgroup> tileSecondarySourceScratch : array<vec2<i32>, 64>;
162
+ // The reconstruction workgroup owns a 4x4 half-resolution result cache for
163
+ // its 8x8 output footprint. Each owner computes the complete bounded support
164
+ // filter once. Full-resolution lanes only select a depth-compatible cached
165
+ // result after the uniform barrier, with no cross-workgroup dependency.
166
+ // Four quadrants are kept per half-cell. Each owner pays four receiver
167
+ // center-color reads and at most supportCount (<=15) accepted source-color
168
+ // reads; midpoint anchor recovery reuses an existing slot and adds no read
169
+ // budget. The shared result retains odd/even coverage instead of spreading an
170
+ // odd row across both rows.
171
+ var<workgroup> halfFilteredColor : array<vec4<f32>, 64>;
172
+ var<workgroup> halfFilteredDepth : array<f32, 64>;
173
+ var<workgroup> halfFilteredCoverage : array<f32, 64>;
174
+ var<workgroup> halfFilteredReceiver : array<vec2<i32>, 16>;
175
+ var<workgroup> halfFilteredMotion : array<vec2<f32>, 16>;
176
+ var<workgroup> halfFilteredRadius : array<f32, 16>;
177
+ var<workgroup> halfFilteredValid : array<u32, 64>;
178
+ var<workgroup> longVectorScratch : array<u32, 64>;
179
+ // A reconstruct workgroup covers one 8x8 output footprint. Its first lane
180
+ // derives the complete bounded tile neighbourhood once and publishes the top
181
+ // two source directions for all lanes and all designated half-resolution
182
+ // owners. This keeps summary work O(P/T^2), rather than repeating a tile walk
183
+ // per output pixel.
184
+ var<workgroup> neighbourhoodPrimaryMotion : vec2<f32>;
185
+ var<workgroup> neighbourhoodPrimaryLength : f32;
186
+ var<workgroup> neighbourhoodPrimaryAnchor : vec2<i32>;
187
+ var<workgroup> neighbourhoodSecondaryMotion : vec2<f32>;
188
+ var<workgroup> neighbourhoodSecondaryLength : f32;
189
+ var<workgroup> neighbourhoodSecondaryAnchor : vec2<i32>;
190
+
191
+ // maxRadiusPixels is authored at [0,64], so a 16-pixel tile support reaches
192
+ // at most four tiles. The fixed 9x9 lattice is geometrically derived from its
193
+ // index, then intersected with the circular/AABB support. It is a constant
194
+ // bounded metadata walk: no loop scales with the requested radius and no
195
+ // radius-squared per-pixel storage or write path is introduced.
196
+ const MAX_TILE_REACH : i32 = 4;
197
+ const TILE_NEIGHBORHOOD_SIDE : u32 = 9u;
198
+ const TILE_NEIGHBORHOOD_COUNT : u32 = 81u;
199
+
200
+ fn finite(value : f32) -> bool {
201
+ return value == value && abs(value) < 3.402823e+38;
202
+ }
203
+
204
+ fn pixelInBounds(pixel : vec2<i32>, dimensions : vec2<i32>) -> bool {
205
+ return pixel.x >= 0 && pixel.y >= 0 && pixel.x < dimensions.x && pixel.y < dimensions.y;
206
+ }
207
+
208
+ fn decodeDepth(packed : f32) -> f32 {
209
+ if (packed < 0.0) { return 0.0; }
210
+ return exp2(packed) - 1.0;
211
+ }
212
+
213
+ fn temporalAt(pixel : vec2<i32>, dimensions : vec2<i32>) -> TemporalSample {
214
+ let samplePixel = clamp(pixel, vec2<i32>(0), dimensions - vec2<i32>(1));
215
+ let packed = textureLoad(sceneTemporal, samplePixel, 0);
216
+ let invalidMotion = packed.w >= 2.0;
217
+ return TemporalSample(
218
+ packed.xy,
219
+ decodeDepth(packed.z),
220
+ clamp(packed.w - select(0.0, 2.0, invalidMotion), 0.0, 1.0),
221
+ packed.z >= 0.0,
222
+ !invalidMotion,
223
+ );
224
+ }
225
+
226
+ fn depthReject(center : f32, sample : f32) -> bool {
227
+ return sample > center + max(0.01, center * 0.01);
228
+ }
229
+
230
+ fn distinctDirection(a : vec2<f32>, b : vec2<f32>) -> bool {
231
+ let aLength = length(a);
232
+ let bLength = length(b);
233
+ if (aLength <= 1e-5 || bLength <= 1e-5) { return false; }
234
+ // Keep only genuinely different directions. Opposite signs still carry
235
+ // separate source segments, while nearly parallel candidates do not spend
236
+ // the bounded second-direction slot.
237
+ return abs(dot(a / aLength, b / bLength)) < 0.94;
238
+ }
239
+
240
+ fn sourceVelocitySegmentCovers(
241
+ receiverPixel : vec2<i32>,
242
+ sourcePixel : vec2<i32>,
243
+ motionPixels : vec2<f32>,
244
+ radius : f32,
245
+ ) -> bool {
246
+ let delta = vec2<f32>(receiverPixel - sourcePixel);
247
+ let motionLength = length(motionPixels);
248
+ if (motionLength <= 1e-5 || radius <= 1e-5) { return false; }
249
+ let direction = motionPixels / motionLength;
250
+ let along = dot(delta, direction);
251
+ let perpendicular = abs(delta.x * direction.y - delta.y * direction.x);
252
+ // A source segment owns a one-pixel coverage tube. This is a bounded
253
+ // geometric test, not a radius-sized source scan, and lets a sparse mover
254
+ // cover a static receiver without accepting a far/orthogonal source.
255
+ return abs(along) <= radius + 0.75 && perpendicular <= 0.75;
256
+ }
257
+
258
+ fn symmetricOffset(index : u32, count : u32, motion : vec2<f32>) -> vec2<f32> {
259
+ let denominator = max(f32(count), 1.0);
260
+ return motion * (2.0 * (f32(index) + 0.5) / denominator - 1.0);
261
+ }
262
+
263
+ fn tileNeighborhoodOffset(index : u32) -> vec2<i32> {
264
+ let x = i32(index % TILE_NEIGHBORHOOD_SIDE) - MAX_TILE_REACH;
265
+ let y = i32(index / TILE_NEIGHBORHOOD_SIDE) - MAX_TILE_REACH;
266
+ return vec2<i32>(x, y);
267
+ }
268
+
269
+ fn tileOffsetWithinSupport(offset : vec2<i32>, tileReach : i32) -> bool {
270
+ if (offset.x == 0 && offset.y == 0) { return false; }
271
+ let boundedReach = min(max(tileReach, 1), MAX_TILE_REACH);
272
+ // The bounded lattice is a square around the receiver tile. The precise
273
+ // AABB/circle intersection below decides whether a diagonal tile really
274
+ // intersects the support; a circular pre-filter here would drop the
275
+ // (±2,±2) and boundary cases before that exact test can run.
276
+ return abs(offset.x) <= boundedReach && abs(offset.y) <= boundedReach;
277
+ }
278
+
279
+ fn tileIntersectsSupport(
280
+ receiverPixel : vec2<i32>,
281
+ candidateTile : vec2<i32>,
282
+ dimensions : vec2<i32>,
283
+ supportRadius : f32,
284
+ ) -> bool {
285
+ // Use the receiver-to-tile-AABB lower bound. This includes every tile whose
286
+ // contents can intersect the support, including signs and diagonals, while
287
+ // rejecting an outside-support tile before loading its summary.
288
+ let tileMin = vec2<f32>(candidateTile * 16);
289
+ let tileMax = min(tileMin + vec2<f32>(16.0), vec2<f32>(dimensions));
290
+ let receiver = vec2<f32>(receiverPixel) + vec2<f32>(0.5);
291
+ let dx = max(max(tileMin.x - receiver.x, 0.0), receiver.x - tileMax.x);
292
+ let dy = max(max(tileMin.y - receiver.y, 0.0), receiver.y - tileMax.y);
293
+ let distance = vec2<f32>(dx, dy);
294
+ return dot(distance, distance) <= supportRadius * supportRadius + 1.0;
295
+ }
296
+
297
+ fn tileIntersectsWorkgroupSupport(
298
+ workgroupBase : vec2<i32>,
299
+ candidateTile : vec2<i32>,
300
+ dimensions : vec2<i32>,
301
+ supportRadius : f32,
302
+ ) -> bool {
303
+ // A workgroup's 8x8 rectangle is the conservative receiver domain for one
304
+ // shared metadata walk. The AABB lower bound includes every pixel in that
305
+ // rectangle that can reach the candidate tile, including diagonal/sign
306
+ // variants, while rejecting a tile fully outside the support.
307
+ let tileMin = vec2<f32>(candidateTile * 16);
308
+ let tileMax = min(tileMin + vec2<f32>(16.0), vec2<f32>(dimensions));
309
+ let groupMin = vec2<f32>(workgroupBase) + vec2<f32>(0.5);
310
+ let groupMax = min(groupMin + vec2<f32>(8.0), vec2<f32>(dimensions));
311
+ let dx = max(max(tileMin.x - groupMax.x, 0.0), groupMin.x - tileMax.x);
312
+ let dy = max(max(tileMin.y - groupMax.y, 0.0), groupMin.y - tileMax.y);
313
+ let distance = vec2<f32>(dx, dy);
314
+ return dot(distance, distance) <= supportRadius * supportRadius + 1.0;
315
+ }
316
+
317
+ fn scanTileNeighbourhood(
318
+ workgroupBase : vec2<i32>,
319
+ dimensions : vec2<i32>,
320
+ ) {
321
+ let tiles = (vec2<u32>(dimensions) + vec2<u32>(15)) / vec2<u32>(16);
322
+ let tileCoordinate = workgroupBase / vec2<i32>(16);
323
+ let currentIndex = u32(tileCoordinate.y) * tiles.x + u32(tileCoordinate.x);
324
+ let current = tileSummary[currentIndex];
325
+ var primary = current.maxMotionAndLength.xy * vec2<f32>(dimensions) * params.exposureScale;
326
+ var primaryLength = length(primary);
327
+ var primaryAnchor = vec2<i32>(
328
+ i32(current.maxMotionAndLength.z),
329
+ i32(current.maxMotionAndLength.w),
330
+ );
331
+ var secondary = current.secondaryMotionAndDepth.xy * vec2<f32>(dimensions) * params.exposureScale;
332
+ var secondaryLength = length(secondary);
333
+ var secondaryAnchor = vec2<i32>(
334
+ i32(current.secondaryMotionAndDepth.z),
335
+ i32(current.secondaryMotionAndDepth.w),
336
+ );
337
+ let supportRadius = min(max(params.maxRadiusPixels, 1.0), 64.0);
338
+ let tileReach = min(MAX_TILE_REACH, max(1, i32(ceil(supportRadius / 16.0))));
339
+ for (var tileCandidateIndex = 0u; tileCandidateIndex < TILE_NEIGHBORHOOD_COUNT; tileCandidateIndex += 1u) {
340
+ let offset = tileNeighborhoodOffset(tileCandidateIndex);
341
+ if (!tileOffsetWithinSupport(offset, tileReach)) { continue; }
342
+ let candidateTile = tileCoordinate + offset;
343
+ if (
344
+ candidateTile.x < 0 ||
345
+ candidateTile.y < 0 ||
346
+ candidateTile.x >= i32(tiles.x) ||
347
+ candidateTile.y >= i32(tiles.y) ||
348
+ !tileIntersectsWorkgroupSupport(workgroupBase, candidateTile, dimensions, supportRadius)
349
+ ) { continue; }
350
+ let candidate = tileSummary[u32(candidateTile.y) * tiles.x + u32(candidateTile.x)];
351
+ let candidatePrimary = candidate.maxMotionAndLength.xy * vec2<f32>(dimensions) * params.exposureScale;
352
+ let candidatePrimaryLength = length(candidatePrimary);
353
+ let candidatePrimaryAnchor = vec2<i32>(
354
+ i32(candidate.maxMotionAndLength.z),
355
+ i32(candidate.maxMotionAndLength.w),
356
+ );
357
+ if (candidatePrimaryLength > primaryLength) {
358
+ let previousPrimary = primary;
359
+ let previousLength = primaryLength;
360
+ let previousAnchor = primaryAnchor;
361
+ primary = candidatePrimary;
362
+ primaryLength = candidatePrimaryLength;
363
+ primaryAnchor = candidatePrimaryAnchor;
364
+ if (previousLength > 1e-5 && distinctDirection(primary, previousPrimary) && previousLength > secondaryLength) {
365
+ secondary = previousPrimary;
366
+ secondaryLength = previousLength;
367
+ secondaryAnchor = previousAnchor;
368
+ }
369
+ } else if (
370
+ candidatePrimaryLength > secondaryLength &&
371
+ distinctDirection(primary, candidatePrimary)
372
+ ) {
373
+ secondary = candidatePrimary;
374
+ secondaryLength = candidatePrimaryLength;
375
+ secondaryAnchor = candidatePrimaryAnchor;
376
+ }
377
+ let candidateSecondary = candidate.secondaryMotionAndDepth.xy * vec2<f32>(dimensions) * params.exposureScale;
378
+ let candidateSecondaryLength = length(candidateSecondary);
379
+ let candidateSecondaryAnchor = vec2<i32>(
380
+ i32(candidate.secondaryMotionAndDepth.z),
381
+ i32(candidate.secondaryMotionAndDepth.w),
382
+ );
383
+ if (candidateSecondaryLength > secondaryLength && distinctDirection(primary, candidateSecondary)) {
384
+ secondary = candidateSecondary;
385
+ secondaryLength = candidateSecondaryLength;
386
+ secondaryAnchor = candidateSecondaryAnchor;
387
+ }
388
+ }
389
+ neighbourhoodPrimaryMotion = primary;
390
+ neighbourhoodPrimaryLength = primaryLength;
391
+ neighbourhoodPrimaryAnchor = primaryAnchor;
392
+ neighbourhoodSecondaryMotion = secondary;
393
+ neighbourhoodSecondaryLength = secondaryLength;
394
+ neighbourhoodSecondaryAnchor = secondaryAnchor;
395
+ }
396
+
397
+ fn validMotionParameters() -> bool {
398
+ return
399
+ finite(params.frameDeltaSeconds) &&
400
+ params.frameDeltaSeconds > 0.0 &&
401
+ params.frameDeltaSeconds <= 0.1 &&
402
+ finite(params.exposureScale) &&
403
+ params.exposureScale > 0.0 &&
404
+ finite(params.targetFps) &&
405
+ params.targetFps >= 0.0 &&
406
+ params.targetFps <= 240.0 &&
407
+ params.targetFps == floor(params.targetFps) &&
408
+ params.sampleCount >= 4u &&
409
+ params.maxRadiusPixels > 0.0 &&
410
+ finite(params.maxRadiusPixels) &&
411
+ params.shutterAngle > 0.0;
412
+ }
413
+
414
+ fn sourceMotionPixels(sample : TemporalSample, dimensions : vec2<i32>) -> vec2<f32> {
415
+ return sample.motion * vec2<f32>(dimensions) * params.exposureScale;
416
+ }
417
+
418
+ fn sourceRadius(motionPixels : vec2<f32>, shutter : f32) -> f32 {
419
+ return min(length(motionPixels) * shutter * 0.5, params.maxRadiusPixels);
420
+ }
421
+
422
+ fn resolveMotionBlurSupport(
423
+ pixel : vec2<i32>,
424
+ dimensions : vec2<i32>,
425
+ ) -> MotionBlurSupport {
426
+ var empty = MotionBlurSupport(
427
+ vec2<f32>(0.0),
428
+ vec2<f32>(0.0),
429
+ vec2<i32>(0),
430
+ vec2<i32>(0),
431
+ 0.0,
432
+ 0.0,
433
+ false,
434
+ false,
435
+ );
436
+ if (!pixelInBounds(pixel, dimensions) || !validMotionParameters() || params.reset != 0u) {
437
+ return empty;
438
+ }
439
+ let center = temporalAt(pixel, dimensions);
440
+ if (!center.validDepth || !center.motionValid) { return empty; }
441
+ let shutter = clamp(params.shutterAngle / 360.0, 0.0, 1.0);
442
+ let centerMotion = sourceMotionPixels(center, dimensions);
443
+ let centerLength = length(centerMotion);
444
+ var primaryMotion = centerMotion;
445
+ var primaryLength = centerLength;
446
+ var primaryAnchor = pixel;
447
+ var secondaryMotion = neighbourhoodSecondaryMotion;
448
+ var secondaryLength = neighbourhoodSecondaryLength;
449
+ var secondaryAnchor = neighbourhoodSecondaryAnchor;
450
+ if (neighbourhoodPrimaryLength > primaryLength) {
451
+ if (distinctDirection(primaryMotion, neighbourhoodPrimaryMotion)) {
452
+ secondaryMotion = primaryMotion;
453
+ secondaryLength = primaryLength;
454
+ secondaryAnchor = pixel;
455
+ }
456
+ primaryMotion = neighbourhoodPrimaryMotion;
457
+ primaryLength = neighbourhoodPrimaryLength;
458
+ primaryAnchor = neighbourhoodPrimaryAnchor;
459
+ } else if (
460
+ neighbourhoodPrimaryLength > secondaryLength &&
461
+ distinctDirection(primaryMotion, neighbourhoodPrimaryMotion)
462
+ ) {
463
+ secondaryMotion = neighbourhoodPrimaryMotion;
464
+ secondaryLength = neighbourhoodPrimaryLength;
465
+ secondaryAnchor = neighbourhoodPrimaryAnchor;
466
+ }
467
+ if (
468
+ secondaryLength <= 1e-5 ||
469
+ !distinctDirection(primaryMotion, secondaryMotion)
470
+ ) {
471
+ secondaryMotion = vec2<f32>(0.0);
472
+ secondaryLength = 0.0;
473
+ }
474
+ empty.primaryMotion = primaryMotion;
475
+ empty.secondaryMotion = secondaryMotion;
476
+ empty.primaryAnchor = primaryAnchor;
477
+ empty.secondaryAnchor = secondaryAnchor;
478
+ empty.primaryRadius = sourceRadius(primaryMotion, shutter);
479
+ empty.secondaryRadius = sourceRadius(secondaryMotion, shutter);
480
+ empty.valid = empty.primaryRadius > 1e-5 || empty.secondaryRadius > 1e-5;
481
+ empty.longVector = max(empty.primaryRadius, empty.secondaryRadius) > 8.0;
482
+ return empty;
483
+ }
484
+
485
+ fn sourceAnchorForReceiver(
486
+ receiverPixel : vec2<i32>,
487
+ anchor : vec2<i32>,
488
+ motion : vec2<f32>,
489
+ dimensions : vec2<i32>,
490
+ ) -> vec2<i32> {
491
+ // A tile summary has one source anchor, while a horizontal source may span
492
+ // the tile's perpendicular axis. Project that anchor onto the receiver's
493
+ // motion line before the midpoint read. The following source footprint
494
+ // validates the projected pixel, so a thin or off-axis source cannot be
495
+ // invented by the projection.
496
+ let motionLength = length(motion);
497
+ if (motionLength <= 1e-5) { return anchor; }
498
+ let direction = motion / motionLength;
499
+ let delta = vec2<f32>(receiverPixel - anchor);
500
+ let perpendicular = delta - direction * dot(delta, direction);
501
+ let projected = vec2<f32>(anchor) + perpendicular;
502
+ let candidate = vec2<i32>(floor(projected + vec2<f32>(0.5)));
503
+ return clamp(candidate, vec2<i32>(0), dimensions - vec2<i32>(1));
504
+ }
505
+
506
+ fn supportTap(
507
+ receiverPixel : vec2<i32>,
508
+ index : u32,
509
+ count : u32,
510
+ motion : vec2<f32>,
511
+ radius : f32,
512
+ anchor : vec2<i32>,
513
+ dimensions : vec2<i32>,
514
+ ) -> MotionBlurTap {
515
+ // Preserve the geometric midpoint as the primary sample. The source anchor
516
+ // is a recovery coordinate for a sparse tile summary, not a replacement for
517
+ // the receiver's authored support. It is tried only when this midpoint has
518
+ // no valid source, and therefore never adds a color read or a tap.
519
+ var fallbackPixel = vec2<i32>(0);
520
+ var fallbackValid = false;
521
+ if (index == count / 2u) {
522
+ fallbackPixel = sourceAnchorForReceiver(receiverPixel, anchor, motion, dimensions);
523
+ fallbackValid = pixelInBounds(fallbackPixel, dimensions);
524
+ }
525
+ let direction = motion / max(length(motion), 1e-5);
526
+ let offset = symmetricOffset(index, count, direction * radius);
527
+ let dimensionsF = vec2<f32>(dimensions);
528
+ let uv = (vec2<f32>(receiverPixel) + vec2<f32>(0.5) + offset) / dimensionsF;
529
+ if (uv.x < 0.0 || uv.y < 0.0 || uv.x >= 1.0 || uv.y >= 1.0) {
530
+ return MotionBlurTap(vec2<i32>(0), fallbackPixel, false, fallbackValid);
531
+ }
532
+ let candidate = vec2<i32>(floor(uv * dimensionsF));
533
+ return MotionBlurTap(candidate, fallbackPixel, pixelInBounds(candidate, dimensions), fallbackValid);
534
+ }
535
+
536
+ fn sourceAt(
537
+ receiverPixel : vec2<i32>,
538
+ sourcePixel : vec2<i32>,
539
+ centerDepth : f32,
540
+ shutter : f32,
541
+ dimensions : vec2<i32>,
542
+ ) -> MotionBlurSource {
543
+ let empty = MotionBlurSource(
544
+ vec2<i32>(0),
545
+ vec2<f32>(0.0),
546
+ 0.0,
547
+ 0.0,
548
+ 0.0,
549
+ 0.0,
550
+ 0.0,
551
+ 0.0,
552
+ 0u,
553
+ false,
554
+ );
555
+ if (!pixelInBounds(sourcePixel, dimensions)) { return empty; }
556
+ let source = temporalAt(sourcePixel, dimensions);
557
+ if (!source.validDepth || !source.motionValid || depthReject(centerDepth, source.depth)) {
558
+ return empty;
559
+ }
560
+ let motionPixels = sourceMotionPixels(source, dimensions);
561
+ let radius = sourceRadius(motionPixels, shutter);
562
+ if (!sourceVelocitySegmentCovers(receiverPixel, sourcePixel, motionPixels, radius)) {
563
+ return empty;
564
+ }
565
+ return MotionBlurSource(
566
+ sourcePixel,
567
+ motionPixels,
568
+ source.depth,
569
+ radius,
570
+ 1.0,
571
+ 1.0,
572
+ 1.0,
573
+ 1.0,
574
+ 0u,
575
+ true,
576
+ );
577
+ }
578
+
579
+ // A half-resolution owner represents a complete 2x2 receiving footprint. The
580
+ // source motion test is evaluated against every receiving pixel in that
581
+ // footprint, so odd/even rows and columns remain visible without a second
582
+ // filter pass or an unbounded neighbourhood walk.
583
+ fn sourceAtFootprint(
584
+ receiverPixel : vec2<i32>,
585
+ sourcePixel : vec2<i32>,
586
+ centerDepth : f32,
587
+ shutter : f32,
588
+ dimensions : vec2<i32>,
589
+ ) -> MotionBlurSource {
590
+ var selected = MotionBlurSource(
591
+ vec2<i32>(0),
592
+ vec2<f32>(0.0),
593
+ 0.0,
594
+ 0.0,
595
+ 0.0,
596
+ 0.0,
597
+ 0.0,
598
+ 0.0,
599
+ 0u,
600
+ false,
601
+ );
602
+ var selectedDepth = 3.402823e+38;
603
+ var coverage0 = 0.0;
604
+ var coverage1 = 0.0;
605
+ var coverage2 = 0.0;
606
+ var coverage3 = 0.0;
607
+ var selectedReceiverMask = 0u;
608
+ for (var y = 0u; y < 2u; y += 1u) {
609
+ for (var x = 0u; x < 2u; x += 1u) {
610
+ let candidatePixel = sourcePixel + vec2<i32>(i32(x), i32(y));
611
+ if (!pixelInBounds(candidatePixel, dimensions)) { continue; }
612
+ let temporal = temporalAt(candidatePixel, dimensions);
613
+ if (!temporal.validDepth || !temporal.motionValid || depthReject(centerDepth, temporal.depth)) {
614
+ continue;
615
+ }
616
+ let motionPixels = sourceMotionPixels(temporal, dimensions);
617
+ let radius = sourceRadius(motionPixels, shutter);
618
+ var receiverMask = 0u;
619
+ // A source half-cell and a receiver half-cell share the same integer
620
+ // displacement. This one-to-one correspondence keeps a diagonal source
621
+ // from being counted for all four receiver quadrants just because its
622
+ // tube crosses the quadrant. The segment test still supplies the motion
623
+ // support and depth/validity checks remain source-owned.
624
+ let receiver = receiverPixel + vec2<i32>(i32(x), i32(y));
625
+ if (
626
+ pixelInBounds(receiver, dimensions) &&
627
+ sourceVelocitySegmentCovers(receiver, candidatePixel, motionPixels, radius)
628
+ ) {
629
+ receiverMask = receiverMask | (1u << (y * 2u + x));
630
+ }
631
+ if (receiverMask == 0u) { continue; }
632
+ if (!selected.valid || temporal.depth < selectedDepth) {
633
+ selected = MotionBlurSource(
634
+ candidatePixel,
635
+ motionPixels,
636
+ temporal.depth,
637
+ radius,
638
+ 0.0,
639
+ 0.0,
640
+ 0.0,
641
+ 0.0,
642
+ receiverMask,
643
+ true,
644
+ );
645
+ selectedDepth = temporal.depth;
646
+ coverage0 = 0.0;
647
+ coverage1 = 0.0;
648
+ coverage2 = 0.0;
649
+ coverage3 = 0.0;
650
+ selectedReceiverMask = receiverMask;
651
+ }
652
+ if (abs(temporal.depth - selectedDepth) <= 1e-4) {
653
+ // The common integer displacement above gives each source cell one
654
+ // receiver quadrant. The matching footprint therefore contributes its
655
+ // full unit overlap to that one owner.
656
+ if ((receiverMask & 1u) != 0u) { coverage0 += 1.0; }
657
+ if ((receiverMask & 2u) != 0u) { coverage1 += 1.0; }
658
+ if ((receiverMask & 4u) != 0u) { coverage2 += 1.0; }
659
+ if ((receiverMask & 8u) != 0u) { coverage3 += 1.0; }
660
+ selectedReceiverMask = selectedReceiverMask | receiverMask;
661
+ }
662
+ }
663
+ }
664
+ if (selected.valid) {
665
+ // The owner samples a 2x2 source footprint but pays for one color read.
666
+ // Each receiving quadrant keeps the fraction of that footprint that is
667
+ // covered at the selected depth. This preserves energy at boundaries and
668
+ // carries a one-pixel odd-row feature into its real row only.
669
+ selected.coverage0 = min(coverage0, 1.0) * select(0.0, 1.0, (selectedReceiverMask & 1u) != 0u);
670
+ selected.coverage1 = min(coverage1, 1.0) * select(0.0, 1.0, (selectedReceiverMask & 2u) != 0u);
671
+ selected.coverage2 = min(coverage2, 1.0) * select(0.0, 1.0, (selectedReceiverMask & 4u) != 0u);
672
+ selected.coverage3 = min(coverage3, 1.0) * select(0.0, 1.0, (selectedReceiverMask & 8u) != 0u);
673
+ selected.receiverMask = selectedReceiverMask;
674
+ }
675
+ return selected;
676
+ }
677
+
678
+ fn midpointTileOwnership(
679
+ receiverPixel : vec2<i32>,
680
+ source : MotionBlurSource,
681
+ ) -> f32 {
682
+ // A recovered tile anchor represents one two-pixel source footprint. The
683
+ // footprint is split across the finite, geometrically derived tile bands of
684
+ // the support segment. One half-cell owner per band claims the represented
685
+ // midpoint slot, rather than painting every receiver in the interval.
686
+ let sourcePixel = source.pixel;
687
+ let motionLength = length(source.motion);
688
+ if (motionLength <= 1e-5 || source.radius <= 1e-5) { return 0.0; }
689
+ let receiverTile = receiverPixel / vec2<i32>(16);
690
+ let sourceTile = sourcePixel / vec2<i32>(16);
691
+ let tileDelta = vec2<f32>(sourceTile - receiverTile);
692
+ let direction = source.motion / motionLength;
693
+ let along = abs(dot(tileDelta, direction));
694
+ let perpendicular = abs(tileDelta.x * direction.y - tileDelta.y * direction.x);
695
+ let tileReach = min(4, max(1, i32(ceil(source.radius / 16.0))));
696
+ if (along < 1.0 || ceil(along) > f32(tileReach) || perpendicular > 1.0) { return 0.0; }
697
+ let groupBase = (receiverPixel / vec2<i32>(8)) * vec2<i32>(8);
698
+ let local = receiverPixel - groupBase;
699
+ let ownerX = select(
700
+ clamp(((sourcePixel.x - groupBase.x) / 2) * 2, 0, 6),
701
+ select(0, 6, sourcePixel.x > receiverPixel.x),
702
+ sourcePixel.x != receiverPixel.x,
703
+ );
704
+ let ownerY = select(
705
+ clamp(((sourcePixel.y - groupBase.y) / 2) * 2, 0, 6),
706
+ select(0, 6, sourcePixel.y > receiverPixel.y),
707
+ sourcePixel.y != receiverPixel.y,
708
+ );
709
+ if (local.x != ownerX || local.y != ownerY) { return 0.0; }
710
+ let sourceFootprintLength = 2.0;
711
+ let supportLength = max(2.0 * source.radius, sourceFootprintLength);
712
+ // Divide the finite footprint ownership over every reachable tile band.
713
+ // This keeps the total recovery mass bounded when the support grows from
714
+ // 32 to 64 pixels while retaining a nonzero owner at any in-view band.
715
+ return min(1.0, sourceFootprintLength / (supportLength * f32(tileReach)));
716
+ }
717
+
718
+ fn integrateLongColor(
719
+ filtered : vec4<f32>,
720
+ validCount : f32,
721
+ hasAcceptedSource : bool,
722
+ uncovered : f32,
723
+ count : u32,
724
+ supportCount : u32,
725
+ centerColor : vec4<f32>,
726
+ centerMoving : bool,
727
+ ) -> vec4<f32> {
728
+ // Uncovered ownership is decremented at the same time as validCount is incremented
729
+ // for both geometric and midpoint-recovered samples. Keep the derived cap
730
+ // as a guard against floating point accumulation and make the ownership
731
+ // transfer explicit instead of adding an independent brightness term.
732
+ let conservedUncovered = min(
733
+ max(uncovered, 0.0),
734
+ max(f32(supportCount) - validCount, 0.0),
735
+ );
736
+ // A moving receiver can be partially covered at a silhouette edge. Use the
737
+ // accepted foreground only for the receiver's missing current coverage; a
738
+ // fully covered receiver keeps the original center contribution and the
739
+ // Dawn energy contract remains unchanged for uniform opaque surfaces.
740
+ let acceptedColor = select(
741
+ vec3<f32>(0.0),
742
+ filtered.rgb / max(validCount, 1.0),
743
+ hasAcceptedSource && validCount > 0.0,
744
+ );
745
+ // Preserve the color target's authored alpha as the output floor. It is
746
+ // separate from the RGB test below, which identifies an empty background
747
+ // receiver without treating alpha as a scene-coverage attachment.
748
+ let missingForeground = 1.0 - clamp(centerColor.a, 0.0, 1.0);
749
+ let edgeCoverage = select(missingForeground, 1.0, centerMoving);
750
+ let edgeFill = acceptedColor * edgeCoverage * EDGE_FILL_FACTOR;
751
+ let movingFallback = select(centerColor.rgb, edgeFill, hasAcceptedSource && validCount > 0.0);
752
+ let background = select(vec3<f32>(0.0), movingFallback, centerMoving);
753
+ // The accepted colour can come from a transparent receiver's moving
754
+ // foreground. Carry the same bounded support coverage into alpha so that
755
+ // RGB written outside the current silhouette is not composited as black.
756
+ // Preserve the receiver's authored coverage as a floor: blur must not make
757
+ // an already visible pixel disappear.
758
+ let acceptedAlpha = select(
759
+ 0.0,
760
+ filtered.a / max(validCount, 1.0),
761
+ validCount > 0.0,
762
+ );
763
+ let acceptedCoverage = validCount + missingForeground * conservedUncovered;
764
+ let reconstructedAlpha = acceptedAlpha * acceptedCoverage / max(f32(count), 1.0);
765
+ let outputAlpha = clamp(max(centerColor.a, reconstructedAlpha), 0.0, 1.0);
766
+ let emptyReceiver =
767
+ !centerMoving &&
768
+ hasAcceptedSource &&
769
+ max(max(abs(centerColor.r), abs(centerColor.g)), abs(centerColor.b)) < 1e-4;
770
+ let sourceScale = select(1.0, 1.0 - EDGE_FILL_FACTOR, emptyReceiver);
771
+ let sourceContribution = filtered.rgb * sourceScale;
772
+ let trailScale = select(1.0, 1.0 - EDGE_FILL_FACTOR, emptyReceiver);
773
+ let trailContribution = background * conservedUncovered * trailScale;
774
+ return vec4<f32>(
775
+ (sourceContribution + centerColor.rgb + trailContribution) / max(f32(count), 1.0),
776
+ outputAlpha,
777
+ );
778
+ }
779
+
780
+ fn longSupportFilter(
781
+ receiverPixel : vec2<i32>,
782
+ support : MotionBlurSupport,
783
+ centerDepth : f32,
784
+ dimensions : vec2<i32>,
785
+ ) -> MotionBlurFilter {
786
+ var empty = MotionBlurFilter(
787
+ vec4<f32>(0.0),
788
+ vec4<f32>(0.0),
789
+ vec4<f32>(0.0),
790
+ vec4<f32>(0.0),
791
+ 0.0,
792
+ 0.0,
793
+ 0.0,
794
+ 0.0,
795
+ 0.0,
796
+ 0.0,
797
+ 0.0,
798
+ 0.0,
799
+ receiverPixel,
800
+ vec2<f32>(0.0),
801
+ 0.0,
802
+ 0u,
803
+ 0u,
804
+ false,
805
+ );
806
+ if (!support.valid) { return empty; }
807
+ let count = min(params.sampleCount, 16u);
808
+ // The authored tier is the total source colour-read budget. Reserve one slot
809
+ // for the receiver/background contribution; the remaining bounded taps
810
+ // (at most 15) are split between the two source directions when needed.
811
+ let supportCount = max(count - 1u, 1u);
812
+ let useSecondDirection = support.secondaryRadius > 1e-5 && distinctDirection(support.primaryMotion, support.secondaryMotion);
813
+ let primaryCount = select(supportCount, max(supportCount / 2u, 1u), useSecondDirection);
814
+ let secondaryCount = max(supportCount - primaryCount, 1u);
815
+ let shutter = clamp(params.shutterAngle / 360.0, 0.0, 1.0);
816
+ let center = temporalAt(receiverPixel, dimensions);
817
+ // Keep each receiving quadrant's own center color. Reusing the owner
818
+ // pixel for all four quadrants causes a one-pixel parity shift to duplicate
819
+ // a bright source (or erase a bright receiver) in the half-resolution cache.
820
+ var centerColor0 = colorAt(receiverPixel, dimensions);
821
+ var centerColor1 = colorAt(receiverPixel + vec2<i32>(1, 0), dimensions);
822
+ var centerColor2 = colorAt(receiverPixel + vec2<i32>(0, 1), dimensions);
823
+ var centerColor3 = colorAt(receiverPixel + vec2<i32>(1, 1), dimensions);
824
+ var filtered0 = vec4<f32>(0.0);
825
+ var filtered1 = vec4<f32>(0.0);
826
+ var filtered2 = vec4<f32>(0.0);
827
+ var filtered3 = vec4<f32>(0.0);
828
+ var validCount0 = 0.0;
829
+ var validCount1 = 0.0;
830
+ var validCount2 = 0.0;
831
+ var validCount3 = 0.0;
832
+ var hasAcceptedSource0 = false;
833
+ var hasAcceptedSource1 = false;
834
+ var hasAcceptedSource2 = false;
835
+ var hasAcceptedSource3 = false;
836
+ var uncovered0 = f32(supportCount);
837
+ var uncovered1 = f32(supportCount);
838
+ var uncovered2 = f32(supportCount);
839
+ var uncovered3 = f32(supportCount);
840
+ var midpointFallbackPixel = vec2<i32>(0);
841
+ var midpointFallbackValid = false;
842
+ var midpointFallbackRadius = 0.0;
843
+ var nearestDepth0 = 3.402823e+38;
844
+ var nearestDepth1 = 3.402823e+38;
845
+ var nearestDepth2 = 3.402823e+38;
846
+ var nearestDepth3 = 3.402823e+38;
847
+ for (var index = 0u; index < 16u; index += 1u) {
848
+ if (index >= supportCount) { break; }
849
+ let secondDirection = useSecondDirection && index >= primaryCount;
850
+ let localIndex = select(index, index - primaryCount, secondDirection);
851
+ let localCount = select(primaryCount, secondaryCount, secondDirection);
852
+ let selectedMotion = select(support.primaryMotion, support.secondaryMotion, secondDirection);
853
+ let selectedRadius = select(support.primaryRadius, support.secondaryRadius, secondDirection);
854
+ let tap = supportTap(
855
+ receiverPixel,
856
+ localIndex,
857
+ localCount,
858
+ selectedMotion,
859
+ selectedRadius,
860
+ select(support.primaryAnchor, support.secondaryAnchor, secondDirection),
861
+ dimensions,
862
+ );
863
+ if (!tap.valid) {
864
+ if (tap.fallbackValid && selectedRadius > midpointFallbackRadius) {
865
+ midpointFallbackPixel = tap.fallbackPixel;
866
+ midpointFallbackValid = true;
867
+ midpointFallbackRadius = selectedRadius;
868
+ }
869
+ continue;
870
+ }
871
+ let source = sourceAtFootprint(
872
+ receiverPixel,
873
+ tap.pixel,
874
+ centerDepth,
875
+ shutter,
876
+ dimensions,
877
+ );
878
+ if (!source.valid) {
879
+ if (tap.fallbackValid && selectedRadius > midpointFallbackRadius) {
880
+ midpointFallbackPixel = tap.fallbackPixel;
881
+ midpointFallbackValid = true;
882
+ midpointFallbackRadius = selectedRadius;
883
+ }
884
+ continue;
885
+ }
886
+ let sampleColor = colorAt(source.pixel, dimensions);
887
+ // Keep the receiver's own color as the center contribution. A source
888
+ // footprint may cover an adjacent receiver quadrant without being the
889
+ // receiver itself; substituting its color here creates odd/even-alignment
890
+ // brightening at thin-object boundaries.
891
+ if ((source.receiverMask & 1u) != 0u) {
892
+ filtered0 += sampleColor * source.coverage0;
893
+ validCount0 += source.coverage0;
894
+ hasAcceptedSource0 = true;
895
+ uncovered0 = max(uncovered0 - source.coverage0, 0.0);
896
+ nearestDepth0 = min(nearestDepth0, source.depth);
897
+ }
898
+ if ((source.receiverMask & 2u) != 0u) {
899
+ filtered1 += sampleColor * source.coverage1;
900
+ validCount1 += source.coverage1;
901
+ hasAcceptedSource1 = true;
902
+ uncovered1 = max(uncovered1 - source.coverage1, 0.0);
903
+ nearestDepth1 = min(nearestDepth1, source.depth);
904
+ }
905
+ if ((source.receiverMask & 4u) != 0u) {
906
+ filtered2 += sampleColor * source.coverage2;
907
+ validCount2 += source.coverage2;
908
+ hasAcceptedSource2 = true;
909
+ uncovered2 = max(uncovered2 - source.coverage2, 0.0);
910
+ nearestDepth2 = min(nearestDepth2, source.depth);
911
+ }
912
+ if ((source.receiverMask & 8u) != 0u) {
913
+ filtered3 += sampleColor * source.coverage3;
914
+ validCount3 += source.coverage3;
915
+ hasAcceptedSource3 = true;
916
+ uncovered3 = max(uncovered3 - source.coverage3, 0.0);
917
+ nearestDepth3 = min(nearestDepth3, source.depth);
918
+ }
919
+ }
920
+ // A tile anchor recovers a sparse source only for the widest support tier,
921
+ // where the fixed geometric lattice can leave a source between taps. It
922
+ // spends the represented midpoint ownership of the finite support segment;
923
+ // every accepted colour, coverage and depth contribution uses that same
924
+ // bounded ownership, so the fallback cannot create an opaque stripe. The
925
+ // shorter tiers keep their exact geometric energy and do not need recovery.
926
+ if (midpointFallbackValid && max(support.primaryRadius, support.secondaryRadius) > 32.0) {
927
+ let fallbackSource = sourceAtFootprint(
928
+ receiverPixel,
929
+ midpointFallbackPixel,
930
+ centerDepth,
931
+ shutter,
932
+ dimensions,
933
+ );
934
+ if (fallbackSource.valid) {
935
+ let midpointWeight = midpointTileOwnership(receiverPixel, fallbackSource);
936
+ if (
937
+ midpointWeight > 0.0
938
+ ) {
939
+ let fallbackColor = colorAt(fallbackSource.pixel, dimensions);
940
+ let fallbackWeight0 = fallbackSource.coverage0 * midpointWeight;
941
+ let fallbackWeight1 = fallbackSource.coverage1 * midpointWeight;
942
+ let fallbackWeight2 = fallbackSource.coverage2 * midpointWeight;
943
+ let fallbackWeight3 = fallbackSource.coverage3 * midpointWeight;
944
+ if (validCount0 <= 0.0 && (fallbackSource.receiverMask & 1u) != 0u) {
945
+ let ownedWeight0 = min(fallbackWeight0, uncovered0);
946
+ if (ownedWeight0 > 0.0) {
947
+ filtered0 += fallbackColor * ownedWeight0;
948
+ validCount0 += ownedWeight0;
949
+ hasAcceptedSource0 = true;
950
+ uncovered0 = max(uncovered0 - ownedWeight0, 0.0);
951
+ nearestDepth0 = min(nearestDepth0, fallbackSource.depth);
952
+ }
953
+ }
954
+ if (validCount1 <= 0.0 && (fallbackSource.receiverMask & 2u) != 0u) {
955
+ let ownedWeight1 = min(fallbackWeight1, uncovered1);
956
+ if (ownedWeight1 > 0.0) {
957
+ filtered1 += fallbackColor * ownedWeight1;
958
+ validCount1 += ownedWeight1;
959
+ hasAcceptedSource1 = true;
960
+ uncovered1 = max(uncovered1 - ownedWeight1, 0.0);
961
+ nearestDepth1 = min(nearestDepth1, fallbackSource.depth);
962
+ }
963
+ }
964
+ if (validCount2 <= 0.0 && (fallbackSource.receiverMask & 4u) != 0u) {
965
+ let ownedWeight2 = min(fallbackWeight2, uncovered2);
966
+ if (ownedWeight2 > 0.0) {
967
+ filtered2 += fallbackColor * ownedWeight2;
968
+ validCount2 += ownedWeight2;
969
+ hasAcceptedSource2 = true;
970
+ uncovered2 = max(uncovered2 - ownedWeight2, 0.0);
971
+ nearestDepth2 = min(nearestDepth2, fallbackSource.depth);
972
+ }
973
+ }
974
+ if (validCount3 <= 0.0 && (fallbackSource.receiverMask & 8u) != 0u) {
975
+ let ownedWeight3 = min(fallbackWeight3, uncovered3);
976
+ if (ownedWeight3 > 0.0) {
977
+ filtered3 += fallbackColor * ownedWeight3;
978
+ validCount3 += ownedWeight3;
979
+ hasAcceptedSource3 = true;
980
+ uncovered3 = max(uncovered3 - ownedWeight3, 0.0);
981
+ nearestDepth3 = min(nearestDepth3, fallbackSource.depth);
982
+ }
983
+ }
984
+ }
985
+ }
986
+ }
987
+ let moving0 = temporalAt(receiverPixel, dimensions);
988
+ let moving1 = temporalAt(receiverPixel + vec2<i32>(1, 0), dimensions);
989
+ let moving2 = temporalAt(receiverPixel + vec2<i32>(0, 1), dimensions);
990
+ let moving3 = temporalAt(receiverPixel + vec2<i32>(1, 1), dimensions);
991
+ let motion0 = sourceMotionPixels(moving0, dimensions);
992
+ let motion1 = sourceMotionPixels(moving1, dimensions);
993
+ let motion2 = sourceMotionPixels(moving2, dimensions);
994
+ let motion3 = sourceMotionPixels(moving3, dimensions);
995
+ let centerMoving0 = moving0.validDepth && moving0.motionValid && sourceRadius(motion0, shutter) > 1e-5;
996
+ let centerMoving1 = moving1.validDepth && moving1.motionValid && sourceRadius(motion1, shutter) > 1e-5;
997
+ let centerMoving2 = moving2.validDepth && moving2.motionValid && sourceRadius(motion2, shutter) > 1e-5;
998
+ let centerMoving3 = moving3.validDepth && moving3.motionValid && sourceRadius(motion3, shutter) > 1e-5;
999
+ empty.color0 = integrateLongColor(filtered0, validCount0, hasAcceptedSource0, uncovered0, count, supportCount, centerColor0, centerMoving0);
1000
+ empty.color1 = integrateLongColor(filtered1, validCount1, hasAcceptedSource1, uncovered1, count, supportCount, centerColor1, centerMoving1);
1001
+ empty.color2 = integrateLongColor(filtered2, validCount2, hasAcceptedSource2, uncovered2, count, supportCount, centerColor2, centerMoving2);
1002
+ empty.color3 = integrateLongColor(filtered3, validCount3, hasAcceptedSource3, uncovered3, count, supportCount, centerColor3, centerMoving3);
1003
+ empty.depth0 = select(centerDepth, nearestDepth0, validCount0 > 0.0);
1004
+ empty.depth1 = select(centerDepth, nearestDepth1, validCount1 > 0.0);
1005
+ empty.depth2 = select(centerDepth, nearestDepth2, validCount2 > 0.0);
1006
+ empty.depth3 = select(centerDepth, nearestDepth3, validCount3 > 0.0);
1007
+ empty.coverage0 = validCount0 / max(f32(supportCount), 1.0);
1008
+ empty.coverage1 = validCount1 / max(f32(supportCount), 1.0);
1009
+ empty.coverage2 = validCount2 / max(f32(supportCount), 1.0);
1010
+ empty.coverage3 = validCount3 / max(f32(supportCount), 1.0);
1011
+ empty.motion = select(support.primaryMotion, support.secondaryMotion, support.primaryRadius <= 1e-5);
1012
+ empty.radius = max(support.primaryRadius, support.secondaryRadius);
1013
+ empty.receiverMask =
1014
+ select(0u, 1u, validCount0 > 0.0 || centerMoving0) |
1015
+ select(0u, 2u, validCount1 > 0.0 || centerMoving1) |
1016
+ select(0u, 4u, validCount2 > 0.0 || centerMoving2) |
1017
+ select(0u, 8u, validCount3 > 0.0 || centerMoving3);
1018
+ empty.validMask =
1019
+ select(0u, 1u, validCount0 > 0.0 || centerMoving0) |
1020
+ select(0u, 2u, validCount1 > 0.0 || centerMoving1) |
1021
+ select(0u, 4u, validCount2 > 0.0 || centerMoving2) |
1022
+ select(0u, 8u, validCount3 > 0.0 || centerMoving3);
1023
+ empty.valid = empty.validMask != 0u;
1024
+ return empty;
1025
+ }
1026
+
1027
+ fn colorAt(pixel : vec2<i32>, dimensions : vec2<i32>) -> vec4<f32> {
1028
+ return textureLoad(currentColor, clamp(pixel, vec2<i32>(0), dimensions - vec2<i32>(1)), 0);
1029
+ }
1030
+
1031
+ fn cacheFootprintContains(receiverPixel : vec2<i32>, ownerPixel : vec2<i32>) -> bool {
1032
+ return
1033
+ receiverPixel.x >= ownerPixel.x && receiverPixel.x <= ownerPixel.x + 1 &&
1034
+ receiverPixel.y >= ownerPixel.y && receiverPixel.y <= ownerPixel.y + 1;
1035
+ }
1036
+
1037
+ fn cachedSupportCovers(
1038
+ receiverPixel : vec2<i32>,
1039
+ ownerPixel : vec2<i32>,
1040
+ motion : vec2<f32>,
1041
+ radius : f32,
1042
+ ) -> bool {
1043
+ if (!cacheFootprintContains(receiverPixel, ownerPixel)) { return false; }
1044
+ let motionLength = length(motion);
1045
+ if (motionLength <= 1e-5 || radius <= 1e-5) { return false; }
1046
+ let direction = motion / motionLength;
1047
+ let delta = vec2<f32>(receiverPixel - ownerPixel);
1048
+ let along = dot(delta, direction);
1049
+ let perpendicular = abs(delta.x * direction.y - delta.y * direction.x);
1050
+ return abs(along) <= radius + 1.0 && perpendicular <= 1.0;
1051
+ }
1052
+
1053
+ fn cachedDirectionMatches(support : MotionBlurSupport, motion : vec2<f32>) -> bool {
1054
+ let motionLength = length(motion);
1055
+ if (motionLength <= 1e-5) { return false; }
1056
+ let direction = motion / motionLength;
1057
+ if (support.primaryRadius > 1e-5 && abs(dot(normalize(support.primaryMotion), direction)) >= 0.94) {
1058
+ return true;
1059
+ }
1060
+ return support.secondaryRadius > 1e-5 && abs(dot(normalize(support.secondaryMotion), direction)) >= 0.94;
1061
+ }
1062
+
1063
+ fn depthGuidedHalfResolutionColor(
1064
+ localId : vec3<u32>,
1065
+ receiverPixel : vec2<i32>,
1066
+ support : MotionBlurSupport,
1067
+ centerDepth : f32,
1068
+ ) -> MotionBlurCacheSample {
1069
+ let cell = vec2<i32>(localId.xy / vec2<u32>(2));
1070
+ let quadrant = (localId.y % 2u) * 2u + (localId.x % 2u);
1071
+ var selected = MotionBlurCacheSample(vec4<f32>(0.0), 0.0, false);
1072
+ var selectedDelta = 3.402823e+38;
1073
+ // Search the bounded adjacent cache cells, but accept only a cell whose
1074
+ // authored 2x2 footprint and direction actually cover this receiver.
1075
+ for (var y = -1; y <= 1; y += 1) {
1076
+ for (var x = -1; x <= 1; x += 1) {
1077
+ let candidateCell = cell + vec2<i32>(x, y);
1078
+ if (candidateCell.x < 0 || candidateCell.y < 0 || candidateCell.x >= 4 || candidateCell.y >= 4) { continue; }
1079
+ let cacheCellIndex = u32(candidateCell.y * 4 + candidateCell.x);
1080
+ let index = cacheCellIndex * 4u + quadrant;
1081
+ if (
1082
+ halfFilteredValid[index] == 0u ||
1083
+ depthReject(centerDepth, halfFilteredDepth[index]) ||
1084
+ !cachedDirectionMatches(support, halfFilteredMotion[cacheCellIndex]) ||
1085
+ !cachedSupportCovers(
1086
+ receiverPixel,
1087
+ halfFilteredReceiver[cacheCellIndex],
1088
+ halfFilteredMotion[cacheCellIndex],
1089
+ halfFilteredRadius[cacheCellIndex],
1090
+ )
1091
+ ) { continue; }
1092
+ let delta = abs(halfFilteredDepth[index] - centerDepth);
1093
+ if (!selected.valid || delta < selectedDelta) {
1094
+ selected.color = halfFilteredColor[index];
1095
+ selected.coverage = halfFilteredCoverage[index];
1096
+ selectedDelta = delta;
1097
+ selected.valid = true;
1098
+ }
1099
+ }
1100
+ }
1101
+ return selected;
1102
+ }
1103
+
1104
+ @compute @workgroup_size(8, 8)
1105
+ fn tile_summary(
1106
+ @builtin(global_invocation_id) id : vec3<u32>,
1107
+ @builtin(local_invocation_id) localId : vec3<u32>,
1108
+ @builtin(local_invocation_index) localIndex : u32,
1109
+ @builtin(workgroup_id) workgroupId : vec3<u32>,
1110
+ ) {
1111
+ let dimensions = textureDimensions(sceneTemporal);
1112
+ let tiles = (dimensions + vec2<u32>(15)) / vec2<u32>(16);
1113
+ let tile = workgroupId.xy;
1114
+ let inTile = tile.x < tiles.x && tile.y < tiles.y;
1115
+ var maxMotion = vec2<f32>(0.0);
1116
+ var maxLength = 0.0;
1117
+ var maxSource = vec2<i32>(0);
1118
+ var secondaryMotion = vec2<f32>(0.0);
1119
+ var secondaryLength = 0.0;
1120
+ var secondarySource = vec2<i32>(0);
1121
+ for (var localY = 0u; localY < 2u; localY += 1u) {
1122
+ for (var localX = 0u; localX < 2u; localX += 1u) {
1123
+ let pixel = tile * vec2<u32>(16) + localId.xy * vec2<u32>(2) + vec2<u32>(localX, localY);
1124
+ if (!inTile || pixel.x >= dimensions.x || pixel.y >= dimensions.y) { continue; }
1125
+ let sample = temporalAt(vec2<i32>(pixel), vec2<i32>(dimensions));
1126
+ if (!sample.validDepth || !sample.motionValid) { continue; }
1127
+ let motionLength = length(sample.motion * vec2<f32>(dimensions));
1128
+ if (motionLength > maxLength) {
1129
+ if (distinctDirection(maxMotion, sample.motion)) {
1130
+ secondaryMotion = maxMotion;
1131
+ secondaryLength = maxLength;
1132
+ secondarySource = maxSource;
1133
+ }
1134
+ maxLength = motionLength;
1135
+ maxMotion = sample.motion;
1136
+ maxSource = vec2<i32>(pixel);
1137
+ } else if (
1138
+ motionLength > secondaryLength &&
1139
+ distinctDirection(maxMotion, sample.motion)
1140
+ ) {
1141
+ secondaryLength = motionLength;
1142
+ secondaryMotion = sample.motion;
1143
+ secondarySource = vec2<i32>(pixel);
1144
+ }
1145
+ }
1146
+ }
1147
+ tileMotionScratch[localIndex] = maxMotion;
1148
+ tileLengthScratch[localIndex] = maxLength;
1149
+ tileSourceScratch[localIndex] = maxSource;
1150
+ tileSecondaryMotionScratch[localIndex] = secondaryMotion;
1151
+ tileSecondaryLengthScratch[localIndex] = secondaryLength;
1152
+ tileSecondarySourceScratch[localIndex] = secondarySource;
1153
+ workgroupBarrier();
1154
+ for (var stride = 32u; stride > 0u; stride = stride / 2u) {
1155
+ if (localIndex < stride) {
1156
+ let otherIndex = localIndex + stride;
1157
+ let otherLength = tileLengthScratch[otherIndex];
1158
+ if (otherLength > tileLengthScratch[localIndex]) {
1159
+ let oldMotion = tileMotionScratch[localIndex];
1160
+ let oldLength = tileLengthScratch[localIndex];
1161
+ let oldSource = tileSourceScratch[localIndex];
1162
+ tileLengthScratch[localIndex] = otherLength;
1163
+ tileMotionScratch[localIndex] = tileMotionScratch[otherIndex];
1164
+ tileSourceScratch[localIndex] = tileSourceScratch[otherIndex];
1165
+ if (
1166
+ oldLength > 1e-5 &&
1167
+ distinctDirection(tileMotionScratch[localIndex], oldMotion) &&
1168
+ oldLength > tileSecondaryLengthScratch[localIndex]
1169
+ ) {
1170
+ tileSecondaryLengthScratch[localIndex] = oldLength;
1171
+ tileSecondaryMotionScratch[localIndex] = oldMotion;
1172
+ tileSecondarySourceScratch[localIndex] = oldSource;
1173
+ }
1174
+ }
1175
+ if (
1176
+ tileSecondaryLengthScratch[otherIndex] > tileSecondaryLengthScratch[localIndex] &&
1177
+ distinctDirection(
1178
+ tileMotionScratch[localIndex],
1179
+ tileSecondaryMotionScratch[otherIndex],
1180
+ )
1181
+ ) {
1182
+ tileSecondaryLengthScratch[localIndex] = tileSecondaryLengthScratch[otherIndex];
1183
+ tileSecondaryMotionScratch[localIndex] = tileSecondaryMotionScratch[otherIndex];
1184
+ tileSecondarySourceScratch[localIndex] = tileSecondarySourceScratch[otherIndex];
1185
+ }
1186
+ if (
1187
+ tileLengthScratch[otherIndex] > tileSecondaryLengthScratch[localIndex] &&
1188
+ distinctDirection(tileMotionScratch[localIndex], tileMotionScratch[otherIndex])
1189
+ ) {
1190
+ tileSecondaryLengthScratch[localIndex] = tileLengthScratch[otherIndex];
1191
+ tileSecondaryMotionScratch[localIndex] = tileMotionScratch[otherIndex];
1192
+ tileSecondarySourceScratch[localIndex] = tileSourceScratch[otherIndex];
1193
+ }
1194
+ }
1195
+ workgroupBarrier();
1196
+ }
1197
+ if (localIndex == 0u && inTile) {
1198
+ let tileIndex = tile.y * tiles.x + tile.x;
1199
+ tileSummary[tileIndex] = MotionBlurTileSummary(
1200
+ vec4<f32>(
1201
+ tileMotionScratch[0],
1202
+ f32(tileSourceScratch[0].x),
1203
+ f32(tileSourceScratch[0].y),
1204
+ ),
1205
+ vec4<f32>(
1206
+ tileSecondaryMotionScratch[0],
1207
+ f32(tileSecondarySourceScratch[0].x),
1208
+ f32(tileSecondarySourceScratch[0].y),
1209
+ ),
1210
+ );
1211
+ }
1212
+ }
1213
+
1214
+ @compute @workgroup_size(8, 8)
1215
+ fn reconstruct(
1216
+ @builtin(global_invocation_id) id : vec3<u32>,
1217
+ @builtin(local_invocation_id) localId : vec3<u32>,
1218
+ @builtin(workgroup_id) workgroupId : vec3<u32>,
1219
+ ) {
1220
+ let dimensions = textureDimensions(currentColor);
1221
+ let colorDimensions = vec2<i32>(dimensions);
1222
+ let pixel = vec2<i32>(id.xy);
1223
+ let inBounds = id.x < dimensions.x && id.y < dimensions.y;
1224
+ let workgroupBase = vec2<i32>(workgroupId.xy * vec2<u32>(8));
1225
+ let ownsHalfCell = (localId.x % 2u == 0u) && (localId.y % 2u == 0u);
1226
+ let halfIndex = (localId.y / 2u) * 4u + (localId.x / 2u);
1227
+ let localIndex = localId.y * 8u + localId.x;
1228
+ if (localIndex == 0u) {
1229
+ scanTileNeighbourhood(workgroupBase, colorDimensions);
1230
+ }
1231
+ workgroupBarrier();
1232
+ let support = resolveMotionBlurSupport(pixel, colorDimensions);
1233
+ let longVector = support.valid && support.longVector;
1234
+
1235
+ longVectorScratch[localId.y * 8u + localId.x] = select(0u, 1u, longVector);
1236
+ workgroupBarrier();
1237
+ for (var longStride = 32u; longStride > 0u; longStride = longStride / 2u) {
1238
+ let localIndex = localId.y * 8u + localId.x;
1239
+ if (localIndex < longStride) {
1240
+ longVectorScratch[localIndex] = max(
1241
+ longVectorScratch[localIndex],
1242
+ longVectorScratch[localIndex + longStride],
1243
+ );
1244
+ }
1245
+ workgroupBarrier();
1246
+ }
1247
+ let anyLongVector = longVectorScratch[0] != 0u;
1248
+ if (anyLongVector && ownsHalfCell) {
1249
+ // Every designated owner evaluates the full support exactly once. The
1250
+ // cached result includes filtered color, nearest accepted depth, and
1251
+ // coverage; no 2x2 prefilter is substituted for the long gather.
1252
+ let sourcePixel = workgroupBase + vec2<i32>(localId.xy);
1253
+ let sourceSupport = resolveMotionBlurSupport(sourcePixel, colorDimensions);
1254
+ let filtered = longSupportFilter(
1255
+ sourcePixel,
1256
+ sourceSupport,
1257
+ temporalAt(sourcePixel, colorDimensions).depth,
1258
+ colorDimensions,
1259
+ );
1260
+ halfFilteredReceiver[halfIndex] = sourcePixel;
1261
+ halfFilteredMotion[halfIndex] = filtered.motion;
1262
+ halfFilteredRadius[halfIndex] = filtered.radius;
1263
+ let cacheBase = halfIndex * 4u;
1264
+ halfFilteredColor[cacheBase] = filtered.color0;
1265
+ halfFilteredColor[cacheBase + 1u] = filtered.color1;
1266
+ halfFilteredColor[cacheBase + 2u] = filtered.color2;
1267
+ halfFilteredColor[cacheBase + 3u] = filtered.color3;
1268
+ halfFilteredDepth[cacheBase] = filtered.depth0;
1269
+ halfFilteredDepth[cacheBase + 1u] = filtered.depth1;
1270
+ halfFilteredDepth[cacheBase + 2u] = filtered.depth2;
1271
+ halfFilteredDepth[cacheBase + 3u] = filtered.depth3;
1272
+ halfFilteredCoverage[cacheBase] = filtered.coverage0;
1273
+ halfFilteredCoverage[cacheBase + 1u] = filtered.coverage1;
1274
+ halfFilteredCoverage[cacheBase + 2u] = filtered.coverage2;
1275
+ halfFilteredCoverage[cacheBase + 3u] = filtered.coverage3;
1276
+ halfFilteredValid[cacheBase] = select(0u, 1u, (filtered.validMask & 1u) != 0u);
1277
+ halfFilteredValid[cacheBase + 1u] = select(0u, 1u, (filtered.validMask & 2u) != 0u);
1278
+ halfFilteredValid[cacheBase + 2u] = select(0u, 1u, (filtered.validMask & 4u) != 0u);
1279
+ halfFilteredValid[cacheBase + 3u] = select(0u, 1u, (filtered.validMask & 8u) != 0u);
1280
+ }
1281
+ workgroupBarrier();
1282
+ // No invocation can return before both uniform barriers above. This keeps
1283
+ // odd extents and out-of-bounds lanes from allowing a long source to read a
1284
+ // cache entry that another invocation has not published yet.
1285
+ if (!inBounds) {
1286
+ return;
1287
+ }
1288
+ if (!support.valid) {
1289
+ textureStore(outputColor, pixel, colorAt(pixel, colorDimensions));
1290
+ return;
1291
+ }
1292
+ if (longVector) {
1293
+ let cached = depthGuidedHalfResolutionColor(
1294
+ localId,
1295
+ pixel,
1296
+ support,
1297
+ temporalAt(pixel, colorDimensions).depth,
1298
+ );
1299
+ if (cached.valid) {
1300
+ textureStore(outputColor, pixel, cached.color);
1301
+ } else {
1302
+ textureStore(outputColor, pixel, colorAt(pixel, colorDimensions));
1303
+ }
1304
+ return;
1305
+ }
1306
+ let useSecondDirection = support.secondaryRadius > 1e-5 && distinctDirection(support.primaryMotion, support.secondaryMotion);
1307
+ let count = min(params.sampleCount, 16u);
1308
+ let supportCount = max(count - 1u, 1u);
1309
+ let primaryCount = select(supportCount, max(supportCount / 2u, 1u), useSecondDirection);
1310
+ let secondaryCount = max(supportCount - primaryCount, 1u);
1311
+ let shutter = clamp(params.shutterAngle / 360.0, 0.0, 1.0);
1312
+ let center = temporalAt(pixel, colorDimensions);
1313
+ let centerMotion = sourceMotionPixels(center, colorDimensions);
1314
+ let centerMoving = center.validDepth && center.motionValid && sourceRadius(centerMotion, shutter) > 1e-5;
1315
+ let centerColor = colorAt(pixel, colorDimensions);
1316
+ var accum = vec4<f32>(0.0);
1317
+ var weight = 0.0;
1318
+ for (var index = 0u; index < 16u; index += 1u) {
1319
+ if (index >= supportCount) { break; }
1320
+ let secondDirection = useSecondDirection && index >= primaryCount;
1321
+ let localIndex = select(index, index - primaryCount, secondDirection);
1322
+ let localCount = select(primaryCount, secondaryCount, secondDirection);
1323
+ let selectedMotion = select(support.primaryMotion, support.secondaryMotion, secondDirection);
1324
+ let selectedRadius = select(support.primaryRadius, support.secondaryRadius, secondDirection);
1325
+ let tap = supportTap(
1326
+ pixel,
1327
+ localIndex,
1328
+ localCount,
1329
+ selectedMotion,
1330
+ selectedRadius,
1331
+ select(support.primaryAnchor, support.secondaryAnchor, secondDirection),
1332
+ colorDimensions,
1333
+ );
1334
+ if (!tap.valid) { continue; }
1335
+ let source = sourceAt(pixel, tap.pixel, center.depth, shutter, colorDimensions);
1336
+ if (!source.valid) { continue; }
1337
+ let sampleColor = colorAt(source.pixel, colorDimensions);
1338
+ accum += sampleColor;
1339
+ weight += 1.0;
1340
+ }
1341
+ let uncovered = max(f32(supportCount) - weight, 0.0);
1342
+ let acceptedColor = select(vec3<f32>(0.0), accum.rgb / max(weight, 1.0), weight > 0.0);
1343
+ let missingForeground = 1.0 - clamp(centerColor.a, 0.0, 1.0);
1344
+ let edgeCoverage = select(missingForeground, 1.0, centerMoving);
1345
+ let edgeFill = acceptedColor * edgeCoverage * EDGE_FILL_FACTOR;
1346
+ let movingFallback = select(centerColor.rgb, edgeFill, weight > 0.0);
1347
+ let uncoveredColor = select(vec3<f32>(0.0), movingFallback, centerMoving);
1348
+ let acceptedAlpha = select(0.0, accum.a / max(weight, 1.0), weight > 0.0);
1349
+ let acceptedCoverage = weight + missingForeground * uncovered;
1350
+ let reconstructedAlpha = acceptedAlpha * acceptedCoverage / max(f32(count), 1.0);
1351
+ let outputAlpha = clamp(max(centerColor.a, reconstructedAlpha), 0.0, 1.0);
1352
+ let emptyReceiver =
1353
+ !centerMoving &&
1354
+ weight > 0.0 &&
1355
+ max(max(abs(centerColor.r), abs(centerColor.g)), abs(centerColor.b)) < 1e-4;
1356
+ let sourceScale = select(1.0, 1.0 - EDGE_FILL_FACTOR, emptyReceiver);
1357
+ let sourceContribution = accum.rgb * sourceScale;
1358
+ let trailScale = select(1.0, 1.0 - EDGE_FILL_FACTOR, emptyReceiver);
1359
+ let trailContribution = uncoveredColor * uncovered * trailScale;
1360
+ textureStore(
1361
+ outputColor,
1362
+ pixel,
1363
+ vec4<f32>((sourceContribution + centerColor.rgb + trailContribution) / max(f32(count), 1.0), outputAlpha),
1364
+ );
1365
+ }
1366
+ `;
1367
+
1368
+ export function encodeMotionBlurComputeParams(
1369
+ params: MotionBlurParams,
1370
+ frameDeltaSeconds = 1 / 60,
1371
+ reset = false,
1372
+ ): Uint8Array {
1373
+ const bytes = new Uint8Array(MOTION_BLUR_PARAMS_BYTE_SIZE);
1374
+ const view = new DataView(bytes.buffer);
1375
+ // Keep invalid timing out of the GPU parameter block. The reset bit carries
1376
+ // the discontinuity; a finite zero makes the bypass deterministic and
1377
+ // avoids propagating NaN payloads through backend validation.
1378
+ const uniformDeltaSeconds = Number.isFinite(frameDeltaSeconds) ? frameDeltaSeconds : 0;
1379
+ view.setFloat32(0, params.shutterAngle, true);
1380
+ view.setFloat32(4, params.maxRadiusPixels, true);
1381
+ view.setUint32(8, effectiveMotionBlurSampleCount(params.sampleCount), true);
1382
+ view.setUint32(12, reset ? 1 : 0, true);
1383
+ view.setFloat32(16, params.targetFps, true);
1384
+ view.setFloat32(20, motionBlurExposureScale(uniformDeltaSeconds, params.targetFps), true);
1385
+ view.setFloat32(24, uniformDeltaSeconds, true);
1386
+ view.setUint32(28, 0, true);
1387
+ return bytes;
1388
+ }
1389
+
1390
+ function positiveDimension(value: number | undefined): number {
1391
+ return Number.isFinite(value) && (value as number) > 0 ? Math.ceil(value as number) : 1;
35
1392
  }
36
1393
 
37
1394
  export function planMotionBlur(
38
1395
  params: MotionBlurParams,
39
1396
  context: RenderFeaturePlanContext,
1397
+ temporal: Pick<MotionBlurFeatureFrame, 'frameDeltaSeconds' | 'reset'> = {
1398
+ frameDeltaSeconds: 1 / 60,
1399
+ reset: false,
1400
+ },
40
1401
  ): Result<RenderFeaturePlan, RenderError> {
41
1402
  if (!motionBlurTemporalDemand(params)) return ok({ resources: [], passes: [] });
42
1403
  try {
43
- const temporal = context.sceneData.require(SCENE_DATA_TEMPORAL_V1_SCHEMA);
1404
+ const sceneTemporal = context.sceneData.require(SCENE_DATA_TEMPORAL_V1_SCHEMA);
44
1405
  const outputFormat =
45
1406
  context.targets.find((target) => target.name === 'motion-output')?.format ?? 'rgba16float';
1407
+ const fullscreen = {
1408
+ kind: 'fullscreen-program' as const,
1409
+ name: 'motion-blur',
1410
+ source: 'forgeax::shader::motion-blur',
1411
+ reads: ['scene-color', 'scene-temporal'] as const,
1412
+ params: {
1413
+ byteSize: MOTION_BLUR_PARAMS_BYTE_SIZE,
1414
+ defaultValue: new Uint8Array(MOTION_BLUR_PARAMS_BYTE_SIZE),
1415
+ },
1416
+ };
1417
+ const computeCapable =
1418
+ context.caps.compute === true &&
1419
+ context.caps.storageBuffer === true &&
1420
+ context.caps.storageTexture === true &&
1421
+ context.caps.rgba16floatRenderable === true &&
1422
+ outputFormat === 'rgba16float';
1423
+ if (computeCapable) {
1424
+ const width = positiveDimension(context.frame.width);
1425
+ const height = positiveDimension(context.frame.height);
1426
+ const tileWidth = Math.max(1, Math.ceil(width / 16));
1427
+ const tileHeight = Math.max(1, Math.ceil(height / 16));
1428
+ return ok({
1429
+ resources: [
1430
+ {
1431
+ kind: 'compute-program',
1432
+ name: 'motion-blur-compute',
1433
+ program: {
1434
+ wgsl: MOTION_BLUR_COMPUTE_WGSL,
1435
+ entryPoints: ['tile_summary', 'reconstruct'],
1436
+ bindings: [
1437
+ {
1438
+ entries: [
1439
+ {
1440
+ binding: 0,
1441
+ visibility: 4,
1442
+ texture: { sampleType: 'float', viewDimension: '2d' },
1443
+ },
1444
+ {
1445
+ binding: 1,
1446
+ visibility: 4,
1447
+ texture: { sampleType: 'float', viewDimension: '2d' },
1448
+ },
1449
+ { binding: 2, visibility: 4, buffer: { type: 'storage' } },
1450
+ {
1451
+ binding: 3,
1452
+ visibility: 4,
1453
+ storageTexture: {
1454
+ access: 'write-only',
1455
+ format: 'rgba16float',
1456
+ viewDimension: '2d',
1457
+ },
1458
+ },
1459
+ { binding: 4, visibility: 4, buffer: { type: 'uniform' } },
1460
+ ],
1461
+ },
1462
+ ],
1463
+ },
1464
+ },
1465
+ {
1466
+ kind: 'buffer',
1467
+ name: 'motion-blur-tile-summary',
1468
+ size: tileWidth * tileHeight * 32,
1469
+ usage: ['storage'] as const,
1470
+ },
1471
+ {
1472
+ kind: 'buffer',
1473
+ name: 'motion-blur-compute-params',
1474
+ size: MOTION_BLUR_PARAMS_BYTE_SIZE,
1475
+ usage: ['uniform'] as const,
1476
+ data: encodeMotionBlurComputeParams(params, temporal.frameDeltaSeconds, temporal.reset),
1477
+ },
1478
+ {
1479
+ kind: 'prepared-gpu-resource',
1480
+ name: 'motion-blur-input',
1481
+ resource: { kind: 'texture-view' as const, value: undefined },
1482
+ logicalTarget: 'motion-input',
1483
+ },
1484
+ {
1485
+ kind: 'prepared-gpu-resource',
1486
+ name: 'motion-blur-temporal',
1487
+ resource: { kind: 'texture-view' as const, value: undefined },
1488
+ logicalTarget: sceneTemporal,
1489
+ },
1490
+ {
1491
+ kind: 'prepared-gpu-resource',
1492
+ name: 'motion-blur-output',
1493
+ resource: { kind: 'texture-view' as const, value: undefined },
1494
+ logicalTarget: 'motion-output',
1495
+ },
1496
+ {
1497
+ kind: 'compute-bindings',
1498
+ name: 'motion-blur-compute-bindings',
1499
+ program: 'motion-blur-compute',
1500
+ entries: [
1501
+ { binding: 0, resource: 'motion-blur-input' },
1502
+ { binding: 1, resource: 'motion-blur-temporal' },
1503
+ { binding: 2, resource: 'motion-blur-tile-summary' },
1504
+ { binding: 3, resource: 'motion-blur-output' },
1505
+ { binding: 4, resource: 'motion-blur-compute-params' },
1506
+ ],
1507
+ },
1508
+ ],
1509
+ passes: [
1510
+ {
1511
+ kind: 'compute',
1512
+ name: 'motion-blur-tile-summary',
1513
+ program: 'motion-blur-compute',
1514
+ bindings: 'motion-blur-compute-bindings',
1515
+ dispatches: [
1516
+ {
1517
+ kind: 'direct',
1518
+ entryPoint: 'tile_summary',
1519
+ workgroups: [tileWidth, tileHeight, 1],
1520
+ },
1521
+ ],
1522
+ },
1523
+ {
1524
+ kind: 'compute',
1525
+ // Keep the public post-process identity stable while the compute
1526
+ // lane adds its private tile-summary producer before it.
1527
+ name: 'motion-blur',
1528
+ program: 'motion-blur-compute',
1529
+ bindings: 'motion-blur-compute-bindings',
1530
+ dispatches: [
1531
+ {
1532
+ kind: 'direct',
1533
+ entryPoint: 'reconstruct',
1534
+ workgroups: [
1535
+ Math.max(1, Math.ceil(width / 8)),
1536
+ Math.max(1, Math.ceil(height / 8)),
1537
+ 1,
1538
+ ],
1539
+ },
1540
+ ],
1541
+ },
1542
+ ],
1543
+ });
1544
+ }
46
1545
  return ok({
47
1546
  resources: [
48
- {
49
- kind: 'fullscreen-program',
50
- name: 'motion-blur',
51
- source: 'forgeax::shader::motion-blur',
52
- reads: ['scene-color', 'scene-temporal'],
53
- params: { byteSize: 16, defaultValue: new Uint8Array(16) },
54
- },
1547
+ fullscreen,
55
1548
  {
56
1549
  kind: 'graphics-program',
57
1550
  name: 'motion-blur-pipeline',
@@ -71,7 +1564,7 @@ export function planMotionBlur(
71
1564
  fullscreen: true,
72
1565
  shader: MOTION_BLUR_FEATURE_IDENTITY,
73
1566
  input: 'motion-input',
74
- temporal,
1567
+ temporal: sceneTemporal,
75
1568
  },
76
1569
  logicalTargets: { input: 'motion-input' },
77
1570
  },
@@ -81,7 +1574,7 @@ export function planMotionBlur(
81
1574
  kind: 'raster',
82
1575
  name: 'motion-blur',
83
1576
  colorAttachments: [{ target: 'motion-output', loadOp: 'clear', storeOp: 'store' }],
84
- sampledTargets: ['motion-input', temporal],
1577
+ sampledTargets: ['motion-input', sceneTemporal],
85
1578
  draws: [
86
1579
  {
87
1580
  program: 'motion-blur-pipeline',
@@ -100,20 +1593,31 @@ export function planMotionBlur(
100
1593
  }
101
1594
 
102
1595
  export function createMotionBlurFeature(
103
- read: () => Partial<MotionBlurParams> | undefined,
1596
+ read: () => MotionBlurFeatureInput | undefined,
104
1597
  ): RenderFeature<MotionBlurFeatureFrame> {
105
1598
  return Object.freeze({
106
1599
  identity: MOTION_BLUR_FEATURE_IDENTITY,
1600
+ // The program is an Engine-owned static WGSL source. Immediate creation
1601
+ // lets a newly admitted frame use the same graph/dispatch receipt instead
1602
+ // of silently falling back to the raster post effect while the validated
1603
+ // manifest adapter is still warming.
1604
+ shaderModuleMode: 'immediate' as const,
107
1605
  extract: (_context: RenderFeatureExtractContext) => {
108
1606
  const input = read();
109
- const validated = validateMotionBlurParams(input);
1607
+ const validated = validateMotionBlurParams(input?.params);
110
1608
  if (!validated.ok) return err(validated.error as unknown as RenderError);
111
- const params = input === undefined ? undefined : validated.value;
112
- return ok({ params, demanded: motionBlurTemporalDemand(params) });
1609
+ const params = input?.params === undefined ? undefined : validated.value;
1610
+ return ok({
1611
+ params,
1612
+ demanded: motionBlurTemporalDemand(params),
1613
+ frameDeltaSeconds:
1614
+ input?.frameDeltaSeconds === undefined ? 1 / 60 : input.frameDeltaSeconds,
1615
+ reset: input?.reset === true,
1616
+ });
113
1617
  },
114
1618
  plan: (frame: MotionBlurFeatureFrame, context: RenderFeaturePlanContext) =>
115
1619
  frame.params === undefined
116
1620
  ? ok({ resources: [], passes: [] })
117
- : planMotionBlur(frame.params, context),
1621
+ : planMotionBlur(frame.params, context, frame),
118
1622
  });
119
1623
  }