Rust 桌面宠物进化记:从静态图标到会动的"QQ 宠物"

这是《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 继续生成,也可以从更多视频里抽。


十、小结

这次从静态图标到动画宠物,收获三个可复用的经验:

  1. 文档的"通用格式"可能被你的使用模式改写 softbuffer 说最高位为 0,但 DWM 透明合成下它是 alpha。改 bug 前先确认自己理解了全部上下文。

  2. 抠纯色背景要靠"连通性",不能只靠"颜色匹配" 否则角色内部的同色细节(眼睛、描边)会被一起抠掉。

  3. AI 时代不用自己画素材,但要会"取"素材 视频抽帧、AI 生成都是手段。真正的技术活是处理素材:抠背景、去水印、压缩、调度播放。

最后回到那个问题:"AI 时代还要自己画图吗?"

不用画,但要会处理。 从视频到能用的动画帧,中间隔着解码、抠图、去水印、帧调度------这些才是工程师的活。AI 负责创作,代码负责让它乖乖地在桌面上动起来。


如果你也在做桌面小工具,希望这两篇能帮你少踩几个坑。欢迎交流。

相关推荐
2601_9620662337 分钟前
SpringBoot3 快速启动框架
java·spring boot·后端
卷无止境3 小时前
Python能做嵌入式开发吗?一份写给动手派的生态与硬件全景图
后端·python
Sherotree9 小时前
Google Antigravity——Agent 被提到主界面
前端·ide·后端·edge浏览器
2601_962056239 小时前
Spring Boot 入门 与 无法解析符号 springframework 的解决
java·spring boot·后端
你驴我10 小时前
WhatsApp 多账号场景下的会话归档与历史消息检索优化实践
后端·python
学长毕业设计10 小时前
基于SpringBoot的瑜伽馆网站的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
Rain的Java大神之路11 小时前
PC版网站被狂刷怎么处理
java·数据库·redis·后端·mysql·web安全·运维开发
学长毕业设计13 小时前
基于springboot的云南中草药知识普及管理网站的设计与开发(源码+文档+讲解视频)
java·spring boot·后端
烂蜻蜓14 小时前
Flask入门教程(八):视图函数详解——请求处理与响应的核心
后端·python·flask