Skip to content

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 ​

csharp
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 ​

csharp
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 ​

csharp
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 ​

csharp
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 ​

csharp
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 ​

csharp
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 ​

csharp
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 the Camera3D); throws ArgumentNullException for 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 ​

csharp
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-major Transform3D (Xx,Yx,Zx,Ox, Xy,Yy,Zy,Oy, Xz,Yz,Zz,Oz) + R,G,B,A. When a group has custom data (see RenderCustom), the stride becomes 16 + CustomStride = 20 and the extra vec4 rides as INSTANCE_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 without RenderCustom (dissolve-safe).
  • UseBulkUpload — true: one RenderingServer.MultimeshSetBuffer per group (buffer sized stride × InstanceCount; tail past n stays stale but invisible via VisibleInstanceCount = n). false: per-instance SetInstanceTransform + SetInstanceColor (+ SetInstanceCustomData) fallback (debug/comparison).
  • RegisteredGroups — count of live groups. IsAlive(groupId) — bounds + live check.
  • Register(mesh, mat, shadows = true) — throws ArgumentNullException for null mesh (null material allowed). shadows is stored per group; applying it to the scene instance (GeometryInstance3D.CastShadow) stays with bootstrap — the MultiMesh has no shadow flag of its own. Register also captures the mesh's local AABB (Mesh.GetAabb) for culling and for the CustomAabb.
  • Unregister(groupId) — throws ArgumentOutOfRangeException for unknown ids; releases the MultiMesh reference (RefCounted, no FreeRid), 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. Throws ArgumentException if dst is short. Verified against hand-computed values in tests.
  • SyncBulk(groupId, transforms, colors, customData = default) — throws for unknown groups; customData must be empty or exactly transforms.Length. Grows T/C mirrors by doubling when n > Capacity; copies transforms; fills missing colors (fewer than n) with white; n = 0 hides all instances. Lazily creates the MultiMesh (Transform3D format, colors, custom enabled only when custom data arrived) on the first non-empty sync when a mesh is registered; computes Bounds (origins AABB) fused into the pack pass. When the mesh has a local AABB, the MultiMesh.CustomAabb is 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 by CullingSystem). Dead groups or meshes without an AABB carry the InvalidAabb sentinel (negative Size) so consumers fall back safely.
  • Count / Capacity / Bounds / GetMultiMesh(groupId) — introspection; all throw ArgumentOutOfRangeException for unknown groups (GetMultiMesh returns null before the first non-empty sync).

CullingSystem ​

csharp
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 (Culled off); intersecting chunk → per-entity world-AABB refinement. Culled change version stamped per chunk.
  • Main-thread ensure pass adds Culled (enabled) to LocalTransform entities lacking it before the snapshot (structural move outside jobs).
  • Throws InvalidOperationException (wrapping the lookup failure) when the RenderFrustum singleton was never set — set it once per frame via world.SetSingleton(...) from the camera.
  • Update(em, world) delegates to the group manager that TransformSync3DSystem.Manager holds (same source as the draws); the 3-argument overload lets tests/benches pass another manager.
  • ResetAll(em) — re-enables the Culled bit 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) throws ArgumentNullException for null args and is directly usable in tests without a SystemGroup.

TransformSync3DSystem ​

csharp
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, else LocalTransform.ToTransform3D().
  • Skips rows with Culled disabled (compaction: no wasted instances) and groups unregistered mid-frame (their entities stay orphaned, no crash).
  • Missing RenderColor defaults to white; missing RenderCustom to (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.
  • Manager is the game-wide default used by OnUpdate; tests/benches pass their own manager to Sync. Throws ArgumentNullException for null args.

LodSystem ​

csharp
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) — throws ArgumentOutOfRangeException for negative groups or unless 0 < 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, current clamps to 0..2. From 0, steps to 2 past d2·(1+h), to 1 past d1·(1+h); from 1, down below d1·(1−h), up past d2·(1+h); from 2, down below d1·(1−h) → 0, below d2·(1−h) → 1.
  • OnUpdate evaluates once every 4 presentation frames; Update(em, world) always evaluates (deterministic, tests). Queries LocalTransform + RenderMeshRef + RenderLod; queues SetShared(RenderMeshRef) moves for changed levels and plays them back after iteration (ECB cannot carry shared components), capped at MaxMovesPerFrame = 512, IsAlive-checked. RenderLod.Current is written only for moves actually applied (aligned during playback): entities beyond the cap keep their real Current and retry at the next evaluation, so the hysteresis always starts from the truly active level and Current never diverges from the group. Missing RenderCamera → silent skip. Throws ArgumentNullException for null args.

HeroFallback ​

csharp
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) — throws ArgumentNullException for null mesh, InvalidOperationException past MaxInstances = 512 (message points at MeshGroupManager for masses), ObjectDisposedException after Dispose. 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 throw ArgumentOutOfRangeException for unknown handles.
  • Unregister(handle) — removes, recycles the handle, FreeRids the instance. LiveCount / IsAlive(handle) introspect.
  • Dispose() — idempotent; frees every still-live RenderingServer instance (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 by INSTANCE_CUSTOM.y (D1.x = visible fraction 1→0, e.g. from health/death): local_h = VERTEX.y varying, discard above mix(-1, 1, visible). cull_disabled.
  • instance_uvscroll.gdshader — per-instance UV offset from INSTANCE_CUSTOM.zw in the vertex shader (UV += ...; trails, ribbons, water without duplicating the material). Drive D0.zw from 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).

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