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 itSpriteRef 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.
| Component | Kind | Meaning |
|---|---|---|
SpriteRef { int AtlasGroupId } | shared | Which atlas group (texture) the entity draws with. |
SpriteFrame { int Index; float Time, Fps; int Count } | data | Animation cursor. Index is derived, never set AnimatedSprite2D per entity. |
SpriteSize { Vector2 Size } | data | World-space size; Transform2D scale derives from this. |
YSort { int Layer } | data | Stable 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.
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 unitQuadMesh+MultiMesh(initial capacity 64,VisibleInstanceCount = 0). Headless-safe:MultiMeshis a CPU-sideResource, 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), setsVisibleInstanceCount = 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-MultimeshSetBufferbulk 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 aMultiMeshInstance2Din the scene:
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.
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 → pxDrawLayeraccumulates multiple calls on the same layer; clear first.DrawLayerRegionconvertssrcUvs(0..1, as written byAnimSystem) to pixels usingtex.GetWidth()/GetHeight().GetLayerItem(layer)lazily creates the canvas item;LayerCountreports live layers.Dispose()frees allRids.
Animation without nodes
AnimSystem is pure math on Spans: no Godot API, zero allocations.
// 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):
// 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).
// 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
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 (
Unregisterfrees); 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_syncbench numbers and theSpriteSwarm2Ddemo (~2000 sprites, 2 atlas groups +BatchCanvaslayer).