Architecture¶
SimVX is a Python game engine: node-based scene hierarchy, Vulkan GPU-driven rendering, and NumPy-based math. This page covers the key internal pipelines.
Node Lifecycle¶
Every node progresses through these stages:
construction --> enter_tree --> ready --> [update / fixed_update] --> exit_tree
Construction –
__init__()sets properties andPropertydefaults. No tree access yet. Anything a node must own before it can be attached (a collider a physics body is built from, say) is built here.Enter tree –
add_child()inserts the node. The whole incoming subtree is bound to theSceneTreefirst – tree references, groups and unique names – and only then doeson_enter_tree()fire on each of its nodes, top-down. A hook may therefore look up nodes the walk has not visited yet, by group, by unique name or by path.Ready –
on_ready()runs bottom-up (children first, then parent). Safe to access children by path:self["Camera"].Process –
on_update(dt)runs every frame with wall-clock delta.on_fixed_update(dt)runs at a fixed rate (default 60 Hz) with accumulator-based catch-up.Exit tree –
destroy()marks the node for removal and disconnects its outgoing signals; at the frame boundaryon_exit_tree()fires, exactly once per node, and the subtree is carried out of theSceneTreein one piece.destroy()is final: the node is spent afterwards, andadd_child()refuses to put it back in a tree.
Children added during on_ready() go through the full lifecycle before the parent’s on_ready() returns. This guarantees that self["Player/Camera"] is valid inside on_ready().
The two entry hooks¶
They are not the same shape, and picking the wrong one is the common lifecycle bug:
|
|
|
|---|---|---|
Fires |
on every entry into a tree |
once per node instance, ever |
Sees |
the whole incoming subtree, bound but not yet readied |
its children readied, and the tree around it |
Belongs there |
per-stay setup: claiming a slot on the tree, subscribing to a tree signal, starting something |
one-time setup: building the children a node owns, caching lookups, connecting signals |
A node that leaves the tree and comes back re-enters but does not ready again, so children spawned in on_ready() are built once rather than once per entry. The mirror rule follows: whatever on_exit_tree() undoes must be redone in on_enter_tree(), never in on_ready().
examples/features/basics/lifecycle.py shows both hooks firing on a node that is added and removed on a repeating timer.
Scene Tree¶
SceneTree owns the root node and drives the frame loop:
SceneTree.tick(dt) # walk tree, call on_update(dt) on every node
SceneTree.physics_tick(dt) # walk tree, call on_fixed_update(dt)
SceneTree.render(Draw2D) # 2D draw pass -- re-runs on_draw only for dirty nodes (retained)
Driver (SceneTree.tick / physics_tick / render) and handler (Node.on_update / on_fixed_update / on_draw) deliberately use different verbs so a misspelt override on a Node never silently shadows the engine’s per-frame loop.
Groups allow batch queries: tree.group("enemies") returns all nodes tagged with that group. The tree also manages deferred deletions – destroy() queues removal until the end of the current frame to avoid mutation during iteration.
Render Pipeline¶
SimVX uses a GPU-driven forward renderer. The per-frame work driven from
Python is bounded: scene state goes into flat NumPy arrays once, then a
single multi-draw-indirect call submits every visible instance. There are
no per-object vkCmdDraw loops in Python.
The public contract is: GPU-driven, one indirect submission for opaque
geometry, a separate sorted pass for transparent. Buffer sizes, descriptor
counts, culling strategy, and pass ordering are implementation details
that may change without notice: read the code in
packages/graphics/src/simvx/graphics/renderer/ if you need the current
specifics.
2D is retained. A node’s on_draw(renderer) is not re-run every frame:
the renderer caches the geometry it produced and re-runs on_draw only when
the node is marked dirty. A Property or transform write auto-dirties the
node; per-frame animation reading non-Property state opts in with
dynamic = True; a discrete non-Property change calls queue_redraw() at
the mutation site. See Your First 2D Game.
Input Flow¶
GLFW key/mouse event --> input_adapter --> Input singleton --> InputMap --> game code
GLFW callback –
key_callback_with_uireceives raw key events from GLFW.Input adapter – Translates GLFW key codes to
Key/MouseButtonenums, updates theInputsingleton’s pressed/released state, and routes events to the UI system.Input singleton – Stores per-frame state.
Input.is_key_pressed(Key.W)checks instantaneous state;Input.is_action_just_pressed("jump")checks action bindings.InputMap – Maps named actions to typed key bindings:
InputMap.add_action("jump", [Key.SPACE, JoyButton.A]). Actions are queried by name inon_update(dt).
UI widgets receive input first. If a focused widget consumes the event, it does not propagate to game nodes.
Performance Notes¶
Node counts: The tree walk is pure Python, so keep node counts under ~5,000 for 60 fps process ticks. Use groups and
find()to avoid deep recursive searches.Draw call batching: All opaque geometry is drawn in a single indirect draw call regardless of material count. Only transparent objects require sorting.
SSBO capacity: Default buffers support 10,000 instances and 256 lights. Exceeding these triggers a buffer resize (one-time stall).
Physics tick: Fixed at 60 Hz by default. Multiple physics steps per frame occur when the frame rate drops below 60 fps (capped at 100 ms accumulation to prevent spiral-of-death).
Headless mode:
App(visible=False)creates a real Vulkan surface with an invisible GLFW window. Rendering is identical to visible mode – useful for automated visual tests in CI.