onedrop_eval/compiled_block.rs
1//! Compiled block: bundles a [`Vec<Node>`] (evalexpr fallback) with an
2//! optional [`CompiledBytecode`] lowering.
3//!
4//! Most preset hot loops (per-frame eqs, per-vertex warp eqs, custom-shape
5//! per-frame, custom-wave per-frame / per_frame_init, …) want the same
6//! "prefer bytecode VM, fall back to evalexpr Node walk" branching that
7//! [`onedrop-engine::engine::wave_phase`] already wires by hand for the
8//! per_point hot path. This newtype hoists that pattern into the eval
9//! crate so every call site picks it up uniformly.
10//!
11//! - `from_nodes(Vec<Node>)` — try-compiles the bytecode lazily; on
12//! failure (an op the VM doesn't support: `rand`, `gmegabuf`, …) the
13//! block keeps the Nodes and runtime falls back transparently.
14//! - `run(&mut MilkContext)` — single entry point that picks the
15//! bytecode path when available. The bytecode path is infallible by
16//! construction (`try_compile` is the only failure point); the
17//! fallback discards per-equation errors silently.
18//! - `nodes()` / `bytecode()` — exposed for call sites that still want
19//! per-equation error logging or special handling (sequential vs
20//! parallel-samples in [`onedrop-engine::engine::wave_phase`]).
21
22use crate::bytecode::CompiledBytecode;
23use crate::context::MilkContext;
24use evalexpr::Node;
25
26/// A pre-compiled block of expressions ready to execute against a
27/// [`MilkContext`]. See module docs.
28#[derive(Debug, Clone)]
29pub struct CompiledBlock {
30 nodes: Vec<Node>,
31 bytecode: Option<CompiledBytecode>,
32}
33
34impl CompiledBlock {
35 /// Take ownership of `nodes` and try to lower them to bytecode
36 /// against `ctx`. Cold-variable names referenced by the block are
37 /// interned into `ctx.cold` so the emitted opcodes carry slab
38 /// indices.
39 ///
40 /// On lowering failure (any unsupported operator/function) the
41 /// block stores `bytecode: None` and the runtime falls back to
42 /// evalexpr's tree-walking interpreter — same observable
43 /// behaviour, just slower.
44 pub fn from_nodes(nodes: Vec<Node>, ctx: &mut MilkContext) -> Self {
45 let bytecode = if nodes.is_empty() {
46 None
47 } else {
48 CompiledBytecode::try_compile(&nodes, ctx).ok()
49 };
50 Self { nodes, bytecode }
51 }
52
53 /// Empty block — `run` is a no-op.
54 pub fn empty() -> Self {
55 Self {
56 nodes: Vec::new(),
57 bytecode: None,
58 }
59 }
60
61 /// Underlying evalexpr nodes. Always populated, even when bytecode
62 /// is also available (kept around as the fallback path for the few
63 /// runtime decisions that need per-node access — e.g. per-equation
64 /// error logging).
65 pub fn nodes(&self) -> &[Node] {
66 &self.nodes
67 }
68
69 /// Bytecode lowering when available. `None` means at least one
70 /// node uses an op the VM doesn't support; runtime falls back to
71 /// the Nodes path.
72 pub fn bytecode(&self) -> Option<&CompiledBytecode> {
73 self.bytecode.as_ref()
74 }
75
76 pub fn is_empty(&self) -> bool {
77 self.nodes.is_empty()
78 }
79
80 pub fn len(&self) -> usize {
81 self.nodes.len()
82 }
83
84 /// Execute the block against `ctx`. Prefers the bytecode VM when
85 /// available; otherwise iterates the evalexpr nodes and discards
86 /// per-equation errors silently. Call sites that want to log
87 /// per-equation failures should use [`CompiledBlock::nodes`] +
88 /// [`CompiledBlock::bytecode`] directly.
89 pub fn run(&self, ctx: &mut MilkContext) {
90 if let Some(bc) = &self.bytecode {
91 bc.run(ctx);
92 } else {
93 for node in &self.nodes {
94 let _ = node.eval_with_context_mut(ctx);
95 }
96 }
97 }
98}
99
100impl Default for CompiledBlock {
101 fn default() -> Self {
102 Self::empty()
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::MilkEvaluator;
110
111 fn compile(eqs: &[&str]) -> CompiledBlock {
112 let mut ev = MilkEvaluator::new();
113 let owned: Vec<String> = eqs.iter().map(|s| s.to_string()).collect();
114 let nodes = ev.compile_batch(&owned).unwrap();
115 CompiledBlock::from_nodes(nodes, ev.context_mut())
116 }
117
118 #[test]
119 fn lowers_to_bytecode_when_supported() {
120 let block = compile(&["x = 0.5", "y = x * 2.0"]);
121 assert!(
122 block.bytecode().is_some(),
123 "simple arithmetic must lower to bytecode"
124 );
125 assert_eq!(block.len(), 2);
126 }
127
128 #[test]
129 fn falls_back_to_nodes_on_unsupported_op() {
130 // `lerp` has no bytecode opcode and isn't a registered evalexpr
131 // builtin either — the compile-fallback path keeps the block on
132 // the Node walker so the renderer doesn't crash on it.
133 let block = compile(&["x = lerp(0, 1, 0.5)"]);
134 assert!(
135 block.bytecode().is_none(),
136 "lerp() must keep block on Node path"
137 );
138 assert_eq!(block.len(), 1);
139 }
140
141 #[test]
142 fn empty_run_is_noop() {
143 let block = CompiledBlock::empty();
144 let mut ctx = MilkContext::new();
145 block.run(&mut ctx); // no panic
146 assert!(block.is_empty());
147 assert_eq!(block.len(), 0);
148 }
149
150 #[test]
151 fn run_bytecode_and_nodes_paths_match() {
152 // Bytecode-lowerable block.
153 let bc_block = compile(&["x = 0.25", "y = x * 4.0", "r = sin(x)"]);
154 assert!(bc_block.bytecode().is_some());
155 let mut ctx_bc = MilkContext::new();
156 bc_block.run(&mut ctx_bc);
157
158 // Force the Node fallback: same eqs, but discard the bytecode.
159 let mut nodes_block = bc_block.clone();
160 nodes_block.bytecode = None;
161 let mut ctx_nodes = MilkContext::new();
162 nodes_block.run(&mut ctx_nodes);
163
164 for name in ["x", "y", "r"] {
165 let a = ctx_bc.get(name).unwrap();
166 let b = ctx_nodes.get(name).unwrap();
167 assert!((a - b).abs() < 1e-12, "{name}: bytecode={a} nodes={b}");
168 }
169 }
170}