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:

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

← 6 · Past triangles 8 · Making it fast →