LuEngine is a C++ game engine built around two user-facing APIs:
Game Logic API
You write your whole Game within flecs ECS.
If you add/set/remove LuEngine Core Components, Observer are triggered, which talk to the Renderer.
Shader API
register graphics pipelines for meshes, multimeshes, skeletonmeshes and 2D text
plus compute pipelines for multimesh-instance transformation.
The renderer is Vulkan-based.
1.] clone the repo with git clone https://github.com/Rlocksley/LuEngine
2.] install CMake and the Vulkan SDK (Vulkan 1.4)
3.] install Visual Studio Code and install the Cpp and CMake Extensions
4.] clone vcpkg inside the parent directory of the LuEngine repo directory and run
vcpkg install [glfw3,glm,flecs,joltphysics,assimp,freetype]
5.] open the LuEngine repo in Visual Studio Code and right click LuEngine/CMakeLists.txt -> build
6.] compile the shaders LuEngine/shader/compile.bat
(you need to get glslc from the Vulkan SDK and paste it into the shader directory)
7.] for your own project, create a directory in examples and
copy/adjust the examples/CMakeLists.txt and .vscode/launch.json , .vscode/tasks.json
1. Hello World
The minimal example creates one cube, gives it a GPU-synchronized transform, and rotates it from a Flecs system. App::registerShape uploads the cube geometry and registerMeshPipe registers the vertex/fragment-shader pipeline, which you reference from the MeshComponent.
Source: examples/hello_world.cpp.
#include "App.hpp"
#include "Shape.hpp"
#include "FlyingCamera.hpp"
#include "component/Material.hpp"
#include "component/Mesh.hpp"
#include "component/Transform.hpp"
#include "component/TransformGpu.hpp"
struct HelloCubeTag {};
struct HelloWorldModule {
explicit HelloWorldModule(flecs::world& world) {
world.import<Lu::Module::FlyingCamera>();
world.entity("Camera")
.set(Lu::Component::FlyingCamera{
.position = glm::vec3(0.0f, 0.0f, 5.0f),
.angle = Lu::Component::FlyingCamera::lookAt(
glm::vec3(0.0f, 0.0f, 5.0f), glm::vec3(0.0f)),
.speed = 5.0f,
.rotationSpeed = 0.005f,
.fov = glm::radians(70.0f),
.nearClip = 0.1f,
.farClip = 100.0f
})
.add<Lu::Component::Transform>();
world.system<Lu::Component::Transform>("RotateCube")
.with<HelloCubeTag>()
.each([](flecs::iter& it, size_t, Lu::Component::Transform& transform) {
const float angle = glm::radians(45.0f) * it.delta_time();
transform.rotation = glm::angleAxis(angle, glm::vec3(0.0f, 1.0f, 0.0f))
* transform.rotation;
});
const auto cube = world.entity("Cube")
.add<HelloCubeTag>()
.set<Lu::Component::Transform>({
glm::vec3(0.0f), 0.0f, glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(1.0f)
})
.add<Lu::Component::TransformGpu>();
world.entity("CubeMesh")
.child_of(cube)
.set(Lu::Component::Mesh{
.mesh = world.lookup("Mesh::Cube"),
.pipeline = world.lookup("MeshPipe::Basic")
})
.set(Lu::Component::Material{
.albedo = glm::vec4(0.25f, 0.7f, 0.95f, 1.0f),
.ambient = glm::vec4(0.12f, 0.12f, 0.12f, 1.0f)
});
}
};
int main() {
Lu::App("LuEngine Hello World", false, 1280, 800)
.registerMeshPipe(Lu::GraphicsPipelineConfig{
.name = "MeshPipe::Basic",
.capacity = 1,
.vertexShader = "shader/mesh_basic.vert.spv",
.fragmentShader = "shader/mesh_basic.frag.spv"
})
.registerShape<Lu::Shape::Cube>(
"Mesh::Cube", glm::vec3(0.5f), glm::vec3(0.0f), glm::vec4(1.0f))
.importModule<HelloWorldModule>()
.run();
}
2. MultiMesh
A Component::MultiMesh represents many copies of one registered geometry. It references:
- A geometry entity registered with
registerShapeorregisterMesh. - A compute pipeline registered with
registerMultiMeshComputePipe. - A multimesh graphics pipeline registered with
registerMultiMeshPipe. - A vector of per-instance
Component::MultiMeshInstancevalues, each containing a transform and material.
The multimesh entity must be a child of a parent that has both Transform and TransformGpu. The parent transform places the whole group; each instance transform is local to that parent. The engine's MultiMeshGpu module submits component changes to the renderer.
std::vector<Lu::Component::MultiMeshInstance> instances;
instances.reserve(1000);
for (uint32_t i = 0; i < 1000; ++i) {
Lu::Component::MultiMeshInstance instance{};
instance.transform = Lu::Component::Transform{
glm::vec3(static_cast<float>(i % 25), static_cast<float>(i / 25), 0.0f),
0.0f,
glm::vec3(0.0f, 1.0f, 0.0f),
glm::vec3(0.05f)
};
instance.material.albedo = glm::vec4(0.9f, 0.45f, 0.15f, 1.0f);
instances.push_back(instance);
}
auto parent = world.entity("InstancesParent")
.set<Lu::Component::Transform>({})
.add<Lu::Component::TransformGpu>();
world.entity("Instances")
.child_of(parent)
.set(Lu::Component::MultiMesh{
world.lookup("Mesh::Cube"),
world.lookup("MultiMeshCompute::UpdateInstances"),
world.lookup("MultiMeshPipe::Basic"),
std::move(instances),
glm::vec4(0.0f, 0.0f, 0.0f, 10.0f)
});
Register the pipelines before the Flecs module creates the multimesh entities:
.registerMultiMeshPipe(Lu::GraphicsPipelineConfig{
.name = "MultiMeshPipe::Basic",
.capacity = Lu::Core::MAX_INSTANCED_MESHES,
.vertexShader = "shader/multi_mesh_basic.vert.spv",
.fragmentShader = "shader/multi_mesh_basic.frag.spv"
})
.registerMultiMeshComputePipe(Lu::ComputePipelineConfig{
.name = "MultiMeshCompute::UpdateInstances",
.computeShader = "shader/multi_mesh_template.comp.spv"
})
The sample scene is examples/main4.cpp; it registers a cube and creates parented multimeshes using the Thomas-attractor compute shader.
3. Mesh Prefab Loading
App::loadModel imports a model with Assimp, registers its mesh geometry and textures, and creates a Flecs prefab named Mesh::<name> by default. Register the mesh pipeline before loading the model.
Lu::App("Robot scene")
.registerMeshPipe(Lu::GraphicsPipelineConfig{
.name = "MeshPipe::Simple",
.capacity = 128,
.vertexShader = "shader/mesh_basic.vert.spv",
.fragmentShader = "shader/mesh_pbr.frag.spv"
})
.loadModel("Robot", "asset/models/robot.gltf")
.importModule<RobotScene>()
.run();
auto robotPrefab = world.lookup("Mesh::Robot");
world.entity("RobotInstance")
.is_a(robotPrefab)
.set<Lu::Component::Transform>({
glm::vec3(0.0f), 0.0f,
glm::vec3(0.0f, 1.0f, 0.0f),
glm::vec3(1.0f)
});
4. Skeleton Mesh Prefab Loading
App::loadSkeletonModel imports a skinned model, registers its skeleton asset and baked animation clips, and creates a prefab named Skeleton::<name> by default. Register a skeleton pipeline before loading it. The prefab contains a skeleton parent and child meshes; instantiate it with is_a and give the instance a Transform.
Lu::App("Character scene")
.threads(4)
.registerSkeletonPipe(Lu::GraphicsPipelineConfig{
.name = "SkeletonPipe::Simple",
.capacity = 4096,
.vertexShader = "shader/skeleton_basic.vert.spv",
.fragmentShader = "shader/skeleton_pbr.frag.spv"
})
.loadSkeletonModel("Hero", "asset/models/hero.fbx")
.importModule<CharacterScene>()
.run();
auto heroPrefab = world.lookup("Skeleton::Hero");
world.entity("HeroInstance")
.is_a(heroPrefab)
.set<Lu::Component::Transform>({
glm::vec3(0.0f), 0.0f,
glm::vec3(0.0f, 1.0f, 0.0f),
glm::vec3(1.0f)
});
See the Skeleton Animation section for animation graphs, state machines, and character control.
5. Skeleton Animation
Skeleton meshes can be animated with baked clips and an AnimationStateMachine.
See examples/main13.cpp.
Lu::App("Character")
.threads(4) // animation graphs are evaluated in parallel
.registerSkeletonPipe({
.name = "SkeletonPipe::Simple",
.capacity = 4096,
.vertexShader = "shader/skeleton_basic.vert.spv",
.fragmentShader = "shader/skeleton_pbr.frag.spv"}
)
// skeleton asset + baked clips "Hero::<anim>" + prefab "Skeleton::Hero"
.loadSkeletonModel("Hero", "asset/models/hero.fbx")
.importModule<MyGame>()
.run();
Lu::AnimationGraphBuilder("HeroGraph", kModel)
.clip("Idle", "Idle")
.clip("Walk", "Walk")
.clip("Run", "Run")
.blend1D("Locomotion", "speed", {{"Idle", 0.0f}, {"Walk", 0.5f}, {"Run", 1.0f}})
.clip("Wave", "Wave", /*looping*/ false)
.layer("Interaction",/*top*/ "Wave", /*base*/ "Locomotion", "Hero::UpperBody",
/*blendIn*/ 0.1f, /*blendOut*/ 0.15f, /*onFinish*/ "Locomotion")
.start("Locomotion")
.build();
// ---- the hero: prefab (skeleton + meshes) + capsule + state machine ---------------------------
world.entity("Hero")
.is_a(world.lookup("Skeleton::Hero"))
.add<Hero>()
// body: position / yaw come from the CharacterCapsule (Jolt), scale stays 1 so the capsule sizes are meters
.set<Transform>({glm::vec3(0.0f, 0.0f, 0.0f), 0.0f, glm::vec3(0, 1, 0), glm::vec3(1.0f)})
// visuals: all SkeletonMeshes of the hero are transformed by Transform * MeshTransform.
// Change it at runtime with: hero.get_mut<MeshTransform>().position.y += 0.1f; hero.modified<MeshTransform>();
.set<MeshTransform>(MeshTransform(glm::vec3(0.0f, kMeshHeight, 0.0f),
glm::radians(kMeshFrontYawDegrees), glm::vec3(0, 1, 0),
glm::vec3(kMeshScale)))
.set<AnimationStateMachine>(AnimationStateMachine::create("HeroGraph"))
.set<CharacterCapsule>({.radius = 0.35f, .halfHeight = 0.55f})
.add<Lu::Component::PhysicsFocus>();
UI: windows, sliders, tabs ... as entity hierarchies
The UI module (importModule<Lu::Module::UI>(), see examples/main_ui.cpp) has no factory functions. A widget is an entity with a Transform2D (local to the parent widget, origin = top-left corner), a Node (size, layout hints) and a widget component. The module observes the widget component and creates the entities that draw it (Transform2D + Mesh2D / Text rectangles, knobs, texts) and the child widgets it consists of (close button of a Window, tabs of a TabView, options of a Dropdown, scroll bar of a ScrollView).
using namespace Lu::UI;
auto window = world.entity("Settings")
.set<Lu::Component::Transform2D>({.position = {100, 80}})
.set<Node>({.size = {360, 240}})
.set<Window>({.title = "Settings"});
world.entity("Volume").child_of(window)
.set<Lu::Component::Transform2D>({.position = {20, 60}}) // relative to the window
.set<Node>({.size = {240, 24}})
.set<Slider>({.value = 0.7f, .onChange = [](flecs::entity, float v) { /* ... */ }});
| Widget components | |
|---|---|
Panel, Label, Image, ProgressBar |
display |
Button, Toggle (check box / radio group), Slider, TextInput, Dropdown |
input |
Window (title bar, drag, close, raise on click), TabView + TabPage, ScrollView (+ ScrollBar) |
containers |
Layout (horizontal / vertical / grid, auto size, grow) |
on any node: places the child nodes |
Style |
theme for the entity and everything below it |
Interact, Draggable, Tooltip, Modal, Hidden |
add to any node |
- Changing a widget component (
set, orget_mut+modified) redraws it. The widgets that need several entities create them in an observer-triggered pass of the UI module (phaseUISync), widgets inside widgets are created one frame later each. - Everything uses the
Transform2Dhierarchy: moving, hiding (Hidden) or destroying a widget moves / hides / destroys everything below it. Layers (positionZ) are derived fromNode::layer(a child sits above its parent), the top-most node under the cursor gets the mouse (Lu::UI::Pointer::of(world).overUItells you whether the mouse is over the UI). - Needed assets:
Mesh2D::UIRect(unit square;Mesh2D::UICirclefor the flat look),Mesh2DPipe::Basicor the SDF pipelines,Font::Main,FontPipe::Basic(names can be changed inStyle). - SDF look: with the pipelines
Mesh2DPipe::UIandMesh2DPipe::UISoft(bothshader/mesh2d_ui.*, registered as inexamples/main_ui.cpp) every widget is drawn with signed distance fields instead of flat rectangles: rounded corners, borders, gradients, inner bevels, check / chevron / cross icons, soft drop shadows and glows, anti-aliased at any zoom, with animated hover / press transitions (Style::animationTime). Crisp shapes use alpha to coverage (GraphicsPipelineConfig::alphaToCoverageEnable, needs MSAA) so edges are anti-aliased independent of the draw order; translucent shapes, shadows and glows use the blended pipeline, which has to be registered after the first one (pipelines are drawn in registration order). The shape is described byMesh2D:color= fill,uvRect= border color,params= radius, border width, softness, kind * 4 + gradient (see the header ofshader/mesh2d_ui.frag), so you can use the shader for your own shapes too. Colors: the swapchain isVK_FORMAT_B8G8R8A8_SRGB, the GPU encodes linear to sRGB when it writes a pixel, so shaders must output linear values. The UI module therefore treats all UI colors (Style, and theColorfields ofPanel,Label,Button, ...) as sRGB like in a color picker and converts them to linear (Lu::UI::srgbToLinear,Style::srgbColors = falseif you give linear values yourself). The same applies to everyMesh2D::color/Text::coloryou set yourself: it is linear. The look is configured inStyle(radius, gradient, shadow, bevel, text shadow, colors). Without the two pipelines the same widget tree is drawn flat. ScrollViewclips on the GPU:Mesh2D::clip/Text::clipname an entity with aTransform2D(+Transform2DGpu) whose unit square is the visible region; the clipping pipelinesMesh2DPipe::UIClip(shader/mesh2d_clip.*) andFontPipe::UIClip(shader/font_clip.*, seeexamples/main_ui.cpp) cut there, so partly visible children are drawn partly. Without these two pipelines aScrollViewfalls back to hiding (Clipped) children that are not completely inside. ItsScrollBaris a rounded track with a rounded thumb of constant size that is at the very top / bottom at scroll 0 / maximum.
Transform hierarchies (Transform / WorldTransform)
Transform (and Transform2D) is what you set. If the entity has an ancestor with a Transform it is local to it, otherwise it is the world transform. The engine adds and maintains a WorldTransform (WorldTransform2D) automatically:
WorldTransform = WorldTransform of the nearest ancestor with a Transform * Transform
- Adding, setting or removing a
Transform, and (re)parenting an entity or a whole subtree, update theWorldTransformof the entity and of every descendant with aTransform(entities in between without aTransformare transparent). The order in which you create entities, addTransforms or set parents does not matter. - Removing the
Transformremoves theWorldTransform. - Entities without
Staticare recomputed every frame (parents first), so it is fine to writeTransformdirectly, e.g. from physics, without callingmodified. Staticonly means "not streamed every frame": setting theTransformof aStaticentity again still updates itsWorldTransformand those of its children (sent once to the renderer). AStaticentity can be the parent of non-Staticentities. AStaticchild does not follow a moving parent until its own or an ancestor'sTransformis set again.- The renderer receives the
WorldTransform(EcsRequest::CreateTransform / UpdateTransform). ReadWorldTransform, do not set it. Transform2D::positionZ(layer) is added up along the hierarchy.
6. Writing Your Own Shaders
The engine expects shader interfaces to match the descriptor set layouts created by its packages.
In simple terms "let the predefined structs and the layout() bindings in the shader code before the void main(){} function the same.
Templates: shader/mesh_basic.vert and shader/mesh_basic.frag
register with
App::registerMeshPipe();
Templates: shader/multi_mesh_basic.vert and shader/multi_mesh_basic.frag
register with
App::registerMultiMeshPipe();
Templates: shader/multi_mesh_thomas_attraktor.comp
register with
App::registerMultiMeshComputePipe();
7. 2D Text
Text is drawn by the 2D part of the renderer: Transform2D + Camera2D + Text, with fonts rasterized by FreeType.
Source: examples/main15.cpp (lu_example15 [font.ttf]).
Lu::App("Text", false, 1280, 800)
// FreeType -> atlas (signed distance field by default) -> a texture of the global texture array
.registerFont("Font::Main", "path/to/font.ttf", 64)
// shaders of the glyph quads, register your own like a mesh pipe. capacity = max glyphs per frame
.registerFontPipe(Lu::GraphicsPipelineConfig{
.name = "FontPipe::Basic",
.capacity = 4096,
.vertexShader = "shader/font_basic.vert.spv",
.fragmentShader = "shader/font_basic.frag.spv",
.cullMode = VK_CULL_MODE_NONE,
.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL
})
.importModule<MyModule>()
.run();
auto label = world.entity("Label")
.set<Lu::Component::Transform2D>({
.position = {20.f, 20.f}, // pixels, origin top-left, y down
.positionZ = 5, // layer, higher = in front
// .rotation, .scale
});
world.entity("LabelText")
.set(Parent{label})
.set(Lu::Component::Text{
.text = "Hello World",
.font = world.lookup("Font::Main"),
.pipeline = world.lookup("FontPipe::Basic"),
.color = glm::vec4(1.f),
.size = 32.f, // line height in pixels
.align = Lu::TextAlign::Left,
.maxWidth = 400.f // optional word wrapping, 0 = off (see below)
});
// change the string later with .set<Lu::Component::Text>(...) or .modified<Lu::Component::Text>():