Materials

This commit is contained in:
alejo committed 2026-10-24 12:24:05 -03:00
1 parent 3cd4ba3f38
commit f3ca6b5721
49 files changed
+13055 -1351

No files matched your search

+80 -6
View File
@@ -13,26 +13,37 @@ endif()
option(ENGINE_BUILD_RENDER "Build the window/renderer (raylib), run() and the examples" ON)
option(ENGINE_BUILD_TESTS "Build the headless test executable" ON)
option(ENGINE_BUILD_EXAMPLES "Build the examples (needs ENGINE_BUILD_RENDER)" ON)
option(ENGINE_BUILD_GFX "Generate eng::gfx, the engine's equivalent of every raylib/rlgl/raymath function, from raylib's headers (needs ENGINE_BUILD_RENDER)" ON)
option(ENGINE_BUILD_GPU_CHECK "Build tools/gpu_check: runs the renderer on a software OpenGL driver (Linux, Mesa EGL) and compares its pixels with a CPU model" OFF)
option(ENGINE_SANITIZE "Build the engine's own code with ASan + UBSan (not the dependencies)" OFF)
include(cmake/deps.cmake)
include(cmake/gfx.cmake)
# ---------------------------------------------------------------------------
# engine_core: ECS, math, input state, resources, physics (Jolt), SVG (pugixml) and
# extrusion (Clipper2). No window and no OpenGL, so it links and runs headless.
# engine_core: ECS, math, input state, resources, textures and materials, physics (Jolt), SVG (pugixml) and
# extrusion (Clipper2), plus everything the renderer decides without a GPU: visibility,
# lights and shadow fitting, ambient zones and the per-frame draw plan.
# No window and no OpenGL, so it links and runs headless.
# ---------------------------------------------------------------------------
add_library(engine_core STATIC
src/ecs.cpp
src/visibility.cpp
src/input.cpp
src/resources.cpp
src/physics.cpp
src/svg.cpp
src/extrude.cpp
src/spawn.cpp)
src/spawn.cpp
src/scene.cpp
src/ambient.cpp
src/texture.cpp
src/material.cpp
src/frame.cpp)
target_include_directories(engine_core PUBLIC include PRIVATE src)
target_link_libraries(engine_core
PUBLIC EnTT::EnTT
PRIVATE Jolt Clipper2 pugixml::pugixml raylib)
PRIVATE Jolt Clipper2 pugixml::pugixml)
# ---------------------------------------------------------------------------
# engine_render: raylib window + GPU-instanced renderer behind eng::run().
@@ -43,6 +54,20 @@ if(ENGINE_BUILD_RENDER)
target_include_directories(engine_render PRIVATE src)
target_link_libraries(engine_render PUBLIC engine_core PRIVATE raylib)
# eng::gfx (include <engine/gfx.hpp>): generated from raylib's own headers, see cmake/gfx.cmake. Optional: when it
# cannot be generated the engine still builds, without it.
if(ENGINE_BUILD_GFX)
engine_configure_gfx()
if(ENGINE_GFX_OK)
add_library(engine_gfx STATIC ${ENGINE_GFX_SOURCES})
target_include_directories(engine_gfx PUBLIC "${ENGINE_GFX_INCLUDE_DIR}")
target_compile_definitions(engine_gfx PRIVATE ${ENGINE_GFX_DEFINITIONS})
target_link_libraries(engine_gfx PUBLIC engine_core PRIVATE raylib)
add_library(engine::gfx ALIAS engine_gfx)
target_link_libraries(engine_render PUBLIC engine_gfx)
endif()
endif()
add_library(engine INTERFACE)
target_link_libraries(engine INTERFACE engine_render)
add_library(engine::engine ALIAS engine)
@@ -61,16 +86,65 @@ endif()
add_library(engine::core ALIAS engine_core)
# ---------------------------------------------------------------------------
# gpu_check (optional): src/render.cpp and src/app.cpp compiled against a software OpenGL
# implementation of the raylib calls they make (tools/gpu_check/gl_shim.cpp) instead of raylib itself,
# then 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.
# Only raylib's HEADERS are used (raylib is not linked, the shim provides those symbols).
# ---------------------------------------------------------------------------
if(ENGINE_BUILD_GPU_CHECK)
if(NOT ENGINE_BUILD_RENDER OR NOT TARGET raylib)
message(FATAL_ERROR "ENGINE_BUILD_GPU_CHECK needs ENGINE_BUILD_RENDER (it uses raylib's headers)")
endif()
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR "ENGINE_BUILD_GPU_CHECK needs Linux (EGL + Mesa)")
endif()
find_package(PNG REQUIRED)
add_executable(gpu_check
tools/gpu_check/render_check.cpp
tools/gpu_check/gl_shim.cpp
src/render.cpp
src/app.cpp)
target_include_directories(gpu_check PRIVATE src tools/gpu_check
$<TARGET_PROPERTY:raylib,INTERFACE_INCLUDE_DIRECTORIES>)
target_link_libraries(gpu_check PRIVATE engine_core PNG::PNG ${CMAKE_DL_LIBS})
if(ENGINE_BUILD_TESTS)
add_test(NAME gpu_check COMMAND gpu_check)
set_tests_properties(gpu_check PROPERTIES
ENVIRONMENT "ENGINE_GPU_CHECK_OUT=${CMAKE_BINARY_DIR}/gpu_check_out"
LABELS gpu)
endif()
endif()
if(ENGINE_BUILD_TESTS)
enable_testing()
add_executable(engine_tests tests/tests.cpp)
target_link_libraries(engine_tests PRIVATE engine_core)
if(TARGET engine_gfx)
# Only the generated HEADERS (the hand-written eng::gfx helpers are tested here); nothing of raylib is linked.
target_include_directories(engine_tests PRIVATE "${ENGINE_GFX_INCLUDE_DIR}")
endif()
add_test(NAME engine_tests COMMAND engine_tests)
# The generated eng::gfx wrappers against a recording stand-in for raylib: no window, no GPU, raylib is not linked
# (only its headers are used). Checks every wrapper passes every argument, in order, unchanged.
if(TARGET engine_gfx AND ENGINE_GFX_SELFTEST_SOURCES)
add_executable(gfx_selftest ${ENGINE_GFX_SELFTEST_SOURCES})
target_include_directories(gfx_selftest PRIVATE
"${ENGINE_GFX_INCLUDE_DIR}" "${ENGINE_GFX_SELFTEST_INCLUDE_DIR}" "${ENGINE_GFX_RAYLIB_INCLUDE}"
$<TARGET_PROPERTY:engine_core,INTERFACE_INCLUDE_DIRECTORIES>)
target_compile_definitions(gfx_selftest PRIVATE ${ENGINE_GFX_DEFINITIONS})
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(gfx_selftest PRIVATE -ffp-contract=off) # raymath results are compared bit for bit
endif()
add_test(NAME gfx_selftest COMMAND gfx_selftest)
endif()
endif()
# Sanitizers for our own code only.
if(ENGINE_SANITIZE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
foreach(t engine_core engine_render demo instancing engine_tests)
foreach(t engine_core engine_render engine_gfx demo instancing engine_tests gfx_selftest gpu_check)
if(TARGET ${t})
target_compile_options(${t} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(${t} PRIVATE -fsanitize=address,undefined)
@@ -80,7 +154,7 @@ endif()
# Warnings for our own code only (never for the dependencies).
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
foreach(t engine_core engine_render demo instancing engine_tests)
foreach(t engine_core engine_render demo instancing engine_tests gpu_check)
if(TARGET ${t})
target_compile_options(${t} PRIVATE -Wall -Wextra -Wno-unused-parameter)
endif()
+26
View File
@@ -0,0 +1,26 @@
{
"configurations": [
{
"name": "x64-Debug",
"generator": "Ninja",
"configurationType": "Debug",
"inheritEnvironments": [ "msvc_x64_x64" ],
"buildRoot": "${projectDir}\\out\\build\\${name}",
"installRoot": "${projectDir}\\out\\install\\${name}",
"cmakeCommandArgs": "",
"buildCommandArgs": "",
"ctestCommandArgs": ""
},
{
"name": "x64-Release",
"generator": "Ninja",
"configurationType": "RelWithDebInfo",
"buildRoot": "${projectDir}\\out\\build\\${name}",
"installRoot": "${projectDir}\\out\\install\\${name}",
"cmakeCommandArgs": "",
"buildCommandArgs": "",
"ctestCommandArgs": "",
"inheritEnvironments": [ "msvc_x64_x64" ]
}
]
}
+445 -25
View File
@@ -1,8 +1,10 @@
# engine
A small C++20 3D engine: an EnTT-based entity/component layer with Unity-style callbacks (no `virtual`),
Jolt physics as components, GPU-instanced raylib rendering, and a pipeline that turns **labelled SVG
drawings into 3D models** (pugixml parser + Clipper2 geometry).
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.
```cpp
#include <engine/engine.hpp>
@@ -19,6 +21,10 @@ int main() {
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};
@@ -32,7 +38,7 @@ No window, renderer, raylib call or loop in game code. `main()` sets up a world,
| library | version | used for |
|---|---|---|
| raylib | 6.0 | window, input, rendering (only inside `engine_render`) |
| 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 |
@@ -46,18 +52,66 @@ Everything is fetched by CMake at pinned tags; you need a C++20 compiler and CMa
cmake -S . -B build
cmake --build build -j
ctest --test-dir build --output-on-failure # headless: no window, no GL needed
./build/demo # WASD, SPACE, right-mouse orbit, F shoots
./build/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) with no raylib/GL.
* `-DENGINE_BUILD_RENDER=OFF` builds only `engine_core`; `-DENGINE_SANITIZE=ON` builds the engine's own code with ASan + UBSan.
`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
@@ -80,7 +134,7 @@ still crashes, run `gdb -batch -ex run -ex 'x/i $pc' ./build/engine_tests` and l
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`).
pimpl; raylib is only inside `engine_render` and the generated `eng::gfx` wrappers).
### Entry point
@@ -98,7 +152,7 @@ adds a default one. For headless use (servers, tests) call `world.update(dt)` yo
* **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()`.
* **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).
@@ -112,6 +166,8 @@ struct Mover : Component { // plain struct, no virtual
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)
};
```
@@ -121,12 +177,259 @@ struct Mover : Component { // plain struct, no virtual
`mousePosition()`, `mouseDelta()`, `mouseWheel()`, `windowSize()`, and `quit()`. Read it from `update()`/`lateUpdate()`
(a `fixedUpdate()` runs 0..N times per frame and can miss one-frame events).
### Rendering components
## Rendering
`MeshRenderer{ MeshPtr mesh; Rgba tint; }`, `Camera` (perspective/orthographic), `Light` (directional, aimed with
`lookAt`), and `Text` (screen-space label: set `value`, `position`, `size`, `color` - that is how HUDs are made).
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.
### Resources (nothing is duplicated)
| 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.)
```cpp
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):
```cpp
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).
```cpp
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
```cpp
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).
```cpp
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.
```cpp
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)
```cpp
MeshPtr m = Resources::box({1, 2, 3}); // same arguments -> the same shared object
@@ -139,20 +442,92 @@ The cache holds weak references: a resource lives exactly as long as something u
afterwards. Sharing pays off three times:
* **CPU**: one `Mesh` however many entities use it (clones share too).
* **GPU**: one buffer per `Mesh`, and every entity using it is drawn with **GPU instancing**: one draw call per distinct mesh, per-instance transform and tint in a single instance buffer uploaded once per frame, top-down transform pass (O(N)), frustum culling per instance. `showStats` prints `draw calls / instances / culled`. (`./build/instancing`: 20,000 cubes -> 1 draw call.)
* **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
## Physics
* A `Collider` makes a body. With a `RigidBody` (`Static / Kinematic / Dynamic`) it behaves as requested; without one it is static. One `Collider` per entity.
* Shapes: `Box, Sphere, Capsule, Cylinder, ConvexHull, Mesh` (triangle meshes are static-only; asked to be dynamic they fall back to a convex hull). Shapes are baked with the entity's world scale when the body is created; change a setting, then call `rebuild()`.
* Sync per fixed step: entity -> body (kinematic bodies are *moved*, static/dynamic bodies are teleported only if you moved the entity), simulate, then dynamic bodies -> entity.
* 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.
* `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*).
### Semantics worth knowing
## `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 |
```cpp
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).
@@ -160,6 +535,7 @@ afterwards. Sharing pays off three times:
* **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
@@ -185,19 +561,63 @@ Decisions made where the brief left room (each lives in one place and is easy to
* `color` defaults to the SVG `fill`. Stroke-only shapes (`fill:none`) become outlines via Clipper2 offsetting (`stroke-width`, `linecap`, `linejoin`), so a drawn line becomes a wall. Fill rules (`nonzero`/`evenodd`) are honoured, so holes and islands-in-holes work.
* Mapping: world X = svg x, world Z = svg y, Y = elevation, so the drawing looks identical from above. `ExtrudeOptions::center` puts the viewBox centre at the origin.
* Supported SVG: `g`, `path` (all commands incl. arcs), `rect` (with rx/ry), `circle`, `ellipse`, `line`, `polyline`, `polygon`, `transform`, `style`/presentation attributes, `display:none`. Skipped with a warning (`Model::warnings`): `<use>`, `<text>`, `<image>`; CSS stylesheets, gradients, clip paths and masks are ignored.
* Output meshes are closed, consistently wound surfaces: walls get smooth normals across edges bending less than `smoothAngleDeg`, caps are flat.
* 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
```
cmake/cpu.cmake deps.cmake CPU-level probe; pinned FetchContent declarations
include/engine/ engine.hpp (the one header) + math ecs input graphics model resources physics app
include/engine/detail/ internal: svg parser, extrusion, raw input state (not included by engine.hpp)
src/ ecs input resources physics(Jolt, pimpl) svg(pugixml) extrude(Clipper2) spawn | render app (raylib)
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: 31 tests, 359 checks
tests/tests.cpp headless suite: 67 tests
```
## Limitations
One `Collider` per entity (no compound colliders from child entities); the lit shader is GLSL 330 (desktop GL only, one directional light, no shadows, no texture support yet); `destroy()` while iterating an `EntityIterator` outside `World::update` is not safe.
* 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()`.
-5
View File
@@ -1,5 +0,0 @@
mkdir build -p
cd build
cmake .. -DUSE_AVX2=OFF -DUSE_AVX=OFF
cmake --build .
cd ..
+334
View File
@@ -0,0 +1,334 @@
# eng::gfx - the engine's equivalent of every raylib function.
#
# The wrappers are not written by hand. tools/gfx_gen reads raylib.h (+ rlgl.h and raymath.h) of the raylib that is
# being built, at configure time, and writes include/engine/gfx_api.hpp (+ gfx_rlgl_api.hpp, gfx_raymath_api.hpp)
# and the wrapper sources, so the wrappers always match the raylib version in use.
#
# Because nothing here can be trusted blindly, every step is checked, and every failure degrades gracefully instead
# of breaking the build of the engine:
# * the generator is compiled and run (a prebuilt one can be given with -DENGINE_GFX_GEN_EXECUTABLE=... when cross-compiling);
# * each generated source is compiled against raylib's real headers; a failing OPTIONAL group (rlgl, raymath) is
# dropped and the rest is regenerated, a failing core group switches eng::gfx off;
# * the generated self-test (a recording stand-in for raylib that checks every wrapper passes every argument, in
# order, unchanged - see tools/gfx_gen/emit_test.hpp) is built and run once;
# * the number of functions found is compared with the number of RLAPI / RMAPI lines in the headers.
# The configure log says what happened. -DENGINE_GFX_STRICT=ON turns every degradation into an error.
#
# Results are cached on a fingerprint of (generator sources, raylib headers, definitions): re-configuring is instant.
#
# Usage (CMakeLists.txt): engine_configure_gfx() -> sets ENGINE_GFX_OK and, when ON:
# ENGINE_GFX_SOURCES wrapper sources ENGINE_GFX_INCLUDE_DIR generated headers
# ENGINE_GFX_RAYLIB_INCLUDE raylib's header directory ENGINE_GFX_DEFINITIONS definitions raylib's headers are read with
# ENGINE_GFX_SELFTEST_SOURCES (empty when the self-test could not be built)
include_guard(GLOBAL)
set(ENGINE_GFX_STRICT OFF CACHE BOOL "Fail the configure step when eng::gfx cannot be generated (default: warn and build the engine without it)")
set(ENGINE_GFX_GEN_EXECUTABLE "" CACHE FILEPATH "A prebuilt gfx_gen to use instead of building one (needed when cross-compiling)")
function(_engine_gfx_problem what)
if(ENGINE_GFX_STRICT)
message(FATAL_ERROR "eng::gfx: ${what}")
endif()
message(WARNING "eng::gfx: ${what}\n"
" The engine is built without eng::gfx. (-DENGINE_GFX_STRICT=ON makes this an error, -DENGINE_BUILD_GFX=OFF turns eng::gfx off.)")
endfunction()
# The first few lines of a compiler log that look like errors.
function(_engine_gfx_errors log out_var)
string(REGEX MATCHALL "[^\n]*(error|Error|ERROR|undefined reference|LNK[0-9]+)[^\n]*" _lines "${log}")
list(LENGTH _lines _n)
if(_n GREATER 8)
list(SUBLIST _lines 0 8 _lines)
endif()
string(REPLACE ";" "\n " _text "${_lines}")
if(_text STREQUAL "")
string(SUBSTRING "${log}" 0 600 _text)
endif()
set(${out_var} "${_text}" PARENT_SCOPE)
endfunction()
function(_engine_gfx_count_lines file regex out_var)
file(STRINGS "${file}" _m REGEX "${regex}")
list(LENGTH _m _n)
set(${out_var} ${_n} PARENT_SCOPE)
endfunction()
function(engine_configure_gfx)
set(ENGINE_GFX_OK OFF PARENT_SCOPE)
# ---- raylib's headers ----------------------------------------------------------------------------------------
set(_dirs)
if(raylib_SOURCE_DIR)
list(APPEND _dirs "${raylib_SOURCE_DIR}/src")
endif()
get_target_property(_ifdirs raylib INTERFACE_INCLUDE_DIRECTORIES)
if(_ifdirs)
foreach(_d IN LISTS _ifdirs)
string(REGEX REPLACE "^\\$<BUILD_INTERFACE:(.*)>$" "\\1" _d "${_d}")
if(NOT _d MATCHES "\\$<")
list(APPEND _dirs "${_d}")
endif()
endforeach()
endif()
set(_hdr_dir "")
foreach(_d IN LISTS _dirs)
if(EXISTS "${_d}/raylib.h")
set(_hdr_dir "${_d}")
break()
endif()
endforeach()
if(_hdr_dir STREQUAL "")
_engine_gfx_problem("raylib.h was not found (looked in: ${_dirs})")
return()
endif()
set(_raylib_h "${_hdr_dir}/raylib.h")
set(_rlgl_h "")
set(_raymath_h "")
if(EXISTS "${_hdr_dir}/rlgl.h")
set(_rlgl_h "${_hdr_dir}/rlgl.h")
endif()
if(EXISTS "${_hdr_dir}/raymath.h")
set(_raymath_h "${_hdr_dir}/raymath.h")
endif()
# Definitions raylib's headers are read with (they decide e.g. the type of rlVertexBuffer::indices).
set(_defs)
get_target_property(_cd raylib INTERFACE_COMPILE_DEFINITIONS)
if(_cd)
foreach(_x IN LISTS _cd)
if(_x AND NOT _x MATCHES "\\$<")
list(APPEND _defs "${_x}")
endif()
endforeach()
endif()
if(DEFINED GRAPHICS AND GRAPHICS MATCHES "^GRAPHICS_API_") # raylib's own CMake option
list(APPEND _defs "${GRAPHICS}")
endif()
list(REMOVE_DUPLICATES _defs)
set(_bin "${CMAKE_BINARY_DIR}/gfx_gen")
set(_out "${_bin}/out")
set(_src_of_raylib gfx_core.cpp)
set(_src_of_rlgl gfx_rlgl.cpp)
set(_src_of_raymath gfx_raymath.cpp)
set(_inc_flags)
foreach(_x IN LISTS _defs)
list(APPEND _inc_flags "-D${_x}")
endforeach()
# ---- fingerprint -----------------------------------------------------------------------------------------------
set(_gen_dir "${PROJECT_SOURCE_DIR}/tools/gfx_gen")
file(GLOB _gen_files "${_gen_dir}/*.cpp" "${_gen_dir}/*.hpp")
list(SORT _gen_files)
set(_key "v1")
foreach(_f IN LISTS _gen_files _raylib_h _rlgl_h _raymath_h)
file(SHA256 "${_f}" _h)
string(APPEND _key "|${_h}")
endforeach()
string(APPEND _key "|${_defs}|${CMAKE_CXX_COMPILER_ID}${CMAKE_CXX_COMPILER_VERSION}|${ENGINE_GFX_GEN_EXECUTABLE}")
string(SHA256 _key "${_key}")
if("${ENGINE_GFX_CACHE_KEY}" STREQUAL "${_key}" AND ENGINE_GFX_CACHE_GROUPS MATCHES "raylib"
AND EXISTS "${_out}/gfx_core.cpp" AND EXISTS "${_out}/include/engine/gfx_api.hpp")
set(_groups ${ENGINE_GFX_CACHE_GROUPS})
set(_selftest "${ENGINE_GFX_CACHE_SELFTEST}")
set(_summary "${ENGINE_GFX_CACHE_SUMMARY}")
message(STATUS "engine: eng::gfx ${_summary} (cached)")
else()
# ---- the generator -------------------------------------------------------------------------------------------
if(ENGINE_GFX_GEN_EXECUTABLE)
set(_gen "${ENGINE_GFX_GEN_EXECUTABLE}")
else()
set(_gen "${_bin}/gfx_gen${CMAKE_EXECUTABLE_SUFFIX}")
message(STATUS "engine: building the eng::gfx generator")
unset(ENGINE_GFX_GEN_BUILT CACHE)
try_compile(ENGINE_GFX_GEN_BUILT "${_bin}/gen_build"
SOURCES "${_gen_dir}/gfx_gen.cpp"
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
COPY_FILE "${_gen}"
OUTPUT_VARIABLE _gen_log)
if(NOT ENGINE_GFX_GEN_BUILT)
_engine_gfx_errors("${_gen_log}" _err)
_engine_gfx_problem("the generator tools/gfx_gen could not be compiled:\n ${_err}")
return()
endif()
endif()
# ---- generate, compile-check, drop what does not compile --------------------------------------------------
set(_groups raylib)
if(_rlgl_h)
list(APPEND _groups rlgl)
endif()
if(_raymath_h)
list(APPEND _groups raymath)
endif()
set(_try_incs "${_out}/include\;${PROJECT_SOURCE_DIR}/include\;${_hdr_dir}")
set(_ok FALSE)
foreach(_round RANGE 1 4)
set(_args --raylib "${_raylib_h}" --out "${_out}")
if("rlgl" IN_LIST _groups)
list(APPEND _args --rlgl "${_rlgl_h}")
endif()
if("raymath" IN_LIST _groups)
list(APPEND _args --raymath "${_raymath_h}")
endif()
list(APPEND _args ${_inc_flags})
execute_process(COMMAND "${_gen}" ${_args}
RESULT_VARIABLE _rc OUTPUT_VARIABLE _gen_out ERROR_VARIABLE _gen_err)
if(NOT _rc EQUAL 0)
if(_groups STREQUAL "raylib")
_engine_gfx_problem("gfx_gen failed on ${_raylib_h} (${_rc}): ${_gen_err}")
return()
endif()
message(WARNING "eng::gfx: gfx_gen failed (${_rc}: ${_gen_err}); retrying with raylib.h alone")
set(_groups raylib)
continue()
endif()
# compile every group against the REAL headers (the generated code is what must fit them)
set(_bad)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
foreach(_g IN LISTS _groups)
unset(ENGINE_GFX_TRY_${_g} CACHE)
try_compile(ENGINE_GFX_TRY_${_g} "${_bin}/check_${_g}"
SOURCES "${_out}/${_src_of_${_g}}"
CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${_try_incs}"
COMPILE_DEFINITIONS ${_inc_flags}
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
OUTPUT_VARIABLE _log_${_g})
if(NOT ENGINE_GFX_TRY_${_g})
_engine_gfx_errors("${_log_${_g}}" _err)
if(_g STREQUAL "raylib")
_engine_gfx_problem("the wrappers generated from raylib.h do not compile against it:\n ${_err}")
return()
endif()
message(WARNING "eng::gfx: the wrappers generated from ${_g}.h do not compile against it, so ${_g}.h is left out:\n ${_err}")
list(APPEND _bad ${_g})
endif()
endforeach()
unset(CMAKE_TRY_COMPILE_TARGET_TYPE)
if(NOT _bad)
set(_ok TRUE)
break()
endif()
if(ENGINE_GFX_STRICT)
message(FATAL_ERROR "eng::gfx: generated wrappers for '${_bad}' do not compile (ENGINE_GFX_STRICT)")
endif()
list(REMOVE_ITEM _groups ${_bad})
endforeach()
if(NOT _ok)
_engine_gfx_problem("could not settle on a set of headers that compile")
return()
endif()
# ---- cross-check: did the generator see every function the headers declare? ----------------------------------
file(STRINGS "${_out}/gfx_report.txt" _sum_lines REGEX "^GFX_SUMMARY ")
set(_summary "")
foreach(_line IN LISTS _sum_lines)
if(_line MATCHES "^GFX_SUMMARY (raylib|rlgl|raymath) declared=([0-9]+) wrapped=([0-9]+) skipped=([0-9]+)")
set(_grp "${CMAKE_MATCH_1}")
set(_declared "${CMAKE_MATCH_2}")
set(_wrapped "${CMAKE_MATCH_3}")
set(_skipped "${CMAKE_MATCH_4}")
if(_grp STREQUAL "raylib")
_engine_gfx_count_lines("${_raylib_h}" "^RLAPI[ \t]" _expect)
elseif(_grp STREQUAL "rlgl")
_engine_gfx_count_lines("${_rlgl_h}" "^RLAPI[ \t]" _expect)
else()
_engine_gfx_count_lines("${_raymath_h}" "^RMAPI[ \t]" _expect)
endif()
if(NOT _declared EQUAL _expect)
set(_msg "gfx_gen found ${_declared} ${_grp} functions but the header has ${_expect} ${_grp} declaration lines (lines inside #if blocks that are off for this build are not wrapped, on purpose; anything else is a function the generator did not see); the report ${_out}/gfx_report.txt lists what was skipped")
if(ENGINE_GFX_STRICT)
message(FATAL_ERROR "eng::gfx: ${_msg}")
else()
message(WARNING "eng::gfx: ${_msg}")
endif()
endif()
string(APPEND _summary "${_grp} ${_wrapped}/${_declared}, ")
if(_skipped GREATER 0)
message(STATUS "engine: eng::gfx skipped ${_skipped} ${_grp} function(s) it cannot wrap safely; see ${_out}/gfx_report.txt")
endif()
endif()
endforeach()
string(REGEX REPLACE ", $" "" _summary "${_summary}")
set(_summary "wrapped ${_summary} functions")
# ---- the self-test: build it (compile + link against the recording stand-in) and run it once ---------------
set(_selftest OFF)
set(_st_srcs)
foreach(_g IN LISTS _groups)
list(APPEND _st_srcs "${_out}/${_src_of_${_g}}")
endforeach()
list(APPEND _st_srcs "${_out}/selftest/gfx_mock.cpp" "${_out}/selftest/gfx_selftest.cpp")
set(_st_flags ${_inc_flags})
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
list(APPEND _st_flags -ffp-contract=off) # the raymath oracles compare bit for bit
endif()
set(_st_incs "${_out}/include\;${_out}/selftest\;${PROJECT_SOURCE_DIR}/include\;${_hdr_dir}")
unset(ENGINE_GFX_SELFTEST_BUILT CACHE)
set(_st_exe "${_bin}/gfx_selftest_configure${CMAKE_EXECUTABLE_SUFFIX}")
try_compile(ENGINE_GFX_SELFTEST_BUILT "${_bin}/check_selftest"
SOURCES ${_st_srcs}
CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${_st_incs}"
COMPILE_DEFINITIONS ${_st_flags}
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
COPY_FILE "${_st_exe}"
OUTPUT_VARIABLE _st_log)
if(NOT ENGINE_GFX_SELFTEST_BUILT)
_engine_gfx_errors("${_st_log}" _err)
if(ENGINE_GFX_STRICT)
message(FATAL_ERROR "eng::gfx: its self-test does not build:\n ${_err}")
endif()
message(WARNING "eng::gfx: the self-test of the wrappers does not build, so it is not part of the tests:\n ${_err}")
elseif(CMAKE_CROSSCOMPILING)
set(_selftest ON)
message(STATUS "engine: eng::gfx self-test built (cross-compiling: not run)")
else()
execute_process(COMMAND "${_st_exe}" RESULT_VARIABLE _rr OUTPUT_VARIABLE _ro ERROR_VARIABLE _re TIMEOUT 300)
set(_selftest ON)
if(_rr EQUAL 0)
string(REGEX MATCH "gfx_selftest: ([0-9]+) checks" _m "${_ro}")
message(STATUS "engine: eng::gfx self-test passed (${CMAKE_MATCH_1} checks)")
string(APPEND _summary "; self-test ${CMAKE_MATCH_1} checks passed")
else()
string(REGEX MATCHALL "FAIL [^\n]*" _fails "${_ro}")
list(LENGTH _fails _nf)
if(_nf GREATER 10)
list(SUBLIST _fails 0 10 _fails)
endif()
string(REPLACE ";" "\n " _fails "${_fails}")
if(ENGINE_GFX_STRICT)
message(FATAL_ERROR "eng::gfx: the self-test of the wrappers FAILED:\n ${_fails}")
endif()
message(WARNING "eng::gfx: the self-test of the generated wrappers FAILED (${_rr}); the wrappers named below pass wrong values to raylib:\n ${_fails}\n ${_re}\n"
" eng::gfx is still built. The failing functions should not be used; see ${_out}/gfx_report.txt.")
string(APPEND _summary "; self-test FAILED")
endif()
endif()
set(ENGINE_GFX_CACHE_KEY "${_key}" CACHE INTERNAL "")
set(ENGINE_GFX_CACHE_GROUPS "${_groups}" CACHE INTERNAL "")
set(ENGINE_GFX_CACHE_SELFTEST "${_selftest}" CACHE INTERNAL "")
set(ENGINE_GFX_CACHE_SUMMARY "${_summary}" CACHE INTERNAL "")
message(STATUS "engine: eng::gfx generated from raylib's headers (${_summary})")
endif()
# ---- results for the caller -------------------------------------------------------------------------------------
set(_srcs)
foreach(_g IN LISTS _groups)
list(APPEND _srcs "${_out}/${_src_of_${_g}}")
endforeach()
set(_st_srcs)
if(_selftest)
set(_st_srcs ${_srcs} "${_out}/selftest/gfx_mock.cpp" "${_out}/selftest/gfx_selftest.cpp")
endif()
set(ENGINE_GFX_OK ON PARENT_SCOPE)
set(ENGINE_GFX_SOURCES ${_srcs} PARENT_SCOPE)
set(ENGINE_GFX_INCLUDE_DIR "${_out}/include" PARENT_SCOPE)
set(ENGINE_GFX_SELFTEST_INCLUDE_DIR "${_out}/selftest" PARENT_SCOPE)
set(ENGINE_GFX_RAYLIB_INCLUDE "${_hdr_dir}" PARENT_SCOPE)
set(ENGINE_GFX_DEFINITIONS ${_defs} PARENT_SCOPE)
set(ENGINE_GFX_SELFTEST_SOURCES ${_st_srcs} PARENT_SCOPE)
endfunction()
+61 -1
View File
@@ -2,7 +2,11 @@
<!-- Demo level. Every shape carries an Inkscape "label" (inkscape:label) with the extrusion labels:
y=<base elevation> h=<top elevation> e=(fraction,ratio)... color=#rrggbb
#group @id vpatch spatch only=(...) except=(...)
Open it in Inkscape and edit the labels in Object Properties (Ctrl+Shift+O). -->
Open it in Inkscape and edit the labels in Object Properties (Ctrl+Shift+O).
Besides the park, the level has a HOUSE (south of the start) and a walled GARDEN (south-east corner): the demo gives the
house a 90% dark ambient zone with a lamp in each room, and the garden - an outdoor area inside the outdoors - purple
ambient light. See "ambient zones" in main.cpp. -->
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100mm" height="100mm" viewBox="0 0 100 100">
@@ -49,4 +53,60 @@
<rect inkscape:label="vpatch h=3 color=#ff5a5a only=(@ramp)" x="80" y="10" width="4" height="16"
style="fill:#ff0000;fill-opacity:0.3"/>
</g>
<g inkscape:label="house walls #house y=0 h=2.6 color=#d9c9ad" inkscape:groupmode="layer" id="layer5">
<!-- A house south of the start: 0.3-thick walls, a door in the north wall, a window in the west wall and a doorway in the partition.
The whole house is one ambient zone in demo/main.cpp (90% dark) with a lamp in each room. -->
<rect inkscape:label="@house_north_a" x="38" y="76" width="4" height="1"/>
<rect inkscape:label="@house_north_b" x="46" y="76" width="14" height="1"/>
<rect inkscape:label="@house_south" x="38" y="91" width="22" height="1"/>
<rect inkscape:label="@house_west_a" x="38" y="77" width="1" height="6.333"/>
<rect inkscape:label="@house_west_b" x="38" y="87.333" width="1" height="3.667"/>
<rect inkscape:label="@house_east" x="59" y="77" width="1" height="14"/>
<rect inkscape:label="@house_part_a" x="50.333" y="77" width="0.667" height="4.667"/>
<rect inkscape:label="@house_part_b" x="50.333" y="85.667" width="0.667" height="5.333"/>
</g>
<g inkscape:label="house lintels #house y=2.0 h=2.6 color=#d9c9ad" inkscape:groupmode="layer" id="layer6">
<!-- above the door, the doorway and the window -->
<rect inkscape:label="@house_lintel_door" x="42" y="76" width="4" height="1"/>
<rect inkscape:label="@house_lintel_inner" x="50.333" y="81.667" width="0.667" height="4"/>
<rect inkscape:label="@house_lintel_window" x="38" y="83.333" width="1" height="4"/>
</g>
<g inkscape:label="house sill #house y=0 h=0.9 color=#d9c9ad" inkscape:groupmode="layer" id="layer7">
<!-- below the window -->
<rect inkscape:label="@house_sill" x="38" y="83.333" width="1" height="4"/>
</g>
<g inkscape:label="house floor #house y=0 h=0.06 color=#a58459" inkscape:groupmode="layer" id="layer8">
<!-- wooden floor (a slab 0.06 high, so the doorstep is a small step) -->
<rect inkscape:label="@house_floor" x="39" y="77" width="20" height="14"/>
<rect inkscape:label="@house_threshold" x="42" y="76" width="4" height="1"/>
</g>
<g inkscape:label="house roof #house y=2.6 h=4.2 e=(1,0) color=#8b4a3a" inkscape:groupmode="layer" id="layer9">
<!-- hip roof: the top ring collapses to a point (e=(1,0)); it overhangs the walls by 0.3 -->
<rect inkscape:label="@house_roof" x="37" y="75" width="24" height="18"/>
</g>
<g inkscape:label="garden walls #garden y=0 h=1.0 color=#8a789e" inkscape:groupmode="layer" id="layer10">
<!-- A walled garden in the south-east corner, open to the sky, with a gate in its north wall: an outdoor area inside the
outdoors. In demo/main.cpp it has its own ambient zone: purple. -->
<rect inkscape:label="@garden_north_a" x="77" y="78" width="7" height="1"/>
<rect inkscape:label="@garden_north_b" x="89" y="78" width="4.5" height="1"/>
<rect inkscape:label="@garden_west" x="77" y="79" width="1" height="14.5"/>
<rect inkscape:label="@garden_east" x="92.5" y="79" width="1" height="14.5"/>
<rect inkscape:label="@garden_south" x="78" y="92.5" width="14.5" height="1"/>
</g>
<g inkscape:label="garden floor #garden y=0 h=0.05 color=#6d5d86" inkscape:groupmode="layer" id="layer11">
<!-- paving -->
<rect inkscape:label="@garden_floor" x="78" y="79" width="14.5" height="13.5"/>
</g>
<g inkscape:label="garden obelisk #garden y=0.05 h=2.6 e=(1,0.35) color=#b9a7d6" inkscape:groupmode="layer" id="layer12">
<!-- a tapering pillar in the middle (the top ring shrinks to 35%) -->
<rect inkscape:label="@garden_obelisk" x="88.667" y="80.667" width="3" height="3"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

+792 -266
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -33,7 +33,7 @@ struct RunConfig {
// ENGINE_MAX_FRAMES=N and ENGINE_SCREENSHOT=file.png, no code needed):
float frameTime = 0; // > 0: use this dt for every frame instead of the measured one
int maxFrames = 0; // > 0: return after this many frames (implies frameTime = 1/60)
std::string screenshot; // written after the last frame when maxFrames is set
std::string screenshot = {}; // written after the last frame when maxFrames is set
};
// Returns 0 on a clean exit, non-zero if the window could not be created.
+135
View File
@@ -0,0 +1,135 @@
// How a collider follows its entity's transform. Pure maths with no Jolt in sight, so the physics
// backend (physics.cpp) and the unit tests share exactly the same rules.
#pragma once
#include "engine/physics.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace eng::detail {
inline constexpr float kMinColliderSize = 0.005f; // nothing reaches the solver thinner than this
// Effective, world-space dimensions of a collider: its authored size multiplied by the entity's world
// scale (parents included), clamped so a degenerate scale never produces an invalid shape.
struct ColliderDims {
ShapeType type = ShapeType::Box;
Vec3 halfExtents{}; // Box
float radius = 0; // Sphere, Capsule (caps), Cylinder
float halfHeight = 0; // Capsule (cylindrical part only), Cylinder
Vec3 meshScale{1, 1, 1}; // ConvexHull / Mesh: signed per-axis scale (negative mirrors)
Vec3 offset{}; // shape centre relative to the body, already scaled
};
inline float clampedSigned(float v) { return v < 0 ? -std::max(-v, kMinColliderSize) : std::max(v, kMinColliderSize); }
// `dynamic` bodies cannot use triangle meshes (a convex hull of the same mesh is used instead).
inline ColliderDims colliderDims(const Collider& c, Vec3 worldScale, bool dynamic) {
const Vec3 as = abs3(worldScale);
const float hMax = std::max({as.x, as.y, as.z});
const float hXZ = std::max(as.x, as.z);
ColliderDims d;
d.type = (c.shape == ShapeType::Mesh && dynamic) ? ShapeType::ConvexHull : c.shape;
d.offset = c.offset * worldScale;
switch (d.type) {
case ShapeType::Box:
d.halfExtents = {std::max(kMinColliderSize, c.size.x * as.x * 0.5f), std::max(kMinColliderSize, c.size.y * as.y * 0.5f),
std::max(kMinColliderSize, c.size.z * as.z * 0.5f)};
break;
case ShapeType::Sphere:
d.radius = std::max(kMinColliderSize, c.radius * hMax);
break;
case ShapeType::Capsule:
d.radius = std::max(kMinColliderSize, c.radius * hXZ);
d.halfHeight = std::max(kMinColliderSize, 0.5f * c.height * as.y - d.radius);
break;
case ShapeType::Cylinder:
d.radius = std::max(kMinColliderSize, c.radius * hXZ);
d.halfHeight = std::max(kMinColliderSize, 0.5f * c.height * as.y);
break;
case ShapeType::ConvexHull:
case ShapeType::Mesh:
d.meshScale = {clampedSigned(worldScale.x), clampedSigned(worldScale.y), clampedSigned(worldScale.z)};
break;
}
return d;
}
// The upright capsule of a character controller follows the entity's scale the same way.
struct CapsuleDims { float radius, halfHeight; };
inline CapsuleDims characterCapsule(float radius, float height, Vec3 worldScale) {
const Vec3 as = abs3(worldScale);
const float r = std::max(0.05f, radius * std::max(as.x, as.z));
return {r, std::max(0.01f, 0.5f * height * as.y - r)};
}
// The scale a built shape depends on, for telling whether it needs rebuilding. Just the magnitude for shapes
// that cannot tell a mirror image from the original (a centred box, sphere, capsule or cylinder), so flipping
// such an entity with a negative scale costs nothing. The sign counts once something can land on the other
// side of the entity: an off-centre collider, a hull or a mesh.
inline Vec3 shapeScale(const Collider& c, Vec3 worldScale) {
const bool signMatters = c.shape == ShapeType::ConvexHull || c.shape == ShapeType::Mesh || c.offset.lengthSq() > 0.0f;
return signMatters ? worldScale : abs3(worldScale);
}
// Are all three axes scaled alike (a mirror does not count)? Only then does a scaled convex hull keep its
// proportions exactly: Jolt rounds a hull with a fixed convex radius, which a scale wrapper can only shrink in
// step with the smallest axis, so a stretched hull would come out smaller than its mesh. Stretched hulls are
// built from scaled points instead.
inline bool uniformScale(Vec3 s) {
const Vec3 a = abs3(s);
const float hi = std::max({a.x, a.y, a.z}), lo = std::min({a.x, a.y, a.z});
return hi - lo <= 1e-3f * hi;
}
// Writing a dynamic body back to its entity. The body stands for the matrix T * Q * diag(bodyScale) (Q is its
// rotation, bodyScale the decomposition of the entity's world matrix, which carries a mirror on X), so the entity has
// to end up showing the same picture:
// parent * T(local position) * R * diag(local scale) == T * Q * diag(bodyScale).
// This returns the local rotation R that makes it so, whichever signs the entity's own scale has and whether or not a
// parent is mirrored; `parentInverse` is the inverse of the parent's world matrix. (Just undoing the parents' rotations
// is only right while nothing is mirrored: a negative scale on the entity or an ancestor would flip its picture, or
// turn it the wrong way, the moment physics takes over.) With a parent that is a rotation plus a uniform scale (mirror
// or not) this is exact; with a sheared parent it is the closest rotation. `keep` comes back if an axis degenerates.
inline Quat localRotationForBody(const Mat4& parentInverse, const Quat& bodyRotation, Vec3 bodyScale, Vec3 localScale, const Quat& keep) {
const float bs[3] = {bodyScale.x, bodyScale.y, bodyScale.z}, ls[3] = {localScale.x, localScale.y, localScale.z};
const Vec3 axis[3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}};
Vec3 col[3];
for (int i = 0; i < 3; ++i) {
col[i] = parentInverse.transformDirection(bodyRotation * axis[i]);
const float len = col[i].length();
if (!(len > 1e-12f)) return keep;
const float sign = (bs[i] < 0 ? -1.0f : 1.0f) * (ls[i] < 0 ? -1.0f : 1.0f); // where the two scales' signs differ the axis flips
col[i] = col[i] * (sign / len);
}
if (dot(col[0], cross(col[1], col[2])) < 0) return keep; // not a rotation (a flipped or sheared hierarchy): leave it
return Quat::fromBasis(col[0], col[1], col[2]);
}
// Has the world scale moved far enough from the one a shape was built with to be worth a new shape?
inline bool scaleChanged(Vec3 built, Vec3 now) {
auto differs = [](float a, float b) { return std::fabs(a - b) > 1e-4f * std::max(1.0f, std::max(std::fabs(a), std::fabs(b))); };
return differs(built.x, now.x) || differs(built.y, now.y) || differs(built.z, now.z);
}
// Everything about a collider that shapes the solver body (not friction/trigger, which need rebuild()).
// A change in this signature reshapes the body in place of the old one: edit `size`, `radius`,
// `height`, `offset`, `shape` or `mesh` and the collider follows, no rebuild() call needed.
inline std::uint64_t colliderSignature(const Collider& c) {
const float f[8] = {c.size.x, c.size.y, c.size.z, c.radius, c.height, c.offset.x, c.offset.y, c.offset.z};
std::uint64_t h = 1469598103934665603ull;
auto mix = [&h](const void* p, std::size_t n) {
const auto* b = static_cast<const unsigned char*>(p);
for (std::size_t i = 0; i < n; ++i) { h ^= b[i]; h *= 1099511628211ull; }
};
const std::uint8_t t = static_cast<std::uint8_t>(c.shape);
mix(&t, 1);
mix(f, sizeof f);
const std::uint64_t meshId = c.mesh ? c.mesh->id() : 0;
mix(&meshId, sizeof meshId);
return h;
}
} // namespace eng::detail
+375
View File
@@ -0,0 +1,375 @@
// Pure mesh generation for one extruded island: ear-clipping triangulation with hole bridging, loft
// levels, wall/cap emission and crease-aware smooth normals. Deliberately free of Clipper2 so it can be
// unit-tested on its own; extrude.cpp (which owns the polygon clipping) includes it.
#pragma once
#include "engine/model.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <utility>
#include <vector>
namespace eng::meshgen {
struct P2 {
double x = 0, y = 0;
};
inline P2 operator-(P2 a, P2 b) { return {a.x - b.x, a.y - b.y}; }
inline double cross2(P2 a, P2 b) { return a.x * b.y - a.y * b.x; }
inline double dot2(P2 a, P2 b) { return a.x * b.x + a.y * b.y; }
inline bool same(P2 a, P2 b) { return std::fabs(a.x - b.x) < 1e-9 && std::fabs(a.y - b.y) < 1e-9; }
// Shoelace area: > 0 for counter-clockwise rings in a y-up plane (what Clipper2 calls positive).
inline double signedArea(const std::vector<P2>& p) {
double a = 0;
for (std::size_t i = 0, n = p.size(); i < n; ++i) {
const P2& u = p[i];
const P2& v = p[(i + 1) % n];
a += u.x * v.y - v.x * u.y;
}
return a * 0.5;
}
inline P2 centroidOf(const std::vector<P2>& p) {
double a2 = 0, cx = 0, cy = 0;
for (std::size_t i = 0, n = p.size(); i < n; ++i) {
const P2& u = p[i];
const P2& v = p[(i + 1) % n];
const double c = u.x * v.y - v.x * u.y;
a2 += c;
cx += (u.x + v.x) * c;
cy += (u.y + v.y) * c;
}
if (std::fabs(a2) < 1e-18) {
P2 m;
for (const P2& q : p) { m.x += q.x; m.y += q.y; }
return {m.x / static_cast<double>(p.size()), m.y / static_cast<double>(p.size())};
}
return {cx / (3.0 * a2), cy / (3.0 * a2)};
}
// ===========================================================================
// Triangulation: ear clipping with hole bridging.
// rings[0] is the outer ring (CCW), the rest are holes (CW), all stored in `pts`;
// ringStart has rings+1 entries. Returns index triples into `pts`. Collinear vertices are
// kept (as zero-area triangles) so per-vertex heights stay honoured on straight edges.
// ===========================================================================
inline std::vector<std::array<int, 3>> triangulate(const std::vector<P2>& pts, const std::vector<std::size_t>& ringStart,
double eps) {
struct Node { P2 p; int idx, prev, next; };
std::vector<Node> nd;
nd.reserve(pts.size() + 2 * ringStart.size() + 4);
auto addRing = [&](std::size_t r) {
int first = -1, last = -1;
for (std::size_t i = ringStart[r]; i < ringStart[r + 1]; ++i) {
const int id = static_cast<int>(nd.size());
nd.push_back({pts[i], static_cast<int>(i), last, -1});
if (last >= 0) nd[static_cast<std::size_t>(last)].next = id; else first = id;
last = id;
}
nd[static_cast<std::size_t>(last)].next = first;
nd[static_cast<std::size_t>(first)].prev = last;
return first;
};
auto N = [&](int id) -> Node& { return nd[static_cast<std::size_t>(id)]; };
const std::size_t rings = ringStart.size() - 1;
int outer = addRing(0);
std::vector<int> holes; // rightmost vertex of each hole
for (std::size_t r = 1; r < rings; ++r) {
const int first = addRing(r);
int best = first;
for (int id = N(first).next; id != first; id = N(id).next)
if (N(id).p.x > N(best).p.x) best = id;
holes.push_back(best);
}
std::sort(holes.begin(), holes.end(), [&](int a, int b) { return N(a).p.x > N(b).p.x; });
auto segHit = [&](P2 p1, P2 p2, P2 q1, P2 q2) {
const double d1 = cross2(p2 - p1, q1 - p1), d2 = cross2(p2 - p1, q2 - p1);
const double d3 = cross2(q2 - q1, p1 - q1), d4 = cross2(q2 - q1, p2 - q1);
if (((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps)))
return true;
auto onSeg = [&](P2 a, P2 b, P2 p) { return std::fabs(cross2(b - a, p - a)) <= eps && dot2(p - a, p - b) <= eps; };
return onSeg(p1, p2, q1) || onSeg(p1, p2, q2) || onSeg(q1, q2, p1) || onSeg(q1, q2, p2);
};
auto ringClear = [&](int start, P2 h, P2 v) { // segment h-v crosses no edge of the ring?
int e = start;
do {
const int f = N(e).next;
const P2 a = N(e).p, b = N(f).p;
if (!(same(a, h) || same(b, h) || same(a, v) || same(b, v)) && segHit(h, v, a, b)) return false;
e = f;
} while (e != start);
return true;
};
auto ringContains = [&](int start, P2 pt) { // even-odd point in ring
bool in = false;
int i = start;
do {
const P2 a = N(i).p, b = N(N(i).next).p;
if ((a.y > pt.y) != (b.y > pt.y) && pt.x < a.x + (pt.y - a.y) * (b.x - a.x) / (b.y - a.y)) in = !in;
i = N(i).next;
} while (i != start);
return in;
};
for (std::size_t hi = 0; hi < holes.size(); ++hi) {
const int H = holes[hi];
const P2 hp = N(H).p;
std::vector<std::pair<double, int>> cand;
int i = outer;
do {
const P2 d = N(i).p - hp;
cand.emplace_back(dot2(d, d), i);
i = N(i).next;
} while (i != outer);
std::sort(cand.begin(), cand.end());
int chosen = -1;
for (const auto& [dist, V] : cand) {
const P2 vp = N(V).p;
bool ok = ringClear(outer, hp, vp);
for (std::size_t hj = hi; ok && hj < holes.size(); ++hj) ok = ringClear(holes[hj], hp, vp);
if (ok) ok = ringContains(outer, {(hp.x + vp.x) * 0.5, (hp.y + vp.y) * 0.5});
if (ok) { chosen = V; break; }
}
if (chosen < 0) chosen = cand.front().second; // degenerate input: connect to the nearest anyway
const int V = chosen;
const int Vn = N(V).next, Hp = N(H).prev;
const int V2 = static_cast<int>(nd.size());
nd.push_back({N(V).p, N(V).idx, -1, -1});
const int H2 = static_cast<int>(nd.size());
nd.push_back({N(H).p, N(H).idx, -1, -1});
N(V).next = H; N(H).prev = V;
N(Hp).next = H2; N(H2).prev = Hp;
N(H2).next = V2; N(V2).prev = H2;
N(V2).next = Vn; N(Vn).prev = V2;
outer = V;
}
auto isEar = [&](int a, int b, int c) {
const P2 pa = N(a).p, pb = N(b).p, pc = N(c).p;
const double cr = cross2(pb - pa, pc - pb);
if (cr < -eps) return false; // reflex
if (cr <= eps) return false; // straight angle: keep it as a real vertex of a later ear
for (int q = N(c).next; q != a; q = N(q).next) {
const P2 pq = N(q).p;
if (same(pq, pa) || same(pq, pb) || same(pq, pc)) continue;
if (cross2(pb - pa, pq - pa) >= -eps && cross2(pc - pb, pq - pb) >= -eps &&
cross2(pa - pc, pq - pc) >= -eps)
return false;
}
return true;
};
int remaining = 0;
for (int i = outer, first = outer; ; ) { ++remaining; i = N(i).next; if (i == first) break; }
std::vector<std::array<int, 3>> tris;
tris.reserve(static_cast<std::size_t>(remaining));
auto removeNode = [&](int id) { N(N(id).prev).next = N(id).next; N(N(id).next).prev = N(id).prev; };
int cur = outer, stall = 0;
while (remaining > 3) {
const int p = N(cur).prev, n = N(cur).next;
if (isEar(p, cur, n)) {
tris.push_back({N(p).idx, N(cur).idx, N(n).idx});
removeNode(cur);
--remaining;
cur = n;
stall = 0;
continue;
}
cur = n;
if (++stall > remaining) { // no ear anywhere: clip the most convex vertex
int best = cur, it = cur;
double bestCross = -1e300;
for (int k = 0; k < remaining; ++k, it = N(it).next) {
const double c = cross2(N(it).p - N(N(it).prev).p, N(N(it).next).p - N(it).p);
if (c > bestCross) { bestCross = c; best = it; }
}
const int bp = N(best).prev, bn = N(best).next;
tris.push_back({N(bp).idx, N(best).idx, N(bn).idx});
removeNode(best);
--remaining;
cur = bn;
stall = 0;
}
}
if (remaining == 3) tris.push_back({N(N(cur).prev).idx, N(cur).idx, N(N(cur).next).idx});
return tris;
}
// ===========================================================================
// Mesh generation for one island
// ===========================================================================
struct Level { float f, r; };
inline std::vector<Level> makeLevels(const std::vector<std::pair<float, float>>& e) {
std::vector<Level> levels;
for (const auto& [f, r] : e) levels.push_back({std::clamp(f, 0.0f, 1.0f), std::max(r, 0.0f)});
std::stable_sort(levels.begin(), levels.end(), [](const Level& a, const Level& b) { return a.f < b.f; });
if (levels.empty() || levels.front().f > 0) levels.insert(levels.begin(), Level{0.0f, 1.0f});
if (levels.back().f < 1) levels.push_back(Level{1.0f, 1.0f});
return levels;
}
struct IslandData {
std::vector<P2> pts;
std::vector<float> base, top;
std::vector<Rgba> color;
std::vector<std::size_t> ringStart;
P2 pivot;
};
// ---------------------------------------------------------------------------
// Smooth shading.
//
// Every triangle is emitted with its flat face normal; this pass then re-computes the normal of each
// corner. Corners are welded by position, and a corner's normal becomes the angle-weighted average of
// the face normals around that position whose faces bend less than the crease angle away from the
// corner's own face. That one rule gives:
// * walls shaded smoothly around curves (circles, arcs, rounded shapes),
// * lofted bands (e=...) shaded smoothly *across* levels when the profile bends gently,
// * hard edges wherever the surface bends sharply (wall/cap edges, ledges, pyramid faces),
// * one consistent normal per position and surface (no seams, no averaging across a crease).
// ---------------------------------------------------------------------------
inline void smoothCreaseNormals(MeshData& m, std::size_t first, float cosCrease) {
const std::size_t total = m.positions.size();
if (first >= total || (total - first) % 3 != 0) return;
const std::size_t tris = (total - first) / 3;
std::vector<Vec3> face(tris); // unit face normals (counter-clockwise = front)
std::vector<std::array<float, 3>> angle(tris); // interior angle at each corner
for (std::size_t t = 0; t < tris; ++t) {
const Vec3* p = &m.positions[first + 3 * t];
face[t] = cross(p[1] - p[0], p[2] - p[0]).normalized();
for (std::size_t k = 0; k < 3; ++k) {
const Vec3 e1 = (p[(k + 1) % 3] - p[k]).normalized();
const Vec3 e2 = (p[(k + 2) % 3] - p[k]).normalized();
angle[t][k] = std::acos(std::clamp(dot(e1, e2), -1.0f, 1.0f));
}
}
struct Key {
std::int64_t x, y, z;
bool operator==(const Key&) const = default;
};
struct KeyHash {
std::size_t operator()(const Key& k) const noexcept {
return static_cast<std::size_t>(k.x) * 73856093u ^ static_cast<std::size_t>(k.y) * 19349663u ^
static_cast<std::size_t>(k.z) * 83492791u;
}
};
auto quant = [](float v) { return static_cast<std::int64_t>(std::llround(static_cast<double>(v) * 1e5)); };
std::unordered_map<Key, std::vector<std::uint32_t>, KeyHash> groups; // corner ids (triangle * 3 + corner)
groups.reserve(tris * 2);
for (std::size_t c = 0; c < tris * 3; ++c) {
const Vec3& p = m.positions[first + c];
groups[Key{quant(p.x), quant(p.y), quant(p.z)}].push_back(static_cast<std::uint32_t>(c));
}
for (const auto& [key, ids] : groups) {
(void)key;
for (const std::uint32_t c : ids) {
const std::size_t tc = c / 3;
if (face[tc].lengthSq() < 0.5f) continue; // degenerate: keep what it has
Vec3 sum;
for (const std::uint32_t d : ids) {
const std::size_t td = d / 3;
if (td != tc && dot(face[tc], face[td]) < cosCrease) continue; // across a crease
sum += face[td] * angle[td][d % 3];
}
const Vec3 n = sum.normalized();
m.normals[first + c] = n.lengthSq() > 0.5f ? n : face[tc];
}
}
}
struct Emitter {
MeshData& out;
// Triangles carry their flat normal; smoothCreaseNormals() refines it afterwards.
void wallTri(Vec3 a, Vec3 b, Vec3 c, Rgba ca, Rgba cb, Rgba cc) const {
const Vec3 cr = cross(b - a, c - a);
if (cr.lengthSq() < 1e-16f) return; // degenerate
const Vec3 n = cr.normalized();
out.addTriangle(a, b, c, n, n, n, ca, cb, cc);
}
void capTri(Vec3 a, Vec3 b, Vec3 c, Rgba ca, Rgba cb, Rgba cc) const {
const Vec3 n = cross(b - a, c - a).normalized();
if (n.lengthSq() < 0.5f) return;
out.addTriangle(a, b, c, n, n, n, ca, cb, cc);
}
};
inline void emitIsland(MeshData& out, const IslandData& d, const std::vector<Level>& levels, const ExtrudeOptions& opt, P2 origin) {
const Emitter em{out};
const std::size_t first = out.positions.size();
const float cosCrease = std::cos(std::max(opt.smoothAngleDeg, 0.0f) * kDegToRad);
const double s = opt.scale;
auto position = [&](std::size_t i, const Level& lv) {
const double x = d.pivot.x + lv.r * (d.pts[i].x - d.pivot.x);
const double y = d.pivot.y + lv.r * (d.pts[i].y - d.pivot.y);
return Vec3{static_cast<float>((x - origin.x) * s), d.base[i] + lv.f * (d.top[i] - d.base[i]),
static_cast<float>((y - origin.y) * s)};
};
// ---- walls: one strip per contour edge between consecutive levels ----
for (std::size_t k = 0; k + 1 < levels.size(); ++k) {
for (std::size_t r = 0; r + 1 < d.ringStart.size(); ++r) {
const std::size_t b0 = d.ringStart[r], n = d.ringStart[r + 1] - b0;
if (n < 3) continue;
std::vector<Vec3> A(n), B(n);
for (std::size_t i = 0; i < n; ++i) {
A[i] = position(b0 + i, levels[k]);
B[i] = position(b0 + i, levels[k + 1]);
}
for (std::size_t i = 0; i < n; ++i) {
const std::size_t j = (i + 1) % n;
const Rgba ci = d.color[b0 + i], cj = d.color[b0 + j];
em.wallTri(A[i], B[j], A[j], ci, cj, cj);
em.wallTri(A[i], B[i], B[j], ci, ci, cj);
}
}
}
// ---- caps ----
const double eps = std::max(1e-12, 0.1 * std::pow(10.0, -2.0 * opt.precision));
const auto tris = triangulate(d.pts, d.ringStart, eps);
const Level& bottom = levels.front();
const Level& topLv = levels.back();
for (const auto& t : tris) {
const std::size_t a = static_cast<std::size_t>(t[0]), b = static_cast<std::size_t>(t[1]), c = static_cast<std::size_t>(t[2]);
const double area2 = cross2(d.pts[b] - d.pts[a], d.pts[c] - d.pts[a]);
const bool ccw = area2 > 0; // in svg space (y up); maps to -Y facing
// top (+Y): reverse the ccw order
{
const Vec3 pa = position(a, topLv), pb = position(b, topLv), pc = position(c, topLv);
if (ccw) em.capTri(pa, pc, pb, d.color[a], d.color[c], d.color[b]);
else em.capTri(pa, pb, pc, d.color[a], d.color[b], d.color[c]);
}
// bottom (-Y)
{
const Vec3 pa = position(a, bottom), pb = position(b, bottom), pc = position(c, bottom);
if (ccw) em.capTri(pa, pb, pc, d.color[a], d.color[b], d.color[c]);
else em.capTri(pa, pc, pb, d.color[a], d.color[c], d.color[b]);
}
}
smoothCreaseNormals(out, first, cosCrease);
}
} // namespace eng::meshgen
+120
View File
@@ -0,0 +1,120 @@
// The CPU half of drawing a frame. Everything the GPU needs - which instances of which mesh go into which pass,
// every shader uniform, the shadow views and the ambient grid - is worked out here without touching OpenGL, so
// it is unit-testable, and the renderer (src/render.cpp) is left with nothing but upload-and-draw calls.
#pragma once
#include "engine/detail/scene.hpp"
#include <cstdint>
#include <memory>
#include <vector>
namespace eng::detail {
// Per-object GPU data, the same for every pass (the shadow pass only reads the matrix).
struct GpuInstance {
float m[16]; // world matrix, column-major
std::uint8_t rgba[4]; // tint
std::uint8_t material[4]; // roughness, metallic, emission / 4, flags (kMat*)
};
static_assert(sizeof(GpuInstance) == 72, "the instance attribute layout (stride 72, colour at 64, material at 68) is hard-coded");
inline constexpr int kInstanceStride = 72;
inline constexpr int kInstanceColorOffset = 64;
inline constexpr int kInstanceMaterialOffset = 68;
// What the lit shader reads about the surface of one batch, in the types it declares them: worked out here (from a Material,
// clamped to sane ranges) so the renderer only uploads it, and so it can be tested without a GPU.
struct MaterialUniforms {
int mode = 0; // 0: no material: roughness, metallic and emission come per instance, no maps
// 1: a Material
int maps = 0; // Material::MapBit bits: which of `textures` are real
float color[4] = {1, 1, 1, 1}; // albedo (gamma-encoded, like the vertex colours)
float surface[4] = {1, 0.5f, 0, 0.85f}; // diffuse, specular level, metalness, roughness
float extra[4] = {1, 0, 0, 0}; // normal strength, emission, 1 = project the textures from the world / 0 = use the mesh's UVs, unused
float specColor[4] = {1, 1, 1, 1}; // specular colour (gamma-encoded)
float uv[4] = {1, 1, 0, 0}; // texture coordinate scale (xy) and offset (zw)
const eng::Texture* textures[Material::kMapCount] = {}; // albedo, diffuse, specular, metalness, roughness, normal (Material::MapBit order)
};
// The shader values of `material` (null: the plain look, mode 0) drawn on a mesh that has / has not texture coordinates.
[[nodiscard]] MaterialUniforms materialUniforms(const eng::Material* material, bool meshHasTexcoords);
// A run of instances (in FramePlan::instances) that share one mesh, one material and one handedness.
struct DrawBatch {
const eng::Mesh* mesh = nullptr;
bool mirrored = false; // negative determinant: winding flips, so the culled face must too
std::uint32_t first = 0;
std::uint32_t count = 0;
MaterialUniforms mat; // the camera's pass only (shadow batches only need the depth)
};
// Values of the lit shader's uniforms, in the types and layout the shader declares them.
struct LitUniforms {
int lightCount = 0;
float lightPosType[kMaxLights][4] = {}; // sun: direction TO the light, w 0; point: position, w 1; spot: position, w 2
float lightColorRange[kMaxLights][4] = {}; // linear colour * intensity, range
float lightSpot[kMaxLights][4] = {}; // spot axis, cos of the outer half angle
float lightParams[kMaxLights][4] = {}; // 1 / (cos inner - cos outer), shadow slot (-1 none), bias multiplier
float camPos[3] = {};
Mat4 shadowMatrix[kMaxShadowMaps];
float shadowInfo[kMaxShadowMaps][4] = {}; // texel world size, 1 = radial (spot), depth range, 1 / resolution
float shadowParams[4] = {}; // strength, softness in texels, sun shadow distance
float ambSkyGlobal[3] = {}; // linear
float ambGroundGlobal[3] = {};
float ambVolOrigin[4] = {}; // grid origin, cell size
float ambVolDims[4] = {0, 0, 0, 1}; // cells x y z, tiles per atlas row; x == 0: no zones
float ambColorScale = 1;
float envParams[4] = {1, 2, 0, 0}; // exposure, tone mapping, fog density
float fogColor[3] = {}; // linear
};
struct ShadowPass {
int slot = 0; // 0..kMaxShadowMaps-1: which shadow map / sampler
int resolution = 0; // square map, texels per side
Mat4 viewProj;
bool radial = false; // spot: the map stores distance from `lightPos` divided by `range`
Vec3 lightPos{};
float range = 1;
std::vector<DrawBatch> batches; // the casters inside the light's view
};
struct FramePlan {
bool hasCamera = false;
CameraData camera;
float aspect = 1;
Mat4 view, projection, viewProj;
std::vector<GpuInstance> instances; // every pass back to back: one upload per frame
std::vector<DrawBatch> main; // the camera's pass
std::vector<ShadowPass> shadows;
LitUniforms lit;
bool ambientChanged = false; // the ambient grid was re-baked: refresh the GPU textures
const AmbientGrid* ambient = nullptr; // owned by the planner; valid until the next plan()
std::vector<TextData> texts; // HUD text, in order
int meshesSeen = 0; // visible mesh renderers before culling
int culled = 0; // rejected by the camera frustum
int lightsShaded = 0;
};
// Shadow-map side length: a power of two in [256, 4096] (rounding up).
[[nodiscard]] int shadowMapResolution(int requested);
class FramePlanner {
public:
FramePlanner();
~FramePlanner();
FramePlanner(const FramePlanner&) = delete;
FramePlanner& operator=(const FramePlanner&) = delete;
// Plans one frame of `world` for a viewport with the given aspect ratio. The returned plan (and the mesh
// pointers in it) stay valid until the next call; the world must not change in between.
[[nodiscard]] const FramePlan& plan(World& world, float aspect);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace eng::detail
+209
View File
@@ -0,0 +1,209 @@
// What the renderer needs from the ECS, gathered without touching the GPU (so all of it is unit-testable),
// plus the CPU side of lighting: light budgeting, shadow-map fitting and the ambient-zone bake.
#pragma once
#include "engine/graphics.hpp"
#include <array>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace eng::detail {
inline constexpr int kMaxLights = 16; // shaded per frame (shader array size)
inline constexpr int kMaxShadowMaps = 3; // shadow maps per frame (shader sampler count)
inline constexpr std::uint8_t kMatReceiveShadows = 1; // MeshInstance::flags
inline constexpr std::uint8_t kMatUnlit = 2;
// ---- frustum -------------------------------------------------------------------------------------
struct Frustum {
float planes[6][4] = {}; // normalised, pointing inwards
static Frustum fromViewProj(const Mat4& viewProj);
[[nodiscard]] bool sphereVisible(Vec3 center, float radius) const;
};
// ---- what is in the scene ------------------------------------------------------------------------
struct MeshInstance {
const eng::Mesh* mesh = nullptr;
const eng::Material* material = nullptr; // null: the renderer's own roughness / metallic / emission below
Mat4 world;
Vec3 center; // world-space bounding sphere
float radius = 0;
Rgba tint{255, 255, 255, 255};
std::uint8_t roughness = 217, metallic = 0, emission = 0; // 0..255 (emission: 0..4)
std::uint8_t flags = kMatReceiveShadows;
bool castShadows = true;
};
struct LightData {
LightType type = LightType::Directional;
Vec3 position{}; // world; unused for directional lights
Vec3 direction{0, -1, 0}; // the way the light travels (unit)
Vec3 color{1, 1, 1};
float intensity = 1;
float range = 20;
float cosInner = 1, cosOuter = 0; // spot cone
bool castShadows = false;
float shadowBias = 1;
};
struct TextData {
std::string value;
Vec2 position;
float size = 20;
Rgba color;
};
struct CameraData {
Vec3 position{}, forward{0, 0, -1}, up{0, 1, 0};
float fovDeg = 60, nearPlane = 0.1f, farPlane = 1000, orthoSize = 10;
bool orthographic = false;
[[nodiscard]] Mat4 view() const { return Mat4::lookAt(position, position + forward, up); }
[[nodiscard]] Mat4 projection(float aspect) const {
if (!orthographic) return Mat4::perspective(fovDeg, aspect, nearPlane, farPlane);
const float top = 0.5f * orthoSize;
return Mat4::ortho(-top * aspect, top * aspect, -top, top, nearPlane, farPlane);
}
};
struct ZoneData {
ZoneShape shape = ZoneShape::Box;
Mat4 world, inverse; // entity -> world, and back
Vec3 size{};
float radius = 0, height = 0;
std::vector<Vec2> polygon;
MeshPtr mesh;
Vec3 sky{}, ground{};
float intensity = 1;
ZoneMode mode = ZoneMode::Replace;
float falloff = 0;
int priority = 0;
};
struct SceneSnapshot {
std::vector<MeshInstance> meshes; // visible renderers (hidden/disabled ones are absent)
std::vector<LightData> lights; // visible lights; a default sun when the world has none at all
std::vector<ZoneData> zones; // enabled ambient zones
std::vector<TextData> texts; // visible HUD text, in hierarchy order
bool hasCamera = false;
CameraData camera;
Environment env; // the first enabled Environment, else the defaults
};
// Walks the hierarchy once, applying the visibility rules of Renderable (see graphics.hpp).
[[nodiscard]] SceneSnapshot gatherScene(World& world);
// ---- lights and shadows --------------------------------------------------------------------------
// The lights shaded this frame: suns first (strongest, at most 4), then the point/spot lights that matter
// most to the camera, up to kMaxLights. Lights with no brightness are dropped.
[[nodiscard]] std::vector<LightData> selectLights(const std::vector<LightData>& all, Vec3 cameraPosition);
// Shadow slot (0..kMaxShadowMaps-1) of every selected light, or -1: the first shadow-casting sun gets slot 0,
// then the shadow-casting spots in order, up to env.maxShadowLights. All -1 when env.shadows is off.
[[nodiscard]] std::vector<int> assignShadowSlots(const std::vector<LightData>& selected, const Environment& env);
struct ShadowView {
Mat4 viewProj;
Frustum frustum; // for culling shadow casters
bool perspective = false; // spot lights
float texelWorld = 0; // sun: world size of one texel. Spot: size at distance 1 (scale by distance).
float depthRange = 1; // sun: world length the depth range covers. Spot: the light's range.
};
// A square orthographic view along `lightDir` that covers the camera's view up to `shadowDistance`, with the
// depth range tightened to the casters in front of it. The window is snapped to whole texels so shadows do not
// shimmer while the camera moves or turns.
[[nodiscard]] ShadowView fitSunShadow(Vec3 lightDir, const CameraData& camera, float aspect, float shadowDistance, int resolution,
const std::vector<MeshInstance>& casters);
[[nodiscard]] ShadowView fitSpotShadow(const LightData& light, int resolution);
// ---- ambient zones -------------------------------------------------------------------------------
[[nodiscard]] bool zoneContains(const ZoneData& zone, Vec3 worldPoint); // meshes are voxelised on demand (see AmbientBaker)
// Colours are authored in the usual "screen" values (0..1) and lit in linear light, as pow(c, 2.2).
inline constexpr float kGamma = 2.2f;
[[nodiscard]] Vec3 toLinear(Vec3 c);
[[nodiscard]] Vec3 toGamma(Vec3 c);
// The 8-bit texel of a linear value in [0, 1]: exactly the byte toGamma() gives it (rounded, clamped), table-driven.
[[nodiscard]] std::uint8_t encodeLinearToByte(float linear);
// A box of grid cells, both ends included, in the grid's own cell coordinates (0 .. n - 1 on every axis).
struct CellBox {
int x0 = 0, y0 = 0, z0 = 0;
int x1 = -1, y1 = -1, z1 = -1;
};
struct AmbientGrid {
bool valid = false; // false: no zones, shade with the Environment's ambient everywhere
Vec3 origin{}; // world position of the grid's minimum corner
float cell = 1; // world units per cell
int nx = 0, ny = 0, nz = 0;
float colorScale = 1; // linear light is stored as toGamma(value / colorScale): 8 bits are spent
// where the eye needs them, and zones can go above 1
std::vector<std::uint8_t> sky, ground; // RGBA8, index (y * nz + z) * nx + x
// The same data laid out for a 2D texture: one nx*nz tile per Y slice, tilesX tiles per row.
int tilesX = 1, tilesY = 1;
std::vector<std::uint8_t> atlasSky, atlasGround; // (nx * tilesX) x (nz * tilesY) RGBA8
// What the last update() rewrote, so the GPU copy can be refreshed without re-sending everything: either all of
// it (a new grid, a new layout or a new colour scale), or just the boxes of cells whose texels changed (a zone
// that walks with the player touches a few thousand cells, not the whole grid).
bool updateAll = false;
std::vector<CellBox> updated;
[[nodiscard]] int atlasWidth() const { return nx * tilesX; }
[[nodiscard]] int atlasHeight() const { return nz * tilesY; }
// Reference sampling of the baked field, in linear light: trilinear on the stored (encoded) texels and decoded
// afterwards, exactly what the pixel shader does on the atlas.
void sample(Vec3 worldPoint, Vec3& sky, Vec3& ground) const;
};
// What the last AmbientBaker::update() did. A bake costs what changed, not the size of the scene: moving one zone
// recomputes that zone's layer and rewrites the texels it touched, nothing else.
struct BakeStats {
int zones = 0; // usable zones blended
int layersComputed = 0; // zones whose reach (inside test + fade-out distances) had to be computed
int layersReused = 0; // zones that kept their cached reach
std::size_t cellsBlended = 0; // grid cells whose colour was worked out again (the part of the grid a changed zone can reach)
std::size_t cellsEncoded = 0; // grid cells whose texels (sky and ground) were rewritten
bool full = false; // every texel was rewritten: the first bake, a different grid, or a new colour scale
};
// Bakes AmbientZone components into a grid and re-bakes only when their inputs change.
//
// The grid lies on a lattice fixed in the world (cell i covers [i * cell, (i + 1) * cell)) and every zone keeps its own
// cached layer (where it is and how its fade-out falls off), keyed by its shape and transform. A bake therefore
// * recomputes the layers of the zones that changed (an unchanged zone is a cache hit, even when the grid grows
// or shrinks around it),
// * works out the colour again only where a changed zone was or is (the cells its old and its new layer cover; every
// other cell keeps the colour of the previous bake, which is what a bake from scratch would give it too), and
// * rewrites only the texels whose colour came out different from the previous bake.
// So the cost of moving one zone follows the size of that zone, not the size of the grid.
// The grid is always exactly what a bake from scratch would give: its extent is a function of the zones alone (their
// bounds, rounded out to blocks of cells so that a zone that walks does not resize the grid on every step), and the
// colour scale is a function of the brightest cell alone (rounded up to a step of a ladder, so that a bright zone that
// overlaps another one as it moves does not re-encode every texel on every step).
class AmbientBaker {
public:
AmbientBaker();
~AmbientBaker();
// Returns true when the grid's texels (or its size) are not what they were, so a GPU copy must be refreshed. An
// input that changed without changing a single texel (a zone moving by less than it takes to flip one) returns false.
bool update(const std::vector<ZoneData>& zones, const Environment& env);
[[nodiscard]] const AmbientGrid& grid() const { return grid_; }
// Voxelisation of closed meshes is cached by mesh id; this is how many are held.
[[nodiscard]] std::size_t cachedMeshVolumes() const;
// Zone layers held for reuse: one per distinct zone (shape + transform) of the last bake, never more.
[[nodiscard]] std::size_t cachedLayers() const;
// What the most recent update() call did (all zero when it returned false: nothing had changed).
[[nodiscard]] const BakeStats& lastBake() const { return stats_; }
private:
struct Impl;
std::unique_ptr<Impl> impl_;
AmbientGrid grid_;
std::uint64_t hash_ = 0;
BakeStats stats_;
};
} // namespace eng::detail
+37
View File
@@ -103,6 +103,12 @@ public:
void setEntityEnabled(bool enabled) const;
[[nodiscard]] bool entityActive() const; // enabled and all ancestors enabled
// Visual only (scripts and physics keep running): hides/shows the entity's renderer components
// (MeshRenderer, Light, Text, ...) and, by default, every entity below it. A plain group entity gets a
// Visibility component to carry the switch. See Renderable in graphics.hpp for finer control.
void setVisible(bool visible, bool includeChildren = true) const;
[[nodiscard]] bool isVisible() const; // drawn right now: not hidden here nor by an ancestor
[[nodiscard]] std::string& name() const;
void setName(std::string_view name) const;
[[nodiscard]] TagType tag() const;
@@ -136,6 +142,8 @@ template <class T> concept HasStart = requires(T& t) { t.start(); };
template <class T> concept HasUpdate = requires(T& t) { t.update(); } || requires(T& t, float dt) { t.update(dt); };
template <class T> concept HasFixedUpdate = requires(T& t) { t.fixedUpdate(); } || requires(T& t, float dt) { t.fixedUpdate(dt); };
template <class T> concept HasLateUpdate = requires(T& t) { t.lateUpdate(); } || requires(T& t, float dt) { t.lateUpdate(dt); };
template <class T> concept HasDrawGizmos = requires(T& t) { t.drawGizmos(); }; // 3D immediate drawing, inside the camera's view
template <class T> concept HasDrawGui = requires(T& t) { t.drawGui(); }; // 2D immediate drawing, on top of everything
template <class T> concept HasDestroyed = requires(T& t) { t.destroyed(); } || detail::HookDestroyed<T>;
template <class T> concept HasCollisionEnter = requires(T& t, Entity o) { t.collisionEnter(o); };
template <class T> concept HasCollisionExit = requires(T& t, Entity o) { t.collisionExit(o); };
@@ -332,6 +340,11 @@ public:
// fixed steps (fixedUpdate + physics), update(), lateUpdate(), then deferred destruction.
// run() calls this for you; call it yourself only for headless use (servers, tests).
void update(float dt);
// Called by the renderer (run() does it for you): components with drawGizmos() draw 3D immediate-mode
// graphics inside the camera's view, components with drawGui() draw 2D on top of the finished frame.
// The immediate-mode drawing functions of gfx.hpp are valid only inside these two callbacks.
void drawGizmos();
void drawGui();
[[nodiscard]] float deltaTime() const noexcept { return dt_; }
[[nodiscard]] float time() const noexcept { return time_; }
[[nodiscard]] std::uint64_t frame() const noexcept { return frame_; }
@@ -355,6 +368,8 @@ private:
void (*update)(World&, float) = nullptr;
void (*fixedUpdate)(World&, float) = nullptr;
void (*lateUpdate)(World&, float) = nullptr;
void (*drawGizmos)(World&) = nullptr;
void (*drawGui)(World&) = nullptr;
void (*destroyed)(Entity) = nullptr;
void (*collisionEnter)(Entity, Entity) = nullptr;
void (*collisionExit)(Entity, Entity) = nullptr;
@@ -368,6 +383,7 @@ private:
template <class T> void runUpdate(float dt);
template <class T> void runFixedUpdate(float dt);
template <class T> void runLateUpdate(float dt);
template <class T> void runDraw(bool gizmos);
template <class T> void snapshot(std::vector<entt::entity>& out);
template <class T> T& attachBuiltin(entt::entity e);
@@ -470,6 +486,8 @@ template <class T> void World::registerType() {
if constexpr (HasUpdate<T>) ops.update = [](World& w, float dt) { w.runUpdate<T>(dt); };
if constexpr (HasFixedUpdate<T>) ops.fixedUpdate = [](World& w, float dt) { w.runFixedUpdate<T>(dt); };
if constexpr (HasLateUpdate<T>) ops.lateUpdate = [](World& w, float dt) { w.runLateUpdate<T>(dt); };
if constexpr (HasDrawGizmos<T>) ops.drawGizmos = [](World& w) { w.runDraw<T>(true); };
if constexpr (HasDrawGui<T>) ops.drawGui = [](World& w) { w.runDraw<T>(false); };
if constexpr (HasCollisionEnter<T>) ops.collisionEnter = [](Entity s, Entity o) {
if (T* c = s.tryGet<T>()) if (c->componentActive()) c->collisionEnter(o); };
if constexpr (HasCollisionExit<T>) ops.collisionExit = [](Entity s, Entity o) {
@@ -560,4 +578,23 @@ template <class T> void World::runLateUpdate(float dt) {
}
}
template <class T> void World::runDraw(bool gizmos) {
if constexpr (std::is_base_of_v<Component, T> && (HasDrawGizmos<T> || HasDrawGui<T>)) {
std::vector<entt::entity> ids;
ids.swap(scratch_);
snapshot<T>(ids);
for (const entt::entity e : ids) {
T* c = reg_.try_get<T>(e);
if (!c) continue;
Component& base = *c;
if (!base.enabled_ || !isActive(e)) continue;
if constexpr (HasStart<T>) { if (!base.started_) continue; }
if constexpr (HasDrawGizmos<T>) { if (gizmos) c->drawGizmos(); }
if constexpr (HasDrawGui<T>) { if (!gizmos) c->drawGui(); }
}
ids.clear();
ids.swap(scratch_);
}
}
} // namespace eng
+10
View File
@@ -4,8 +4,18 @@
#include "math.hpp"
#include "ecs.hpp"
#include "input.hpp"
#include "texture.hpp"
#include "material.hpp"
#include "graphics.hpp"
#include "model.hpp"
#include "resources.hpp"
#include "physics.hpp"
#include "app.hpp"
// eng::gfx, the engine's equivalent of every raylib function (see gfx.hpp), when this build has it. It is left out
// when the file already included <raylib.h> before this header: raylib's macros (RED, PI, ...) cannot coexist with
// the declarations, and that code does not need it. Include <engine/gfx.hpp> yourself to get a clear error instead.
#if __has_include(<engine/gfx_api.hpp>) && !defined(RAYLIB_H) && !defined(RLGL_H) && !defined(RAYMATH_H)
#include "gfx.hpp"
#define ENGINE_HAS_GFX 1 // code that must also build without eng::gfx can test this
#endif
+86
View File
@@ -0,0 +1,86 @@
// eng::gfx - the engine's equivalent of raylib's API.
//
// #include <engine/gfx.hpp>
//
// Every raylib function (raylib.h, and rlgl.h / raymath.h when present) is available as eng::gfx::<name> with the
// first letter lower-cased: DrawCircle -> gfx::drawCircle, LoadTexture -> gfx::loadTexture, rlBegin -> gfx::rlBegin,
// Vector3Add -> gfx::vector3Add. Constants and enumerators keep raylib's names (gfx::KEY_SPACE, gfx::FLAG_VSYNC_HINT),
// colours live in gfx::colors (gfx::colors::SkyBlue). The wrappers are generated from the raylib headers you build
// against (tools/gfx_gen, see cmake/gfx.cmake), so they always match that version; the build log and
// <build>/gfx_gen/out/gfx_report.txt say what was wrapped.
//
// Types: raylib's Vector2/Vector3/Quaternion/Color/BoundingBox ARE the engine's Vec2/Vec3/Quat/Rgba/Aabb, and a
// Matrix passed by value is a Mat4 (the wrappers convert, so there is nothing to do on your side). Everything
// else (Texture2D, Font, Model, Camera3D, Rectangle, Ray, ...) has raylib's own layout under raylib's own name.
// Overloads taking std::string exist next to every `const char*` parameter, gfx::textFormat() returns a std::string
// and gfx::traceLog() takes printf arguments.
//
// Where to call it:
// * Drawing: in a component's `void drawGizmos()` (3D, inside the camera's BeginMode3D) or `void drawGui()` (2D,
// on top of the frame). run() owns the window, the frame and the 3D camera: InitWindow, CloseWindow,
// BeginDrawing, EndDrawing, BeginMode3D, ClearBackground, SetTargetFPS ... exist here for completeness (their
// docs say so) but the engine calls them itself; use RunConfig for the window.
// * Anything that needs the window (loadTexture, loadFont, loadModel, loadShader, audio ...) must run after the
// window exists, i.e. from a component callback during run(), not before it.
// * Do not include <raylib.h>, <rlgl.h> or <raymath.h> before this header in the same file: they define macros
// (RED, PI, ...) that would corrupt the declarations; the headers stop you with an #error. Including them after
// is fine, and qualifying names (gfx::...) is always safe. Code that includes <engine/engine.hpp> and raylib.h
// together keeps working: the umbrella header then simply leaves eng::gfx out.
#pragma once
#if !__has_include(<engine/gfx_api.hpp>)
#error "eng::gfx is not part of this build: it is generated from raylib's headers by CMake (ENGINE_BUILD_GFX, see cmake/gfx.cmake). Link the `engine` target, and check the configure log for an 'eng::gfx:' warning."
#endif
#include <engine/ecs.hpp>
#include <engine/graphics.hpp>
#include <engine/math.hpp>
#include <engine/gfx_api.hpp>
#if __has_include(<engine/gfx_rlgl_api.hpp>)
#include <engine/gfx_rlgl_api.hpp>
#endif
#if __has_include(<engine/gfx_raymath_api.hpp>)
#include <engine/gfx_raymath_api.hpp>
#endif
namespace eng::gfx {
// ---------------------------------------------------------------------------------------------------------------
// Engine <-> raylib glue
// (Inside eng::gfx the names Camera and Transform are raylib's own types, so the engine's are written eng::...)
// ---------------------------------------------------------------------------------------------------------------
// The raylib Camera3D for a camera entity: what BeginMode3D(...) / GetWorldToScreen(...) / GetScreenToWorldRay(...)
// take. Position and orientation come from the entity's WORLD transform (parents included, it looks down its -Z),
// fovy is the field of view in degrees (perspective) or the vertical extent in world units (orthographic), as in
// raylib. An entity without a eng::Camera component gets the Camera defaults.
[[nodiscard]] inline Camera3D toCamera3D(const eng::Transform& transform, const eng::Camera& camera) {
const Vec3 position = transform.worldPosition();
Camera3D out{};
out.position = position;
out.target = position + transform.forward();
out.up = transform.up();
out.fovy = camera.orthographic ? camera.orthoSize : camera.fovDeg;
out.projection = camera.orthographic ? static_cast<int>(CAMERA_ORTHOGRAPHIC) : static_cast<int>(CAMERA_PERSPECTIVE);
return out;
}
[[nodiscard]] inline Camera3D toCamera3D(eng::Entity cameraEntity) {
static const eng::Camera defaults{};
const eng::Camera* camera = cameraEntity.tryGet<eng::Camera>();
return toCamera3D(cameraEntity.transform(), camera ? *camera : defaults);
}
// Where a world position lands on the window (pixels, origin top-left) as seen by `camera`.
// Uses the current window size; the result is only meaningful once the window exists (inside run()).
[[nodiscard]] inline Vec2 worldToScreen(eng::Entity camera, Vec3 worldPosition) {
return getWorldToScreenEx(worldPosition, toCamera3D(camera), getScreenWidth(), getScreenHeight());
}
// The world-space ray through a window position (e.g. gfx::getMousePosition()) as seen by `camera`.
[[nodiscard]] inline Ray screenRay(eng::Entity camera, Vec2 screenPosition) {
return getScreenToWorldRayEx(screenPosition, toCamera3D(camera), getScreenWidth(), getScreenHeight());
}
} // namespace eng::gfx
+227 -5
View File
@@ -3,6 +3,7 @@
// identical meshes are shared instead of duplicated.
#pragma once
#include "ecs.hpp"
#include "material.hpp"
#include <cstdint>
#include <memory>
@@ -16,20 +17,38 @@ struct MeshData {
std::vector<Vec3> positions;
std::vector<Vec3> normals;
std::vector<Rgba> colors;
// Optional: one per vertex, or none at all (empty). (0, 0) is the top-left corner of a texture, v grows downwards.
// Without them a material maps its textures from the world instead (UvMode::Auto, see material.hpp).
std::vector<Vec2> texcoords;
[[nodiscard]] std::size_t vertexCount() const { return positions.size(); }
[[nodiscard]] std::size_t triangleCount() const { return positions.size() / 3; }
[[nodiscard]] bool empty() const { return positions.empty(); }
[[nodiscard]] bool hasTexcoords() const { return !positions.empty() && texcoords.size() == positions.size(); }
void addTriangle(Vec3 a, Vec3 b, Vec3 c, Vec3 na, Vec3 nb, Vec3 nc, Rgba ca, Rgba cb, Rgba cc) {
positions.insert(positions.end(), {a, b, c});
normals.insert(normals.end(), {na, nb, nc});
colors.insert(colors.end(), {ca, cb, cc});
if (!texcoords.empty()) texcoords.resize(positions.size()); // a mesh that has texture coordinates keeps one per vertex
}
void addTriangle(Vec3 a, Vec3 b, Vec3 c, Vec3 na, Vec3 nb, Vec3 nc, Rgba ca, Rgba cb, Rgba cc, Vec2 ta, Vec2 tb, Vec2 tc) {
texcoords.resize(positions.size()); // triangles added without them so far get (0, 0)
positions.insert(positions.end(), {a, b, c});
normals.insert(normals.end(), {na, nb, nc});
colors.insert(colors.end(), {ca, cb, cc});
texcoords.insert(texcoords.end(), {ta, tb, tc});
}
void append(const MeshData& o) {
const std::size_t had = positions.size();
positions.insert(positions.end(), o.positions.begin(), o.positions.end());
normals.insert(normals.end(), o.normals.begin(), o.normals.end());
colors.insert(colors.end(), o.colors.begin(), o.colors.end());
if (!texcoords.empty() || !o.texcoords.empty()) { // whichever side has them: the other one's vertices get (0, 0)
texcoords.resize(had);
texcoords.insert(texcoords.end(), o.texcoords.begin(), o.texcoords.end());
texcoords.resize(positions.size());
}
}
[[nodiscard]] Aabb bounds() const {
Aabb b;
@@ -66,10 +85,52 @@ private:
};
// ---- components ---------------------------------------------------------------------------
class MeshRenderer : public Component {
// Base of every component that draws something (or lights the scene).
//
// Hiding is purely visual: scripts keep updating, physics keeps simulating, colliders keep colliding
// (use Entity::setEntityEnabled() to switch an entity off completely). A hidden mesh also casts no
// shadow and a hidden light is off.
//
// visible this component draws nothing while false
// childrenVisible false hides every entity BELOW this one as well; this component itself is
// controlled by `visible`, so a parent can stay visible while its subtree is hidden
//
// Entity::setVisible(false) is the one-call version (hides the entity and everything below it); it works
// on plain group entities too, by giving them a Visibility component.
class Renderable : public Component {
public:
bool visible = true;
bool childrenVisible = true;
// Hides/shows this component and, by default, everything below its entity.
void setVisible(bool show, bool includeChildren = true) {
visible = show;
if (includeChildren) childrenVisible = show;
}
// `visible` and no ancestor hides its children.
[[nodiscard]] bool visibleInHierarchy() const;
};
// Draws nothing: gives a plain group entity the same visibility switches as a renderer, so it can hide
// its subtree (Entity::setVisible adds one automatically).
class Visibility : public Renderable {};
class MeshRenderer : public Renderable {
public:
MeshPtr mesh;
Rgba tint{255, 255, 255, 255}; // multiplied with the vertex colours
Rgba tint{255, 255, 255, 255}; // multiplied with the vertex colours (and with a material's albedo)
// Surface. The defaults give a matte, non-glowing material.
float roughness = 0.85f; // 0 = mirror-like highlights .. 1 = fully diffuse
float metallic = 0.0f; // 0 = plastic/stone/wood .. 1 = metal (the colour tints the reflection)
float emission = 0.0f; // self-lit: adds colour * emission regardless of lights (glowing: 1..3)
// A material (material.hpp) with textures and more properties. While it is set, it decides the surface: the three
// numbers above are not used (the tint still is). Share one material between as many renderers as you like.
MaterialPtr material;
bool castShadows = true;
bool receiveShadows = true;
bool unlit = false; // ignore all lighting and draw the plain colour
};
// run() renders with the first enabled Camera. It looks down the entity's -Z.
@@ -82,15 +143,167 @@ public:
float orthoSize = 10.0f; // vertical extent in world units when orthographic
};
// Directional light shining along the entity's -Z (aim it with Transform::lookAt / rotateTo).
class Light : public Component {
enum class LightType : std::uint8_t {
Directional, // the sun: parallel rays along the entity's -Z, position ignored
Point, // radiates from the entity's position
Spot, // cone from the entity's position along its -Z
};
// A light source. Aim directional/spot lights with Transform::lookAt / rotateTo. A world without any
// Light gets a default sun. Every light is a Renderable: a hidden light is off.
//
// Brightness: for the sun, `intensity` 1 lights a surface facing it with its plain colour. Point and spot
// lights fall off with the inverse square of the distance (smoothly reaching zero at `range`), so give
// them more: `intensity` is the brightness one world unit away.
class Light : public Renderable {
public:
LightType type = LightType::Directional;
Vec3 color{1.0f, 0.98f, 0.92f};
float intensity = 1.0f;
float range = 20.0f; // Point/Spot: distance at which the light has faded to nothing
float spotAngleDeg = 50.0f; // Spot: full cone angle
float spotSoftness = 0.25f; // Spot: 0 = hard edge .. 1 = fades from the axis outwards
bool castShadows = true; // Directional and Spot lights (see Environment::maxShadowLights)
float shadowBias = 1.0f; // multiplier on the automatic depth bias: raise for acne, lower for floating shadows
static Light directional(Vec3 color = {1.0f, 0.98f, 0.92f}, float intensity = 1.0f) {
Light l;
l.color = color;
l.intensity = intensity;
return l;
}
static Light point(Vec3 color, float intensity = 30.0f, float range = 15.0f) {
Light l;
l.type = LightType::Point;
l.color = color;
l.intensity = intensity;
l.range = range;
l.castShadows = false;
return l;
}
static Light spot(Vec3 color, float intensity = 60.0f, float range = 25.0f, float angleDeg = 50.0f) {
Light l;
l.type = LightType::Spot;
l.color = color;
l.intensity = intensity;
l.range = range;
l.spotAngleDeg = angleDeg;
return l;
}
};
enum class ToneMapping : std::uint8_t { None, Reinhard, Aces };
// Scene-wide look: ambient light, exposure, fog and shadow quality. The first enabled Environment
// in the world is used; without one the defaults below apply.
class Environment : public Component {
public:
// Hemisphere ambient: `ambientSky` lights surfaces facing up, `ambientGround` surfaces facing down.
// AmbientZone components override this inside their region.
Vec3 ambientSky{0.42f, 0.47f, 0.56f};
Vec3 ambientGround{0.20f, 0.18f, 0.16f};
float ambientIntensity = 1.0f;
float exposure = 1.0f;
ToneMapping toneMapping = ToneMapping::Aces;
// Distance fog (exponential squared); 0 = off.
Vec3 fogColor{0.62f, 0.70f, 0.82f};
float fogDensity = 0.0f;
// Shadows
bool shadows = true;
int shadowResolution = 2048; // texels per side, rounded to a power of two in [256, 4096]
float shadowDistance = 60.0f; // how far from the camera the sun's shadow reaches
float shadowSoftness = 1.0f; // filter radius in texels (0 = hard edges)
float shadowStrength = 1.0f; // 0 = no visible shadow .. 1 = fully dark
int maxShadowLights = 3; // shadow maps per frame: the sun first, then the nearest shadowing spots
// Ambient zones are baked into a 3D grid this fine (world units per cell), coarsened automatically
// when the zones span more than `zoneMaxCells` cells.
float zoneCellSize = 0.5f;
int zoneMaxCells = 262144;
};
enum class ZoneShape : std::uint8_t {
Box, // `size`, centred on the entity
Sphere, // `radius`, centred on the entity (an ellipsoid when the entity is scaled)
Capsule, // `radius` and `height` (end to end) along local Y, centred
Cylinder, // `radius` and `height` along local Y, centred
Polygon, // `polygon` on the local XZ plane, extruded from y = 0 up to `height`
Mesh, // any closed mesh in local space (e.g. an extruded SVG shape)
};
enum class ZoneMode : std::uint8_t {
Replace, // the zone's ambient takes the place of what is below it
Add, // adds its ambient to what is below it
Multiply, // tints/darkens what is below it (1 = unchanged)
};
// A region of space with its own ambient light.
//
// The shape lives in the entity's local space, so moving, rotating or scaling the entity moves, turns
// and resizes the zone. It can be a box, sphere, capsule, cylinder, any polygon or any closed mesh, and
// fades in over `falloff` world units outside its boundary (0 = hard edge). Overlapping zones are applied
// in `priority` order (the highest ends up on top); where no zone applies the Environment's ambient is used.
// Zones are baked into a grid (see Environment::zoneCellSize); the light is evaluated per pixel, so large
// meshes crossing a boundary shade correctly. Disable the component to switch a zone off. Moving, resizing or
// recolouring a zone re-bakes only what it touches (about a millisecond on a level of ~90,000 cells), so zones
// that follow a player or breathe are fine; a bigger grid (smaller zoneCellSize, larger world) costs more.
class AmbientZone : public Component {
public:
ZoneShape shape = ZoneShape::Box;
Vec3 size{4.0f, 3.0f, 4.0f};
float radius = 2.0f;
float height = 4.0f;
std::vector<Vec2> polygon;
MeshPtr mesh;
Vec3 sky{0.42f, 0.47f, 0.56f}; // ambient from above inside the zone
Vec3 ground{0.20f, 0.18f, 0.16f}; // ambient from below inside the zone
float intensity = 1.0f;
ZoneMode mode = ZoneMode::Replace;
float falloff = 1.0f; // world units over which the zone fades in outside its boundary
int priority = 0;
static AmbientZone box(Vec3 size, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Box;
z.size = size;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone sphere(float radius, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Sphere;
z.radius = radius;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone polygonal(std::vector<Vec2> outline, float height, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Polygon;
z.polygon = std::move(outline);
z.height = height;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone fromMesh(MeshPtr closedMesh, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Mesh;
z.mesh = std::move(closedMesh);
z.sky = sky;
z.ground = ground;
return z;
}
};
// Screen-space text drawn on top of the 3D scene (HUDs, debug read-outs).
class Text : public Component {
class Text : public Renderable {
public:
std::string value;
Vec2 position{10, 10}; // pixels from the top-left corner
@@ -98,4 +311,13 @@ public:
Rgba color{20, 20, 30, 255};
};
// Calls f(Renderable&) for every renderer-type component on `e` (the renderer's visibility rules read
// these). Add new Renderable component types here.
template <class F> void forEachRenderable(Entity e, F&& f) {
if (MeshRenderer* c = e.tryGet<MeshRenderer>()) f(static_cast<Renderable&>(*c));
if (Light* c = e.tryGet<Light>()) f(static_cast<Renderable&>(*c));
if (Text* c = e.tryGet<Text>()) f(static_cast<Renderable&>(*c));
if (Visibility* c = e.tryGet<Visibility>()) f(static_cast<Renderable&>(*c));
}
} // namespace eng
-3
View File
@@ -36,9 +36,6 @@ public:
[[nodiscard]] static Vec2 mouseDelta(); // pixels moved since last frame
[[nodiscard]] static float mouseWheel(); // notches scrolled this frame (+ = away from you)
static void enableCursor();
static void disableCursor();
[[nodiscard]] static Vec2 windowSize(); // framebuffer size in pixels (0,0 when headless)
};
+98
View File
@@ -0,0 +1,98 @@
// Materials: how a surface looks under light. 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).
//
// albedo the colour of the surface under plain white light albedo (colour) * albedoMap (colour, RGB)
// diffuse how much of the light it scatters in all directions diffuse * diffuseMap (R)
// specular how strongly a non-metal reflects light like glass specular, specularColor * specularMap (R)
// metalness 0 = plastic/stone/wood .. 1 = metal (reflects in its colour, no diffuse) metalness * metalnessMap (R)
// roughness 0 = mirror-like highlights .. 1 = fully matte roughness * roughnessMap (R)
// normals small bumps without geometry (tangent space, see below) normalMap, normalStrength
// emission self-lit: adds albedo * emission whatever the lights do emission
//
// Colour maps (albedoMap) hold gamma-encoded colours like every colour in the engine; data maps (all the others) hold the
// numbers as they are. A normal map stores the surface normal in tangent space as RGB = (x, y, z) * 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)).
//
// Where the texture coordinates come from: the mesh's own (boxes and spheres have them; so can your own meshes), or the world:
// UvMode::World projects the textures along the three axes by position, so ANY geometry takes them with no unwrapping and
// neighbouring pieces (the extruded SVG shapes of a level) line up seamlessly. See UvMode.
//
// Give a MeshRenderer a material with MeshRenderer::material. Materials are shared and meant to be edited: change a field
// and every renderer that uses the material follows from the next frame (a flickering lamp is one assignment per frame).
// A renderer without a material keeps its own roughness / metallic / emission and the plain look.
//
// Like meshes, objects with the same material and mesh draw in one instanced draw call.
#pragma once
#include "math.hpp"
#include "texture.hpp"
#include <cstdint>
#include <memory>
namespace eng {
enum class UvMode : std::uint8_t {
Auto, // the mesh's own texture coordinates if it has them, else World
Mesh, // the mesh's own texture coordinates (a mesh without them shows one texel of each texture)
World, // projected from the world along x, y and z (triplanar), blended where faces turn: no UVs needed
};
class Material;
using MaterialPtr = std::shared_ptr<Material>;
class Material {
public:
Material();
Material(const Material&) = delete; // a copy would share the id; clone() makes a proper new material
Material& operator=(const Material&) = delete;
static MaterialPtr create(); // a new material: white, matte, not shiny, no maps
[[nodiscard]] MaterialPtr clone() const; // a new material (and id) with the same numbers and the same textures
// ---- albedo ----
Vec3 albedo{1, 1, 1}; // multiplies the map, the mesh's vertex colours and MeshRenderer::tint
TexturePtr albedoMap; // its alpha multiplies the opacity of the tint
// ---- diffuse: the scattered light ----
float diffuse = 1.0f; // 1 as usual .. 0 none (a surface that only reflects)
TexturePtr diffuseMap; // R
// ---- specular: the reflection of non-metals ----
float specular = 0.5f; // 0 none .. 1 strong; 0.5 is the 4% reflectance of plastic and glass
Vec3 specularColor{1, 1, 1}; // tints it (metals reflect in their albedo instead)
TexturePtr specularMap; // R
// ---- metalness ----
float metalness = 0.0f; // 0 .. 1
TexturePtr metalnessMap; // R
// ---- roughness ----
float roughness = 0.85f; // 0 .. 1 (the same default as MeshRenderer)
TexturePtr roughnessMap; // R
// ---- normals ----
TexturePtr normalMap;
float normalStrength = 1.0f; // 0 flat .. 1 as authored .. more exaggerates
// ---- emission ----
float emission = 0.0f; // 0 .. 4: adds albedo * emission regardless of lights (glowing: 1..3)
// ---- texture coordinates ----
UvMode uv = UvMode::Auto;
Vec2 uvScale{1, 1}; // repeats per unit: one repeat per world unit for World and for the boxes and spheres of Resources
Vec2 uvOffset{0, 0}; // shifts the textures (in repeats)
[[nodiscard]] std::uint64_t id() const noexcept { return id_; } // unique for the process lifetime
// Which maps are set, one bit each (the renderer's lit shader takes the same bits).
enum MapBit : unsigned { kAlbedoMap = 1, kDiffuseMap = 2, kSpecularMap = 4, kMetalnessMap = 8, kRoughnessMap = 16, kNormalMap = 32 };
inline static constexpr int kMapCount = 6;
[[nodiscard]] unsigned maps() const noexcept {
return (albedoMap ? kAlbedoMap : 0u) | (diffuseMap ? kDiffuseMap : 0u) | (specularMap ? kSpecularMap : 0u) |
(metalnessMap ? kMetalnessMap : 0u) | (roughnessMap ? kRoughnessMap : 0u) | (normalMap ? kNormalMap : 0u);
}
private:
std::uint64_t id_;
};
} // namespace eng
+73
View File
@@ -227,6 +227,79 @@ struct Mat4 {
return r;
}
// General 4x4 inverse (identity when singular). Use inverseAffine() for transforms: it is cheaper.
Mat4 inverse() const {
const float* a = m;
float inv[16];
inv[0] = a[5] * a[10] * a[15] - a[5] * a[11] * a[14] - a[9] * a[6] * a[15] + a[9] * a[7] * a[14] + a[13] * a[6] * a[11] - a[13] * a[7] * a[10];
inv[4] = -a[4] * a[10] * a[15] + a[4] * a[11] * a[14] + a[8] * a[6] * a[15] - a[8] * a[7] * a[14] - a[12] * a[6] * a[11] + a[12] * a[7] * a[10];
inv[8] = a[4] * a[9] * a[15] - a[4] * a[11] * a[13] - a[8] * a[5] * a[15] + a[8] * a[7] * a[13] + a[12] * a[5] * a[11] - a[12] * a[7] * a[9];
inv[12] = -a[4] * a[9] * a[14] + a[4] * a[10] * a[13] + a[8] * a[5] * a[14] - a[8] * a[6] * a[13] - a[12] * a[5] * a[10] + a[12] * a[6] * a[9];
inv[1] = -a[1] * a[10] * a[15] + a[1] * a[11] * a[14] + a[9] * a[2] * a[15] - a[9] * a[3] * a[14] - a[13] * a[2] * a[11] + a[13] * a[3] * a[10];
inv[5] = a[0] * a[10] * a[15] - a[0] * a[11] * a[14] - a[8] * a[2] * a[15] + a[8] * a[3] * a[14] + a[12] * a[2] * a[11] - a[12] * a[3] * a[10];
inv[9] = -a[0] * a[9] * a[15] + a[0] * a[11] * a[13] + a[8] * a[1] * a[15] - a[8] * a[3] * a[13] - a[12] * a[1] * a[11] + a[12] * a[3] * a[9];
inv[13] = a[0] * a[9] * a[14] - a[0] * a[10] * a[13] - a[8] * a[1] * a[14] + a[8] * a[2] * a[13] + a[12] * a[1] * a[10] - a[12] * a[2] * a[9];
inv[2] = a[1] * a[6] * a[15] - a[1] * a[7] * a[14] - a[5] * a[2] * a[15] + a[5] * a[3] * a[14] + a[13] * a[2] * a[7] - a[13] * a[3] * a[6];
inv[6] = -a[0] * a[6] * a[15] + a[0] * a[7] * a[14] + a[4] * a[2] * a[15] - a[4] * a[3] * a[14] - a[12] * a[2] * a[7] + a[12] * a[3] * a[6];
inv[10] = a[0] * a[5] * a[15] - a[0] * a[7] * a[13] - a[4] * a[1] * a[15] + a[4] * a[3] * a[13] + a[12] * a[1] * a[7] - a[12] * a[3] * a[5];
inv[14] = -a[0] * a[5] * a[14] + a[0] * a[6] * a[13] + a[4] * a[1] * a[14] - a[4] * a[2] * a[13] - a[12] * a[1] * a[6] + a[12] * a[2] * a[5];
inv[3] = -a[1] * a[6] * a[11] + a[1] * a[7] * a[10] + a[5] * a[2] * a[11] - a[5] * a[3] * a[10] - a[9] * a[2] * a[7] + a[9] * a[3] * a[6];
inv[7] = a[0] * a[6] * a[11] - a[0] * a[7] * a[10] - a[4] * a[2] * a[11] + a[4] * a[3] * a[10] + a[8] * a[2] * a[7] - a[8] * a[3] * a[6];
inv[11] = -a[0] * a[5] * a[11] + a[0] * a[7] * a[9] + a[4] * a[1] * a[11] - a[4] * a[3] * a[9] - a[8] * a[1] * a[7] + a[8] * a[3] * a[5];
inv[15] = a[0] * a[5] * a[10] - a[0] * a[6] * a[9] - a[4] * a[1] * a[10] + a[4] * a[2] * a[9] + a[8] * a[1] * a[6] - a[8] * a[2] * a[5];
const float det = a[0] * inv[0] + a[1] * inv[4] + a[2] * inv[8] + a[3] * inv[12];
if (std::fabs(det) < 1e-30f) return {};
const float id = 1.0f / det;
Mat4 r;
for (int i = 0; i < 16; ++i) r.m[i] = inv[i] * id;
return r;
}
// Transforms a point by a projective matrix (perspective divide included).
Vec3 transformProjective(Vec3 p) const {
const float x = m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12];
const float y = m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13];
const float z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
const float w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
const float iw = std::fabs(w) > 1e-20f ? 1.0f / w : 1.0f;
return {x * iw, y * iw, z * iw};
}
// Right-handed view matrix: a camera at `eye` looks at `target` down its -Z axis.
static Mat4 lookAt(Vec3 eye, Vec3 target, Vec3 up) {
const Vec3 f = (target - eye).normalized();
Vec3 side = cross(f, up).normalized();
if (side.lengthSq() < 0.5f) side = cross(f, std::fabs(f.y) < 0.99f ? Vec3{0, 1, 0} : Vec3{1, 0, 0}).normalized(); // up parallel to the view
const Vec3 u = cross(side, f);
Mat4 r;
r.m[0] = side.x; r.m[4] = side.y; r.m[8] = side.z; r.m[12] = -dot(side, eye);
r.m[1] = u.x; r.m[5] = u.y; r.m[9] = u.z; r.m[13] = -dot(u, eye);
r.m[2] = -f.x; r.m[6] = -f.y; r.m[10] = -f.z; r.m[14] = dot(f, eye);
return r;
}
// OpenGL clip space (depth -1..1), vertical field of view in degrees.
static Mat4 perspective(float fovyDeg, float aspect, float nearPlane, float farPlane) {
const float t = 1.0f / std::tan(fovyDeg * kDegToRad * 0.5f);
Mat4 r;
r.m[0] = t / aspect;
r.m[5] = t;
r.m[10] = (farPlane + nearPlane) / (nearPlane - farPlane);
r.m[11] = -1.0f;
r.m[14] = 2.0f * farPlane * nearPlane / (nearPlane - farPlane);
r.m[15] = 0.0f;
return r;
}
static Mat4 ortho(float left, float right, float bottom, float top, float nearPlane, float farPlane) {
Mat4 r;
r.m[0] = 2.0f / (right - left);
r.m[5] = 2.0f / (top - bottom);
r.m[10] = -2.0f / (farPlane - nearPlane);
r.m[12] = -(right + left) / (right - left);
r.m[13] = -(top + bottom) / (top - bottom);
r.m[14] = -(farPlane + nearPlane) / (farPlane - nearPlane);
return r;
}
// Split into translation / rotation / (lossy) scale.
void decompose(Vec3& t, Quat& q, Vec3& s) const {
t = translation();
+1 -1
View File
@@ -22,7 +22,7 @@ struct ExtrudeOptions {
float defaultBase = 0.0f; // used when a shape has no y label
float defaultTop = 1.0f; // used when a shape has no h label
Rgba fallbackColor{200, 200, 200, 255}; // when there is neither a color label nor a fill
float smoothAngleDeg = 40.0f; // wall normals are averaged across edges bending less than this
float smoothAngleDeg = 40.0f; // normals are averaged across edges bending less than this (walls, lofted bands, caps)
int precision = 4; // Clipper2 decimal places (in SVG user units)
double curveTolerance = 0.05; // max error when flattening curves/arcs, SVG user units
};
+28 -7
View File
@@ -5,12 +5,33 @@
// * An entity with a Collider gets a body. With a RigidBody it is Static / Kinematic /
// Dynamic as requested; without one it is a Static body (a level piece).
// * Bodies are created lazily at the start of the next physics step, so component order
// does not matter. Change a shape/setting at runtime, then call rebuild() on the component.
// does not matter. Surface and body settings (friction, restitution, trigger, mass, ...) are
// read when the body is created: change them at runtime, then call rebuild() on the component.
// * Every fixed step: entity transforms -> bodies (kinematic bodies are moved, static and
// dynamic bodies are teleported only if you moved the entity), simulate, then dynamic
// bodies -> entity transforms.
// * Collider shapes are baked with the entity's world scale at creation time, and shared
// between bodies that ask for the same shape (clones, repeated props, shared meshes).
// 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 transforms. 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. Characters only move.
// * 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. Editing size / radius / height / offset / mesh / shape also
// takes effect by itself on the next step.
// A box scales per axis, a sphere stays round (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.
// A negative scale mirrors whatever can land on the other side of the entity (an off-centre
// collider, a hull, a mesh); a centred box, sphere, capsule or cylinder looks the same mirrored, so
// flipping those costs nothing.
// 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 are shared between bodies that ask for the same shape (clones, repeated props, shared
// meshes): a mesh, or a hull scaled alike on every axis, is a cheap wrapper around one shared
// shape, while a hull stretched unevenly gets its own, built from the scaled points so it keeps the
// proportions of its mesh.
// * A character's capsule follows the entity's scale the same way (radius from X/Z, height from Y)
// and keeps its fall speed when it is rebuilt.
// * Limit: a dynamic body under a parent that is both rotated and scaled unevenly (a sheared parent)
// cannot be shown exactly; it gets the closest rotation.
// * Contact events call collisionEnter/Exit(Entity other) and triggerEnter/Exit(Entity other)
// on components of both entities (Collider::trigger = true makes a sensor volume).
#pragma once
@@ -27,10 +48,10 @@ enum class BodyType : std::uint8_t { Static, Kinematic, Dynamic };
class Collider : public Component {
public:
ShapeType shape = ShapeType::Box;
Vec3 size{1, 1, 1}; // Box: full extents
Vec3 size{1, 1, 1}; // Box: full extents (before the entity's world scale)
float radius = 0.5f; // Sphere / Capsule / Cylinder
float height = 1.0f; // Capsule (total height including caps) / Cylinder
Vec3 offset{0, 0, 0}; // shape centre relative to the entity origin
Vec3 offset{0, 0, 0}; // shape centre relative to the entity origin (scaled with the entity)
float friction = 0.5f;
float restitution = 0.0f;
bool trigger = false; // sensor: reports overlaps, never pushes anything
+9 -2
View File
@@ -20,13 +20,20 @@ class Resources {
public:
Resources() = delete;
// Procedural primitives, centred on the origin, outward facing, vertex-coloured.
// Procedural primitives, centred on the origin, outward facing, vertex-coloured. They carry texture coordinates for
// materials: a box shows a texture once per world unit on every face (not stretched, however long it is; v grows
// downwards from the top edge of a face), a sphere wraps it once around and from pole to pole.
static MeshPtr box(Vec3 size = {1, 1, 1}, Rgba color = {255, 255, 255, 255});
static MeshPtr sphere(float radius = 0.5f, int segments = 24, int rings = 14, Rgba color = {255, 255, 255, 255});
// Your own mesh, cached under `key`: `build` only runs if no live mesh has that key.
static MeshPtr mesh(const std::string& key, const std::function<MeshData()>& build);
// An image file as a texture (see texture.hpp), shared between everything that asks for the same file with the same
// options. Throws std::runtime_error when the file cannot be read or no image decoder is installed (eng::run() installs one).
// The cache key includes the file's size and modification time, so edited files are reloaded.
static TexturePtr texture(const std::string& path, TextureOptions options = {});
// Labelled SVG drawing -> extruded model. Throws std::runtime_error on a missing/invalid file.
// The cache key includes the file's size and modification time, so edited files are reloaded.
static ModelPtr svg(const std::string& path, const ExtrudeOptions& options = {});
@@ -36,7 +43,7 @@ public:
static void collect();
struct Stats {
std::size_t liveMeshes = 0, liveModels = 0; // currently shared resources
std::size_t liveMeshes = 0, liveModels = 0, liveTextures = 0; // currently shared resources
std::size_t hits = 0, misses = 0; // cache lookups since start
};
[[nodiscard]] static Stats stats();
+88
View File
@@ -0,0 +1,88 @@
// Images for materials (material.hpp). A Texture is immutable RGBA8 pixel data shared by everything that uses it, like
// a Mesh: the renderer keeps one GPU copy per Texture::id(), however many materials point at it. Nothing here depends on
// raylib/OpenGL, so textures can be built (and tested) headless.
//
// Image files come through Resources::texture(path). Decoding them is the window's job (eng::run() installs raylib's
// decoder: PNG, JPG, BMP, TGA, ...); a program without a window can install its own with setImageLoader().
//
// Conventions: pixel (0, 0) is the TOP-left corner of the image and texture coordinate v = 0 is its top edge (v grows
// downwards, as in raylib). Colour textures (albedo, specular colour) hold gamma-encoded values like every colour in the
// engine; data textures (normal, roughness, metalness, specular, diffuse) hold the numbers as they are.
#pragma once
#include "math.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace eng {
enum class TextureWrap : std::uint8_t {
Repeat, // tiles forever (the default)
Clamp, // the edge pixels continue
};
enum class TextureFilter : std::uint8_t {
Smooth, // mipmaps + trilinear (+ anisotropic where the GPU has it): no shimmer at a distance
Pixelated, // nearest pixel, no mipmaps: crisp pixel art
};
struct TextureOptions {
TextureWrap wrap = TextureWrap::Repeat;
TextureFilter filter = TextureFilter::Smooth;
bool operator==(const TextureOptions&) const = default;
};
class Texture;
using TexturePtr = std::shared_ptr<const Texture>;
class Texture {
public:
// `rgba`: width * height * 4 bytes, top row first. Throws std::invalid_argument for a size that is not 1..16384 or a buffer
// of the wrong length.
Texture(int width, int height, std::vector<std::uint8_t> rgba, TextureOptions options);
static TexturePtr create(int width, int height, std::vector<std::uint8_t> rgba, TextureOptions options = {});
// ---- procedural textures (no files needed: placeholders, tests, patterns) ----
static TexturePtr solid(Rgba color, TextureOptions options = {}); // 1 x 1
// `cells` x `cells` squares alternating between two colours, `size` pixels wide.
static TexturePtr checker(int size = 64, int cells = 8, Rgba a = {230, 230, 230, 255}, Rgba b = {90, 90, 90, 255}, TextureOptions options = {});
// A tangent-space normal map from a height map (its red channel: white = high), with wrap-around so it tiles.
// `strength`: slope per unit of height difference between neighbouring pixels (about 1..8).
static TexturePtr normalMapFromHeight(const Texture& height, float strength = 3.0f);
[[nodiscard]] int width() const noexcept { return width_; }
[[nodiscard]] int height() const noexcept { return height_; }
[[nodiscard]] const std::vector<std::uint8_t>& pixels() const noexcept { return pixels_; }
[[nodiscard]] const TextureOptions& options() const noexcept { return options_; }
[[nodiscard]] std::uint64_t id() const noexcept { return id_; } // unique for the process lifetime
[[nodiscard]] Rgba texel(int x, int y) const; // wraps or clamps like the texture does
// Bilinear sample at texture coordinates `uv` (wrapping or clamping like the GPU does), channels 0..1. Handy for tests
// and CPU-side effects; the GPU does its own filtering (mipmaps, anisotropy).
struct Sample { float r, g, b, a; };
[[nodiscard]] Sample sample(Vec2 uv) const;
private:
int width_, height_;
std::vector<std::uint8_t> pixels_;
TextureOptions options_;
std::uint64_t id_;
};
// ---- decoding image files ------------------------------------------------------------------------
struct ImageData {
int width = 0, height = 0;
std::vector<std::uint8_t> rgba; // width * height * 4, top row first
};
// Reads and decodes the file at `path`; false when it cannot be read.
using ImageLoader = std::function<bool(const std::string& path, ImageData& out)>;
// eng::run() installs one that uses raylib. Replace it to read another way (or to run without a window).
void setImageLoader(ImageLoader loader);
[[nodiscard]] bool hasImageLoader();
// Decodes `path` with the installed loader. Throws std::runtime_error when there is none or the file cannot be read.
[[nodiscard]] ImageData loadImageFile(const std::string& path);
} // namespace eng
-402
View File
@@ -1,402 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Demo level. Every shape carries an Inkscape "label" (inkscape:label) with the extrusion labels:
y=<base elevation> h=<top elevation> e=(fraction,ratio)... color=#rrggbb
#group @id vpatch spatch only=(...) except=(...)
Open it in Inkscape and edit the labels in Object Properties (Ctrl+Shift+O). -->
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg10"
sodipodi:docname="level.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.4049375"
inkscape:cx="108.90164"
inkscape:cy="478.66898"
inkscape:window-width="1366"
inkscape:window-height="740"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="true">
<inkscape:grid
id="grid10"
units="mm"
originx="0"
originy="0"
spacingx="0.99999999"
spacingy="0.99999999"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
</sodipodi:namedview>
<g
inkscape:label="terrain"
inkscape:groupmode="layer"
id="layer1">
<!-- floor slab: 0.5 thick, top at y=0 -->
<rect
inkscape:label="y=-0.5 h=-.01 color=#5d7a5a @ground #floor"
x="0"
y="0"
width="100"
height="178.56743"
id="rect1"
style="stroke-width:1.33629" />
</g>
<g
inkscape:label="walls #walls y=0 h=2.5 color=#9a9aa5"
inkscape:groupmode="layer"
id="layer2">
<!-- the group label is inherited by the four walls below -->
<rect
inkscape:label="@north"
x="3"
y="3"
width="94"
height="3"
id="rect2"
style="fill:#483e37" />
<rect
inkscape:label="@south"
x="3"
y="173.37502"
width="94"
height="3"
id="rect3"
style="fill:#483e37" />
<rect
inkscape:label="@west"
x="3"
y="6"
width="3"
height="167.37502"
id="rect4"
style="fill:#483e37;stroke-width:1.37913" />
<rect
inkscape:label="@east"
x="94"
y="6"
width="3"
height="167.37502"
id="rect5"
style="fill:#483e37;stroke-width:1.37913" />
<!-- spatch: patches every #walls shape it touches (here: only the east wall) -->
<rect
inkscape:label="spatch h=5 color=#c9a0a0 only=(#walls) except=(@north @south @west)"
x="92"
y="40"
width="8"
height="12"
style="fill:#ff00ff;fill-opacity:0.3"
id="rect6" />
</g>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="house #house y=0 h=5">
<rect
inkscape:label="floor y=-.5 h=0"
x="15"
y="95"
width="70"
height="69.999977"
id="rect3-6-56-9-3-1-2"
style="display:inline;fill:#ac9d93;stroke-width:1.86418" />
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="wall"
style="fill:#483e37;stroke-width:0.326164">
<rect
inkscape:label="b"
x="15"
y="162"
width="10"
height="2.9999988"
id="rect3-6"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=4.2 h=5"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_low h=.8"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9-3"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="a"
x="75"
y="162"
width="9.999999"
height="2.9999988"
id="rect3-6-56"
style="fill:#483e37;stroke-width:0.326164" />
</g>
<g
inkscape:groupmode="layer"
id="layer6-7"
inkscape:label="wall"
transform="rotate(-90,16.500001,163.5)"
style="fill:#483e37;stroke-width:0.326164">
<rect
inkscape:label="b"
x="15"
y="162"
width="10"
height="2.9999988"
id="rect3-6-9"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=4.2 h=5"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9-2"
style="fill:#483e37;stroke-width:0.729325" />
<rect
inkscape:label="ab_low h=.8"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9-3-0"
style="fill:#483e37;stroke-width:0.729325" />
<rect
inkscape:label="a"
x="75"
y="162"
width="9.999999"
height="2.9999988"
id="rect3-6-56-2"
style="fill:#483e37;stroke-width:0.326164" />
</g>
<g
inkscape:groupmode="layer"
id="layer6-75"
inkscape:label="wall"
style="fill:#483e37;stroke-width:0.326164"
transform="rotate(180,50,130)">
<rect
inkscape:label="b"
x="15"
y="162"
width="9.999999"
height="2.9999988"
id="rect3-6-92"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=4.2 h=5"
x="210"
y="-85"
width="12"
height="2.9999988"
id="rect3-6-56-9-3-0-5-3"
style="fill:#483e37;stroke-width:0.357295"
transform="rotate(180,123.5,40)" />
<rect
inkscape:label="ab_low h=.8"
x="-37"
y="-165"
width="12"
height="2.9999988"
id="rect3-6-56-9-3-0-5-3-6"
style="fill:#483e37;stroke-width:0.357295"
transform="scale(-1)" />
<rect
inkscape:label="b"
x="37"
y="162"
width="9.999999"
height="2.9999988"
id="rect3-6-92-8"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=3 h=5"
x="47"
y="162"
width="6"
height="2.9999988"
id="rect3-6-56-9-28"
style="fill:#483e37;stroke-width:0.112987" />
<rect
inkscape:label="a"
x="75"
y="162"
width="10"
height="2.9999988"
id="rect3-6-56-7"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=4.2 h=5"
x="172"
y="-85"
width="11.999999"
height="2.9999988"
id="rect3-6-56-9-3-0-5"
style="fill:#483e37;stroke-width:0.357295"
transform="rotate(180,123.5,40)" />
<rect
inkscape:label="ab_low h=.8"
x="-75"
y="-165"
width="11.999999"
height="2.9999988"
id="rect3-6-56-9-3-0-5-1"
style="fill:#483e37;stroke-width:0.357295"
transform="scale(-1)" />
<rect
inkscape:label="a"
x="53"
y="162"
width="10"
height="2.9999988"
id="rect3-6-56-7-4"
style="fill:#483e37;stroke-width:0.326164" />
</g>
<g
inkscape:groupmode="layer"
id="layer6-7-3"
inkscape:label="wall"
transform="rotate(90,83.499998,163.5)"
style="fill:#483e37;stroke-width:0.326164">
<rect
inkscape:label="b"
x="15"
y="162"
width="10"
height="2.9999988"
id="rect3-6-9-6"
style="fill:#483e37;stroke-width:0.326164" />
<rect
inkscape:label="ab_hi y=4.2 h=5"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9-2-1"
style="fill:#483e37;stroke-width:0.729325" />
<rect
inkscape:label="ab_low h=.8"
x="25"
y="162"
width="50"
height="2.9999988"
id="rect3-6-56-9-3-0-2"
style="fill:#483e37;stroke-width:0.729325" />
<rect
inkscape:label="a"
x="75"
y="162"
width="9.999999"
height="2.9999988"
id="rect3-6-56-2-9"
style="fill:#483e37;stroke-width:0.326164" />
</g>
<rect
inkscape:label="roof y=5 h=5.2"
x="15"
y="95"
width="70"
height="69.999977"
id="rect3-6-56-9-3-1"
style="display:inline;fill:#483e37;stroke-width:1.86418" />
</g>
<g
inkscape:label="props"
inkscape:groupmode="layer"
id="layer3">
<!-- vase: at 30% of the height the outline shrinks to 75%, at 70% it grows to 125% -->
<circle
inkscape:label="y=0 h=7 e=(0.3,0.75)(0.7,1.25) color=#c9866b #decor @vase"
cx="28"
cy="28"
r="5"
id="circle6"
style="fill:#a02c2c" />
<!-- pyramid: the top ring collapses to a point (ratio 0) -->
<rect
inkscape:label="y=0 h=4 e=(1,0) color=#e0c060 #decor @pyramid"
x="66"
y="64"
width="12"
height="12"
id="rect7"
style="fill:#a02c2c" />
<!-- ziggurat: equal fractions make a horizontal ledge; the profile starts/ends at ratio 1 unless given -->
<rect
inkscape:label="y=0 h=3 e=(0.33,1)(0.33,0.75)(0.66,0.75)(0.66,0.5)(1,0.5) color=#8fb8d8 #decor @steps"
x="14"
y="66"
width="18"
height="18"
id="rect8"
style="fill:#a02c2c" />
<!-- ring (evenodd hole) -->
<path
inkscape:label="y=0 h=1 color=#d8d8d8 @ring"
style="fill-rule:evenodd;fill:#a02c2c"
d="M45,45 h14 v14 h-14 Z M48,48 v8 h8 v-8 Z"
id="path8" />
<!-- a wall drawn as a stroked line: Clipper2 offsets it into a 2-unit-thick outline -->
<path
inkscape:label="y=0 h=1.6 color=#a58f7a #decor @fence"
d="M22,50 L40,50 L40,38"
style="fill:none;stroke:#a02c2c;stroke-width:2"
id="path9" />
</g>
<g
inkscape:label="ramp"
inkscape:groupmode="layer"
id="layer4">
<rect
inkscape:label="y=0 h=0.3 color=#b58bd6 @ramp #decor"
x="56"
y="12"
width="26"
height="12"
id="rect9"
style="fill:#decd87" />
<!-- vpatch: patches only the vertices it covers (the two right-hand corners) of the ramp -->
<rect
inkscape:label="vpatch h=3 color=#ff5a5a only=(@ramp)"
x="80"
y="10"
width="4"
height="16"
style="fill:#ff0000;fill-opacity:0.3"
id="rect10" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

+956
View File
@@ -0,0 +1,956 @@
// Ambient zones: shape tests for any shape, and the bake of all zones into one 3D grid that the pixel shader
// samples (so a large mesh crossing a zone boundary is shaded correctly, per pixel).
#include "engine/detail/scene.hpp"
#include <algorithm>
#include <bit>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <deque>
#include <limits>
#include <unordered_map>
#include <unordered_set>
namespace eng::detail {
namespace {
constexpr float kInf = std::numeric_limits<float>::infinity();
float smoothstep01(float x) {
x = std::clamp(x, 0.0f, 1.0f);
return x * x * (3.0f - 2.0f * x);
}
// ---------------------------------------------------------------------------------------------
// Shapes
// ---------------------------------------------------------------------------------------------
struct Box3 {
Vec3 lo{kInf, kInf, kInf}, hi{-kInf, -kInf, -kInf};
bool valid() const { return lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z; }
void add(Vec3 p) {
lo = {std::min(lo.x, p.x), std::min(lo.y, p.y), std::min(lo.z, p.z)};
hi = {std::max(hi.x, p.x), std::max(hi.y, p.y), std::max(hi.z, p.z)};
}
};
bool usable(const ZoneData& z) {
switch (z.shape) {
case ZoneShape::Box: return z.size.x > 0 && z.size.y > 0 && z.size.z > 0;
case ZoneShape::Sphere: return z.radius > 0;
case ZoneShape::Capsule:
case ZoneShape::Cylinder: return z.radius > 0 && z.height > 0;
case ZoneShape::Polygon: return z.polygon.size() >= 3 && z.height > 0;
case ZoneShape::Mesh: return z.mesh && !z.mesh->data().empty() && z.mesh->bounds().valid();
}
return false;
}
Box3 localBounds(const ZoneData& z) {
Box3 b;
switch (z.shape) {
case ZoneShape::Box: b.add(z.size * -0.5f); b.add(z.size * 0.5f); break;
case ZoneShape::Sphere: b.add({-z.radius, -z.radius, -z.radius}); b.add({z.radius, z.radius, z.radius}); break;
case ZoneShape::Capsule:
case ZoneShape::Cylinder: {
const float h = 0.5f * std::max(z.height, z.shape == ZoneShape::Capsule ? 2.0f * z.radius : 0.0f);
b.add({-z.radius, -h, -z.radius});
b.add({z.radius, h, z.radius});
break;
}
case ZoneShape::Polygon:
for (const Vec2& p : z.polygon) { b.add({p.x, 0, p.y}); b.add({p.x, z.height, p.y}); }
break;
case ZoneShape::Mesh:
if (z.mesh && z.mesh->bounds().valid()) { b.add(z.mesh->bounds().min); b.add(z.mesh->bounds().max); }
break;
}
return b;
}
Box3 worldBounds(const ZoneData& z) {
const Box3 l = localBounds(z);
Box3 w;
if (!l.valid()) return w;
for (int i = 0; i < 8; ++i)
w.add(z.world.transformPoint({(i & 1) ? l.hi.x : l.lo.x, (i & 2) ? l.hi.y : l.lo.y, (i & 4) ? l.hi.z : l.lo.z}));
return w;
}
bool insidePolygon(const std::vector<Vec2>& poly, float x, float z) { // even-odd, either winding
bool in = false;
for (std::size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) {
const Vec2 &a = poly[i], &b = poly[j];
if (((a.y > z) != (b.y > z)) && (x < (b.x - a.x) * (z - a.y) / (b.y - a.y) + a.x)) in = !in;
}
return in;
}
// Crossings of the vertical line through (x, z) with a triangle soup, as Y values (callback form).
template <class F> void forEachTriangleXZ(const MeshData& md, F&& f) {
for (std::size_t i = 0; i + 2 < md.positions.size(); i += 3) f(md.positions[i], md.positions[i + 1], md.positions[i + 2]);
}
bool crossing(Vec3 a, Vec3 b, Vec3 c, float x, float z, float& y) {
auto side = [](Vec3 p, Vec3 q, float px, float pz) { return (q.x - p.x) * (pz - p.z) - (q.z - p.z) * (px - p.x); };
const float area = side(a, b, c.x, c.z);
if (std::fabs(area) < 1e-12f) return false; // vertical or degenerate in XZ
const float u = side(b, c, x, z) / area, v = side(c, a, x, z) / area, w = side(a, b, x, z) / area;
if (u < 0 || v < 0 || w < 0) return false;
y = u * a.y + v * b.y + w * c.y;
return true;
}
// Reference inside test for a closed mesh: ray parity along +Y (jittered so it never runs along an edge).
bool insideMeshByRay(const MeshData& md, const Aabb& bounds, Vec3 p) {
const float eps = 1e-4f * std::max({bounds.size().x, bounds.size().y, bounds.size().z, 1e-3f});
const float x = p.x + 0.7313f * eps, z = p.z + 0.4129f * eps;
int hits = 0;
forEachTriangleXZ(md, [&](Vec3 a, Vec3 b, Vec3 c) {
float y;
if (crossing(a, b, c, x, z, y) && y > p.y) ++hits;
});
return (hits & 1) != 0;
}
bool containsLocal(const ZoneData& z, Vec3 l) {
switch (z.shape) {
case ZoneShape::Box:
return std::fabs(l.x) <= 0.5f * z.size.x && std::fabs(l.y) <= 0.5f * z.size.y && std::fabs(l.z) <= 0.5f * z.size.z;
case ZoneShape::Sphere: return l.lengthSq() <= z.radius * z.radius;
case ZoneShape::Capsule: {
const float half = std::max(0.0f, 0.5f * z.height - z.radius);
const float cy = std::clamp(l.y, -half, half);
return l.x * l.x + (l.y - cy) * (l.y - cy) + l.z * l.z <= z.radius * z.radius;
}
case ZoneShape::Cylinder: return l.x * l.x + l.z * l.z <= z.radius * z.radius && std::fabs(l.y) <= 0.5f * z.height;
case ZoneShape::Polygon: return l.y >= 0 && l.y <= z.height && insidePolygon(z.polygon, l.x, l.z);
case ZoneShape::Mesh:
return z.mesh && z.mesh->bounds().valid() && l.x >= z.mesh->bounds().min.x && l.x <= z.mesh->bounds().max.x &&
l.y >= z.mesh->bounds().min.y && l.y <= z.mesh->bounds().max.y && l.z >= z.mesh->bounds().min.z &&
l.z <= z.mesh->bounds().max.z && insideMeshByRay(z.mesh->data(), z.mesh->bounds(), l);
}
return false;
}
// ---------------------------------------------------------------------------------------------
// Closed meshes, voxelised once (scanline: every XZ column is crossed with the triangles, the Y crossings
// are paired up, and the voxels between each pair are inside).
// ---------------------------------------------------------------------------------------------
struct MeshVolume {
Vec3 origin{};
float vox = 1;
int nx = 0, ny = 0, nz = 0;
std::vector<std::uint8_t> inside; // (y * nz + z) * nx + x
bool contains(Vec3 l) const {
const int ix = static_cast<int>(std::floor((l.x - origin.x) / vox));
const int iy = static_cast<int>(std::floor((l.y - origin.y) / vox));
const int iz = static_cast<int>(std::floor((l.z - origin.z) / vox));
if (ix < 0 || iy < 0 || iz < 0 || ix >= nx || iy >= ny || iz >= nz) return false;
return inside[(static_cast<std::size_t>(iy) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(iz)) * static_cast<std::size_t>(nx) +
static_cast<std::size_t>(ix)] != 0;
}
};
std::shared_ptr<MeshVolume> voxelise(const MeshData& md, const Aabb& bounds, float targetVox, std::size_t maxVoxels) {
auto vol = std::make_shared<MeshVolume>();
float vox = std::max(targetVox, 1e-4f);
const Vec3 ext = bounds.size();
auto dimsFor = [&](float v) {
return std::array<std::size_t, 3>{static_cast<std::size_t>(std::ceil(ext.x / v)) + 2, static_cast<std::size_t>(std::ceil(ext.y / v)) + 2,
static_cast<std::size_t>(std::ceil(ext.z / v)) + 2};
};
auto d = dimsFor(vox);
while (d[0] * d[1] * d[2] > maxVoxels) { vox *= 1.2f; d = dimsFor(vox); }
vol->vox = vox;
vol->nx = static_cast<int>(d[0]);
vol->ny = static_cast<int>(d[1]);
vol->nz = static_cast<int>(d[2]);
vol->origin = bounds.min - Vec3{vox, vox, vox} * 0.5f;
vol->inside.assign(d[0] * d[1] * d[2], 0);
const std::size_t columns = d[0] * d[2];
const float jx = 0.0173f * vox, jz = 0.0291f * vox; // keeps column centres off shared edges
auto colIndex = [&](int ix, int iz) { return static_cast<std::size_t>(iz) * d[0] + static_cast<std::size_t>(ix); };
// two passes over the triangles: count crossings per column, then fill (CSR layout, no per-column vectors)
auto traverse = [&](auto&& emit) {
forEachTriangleXZ(md, [&](Vec3 a, Vec3 b, Vec3 c) {
const float minx = std::min({a.x, b.x, c.x}), maxx = std::max({a.x, b.x, c.x});
const float minz = std::min({a.z, b.z, c.z}), maxz = std::max({a.z, b.z, c.z});
const int x0 = std::max(0, static_cast<int>(std::floor((minx - vol->origin.x) / vox - 0.5f)));
const int x1 = std::min(vol->nx - 1, static_cast<int>(std::ceil((maxx - vol->origin.x) / vox - 0.5f)));
const int z0 = std::max(0, static_cast<int>(std::floor((minz - vol->origin.z) / vox - 0.5f)));
const int z1 = std::min(vol->nz - 1, static_cast<int>(std::ceil((maxz - vol->origin.z) / vox - 0.5f)));
for (int iz = z0; iz <= z1; ++iz)
for (int ix = x0; ix <= x1; ++ix) {
const float px = vol->origin.x + (static_cast<float>(ix) + 0.5f) * vox + jx;
const float pz = vol->origin.z + (static_cast<float>(iz) + 0.5f) * vox + jz;
float y;
if (crossing(a, b, c, px, pz, y)) emit(colIndex(ix, iz), y);
}
});
};
std::vector<std::uint32_t> start(columns + 1, 0);
traverse([&](std::size_t col, float) { ++start[col + 1]; });
for (std::size_t i = 0; i < columns; ++i) start[i + 1] += start[i];
std::vector<float> ys(start.back());
std::vector<std::uint32_t> fillAt(start.begin(), start.end() - 1);
traverse([&](std::size_t col, float y) { ys[fillAt[col]++] = y; });
for (std::size_t col = 0; col < columns; ++col) {
float* b = ys.data() + start[col];
float* e = ys.data() + start[col + 1];
std::sort(b, e);
const std::size_t n = static_cast<std::size_t>(e - b) & ~static_cast<std::size_t>(1); // an odd leftover means a leaky mesh: ignore it
const int ix = static_cast<int>(col % d[0]), iz = static_cast<int>(col / d[0]);
for (std::size_t k = 0; k + 1 < n; k += 2) {
const int y0 = std::max(0, static_cast<int>(std::ceil((b[k] - vol->origin.y) / vox - 0.5f)));
const int y1 = std::min(vol->ny - 1, static_cast<int>(std::floor((b[k + 1] - vol->origin.y) / vox - 0.5f)));
for (int iy = y0; iy <= y1; ++iy)
vol->inside[(static_cast<std::size_t>(iy) * d[2] + static_cast<std::size_t>(iz)) * d[0] + static_cast<std::size_t>(ix)] = 1;
}
}
return vol;
}
// ---------------------------------------------------------------------------------------------
// Hashing of everything a bake depends on
// ---------------------------------------------------------------------------------------------
struct Hasher {
std::uint64_t h = 1469598103934665603ull;
void bytes(const void* p, std::size_t n) {
const auto* b = static_cast<const unsigned char*>(p);
for (std::size_t i = 0; i < n; ++i) { h ^= b[i]; h *= 1099511628211ull; }
}
template <class T> void add(const T& v) { bytes(&v, sizeof v); }
};
std::uint64_t hashInputs(const std::vector<ZoneData>& zones, const Environment& env) {
Hasher h;
h.add(env.ambientSky); h.add(env.ambientGround); h.add(env.ambientIntensity);
h.add(env.zoneCellSize); h.add(env.zoneMaxCells);
h.add(zones.size());
for (const ZoneData& z : zones) {
h.bytes(z.world.m, sizeof z.world.m);
h.add(z.shape); h.add(z.size); h.add(z.radius); h.add(z.height);
for (const Vec2& p : z.polygon) h.add(p);
h.add(z.polygon.size());
h.add(static_cast<std::uint64_t>(z.mesh ? z.mesh->id() : 0));
h.add(z.sky); h.add(z.ground); h.add(z.intensity); h.add(z.mode); h.add(z.falloff); h.add(z.priority);
}
return h.h;
}
// What the layer of a zone (its reach: see AmbientBaker::Impl::Layer) depends on. Colours, mode and priority are
// not part of it, so recolouring or re-prioritising a zone keeps its layer.
std::uint64_t layerKey(const ZoneData& z, float cell) {
Hasher h;
h.bytes(z.world.m, sizeof z.world.m);
h.add(z.shape); h.add(z.size); h.add(z.radius); h.add(z.height);
for (const Vec2& p : z.polygon) h.add(p);
h.add(z.polygon.size());
h.add(static_cast<std::uint64_t>(z.mesh ? z.mesh->id() : 0));
h.add(z.falloff);
h.add(cell);
return h.h;
}
std::uint8_t toByte(float v) { return static_cast<std::uint8_t>(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); }
// Texel encoding. encodeLinearToByte(v) is, for every float v, exactly toByte(toGamma(v)): the gamma curve and the
// rounding the texels have always had, but without a pow() per texel (which was more than half the cost of a bake).
// The byte only changes at 255 thresholds. They are found once, by bisection on the real function, and a table over
// [0, 1] says which one to start from, so a lookup is a table read and (at most a few) comparisons.
struct GammaCodec {
static constexpr int kBins = 2048;
float threshold[256] = {}; // threshold[k]: the smallest v that encodes to k or more (k = 1..255)
std::uint8_t binStart[kBins + 1] = {}; // the byte of v = i / kBins
static std::uint8_t reference(float v) { return toByte(std::pow(std::max(v, 0.0f), 1.0f / kGamma)); }
GammaCodec() {
for (int k = 1; k <= 255; ++k) {
std::uint32_t lo = 0, hi = std::bit_cast<std::uint32_t>(1.0f); // floats >= 0 order like their bits: reference(lo) < k <= reference(hi)
while (hi - lo > 1) {
const std::uint32_t mid = lo + (hi - lo) / 2;
if (reference(std::bit_cast<float>(mid)) >= k) hi = mid; else lo = mid;
}
threshold[k] = std::bit_cast<float>(hi);
}
int b = 0;
for (int i = 0; i <= kBins; ++i) {
const float v = static_cast<float>(i) / static_cast<float>(kBins);
while (b < 255 && v >= threshold[b + 1]) ++b;
binStart[i] = static_cast<std::uint8_t>(b);
}
}
std::uint8_t encode(float v) const {
if (!(v > 0.0f)) return 0;
if (v >= threshold[255]) return 255;
int b = binStart[static_cast<int>(v * static_cast<float>(kBins))];
while (b < 255 && v >= threshold[b + 1]) ++b;
return static_cast<std::uint8_t>(b);
}
};
const GammaCodec& gammaCodec() {
static const GammaCodec codec;
return codec;
}
// The largest component of any colour in a row of cells, at least 1. Independent accumulators per component keep it from
// being one long dependency chain.
float largestComponent(const Vec3* a, const Vec3* b, int n) {
static_assert(sizeof(Vec3) == 3 * sizeof(float));
float mx = 1.0f, my = 1.0f, mz = 1.0f;
for (int i = 0; i < n; ++i) {
mx = a[i].x > mx ? a[i].x : mx;
my = a[i].y > my ? a[i].y : my;
mz = a[i].z > mz ? a[i].z : mz;
mx = b[i].x > mx ? b[i].x : mx;
my = b[i].y > my ? b[i].y : my;
mz = b[i].z > mz ? b[i].z : mz;
}
return std::max({mx, my, mz});
}
// The colour scale of a grid: the brightest colour, rounded UP to a step of a fixed ladder (1, 1.25, 1.5, 1.75, 2, 2.5,
// 3, 3.5, 4, 5, ...: four steps per octave), and at least 1. Every texel is stored divided by it, so it is part of every
// texel: a scale that followed the brightest cell exactly would change (and re-encode and re-upload the whole grid)
// every time a bright zone and a moving one overlap by a little more or less. On the ladder it only changes when the
// brightest colour crosses a rung. It is a function of that colour alone, so a bake from scratch finds the same one.
float colorScaleFor(float top) {
if (!(top > 1.0f)) return 1.0f; // also NaN
if (!std::isfinite(top)) return top;
int e = 0;
const float m = std::frexp(top, &e); // top = m * 2^e with m in [0.5, 1)
return std::ldexp(std::ceil(m * 8.0f) / 8.0f, e); // exact: the multiplications and the division are by powers of two
}
// The grid's extent is rounded out to whole blocks of cells (Y in smaller ones: levels are wider than they are tall), so a
// zone that walks beyond the others does not resize the grid on every step, only when it crosses a block edge. The rounding
// needs room: a grid that is already close to the cell budget gets smaller blocks, and finally none (see update()).
struct Blocks { int xz, y; };
constexpr Blocks kBlocks[] = {{8, 4}, {4, 2}, {2, 1}, {1, 1}};
// A box of lattice cells, both ends included (empty when hi < lo on any axis).
struct Range {
int lo[3] = {0, 0, 0}, hi[3] = {-1, -1, -1};
[[nodiscard]] bool empty() const { return hi[0] < lo[0] || hi[1] < lo[1] || hi[2] < lo[2]; }
[[nodiscard]] std::size_t volume() const {
return empty() ? 0 : static_cast<std::size_t>(hi[0] - lo[0] + 1) * static_cast<std::size_t>(hi[1] - lo[1] + 1) * static_cast<std::size_t>(hi[2] - lo[2] + 1);
}
};
Range intersect(const Range& a, const Range& b) {
Range r;
for (int i = 0; i < 3; ++i) { r.lo[i] = std::max(a.lo[i], b.lo[i]); r.hi[i] = std::min(a.hi[i], b.hi[i]); }
return r;
}
Range hull(const Range& a, const Range& b) {
Range r;
for (int i = 0; i < 3; ++i) { r.lo[i] = std::min(a.lo[i], b.lo[i]); r.hi[i] = std::max(a.hi[i], b.hi[i]); }
return r;
}
// Everything one zone contributes to a bake: where it is (its layer key), its colours, intensity and mode, and its place in
// the blending order (its priority and its rank among the zones of that priority). Two zones with the same key paint the
// same cells in the same colours at the same point of the blend, whichever bake they come from.
std::uint64_t zoneKey(const ZoneData& z, std::uint64_t layer, int tie) {
Hasher h;
h.add(layer);
h.add(z.sky); h.add(z.ground); h.add(z.intensity); h.add(z.mode); h.add(z.priority); h.add(tie);
return h.h;
}
// Y slices are laid out as a roughly square grid of tiles.
int atlasTilesX(int ny) { return std::max(1, static_cast<int>(std::ceil(std::sqrt(static_cast<double>(ny))))); }
} // namespace
Vec3 toLinear(Vec3 c) { return {std::pow(std::max(c.x, 0.0f), kGamma), std::pow(std::max(c.y, 0.0f), kGamma), std::pow(std::max(c.z, 0.0f), kGamma)}; }
Vec3 toGamma(Vec3 c) {
const float g = 1.0f / kGamma;
return {std::pow(std::max(c.x, 0.0f), g), std::pow(std::max(c.y, 0.0f), g), std::pow(std::max(c.z, 0.0f), g)};
}
std::uint8_t encodeLinearToByte(float v) { return gammaCodec().encode(v); }
bool zoneContains(const ZoneData& zone, Vec3 worldPoint) {
return usable(zone) && containsLocal(zone, zone.inverse.transformPoint(worldPoint));
}
// ===========================================================================
// AmbientGrid
// ===========================================================================
void AmbientGrid::sample(Vec3 p, Vec3& outSky, Vec3& outGround) const {
if (!valid) { outSky = outGround = {}; return; }
const float gx = (p.x - origin.x) / cell - 0.5f, gy = (p.y - origin.y) / cell - 0.5f, gz = (p.z - origin.z) / cell - 0.5f;
const int x0 = static_cast<int>(std::floor(gx)), y0 = static_cast<int>(std::floor(gy)), z0 = static_cast<int>(std::floor(gz));
const float tx = gx - static_cast<float>(x0), ty = gy - static_cast<float>(y0), tz = gz - static_cast<float>(z0);
auto at = [&](const std::vector<std::uint8_t>& v, int x, int y, int z) { // an encoded texel, 0..1
x = std::clamp(x, 0, nx - 1); y = std::clamp(y, 0, ny - 1); z = std::clamp(z, 0, nz - 1);
const std::size_t i = ((static_cast<std::size_t>(y) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(z)) * static_cast<std::size_t>(nx) +
static_cast<std::size_t>(x)) * 4;
return Vec3{static_cast<float>(v[i]), static_cast<float>(v[i + 1]), static_cast<float>(v[i + 2])} * (1.0f / 255.0f);
};
auto tri = [&](const std::vector<std::uint8_t>& v) {
Vec3 r{};
for (int dz = 0; dz < 2; ++dz)
for (int dy = 0; dy < 2; ++dy)
for (int dx = 0; dx < 2; ++dx)
r += at(v, x0 + dx, y0 + dy, z0 + dz) * ((dx ? tx : 1 - tx) * (dy ? ty : 1 - ty) * (dz ? tz : 1 - tz));
return r;
};
outSky = toLinear(tri(sky)) * colorScale; // filter the encoded texels, then decode (as the GPU does)
outGround = toLinear(tri(ground)) * colorScale;
}
// ===========================================================================
// AmbientBaker
// ===========================================================================
struct AmbientBaker::Impl {
// ---- voxelised closed meshes, cached by mesh id and voxel size ----
std::unordered_map<std::uint64_t, std::shared_ptr<MeshVolume>> volumes; // mesh id + voxel size bucket
std::unordered_set<std::uint64_t> usedThisBake;
// Voxel size for a mesh zone: half a bake cell in WORLD units, expressed in the mesh's own space, quantised
// to powers of sqrt(2) so small scale animations reuse the cached voxelisation.
static std::uint64_t volumeKey(const ZoneData& z, float bakeCell, int& bucket) {
const Vec3 c0{z.world.m[0], z.world.m[1], z.world.m[2]}, c1{z.world.m[4], z.world.m[5], z.world.m[6]},
c2{z.world.m[8], z.world.m[9], z.world.m[10]};
const float scale = std::max({c0.length(), c1.length(), c2.length(), 1e-4f});
bucket = static_cast<int>(std::lround(std::log2(std::max(0.5f * bakeCell / scale, 1e-4f)) * 2.0f));
return (static_cast<std::uint64_t>(z.mesh->id()) << 8) ^ static_cast<std::uint64_t>(static_cast<std::uint8_t>(bucket + 128));
}
const MeshVolume* volumeFor(const ZoneData& z, float bakeCell) {
int bucket = 0;
const std::uint64_t key = volumeKey(z, bakeCell, bucket);
usedThisBake.insert(key);
if (const auto it = volumes.find(key); it != volumes.end()) return it->second.get();
const float vox = std::exp2(static_cast<float>(bucket) * 0.5f);
return (volumes[key] = voxelise(z.mesh->data(), z.mesh->bounds(), vox, 4'000'000)).get();
}
// ---- layers: where one zone is, and how far its fade-out reaches ----
// A layer holds, for every cell of the lattice around a zone, the zone's weight there: 1 inside, fading to 0 over
// the zone's falloff. It depends only on the zone's shape and transform (see layerKey), so it is computed once
// and reused until that zone changes: moving one zone does not recompute any other, and colours, mode and
// priority (which only decide how layers are blended) are free to change.
struct Layer {
int x0 = 0, y0 = 0, z0 = 0; // first cell, in lattice coordinates (cell i covers [i * cell, (i + 1) * cell))
int rx = 0, ry = 0, rz = 0; // size in cells
std::vector<float> w; // (y * rz + z) * rx + x
bool used = false;
};
static constexpr std::size_t kLayerCacheCells = 8'000'000; // 32 MB of weights; beyond that layers are rebuilt every bake
std::unordered_map<std::uint64_t, Layer> layers;
std::size_t layerCells = 0;
// ---- what the previous bake left behind, so the next one only touches what changed ----
struct Lattice {
int lo[3] = {}; // first cell of the grid, in lattice coordinates
int n[3] = {}; // cells per axis
float cell = 0;
bool operator==(const Lattice&) const = default;
};
Lattice lattice;
bool haveState = false; // the members below describe a finished bake
std::vector<Vec3> lin[2]; // the blended colour of every cell of `lattice`, linear light: [0] sky, [1] ground
std::vector<float> rowMax; // for every (y, z) row of the lattice: its largest colour component, at least 1
float scale = 1; // the colour scale the texels were encoded with
std::uint64_t baseKey = 0; // the Environment's own ambient (what a cell holds where no zone is)
std::vector<std::pair<std::uint64_t, Range>> placed; // the zones of the previous bake: their keys and the cells their layers cover
std::vector<Vec3> neu[2]; // scratch: the new colours of one box of cells
std::vector<Vec3> spare[2]; // the colours of the grid before the last one: its memory is reused by the next grid
void reset() {
volumes.clear();
layers.clear();
layerCells = 0;
for (auto* v : {&lin[0], &lin[1], &neu[0], &neu[1], &spare[0], &spare[1]}) std::vector<Vec3>().swap(*v);
std::vector<float>().swap(rowMax);
placed.clear();
lattice = {};
haveState = false;
}
void computeLayer(const ZoneData& z, float cell, Layer& L) {
const float fade = z.falloff > 0.0f ? z.falloff : 0.0f;
const Box3 wb = worldBounds(z);
const float reach = fade + cell;
auto cellOf = [&](float v) { return static_cast<int>(std::floor(v / cell)); };
L.x0 = cellOf(wb.lo.x - reach);
L.y0 = cellOf(wb.lo.y - reach);
L.z0 = cellOf(wb.lo.z - reach);
L.rx = cellOf(wb.hi.x + reach) - L.x0 + 1;
L.ry = cellOf(wb.hi.y + reach) - L.y0 + 1;
L.rz = cellOf(wb.hi.z + reach) - L.z0 + 1;
const std::size_t rx = static_cast<std::size_t>(L.rx), ry = static_cast<std::size_t>(L.ry), rz = static_cast<std::size_t>(L.rz);
const MeshVolume* vol = z.shape == ZoneShape::Mesh ? volumeFor(z, cell) : nullptr;
std::vector<float>& dist = L.w; // distances first, turned into weights at the end
dist.assign(rx * ry * rz, kInf);
auto ridx = [&](int x, int y, int zz) { return (static_cast<std::size_t>(y) * rz + static_cast<std::size_t>(zz)) * rx + static_cast<std::size_t>(x); };
for (int y = 0; y < L.ry; ++y)
for (int zz = 0; zz < L.rz; ++zz)
for (int x = 0; x < L.rx; ++x) {
const Vec3 p{(static_cast<float>(L.x0 + x) + 0.5f) * cell, (static_cast<float>(L.y0 + y) + 0.5f) * cell,
(static_cast<float>(L.z0 + zz) + 0.5f) * cell};
const Vec3 l = z.inverse.transformPoint(p);
if (vol ? vol->contains(l) : containsLocal(z, l)) dist[ridx(x, y, zz)] = 0;
}
if (fade > 0) { // two-pass 26-neighbour chamfer distance to the nearest inside cell
struct Off { int dx, dy, dz; std::ptrdiff_t step; float w; }; // the 13 neighbours a forward sweep has already visited
Off fwd[13];
int nf = 0;
for (int dy = -1; dy <= 1; ++dy)
for (int dz = -1; dz <= 1; ++dz)
for (int dx = -1; dx <= 1; ++dx) {
if (dx == 0 && dy == 0 && dz == 0) continue;
if (dy < 0 || (dy == 0 && dz < 0) || (dy == 0 && dz == 0 && dx < 0))
fwd[nf++] = {dx, dy, dz, (static_cast<std::ptrdiff_t>(dy) * static_cast<std::ptrdiff_t>(rz) + dz) * static_cast<std::ptrdiff_t>(rx) + dx,
cell * std::sqrt(static_cast<float>(dx * dx + dy * dy + dz * dz))};
}
auto relaxChecked = [&](int x, int y, int zz, int sgn) { // a cell on the layer's surface: some neighbours do not exist
float best = dist[ridx(x, y, zz)];
for (const Off& o : fwd) {
const int nx = x + sgn * o.dx, ny = y + sgn * o.dy, nz = zz + sgn * o.dz;
if (nx < 0 || ny < 0 || nz < 0 || nx >= L.rx || ny >= L.ry || nz >= L.rz) continue;
best = std::min(best, dist[ridx(nx, ny, nz)] + o.w);
}
dist[ridx(x, y, zz)] = best;
};
// The sweeps are the cost of a layer (13 neighbours of every cell, twice): a cell with all of them (nearly every
// cell) reaches them by a fixed step in memory, without testing the edges.
auto sweep = [&](int sgn) {
const std::ptrdiff_t sg = sgn;
for (int yi = 0; yi < L.ry; ++yi) {
const int y = sgn > 0 ? yi : L.ry - 1 - yi;
for (int zi = 0; zi < L.rz; ++zi) {
const int zz = sgn > 0 ? zi : L.rz - 1 - zi;
const bool inner = y >= 1 && y <= L.ry - 2 && zz >= 1 && zz <= L.rz - 2;
float* row = dist.data() + (static_cast<std::size_t>(y) * rz + static_cast<std::size_t>(zz)) * rx;
for (int xi = 0; xi < L.rx; ++xi) {
const int x = sgn > 0 ? xi : L.rx - 1 - xi;
if (inner && x >= 1 && x <= L.rx - 2) {
float* c = row + x;
float best = *c;
for (const Off& o : fwd) best = std::min(best, c[sg * o.step] + o.w);
*c = best;
} else {
relaxChecked(x, y, zz, sgn);
}
}
}
}
};
sweep(+1);
sweep(-1);
}
for (float& d : dist) d = d == 0 ? 1.0f : (fade > 0 ? 1.0f - smoothstep01(d / fade) : 0.0f);
}
// The layer of `z` (its key is `key`): the cached one, or a new one (kept in the cache while there is room, else in
// `overflow`, which holds the layers of this bake only). The pointer stays valid until the bake ends.
const Layer* acquire(const ZoneData& z, std::uint64_t key, float cell, std::deque<Layer>& overflow, BakeStats& stats) {
if (const auto it = layers.find(key); it != layers.end()) {
it->second.used = true;
if (z.shape == ZoneShape::Mesh && z.mesh) { // its voxels stay for the day the layer has to be rebuilt
int bucket = 0;
usedThisBake.insert(volumeKey(z, cell, bucket));
}
++stats.layersReused;
return &it->second;
}
Layer& fresh = overflow.emplace_back();
computeLayer(z, cell, fresh);
++stats.layersComputed;
if (layerCells + fresh.w.size() > kLayerCacheCells) return &fresh;
layerCells += fresh.w.size();
Layer& stored = layers[key];
stored = std::move(fresh);
overflow.pop_back();
stored.used = true;
return &stored;
}
void dropUnusedLayers() {
for (auto it = layers.begin(); it != layers.end();) {
if (it->second.used) { it->second.used = false; ++it; }
else { layerCells -= it->second.w.size(); it = layers.erase(it); }
}
}
};
AmbientBaker::AmbientBaker() : impl_(std::make_unique<Impl>()) {}
AmbientBaker::~AmbientBaker() = default;
std::size_t AmbientBaker::cachedMeshVolumes() const { return impl_->volumes.size(); }
std::size_t AmbientBaker::cachedLayers() const { return impl_->layers.size(); }
bool AmbientBaker::update(const std::vector<ZoneData>& zonesIn, const Environment& env) {
stats_ = {};
const std::uint64_t h = hashInputs(zonesIn, env);
if (h == hash_) return false;
hash_ = h;
Impl& s = *impl_;
s.usedThisBake.clear();
// ---- the zones that count, in the order they are blended: by priority, then as given ----
struct Run {
const ZoneData* z = nullptr;
Box3 bounds;
int tie = 0; // rank among the zones of the same priority
std::uint64_t key = 0; // see zoneKey()
const Impl::Layer* layer = nullptr;
Vec3 sky{}, ground{}; // linear light times intensity
};
std::vector<Run> zones;
for (const ZoneData& z : zonesIn) {
if (!usable(z)) continue;
Run r;
r.z = &z;
r.bounds = worldBounds(z);
if (r.bounds.valid()) zones.push_back(std::move(r));
}
if (zones.empty()) { grid_ = {}; s.reset(); return true; }
std::stable_sort(zones.begin(), zones.end(), [](const Run& a, const Run& b) { return a.z->priority < b.z->priority; });
for (std::size_t i = 1; i < zones.size(); ++i)
zones[i].tie = zones[i - 1].z->priority == zones[i].z->priority ? zones[i - 1].tie + 1 : 0;
// ---- grid extent: every zone plus its fade-out, one cell of padding, rounded out to blocks of cells ----
Box3 all;
for (const Run& r : zones) {
const float fade = r.z->falloff > 0.0f ? r.z->falloff : 0.0f;
all.add(r.bounds.lo - Vec3{fade, fade, fade});
all.add(r.bounds.hi + Vec3{fade, fade, fade});
}
if (!all.valid() || !std::isfinite(all.lo.x + all.lo.y + all.lo.z + all.hi.x + all.hi.y + all.hi.z)) { grid_ = {}; s.reset(); return true; }
// The grid lies on a lattice fixed in the world (cell i covers [i * cell, (i + 1) * cell)), so a zone covers the
// same cells whatever the other zones do to the grid's extent, and its cached layer stays valid.
float cell = std::isfinite(env.zoneCellSize) ? std::max(env.zoneCellSize, 0.05f) : 0.5f; // NaN or infinity would never fit any budget
const std::size_t maxCells = static_cast<std::size_t>(std::clamp(env.zoneMaxCells, 1000, 4'194'304));
const float lo[3] = {all.lo.x, all.lo.y, all.lo.z}, hi[3] = {all.hi.x, all.hi.y, all.hi.z};
auto latticeFor = [&](float c, const Blocks& blocks) {
Impl::Lattice l;
l.cell = c;
for (int a = 0; a < 3; ++a) {
const double block = a == 1 ? blocks.y : blocks.xz; // out to whole blocks of cells
const double first = std::clamp(std::floor(static_cast<double>(lo[a]) / static_cast<double>(c)) - 1.0, -1.0e9, 1.0e9);
const double last = std::clamp(std::floor(static_cast<double>(hi[a]) / static_cast<double>(c)) + 1.0, -1.0e9, 1.0e9);
const double from = std::floor(first / block) * block;
const double to = std::ceil((last + 1.0) / block) * block - 1.0;
l.lo[a] = static_cast<int>(from);
l.n[a] = static_cast<int>(std::min(to - from + 1.0, 2.0e9));
}
return l;
};
// The grid is uploaded as a 2D atlas, so besides the cell budget it has to fit a texture every GPU supports.
constexpr double kMaxAtlasSide = 4096;
auto fits = [&](const Impl::Lattice& l) {
const double tx = atlasTilesX(l.n[1]), ty = std::ceil(static_cast<double>(l.n[1]) / tx);
return static_cast<double>(l.n[0]) * l.n[1] * l.n[2] <= static_cast<double>(maxCells) && l.n[0] * tx <= kMaxAtlasSide && l.n[2] * ty <= kMaxAtlasSide;
};
// The rounded-out grid has a little more room around the zones than they need, which a tight budget may not have:
// smaller blocks come next, down to the exact grid (what the grid always was), and only then are the cells coarsened.
// So the rounding never costs resolution.
Impl::Lattice lat;
for (bool found = false; !found;) {
for (const Blocks& blocks : kBlocks) {
lat = latticeFor(cell, blocks);
if ((found = fits(lat))) break;
}
if (!found) cell *= 1.1f;
}
const float farthest = std::max({std::fabs(lo[0]), std::fabs(lo[1]), std::fabs(lo[2]), std::fabs(hi[0]), std::fabs(hi[1]), std::fabs(hi[2])});
if (!std::isfinite(cell) || !(farthest / cell < 1.0e9f)) { grid_ = {}; s.reset(); return true; } // beyond what a float lattice can address
const int nx = lat.n[0], ny = lat.n[1], nz = lat.n[2];
const std::size_t total = static_cast<std::size_t>(nx) * static_cast<std::size_t>(ny) * static_cast<std::size_t>(nz);
auto cellIndex = [&](int x, int y, int z) { // grid coordinates (0 .. n - 1)
return (static_cast<std::size_t>(y) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(z)) * static_cast<std::size_t>(nx) + static_cast<std::size_t>(x);
};
Range whole; // the grid, in lattice coordinates
for (int a = 0; a < 3; ++a) { whole.lo[a] = lat.lo[a]; whole.hi[a] = lat.lo[a] + lat.n[a] - 1; }
// ---- every zone's layer (cached: only a zone that changed costs anything here) ----
std::deque<Impl::Layer> overflow;
for (Run& r : zones) {
const ZoneData& z = *r.z;
const std::uint64_t layerId = layerKey(z, cell);
r.key = zoneKey(z, layerId, r.tie);
r.layer = s.acquire(z, layerId, cell, overflow, stats_);
r.sky = toLinear(z.sky) * z.intensity;
r.ground = toLinear(z.ground) * z.intensity;
++stats_.zones;
}
auto layerRange = [](const Impl::Layer& L) {
Range r;
r.lo[0] = L.x0; r.lo[1] = L.y0; r.lo[2] = L.z0;
r.hi[0] = L.x0 + L.rx - 1; r.hi[1] = L.y0 + L.ry - 1; r.hi[2] = L.z0 + L.rz - 1;
return r;
};
// ---- what is different from the previous bake ----
Hasher baseHash;
baseHash.add(env.ambientSky); baseHash.add(env.ambientGround); baseHash.add(env.ambientIntensity);
const std::uint64_t baseKey = baseHash.h;
const Vec3 baseSky = toLinear(env.ambientSky) * env.ambientIntensity, baseGround = toLinear(env.ambientGround) * env.ambientIntensity;
const bool layoutChanged = !s.haveState || !grid_.valid || !(s.lattice == lat); // another grid: the texels move
const bool everything = !s.haveState || s.baseKey != baseKey || s.lattice.cell != lat.cell;
// Dirty boxes: the cells whose colour has to be worked out again. A cell nobody changed has the colour it had (it is
// a function of the zones that cover it, and those are the same zones in the same order), so it keeps it.
std::vector<Range> dirty;
Range common; // the cells of this grid that the previous grid had
if (everything) {
dirty.push_back(whole);
} else {
std::unordered_multimap<std::uint64_t, Range> before(s.placed.begin(), s.placed.end());
for (const Run& r : zones) {
const auto same = before.find(r.key); // a zone with the key of one the previous bake had: unchanged
if (same != before.end()) before.erase(same);
else dirty.push_back(layerRange(*r.layer)); // new, or changed: where it is now ...
}
for (const auto& gone : before) dirty.push_back(gone.second); // ... and where it was (or where a zone that is gone was)
if (layoutChanged) { // cells the previous grid did not have
Range old;
for (int a = 0; a < 3; ++a) { old.lo[a] = s.lattice.lo[a]; old.hi[a] = old.lo[a] + s.lattice.n[a] - 1; }
common = intersect(whole, old);
if (common.empty()) {
dirty.push_back(whole);
} else {
Range rest = whole; // peel the new cells off axis by axis; what is left is `common`
for (int a = 0; a < 3; ++a) {
if (rest.lo[a] < common.lo[a]) { Range slab = rest; slab.hi[a] = common.lo[a] - 1; dirty.push_back(slab); rest.lo[a] = common.lo[a]; }
if (rest.hi[a] > common.hi[a]) { Range slab = rest; slab.lo[a] = common.hi[a] + 1; dirty.push_back(slab); rest.hi[a] = common.hi[a]; }
}
}
}
}
for (Range& r : dirty) r = intersect(r, whole);
dirty.erase(std::remove_if(dirty.begin(), dirty.end(), [](const Range& r) { return r.empty(); }), dirty.end());
for (bool merged = true; merged;) { // overlapping boxes become one (a cell would be worked out twice)
merged = false;
for (std::size_t i = 0; i < dirty.size() && !merged; ++i)
for (std::size_t j = i + 1; j < dirty.size() && !merged; ++j)
if (!intersect(dirty[i], dirty[j]).empty()) {
dirty[i] = hull(dirty[i], dirty[j]);
dirty.erase(dirty.begin() + static_cast<std::ptrdiff_t>(j));
merged = true;
}
}
// ---- the colours of the new grid: the cells of the previous one, carried over (the rest start as the plain ambient) ----
if (layoutChanged) {
std::vector<Vec3> oldSky = std::move(s.lin[0]), oldGround = std::move(s.lin[1]);
s.lin[0] = std::move(s.spare[0]); // memory of a grid two bakes ago: no new allocation (and page faults) unless this one is bigger
s.lin[1] = std::move(s.spare[1]);
s.lin[0].resize(total);
s.lin[1].resize(total);
const bool carry = !everything && !common.empty();
const Impl::Lattice& ol = s.lattice;
for (int y = 0; y < ny; ++y)
for (int zz = 0; zz < nz; ++zz) {
Vec3* skyRow = &s.lin[0][cellIndex(0, y, zz)];
Vec3* groundRow = &s.lin[1][cellIndex(0, y, zz)];
const int wy = lat.lo[1] + y, wz = lat.lo[2] + zz; // this row, in lattice coordinates
int from = 0, to = 0; // the cells of the row that carry over: [from, to)
if (carry && wy >= common.lo[1] && wy <= common.hi[1] && wz >= common.lo[2] && wz <= common.hi[2]) {
from = common.lo[0] - lat.lo[0];
to = common.hi[0] - lat.lo[0] + 1;
}
std::fill(skyRow, skyRow + from, baseSky);
std::fill(groundRow, groundRow + from, baseGround);
if (to > from) {
const std::size_t src = (static_cast<std::size_t>(wy - ol.lo[1]) * static_cast<std::size_t>(ol.n[2]) + static_cast<std::size_t>(wz - ol.lo[2])) *
static_cast<std::size_t>(ol.n[0]) + static_cast<std::size_t>(common.lo[0] - ol.lo[0]);
std::memcpy(skyRow + from, &oldSky[src], static_cast<std::size_t>(to - from) * sizeof(Vec3));
std::memcpy(groundRow + from, &oldGround[src], static_cast<std::size_t>(to - from) * sizeof(Vec3));
}
std::fill(skyRow + to, skyRow + nx, baseSky);
std::fill(groundRow + to, groundRow + nx, baseGround);
}
s.spare[0] = std::move(oldSky);
s.spare[1] = std::move(oldGround);
}
// ---- work the dirty cells out again: base ambient, then every zone that reaches them, lowest priority first ----
const GammaCodec& codec = gammaCodec();
const bool inPlace = !layoutChanged; // texels of the existing grid are rewritten as colours change
auto encodeCell = [&](AmbientGrid& g, int x, int y, int zz, float sc) {
const std::size_t i = cellIndex(x, y, zz);
const std::size_t a = ((static_cast<std::size_t>(y / g.tilesX) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(zz)) * static_cast<std::size_t>(g.atlasWidth()) +
static_cast<std::size_t>(y % g.tilesX) * static_cast<std::size_t>(nx) + static_cast<std::size_t>(x)) * 4;
const Vec3 cs = s.lin[0][i] / sc, cg = s.lin[1][i] / sc;
const std::uint8_t sb[4] = {codec.encode(cs.x), codec.encode(cs.y), codec.encode(cs.z), 255};
const std::uint8_t gb[4] = {codec.encode(cg.x), codec.encode(cg.y), codec.encode(cg.z), 255};
std::memcpy(&g.sky[i * 4], sb, 4);
std::memcpy(&g.ground[i * 4], gb, 4);
std::memcpy(&g.atlasSky[a], sb, 4);
std::memcpy(&g.atlasGround[a], gb, 4);
};
if (layoutChanged) s.rowMax.assign(static_cast<std::size_t>(ny) * static_cast<std::size_t>(nz), 1.0f);
std::size_t blended = 0, changed = 0;
std::vector<CellBox> updated;
for (const Range& D : dirty) {
const int dw = D.hi[0] - D.lo[0] + 1, dd = D.hi[2] - D.lo[2] + 1;
const std::size_t vol = D.volume();
s.neu[0].assign(vol, baseSky);
s.neu[1].assign(vol, baseGround);
for (const Run& r : zones) {
const Impl::Layer& L = *r.layer;
const Range R = intersect(layerRange(L), D);
if (R.empty()) continue;
for (int y = R.lo[1]; y <= R.hi[1]; ++y)
for (int zz = R.lo[2]; zz <= R.hi[2]; ++zz) {
const float* wrow = &L.w[(static_cast<std::size_t>(y - L.y0) * static_cast<std::size_t>(L.rz) + static_cast<std::size_t>(zz - L.z0)) * static_cast<std::size_t>(L.rx)];
const std::size_t o = (static_cast<std::size_t>(y - D.lo[1]) * static_cast<std::size_t>(dd) + static_cast<std::size_t>(zz - D.lo[2])) * static_cast<std::size_t>(dw);
Vec3* skyRow = &s.neu[0][o];
Vec3* groundRow = &s.neu[1][o];
for (int x = R.lo[0]; x <= R.hi[0]; ++x) {
const float wt = wrow[x - L.x0];
if (wt <= 0) continue;
Vec3& sk = skyRow[x - D.lo[0]];
Vec3& gr = groundRow[x - D.lo[0]];
switch (r.z->mode) {
case ZoneMode::Replace: sk = lerp(sk, r.sky, wt); gr = lerp(gr, r.ground, wt); break;
case ZoneMode::Add: sk += r.sky * wt; gr += r.ground * wt; break;
case ZoneMode::Multiply:
sk = sk * lerp(Vec3{1, 1, 1}, r.sky, wt);
gr = gr * lerp(Vec3{1, 1, 1}, r.ground, wt);
break;
}
}
}
}
blended += vol;
// keep what came out, and remember which cells are not what they were
CellBox box;
bool any = false;
for (int y = D.lo[1]; y <= D.hi[1]; ++y)
for (int zz = D.lo[2]; zz <= D.hi[2]; ++zz) {
const std::size_t li = cellIndex(D.lo[0] - lat.lo[0], y - lat.lo[1], zz - lat.lo[2]);
const std::size_t ni = (static_cast<std::size_t>(y - D.lo[1]) * static_cast<std::size_t>(dd) + static_cast<std::size_t>(zz - D.lo[2])) * static_cast<std::size_t>(dw);
Vec3* oldSky = &s.lin[0][li];
Vec3* oldGround = &s.lin[1][li];
const Vec3* newSky = &s.neu[0][ni];
const Vec3* newGround = &s.neu[1][ni];
if (std::memcmp(oldSky, newSky, static_cast<std::size_t>(dw) * sizeof(Vec3)) == 0 &&
std::memcmp(oldGround, newGround, static_cast<std::size_t>(dw) * sizeof(Vec3)) == 0)
continue; // a whole row came out as before
bool rowChanged = false;
for (int x = 0; x < dw; ++x) {
if (oldSky[x] == newSky[x] && oldGround[x] == newGround[x]) continue;
oldSky[x] = newSky[x];
oldGround[x] = newGround[x];
rowChanged = true;
++changed;
const int gx = D.lo[0] - lat.lo[0] + x, gy = y - lat.lo[1], gz = zz - lat.lo[2];
if (!any) { box = {gx, gy, gz, gx, gy, gz}; any = true; }
else {
box.x0 = std::min(box.x0, gx); box.x1 = std::max(box.x1, gx);
box.y0 = std::min(box.y0, gy); box.y1 = std::max(box.y1, gy);
box.z0 = std::min(box.z0, gz); box.z1 = std::max(box.z1, gz);
}
if (inPlace) encodeCell(grid_, gx, gy, gz, s.scale); // right unless the colour scale changes: then everything is redone
}
if (rowChanged && inPlace) {
const std::size_t rowStart = cellIndex(0, y - lat.lo[1], zz - lat.lo[2]);
s.rowMax[static_cast<std::size_t>(y - lat.lo[1]) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(zz - lat.lo[2])] =
largestComponent(&s.lin[0][rowStart], &s.lin[1][rowStart], nx);
}
}
if (any) updated.push_back(box);
}
if (layoutChanged)
for (int y = 0; y < ny; ++y)
for (int zz = 0; zz < nz; ++zz) {
const std::size_t rowStart = cellIndex(0, y, zz);
s.rowMax[static_cast<std::size_t>(y) * static_cast<std::size_t>(nz) + static_cast<std::size_t>(zz)] = largestComponent(&s.lin[0][rowStart], &s.lin[1][rowStart], nx);
}
// ---- store as RGBA8, gamma-encoded (colours above 1 are carried by colorScale) ----
float top = 1.0f;
for (const float m : s.rowMax) top = std::max(top, m);
const float scale = colorScaleFor(top);
const bool scaleChanged = !s.haveState || s.scale != scale;
const bool full = layoutChanged || scaleChanged; // a texel is a function of its colour and the scale: a new scale or layout rewrites them all
auto encodeAll = [&](AmbientGrid& g) {
const std::size_t atlasW = static_cast<std::size_t>(g.atlasWidth());
Vec3 lastSky{-1, -1, -1}, lastGround{-1, -1, -1}; // a run of equal colours (most of a level is the plain ambient) is encoded once
std::uint8_t sb[4] = {0, 0, 0, 255}, gb[4] = {0, 0, 0, 255};
bool haveLast = false;
for (int y = 0; y < ny; ++y) {
const std::size_t tileX = static_cast<std::size_t>(y % g.tilesX) * static_cast<std::size_t>(nx);
const std::size_t tileY = static_cast<std::size_t>(y / g.tilesX) * static_cast<std::size_t>(nz);
for (int zz = 0; zz < nz; ++zz) {
std::size_t i = cellIndex(0, y, zz);
std::size_t a = ((tileY + static_cast<std::size_t>(zz)) * atlasW + tileX) * 4;
for (int x = 0; x < nx; ++x, ++i, a += 4) {
const Vec3 sky = s.lin[0][i], ground = s.lin[1][i];
if (!haveLast || !(sky == lastSky && ground == lastGround)) {
const Vec3 cs = sky / scale, cg = ground / scale;
sb[0] = codec.encode(cs.x); sb[1] = codec.encode(cs.y); sb[2] = codec.encode(cs.z);
gb[0] = codec.encode(cg.x); gb[1] = codec.encode(cg.y); gb[2] = codec.encode(cg.z);
lastSky = sky;
lastGround = ground;
haveLast = true;
}
std::memcpy(&g.sky[i * 4], sb, 4);
std::memcpy(&g.ground[i * 4], gb, 4);
std::memcpy(&g.atlasSky[a], sb, 4);
std::memcpy(&g.atlasGround[a], gb, 4);
}
}
}
};
if (layoutChanged) {
AmbientGrid fresh;
fresh.sky = std::move(grid_.sky); // the arrays of the grid it replaces: same memory, no new allocation unless it is bigger
fresh.ground = std::move(grid_.ground);
fresh.atlasSky = std::move(grid_.atlasSky);
fresh.atlasGround = std::move(grid_.atlasGround);
fresh.valid = true;
fresh.cell = cell;
fresh.nx = nx;
fresh.ny = ny;
fresh.nz = nz;
fresh.origin = {static_cast<float>(lat.lo[0]) * cell, static_cast<float>(lat.lo[1]) * cell, static_cast<float>(lat.lo[2]) * cell};
fresh.colorScale = scale;
fresh.sky.assign(total * 4, 255);
fresh.ground.assign(total * 4, 255);
fresh.tilesX = atlasTilesX(ny); // the 2D atlas for the GPU: one nx*nz tile per Y slice
fresh.tilesY = (ny + fresh.tilesX - 1) / fresh.tilesX;
fresh.atlasSky.assign(static_cast<std::size_t>(fresh.atlasWidth()) * static_cast<std::size_t>(fresh.atlasHeight()) * 4, 255);
fresh.atlasGround.assign(fresh.atlasSky.size(), 255);
encodeAll(fresh);
grid_ = std::move(fresh);
} else if (scaleChanged) {
grid_.colorScale = scale;
encodeAll(grid_);
}
grid_.updateAll = full;
grid_.updated = full ? std::vector<CellBox>{} : std::move(updated);
s.lattice = lat;
s.scale = scale;
s.baseKey = baseKey;
s.haveState = true;
s.placed.clear();
for (const Run& r : zones) s.placed.emplace_back(r.key, layerRange(*r.layer));
s.dropUnusedLayers();
stats_.cellsBlended = blended;
stats_.cellsEncoded = full ? total : changed;
stats_.full = full;
// forget voxelised meshes no zone uses any more
for (auto it = s.volumes.begin(); it != s.volumes.end();)
it = s.usedThisBake.count(it->first) ? std::next(it) : s.volumes.erase(it);
return full || changed > 0; // a zone that moved less than it takes to flip a texel changes nothing to upload
}
} // namespace eng::detail
+21
View File
@@ -2,13 +2,16 @@
#include "engine/detail/input_state.hpp"
#include "engine/graphics.hpp"
#include "engine/texture.hpp"
#include "renderer.hpp"
#include <raylib.h>
#include <rlgl.h>
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <string>
namespace eng {
namespace {
@@ -49,6 +52,24 @@ void pollInput() {
void clearInput() { detail::inputState() = detail::InputState{}; }
// Decodes image files for Resources::texture() with raylib (PNG, JPG, BMP, TGA, ...). It needs no window, and is installed when
// the program starts - not in run() - because the scene (and its textures) is usually built before run() is called.
bool decodeWithRaylib(const std::string& path, ImageData& out) {
::Image img = LoadImage(path.c_str());
if (!IsImageValid(img)) return false;
ImageFormat(&img, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8);
const bool ok = img.data != nullptr && img.format == PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 && img.width > 0 && img.height > 0;
if (ok) {
out.width = img.width;
out.height = img.height;
const auto* px = static_cast<const std::uint8_t*>(img.data);
out.rgba.assign(px, px + static_cast<std::size_t>(img.width) * static_cast<std::size_t>(img.height) * 4);
}
UnloadImage(img);
return ok;
}
[[maybe_unused]] const bool kImageLoaderInstalled = (setImageLoader(decodeWithRaylib), true);
} // namespace
int run(World& world, const RunConfig& cfg) {
+16
View File
@@ -407,4 +407,20 @@ void World::update(float dt) {
flushDestroyed();
}
void World::drawGizmos() {
deferring_ = true;
for (std::size_t i = 0; i < ops_.size(); ++i)
if (auto fn = ops_[i].drawGizmos) fn(*this);
deferring_ = false;
flushDestroyed();
}
void World::drawGui() {
deferring_ = true;
for (std::size_t i = 0; i < ops_.size(); ++i)
if (auto fn = ops_[i].drawGui) fn(*this);
deferring_ = false;
flushDestroyed();
}
} // namespace eng
+2 -299
View File
@@ -1,4 +1,5 @@
#include "engine/detail/extrude_impl.hpp"
#include "engine/detail/extrude_mesh.hpp"
#include <clipper2/clipper.h>
@@ -11,200 +12,7 @@ namespace eng {
namespace {
namespace C2 = Clipper2Lib;
struct P2 {
double x = 0, y = 0;
};
inline P2 operator-(P2 a, P2 b) { return {a.x - b.x, a.y - b.y}; }
inline double cross2(P2 a, P2 b) { return a.x * b.y - a.y * b.x; }
inline double dot2(P2 a, P2 b) { return a.x * b.x + a.y * b.y; }
inline bool same(P2 a, P2 b) { return std::fabs(a.x - b.x) < 1e-9 && std::fabs(a.y - b.y) < 1e-9; }
// Shoelace area: > 0 for counter-clockwise rings in a y-up plane (what Clipper2 calls positive).
double signedArea(const std::vector<P2>& p) {
double a = 0;
for (std::size_t i = 0, n = p.size(); i < n; ++i) {
const P2& u = p[i];
const P2& v = p[(i + 1) % n];
a += u.x * v.y - v.x * u.y;
}
return a * 0.5;
}
P2 centroidOf(const std::vector<P2>& p) {
double a2 = 0, cx = 0, cy = 0;
for (std::size_t i = 0, n = p.size(); i < n; ++i) {
const P2& u = p[i];
const P2& v = p[(i + 1) % n];
const double c = u.x * v.y - v.x * u.y;
a2 += c;
cx += (u.x + v.x) * c;
cy += (u.y + v.y) * c;
}
if (std::fabs(a2) < 1e-18) {
P2 m;
for (const P2& q : p) { m.x += q.x; m.y += q.y; }
return {m.x / static_cast<double>(p.size()), m.y / static_cast<double>(p.size())};
}
return {cx / (3.0 * a2), cy / (3.0 * a2)};
}
// ===========================================================================
// Triangulation: ear clipping with hole bridging.
// rings[0] is the outer ring (CCW), the rest are holes (CW), all stored in `pts`;
// ringStart has rings+1 entries. Returns index triples into `pts`. Collinear vertices are
// kept (as zero-area triangles) so per-vertex heights stay honoured on straight edges.
// ===========================================================================
std::vector<std::array<int, 3>> triangulate(const std::vector<P2>& pts, const std::vector<std::size_t>& ringStart,
double eps) {
struct Node { P2 p; int idx, prev, next; };
std::vector<Node> nd;
nd.reserve(pts.size() + 2 * ringStart.size() + 4);
auto addRing = [&](std::size_t r) {
int first = -1, last = -1;
for (std::size_t i = ringStart[r]; i < ringStart[r + 1]; ++i) {
const int id = static_cast<int>(nd.size());
nd.push_back({pts[i], static_cast<int>(i), last, -1});
if (last >= 0) nd[static_cast<std::size_t>(last)].next = id; else first = id;
last = id;
}
nd[static_cast<std::size_t>(last)].next = first;
nd[static_cast<std::size_t>(first)].prev = last;
return first;
};
auto N = [&](int id) -> Node& { return nd[static_cast<std::size_t>(id)]; };
const std::size_t rings = ringStart.size() - 1;
int outer = addRing(0);
std::vector<int> holes; // rightmost vertex of each hole
for (std::size_t r = 1; r < rings; ++r) {
const int first = addRing(r);
int best = first;
for (int id = N(first).next; id != first; id = N(id).next)
if (N(id).p.x > N(best).p.x) best = id;
holes.push_back(best);
}
std::sort(holes.begin(), holes.end(), [&](int a, int b) { return N(a).p.x > N(b).p.x; });
auto segHit = [&](P2 p1, P2 p2, P2 q1, P2 q2) {
const double d1 = cross2(p2 - p1, q1 - p1), d2 = cross2(p2 - p1, q2 - p1);
const double d3 = cross2(q2 - q1, p1 - q1), d4 = cross2(q2 - q1, p2 - q1);
if (((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps)))
return true;
auto onSeg = [&](P2 a, P2 b, P2 p) { return std::fabs(cross2(b - a, p - a)) <= eps && dot2(p - a, p - b) <= eps; };
return onSeg(p1, p2, q1) || onSeg(p1, p2, q2) || onSeg(q1, q2, p1) || onSeg(q1, q2, p2);
};
auto ringClear = [&](int start, P2 h, P2 v) { // segment h-v crosses no edge of the ring?
int e = start;
do {
const int f = N(e).next;
const P2 a = N(e).p, b = N(f).p;
if (!(same(a, h) || same(b, h) || same(a, v) || same(b, v)) && segHit(h, v, a, b)) return false;
e = f;
} while (e != start);
return true;
};
auto ringContains = [&](int start, P2 pt) { // even-odd point in ring
bool in = false;
int i = start;
do {
const P2 a = N(i).p, b = N(N(i).next).p;
if ((a.y > pt.y) != (b.y > pt.y) && pt.x < a.x + (pt.y - a.y) * (b.x - a.x) / (b.y - a.y)) in = !in;
i = N(i).next;
} while (i != start);
return in;
};
for (std::size_t hi = 0; hi < holes.size(); ++hi) {
const int H = holes[hi];
const P2 hp = N(H).p;
std::vector<std::pair<double, int>> cand;
int i = outer;
do {
const P2 d = N(i).p - hp;
cand.emplace_back(dot2(d, d), i);
i = N(i).next;
} while (i != outer);
std::sort(cand.begin(), cand.end());
int chosen = -1;
for (const auto& [dist, V] : cand) {
const P2 vp = N(V).p;
bool ok = ringClear(outer, hp, vp);
for (std::size_t hj = hi; ok && hj < holes.size(); ++hj) ok = ringClear(holes[hj], hp, vp);
if (ok) ok = ringContains(outer, {(hp.x + vp.x) * 0.5, (hp.y + vp.y) * 0.5});
if (ok) { chosen = V; break; }
}
if (chosen < 0) chosen = cand.front().second; // degenerate input: connect to the nearest anyway
const int V = chosen;
const int Vn = N(V).next, Hp = N(H).prev;
const int V2 = static_cast<int>(nd.size());
nd.push_back({N(V).p, N(V).idx, -1, -1});
const int H2 = static_cast<int>(nd.size());
nd.push_back({N(H).p, N(H).idx, -1, -1});
N(V).next = H; N(H).prev = V;
N(Hp).next = H2; N(H2).prev = Hp;
N(H2).next = V2; N(V2).prev = H2;
N(V2).next = Vn; N(Vn).prev = V2;
outer = V;
}
auto isEar = [&](int a, int b, int c) {
const P2 pa = N(a).p, pb = N(b).p, pc = N(c).p;
const double cr = cross2(pb - pa, pc - pb);
if (cr < -eps) return false; // reflex
if (cr <= eps) return false; // straight angle: keep it as a real vertex of a later ear
for (int q = N(c).next; q != a; q = N(q).next) {
const P2 pq = N(q).p;
if (same(pq, pa) || same(pq, pb) || same(pq, pc)) continue;
if (cross2(pb - pa, pq - pa) >= -eps && cross2(pc - pb, pq - pb) >= -eps &&
cross2(pa - pc, pq - pc) >= -eps)
return false;
}
return true;
};
int remaining = 0;
for (int i = outer, first = outer; ; ) { ++remaining; i = N(i).next; if (i == first) break; }
std::vector<std::array<int, 3>> tris;
tris.reserve(static_cast<std::size_t>(remaining));
auto removeNode = [&](int id) { N(N(id).prev).next = N(id).next; N(N(id).next).prev = N(id).prev; };
int cur = outer, stall = 0;
while (remaining > 3) {
const int p = N(cur).prev, n = N(cur).next;
if (isEar(p, cur, n)) {
tris.push_back({N(p).idx, N(cur).idx, N(n).idx});
removeNode(cur);
--remaining;
cur = n;
stall = 0;
continue;
}
cur = n;
if (++stall > remaining) { // no ear anywhere: clip the most convex vertex
int best = cur, it = cur;
double bestCross = -1e300;
for (int k = 0; k < remaining; ++k, it = N(it).next) {
const double c = cross2(N(it).p - N(N(it).prev).p, N(N(it).next).p - N(it).p);
if (c > bestCross) { bestCross = c; best = it; }
}
const int bp = N(best).prev, bn = N(best).next;
tris.push_back({N(bp).idx, N(best).idx, N(bn).idx});
removeNode(best);
--remaining;
cur = bn;
stall = 0;
}
}
if (remaining == 3) tris.push_back({N(N(cur).prev).idx, N(cur).idx, N(N(cur).next).idx});
return tris;
}
using namespace meshgen;
// ===========================================================================
// Regions (Clipper2): the normalised outline of one SVG shape.
@@ -349,111 +157,6 @@ bool patchAppliesTo(const SvgLabel& patch, const Item& target) {
if (std::any_of(patch.except.begin(), patch.except.end(), hit)) return false;
return true;
}
// ===========================================================================
// Mesh generation for one island
// ===========================================================================
struct Level { float f, r; };
std::vector<Level> makeLevels(const std::vector<std::pair<float, float>>& e) {
std::vector<Level> levels;
for (const auto& [f, r] : e) levels.push_back({std::clamp(f, 0.0f, 1.0f), std::max(r, 0.0f)});
std::stable_sort(levels.begin(), levels.end(), [](const Level& a, const Level& b) { return a.f < b.f; });
if (levels.empty() || levels.front().f > 0) levels.insert(levels.begin(), Level{0.0f, 1.0f});
if (levels.back().f < 1) levels.push_back(Level{1.0f, 1.0f});
return levels;
}
struct IslandData {
std::vector<P2> pts;
std::vector<float> base, top;
std::vector<Rgba> color;
std::vector<std::size_t> ringStart;
P2 pivot;
};
struct Emitter {
MeshData& out;
const ExtrudeOptions& opt;
P2 origin;
void tri(Vec3 a, Vec3 b, Vec3 c, Vec3 na, Vec3 nb, Vec3 nc, Rgba ca, Rgba cb, Rgba cc) const {
if (cross(b - a, c - a).lengthSq() < 1e-16f) return; // degenerate
out.addTriangle(a, b, c, na, nb, nc, ca, cb, cc);
}
void flatTri(Vec3 a, Vec3 b, Vec3 c, Rgba ca, Rgba cb, Rgba cc) const {
const Vec3 n = cross(b - a, c - a).normalized();
if (n.lengthSq() < 0.5f) return;
out.addTriangle(a, b, c, n, n, n, ca, cb, cc);
}
};
void emitIsland(MeshData& out, const IslandData& d, const std::vector<Level>& levels, const ExtrudeOptions& opt, P2 origin) {
const Emitter em{out, opt, origin};
const float cosCrease = std::cos(opt.smoothAngleDeg * kDegToRad);
const double s = opt.scale;
auto position = [&](std::size_t i, const Level& lv) {
const double x = d.pivot.x + lv.r * (d.pts[i].x - d.pivot.x);
const double y = d.pivot.y + lv.r * (d.pts[i].y - d.pivot.y);
return Vec3{static_cast<float>((x - origin.x) * s), d.base[i] + lv.f * (d.top[i] - d.base[i]),
static_cast<float>((y - origin.y) * s)};
};
// ---- walls: one strip per contour edge between consecutive levels ----
for (std::size_t k = 0; k + 1 < levels.size(); ++k) {
for (std::size_t r = 0; r + 1 < d.ringStart.size(); ++r) {
const std::size_t b0 = d.ringStart[r], n = d.ringStart[r + 1] - b0;
if (n < 3) continue;
std::vector<Vec3> A(n), B(n), nq(n);
for (std::size_t i = 0; i < n; ++i) {
A[i] = position(b0 + i, levels[k]);
B[i] = position(b0 + i, levels[k + 1]);
}
for (std::size_t i = 0; i < n; ++i) {
const std::size_t j = (i + 1) % n;
nq[i] = cross(B[j] - A[i], A[j] - B[i]).normalized();
}
auto smooth = [&](Vec3 own, Vec3 other) {
if (own.lengthSq() < 0.5f) return other;
if (other.lengthSq() > 0.5f && dot(own, other) > cosCrease) return (own + other).normalized();
return own;
};
for (std::size_t i = 0; i < n; ++i) {
const std::size_t j = (i + 1) % n, prev = (i + n - 1) % n, next = (j + 1) % n;
if (nq[i].lengthSq() < 0.5f) continue;
const Vec3 ni = smooth(nq[i], nq[prev]), nj = smooth(nq[i], nq[next]);
const Rgba ci = d.color[b0 + i], cj = d.color[b0 + j];
em.tri(A[i], B[j], A[j], ni, nj, nj, ci, cj, cj);
em.tri(A[i], B[i], B[j], ni, ni, nj, ci, ci, cj);
}
}
}
// ---- caps ----
const double eps = std::max(1e-12, 0.1 * std::pow(10.0, -2.0 * opt.precision));
const auto tris = triangulate(d.pts, d.ringStart, eps);
const Level& bottom = levels.front();
const Level& topLv = levels.back();
for (const auto& t : tris) {
const std::size_t a = static_cast<std::size_t>(t[0]), b = static_cast<std::size_t>(t[1]), c = static_cast<std::size_t>(t[2]);
const double area2 = cross2(d.pts[b] - d.pts[a], d.pts[c] - d.pts[a]);
const bool ccw = area2 > 0; // in svg space (y up); maps to -Y facing
// top (+Y): reverse the ccw order
{
const Vec3 pa = position(a, topLv), pb = position(b, topLv), pc = position(c, topLv);
if (ccw) em.flatTri(pa, pc, pb, d.color[a], d.color[c], d.color[b]);
else em.flatTri(pa, pb, pc, d.color[a], d.color[b], d.color[c]);
}
// bottom (-Y)
{
const Vec3 pa = position(a, bottom), pb = position(b, bottom), pc = position(c, bottom);
if (ccw) em.flatTri(pa, pb, pc, d.color[a], d.color[b], d.color[c]);
else em.flatTri(pa, pc, pb, d.color[a], d.color[c], d.color[b]);
}
}
}
} // namespace
// ===========================================================================
+261
View File
@@ -0,0 +1,261 @@
#include "engine/detail/frame.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <unordered_map>
namespace eng::detail {
int shadowMapResolution(int requested) {
int r = 256;
while (r < requested && r < 4096) r *= 2;
return r;
}
MaterialUniforms materialUniforms(const eng::Material* m, bool meshHasTexcoords) {
MaterialUniforms u;
if (!m) return u;
auto num = [](float v, float lo, float hi, float fallback) { return std::isfinite(v) ? std::clamp(v, lo, hi) : fallback; };
u.mode = 1;
u.maps = static_cast<int>(m->maps());
u.color[0] = num(m->albedo.x, 0.0f, 16.0f, 1.0f);
u.color[1] = num(m->albedo.y, 0.0f, 16.0f, 1.0f);
u.color[2] = num(m->albedo.z, 0.0f, 16.0f, 1.0f);
u.surface[0] = num(m->diffuse, 0.0f, 4.0f, 1.0f);
u.surface[1] = num(m->specular, 0.0f, 4.0f, 0.5f);
u.surface[2] = num(m->metalness, 0.0f, 1.0f, 0.0f);
u.surface[3] = num(m->roughness, 0.0f, 1.0f, 0.85f);
u.extra[0] = num(m->normalStrength, 0.0f, 8.0f, 1.0f);
u.extra[1] = num(m->emission, 0.0f, 16.0f, 0.0f);
const bool fromWorld = m->uv == UvMode::World || (m->uv == UvMode::Auto && !meshHasTexcoords);
u.extra[2] = fromWorld ? 1.0f : 0.0f;
u.specColor[0] = num(m->specularColor.x, 0.0f, 4.0f, 1.0f);
u.specColor[1] = num(m->specularColor.y, 0.0f, 4.0f, 1.0f);
u.specColor[2] = num(m->specularColor.z, 0.0f, 4.0f, 1.0f);
u.uv[0] = num(m->uvScale.x, -1.0e4f, 1.0e4f, 1.0f);
u.uv[1] = num(m->uvScale.y, -1.0e4f, 1.0e4f, 1.0f);
u.uv[2] = num(m->uvOffset.x, -1.0e4f, 1.0e4f, 0.0f);
u.uv[3] = num(m->uvOffset.y, -1.0e4f, 1.0e4f, 0.0f);
u.textures[0] = m->albedoMap.get();
u.textures[1] = m->diffuseMap.get();
u.textures[2] = m->specularMap.get();
u.textures[3] = m->metalnessMap.get();
u.textures[4] = m->roughnessMap.get();
u.textures[5] = m->normalMap.get();
return u;
}
namespace {
struct BucketKey {
std::uint64_t mesh = 0, material = 0; // material 0: none
bool mirrored = false;
bool operator==(const BucketKey&) const = default;
};
struct BucketKeyHash {
std::size_t operator()(const BucketKey& k) const {
std::uint64_t h = k.mesh * 0x9E3779B97F4A7C15ull;
h ^= (k.material + 0x7F4A7C15F39CC060ull) * 0xC2B2AE3D27D4EB4Full + (h << 6) + (h >> 2);
h ^= k.mirrored ? 0x165667B19E3779F9ull : 0;
return static_cast<std::size_t>(h);
}
};
struct Bucket { // every instance of one mesh, material and handedness in one pass
const eng::Mesh* mesh = nullptr;
const eng::Material* material = nullptr;
bool mirrored = false;
std::vector<GpuInstance> items;
std::uint64_t lastFrame = 0;
};
using Pool = std::unordered_map<BucketKey, Bucket, BucketKeyHash>;
bool isMirrored(const Mat4& w) {
const Vec3 c0{w.m[0], w.m[1], w.m[2]}, c1{w.m[4], w.m[5], w.m[6]}, c2{w.m[8], w.m[9], w.m[10]};
return dot(c0, cross(c1, c2)) < 0.0f;
}
GpuInstance pack(const MeshInstance& in) {
GpuInstance g;
std::memcpy(g.m, in.world.m, sizeof g.m);
g.rgba[0] = in.tint.r; g.rgba[1] = in.tint.g; g.rgba[2] = in.tint.b; g.rgba[3] = in.tint.a;
g.material[0] = in.roughness; g.material[1] = in.metallic; g.material[2] = in.emission; g.material[3] = in.flags;
return g;
}
void clearPool(Pool& pool) {
for (auto& [key, b] : pool) b.items.clear();
}
// `byMaterial`: the camera's pass splits batches by material (a draw call per mesh and material); the shadow passes only
// draw depth, so they ignore materials and keep one batch per mesh.
void route(Pool& pool, const MeshInstance& in, std::uint64_t frame, bool byMaterial) {
const bool mirrored = isMirrored(in.world);
const eng::Material* material = byMaterial ? in.material : nullptr;
Bucket& b = pool[BucketKey{in.mesh->id(), material ? material->id() : 0, mirrored}];
b.mesh = in.mesh;
b.material = material;
b.mirrored = mirrored;
b.lastFrame = frame;
b.items.push_back(pack(in));
}
// Appends the pool's batches to `all` in a stable order (mesh id, then material id, then handedness), so draws are reproducible.
void emit(Pool& pool, std::vector<GpuInstance>& all, std::vector<DrawBatch>& out, bool withMaterials) {
std::vector<Bucket*> used;
for (auto& [key, b] : pool)
if (!b.items.empty()) used.push_back(&b);
auto materialId = [](const Bucket* b) { return b->material ? b->material->id() : std::uint64_t{0}; };
std::sort(used.begin(), used.end(), [&](const Bucket* a, const Bucket* b) {
if (a->mesh->id() != b->mesh->id()) return a->mesh->id() < b->mesh->id();
if (materialId(a) != materialId(b)) return materialId(a) < materialId(b);
return a->mirrored < b->mirrored;
});
for (const Bucket* b : used) {
DrawBatch batch;
batch.mesh = b->mesh;
batch.mirrored = b->mirrored;
batch.first = static_cast<std::uint32_t>(all.size());
batch.count = static_cast<std::uint32_t>(b->items.size());
if (withMaterials) batch.mat = materialUniforms(b->material, b->mesh->data().hasTexcoords());
out.push_back(batch);
all.insert(all.end(), b->items.begin(), b->items.end());
}
}
void setVec4(float (&dst)[4], float x, float y, float z, float w) {
dst[0] = x; dst[1] = y; dst[2] = z; dst[3] = w;
}
void setVec3(float (&dst)[3], Vec3 v) {
dst[0] = v.x; dst[1] = v.y; dst[2] = v.z;
}
} // namespace
struct FramePlanner::Impl {
Pool pools[1 + kMaxShadowMaps]; // 0: the camera, 1..: the shadow maps
AmbientBaker baker;
FramePlan plan;
std::uint64_t frame = 0;
void prune() {
for (Pool& pool : pools)
for (auto it = pool.begin(); it != pool.end();) {
if (frame - it->second.lastFrame > 240) it = pool.erase(it); else ++it;
}
}
};
FramePlanner::FramePlanner() : impl_(std::make_unique<Impl>()) {}
FramePlanner::~FramePlanner() = default;
const FramePlan& FramePlanner::plan(World& world, float aspect) {
Impl& s = *impl_;
++s.frame;
if (s.frame % 120 == 0) s.prune();
FramePlan& p = s.plan;
std::vector<GpuInstance> keep = std::move(p.instances); // reuse the big allocation
p = FramePlan{};
keep.clear();
p.instances = std::move(keep);
p.aspect = aspect > 1e-3f ? aspect : 1.0f;
SceneSnapshot snap = gatherScene(world);
p.texts = std::move(snap.texts);
p.hasCamera = snap.hasCamera;
p.camera = snap.camera;
p.meshesSeen = static_cast<int>(snap.meshes.size());
const Environment& env = snap.env;
// The ambient grid only depends on the zones: bake it (or keep the cached bake) even without a camera.
p.ambientChanged = s.baker.update(snap.zones, env);
p.ambient = &s.baker.grid();
if (!snap.hasCamera) return p;
const CameraData& cam = snap.camera;
p.view = cam.view();
p.projection = cam.projection(p.aspect);
p.viewProj = p.projection * p.view;
const Frustum frustum = Frustum::fromViewProj(p.viewProj);
// ---- the camera's pass ----
Pool& mainPool = s.pools[0];
clearPool(mainPool);
for (const MeshInstance& m : snap.meshes) {
if (!frustum.sphereVisible(m.center, m.radius)) { ++p.culled; continue; }
route(mainPool, m, s.frame, true);
}
emit(mainPool, p.instances, p.main, true);
// ---- lights ----
LitUniforms& u = p.lit;
const std::vector<LightData> lights = selectLights(snap.lights, cam.position);
const std::vector<int> slots = assignShadowSlots(lights, env);
p.lightsShaded = static_cast<int>(lights.size());
u.lightCount = static_cast<int>(lights.size());
setVec3(u.camPos, cam.position);
for (std::size_t i = 0; i < lights.size(); ++i) {
const LightData& l = lights[i];
const Vec3 lin = toLinear(l.color) * l.intensity;
if (l.type == LightType::Directional)
setVec4(u.lightPosType[i], -l.direction.x, -l.direction.y, -l.direction.z, 0.0f);
else
setVec4(u.lightPosType[i], l.position.x, l.position.y, l.position.z, l.type == LightType::Point ? 1.0f : 2.0f);
setVec4(u.lightColorRange[i], lin.x, lin.y, lin.z, l.range);
setVec4(u.lightSpot[i], l.direction.x, l.direction.y, l.direction.z, l.cosOuter);
setVec4(u.lightParams[i], 1.0f / std::max(l.cosInner - l.cosOuter, 1e-4f), static_cast<float>(slots[i]), l.shadowBias, 0.0f);
}
// ---- shadow maps ----
std::vector<MeshInstance> castersStorage;
const std::vector<MeshInstance>* casters = &snap.meshes;
if (!std::all_of(snap.meshes.begin(), snap.meshes.end(), [](const MeshInstance& m) { return m.castShadows; })) {
for (const MeshInstance& m : snap.meshes)
if (m.castShadows) castersStorage.push_back(m);
casters = &castersStorage;
}
const int sunRes = shadowMapResolution(env.shadowResolution);
for (std::size_t i = 0; i < lights.size(); ++i) {
const int slot = slots[i];
if (slot < 0) continue;
const LightData& l = lights[i];
const bool sun = l.type == LightType::Directional;
const int res = sun ? sunRes : std::max(256, sunRes / 2); // spots get half the resolution
const ShadowView view = sun ? fitSunShadow(l.direction, cam, p.aspect, env.shadowDistance, res, *casters)
: fitSpotShadow(l, res);
ShadowPass pass;
pass.slot = slot;
pass.resolution = res;
pass.viewProj = view.viewProj;
pass.radial = view.perspective;
pass.lightPos = l.position;
pass.range = std::max(l.range, 0.01f);
Pool& pool = s.pools[1 + slot];
clearPool(pool);
for (const MeshInstance& c : *casters)
if (view.frustum.sphereVisible(c.center, c.radius)) route(pool, c, s.frame, false);
emit(pool, p.instances, pass.batches, false);
p.shadows.push_back(std::move(pass));
u.shadowMatrix[slot] = view.viewProj;
setVec4(u.shadowInfo[slot], view.texelWorld, view.perspective ? 1.0f : 0.0f, view.depthRange, 1.0f / static_cast<float>(res));
}
setVec4(u.shadowParams, std::clamp(env.shadowStrength, 0.0f, 1.0f), std::max(env.shadowSoftness, 0.0f), std::max(env.shadowDistance, 1.0f), 0.0f);
// ---- ambient, tone mapping, fog ----
setVec3(u.ambSkyGlobal, toLinear(env.ambientSky) * env.ambientIntensity);
setVec3(u.ambGroundGlobal, toLinear(env.ambientGround) * env.ambientIntensity);
const AmbientGrid& g = s.baker.grid();
if (g.valid) {
setVec4(u.ambVolOrigin, g.origin.x, g.origin.y, g.origin.z, g.cell);
setVec4(u.ambVolDims, static_cast<float>(g.nx), static_cast<float>(g.ny), static_cast<float>(g.nz), static_cast<float>(g.tilesX));
u.ambColorScale = g.colorScale;
}
setVec4(u.envParams, std::max(env.exposure, 0.0f), static_cast<float>(static_cast<int>(env.toneMapping)), std::max(env.fogDensity, 0.0f), 0.0f);
setVec3(u.fogColor, toLinear(env.fogColor));
return p;
}
} // namespace eng::detail
+1 -3
View File
@@ -1,6 +1,6 @@
#include "engine/detail/input_state.hpp"
#include <atomic>
#include <raylib.h>
namespace eng {
namespace detail {
@@ -32,8 +32,6 @@ bool Input::mouseReleased(MouseButton b) { return inRange(b) && detail::inputSta
Vec2 Input::mousePosition() { return detail::inputState().mousePosition; }
Vec2 Input::mouseDelta() { return detail::inputState().mouseDelta; }
float Input::mouseWheel() { return detail::inputState().mouseWheel; }
void Input::enableCursor() { EnableCursor(); }
void Input::disableCursor() { DisableCursor(); }
Vec2 Input::windowSize() { return detail::inputState().windowSize; }
void quit() { detail::g_quit.store(true); }
+36
View File
@@ -0,0 +1,36 @@
#include "engine/material.hpp"
#include <atomic>
namespace eng {
Material::Material() {
static std::atomic<std::uint64_t> counter{1};
id_ = counter++;
}
MaterialPtr Material::create() { return std::make_shared<Material>(); }
MaterialPtr Material::clone() const {
MaterialPtr m = std::make_shared<Material>();
m->albedo = albedo;
m->albedoMap = albedoMap;
m->diffuse = diffuse;
m->diffuseMap = diffuseMap;
m->specular = specular;
m->specularColor = specularColor;
m->specularMap = specularMap;
m->metalness = metalness;
m->metalnessMap = metalnessMap;
m->roughness = roughness;
m->roughnessMap = roughnessMap;
m->normalMap = normalMap;
m->normalStrength = normalStrength;
m->emission = emission;
m->uv = uv;
m->uvScale = uvScale;
m->uvOffset = uvOffset;
return m;
}
} // namespace eng
+185 -80
View File
@@ -17,6 +17,7 @@
#include <Jolt/Physics/Collision/Shape/CylinderShape.h>
#include <Jolt/Physics/Collision/Shape/MeshShape.h>
#include <Jolt/Physics/Collision/Shape/RotatedTranslatedShape.h>
#include <Jolt/Physics/Collision/Shape/ScaledShape.h>
#include <Jolt/Physics/Collision/Shape/SphereShape.h>
#include <Jolt/Physics/Constraints/DistanceConstraint.h>
#include <Jolt/Physics/Constraints/FixedConstraint.h>
@@ -28,6 +29,7 @@
#include <Jolt/RegisterTypes.h>
#include "access.hpp"
#include "engine/detail/collider_math.hpp"
#include "engine/physics.hpp"
#include <cstdio>
@@ -157,18 +159,26 @@ struct BodyLink {
bool added = false;
Vec3 lastPos{};
Quat lastRot{};
Vec3 bodyScale{1, 1, 1}; // world scale the body's pose was taken with (decomposed: a mirror shows as x < 0)
Vec3 scale{1, 1, 1}; // detail::shapeScale() the shape was built for: a different one reshapes the body
std::uint64_t sig = 0; // detail::colliderSignature() of the collider it was built from
};
struct CharLink {
JPH::Ref<JPH::CharacterVirtual> ch;
JPH::BodyID inner; // kinematic body inside the character: lets sensors/dynamics see it
Vec3 lastPos{};
Vec3 scale{1, 1, 1}; // |world scale| the capsule was built for
};
struct JointLink {
JPH::Ref<JPH::TwoBodyConstraint> c;
};
// Has an entity's pose left the one its body was last given? The turn is measured by the vector part of q * lq^-1, which is
// sin(angle / 2) and as exact as a float can be; "1 - |dot|" is not (rounding alone makes it exceed 1e-8 for identical
// rotations, which put dynamic bodies back in place, and woke them, on frames where nothing had moved).
bool moved(Vec3 p, Quat q, Vec3 lp, Quat lq) {
return (p - lp).lengthSq() > 1e-8f || (1.0f - std::fabs(Quat::dot(q, lq))) > 1e-8f;
const Quat d = q * lq.conjugate();
return (p - lp).lengthSq() > 1e-8f || d.x * d.x + d.y * d.y + d.z * d.z > 1e-8f; // 0.1 mm, 0.011 degrees
}
} // namespace
@@ -220,97 +230,161 @@ struct Physics::Impl {
// ---- shapes -----------------------------------------------------------
// Shapes are immutable and reference counted, so bodies that ask for the same shape share one.
// Boxes, spheres, capsules and cylinders are sized straight from the world scale (detail::colliderDims).
// Meshes and hulls are built once at unit scale and scaled with a cheap wrapper, so animating an
// entity's scale never rebuilds them.
std::unordered_map<std::string, JPH::ShapeRefC> shapeCache;
std::size_t shapePruneAt = 256;
JPH::ShapeRefC makeShape(const Collider& c, Vec3 worldScale, bool dynamic) {
const ShapeType type = (c.shape == ShapeType::Mesh && dynamic) ? ShapeType::ConvexHull : c.shape;
std::string key;
const std::uint8_t t = static_cast<std::uint8_t>(type);
key.append(reinterpret_cast<const char*>(&t), 1);
const float f[11] = {c.size.x, c.size.y, c.size.z, c.radius, c.height, c.offset.x, c.offset.y, c.offset.z,
worldScale.x, worldScale.y, worldScale.z};
key.append(reinterpret_cast<const char*>(f), sizeof f);
const std::uint64_t meshId = c.mesh ? c.mesh->id() : 0;
key.append(reinterpret_cast<const char*>(&meshId), sizeof meshId);
if (const auto it = shapeCache.find(key); it != shapeCache.end()) return it->second;
template <class T> static void put(std::string& key, const T& v) { key.append(reinterpret_cast<const char*>(&v), sizeof v); }
JPH::ShapeRefC shape = buildShape(c, worldScale, dynamic);
if (!shape) return nullptr;
void remember(std::string key, const JPH::ShapeRefC& shape) {
if (shapeCache.size() >= shapePruneAt) { // forget shapes no body uses any more
for (auto it = shapeCache.begin(); it != shapeCache.end();)
it = it->second->GetRefCount() == 1 ? shapeCache.erase(it) : std::next(it);
shapePruneAt = std::max<std::size_t>(256, shapeCache.size() * 2);
}
shapeCache.emplace(std::move(key), shape);
}
JPH::ShapeRefC makeShape(const Collider& c, Vec3 worldScale, bool dynamic) {
const detail::ColliderDims d = detail::colliderDims(c, worldScale, dynamic);
std::string key;
put(key, static_cast<std::uint8_t>(d.type));
const float f[12] = {d.halfExtents.x, d.halfExtents.y, d.halfExtents.z, d.radius, d.halfHeight, d.meshScale.x,
d.meshScale.y, d.meshScale.z, d.offset.x, d.offset.y, d.offset.z, 0.0f};
put(key, f);
put(key, static_cast<std::uint64_t>(c.mesh ? c.mesh->id() : 0));
if (const auto it = shapeCache.find(key); it != shapeCache.end()) return it->second;
JPH::ShapeRefC shape = buildShape(c, d);
if (!shape) return nullptr;
remember(std::move(key), shape);
return shape;
}
JPH::ShapeRefC buildShape(const Collider& c, Vec3 worldScale, bool dynamic) {
const Vec3 as = abs3(worldScale);
const float hMax = std::max({as.x, as.y, as.z});
const float hXZ = std::max(as.x, as.z);
ShapeType type = c.shape;
if (type == ShapeType::Mesh && dynamic) type = ShapeType::ConvexHull; // Jolt meshes cannot be dynamic
// A mesh or convex hull at unit scale, shared by every scaled variant of it.
JPH::ShapeRefC unitShape(const Collider& c, ShapeType type) {
if (!c.mesh || c.mesh->data().empty()) {
std::fprintf(stderr, "eng::Physics: %s collider without a mesh\n", type == ShapeType::Mesh ? "Mesh" : "ConvexHull");
return nullptr;
}
std::string key;
put(key, static_cast<std::uint8_t>(static_cast<unsigned>(type) | 0x80u)); // distinct from the scaled variants
put(key, static_cast<std::uint64_t>(c.mesh->id()));
if (const auto it = shapeCache.find(key); it != shapeCache.end()) return it->second;
const MeshData& md = c.mesh->data();
JPH::ShapeSettings::ShapeResult res;
switch (type) {
case ShapeType::Box: {
const Vec3 he = {std::max(0.005f, c.size.x * as.x * 0.5f), std::max(0.005f, c.size.y * as.y * 0.5f),
std::max(0.005f, c.size.z * as.z * 0.5f)};
res = JPH::BoxShapeSettings(J(he), std::min(JPH::cDefaultConvexRadius, std::min({he.x, he.y, he.z}))).Create();
break;
}
case ShapeType::Sphere:
res = JPH::SphereShapeSettings(std::max(0.005f, c.radius * hMax)).Create();
break;
case ShapeType::Capsule: {
const float r = std::max(0.005f, c.radius * hXZ);
const float half = std::max(0.005f, 0.5f * c.height * as.y - r);
res = JPH::CapsuleShapeSettings(half, r).Create();
break;
}
case ShapeType::Cylinder: {
const float r = std::max(0.005f, c.radius * hXZ);
const float half = std::max(0.005f, 0.5f * c.height * as.y);
res = JPH::CylinderShapeSettings(half, r, std::min(JPH::cDefaultConvexRadius, std::min(half, r))).Create();
break;
}
case ShapeType::ConvexHull: {
if (!c.mesh || c.mesh->data().empty()) { std::fprintf(stderr, "eng::Physics: ConvexHull collider without a mesh\n"); return nullptr; }
JPH::Array<JPH::Vec3> pts;
pts.reserve(c.mesh->data().positions.size());
for (const Vec3& p : c.mesh->data().positions) pts.push_back(J(p * worldScale));
res = JPH::ConvexHullShapeSettings(pts).Create();
break;
}
case ShapeType::Mesh: {
if (!c.mesh || c.mesh->data().empty()) { std::fprintf(stderr, "eng::Physics: Mesh collider without a mesh\n"); return nullptr; }
JPH::TriangleList tris;
const MeshData& md = c.mesh->data();
tris.reserve(md.triangleCount());
for (std::size_t i = 0; i + 2 < md.positions.size(); i += 3) {
const Vec3 a = md.positions[i] * worldScale, b = md.positions[i + 1] * worldScale,
d = md.positions[i + 2] * worldScale;
tris.push_back(JPH::Triangle(J(a), J(b), J(d)));
}
res = JPH::MeshShapeSettings(tris).Create();
break;
}
if (type == ShapeType::ConvexHull) {
JPH::Array<JPH::Vec3> pts;
pts.reserve(md.positions.size());
for (const Vec3& p : md.positions) pts.push_back(J(p));
res = JPH::ConvexHullShapeSettings(pts).Create();
} else {
JPH::TriangleList tris;
tris.reserve(md.triangleCount());
for (std::size_t i = 0; i + 2 < md.positions.size(); i += 3)
tris.push_back(JPH::Triangle(J(md.positions[i]), J(md.positions[i + 1]), J(md.positions[i + 2])));
res = JPH::MeshShapeSettings(tris).Create();
}
if (res.HasError()) {
std::fprintf(stderr, "eng::Physics: cannot create shape: %s\n", res.GetError().c_str());
return nullptr;
}
JPH::ShapeRefC shape = res.Get();
if (c.offset.lengthSq() > 0) {
JPH::ShapeSettings::ShapeResult r2 =
JPH::RotatedTranslatedShapeSettings(J(c.offset * worldScale), JPH::Quat::sIdentity(), shape).Create();
remember(std::move(key), shape);
return shape;
}
// A convex hull scaled unevenly. Jolt rounds every hull with a convex radius that a scale wrapper can only
// shrink in step with the smallest axis, which would leave a stretched hull smaller than its mesh (by up to
// radius * (largest - smallest scale)). So the points are scaled first and the hull is built from them; the
// shape cache keeps one per scale. (Uniform scales and triangle meshes only wrap the shared unit shape.)
JPH::ShapeRefC stretchedHull(const Collider& c, Vec3 scale) {
if (!c.mesh || c.mesh->data().empty()) {
std::fprintf(stderr, "eng::Physics: ConvexHull collider without a mesh\n");
return nullptr;
}
const MeshData& md = c.mesh->data();
JPH::Array<JPH::Vec3> pts;
pts.reserve(md.positions.size());
for (const Vec3& p : md.positions) pts.push_back(J(p * scale));
const JPH::ShapeSettings::ShapeResult res = JPH::ConvexHullShapeSettings(pts).Create();
if (res.HasError()) {
std::fprintf(stderr, "eng::Physics: cannot create shape: %s\n", res.GetError().c_str());
return nullptr;
}
return res.Get();
}
JPH::ShapeRefC buildShape(const Collider& c, const detail::ColliderDims& d) {
JPH::ShapeSettings::ShapeResult res;
JPH::ShapeRefC shape;
switch (d.type) {
case ShapeType::Box:
res = JPH::BoxShapeSettings(J(d.halfExtents),
std::min(JPH::cDefaultConvexRadius, std::min({d.halfExtents.x, d.halfExtents.y, d.halfExtents.z})))
.Create();
break;
case ShapeType::Sphere:
res = JPH::SphereShapeSettings(d.radius).Create();
break;
case ShapeType::Capsule:
res = JPH::CapsuleShapeSettings(d.halfHeight, d.radius).Create();
break;
case ShapeType::Cylinder:
res = JPH::CylinderShapeSettings(d.halfHeight, d.radius, std::min(JPH::cDefaultConvexRadius, std::min(d.halfHeight, d.radius)))
.Create();
break;
case ShapeType::ConvexHull:
case ShapeType::Mesh: {
if (d.type == ShapeType::ConvexHull && !detail::uniformScale(d.meshScale)) {
shape = stretchedHull(c, d.meshScale);
if (!shape) return nullptr;
break;
}
JPH::ShapeRefC unit = unitShape(c, d.type);
if (!unit) return nullptr;
if (d.meshScale.x == 1.0f && d.meshScale.y == 1.0f && d.meshScale.z == 1.0f) shape = unit;
else res = JPH::ScaledShapeSettings(unit, J(d.meshScale)).Create();
break;
}
}
if (!shape) {
if (res.HasError()) {
std::fprintf(stderr, "eng::Physics: cannot create shape: %s\n", res.GetError().c_str());
return nullptr;
}
shape = res.Get();
}
if (d.offset.lengthSq() > 0) {
JPH::ShapeSettings::ShapeResult r2 = JPH::RotatedTranslatedShapeSettings(J(d.offset), JPH::Quat::sIdentity(), shape).Create();
if (!r2.HasError()) shape = r2.Get();
}
return shape;
}
// A collider follows its entity: a new world scale, or edited size/radius/height/offset/mesh, gives the
// body a new shape. Static and kinematic bodies swap it in place (overlaps stay continuous, so a
// growing trigger volume just starts or stops touching things). A dynamic body is rebuilt with its
// velocity carried over, so its mass and inertia come out right for the new shape.
void reshape(entt::entity e, BodyLink*& link, const Collider& col, Vec3 worldScale) {
if (link->type == BodyType::Dynamic) {
const Vec3 v = fromJ(bi().GetLinearVelocity(link->id)), w = fromJ(bi().GetAngularVelocity(link->id));
destroyBody(e);
link = createBody(e);
if (!link) return;
bi().SetLinearVelocity(link->id, J(v));
bi().SetAngularVelocity(link->id, J(w));
return;
}
link->scale = detail::shapeScale(col, worldScale); // also when the build fails: no retry (and no log spam) every frame
link->sig = detail::colliderSignature(col);
if (JPH::ShapeRefC shape = makeShape(col, worldScale, false))
bi().SetShape(link->id, shape, false, JPH::EActivation::DontActivate);
}
// ---- bodies -----------------------------------------------------------
void worldPose(entt::entity e, Vec3& p, Quat& q, Vec3& s) { reg.get<Transform>(e).worldMatrix().decompose(p, q, s); }
@@ -356,6 +430,8 @@ struct Physics::Impl {
link.type = type;
link.lastPos = p;
link.lastRot = q;
link.scale = detail::shapeScale(col, s);
link.sig = detail::colliderSignature(col);
bodyToEntity[link.id.GetIndexAndSequenceNumber()] = e;
if (wantActive(e)) {
bi().AddBody(link.id, type == BodyType::Static ? JPH::EActivation::DontActivate : JPH::EActivation::Activate);
@@ -444,19 +520,34 @@ struct Physics::Impl {
Vec3 p, s;
Quat q;
worldPose(e, p, q, s);
if (detail::scaleChanged(link->scale, detail::shapeScale(col, s)) || detail::colliderSignature(col) != link->sig) {
reshape(e, link, col, s);
if (!link) continue;
}
// lastPos / lastRot are the pose the body was last given: a drift too small to matter in one frame builds up
// until it does, instead of being forgotten every frame (a platform creeping at 5 mm/s must still arrive).
switch (link->type) {
case BodyType::Kinematic: bi().MoveKinematic(link->id, JR(p), J(q), dt); break;
case BodyType::Kinematic:
bi().MoveKinematic(link->id, JR(p), J(q), dt);
link->lastPos = p;
link->lastRot = q;
break;
case BodyType::Static:
if (moved(p, q, link->lastPos, link->lastRot))
if (moved(p, q, link->lastPos, link->lastRot)) {
bi().SetPositionAndRotation(link->id, JR(p), J(q), JPH::EActivation::DontActivate);
link->lastPos = p;
link->lastRot = q;
}
break;
case BodyType::Dynamic:
if (moved(p, q, link->lastPos, link->lastRot))
if (moved(p, q, link->lastPos, link->lastRot)) {
bi().SetPositionAndRotation(link->id, JR(p), J(q), JPH::EActivation::Activate);
link->lastPos = p;
link->lastRot = q;
}
break;
}
link->lastPos = p;
link->lastRot = q;
link->bodyScale = s;
}
syncJoints();
}
@@ -552,14 +643,21 @@ struct Physics::Impl {
CharLink* link = nullptr;
if (const auto it = chars.find(e); it != chars.end()) link = &it->second;
if (link && cc.dirty_) { destroyCharacter(e); link = nullptr; }
bool carryVelocity = false; // a new size must not stop a fall in mid-air
JPH::Vec3 carried = JPH::Vec3::sZero();
if (link && (cc.dirty_ || detail::scaleChanged(link->scale, abs3(s)))) {
if (!cc.dirty_) { carried = link->ch->GetLinearVelocity(); carryVelocity = true; }
destroyCharacter(e);
link = nullptr;
}
if (!link) {
JPH::CharacterVirtualSettings cs;
cs.mUp = JPH::Vec3::sAxisY();
cs.mMaxSlopeAngle = cc.maxSlopeDeg * kDegToRad;
cs.mMass = cc.mass;
const float radius = std::max(0.05f, cc.radius);
const float half = std::max(0.01f, 0.5f * cc.height - radius);
const detail::CapsuleDims cap = detail::characterCapsule(cc.radius, cc.height, s); // follows the entity's scale
const float radius = cap.radius;
const float half = cap.halfHeight;
cs.mShape = JPH::RotatedTranslatedShapeSettings(JPH::Vec3(0, half + radius, 0), JPH::Quat::sIdentity(),
new JPH::CapsuleShape(half, radius))
.Create().Get();
@@ -568,9 +666,11 @@ struct Physics::Impl {
cs.mInnerBodyLayer = Layers::MOVING;
CharLink fresh;
fresh.ch = new JPH::CharacterVirtual(&cs, JR(p), JPH::Quat::sIdentity(), static_cast<JPH::uint64>(entt::to_integral(e)), &system);
if (carryVelocity) fresh.ch->SetLinearVelocity(carried);
fresh.inner = fresh.ch->GetInnerBodyID();
if (!fresh.inner.IsInvalid()) bodyToEntity[fresh.inner.GetIndexAndSequenceNumber()] = e;
fresh.lastPos = p;
fresh.scale = abs3(s);
link = &(chars[e] = fresh);
cc.dirty_ = false;
} else if ((p - link->lastPos).lengthSq() > 1e-8f) {
@@ -605,7 +705,8 @@ struct Physics::Impl {
system.GetDefaultLayerFilter(Layers::MOVING), JPH::BodyFilter(), JPH::ShapeFilter(), temp);
const Vec3 np = fromJ(ch.GetPosition());
reg.get<Transform>(e).setWorldPose(np, q);
Transform& tr = reg.get<Transform>(e);
tr.setWorldPose(np, tr.worldRotation()); // controllers only move: the entity keeps its own rotation, mirrored or not
link->lastPos = np;
cc.grounded_ = ch.GetGroundState() == JPH::CharacterBase::EGroundState::OnGround;
cc.velocity_ = fromJ(ch.GetLinearVelocity());
@@ -622,7 +723,11 @@ struct Physics::Impl {
bi().GetPositionAndRotation(l.id, p, q);
l.lastPos = fromJ(p);
l.lastRot = fromJ(q);
reg.get<Transform>(e).setWorldPose(l.lastPos, l.lastRot);
// The entity must show what the body stands for, mirrored scales (its own or a parent's) included.
Transform& tr = reg.get<Transform>(e);
const Mat4 parentInverse = tr.parentWorldMatrix().inverseAffine();
tr.position = parentInverse.transformPoint(l.lastPos);
tr.rotation = detail::localRotationForBody(parentInverse, l.lastRot, l.bodyScale, tr.scale, tr.rotation);
}
}
@@ -737,10 +842,10 @@ Collider Collider::triangleMesh(MeshPtr mesh) {
}
namespace detail {
void Hooks<Collider>::awake(Collider& c) { c.world().physics(); c.dirty_ = true; }
void Hooks<RigidBody>::awake(RigidBody& c) { c.world().physics(); c.dirty_ = true; }
void Hooks<CharacterController>::awake(CharacterController& c) { c.world().physics(); c.dirty_ = true; }
void Hooks<Joint>::awake(Joint& c) { c.world().physics(); c.dirty_ = true; }
void Hooks<Collider>::awake(Collider& c) { static_cast<void>(c.world().physics()); c.dirty_ = true; }
void Hooks<RigidBody>::awake(RigidBody& c) { static_cast<void>(c.world().physics()); c.dirty_ = true; }
void Hooks<CharacterController>::awake(CharacterController& c) { static_cast<void>(c.world().physics()); c.dirty_ = true; }
void Hooks<Joint>::awake(Joint& c) { static_cast<void>(c.world().physics()); c.dirty_ = true; }
void Hooks<Collider>::destroyed(Collider& c) {
if (Access::hasPhysics(c.world())) c.world().physics().releaseBody(c.entity());
+458 -233
View File
@@ -1,104 +1,58 @@
// The GPU half of rendering: uploads what FramePlanner (frame.cpp) worked out and issues the draw calls.
// Nothing here decides anything about the scene - that is all in the (headless, tested) frame plan.
//
// A frame is: refresh the ambient-zone textures if they were re-baked, upload every instance of every pass
// in ONE buffer update, render each shadow map into its own render texture, then draw the camera's pass with
// the lit shader (PBR-style, up to 16 lights, shadow maps sampled with PCF, ambient zones sampled from a
// 3D grid stored in a 2D atlas), let components draw gizmos, and finish with the 2D overlay.
#include "renderer.hpp"
#include "engine/graphics.hpp"
#include "engine/detail/frame.hpp"
#include "shaders.hpp"
#include <raylib.h>
#include <rlgl.h>
#if defined(__GNUC__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
#endif
#include <raymath.h> // MatrixMultiply (raylib's own header trips -Wextra)
#if defined(__GNUC__)
#pragma GCC diagnostic pop
#endif
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <type_traits>
#include <unordered_map>
#include <vector>
// raylib declares global types named Camera, Transform, Color, ... Inside namespace eng the
// unqualified names are the engine's own; raylib's are spelled with a leading "::" or by their
// unique names (Camera3D, Mesh is eng::Mesh here, raylib's is ::Mesh).
// unqualified names are the engine's own; raylib's are spelled with a leading "::" (::Mesh, ::Camera3D, ...).
namespace eng::detail {
namespace {
// Attribute locations: 0..5 are raylib's standard mesh layout (UploadMesh), instance data follows.
constexpr unsigned kLocInstanceTransform = 9; // mat4 -> 9..12
constexpr unsigned kLocInstanceColor = 13;
const char* kVertexShader = R"(#version 330
layout(location = 0) in vec3 vertexPosition;
layout(location = 2) in vec3 vertexNormal;
layout(location = 3) in vec4 vertexColor;
layout(location = 9) in mat4 instanceTransform;
layout(location = 13) in vec4 instanceColor;
uniform mat4 viewProj;
out vec3 fragNormal;
out vec4 fragColor;
void main() {
vec3 c0 = instanceTransform[0].xyz, c1 = instanceTransform[1].xyz, c2 = instanceTransform[2].xyz;
vec3 k0 = cross(c1, c2);
mat3 normalMat = mat3(k0, cross(c2, c0), cross(c0, c1)); // cofactor matrix: det * inverse-transpose
if (dot(c0, k0) < 0.0) normalMat = -normalMat;
fragNormal = normalize(normalMat*vertexNormal);
fragColor = vertexColor*instanceColor;
gl_Position = viewProj*instanceTransform*vec4(vertexPosition, 1.0);
}
)";
const char* kFragmentShader = R"(#version 330
in vec3 fragNormal;
in vec4 fragColor;
uniform vec3 lightDir; // direction the light travels
uniform vec3 lightColor;
uniform vec3 ambient;
out vec4 finalColor;
void main() {
float ndl = max(dot(normalize(fragNormal), -lightDir), 0.0);
finalColor = vec4(fragColor.rgb*(ambient + lightColor*ndl), fragColor.a);
}
)";
struct Instance { // per-object GPU data: 68 bytes
float m[16];
std::uint8_t rgba[4];
};
static_assert(sizeof(Instance) == 68, "instance layout is hard-coded in the attribute setup");
void matrixToArray(const ::Matrix& m, float* a) {
a[0] = m.m0; a[1] = m.m1; a[2] = m.m2; a[3] = m.m3;
a[4] = m.m4; a[5] = m.m5; a[6] = m.m6; a[7] = m.m7;
a[8] = m.m8; a[9] = m.m9; a[10] = m.m10; a[11] = m.m11;
a[12] = m.m12; a[13] = m.m13; a[14] = m.m14; a[15] = m.m15;
}
// Gribb/Hartmann frustum planes of a column-major view-projection matrix (clip = M * p).
void extractFrustum(const float* a, float planes[6][4]) {
auto row = [&](int r, int c) { return a[c * 4 + r]; };
const int sign[6] = {+1, -1, +1, -1, +1, -1};
const int axis[6] = {0, 0, 1, 1, 2, 2};
for (int i = 0; i < 6; ++i) {
float* p = planes[i];
for (int c = 0; c < 4; ++c) p[c] = row(3, c) + static_cast<float>(sign[i]) * row(axis[i], c);
const float len = std::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
if (len > 1e-12f) for (int c = 0; c < 4; ++c) p[c] /= len;
}
}
bool sphereVisible(const float planes[6][4], Vec3 c, float r) {
for (int i = 0; i < 6; ++i)
if (planes[i][0] * c.x + planes[i][1] * c.y + planes[i][2] * c.z + planes[i][3] < -r) return false;
return true;
}
::Vector3 toRaylib(Vec3 v) { return ::Vector3{v.x, v.y, v.z}; }
// raylib's Matrix lists its fields row by row (m0 m4 m8 m12 / m1 ...); element m[i] of a column-major array is mi.
::Matrix toRaylib(const Mat4& a) {
::Matrix m;
m.m0 = a.m[0]; m.m1 = a.m[1]; m.m2 = a.m[2]; m.m3 = a.m[3];
m.m4 = a.m[4]; m.m5 = a.m[5]; m.m6 = a.m[6]; m.m7 = a.m[7];
m.m8 = a.m[8]; m.m9 = a.m[9]; m.m10 = a.m[10]; m.m11 = a.m[11];
m.m12 = a.m[12]; m.m13 = a.m[13]; m.m14 = a.m[14]; m.m15 = a.m[15];
return m;
}
// rlSetVertexAttribute() takes the byte offset as an int in raylib 6 and as a `const void*` before; accept either.
template <class Fn>
void setAttribute(Fn&& fn, unsigned index, int components, int type, bool normalized, int stride, int offset) {
if constexpr (std::is_invocable_v<Fn, unsigned, int, int, bool, int, int>)
fn(index, components, type, normalized, stride, offset);
else
fn(index, components, type, normalized, stride, reinterpret_cast<const void*>(static_cast<std::intptr_t>(offset)));
}
constexpr int kTextureSlotShadow = 0; // shadowMap0..2 use slots 0..2
constexpr int kTextureSlotAmbientSky = kMaxShadowMaps;
constexpr int kTextureSlotAmbientGround = kMaxShadowMaps + 1;
constexpr int kTextureSlotMaterial = kMaxShadowMaps + 2; // the material's maps (Material::MapBit order) follow the ambient grid's
constexpr int kLastTextureSlot = kTextureSlotMaterial + Material::kMapCount - 1;
} // namespace
struct Renderer::Impl {
@@ -106,24 +60,58 @@ struct Renderer::Impl {
::Mesh mesh{};
std::uint64_t lastUsed = 0;
};
struct Bucket { // every instance of one mesh, this frame
const eng::Mesh* mesh = nullptr;
std::vector<Instance> items;
std::uint64_t lastFrame = 0;
struct LitLocs {
int viewProj = -1, camPos = -1, lightCount = -1;
int lightPosType = -1, lightColorRange = -1, lightSpot = -1, lightParams = -1;
int shadowMap[kMaxShadowMaps], shadowMatrix[kMaxShadowMaps], shadowInfo[kMaxShadowMaps];
int shadowParams = -1;
int ambSkyGlobal = -1, ambGroundGlobal = -1, ambSkyTex = -1, ambGroundTex = -1;
int ambVolOrigin = -1, ambVolDims = -1, ambColorScale = -1;
int envParams = -1, fogColor = -1;
int matMode = -1, matMaps = -1, matColor = -1, matSurface = -1, matExtra = -1, matSpecColor = -1, matUv = -1;
int matMap[Material::kMapCount];
LitLocs() {
std::fill(std::begin(matMap), std::end(matMap), -1);
std::fill(std::begin(shadowMap), std::end(shadowMap), -1);
std::fill(std::begin(shadowMatrix), std::end(shadowMatrix), -1);
std::fill(std::begin(shadowInfo), std::end(shadowInfo), -1);
}
};
struct ShadowLocs {
int viewProj = -1, shadowCast = -1, lightPos = -1;
};
::Shader shader{};
bool ready = false;
int locViewProj = -1, locLightDir = -1, locLightColor = -1, locAmbient = -1;
::Shader lit{};
::Shader shadow{};
bool ready = false; // the lit shader works (nothing can be drawn without it)
bool shadowReady = false; // the depth shader works (without it there are simply no shadows)
LitLocs L;
ShadowLocs S;
FramePlanner planner;
std::unordered_map<std::uint64_t, Gpu> gpu;
std::unordered_map<std::uint64_t, Bucket> buckets;
std::vector<Instance> packed;
unsigned instanceVbo = 0;
std::size_t instanceCapacity = 0; // in instances
float planes[6][4] = {};
::RenderTexture2D shadowRt[kMaxShadowMaps] = {};
int shadowRes[kMaxShadowMaps] = {};
bool shadowActive[kMaxShadowMaps] = {}; // rendered this frame: the lit pass may sample it
std::uint64_t shadowLastUsed[kMaxShadowMaps] = {};
struct GpuTexture {
::Texture2D tex{};
std::uint64_t lastUsed = 0;
};
std::unordered_map<std::uint64_t, GpuTexture> textures; // by Texture::id()
::Texture2D ambSky{}, ambGround{};
int ambW = 0, ambH = 0;
std::vector<std::uint8_t> ambStage; // scratch for the rectangles of a partial ambient upload
std::uint64_t frame = 0;
RenderStats stats;
// ---- meshes --------------------------------------------------------------------------------------------
// Unindexed mesh upload (no 16-bit index limit); raylib keeps the VAO/VBO ids in the ::Mesh.
static ::Mesh upload(const MeshData& d) {
::Mesh m{};
@@ -135,8 +123,13 @@ struct Renderer::Impl {
m.texcoords = static_cast<float*>(MemAlloc(static_cast<unsigned>(n) * 2 * sizeof(float)));
m.colors = static_cast<unsigned char*>(MemAlloc(static_cast<unsigned>(n) * 4));
std::memset(m.texcoords, 0, static_cast<std::size_t>(n) * 2 * sizeof(float));
const bool uvs = d.hasTexcoords();
for (int i = 0; i < n; ++i) {
const auto k = static_cast<std::size_t>(i);
if (uvs) {
m.texcoords[i * 2 + 0] = d.texcoords[k].x;
m.texcoords[i * 2 + 1] = d.texcoords[k].y;
}
m.vertices[i * 3 + 0] = d.positions[k].x;
m.vertices[i * 3 + 1] = d.positions[k].y;
m.vertices[i * 3 + 2] = d.positions[k].z;
@@ -154,51 +147,332 @@ struct Renderer::Impl {
return m;
}
const ::Mesh& gpuMesh(const eng::Mesh& mesh) {
auto it = gpu.find(mesh.id());
if (it == gpu.end()) it = gpu.emplace(mesh.id(), Gpu{upload(mesh.data()), 0}).first;
it->second.lastUsed = frame;
return it->second.mesh;
}
void ensureInstanceCapacity(std::size_t needed) {
if (needed <= instanceCapacity) return;
if (instanceVbo) rlUnloadVertexBuffer(instanceVbo);
instanceCapacity = std::max<std::size_t>({needed, instanceCapacity * 2, 1024});
instanceVbo = rlLoadVertexBuffer(nullptr, static_cast<int>(instanceCapacity * sizeof(Instance)), true);
instanceVbo = rlLoadVertexBuffer(nullptr, static_cast<int>(instanceCapacity * sizeof(GpuInstance)), true);
}
// Gathers every visible MeshRenderer below `e`, in one top-down pass (O(N), no matrix chains).
void collect(Entity e, const Mat4& parentWorld) {
if (!e.entityActive()) return; // a disabled entity hides its whole subtree
const Mat4 world = parentWorld * e.transform().localMatrix();
// All passes' instances go up in one go; each batch then points its attributes at its own slice.
void uploadInstances(const FramePlan& plan) {
if (plan.instances.empty()) return;
ensureInstanceCapacity(plan.instances.size());
rlUpdateVertexBuffer(instanceVbo, plan.instances.data(), static_cast<int>(plan.instances.size() * sizeof(GpuInstance)), 0);
}
const MeshRenderer* mr = e.tryGet<MeshRenderer>();
if (mr && mr->componentEnabled() && mr->mesh && !mr->mesh->data().empty()) {
const eng::Mesh& mesh = *mr->mesh;
bool visible = true;
if (mesh.bounds().valid()) {
const Vec3 c = world.transformPoint(mesh.bounds().center());
const Vec3 c0{world.m[0], world.m[1], world.m[2]}, c1{world.m[4], world.m[5], world.m[6]},
c2{world.m[8], world.m[9], world.m[10]};
const float scale = std::max({c0.length(), c1.length(), c2.length()});
visible = sphereVisible(planes, c, 0.5f * mesh.bounds().size().length() * scale);
// One instanced draw call. `culling`: set the cull face from the batch's handedness (shadow maps draw both faces).
// `withMaterial`: the lit pass also reads the per-instance colour and material; the depth pass leaves them off.
void drawBatch(const DrawBatch& b, bool culling, bool withMaterial) {
const ::Mesh& gm = gpuMesh(*b.mesh);
rlEnableVertexArray(gm.vaoId);
rlEnableVertexBuffer(instanceVbo);
const int base = static_cast<int>(b.first) * kInstanceStride;
for (unsigned i = 0; i < 4; ++i) {
rlEnableVertexAttribute(kLocInstanceTransform + i);
setAttribute(rlSetVertexAttribute, kLocInstanceTransform + i, 4, RL_FLOAT, false, kInstanceStride, base + static_cast<int>(i) * 16);
rlSetVertexAttributeDivisor(kLocInstanceTransform + i, 1);
}
if (withMaterial) {
rlEnableVertexAttribute(kLocInstanceColor);
setAttribute(rlSetVertexAttribute, kLocInstanceColor, 4, RL_UNSIGNED_BYTE, true, kInstanceStride, base + kInstanceColorOffset);
rlSetVertexAttributeDivisor(kLocInstanceColor, 1);
rlEnableVertexAttribute(kLocInstanceMaterial);
setAttribute(rlSetVertexAttribute, kLocInstanceMaterial, 4, RL_UNSIGNED_BYTE, true, kInstanceStride, base + kInstanceMaterialOffset);
rlSetVertexAttributeDivisor(kLocInstanceMaterial, 1);
} else {
rlDisableVertexAttribute(kLocInstanceColor); // the VAO is shared with the lit pass: nothing stale may stay enabled
rlDisableVertexAttribute(kLocInstanceMaterial);
}
rlDisableVertexBuffer();
if (culling) rlSetCullFace(b.mirrored ? RL_CULL_FACE_FRONT : RL_CULL_FACE_BACK); // mirroring flips the winding
rlDrawVertexArrayInstanced(0, gm.vertexCount, static_cast<int>(b.count));
rlDisableVertexArray();
}
// ---- material textures ---------------------------------------------------------------------------------
// One GPU copy per Texture::id(), made the first time a batch needs it and dropped when nobody has drawn with it for a while.
// Smooth textures get mipmaps and anisotropic filtering (no shimmer on floors seen at a grazing angle).
unsigned gpuTexture(const eng::Texture& t, unsigned fallback) {
auto it = textures.find(t.id());
if (it == textures.end()) {
GpuTexture g;
::Image img{};
img.data = const_cast<std::uint8_t*>(t.pixels().data()); // read only: LoadTextureFromImage copies it
img.width = t.width();
img.height = t.height();
img.mipmaps = 1;
img.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8;
g.tex = LoadTextureFromImage(img);
if (g.tex.id != 0) {
if (t.options().filter == TextureFilter::Smooth) {
GenTextureMipmaps(&g.tex);
SetTextureFilter(g.tex, TEXTURE_FILTER_TRILINEAR);
SetTextureFilter(g.tex, TEXTURE_FILTER_ANISOTROPIC_8X);
} else {
SetTextureFilter(g.tex, TEXTURE_FILTER_POINT);
}
SetTextureWrap(g.tex, t.options().wrap == TextureWrap::Clamp ? TEXTURE_WRAP_CLAMP : TEXTURE_WRAP_REPEAT);
}
if (visible) {
Bucket& b = buckets[mesh.id()];
b.mesh = &mesh;
b.lastFrame = frame;
Instance inst;
std::memcpy(inst.m, world.m, sizeof inst.m);
inst.rgba[0] = mr->tint.r; inst.rgba[1] = mr->tint.g; inst.rgba[2] = mr->tint.b; inst.rgba[3] = mr->tint.a;
b.items.push_back(inst);
} else {
++stats.culled;
it = textures.emplace(t.id(), g).first;
}
it->second.lastUsed = frame;
return it->second.tex.id != 0 ? it->second.tex.id : fallback;
}
// The lit shader's material uniforms and the six map samplers for one batch. A map that is not set gets the 1x1 white
// texture (an unbound sampler is undefined behaviour on some drivers); the shader skips it by `maps` anyway.
void applyMaterial(const MaterialUniforms& m, unsigned fallback) {
rlSetUniform(L.matMode, &m.mode, RL_SHADER_UNIFORM_INT, 1);
rlSetUniform(L.matMaps, &m.maps, RL_SHADER_UNIFORM_INT, 1);
rlSetUniform(L.matColor, m.color, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.matSurface, m.surface, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.matExtra, m.extra, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.matSpecColor, m.specColor, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.matUv, m.uv, RL_SHADER_UNIFORM_VEC4, 1);
for (int k = 0; k < Material::kMapCount; ++k) {
const eng::Texture* t = ((m.maps >> k) & 1) != 0 ? m.textures[k] : nullptr;
bindTexture(kTextureSlotMaterial + k, t ? gpuTexture(*t, fallback) : fallback, L.matMap[k]);
}
}
// ---- ambient zones -------------------------------------------------------------------------------------
void releaseAmbient() {
if (ambSky.id) UnloadTexture(ambSky);
if (ambGround.id) UnloadTexture(ambGround);
ambSky = {};
ambGround = {};
ambW = ambH = 0;
}
// Sends just the texels the last bake rewrote: one rectangle per Y slice of every box of changed cells. A zone that
// walks with the player changes a few thousand texels per frame, where the whole atlas is about a megabyte, twice.
// Returns false when that is not worth it (a large part of the atlas, or too many pieces): send everything then.
bool updateAmbientPartly(const AmbientGrid& g) {
std::size_t texels = 0, pieces = 0;
for (const CellBox& b : g.updated) {
const std::size_t slices = static_cast<std::size_t>(b.y1 - b.y0 + 1);
texels += slices * static_cast<std::size_t>(b.x1 - b.x0 + 1) * static_cast<std::size_t>(b.z1 - b.z0 + 1);
pieces += slices;
}
const std::size_t atlasW = static_cast<std::size_t>(g.atlasWidth());
if (pieces > 96 || texels * 2 > atlasW * static_cast<std::size_t>(g.atlasHeight())) return false;
for (const CellBox& b : g.updated) {
const int w = b.x1 - b.x0 + 1, h = b.z1 - b.z0 + 1;
ambStage.resize(static_cast<std::size_t>(w) * static_cast<std::size_t>(h) * 4);
for (int y = b.y0; y <= b.y1; ++y) {
const int px = (y % g.tilesX) * g.nx + b.x0, py = (y / g.tilesX) * g.nz + b.z0; // this slice's tile in the atlas
for (int pass = 0; pass < 2; ++pass) {
const std::vector<std::uint8_t>& atlas = pass == 0 ? g.atlasSky : g.atlasGround;
for (int r = 0; r < h; ++r)
std::memcpy(&ambStage[static_cast<std::size_t>(r) * static_cast<std::size_t>(w) * 4],
&atlas[(static_cast<std::size_t>(py + r) * atlasW + static_cast<std::size_t>(px)) * 4], static_cast<std::size_t>(w) * 4);
UpdateTextureRec(pass == 0 ? ambSky : ambGround,
::Rectangle{static_cast<float>(px), static_cast<float>(py), static_cast<float>(w), static_cast<float>(h)}, ambStage.data());
}
}
}
for (Entity c = e.hierarchy().firstChild(); c.valid(); c = c.hierarchy().nextSibling()) collect(c, world);
return true;
}
void refreshAmbient(const FramePlan& plan) {
if (!plan.ambientChanged || !plan.ambient) return;
const AmbientGrid& g = *plan.ambient;
if (!g.valid) {
releaseAmbient();
return;
}
const int w = g.atlasWidth(), h = g.atlasHeight();
if (ambSky.id == 0 || ambGround.id == 0 || w != ambW || h != ambH) {
releaseAmbient();
auto make = [&](const std::vector<std::uint8_t>& px) {
::Image img{};
img.data = const_cast<std::uint8_t*>(px.data()); // LoadTextureFromImage only reads it
img.width = w;
img.height = h;
img.mipmaps = 1;
img.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8;
::Texture2D t = LoadTextureFromImage(img);
SetTextureFilter(t, TEXTURE_FILTER_BILINEAR); // hardware filtering inside a slice, the shader blends slices
SetTextureWrap(t, TEXTURE_WRAP_CLAMP);
return t;
};
ambSky = make(g.atlasSky);
ambGround = make(g.atlasGround);
ambW = w;
ambH = h;
} else if (g.updateAll || !updateAmbientPartly(g)) {
UpdateTexture(ambSky, g.atlasSky.data());
UpdateTexture(ambGround, g.atlasGround.data());
}
}
// ---- shadow maps ---------------------------------------------------------------------------------------
void releaseShadow(int slot) {
if (shadowRt[slot].id) UnloadRenderTexture(shadowRt[slot]);
shadowRt[slot] = {};
shadowRes[slot] = 0;
shadowActive[slot] = false;
}
// Packed depth lives in RGBA8 (24 bits across RGB), so the map must be sampled without filtering.
bool ensureShadowTarget(int slot, int resolution) {
if (shadowRt[slot].id != 0 && shadowRes[slot] == resolution) return true;
releaseShadow(slot);
::RenderTexture2D rt = LoadRenderTexture(resolution, resolution);
if (!IsRenderTextureValid(rt)) return false;
SetTextureFilter(rt.texture, TEXTURE_FILTER_POINT);
SetTextureWrap(rt.texture, TEXTURE_WRAP_CLAMP);
shadowRt[slot] = rt;
shadowRes[slot] = resolution;
return true;
}
void drawShadowPass(const ShadowPass& pass) {
if (pass.slot < 0 || pass.slot >= kMaxShadowMaps) return;
if (!ensureShadowTarget(pass.slot, pass.resolution)) return;
BeginTextureMode(shadowRt[pass.slot]);
ClearBackground(::Color{255, 255, 255, 255}); // depth 1: nothing in the way
rlEnableDepthTest();
rlDisableColorBlend();
rlDisableBackfaceCulling(); // both faces cast: no light leaks through thin walls
rlEnableShader(shadow.id);
rlSetUniformMatrix(S.viewProj, toRaylib(pass.viewProj));
const float cast[4] = {pass.radial ? 1.0f : 0.0f, pass.range, 0.0f, 0.0f};
rlSetUniform(S.shadowCast, cast, RL_SHADER_UNIFORM_VEC4, 1);
const float lightPos[3] = {pass.lightPos.x, pass.lightPos.y, pass.lightPos.z};
rlSetUniform(S.lightPos, lightPos, RL_SHADER_UNIFORM_VEC3, 1);
for (const DrawBatch& b : pass.batches) {
drawBatch(b, false, false);
++stats.shadowDrawCalls;
}
rlDisableShader();
rlEnableBackfaceCulling();
rlEnableColorBlend();
rlDisableDepthTest();
EndTextureMode();
shadowActive[pass.slot] = true;
shadowLastUsed[pass.slot] = frame;
++stats.shadowMaps;
}
// ---- the camera's pass ---------------------------------------------------------------------------------
void bindTexture(int slot, unsigned id, int samplerLoc) {
rlActiveTextureSlot(slot);
rlEnableTexture(id);
rlSetUniform(samplerLoc, &slot, RL_SHADER_UNIFORM_INT, 1);
}
void drawLit(const FramePlan& plan) {
if (plan.main.empty()) return;
const LitUniforms& u = plan.lit;
rlEnableShader(lit.id);
rlSetUniformMatrix(L.viewProj, toRaylib(plan.viewProj));
rlSetUniform(L.camPos, u.camPos, RL_SHADER_UNIFORM_VEC3, 1);
rlSetUniform(L.lightCount, &u.lightCount, RL_SHADER_UNIFORM_INT, 1);
if (u.lightCount > 0) {
rlSetUniform(L.lightPosType, u.lightPosType, RL_SHADER_UNIFORM_VEC4, u.lightCount);
rlSetUniform(L.lightColorRange, u.lightColorRange, RL_SHADER_UNIFORM_VEC4, u.lightCount);
rlSetUniform(L.lightSpot, u.lightSpot, RL_SHADER_UNIFORM_VEC4, u.lightCount);
rlSetUniform(L.lightParams, u.lightParams, RL_SHADER_UNIFORM_VEC4, u.lightCount);
}
for (int i = 0; i < kMaxShadowMaps; ++i) {
rlSetUniformMatrix(L.shadowMatrix[i], toRaylib(u.shadowMatrix[i]));
rlSetUniform(L.shadowInfo[i], u.shadowInfo[i], RL_SHADER_UNIFORM_VEC4, 1);
}
rlSetUniform(L.shadowParams, u.shadowParams, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.ambSkyGlobal, u.ambSkyGlobal, RL_SHADER_UNIFORM_VEC3, 1);
rlSetUniform(L.ambGroundGlobal, u.ambGroundGlobal, RL_SHADER_UNIFORM_VEC3, 1);
rlSetUniform(L.ambVolOrigin, u.ambVolOrigin, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.ambVolDims, u.ambVolDims, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.ambColorScale, &u.ambColorScale, RL_SHADER_UNIFORM_FLOAT, 1);
rlSetUniform(L.envParams, u.envParams, RL_SHADER_UNIFORM_VEC4, 1);
rlSetUniform(L.fogColor, u.fogColor, RL_SHADER_UNIFORM_VEC3, 1);
// Every sampler gets a real texture (raylib's 1x1 white one when there is no shadow map / ambient grid):
// an unbound sampler is undefined behaviour on some drivers.
const unsigned fallback = rlGetTextureIdDefault();
for (int i = 0; i < kMaxShadowMaps; ++i)
bindTexture(kTextureSlotShadow + i, shadowActive[i] ? shadowRt[i].texture.id : fallback, L.shadowMap[i]);
bindTexture(kTextureSlotAmbientSky, ambSky.id ? ambSky.id : fallback, L.ambSkyTex);
bindTexture(kTextureSlotAmbientGround, ambGround.id ? ambGround.id : fallback, L.ambGroundTex);
for (const DrawBatch& b : plan.main) {
applyMaterial(b.mat, fallback);
drawBatch(b, true, true);
++stats.drawCalls;
++stats.meshes;
stats.instances += static_cast<int>(b.count);
stats.triangles += static_cast<long>(b.mesh->data().triangleCount()) * static_cast<long>(b.count);
}
for (int slot = kLastTextureSlot; slot >= 0; --slot) {
rlActiveTextureSlot(slot);
rlDisableTexture();
}
rlActiveTextureSlot(0);
rlSetCullFace(RL_CULL_FACE_BACK);
rlDisableShader();
}
void drawScene(const FramePlan& plan, World& world) {
const CameraData& cam = plan.camera;
::Camera3D rc{};
rc.position = toRaylib(cam.position);
rc.target = toRaylib(cam.position + cam.forward);
rc.up = toRaylib(cam.up);
rc.fovy = cam.orthographic ? cam.orthoSize : cam.fovDeg;
rc.projection = cam.orthographic ? CAMERA_ORTHOGRAPHIC : CAMERA_PERSPECTIVE;
rlSetClipPlanes(cam.nearPlane, cam.farPlane);
BeginMode3D(rc);
drawLit(plan);
world.drawGizmos();
EndMode3D();
}
// ---- housekeeping --------------------------------------------------------------------------------------
void release() {
for (auto& [id, g] : gpu) UnloadMesh(g.mesh);
gpu.clear();
buckets.clear();
if (instanceVbo) rlUnloadVertexBuffer(instanceVbo);
instanceVbo = 0;
instanceCapacity = 0;
for (int i = 0; i < kMaxShadowMaps; ++i) releaseShadow(i);
releaseAmbient();
for (auto& [id, g] : textures)
if (g.tex.id != 0) UnloadTexture(g.tex);
textures.clear();
}
void housekeeping() {
for (int i = 0; i < kMaxShadowMaps; ++i)
if (shadowRt[i].id != 0 && frame - shadowLastUsed[i] > 600) releaseShadow(i); // lights went away: give the memory back
if (frame % 120 != 0) return;
for (auto it = gpu.begin(); it != gpu.end();) { // meshes nobody has drawn for a while
if (frame - it->second.lastUsed > 240) { UnloadMesh(it->second.mesh); it = gpu.erase(it); }
else ++it;
}
for (auto it = textures.begin(); it != textures.end();) { // textures nobody has drawn with for a while
if (frame - it->second.lastUsed > 240) {
if (it->second.tex.id != 0) UnloadTexture(it->second.tex);
it = textures.erase(it);
} else {
++it;
}
}
}
};
@@ -208,21 +482,60 @@ const RenderStats& Renderer::stats() const { return impl_->stats; }
bool Renderer::init() {
Impl& r = *impl_;
r.shader = LoadShaderFromMemory(kVertexShader, kFragmentShader);
r.ready = IsShaderValid(r.shader);
r.lit = LoadShaderFromMemory(kLitVertexShader, kLitFragmentShader);
r.ready = IsShaderValid(r.lit);
if (!r.ready) return false;
r.locViewProj = GetShaderLocation(r.shader, "viewProj");
r.locLightDir = GetShaderLocation(r.shader, "lightDir");
r.locLightColor = GetShaderLocation(r.shader, "lightColor");
r.locAmbient = GetShaderLocation(r.shader, "ambient");
auto loc = [&](const char* name) { return GetShaderLocation(r.lit, name); };
Impl::LitLocs& L = r.L;
L.viewProj = loc("viewProj");
L.camPos = loc("camPos");
L.lightCount = loc("lightCount");
L.lightPosType = loc("lightPosType");
L.lightColorRange = loc("lightColorRange");
L.lightSpot = loc("lightSpot");
L.lightParams = loc("lightParams");
L.shadowMap[0] = loc("shadowMap0"); L.shadowMap[1] = loc("shadowMap1"); L.shadowMap[2] = loc("shadowMap2");
L.shadowMatrix[0] = loc("shadowMatrix0"); L.shadowMatrix[1] = loc("shadowMatrix1"); L.shadowMatrix[2] = loc("shadowMatrix2");
L.shadowInfo[0] = loc("shadowInfo0"); L.shadowInfo[1] = loc("shadowInfo1"); L.shadowInfo[2] = loc("shadowInfo2");
L.shadowParams = loc("shadowParams");
L.ambSkyGlobal = loc("ambSkyGlobal");
L.ambGroundGlobal = loc("ambGroundGlobal");
L.ambSkyTex = loc("ambSkyTex");
L.ambGroundTex = loc("ambGroundTex");
L.ambVolOrigin = loc("ambVolOrigin");
L.ambVolDims = loc("ambVolDims");
L.ambColorScale = loc("ambColorScale");
L.envParams = loc("envParams");
L.fogColor = loc("fogColor");
L.matMode = loc("matMode");
L.matMaps = loc("matMaps");
L.matColor = loc("matColor");
L.matSurface = loc("matSurface");
L.matExtra = loc("matExtra");
L.matSpecColor = loc("matSpecColor");
L.matUv = loc("matUv");
const char* const mapNames[Material::kMapCount] = {"matAlbedoMap", "matDiffuseMap", "matSpecularMap", "matMetalnessMap", "matRoughnessMap", "matNormalMap"};
for (int k = 0; k < Material::kMapCount; ++k) L.matMap[k] = loc(mapNames[k]);
r.shadow = LoadShaderFromMemory(kShadowVertexShader, kShadowFragmentShader);
r.shadowReady = IsShaderValid(r.shadow);
if (r.shadowReady) {
r.S.viewProj = GetShaderLocation(r.shadow, "viewProj");
r.S.shadowCast = GetShaderLocation(r.shadow, "shadowCast");
r.S.lightPos = GetShaderLocation(r.shadow, "lightPos");
}
return true;
}
void Renderer::shutdown() {
Impl& r = *impl_;
r.release();
if (r.shadowReady) {
UnloadShader(r.shadow);
r.shadowReady = false;
}
if (r.ready) {
UnloadShader(r.shader);
UnloadShader(r.lit);
r.ready = false;
}
}
@@ -232,123 +545,35 @@ void Renderer::render(World& world, bool showStats) {
r.stats = {};
if (!r.ready) return;
++r.frame;
Hierarchy& scene = world.hierarchy();
for (bool& active : r.shadowActive) active = false;
// ---- camera ----
Entity camEntity;
const Camera* cam = nullptr;
for (const Entity e : scene.entitiesWith<Camera>()) {
const Camera& c = e.get<Camera>();
if (c.componentActive()) { cam = &c; camEntity = e; break; }
}
if (cam) {
const Transform& ct = camEntity.transform();
const Vec3 pos = ct.worldPosition();
::Camera3D rc{};
rc.position = toRaylib(pos);
rc.target = toRaylib(pos + ct.forward());
rc.up = toRaylib(ct.up());
rc.fovy = cam->orthographic ? cam->orthoSize : cam->fovDeg;
rc.projection = cam->orthographic ? CAMERA_ORTHOGRAPHIC : CAMERA_PERSPECTIVE;
const int screenW = GetScreenWidth(), screenH = GetScreenHeight();
const FramePlan& plan = r.planner.plan(world, static_cast<float>(screenW) / static_cast<float>(std::max(screenH, 1)));
r.refreshAmbient(plan); // the bake is announced once: apply it even on a frame without a camera
r.stats.culled = plan.culled;
r.stats.lights = plan.lightsShaded;
// ---- light ----
Vec3 lightDir = Vec3{-0.4f, -1.0f, -0.3f}.normalized();
Vec3 lightColor{1.0f, 0.97f, 0.9f};
for (const Entity e : scene.entitiesWith<Light>()) {
const Light& l = e.get<Light>();
if (!l.componentActive()) continue;
lightDir = e.transform().forward();
lightColor = l.color * l.intensity;
break;
}
const Vec3 ambient{0.35f, 0.37f, 0.42f};
SetShaderValue(r.shader, r.locLightDir, &lightDir, SHADER_UNIFORM_VEC3);
SetShaderValue(r.shader, r.locLightColor, &lightColor, SHADER_UNIFORM_VEC3);
SetShaderValue(r.shader, r.locAmbient, &ambient, SHADER_UNIFORM_VEC3);
rlSetClipPlanes(cam->nearPlane, cam->farPlane);
BeginMode3D(rc);
const ::Matrix viewProj = MatrixMultiply(rlGetMatrixModelview(), rlGetMatrixProjection());
float vp[16];
matrixToArray(viewProj, vp);
extractFrustum(vp, r.planes);
// ---- gather -> batches per mesh ----
for (auto& [id, b] : r.buckets) b.items.clear();
r.collect(scene.entity(), Mat4{});
// ---- pack all instances into one buffer, one upload ----
r.packed.clear();
struct Batch { const eng::Mesh* mesh; std::size_t first, count; };
std::vector<Batch> batches;
for (auto& [id, b] : r.buckets) {
if (b.items.empty()) continue;
batches.push_back({b.mesh, r.packed.size(), b.items.size()});
r.packed.insert(r.packed.end(), b.items.begin(), b.items.end());
}
if (!r.packed.empty()) {
r.ensureInstanceCapacity(r.packed.size());
rlUpdateVertexBuffer(r.instanceVbo, r.packed.data(), static_cast<int>(r.packed.size() * sizeof(Instance)), 0);
rlEnableShader(r.shader.id);
rlSetUniformMatrix(r.locViewProj, viewProj);
for (const Batch& batch : batches) {
auto it = r.gpu.find(batch.mesh->id());
if (it == r.gpu.end()) it = r.gpu.emplace(batch.mesh->id(), Impl::Gpu{Impl::upload(batch.mesh->data()), 0}).first;
it->second.lastUsed = r.frame;
const ::Mesh& gm = it->second.mesh;
rlEnableVertexArray(gm.vaoId);
rlEnableVertexBuffer(r.instanceVbo);
const int base = static_cast<int>(batch.first * sizeof(Instance));
for (unsigned i = 0; i < 4; ++i) {
rlEnableVertexAttribute(kLocInstanceTransform + i);
rlSetVertexAttribute(kLocInstanceTransform + i, 4, RL_FLOAT, false, sizeof(Instance), base + static_cast<int>(i) * 16);
rlSetVertexAttributeDivisor(kLocInstanceTransform + i, 1);
}
rlEnableVertexAttribute(kLocInstanceColor);
rlSetVertexAttribute(kLocInstanceColor, 4, RL_UNSIGNED_BYTE, true, sizeof(Instance), base + 64);
rlSetVertexAttributeDivisor(kLocInstanceColor, 1);
rlDisableVertexBuffer();
rlDrawVertexArrayInstanced(0, gm.vertexCount, static_cast<int>(batch.count));
rlDisableVertexArray();
++r.stats.drawCalls;
++r.stats.meshes;
r.stats.instances += static_cast<int>(batch.count);
r.stats.triangles += static_cast<long>(gm.triangleCount) * static_cast<long>(batch.count);
}
rlDisableShader();
}
EndMode3D();
// ---- housekeeping: free GPU meshes / buckets nobody used for a while ----
if (r.frame % 120 == 0) {
for (auto it = r.gpu.begin(); it != r.gpu.end();) {
if (r.frame - it->second.lastUsed > 240) { UnloadMesh(it->second.mesh); it = r.gpu.erase(it); }
else ++it;
}
for (auto it = r.buckets.begin(); it != r.buckets.end();) {
if (r.frame - it->second.lastFrame > 240) it = r.buckets.erase(it); else ++it;
}
}
if (plan.hasCamera) {
r.uploadInstances(plan);
if (r.shadowReady)
for (const ShadowPass& pass : plan.shadows) r.drawShadowPass(pass);
r.drawScene(plan, world);
}
// ---- 2D overlay ----
for (const Entity e : scene.entitiesWith<Text>()) {
const Text& t = e.get<Text>();
if (!t.componentActive() || t.value.empty()) continue;
for (const TextData& t : plan.texts)
DrawText(t.value.c_str(), static_cast<int>(t.position.x), static_cast<int>(t.position.y), static_cast<int>(t.size),
::Color{t.color.r, t.color.g, t.color.b, t.color.a});
}
world.drawGui();
if (showStats) {
const int y = GetScreenHeight() - 28;
DrawRectangle(0, y - 6, 560, 34, ::Color{0, 0, 0, 140});
DrawText(TextFormat("%d fps draw calls %d instances %d culled %d tris %ld", GetFPS(), r.stats.drawCalls,
r.stats.instances, r.stats.culled, r.stats.triangles),
const int y = screenH - 28;
DrawRectangle(0, y - 6, 820, 34, ::Color{0, 0, 0, 140});
DrawText(TextFormat("%d fps draw calls %d (+%d shadow) instances %d culled %d tris %ld lights %d shadow maps %d",
GetFPS(), r.stats.drawCalls, r.stats.shadowDrawCalls, r.stats.instances, r.stats.culled, r.stats.triangles,
r.stats.lights, r.stats.shadowMaps),
8, y, 18, ::Color{255, 255, 255, 255});
}
r.housekeeping();
}
} // namespace eng::detail
+7 -3
View File
@@ -7,11 +7,14 @@
namespace eng::detail {
struct RenderStats {
int drawCalls = 0; // instanced draw calls issued (one per distinct mesh)
int drawCalls = 0; // instanced draw calls of the camera pass (one per distinct mesh and handedness)
int instances = 0; // objects drawn
int culled = 0; // objects rejected by frustum culling
int meshes = 0; // distinct meshes drawn
long triangles = 0; // triangles submitted
long triangles = 0; // triangles submitted by the camera pass
int lights = 0; // lights shaded
int shadowMaps = 0; // shadow maps rendered
int shadowDrawCalls = 0; // draw calls spent on shadow maps
};
class Renderer {
@@ -23,7 +26,8 @@ public:
bool init(); // after the window exists
void shutdown(); // before it closes
// 3D pass from the first active Camera (GPU-instanced, frustum culled), then Text overlays.
// Shadow maps, then the lit 3D pass from the first active Camera (GPU-instanced, frustum culled), the
// components' drawGizmos() inside that view, then HUD text, drawGui() and the optional stats overlay.
void render(World& world, bool showStats);
[[nodiscard]] const RenderStats& stats() const;
+35 -8
View File
@@ -43,18 +43,24 @@ namespace {
// Generators
// ===========================================================================
MeshData generateBox(Vec3 size, Rgba color) {
struct Face { Vec3 n, u, v; };
static const Face faces[6] = {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}, {{-1, 0, 0}, {0, 0, 1}, {0, 1, 0}},
{{0, 1, 0}, {0, 0, 1}, {1, 0, 0}}, {{0, -1, 0}, {1, 0, 0}, {0, 0, 1}},
{{0, 0, 1}, {1, 0, 0}, {0, 1, 0}}, {{0, 0, -1}, {0, 1, 0}, {1, 0, 0}}};
struct Face { Vec3 n, u, v, up; }; // outward normal, two edge directions for the corners, and "up" in the texture
static const Face faces[6] = {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}, {0, 1, 0}}, {{-1, 0, 0}, {0, 0, 1}, {0, 1, 0}, {0, 1, 0}},
{{0, 1, 0}, {0, 0, 1}, {1, 0, 0}, {0, 0, -1}}, {{0, -1, 0}, {1, 0, 0}, {0, 0, 1}, {0, 0, 1}},
{{0, 0, 1}, {1, 0, 0}, {0, 1, 0}, {0, 1, 0}}, {{0, 0, -1}, {0, 1, 0}, {1, 0, 0}, {0, 1, 0}}};
MeshData m;
const Vec3 h = size * 0.5f;
for (const Face& f : faces) {
const Vec3 c{f.n.x * h.x, f.n.y * h.y, f.n.z * h.z};
const Vec3 u{f.u.x * h.x, f.u.y * h.y, f.u.z * h.z}, v{f.v.x * h.x, f.v.y * h.y, f.v.z * h.z};
const Vec3 p0 = c - u - v, p1 = c + u - v, p2 = c + u + v, p3 = c - u + v;
m.addTriangle(p0, p1, p2, f.n, f.n, f.n, color, color, color);
m.addTriangle(p0, p2, p3, f.n, f.n, f.n, color, color, color);
// Texture coordinates in world units, so a texture is not stretched however long the box is: seen from outside,
// u grows to the right and v downwards from the top edge of the face (the top face's "up" is -Z).
const Vec3 right = cross(f.up, f.n);
const float width = std::fabs(right.x) * size.x + std::fabs(right.y) * size.y + std::fabs(right.z) * size.z;
const float height = std::fabs(f.up.x) * size.x + std::fabs(f.up.y) * size.y + std::fabs(f.up.z) * size.z;
auto uv = [&](Vec3 p) { const Vec3 d = p - c; return Vec2{dot(d, right) + width * 0.5f, height * 0.5f - dot(d, f.up)}; };
m.addTriangle(p0, p1, p2, f.n, f.n, f.n, color, color, color, uv(p0), uv(p1), uv(p2));
m.addTriangle(p0, p2, p3, f.n, f.n, f.n, color, color, color, uv(p0), uv(p2), uv(p3));
}
return m;
}
@@ -68,12 +74,14 @@ MeshData generateSphere(float radius, int segments, int rings, Rgba color) {
const float phi = 2 * kPi * static_cast<float>(j) / static_cast<float>(segments);
return Vec3{std::sin(theta) * std::cos(phi), std::cos(theta), std::sin(theta) * std::sin(phi)};
};
// The whole texture once around the sphere (u from the right as seen from outside) and from pole to pole (v = 0 at the top).
auto uv = [&](int i, int j) { return Vec2{1.0f - static_cast<float>(j) / static_cast<float>(segments), static_cast<float>(i) / static_cast<float>(rings)}; };
for (int i = 0; i < rings; ++i)
for (int j = 0; j < segments; ++j) {
const Vec3 a = point(i, j), b = point(i + 1, j), c = point(i + 1, j + 1), d = point(i, j + 1);
// a==d at the top pole and b==c at the bottom pole: skip the degenerate half of the quad
if (i < rings - 1) m.addTriangle(a * radius, c * radius, b * radius, a, c, b, color, color, color);
if (i > 0) m.addTriangle(a * radius, d * radius, c * radius, a, d, c, color, color, color);
if (i < rings - 1) m.addTriangle(a * radius, c * radius, b * radius, a, c, b, color, color, color, uv(i, j), uv(i + 1, j + 1), uv(i + 1, j));
if (i > 0) m.addTriangle(a * radius, d * radius, c * radius, a, d, c, color, color, color, uv(i, j), uv(i, j + 1), uv(i + 1, j + 1));
}
return m;
}
@@ -128,6 +136,7 @@ struct State {
std::mutex mutex;
Cache<Mesh> meshes;
Cache<Model> models;
Cache<Texture> textures;
std::size_t hits = 0, misses = 0;
};
State& state() {
@@ -221,11 +230,28 @@ ModelPtr Resources::svgFromString(std::string_view xml, const ExtrudeOptions& op
});
}
TexturePtr Resources::texture(const std::string& path, TextureOptions options) {
std::error_code ec;
const std::filesystem::path canonical = std::filesystem::weakly_canonical(path, ec);
Key k;
k.tag("texture").add(std::string_view(ec ? path : canonical.string()));
const auto size = std::filesystem::file_size(path, ec);
k.raw(ec ? std::uintmax_t{0} : size);
const auto stamp = std::filesystem::last_write_time(path, ec);
k.raw(ec ? std::int64_t{0} : static_cast<std::int64_t>(stamp.time_since_epoch().count()));
k.raw(static_cast<std::uint32_t>(options.wrap)).raw(static_cast<std::uint32_t>(options.filter));
return getOrCreate(&State::textures, k.s, [&] {
ImageData img = loadImageFile(path);
return Texture::create(img.width, img.height, std::move(img.rgba), options);
});
}
void Resources::collect() {
State& st = state();
std::lock_guard<std::mutex> lock(st.mutex);
st.meshes.prune();
st.models.prune();
st.textures.prune();
}
Resources::Stats Resources::stats() {
@@ -234,6 +260,7 @@ Resources::Stats Resources::stats() {
Stats s;
s.liveMeshes = st.meshes.live();
s.liveModels = st.models.live();
s.liveTextures = st.textures.live();
s.hits = st.hits;
s.misses = st.misses;
return s;
+276
View File
@@ -0,0 +1,276 @@
#include "engine/detail/scene.hpp"
#include <algorithm>
#include <cmath>
namespace eng::detail {
// ===========================================================================
// Frustum
// ===========================================================================
Frustum Frustum::fromViewProj(const Mat4& vp) {
Frustum f;
auto row = [&](int r, int c) { return vp.m[c * 4 + r]; };
const int sign[6] = {+1, -1, +1, -1, +1, -1};
const int axis[6] = {0, 0, 1, 1, 2, 2};
for (int i = 0; i < 6; ++i) {
float* p = f.planes[i];
for (int c = 0; c < 4; ++c) p[c] = row(3, c) + static_cast<float>(sign[i]) * row(axis[i], c);
const float len = std::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
if (len > 1e-12f) for (int c = 0; c < 4; ++c) p[c] /= len;
}
return f;
}
bool Frustum::sphereVisible(Vec3 c, float r) const {
for (int i = 0; i < 6; ++i)
if (planes[i][0] * c.x + planes[i][1] * c.y + planes[i][2] * c.z + planes[i][3] < -r) return false;
return true;
}
// ===========================================================================
// Scene gathering
// ===========================================================================
namespace {
std::uint8_t toByte(float v) { return static_cast<std::uint8_t>(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); }
struct Gatherer {
SceneSnapshot& out;
void visit(Entity e, const Mat4& parentWorld, bool hiddenAbove) {
if (!e.entityActive()) return; // a disabled entity hides its whole subtree
const Mat4 world = parentWorld * e.transform().localMatrix();
if (!hiddenAbove) {
if (const MeshRenderer* mr = e.tryGet<MeshRenderer>(); mr && mr->componentEnabled() && mr->visible) addMesh(*mr, world);
if (const Light* l = e.tryGet<Light>(); l && l->componentEnabled() && l->visible) addLight(*l, world);
if (const Text* t = e.tryGet<Text>(); t && t->componentEnabled() && t->visible && !t->value.empty())
out.texts.push_back({t->value, t->position, t->size, t->color});
}
if (const AmbientZone* z = e.tryGet<AmbientZone>(); z && z->componentEnabled()) addZone(*z, world); // zones are not "drawn"
bool hideChildren = hiddenAbove;
forEachRenderable(e, [&](Renderable& r) { hideChildren = hideChildren || (r.componentEnabled() && !r.childrenVisible); });
for (Entity c = e.hierarchy().firstChild(); c.valid(); c = c.hierarchy().nextSibling()) visit(c, world, hideChildren);
}
void addMesh(const MeshRenderer& mr, const Mat4& world) {
if (!mr.mesh || mr.mesh->data().empty()) return;
const eng::Mesh& mesh = *mr.mesh;
MeshInstance inst;
inst.mesh = &mesh;
inst.material = mr.material.get();
inst.world = world;
if (mesh.bounds().valid()) {
inst.center = world.transformPoint(mesh.bounds().center());
const Vec3 c0{world.m[0], world.m[1], world.m[2]}, c1{world.m[4], world.m[5], world.m[6]},
c2{world.m[8], world.m[9], world.m[10]};
inst.radius = 0.5f * mesh.bounds().size().length() * std::max({c0.length(), c1.length(), c2.length()});
} else {
inst.center = world.translation();
}
inst.tint = mr.tint;
inst.roughness = toByte(mr.roughness);
inst.metallic = toByte(mr.metallic);
inst.emission = toByte(mr.emission / 4.0f);
inst.flags = static_cast<std::uint8_t>((mr.receiveShadows ? kMatReceiveShadows : 0) | (mr.unlit ? kMatUnlit : 0));
inst.castShadows = mr.castShadows;
out.meshes.push_back(inst);
}
void addLight(const Light& l, const Mat4& world) {
LightData d;
d.type = l.type;
d.position = world.translation();
d.direction = world.transformDirection({0, 0, -1}).normalized();
d.color = l.color;
d.intensity = l.intensity;
d.range = std::max(l.range, 0.01f);
const float outerHalf = 0.5f * std::clamp(l.spotAngleDeg, 1.0f, 179.0f);
const float innerHalf = outerHalf * (1.0f - std::clamp(l.spotSoftness, 0.0f, 1.0f));
d.cosOuter = std::cos(outerHalf * kDegToRad);
d.cosInner = std::cos(innerHalf * kDegToRad);
d.castShadows = l.castShadows && l.type != LightType::Point; // point lights are never shadowed
d.shadowBias = l.shadowBias;
out.lights.push_back(d);
}
void addZone(const AmbientZone& z, const Mat4& world) {
ZoneData d;
d.shape = z.shape;
d.world = world;
d.inverse = world.inverseAffine();
d.size = z.size;
d.radius = z.radius;
d.height = z.height;
d.polygon = z.polygon;
d.mesh = z.mesh;
d.sky = z.sky;
d.ground = z.ground;
d.intensity = z.intensity;
d.mode = z.mode;
d.falloff = std::max(z.falloff, 0.0f);
d.priority = z.priority;
out.zones.push_back(std::move(d));
}
};
} // namespace
SceneSnapshot gatherScene(World& world) {
SceneSnapshot s;
Hierarchy& scene = world.hierarchy();
for (const Entity e : scene.entitiesWith<Camera>()) {
const Camera& c = e.get<Camera>();
if (!c.componentActive()) continue;
const Transform& t = e.transform();
s.hasCamera = true;
s.camera.position = t.worldPosition();
s.camera.forward = t.forward();
s.camera.up = t.up();
s.camera.fovDeg = c.fovDeg;
s.camera.nearPlane = c.nearPlane;
s.camera.farPlane = c.farPlane;
s.camera.orthographic = c.orthographic;
s.camera.orthoSize = c.orthoSize;
break;
}
for (const Entity e : scene.entitiesWith<Environment>()) {
const Environment& env = e.get<Environment>();
if (env.componentActive()) { s.env = env; break; }
}
Gatherer g{s};
g.visit(scene.entity(), Mat4{}, false);
// A world without any Light component gets a default sun. (Lights that exist but are hidden or disabled
// mean "lights off", so they do not bring it back.)
bool anyLight = false;
for (const Entity e : scene.entitiesWith<Light>()) { (void)e; anyLight = true; break; }
if (!anyLight) {
LightData sun;
sun.direction = Vec3{-0.4f, -1.0f, -0.3f}.normalized();
sun.color = {1.0f, 0.97f, 0.9f};
sun.castShadows = true;
s.lights.push_back(sun);
}
return s;
}
// ===========================================================================
// Light budget and shadow slots
// ===========================================================================
std::vector<LightData> selectLights(const std::vector<LightData>& all, Vec3 cameraPosition) {
std::vector<LightData> suns, local;
for (const LightData& l : all) {
if (l.intensity <= 0 || std::max({l.color.x, l.color.y, l.color.z}) <= 0) continue;
(l.type == LightType::Directional ? suns : local).push_back(l);
}
auto brightness = [](const LightData& l) { return l.intensity * std::max({l.color.x, l.color.y, l.color.z}); };
std::stable_sort(suns.begin(), suns.end(), [&](const LightData& a, const LightData& b) { return brightness(a) > brightness(b); });
if (suns.size() > 4) suns.resize(4);
auto score = [&](const LightData& l) { return brightness(l) / (1.0f + (l.position - cameraPosition).lengthSq()); };
std::stable_sort(local.begin(), local.end(), [&](const LightData& a, const LightData& b) { return score(a) > score(b); });
std::vector<LightData> out = suns;
for (const LightData& l : local) {
if (static_cast<int>(out.size()) >= kMaxLights) break;
out.push_back(l);
}
return out;
}
std::vector<int> assignShadowSlots(const std::vector<LightData>& selected, const Environment& env) {
std::vector<int> slot(selected.size(), -1);
if (!env.shadows) return slot;
const int budget = std::clamp(env.maxShadowLights, 0, kMaxShadowMaps);
int next = 0;
for (std::size_t i = 0; i < selected.size() && next < budget; ++i) // the sun first ...
if (selected[i].castShadows && selected[i].type == LightType::Directional) { slot[i] = next++; break; }
for (std::size_t i = 0; i < selected.size() && next < budget; ++i) // ... then spots, in priority order
if (selected[i].castShadows && selected[i].type == LightType::Spot) slot[i] = next++;
return slot;
}
// ===========================================================================
// Shadow-map fitting
// ===========================================================================
namespace {
Vec3 lightUp(Vec3 d) { return std::fabs(d.y) > 0.99f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; }
} // namespace
ShadowView fitSunShadow(Vec3 lightDir, const CameraData& cam, float aspect, float shadowDistance, int resolution,
const std::vector<MeshInstance>& casters) {
const Vec3 d = lightDir.normalized();
const float nearD = std::max(cam.nearPlane, 0.01f);
const float farD = std::max(nearD + 0.1f, std::min(cam.farPlane, shadowDistance));
// The slice of the camera's view the shadow has to cover.
const Vec3 right = cross(cam.forward, cam.up).normalized();
Vec3 corners[8];
for (int i = 0; i < 2; ++i) {
const float dist = i == 0 ? nearD : farD;
const float hh = cam.orthographic ? 0.5f * cam.orthoSize : dist * std::tan(cam.fovDeg * kDegToRad * 0.5f);
const float hw = hh * aspect;
const Vec3 c = cam.position + cam.forward * dist;
corners[i * 4 + 0] = c + right * hw + cam.up * hh;
corners[i * 4 + 1] = c - right * hw + cam.up * hh;
corners[i * 4 + 2] = c + right * hw - cam.up * hh;
corners[i * 4 + 3] = c - right * hw - cam.up * hh;
}
Vec3 centre{};
for (const Vec3& c : corners) centre += c;
centre = centre / 8.0f;
float radius = 1.0f;
for (const Vec3& c : corners) radius = std::max(radius, (c - centre).length()); // a sphere: the same size however the camera turns
// Snap the window to whole texels (in light space) so shadow edges do not crawl.
const Vec3 up = lightUp(d);
const Mat4 ref = Mat4::lookAt({0, 0, 0}, d, up);
const float texel = 2.0f * radius / static_cast<float>(std::max(resolution, 1));
Vec3 ls = ref.transformPoint(centre);
ls.x = std::floor(ls.x / texel) * texel;
ls.y = std::floor(ls.y / texel) * texel;
const Vec3 snapped = ref.inverseAffine().transformPoint(ls);
// Depth range: just the casters that can land in the window.
float tmin = -radius, tmax = radius;
bool any = false;
for (const MeshInstance& c : casters) {
const Vec3 rel = c.center - snapped;
const float t = dot(rel, d);
if ((rel - d * t).length() > radius * 1.5f + c.radius) continue;
const float lo = t - c.radius, hi = t + c.radius;
tmin = any ? std::min(tmin, lo) : lo;
tmax = any ? std::max(tmax, hi) : hi;
any = true;
}
const Vec3 eye = snapped + d * (tmin - 1.0f);
const float depth = (tmax - tmin) + 2.0f;
ShadowView v;
v.viewProj = Mat4::ortho(-radius, radius, -radius, radius, 0.0f, depth) * Mat4::lookAt(eye, eye + d, up);
v.frustum = Frustum::fromViewProj(v.viewProj);
v.perspective = false;
v.texelWorld = texel;
v.depthRange = depth;
return v;
}
ShadowView fitSpotShadow(const LightData& l, int resolution) {
const Vec3 d = l.direction.normalized();
const float halfOuterDeg = std::acos(std::clamp(l.cosOuter, -1.0f, 1.0f)) / kDegToRad;
const float fov = std::min(170.0f, 2.0f * halfOuterDeg + 4.0f);
const float nearP = std::max(0.05f, l.range * 0.01f);
ShadowView v;
v.viewProj = Mat4::perspective(fov, 1.0f, nearP, std::max(l.range, nearP + 0.1f)) * Mat4::lookAt(l.position, l.position + d, lightUp(d));
v.frustum = Frustum::fromViewProj(v.viewProj);
v.perspective = true;
v.texelWorld = 2.0f * std::tan(fov * kDegToRad * 0.5f) / static_cast<float>(std::max(resolution, 1));
v.depthRange = std::max(l.range, 0.01f);
return v;
}
} // namespace eng::detail
+381
View File
@@ -0,0 +1,381 @@
// GLSL 330 core for the lit, instanced mesh pipeline and its shadow pass. Kept in plain raw strings; tools/gpu_check
// compiles them on a software OpenGL driver and compares what they draw with an independent CPU model of the lighting.
#pragma once
namespace eng::detail {
// Attribute locations: 0..5 are raylib's standard mesh layout (UploadMesh), instance data follows.
inline constexpr unsigned kLocInstanceTransform = 9; // mat4 -> 9..12
inline constexpr unsigned kLocInstanceColor = 13;
inline constexpr unsigned kLocInstanceMaterial = 14; // roughness, metallic, emission / 4, flags
inline const char* const kLitVertexShader = R"GLSL(#version 330
layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec2 vertexTexCoord;
layout(location = 2) in vec3 vertexNormal;
layout(location = 3) in vec4 vertexColor;
layout(location = 9) in mat4 instanceTransform;
layout(location = 13) in vec4 instanceColor;
layout(location = 14) in vec4 instanceMaterial;
uniform mat4 viewProj;
out vec3 fragWorldPos;
out vec3 fragNormal;
out vec4 fragColor;
out vec2 fragUV;
flat out vec4 fragMaterial;
void main() {
vec3 c0 = instanceTransform[0].xyz;
vec3 c1 = instanceTransform[1].xyz;
vec3 c2 = instanceTransform[2].xyz;
vec3 k0 = cross(c1, c2);
mat3 normalMat = mat3(k0, cross(c2, c0), cross(c0, c1)); // cofactor matrix: det * inverse-transpose
if (dot(c0, k0) < 0.0) normalMat = -normalMat;
fragNormal = normalize(normalMat*vertexNormal);
vec4 world = instanceTransform*vec4(vertexPosition, 1.0);
fragWorldPos = world.xyz;
fragColor = vertexColor*instanceColor;
fragUV = vertexTexCoord;
fragMaterial = instanceMaterial;
gl_Position = viewProj*world;
}
)GLSL";
inline const char* const kLitFragmentShader = R"GLSL(#version 330
#define MAX_LIGHTS 16
const float PI = 3.14159265359;
in vec3 fragWorldPos;
in vec3 fragNormal;
in vec4 fragColor;
in vec2 fragUV;
flat in vec4 fragMaterial;
uniform vec3 camPos;
uniform int lightCount;
uniform vec4 lightPosType[MAX_LIGHTS]; // xyz: position (point/spot) or direction TO the light (sun); w: 0 sun, 1 point, 2 spot
uniform vec4 lightColorRange[MAX_LIGHTS]; // rgb: linear colour * intensity; w: range
uniform vec4 lightSpot[MAX_LIGHTS]; // xyz: the way a spot points; w: cos of the outer half angle
uniform vec4 lightParams[MAX_LIGHTS]; // x: 1 / (cos inner - cos outer); y: shadow slot (-1 none); z: shadow bias multiplier
uniform sampler2D shadowMap0;
uniform sampler2D shadowMap1;
uniform sampler2D shadowMap2;
uniform mat4 shadowMatrix0;
uniform mat4 shadowMatrix1;
uniform mat4 shadowMatrix2;
uniform vec4 shadowInfo0; // x: texel size in world units (spot: at distance 1); y: 1 = spot; z: depth range; w: 1 / resolution
uniform vec4 shadowInfo1;
uniform vec4 shadowInfo2;
uniform vec4 shadowParams; // x: strength; y: softness (texels); z: sun shadow distance
uniform vec3 ambSkyGlobal; // linear
uniform vec3 ambGroundGlobal;
uniform sampler2D ambSkyTex; // baked ambient zones: one tile per Y slice, gamma-encoded
uniform sampler2D ambGroundTex;
uniform vec4 ambVolOrigin; // xyz: grid origin; w: cell size
uniform vec4 ambVolDims; // xyz: cells; w: tiles per atlas row (x < 0.5: no zones)
uniform float ambColorScale;
uniform vec4 envParams; // x: exposure; y: tone mapping (0 none, 1 Reinhard, 2 ACES); z: fog density
uniform vec3 fogColor; // linear
// ---- the material of this draw call (see material.hpp) ----
uniform int matMode; // 0: plain (roughness / metallic / emission per instance, no maps); 1: a Material
uniform int matMaps; // which maps are set: 1 albedo, 2 diffuse, 4 specular, 8 metalness, 16 roughness, 32 normal
uniform vec4 matColor; // rgb: albedo (gamma-encoded)
uniform vec4 matSurface; // x: diffuse; y: specular level; z: metalness; w: roughness
uniform vec4 matExtra; // x: normal strength; y: emission; z: 1 = project the textures from the world, 0 = mesh UVs
uniform vec4 matSpecColor; // rgb: specular colour (gamma-encoded)
uniform vec4 matUv; // xy: texture coordinate scale; zw: offset
uniform sampler2D matAlbedoMap;
uniform sampler2D matDiffuseMap;
uniform sampler2D matSpecularMap;
uniform sampler2D matMetalnessMap;
uniform sampler2D matRoughnessMap;
uniform sampler2D matNormalMap;
out vec4 finalColor;
vec3 toLinear(vec3 c) { return pow(c, vec3(2.2)); }
vec3 toSrgb(vec3 c) { return pow(c, vec3(1.0/2.2)); }
// ---- ambient (hemisphere, optionally overridden by baked zones) ----
vec2 tileUV(vec2 xz, float slice, vec3 dims, float tilesX) {
float row = floor(slice/tilesX);
float col = slice - row*tilesX;
vec2 atlas = vec2(dims.x*tilesX, dims.z*ceil(dims.y/tilesX));
return (vec2(col*dims.x, row*dims.z) + xz)/atlas;
}
void ambientColors(vec3 wp, out vec3 skyC, out vec3 groundC) {
skyC = ambSkyGlobal;
groundC = ambGroundGlobal;
if (ambVolDims.x < 0.5) return;
vec3 dims = ambVolDims.xyz;
vec3 g = clamp((wp - ambVolOrigin.xyz)/ambVolOrigin.w - 0.5, vec3(0.0), dims - 1.0);
float y0 = floor(g.y);
float ty = g.y - y0;
float y1 = min(y0 + 1.0, dims.y - 1.0);
vec2 xz = g.xz + 0.5;
float tilesX = ambVolDims.w;
vec2 uv0 = tileUV(xz, y0, dims, tilesX);
vec2 uv1 = tileUV(xz, y1, dims, tilesX);
vec3 s = mix(textureLod(ambSkyTex, uv0, 0.0).rgb, textureLod(ambSkyTex, uv1, 0.0).rgb, ty);
vec3 gr = mix(textureLod(ambGroundTex, uv0, 0.0).rgb, textureLod(ambGroundTex, uv1, 0.0).rgb, ty);
skyC = pow(s, vec3(2.2))*ambColorScale;
groundC = pow(gr, vec3(2.2))*ambColorScale;
}
// ---- shadows ----
mat4 shadowMatrixOf(int slot) {
if (slot == 0) return shadowMatrix0;
if (slot == 1) return shadowMatrix1;
return shadowMatrix2;
}
vec4 shadowInfoOf(int slot) {
if (slot == 0) return shadowInfo0;
if (slot == 1) return shadowInfo1;
return shadowInfo2;
}
float shadowDepthAt(int slot, vec2 uv) {
vec3 enc;
if (slot == 0) enc = textureLod(shadowMap0, uv, 0.0).rgb;
else if (slot == 1) enc = textureLod(shadowMap1, uv, 0.0).rgb;
else enc = textureLod(shadowMap2, uv, 0.0).rgb;
return dot(enc, vec3(1.0, 1.0/255.0, 1.0/65025.0));
}
// 1 = fully lit, 0 = fully shadowed
float shadowFactor(int slot, vec3 wp, vec3 N, float NdL, vec3 lightPos, float biasMul) {
mat4 M = shadowMatrixOf(slot);
vec4 info = shadowInfoOf(slot);
bool radial = info.y > 0.5;
float texel = radial ? info.x*length(wp - lightPos) : info.x;
vec3 pos = wp + N*(texel*(1.5 + 2.0*(1.0 - NdL))*biasMul); // normal offset: no acne, no light leaks
vec4 sc = M*vec4(pos, 1.0);
if (sc.w <= 0.0) return 1.0;
vec3 p = sc.xyz/sc.w*0.5 + 0.5;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z < 0.0 || p.z > 1.0) return 1.0;
float receiver = radial ? length(pos - lightPos)/info.z : p.z;
float bias = (texel + 0.004)*biasMul/info.z;
float noise = fract(52.9829189*fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
float rot = noise*6.2831853;
float radius = shadowParams.y*info.w;
float lit = 0.0;
for (int i = 0; i < 12; ++i) { // a rotated Vogel disk: smooth penumbrae with few taps
float r = sqrt((float(i) + 0.5)/12.0);
float a = float(i)*2.39996323 + rot;
vec2 o = vec2(cos(a), sin(a))*(r*radius);
float stored = shadowDepthAt(slot, p.xy + o);
lit += (receiver - bias <= stored) ? 1.0 : 0.0;
}
return lit/12.0;
}
// ---- material textures ----
// Texture coordinates: the mesh's own, or three projections from the world (triplanar) blended by how much the surface
// faces each axis. A projection looks along its axis at the surface and has u to the right and v DOWN the image (the top
// edge of an image is v = 0), whichever side the surface faces, so a texture is never mirrored.
vec2 gUvMesh;
vec2 gUvX; // the surfaces that face +-x: u along -z (+z when facing -x), v = -y
vec2 gUvY; // +-y: u = x, v = z (-z when facing down)
vec2 gUvZ; // +-z: u = x (-x when facing -z), v = -y
vec3 gTri; // blend weights of the three projections
vec3 gSign; // which way the surface faces on each axis, +1 or -1
void materialUv(vec3 wp, vec3 N) {
gUvMesh = fragUV*matUv.xy + matUv.zw;
gSign = vec3(N.x >= 0.0 ? 1.0 : -1.0, N.y >= 0.0 ? 1.0 : -1.0, N.z >= 0.0 ? 1.0 : -1.0);
vec3 w = pow(abs(N), vec3(4.0));
gTri = w/(w.x + w.y + w.z);
gUvX = vec2(-wp.z*gSign.x, -wp.y)*matUv.xy + matUv.zw;
gUvY = vec2(wp.x, wp.z*gSign.y)*matUv.xy + matUv.zw;
gUvZ = vec2(wp.x*gSign.z, -wp.y)*matUv.xy + matUv.zw;
}
vec4 materialSample(sampler2D t) {
if (matExtra.z < 0.5) return texture(t, gUvMesh);
return texture(t, gUvX)*gTri.x + texture(t, gUvY)*gTri.y + texture(t, gUvZ)*gTri.z;
}
// Tilts the normal by the normal map. A map normal (x, y, z) leans the surface by the slope (x, y)/z towards the image's
// right (x) and its top (y); the strength scales the slope. A flat map ((0, 0, 1)) leaves the normal alone, and on a
// surface that faces an axis the result is exactly the tangent-space normal of the map.
vec3 materialNormal(vec3 N, vec3 wp) {
if (matExtra.z < 0.5) { // the mesh's UVs: the frame comes from the screen-space derivatives
vec3 dp1 = dFdx(wp);
vec3 dp2 = dFdy(wp);
vec2 duv1 = dFdx(gUvMesh);
vec2 duv2 = dFdy(gUvMesh);
vec3 dp2perp = cross(dp2, N);
vec3 dp1perp = cross(N, dp1);
vec3 T = dp2perp*duv1.x + dp1perp*duv2.x; // towards increasing u
vec3 Bd = dp2perp*duv1.y + dp1perp*duv2.y; // towards increasing v: DOWN the image
float m = max(dot(T, T), dot(Bd, Bd));
if (m < 1e-20) return N;
vec3 tn = texture(matNormalMap, gUvMesh).xyz*2.0 - 1.0;
vec2 slope = tn.xy/max(tn.z, 0.05)*matExtra.x;
return normalize(N + (slope.x*T - slope.y*Bd)*inversesqrt(m));
}
vec3 tx = texture(matNormalMap, gUvX).xyz*2.0 - 1.0;
vec3 ty = texture(matNormalMap, gUvY).xyz*2.0 - 1.0;
vec3 tz = texture(matNormalMap, gUvZ).xyz*2.0 - 1.0;
vec2 sx = tx.xy/max(tx.z, 0.05)*matExtra.x;
vec2 sy = ty.xy/max(ty.z, 0.05)*matExtra.x;
vec2 sz = tz.xy/max(tz.z, 0.05)*matExtra.x;
vec3 dX = sx.x*vec3(0.0, 0.0, -gSign.x) + sx.y*vec3(0.0, 1.0, 0.0);
vec3 dY = sy.x*vec3(1.0, 0.0, 0.0) + sy.y*vec3(0.0, 0.0, -gSign.y);
vec3 dZ = sz.x*vec3(gSign.z, 0.0, 0.0) + sz.y*vec3(0.0, 1.0, 0.0);
return normalize(N + dX*gTri.x + dY*gTri.y + dZ*gTri.z);
}
void main() {
int flags = int(fragMaterial.w*255.0 + 0.5);
vec3 Ng = normalize(fragNormal); // the geometry's normal (shadows bias along it)
vec3 N = Ng; // the shading normal (a normal map tilts it)
vec3 baseG = fragColor.rgb; // gamma-encoded colour: vertex colours * tint * material
float opacity = fragColor.a;
float roughnessIn = fragMaterial.x;
float metallic = fragMaterial.y;
float emission = fragMaterial.z*4.0;
float diffuseK = 1.0;
float specLevel = 0.5;
vec3 specTint = vec3(1.0);
if (matMode == 1) {
materialUv(fragWorldPos, Ng);
baseG *= matColor.rgb;
if ((matMaps & 1) != 0) {
vec4 t = materialSample(matAlbedoMap);
baseG *= t.rgb;
opacity *= t.a;
}
diffuseK = matSurface.x;
specLevel = matSurface.y;
metallic = matSurface.z;
roughnessIn = matSurface.w;
emission = matExtra.y;
specTint = toLinear(matSpecColor.rgb);
if ((matMaps & 2) != 0) diffuseK *= materialSample(matDiffuseMap).r;
if ((matMaps & 4) != 0) specLevel *= materialSample(matSpecularMap).r;
if ((matMaps & 8) != 0) metallic *= materialSample(matMetalnessMap).r;
if ((matMaps & 16) != 0) roughnessIn *= materialSample(matRoughnessMap).r;
if ((matMaps & 32) != 0) N = materialNormal(Ng, fragWorldPos);
}
if ((flags & 2) != 0) { // unlit: the plain colour
finalColor = vec4(baseG, opacity);
return;
}
bool receive = (flags & 1) != 0;
vec3 toCam = camPos - fragWorldPos;
float camDist = length(toCam);
vec3 V = toCam/max(camDist, 0.0001);
vec3 albedo = toLinear(baseG);
float roughness = clamp(roughnessIn, 0.05, 1.0);
vec3 F0 = mix(vec3(0.08)*specLevel*specTint, albedo, metallic); // 0.08 * 0.5 = the 4% of plastic and glass
vec3 diffuseColor = albedo*(1.0 - metallic)*diffuseK;
float NdV = max(dot(N, V), 0.0001);
float a2 = roughness*roughness*roughness*roughness;
float k = (roughness + 1.0)*(roughness + 1.0)/8.0;
vec3 Lo = vec3(0.0);
for (int i = 0; i < lightCount; ++i) {
vec4 pt = lightPosType[i];
vec4 cr = lightColorRange[i];
vec4 sp = lightSpot[i];
vec4 lp = lightParams[i];
int type = int(pt.w + 0.5);
vec3 L;
float atten = 1.0;
if (type == 0) {
L = pt.xyz;
} else {
vec3 d = pt.xyz - fragWorldPos;
float dist = length(d);
L = d/max(dist, 0.0001);
float x = dist/cr.w;
float window = clamp(1.0 - x*x*x*x, 0.0, 1.0); // reaches exactly zero at the range
atten = window*window/max(dist*dist, 1.0); // inverse square, intensity = brightness at 1 unit
if (type == 2) {
float cd = dot(-L, sp.xyz);
float spot = clamp((cd - sp.w)*lp.x, 0.0, 1.0);
atten *= spot*spot*(3.0 - 2.0*spot);
}
}
float NdL = max(dot(N, L), 0.0);
if (atten <= 0.0 || NdL <= 0.0) continue;
float lit = 1.0;
int slot = int(floor(lp.y + 0.5));
if (slot >= 0 && receive) {
lit = shadowFactor(slot, fragWorldPos, Ng, max(dot(Ng, L), 0.0), pt.xyz, lp.z);
if (type == 0) lit = mix(1.0, lit, 1.0 - smoothstep(0.85, 1.0, camDist/shadowParams.z));
lit = mix(1.0, lit, shadowParams.x);
}
vec3 H = normalize(V + L);
float NdH = max(dot(N, H), 0.0);
float VdH = max(dot(V, H), 0.0);
float dd = NdH*NdH*(a2 - 1.0) + 1.0;
float D = a2/(PI*dd*dd); // GGX
float G = (NdL/(NdL*(1.0 - k) + k))*(NdV/(NdV*(1.0 - k) + k)); // Smith / Schlick-GGX
vec3 F = F0 + (1.0 - F0)*pow(1.0 - VdH, 5.0); // Schlick Fresnel
vec3 specular = D*G*F/max(4.0*NdV*NdL, 0.0001);
vec3 radiance = cr.rgb*(atten*lit*PI); // intensity 1 shows a surface facing it in its plain colour
Lo += ((1.0 - F)*diffuseColor/PI + specular)*radiance*NdL;
}
vec3 skyA;
vec3 groundA;
ambientColors(fragWorldPos, skyA, groundA);
vec3 color = Lo + mix(groundA, skyA, N.y*0.5 + 0.5)*diffuseColor;
vec3 R = reflect(-V, N);
vec3 Fr = F0 + (max(vec3(1.0 - roughness), F0) - F0)*pow(1.0 - NdV, 5.0);
color += mix(groundA, skyA, R.y*0.5 + 0.5)*Fr*(1.0 - 0.7*roughness);
color += albedo*emission;
if (envParams.z > 0.0) { // exponential-squared fog
float f = 1.0 - exp(-pow(envParams.z*camDist, 2.0));
color = mix(color, fogColor, f);
}
color *= envParams.x;
int mode = int(envParams.y + 0.5);
if (mode == 1) color = color/(color + 1.0);
else if (mode == 2) color = clamp((color*(2.51*color + 0.03))/(color*(2.43*color + 0.59) + 0.14), 0.0, 1.0);
else color = clamp(color, 0.0, 1.0);
finalColor = vec4(toSrgb(color), opacity);
}
)GLSL";
inline const char* const kShadowVertexShader = R"GLSL(#version 330
layout(location = 0) in vec3 vertexPosition;
layout(location = 9) in mat4 instanceTransform;
uniform mat4 viewProj;
out vec3 fragWorld;
void main() {
vec4 world = instanceTransform*vec4(vertexPosition, 1.0);
fragWorld = world.xyz;
gl_Position = viewProj*world;
}
)GLSL";
inline const char* const kShadowFragmentShader = R"GLSL(#version 330
in vec3 fragWorld;
uniform vec4 shadowCast; // x: 0 = depth along the light, 1 = distance from the light; y: range
uniform vec3 lightPos;
out vec4 finalColor;
vec3 packDepth(float d) { // 24 bits in RGB (alpha stays 1, so blending cannot touch it)
d = clamp(d, 0.0, 0.99999);
vec3 enc = fract(vec3(1.0, 255.0, 65025.0)*d);
enc -= enc.yzz*vec3(1.0/255.0, 1.0/255.0, 0.0);
return enc;
}
void main() {
float d = shadowCast.x < 0.5 ? gl_FragCoord.z : length(fragWorld - lightPos)/shadowCast.y;
finalColor = vec4(packDepth(d), 1.0);
}
)GLSL";
} // namespace eng::detail
+126
View File
@@ -0,0 +1,126 @@
#include "engine/texture.hpp"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <mutex>
#include <stdexcept>
namespace eng {
namespace {
constexpr int kMaxSide = 16384;
std::mutex& loaderMutex() { static std::mutex m; return m; }
ImageLoader& loaderSlot() { static ImageLoader l; return l; }
int wrapIndex(int i, int n, TextureWrap w) {
if (w == TextureWrap::Clamp) return std::clamp(i, 0, n - 1);
i %= n;
return i < 0 ? i + n : i;
}
} // namespace
Texture::Texture(int width, int height, std::vector<std::uint8_t> rgba, TextureOptions options)
: width_(width), height_(height), pixels_(std::move(rgba)), options_(options) {
if (width < 1 || height < 1 || width > kMaxSide || height > kMaxSide)
throw std::invalid_argument("Texture: the size must be 1.." + std::to_string(kMaxSide) + " pixels on each side");
if (pixels_.size() != static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4)
throw std::invalid_argument("Texture: expected width * height * 4 bytes of RGBA8 pixels");
static std::atomic<std::uint64_t> counter{1};
id_ = counter++;
}
TexturePtr Texture::create(int width, int height, std::vector<std::uint8_t> rgba, TextureOptions options) {
return std::make_shared<const Texture>(width, height, std::move(rgba), options);
}
TexturePtr Texture::solid(Rgba color, TextureOptions options) {
return create(1, 1, {color.r, color.g, color.b, color.a}, options);
}
TexturePtr Texture::checker(int size, int cells, Rgba a, Rgba b, TextureOptions options) {
size = std::clamp(size, 1, 4096);
cells = std::clamp(cells, 1, size);
std::vector<std::uint8_t> px(static_cast<std::size_t>(size) * static_cast<std::size_t>(size) * 4);
for (int y = 0; y < size; ++y)
for (int x = 0; x < size; ++x) {
const bool odd = ((x * cells / size) + (y * cells / size)) % 2 != 0;
const Rgba c = odd ? b : a;
std::uint8_t* p = &px[(static_cast<std::size_t>(y) * static_cast<std::size_t>(size) + static_cast<std::size_t>(x)) * 4];
p[0] = c.r; p[1] = c.g; p[2] = c.b; p[3] = c.a;
}
return create(size, size, std::move(px), options);
}
TexturePtr Texture::normalMapFromHeight(const Texture& height, float strength) {
const int w = height.width(), h = height.height();
if (!std::isfinite(strength)) strength = 3.0f;
auto high = [&](int x, int y) {
x = wrapIndex(x, w, TextureWrap::Repeat);
y = wrapIndex(y, h, TextureWrap::Repeat);
return static_cast<float>(height.pixels()[(static_cast<std::size_t>(y) * static_cast<std::size_t>(w) + static_cast<std::size_t>(x)) * 4]) / 255.0f;
};
std::vector<std::uint8_t> px(static_cast<std::size_t>(w) * static_cast<std::size_t>(h) * 4);
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x) {
// slope towards +u (to the right) and towards the top of the image (y - 1): the normal leans away from rising ground
const float dx = (high(x + 1, y) - high(x - 1, y)) * 0.5f * strength;
const float dy = (high(x, y - 1) - high(x, y + 1)) * 0.5f * strength;
const Vec3 n = Vec3{-dx, -dy, 1.0f}.normalized();
std::uint8_t* p = &px[(static_cast<std::size_t>(y) * static_cast<std::size_t>(w) + static_cast<std::size_t>(x)) * 4];
p[0] = static_cast<std::uint8_t>(std::lround((n.x * 0.5f + 0.5f) * 255.0f));
p[1] = static_cast<std::uint8_t>(std::lround((n.y * 0.5f + 0.5f) * 255.0f));
p[2] = static_cast<std::uint8_t>(std::lround((n.z * 0.5f + 0.5f) * 255.0f));
p[3] = 255;
}
return create(w, h, std::move(px), TextureOptions{height.options().wrap, height.options().filter});
}
Rgba Texture::texel(int x, int y) const {
x = wrapIndex(x, width_, options_.wrap);
y = wrapIndex(y, height_, options_.wrap);
const std::uint8_t* p = &pixels_[(static_cast<std::size_t>(y) * static_cast<std::size_t>(width_) + static_cast<std::size_t>(x)) * 4];
return Rgba{p[0], p[1], p[2], p[3]};
}
Texture::Sample Texture::sample(Vec2 uv) const {
// texel centres at (i + 0.5) / n, like the GPU
const float fx = uv.x * static_cast<float>(width_) - 0.5f, fy = uv.y * static_cast<float>(height_) - 0.5f;
const float x0 = std::floor(fx), y0 = std::floor(fy);
const float tx = fx - x0, ty = fy - y0;
const int ix = static_cast<int>(x0), iy = static_cast<int>(y0);
const Rgba c00 = texel(ix, iy), c10 = texel(ix + 1, iy), c01 = texel(ix, iy + 1), c11 = texel(ix + 1, iy + 1);
auto mix = [&](std::uint8_t a, std::uint8_t b, std::uint8_t c, std::uint8_t d) {
const float top = static_cast<float>(a) * (1 - tx) + static_cast<float>(b) * tx;
const float bottom = static_cast<float>(c) * (1 - tx) + static_cast<float>(d) * tx;
return (top * (1 - ty) + bottom * ty) / 255.0f;
};
return {mix(c00.r, c10.r, c01.r, c11.r), mix(c00.g, c10.g, c01.g, c11.g), mix(c00.b, c10.b, c01.b, c11.b), mix(c00.a, c10.a, c01.a, c11.a)};
}
void setImageLoader(ImageLoader loader) {
std::lock_guard<std::mutex> lock(loaderMutex());
loaderSlot() = std::move(loader);
}
bool hasImageLoader() {
std::lock_guard<std::mutex> lock(loaderMutex());
return static_cast<bool>(loaderSlot());
}
ImageData loadImageFile(const std::string& path) {
ImageLoader loader;
{
std::lock_guard<std::mutex> lock(loaderMutex());
loader = loaderSlot();
}
if (!loader) throw std::runtime_error("no image decoder is installed (eng::run() installs one; see setImageLoader), cannot read " + path);
ImageData img;
if (!loader(path, img)) throw std::runtime_error("cannot read the image " + path);
if (img.width < 1 || img.height < 1 || img.rgba.size() != static_cast<std::size_t>(img.width) * static_cast<std::size_t>(img.height) * 4)
throw std::runtime_error("the decoder returned an invalid image for " + path);
return img;
}
} // namespace eng
+39
View File
@@ -0,0 +1,39 @@
#include "engine/graphics.hpp"
namespace eng {
namespace {
// Does any ancestor of `e` hide the entities below it? Only enabled components count.
bool hiddenByAncestor(Entity e) {
for (Entity p = e.parent(); p.valid(); p = p.parent()) {
bool hides = false;
forEachRenderable(p, [&](Renderable& r) { hides = hides || (r.componentEnabled() && !r.childrenVisible); });
if (hides) return true;
}
return false;
}
} // namespace
bool Renderable::visibleInHierarchy() const { return visible && !hiddenByAncestor(entity()); }
void Entity::setVisible(bool visible, bool includeChildren) const {
bool any = false;
forEachRenderable(*this, [&](Renderable& r) {
any = true;
r.setVisible(visible, includeChildren);
});
if (!any && !visible) add<Visibility>().setVisible(false, includeChildren); // a plain group carries the switch itself
}
bool Entity::isVisible() const {
if (!valid() || !entityActive() || hiddenByAncestor(*this)) return false;
bool any = false, shown = false;
forEachRenderable(*this, [&](Renderable& r) {
any = true;
shown = shown || (r.visible && r.componentEnabled());
});
return any ? shown : true; // an entity without renderers is "visible" unless an ancestor hides it
}
} // namespace eng
+2394 -1
View File
File diff suppressed because it is too large. Load diff
+539
View File
@@ -0,0 +1,539 @@
// gfx_gen, part 3: the wrapper plan and the code for eng::gfx.
//
// For every raylib function we can express in the engine's terms we emit
// RET name(PARAMS) in engine/gfx_api.hpp (declaration, with raylib's own doc comment)
// RET name(PARAMS) {...} in gfx_core.cpp (a thin cast-and-forward body)
// Names: first letter lower-cased (DrawCircle -> drawCircle; rlgl's rl* names are kept).
// Types: Vector2/Vector3/Quaternion/Color/BoundingBox ARE the engine's Vec2/Vec3/Quat/Rgba/Aabb (same layout,
// asserted at compile time); a by-value Matrix is the engine's Mat4 (converted element by element); every other
// raylib struct is mirrored field for field in eng::gfx and passed through unchanged (also asserted).
#pragma once
#include "model.hpp"
namespace gen {
constexpr int kGroupRaylib = 0, kGroupRlgl = 1, kGroupRaymath = 2;
inline const char* groupName(int g) { return g == kGroupRaylib ? "raylib.h" : g == kGroupRlgl ? "rlgl.h" : "raymath.h"; }
inline const char* groupFile(int g) { return g == kGroupRaylib ? "gfx_core.cpp" : g == kGroupRlgl ? "gfx_rlgl.cpp" : "gfx_raymath.cpp"; }
inline const char* groupHeader(int g) { return g == kGroupRaylib ? "gfx_api.hpp" : g == kGroupRlgl ? "gfx_rlgl_api.hpp" : "gfx_raymath_api.hpp"; }
struct Wrapped {
const FuncDef* f = nullptr;
std::string name; // eng::gfx name
std::vector<Res> p; // resolved parameter types
std::vector<std::string> pn; // parameter names
Res ret;
bool special = false; // printf-style: hand-written (traceLog, textFormat)
bool strOverload = false; // has a const char* parameter -> also a std::string overload
bool engineOwned = false;
};
struct Skipped {
const FuncDef* f = nullptr;
std::string why;
};
inline std::string lowerFirst(std::string s) {
if (!s.empty()) s[0] = static_cast<char>(std::tolower(static_cast<unsigned char>(s[0])));
return s;
}
inline const std::set<std::string>& cppKeywords() {
static const std::set<std::string> s = {
"alignas", "alignof", "and", "asm", "auto", "bitand", "bitor", "bool", "break", "case", "catch", "char", "class", "compl", "concept",
"const", "consteval", "constexpr", "constinit", "continue", "decltype", "default", "delete", "do", "double", "else", "enum",
"explicit", "export", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace",
"new", "noexcept", "not", "nullptr", "operator", "or", "private", "protected", "public", "register", "requires", "return", "short",
"signed", "sizeof", "static", "struct", "switch", "template", "this", "throw", "true", "try", "typedef", "typeid", "typename",
"union", "unsigned", "using", "virtual", "void", "volatile", "while", "xor",
// names that platform headers turn into macros
"min", "max", "near", "far", "small", "interface", "IN", "OUT", "ERROR", "DELETE", "NO_ERROR"};
return s;
}
class Plan {
public:
Plan(ModelData& m, Types& ty, Preprocessor& pp, std::set<int> groups) : m_(m), ty_(ty), pp_(pp), groups_(std::move(groups)) { build(); }
ModelData& m_;
Types& ty_;
Preprocessor& pp_;
std::set<int> groups_;
std::vector<Wrapped> wrapped;
std::vector<Skipped> skipped;
std::set<std::string> usedNames; // everything declared in eng::gfx
std::set<std::string> emittedStructs, emittedEnums, emittedCallbacks; // what actually gets declared
bool enabled(int g) const { return groups_.count(g) != 0; }
// ---- type strings --------------------------------------------------------------------------------------
static std::string extName(const std::string& n) { return n == "va_list" ? "std::va_list" : "std::" + n; }
bool isMat4(const Res& r, bool inStruct) const { return !inStruct && ty_.matrixMapped && r.cat == Cat::Struct && r.t.base == "Matrix" && r.t.ptr == 0; }
std::string gfxBase(const Res& r, bool inStruct) const {
switch (r.cat) {
case Cat::Void: return "void";
case Cat::Builtin: return r.t.base;
case Cat::ExtBuiltin: return extName(r.t.base);
case Cat::Mapped: return ty_.mapped.at(r.t.base);
case Cat::Struct:
if (isMat4(r, inStruct)) return "Mat4";
return r.t.base;
case Cat::Opaque: case Cat::Callback: return r.t.base;
case Cat::Enum: return (r.t.ptr == 0 && !inStruct) ? "int" : r.t.base;
case Cat::Unknown: break;
}
return "/*?*/";
}
std::string gfxType(const Res& r, bool inStruct = false) const {
std::string s;
if (r.t.ptr > 0 && r.t.constBase) s = "const ";
s += gfxBase(r, inStruct);
s += std::string(static_cast<size_t>(r.t.ptr), '*');
return s;
}
std::string rlBase(const Res& r) const {
switch (r.cat) {
case Cat::Void: return "void";
case Cat::Builtin: return r.t.base;
case Cat::ExtBuiltin: return extName(r.t.base);
case Cat::Mapped: case Cat::Struct: case Cat::Opaque: case Cat::Callback: case Cat::Enum: return "::" + r.t.base;
case Cat::Unknown: break;
}
return "/*?*/";
}
std::string rlType(const Res& r) const {
std::string s;
if (r.t.ptr > 0 && r.t.constBase) s = "const ";
s += rlBase(r);
s += std::string(static_cast<size_t>(r.t.ptr), '*');
return s;
}
// gfx value -> what the raylib function is called with
std::string toRl(const Res& r, const std::string& e) const {
if (r.t.ptr > 0) {
if (gfxType(r) == rlType(r)) return e;
return "reinterpret_cast<" + rlType(r) + ">(" + e + ")";
}
switch (r.cat) {
case Cat::Builtin: case Cat::ExtBuiltin: return e;
case Cat::Mapped: return "std::bit_cast<" + rlType(r) + ">(" + e + ")";
case Cat::Struct:
if (isMat4(r, false)) return "std::bit_cast<::Matrix>(toMatrix(" + e + "))";
return "std::bit_cast<" + rlType(r) + ">(" + e + ")";
case Cat::Enum: return "static_cast<" + rlType(r) + ">(" + e + ")";
case Cat::Callback: return "reinterpret_cast<" + rlType(r) + ">(" + e + ")";
default: break;
}
return e;
}
// what the raylib function returned -> gfx value
std::string fromRl(const Res& r, const std::string& e) const {
if (r.t.ptr > 0) {
if (gfxType(r) == rlType(r)) return e;
return "reinterpret_cast<" + gfxType(r) + ">(" + e + ")";
}
switch (r.cat) {
case Cat::Builtin: case Cat::ExtBuiltin: return e;
case Cat::Mapped: return "std::bit_cast<" + gfxType(r) + ">(" + e + ")";
case Cat::Struct:
if (isMat4(r, false)) return "toMat4(std::bit_cast<Matrix>(" + e + "))";
return "std::bit_cast<" + gfxType(r) + ">(" + e + ")";
case Cat::Enum: return "static_cast<int>(" + e + ")";
case Cat::Callback: return "reinterpret_cast<" + gfxType(r) + ">(" + e + ")";
default: break;
}
return e;
}
static bool isCString(const Res& r) { return r.cat == Cat::Builtin && r.t.base == "char" && r.t.ptr == 1 && r.t.constBase; }
private:
void build() {
for (const StructDef& s : m_.structs) if (enabled(s.group) && !s.opaque && s.ok && !ty_.mapped.count(s.name)) emittedStructs.insert(s.name);
for (const StructDef& s : m_.structs) if (enabled(s.group) && s.opaque) emittedStructs.insert(s.name);
for (const EnumDef& e : m_.enums) if (enabled(e.group) && e.ok && !e.name.empty()) emittedEnums.insert(e.name);
for (const CallbackDef& c : m_.callbacks) if (enabled(c.group) && c.ok && ty_.callbackSupported(c)) emittedCallbacks.insert(c.name);
for (const StructDef& s : m_.structs) if (emittedStructs.count(s.name)) usedNames.insert(s.name);
for (const std::string& n : emittedEnums) usedNames.insert(n);
for (const std::string& n : emittedCallbacks) usedNames.insert(n);
for (const auto& kv : ty_.mapped) usedNames.insert(kv.first);
for (const std::string& a : m_.aliasOrder) if (enabled(m_.aliasGroup[a])) usedNames.insert(a);
for (const EnumDef& e : m_.enums) if (enabled(e.group) && e.ok) for (const EnumItem& i : e.items) usedNames.insert(i.name);
usedNames.insert("Mat4"); usedNames.insert("toMat4"); usedNames.insert("toMatrix"); usedNames.insert("colors");
for (const FuncDef& f : m_.funcs) if (enabled(f.group)) funcNames_.insert(lowerFirst(f.name));
static const std::set<std::string> owned = {"InitWindow", "CloseWindow", "WindowShouldClose", "BeginDrawing", "EndDrawing", "SwapScreenBuffer",
"PollInputEvents", "SetTargetFPS", "SetConfigFlags", "BeginMode3D", "EndMode3D", "ClearBackground"};
for (const FuncDef& f : m_.funcs) {
if (!enabled(f.group)) continue;
Wrapped w;
w.f = &f;
w.engineOwned = owned.count(f.name) != 0 && f.group == kGroupRaylib;
if (f.variadic) {
if (f.name == "TraceLog" || f.name == "TextFormat") {
w.special = true;
w.name = lowerFirst(f.name);
if (usedNames.count(w.name)) { skipped.push_back({&f, "name collision"}); continue; }
usedNames.insert(w.name);
wrapped.push_back(w);
} else {
skipped.push_back({&f, "variadic"});
}
continue;
}
if (f.retUnsupported) { skipped.push_back({&f, f.retWhy}); continue; }
std::string why;
w.ret = ty_.classify(f.ret);
if (!w.ret.ok()) why = "return type: " + w.ret.why;
if (why.empty() && w.ret.cat == Cat::Opaque) why = "returns an opaque type by value";
std::set<std::string> used;
for (size_t i = 0; why.empty() && i < f.params.size(); ++i) {
const Param& p = f.params[i];
if (p.unsupported) { why = "parameter " + std::to_string(i + 1) + ": " + p.why; break; }
Res r = ty_.classify(p.type);
if (!r.ok()) { why = "parameter " + std::to_string(i + 1) + ": " + r.why; break; }
if (r.cat == Cat::Void) { why = "void parameter"; break; }
if (r.cat == Cat::ExtBuiltin && r.t.base == "va_list") { why = "va_list parameter"; break; }
w.p.push_back(r);
w.pn.push_back(paramName(p.name, i, used));
if (isCString(r)) w.strOverload = true;
}
if (!why.empty()) { skipped.push_back({&f, why}); continue; }
w.name = lowerFirst(f.name);
if (cppKeywords().count(w.name)) w.name += "_";
if (usedNames.count(w.name)) { skipped.push_back({&f, "name collision with " + w.name}); continue; }
usedNames.insert(w.name);
wrapped.push_back(w);
}
}
std::set<std::string> funcNames_; // parameter names must not hide any wrapper (the std::string overloads call them)
std::string paramName(const std::string& raw, size_t idx, std::set<std::string>& used) const {
std::string n = raw.empty() ? "a" + std::to_string(idx) : raw;
if (cppKeywords().count(n) || pp_.macros.count(n) || usedNames.count(n) || funcNames_.count(n)) n += "_";
while (used.count(n)) n += "_";
used.insert(n);
return n;
}
};
// ---------------------------------------------------------------------------------------------------------
// Emission
// ---------------------------------------------------------------------------------------------------------
inline std::string cppFloatLiteral(double v, bool f32) {
char buf[64];
std::snprintf(buf, sizeof buf, f32 ? "%.9g" : "%.17g", v);
std::string s = buf;
if (s.find_first_of(".eEn") == std::string::npos) s += ".0"; // keep it a floating literal
if (f32) s += "f";
return s;
}
inline std::string colorConstName(const std::string& macro) {
std::string low;
for (char c : macro) low += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (const char* pre : {"light", "dark", "sky", "ray"}) {
const std::string p = pre;
if (low.size() > p.size() && low.compare(0, p.size(), p) == 0) {
std::string a = p, b = low.substr(p.size());
a[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(a[0])));
b[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(b[0])));
return a + b;
}
}
low[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(low[0])));
return low;
}
class ApiWriter {
public:
explicit ApiWriter(const Plan& p) : p_(p), m_(p.m_), ty_(p.ty_) {}
// ---- the public header of one group ---------------------------------------------------------------------
std::string header(int g, const std::string& version) const {
std::ostringstream o;
o << "// GENERATED by tools/gfx_gen from raylib " << version << " (" << groupName(g) << "). DO NOT EDIT.\n"
<< "//\n"
<< "// eng::gfx is the engine's equivalent of raylib's API: every function is here under its raylib name with the\n"
<< "// first letter lower-cased (DrawCircle -> gfx::drawCircle). Vector2/Vector3/Quaternion/Color/BoundingBox are the\n"
<< "// engine's own Vec2/Vec3/Quat/Rgba/Aabb, a by-value Matrix is Mat4, everything else keeps raylib's layout.\n"
<< "// raylib itself is never included by game code: do not include <raylib.h> in a file that includes this one.\n"
<< "#pragma once\n";
const char* guard = g == kGroupRaylib ? "RAYLIB_H" : g == kGroupRlgl ? "RLGL_H" : "RAYMATH_H";
o << "#if defined(" << guard << ")\n"
<< "#error \"engine/" << groupHeader(g) << " wraps raylib for you: include only the engine headers in this file (qualify names as gfx::...)\"\n"
<< "#endif\n\n";
o << "#include <engine/math.hpp>\n";
if (g != kGroupRaylib) o << "#include <engine/gfx_api.hpp>\n";
o << "\n#include <cstddef>\n#include <cstdarg>\n#include <cstdint>\n#include <string>\n\n";
if (g == kGroupRaylib)
o << "#if defined(__GNUC__) || defined(__clang__)\n#define ENG_GFX_PRINTF(fmtIndex, firstArg) __attribute__((format(printf, fmtIndex, firstArg)))\n"
<< "#else\n#define ENG_GFX_PRINTF(fmtIndex, firstArg)\n#endif\n\n";
o << "namespace eng::gfx {\n\n";
if (g == kGroupRaylib) {
o << "// raylib's own names for the types the engine already has\n";
for (const auto& kv : ty_.mapped) o << "using " << kv.first << " = " << kv.second << ";\n";
o << "\n";
}
opaqueTypes(o, g);
enums(o, g);
constants(o, g);
structs(o, g);
if (g == kGroupRaylib) matrixHelpers(o);
aliases(o, g);
callbacks(o, g);
if (g == kGroupRaylib) colors(o);
functions(o, g);
o << "} // namespace eng::gfx\n";
return o.str();
}
// ---- the cpp of one group ------------------------------------------------------------------------------
std::string source(int g, const std::string& version) const {
std::ostringstream o;
o << "// GENERATED by tools/gfx_gen from raylib " << version << " (" << groupName(g) << "). DO NOT EDIT.\n"
<< "// The engine side first: the raylib headers define macros (RED, WHITE, PI, ...) that must not leak into eng::gfx.\n"
<< "#include <engine/gfx_api.hpp>\n";
if (g != kGroupRaylib) o << "#include <engine/" << groupHeader(g) << ">\n";
o << "\n#include <bit>\n#include <cstdarg>\n#include <cstddef>\n#include <cstdio>\n#include <string>\n#include <type_traits>\n\n#include <raylib.h>\n";
if (g == kGroupRlgl) o << "#include <rlgl.h>\n";
if (g == kGroupRaymath) o << "#include <raymath.h>\n";
o << "\n#define GFX_LAYOUT(G, R) static_assert(sizeof(G) == sizeof(R) && alignof(G) == alignof(R) && std::is_trivially_copyable_v<G> && \\\n"
<< " std::is_trivially_copyable_v<R>, \"eng::gfx::\" #G \" does not have raylib's layout of \" #R)\n"
<< "#define GFX_FIELD(G, R, f) static_assert(offsetof(G, f) == offsetof(R, f) && sizeof(G::f) == sizeof(R::f), \\\n"
<< " \"eng::gfx::\" #G \"::\" #f \" is not where raylib's \" #R \" has it\")\n"
<< "// fields of plain C types (int, float*, unsigned char[4], ...) must be exactly the type raylib declares: this is what\n"
<< "// catches a header read with different #defines (e.g. rlVertexBuffer::indices is unsigned short* on GLES2).\n"
<< "#define GFX_FIELD_TYPE(G, R, f) static_assert(std::is_same_v<decltype(G::f), decltype(R::f)>, \\\n"
<< " \"eng::gfx::\" #G \"::\" #f \" does not have the type raylib's \" #R \" gives it\")\n\n";
o << "namespace eng::gfx {\nnamespace {\n\n";
layoutChecks(o, g);
o << "} // namespace\n\n";
for (const Wrapped& w : p_.wrapped) {
if (w.f->group != g) continue;
if (w.special) { special(o, w); continue; }
definition(o, w);
}
o << "} // namespace eng::gfx\n";
return o.str();
}
private:
const Plan& p_;
const ModelData& m_;
const Types& ty_;
bool mine(int group, int g) const { return group == g; }
void opaqueTypes(std::ostream& o, int g) const {
bool any = false;
for (const StructDef& s : m_.structs)
if (s.group == g && s.opaque && p_.emittedStructs.count(s.name)) { o << "struct " << s.name << ";\n"; any = true; }
if (any) o << "\n";
}
void enums(std::ostream& o, int g) const {
std::set<std::string> seen;
for (const EnumDef& e : m_.enums) {
if (e.group != g || !e.ok || e.items.empty()) continue;
o << "enum " << (e.name.empty() ? "" : e.name + " ") << ": int {\n";
for (const EnumItem& i : e.items) o << " " << i.name << " = " << i.value << ",\n";
o << "};\n\n";
}
}
void constants(std::ostream& o, int g) const {
bool any = false;
for (const ConstDef& c : m_.consts) {
if (c.group != g || p_.usedNames.count(c.name)) continue;
if (!c.str.empty()) o << "inline constexpr const char* " << c.name << " = " << c.str << ";\n";
else if (c.value.isFloat) o << "inline constexpr " << (c.isFloat32 ? "float " : "double ") << c.name << " = " << cppFloatLiteral(c.value.d, c.isFloat32) << ";\n";
else if (c.value.i > 2147483647LL || c.value.i < -2147483648LL) o << "inline constexpr long long " << c.name << " = " << c.value.i << "LL;\n";
else o << "inline constexpr int " << c.name << " = " << c.value.i << ";\n";
any = true;
}
if (any) o << "\n";
}
void structs(std::ostream& o, int g) const {
for (const StructDef& s : m_.structs) {
if (s.group != g || s.opaque || !s.ok || ty_.mapped.count(s.name)) continue;
o << "struct " << s.name << " {\n";
for (const Field& f : s.fields) {
const Res r = ty_.classify(f.type);
o << " " << p_.gfxType(r, true) << " " << f.name;
for (long long d : f.dims) o << "[" << d << "]";
o << ";\n";
}
o << "};\n\n";
}
}
void matrixHelpers(std::ostream& o) const {
if (!ty_.matrixMapped) return;
o << "// raylib's Matrix lists its fields row by row (m0, m4, m8, m12, m1, ...); the engine's Mat4 stores element i in m[i].\n"
<< "// Both mean the same matrix when element i is m<i>; these two functions convert by that rule.\n"
<< "inline Mat4 toMat4(const Matrix& r) {\n Mat4 m;\n";
for (size_t i = 0; i < ty_.matrixIndex.size(); ++i) {
const std::string f = m_.structs[m_.structIdx.at("Matrix")].fields[i].name;
o << " m.m[" << ty_.matrixIndex[i] << "] = r." << f << ";\n";
}
o << " return m;\n}\ninline Matrix toMatrix(const Mat4& m) {\n Matrix r{};\n";
for (size_t i = 0; i < ty_.matrixIndex.size(); ++i) {
const std::string f = m_.structs[m_.structIdx.at("Matrix")].fields[i].name;
o << " r." << f << " = m.m[" << ty_.matrixIndex[i] << "];\n";
}
o << " return r;\n}\n\n";
}
void aliases(std::ostream& o, int g) const {
bool any = false;
for (const std::string& a : m_.aliasOrder) {
if (m_.aliasGroup.at(a) != g || ty_.mapped.count(a)) continue;
const Res r = ty_.classify(Type{a, 0, false});
if (!r.ok() || r.cat == Cat::Void) continue;
if (r.cat == Cat::Callback) continue; // emitted with the callbacks
o << "using " << a << " = " << p_.gfxType(r, true) << ";\n";
any = true;
}
if (any) o << "\n";
}
void callbacks(std::ostream& o, int g) const {
bool any = false;
for (const CallbackDef& c : m_.callbacks) {
if (c.group != g || !p_.emittedCallbacks.count(c.name)) continue;
o << "using " << c.name << " = " << p_.gfxType(ty_.classify(c.ret), true) << " (*)(";
for (size_t i = 0; i < c.params.size(); ++i) o << (i ? ", " : "") << p_.gfxType(ty_.classify(c.params[i].type), true);
o << ");\n";
any = true;
}
if (any) o << "\n";
}
void colors(std::ostream& o) const {
if (m_.colors.empty() || !ty_.mapped.count("Color")) return;
o << "// raylib's colour constants (RED, RAYWHITE, ...), CamelCase because the originals are macros.\nnamespace colors {\n";
for (const ColorDef& c : m_.colors)
o << "inline constexpr Rgba " << colorConstName(c.name) << "{" << c.r << ", " << c.g << ", " << c.b << ", " << c.a << "};\n";
o << "} // namespace colors\n\n";
}
void functions(std::ostream& o, int g) const {
o << "// ---- " << groupName(g) << " functions " << std::string(60, '-') << "\n\n";
for (const Wrapped& w : p_.wrapped) {
if (w.f->group != g) continue;
std::string doc = w.f->doc;
if (w.engineOwned) doc += (doc.empty() ? "" : " ") + std::string("[the engine calls this itself in run(); using it from game code interferes with the frame loop]");
if (!doc.empty()) o << "/// " << doc << "\n";
if (w.special) {
if (w.f->name == "TraceLog") o << "void traceLog(int logLevel, const char* text, ...) ENG_GFX_PRINTF(2, 3); // printf-style, like raylib's TraceLog\n";
else o << "std::string textFormat(const char* text, ...) ENG_GFX_PRINTF(1, 2); // printf-style; returns a std::string instead of raylib's shared static buffer\n";
continue;
}
o << signature(w, false) << ";\n";
}
o << "\n";
// std::string overloads
bool any = false;
for (const Wrapped& w : p_.wrapped) {
if (w.f->group != g || !w.strOverload) continue;
if (!any) { o << "// ---- overloads taking std::string wherever raylib takes a const char* ----\n"; any = true; }
o << "inline " << signature(w, true) << " {\n " << (w.ret.cat == Cat::Void ? "" : "return ") << "::eng::gfx::" << w.name << "(";
for (size_t i = 0; i < w.p.size(); ++i) o << (i ? ", " : "") << w.pn[i] << (Plan::isCString(w.p[i]) ? ".c_str()" : "");
o << ");\n}\n";
}
if (any) o << "\n";
// #define A B function aliases
bool anyAlias = false;
for (const MacroAlias& a : m_.macroAliases) {
if (a.group != g) continue;
const Wrapped* target = nullptr;
for (const Wrapped& w : p_.wrapped) if (w.f->name == a.target && !w.special) target = &w;
if (!target) continue;
const std::string name = lowerFirst(a.name);
if (p_.usedNames.count(name)) continue;
if (!anyAlias) { o << "// ---- raylib's compatibility #defines ----\n"; anyAlias = true; }
o << "template <class... A> inline decltype(auto) " << name << "(A&&... a) { return " << target->name << "(static_cast<A&&>(a)...); } // " << a.name << " -> " << a.target << "\n";
}
if (anyAlias) o << "\n";
}
std::string signature(const Wrapped& w, bool stringForm) const {
std::string s = p_.gfxType(w.ret) + " " + w.name + "(";
for (size_t i = 0; i < w.p.size(); ++i) {
s += i ? ", " : "";
s += (stringForm && Plan::isCString(w.p[i])) ? "const std::string&" : p_.gfxType(w.p[i]);
s += " " + w.pn[i];
}
return s + ")";
}
void definition(std::ostream& o, const Wrapped& w) const {
o << signature(w, false) << " {\n ";
std::string call = "::" + w.f->name + "(";
for (size_t i = 0; i < w.p.size(); ++i) call += (i ? ", " : "") + p_.toRl(w.p[i], w.pn[i]);
call += ")";
if (w.ret.cat == Cat::Void) o << call << ";\n";
else o << "return " << p_.fromRl(w.ret, call) << ";\n";
o << "}\n\n";
}
void special(std::ostream& o, const Wrapped& w) const {
const char* fmtBody =
" std::va_list args;\n"
" va_start(args, text);\n"
" std::va_list copy;\n"
" va_copy(copy, args);\n"
" const int n = std::vsnprintf(nullptr, 0, text, copy);\n"
" va_end(copy);\n"
" std::string s;\n"
" if (n > 0) {\n"
" s.resize(static_cast<size_t>(n) + 1);\n"
" std::vsnprintf(s.data(), s.size(), text, args);\n"
" s.resize(static_cast<size_t>(n));\n"
" }\n"
" va_end(args);\n";
if (w.f->name == "TraceLog") {
o << "void traceLog(int logLevel, const char* text, ...) {\n" << fmtBody << " ::TraceLog(logLevel, \"%s\", s.c_str());\n}\n\n";
} else {
o << "std::string textFormat(const char* text, ...) {\n" << fmtBody << " return s;\n}\n\n";
}
}
void fieldChecks(std::ostream& o, const std::string& gfxName, const std::string& rlName, const Field& f) const {
o << "GFX_FIELD(" << gfxName << ", ::" << rlName << ", " << f.name << ");\n";
const Res r = ty_.classify(f.type);
if (r.cat == Cat::Builtin || r.cat == Cat::ExtBuiltin) o << "GFX_FIELD_TYPE(" << gfxName << ", ::" << rlName << ", " << f.name << ");\n";
}
void layoutChecks(std::ostream& o, int g) const {
if (g == kGroupRaylib)
for (const auto& kv : ty_.mapped) {
const StructDef* s = nullptr;
const std::string src = kv.first == "Quaternion" ? "Vector4" : kv.first;
const auto it = m_.structIdx.find(src);
if (it != m_.structIdx.end()) s = &m_.structs[it->second];
if (!s) continue;
o << "GFX_LAYOUT(" << kv.second << ", ::" << kv.first << ");\n";
for (const Field& f : s->fields) fieldChecks(o, kv.second, kv.first, f);
}
for (const StructDef& s : m_.structs) {
if (s.group != g || s.opaque || !s.ok || ty_.mapped.count(s.name)) continue;
o << "GFX_LAYOUT(" << s.name << ", ::" << s.name << ");\n";
for (const Field& f : s.fields) fieldChecks(o, s.name, s.name, f);
}
for (const EnumDef& e : m_.enums)
if (e.group == g && e.ok && !e.name.empty() && !e.items.empty()) o << "static_assert(sizeof(" << e.name << ") == sizeof(::" << e.name << "), \"enum " << e.name << " must have raylib's size\");\n";
o << "\n";
}
};
} // namespace gen
+518
View File
@@ -0,0 +1,518 @@
// gfx_gen, part 4: the self-test of the generated wrappers.
//
// The wrappers are mechanical, so the test is mechanical too. gfx_mock.cpp defines every raylib / rlgl function the
// wrappers call (with raylib's own signatures, taken from the real headers, so a mismatch is a compile error): the
// mock records a fingerprint of each argument exactly as raylib would have received it, and returns a sentinel.
// gfx_selftest.cpp calls every wrapper with distinctive values (every parameter, every struct field, every matrix
// element gets its own number) and checks that raylib saw exactly those values, in order, and that the sentinel
// comes back intact. raymath functions are real inline code, so there the wrapper's result is compared with a
// direct call of the raymath function ("oracle").
// A swapped parameter, a wrong cast or a transposed matrix cannot pass.
#pragma once
#include "emit_api.hpp"
namespace gen {
class TestWriter {
public:
explicit TestWriter(const Plan& p) : p_(p), m_(p.m_), ty_(p.ty_) {}
static std::string common() {
return R"(// GENERATED by tools/gfx_gen. DO NOT EDIT.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <deque>
#include <string>
#include <type_traits>
#include <vector>
namespace gfxtest {
struct Hash {
std::uint64_t v = 1469598103934665603ull;
void add(std::uint64_t x) {
for (int i = 0; i < 8; ++i) { v ^= (x >> (8 * i)) & 0xffu; v *= 1099511628211ull; }
}
};
inline void fp(Hash& h, bool x) { h.add(x ? 1u : 0u); }
template <class T, std::enable_if_t<std::is_integral_v<T> && !std::is_same_v<T, bool>, int> = 0>
inline void fp(Hash& h, T x) { h.add(static_cast<std::uint64_t>(static_cast<std::int64_t>(x))); }
template <class T, std::enable_if_t<std::is_enum_v<T>, int> = 0>
inline void fp(Hash& h, T x) { h.add(static_cast<std::uint64_t>(static_cast<std::int64_t>(x))); }
inline void fp(Hash& h, float x) { std::uint32_t b; std::memcpy(&b, &x, 4); h.add(b); }
inline void fp(Hash& h, double x) { std::uint64_t b; std::memcpy(&b, &x, 8); h.add(b); }
template <class T> inline void fpPtr(Hash& h, const T* p) { h.add(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(p))); }
inline void fpStr(Hash& h, const char* s) {
if (!s) { h.add(0); return; }
h.add(1);
for (; *s; ++s) h.add(static_cast<unsigned char>(*s));
}
template <class T> inline std::uint64_t hv(const T& x) { Hash h; fp(h, x); return h.v; }
template <class T> inline std::uint64_t hvPtr(const T* p) { Hash h; fpPtr(h, p); return h.v; }
inline std::uint64_t hvStr(const char* s) { Hash h; fpStr(h, s); return h.v; }
template <class F> inline std::uint64_t hvFn(F f) { Hash h; h.add(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(f))); return h.v; }
// The compiler must not know the sentinel values. raymath's sinf/cosf/tan on a value it knows would be folded at
// compile time with the compiler's own rounding, which can differ in the last bit from the C library at run time;
// then a correct wrapper would be reported as "differs from calling raymath directly".
template <class T> inline T opaque(T v) {
static_assert(std::is_trivially_copyable_v<T>, "opaque() copies bytes");
unsigned char raw[sizeof(T)];
std::memcpy(raw, &v, sizeof(T));
const volatile unsigned char* p = raw;
unsigned char out[sizeof(T)];
for (std::size_t i = 0; i < sizeof(T); ++i) out[i] = p[i];
std::memcpy(&v, out, sizeof(T));
return v;
}
// Distinctive values. The same salt gives the same value in the engine's types and in raylib's.
template <class T> inline T makeScalar(int salt) {
if constexpr (std::is_same_v<T, bool>) return opaque<bool>((salt % 3) != 0);
else if constexpr (std::is_floating_point_v<T>) return opaque<T>(static_cast<T>(static_cast<double>(salt) * 0.37 + 0.5));
else if constexpr (std::is_enum_v<T>) return opaque<T>(static_cast<T>(0));
else if constexpr (sizeof(T) == 1) return opaque<T>(static_cast<T>(salt % 120 + 1));
else return opaque<T>(static_cast<T>(salt % 30011 + 1));
}
template <class P> inline P makePtr(int salt) { return reinterpret_cast<P>(static_cast<std::uintptr_t>(0x100000 + static_cast<std::uintptr_t>(salt) * 64)); }
inline const char* makeStr(int salt) {
static std::deque<std::string> store;
store.push_back("str" + std::to_string(salt));
return store.back().c_str();
}
} // namespace gfxtest
)";
}
static std::string iface() {
return R"(// GENERATED by tools/gfx_gen. DO NOT EDIT.
#pragma once
#include "gfx_selftest_common.hpp"
#include <cstdio>
#include <initializer_list>
namespace gfxtest {
struct Call {
const char* name = "";
std::vector<std::uint64_t> args;
std::uint64_t ret = 0;
};
// implemented by gfx_mock.cpp
void reset();
const std::vector<Call>& calls();
int oracleCount();
const char* oracleName(int i);
// Runs raymath function i directly on the sentinel arguments; hashes = arguments, result, out-parameters.
bool oracleRun(int i, std::vector<std::uint64_t>& hashes);
struct Ctx {
int checks = 0, failures = 0;
void fail(const char* what, const std::string& why) {
++failures;
std::printf("FAIL %s: %s\n", what, why.c_str());
}
void expectCall(const char* name, std::initializer_list<std::uint64_t> args, bool hasRet, std::uint64_t ret) {
++checks;
const std::vector<Call>& c = calls();
if (c.size() != 1) { fail(name, "expected exactly one raylib call, saw " + std::to_string(c.size())); return; }
if (std::strcmp(c[0].name, name) != 0) { fail(name, std::string("called ") + c[0].name + " instead"); return; }
if (c[0].args.size() != args.size()) { fail(name, "wrong number of arguments"); return; }
size_t i = 0;
for (std::uint64_t a : args) {
if (c[0].args[i] != a) { fail(name, "argument " + std::to_string(i + 1) + " reached raylib changed or in the wrong place"); return; }
++i;
}
if (hasRet && c[0].ret != ret) fail(name, "the value raylib returned came back changed");
}
void expectEqual(const char* name, const std::vector<std::uint64_t>& got, const std::vector<std::uint64_t>& want) {
++checks;
if (got.size() != want.size()) { fail(name, "result count differs from the direct call"); return; }
for (size_t i = 0; i < got.size(); ++i)
if (got[i] != want[i]) { fail(name, "value " + std::to_string(i + 1) + " differs from calling raymath directly"); return; }
}
};
} // namespace gfxtest
)";
}
// ---- gfx_mock.cpp ------------------------------------------------------------------------------------------
std::string mock(const std::string& version) const {
std::ostringstream o;
o << "// GENERATED by tools/gfx_gen from raylib " << version << ". DO NOT EDIT.\n"
<< "// A recording stand-in for raylib: see the header comment of gfx_gen's emit_test.hpp.\n"
<< "#include \"gfx_selftest_iface.hpp\"\n\n#include <cstdarg>\n#include <cstdint>\n\n#include <raylib.h>\n";
if (p_.enabled(kGroupRlgl)) o << "#include <rlgl.h>\n";
if (p_.enabled(kGroupRaymath)) o << "#include <raymath.h>\n";
o << "\nnamespace gfxtest {\nnamespace {\nstd::vector<Call> g_calls;\n}\n\n";
o << "void reset() { g_calls.clear(); }\nconst std::vector<Call>& calls() { return g_calls; }\n";
o << "inline Call& begin(const char* name) {\n g_calls.emplace_back();\n g_calls.back().name = name;\n return g_calls.back();\n}\n\n";
structFns(o, true);
o << "} // namespace gfxtest\n\n";
// the raylib / rlgl functions, with raylib's signatures
for (const Wrapped& w : p_.wrapped) {
if (w.f->group == kGroupRaymath) continue;
if (w.special) {
if (w.f->name == "TraceLog")
o << "void TraceLog(int a0, const char* a1, ...) {\n gfxtest::Call& c = gfxtest::begin(\"TraceLog\");\n"
<< " va_list args;\n va_start(args, a1);\n const char* msg = va_arg(args, const char*); // the wrapper always passes \"%s\", message\n"
<< " va_end(args);\n c.args = {gfxtest::hv(a0), gfxtest::hvStr(a1), gfxtest::hvStr(msg)};\n}\n\n";
continue;
}
mockFunction(o, w);
}
// raymath oracles
o << "namespace gfxtest {\n";
std::vector<const Wrapped*> ray = raymathList();
o << "int oracleCount() { return " << ray.size() << "; }\n";
o << "const char* oracleName(int i) {\n static const char* names[] = {";
for (const Wrapped* w : ray) o << "\"" << w->f->name << "\", ";
o << "\"\"};\n return names[i];\n}\n";
o << "bool oracleRun(int i, std::vector<std::uint64_t>& hashes) {\n hashes.clear();\n switch (i) {\n";
for (size_t k = 0; k < ray.size(); ++k) {
o << " case " << k << ": {\n";
if (!oracleTestable(*ray[k])) { o << " return false;\n }\n"; continue; }
oracleBody(o, *ray[k], k, true);
o << " return true;\n }\n";
}
o << " default: break;\n }\n return false;\n}\n} // namespace gfxtest\n";
return o.str();
}
// ---- gfx_selftest.cpp --------------------------------------------------------------------------------------
std::string test(const std::string& version) const {
std::ostringstream o;
o << "// GENERATED by tools/gfx_gen from raylib " << version << ". DO NOT EDIT.\n"
<< "#include <engine/gfx_api.hpp>\n";
if (p_.enabled(kGroupRlgl)) o << "#include <engine/gfx_rlgl_api.hpp>\n";
if (p_.enabled(kGroupRaymath)) o << "#include <engine/gfx_raymath_api.hpp>\n";
o << "#include \"gfx_selftest_iface.hpp\"\n\nnamespace gfx = eng::gfx;\nusing namespace eng::gfx;\nusing eng::Mat4;\nusing eng::Vec2;\nusing eng::Vec3;\nusing eng::Quat;\nusing eng::Rgba;\nusing eng::Aabb;\n\n";
o << "namespace gfxtest {\n";
structFns(o, false);
// callback stand-ins
for (const std::string& n : p_.emittedCallbacks) {
const CallbackDef& c = m_.callbacks[m_.callbackIdx.at(n)];
o << "[[maybe_unused]] static " << p_.gfxType(ty_.classify(c.ret), true) << " cb_" << n << "(";
for (size_t i = 0; i < c.params.size(); ++i) o << (i ? ", " : "") << p_.gfxType(ty_.classify(c.params[i].type), true);
o << ") { ";
const Res r = ty_.classify(c.ret);
if (r.cat != Cat::Void) o << "return " << (r.t.ptr ? "nullptr" : "{}") << "; ";
o << "}\n";
}
o << "} // namespace gfxtest\n\n";
size_t idx = 0;
std::vector<std::string> fnNames;
for (const Wrapped& w : p_.wrapped) {
if (w.f->group == kGroupRaymath || w.special) continue;
const std::string fn = "t" + std::to_string(idx++);
fnNames.push_back(fn);
testFunction(o, w, fn);
}
std::vector<const Wrapped*> ray = raymathList();
for (size_t k = 0; k < ray.size(); ++k) {
const std::string fn = "r" + std::to_string(k);
fnNames.push_back(fn);
o << "static void " << fn << "(gfxtest::Ctx& c) { // " << ray[k]->f->name << " -> gfx::" << ray[k]->name << "\n";
if (!oracleTestable(*ray[k])) { o << " (void)c;\n}\n\n"; continue; }
o << " std::vector<std::uint64_t> got, want;\n";
o << " if (!gfxtest::oracleRun(" << k << ", want)) return;\n";
o << " if (std::string(gfxtest::oracleName(" << k << ")) != \"" << ray[k]->f->name << "\") { c.fail(\"" << ray[k]->f->name << "\", \"oracle table out of step\"); return; }\n";
oracleBody(o, *ray[k], k, false);
o << " c.expectEqual(\"" << ray[k]->f->name << "\", got, want);\n}\n\n";
}
o << "int main() {\n gfxtest::Ctx c;\n";
for (const std::string& f : fnNames) o << " " << f << "(c);\n";
o << R"( // printf-style functions
{
const std::string s = gfx::textFormat("%d-%s-%.2f", 7, "q", 1.5);
++c.checks;
if (s != "7-q-1.50") c.fail("textFormat", "got '" + s + "'");
std::string big(3000, 'x');
const std::string s2 = gfx::textFormat("<%s>", big.c_str());
++c.checks;
if (s2 != "<" + big + ">") c.fail("textFormat", "long strings must not be cut off");
}
)";
if (hasSpecial("TraceLog"))
o << R"( {
gfxtest::reset();
gfx::traceLog(3, "x=%d %s", 5, "abc");
c.expectCall("TraceLog", {gfxtest::hv(3), gfxtest::hvStr("%s"), gfxtest::hvStr("x=5 abc")}, false, 0);
}
)";
o << " std::printf(\"gfx_selftest: %d checks, %d failed\\n\", c.checks, c.failures);\n return c.failures == 0 ? 0 : 1;\n}\n";
return o.str();
}
size_t raymathCount() const { return raymathList().size(); }
size_t mockedCount() const {
size_t n = 0;
for (const Wrapped& w : p_.wrapped) if (w.f->group != kGroupRaymath && !w.special) ++n;
return n;
}
size_t oracleTestableCount() const {
size_t n = 0;
for (const Wrapped* w : raymathList()) if (oracleTestable(*w)) ++n;
return n;
}
private:
const Plan& p_;
const ModelData& m_;
const Types& ty_;
bool hasSpecial(const std::string& rlName) const {
for (const Wrapped& w : p_.wrapped) if (w.special && w.f->name == rlName) return true;
return false;
}
std::vector<const Wrapped*> raymathList() const {
std::vector<const Wrapped*> v;
for (const Wrapped& w : p_.wrapped) if (w.f->group == kGroupRaymath && !w.special) v.push_back(&w);
return v;
}
// ---- per-struct fingerprint / sentinel functions ----------------------------------------------------------
struct StructFn {
std::string tag; // name used in mk_<tag>
std::string type; // how this TU spells the type
const StructDef* def;
};
std::vector<StructFn> structList(bool rl) const {
std::vector<StructFn> v;
for (const StructDef& s : m_.structs) {
if (s.opaque || !s.ok || !p_.enabled(s.group)) continue;
if (rl) v.push_back({s.name, "::" + s.name, &s});
else if (ty_.mapped.count(s.name)) v.push_back({s.name, ty_.mapped.at(s.name), &s});
else v.push_back({s.name, s.name, &s});
// Quaternion is raylib's alias of Vector4 and the engine's Quat: right behind it, before anything uses it
if (!rl && s.name == "Vector4" && ty_.mapped.count("Quaternion")) v.push_back({"Quaternion", "Quat", &s});
}
return v;
}
static std::string tagOf(const Res& r, bool rl) {
if (rl && r.t.base == "Quaternion") return "Vector4";
return r.t.base;
}
const EnumDef* enumOf(const std::string& name) const {
for (const EnumDef& e : m_.enums) if (e.name == name && e.ok && !e.items.empty()) return &e;
return nullptr;
}
// the type of a field/param as this TU spells it
std::string typeStr(const Res& r, bool rl, bool inStruct) const { return rl ? p_.rlType(r) : p_.gfxType(r, inStruct); }
std::string enumValue(const Res& r, int pick) const {
const EnumDef* e = enumOf(r.t.base);
return e ? std::to_string(e->items[static_cast<size_t>(pick) % e->items.size()].value) : "0";
}
// expression producing a sentinel value of type r
std::string makeExpr(const Res& r, const std::string& salt, bool rl, bool inStruct, int pick) const {
const std::string ts = typeStr(r, rl, inStruct);
if (r.t.ptr > 0) {
if (Plan::isCString(r)) return "gfxtest::makeStr(" + salt + ")";
return "gfxtest::makePtr<" + ts + ">(" + salt + ")";
}
switch (r.cat) {
case Cat::Builtin: case Cat::ExtBuiltin: return "gfxtest::makeScalar<" + ts + ">(" + salt + ")";
case Cat::Enum: {
if (!rl && !inStruct) return "int(" + enumValue(r, pick) + ")";
return "static_cast<" + ts + ">(" + enumValue(r, pick) + ")";
}
case Cat::Mapped: return "gfxtest::mk_" + tagOf(r, rl) + "(" + salt + ")";
case Cat::Struct:
if (!rl && p_.isMat4(r, inStruct)) return "gfxtest::mk_Mat4(" + salt + ")";
return "gfxtest::mk_" + tagOf(r, rl) + "(" + salt + ")";
case Cat::Callback: return rl ? "static_cast<" + ts + ">(nullptr)" : "&gfxtest::cb_" + r.t.base;
default: break;
}
return "{}";
}
std::string hashExpr(const Res& r, const std::string& e) const {
if (r.t.ptr > 0) return Plan::isCString(r) ? "gfxtest::hvStr(" + e + ")" : "gfxtest::hvPtr(" + e + ")";
if (r.cat == Cat::Callback) return "gfxtest::hvFn(" + e + ")";
return "gfxtest::hv(" + e + ")";
}
void structFns(std::ostream& o, bool rl) const {
// fingerprints and sentinels of every struct (a struct's fields are always declared before it)
for (const StructFn& sf : structList(rl)) {
const StructDef& s = *sf.def;
const bool isMatrix = s.name == "Matrix" && ty_.matrixMapped;
o << "inline void fp(Hash& h, const " << sf.type << "& v) {\n";
if (s.fields.empty()) o << " (void)h; (void)v;\n";
for (const Field& f : s.fields) {
const Res rf = ty_.classify(f.type);
std::string expr = "v." + f.name;
std::string close;
for (size_t d = 0; d < f.dims.size(); ++d) {
o << std::string(4 + 4 * d, ' ') << "for (const auto& x" << d << " : " << expr << ") {\n";
expr = "x" + std::to_string(d);
close = std::string(4 + 4 * d, ' ') + "}\n" + close;
}
const std::string ind(4 + 4 * f.dims.size(), ' ');
if (rf.t.ptr > 0) o << ind << "fpPtr(h, " << expr << ");\n";
else o << ind << "fp(h, " << expr << ");\n";
o << close;
}
o << "}\n";
o << "inline " << sf.type << " mk_" << sf.tag << "(int salt) {\n " << sf.type << " r{};\n";
if (s.fields.empty()) o << " (void)salt;\n";
int j = 0;
for (size_t fi = 0; fi < s.fields.size(); ++fi) {
const Field& f = s.fields[fi];
const Res rf = ty_.classify(f.type);
const int jj = isMatrix ? ty_.matrixIndex[fi] : j;
std::string lhs = "r." + f.name;
std::string saltE = "(salt * 16 + " + std::to_string(jj) + ")";
std::string close;
for (size_t d = 0; d < f.dims.size(); ++d) {
o << std::string(4 + 4 * d, ' ') << "for (std::size_t i" << d << " = 0; i" << d << " < " << f.dims[d] << "; ++i" << d << ") {\n";
lhs += "[i" + std::to_string(d) + "]";
saltE = "(" + saltE + " + static_cast<int>(i" + std::to_string(d) + ") * 3)";
close = std::string(4 + 4 * d, ' ') + "}\n" + close;
}
const std::string ind(4 + 4 * f.dims.size(), ' ');
o << ind << lhs << " = " << makeExpr(rf, saltE + " % 100003", rl, true, jj) << ";\n" << close;
++j;
}
o << " return r;\n}\n";
}
if (!rl && ty_.matrixMapped) {
o << "inline void fp(Hash& h, const eng::Mat4& v) {\n";
for (int idx : ty_.matrixIndex) o << " fp(h, v.m[" << idx << "]);\n";
o << "}\ninline eng::Mat4 mk_Mat4(int salt) {\n eng::Mat4 r{};\n for (int i = 0; i < 16; ++i) r.m[i] = makeScalar<float>((salt * 16 + i) % 100003);\n return r;\n}\n";
}
o << "\n";
}
// ---- mock of one raylib/rlgl function --------------------------------------------------------------------
int saltOf(const Wrapped& w) const { return static_cast<int>(&w - p_.wrapped.data()) * 100 + 100; }
void mockFunction(std::ostream& o, const Wrapped& w) const {
const int base = saltOf(w);
o << p_.rlType(w.ret) << " " << w.f->name << "(";
for (size_t i = 0; i < w.p.size(); ++i) o << (i ? ", " : "") << p_.rlType(w.p[i]) << " a" << i;
const bool needsCall = !w.p.empty() || w.ret.cat != Cat::Void;
o << ") {\n " << (needsCall ? "gfxtest::Call& c = " : "") << "gfxtest::begin(\"" << w.f->name << "\");\n";
if (!w.p.empty()) {
o << " c.args = {";
for (size_t i = 0; i < w.p.size(); ++i) o << (i ? ", " : "") << hashExpr(w.p[i], "a" + std::to_string(i));
o << "};\n";
}
if (w.ret.cat != Cat::Void) {
const std::string salt = std::to_string(base + 99);
const std::string rt = p_.rlType(w.ret);
if (Plan::isCString(w.ret)) o << " const char* r = gfxtest::makeStr(" << salt << ");\n";
else o << " " << rt << " r = " << makeExpr(w.ret, salt, true, false, 1) << ";\n";
o << " c.ret = " << hashExpr(w.ret, "r") << ";\n return r;\n";
}
o << "}\n\n";
}
// ---- the test of one wrapper -----------------------------------------------------------------------------
void testFunction(std::ostream& o, const Wrapped& w, const std::string& fn) const {
const int base = saltOf(w);
o << "static void " << fn << "(gfxtest::Ctx& c) { // " << w.f->name << " -> gfx::" << w.name << "\n gfxtest::reset();\n";
for (size_t i = 0; i < w.p.size(); ++i) {
const std::string salt = std::to_string(base + static_cast<int>(i) * 10);
o << " " << p_.gfxType(w.p[i]) << " a" << i << " = " << makeExpr(w.p[i], salt, false, false, static_cast<int>(i)) << ";\n";
}
auto call = [&](bool strings) {
std::string s = "gfx::" + w.name + "(";
for (size_t i = 0; i < w.p.size(); ++i) s += (i ? ", " : "") + (strings && Plan::isCString(w.p[i]) ? "s" + std::to_string(i) : "a" + std::to_string(i));
return s + ")";
};
auto expect = [&](const std::string& resultVar) {
std::string e = " c.expectCall(\"" + w.f->name + "\", {";
for (size_t i = 0; i < w.p.size(); ++i) e += (i ? ", " : "") + hashExpr(w.p[i], "a" + std::to_string(i));
e += "}, ";
e += w.ret.cat == Cat::Void ? "false, 0" : "true, " + hashExpr(w.ret, resultVar);
return e + ");\n";
};
if (w.ret.cat == Cat::Void) o << " " << call(false) << ";\n";
else o << " const auto r = " << call(false) << ";\n";
o << expect("r");
if (w.strOverload) {
o << " gfxtest::reset();\n";
for (size_t i = 0; i < w.p.size(); ++i) if (Plan::isCString(w.p[i])) o << " const std::string s" << i << " = a" << i << ";\n";
if (w.ret.cat == Cat::Void) o << " " << call(true) << ";\n";
else o << " const auto r2 = " << call(true) << ";\n";
o << expect("r2");
}
o << "}\n\n";
}
// ---- raymath ---------------------------------------------------------------------------------------------
bool oracleTestable(const Wrapped& w) const {
for (const Res& r : w.p) {
if (r.t.ptr > 1) return false;
if (r.t.ptr == 1) {
if (r.cat == Cat::Opaque || (r.cat == Cat::Builtin && r.t.base == "void") || r.cat == Cat::Callback) return false;
if (Plan::isCString(r)) return false;
}
if (r.cat == Cat::Callback) return false;
}
if (w.ret.t.ptr > 0 || w.ret.cat == Cat::Callback) return false;
return true;
}
// Declares the arguments, calls (the raymath function directly when `rl`, else the wrapper) and appends
// [arguments, result, out-parameters] fingerprints to `hashes` (rl) / `got` (wrapper).
void oracleBody(std::ostream& o, const Wrapped& w, size_t k, bool rl) const {
const std::string ind = rl ? " " : " ";
const std::string vec = rl ? "hashes" : "got";
const int base = 900000 + static_cast<int>(k) * 100;
std::vector<std::string> argExpr;
std::vector<std::string> outs;
for (size_t i = 0; i < w.p.size(); ++i) {
const Res& r = w.p[i];
const std::string salt = std::to_string(base + static_cast<int>(i) * 10);
if (r.t.ptr == 1) {
Res pointee = r;
pointee.t.ptr = 0;
// storage behind a pointer keeps raylib's layout (a Matrix stays the mirror struct, never Mat4)
o << ind << typeStr(pointee, rl, true) << " p" << i << " = " << makeExpr(pointee, salt, rl, true, static_cast<int>(i)) << ";\n";
argExpr.push_back("&p" + std::to_string(i));
outs.push_back("p" + std::to_string(i));
} else {
o << ind << typeStr(r, rl, false) << " a" << i << " = " << makeExpr(r, salt, rl, false, static_cast<int>(i)) << ";\n";
argExpr.push_back("a" + std::to_string(i));
}
}
for (size_t i = 0; i < w.p.size(); ++i) {
const Res& r = w.p[i];
if (r.t.ptr == 1) o << ind << vec << ".push_back(gfxtest::hv(p" << i << "));\n";
else o << ind << vec << ".push_back(" << hashExpr(r, "a" + std::to_string(i)) << ");\n";
}
std::string call = rl ? "::" + w.f->name + "(" : "gfx::" + w.name + "(";
for (size_t i = 0; i < argExpr.size(); ++i) call += (i ? ", " : "") + argExpr[i];
call += ")";
if (w.ret.cat == Cat::Void) o << ind << call << ";\n";
else {
o << ind << "const auto res = " << call << ";\n";
o << ind << vec << ".push_back(" << hashExpr(w.ret, "res") << ");\n";
}
for (const std::string& out : outs) o << ind << vec << ".push_back(gfxtest::hv(" << out << "));\n";
}
};
} // namespace gen
+234
View File
@@ -0,0 +1,234 @@
// gfx_gen: writes eng::gfx, the engine's equivalent of every raylib function, from raylib's own headers.
//
// gfx_gen --raylib <raylib.h> [--rlgl <rlgl.h>] [--raymath <raymath.h>] --out <dir> [-DNAME[=VALUE]]... [--no-selftest]
//
// Output (all under <dir>):
// include/engine/gfx_api.hpp, gfx_rlgl_api.hpp, gfx_raymath_api.hpp the public headers (one per input header)
// gfx_core.cpp, gfx_rlgl.cpp, gfx_raymath.cpp the wrappers (compile against raylib)
// selftest/* mock raylib + test of every wrapper
// gfx_report.txt what was wrapped, what was skipped and why
//
// The generator reads the headers of the raylib you build against, so the wrappers always match that version.
// What it cannot express safely (varargs other than TraceLog/TextFormat, function-pointer parameters, ...) is
// skipped and listed in the report; nothing is guessed.
#include "emit_test.hpp"
#include <filesystem>
namespace {
using namespace gen;
namespace fs = std::filesystem;
struct Options {
std::string raylib, rlgl, raymath, out;
std::vector<std::string> defines;
bool selftest = true;
};
Options parseArgs(int argc, char** argv) {
Options o;
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
auto next = [&]() -> std::string {
if (i + 1 >= argc) die("missing value after " + a);
return argv[++i];
};
if (a == "--raylib") o.raylib = next();
else if (a == "--rlgl") o.rlgl = next();
else if (a == "--raymath") o.raymath = next();
else if (a == "--out") o.out = next();
else if (a == "--no-selftest") o.selftest = false;
else if (a == "-D") o.defines.push_back(next());
else if (a.rfind("-D", 0) == 0) o.defines.push_back(a.substr(2));
else die("unknown argument " + a);
}
if (o.raylib.empty() || o.out.empty()) die("usage: gfx_gen --raylib raylib.h [--rlgl rlgl.h] [--raymath raymath.h] --out dir [-DNAME[=VALUE]]... [--no-selftest]");
return o;
}
void writeIfChanged(const fs::path& p, const std::string& content) {
fs::create_directories(p.parent_path());
{
std::ifstream in(p, std::ios::binary);
if (in) {
std::ostringstream cur;
cur << in.rdbuf();
if (cur.str() == content) return; // untouched: no needless rebuilds
}
}
std::ofstream out(p, std::ios::binary | std::ios::trunc);
if (!out) die("cannot write " + p.string());
out << content;
}
bool f32Literal(const Preprocessor& pp, const std::string& body, int depth = 0) {
if (depth > 8) return false;
for (const Tok& t : tokenize(body, 0)) {
if (t.kind == TK::Number && t.text.size() > 1 && (t.text.back() == 'f' || t.text.back() == 'F') &&
(t.text.find('.') != std::string::npos || t.text.find('e') != std::string::npos) && t.text.compare(0, 2, "0x") != 0)
return true;
if (t.kind == TK::Ident) {
const auto it = pp.macros.find(t.text);
if (it != pp.macros.end() && !it->second.fnLike && f32Literal(pp, it->second.body, depth + 1)) return true;
}
}
return false;
}
// #define NAME body -> constant / colour / alias
void processDefines(ModelData& m, const Preprocessor& pp, int group, size_t from) {
for (size_t i = from; i < pp.defined.size(); ++i) {
const std::string& name = pp.defined[i].first;
const std::string& body = pp.defined[i].second;
if (body.empty() || name.empty() || name[0] == '_' || name == "RLAPI" || name == "RMAPI") continue;
const std::vector<Tok> toks = tokenize(body, 0);
if (toks.empty()) continue;
// CLITERAL(Color){ r, g, b, a }
if (toks.size() >= 3 && toks[0].text == "CLITERAL") {
std::vector<long long> v;
for (const Tok& t : toks) {
Val x;
if (t.kind == TK::Number && parseNumberLiteral(t.text, x) && !x.isFloat) v.push_back(x.i);
}
if (v.size() == 4) {
ColorDef c;
c.name = name;
c.r = static_cast<int>(v[0]); c.g = static_cast<int>(v[1]); c.b = static_cast<int>(v[2]); c.a = static_cast<int>(v[3]);
c.group = group;
m.colors.push_back(c);
}
continue;
}
if (toks.size() == 1 && toks[0].kind == TK::String) {
ConstDef c;
c.name = name;
c.str = toks[0].text;
c.group = group;
m.consts.push_back(c);
continue;
}
if (toks.size() == 1 && toks[0].kind == TK::Ident) {
const auto e = m.enumerators.find(toks[0].text);
if (e != m.enumerators.end()) {
ConstDef c;
c.name = name;
c.value = Val::ofInt(e->second);
c.group = group;
m.consts.push_back(c);
continue;
}
bool isFunc = false;
for (const FuncDef& f : m.funcs) if (f.name == toks[0].text) isFunc = true;
if (isFunc) {
m.macroAliases.push_back({name, toks[0].text, group});
continue;
}
}
Val v;
if (pp.macroValue(name, v)) {
ConstDef c;
c.name = name;
c.value = v;
c.isFloat32 = v.isFloat && f32Literal(pp, body);
c.group = group;
m.consts.push_back(c);
}
}
}
std::string unquote(std::string s) {
if (s.size() >= 2 && s.front() == '"' && s.back() == '"') s = s.substr(1, s.size() - 2);
return s;
}
} // namespace
int main(int argc, char** argv) {
const Options opt = parseArgs(argc, argv);
Preprocessor pp;
for (const std::string& d : opt.defines) pp.predefine(d);
ModelData m;
struct Input { int group; std::string path; };
std::vector<Input> inputs = {{kGroupRaylib, opt.raylib}};
if (!opt.rlgl.empty()) inputs.push_back({kGroupRlgl, opt.rlgl});
if (!opt.raymath.empty()) inputs.push_back({kGroupRaymath, opt.raymath});
std::set<int> groups;
for (const Input& in : inputs) {
const size_t before = pp.defined.size();
const Unit u = pp.run(in.path);
Parser(m, pp, in.group).parse(u);
processDefines(m, pp, in.group, before);
groups.insert(in.group);
}
// #define A B where B turned out to be a function declared later in the same header set is handled in
// processDefines for already-known functions; nothing else to resolve.
std::string version = "?";
if (const auto it = pp.macros.find("RAYLIB_VERSION"); it != pp.macros.end()) version = unquote(it->second.body);
Types ty(m);
Plan plan(m, ty, pp, groups);
ApiWriter api(plan);
TestWriter test(plan);
const fs::path out = opt.out;
for (int g : {kGroupRaylib, kGroupRlgl, kGroupRaymath}) {
const fs::path hdr = out / "include" / "engine" / groupHeader(g);
const fs::path src = out / groupFile(g);
if (groups.count(g)) {
writeIfChanged(hdr, api.header(g, version));
writeIfChanged(src, api.source(g, version));
} else {
std::error_code ec;
fs::remove(hdr, ec);
fs::remove(src, ec);
}
}
if (opt.selftest) {
writeIfChanged(out / "selftest" / "gfx_selftest_common.hpp", TestWriter::common());
writeIfChanged(out / "selftest" / "gfx_selftest_iface.hpp", TestWriter::iface());
writeIfChanged(out / "selftest" / "gfx_mock.cpp", test.mock(version));
writeIfChanged(out / "selftest" / "gfx_selftest.cpp", test.test(version));
}
// ---- report ----------------------------------------------------------------------------------------------
std::ostringstream rep;
rep << "gfx_gen report: raylib " << version << "\n\n";
for (int g : {kGroupRaylib, kGroupRlgl, kGroupRaymath}) {
if (!groups.count(g)) continue;
int declared = 0, wrapped = 0, skipped = 0;
for (const FuncDef& f : m.funcs) if (f.group == g) ++declared;
for (const Wrapped& w : plan.wrapped) if (w.f->group == g) ++wrapped;
for (const Skipped& s : plan.skipped) if (s.f->group == g) ++skipped;
rep << "GFX_SUMMARY " << (g == kGroupRaylib ? "raylib" : g == kGroupRlgl ? "rlgl" : "raymath") << " declared=" << declared << " wrapped=" << wrapped << " skipped=" << skipped << "\n";
}
rep << "GFX_SUMMARY structs=" << plan.emittedStructs.size() << " enums=" << plan.emittedEnums.size() << " callbacks=" << plan.emittedCallbacks.size()
<< " constants=" << m.consts.size() << " colors=" << m.colors.size() << " mat4=" << (ty.matrixMapped ? 1 : 0) << "\n";
rep << "GFX_SUMMARY mapped:";
for (const auto& kv : ty.mapped) rep << " " << kv.first << "->" << kv.second;
rep << "\n";
if (opt.selftest)
rep << "GFX_SUMMARY selftest mocked=" << test.mockedCount() << " raymath_oracles=" << test.oracleTestableCount() << "/" << test.raymathCount() << "\n";
rep << "\nSkipped functions (not wrapped; use raylib directly for these):\n";
for (const Skipped& s : plan.skipped) rep << " " << groupName(s.f->group) << ": " << s.f->name << " - " << s.why << "\n";
rep << "\nStructs that could not be mirrored:\n";
for (const StructDef& s : m.structs) if (!s.opaque && !s.ok) rep << " " << s.name << " - " << s.why << "\n";
rep << "\nEnums that could not be mirrored:\n";
for (const EnumDef& e : m.enums) if (!e.ok) rep << " " << (e.name.empty() ? "(anonymous)" : e.name) << " - " << e.why << "\n";
rep << "\nCallbacks that could not be mirrored:\n";
for (const CallbackDef& c : m.callbacks) if (!c.ok || !ty.callbackSupported(c)) rep << " " << c.name << " - " << (c.why.empty() ? "uses types other than plain C types" : c.why) << "\n";
if (!pp.notes.empty()) {
rep << "\nPreprocessor notes:\n";
for (const std::string& n : pp.notes) rep << " " << n << "\n";
}
writeIfChanged(out / "gfx_report.txt", rep.str());
// a short console summary
std::fputs(rep.str().substr(0, rep.str().find("\nSkipped functions")).c_str(), stdout);
return 0;
}
+481
View File
@@ -0,0 +1,481 @@
// gfx_gen, part 1: reading C headers.
//
// A small C preprocessor (conditionals, #define bookkeeping, comment stripping), a tokenizer and a
// constant-expression evaluator: exactly as much C as raylib.h, rlgl.h and raymath.h need, evaluated the way a
// C++ compiler would see them (so `#if defined(X)` blocks inside struct bodies resolve instead of being
// parsed twice). Nothing here knows about raylib; see model.hpp for the declaration parser.
#pragma once
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <functional>
#include <map>
#include <set>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
namespace gen {
[[noreturn]] inline void die(const std::string& msg) {
std::fprintf(stderr, "gfx_gen: error: %s\n", msg.c_str());
std::exit(2);
}
inline std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
inline bool isIdentStart(char c) { return std::isalpha(static_cast<unsigned char>(c)) || c == '_'; }
inline bool isIdentChar(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
template <class... A> std::string fmt(const char* f, A... a) {
char buf[1024];
std::snprintf(buf, sizeof buf, f, a...);
return buf;
}
// ---------------------------------------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------------------------------------
enum class TK { Ident, Number, String, Punct };
struct Tok {
TK kind = TK::Punct;
std::string text;
int line = 0;
};
inline std::vector<Tok> tokenize(const std::string& s, int line) {
std::vector<Tok> out;
size_t i = 0;
const size_t n = s.size();
while (i < n) {
const char c = s[i];
if (std::isspace(static_cast<unsigned char>(c))) { ++i; continue; }
if (isIdentStart(c)) {
size_t j = i + 1;
while (j < n && isIdentChar(s[j])) ++j;
out.push_back({TK::Ident, s.substr(i, j - i), line});
i = j;
continue;
}
if (std::isdigit(static_cast<unsigned char>(c)) || (c == '.' && i + 1 < n && std::isdigit(static_cast<unsigned char>(s[i + 1])))) {
const bool hex = c == '0' && i + 1 < n && (s[i + 1] == 'x' || s[i + 1] == 'X');
size_t j = i + 1;
while (j < n) {
const char d = s[j];
if (isIdentChar(d) || d == '.') ++j;
else if (!hex && (d == '+' || d == '-') && (s[j - 1] == 'e' || s[j - 1] == 'E')) ++j;
else break;
}
out.push_back({TK::Number, s.substr(i, j - i), line});
i = j;
continue;
}
if (c == '"' || c == '\'') {
size_t j = i + 1;
while (j < n && s[j] != c) j += (s[j] == '\\' && j + 1 < n) ? 2 : 1;
j = std::min(n, j + 1);
out.push_back({c == '"' ? TK::String : TK::Number, s.substr(i, j - i), line});
i = j;
continue;
}
if (s.compare(i, 3, "...") == 0) { out.push_back({TK::Punct, "...", line}); i += 3; continue; }
static const char* two[] = {"<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "::", "->"};
bool matched = false;
for (const char* t : two)
if (s.compare(i, 2, t) == 0) { out.push_back({TK::Punct, t, line}); i += 2; matched = true; break; }
if (matched) continue;
out.push_back({TK::Punct, std::string(1, c), line});
++i;
}
return out;
}
// ---------------------------------------------------------------------------------------------------------
// Constant expressions (#if conditions, enum values, array sizes, numeric #defines)
// ---------------------------------------------------------------------------------------------------------
struct Val {
bool isFloat = false;
long long i = 0;
double d = 0;
double asDouble() const { return isFloat ? d : static_cast<double>(i); }
long long asInt() const { return isFloat ? static_cast<long long>(d) : i; }
static Val ofInt(long long v) { Val r; r.i = v; return r; }
static Val ofFloat(double v) { Val r; r.isFloat = true; r.d = v; return r; }
};
inline bool parseNumberLiteral(const std::string& text, Val& out) {
if (text.empty()) return false;
if (text[0] == '\'') { // character literal
if (text.size() >= 3 && text[1] != '\\') { out = Val::ofInt(static_cast<unsigned char>(text[1])); return true; }
if (text.size() >= 4 && text[1] == '\\') {
const char e = text[2];
out = Val::ofInt(e == 'n' ? 10 : e == 't' ? 9 : e == 'r' ? 13 : e == '0' ? 0 : e);
return true;
}
return false;
}
std::string t = text;
const bool hex = t.size() > 1 && t[0] == '0' && (t[1] == 'x' || t[1] == 'X');
if (!hex) {
const bool floaty = t.find('.') != std::string::npos || t.find('e') != std::string::npos || t.find('E') != std::string::npos;
if (floaty) {
while (!t.empty() && (t.back() == 'f' || t.back() == 'F' || t.back() == 'l' || t.back() == 'L')) t.pop_back();
char* end = nullptr;
const double v = std::strtod(t.c_str(), &end);
if (!end || *end) return false;
out = Val::ofFloat(v);
return true;
}
}
while (!t.empty() && (t.back() == 'u' || t.back() == 'U' || t.back() == 'l' || t.back() == 'L')) t.pop_back();
if (t.empty()) return false;
char* end = nullptr;
const unsigned long long v = std::strtoull(t.c_str(), &end, hex ? 16 : (t.size() > 1 && t[0] == '0' ? 8 : 10));
if (!end || *end) return false;
out = Val::ofInt(static_cast<long long>(v));
return true;
}
// Evaluates a token range. `lookup` resolves identifiers (macros, enumerators); `isDefined` answers defined(X).
// In `lenient` mode (the preprocessor) unknown identifiers and function-like calls are 0, as in a real #if.
class Eval {
public:
using Lookup = std::function<bool(const std::string&, Val&)>;
using Defined = std::function<bool(const std::string&)>;
Eval(const std::vector<Tok>& t, Lookup lookup, Defined isDefined = {}, bool lenient = false)
: t_(t), lookup_(std::move(lookup)), defined_(std::move(isDefined)), lenient_(lenient) {}
bool run(Val& out) {
pos_ = 0;
if (!ternary(out)) return false;
return pos_ == t_.size();
}
private:
const std::vector<Tok>& t_;
Lookup lookup_;
Defined defined_;
bool lenient_;
size_t pos_ = 0;
bool peekIs(const char* s) const { return pos_ < t_.size() && t_[pos_].kind == TK::Punct && t_[pos_].text == s; }
static bool truthy(const Val& v) { return v.isFloat ? v.d != 0 : v.i != 0; }
bool ternary(Val& out) {
if (!binary(1, out)) return false;
if (peekIs("?")) {
++pos_;
Val a, b;
if (!ternary(a)) return false;
if (!peekIs(":")) return false;
++pos_;
if (!ternary(b)) return false;
out = truthy(out) ? a : b;
}
return true;
}
static int precedence(const std::string& op) {
if (op == "||") return 1;
if (op == "&&") return 2;
if (op == "|") return 3;
if (op == "^") return 4;
if (op == "&") return 5;
if (op == "==" || op == "!=") return 6;
if (op == "<" || op == "<=" || op == ">" || op == ">=") return 7;
if (op == "<<" || op == ">>") return 8;
if (op == "+" || op == "-") return 9;
if (op == "*" || op == "/" || op == "%") return 10;
return 0;
}
bool binary(int minPrec, Val& lhs) {
if (!unary(lhs)) return false;
for (;;) {
if (pos_ >= t_.size() || t_[pos_].kind != TK::Punct) return true;
const std::string op = t_[pos_].text;
const int p = precedence(op);
if (p == 0 || p < minPrec) return true;
++pos_;
Val rhs;
if (!binary(p + 1, rhs)) return false;
if (!apply(op, lhs, rhs)) return false;
}
}
static bool apply(const std::string& op, Val& a, const Val& b) {
const bool fl = a.isFloat || b.isFloat;
if (op == "||") { a = Val::ofInt(truthy(a) || truthy(b)); return true; }
if (op == "&&") { a = Val::ofInt(truthy(a) && truthy(b)); return true; }
if (op == "==" || op == "!=" || op == "<" || op == "<=" || op == ">" || op == ">=") {
const double x = a.asDouble(), y = b.asDouble();
const bool r = op == "==" ? x == y : op == "!=" ? x != y : op == "<" ? x < y : op == "<=" ? x <= y : op == ">" ? x > y : x >= y;
a = Val::ofInt(r);
return true;
}
if (op == "+" || op == "-" || op == "*" || op == "/") {
if (fl) {
const double x = a.asDouble(), y = b.asDouble();
if (op == "/" && y == 0) return false;
a = Val::ofFloat(op == "+" ? x + y : op == "-" ? x - y : op == "*" ? x * y : x / y);
} else {
if (op == "/" && b.i == 0) return false;
a = Val::ofInt(op == "+" ? a.i + b.i : op == "-" ? a.i - b.i : op == "*" ? a.i * b.i : a.i / b.i);
}
return true;
}
if (fl) return false; // the remaining operators are integer-only
if (op == "%") { if (b.i == 0) return false; a = Val::ofInt(a.i % b.i); return true; }
if (op == "|") { a = Val::ofInt(a.i | b.i); return true; }
if (op == "&") { a = Val::ofInt(a.i & b.i); return true; }
if (op == "^") { a = Val::ofInt(a.i ^ b.i); return true; }
if (op == "<<") { a = Val::ofInt(a.i << b.i); return true; }
if (op == ">>") { a = Val::ofInt(a.i >> b.i); return true; }
return false;
}
bool unary(Val& out) {
if (peekIs("!")) { ++pos_; if (!unary(out)) return false; out = Val::ofInt(!truthy(out)); return true; }
if (peekIs("-")) { ++pos_; if (!unary(out)) return false; out = out.isFloat ? Val::ofFloat(-out.d) : Val::ofInt(-out.i); return true; }
if (peekIs("+")) { ++pos_; return unary(out); }
if (peekIs("~")) { ++pos_; if (!unary(out) || out.isFloat) return false; out = Val::ofInt(~out.i); return true; }
return primary(out);
}
bool primary(Val& out) {
if (pos_ >= t_.size()) return false;
const Tok& k = t_[pos_];
if (k.kind == TK::Punct && k.text == "(") {
++pos_;
if (!ternary(out) || !peekIs(")")) return false;
++pos_;
return true;
}
if (k.kind == TK::Number) {
++pos_;
return parseNumberLiteral(k.text, out);
}
if (k.kind != TK::Ident) return false;
++pos_;
if (k.text == "defined") {
bool paren = peekIs("(");
if (paren) ++pos_;
if (pos_ >= t_.size() || t_[pos_].kind != TK::Ident) return false;
const std::string name = t_[pos_++].text;
if (paren) {
if (!peekIs(")")) return false;
++pos_;
}
out = Val::ofInt(defined_ ? defined_(name) : false);
return true;
}
if (k.text == "true") { out = Val::ofInt(1); return true; }
if (k.text == "false") { out = Val::ofInt(0); return true; }
if (peekIs("(") && lenient_) { // function-like macro or __has_include(...): unknown, so 0
int depth = 0;
while (pos_ < t_.size()) {
if (peekIs("(")) ++depth;
else if (peekIs(")") && --depth == 0) { ++pos_; break; }
++pos_;
}
out = Val::ofInt(0);
return true;
}
if (lookup_ && lookup_(k.text, out)) return true;
if (lenient_) { out = Val::ofInt(0); return true; }
return false;
}
};
// ---------------------------------------------------------------------------------------------------------
// Preprocessor
// ---------------------------------------------------------------------------------------------------------
struct Macro {
bool fnLike = false;
std::string body;
};
struct Unit {
std::string path;
std::vector<Tok> toks; // tokens of the ACTIVE regions, directives removed
std::map<int, std::string> docs; // line -> trailing // comment on that line
};
class Preprocessor {
public:
std::map<std::string, Macro> macros;
std::vector<std::pair<std::string, std::string>> defined; // every active object-like #define, in order
std::vector<std::string> notes;
Preprocessor() {
define("__cplusplus", "202002L"); // we want the C++ view of the headers: that is how they get compiled
define("__STDC__", "1");
}
void define(const std::string& name, const std::string& body) { macros[name] = {false, body}; }
// "NAME" or "NAME=VALUE" (command line -D)
void predefine(const std::string& spec) {
const size_t eq = spec.find('=');
if (eq == std::string::npos) define(spec, "1");
else define(spec.substr(0, eq), spec.substr(eq + 1));
}
Unit run(const std::string& path) {
Unit u;
u.path = path;
std::ifstream in(path);
if (!in) die("cannot read " + path);
std::vector<std::string> lines;
for (std::string l; std::getline(in, l);) {
if (!l.empty() && l.back() == '\r') l.pop_back();
lines.push_back(l);
}
struct Cond { bool parentActive, taken, active; };
std::vector<Cond> stack;
bool active = true, inBlock = false;
for (size_t ln = 0; ln < lines.size(); ++ln) {
const int lineNo = static_cast<int>(ln) + 1;
std::string raw = lines[ln];
while (!raw.empty() && raw.back() == '\\' && ln + 1 < lines.size()) { // line continuation
raw.pop_back();
raw += ' ' + lines[++ln];
}
std::string comment;
const std::string code = trim(stripComments(raw, inBlock, &comment));
if (!comment.empty()) u.docs[lineNo] = comment;
if (code.empty()) continue;
if (code[0] != '#') {
if (active) for (Tok& t : tokenize(code, lineNo)) u.toks.push_back(std::move(t));
continue;
}
size_t p = 1;
while (p < code.size() && std::isspace(static_cast<unsigned char>(code[p]))) ++p;
size_t q = p;
while (q < code.size() && isIdentChar(code[q])) ++q;
const std::string dir = code.substr(p, q - p);
const std::string rest = trim(code.substr(q));
if (dir == "if") {
const bool c = active && evalCondition(rest, path, lineNo);
stack.push_back({active, c, c});
active = c;
} else if (dir == "ifdef" || dir == "ifndef") {
const std::string name = firstIdent(rest);
const bool d = macros.count(name) != 0;
const bool c = active && (dir == "ifdef" ? d : !d);
stack.push_back({active, c, c});
active = c;
} else if (dir == "elif") {
if (stack.empty()) continue;
Cond& top = stack.back();
if (!top.parentActive || top.taken) top.active = false;
else { const bool c = evalCondition(rest, path, lineNo); top.active = c; top.taken = c; }
active = top.active;
} else if (dir == "else") {
if (stack.empty()) continue;
Cond& top = stack.back();
top.active = top.parentActive && !top.taken;
top.taken = true;
active = top.active;
} else if (dir == "endif") {
if (stack.empty()) continue;
active = stack.back().parentActive;
stack.pop_back();
} else if (active && dir == "define") {
size_t a = 0;
while (a < rest.size() && isIdentChar(rest[a])) ++a;
const std::string name = rest.substr(0, a);
if (name.empty()) continue;
if (a < rest.size() && rest[a] == '(') { // function-like: remember it exists, ignore the body
macros[name] = {true, ""};
} else {
const std::string body = trim(rest.substr(a));
macros[name] = {false, body};
defined.emplace_back(name, body);
}
} else if (active && dir == "undef") {
macros.erase(firstIdent(rest));
}
// #include, #pragma, #error, #warning, #line: nothing to do
}
return u;
}
// Value of an object-like macro (recursively expanded), if it is a constant expression.
bool macroValue(const std::string& name, Val& out, int depth = 0) const {
const auto it = macros.find(name);
if (it == macros.end() || it->second.fnLike || depth > 16) return false;
const std::vector<Tok> toks = tokenize(it->second.body, 0);
if (toks.empty()) return false;
Eval ev(toks, [&](const std::string& id, Val& v) { return macroValue(id, v, depth + 1); },
[&](const std::string& id) { return macros.count(id) != 0; }, false);
return ev.run(out);
}
private:
static std::string firstIdent(const std::string& s) {
size_t a = 0;
while (a < s.size() && !isIdentStart(s[a])) ++a;
size_t b = a;
while (b < s.size() && isIdentChar(s[b])) ++b;
return s.substr(a, b - a);
}
// Removes comments (keeping string literals intact); `lineComment` receives the text of a trailing // comment.
static std::string stripComments(const std::string& raw, bool& inBlock, std::string* lineComment) {
std::string out;
bool inStr = false;
char quote = 0;
for (size_t i = 0; i < raw.size(); ++i) {
const char c = raw[i];
if (inBlock) {
if (c == '*' && i + 1 < raw.size() && raw[i + 1] == '/') { inBlock = false; ++i; out += ' '; }
continue;
}
if (inStr) {
out += c;
if (c == '\\' && i + 1 < raw.size()) out += raw[++i];
else if (c == quote) inStr = false;
continue;
}
if (c == '"' || c == '\'') { inStr = true; quote = c; out += c; continue; }
if (c == '/' && i + 1 < raw.size() && raw[i + 1] == '/') {
if (lineComment) *lineComment = trim(raw.substr(i + 2));
break;
}
if (c == '/' && i + 1 < raw.size() && raw[i + 1] == '*') { inBlock = true; ++i; continue; }
out += c;
}
return out;
}
bool evalCondition(const std::string& expr, const std::string& path, int line) {
const std::vector<Tok> toks = tokenize(expr, line);
Eval ev(toks, [&](const std::string& id, Val& v) {
if (!macroValue(id, v)) v = Val::ofInt(0);
return true;
},
[&](const std::string& id) { return macros.count(id) != 0; }, true);
Val v;
if (!ev.run(v)) {
notes.push_back(fmt("%s:%d: could not evaluate '#if %s'; treated as false", path.c_str(), line, expr.c_str()));
return false;
}
return v.isFloat ? v.d != 0 : v.i != 0;
}
};
} // namespace gen
+693
View File
@@ -0,0 +1,693 @@
// gfx_gen, part 2: the declarations found in the headers, and the rules for what can be wrapped.
//
// Parser: typedef struct / enum / alias / callback, RLAPI / RMAPI functions, numeric and colour #defines.
// Types: alias resolution, the mapping of raylib's Vector2/Vector3/Quaternion/Color/BoundingBox onto the
// engine's own Vec2/Vec3/Quat/Rgba/Aabb, and the verdict (wrappable or not, and why) for every
// struct and function. Whatever cannot be wrapped safely is skipped and listed in the report.
#pragma once
#include "lex.hpp"
#include <algorithm>
#include <optional>
namespace gen {
// ---------------------------------------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------------------------------------
struct Type {
std::string base; // builtin spelled canonically ("unsigned int"), or a typedef / struct / enum name
int ptr = 0;
bool constBase = false; // `const` applies to the pointee (or to the value when ptr == 0)
};
struct Field {
Type type;
std::string name;
std::vector<long long> dims; // array extents, empty = plain field
};
struct StructDef {
std::string name;
std::vector<Field> fields;
bool opaque = false; // declared, never defined (rAudioBuffer): usable behind a pointer only
int group = 0;
bool ok = true; // parsed completely
std::string why;
};
struct EnumItem { std::string name; long long value = 0; };
struct EnumDef {
std::string name; // may be empty (anonymous)
std::vector<EnumItem> items;
int group = 0;
bool ok = true;
std::string why;
};
struct Param {
Type type;
std::string name;
bool unsupported = false;
std::string why;
};
struct CallbackDef {
std::string name;
Type ret;
std::vector<Param> params;
int group = 0;
bool ok = true;
std::string why;
};
struct FuncDef {
std::string name;
Type ret;
std::vector<Param> params;
bool variadic = false;
bool retUnsupported = false;
std::string retWhy;
bool hasBody = false; // RMAPI (raymath): the declaration is followed by a body
int group = 0;
std::string doc;
};
struct ConstDef {
std::string name;
Val value;
bool isFloat32 = false; // the literal had an f suffix
std::string str; // set for string constants
int group = 0;
};
struct ColorDef { std::string name; int r = 0, g = 0, b = 0, a = 255; int group = 0; };
struct MacroAlias { std::string name, target; int group = 0; };
struct ModelData {
std::vector<StructDef> structs;
std::map<std::string, size_t> structIdx;
std::vector<EnumDef> enums;
std::map<std::string, long long> enumerators;
std::map<std::string, std::string> enumOf; // enumerator -> enum type name (possibly empty)
std::map<std::string, Type> aliases; // typedef NAME -> target
std::vector<std::string> aliasOrder;
std::map<std::string, int> aliasGroup;
std::vector<CallbackDef> callbacks;
std::map<std::string, size_t> callbackIdx;
std::vector<FuncDef> funcs;
std::vector<ConstDef> consts;
std::vector<ColorDef> colors;
std::vector<MacroAlias> macroAliases;
std::vector<std::string> notes;
};
inline bool isBuiltinWord(const std::string& w) {
static const std::set<std::string> s = {"unsigned", "signed", "short", "long", "int", "char", "float", "double", "void", "_Bool", "bool"};
return s.count(w) != 0;
}
inline bool isQualifier(const std::string& w) {
static const std::set<std::string> s = {"const", "volatile", "restrict", "__restrict", "register", "static", "extern", "inline", "__inline", "__inline__"};
return s.count(w) != 0;
}
inline std::optional<std::string> canonicalBuiltin(const std::vector<std::string>& words) {
int nLong = 0, nShort = 0;
bool uns = false, sig = false;
std::string core;
for (const std::string& w : words) {
if (w == "unsigned") uns = true;
else if (w == "signed") sig = true;
else if (w == "long") ++nLong;
else if (w == "short") ++nShort;
else if (w == "int" || w == "char" || w == "float" || w == "double" || w == "void" || w == "_Bool" || w == "bool") {
if (!core.empty()) return std::nullopt;
core = w;
} else return std::nullopt;
}
if (core == "float" || core == "double" || core == "void") return nLong || nShort || uns || sig ? std::nullopt : std::optional<std::string>(core);
if (core == "_Bool" || core == "bool") return std::string("bool");
if (core == "char") return uns ? "unsigned char" : sig ? "signed char" : "char";
if (nShort) return uns ? "unsigned short" : "short";
if (nLong == 1) return uns ? "unsigned long" : "long";
if (nLong == 2) return uns ? "unsigned long long" : "long long";
if (nLong > 2) return std::nullopt;
return uns ? "unsigned int" : "int";
}
// ---------------------------------------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------------------------------------
class Parser {
public:
Parser(ModelData& m, Preprocessor& pp, int group) : m_(m), pp_(pp), group_(group) {}
void parse(const Unit& u) {
t_ = &u.toks;
docs_ = &u.docs;
const std::vector<Tok>& t = *t_;
size_t i = 0;
while (i < t.size()) {
const Tok& k = t[i];
if (k.kind == TK::Ident && k.text == "extern" && i + 2 < t.size() && t[i + 1].kind == TK::String && t[i + 2].text == "{") { i += 3; continue; }
if (k.text == "}" || k.text == ";") { ++i; continue; }
if (k.kind == TK::Ident && k.text == "typedef") { i = typedefStmt(i); continue; }
if (k.kind == TK::Ident && (k.text == "RLAPI" || k.text == "RMAPI")) { i = functionStmt(i); continue; }
i = skipStmt(i);
}
}
private:
ModelData& m_;
Preprocessor& pp_;
int group_;
const std::vector<Tok>* t_ = nullptr;
const std::map<int, std::string>* docs_ = nullptr;
struct Decl {
std::vector<std::string> words;
int ptr = 0;
bool constBase = false;
std::vector<long long> dims;
bool hasArray = false, hasParen = false, ok = true;
std::string why;
};
size_t matchBrace(size_t j) const {
const std::vector<Tok>& t = *t_;
int depth = 0;
for (size_t k = j; k < t.size(); ++k) {
if (t[k].text == "{") ++depth;
else if (t[k].text == "}" && --depth == 0) return k;
}
return t.size() - 1;
}
size_t matchParen(size_t j) const {
const std::vector<Tok>& t = *t_;
int depth = 0;
for (size_t k = j; k < t.size(); ++k) {
if (t[k].text == "(") ++depth;
else if (t[k].text == ")" && --depth == 0) return k;
}
return t.size() - 1;
}
size_t skipStmt(size_t i) const {
const std::vector<Tok>& t = *t_;
int depth = 0;
for (size_t j = i; j < t.size(); ++j) {
if (t[j].text == "{") ++depth;
else if (t[j].text == "}") {
if (depth == 0) return j + 1;
if (--depth == 0) {
++j;
if (j < t.size() && t[j].text == ";") ++j;
return j;
}
} else if (t[j].text == ";" && depth == 0) return j + 1;
}
return t.size();
}
bool evalConst(const std::vector<Tok>& toks, Val& out) const {
Eval ev(toks,
[this](const std::string& id, Val& v) {
const auto it = m_.enumerators.find(id);
if (it != m_.enumerators.end()) { v = Val::ofInt(it->second); return true; }
return pp_.macroValue(id, v);
},
[this](const std::string& id) { return pp_.macros.count(id) != 0; }, false);
return ev.run(out);
}
// Splits t[from, to) at top-level commas.
std::vector<std::pair<size_t, size_t>> splitCommas(const std::vector<Tok>& t, size_t from, size_t to) const {
std::vector<std::pair<size_t, size_t>> parts;
int depth = 0;
size_t start = from;
for (size_t k = from; k < to; ++k) {
const std::string& s = t[k].text;
if (s == "(" || s == "[" || s == "{") ++depth;
else if (s == ")" || s == "]" || s == "}") --depth;
else if (s == "," && depth == 0) { parts.emplace_back(start, k); start = k + 1; }
}
parts.emplace_back(start, to);
return parts;
}
Decl scanDecl(const std::vector<Tok>& t, size_t from, size_t to) const {
Decl d;
for (size_t k = from; k < to; ++k) {
const Tok& tk = t[k];
if (tk.kind == TK::Ident) {
if (tk.text == "const") { if (d.ptr == 0) d.constBase = true; }
else if (isQualifier(tk.text) || tk.text == "struct" || tk.text == "enum" || tk.text == "union") {}
else d.words.push_back(tk.text);
} else if (tk.text == "*") {
++d.ptr;
} else if (tk.text == "[") {
size_t e = k + 1;
int depth = 1;
while (e < to && depth > 0) {
if (t[e].text == "[") ++depth;
else if (t[e].text == "]") --depth;
if (depth > 0) ++e;
}
d.hasArray = true;
const std::vector<Tok> expr(t.begin() + static_cast<long>(k) + 1, t.begin() + static_cast<long>(e));
Val v;
if (expr.empty()) d.dims.push_back(0);
else if (evalConst(expr, v) && !v.isFloat && v.i >= 0) d.dims.push_back(v.i);
else { d.ok = false; d.why = "array size is not a constant"; }
k = e;
} else if (tk.text == "(" || tk.text == ")") {
d.hasParen = true;
} else {
d.ok = false;
d.why = "unexpected '" + tk.text + "'";
}
}
return d;
}
// Declarator that carries a name (fields, parameters, typedef aliases) -> type + name. A parameter may be unnamed.
static bool typeAndName(const Decl& d, bool expectName, Type& type, std::string& name) {
std::vector<std::string> w = d.words;
name.clear();
if (expectName) {
if (w.size() >= 2 && !isBuiltinWord(w.back())) { name = w.back(); w.pop_back(); }
}
if (w.empty()) return false;
bool anyBuiltin = false, anyOther = false;
for (const std::string& x : w) (isBuiltinWord(x) ? anyBuiltin : anyOther) = true;
if (anyBuiltin && !anyOther) {
const auto c = canonicalBuiltin(w);
if (!c) return false;
type.base = *c;
} else if (!anyBuiltin && w.size() == 1) {
type.base = w[0];
} else {
return false;
}
type.ptr = d.ptr;
type.constBase = d.constBase;
return true;
}
// typedef ... ;
size_t typedefStmt(size_t i) {
const std::vector<Tok>& t = *t_;
const size_t n = t.size();
size_t j = i + 1;
if (j < n && t[j].kind == TK::Ident && (t[j].text == "struct" || t[j].text == "enum")) {
const bool isEnum = t[j].text == "enum";
size_t k = j + 1;
std::string tag;
if (k < n && t[k].kind == TK::Ident) { tag = t[k].text; ++k; }
if (k < n && t[k].text == "{") {
const size_t close = matchBrace(k);
const std::vector<Tok> body(t.begin() + static_cast<long>(k) + 1, t.begin() + static_cast<long>(close));
size_t e = close + 1;
std::vector<std::string> names;
while (e < n && t[e].text != ";") {
if (t[e].kind == TK::Ident) names.push_back(t[e].text);
++e;
}
const std::string name = names.empty() ? tag : names[0];
if (isEnum) addEnum(name, body);
else addStruct(name, body);
for (size_t x = 1; x < names.size(); ++x) addAlias(names[x], Type{name, 0, false});
return e < n ? e + 1 : e;
}
}
size_t end = j;
int depth = 0;
while (end < n && !(t[end].text == ";" && depth == 0)) {
if (t[end].text == "(") ++depth;
else if (t[end].text == ")") --depth;
++end;
}
const size_t next = end < n ? end + 1 : end;
// function pointer: typedef RET (*NAME)(PARAMS);
size_t open = j;
while (open < end && t[open].text != "(") ++open;
if (open < end) {
if (open + 3 < end && t[open + 1].text == "*" && t[open + 2].kind == TK::Ident && t[open + 3].text == ")") addCallback(i, j, open, end);
return next;
}
const Decl d = scanDecl(t, j, end);
Type type;
std::string name;
if (!d.ok || d.hasArray || !typeAndName(d, true, type, name) || name.empty()) return next;
if (type.base == "bool" && name == "bool") return next;
addAlias(name, type);
return next;
}
void addAlias(const std::string& name, const Type& type) {
if (name == type.base && type.ptr == 0) { // typedef struct X X; -> an opaque struct
if (!m_.structIdx.count(name)) {
StructDef s;
s.name = name;
s.opaque = true;
s.group = group_;
m_.structIdx[name] = m_.structs.size();
m_.structs.push_back(s);
}
return;
}
if (m_.aliases.count(name)) return;
if (type.ptr == 0 && !isBuiltinWord(type.base) && !m_.structIdx.count(type.base) && !m_.aliases.count(type.base) &&
!m_.callbackIdx.count(type.base) && !isExternalName(type.base)) {
// typedef struct Tag Name; for a tag that was never defined: opaque
StructDef s;
s.name = type.base;
s.opaque = true;
s.group = group_;
m_.structIdx[s.name] = m_.structs.size();
m_.structs.push_back(s);
}
m_.aliases[name] = type;
m_.aliasOrder.push_back(name);
m_.aliasGroup[name] = group_;
}
public:
static bool isExternalName(const std::string& n) {
static const std::set<std::string> s = {"size_t", "ptrdiff_t", "intptr_t", "uintptr_t", "int8_t", "int16_t", "int32_t", "int64_t",
"uint8_t", "uint16_t", "uint32_t", "uint64_t", "va_list"};
return s.count(n) != 0;
}
private:
void addStruct(const std::string& name, const std::vector<Tok>& body) {
if (m_.structIdx.count(name) && !m_.structs[m_.structIdx[name]].opaque) return; // first definition wins
StructDef s;
s.name = name;
s.group = group_;
size_t start = 0;
int depth = 0;
for (size_t k = 0; k <= body.size(); ++k) {
const bool end = k == body.size();
if (end || (body[k].text == ";" && depth == 0)) {
if (k > start) fieldStmt(s, body, start, k);
start = k + 1;
} else if (body[k].text == "{" || body[k].text == "(" || body[k].text == "[") ++depth;
else if (body[k].text == "}" || body[k].text == ")" || body[k].text == "]") --depth;
}
const auto it = m_.structIdx.find(name);
if (it != m_.structIdx.end()) m_.structs[it->second] = s; // replaces the opaque forward declaration
else { m_.structIdx[name] = m_.structs.size(); m_.structs.push_back(s); }
}
void fieldStmt(StructDef& s, const std::vector<Tok>& b, size_t from, size_t to) {
const auto parts = splitCommas(b, from, to);
Type base;
for (size_t p = 0; p < parts.size(); ++p) {
const Decl d = scanDecl(b, parts[p].first, parts[p].second);
if (!d.ok || d.hasParen) { s.ok = false; s.why = d.hasParen ? "function pointer or nested aggregate field" : d.why; return; }
Field f;
if (p == 0) {
std::string name;
if (!typeAndName(d, true, base, name) || name.empty()) { s.ok = false; s.why = "cannot read a field declaration"; return; }
f.type = base;
f.name = name;
} else {
if (d.words.size() != 1) { s.ok = false; s.why = "cannot read a field declaration"; return; }
f.type = base;
f.type.ptr = d.ptr;
f.name = d.words[0];
}
f.dims = d.dims;
s.fields.push_back(f);
}
}
void addEnum(const std::string& name, const std::vector<Tok>& body) {
EnumDef e;
e.name = name;
e.group = group_;
if (name == "bool") return;
long long next = 0;
for (const auto& part : splitCommas(body, 0, body.size())) {
if (part.first >= part.second) continue; // trailing comma
if (body[part.first].kind != TK::Ident) { e.ok = false; e.why = "unexpected token in enum"; break; }
EnumItem item;
item.name = body[part.first].text;
if (part.first + 1 < part.second) {
if (body[part.first + 1].text != "=") { e.ok = false; e.why = "unexpected token in enum"; break; }
const std::vector<Tok> expr(body.begin() + static_cast<long>(part.first) + 2, body.begin() + static_cast<long>(part.second));
Val v;
if (!evalConst(expr, v) || v.isFloat) { e.ok = false; e.why = "value of " + item.name + " is not an integer constant"; break; }
next = v.i;
}
item.value = next++;
m_.enumerators[item.name] = item.value;
m_.enumOf[item.name] = name;
e.items.push_back(item);
}
m_.enums.push_back(e);
}
// typedef RET (*NAME)(PARAMS); tokens: i = typedef, j = first token after it, open = the '(' before '*'
void addCallback(size_t /*i*/, size_t j, size_t open, size_t end) {
const std::vector<Tok>& t = *t_;
CallbackDef c;
c.name = t[open + 2].text;
c.group = group_;
const Decl rd = scanDecl(t, j, open);
std::string unused;
if (!rd.ok || rd.hasParen || rd.hasArray || !typeAndName(rd, false, c.ret, unused)) { c.ok = false; c.why = "unreadable return type"; }
size_t po = open + 4;
if (po < end && t[po].text == "(") {
const size_t pc = matchParen(po);
parseParams(po + 1, pc, c.params, nullptr);
for (const Param& p : c.params) if (p.unsupported) { c.ok = false; c.why = p.why; }
} else {
c.ok = false;
c.why = "unreadable parameter list";
}
if (m_.callbackIdx.count(c.name)) return;
m_.callbackIdx[c.name] = m_.callbacks.size();
m_.callbacks.push_back(c);
}
void parseParams(size_t from, size_t to, std::vector<Param>& out, bool* variadic) {
const std::vector<Tok>& t = *t_;
if (from >= to) return;
if (to == from + 1 && t[from].text == "void") return;
for (const auto& part : splitCommas(t, from, to)) {
Param p;
if (part.second == part.first + 1 && t[part.first].text == "...") {
if (variadic) *variadic = true;
continue;
}
const Decl d = scanDecl(t, part.first, part.second);
if (!d.ok || d.hasParen || d.hasArray) {
p.unsupported = true;
p.why = d.hasParen ? "function-pointer parameter" : d.hasArray ? "array parameter" : d.why;
} else if (!typeAndName(d, true, p.type, p.name)) {
p.unsupported = true;
p.why = "unreadable parameter";
}
out.push_back(p);
}
}
size_t functionStmt(size_t i) {
const std::vector<Tok>& t = *t_;
const size_t n = t.size();
const bool body = t[i].text == "RMAPI";
size_t open = i + 1;
while (open < n && t[open].text != "(" && t[open].text != ";") ++open;
if (open >= n || t[open].text != "(" || open == i + 1 || t[open - 1].kind != TK::Ident) return skipStmt(i);
FuncDef f;
f.name = t[open - 1].text;
f.group = group_;
f.hasBody = body;
const Decl rd = scanDecl(t, i + 1, open - 1);
std::string unused;
if (!rd.ok || rd.hasParen || rd.hasArray || !typeAndName(rd, false, f.ret, unused)) {
f.retUnsupported = true;
f.retWhy = "unreadable return type";
}
const size_t close = matchParen(open);
parseParams(open + 1, close, f.params, &f.variadic);
size_t after = close + 1;
if (body) {
while (after < n && t[after].text != "{" && t[after].text != ";") ++after;
if (after < n && t[after].text == "{") after = matchBrace(after) + 1;
else if (after < n) ++after;
} else {
if (after < n && t[after].text == ";") ++after;
}
const size_t last = std::min(close + 1, n - 1);
for (size_t probe : {last, close}) {
const auto it = docs_->find(t[probe].line);
if (it != docs_->end()) { f.doc = it->second; break; }
}
m_.funcs.push_back(f);
return after;
}
};
// ---------------------------------------------------------------------------------------------------------
// Types: what is wrappable
// ---------------------------------------------------------------------------------------------------------
enum class Cat { Void, Builtin, ExtBuiltin, Mapped, Struct, Opaque, Enum, Callback, Unknown };
struct Res {
Type t; // alias resolved
Cat cat = Cat::Unknown;
std::string why;
bool ok() const { return cat != Cat::Unknown; }
};
class Types {
public:
explicit Types(ModelData& m) : m_(m) { decideMappings(); decideStructs(); }
// raylib name -> engine name for the types that share the engine's layout
std::map<std::string, std::string> mapped;
bool matrixMapped = false; // by-value Matrix <-> engine Mat4
std::vector<int> matrixIndex; // field i of struct Matrix is element matrixIndex[i] of a Mat4
Res classify(Type t) const {
Res r;
for (int guard = 0; guard < 16; ++guard) {
if (mapped.count(t.base)) break;
const auto it = m_.aliases.find(t.base);
if (it == m_.aliases.end()) break;
t.base = it->second.base;
t.ptr += it->second.ptr;
t.constBase = t.constBase || it->second.constBase;
}
r.t = t;
const std::string& b = t.base;
if (b == "void") { r.cat = t.ptr == 0 ? Cat::Void : Cat::Builtin; return r; }
if (isBuiltin(b)) { r.cat = Cat::Builtin; return r; }
if (Parser::isExternalName(b)) { r.cat = Cat::ExtBuiltin; return r; }
if (mapped.count(b)) { r.cat = Cat::Mapped; return r; }
if (m_.callbackIdx.count(b)) {
const CallbackDef& c = m_.callbacks[m_.callbackIdx.at(b)];
if (t.ptr != 0) r.why = "pointer to function-pointer typedef " + b;
else if (!c.ok) r.why = "callback " + b + ": " + c.why;
else if (!callbackSupported(c)) r.why = "callback " + b + " uses types other than plain C types";
else r.cat = Cat::Callback;
return r;
}
const auto s = m_.structIdx.find(b);
if (s != m_.structIdx.end()) {
const StructDef& sd = m_.structs[s->second];
if (sd.opaque) {
if (t.ptr == 0) r.why = "opaque type " + b + " by value";
else r.cat = Cat::Opaque;
} else if (!sd.ok) r.why = "struct " + b + ": " + sd.why;
else r.cat = Cat::Struct;
return r;
}
for (const EnumDef& e : m_.enums)
if (e.name == b && !b.empty()) {
if (e.ok) r.cat = Cat::Enum;
else r.why = "enum " + b + ": " + e.why;
return r;
}
r.why = "unknown type " + b;
return r;
}
// A struct is mirrored iff every field is a type we know. (Computed once, in declaration order.)
bool structOk(const std::string& name) const {
const auto it = m_.structIdx.find(name);
return it != m_.structIdx.end() && !m_.structs[it->second].opaque && m_.structs[it->second].ok;
}
static bool isBuiltin(const std::string& b) {
static const std::set<std::string> s = {"char", "signed char", "unsigned char", "short", "unsigned short", "int", "unsigned int", "long",
"unsigned long", "long long", "unsigned long long", "float", "double", "bool"};
return s.count(b) != 0;
}
bool callbackSupported(const CallbackDef& c) const {
auto plain = [&](const Type& t0) {
const Res r = classify(t0);
return r.cat == Cat::Void || r.cat == Cat::Builtin || r.cat == Cat::ExtBuiltin;
};
if (!plain(c.ret)) return false;
for (const Param& p : c.params) if (p.unsupported || !plain(p.type)) return false;
return true;
}
private:
ModelData& m_;
const StructDef* find(const std::string& n) const {
const auto it = m_.structIdx.find(n);
return it == m_.structIdx.end() ? nullptr : &m_.structs[it->second];
}
bool shape(const std::string& structName, const std::vector<std::pair<std::string, std::string>>& fields) const {
const StructDef* s = find(structName);
if (!s || s->opaque || !s->ok || s->fields.size() != fields.size()) return false;
for (size_t i = 0; i < fields.size(); ++i) {
const Field& f = s->fields[i];
if (!f.dims.empty() || f.type.ptr != 0 || f.name != fields[i].second) return false;
std::string base = f.type.base;
const auto a = m_.aliases.find(base);
if (a != m_.aliases.end() && a->second.ptr == 0) base = a->second.base;
if (base != fields[i].first) return false;
}
return true;
}
void decideMappings() {
if (shape("Vector2", {{"float", "x"}, {"float", "y"}})) mapped["Vector2"] = "Vec2";
if (shape("Vector3", {{"float", "x"}, {"float", "y"}, {"float", "z"}})) mapped["Vector3"] = "Vec3";
if (shape("Vector4", {{"float", "x"}, {"float", "y"}, {"float", "z"}, {"float", "w"}})) {
const auto q = m_.aliases.find("Quaternion");
if (q != m_.aliases.end() && q->second.base == "Vector4" && q->second.ptr == 0) mapped["Quaternion"] = "Quat";
}
if (shape("Color", {{"unsigned char", "r"}, {"unsigned char", "g"}, {"unsigned char", "b"}, {"unsigned char", "a"}})) mapped["Color"] = "Rgba";
if (mapped.count("Vector3") && shape("BoundingBox", {{"Vector3", "min"}, {"Vector3", "max"}})) mapped["BoundingBox"] = "Aabb";
if (const StructDef* s = find("Matrix"); s && !s->opaque && s->ok && s->fields.size() == 16) {
std::vector<int> idx;
std::set<int> seen;
bool good = true;
for (const Field& f : s->fields) {
if (f.type.base != "float" || f.type.ptr != 0 || !f.dims.empty() || f.name.size() < 2 || f.name[0] != 'm') { good = false; break; }
char* end = nullptr;
const long v = std::strtol(f.name.c_str() + 1, &end, 10);
if (!end || *end || v < 0 || v > 15 || !seen.insert(static_cast<int>(v)).second) { good = false; break; }
idx.push_back(static_cast<int>(v));
}
if (good) { matrixMapped = true; matrixIndex = idx; }
}
}
void decideStructs() {
for (StructDef& s : m_.structs) {
if (s.opaque || !s.ok) continue;
for (const Field& f : s.fields) {
const Res r = classify(f.type);
bool good = false;
switch (r.cat) {
case Cat::Builtin: case Cat::Mapped: case Cat::Struct: case Cat::Enum: good = true; break;
case Cat::ExtBuiltin: good = f.type.base != "va_list"; break;
case Cat::Opaque: good = r.t.ptr > 0; break;
case Cat::Void: good = false; break;
case Cat::Callback: good = false; break;
case Cat::Unknown: break;
}
if (r.cat == Cat::Builtin && r.t.base == "void" && r.t.ptr == 0) good = false;
if (!good) {
s.ok = false;
s.why = "field " + f.name + ": " + (r.why.empty() ? "type not supported" : r.why);
break;
}
}
}
}
};
} // namespace gen
+126
View File
@@ -0,0 +1,126 @@
// The slice of desktop OpenGL 3.3 core the shim needs, declared by hand (no GL headers on this machine) and loaded
// through eglGetProcAddress. Not a general-purpose loader.
#pragma once
#include <cstddef>
typedef unsigned int GLenum;
typedef unsigned char GLboolean;
typedef unsigned int GLbitfield;
typedef int GLint;
typedef int GLsizei;
typedef unsigned char GLubyte;
typedef unsigned int GLuint;
typedef float GLfloat;
typedef double GLdouble;
typedef char GLchar;
typedef std::ptrdiff_t GLsizeiptr;
typedef std::ptrdiff_t GLintptr;
constexpr GLenum GL_NO_ERROR = 0;
constexpr GLenum GL_FALSE = 0, GL_TRUE = 1;
constexpr GLenum GL_DEPTH_TEST = 0x0B71, GL_BLEND = 0x0BE2, GL_CULL_FACE = 0x0B44, GL_LEQUAL = 0x0203;
constexpr GLenum GL_SRC_ALPHA = 0x0302, GL_ONE_MINUS_SRC_ALPHA = 0x0303, GL_BACK = 0x0405, GL_FRONT = 0x0404, GL_CCW = 0x0901;
constexpr GLbitfield GL_COLOR_BUFFER_BIT = 0x4000, GL_DEPTH_BUFFER_BIT = 0x0100, GL_STENCIL_BUFFER_BIT = 0x0400;
constexpr GLenum GL_TEXTURE_2D = 0x0DE1, GL_TEXTURE0 = 0x84C0, GL_RGBA = 0x1908, GL_RGBA8 = 0x8058;
constexpr GLenum GL_UNSIGNED_BYTE = 0x1401, GL_FLOAT = 0x1406;
constexpr GLenum GL_TEXTURE_MIN_FILTER = 0x2801, GL_TEXTURE_MAG_FILTER = 0x2800, GL_TEXTURE_WRAP_S = 0x2802, GL_TEXTURE_WRAP_T = 0x2803;
constexpr GLenum GL_NEAREST = 0x2600, GL_LINEAR = 0x2601, GL_REPEAT = 0x2901, GL_CLAMP_TO_EDGE = 0x812F, GL_LINEAR_MIPMAP_LINEAR = 0x2703;
constexpr GLenum GL_UNPACK_ALIGNMENT = 0x0CF5, GL_PACK_ALIGNMENT = 0x0D05;
constexpr GLenum GL_FRAMEBUFFER = 0x8D40, GL_RENDERBUFFER = 0x8D41, GL_COLOR_ATTACHMENT0 = 0x8CE0, GL_DEPTH_ATTACHMENT = 0x8D00;
constexpr GLenum GL_DEPTH_COMPONENT24 = 0x81A6, GL_FRAMEBUFFER_COMPLETE = 0x8CD5;
constexpr GLenum GL_VERTEX_SHADER = 0x8B31, GL_FRAGMENT_SHADER = 0x8B30;
constexpr GLenum GL_COMPILE_STATUS = 0x8B81, GL_LINK_STATUS = 0x8B82, GL_INFO_LOG_LENGTH = 0x8B84;
constexpr GLenum GL_ARRAY_BUFFER = 0x8892, GL_STATIC_DRAW = 0x88E4, GL_DYNAMIC_DRAW = 0x88E8, GL_TRIANGLES = 0x0004;
constexpr GLenum GL_VERSION = 0x1F02, GL_RENDERER = 0x1F01, GL_SHADING_LANGUAGE_VERSION = 0x8B8C;
constexpr GLenum GL_MAX_TEXTURE_SIZE = 0x0D33, GL_MAX_VERTEX_ATTRIBS = 0x8869, GL_MAX_TEXTURE_IMAGE_UNITS = 0x8872;
constexpr GLenum GL_DEBUG_OUTPUT = 0x92E0, GL_DEBUG_OUTPUT_SYNCHRONOUS = 0x8242;
constexpr GLenum GL_DEBUG_SEVERITY_HIGH = 0x9146, GL_DEBUG_SEVERITY_MEDIUM = 0x9147, GL_DEBUG_SEVERITY_LOW = 0x9148, GL_DEBUG_SEVERITY_NOTIFICATION = 0x826B;
constexpr GLenum GL_DEPTH_FUNC = 0x0B74;
constexpr GLenum GL_CURRENT_PROGRAM = 0x8B8D, GL_FRAMEBUFFER_BINDING = 0x8CA6, GL_VERTEX_ARRAY_BINDING = 0x85B5;
constexpr GLenum GL_ACTIVE_TEXTURE = 0x84E0, GL_TEXTURE_BINDING_2D = 0x8069, GL_CULL_FACE_MODE = 0x0B45;
typedef void (*GLDEBUGPROC)(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar* message, const void* userParam);
// X(return type, name, parameter list)
#define GL_FUNCTIONS(X) \
X(const GLubyte*, glGetString, (GLenum)) \
X(GLenum, glGetError, (void)) \
X(void, glGetIntegerv, (GLenum, GLint*)) \
X(void, glEnable, (GLenum)) \
X(void, glDisable, (GLenum)) \
X(GLboolean, glIsEnabled, (GLenum)) \
X(void, glDepthFunc, (GLenum)) \
X(void, glBlendFunc, (GLenum, GLenum)) \
X(void, glCullFace, (GLenum)) \
X(void, glFrontFace, (GLenum)) \
X(void, glClearColor, (GLfloat, GLfloat, GLfloat, GLfloat)) \
X(void, glClearDepth, (GLdouble)) \
X(void, glClear, (GLbitfield)) \
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, glFinish, (void)) \
X(void, glPixelStorei, (GLenum, GLint)) \
X(void, glReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*)) \
X(void, glGenTextures, (GLsizei, GLuint*)) \
X(void, glDeleteTextures, (GLsizei, const GLuint*)) \
X(void, glBindTexture, (GLenum, GLuint)) \
X(void, glActiveTexture, (GLenum)) \
X(void, glTexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*)) \
X(void, glTexSubImage2D, (GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, const void*)) \
X(void, glTexParameteri, (GLenum, GLenum, GLint)) \
X(void, glGenerateMipmap, (GLenum)) \
X(void, glGenFramebuffers, (GLsizei, GLuint*)) \
X(void, glDeleteFramebuffers, (GLsizei, const GLuint*)) \
X(void, glBindFramebuffer, (GLenum, GLuint)) \
X(void, glFramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \
X(void, glFramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint)) \
X(GLenum, glCheckFramebufferStatus, (GLenum)) \
X(void, glGenRenderbuffers, (GLsizei, GLuint*)) \
X(void, glDeleteRenderbuffers, (GLsizei, const GLuint*)) \
X(void, glBindRenderbuffer, (GLenum, GLuint)) \
X(void, glRenderbufferStorage, (GLenum, GLenum, GLsizei, GLsizei)) \
X(GLuint, glCreateShader, (GLenum)) \
X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \
X(void, glCompileShader, (GLuint)) \
X(void, glGetShaderiv, (GLuint, GLenum, GLint*)) \
X(void, glGetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, glDeleteShader, (GLuint)) \
X(GLuint, glCreateProgram, (void)) \
X(void, glAttachShader, (GLuint, GLuint)) \
X(void, glDetachShader, (GLuint, GLuint)) \
X(void, glBindAttribLocation, (GLuint, GLuint, const GLchar*)) \
X(void, glLinkProgram, (GLuint)) \
X(void, glGetProgramiv, (GLuint, GLenum, GLint*)) \
X(void, glGetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, glDeleteProgram, (GLuint)) \
X(void, glUseProgram, (GLuint)) \
X(GLint, glGetUniformLocation, (GLuint, const GLchar*)) \
X(void, glUniform1fv, (GLint, GLsizei, const GLfloat*)) \
X(void, glUniform2fv, (GLint, GLsizei, const GLfloat*)) \
X(void, glUniform3fv, (GLint, GLsizei, const GLfloat*)) \
X(void, glUniform4fv, (GLint, GLsizei, const GLfloat*)) \
X(void, glUniform1iv, (GLint, GLsizei, const GLint*)) \
X(void, glUniform2iv, (GLint, GLsizei, const GLint*)) \
X(void, glUniform3iv, (GLint, GLsizei, const GLint*)) \
X(void, glUniform4iv, (GLint, GLsizei, const GLint*)) \
X(void, glUniformMatrix4fv, (GLint, GLsizei, GLboolean, const GLfloat*)) \
X(void, glGenVertexArrays, (GLsizei, GLuint*)) \
X(void, glBindVertexArray, (GLuint)) \
X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \
X(void, glGenBuffers, (GLsizei, GLuint*)) \
X(void, glBindBuffer, (GLenum, GLuint)) \
X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
X(void, glDeleteBuffers, (GLsizei, const GLuint*)) \
X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*)) \
X(void, glEnableVertexAttribArray, (GLuint)) \
X(void, glDisableVertexAttribArray, (GLuint)) \
X(void, glVertexAttribDivisor, (GLuint, GLuint)) \
X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \
X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \
X(void, glDebugMessageCallback, (GLDEBUGPROC, const void*))
namespace gl {
#define X(ret, name, args) extern ret(*name) args;
GL_FUNCTIONS(X)
#undef X
} // namespace gl
+738
View File
@@ -0,0 +1,738 @@
// VERIFICATION HARNESS ONLY. A software-OpenGL implementation of the slice of raylib/rlgl that the engine's renderer
// (src/render.cpp) and run loop (src/app.cpp) call, so both can be compiled UNMODIFIED against the stand-in headers and
// run on Mesa llvmpipe (surfaceless EGL, desktop GL 3.3 core). Every function follows what raylib 5.5/6.0's own source
// does at the GL level (the state it sets, the textures it binds, the order it does it in) - that is what makes the
// frames it draws meaningful. Everything else raylib offers is simply not here: the linker tells us if the engine
// starts depending on more.
#include "gl_min.h"
#include "raylib.h"
#include "rlgl.h"
#include "shim_api.h"
#include <dlfcn.h>
#include <png.h>
#include <algorithm>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace gl {
#define X(ret, name, args) ret(*name) args = nullptr;
GL_FUNCTIONS(X)
#undef X
} // namespace gl
namespace {
// ---- EGL (surfaceless Mesa) ------------------------------------------------------------------------------------------
typedef void* EGLDisplay;
typedef void* EGLConfig;
typedef void* EGLContext;
typedef void* EGLSurface;
typedef int EGLint;
typedef unsigned int EGLBoolean;
typedef unsigned int EGLenum;
constexpr EGLint EGL_NONE = 0x3038, EGL_SURFACE_TYPE = 0x3033, EGL_RENDERABLE_TYPE = 0x3040, EGL_OPENGL_BIT = 0x0008;
constexpr EGLenum EGL_OPENGL_API = 0x30A2, EGL_PLATFORM_SURFACELESS_MESA = 0x31DD;
constexpr EGLint EGL_CONTEXT_MAJOR_VERSION = 0x3098, EGL_CONTEXT_MINOR_VERSION = 0x30FB;
constexpr EGLint EGL_CONTEXT_OPENGL_PROFILE_MASK = 0x30FD, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT = 1;
constexpr EGLint EGL_CONTEXT_OPENGL_DEBUG = 0x31B0, EGL_TRUE = 1;
// ---- shared state ------------------------------------------------------------------------------------------------------
shim::Report gReport;
shim::Frame gScreenshot;
struct Egl {
void* lib = nullptr;
void* (*getProc)(const char*) = nullptr;
EGLDisplay dpy = nullptr;
EGLContext ctx = nullptr;
bool ok = false;
} gEgl;
struct Window {
bool ready = false;
int width = 0, height = 0;
unsigned screenFbo = 0, screenColor = 0, screenDepth = 0;
unsigned defaultTexture = 0, defaultShader = 0;
unsigned currentFbo = 0; // what BeginTextureMode/EndTextureMode toggle
double cullNear = 0.05, cullFar = 4000.0;
bool in3D = false;
unsigned configFlags = 0;
double time = 0;
} gWin;
constexpr int kMaxShaderLocations = 32;
void note(const std::string& s) {
gReport.log.push_back(s);
std::fprintf(stderr, "[shim] %s\n", s.c_str());
}
void checkGl(const char* where) {
if (!gl::glGetError) return;
for (int n = 0; n < 8; ++n) {
const GLenum e = gl::glGetError();
if (e == GL_NO_ERROR) break;
++gReport.glErrors;
char buf[160];
std::snprintf(buf, sizeof buf, "GL error 0x%04x after %s", e, where);
note(buf);
}
}
void debugCallback(GLenum, GLenum type, GLuint, GLenum severity, GLsizei, const GLchar* message, const void*) {
if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) return;
const bool isError = type == 0x824C /*ERROR*/ || type == 0x824E /*UNDEFINED_BEHAVIOR*/;
if (isError) ++gReport.glErrors;
char buf[640];
std::snprintf(buf, sizeof buf, "GL %s: %s", isError ? "ERROR" : "message", message);
note(buf);
}
bool loadGl() {
if (gEgl.ok) return true;
gEgl.lib = dlopen("libEGL.so.1", RTLD_NOW | RTLD_GLOBAL);
if (!gEgl.lib) {
note("cannot load libEGL.so.1");
return false;
}
gEgl.getProc = reinterpret_cast<void* (*)(const char*)>(dlsym(gEgl.lib, "eglGetProcAddress"));
auto getPlatformDisplay = reinterpret_cast<EGLDisplay (*)(EGLenum, void*, const EGLint*)>(gEgl.getProc("eglGetPlatformDisplay"));
auto eglInitialize = reinterpret_cast<EGLBoolean (*)(EGLDisplay, EGLint*, EGLint*)>(dlsym(gEgl.lib, "eglInitialize"));
auto eglBindAPI = reinterpret_cast<EGLBoolean (*)(EGLenum)>(dlsym(gEgl.lib, "eglBindAPI"));
auto eglChooseConfig = reinterpret_cast<EGLBoolean (*)(EGLDisplay, const EGLint*, EGLConfig*, EGLint, EGLint*)>(dlsym(gEgl.lib, "eglChooseConfig"));
auto eglCreateContext = reinterpret_cast<EGLContext (*)(EGLDisplay, EGLConfig, EGLContext, const EGLint*)>(dlsym(gEgl.lib, "eglCreateContext"));
auto eglMakeCurrent = reinterpret_cast<EGLBoolean (*)(EGLDisplay, EGLSurface, EGLSurface, EGLContext)>(dlsym(gEgl.lib, "eglMakeCurrent"));
gEgl.dpy = getPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, nullptr, nullptr);
EGLint maj = 0, min = 0;
if (!gEgl.dpy || !eglInitialize(gEgl.dpy, &maj, &min) || !eglBindAPI(EGL_OPENGL_API)) {
note("EGL display/initialise failed");
return false;
}
const EGLint cfgAttr[] = {EGL_SURFACE_TYPE, 0, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE};
EGLConfig cfg = nullptr;
EGLint n = 0;
if (!eglChooseConfig(gEgl.dpy, cfgAttr, &cfg, 1, &n) || n == 0) {
note("EGL: no config");
return false;
}
const EGLint ctxAttr[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 3,
EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
EGL_CONTEXT_OPENGL_DEBUG, EGL_TRUE, EGL_NONE};
gEgl.ctx = eglCreateContext(gEgl.dpy, cfg, nullptr, ctxAttr);
if (!gEgl.ctx || !eglMakeCurrent(gEgl.dpy, nullptr, nullptr, gEgl.ctx)) {
note("EGL: no core 3.3 context");
return false;
}
#define X(ret, name, args) \
gl::name = reinterpret_cast<ret(*) args>(gEgl.getProc(#name)); \
if (!gl::name) { \
note(std::string("missing GL function ") + #name); \
return false; \
}
GL_FUNCTIONS(X)
#undef X
gReport.glVersion = reinterpret_cast<const char*>(gl::glGetString(GL_VERSION));
gReport.glRenderer = reinterpret_cast<const char*>(gl::glGetString(GL_RENDERER));
gReport.glsl = reinterpret_cast<const char*>(gl::glGetString(GL_SHADING_LANGUAGE_VERSION));
gl::glEnable(GL_DEBUG_OUTPUT);
gl::glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
gl::glDebugMessageCallback(debugCallback, nullptr);
gEgl.ok = true;
return true;
}
unsigned loadTextureRgba8(const void* data, int w, int h) {
GLuint id = 0;
gl::glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
gl::glGenTextures(1, &id);
gl::glBindTexture(GL_TEXTURE_2D, id);
gl::glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
gl::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); // raylib's defaults for a new texture
gl::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
gl::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl::glBindTexture(GL_TEXTURE_2D, 0);
return id;
}
unsigned compileStage(const char* code, GLenum type) {
const GLuint s = gl::glCreateShader(type);
gl::glShaderSource(s, 1, &code, nullptr);
gl::glCompileShader(s);
GLint ok = 0;
gl::glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (ok == GL_FALSE) {
++gReport.shaderFailures;
GLint len = 0;
gl::glGetShaderiv(s, GL_INFO_LOG_LENGTH, &len);
std::string log(static_cast<std::size_t>(len > 0 ? len : 1), '\0');
gl::glGetShaderInfoLog(s, len, nullptr, log.data());
note(std::string("SHADER: failed to compile ") + (type == GL_VERTEX_SHADER ? "vertex" : "fragment") + " shader:\n" + log);
gl::glDeleteShader(s);
return 0;
}
return s;
}
unsigned linkProgram(unsigned vs, unsigned fs) {
const GLuint p = gl::glCreateProgram();
gl::glAttachShader(p, vs);
gl::glAttachShader(p, fs);
// raylib binds its default attribute names before linking (explicit layout(location) in a shader wins).
gl::glBindAttribLocation(p, 0, "vertexPosition");
gl::glBindAttribLocation(p, 1, "vertexTexCoord");
gl::glBindAttribLocation(p, 2, "vertexNormal");
gl::glBindAttribLocation(p, 3, "vertexColor");
gl::glBindAttribLocation(p, 4, "vertexTangent");
gl::glBindAttribLocation(p, 5, "vertexTexCoord2");
gl::glLinkProgram(p);
GLint ok = 0;
gl::glGetProgramiv(p, GL_LINK_STATUS, &ok);
if (ok == GL_FALSE) {
++gReport.shaderFailures;
GLint len = 0;
gl::glGetProgramiv(p, GL_INFO_LOG_LENGTH, &len);
std::string log(static_cast<std::size_t>(len > 0 ? len : 1), '\0');
gl::glGetProgramInfoLog(p, len, nullptr, log.data());
note("SHADER: failed to link program:\n" + log);
gl::glDeleteProgram(p);
return 0;
}
return p;
}
void setupScreen(int w, int h) {
gWin.width = w;
gWin.height = h;
gl::glGenFramebuffers(1, &gWin.screenFbo);
gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.screenFbo);
gWin.screenColor = loadTextureRgba8(nullptr, w, h);
gl::glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gWin.screenColor, 0);
gl::glGenRenderbuffers(1, &gWin.screenDepth);
gl::glBindRenderbuffer(GL_RENDERBUFFER, gWin.screenDepth);
gl::glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, w, h);
gl::glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, gWin.screenDepth);
if (gl::glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) note("screen framebuffer incomplete");
gWin.currentFbo = gWin.screenFbo;
gl::glViewport(0, 0, w, h);
}
// rlglInit()'s default state.
void defaultState() {
gl::glDepthFunc(GL_LEQUAL);
gl::glDisable(GL_DEPTH_TEST);
gl::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
gl::glEnable(GL_BLEND);
gl::glCullFace(GL_BACK);
gl::glFrontFace(GL_CCW);
gl::glEnable(GL_CULL_FACE);
gl::glClearColor(0, 0, 0, 1);
gl::glClearDepth(1.0);
gl::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
shim::Frame readFramebuffer(unsigned fbo, int w, int h) {
shim::Frame f;
f.width = w;
f.height = h;
std::vector<std::uint8_t> raw(static_cast<std::size_t>(w) * static_cast<std::size_t>(h) * 4);
GLint prev = 0;
gl::glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev);
gl::glBindFramebuffer(GL_FRAMEBUFFER, fbo);
gl::glPixelStorei(GL_PACK_ALIGNMENT, 1);
gl::glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, raw.data());
gl::glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(prev));
f.rgba.resize(raw.size());
for (int y = 0; y < h; ++y) { // flip: glReadPixels starts at the bottom row
const std::uint8_t* src = &raw[static_cast<std::size_t>(h - 1 - y) * static_cast<std::size_t>(w) * 4];
std::uint8_t* dst = &f.rgba[static_cast<std::size_t>(y) * static_cast<std::size_t>(w) * 4];
std::memcpy(dst, src, static_cast<std::size_t>(w) * 4);
for (int x = 0; x < w; ++x) dst[x * 4 + 3] = 255;
}
return f;
}
} // namespace
// ============================================================================================================================
// shim_api.h
// ============================================================================================================================
namespace shim {
Report& report() { return gReport; }
void resetCounters() {
gReport.drawCalls = 0;
gReport.instancesDrawn = 0;
gReport.texts.clear();
}
const Frame& lastScreenshot() { return gScreenshot; }
Frame readScreen() { return readFramebuffer(gWin.screenFbo, gWin.width, gWin.height); }
bool writePng(const std::string& path, const Frame& f) {
if (!f.valid()) return false;
png_image img{};
img.version = PNG_IMAGE_VERSION;
img.width = static_cast<png_uint_32>(f.width);
img.height = static_cast<png_uint_32>(f.height);
img.format = PNG_FORMAT_RGBA;
return png_image_write_to_file(&img, path.c_str(), 0, f.rgba.data(), 0, nullptr) != 0;
}
} // namespace shim
// ============================================================================================================================
// raylib core
// ============================================================================================================================
extern "C" {
void SetConfigFlags(unsigned int flags) { gWin.configFlags = flags; }
void InitWindow(int width, int height, const char*) {
if (!loadGl()) return;
setupScreen(width, height);
defaultState();
const unsigned char white[4] = {255, 255, 255, 255};
gWin.defaultTexture = loadTextureRgba8(white, 1, 1);
gWin.defaultShader = 1; // only ever compared against, never used by the renderer
gWin.cullNear = 0.05;
gWin.cullFar = 4000.0;
gWin.time = 0;
gWin.ready = true;
gReport.framesPresented = 0;
}
bool IsWindowReady(void) { return gWin.ready; }
void CloseWindow(void) {
if (!gWin.ready) return;
gl::glBindFramebuffer(GL_FRAMEBUFFER, 0);
gl::glDeleteFramebuffers(1, &gWin.screenFbo);
gl::glDeleteRenderbuffers(1, &gWin.screenDepth);
gl::glDeleteTextures(1, &gWin.screenColor);
gl::glDeleteTextures(1, &gWin.defaultTexture);
checkGl("CloseWindow");
gWin.ready = false;
}
void SetTargetFPS(int) {}
void SetExitKey(int) {}
bool WindowShouldClose(void) { return false; }
int GetScreenWidth(void) { return gWin.width; }
int GetScreenHeight(void) { return gWin.height; }
float GetFrameTime(void) { return 1.0f / 60.0f; }
double GetTime(void) { return gWin.time; }
int GetFPS(void) { return 60; }
void BeginDrawing(void) {
gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.screenFbo);
gl::glViewport(0, 0, gWin.width, gWin.height);
}
void EndDrawing(void) {
gl::glFinish();
checkGl("EndDrawing");
++gReport.framesPresented;
gWin.time += 1.0 / 60.0;
}
void ClearBackground(Color c) {
gl::glClearColor(static_cast<float>(c.r) / 255.0f, static_cast<float>(c.g) / 255.0f, static_cast<float>(c.b) / 255.0f,
static_cast<float>(c.a) / 255.0f);
gl::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
}
void BeginMode3D(Camera3D) {
gWin.in3D = true;
gl::glEnable(GL_DEPTH_TEST); // rlEnableDepthTest() at the end of BeginMode3D
}
void EndMode3D(void) {
gWin.in3D = false;
gl::glDisable(GL_DEPTH_TEST);
}
void BeginTextureMode(RenderTexture2D target) {
gl::glBindFramebuffer(GL_FRAMEBUFFER, target.id);
gl::glViewport(0, 0, target.texture.width, target.texture.height);
gWin.currentFbo = target.id;
}
void EndTextureMode(void) {
gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.screenFbo);
gl::glViewport(0, 0, gWin.width, gWin.height);
gWin.currentFbo = gWin.screenFbo;
}
// ---- shaders ------------------------------------------------------------------------------------------------------------
Shader LoadShaderFromMemory(const char* vsCode, const char* fsCode) {
Shader shader{};
const unsigned vs = compileStage(vsCode, GL_VERTEX_SHADER);
const unsigned fs = compileStage(fsCode, GL_FRAGMENT_SHADER);
unsigned program = 0;
if (vs && fs) program = linkProgram(vs, fs);
if (vs) gl::glDeleteShader(vs);
if (fs) gl::glDeleteShader(fs);
shader.id = program;
if (program > 0) {
shader.locs = static_cast<int*>(std::calloc(kMaxShaderLocations, sizeof(int)));
for (int i = 0; i < kMaxShaderLocations; ++i) shader.locs[i] = -1;
}
checkGl("LoadShaderFromMemory");
return shader;
}
bool IsShaderValid(Shader shader) { return shader.id > 0 && shader.locs != nullptr; }
int GetShaderLocation(Shader shader, const char* uniformName) { return gl::glGetUniformLocation(shader.id, uniformName); }
void UnloadShader(Shader shader) {
if (shader.id != gWin.defaultShader) {
gl::glDeleteProgram(shader.id);
std::free(shader.locs);
}
}
// ---- textures / render textures -----------------------------------------------------------------------------------------
Texture2D LoadTextureFromImage(Image image) {
Texture2D t{};
if (image.data == nullptr || image.format != PIXELFORMAT_UNCOMPRESSED_R8G8B8A8) {
note("LoadTextureFromImage: only RGBA8 images are supported by the shim");
return t;
}
t.id = loadTextureRgba8(image.data, image.width, image.height);
t.width = image.width;
t.height = image.height;
t.mipmaps = 1;
t.format = image.format;
checkGl("LoadTextureFromImage");
return t;
}
void UnloadTexture(Texture2D texture) {
if (texture.id > 0) gl::glDeleteTextures(1, &texture.id);
}
void UpdateTexture(Texture2D texture, const void* pixels) {
gl::glBindTexture(GL_TEXTURE_2D, texture.id);
gl::glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, texture.width, texture.height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
gl::glBindTexture(GL_TEXTURE_2D, 0);
checkGl("UpdateTexture");
}
void UpdateTextureRec(Texture2D texture, Rectangle rec, const void* pixels) {
gl::glBindTexture(GL_TEXTURE_2D, texture.id);
gl::glTexSubImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(rec.x), static_cast<GLint>(rec.y), static_cast<GLsizei>(rec.width), static_cast<GLsizei>(rec.height),
GL_RGBA, GL_UNSIGNED_BYTE, pixels);
gl::glBindTexture(GL_TEXTURE_2D, 0);
checkGl("UpdateTextureRec");
}
// raylib's SetTextureFilter: point and bilinear set both filters; trilinear needs mipmaps (it falls back to bilinear without);
// the anisotropic modes only set the anisotropy, which this shim does not model.
void SetTextureFilter(Texture2D texture, int filter) {
if (filter == TEXTURE_FILTER_POINT) {
rlTextureParameters(texture.id, RL_TEXTURE_MIN_FILTER, GL_NEAREST);
rlTextureParameters(texture.id, RL_TEXTURE_MAG_FILTER, GL_NEAREST);
} else if (filter == TEXTURE_FILTER_BILINEAR || (filter == TEXTURE_FILTER_TRILINEAR && texture.mipmaps <= 1)) {
rlTextureParameters(texture.id, RL_TEXTURE_MIN_FILTER, GL_LINEAR);
rlTextureParameters(texture.id, RL_TEXTURE_MAG_FILTER, GL_LINEAR);
} else if (filter == TEXTURE_FILTER_TRILINEAR) {
rlTextureParameters(texture.id, RL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
rlTextureParameters(texture.id, RL_TEXTURE_MAG_FILTER, GL_LINEAR);
}
}
void GenTextureMipmaps(Texture2D* texture) {
gl::glBindTexture(GL_TEXTURE_2D, texture->id);
gl::glGenerateMipmap(GL_TEXTURE_2D);
gl::glBindTexture(GL_TEXTURE_2D, 0);
int levels = 1;
for (int side = std::max(texture->width, texture->height); side > 1; side >>= 1) ++levels;
texture->mipmaps = levels;
checkGl("GenTextureMipmaps");
}
// Image files: PNG only (libpng's simplified API), always decoded to RGBA8. Enough to test the engine's whole
// path from a file on disk to a pixel on screen; the real raylib reads many more formats.
Image LoadImage(const char* fileName) {
Image img{};
png_image png;
std::memset(&png, 0, sizeof png);
png.version = PNG_IMAGE_VERSION;
if (!png_image_begin_read_from_file(&png, fileName)) return img;
png.format = PNG_FORMAT_RGBA;
void* data = std::malloc(PNG_IMAGE_SIZE(png));
if (data == nullptr || !png_image_finish_read(&png, nullptr, data, 0, nullptr)) {
std::free(data);
png_image_free(&png);
return img;
}
img.data = data;
img.width = static_cast<int>(png.width);
img.height = static_cast<int>(png.height);
img.mipmaps = 1;
img.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8;
return img;
}
bool IsImageValid(Image image) { return image.data != nullptr && image.width > 0 && image.height > 0; }
void ImageFormat(Image*, int) {} // LoadImage above already returns RGBA8
void SetTextureWrap(Texture2D texture, int wrap) {
const GLint w = wrap == TEXTURE_WRAP_CLAMP ? GL_CLAMP_TO_EDGE : GL_REPEAT;
rlTextureParameters(texture.id, RL_TEXTURE_WRAP_S, w);
rlTextureParameters(texture.id, RL_TEXTURE_WRAP_T, w);
}
RenderTexture2D LoadRenderTexture(int width, int height) {
RenderTexture2D target{};
GLuint fbo = 0;
gl::glGenFramebuffers(1, &fbo);
target.id = fbo;
gl::glBindFramebuffer(GL_FRAMEBUFFER, fbo);
target.texture.id = loadTextureRgba8(nullptr, width, height);
target.texture.width = width;
target.texture.height = height;
target.texture.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8;
target.texture.mipmaps = 1;
GLuint depth = 0;
gl::glGenRenderbuffers(1, &depth);
gl::glBindRenderbuffer(GL_RENDERBUFFER, depth);
gl::glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
target.depth.id = depth;
target.depth.width = width;
target.depth.height = height;
target.depth.mipmaps = 1;
gl::glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture.id, 0);
gl::glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depth);
if (gl::glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
note("render texture framebuffer incomplete");
gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.currentFbo);
return RenderTexture2D{};
}
gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.currentFbo);
checkGl("LoadRenderTexture");
return target;
}
bool IsRenderTextureValid(RenderTexture2D target) { return target.id > 0 && target.texture.id > 0 && target.depth.id > 0; }
void UnloadRenderTexture(RenderTexture2D target) {
if (target.id == 0) return;
gl::glDeleteTextures(1, &target.texture.id);
gl::glDeleteRenderbuffers(1, &target.depth.id);
gl::glDeleteFramebuffers(1, &target.id);
}
// ---- meshes ---------------------------------------------------------------------------------------------------------------
void* MemAlloc(unsigned int size) { return std::calloc(1, size); }
void MemFree(void* ptr) { std::free(ptr); }
void UploadMesh(Mesh* mesh, bool dynamic) {
if (mesh->vaoId > 0) return;
mesh->vboId = static_cast<unsigned int*>(std::calloc(9, sizeof(unsigned int)));
GLuint vao = 0;
gl::glGenVertexArrays(1, &vao);
mesh->vaoId = vao;
gl::glBindVertexArray(vao);
const unsigned n = static_cast<unsigned>(mesh->vertexCount);
mesh->vboId[0] = rlLoadVertexBuffer(mesh->vertices, static_cast<int>(n * 3 * sizeof(float)), dynamic);
rlSetVertexAttribute(0, 3, RL_FLOAT, false, 0, 0);
rlEnableVertexAttribute(0);
mesh->vboId[1] = rlLoadVertexBuffer(mesh->texcoords, static_cast<int>(n * 2 * sizeof(float)), dynamic);
rlSetVertexAttribute(1, 2, RL_FLOAT, false, 0, 0);
rlEnableVertexAttribute(1);
if (mesh->normals) {
mesh->vboId[2] = rlLoadVertexBuffer(mesh->normals, static_cast<int>(n * 3 * sizeof(float)), dynamic);
rlSetVertexAttribute(2, 3, RL_FLOAT, false, 0, 0);
rlEnableVertexAttribute(2);
}
if (mesh->colors) {
mesh->vboId[3] = rlLoadVertexBuffer(mesh->colors, static_cast<int>(n * 4), dynamic);
rlSetVertexAttribute(3, 4, RL_UNSIGNED_BYTE, true, 0, 0);
rlEnableVertexAttribute(3);
}
gl::glBindVertexArray(0);
checkGl("UploadMesh");
}
void UnloadMesh(Mesh mesh) {
if (mesh.vaoId > 0) {
GLuint vao = mesh.vaoId;
gl::glDeleteVertexArrays(1, &vao);
}
if (mesh.vboId) {
for (int i = 0; i < 9; ++i)
if (mesh.vboId[i]) {
GLuint b = mesh.vboId[i];
gl::glDeleteBuffers(1, &b);
}
std::free(mesh.vboId);
}
std::free(mesh.vertices);
std::free(mesh.texcoords);
std::free(mesh.normals);
std::free(mesh.colors);
std::free(mesh.indices);
}
// ---- 2D drawing (recorded, not drawn) -----------------------------------------------------------------------------------
void DrawText(const char* text, int, int, int, Color) { gReport.texts.emplace_back(text); }
void DrawRectangle(int, int, int, int, Color) {}
const char* TextFormat(const char* text, ...) {
static char buffers[4][1024];
static int index = 0;
char* cur = buffers[index];
index = (index + 1) % 4;
va_list args;
va_start(args, text);
std::vsnprintf(cur, 1024, text, args);
va_end(args);
return cur;
}
void TraceLog(int, const char* text, ...) {
char buf[512];
va_list args;
va_start(args, text);
std::vsnprintf(buf, sizeof buf, text, args);
va_end(args);
note(buf);
}
// ---- images ------------------------------------------------------------------------------------------------------------
Image LoadImageFromScreen(void) {
gScreenshot = readFramebuffer(gWin.screenFbo, gWin.width, gWin.height);
Image img{};
const std::size_t bytes = gScreenshot.rgba.size();
img.data = std::malloc(bytes);
std::memcpy(img.data, gScreenshot.rgba.data(), bytes);
img.width = gScreenshot.width;
img.height = gScreenshot.height;
img.mipmaps = 1;
img.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8;
return img;
}
bool ExportImage(Image image, const char* fileName) {
if (image.format != PIXELFORMAT_UNCOMPRESSED_R8G8B8A8) return false;
shim::Frame f;
f.width = image.width;
f.height = image.height;
f.rgba.assign(static_cast<const std::uint8_t*>(image.data), static_cast<const std::uint8_t*>(image.data) + static_cast<std::size_t>(image.width) * static_cast<std::size_t>(image.height) * 4);
return shim::writePng(fileName, f);
}
void UnloadImage(Image image) { std::free(image.data); }
// ---- input: nothing is ever pressed -----------------------------------------------------------------------------------
bool IsKeyDown(int) { return false; }
bool IsKeyPressed(int) { return false; }
bool IsKeyReleased(int) { return false; }
bool IsMouseButtonDown(int) { return false; }
bool IsMouseButtonPressed(int) { return false; }
bool IsMouseButtonReleased(int) { return false; }
Vector2 GetMousePosition(void) { return Vector2{0, 0}; }
Vector2 GetMouseDelta(void) { return Vector2{0, 0}; }
float GetMouseWheelMove(void) { return 0; }
// ============================================================================================================================
// rlgl
// ============================================================================================================================
void rlSetClipPlanes(double nearPlane, double farPlane) {
gWin.cullNear = nearPlane;
gWin.cullFar = farPlane;
}
double rlGetCullDistanceNear(void) { return gWin.cullNear; }
double rlGetCullDistanceFar(void) { return gWin.cullFar; }
unsigned int rlLoadVertexBuffer(const void* buffer, int size, bool dynamic) {
GLuint id = 0;
gl::glGenBuffers(1, &id);
gl::glBindBuffer(GL_ARRAY_BUFFER, id);
gl::glBufferData(GL_ARRAY_BUFFER, size, buffer, dynamic ? GL_DYNAMIC_DRAW : GL_STATIC_DRAW);
return id;
}
void rlUnloadVertexBuffer(unsigned int vboId) { gl::glDeleteBuffers(1, &vboId); }
void rlUpdateVertexBuffer(unsigned int bufferId, const void* data, int dataSize, int offset) {
gl::glBindBuffer(GL_ARRAY_BUFFER, bufferId);
gl::glBufferSubData(GL_ARRAY_BUFFER, offset, dataSize, data);
}
void rlEnableShader(unsigned int id) { gl::glUseProgram(id); }
void rlDisableShader(void) { gl::glUseProgram(0); }
void rlSetUniform(int locIndex, const void* value, int uniformType, int count) {
switch (uniformType) {
case RL_SHADER_UNIFORM_FLOAT: gl::glUniform1fv(locIndex, count, static_cast<const float*>(value)); break;
case RL_SHADER_UNIFORM_VEC2: gl::glUniform2fv(locIndex, count, static_cast<const float*>(value)); break;
case RL_SHADER_UNIFORM_VEC3: gl::glUniform3fv(locIndex, count, static_cast<const float*>(value)); break;
case RL_SHADER_UNIFORM_VEC4: gl::glUniform4fv(locIndex, count, static_cast<const float*>(value)); break;
case RL_SHADER_UNIFORM_INT: gl::glUniform1iv(locIndex, count, static_cast<const int*>(value)); break;
case RL_SHADER_UNIFORM_IVEC2: gl::glUniform2iv(locIndex, count, static_cast<const int*>(value)); break;
case RL_SHADER_UNIFORM_IVEC3: gl::glUniform3iv(locIndex, count, static_cast<const int*>(value)); break;
case RL_SHADER_UNIFORM_IVEC4: gl::glUniform4iv(locIndex, count, static_cast<const int*>(value)); break;
case RL_SHADER_UNIFORM_SAMPLER2D: gl::glUniform1iv(locIndex, count, static_cast<const int*>(value)); break;
default: note("SHADER: failed to set uniform value, data type not recognized");
}
}
void rlSetUniformMatrix(int locIndex, Matrix mat) {
const float m[16] = {mat.m0, mat.m1, mat.m2, mat.m3, mat.m4, mat.m5, mat.m6, mat.m7,
mat.m8, mat.m9, mat.m10, mat.m11, mat.m12, mat.m13, mat.m14, mat.m15};
gl::glUniformMatrix4fv(locIndex, 1, GL_FALSE, m);
}
void rlSetUniformSampler(int locIndex, unsigned int) { (void)locIndex; }
int rlGetLocationUniform(unsigned int id, const char* name) { return gl::glGetUniformLocation(id, name); }
int rlGetLocationAttrib(unsigned int, const char*) { return -1; }
bool rlEnableVertexArray(unsigned int vaoId) {
gl::glBindVertexArray(vaoId);
return true;
}
void rlDisableVertexArray(void) { gl::glBindVertexArray(0); }
void rlEnableVertexBuffer(unsigned int id) { gl::glBindBuffer(GL_ARRAY_BUFFER, id); }
void rlDisableVertexBuffer(void) { gl::glBindBuffer(GL_ARRAY_BUFFER, 0); }
void rlEnableVertexAttribute(unsigned int index) { gl::glEnableVertexAttribArray(index); }
void rlDisableVertexAttribute(unsigned int index) { gl::glDisableVertexAttribArray(index); }
void rlSetVertexAttribute(unsigned int index, int compSize, int type, bool normalized, int stride, int offset) {
gl::glVertexAttribPointer(index, compSize, static_cast<GLenum>(type), normalized ? GL_TRUE : GL_FALSE, stride,
reinterpret_cast<const void*>(static_cast<std::intptr_t>(offset)));
}
void rlSetVertexAttributeDivisor(unsigned int index, int divisor) { gl::glVertexAttribDivisor(index, static_cast<GLuint>(divisor)); }
void rlDrawVertexArray(int offset, int count) { gl::glDrawArrays(GL_TRIANGLES, offset, count); }
void rlDrawVertexArrayInstanced(int, int count, int instances) {
gl::glDrawArraysInstanced(GL_TRIANGLES, 0, count, instances); // raylib ignores `offset` here (known quirk)
++gReport.drawCalls;
gReport.instancesDrawn += instances;
checkGl("glDrawArraysInstanced");
}
void rlDrawRenderBatchActive(void) {}
void rlActiveTextureSlot(int slot) { gl::glActiveTexture(GL_TEXTURE0 + static_cast<GLenum>(slot)); }
void rlEnableTexture(unsigned int id) { gl::glBindTexture(GL_TEXTURE_2D, id); }
void rlDisableTexture(void) { gl::glBindTexture(GL_TEXTURE_2D, 0); }
void rlTextureParameters(unsigned int id, int param, int value) {
gl::glBindTexture(GL_TEXTURE_2D, id);
gl::glTexParameteri(GL_TEXTURE_2D, static_cast<GLenum>(param), value);
gl::glBindTexture(GL_TEXTURE_2D, 0);
}
unsigned int rlGetTextureIdDefault(void) { return gWin.defaultTexture; }
unsigned int rlGetShaderIdDefault(void) { return gWin.defaultShader; }
void rlSetCullFace(int mode) { gl::glCullFace(mode == RL_CULL_FACE_BACK ? GL_BACK : GL_FRONT); }
void rlEnableBackfaceCulling(void) { gl::glEnable(GL_CULL_FACE); }
void rlDisableBackfaceCulling(void) { gl::glDisable(GL_CULL_FACE); }
void rlEnableDepthTest(void) { gl::glEnable(GL_DEPTH_TEST); }
void rlDisableDepthTest(void) { gl::glDisable(GL_DEPTH_TEST); }
void rlEnableColorBlend(void) { gl::glEnable(GL_BLEND); }
void rlDisableColorBlend(void) { gl::glDisable(GL_BLEND); }
void rlEnableFramebuffer(unsigned int id) { gl::glBindFramebuffer(GL_FRAMEBUFFER, id); }
void rlDisableFramebuffer(void) { gl::glBindFramebuffer(GL_FRAMEBUFFER, gWin.screenFbo); }
} // extern "C"
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
// What the GL shim lets a test inspect. Not part of raylib: only the verification harness includes this.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace shim {
struct Frame {
int width = 0, height = 0;
std::vector<std::uint8_t> rgba; // top row first, alpha forced to 255 (like LoadImageFromScreen)
[[nodiscard]] bool valid() const { return width > 0 && height > 0 && rgba.size() == std::size_t(width) * std::size_t(height) * 4; }
[[nodiscard]] const std::uint8_t* px(int x, int y) const { return &rgba[(std::size_t(y) * std::size_t(width) + std::size_t(x)) * 4]; }
};
struct Report {
std::string glVersion, glRenderer, glsl;
int glErrors = 0; // GL errors and error-type debug messages seen so far
int shaderFailures = 0; // shader stages/programs that failed to compile or link
int framesPresented = 0; // EndDrawing calls
long drawCalls = 0; // glDrawArraysInstanced calls since the last reset
long instancesDrawn = 0; // sum of their instance counts
std::vector<std::string> log; // shader logs, GL debug messages, raylib TraceLog lines
std::vector<std::string> texts; // every DrawText string, in order
};
Report& report();
void resetCounters(); // zero drawCalls/instancesDrawn/texts (not the error totals)
const Frame& lastScreenshot(); // what LoadImageFromScreen returned last
Frame readScreen(); // the current contents of the window's framebuffer
bool writePng(const std::string& path, const Frame& f);
} // namespace shim