reckfellENGINE

MultiMesh: buffers, bounds and update cost

Row-packed 3D transforms, 20 floats per instance and grouped-rendering limits.

On this page

Run a bulk upload

Add GeometryStage3D.cs and MultiMeshFieldLab3D.cs, attach MultiMeshFieldLab3D to a 1.0.0 C# Entity3D-root level and enable UseBulkUpload. Use a 640×420 viewport. Build and press F6: the field should match individual SetInstance calls, with the same 64 cube transforms and colors.

The same MultiMesh field from individual and bulk uploads

GeometryStage3D.cs
C#
using Reckfell;

public static class GeometryStage3D
{
    public static StandardMaterial3D Material(bool vertexColors = false, bool unshaded = false)
    {
        return new StandardMaterial3D
        {
            AlbedoColor = vertexColors ? Colors.White : new Color(0.24f, 0.44f, 1),
            VertexColorUseAsAlbedo = vertexColors,
            ShadingMode = unshaded ? BaseMaterial3D.ShadingModeEnum.Unshaded : BaseMaterial3D.ShadingModeEnum.PerPixel,
            Roughness = 0.8f
        };
    }

    public static void Setup(Entity3D root, string ru, string en)
    {
        root.AddChild(new WorldEnvironment { Environment = new Reckfell.Environment
        {
            BackgroundMode = Reckfell.Environment.BGMode.Color,
            BackgroundColor = new Color(0.035f, 0.04f, 0.05f),
            AmbientLightSource = Reckfell.Environment.AmbientSource.Color,
            AmbientLightColor = Colors.White, AmbientLightEnergy = 0.5f
        }});
        root.AddChild(new DirectionalLight3D { RotationDegrees = new Vector3(-55, -25, 0), LightEnergy = 1.2f });
        root.AddChild(new MeshInstance3D
        {
            Mesh = new BoxMesh { Size = new Vector3(8, 0.05f, 6) }, Position = new Vector3(0, -0.12f, 0),
            MaterialOverride = new StandardMaterial3D { AlbedoColor = new Color(0.10f, 0.11f, 0.13f), Roughness = 1 }
        });
        var camera = new Camera3D { Position = new Vector3(4, 4.8f, 6), Current = true, Fov = 45 };
        root.AddChild(camera); camera.LookAt(new Vector3(0, 0.35f, 0), Vector3.Up);
        var canvas = new CanvasLayer(); root.AddChild(canvas);
        var label = new Label { Position = new Vector2(24, 20), Text = TranslationServer.GetLocale().StartsWith("ru") ? ru : en };
        label.AddThemeFontSizeOverride("font_size", 22); canvas.AddChild(label);
    }
}
MultiMeshFieldLab3D.cs
C#
using System;
using Reckfell;

public partial class MultiMeshFieldLab3D : Entity3D
{
    [Export] public int Count { get; set; } = 64;
    [Export] public bool UseBulkUpload { get; set; }
    public MultiMesh Field { get; private set; } = null!;
    private MultiMeshInstance3D _visual = null!;

    public static Transform3D TransformAt(int index)
    {
        float height = 0.35f + (index % 5) * 0.12f;
        var basis = Basis.Identity.Scaled(new Vector3(0.32f, height, 0.32f));
        return new Transform3D(basis, new Vector3((index % 8 - 3.5f) * 0.5f, height / 2, (index / 8 - 3.5f) * 0.5f));
    }

    public static Color ColorAt(int index) => index % 4 == 0 ? Colors.White : new Color(0.24f, 0.44f, 1);
    public static Color DataAt(int index) => new(index / 255.0f, 0, 0, 1);

    public static float[] BuildBuffer(int count)
    {
        if (count < 1 || count > 256) throw new ArgumentOutOfRangeException(nameof(count));
        float[] buffer = new float[count * 20];
        for (int i = 0; i < count; i++)
        {
            Transform3D t = TransformAt(i); Color color = ColorAt(i), data = DataAt(i); int p = i * 20;
            buffer[p] = t.Basis.Row0.X; buffer[p + 1] = t.Basis.Row0.Y; buffer[p + 2] = t.Basis.Row0.Z; buffer[p + 3] = t.Origin.X;
            buffer[p + 4] = t.Basis.Row1.X; buffer[p + 5] = t.Basis.Row1.Y; buffer[p + 6] = t.Basis.Row1.Z; buffer[p + 7] = t.Origin.Y;
            buffer[p + 8] = t.Basis.Row2.X; buffer[p + 9] = t.Basis.Row2.Y; buffer[p + 10] = t.Basis.Row2.Z; buffer[p + 11] = t.Origin.Z;
            buffer[p + 12] = color.R; buffer[p + 13] = color.G; buffer[p + 14] = color.B; buffer[p + 15] = color.A;
            buffer[p + 16] = data.R; buffer[p + 17] = data.G; buffer[p + 18] = data.B; buffer[p + 19] = data.A;
        }
        return buffer;
    }

    public Error Rebuild(int count, bool bulk)
    {
        if (_visual is null) return Error.Unconfigured;
        if (count < 1 || count > 256) return Error.InvalidParameter;
        _visual.Multimesh = null;
        Field?.Dispose();
        Field = new MultiMesh
        {
            TransformFormat = MultiMesh.TransformFormatEnum.Transform3D,
            UseColors = true, UseCustomData = true,
            Mesh = new BoxMesh { Size = Vector3.One, Material = GeometryStage3D.Material(vertexColors: true) },
            InstanceCount = count
        };
        if (bulk) Field.Buffer = BuildBuffer(count);
        else for (int i = 0; i < count; i++)
        {
            Field.SetInstanceTransform(i, TransformAt(i));
            Field.SetInstanceColor(i, ColorAt(i));
            Field.SetInstanceCustomData(i, DataAt(i));
        }
        int rows = (count + 7) / 8;
        Field.CustomAabb = new Aabb(new Vector3(-2, -0.05f, -2), new Vector3(4, 1.5f, Math.Max(4, rows * 0.5f)));
        Field.VisibleInstanceCount = -1;
        _visual.Multimesh = Field;
        Count = count; UseBulkUpload = bulk;
        return Error.Ok;
    }

    public override void _Ready()
    {
        GeometryStage3D.Setup(this, "MultiMesh: общая геометрия, свои данные", "MultiMesh: shared geometry, per-instance data");
        _visual = new MultiMeshInstance3D(); AddChild(_visual);
        if (Rebuild(Count, UseBulkUpload) != Error.Ok) ReckfellApi.PrintErr("MultiMesh count must be 1..256");
    }
}

Per-instance layout

The current 3D transform uses 12 floats. UseColors and UseCustomData each add four, producing stride = 20 here. Buffer length is InstanceCount × stride. The engine rejects a mismatched size rather than converting it automatically.

Element offsetData
0..3Basis.Row0.X/Y/Z, Origin.X
4..7Basis.Row1.X/Y/Z, Origin.Y
8..11Basis.Row2.X/Y/Z, Origin.Z
12..15Color R/G/B/A, when UseColors
16..19Custom R/G/B/A, when UseCustomData

These are rows, not three consecutively written Basis columns. A 2D transform occupies eight floats; do not reuse that layout for this 3D buffer. The fixture reads transforms/color/custom data back and verifies matching visible output after both upload paths.

What bulk upload changes

BuildBuffer prepares CPU data, then Buffer submits the entire array. This reduces individual API calls but still allocates and transfers data to the renderer. Buffer reads may synchronize/read back data; do not use the getter as free per-frame game storage. Retain owned CPU data when needed continuously.

The example's 1–256 limit is a fixture choice, not API capacity. It does not measure bulk speedup. Profile equal object counts, frames and target builds; include preparation/transfer and GPU cost, not just a setter call.

Culling and active elements

Culling uses the shared MultiMesh bounds. A huge field may render many invisible elements when its overall AABB intersects the camera. Partition the world into appropriate groups and measure. Materials, surfaces and passes, including shadows, affect draw calls; “always one draw call” is incorrect.

VisibleInstanceCount displays a contiguous prefix rather than an arbitrary live-object mask. Keep active data densely packed or maintain several groups. This does not replace Entity gameplay identity, physics or level streaming.

Checks cover buffer size/layout, pre-allocation format, individual/bulk data, CustomAabb, capacity and visibility prefix. Actual rendering also checks bulk equivalence and disappearance at VisibleInstanceCount = 0. Other renderers, motion-vector/interpolation modes and performance remain outside this fixture.

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.