Table of contents
1. Creating a SceneTree
Subclass lysa::nodes::SceneTree and override the virtual callbacks:
public:
PhysicsScene(lysa::ui::WindowManager& windowManager);
void onReady() override;
void onProcess(double alpha) override;
void onPhysicsProcess(double delta) override;
};
}
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;
lysa::ui::WindowManager windowManager(window, "app://res/fonts/Signwood", 0.25f);
auto scene = std::make_unique<nodes::PhysicsScene>(windowManager);
lysa::ctx().events.subscribe(lysa::RenderTargetEvent::RESIZED,
window.getRenderTarget().id, [&](const lysa::Event&) {
});
lysa::ctx().events.subscribe(lysa::MainLoopEvent::PROCESS,
[&](const lysa::Event&) {
window.getRenderTarget().render();
});
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");
node->setPosition(1.0f, 0.0f, -5.0f);
node->setPositionGlobal(0.0f, 0.0f, 0.0f);
node->translate(0.0f, 0.0f, -1.0f);
node->setRotationY(lysa::radians(90.0f));
node->setScale({2.0f, 2.0f, 2.0f});
float3 forward =
node->getFrontVector();
float3 right =
node->getRightVector();
float3 up =
node->getUpVector();
Positioning nodes built in onReady:
player->translate(0.0f, 0.0f, 15.0f);
directionalLight->rotateX(lysa::radians(-70.0f));
directionalLight->rotateY(lysa::radians(45.0f));
attachment->setPosition(0.0f, attachmentYOffset, attachmentZOffset);
Move a character by computing a direction from input:
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");
parent->addChild(child);
parent->removeChild(child);
parent->removeAllChildren();
bool has = parent->haveChild(child);
bool direct = parent->haveChild(child, false);
parent->printTree();
A common pattern is building a small hierarchy in onReady using child nodes created in the constructor:
Player::Player():
Character(1.8f, 0.5f, PLAYER, "Player"),
pivot(
std::make_shared<Node>(
"Camera pivot")),
attachment(
std::make_shared<Node>(
"Camera attachment")) {}
void Player::onReady() {
model->scale(0.5f);
addChild(model);
attachment->setPosition(0.0f, 2.0f, 2.0f);
addChild(attachment);
attachment->addChild(pivot);
}
Searching the tree for specific node types:
animationPlayers = findAllChildren<lysa::nodes::AnimationPlayer>();
const auto&
scene = getParent();
Dynamically adding and removing nodes at runtime:
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);
if (!
nodes.empty() && removeChild(
nodes.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:
CameraNode::CameraNode():
Node("Player"),
attachment(
std::make_shared<
lysa::
nodes::Node>(
"Camera attachment")) {}
void CameraNode::onReady() {
addChild(attachment);
attachment->addChild(pivot);
}
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);
const auto rot = lysa::quaternion{lysa::float3x3{pivot->getTransform()}};
const auto angle = std::clamp(
static_cast<float>(lysa::euler_angles(rot).x),
maxCameraAngleDown,
maxCameraAngleUp);
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);
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);
pivot->rotateX(x);
}
5. Mesh instances
Load a complete scene hierarchy from an asset pack and add it as a child:
scene->addChild(crateModel);
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):
mesh->setSurfaceOverrideMaterial(0, rayCastOutlineMaterial);
mesh->removeSurfaceOverrideMaterial(0);
Scale a specific instance independently of others:
Print the loaded tree structure to the log for debugging:
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});
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,
lysa::float4{1.0f, 1.0f, 1.0f, 1.0f});
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,
20.0f,
lysa::float4{1.0f, 0.0f, 0.0f, 0.5f});
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:
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:
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)});
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);
root->addChild(floor);
Rigid body — dynamic objects driven by the simulation. Subclass it to add custom behaviour:
public:
Crate():
RigidBody{
std::make_shared<
lysa::BoxCollisionShape>(
lysa::float3{2.0f, 2.0f, 2.0f}),
BODIES, "CrateBody"} {
}
};
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):
force * collision.normal * -1.0f,
collision.position);
Character body — capsule shape moved by code, with ground detection and setVelocity :
public:
Player():
Character(1.8f, 0.5f, PLAYER, "Player") {}
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;
}
const float t = 1.0f - std::exp(-jumpLerpK * delta);
jumpVelocity = jumpVelocity * (1.0f - t) + jumpTarget * t;
if (std::abs(jumpVelocity - jumpTarget) < 0.01f) jumpTarget = 0.0f;
}
};
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()) {
if (!player->isGround(*collision.object) && collision.normal.y < 0.8f) {
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() {
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:
public:
Interactions(): RayCast("interactions") {}
setCollisionLayer(INTERACTIONS);
setTarget({0.0f, 0.0f, -1.0f});
}
if (object) {
} else if (targetNode) {
targetNode = nullptr;
}
}
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();
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:
enemy->addToGroup("enemies");
powerup->addToGroup("collectibles");
powerup->addToGroup("interactable");
auto allEnemies =
scene->findAllChildrenByGroup<Node>(
"enemies");
enemy->removeFromGroup("enemies");
bool isEnemy = enemy->isInGroup("enemies");
12. Process modes
Control whether a node ticks while the scene is paused:
node->setProcessMode(ProcessMode::INHERIT);
node->setProcessMode(ProcessMode::ALWAYS);
node->setProcessMode(ProcessMode::WHEN_PAUSED);
node->setProcessMode(ProcessMode::DISABLED);
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;
}