feat(live2d): 添加 Live2D 渲染插件及相关依赖,替代 Firefox kiosk 方案
This commit is contained in:
5
.build.sh
Normal file
5
.build.sh
Normal 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=$?"
|
||||||
@@ -37,3 +37,6 @@ ureq = "2"
|
|||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
|
|
||||||
|
# Live2D 渲染
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod plugin_ids {
|
|||||||
pub const BLE: &str = "ble";
|
pub const BLE: &str = "ble";
|
||||||
pub const DEVICE: &str = "device";
|
pub const DEVICE: &str = "device";
|
||||||
pub const SCREEN: &str = "screen";
|
pub const SCREEN: &str = "screen";
|
||||||
|
pub const LIVE2D: &str = "live2d";
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use showen_v2::core::service_manager::ServiceManager;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use showen_v2::core::version_manager::VersionManager;
|
use showen_v2::core::version_manager::VersionManager;
|
||||||
use showen_v2::plugins::{
|
use showen_v2::plugins::{
|
||||||
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin,
|
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, live2d::Live2DPlugin,
|
||||||
video::VideoPlugin, wifi::WifiPlugin,
|
screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -75,6 +75,9 @@ fn main() -> Result<()> {
|
|||||||
manager.register(Box::new(VideoPlugin::new()));
|
manager.register(Box::new(VideoPlugin::new()));
|
||||||
println!(" ✓ VideoPlugin");
|
println!(" ✓ VideoPlugin");
|
||||||
|
|
||||||
|
manager.register(Box::new(Live2DPlugin::new()));
|
||||||
|
println!(" ✓ Live2DPlugin");
|
||||||
|
|
||||||
manager.register(Box::new(BlePlugin::new()));
|
manager.register(Box::new(BlePlugin::new()));
|
||||||
println!(" ✓ BlePlugin");
|
println!(" ✓ BlePlugin");
|
||||||
|
|
||||||
|
|||||||
225
src/plugins/live2d/plugin.rs
Normal file
225
src/plugins/live2d/plugin.rs
Normal 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 _;
|
||||||
266
src/plugins/live2d/renderer.rs
Normal file
266
src/plugins/live2d/renderer.rs
Normal 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -18,8 +18,6 @@ pub struct VideoPlugin {
|
|||||||
ctx: Option<PluginContext>,
|
ctx: Option<PluginContext>,
|
||||||
processor: Option<Arc<Mutex<VideoProcessor>>>,
|
processor: Option<Arc<Mutex<VideoProcessor>>>,
|
||||||
worker: Option<JoinHandle<()>>,
|
worker: Option<JoinHandle<()>>,
|
||||||
/// Live2D 模式下运行的 Firefox kiosk 子进程
|
|
||||||
live2d_child: Option<std::process::Child>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoPlugin {
|
impl VideoPlugin {
|
||||||
@@ -28,7 +26,6 @@ impl VideoPlugin {
|
|||||||
ctx: None,
|
ctx: None,
|
||||||
processor: None,
|
processor: None,
|
||||||
worker: None,
|
worker: None,
|
||||||
live2d_child: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +195,7 @@ impl Plugin for VideoPlugin {
|
|||||||
self.publish_status();
|
self.publish_status();
|
||||||
}
|
}
|
||||||
Message::ConfigReloaded(config) => {
|
Message::ConfigReloaded(config) => {
|
||||||
// Live2D 模式:停止视频播放,启动 Firefox kiosk 全屏渲染 Live2D 模型
|
// Live2D 模式:停止视频播放,由 Live2DPlugin 接管原生渲染
|
||||||
if config.character.render_type == crate::core::config::RenderType::Live2d {
|
if config.character.render_type == crate::core::config::RenderType::Live2d {
|
||||||
// 停止视频播放
|
// 停止视频播放
|
||||||
if let Some(old) = self.processor.take() {
|
if let Some(old) = self.processor.take() {
|
||||||
@@ -209,51 +206,21 @@ impl Plugin for VideoPlugin {
|
|||||||
if let Some(handle) = self.worker.take() {
|
if let Some(handle) = self.worker.take() {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
// 杀掉旧的 Firefox(如有)
|
// 杀掉残留的 Firefox(如旧方案遗留)
|
||||||
if let Some(mut child) = self.live2d_child.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
// Firefox 会 fork 多个子进程,用 shell kill -9 杀整个进程树
|
|
||||||
let _ = std::process::Command::new("/bin/bash")
|
let _ = std::process::Command::new("/bin/bash")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
||||||
.output();
|
.output();
|
||||||
// 启动 Firefox kiosk 模式全屏显示 Live2D
|
println!("[VideoPlugin] Live2D 模式:视频已停止,由 Live2DPlugin 接管渲染");
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.publish_status();
|
self.publish_status();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切回视频模式:杀掉 Firefox kiosk
|
// 切回视频模式:杀掉残留 Firefox
|
||||||
if let Some(mut child) = self.live2d_child.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
// Firefox 会 fork 多个子进程,用 shell kill -9 杀整个进程树
|
|
||||||
let _ = std::process::Command::new("/bin/bash")
|
let _ = std::process::Command::new("/bin/bash")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
||||||
.output();
|
.output();
|
||||||
println!("[VideoPlugin] 切回视频模式:Firefox kiosk 已关闭");
|
|
||||||
|
|
||||||
let processor = Arc::new(Mutex::new(VideoProcessor::new(config)?));
|
let processor = Arc::new(Mutex::new(VideoProcessor::new(config)?));
|
||||||
if let Some(old) = self.processor.replace(Arc::clone(&processor)) {
|
if let Some(old) = self.processor.replace(Arc::clone(&processor)) {
|
||||||
@@ -300,11 +267,7 @@ impl Plugin for VideoPlugin {
|
|||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭 Live2D Firefox kiosk
|
// 清理残留 Firefox(兼容旧方案)
|
||||||
if let Some(mut child) = self.live2d_child.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
let _ = std::process::Command::new("/bin/bash")
|
let _ = std::process::Command::new("/bin/bash")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
|
||||||
|
|||||||
Reference in New Issue
Block a user