A 3D scene is a tree of Container3D nodes under a view's root, and it works much like PixiJS's 2D scene graph. Each node has a position, a rotation and a scale relative to its parent, so moving a parent moves everything under it. Meshes, models, cameras and lights are all Container3Ds, so you move and group them the same way.
With the camera unrotated, +X points right, +Y points up and +Z points out of the screen toward you. In 2D, +y points down.
glTF, the 3D model format PixiJS 3D loads, measures in metres, and the engine's defaults expect metres too. By default a camera draws nothing more than 1000 units in front of it, its far distance. A PointLight fades with the square of the distance. At pixel sizes, a scene gets cut off and its lamps barely light it. Treat one unit as one metre and build things at their real size.
Which way a node faces depends on what it is:
lookAt turns a node to face a point in world space, which means the scene's own coordinates rather than its parent's. It points a camera's or light's -Z axis at that point, and any other node's +Z side. This lays a floor flat and aims a sun at its middle:
import { DirectionalLight, Material3D, Mesh3D, PlaneGeometry } from '@pixi/3d';
const floor = new Mesh3D({
geometry: new PlaneGeometry({ width: 8, height: 8 }),
material: new Material3D({ baseColor: 0x888888, roughness: 0.85 }),
});
// The plane faces +Z. A quarter turn around X points that face up.
floor.rotation.x = -Math.PI / 2;
const sun = new DirectionalLight({ intensity: 3 });
sun.position.set(-3, 5, 2);
view3d.root.addChild(floor, sun);
// lookAt reads the world transform of the light's parents, so add the light first.
// This turns its -Z axis toward the middle of the floor.
sun.lookAt({ x: 0, y: 0, z: 0 });
position, rotation and scale are ObservableVector3s. These 3D vectors tell their node when they change, so you can change one component or call any vector method on them. As in 2D, x, y and z are shortcuts for the position's components.
rotation holds three angles in radians, one around each axis. A positive angle turns counter-clockwise as you look down the axis from its + end toward the origin. Since +Z points toward you, a positive rotation.z turns a node counter-clockwise on screen, the opposite way to 2D's rotation. A positive rotation.y turns a node that faces you toward +X, to your right. angle is the same rotation in degrees.
When you set more than one angle, the node turns around Z first, then Y, then X, each around its parent's axes. So to lean a campfire's logs in toward the middle, lean each one around Z and then turn it around Y. A lean around X would come after the turn and tip every log the same way.
crate.position.set(0, 0.5, -2);
crate.y += 1;
crate.position = spawnPoint; // copies the values; crate and spawnPoint stay separate
crate.scale = 0.5; // one number scales all three axes
door.angle.y = 90; // the same as door.rotation.y = Math.PI / 2
// a log `angle` radians round the fire from +X
log.position.set(Math.cos(angle) * 0.3, 0.55, Math.sin(angle) * 0.3);
log.rotation.set(0, -angle, 0.4); // lean it around Z, then turn the lean toward the middle around Y
A node also has a quaternion, a rotation stored as four numbers. The node uses whichever of the two you set last. lookAt, reparentChild, loaded models and their animations set the quaternion. Watch for three things:
rotation doesn't read back a turn made through the quaternion. A node inside a loaded model reads 0, 0, 0 however the file turned it, so rotation.y += 0.1 on it replaces the file's rotation. To turn such a node further, change its quaternion, as Math shows. The file never turns the model itself, so model.rotation.y works as usual.rotation to values it already holds, through a component or set, changes nothing. lookAt never touches rotation, so a turret aimed with it stays aimed after turret.rotation.set(0, 0, 0). To make the node use rotation again, assign a whole rotation: turret.rotation = { x: 0, y: 0, z: 0 }.rotation and call lookAt on the same node in one frame. If an earlier frame's lookAt already left the node facing that point, the rotation write wins.A plain Container3D draws nothing. Use one to group nodes that move together. In the floating-shapes tutorial, a container moves each shape around while the mesh inside it spins:
import { Container3D, Mesh3D } from '@pixi/3d';
const float = new Container3D();
const mesh = new Mesh3D({ geometry, material });
float.addChild(mesh);
view3d.root.addChild(float);
app.ticker.add((ticker) => {
float.position.y = Math.sin(ticker.lastTime / 1000) * 0.25; // bob up and down
mesh.rotation.y += 0.5 * (ticker.deltaMS / 1000); // spin in place
});
As with 2D's ContainerOptions, every node's constructor takes Container3DOptions, such as position, rotation and label, on top of its own options. The child methods and events match a 2D Container's, such as addChild, removeFromParent and the added event. Container3DEvents lists the events.
Unlike 2D, the order of children doesn't decide what covers what. A nearer solid surface hides a farther one.
getChildByLabel finds a node by its label. A loaded model labels each node with its name in the file, as Loading models covers. That lets you find a part by name, such as a car's wheel:
const car = new Model3D({ source });
const wheel = car.getChildByLabel('wheel-front-left', true); // true searches the whole subtree
A skinned model, such as a character, has a mesh that bends with a skeleton. A bone becomes a node only when the file puts a mesh under it, so getChildByLabel doesn't find the others. Every bone is in a Skeleton on the model, and Animation shows how to find one by name and pose it.
These methods convert between a node's own space and world space:
In Parents and children, turning an empty orbit node carries a planet round the sun. The planet's position never changes, and getGlobalPosition returns where the orbit has moved it:
import { Vector3 } from '@pixi/3d';
orbit.addChild(planet);
planet.position.x = 2.5; // 2.5 units out from the orbit's centre
const world = new Vector3();
app.ticker.add((ticker) => {
orbit.rotation.y += 0.45 * (ticker.deltaMS / 1000);
planet.getGlobalPosition(world); // writes into world, so it allocates nothing each frame
});
To place 2D content over a 3D node, pass its world position to worldToScreen, which View3D and 2D content covers.
addChild keeps a node's local position, rotation and scale, so a node jumps when its new parent sits somewhere else. reparentChild moves it under the new parent and changes its local transform so it stays where it was in the world:
hand.reparentChild(sword); // the sword stays put, and from now on follows the hand
Changing a position, rotation or scale only marks the node as changed. worldTransform holds what the view worked out the last time it drew, normally once a frame, so it can be out of date:
getGlobalPosition, toGlobal, toLocal and getGlobalTransform walk up through the node's parents on each call, so their result is current even straight after a change. Reading worldTransform costs nothing and suits per-frame code that can use last frame's value.
visible hides a node and everything under it. Hiding and showing a node costs less than removing it and adding it back, so use visible for things that come and go often. Rendering throws while the view's camera or one of its parents is hidden, so keep the camera out of groups you hide.
To take a node out for good, remove or destroy it. Resources and cleanup covers what that frees. The view's camera sits under view3d.root too, so clearing a level with view3d.root.removeChildren() takes the camera out, and the next render throws. Put each level in a Container3D of its own and clear that instead.
getLocalBounds measures a node and everything visible under it as a Box. It measures in the node's own space, before the node's own position, rotation and scale apply. Each call walks the subtree, so measure once rather than every frame.
This scales a fox model to two units long and stands it on the ground at y = 0:
import { Model3D } from '@pixi/3d';
const fox = new Model3D({ source });
const bounds = fox.getLocalBounds();
const scale = 2 / bounds.getSize().z;
fox.scale.set(scale);
fox.position.y = -bounds.min.y * scale;
width, height and depth give the measured size times the scale. Setting one changes the scale on that axis only, and setSize sets all three. Both stretch a model unless the sizes you ask for keep its proportions. To keep them, work out one scale from the bounds, as above. Size and bounds fits three models into equal cubes, stretched with setSize or kept in shape with one scale.