最近有一个很火的电子桌面宠物应用: AIRI,感兴趣的可以在Github上查看:https://github.com/moeru-ai/airi
它其实是一个基于3D 模型 (Live2D 和 VRM 模型,这两个是专门用于数字人的模型格式,基于glTF)的虚拟桌面宠物,可以和电脑进行交互,拼接入了大模型的功能,让3d模型真正意义上有了智慧的效果。
在桌面平台上,它最开始是一个用 Tauri 开发的 Rust 应用(但是现在它不是了,我不知道具体原因,但是大概原因后面再讲)。
虽然现在 AI 编程让开发门槛大幅度降低了,但是这并不意味着我们不需要思考。
注意:这是在 Linux 下做的实验。
假如你也行
我能实现一个类似的电子桌面宠物吗?
我的想法是, 我可以使用 Tauri 实现一个全屏的、透明置顶、并且需要点击穿透的窗口,来模拟桌面宠物。
重点在于,这个应用的窗口必须是点击穿透的,这样,才不会干扰到用户操作底层的界面。
模型
本质上来说,这其实就是使用前端的 three3D 来渲染3D模型,然后把模型放在一个全屏的窗口里。只是这里我们使用的是比较特殊的模型,即 Live2D 模型或者 VRM 模型。
这些模型格式,是专门为数字人设计的,基于 glTF。因此,它的动作绑定比传统的3D模型要简单得多。比如:
// 左臂自然下垂
const leftUpperArm = vrm.humanoid.getNormalizedBoneNode('leftUpperArm');
const leftLowerArm = vrm.humanoid.getNormalizedBoneNode('leftLowerArm');
const leftHand = vrm.humanoid.getNormalizedBoneNode('leftHand');
// 右臂
const rightUpperArm = vrm.humanoid.getNormalizedBoneNode('rightUpperArm');
const rightLowerArm = vrm.humanoid.getNormalizedBoneNode('rightLowerArm');
const rightHand = vrm.humanoid.getNormalizedBoneNode('rightHand');
这是基于传统模型的高度抽象。
我这里使用到的是下面这些资源:
- three-vrm: 主要用于渲染 VRM 模型
- vroid hub: VRM 模型资源
- VRMA: VRMA 是一些封闭好的 VRM 模型动画,这里面有10套常用动画,几乎可以适配所有的 vrm 模型。
效果如下:

眼部跟随
不能和主人交互的宠物,它能叫宠物吗?
所以,我希望它的眼睛能跟随鼠标,让3d模型产生一丢丢的交互效果。 实现的思路很简单,就是监听鼠标移动事件,然后计算出鼠标在窗口中的位置,然后把坐标传给模型。实事上,@pixiv/three-vrm 库本身就支持 LookAt 功能,因此我们非常方便集成。
问题的出现
还记得上面的设置吗?我们把应用窗口设置为了点击穿透,也就是 setIgnoreCursorEvents(true)。如果你想控制应用获取鼠标事件,你会发现,这个设置会阻止应用获取鼠标事件。
我们已经拿不到鼠标事件了,这怎么解决呢?
我们肯定会想到一个思路,就是通过 rust 层监听鼠标事件,然后把鼠标事件传递给 js 层,然后 js 层再把鼠标事件传递给应用。
Rust 层可以使用 rdev 或者 Enigo 来获取鼠标事件。
在 rust 层监听鼠标事件:
/// 启动鼠标位置监听线程
fn start_mouse_listener() {
// 在 Tauri 初始化之前就创建 Enigo 实例
let enigo = match Enigo::new(&Settings::default()) {
Ok(e) => Arc::new(Mutex::new(e)),
Err(e) => {
eprintln!("无法初始化 Enigo: {:?}", e);
return;
}
};
// 打印屏幕尺寸
if let Ok(e) = enigo.lock() {
if let Ok(display) = e.main_display() {
println!("屏幕尺寸: {:?}", display);
}
}
println!("开始监听鼠标位置...");
thread::spawn(move || {
let mut last_x: i32 = -1;
let mut last_y: i32 = -1;
let mut heartbeat = 0u32;
loop {
if let Ok(e) = enigo.lock() {
match e.location() {
Ok((x, y)) => {
if x != last_x || y != last_y {
println!("鼠标位置: x={}, y={}", x, y);
MOUSE_X.store(x, Ordering::Relaxed);
MOUSE_Y.store(y, Ordering::Relaxed);
last_x = x;
last_y = y;
}
}
Err(err) => {
eprintln!("获取鼠标位置失败: {:?}", err);
}
}
}
heartbeat += 1;
if heartbeat % 60 == 0 {
println!("[心跳] 鼠标监听线程运行中... (位置: x={}, y={})", last_x, last_y);
}
thread::sleep(Duration::from_millis(16)); // ~60fps
}
});
}
上面这个代码确实可以监听到鼠标位置。但是,当窗体应用初始化完成时,上面的enigo.lock() 就拿不到 true 了。 因为setIgnoreCursorEvents(true) 是在webview 初始化之后才设置的。这说明,setIgnoreCursorEvents(true) 直接影响到了更底层的地方。通过对 Tauri 的源码分析,我发现 setIgnoreCursorEvents(true) 是 Wry runtime 实现的。
由于我对 Wry 运行时了解不多,所以没有深细研究下去。
但是至少,对于正常开发者来说,这个问题已经难解决了。
看看别人怎么做
既然自己无法解决,那么,我们可以看看别人是怎么做的。通过对 AIRI 项目的源码分析,结果发现 AIRI 项目桌面端已经完成了从 Tauri 到 Electron 的迁移。。。
通过 git commit 历史记录发现, AIRI 项目在使用 Tauri 时,是通过 tauri-plugin-window-pass-through-on-hover 插件实现点击穿透,但是本质上,也是在调用底层的window.set_ignore_cursor_events(enabled) 实现点击穿透。而这个插件,刚好不支持 Linux 系统。
于是我重新在windows 平台试了一下在点击穿透的情况下,在 rust 层获取鼠标位置是否会失效。
thread::spawn(move || {
let enigo = Enigo::new(&enigo::Settings::default()).unwrap();
loop {
let (x, y) = enigo.location().unwrap();
// 发送鼠标坐标事件到前端
println!("鼠标位置: x={}, y={}", x, y);
thread::sleep(Duration::from_millis(50));
}
});
答案是可以的。

也就是说,上面这个问题,其实是 linux 的问题。目前这个问题由于我不熟悉 wry 和 wayland(x11),暂时找不到可以解决的方案。
麻烦的 linux
虽然我不知道 AIRI 为什么中途切换了Tauri 到 Electron,但是想来,在跨平台的兼容性上, electron 确实比 tauri 更强。
更麻烦的是,目前 Linux 正在处于 x11 被抛弃而 wayland 却完全没有准备好的过滤状态。在开发 linux 应用时,这不是向左或者向右的问题,而是无论向左还是向右,都有可能会出现兼容性的问题。
比如, Tauri 应用在 wayland 模式下运行时,置顶窗口就有可能会失效。 比如, Kde 桌面启动 Tauri的应用时,默认情况下窗体装饰栏的按键响应就会失效,原因是 Tauri 底层使用的是 GTK 。