2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-02 03:22:11 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -03:00
2026-10-24 12:24:05 -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, a GPU-instanced raylib renderer with real-time lighting, soft shadows and per-region ambient light, a pipeline that turns labelled SVG drawings into 3D models (pugixml parser + Clipper2 geometry), and eng::gfx: the engine's own equivalent of every raylib function, generated from the raylib headers you build against.

#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 sun = world.createEntity("sun");
    sun.add<Light>(Light::directional());                  // casts shadows by default
    sun.transform().lookAt({-0.4f, -1, -0.3f});

    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), and the source of the generated eng::gfx
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                                   # see the key list below
./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, and everything the renderer decides without a GPU: visibility, lights, shadow fitting, ambient zones) with no raylib/GL.
  • 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).
CMake option default meaning
ENGINE_BUILD_RENDER ON window + renderer (raylib), run(), the examples. OFF builds only engine_core
ENGINE_BUILD_TESTS ON engine_tests (and gfx_selftest, see below)
ENGINE_BUILD_EXAMPLES ON demo, instancing (needs ENGINE_BUILD_RENDER)
ENGINE_BUILD_GFX ON generate eng::gfx from raylib's headers at configure time (needs ENGINE_BUILD_RENDER)
ENGINE_GFX_STRICT OFF make any problem generating eng::gfx a configure error instead of a warning
ENGINE_GFX_GEN_EXECUTABLE a prebuilt gfx_gen (tools/gfx_gen), needed when cross-compiling
ENGINE_BUILD_GPU_CHECK OFF gpu_check: the renderer on a software OpenGL driver, pixel-compared with a CPU model (Linux, Mesa EGL, libpng)
ENGINE_SANITIZE OFF build the engine's own code (not the dependencies) with ASan + UBSan
ENGINE_CPU_LEVEL auto instruction-set level, see below

ctest runs engine_tests (headless suite), gfx_selftest (when eng::gfx was generated) and, if enabled, gpu_check. The configure log says what happened to eng::gfx (lines starting engine: eng::gfx / eng::gfx:).

The demo

A labelled SVG becomes the level; a Jolt character walks around it. It shows lighting (a sun and two spot lights that cast shadows, a point lamp, fog, tone mapping), ambient zones of every shape (one follows the player), materials (textures made in code: stone, planks, plaster, grass, roof tiles, and a row of spheres that differ in one number each), hide/show, colliders that follow rotating and scaling parents (a windmill, a breathing block) and eng::gfx drawing calls.

The level has a park, a house south of the start and a walled garden in the south-east corner, to see how ambient zones nest and blend:

  • The park is an outdoor area of its own (a big Replace zone at the lowest priority). Every other zone is blended over it.
  • The garden is an outdoor part inside that outdoor part, with purple light: a Replace zone with a purple sky and ground colour, fading over 1.5 units outside its walls. There is no purple light source anywhere; the colour comes from the area, and it tints the garden's stone, the paving, the spheres and, softly, the grass and the wall beyond its edge.
  • The house is 90% dark, with some light: a Multiply zone with intensity = 0.1 keeps 10% of whatever ambient light the zones below it give (so a house built inside the garden would be dark and purple). The light in it: a point lamp in each room (the west one flickers: it edits its shared bulb Material every frame), a small warm Add zone around each lamp, and the sun through the window in the west wall (the roof and walls shadow it from the rest).
  • The Z key switches all zones off to compare with the plain hemisphere ambient; M switches the materials off.
key
W A S D, SPACE walk, jump
right mouse + drag, wheel orbit, zoom
F / left click shoot a box
Q / E turn the sun
H hide / show the decor (Entity::setVisible on the children of the level tagged "decor")
C hide / show the player's nose (MeshRenderer::childrenVisible)
L headlamp on / off (a hidden Light is off)
Z ambient zones on / off (AmbientZone::setComponentEnabled)
M materials on / off (every renderer that has a Material gets nullptr and back: the plain look)
G eng::gfx overlay on / off (only when eng::gfx is part of the build)

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 and the generated eng::gfx wrappers).

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(), and setVisible(bool, includeChildren = true) isVisible() (see Showing and hiding).
  • 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
    void drawGizmos()     {}                     // 3D immediate drawing inside the camera's view (eng::gfx)
    void drawGui()        {}                     // 2D immediate drawing on top of the frame (eng::gfx)
};

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

Rendering is driven by components; the renderer reads them every frame, so changing a field (or toggling a component) is all it takes. Everything on this page is plain data, and everything the renderer decides (what is visible, which lights are shaded, how the shadow maps are fitted, what the ambient grid contains) is computed on the CPU by engine_core and unit-tested headless; engine_render only uploads and draws.

component what it does
MeshRenderer draws a MeshPtr with a tint and a surface: a shared Material (maps and numbers) or its own roughness, metallic, emission; shadow flags, unlit
Camera perspective or orthographic; run() renders with the first enabled one, looking down the entity's -Z
Light Directional (sun), Point or Spot; colour, intensity, range, cone, shadow settings
Environment scene-wide look: hemisphere ambient, exposure, tone mapping, fog, shadow quality, zone grid resolution
AmbientZone a region of space (box, sphere, capsule, cylinder, any polygon, any closed mesh) with its own ambient light
Text screen-space label (HUDs, read-outs)
Visibility draws nothing; gives a plain group entity the hide/show switches of a renderer

MeshRenderer, Light, Text and Visibility derive from Renderable (the hide/show switches below). Material and Texture are shared resources, not components (see Materials).

Showing and hiding

Hiding is visual only: scripts keep running, physics keeps simulating, colliders keep colliding. A hidden mesh draws nothing and casts no shadow; a hidden light is off. (To switch an entity off completely use Entity::setEntityEnabled(false): no callbacks, no body, no drawing, whole subtree.)

entity.setVisible(false);                  // hides this entity AND everything below it
entity.setVisible(false, false);           // hides only this entity's own renderers; children stay visible
entity.setVisible(true);
bool shown = entity.isVisible();           // drawn right now: not hidden here, nor by an ancestor

auto& mesh = entity.get<MeshRenderer>();   // or per component, on any renderer (MeshRenderer, Light, Text, Visibility)
mesh.visible = false;                      // this component draws nothing
mesh.childrenVisible = false;              // everything BELOW this entity is hidden; the mesh itself still follows `visible`
mesh.setVisible(false, true);              // both at once (what Entity::setVisible calls)
  • visible controls the component itself, childrenVisible the subtree below its entity, so a parent can stay visible while its children are hidden (the demo's C key hides a character's nose that way) and the other way round.
  • A child cannot override an ancestor that hides its subtree: isVisible() / Renderable::visibleInHierarchy() tell you what actually happens.
  • Entity::setVisible(false) on a plain group entity (no renderer at all) adds a Visibility component to carry the switch; entity.add<Visibility>() does the same by hand. Disabling a Renderable component (disableComponent()) also stops it from hiding children.
  • AmbientZone is not drawn, so hiding does not affect it: disable the component (or the entity) to switch a zone off.

Materials

A surface is described in one of two ways. Plain numbers on the MeshRenderer (the default; matte, no maps):

MeshRenderer& m = e.add<MeshRenderer>();
m.mesh = Resources::sphere(0.5f);
m.tint = {200, 60, 40, 255};      // multiplied with the mesh's vertex colours (and with a material's albedo)
m.roughness = 0.3f;               // 0 mirror-like highlights .. 1 fully diffuse (default 0.85: matte)
m.metallic = 1.0f;                // 0 plastic/stone/wood .. 1 metal (the colour tints the reflection)
m.emission = 2.0f;                // self-lit: adds colour * emission regardless of lights (glow: 1..3, max 4)
m.castShadows = true;  m.receiveShadows = true;
m.unlit = false;                  // true: ignore all lighting, draw the plain colour (gizmos, skies, UI-in-world)

Or a Material: every property is a number, a texture, or both (the texture's value is multiplied by the number, so a map keeps its range and the number turns it up or down).

MaterialPtr brick = Material::create();
brick->albedoMap    = Resources::texture("brick_albedo.png");     // colour
brick->normalMap    = Resources::texture("brick_normal.png");     // bumps
brick->roughnessMap = Resources::texture("brick_rough.png");
brick->roughness    = 1.0f;                                       // x the map
brick->specular     = 0.3f;                                       // bricks hardly shine
brick->uvScale      = {0.5f, 0.5f};                               // repeats per unit
e.get<MeshRenderer>().material = brick;                           // as many renderers as you like share it

MaterialPtr gold = Material::create();                            // numbers only
gold->albedo = {1.0f, 0.78f, 0.34f};  gold->metalness = 1.0f;  gold->roughness = 0.28f;
property number map (channel) meaning
albedo albedo (colour) albedoMap (RGB, alpha = opacity) the colour of the surface under plain white light; multiplies the mesh's vertex colours and the renderer's tint
diffuse diffuse (1) diffuseMap (R) how much of the light it scatters in all directions; 0 = none (a surface that only reflects)
specular specular (0.5), specularColor specularMap (R) how strongly a non-metal reflects light like glass: reflectance F0 = 0.08 x specular x specularColor (0.5 is the 4% of plastic and glass); ambient reflections follow it too
metalness metalness (0) metalnessMap (R) 0 = plastic/stone/wood .. 1 = metal (reflects in its albedo colour, no diffuse)
roughness roughness (0.85) roughnessMap (R) 0 = mirror-like highlights .. 1 = fully matte
normals normalStrength (1) normalMap small bumps without geometry: the map leans the surface by the slope (x, y) / z of its tangent-space normal, scaled by the strength
emission emission (0) self-lit: adds albedo x emission whatever the lights do (a bulb: 2)
  • Colour spaces. albedoMap and the colour numbers are gamma-encoded like every colour in the engine; the other maps hold numbers as they are. Normal maps are tangent-space, RGB = (x, y, z) x 0.5 + 0.5 with +x towards the right of the image, +y towards its top and +z out of the surface (a flat map is (128, 128, 255); the usual "OpenGL" convention). Texture coordinates have (0, 0) at the top-left corner of an image and v growing downwards (as in raylib).
  • Where the texture coordinates come from (Material::uv): UvMode::Mesh is the mesh's own (MeshData::texcoords; Resources::box and sphere have them: a box shows a texture once per world unit on every face, not stretched however long it is; a sphere wraps it once around). UvMode::World projects the textures from the world along x, y and z (triplanar), blended where a surface turns: no UVs and no unwrapping needed, and neighbouring pieces line up seamlessly. The extruded SVG shapes have no UVs, so they use it. UvMode::Auto (the default) is Mesh when the mesh has UVs, else World. uvScale (repeats per unit) and uvOffset apply to both. A world-projected texture stays where it is when the object moves (give a moving object a mesh with UVs).
  • Textures (texture.hpp) are shared RGBA8 images, one GPU copy each, however many materials use them. Resources::texture(path, options) reads an image file (PNG, JPG, BMP, TGA, ... through raylib; it needs no window and works while the world is being built; cached by path, size and modification time; throws std::runtime_error when it cannot read the file). Without a file: Texture::create(w, h, rgba), Texture::solid(colour), Texture::checker(...) and Texture::normalMapFromHeight(heightMap, strength) (see the demo, which makes all of its textures in code). A program with no window can install its own decoder with setImageLoader. TextureOptions: Repeat / Clamp, Smooth (mipmaps, trilinear, 8x anisotropic: no shimmer on floors at a grazing angle) / Pixelated (nearest, for pixel art).
  • Materials are shared and editable: change a field and every renderer that uses the material follows from the next frame (a flickering lamp is one assignment per frame; see Flicker in the demo). material->clone() makes a new material (new id) with the same numbers and textures to change a few. Materials cannot be copied (a copy would share the id).
  • Draw calls: one instanced draw per mesh, material and handedness (before: per mesh and handedness); the shadow passes only need depth and ignore materials. Many objects with one material are still one draw call.
  • With a material set, the renderer's own roughness, metallic and emission are not used (the tint still is). A default Material::create() draws exactly like a renderer without one.

Colours are the usual 0..1 "screen" values; the shader lights them in linear light (gamma 2.2) and encodes the result again, so intensity = 1 sun light on a surface facing it shows its plain colour.

Lights

e.add<Light>(Light::directional({1.0f, 0.95f, 0.85f}, 1.1f));          // the sun: rays along the entity's -Z
e.add<Light>(Light::point({1.0f, 0.62f, 0.28f}, 10.0f, 12.0f));        // colour, intensity, range
e.add<Light>(Light::spot({0.55f, 0.8f, 1.0f}, 380.0f, 40.0f, 32.0f));  // colour, intensity, range, cone angle (degrees)
e.transform().lookAt(target);                                          // aim sun and spot lights
field meaning
type, color, intensity sun: intensity 1 lights a surface facing it with its plain colour. Point/spot lights fall off with the inverse square of the distance, so intensity is the brightness one world unit away
range point/spot: the distance at which the light has faded to exactly nothing (smooth window, no hard edge)
spotAngleDeg, spotSoftness spot: full cone angle; 0 = hard edge .. 1 = fades from the axis outwards
castShadows sun and spot lights (point lights do not cast shadows)
shadowBias multiplier on the automatic depth bias: raise it for acne, lower it for floating shadows
visible / setVisible a hidden light is off

A world without any Light gets a default sun, so a bare scene is not black.

Environment

The first enabled Environment in the world is used (defaults apply without one).

Environment& env = world.createEntity("environment").add<Environment>();
env.ambientSky = {0.40f, 0.46f, 0.58f};     // hemisphere ambient: surfaces facing up get the sky colour,
env.ambientGround = {0.22f, 0.19f, 0.16f};  // surfaces facing down the ground colour, blended by the normal
env.ambientIntensity = 1.0f;
env.exposure = 1.0f;
env.toneMapping = ToneMapping::Aces;        // None (clamp) | Reinhard | Aces
env.fogColor = {0.62f, 0.70f, 0.82f};
env.fogDensity = 0.004f;                    // exponential-squared fog, 0 = off
env.shadows = true;                         // master switch
env.shadowResolution = 2048;                // texels per side, rounded to a power of two in [256, 4096]
env.shadowDistance = 45.0f;                 // how far from the camera the sun's shadow reaches
env.shadowSoftness = 1.5f;                  // filter radius in texels (0 = hard edges)
env.shadowStrength = 1.0f;                  // 0 = no visible shadow .. 1 = fully dark
env.maxShadowLights = 3;                    // shadow maps per frame (0..3): the sun first, then spot lights
env.zoneCellSize = 0.5f;                    // ambient zone grid resolution, world units per cell
env.zoneMaxCells = 262144;                  // the grid is coarsened automatically beyond this many cells

Ambient zones

An AmbientZone gives a region of space its own ambient light: a dark cellar inside a bright level, a warm glow around a lamp, a cold shade under a bridge. Zones can have any shape, are configured and switched by components, and are evaluated per pixel, so a large mesh that crosses a zone boundary shades correctly across it.

Entity cellar = world.createEntity("cellar");
cellar.position() = {-9.5f, 3, -9};
AmbientZone& z = cellar.add<AmbientZone>(AmbientZone::box({7, 6, 6}, /*sky*/ {0.07f, 0.08f, 0.09f}, /*ground*/ {0.03f, 0.03f, 0.03f}));
z.mode = ZoneMode::Multiply;         // darken what is below it
z.falloff = 2.0f;                    // fades in over 2 world units outside its boundary

cellar.get<AmbientZone>().setComponentEnabled(false);   // switch it off (and on again)
shape defined by notes
Box size centred on the entity
Sphere radius an ellipsoid when the entity is scaled non-uniformly
Capsule radius, height (end to end) along local Y, centred
Cylinder radius, height along local Y, centred
Polygon polygon (outline on the local XZ plane), height extruded from y = 0 up to height; any simple outline, concave included (a star works)
Mesh mesh any closed mesh in local space, e.g. an extruded SVG shape: AmbientZone::fromMesh(model->find("pyramid")->mesh, sky, ground)

Factories: AmbientZone::box / sphere / polygonal / fromMesh; the other shapes are set through the fields.

field meaning
sky, ground, intensity the zone's ambient light from above and from below (blended by the surface normal, like the Environment's), times intensity
mode Replace: the zone's ambient takes the place of what is below it. Add: adds it. Multiply: tints / darkens what is below (1 = unchanged)
falloff world units over which the zone fades in outside its boundary (smooth); 0 = hard edge
priority overlapping zones are applied lowest priority first, so the highest ends up on top (equal priorities: hierarchy order)
  • The shape lives in the entity's local space, so moving, rotating or scaling the entity (or any ancestor) moves, turns and resizes the zone: a zone parented to the player follows the player.
  • Where no zone applies the Environment's hemisphere ambient is used, and it is the base the zones are blended onto.
  • How it works: all zones are baked into one 3D grid (Environment::zoneCellSize, coarsened automatically beyond zoneMaxCells or a texture-size limit), stored as a 2D atlas, and sampled with trilinear filtering in the pixel shader. Closed meshes are voxelised once per mesh and size and cached.
  • Cost: nothing is rebaked while nothing changes, and a zone that moves (a zone on the player) costs what the zone covers, not what the grid holds. Each zone's shape field is cached until that zone's own shape or transform changes; a bake works out the colour only of the cells that a changed zone covered before or covers now (every other cell keeps the colour it had: it is a function of the zones that reach it, and those are unchanged), rewrites only the texels that came out different, and the renderer uploads only those rectangles to the GPU (AmbientGrid::updated) instead of both whole atlases. On the demo's level (~100,000 cells; a sphere zone of 2 units walking at 6 units per second; one core) a bake takes about 0.3 ms instead of 1.0-1.3 ms, and the upload is 12-24 KB per frame instead of 0.9-1 MB. Two things that used to rebuild everything when a zone walked now change rarely:
    • The grid's extent is rounded out to blocks of cells (8 across, 4 up; smaller blocks, down to exact, when the cell budget has no room for them, so the resolution never drops because of it). A zone walking out of the others' bounding box used to resize the grid on every cell it crossed (a full rebake and new textures: 62 times in a 14-second walk along the demo's south edge); now it does so when it crosses a block edge (10 times).
    • The colour scale is rounded up to a step of a ladder (1, 1.25, 1.5, 1.75, 2, 2.5, ... four steps per octave). Texels are stored divided by the brightest colour in the grid, so an exact scale changed (and re-encoded every texel) whenever a bright zone and a moving one overlapped by a little more or less. On the ladder it only changes when the brightest colour crosses a rung (and the encoding keeps its 8 bits: the loss is below one step of the gamma curve). The grid is still exactly what a bake from scratch gives (both are functions of the zones alone; the tests compare them byte for byte after every edit). A bake that changes the grid's layout (the zone crossed a block edge, a zone appeared far away, zoneCellSize changed) still costs a few milliseconds (it re-encodes and re-uploads everything). Raise zoneCellSize for big worlds: cost scales with the cell count.

How a frame is lit

  • Shading is physically based: GGX specular with Smith/Schlick visibility, Schlick Fresnel, an energy-conserving diffuse term and a metal/rough workflow (metallic, roughness). Ambient (hemisphere or zone) lights the diffuse term and gives a soft, Fresnel-weighted reflection of the sky/ground colours (there are no environment maps or probes). Emission is added on top; then exponential-squared fog, exposure and tone mapping.
  • Lights: up to 16 lights are shaded per frame: suns first (at most 4, strongest first), then the point and spot lights that matter most to the camera. Lights beyond the budget, hidden lights and lights with no brightness are dropped.
  • Shadows: up to 3 shadow maps per frame (Environment::maxShadowLights): the first shadow-casting sun, then shadow-casting spot lights in order. The sun's map is an orthographic fit to the part of the camera's view up to shadowDistance, snapped to whole texels so shadows do not shimmer while the camera moves or turns, and it fades out towards the far end. Spot maps cover the cone. Filtering is a rotated 12-tap disk (shadowSoftness texels of radius) with normal-offset and slope-scaled bias against acne and light leaks.
  • Per mesh: castShadows, receiveShadows, unlit. Hidden meshes cast no shadow.
  • Materials (see above) change what goes into that model, not the model: albedo (number x map x vertex colour x tint), the diffuse weight, F0 = mix(0.08 x specular x specularColor, albedo, metalness), roughness, the normal map's tilt of the shading normal (shadows keep biasing along the geometry's own normal) and emission. A draw without a material evaluates none of it and produces the same pixels as before materials existed.
  • Colours are authored as 0..1 values and lit in linear light (gamma 2.2).

Camera

Camera{ fovDeg = 60, nearPlane, farPlane, orthographic, orthoSize } (vertical field of view, or vertical extent in world units when orthographic). It looks down the entity's -Z.

Text

Text (screen-space label): set value, position (pixels from the top-left), size, color - that is how HUDs are made. It is a Renderable, so it hides like any renderer.

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, tint and material 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).
  • Colliders follow their entity's transform. The shape is sized from the entity's world scale (parents included) and its offset scales with it: scale the entity, or any ancestor, and the collider grows or shrinks with it; rotate or move a parent and the collider goes along. A box scales per axis, a sphere stays round (the largest axis wins), a capsule or cylinder takes its width from the larger of X/Z and its length from Y, a hull or mesh scales per axis.
  • Mirroring (a negative scale on the entity or any ancestor) flips whatever can land on the other side of the entity: an off-centre offset, a ConvexHull, a Mesh. A centred box, sphere, capsule or cylinder looks the same mirrored, so flipping those costs nothing (no new shape). A hull scaled alike on every axis, and any mesh, is a cheap wrapper around one shared shape; a hull stretched unevenly gets its own, built from the scaled points so it keeps the proportions of its mesh.
  • Editing shape, size, radius, height, offset or mesh on a live collider takes effect by itself on the next step: no rebuild(). Static and kinematic bodies swap the shape in place; a dynamic body is rebuilt with its linear and angular velocity carried over (its contact events start over: collisionEnter fires again). Shapes stay shared (and are released again) while a scale animates.
  • Surface and body settings (friction, restitution, trigger, mass, damping, ...) are read when the body is created: change them at runtime, then call rebuild() on the component.
  • Sync per fixed step: entity -> body (kinematic bodies are moved, static/dynamic bodies are teleported only if you moved the entity: a change under 0.1 mm or 0.01 degrees is ignored and slow drifts add up until they count), simulate, then dynamic bodies -> entity. That write-back is position and rotation only: the scale you authored, and the picture it makes, stay as they are, whichever signs the scales on the entity or its parents have. Animate kinematic parts from fixedUpdate() so they get a pose per step.
  • 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. Its capsule follows the entity's scale (radius from X/Z, height from Y) and keeps its fall speed when it is rebuilt.
  • Joint: Fixed, Point, Hinge, Distance, Slider, created in world space from the current poses.
  • world.physics().raycast(origin, dir, maxDist, hit), setGravity().
  • Hiding an entity does not touch its collider (see Showing and hiding).

eng::gfx: raylib's API, the engine's way

#include <engine/gfx.hpp> (the umbrella engine.hpp includes it for you when it is part of the build, and defines ENGINE_HAS_GFX). Every raylib function from raylib.h, and from rlgl.h and raymath.h when present, is available as eng::gfx::<name>:

raylib engine
DrawCircle(...), LoadTexture(...) gfx::drawCircle(...), gfx::loadTexture(...): first letter lower-case
rlBegin(...), Vector3Add(...) gfx::rlBegin(...), gfx::vector3Add(...)
KEY_SPACE, FLAG_VSYNC_HINT, enumerators, #define constants gfx::KEY_SPACE, gfx::FLAG_VSYNC_HINT: raylib's names
RED, SKYBLUE, ... gfx::colors::Red, gfx::colors::SkyBlue
Vector2, Vector3, Quaternion, Color, BoundingBox the engine's own Vec2, Vec3, Quat, Rgba, Aabb (the same type, layout checked at compile time)
Matrix passed by value Mat4 (converted by the wrapper)
everything else (Texture2D, Font, Model, Camera3D, Rectangle, Ray, ...) raylib's own layout under raylib's own name, in eng::gfx
const char* parameters an extra overload taking std::string
TextFormat(...), TraceLog(...) gfx::textFormat(...) returns a std::string; gfx::traceLog(level, fmt, ...) takes printf arguments
struct Overlay : Component {
    Entity camera, target;

    void drawGizmos() {                                       // 3D, inside the camera's view
        gfx::drawSphereWires(target.transform().worldPosition(), 1.0f, 12, 12, gfx::colors::Orange);
    }
    void drawGui() {                                          // 2D, on top of the finished frame
        const Vec2 p = gfx::worldToScreen(camera, target.transform().worldPosition());
        gfx::drawText("target", static_cast<int>(p.x), static_cast<int>(p.y), 20, gfx::colors::White);
        gfx::drawFPS(10, 10);
    }
};

Rules of the road:

  • Draw from drawGizmos() / drawGui(). run() owns the window, the frame and the 3D camera: InitWindow, CloseWindow, BeginDrawing, EndDrawing, BeginMode3D, ClearBackground, SetTargetFPS, ... exist in eng::gfx for completeness, but the engine calls them itself; use RunConfig for the window.
  • Anything that needs the window (loadTexture, loadFont, loadModel, loadShader, audio, ...) must run after it exists: from a component callback during run(), not before it.
  • Do not include <raylib.h>, <rlgl.h> or <raymath.h> before <engine/gfx.hpp> in the same file: they define macros (RED, PI, ...) that would corrupt the declarations, and the headers stop you with an #error. Including them after is fine, and qualifying names (gfx::...) is always safe. A file that includes engine.hpp and raylib.h together keeps working: the umbrella header then simply leaves eng::gfx out (test ENGINE_HAS_GFX if your code must also build without it).
  • Glue between the engine and raylib, hand-written in gfx.hpp: gfx::toCamera3D(entity) (or toCamera3D(transform, camera)) gives the raylib Camera3D of a camera entity from its world transform and Camera component; gfx::worldToScreen(cameraEntity, pos) and gfx::screenRay(cameraEntity, screenPos) do the projections.

Where it comes from. The wrappers are not written by hand: tools/gfx_gen reads raylib.h (+ rlgl.h, raymath.h) of the raylib that is being built, at configure time, with its own small C preprocessor (so #if/#ifdef blocks follow the same defines as your compiler), and writes the public headers and the wrapper sources, so eng::gfx always matches the raylib version you build against. Because nothing generated can be trusted blindly, every step is checked and every failure degrades gracefully instead of breaking the engine:

  • every generated source is compiled against raylib's real headers (including exact-type checks of struct fields that the engine's types stand in for); an optional group (rlgl, raymath) that fails is left out with a warning, a failing core group switches eng::gfx off (the engine still builds, without it);
  • the number of functions found is compared with the number of RLAPI / RMAPI declarations in the headers;
  • a generated self-test (a recording stand-in for raylib that checks every wrapper passes every argument, in order, unchanged; hundreds of checks) is built and run once at configure time, and again as the gfx_selftest ctest;
  • results are cached on a fingerprint of the generator, the headers and the definitions, so re-configuring is instant.

Items that cannot be wrapped safely (variadic functions other than TextFormat/TraceLog, function-pointer parameters, types the generator does not know) are skipped and listed in <build>/gfx_gen/out/gfx_report.txt and in the configure log. -DENGINE_GFX_STRICT=ON turns any degradation into an error; -DENGINE_BUILD_GFX=OFF turns eng::gfx off.

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().
  • Hiding (setVisible, visible, childrenVisible) is visual only; enabling/disabling (setEntityEnabled, setComponentEnabled) switches behaviour off.

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, so they also work as AmbientZone meshes. Shading normals are averaged per welded position across edges bending less than smoothAngleDeg (default 40) and left crisp beyond it: circles and arcs shade as true curves, gentle lofts (e=) shade smoothly across levels, and sharp edges (wall/cap, ledges, pyramid faces) stay crisp. Caps of flat shapes are flat.

Testing

  • engine_tests (tests/tests.cpp, headless, no window or GL): 67 tests over the ECS and transforms, extrusion and SVG (including the normals), visibility rules, light budgeting and shadow-map fitting (coverage, texel snapping), ambient zones (every shape against its definition, blending, falloff, mesh voxelisation, the incremental bake against a from-scratch bake byte for byte, exact gamma encoding, hostile input; a zone that walks costs what it covers, the rounded grid extent, the colour-scale ladder, and a GPU copy that is only ever updated the way the renderer does it stays equal to the grid), textures (sampling, wrapping, decoders, sharing), mesh texture coordinates, materials (defaults, the clamped values the shader gets, which UV source a draw uses, batches split per material), the frame planner (what a frame draws and every shader uniform), collider maths (world-scale dimensions, which scales need a new shape, and the write-back rotation for every combination of mirrored entity and mirrored parent), the eng::gfx camera helpers and, with the real physics backend, colliders following their entities: rays are dropped on every kind of collider (box, hull, triangle mesh; scaled, offset, turned, mirrored, under flipped parents, kinematic, spinning) to check that each sits exactly where its entity is drawn, plus scaled sensors, dynamic bodies that are rescaled at runtime and characters whose capsule follows their scale.
  • gfx_selftest: the generated eng::gfx wrappers against a recording stand-in for raylib.
  • gpu_check (-DENGINE_BUILD_GPU_CHECK=ON, Linux): src/render.cpp compiled against a software OpenGL implementation of the raylib calls it makes (tools/gpu_check/gl_shim.cpp), run on Mesa's llvmpipe through EGL (no display, no GPU). It renders scenes and compares the pixels with an independent CPU model of the lighting, exact shadow ray tests and the CPU ambient grid. For materials the model evaluates what material.hpp documents (every property as a number and as a map, the normal map's tilt direction, the texture orientation) and is compared on every face of a box, with the mesh's UVs and with the world projection, on an instance mirrored in x, unlit, with clamped / pixelated textures, a texture decoded from a PNG file, and the draw calls of one mesh with four materials.
  • -DENGINE_SANITIZE=ON runs all of the above with ASan + UBSan on the engine's own code.

Layout

CMakeLists.txt                targets: engine_core, engine_render, engine_gfx (generated), engine (what you link), demo, instancing, tests
cmake/                        cpu.cmake (CPU-level probe), deps.cmake (pinned FetchContent), gfx.cmake (eng::gfx: generate, check, cache)
include/engine/               engine.hpp (the one header) + math ecs input texture material graphics model resources physics app, gfx.hpp (eng::gfx glue)
include/engine/detail/        internal, not included by engine.hpp: svg parser, extrusion, collider maths, scene gathering / light
                              budget / shadow fitting / ambient zones (scene.hpp), the frame planner (frame.hpp), raw input state
src/                          ecs visibility input resources texture material physics (Jolt, pimpl) svg (pugixml) extrude (Clipper2) spawn
                              scene ambient frame (lights, shadows, zones, draw plan; headless) | render app shaders (raylib)
tools/gfx_gen/                the generator behind eng::gfx: C preprocessor, declaration parser, wrapper + self-test emitters
tools/gpu_check/              optional: the renderer on a software OpenGL driver, pixel-compared with a CPU model
examples/demo, instancing     the clean entry point in practice
tests/tests.cpp               headless suite: 67 tests

Limitations

  • One Collider per entity (no compound colliders from child entities).
  • Colliders follow rotation, uniform and per-axis scale and mirroring through any hierarchy. The one thing they cannot follow exactly is a dynamic body under a parent that is both rotated and scaled unevenly (a sheared parent): a rigid body cannot shear, so it is shown with the closest rotation.
  • Rebuilding a dynamic body for a new scale restarts its contact events (collisionEnter fires again for what it touches).
  • The lit shader is GLSL 330 (desktop GL only). Materials: six maps (albedo, diffuse, specular, metalness, roughness, normal; one texture unit each, so a draw uses 11 of the 16 a GL 3.3 driver must have), RGBA8 textures decoded as gamma 2.2 (not the exact sRGB curve), no emission, ambient-occlusion, height/parallax or detail maps; an albedo map's alpha makes the surface translucent but transparent objects are not sorted. UvMode::World samples every map three times per pixel (one per axis) and a world-projected texture does not move with its object.
  • Lighting: at most 16 lights and 3 shadow maps per frame; point lights do not cast shadows; no global illumination, environment maps or light probes (ambient is the hemisphere / zone colour); ambient zones are a baked 3D grid (resolution = zoneCellSize), so detail finer than a cell is smoothed and Mesh zones must be closed meshes.
  • destroy() while iterating an EntityIterator outside World::update is not safe.
  • eng::gfx wraps what the generator can wrap safely; anything else is listed in gfx_report.txt. Functions that own the frame or window (BeginDrawing, InitWindow, ...) are there but belong to run().
S
Description
No description provided
Readme
524 KiB
0 Stars 1 Watchers 0 Forks
Languages
C++ 96.4%
CMake 3.6%