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). D1 reserved (dissolve/health). Known v1 limit: MeshGroupManager does not mirror this into the MultiMesh buffer yet — the example shaders below show the intended consumption.
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 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 void PackInstance(Span<float> dst, in Transform3D t, in Color c);
public void SyncBulk(int groupId, ReadOnlySpan<Transform3D> transforms,
ReadOnlySpan<Color> colors);
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, matchingmultimesh.cppset_bufferin 4.x.UseBulkUpload—true: oneRenderingServer.MultimeshSetBufferper group (buffer sizedstride × InstanceCount; tail pastnstays stale but invisible viaVisibleInstanceCount = n).false: per-instanceSetInstanceTransform+SetInstanceColorfallback (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.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)— throws for unknown groups. GrowsT/Cmirrors by doubling whenn > Capacity; copies transforms; fills missing colors (fewer thann) with white;n = 0hides all instances. Lazily creates theMultiMesh(Transform3Dformat, colors, capacity ≥ 1) on the first non-empty sync when a mesh is registered; computesBounds(origins AABB) fused into the pack pass.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);
}Presentation, after interpolation. Chunk AABB (from LocalTransform, expanded by half the chunk max scale, NaN/negative-guarded) vs the RenderFrustum singleton; writes the Culled enable bit. Parallel over chunks unless Scheduler.ForceSequential or fewer than 2 chunks; static chunk partitioning (deterministic, chunks independent).
- Main-thread ensure pass adds
Culled(enabled) toLocalTransformentities lacking it before the snapshot (structural move outside jobs). - Fully-outside chunk → all rows disabled; intersecting chunk → per-entity point-test refinement.
Culledchange version stamped per chunk. - Throws
InvalidOperationException(wrapping the lookup failure) when theRenderFrustumsingleton was never set — set it once per frame viaworld.SetSingleton(...)from the camera. 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.
- 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. - 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; writesRenderLod.Currentin place, queuesSetShared(RenderMeshRef)moves for changed levels and plays them back after iteration (ECB cannot carry shared components), capped atMaxMovesPerFrame = 512,IsAlive-checked. MissingRenderCamera→ silent skip. ThrowsArgumentNullExceptionfor null args.
HeroFallback
public sealed class HeroFallback
{
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);
}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). 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.
Shaders
addons/godotecs/shaders/*.gdshader — spatial-shader examples consuming per-instance data (group material with vertex-color-as-albedo; colors written by the 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).