reckfellENGINE

Many objects: MultiMesh or GPU particles

A complete project example with two GPU-animation approaches and explicit scope.

On this page

Choose an approach

TaskApproach
Many copies of one geometry with specified positionsMultiMesh
GPU motion for many visual instancesGpuParticles3D with a process shader
Individual behavior, interaction and a physics bodySeparate 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.

Actual rendering of 2,048 instances

SchoolShaders.cs
C#
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
C#
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
C#
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
C#
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
C#
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.

Vertex animation · Particle motion · Find the bottleneck

Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.