MultiMesh: vertex animation for many instances
One mesh, per-instance animation phase and correct culling bounds.
Start with the complete example
Use the five files and run steps with ParticleMotion=false. C# sets instance locations once; the vertex shader deforms each mesh during drawing.
Instance data
Set TransformFormat and UseCustomData before InstanceCount. Changing format after a nonzero allocation requires reallocation. InstanceCount is capacity; VisibleInstanceCount draws the first instances: 0 hides the group and -1 permits its full capacity. This is not automatic per-fish culling.
VertexSchool3D.cs
using Reckfell;
public partial class VertexSchool3D : MultiMeshInstance3D
{
public ShaderMaterial SwimMaterial { get; private set; } = null!;
public bool TryBuild(int count)
{
if (count < 1 || count > 8192) return false;
var layout = SchoolMesh.Layout(count);
SwimMaterial = new ShaderMaterial { Shader = new Shader { Code = SchoolShaders.Swim } };
var data = new MultiMesh
{
TransformFormat = MultiMesh.TransformFormatEnum.Transform3D,
UseCustomData = true,
Mesh = SchoolMesh.Create(),
InstanceCount = count,
CustomAabb = layout.Bounds
};
for (int i = 0; i < count; i++)
{
float x = (i % layout.Columns - (layout.Columns - 1) * 0.5f) * 0.85f;
float y = (i / layout.Columns - (layout.Rows - 1) * 0.5f) * 0.6f;
data.SetInstanceTransform(i, new Transform3D(Basis.Identity.Scaled(Vector3.One * 0.6f),
new Vector3(x, y, 0)));
data.SetInstanceCustomData(i, new Color(Mathf.PosMod(i * 0.6180339f, 1), 0.5f, 0, 1));
}
Multimesh = data;
MaterialOverride = SwimMaterial;
CastShadow = ShadowCastingSetting.Off;
return true;
}
public void SetMotion(float clock, float amplitude)
{
SwimMaterial.SetShaderParameter("clock", clock);
SwimMaterial.SetShaderParameter("amplitude", Mathf.Clamp(amplitude, 0, 0.4f));
}
}Take SchoolMesh.cs and SchoolShaders.cs from the combined example. Custom-data X supplies phase and Y adjusts frequency. Positions are uploaded once; each frame updates one shared clock uniform rather than 2048 C# transforms.
Complete vertex shader
shader_type spatial;
render_mode unshaded, cull_disabled;
uniform float clock = 0.0;
uniform float amplitude = 0.22;
varying float shade;
void vertex() {
float phase = INSTANCE_CUSTOM.x * TAU;
float tail = clamp((0.6 - VERTEX.x) / 1.2, 0.0, 1.0);
VERTEX.y += sin(clock * (2.0 + INSTANCE_CUSTOM.y) + phase + VERTEX.x * 4.0)
* amplitude * tail * tail;
shade = 0.65 + INSTANCE_CUSTOM.x * 0.35;
}
void fragment() {
ALBEDO = vec3(shade * 0.84, shade * 0.91, shade);
}VERTEX.x runs along the body. tail*tail keeps the head stable and moves the tail more. INSTANCE_CUSTOM varies between instances, preventing identical phase despite a shared material. This example does not use a skeleton or prove skeleton animation is always slower.
Bounds and group partitioning
GPU VERTEX displacement does not automatically provide appropriate CPU bounds for every animated pose. CustomAabb here covers the layout plus a motion margin. Bounds that are too small can cull visible fish; oversized bounds reduce culling effectiveness.
A large group is culled as one object. For instances spread across a large level, use spatial groups with separate bounds. This partitions rendering rather than providing world streaming or per-instance gameplay logic.