This page walks through common engine tasks using concrete code.
- Note
- The sample code uses the base
Scene class from the engine and imaginary types SceneTree, SceneInstance and SceneMeshInstance to represent the scene graph. You can use the [Lysa Nodes]https://codeberg.org/LysaEngine/lysa_nodes) library which provide a Node-based scene graph.
Table of contents
1. Bootstrapping the engine
Every application starts by creating a lysa::ContextConfiguration, instantiating lysa::Lysa, and calling run(). The configuration covers the graphics backend, resource pool capacities, the logging system, and optional physics and scripting settings.
},
};
int lysaMain() {
if constexpr (vireo::getPlatform() == vireo::Platform::WINDOWS) {
contextConfiguration.backendConfiguration.backend = vireo::Backend::DIRECTX;
}
return 0;
}
The entry function must be named lysaMain() (the platform layer provides the real main() / WinMain() and calls lysaMain() after performing platform-specific initialization).
Sanity-check the VFS before doing any asset work:
if (!
lysa::ctx().fs.directoryExists(
"app://shaders")) {
lysa::Log::critical(
"'shaders' directory not found, run from the project root or build the 'shaders' target first");
return;
}
2. Creating the window
Declare a lysa::RenderingWindowConfiguration and extend lysa::RenderingWindow. The nested rendererConfiguration selects the rendering path, anti-aliasing, tone-mapping, and optional effects (bloom, AO, TAA, ...).
.width = 1280,
.height = 720,
.renderTargetConfiguration = {
.presentMode = vireo::PresentMode::VSYNC,
.rendererConfiguration = {
.depthStencilFormat = vireo::ImageFormat::D32_SFLOAT,
.clearColor = lysa::float3{0.0f, 0.2f, 0.4f},
.bloomEnabled = true,
.taaEnabled = false,
}
}
};
public:
MainWindow() : RenderingWindow(renderingWindowConfiguration) {
});
});
}
};
Renderer type options are lysa::RendererType::FORWARD and lysa::RendererType::DEFERRED (the latter requires the DEFERRED_RENDERER CMake flag, which is ON by default).
3. Setting up a scene
Derive from lysa::Scene and override the lifecycle hooks:
public:
MyScene() : Scene(
lysa::SceneConfiguration{}) {
}
};
Wire the scene into the render target and connect the main-loop events in your application class:
window.getRenderTarget().addView(view);
const auto delta = std::any_cast<double>(evt.
payload);
scene.onPhysicsProcess(delta);
});
const auto delta = std::any_cast<double>(evt.
payload);
window.getRenderTarget().render();
});
window.getRenderTarget().id,
view.viewport = {};
view.scissors = {};
window.getRenderTarget().updateView(view);
});
4. Creating meshes and instances
Build a lysa::Mesh from raw vertex/index data, create a lysa::MeshInstance to place it in the world, and add it to the scene:
const std::vector<lysa::Vertex> vertices {
{.position = { 0.0f, 0.5f, 0.0f}, .uv = {0.5f, 0.25f}},
{.position = { 0.5f, -0.5f, 0.0f}, .uv = {0.75f, 0.75f}},
{.position = {-0.5f, -0.5f, 0.0f}, .uv = {0.25f, 0.75f}},
};
const std::vector<lysa::uint32> indices { 0, 1, 2 };
const std::vector<lysa::MeshSurface> surfaces {
};
lysa::Mesh&
mesh = meshManager.create(vertices, indices, surfaces,
"MyTriangle");
instance.setTransform(lysa::float4x4::translation(1.0f, 0.0f, 0.0f));
scene.addInstance(instance);
Build a scene graph (parent → child transforms):
auto parent = std::make_shared<SceneMeshInstance>(meshA, "Parent");
auto child = std::make_shared<SceneMeshInstance>(meshB, "Child",
lysa::float4x4::translation(0.5f, 0.0f, 0.0f));
parent->addChild(child);
root.addChild(parent);
addInstance(root);
- Note
- The Lysa Nodes library provides a Node-bases scene graph with assets pack loading and animation support.
Updating transforms every frame:
instance->setTransform(
lysa::mul(lysa::float4x4::rotation_y(angle),
instance->getTransform()));
5. Materials
Standard material (PBR surface with albedo, diffuse texture, and transparency) :
Shader material (custom Slang vertex/fragment shaders with per-material parameters) :
"uv_gradient.frag",
"scale.vert"
);
Assign a material to a mesh surface:
mesh.setSurfaceMaterial(0, mat.
id);
Override a material on a specific instance without modifying the mesh:
instance.meshInstance.setSurfaceOverrideMaterial(0, otherMat.id);
instance.meshInstance.removeSurfaceOverrideMaterial(0);
Update shader parameters every frame (e.g., in onPhysicsProcess):
void MyScene::onPhysicsProcess(const double delta) {
gradient = std::clamp(gradient + gradientSpeed * static_cast<float>(delta), 0.0f, 1.0f);
if (gradient == 1.0f || gradient == 0.0f) gradientSpeed = -gradientSpeed;
}
6. Lights
Three light types are available. All lights can optionally cast shadow maps.
Directional light:
{1.0f, 1.0f, 1.0f},
2.0f,
};
sun.castShadows = true;
sun.shadowMapSize = 4096;
sun.shadowMapCascadesCount = 3;
sun.shadowMapCascadesSplitLambda = 0.80f;
addLight(sun);
Omni (point) light:
{1.0f, 1.0f, 1.0f},
1.0f,
lysa::float4x4::translation(0.0f, 2.0f, 0.0f),
10.0f
};
omni.castShadows = true;
omni.shadowMapSize = 1024;
addLight(omni);
Spot light:
{1.0f, 1.0f, 1.0f},
1.5f,
lysa::float4x4::translation(-7.0f, 1.8f, 0.0f))),
10.0f,
};
addLight(spot);
The maximum number of simultaneous lights per scene is set through SceneConfiguration::maxLights.
7. Loading assets packs
Pre-exported .assets files (produced by the Blender add-on or the gltf2lysa converter) contain a list of all the assets in the scene, are loaded through the VFS and hydrated into your scene-graph node types via AssetsPack::load<>():
std::shared_ptr<SceneInstance> load(const std::string& fileURI) {
return lysa::AssetsPack::load<SceneInstance, SceneMeshInstance, DummyAnimationPlayer>(fileURI);
}
Usage in a scene:
const auto model = load("app://res/models/sponza.assets");
root.addChild(model);
addInstance(root);
Cloning an already-loaded asset (useful for placing multiple copies without reloading from disk):
std::shared_ptr<SceneInstance> clone(const std::shared_ptr<SceneInstance>& orig) {
std::shared_ptr<SceneInstance> copy;
if (const auto& mi = std::dynamic_pointer_cast<SceneMeshInstance>(orig)) {
copy = std::make_shared<SceneMeshInstance>(*mi);
} else {
copy = std::make_shared<SceneInstance>(*orig);
}
for (const auto& child : orig->children) {
copy->addChild(clone(child));
}
return copy;
}
for (int i = 0; i < 5; ++i) {
const auto instance = clone(model);
instance->setTransform(lysa::float4x4::translation(
0.0f,
root.addChild(instance);
}
addInstance(root);
8. Camera
Construct a lysa::Camera with a view transform, a perspective projection, and near/far planes, then pass it to lysa::RenderView :
constexpr float near = 0.01f;
constexpr float far = 100.0f;
lysa::float4x4::identity(),
near, far);
camera.transform = lysa::float4x4::translation(0.0f, 1.8f, 8.0f);
Rebuild the projection matrix when the window is resized:
window.getRenderTarget().id,
fov, window.getRenderTarget().getAspectRatio(), near, far);
});
For a fly camera with mouse look, keyboard movement, and gamepad support subscribes to PHYSICS_PROCESS for movement and PROCESS for mouse-delta look.
const auto mousePos = window.getMousePosition();
auto offset = (lastMousePos - mousePos) * mouseSensitivity;
rotate(offset.y * invertY, offset.x);
camera.transform = pivot->globalTransform;
});
9. Handling input
Input events arrive through the event system. Subscribe to RenderingWindowEvent::INPUT on your window's id:
const auto& inputEvent = std::any_cast<const lysa::InputEvent&>(evt.payload);
scene.onInput(inputEvent);
});
Inside your scene, dispatch on the event type:
const auto& key = std::get<lysa::InputEventKey>(
event.data);
rotating = !rotating;
}
spawnInstance();
}
}
const auto& btn = std::get<lysa::InputEventMouseButton>(
event.data);
if (!btn.pressed) captureMouse();
}
}
Polling helpers (call inside PHYSICS_PROCESS):
Mouse capture:
window.resetMousePosition();
auto pos = window.getMousePosition();
10. The event system
Subscribe to any event by type (and optional source id):
const double delta = std::any_cast<double>(evt.
payload);
update(delta);
});
renderTarget.id,
const auto extent = std::any_cast<vireo::Extent>(evt.payload);
onResize(extent);
});
Always store and unsubscribe event from objects with a shorter lifetime than the engine (e.g., in destructors of camera or scene helpers):
11. Instance visibility and dynamic add/remove
Toggle visibility on an instance (and all its children recursively):
instance->setVisible(false);
instance->setVisible(true);
Remove an instance from the scene without destroying it, then add it back:
if (
scene.haveInstance(meshInstance)) {
scene.removeInstance(meshInstance);
}
scene.addInstance(meshInstance);
Check presence before operating:
mesh.meshInstance.setVisible(!
mesh.meshInstance.isVisible());
}
12. Post-processing passes
Custom compute passes can be added to the renderer's post-processing chain. Pass a shader name and an optional push-constant data pointer:
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat(),
"blur",
&blurData,
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat(),
"greyscale");
renderTarget.getRenderer().addPostprocessing(blur);
renderTarget.getRenderer().addPostprocessing(greyscale);
Update push-constant data when the window is resized:
void MyScene::onResize(const vireo::Extent& extent) {
blurData.
update(extent, 2.0f);
}
Debug: display an internal attachment on screen using lysa::DisplayAttachment to inspect G-Buffer layers, AO, shadow maps, TAA, or any intermediate pass:
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat());
renderTarget.getRenderer().addPostprocessing(display);
13. 2D and 3D vector rendering
Add a lysa::Vector3DRenderer for world-space primitives and a lysa::Vector2DRenderer for screen-space UI overlays:
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat());
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat());
renderTarget.addSceneRenderer(renderer3D);
renderTarget.addUIRenderer(renderer2D);
Call restart() at the start of each onProcess frame, then issue draw commands:
void MyScene::onProcess(const double alpha) {
SceneTree::onProcess(alpha);
renderer3D.restart();
renderer2D.restart();
renderer3D.drawLine(from, to, {1.0f, 0.0f, 0.0f, 1.0f});
renderer2D.drawText(screenFont, "Hello!", {10.0f, 10.0f}, {1.0f, 1.0f, 1.0f, 1.0f});
}
Load a lysa::Font for text rendering:
lysa::Font screenFont(
"app://res/fonts/Signwood");
screenFont.setOutlineWidthAbsolute(1.0f / 4.0f);
screenFont.setOutlineWidthRelative(1.0f / 2.0f);
screenFont.setOutlineThreshold(0.2f);
14. Timers and Tweens
lysa::Timer and lysa::Tween provide lightweight, frame-driven helpers for delayed calls and time-based animations. Both are advanced automatically on every physics tick by the engine through lysa::Context::timers and lysa::Context::tweens (no manual update() call is needed in application code).
One-shot timer, fired once after the delay then discarded:
Repeating timer, kept alive so it can be stopped later:
}, false);
blinkTimer->stop();
Animate a property over time with a lysa::PropertyTween. The target object must outlive the tween:
&mat,
lysa::float4{1.0f, 0.0f, 0.0f, 1.0f},
lysa::float4{0.0f, 0.0f, 1.0f, 1.0f},
1.5f,
Chain several tweens with a lysa::TweenSequence (the leftover delta time of one step is carried over to the next, so there is no one-frame gap between them) :
auto fadeOut = std::make_shared<lysa::PropertyTween<lysa::float4>>(
lysa::float4{1.0f, 1.0f, 1.0f, 1.0f}, lysa::float4{1.0f, 1.0f, 1.0f, 0.0f}, 1.0f);
auto fadeIn = std::make_shared<lysa::PropertyTween<lysa::float4>>(
lysa::float4{1.0f, 1.0f, 1.0f, 0.0f}, lysa::float4{1.0f, 1.0f, 1.0f, 1.0f}, 1.0f);
});
Stop a tween before it completes (kill() does not fire the completion callback):