20 KiB
BSN export
Bevy Forge's primary export target is Bevy 0.20 BSN: Rust source code written with the bsn!
and bsn_list! macros from bevy::scene. This is the modern scene notation in Bevy 0.20 - not the
legacy DynamicScene / .scn.ron format. The output is ordinary Rust that your game compiles; there
is no runtime .bsn asset to load.
Related pages: scene format, architecture, README.
Where the code comes from
- Editor: File > Export > BSN scene. The dialog shows a preview with the generated function name,
lets you copy it to the clipboard, and can write it to a file (default
export/<function>.rs). - CLI:
cargo run -p bevy_forge_cli -- export examples/demo-project/scenes/garden.forge.ron \
--format bsn -o /tmp/garden.rs
Without -o, the code is printed to stdout. --function <name> sets the generated function name;
the default is scene. The name must be a valid Rust identifier that is not a Rust keyword,
otherwise the exporter returns an error.
Generated files begin with use bevy::prelude::*; (plus a macro import when needed). With
CodegenOptions::include_header enabled - the editor's default - a short "generated by Bevy Forge"
comment precedes the imports; the CLI exports without that header, so its stdout and -o files start
directly with the import. Generated files end with exactly one newline. Generation is deterministic
for a given scene and options.
Output shape
One root scene:
// Generated by Bevy Forge. Paste into a Bevy 0.20 project.
use bevy::prelude::*;
pub fn scene() -> impl Scene {
bsn! {
Name("Player")
Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
Sprite { image: "textures/player.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 64.0)) }, flip_x: false, flip_y: false }
Children [
Name("Hat")
Transform {
translation: Vec3::new(0.0, 1.0, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
Sprite { color: Color::srgba(1.0, 0.5, 0.2, 1.0), flip_x: false, flip_y: false }
]
}
}
Multiple roots use bsn_list! and return impl SceneList. Roots (and children) are separated by
--:
// Generated by Bevy Forge. Paste into a Bevy 0.20 project.
use bevy::prelude::*;
use bevy::scene::bsn_list;
pub fn scene() -> impl SceneList {
bsn_list! {
Name("Left")
Transform {
translation: Vec3::new(-2.0, 0.0, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
Sprite { color: Color::srgba(0.4, 0.7, 1.0, 1.0), flip_x: false, flip_y: false }
--
Name("Right")
Transform {
translation: Vec3::new(2.0, 0.0, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
Sprite { color: Color::srgba(1.0, 0.6, 0.4, 1.0), flip_x: false, flip_y: false }
}
}
Rules the generator follows:
bsn!accepts exactly one root. Two roots inside onebsn!are a parse error; usebsn_list!.--separates sibling scenes insidebsn_list!andChildren [ ... ]. The editor's scene order is preserved.- Every entity emits
Name("<escaped display name>")and a completeTransformblock with all three fields. Display names are emitted as escaped Rust strings, never as#Identifierreferences. - Rotation is converted from stored XYZ Euler degrees to a quaternion:
Quat::from_euler(EulerRot::XYZ, 45.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()). Thef32suffix is part of the generated output; it keeps the literal type unambiguous. - Components are emitted in stored order after
Transform; children appear as a nestedChildrenblock. - Macro imports are normal
usestatements. Do not fully qualify the macros (bevy::scene::bsn_list! { ... }compiles but prints a build-time macro-root warning).
Component templates
Name
Name("Player")
The string is Rust-escaped. Name takes a string-like value; Name(42) does not compile.
Transform
Transform {
translation: Vec3::new(1.0, 0.5, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 45.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
All three fields are always present. (Here 45.0f32.to_radians() is 45 degrees.) BSN component patches
do not support struct-update syntax: Transform { ..default() } fails with a type inference error,
so the generator writes every field.
Sprite
Sprite { image: "textures/player.png", color: Color::srgba(1.0, 1.0, 1.0, 1.0), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: false, flip_y: false }
imageis omitted when the sprite has no image;custom_sizeis omitted when it isNone.coloris always emitted.Some(...)must be braced inside a BSN patch; bareSome(..)is a parse error. BareNoneis accepted, but the generator simply omits optional fields instead.- For a solid-colour sprite the output is
Sprite { color: ..., flip_x: false, flip_y: false }.
Text2d
Text2d("Hello, Bevy!")
TextFont { font_size: FontSize::Px(32.0) }
TextColor(Color::srgba(1.0, 1.0, 1.0, 1.0))
Text is a Rust string literal; newlines and quotes are escaped normally. font_size is in pixels.
Cameras
Camera2d
Camera3d::default()
Camera2d is emitted bare. Camera3d is a constructor call, not a bare identifier.
Point light
PointLight { color: Color::srgba(1.0, 0.8, 0.6, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true }
Directional light
DirectionalLight { color: Color::srgba(1.0, 0.95, 0.85, 1.0), illuminance: 10000.0, shadow_maps_enabled: true }
Negative literal caveat: BSN patches do not accept a leading - in a direct field value. A
negative number must be wrapped in a block and suffixed:
intensity: { -5.0f32 }. Values produced by calls or paths (Vec3::new(-1.0, 0.0, 0.0),
f32::NEG_INFINITY) are fine. The generator uses the braced form when a direct literal is negative.
Visibility
Visibility::Inherited
Visibility::Visible
Visibility::Hidden
An entity without a visibility component emits nothing; Bevy treats it as inherited.
Mesh3d and materials
Primitive meshes and materials need asset handles. The generated function takes parameters for the asset collections the scene uses:
pub fn scene(
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> impl Scene {
bsn! {
Name("Ground")
Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 0.0f32.to_radians()),
scale: Vec3::new(1.0, 1.0, 1.0),
}
Mesh3d({ meshes.add(Cuboid::new(12.0, 0.2, 12.0)) })
MeshMaterial3d::<StandardMaterial>({ materials.add(StandardMaterial {
base_color: Color::srgba(0.35, 0.55, 0.3, 1.0),
metallic: 0.0,
perceptual_roughness: 0.9,
unlit: false,
..default()
}) })
}
}
- Primitive mesh handles must be braced because method calls are not accepted as direct BSN
values:
Mesh3d({ meshes.add(..) }), notMesh3d(meshes.add(..)). MeshMaterial3drequires the turbofish::<StandardMaterial>; without it the compiler reports missing generics...default()is accepted inside the raw RustStandardMaterialliteral (it is a constructor argument), but not inside a BSN component patch.- The generator includes
meshesonly when a primitive mesh exists, andmaterialswhenever aMesh3dexists. A scene with no meshes has no asset parameters. - Primitive constructors (each wrapped in
Mesh3d({ meshes.add(...) })):- Cuboid:
Cuboid::new(x, y, z) - Sphere:
Sphere::new(radius) - Plane:
Plane3d::new(Vec3::Y, Vec2::new(width * 0.5, height * 0.5))- the scene stores the full size, while Bevy'sPlane3dtakes half extents along X and Z - Cylinder:
Cylinder::new(radius, height) - Capsule:
Capsule3d::new(radius, length)
- Cuboid:
An asset mesh is addressed by path instead of an added handle:
Mesh3d("models/robot.gltf#Mesh0/Primitive0")
MeshMaterial3d::<StandardMaterial>({ materials.add(StandardMaterial {
base_color: Color::srgba(0.8, 0.8, 0.85, 1.0),
metallic: 0.1,
perceptual_roughness: 0.4,
unlit: false,
..default()
}) })
An asset mesh still needs materials, but not meshes. The path is resolved by Bevy's AssetServer
at spawn time; see runtime asset caveats.
Custom components
forge_demo::Orbit {
radius: 2.0,
speed: { -0.5f32 },
}
A custom component exports as a struct literal: the stored type_path, then each name: expr pair.
A custom component with zero fields is emitted as the bare type path:
forge_demo::Marker
Field expressions are copied from the scene file. The generator wraps an expression in
{ ... } when it starts with - or is a bare enum-variant call such as Some(..), because neither
form is accepted as a direct BSN field value. Duplicate field names are rejected. In the example
above the scene expression is -0.5f32, so the f32 suffix survives bracing.
Custom component requirements
The generated code must compile in the game crate that defines the component. Bevy 0.20's BSN macro
needs a FromTemplate implementation, provided either by the blanket Default + Clone + Unpin
implementation or by a derived FromTemplate.
| Component shape | Minimum derives |
|---|---|
Simple data fields (f32, Color, Vec3, ...) |
#[derive(Component, Default, Clone)] |
| Unit marker | #[derive(Component, Default, Clone)] |
Fields like Handle<Image> or Entity |
#[derive(Component, FromTemplate)] |
Option<Handle<T>> or other #[template(built_in)] fields |
#[derive(Component, FromTemplate)] plus #[template(built_in)] on that field |
Notes:
FromTemplateis in Bevy's prelude. The derive generates a companion template type, so it can be combined withDefault + Clonein 0.20.- A component with
Handle<Image>and onlyDefault + Clonefails to compile becauseHandle<Image>does not convert from a string path through the blanket template; useFromTemplate. - Add
#[template(built_in)]in your Rust source where a field type needs it. The editor does not introspect your types, so it cannot infer this option. - The exported
type_pathmust be importable where the generated file is compiled. Use a qualified path such asmy_game::Healthif the type is not in scope. - The generated BSN binds no entity references (
#Child), so custom components that link to other entities should use whatever handle/entity value your scene code provides; a rawEntityfield expression is the author's responsibility.
Example component definitions:
use bevy::prelude::*;
#[derive(Component, Default, Clone)]
pub struct Health {
pub current: f32,
pub max: f32,
}
#[derive(Component, FromTemplate)]
pub struct Icon {
pub image: Handle<Image>,
pub tint: Color,
}
Pasting generated code into a Bevy app
- Export the file, for example into
src/scenes/garden.rsin your game. - Declare the module and call the generated function from a startup system:
use bevy::prelude::*;
mod scenes {
pub mod garden;
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, spawn_garden)
.run();
}
fn spawn_garden(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
commands.spawn_scene(scenes::garden::scene(&mut meshes, &mut materials));
}
For a scene whose generated signature has no asset parameters:
commands.spawn_scene(scenes::garden::scene());
For multiple roots:
commands.spawn_scene_list(scenes::garden::scene());
spawn_scene,spawn_scene_listand the related traits are in Bevy's prelude.- Matching the generated parameter list is required: passing unused asset collections is harmless, but omitting a required one does not compile.
- On
World(for example in exclusive systems or tests), useworld.spawn_scene(scene)orworld.spawn_scene_list(scene);spawn_scene_listreturns aResult<Vec<Entity>, _>. commands.queue_spawn_scene(..)(and the list variant) defers the spawn. Handles created bymeshes.add/materials.addare evaluated whenscene()is called and are owned by the scene, so deferred spawning is safe.- Use
DefaultPluginsor the specific plugins your components need (ScenePlugin,SpritePlugin,PbrPlugin, ...). A headless test harness needs at least task pool, asset and scene plugins.
Runtime asset caveats
- Primitives and inline materials work immediately.
meshes.add(Cuboid::new(..))andmaterials.add(StandardMaterial { .. })create values whenscene()runs; the entity renders as soon as it is spawned and the asset is registered. - Sprite images and asset meshes are path handles. A string like
"textures/player.png"becomes a path handled byAssetServerat spawn time. If the file is missing, or the relevant asset loader is not enabled in your game (bevy_gltffor.gltf, for example), the handle silently staysLoadingorFailed- spawning does not error. - Material asset paths need a loader.
MeshMaterial3d::<StandardMaterial>("materials/cube.toml")compiles, but unless your project registers aStandardMaterialasset loader it never resolves. This is why Forge emits inline material values instead of material asset paths. Do not replace the generated material expression with a bare path and expect it to render. - No runtime BSN scene assets. The generated code is compiled into your crate. Bevy's scene asset/caching features are not used by this exporter.
Determinism and export errors
ExportFormat::generate is pure and deterministic. It returns errors instead of panicking:
| Error | Cause |
|---|---|
EmptyScene |
The scene has no root entities (including an entirely empty scene); there is nothing to generate. |
InvalidIdentifier |
The function name is not a valid Rust identifier, or is a Rust keyword. |
NonFinite |
A transform, material or light value is NaN or infinite. |
InvalidCustomComponent |
A custom type path or field name is invalid, or a field expression is empty. The BSN generator additionally rejects duplicate field names. |
The CLI exits 1 for these codegen errors and 2 for I/O, RON parse or usage errors. The editor
shows the error in the export dialog instead of writing a file.
Troubleshooting compile errors
| Compiler message | Cause | Fix |
|---|---|---|
error: Unexpected input after function name |
Method call used as a direct BSN value, e.g. Mesh3d(meshes.add(..)) |
Brace it: Mesh3d({ meshes.add(..) }) |
error[E0107]: missing generics for struct MeshMaterial3d |
Missing turbofish | Use MeshMaterial3d::<StandardMaterial>(...) |
error: expected type, found variant 'Some' |
Unbraced Some(..) in a patch |
Use custom_size: { Some(Vec2::new(..)) } |
error: expected identifier at a negative number |
Negative direct field literal | Use intensity: { -5.0f32 } (or a call/path value) |
error: expected f32, found integer |
Integer literal in an f32 field |
Write 10.0, not 10 |
error[E0282]: type annotations needed |
..default() or ..Default::default() inside a BSN component patch |
Not supported; write the fields or omit them to keep defaults. ..default() is only valid inside raw Rust literals in {} arguments |
error: expected HashedStr, found integer |
Name(42) |
Name takes a string: Name("42") |
error: unexpected token at -- inside bsn! |
Multiple roots in a single-root macro | Use bsn_list! and return impl SceneList |
cannot find macro bsn_list |
Missing import | Add use bevy::scene::bsn_list; (or use bevy::prelude::*;) |
Unknown macro root! printed to stderr |
Fully qualified macro path | Import the macro with use instead |
warning: unused variable: meshes |
Asset parameter present but unused (e.g. hand-edited) | Let the exporter include only needed parameters, or allow the warning |
warning: float_literal_f32_fallback |
Unsuffixed negative float in a bracing block | Use an f32 suffix: { -5.0f32 } |
| Duplicate sibling entities visually merged | Missing -- between scenes |
Separate sibling patches with -- |
If an exported scene fails to compile, first check that the pasted file is in a crate that depends on
Bevy 0.20 and that custom component types and their derives are present. Then compare against the
templates above. The repository's compat/bsn-smoke crate compiles representative generated output
against real Bevy 0.20 and is the reference for supported syntax.
Imperative Rust alternative
forge export --format rust (or File > Export > Rust spawn code) produces a function that spawns the
scene immediately with Commands. It targets the same Bevy 0.20 API surface but does not use BSN.
// Generated by Bevy Forge. Paste into a Bevy 0.20 project.
// Imperative `Commands::spawn` code: call this function from a startup system.
use bevy::prelude::*;
pub fn scene(
commands: &mut Commands,
asset_server: &AssetServer,
) {
commands.spawn((
Name::new("Player"),
Transform::from_xyz(0.0, 0.0, 0.0)
.with_rotation(Quat::from_euler(EulerRot::XYZ, 0.0_f32.to_radians(), 0.0_f32.to_radians(), 0.0_f32.to_radians()))
.with_scale(Vec3::new(1.0, 1.0, 1.0)),
Sprite { image: asset_server.load("textures/player.png"), color: Color::srgba(1.0, 1.0, 1.0, 1.0), flip_x: false, flip_y: false, ..default() },
)).with_children(|parent| {
parent.spawn((
Name::new("Hat"),
Transform::from_xyz(0.0, 1.0, 0.0)
.with_rotation(Quat::from_euler(EulerRot::XYZ, 0.0_f32.to_radians(), 0.0_f32.to_radians(), 0.0_f32.to_radians()))
.with_scale(Vec3::new(1.0, 1.0, 1.0)),
Sprite { color: Color::srgba(1.0, 0.5, 0.2, 1.0), flip_x: false, flip_y: false, ..default() },
));
});
}
Usage in a system:
fn spawn_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
scene(&mut commands, &asset_server);
}
Rules for the imperative generator:
commandsis always a parameter.asset_serverappears only when a sprite image or asset mesh is used.meshesandmaterialsare both emitted whenever the scene contains anyMesh3d(primitive or asset), because the mesh handle and the inline material are created at the same time.- Each entity spawns a tuple of
Name::new(..), aTransform, then its components. OmittedSpritefields use..default(). - Hierarchy uses
.with_children(|parent| { ... }); sibling order is preserved. - Primitive meshes and materials use the same constructors as BSN. Text2d uses
Text2d::new,TextFont { font_size: FontSize::Px(..), ..default() }andTextColor(..). - Custom components are emitted as struct literals; the type must be imported where the generated code compiles.
- Empty scenes are an error, exactly as for BSN.
The imperative form is useful when a project cannot use BSN macros or when code must run against an API where a scene is spawned imperatively. BSN remains the primary, recommended export.