@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,1093 @@
1
+ use crate::handles::HandleRegistry;
2
+ use crate::renderer::Vertex3D;
3
+ use std::sync::Arc;
4
+
5
+ #[cfg(debug_assertions)]
6
+ static DEBUG_PRINTED: std::sync::Once = std::sync::Once::new();
7
+
8
+ pub struct MeshData {
9
+ pub vertices: Vec<Vertex3D>,
10
+ pub indices: Vec<u32>,
11
+ pub texture_idx: Option<u32>,
12
+ pub normal_texture_idx: Option<u32>,
13
+ pub metallic_roughness_texture_idx: Option<u32>,
14
+ pub emissive_texture_idx: Option<u32>,
15
+ pub occlusion_texture_idx: Option<u32>,
16
+ pub metallic_factor: f32,
17
+ pub roughness_factor: f32,
18
+ pub emissive_factor: [f32; 3],
19
+ /// glTF alpha cutoff for MASK mode — fragments with base-colour
20
+ /// alpha below this are discarded. 0.0 means OPAQUE mode (no
21
+ /// discard); glTF spec default for MASK is 0.5. BLEND mode is
22
+ /// currently treated as MASK @ 0.5 since we don't have a sorted
23
+ /// transparent pipeline yet.
24
+ pub alpha_cutoff: f32,
25
+ }
26
+
27
+ pub struct ModelData {
28
+ pub meshes: Vec<MeshData>,
29
+ pub bbox_min: [f32; 3],
30
+ pub bbox_max: [f32; 3],
31
+ }
32
+
33
+ pub struct JointData {
34
+ pub inverse_bind: [[f32; 4]; 4],
35
+ pub children: Vec<usize>,
36
+ pub name: String,
37
+ pub rest_translation: [f32; 3],
38
+ pub rest_rotation: [f32; 4],
39
+ pub rest_scale: [f32; 3],
40
+ }
41
+
42
+ pub struct AnimationChannel {
43
+ pub joint_index: usize,
44
+ pub timestamps: Vec<f32>,
45
+ pub translations: Vec<[f32; 3]>,
46
+ pub rotation_timestamps: Vec<f32>,
47
+ pub rotations: Vec<[f32; 4]>,
48
+ pub scale_timestamps: Vec<f32>,
49
+ pub scales: Vec<[f32; 3]>,
50
+ }
51
+
52
+ pub struct AnimationData {
53
+ pub channels: Vec<AnimationChannel>,
54
+ pub duration: f32,
55
+ pub name: String,
56
+ }
57
+
58
+ pub struct SkeletonData {
59
+ pub joints: Vec<JointData>,
60
+ pub root_joints: Vec<usize>,
61
+ }
62
+
63
+ pub use crate::anim_mixer::AnimMixer;
64
+
65
+ // glTF/GLB parsing lives in models_gltf.rs, models3d-gated so builds
66
+ // without the feature drop the gltf/image_dds deps (EN-014, re-fixed by
67
+ // EN-063). Everything else in this file compiles in every configuration.
68
+ #[cfg(feature = "models3d")]
69
+ #[path = "models_gltf.rs"]
70
+ mod gltf_load;
71
+ #[cfg(feature = "models3d")]
72
+ pub use gltf_load::load_gltf_staged;
73
+
74
+ pub struct ModelAnimation {
75
+ /// EN-055 — the parsed clip data (skeleton, keyframe tracks, rest
76
+ /// rotations) is IMMUTABLE after load and shared between instances via
77
+ /// `Arc`: `instantiate_animation` clones the handles, not the data. Only
78
+ /// the fields below the shared block are per-instance state.
79
+ pub skeleton: Option<Arc<SkeletonData>>,
80
+ pub animations: Arc<Vec<AnimationData>>,
81
+ /// Reference rest-pose rotations (from first animation, sampled at t=0).
82
+ /// Used for retargeting when multiple armatures have different rest orientations.
83
+ pub ref_rest_rotations: Option<Arc<Vec<[f32; 4]>>>,
84
+ // ---- per-instance state from here down ---------------------------------
85
+ pub joint_matrices: Vec<[[f32; 4]; 4]>,
86
+ /// EN-028 mixer state.
87
+ pub mixer: AnimMixer,
88
+ /// EN-033 — joint world transforms *before* the inverse-bind multiply.
89
+ /// `joint_matrices` is skinning-space and useless for attaching props;
90
+ /// this is the model-space transform a socket actually wants.
91
+ pub joint_world: Vec<[[f32; 4]; 4]>,
92
+ /// Cached per-joint layer weights, rebuilt when `layer_mask_root` changes.
93
+ pub mask_weights: Vec<f32>,
94
+ pub mask_cached_root: i32,
95
+ }
96
+
97
+ pub struct ModelManager {
98
+ pub models: HandleRegistry<ModelData>,
99
+ pub animations: HandleRegistry<ModelAnimation>,
100
+ /// Scratch buffers for the array-free mesh-upload path. Perry 0.5.1171
101
+ /// rejects passing a JS `number[]` to a native `i64` pointer param
102
+ /// (strict safe-integer check), so `createMesh` instead pushes vertex
103
+ /// floats / indices one scalar at a time through `mesh_scratch_push_*`
104
+ /// (all `f64` ABI) and then builds the mesh from these. Mirrors the
105
+ /// physics subsystem's `scratch_*` shape-upload path.
106
+ pub scratch_f32: Vec<f32>,
107
+ pub scratch_u32: Vec<u32>,
108
+ }
109
+
110
+ impl ModelManager {
111
+ pub fn new() -> Self {
112
+ Self {
113
+ models: HandleRegistry::new(),
114
+ animations: HandleRegistry::new(),
115
+ scratch_f32: Vec::new(),
116
+ scratch_u32: Vec::new(),
117
+ }
118
+ }
119
+
120
+ pub fn mesh_scratch_reset(&mut self) {
121
+ self.scratch_f32.clear();
122
+ self.scratch_u32.clear();
123
+ }
124
+ pub fn mesh_scratch_push_f32(&mut self, v: f32) { self.scratch_f32.push(v); }
125
+ pub fn mesh_scratch_push_u32(&mut self, v: u32) { self.scratch_u32.push(v); }
126
+
127
+ /// Build a mesh from the scratch buffers: `vertex_count` vertices of 12
128
+ /// floats each in `scratch_f32`, `index_count` indices in `scratch_u32`.
129
+ pub fn create_mesh_from_scratch(&mut self, vertex_count: u32, index_count: u32) -> f64 {
130
+ let need_f = vertex_count as usize * 12;
131
+ let need_u = index_count as usize;
132
+ if vertex_count == 0 || self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u {
133
+ return 0.0;
134
+ }
135
+ // Clone out so create_mesh's &self borrow doesn't alias scratch.
136
+ let verts: Vec<f32> = self.scratch_f32[..need_f].to_vec();
137
+ let inds: Vec<u32> = self.scratch_u32[..need_u].to_vec();
138
+ self.create_mesh(&verts, &inds)
139
+ }
140
+
141
+ /// Read-only view of the f32 scratch buffer, for consumers that lay their
142
+ /// own data out in it (the spline ribbon packs positions then widths).
143
+ pub fn scratch_floats(&self) -> &[f32] {
144
+ &self.scratch_f32
145
+ }
146
+
147
+ /// Take the scratch buffers as raw vertex floats + indices, for callers
148
+ /// that build something other than a Model out of them (the scene graph's
149
+ /// `update_geometry`). Same 12-floats-per-vertex layout as
150
+ /// `create_mesh_from_scratch`; returns None if the scratch is short.
151
+ pub fn take_scratch_geometry(
152
+ &self,
153
+ vertex_count: u32,
154
+ index_count: u32,
155
+ ) -> Option<(Vec<f32>, Vec<u32>)> {
156
+ let need_f = vertex_count as usize * 12;
157
+ let need_u = index_count as usize;
158
+ if vertex_count == 0 || self.scratch_f32.len() < need_f || self.scratch_u32.len() < need_u {
159
+ return None;
160
+ }
161
+ Some((
162
+ self.scratch_f32[..need_f].to_vec(),
163
+ self.scratch_u32[..need_u].to_vec(),
164
+ ))
165
+ }
166
+
167
+ #[cfg(feature = "models3d")]
168
+ pub fn load_model(&mut self, file_data: &[u8]) -> f64 {
169
+ match gltf_load::load_gltf(file_data) {
170
+ Some(model) => self.models.alloc(model),
171
+ None => 0.0,
172
+ }
173
+ }
174
+
175
+ #[cfg(feature = "models3d")]
176
+ pub fn load_model_with_textures(&mut self, file_data: &[u8], renderer: &mut crate::renderer::Renderer) -> f64 {
177
+ match gltf_load::load_gltf_with_textures(file_data, renderer, None) {
178
+ Some(model) => self.models.alloc(model),
179
+ None => 0.0,
180
+ }
181
+ }
182
+
183
+ /// Like `load_model_with_textures` but also resolves external `.bin`
184
+ /// and image URIs relative to `base_dir` — required for loose glTF
185
+ /// files (as opposed to single-file .glb). Intel Sponza etc.
186
+ #[cfg(feature = "models3d")]
187
+ pub fn load_model_with_textures_from_path(
188
+ &mut self,
189
+ file_data: &[u8],
190
+ base_dir: &std::path::Path,
191
+ renderer: &mut crate::renderer::Renderer,
192
+ ) -> f64 {
193
+ match gltf_load::load_gltf_with_textures(file_data, renderer, Some(base_dir)) {
194
+ Some(model) => self.models.alloc(model),
195
+ None => 0.0,
196
+ }
197
+ }
198
+
199
+ pub fn get(&self, handle: f64) -> Option<&ModelData> {
200
+ self.models.get(handle)
201
+ }
202
+
203
+ /// Return the axis-aligned bounding box of a loaded model as
204
+ /// `(min_xyz, max_xyz)`. Used by editors to size move/rotate gizmos,
205
+ /// auto-frame the camera on selection, and snap placed entities onto
206
+ /// terrain. Returns the origin for unknown handles so callers can read
207
+ /// without checking for None.
208
+ pub fn get_bounds(&self, handle: f64) -> ([f32; 3], [f32; 3]) {
209
+ match self.models.get(handle) {
210
+ Some(model) => (model.bbox_min, model.bbox_max),
211
+ None => ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
212
+ }
213
+ }
214
+
215
+ /// EN-025 — the ragdoll writes joint matrices directly, bypassing the
216
+ /// sampler entirely: once a thing is dead, physics owns its pose.
217
+ pub fn get_animation_mut(&mut self, handle: f64) -> Option<&mut ModelAnimation> {
218
+ self.animations.get_mut(handle)
219
+ }
220
+
221
+ pub fn get_animation(&self, handle: f64) -> Option<&ModelAnimation> {
222
+ self.animations.get(handle)
223
+ }
224
+
225
+ pub fn unload_model(&mut self, handle: f64) {
226
+ self.models.free(handle);
227
+ }
228
+
229
+ pub fn gen_mesh_cube(&mut self, w: f32, h: f32, d: f32) -> f64 {
230
+ let hw = w * 0.5;
231
+ let hh = h * 0.5;
232
+ let hd = d * 0.5;
233
+ let white = [1.0, 1.0, 1.0, 1.0];
234
+
235
+ #[rustfmt::skip]
236
+ let faces: &[([f32; 3], [f32; 3], [f32; 2])] = &[
237
+ // Front face (+Z)
238
+ ([-hw, -hh, hd], [0.0, 0.0, 1.0], [0.0, 1.0]),
239
+ ([ hw, -hh, hd], [0.0, 0.0, 1.0], [1.0, 1.0]),
240
+ ([ hw, hh, hd], [0.0, 0.0, 1.0], [1.0, 0.0]),
241
+ ([-hw, hh, hd], [0.0, 0.0, 1.0], [0.0, 0.0]),
242
+ // Back face (-Z)
243
+ ([ hw, -hh, -hd], [0.0, 0.0, -1.0], [0.0, 1.0]),
244
+ ([-hw, -hh, -hd], [0.0, 0.0, -1.0], [1.0, 1.0]),
245
+ ([-hw, hh, -hd], [0.0, 0.0, -1.0], [1.0, 0.0]),
246
+ ([ hw, hh, -hd], [0.0, 0.0, -1.0], [0.0, 0.0]),
247
+ // Right face (+X)
248
+ ([ hw, -hh, hd], [1.0, 0.0, 0.0], [0.0, 1.0]),
249
+ ([ hw, -hh, -hd], [1.0, 0.0, 0.0], [1.0, 1.0]),
250
+ ([ hw, hh, -hd], [1.0, 0.0, 0.0], [1.0, 0.0]),
251
+ ([ hw, hh, hd], [1.0, 0.0, 0.0], [0.0, 0.0]),
252
+ // Left face (-X)
253
+ ([-hw, -hh, -hd], [-1.0, 0.0, 0.0], [0.0, 1.0]),
254
+ ([-hw, -hh, hd], [-1.0, 0.0, 0.0], [1.0, 1.0]),
255
+ ([-hw, hh, hd], [-1.0, 0.0, 0.0], [1.0, 0.0]),
256
+ ([-hw, hh, -hd], [-1.0, 0.0, 0.0], [0.0, 0.0]),
257
+ // Top face (+Y)
258
+ ([-hw, hh, hd], [0.0, 1.0, 0.0], [0.0, 1.0]),
259
+ ([ hw, hh, hd], [0.0, 1.0, 0.0], [1.0, 1.0]),
260
+ ([ hw, hh, -hd], [0.0, 1.0, 0.0], [1.0, 0.0]),
261
+ ([-hw, hh, -hd], [0.0, 1.0, 0.0], [0.0, 0.0]),
262
+ // Bottom face (-Y)
263
+ ([-hw, -hh, -hd], [0.0, -1.0, 0.0], [0.0, 1.0]),
264
+ ([ hw, -hh, -hd], [0.0, -1.0, 0.0], [1.0, 1.0]),
265
+ ([ hw, -hh, hd], [0.0, -1.0, 0.0], [1.0, 0.0]),
266
+ ([-hw, -hh, hd], [0.0, -1.0, 0.0], [0.0, 0.0]),
267
+ ];
268
+
269
+ let vertices: Vec<Vertex3D> = faces.iter().map(|(pos, norm, uv)| Vertex3D {
270
+ position: *pos,
271
+ normal: *norm,
272
+ color: white,
273
+ uv: *uv,
274
+ joints: [0.0; 4],
275
+ weights: [0.0; 4],
276
+ tangent: [0.0; 4],
277
+ }).collect();
278
+
279
+ let mut indices = Vec::with_capacity(36);
280
+ for face in 0..6u32 {
281
+ let base = face * 4;
282
+ indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
283
+ }
284
+
285
+ let model = ModelData {
286
+ meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }],
287
+ bbox_min: [-hw, -hh, -hd],
288
+ bbox_max: [hw, hh, hd],
289
+ };
290
+ self.models.alloc(model)
291
+ }
292
+
293
+ pub fn gen_mesh_heightmap(&mut self, image_data: &[u8], img_w: u32, img_h: u32, size_x: f32, size_y: f32, size_z: f32) -> f64 {
294
+ let cols = img_w as usize;
295
+ let rows = img_h as usize;
296
+ if cols < 2 || rows < 2 { return 0.0; }
297
+
298
+ let mut vertices = Vec::with_capacity(cols * rows);
299
+ let white = [1.0, 1.0, 1.0, 1.0];
300
+
301
+ for z in 0..rows {
302
+ for x in 0..cols {
303
+ let pixel_idx = (z * cols + x) * 4;
304
+ let luminance = if pixel_idx + 2 < image_data.len() {
305
+ (image_data[pixel_idx] as f32 * 0.299
306
+ + image_data[pixel_idx + 1] as f32 * 0.587
307
+ + image_data[pixel_idx + 2] as f32 * 0.114) / 255.0
308
+ } else {
309
+ 0.0
310
+ };
311
+
312
+ let px = (x as f32 / (cols - 1) as f32 - 0.5) * size_x;
313
+ let py = luminance * size_y;
314
+ let pz = (z as f32 / (rows - 1) as f32 - 0.5) * size_z;
315
+ let u = x as f32 / (cols - 1) as f32;
316
+ let v = z as f32 / (rows - 1) as f32;
317
+
318
+ vertices.push(Vertex3D {
319
+ position: [px, py, pz],
320
+ normal: [0.0, 1.0, 0.0],
321
+ color: white,
322
+ uv: [u, v],
323
+ joints: [0.0; 4],
324
+ weights: [0.0; 4],
325
+ tangent: [0.0; 4],
326
+ });
327
+ }
328
+ }
329
+
330
+ // Compute normals from neighboring heights
331
+ for z in 0..rows {
332
+ for x in 0..cols {
333
+ let idx = z * cols + x;
334
+ let left = if x > 0 { vertices[z * cols + x - 1].position[1] } else { vertices[idx].position[1] };
335
+ let right = if x < cols - 1 { vertices[z * cols + x + 1].position[1] } else { vertices[idx].position[1] };
336
+ let up = if z > 0 { vertices[(z - 1) * cols + x].position[1] } else { vertices[idx].position[1] };
337
+ let down = if z < rows - 1 { vertices[(z + 1) * cols + x].position[1] } else { vertices[idx].position[1] };
338
+ let sx = size_x / (cols - 1) as f32;
339
+ let sz = size_z / (rows - 1) as f32;
340
+ let nx = (left - right) / (2.0 * sx);
341
+ let nz = (up - down) / (2.0 * sz);
342
+ let len = (nx * nx + 1.0 + nz * nz).sqrt();
343
+ vertices[idx].normal = [nx / len, 1.0 / len, nz / len];
344
+ }
345
+ }
346
+
347
+ let mut indices = Vec::with_capacity((cols - 1) * (rows - 1) * 6);
348
+ for z in 0..rows - 1 {
349
+ for x in 0..cols - 1 {
350
+ let tl = (z * cols + x) as u32;
351
+ let tr = tl + 1;
352
+ let bl = ((z + 1) * cols + x) as u32;
353
+ let br = bl + 1;
354
+ indices.extend_from_slice(&[tl, bl, tr, tr, bl, br]);
355
+ }
356
+ }
357
+
358
+ let model = ModelData {
359
+ meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }],
360
+ bbox_min: [-size_x * 0.5, 0.0, -size_z * 0.5],
361
+ bbox_max: [size_x * 0.5, size_y, size_z * 0.5],
362
+ };
363
+ self.models.alloc(model)
364
+ }
365
+
366
+ /// Create a mesh from raw float data passed from TS.
367
+ /// vertex_data layout: [x,y,z, nx,ny,nz, r,g,b,a, u,v] per vertex (12 floats each)
368
+ pub fn create_mesh(&mut self, vertex_data: &[f32], index_data: &[u32]) -> f64 {
369
+ let floats_per_vert = 12;
370
+ let vert_count = vertex_data.len() / floats_per_vert;
371
+ if vert_count == 0 { return 0.0; }
372
+
373
+ let mut vertices = Vec::with_capacity(vert_count);
374
+ let mut bbox_min = [f32::MAX; 3];
375
+ let mut bbox_max = [f32::MIN; 3];
376
+
377
+ for i in 0..vert_count {
378
+ let o = i * floats_per_vert;
379
+ let pos = [vertex_data[o], vertex_data[o+1], vertex_data[o+2]];
380
+ for k in 0..3 {
381
+ if pos[k] < bbox_min[k] { bbox_min[k] = pos[k]; }
382
+ if pos[k] > bbox_max[k] { bbox_max[k] = pos[k]; }
383
+ }
384
+ vertices.push(Vertex3D {
385
+ position: pos,
386
+ normal: [vertex_data[o+3], vertex_data[o+4], vertex_data[o+5]],
387
+ color: [vertex_data[o+6], vertex_data[o+7], vertex_data[o+8], vertex_data[o+9]],
388
+ uv: [vertex_data[o+10], vertex_data[o+11]],
389
+ joints: [0.0; 4],
390
+ weights: [0.0; 4],
391
+ tangent: [0.0; 4],
392
+ });
393
+ }
394
+
395
+ let indices = index_data.to_vec();
396
+ let model = ModelData {
397
+ meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }],
398
+ bbox_min,
399
+ bbox_max,
400
+ };
401
+ self.models.alloc(model)
402
+ }
403
+
404
+ /// Q9: Generate a ribbon mesh along a Catmull-Rom spline. Used by the
405
+ /// editor's river tool. `points` is flat [x0,y0,z0, x1,y1,z1, ...],
406
+ /// `widths` has one width per control point.
407
+ pub fn gen_mesh_spline_ribbon(&mut self, points: &[f32], widths: &[f32]) -> f64 {
408
+ let n = points.len() / 3;
409
+ if n < 2 || widths.len() < n { return 0.0; }
410
+
411
+ // Evaluate Catmull-Rom at fine intervals.
412
+ let segments = (n - 1) * 8; // 8 subdivisions per segment.
413
+ let mut center_pts: Vec<[f32; 3]> = Vec::with_capacity(segments + 1);
414
+ let mut center_widths: Vec<f32> = Vec::with_capacity(segments + 1);
415
+
416
+ for i in 0..n - 1 {
417
+ for sub in 0..8 {
418
+ let t = sub as f32 / 8.0;
419
+ let p = catmull_rom_point(points, n, i, t);
420
+ let w = widths[i] * (1.0 - t) + widths[i + 1] * t;
421
+ center_pts.push(p);
422
+ center_widths.push(w);
423
+ }
424
+ }
425
+ // Add the last point.
426
+ let last = n - 1;
427
+ center_pts.push([points[last * 3], points[last * 3 + 1], points[last * 3 + 2]]);
428
+ center_widths.push(widths[last]);
429
+
430
+ // Build ribbon vertices (two per center point: left and right).
431
+ let ribbon_len = center_pts.len();
432
+ let mut vertices = Vec::with_capacity(ribbon_len * 2);
433
+ let mut bbox_min = [f32::MAX; 3];
434
+ let mut bbox_max = [f32::MIN; 3];
435
+ let white = [0.3, 0.5, 0.8, 0.7]; // Water-blue tint.
436
+
437
+ for i in 0..ribbon_len {
438
+ // Tangent direction.
439
+ let tangent = if i < ribbon_len - 1 {
440
+ let dx = center_pts[i + 1][0] - center_pts[i][0];
441
+ let dz = center_pts[i + 1][2] - center_pts[i][2];
442
+ let len = (dx * dx + dz * dz).sqrt().max(1e-6);
443
+ [dx / len, dz / len]
444
+ } else if i > 0 {
445
+ let dx = center_pts[i][0] - center_pts[i - 1][0];
446
+ let dz = center_pts[i][2] - center_pts[i - 1][2];
447
+ let len = (dx * dx + dz * dz).sqrt().max(1e-6);
448
+ [dx / len, dz / len]
449
+ } else {
450
+ [0.0, 1.0]
451
+ };
452
+
453
+ // Perpendicular in XZ plane (rotate tangent 90 degrees).
454
+ let perp = [-tangent[1], tangent[0]];
455
+ let hw = center_widths[i] * 0.5;
456
+ let cp = center_pts[i];
457
+ let u = i as f32 / (ribbon_len - 1).max(1) as f32;
458
+
459
+ // Left vertex.
460
+ let lx = cp[0] + perp[0] * hw;
461
+ let ly = cp[1];
462
+ let lz = cp[2] + perp[1] * hw;
463
+ update_bounds(&mut bbox_min, &mut bbox_max, lx, ly, lz);
464
+ vertices.push(Vertex3D {
465
+ position: [lx, ly, lz],
466
+ normal: [0.0, 1.0, 0.0],
467
+ color: white,
468
+ uv: [u, 0.0],
469
+ joints: [0.0; 4],
470
+ weights: [0.0; 4],
471
+ tangent: [0.0; 4],
472
+ });
473
+
474
+ // Right vertex.
475
+ let rx = cp[0] - perp[0] * hw;
476
+ let ry = cp[1];
477
+ let rz = cp[2] - perp[1] * hw;
478
+ update_bounds(&mut bbox_min, &mut bbox_max, rx, ry, rz);
479
+ vertices.push(Vertex3D {
480
+ position: [rx, ry, rz],
481
+ normal: [0.0, 1.0, 0.0],
482
+ color: white,
483
+ uv: [u, 1.0],
484
+ joints: [0.0; 4],
485
+ weights: [0.0; 4],
486
+ tangent: [0.0; 4],
487
+ });
488
+ }
489
+
490
+ // Triangle strip indices.
491
+ let mut indices = Vec::with_capacity((ribbon_len - 1) * 6);
492
+ for i in 0..(ribbon_len - 1) as u32 {
493
+ let bl = i * 2;
494
+ let br = bl + 1;
495
+ let tl = bl + 2;
496
+ let tr = bl + 3;
497
+ indices.extend_from_slice(&[bl, tl, br, br, tl, tr]);
498
+ }
499
+
500
+ if vertices.is_empty() {
501
+ bbox_min = [0.0; 3];
502
+ bbox_max = [0.0; 3];
503
+ }
504
+
505
+ let model = ModelData {
506
+ meshes: vec![MeshData { vertices, indices, texture_idx: None, normal_texture_idx: None, metallic_roughness_texture_idx: None, emissive_texture_idx: None, occlusion_texture_idx: None, metallic_factor: 0.0, roughness_factor: 1.0, emissive_factor: [0.0; 3], alpha_cutoff: 0.0 }],
507
+ bbox_min,
508
+ bbox_max,
509
+ };
510
+ self.models.alloc(model)
511
+ }
512
+
513
+ #[cfg(feature = "models3d")]
514
+ pub fn load_model_animation(&mut self, file_data: &[u8]) -> f64 {
515
+ match gltf_load::load_gltf_animation(file_data) {
516
+ Some(anim) => self.animations.alloc(anim),
517
+ None => 0.0,
518
+ }
519
+ }
520
+
521
+ /// EN-055 — a new animation INSTANCE over an already-loaded clip set.
522
+ /// The parsed data (skeleton, keyframe tracks, rest rotations) is shared
523
+ /// via `Arc`; the mixer, joint matrices and mask cache are fresh — so N
524
+ /// characters get independent clocks/fades without N GLB re-parses
525
+ /// (which was 5.5 s of the shooter's 8 s boot). Returns 0 for a dead
526
+ /// source handle, mirroring load's failure convention.
527
+ pub fn instantiate_animation(&mut self, src: f64) -> f64 {
528
+ let inst = match self.animations.get(src) {
529
+ Some(a) => ModelAnimation {
530
+ skeleton: a.skeleton.clone(),
531
+ animations: a.animations.clone(),
532
+ ref_rest_rotations: a.ref_rest_rotations.clone(),
533
+ joint_matrices: a.joint_matrices.clone(),
534
+ mixer: AnimMixer::default(),
535
+ joint_world: a.joint_world.clone(),
536
+ mask_weights: vec![0.0; a.mask_weights.len()],
537
+ mask_cached_root: -1,
538
+ },
539
+ None => return 0.0,
540
+ };
541
+ self.animations.alloc(inst)
542
+ }
543
+
544
+ pub fn update_model_animation(&mut self, handle: f64, anim_index: usize, time: f32) {
545
+ if let Some(model_anim) = self.animations.get_mut(handle) {
546
+ let skeleton = match &model_anim.skeleton {
547
+ Some(s) => s,
548
+ None => return,
549
+ };
550
+ if anim_index >= model_anim.animations.len() { return; }
551
+ #[cfg(debug_assertions)]
552
+ let joint_count = skeleton.joints.len();
553
+
554
+ let pose = sample_local_pose(skeleton, &model_anim.animations[anim_index], time, true);
555
+ model_anim.apply_pose(&pose);
556
+
557
+ #[cfg(debug_assertions)]
558
+ {
559
+ if joint_count > 0 {
560
+ DEBUG_PRINTED.call_once(|| {
561
+ eprintln!(
562
+ "[anim] joints={}, t={:.3}, anim_index={}",
563
+ joint_count, time, anim_index
564
+ );
565
+ });
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ // ---- EN-028 mixer -----------------------------------------------------
572
+
573
+ /// Start a transition to `clip`. Re-requesting the clip already playing is
574
+ /// a no-op, so game code can call this unconditionally every frame ("I
575
+ /// want to be walking") instead of tracking edges itself — which is how
576
+ /// this gets used in practice and where the pops came from before.
577
+ pub fn anim_play(&mut self, handle: f64, clip: usize, fade: f32, speed: f32, looping: bool) {
578
+ if let Some(ma) = self.animations.get_mut(handle) {
579
+ if clip >= ma.animations.len() { return; }
580
+ let m = &mut ma.mixer;
581
+ // Re-requesting the clip that is ALREADY current is a no-op by
582
+ // contract ("safe to call every frame with the clip you want"), and
583
+ // that has to hold DURING a fade too — a fade is a fade *to*
584
+ // cur_clip, so the right thing is to let it finish.
585
+ //
586
+ // This used to also require `m.fade_dur <= 0.0`. A caller doing the
587
+ // documented thing then fell through to the restart path on every
588
+ // frame of the fade: it re-seeded the fade (fade_t = 0, so the fade
589
+ // could never complete) and reset cur_time = 0. The base clip was
590
+ // pinned at t≈0 forever, from the first clip change onward — every
591
+ // animated character in a game froze the moment it stopped idling.
592
+ if m.started && m.cur_clip == clip {
593
+ m.cur_speed = speed;
594
+ m.cur_loop = looping;
595
+ return;
596
+ }
597
+ if m.started && fade > 0.0 {
598
+ m.prev_clip = m.cur_clip;
599
+ m.prev_time = m.cur_time;
600
+ m.prev_speed = m.cur_speed;
601
+ m.prev_loop = m.cur_loop;
602
+ m.fade_dur = fade;
603
+ m.fade_t = 0.0;
604
+ } else {
605
+ m.fade_dur = 0.0;
606
+ m.fade_t = 0.0;
607
+ }
608
+ m.cur_clip = clip;
609
+ m.cur_time = 0.0;
610
+ m.cur_speed = speed;
611
+ m.cur_loop = looping;
612
+ m.finished = false;
613
+ m.started = true;
614
+ }
615
+ }
616
+
617
+ /// Masked layer: `clip` drives every joint at or below `mask_root`,
618
+ /// blended in by `weight`. weight <= 0 (or clip < 0) turns it off.
619
+ pub fn anim_set_layer(&mut self, handle: f64, clip: i32, weight: f32, mask_root: i32, speed: f32, looping: bool) {
620
+ if let Some(ma) = self.animations.get_mut(handle) {
621
+ let m = &mut ma.mixer;
622
+ let off = clip < 0 || weight <= 0.0 || (clip as usize) >= ma.animations.len();
623
+ if off {
624
+ m.layer_clip = -1;
625
+ m.layer_weight = 0.0;
626
+ return;
627
+ }
628
+ if m.layer_clip != clip {
629
+ m.layer_time = 0.0;
630
+ m.layer_clip = clip;
631
+ }
632
+ m.layer_weight = weight.clamp(0.0, 1.0);
633
+ m.layer_mask_root = mask_root;
634
+ m.layer_speed = speed;
635
+ m.layer_loop = looping;
636
+ }
637
+ }
638
+
639
+ pub fn anim_set_root_motion(&mut self, handle: f64, on: bool) {
640
+ if let Some(ma) = self.animations.get_mut(handle) {
641
+ ma.mixer.root_motion = on;
642
+ ma.mixer.root_delta = [0.0; 3];
643
+ }
644
+ }
645
+
646
+ pub fn anim_finished(&self, handle: f64) -> bool {
647
+ self.animations.get(handle).map(|m| m.mixer.finished).unwrap_or(true)
648
+ }
649
+
650
+ pub fn anim_clip_duration(&self, handle: f64, clip: usize) -> f32 {
651
+ self.animations.get(handle)
652
+ .and_then(|m| m.animations.get(clip))
653
+ .map(|a| a.duration)
654
+ .unwrap_or(0.0)
655
+ }
656
+
657
+ pub fn anim_root_delta(&self, handle: f64) -> [f32; 3] {
658
+ self.animations.get(handle).map(|m| m.mixer.root_delta).unwrap_or([0.0; 3])
659
+ }
660
+
661
+ pub fn find_joint(&self, handle: f64, name: &str) -> i32 {
662
+ if let Some(ma) = self.animations.get(handle) {
663
+ if let Some(sk) = &ma.skeleton {
664
+ for (i, j) in sk.joints.iter().enumerate() {
665
+ if j.name == name { return i as i32; }
666
+ }
667
+ // Fall back to a case-insensitive contains match: exporters
668
+ // decorate names ("mixamorig:Hand_R", "Bip01 R Hand") often
669
+ // enough that an exact match is the exception, not the rule.
670
+ let want = name.to_ascii_lowercase();
671
+ for (i, j) in sk.joints.iter().enumerate() {
672
+ if j.name.to_ascii_lowercase().contains(&want) { return i as i32; }
673
+ }
674
+ }
675
+ }
676
+ -1
677
+ }
678
+
679
+ /// Model-space transform of a joint (EN-033 sockets). Valid after the
680
+ /// frame's `advance_and_update`.
681
+ pub fn joint_world(&self, handle: f64, joint: usize) -> Option<[[f32; 4]; 4]> {
682
+ self.animations.get(handle)
683
+ .and_then(|m| m.joint_world.get(joint))
684
+ .copied()
685
+ }
686
+
687
+ /// Advance every mixer clock by `dt` and rebuild the pose. One call per
688
+ /// model per frame; the game never touches clip time.
689
+ pub fn advance_and_update(&mut self, handle: f64, dt: f32) {
690
+ if let Some(ma) = self.animations.get_mut(handle) {
691
+ if ma.skeleton.is_none() || ma.animations.is_empty() { return; }
692
+ if !ma.mixer.started { ma.mixer.started = true; }
693
+
694
+ // --- advance clocks
695
+ let cur_dur = ma.animations.get(ma.mixer.cur_clip).map(|a| a.duration).unwrap_or(0.0);
696
+ let t_before = ma.mixer.cur_time;
697
+ let t_raw = ma.mixer.cur_time + dt * ma.mixer.cur_speed;
698
+ let mut wrapped = false;
699
+ ma.mixer.cur_time = if cur_dur <= 0.0 {
700
+ 0.0
701
+ } else if ma.mixer.cur_loop {
702
+ if t_raw >= cur_dur { wrapped = true; }
703
+ t_raw.rem_euclid(cur_dur)
704
+ } else if t_raw >= cur_dur {
705
+ ma.mixer.finished = true;
706
+ cur_dur
707
+ } else {
708
+ t_raw
709
+ };
710
+
711
+ if ma.mixer.fade_dur > 0.0 {
712
+ let prev_dur = ma.animations.get(ma.mixer.prev_clip).map(|a| a.duration).unwrap_or(0.0);
713
+ let pt = ma.mixer.prev_time + dt * ma.mixer.prev_speed;
714
+ ma.mixer.prev_time = if prev_dur <= 0.0 {
715
+ 0.0
716
+ } else if ma.mixer.prev_loop {
717
+ pt.rem_euclid(prev_dur)
718
+ } else {
719
+ pt.min(prev_dur)
720
+ };
721
+ ma.mixer.fade_t += dt;
722
+ if ma.mixer.fade_t >= ma.mixer.fade_dur {
723
+ ma.mixer.fade_dur = 0.0;
724
+ ma.mixer.fade_t = 0.0;
725
+ }
726
+ }
727
+
728
+ if ma.mixer.layer_clip >= 0 {
729
+ let li = ma.mixer.layer_clip as usize;
730
+ let ldur = ma.animations.get(li).map(|a| a.duration).unwrap_or(0.0);
731
+ let lt = ma.mixer.layer_time + dt * ma.mixer.layer_speed;
732
+ ma.mixer.layer_time = if ldur <= 0.0 {
733
+ 0.0
734
+ } else if ma.mixer.layer_loop {
735
+ lt.rem_euclid(ldur)
736
+ } else {
737
+ lt.min(ldur)
738
+ };
739
+ }
740
+
741
+ // --- sample + blend the base track
742
+ let skel = ma.skeleton.as_ref().unwrap();
743
+ let strip_root = !ma.mixer.root_motion;
744
+ let mut pose = sample_local_pose(skel, &ma.animations[ma.mixer.cur_clip], ma.mixer.cur_time, strip_root);
745
+
746
+ if ma.mixer.fade_dur > 0.0 {
747
+ let w = (ma.mixer.fade_t / ma.mixer.fade_dur).clamp(0.0, 1.0);
748
+ // Smoothstep the fade — a linear pose blend still reads as a
749
+ // slope discontinuity at both ends of the transition.
750
+ let w = w * w * (3.0 - 2.0 * w);
751
+ let prev = sample_local_pose(skel, &ma.animations[ma.mixer.prev_clip], ma.mixer.prev_time, strip_root);
752
+ blend_pose(&mut pose, &prev, 1.0 - w, None);
753
+ }
754
+
755
+ // --- root motion: delta of the root joint's authored translation
756
+ if ma.mixer.root_motion && !skel.joints.is_empty() {
757
+ let cd = cur_dur;
758
+ let anim = &ma.animations[ma.mixer.cur_clip];
759
+ let p_now = root_translation_at(skel, anim, ma.mixer.cur_time);
760
+ let p_old = root_translation_at(skel, anim, t_before);
761
+ let d = if wrapped && cd > 0.0 {
762
+ let p_end = root_translation_at(skel, anim, cd);
763
+ let p_start = root_translation_at(skel, anim, 0.0);
764
+ [
765
+ (p_end[0] - p_old[0]) + (p_now[0] - p_start[0]),
766
+ (p_end[1] - p_old[1]) + (p_now[1] - p_start[1]),
767
+ (p_end[2] - p_old[2]) + (p_now[2] - p_start[2]),
768
+ ]
769
+ } else {
770
+ [p_now[0] - p_old[0], p_now[1] - p_old[1], p_now[2] - p_old[2]]
771
+ };
772
+ ma.mixer.root_delta = d;
773
+ // The delta is handed to the character controller, so the pose
774
+ // itself must not also carry it or the model double-moves.
775
+ pose.0[0] = skel.joints[0].rest_translation;
776
+ } else {
777
+ ma.mixer.root_delta = [0.0; 3];
778
+ }
779
+
780
+ // --- masked layer over the top
781
+ if ma.mixer.layer_clip >= 0 && ma.mixer.layer_weight > 0.0 {
782
+ let root = ma.mixer.layer_mask_root;
783
+ if ma.mask_cached_root != root {
784
+ ma.mask_weights = build_mask_weights(skel, root);
785
+ ma.mask_cached_root = root;
786
+ }
787
+ let li = ma.mixer.layer_clip as usize;
788
+ let lpose = sample_local_pose(skel, &ma.animations[li], ma.mixer.layer_time, true);
789
+ let w = ma.mixer.layer_weight;
790
+ let mask = ma.mask_weights.clone();
791
+ blend_pose(&mut pose, &lpose, w, Some(&mask));
792
+ }
793
+
794
+ ma.apply_pose(&pose);
795
+ }
796
+ }
797
+ }
798
+
799
+ type LocalPose = (Vec<[f32; 3]>, Vec<[f32; 4]>, Vec<[f32; 3]>);
800
+
801
+ impl ModelAnimation {
802
+ /// Local TRS pose -> world transforms -> skinning matrices. Keeps the
803
+ /// world transforms around too, because that is what sockets read.
804
+ fn apply_pose(&mut self, pose: &LocalPose) {
805
+ let skeleton = match &self.skeleton { Some(s) => s, None => return };
806
+ let joint_count = skeleton.joints.len();
807
+ if self.joint_matrices.len() != joint_count {
808
+ self.joint_matrices = vec![mat4_identity(); joint_count];
809
+ }
810
+ if self.joint_world.len() != joint_count {
811
+ self.joint_world = vec![mat4_identity(); joint_count];
812
+ }
813
+ let mut world = vec![mat4_identity(); joint_count];
814
+ for &root in &skeleton.root_joints {
815
+ compute_joint_transforms(skeleton, root, &mat4_identity(), &pose.0, &pose.1, &pose.2, &mut world);
816
+ }
817
+ for i in 0..joint_count {
818
+ self.joint_matrices[i] = mat4_mul(&world[i], &skeleton.joints[i].inverse_bind);
819
+ }
820
+ self.joint_world.copy_from_slice(&world);
821
+ }
822
+ }
823
+
824
+ /// Sample one clip into a local TRS pose, rest pose as the fallback for
825
+ /// joints the clip does not animate.
826
+ fn sample_local_pose(skeleton: &SkeletonData, anim: &AnimationData, time: f32, strip_root: bool) -> LocalPose {
827
+ let joint_count = skeleton.joints.len();
828
+ let mut t: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_translation).collect();
829
+ let mut r: Vec<[f32; 4]> = skeleton.joints.iter().map(|j| j.rest_rotation).collect();
830
+ let mut s: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_scale).collect();
831
+
832
+ let time = if anim.duration > 0.0 { time.rem_euclid(anim.duration) } else { 0.0 };
833
+
834
+ for channel in &anim.channels {
835
+ let ji = channel.joint_index;
836
+ if ji >= joint_count { continue; }
837
+ if !channel.translations.is_empty() && !channel.timestamps.is_empty() {
838
+ t[ji] = sample_vec3(&channel.timestamps, &channel.translations, time);
839
+ }
840
+ if !channel.rotations.is_empty() {
841
+ let ts = if !channel.rotation_timestamps.is_empty() { &channel.rotation_timestamps } else { &channel.timestamps };
842
+ if !ts.is_empty() { r[ji] = sample_quat(ts, &channel.rotations, time); }
843
+ }
844
+ if !channel.scales.is_empty() {
845
+ let ts = if !channel.scale_timestamps.is_empty() { &channel.scale_timestamps } else { &channel.timestamps };
846
+ if !ts.is_empty() { s[ji] = sample_vec3(ts, &channel.scales, time); }
847
+ }
848
+ }
849
+
850
+ if strip_root && joint_count > 0 {
851
+ t[0] = skeleton.joints[0].rest_translation;
852
+ }
853
+ (t, r, s)
854
+ }
855
+
856
+ /// The root joint's authored translation at `time` — the raw channel value,
857
+ /// *not* the rest-locked one, which is the whole point of root motion.
858
+ fn root_translation_at(skeleton: &SkeletonData, anim: &AnimationData, time: f32) -> [f32; 3] {
859
+ if skeleton.joints.is_empty() { return [0.0; 3]; }
860
+ let time = if anim.duration > 0.0 { time.clamp(0.0, anim.duration) } else { 0.0 };
861
+ for channel in &anim.channels {
862
+ if channel.joint_index == 0 && !channel.translations.is_empty() && !channel.timestamps.is_empty() {
863
+ return sample_vec3(&channel.timestamps, &channel.translations, time);
864
+ }
865
+ }
866
+ skeleton.joints[0].rest_translation
867
+ }
868
+
869
+ /// `dst = lerp(dst, src, w * mask[j])`. Rotations use nlerp with a
870
+ /// hemisphere fix — without the dot-sign flip, two clips whose quaternions
871
+ /// land on opposite hemispheres blend the *long* way round and the limb
872
+ /// visibly swings through the body.
873
+ fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]>) {
874
+ let n = dst.0.len().min(src.0.len());
875
+ for j in 0..n {
876
+ let jw = match mask {
877
+ Some(m) => w * m.get(j).copied().unwrap_or(0.0),
878
+ None => w,
879
+ };
880
+ if jw <= 0.0 { continue; }
881
+ let jw = jw.min(1.0);
882
+ for k in 0..3 {
883
+ dst.0[j][k] = dst.0[j][k] + (src.0[j][k] - dst.0[j][k]) * jw;
884
+ dst.2[j][k] = dst.2[j][k] + (src.2[j][k] - dst.2[j][k]) * jw;
885
+ }
886
+ let a = dst.1[j];
887
+ let mut b = src.1[j];
888
+ let dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
889
+ if dot < 0.0 { b = [-b[0], -b[1], -b[2], -b[3]]; }
890
+ let mut q = [
891
+ a[0] + (b[0] - a[0]) * jw,
892
+ a[1] + (b[1] - a[1]) * jw,
893
+ a[2] + (b[2] - a[2]) * jw,
894
+ a[3] + (b[3] - a[3]) * jw,
895
+ ];
896
+ let len = (q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]).sqrt();
897
+ if len > 1e-6 {
898
+ q = [q[0]/len, q[1]/len, q[2]/len, q[3]/len];
899
+ } else {
900
+ q = a;
901
+ }
902
+ dst.1[j] = q;
903
+ }
904
+ }
905
+
906
+ /// 1.0 for every joint at or below `root`, 0.0 elsewhere. `root < 0` means
907
+ /// "whole skeleton" so a layer with no mask is a plain full-body override.
908
+ fn build_mask_weights(skeleton: &SkeletonData, root: i32) -> Vec<f32> {
909
+ let n = skeleton.joints.len();
910
+ if root < 0 || (root as usize) >= n { return vec![1.0; n]; }
911
+ let mut w = vec![0.0f32; n];
912
+ let mut stack = vec![root as usize];
913
+ while let Some(j) = stack.pop() {
914
+ if j >= n || w[j] > 0.0 { continue; }
915
+ w[j] = 1.0;
916
+ for &c in &skeleton.joints[j].children { stack.push(c); }
917
+ }
918
+ w
919
+ }
920
+
921
+ // ============================================================
922
+ // Matrix / quaternion helpers for skeletal animation
923
+ // ============================================================
924
+
925
+ fn mat4_identity() -> [[f32; 4]; 4] {
926
+ [
927
+ [1.0, 0.0, 0.0, 0.0],
928
+ [0.0, 1.0, 0.0, 0.0],
929
+ [0.0, 0.0, 1.0, 0.0],
930
+ [0.0, 0.0, 0.0, 1.0],
931
+ ]
932
+ }
933
+
934
+ fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
935
+ let mut out = [[0.0f32; 4]; 4];
936
+ for col in 0..4 {
937
+ for row in 0..4 {
938
+ out[col][row] = a[0][row]*b[col][0] + a[1][row]*b[col][1] + a[2][row]*b[col][2] + a[3][row]*b[col][3];
939
+ }
940
+ }
941
+ out
942
+ }
943
+
944
+ fn mat4_from_trs(t: &[f32; 3], r: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] {
945
+ let (x, y, z, w) = (r[0], r[1], r[2], r[3]);
946
+ let x2 = x + x; let y2 = y + y; let z2 = z + z;
947
+ let xx = x * x2; let xy = x * y2; let xz = x * z2;
948
+ let yy = y * y2; let yz = y * z2; let zz = z * z2;
949
+ let wx = w * x2; let wy = w * y2; let wz = w * z2;
950
+
951
+ // Column-major: m[col][row]
952
+ [
953
+ [(1.0 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0.0], // column 0
954
+ [(xy - wz) * s[1], (1.0 - (xx + zz)) * s[1], (yz + wx) * s[1], 0.0], // column 1
955
+ [(xz + wy) * s[2], (yz - wx) * s[2], (1.0 - (xx + yy)) * s[2], 0.0], // column 2
956
+ [t[0], t[1], t[2], 1.0], // column 3 (translation)
957
+ ]
958
+ }
959
+
960
+ fn quat_slerp(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] {
961
+ let mut dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
962
+ let mut b2 = *b;
963
+ if dot < 0.0 {
964
+ dot = -dot;
965
+ b2 = [-b[0], -b[1], -b[2], -b[3]];
966
+ }
967
+ if dot > 0.9995 {
968
+ let mut out = [
969
+ a[0] + t * (b2[0] - a[0]),
970
+ a[1] + t * (b2[1] - a[1]),
971
+ a[2] + t * (b2[2] - a[2]),
972
+ a[3] + t * (b2[3] - a[3]),
973
+ ];
974
+ let len = (out[0]*out[0] + out[1]*out[1] + out[2]*out[2] + out[3]*out[3]).sqrt();
975
+ if len > 0.0 { for v in &mut out { *v /= len; } }
976
+ return out;
977
+ }
978
+ let theta = dot.acos();
979
+ let sin_theta = theta.sin();
980
+ let wa = ((1.0 - t) * theta).sin() / sin_theta;
981
+ let wb = (t * theta).sin() / sin_theta;
982
+ [
983
+ wa * a[0] + wb * b2[0],
984
+ wa * a[1] + wb * b2[1],
985
+ wa * a[2] + wb * b2[2],
986
+ wa * a[3] + wb * b2[3],
987
+ ]
988
+ }
989
+
990
+ fn lerp_vec3(a: &[f32; 3], b: &[f32; 3], t: f32) -> [f32; 3] {
991
+ [
992
+ a[0] + t * (b[0] - a[0]),
993
+ a[1] + t * (b[1] - a[1]),
994
+ a[2] + t * (b[2] - a[2]),
995
+ ]
996
+ }
997
+
998
+ fn find_keyframe_pair(timestamps: &[f32], time: f32) -> (usize, usize, f32) {
999
+ if timestamps.len() <= 1 {
1000
+ return (0, 0, 0.0);
1001
+ }
1002
+ if time <= timestamps[0] {
1003
+ return (0, 0, 0.0);
1004
+ }
1005
+ if time >= timestamps[timestamps.len() - 1] {
1006
+ let last = timestamps.len() - 1;
1007
+ return (last, last, 0.0);
1008
+ }
1009
+ for i in 0..timestamps.len() - 1 {
1010
+ if time >= timestamps[i] && time < timestamps[i + 1] {
1011
+ let dt = timestamps[i + 1] - timestamps[i];
1012
+ let t = if dt > 0.0 { (time - timestamps[i]) / dt } else { 0.0 };
1013
+ return (i, i + 1, t);
1014
+ }
1015
+ }
1016
+ let last = timestamps.len() - 1;
1017
+ (last, last, 0.0)
1018
+ }
1019
+
1020
+ fn sample_vec3(timestamps: &[f32], values: &[[f32; 3]], time: f32) -> [f32; 3] {
1021
+ if values.is_empty() { return [0.0; 3]; }
1022
+ if values.len() == 1 { return values[0]; }
1023
+ let (i0, i1, t) = find_keyframe_pair(timestamps, time);
1024
+ if i0 >= values.len() { return values[values.len() - 1]; }
1025
+ if i1 >= values.len() { return values[values.len() - 1]; }
1026
+ lerp_vec3(&values[i0], &values[i1], t)
1027
+ }
1028
+
1029
+ fn sample_quat(timestamps: &[f32], values: &[[f32; 4]], time: f32) -> [f32; 4] {
1030
+ if values.is_empty() { return [0.0, 0.0, 0.0, 1.0]; }
1031
+ if values.len() == 1 { return values[0]; }
1032
+ let (i0, i1, t) = find_keyframe_pair(timestamps, time);
1033
+ if i0 >= values.len() { return values[values.len() - 1]; }
1034
+ if i1 >= values.len() { return values[values.len() - 1]; }
1035
+ quat_slerp(&values[i0], &values[i1], t)
1036
+ }
1037
+
1038
+ fn compute_joint_transforms(
1039
+ skeleton: &SkeletonData,
1040
+ joint_idx: usize,
1041
+ parent_transform: &[[f32; 4]; 4],
1042
+ translations: &[[f32; 3]],
1043
+ rotations: &[[f32; 4]],
1044
+ scales: &[[f32; 3]],
1045
+ world_transforms: &mut [[[f32; 4]; 4]],
1046
+ ) {
1047
+ if joint_idx >= skeleton.joints.len() { return; }
1048
+ let local = mat4_from_trs(&translations[joint_idx], &rotations[joint_idx], &scales[joint_idx]);
1049
+ let world = mat4_mul(parent_transform, &local);
1050
+ world_transforms[joint_idx] = world;
1051
+ let children = skeleton.joints[joint_idx].children.clone();
1052
+ for &child in &children {
1053
+ compute_joint_transforms(skeleton, child, &world, translations, rotations, scales, world_transforms);
1054
+ }
1055
+ }
1056
+
1057
+ // ---- Catmull-Rom spline helpers (Q9) ----
1058
+
1059
+ fn catmull_rom_point(points: &[f32], n: usize, segment: usize, t: f32) -> [f32; 3] {
1060
+ // Indices: p0 = segment - 1, p1 = segment, p2 = segment + 1, p3 = segment + 2.
1061
+ // Clamp at boundaries.
1062
+ let i0 = if segment > 0 { segment - 1 } else { 0 };
1063
+ let i1 = segment;
1064
+ let i2 = if segment + 1 < n { segment + 1 } else { n - 1 };
1065
+ let i3 = if segment + 2 < n { segment + 2 } else { n - 1 };
1066
+
1067
+ let p0 = [points[i0 * 3], points[i0 * 3 + 1], points[i0 * 3 + 2]];
1068
+ let p1 = [points[i1 * 3], points[i1 * 3 + 1], points[i1 * 3 + 2]];
1069
+ let p2 = [points[i2 * 3], points[i2 * 3 + 1], points[i2 * 3 + 2]];
1070
+ let p3 = [points[i3 * 3], points[i3 * 3 + 1], points[i3 * 3 + 2]];
1071
+
1072
+ let t2 = t * t;
1073
+ let t3 = t2 * t;
1074
+ let mut out = [0.0f32; 3];
1075
+ for k in 0..3 {
1076
+ out[k] = 0.5 * (
1077
+ (2.0 * p1[k]) +
1078
+ (-p0[k] + p2[k]) * t +
1079
+ (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 +
1080
+ (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3
1081
+ );
1082
+ }
1083
+ out
1084
+ }
1085
+
1086
+ fn update_bounds(bmin: &mut [f32; 3], bmax: &mut [f32; 3], x: f32, y: f32, z: f32) {
1087
+ if x < bmin[0] { bmin[0] = x; }
1088
+ if y < bmin[1] { bmin[1] = y; }
1089
+ if z < bmin[2] { bmin[2] = z; }
1090
+ if x > bmax[0] { bmax[0] = x; }
1091
+ if y > bmax[1] { bmax[1] = y; }
1092
+ if z > bmax[2] { bmax[2] = z; }
1093
+ }