Lysa  0.0
Lysa 3D Engine
How To Use

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 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.

import lysa;
lysa::ContextConfiguration contextConfiguration {
.displayFPS = true,
.logLevelMin = lysa::LogLevel::TRACE,
},
};
int lysaMain() {
// Select the best available backend for the current platform
if constexpr (vireo::getPlatform() == vireo::Platform::WINDOWS) {
contextConfiguration.backendConfiguration.backend = vireo::Backend::DIRECTX;
}
lysa::Lysa lysa(contextConfiguration);
// … create window, scene, register events …
lysa.run();
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…).

lysa::RenderingWindowConfiguration renderingWindowConfiguration {
.title = "MyGame",
.width = 1280,
.height = 720,
.renderTargetConfiguration = {
.presentMode = vireo::PresentMode::VSYNC,
.rendererConfiguration = {
.depthStencilFormat = vireo::ImageFormat::D32_SFLOAT,
.clearColor = lysa::float3{0.0f, 0.2f, 0.4f},
.toneMappingType = lysa::ToneMappingType::ACES,
.postProcessAntiAliasingType = lysa::PostProcessAntiAliasingType::SMAA,
.bloomEnabled = true,
.ambientOcclusionType = lysa::AmbientOcclusionType::GTAO,
.taaEnabled = false,
}
}
};
class MainWindow : public lysa::RenderingWindow {
public:
MainWindow() : RenderingWindow(renderingWindowConfiguration) {
// Show the window once the GPU is ready and update the title bar
[&](const lysa::Event&) {
setTitle("MyGame");
show();
});
// Quit the engine when the OS closes the window
[&](const lysa::Event&) {
lysa::ctx().exit = true;
});
}
};

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:

class MyScene : public lysa::Scene {
public:
MyScene() : Scene(lysa::SceneConfiguration{}) {
setEnvironment({lysa::float3{1.0f, 1.0f, 1.0f}, 0.25f}); // color + ambient intensity
}
};

Wire the scene into the render target and connect the main-loop events in your application class:

window.getRenderTarget().addView(view);
auto onPhysicsProcessEventId = lysa::ctx().events.subscribe(lysa::MainLoopEvent::PHYSICS_PROCESS, [&](const lysa::Event& evt) {
const auto delta = std::any_cast<double>(evt.payload);
scene.onPhysicsProcess(delta);
});
auto onProcessEventId =lysa::ctx().events.subscribe(lysa::MainLoopEvent::PROCESS, [&](const lysa::Event& evt) {
const auto delta = std::any_cast<double>(evt.payload);
scene.onProcess(delta);
window.getRenderTarget().render(); // submit the frame
});
window.getRenderTarget().id,
[&](const lysa::Event& evt) {
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:

// Geometry
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::MeshSurface{0, static_cast<lysa::uint32>(indices.size())}
};
lysa::Mesh& mesh = meshManager.create(vertices, indices, surfaces, "MyTriangle");
// Place the mesh in the scene
lysa::MeshInstance instance(mesh, "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); // recurse the tree into the Scene
Note
The Lysa Nodes library provides a Node-bases scene graph with assets pack loading and animation support.

Updating transforms every frame:

// Rotate 90°/s around Y
const float angle = delta * lysa::radians(90.0f);
instance->setTransform(lysa::mul(lysa::float4x4::rotation_y(angle),
instance->getTransform()));

5. Materials

Standard material — PBR surface with albedo, diffuse texture, and transparency:

lysa::StandardMaterial& mat = materialManager.create();
mat.setAlbedoColor({0.0f, 1.0f, 0.0f, 1.0f});
// Bind a texture loaded from the VFS
const lysa::Image& image = imageManager.load("app://res/my_texture.jpg");
// Semi-transparent variant
lysa::StandardMaterial& transparent = materialManager.create();
transparent.setAlbedoColor({1.0f, 0.0f, 0.0f, 0.25f});

Shader material — custom Slang vertex/fragment shaders with per-material parameters:

lysa::ShaderMaterial& shaderMat = materialManager.create(
"uv_gradient.frag", // fragment shader name (without extension)
"scale.vert" // vertex shader name (without extension, optional)
);
// Pass float4 parameters that are read directly in the shader
shaderMat.setParameter(0, lysa::float4{gradient}); // fragment param
shaderMat.setParameter(1, lysa::float4{gradient}); // vertex param

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);
// Restore the mesh's original material
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;
shaderMat.setParameter(0, lysa::float4{gradient});
}

6. Lights

Three light types are available. All lights can optionally cast shadow maps.

Directional light:

{1.0f, 1.0f, 1.0f}, // color
2.0f, // intensity
lysa::float4x4::rotation_x(lysa::radians(-45.0f)),
lysa::float4x4::rotation_y(lysa::radians(-45.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 // range
};
omni.castShadows = true;
omni.shadowMapSize = 1024;
addLight(omni);

Spot light:

{1.0f, 1.0f, 1.0f},
1.5f,
lysa::float4x4::rotation_y(lysa::radians(-90.0f)),
lysa::mul(lysa::float4x4::rotation_x(lysa::radians(-90.0f)),
lysa::float4x4::translation(-7.0f, 1.8f, 0.0f))),
10.0f, // range
lysa::radians(75.0f), // inner cone angle
lysa::radians(80.0f), // outer cone angle
};
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<>():

// In your SceneInstance module
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;
}
// Place clones at random positions
for (int i = 0; i < 5; ++i) {
const auto instance = clone(model);
instance->setTransform(lysa::float4x4::translation(
lysa::randomf(10.0f) - 5.0f,
0.0f,
lysa::randomf(10.0f) - 5.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 fov = lysa::radians(75.0f);
constexpr float near = 0.01f;
constexpr float far = 100.0f;
lysa::float4x4::identity(), // view transform
lysa::perspective(fov, window.getRenderTarget().getAspectRatio(), near, far),
near, far);
// Position the camera
camera.transform = lysa::float4x4::translation(0.0f, 1.8f, 8.0f);

Rebuild the projection matrix when the window is resized:

window.getRenderTarget().id,
[&](const lysa::Event&) {
camera.projection = lysa::perspective(
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.

// Subscribe in PROCESS for mouse look
const auto mousePos = window.getMousePosition();
auto offset = (lastMousePos - mousePos) * mouseSensitivity;
rotate(offset.y * invertY, offset.x); // rotate pivot (X) and attachment (Y)
camera.transform = pivot->globalTransform;
});

9. Handling input

Input events arrive through the event system. Subscribe to RenderingWindowEvent::INPUT on your window's id:

[&](const lysa::Event& evt) {
const auto& inputEvent = std::any_cast<const lysa::InputEvent&>(evt.payload);
scene.onInput(inputEvent);
});

Inside your scene, dispatch on the event type:

void MyScene::onInput(const lysa::InputEvent& event) {
const auto& key = std::get<lysa::InputEventKey>(event.data);
if (key.pressed && key.key == lysa::KEY_SPACE) {
rotating = !rotating;
}
if (!key.pressed && key.key == lysa::KEY_KP_ADD) {
spawnInstance();
}
}
const auto& btn = std::get<lysa::InputEventMouseButton>(event.data);
if (!btn.pressed) captureMouse();
}
}

Polling helpers (call inside PHYSICS_PROCESS):

// Keyboard
// Axis vector from four keys (returns float2 in [-1,1])
lysa::float2 move = lysa::Input::getKeyboardVector(
// Gamepad
lysa::float2 stick = lysa::Input::getGamepadVector(

Mouse capture:

window.setMouseMode(lysa::MouseMode::HIDDEN_CAPTURED); // capture
window.setMouseMode(lysa::MouseMode::VISIBLE); // release
window.resetMousePosition(); // re-center
auto pos = window.getMousePosition(); // float2

10. The event system

Subscribe to any event by type (and optional source id):

// Global event (no source filter)
[&](const lysa::Event& evt) {
const double delta = std::any_cast<double>(evt.payload);
update(delta);
});
// Source-filtered event
renderTarget.id,
[&](const lysa::Event& evt) {
const auto extent = std::any_cast<vireo::Extent>(evt.payload);
onResize(extent);
});
// Unsubscribe when the listener is destroyed

Always store and unsubscribe event from objects with a shorter lifetime than the engine (e.g., in destructors of camera or scene helpers):

Camera::~Camera() {
lysa::ctx().events.unsubscribe(onPhysicsProcess);
}

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);
}
// … later …
scene.addInstance(meshInstance);

Check presence before operating:

if (scene.haveInstance(mesh.meshInstance)) {
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:

lysa::BlurData blurData;
renderTarget.getRendererConfiguration(),
renderTarget.getImageFormat(),
"blur", // compiled shader name in shaders/
&blurData, // push-constant struct (optional)
sizeof(lysa::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); // radius in pixels
}

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);
// Pick the attachment to visualize
display.setAttachment(
dynamic_cast<lysa::DeferredRenderer&>(renderTarget.getRenderer())
.getAOPass().getColorAttachment(frameIndex));

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); // drawn in world space
renderTarget.addUIRenderer(renderer2D); // drawn on top in screen space

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();
// 3D: lines, quads, images, text anchored in world space
renderer3D.drawLine(from, to, {1.0f, 0.0f, 0.0f, 1.0f});
// 2D: screen-space text and images (coordinates in NDC or pixels)
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);