@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,391 @@
1
+ //! EN-026 — CPU-simulated, GPU-instanced particle system.
2
+ //!
3
+ //! Why this lives in the engine rather than in game code: the game can already
4
+ //! build an instance buffer and draw it, but only by pushing every float
5
+ //! across the FFI one call at a time (`bloom_mesh_scratch_push_f32`). At 2 000
6
+ //! live particles that is ~24 000 FFI calls per frame — the shooter's entire
7
+ //! current per-frame FFI budget is ~240. So the *simulation* has to sit on the
8
+ //! native side, and the game/engine traffic becomes O(spawn events) instead of
9
+ //! O(particles): a burst is one call.
10
+ //!
11
+ //! What the engine owns: the pool, the integrator, and the packed instance
12
+ //! buffer. What the game owns: the material (so the look, the atlas and the
13
+ //! blend mode stay authorable in WGSL) and the draw call. A system is created
14
+ //! per *look* — smoke, sparks, blood, shells — because each wants its own
15
+ //! texture and blend bucket anyway, and one draw per look is cheap.
16
+ //!
17
+ //! Sim is deliberately simple and closed-form-ish: position, velocity,
18
+ //! gravity, linear drag, and a lifetime; size and colour are curves over
19
+ //! normalized age evaluated in the shader from `extra.x`. No collision, no
20
+ //! sorting (additive needs none, and the cutout bucket is depth-tested).
21
+
22
+ /// Everything about how one system's particles are born, move, and look.
23
+ /// Uploaded once from TS as a flat float array — see `configure_from_slice`.
24
+ #[derive(Clone, Copy)]
25
+ pub struct ParticleConfig {
26
+ pub life: f32,
27
+ pub life_var: f32,
28
+ /// Initial speed along the emit direction.
29
+ pub speed: f32,
30
+ pub speed_var: f32,
31
+ /// Half-angle of the emission cone, radians. `PI` = fully spherical.
32
+ pub spread: f32,
33
+ /// Constant acceleration (usually negative Y).
34
+ pub gravity: f32,
35
+ /// Linear drag coefficient (per second). 0 = vacuum.
36
+ pub drag: f32,
37
+ pub size0: f32,
38
+ pub size1: f32,
39
+ pub size_var: f32,
40
+ pub color0: [f32; 4],
41
+ pub color1: [f32; 4],
42
+ /// Roll speed, radians/sec (billboard spin).
43
+ pub spin: f32,
44
+ pub spin_var: f32,
45
+ /// Spawn positions are jittered inside a sphere of this radius.
46
+ pub pos_jitter: f32,
47
+ /// > 0 stretches the billboard along its velocity by this many seconds of
48
+ /// travel — the difference between a round spark and a tracer streak.
49
+ pub stretch: f32,
50
+ /// Fraction of the emitter's own velocity the particle inherits.
51
+ pub inherit: f32,
52
+ /// Number of atlas frames; the shader gets a frame index in `extra.y`.
53
+ pub frames: f32,
54
+ /// Bounce off the y = `floor_y` plane instead of passing through it.
55
+ /// `restitution` <= 0 disables (the default).
56
+ pub floor_y: f32,
57
+ pub restitution: f32,
58
+ }
59
+
60
+ impl Default for ParticleConfig {
61
+ fn default() -> Self {
62
+ Self {
63
+ life: 1.0, life_var: 0.0,
64
+ speed: 1.0, speed_var: 0.0,
65
+ spread: 0.3,
66
+ gravity: -9.81,
67
+ drag: 0.0,
68
+ size0: 0.2, size1: 0.2, size_var: 0.0,
69
+ color0: [1.0; 4], color1: [1.0, 1.0, 1.0, 0.0],
70
+ spin: 0.0, spin_var: 0.0,
71
+ pos_jitter: 0.0,
72
+ stretch: 0.0,
73
+ inherit: 0.0,
74
+ frames: 1.0,
75
+ floor_y: 0.0, restitution: 0.0,
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Structure-of-arrays pool. Dead particles are swap-removed from the live
81
+ /// prefix, so the live set is always `[0, live)` and the instance write is one
82
+ /// contiguous memcpy with no compaction pass.
83
+ pub struct ParticleSystem {
84
+ pub capacity: usize,
85
+ pub live: usize,
86
+ pub cfg: ParticleConfig,
87
+ /// GPU instance buffer handle (dynamic, capacity-sized).
88
+ pub instance_buffer: u32,
89
+
90
+ px: Vec<f32>, py: Vec<f32>, pz: Vec<f32>,
91
+ vx: Vec<f32>, vy: Vec<f32>, vz: Vec<f32>,
92
+ age: Vec<f32>, life: Vec<f32>,
93
+ rot: Vec<f32>, spin: Vec<f32>,
94
+ size: Vec<f32>, seed: Vec<f32>,
95
+
96
+ /// Packed 12-float-per-instance staging buffer, reused every frame.
97
+ packed: Vec<f32>,
98
+ rng: u32,
99
+ }
100
+
101
+ impl ParticleSystem {
102
+ pub fn new(capacity: usize, instance_buffer: u32) -> Self {
103
+ let z = || vec![0.0f32; capacity];
104
+ Self {
105
+ capacity,
106
+ live: 0,
107
+ cfg: ParticleConfig::default(),
108
+ instance_buffer,
109
+ px: z(), py: z(), pz: z(),
110
+ vx: z(), vy: z(), vz: z(),
111
+ age: z(), life: z(),
112
+ rot: z(), spin: z(),
113
+ size: z(), seed: z(),
114
+ packed: vec![0.0; capacity * 12],
115
+ rng: 0x9E3779B9,
116
+ }
117
+ }
118
+
119
+ #[inline]
120
+ fn rand(&mut self) -> f32 {
121
+ // xorshift32 — deterministic per system, which keeps a replayed burst
122
+ // identical frame-to-frame for screenshot diffing.
123
+ let mut x = self.rng;
124
+ x ^= x << 13;
125
+ x ^= x >> 17;
126
+ x ^= x << 5;
127
+ self.rng = x;
128
+ (x & 0x00FF_FFFF) as f32 / 16_777_215.0
129
+ }
130
+
131
+ /// Symmetric ±1 noise.
132
+ #[inline]
133
+ fn rand_s(&mut self) -> f32 { self.rand() * 2.0 - 1.0 }
134
+
135
+ pub fn configure_from_slice(&mut self, p: &[f32]) {
136
+ let g = |i: usize| -> f32 { p.get(i).copied().unwrap_or(0.0) };
137
+ self.cfg = ParticleConfig {
138
+ life: g(0).max(0.01), life_var: g(1),
139
+ speed: g(2), speed_var: g(3),
140
+ spread: g(4),
141
+ gravity: g(5),
142
+ drag: g(6),
143
+ size0: g(7), size1: g(8), size_var: g(9),
144
+ color0: [g(10), g(11), g(12), g(13)],
145
+ color1: [g(14), g(15), g(16), g(17)],
146
+ spin: g(18), spin_var: g(19),
147
+ pos_jitter: g(20),
148
+ stretch: g(21),
149
+ inherit: g(22),
150
+ frames: g(23).max(1.0),
151
+ floor_y: g(24),
152
+ restitution: g(25),
153
+ };
154
+ }
155
+
156
+ /// Spawn `count` particles at `pos`, biased along `dir`. A zero `dir`
157
+ /// means "no preferred direction" and emits into the full sphere, which is
158
+ /// what a blood burst or an explosion core wants; a surface normal gives a
159
+ /// cone, which is what an impact wants.
160
+ pub fn emit(&mut self, pos: [f32; 3], dir: [f32; 3], count: usize) {
161
+ let c = self.cfg;
162
+ let dlen = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
163
+ let base = if dlen > 1e-5 {
164
+ [dir[0] / dlen, dir[1] / dlen, dir[2] / dlen]
165
+ } else {
166
+ [0.0, 1.0, 0.0]
167
+ };
168
+ // Basis around the emit direction, for the cone sample.
169
+ let up = if base[1].abs() > 0.99 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
170
+ let right = normalize(cross(up, base));
171
+ let realup = cross(base, right);
172
+
173
+ let spread = if dlen > 1e-5 { c.spread } else { std::f32::consts::PI };
174
+
175
+ for _ in 0..count {
176
+ if self.live >= self.capacity {
177
+ // Full: overwrite the oldest-looking slot rather than dropping
178
+ // the spawn. A burst you asked for should always be visible;
179
+ // it is the *stale* particle that is expendable.
180
+ let victim = (self.rand() * self.capacity as f32) as usize % self.capacity.max(1);
181
+ self.age[victim] = self.life[victim];
182
+ self.kill(victim);
183
+ }
184
+ let i = self.live;
185
+ self.live += 1;
186
+
187
+ // Cone sample: cosine-ish, biased toward the axis.
188
+ let a = self.rand() * std::f32::consts::TAU;
189
+ let t = self.rand().sqrt() * spread;
190
+ let (st, ct) = (t.sin(), t.cos());
191
+ let d = [
192
+ base[0] * ct + (right[0] * a.cos() + realup[0] * a.sin()) * st,
193
+ base[1] * ct + (right[1] * a.cos() + realup[1] * a.sin()) * st,
194
+ base[2] * ct + (right[2] * a.cos() + realup[2] * a.sin()) * st,
195
+ ];
196
+ let sp = (c.speed + self.rand_s() * c.speed_var).max(0.0);
197
+
198
+ let jx = self.rand_s() * c.pos_jitter;
199
+ let jy = self.rand_s() * c.pos_jitter;
200
+ let jz = self.rand_s() * c.pos_jitter;
201
+
202
+ self.px[i] = pos[0] + jx;
203
+ self.py[i] = pos[1] + jy;
204
+ self.pz[i] = pos[2] + jz;
205
+ self.vx[i] = d[0] * sp;
206
+ self.vy[i] = d[1] * sp;
207
+ self.vz[i] = d[2] * sp;
208
+ self.age[i] = 0.0;
209
+ self.life[i] = (c.life + self.rand_s() * c.life_var).max(0.02);
210
+ self.rot[i] = self.rand() * std::f32::consts::TAU;
211
+ self.spin[i] = c.spin + self.rand_s() * c.spin_var;
212
+ self.size[i] = 1.0 + self.rand_s() * c.size_var;
213
+ self.seed[i] = self.rand();
214
+ }
215
+ }
216
+
217
+ #[inline]
218
+ fn kill(&mut self, i: usize) {
219
+ let last = self.live - 1;
220
+ if i != last {
221
+ self.px.swap(i, last); self.py.swap(i, last); self.pz.swap(i, last);
222
+ self.vx.swap(i, last); self.vy.swap(i, last); self.vz.swap(i, last);
223
+ self.age.swap(i, last); self.life.swap(i, last);
224
+ self.rot.swap(i, last); self.spin.swap(i, last);
225
+ self.size.swap(i, last); self.seed.swap(i, last);
226
+ }
227
+ self.live = last;
228
+ }
229
+
230
+ /// Integrate one step and repack. Returns the live count to draw.
231
+ pub fn update(&mut self, dt: f32) -> u32 {
232
+ let c = self.cfg;
233
+ let dt = dt.clamp(0.0, 0.1); // a hitch must not teleport the sim
234
+
235
+ let mut i = 0usize;
236
+ while i < self.live {
237
+ self.age[i] += dt;
238
+ if self.age[i] >= self.life[i] {
239
+ self.kill(i);
240
+ continue; // a live particle was swapped into i — re-test it
241
+ }
242
+ // Semi-implicit Euler + exponential drag.
243
+ self.vy[i] += c.gravity * dt;
244
+ if c.drag > 0.0 {
245
+ let k = (1.0 - c.drag * dt).max(0.0);
246
+ self.vx[i] *= k; self.vy[i] *= k; self.vz[i] *= k;
247
+ }
248
+ self.px[i] += self.vx[i] * dt;
249
+ self.py[i] += self.vy[i] * dt;
250
+ self.pz[i] += self.vz[i] * dt;
251
+
252
+ if c.restitution > 0.0 && self.py[i] < c.floor_y {
253
+ self.py[i] = c.floor_y;
254
+ self.vy[i] = -self.vy[i] * c.restitution;
255
+ // Kill the horizontal skid too, or shells slide forever.
256
+ self.vx[i] *= 0.6;
257
+ self.vz[i] *= 0.6;
258
+ if self.vy[i].abs() < 0.4 { self.vy[i] = 0.0; }
259
+ }
260
+
261
+ self.rot[i] += self.spin[i] * dt;
262
+ i += 1;
263
+ }
264
+
265
+ // Pack. Layout must match InstanceData3D: pos.xyz, rot_y, scale,
266
+ // tint.rgba, extra.xyz.
267
+ for i in 0..self.live {
268
+ let t = (self.age[i] / self.life[i]).clamp(0.0, 1.0);
269
+ let size = lerp(c.size0, c.size1, t) * self.size[i];
270
+ let col = [
271
+ lerp(c.color0[0], c.color1[0], t),
272
+ lerp(c.color0[1], c.color1[1], t),
273
+ lerp(c.color0[2], c.color1[2], t),
274
+ lerp(c.color0[3], c.color1[3], t),
275
+ ];
276
+ let frame = if c.frames > 1.0 {
277
+ (t * c.frames).floor().min(c.frames - 1.0)
278
+ } else { 0.0 };
279
+ // Velocity-stretch length in metres — the shader elongates the
280
+ // quad along the projected velocity by this much.
281
+ let stretch = if c.stretch > 0.0 {
282
+ let v = (self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i] + self.vz[i] * self.vz[i]).sqrt();
283
+ v * c.stretch
284
+ } else { 0.0 };
285
+
286
+ let o = i * 12;
287
+ self.packed[o] = self.px[i];
288
+ self.packed[o + 1] = self.py[i];
289
+ self.packed[o + 2] = self.pz[i];
290
+ self.packed[o + 3] = self.rot[i];
291
+ self.packed[o + 4] = size;
292
+ self.packed[o + 5] = col[0];
293
+ self.packed[o + 6] = col[1];
294
+ self.packed[o + 7] = col[2];
295
+ self.packed[o + 8] = col[3];
296
+ self.packed[o + 9] = t; // extra.x — normalized age
297
+ self.packed[o + 10] = frame; // extra.y — atlas frame
298
+ self.packed[o + 11] = stretch; // extra.z — stretch metres
299
+ }
300
+ self.live as u32
301
+ }
302
+
303
+ pub fn packed(&self) -> &[f32] { &self.packed }
304
+
305
+ pub fn clear(&mut self) { self.live = 0; }
306
+ }
307
+
308
+ #[inline] fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
309
+ #[inline] fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
310
+ [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]]
311
+ }
312
+ #[inline] fn normalize(v: [f32; 3]) -> [f32; 3] {
313
+ let l = (v[0]*v[0] + v[1]*v[1] + v[2]*v[2]).sqrt();
314
+ if l > 1e-6 { [v[0]/l, v[1]/l, v[2]/l] } else { [1.0, 0.0, 0.0] }
315
+ }
316
+
317
+ /// Registry of all systems. Handles are 1-based; 0 is "no system".
318
+ pub struct ParticleManager {
319
+ pub systems: Vec<Option<ParticleSystem>>,
320
+ }
321
+
322
+ impl ParticleManager {
323
+ pub fn new() -> Self { Self { systems: Vec::new() } }
324
+
325
+ pub fn create(&mut self, capacity: usize, instance_buffer: u32) -> u32 {
326
+ self.systems.push(Some(ParticleSystem::new(capacity, instance_buffer)));
327
+ self.systems.len() as u32
328
+ }
329
+
330
+ pub fn get_mut(&mut self, handle: u32) -> Option<&mut ParticleSystem> {
331
+ if handle == 0 { return None; }
332
+ self.systems.get_mut(handle as usize - 1)?.as_mut()
333
+ }
334
+ }
335
+
336
+ impl Default for ParticleManager {
337
+ fn default() -> Self { Self::new() }
338
+ }
339
+
340
+ #[cfg(test)]
341
+ mod tests {
342
+ use super::*;
343
+
344
+ fn sys() -> ParticleSystem { ParticleSystem::new(64, 1) }
345
+
346
+ #[test]
347
+ fn emit_then_expire() {
348
+ let mut s = sys();
349
+ s.configure_from_slice(&[0.5, 0.0, 1.0, 0.0, 0.3, 0.0, 0.0, 1.0, 1.0, 0.0]);
350
+ s.emit([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 10);
351
+ assert_eq!(s.live, 10);
352
+ assert_eq!(s.update(0.1), 10);
353
+ // Past the 0.5 s lifetime everything must be reclaimed — the swap-remove
354
+ // loop has to re-test the swapped-in particle or it leaks half the pool.
355
+ for _ in 0..10 { s.update(0.1); }
356
+ assert_eq!(s.live, 0);
357
+ }
358
+
359
+ #[test]
360
+ fn capacity_is_never_exceeded() {
361
+ let mut s = sys();
362
+ s.configure_from_slice(&[10.0]);
363
+ s.emit([0.0; 3], [0.0, 1.0, 0.0], 500);
364
+ assert_eq!(s.live, 64);
365
+ assert_eq!(s.update(0.016), 64);
366
+ }
367
+
368
+ #[test]
369
+ fn gravity_pulls_down() {
370
+ let mut s = sys();
371
+ // life 10, speed 0, gravity -10
372
+ s.configure_from_slice(&[10.0, 0.0, 0.0, 0.0, 0.0, -10.0]);
373
+ s.emit([0.0, 5.0, 0.0], [0.0, 1.0, 0.0], 1);
374
+ s.update(0.1);
375
+ assert!(s.py[0] < 5.0, "expected fall, got y={}", s.py[0]);
376
+ }
377
+
378
+ #[test]
379
+ fn floor_bounce_reverses_velocity() {
380
+ let mut s = sys();
381
+ let mut p = vec![0.0f32; 26];
382
+ p[0] = 10.0; // life
383
+ p[5] = -10.0; // gravity
384
+ p[24] = 0.0; // floor_y
385
+ p[25] = 0.5; // restitution
386
+ s.configure_from_slice(&p);
387
+ s.emit([0.0, 0.1, 0.0], [0.0, -1.0, 0.0], 1);
388
+ for _ in 0..20 { s.update(0.016); }
389
+ assert!(s.py[0] >= 0.0, "particle sank through the floor: y={}", s.py[0]);
390
+ }
391
+ }