@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,456 @@
1
+ //! Phase 7 — world-space impulse field.
2
+ //!
3
+ //! A small R32Float texture covering the playable world as a top-down
4
+ //! XZ projection. Games call `submit_splat(x, z, radius, strength)` on
5
+ //! events of interest (player enters water, explosion lands, footstep
6
+ //! on mud), and a per-frame compute pass decays the previous frame's
7
+ //! value and adds the new splats. Materials in the translucent pass
8
+ //! sample it from `@group(4) @binding(4)` for ripples, decals, snow,
9
+ //! wet spots — whatever decays-over-time, localised-in-world
10
+ //! phenomenon the material wants to express.
11
+ //!
12
+ //! Texture is ping-ponged to avoid a read/write alias on a single
13
+ //! storage resource; read side is a plain sampled `texture_2d<f32>`
14
+ //! so games don't need the read_write storage feature flag.
15
+ //!
16
+ //! World footprint is a hardcoded 128 m square centred on origin at
17
+ //! 256×256 texels = 0.5 m/texel for now. A later phase can replace
18
+ //! that with a per-game descriptor.
19
+ use crate::renderer::shader_include::ShaderSource;
20
+ use crate::renderer::shader_library::library;
21
+
22
+ /// Max splats accumulated in a single frame. Extra submissions are
23
+ /// dropped silently — games should batch their own impulses.
24
+ pub const MAX_SPLATS_PER_FRAME: usize = 16;
25
+
26
+ /// Pixel side of the impulse texture. Power-of-two, workgroup-divisible.
27
+ pub const IMPULSE_SIZE: u32 = 256;
28
+
29
+ /// Default world footprint — a centred square. Half-extent in metres.
30
+ pub const IMPULSE_WORLD_HALF_EXTENT: f32 = 64.0;
31
+
32
+ /// Per-frame decay multiplier. A splat with strength 1.0 fades to
33
+ /// ~2% after 120 frames @ 60 fps (two seconds) — matches the RFC's
34
+ /// acceptance criterion.
35
+ pub const IMPULSE_DECAY_PER_FRAME: f32 = 0.968;
36
+
37
+ #[repr(C)]
38
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
39
+ struct SplatData {
40
+ pos: [f32; 2], // world xz
41
+ radius: f32,
42
+ strength: f32,
43
+ }
44
+
45
+ #[repr(C)]
46
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
47
+ struct InfoUniforms {
48
+ world_min: [f32; 2], // meters
49
+ world_size: [f32; 2], // meters
50
+ decay: f32,
51
+ _pad0: f32,
52
+ splat_count: u32,
53
+ _pad1: u32,
54
+ splats: [SplatData; MAX_SPLATS_PER_FRAME],
55
+ }
56
+
57
+ pub struct ImpulseField {
58
+ pipeline: wgpu::ComputePipeline,
59
+ bg_layout: wgpu::BindGroupLayout,
60
+ info_buf: wgpu::Buffer,
61
+ /// Backing textures for view_a/view_b — kept alive so the views
62
+ /// don't dangle. Sampling and binding go through the views; these
63
+ /// fields are write-only handles.
64
+ _tex_a: wgpu::Texture,
65
+ _tex_b: wgpu::Texture,
66
+ view_a: wgpu::TextureView,
67
+ view_b: wgpu::TextureView,
68
+ sampler: wgpu::Sampler,
69
+ /// When true, view_a is the "front" that scene_inputs reads and
70
+ /// the next compute pass reads-from. After dispatch we swap.
71
+ front_is_a: bool,
72
+ splats: Vec<SplatData>,
73
+ }
74
+
75
+ impl ImpulseField {
76
+ pub fn new(device: &wgpu::Device) -> Self {
77
+ let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
78
+ label: Some("impulse_field_bg_layout"),
79
+ entries: &[
80
+ // 0: src (previous-frame sampled texture)
81
+ wgpu::BindGroupLayoutEntry {
82
+ binding: 0, visibility: wgpu::ShaderStages::COMPUTE,
83
+ ty: wgpu::BindingType::Texture {
84
+ sample_type: wgpu::TextureSampleType::Float { filterable: false },
85
+ view_dimension: wgpu::TextureViewDimension::D2,
86
+ multisampled: false,
87
+ },
88
+ count: None,
89
+ },
90
+ // 1: dst (write-only storage)
91
+ wgpu::BindGroupLayoutEntry {
92
+ binding: 1, visibility: wgpu::ShaderStages::COMPUTE,
93
+ ty: wgpu::BindingType::StorageTexture {
94
+ access: wgpu::StorageTextureAccess::WriteOnly,
95
+ format: wgpu::TextureFormat::R32Float,
96
+ view_dimension: wgpu::TextureViewDimension::D2,
97
+ },
98
+ count: None,
99
+ },
100
+ // 2: info UBO (splats + bounds + decay)
101
+ wgpu::BindGroupLayoutEntry {
102
+ binding: 2, visibility: wgpu::ShaderStages::COMPUTE,
103
+ ty: wgpu::BindingType::Buffer {
104
+ ty: wgpu::BufferBindingType::Uniform,
105
+ has_dynamic_offset: false,
106
+ min_binding_size: None,
107
+ },
108
+ count: None,
109
+ },
110
+ ],
111
+ });
112
+
113
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
114
+ label: Some("impulse_field_pipeline_layout"),
115
+ bind_group_layouts: &[Some(&bg_layout)],
116
+ immediate_size: 0,
117
+ });
118
+
119
+ let shader_src = library().fetch("impulse_field.wgsl")
120
+ .expect("impulse_field.wgsl must be present in shader_library")
121
+ .to_string();
122
+ let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
123
+ label: Some("impulse_field"),
124
+ source: wgpu::ShaderSource::Wgsl(shader_src.into()),
125
+ });
126
+ let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
127
+ label: Some("impulse_field_pipeline"),
128
+ layout: Some(&pipeline_layout),
129
+ module: &module,
130
+ entry_point: Some("cs_main"),
131
+ compilation_options: Default::default(),
132
+ cache: None,
133
+ });
134
+
135
+ let info_buf = device.create_buffer(&wgpu::BufferDescriptor {
136
+ label: Some("impulse_field_info"),
137
+ size: std::mem::size_of::<InfoUniforms>() as u64,
138
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
139
+ mapped_at_creation: false,
140
+ });
141
+
142
+ let make_tex = |label: &str| {
143
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
144
+ label: Some(label),
145
+ size: wgpu::Extent3d {
146
+ width: IMPULSE_SIZE, height: IMPULSE_SIZE,
147
+ depth_or_array_layers: 1,
148
+ },
149
+ mip_level_count: 1, sample_count: 1,
150
+ dimension: wgpu::TextureDimension::D2,
151
+ format: wgpu::TextureFormat::R32Float,
152
+ usage: wgpu::TextureUsages::TEXTURE_BINDING
153
+ | wgpu::TextureUsages::STORAGE_BINDING
154
+ | wgpu::TextureUsages::COPY_SRC,
155
+ view_formats: &[],
156
+ });
157
+ let view = tex.create_view(&Default::default());
158
+ (tex, view)
159
+ };
160
+ let (tex_a, view_a) = make_tex("impulse_field_a");
161
+ let (tex_b, view_b) = make_tex("impulse_field_b");
162
+
163
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
164
+ label: Some("impulse_field_sampler"),
165
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
166
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
167
+ mag_filter: wgpu::FilterMode::Nearest,
168
+ min_filter: wgpu::FilterMode::Nearest,
169
+ mipmap_filter: wgpu::MipmapFilterMode::Nearest,
170
+ ..Default::default()
171
+ });
172
+
173
+ Self {
174
+ pipeline, bg_layout, info_buf,
175
+ _tex_a: tex_a, _tex_b: tex_b, view_a, view_b, sampler,
176
+ front_is_a: true,
177
+ splats: Vec::with_capacity(MAX_SPLATS_PER_FRAME),
178
+ }
179
+ }
180
+
181
+ /// Gameplay API — queue a splat at world xz with given radius (m)
182
+ /// and peak strength. Silently drops overflow.
183
+ pub fn submit_splat(&mut self, world_x: f32, world_z: f32, radius: f32, strength: f32) {
184
+ if self.splats.len() >= MAX_SPLATS_PER_FRAME { return; }
185
+ self.splats.push(SplatData {
186
+ pos: [world_x, world_z], radius, strength,
187
+ });
188
+ }
189
+
190
+ /// Run the decay + splat compute pass. Caller encodes just before
191
+ /// the translucent pass so scene_inputs sees the latest field.
192
+ /// After this, `front_view()` returns the view containing the new
193
+ /// field and `update_scene_inputs` should bind it at group 4
194
+ /// binding 4.
195
+ pub fn update(&mut self, device: &wgpu::Device, queue: &wgpu::Queue,
196
+ encoder: &mut wgpu::CommandEncoder) {
197
+ // Ping-pong: read from the CURRENT front (last frame's output),
198
+ // write into the other side, then flip `front_is_a`.
199
+ let (src_view, dst_view) = if self.front_is_a {
200
+ (&self.view_a, &self.view_b)
201
+ } else {
202
+ (&self.view_b, &self.view_a)
203
+ };
204
+
205
+ // Build info UBO from the queued splats.
206
+ let mut info = InfoUniforms {
207
+ world_min: [-IMPULSE_WORLD_HALF_EXTENT, -IMPULSE_WORLD_HALF_EXTENT],
208
+ world_size: [IMPULSE_WORLD_HALF_EXTENT * 2.0, IMPULSE_WORLD_HALF_EXTENT * 2.0],
209
+ decay: IMPULSE_DECAY_PER_FRAME,
210
+ _pad0: 0.0,
211
+ splat_count: self.splats.len() as u32,
212
+ _pad1: 0,
213
+ splats: [SplatData { pos: [0.0; 2], radius: 0.0, strength: 0.0 };
214
+ MAX_SPLATS_PER_FRAME],
215
+ };
216
+ for (i, s) in self.splats.iter().enumerate() {
217
+ info.splats[i] = *s;
218
+ }
219
+ queue.write_buffer(&self.info_buf, 0, bytemuck::bytes_of(&info));
220
+ self.splats.clear();
221
+
222
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
223
+ label: Some("impulse_field_bg"),
224
+ layout: &self.bg_layout,
225
+ entries: &[
226
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(src_view) },
227
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(dst_view) },
228
+ wgpu::BindGroupEntry { binding: 2, resource: self.info_buf.as_entire_binding() },
229
+ ],
230
+ });
231
+
232
+ {
233
+ let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
234
+ label: Some("impulse_field_pass"),
235
+ timestamp_writes: None,
236
+ });
237
+ pass.set_pipeline(&self.pipeline);
238
+ pass.set_bind_group(0, &bg, &[]);
239
+ let groups = IMPULSE_SIZE / 8;
240
+ pass.dispatch_workgroups(groups, groups, 1);
241
+ }
242
+
243
+ self.front_is_a = !self.front_is_a;
244
+ }
245
+
246
+ /// The view containing the latest impulse field — what materials
247
+ /// should sample. Available AFTER `update` ran this frame.
248
+ pub fn front_view(&self) -> &wgpu::TextureView {
249
+ if self.front_is_a { &self.view_a } else { &self.view_b }
250
+ }
251
+
252
+ pub fn sampler(&self) -> &wgpu::Sampler { &self.sampler }
253
+ }
254
+
255
+ // =====================================================================
256
+ // Tests
257
+ // =====================================================================
258
+
259
+ #[cfg(test)]
260
+ mod tests {
261
+ use super::*;
262
+
263
+ /// Headless wgpu device for tests. Mirrors the helper in
264
+ /// `transient.rs` — falls back to a low-power adapter and returns
265
+ /// `None` when no adapter is available so CI runners without a GPU
266
+ /// skip the test instead of failing.
267
+ ///
268
+ /// The impulse field uses a `R32Float` storage texture (writeOnly).
269
+ /// `downlevel_defaults` does **not** allow R32Float storage, so we
270
+ /// request the explicit `Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`
271
+ /// upgrade. If the adapter doesn't expose it, return `None` — the
272
+ /// test then skips gracefully (matches the "skipped on adapters
273
+ /// without storage textures" doc-comment contract).
274
+ fn try_create_device() -> Option<(wgpu::Device, wgpu::Queue)> {
275
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
276
+ backends: wgpu::Backends::all(),
277
+ ..wgpu::InstanceDescriptor::new_without_display_handle()
278
+ });
279
+ let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
280
+ power_preference: wgpu::PowerPreference::LowPower,
281
+ compatible_surface: None,
282
+ force_fallback_adapter: true,
283
+ })).ok()?;
284
+ // Only proceed if the adapter advertises R32Float storage support.
285
+ let needed = wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
286
+ let supported = adapter.features();
287
+ if !supported.contains(needed) {
288
+ return None;
289
+ }
290
+ let (device, queue) = pollster::block_on(adapter.request_device(
291
+ &wgpu::DeviceDescriptor {
292
+ label: Some("impulse-test-device"),
293
+ required_features: needed,
294
+ required_limits: wgpu::Limits::downlevel_defaults(),
295
+ ..Default::default()
296
+ },
297
+ )).ok()?;
298
+ Some((device, queue))
299
+ }
300
+
301
+ /// Read back the entire impulse front texture as `IMPULSE_SIZE *
302
+ /// IMPULSE_SIZE` floats. R32Float = 4 B/texel, padded to 256 B per
303
+ /// row by wgpu's copy-to-buffer rules.
304
+ fn read_front_texture(
305
+ device: &wgpu::Device,
306
+ queue: &wgpu::Queue,
307
+ field: &ImpulseField,
308
+ ) -> Vec<f32> {
309
+ let bpr_unpadded = IMPULSE_SIZE * 4;
310
+ let bpr = (bpr_unpadded + 255) & !255;
311
+ let staging = device.create_buffer(&wgpu::BufferDescriptor {
312
+ label: Some("impulse_test_staging"),
313
+ size: (bpr * IMPULSE_SIZE) as u64,
314
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
315
+ mapped_at_creation: false,
316
+ });
317
+ // Pull the actual texture handle that backs `front_view`.
318
+ let front_tex = if field.front_is_a { &field._tex_a } else { &field._tex_b };
319
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
320
+ label: Some("impulse_test_readback"),
321
+ });
322
+ encoder.copy_texture_to_buffer(
323
+ wgpu::TexelCopyTextureInfo {
324
+ texture: front_tex,
325
+ mip_level: 0,
326
+ origin: wgpu::Origin3d::ZERO,
327
+ aspect: wgpu::TextureAspect::All,
328
+ },
329
+ wgpu::TexelCopyBufferInfo {
330
+ buffer: &staging,
331
+ layout: wgpu::TexelCopyBufferLayout {
332
+ offset: 0,
333
+ bytes_per_row: Some(bpr),
334
+ rows_per_image: Some(IMPULSE_SIZE),
335
+ },
336
+ },
337
+ wgpu::Extent3d { width: IMPULSE_SIZE, height: IMPULSE_SIZE, depth_or_array_layers: 1 },
338
+ );
339
+ queue.submit(std::iter::once(encoder.finish()));
340
+ let slice = staging.slice(..);
341
+ let (tx, rx) = std::sync::mpsc::channel();
342
+ slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
343
+ let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
344
+ rx.recv().expect("map_async sender dropped").expect("map failed");
345
+ let data = slice.get_mapped_range();
346
+ let mut out: Vec<f32> = Vec::with_capacity((IMPULSE_SIZE * IMPULSE_SIZE) as usize);
347
+ for row in 0..IMPULSE_SIZE {
348
+ let row_start = (row * bpr) as usize;
349
+ let row_end = row_start + (bpr_unpadded as usize);
350
+ let slice = &data[row_start..row_end];
351
+ let floats: &[f32] = bytemuck::cast_slice(slice);
352
+ out.extend_from_slice(floats);
353
+ }
354
+ drop(data);
355
+ staging.unmap();
356
+ out
357
+ }
358
+
359
+ /// Convert a world-XZ position into the impulse texel index that
360
+ /// owns it. Mirrors the WGSL `world_min + uv * world_size` math.
361
+ fn world_to_texel(x: f32, z: f32) -> (u32, u32) {
362
+ let w_min = -IMPULSE_WORLD_HALF_EXTENT;
363
+ let w_size = IMPULSE_WORLD_HALF_EXTENT * 2.0;
364
+ let u = ((x - w_min) / w_size).clamp(0.0, 1.0 - 1e-6);
365
+ let v = ((z - w_min) / w_size).clamp(0.0, 1.0 - 1e-6);
366
+ let tx = (u * IMPULSE_SIZE as f32) as u32;
367
+ let ty = (v * IMPULSE_SIZE as f32) as u32;
368
+ (tx, ty)
369
+ }
370
+
371
+ /// EN-006 — verifies that submitting a splat + dispatching the
372
+ /// impulse-field compute shader writes a non-zero value at the
373
+ /// expected texel and that the value decays monotonically across
374
+ /// subsequent frames with no new splats.
375
+ /// Skipped on adapters without `TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`
376
+ /// (R32Float storage textures).
377
+ #[test]
378
+ fn splat_appears_then_decays_across_frames() {
379
+ let Some((device, queue)) = try_create_device() else { return; };
380
+ let mut field = ImpulseField::new(&device);
381
+
382
+ // Submit a splat at world origin with a generous radius so it
383
+ // covers a couple of texels (the texture is 0.5 m / texel; a
384
+ // 2 m radius lights up a 4-texel disc).
385
+ field.submit_splat(0.0, 0.0, 2.0, 1.0);
386
+
387
+ // Dispatch frame 1.
388
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
389
+ label: Some("impulse_test_frame_1"),
390
+ });
391
+ field.update(&device, &queue, &mut encoder);
392
+ queue.submit(std::iter::once(encoder.finish()));
393
+
394
+ let pixels = read_front_texture(&device, &queue, &field);
395
+ let (tx, ty) = world_to_texel(0.0, 0.0);
396
+ let center_idx = (ty * IMPULSE_SIZE + tx) as usize;
397
+ let center_v1 = pixels[center_idx];
398
+ assert!(
399
+ center_v1 > 0.0,
400
+ "splat should write a positive value at the centre texel ({}, {}); got {}",
401
+ tx, ty, center_v1,
402
+ );
403
+
404
+ // A texel far outside the splat radius (16 m away ≫ 2 m radius)
405
+ // must remain zero — confirms the splat is localised.
406
+ let far_idx = {
407
+ let (fx, fy) = world_to_texel(16.0, 16.0);
408
+ (fy * IMPULSE_SIZE + fx) as usize
409
+ };
410
+ assert_eq!(pixels[far_idx], 0.0, "no splat far from origin");
411
+
412
+ // Dispatch a few more frames with no new splats. The value at
413
+ // the centre texel must decay strictly monotonically — the
414
+ // shader multiplies by IMPULSE_DECAY_PER_FRAME (~0.968) every
415
+ // frame.
416
+ let mut prev = center_v1;
417
+ for frame in 2..=4 {
418
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
419
+ label: Some("impulse_test_decay"),
420
+ });
421
+ field.update(&device, &queue, &mut encoder);
422
+ queue.submit(std::iter::once(encoder.finish()));
423
+ let pixels = read_front_texture(&device, &queue, &field);
424
+ let v = pixels[center_idx];
425
+ assert!(
426
+ v < prev,
427
+ "frame {} centre value {} should be < previous {}",
428
+ frame, v, prev,
429
+ );
430
+ assert!(v > 0.0, "frame {} centre value should still be positive (decay, not clear)", frame);
431
+ // Verify the multiplier is roughly the documented decay.
432
+ let ratio = v / prev;
433
+ assert!(
434
+ (ratio - IMPULSE_DECAY_PER_FRAME).abs() < 1e-3,
435
+ "frame {} ratio {} should match documented decay {}",
436
+ frame, ratio, IMPULSE_DECAY_PER_FRAME,
437
+ );
438
+ prev = v;
439
+ }
440
+ }
441
+
442
+ /// EN-006 — guard: `submit_splat` silently drops when the
443
+ /// per-frame queue is full. Pure CPU; runs everywhere.
444
+ #[test]
445
+ fn submit_splat_caps_at_max_per_frame() {
446
+ // Construct only the CPU-side state we need to assert on.
447
+ // Avoid `ImpulseField::new` here so this runs without a GPU.
448
+ let mut splats: Vec<SplatData> = Vec::with_capacity(MAX_SPLATS_PER_FRAME);
449
+ for _ in 0..(MAX_SPLATS_PER_FRAME + 5) {
450
+ if splats.len() < MAX_SPLATS_PER_FRAME {
451
+ splats.push(SplatData { pos: [0.0, 0.0], radius: 1.0, strength: 1.0 });
452
+ }
453
+ }
454
+ assert_eq!(splats.len(), MAX_SPLATS_PER_FRAME);
455
+ }
456
+ }
@@ -0,0 +1,154 @@
1
+ //! Group-1 lighting bind group — layout and construction.
2
+ //!
3
+ //! The scene + immediate-mode 3D pipelines share one bind-group layout
4
+ //! for lighting data: the Lighting UBO, env/IBL textures, the shadow
5
+ //! cascade, and (on clustered devices) the froxel buffers at bindings
6
+ //! 10-12. The bind group is rebuilt whenever the env source changes
7
+ //! (HDR load, panorama, procedural sky); every rebuild goes through
8
+ //! [`Renderer::make_lighting_bind_group`] so the entry list exists in
9
+ //! exactly one place and cannot drift between call sites.
10
+
11
+ use super::{froxel, Renderer};
12
+
13
+ /// Create the group-1 layout. `clustered` appends the froxel bindings —
14
+ /// set when [`froxel::FroxelPass::supported`] holds for the device.
15
+ /// Pipelines whose shaders don't reference bindings 10-12 (pipeline_3d)
16
+ /// share the layout unaffected; extra entries are legal as long as the
17
+ /// bind group provides them.
18
+ pub(super) fn create_lighting_layout(
19
+ device: &wgpu::Device,
20
+ clustered: bool,
21
+ ) -> wgpu::BindGroupLayout {
22
+ let tex_float = wgpu::BindingType::Texture {
23
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
24
+ view_dimension: wgpu::TextureViewDimension::D2,
25
+ multisampled: false,
26
+ };
27
+ let tex_depth = wgpu::BindingType::Texture {
28
+ sample_type: wgpu::TextureSampleType::Depth,
29
+ view_dimension: wgpu::TextureViewDimension::D2,
30
+ multisampled: false,
31
+ };
32
+ let frag = wgpu::ShaderStages::FRAGMENT;
33
+ let mut entries = vec![
34
+ // 0: Lighting UBO. VERTEX_FRAGMENT so the scene vertex shader can read
35
+ // `wind` (foliage sway); the fragment stage uses the full struct.
36
+ wgpu::BindGroupLayoutEntry {
37
+ binding: 0,
38
+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
39
+ ty: wgpu::BindingType::Buffer {
40
+ ty: wgpu::BufferBindingType::Uniform,
41
+ has_dynamic_offset: false,
42
+ min_binding_size: None,
43
+ },
44
+ count: None,
45
+ },
46
+ // 1/2: env (IBL specular) texture + sampler
47
+ wgpu::BindGroupLayoutEntry { binding: 1, visibility: frag, ty: tex_float, count: None },
48
+ wgpu::BindGroupLayoutEntry {
49
+ binding: 2,
50
+ visibility: frag,
51
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
52
+ count: None,
53
+ },
54
+ // 3/4: BRDF LUT + sampler
55
+ wgpu::BindGroupLayoutEntry { binding: 3, visibility: frag, ty: tex_float, count: None },
56
+ wgpu::BindGroupLayoutEntry {
57
+ binding: 4,
58
+ visibility: frag,
59
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
60
+ count: None,
61
+ },
62
+ // 5-7: shadow cascades, 8: comparison sampler
63
+ wgpu::BindGroupLayoutEntry { binding: 5, visibility: frag, ty: tex_depth, count: None },
64
+ wgpu::BindGroupLayoutEntry { binding: 6, visibility: frag, ty: tex_depth, count: None },
65
+ wgpu::BindGroupLayoutEntry { binding: 7, visibility: frag, ty: tex_depth, count: None },
66
+ wgpu::BindGroupLayoutEntry {
67
+ binding: 8,
68
+ visibility: frag,
69
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
70
+ count: None,
71
+ },
72
+ // 9: env diffuse (IBL irradiance)
73
+ wgpu::BindGroupLayoutEntry { binding: 9, visibility: frag, ty: tex_float, count: None },
74
+ ];
75
+ if clustered {
76
+ entries.extend(froxel::extra_lighting_layout_entries());
77
+ }
78
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
79
+ label: Some("lighting_layout"),
80
+ entries: &entries,
81
+ })
82
+ }
83
+
84
+ /// Everything a lighting bind group references besides the env views.
85
+ /// `Renderer::new` builds one from constructor locals (before `self`
86
+ /// exists); [`Renderer::make_lighting_bind_group`] from fields.
87
+ pub(super) struct LightingBindSources<'a> {
88
+ pub lighting_buffer: &'a wgpu::Buffer,
89
+ pub env_sampler: &'a wgpu::Sampler,
90
+ pub brdf_lut_view: &'a wgpu::TextureView,
91
+ pub brdf_lut_sampler: &'a wgpu::Sampler,
92
+ pub shadow_map: &'a crate::shadows::ShadowMap,
93
+ pub froxel: Option<&'a froxel::FroxelPass>,
94
+ }
95
+
96
+ /// The single source of truth for the group-1 entry list — every
97
+ /// lighting bind group the renderer ever creates goes through here.
98
+ pub(super) fn create_lighting_bind_group(
99
+ device: &wgpu::Device,
100
+ layout: &wgpu::BindGroupLayout,
101
+ label: &str,
102
+ src: &LightingBindSources<'_>,
103
+ env_view: &wgpu::TextureView,
104
+ diffuse_view: &wgpu::TextureView,
105
+ ) -> wgpu::BindGroup {
106
+ let mut entries = vec![
107
+ wgpu::BindGroupEntry { binding: 0, resource: src.lighting_buffer.as_entire_binding() },
108
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(env_view) },
109
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(src.env_sampler) },
110
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(src.brdf_lut_view) },
111
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(src.brdf_lut_sampler) },
112
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[0]) },
113
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[1]) },
114
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[2]) },
115
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&src.shadow_map.sampler) },
116
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(diffuse_view) },
117
+ ];
118
+ if let Some(f) = src.froxel {
119
+ entries.extend(f.extra_lighting_bind_entries());
120
+ }
121
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
122
+ label: Some(label),
123
+ layout,
124
+ entries: &entries,
125
+ })
126
+ }
127
+
128
+ impl Renderer {
129
+ /// Build a group-1 lighting bind group for the given env-specular /
130
+ /// env-diffuse views. Everything else (UBO, BRDF LUT, shadow
131
+ /// cascade, froxel buffers when clustered) comes from `self`.
132
+ pub(super) fn make_lighting_bind_group(
133
+ &self,
134
+ label: &str,
135
+ env_view: &wgpu::TextureView,
136
+ diffuse_view: &wgpu::TextureView,
137
+ ) -> wgpu::BindGroup {
138
+ create_lighting_bind_group(
139
+ &self.device,
140
+ &self.lighting_layout,
141
+ label,
142
+ &LightingBindSources {
143
+ lighting_buffer: &self.lighting_buffer,
144
+ env_sampler: &self.env_sampler,
145
+ brdf_lut_view: &self.brdf_lut_view,
146
+ brdf_lut_sampler: &self.brdf_lut_sampler,
147
+ shadow_map: &self.shadow_map,
148
+ froxel: self.froxel.as_ref(),
149
+ },
150
+ env_view,
151
+ diffuse_view,
152
+ )
153
+ }
154
+ }