@woosh/meep-engine 3.11.3 → 3.12.0

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 (401) hide show
  1. package/README.md +14 -18
  2. package/editor/SelectionVisualizer.js +127 -127
  3. package/editor/entity_world_bounds.js +27 -27
  4. package/editor/particles/rebuildParticleEmitter.js +31 -31
  5. package/editor/process/SymbolicDisplayProcess.js +1 -1
  6. package/editor/process/symbolic/CameraSymbolicDisplay.js +146 -146
  7. package/editor/process/symbolic/LightSymbolicDisplay.js +172 -172
  8. package/editor/prototypeEditorShell.js +156 -156
  9. package/editor/selection/editor_pick.js +130 -130
  10. package/editor/selection/screen_rect_frustum.js +119 -119
  11. package/editor/tools/v2/TransformControls.js +971 -971
  12. package/package.json +1 -1
  13. package/src/engine/Engine.d.ts +3 -3
  14. package/src/engine/Engine.d.ts.map +1 -1
  15. package/src/engine/Engine.js +726 -726
  16. package/src/engine/EngineHarness.d.ts +1 -1
  17. package/src/engine/EngineHarness.js +590 -590
  18. package/src/engine/ecs/HIERARCHY_REVIEW.md +717 -717
  19. package/src/engine/ecs/terrain/ecs/BuildLightTexture.d.ts +1 -1
  20. package/src/engine/ecs/terrain/ecs/BuildLightTexture.js +27 -27
  21. package/src/engine/ecs/terrain/ecs/Terrain.d.ts +1 -5
  22. package/src/engine/ecs/terrain/ecs/Terrain.d.ts.map +1 -1
  23. package/src/engine/ecs/terrain/ecs/Terrain.js +790 -790
  24. package/src/engine/ecs/terrain/util/loadVisibleTerrainTiles.js +129 -129
  25. package/src/engine/graphics/ecs/camera/camera_find_active.d.ts +1 -1
  26. package/src/engine/graphics/ecs/camera/camera_find_active.js +28 -28
  27. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.d.ts +1 -1
  28. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js +428 -428
  29. package/src/engine/graphics/ecs/water/Water.d.ts +1 -1
  30. package/src/engine/graphics/ecs/water/Water.js +104 -104
  31. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.d.ts +1 -1
  32. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js +1 -1
  33. package/src/engine/graphics3/AnimationGraphSystem.d.ts +65 -0
  34. package/src/engine/graphics3/AnimationGraphSystem.d.ts.map +1 -0
  35. package/src/engine/graphics3/AnimationGraphSystem.js +329 -0
  36. package/src/engine/graphics3/AnimationGraphSystem3.d.ts +6 -61
  37. package/src/engine/graphics3/AnimationGraphSystem3.d.ts.map +1 -1
  38. package/src/engine/graphics3/AnimationGraphSystem3.js +6 -325
  39. package/src/engine/graphics3/AnimationSystem.d.ts +76 -0
  40. package/src/engine/graphics3/AnimationSystem.d.ts.map +1 -0
  41. package/src/engine/graphics3/AnimationSystem.js +352 -0
  42. package/src/engine/graphics3/AnimationSystem3.d.ts +6 -72
  43. package/src/engine/graphics3/AnimationSystem3.d.ts.map +1 -1
  44. package/src/engine/graphics3/AnimationSystem3.js +6 -348
  45. package/src/engine/graphics3/CameraSystem.d.ts +49 -0
  46. package/src/engine/graphics3/CameraSystem.d.ts.map +1 -0
  47. package/src/engine/graphics3/CameraSystem.js +170 -0
  48. package/src/engine/graphics3/CameraSystem3.d.ts +6 -45
  49. package/src/engine/graphics3/CameraSystem3.d.ts.map +1 -1
  50. package/src/engine/graphics3/CameraSystem3.js +6 -166
  51. package/src/engine/graphics3/DebugDrawSystem.d.ts +72 -0
  52. package/src/engine/graphics3/DebugDrawSystem.d.ts.map +1 -0
  53. package/src/engine/graphics3/DebugDrawSystem.js +205 -0
  54. package/src/engine/graphics3/DebugDrawSystem3.d.ts +6 -68
  55. package/src/engine/graphics3/DebugDrawSystem3.d.ts.map +1 -1
  56. package/src/engine/graphics3/DebugDrawSystem3.js +6 -201
  57. package/src/engine/graphics3/DecalSystem.d.ts +81 -0
  58. package/src/engine/graphics3/DecalSystem.d.ts.map +1 -0
  59. package/src/engine/graphics3/DecalSystem.js +441 -0
  60. package/src/engine/graphics3/DecalSystem3.d.ts +6 -77
  61. package/src/engine/graphics3/DecalSystem3.d.ts.map +1 -1
  62. package/src/engine/graphics3/DecalSystem3.js +6 -437
  63. package/src/engine/graphics3/FogOfWarSystem.d.ts +74 -0
  64. package/src/engine/graphics3/FogOfWarSystem.d.ts.map +1 -0
  65. package/src/engine/graphics3/FogOfWarSystem.js +265 -0
  66. package/src/engine/graphics3/FogOfWarSystem3.d.ts +6 -70
  67. package/src/engine/graphics3/FogOfWarSystem3.d.ts.map +1 -1
  68. package/src/engine/graphics3/FogOfWarSystem3.js +6 -261
  69. package/src/engine/graphics3/GraphicsEngine.d.ts +340 -0
  70. package/src/engine/graphics3/GraphicsEngine.d.ts.map +1 -0
  71. package/src/engine/graphics3/GraphicsEngine.js +790 -0
  72. package/src/engine/graphics3/GraphicsEngine3.d.ts +6 -336
  73. package/src/engine/graphics3/GraphicsEngine3.d.ts.map +1 -1
  74. package/src/engine/graphics3/GraphicsEngine3.js +6 -786
  75. package/src/engine/graphics3/HighlightSystem.d.ts +82 -0
  76. package/src/engine/graphics3/HighlightSystem.d.ts.map +1 -0
  77. package/src/engine/graphics3/HighlightSystem.js +241 -0
  78. package/src/engine/graphics3/HighlightSystem3.d.ts +6 -78
  79. package/src/engine/graphics3/HighlightSystem3.d.ts.map +1 -1
  80. package/src/engine/graphics3/HighlightSystem3.js +6 -237
  81. package/src/engine/graphics3/LightSystem.d.ts +40 -0
  82. package/src/engine/graphics3/LightSystem.d.ts.map +1 -0
  83. package/src/engine/graphics3/LightSystem.js +211 -0
  84. package/src/engine/graphics3/LightSystem3.d.ts +6 -36
  85. package/src/engine/graphics3/LightSystem3.d.ts.map +1 -1
  86. package/src/engine/graphics3/LightSystem3.js +6 -207
  87. package/src/engine/graphics3/MeshSystem.d.ts +106 -0
  88. package/src/engine/graphics3/MeshSystem.d.ts.map +1 -0
  89. package/src/engine/graphics3/MeshSystem.js +647 -0
  90. package/src/engine/graphics3/MeshSystem3.d.ts +6 -102
  91. package/src/engine/graphics3/MeshSystem3.d.ts.map +1 -1
  92. package/src/engine/graphics3/MeshSystem3.js +6 -642
  93. package/src/engine/graphics3/ParticipatingMedia.d.ts +2 -2
  94. package/src/engine/graphics3/ParticipatingMedia.js +203 -203
  95. package/src/engine/graphics3/ParticipatingMediaSystem.d.ts +87 -0
  96. package/src/engine/graphics3/ParticipatingMediaSystem.d.ts.map +1 -0
  97. package/src/engine/graphics3/ParticipatingMediaSystem.js +184 -0
  98. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +6 -83
  99. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -1
  100. package/src/engine/graphics3/ParticipatingMediaSystem3.js +6 -180
  101. package/src/engine/graphics3/ParticleEmitterSystem.d.ts +76 -0
  102. package/src/engine/graphics3/ParticleEmitterSystem.d.ts.map +1 -0
  103. package/src/engine/graphics3/ParticleEmitterSystem.js +289 -0
  104. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts +6 -72
  105. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts.map +1 -1
  106. package/src/engine/graphics3/ParticleEmitterSystem3.js +6 -285
  107. package/src/engine/graphics3/PathDisplaySystem.d.ts +70 -0
  108. package/src/engine/graphics3/PathDisplaySystem.d.ts.map +1 -0
  109. package/src/engine/graphics3/PathDisplaySystem.js +309 -0
  110. package/src/engine/graphics3/PathDisplaySystem3.d.ts +6 -66
  111. package/src/engine/graphics3/PathDisplaySystem3.d.ts.map +1 -1
  112. package/src/engine/graphics3/PathDisplaySystem3.js +6 -305
  113. package/src/engine/graphics3/Pickable.d.ts +1 -1
  114. package/src/engine/graphics3/Pickable.js +65 -65
  115. package/src/engine/graphics3/PickingQuery.d.ts +1 -1
  116. package/src/engine/graphics3/PickingQuery.js +48 -48
  117. package/src/engine/graphics3/PickingSystem.d.ts +67 -0
  118. package/src/engine/graphics3/PickingSystem.d.ts.map +1 -0
  119. package/src/engine/graphics3/PickingSystem.js +273 -0
  120. package/src/engine/graphics3/PickingSystem3.d.ts +6 -63
  121. package/src/engine/graphics3/PickingSystem3.d.ts.map +1 -1
  122. package/src/engine/graphics3/PickingSystem3.js +6 -269
  123. package/src/engine/graphics3/ShadedGeometrySystem.d.ts +47 -0
  124. package/src/engine/graphics3/ShadedGeometrySystem.d.ts.map +1 -0
  125. package/src/engine/graphics3/ShadedGeometrySystem.js +169 -0
  126. package/src/engine/graphics3/ShadedGeometrySystem3.d.ts +6 -43
  127. package/src/engine/graphics3/ShadedGeometrySystem3.d.ts.map +1 -1
  128. package/src/engine/graphics3/ShadedGeometrySystem3.js +6 -165
  129. package/src/engine/graphics3/TerrainSystem.d.ts +80 -0
  130. package/src/engine/graphics3/TerrainSystem.d.ts.map +1 -0
  131. package/src/engine/graphics3/TerrainSystem.js +566 -0
  132. package/src/engine/graphics3/TerrainSystem3.d.ts +6 -76
  133. package/src/engine/graphics3/TerrainSystem3.d.ts.map +1 -1
  134. package/src/engine/graphics3/TerrainSystem3.js +6 -562
  135. package/src/engine/graphics3/TooltipComponentSystem.d.ts +65 -0
  136. package/src/engine/graphics3/TooltipComponentSystem.d.ts.map +1 -0
  137. package/src/engine/graphics3/TooltipComponentSystem.js +328 -0
  138. package/src/engine/graphics3/TooltipComponentSystem3.d.ts +6 -61
  139. package/src/engine/graphics3/TooltipComponentSystem3.d.ts.map +1 -1
  140. package/src/engine/graphics3/TooltipComponentSystem3.js +6 -324
  141. package/src/engine/graphics3/Trail3DSystem.d.ts +73 -0
  142. package/src/engine/graphics3/Trail3DSystem.d.ts.map +1 -0
  143. package/src/engine/graphics3/Trail3DSystem.js +221 -0
  144. package/src/engine/graphics3/Trail3DSystem3.d.ts +6 -69
  145. package/src/engine/graphics3/Trail3DSystem3.d.ts.map +1 -1
  146. package/src/engine/graphics3/Trail3DSystem3.js +6 -217
  147. package/src/engine/graphics3/VolumetricLightMap.d.ts +2 -2
  148. package/src/engine/graphics3/VolumetricLightMap.js +2 -2
  149. package/src/engine/graphics3/VolumetricLightMapSystem.d.ts +98 -0
  150. package/src/engine/graphics3/VolumetricLightMapSystem.d.ts.map +1 -0
  151. package/src/engine/graphics3/VolumetricLightMapSystem.js +261 -0
  152. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts +6 -94
  153. package/src/engine/graphics3/VolumetricLightMapSystem3.d.ts.map +1 -1
  154. package/src/engine/graphics3/VolumetricLightMapSystem3.js +10 -261
  155. package/src/engine/graphics3/WaterSystem.d.ts +59 -0
  156. package/src/engine/graphics3/WaterSystem.d.ts.map +1 -0
  157. package/src/engine/graphics3/WaterSystem.js +293 -0
  158. package/src/engine/graphics3/WaterSystem3.d.ts +6 -55
  159. package/src/engine/graphics3/WaterSystem3.d.ts.map +1 -1
  160. package/src/engine/graphics3/WaterSystem3.js +6 -289
  161. package/src/engine/graphics3/animation/AnimationGraphController.d.ts +1 -1
  162. package/src/engine/graphics3/animation/AnimationGraphController.js +518 -518
  163. package/src/engine/graphics3/animation/ClipListPlayer.d.ts +1 -1
  164. package/src/engine/graphics3/animation/ClipListPlayer.js +263 -263
  165. package/src/engine/graphics3/animation/ClipPlayback.d.ts +2 -2
  166. package/src/engine/graphics3/animation/ClipPlayback.js +84 -84
  167. package/src/engine/graphics3/decal/graph_build_decal_clusters.d.ts.map +1 -1
  168. package/src/engine/graphics3/decal/graph_build_decal_clusters.js +6 -8
  169. package/src/engine/graphics3/decal/shader_cluster_assign_decals.d.ts +0 -7
  170. package/src/engine/graphics3/decal/shader_cluster_assign_decals.d.ts.map +1 -1
  171. package/src/engine/graphics3/decal/shader_cluster_assign_decals.js +1 -1
  172. package/src/engine/graphics3/decal/shader_cull_decals_frustum.d.ts +0 -4
  173. package/src/engine/graphics3/decal/shader_cull_decals_frustum.d.ts.map +1 -1
  174. package/src/engine/graphics3/decal/shader_cull_decals_frustum.js +1 -1
  175. package/src/engine/graphics3/highlight/pack_highlight_table.d.ts +2 -2
  176. package/src/engine/graphics3/highlight/pack_highlight_table.d.ts.map +1 -1
  177. package/src/engine/graphics3/highlight/pack_highlight_table.js +74 -74
  178. package/src/engine/graphics3/pose/query_entity_node_world_pose.d.ts +1 -1
  179. package/src/engine/graphics3/pose/query_entity_node_world_pose.js +101 -101
  180. package/src/engine/graphics3/preview/make_model_thumbnail.d.ts +3 -3
  181. package/src/engine/graphics3/preview/make_model_thumbnail.d.ts.map +1 -1
  182. package/src/engine/graphics3/preview/make_model_thumbnail.js +48 -48
  183. package/src/engine/graphics3/shade_camera_projection_ray.d.ts +1 -1
  184. package/src/engine/graphics3/shade_camera_projection_ray.js +77 -77
  185. package/src/engine/graphics3/shade_node_to_entity_composition.d.ts +1 -1
  186. package/src/engine/graphics3/shade_node_to_entity_composition.js +84 -84
  187. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.d.ts.map +1 -1
  188. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_leaf_triangles.js +0 -1
  189. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.d.ts.map +1 -1
  190. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_points.js +0 -1
  191. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.d.ts.map +1 -1
  192. package/src/shade/playground/skinned_blas_refit/shader_blas_audit_visibility.js +133 -134
  193. package/src/shade/playground/skinned_blas_refit/verify_leaf_triangles.js +202 -202
  194. package/src/shade/playground/skinned_blas_refit/verify_traversal_reachability.js +249 -249
  195. package/src/shade/renderer/STATIC_GRAPHICS_ENGINE_ASSETS.js +1 -1
  196. package/src/shade/renderer/animation/pose/PosePlayback.d.ts +2 -1
  197. package/src/shade/renderer/animation/pose/PosePlayback.d.ts.map +1 -1
  198. package/src/shade/renderer/animation/pose/PosePlayback.js +2 -1
  199. package/src/shade/renderer/animation/shader_animation_apply.d.ts.map +1 -1
  200. package/src/shade/renderer/animation/shader_animation_apply.js +128 -129
  201. package/src/shade/renderer/animation/shader_animation_pose_resolve.d.ts.map +1 -1
  202. package/src/shade/renderer/animation/shader_animation_pose_resolve.js +176 -177
  203. package/src/shade/renderer/animation/shader_animation_tick.d.ts.map +1 -1
  204. package/src/shade/renderer/animation/shader_animation_tick.js +125 -126
  205. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.d.ts.map +1 -1
  206. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_geometry_sphere_derive.js +98 -99
  207. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.d.ts.map +1 -1
  208. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_reduce.js +212 -213
  209. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.d.ts.map +1 -1
  210. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_meshlet_bounds_scatter.js +120 -121
  211. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.d.ts.map +1 -1
  212. package/src/shade/renderer/animation/skinning/bounds_refresh/shader_skinned_mesh_bounds_refresh.js +75 -76
  213. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.d.ts.map +1 -1
  214. package/src/shade/renderer/animation/skinning/shader_meshlet_apply_skinning.js +371 -372
  215. package/src/shade/renderer/animation/skinning/shader_skin_matrix_prep.d.ts.map +1 -1
  216. package/src/shade/renderer/animation/skinning/shader_skin_matrix_prep.js +104 -105
  217. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.d.ts.map +1 -1
  218. package/src/shade/renderer/atmosphere/shader_multiscatter_lut.js +0 -1
  219. package/src/shade/renderer/buffer/graph_inspect_gpu_buffer.js +136 -136
  220. package/src/shade/renderer/buffer/table/GPUDatabase.d.ts.map +1 -1
  221. package/src/shade/renderer/buffer/table/GPUDatabase.js +2 -3
  222. package/src/shade/renderer/buffer/table/single/GPUSingleTypeTable.js +319 -319
  223. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.d.ts.map +1 -1
  224. package/src/shade/renderer/buffer/util/shader_buffer_copy_storage_to_storage.js +41 -42
  225. package/src/shade/renderer/buffer/util/shader_buffer_intra_copy_regions.d.ts.map +1 -1
  226. package/src/shade/renderer/buffer/util/shader_buffer_intra_copy_regions.js +83 -84
  227. package/src/shade/renderer/dynamic/shader_dynamic_mesh.d.ts.map +1 -1
  228. package/src/shade/renderer/dynamic/shader_dynamic_mesh.js +177 -170
  229. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +918 -918
  230. package/src/shade/renderer/geometry/bvh/record_blas_refit.js +4 -4
  231. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.d.ts.map +1 -1
  232. package/src/shade/renderer/geometry/bvh/shader_blas_refit_leaf_bounds.js +121 -122
  233. package/src/shade/renderer/geometry/meshlet/GPUMeshletManager.js +774 -774
  234. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.d.ts +1 -1
  235. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.d.ts.map +1 -1
  236. package/src/shade/renderer/geometry/meshlet/shader_meshlet_metadata_address_patch.js +69 -70
  237. package/src/shade/renderer/global_illumination/lpv/placement/graph_generate_probe_locations.js +367 -367
  238. package/src/shade/renderer/global_illumination/probe/octahedral/convolve/graph_lvp_atlas_to_sh.js +184 -184
  239. package/src/shade/renderer/gpu_primitive/bvh/GPU_BVH_BUILD_PLAN.md +1115 -1115
  240. package/src/shade/renderer/gpu_primitive/bvh/graph_bvh_build_lbvh.js +217 -217
  241. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.d.ts.map +1 -1
  242. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_emit_hierarchy.js +142 -143
  243. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.d.ts.map +1 -1
  244. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_morton.js +0 -1
  245. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.d.ts.map +1 -1
  246. package/src/shade/renderer/gpu_primitive/bvh/shader_bvh_refit.js +140 -141
  247. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.d.ts.map +1 -1
  248. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_histogram.js +0 -1
  249. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.d.ts.map +1 -1
  250. package/src/shade/renderer/gpu_primitive/sort/shader_radix_sort_scatter.js +0 -1
  251. package/src/shade/renderer/light/cluster/cull/graph_cull_lights.d.ts.map +1 -1
  252. package/src/shade/renderer/light/cluster/cull/graph_cull_lights.js +191 -198
  253. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.d.ts +0 -6
  254. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.d.ts.map +1 -1
  255. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_frustum.js +120 -120
  256. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.d.ts +0 -4
  257. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.d.ts.map +1 -1
  258. package/src/shade/renderer/light/cluster/cull/shader_cull_lights_hzb.js +112 -112
  259. package/src/shade/renderer/light/cluster/graph_assign_clsuters.d.ts.map +1 -1
  260. package/src/shade/renderer/light/cluster/graph_assign_clsuters.js +4 -7
  261. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.d.ts +0 -6
  262. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.d.ts.map +1 -1
  263. package/src/shade/renderer/light/cluster/shader_cluster_assign_lights.js +1 -1
  264. package/src/shade/renderer/lightmap/bake/raster/shader_lightmap_to_viz_buffer.js +222 -222
  265. package/src/shade/renderer/lightmap/bake/tile/shader_lightmap_tile_composit.js +81 -81
  266. package/src/shade/renderer/material/GPUMaterialContext.js +380 -380
  267. package/src/shade/renderer/material/standard/fragment_gbuffer_vt.js +169 -169
  268. package/src/shade/renderer/object_property/shader_object_property_process.d.ts.map +1 -1
  269. package/src/shade/renderer/object_property/shader_object_property_process.js +83 -84
  270. package/src/shade/renderer/object_property/shader_refresh_all_mesh_bounds.d.ts.map +1 -1
  271. package/src/shade/renderer/object_property/shader_refresh_all_mesh_bounds.js +57 -58
  272. package/src/shade/renderer/particles/prototypeParticleSystem.js +2 -3
  273. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  274. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +117 -118
  275. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  276. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +77 -79
  277. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  278. package/src/shade/renderer/particles/shaders/shader_particle_render.js +190 -183
  279. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  280. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +99 -100
  281. package/src/shade/renderer/particles/sort/shader_particle_sort.d.ts.map +1 -1
  282. package/src/shade/renderer/particles/sort/shader_particle_sort.js +158 -161
  283. package/src/shade/renderer/path_tracer/graph_path_trace_scene.js +184 -184
  284. package/src/shade/renderer/postprocess/denoise/graph_atorus_denoise_luma.js +119 -119
  285. package/src/shade/renderer/postprocess/dof/raymarch/graph_postprocess_dof.js +3 -4
  286. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.d.ts.map +1 -1
  287. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_raymarch_compute.js +266 -267
  288. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.d.ts.map +1 -1
  289. package/src/shade/renderer/postprocess/dof/raymarch/shader_dof_slice_compute.js +270 -271
  290. package/src/shade/renderer/postprocess/nss/shader_nss_concat.d.ts.map +1 -1
  291. package/src/shade/renderer/postprocess/nss/shader_nss_concat.js +68 -69
  292. package/src/shade/renderer/postprocess/nss/shader_nss_conv_layer.d.ts.map +1 -1
  293. package/src/shade/renderer/postprocess/nss/shader_nss_conv_layer.js +225 -226
  294. package/src/shade/renderer/postprocess/nss/shader_nss_extract_feedback.d.ts.map +1 -1
  295. package/src/shade/renderer/postprocess/nss/shader_nss_extract_feedback.js +49 -50
  296. package/src/shade/renderer/postprocess/nss/shader_nss_postprocess.d.ts.map +1 -1
  297. package/src/shade/renderer/postprocess/nss/shader_nss_postprocess.js +291 -292
  298. package/src/shade/renderer/postprocess/nss/shader_nss_preprocess.d.ts.map +1 -1
  299. package/src/shade/renderer/postprocess/nss/shader_nss_preprocess.js +230 -231
  300. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +16 -2
  301. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +1 -1
  302. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.js +12 -12
  303. package/src/shade/renderer/rasterize/bucket/prepare_meshlet_draw_commands_by_material.js +2 -2
  304. package/src/shade/renderer/rasterize/bucket/shader_count_meshlets_by_material.d.ts.map +1 -1
  305. package/src/shade/renderer/rasterize/bucket/shader_count_meshlets_by_material.js +68 -69
  306. package/src/shade/renderer/rasterize/bucket/shader_make_meshlet_indirect_draw_commands_by_material.d.ts.map +1 -1
  307. package/src/shade/renderer/rasterize/bucket/shader_make_meshlet_indirect_draw_commands_by_material.js +59 -60
  308. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.d.ts.map +1 -1
  309. package/src/shade/renderer/rasterize/bucket/shader_read_out_bucket.js +0 -1
  310. package/src/shade/renderer/rasterize/bucket/shader_sort_meshlets_by_material.d.ts.map +1 -1
  311. package/src/shade/renderer/rasterize/bucket/shader_sort_meshlets_by_material.js +64 -65
  312. package/src/shade/renderer/rasterize/compute/graph_mesh_frustum_cull.d.ts.map +1 -1
  313. package/src/shade/renderer/rasterize/compute/graph_mesh_frustum_cull.js +168 -169
  314. package/src/shade/renderer/rasterize/compute/graph_mesh_sphere_cull.d.ts.map +1 -1
  315. package/src/shade/renderer/rasterize/compute/graph_mesh_sphere_cull.js +160 -161
  316. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.d.ts.map +1 -1
  317. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_mesh_filter_2way.js +109 -110
  318. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.d.ts.map +1 -1
  319. package/src/shade/renderer/rasterize/cull/hzb/shader_hzb_meshlet_filter_1way_full.js +115 -116
  320. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_classify_bucket.d.ts.map +1 -1
  321. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_classify_bucket.js +139 -140
  322. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_count_by_rasterization_bucket.d.ts.map +1 -1
  323. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_count_by_rasterization_bucket.js +91 -92
  324. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.d.ts.map +1 -1
  325. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.js +88 -89
  326. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_count_by_rasterization_bucket.d.ts.map +1 -1
  327. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_count_by_rasterization_bucket.js +90 -91
  328. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.d.ts.map +1 -1
  329. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_extract_bucket.js +65 -66
  330. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.d.ts.map +1 -1
  331. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.js +89 -90
  332. package/src/shade/renderer/rasterize/expand/graph_mesh_filter_one_way.d.ts.map +1 -1
  333. package/src/shade/renderer/rasterize/expand/graph_mesh_filter_one_way.js +169 -170
  334. package/src/shade/renderer/rasterize/expand/mesh/shader_mesh_expand_to_meshlets_prefix.d.ts.map +1 -1
  335. package/src/shade/renderer/rasterize/expand/mesh/shader_mesh_expand_to_meshlets_prefix.js +89 -90
  336. package/src/shade/renderer/rasterize/expand/mesh/shader_meshes_to_meshlet_counts.d.ts.map +1 -1
  337. package/src/shade/renderer/rasterize/expand/mesh/shader_meshes_to_meshlet_counts.js +61 -62
  338. package/src/shade/renderer/rasterize/expand/pass1/filter_meshlets.2-way.compute.d.ts.map +1 -1
  339. package/src/shade/renderer/rasterize/expand/pass1/filter_meshlets.2-way.compute.js +153 -154
  340. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.js +144 -144
  341. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_pass_descriptor.js +88 -88
  342. package/src/shade/renderer/rasterize/native/oit/shader_oit_generate_moments.js +217 -217
  343. package/src/shade/renderer/rasterize/native/oit/shader_oit_resolve_moments.js +179 -179
  344. package/src/shade/renderer/rasterize/native/oit/shader_oit_resolve_moments_ibl.js +178 -178
  345. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_alpha_tested_pass_descriptor.js +194 -194
  346. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_opaque_pass_descriptor.js +76 -76
  347. package/src/shade/renderer/restir/di/shader_restir_di_denoise.d.ts.map +1 -1
  348. package/src/shade/renderer/restir/di/shader_restir_di_denoise.js +136 -137
  349. package/src/shade/renderer/restir/di/shader_restir_di_resample.d.ts.map +1 -1
  350. package/src/shade/renderer/restir/di/shader_restir_di_resample.js +129 -130
  351. package/src/shade/renderer/restir/di/shader_restir_di_resolve.d.ts.map +1 -1
  352. package/src/shade/renderer/restir/di/shader_restir_di_resolve.js +99 -100
  353. package/src/shade/renderer/restir/di/shader_restir_di_spatial.d.ts.map +1 -1
  354. package/src/shade/renderer/restir/di/shader_restir_di_spatial.js +116 -117
  355. package/src/shade/renderer/scene/GPUInstancesAccelerationStructure.js +383 -383
  356. package/src/shade/renderer/scene/hierarchy/graph_scene_update_transform_hierarchy_naive.js +123 -123
  357. package/src/shade/renderer/scene/hierarchy/shader_nodes_record_input_positions.d.ts.map +1 -1
  358. package/src/shade/renderer/scene/hierarchy/shader_nodes_record_input_positions.js +64 -65
  359. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy.d.ts.map +1 -1
  360. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy.js +372 -373
  361. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy_naive.d.ts.map +1 -1
  362. package/src/shade/renderer/scene/hierarchy/shader_nodes_update_transform_hierarchy_naive.js +111 -112
  363. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.d.ts.map +1 -1
  364. package/src/shade/renderer/scene/shader_bvh_extract_instance_leaves.js +0 -1
  365. package/src/shade/renderer/shader/ComputeShader.d.ts +24 -3
  366. package/src/shade/renderer/shader/ComputeShader.d.ts.map +1 -1
  367. package/src/shade/renderer/shader/ComputeShader.js +350 -296
  368. package/src/shade/renderer/shader/ImageShader.js +217 -217
  369. package/src/shade/renderer/shader/ShaderDescriptor.d.ts +5 -0
  370. package/src/shade/renderer/shader/ShaderDescriptor.d.ts.map +1 -1
  371. package/src/shade/renderer/shader/ShaderDescriptor.js +20 -0
  372. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.d.ts +47 -0
  373. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.d.ts.map +1 -0
  374. package/src/shade/renderer/shader/util/compute_infer_workgroup_size_from_source.js +448 -0
  375. package/src/shade/renderer/shadow/map/csm/graph_csm_setup_cascades.js +3 -3
  376. package/src/shade/renderer/shadow/ray/staged/gen/graph_gen_rays.js +3 -3
  377. package/src/shade/renderer/shadow/ray/staged/trace/graph_trace_rays.js +2 -2
  378. package/src/shade/renderer/shadow/sdf/cascade/CascadedSceneSDF.js +4 -4
  379. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.d.ts.map +1 -1
  380. package/src/shade/renderer/texture/virtual/build_shader_vt_feedback.js +231 -232
  381. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +1 -1
  382. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  383. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +12 -15
  384. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts +3 -2
  385. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  386. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +3 -2
  387. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.d.ts +0 -2
  388. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.d.ts.map +1 -1
  389. package/src/shade/renderer/volumetrics/taa/shader_volumetrics_taa.js +17 -6
  390. package/src/shade/renderer/water/shader_water.js +246 -246
  391. package/src/shade/vitest.setup.mjs +1 -1
  392. package/src/shade/wgsl/emulator/ComputeShaderEmulator.js +291 -291
  393. package/src/view/minimap/Minimap.d.ts +2 -2
  394. package/src/view/minimap/Minimap.d.ts.map +1 -1
  395. package/src/view/minimap/Minimap.js +225 -225
  396. package/src/view/minimap/dom/MinimapCameraView.d.ts +3 -3
  397. package/src/view/minimap/dom/MinimapCameraView.d.ts.map +1 -1
  398. package/src/view/minimap/dom/MinimapCameraView.js +206 -206
  399. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.d.ts +0 -3
  400. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.d.ts.map +0 -1
  401. package/src/shade/renderer/shadow/map/csm/shader/shader_setup_cascades.js +0 -20
@@ -1,918 +1,918 @@
1
- # Render extensions — design
2
-
3
- Replaces `Renderer.add_pass` / `remove_pass`, `FramePhase`, `FramePassContext` and
4
- `FramePassRegistry` (MEEP 3, phase 2, item S3).
5
-
6
- Status: **design, not built.** Nothing in §3 through §11 is committed code.
7
-
8
- **Revised 2026-08-24** against the frame-graph work that has landed since the first draft:
9
- `FrameGraphState`, `validate()`, recording scopes, record-time producers, and the `exportToJson`
10
- fix. Those came out of this document's §4 and §13 and are tracked in
11
- `engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md`. §1.2, §3.4, §4, §5 and §13
12
- describe what the graph does now, not what it did — the net effect is that the design leans on the
13
- graph in three more places and asks it for nothing.
14
-
15
- The same revision **withdrew the cull diagnostic** and the engine change it needed, on sponsor
16
- review: culling is what a frame graph is for, and a check that calls it a failure when an extension
17
- is the recorder invents a distinction that does not exist and outlaws the speculative producer.
18
- §5.1 is the argument; the bar it sets — a check may only fire on something that cannot be true —
19
- also took out a provenance check in §3.4.
20
-
21
- ---
22
-
23
- ## 0. Summary
24
-
25
- An extension is a **named object with declarations** — a phase, ordering constraints, the optional
26
- frame products it needs — and one method that records into the frame's graph. Everything it exchanges
27
- with the frame goes through **typed per-frame records** holding frame-graph handles: it reads a
28
- handle out of a record and writes a replacement back. There is no return value and no privileged
29
- resource.
30
-
31
- Three properties follow, and they are the point:
32
-
33
- 1. **Every resource the frame carries is exchangeable**, not just the colour. The G-buffer stops
34
- being a documented special case, depth stops being read-only, and an extension can publish a
35
- record of its own that another extension reads.
36
- 2. **Order is declared**, not emergent from the order in which ECS systems happened to reach their
37
- first `await`.
38
- 3. **The frame can be described without being rendered, and read back attributed once it has been.**
39
- The set of extensions, their order per phase, and the products they force on are data, so
40
- `describe()` prints the pipeline; and the registry records each extension inside its own graph
41
- scope, so a DOT export of the frame groups by who asked for what instead of by whatever the
42
- `graph_*` helpers named things. That pair is what "the frame graph is a first-class construct"
43
- has to mean at this seam.
44
-
45
- Underneath all three, one correction that is not an abstraction: **the upscale is a phase boundary
46
- and the surface says so.** Three phases are before it and two are after, the before case is the
47
- common one, and the size of what an extension is drawing into is derived from the live handle rather
48
- than handed over as a field the renderer fills in once for the whole frame — which is what the
49
- current mechanism does, and gets wrong at two of its five phases (§1.3b).
50
-
51
- The shape is Unity 6 URP's — `ContextContainer` / `ContextItem`, `ScriptableRenderPass`,
52
- `ConfigureInput` — with URP's two worst traits removed (§2).
53
-
54
- ---
55
-
56
- ## 1. What is wrong with `add_pass`
57
-
58
- ### 1.1 One resource is modifiable
59
-
60
- `FramePassRegistry.run` threads exactly one value:
61
-
62
- ```js
63
- const produced = passes[i](context);
64
- if (produced === undefined) { continue; }
65
- context.color = produced;
66
- ```
67
-
68
- A pass's only sanctioned output is a colour handle. Everything else on `FramePassContext` — depth,
69
- previous depth, the four G-buffer targets, the two visibility targets — is an input. The one
70
- exception, the G-buffer being in/out at `AfterGBuffer`, is implemented by letting the pass reach into
71
- `context.gbuffer` and assign, and costs three paragraphs of prose in `FramePassContext` to explain
72
- why replacing beats writing in place.
73
-
74
- What that closes off, concretely: writing depth (terrain and water both rasterize geometry that
75
- should own depth), publishing a resource an extension computed for other extensions to read (fog of
76
- war's visibility grid is exactly this and currently lives outside the graph), and reading anything
77
- the frame builds but the struct does not name — velocity, the Hi-Z pyramid, the TAA history, the
78
- shadow atlas, the light cluster tables.
79
-
80
- The struct is closed by construction: adding a field is an edit to Shade. That is the wrong cost for
81
- "my game needs one more thing from the frame".
82
-
83
- ### 1.2 The graph is an escape hatch, not the mechanism
84
-
85
- `context.graph` hands the pass the `FrameGraph` and steps back. The registry never learns what the
86
- pass read or wrote, so:
87
-
88
- - **It cannot order passes.** It orders by registration order.
89
- - **It cannot attribute anything.** `exportToDot` shows the nodes an extension created with whatever
90
- names its helpers gave them, unrelated to the extension. The graph has since gained recording
91
- scopes, which are exactly the missing mechanism — but they are opt-in and `add_pass` opens none,
92
- so a frame's DOT export is still one flat wall of passes.
93
- - **It cannot validate.** Reading `context.color` at `AfterGBuffer` yields `-1` and fails somewhere
94
- inside a binding builder. Retaining a handle across frames is undetectable. Publishing a stale
95
- handle is now caught, but by the graph at `compile()` and against the *reader* — the pass that
96
- consumed it — so the report names the built-in pass that broke, never the extension that handed
97
- it the bad handle.
98
- - **`AfterGBuffer` has no return channel, so the culling contract cannot be met there.** The rule
99
- itself is the graph's, and it is right everywhere: work nothing consumes is elided. But the only
100
- way this mechanism lets a pass thread its result into the frame is by returning a colour handle,
101
- and at `AfterGBuffer` there is no colour — so a pass whose G-buffer work *should* be consumed
102
- reaches for `make_side_effect()` instead, which does not say "my output is used", it says "run me
103
- anyway". Three paragraphs of `FramePhase` document the workaround. The record model removes the
104
- reason for it (§3.3) and leaves the culling rule alone (§5.1).
105
-
106
- And the injection points themselves are call sites: a phase is wherever `#run_frame_passes` was
107
- typed inside an 800-line method. There are five because five were typed.
108
-
109
- ### 1.3 What this has already produced
110
-
111
- Four findings from reading the current code, each independent of the design that replaces it.
112
-
113
- **(a) Order is an accident, and the code says so.** `TerrainSystem3.startup`:
114
-
115
- > `// before anything is awaited, so that pass order within the phase is the profile's system order —`
116
- > `// the decal composite has to see the textured ground, not precede it`
117
-
118
- An ordering requirement between two extensions is being met by placing a registration call above an
119
- `await`. Adding an `await` to a system's startup silently reorders the frame.
120
-
121
- The same mechanism decides the six-deep chain at `AfterTransparency`, and **it has already produced a
122
- shipped bug**. In profile order that chain is fog of war, particles, path display, highlight, debug
123
- draw, trails — so the fog composites *first* and everything drawn after it is unfogged. Confirmed
124
- with the sponsor: that is not intended. Fog of war conceals, so it belongs over everything it
125
- conceals; particles drawing on top of it defeat what it is for.
126
-
127
- Nobody chose that order. Nothing recorded it. Nothing failed when it turned out wrong. That is the
128
- argument for §3.6 in one paragraph.
129
-
130
- **(b) The one size the context states is wrong at two phases.** `Renderer` fills
131
- `pass_context.resolution` from `resolution_internal.asArray()`, once, for all five phases. But TAA
132
- and NSS upscale to `output_resolution` before `BeforePresent`, and the tonemap writes the canvas at
133
- output resolution — so at `BeforePresent` and `Overlay` the colour is *not* the size the context
134
- reports, whenever TAA is on. With TAA off it is, because the upscale is missing (there is a `TODO`
135
- in `Renderer` saying so). The size of the frame's colour at `BeforePresent` is therefore not
136
- derivable from the context at all; it depends on a renderer feature flag the extension cannot see.
137
-
138
- `probeShadePassInjection` — the gate for the current mechanism — does
139
- `output_resolution: context.resolution` at `BeforePresent`. It passes because the probe runs at 100%
140
- scale. The game ships with dynamic resolution scaling enabled against a 30 fps floor, and at any
141
- scale below 100% that pass produces a cropped, wrongly-scaled frame while the gate still measures a
142
- luminance change and reports green.
143
-
144
- The single field is the tell: the surface was built as though a frame had one resolution. Three of
145
- the five phases are before the upscale and two are after it, and nothing in the surface says which
146
- is which — `FramePhase`'s own text for `BeforePresent` says "still HDR" and never mentions that the
147
- colour has been upscaled by then. §3.5 makes the boundary part of what a phase *is*.
148
-
149
- **(c) `-1` is a sentinel with no guard.** `color` is `-1` at `AfterGBuffer`. Nothing stops a pass
150
- reading it. The house rule is that the common case is the only case and preconditions are asserted,
151
- not encoded as magic values.
152
-
153
- **(d) The G-buffer special case exists only because colour owns the return channel.** Given a
154
- uniform way to publish any handle, "the G-buffer is in/out at one phase" stops being a rule and
155
- becomes an instance of the general one.
156
-
157
- ---
158
-
159
- ## 2. What is borrowed from URP, and what is not
160
-
161
- Borrowed:
162
-
163
- | URP 6 | Here | Why |
164
- | --- | --- | --- |
165
- | `ContextContainer` + `ContextItem` | `FrameContext.get(RecordType)` + frame records | The open, typed resource vocabulary. This is the fix for §1.1, and the reason it is keyed by type rather than by string is that the house rule forbids string-keyed registries. |
166
- | `UniversalResourceData` | `SceneColor`, `GBufferTextures`, `ViewTextures`, `PresentTarget` | Built-in resources are ordinary classes with named fields, carried in the same container as anyone else's. One mechanism, not two. |
167
- | `ScriptableRenderPass` | `RenderExtension` | A declaring object, not a closure. Declarations are what makes ordering, validation and description possible. |
168
- | `ConfigureInput(ScriptableRenderPassInput.Motion)` | `RenderExtension.requires` + `FrameProduct` | An extension states what the frame must produce and the renderer turns it on. This is the answer to "velocity exists, is built, and is unreachable". |
169
- | Renderer feature list, ordered | The registry's resolved order, printable | The pipeline is inspectable before it runs. |
170
-
171
- Not borrowed, deliberately:
172
-
173
- - **`RenderPassEvent` + integer offsets.** URP orders same-event passes by an `int` you add to the
174
- enum. It is the same defect as registration order with an extra step: unreadable, unstable under
175
- insertion, and it makes every ordering question a numerology question. Replaced by explicit
176
- `after` / `before` constraints over extension classes (§3.6).
177
- - **`SetGlobalTexture` / global shader state.** An implicit channel between passes that the graph
178
- cannot see. Records are the channel.
179
- - **`AddUnsafePass`.** URP needs a legacy `CommandBuffer` escape hatch. There is no legacy to carry
180
- here, and adding an unchecked door on day one guarantees it becomes the door.
181
- - **Two-level feature/pass split.** URP splits `ScriptableRendererFeature` from
182
- `ScriptableRenderPass` because a feature is a `ScriptableObject` living on an asset. That reason
183
- does not exist here, so there is one class.
184
-
185
- ---
186
-
187
- ## 3. The model
188
-
189
- ### 3.1 `RenderExtension`
190
-
191
- ```js
192
- export class RenderExtension {
193
- /**
194
- * Stable identity. Used in ordering diagnostics, error decoration and `describe()`.
195
- * @type {string}
196
- */
197
- name = "";
198
-
199
- /** @type {FramePhase} */
200
- phase = FramePhase.AfterTransparency;
201
-
202
- /**
203
- * Extension classes this one records after / before. Constructors, not instances — an
204
- * extension does not need a reference to another system's object to state an order.
205
- * @type {Function[]}
206
- */
207
- after = [];
208
- before = [];
209
-
210
- /**
211
- * Optional frame products this extension reads. A bitmask of {@link FrameProduct}.
212
- * Reading a product nobody required is an assert failure, not a silent `-1`.
213
- * @type {number}
214
- */
215
- requires = 0;
216
-
217
- /**
218
- * Record this extension's work into the frame. Called once per frame, when the phase comes
219
- * round. Returns nothing: everything this produces reaches the rest of the frame by
220
- * publication (§3.4).
221
- *
222
- * @param {FrameContext} frame
223
- */
224
- record(frame) {}
225
- }
226
- ```
227
-
228
- `record` returning `void` is load-bearing. It is what makes colour stop being privileged.
229
-
230
- An extension that has nothing to draw this frame returns without recording; that is the only "off"
231
- switch and there is no `enabled` flag beside it.
232
-
233
- ### 3.2 `FrameContext`
234
-
235
- ```js
236
- export class FrameContext {
237
- /** @type {FrameGraph} the frame's graph — record into this with the `graph_*` helpers */
238
- graph;
239
- /** @type {ViewRenderContext} */
240
- view;
241
- /** @type {FramePhase} */
242
- phase;
243
-
244
- /** @template T @param {new()=>T} type @returns {T} throws if not present at this phase */
245
- get(type) {}
246
-
247
- /** @param {Function} type @returns {boolean} */
248
- has(type) {}
249
-
250
- /** @template T @param {new()=>T} type @returns {T} adds a record this extension owns */
251
- create(type) {}
252
-
253
- /**
254
- * Pixel size of the target this phase carries, derived from the live handle rather than filled
255
- * in by the renderer.
256
- * @returns {number[]}
257
- */
258
- get resolution() {}
259
-
260
- /** @param {number} handle @returns {ResourceDescriptor} size, format and usage of a handle */
261
- describe(handle) {}
262
- }
263
- ```
264
-
265
- Three accessors rather than one `get_or_create`, because "absent" has to stay distinguishable from
266
- "empty" — that is what lets `get(SceneColor)` at `AfterGBuffer` throw with a sentence instead of
267
- handing back `-1`.
268
-
269
- **`resolution` is derived, and that is the whole of the fix for defect (b).** It reads the graph
270
- through the handle this phase's target actually is, so it is right before the upscale, right after
271
- it, right when TAA is off and the upscale does not happen at all, and right at every dynamic
272
- resolution scale. The common case — "how big is the thing I am drawing into" — stays one property
273
- access and cannot be wrong.
274
-
275
- `describe(handle)` is the general form, and it is needed whenever two things in one pass are
276
- different sizes: an extension at `BeforePresent` reading the G-buffer is working across the upscale
277
- boundary and has to ask for both.
278
-
279
- > Both lean on descriptors being trustworthy, which was checked: transient and imported resources
280
- > both carry a real `TextureResourceDescriptor` holding absolute pixels.
281
- > `TextureResourceDescriptor.resolution_domain` says otherwise by default and is vestigial — nothing
282
- > branches on it, only `hash()` folds it in. It stays; it is now documented as vestigial on the field
283
- > itself so the next reader does not take it for a contract.
284
-
285
- ### 3.3 Frame records
286
-
287
- A record is a plain class holding frame-graph handles. The renderer publishes the built-in ones as
288
- the frame reaches the phase where they are meaningful.
289
-
290
- ```js
291
- /** present from AfterGBuffer */
292
- class GBufferTextures { albedo; normal; pbr; emissive; }
293
-
294
- /** present from AfterGBuffer */
295
- class ViewTextures {
296
- depth; depth_previous;
297
- visibility_mesh; visibility_triangle;
298
- velocity; // requires FrameProduct.Velocity
299
- }
300
-
301
- /** present from AfterLighting through BeforePresent */
302
- class SceneColor {
303
- color;
304
- copy; // requires FrameProduct.ColorCopy
305
- }
306
-
307
- /** present at Overlay, and only there */
308
- class PresentTarget { canvas; }
309
- ```
310
-
311
- Presence by phase:
312
-
313
- | phase | records | resolution |
314
- | --- | --- | --- |
315
- | `FrameStart` | — (the view only) | internal |
316
- | `AfterGBuffer` | `GBufferTextures` (writable), `ViewTextures` | internal |
317
- | `AfterLighting` | `GBufferTextures` (frozen), `ViewTextures`, `SceneColor` | internal |
318
- | `AfterTransparency` | as above | internal |
319
- | `BeforePresent` | as above — but `SceneColor` and `GBufferTextures` are now different sizes | output |
320
- | `Overlay` | `PresentTarget` | output |
321
-
322
- Two of those rows are decisions worth naming.
323
-
324
- **`SceneColor` is absent at `Overlay`, and `PresentTarget` is not `SceneColor`.** The current
325
- mechanism hands the tonemapped colour to `Overlay` passes and then documents, at length, that they
326
- must not read it — a texture cannot be a render attachment and a sampled source in the same pass.
327
- That was found by measurement after the fact. Handing back a differently-named record with one field
328
- says the same thing structurally: there is no `color` at `Overlay` to be tempted by, and an
329
- extension that wants the finished image as *input* discovers at `get` time that it belongs at
330
- `BeforePresent`.
331
-
332
- **`GBufferTextures` is present but frozen after `AfterGBuffer`.** Outline and highlight effects
333
- legitimately read the G-buffer and the visibility buffer late. Publishing into it late is
334
- meaningless — shading has run — so the record is frozen and the assert says why (§5). At
335
- `BeforePresent` it is also on the far side of the upscale from the colour (§3.5), so the two are
336
- different sizes; `describe()` is how a pass learns the ratio, and the record's own doc comment says
337
- to expect it.
338
-
339
- Extensions add their own records with `create`:
340
-
341
- ```js
342
- export class FogOfWarVisibility { grid; } // a graph buffer handle
343
-
344
- // producer, at FrameStart
345
- frame.create(FogOfWarVisibility).grid = this.#update_grid(frame.graph);
346
-
347
- // consumer, at AfterTransparency
348
- if (frame.has(FogOfWarVisibility)) { ... }
349
- ```
350
-
351
- Which is the second half of the fix for §1.1: the vocabulary is open, and extending it costs no
352
- edit to Shade.
353
-
354
- ### 3.4 Publication
355
-
356
- A field on a record is an accessor. Writing it is publication:
357
-
358
- ```js
359
- const scene = frame.get(SceneColor);
360
-
361
- const [fogged] = graph_image_pass({
362
- graph: frame.graph,
363
- shader: this.#shader,
364
- output_resolution: frame.describe(scene.color).resolution,
365
- inputs: { input_color: scene.color, input_depth: frame.get(ViewTextures).depth }
366
- });
367
-
368
- scene.color = fogged; // the rest of the frame carries this
369
- ```
370
-
371
- The setter asserts three things, all free in production:
372
-
373
- 1. The record is not frozen (§3.3), and the frame index matches — a handle captured last frame
374
- cannot be published this frame.
375
- 2. `graph.is_valid_resource(handle)` — the handle is the current version of its resource.
376
- 3. The current phase is one where the record is writable.
377
-
378
- Check 2 exists for **attribution**, not detection. The graph catches a superseded handle on its
379
- own: whoever reads it next trips `validate()`'s superseded-read check at `compile()` (§4). But that
380
- report names the *reader* — some built-in pass downstream — and says nothing about which extension
381
- put the bad handle into the record. Checking at the setter fails at the line that caused it, with
382
- the extension's name on it. It forbids nothing: one `ResourceEntry` owns one concrete resource, so
383
- an older node is not a snapshot of older contents and publishing one is wrong in every case.
384
-
385
- **A provenance check was considered here and rejected** — "the handle's producer is a pass recorded
386
- inside this extension's scope", which the record-time producer (§4) made possible. It would have
387
- forbidden a legitimate thing: `import_resource` leaves a node with no producer, so an extension that
388
- imports a texture it owns and publishes it as the frame's colour — a debug view, video playback —
389
- would have failed a check aimed at typos. What it uniquely caught, publishing a handle you read
390
- rather than wrote, surfaces immediately downstream anyway.
391
-
392
- Reading a field asserts frame identity and, for product-gated fields, that the *recording extension*
393
- declared the product (§3.7).
394
-
395
- **Publication is not the dependency edge.** The graph already has the edge: `FramePassBuilder.write`
396
- renames a written resource into a versioned clone and records the read, so "this pass consumed the
397
- colour as it stood and produced a new one" is already a graph fact. Publication is the *naming*
398
- step — which handle currently means "the scene colour". Keeping those two ideas separate is what
399
- lets the record model be this small.
400
-
401
- ### 3.5 Phases
402
-
403
- `FrameStart`, `AfterGBuffer`, `AfterLighting`, `AfterTransparency`, `BeforePresent`, `Overlay`.
404
-
405
- **The upscale is a phase boundary, and saying so is part of what a phase means.** `AfterGBuffer`,
406
- `AfterLighting` and `AfterTransparency` are before it and run at internal resolution; TAA and NSS
407
- upscale to output resolution, so `BeforePresent` and `Overlay` are after it and run at output
408
- resolution.
409
-
410
- **Before the upscale is the common case, and the design leans on it.** An extension drawing into the
411
- scene belongs at `AfterTransparency`: its work is then temporally resolved, upscaled and sharpened
412
- along with everything else, and it is the cheapest place to draw because it draws at internal
413
- resolution. `BeforePresent` is the deliberate other choice — full output resolution, still HDR,
414
- nothing after it but the tonemap — and it is a trade, not an upgrade: work there gets no temporal
415
- history and costs full-resolution pixels. An extension that does not have a reason to be after the
416
- upscale should not be.
417
-
418
- One wrinkle, and it belongs to the renderer rather than to this design: with
419
- `feature_taa_enabled === false` the upscale does not happen at all — there is a `TODO` in `Renderer`
420
- saying exactly that — so in that configuration `BeforePresent` is at internal resolution. A derived
421
- `frame.resolution` (§3.2) is right in both configurations. A documented constant would be wrong in
422
- one, which is how the current mechanism ended up wrong in two.
423
-
424
- Five phases are the existing ones, unchanged in meaning. **`FrameStart` is the one addition**: a point after
425
- the view is updated and before anything is rasterized. It exists because per-frame GPU compute that
426
- later phases depend on currently has nowhere to go — fog of war's grid update, a custom cull, a
427
- procedural buffer build — so it is either bolted onto a draw pass at a later phase, which puts it in
428
- the wrong place in the graph and mis-attributes it in a capture, or done outside the graph entirely,
429
- which is precisely the aliasing-and-lifetime blindness §1.2 is about.
430
-
431
- Considered and rejected: `BeforeGBuffer` (nothing distinguishes it from `FrameStart` for anything an
432
- extension can do), `AfterShadows` (shadow rendering is per-light and interior to
433
- `GPULightCollection`; a phase there would be a lie about what is stable), per-post-effect phases
434
- (URP has seventeen events and their number is a symptom, not a feature).
435
-
436
- ### 3.6 Ordering
437
-
438
- Within a phase, order is the topological order of the `after` / `before` edges. Cycles throw at
439
- registration, naming the cycle. Both directions exist because an extension that must precede one it
440
- cannot edit is exactly the case an extension mechanism is for.
441
-
442
- Unconstrained pairs are broken by `name`, ascending. Stable and reproducible; deliberately *not*
443
- registration order, so that adding an `await` to a system's startup cannot change the picture.
444
-
445
- Name-order is stable, not correct. So the resolver also reports **ambiguity**: two extensions on one
446
- phase that publish the same field of the same record, with no ordering path between them, is an
447
- assert failure naming both and the field. Publication goes through our setters, so the frame knows
448
- who published what — no extra syntax on the author's side. This is the check that would have made
449
- somebody write down what the six-deep `AfterTransparency` chain is supposed to be.
450
-
451
- The ambiguity check is per-frame and assert-only. It costs nothing in production and fires on the
452
- first frame in development.
453
-
454
- ### 3.7 Products
455
-
456
- `FrameProduct` is a bitmask of things the frame *can* build but does not always:
457
-
458
- ```js
459
- export const FrameProduct = {
460
- /** per-object + camera velocity, built even when TAA and motion blur are off */
461
- Velocity: 1,
462
- /** a readable snapshot of the scene colour as it stood when the phase began */
463
- ColorCopy: 2,
464
- };
465
- ```
466
-
467
- The union over registered extensions is computed once per registration change, and the renderer
468
- consults it the way it consults its own feature flags. `ColorCopy` is produced immediately before
469
- the passes of each phase that requires it.
470
-
471
- Two members, because two have a nameable consumer: `Velocity` for any effect an extension wants to
472
- temporally reproject itself (currently built for TAA, unreachable by anyone else), and `ColorCopy`
473
- for refraction — a pass that rasterizes geometry *into* the live colour with a `load` op and must
474
- also sample what is behind it, which is refractive water and distortion particles. A full-screen
475
- pass that reads the colour and writes a new one does not need it and must not ask for it.
476
-
477
- Considered and deferred with reasons in §9: `HiZ`, `PreviousColor`, `SceneDepthPyramid`.
478
-
479
- This is the part of the design with the weakest present-day demand and it ships last (§10).
480
-
481
- ---
482
-
483
- ## 4. What the frame graph does, and what it does not
484
-
485
- Stated plainly, because the design would be dishonest without it.
486
-
487
- `FrameGraph.execute` iterates `__pass_nodes` **in insertion order**. It does not topologically sort,
488
- and there is no plan for it to. What the graph gives is culling (`ref_count` propagation from
489
- unreferenced resources), transient resource lifetime and aliasing, versioned renaming of written
490
- resources, and export.
491
-
492
- So: **the graph does not order extensions; §3.6 does.** Every claim in this document about ordering
493
- is a claim about the resolver, not about the graph.
494
-
495
- **What the graph does instead of sorting is check.** The first draft of this document listed two
496
- implementation constraints the design had to route around. Both are now fixed, and a third thing
497
- landed that the design had assumed away, so the division of labour is different from what §3.6 was
498
- written against. The four that matter here — the full list and its evidence are in
499
- `engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md`:
500
-
501
- - **`validate()` proves the insertion order is a valid topological order**, and `compile()` asserts
502
- on it. It reports a *read before write* (a pass reading a node a later pass produces) and a
503
- *superseded read* (a pass reading a version an earlier pass has already replaced — one
504
- `ResourceEntry` owns one concrete resource, so an older node is not a snapshot). This is the
505
- honest answer to insertion-order execution: not to sort, but to refuse a recording whose order is
506
- wrong. It is asserted, so it costs nothing shipped and fails the frame in development.
507
- - **The lifecycle is one-shot and stated.** `FrameGraphState`: `Recording` → `Compiled` →
508
- `Executed`, monotonic, asserted at every mutating entry point. `compile()` was not idempotent and
509
- `execute()` could not tell whether `compile()` had run — a graph executed uncompiled culls itself
510
- entirely and says nothing. The design's old "the extension layer must never compile the graph
511
- itself" caveat is deleted: the graph says so now.
512
- - **`ResourceNode.producer` is assigned at record time**, by `FramePassBuilder`, rather than in
513
- `compile()`. So "this handle was produced by the pass that is publishing it" is answerable at
514
- publication (§3.4).
515
- - **Recording scopes exist.** `push_scope(name)` / `pop_scope()` stamp every pass and resource node
516
- created inside them, nesting through a parent chain. This supersedes §13's request for a
517
- `pass_count` getter and is what §1.2's attribution complaint was asking for.
518
-
519
- One finding from that work belongs here rather than there, because it is the strongest available
520
- argument for the whole model: `validate()`, on its first run against the live 61-pass `Shading`
521
- graph, found that **Shade's own rasterization was not threading its colour targets.** Depth was
522
- threaded through the two-phase opaque pass and colour was not, so every rasterization pass read
523
- `out_color/0` at version 0 while depth advanced. It was harmless only because those passes write an
524
- imported resource and therefore always execute, and because insertion order happened to give the
525
- right picture — the colour dependency between them was simply not a graph fact. That is what
526
- "working around the graph rather than through it" costs, in the engine's own code, found the day
527
- someone checked.
528
-
529
- What remains true, and what §3.6 rests on: the graph checks that a recording order is *consistent*,
530
- never that it is the one anybody wanted. Two extensions each reading the colour and writing a new
531
- one are valid in either order. Data-order violations are the graph's; visual-order mistakes are
532
- §3.6's, and nothing else will catch them.
533
-
534
- ---
535
-
536
- ## 5. Validation and diagnostics
537
-
538
- All of it under `assert`, which is compiled out of production builds — so the price of every check
539
- here is zero in a shipped game and the checks may be as thorough as they like.
540
-
541
- | check | when | what it catches |
542
- | --- | --- | --- |
543
- | record present at phase | `get` | reading `SceneColor` at `AfterGBuffer` — replaces `-1` |
544
- | record frozen | publish | publishing a G-buffer handle after shading |
545
- | frame identity | read + publish | a handle retained across frames |
546
- | `is_valid_resource` | publish | publishing a superseded handle, attributed to the extension |
547
- | product declared by the recording extension | read | reading `velocity` because *someone else* required it |
548
- | ordering cycle | registration | `after`/`before` cycle, named |
549
- | publication ambiguity | end of phase | two extensions writing one field with no order between them |
550
-
551
- Beneath all of them the graph runs its own `validate()` inside `compile()` (§4), which catches the
552
- data-order failures this layer cannot see.
553
-
554
- Every one of these fires on a **statement that cannot be true** — a superseded handle names one
555
- physical resource holding newer contents, a cycle has no order, an undeclared collision has no
556
- answer. None of them fires on an extension that is behaving correctly. That is the bar; §5.1 is
557
- what happens when a check is written without holding to it.
558
-
559
- ### 5.1 Culling is not an error, and no diagnostic here treats it as one
560
-
561
- An earlier revision of this document asked the graph to report per-scope culling so that "an
562
- extension whose entire recorded work was culled" could be an assert. **That was wrong and it is
563
- removed**, along with the engine change it needed. Three reasons, in increasing order of weight.
564
-
565
- Culling is what a frame graph is *for*. Frostbite's paper is about eliminating work nobody
566
- consumes; that is why the pattern spread. A mechanism cannot adopt the construct and then treat its
567
- central behaviour as a failure.
568
-
569
- The distinction it drew does not exist. "Culled built-in pass: optimisation. Culled extension pass:
570
- bug." Nothing about the recorder changes what culling means, and encoding the split would have made
571
- extensions second-class in the one place the design claims they are not.
572
-
573
- And it forbids the pattern this design exists to enable. §3.3's own worked example is an extension
574
- publishing `FogOfWarVisibility` at `FrameStart` for a consumer at `AfterTransparency`. Take the
575
- consumer out — disable fog — and the producer's work is dead, the graph elides it, and the frame is
576
- correct and cheaper. Under the assert that is a hard error. A speculative producer whose consumer
577
- may or may not be present is the whole point of an open record vocabulary, and the check would have
578
- outlawed it on day one.
579
-
580
- **What replaces it: nothing, because nothing is missing.** `exportToJson` already reports
581
- `culled` per pass and `exportToDot` already greys culled passes out; with the registry's scopes
582
- (§5.2) a culled extension is a grey cluster with its own name on it. An author asking "why did my
583
- pass not run" dumps the graph and sees it. That is a tool, not a tribunal.
584
-
585
- The culling contract itself is stated once, on `RenderExtension.record`, in the same terms it holds
586
- for every other pass in the graph: work whose output nothing consumes is elided; thread your result
587
- into a record and it is consumed; if you want work that runs regardless, `make_side_effect()` says
588
- so, exactly as it does for a built-in pass.
589
-
590
- ### 5.2 Recording scopes
591
-
592
- **The registry opens a recording scope per extension**, named after it, around the `record()` call:
593
-
594
- ```js
595
- graph.push_scope(extension.name);
596
-
597
- try {
598
- extension.record(frame);
599
- } finally {
600
- graph.pop_scope();
601
- }
602
- ```
603
-
604
- The `finally` is not error handling — nothing is caught and nothing is swallowed. It is there
605
- because `compile()` asserts the scope stack came back empty, so an extension that throws while
606
- recording would otherwise fail on an unbalanced scope and bury the real error behind it. The throw
607
- propagates, decorated with the extension's name the way `FrameGraph` already decorates a failing
608
- pass with its own.
609
-
610
- **Scopes are for reading the frame back, not for policing it.** `exportToDot` puts each extension's
611
- passes in its own labelled cluster, nested as the scopes nest, and a graph that opens no scope
612
- exports byte-identically to before. That is the whole of §1.2's attribution complaint, answered by
613
- an API that already exists and that nothing currently calls — and it is what makes §5.1's answer to
614
- culling a real answer rather than a shrug: the grey box in the dump has the extension's name on it.
615
-
616
- No check depends on scopes. The one that did is gone (§5.1), and the provenance check that would
617
- have used `is_within` was rejected on its own merits (§3.4).
618
-
619
- `RenderExtensionRegistry.describe()` returns the resolved pipeline as text: per phase, the extensions
620
- in resolved order, each with the products it required and the records it publishes into. Printable
621
- without a device, which is what makes it testable without one.
622
-
623
- ---
624
-
625
- ## 6. Lifecycle
626
-
627
- Registration is device-free and survives device loss — the current mechanism's one clearly right
628
- decision, kept. `GraphicsEngine3` holds the extension list; a renderer replaced by a restart is
629
- re-populated from it. A system registers once at startup without knowing whether a device exists.
630
-
631
- ```js
632
- graphics.add_extension(extension); // returns the extension
633
- graphics.remove_extension(extension); // returns whether it was registered
634
- ```
635
-
636
- Identity is the object, so the phase no longer has to be passed to removal. Registering or removing
637
- during `record` is an assert failure.
638
-
639
- `GraphicsEngine3` gains no surface it did not have: this replaces `add_pass` / `remove_pass`
640
- one-for-one, so D14's "the facade does not grow" holds. `pass_count(phase)` becomes
641
- `extension_count(phase)`, which the start → stop → start leak gate needs.
642
-
643
- **Views.** There is one injection site today: the primary view inside `render_to_target`. Shadow
644
- views, probe bakes and picking do not run extensions and this design does not change that. When a
645
- second view kind gains injection points, the filter belongs on the registry and the extension gains
646
- a declaration for it; building that now would be inventing a contract for a caller that does not
647
- exist.
648
-
649
- ---
650
-
651
- ## 7. The whole API
652
-
653
- That is the entire surface.
654
-
655
- ```js
656
- // declaration
657
- class RenderExtension { name; phase; after; before; requires; record(frame); }
658
- const FramePhase = { FrameStart, AfterGBuffer, AfterLighting, AfterTransparency, BeforePresent, Overlay };
659
- const FrameProduct = { Velocity, ColorCopy };
660
-
661
- // recording
662
- class FrameContext { graph; view; phase; resolution; get(type); has(type); create(type); describe(handle); }
663
-
664
- // built-in records
665
- class GBufferTextures { albedo; normal; pbr; emissive; }
666
- class ViewTextures { depth; depth_previous; visibility_mesh; visibility_triangle; velocity; }
667
- class SceneColor { color; copy; }
668
- class PresentTarget { canvas; }
669
-
670
- // registration
671
- class RenderExtensionRegistry { add(e); remove(e); count(phase); describe(); run(frame); }
672
- // on GraphicsEngine3 / Renderer
673
- add_extension(e); remove_extension(e); extension_count(phase);
674
- ```
675
-
676
- ---
677
-
678
- ## 8. Migration
679
-
680
- Nine live systems, one probe, one prototype viewer. Every one of them is the same mechanical change:
681
- a closure becomes a class, `context.color` becomes `frame.get(SceneColor).color`, and the return
682
- value becomes a publication.
683
-
684
- | consumer | phase | change beyond the mechanical |
685
- | --- | --- | --- |
686
- | `TerrainSystem3` | `AfterGBuffer` | drops the registration-before-`await` comment; nothing else |
687
- | `DecalSystem3` | `AfterGBuffer` | `after: [TerrainExtension]`, which is the constraint the comment in `TerrainSystem3` was hand-enforcing; stops assigning through `context.gbuffer` |
688
- | `WaterSystem3` | `AfterLighting` | publication instead of return; a candidate first consumer of `FrameProduct.ColorCopy` if refraction is wanted |
689
- | `FogOfWarSystem3` | `AfterTransparency` | the grid update becomes a `FrameStart` extension publishing `FogOfWarVisibility`, and the composite reads it; declares `after` the four content extensions — **this is where §1.3a's shipped bug is fixed** |
690
- | `ParticleEmitterSystem3` | `AfterTransparency` | — (now before the fog, by declaration) |
691
- | `PathDisplaySystem3` | `AfterTransparency` | — |
692
- | `HighlightSystem3` | `AfterTransparency` | reads `ViewTextures.visibility_*`, unchanged in meaning |
693
- | `DebugDrawSystem3` | `AfterTransparency` | `after: [FogOfWarExtension]` |
694
- | `Trail3DSystem3` | `AfterTransparency` | — |
695
- | `probeShadePassInjection` | all | the gate is rewritten and gains the two checks it is currently blind to: a G-buffer replacement, and an order flip |
696
- | `prototypeVGEOFormatViewer` | `AfterTransparency` | prototype; mechanical |
697
-
698
- **The `AfterTransparency` chain is settled, not open.** Fog of war is last among the content
699
- extensions, because it conceals and therefore belongs over everything it is meant to conceal —
700
- particles, trails, path display and the selection highlight all draw before it and are all subject
701
- to it. Debug draw is the single deliberate exception, `after: [FogOfWarExtension]`: developer
702
- geometry that the fog can hide is a debugging tool that stops working exactly where debugging is
703
- needed.
704
-
705
- That is six extensions and two declarations. Written down once, in the two files that care, instead
706
- of being a property of where an `await` sits.
707
-
708
- **The bug does not have to wait for the migration.** Today's ordering is registration order, so
709
- moving `FogOfWarSystem3` below `Trail3DSystem3` in `graphics_profile.js` fixes the picture now — at
710
- the cost of it being another undeclared ordering fact, which is what this design exists to end.
711
-
712
- ---
713
-
714
- ## 9. Non-goals and known limits
715
-
716
- **Shade's own frame is not rewritten into a list of extensions.** URP's real strength is that
717
- built-in and injected passes are the same type. Getting there means restructuring
718
- `Renderer.render_to_target`, and renderer internals are hands-off. The consequence, stated so it is
719
- not discovered later: injection points remain hand-placed call sites, so the phase set is only as
720
- good as the places the renderer publishes records, and an extension cannot insert itself between two
721
- built-in post passes.
722
-
723
- **Extensions cannot participate in OIT.** Work at `AfterTransparency` composites over the resolved
724
- transparency; it cannot interleave with sorted transparents. True today, unchanged, and the honest
725
- reason the phase model has a ceiling.
726
-
727
- **Extensions cannot add to a built-in pass** — no extra material variants in the G-buffer raster, no
728
- extra lights. A new pass is the only unit.
729
-
730
- **No `FrameProduct` for `HiZ` / `PreviousColor` / depth pyramid yet.** Each is built by the renderer
731
- and each would be a one-line addition, and none has a consumer today. Adding them speculatively puts
732
- three untested branches in the renderer's product wiring.
733
-
734
- **No view kinds.** §6.
735
-
736
- **Ordering ambiguity is caught at frame time, not registration time**, because publication is
737
- observed rather than declared. Declaring publications up front would catch it earlier at the cost of
738
- a second declaration that can disagree with the code; observation cannot disagree with the code.
739
-
740
- ---
741
-
742
- ## 10. Delivery
743
-
744
- Three steps, each independently green.
745
-
746
- - **A — the model.** Records, `FrameContext`, publication with all four checks, registry with
747
- ordering, cycle detection and scoped recording. Port the nine systems and the viewer. Delete
748
- `FramePassContext`, `FramePassRegistry` and `add_pass` / `remove_pass`. `FrameStart` and
749
- `PresentTarget` land here, because they are what the fog-of-war split and the `Overlay` rule need.
750
- - **B — diagnostics.** Ambiguity check and `describe()`. Nothing here waits on the engine; scoped
751
- recording lands in A, because it needs nothing that is not already there.
752
- - **C — products.** `FrameProduct.Velocity` and `ColorCopy`, and the renderer wiring that honours
753
- them.
754
-
755
- A is the whole of the user-visible design. B is what makes it hold. C is the part with the weakest
756
- demand and it is separable on purpose.
757
-
758
- ---
759
-
760
- ## 11. Tests and gates
761
-
762
- Device-free (`vitest`, from the moh root), black-box, real objects:
763
-
764
- - resolved order for a set of extensions with `after` / `before`; a cycle throws and names both;
765
- unconstrained pairs resolve by name and the result does not change when registration order does.
766
- - `get` of an absent record throws; `create` twice throws; `has` agrees with both.
767
- - publication of a superseded handle fails; publication into a frozen record fails; a handle from a
768
- previous frame fails. `FrameGraph` and a `SoftwareGPUDevice` are enough for all of these — no real
769
- device, per the standing rule.
770
- - **an extension whose output nothing consumes is culled, and that is not an error** — the frame
771
- renders, no assert fires, and the pass reports `culled` in the JSON export. Pinned as a test
772
- because it is a property somebody will otherwise "fix" (§5.1).
773
- - an extension's passes land inside its own DOT cluster, and a graph recorded with no extensions
774
- exports exactly as it did before. The scope machinery is already specced on the graph; what this
775
- adds is that the registry opens and closes one per extension, including when `record()` throws.
776
- - `describe()` output is stable and contains every registered extension.
777
- - registry survives a simulated renderer replacement with the same extensions in the same order.
778
-
779
- On a device, extending `probeShadePassInjection`:
780
-
781
- - a colour-writing extension at each colour-carrying phase, in and out, restoring to within the
782
- existing 25% bar.
783
- - **a G-buffer-replacing extension at `AfterGBuffer`** — writes a flat albedo, and the lit frame
784
- changes. The current gate has no such check, which is why the in/out G-buffer has never been
785
- exercised outside the decal system.
786
- - **an order flip** — two extensions publishing the colour, with the `after` edge reversed between
787
- runs, producing different frames. This is the property the current mechanism cannot state at all.
788
- - **the upscale boundary, at 60% dynamic resolution scale**: an extension at `AfterTransparency` and
789
- one at `BeforePresent`, each sizing its output from `frame.resolution`, both producing a full,
790
- uncropped frame. They must resolve to *different* sizes. The `BeforePresent` half of this fails on
791
- the current mechanism (§1.3b), and the same check run with `feature_taa_enabled = false` must
792
- still pass, which is what pins `resolution` as derived rather than declared.
793
- - **fog of war over particles** — the `AfterTransparency` chain resolved from declarations, with a
794
- particle emitter inside the concealed region staying concealed. This is §1.3a's bug, as a gate.
795
-
796
- ---
797
-
798
- ## 12. Adversarial review
799
-
800
- The design above is the post-review version. What follows is what was attacked and what changed.
801
-
802
- **"A type-keyed container is a registry, and the house rule forbids registries."** The rule forbids
803
- *string*-keyed factories chosen to save boilerplate. A class is a type; the container is a typed
804
- map. The alternative — a struct with fixed fields — is defect §1.1 restated. *Survived, with the
805
- concession that every built-in record is an ordinary class with named fields, so the container is
806
- the only indirection.*
807
-
808
- **"You claim the graph orders passes. It executes in insertion order."** Correct, and the first
809
- draft leaned on the graph for ordering. **Changed:** §4 now states the execution model outright, and
810
- every ordering claim is attributed to the resolver. The design would have been wrong without this.
811
-
812
- > *Since.* The graph answered this from its own side rather than by being routed around: `validate()`
813
- > now proves that the recorded insertion order is a valid topological order and `compile()` asserts
814
- > on it. The design's position is unchanged — §3.6 still owns ordering — but the failure mode it was
815
- > protecting against narrowed sharply. A recording order that is *inconsistent* is now the graph's
816
- > to reject; §3.6 is left owning the orders that are consistent and still wrong, which is exactly
817
- > the fog-over-particles case (§1.3a) and is the only kind of ordering bug the game actually had.
818
-
819
- **"`scene.color = handle` is untracked mutation — the same side channel you criticised
820
- `context.gbuffer` for being."** Half right. The criticism of `context.gbuffer` was that it was the
821
- *only* extra mutable thing, at *one* phase, invisible in the pass signature. **Changed:** publication
822
- is now uniform across every record and validated at the setter — freeze state, frame identity, and
823
- `is_valid_resource` against the graph. The last of those catches a class of silent wrong-picture bug
824
- that neither the current mechanism nor URP catches at all.
825
-
826
- **"You deleted `resolution` and told every extension to call `describe()`. That is a worse API for
827
- the case everyone has."** Fair, and the first draft did exactly that in reaction to defect (b).
828
- **Changed:** `frame.resolution` came back as a derived getter over the phase's live target handle,
829
- so the common case is one property access *and* correct on both sides of the upscale, with
830
- `describe()` left for the genuinely two-sized case. The lesson from (b) is that the number must not
831
- be a field somebody fills in, not that the number should be hard to get.
832
-
833
- **"Ambiguity detection needs the frame to know who is recording — hidden global state."** One field,
834
- set by the loop that calls `record`. Re-entrant recording is an assert failure. *Survived.*
835
-
836
- **"Alphabetical tie-breaking is arbitrary. Why is arbitrary-by-name better than
837
- arbitrary-by-registration?"** Because it does not change when a system's `startup` gains an `await`.
838
- Reproducibility is the property, not correctness. **Changed:** the ambiguity check was added in the
839
- same pass, so an arbitrary order between two extensions that actually conflict is an error rather
840
- than a silently-chosen order.
841
-
842
- **"`FrameProduct` is a closed enum, so §1.1 is only half fixed."** Concede. The frame's own optional
843
- products are Shade's features and enumerating them is correct; the *open* half is the record
844
- vocabulary, which needs no engine edit. **Changed:** §3.7 says this, the enum was cut to two members
845
- with named consumers, and it was moved to the last delivery step.
846
-
847
- **"An `enabled` flag and an early return from `record` are two ways to do one thing."** Agreed.
848
- **Changed:** the flag was cut.
849
-
850
- **"`get_or_create` is friendlier than three methods."** It is, and it destroys the property that
851
- made `-1` go away: absent has to stay distinguishable from empty. *Rejected.*
852
-
853
- **"Six phases plus a blackboard plus a topological sort, to replace two methods, for nine
854
- consumers."** The two methods currently need four paragraphs of documentation about culling, carry a
855
- wrong resolution at two of five phases, and enforce a documented ordering requirement through the
856
- placement of an `await`. The sort is thirty lines over at most a dozen nodes. *Survived, and it is
857
- the reason §10 splits diagnostics out: if the sort and the records are the value, B and C can be
858
- judged separately.*
859
-
860
- **"`SceneColor` absent at `Overlay` means an extension that wants the tonemapped image just gets a
861
- throw."** Yes, with a message naming `BeforePresent`. That is strictly better than the current
862
- outcome, which is a validation error from inside a binding builder, and it is better than the
863
- documentation-only fix that shipped, which was discovered by measurement after the fact. *Survived.*
864
-
865
- **"The migration makes somebody decide the `AfterTransparency` order, and they may get it wrong."**
866
- It was decided while this document was under review, and the order in the shipped game turned out to
867
- be wrong (§1.3a). That is the argument for declaring it, not a counter to it — a mechanism where the
868
- answer is written down is a mechanism where being wrong is a diff. **Changed:** §8 states the
869
- resolved chain instead of leaving it open.
870
-
871
- **"`describe()` is a feature nobody asked for."** It is the only thing that makes "the frame graph is
872
- a first-class construct" mean something testable at this seam — a pipeline you can print without a
873
- device is a pipeline you can spec without a device. *Survived, in delivery step B.*
874
-
875
- **"Culling is what a frame graph is for. You made it an error for one class of user."** Sponsor, on
876
- the revision that asked the graph to report per-scope culling so a fully-culled extension could
877
- assert. **The objection is correct and the check is gone (§5.1).** Three ways it was wrong, and the
878
- review that produced the rest of this document did not catch any of them:
879
-
880
- - It adopted the construct and then treated its central behaviour as a failure.
881
- - The line it drew — culled built-in pass is optimisation, culled extension pass is a bug — has
882
- nothing behind it. Who recorded a pass does not change what culling means.
883
- - Fatally, it outlawed a pattern this document's own §3.3 puts forward as the reason the record
884
- vocabulary is open: an extension publishing `FogOfWarVisibility` for a consumer that may not be
885
- registered. Disable the consumer and the producer is correctly elided — and the assert would have
886
- called that a hard error. A speculative producer is the point, not an accident.
887
-
888
- Withdrawn with it: `FRAME_GRAPH_RECOMMENDATIONS.md` item 5, which was the last open engine change,
889
- and the provenance check in §3.4, which fell to the same standard once it was applied consistently
890
- — it forbade publishing an imported texture in order to catch a typo. What the argument leaves
891
- standing is a bar the remaining checks are now stated against: **a check may only fire on something
892
- that cannot be true**, never on an extension behaving correctly.
893
-
894
- ---
895
-
896
- ## 13. Engine changes required
897
-
898
- Small, and enumerated so the blast radius is visible. **`FrameGraph` needs nothing.** The graph-side
899
- items this document originally asked for have either landed or been withdrawn — the last of them,
900
- the culling report, was withdrawn on the reasoning in §5.1. Everything below is renderer and facade
901
- work.
902
-
903
- 1. `Renderer` publishes records at six points instead of filling one context at five. The record
904
- objects are built where `#frame_pass_context` is built today.
905
- 2. `Renderer` gains a `FrameStart` call site, and (delivery C) honours the product union when
906
- deciding whether to build velocity and the colour copy.
907
- 3. `GraphicsEngine3` swaps `add_pass`/`remove_pass`/`pass_count` for
908
- `add_extension`/`remove_extension`/`extension_count`. No net surface change.
909
- 4. `src/shade/renderer/pass/` becomes `src/shade/renderer/extension/`. The folder is MEEP 3's, not
910
- Shade's, and "pass" collides with `FramePassNode` — the graph's passes and an extension are
911
- different things and should not share a word.
912
-
913
- Nothing in Shade's frame structure, no pass reordering, no change to how built-in passes record.
914
-
915
- **Already done, and nothing further is needed from them** — `FrameGraphState` and its asserts,
916
- `validate()` with both order checks, `push_scope` / `pop_scope` and DOT clustering,
917
- `ResourceNode.producer` at record time, and the `exportToJson` fix. `getDescriptor`,
918
- `is_valid_resource` and `onExecuted` were already there and the design uses them as they stand.
1
+ # Render extensions — design
2
+
3
+ Replaces `Renderer.add_pass` / `remove_pass`, `FramePhase`, `FramePassContext` and
4
+ `FramePassRegistry` (MEEP 3, phase 2, item S3).
5
+
6
+ Status: **design, not built.** Nothing in §3 through §11 is committed code.
7
+
8
+ **Revised 2026-08-24** against the frame-graph work that has landed since the first draft:
9
+ `FrameGraphState`, `validate()`, recording scopes, record-time producers, and the `exportToJson`
10
+ fix. Those came out of this document's §4 and §13 and are tracked in
11
+ `engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md`. §1.2, §3.4, §4, §5 and §13
12
+ describe what the graph does now, not what it did — the net effect is that the design leans on the
13
+ graph in three more places and asks it for nothing.
14
+
15
+ The same revision **withdrew the cull diagnostic** and the engine change it needed, on sponsor
16
+ review: culling is what a frame graph is for, and a check that calls it a failure when an extension
17
+ is the recorder invents a distinction that does not exist and outlaws the speculative producer.
18
+ §5.1 is the argument; the bar it sets — a check may only fire on something that cannot be true —
19
+ also took out a provenance check in §3.4.
20
+
21
+ ---
22
+
23
+ ## 0. Summary
24
+
25
+ An extension is a **named object with declarations** — a phase, ordering constraints, the optional
26
+ frame products it needs — and one method that records into the frame's graph. Everything it exchanges
27
+ with the frame goes through **typed per-frame records** holding frame-graph handles: it reads a
28
+ handle out of a record and writes a replacement back. There is no return value and no privileged
29
+ resource.
30
+
31
+ Three properties follow, and they are the point:
32
+
33
+ 1. **Every resource the frame carries is exchangeable**, not just the colour. The G-buffer stops
34
+ being a documented special case, depth stops being read-only, and an extension can publish a
35
+ record of its own that another extension reads.
36
+ 2. **Order is declared**, not emergent from the order in which ECS systems happened to reach their
37
+ first `await`.
38
+ 3. **The frame can be described without being rendered, and read back attributed once it has been.**
39
+ The set of extensions, their order per phase, and the products they force on are data, so
40
+ `describe()` prints the pipeline; and the registry records each extension inside its own graph
41
+ scope, so a DOT export of the frame groups by who asked for what instead of by whatever the
42
+ `graph_*` helpers named things. That pair is what "the frame graph is a first-class construct"
43
+ has to mean at this seam.
44
+
45
+ Underneath all three, one correction that is not an abstraction: **the upscale is a phase boundary
46
+ and the surface says so.** Three phases are before it and two are after, the before case is the
47
+ common one, and the size of what an extension is drawing into is derived from the live handle rather
48
+ than handed over as a field the renderer fills in once for the whole frame — which is what the
49
+ current mechanism does, and gets wrong at two of its five phases (§1.3b).
50
+
51
+ The shape is Unity 6 URP's — `ContextContainer` / `ContextItem`, `ScriptableRenderPass`,
52
+ `ConfigureInput` — with URP's two worst traits removed (§2).
53
+
54
+ ---
55
+
56
+ ## 1. What is wrong with `add_pass`
57
+
58
+ ### 1.1 One resource is modifiable
59
+
60
+ `FramePassRegistry.run` threads exactly one value:
61
+
62
+ ```js
63
+ const produced = passes[i](context);
64
+ if (produced === undefined) { continue; }
65
+ context.color = produced;
66
+ ```
67
+
68
+ A pass's only sanctioned output is a colour handle. Everything else on `FramePassContext` — depth,
69
+ previous depth, the four G-buffer targets, the two visibility targets — is an input. The one
70
+ exception, the G-buffer being in/out at `AfterGBuffer`, is implemented by letting the pass reach into
71
+ `context.gbuffer` and assign, and costs three paragraphs of prose in `FramePassContext` to explain
72
+ why replacing beats writing in place.
73
+
74
+ What that closes off, concretely: writing depth (terrain and water both rasterize geometry that
75
+ should own depth), publishing a resource an extension computed for other extensions to read (fog of
76
+ war's visibility grid is exactly this and currently lives outside the graph), and reading anything
77
+ the frame builds but the struct does not name — velocity, the Hi-Z pyramid, the TAA history, the
78
+ shadow atlas, the light cluster tables.
79
+
80
+ The struct is closed by construction: adding a field is an edit to Shade. That is the wrong cost for
81
+ "my game needs one more thing from the frame".
82
+
83
+ ### 1.2 The graph is an escape hatch, not the mechanism
84
+
85
+ `context.graph` hands the pass the `FrameGraph` and steps back. The registry never learns what the
86
+ pass read or wrote, so:
87
+
88
+ - **It cannot order passes.** It orders by registration order.
89
+ - **It cannot attribute anything.** `exportToDot` shows the nodes an extension created with whatever
90
+ names its helpers gave them, unrelated to the extension. The graph has since gained recording
91
+ scopes, which are exactly the missing mechanism — but they are opt-in and `add_pass` opens none,
92
+ so a frame's DOT export is still one flat wall of passes.
93
+ - **It cannot validate.** Reading `context.color` at `AfterGBuffer` yields `-1` and fails somewhere
94
+ inside a binding builder. Retaining a handle across frames is undetectable. Publishing a stale
95
+ handle is now caught, but by the graph at `compile()` and against the *reader* — the pass that
96
+ consumed it — so the report names the built-in pass that broke, never the extension that handed
97
+ it the bad handle.
98
+ - **`AfterGBuffer` has no return channel, so the culling contract cannot be met there.** The rule
99
+ itself is the graph's, and it is right everywhere: work nothing consumes is elided. But the only
100
+ way this mechanism lets a pass thread its result into the frame is by returning a colour handle,
101
+ and at `AfterGBuffer` there is no colour — so a pass whose G-buffer work *should* be consumed
102
+ reaches for `make_side_effect()` instead, which does not say "my output is used", it says "run me
103
+ anyway". Three paragraphs of `FramePhase` document the workaround. The record model removes the
104
+ reason for it (§3.3) and leaves the culling rule alone (§5.1).
105
+
106
+ And the injection points themselves are call sites: a phase is wherever `#run_frame_passes` was
107
+ typed inside an 800-line method. There are five because five were typed.
108
+
109
+ ### 1.3 What this has already produced
110
+
111
+ Four findings from reading the current code, each independent of the design that replaces it.
112
+
113
+ **(a) Order is an accident, and the code says so.** `TerrainSystem.startup`:
114
+
115
+ > `// before anything is awaited, so that pass order within the phase is the profile's system order —`
116
+ > `// the decal composite has to see the textured ground, not precede it`
117
+
118
+ An ordering requirement between two extensions is being met by placing a registration call above an
119
+ `await`. Adding an `await` to a system's startup silently reorders the frame.
120
+
121
+ The same mechanism decides the six-deep chain at `AfterTransparency`, and **it has already produced a
122
+ shipped bug**. In profile order that chain is fog of war, particles, path display, highlight, debug
123
+ draw, trails — so the fog composites *first* and everything drawn after it is unfogged. Confirmed
124
+ with the sponsor: that is not intended. Fog of war conceals, so it belongs over everything it
125
+ conceals; particles drawing on top of it defeat what it is for.
126
+
127
+ Nobody chose that order. Nothing recorded it. Nothing failed when it turned out wrong. That is the
128
+ argument for §3.6 in one paragraph.
129
+
130
+ **(b) The one size the context states is wrong at two phases.** `Renderer` fills
131
+ `pass_context.resolution` from `resolution_internal.asArray()`, once, for all five phases. But TAA
132
+ and NSS upscale to `output_resolution` before `BeforePresent`, and the tonemap writes the canvas at
133
+ output resolution — so at `BeforePresent` and `Overlay` the colour is *not* the size the context
134
+ reports, whenever TAA is on. With TAA off it is, because the upscale is missing (there is a `TODO`
135
+ in `Renderer` saying so). The size of the frame's colour at `BeforePresent` is therefore not
136
+ derivable from the context at all; it depends on a renderer feature flag the extension cannot see.
137
+
138
+ `probeShadePassInjection` — the gate for the current mechanism — does
139
+ `output_resolution: context.resolution` at `BeforePresent`. It passes because the probe runs at 100%
140
+ scale. The game ships with dynamic resolution scaling enabled against a 30 fps floor, and at any
141
+ scale below 100% that pass produces a cropped, wrongly-scaled frame while the gate still measures a
142
+ luminance change and reports green.
143
+
144
+ The single field is the tell: the surface was built as though a frame had one resolution. Three of
145
+ the five phases are before the upscale and two are after it, and nothing in the surface says which
146
+ is which — `FramePhase`'s own text for `BeforePresent` says "still HDR" and never mentions that the
147
+ colour has been upscaled by then. §3.5 makes the boundary part of what a phase *is*.
148
+
149
+ **(c) `-1` is a sentinel with no guard.** `color` is `-1` at `AfterGBuffer`. Nothing stops a pass
150
+ reading it. The house rule is that the common case is the only case and preconditions are asserted,
151
+ not encoded as magic values.
152
+
153
+ **(d) The G-buffer special case exists only because colour owns the return channel.** Given a
154
+ uniform way to publish any handle, "the G-buffer is in/out at one phase" stops being a rule and
155
+ becomes an instance of the general one.
156
+
157
+ ---
158
+
159
+ ## 2. What is borrowed from URP, and what is not
160
+
161
+ Borrowed:
162
+
163
+ | URP 6 | Here | Why |
164
+ | --- | --- | --- |
165
+ | `ContextContainer` + `ContextItem` | `FrameContext.get(RecordType)` + frame records | The open, typed resource vocabulary. This is the fix for §1.1, and the reason it is keyed by type rather than by string is that the house rule forbids string-keyed registries. |
166
+ | `UniversalResourceData` | `SceneColor`, `GBufferTextures`, `ViewTextures`, `PresentTarget` | Built-in resources are ordinary classes with named fields, carried in the same container as anyone else's. One mechanism, not two. |
167
+ | `ScriptableRenderPass` | `RenderExtension` | A declaring object, not a closure. Declarations are what makes ordering, validation and description possible. |
168
+ | `ConfigureInput(ScriptableRenderPassInput.Motion)` | `RenderExtension.requires` + `FrameProduct` | An extension states what the frame must produce and the renderer turns it on. This is the answer to "velocity exists, is built, and is unreachable". |
169
+ | Renderer feature list, ordered | The registry's resolved order, printable | The pipeline is inspectable before it runs. |
170
+
171
+ Not borrowed, deliberately:
172
+
173
+ - **`RenderPassEvent` + integer offsets.** URP orders same-event passes by an `int` you add to the
174
+ enum. It is the same defect as registration order with an extra step: unreadable, unstable under
175
+ insertion, and it makes every ordering question a numerology question. Replaced by explicit
176
+ `after` / `before` constraints over extension classes (§3.6).
177
+ - **`SetGlobalTexture` / global shader state.** An implicit channel between passes that the graph
178
+ cannot see. Records are the channel.
179
+ - **`AddUnsafePass`.** URP needs a legacy `CommandBuffer` escape hatch. There is no legacy to carry
180
+ here, and adding an unchecked door on day one guarantees it becomes the door.
181
+ - **Two-level feature/pass split.** URP splits `ScriptableRendererFeature` from
182
+ `ScriptableRenderPass` because a feature is a `ScriptableObject` living on an asset. That reason
183
+ does not exist here, so there is one class.
184
+
185
+ ---
186
+
187
+ ## 3. The model
188
+
189
+ ### 3.1 `RenderExtension`
190
+
191
+ ```js
192
+ export class RenderExtension {
193
+ /**
194
+ * Stable identity. Used in ordering diagnostics, error decoration and `describe()`.
195
+ * @type {string}
196
+ */
197
+ name = "";
198
+
199
+ /** @type {FramePhase} */
200
+ phase = FramePhase.AfterTransparency;
201
+
202
+ /**
203
+ * Extension classes this one records after / before. Constructors, not instances — an
204
+ * extension does not need a reference to another system's object to state an order.
205
+ * @type {Function[]}
206
+ */
207
+ after = [];
208
+ before = [];
209
+
210
+ /**
211
+ * Optional frame products this extension reads. A bitmask of {@link FrameProduct}.
212
+ * Reading a product nobody required is an assert failure, not a silent `-1`.
213
+ * @type {number}
214
+ */
215
+ requires = 0;
216
+
217
+ /**
218
+ * Record this extension's work into the frame. Called once per frame, when the phase comes
219
+ * round. Returns nothing: everything this produces reaches the rest of the frame by
220
+ * publication (§3.4).
221
+ *
222
+ * @param {FrameContext} frame
223
+ */
224
+ record(frame) {}
225
+ }
226
+ ```
227
+
228
+ `record` returning `void` is load-bearing. It is what makes colour stop being privileged.
229
+
230
+ An extension that has nothing to draw this frame returns without recording; that is the only "off"
231
+ switch and there is no `enabled` flag beside it.
232
+
233
+ ### 3.2 `FrameContext`
234
+
235
+ ```js
236
+ export class FrameContext {
237
+ /** @type {FrameGraph} the frame's graph — record into this with the `graph_*` helpers */
238
+ graph;
239
+ /** @type {ViewRenderContext} */
240
+ view;
241
+ /** @type {FramePhase} */
242
+ phase;
243
+
244
+ /** @template T @param {new()=>T} type @returns {T} throws if not present at this phase */
245
+ get(type) {}
246
+
247
+ /** @param {Function} type @returns {boolean} */
248
+ has(type) {}
249
+
250
+ /** @template T @param {new()=>T} type @returns {T} adds a record this extension owns */
251
+ create(type) {}
252
+
253
+ /**
254
+ * Pixel size of the target this phase carries, derived from the live handle rather than filled
255
+ * in by the renderer.
256
+ * @returns {number[]}
257
+ */
258
+ get resolution() {}
259
+
260
+ /** @param {number} handle @returns {ResourceDescriptor} size, format and usage of a handle */
261
+ describe(handle) {}
262
+ }
263
+ ```
264
+
265
+ Three accessors rather than one `get_or_create`, because "absent" has to stay distinguishable from
266
+ "empty" — that is what lets `get(SceneColor)` at `AfterGBuffer` throw with a sentence instead of
267
+ handing back `-1`.
268
+
269
+ **`resolution` is derived, and that is the whole of the fix for defect (b).** It reads the graph
270
+ through the handle this phase's target actually is, so it is right before the upscale, right after
271
+ it, right when TAA is off and the upscale does not happen at all, and right at every dynamic
272
+ resolution scale. The common case — "how big is the thing I am drawing into" — stays one property
273
+ access and cannot be wrong.
274
+
275
+ `describe(handle)` is the general form, and it is needed whenever two things in one pass are
276
+ different sizes: an extension at `BeforePresent` reading the G-buffer is working across the upscale
277
+ boundary and has to ask for both.
278
+
279
+ > Both lean on descriptors being trustworthy, which was checked: transient and imported resources
280
+ > both carry a real `TextureResourceDescriptor` holding absolute pixels.
281
+ > `TextureResourceDescriptor.resolution_domain` says otherwise by default and is vestigial — nothing
282
+ > branches on it, only `hash()` folds it in. It stays; it is now documented as vestigial on the field
283
+ > itself so the next reader does not take it for a contract.
284
+
285
+ ### 3.3 Frame records
286
+
287
+ A record is a plain class holding frame-graph handles. The renderer publishes the built-in ones as
288
+ the frame reaches the phase where they are meaningful.
289
+
290
+ ```js
291
+ /** present from AfterGBuffer */
292
+ class GBufferTextures { albedo; normal; pbr; emissive; }
293
+
294
+ /** present from AfterGBuffer */
295
+ class ViewTextures {
296
+ depth; depth_previous;
297
+ visibility_mesh; visibility_triangle;
298
+ velocity; // requires FrameProduct.Velocity
299
+ }
300
+
301
+ /** present from AfterLighting through BeforePresent */
302
+ class SceneColor {
303
+ color;
304
+ copy; // requires FrameProduct.ColorCopy
305
+ }
306
+
307
+ /** present at Overlay, and only there */
308
+ class PresentTarget { canvas; }
309
+ ```
310
+
311
+ Presence by phase:
312
+
313
+ | phase | records | resolution |
314
+ | --- | --- | --- |
315
+ | `FrameStart` | — (the view only) | internal |
316
+ | `AfterGBuffer` | `GBufferTextures` (writable), `ViewTextures` | internal |
317
+ | `AfterLighting` | `GBufferTextures` (frozen), `ViewTextures`, `SceneColor` | internal |
318
+ | `AfterTransparency` | as above | internal |
319
+ | `BeforePresent` | as above — but `SceneColor` and `GBufferTextures` are now different sizes | output |
320
+ | `Overlay` | `PresentTarget` | output |
321
+
322
+ Two of those rows are decisions worth naming.
323
+
324
+ **`SceneColor` is absent at `Overlay`, and `PresentTarget` is not `SceneColor`.** The current
325
+ mechanism hands the tonemapped colour to `Overlay` passes and then documents, at length, that they
326
+ must not read it — a texture cannot be a render attachment and a sampled source in the same pass.
327
+ That was found by measurement after the fact. Handing back a differently-named record with one field
328
+ says the same thing structurally: there is no `color` at `Overlay` to be tempted by, and an
329
+ extension that wants the finished image as *input* discovers at `get` time that it belongs at
330
+ `BeforePresent`.
331
+
332
+ **`GBufferTextures` is present but frozen after `AfterGBuffer`.** Outline and highlight effects
333
+ legitimately read the G-buffer and the visibility buffer late. Publishing into it late is
334
+ meaningless — shading has run — so the record is frozen and the assert says why (§5). At
335
+ `BeforePresent` it is also on the far side of the upscale from the colour (§3.5), so the two are
336
+ different sizes; `describe()` is how a pass learns the ratio, and the record's own doc comment says
337
+ to expect it.
338
+
339
+ Extensions add their own records with `create`:
340
+
341
+ ```js
342
+ export class FogOfWarVisibility { grid; } // a graph buffer handle
343
+
344
+ // producer, at FrameStart
345
+ frame.create(FogOfWarVisibility).grid = this.#update_grid(frame.graph);
346
+
347
+ // consumer, at AfterTransparency
348
+ if (frame.has(FogOfWarVisibility)) { ... }
349
+ ```
350
+
351
+ Which is the second half of the fix for §1.1: the vocabulary is open, and extending it costs no
352
+ edit to Shade.
353
+
354
+ ### 3.4 Publication
355
+
356
+ A field on a record is an accessor. Writing it is publication:
357
+
358
+ ```js
359
+ const scene = frame.get(SceneColor);
360
+
361
+ const [fogged] = graph_image_pass({
362
+ graph: frame.graph,
363
+ shader: this.#shader,
364
+ output_resolution: frame.describe(scene.color).resolution,
365
+ inputs: { input_color: scene.color, input_depth: frame.get(ViewTextures).depth }
366
+ });
367
+
368
+ scene.color = fogged; // the rest of the frame carries this
369
+ ```
370
+
371
+ The setter asserts three things, all free in production:
372
+
373
+ 1. The record is not frozen (§3.3), and the frame index matches — a handle captured last frame
374
+ cannot be published this frame.
375
+ 2. `graph.is_valid_resource(handle)` — the handle is the current version of its resource.
376
+ 3. The current phase is one where the record is writable.
377
+
378
+ Check 2 exists for **attribution**, not detection. The graph catches a superseded handle on its
379
+ own: whoever reads it next trips `validate()`'s superseded-read check at `compile()` (§4). But that
380
+ report names the *reader* — some built-in pass downstream — and says nothing about which extension
381
+ put the bad handle into the record. Checking at the setter fails at the line that caused it, with
382
+ the extension's name on it. It forbids nothing: one `ResourceEntry` owns one concrete resource, so
383
+ an older node is not a snapshot of older contents and publishing one is wrong in every case.
384
+
385
+ **A provenance check was considered here and rejected** — "the handle's producer is a pass recorded
386
+ inside this extension's scope", which the record-time producer (§4) made possible. It would have
387
+ forbidden a legitimate thing: `import_resource` leaves a node with no producer, so an extension that
388
+ imports a texture it owns and publishes it as the frame's colour — a debug view, video playback —
389
+ would have failed a check aimed at typos. What it uniquely caught, publishing a handle you read
390
+ rather than wrote, surfaces immediately downstream anyway.
391
+
392
+ Reading a field asserts frame identity and, for product-gated fields, that the *recording extension*
393
+ declared the product (§3.7).
394
+
395
+ **Publication is not the dependency edge.** The graph already has the edge: `FramePassBuilder.write`
396
+ renames a written resource into a versioned clone and records the read, so "this pass consumed the
397
+ colour as it stood and produced a new one" is already a graph fact. Publication is the *naming*
398
+ step — which handle currently means "the scene colour". Keeping those two ideas separate is what
399
+ lets the record model be this small.
400
+
401
+ ### 3.5 Phases
402
+
403
+ `FrameStart`, `AfterGBuffer`, `AfterLighting`, `AfterTransparency`, `BeforePresent`, `Overlay`.
404
+
405
+ **The upscale is a phase boundary, and saying so is part of what a phase means.** `AfterGBuffer`,
406
+ `AfterLighting` and `AfterTransparency` are before it and run at internal resolution; TAA and NSS
407
+ upscale to output resolution, so `BeforePresent` and `Overlay` are after it and run at output
408
+ resolution.
409
+
410
+ **Before the upscale is the common case, and the design leans on it.** An extension drawing into the
411
+ scene belongs at `AfterTransparency`: its work is then temporally resolved, upscaled and sharpened
412
+ along with everything else, and it is the cheapest place to draw because it draws at internal
413
+ resolution. `BeforePresent` is the deliberate other choice — full output resolution, still HDR,
414
+ nothing after it but the tonemap — and it is a trade, not an upgrade: work there gets no temporal
415
+ history and costs full-resolution pixels. An extension that does not have a reason to be after the
416
+ upscale should not be.
417
+
418
+ One wrinkle, and it belongs to the renderer rather than to this design: with
419
+ `feature_taa_enabled === false` the upscale does not happen at all — there is a `TODO` in `Renderer`
420
+ saying exactly that — so in that configuration `BeforePresent` is at internal resolution. A derived
421
+ `frame.resolution` (§3.2) is right in both configurations. A documented constant would be wrong in
422
+ one, which is how the current mechanism ended up wrong in two.
423
+
424
+ Five phases are the existing ones, unchanged in meaning. **`FrameStart` is the one addition**: a point after
425
+ the view is updated and before anything is rasterized. It exists because per-frame GPU compute that
426
+ later phases depend on currently has nowhere to go — fog of war's grid update, a custom cull, a
427
+ procedural buffer build — so it is either bolted onto a draw pass at a later phase, which puts it in
428
+ the wrong place in the graph and mis-attributes it in a capture, or done outside the graph entirely,
429
+ which is precisely the aliasing-and-lifetime blindness §1.2 is about.
430
+
431
+ Considered and rejected: `BeforeGBuffer` (nothing distinguishes it from `FrameStart` for anything an
432
+ extension can do), `AfterShadows` (shadow rendering is per-light and interior to
433
+ `GPULightCollection`; a phase there would be a lie about what is stable), per-post-effect phases
434
+ (URP has seventeen events and their number is a symptom, not a feature).
435
+
436
+ ### 3.6 Ordering
437
+
438
+ Within a phase, order is the topological order of the `after` / `before` edges. Cycles throw at
439
+ registration, naming the cycle. Both directions exist because an extension that must precede one it
440
+ cannot edit is exactly the case an extension mechanism is for.
441
+
442
+ Unconstrained pairs are broken by `name`, ascending. Stable and reproducible; deliberately *not*
443
+ registration order, so that adding an `await` to a system's startup cannot change the picture.
444
+
445
+ Name-order is stable, not correct. So the resolver also reports **ambiguity**: two extensions on one
446
+ phase that publish the same field of the same record, with no ordering path between them, is an
447
+ assert failure naming both and the field. Publication goes through our setters, so the frame knows
448
+ who published what — no extra syntax on the author's side. This is the check that would have made
449
+ somebody write down what the six-deep `AfterTransparency` chain is supposed to be.
450
+
451
+ The ambiguity check is per-frame and assert-only. It costs nothing in production and fires on the
452
+ first frame in development.
453
+
454
+ ### 3.7 Products
455
+
456
+ `FrameProduct` is a bitmask of things the frame *can* build but does not always:
457
+
458
+ ```js
459
+ export const FrameProduct = {
460
+ /** per-object + camera velocity, built even when TAA and motion blur are off */
461
+ Velocity: 1,
462
+ /** a readable snapshot of the scene colour as it stood when the phase began */
463
+ ColorCopy: 2,
464
+ };
465
+ ```
466
+
467
+ The union over registered extensions is computed once per registration change, and the renderer
468
+ consults it the way it consults its own feature flags. `ColorCopy` is produced immediately before
469
+ the passes of each phase that requires it.
470
+
471
+ Two members, because two have a nameable consumer: `Velocity` for any effect an extension wants to
472
+ temporally reproject itself (currently built for TAA, unreachable by anyone else), and `ColorCopy`
473
+ for refraction — a pass that rasterizes geometry *into* the live colour with a `load` op and must
474
+ also sample what is behind it, which is refractive water and distortion particles. A full-screen
475
+ pass that reads the colour and writes a new one does not need it and must not ask for it.
476
+
477
+ Considered and deferred with reasons in §9: `HiZ`, `PreviousColor`, `SceneDepthPyramid`.
478
+
479
+ This is the part of the design with the weakest present-day demand and it ships last (§10).
480
+
481
+ ---
482
+
483
+ ## 4. What the frame graph does, and what it does not
484
+
485
+ Stated plainly, because the design would be dishonest without it.
486
+
487
+ `FrameGraph.execute` iterates `__pass_nodes` **in insertion order**. It does not topologically sort,
488
+ and there is no plan for it to. What the graph gives is culling (`ref_count` propagation from
489
+ unreferenced resources), transient resource lifetime and aliasing, versioned renaming of written
490
+ resources, and export.
491
+
492
+ So: **the graph does not order extensions; §3.6 does.** Every claim in this document about ordering
493
+ is a claim about the resolver, not about the graph.
494
+
495
+ **What the graph does instead of sorting is check.** The first draft of this document listed two
496
+ implementation constraints the design had to route around. Both are now fixed, and a third thing
497
+ landed that the design had assumed away, so the division of labour is different from what §3.6 was
498
+ written against. The four that matter here — the full list and its evidence are in
499
+ `engine/graphics/render/frame_graph/FRAME_GRAPH_RECOMMENDATIONS.md`:
500
+
501
+ - **`validate()` proves the insertion order is a valid topological order**, and `compile()` asserts
502
+ on it. It reports a *read before write* (a pass reading a node a later pass produces) and a
503
+ *superseded read* (a pass reading a version an earlier pass has already replaced — one
504
+ `ResourceEntry` owns one concrete resource, so an older node is not a snapshot). This is the
505
+ honest answer to insertion-order execution: not to sort, but to refuse a recording whose order is
506
+ wrong. It is asserted, so it costs nothing shipped and fails the frame in development.
507
+ - **The lifecycle is one-shot and stated.** `FrameGraphState`: `Recording` → `Compiled` →
508
+ `Executed`, monotonic, asserted at every mutating entry point. `compile()` was not idempotent and
509
+ `execute()` could not tell whether `compile()` had run — a graph executed uncompiled culls itself
510
+ entirely and says nothing. The design's old "the extension layer must never compile the graph
511
+ itself" caveat is deleted: the graph says so now.
512
+ - **`ResourceNode.producer` is assigned at record time**, by `FramePassBuilder`, rather than in
513
+ `compile()`. So "this handle was produced by the pass that is publishing it" is answerable at
514
+ publication (§3.4).
515
+ - **Recording scopes exist.** `push_scope(name)` / `pop_scope()` stamp every pass and resource node
516
+ created inside them, nesting through a parent chain. This supersedes §13's request for a
517
+ `pass_count` getter and is what §1.2's attribution complaint was asking for.
518
+
519
+ One finding from that work belongs here rather than there, because it is the strongest available
520
+ argument for the whole model: `validate()`, on its first run against the live 61-pass `Shading`
521
+ graph, found that **Shade's own rasterization was not threading its colour targets.** Depth was
522
+ threaded through the two-phase opaque pass and colour was not, so every rasterization pass read
523
+ `out_color/0` at version 0 while depth advanced. It was harmless only because those passes write an
524
+ imported resource and therefore always execute, and because insertion order happened to give the
525
+ right picture — the colour dependency between them was simply not a graph fact. That is what
526
+ "working around the graph rather than through it" costs, in the engine's own code, found the day
527
+ someone checked.
528
+
529
+ What remains true, and what §3.6 rests on: the graph checks that a recording order is *consistent*,
530
+ never that it is the one anybody wanted. Two extensions each reading the colour and writing a new
531
+ one are valid in either order. Data-order violations are the graph's; visual-order mistakes are
532
+ §3.6's, and nothing else will catch them.
533
+
534
+ ---
535
+
536
+ ## 5. Validation and diagnostics
537
+
538
+ All of it under `assert`, which is compiled out of production builds — so the price of every check
539
+ here is zero in a shipped game and the checks may be as thorough as they like.
540
+
541
+ | check | when | what it catches |
542
+ | --- | --- | --- |
543
+ | record present at phase | `get` | reading `SceneColor` at `AfterGBuffer` — replaces `-1` |
544
+ | record frozen | publish | publishing a G-buffer handle after shading |
545
+ | frame identity | read + publish | a handle retained across frames |
546
+ | `is_valid_resource` | publish | publishing a superseded handle, attributed to the extension |
547
+ | product declared by the recording extension | read | reading `velocity` because *someone else* required it |
548
+ | ordering cycle | registration | `after`/`before` cycle, named |
549
+ | publication ambiguity | end of phase | two extensions writing one field with no order between them |
550
+
551
+ Beneath all of them the graph runs its own `validate()` inside `compile()` (§4), which catches the
552
+ data-order failures this layer cannot see.
553
+
554
+ Every one of these fires on a **statement that cannot be true** — a superseded handle names one
555
+ physical resource holding newer contents, a cycle has no order, an undeclared collision has no
556
+ answer. None of them fires on an extension that is behaving correctly. That is the bar; §5.1 is
557
+ what happens when a check is written without holding to it.
558
+
559
+ ### 5.1 Culling is not an error, and no diagnostic here treats it as one
560
+
561
+ An earlier revision of this document asked the graph to report per-scope culling so that "an
562
+ extension whose entire recorded work was culled" could be an assert. **That was wrong and it is
563
+ removed**, along with the engine change it needed. Three reasons, in increasing order of weight.
564
+
565
+ Culling is what a frame graph is *for*. Frostbite's paper is about eliminating work nobody
566
+ consumes; that is why the pattern spread. A mechanism cannot adopt the construct and then treat its
567
+ central behaviour as a failure.
568
+
569
+ The distinction it drew does not exist. "Culled built-in pass: optimisation. Culled extension pass:
570
+ bug." Nothing about the recorder changes what culling means, and encoding the split would have made
571
+ extensions second-class in the one place the design claims they are not.
572
+
573
+ And it forbids the pattern this design exists to enable. §3.3's own worked example is an extension
574
+ publishing `FogOfWarVisibility` at `FrameStart` for a consumer at `AfterTransparency`. Take the
575
+ consumer out — disable fog — and the producer's work is dead, the graph elides it, and the frame is
576
+ correct and cheaper. Under the assert that is a hard error. A speculative producer whose consumer
577
+ may or may not be present is the whole point of an open record vocabulary, and the check would have
578
+ outlawed it on day one.
579
+
580
+ **What replaces it: nothing, because nothing is missing.** `exportToJson` already reports
581
+ `culled` per pass and `exportToDot` already greys culled passes out; with the registry's scopes
582
+ (§5.2) a culled extension is a grey cluster with its own name on it. An author asking "why did my
583
+ pass not run" dumps the graph and sees it. That is a tool, not a tribunal.
584
+
585
+ The culling contract itself is stated once, on `RenderExtension.record`, in the same terms it holds
586
+ for every other pass in the graph: work whose output nothing consumes is elided; thread your result
587
+ into a record and it is consumed; if you want work that runs regardless, `make_side_effect()` says
588
+ so, exactly as it does for a built-in pass.
589
+
590
+ ### 5.2 Recording scopes
591
+
592
+ **The registry opens a recording scope per extension**, named after it, around the `record()` call:
593
+
594
+ ```js
595
+ graph.push_scope(extension.name);
596
+
597
+ try {
598
+ extension.record(frame);
599
+ } finally {
600
+ graph.pop_scope();
601
+ }
602
+ ```
603
+
604
+ The `finally` is not error handling — nothing is caught and nothing is swallowed. It is there
605
+ because `compile()` asserts the scope stack came back empty, so an extension that throws while
606
+ recording would otherwise fail on an unbalanced scope and bury the real error behind it. The throw
607
+ propagates, decorated with the extension's name the way `FrameGraph` already decorates a failing
608
+ pass with its own.
609
+
610
+ **Scopes are for reading the frame back, not for policing it.** `exportToDot` puts each extension's
611
+ passes in its own labelled cluster, nested as the scopes nest, and a graph that opens no scope
612
+ exports byte-identically to before. That is the whole of §1.2's attribution complaint, answered by
613
+ an API that already exists and that nothing currently calls — and it is what makes §5.1's answer to
614
+ culling a real answer rather than a shrug: the grey box in the dump has the extension's name on it.
615
+
616
+ No check depends on scopes. The one that did is gone (§5.1), and the provenance check that would
617
+ have used `is_within` was rejected on its own merits (§3.4).
618
+
619
+ `RenderExtensionRegistry.describe()` returns the resolved pipeline as text: per phase, the extensions
620
+ in resolved order, each with the products it required and the records it publishes into. Printable
621
+ without a device, which is what makes it testable without one.
622
+
623
+ ---
624
+
625
+ ## 6. Lifecycle
626
+
627
+ Registration is device-free and survives device loss — the current mechanism's one clearly right
628
+ decision, kept. `GraphicsEngine` holds the extension list; a renderer replaced by a restart is
629
+ re-populated from it. A system registers once at startup without knowing whether a device exists.
630
+
631
+ ```js
632
+ graphics.add_extension(extension); // returns the extension
633
+ graphics.remove_extension(extension); // returns whether it was registered
634
+ ```
635
+
636
+ Identity is the object, so the phase no longer has to be passed to removal. Registering or removing
637
+ during `record` is an assert failure.
638
+
639
+ `GraphicsEngine` gains no surface it did not have: this replaces `add_pass` / `remove_pass`
640
+ one-for-one, so D14's "the facade does not grow" holds. `pass_count(phase)` becomes
641
+ `extension_count(phase)`, which the start → stop → start leak gate needs.
642
+
643
+ **Views.** There is one injection site today: the primary view inside `render_to_target`. Shadow
644
+ views, probe bakes and picking do not run extensions and this design does not change that. When a
645
+ second view kind gains injection points, the filter belongs on the registry and the extension gains
646
+ a declaration for it; building that now would be inventing a contract for a caller that does not
647
+ exist.
648
+
649
+ ---
650
+
651
+ ## 7. The whole API
652
+
653
+ That is the entire surface.
654
+
655
+ ```js
656
+ // declaration
657
+ class RenderExtension { name; phase; after; before; requires; record(frame); }
658
+ const FramePhase = { FrameStart, AfterGBuffer, AfterLighting, AfterTransparency, BeforePresent, Overlay };
659
+ const FrameProduct = { Velocity, ColorCopy };
660
+
661
+ // recording
662
+ class FrameContext { graph; view; phase; resolution; get(type); has(type); create(type); describe(handle); }
663
+
664
+ // built-in records
665
+ class GBufferTextures { albedo; normal; pbr; emissive; }
666
+ class ViewTextures { depth; depth_previous; visibility_mesh; visibility_triangle; velocity; }
667
+ class SceneColor { color; copy; }
668
+ class PresentTarget { canvas; }
669
+
670
+ // registration
671
+ class RenderExtensionRegistry { add(e); remove(e); count(phase); describe(); run(frame); }
672
+ // on GraphicsEngine / Renderer
673
+ add_extension(e); remove_extension(e); extension_count(phase);
674
+ ```
675
+
676
+ ---
677
+
678
+ ## 8. Migration
679
+
680
+ Nine live systems, one probe, one prototype viewer. Every one of them is the same mechanical change:
681
+ a closure becomes a class, `context.color` becomes `frame.get(SceneColor).color`, and the return
682
+ value becomes a publication.
683
+
684
+ | consumer | phase | change beyond the mechanical |
685
+ | --- | --- | --- |
686
+ | `TerrainSystem` | `AfterGBuffer` | drops the registration-before-`await` comment; nothing else |
687
+ | `DecalSystem` | `AfterGBuffer` | `after: [TerrainExtension]`, which is the constraint the comment in `TerrainSystem` was hand-enforcing; stops assigning through `context.gbuffer` |
688
+ | `WaterSystem` | `AfterLighting` | publication instead of return; a candidate first consumer of `FrameProduct.ColorCopy` if refraction is wanted |
689
+ | `FogOfWarSystem` | `AfterTransparency` | the grid update becomes a `FrameStart` extension publishing `FogOfWarVisibility`, and the composite reads it; declares `after` the four content extensions — **this is where §1.3a's shipped bug is fixed** |
690
+ | `ParticleEmitterSystem` | `AfterTransparency` | — (now before the fog, by declaration) |
691
+ | `PathDisplaySystem` | `AfterTransparency` | — |
692
+ | `HighlightSystem` | `AfterTransparency` | reads `ViewTextures.visibility_*`, unchanged in meaning |
693
+ | `DebugDrawSystem` | `AfterTransparency` | `after: [FogOfWarExtension]` |
694
+ | `Trail3DSystem` | `AfterTransparency` | — |
695
+ | `probeShadePassInjection` | all | the gate is rewritten and gains the two checks it is currently blind to: a G-buffer replacement, and an order flip |
696
+ | `prototypeVGEOFormatViewer` | `AfterTransparency` | prototype; mechanical |
697
+
698
+ **The `AfterTransparency` chain is settled, not open.** Fog of war is last among the content
699
+ extensions, because it conceals and therefore belongs over everything it is meant to conceal —
700
+ particles, trails, path display and the selection highlight all draw before it and are all subject
701
+ to it. Debug draw is the single deliberate exception, `after: [FogOfWarExtension]`: developer
702
+ geometry that the fog can hide is a debugging tool that stops working exactly where debugging is
703
+ needed.
704
+
705
+ That is six extensions and two declarations. Written down once, in the two files that care, instead
706
+ of being a property of where an `await` sits.
707
+
708
+ **The bug does not have to wait for the migration.** Today's ordering is registration order, so
709
+ moving `FogOfWarSystem` below `Trail3DSystem` in `graphics_profile.js` fixes the picture now — at
710
+ the cost of it being another undeclared ordering fact, which is what this design exists to end.
711
+
712
+ ---
713
+
714
+ ## 9. Non-goals and known limits
715
+
716
+ **Shade's own frame is not rewritten into a list of extensions.** URP's real strength is that
717
+ built-in and injected passes are the same type. Getting there means restructuring
718
+ `Renderer.render_to_target`, and renderer internals are hands-off. The consequence, stated so it is
719
+ not discovered later: injection points remain hand-placed call sites, so the phase set is only as
720
+ good as the places the renderer publishes records, and an extension cannot insert itself between two
721
+ built-in post passes.
722
+
723
+ **Extensions cannot participate in OIT.** Work at `AfterTransparency` composites over the resolved
724
+ transparency; it cannot interleave with sorted transparents. True today, unchanged, and the honest
725
+ reason the phase model has a ceiling.
726
+
727
+ **Extensions cannot add to a built-in pass** — no extra material variants in the G-buffer raster, no
728
+ extra lights. A new pass is the only unit.
729
+
730
+ **No `FrameProduct` for `HiZ` / `PreviousColor` / depth pyramid yet.** Each is built by the renderer
731
+ and each would be a one-line addition, and none has a consumer today. Adding them speculatively puts
732
+ three untested branches in the renderer's product wiring.
733
+
734
+ **No view kinds.** §6.
735
+
736
+ **Ordering ambiguity is caught at frame time, not registration time**, because publication is
737
+ observed rather than declared. Declaring publications up front would catch it earlier at the cost of
738
+ a second declaration that can disagree with the code; observation cannot disagree with the code.
739
+
740
+ ---
741
+
742
+ ## 10. Delivery
743
+
744
+ Three steps, each independently green.
745
+
746
+ - **A — the model.** Records, `FrameContext`, publication with all four checks, registry with
747
+ ordering, cycle detection and scoped recording. Port the nine systems and the viewer. Delete
748
+ `FramePassContext`, `FramePassRegistry` and `add_pass` / `remove_pass`. `FrameStart` and
749
+ `PresentTarget` land here, because they are what the fog-of-war split and the `Overlay` rule need.
750
+ - **B — diagnostics.** Ambiguity check and `describe()`. Nothing here waits on the engine; scoped
751
+ recording lands in A, because it needs nothing that is not already there.
752
+ - **C — products.** `FrameProduct.Velocity` and `ColorCopy`, and the renderer wiring that honours
753
+ them.
754
+
755
+ A is the whole of the user-visible design. B is what makes it hold. C is the part with the weakest
756
+ demand and it is separable on purpose.
757
+
758
+ ---
759
+
760
+ ## 11. Tests and gates
761
+
762
+ Device-free (`vitest`, from the moh root), black-box, real objects:
763
+
764
+ - resolved order for a set of extensions with `after` / `before`; a cycle throws and names both;
765
+ unconstrained pairs resolve by name and the result does not change when registration order does.
766
+ - `get` of an absent record throws; `create` twice throws; `has` agrees with both.
767
+ - publication of a superseded handle fails; publication into a frozen record fails; a handle from a
768
+ previous frame fails. `FrameGraph` and a `SoftwareGPUDevice` are enough for all of these — no real
769
+ device, per the standing rule.
770
+ - **an extension whose output nothing consumes is culled, and that is not an error** — the frame
771
+ renders, no assert fires, and the pass reports `culled` in the JSON export. Pinned as a test
772
+ because it is a property somebody will otherwise "fix" (§5.1).
773
+ - an extension's passes land inside its own DOT cluster, and a graph recorded with no extensions
774
+ exports exactly as it did before. The scope machinery is already specced on the graph; what this
775
+ adds is that the registry opens and closes one per extension, including when `record()` throws.
776
+ - `describe()` output is stable and contains every registered extension.
777
+ - registry survives a simulated renderer replacement with the same extensions in the same order.
778
+
779
+ On a device, extending `probeShadePassInjection`:
780
+
781
+ - a colour-writing extension at each colour-carrying phase, in and out, restoring to within the
782
+ existing 25% bar.
783
+ - **a G-buffer-replacing extension at `AfterGBuffer`** — writes a flat albedo, and the lit frame
784
+ changes. The current gate has no such check, which is why the in/out G-buffer has never been
785
+ exercised outside the decal system.
786
+ - **an order flip** — two extensions publishing the colour, with the `after` edge reversed between
787
+ runs, producing different frames. This is the property the current mechanism cannot state at all.
788
+ - **the upscale boundary, at 60% dynamic resolution scale**: an extension at `AfterTransparency` and
789
+ one at `BeforePresent`, each sizing its output from `frame.resolution`, both producing a full,
790
+ uncropped frame. They must resolve to *different* sizes. The `BeforePresent` half of this fails on
791
+ the current mechanism (§1.3b), and the same check run with `feature_taa_enabled = false` must
792
+ still pass, which is what pins `resolution` as derived rather than declared.
793
+ - **fog of war over particles** — the `AfterTransparency` chain resolved from declarations, with a
794
+ particle emitter inside the concealed region staying concealed. This is §1.3a's bug, as a gate.
795
+
796
+ ---
797
+
798
+ ## 12. Adversarial review
799
+
800
+ The design above is the post-review version. What follows is what was attacked and what changed.
801
+
802
+ **"A type-keyed container is a registry, and the house rule forbids registries."** The rule forbids
803
+ *string*-keyed factories chosen to save boilerplate. A class is a type; the container is a typed
804
+ map. The alternative — a struct with fixed fields — is defect §1.1 restated. *Survived, with the
805
+ concession that every built-in record is an ordinary class with named fields, so the container is
806
+ the only indirection.*
807
+
808
+ **"You claim the graph orders passes. It executes in insertion order."** Correct, and the first
809
+ draft leaned on the graph for ordering. **Changed:** §4 now states the execution model outright, and
810
+ every ordering claim is attributed to the resolver. The design would have been wrong without this.
811
+
812
+ > *Since.* The graph answered this from its own side rather than by being routed around: `validate()`
813
+ > now proves that the recorded insertion order is a valid topological order and `compile()` asserts
814
+ > on it. The design's position is unchanged — §3.6 still owns ordering — but the failure mode it was
815
+ > protecting against narrowed sharply. A recording order that is *inconsistent* is now the graph's
816
+ > to reject; §3.6 is left owning the orders that are consistent and still wrong, which is exactly
817
+ > the fog-over-particles case (§1.3a) and is the only kind of ordering bug the game actually had.
818
+
819
+ **"`scene.color = handle` is untracked mutation — the same side channel you criticised
820
+ `context.gbuffer` for being."** Half right. The criticism of `context.gbuffer` was that it was the
821
+ *only* extra mutable thing, at *one* phase, invisible in the pass signature. **Changed:** publication
822
+ is now uniform across every record and validated at the setter — freeze state, frame identity, and
823
+ `is_valid_resource` against the graph. The last of those catches a class of silent wrong-picture bug
824
+ that neither the current mechanism nor URP catches at all.
825
+
826
+ **"You deleted `resolution` and told every extension to call `describe()`. That is a worse API for
827
+ the case everyone has."** Fair, and the first draft did exactly that in reaction to defect (b).
828
+ **Changed:** `frame.resolution` came back as a derived getter over the phase's live target handle,
829
+ so the common case is one property access *and* correct on both sides of the upscale, with
830
+ `describe()` left for the genuinely two-sized case. The lesson from (b) is that the number must not
831
+ be a field somebody fills in, not that the number should be hard to get.
832
+
833
+ **"Ambiguity detection needs the frame to know who is recording — hidden global state."** One field,
834
+ set by the loop that calls `record`. Re-entrant recording is an assert failure. *Survived.*
835
+
836
+ **"Alphabetical tie-breaking is arbitrary. Why is arbitrary-by-name better than
837
+ arbitrary-by-registration?"** Because it does not change when a system's `startup` gains an `await`.
838
+ Reproducibility is the property, not correctness. **Changed:** the ambiguity check was added in the
839
+ same pass, so an arbitrary order between two extensions that actually conflict is an error rather
840
+ than a silently-chosen order.
841
+
842
+ **"`FrameProduct` is a closed enum, so §1.1 is only half fixed."** Concede. The frame's own optional
843
+ products are Shade's features and enumerating them is correct; the *open* half is the record
844
+ vocabulary, which needs no engine edit. **Changed:** §3.7 says this, the enum was cut to two members
845
+ with named consumers, and it was moved to the last delivery step.
846
+
847
+ **"An `enabled` flag and an early return from `record` are two ways to do one thing."** Agreed.
848
+ **Changed:** the flag was cut.
849
+
850
+ **"`get_or_create` is friendlier than three methods."** It is, and it destroys the property that
851
+ made `-1` go away: absent has to stay distinguishable from empty. *Rejected.*
852
+
853
+ **"Six phases plus a blackboard plus a topological sort, to replace two methods, for nine
854
+ consumers."** The two methods currently need four paragraphs of documentation about culling, carry a
855
+ wrong resolution at two of five phases, and enforce a documented ordering requirement through the
856
+ placement of an `await`. The sort is thirty lines over at most a dozen nodes. *Survived, and it is
857
+ the reason §10 splits diagnostics out: if the sort and the records are the value, B and C can be
858
+ judged separately.*
859
+
860
+ **"`SceneColor` absent at `Overlay` means an extension that wants the tonemapped image just gets a
861
+ throw."** Yes, with a message naming `BeforePresent`. That is strictly better than the current
862
+ outcome, which is a validation error from inside a binding builder, and it is better than the
863
+ documentation-only fix that shipped, which was discovered by measurement after the fact. *Survived.*
864
+
865
+ **"The migration makes somebody decide the `AfterTransparency` order, and they may get it wrong."**
866
+ It was decided while this document was under review, and the order in the shipped game turned out to
867
+ be wrong (§1.3a). That is the argument for declaring it, not a counter to it — a mechanism where the
868
+ answer is written down is a mechanism where being wrong is a diff. **Changed:** §8 states the
869
+ resolved chain instead of leaving it open.
870
+
871
+ **"`describe()` is a feature nobody asked for."** It is the only thing that makes "the frame graph is
872
+ a first-class construct" mean something testable at this seam — a pipeline you can print without a
873
+ device is a pipeline you can spec without a device. *Survived, in delivery step B.*
874
+
875
+ **"Culling is what a frame graph is for. You made it an error for one class of user."** Sponsor, on
876
+ the revision that asked the graph to report per-scope culling so a fully-culled extension could
877
+ assert. **The objection is correct and the check is gone (§5.1).** Three ways it was wrong, and the
878
+ review that produced the rest of this document did not catch any of them:
879
+
880
+ - It adopted the construct and then treated its central behaviour as a failure.
881
+ - The line it drew — culled built-in pass is optimisation, culled extension pass is a bug — has
882
+ nothing behind it. Who recorded a pass does not change what culling means.
883
+ - Fatally, it outlawed a pattern this document's own §3.3 puts forward as the reason the record
884
+ vocabulary is open: an extension publishing `FogOfWarVisibility` for a consumer that may not be
885
+ registered. Disable the consumer and the producer is correctly elided — and the assert would have
886
+ called that a hard error. A speculative producer is the point, not an accident.
887
+
888
+ Withdrawn with it: `FRAME_GRAPH_RECOMMENDATIONS.md` item 5, which was the last open engine change,
889
+ and the provenance check in §3.4, which fell to the same standard once it was applied consistently
890
+ — it forbade publishing an imported texture in order to catch a typo. What the argument leaves
891
+ standing is a bar the remaining checks are now stated against: **a check may only fire on something
892
+ that cannot be true**, never on an extension behaving correctly.
893
+
894
+ ---
895
+
896
+ ## 13. Engine changes required
897
+
898
+ Small, and enumerated so the blast radius is visible. **`FrameGraph` needs nothing.** The graph-side
899
+ items this document originally asked for have either landed or been withdrawn — the last of them,
900
+ the culling report, was withdrawn on the reasoning in §5.1. Everything below is renderer and facade
901
+ work.
902
+
903
+ 1. `Renderer` publishes records at six points instead of filling one context at five. The record
904
+ objects are built where `#frame_pass_context` is built today.
905
+ 2. `Renderer` gains a `FrameStart` call site, and (delivery C) honours the product union when
906
+ deciding whether to build velocity and the colour copy.
907
+ 3. `GraphicsEngine` swaps `add_pass`/`remove_pass`/`pass_count` for
908
+ `add_extension`/`remove_extension`/`extension_count`. No net surface change.
909
+ 4. `src/shade/renderer/pass/` becomes `src/shade/renderer/extension/`. The folder is MEEP 3's, not
910
+ Shade's, and "pass" collides with `FramePassNode` — the graph's passes and an extension are
911
+ different things and should not share a word.
912
+
913
+ Nothing in Shade's frame structure, no pass reordering, no change to how built-in passes record.
914
+
915
+ **Already done, and nothing further is needed from them** — `FrameGraphState` and its asserts,
916
+ `validate()` with both order checks, `push_scope` / `pop_scope` and DOT clustering,
917
+ `ResourceNode.producer` at record time, and the `exportToJson` fix. `getDescriptor`,
918
+ `is_valid_resource` and `onExecuted` were already there and the design uses them as they stand.