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.