feat(live2d): 添加 Live2D 渲染插件及相关依赖,替代 Firefox kiosk 方案

This commit is contained in:
2026-07-07 16:13:40 +08:00
parent 2aac62b2bb
commit 86e36aa677
7 changed files with 510 additions and 44 deletions

5
.build.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
cd /home/showen/Showen/ShowenV2
export PATH=/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH
cargo check 2>&1 | tail -80
echo "EXIT=$?"

View File

@@ -37,3 +37,6 @@ ureq = "2"
flate2 = "1"
tar = "0.4"
semver = "1"
# Live2D 渲染
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -17,6 +17,7 @@ pub mod plugin_ids {
pub const BLE: &str = "ble";
pub const DEVICE: &str = "device";
pub const SCREEN: &str = "screen";
pub const LIVE2D: &str = "live2d";
}
#[cfg(test)]

View File

@@ -5,8 +5,8 @@ use showen_v2::core::service_manager::ServiceManager;
#[cfg(not(test))]
use showen_v2::core::version_manager::VersionManager;
use showen_v2::plugins::{
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin,
video::VideoPlugin, wifi::WifiPlugin,
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, live2d::Live2DPlugin,
screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -75,6 +75,9 @@ fn main() -> Result<()> {
manager.register(Box::new(VideoPlugin::new()));
println!(" ✓ VideoPlugin");
manager.register(Box::new(Live2DPlugin::new()));
println!(" ✓ Live2DPlugin");
manager.register(Box::new(BlePlugin::new()));
println!(" ✓ BlePlugin");

View File

@@ -0,0 +1,225 @@
//! Live2DPlugin — Live2D 原生渲染插件
//!
//! 接管 Live2D 模式下的设备端渲染,替代 Firefox kiosk 方案。
//! 监听 ConfigReloaded 消息,当 render_type=Live2d 时启动原生渲染循环。
use crate::core::config::RenderType;
use crate::core::message::{Destination, Envelope, Message};
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
use crate::core::plugin_ids;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use super::renderer::Live2DRenderer;
pub struct Live2DPlugin {
ctx: Option<PluginContext>,
/// 渲染器句柄(运行在独立线程)
renderer: Option<Arc<std::sync::Mutex<Live2DRenderer>>>,
worker: Option<JoinHandle<()>>,
/// 渲染停止信号
stop_flag: Arc<AtomicBool>,
}
impl Live2DPlugin {
pub fn new() -> Self {
Self {
ctx: None,
renderer: None,
worker: None,
stop_flag: Arc::new(AtomicBool::new(false)),
}
}
}
impl Default for Live2DPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for Live2DPlugin {
fn id(&self) -> &str {
"live2d"
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: "Live2D Renderer".to_string(),
version: "0.1.0".to_string(),
description: "Live2D 原生渲染Cubism Core FFI + OpenGL ES 2.0".to_string(),
platform: Platform::Linux,
}
}
fn dependencies(&self) -> Vec<String> {
vec![]
}
fn init(&mut self, ctx: PluginContext) -> Result<()> {
self.ctx = Some(ctx);
Ok(())
}
fn start(&mut self) -> Result<()> {
let ctx = self
.ctx
.as_ref()
.context("live2d plugin context is not initialized")?;
ctx.tx.send(Envelope {
from: self.id().to_string(),
to: Destination::Manager,
message: Message::PluginReady(self.id().to_string()),
})?;
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg {
Message::ConfigReloaded(config) => {
if config.character.render_type == RenderType::Live2d {
self.start_live2d(&config)?;
} else {
self.stop_live2d();
}
}
Message::Shutdown => {
self.stop_live2d();
}
_ => {}
}
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.stop_live2d();
Ok(())
}
}
impl Live2DPlugin {
fn start_live2d(&mut self, config: &crate::core::config::Config) -> Result<()> {
// 先停止旧的渲染
self.stop_live2d();
let model_rel = config
.character
.live2d_model
.clone()
.context("未配置 live2d_model")?;
// Core .so 路径(相对项目根目录或绝对路径)
let core_so = find_core_so()?;
// live2d 资源根目录
let live2d_base = find_live2d_base(config)?;
let width = config.display.render_width as i32;
let height = config.display.render_height as i32;
let stop_flag = Arc::clone(&self.stop_flag);
stop_flag.store(false, Ordering::SeqCst);
let tx = self
.ctx
.as_ref()
.context("no ctx")?
.tx
.clone();
let renderer = Arc::new(std::sync::Mutex::new(
Live2DRenderer::new(&core_so, &live2d_base, &model_rel, width, height)?,
));
self.renderer = Some(Arc::clone(&renderer));
let handle = std::thread::spawn(move || {
// 渲染循环
loop {
if stop_flag.load(Ordering::SeqCst) {
break;
}
// 检查 talking 状态(从 HTTP 插件共享的状态读取,这里通过轮询 config 实现)
// 简化:直接渲染
let render_result = {
let mut r = renderer.lock().unwrap();
r.render_frame()
};
if let Err(e) = render_result {
eprintln!("[Live2DPlugin] 渲染失败: {e}");
let _ = tx.send(Envelope {
from: "live2d".to_string(),
to: Destination::Manager,
message: Message::StateChanged {
old_state: Some("live2d_running".into()),
new_state: Some("live2d_error".into()),
},
});
break;
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
self.worker = Some(handle);
println!("[Live2DPlugin] Live2D 原生渲染已启动: {model_rel}");
Ok(())
}
fn stop_live2d(&mut self) {
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
self.renderer = None;
}
}
/// 查找 libLive2DCubismCore.so
fn find_core_so() -> Result<String> {
// 候选路径优先级:
// 1. 环境变量 SHOWEN_LIVE2D_CORE_SO
// 2. 项目根 extern/libLive2DCubismCore.so运行时随二进制一起部署
// 3. /usr/lib/libLive2DCubismCore.so
// 4. /home/showen/Showen/ShowenV2/extern/libLive2DCubismCore.so
if let Ok(p) = std::env::var("SHOWEN_LIVE2D_CORE_SO") {
if std::path::Path::new(&p).exists() {
return Ok(p);
}
}
let candidates = [
"extern/libLive2DCubismCore.so",
"/usr/lib/libLive2DCubismCore.so",
"/usr/local/lib/libLive2DCubismCore.so",
"/home/showen/Showen/ShowenV2/extern/libLive2DCubismCore.so",
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Ok(c.to_string());
}
}
anyhow::bail!("找不到 libLive2DCubismCore.so请设置环境变量 SHOWEN_LIVE2D_CORE_SO 或部署到 extern/")
}
/// 查找 live2d 资源根目录
fn find_live2d_base(config: &crate::core::config::Config) -> Result<PathBuf> {
let candidates = [
PathBuf::from("configs/live2d"),
PathBuf::from("/home/showen/Showen/ShowenV2/configs/live2d"),
std::env::current_dir()?.join("configs/live2d"),
];
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
anyhow::bail!("找不到 configs/live2d 目录")
}
// 保留 plugin_ids 引用避免 unused
#[allow(unused_imports)]
use plugin_ids as _;

View File

@@ -0,0 +1,266 @@
//! Live2D 渲染器:整合 Core + Model + GL + Animation
use super::animation::AnimationState;
use super::assets::{self, Model3Json, TextureImage};
use super::core_ffi::{self, CubismCore};
use super::gl::{self, RenderContext};
use super::model::{CubismModel, DrawableSnapshot};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct Live2DRenderer {
pub core: Arc<CubismCore>,
pub model: CubismModel,
pub model3: Model3Json,
pub textures: Vec<TextureImage>,
pub gl_ctx: RenderContext,
pub texture_ids: Vec<u32>,
pub animation: AnimationState,
pub model_matrix: [f32; 16],
pub running: bool,
}
impl Live2DRenderer {
pub fn new(
core_so_path: &str,
live2d_base_dir: &Path,
model3_json_rel: &str,
width: i32,
height: i32,
) -> Result<Self> {
let core = Arc::new(CubismCore::load(core_so_path)?);
println!("[Live2D] Core 版本: {}", core.version_string());
let paths = assets::resolve_asset_paths(live2d_base_dir, model3_json_rel)?;
let model3 = Model3Json::from_path(&paths.model3_json)?;
println!(
"[Live2D] model3.json: 版本={} moc={} 纹理数={}",
model3.version,
model3.file_references.moc,
model3.file_references.textures.len()
);
let model = CubismModel::load(Arc::clone(&core), &paths.moc3)?;
let textures = assets::load_all_textures(&paths.model3_dir, &model3.file_references)?;
let gl_ctx = RenderContext::new_fullscreen(width, height)?;
let texture_ids: Vec<u32> = textures.iter().map(|t| gl_ctx.create_texture(t)).collect();
println!("[Live2D] 已上传 {} 个纹理到 GPU", texture_ids.len());
let canvas = model.canvas;
let model_matrix = compute_model_matrix(canvas, width, height);
model.reset_to_default(&core);
Ok(Self {
core,
model,
model3,
textures,
gl_ctx,
texture_ids,
animation: AnimationState::default(),
model_matrix,
running: false,
})
}
pub fn set_talking(&mut self, talking: bool) {
self.animation.set_talking(talking);
}
pub fn render_frame(&mut self) -> Result<()> {
let now = Instant::now();
let dt = 1.0 / 60.0;
self.animation.update(&self.core, &self.model, now, dt);
self.model.update(&self.core);
let snapshots = self.model.drawable_snapshots(&self.core);
unsafe {
let gl = &self.gl_ctx.gl;
(gl.gl_clear_color)(0.0, 0.0, 0.0, 1.0);
(gl.gl_clear)(gl::GL_COLOR_BUFFER_BIT);
(gl.gl_enable)(gl::GL_BLEND);
(gl.gl_use_program)(self.gl_ctx.program);
(gl.gl_uniform_matrix4fv)(
self.gl_ctx.uniform_matrix,
1,
gl::GL_FALSE,
self.model_matrix.as_ptr(),
);
}
for snap in &snapshots {
if snap.opacity <= 0.001 {
continue;
}
self.draw_drawable(snap);
}
self.model.reset_dynamic_flags(&self.core);
unsafe {
(self.gl_ctx.gl.egl_swap_buffers)(self.gl_ctx.display, self.gl_ctx.surface);
}
Ok(())
}
fn draw_drawable(&self, snap: &DrawableSnapshot) {
let gl = &self.gl_ctx.gl;
unsafe {
// 纹理
let tex_idx = snap.texture_index as usize;
if tex_idx < self.texture_ids.len() {
(gl.gl_active_texture)(gl::GL_TEXTURE0);
(gl.gl_bind_texture)(gl::GL_TEXTURE_2D, self.texture_ids[tex_idx]);
(gl.gl_uniform1i)(self.gl_ctx.uniform_texture, 0);
}
// base color不透明度乘到 alpha
(gl.gl_uniform4f)(self.gl_ctx.uniform_base_color, 1.0, 1.0, 1.0, snap.opacity);
// multiply/screen color
let mc = snap.multiply_color;
(gl.gl_uniform4f)(self.gl_ctx.uniform_multiply_color, mc[0], mc[1], mc[2], mc[3]);
let sc = snap.screen_color;
(gl.gl_uniform4f)(self.gl_ctx.uniform_screen_color, sc[0], sc[1], sc[2], sc[3]);
// 混合模式
match snap.blend_mode {
core_ffi::blend_mode::NORMAL => {
(gl.gl_blend_func)(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
core_ffi::blend_mode::ADDITIVE => {
(gl.gl_blend_func)(gl::GL_SRC_ALPHA, gl::GL_ONE);
}
core_ffi::blend_mode::MULTIPLICATIVE => {
(gl.gl_blend_func)(gl::GL_ZERO, gl::GL_SRC_COLOR);
}
_ => {
(gl.gl_blend_func)(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
}
// 顶点数据position(xy) + texCoord(uv) 交错
let vc = snap.vertex_positions.len();
let mut vertex_data: Vec<f32> = Vec::with_capacity(vc * 4);
for i in 0..vc {
vertex_data.push(snap.vertex_positions[i][0]);
vertex_data.push(snap.vertex_positions[i][1]);
vertex_data.push(snap.vertex_uvs[i][0]);
vertex_data.push(snap.vertex_uvs[i][1]);
}
let mut vbo = 0u32;
(gl.gl_gen_buffers)(1, &mut vbo);
(gl.gl_bind_buffer)(gl::GL_ARRAY_BUFFER, vbo);
(gl.gl_buffer_data)(
gl::GL_ARRAY_BUFFER,
(vertex_data.len() * 4) as isize,
vertex_data.as_ptr() as *const std::ffi::c_void,
gl::GL_STATIC_DRAW,
);
let mut ibo = 0u32;
(gl.gl_gen_buffers)(1, &mut ibo);
(gl.gl_bind_buffer)(gl::GL_ELEMENT_ARRAY_BUFFER, ibo);
(gl.gl_buffer_data)(
gl::GL_ELEMENT_ARRAY_BUFFER,
(snap.indices.len() * 2) as isize,
snap.indices.as_ptr() as *const std::ffi::c_void,
gl::GL_STATIC_DRAW,
);
let stride = 4 * 4;
if self.gl_ctx.attrib_position >= 0 {
(gl.gl_enable_vertex_attrib_array)(self.gl_ctx.attrib_position as u32);
(gl.gl_vertex_attrib_pointer)(
self.gl_ctx.attrib_position as u32,
2,
gl::GL_FLOAT,
gl::GL_FALSE,
stride,
std::ptr::null(),
);
}
if self.gl_ctx.attrib_tex_coord >= 0 {
(gl.gl_enable_vertex_attrib_array)(self.gl_ctx.attrib_tex_coord as u32);
(gl.gl_vertex_attrib_pointer)(
self.gl_ctx.attrib_tex_coord as u32,
2,
gl::GL_FLOAT,
gl::GL_FALSE,
stride,
(2 * 4) as *const std::ffi::c_void,
);
}
(gl.gl_draw_elements)(
gl::GL_TRIANGLES,
snap.indices.len() as i32,
gl::GL_UNSIGNED_SHORT,
std::ptr::null(),
);
(gl.gl_delete_buffers)(1, &vbo);
(gl.gl_delete_buffers)(1, &ibo);
}
}
pub fn run_loop(&mut self) -> Result<()> {
self.running = true;
let frame_duration = Duration::from_millis(16);
while self.running {
let start = Instant::now();
if let Err(e) = self.render_frame() {
eprintln!("[Live2D] 渲染帧失败: {e}");
break;
}
let elapsed = start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
Ok(())
}
pub fn stop(&mut self) {
self.running = false;
}
}
/// 计算 model → NDC 变换矩阵(列优先 4x4
///
/// Cubism 坐标:原点在 canvas 中心Y 轴向上,单位为像素。
/// 需要等比缩放使模型完整显示在窗口内。
fn compute_model_matrix(
canvas: super::model::CanvasInfo,
width: i32,
height: i32,
) -> [f32; 16] {
let canvas_w = canvas.size[0].max(1.0);
let canvas_h = canvas.size[1].max(1.0);
// 计算缩放:让模型等比适应窗口
let scale_x = 2.0 / canvas_w;
let scale_y = 2.0 / canvas_h;
let scale = scale_x.min(scale_y);
// 列优先矩阵:
// [ scale 0 0 0 ]
// [ 0 scale 0 0 ]
// [ 0 0 1 0 ]
// [ 0 0 0 1 ]
[
scale, 0.0, 0.0, 0.0,
0.0, scale, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
]
}

View File

@@ -18,8 +18,6 @@ pub struct VideoPlugin {
ctx: Option<PluginContext>,
processor: Option<Arc<Mutex<VideoProcessor>>>,
worker: Option<JoinHandle<()>>,
/// Live2D 模式下运行的 Firefox kiosk 子进程
live2d_child: Option<std::process::Child>,
}
impl VideoPlugin {
@@ -28,7 +26,6 @@ impl VideoPlugin {
ctx: None,
processor: None,
worker: None,
live2d_child: None,
}
}
@@ -198,7 +195,7 @@ impl Plugin for VideoPlugin {
self.publish_status();
}
Message::ConfigReloaded(config) => {
// Live2D 模式:停止视频播放,启动 Firefox kiosk 全屏渲染 Live2D 模型
// Live2D 模式:停止视频播放,由 Live2DPlugin 接管原生渲染
if config.character.render_type == crate::core::config::RenderType::Live2d {
// 停止视频播放
if let Some(old) = self.processor.take() {
@@ -209,51 +206,21 @@ impl Plugin for VideoPlugin {
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
// 杀掉的 Firefox
if let Some(mut child) = self.live2d_child.take() {
let _ = child.kill();
let _ = child.wait();
}
// Firefox 会 fork 多个子进程,用 shell kill -9 杀整个进程树
// 杀掉残留的 Firefox旧方案遗留
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
.output();
// 启动 Firefox kiosk 模式全屏显示 Live2D
let port = config.remote_control.port;
let url = format!("http://localhost:{port}/live2d-display.html");
match std::process::Command::new("firefox")
.arg("--kiosk")
.arg("--width").arg(config.display.render_width.to_string())
.arg("--height").arg(config.display.render_height.to_string())
.arg(&url)
.env("DISPLAY", ":0")
.env("MOZ_DISABLE_AUTOPLAY", "1")
.spawn()
{
Ok(child) => {
self.live2d_child = Some(child);
println!("[VideoPlugin] Live2D 模式Firefox kiosk 已启动 ({url})");
}
Err(e) => {
eprintln!("[VideoPlugin] 启动 Firefox 失败: {e}");
}
}
println!("[VideoPlugin] Live2D 模式:视频已停止,由 Live2DPlugin 接管渲染");
self.publish_status();
return Ok(());
}
// 切回视频模式:杀掉 Firefox kiosk
if let Some(mut child) = self.live2d_child.take() {
let _ = child.kill();
let _ = child.wait();
}
// Firefox 会 fork 多个子进程,用 shell kill -9 杀整个进程树
// 切回视频模式:杀掉残留 Firefox
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
.output();
println!("[VideoPlugin] 切回视频模式Firefox kiosk 已关闭");
let processor = Arc::new(Mutex::new(VideoProcessor::new(config)?));
if let Some(old) = self.processor.replace(Arc::clone(&processor)) {
@@ -300,11 +267,7 @@ impl Plugin for VideoPlugin {
let _ = handle.join();
}
// 关闭 Live2D Firefox kiosk
if let Some(mut child) = self.live2d_child.take() {
let _ = child.kill();
let _ = child.wait();
}
// 清理残留 Firefox(兼容旧方案)
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")