Skip to main content

concinnity_core/gfx/anim_graph/
blend.rs

1// src/gfx/anim_graph/blend.rs
2//
3// What a graph state plays: a single clip or a blendspace over one or two
4// parameters. Blendspace weights are pure functions of the parameter values,
5// so the same math serves the cursor's clock (effective duration), the pose
6// sampler, and the `anim-state` debug report.
7
8use alloc::vec::Vec;
9
10/// One playable member: a clip index in the target's clip list plus its
11/// duration, copied at compile time for wrap / phase math (refreshed on clip
12/// hot-reload).
13#[derive(Debug, Clone)]
14pub struct ClipPlay {
15    /// Index into the target's clip list.
16    pub clip: usize,
17    /// The clip's duration, copied at compile time for wrap / phase math.
18    pub duration_secs: f32,
19}
20
21/// A 1D blendspace: members at ascending `thresholds` positions along one
22/// parameter. The parameter picks the bracketing pair and lerps their
23/// weights; outside the range the nearest end member plays alone.
24#[derive(Debug, Clone)]
25pub struct Blend1D {
26    /// Index of the driving parameter.
27    pub param: usize,
28    /// Member positions along the parameter, ascending.
29    pub thresholds: Vec<f32>,
30    /// One member per threshold, in the same order.
31    pub plays: Vec<ClipPlay>,
32    /// Whether members play phase-synchronised.
33    pub sync: bool,
34}
35
36/// A 2D blendspace: members on a regular grid over two parameters, weighted
37/// bilinearly across the four members surrounding the parameter point.
38/// `plays` is row-major: `plays[iy * x_values.len() + ix]` sits at
39/// `(x_values[ix], y_values[iy])`.
40#[derive(Debug, Clone)]
41pub struct Blend2D {
42    pub(crate) param_x: usize,
43    pub(crate) param_y: usize,
44    /// Grid positions along the X parameter, ascending.
45    pub x_values: Vec<f32>,
46    /// Grid positions along the Y parameter, ascending.
47    pub y_values: Vec<f32>,
48    /// Members in row-major grid order.
49    pub plays: Vec<ClipPlay>,
50    /// Whether members play phase-synchronised.
51    pub sync: bool,
52}
53
54/// What a compiled state plays.
55#[derive(Debug, Clone)]
56pub enum StatePlay {
57    /// A single clip.
58    Clip(ClipPlay),
59    /// A 1D blendspace over one parameter.
60    Blend1D(Blend1D),
61    /// A 2D blendspace over two parameters.
62    Blend2D(Blend2D),
63}
64
65impl StatePlay {
66    /// Whether members share one normalized phase clock (foot-phase
67    /// alignment). Single clips have nothing to sync against.
68    pub fn sync(&self) -> bool {
69        match self {
70            StatePlay::Clip(_) => false,
71            StatePlay::Blend1D(b) => b.sync,
72            StatePlay::Blend2D(b) => b.sync,
73        }
74    }
75
76    /// The playable members, in weight order.
77    pub fn members(&self) -> &[ClipPlay] {
78        match self {
79            StatePlay::Clip(play) => core::slice::from_ref(play),
80            StatePlay::Blend1D(b) => &b.plays,
81            StatePlay::Blend2D(b) => &b.plays,
82        }
83    }
84
85    pub(crate) fn members_mut(&mut self) -> &mut [ClipPlay] {
86        match self {
87            StatePlay::Clip(play) => core::slice::from_mut(play),
88            StatePlay::Blend1D(b) => &mut b.plays,
89            StatePlay::Blend2D(b) => &mut b.plays,
90        }
91    }
92
93    /// One weight per member at the given parameter values. A single clip is
94    /// always `[1.0]`.
95    pub fn weights(&self, params: &[f32]) -> Vec<f32> {
96        let mut out = Vec::new();
97        self.weights_into(params, &mut out);
98        out
99    }
100
101    /// `weights` written into `out` (cleared first, so its capacity is
102    /// reused). The per-frame sampling path calls this with a persistent
103    /// scratch buffer so steady-state blending allocates nothing.
104    pub fn weights_into(&self, params: &[f32], out: &mut Vec<f32>) {
105        let at = |i: usize| params.get(i).copied().unwrap_or(0.0);
106        out.clear();
107        match self {
108            StatePlay::Clip(_) => out.push(1.0),
109            StatePlay::Blend1D(b) => blend1d_weights_into(&b.thresholds, at(b.param), out),
110            StatePlay::Blend2D(b) => {
111                blend2d_weights_into(&b.x_values, &b.y_values, at(b.param_x), at(b.param_y), out)
112            }
113        }
114    }
115
116    /// Weighted-average member duration: the length of one pass of the state
117    /// at the given weights. Members with zero weight contribute nothing, so
118    /// a pure-walk pose is one walk cycle long.
119    pub fn effective_duration(&self, weights: &[f32]) -> f32 {
120        let members = self.members();
121        let mut total_w = 0.0f32;
122        let mut acc = 0.0f32;
123        for (member, &w) in members.iter().zip(weights) {
124            let w = w.max(0.0);
125            acc += w * member.duration_secs;
126            total_w += w;
127        }
128        if total_w <= 1e-6 {
129            members.first().map(|m| m.duration_secs).unwrap_or(0.0)
130        } else {
131            acc / total_w
132        }
133    }
134}
135
136/// 1D blendspace weights: `x` against ascending `thresholds`. Outside the
137/// range the nearest end gets full weight; inside, the bracketing pair lerps.
138/// At most two weights are nonzero.
139pub fn blend1d_weights(thresholds: &[f32], x: f32) -> Vec<f32> {
140    let mut weights = Vec::new();
141    blend1d_weights_into(thresholds, x, &mut weights);
142    weights
143}
144
145// `blend1d_weights` written into `weights` (cleared first).
146pub(super) fn blend1d_weights_into(thresholds: &[f32], x: f32, weights: &mut Vec<f32>) {
147    weights.clear();
148    weights.resize(thresholds.len(), 0.0);
149    let Some((&first, &last)) = thresholds.first().zip(thresholds.last()) else {
150        return;
151    };
152    if x <= first {
153        weights[0] = 1.0;
154        return;
155    }
156    if x >= last {
157        *weights.last_mut().expect("non-empty") = 1.0;
158        return;
159    }
160    for i in 0..thresholds.len() - 1 {
161        let (a, b) = (thresholds[i], thresholds[i + 1]);
162        if x >= a && x <= b {
163            let f = (x - a) / (b - a).max(1e-6);
164            weights[i] = 1.0 - f;
165            weights[i + 1] = f;
166            break;
167        }
168    }
169}
170
171/// 2D blendspace weights: bilinear over the grid cell containing `(x, y)`,
172/// clamped to the grid edges. Row-major to match `Blend2D::plays`; at most
173/// four weights are nonzero.
174pub fn blend2d_weights(x_values: &[f32], y_values: &[f32], x: f32, y: f32) -> Vec<f32> {
175    let mut weights = Vec::new();
176    blend2d_weights_into(x_values, y_values, x, y, &mut weights);
177    weights
178}
179
180// `blend2d_weights` written into `weights` (cleared first).
181pub(super) fn blend2d_weights_into(
182    x_values: &[f32],
183    y_values: &[f32],
184    x: f32,
185    y: f32,
186    weights: &mut Vec<f32>,
187) {
188    weights.clear();
189    weights.resize(x_values.len() * y_values.len(), 0.0);
190    if x_values.is_empty() || y_values.is_empty() {
191        return;
192    }
193    let (ix, fx) = axis_segment(x_values, x);
194    let (iy, fy) = axis_segment(y_values, y);
195    let nx = x_values.len();
196    let ix1 = (ix + 1).min(nx - 1);
197    let iy1 = (iy + 1).min(y_values.len() - 1);
198    weights[iy * nx + ix] += (1.0 - fx) * (1.0 - fy);
199    weights[iy * nx + ix1] += fx * (1.0 - fy);
200    weights[iy1 * nx + ix] += (1.0 - fx) * fy;
201    weights[iy1 * nx + ix1] += fx * fy;
202}
203
204// The segment of an ascending axis containing `v`: the lower sample index
205// plus the fraction toward the next. Clamps outside the range.
206fn axis_segment(values: &[f32], v: f32) -> (usize, f32) {
207    if v <= values[0] || values.len() == 1 {
208        return (0, 0.0);
209    }
210    if v >= values[values.len() - 1] {
211        return (values.len() - 1, 0.0);
212    }
213    for i in 0..values.len() - 1 {
214        let (a, b) = (values[i], values[i + 1]);
215        if v >= a && v <= b {
216            return (i, (v - a) / (b - a).max(1e-6));
217        }
218    }
219    (values.len() - 1, 0.0)
220}