这是《Rust 桌面宠物拖拽踩坑实录》的续篇。 上一篇解决的是"拖得动",这一篇解决的是"动起来"。 环境:Windows 11 / Rust / winit 0.30 / softbuffer 0.4 / windows 0.61
一、起点:一个不会动的图标
上一篇结束时,我的桌面宠物已经能丝滑拖拽、不重影、保持置顶了。但它有个致命的无聊之处------
它不会动。
就是一张 96×96 的静态图,杵在桌面上。用户跟我说:"QQ 宠物你知道吧,它会做动画的,那是怎么做到的?"
于是有了这一篇:把一个静态图标,变成一个会播放动画的桌面宠物。中间踩了三个坑,都不算难,但每一个都挺"阴间"。
二、第一个坑:颜色不对(我被文档坑了一次)
换上新图片后,用户第一反应是:"为什么颜色不太对?"
我去看 draw() 里写像素的那行:
rust
buffer[px] = (a << 24) | (b << 16) | (g << 8) | r;
直觉就是通道顺序错了。于是我去翻 softbuffer 0.4 的官方文档,白纸黑字写着:
python
Pixel format (u32):
00000000RRRRRRRRGGGGGGGGBBBBBBBB
0: Bit is 0 R: Red G: Green B: Blue
文档说:最高 8 位必须为 0,然后依次是 R、G、B。
对照我的代码:(a<<24) | (b<<16) | (g<<8) | r------这里 bit16-23 放的是 B ,bit0-7 放的是 R 。按文档的格式,R 和 B 放反了。
而且最高位写的是 alpha,文档说必须为 0。所以我"顺理成章"地改成了:
rust
buffer[px] = (r << 16) | (g << 8) | b; // 按文档来
编译通过,我信心满满地交付------结果用户说:问题没有解决。
真正的原因
问题出在我只读了 softbuffer 的文档,没看窗口是怎么变透明的。
我的窗口设了 transparent(true)。去翻 winit 源码,它在 Windows 上是这么实现的:
rust
// winit-0.30.13/src/platform_impl/windows/window.rs:1232
// making the window transparent
let region = CreateRectRgn(0, 0, -1, -1); // 空区域
let bb = DWM_BLURBEHIND {
dwFlags: DWM_BB_ENABLE | DWM_BB_BLURREGION,
fEnable: true.into(),
hRgnBlur: region,
...
};
DwmEnableBlurBehindWindow(win.hwnd(), &bb);
关键:窗口透明走的是 DWM(桌面窗口管理器)合成 。在这个路径下,像素的最高 8 位会被当作 alpha 通道参与合成,而不是文档说的"必须为 0"。
也就是说实际生效的格式是:
AAAAAAAARRRRRRRRGGGGGGGGBBBBBBBB
所以我那一改,把 alpha 清成 0,等于把整个窗口设成了全透明------颜色是"对"了,但人也看不见了。
正确写法是两者结合:保留 alpha,交换 R 和 B:
rust
buffer[px as usize] = (a << 24) | (r << 16) | (g << 8) | b;
| 版本 | alpha 位 | RGB 顺序 | 结果 |
|---|---|---|---|
| 原代码 | (a<<24) ✅ |
R/B 反了 ❌ | 透明正常,但偏色 |
| 我按文档改 | 0 ❌ |
正确 ✅ | 颜色对了,但整个窗口全透明 |
| 最终 | (a<<24) ✅ |
正确 ✅ | 颜色对 + 透明正常 |
💡 教训 :库文档描述的是"通用情况下的格式",但你的窗口模式(这里是无边框 + DWM 透明合成)可能改变数据的语义。 遇到"改了没效果甚至更糟",要回头检查:我是不是只理解了其中一层?
三、零素材方案:用代码让它"飘"起来
颜色搞定后,先做了个不需要任何素材的动画------变换动画:用正弦波让图标上下浮动 + 左右轻摆。
rust
let t = 动画已运行的秒数;
let float_y = (t * std::f32::consts::PI).sin() * 4.0; // 上下 ±4px,周期 2s
let sway_x = (t * std::f32::consts::PI * 0.5).sin() * 2.0; // 左右 ±2px,周期 4s
为什么用两个不同周期? 单一正弦波的运动是机械重复的,一眼就看出来在"循环"。两个周期不成整数倍(2s 和 4s 是 2:1,这里用了不同频率叠加),运动轨迹就不会在短时间内重复,看起来自然得多。
再配一个 30 FPS 的重绘循环:
rust
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
win.request_redraw();
event_loop.set_control_flow(ControlFlow::WaitUntil(Instant::now() + FRAME_INTERVAL));
}
效果:萝卜缓慢上下浮动、轻微左右晃。零素材,纯代码,立刻见效。
但这只是"待机漂浮",不是 QQ 宠物那种"播一段动作"。
四、QQ 宠物的真相:序列帧动画
QQ 宠物、Shimeji、桌面鹅......这些桌面宠物的动画,没有一个是实时渲染的,更没有 3D。
原理朴素得令人失望------序列帧动画,和翻页动画书、GIF 是一回事:
arduino
准备一组图片:待机01.png 待机02.png ... 挥手01.png 挥手02.png ...
↓
程序维护一个"当前帧"变量,每隔 N 毫秒 +1
↓
每换一帧就重绘窗口 → 看起来就在动
QQ 宠物那套本质就是:一堆 PNG 帧 + 一个描述文件(说明"待机有 6 帧、每帧 100ms、播完循环")。客户端只负责"按时间换图、重绘"。
所以问题变成了:帧从哪来?
五、AI 时代的素材:三条路
用户问了个很好的问题:"现在 AI 时代这么厉害,做一个 QQ 宠物,竟然还需要我自己画图吗?"
确实不用。素材有三条路:
| 路径 | 做法 | 一致性 |
|---|---|---|
| AI 生成精灵图 | 让 AI 生成一张多动作横向排列的图,代码切成帧 | 一般(角色可能和你原图对不上) |
| 图生视频 → 抽帧 | 用现有角色图让 AI 生成动画视频,再拆帧 | 好 |
| 用现成视频抽帧 | 直接拿已有 mp4 拆帧 | 最好(现成动画,天然一致) |
我们手上有用户提供的 拔萝卜.mp4,天然就是最好的素材------它已经是动画,角色一致性 100%,不用生成、不用手绘。
用 ffmpeg 拆帧
先看看视频规格:
bash
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,r_frame_rate,duration \
-of default=noprint_wrappers=1 拔萝卜.mp4
ini
width=960
height=960
r_frame_rate=24/1
duration=5.041667
960×960、24fps、5 秒,约 121 帧。窗口只有 128×128,没必要保留原始分辨率,抽帧时直接降采样:
bash
ffmpeg -i 拔萝卜.mp4 -vf "fps=12,scale=256:256" frames/f_%03d.png
fps=12:每秒取 12 帧(动画够流畅,帧数减半)scale=256:256:缩到 256×256- 输出 61 帧 PNG
降采样很重要:121 帧 × 960×960×4B ≈ 447MB,缩到 256 后 61 帧只有约 16MB,启动时加载毫无压力。
六、第二个坑:黑色背景不能"凡黑即透"
抽出来的帧有个问题:视频背景是纯黑 (0,0,0) 且不透明。直接显示的话,宠物就是一个移动的黑色方块。
最直觉的做法:
rust
if r < 45 && g < 45 && b < 45 { alpha = 0; } // ❌ 错误
这是错的。 萝卜的眼睛、角色的黑色描边也是纯黑------这么写会把它们抠成透明的洞,萝卜变成"无脸怪"。
正确做法:从边缘 flood fill
核心思路:背景是和外围连成一片 的黑色,而眼睛是被角色包围 的黑色。所以只从图片四条边出发,把与边缘连通的黑色区域清掉:
rust
fn remove_black_background(pixels: &mut Vec<u8>, w: u32, h: u32) {
let (w, h) = (w as usize, h as usize);
let mut visited = vec![false; w * h];
let mut stack: Vec<usize> = Vec::new();
// 1. 种子:四条边上的黑色像素
for x in 0..w {
for y in [0usize, h - 1] {
let i = y * w + x;
if !visited[i] && is_near_black(pixels, i) {
visited[i] = true;
stack.push(i);
}
}
}
for y in 0..h {
for x in [0usize, w - 1] { /* 同理 */ }
}
// 2. 四邻域扩散,收集所有连通的背景像素
let mut bg = Vec::new();
while let Some(i) = stack.pop() {
bg.push(i);
// 上下左右,黑色且未访问过就入栈
...
}
// 3. 统一设为透明
for i in bg {
pixels[i * 4 + 3] = 0;
}
}
角色内部的黑色因为不与边缘连通,永远不会被扩散到,眼睛和描边完整保留。
💡 这个思路很通用:抠纯色背景时,"颜色匹配"只能定位候选,"连通性"才能区分"外面"和"里面"。
七、第三个坑:右下角的水印
用户又提了个要求:"右下角有'元宝AI生成'的水印,也要去掉。"
直接挖掉右下角一整块矩形当然简单,但那块区域还有泥土,会缺一个角。
所以我先做了像素统计,看看右下角到底有什么:
powershell
# 统计右下角区域 (x:140-255, y:205-255) 的颜色分布
ini
RGB~(0,0,0) count=4052 ← 黑色背景
RGB~(224,224,224) count=426 ← 水印文字(浅灰白)
RGB~(160,96,32) count=408 ← 泥土(棕色)
RGB~(64,32,0) count=325 ← 泥土暗部
关键发现 :水印是浅灰白 (三通道数值接近),泥土是高饱和棕色(三通道差异大)。
所以用饱和度就能精准区分,完全不需要位置硬编码:
rust
fn remove_watermark(pixels: &mut [u8], w: u32, h: u32) {
let (w, h) = (w as usize, h as usize);
let x0 = (w as f32 * 0.55) as usize; // 只在右下角区域处理
let y0 = (h as f32 * 0.76) as usize;
for y in y0..h {
for x in x0..w {
let i = (y * w + x) * 4;
let r = pixels[i] as i32;
let g = pixels[i + 1] as i32;
let b = pixels[i + 2] as i32;
let mx = r.max(g).max(b);
let mn = r.min(g).min(b);
// 灰白系(三通道接近)且够亮 → 水印文字
if mn > 110 && (mx - mn) < 50 {
pixels[i + 3] = 0;
}
}
}
}
泥土的 mx - mn 很大(棕色的 R 和 B 差得多),不会被误删;水印的三通道几乎相等,精准命中。
💡 先统计,再动手。 写代码前花一分钟统计一下像素分布,往往能直接发现"可区分的特征",比拍脑袋定阈值靠谱得多。
八、播放:帧调度 + 叠加浮动
最后是播放逻辑。启动时加载所有帧,按时间取当前帧:
rust
const FRAME_DURATION: f32 = 1.0 / 12.0; // 和抽帧时的 12fps 对应
let t = anim_start.elapsed().as_secs_f32();
let (icon, iw, ih) = if self.frames.is_empty() {
(&self.icon_pixels.0, self.icon_pixels.1, self.icon_pixels.2) // 回退静态图
} else {
let idx = ((t / FRAME_DURATION) as usize) % self.frames.len();
let f = &self.frames[idx];
(&f.pixels, f.w, f.h)
};
帧动画之外,浮动动画依然叠加生效------萝卜一边播拔萝卜的动作,一边整体上下漂浮。两个动画层互不干扰。
顺带把窗口从 96×96 调到了 128×128:帧内容比原来那张静态图丰富得多,太小看不清。
九、还能做什么
现在的宠物会循环播放一段 5 秒动画。要更像 QQ 宠物,还差:
- 动作状态机:待机循环 + 每隔几秒随机播一个动作(挥手、蹦跳、睡觉),播完回待机
- 交互反馈:点击时播放"被摸头"的动作
- 跟随鼠标:宠物眼睛/朝向跟着光标转
- 重力/落地:拖动松手后下落到"地面"(屏幕底部)并弹一下
这些都不难------本质是多组帧 + 一个状态机。素材可以用 AI 继续生成,也可以从更多视频里抽。
十、小结
这次从静态图标到动画宠物,收获三个可复用的经验:
-
文档的"通用格式"可能被你的使用模式改写 softbuffer 说最高位为 0,但 DWM 透明合成下它是 alpha。改 bug 前先确认自己理解了全部上下文。
-
抠纯色背景要靠"连通性",不能只靠"颜色匹配" 否则角色内部的同色细节(眼睛、描边)会被一起抠掉。
-
AI 时代不用自己画素材,但要会"取"素材 视频抽帧、AI 生成都是手段。真正的技术活是处理素材:抠背景、去水印、压缩、调度播放。
最后回到那个问题:"AI 时代还要自己画图吗?"
不用画,但要会处理。 从视频到能用的动画帧,中间隔着解码、抠图、去水印、帧调度------这些才是工程师的活。AI 负责创作,代码负责让它乖乖地在桌面上动起来。
如果你也在做桌面小工具,希望这两篇能帮你少踩几个坑。欢迎交流。