One control, one draw, many copies — each with its own transform, and for
InstancedModel, its own animation clip, speed and phase. The
robots, birds, rocks and beach tiles in the reference application are all one instanced
control each.
You derive your own type from
MeshInstanceTransform, put your own fields on it, and add your
objects to Instances. The object in the list is the
instance, so editing it takes effect on the next frame with no copy step and no handle to keep
in sync.
// Your own fields ride along with the engine's.
internal class Person : MeshInstanceTransform
{
internal float Yaw { get; set; }
internal string? Animation { get; set; }
}
robotField = new InstancedModel { ModelName = "Assets/3DGodotRobot.glb" };
AddControl(robotField);
for (var i = 0; i < 10; i++)
{
robotField.Instances.Add(new Person
{
PosX = -2f, PosY = 1f, PosZ = 6f + i * 3f,
Width = 1f, Height = 1f, Depth = 0.5f,
Animation = "Idle-loop"
});
}
Adding and removing at runtime, including in the middle of the list, preserves order. There is no rebuild step and no instance-count ceiling to declare up front.
| Field | Notes |
|---|---|
PosX, PosY, PosZ | World position of the instance anchor, which is the centre of the shared template box. |
Width, Height, Depth | Target size in world units. Plain float here, not float? — 0 means unset and is settled to the template size during the host's update. |
Rotation | A Quaternion, pivoting on the anchor. Note the difference from Model.Rotation, which is a float around Y. |
Enable | Skips the copy without removing it from the list. True by default. |
AnimationClip | Clip index, not a name. 0 is the default clip. |
AnimationSpeed | Playback rate, 1 being normal. Varying it slightly per copy is what stops a crowd looking mechanical. |
AnimationTimeOffset | Phase offset in seconds, so copies do not step in unison. |
Selected, Highlight | Per-instance picking state, so one copy out of two hundred can be outlined. |
ID, Name | Identity, useful when the list is rebuilt from data. |
The animation fields are read by InstancedModel and ignored by
InstancedMesh3D, because procedural geometry has no clips.
Instances address clips by index. Names are usually what your own code wants to think in, so the reference application keeps a name on its instance type and converts once per frame. This is the whole conversion, and it is worth copying rather than reinventing:
int ResolveAnimationClip(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return 0;
var names = robotField.AnimationNames;
for (var i = 0; i < names.Count; i++)
if (names[i] == name)
return i;
return 0; // unmatched falls back to the default clip
}
// Per frame, per instance:
person.AnimationClip = ResolveAnimationClip(person.Animation);
InstancedModel.AnimationNames is populated after the GLB loads,
so before that the loop finds nothing and every copy plays clip 0. A null, empty or unmatched
name falls back to the default clip rather than throwing, which is the behaviour you want
while an asset is still loading.
The geometry is shared, so the bounds are too. Everything on
Mesh3DBase has a template-scoped counterpart here.
| Member | Meaning |
|---|---|
TemplateLocalBounds | Inflated by AnimatedBoundsScale for animated models. What culling tests. |
TemplateLocalBoundsRaw | Not inflated. What anchor and scale computation uses. |
TemplateLocalSize | Full size of the raw template box. |
TemplateAnchorLocal | Centre of the raw template box — the same centre-anchor convention as single models. |
BuildInstanceMatrix(instance) | The world matrix for one copy, built exactly like a single model's. |
InstanceAnchorWorldOffset(instance) | The conversion to use when you want to pin the template's origin instead of its centre. |
GetInstanceWorldBoundsRaw(instance) | World bounds of one copy, matching the rendered body. |
TryPickInstance(...), TryPickInstanceSurface(...) | Box-level and surface-level ray tests, both reporting which MeshInstanceTransform was hit. |
Highlight on the host cascades
Assigning a whole new Highlight object to the instanced
control copies it into every instance that exists at that moment. Nested assignment
— Highlight = { Style = ... } — does
not cascade, because no assignment to the property happens. Instances added later
keep their own default and are resolved as "inherit the host" by
ObjectPicker, which is what makes a panel that grows its
instance list frame by frame still highlight correctly.
Each instance gets a bounding-sphere frustum test, and the whole batch is skipped only when
every copy is invisible. The shadow pass does not frustum-cull at all; it is gated by
CastShadows instead.
ITransparentSortable exists only on
Mesh3DBase, so instanced batches are not depth-sorted
against transparent single objects.