HarmonyOS 鸿蒙 Thinking Orbs AI 状态球实战 —— 六态数学、对象池与共享时间轴
一、前言:为什么一个「状态球」值得写这么细?
先看六种状态,它们是真实 AI 产品里的语义:
|
状态 |
视觉 |
想表达的感觉 |
|---|---|---|
|
Working |
倾斜轨道上的粒子 + 幽灵轨道 |
后台稳定工作中 |
|
Searching |
经纬球 + 一道扫描弧 |
在检索信息 |
|
Solving |
魔方格子层旋转 |
在解一个难题 |
|
Listening |
球面波纹起伏 |
在听你说话 |
|
Composing |
大圆上的飘带 |
在组织语言 |
|
Shaping |
圆形 → 三角 → 方形 → 圆形 |
在塑形 / 生成 |
这六态的价值不在「好看」,而在语义可辨——用户扫一眼就知道 AI 在干什么。而且它刻意用了单色灰度语言(黑纸白墨 / 白纸黑墨),不抢文字内容的风头。
六态背后是六套完全不同的数学引擎,不是「调几个参数」能糊弄的。下面逐一拆。
二、整体架构:纯 ArkTS + DisplaySync,无原生依赖
2.1 分层
thinkingorbs/
ThinkingOrbModels.ets 枚举 / Dot / Vec3 / ModeOpts / 主题解析
ThinkingOrbCore.ets hashD / fibDir / angleDelta / Projector / DotBuffer
ThinkingOrbProfiles.ets BASE + 20/64 两套 preset(数值锁定上游 pin)
ThinkingOrbOrbits.ets Working(轨道)
ThinkingOrbLattice.ets Searching/Solving/Listening(经纬球/魔方/波纹)
ThinkingOrbRibbon.ets Composing(飘带)
ThinkingOrbMorph.ets Shaping(形态变换)
ThinkingOrbPainter.ets 生成调度 + z-sort + 灰度绘制
ThinkingOrb.ets 组件生命周期 + DisplaySync
Core / Profiles / 六套引擎全部纯函数可单测,Component 只管生命周期。这是项目里「算法与视图分层」范式最规整的一次。
2.2 公共 API
ThinkingOrb({
state: ThinkingOrbState.Working, // 六态之一
orbSize: ThinkingOrbSize.Avatar, // Avatar(64vp) | Inline(20vp)
theme: ThinkingOrbTheme.Auto, // Auto | Light | Dark
darkSurface: false, // Auto 时宿主注入,不读 AppStorage
speed: 1,
paused: false,
respectReducedMotion: true,
reduceMotion: false, // true → 固定 t=0.6 代表帧
label: ''
})
注意:orbSize 而非 size(ArkUI 保留属性名,和前几篇的 avatarSize/beamRadius 一脉相承)。
三、共享基建:三样「每个引擎都用」的东西
3.1 确定性哈希 hashD:让随机「可复现」
动画里要很多「随机」方向,但真正的 Math.random() 每帧都会变,导致闪动。hashD 把下标映射成稳定的 [0,1):
export function hashD(a: number, b: number): number {
const h: number = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
return h - Math.floor(h); // 取小数部分,∈ [0,1)
}
这是图形学里经典的「伪随机哈希」技巧——同一组 (a,b) 永远得到同一个值,所以每个轨道/每个点的方向是确定但看起来随机的。测试里 hashD(3,1.7) 两次结果相等、∈ [0,1)。
3.2 投影器 ThinkingOrbProjector:零分配的 3D → 2D
六态里四态(orbits/globe/rubik/wave/ribbon)都是「球面上的点投影到屏幕」。投影器把 sin/cos 预先缓存,投影时零分配:
export class ThinkingOrbProjector {
configure(yaw, tilt, cx, cy, scale): void {
this.st = Math.sin(tilt); this.ct = Math.cos(tilt);
this.sy = Math.sin(yaw); this.cyw = Math.cos(yaw);
// ...
}
project(x, y, z, out): void { // out 参数复用,不 new 对象
const x1 = x * this.cyw + z * this.sy;
const z1 = -x * this.sy + z * this.cyw;
const y1 = y * this.ct - z1 * this.st;
const z2 = y * this.st + z1 * this.ct;
out.x = this.cx + x1 * this.scale;
out.y = this.cy - y1 * this.scale;
out.z = z2; // z 保留,用于深度排序和明暗
}
}
关键:投影后的 z(深度)被保留,它同时驱动「z-sort 远近排序」和「灰度明暗」(近处白、远处灰)——这是 3D 感的两大来源。
3.3 fibDir:Fibonacci 球面均匀分布
Ribbon(Composing)要在一片球面上撒 ghost 点,如果直接用 lon/lat 均匀采样,两极会过密、赤道稀疏。Fibonacci 球面(黄金角螺旋)能近似均匀地分布于球面:
export function fibDir(i: number, n: number, out: ThinkingOrbVec3): void {
const golden: number = Math.PI * (3 - Math.sqrt(5)); // 黄金角
const y: number = 1 - (2 * (i + 0.5)) / n; // 纵向线性
const rad: number = Math.sqrt(Math.max(0, 1 - y * y));
const a: number = i * golden;
out.x = rad * Math.cos(a);
out.y = y;
out.z = rad * Math.sin(a);
}
√(1-y²) 这一项是让「每个纬度带的点数」正比于其弧长,从而整体均匀——这比朴素的经纬网格更「球面贴服」。
四、六套引擎:每个状态的数学骨架
4.1 Working(Orbits):倾斜轨道 + 粒子
粒子沿倾斜的随机轨道运动,每条轨道由 hashD 生成三个随机量(半径、方向、速度),每个轨道再铺一圈「幽灵点」当背景:
for (let orb = 0; orb < orbitN; orb++) {
const h1 = hashD(orb, 1.7); // 半径 + 方向
const h2 = hashD(orb, 5.2); // 法向量
const h3 = hashD(orb, 8.9); // 速度
// ... 用 h1/h2 构造轨道平面的正交基 (u, v)
// 幽灵点:整圈均匀铺
for (let k = 0; k < ghostN; k++) { /* 投影 + alloc */ }
// 粒子:沿轨道运动
for (let m = 0; m < particles; m++) {
const a = t * speed + (m / particles) * 2π + h2 * 6;
/* 投影 + alloc,半径随深度变化 */
}
}
一个轨道 = ghostN(40) + particles(3) 个点,共 orbitN(12) 轨道 → 12×43 ≈ 516 dots/帧(64vp),这正是后面要聊对象池的原因。
4.2 Searching(Globe):经纬球 + 扫描弧
球面按「纬度环 × 经度密度」铺点,经度密度随 |cos(lat)| 变化(赤道密、两极疏),再加一道「扫描弧」让弧经过的点变亮变大:
for (let li = 0; li <= latRings; li++) {
const lat = -π/2 + (li / latRings) * π;
const lonCount = Math.max(1, Math.round(Math.abs(Math.cos(lat)) * lonDensity));
for (let lj = 0; lj < lonCount; lj++) {
const lon = (lj / lonCount) * 2π;
// 扫描弧:与扫描中心的角距离 → 高斯 boost
const dLon = angleDelta(lon + t * spin, scan);
const boost = Math.exp(-(dLon * dLon) / 0.18) * Math.max(0, tmp.z);
d.r = (o.rBase + o.rDepth * depth + o.rBoost * boost) * rs;
d.a = dimBase + (1 - dimBase) * Math.min(1, boost);
}
}
angleDelta 用 atan2(sin(a-b), cos(a-b)) 求最短角距离(绕回 ±π),这样扫描弧绕球一圈回来不会瞬间跳变。
4.3 Solving(Rubik):魔方层旋转 + 求解状态机
这个最精巧。它内置了一个 solveCycle 状态机,让魔方的「层」按一个循环:逐层转过去,再逐层转回来,模拟「解魔方」的节奏:
function solveCycle(time, count, slotDur, rest, sc): void {
const cyc = 2 * count * slotDur + rest; // 一个完整周期
const tc = time % cyc;
// 前半段:逐层「拧过去」,每层用 ease 插值
// 后半段:逐层「拧回来」
// sc.active 记为「当前正在转的那一层」
}
每一层由 RubikMove 描述(哪个轴、哪一段、转多少),applyMoves 把球面上的点按「是否落在该层」选择性旋转:
// 只旋转落在 [lo, hi) 区间内的点(模拟一层棱块转动)
const coord = mv.axis === 0 ? x : (mv.axis === 1 ? y : z);
if (coord < mv.lo || coord >= mv.hi) continue; // 不在这一层就跳过
这样视觉上就是「某一层在转动、其他层不动」,非常像真的魔方。
4.4 Listening(Wave):球面波纹
半径不再固定,而是随「纬度 + 时间」做正弦叠加,形成球面起伏:
const w = 0.62 * Math.sin(t * 2.1 - ri * 0.52) + 0.38 * Math.sin(t * 1.27 + ri * 0.83);
const rr = R * (0.88 + 0.105 * w); // 半径随波起伏
// crest 取波峰部分,让峰顶的点更大更亮
const crest = Math.max(0, w);
d.r = (o.rBase + o.rDepth * depth) * (1 + 0.4 * crest) * rs;
两个不同频率、不同相位的正弦叠加,形成「此起彼伏」的呼吸感。
4.5 Composing(Ribbon):大圆飘带 + 摆动
先铺一层 fibDir ghost 当底,再沿一个大圆铺「多车道飘带」,每条车道加了 wob 摆动:
const wob = (0.16 * Math.sin(a * 3 - t * 1.7 + w * 0.22)
+ 0.07 * Math.sin(a * 5 + t * 1.1)) * o.wobMul;
const off = laneOff + wob; // 车道偏移 + 摆动
// 沿大圆参数 a 铺点,加垂直于圆面的偏移 off
spin: 0(preset 锁定值)意味着飘带不自转,只有摆动——这是和上游对齐的刻意选择(测试里 expect(r64.opts.spin).assertEqual(0))。
4.6 Shaping(Morph):圆 → 三角 → 方形,arc-length 均匀
这个状态与众不同——它是 2D 平面的(不投影到球面),做的是「三个形状之间 morph」:
// 用 arc-length 参数 f ∈ [0,1) 在多边形边上采样
function polySample(vertsX, vertsY, edgeL, total, f, out): void {
let target = f * total;
let i = 0;
while (target > edgeL[i] && i < V - 1) { target -= edgeL[i]; i++; }
// 在当前边上线性插值
const ff = edgeL[i] > 0 ? Math.min(1, target / edgeL[i]) : 0;
out[0] = aX + (bX - aX) * ff;
out[1] = aY + (bY - aY) * ff;
}
关键是用弧长(arc-length)参数化,而不是角度参数化——否则正方形角上点会挤在一起,morph 时点分布不均、会有「跳点」。测试里专门验证了「相邻点间距大致均匀」(maxD/minD < 8)和「路径闭合」。
morph 的三个阶段用 HOLD(1.4s) + MORPH(0.9s) 循环:先保持形状 1.4 秒,再用 0.9 秒平滑过渡到下一个形状。
五、20 / 64 不是等比缩放
这是最容易踩的坑,也是 experiment 的 Finding #2 特别强调的:
20/64 不是等比缩放。密度用 √count 或 linear count,半径用独立 size 乘子;误用
orbSize * k会破坏 inline 可读性。
看 presetFor 里对 Orbits 的两套 config:
if (mode === ThinkingOrbMode.Orbits) {
p.speed = is64 ? 1.885 : 3.9; // 小尺寸反而更快
p.count = is64 ? 1 : 0.238; // 密度:64 是满,20 只有 0.238 倍
p.size = is64 ? 1 : 2.4; // 单个点半径:20 反而更大 2.4 倍
return p;
}
三句话拆解:
-
count(密度):64vp 的点数量是 12 轨道 × 43 = 516;20vp 不能等比缩小(516 个点挤在 20vp 里会糊成一团),所以用count = 0.238把轨道/粒子数降到约 1/4。 -
size(单点半径):20vp 太小,如果半径也等比缩小,点会小到看不见,所以反而放大 2.4 倍。 -
speed(速度):小尺寸转太快看不清,所以调成 3.9(更快但配合更少的点)。
密度缩放用的是 √count(面积是平方关系,点数按线性的平方根缩):
const rt = Math.sqrt(scale);
out.latRings = Math.max(2, Math.round(opts.latRings * rt));
out.lonDensity = Math.max(2, Math.round(opts.lonDensity * rt));
半径缩放用独立乘子(rBase/rDepth/rActive 各自乘 scale),两者彻底分开。这就是「不要用单一 scale 因子二合一」的原因——测试名直接叫 scaleCountsDoesNotUnifySizes。
六、DotBuffer 对象池:为什么 516 dots/帧必须复用
这是纯性能问题。64vp 的 Working 态每帧 orbitN × (ghostN + particles) = 12 × (40+3) = 516 个点。如果每帧 new ThinkingOrbDot() 516 次,ArkTS 的 GC 会频繁触发,造成帧率抖动(掉帧)。
DotBuffer 用对象池解决:
export class DotBuffer {
readonly dots: Array<ThinkingOrbDot> = [];
private lengthValue: number = 0;
constructor(capacity: number = 640) {
for (let i = 0; i < Math.max(64, capacity); i++) {
this.dots.push(new ThinkingOrbDot()); // 预先分配
}
}
reset(): void { this.lengthValue = 0; } // 每帧清零「长度」,对象复用
alloc(): ThinkingOrbDot {
if (this.lengthValue >= this.dots.length) {
this.dots.push(new ThinkingOrbDot()); // 超出才扩容
}
const d = this.dots[this.lengthValue];
d.x = 0; d.y = 0; d.z = 0; d.r = 1; d.white = 0.5; d.a = 1; // 复用 + 复位
this.lengthValue++;
return d;
}
}
reset() 只把 lengthValue 归零,不释放对象;alloc() 从池里取、复位字段再返回。这样一帧 516 次 alloc 都是复用,new 只在池不够时发生(容量 640 > 516,有富余)。
配套的 sortByDepth 也用插入排序而非 sort()——因为已经基本有序(上一帧排过),插入排序近 O(n) 且不频繁分配 comparator 闭包:
sortByDepth(): void { // 插入排序,~500 点足够快,且避免反复 new comparator
for (let i = 1; i < n; i++) {
const key = arr[i]; const keyZ = key.z;
let j = i - 1;
while (j >= 0 && arr[j].z > keyZ) { arr[j+1] = arr[j]; j--; }
arr[j+1] = key;
}
}
七、共享绝对时间轴:多实例同相
这是 E022 相对 E016(FlowAvatar)的一个关键设计差异。FlowAvatar 用「per-instance phase 累加」,而 ThinkingOrbs 用共享绝对时间:
private timeSeconds(info?: displaySync.IntervalInfo): number {
let ms: number = Date.now(); // 用墙钟,保证多实例同相
// ... DisplaySync timestamp 单位设备间有差异,故统一回退 Date.now()
return (ms / 1000) * this.effectiveSpeed();
}
为什么用 Date.now() 而不用 DisplaySync 的 timestamp?
DisplaySync 的
IntervalInfo.timestamp单位在不同设备/系统版本间有差异(有的是纳秒、有的是别的),直接用会导致「多实例不同相」甚至「速度错乱」。Date.now()的毫秒墙钟是对齐上游performance.now()契约最稳的选择。
这带来一个好处:页面上放多个状态球(比如一个 Working + 一个 Searching),它们的相位天然一致(比如扫描弧、波纹同步起伏),不会各跑各的。源码注释直接写明:
Shared wall-clock time keeps mounted orbs in phase.(共享墙钟让挂载的多个球保持同相)
配套的 reduceMotion 会返回固定代表帧 t = 0.6(对齐上游),而不是「慢速动画」:
const REDUCED_MOTION_T: number = 0.6;
if (this.respectReducedMotion && this.reduceMotion) {
return REDUCED_MOTION_T;
}
八、灰度语言与主题:inkToGray 的明暗翻转
六态只用灰度(white ∈ [0,1] → 0~255),深浅表达「近亮远灰」的深度感。暗色背景要「镜像」:
export function inkToGray(white: number, dark: boolean): number {
const w = Math.min(1, Math.max(0, white));
return Math.round((dark ? 1 - w : w) * 255); // 暗色下白墨变黑墨
}
主题 Auto 由宿主注入 darkSurface,组件不读 AppStorage(与前几篇一致,避免耦合全局)。测试里锁定了真值表:
thinkingOrbResolveDark(Auto, true) → true
thinkingOrbResolveDark(Auto, false) → false
thinkingOrbResolveDark(Dark, false) → true
thinkingOrbResolveDark(Light, true) → false
九、生命周期:DisplaySync 按需启停(与 GrokBot 同源)
这部分和 GrokBot / BorderBeam / MetalFx 一脉相承,但值得单独拎出来强调「离屏停表」这条铁律:
private reconcileAnimation(): void {
if (this.shouldAnimate() && this.canvasReady && this.visible) {
this.startAnimation();
} else {
this.stopAnimation();
}
}
// onVisibleAreaChange
.onVisibleAreaChange([0.0, 1.0], (isVisible) => {
this.visible = isVisible;
if (isVisible) { this.paintNow(); this.reconcileAnimation(); }
else { this.stopAnimation(); } // 离屏立刻停表
})
实验 Finding #8 明确:离屏应停表——onVisibleAreaChange stop/start;paused / reduceMotion / disappear 均不得遗留 frame 回调。
shouldAnimate() 里的三个闸门:
private shouldAnimate(): boolean {
if (this.paused) return false; // 暂停
if (this.respectReducedMotion && this.reduceMotion) return false; // 减少动态效果
return true;
}
十、工程要点清单
-
复制
thinkingorbs/整目录,别带 Lab/Navigation。 -
state用六态枚举,orbSize只选Inline(20)或Avatar(64)。 -
需要中间尺寸时先独立调优 preset,不要线性插值 20/64。
-
列表/宫格限制同时动画实例数,多实例要测帧率。
-
Auto 主题由宿主注入
darkSurface,组件不碰 AppStorage。 -
暂停用
paused;离屏交给onVisibleAreaChange,不要靠卸载重建。 -
无障碍用
label,默认是'Working…'等英文,业务要传中文。
十一、总结:状态感 > 花哨
Thinking Orbs 是这系列里「语义驱动动效」的典范:六种状态不是六个调色板,而是六套完全不同的几何引擎,用纯灰度 + 明暗深度就做出了「你一眼能分辨 AI 在干嘛」的状态感。
核心结论:
六态是六套数学,不是六个参数。
20/64 不是等比缩放:密度 √count、半径独立乘子、速度各自调。
516 dots/帧必须对象池,否则 GC 抖动吃掉帧率。
共享 wall-clock 让多实例同相,不要用 per-instance phase。
灰度 + 深度(z-sort + 明暗)就够了,不需要渐变色。
它和 GrokBot 一样「纯 ArkTS + Canvas + DisplaySync、零第三方依赖」,但更轻、更适合列表内联(20vp)的场景。如果你的鸿蒙 App 需要一个「AI 思考中」的状态球,直接拷贝 thinkingorbs/ 目录起步,再按六态语义和品牌黑白关系微调即可。
更多推荐




所有评论(0)