2026-10-03 20:07:55 -03:00
2026-10-02 03:22:11 -03:00
2026-10-03 20:07:55 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00
2026-10-02 03:22:11 -03:00

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 engine target from your own CMake project (target_link_libraries(game PRIVATE engine)); engine_core alone is the headless engine (ECS, physics, SVG, resources) with no raylib/GL.
  • -DENGINE_BUILD_RENDER=OFF builds only engine_core; -DENGINE_SANITIZE=ON builds 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 uses run(); under CI use xvfb-run with 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 returning Entity; 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(); extras lookAt, 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 Mesh however 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. showStats prints draw 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 Collider makes a body. With a RigidBody (Static / Kinematic / Dynamic) it behaves as requested; without one it is static. One Collider per 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 call rebuild().
  • 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 = true makes a sensor. Contact events call collisionEnter/Exit(Entity other) and triggerEnter/Exit(Entity other) on the components of both entities.
  • CharacterController (Jolt CharacterVirtual): origin at the feet; call move(velocity) every frame (it is used by the next frame's steps, then forgotten), jump(speed), read grounded(). 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::Local for *To sets the stored local value; for translateBy/rotateBy it means the entity's own axes (Unity's Space.Self). Locality::World for *To sets the world value; for *By it works along world axes. scaleBy multiplies component-wise either way (world scale is lossy under rotated, non-uniformly scaled parents).
  • setParent(..., keepWorld=false) keeps the local values (scene-building default); pass true to 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 while World::update runs (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 calls awake() again. References from get<T>() are invalidated by removing components of that type; don't hold them across destroy()/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):

  • y and h are absolute elevations in world units (thickness = h - y), unaffected by ExtrudeOptions::scale, which only scales X/Z. If h < y at a vertex the two are swapped.
  • e ratios 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; vpatch only 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's y/h/e/color.
  • color defaults to the SVG fill. 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::center puts 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.

S
Description
No description provided
Readme
524 KiB
0 Stars 1 Watchers 0 Forks
Languages
C++ 96.4%
CMake 3.6%