Lysa Nodes  0.0
Lysa Nodes — Scene Graph for the Lysa Engine
How To Use

Table of contents


1. Creating a SceneTree

Subclass lysa::nodes::SceneTree and override the virtual callbacks:

export module nodes.physics_scene;
import lysa;
import lysa.nodes;
import lysa.ui;
export namespace mygame::nodes {
class PhysicsScene : public lysa::nodes::SceneTree {
public:
PhysicsScene(lysa::ui::WindowManager& windowManager);
void onReady() override; // called once after the tree is built
void onProcess(double alpha) override; // called every rendered frame
void onPhysicsProcess(double delta) override; // called at fixed physics rate
};
}

Wire the scene up to a window in your application entry point. The application is responsible for creating the engine context, the window, subscribing to the main-loop events, and calling lysa.run(). Here is the minimal boilerplate for a lysa::nodes::SceneTree -based application with UI:

int lysaMain() {
lysa::ContextConfiguration contextConfiguration { /* ... */ };
lysa::Lysa lysa(contextConfiguration);
MainWindow window;
// WindowManager takes ownership of font loading
lysa::ui::WindowManager windowManager(window, "app://res/fonts/Signwood", 0.25f);
// Instantiate the scene and attach it to the window
auto scene = std::make_unique<nodes::PhysicsScene>(windowManager);
scene->attach(window);
// Reset viewport/scissors on window resize
lysa::ctx().events.subscribe(lysa::RenderTargetEvent::RESIZED,
window.getRenderTarget().id, [&](const lysa::Event&) {
scene->setViewport({});
scene->setScissors({});
});
// Trigger rendering each frame
lysa::ctx().events.subscribe(lysa::MainLoopEvent::PROCESS,
[&](const lysa::Event&) {
window.getRenderTarget().render();
});
lysa.run();
return 0;
}

2. Transforms

Every node has a local and a world-space transform. Use the helpers or manipulate the matrix directly with lysa::nodes::Node::setTransform and lysa::nodes::Node::getTransform :

auto node = std::make_shared<Node>("Player");
// Position
node->setPosition(1.0f, 0.0f, -5.0f); // local space
node->setPositionGlobal(0.0f, 0.0f, 0.0f); // world space
node->translate(0.0f, 0.0f, -1.0f); // move in local space
// Rotation
node->setRotationY(lysa::radians(90.0f)); // Y axis, radians
node->rotateX(0.01f); // incremental rotation
// Scale
node->setScale({2.0f, 2.0f, 2.0f});
node->scale(0.5f); // uniform scale factor
// Direction vectors (world space)
float3 forward = node->getFrontVector();
float3 right = node->getRightVector();
float3 up = node->getUpVector();

Positioning nodes built in onReady:

// Translate the player to a start position
player->translate(0.0f, 0.0f, 15.0f);
// Orient a directional light
directionalLight->rotateX(lysa::radians(-70.0f));
directionalLight->rotateY(lysa::radians(45.0f));
// Offset a child node from its parent
attachment->setPosition(0.0f, attachmentYOffset, attachmentZOffset);

Move a character by computing a direction from input:

// Build a movement direction relative to the attachment node's orientation
const auto direction = attachment->getRightVector() * input.x
- attachment->getFrontVector() * input.y;
translate(previousState.displacement * (1.0f - delta)
+ currentState.displacement * delta);

3. Child management

auto parent = std::make_shared<Node>("Root");
auto child = std::make_shared<MeshInstance>(mesh, "Sword");
// Add / remove
parent->addChild(child);
parent->removeChild(child);
parent->removeAllChildren();
// Check membership
bool has = parent->haveChild(child); // default: recursive
bool direct = parent->haveChild(child, false);
// Print the subtree to the log
parent->printTree();

A common pattern is building a small hierarchy in onReady using child nodes created in the constructor:

// Player constructor — camera rig built from plain Nodes
Player::Player():
Character(1.8f, 0.5f, PLAYER, "Player"),
camera(std::make_shared<lysa::nodes::Camera>()),
pivot(std::make_shared<Node>("Camera pivot")),
attachment(std::make_shared<Node>("Camera attachment")) {}
void Player::onReady() {
model = lysa::nodes::load("app://res/models/officebot.assets");
model->scale(0.5f);
addChild(model);
attachment->setPosition(0.0f, 2.0f, 2.0f);
addChild(attachment);
attachment->addChild(pivot);
pivot->addChild(camera);
}

Searching the tree for specific node types:

// Find all AnimationPlayer nodes under this node (used for door animations)
animationPlayers = findAllChildren<lysa::nodes::AnimationPlayer>();
// Find the first MeshInstance under a collider to apply an outline material
const auto meshInstance = collision.object->findFirstChild<lysa::nodes::MeshInstance>();
// Get the parent node
const auto& scene = getParent();

Dynamically adding and removing nodes at runtime:

// Spawn a new model node at a random position
const auto newNode = lysa::randomi(1) == 0
? sphere->duplicate() : crate->duplicate();
newNode->setTransform(lysa::float4x4::translation(
lysa::randomf(10.0f) - 5.0f,
lysa::randomf(10.0f) - 5.0f,
lysa::randomf(10.0f) - 15.0f));
addChild(newNode);
nodes.push_back(newNode);
// Remove the most recently added node
if (!nodes.empty() && removeChild(nodes.back())) {
nodes.pop_back();
}

4. Camera

The simplest approach is to inherit from lysa::nodes::Node and build a camera rig inside onReady. Three plain nodes — an attachment (position + yaw), a pivot (pitch), and the actual lysa::nodes::Camera — give smooth, clamped first-person or free-look movement:

// Constructor
CameraNode::CameraNode():
Node("Player"),
camera(std::make_shared<lysa::nodes::Camera>()),
pivot(std::make_shared<lysa::nodes::Node>("Camera pivot")),
attachment(std::make_shared<lysa::nodes::Node>("Camera attachment")) {}
void CameraNode::onReady() {
addChild(attachment);
attachment->addChild(pivot);
pivot->addChild(camera);
}

Apply yaw to the attachment and pitch to the pivot, clamping the pitch angle:

void CameraNode::rotate(const float x, const float y) const {
attachment->rotateY(y);
pivot->rotateX(x);
// Clamp vertical angle
const auto rot = lysa::quaternion{lysa::float3x3{pivot->getTransform()}};
const auto angle = std::clamp(
static_cast<float>(lysa::euler_angles(rot).x),
maxCameraAngleDown, // e.g. -lysa::radians(45.0)
maxCameraAngleUp); // e.g. lysa::radians(60.0)
pivot->setRotationX(angle);
}

Read mouse delta in onProcess to drive the rotation:

void CameraNode::onProcess(float alpha) {
if (mouseCaptured) {
if (const auto* st = getScene<lysa::nodes::SceneTree>()) {
if (st->isAttachedToWindow()) {
const auto& window = st->getRenderingWindow();
auto offset = (lastMousePos - window.getMousePosition())
mouseSensitivity;
offset.y *= mouseInvertedAxisY;
if (any(offset != lysa::FLOAT2ZERO)) {
rotate(offset.y, offset.x);
}
window.resetMousePosition();
lastMousePos = window.getMousePosition();
}
}
}
}

Toggle mouse capture on click:

bool CameraNode::onInput(const lysa::InputEvent& inputEvent) {
if (inputEvent.type == lysa::InputEventType::MOUSE_BUTTON) {
const auto& event = std::get<lysa::InputEventMouseButton>(inputEvent.data);
if (!event.pressed) {
mouseCaptured ? releaseMouse() : captureMouse();
return true;
}
}
return false;
}
void CameraNode::captureMouse() {
if (const auto* st = getScene<lysa::nodes::SceneTree>()) {
if (!mouseCaptured && st->isAttachedToWindow()) {
st->getRenderingWindow().setMouseMode(lysa::MouseMode::HIDDEN_CAPTURED);
mouseCaptured = true;
lastMousePos = st->getRenderingWindow().getMousePosition();
}
}
}
void CameraNode::releaseMouse() {
if (const auto* st = getScene<lysa::nodes::SceneTree>()) {
if (st->isAttachedToWindow()) {
st->getRenderingWindow().setMouseMode(lysa::MouseMode::VISIBLE);
mouseCaptured = false;
}
}
}

For a first-person player that inherits from Character, attach the camera rig the same way inside Player::onReady and rotate the player node itself for yaw instead of a separate attachment node:

void Player::rotate(const float x, const float y) {
rotateY(y); // yaw: rotate the whole Character
pivot->rotateX(x); // pitch: tilt only the camera pivot
// ... clamp pitch as above
}

5. Mesh instances

Load a complete scene hierarchy from an asset pack and add it as a child:

// Load a packed asset and attach it to the scene
const auto crateModel = lysa::nodes::load("app://res/models/crate.assets");
scene->addChild(crateModel);
// Duplicate to create multiple independent instances
for (int x = 0; x < 4; x++) {
for (int z = 0; z < 4; z++) {
const auto model = crateModel->duplicate();
model->setPosition({x * 5.0f, 0.0f, -z * 5.0f});
root->addChild(model);
}
}

Override the material on a specific surface (e.g. highlight an object under the cursor):

// Apply an outline material to the mesh in front of the player
const auto mesh = collider->findFirstChild<lysa::nodes::MeshInstance>();
mesh->setSurfaceOverrideMaterial(0, rayCastOutlineMaterial);
// Remove the override to restore the original material
mesh->removeSurfaceOverrideMaterial(0);

Scale a specific instance independently of others:

model->scale(0.5f);

Print the loaded tree structure to the log for debugging:

scene->printTree();

6. Lights

Always add an Environment node first to set the ambient term. The fourth component of the float4 is the intensity:

addChild(std::make_shared<lysa::nodes::Environment>(lysa::float4{1.0f, 1.0f, 1.0f, 0.25f}));

Directional light (sun) with optional shadow casting:

const auto sun = std::make_shared<lysa::nodes::DirectionalLight>(
lysa::float4{1.0f, 1.0f, 1.0f, 2.0f}); // RGB + intensity
sun->rotateX(lysa::radians(-70.0f));
sun->rotateY(lysa::radians(45.0f));
sun->setCastShadows(true);
sun->setShadowMapSize(2048);
addChild(sun);

Omni (point) light:

const auto omni = std::make_shared<lysa::nodes::OmniLight>(
20.0f, // range in metres
lysa::float4{1.0f, 1.0f, 1.0f, 1.0f}); // RGB + intensity
omni->setCastShadows(true);
omni->setShadowMapSize(2048);
omni->translate({-2.0f, 4.0f, 0.0f});
addChild(omni);

Spotlight:

const auto spot = std::make_shared<lysa::nodes::SpotLight>(
75.0f, 85.0f, // inner / outer cutoff (degrees)
20.0f, // range
lysa::float4{1.0f, 0.0f, 0.0f, 0.5f}); // RGB + intensity
spot->setCastShadows(true);
spot->setShadowMapSize(2048);
spot->translate({0.0f, 2.0f, 0.0f});
addChild(spot);

7. Animation playback

AnimationPlayer nodes are usually loaded as part of an asset pack. Find them with findAllChildren and call play / playBackwards:

// Door node: collect all AnimationPlayer children on ready
void Door::onReady() {
animationPlayers = findAllChildren<lysa::nodes::AnimationPlayer>();
}
void Door::open() const {
for (auto& player : animationPlayers) {
if (!player->isPlaying()) { player->play(); }
}
}
void Door::close() const {
for (auto& player : animationPlayers) {
if (!player->isPlaying()) { player->playBackwards(); }
}
}

Listen for the finish event to toggle state after an animation completes:

animationPlayers.front()->connect(
lysa::nodes::AnimationPlayer::on_playback_finish,
[this] { toggleState(); });

For a scene loaded from an asset pack, find the player and configure looping before calling play:

const auto scene = lysa::nodes::load("app://res/models/anim.assets");
const auto animPlayer = scene->findFirstChild<lysa::nodes::AnimationPlayer>();
animPlayer->getCurrentAnimation()->setLoopMode(lysa::AnimationLoopMode::LINEAR);
animPlayer->play();
addChild(scene);

8. Physics bodies

Configure collision layers in ContextConfiguration before creating any physics nodes:

lysa::ContextConfiguration contextConfiguration {
.physicsEngineConfiguration {
.layerCollisionTable = lysa::LayerCollisionTable{
LAYERS_COUNT,
{
{ PLAYER, { WORLD, BODIES, USABLE_PROP } },
{ BODIES, { WORLD, BODIES, PLAYER, USABLE_PROP } },
{ PLAYER_RAYCAST, { BODIES } },
{ TRIGGERS, { PLAYER } },
{ INTERACTIONS, { USABLE_PROP } },
}
},
},
};

Static body — immovable terrain, floor, walls. Compound shapes let you combine multiple collision primitives into one body:

auto floorMaterial = lysa::ctx().physicsEngine->createMaterial(0.5f, 0.5f);
lysa::ctx().physicsEngine->setRestitutionCombineMode(
floorMaterial, lysa::CombineMode::MAX);
std::vector<lysa::CollisionSubShape> floorSubShapes;
floorSubShapes.push_back(lysa::CollisionSubShape{
std::make_shared<lysa::BoxCollisionShape>(lysa::float3{50.0f, 0.2f, 50.0f}, floorMaterial)});
// invisible boundary wall
floorSubShapes.push_back(lysa::CollisionSubShape{
std::make_shared<lysa::BoxCollisionShape>(lysa::float3{25.0f, 10.0f, 1.0f}),
lysa::float3{0.0f, 5.0f, -15.0f}});
const auto floor = std::make_shared<lysa::nodes::StaticBody>(
std::make_shared<lysa::StaticCompoundCollisionShape>(floorSubShapes),
WORLD, "Floor");
floor->addChild(floorScene); // attach the visual mesh as a child
root->addChild(floor);

Rigid body — dynamic objects driven by the simulation. Subclass it to add custom behaviour:

class Crate : public lysa::nodes::RigidBody {
public:
Crate():
RigidBody{
std::make_shared<lysa::BoxCollisionShape>(lysa::float3{2.0f, 2.0f, 2.0f}),
BODIES, "CrateBody"} {
setDensity(400.0f);
}
};
// Spawn a grid of crates with random heights
const auto crateModel = lysa::nodes::load("app://res/models/crate.assets");
for (int x = 0; x < 4; x++) {
for (int z = 0; z < 4; z++) {
const auto model = crateModel->duplicate();
auto body = std::make_shared<Crate>();
body->addChild(model);
body->setPosition({x * 5.0f - 7.5f, 2.0f + std::rand() % 5, -z * 5.0f + 5.0f});
root->addChild(body);
}
}

Apply an impulse to push a rigid body (e.g. when the player shoves a crate):

dynamic_cast<lysa::nodes::RigidBody*>(collision.object)->addImpulse(
force * collision.normal * -1.0f,
collision.position);

Character body — capsule shape moved by code, with ground detection and setVelocity :

class Player : public lysa::nodes::Character {
public:
Player():
Character(1.8f, 0.5f, PLAYER, "Player") {}
void onPhysicsProcess(float delta) override {
const auto onGround = isOnGround();
bool jumpPressed = lysa::Input::isKeyPressed(lysa::KEY_SPACE);
if (jumpPressed && (onGround || coyoteJumpAirTime < coyoteJumpTimeThreshold)) {
jumpTarget = jumpMax;
coyoteJumpAirTime = coyoteJumpTimeThreshold;
}
if (onGround) {
coyoteJumpAirTime = 0.0f;
const auto direction = mul(lysa::float3{input.x, 0.0f, input.y},
lysa::TRANSFORM_BASIS);
velocity = direction * movementsSpeed * delta;
} else {
coyoteJumpAirTime += delta;
const float airTime = 1.0f - std::exp(-airDragK * delta);
velocity *= (1.0f - airTime);
velocity.y = gravity * delta;
}
// Smooth jump with exponential lerp
const float t = 1.0f - std::exp(-jumpLerpK * delta);
jumpVelocity = jumpVelocity * (1.0f - t) + jumpTarget * t;
velocity += jumpVelocity * getUpVector();
if (std::abs(jumpVelocity - jumpTarget) < 0.01f) jumpTarget = 0.0f;
setVelocity(velocity);
}
};

Collision area — sensor/trigger, no physical response:

const auto area = std::make_shared<lysa::nodes::CollisionArea>(
std::make_shared<lysa::SphereCollisionShape>(2.0f),
TRIGGERS, "DoorTrigger");
area->setPosition({0.0f, -1.4f, 0.0f});
area->connect(lysa::CollisionObject::on_collision_starts, [this] { open(); });
addChild(area);

Iterate the player's active collisions each frame (e.g. to highlight side-colliding crates):

void PhysicsScene::onProcess(double alpha) {
for (const auto& collision : player->getCollisions()) {
// Ignore collisions where the player is standing on top of the object
if (!player->isGround(*collision.object) && collision.normal.y < 0.8f) {
const auto mesh = collision.object->findFirstChild<lysa::nodes::MeshInstance>();
mesh->setSurfaceOverrideMaterial(0, collisionOutlineMaterial);
}
}
}

9. Ray cast

Attach a RayCast node to a character to query the physics world each tick.

Set up the ray in onReady:

void PhysicsScene::onReady() {
// Ray extends 50 units along AXIS_FRONT, offset 0.5 m above the player origin
rayCast = std::make_shared<lysa::nodes::RayCast>(
lysa::AXIS_FRONT * 50.0f, PLAYER_RAYCAST);
rayCast->setPosition({0.0f, 0.5f, 0.0f});
player->addChild(rayCast);
}

For interaction detection, subclass RayCast directly:

class Interactions : public lysa::nodes::RayCast {
public:
Interactions(): RayCast("interactions") {}
void onReady() override {
setCollisionLayer(INTERACTIONS);
setTarget({0.0f, 0.0f, -1.0f});
}
void onProcess(float alpha) override {
const auto& object = getCollider();
if (object) {
// object in range — show interaction prompt
} else if (targetNode) {
targetNode = nullptr;
// hide prompt
}
}
private:
std::shared_ptr<Node> targetNode;
};

Read the hit result in onProcess to update UI or apply material overrides:

if (rayCast->isColliding()) {
const auto& collider = rayCast->getCollider();
const auto mesh = collider->findFirstChild<lysa::nodes::MeshInstance>();
if (mesh != previousSelection) {
mesh->setSurfaceOverrideMaterial(0, rayCastOutlineMaterial);
if (previousSelection)
previousSelection->removeSurfaceOverrideMaterial(0);
previousSelection = mesh;
infoText->setText(mesh->getName() + " #" + std::to_string(mesh->getId()));
infoBox->show();
}
} else if (previousSelection != nullptr) {
previousSelection->removeSurfaceOverrideMaterial(0);
previousSelection = nullptr;
infoBox->hide();
}

10. Groups

Groups let you tag arbitrary sets of nodes and query them together:

// Tag nodes
enemy->addToGroup("enemies");
powerup->addToGroup("collectibles");
powerup->addToGroup("interactable");
// Query
auto allEnemies = scene->findAllChildrenByGroup<Node>("enemies");
// Remove from group
enemy->removeFromGroup("enemies");
// Check membership
bool isEnemy = enemy->isInGroup("enemies");

12. Process modes

Control whether a node ticks while the scene is paused:

// Default: inherits from parent (root defaults to PAUSABLE)
node->setProcessMode(ProcessMode::INHERIT);
// Always ticks, even when paused — useful for UI or pause menus
node->setProcessMode(ProcessMode::ALWAYS);
// Only ticks while paused — useful for pause-menu nodes
node->setProcessMode(ProcessMode::WHEN_PAUSED);
// Never ticks
node->setProcessMode(ProcessMode::DISABLED);
// Pause / resume the whole tree
scene->setPaused(true);
scene->setPaused(false);

Listen for pause/resume events in a SceneTree subclass:

void TrianglesScene::onPause() {
lysa::Log::game1("Paused");
}
void TrianglesScene::onResume() {
lysa::Log::game1("Resumed");
}

Toggle pause from input:

bool TrianglesScene::onInput(const lysa::InputEvent& event) {
if (event.type == lysa::InputEventType::KEY) {
const auto& evt = std::get<lysa::InputEventKey>(event.data);
if (evt.pressed && evt.key == lysa::KEY_P) {
setPaused(!isPaused());
return true;
}
}
return false;
}