鸿蒙HarmonyOS GrokBot 渲染引擎实战 —— 投影几何、弹簧物理与 DisplaySync 高刷绘制
一、前言:Canvas 逐帧动画的三道坎
在鸿蒙上写死一个静态 Canvas 很容易,但要写一个「活的」Canvas 头像,会立刻撞上三道坎:
-
几何坎:眼睛不是贴在脸上的贴纸,它会「越球面转动」——转到侧面会变窄、转到背面应该消失。这需要投影,而不是简单的
translate。 -
物理坎:表情切换不能瞬跳,要有「弹一下又阻尼停下」的质感;但数值积分方程写不对,动画会越弹越大,甚至发散。
-
调度坎:要 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)短边:避免在非正方形画布上产生非等比缩放(否则眼睛会被拉椭圆)。 -
先
translate再scale再translate的顺序不能反,这是把「内边距 + 居中 + 缩放」正确叠加的标准写法。
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.ets 里 preservesEllipticalCssRadiusAndClampsPairs 锁定的行为——不 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 逐行拆解
|
变量 |
含义 |
|---|---|
|
|
眼睛质心相对脸部中心的水平偏移 |
|
|
|
|
|
加上 |
|
|
|
|
|
|
|
|
最终的水平/垂直缩放,clamp 到 |
|
|
|
关键点:scaleX 用 perspective(会被 depth 压窄),scaleY 用 blinkScale(会被眨眼压扁)。两者解耦——转动只影响宽度,眨眼只影响高度,这让「边转边眨眼」的组合动作依然自然。
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 三个工程细节
-
半隐式欧拉(semi-implicit Euler):先算
velocity,再用新 velocity 更新value。相比显式欧拉(用旧 velocity 更新位置),它对二阶系统更稳定、能量更守恒,不容易越弹越大。 -
固定步长子步进:
rawDt可能因为一帧抖动而忽大忽小(比如卡顿后一帧 100ms)。直接拿大dt积分会导致不稳定。GrokBot 把rawDtclamp 到0.1秒,再切成一个个≤ 1/120秒的子步逐段积分,把可变帧间隔变成固定步长,稳定性大幅提升。 -
阻尼 + 频率系数的写法:加速度项
-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 直接加进 turn(this.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 内部的绘制顺序(关键步骤):
-
clearRect清屏 →save。 -
计算
canvasScale,做居中 + 缩放的坐标映射。 -
(可选)
flipX镜像。 -
画躯干(
appendBodyPath+ 填bodyColor),先做squashOnTurn的身体压扁。 -
用躯干 path 做
clip(),让眼睛只在躯干范围内绘制。 -
对每只眼:射影 → 若
visible则translate/scale后填eyeColor。 -
(可选
showGuides)画投影引导线 + 质心点,用于 Lab 调试。
其中 squashOnTurn(转动时身体压扁)是形状层的一个布尔:
const bodyScale = shape.squashOnTurn ? Math.max(Math.cos(turn), 0.55) : 1;
Bean、Tablet、Cloud 这类形状在转动时会「压扁」(cos(turn) 衰减,下限 0.55),模拟 3D 纸片转动;而 Blob 等不压扁,保持恒定宽度。
九、踩坑速查
|
现象 |
根因 |
修复 |
|---|---|---|
|
眼睛越球面转到侧面不消失 |
投影 |
|
|
表情切换越弹越大、抽搐 |
用显式欧拉 + 大 dt |
半隐式欧拉 + 固定步长子步进 |
|
静止时设备发烫耗电 |
DisplaySync 一直 |
每帧末尾 |
|
返回页面后动画卡死/泄漏 |
未移除 |
|
|
滚出可视区还在逐帧画 |
未监听可见性 |
|
|
眼睛被拉成椭圆 |
非等比 Canvas 直接用 w/h 缩放 |
取 |
|
圆角叠成畸形角 |
CSS 百分比圆角没 clamp 和 |
|
|
深色模式眼睛还是白 |
主题没随 dark 切换 |
传 |
|
眨眼「啪」一下消失 |
最小眼裂没设下限 |
|
|
spin 机械匀速生硬 |
没加缓动 |
|
十、已知边界
-
当前是约 60fps 的 Canvas 逐帧绘制,不涉及离屏渲染或硬件合成;超大尺寸(数百 vp 以上)前应做帧率专项评测。
-
弹簧、投影、缓动等纯函数有单测;帧率、功耗、后台恢复需真机仪器化,不在本文「已证明」结论内。
-
displaySync的min: 0依赖系统调度,实际节能效果因设备/系统版本而异。 -
球面投影是「圆柱投影」的近似,不包含完整 3D 旋转矩阵(roll/pitch 全自由的 3D 眼球不在当前模型内)。
十一、写在最后
GrokBot 这一篇把「让它活起来」的底层原理讲透了。核心结论:
坐标只映射一次,数据永不关心像素。
球面投影用asin/cos,转动只压宽度、眨眼只压高度。
弹簧选半隐式欧拉 + 固定步长,数值稳定比炫技重要。
DisplaySync 按需启停,静止就停表,绝不空转。
缓动用不对称节奏,快闭慢睁、缓入缓出,才有「生命感」。
更多推荐




所有评论(0)