Architecture
simple-3d is a strict layered wrapper. Every design decision flows from a single rule: engine internals must never appear in the public API surface.
Layer Overview
Design Rules
Rule 1 — Public headers contain no Urho3D types
Files under include/Simple3D/ must never #include any Urho3D engine
class header. This is enforced by a header-only compile test in the CMake build that compiles
each public header without Urho3D on the include path.
The only exception is Core/Types.h, which imports Urho3D math types as
Simple3D:: aliases. These types are ABI-identical — they compile to the same
struct layout with the same vtable (none) and the same sizeof — so no conversion overhead
occurs when passing them across the API boundary.
Rule 2 — Engine types are hidden via PIMPL
Every class with a backing Urho3D object declares struct Impl; in the public
header and stores it via std::unique_ptr<Impl>. The Impl
struct is defined only in the corresponding .cpp file, which is free to include
any Urho3D header.
Rule 3 — Internal headers live in src/
Bridge headers in src/Simple3D/Internal/ allow different .cpp files
to share internal conversions (e.g., GetNode(Entity*)) without exposing them to
game code. These files are never installed and never included from
include/.
Rule 4 — Game subclass = game entry point
Users subclass Simple3D::Game and override three methods.
CreateGame<T>() handles all engine initialisation before calling
Start(). Game code never touches Urho3D::Context or
Urho3D::Application.
PIMPL Isolation — Detailed Example
Here is the full pattern as used for Entity:
// ── include/Simple3D/Entity/Entity.h ──────────────────────── #pragma once #include <Simple3D/Core/Types.h> // only math; no Urho3D engine headers #include <string> #include <functional> #include <vector> #include <memory> namespace Simple3D { class AnimationController; // forward-declared — still no engine types class Entity { public: void SetPosition(float x, float y, float z); void AddModel(const std::string& path); void AddRigidBody(float mass); // ... more public methods ... private: struct Impl; // forward declaration only std::unique_ptr<Impl> impl; // owns the Urho3D Node // Allow internal bridge to access impl directly friend class EntityBridge; }; } // namespace Simple3D
// ── src/Simple3D/Entity/Entity.cpp ────────────────────────── #include <Simple3D/Entity/Entity.h> #include "../Internal/UrhoEngine.h" // Urho3D freely available here namespace Simple3D { struct Entity::Impl { Urho3D::SharedPtr<Urho3D::Node> node; // Other implementation details... }; void Entity::AddRigidBody(float mass) { auto* rb = impl->node->GetOrCreateComponent<Urho3D::RigidBody>(); rb->SetMass(mass); rb->SetCollisionEventMode(Urho3D::COLLISION_ALWAYS); } } // namespace Simple3D
The consequence: changing how AddRigidBody works internally (different Urho3D component,
different configuration) only requires recompiling Entity.cpp — game code that calls
entity->AddRigidBody(1.0f) does not recompile.
Game Bootstrap — What Happens Inside Run()
When the user calls CreateGame<MyGame>()->Run(), the following sequence
runs before Start() is ever called:
- Context created —
Urho3D::Contextallocated, all subsystems registered. - Renderer initialised — SDL3 window created, GPU context established, bgfx/GLES backend selected.
- Audio system started — OpenAL (or platform audio) initialised, master volume set to 1.0.
- Input system initialised — keyboard / mouse / touch event handlers installed.
- Scene created — A root
Urho3D::Scenecreated withPhysicsWorld(Bullet 3D) andPhysicsWorld2D(Box2D) components attached automatically. - Lua state initialised — Urho3D's LuaJIT scripting component registered; all Urho3D Lua bindings registered; Simple3D custom Lua bindings registered.
- Viewport created — Default
Urho3D::Viewportset up for the full window. - User's Start() called — At this point the scene, renderer, input, audio, and Lua are all ready.
- Main loop runs — Each frame: process events → update physics → call user's
Update(dt)→ render → present. - User's Stop() called — On exit signal or
Quit().
Entity Component Model (Internal)
Each Simple3D::Entity wraps exactly one Urho3D::Node.
Components are created and configured on the node via Urho3D's built-in ECS:
| Simple3D method | Urho3D call | Notes |
|---|---|---|
AddModel(path) | node->CreateComponent<StaticModel>() | Loads mesh from ResourceCache |
AddDirectionalLight(c, i) | CreateComponent<Light>(); light->SetLightType(LIGHT_DIRECTIONAL) | |
AddPointLight(c, r) | CreateComponent<Light>(); LIGHT_POINT; SetRange(r) | |
AddSpotLight(c, r, a) | CreateComponent<Light>(); LIGHT_SPOT; SetRange; SetFov | |
AddRigidBody(mass) | GetOrCreateComponent<RigidBody>(); SetMass(mass) | GetOrCreate → safe to call twice |
AddBoxCollider(size) | GetOrCreateComponent<CollisionShape>(); SetBox(size) | Full-size Vector3, not half-extents |
AddCapsuleCollider(r, h) | SetCapsule(r*2, h, ...) | |
AddSphereCollider(r) | SetSphere(r*2) | |
AddMeshCollider(path) | SetTriangleMesh(model) | Static bodies only (mass=0) |
AddTriggerBox(size) | SetBox; rb->SetTrigger(true) | No physics response, only callbacks |
AddAnimationController() | GetOrCreateComponent<AnimationController>() | |
AddScript(path) | CreateComponent<LuaScriptInstance>(); LoadFile(path) | Logs warning if file missing |
AddSprite2D(path) | CreateComponent<StaticSprite2D>(); SetSprite(path) | |
AddAnimatedSprite2D(scml) | CreateComponent<AnimatedSprite2D>(); SetAnimationSet(scml) | Spriter .scml format |
AddRigidBody2D(mass) | CreateComponent<RigidBody2D>(); SetMass(mass) | Box2D body |
AddBoxCollider2D(w, h) | CreateComponent<CollisionBox2D>(); SetSize(w, h) | |
AddCircleCollider2D(r) | CreateComponent<CollisionCircle2D>(); SetRadius(r) |
Entity Registry
A key design challenge: Urho3D internally uses raw Urho3D::Node* pointers.
When a trigger fires, the callback receives a Node* — but game code needs an
Entity*. The same problem arises in GetParent() and
CreateChild().
The solution is a bi-directional registry stored inside GameImpl:
// Inside GameImpl (src, not public): std::unordered_map<Urho3D::Node*, std::unique_ptr<Entity>> entityRegistry;
Every CreateEntity() call creates an Urho3D::Node, allocates an
Entity, and inserts the pair. Every DestroyEntity() removes and
deletes both. When trigger callbacks or parent lookups need to convert a Node*
to an Entity*, they look it up in this map.
Entity::CreateChild() creates a child Node and also registers the
resulting Entity in the same registry, so hierarchy-traversal functions always
return valid Entity* values.
Physics Integration
simple-3d includes both a 3D physics world (Bullet) and a 2D physics world (Box2D) in the same scene. They do not interact — a 3D rigid body does not know about 2D rigid bodies and vice versa.
3D physics (Bullet)
PhysicsWorld is automatically added to the root Urho3D Scene in
Game::Run(). Gravity is (0, -9.81, 0) by default.
Physics runs at 60 Hz fixed timestep; rendering is uncapped.
Trigger volumes use RigidBody::SetTrigger(true), which disables collision
response while preserving overlap events. The enter/exit callbacks set via
SetOnTriggerEnter receive an Entity* resolved from the registry.
IsOnGround() performs a downward raycast of 0.7 m from the entity's origin.
It hits any body on any collision layer. If you need layer filtering, use
SetCollisionMask on the rigid body or perform your own raycast via Urho3D directly
(this is an advanced use case not yet exposed in the simple-3d API).
2D physics (Box2D)
PhysicsWorld2D is added automatically alongside the 3D world.
2D bodies use RigidBody2D and 2D colliders use CollisionBox2D /
CollisionCircle2D. They live in the XY plane.
Tile maps loaded via LoadTileMap() automatically create static RigidBody2D
collision shapes from Tiled collision layers.
Lua Scripting Architecture
Scripts are managed by Urho3D's LuaScriptInstance component.
Each entity's script runs in its own Lua function environment (not a separate Lua state),
so scripts can communicate via shared globals, but simple-3d convention discourages this.
Prefer FindEntity() and explicit message passing.
The Lua state lifecycle:
entity->AddScript("path.lua")loads and compiles the file via ResourceCache.- A
LuaScriptInstancecomponent is attached to the Urho3D Node. - When the scene first updates,
Start()in the script is called once. - Every frame,
Update(dt)is called if it exists. - When the entity is destroyed,
Stop()is called if it exists.
Custom Lua bindings registration
Simple3D custom bindings are registered during Game::Run() initialisation,
before user's Start() is called. They are registered via Urho3D's
RegisterLuaLibrary mechanism and extend the Node Lua metatable:
// src/Simple3D/Core/LuaBindings.cpp void RegisterSimple3DLua(Urho3D::Context* ctx) { auto* lua = ctx->GetSubsystem<Urho3D::LuaScript>(); // Extend the Node metatable with simple-3d helpers lua->ExecuteString(R"LUA( function Node:MoveRelative(delta) local rb = self:GetComponent("RigidBody") if rb then local vel = Vector3(delta.x, 0, delta.z) / 0.016 -- approx rb:SetLinearVelocity(vel + Vector3(0, rb:GetLinearVelocity().y, 0)) else self:Translate(delta) end end function Node:IsOnGround() -- raycast 0.7m down local hit = scene:GetPhysicsWorld():RaycastSingle( Ray(self:GetWorldPosition(), Vector3(0,-1,0)), 0.7) return hit.body ~= nil and hit.body ~= self:GetComponent("RigidBody") end )LUA"); // Register global functions (PlaySound, FindEntity, …) RegisterAudioLua(lua); RegisterSceneLua(lua); }
Resource Loading
All asset loading goes through Urho3D::ResourceCache, which is an LRU cache
that loads assets from disk on first use and returns the same object on subsequent requests.
Resources are reference-counted — they stay loaded as long as at least one component
references them.
When you call entity->AddModel("Models/hero.mdl"), simple-3d calls:
auto* cache = GetSubsystem<Urho3D::ResourceCache>(); auto* model = cache->GetResource<Urho3D::Model>("Models/hero.mdl"); if (!model) { URHO3D_LOGWARNING("simple-3d: AddModel — resource not found: Models/hero.mdl"); return; } staticModel->SetModel(model);
If a resource is not found, simple-3d logs a [Warning] message and returns
without crashing. The entity is still created and all other calls still work — this allows
development to continue with placeholder assets.
Threading Model
simple-3d is single-threaded. All public API calls must be made from the main thread. This is the same constraint as direct Urho3D use.
Internally, Urho3D may use worker threads for:
- Physics simulation stepping (Bullet has its own dispatch).
- Background resource loading (ResourceCache can queue load jobs).
- Navigation mesh baking (Recast bakes on a worker thread).
- Network I/O (ENet runs on the main thread's event loop, not a separate thread).
None of these affect the simple-3d API contract: Start(), Update(dt),
Stop(), and all entity/camera/label operations are always called from the main thread.
Backend Switching
The CMake variable SIMPLE3D_ENGINE selects the backend. Both backends expose the
same Urho3D API surface. Only the CMake linkage section in CMakeLists.txt differs:
# U3D backend linkage
find_package(U3D REQUIRED
PATHS ${U3D_HOME}
${URHO3D_HOME})
target_link_libraries(simple3d
PUBLIC Urho3D)
# NOVA3D backend linkage
find_package(NOVA3D REQUIRED
PATHS ${NOVA3D_HOME})
target_link_libraries(simple3d
PUBLIC nova3d)
Because simple-3d source files only include Internal/UrhoEngine.h (an
umbrella header that maps to whichever backend is selected), switching backends
requires only a cmake -DSIMPLE3D_ENGINE=NOVA3D reconfigure followed by a
full rebuild. Game source files are untouched.
Complete Component Mapping
| simple-3d call | Urho3D component(s) created | Method |
|---|---|---|
AddModel(path) | StaticModel | CreateComponent |
AddDirectionalLight(c, i) | Light (DIRECTIONAL) | CreateComponent |
AddPointLight(c, r) | Light (POINT) | CreateComponent |
AddSpotLight(c, r, a) | Light (SPOT) | CreateComponent |
AddRigidBody(mass) | RigidBody | GetOrCreateComponent |
AddBoxCollider(size) | CollisionShape | GetOrCreateComponent; SetBox |
AddCapsuleCollider(r, h) | CollisionShape | SetCapsule |
AddSphereCollider(r) | CollisionShape | SetSphere |
AddMeshCollider(path) | CollisionShape | SetTriangleMesh |
AddTriggerBox/Sphere/Capsule | CollisionShape + RigidBody (trigger=true) | Subscribe E_NODECOLLISIONSTART |
AddAnimationController() | AnimationController | GetOrCreateComponent |
AddScript(lua) | LuaScriptInstance | CreateComponent; LoadFile |
AddSprite2D(path) | StaticSprite2D | CreateComponent |
AddAnimatedSprite2D(scml) | AnimatedSprite2D | CreateComponent |
AddRigidBody2D(mass) | RigidBody2D | CreateComponent |
AddBoxCollider2D(w, h) | CollisionBox2D | CreateComponent |
AddCircleCollider2D(r) | CollisionCircle2D | CreateComponent |
CreateCamera() | Camera component on new Node | New Node, CreateComponent<Camera> |
CreateLabel(text) | Text UI element | Added to UI root element |
LoadTileMap(path) | TileMap2D on new Node | Also creates RigidBody2D + CollisionPolygon2D for each Tiled collision layer |
File Layout
Internal Headers Reference
| File | Purpose | Used by |
|---|---|---|
Internal/UrhoEngine.h |
Umbrella include that pulls in all Urho3D headers needed by the implementation layer. Also defines backend-specific preprocessor guards. | All src/ .cpp files |
Internal/EntityInternal.h |
Declares GetNode(Entity*) (returns the backing Urho3D::Node*) and MakeEntity(Node*, GameImpl*) (wraps a Node in a new Entity and registers it). |
Game.cpp, Camera.cpp, trigger callbacks |
Internal/CameraInternal.h |
Declares GetCameraNode(Camera*) and GetUrhoCamera(Camera*) for follow/orbit logic that needs to read the Urho3D camera position. |
Game.cpp |
Never include internal headers from game code
Files in src/Simple3D/Internal/ are not installed and are not part of the public API.
Including them from game code will break when the library is updated. Always use only
#include <Simple3D/Simple3D.h>.