一、前言:为什么一个「状态球」值得写这么细?

先看六种状态,它们是真实 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);
  }
}

angleDeltaatan2(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;
}

三句话拆解:

  1. count(密度):64vp 的点数量是 12 轨道 × 43 = 516;20vp 不能等比缩小(516 个点挤在 20vp 里会糊成一团),所以用 count = 0.238 把轨道/粒子数降到约 1/4。

  2. size(单点半径):20vp 太小,如果半径也等比缩小,点会小到看不见,所以反而放大 2.4 倍

  3. 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/ 目录起步,再按六态语义和品牌黑白关系微调即可。

Logo

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

更多推荐