Many objects: MultiMesh or GPU particles
A complete project example with two GPU-animation approaches and explicit scope.
Choose an approach
| Task | Approach |
|---|---|
| Many copies of one geometry with specified positions | MultiMesh |
| GPU motion for many visual instances | GpuParticles3D with a process shader |
| Individual behavior, interaction and a physics body | Separate entities or a separate gameplay simulation |
A GPU instance is not an Entity with callbacks, collision or a network ID. This example is a visual school rather than thousands of simulated game characters. Its 8192 guard is an example limit, not an engine limit.
Run both modes
Use a C# project with Reckfell.NET.Sdk 1.0.0, net10.0 and Compatibility rendering. Save all five files below, run dotnet build, create a level with an Entity3D root and attach SchoolShowcase.cs.
Save as school.rscene and run. The default school has 512 fish. Count changes instance capacity; ParticleMotion=false uses MultiMesh and true uses GPU particles. Configure these before launch. The pictures use Count=2048 and are actual engine frames rather than FPS measurements.
SchoolShaders.cs
public static class SchoolShaders
{
public const string Swim = """
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);
}
""";
public const string Motion = """
shader_type particles;
render_mode disable_force, disable_velocity;
uniform float clock = 0.0;
uniform int columns = 32;
uniform int rows = 16;
void start() {
CUSTOM = vec4(fract(float(INDEX) * 0.6180339), 0.5, 0.0, 1.0);
COLOR = vec4(1.0);
}
void process() {
float phase = CUSTOM.x * TAU;
float x = (float(INDEX % uint(columns)) - float(columns - 1) * 0.5) * 0.85;
float y = (float(INDEX / uint(columns)) - float(rows - 1) * 0.5) * 0.6;
TRANSFORM = mat4(vec4(0.6, 0.0, 0.0, 0.0), vec4(0.0, 0.6, 0.0, 0.0),
vec4(0.0, 0.0, 0.6, 0.0),
vec4(x + sin(clock + phase) * 0.35, y + cos(clock + phase) * 0.12, 0.0, 1.0));
}
""";
}SchoolMesh.cs
using Reckfell;
public static class SchoolMesh
{
public static ArrayMesh Create()
{
Vector3 head = new(0.6f, 0, 0);
Vector3 upper = new(0.05f, 0.18f, 0);
Vector3 lower = new(0.05f, -0.18f, 0);
Vector3 tail = new(-0.35f, 0, 0);
Vector3 finUp = new(-0.6f, 0.24f, 0);
Vector3 finDown = new(-0.6f, -0.24f, 0);
Vector3[] vertices = { head, upper, tail, head, tail, lower, tail, finUp, finDown };
using var arrays = new Reckfell.Collections.Array();
arrays.Resize((int)Mesh.ArrayType.Max);
arrays[(int)Mesh.ArrayType.Vertex] = vertices;
var mesh = new ArrayMesh();
mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, arrays);
return mesh;
}
public static (int Columns, int Rows, Aabb Bounds) Layout(int count)
{
int columns = Mathf.CeilToInt(Mathf.Sqrt(count * 2));
int rows = (count + columns - 1) / columns;
float width = columns * 0.85f + 2;
float height = rows * 0.6f + 2;
return (columns, rows, new Aabb(new Vector3(-width / 2, -height / 2, -1),
new Vector3(width, height, 2)));
}
}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));
}
}ParticleSchool3D.cs
using Reckfell;
public partial class ParticleSchool3D : GpuParticles3D
{
public ShaderMaterial SwimMaterial { get; private set; } = null!;
public ShaderMaterial MotionMaterial { 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 } };
MotionMaterial = new ShaderMaterial { Shader = new Shader { Code = SchoolShaders.Motion } };
MotionMaterial.SetShaderParameter("columns", layout.Columns);
MotionMaterial.SetShaderParameter("rows", layout.Rows);
Amount = count;
Lifetime = 60;
Explosiveness = 1;
FixedFps = 30;
UseFixedSeed = true;
Seed = 42;
LocalCoords = true;
VisibilityAabb = layout.Bounds.Grow(0.5f);
DrawPass1 = SchoolMesh.Create();
MaterialOverride = SwimMaterial;
ProcessMaterial = MotionMaterial;
CastShadow = ShadowCastingSetting.Off;
Emitting = true;
Restart();
return true;
}
public void SetMotion(float clock, float amplitude)
{
MotionMaterial.SetShaderParameter("clock", clock);
SwimMaterial.SetShaderParameter("clock", clock);
SwimMaterial.SetShaderParameter("amplitude", Mathf.Clamp(amplitude, 0, 0.4f));
}
}SchoolShowcase.cs
using Reckfell;
public partial class SchoolShowcase : Entity3D
{
[Export] public bool ParticleMotion { get; set; }
[Export(PropertyHint.Range, "1,8192,1")] public int Count { get; set; } = 512;
[Export] public bool Animate { get; set; } = true;
public VertexSchool3D? Vertices { get; private set; }
public ParticleSchool3D? Particles { get; private set; }
public Camera3D Camera { get; private set; } = null!;
private float _clock;
public override void _Ready()
{
if (Count < 1 || Count > 8192)
{
ReckfellApi.PushError("SchoolShowcase.Count must be between 1 and 8192.");
return;
}
var layout = SchoolMesh.Layout(Count);
Camera = new Camera3D { Projection = Camera3D.ProjectionType.Orthogonal,
Position = new Vector3(0, 0, 20), Current = true };
AddChild(Camera);
Vector2 size = GetViewport().GetVisibleRect().Size;
float aspect = size.Y > 0 ? size.X / size.Y : 1;
Camera.Size = Mathf.Max(layout.Bounds.Size.Y, layout.Bounds.Size.X / aspect);
if (ParticleMotion)
{
Particles = new ParticleSchool3D();
AddChild(Particles);
Particles.TryBuild(Count);
}
else
{
Vertices = new VertexSchool3D();
AddChild(Vertices);
Vertices.TryBuild(Count);
}
SetMotion(0, 0.22f);
}
public override void _Process(double delta)
{
if (!Animate) return;
_clock += (float)delta;
SetMotion(_clock, 0.22f);
}
public void SetMotion(float clock, float amplitude)
{
Vertices?.SetMotion(clock, amplitude);
Particles?.SetMotion(clock, amplitude);
}
}Example limits
SchoolShaders.cs contains shader strings, so no external models, textures or shader files are required. The fish is an original flat three-triangle mesh. Recalculate scale, motion axis and bounds for other geometry.
The camera selects its orthographic size in _Ready using the current viewport. Recalculate size separately if the window aspect changes later. Animate=false allows reproducible frames through SetMotion.