Documentation · 3D Guide
7 · Making it move
A scene that never changes is a picture. This chapter is the loop that changes it, the three small maths helpers that stop every moving scene from reinventing the same curve, and the one way to move vertices without allocating a megabyte a frame.
The loop
There is no Update callback to override, because a control that owned the clock would own it
for everybody. You drive it, from an ordinary Avalonia timer:
var clock = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
var started = Stopwatch.StartNew();
clock.Tick += (_, _) =>
{
var t = (float)started.Elapsed.TotalSeconds;
ship.Position = new Vector3(MathF.Sin(t) * 4f, 0f, MathF.Cos(t) * 4f);
ship.LookAt(Vector3.Zero);
};
clock.Start();
Setting a node property marks the scene dirty, and the control re-snapshots and redraws on the next frame.
Nothing else is needed — no invalidate call, no begin/end, no lock. Changing something the graph
cannot see — a Material, a Light — still wants
scene.Invalidate(), as chapter 2 said.
Drive from elapsed time, not from a frame counter. The same code runs at 120 fps on a Mac, 60 in an iOS simulator and 62 on the CPU fallback. Anything advanced by "a bit per frame" runs at three different speeds; anything computed from seconds runs at one.
Paths
Movement that is not a straight line usually wants a curve through points you chose. Spline is a Catmull–Rom curve, which is the one that actually passes through its waypoints rather than being politely influenced by them:
Vector3[] path = [new(-80, 0, 120), new(-20, 6, 40), new(30, 2, -10), new(90, -4, -60)];
var where = Spline.Sample(path, t); // position at t, 0..1 along the whole path
var which = Spline.Direction(path, t); // unit heading there — the tangent, normalised
ship.Position = where;
ship.Rotation = Rotations.LookAlong(which);
Taking the heading from the curve rather than storing it separately is the point: there is no second
quantity to drift out of step with the first. Pass closed: true for a loop, and there is a
float[] overload for anything else that should ease through keyframes — a throttle, a fade, a
camera distance.
Easing
Linear motion looks mechanical because nothing in the world starts and stops instantly.
Ease shapes a 0-to-1 parameter — it does not interpolate anything, because
float.Lerp already exists:
Ease.In(t) // slow start, hard stop
Ease.Out(t) // hard start, gentle stop
Ease.InOut(t) // soft at both ends — smoothstep, and the one you want most of the time
Ease.Ramp(from, to, value) // clamped inverse lerp: where `value` sits between the two, as 0..1
Ramp is the one that turns a raw quantity into an animation parameter — how far through a time
window you are, how close a ship is to its target. Composed with InOut it is exactly GLSL's
three-argument smoothstep, which is why there is no fourth curve for that:
var fade = Ease.InOut(Ease.Ramp(40f, 10f, distance)); // 0 at 40 units, 1 at 10, soft at both ends
Randomness you can repeat
Scatter a thousand rocks with Random and they land somewhere different every run, which makes
a scene impossible to photograph twice and a bug impossible to reproduce. Seeding it does not help as much
as it sounds: a sequence's thousandth answer depends on having asked for the first nine hundred and
ninety-nine, so drawing one extra value anywhere moves everything after it.
Scatter is indexed instead of sequential. Every answer is a pure function of an index and a channel — no state, no seeding, no allocation, the same on every platform and every run:
for (var i = 0; i < 1660; i++)
stars[i] = Scatter.Direction(i) * 2_400_000f; // uniform over the sphere
var size = 0.5f + Scatter.Value(i, channel: 1) * 2f; // a second, independent stream
var inside = Scatter.Point(i, channel: 2); // uniform through the ball, not just on it
Channels are what let one index carry several unrelated properties without them correlating. Delete a rock and every other rock stays exactly where it was.
Moving vertices
Everything above moves whole objects. Sometimes the shape itself has to change — a wake, a waveform, a flag, a particle system where every fragment goes its own way.
A Mesh's arrays are init-only so that nothing can swap one out from under a GPU buffer sized
for it. The contents are yours: write into them, say so, and the backends refill the buffers they
already hold — no allocation, no new buffer names, nothing freed.
for (var i = 0; i < mesh.Positions.Length; i++)
mesh.Positions[i].Y = MathF.Sin(mesh.Positions[i].X * 0.5f + t) * 0.4f;
mesh.InvalidateGeometry();
scene.Invalidate(); // a Mesh is not in the graph, so tell the scene yourself
LineNode and PointsNode have the same
method and do not need the second line — they are nodes, so they tell their own scene. Their
Positions are also settable, which is how the count changes; assigning a new array invalidates
by itself.
Normals do not update themselves. Move the positions of a lit mesh and the shading is still the old shape's. Recompute them into the existing array if the deformation is large enough to see — or use it on something unlit, like a line or a point cloud, where the question does not arise.
Many copies of one model
A character file is a rig, its clips and its morph targets, and a crowd is that file many times over with every copy in its own pose. Loading it once per copy would parse and upload the same geometry each time; loading it once and moving one copy about would move them all. The answer is to read the file once, as a GltfAsset, and instantiate it as often as it is on screen.
var asset = await GltfAsset.LoadAsync(new Uri("avares://MyApp/Assets/walker.glb"));
var left = asset.Instantiate().AddTo(scene);
var right = asset.Instantiate().AddTo(scene);
right.Root.Position = new Vector3(2f, 0f, 0f);
right.Animator.Play("Walk"); // the right one walks; the left stands
left.Shape["smile"] = 1f; // the left one smiles; the right does not
Each GltfInstance has its own nodes, its own Animator, its own morph weights and its own skins bound to its own joints — so a clip playing on one and a shape set on another never meet. What they share is the asset's meshes and textures, uploaded once, and by default its materials: recolour one and both change, which is what a crowd wants. For the one copy that has to be different, ask for its own:
var odd = asset.Instantiate(new InstantiateOptions { Materials = MaterialSharing.Copy }).AddTo(scene);
odd.Materials["coat"].BaseColor = new Vector4(0.7f, 0.1f, 0.1f, 1f); // this coat alone
AddTo registers the instance's animator with the scene, so the view advances it from its own
clock — the loop at the top of this chapter is not needed for clips, only for what you move by hand.
GltfLoader.LoadModel is the same two steps in one call for a viewer with one model in it, and
the GltfModel it hands back carries the asset, so a second copy is one
more Instantiate away.
Effects that never touch a vertex
Before reaching for moving vertices, check whether a transform will do. Almost every effect in the demo's film is a fixed pool of nodes, hidden when idle, whose animation is entirely position, rotation, scale, opacity or sprite size:
- A fireball is one sprite whose
Sizegrows andOpacityfalls. - A debris burst is a
PointsNodeof 48 points on a unit sphere, with the node'sScalegrowing. - A shockwave is a 32-segment
LineNodering, scaled outward. - A tracer is one segment plus a sprite, translated along its own −Z.
An invisible node costs a stack push — IsVisible = false and the snapshot skips the whole
subtree — so a pool of sixteen bolts that are mostly idle costs nothing to keep around. It is how a game
would do it anyway, and it means the geometry is uploaded once for the life of the scene.
Run it
The animation scene is transforms on a clock. The film is all of the above at once, and prints its captions so you can follow the timeline without watching it.
AVA3D_SCENE=Animation dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Contact AVA3D_CAPTIONS=1 dotnet run --project samples/Ava3D.Demo.Desktop