From 9e31aee42dfc3daf00a047c1a81f341cffefed4d Mon Sep 17 00:00:00 2001 From: XiuchengWu <732857315@qq.com> Date: Thu, 9 Jul 2026 00:29:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(live2d):=20=E6=B7=BB=E5=8A=A0=20Pose=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=A8=A1=E5=9D=97=E4=BB=A5=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=BA=92=E6=96=A5=20Part=20=E7=9A=84=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=8F=82=E6=95=B0=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/live2d/core_ffi.rs | 3 + src/plugins/live2d/mod.rs | 2 + src/plugins/live2d/model.rs | 78 ++++++++++- src/plugins/live2d/pose.rs | 233 +++++++++++++++++++++++++++++++++ src/plugins/live2d/renderer.rs | 17 ++- 5 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 src/plugins/live2d/pose.rs diff --git a/src/plugins/live2d/core_ffi.rs b/src/plugins/live2d/core_ffi.rs index 78232f3..f1b5fa9 100644 --- a/src/plugins/live2d/core_ffi.rs +++ b/src/plugins/live2d/core_ffi.rs @@ -91,6 +91,8 @@ pub struct CubismCore { unsafe extern "C" fn(model: *const c_void) -> *const f32, pub csm_get_parameter_values: unsafe extern "C" fn(model: *mut c_void) -> *mut f32, pub csm_get_part_count: unsafe extern "C" fn(model: *const c_void) -> c_int, + pub csm_get_part_ids: + unsafe extern "C" fn(model: *const c_void) -> *const *const c_char, pub csm_get_part_opacities: unsafe extern "C" fn(model: *mut c_void) -> *mut f32, pub csm_get_drawable_count: unsafe extern "C" fn(model: *const c_void) -> c_int, pub csm_get_drawable_ids: @@ -163,6 +165,7 @@ impl CubismCore { .get(b"csmGetParameterDefaultValues\0")?, csm_get_parameter_values: *lib.get(b"csmGetParameterValues\0")?, csm_get_part_count: *lib.get(b"csmGetPartCount\0")?, + csm_get_part_ids: *lib.get(b"csmGetPartIds\0")?, csm_get_part_opacities: *lib.get(b"csmGetPartOpacities\0")?, csm_get_drawable_count: *lib.get(b"csmGetDrawableCount\0")?, csm_get_drawable_ids: *lib.get(b"csmGetDrawableIds\0")?, diff --git a/src/plugins/live2d/mod.rs b/src/plugins/live2d/mod.rs index eda92b5..86182f7 100644 --- a/src/plugins/live2d/mod.rs +++ b/src/plugins/live2d/mod.rs @@ -6,6 +6,7 @@ //! - `assets`:model3.json 解析、PNG 纹理加载 //! - `gl`:EGL 上下文 + OpenGL ES 2.0 着色器/渲染 //! - `animation`:参数动画(眨眼、嘴部、待机呼吸) +//! - `pose`:Pose PartOpacity 切换(避免互斥手臂/服装同时显示) //! - `renderer`:整合渲染循环 pub mod animation; @@ -13,6 +14,7 @@ pub mod assets; pub mod core_ffi; pub mod gl; pub mod model; +pub mod pose; pub mod renderer; mod plugin; diff --git a/src/plugins/live2d/model.rs b/src/plugins/live2d/model.rs index 8b7a584..f7233d9 100644 --- a/src/plugins/live2d/model.rs +++ b/src/plugins/live2d/model.rs @@ -64,6 +64,12 @@ pub struct CubismModel { pub param_ids: Vec, /// 参数索引映射(id → index) pub param_index: std::collections::HashMap, + /// Part 数量 + pub part_count: usize, + /// Part ID 列表(与 Core 内部顺序一致) + pub part_ids: Vec, + /// Part 索引映射(id → index) + pub part_index: std::collections::HashMap, /// drawable 数量 pub drawable_count: usize, /// canvas 信息 @@ -134,6 +140,20 @@ impl CubismModel { param_ids.push(id); } + // 读取 part 信息(Pose 会按 PartOpacity 切换互斥部件,如 Haru 的两组手臂) + let part_count = unsafe { (core.csm_get_part_count)(model) } as usize; + let part_ids_ptr = unsafe { (core.csm_get_part_ids)(model) }; + let mut part_ids = Vec::with_capacity(part_count); + let mut part_index = std::collections::HashMap::new(); + for i in 0..part_count { + let id = unsafe { + super::core_ffi::cstr_ptr_to_string(*part_ids_ptr.add(i)) + .unwrap_or_default() + }; + part_index.insert(id.clone(), i); + part_ids.push(id); + } + // 读取 drawable 数量 let drawable_count = unsafe { (core.csm_get_drawable_count)(model) } as usize; @@ -152,8 +172,8 @@ impl CubismModel { }; println!( - "[Live2D] 模型加载成功: 参数={} drawable={} canvas={:?} origin=({:.1},{:.1})x{:.0}ppu", - param_count, drawable_count, canvas.size, canvas.origin[0], canvas.origin[1], canvas.pixels_per_unit + "[Live2D] 模型加载成功: 参数={} part={} drawable={} canvas={:?} origin=({:.1},{:.1})x{:.0}ppu", + param_count, part_count, drawable_count, canvas.size, canvas.origin[0], canvas.origin[1], canvas.pixels_per_unit ); Ok(Self { @@ -165,6 +185,9 @@ impl CubismModel { param_count, param_ids, param_index, + part_count, + part_ids, + part_index, drawable_count, canvas, }) @@ -205,19 +228,34 @@ impl CubismModel { /// 按参数 ID 设置值 pub fn set_param(&self, core: &CubismCore, id: &str, value: f32) -> bool { if let Some(&idx) = self.param_index.get(id) { - let slice = self.param_values_mut(core); - slice[idx] = value; + self.set_param_by_index(core, idx, value); true } else { false } } + /// 按参数索引设置值 + pub fn set_param_by_index(&self, core: &CubismCore, index: usize, value: f32) { + if index < self.param_count { + let slice = self.param_values_mut(core); + slice[index] = value; + } + } + /// 按参数 ID 获取值 pub fn get_param(&self, core: &CubismCore, id: &str) -> Option { let idx = *self.param_index.get(id)?; + self.get_param_by_index(core, idx) + } + + /// 按参数索引获取值 + pub fn get_param_by_index(&self, core: &CubismCore, index: usize) -> Option { + if index >= self.param_count { + return None; + } let slice = self.param_values_mut(core); - Some(slice[idx]) + Some(slice[index]) } /// 将所有参数重置为默认值 @@ -229,6 +267,36 @@ impl CubismModel { } } + /// 获取 PartOpacity 缓冲区(可读写) + pub fn part_opacities_mut(&self, core: &CubismCore) -> &mut [f32] { + unsafe { + let p = (core.csm_get_part_opacities)(self.model); + std::slice::from_raw_parts_mut(p, self.part_count) + } + } + + /// 按 Part ID 获取索引 + pub fn part_index(&self, id: &str) -> Option { + self.part_index.get(id).copied() + } + + /// 按 Part 索引设置 opacity + pub fn set_part_opacity_by_index(&self, core: &CubismCore, index: usize, opacity: f32) { + if index < self.part_count { + let slice = self.part_opacities_mut(core); + slice[index] = opacity.clamp(0.0, 1.0); + } + } + + /// 按 Part 索引获取 opacity + pub fn get_part_opacity_by_index(&self, core: &CubismCore, index: usize) -> Option { + if index >= self.part_count { + return None; + } + let slice = self.part_opacities_mut(core); + Some(slice[index]) + } + /// 更新 model(应用参数变化,重新计算顶点/不透明度等) pub fn update(&self, core: &CubismCore) { unsafe { (core.csm_update_model)(self.model) }; diff --git a/src/plugins/live2d/pose.rs b/src/plugins/live2d/pose.rs new file mode 100644 index 0000000..db38dd9 --- /dev/null +++ b/src/plugins/live2d/pose.rs @@ -0,0 +1,233 @@ +//! Pose 处理:根据 `.pose3.json` 管理互斥 Part 的显示状态。 +//! +//! Haru 这类模型会把“手臂伸开”和“手臂抱胸”做成不同 Part。 +//! 如果不执行 Pose,多个 Part 会同时可见,画面上就会出现四只手臂。 + +use super::core_ffi::CubismCore; +use super::model::CubismModel; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::Path; + +const DEFAULT_FADE_IN_SECONDS: f32 = 0.5; +const EPSILON: f32 = 0.001; +const PHI: f32 = 0.5; +const BACK_OPACITY_THRESHOLD: f32 = 0.15; + +#[derive(Deserialize)] +struct PoseJson { + #[serde(rename = "FadeInTime")] + fade_in_time: Option, + #[serde(rename = "Groups", default)] + groups: Vec>, +} + +#[derive(Deserialize)] +struct PosePartJson { + #[serde(rename = "Id")] + id: String, + #[serde(rename = "Link", default)] + link: Vec, +} + +#[derive(Clone, Debug)] +struct PosePart { + part_index: Option, + param_index: Option, + link_part_indices: Vec, + virtual_param_value: f32, +} + +/// Live2D Pose 状态。 +pub struct PoseState { + fade_time_seconds: f32, + groups: Vec>, + initialized: bool, +} + +impl PoseState { + pub fn from_path(path: &Path, model: &CubismModel) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("读取 pose3.json 失败: {}", path.display()))?; + let json: PoseJson = serde_json::from_str(&text) + .with_context(|| format!("解析 pose3.json 失败: {}", path.display()))?; + + let fade_time_seconds = json + .fade_in_time + .filter(|v| *v >= 0.0) + .unwrap_or(DEFAULT_FADE_IN_SECONDS); + + let groups: Vec> = json + .groups + .into_iter() + .map(|group| { + group + .into_iter() + .map(|part| PosePart { + param_index: model.param_index.get(&part.id).copied(), + part_index: model.part_index(&part.id), + link_part_indices: part + .link + .iter() + .filter_map(|id| model.part_index(id)) + .collect(), + virtual_param_value: 0.0, + }) + .collect() + }) + .collect(); + + println!( + "[Live2D] Pose 加载: {} groups={} fade={:.3}s", + path.display(), + groups.len(), + fade_time_seconds + ); + + Ok(Self { + fade_time_seconds, + groups, + initialized: false, + }) + } + + /// 初始化 Pose:每个互斥组保留当前最可见的 Part,隐藏其余 Part。 + pub fn reset(&mut self, core: &CubismCore, model: &CubismModel) { + for group in &mut self.groups { + let mut visible_index = 0; + let mut max_opacity = -1.0; + for (index, part) in group.iter().enumerate() { + let opacity = part + .part_index + .and_then(|idx| model.get_part_opacity_by_index(core, idx)) + .unwrap_or(0.0); + if opacity >= max_opacity { + max_opacity = opacity; + visible_index = index; + } + } + + for (index, part) in group.iter_mut().enumerate() { + let opacity = if index == visible_index { 1.0 } else { 0.0 }; + part.virtual_param_value = opacity; + if let Some(param_index) = part.param_index { + model.set_param_by_index(core, param_index, opacity); + } + if let Some(part_index) = part.part_index { + model.set_part_opacity_by_index(core, part_index, opacity); + } + } + } + self.copy_part_opacities(core, model); + self.initialized = true; + } + + /// 按官方 CubismPose 的互斥淡入规则更新 PartOpacity。 + pub fn update_parameters(&mut self, core: &CubismCore, model: &CubismModel, dt: f32) { + if !self.initialized { + self.reset(core, model); + } + + let dt = dt.max(0.0); + let fade_time_seconds = self.fade_time_seconds; + for group in &mut self.groups { + Self::do_fade(core, model, fade_time_seconds, dt, group); + } + self.copy_part_opacities(core, model); + } + + fn do_fade( + core: &CubismCore, + model: &CubismModel, + fade_time_seconds: f32, + dt: f32, + group: &mut [PosePart], + ) { + if group.is_empty() { + return; + } + + let mut visible_part_index = None; + let mut new_opacity = 1.0; + + for (index, part) in group.iter().enumerate() { + if part.pose_value(core, model) <= EPSILON { + continue; + } + + visible_part_index = Some(index); + if fade_time_seconds == 0.0 { + new_opacity = 1.0; + } else { + new_opacity = part + .part_index + .and_then(|idx| model.get_part_opacity_by_index(core, idx)) + .unwrap_or(0.0); + new_opacity = (new_opacity + dt / fade_time_seconds).min(1.0); + } + break; + } + + let visible_part_index = visible_part_index.unwrap_or(0); + if visible_part_index == 0 && group[0].pose_value(core, model) <= EPSILON { + group[0].virtual_param_value = 1.0; + if let Some(param_index) = group[0].param_index { + model.set_param_by_index(core, param_index, 1.0); + } + } + + for (index, part) in group.iter().enumerate() { + let Some(part_index) = part.part_index else { + continue; + }; + + if index == visible_part_index { + model.set_part_opacity_by_index(core, part_index, new_opacity); + continue; + } + + let mut opacity = model + .get_part_opacity_by_index(core, part_index) + .unwrap_or(0.0); + let mut target_opacity = if new_opacity < PHI { + new_opacity * (PHI - 1.0) / PHI + 1.0 + } else { + (1.0 - new_opacity) * PHI / (1.0 - PHI) + }; + + let back_opacity = (1.0 - target_opacity) * (1.0 - new_opacity); + if back_opacity > BACK_OPACITY_THRESHOLD { + target_opacity = 1.0 - BACK_OPACITY_THRESHOLD / (1.0 - new_opacity); + } + + if opacity > target_opacity { + opacity = target_opacity; + } + model.set_part_opacity_by_index(core, part_index, opacity); + } + } + + fn copy_part_opacities(&self, core: &CubismCore, model: &CubismModel) { + for group in &self.groups { + for part in group { + let Some(part_index) = part.part_index else { + continue; + }; + let Some(opacity) = model.get_part_opacity_by_index(core, part_index) else { + continue; + }; + for &link_part_index in &part.link_part_indices { + model.set_part_opacity_by_index(core, link_part_index, opacity); + } + } + } + } +} + +impl PosePart { + fn pose_value(&self, core: &CubismCore, model: &CubismModel) -> f32 { + self.param_index + .and_then(|idx| model.get_param_by_index(core, idx)) + .unwrap_or(self.virtual_param_value) + } +} diff --git a/src/plugins/live2d/renderer.rs b/src/plugins/live2d/renderer.rs index de5d820..e93939d 100644 --- a/src/plugins/live2d/renderer.rs +++ b/src/plugins/live2d/renderer.rs @@ -6,6 +6,7 @@ use super::core_ffi::{self, CubismCore}; use super::gl::{self as gl_api, RenderContext}; use gl_api::gl as gl_const; use super::model::{CubismModel, DrawableSnapshot}; +use super::pose::PoseState; use anyhow::Result; use std::path::Path; use std::sync::Arc; @@ -19,6 +20,7 @@ pub struct Live2DRenderer { pub gl_ctx: RenderContext, pub texture_ids: Vec, pub animation: AnimationState, + pub pose: Option, pub model_matrix: [f32; 16], pub running: bool, } @@ -56,6 +58,15 @@ impl Live2DRenderer { let model_matrix = compute_model_matrix(canvas, gl_ctx.width, gl_ctx.height); model.reset_to_default(&core); + let mut pose = if let Some(pose_rel) = model3.file_references.pose.as_deref() { + let pose_path = paths.model3_dir.join(pose_rel); + Some(PoseState::from_path(&pose_path, &model)?) + } else { + None + }; + if let Some(pose) = pose.as_mut() { + pose.reset(&core, &model); + } Ok(Self { core, @@ -65,6 +76,7 @@ impl Live2DRenderer { gl_ctx, texture_ids, animation: AnimationState::default(), + pose, model_matrix, running: false, }) @@ -79,6 +91,9 @@ impl Live2DRenderer { let dt = 1.0 / 60.0; self.animation.update(&self.core, &self.model, now, dt); + if let Some(pose) = self.pose.as_mut() { + pose.update_parameters(&self.core, &self.model, dt); + } self.model.update(&self.core); let snapshots = self.model.drawable_snapshots(&self.core); @@ -426,4 +441,4 @@ fn compute_model_matrix( 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ] -} \ No newline at end of file +}