Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions native/shared/src/anim_mixer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! EN-028 — per-model animation mixer state.
//!
//! Split out of `models.rs` (EN-052 line budget) when EN-055 landed: the
//! mixer is exactly the state that is PER-INSTANCE under animation
//! instancing, so it earns its own module. Re-exported from `models`, so
//! `crate::models::AnimMixer` paths are unchanged.
//!
//! Three things a single-clip sampler cannot do, all of which read as
//! "cheap" on screen: transitions pop, an attacking character has to stop
//! walking, and authored locomotion arcs (a pounce, a lunge) get replaced
//! by hand-tuned kinematics because the root is nailed to the rest pose.
//!
//! Layout: a base track that crossfades from `prev` to `cur` over
//! `fade_dur`, plus one optional additive-by-mask layer (an attack driving
//! the spine-up while the legs keep walking). All clocks advance in
//! `advance_animation`, so the game hands over a dt and never tracks clip
//! time itself.

#[derive(Clone)]
pub struct AnimMixer {
pub cur_clip: usize,
pub cur_time: f32,
pub cur_speed: f32,
pub cur_loop: bool,
/// Clip we are fading *out* of. `fade_dur <= 0` means no fade in flight.
pub prev_clip: usize,
pub prev_time: f32,
pub prev_speed: f32,
pub prev_loop: bool,
pub fade_t: f32,
pub fade_dur: f32,
/// Masked layer. `layer_clip < 0` = inactive.
pub layer_clip: i32,
pub layer_time: f32,
pub layer_speed: f32,
pub layer_loop: bool,
pub layer_weight: f32,
/// Root joint of the masked subtree (e.g. the spine). Every joint at or
/// below it takes the layer pose; everything else keeps the base pose.
pub layer_mask_root: i32,
/// Opt-in root motion. Off by default so existing games are unchanged.
pub root_motion: bool,
pub root_delta: [f32; 3],
/// True once a non-looping `cur_clip` has played past its duration.
pub finished: bool,
pub started: bool,
}

impl Default for AnimMixer {
fn default() -> Self {
Self {
cur_clip: 0, cur_time: 0.0, cur_speed: 1.0, cur_loop: true,
prev_clip: 0, prev_time: 0.0, prev_speed: 1.0, prev_loop: true,
fade_t: 0.0, fade_dur: 0.0,
layer_clip: -1, layer_time: 0.0, layer_speed: 1.0, layer_loop: false,
layer_weight: 0.0, layer_mask_root: -1,
root_motion: false, root_delta: [0.0; 3],
finished: false, started: false,
}
}
}
50 changes: 42 additions & 8 deletions native/shared/src/ffi_core/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,24 @@ macro_rules! __bloom_ffi_models {
0.0
}

// bloom_instantiate_animation [source: EN-055; gated: models3d]
// A fresh animation INSTANCE (own mixer/joint state) over an
// already-loaded clip set — clip data is Arc-shared, so N characters
// no longer cost N GLB re-parses at load.
#[cfg(feature = "models3d")]
#[no_mangle]
pub extern "C" fn bloom_instantiate_animation(src: f64) -> f64 {
$crate::ffi::guard("bloom_instantiate_animation", move || {
engine().models.instantiate_animation(src)
})
}
#[cfg(not(feature = "models3d"))]
#[no_mangle]
pub extern "C" fn bloom_instantiate_animation(_src: f64) -> f64 {
$crate::ffi::feature_off_warn_once("bloom_instantiate_animation", "models3d");
0.0
}

// bloom_update_model_animation [source: linux; gated: models3d]
#[cfg(feature = "models3d")]
#[no_mangle]
Expand Down Expand Up @@ -811,20 +829,36 @@ macro_rules! __bloom_ffi_models {
None => return 0.0,
};
let eng = engine();
// Normal maps must go through the kind-aware registration
// (linear space + LEADR mips) or the committed model shades
// visibly flatter than the same GLB through loadModel.
let mut tex_map: Vec<u32> = Vec::with_capacity(staged.textures.len());
for tex in &staged.textures {
tex_map.push(eng.renderer.register_texture(tex.width, tex.height, &tex.data));
tex_map.push(eng.renderer.register_texture_kind(
tex.width, tex.height, &tex.data, tex.is_normal));
}
let mut model = staged.model;
for mesh in &mut model.meshes {
if let Some(ref mut idx) = mesh.texture_idx {
let staged_idx = *idx as usize;
if staged_idx > 0 && staged_idx <= tex_map.len() {
*idx = tex_map[staged_idx - 1];
// Remap EVERY texture slot, not just the base colour — this
// used to drop normal/MR/emissive/occlusion references on the
// floor (the round-5 "stale aux-texture remap" bug), which
// silently stripped the SH-046 character maps on any model
// loaded through the staged path.
let remap = |idx: &mut Option<u32>| {
if let Some(i) = *idx {
let s = i as usize;
*idx = if s > 0 && s <= tex_map.len() {
Some(tex_map[s - 1])
} else {
mesh.texture_idx = None;
}
None
};
}
};
for mesh in &mut model.meshes {
remap(&mut mesh.texture_idx);
remap(&mut mesh.normal_texture_idx);
remap(&mut mesh.metallic_roughness_texture_idx);
remap(&mut mesh.emissive_texture_idx);
remap(&mut mesh.occlusion_texture_idx);
}
eng.models.models.alloc(model)
})
Expand Down
1 change: 1 addition & 0 deletions native/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod text_renderer;
pub mod audio;
pub mod textures;
#[cfg(feature = "models3d")]
pub mod anim_mixer;
pub mod models;
pub mod scene;
pub mod frame_callbacks;
Expand Down
112 changes: 49 additions & 63 deletions native/shared/src/models.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::handles::HandleRegistry;
use crate::renderer::Vertex3D;
use std::sync::Arc;

pub struct MeshData {
pub vertices: Vec<Vertex3D>,
Expand Down Expand Up @@ -56,69 +57,20 @@ pub struct SkeletonData {
pub root_joints: Vec<usize>,
}

/// EN-028 — per-model animation mixer.
///
/// Three things a single-clip sampler cannot do, all of which read as
/// "cheap" on screen: transitions pop, an attacking character has to stop
/// walking, and authored locomotion arcs (a pounce, a lunge) get replaced
/// by hand-tuned kinematics because the root is nailed to the rest pose.
///
/// Layout: a base track that crossfades from `prev` to `cur` over
/// `fade_dur`, plus one optional additive-by-mask layer (an attack driving
/// the spine-up while the legs keep walking). All clocks advance in
/// `advance_animation`, so the game hands over a dt and never tracks clip
/// time itself.
#[derive(Clone)]
pub struct AnimMixer {
pub cur_clip: usize,
pub cur_time: f32,
pub cur_speed: f32,
pub cur_loop: bool,
/// Clip we are fading *out* of. `fade_dur <= 0` means no fade in flight.
pub prev_clip: usize,
pub prev_time: f32,
pub prev_speed: f32,
pub prev_loop: bool,
pub fade_t: f32,
pub fade_dur: f32,
/// Masked layer. `layer_clip < 0` = inactive.
pub layer_clip: i32,
pub layer_time: f32,
pub layer_speed: f32,
pub layer_loop: bool,
pub layer_weight: f32,
/// Root joint of the masked subtree (e.g. the spine). Every joint at or
/// below it takes the layer pose; everything else keeps the base pose.
pub layer_mask_root: i32,
/// Opt-in root motion. Off by default so existing games are unchanged.
pub root_motion: bool,
pub root_delta: [f32; 3],
/// True once a non-looping `cur_clip` has played past its duration.
pub finished: bool,
pub started: bool,
}

impl Default for AnimMixer {
fn default() -> Self {
Self {
cur_clip: 0, cur_time: 0.0, cur_speed: 1.0, cur_loop: true,
prev_clip: 0, prev_time: 0.0, prev_speed: 1.0, prev_loop: true,
fade_t: 0.0, fade_dur: 0.0,
layer_clip: -1, layer_time: 0.0, layer_speed: 1.0, layer_loop: false,
layer_weight: 0.0, layer_mask_root: -1,
root_motion: false, root_delta: [0.0; 3],
finished: false, started: false,
}
}
}
pub use crate::anim_mixer::AnimMixer;

pub struct ModelAnimation {
pub skeleton: Option<SkeletonData>,
pub animations: Vec<AnimationData>,
pub joint_matrices: Vec<[[f32; 4]; 4]>,
/// EN-055 — the parsed clip data (skeleton, keyframe tracks, rest
/// rotations) is IMMUTABLE after load and shared between instances via
/// `Arc`: `instantiate_animation` clones the handles, not the data. Only
/// the fields below the shared block are per-instance state.
pub skeleton: Option<Arc<SkeletonData>>,
pub animations: Arc<Vec<AnimationData>>,
/// Reference rest-pose rotations (from first animation, sampled at t=0).
/// Used for retargeting when multiple armatures have different rest orientations.
pub ref_rest_rotations: Option<Vec<[f32; 4]>>,
pub ref_rest_rotations: Option<Arc<Vec<[f32; 4]>>>,
// ---- per-instance state from here down ---------------------------------
pub joint_matrices: Vec<[[f32; 4]; 4]>,
/// EN-028 mixer state.
pub mixer: AnimMixer,
/// EN-033 — joint world transforms *before* the inverse-bind multiply.
Expand Down Expand Up @@ -550,6 +502,29 @@ impl ModelManager {
}
}

/// EN-055 — a new animation INSTANCE over an already-loaded clip set.
/// The parsed data (skeleton, keyframe tracks, rest rotations) is shared
/// via `Arc`; the mixer, joint matrices and mask cache are fresh — so N
/// characters get independent clocks/fades without N GLB re-parses
/// (which was 5.5 s of the shooter's 8 s boot). Returns 0 for a dead
/// source handle, mirroring load's failure convention.
pub fn instantiate_animation(&mut self, src: f64) -> f64 {
let inst = match self.animations.get(src) {
Some(a) => ModelAnimation {
skeleton: a.skeleton.clone(),
animations: a.animations.clone(),
ref_rest_rotations: a.ref_rest_rotations.clone(),
joint_matrices: a.joint_matrices.clone(),
mixer: AnimMixer::default(),
joint_world: a.joint_world.clone(),
mask_weights: vec![0.0; a.mask_weights.len()],
mask_cached_root: -1,
},
None => return 0.0,
};
self.animations.alloc(inst)
}

pub fn update_model_animation(&mut self, handle: f64, anim_index: usize, time: f32) {
if let Some(model_anim) = self.animations.get_mut(handle) {
let skeleton = match &model_anim.skeleton {
Expand Down Expand Up @@ -1425,10 +1400,10 @@ fn load_gltf_animation(data: &[u8]) -> Option<ModelAnimation> {
} else { None };

Some(ModelAnimation {
skeleton,
animations,
skeleton: skeleton.map(Arc::new),
animations: Arc::new(animations),
joint_matrices: vec![mat4_identity(); joint_count],
ref_rest_rotations,
ref_rest_rotations: ref_rest_rotations.map(Arc::new),
mixer: AnimMixer::default(),
joint_world: vec![mat4_identity(); joint_count],
mask_weights: vec![0.0; joint_count],
Expand Down Expand Up @@ -1847,12 +1822,22 @@ pub fn load_gltf_staged(data: &[u8]) -> Option<crate::staging::StagedModel> {
}
}

// Pre-walk materials for the image indices used as normal maps — they
// must be registered via register_texture_kind's linear/LEADR path at
// commit time (same pre-walk as load_gltf_with_textures).
let mut normal_image_set: std::collections::HashSet<usize> = Default::default();
for mat in gltf.materials() {
if let Some(nt) = mat.normal_texture() {
normal_image_set.insert(nt.texture().source().index());
}
}

// Decode textures to RGBA without GPU registration.
// staged_textures[i] corresponds to glTF image index i.
// texture_indices maps glTF image index -> 1-based index into staged_textures (0 = no texture).
let mut staged_textures: Vec<StagedTexture> = Vec::new();
let mut texture_indices: Vec<u32> = Vec::new();
for image in gltf.images() {
for (image_idx, image) in gltf.images().enumerate() {
match image.source() {
gltf::image::Source::View { view, .. } => {
let buf_idx = view.buffer().index();
Expand All @@ -1868,6 +1853,7 @@ pub fn load_gltf_staged(data: &[u8]) -> Option<crate::staging::StagedModel> {
data: rgba.into_raw(),
width: w,
height: h,
is_normal: normal_image_set.contains(&image_idx),
});
// 1-based index into staged_textures
texture_indices.push(staged_textures.len() as u32);
Expand Down
9 changes: 8 additions & 1 deletion native/shared/src/staging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ pub struct StagedTexture {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
/// Normal maps need `register_texture_kind`'s linear-space + LEADR mip
/// path at commit time; registering them like albedo (sRGB) visibly
/// flattens the shading. Set by `load_gltf_staged` from the material's
/// `normal_texture` references, mirroring `load_gltf_with_textures`.
pub is_normal: bool,
Comment on lines +10 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file outline ==\n'
ast-grep outline native/shared/src/staging.rs --view expanded || true

printf '\n== relevant symbols ==\n'
rg -n "is_normal|load_gltf_staged|decode_and_stage_texture|register_texture_kind|StagedTexture|normal_texture|base_color|texture kind" native/shared/src/staging.rs native/shared/src -S

printf '\n== staging.rs excerpt ==\n'
sed -n '1,220p' native/shared/src/staging.rs

printf '\n== nearby commit/registry code ==\n'
rg -n "register_texture_kind|commit.*texture|is_normal" native/shared/src -S

Repository: Bloom-Engine/engine

Length of output: 23816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== list files in native/shared/src ==\n'
git ls-files native/shared/src | sed -n '1,120p'

printf '\n== ast outline for candidate files that mention registration ==\n'
for f in $(git ls-files native/shared/src | grep -E 'staging\.rs|texture|gltf|commit'); do
  echo "--- $f ---"
  ast-grep outline "$f" --view expanded 2>/dev/null || true
done

Repository: Bloom-Engine/engine

Length of output: 6973


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== models.rs around load_gltf_with_textures ==\n'
sed -n '1430,1535p' native/shared/src/models.rs

printf '\n== models.rs around load_gltf_staged ==\n'
sed -n '1800,1875p' native/shared/src/models.rs

printf '\n== texture_store.rs register_texture_kind ==\n'
sed -n '80,165p' native/shared/src/renderer/texture_store.rs

printf '\n== ffi_core/models.rs texture commit path ==\n'
sed -n '800,875p' native/shared/src/ffi_core/models.rs

Repository: Bloom-Engine/engine

Length of output: 17010


Track texture kind per material use, not per image. A glTF image can be reused as both a normal map and a base-color texture; this image-level is_normal flag forces one registration kind for every reference to that image, so one of the roles renders incorrectly. Split the staged texture by usage or carry the role through commit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/staging.rs` around lines 10 - 14, Replace the image-level
is_normal tracking in the staged texture flow with per-material-use texture-kind
tracking. Update load_gltf_staged and the commit-time registration path around
register_texture_kind so an image referenced as both normal and base-color is
registered appropriately for each usage, either by creating separate staged
entries or by propagating the role through commit; do not apply one global role
to all references.

}

#[cfg(feature = "models3d")]
Expand Down Expand Up @@ -65,7 +70,9 @@ pub fn decode_and_stage_texture(file_data: &[u8]) -> f64 {
};
let width = img.width();
let height = img.height();
stage_texture(StagedTexture { data: img.into_raw(), width, height })
// Standalone staged textures are albedo-class; nothing routes a normal
// map through this path (models carry theirs inside StagedModel).
stage_texture(StagedTexture { data: img.into_raw(), width, height, is_normal: false })
}

pub fn stage_texture(tex: StagedTexture) -> f64 {
Expand Down
1 change: 1 addition & 0 deletions native/watchos/src/ffi_stubs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
}
#[no_mangle] pub extern "C" fn bloom_draw_material(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64) {
}
#[no_mangle] pub extern "C" fn bloom_instantiate_animation(_p0: f64) -> f64 { 0.0 }
#[no_mangle] pub extern "C" fn bloom_load_model_animation(_p0: i64) -> f64 {
0.0
}
Expand Down
5 changes: 5 additions & 0 deletions native/web/src/material_ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,11 @@ pub fn bloom_load_model_animation_bytes(data: &[u8]) -> f64 {
engine().models.load_model_animation(data)
}

#[wasm_bindgen]
pub fn bloom_instantiate_animation(src: f64) -> f64 {
engine().models.instantiate_animation(src)
}

#[wasm_bindgen]
pub fn bloom_update_model_animation(_handle: f64, _anim_index: f64, _time: f64, _scale: f64, _px: f64, _py: f64, _pz: f64, _rot_sin: f64, _rot_cos: f64) {
// TODO: Phase 4 — depends on bloom_load_model_animation
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,13 @@
],
"returns": "f64"
},
{
"name": "bloom_instantiate_animation",
"params": [
"f64"
],
"returns": "f64"
},
{
"name": "bloom_update_model_animation",
"params": [
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export {
createPlanarReflection, setMaterialReflectionProbe, setMaterialProbeVisible,
compileMaterialFromFile, loadMaterial,
createMeshExplicit,
loadModelAnimation, updateModelAnimation, createMesh,
loadModelAnimation, instantiateAnimation, updateModelAnimation, createMesh,
setAmbientLight, setDirectionalLight, setJointTest,
setProceduralSky, setSunDirection,
loadModelAsync, stageModels, commitModel,
Expand Down
9 changes: 9 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ declare function bloom_compile_material_from_file(path: number, bucketKind: numb
declare function bloom_set_material_params(handle: number, paramsPtr: any, paramCount: number): void;
declare function bloom_draw_material(material: number, meshHandle: number, meshIdx: number, x: number, y: number, z: number, scale: number, r: number, g: number, b: number, a: number): void;
declare function bloom_load_model_animation(path: number): number;
declare function bloom_instantiate_animation(src: number): number;
declare function bloom_update_model_animation(handle: number, animIndex: number, time: number, scale: number, px: number, py: number, pz: number, rotY: number): void;
declare function bloom_create_mesh(vertexPtr: number, vertexCount: number, indexPtr: number, indexCount: number): number;
declare function bloom_mesh_scratch_reset(): void;
Expand Down Expand Up @@ -811,6 +812,14 @@ export function loadModelAnimation(path: string): number {
return bloom_load_model_animation(path as any);
}

/// EN-055 — a fresh animation INSTANCE (own mixer, own joint matrices) over
/// an already-loaded clip set. The parsed keyframe data is shared, so a crowd
/// of N characters costs one GLB parse + N cheap instances instead of N
/// parses. Returns 0 if `src` is not a live animation handle.
export function instantiateAnimation(src: number): number {
return bloom_instantiate_animation(src);
}

export function updateModelAnimation(handle: number, animIndex: number, time: number, scale: number, px: number, py: number, pz: number, rotY: number): void {
bloom_update_model_animation(handle, animIndex, time, scale, px, py, pz, rotY);
}
Expand Down
Loading
Loading