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

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 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-major Transform3D (Xx,Yx,Zx,Ox, Xy,Yy,Zy,Oy, Xz,Yz,Zz,Oz) + R,G,B,A, matching multimesh.cpp set_buffer in 4.x.
  • UseBulkUploadtrue: one RenderingServer.MultimeshSetBuffer per group (buffer sized stride × InstanceCount; tail past n stays stale but invisible via VisibleInstanceCount = n). false: per-instance SetInstanceTransform + SetInstanceColor 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.
  • 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) — throws for unknown groups. 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, capacity ≥ 1) on the first non-empty sync when a mesh is registered; computes Bounds (origins AABB) fused into the pack pass.
  • 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);
}

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) to LocalTransform entities lacking it before the snapshot (structural move outside jobs).
  • Fully-outside chunk → all rows disabled; intersecting chunk → per-entity point-test refinement. Culled change version stamped per chunk.
  • 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) 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.

  • 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.
  • 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; writes RenderLod.Current in place, queues SetShared(RenderMeshRef) moves for changed levels and plays them back after iteration (ECB cannot carry shared components), capped at MaxMovesPerFrame = 512, IsAlive-checked. Missing RenderCamera → silent skip. Throws ArgumentNullException for null args.

HeroFallback

csharp
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) — throws ArgumentNullException for null mesh, InvalidOperationException past MaxInstances = 512 (message points at MeshGroupManager for 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 throw ArgumentOutOfRangeException for 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.gdshaderCOLOR.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).

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