Table of contents
1. Overview
When Lysa Nodes is built with LUA_BINDINGS=ON, every node type is accessible from Lua scripts via the lysa.nodes global table. This lets you drive scene logic entirely in Lua — attaching scripts to nodes, responding to lifecycle callbacks, manipulating transforms, loading assets, and interacting with the physics engine — without writing any additional C++.
Type annotations for all bindings are provided in src/bindings/lua/lysa_nodes.lua as an ---@meta file for use with Lua language servers (e.g. LuaLS or EmmyLua).
2. Enabling the Lua bindings
Enable the CMake option before configuring:
cmake -DLUA_BINDINGS=ON ..
In your C++ application, call setScript on the SceneTree and pass the script name (without extension). The engine resolves the file from the virtual file system:
auto scene = std::make_unique<lysa::nodes::SceneTree>(
lysa::SceneConfiguration{.asyncObjectUpdatesPerFrame = 20});
scene->setScript(
"game/main_scene");
3. Script lifecycle callbacks
A Lua script is a plain table returned at the end of the file. The engine looks for the following optional functions on that table and calls them at the appropriate points in the frame loop:
| Callback | When called |
_init() | Called once right after the table is constructed, before the node enters the scene. Use it to initialize fields. |
_attach() | Called when the SceneTree is attached to a window. Useful for acquiring render targets. |
_detach() | Called when the SceneTree is detached. Clean up resources such as UI widgets or extra renderers here. |
_ready() | Called once after the node (or scene) is fully added to the tree. Build your scene graph here. |
_process() | Called every rendered frame. Use for rendering-side logic (UI updates, visual effects). |
_physics_process(delta) | Called at the fixed physics rate. Use for movement and physics-driven logic. delta is the time step in seconds. |
_input(input_event) | Called when an input event arrives. Return true to consume the event. |
_enter_scene() | Called when a node is added to the active scene tree. |
_exit_scene() | Called when a node is removed from the active scene tree. |
self.this always refers to the underlying C++ node (a lysa.nodes.SceneTree for scene scripts, or whatever node the script is attached to). It allows you to have hybrid Lua/C++ objects.
A minimal scene script skeleton:
---@class MainScene
---@field this lysa.nodes.SceneTree
local MainScene = {}
MainScene.__index = MainScene
function MainScene:_init()
-- initialize fields
end
function MainScene:_ready()
-- build the scene graph
end
function MainScene:_process()
-- per-frame updates
end
return MainScene
4. Setting up a SceneTree script
The scene tree script is the root of your Lua scene. It is attached to the SceneTree on the C++ side. Use _ready to populate the tree and _detach to release UI widgets or other resources that must be freed explicitly.
---@class MainScene
---@field this lysa.nodes.SceneTree
local MainScene = {}
MainScene.__index = MainScene
---@type lysa.ui.WindowManager
local window_manager = ctx.res.window_manager
local hud = window_manager:create(lysa.RECT_FULLSCREEN)
local text_loading = hud:create_text(lysa.ui.Alignment.CENTER, "Loading...")
function MainScene:_detach()
window_manager:remove(hud)
end
function MainScene:_ready()
self.this:add_child(lysa.nodes.Environment.create(lysa.float4(1.0, 1.0, 1.0, 0.25)))
-- add more children here
end
return MainScene
For scripts that need a render target (e.g. to add custom renderers), use _attach to grab it before _ready is called:
function MainScene:_attach()
self.rt = self.this:get_render_target()
self.window = self.this:get_rendering_window()
end
function MainScene:_detach()
self.rt:remove_scene_renderer(self.vector_renderer)
end
5. Loading and adding nodes
Nodes can be loaded synchronously or asynchronously from asset packs, and then added as children of the scene tree or of other nodes.
Synchronous load — blocks until the asset is fully loaded. Suitable for small assets :
function MainScene:_ready()
local scene = lysa.nodes.Node.load("app://res/models/crate.assets")
self.this:add_child(scene)
end
Asynchronous load — returns immediately; the callback is invoked on the main thread once loading is complete. Use this for large assets to avoid frame hitches:
function MainScene:_ready()
lysa.nodes.Node.load_async("app://res/models/city_buildings.assets",
function(node)
self.this:add_child(node)
text_loading:show(false)
end)
end
Creating nodes directly — most node types expose a static create factory:
-- Generic node
local node = lysa.nodes.Node.create("MyNode")
-- Camera (several overloads)
local camera = lysa.nodes.Camera.create()
local camera2 = lysa.nodes.Camera.create("MainCamera")
-- Environment (ambient light)
local env = lysa.nodes.Environment.create(lysa.float4(1.0, 1.0, 1.0, 0.25))
-- Directional light
local sun = lysa.nodes.DirectionalLight.create(lysa.float4(1.0, 1.0, 1.0, 2.0))
Adding and removing children:
self.this:add_child(node) -- add to scene tree root
parent:add_child(child) -- add to another node (sync)
parent:add_child(child, true) -- async variant
parent:remove_child(child)
parent:remove_all_children()
Traversal helpers:
local child = parent:get_child("Sword")
local deep = parent:get_child_by_path("Armature/Sword")
local found = parent:find_first_child("Sword")
6. Transform manipulation
Every node exposes local and world-space transform helpers. All angles are in radians; use lysa.radians(degrees) to convert.
Position:
-- Local space
node:set_position(lysa.float3(1.0, 0.0, -5.0))
node:set_position_xyz(1.0, 0.0, -5.0)
node:translate(lysa.float3(0.0, 0.0, -1.0))
node:translate_xyz(0.0, 0.0, -1.0)
-- World space
node:set_position_global(lysa.float3(0.0, 0.0, 0.0))
node:set_position_global_xyz(0.0, 0.0, 0.0)
local pos = node:get_position() -- local lysa.float3
local wpos = node:get_position_global() -- world lysa.float3
Rotation:
-- Incremental rotation (radians)
node:rotate_x(lysa.radians(15.0))
node:rotate_y(lysa.radians(-90.0))
node:rotate_z(lysa.radians(30.0))
-- Absolute rotation (euler angles, radians)
node:set_rotation_x(lysa.radians(45.0))
node:set_rotation_y(0.0)
-- Full transform matrix
node:set_transform(
lysa.mul(
lysa.float4x4.rotation_x(self.pitch),
lysa.mul(
lysa.float4x4.rotation_y(self.yaw),
lysa.float4x4.translation(self.pivot_position)
)
)
)
Scale:
node:scale(0.5) -- uniform
node:set_scale(lysa.float3(2.0, 2.0, 2.0)) -- per-axis
local s = node:get_scale()
Direction vectors (world space):
local fwd = node:get_front_vector()
local right = node:get_right_vector()
local up = node:get_up_vector()
Look-at and coordinate conversion:
node:look_at(target_position)
local world = node:to_global(lysa.float3(0, 0, 1))
local local_pos = node:to_local(world_pos)
7. Camera
Create a camera, optionally attach a Lua script to it, and add it to the scene:
function MainScene:_ready()
local camera = lysa.nodes.Camera.create()
camera:set_script("camera") -- loads camera.lua and attaches it
camera.self:set_fov(60.0)
self.this:add_child(camera)
self.this:set_camera(camera) -- make it the active camera
end
- Note
- When a script is attached to a node via
set_script, the script's this field is the underlying lysa.nodes.* object (e.g. lysa.nodes.Camera), while the script table itself is accessed via self. For example use camera.self from the outside to call Lua node methods on the camera script object.
Inside the camera script, configure projection in _enter_scene:
function Camera:_enter_scene()
-- Perspective: fov (degrees), aspect ratio, near, far
self.this:set_perspective_projection(self.current_fov, 1.0, 150.0)
end
Switching between perspective and orthographic:
-- Perspective
camera:set_perspective_projection(fov, aspect, near, far)
camera:set_fov(45.0)
camera:set_aspect_ratio(16/9)
camera:set_near_distance(0.1)
camera:set_far_distance(1000.0)
-- Orthographic
camera:set_orthographic_projection(left, right, bottom, top, near, far)
Orbit camera pattern — compute camera position from yaw, pitch, and a pivot point, then set the full transform matrix each physics frame:
function Camera:_physics_process(delta)
self.this:set_transform(
lysa.mul(lysa.float4x4.rotation_x(self.pitch),
lysa.mul(lysa.float4x4.rotation_y(self.yaw),
lysa.float4x4.translation(self.pivot_position)))
)
end
Screen-to-world ray — useful for mouse picking or editor tools:
function MainScene:get_current_cell()
local mouse = self.window:get_mouse_position()
local ray = self.camera:screen_to_world(
mouse,
lysa.float2(self.rt.width, self.rt.height)
)
if ray.direction.y ~= 0 then
local t = -ray.origin.y / ray.direction.y
local wx = ray.origin.x + t * ray.direction.x
local wz = ray.origin.z + t * ray.direction.z
-- wx, wz are the world-space coordinates on the y=0 plane
end
end
8. Lights
Always add an Environment node first to provide ambient light. The four components of the float4 are R, G, B and intensity:
self.this:add_child(
lysa.nodes.Environment.create(lysa.float4(1.0, 1.0, 1.0, 0.25))
)
Directional light (sun):
local light = lysa.nodes.DirectionalLight.create(lysa.float4(1.0, 1.0, 1.0, 1.0))
light:set_cast_shadows(true)
light:set_shadow_map_size(8196)
light:set_shadow_map_cascades_count(4)
light:rotate_x(lysa.radians(-60))
light:rotate_y(lysa.radians(-20))
self.this:add_child(light)
OmniLight (point light) and SpotLight share the Light base:
-- Color and intensity together as float4
light:set_color_and_intensity(lysa.float4(1.0, 0.8, 0.6, 1.5))
local color = light:get_color_and_intensity()
-- Shadow map
light:set_cast_shadows(true)
light:set_shadow_map_size(2048)
-- OmniLight range
omni:set_range(20.0)
-- SpotLight cutoff angles (radians)
spot:set_cut_off(lysa.radians(25.0))
spot:set_outer_cut_off(lysa.radians(35.0))
9. Animation
AnimationPlayer nodes are typically loaded as part of an asset pack. Retrieve a reference with find_first_child or get_child_by_path, then control playback:
function MainScene:_ready()
local scene = lysa.nodes.Node.load("app://res/models/anim.assets")
local player = scene:find_first_child("AnimationPlayer")
player:set_current_animation("Walk")
player:play() -- play named (or current) animation
player:play_backwards() -- reverse playback
player:stop()
player:seek(1.5) -- jump to time in seconds
player:set_auto_start(true)
self.this:add_child(scene)
end
function MainScene:_process()
if player:is_playing() then
-- animation is running
end
end
Animation libraries:
player:set_current_library("combat")
player:set_current_animation("Attack")
player:play()
10. Physics bodies
Collision layers are configured on the C++ side in ContextConfiguration before the scene starts.
RigidBody:
local body = -- a lysa.nodes.RigidBody obtained from the scene graph
body:set_density(400.0)
body:set_mass(10.0)
body:set_gravity_factor(1.0)
-- Velocity
body:set_velocity(lysa.float3(0.0, 5.0, 0.0))
local vel = body:get_velocity()
-- Forces and impulses
body:add_force(lysa.float3(0.0, 100.0, 0.0), nil) -- force at center
body:add_impulse(lysa.float3(10.0, 0.0, 0.0))
Character body:
-- Capsule shape: height, radius
character:set_shape(1.8, 0.5)
-- Ground detection
if character:is_on_ground() then
character:set_velocity(lysa.float3(vx, 0.0, vz))
else
local gv = character:get_ground_velocity()
end
-- Up vector
character:set_up(lysa.float3(0.0, 1.0, 0.0))
local up = character:get_up()
-- Active collisions (returns a list)
local collisions = character:get_collisions()
for i, col in ipairs(collisions) do
-- col.position, col.normal, col.object
end
RayCast:
local collider = raycast:get_collider() -- returns lysa.nodes.CollisionObject or nil
if collider then
-- something was hit
end
11. Input handling
Handle input inside _input(input_event). Return true to consume the event and prevent further propagation.
---@param input_event lysa.InputEvent
function Camera:_input(input_event)
-- Mouse buttons
if input_event.type == lysa.InputEventType.MOUSE_BUTTON then
local event = input_event.input_event_mouse_button
if event.button == lysa.MouseButton.RIGHT and event.pressed then
self.mouse_rotating = true
return true
end
if event.button == lysa.MouseButton.WHEEL then
-- event.pressed is true for scroll-up, false for scroll-down
self:set_zoom(self.current_fov + (event.pressed and 1.2 or -1.2))
return true
end
end
-- Mouse motion
if input_event.type == lysa.InputEventType.MOUSE_MOTION then
if self.mouse_rotating then
local event = input_event.input_event_mouse_motion
self.yaw = self.yaw - event.relative.x * 0.002
return true
end
end
return false
end
Polling input in _physics_process:
function Camera:_physics_process(delta)
if lysa.Input.is_key_pressed(lysa.Key.KEY_W) then
-- move forward
end
if lysa.Input.is_key_pressed(lysa.Key.KEY_SPACE) then
-- jump
end
end
Gamepad support:
function Camera:_enter_scene()
for i = 0, lysa.Input.get_connected_joypads() - 1 do
if lysa.Input.is_gamepad(i) then
self.gamepad_index = i
break
end
end
end
function Camera:_physics_process(delta)
if self.gamepad_index >= 0 then
-- Returns a float2 vector from an analog stick
local rs = lysa.Input.get_gamepad_vector(
self.gamepad_index,
lysa.GamepadAxisJoystick.RIGHT)
if not rs.is_zero then
self.yaw = self.yaw - rs.x * delta
end
end
end
12. Groups
Tag arbitrary sets of nodes and query them as a group:
-- Add / remove
node:add_to_group("enemies")
node:remove_from_group("enemies")
-- Membership check
if node:is_in_group("enemies") then ... end
13. Process modes
Control whether a node receives _process and _physics_process callbacks while the scene is paused. Use the lysa.nodes.ProcessMode enum:
node:set_process_mode(lysa.nodes.ProcessMode.INHERIT) -- default: follows parent
node:set_process_mode(lysa.nodes.ProcessMode.ALWAYS) -- runs even when paused
node:set_process_mode(lysa.nodes.ProcessMode.WHEN_PAUSED) -- only runs while paused
node:set_process_mode(lysa.nodes.ProcessMode.DISABLED) -- never runs
node:set_process_mode(lysa.nodes.ProcessMode.PAUSABLE) -- runs unless paused
-- Pause / resume the whole tree
self.this:set_paused(true)
if self.this:is_paused() then ... end