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.


    
main.lua
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()