Pre-1.0: the API is not stable yet and may change from version to version. Pin the exact version in your Cargo.toml and check the changelog before upgrading.

3D scenes

Lit 3D models inside a 2D animation: every position, rotation, color and the camera are signals, and the 3D view is a node like any other.

Loading live preview…

Source of cube_3d — edit in the playground
//! A 3D node inside a 2D diagram: a lit cube turns in a `View3d`, and 2D
//! labels follow points on its faces.

use oxframe::prelude::*;

/// A label and a leader line pinned to a point on the cube.
fn pin(world: &Scene3d, cube: &Object3d, frame: View3d, text: &str, local: Vec3, offset: Vec2) {
    let (w, c, to_view) = (world.clone(), cube.clone(), frame.projector());
    let at = move || to_view(w.world_point(&c, local));
    let label = frame.add(Txt::new().text(text).caption().opacity(0.0));
    let line = frame.add(
        Line::new()
            .points([(0.0, 0.0), (0.0, 0.0)])
            .stroke(Muted)
            .line_width(2.0)
            .opacity(0.0),
    );
    let a = at.clone();
    label.x().bind(move || a().x + offset.x);
    let a = at.clone();
    label.y().bind(move || a().y + offset.y);
    line.points().bind(move || {
        let p = at();
        vec![p + offset * 0.8, p]
    });
    spawn(async move {
        all((label.fade_in(), line.fade_in())).await;
    });
}

#[oxframe::scene]
pub async fn diagram(view: View) {
    view.add(
        Txt::new()
            .text("A 3D node in a 2D diagram")
            .h1()
            .to_edge(Edge::Top),
    );
    let world = Scene3d::new();
    let cube = world.add(Mesh::cube().color(Accent).roughness(0.35));
    let frame = view.add(View3d::new(&world).size(760.0, 520.0).y(40.0));

    // The diagram around it is plain 2D.
    let code = view.add(
        Rect::new()
            .width(340.0)
            .height(90.0)
            .radius(Lg)
            .fill(Surface)
            .x(-740.0)
            .y(40.0),
    );
    code.add(Txt::new().text("Scene3d").body());
    let out = view.add(
        Rect::new()
            .width(340.0)
            .height(90.0)
            .radius(Lg)
            .fill(Surface)
            .x(740.0)
            .y(40.0),
    );
    out.add(Txt::new().text("MP4 · PNG · check").body());
    for (a, b) in [
        ((-560.0, 40.0), (-400.0, 40.0)),
        ((400.0, 40.0), (560.0, 40.0)),
    ] {
        view.add(
            Line::new()
                .points([a, b])
                .end_arrow(true)
                .stroke(Muted)
                .line_width(4.0),
        );
    }

    pin(
        &world,
        &cube,
        frame,
        "top",
        Vec3::new(0.0, 0.5, 0.0),
        Vec2::new(-120.0, -110.0),
    );
    pin(
        &world,
        &cube,
        frame,
        "front",
        Vec3::new(0.0, 0.0, 0.5),
        Vec2::new(150.0, 90.0),
    );

    // One full turn while the camera moves in.
    all((
        cube.rotation().to(Vec3::new(0.0, 360.0, 0.0), 6.0),
        world.camera().position().to(Vec3::new(2.2, 1.8, 3.0), 6.0),
    ))
    .await;
    cube.color().to(Success, 0.6).await;
    wait_for(0.5).await;
}

pub fn project() -> Project {
    Project::new("cube_3d").scene(diagram())
}

A world and a view

Turn on the 3d feature of oxframe:

oxframe = { version = "…", features = ["3d"] }
let world = Scene3d::new();
let cube = world.add(Mesh::cube().color(Accent).roughness(0.35));
view.add(View3d::new(&world).size(640.0, 400.0));
cube.rotation().to(Vec3::new(0.0, 360.0, 0.0), 6.0).await;

Scene3d::new() is an empty world with a camera looking at the origin. scene.add(mesh) puts a mesh in it and returns an Object3d. View3d::new(&scene).size(640.0, 400.0) shows the world in the 2D scene: it is a picture there, so position, opacity, effects, the 2D camera and layout work as for any node. Y is up and a cube is 1 unit wide.

Meshes and materials

Mesh::cube(), Mesh::sphere(), Mesh::plane() (a floor facing up) and Mesh::cylinder() are 1 unit across. Mesh::glb_file reads a binary glTF model and fits it into the same 1-unit box; its base color is kept, textures are not read yet. Mesh::group() draws nothing and holds children: object.add(mesh) adds one that moves, turns and scales with its parent.

The material follows glTF: .color() takes a color or a theme role, .metallic() goes from paint (0) to metal (1), .roughness() from mirror (0) to matte (1), .emissive() makes a surface glow and .opacity() makes it see-through. .position(), .rotation() in degrees and .scale() place it.

Everything is a signal

cube.position().to(Vec3::new(1.0, 0.0, 0.0), 1.0).await;
cube.color().to(Success, 0.5).await;
world.camera().position().to(Vec3::new(2.2, 1.8, 3.0), 2.0).await;
let sun = world.light(Light::directional((-2.0, -4.0, -1.0)));
sun.intensity().to(0.3, 1.0).await;

An Object3d has position, rotation (degrees around X, then Y, then Z — animate past 360 for more turns), orientation (a Quat), scale, color, metallic, roughness, emissive and opacity as signals. scene.camera() has position, target and field of view. scene.light(Light::directional(..)) adds sunlight and Light::point(..) a bulb; with no light added, a default sunlight gives each face of a box its own shade.

2D labels on 3D points

let frame = view.add(View3d::new(&world).size(760.0, 520.0));
let to_view = frame.projector();
let label = frame.add(Txt::new().text("top").caption());
let (w, c) = (world.clone(), cube.clone());
label.x().bind(move || to_view(w.world_point(&c, Vec3::new(0.0, 0.5, 0.0))).x);

view.projector() maps a 3D point into the view's own coordinates, so a label or a leader line follows a corner while the model turns. scene.world_point(object, point) gives the point of an object after its parents' transforms.

How it renders

The renderer is a software rasterizer with a depth buffer and 2 × 2 supersampling for smooth edges. The same scene gives the same pixels in a render, in oxframe check, in CI without a GPU and in the browser playground. A frame is redrawn only when something in the scene changed. .samples(1) renders faster, .resolution(2.0) keeps the view sharp when it is scaled up. Shadows are not drawn yet.

The same picture path is open to nodes of your own: DrawCtx::image draws a picture in a node you write.