By VParamix

ReacTails

Drives nine tail sections from one shared phase and exposes the detected smile.

Version
1.0.0
Published
Aug 21, 2026
Face Tracking

ReacTails turns face-tracking expressions into a smooth nine-section tail wave. It accelerates when you smile, slows for a sad expression, and exposes the detected smile as a separate output.

Reference

reactails.lua
-- ReacTails
-- Drives nine tail sections from one shared phase and exposes the detected smile.

local params = vparamix.params
local face = vparamix.face

-- @inspect min=0 max=2 step=0.01
local normal_speed = 0.20
-- @inspect min=0 max=4 step=0.01
local happy_speed = 0.70
-- @inspect min=0 max=2 step=0.01
local sad_speed = 0.07
-- @inspect min=0 max=90 step=1
local section_delay = 25.0
-- @inspect min=0 max=1.5 step=0.01
local amplitude = 1.0
-- @inspect min=0 max=1 step=0.01
local smile_trigger = 0.55

local function clamp(value, min_value, max_value)
    if value < min_value then return min_value end
    if value > max_value then return max_value end
    return value
end

local output = params.declare({
    { name = "ReacTailSmile", min = 0, max = 1, default = 0 },
    { name = "ReacTailWave1", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave2", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave3", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave4", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave5", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave6", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave7", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave8", min = -1, max = 1, default = 0 },
    { name = "ReacTailWave9", min = -1, max = 1, default = 0 },
})

function update()
    local smile = (face.get("MouthSmileLeft") + face.get("MouthSmileRight")) * 0.5
    smile = clamp(smile, 0.0, 1.0)

    local eye_wide = (face.get("EyeWideLeft") + face.get("EyeWideRight")) * 0.5
    local frown = (face.get("MouthFrownLeft") + face.get("MouthFrownRight")) * 0.5
    local brow_down = (face.get("BrowDownLeft") + face.get("BrowDownRight")) * 0.5
    local eye_blink = (face.get("EyeBlinkLeft") + face.get("EyeBlinkRight")) * 0.5
    local eye_relaxed = (1.0 - eye_wide) * 0.5 + eye_blink * 0.5
    local sad = (
        face.get("BrowInnerUp") * 0.35
        + brow_down * 0.25
        + frown * 0.4
        + eye_relaxed * 0.1
        - smile * 0.35
        - 0.12
    ) / 0.85
    sad = clamp(sad, 0.0, 1.0)

    local happy_target = clamp(eye_wide * 0.15, 0.0, 1.0)
    if smile >= smile_trigger then
        happy_target = 1.0
    end

    local happy_blend = load("happy_blend", 0.0)
    local blend_speed = happy_target > happy_blend and 2.0 or 3.0
    local blend_step = clamp(blend_speed * math.min(dt(), 0.1), 0.0, 1.0)
    happy_blend = clamp(
        happy_blend + (happy_target - happy_blend) * blend_step,
        0.0,
        1.0
    )
    save("happy_blend", happy_blend)

    local speed = normal_speed + (happy_speed - normal_speed) * happy_blend
    speed = math.max(0.0, speed + (sad_speed - speed) * sad)

    local angle = load("angle", 0.0) + speed * 360.0 * math.min(dt(), 0.1)
    angle = angle % 360.0
    save("angle", angle)

    local wave_amplitude = clamp(amplitude, 0.0, 1.5)
    local delay = clamp(section_delay, 0.0, 90.0)
    local radians = math.pi / 180.0
    local function wave(section_index)
        return clamp(
            math.sin((angle - delay * section_index) * radians) * wave_amplitude,
            -1.0,
            1.0
        )
    end

    output.set("ReacTailSmile", smile)
    output.set("ReacTailWave1", wave(0))
    output.set("ReacTailWave2", wave(1))
    output.set("ReacTailWave3", wave(2))
    output.set("ReacTailWave4", wave(3))
    output.set("ReacTailWave5", wave(4))
    output.set("ReacTailWave6", wave(5))
    output.set("ReacTailWave7", wave(6))
    output.set("ReacTailWave8", wave(7))
    output.set("ReacTailWave9", wave(8))
end