Render 3D API
Namespace GodotECS.Render (not Render3D — see note in RenderComponents3D.cs; alias with using in consumers if needed, never duplicate the structs: duplicates register different TypeIndex values and break queries). Source: addons/godotecs/src/render3d/. Shaders: addons/godotecs/shaders/.
RenderMeshRef
public struct RenderMeshRef : ISharedComponent
{
public int GroupId;
}Shared mesh-group id, as returned by MeshGroupManager.Register. Change = archetype move: main thread, deferred (via LodSystem), never inside a job.
RenderColor
public struct RenderColor : IComponentData
{
public Color Value;
public static RenderColor White => new RenderColor { Value = Colors.White };
}Per-instance color (MultiMesh use-colors). Written in jobs, read at the sync point. Missing column defaults to white in TransformSync3DSystem.
RenderCustom
public struct RenderCustom : IComponentData
{
public Vector4 D0;
public Vector4 D1;
}Per-instance shader data (INSTANCE_CUSTOM). TransformSync3DSystem compacts it per chunk and ships it inside the same bulk upload as transforms/colors — one MultimeshSetBuffer per group per frame, no per-instance writes. GPU layout (MeshGroupManager.PackCustom): .x = D0.x (emissive/flash), .y = D1.x (dissolve 0..1), .zw = D0.zw (UV scroll); D0.y and D1.yzw do not transit (reserved). Entities without the component get the default (0, 1, 0, 0) — no emissive, dissolve fully visible, no scroll. A group's buffer switches from stride 16 to 20 floats (UseCustomData = true, monotonic latch) the first time custom data is synced and stays there.
Culled
public struct Culled : IComponentData, IEnableableComponent
{
public byte Unused;
}Frustum-culling flag with a dual interface (data + enableable, same pattern as core QFlag tests): addable via AddComponent<T>/ECB, switchable via SetEnable without an archetype move. CullingSystem auto-adds it (enabled) to LocalTransform entities that lack it, then toggles the enable bit. Unused carries no data.
RenderLod
public struct RenderLod : IComponentData
{
public int Current;
public int Clamped => Current < 0 ? 0 : (Current > 2 ? 2 : Current);
}Current LOD level (0 near/detail … 2 far), maintained by LodSystem with hysteresis. Clamped normalizes out-of-range values to 0..2.
RenderCamera
public struct RenderCamera : ISingleton
{
public Vector3 Pos;
}This frame's camera position. Written once per frame from the camera (main thread), read by LodSystem. Absent → LodSystem silently skips.
RenderFrustum
public struct RenderFrustum : ISingleton
{
public GodotECS.Math.Frustum Value;
public static RenderFrustum FromMath(in GodotECS.Math.Frustum f) => ...;
public static RenderFrustum FromCamera(Camera3D cam);
public static RenderFrustum FromBox(in Vector3 min, in Vector3 max);
public bool Intersects(in GodotECS.Math.Aabb box) => Value.Intersects(box);
}This frame's frustum singleton. Written once per frame (main thread), read by culling jobs as pure math.
FromMath(in f)— wrap an existing math frustum.FromCamera(cam)— main thread only (reads theCamera3D); throwsArgumentNullExceptionfor null.FromBox(in min, in max)— axis-aligned box with inward normals, for deterministic tests and custom ortho cameras.Intersects(in box)— forwards to the math frustum.
MeshGroupManager
public sealed class MeshGroupManager
{
public const int BulkStride = 16;
public const int CustomStride = 4;
public static readonly Vector4 DefaultCustom = new Vector4(0f, 1f, 0f, 0f);
public static readonly Aabb InvalidAabb = ...; // sentinel: Size < 0
public bool UseBulkUpload { get; set; } = true;
public int RegisteredGroups { get; }
public bool IsAlive(int groupId) => ...;
public int Register(Mesh mesh, Material mat, bool shadows = true);
public void Unregister(int groupId);
public bool GetShadows(int groupId);
public static Vector4 PackCustom(in Vector4 d0, in Vector4 d1); // → (d0.x, d1.x, d0.z, d0.w)
public static void PackInstance(Span<float> dst, in Transform3D t, in Color c);
public void SyncBulk(int groupId, ReadOnlySpan<Transform3D> transforms,
ReadOnlySpan<Color> colors, ReadOnlySpan<Vector4> customData = default);
public ReadOnlySpan<Aabb> GetLocalAabbs();
public int Count(int groupId);
public int Capacity(int groupId);
public Aabb Bounds(int groupId);
public MultiMesh GetMultiMesh(int groupId);
}One group = one (Mesh, Material) pair = one MultiMesh with colors. Main thread only. Capacity doubles, never shrinks (Unregister frees and recycles the id via a free stack). Headless-safe: the MultiMesh is a CPU-side Resource (no Scenario/instances — those are HeroFallback).
BulkStride = 16— floats per instance in the bulk buffer: row-majorTransform3D(Xx,Yx,Zx,Ox, Xy,Yy,Zy,Oy, Xz,Yz,Zz,Oz) +R,G,B,A. When a group has custom data (seeRenderCustom), the stride becomes16 + CustomStride = 20and the extravec4rides asINSTANCE_CUSTOM.PackCustom(in d0, in d1)is the pure layout function (.x = D0.x,.y = D1.x,.zw = D0.zw);DefaultCustom = (0,1,0,0)is used for entities withoutRenderCustom(dissolve-safe).UseBulkUpload—true: oneRenderingServer.MultimeshSetBufferper group (buffer sizedstride × InstanceCount; tail pastnstays stale but invisible viaVisibleInstanceCount = n).false: per-instanceSetInstanceTransform+SetInstanceColor(+SetInstanceCustomData) fallback (debug/comparison).RegisteredGroups— count of live groups.IsAlive(groupId)— bounds + live check.Register(mesh, mat, shadows = true)— throwsArgumentNullExceptionfor null mesh (null material allowed).shadowsis stored per group; applying it to the scene instance (GeometryInstance3D.CastShadow) stays with bootstrap — theMultiMeshhas no shadow flag of its own.Registeralso captures the mesh's local AABB (Mesh.GetAabb) for culling and for theCustomAabb.Unregister(groupId)— throwsArgumentOutOfRangeExceptionfor unknown ids; releases theMultiMeshreference (RefCounted, noFreeRid), clears buffers/bounds, recycles the id.GetShadows(groupId)— stored flag; throws for unknown groups.PackInstance(dst, in t, in c)— pure and testable; packs one instance into ≥16 floats. ThrowsArgumentExceptionifdstis short. Verified against hand-computed values in tests.SyncBulk(groupId, transforms, colors, customData = default)— throws for unknown groups;customDatamust be empty or exactlytransforms.Length. GrowsT/Cmirrors by doubling whenn > Capacity; copies transforms; fills missing colors (fewer thann) with white;n = 0hides all instances. Lazily creates theMultiMesh(Transform3Dformat, colors, custom enabled only when custom data arrived) on the first non-empty sync when a mesh is registered; computesBounds(origins AABB) fused into the pack pass. When the mesh has a local AABB, theMultiMesh.CustomAabbis set per sync from the visible instances' bounds grown by the mesh extent × max basis column — exact culling bounds without server recompute in the hot path.GetLocalAabbs()— main-thread snapshot of the mesh-local AABB per group id (consumed byCullingSystem). Dead groups or meshes without an AABB carry theInvalidAabbsentinel (negativeSize) so consumers fall back safely.Count / Capacity / Bounds / GetMultiMesh(groupId)— introspection; all throwArgumentOutOfRangeExceptionfor unknown groups (GetMultiMeshreturnsnullbefore the first non-empty sync).
CullingSystem
public struct CullingSystem : ISystem
{
public void OnCreate(ref SystemState state);
public void OnUpdate(ref SystemState state) => Update(state.Entities, state.World);
public void OnDestroy(ref SystemState state) { }
public void Update(EntityManager em, World world); // uses TransformSync3DSystem.Manager
public void Update(EntityManager em, World world, MeshGroupManager groups);
public void ResetAll(EntityManager em);
}Presentation, after interpolation. Conservative culling with real mesh bounds: each entity's world AABB comes from the group's mesh-local AABB (snapshot of MeshGroupManager.GetLocalAabbs() taken on the main thread) transformed by the drawn pose — RenderTransform when the archetype has it, else LocalTransform — so culling and drawing can never diverge. The world AABB uses the rotated-basis extents (|basis| × halfExtents × scale), which covers any orientation; a chunk's AABB is the union of its entity boxes. Without a valid mesh group, the per-entity fallback is a unit cube scaled by the entity's scale. A partially-visible entity is never turned off (no center-only point test). Both phase tests run in parallel jobs (pure math + enableable bits, never Godot API inside the job body) against the RenderFrustum singleton.
- Fully-outside chunk → all rows disabled (
Culledoff); intersecting chunk → per-entity world-AABB refinement.Culledchange version stamped per chunk. - Main-thread ensure pass adds
Culled(enabled) toLocalTransformentities lacking it before the snapshot (structural move outside jobs). - Throws
InvalidOperationException(wrapping the lookup failure) when theRenderFrustumsingleton was never set — set it once per frame viaworld.SetSingleton(...)from the camera. Update(em, world)delegates to the group manager thatTransformSync3DSystem.Managerholds (same source as the draws); the 3-argument overload lets tests/benches pass another manager.ResetAll(em)— re-enables theCulledbit on every entity. Call it when culling is disabled at runtime, otherwise stale bits from the last culled frame keep hiding entities in the next sync.Update(em, world)throwsArgumentNullExceptionfor null args and is directly usable in tests without aSystemGroup.
TransformSync3DSystem
public struct TransformSync3DSystem : ISystem
{
public static MeshGroupManager Manager { get; set; } = new MeshGroupManager();
public void OnCreate(ref SystemState state);
public void OnUpdate(ref SystemState state) => Sync(state.Entities, Manager);
public void OnDestroy(ref SystemState state) { }
public void Sync(EntityManager em, MeshGroupManager mgr);
}Last presentation system, main thread, sole renderer writer. Compacts one reused scratch buffer per group (doubling, 64 initial) and calls SyncBulk once per touched group — never one call per entity. RenderCustom is compacted alongside transforms and colors and passed to SyncBulk only when some chunk of that group actually had the column, so groups without custom shaders stay at stride 16; rows without RenderCustom get MeshGroupManager.DefaultCustom.
- Prefers interpolated
RenderTransform(Basis(Rot).Scaled(Scale),Pos) when the archetype has it, elseLocalTransform.ToTransform3D(). - Skips rows with
Culleddisabled (compaction: no wasted instances) and groups unregistered mid-frame (their entities stay orphaned, no crash). - Missing
RenderColordefaults to white; missingRenderCustomto(0, 1, 0, 0). - Zeroes groups that emptied since the last sync (destroyed/moved entities) so no ghost instances linger; tracks them in a reused active list.
Manageris the game-wide default used byOnUpdate; tests/benches pass their own manager toSync. ThrowsArgumentNullExceptionfor null args.
LodSystem
public struct LodSystem : ISystem
{
public const float Hysteresis = 0.05f;
public const int MaxMovesPerFrame = 512;
public struct LodChain
{
public int Lod0;
public int Lod1;
public int Lod2;
public float D1;
public float D2;
}
public static void RegisterChain(int lod0, int lod1, int lod2, float d1, float d2);
public static void ClearChains();
public static bool TryGetChain(int groupId, out LodChain chain);
public static int SelectLod(float dist, float d1, float d2, int current);
public void OnCreate(ref SystemState state);
public void OnUpdate(ref SystemState state);
public void OnDestroy(ref SystemState state) { }
public void Update(EntityManager em, World world);
}Presentation. Camera distance → LOD 0/1/2 with 5% hysteresis (no threshold thrashing), deferred group switches on the main thread.
RegisterChain(lod0, lod1, lod2, d1, d2)— throwsArgumentOutOfRangeExceptionfor negative groups or unless0 < d1 < d2. Every group in the chain resolves to the same chain. Main-thread setup; lock-free reads at steady state.ClearChains()— teardown/tests.TryGetChain(groupId, out chain)— chain lookup.SelectLod(dist, d1, d2, current)— pure: negative distances clamp to 0,currentclamps to 0..2. From 0, steps to 2 pastd2·(1+h), to 1 pastd1·(1+h); from 1, down belowd1·(1−h), up pastd2·(1+h); from 2, down belowd1·(1−h)→ 0, belowd2·(1−h)→ 1.OnUpdateevaluates once every 4 presentation frames;Update(em, world)always evaluates (deterministic, tests). QueriesLocalTransform + RenderMeshRef + RenderLod; queuesSetShared(RenderMeshRef)moves for changed levels and plays them back after iteration (ECB cannot carry shared components), capped atMaxMovesPerFrame = 512,IsAlive-checked.RenderLod.Currentis written only for moves actually applied (aligned during playback): entities beyond the cap keep their realCurrentand retry at the next evaluation, so the hysteresis always starts from the truly active level andCurrentnever diverges from the group. MissingRenderCamera→ silent skip. ThrowsArgumentNullExceptionfor null args.
HeroFallback
public sealed class HeroFallback : System.IDisposable
{
public const int MaxInstances = 512;
public int LiveCount => ...;
public void SetScenario(Rid scenario) => ...;
public bool IsAlive(int handle) => ...;
public int Register(Mesh mesh);
public void Sync(int handle, in Transform3D t) => ...;
public void SetVisible(int handle, bool visible) => ...;
public void Unregister(int handle);
public void Dispose();
}A few hundred unique objects (skinned/animated/skeletons) via direct RenderingServer instances (InstanceCreate + InstanceSetBase). Never for masses. Main thread only; CPU-side and headless-safe with the dummy renderer.
Register(mesh)— throwsArgumentNullExceptionfor null mesh,InvalidOperationExceptionpastMaxInstances = 512(message points atMeshGroupManagerfor masses),ObjectDisposedExceptionafterDispose. Recycles handles from a free stack.SetScenario(scenario)— assign from bootstrap (GetWorld3D().Scenario); without it, instances exist but stay invisible.Sync(handle, in t)→InstanceSetTransform;SetVisible(handle, visible)→InstanceSetVisible; both throwArgumentOutOfRangeExceptionfor unknown handles.Unregister(handle)— removes, recycles the handle,FreeRids the instance.LiveCount/IsAlive(handle)introspect.Dispose()— idempotent; frees every still-liveRenderingServerinstance (no leaks when the holder is torn down). Any use after dispose throws.
Shaders
addons/godotecs/shaders/*.gdshader — example shaders consuming per-instance data (group material with vertex-color-as-albedo; colors and INSTANCE_CUSTOM are both written by the bulk sync):
instance_color.gdshader—COLOR.rgb→ albedo; emission = color ×clamp(INSTANCE_CUSTOM.x, 0, 4)(D0.x: damage flash, selection highlight).diffuse_lambert, specular_disabled.instance_dissolve.gdshader— vertical dissolve driven byINSTANCE_CUSTOM.y(D1.x= visible fraction 1→0, e.g. from health/death):local_h = VERTEX.yvarying,discardabovemix(-1, 1, visible).cull_disabled.instance_uvscroll.gdshader— per-instance UV offset fromINSTANCE_CUSTOM.zwin the vertex shader (UV += ...; trails, ribbons, water without duplicating the material). DriveD0.zwfrom a system (e.g. time × direction).
All three work out of the box on a bulk group: TransformSync3DSystem ships RenderColor and RenderCustom inside the same per-group upload (stride 20 when the group carries custom data, default (0,1,0,0) when the component is absent).