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), mirrored to the GPU in the same bulk upload as transforms/colors. Layout: .x = D0.x (flash), .y = D1.x (dissolve), .zw = D0.zw (uv scroll). Missing → default (0,1,0,0).
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). When the group carries RenderCustom, TransformSync3DSystem ships the packed custom vec4 inside the same buffer and the stride becomes 20 (CustomStride = 4, monotonic latch per group) — no extra draw calls.
  • Grow-only capacity (doubling, never shrink; Unregister frees and recycles the id). Missing colors are filled with white. n = 0 sets VisibleInstanceCount = 0. When the mesh has a local AABB, the manager sets MultiMesh.CustomAabb per sync from the visible instances plus the mesh extent × max scale — exact culling bounds without a server recompute.
  • 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, and is always conservative: a partially visible entity is never culled. In parallel jobs (pure math on Spans plus enableable bits, never Godot API inside the job body) it tests the RenderFrustum singleton against per-entity world AABBs:

  • Each entity's box comes from the real mesh bounds of its group (MeshGroupManager captures Mesh.GetAabb at Register), transformed by the drawn pose — interpolated RenderTransform when present, else LocalTransform — so culling and drawing cannot disagree. Rotated extents use |basis| × halfExtents × scale, correct for any orientation.
  • Groups without valid mesh bounds fall back to a unit cube scaled by the entity's scale.
  • Chunk fully outside → all rows disabled (Culled off); chunk intersecting → per-entity world-AABB refinement. A chunk's AABB is the union of its entity boxes.
  • 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.
  • cull.ResetAll(em) re-enables every Culled bit — call it when you disable culling at runtime, or stale bits keep entities hidden.
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. RenderLod.Current is updated only for moves actually applied, so entities beyond the cap keep their real level and retry next evaluation — hysteresis never starts from a level the entity isn't on. 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/) ​

Four example shaders show how to consume per-instance data. The 3D ones expect a material with vertex-color-as-albedo on the group, with TransformSync writing the colors; the custom data (INSTANCE_CUSTOM) is shipped in the same bulk upload, so they work out of the box:

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).

Write RenderCustom from any gameplay system; TransformSync3DSystem compacts it per group (default (0,1,0,0) — no flash, fully visible, no scroll — where the component is missing, and groups that never use it stay at stride 16 with no GPU overhead).

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.2.0. All rights reserved until a license is added.