Skip to content

3D rendering

One MultiMesh per (mesh, material) group, fed by a single bulk upload per frame. Jobs do pure math; only the last presentation system touches the renderer, on the main thread.

Mental model

LocalTransform (+ RenderTransform from interpolation) + RenderMeshRef(shared) + RenderColor
   │  group by GroupId (shared component → archetype move, main thread only)

InterpolateSystem ──→ RenderTransform (see fixed-timestep docs)

CullingSystem (parallel jobs, pure math) ──→ Culled enableable bit

LodSystem (main thread, throttled) ──→ maybe SetShared(RenderMeshRef) to another LOD group

TransformSync3DSystem (main thread, LAST) ──→ ONE MeshGroupManager.SyncBulk per group
   → MultiMeshInstance3D nodes in the scene draw it
Heroes (unique/skinned): HeroFallback, direct RenderingServer instances

Namespace note: the 3D API lives in GodotECS.Render (not Render3D). It also owns the types shared with 2D (RenderColor, RenderCustom, Culled, RenderFrustum). Never duplicate these structs — each duplicate would register a different TypeIndex and break queries.

Components

See API reference for exact signatures.

ComponentKindMeaning
RenderMeshRef { int GroupId }sharedMesh group. Change = archetype move: main thread, deferred (via LodSystem), never in a job.
RenderColor { Color Value }dataPer-instance color (MultiMesh use-colors). RenderColor.White helper. Written in jobs, read at the sync point.
RenderCustom { Vector4 D0, D1 }dataPer-instance shader data (INSTANCE_CUSTOM). D1 reserved (dissolve/health). Known v1 limit: not yet mirrored into the MultiMesh buffer — shaders read it once wired, see below.
Culleddata + enableableFrustum-culling flag. Toggling the enable bit skips entities without an archetype move.
RenderLod { int Current }dataCurrent LOD level (0 near … 2 far). .Clamped clamps to 0..2.
RenderCamera { Vector3 Pos }singletonThis frame's camera position, written once per frame from the camera (main thread), read by LodSystem. Missing → LodSystem silently skips.
RenderFrustumsingletonThis frame's frustum, written once per frame (main thread), read by culling jobs as pure math. Build via FromCamera(cam), FromBox(min, max) (tests/ortho), or FromMath(f).

Groups and bulk upload

MeshGroupManager: one group = one (Mesh, Material) pair = one MultiMesh with colors. Main thread only. Headless-safe (the MultiMesh is a CPU-side Resource; no Scenario/instances here — those belong to HeroFallback).

csharp
using GodotECS.Render;

var mgr = new MeshGroupManager();
int gBox = mgr.Register(new BoxMesh { Size = new Vector3(1f, 1f, 1f) }, mat, shadows: true);
em.SetShared(e, new RenderMeshRef { GroupId = gBox });

// Per frame, per group (done for you by TransformSync3DSystem):
mgr.SyncBulk(gBox, transformSpan, colorSpan);
  • One native call per group. With UseBulkUpload = true (default), the whole group goes through a single RenderingServer.MultimeshSetBuffer with stride BulkStride = 16: row-major Transform3D (12 floats: Xx,Yx,Zx,Ox, Xy,Yy,Zy,Oy, Xz,Yz,Zz,Oz) + R,G,B,A. PackInstance(dst, in t, in c) packs one instance and is unit-tested against hand-computed values. Set UseBulkUpload = false for the per-instance fallback (SetInstanceTransform + SetInstanceColor, debug/comparison only).
  • Grow-only capacity (doubling, never shrink; Unregister frees and recycles the id). Missing colors are filled with white. n = 0 sets VisibleInstanceCount = 0.
  • The MultiMesh is created lazily on the first non-empty sync; attach it to the scene after the first sync (groups that start empty have no MultiMesh yet — check for null):
csharp
mgr.SyncBulk(g, transforms, colors); // or TransformSync3DSystem.Sync(em, mgr)
MultiMesh mm = mgr.GetMultiMesh(g);
if (mm != null)
    AddChild(new MultiMeshInstance3D { Multimesh = mm, MaterialOverride = mat });
  • Count(g), Capacity(g), Bounds(g) (AABB over origins, computed during the pack), IsAlive(g), RegisteredGroups, GetShadows(g) cover introspection. Assign GeometryInstance3D.CastShadow on the scene instance from bootstrap — the MultiMesh itself has no shadow flag.

Frustum culling

CullingSystem runs in Presentation, after interpolation. Per chunk it computes an AABB from LocalTransform positions (expanded by half the chunk's max scale, NaN/negative-guarded) in parallel jobs — pure math on Spans plus enableable bits, never Godot API inside the job body — and tests it against the RenderFrustum singleton:

  • Chunk fully outside → all rows disabled (Culled off).
  • Chunk intersecting → per-entity refinement with a degenerate AABB (point test).
  • Entities lacking a Culled column get it added in a main-thread ensure pass before the snapshot (structural add, enabled by default).
  • Change versions on the Culled column are stamped per chunk.
  • Missing RenderFrustum singleton → throws with a message telling you to world.SetSingleton(...) once per frame from the camera.
csharp
world.SetSingleton(RenderFrustum.FromCamera(camera)); // every frame, main thread
cull.Update(world.Entities, world);                   // or via SystemGroup

Determinism: static partition over chunks; chunks are independent. For deterministic tests use RenderFrustum.FromBox(min, max).

Distance LOD

LodSystem maps camera distance to LOD 0/1/2 with 5% hysteresis so entities on a threshold don't thrash: it steps up past d·(1+h) and steps down below d·(1−h) (Hysteresis = 0.05).

csharp
LodSystem.RegisterChain(lod0: gBoxHi, lod1: gBoxMid, lod2: gBoxLow, d1: 30f, d2: 60f);
// Pure, testable:
int want = LodSystem.SelectLod(dist, d1: 30f, d2: 60f, current);
  • RegisterChain requires 0 < d1 < d2 and non-negative groups; every group in the chain resolves to the same chain (TryGetChain, ClearChains for teardown/tests).
  • OnUpdate evaluates once every 4 presentation frames; Update(em, world) always evaluates (deterministic, used by tests). Missing RenderCamera → silent skip.
  • Group switches are applied after iteration on the main thread via SetShared (deferred like ECB playback — ECB can't carry shared components), capped at MaxMovesPerFrame = 512 per frame against spikes. Entities need LocalTransform + RenderMeshRef + RenderLod to participate.

The final sync

TransformSync3DSystem is the last presentation system and the only place that writes to the renderer. Main thread.

  • Prefers interpolated RenderTransform when the archetype has it, else falls back to LocalTransform (ts[r].ToTransform3D()).
  • Compacts one scratch buffer per group, skipping rows with Culled disabled (no wasted instances), defaults missing RenderColor to white, calls SyncBulk once per touched group.
  • Tracks previously active groups and zeroes ones that emptied (all entities destroyed or moved to another group/LOD) so no ghost instances linger with a stale VisibleInstanceCount.
  • Groups unregistered mid-frame are skipped (their entities stay orphaned to the group, no crash).
csharp
// Wiring (BulletHell3D pattern):
world.SetSingleton(new RenderCamera { Pos = cam.Position });
world.SetSingleton(RenderFrustum.FromCamera(cam));
_cull.Update(world.Entities, world);
_lod.Update(world.Entities, world);
_sync.Sync(world.Entities, mgr);   // TransformSync3DSystem.Manager is the game default

Heroes (unique objects)

HeroFallback covers the few hundred unique objects (skinned/animated/ skeletons) with direct RenderingServer instances (InstanceCreate + InstanceSetBase). Never for masses — those go in MeshGroup bulk.

csharp
var hero = new HeroFallback();
hero.SetScenario(GetWorld3D().Scenario); // without a scenario, instances exist but stay invisible
int h = hero.Register(uniqueMesh);       // throws past MaxInstances (512)
hero.Sync(h, transform);
hero.SetVisible(h, false);
hero.Unregister(h);

Handles are recycled; LiveCount / IsAlive(handle) introspect. Main thread only; CPU-side and safe headless with the dummy renderer.

Shaders (addons/godotecs/shaders/)

Three spatial-shader examples show how to consume per-instance data. They expect a material with vertex-color-as-albedo on the group, with TransformSync writing the colors:

ShaderReadsEffect
instance_color.gdshaderCOLOR.rgb, INSTANCE_CUSTOM.xAlbedo from instance color; emission = color × clamped D0.x (damage flash, selection highlight).
instance_dissolve.gdshaderINSTANCE_CUSTOM.yVertical dissolve: discards fragments above mix(-1, 1, D1.x) in local space. Drive D1.x 1→0 from health/death. cull_disabled.
instance_uvscroll.gdshaderINSTANCE_CUSTOM.zwPer-instance UV offset in the vertex shader (trails, ribbons, water) without duplicating the material. Drive D0.zw from a system (e.g. time × direction).

Reminder: MeshGroupManager does not mirror RenderCustom into the buffer yet (known v1 limit), so treat INSTANCE_CUSTOM effects as wired-shader examples to enable once the mirror lands.

Performance notes

  • Per-frame native calls scale with groups (< 20 typical), not entities.
  • LOD evaluation is throttled (¼ rate) and group moves are capped (512/frame).
  • Culling jobs touch only math + enable bits; renderer writes happen once per group at the end.
  • See Examples & benchmarks for render3d_sync / render3d_upload numbers, the honest dummy-driver caveat (bulk loses to per-instance headless, wins on a real GPU), and the BulletHell3D / HybridDemo walkthroughs.

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