@bornengine/engine 0.4.16

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,269 @@
1
+ //! Hi-Z (hierarchical linear-depth) pyramid build: a linearize pass
2
+ //! into mip 0 followed by min-downsample passes. Consumed by SSAO/SSR
3
+ //! ray-march acceleration and (via the max-reduce in occlusion.rs) the
4
+ //! occlusion-culling grid. Split out of renderer/mod.rs (2000-line file
5
+ //! policy); pipelines and the mip chain stay fields on [`Renderer`].
6
+
7
+ use super::formats::HIZ_MIP_COUNT;
8
+ use super::formats::halton;
9
+ use super::{HizDownsampleParams, HizLinearizeParams, SsaoBlurParams, SsaoParams};
10
+ use super::Renderer;
11
+
12
+ impl Renderer {
13
+ pub(super) fn record_hiz_chain(
14
+ &mut self,
15
+ encoder: &mut wgpu::CommandEncoder,
16
+ profiler: &mut crate::profiler::Profiler,
17
+ half_w: u32,
18
+ half_h: u32,
19
+ p22: f32,
20
+ p32: f32,
21
+ ) {
22
+ // --- Hi-Z build: linearize depth into mip 0 -----------------
23
+ let lin_params = HizLinearizeParams {
24
+ params: [1.0 / half_w as f32, 1.0 / half_h as f32, p22, p32],
25
+ size: [half_w, half_h, 0, 0],
26
+ };
27
+ self.queue.write_buffer(&self.hiz_linearize_uniform_buffer, 0, bytemuck::bytes_of(&lin_params));
28
+ if self.hiz_linearize_bg_cache.is_none() {
29
+ self.hiz_linearize_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
30
+ label: Some("hiz_linearize_bg"),
31
+ layout: &self.hiz_linearize_layout,
32
+ entries: &[
33
+ wgpu::BindGroupEntry { binding: 0, resource: self.hiz_linearize_uniform_buffer.as_entire_binding() },
34
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.depth_view) },
35
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) },
36
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) },
37
+ ],
38
+ }));
39
+ }
40
+ {
41
+ let bg = self.hiz_linearize_bg_cache.as_ref().unwrap();
42
+ let ts = profiler.compute_pass_timestamp_writes("hiz_linearize_pass");
43
+ let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
44
+ label: Some("hiz_linearize_pass"),
45
+ timestamp_writes: ts,
46
+ });
47
+ pass.set_pipeline(&self.hiz_linearize_pipeline);
48
+ pass.set_bind_group(0, bg, &[]);
49
+ pass.dispatch_workgroups((half_w + 7) / 8, (half_h + 7) / 8, 1);
50
+ }
51
+
52
+ // --- Hi-Z build: downsample mip i -> mip i+1 ----------------
53
+ for i in 0..(HIZ_MIP_COUNT - 1) as usize {
54
+ let dst_w = (half_w >> (i + 1)).max(1);
55
+ let dst_h = (half_h >> (i + 1)).max(1);
56
+ let ds_params = HizDownsampleParams {
57
+ size: [dst_w, dst_h, 0, 0],
58
+ };
59
+ self.queue.write_buffer(&self.hiz_downsample_uniform_buffers[i], 0, bytemuck::bytes_of(&ds_params));
60
+ if self.hiz_downsample_bg_cache[i].is_none() {
61
+ self.hiz_downsample_bg_cache[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
62
+ label: Some("hiz_downsample_bg"),
63
+ layout: &self.hiz_downsample_layout,
64
+ entries: &[
65
+ wgpu::BindGroupEntry { binding: 0, resource: self.hiz_downsample_uniform_buffers[i].as_entire_binding() },
66
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i]) },
67
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i + 1]) },
68
+ ],
69
+ }));
70
+ }
71
+ let bg = self.hiz_downsample_bg_cache[i].as_ref().unwrap();
72
+ let ts_label: &'static str = match i {
73
+ 0 => "hiz_downsample_pass_1",
74
+ 1 => "hiz_downsample_pass_2",
75
+ 2 => "hiz_downsample_pass_3",
76
+ _ => "hiz_downsample_pass_4",
77
+ };
78
+ let ts = profiler.compute_pass_timestamp_writes(ts_label);
79
+ let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
80
+ label: Some(ts_label),
81
+ timestamp_writes: ts,
82
+ });
83
+ pass.set_pipeline(&self.hiz_downsample_pipeline);
84
+ pass.set_bind_group(0, bg, &[]);
85
+ pass.dispatch_workgroups((dst_w + 7) / 8, (dst_h + 7) / 8, 1);
86
+ }
87
+
88
+ }
89
+ }
90
+
91
+ impl Renderer {
92
+ /// SSAO bilateral blur (depth-guided, edge-preserving) when SSAO is
93
+ /// on, or a white-clear of the blur RT when it's off so the
94
+ /// composite samples "no occlusion". Split from
95
+ /// end_frame_with_scene (2000-line file policy).
96
+ pub(super) fn record_ssao_blur(
97
+ &mut self,
98
+ encoder: &mut wgpu::CommandEncoder,
99
+ surf_w: u32,
100
+ surf_h: u32,
101
+ ) {
102
+ // ============================================================
103
+ // SSAO bilateral blur: smooth the noisy GTAO output while
104
+ // preserving depth edges (depth-guided bilateral filter).
105
+ // Reads ssao_rt → writes ssao_blur_rt.
106
+ // ============================================================
107
+ // PT: when the path tracer owns the frame it computes real
108
+ // occlusion by tracing — screen-space AO on top double-darkens
109
+ // every crevice. Route compose to "no occlusion" (white clear
110
+ // below) for those frames.
111
+ if self.ssao_enabled && !self.pt_owns_frame() {
112
+ // texel_size is the size of one SSAO RT texel (half-res).
113
+ let ao_w = (surf_w / 2).max(1) as f32;
114
+ let ao_h = (surf_h / 2).max(1) as f32;
115
+ let bp = SsaoBlurParams {
116
+ params: [1.0 / ao_w, 1.0 / ao_h, 0.05, 0.0],
117
+ };
118
+ self.queue.write_buffer(&self.ssao_blur_uniform_buffer, 0, bytemuck::bytes_of(&bp));
119
+
120
+ if self.ssao_blur_bg_cache.is_none() {
121
+ self.ssao_blur_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
122
+ label: Some("ssao_blur_bg"),
123
+ layout: &self.ssao_blur_layout,
124
+ entries: &[
125
+ wgpu::BindGroupEntry { binding: 0, resource: self.ssao_blur_uniform_buffer.as_entire_binding() },
126
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) },
127
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) },
128
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) },
129
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) },
130
+ ],
131
+ }));
132
+ }
133
+ let bg = self.ssao_blur_bg_cache.as_ref().unwrap();
134
+
135
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
136
+ label: Some("ssao_blur_pass"),
137
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
138
+ view: &self.ssao_blur_rt_view,
139
+ resolve_target: None,
140
+ depth_slice: None,
141
+ ops: wgpu::Operations {
142
+ load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
143
+ store: wgpu::StoreOp::Store,
144
+ },
145
+ })],
146
+ depth_stencil_attachment: None,
147
+ timestamp_writes: None,
148
+ occlusion_query_set: None,
149
+ multiview_mask: None,
150
+ });
151
+ pass.set_pipeline(&self.ssao_blur_pipeline);
152
+ pass.set_bind_group(0, bg, &[]);
153
+ pass.draw(0..3, 0..1);
154
+ } else {
155
+ // SSAO disabled — clear the blur RT to WHITE so the
156
+ // composite pass samples "no occlusion". Cheaper than a
157
+ // full blur pass; the clear is the only GPU work.
158
+ encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
159
+ label: Some("ssao_blur_disabled_clear"),
160
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
161
+ view: &self.ssao_blur_rt_view,
162
+ resolve_target: None,
163
+ depth_slice: None,
164
+ ops: wgpu::Operations {
165
+ load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
166
+ store: wgpu::StoreOp::Store,
167
+ },
168
+ })],
169
+ depth_stencil_attachment: None,
170
+ timestamp_writes: None,
171
+ occlusion_query_set: None,
172
+ multiview_mask: None,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ impl Renderer {
179
+ /// GTAO compute dispatch (half-res, Hi-Z-accelerated, temporal EMA
180
+ /// ping-pong). Caller guards on `ssao_enabled` and passes the
181
+ /// projection terms. Split from end_frame_with_scene.
182
+ #[allow(clippy::too_many_arguments)]
183
+ pub(super) fn record_gtao(
184
+ &mut self,
185
+ encoder: &mut wgpu::CommandEncoder,
186
+ profiler: &mut crate::profiler::Profiler,
187
+ half_w: u32,
188
+ half_h: u32,
189
+ p00: f32,
190
+ p11: f32,
191
+ p20: f32,
192
+ p21: f32,
193
+ ) {
194
+ let p22 = self.current_proj_matrix[2][2];
195
+ let p32 = self.current_proj_matrix[3][2];
196
+ // --- SSAO (compute GTAO, samples Hi-Z pyramid) --------------
197
+ let ld = self.lighting_uniforms.light_dir;
198
+ let v = &self.current_view_matrix;
199
+ let light_dir_vs = [
200
+ v[0][0]*ld[0] + v[1][0]*ld[1] + v[2][0]*ld[2],
201
+ v[0][1]*ld[0] + v[1][1]*ld[1] + v[2][1]*ld[2],
202
+ v[0][2]*ld[0] + v[1][2]*ld[1] + v[2][2]*ld[2],
203
+ 0.0,
204
+ ];
205
+ // Temporal accumulation: ping-pong history textures.
206
+ // `write_idx` is the current-frame output; `read_idx` the
207
+ // previous frame's result. First 4 frames force alpha=1
208
+ // so the initial clear never contaminates the signal.
209
+ let write_idx = self.ssao_history_idx;
210
+ let read_idx = 1 - write_idx;
211
+ let frame_phase = self.ssao_history_frame % 4;
212
+ let force_refresh = if self.ssao_history_frame < 4 { 1u32 } else { 0u32 };
213
+ // 4-frame EMA: alpha = 1/4 = 0.25 gives equal weight to
214
+ // each of the 4 phases at steady state.
215
+ let alpha = 0.25_f32;
216
+ // Halton-5 rotation: uncorrelated with TAA's base-2/3 jitter
217
+ // so the two noise patterns don't resonate.
218
+ let halton5 = halton(self.ssao_history_frame + 1, 5);
219
+ let sp = SsaoParams {
220
+ params: [
221
+ 1.0 / half_w as f32,
222
+ 1.0 / half_h as f32,
223
+ self.ssao_radius,
224
+ self.ssao_strength,
225
+ ],
226
+ proj_row01: [p00, p11, p20, p21],
227
+ proj_z: [p22, p32, 1.0 / p00, 1.0 / p11],
228
+ light_dir_vs,
229
+ size: [half_w, half_h, frame_phase, force_refresh],
230
+ temporal: [alpha, halton5, 0.0, 0.0],
231
+ };
232
+ self.queue.write_buffer(&self.ssao_uniform_buffer, 0, bytemuck::bytes_of(&sp));
233
+
234
+ if self.ssao_bg_cache[write_idx].is_none() {
235
+ self.ssao_bg_cache[write_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
236
+ label: Some("ssao_bg"),
237
+ layout: &self.ssao_layout,
238
+ entries: &[
239
+ wgpu::BindGroupEntry { binding: 0, resource: self.ssao_uniform_buffer.as_entire_binding() },
240
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) },
241
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) },
242
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) },
243
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]) },
244
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]) },
245
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]) },
246
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]) },
247
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) },
248
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[read_idx]) },
249
+ wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) },
250
+ wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[write_idx]) },
251
+ ],
252
+ }));
253
+ }
254
+ let bg = self.ssao_bg_cache[write_idx].as_ref().unwrap();
255
+
256
+ let ssao_ts = profiler.compute_pass_timestamp_writes("ssao_pass");
257
+ let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
258
+ label: Some("ssao_pass"),
259
+ timestamp_writes: ssao_ts,
260
+ });
261
+ pass.set_pipeline(&self.ssao_pipeline);
262
+ pass.set_bind_group(0, bg, &[]);
263
+ pass.dispatch_workgroups((half_w + 7) / 8, (half_h + 7) / 8, 1);
264
+
265
+ // Flip ping-pong indices for the next frame.
266
+ self.ssao_history_idx = read_idx;
267
+ self.ssao_history_frame = self.ssao_history_frame.wrapping_add(1);
268
+ }
269
+ }
@@ -0,0 +1,390 @@
1
+ //! Phase 6 — material WGSL hot reload.
2
+ //!
3
+ //! When a game compiles a material via `compile_material_from_file`,
4
+ //! the source path is registered here, the parent directory is added
5
+ //! to a `notify` recursive watcher, and a worker thread funnels
6
+ //! file-changed events into a `mpsc::Receiver` that the main thread
7
+ //! drains once per frame from `Renderer::end_frame_with_scene`.
8
+ //!
9
+ //! Recompilation happens on the main thread (wgpu's `Device` is
10
+ //! `!Send` for our purposes here): each pending event re-reads the
11
+ //! file from disk, runs the same `compile_material` it originally
12
+ //! used, and replaces the entry in `MaterialSystem::pipelines` at the
13
+ //! same handle index. Existing draws keep working — they look up by
14
+ //! handle, never by `&MaterialPipeline`.
15
+ //!
16
+ //! Failures (parse errors, validation) are logged but don't kill the
17
+ //! game — the previous pipeline stays bound until the file is edited
18
+ //! again to something valid.
19
+
20
+ use std::collections::{HashMap, HashSet};
21
+ use std::path::PathBuf;
22
+ use std::sync::mpsc::{channel, Receiver, Sender};
23
+ use std::sync::Mutex;
24
+ use std::time::{Duration, Instant};
25
+
26
+ use super::material_pipeline::{Bucket, FragmentProfile};
27
+
28
+ /// What we remember about each file-backed material so we can
29
+ /// rebuild it when the file changes.
30
+ #[derive(Clone)]
31
+ pub struct FileMaterialDesc {
32
+ pub path: PathBuf,
33
+ pub profile: FragmentProfile,
34
+ pub bucket: Bucket,
35
+ pub reads_scene: bool,
36
+ /// EN-001 — preserved across reloads so a file-backed instanced
37
+ /// material continues to opt into the per-instance vertex layout
38
+ /// after hot reload.
39
+ pub wants_instancing: bool,
40
+ }
41
+
42
+ pub struct MaterialHotReload {
43
+ /// Path → handle (1-based). Multiple handles per path possible if
44
+ /// the same file is compiled into multiple buckets — rare, but
45
+ /// supported via `Vec<u32>`.
46
+ by_path: HashMap<PathBuf, Vec<u32>>,
47
+ /// Handle (1-based, used as Vec<Option<…>> idx via -1) → desc.
48
+ /// Indexed parallel to `MaterialSystem::pipelines`.
49
+ descriptors: HashMap<u32, FileMaterialDesc>,
50
+ /// Cross-thread channel from the watcher's worker into the main
51
+ /// thread's per-frame poll. Use Mutex<Receiver> because the
52
+ /// renderer is held behind `&mut self` from many places.
53
+ rx: Mutex<Receiver<PathBuf>>,
54
+ _tx: Sender<PathBuf>,
55
+ /// Holding the watcher in the struct keeps the worker alive.
56
+ /// `Option` because creation is fallible and we want a clean
57
+ /// fallback (no hot reload) if `notify` errors at startup.
58
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
59
+ _watcher: Option<notify::RecommendedWatcher>,
60
+ /// Last-seen change time per path — `notify` fires multiple
61
+ /// events for one save (truncate + write + close on macOS).
62
+ /// Coalesce within `DEBOUNCE_WINDOW`.
63
+ last_event: Mutex<HashMap<PathBuf, Instant>>,
64
+ /// Directories already passed to `watcher.watch` — repeatedly
65
+ /// adding the same dir is fine but creates duplicate events.
66
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
67
+ watched_dirs: Mutex<Vec<PathBuf>>,
68
+ }
69
+
70
+ const DEBOUNCE_WINDOW: Duration = Duration::from_millis(120);
71
+
72
+ impl MaterialHotReload {
73
+ pub fn new() -> Self {
74
+ let (tx, rx) = channel::<PathBuf>();
75
+
76
+ // Watcher is on by default — even in release builds, since
77
+ // our daily dev cycle uses --release for perf. Shipped game
78
+ // binaries can opt out two ways:
79
+ // - runtime: set `BLOOM_NO_HOT_RELOAD=1`, short-circuits the
80
+ // watcher thread (compile_material_from_file still works,
81
+ // drain_pending just always returns empty)
82
+ // - compile-time: build with `--no-default-features` (or
83
+ // without the `hot-reload` feature), which drops `notify`
84
+ // from the dep tree entirely (EN-008)
85
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
86
+ let watcher = if std::env::var("BLOOM_NO_HOT_RELOAD").map(|v| v == "1").unwrap_or(false) {
87
+ None
88
+ } else {
89
+ use notify::{Event, EventKind};
90
+ let tx_clone = tx.clone();
91
+ let cb = move |res: Result<Event, notify::Error>| {
92
+ if let Ok(ev) = res {
93
+ if matches!(ev.kind, EventKind::Modify(_) | EventKind::Create(_)) {
94
+ for p in ev.paths {
95
+ let _ = tx_clone.send(p);
96
+ }
97
+ }
98
+ }
99
+ };
100
+ match notify::recommended_watcher(cb) {
101
+ Ok(w) => Some(w),
102
+ Err(e) => {
103
+ eprintln!("[hot_reload] failed to start file watcher: {e:?}");
104
+ None
105
+ }
106
+ }
107
+ };
108
+
109
+ Self {
110
+ by_path: HashMap::new(),
111
+ descriptors: HashMap::new(),
112
+ rx: Mutex::new(rx),
113
+ _tx: tx,
114
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
115
+ _watcher: watcher,
116
+ last_event: Mutex::new(HashMap::new()),
117
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
118
+ watched_dirs: Mutex::new(Vec::new()),
119
+ }
120
+ }
121
+
122
+ /// Called by the FFI `compile_material_from_file` after a successful
123
+ /// initial compile. Records the path → handle mapping and starts
124
+ /// watching the file's parent directory.
125
+ pub fn register(&mut self, handle: u32, desc: FileMaterialDesc) {
126
+ let path = desc.path.clone();
127
+ self.by_path.entry(path.clone()).or_default().push(handle);
128
+ self.descriptors.insert(handle, desc);
129
+ self.ensure_dir_watched(&path);
130
+ }
131
+
132
+ /// Drain pending file-change events. Returns the unique set of
133
+ /// (handle, descriptor) pairs that need recompilation this frame.
134
+ /// `notify` fires multiple events per save; we coalesce within a
135
+ /// 120 ms window.
136
+ pub fn drain_pending(&self) -> Vec<(u32, FileMaterialDesc)> {
137
+ let mut paths: Vec<PathBuf> = Vec::new();
138
+ if let Ok(rx) = self.rx.lock() {
139
+ while let Ok(p) = rx.try_recv() {
140
+ paths.push(p);
141
+ }
142
+ }
143
+ if paths.is_empty() { return Vec::new(); }
144
+
145
+ let now = Instant::now();
146
+ let mut last = self.last_event.lock().expect("hot_reload poisoned");
147
+ let mut out: Vec<(u32, FileMaterialDesc)> = Vec::new();
148
+ let mut seen: HashSet<u32> = HashSet::new();
149
+ for raw in paths {
150
+ // Resolve canonically so the keys we matched on register
151
+ // line up with the events. notify on macOS sometimes
152
+ // returns the realpath, sometimes the symlink path.
153
+ let p = std::fs::canonicalize(&raw).unwrap_or(raw);
154
+ if let Some(t) = last.get(&p) {
155
+ if now.duration_since(*t) < DEBOUNCE_WINDOW { continue; }
156
+ }
157
+ last.insert(p.clone(), now);
158
+ if let Some(handles) = self.by_path.get(&p) {
159
+ for h in handles {
160
+ if seen.insert(*h) {
161
+ if let Some(d) = self.descriptors.get(h) {
162
+ out.push((*h, d.clone()));
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ out
169
+ }
170
+
171
+ /// Test-only: push a path through the channel as if `notify` had
172
+ /// fired a Modify event. Lets unit tests exercise the drain +
173
+ /// debounce logic without a real file system.
174
+ #[cfg(test)]
175
+ pub fn test_inject_event(&self, path: PathBuf) {
176
+ let _ = self._tx.send(path);
177
+ }
178
+
179
+ fn ensure_dir_watched(&mut self, path: &std::path::Path) {
180
+ #[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
181
+ {
182
+ use notify::{RecursiveMode, Watcher};
183
+ let dir = match path.parent() {
184
+ Some(d) => match std::fs::canonicalize(d) {
185
+ Ok(c) => c,
186
+ Err(_) => return,
187
+ },
188
+ None => return,
189
+ };
190
+ let mut watched = self.watched_dirs.lock().expect("hot_reload poisoned");
191
+ if watched.contains(&dir) { return; }
192
+ if let Some(w) = self._watcher.as_mut() {
193
+ if let Err(e) = w.watch(&dir, RecursiveMode::NonRecursive) {
194
+ eprintln!("[hot_reload] failed to watch {dir:?}: {e:?}");
195
+ return;
196
+ }
197
+ watched.push(dir);
198
+ }
199
+ }
200
+ #[cfg(any(target_arch = "wasm32", not(feature = "hot-reload")))]
201
+ {
202
+ let _ = path;
203
+ }
204
+ }
205
+ }
206
+
207
+ #[cfg(test)]
208
+ mod tests {
209
+ use super::*;
210
+ use crate::renderer::material_pipeline::{Bucket, FragmentProfile};
211
+
212
+ fn desc(path: &str) -> FileMaterialDesc {
213
+ FileMaterialDesc {
214
+ path: PathBuf::from(path),
215
+ profile: FragmentProfile::Translucent,
216
+ bucket: Bucket::Refractive,
217
+ reads_scene: true,
218
+ wants_instancing: false,
219
+ }
220
+ }
221
+
222
+ #[test]
223
+ fn drain_returns_registered_handle_for_event_path() {
224
+ let mut hr = MaterialHotReload::new();
225
+ let p = PathBuf::from("/tmp/bloom_test_water.wgsl");
226
+ hr.register(7, FileMaterialDesc {
227
+ path: p.clone(),
228
+ profile: FragmentProfile::Translucent,
229
+ bucket: Bucket::Refractive,
230
+ reads_scene: true,
231
+ wants_instancing: false,
232
+ });
233
+ // canonicalize() in drain_pending falls back to the raw path
234
+ // when the file doesn't exist, so this resolves to itself.
235
+ hr.test_inject_event(p.clone());
236
+ let pending = hr.drain_pending();
237
+ assert_eq!(pending.len(), 1);
238
+ assert_eq!(pending[0].0, 7);
239
+ assert_eq!(pending[0].1.path, p);
240
+ }
241
+
242
+ #[test]
243
+ fn drain_dedups_within_debounce_window() {
244
+ let mut hr = MaterialHotReload::new();
245
+ let p = PathBuf::from("/tmp/bloom_test_dedup.wgsl");
246
+ hr.register(3, desc("/tmp/bloom_test_dedup.wgsl"));
247
+ // notify on macOS fires multiple events per save; same path
248
+ // 5x within the 120 ms window must collapse to one drain.
249
+ for _ in 0..5 {
250
+ hr.test_inject_event(p.clone());
251
+ }
252
+ let pending = hr.drain_pending();
253
+ assert_eq!(pending.len(), 1, "debounce should collapse 5 events to 1");
254
+ }
255
+
256
+ #[test]
257
+ fn drain_returns_distinct_handles_for_distinct_paths() {
258
+ let mut hr = MaterialHotReload::new();
259
+ let p1 = PathBuf::from("/tmp/bloom_test_a.wgsl");
260
+ let p2 = PathBuf::from("/tmp/bloom_test_b.wgsl");
261
+ hr.register(1, desc("/tmp/bloom_test_a.wgsl"));
262
+ hr.register(2, desc("/tmp/bloom_test_b.wgsl"));
263
+ hr.test_inject_event(p1);
264
+ hr.test_inject_event(p2);
265
+ let pending = hr.drain_pending();
266
+ assert_eq!(pending.len(), 2);
267
+ let mut handles: Vec<u32> = pending.iter().map(|(h, _)| *h).collect();
268
+ handles.sort();
269
+ assert_eq!(handles, vec![1, 2]);
270
+ }
271
+
272
+ #[test]
273
+ fn drain_ignores_unregistered_paths() {
274
+ let hr = MaterialHotReload::new();
275
+ hr.test_inject_event(PathBuf::from("/tmp/never_registered.wgsl"));
276
+ let pending = hr.drain_pending();
277
+ assert!(pending.is_empty());
278
+ }
279
+
280
+ #[test]
281
+ fn empty_channel_drains_to_empty_vec() {
282
+ let hr = MaterialHotReload::new();
283
+ assert!(hr.drain_pending().is_empty());
284
+ }
285
+
286
+ /// EN-006 — end-to-end hot-reload: register a real on-disk file,
287
+ /// inject an event for its canonical path, then verify
288
+ /// `drain_pending` surfaces the descriptor with the same handle.
289
+ /// Exercises the path-canonicalisation + by_path lookup that the
290
+ /// purely-synthetic tests above bypass (their paths don't exist on
291
+ /// disk, so canonicalize is a no-op fallback).
292
+ #[test]
293
+ fn end_to_end_disk_file_drain_returns_descriptor() {
294
+ let mut tmp = std::env::temp_dir();
295
+ let unique = format!(
296
+ "bloom_hotreload_e2e_{}_{}.wgsl",
297
+ std::process::id(),
298
+ std::time::SystemTime::now()
299
+ .duration_since(std::time::UNIX_EPOCH)
300
+ .map(|d| d.as_nanos())
301
+ .unwrap_or(0),
302
+ );
303
+ tmp.push(unique);
304
+ std::fs::write(&tmp, "// initial contents\n").expect("write tmp wgsl");
305
+ let canonical = std::fs::canonicalize(&tmp).expect("canonicalize tmp");
306
+
307
+ let mut hr = MaterialHotReload::new();
308
+ hr.register(
309
+ 42,
310
+ FileMaterialDesc {
311
+ path: canonical.clone(),
312
+ profile: FragmentProfile::Translucent,
313
+ bucket: Bucket::Refractive,
314
+ reads_scene: true,
315
+ wants_instancing: false,
316
+ },
317
+ );
318
+
319
+ // Simulate a "file changed" event identical to what the notify
320
+ // worker would push. The drain path canonicalises the inbound
321
+ // event path before lookup; the registered key is already
322
+ // canonical, so the two line up.
323
+ hr.test_inject_event(canonical.clone());
324
+ let pending = hr.drain_pending();
325
+ assert_eq!(pending.len(), 1, "registered descriptor surfaced exactly once");
326
+ assert_eq!(pending[0].0, 42);
327
+ assert_eq!(pending[0].1.path, canonical);
328
+ assert_eq!(pending[0].1.bucket, Bucket::Refractive);
329
+ assert!(pending[0].1.reads_scene);
330
+
331
+ // A subsequent edit (rewrite) injected after the debounce window
332
+ // re-fires for the same handle. We don't actually wait the full
333
+ // 120 ms here — the synthetic test_inject_event path bypasses
334
+ // notify entirely; we just rotate the cache by clearing it
335
+ // through a fresh inject after manual sleep.
336
+ std::thread::sleep(DEBOUNCE_WINDOW + std::time::Duration::from_millis(10));
337
+ std::fs::write(&tmp, "// updated contents\n").expect("rewrite tmp wgsl");
338
+ hr.test_inject_event(canonical.clone());
339
+ let pending2 = hr.drain_pending();
340
+ assert_eq!(pending2.len(), 1, "post-debounce edit drains again");
341
+ assert_eq!(pending2[0].0, 42);
342
+
343
+ let _ = std::fs::remove_file(&tmp);
344
+ }
345
+
346
+ /// EN-006 — full notify path. Registers a file, writes to it, waits
347
+ /// past the debounce window, drains. Skipped by default because
348
+ /// macOS FSEvents has variable latency (sometimes >500 ms after
349
+ /// open) which makes this flaky in CI; run locally with
350
+ /// `cargo test --release -- --ignored real_notify_fires_after_disk_write`
351
+ /// to validate the end-to-end watcher wiring.
352
+ #[test]
353
+ #[ignore]
354
+ fn real_notify_fires_after_disk_write() {
355
+ let mut tmp = std::env::temp_dir();
356
+ let unique = format!(
357
+ "bloom_hotreload_notify_{}_{}.wgsl",
358
+ std::process::id(),
359
+ std::time::SystemTime::now()
360
+ .duration_since(std::time::UNIX_EPOCH)
361
+ .map(|d| d.as_nanos())
362
+ .unwrap_or(0),
363
+ );
364
+ tmp.push(unique);
365
+ std::fs::write(&tmp, "// v1\n").expect("write tmp wgsl");
366
+ let canonical = std::fs::canonicalize(&tmp).expect("canonicalize tmp");
367
+
368
+ let mut hr = MaterialHotReload::new();
369
+ hr.register(99, FileMaterialDesc {
370
+ path: canonical.clone(),
371
+ profile: FragmentProfile::Opaque,
372
+ bucket: Bucket::Opaque,
373
+ reads_scene: false,
374
+ wants_instancing: false,
375
+ });
376
+
377
+ // Give the OS a moment to register the watch, then rewrite and
378
+ // wait past the debounce.
379
+ std::thread::sleep(std::time::Duration::from_millis(200));
380
+ std::fs::write(&tmp, "// v2\n").expect("rewrite tmp wgsl");
381
+ std::thread::sleep(DEBOUNCE_WINDOW + std::time::Duration::from_millis(400));
382
+
383
+ let pending = hr.drain_pending();
384
+ assert!(
385
+ pending.iter().any(|(h, _)| *h == 99),
386
+ "notify should have fired for {:?}", canonical,
387
+ );
388
+ let _ = std::fs::remove_file(&tmp);
389
+ }
390
+ }