Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions live2d/cubism3/motion.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,35 @@ local motion3 = require("live2d.cubism3.json.motion3")
local MotionPlayer = {}
MotionPlayer.__index = MotionPlayer

function MotionPlayer.new(motion, loop)
-- The Cubism SDK defaults motion fades to 1 second when neither the
-- motion3.json meta nor the model3.json motion reference specifies one.
-- Fading (re-applied on every loop wrap) is what keeps looping idle motions
-- from snapping back to their first keyframe.
local DEFAULT_FADE_SECONDS = 1.0

local function resolved_fade_seconds(override, meta_value)
local fade = tonumber(override)
if fade == nil then
fade = tonumber(meta_value)
end
if fade == nil then
fade = DEFAULT_FADE_SECONDS
end
if fade < 0 then
fade = 0
end
return fade
end

function MotionPlayer.new(motion, loop, fade_in_override, fade_out_override)
return setmetatable({
motion = motion,
time = 0.0,
weight = 1.0,
loop = loop,
finished = false,
fade_in_seconds = resolved_fade_seconds(fade_in_override, motion.meta.FadeInTime),
fade_out_seconds = resolved_fade_seconds(fade_out_override, motion.meta.FadeOutTime),
}, MotionPlayer)
end

Expand Down Expand Up @@ -56,10 +78,8 @@ end
function MotionPlayer:apply(runtime)
local duration = self.motion.meta.Duration or 0
local end_time = self:should_loop() and -1.0 or duration
local fade_in_seconds = tonumber(self.motion.meta.FadeInTime) or 0
local fade_out_seconds = tonumber(self.motion.meta.FadeOutTime) or 0
if fade_in_seconds < 0 then fade_in_seconds = 0 end
if fade_out_seconds < 0 then fade_out_seconds = 0 end
local fade_in_seconds = self.fade_in_seconds or 0
local fade_out_seconds = self.fade_out_seconds or 0
local fade_in = motion3.motion_fade_in_weight(self.time, 0, fade_in_seconds)
local fade_out = motion3.motion_fade_out_weight(self.time, end_time, fade_out_seconds)

Expand Down
23 changes: 23 additions & 0 deletions live2d/cubism3/runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,29 @@ function ModelRuntime:reset_parameters()
end
end

-- Snapshot helpers mirroring the SDK's SaveParameters/LoadParameters: the
-- motion-driven base pose is saved each frame and restored before the next
-- motion update, so per-frame add-ons (drag follow, host writes, overrides)
-- never accumulate into the values motions fade from.
function ModelRuntime:save_parameter_snapshot(out)
local values = self.parameter_values
out = out or {}
for i = 1, #values do
out[i] = values[i]
end
return out
end

function ModelRuntime:load_parameter_snapshot(snapshot)
local values = self.parameter_values
for i = 1, #values do
local value = snapshot[i]
if value ~= nil then
values[i] = value
end
end
end

function ModelRuntime:part_index_of(id)
return self.part_index[id]
end
Expand Down
28 changes: 27 additions & 1 deletion live2d_moc3_embed.lua
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ function Renderer:load_model(model_path, opts)
self.base_path = base
self.model_data = model_data
self.runtime = runtime
self.saved_parameter_values = nil
self.textures = references.textures or {}
self.texture_paths = {}
for i, texture in ipairs(self.textures) do
Expand Down Expand Up @@ -258,6 +259,7 @@ end

function Renderer:reset_parameters()
require_runtime(self):reset_parameters()
self.saved_parameter_values = nil
return self
end

Expand Down Expand Up @@ -379,7 +381,21 @@ function Renderer:load_motion(group, no)
end

function Renderer:start_motion(group, no, weight, loop)
local player = MotionPlayer.new(self:load_motion(group, no), loop)
local motion = self:load_motion(group, no)
-- model3.json motion references may override the motion3.json fades
local reference
local references = self.model_data and self.model_data.file_references
local groups = references and references.motions or {}
local entry = groups[group]
if entry ~= nil then
reference = entry[(tonumber(no) or 0) + 1]
end
local player = MotionPlayer.new(
motion,
loop,
reference and reference.FadeInTime,
reference and reference.FadeOutTime
)
if weight ~= nil then player:set_weight(tonumber(weight) or 1.0) end
self.active_motions[#self.active_motions + 1] = player
return self
Expand Down Expand Up @@ -438,6 +454,11 @@ end
function Renderer:update(delta_seconds)
local runtime = require_runtime(self)
delta_seconds = tonumber(delta_seconds) or 0
-- Restore the motion-driven base pose (SDK LoadParameters) so add-ons
-- applied after the snapshot below never leak into motion fade blending.
if self.saved_parameter_values ~= nil then
runtime:load_parameter_snapshot(self.saved_parameter_values)
end
local motions = self.active_motions
local kept_count = 0
for i = 1, #motions do
Expand All @@ -454,6 +475,10 @@ function Renderer:update(delta_seconds)
end
self.expression_manager:tick(delta_seconds)
self.expression_manager:apply(runtime)
self.saved_parameter_values = runtime:save_parameter_snapshot(self.saved_parameter_values)
if self.pre_override_hook ~= nil then
self.pre_override_hook(runtime, delta_seconds)
end
runtime:apply_parameter_overrides()
runtime:update_physics(delta_seconds)
runtime:apply_pose(delta_seconds)
Expand Down Expand Up @@ -483,6 +508,7 @@ end

function Renderer:dispose()
self.runtime = nil
self.saved_parameter_values = nil
self.model_data = nil
self.model_path = nil
self.base_path = nil
Expand Down
92 changes: 82 additions & 10 deletions live2d_moc3_pet_embed.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ Renderer.__index = Renderer

local GL_COLOR_BUFFER_BIT = 0x00004000

-- Drag (head/eye follow) easing half-life in seconds. The Cubism 2 framework
-- drags through a smoothed target point; applying drag instantly on moc3 made
-- the whole body snap between poses whenever the tracking target changed.
local DRAG_SMOOTHING_HALF_LIFE = 0.1

-- Drag offsets are ADDED on top of motion-driven values each frame (the
-- standard Cubism update order), so idle motions keep their sway instead of
-- being frozen by persistent parameter overrides.
local DRAG_PARAM_SPECS = {
{ id = "ParamAngleX", axis = "x", scale = 30.0 },
{ id = "ParamAngleY", axis = "y", scale = -30.0 },
{ id = "ParamEyeBallX", axis = "x", scale = 1.0 },
{ id = "ParamEyeBallY", axis = "y", scale = -1.0 },
}

local function compute_delta_seconds(state, time_msec)
time_msec = tonumber(time_msec)
if time_msec == nil then
Expand Down Expand Up @@ -80,6 +95,11 @@ function M.new(width, height)
renderer = moc3.new({ gl = gl }),
projection = nil,
last_time_msec = nil,
drag_x = 0.0,
drag_y = 0.0,
drag_target_x = 0.0,
drag_target_y = 0.0,
pending_host_parameters = nil,
gc_frame_count = 0,
}, Renderer)
self:resize(self.width, self.height)
Expand All @@ -97,12 +117,62 @@ function Renderer:load_model(model_path, width, height, opts)
texture_streams = opts.texture_streams or opts.textureStreams or {},
})
self.renderer:load_model(model_path, opts)
self.renderer.pre_override_hook = function(runtime, delta_seconds)
self:_apply_host_state(runtime, delta_seconds)
end
self.last_time_msec = nil
self.drag_x, self.drag_y = 0.0, 0.0
self.drag_target_x, self.drag_target_y = 0.0, 0.0
self.pending_host_parameters = nil
self.gc_frame_count = 0
self:resize(width or self.width, height or self.height)
return self
end

-- Applied from inside Renderer:update, after motions/expressions and before
-- pose, so host writes land on top of motion values for exactly one frame.
function Renderer:_apply_host_state(runtime, delta_seconds)
local pending = self.pending_host_parameters
if pending ~= nil then
self.pending_host_parameters = nil
for i = 1, #pending do
local entry = pending[i]
local index = runtime:parameter_index_of(entry.id)
if index ~= nil then
local value = entry.value
if entry.weight < 1.0 then
local current = runtime:parameter_value_by_index(index) or 0
value = current + (value - current) * entry.weight
end
runtime:set_parameter_by_index(index, value)
end
end
end

local target_x, target_y = self.drag_target_x, self.drag_target_y
local drag_x, drag_y = self.drag_x, self.drag_y
if target_x ~= drag_x or target_y ~= drag_y then
local dt = tonumber(delta_seconds) or 0
local ease = 1.0 - 0.5 ^ (dt / DRAG_SMOOTHING_HALF_LIFE)
drag_x = drag_x + (target_x - drag_x) * ease
drag_y = drag_y + (target_y - drag_y) * ease
if math.abs(target_x - drag_x) < 0.001 then drag_x = target_x end
if math.abs(target_y - drag_y) < 0.001 then drag_y = target_y end
self.drag_x, self.drag_y = drag_x, drag_y
end
if drag_x ~= 0.0 or drag_y ~= 0.0 then
for i = 1, #DRAG_PARAM_SPECS do
local spec = DRAG_PARAM_SPECS[i]
local index = runtime:parameter_index_of(spec.id)
if index ~= nil then
local amount = (spec.axis == "x" and drag_x or drag_y) * spec.scale
local current = runtime:parameter_value_by_index(index) or 0
runtime:set_parameter_by_index(index, current + amount)
end
end
end
end

function Renderer:resize(width, height)
self.width = math.max(tonumber(width) or self.width or 1, 1)
self.height = math.max(tonumber(height) or self.height or 1, 1)
Expand All @@ -123,24 +193,26 @@ function Renderer:set_scale(scale)
return self:resize(self.width, self.height)
end

function Renderer:set_parameter(param_id, value, _weight)
function Renderer:set_parameter(param_id, value, weight)
if self.renderer == nil then return self end
local id = parameter_id(param_id)
if self.renderer.set_parameter_override ~= nil then
pcall(function() self.renderer:set_parameter_override(id, tonumber(value) or 0) end)
else
pcall(function() self.renderer:set_parameter(id, tonumber(value) or 0) end)
local pending = self.pending_host_parameters
if pending == nil then
pending = {}
self.pending_host_parameters = pending
end
pending[#pending + 1] = {
id = parameter_id(param_id),
value = tonumber(value) or 0,
weight = clamp(tonumber(weight) or 1.0, 0.0, 1.0),
}
return self
end

function Renderer:drag(x, y)
local nx = ((tonumber(x) or 0) / math.max(self.width, 1) - 0.5) * 2.0
local ny = ((tonumber(y) or 0) / math.max(self.height, 1) - 0.5) * 2.0
self:set_parameter("ParamAngleX", clamp(nx * 30, -30, 30), 1)
self:set_parameter("ParamAngleY", clamp(-ny * 30, -30, 30), 1)
self:set_parameter("ParamEyeBallX", clamp(nx, -1, 1), 1)
self:set_parameter("ParamEyeBallY", clamp(-ny, -1, 1), 1)
self.drag_target_x = clamp(nx, -1, 1)
self.drag_target_y = clamp(ny, -1, 1)
return self
end

Expand Down