Documentation · 3D Guide
2 · Moving things about
A mesh has no position. It is a shape, sitting around the origin, and it can be drawn in fifty places at once — that is the whole reason it does not know where it is. What knows is the Node holding it, and this chapter is about what a node can do to it.
A transform answers three questions
Where is it, which way is it turned, and how big is it. Every node has all three, and they are ordinary properties:
var node = new MeshNode(Primitives.Box(1f, 1f, 1f), Material.FromColor(0.8f, 0.3f, 0.2f))
{
Position = new Vector3(2f, 0f, -3f), // two right, three away from the viewer
Scale = new Vector3(1f, 2f, 1f), // twice as tall, same width and depth
RotationDegrees = new Vector3(0f, 45f, 0f) // turned 45° about the up axis
};
Rotation is a quaternion — four numbers rather than
three angles, for reasons that are the whole of chapter 4.
RotationDegrees is there so you do not have to care yet.
The three become one 4×4 matrix, and a matrix is simply the compact way to hold "scale, then rotate, then move" so that applying it to a point is one operation instead of three. You can hand one over directly if you have built it yourself:
node.LocalTransform = Matrix4x4.CreateScale(2f) *
Matrix4x4.CreateRotationY(MathF.PI / 4f) *
Matrix4x4.CreateTranslation(0f, 1f, 0f);
Setting LocalTransform takes over: Position, Rotation and
Scale stop driving the node until you set one of them again. It is the escape hatch, not the
usual road.
Order is not a detail. Scale, then rotate, then translate. Do it the other way — move
first and rotate second — and the object swings around the origin like a moon instead of turning where it
stands. In System.Numerics, A * B means "do A, then B", which is the opposite
of the convention in most textbooks. Read the multiplication left to right and it says what happens.
The scene graph
Nodes hold nodes. A child's transform is relative to its parent, so moving the parent moves everything under it, and the child never needs to know.
var car = new Node { Position = new Vector3(0f, 0f, 0f) };
car.Children.Add(new MeshNode(body, paint));
car.Children.Add(new MeshNode(wheel, rubber) { Position = new Vector3(-0.8f, -0.3f, 1.2f) });
car.Children.Add(new MeshNode(wheel, rubber) { Position = new Vector3( 0.8f, -0.3f, 1.2f) });
scene.Children.Add(car);
car.Position = new Vector3(0f, 0f, -10f); // the whole car moves; the wheels stay bolted on
Two things follow. The first is instancing: both wheels here point at the same Mesh instance,
so the geometry is uploaded to the GPU once and drawn twice. The second is that a hierarchy costs
almost nothing — the snapshot walks it once a frame and multiplies the matrices on the way down.
A node has exactly one parent, and adding it somewhere else moves it rather than copying it. That is what makes handover work — a crate picked up by a crane, a missile leaving its rail, a passenger stepping off a train — and it is one line, because the old parent lets go on its own:
crane.Hook.Children.Add(crate); // leaves the deck, keeps its mesh and its material
What does not carry over is where it appeared to be. Position is relative to the
parent, so a crate at (0, 0, 0) on the deck is at the hook's origin the instant it changes
hands. When the handover has to be seamless, read
WorldTransform before the move and set the local transform to match after it.
Beside Add and Remove, Children does Insert,
RemoveAt, IndexOf, Contains and Clear. Order in the list
is not draw order — that is RenderOrder and the distance sort — so inserting rather than adding
matters only when your own code walks the children and cares which comes first.
A node cannot be its own ancestor. Adding a node to something underneath it would make a loop, and a loop in a graph that gets walked once a frame is a hang rather than an error. So it throws instead, at the moment the mistake is made and while the stack still says who made it.
Local space, world space
A node's Position is local: relative to its parent. Most of the time that is exactly
what you want. Sometimes it is not — a wheel two levels down, a turret on a hull that is itself rolling —
and then you want the answer in world space, which is what everything is finally measured in.
WorldTransform— the full matrix, parents included.WorldPosition— where the node actually is. It has a setter: assign one and the node solves for the local position that lands there, whatever its parents are doing.WorldBounds— the box it occupies in the world.DescendantsandFind(name)— for walking a graph somebody else built, which is what a loaded model is.
var turret = ship.Find("turret_port");
turret.LookAt(target.WorldPosition); // aims in world space; the ship's roll is solved out
Bounds, and framing what you built
Every node knows the box it occupies: LocalBounds in its own space,
Bounds and WorldBounds once the transforms are applied. A
BoundingBox is a cheap thing to test against, which is why picking
uses it before it looks at a single triangle, and it is how the control frames a scene it has never seen:
View.Camera.Fit(scene.WorldBounds); // everything visible, with a margin
The control does that for you on the first frame unless you set AutoFit="False". After that
the camera is yours.
Telling the scene something changed
Node properties are watched: set Position and the scene knows a new frame is due. Objects the
graph cannot watch are not — a Material and a
Light are plain mutable objects that could be shared by fifty nodes — so
after changing one of those, say so:
material.BaseColor = new Vector4(1f, 0f, 0f, 1f);
scene.Invalidate();
Adding or removing a node, or a light through Scene.Lights, does it for you. The rule is
simple enough to state once: if you reached past a node to change something, tell the scene.
Run it
Parenting, local versus world, and a hierarchy turning as one thing.
AVA3D_SCENE=Transforms dotnet run --project samples/Ava3D.Demo.Desktop