On AI assistance
Yes, AI assistance was involved in writing this chapter. I worked on the structure, the technical decisions, the approach, how to structure the code, and put together a list of anticipated questions learners would have. AI helped expand on the structure and explanations, and I edited throughout. In total, I spent around 20–25 hours on each chapter, between coding and writing. If anything feels off in any section, let me know on Reddit or Discord and I'll work on it.

I’ve been following the Bevy 0.19 release and kept asking myself: what’s something cool to build with all the new building blocks 0.19 is shipping? I tinkered around and ended up with a small 3D editor inspired by Blender.

A 3D editor sounds big, but our focus is a simple scene editor to build environments, place enemies, etc. Any game that has a world needs some way to author it.

In this series we’ll build a simple one for exactly that purpose, something you can extend however your game needs. The nice part about building this in Bevy is that your editor and your game share the same ECS, so the scene you save is the scene your game loads.

Which brings up the usual question. Should you actually build your own game editor, or wait for the official one, or just hand author your scenes in code? I’m not touching that debate with a ten-foot pole. Whatever you pick, someone in the internet will tell you you’re wrong anyway.

The principle The principle
The principle

Here’s what I plan to cover in this tutorial series:

  • A mini 3D editor inspired by Blender
  • Move, rotate, and resize with keyboard shortcuts
  • Change object materials, create a toon shader
  • Save and load scenes
  • Import GLTF models

Everything listed above will ship as free blog posts. That part is settled. What I haven’t figured out yet is whether there will be any extra material beyond this list, and if so, whether it would be pay-gated. So treat the free posts as the plan, and anything past them as a maybe.

In this tutorial, we will focus on the following:

  • A small 3D scene you can move around in like Blender
  • A grey cube in the middle
  • An infinite ground-plane grid with a red X axis and a green Y axis
  • A camera to orbit around the 3D space.
Before We Begin: I'm constantly working to improve this tutorial. If anything trips you up, or if you want to see what's coming next, drop a note on Reddit / Discord / LinkedIn. Loved it? Let me know what worked.

This tutorial assumes you’re comfortable with structs, enums, associated functions and closures, all covered in our Bevy 2D Game Development series, Chapters 1–7. If any of those feel shaky, those chapters are free and worth a read first.


Project Setup

The source code for the series and each episode is available in this repo.

cargo new bevy_tutorial_editorcd bevy_tutorial_editor

Replace Cargo.toml with:

Cargo.toml
[package]name = "bevy_tutorial_editor"version = "0.1.0"edition = "2024"[dependencies]bevy = "=0.19.0-rc.2"# Bevy's recommended dev build profile..[profile.dev]opt-level = 1[profile.dev.package."*"]opt-level = 3

Scene Setup

Every 3D scene needs exactly three things to be visible:

  • A camera, something to look through
  • A light, something to illuminate the scene
  • A mesh, something to look at

Let’s start here and see where it leads.

src/main.rs
Replace main.rs with the following code.
use bevy::prelude::*;fn main() {    App::new()        .add_plugins(DefaultPlugins)        .add_systems(Startup, setup_scene)        .run();}fn setup_scene(    mut commands: Commands,    mut meshes: ResMut<Assets<Mesh>>,    mut materials: ResMut<Assets<StandardMaterial>>,) {    commands.spawn((        Camera3d::default(),        Transform::from_xyz(3.0, 3.0, 8.0).looking_at(Vec3::ZERO, Vec3::Y),    ));    commands.spawn((        DirectionalLight {            illuminance: 5000.0,            ..default()        },        Transform::IDENTITY.looking_to(Vec3::new(-1.0, -2.0, -1.0).normalize(), Vec3::Y),    ));    commands.spawn((        Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),        MeshMaterial3d(materials.add(StandardMaterial {            base_color: Color::srgb(0.7, 0.7, 0.7),            perceptual_roughness: 0.85,            ..default()        })),        Transform::IDENTITY,    ));}

Building things by composition

In Bevy, everything in your world is an entity represented by a unique ID. A camera is an entity. A light is an entity. A cube is an entity. So is player, enemy, etc. The ID itself means nothing; what shapes it is the data you attach to it, called components.

Bevy ECS composition: a Camera entity composed of Transform and Camera3d; a Cube entity composed of Transform, Mesh3d, and MeshMaterial3d. Bevy ECS composition: a Camera entity composed of Transform and Camera3d; a Cube entity composed of Transform, Mesh3d, and MeshMaterial3d.

Spawn Command

commands.spawn((…)) creates an entity and composes it from parts in one call. Like assembling from pieces: you pick the parts that describe what you want, hand them to spawn, and Bevy creates it. Want a camera? Compose Camera3d + a Transform that says where it sits and what it looks at. Want a cube? Compose a mesh shape, a material, and a position.

Any combination of components is valid Rust, Bevy ignores ones it doesn’t recognise. The interesting question is which combinations the renderer responds to, and you’ll build up instinct for that as the series progresses.

Just staple it on Just staple it on
Just staple it on

How does setup_scene get its arguments if we never call it ourselves?

This is one of Bevy’s nicest tricks. When you write add_systems(Startup, setup_scene), you’re handing Bevy the function, not calling it. Bevy inspects its parameter types and at runtime fetches each one from its internal world automatically.

Pseudocode don't use.
fn setup_scene(    mut commands: Commands,    mut meshes: ResMut<Assets<Mesh>>,    mut materials: ResMut<Assets<StandardMaterial>>,) 

You never wire these up. Bevy sees ResMut<Assets<Mesh>> and knows exactly which resource to pull from the world.

cargo run

The first build will take a few minutes. Bevy is a large engine and this is a cold compile of every dependency. Subsequent builds are fast.

You should see a window with a grey cube.

A grey cube rendered in a Bevy window

Viewport Camera Setup

A static camera doesn’t help much, you can’t inspect the cube from different directions. We want Blender-style viewport, and we’ll support both a mouse and a trackpad.

Orbit camera controls: Orbit is middle-button drag (mouse) or two-finger drag (trackpad); Pan is Shift plus middle-button drag (mouse) or Shift plus two-finger drag (trackpad); Zoom is the scroll wheel (mouse) or a pinch (trackpad). Orbit camera controls: Orbit is middle-button drag (mouse) or two-finger drag (trackpad); Pan is Shift plus middle-button drag (mouse) or Shift plus two-finger drag (trackpad); Zoom is the scroll wheel (mouse) or a pinch (trackpad).

To do that, we need a camera that responds to user input, turning mouse and trackpad motion into orbit, pan, and zoom every frame.

Now we will start organising the code with plugins: small structs that bundle related systems, mostly to keep things tidy as the editor grows.

Update src/main.rs. The scene is the same, we’re just wrapping it in a plugin and moving the camera out.

src/main.rs
Update the code in main.rs
use bevy::prelude::*;fn main() {    App::new()        .add_plugins(DefaultPlugins)        .add_plugins(EditorPlugin)        .run();}struct EditorPlugin;impl Plugin for EditorPlugin {    fn build(&self, app: &mut App) {        app.add_systems(Startup, setup_scene);    }}fn setup_scene(    mut commands: Commands,    mut meshes: ResMut<Assets<Mesh>>,    mut materials: ResMut<Assets<StandardMaterial>>,) {    commands.spawn((        Camera3d::default(),        Transform::from_xyz(3.0, 3.0, 8.0).looking_at(Vec3::ZERO, Vec3::Y),    ));    commands.spawn((        DirectionalLight {            illuminance: 5000.0,            ..default()        },        Transform::IDENTITY.looking_to(Vec3::new(-1.0, -2.0, -1.0).normalize(), Vec3::Y),    ));    commands.spawn((        Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),        MeshMaterial3d(materials.add(StandardMaterial {            base_color: Color::srgb(0.7, 0.7, 0.7),            perceptual_roughness: 0.85,            ..default()        })),        Transform::IDENTITY,    ));}

Make a directory for our viewport module.

mkdir src/viewport

Create src/viewport/mod.rs.

src/viewport/mod.rs
pub mod camera;   

Moving the Camera

Let’s make the camera move. We need three things: the camera’s state (where it sits and what it looks at), the math that updates that state, and the system that reads your mouse and trackpad every frame and feeds the math.

Create src/viewport/camera.rs. We’ll start with the imports, the plugin setup, and the camera state.

src/viewport/camera.rs
use bevy::{    input::{        gestures::PinchGesture,        mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll, MouseScrollUnit},    },    prelude::*,};pub struct CameraPlugin;impl Plugin for CameraPlugin {    fn build(&self, app: &mut App) {        app.add_systems(Startup, spawn_camera)            .add_systems(Update, control);    }}#[derive(Component)]pub struct OrbitCamera {    pub focus: Vec3,    pub distance: f32,    pub yaw: f32,    pub pitch: f32,}impl Default for OrbitCamera {    fn default() -> Self {        Self {            focus: Vec3::ZERO,            distance: 10.0,            yaw: -0.7,            pitch: 0.5,        }    }}

So far the camera only describes itself: focus, distance, yaw, and pitch are just four numbers. Now we need the camera to move based on the input.

So we add the methods that actually move the camera. Each one handles a single action: orbit_by spins the view, pan slides the focus, zoom dollies in or out. Both mouse and trackpad input end up here; the control function at the bottom decides which to call based on what the user is doing.

src/viewport/camera.rs
Append this code to camera.rs
const ORBIT_SENSITIVITY: f32 = 0.005;const PAN_SENSITIVITY: f32 = 0.0015;const WHEEL_ZOOM_SENSITIVITY: f32 = 0.12;const TRACKPAD_ZOOM_SENSITIVITY: f32 = 0.01;const PINCH_ZOOM_SENSITIVITY: f32 = 3.0;const TRACKPAD_MOTION_SCALE: f32 = 0.4;const MIN_DISTANCE: f32 = 0.5;const MAX_DISTANCE: f32 = 500.0;impl OrbitCamera {    fn orbit_by(&mut self, delta: Vec2) {        self.yaw -= delta.x * ORBIT_SENSITIVITY;        self.pitch -= delta.y * ORBIT_SENSITIVITY;    }    fn pan(&mut self, transform: &Transform, delta: Vec2) {        let right = *transform.right();        let up = *transform.up();        let scale = PAN_SENSITIVITY * self.distance;        self.focus += (-right * delta.x + up * delta.y) * scale;    }    fn zoom(&mut self, amount: f32) {        self.distance = (self.distance * (1.0 - amount)).clamp(MIN_DISTANCE, MAX_DISTANCE);    }    fn apply_to(&self, transform: &mut Transform) {        let rot = Quat::from_axis_angle(Vec3::Y, self.yaw)            * Quat::from_axis_angle(Vec3::X, self.pitch);        transform.rotation = rot;        transform.translation = self.focus + rot * Vec3::new(0.0, 0.0, self.distance);    }}fn spawn_camera(mut commands: Commands) {    let orbit = OrbitCamera::default();    let mut transform = Transform::IDENTITY;    orbit.apply_to(&mut transform);    commands.spawn((Camera3d::default(), transform, orbit));}fn control(    mouse_buttons: Res<ButtonInput<MouseButton>>,    keys: Res<ButtonInput<KeyCode>>,    mouse_motion: Res<AccumulatedMouseMotion>,    mouse_scroll: Res<AccumulatedMouseScroll>,    mut pinch_reader: MessageReader<PinchGesture>,    mut camera: Single<(&mut OrbitCamera, &mut Transform)>,) {    let (orbit, transform) = &mut *camera;    let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);    let ctrl = keys.pressed(KeyCode::ControlLeft)        || keys.pressed(KeyCode::ControlRight)        || keys.pressed(KeyCode::SuperLeft)        || keys.pressed(KeyCode::SuperRight);    if mouse_buttons.pressed(MouseButton::Middle) {        let delta = mouse_motion.delta;        if delta != Vec2::ZERO {            if shift {                orbit.pan(transform, delta);            } else {                orbit.orbit_by(delta);            }        }    }    let scroll = mouse_scroll.delta;    if scroll != Vec2::ZERO {        match mouse_scroll.unit {            MouseScrollUnit::Line => orbit.zoom(scroll.y * WHEEL_ZOOM_SENSITIVITY),            MouseScrollUnit::Pixel => {                let d = scroll * TRACKPAD_MOTION_SCALE;                if ctrl {                    orbit.zoom(d.y * TRACKPAD_ZOOM_SENSITIVITY);                } else if shift {                    orbit.pan(transform, d);                } else {                    orbit.orbit_by(Vec2::new(-d.x, -d.y));                }            }        }    }    let pinch: f32 = pinch_reader.read().map(|g| g.0).sum();    if pinch != 0.0 {        orbit.zoom(pinch * PINCH_ZOOM_SENSITIVITY);    }    orbit.apply_to(transform);}

Update src/main.rs to declare the new module and register CameraPlugin inside EditorPlugin:

src/main.rs
Update main.rs to declare the viewport module and register CameraPlugin.
use bevy::prelude::*;pub mod viewport;use viewport::camera::CameraPlugin;fn main() {    App::new()        .add_plugins(DefaultPlugins)        .add_plugins(EditorPlugin)        .run();}struct EditorPlugin;impl Plugin for EditorPlugin {    fn build(&self, app: &mut App) {        app.add_plugins(CameraPlugin)            .add_systems(Startup, setup_scene);    }}fn setup_scene(    mut commands: Commands,    mut meshes: ResMut<Assets<Mesh>>,    mut materials: ResMut<Assets<StandardMaterial>>,) {    commands.spawn((        DirectionalLight {            illuminance: 5000.0,            ..default()        },        Transform::IDENTITY.looking_to(Vec3::new(-1.0, -2.0, -1.0).normalize(), Vec3::Y),    ));    commands.spawn((        Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),        MeshMaterial3d(materials.add(StandardMaterial {            base_color: Color::srgb(0.7, 0.7, 0.7),            perceptual_roughness: 0.85,            ..default()        })),        Transform::IDENTITY,    ));}

Now let’s run it and see the camera respond to your inputs.

cargo run

Infinite Grid Setup

You can orbit around the cube now, but there’s nothing to ground the scene: no horizon, no floor. We want Blender’s infinite grid: a ground plane with a red X axis line, a green Y axis line, and minor/major grid lines that fade out at the horizon.

Bevy 0.19 ships this as bevy_dev_tools::infinite_grid. We just need to turn the feature on and spawn the entity.

Update Cargo.toml to enable bevy_dev_tools.

Cargo.toml
Only the bevy line changes; the rest of the file stays the same.
[package]name = "bevy_tutorial_editor"version = "0.1.0"edition = "2024"[dependencies]bevy = { version = "=0.19.0-rc.2", features = [ "bevy_dev_tools"] }# Bevy's recommended dev build profile..[profile.dev]opt-level = 1[profile.dev.package."*"]opt-level = 3

Blender Conventions

Create the grid module inside viewport folder.

One thing worth noting before you read the code: this editor follows Blender’s axis convention, where X is left-right, Y is depth, and Z is up. Bevy is Y-up, so we swap Y and Z to keep it similar to Blender’s interface.

src/viewport/grid.rs
Create viewport/grid.rs with the following code.
use bevy::{    dev_tools::infinite_grid::{InfiniteGrid, InfiniteGridSettings},    prelude::*,};pub struct GridPlugin;impl Plugin for GridPlugin {    fn build(&self, app: &mut App) {        app.add_systems(Startup, spawn_grid);    }}fn spawn_grid(mut commands: Commands) {    commands.spawn((        InfiniteGrid,        InfiniteGridSettings {            x_axis_color: Color::srgb(0.80, 0.24, 0.24),            z_axis_color: Color::srgb(0.33, 0.66, 0.33),            minor_line_color: Color::srgb(0.28, 0.28, 0.28),            major_line_color: Color::srgb(0.40, 0.40, 0.40),            fadeout_distance: 150.0,            ..default()        },    ));}

Register the module by updating src/viewport/mod.rs.

src/viewport/mod.rs
pub mod camera;pub mod grid;

Putting it Together

Update src/main.rs: add InfiniteGridPlugin and GridPlugin.

src/main.rs
Update main.rs to add the grid plugins.
use bevy::dev_tools::infinite_grid::InfiniteGridPlugin;use bevy::prelude::*;pub mod viewport;use viewport::camera::CameraPlugin;use viewport::grid::GridPlugin;fn main() {    App::new()        .add_plugins(DefaultPlugins)        .add_plugins(EditorPlugin)        .run();}struct EditorPlugin;impl Plugin for EditorPlugin {    fn build(&self, app: &mut App) {        app.add_plugins((InfiniteGridPlugin, CameraPlugin, GridPlugin))            .add_systems(Startup, setup_scene);    }}fn setup_scene(    mut commands: Commands,    mut meshes: ResMut<Assets<Mesh>>,    mut materials: ResMut<Assets<StandardMaterial>>,) {    commands.spawn((        DirectionalLight {            illuminance: 5000.0,            ..default()        },        Transform::IDENTITY.looking_to(Vec3::new(-1.0, -2.0, -1.0).normalize(), Vec3::Y),    ));    commands.spawn((        Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),        MeshMaterial3d(materials.add(StandardMaterial {            base_color: Color::srgb(0.7, 0.7, 0.7),            perceptual_roughness: 0.85,            ..default()        })),        Transform::from_xyz(0.0, 0.5, 0.0),    ));}
cargo run


In upcoming posts, we’ll add click to select, a menu to spawn new shapes, keyboard tools to move, rotate, and scale them, materials and a toon shader, and finally saving and loading scenes plus importing .gltf models.