use std::collections::HashMap;
use bevy::{
asset::AssetPath,
math::{UVec2, Vec2, Vec3, Vec4},
reflect::{
PartialReflect, Reflect, ReflectMut, TypePath, TypeRegistry,
enums::{DynamicEnum, DynamicVariant},
},
};
use bevy_hanabi::{
BoxedModifier, EffectAsset, Expr, ExprHandle, ModifierContext, Module, ReflectModifier,
SetPositionCircleModifier, SetVelocityCircleModifier, SetVelocityTangentModifier,
TangentAccelModifier, Value,
graph::expr::{PropertyHandle, TextureSampleExpr},
};
use super::{
model::{
EditValue, EffectGraph, ExprNode, GradientVec3, GradientVec4, ImageBinding,
ModifierNodeData, NodeId, NodePayload, PortRef, PropertyDef, PropertyId, SharedStr, SlotId,
},
schema::{FieldRole, modifier_schema},
};
use crate::ModifierGroup;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BakeSubject {
Node(NodeId),
Property(PropertyId),
Graph,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BakeError {
pub subject: BakeSubject,
pub message: String,
}
impl BakeError {
fn node(node: NodeId, message: impl Into<String>) -> Self {
Self {
subject: BakeSubject::Node(node),
message: message.into(),
}
}
fn property(id: PropertyId, message: impl Into<String>) -> Self {
Self {
subject: BakeSubject::Property(id),
message: message.into(),
}
}
#[allow(dead_code)]
fn graph(message: impl Into<String>) -> Self {
Self {
subject: BakeSubject::Graph,
message: message.into(),
}
}
}
struct PropertyBindings<'a> {
handles: HashMap<PropertyId, PropertyHandle>,
defs: HashMap<PropertyId, &'a PropertyDef>,
}
fn bake_properties<'a>(
graph: &'a EffectGraph,
module: &mut Module,
errors: &mut Vec<BakeError>,
) -> PropertyBindings<'a> {
let mut handles = HashMap::new();
let mut defs = HashMap::with_capacity(graph.properties.len());
let mut exposed_names: HashMap<&str, PropertyId> = HashMap::new();
for prop in &graph.properties {
if defs.insert(prop.id, prop).is_some() {
errors.push(BakeError::property(
prop.id,
format!("duplicate property id {}", prop.id.get()),
));
continue;
}
if prop.exposed {
let name: &str = &prop.name;
if exposed_names.contains_key(name) {
errors.push(BakeError::property(
prop.id,
format!("two exposed properties share the name '{name}'; rename one to bake"),
));
continue;
}
exposed_names.insert(name, prop.id);
let handle = module.add_property(name, prop.default);
handles.insert(prop.id, handle);
}
}
PropertyBindings { handles, defs }
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum LiteralSite {
Node(NodeId),
Input { node: NodeId, port: SharedStr },
}
pub type LiteralSites = HashMap<LiteralSite, ExprHandle>;
#[derive(Debug, Clone, PartialEq)]
pub enum PlannedImage {
Asset(AssetPath<'static>),
Runtime(SharedStr),
Unbound,
}
pub type TexturePlan = Vec<PlannedImage>;
#[derive(Debug, Clone, Default)]
pub struct BakeProvenance {
pub literal_sites: LiteralSites,
pub texture_plan: TexturePlan,
}
struct ExprBaker<'a, 'm> {
graph: &'a EffectGraph,
props: &'a PropertyBindings<'a>,
module: &'m mut Module,
handles: HashMap<NodeId, ExprHandle>,
literal_sites: HashMap<LiteralSite, ExprHandle>,
visiting: Vec<NodeId>,
texture_plan: TexturePlan,
used_slot_names: std::collections::HashSet<String>,
registry_slots: HashMap<SlotId, usize>,
pinned_asset_slots: HashMap<AssetPath<'static>, usize>,
image_node_slots: HashMap<NodeId, usize>,
}
impl<'a, 'm> ExprBaker<'a, 'm> {
fn new(
graph: &'a EffectGraph,
props: &'a PropertyBindings<'a>,
module: &'m mut Module,
) -> Self {
let mut baker = Self {
graph,
props,
module,
handles: HashMap::new(),
literal_sites: HashMap::new(),
visiting: Vec::new(),
texture_plan: Vec::new(),
used_slot_names: std::collections::HashSet::new(),
registry_slots: HashMap::new(),
pinned_asset_slots: HashMap::new(),
image_node_slots: HashMap::new(),
};
for slot in &graph.texture_slots {
let index = baker.alloc_slot(&slot.name, PlannedImage::Runtime(slot.name.clone()));
baker.registry_slots.insert(slot.id, index);
}
baker
}
fn resolve(&mut self, node_id: NodeId, errors: &mut Vec<BakeError>) -> Option<ExprHandle> {
if let Some(h) = self.handles.get(&node_id) {
return Some(*h);
}
if self.visiting.contains(&node_id) {
errors.push(BakeError::node(node_id, "expression cycle"));
return None;
}
let node = self.graph.node(node_id).or_else(|| {
errors.push(BakeError::node(
node_id,
format!("link references missing node {}", node_id.get()),
));
None
})?;
let NodePayload::Expr(expr) = &node.payload else {
errors.push(BakeError::node(
node_id,
"expected an expression node as a link source",
));
return None;
};
self.visiting.push(node_id);
let handle = self.bake_expr(node_id, expr, errors);
self.visiting.pop();
if let Some(h) = handle {
self.handles.insert(node_id, h);
}
handle
}
fn bake_expr(
&mut self,
node_id: NodeId,
expr: &ExprNode,
errors: &mut Vec<BakeError>,
) -> Option<ExprHandle> {
let handle = match expr {
ExprNode::Literal(v) => {
let h = self.module.lit(*v);
self.literal_sites.insert(LiteralSite::Node(node_id), h);
h
}
ExprNode::Property(id) => self.bake_property_ref(node_id, *id, errors)?,
ExprNode::Attribute(a) => self.module.attr(*a),
ExprNode::ParentAttribute(a) => self.module.parent_attr(*a),
ExprNode::BuiltIn(op) => self.module.builtin(*op),
ExprNode::Unary(op) => {
let inner = self.operand(node_id, "in", errors)?;
self.module.unary(*op, inner)
}
ExprNode::Binary(op) => {
let lhs = self.operand(node_id, "lhs", errors)?;
let rhs = self.operand(node_id, "rhs", errors)?;
self.module.binary(*op, lhs, rhs)
}
ExprNode::Ternary(op) => {
let a = self.operand(node_id, "a", errors)?;
let b = self.operand(node_id, "b", errors)?;
let c = self.operand(node_id, "c", errors)?;
self.module.ternary(*op, a, b, c)
}
ExprNode::Cast(ty) => {
let inner = self.operand(node_id, "in", errors)?;
self.module.cast(inner, *ty)
}
ExprNode::Image(_) | ExprNode::SelectImage { .. } => self.module.lit(0u32),
ExprNode::TextureSample => {
let slot = self.resolve_image_slot(node_id, "image", errors)?;
let coordinates = self.operand(node_id, "coordinates", errors)?;
let image = self.module.lit(slot as i32);
self.module
.add_expr(Expr::TextureSample(TextureSampleExpr::new(
image,
coordinates,
)))
}
};
Some(handle)
}
fn bake_property_ref(
&mut self,
node_id: NodeId,
id: PropertyId,
errors: &mut Vec<BakeError>,
) -> Option<ExprHandle> {
let Some(def) = self.props.defs.get(&id) else {
errors.push(BakeError::node(
node_id,
format!("reference to unknown property id {}", id.get()),
));
return None;
};
if def.exposed {
let Some(handle) = self.props.handles.get(&id) else {
return None;
};
Some(self.module.prop(*handle))
} else {
let h = self.module.lit(def.default);
self.literal_sites.insert(LiteralSite::Node(node_id), h);
Some(h)
}
}
fn operand(
&mut self,
node_id: NodeId,
port: &str,
errors: &mut Vec<BakeError>,
) -> Option<ExprHandle> {
if let Some(source) = self.linked_source(node_id, port) {
return self.resolve(source, errors);
}
if let Some(default) = self.inline_default(node_id, port) {
return Some(self.record_inline_literal(node_id, port, default));
}
errors.push(BakeError::node(
node_id,
format!("input port '{port}' is neither linked nor given a default"),
));
None
}
fn operand_optional(
&mut self,
node_id: NodeId,
port: &str,
errors: &mut Vec<BakeError>,
) -> Option<ExprHandle> {
if let Some(source) = self.linked_source(node_id, port) {
return self.resolve(source, errors);
}
let default = self.inline_default(node_id, port)?;
Some(self.record_inline_literal(node_id, port, default))
}
fn record_inline_literal(
&mut self,
node_id: NodeId,
port: &str,
value: bevy_hanabi::Value,
) -> ExprHandle {
let h = self.module.lit(value);
self.literal_sites.insert(
LiteralSite::Input {
node: node_id,
port: port.into(),
},
h,
);
h
}
fn linked_source(&self, node_id: NodeId, port: &str) -> Option<NodeId> {
let target = PortRef {
node: node_id,
port: port.into(),
};
self.graph
.links
.iter()
.find(|l| l.to == target)
.map(|l| l.from.node)
}
fn inline_default(&self, node_id: NodeId, port: &str) -> Option<bevy_hanabi::Value> {
let node = self.graph.node(node_id)?;
node.inputs
.iter()
.find(|s| &*s.name == port)
.and_then(|s| s.default.as_value())
}
fn inline_image(&self, node_id: NodeId, port: &str) -> Option<ImageBinding> {
let node = self.graph.node(node_id)?;
node.inputs
.iter()
.find(|s| &*s.name == port)
.and_then(|s| s.default.as_image())
.cloned()
}
fn alloc_slot(&mut self, desired_name: &str, image: PlannedImage) -> usize {
let mut name = desired_name.to_string();
let mut n = 2;
while self.used_slot_names.contains(&name) {
name = format!("{desired_name}_{n}");
n += 1;
}
self.used_slot_names.insert(name.clone());
let index = self.texture_plan.len();
self.module.add_texture_slot(name);
self.texture_plan.push(image);
index
}
fn binding_slot(
&mut self,
binding: &ImageBinding,
node_id: NodeId,
errors: &mut Vec<BakeError>,
) -> Option<usize> {
match binding {
ImageBinding::Unbound => Some(self.alloc_slot("image", PlannedImage::Unbound)),
ImageBinding::Asset(path) => {
if let Some(index) = self.pinned_asset_slots.get(path) {
return Some(*index);
}
let name = asset_slot_name(path);
let index = self.alloc_slot(&name, PlannedImage::Asset(path.clone()));
self.pinned_asset_slots.insert(path.clone(), index);
Some(index)
}
ImageBinding::Slot(id) => match self.registry_slots.get(id) {
Some(index) => Some(*index),
None => {
errors.push(BakeError::node(
node_id,
format!("image references unknown texture slot {}", id.get()),
));
None
}
},
}
}
fn image_node_slot(&mut self, node_id: NodeId, errors: &mut Vec<BakeError>) -> Option<usize> {
if let Some(index) = self.image_node_slots.get(&node_id) {
return Some(*index);
}
let binding = match &self.graph.node(node_id)?.payload {
NodePayload::Expr(ExprNode::Image(binding)) => binding.clone(),
_ => {
errors.push(BakeError::node(
node_id,
"expected an image source feeding an image port",
));
return None;
}
};
let index = self.binding_slot(&binding, node_id, errors)?;
self.image_node_slots.insert(node_id, index);
Some(index)
}
fn resolve_image_slot(
&mut self,
node_id: NodeId,
port: &str,
errors: &mut Vec<BakeError>,
) -> Option<usize> {
if let Some(source) = self.linked_source(node_id, port) {
return self.image_source_slot(source, errors);
}
if let Some(binding) = self.inline_image(node_id, port) {
return self.binding_slot(&binding, node_id, errors);
}
errors.push(BakeError::node(
node_id,
format!("image port '{port}' is neither linked nor bound to an image"),
));
None
}
fn image_source_slot(&mut self, source: NodeId, errors: &mut Vec<BakeError>) -> Option<usize> {
match &self.graph.node(source)?.payload {
NodePayload::Expr(ExprNode::Image(_)) => self.image_node_slot(source, errors),
NodePayload::Expr(ExprNode::SelectImage { count }) => {
let count = *count;
self.select_image_slot(source, count, errors)
}
_ => {
errors.push(BakeError::node(
source,
"expected an image source feeding an image port",
));
None
}
}
}
fn select_image_slot(
&mut self,
node_id: NodeId,
count: u32,
errors: &mut Vec<BakeError>,
) -> Option<usize> {
let Some(index) = self.const_u32(node_id, "index") else {
errors.push(BakeError::node(
node_id,
"Select Image needs a compile-time constant 'index' to bake; runtime texture \
selection is unsupported by this bevy_hanabi version",
));
return None;
};
let index = index.min(count.saturating_sub(1));
let port = format!("image{index}");
let Some(source) = self.linked_source(node_id, &port) else {
errors.push(BakeError::node(
node_id,
format!("Select Image input '{port}' is empty"),
));
return None;
};
self.image_source_slot(source, errors)
}
fn const_u32(&self, node_id: NodeId, port: &str) -> Option<u32> {
if let Some(source) = self.linked_source(node_id, port) {
match &self.graph.node(source)?.payload {
NodePayload::Expr(ExprNode::Literal(v)) => value_as_u32(v),
_ => None,
}
} else {
self.inline_default(node_id, port)
.and_then(|v| value_as_u32(&v))
}
}
fn bake_modifier(
&mut self,
node_id: NodeId,
registry: &TypeRegistry,
errors: &mut Vec<BakeError>,
) -> Option<BoxedModifier> {
let node = self.graph.node(node_id).or_else(|| {
errors.push(BakeError::node(
node_id,
format!("stack references missing node {}", node_id.get()),
));
None
})?;
let NodePayload::Modifier(data) = &node.payload else {
errors.push(BakeError::node(
node_id,
"expected a modifier node in a stack",
));
return None;
};
let (type_path, config) = match data {
ModifierNodeData::Known { type_path, config } => (type_path, config),
ModifierNodeData::Unknown { type_path, .. } => {
errors.push(BakeError::node(
node_id,
format!("modifier type '{type_path}' is not registered; cannot bake"),
));
return None;
}
};
let Some(registration) = registry.get_with_type_path(type_path) else {
errors.push(BakeError::node(
node_id,
format!("modifier type '{type_path}' is not in the type registry"),
));
return None;
};
let Some(reflect_modifier) = registration.data::<ReflectModifier>() else {
errors.push(BakeError::node(
node_id,
format!("type '{type_path}' is registered but is not a modifier"),
));
return None;
};
let Some(schema) = modifier_schema(registration.type_info()) else {
errors.push(BakeError::node(
node_id,
format!("modifier type '{type_path}' does not reflect as a struct"),
));
return None;
};
let mut boxed = (reflect_modifier.factory)(self.module);
for field in schema.ports() {
if matches!(field.role, FieldRole::Texture) {
let Some(slot) = self.resolve_image_slot(node_id, &field.name, errors) else {
continue;
};
let handle = self.module.lit(slot as u32);
if !set_expr_field(boxed.as_reflect_mut(), &field.name, handle, false) {
errors.push(BakeError::node(
node_id,
format!("could not set texture field '{}'", field.name),
));
}
continue;
}
let optional = matches!(field.role, FieldRole::ExprPort { optional: true });
let handle = if optional {
self.operand_optional(node_id, &field.name, errors)
} else {
self.operand(node_id, &field.name, errors)
};
let handle = handle.map(|handle| {
if normalizes_modifier_input(type_path, &field.name) {
self.module.normalize(handle)
} else {
handle
}
});
if let Some(handle) = handle
&& !set_expr_field(boxed.as_reflect_mut(), &field.name, handle, optional)
{
errors.push(BakeError::node(
node_id,
format!("could not set expression field '{}'", field.name),
));
}
}
for field in schema.config() {
let Some(value) = config.get(field.name.as_ref()) else {
continue;
};
if let Err(message) = apply_config_field(boxed.as_reflect_mut(), &field.name, value) {
errors.push(BakeError::node(node_id, message));
}
}
Some(boxed)
}
}
fn normalizes_modifier_input(type_path: &str, field_name: &str) -> bool {
field_name == "axis"
&& matches!(
type_path,
path if path == SetPositionCircleModifier::type_path()
|| path == SetVelocityCircleModifier::type_path()
|| path == SetVelocityTangentModifier::type_path()
|| path == TangentAccelModifier::type_path()
)
}
fn asset_slot_name(path: &AssetPath) -> String {
path.path()
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("image")
.to_string()
}
pub(crate) fn value_as_u32(value: &Value) -> Option<u32> {
match value {
Value::Scalar(s) => Some(s.as_u32()),
_ => None,
}
}
fn set_expr_field(
reflect: &mut dyn Reflect,
name: &str,
handle: ExprHandle,
optional: bool,
) -> bool {
let ReflectMut::Struct(s) = reflect.reflect_mut() else {
return false;
};
let Some(field) = s.field_mut(name) else {
return false;
};
if optional && let Some(slot) = field.try_downcast_mut::<Option<ExprHandle>>() {
*slot = Some(handle);
return true;
}
if let Some(slot) = field.try_downcast_mut::<ExprHandle>() {
*slot = handle;
return true;
}
false
}
fn apply_config_field(
reflect: &mut dyn Reflect,
name: &str,
value: &EditValue,
) -> Result<(), String> {
let ReflectMut::Struct(s) = reflect.reflect_mut() else {
return Err("modifier does not reflect as a struct".to_string());
};
let field = s
.field_mut(name)
.ok_or_else(|| format!("modifier has no field '{name}'"))?;
apply_edit_value(field, value, name)
}
fn apply_edit_value(
field: &mut dyn PartialReflect,
value: &EditValue,
name: &str,
) -> Result<(), String> {
match value {
EditValue::Bool(b) => assign(field, *b, name),
EditValue::U32(u) => assign(field, *u, name),
EditValue::UVec2(v) => assign(field, *v, name),
EditValue::Color(c) => assign(field, *c, name),
EditValue::Attribute(a) => assign(field, *a, name),
EditValue::CpuVec3(v) => assign(field, v.clone(), name),
EditValue::CpuVec4(v) => assign(field, v.clone(), name),
EditValue::Scalar(v) => assign_scalar(field, v, name),
EditValue::Gradient3(g) => match g {
GradientVec3::Analytical(grad) => assign(field, grad.clone(), name),
GradientVec3::Lut(_) => Err(format!(
"field '{name}': texture-LUT gradient has no bevy_hanabi 0.18 representation"
)),
},
EditValue::Gradient4(g) => match g {
GradientVec4::Analytical(grad) => assign(field, grad.clone(), name),
GradientVec4::Lut(_) => Err(format!(
"field '{name}': texture-LUT gradient has no bevy_hanabi 0.18 representation"
)),
},
EditValue::Enum { variant, .. } => assign_enum(field, variant, name),
EditValue::Flags { bits, .. } => assign_flags(field, *bits, name),
EditValue::Texture(_) => Err(format!(
"field '{name}': texture baking is not yet supported"
)),
EditValue::Raw(_) => Err(format!("field '{name}': raw config values cannot be baked")),
}
}
fn assign<T: Reflect>(field: &mut dyn PartialReflect, value: T, name: &str) -> Result<(), String> {
match field.try_downcast_mut::<T>() {
Some(slot) => {
*slot = value;
Ok(())
}
None => Err(format!(
"field '{name}': expected {}, found {}",
std::any::type_name::<T>(),
field.reflect_type_path()
)),
}
}
fn assign_scalar(field: &mut dyn PartialReflect, value: &Value, name: &str) -> Result<(), String> {
match value {
Value::Scalar(s) => {
if let Some(slot) = field.try_downcast_mut::<f32>() {
*slot = s.as_f32();
} else if let Some(slot) = field.try_downcast_mut::<i32>() {
*slot = s.as_i32();
} else if let Some(slot) = field.try_downcast_mut::<u32>() {
*slot = s.as_u32();
} else if let Some(slot) = field.try_downcast_mut::<bool>() {
*slot = s.as_bool();
} else {
return Err(scalar_mismatch(name, field));
}
}
Value::Vector(v) => {
if let Some(slot) = field.try_downcast_mut::<Vec2>() {
*slot = v.as_vec2();
} else if let Some(slot) = field.try_downcast_mut::<Vec3>() {
*slot = v.as_vec3();
} else if let Some(slot) = field.try_downcast_mut::<Vec4>() {
*slot = v.as_vec4();
} else if let Some(slot) = field.try_downcast_mut::<UVec2>() {
*slot = v.as_uvec2();
} else {
return Err(scalar_mismatch(name, field));
}
}
Value::Matrix(_) => return Err(scalar_mismatch(name, field)),
_ => return Err(scalar_mismatch(name, field)),
}
Ok(())
}
fn scalar_mismatch(name: &str, field: &dyn PartialReflect) -> String {
format!(
"field '{name}': scalar value does not match field type {}",
field.reflect_type_path()
)
}
fn assign_enum(field: &mut dyn PartialReflect, variant: &str, name: &str) -> Result<(), String> {
let dynamic = DynamicEnum::new(variant.to_string(), DynamicVariant::Unit);
field
.try_apply(&dynamic)
.map_err(|e| format!("field '{name}': cannot select enum variant '{variant}': {e:?}"))
}
fn assign_flags(field: &mut dyn PartialReflect, bits: u64, name: &str) -> Result<(), String> {
let ReflectMut::TupleStruct(ts) = field.reflect_mut() else {
return Err(format!("field '{name}': flags field is not a tuple struct"));
};
let inner = ts
.field_mut(0)
.ok_or_else(|| format!("field '{name}': flags newtype has no inner value"))?;
if let Some(slot) = inner.try_downcast_mut::<u8>() {
*slot = bits as u8;
} else if let Some(slot) = inner.try_downcast_mut::<u16>() {
*slot = bits as u16;
} else if let Some(slot) = inner.try_downcast_mut::<u32>() {
*slot = bits as u32;
} else if let Some(slot) = inner.try_downcast_mut::<u64>() {
*slot = bits;
} else {
return Err(format!(
"field '{name}': unsupported flags integer type {}",
inner.reflect_type_path()
));
}
Ok(())
}
pub fn bake_module(
graph: &EffectGraph,
) -> Result<(Module, HashMap<NodeId, ExprHandle>), Vec<BakeError>> {
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(graph, &props, &mut module);
let participants = expr_participants(graph);
for node_id in participants {
baker.resolve(node_id, &mut errors);
}
let handles = std::mem::take(&mut baker.handles);
drop(baker);
if errors.is_empty() {
Ok((module, handles))
} else {
Err(errors)
}
}
pub fn bake(graph: &EffectGraph, registry: &TypeRegistry) -> Result<EffectAsset, Vec<BakeError>> {
bake_with_provenance(graph, registry).map(|(asset, _provenance)| asset)
}
pub fn bake_with_provenance(
graph: &EffectGraph,
registry: &TypeRegistry,
) -> Result<(EffectAsset, BakeProvenance), Vec<BakeError>> {
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(graph, &props, &mut module);
let mut init: Vec<bevy_hanabi::BoxedModifier> = Vec::new();
let mut update: Vec<bevy_hanabi::BoxedModifier> = Vec::new();
let mut render: Vec<Box<dyn bevy_hanabi::RenderModifier>> = Vec::new();
for stack in &graph.stacks {
for &member in &stack.members {
let Some(boxed) = baker.bake_modifier(member, registry, &mut errors) else {
continue;
};
match (stack.group, boxed.as_render().is_some()) {
(ModifierGroup::Init, false) => init.push(boxed),
(ModifierGroup::Update, false) => update.push(boxed),
(ModifierGroup::Render, true) => {
render.push(boxed.as_render().unwrap().boxed_render_clone())
}
(ModifierGroup::Render, false) => errors.push(BakeError::node(
member,
"non-render modifier placed in a Render stack",
)),
(group, true) => errors.push(BakeError::node(
member,
format!("render modifier placed in a {group:?} stack"),
)),
}
}
}
let literal_sites = std::mem::take(&mut baker.literal_sites);
let texture_plan = std::mem::take(&mut baker.texture_plan);
drop(baker);
if !errors.is_empty() {
return Err(errors);
}
let header = &graph.header;
let mut asset = EffectAsset::new(header.capacity, header.spawner, module);
asset.name = header.name.to_string();
asset.simulation_space = header.simulation_space;
asset.simulation_condition = header.simulation_condition;
asset.z_layer_2d = header.z_layer_2d;
for m in init {
asset = asset.add_modifier(ModifierContext::Init, m);
}
for m in update {
asset = asset.add_modifier(ModifierContext::Update, m);
}
for m in render {
asset = asset.add_render_modifier(m);
}
Ok((
asset,
BakeProvenance {
literal_sites,
texture_plan,
},
))
}
pub fn bake_preview(graph: &EffectGraph, registry: &TypeRegistry, preview_tag: u64) -> EffectAsset {
bake_preview_with_provenance(graph, registry, preview_tag).0
}
pub fn bake_preview_with_provenance(
graph: &EffectGraph,
registry: &TypeRegistry,
preview_tag: u64,
) -> (EffectAsset, BakeProvenance) {
let (mut asset, provenance) = bake_or_empty_with_provenance(graph, registry);
asset.name = preview_asset_name(&graph.header.name, preview_tag);
(asset, provenance)
}
pub fn preview_asset_name(base: &str, preview_tag: u64) -> String {
format!("{base}~{preview_tag}")
}
pub fn bake_or_empty(graph: &EffectGraph, registry: &TypeRegistry) -> EffectAsset {
bake_or_empty_with_provenance(graph, registry).0
}
pub fn bake_or_empty_with_provenance(
graph: &EffectGraph,
registry: &TypeRegistry,
) -> (EffectAsset, BakeProvenance) {
bake_with_provenance(graph, registry).unwrap_or_else(|errors| {
bevy::log::error!(
"effect graph failed to bake ({} error(s)): {errors:?}",
errors.len()
);
let mut asset = EffectAsset::new(
graph.header.capacity,
graph.header.spawner,
Module::default(),
);
asset.name = graph.header.name.to_string();
(asset, BakeProvenance::default())
})
}
fn expr_participants(graph: &EffectGraph) -> Vec<NodeId> {
let mut seen = Vec::new();
let push = |id: NodeId, seen: &mut Vec<NodeId>| {
if !seen.contains(&id) {
seen.push(id);
}
};
for link in &graph.links {
push(link.from.node, &mut seen);
push(link.to.node, &mut seen);
}
for node in &graph.nodes {
if let NodePayload::Expr(expr) = &node.payload
&& !expr.input_ports().is_empty()
{
push(node.id, &mut seen);
}
}
seen
}
#[cfg(test)]
mod tests {
use bevy_hanabi::{
Attribute, Expr, SimulationCondition, SimulationSpace, SpawnerSettings, Value,
graph::expr::{BinaryOperator, UnaryOperator},
};
use super::*;
use crate::model::{EffectHeader, GraphLink, GraphNode, InputSlot, PortRef};
fn header() -> EffectHeader {
EffectHeader {
name: "t".into(),
capacity: 32,
spawner: SpawnerSettings::rate(1.0.into()),
simulation_space: SimulationSpace::Global,
simulation_condition: SimulationCondition::Always,
z_layer_2d: 0.0,
}
}
fn graph_with(
nodes: Vec<GraphNode>,
links: Vec<GraphLink>,
props: Vec<PropertyDef>,
) -> EffectGraph {
let max = nodes.iter().map(|n| n.id.get()).max().unwrap_or(0);
EffectGraph {
header: header(),
properties: props,
texture_slots: vec![],
nodes,
stacks: vec![],
links,
next_id: max + 1,
}
}
fn expr_node(id: u32, expr: ExprNode, inputs: Vec<InputSlot>) -> GraphNode {
GraphNode {
id: NodeId::new(id).unwrap(),
payload: NodePayload::Expr(expr),
inputs,
}
}
fn pid(n: u32) -> PropertyId {
PropertyId::new(n).unwrap()
}
fn prop_def(id: u32, name: &str, default: Value, exposed: bool) -> PropertyDef {
PropertyDef {
id: pid(id),
name: name.into(),
default,
exposed,
}
}
#[test]
fn bakes_binary_with_link_and_inline_default() {
let n1 = expr_node(1, ExprNode::Attribute(Attribute::POSITION), vec![]);
let n2 = expr_node(
2,
ExprNode::Binary(BinaryOperator::Add),
vec![InputSlot {
name: "rhs".into(),
default: Value::from(2.0f32).into(),
}],
);
let link = GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(2).unwrap(),
port: "lhs".into(),
},
};
let graph = graph_with(vec![n1, n2], vec![link], vec![]);
let (module, handles) = bake_module(&graph).expect("bake");
assert_eq!(handles.len(), 2);
let top = handles[&NodeId::new(2).unwrap()];
assert!(matches!(module.get(top), Some(Expr::Binary { .. })));
}
#[test]
fn exposed_property_becomes_module_property() {
let prop = expr_node(1, ExprNode::Property(pid(10)), vec![]);
let unary = expr_node(
2,
ExprNode::Unary(bevy_hanabi::graph::expr::UnaryOperator::Abs),
vec![],
);
let link = GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(2).unwrap(),
port: "in".into(),
},
};
let graph = graph_with(
vec![prop, unary],
vec![link],
vec![prop_def(10, "speed", Value::from(4.0f32), true)],
);
let (module, _) = bake_module(&graph).expect("bake");
assert_eq!(module.properties().len(), 1);
assert_eq!(module.properties()[0].name(), "speed");
}
#[test]
fn edit_only_property_is_inlined() {
let n1 = expr_node(1, ExprNode::Property(pid(10)), vec![]);
let unary = expr_node(
2,
ExprNode::Unary(bevy_hanabi::graph::expr::UnaryOperator::Abs),
vec![],
);
let link = GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(2).unwrap(),
port: "in".into(),
},
};
let graph = graph_with(
vec![n1, unary],
vec![link],
vec![prop_def(10, "tweak", Value::from(7.0f32), false)],
);
let (module, handles) = bake_module(&graph).expect("bake");
assert!(module.properties().is_empty());
let lit = handles[&NodeId::new(1).unwrap()];
assert!(matches!(module.get(lit), Some(Expr::Literal(_))));
}
#[test]
fn unexposed_property_ref_records_node_site() {
let n1 = expr_node(1, ExprNode::Property(pid(10)), vec![]);
let graph = graph_with(
vec![n1],
vec![],
vec![prop_def(10, "tweak", Value::from(7.0f32), false)],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let node = NodeId::new(1).unwrap();
let h = baker.resolve(node, &mut errors).expect("resolve");
assert!(errors.is_empty());
assert_eq!(
baker.literal_sites.get(&LiteralSite::Node(node)).copied(),
Some(h),
"an unexposed property reference records a Node site for its inlined literal"
);
assert!(matches!(baker.module.get(h), Some(Expr::Literal(_))));
}
#[test]
fn detects_cycle() {
let n1 = expr_node(
1,
ExprNode::Unary(bevy_hanabi::graph::expr::UnaryOperator::Abs),
vec![],
);
let n2 = expr_node(
2,
ExprNode::Unary(bevy_hanabi::graph::expr::UnaryOperator::Abs),
vec![],
);
let links = vec![
GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(2).unwrap(),
port: "in".into(),
},
},
GraphLink {
from: PortRef {
node: NodeId::new(2).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(1).unwrap(),
port: "in".into(),
},
},
];
let graph = graph_with(vec![n1, n2], links, vec![]);
let errors = bake_module(&graph).unwrap_err();
assert!(errors.iter().any(|e| e.message.contains("cycle")));
}
#[test]
fn unknown_property_errors() {
let n1 = expr_node(1, ExprNode::Property(pid(99)), vec![]);
let unary = expr_node(
2,
ExprNode::Unary(bevy_hanabi::graph::expr::UnaryOperator::Abs),
vec![],
);
let link = GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(2).unwrap(),
port: "in".into(),
},
};
let graph = graph_with(vec![n1, unary], vec![link], vec![]);
let errors = bake_module(&graph).unwrap_err();
assert!(errors.iter().any(|e| {
e.subject == BakeSubject::Node(NodeId::new(1).unwrap())
&& e.message.contains("unknown property")
}));
}
#[test]
fn duplicate_exposed_property_name_errors() {
let graph = graph_with(
vec![],
vec![],
vec![
prop_def(10, "dup", Value::from(1.0f32), true),
prop_def(11, "dup", Value::from(2.0f32), true),
],
);
let errors = bake_module(&graph).unwrap_err();
assert!(errors.iter().any(|e| {
e.subject == BakeSubject::Property(pid(11))
&& e.message.contains("share the name 'dup'")
}));
}
#[test]
fn duplicate_edit_only_property_name_is_tolerated() {
let graph = graph_with(
vec![],
vec![],
vec![
prop_def(10, "tweak", Value::from(1.0f32), false),
prop_def(11, "tweak", Value::from(2.0f32), false),
],
);
let (module, _) = bake_module(&graph).expect("edit-only duplicates are harmless");
assert!(module.properties().is_empty());
}
#[test]
fn distinct_ids_resolve_independently() {
let r1 = expr_node(1, ExprNode::Property(pid(10)), vec![]);
let r2 = expr_node(2, ExprNode::Property(pid(11)), vec![]);
let add = expr_node(3, ExprNode::Binary(BinaryOperator::Add), vec![]);
let links = vec![
GraphLink {
from: PortRef {
node: NodeId::new(1).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(3).unwrap(),
port: "lhs".into(),
},
},
GraphLink {
from: PortRef {
node: NodeId::new(2).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(3).unwrap(),
port: "rhs".into(),
},
},
];
let graph = graph_with(
vec![r1, r2, add],
links,
vec![
prop_def(10, "same", Value::from(1.0f32), false),
prop_def(11, "same", Value::from(2.0f32), false),
],
);
let (module, handles) = bake_module(&graph).expect("bake");
assert!(matches!(
module.get(handles[&NodeId::new(1).unwrap()]),
Some(Expr::Literal(_))
));
assert!(matches!(
module.get(handles[&NodeId::new(2).unwrap()]),
Some(Expr::Literal(_))
));
}
use std::collections::BTreeMap;
use bevy::{ecs::reflect::AppTypeRegistry, reflect::TypePath};
use bevy_hanabi::{
ColorBlendMask, ColorBlendMode, CpuValue, ParticleTextureModifier, SetColorModifier,
SetPositionSphereModifier,
};
use crate::model::ModifierNodeData;
fn test_registry() -> AppTypeRegistry {
let registry = AppTypeRegistry::default();
bevy_hanabi::register_modifiers(®istry);
registry
}
fn modifier_node(
id: u32,
type_path: &str,
config: BTreeMap<crate::model::SharedStr, EditValue>,
inputs: Vec<InputSlot>,
) -> GraphNode {
GraphNode {
id: NodeId::new(id).unwrap(),
payload: NodePayload::Modifier(ModifierNodeData::Known {
type_path: type_path.into(),
config,
}),
inputs,
}
}
fn bake_one(
graph: &EffectGraph,
registry: &TypeRegistry,
node_id: NodeId,
) -> (Option<BoxedModifier>, Vec<BakeError>) {
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(graph, &props, &mut module);
let baked = baker.bake_modifier(node_id, registry, &mut errors);
(baked, errors)
}
#[test]
fn bakes_modifier_enum_and_flags_config() {
let mut config = BTreeMap::new();
config.insert(
"blend".into(),
EditValue::Enum {
type_path: ColorBlendMode::type_path().into(),
variant: "Add".into(),
},
);
config.insert(
"mask".into(),
EditValue::Flags {
type_path: ColorBlendMask::type_path().into(),
bits: ColorBlendMask::RGB.bits() as u64,
},
);
config.insert(
"color".into(),
EditValue::CpuVec4(CpuValue::Single(Vec4::new(0.2, 0.4, 0.6, 1.0))),
);
let node = modifier_node(1, SetColorModifier::type_path(), config, vec![]);
let graph = graph_with(vec![node], vec![], vec![]);
let (baked, errors) = bake_one(&graph, &test_registry().read(), NodeId::new(1).unwrap());
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
let m = baked.expect("baked");
assert!(m.as_render().is_some(), "expected a render modifier");
let scm = m
.as_reflect()
.downcast_ref::<SetColorModifier>()
.expect("SetColorModifier");
assert_eq!(scm.blend, ColorBlendMode::Add);
assert_eq!(scm.mask, ColorBlendMask::RGB);
assert_eq!(scm.color, CpuValue::Single(Vec4::new(0.2, 0.4, 0.6, 1.0)));
}
#[test]
fn bakes_modifier_ports_from_inline_defaults() {
let node = modifier_node(
1,
SetPositionSphereModifier::type_path(),
BTreeMap::new(),
vec![
InputSlot {
name: "center".into(),
default: Value::from(Vec3::new(1.0, 2.0, 3.0)).into(),
},
InputSlot {
name: "radius".into(),
default: Value::from(5.0_f32).into(),
},
],
);
let graph = graph_with(vec![node], vec![], vec![]);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let baked = baker
.bake_modifier(
NodeId::new(1).unwrap(),
&test_registry().read(),
&mut errors,
)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
let m = baked;
assert!(m.as_render().is_none(), "expected a plain modifier");
let spm = m
.as_reflect()
.downcast_ref::<SetPositionSphereModifier>()
.expect("SetPositionSphereModifier");
assert_eq!(
module.get(spm.radius),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
5.0_f32
)))
);
assert_eq!(
module.get(spm.center),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
Vec3::new(1.0, 2.0, 3.0)
)))
);
}
#[test]
fn normalizes_modifier_direction_axes() {
let axis = Vec3::new(0.0, 1.94, 1.95);
let node = modifier_node(
1,
SetPositionCircleModifier::type_path(),
BTreeMap::new(),
vec![
InputSlot {
name: "center".into(),
default: Value::from(Vec3::ZERO).into(),
},
InputSlot {
name: "axis".into(),
default: Value::from(axis).into(),
},
InputSlot {
name: "radius".into(),
default: Value::from(1.0_f32).into(),
},
],
);
let graph = graph_with(vec![node], vec![], vec![]);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let baked = baker
.bake_modifier(
NodeId::new(1).unwrap(),
&test_registry().read(),
&mut errors,
)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
let modifier = baked
.as_reflect()
.downcast_ref::<SetPositionCircleModifier>()
.expect("SetPositionCircleModifier");
let Some(Expr::Unary { op, expr }) = module.get(modifier.axis) else {
panic!("expected the axis to bake as a unary expression");
};
assert_eq!(*op, UnaryOperator::Normalize);
assert_eq!(
module.get(*expr),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
axis
)))
);
}
use crate::model::{ImageBinding, SlotId, TextureSlotDef};
fn slot_def(id: u32, name: &str) -> TextureSlotDef {
TextureSlotDef {
id: SlotId::new(id).unwrap(),
name: name.into(),
}
}
fn graph_with_textures(
nodes: Vec<GraphNode>,
links: Vec<GraphLink>,
texture_slots: Vec<TextureSlotDef>,
) -> EffectGraph {
let mut graph = graph_with(nodes, links, vec![]);
graph.texture_slots = texture_slots;
graph
}
fn sampler_node(id: u32, image: Option<ImageBinding>) -> GraphNode {
let mut inputs = vec![InputSlot {
name: "coordinates".into(),
default: Value::from(Vec2::ZERO).into(),
}];
if let Some(binding) = image {
inputs.insert(
0,
InputSlot {
name: "image".into(),
default: binding.into(),
},
);
}
expr_node(id, ExprNode::TextureSample, inputs)
}
fn image_link(from: u32, to: u32, to_port: &str) -> GraphLink {
GraphLink {
from: PortRef {
node: NodeId::new(from).unwrap(),
port: "out".into(),
},
to: PortRef {
node: NodeId::new(to).unwrap(),
port: to_port.into(),
},
}
}
#[test]
fn modifier_texture_slot_bakes_asset_to_u32_literal() {
let node = modifier_node(
1,
ParticleTextureModifier::type_path(),
BTreeMap::new(),
vec![InputSlot {
name: "texture_slot".into(),
default: ImageBinding::Asset("ramps/fire.png".into()).into(),
}],
);
let graph = graph_with(vec![node], vec![], vec![]);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let baked = baker
.bake_modifier(
NodeId::new(1).unwrap(),
&test_registry().read(),
&mut errors,
)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1);
assert!(matches!(baker.texture_plan[0], PlannedImage::Asset(_)));
drop(baker);
let ptm = baked
.as_reflect()
.downcast_ref::<ParticleTextureModifier>()
.expect("ParticleTextureModifier");
assert_eq!(
module.get(ptm.texture_slot),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
0u32
)))
);
assert_eq!(module.texture_layout().layout.len(), 1);
}
#[test]
fn texture_sample_bakes_image_to_i32_literal() {
let node = sampler_node(1, Some(ImageBinding::Asset("fire.png".into())));
let graph = graph_with(vec![node], vec![], vec![]);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let handle = baker
.resolve(NodeId::new(1).unwrap(), &mut errors)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1);
drop(baker);
let Some(Expr::TextureSample(tse)) = module.get(handle) else {
panic!("expected a TextureSample expression");
};
assert_eq!(
module.get(tse.image),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
0i32
)))
);
}
#[test]
fn image_node_fan_out_shares_one_slot() {
let image = expr_node(
1,
ExprNode::Image(ImageBinding::Asset("a.png".into())),
vec![],
);
let graph = graph_with(
vec![image, sampler_node(2, None), sampler_node(3, None)],
vec![image_link(1, 2, "image"), image_link(1, 3, "image")],
vec![],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
baker
.resolve(NodeId::new(2).unwrap(), &mut errors)
.expect("baked s2");
baker
.resolve(NodeId::new(3).unwrap(), &mut errors)
.expect("baked s3");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1, "fan-out must share one slot");
}
#[test]
fn identical_assets_across_image_nodes_and_inline_bindings_share_one_slot() {
let first_image = expr_node(
1,
ExprNode::Image(ImageBinding::Asset("shared.png".into())),
vec![],
);
let second_image = expr_node(
2,
ExprNode::Image(ImageBinding::Asset("shared.png".into())),
vec![],
);
let graph = graph_with(
vec![
first_image,
second_image,
sampler_node(3, None),
sampler_node(4, None),
sampler_node(5, Some(ImageBinding::Asset("shared.png".into()))),
],
vec![image_link(1, 3, "image"), image_link(2, 4, "image")],
vec![],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let handles = [3, 4, 5].map(|id| {
baker
.resolve(NodeId::new(id).unwrap(), &mut errors)
.expect("baked")
});
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1);
assert_eq!(
baker.texture_plan[0],
PlannedImage::Asset("shared.png".into())
);
drop(baker);
assert_eq!(module.texture_layout().layout.len(), 1);
for handle in handles {
let Some(Expr::TextureSample(sample)) = module.get(handle) else {
panic!("expected a TextureSample expression");
};
assert_eq!(
module.get(sample.image),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
0i32
)))
);
}
}
#[test]
fn runtime_slots_stay_leading_and_unbound_bindings_stay_distinct() {
let graph = graph_with_textures(
vec![
sampler_node(1, Some(ImageBinding::Slot(SlotId::new(20).unwrap()))),
sampler_node(2, Some(ImageBinding::Slot(SlotId::new(10).unwrap()))),
sampler_node(3, Some(ImageBinding::Unbound)),
sampler_node(4, Some(ImageBinding::Unbound)),
],
vec![],
vec![slot_def(10, "first"), slot_def(20, "second")],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let handles = [1, 2, 3, 4].map(|id| {
baker
.resolve(NodeId::new(id).unwrap(), &mut errors)
.expect("baked")
});
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(
baker.texture_plan,
vec![
PlannedImage::Runtime("first".into()),
PlannedImage::Runtime("second".into()),
PlannedImage::Unbound,
PlannedImage::Unbound,
]
);
drop(baker);
assert_eq!(module.texture_layout().layout.len(), 4);
for (handle, slot) in handles.into_iter().zip([1, 0, 2, 3]) {
let Some(Expr::TextureSample(sample)) = module.get(handle) else {
panic!("expected a TextureSample expression");
};
assert_eq!(
module.get(sample.image),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
slot
)))
);
}
}
#[test]
fn host_slot_binding_reuses_reserved_index() {
let node = sampler_node(1, Some(ImageBinding::Slot(SlotId::new(7).unwrap())));
let graph = graph_with_textures(vec![node], vec![], vec![slot_def(7, "noise")]);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let handle = baker
.resolve(NodeId::new(1).unwrap(), &mut errors)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1);
assert!(matches!(baker.texture_plan[0], PlannedImage::Runtime(_)));
drop(baker);
let Some(Expr::TextureSample(tse)) = module.get(handle) else {
panic!("expected a TextureSample expression");
};
assert_eq!(
module.get(tse.image),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
0i32
)))
);
}
#[test]
fn select_image_with_constant_index_bakes_selected_slot() {
let a = expr_node(
1,
ExprNode::Image(ImageBinding::Asset("a.png".into())),
vec![],
);
let b = expr_node(
2,
ExprNode::Image(ImageBinding::Asset("b.png".into())),
vec![],
);
let select = expr_node(
3,
ExprNode::SelectImage { count: 2 },
vec![InputSlot {
name: "index".into(),
default: Value::from(1u32).into(),
}],
);
let graph = graph_with(
vec![a, b, select, sampler_node(4, None)],
vec![
image_link(1, 3, "image0"),
image_link(2, 3, "image1"),
image_link(3, 4, "image"),
],
vec![],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let handle = baker
.resolve(NodeId::new(4).unwrap(), &mut errors)
.expect("baked");
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(baker.texture_plan.len(), 1);
match &baker.texture_plan[0] {
PlannedImage::Asset(path) => {
assert!(path.path().to_str().unwrap().contains("b.png"))
}
other => panic!("expected b.png asset slot, got {other:?}"),
}
drop(baker);
let Some(Expr::TextureSample(tse)) = module.get(handle) else {
panic!("expected a TextureSample expression");
};
assert_eq!(
module.get(tse.image),
Some(&Expr::Literal(bevy_hanabi::graph::expr::LiteralExpr::new(
0i32
)))
);
}
#[test]
fn select_image_with_runtime_index_errors() {
let select = expr_node(1, ExprNode::SelectImage { count: 2 }, vec![]);
let graph = graph_with(
vec![select, sampler_node(2, None)],
vec![image_link(1, 2, "image")],
vec![],
);
let mut module = Module::default();
let mut errors = Vec::new();
let props = bake_properties(&graph, &mut module, &mut errors);
let mut baker = ExprBaker::new(&graph, &props, &mut module);
let result = baker.resolve(NodeId::new(2).unwrap(), &mut errors);
assert!(result.is_none());
assert!(
errors
.iter()
.any(|e| e.message.contains("compile-time constant")),
"expected a runtime-selection error, got: {errors:?}"
);
}
#[test]
fn unregistered_modifier_type_errors() {
let node = modifier_node(1, "not::a::real::Modifier", BTreeMap::new(), vec![]);
let graph = graph_with(vec![node], vec![], vec![]);
let (baked, errors) = bake_one(&graph, &test_registry().read(), NodeId::new(1).unwrap());
assert!(baked.is_none());
assert!(errors.iter().any(|e| {
e.subject == BakeSubject::Node(NodeId::new(1).unwrap())
&& e.message.contains("not in the type registry")
}));
}
use crate::model::{GraphStack, StackId};
fn graph_with_stacks(nodes: Vec<GraphNode>, stacks: Vec<GraphStack>) -> EffectGraph {
let max = nodes.iter().map(|n| n.id.get()).max().unwrap_or(0);
EffectGraph {
header: header(),
properties: vec![],
texture_slots: vec![],
nodes,
stacks,
links: vec![],
next_id: max + 1,
}
}
fn stack(id: u32, group: ModifierGroup, members: Vec<u32>) -> GraphStack {
GraphStack {
id: StackId::new(id).unwrap(),
group,
members: members
.into_iter()
.map(|m| NodeId::new(m).unwrap())
.collect(),
}
}
#[test]
fn bakes_whole_graph_into_effect_asset() {
let pos = modifier_node(
1,
SetPositionSphereModifier::type_path(),
BTreeMap::new(),
vec![
InputSlot {
name: "center".into(),
default: Value::from(Vec3::ZERO).into(),
},
InputSlot {
name: "radius".into(),
default: Value::from(2.0_f32).into(),
},
],
);
let color = modifier_node(2, SetColorModifier::type_path(), BTreeMap::new(), vec![]);
let graph = graph_with_stacks(
vec![pos, color],
vec![
stack(1, ModifierGroup::Init, vec![1]),
stack(2, ModifierGroup::Render, vec![2]),
],
);
let asset = bake(&graph, &test_registry().read()).expect("bake");
assert_eq!(asset.name, "t");
assert_eq!(asset.capacity(), 32);
assert_eq!(asset.init_modifiers().count(), 1);
assert_eq!(asset.update_modifiers().count(), 0);
assert_eq!(asset.render_modifiers().count(), 1);
assert!(
asset
.init_modifiers()
.next()
.unwrap()
.as_reflect()
.downcast_ref::<SetPositionSphereModifier>()
.is_some()
);
assert!(
asset
.render_modifiers()
.next()
.unwrap()
.as_modifier()
.as_reflect()
.downcast_ref::<SetColorModifier>()
.is_some()
);
}
#[test]
fn provenance_records_inline_default_sites() {
let pos = modifier_node(
1,
SetPositionSphereModifier::type_path(),
BTreeMap::new(),
vec![
InputSlot {
name: "center".into(),
default: Value::from(Vec3::ZERO).into(),
},
InputSlot {
name: "radius".into(),
default: Value::from(2.0_f32).into(),
},
],
);
let graph = graph_with_stacks(vec![pos], vec![stack(1, ModifierGroup::Init, vec![1])]);
let (asset, provenance) =
bake_with_provenance(&graph, &test_registry().read()).expect("bake");
let sites = &provenance.literal_sites;
let node = NodeId::new(1).unwrap();
let radius = sites
.get(&LiteralSite::Input {
node,
port: "radius".into(),
})
.copied()
.expect("radius inline-default site recorded");
assert!(matches!(asset.module().get(radius), Some(Expr::Literal(_))));
assert!(sites.contains_key(&LiteralSite::Input {
node,
port: "center".into(),
}));
}
#[test]
fn render_modifier_in_init_stack_errors() {
let color = modifier_node(1, SetColorModifier::type_path(), BTreeMap::new(), vec![]);
let graph = graph_with_stacks(vec![color], vec![stack(1, ModifierGroup::Init, vec![1])]);
let errors = match bake(&graph, &test_registry().read()) {
Err(errors) => errors,
Ok(_) => panic!("expected a bake error"),
};
assert!(errors.iter().any(|e| {
e.subject == BakeSubject::Node(NodeId::new(1).unwrap())
&& e.message.contains("render modifier placed in a Init stack")
}));
}
}