Advanced skeletal animation
Switch between animation clips with optional cross-fading, layer a masked wave over the base animation, and toggle a cube attached to the character's hand. The panel shows the active clip, playback time, states, and controls.
local natiny = require("natiny")
natiny.backend.init(arg and arg[1] or natiny.backend.AUTO)
local window = natiny.window.create("Natiny - Advanced skeletal animation", 1280, 720)
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_STRETCH)
local MODEL_PATH = "common/models/RobotExpressive.glb"
local PLATFORM_PATH = "common/models/platform.glb"
local BG = natiny.color.from_hex("#151E2C")
local ACCENT = natiny.color.from_hex("#A8C238")
local INK = natiny.color.from_hex("#F0F5FA")
local INK_MUTED = natiny.color.from_hex("#B3BAC7")
local PANEL = {
x = 20,
y = 104,
width = 200,
padding = 18,
line_height = 23,
section_gap = 12,
corner_radius = 12,
background = natiny.color.from_hex("#333840D1"),
}
local LAYER_ROOT = "Torso"
local HAND_BONE = "Middle2.R"
local MODEL_SCALE = 0.35
local CUBE_SIZE = 0.12
local FADE = 0.25
local ORBIT_SPEED = 18.0
local clips, clip_index = {}, 1
local body_layer, wave_layer
local hand_cube
local camera, pivot
local panel_font
local wave_on, fade_on, cube_on = false, true, false
-- Bone children inherit the complete scale of the armature hierarchy.
local function world_scale_of(entity)
local scale = 1.0
while entity and entity ~= 0 do
local sx = natiny.entity.get_scale(entity)
scale = scale * sx
entity = natiny.entity.get_parent(entity)
end
return scale
end
local function build()
local model_resource = natiny.resource.load(MODEL_PATH)
local mesh = natiny.mesh.load(model_resource)
natiny.resource.destroy(model_resource)
local platform_resource = natiny.resource.load(PLATFORM_PATH)
local platform_mesh = natiny.mesh.load(platform_resource)
natiny.resource.destroy(platform_resource)
if not natiny.mesh.get_skinned(mesh) then
error("this model has no skeleton")
end
for i = 0, natiny.mesh.get_animation_count(mesh) - 1 do
clips[#clips + 1] = natiny.mesh.get_animation(mesh, i)
end
local robot = natiny.model.create(mesh)
natiny.entity.set_scale(robot, MODEL_SCALE, MODEL_SCALE, MODEL_SCALE)
body_layer = natiny.animator.create(robot, clips[clip_index])
wave_layer = natiny.animator.create(robot, "Wave")
natiny.animator.set_mask(wave_layer, LAYER_ROOT, true)
natiny.animator.set_order(wave_layer, 1)
natiny.animator.set_weight(wave_layer, 0.0)
local hand = natiny.model.get_bone(robot, HAND_BONE)
if hand then
local shrink = CUBE_SIZE / world_scale_of(hand)
hand_cube = natiny.model.create(natiny.mesh.cube(1.0))
natiny.model.set_color(hand_cube, ACCENT)
natiny.entity.set_parent(hand_cube, hand)
natiny.entity.set_scale(hand_cube, shrink, shrink, shrink)
natiny.model.set_visible(hand_cube, false)
end
natiny.model.create(platform_mesh)
pivot = natiny.pivot.create()
natiny.entity.set_position(pivot, natiny.entity.SPACE_WORLD, 0.0, 0.5, 0.0)
camera = natiny.camera.create()
natiny.entity.set_parent(camera, pivot)
natiny.entity.set_position(camera, natiny.entity.SPACE_LOCAL, 0.0, 0.9, 2.6)
natiny.entity.look_at(camera, 0.0, 0.6, 0.0)
natiny.camera.set_fov(camera, 50.0)
natiny.camera.set_clip(camera, 0.1, 100.0)
end
local function draw_panel()
local font = panel_font
local rows = {
{ "CURRENT CLIP", INK_MUTED },
{ natiny.animation.get_name(clips[clip_index]), INK },
{ string.format("Time %.2f / %.2fs",
natiny.animator.get_time(body_layer),
natiny.animation.get_duration(clips[clip_index])), INK_MUTED },
false,
{ "STATE", INK_MUTED },
{ "Cross-fade", fade_on and ACCENT or INK_MUTED,
fade_on and "ON" or "OFF" },
{ "Wave layer", wave_on and ACCENT or INK_MUTED,
wave_on and "ON" or "OFF" },
{ "Hand cube", cube_on and ACCENT or INK_MUTED,
cube_on and "ON" or "OFF" },
false,
{ "CONTROLS", INK_MUTED },
{ "SPACE", INK, "Next clip" },
{ "ENTER", INK, "Toggle cross-fade" },
{ "L", INK, "Toggle wave layer" },
{ "B", INK, "Toggle hand cube" },
}
local content_height = 0
for _, row in ipairs(rows) do
content_height = content_height
+ (row and PANEL.line_height or PANEL.section_gap)
end
local panel_height = content_height + PANEL.padding * 2
local x = PANEL.x + PANEL.padding
local y = PANEL.y + PANEL.padding
natiny.render.set_color(PANEL.background)
natiny.render.rounded_rectangle(PANEL.x, PANEL.y, PANEL.width,
panel_height, PANEL.corner_radius)
for _, row in ipairs(rows) do
if row then
natiny.render.set_color(row[2])
natiny.render.text(font, x, y, row[1])
if row[3] then
local value_width = natiny.font.measure_text(font, row[3])
natiny.render.text(font,
PANEL.x + PANEL.width - PANEL.padding - value_width,
y, row[3])
end
y = y + PANEL.line_height
else
y = y + PANEL.section_gap
end
end
end
local function handle_input()
if natiny.input.get_key_pressed(natiny.input.KEY_SPACE) then
clip_index = clip_index % #clips + 1
natiny.animator.play(body_layer, clips[clip_index], fade_on and FADE or 0.0)
natiny.animator.set_loop(body_layer, true)
end
if natiny.input.get_key_pressed(natiny.input.KEY_ENTER) then
fade_on = not fade_on
end
if natiny.input.get_key_pressed(natiny.input.KEY_L) then
wave_on = not wave_on
natiny.animator.set_weight(wave_layer, wave_on and 1.0 or 0.0, FADE)
if wave_on then
natiny.animator.play(wave_layer, "Wave", 0.0)
natiny.animator.set_loop(wave_layer, true)
end
end
if hand_cube and natiny.input.get_key_pressed(natiny.input.KEY_B) then
cube_on = not cube_on
natiny.model.set_visible(hand_cube, cube_on)
end
end
local function frame(dt)
handle_input()
natiny.entity.rotate(pivot, natiny.entity.SPACE_LOCAL, 0.0, ORBIT_SPEED * dt, 0.0)
natiny.window.bind(window, function()
natiny.render.clear(BG)
natiny.camera.bind(camera, function()
natiny.render.tag("model")
end)
draw_panel()
end)
end
local font_resource = natiny.resource.load("common/fonts/roboto.ttf")
panel_font = natiny.font.create(font_resource, 16)
natiny.resource.destroy(font_resource)
build()
natiny.backend.loop(function(dt)
frame(dt)
end)
natiny.backend.shutdown()