engine
A small C++20 3D engine: an EnTT-based entity/component layer with Unity-style callbacks (no virtual),
Jolt physics as components, GPU-instanced raylib rendering, and a pipeline that turns labelled SVG
drawings into 3D models (pugixml parser + Clipper2 geometry).
#include <engine/engine.hpp>
using namespace eng;
struct Spinner : Component {
void update(float dt) { transform().rotateBy({0, 90 * dt, 0}); }
};
int main() {
World world;
Entity cube = world.createEntity("cube");
cube.add<MeshRenderer>().mesh = Resources::box(); // shared, instanced on the GPU
cube.add<Spinner>();
Entity camera = world.createEntity("camera");
camera.add<Camera>();
camera.position() = {0, 2, 6};
camera.transform().lookAt({0, 0, 0});
return run(world, {.title = "hello"}); // window, input, loop, physics, rendering
}
No window, renderer, raylib call or loop in game code. main() sets up a world, creates entities and calls run().
| library | version | used for |
|---|---|---|
| raylib | 6.0 | window, input, rendering (only inside engine_render) |
| Jolt Physics | 5.6.0 | rigid bodies, character controller, joints, raycasts |
| EnTT | 4.0.0 | component storage |
| Clipper2 | 2.0.1 | fill normalisation (islands + holes), stroke outlines, patch hit tests |
| pugixml | 1.16 | XML for the SVG parser |
Everything is fetched by CMake at pinned tags; you need a C++20 compiler and CMake >= 3.20.
Build
cmake -S . -B build
cmake --build build -j
ctest --test-dir build --output-on-failure # headless: no window, no GL needed
./build/demo # WASD, SPACE, right-mouse orbit, F shoots
./build/instancing # 20,000 animated cubes, one draw call
- Linux needs the X11/GL dev packages for raylib, e.g. Ubuntu:
sudo apt install libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev. - Link the
enginetarget from your own CMake project (target_link_libraries(game PRIVATE engine));engine_corealone is the headless engine (ECS, physics, SVG, resources) with no raylib/GL. -DENGINE_BUILD_RENDER=OFFbuilds onlyengine_core;-DENGINE_SANITIZE=ONbuilds the engine's own code with ASan + UBSan.- Smoke tests / screenshots without code changes:
ENGINE_MAX_FRAMES=120 ENGINE_SCREENSHOT=out.png ./build/demo(works with any app that usesrun(); under CI usexvfb-runwith Mesa software GL).
CPU level (read this if a binary dies with "Illegal instruction")
Jolt exports its SIMD flags (-mavx2 -mfma -mbmi -mlzcnt -mf16c -mpopcnt by default) as PUBLIC compile
options, so all engine code is compiled with them: on a CPU or VM without one of those extensions
(Celeron/Pentium parts, Rosetta, QEMU's default CPU, very old Core 2 ...) the first such instruction ends the
program with SIGILL, in every executable. The build therefore chooses the level itself:
-DENGINE_CPU_LEVEL= |
meaning |
|---|---|
auto (default) |
at configure time, runs a probe for AVX2+FMA+BMI+LZCNT+F16C, then for SSE4.2, on the build machine and uses the best that works |
baseline |
plain x86-64 (SSE2): runs on every 64-bit x86 CPU. Use this for binaries you distribute |
sse4 |
SSE4.2 + POPCNT (any CPU since ~2009) |
avx2 |
AVX2 + FMA + BMI + LZCNT + F16C (Haswell / Zen or newer) |
The configure output says what was picked (engine: CPU level (auto) = ...). auto is a build-machine
probe: don't copy auto builds to older machines. ARM/WASM targets are untouched. If a baseline build
still crashes, run gdb -batch -ex run -ex 'x/i $pc' ./build/engine_tests and look at the faulting instruction.
The user API
Everything game code needs is behind #include <engine/engine.hpp>. EnTT, Jolt, Clipper2, pugixml and raylib
never appear in a public signature (EnTT lives inside the private parts of World/Entity; Jolt is behind a
pimpl; raylib is only inside engine_render).
Entry point
int run(World&, const RunConfig& = {}); // returns when the window closes, Escape is pressed, or quit() is called
struct RunConfig { title, width, height, resizable, fullscreen, vsync, escapeQuits, msaa, targetFps,
showStats, background, frameTime, maxFrames, screenshot };
run() opens the window, polls input into Input, calls world.update(dt) (start -> fixed steps + physics ->
update -> lateUpdate -> deferred destruction), renders, and cleans up. If the world has no active Camera it
adds a default one. For headless use (servers, tests) call world.update(dt) yourself.
Entities, components, hierarchy
- World:
createEntity(name, parent),hierarchy()(the invisible root: query the whole scene),update(dt),physics(). - Hierarchy (component):
parent() firstChild() previousSibling() nextSibling()(methods returningEntity;parent()is null for top-level entities),entityByName(name),entityByTag(TagType),entitiesWith<...>(),entities(),children(),setParent(Hierarchy&, position, keepWorld=false). Queries search below the node, excluding the node itself. - EntityIterator: iterator and range over a subtree in pre-order, optionally filtered; works in range-for.
- Entity:
destroy() clone(name) has<T...>() get<T>() add<T>(T) world() transform() hierarchy() parent() position() rotation() scale() entityEnabled() enableEntity() disableEntity() setEntityEnabled(bool); extras:tryGet<T>() emplace<T>(args...) remove<T>() name() tag() setParent() entityActive(). - Component: base class with
entity()and the same shorthands (destroy()/clone()act on the whole entity),deltaTime(),componentEnabled() enableComponent() disableComponent() setComponentEnabled(bool). - Transform: public local
position / rotation / scale;translateTo/By,rotateTo/By,scaleTo/By(vec3, Locality);localPosition() localRotation() localScale() worldPosition() worldRotation() worldScale(); extraslookAt,forward/right/up,setWorldPose,localMatrix/worldMatrix. - EntityData:
name,tag,enabled(entity-wide flag; a disabled entity disables its whole subtree).
struct Mover : Component { // plain struct, no virtual
void awake() {} // added to an entity
void start() {} // once, before the first update while active
void update(float dt) {} // or update() with no parameter
void fixedUpdate() {} // 60 Hz, right before the physics step
void lateUpdate() {}
void destroyed() {} // all components of the entity still exist here
void collisionEnter(Entity other) {} // + collisionExit / triggerEnter / triggerExit
};
Input
Input::keyDown/keyPressed/keyReleased(Key), mouseDown/mousePressed/mouseReleased(MouseButton),
mousePosition(), mouseDelta(), mouseWheel(), windowSize(), and quit(). Read it from update()/lateUpdate()
(a fixedUpdate() runs 0..N times per frame and can miss one-frame events).
Rendering components
MeshRenderer{ MeshPtr mesh; Rgba tint; }, Camera (perspective/orthographic), Light (directional, aimed with
lookAt), and Text (screen-space label: set value, position, size, color - that is how HUDs are made).
Resources (nothing is duplicated)
MeshPtr m = Resources::box({1, 2, 3}); // same arguments -> the same shared object
MeshPtr s = Resources::sphere(0.5f, 24, 14);
MeshPtr c = Resources::mesh("rock/03", [] { return buildRock(); }); // your own mesh, built once per live key
ModelPtr l = Resources::svg("level.svg", {.scale = 0.3f}); // extruded once, shared (key = path + size + mtime + options)
Resources::stats(); // live meshes/models, cache hits/misses
The cache holds weak references: a resource lives exactly as long as something uses it and is rebuilt on demand afterwards. Sharing pays off three times:
- CPU: one
Meshhowever many entities use it (clones share too). - GPU: one buffer per
Mesh, and every entity using it is drawn with GPU instancing: one draw call per distinct mesh, per-instance transform and tint in a single instance buffer uploaded once per frame, top-down transform pass (O(N)), frustum culling per instance.showStatsprintsdraw calls / instances / culled. (./build/instancing: 20,000 cubes -> 1 draw call.) - Physics: Jolt collision shapes are shared by bodies that ask for the same shape (type, size, scale, mesh).
Physics
- A
Collidermakes a body. With aRigidBody(Static / Kinematic / Dynamic) it behaves as requested; without one it is static. OneColliderper entity. - Shapes:
Box, Sphere, Capsule, Cylinder, ConvexHull, Mesh(triangle meshes are static-only; asked to be dynamic they fall back to a convex hull). Shapes are baked with the entity's world scale when the body is created; change a setting, then callrebuild(). - Sync per fixed step: entity -> body (kinematic bodies are moved, static/dynamic bodies are teleported only if you moved the entity), simulate, then dynamic bodies -> entity.
Collider::trigger = truemakes a sensor. Contact events callcollisionEnter/Exit(Entity other)andtriggerEnter/Exit(Entity other)on the components of both entities.CharacterController(JoltCharacterVirtual): origin at the feet; callmove(velocity)every frame (it is used by the next frame's steps, then forgotten),jump(speed), readgrounded(). Slopes, stairs and gravity are handled inside the step.Joint:Fixed, Point, Hinge, Distance, Slider, created in world space from the current poses.world.physics().raycast(origin, dir, maxDist, hit),setGravity().
Semantics worth knowing
- Angles are degrees, Euler order is Unity's (Z, then X, then Y). Right-handed, Y up, forward = -Z.
Locality::Localfor*Tosets the stored local value; fortranslateBy/rotateByit means the entity's own axes (Unity'sSpace.Self).Locality::Worldfor*Tosets the world value; for*Byit works along world axes.scaleBymultiplies component-wise either way (world scale is lossy under rotated, non-uniformly scaled parents).setParent(..., keepWorld=false)keeps the local values (scene-building default); passtrueto keep the world transform.- Callbacks must be public. They are detected at compile time (C++20 concepts) when a component type is first added; the World keeps plain function pointers per type. A private
update()is silently not called. destroy()is deferred to the end of the frame whileWorld::updateruns (safe inside callbacks) and immediate otherwise; a destroyed entity takes its subtree with it. Disabled entities/components get no callbacks, and their bodies leave the simulation.add<T>(T)replaces an existing component of that type and callsawake()again. References fromget<T>()are invalidated by removing components of that type; don't hold them acrossdestroy()/remove().
SVG -> 3D
Resources::svg(path, ExtrudeOptions) -> ModelPtr; spawnModel(world, model, "level") creates an entity per shape (name = shape id, tag = hash of the first group, MeshRenderer + static mesh Collider). Labels are read from inkscape:label (then data-label, label, <title>), are inherited from parent groups/layers, and unknown words are ignored:
| label | meaning |
|---|---|
y=<f> |
elevation of the base vertices (default 0) |
h=<f> |
elevation of the top vertices (default 1) |
e=(f,r)(f,r)... |
fragment the extrusion: at fraction f (0 = base, 1 = top) the cross-section is scaled by r (1 = as drawn, 2 = double, 0 = collapses to a point) |
color=#rrggbb / #rrggbbaa / r,g,b / (r,g,b) |
shape colour; vertex colours blend across faces |
vpatch |
not geometry: patches the vertices it touches (y, h, color) |
spatch |
not geometry: patches the shapes it touches (y, h, e, color) |
#name |
add the shape to a group (repeatable) |
@name |
set the shape id (default: the element id, else shapeN) |
only=(#a @b ...) / except=(#c ...) |
restrict which shapes a patch applies to (by group # or id @) |
Decisions made where the brief left room (each lives in one place and is easy to change):
yandhare absolute elevations in world units (thickness =h - y), unaffected byExtrudeOptions::scale, which only scales X/Z. Ifh < yat a vertex the two are swapped.eratios scale each island uniformly about its own area centroid (holes scale with it). The profile is piecewise linear and implicitly starts and ends at ratio 1 - add(0,r)/(1,r)to change the ends. Repeating a fraction makes a flat ledge (e=(0.5,1)(0.5,0.6)is a step).- A patch touches a shape when their areas overlap or their outlines touch;
vpatchonly changes vertices that already exist in the outline (add nodes in Inkscape where you need more). Patches apply in document order, later ones win, vertex patches after shape patches. A patch inside an ordinary layer does not inherit that layer'sy/h/e/color. colordefaults to the SVGfill. Stroke-only shapes (fill:none) become outlines via Clipper2 offsetting (stroke-width,linecap,linejoin), so a drawn line becomes a wall. Fill rules (nonzero/evenodd) are honoured, so holes and islands-in-holes work.- Mapping: world X = svg x, world Z = svg y, Y = elevation, so the drawing looks identical from above.
ExtrudeOptions::centerputs the viewBox centre at the origin. - Supported SVG:
g,path(all commands incl. arcs),rect(with rx/ry),circle,ellipse,line,polyline,polygon,transform,style/presentation attributes,display:none. Skipped with a warning (Model::warnings):<use>,<text>,<image>; CSS stylesheets, gradients, clip paths and masks are ignored. - Output meshes are closed, consistently wound surfaces: walls get smooth normals across edges bending less than
smoothAngleDeg, caps are flat.
Layout
cmake/cpu.cmake deps.cmake CPU-level probe; pinned FetchContent declarations
include/engine/ engine.hpp (the one header) + math ecs input graphics model resources physics app
include/engine/detail/ internal: svg parser, extrusion, raw input state (not included by engine.hpp)
src/ ecs input resources physics(Jolt, pimpl) svg(pugixml) extrude(Clipper2) spawn | render app (raylib)
examples/demo, instancing the clean entry point in practice
tests/tests.cpp headless suite: 31 tests, 359 checks
Limitations
One Collider per entity (no compound colliders from child entities); the lit shader is GLSL 330 (desktop GL only, one directional light, no shadows, no texture support yet); destroy() while iterating an EntityIterator outside World::update is not safe.