Skip to content

Scene Graph & Nodes

Everything in a game is a node in a tree, as in Godot. Behaviour comes from node types (C# subclasses); scenes are reusable subtrees saved as data (Scene serialization). The engine owns a SceneTree and runs the frame for every node inside it: lifecycle callbacks, fixed-step physics process, process, deferred calls and frees, transform sync, input routing, groups and signals. Nodes are thin front-ends to engine servers (rendering and audio now; physics and UI later): the node holds editable state, the server holds GPU/physics/audio objects.

Names follow Godot (Node3D, Camera3D, OmniLight3D, WorldEnvironment, QueueFree, NodePath, …) with two exceptions recorded in the Godot-names ADR: the root is SceneViewport (not Viewport, which the renderer uses unqualified for Silk.NET.Vulkan.Viewport) and lifecycle methods use the engine’s On* prefix.

classDiagram
    class Node {
        +string Name
        +NodeId Id
        +Node? Parent
        +IReadOnlyList~Node~ Children
        +Node? Owner
        +SceneTree? Tree
        +ProcessMode ProcessMode
        +int ProcessPriority
        +AddChild(Node) / RemoveChild / MoveChild / Reparent
        +GetNode~T~(NodePath) / FindChild / GetPath / GetPathTo
        +AddToGroup(string, bool persistent)
        +Connect(signal, target, method, flags)
        +QueueFree() / Free() / CallDeferred(Action)
        #OnEnterTree() / OnReady() / OnExitTree()
        #OnProcess(in GameTime) / OnPhysicsProcess(float)
        #OnInput(InputEvent) / OnUnhandledInput(InputEvent)
    }
    class Node3D {
        +Vector3 Position
        +Quaternion Rotation
        +Vector3 RotationDegrees
        +Vector3 Scale
        +bool Visible
        +Transform3D Transform / GlobalTransform
        +Matrix4x4 ModelMatrix
        +LookAt(Vector3 target, Vector3? up)
    }
    class Node2D {
        +Vector2 Position
        +float Rotation
        +Vector2 Scale
        +int ZIndex
        +Transform2D GlobalTransform
    }
    class SceneViewport { +World3D World3D +World2D World2D +Camera3D? ActiveCamera3D }
    class VisualInstance3D { +bool CastShadows +int RenderPriority +Draw() +DrawShadow2D() +DrawShadowPoint() }
    Node <|-- Node3D
    Node <|-- Node2D
    Node <|-- SceneViewport
    Node <|-- WorldEnvironment
    Node <|-- Timer
    Node <|-- NetworkNode
    Node <|-- MissingNode
    Node3D <|-- VisualInstance3D
    Node3D <|-- Camera3D
    Node3D <|-- Light3D
    Light3D <|-- DirectionalLight3D
    Light3D <|-- OmniLight3D
    Light3D <|-- SpotLight3D
    VisualInstance3D <|-- GeometryInstance3D
    GeometryInstance3D <|-- MeshInstance3D
    GeometryInstance3D <|-- Sprite3D
    SceneViewport <|-- SubViewport
    VisualInstance3D <|-- SpineNode
    VisualInstance3D <|-- Grid3D
    Node2D <|-- Camera2D
TypeFileNotes
NodeScene/Node.cs (+ .Processing, .Groups, .Signals)tree, names, paths, lifecycle, groups, signals, freeing
SceneTree, SceneTreeTimerScene/SceneTree.csframe loop, process lists, groups, deferred queue, NodeId registry
SceneViewport, World3D, World2DScene/root viewport, render scenario
Node3D, Node2D, Transform3D, Basis, Transform2D, EulerAnglesScene/cached, dirty-flagged transforms
NodePath, NodeId, ProcessMode, Timer, MissingNodeScene/
InputEvent*, InputRouterScene/Input/SDL/Silk input → tree
VisualInstance3D, Camera3D, Light3D (+3), WorldEnvironment, Sky, Grid3DScene/Nodes3D/render front-ends
Camera2DScene/Nodes2D/
IServer, ServerRegistry, RenderServerServers/
GeometryInstance3D, MeshInstance3D, Sprite3DScene/Nodes3D/GeometryInstance3D.csbatched mesh drawables — see Materials & meshes
SubViewportScene/SubViewport.csoffscreen view with its own world
SpineNode, NetworkNodeNodes/existing drawables, retargeted onto VisualInstance3D
  • Children are ordered (Children, GetChild, GetIndex, MoveChild, AddSibling). AddChild rejects a node that already has a parent and cycles; Reparent(newParent, keepGlobalTransform: true) moves a subtree keeping its world transform.
  • Names are unique among siblings. An empty name becomes the type name; a collision appends a number (Box → Box2, Box2 → Box3). / : @ % " are replaced with _. Up to 8 children are searched linearly; above that a name index is kept (span-keyed, so lookups do not allocate).
  • Owner is the scene root a node was saved with; it must be an ancestor and is cleared when a removal breaks that. The scene saver writes only nodes owned by the root, so runtime-spawned nodes are never saved. UniqueNameInOwner makes a node reachable as %Name from nodes sharing its owner.
  • NodePath: relative ("Camera", "../Player/Camera", ".") or absolute ("/root/Main"), with %Name segments. GetNode<T> throws on a missing node or wrong type, GetNodeOrNull<T> does not. Resolution walks spans and does not allocate. FindChild(pattern) / FindChildren<T> match */? wildcards (owned nodes only by default, as in Godot). GetPath() / GetPathTo(node) build paths.
  • Freeing: Free() removes the node (exiting the tree), frees its children, disconnects its named signal connections and calls Dispose(bool) for resource release; Dispose() is Free() so nodes work with using. QueueFree() defers the free to the end of the frame (immediate outside a tree). Node.IsInstanceValid(node) is Godot’s is_instance_valid.
  • Identity: every node gets a runtime-unique NodeId (never reused). SceneTree.Find(NodeId) looks it up through a dictionary maintained on enter/exit. NodeIds are per process, so replication uses its own network ids (Node.NetworkId, assigned by the server, see Networking). Scene files reference nodes by path, never by id.
sequenceDiagram
    autonumber
    participant P as parent.AddChild(child)
    participant C as child subtree
    participant S as Servers
    P->>C: Parent, Tree, viewport, resolved ProcessMode set
    C->>C: OnEnterTree() — top-down#59; TreeEntered#59; parent's ChildEnteredTree
    C->>S: visuals/lights/cameras register with their World3D / RenderServer
    C->>C: OnReady() — bottom-up, once per node#59; Ready
    loop every frame while inside the tree
        C->>C: OnPhysicsProcess(fixedDt) × N fixed steps
        C->>C: OnProcess(gameTime)
    end
    P->>C: RemoveChild / QueueFree
    C->>C: OnExitTree() — bottom-up#59; TreeExiting#59; parent's ChildExitingTree
    C->>S: unregister
    C->>C: TreeExited (after removal)
  • OnReady runs once per node (RequestReady() re-arms it) after all its children are ready — the place for GetNode<T>(). Children added inside OnEnterTree/OnReady enter immediately.
  • Callbacks are protected virtual. Process callbacks are enabled exactly when a type overrides them (found once per type, cached); SetProcess, SetPhysicsProcess, SetProcessInput and SetProcessUnhandledInput toggle them.
  • ProcessMode: Inherit (the root is Pausable), Pausable, WhenPaused, Always, Disabled. SceneTree.Paused stops pausable nodes (process, physics, input) and the fixed-step servers.
  • ProcessPriority: lower runs first; equal priorities run in tree order. Physics process uses the same order.
  • Built-in signals ([Signal] events): TreeEntered, Ready, TreeExiting, TreeExited, Renamed, ChildEnteredTree, ChildExitingTree; plus Timer.Timeout.
  • Locale changes (M9): Tr.SetLocale runs OnLocaleChanged() on every node in every live tree, parents first, then SceneTree.LocaleChanged — at the end of the frame when it happened during Tick, at the next Tick when it came from another thread. AutoTranslateMode (exported, inherited) decides whether Atr/AtrN translate. See Localization.
StepWhat runs
1. Physicsaccumulator += min(dt, MaxFrameDelta = 0.25 s); up to MaxPhysicsStepsPerFrame = 5 steps of 1 / PhysicsTicksPerSecond (60 Hz): PhysicsFrame event, OnPhysicsProcess, physics-callback timers, IFixedStepServer.FixedStep (not while paused). A backlog beyond the cap is dropped. Fixed-step servers get BeforeFixedSteps() before the first step of a frame and AfterFixedSteps(PhysicsInterpolationFraction) after the loop (both skipped while paused): the physics servers restore physics poses and write interpolated render poses there (Physics).
2. ProcessProcessFrame event, OnProcess(in GameTime), idle timers (SceneTreeTimer).
3. DeferredCallDeferred callbacks in order (including ones queued while flushing), then QueueFree frees; repeated until both queues are empty.
4. Transform syncOnTransformChanged for 2D/3D nodes that asked (SetNotifyTransform) and whose global transform changed — lights push their direction/position here.
5. Frame serversIFrameServer.Process (Steam callbacks, the AudioServer; UI later).

Iteration is over sorted, reused snapshots, so callbacks may add, remove, free or reorder nodes: removed or queued nodes are skipped, added ones start next frame. The lists re-sort lazily (only after the tree, membership or a priority changed). A steady-state tick allocates nothing (unit-tested over 10 000 nodes; see Testing).

Engine owns one tree (Engine.Tree, Engine.Root) and ticks it after the legacy OnUpdate hook; see Engine lifecycle. Tests and tools create their own SceneTree.

Node3D stores local position, rotation (a normalized quaternion) and scale. RotationDegrees is Euler degrees applied about X, then Y, then Z (EulerAngles; the order the engine always used, so old scenes keep their look); the value set is returned unchanged until Rotation is set directly, so incrementing it is stable. Decomposed angles snap within 0.0005° of a whole degree so scene files stay readable.

  • Transform (local) and GlobalTransform are Transform3D (a Basis of column vectors + origin). ModelMatrix is the cached row-vector world matrix (Scale × Rotation × Translation) the drawables use.
  • Setting any transform property marks the node’s local transform dirty and its Node3D subtree’s global transforms dirty, stopping at subtrees that are already dirty (a node is only cleaned after its ancestors, so a dirty node’s subtree is dirty). Globals are recomputed lazily on read.
  • Transforms chain only through Node3D parents: a Node3D under a plain Node is a new transform root (Godot semantics). Visible / IsVisibleInTree() follow the same chain.
  • LookAt(target, up) points -Z at a world position, keeping position and scale; GlobalPosition, GlobalTransform setters compute the local transform; TranslateObjectLocal, RotateObjectLocal.
  • SetNotifyTransform(true) queues OnTransformChanged() for the end of the frame whenever the global transform changed (once per frame, also on entering the tree).
  • Engine-internal hooks (private protected): TrackGlobalTransformChanges → OnGlobalTransformInvalidated() runs synchronously when the global transform becomes dirty (own or ancestor change), and TrackLocalTransformChanges → OnLocalTransformChanged() on every local set. Physics bodies and collision shapes use them to push moves to their server without per-step scans.

Node2D mirrors this in 2D (Transform2D, rotation in radians, RotationDegrees serialized, ZIndex), plus Godot’s Skew (SkewDegrees serialized); a transform assigned to Transform is kept exactly and only decomposed for the getters. SceneTree.Tick gives process callbacks the frame’s delta minus the physics time it dropped (the MaxFrameDelta clamp and the backlog beyond MaxPhysicsStepsPerFrame), as Godot does, so a stall reads as the steps physics ran (ADR 0114).

  • AddToGroup(name, persistent) / RemoveFromGroup / IsInGroup; persistent groups are saved with the scene. The tree tracks members while they are inside it: GetNodesInGroup (tree order, live list), GetFirstNodeInGroup, CallGroup(name, action) and the allocation-free CallGroup<TState>(name, state, static action), which iterate a snapshot.
  • CallDeferred(Action) and the allocation-free CallDeferred(Action<object?>, state) (on nodes inside a tree, or on SceneTree) run at step 3.
  • SceneTree.CreateTimer(seconds) returns a one-shot SceneTreeTimer (Timeout event); the Timer node (WaitTime, OneShot, Autostart, ProcessCallback) is the saveable kind.
  • SceneTree.ChangeScene(Node | PackedScene) / ChangeSceneToFile(pathOrUid) replace CurrentScene. Outside a tick and outside input dispatch (start-up, tools) the swap is immediate. During a tick or a PushInput (UI clicks run there, outside the tick) it waits for Godot’s scene change flush: after the frame’s process callbacks and deferred calls, before timers (0137). So a click handler never frees or builds a scene inside RmlUi’s dispatch, and the new scene first processes a frame later. The last change of a frame wins; an earlier pending scene is freed without entering the tree. Shutdown() frees everything.

Node.CreateTween() (bound: pauses with the node, dies when it is freed) or SceneTree.CreateTween() returns a Tween, a port of Godot 4.7’s: TweenProperty(target, "modulate:a", value, seconds) (Godot property paths; snake_case → the C# property, :x/:y/:z/:w, :r/:g/:b/:a components), TweenInterval, TweenCallback, TweenMethod; SetParallel/Parallel/Chain, SetLoops, SetSpeedScale, SetTrans/SetEase (Godot’s easing equations, default Linear / InOut), From, AsRelative, SetDelay, Finished. The tree steps tweens after timers (process or physics, SetProcessMode), leftover time carried into the next step as Godot does.

Signals are plain C# events marked [Signal], so code connects with += as usual. The source generator records each one (name, delegate type, parameter types, add/remove accessors) so the editor’s Signals panel and scene files can connect by name:

area.Connect("BodyEntered", player, nameof(Player.OnHit), ConnectFlags.Persist);
  • The target method must be public or marked [SignalHandler], and its parameters must accept the signal’s. Binding uses reflection once, at connect time; emitting is a normal delegate call.
  • ConnectFlags.Persist connections are saved with the scene and re-bound on instantiation (their OriginScene is the scene root that created them, so a sub-scene’s connections are not re-saved by the scenes that instance it). Deferred runs the handler at the end of the frame, OneShot disconnects after the first emission (both through a generated forwarder; void signals up to 8 parameters).
  • Named connections are removed automatically when either node is freed. Disconnect, IsConnected, GetSignalConnections, GetIncomingConnections.

SceneTree.EditMode (set by the editor, M10) runs OnProcess, OnPhysicsProcess, OnInput and OnUnhandledInput only for nodes whose type is marked [Tool] (Node.IsTool, not inherited by subclasses), and does not step the fixed-step servers (physics). Lifecycle callbacks, deferred calls, frees, transform sync and frame servers run as usual, so edited scenes render, register with their worlds and can be picked, but game code does not run. Tools render a viewport with their own camera through SceneViewport.CameraOverride (any ICamera; it never touches the scene’s cameras) and draw always-visible handles into SceneViewport.OverlayLines (debug lines without depth test). See Editor.

InputRouter (owned by Engine) turns Silk.NET/SDL keyboard, mouse and gamepad callbacks into InputEventKey, InputEventText, InputEventMouseButton, InputEventMouseMotion, InputEventMouseWheel, InputEventGamepadButton and InputEventGamepadAxis and calls SceneTree.PushInput: OnInput in reverse tree order (children before parents), then OnUnhandledInput, stopping once a node calls GetViewport().SetInputAsHandled(). Nodes that cannot process (pause) are skipped. One event instance per type is reused (allocation-free): Clone() an event to keep it. Before any node, PushInput offers the event to every IInputServer (M8: the UiServer); an event the game UI consumed stops there and is reported handled — see Game UI → input routing. Nodes with InputBeforeUi = true get OnInput first, before the UI (Godot’s _input placement, for toggles such as a menu on Tab that a focused button would otherwise take; ADR 0138). The dev overlay is an RmlUi layer, so it is covered by the same server: a click on its panel never reaches nodes.

Servers replace the old static Node.Initialize: Engine registers them in OnLoad (after the renderer) into SceneTree.Servers (ServerRegistry), and nodes reach them in OnEnterTree:

ServerInterfaceStatus
RenderServerIServerM2: facade over the renderer and one shared ShadowSystem
SteamServerIFrameServerM2: registered when EngineOptions.SteamAppId ≠ 0; pumps Steam.RunCallbacks
MultiplayerApiIFrameServerM5: always registered (Engine.Multiplayer), idle until started; receives at SceneTree.ProcessFrame, sends in Process (Networking)
AudioServerIFrameServerM7: registered when EngineOptions.Audio.Enabled (default); voices, buses, listener; see Audio
PhysicsServer3D, PhysicsServer2DIFixedStepServer, IFrameServerM6: one Jitter2 / Box2D space per world; stepped after OnPhysicsProcess, frozen while paused; debug draw per frame (Physics)
UI (M8)IFrameServerticked after transform sync

ServerRegistry holds one server per type (Register, Get<T>, GetRequired<T>, Render) and disposes them in reverse order after the tree is freed.

RenderServer draws a viewport’s World3D with its active camera (SceneViewport.ActiveCamera3D, else ActiveCamera2D; aspect from the swapchain extent):

  • PrepareFrame(root) (M3, before BeginFrame) — culls and sorts the world’s GeometryInstance3Ds (and every SubViewport’s), creates/updates their GPU meshes, materials and textures, completes finished picks.
  • RenderShadows(viewport) — every shadow-casting, visible visual into every light’s map (ShadowSystem.RenderShadows<TState> with static lambdas): batched mesh casters as instanced draws, other visuals through DrawShadow2D/DrawShadowPoint.
  • RenderOffscreen(root) (M3) — SubViewports and object-ID picking passes, before the main pass.
  • RenderPrepass(root) (ADR 0163) — the main view’s depth prepass and motion vectors, and the post effects that run on them (SSAO), when an enabled effect needs them or ForceDepthPrepass is set (Post-processing); DebugView shows the motion vectors.
  • RenderMain(viewport) — writes the frame’s shared set 0 (IVulkanContext.Frame.Begin(camera, World3D.Lights): camera + lights, once per frame), then the WorldEnvironment’s sky, visuals with a negative RenderPriority, the opaque mesh batches, the other visuals by RenderPriority then tree-entry order, and the transparent mesh batches back to front, all into the HDR scene target (Color pipeline).
  • PickAsync(x, y) / RequestPick + TryGetPickResult — GPU picking (Materials & meshes); MeshStats, PipelineStates, ResidentResources — diagnostics.
  • ShadowsEnabled = false runs without a ShadowSystem (lit pipelines bind the fallback set).
  • It tracks every GPU resource owner it created, so nodes removed but never freed are still released at shutdown.

Render node front-ends:

NodeWrapsNotes
VisualInstance3D—registers with World3D while in a tree; creates GPU objects through the server on first enter (or lazily at the first draw); releases them on Free or server shutdown; CastShadows, RenderPriority
MeshInstance3D, Sprite3D (GeometryInstance3D)MeshRendererMesh, MaterialOverride; never drawn one by one: culled, sorted and batched into instanced draws (M3, Materials & meshes)
MultiMeshInstance3D (GeometryInstance3D)MeshRenderer, MultiMeshGpuMultimesh: every instance of a MultiMesh as one render item, from a persistent instance buffer (ADR 0151, MultiMesh)
SubViewportits own World3D, offscreen targetsrendered before the main pass; picking; shown in the UI as an engine:// image
SpineNodeSpineRendererFolder, Animation (played on load), SpineScale; skeleton data loads on the CPU on first use
Grid3DSceneGrid3ddebug/editor grid: no shadows, RenderPriority -100
Camera3DPerspectiveCameraCurrent, Fov, Near, Far; looks along -Z; RenderCamera is the synced math camera
Camera2DOrthographicCameraCurrent, Zoom, Distance
DirectionalLight3D, OmniLight3D, SpotLight3DDirectionalLight, PointLight, SpotLightColor, Energy, Range, cone angles; direction = global -Z; registered in World3D.Lights
WorldEnvironmentSkyEnvironmentSky resource (procedural / panoramic / cubemap + colors), AmbientColor

The pre-M2 math cameras are now PerspectiveCamera / OrthographicCamera (ICamera), still usable without a tree.

Visibility ranges (ADR 0151, Godot’s HLOD distances): every GeometryInstance3D has VisibilityRangeBegin and VisibilityRangeEnd (metres, 0 = unbounded). The render server measures the distance from the view’s camera to the centre of the instance’s world AABB (for a MultiMeshInstance3D, the AABB of all its instances) and draws the instance only while it is in [begin, end): outside, it is skipped in that view’s colour and ID passes and, for the main view, in every shadow pass. The test is GeometryInstance3D.IsInVisibilityRange. Godot’s margins (hysteresis) and fade modes are not implemented; a near/far pair of nodes (a detailed mesh ending where its impostor begins) is the intended use.

  • Node constructors must be cheap and side-effect free (the type registry creates a pristine instance of every serialized type; scenes are instantiated outside the tree). Acquire GPU/native objects in OnEnterTree or through a server.
  • Nodes are main-thread only while inside a tree.
  • Draw methods run inside the server’s passes (main render pass / shadow callbacks); tree-less code may call them by hand inside the matching pass.
  • SubViewports render 3D only, without shadows unless Shadows is set and the main world draws nothing (the editor). 2D canvas items draw through the canvas renderer (2D canvas); Camera2D still renders 3D visuals orthographically and does not yet move the canvas (E9).
  • Euler angles use X→Y→Z order (engine legacy), not Godot’s Y→X→Z.
  • Re-sorting a process list after a structural change is O(n log n); spawning every frame in a very large tree pays it each frame.

Scene serialization · Engine lifecycle · Spine · Lighting · Cameras & input · Shadow system · Materials & meshes · Editor