@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,1280 @@
1
+ //! glTF/GLB loading — everything that touches the optional `gltf` /
2
+ //! `image_dds` dependencies (the EN-014 `models3d` feature). Split out
3
+ //! of `models.rs` (EN-052 line ratchet) by EN-063, which re-gated the
4
+ //! loaders: this whole module compiles only with `models3d`, so pure-2D
5
+ //! and web-without-3D builds drop the parsers and their deps entirely.
6
+ //! The always-compiled half of the model system (structs, mixer-driven
7
+ //! animation update, procedural mesh gen) stays in `models.rs`.
8
+
9
+ use super::*;
10
+
11
+ /// Walk the scene graph and collect EVERY world-space transform that
12
+ /// references each mesh. Unlike `walk_scene_for_mesh_transforms` which
13
+ /// records only the first occurrence, this version captures every
14
+ /// instance — so glTF scenes with heavy mesh reuse (Bistro: 5910 nodes
15
+ /// referencing 551 unique meshes) render every chair / bollard / chain
16
+ /// / bush instead of collapsing to a single copy each.
17
+ fn walk_scene_collect_instances(
18
+ node: &gltf::Node,
19
+ parent: &[[f32; 4]; 4],
20
+ out: &mut [Vec<[[f32; 4]; 4]>],
21
+ ) {
22
+ let local = node.transform().matrix();
23
+ let world = mat4_mul(parent, &local);
24
+ if let Some(mesh) = node.mesh() {
25
+ let idx = mesh.index();
26
+ if idx < out.len() {
27
+ out[idx].push(world);
28
+ }
29
+ }
30
+ for child in node.children() {
31
+ walk_scene_collect_instances(&child, &world, out);
32
+ }
33
+ }
34
+
35
+ /// Transform a 3D point by a 4x4 matrix (column-major). Treats the
36
+ /// point as having w=1 and drops w from the result.
37
+ fn mat4_transform_point(m: &[[f32; 4]; 4], p: &[f32; 3]) -> [f32; 3] {
38
+ [
39
+ m[0][0]*p[0] + m[1][0]*p[1] + m[2][0]*p[2] + m[3][0],
40
+ m[0][1]*p[0] + m[1][1]*p[1] + m[2][1]*p[2] + m[3][1],
41
+ m[0][2]*p[0] + m[1][2]*p[1] + m[2][2]*p[2] + m[3][2],
42
+ ]
43
+ }
44
+
45
+ /// Transform a direction vector by a 3x3 matrix (extracted from a 4x4
46
+ /// column-major stored as the top-left 3x3). Used for normals under
47
+ /// the inverse-transpose matrix.
48
+ fn mat3_transform_vec(m: &[[f32; 3]; 3], v: &[f32; 3]) -> [f32; 3] {
49
+ [
50
+ m[0][0]*v[0] + m[1][0]*v[1] + m[2][0]*v[2],
51
+ m[0][1]*v[0] + m[1][1]*v[1] + m[2][1]*v[2],
52
+ m[0][2]*v[0] + m[1][2]*v[1] + m[2][2]*v[2],
53
+ ]
54
+ }
55
+
56
+ /// Inverse-transpose of the 3x3 rotation+scale part of a 4x4 matrix.
57
+ /// Correct way to transform normals when the matrix has non-uniform
58
+ /// scale; falls back to identity if the 3x3 block isn't invertible.
59
+ fn mat4_inverse_transpose_3x3(m: &[[f32; 4]; 4]) -> [[f32; 3]; 3] {
60
+ let a = m[0][0]; let b = m[1][0]; let c = m[2][0];
61
+ let d = m[0][1]; let e = m[1][1]; let f = m[2][1];
62
+ let g = m[0][2]; let h = m[1][2]; let i = m[2][2];
63
+
64
+ let inv00 = e*i - f*h;
65
+ let inv01 = f*g - d*i;
66
+ let inv02 = d*h - e*g;
67
+ let inv10 = c*h - b*i;
68
+ let inv11 = a*i - c*g;
69
+ let inv12 = b*g - a*h;
70
+ let inv20 = b*f - c*e;
71
+ let inv21 = c*d - a*f;
72
+ let inv22 = a*e - b*d;
73
+
74
+ let det = a*inv00 + b*inv01 + c*inv02;
75
+ if det.abs() < 1e-10 {
76
+ return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
77
+ }
78
+ let inv_det = 1.0 / det;
79
+ // Store in column-major like the rest of the file (columns first).
80
+ // The result is the inverse-transpose, so rows/cols are swapped
81
+ // from the plain inverse.
82
+ [
83
+ [inv00 * inv_det, inv01 * inv_det, inv02 * inv_det],
84
+ [inv10 * inv_det, inv11 * inv_det, inv12 * inv_det],
85
+ [inv20 * inv_det, inv21 * inv_det, inv22 * inv_det],
86
+ ]
87
+ }
88
+
89
+ // ============================================================
90
+ // glTF animation loader
91
+ // ============================================================
92
+
93
+ fn read_accessor_f32(_gltf: &gltf::Gltf, buffer_data: &[Vec<u8>], accessor: &gltf::Accessor) -> Vec<f32> {
94
+ let view = match accessor.view() {
95
+ Some(v) => v,
96
+ None => return Vec::new(),
97
+ };
98
+ let buf_idx = view.buffer().index();
99
+ if buf_idx >= buffer_data.len() { return Vec::new(); }
100
+ let buf = &buffer_data[buf_idx];
101
+ let offset = view.offset() + accessor.offset();
102
+ let count = accessor.count();
103
+ let stride = view.stride().unwrap_or(accessor.size());
104
+ let component_count = match accessor.dimensions() {
105
+ gltf::accessor::Dimensions::Scalar => 1,
106
+ gltf::accessor::Dimensions::Vec2 => 2,
107
+ gltf::accessor::Dimensions::Vec3 => 3,
108
+ gltf::accessor::Dimensions::Vec4 => 4,
109
+ gltf::accessor::Dimensions::Mat4 => 16,
110
+ _ => 1,
111
+ };
112
+
113
+ let mut result = Vec::with_capacity(count * component_count);
114
+ for i in 0..count {
115
+ let base = offset + i * stride;
116
+ for c in 0..component_count {
117
+ let byte_offset = base + c * 4;
118
+ if byte_offset + 4 <= buf.len() {
119
+ let val = f32::from_le_bytes([buf[byte_offset], buf[byte_offset+1], buf[byte_offset+2], buf[byte_offset+3]]);
120
+ result.push(val);
121
+ } else {
122
+ result.push(0.0);
123
+ }
124
+ }
125
+ }
126
+ result
127
+ }
128
+
129
+ pub(super) fn load_gltf_animation(data: &[u8]) -> Option<ModelAnimation> {
130
+ let gltf = gltf::Gltf::from_slice(data).ok()?;
131
+
132
+ // Get buffer data
133
+ let mut buffer_data: Vec<Vec<u8>> = Vec::new();
134
+ for buffer in gltf.buffers() {
135
+ match buffer.source() {
136
+ gltf::buffer::Source::Bin => {
137
+ if let Some(blob) = gltf.blob.as_ref() {
138
+ buffer_data.push(blob.clone());
139
+ }
140
+ }
141
+ gltf::buffer::Source::Uri(uri) => {
142
+ if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") {
143
+ let mut decoded = Vec::new();
144
+ let _ = base64_decode(encoded, &mut decoded);
145
+ buffer_data.push(decoded);
146
+ } else {
147
+ buffer_data.push(Vec::new());
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // Parse skeleton from the first skin
154
+ let skeleton = if let Some(skin) = gltf.skins().next() {
155
+ let joints_nodes: Vec<_> = skin.joints().collect();
156
+ let joint_count = joints_nodes.len();
157
+
158
+ // Build a mapping from node index to joint index
159
+ let mut node_to_joint = std::collections::HashMap::new();
160
+ for (ji, node) in joints_nodes.iter().enumerate() {
161
+ node_to_joint.insert(node.index(), ji);
162
+ }
163
+
164
+ // Read inverse bind matrices
165
+ let ibm_data = if let Some(accessor) = skin.inverse_bind_matrices() {
166
+ read_accessor_f32(&gltf, &buffer_data, &accessor)
167
+ } else {
168
+ let mut default_ibm = Vec::with_capacity(joint_count * 16);
169
+ for _ in 0..joint_count {
170
+ default_ibm.extend_from_slice(&[
171
+ 1.0, 0.0, 0.0, 0.0,
172
+ 0.0, 1.0, 0.0, 0.0,
173
+ 0.0, 0.0, 1.0, 0.0,
174
+ 0.0, 0.0, 0.0, 1.0,
175
+ ]);
176
+ }
177
+ default_ibm
178
+ };
179
+
180
+ let mut joints = Vec::with_capacity(joint_count);
181
+ let mut root_joints = Vec::new();
182
+
183
+ for (ji, node) in joints_nodes.iter().enumerate() {
184
+ let mut ibm = [[0.0f32; 4]; 4];
185
+ let base = ji * 16;
186
+ if base + 16 <= ibm_data.len() {
187
+ // glTF stores column-major; read directly (we also use column-major)
188
+ for a in 0..4 {
189
+ for b in 0..4 {
190
+ ibm[a][b] = ibm_data[base + a * 4 + b];
191
+ }
192
+ }
193
+ } else {
194
+ ibm = mat4_identity();
195
+ }
196
+
197
+ // Blender FBX export bakes 100x scale into IBMs (converts m→cm for bone space).
198
+ // This is NEEDED because Blender also pre-scales vertex positions to meters.
199
+ // The 100x in IBMs converts meter-space vertices to cm-space bone transforms.
200
+ // DO NOT normalize — the scale is intentional and required.
201
+
202
+ let children: Vec<usize> = node.children()
203
+ .filter_map(|child| node_to_joint.get(&child.index()).copied())
204
+ .collect();
205
+
206
+ let name = node.name().unwrap_or("").to_string();
207
+ let (t, r, s) = node.transform().decomposed();
208
+
209
+ joints.push(JointData {
210
+ inverse_bind: ibm, children, name,
211
+ rest_translation: t,
212
+ rest_rotation: r,
213
+ rest_scale: s,
214
+ });
215
+ }
216
+
217
+ // Find root joints (joints that are not children of any other joint)
218
+ let mut is_child = vec![false; joint_count];
219
+ for joint in &joints {
220
+ for &child in &joint.children {
221
+ if child < joint_count { is_child[child] = true; }
222
+ }
223
+ }
224
+ for i in 0..joint_count {
225
+ if !is_child[i] { root_joints.push(i); }
226
+ }
227
+
228
+ #[cfg(debug_assertions)]
229
+ {
230
+ eprintln!("[anim] Skeleton: {} joints, {} roots", joints.len(), root_joints.len());
231
+ for (i, j) in joints.iter().enumerate() {
232
+ if i < 5 || i == joints.len() - 1 {
233
+ eprintln!("[anim] joint {}: '{}' children={:?}", i, j.name, j.children);
234
+ }
235
+ }
236
+ }
237
+
238
+ Some(SkeletonData { joints, root_joints })
239
+ } else {
240
+ #[cfg(debug_assertions)]
241
+ eprintln!("[anim] No skin found in glTF!");
242
+ None
243
+ };
244
+
245
+ // Parse animations
246
+ let mut animations = Vec::new();
247
+ for anim in gltf.animations() {
248
+ let mut channels = Vec::new();
249
+ let mut duration: f32 = 0.0;
250
+
251
+ // Build node-to-joint mapping for channel resolution
252
+ let node_to_joint: std::collections::HashMap<usize, usize> = if let Some(skin) = gltf.skins().next() {
253
+ skin.joints().enumerate().map(|(ji, node)| (node.index(), ji)).collect()
254
+ } else {
255
+ std::collections::HashMap::new()
256
+ };
257
+
258
+ // Group channels by target node: (trans_ts, translations, rot_ts, rotations, scale_ts, scales)
259
+ let mut node_channels: std::collections::HashMap<usize, (Vec<f32>, Vec<[f32; 3]>, Vec<f32>, Vec<[f32; 4]>, Vec<f32>, Vec<[f32; 3]>)> = std::collections::HashMap::new();
260
+
261
+ #[cfg(debug_assertions)]
262
+ let mut skipped_channels = 0usize;
263
+ #[cfg(debug_assertions)]
264
+ let mut mapped_channels = 0usize;
265
+ #[cfg(debug_assertions)]
266
+ {
267
+ eprintln!("[anim] Animation '{}' has {} channels, node_to_joint map has {} entries",
268
+ anim.name().unwrap_or("?"), anim.channels().count(), node_to_joint.len());
269
+ for (ci, ch) in anim.channels().enumerate() {
270
+ if ci < 5 {
271
+ let tn = ch.target().node();
272
+ eprintln!("[anim] channel {} targets node {} '{}' mapped={}",
273
+ ci, tn.index(), tn.name().unwrap_or("?"),
274
+ node_to_joint.contains_key(&tn.index()));
275
+ }
276
+ }
277
+ }
278
+ for channel in anim.channels() {
279
+ let target_node = channel.target().node().index();
280
+ let joint_index = match node_to_joint.get(&target_node) {
281
+ Some(&ji) => {
282
+ #[cfg(debug_assertions)]
283
+ { mapped_channels += 1; }
284
+ ji
285
+ },
286
+ None => {
287
+ #[cfg(debug_assertions)]
288
+ { skipped_channels += 1; }
289
+ continue;
290
+ },
291
+ };
292
+
293
+ let sampler = channel.sampler();
294
+ let input_accessor = sampler.input();
295
+ let output_accessor = sampler.output();
296
+
297
+ let timestamps = read_accessor_f32(&gltf, &buffer_data, &input_accessor);
298
+ let values = read_accessor_f32(&gltf, &buffer_data, &output_accessor);
299
+
300
+ if let Some(&last) = timestamps.last() {
301
+ if last > duration { duration = last; }
302
+ }
303
+
304
+ let entry = node_channels.entry(joint_index).or_insert_with(|| (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()));
305
+
306
+ match channel.target().property() {
307
+ gltf::animation::Property::Translation => {
308
+ entry.0 = timestamps;
309
+ entry.1 = values.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
310
+ }
311
+ gltf::animation::Property::Rotation => {
312
+ entry.2 = timestamps;
313
+ entry.3 = values.chunks(4).map(|c| [c[0], c[1], c[2], c[3]]).collect();
314
+ }
315
+ gltf::animation::Property::Scale => {
316
+ entry.4 = timestamps;
317
+ entry.5 = values.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
318
+ }
319
+ _ => {}
320
+ }
321
+ }
322
+
323
+ for (joint_index, (trans_ts, translations, rot_ts, rotations, scale_ts, scales)) in node_channels {
324
+ // Use the longest timestamp array as the primary (for backward compat)
325
+ let timestamps = if rot_ts.len() >= trans_ts.len() && rot_ts.len() >= scale_ts.len() {
326
+ rot_ts.clone()
327
+ } else if trans_ts.len() >= scale_ts.len() {
328
+ trans_ts.clone()
329
+ } else {
330
+ scale_ts.clone()
331
+ };
332
+ channels.push(AnimationChannel {
333
+ joint_index,
334
+ timestamps,
335
+ translations,
336
+ rotation_timestamps: rot_ts,
337
+ rotations,
338
+ scale_timestamps: scale_ts,
339
+ scales,
340
+ });
341
+ }
342
+
343
+ let name = anim.name().unwrap_or("").to_string();
344
+ #[cfg(debug_assertions)]
345
+ {
346
+ let total_kf: usize = channels.iter().map(|c| c.timestamps.len()).sum();
347
+ let avg_kf = if !channels.is_empty() { total_kf / channels.len() } else { 0 };
348
+ eprintln!("[anim] Animation '{}': {} channels mapped, {} skipped, duration={:.2}s, avg {}/ch keyframes",
349
+ name, mapped_channels, skipped_channels, duration, avg_kf);
350
+ }
351
+ animations.push(AnimationData { channels, duration, name });
352
+ }
353
+
354
+ let joint_count = skeleton.as_ref().map(|s| s.joints.len()).unwrap_or(0);
355
+ // Build reference rest rotations from the first animation at t=0
356
+ let ref_rest_rotations = if animations.len() > 1 {
357
+ if let Some(ref skel) = skeleton {
358
+ let joint_count_s = skel.joints.len();
359
+ let mut rest_rots = vec![[0.0f32, 0.0, 0.0, 1.0]; joint_count_s];
360
+ // Sample first animation at t=0 to get reference rest rotations
361
+ let anim0 = &animations[0];
362
+ for ch in &anim0.channels {
363
+ if ch.joint_index < joint_count_s && !ch.rotations.is_empty() {
364
+ rest_rots[ch.joint_index] = if ch.rotations.len() > 0 { ch.rotations[0] } else { [0.0, 0.0, 0.0, 1.0] };
365
+ }
366
+ }
367
+ #[cfg(debug_assertions)]
368
+ eprintln!("[retarget] Built reference rest rotations from anim 0 for {} joints", joint_count_s);
369
+ Some(rest_rots)
370
+ } else { None }
371
+ } else { None };
372
+
373
+ Some(ModelAnimation {
374
+ skeleton: skeleton.map(Arc::new),
375
+ animations: Arc::new(animations),
376
+ joint_matrices: vec![mat4_identity(); joint_count],
377
+ ref_rest_rotations: ref_rest_rotations.map(Arc::new),
378
+ mixer: AnimMixer::default(),
379
+ joint_world: vec![mat4_identity(); joint_count],
380
+ mask_weights: vec![0.0; joint_count],
381
+ mask_cached_root: -1,
382
+ })
383
+ }
384
+
385
+ pub(super) fn load_gltf_with_textures(
386
+ data: &[u8],
387
+ renderer: &mut crate::renderer::Renderer,
388
+ base_dir: Option<&std::path::Path>,
389
+ ) -> Option<ModelData> {
390
+ let gltf = gltf::Gltf::from_slice(data).ok()?;
391
+
392
+ // Get buffer data
393
+ let mut buffer_data: Vec<Vec<u8>> = Vec::new();
394
+ for buffer in gltf.buffers() {
395
+ match buffer.source() {
396
+ gltf::buffer::Source::Bin => {
397
+ if let Some(blob) = gltf.blob.as_ref() { buffer_data.push(blob.clone()); }
398
+ }
399
+ gltf::buffer::Source::Uri(uri) => {
400
+ if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") {
401
+ let mut decoded = Vec::new();
402
+ let _ = base64_decode(encoded, &mut decoded);
403
+ buffer_data.push(decoded);
404
+ } else if let Some(dir) = base_dir {
405
+ // External .bin file alongside the .gltf.
406
+ let path = dir.join(uri);
407
+ match std::fs::read(&path) {
408
+ Ok(bytes) => buffer_data.push(bytes),
409
+ Err(_) => buffer_data.push(Vec::new()),
410
+ }
411
+ } else {
412
+ buffer_data.push(Vec::new());
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ // Pre-walk materials to identify which image indices are normal
419
+ // maps. They need LEADR-style vector-space mip generation and per-
420
+ // mip variance baked into alpha; see register_texture_kind.
421
+ let mut normal_image_set: std::collections::HashSet<usize> = Default::default();
422
+ for mat in gltf.materials() {
423
+ if let Some(nt) = mat.normal_texture() {
424
+ normal_image_set.insert(nt.texture().source().index());
425
+ }
426
+ }
427
+
428
+ // Extract and register textures
429
+ let mut texture_indices: Vec<u32> = Vec::new(); // maps glTF image index -> renderer texture index
430
+ for (image_idx, image) in gltf.images().enumerate() {
431
+ let is_normal = normal_image_set.contains(&image_idx);
432
+ match image.source() {
433
+ gltf::image::Source::View { view, .. } => {
434
+ let buf_idx = view.buffer().index();
435
+ if buf_idx < buffer_data.len() {
436
+ let offset = view.offset();
437
+ let length = view.length();
438
+ if offset + length <= buffer_data[buf_idx].len() {
439
+ let img_data = &buffer_data[buf_idx][offset..offset + length];
440
+ // Decode image (PNG/JPEG)
441
+ if let Ok(img) = image::load_from_memory(img_data) {
442
+ let rgba = img.to_rgba8();
443
+ let (w, h) = (rgba.width(), rgba.height());
444
+ let tex_idx = renderer.register_texture_kind(w, h, &rgba, is_normal);
445
+ texture_indices.push(tex_idx);
446
+ } else {
447
+ texture_indices.push(0); // fallback to white
448
+ }
449
+ } else {
450
+ texture_indices.push(0);
451
+ }
452
+ } else {
453
+ texture_indices.push(0);
454
+ }
455
+ }
456
+ gltf::image::Source::Uri { uri, .. } => {
457
+ // External image file (loose glTF). Resolve relative to
458
+ // the .gltf file's directory.
459
+ let (bytes, effective_uri): (Option<Vec<u8>>, String) =
460
+ if let Some(encoded) = uri.strip_prefix("data:") {
461
+ let decoded = encoded.find(";base64,").map(|pos| {
462
+ let b64 = &encoded[pos + 8..];
463
+ let mut out = Vec::new();
464
+ let _ = base64_decode(b64, &mut out);
465
+ out
466
+ });
467
+ (decoded, uri.to_string())
468
+ } else if let Some(dir) = base_dir {
469
+ let primary = dir.join(uri);
470
+ if let Ok(b) = std::fs::read(&primary) {
471
+ (Some(b), uri.to_string())
472
+ } else {
473
+ // Asset packs sometimes ship DDS-only while the
474
+ // glTF still references a .png URI (Lumberyard
475
+ // Bistro does this). Retry with a .dds
476
+ // sibling before giving up.
477
+ let swapped = swap_extension(uri, "dds");
478
+ let alt = dir.join(&swapped);
479
+ match std::fs::read(&alt) {
480
+ Ok(b) => (Some(b), swapped),
481
+ Err(_) => (None, uri.to_string()),
482
+ }
483
+ }
484
+ } else {
485
+ (None, uri.to_string())
486
+ };
487
+ match bytes.and_then(|b| decode_texture_bytes(&b, &effective_uri)) {
488
+ Some((rgba, w, h)) => {
489
+ texture_indices.push(renderer.register_texture_kind(w, h, &rgba, is_normal));
490
+ }
491
+ None => texture_indices.push(0),
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ // Detect armature scale for skinned meshes.
498
+ // Blender FBX imports set armature scale to 0.01 (cm→m conversion).
499
+ // Vertex positions inherit this scale but bone transforms don't,
500
+ // creating a unit mismatch. We apply the inverse to vertex positions.
501
+ let skin_vertex_scale: f32 = {
502
+ let mut scale = 1.0f32;
503
+ for node in gltf.nodes() {
504
+ if node.mesh().is_some() && node.skin().is_some() {
505
+ // Found a skinned mesh node — look for parent with scale
506
+ for parent in gltf.nodes() {
507
+ for child in parent.children() {
508
+ if child.index() == node.index() {
509
+ let (_, _, s) = parent.transform().decomposed();
510
+ let avg_scale = (s[0] + s[1] + s[2]) / 3.0;
511
+ if avg_scale > 0.001 && (avg_scale - 1.0).abs() > 0.01 {
512
+ scale = 1.0 / avg_scale;
513
+ }
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+ // Fallback: check IBMs for large scale (Blender FBX baked 100x)
520
+ if (scale - 1.0).abs() < 0.01 {
521
+ if let Some(skin) = gltf.skins().next() {
522
+ if let Some(accessor) = skin.inverse_bind_matrices() {
523
+ let view = accessor.view().unwrap();
524
+ let buf_idx = view.buffer().index();
525
+ if buf_idx < buffer_data.len() {
526
+ let offset = view.offset() + accessor.offset();
527
+ let data = &buffer_data[buf_idx];
528
+ if offset + 12 <= data.len() {
529
+ // Read first 3 floats (first column of first IBM)
530
+ let f0 = f32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]);
531
+ let f1 = f32::from_le_bytes([data[offset+4], data[offset+5], data[offset+6], data[offset+7]]);
532
+ let f2 = f32::from_le_bytes([data[offset+8], data[offset+9], data[offset+10], data[offset+11]]);
533
+ let diag = (f0*f0 + f1*f1 + f2*f2).sqrt();
534
+ if diag > 10.0 {
535
+ scale = diag;
536
+ #[cfg(debug_assertions)]
537
+ eprintln!("[skin] IBM col0 len={:.1}, applying {:.0}x vertex scale", diag, scale);
538
+ }
539
+ }
540
+ }
541
+ }
542
+ }
543
+ }
544
+ if (scale - 1.0).abs() > 0.01 {
545
+ #[cfg(debug_assertions)]
546
+ eprintln!("[skin] Applying {:.0}x vertex scale to compensate armature transform", scale);
547
+ }
548
+ scale
549
+ };
550
+
551
+ let mut meshes = Vec::new();
552
+ let mut bbox_min = [f32::MAX; 3];
553
+ let mut bbox_max = [f32::MIN; 3];
554
+
555
+ // Walk the scene node tree to collect world-space transforms for
556
+ // each mesh-referencing node. glTF supports instancing by having
557
+ // multiple nodes reference the same mesh at different transforms
558
+ // — Bistro uses this heavily (5910 nodes, 551 meshes: chairs,
559
+ // bollards, chains, foliage repeated everywhere). We emit one
560
+ // MeshData PER (mesh, transform) pair so every instance actually
561
+ // shows up in the scene. Memory cost is linear in node count;
562
+ // not great for deep instancing but correct. Animated / skinned
563
+ // meshes are unaffected — the armature transforms apply on top.
564
+ let mesh_count = gltf.meshes().count();
565
+ let mut mesh_instances: Vec<Vec<[[f32; 4]; 4]>> = vec![Vec::new(); mesh_count];
566
+ let identity = [[1.0f32, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
567
+ for scene in gltf.scenes() {
568
+ for node in scene.nodes() {
569
+ walk_scene_collect_instances(&node, &identity, &mut mesh_instances);
570
+ }
571
+ }
572
+
573
+ for mesh in gltf.meshes() {
574
+ let instances = mesh_instances[mesh.index()].clone();
575
+ // Meshes reachable from no scene node would have no instances;
576
+ // fall back to a single identity transform so orphan meshes
577
+ // still render (matches prior behaviour for simple models).
578
+ let instance_transforms: Vec<Option<[[f32; 4]; 4]>> = if instances.is_empty() {
579
+ vec![None]
580
+ } else {
581
+ instances.into_iter().map(Some).collect()
582
+ };
583
+
584
+ for mesh_world in &instance_transforms {
585
+ let mesh_world = *mesh_world;
586
+ // Inverse-transpose 3×3 for normals under non-uniform scale.
587
+ let normal_xform = mesh_world.map(|m| mat4_inverse_transpose_3x3(&m));
588
+ for primitive in mesh.primitives() {
589
+ let reader = primitive.reader(|buf| buffer_data.get(buf.index()).map(|d| d.as_slice()));
590
+ let positions: Vec<[f32; 3]> = match reader.read_positions() {
591
+ Some(iter) => iter.collect(),
592
+ None => continue,
593
+ };
594
+ let normals: Vec<[f32; 3]> = reader.read_normals()
595
+ .map(|iter| iter.collect())
596
+ .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]);
597
+ let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0)
598
+ .map(|iter| iter.into_f32().collect())
599
+ .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
600
+ // Tangents (vec4: xyz = tangent, w = bitangent sign ±1).
601
+ // If absent, we leave them as zero so the shader knows to
602
+ // skip normal-map perturbation for this mesh.
603
+ let tangents: Vec<[f32; 4]> = reader.read_tangents()
604
+ .map(|iter| iter.collect())
605
+ .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]);
606
+
607
+ // Get vertex colors if available
608
+ let vert_colors: Option<Vec<[f32; 4]>> = reader.read_colors(0)
609
+ .map(|iter| iter.into_rgba_f32().collect());
610
+
611
+ let mat = primitive.material();
612
+ let pbr = mat.pbr_metallic_roughness();
613
+ let emissive_factor = mat.emissive_factor();
614
+
615
+ let tex_idx_of = |img_idx: usize| -> Option<u32> {
616
+ texture_indices.get(img_idx).copied()
617
+ };
618
+
619
+ let normal_tex_idx = mat.normal_texture()
620
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
621
+ let emissive_tex_idx = mat.emissive_texture()
622
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
623
+ let occlusion_tex_idx = mat.occlusion_texture()
624
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
625
+
626
+ // Metallic-roughness first; fall back to
627
+ // KHR_materials_pbrSpecularGlossiness when only that's
628
+ // authored (Lumberyard Bistro + many FBX exports).
629
+ // Conversion matches the load_gltf_staged path — see
630
+ // specgloss_to_metalrough for the algorithm.
631
+ let (mut base_color, mut metallic_factor, mut roughness_factor, tex_idx, mr_tex_idx) =
632
+ if pbr.base_color_texture().is_none() {
633
+ if let Some(sg) = mat.pbr_specular_glossiness() {
634
+ let diffuse = sg.diffuse_factor();
635
+ let spec = sg.specular_factor();
636
+ let (base_color, metallic) =
637
+ specgloss_to_metalrough(diffuse, spec);
638
+ let roughness = 1.0 - sg.glossiness_factor();
639
+ let diffuse_tex = sg.diffuse_texture()
640
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
641
+ (base_color, metallic, roughness, diffuse_tex, None)
642
+ } else {
643
+ (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), None, None)
644
+ }
645
+ } else {
646
+ let tex = pbr.base_color_texture()
647
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
648
+ let mr = pbr.metallic_roughness_texture()
649
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
650
+ (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), tex, mr)
651
+ };
652
+
653
+ if let Some(t) = mat.transmission() {
654
+ apply_transmission_hack(
655
+ t.transmission_factor(),
656
+ &mut base_color,
657
+ &mut metallic_factor,
658
+ &mut roughness_factor,
659
+ );
660
+ }
661
+
662
+ let mut vertices = Vec::with_capacity(positions.len());
663
+ for i in 0..positions.len() {
664
+ let p = positions[i];
665
+ for k in 0..3 {
666
+ if p[k] < bbox_min[k] { bbox_min[k] = p[k]; }
667
+ if p[k] > bbox_max[k] { bbox_max[k] = p[k]; }
668
+ }
669
+ let color = if let Some(ref vc) = vert_colors {
670
+ vc[i]
671
+ } else {
672
+ [base_color[0], base_color[1], base_color[2], base_color[3]]
673
+ };
674
+ // Skin data (joints + weights)
675
+ let joint_vals: Option<Vec<[u16; 4]>> = reader.read_joints(0)
676
+ .map(|iter| iter.into_u16().collect());
677
+ let weight_vals: Option<Vec<[f32; 4]>> = reader.read_weights(0)
678
+ .map(|iter| iter.into_f32().collect());
679
+
680
+ let jv = if let Some(ref j) = joint_vals {
681
+ [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32]
682
+ } else {
683
+ [0.0; 4]
684
+ };
685
+ let wv = if let Some(ref w) = weight_vals {
686
+ w[i]
687
+ } else {
688
+ [0.0; 4]
689
+ };
690
+ // Apply inverse armature scale to skinned vertex positions
691
+ let is_skinned = wv[0] + wv[1] + wv[2] + wv[3] > 0.01;
692
+ let base_pos = if is_skinned && (skin_vertex_scale - 1.0).abs() > 0.01 {
693
+ [p[0] * skin_vertex_scale, p[1] * skin_vertex_scale, p[2] * skin_vertex_scale]
694
+ } else {
695
+ p
696
+ };
697
+ // Bake the mesh's scene node transform into world-space
698
+ // position/normal. Skinned meshes are NOT world-baked:
699
+ // their node transform is expected to be consumed by the
700
+ // armature, and the pose is driven by joint matrices at
701
+ // draw time. Static (non-skinned) meshes get the baked
702
+ // transform so drawModel's position/scale arguments
703
+ // apply on top of the correct base pose.
704
+ let (final_pos, final_normal, final_tangent) = if is_skinned {
705
+ (base_pos, normals[i], tangents[i])
706
+ } else if let Some(xform) = mesh_world {
707
+ let t_in = [tangents[i][0], tangents[i][1], tangents[i][2]];
708
+ let t_out = match normal_xform {
709
+ // Tangents transform like positions (as directions)
710
+ // under the linear part of the transform — we use
711
+ // the upper 3×3 of the model matrix, not its
712
+ // inverse-transpose. But since our mesh_world is
713
+ // rigid-ish (no shear), the normal_xform gets us
714
+ // close enough for the common case. For a purely
715
+ // orthonormal node transform these are identical.
716
+ Some(ref n) => mat3_transform_vec(n, &t_in),
717
+ None => t_in,
718
+ };
719
+ (
720
+ mat4_transform_point(&xform, &base_pos),
721
+ match normal_xform {
722
+ Some(ref n) => mat3_transform_vec(n, &normals[i]),
723
+ None => normals[i],
724
+ },
725
+ [t_out[0], t_out[1], t_out[2], tangents[i][3]],
726
+ )
727
+ } else {
728
+ (base_pos, normals[i], tangents[i])
729
+ };
730
+ // Update bbox to reflect the final (possibly transformed)
731
+ // position so the camera auto-framing still works right.
732
+ for k in 0..3 {
733
+ if final_pos[k] < bbox_min[k] { bbox_min[k] = final_pos[k]; }
734
+ if final_pos[k] > bbox_max[k] { bbox_max[k] = final_pos[k]; }
735
+ }
736
+ vertices.push(Vertex3D {
737
+ position: final_pos,
738
+ normal: final_normal,
739
+ color,
740
+ uv: tex_coords[i],
741
+ joints: jv,
742
+ weights: wv,
743
+ tangent: final_tangent,
744
+ });
745
+ }
746
+ let indices: Vec<u32> = match reader.read_indices() {
747
+ Some(iter) => iter.into_u32().collect(),
748
+ None => (0..positions.len() as u32).collect(),
749
+ };
750
+ meshes.push(MeshData {
751
+ vertices,
752
+ indices,
753
+ texture_idx: tex_idx,
754
+ normal_texture_idx: normal_tex_idx,
755
+ metallic_roughness_texture_idx: mr_tex_idx,
756
+ emissive_texture_idx: emissive_tex_idx,
757
+ occlusion_texture_idx: occlusion_tex_idx,
758
+ metallic_factor,
759
+ roughness_factor,
760
+ emissive_factor,
761
+ alpha_cutoff: alpha_cutoff_from_material(&mat),
762
+ });
763
+ }
764
+ } // end instance loop
765
+ }
766
+
767
+ if meshes.is_empty() { return None; }
768
+ Some(ModelData { meshes, bbox_min, bbox_max })
769
+ }
770
+
771
+ /// Like load_gltf_with_textures but decodes textures to RGBA without GPU registration.
772
+ /// Returns a StagedModel with decoded textures that can later be committed on the main thread.
773
+ pub fn load_gltf_staged(data: &[u8]) -> Option<crate::staging::StagedModel> {
774
+ use crate::staging::{StagedTexture, StagedModel};
775
+
776
+ let gltf = gltf::Gltf::from_slice(data).ok()?;
777
+
778
+ let mut buffer_data: Vec<Vec<u8>> = Vec::new();
779
+ for buffer in gltf.buffers() {
780
+ match buffer.source() {
781
+ gltf::buffer::Source::Bin => {
782
+ if let Some(blob) = gltf.blob.as_ref() { buffer_data.push(blob.clone()); }
783
+ }
784
+ gltf::buffer::Source::Uri(uri) => {
785
+ if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") {
786
+ let mut decoded = Vec::new();
787
+ let _ = base64_decode(encoded, &mut decoded);
788
+ buffer_data.push(decoded);
789
+ } else {
790
+ buffer_data.push(Vec::new());
791
+ }
792
+ }
793
+ }
794
+ }
795
+
796
+ // Pre-walk materials for the image indices used as normal maps — they
797
+ // must be registered via register_texture_kind's linear/LEADR path at
798
+ // commit time (same pre-walk as load_gltf_with_textures).
799
+ let mut normal_image_set: std::collections::HashSet<usize> = Default::default();
800
+ for mat in gltf.materials() {
801
+ if let Some(nt) = mat.normal_texture() {
802
+ normal_image_set.insert(nt.texture().source().index());
803
+ }
804
+ }
805
+
806
+ // Decode textures to RGBA without GPU registration.
807
+ // staged_textures[i] corresponds to glTF image index i.
808
+ // texture_indices maps glTF image index -> 1-based index into staged_textures (0 = no texture).
809
+ let mut staged_textures: Vec<StagedTexture> = Vec::new();
810
+ let mut texture_indices: Vec<u32> = Vec::new();
811
+ for (image_idx, image) in gltf.images().enumerate() {
812
+ match image.source() {
813
+ gltf::image::Source::View { view, .. } => {
814
+ let buf_idx = view.buffer().index();
815
+ if buf_idx < buffer_data.len() {
816
+ let offset = view.offset();
817
+ let length = view.length();
818
+ if offset + length <= buffer_data[buf_idx].len() {
819
+ let img_data = &buffer_data[buf_idx][offset..offset + length];
820
+ if let Ok(img) = image::load_from_memory(img_data) {
821
+ let rgba = img.to_rgba8();
822
+ let (w, h) = (rgba.width(), rgba.height());
823
+ staged_textures.push(StagedTexture {
824
+ data: rgba.into_raw(),
825
+ width: w,
826
+ height: h,
827
+ is_normal: normal_image_set.contains(&image_idx),
828
+ });
829
+ // 1-based index into staged_textures
830
+ texture_indices.push(staged_textures.len() as u32);
831
+ } else {
832
+ texture_indices.push(0);
833
+ }
834
+ } else {
835
+ texture_indices.push(0);
836
+ }
837
+ } else {
838
+ texture_indices.push(0);
839
+ }
840
+ }
841
+ _ => { texture_indices.push(0); }
842
+ }
843
+ }
844
+
845
+ // Detect armature scale (same logic as load_gltf_with_textures)
846
+ let skin_vertex_scale: f32 = {
847
+ let mut scale = 1.0f32;
848
+ for node in gltf.nodes() {
849
+ if node.mesh().is_some() && node.skin().is_some() {
850
+ for parent in gltf.nodes() {
851
+ for child in parent.children() {
852
+ if child.index() == node.index() {
853
+ let (_, _, s) = parent.transform().decomposed();
854
+ let avg_scale = (s[0] + s[1] + s[2]) / 3.0;
855
+ if avg_scale > 0.001 && (avg_scale - 1.0).abs() > 0.01 {
856
+ scale = 1.0 / avg_scale;
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+ }
863
+ if (scale - 1.0).abs() < 0.01 {
864
+ if let Some(skin) = gltf.skins().next() {
865
+ if let Some(accessor) = skin.inverse_bind_matrices() {
866
+ let view = accessor.view().unwrap();
867
+ let buf_idx = view.buffer().index();
868
+ if buf_idx < buffer_data.len() {
869
+ let offset = view.offset() + accessor.offset();
870
+ let data = &buffer_data[buf_idx];
871
+ if offset + 12 <= data.len() {
872
+ let f0 = f32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]);
873
+ let f1 = f32::from_le_bytes([data[offset+4], data[offset+5], data[offset+6], data[offset+7]]);
874
+ let f2 = f32::from_le_bytes([data[offset+8], data[offset+9], data[offset+10], data[offset+11]]);
875
+ let diag = (f0*f0 + f1*f1 + f2*f2).sqrt();
876
+ if diag > 10.0 {
877
+ scale = diag;
878
+ }
879
+ }
880
+ }
881
+ }
882
+ }
883
+ }
884
+ scale
885
+ };
886
+
887
+ let mut meshes = Vec::new();
888
+ let mut bbox_min = [f32::MAX; 3];
889
+ let mut bbox_max = [f32::MIN; 3];
890
+
891
+ for mesh in gltf.meshes() {
892
+ for primitive in mesh.primitives() {
893
+ let reader = primitive.reader(|buf| buffer_data.get(buf.index()).map(|d| d.as_slice()));
894
+ let positions: Vec<[f32; 3]> = match reader.read_positions() {
895
+ Some(iter) => iter.collect(),
896
+ None => continue,
897
+ };
898
+ let normals: Vec<[f32; 3]> = reader.read_normals()
899
+ .map(|iter| iter.collect())
900
+ .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]);
901
+ let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0)
902
+ .map(|iter| iter.into_f32().collect())
903
+ .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
904
+ let tangents: Vec<[f32; 4]> = reader.read_tangents()
905
+ .map(|iter| iter.collect())
906
+ .unwrap_or_else(|| vec![[0.0; 4]; positions.len()]);
907
+ let vert_colors: Option<Vec<[f32; 4]>> = reader.read_colors(0)
908
+ .map(|iter| iter.into_rgba_f32().collect());
909
+
910
+ let mat = primitive.material();
911
+ let pbr = mat.pbr_metallic_roughness();
912
+ let emissive_factor = mat.emissive_factor();
913
+
914
+ let tex_idx_of = |img_idx: usize| -> Option<u32> {
915
+ texture_indices.get(img_idx).copied()
916
+ };
917
+
918
+ let normal_tex_idx = mat.normal_texture()
919
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
920
+ let emissive_tex_idx = mat.emissive_texture()
921
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
922
+ let occlusion_tex_idx = mat.occlusion_texture()
923
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
924
+
925
+ // Prefer the glTF 2.0 metallic-roughness model. Fall back
926
+ // to KHR_materials_pbrSpecularGlossiness when the material
927
+ // only ships the legacy spec-gloss extension (Lumberyard
928
+ // Bistro and many FBX-exported scenes do). Conversion
929
+ // follows the reference Khronos algorithm: pick metallic
930
+ // that best explains the diffuse/specular split under the
931
+ // assumption of a 0.04 dielectric baseline, then blend
932
+ // base_color between diffuse and specular weighted by
933
+ // metallic² (metals tint their reflection, dielectrics
934
+ // show their diffuse).
935
+ let (mut base_color, mut metallic_factor, mut roughness_factor, tex_idx, mr_tex_idx) =
936
+ if pbr.base_color_texture().is_none() {
937
+ if let Some(sg) = mat.pbr_specular_glossiness() {
938
+ let diffuse = sg.diffuse_factor();
939
+ let spec = sg.specular_factor();
940
+ let (base_color, metallic) =
941
+ specgloss_to_metalrough(diffuse, spec);
942
+ let roughness = 1.0 - sg.glossiness_factor();
943
+ let diffuse_tex = sg.diffuse_texture()
944
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
945
+ (base_color, metallic, roughness, diffuse_tex, None)
946
+ } else {
947
+ (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), None, None)
948
+ }
949
+ } else {
950
+ let tex = pbr.base_color_texture()
951
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
952
+ let mr = pbr.metallic_roughness_texture()
953
+ .and_then(|info| tex_idx_of(info.texture().source().index()));
954
+ (pbr.base_color_factor(), pbr.metallic_factor(), pbr.roughness_factor(), tex, mr)
955
+ };
956
+
957
+ if let Some(t) = mat.transmission() {
958
+ apply_transmission_hack(
959
+ t.transmission_factor(),
960
+ &mut base_color,
961
+ &mut metallic_factor,
962
+ &mut roughness_factor,
963
+ );
964
+ }
965
+
966
+ let mut vertices = Vec::with_capacity(positions.len());
967
+ for i in 0..positions.len() {
968
+ let p = positions[i];
969
+ for k in 0..3 {
970
+ if p[k] < bbox_min[k] { bbox_min[k] = p[k]; }
971
+ if p[k] > bbox_max[k] { bbox_max[k] = p[k]; }
972
+ }
973
+ let color = if let Some(ref vc) = vert_colors {
974
+ vc[i]
975
+ } else {
976
+ [base_color[0], base_color[1], base_color[2], base_color[3]]
977
+ };
978
+ let joint_vals: Option<Vec<[u16; 4]>> = reader.read_joints(0)
979
+ .map(|iter| iter.into_u16().collect());
980
+ let weight_vals: Option<Vec<[f32; 4]>> = reader.read_weights(0)
981
+ .map(|iter| iter.into_f32().collect());
982
+ let jv = if let Some(ref j) = joint_vals {
983
+ [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32]
984
+ } else {
985
+ [0.0; 4]
986
+ };
987
+ let wv = if let Some(ref w) = weight_vals { w[i] } else { [0.0; 4] };
988
+ let is_skinned = wv[0] + wv[1] + wv[2] + wv[3] > 0.01;
989
+ let final_pos = if is_skinned && (skin_vertex_scale - 1.0).abs() > 0.01 {
990
+ [p[0] * skin_vertex_scale, p[1] * skin_vertex_scale, p[2] * skin_vertex_scale]
991
+ } else {
992
+ p
993
+ };
994
+ vertices.push(Vertex3D {
995
+ position: final_pos,
996
+ normal: normals[i],
997
+ color,
998
+ uv: tex_coords[i],
999
+ joints: jv,
1000
+ weights: wv,
1001
+ tangent: tangents[i],
1002
+ });
1003
+ }
1004
+ let indices: Vec<u32> = match reader.read_indices() {
1005
+ Some(iter) => iter.into_u32().collect(),
1006
+ None => (0..positions.len() as u32).collect(),
1007
+ };
1008
+ meshes.push(MeshData {
1009
+ vertices,
1010
+ indices,
1011
+ texture_idx: tex_idx,
1012
+ normal_texture_idx: normal_tex_idx,
1013
+ metallic_roughness_texture_idx: mr_tex_idx,
1014
+ emissive_texture_idx: emissive_tex_idx,
1015
+ occlusion_texture_idx: occlusion_tex_idx,
1016
+ metallic_factor,
1017
+ roughness_factor,
1018
+ emissive_factor,
1019
+ alpha_cutoff: alpha_cutoff_from_material(&mat),
1020
+ });
1021
+ }
1022
+ }
1023
+
1024
+ if meshes.is_empty() { return None; }
1025
+ Some(StagedModel {
1026
+ model: ModelData { meshes, bbox_min, bbox_max },
1027
+ textures: staged_textures,
1028
+ })
1029
+ }
1030
+
1031
+ pub(super) fn load_gltf(data: &[u8]) -> Option<ModelData> {
1032
+ let gltf = gltf::Gltf::from_slice(data).ok()?;
1033
+
1034
+ // Get buffer data (for .glb, embedded; for .gltf, inline base64)
1035
+ let mut buffer_data: Vec<Vec<u8>> = Vec::new();
1036
+ for buffer in gltf.buffers() {
1037
+ match buffer.source() {
1038
+ gltf::buffer::Source::Bin => {
1039
+ if let Some(blob) = gltf.blob.as_ref() {
1040
+ buffer_data.push(blob.clone());
1041
+ }
1042
+ }
1043
+ gltf::buffer::Source::Uri(uri) => {
1044
+ if let Some(encoded) = uri.strip_prefix("data:application/octet-stream;base64,") {
1045
+ // Try to decode base64 inline data
1046
+ let mut decoded = Vec::new();
1047
+ let _ = base64_decode(encoded, &mut decoded);
1048
+ buffer_data.push(decoded);
1049
+ } else {
1050
+ buffer_data.push(Vec::new());
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ let mut meshes = Vec::new();
1057
+ let mut bbox_min = [f32::MAX; 3];
1058
+ let mut bbox_max = [f32::MIN; 3];
1059
+
1060
+ for mesh in gltf.meshes() {
1061
+ for primitive in mesh.primitives() {
1062
+ let reader = primitive.reader(|buf| buffer_data.get(buf.index()).map(|d| d.as_slice()));
1063
+
1064
+ let positions: Vec<[f32; 3]> = match reader.read_positions() {
1065
+ Some(iter) => iter.collect(),
1066
+ None => continue,
1067
+ };
1068
+
1069
+ let normals: Vec<[f32; 3]> = reader.read_normals()
1070
+ .map(|iter| iter.collect())
1071
+ .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]);
1072
+
1073
+ let tex_coords: Vec<[f32; 2]> = reader.read_tex_coords(0)
1074
+ .map(|iter| iter.into_f32().collect())
1075
+ .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
1076
+
1077
+ // Material base color
1078
+ let base_color = primitive.material().pbr_metallic_roughness()
1079
+ .base_color_factor();
1080
+ let color = [base_color[0], base_color[1], base_color[2], base_color[3]];
1081
+
1082
+ let mut vertices = Vec::with_capacity(positions.len());
1083
+ for i in 0..positions.len() {
1084
+ let p = positions[i];
1085
+ for k in 0..3 {
1086
+ if p[k] < bbox_min[k] { bbox_min[k] = p[k]; }
1087
+ if p[k] > bbox_max[k] { bbox_max[k] = p[k]; }
1088
+ }
1089
+ // Read skin data if available
1090
+ let joint_vals: Option<Vec<[u16; 4]>> = reader.read_joints(0)
1091
+ .map(|iter| iter.into_u16().collect());
1092
+ let weight_vals: Option<Vec<[f32; 4]>> = reader.read_weights(0)
1093
+ .map(|iter| iter.into_f32().collect());
1094
+ let jv = if let Some(ref j) = joint_vals {
1095
+ [j[i][0] as f32, j[i][1] as f32, j[i][2] as f32, j[i][3] as f32]
1096
+ } else { [0.0; 4] };
1097
+ let wv = if let Some(ref w) = weight_vals { w[i] } else { [0.0; 4] };
1098
+
1099
+ vertices.push(Vertex3D {
1100
+ position: p,
1101
+ normal: normals[i],
1102
+ color,
1103
+ uv: tex_coords[i],
1104
+ joints: jv,
1105
+ weights: wv,
1106
+ tangent: [0.0; 4],
1107
+ });
1108
+ }
1109
+
1110
+ let indices: Vec<u32> = match reader.read_indices() {
1111
+ Some(iter) => iter.into_u32().collect(),
1112
+ None => (0..positions.len() as u32).collect(),
1113
+ };
1114
+
1115
+ meshes.push(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 });
1116
+ }
1117
+ }
1118
+
1119
+ if meshes.is_empty() { return None; }
1120
+ Some(ModelData { meshes, bbox_min, bbox_max })
1121
+ }
1122
+
1123
+ /// Convert a KHR_materials_pbrSpecularGlossiness (diffuse + specular
1124
+ /// + glossiness) material to the metallic-roughness model. Uses the
1125
+ /// reference Khronos two-path formula so materials authored in
1126
+ /// Substance/3ds Max/FBX pipelines (Lumberyard Bistro, many ORCA
1127
+ /// assets) render correctly on a metal-rough pipeline.
1128
+ ///
1129
+ /// High-level idea: assume a 0.04 dielectric reflectance baseline,
1130
+ /// solve for the metallic factor that best reconciles the authored
1131
+ /// diffuse and specular colors, then blend base_color between the
1132
+ /// two weighted by metallic². Metals have specular ≈ albedo, so the
1133
+ /// specular color becomes their base_color; dielectrics carry their
1134
+ /// diffuse color through at metallic ≈ 0.
1135
+ ///
1136
+ /// Map glTF alpha mode + cutoff to a single shader cutoff value.
1137
+ /// OPAQUE → 0.0 (fragment shader's `< cutoff` discard never fires).
1138
+ /// MASK → material-authored cutoff (default 0.5 per glTF spec).
1139
+ /// BLEND → treated as MASK @ 0.5 since we don't yet have a sorted
1140
+ /// transparent pipeline. Better than silently rendering
1141
+ /// foliage + fabric as fully opaque — an alpha-cutout leaf
1142
+ /// card is at least the right *shape*.
1143
+ fn alpha_cutoff_from_material(mat: &gltf::Material) -> f32 {
1144
+ match mat.alpha_mode() {
1145
+ gltf::material::AlphaMode::Opaque => 0.0,
1146
+ gltf::material::AlphaMode::Mask => mat.alpha_cutoff().unwrap_or(0.5),
1147
+ gltf::material::AlphaMode::Blend => 0.5,
1148
+ }
1149
+ }
1150
+
1151
+ /// Fake KHR_materials_transmission as a near-mirror dielectric.
1152
+ ///
1153
+ /// We don't implement real refractive transmission (no back-buffer
1154
+ /// refraction pass, no thin-walled/volume distinction), so a
1155
+ /// transmission=0.9 glass pane loads as a plain diffuse white surface
1156
+ /// that drowns the 4% Fresnel specular in a bright diffuse term — the
1157
+ /// classic "painted white window" look.
1158
+ ///
1159
+ /// As a stand-in we force heavy transmission materials to behave like
1160
+ /// chrome: metallic=1 so f0=base_color (not 0.04), roughness ≤ 0.05
1161
+ /// so reflections stay crisp, and a mild (0.85×) tint on base_color
1162
+ /// so pure-white glass doesn't read as perfectly reflective chrome.
1163
+ /// Not physically correct for glass — real glass only reflects 4% at
1164
+ /// normal incidence — but it matches how windows *read* in photos
1165
+ /// (reflecting sky/buildings) far better than a flat diffuse surface.
1166
+ fn apply_transmission_hack(
1167
+ transmission: f32,
1168
+ base_color: &mut [f32; 4],
1169
+ metallic: &mut f32,
1170
+ roughness: &mut f32,
1171
+ ) {
1172
+ if transmission > 0.5 {
1173
+ *metallic = 1.0;
1174
+ *roughness = roughness.min(0.05);
1175
+ base_color[0] *= 0.85;
1176
+ base_color[1] *= 0.85;
1177
+ base_color[2] *= 0.85;
1178
+ base_color[3] = 1.0;
1179
+ }
1180
+ }
1181
+
1182
+ /// Reference: Khronos glTF sample specGloss→metallicRoughness
1183
+ /// converter (https://github.com/KhronosGroup/glTF/pull/1355).
1184
+ fn specgloss_to_metalrough(diffuse: [f32; 4], specular: [f32; 3]) -> ([f32; 4], f32) {
1185
+ let dielectric_specular = 0.04_f32;
1186
+ let epsilon = 1e-6_f32;
1187
+
1188
+ let one_minus_dielectric = 1.0 - dielectric_specular;
1189
+ let diffuse_max = diffuse[0].max(diffuse[1]).max(diffuse[2]);
1190
+ let specular_max = specular[0].max(specular[1]).max(specular[2]);
1191
+
1192
+ // Solve a quadratic for metallic. Coefficients from the Khronos
1193
+ // reference: mapping perceived brightness split between diffuse
1194
+ // and specular back to a single metallic parameter.
1195
+ let a = dielectric_specular;
1196
+ let b = diffuse_max * one_minus_dielectric / dielectric_specular.max(epsilon)
1197
+ + specular_max
1198
+ - 2.0 * dielectric_specular;
1199
+ let c = dielectric_specular - specular_max;
1200
+ let discriminant = (b * b - 4.0 * a * c).max(0.0);
1201
+ let metallic = if specular_max < dielectric_specular {
1202
+ 0.0
1203
+ } else {
1204
+ (((-b + discriminant.sqrt()) / (2.0 * a)).clamp(0.0, 1.0)).min(1.0)
1205
+ };
1206
+
1207
+ // base_color = mix(diffuse, specular, metallic²) with the diffuse
1208
+ // branch scaled to undo the dielectric energy split.
1209
+ let diffuse_branch_scale = one_minus_dielectric
1210
+ / (1.0 - metallic * dielectric_specular).max(epsilon);
1211
+ let metal_weight = metallic * metallic;
1212
+ let lerp = |a: f32, b: f32, t: f32| a * (1.0 - t) + b * t;
1213
+ let r = lerp(diffuse[0] * diffuse_branch_scale, specular[0], metal_weight);
1214
+ let g = lerp(diffuse[1] * diffuse_branch_scale, specular[1], metal_weight);
1215
+ let bl = lerp(diffuse[2] * diffuse_branch_scale, specular[2], metal_weight);
1216
+ ([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), bl.clamp(0.0, 1.0), diffuse[3]], metallic)
1217
+ }
1218
+
1219
+ /// Replace the extension on a URI (keeps directories / query strings
1220
+ /// untouched). Used to fall back from `foo.png` → `foo.dds` when a
1221
+ /// glTF references a PNG URI that isn't on disk but the DDS sibling is.
1222
+ fn swap_extension(uri: &str, new_ext: &str) -> String {
1223
+ let q = uri.find('?').unwrap_or(uri.len());
1224
+ let (path, query) = uri.split_at(q);
1225
+ let new_path = match path.rfind('.') {
1226
+ Some(dot) if dot > path.rfind('/').unwrap_or(0) => {
1227
+ format!("{}.{}", &path[..dot], new_ext)
1228
+ }
1229
+ _ => format!("{}.{}", path, new_ext),
1230
+ };
1231
+ format!("{}{}", new_path, query)
1232
+ }
1233
+
1234
+ /// Decode a texture byte slice into RGBA8 pixels + dimensions. Tries
1235
+ /// DDS first when the URI extension suggests it (for asset packs like
1236
+ /// Lumberyard Bistro that ship BC-compressed textures), falling back
1237
+ /// to the `image` crate for PNG/JPEG/etc. Returns None on failure.
1238
+ fn decode_texture_bytes(bytes: &[u8], uri: &str) -> Option<(Vec<u8>, u32, u32)> {
1239
+ let is_dds = uri.to_ascii_lowercase().ends_with(".dds")
1240
+ || bytes.len() >= 4 && &bytes[..4] == b"DDS ";
1241
+ if is_dds {
1242
+ if let Ok(dds) = image_dds::ddsfile::Dds::read(bytes) {
1243
+ // Decode mip 0 → RGBA8. image_from_dds handles the common
1244
+ // BC1–BC7 formats; anything it can't decode falls through
1245
+ // to the image crate which will almost certainly fail too.
1246
+ if let Ok(rgba) = image_dds::image_from_dds(&dds, 0) {
1247
+ let (w, h) = (rgba.width(), rgba.height());
1248
+ return Some((rgba.into_raw(), w, h));
1249
+ }
1250
+ }
1251
+ }
1252
+ let img = image::load_from_memory(bytes).ok()?;
1253
+ let rgba = img.to_rgba8();
1254
+ let (w, h) = (rgba.width(), rgba.height());
1255
+ Some((rgba.into_raw(), w, h))
1256
+ }
1257
+
1258
+ fn base64_decode(input: &str, output: &mut Vec<u8>) {
1259
+ let mut buf = 0u32;
1260
+ let mut bits = 0u32;
1261
+ for &b in input.as_bytes() {
1262
+ let val = match b {
1263
+ b'A'..=b'Z' => b - b'A',
1264
+ b'a'..=b'z' => b - b'a' + 26,
1265
+ b'0'..=b'9' => b - b'0' + 52,
1266
+ b'+' => 62,
1267
+ b'/' => 63,
1268
+ b'=' | b'\n' | b'\r' => continue,
1269
+ _ => continue,
1270
+ };
1271
+ buf = (buf << 6) | val as u32;
1272
+ bits += 6;
1273
+ if bits >= 8 {
1274
+ bits -= 8;
1275
+ output.push((buf >> bits) as u8);
1276
+ buf &= (1 << bits) - 1;
1277
+ }
1278
+ }
1279
+ }
1280
+