一、前言:Canvas 逐帧动画的三道坎

在鸿蒙上写死一个静态 Canvas 很容易,但要写一个「活的」Canvas 头像,会立刻撞上三道坎:

  1. 几何坎:眼睛不是贴在脸上的贴纸,它会「越球面转动」——转到侧面会变窄、转到背面应该消失。这需要投影,而不是简单的 translate

  2. 物理坎:表情切换不能瞬跳,要有「弹一下又阻尼停下」的质感;但数值积分方程写不对,动画会越弹越大,甚至发散。

  3. 调度坎:要 60fps 逐帧,但头像静止时(Idle 且无动画)又不能让 DisplaySync 空转耗电。

GrokBot 的渲染引擎正是围绕这三道坎设计。下面逐一拆解。


二、坐标映射:259 逻辑空间 → 物理 Canvas

2.1 为什么所有数据都落在 259 的 viewBox

GrokBotExpressionData.ets 里 25×2×48 个点,坐标都是 0~259 的逻辑值。真正画到屏幕前,paintGrokBotFrame 做统一缩放:

const GROKBOT_VIEWBOX_SIZE = 259;
const GROKBOT_VIEWBOX_INSET = 15;
const GROKBOT_BODY_WIDTH = 228.541;

export function paintGrokBotFrame(ctx, size, rings, shape, gaze, turn,
  eyeScale, blinkScale, flipX, emphasis, showGuides, theme): void {
  const w = ctx.width > 0 ? ctx.width : size;
  const h = ctx.height > 0 ? ctx.height : size;
  ctx.clearRect(0, 0, w, h);
  ctx.save();
  const side = Math.min(w, h);                     // 取短边,保证不拉伸
  const canvasScale = side / GROKBOT_VIEWBOX_SIZE; // 逻辑 → 物理的缩放系数
  ctx.translate((w - side) / 2, (h - side) / 2);   // 居中
  ctx.scale(canvasScale, canvasScale);
  ctx.translate(GROKBOT_VIEWBOX_INSET, GROKBOT_VIEWBOX_INSET); // 留白
  // ...
}

要点

  • canvasScale 是唯一的分辨率入口:数据永远在 259 空间,size 变了只改这一个系数。DPR 无关、vp/px 无关,一套数据适配所有屏。

  • Math.min(w, h) 短边:避免在非正方形画布上产生非等比缩放(否则眼睛会被拉椭圆)。

  • translatescaletranslate 的顺序不能反,这是把「内边距 + 居中 + 缩放」正确叠加的标准写法。

2.2 flipX 的水平镜像

if (flipX) { ctx.translate(GROKBOT_BODY_WIDTH, 0); ctx.scale(-1, 1); }

镜像不是去改 96 个点的 x 坐标,而是在 Canvas 变换层做一次 scale(-1, 1)。这样数据层完全不用动,性能也更好(一次矩阵变换 vs 逐点翻符号)。用于道具「从右看」的场景(Lab 里的 Horizontal mirror)。


三、躯干:CSS border-radius 语法在 Canvas 的重现

3.1 形状数据里的圆角

18 种形状的核心差异之一就是「圆角」。形状数据里的 radius 字段直接用了 CSS border-radius 的语法

[GrokBotShape.Blob,      new GrokBotShapeData(0, 0, 1, 1, 1, false, '50%', 1, 1)],
[GrokBotShape.Capsule,   new GrokBotShapeData(0, 0, .64, .92, .81, false, '50% / 32%', .64, 1.08)],
[GrokBotShape.Egg,       new GrokBotShapeData(0, 2, .77, .97, .93, false, '50% 50% 56% 44%', .88, 1.05)],
[GrokBotShape.Teardrop,  new GrokBotShapeData(0, 22, .79, .79, .88, false, '50% 50% 62% 38%', .9, 1.05)],
// ...

'50% / 32%'椭圆角rx / ry),'50% 50% 56% 44%'四个角(左上/右上/右下/左下),'50%' 是全覆盖。这直接复用了前端熟知的语义,但 ArkUI 的 Canvas 没有现成的「按 CSS 圆角画圆角矩形」API,所以必须自己解析 + 用贝塞尔画。

3.2 解析:grokBotParseBorderRadius

function expandRadius(values: Array<string>): Array<string> {
  if (values.length === 0) return ['0', '0', '0', '0'];
  if (values.length === 1) return [values[0], values[0], values[0], values[0]];
  if (values.length === 2) return [values[0], values[1], values[0], values[1]];
  if (values.length === 3) return [values[0], values[1], values[2], values[1]];
  return values.slice(0, 4);
}

export function grokBotParseBorderRadius(css, width, height): Array<GrokBotRadius> {
  const parts = css.split('/');                       // 水平 / 垂直
  const horizontal = expandRadius(radiusTokens(parts[0]));
  const vertical = expandRadius(radiusTokens(parts.length > 1 ? parts[1] : parts[0]));
  const radii = [];
  for (let i = 0; i < 4; i++) {
    radii.push(new GrokBotRadius(
      parseRadiusToken(horizontal[i], width),   // 百分比按 width 换算
      parseRadiusToken(vertical[i], height)));  // 百分比按 height 换算
  }
  clampRadiusPair(radii, 0, 1, width, true);  // 水平方向约束
  clampRadiusPair(radii, 3, 2, width, true);
  clampRadiusPair(radii, 0, 3, height, false); // 垂直方向约束
  clampRadiusPair(radii, 1, 2, height, false);
  return radii;
}

百分比会被换算成绝对像素:水平圆角按 width,垂直圆角按 height。这一步看似琐碎,却正是「数据层用 CSS 语义、渲染层用绝对几何」的桥。

3.3 比例 clamp:圆角和不能超过边长

CSS 规范里有一条:相邻两角的半径之和不能超过对应边长,否则要按比例缩小。Canvas 自己不会做这件事,所以 GrokBot 手动实现:

function clampRadiusPair(radii, a, b, size, horizontal): void {
  const first = horizontal ? radii[a].x : radii[a].y;
  const second = horizontal ? radii[b].x : radii[b].y;
  const sum = first + second;
  if (sum <= size || sum <= 0) return;
  const scale = size / sum;
  if (horizontal) { radii[a].x *= scale; radii[b].x *= scale; }
  else { radii[a].y *= scale; radii[b].y *= scale; }
}

这是 GrokBot.test.etspreservesEllipticalCssRadiusAndClampsPairs 锁定的行为——不 clamp 的话,'90% 90%' 这种输入会画出重叠的畸形角。

3.4 用 KAPPA 贝塞尔画圆角矩形

真正的 appendGrokBotBodyPath 用四段贝塞尔 + 直线拼出圆角矩形,圆角用标准 KAPPA 常数(≈0.55228)近似四分之一圆:

const KAPPA = 0.5522847498307936;

function appendCorner(ctx, cx, cy, rx, ry, startX, startY, endX, endY, c1x, c1y, c2x, c2y): void {
  ctx.bezierCurveTo(
    cx + c1x * rx, cy + c1y * ry,
    cx + c2x * rx, cy + c2y * ry,
    cx + endX * rx, cy + endY * ry
  );
}

export function appendGrokBotBodyPath(ctx, shape): void {
  const b = grokBotBodyBounds(shape);       // [x, y, w, h]
  const r = grokBotParseBorderRadius(shape.radius, b[2], b[3]);
  ctx.beginPath();
  ctx.moveTo(b[0] + r[0].x, b[1]);
  ctx.lineTo(b[0] + b[2] - r[1].x, b[1]);
  // 右上角 ...
  ctx.closePath();
}

四、球面投影:眼睛「越球面转动」的核心

这是整个引擎最「技术」的部分。眼睛不是贴在平面上的,而是贴在**一个横向的球面(圆柱)**上,转动时会变窄、转到背面会消失。

4.1 从「环的质心」到「投影后位置」

每只眼睛的 48 点先算出质心,再由质心相对脸部中心的水平偏移算出它在球面上的「基准经度」,加上 turn(转动角)得到当前经度:

export function grokBotProjectEye(centroid, origin, radius, turn, gaze, scale, blinkScale): GrokBotProjection {
  const offset = centroid.x - origin.x;
  const baseLongitude = Math.asin(grokBotClamp(offset / Math.max(radius, 1), -1, 1));
  const longitude = baseLongitude + turn;         // 加上转动角
  const depth = Math.cos(longitude);              // 深度 = cos(经度),转到侧面 → 0
  const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02);
  return new GrokBotProjection(
    new GrokBotPoint(origin.x + radius * Math.sin(longitude) + gaze.x,  // 投影后的屏幕 x
      centroid.y + gaze.y),                                            // y 保持 + 视线偏移
    grokBotClamp(perspective * scale, 0.02, 2.4),   // 水平缩放
    grokBotClamp(blinkScale * scale, 0.02, 2.4),    // 垂直缩放(含眨眼)
    depth > 0.02                                    // 是否可见
  );
}

4.2 逐行拆解

变量

含义

offset

眼睛质心相对脸部中心的水平偏移

baseLongitude

asin(offset / radius):把水平偏移「反投影」回球面的基准经度

longitude

加上 turn 后的当前经度

depth

cos(longitude):正面 = 1,侧面 = 0,背面 = 负

perspective

depth / cos(baseLongitude):相对自身的透视缩放,转到侧面趋近 0

scaleX / scaleY

最终的水平/垂直缩放,clamp 到 [0.02, 2.4] 防除零

visible

depth > 0.02:转到背面就隐藏

关键点scaleXperspective(会被 depth 压窄),scaleYblinkScale(会被眨眼压扁)。两者解耦——转动只影响宽度,眨眼只影响高度,这让「边转边眨眼」的组合动作依然自然。

4.3 为什么是 asin 而不是 atan

普通透视用 atan 足够,但 GrokBot 模拟的是球面(圆柱):一个点绕圆柱转,它在屏幕上的 x 位置是 radius * sin(经度),而不是 radius * tan(经度)。这正是「越到侧面越慢、到背面消失」的球面观感来源。测试里锁定了这个行为:

// 正面可见、缩放为 1
const front = grokBotProjectEye(origin, origin, 105, 0, new GrokBotPoint(), 1, 1);
expect(front.visible).assertTrue();
expect(near(front.scaleX, 1)).assertTrue();
// 转过 PI(反面)不可见、缩放钳到 0.02
const back = grokBotProjectEye(origin, origin, 105, Math.PI, new GrokBotPoint(), 1, 1);
expect(back.visible).assertFalse();
expect(back.scaleX).assertEqual(0.02);

4.4 grokBotMapGaze:视线方向映射

gaze{x, y} ∈ [-1, 1] 的归一化向量,映射成眼睛的像素偏移:

export function grokBotMapGaze(gaze: GrokBotGaze): GrokBotPoint {
  return new GrokBotPoint(
    grokBotClamp(gaze.x, -1, 1) * 13.2,   // 水平最大偏 13.2
    grokBotClamp(gaze.y, -1, 1) * 8.4     // 垂直最大偏 8.4
  );
}

然后这个偏移直接加到投影后的眼睛中心上(见 4.1 里的 + gaze.x / + gaze.y)。所以「看方向」本质上也是纯几何的平移,走 onVisualChanged 一帧重绘即可,不触发任何弹簧动画。


五、弹簧物理:为什么选半隐式欧拉 + 固定步长子步进

表情切换的「弹一下再停」由一个二阶弹簧驱动。这是数值积分最容易翻车的地方。

5.1 弹簧类

export class GrokBotSpring {
  value: number = 1;      // 当前位置,目标恒为 1
  velocity: number = 0;

  start(): void { this.value = 0; this.velocity = 0; }
  active(): boolean {
    return Math.abs(this.value - 1) >= 0.001 || Math.abs(this.velocity) >= 0.001;
  }
  step(rawDt: number, frequency: number): void {
    let remaining: number = Math.min(Math.max(rawDt, 0), 0.1);
    const omega: number = Math.max(frequency, 0.001);
    while (remaining > 0) {
      const dt: number = Math.min(remaining, 1 / 120);
      // 半隐式欧拉:先更新速度,再用新速度更新位置
      this.velocity += (-2 * omega * this.velocity - omega * omega * (this.value - 1)) * dt;
      this.value += this.velocity * dt;
      remaining -= dt;
    }
    if (Math.abs(this.value - 1) < 0.001 && Math.abs(this.velocity) < 0.001) {
      this.value = 1; this.velocity = 0;   // 收敛后吸到稳态
    }
  }
}

5.2 三个工程细节

  1. 半隐式欧拉(semi-implicit Euler):先算 velocity,再用 velocity 更新 value。相比显式欧拉(用旧 velocity 更新位置),它对二阶系统更稳定、能量更守恒,不容易越弹越大。

  2. 固定步长子步进rawDt 可能因为一帧抖动而忽大忽小(比如卡顿后一帧 100ms)。直接拿大 dt 积分会导致不稳定。GrokBot 把 rawDt clamp 到 0.1 秒,再切成一个个 ≤ 1/120 秒的子步逐段积分,把可变帧间隔变成固定步长,稳定性大幅提升。

  3. 阻尼 + 频率系数的写法:加速度项 -2ω·v - ω²·(x-1)临界阻尼对应的系数(阻尼比 ζ = 1),保证「最快回到目标且不过冲」。frequency(默认 7)可调,值越大回弹越快越硬。

5.3 显式欧拉会怎样(反例)

如果用户手滑写成显式欧拉 value += velocity * dt; velocity += accel * dt,当 dt 偏大、omega 偏高时,模拟会「绕目标震荡甚至发散」,表情切换会抽风。GrokBot 的测试用 300 次 step(1/120, 7) 验证弹簧能收敛到 1(near(spring.value, 1, 0.002)active() 为 false),把这个稳定性守护住。


六、blink / spin 的缓动节奏

6.1 眨眼:快闭慢睁的 0.32 秒

眨眼不是线性开合,而是「快速闭上、缓慢睁开」的不对称节奏:

export function grokBotBlinkScale(seconds: number): number {
  const progress: number = seconds / 0.32;   // 总时长 0.32 秒
  // 前 42% 时间:从 1 快速降到 0.04(闭眼);后 58%:从 0.04 缓慢回到 1
  return Math.max(
    progress < 0.42
      ? 1 - progress / 0.42
      : (progress - 0.42) / 0.58,
    0.04
  );
}
  • 0.32 秒是眨眼总时长,写死在组件里(blinkSeconds >= 0.32 就完成)。

  • 0.42 / 0.58 是闭/睁的时间分配比例——闭得快(0.13 秒)、睁得慢(0.19 秒),符合真实眨眼。

  • 0.04最小眼裂(不会完全闭死,留一丝),避免整只眼「啪」消失的突兀。

blinkScale 最终作用在 4.1 的 scaleY = blinkScale * scale 上,把眼睛垂直压扁成一条缝。

6.2 spin:easeInOutCubic 的整圈旋转

controller.spin(turns, duration) 让整只 GrokBot 做「原地打转」。转动角由缓动函数驱动:

export function grokBotEaseInOutCubic(t: number): number {
  const p: number = grokBotClamp(t, 0, 1);
  return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
// onFrame 里:
this.spinSeconds += dt;
const progress = clamp(this.spinSeconds * 1000 / this.spinDuration, 0, 1);
this.spinAngle = grokBotEaseInOutCubic(progress) * Math.PI * 2 * this.spinTurns;

easeInOutCubic缓入缓出:开头加速、结尾减速,让旋转有「蓄力 → 甩出 → 停稳」的手感,而不是机械匀速。spinAngle 直接加进 turnthis.turn + this.spinAngle)参与 4.1 的球面投影,所以 spin 时眼睛会随躯干一起「转过去再转回来」。


七、DisplaySync:60fps 逐帧 + 静止停表

这是鸿蒙自定义动画最重要也最容易被写错的一环。setInterval 做动画会有帧率不稳、后台空转、与屏幕 vsync 脱节等问题,GrokBot 用的是 ArkGraphics2D 的 displaySync

7.1 创建与配置

import { displaySync } from '@kit.ArkGraphics2D';

private ensureSync(): void {
  if (this.sync !== null) return;
  const sync: displaySync.DisplaySync = displaySync.create();
  sync.setExpectedFrameRateRange({ expected: 60, min: 0, max: 120 });
  const callback = (_info: displaySync.IntervalInfo): void => this.onFrame();
  this.frameCallback = callback;
  sync.on('frame', callback);
  this.sync = sync;
}

setExpectedFrameRateRange({ expected: 60, min: 0, max: 120 }) 表达「期望 60fps,但允许 0~120 自适应」——min: 0 让系统在静止时可以不给帧(配合下面的停表逻辑),这是节能的关键。

7.2 按需启动 / 停表

DisplaySync 不是一直跑,而是需要动画时才 start,静止就 stop

private startAnimation(): void {
  if (!this.appeared || !this.visible || !this.canvasReady || this.running) return;
  this.ensureSync();
  if (this.sync !== null) {
    this.lastFrameMs = Date.now();
    this.sync.start();
    this.running = true;
  }
}

private stopIfIdle(): void {
  if (this.spring.active() || this.blinkSeconds >= 0 || this.spinSeconds >= 0) return;
  if (this.sync !== null && this.running) this.sync.stop();
  this.running = false;
  this.lastFrameMs = 0;
}

stopIfIdle 在每帧末尾调用:只要弹簧不再动、没有眨眼、没有 spin,就立刻停表。这样 Idle 静止时 DisplaySync 完全不耗电,而一旦有动作(弹簧/眨眼/spin)又立即 start

7.3 帧循环与 delta time

private onFrame(): void {
  const now = Date.now();
  const dt = this.lastFrameMs > 0 ? (now - this.lastFrameMs) / 1000 : 0;
  this.lastFrameMs = now;

  if (this.spring.active()) {
    this.spring.step(dt, Math.max(this.springFrequency, 0.001));
    this.resolveDisplayedRings();
  }
  if (this.blinkSeconds >= 0) {
    this.blinkSeconds += dt;
    if (this.blinkSeconds >= 0.32) { this.blinkSeconds = -1; this.completeBlink(); }
  }
  if (this.spinSeconds >= 0) {
    this.spinSeconds += dt;
    const progress = clamp(this.spinSeconds * 1000 / this.spinDuration, 0, 1);
    this.spinAngle = grokBotEaseInOutCubic(progress) * Math.PI * 2 * this.spinTurns;
    if (progress >= 1) { this.spinSeconds = -1; this.spinAngle = 0; this.completeSpin(); }
  }
  this.paintNow();
  this.stopIfIdle();
}

第一帧 dt = 0 的处理lastFrameMs 初始为 0,startAnimation 里会先赋 Date.now(),所以第一帧 dt=0 只画不推——避免停表后重新启动时出现一次大跳变。

7.4 teardown 与可见性联动

private teardownSync(): void {
  if (this.sync !== null) {
    if (this.running) this.sync.stop();
    if (this.frameCallback !== null) this.sync.off('frame', this.frameCallback);
    else this.sync.off('frame');
  }
  this.sync = null; this.frameCallback = null;
  this.running = false; this.lastFrameMs = 0;
}

// build 里的 onVisibleAreaChange:
.onVisibleAreaChange([0.0, 1.0], (visible, _ratio): void => {
  this.visible = visible;
  if (visible) {
    this.paintNow(); this.scheduleBlink(); this.scheduleExpression();
    if (this.spring.active() || this.blinkSeconds >= 0 || this.spinSeconds >= 0) this.startAnimation();
  } else {
    this.clearTimers();
    if (this.sync !== null && this.running) this.sync.stop();
    this.running = false;
  }
})

这是最容易被忽略的生命周期细节

  • 组件滚出可视区visible=false → 停表 + 清定时器,绝不后台空转。

  • 组件重新可见 → 重绘 + 重排眨眼/表情 + 若动画未完成则续跑。

  • 组件销毁aboutToDisappear)→ teardownSync 移除 frame 监听,防止内存泄漏。


八、绘制装配:paintGrokBotFrame 的全流程

把前面的几何、物理、调度串起来,一帧的完整装配是:

private paintNow(): void {
  if (!this.canvasReady) return;
  const shapeData = GROKBOT_SHAPES.get(this.shape);
  if (shapeData === undefined) return;
  this.resolveDisplayedRings();   // 1. 弹簧插值出当前眼睛形状
  paintGrokBotFrame(this.context, Math.max(1, this.botSize),
    this.displayRings, shapeData, this.gaze,
    this.turn + this.spinAngle,   // 2. 转动角 = 手动 turn + spin 动画
    Math.max(this.eyeScale, 0.001), this.blinkValue(), this.flipX,
    this.emphasis, this.showGuides, this.theme);
}

paintGrokBotFrame 内部的绘制顺序(关键步骤):

  1. clearRect 清屏 → save

  2. 计算 canvasScale,做居中 + 缩放的坐标映射。

  3. (可选)flipX 镜像。

  4. 画躯干(appendBodyPath + 填 bodyColor),先做 squashOnTurn 的身体压扁。

  5. 用躯干 path 做 clip(),让眼睛只在躯干范围内绘制。

  6. 对每只眼:射影 → 若 visibletranslate/scale 后填 eyeColor

  7. (可选 showGuides)画投影引导线 + 质心点,用于 Lab 调试。

其中 squashOnTurn(转动时身体压扁)是形状层的一个布尔:

const bodyScale = shape.squashOnTurn ? Math.max(Math.cos(turn), 0.55) : 1;

BeanTabletCloud 这类形状在转动时会「压扁」(cos(turn) 衰减,下限 0.55),模拟 3D 纸片转动;而 Blob 等不压扁,保持恒定宽度。


九、踩坑速查

现象

根因

修复

眼睛越球面转到侧面不消失

投影 depth 没判断

visible = depth > 0.02

表情切换越弹越大、抽搐

用显式欧拉 + 大 dt

半隐式欧拉 + 固定步长子步进

静止时设备发烫耗电

DisplaySync 一直 start 没停

每帧末尾 stopIfIdle

返回页面后动画卡死/泄漏

未移除 frame 监听

aboutToDisappear 里 teardown

滚出可视区还在逐帧画

未监听可见性

onVisibleAreaChange 停表 + 清定时器

眼睛被拉成椭圆

非等比 Canvas 直接用 w/h 缩放

Math.min(w,h) 短边做 canvasScale

圆角叠成畸形角

CSS 百分比圆角没 clamp 和

clampRadiusPair 按边长比例缩放

深色模式眼睛还是白

主题没随 dark 切换 eyeColor

GrokBotThemeData.dark()

眨眼「啪」一下消失

最小眼裂没设下限

blinkScale 下限 0.04

spin 机械匀速生硬

没加缓动

easeInOutCubic 缓入缓出


十、已知边界

  • 当前是约 60fps 的 Canvas 逐帧绘制,不涉及离屏渲染或硬件合成;超大尺寸(数百 vp 以上)前应做帧率专项评测。

  • 弹簧、投影、缓动等纯函数有单测;帧率、功耗、后台恢复需真机仪器化,不在本文「已证明」结论内。

  • displaySyncmin: 0 依赖系统调度,实际节能效果因设备/系统版本而异。

  • 球面投影是「圆柱投影」的近似,不包含完整 3D 旋转矩阵(roll/pitch 全自由的 3D 眼球不在当前模型内)。


十一、写在最后

GrokBot 这一篇把「让它活起来」的底层原理讲透了。核心结论:

坐标只映射一次,数据永不关心像素。
球面投影用 asin/cos,转动只压宽度、眨眼只压高度。
弹簧选半隐式欧拉 + 固定步长,数值稳定比炫技重要。
DisplaySync 按需启停,静止就停表,绝不空转。
缓动用不对称节奏,快闭慢睁、缓入缓出,才有「生命感」。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐