diff --git a/.build.sh b/.build.sh new file mode 100644 index 0000000..f0f14ea --- /dev/null +++ b/.build.sh @@ -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=$?" diff --git a/Cargo.toml b/Cargo.toml index a4ff120..943c10a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,6 @@ ureq = "2" flate2 = "1" tar = "0.4" semver = "1" + +# Live2D 渲染 +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/src/core/mod.rs b/src/core/mod.rs index 698ac7d..06356e3 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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)] diff --git a/src/main.rs b/src/main.rs index 5244af5..79be105 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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"); diff --git a/src/plugins/live2d/plugin.rs b/src/plugins/live2d/plugin.rs new file mode 100644 index 0000000..8d8f76a --- /dev/null +++ b/src/plugins/live2d/plugin.rs @@ -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, + /// 渲染器句柄(运行在独立线程) + renderer: Option>>, + worker: Option>, + /// 渲染停止信号 + stop_flag: Arc, +} + +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 { + 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 { + // 候选路径优先级: + // 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 { + 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 _; diff --git a/src/plugins/live2d/renderer.rs b/src/plugins/live2d/renderer.rs new file mode 100644 index 0000000..87936b6 --- /dev/null +++ b/src/plugins/live2d/renderer.rs @@ -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, + pub model: CubismModel, + pub model3: Model3Json, + pub textures: Vec, + pub gl_ctx: RenderContext, + pub texture_ids: Vec, + 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 { + 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 = 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 = 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, + ] +} diff --git a/src/plugins/video/mod.rs b/src/plugins/video/mod.rs index 97f0662..bbeb0fb 100644 --- a/src/plugins/video/mod.rs +++ b/src/plugins/video/mod.rs @@ -18,8 +18,6 @@ pub struct VideoPlugin { ctx: Option, processor: Option>>, worker: Option>, - /// Live2D 模式下运行的 Firefox kiosk 子进程 - live2d_child: Option, } 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")