优化NekoWebShow,提升桌面背景使用体验封面图

优化NekoWebShow,提升桌面背景使用体验

NekoWebShow 这个玩意,我从去年暑假开发出来都没有太大的改进,唯一一次比较大的改进是引入了压缩和解压机制,缩小部署文件的大小的同时,也优化了加载的速度。当然,那是我需要为我的博客开发 NekoEcho 做的优化工作,想到这部分优化是可以应用到 NekoWebShow 上面的,就一并优化了。除此之外,界面的 UI,功能什么的,还真没什么大的改进。
正好这段时间华为云的 AI 开发者空间有无限量的DeepSeek V4 Pro可以用,GLM 5.2也是无限量,这段时间已经是 Token 自由了,为何不尝试优化一下呢?

天下词元谁最多,大家都说是华为。词元耐用速度快,国产模型不限量。华为好呀华为美,华为给我增Token。

那就动工吧,不过现在 AI 有了 Agent 后,基本上都不需要手改代码了,相比于之前复制代码放到对话框问 AI,开发速度很明显变得更快了:需要做的事情只是产品经理+质量检查验收了。
我打算进行修改完善的内容如下:

  • 优化 UI 效果:改进控制面板的样式,让设计更加美观;为控制面板和展示/隐藏按钮增加可以进行拖动的功能。
  • 优化角色展示:增加缩放大小,拖动这两个功能。
  • 功能增强:增加帧率限制,节省壁纸性能消耗;增加角色静音功能,防止声音社死(虽然我不在意);增加角色位置锁定和重置功能。
  • 背景更换:支持更换不同常见的背景图。

抽离UI层相关样式,并重构

原来所有控制 UI 都内联在 index.php 里,305 行 PHP 混着 HTML 混着 JS,改一个按钮样式要在三个地方跳。我决定还是把这些固定的代码以静态资源的方式存放,也就是独立为locales.js、ui.js和ui.css。
另外,因为本来的UI并不好看,而且也无法拖动,于是重构我对UI的样式进行了重新设计:白色的设置菜单背景加上粉色的描边,粉白搭配看起来会更可爱。
对控制UI的重构可以说是大刀阔斧的改动:
locales.js 里把四语言文案(英/简/繁/日)统一收口,通过 data-i18n 属性驱动,不再在 PHP 里 echo 一堆条件分支。ui.js 负责所有交互:抽屉展开、帧率选择、壁纸切换、静音锁定,状态全部走 localStorage 持久化。ui.css 把之前散落在 <style> 和行内 style 里的样式集中起来,粉白配色 + 圆角 + 半透明,顺手给切换图标和控制面板都加上了拖动。

有个小坑值得记一下:切换图标刷新后偶尔会消失。原因是原来用 getBoundingClientRect() 拿初始位置,但图片还没加载完,宽高是 0,算出来位置不对。改成用视口宽度 + 固定尺寸(CSS 已固定 44px)算右上角定位,不依赖图片加载:

if (icon.style.left === '' || icon.style.left === 'auto') {
  var vw = viewportSize().w;
  var iw = icon.offsetWidth || 44;
  icon.style.top = '8px';
  icon.style.left = Math.max(8, vw - iw - 8) + 'px';
  icon.style.right = 'auto';
}
clampToViewport(icon, 8); // 防御:保存的位置越界也夹回来

改完 index.php 瘦了 305 行,UI 改起来清爽多了,后面加帧率、锁定、壁纸这些功能都快得多,不用在 PHP/HTML/JS 三种语法里跳来跳去。

增加帧率限制

之前壁纸的渲染帧率是顶着最大帧率,没有做限制。丝滑是丝滑,就是有点吃性能,长期拿来做桌面背景对性能消耗有点大。于是我打算加入帧率限制,设置30帧,60帧,120帧,和无限制四个选择。
帧率限制加在 driver/emoteplayer.js 的 drawAnimation 里。一开始我想直接 return 跳过没到时间的帧,但那样 rAF 链就断了,下一帧不会再被调度。所以改成"跳过这一帧 + 用 setTimeout 补一次 rAF":

drawAnimation(timeStamp) {
  const fpsLimit = EmotePlayer.fpsLimit || 0;
  if (fpsLimit > 0) {
    const minInterval = 1000 / fpsLimit;
    if (this.lastFrameTime !== null) {
      const elapsed = timeStamp - this.lastFrameTime;
      if (elapsed < minInterval) {
        setTimeout(() => {
          this.requestId = requestAnimationFrame(this.drawAnimation.bind(this));
        }, minInterval - elapsed);
        return;
      }
    }
    this.lastFrameTime = timeStamp;
  }
  // ...原有绘制逻辑
}

ui.js 里用 localStorage 持久化用户选择,默认 60,main.js 启动时调一下 applyFps() 把设置灌进去。
不过这个版本后来又改了——setTimeout + rAF 叠加在 144Hz 显示器上会偶发跳帧,这个坑放到最后一节"优化性能占用"里讲。

新增角色控制功能

这次的改进包含了角色的移动,缩放,锁定功能,满足对角色位置和大小的调整需求。此外,还增加了角色静音功能,防止误触发出声音造成社死。

几个设计点要先想清楚:

  • 渲染区域怎么铺满? 原来画布是"居中固定 120vh 宽",两侧留白。我想改成铺满整个窗口,但渲染分辨率在页面加载时就定死了(WebGL 帧缓冲/纹理/顶点缓冲初始化时一次性烘焙,没有干净的设备重建路径),运行中改不了。所以预设一个最低渲染高度 1440,给"放大"留清晰度余量,然后用桌面壁纸的 cover 思路:画布始终铺满窗口,最窄边对齐,另一边超出部分裁掉。这样不管窗口横竖,角色拖到哪都不会被画布边界截断。
  • 拖动和缩放怎么叠加到响应式布局上? 引入 userOffsetX/Y 和 userScale,在响应式 applyResponsivePlayerLayout 之上叠加。锁定时这两个变量不响应输入,但保留当前值(解锁后还在原位)。

    let userOffsetX = 0, userOffsetY = 0, userScale = 1;
    const isLocked = () => !!(window.NekoUI?.isLocked?.());
    
    canvas.addEventListener('mousedown', (ev) => {
    if (ev.button !== 0 || isLocked()) return;
    dragState.active = true;
    dragState.startX = ev.clientX; dragState.startY = ev.clientY;
    });
    
    canvas.addEventListener('wheel', (ev) => {
    if (isLocked()) return;
    ev.preventDefault();
    const factor = Math.exp(-ev.deltaY * 0.0012);
    userScale = Math.max(0.25, Math.min(5, userScale * factor));
    applyResponsivePlayerLayout();
    }, { passive: false });

静音在声音播放处短路,嘴型仍由动作变量驱动(这样静音了嘴还在动,不会像卡死):

if (window.NekoUI?.isMuted?.()) {
  return { durationMs: 0, ended: Promise.resolve() };
}

壁纸选择器:PHP 版扫描 img/ 目录动态注入 window.NekoWallpapers,静态版走 ui.js 里的 STATIC_WALLPAPERS 回退清单。这里有个小坑——静态版没 PHP,加新图必须同时去 ui.js 把文件名补进数组,不然选不出来。
改完角色能拖到屏幕任意角落,滚轮能放大到 5x,锁定后点角色不再触发动作。壁纸能切了,静音能关了,四语言文案都补齐。

优化鼠标跳跃导致抽搐

在测试的时候发现:角色盯着鼠标看(eyetracking_reaction),但当鼠标离开 canvas(比如切到另一个窗口做事)再回来时,注视目标从"上次离开点"瞬变到"当前鼠标位置",角色头部会猛地一抽。频繁切窗口的话特别明显,很影响角色的观感,我决定解决这个问题。

最简单的办法是鼠标回来时不让它瞬变——用一段过渡把注视点从离开点"追"到当前鼠标位置。但要解决两个问题:

  1. 过渡期间鼠标还在动怎么办? 如果过渡终点固定在"回来那一刻"的位置,鼠标继续动就会追到一个旧位置,反而更怪。所以过渡的终点要随鼠标实时更新,鼠标停在哪就追到哪。
  2. 正常移动要不要也插值? 不要。正常跟手是最自然的,插值反而有延迟感。所以只在"重新进入"那一刻启动过渡,过渡结束就恢复直接跟手。

我管这个过渡里的虚拟注视点叫"影子":

const SHADOW_TRANSITION_MS = 1000;
let lastMouseX = null, lastMouseY = null;
let leaveX = null, leaveY = null;
let shadowRAF = null, shadowState = null;

const runShadowStep = () => {
  const t = Math.min(1, (performance.now() - shadowState.startTime) / SHADOW_TRANSITION_MS);
  const e = 1 - Math.pow(1 - t, 3); // ease-out cubic
  const sx = shadowState.fromX + (shadowState.toX - shadowState.fromX) * e;
  const sy = shadowState.fromY + (shadowState.toY - shadowState.fromY) * e;
  shadowState.curX = sx; shadowState.curY = sy;
  eyetracking_reaction({ clientX: sx, clientY: sy });
  if (t < 1) {
    shadowRAF = requestAnimationFrame(runShadowStep);
  } else { shadowRAF = null; shadowState = null; }
};

canvas.addEventListener('mouseenter', (ev) => {
  if (leaveX === null) return;
  shadowState = {
    fromX: leaveX, fromY: leaveY,
    toX: ev.clientX, toY: ev.clientY,
    curX: leaveX, curY: leaveY,
    startTime: performance.now(),
  };
  leaveX = null; leaveY = null;
  if (shadowRAF) cancelAnimationFrame(shadowRAF);
  shadowRAF = requestAnimationFrame(runShadowStep);
});

canvas.onmousemove = (ev) => {
  lastMouseX = ev.clientX; lastMouseY = ev.clientY;
  if (shadowState) {
    shadowState.toX = ev.clientX; shadowState.toY = ev.clientY; // 影子终点实时跟随
    return;
  }
  eyetracking_reaction(ev); // 正常跟手
};

关键细节:首帧影子 = 离开点 = 角色当前注视位置,所以第一帧没有任何跳变,过渡从"角色现在看着的地方"自然滑向"鼠标现在的地方"。mouseleave 时如果正在过渡中,要把当前影子位置记成新的离开点(不然下次回来会从旧的 fromX 起跳)。
改完切窗口回来角色头平滑地转过去,再也不抽了。正常移动还是跟手,没引入延迟。

优化性能占用

前面那些都是功能层面的改进,这一节是真正动性能手术。长期挂作桌面背景,最在乎的就是 CPU 和内存,所以我专门用 chromium + SwiftShader 软件渲染做了一轮前后对比测试。
经过分析,发现之前的代码会进行多一次复制,导致性能开销:

模型绘制到纹理 → 绘制到隐藏的 WebGL 画布 → drawImage 复制到可见的 2D 画布

把中间的环节去掉,让纹理直接输出到可见画布,可以减少性能开销。
减小驱动的JS堆内存:之前因为大一些的角色模型会加载失败,所以简单粗暴设置堆内存为256MB,经过核对后,调整为144MB,刚好卡着占用最高的情况。
此外,鼠标注视每次移动都会读取位置并设置多个模型变量,这也是一个潜在的优化点。

修复帧率调度:干掉 setTimeout + rAF 叠加

之前那版帧率限制(setTimeout 补一帧 rAF)在 144Hz 显示器上会偶发跳帧。原因是 setTimeout 的回调时间和浏览器的刷新时钟不对齐,两个定时器叠加起来,有时候一帧被跳过、有时候又连着渲染两帧,肉眼看就是一卡一卡的。而且每次都 this.drawAnimation.bind(this) 创建一个新函数,rAF 链上的回调对象每次都不一样,虽然性能影响不大,但不够干净。
改成纯 rAF 链,自己维护一个 nextFrameTime 截止时间,没到就只 requestAnimationFrame 不绘制,到了就推进截止时间。关键改动在 driver/emoteplayer.js:

// 改动前:setTimeout + rAF 叠加,bind 每次创建新函数
if (elapsed < minInterval) {
  setTimeout(() => {
    this.requestId = requestAnimationFrame(this.drawAnimation.bind(this));
  }, minInterval - elapsed);
  return;
}
this.lastFrameTime = timeStamp;

// 改动后:纯 rAF 链,animationCallback 复用,nextFrameTime 累积推进
this.animationCallback = this.drawAnimation.bind(this);  // initMembers 里绑定一次
// ...
if (timeStamp + tolerance < this.nextFrameTime) {
  this.requestId = requestAnimationFrame(this.animationCallback);  // 没到时间,只调度不绘制
  return;
}
const intervals = Math.floor((timeStamp + tolerance - this.nextFrameTime) / minInterval) + 1;
this.nextFrameTime += intervals * minInterval;  // 推进截止时间,保留小数余量

几个细节:

  • animationCallback 在 initMembers 里 bind 一次,之后永远复用同一个函数引用,rAF 链不断。
  • tolerance = Math.min(1, minInterval / 4),容忍刷新时钟的轻微抖动和 timeStamp 取整误差,避免因为差 0.5ms 就误判跳帧。
  • nextFrameTime 用 intervals * minInterval 累积推进而不是直接赋成 timeStamp,这样在 60FPS 限速跑于 144Hz 显示器时,帧间隔能保留小数部分(16.67ms),不会因为取整慢慢漂移。
  • 切换帧率时 activeFpsLimit 变了,把 nextFrameTime 重置成 null 让下一帧重新对齐,立即生效。
  • 停止动画时 cancelAnimationFrame 之后把 requestId 置 null,避免残留回调。

实测这一项让 rAF 间隔的 P99 尾延迟从 483ms 降到 150ms——之前那种偶发的大间隔(setTimeout 没对齐刷新时钟导致的)基本消失了。

直接 WebGL 显示:画布从 2 个减为 1 个

这是收益最大的一项。原来的渲染链路是:

模型绘制到 WebGL 纹理 → 绘制到隐藏的 WebGL 画布 → drawImage 复制到可见的 2D 画布

那个隐藏的 WebGL 画布是 EmotePlayer.createRenderCanvas 创建的,可见的 2D 画布才是页面上 <canvas id="canvas">。每帧渲染完都要把隐藏画布的内容 drawImage 到可见画布,这一步复制完全是多余的——WebGL 画布本身就能直接显示。
改成让 WebGL 直接渲染到可见画布。main.js 里把 createRenderCanvas 换成 setRenderCanvas:

// 改动前:创建隐藏 WebGL 画布,再 new EmotePlayer(可见 2D 画布),每帧 drawImage 复制
EmotePlayer.createRenderCanvas(width, height);
const canvas = document.getElementById('canvas');
const player = new EmotePlayer(canvas);
canvas.width = width;
canvas.height = height;

// 改动后:直接把可见画布设为渲染画布,WebGL 直接输出到屏幕
const canvas = document.getElementById('canvas');
canvas.width = width;        // 先定尺寸再创建 WebGL 设备,避免之后 resize 重置缓冲
canvas.height = height;
EmotePlayer.setRenderCanvas(canvas);
const player = new EmotePlayer(canvas);

注意顺序:必须先 canvas.width/height 再 setRenderCanvas,因为 WebGL 设备创建时帧缓冲/纹理是一次性烘焙的,之后改尺寸会重置 drawing buffer。
emoteplayer.js 里相应改两处:清屏时如果当前画布就是 renderCanvas,用 gl.clear 而不是 2d.clearRect;endScene 时对 renderCanvas 直接 return,跳过复制:

// 清屏:WebGL 画布用 gl.clear,2D 画布保留原路径
if (canvas === this.renderCanvas) {
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
  this.gl.clearColor(0, 0, 0, 0);
  this.gl.clear(this.gl.COLOR_BUFFER_BIT);
} else {
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, canvas.width, canvas.height);
}

// endScene:renderCanvas 已由浏览器直接呈现,跳过 drawImage 复制
if (canvas == null || canvas === this.renderCanvas)
  return;

保留 else 分支是为了兼容那种确实需要单独 2D 画布的调用路径,但主页面现在只走 renderCanvas 这条路。实测这一项让 JS 堆从 262MB 降到 150MB——少了一个画布的帧缓冲和中间纹理,内存直接砍掉一大块。

细节优化:合并按帧处理,少读布局

把"每次事件都立即处理"改成"打标记,等渲染帧统一处理",减少冗余计算和布局读取。
鼠标注视去掉三角运算:原来算注视方向用了 atan2 + sqrt + cos + sin,其实注视变量是按分量线性喂的,直接用 mouseOffsetX/Y 就行,省掉一串三角函数。距离判断也用 distanceSquared > 60*60 代替 len > 60,避免开方:

// 改动前:atan2 + sqrt + cos + sin
const angle = Math.atan2(mouseOffsetY, mouseOffsetX);
const len = Math.sqrt(mouseOffsetX ** 2 + mouseOffsetY ** 2);
const c = Math.cos(angle), s = Math.sin(angle);
player.setVariableDiff('eyetrack', 'face_eye_LR', len / 3 * c, 500, -1);
if (len > 60) { player.setVariableDiff('eyetrack', 'head_LR', len / 6 * c, 1000, -1); }

// 改动后:直接用分量,distanceSquared 避免开方
const distanceSquared = mouseOffsetX ** 2 + mouseOffsetY ** 2;
player.setVariableDiff('eyetrack', 'face_eye_LR', mouseOffsetX / 3, 500, -1);
if (distanceSquared > 60 * 60) { player.setVariableDiff('eyetrack', 'head_LR', mouseOffsetX / 6, 1000, -1); }

影子过渡复用渲染循环:上一节"影子过渡"原来自己开了一个 shadowRAF = requestAnimationFrame(runShadowStep),现在改成 pendingGaze 标记,由 emoteplayer.js 的主渲染循环在每帧 onUpdate(timeStamp) 时统一驱动,少一条独立 rAF 链。onUpdate 也传入了 timeStamp,影子过渡直接用这个时间算进度,不用自己 performance.now()。
命中检测共用一次布局读取:原来 getMarkerPosition 每次都 this.canvas.getBoundingClientRect(),点击命中检测要读 8 个 marker 位置就读 8 次布局。改成 getMarkerPosition(marker, canvasRect) 接受外部传入的 rect,命中检测只读一次 getBoundingClientRect 复用给所有 marker:

// 改动前:每个 marker 各读一次布局
const bustPosition = player.getMarkerPosition('bust');
const eyePosition = player.getMarkerPosition('eye');
// ...8 次 getBoundingClientRect

// 改动后:只读一次,复用给所有 marker
const canvasRect = canvas.getBoundingClientRect();
const bustPosition = player.getMarkerPosition('bust', canvasRect);
const eyePosition = player.getMarkerPosition('eye', canvasRect);
// ...1 次 getBoundingClientRect

拖动/缩放按帧合并:引入 transformPending 标志,鼠标拖动和滚轮缩放时只置标志,不立即 player.scale = ...,等渲染帧 flushUserTransform() 统一应用。这样连续拖动时不会每mousemove都重算一次模型矩阵,静置时也不每帧读画布位置。

顺手修了个 bug:切换帧率时误清了壁纸选中标记,原因是两个 localStorage key 写岔了。

调整 WASM 堆容量:256MB → 144MB

driver/FreeMoteDriver.js 是 Emscripten 编译出来的 WASM 加载器,里面 TOTAL_MEMORY 控制线性内存初始大小。之前因为个别大角色模型加载失败,简单粗暴设成了 256MB(268435456)。后来把所有角色模型都跑了一遍,实测峰值占用卡在 136.352 MB,就调成 150994944(144 MB),留 7MB 左右的余量,栈保持 5 MB 不动:

// 改动前
var TOTAL_MEMORY = Module["TOTAL_MEMORY"] || 268435456;   // 256 MB

// 改动后
var TOTAL_MEMORY = Module["TOTAL_MEMORY"] || 150994944;   // 144 MB

这个值是 WASM 线性内存的 ArrayBuffer 大小,一启动就 new ArrayBuffer(TOTAL_MEMORY) 占住,属于"不管你用不用都先分配"的那种。从 256 降到 144,每个标签页常驻内存直接少 112MB。新增了个 driver/set-memory.py 脚本方便以后改这个值。

实测对比

四次改完跑了一轮对比(chromium + SwiftShader 软件渲染,更能体现性能差距,稳态 35s):

指标优化前优化后变化
平均 FPS6.68.5+29%
rAF 间隔均值145ms113ms-22%
rAF 间隔 P99483ms150ms-69%
JS 堆已用262MB150MB-43%
进程 CPU 均值879%764%-13%
画布数量21-50%

软件渲染下绝对 FPS 偏低,但前后条件一致,可以看到这一套组合拳下来性能提升挺明显。测试了一段时间,内存无增长趋势,没有泄漏。

小结

这次修改真的是一次酣畅淋漓的性能优化,感谢华为云无限 Token 的 Codearts 让我可以一直狠狠用,一个月干掉1.5亿 Token 不花一分钱,还送代金券给我续费服务器。

华为好呀华为美,华为给我服务器。

下一步按计划要新开 html_version_v2 分支,把静态版重构成单一 index.html + JS 角色切换 + cookie 持久化,通过页面内重建引擎切换角色而不是 reload。但是目前发现似乎这个FreeMoteDriver.js驱动似乎无法在不刷新页面的情况下进行重新初始化更换角色,反正 CodeArts 里面的 GLM-5.2 已经是败下阵来了,接下来应该是要 Codex 启动了,看看新的 ChatGPT-6-Astra 实力如何。

如果你想看看现在最新版的 NekoWebShow 是什么样了,请浏览器打开:
chocola-x.github.io/NekoWebShow/
我就不截图放上来了,因为你自己点开去看看可比我放截图效果好多了。

哦,对了,针对 NekoWebShow 的性能优化,我已经同步落实到我博客主题 NekoEcho 上面了~

评论区 (0)