Skip to content

2D rendering

Bulk sprite rendering for thousands of entities with zero per-entity nodes. Two paths cover the two cases: homogeneous sprites share one texture and go through AtlasGroupManager (one MultiMesh per group); heterogeneous sprites go through BatchCanvas (one CanvasItem per layer, direct RenderingServer draws).

Mental model

LocalTransform + SpriteRef(shared) + SpriteFrame + SpriteSize + RenderColor (+ YSort?)
   │  group by AtlasGroupId (shared component → archetype move, never in hot path)

per group: TransformSync2D.BuildTransforms / BuildRects (pure CPU math on Spans)

AnimSystem.Update (frames → atlas UV rects, pure math)

YSortSystem.Sort (optional, stable order by Layer)

AtlasGroupManager.SyncBulk2D (ONE call per group, main thread)
   → MultiMeshInstance2D in the scene draws it

SpriteRef is a shared component: changing an entity's group moves it to a different archetype. Do that on the main thread, outside hot loops — never inside a job.

Components

All in namespace GodotECS.Render2D (see API reference). Shared color/visibility types (RenderColor, RenderCustom, Culled, RenderFrustum) live in GodotECS.Render and are combined by name in 2D queries — they are not redefined.

ComponentKindMeaning
SpriteRef { int AtlasGroupId }sharedWhich atlas group (texture) the entity draws with.
SpriteFrame { int Index; float Time, Fps; int Count }dataAnimation cursor. Index is derived, never set AnimatedSprite2D per entity.
SpriteSize { Vector2 Size }dataWorld-space size; Transform2D scale derives from this.
YSort { int Layer }dataStable sort key consumed by YSortSystem, never z_index per entity.

Atlas groups (homogeneous path)

One group = one texture = one MultiMesh with TransformFormatEnum.Transform2D and UseColors = true.

csharp
using Godot;
using GodotECS.Render2D;

var mgr = new AtlasGroupManager();
int groupA = mgr.Register(myTexture);          // returns AtlasGroupId
// ... spawn entities, em.SetShared(e, new SpriteRef { AtlasGroupId = groupA });
// per frame (main thread, presentation):
TransformSync2D.Sync(mgr, groupA, transforms, modulates, uvs);

Key rules:

  • Register(Texture2D) builds a unit QuadMesh + MultiMesh (initial capacity 64, VisibleInstanceCount = 0). Headless-safe: MultiMesh is a CPU-side Resource, no tree dependency.
  • SyncBulk2D(groupId, transforms, modulates, uvs) requires all three spans to have equal length, grows capacity by doubling only (never per frame at steady state), sets VisibleInstanceCount = n, and writes one transform + one color per instance. Zero allocations in the loop.
  • Atlas UVs are validated for length but have no per-instance slot in the 2D MultiMesh: they are resolved in-shader or via the heterogeneous path (BatchCanvas.DrawLayerRegion). A true single-MultimeshSetBuffer bulk upload for 2D is still TODO — the hypothesized layout (stride 12: 8 floats row-major 3×2 + RGBA) is documented in code but not yet validated on a GPU run, so the per-instance loop is current behavior.
  • EnsureCapacity(groupId, count), Count(groupId), GetMultiMesh(groupId), TryGetTexture(groupId, out tex), Unregister(groupId) are available for setup/teardown and for attaching a MultiMeshInstance2D in the scene:
csharp
AddChild(new MultiMeshInstance2D { Multimesh = mgr.GetMultiMesh(groupA), Texture = myTexture });

BatchCanvas (heterogeneous path)

When sprites differ per entity (different textures/regions on the same layer), use BatchCanvas: one RenderingServer canvas item (Rid) per layer, bulk draws with canvas_item_add_texture_rect (+ _region for atlas frames). Main thread only (sync point). Headless no-op safe.

csharp
using GodotECS.Render2D;

var batch = new BatchCanvas();
batch.ClearLayer(7); // call Clear/ClearLayer at the start of the frame
batch.DrawLayer(7, tex, rects, modulates);                    // one texture per call
batch.DrawLayerRegion(7, tex, dstRects, srcUvs, modulates);   // atlas: UV 0..1 → px
  • DrawLayer accumulates multiple calls on the same layer; clear first.
  • DrawLayerRegion converts srcUvs (0..1, as written by AnimSystem) to pixels using tex.GetWidth()/GetHeight().
  • GetLayerItem(layer) lazily creates the canvas item; LayerCount reports live layers. Dispose() frees all Rids.

Animation without nodes

AnimSystem is pure math on Spans: no Godot API, zero allocations.

csharp
// Advance Time by dt, derive Index = floor(Time * Fps) % Count (wrapped,
// deterministic), write the atlas cell UV (columns × rows grid, row-major).
AnimSystem.Update(frames.AsSpan(), uvs.AsSpan(), dt, columns: 2, rows: 2);
Rect2 cell = AnimSystem.UvFor(index: 3, columns: 2, rows: 2);

Rules: Fps > 0 and Count > 1 and dt != 0 advance the cursor, otherwise the current (or 0 for single-frame) index is kept and the UV is still written. Count is clamped to columns × rows; negative Time is clamped to 0. columns/rows must be > 0.

Building transforms

TransformSync2D splits pure CPU math (Build*, bench-measured, only value types) from the single renderer touchpoint (Sync):

csharp
// positions/rotations/scales → Transform2D (rotation matrix × scale + origin)
TransformSync2D.BuildTransforms(positions, rotations, scales, dstTransforms);
// positions/sizes → centered Rect2 (for the BatchCanvas path)
TransformSync2D.BuildRects(positions, sizes, dstRects);
// single call into the group manager (never one call per entity)
TransformSync2D.Sync(mgr, groupA, transforms, modulates, uvs);

Source spans must be at least as long as the destination; otherwise ArgumentException.

Stable Y-sorting

YSortSystem permutes a dense order[] index array (init 0..n-1) into stable ascending Layer order via iterative bottom-up merge sort (<= keeps the left run first, so equal layers preserve spawn order). Deterministic O(n log n).

csharp
// Hot path: caller-provided scratch → zero allocations.
YSortSystem.Sort(order.AsSpan(), layers, scratch.AsSpan());
// Convenience (allocates scratch): tests/tooling only.
YSortSystem.Sort(order.AsSpan(), layers);
bool ok = YSortSystem.IsSorted(order, layers);

scratch.Length >= order.Length is required; every order[i] must index into layers.

Full frame example

csharp
public override void _Process(double delta)
{
    float dt = (float)delta;
    // 1. advance animation cursors (pure math)
    AnimSystem.Update(frameSpan, uvSpan, dt, columns: 2, rows: 2);
    // 2. build transforms (pure math)
    TransformSync2D.BuildTransforms(posSpan, rotSpan, scaleSpan, xfSpan);
    // 3. stable-sort the YSort rows before packing group A's buffer
    YSortSystem.Sort(orderSpan, layerSpan, scratchSpan);
    // 4. ONE sync call per atlas group, main thread
    TransformSync2D.Sync(_mgr, _groupA, xfSpan, colorSpan, uvSpan);
    // 5. heterogeneous extras on BatchCanvas
    _batch.ClearLayer(7);
    _batch.DrawLayerRegion(7, _tex, dstRects, uvSpan, colorSpan);
}

Performance notes

  • Capacity grows by doubling and never shrinks (Unregister frees); at steady state the sync path allocates nothing.
  • Group changes are archetype moves: batch them, defer them, never do them per frame per entity.
  • See Examples & benchmarks for the render2d_sync bench numbers and the SpriteSwarm2D demo (~2000 sprites, 2 atlas groups + BatchCanvas layer).

GodotECS docs — version 0.1.0. All rights reserved until a license is added.