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
enginetarget from your own CMake project (target_link_libraries(game PRIVATE engine));engine_corealone 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 usesrun(); under CI usexvfb-runwith 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
Replacezone 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
Replacezone 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
Multiplyzone withintensity = 0.1keeps 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 bulbMaterialevery frame), a small warmAddzone around each lamp, and the sun through the window in the west wall (the roof and walls shadow it from the rest). - The
Zkey switches all zones off to compare with the plain hemisphere ambient;Mswitches 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 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(), andsetVisible(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(); 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
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)
visiblecontrols the component itself,childrenVisiblethe subtree below its entity, so a parent can stay visible while its children are hidden (the demo'sCkey 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 aVisibilitycomponent to carry the switch;entity.add<Visibility>()does the same by hand. Disabling aRenderablecomponent (disableComponent()) also stops it from hiding children.AmbientZoneis 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.
albedoMapand 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::Meshis the mesh's own (MeshData::texcoords;Resources::boxandspherehave 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::Worldprojects 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) isMeshwhen the mesh has UVs, elseWorld.uvScale(repeats per unit) anduvOffsetapply 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; throwsstd::runtime_errorwhen it cannot read the file). Without a file:Texture::create(w, h, rgba),Texture::solid(colour),Texture::checker(...)andTexture::normalMapFromHeight(heightMap, strength)(see the demo, which makes all of its textures in code). A program with no window can install its own decoder withsetImageLoader.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
Flickerin 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
materialset, the renderer's ownroughness,metallicandemissionare not used (thetintstill is). A defaultMaterial::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 beyondzoneMaxCellsor 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,
zoneCellSizechanged) still costs a few milliseconds (it re-encodes and re-uploads everything). RaisezoneCellSizefor 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 toshadowDistance, 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 (shadowSoftnesstexels 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
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, tint and material 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). - Colliders follow their entity's transform. The shape is sized from the entity's world scale (parents included) and its
offsetscales 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, aConvexHull, aMesh. 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,offsetormeshon a live collider takes effect by itself on the next step: norebuild(). 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:collisionEnterfires 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 callrebuild()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 = 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. 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 ineng::gfxfor completeness, but the engine calls them itself; useRunConfigfor the window. - Anything that needs the window (
loadTexture,loadFont,loadModel,loadShader, audio, ...) must run after it exists: from a component callback duringrun(), 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 includesengine.hppandraylib.htogether keeps working: the umbrella header then simply leaveseng::gfxout (testENGINE_HAS_GFXif your code must also build without it). - Glue between the engine and raylib, hand-written in
gfx.hpp:gfx::toCamera3D(entity)(ortoCamera3D(transform, camera)) gives the raylibCamera3Dof a camera entity from its world transform andCameracomponent;gfx::worldToScreen(cameraEntity, pos)andgfx::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 switcheseng::gfxoff (the engine still builds, without it); - the number of functions found is compared with the number of
RLAPI/RMAPIdeclarations 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_selftestctest; - 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::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().- 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):
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, so they also work as
AmbientZonemeshes. Shading normals are averaged per welded position across edges bending less thansmoothAngleDeg(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), theeng::gfxcamera 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 generatedeng::gfxwrappers against a recording stand-in for raylib.gpu_check(-DENGINE_BUILD_GPU_CHECK=ON, Linux):src/render.cppcompiled 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 whatmaterial.hppdocuments (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=ONruns 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
Colliderper 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 (
collisionEnterfires 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::Worldsamples 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 andMeshzones must be closed meshes. destroy()while iterating anEntityIteratoroutsideWorld::updateis not safe.eng::gfxwraps what the generator can wrap safely; anything else is listed ingfx_report.txt. Functions that own the frame or window (BeginDrawing,InitWindow, ...) are there but belong torun().