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 instancesNamespace 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.
| Component | Kind | Meaning |
|---|---|---|
RenderMeshRef { int GroupId } | shared | Mesh group. Change = archetype move: main thread, deferred (via LodSystem), never in a job. |
RenderColor { Color Value } | data | Per-instance color (MultiMesh use-colors). RenderColor.White helper. Written in jobs, read at the sync point. |
RenderCustom { Vector4 D0, D1 } | data | Per-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. |
Culled | data + enableable | Frustum-culling flag. Toggling the enable bit skips entities without an archetype move. |
RenderLod { int Current } | data | Current LOD level (0 near … 2 far). .Clamped clamps to 0..2. |
RenderCamera { Vector3 Pos } | singleton | This frame's camera position, written once per frame from the camera (main thread), read by LodSystem. Missing → LodSystem silently skips. |
RenderFrustum | singleton | This 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).
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 singleRenderingServer.MultimeshSetBufferwith strideBulkStride = 16: row-majorTransform3D(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. SetUseBulkUpload = falsefor the per-instance fallback (SetInstanceTransform+SetInstanceColor, debug/comparison only). - Grow-only capacity (doubling, never shrink;
Unregisterfrees and recycles the id). Missing colors are filled with white.n = 0setsVisibleInstanceCount = 0. - The
MultiMeshis created lazily on the first non-empty sync; attach it to the scene after the first sync (groups that start empty have noMultiMeshyet — check fornull):
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. AssignGeometryInstance3D.CastShadowon the scene instance from bootstrap — theMultiMeshitself 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 (
Culledoff). - Chunk intersecting → per-entity refinement with a degenerate AABB (point test).
- Entities lacking a
Culledcolumn get it added in a main-thread ensure pass before the snapshot (structural add, enabled by default). - Change versions on the
Culledcolumn are stamped per chunk. - Missing
RenderFrustumsingleton → throws with a message telling you toworld.SetSingleton(...)once per frame from the camera.
world.SetSingleton(RenderFrustum.FromCamera(camera)); // every frame, main thread
cull.Update(world.Entities, world); // or via SystemGroupDeterminism: 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).
LodSystem.RegisterChain(lod0: gBoxHi, lod1: gBoxMid, lod2: gBoxLow, d1: 30f, d2: 60f);
// Pure, testable:
int want = LodSystem.SelectLod(dist, d1: 30f, d2: 60f, current);RegisterChainrequires0 < d1 < d2and non-negative groups; every group in the chain resolves to the same chain (TryGetChain,ClearChainsfor teardown/tests).OnUpdateevaluates once every 4 presentation frames;Update(em, world)always evaluates (deterministic, used by tests). MissingRenderCamera→ 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 atMaxMovesPerFrame = 512per frame against spikes. Entities needLocalTransform + RenderMeshRef + RenderLodto participate.
The final sync
TransformSync3DSystem is the last presentation system and the only place that writes to the renderer. Main thread.
- Prefers interpolated
RenderTransformwhen the archetype has it, else falls back toLocalTransform(ts[r].ToTransform3D()). - Compacts one scratch buffer per group, skipping rows with
Culleddisabled (no wasted instances), defaults missingRenderColorto white, callsSyncBulkonce 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).
// 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 defaultHeroes (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.
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:
| Shader | Reads | Effect |
|---|---|---|
instance_color.gdshader | COLOR.rgb, INSTANCE_CUSTOM.x | Albedo from instance color; emission = color × clamped D0.x (damage flash, selection highlight). |
instance_dissolve.gdshader | INSTANCE_CUSTOM.y | Vertical dissolve: discards fragments above mix(-1, 1, D1.x) in local space. Drive D1.x 1→0 from health/death. cull_disabled. |
instance_uvscroll.gdshader | INSTANCE_CUSTOM.zw | Per-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_uploadnumbers, the honest dummy-driver caveat (bulk loses to per-instance headless, wins on a real GPU), and theBulletHell3D/HybridDemowalkthroughs.