MIT License NuGet

Home / Docs / Placement and camera

Placement, anchors and the camera

One convention covers 2D and 3D, single objects and instances: position is the world position of the anchor, and width, height and depth are the target size — not scale factors. The anchor is the geometric centre of the object's own bounding box.

The members you use

MemberMeaning
PosX / PosY / PosZWorld position of the anchor. Left-handed axes; +X is screen right with the default camera.
Width / Height / DepthDesired world size. The engine derives the scale from the asset's own bounds. They are float? — null means "not set yet".
RotationRadians around Y on Model. Mesh3D and instance types take a quaternion.
AlphaOpacity, and the usual way to hide something without removing it.
LocalSizeRead-only. The asset's own size before scaling, if you need to reason in its units.
ComputedScaleRead-only. The per-axis scale the engine derived: target size divided by local size.
AnchorLocalRead-only. The anchor in model space — the centre of the raw bounding box.
AnchorWorldOffsetRead-only. Use it when you need to place the model's origin instead of its centre; see below.

The payoff is that you can size an imported asset without knowing how the artist exported it. Width = 1f means one metre wide, whatever the GLB claims, and two models from two artists placed side by side are actually the same height.

The world matrix, written out

Every 3D control builds its matrix the same way, and the order is the whole point:

Matrix4x4.CreateTranslation(-AnchorLocal)
    * Matrix4x4.CreateScale(ComputedScale)
    * GetRotationMatrix()
    * Matrix4x4.CreateTranslation(PosX, PosY, PosZ)

Under the row-vector convention that is ((p - A) * S) * R + Pos. The anchor A lands exactly on Pos, the rotation pivot is the anchor, and therefore rotating an object changes neither its position nor its size. That property is what makes the object editor in the reference application possible without any compensation maths.

Why the anchor is subtracted before the scale

If scale came first, evaluation would degrade to (p * S - A) * R + Pos: the anchor would no longer land on Pos and the world centre would drift by A * (1 - S), so rendering, picking and highlight bounds would quietly disagree with each other. The anchor was also a corner (Min.X, Max.Y, Min.Z) until it was changed to the bounding-box centre, because corner anchoring gave biased rotation pivots and made placement hard to think about.

Placing an origin instead of a centre

Sometimes you want the model's own origin pinned to a world point — a character exported standing on the ground plane, for instance. The identity p*S*R + t == ((p-A)*S)*R + Pos gives you the conversion directly:

// Pin the model's local origin to a world-space target.
var target = new Vector3(4f, 0f, 12f);
var offset = robot.AnchorWorldOffset;   // recompute each frame

robot.PosX = target.X + offset.X;
robot.PosY = target.Y + offset.Y;
robot.PosZ = target.Z + offset.Z;

Recompute the offset each frame rather than caching it: it already has the current scale and rotation folded in, so the identity keeps holding as the object changes. Set size and rotation first, then position.

Three behaviours worth knowing before they surprise you

  • An unset dimension settles to the asset's own size. When bounds are established after loading, any Width, Height or Depth that is null or 0 is filled in from LocalSize, so a model with no size set appears at its exported scale rather than collapsing to nothing.
  • Flat axes are not scaled. If a local dimension is effectively zero — below 1e-6, or more than four orders of magnitude smaller than the largest axis — that axis stays at scale 1. Without the guard, thickness noise in an exported plane produces scales around 1e5 and tears the mesh into offset fragments. This is a real asset, not a hypothetical one.
  • Animated models have two bounding boxes. GetWorldBounds() returns the conservative box used for culling and shadows, inflated for skinned motion. GetWorldBoundsRaw() matches the rendered body. Picking and selection boxes use the raw one; using the conservative box for a selection box leaves visible empty space above and below the model.

The camera

BaseApp.Camera is a single Camera3D shared by every backend and every pass, and it is the only source of truth for view, projection and frustum. CameraPos and CameraTarget forward to Camera.Position and Camera.Target.

CameraTarget = new Vector3(0f, 3.5f, -2.5f);
CameraPos = CameraTarget + new Vector3(0f, 0.5f, -3.5f);

Camera.FovY = MathF.PI / 4f;   // radians
Camera.Near = 0.1f;
Camera.Far = 1300f;
MemberNotes
Position, Target, UpLeft-handed look-at. Writing an identical value does not dirty the matrices.
FovY, Near, FarProjection, changeable at runtime. Far also clamps how far the sun cascades reach.
AspectRead-only; the engine supplies it from the surface each frame.
View, Projection, ViewProjectionRebuilt only when something changed, which is why touching the camera every frame is free.
FrustumWhat culling tests against. Available to you for your own coarse visibility checks.
ProjectionJittered, PrevViewProjection, JitterPixelsOwned by temporal anti-aliasing. Read them if you are debugging TAA; do not write them.

With CameraPos.Z negative the camera looks toward the origin and world +X is screen right — the arrangement every sample uses, and the one the 2D overlay coordinate system agrees with.