技术文章

一颗 148px 的球改了九版:说不全的东西,AI 不可能画对

换更强的视觉模型、更精深的技能、手里握着行业顶级的原型图,都不改变一件事 —— 叙述是有损的,模型只能重建我说出口的那一部分。九版之后我拿到的不是一颗更好看的球,是一份第一天并不存在的规格。

Vox 的唤醒球是一个 148px 的透明置顶窗口,一直挂在桌面上,负责表达六个状态,并且承载 shell.run 的确认卡 —— 这条命令要不要在你机器上跑。

两天,九版。渲染代码是编程 agent 写的,方向、否决与验收口径是我的。

九版之后最值得记下来的不是那几条设计规则,是一件更根上的事:被浪费掉的轮次里,绝大多数 不是它画错了,是我说漏了。它每一版都精确地实现了我说出口的那句话,而那句话本身不够。

换更强的模型不解决这件事

先把结论放前面,因为后面每一节都是它的证据。

我脑子里那颗球是一个高维的东西:轮廓、时间上的起伏、材质的成因、各个量之间怎么耦合、 以及每个元素在报告什么状态。我发出去的是几十个词。模型拿到的是这几十个词,它重建出来的 一定是同时满足这几十个词的无穷多个东西里的一个。

它挑中我那一个的概率,跟它有多强关系不大,跟我给了多少条约束关系很大。换一个更强的视觉 模型,是把那无穷多个里的某一个画得更精致 —— 像素变好,靶心没有靠近。技能同理:技能能提 高符合我这句话的那批东西的平均质量,不能替我把那句话说全。

那手上有一张行业顶级的原型图呢?会好,但不解决。原型图给的是结果,不是成因。能产生同一 帧画面的成因有无穷多个,而模型必须挑一个。这一条后面有一次很难看的实测,就在脊线含量那 一节:我把参考素材的 270 帧全部提取成参数、七项逐帧对照、其中一项数字精确对上,渲染出来 还是错的。

最麻烦的是第三层:我不知道我哪个词是含糊的。统一这个词我用了三轮才发现它在我嘴里同时指 两件事,而那三轮里它每次都在同一个误读上做正确的增量。说不全不是懒,是我看不见自己说漏 了哪一块 —— 在脑子里那一块从来不需要说。

所以可执行的部分只剩一个判据:一句要求能不能执行,看我能不能写出决定它通过与否的那句断 言。写不出来就等于没说。要有生命呼吸感 —— 写不出来;稳态呼吸摆动 ≥1.8× —— 写得出来。亮 片要统一,写不出来;六个元素角间距全部 60.00°,写得出来。剩下的部分只能靠否决往回收敛, 而否决必须被留下来。

前三代白改了:构成比材质先被读到

第一代是粉彩流体玻璃的宠物球,带眼睛、腮红、会眨眼。第二代换成驻波核,一条波。第三代换 成光学核心 —— 一对精密相机镜头,理由是这颗球要承载确认卡,可爱的人格与那份职责冲突。

三代换的是材质与颜色,轮廓一直没动:两个圆孔加下缘一条细节带。而 148px 下最先被读到的正 是构成。七格基线渲染出来全部读作面孔,浅色桌面上是明确的卡通脸 —— 两颗眼珠加一条嘴。

回头看这三代白改的原因很干净:我说的是换个气质,而气质在我脑子里是连着轮廓的,在那句话 里没有。它没有理解错,它把我给出的唯一那一维 —— 材质 —— 换到了极致。

中间有一次自以为解决了。下缘那条刻度带原本沿球体弧度走,弧线加上方两片镜头就是一张笑脸 嘴,于是把它改成水平直线,还写下一条硬要求:基线必须水平。换掉的只是笑脸,得到的是一张 木然的脸 —— 只要上方有一对孔,下方任何一条横向细节都会读作嘴。

第四代才动对地方:删掉第二片镜头,删掉整条下缘带,规则改成球内只有一个几何中心,任何元 素不得成对出现。两个等大等高的圆就是一张脸,这是空想性错视,不是风格问题,换材质换不掉。

同一代里又犯了一个同类错误:删掉面孔的同时,给球加了 12 条黄铜刻线当读数环。渲染出来读 作老怀表的表盘、音响的旋钮 —— 而刻度是测量与调节的符号,这颗球既不测量也不能调节。它给 出的是错误的交互暗示,这是可用性缺陷,不是审美偏好。病根记在决策表里:不能用某种配色, 不等于必须继续用上一代的材质。

我要生命呼吸感,是一句不能执行的话

这是说不全的最纯形态:我给出的是一个感觉,而感觉没有单位。

我说过两次同一句话,离生命呼吸感太远。第一次它调了参数,第二次它把硬边换成柔边。两次都 不对,因为当时够不够活只能靠肉眼吵 —— 而肉眼吵架的结局一定是调参数。

改法是把它换成两个可复算的数:跑满一个呼吸周期,逐帧读 canvas 像素,量亮度总和的摆动倍 数,和亮度加权等效半径的摆动幅度。

第一次测出来的答案很直接:呼吸幅度只有 ±2–7%,整体摆动 1.08× / ±1.5%。这是生命波动不够 的量化答案,不是感觉问题 —— 它只有一个活物该有的一半。现在的读数是 idle 2.60× / ±9.1%、 speaking 3.24× / ±10.6%。

这两个数真正的价值不在数字本身,在于生命感从此可回归测试:掉回 1.1× 量级就是退回一张贴 图,而那正是我两次判断都指出过的东西。

呼吸这件事上还有一次纠错值得单独记。第四代做过一轮大幅减法,删了十项:眨眼、点击飞散粒 子、常驻旋转、148px 下根本看不见却每帧在算的彩虹镀膜环……判据是项目宪法第四条:动画必须 携带信息,报告不了状态、数据或进度的删掉。其中常驻呼吸缩放那一项删错了。呼吸携带的信息 是它活着、它在待命,对一个语音助手来说 presence 本身就是要传达的状态。删掉之后球读作一张 贴图。

为了防止这条被反向滥用成什么动效都能留,裁决表里给它配了判据:呼吸必须长在 AI 本体上, 不能长在背景或装饰层上;幅度与快慢必须按态不同,且是可断言的纯函数 —— 待机 0.85 rad/s 像睡着的人、思考 2.30 像在忙、异常 3.10 像喘、闸门 0 完全不呼吸。一套参数通吃六态,它就 退回装饰了。

手里有顶级素材也不行:标量对上了不等于结构对上了

这一节回答开头那个问题 —— 手上有一份现成的、做得极好的参考动效,是不是就不用叙述了。

我把那段素材的 270 帧全部提取成参数,逐帧对照 —— 运动学量照抄,配色也按逐帧取样重配。 两边用同一套代码读回来,比七项:能量、等效半径、核亮度比、横向亮度梯度占比、加权 RGB 质 心、脊线含量、角向主谐波。

这已经是叙述能站到的相当靠前的位置了:七个带小数点的数,逐帧可比。

其中有一项叫脊线含量,素材 0.076,我的实现 0.022。为了补上,我让它沿花瓣迎光那条长边描 一道亮线 —— 脊线含量立刻到 0.095,数字对上了。

并排渲染出来是一圈白色线框,每片花瓣都被描了边,读作矢量花瓣。素材的脊线来自弯曲面自己 的明暗渐变,不是轮廓线。

原型图和参考素材给的是结果,不是成因。0.095 这个数有无穷多种画法,描边是其中最省的一种, 模型没有理由不选它。我当时也没有说过这不能是一条线 —— 因为在我脑子里它从来就不是一条线, 不需要说。不需要说的那一部分,正是叙述里丢掉的那一部分。

亮线删掉,脊线含量退回 0.026,作为已知落差留着。新规则写进文档并挂在改动触发条件那一节: 新增任何贴合度标量,必须同时给并排渲染。数是叙述,并排渲染是把成因也塞进通道里的唯一便 宜办法。

同类的第二例更反直觉:角向主谐波的命中率从 46% 掉到 26%,而视觉明显变好。原因是片体排布 从等角改成不规则之后,这个 argmax 本身就不稳定了 —— 而等角那一版并排渲染读作风车。命中率 下降与视觉变好同时发生,说明它已经不是一个有效判据,不是保真度退步。

看起来像取舍的东西,先问它是不是键错了量

这条出现了三次,是九版里最贵的一条。三次都不是审美问题,是它把我的话正确执行到了一个错误 的量上。

第一次。上一代的文档里写着一句读起来很专业的话:静态贴合与动态起伏二选一,体积光和玻璃盘 把等效半径钉死了。根因不是取舍 —— 那两项画的是固定大小的盘。改成可见半径随聚合度长之后, 两边同时变好:等效半径的逐帧平均绝对差 0.085 → 0.038,listening 的半径摆动 ±4.0% → ±8.1%。

第二次。高聚合时整颗球烧成一团白,于是给单片透明度加了一道随聚合度反向的补偿。结果能量摆 动从 4.5× 崩到 1.33×,球又退回一张贴图。补偿咬的是这一帧有多聚合 —— 而呼吸让聚合度涨的时 候,透明度正好同步跌,一涨一跌相消。改成咬这一态有多聚合,也就是不含呼吸的目标值,静态过 曝照旧被压住,呼吸完全不受影响。

第三次。listening 的聚合度基准 0.902 加呼吸幅度 0.165 等于 1.067,上界被 clamp01 削平, 一轮里约 23% 的时间卡在 1.0 不动。把基准压到 1−amp 之后,逐帧对照的能量形状从 0.211 直 接回到 0.151。

三次的共同点是:把缺陷写成取舍,会让它永远不被修。上一代的文档正是这么写的,而它读起来完 全像一份成熟的工程判断。这件事比听起来更严重 —— 下一个 agent 读到的是我的文档,不是我的脑 子。文档里那句话写歪了,它就会非常靠得住地把歪的东西实现好。

顺着这条还查出一件更难看的事:动效包络那道门本身是靠错误度量才通过的。采样例程在切态之后 立刻开始读数,把 400ms 的状态过渡算进了呼吸摆动,所以记下来的 4.23× 虚高一倍以上。加 140 帧预热之后稳态只有 1.80×。判据必须跟着改,不能留一条靠错误度量才通过的门。现在它分两层: 每个呼吸态稳态 ≥1.8×,保真度看逐帧对照的能量形状,不看单态摆动。

同一处被否三次,就该回去查自己的词

思考态的形态我否了三次。这三轮是整篇文章的核心证据,因为它三次都没有做错。

我第一句话是这么说的 —— 为什么思考界面它那些亮片不能化为统一的形态在球内进行旋转。

它把六片收拢到同一朝向、叠成一叠,不对。它加上收拢的过程,把一帧之内的跳变改成 700ms 的插 值,过程对了,终态还是不对。它改成各片缩小、合成一个亮团沿轨道公转,仍然不对。

第三轮我把话说破 —— 通过形态转化变成独立互不重叠的独立元素进行环绕。

统一这个词是 uniform,各片长得一样;不是 merged,并成一个。我用了三轮才发现问题在我这个词 上,而这三轮里它每次都在同一个误读上做正确的增量:收拢得更平滑、合得更漂亮,每一版都比上 一版更好地实现了我说出口的那句话。换个更强的模型,只会让它合得更好看。

更难受的是答案一直摆在并排渲染的第二格里:素材的散开相就是一圈分离的亮片,等效半径 0.680、 全轮最大值、中心全暗 —— 而我们三轮都在往球心收。素材就摆在那儿,我自己也没读出来。

教训写成一条规则挂进决策表:同一处被否三次,就不是实现不到位,是需求读错了。第三轮该做的 不是再调一次参数,是回去问统一这个词到底指什么。

方向改对之后还剩一个纯粹的量的问题,同样值得记。我把保留多少片间差异写成 1 − 0.94·ring, 稳态还剩 6%;慢摆写成 ×(1 − 0.92·ring),还剩 8%。这两个残量分别扰动各片的大小、朝向、形 状与呼吸相位,所以六片的角距本来就不可能相等,最近的一对必然先贴上。两处改成精确归零之后 实测:六个元素的中心半径全部 0.560、角间距全部 60.00°。一条可断言的几何性质,不能留一个看 起来无害的残量 —— 那点残量会把它变成不可断言的。

叙述有损,而且不持久

九版里有三版是同一天换的,而那一天我一条决策记录都没留。第二天的后果很具体:仓库里两处文 档还在描述两代前的驻波核,连六态弧长 115.61 这种数都还写着,而它引用的那个采样函数在当时的 渲染器里早已不存在。

于是把审美从对话里搬进文件:宪法(原则与冲突时的优先级链)、视觉词汇(每个产品的物理参照物 与硬约束)、状态语言(六态的契约边界)、反模式裁决表、决策记录、状态读数。规则放在工作区根 目录,由一份刻意保持很短的路由文件按改动等级决定读哪几份 —— 全文导入等于每次对话都付一遍 这份文档的钱,与按需加载正好相反。

决策记录的格式是固定的五栏:决定 / 理由 / 备选与被否原因 / 预期效果 / 来源。

第三栏是它真正干活的地方,也是整套东西里唯一在正面对抗叙述有损的部分。既然收敛只能靠否决, 否决就必须能被攒起来。没有它,下一个 agent 会重新提一遍已经被否过的方案,而且理由听起来同 样有道理 —— 加个渐变光晕试试,在没有记录的世界里永远是一个新想法。

第五栏管另一件事:来源必须标明是用户明说、代码注释推断、本次实测,还是文档记载,而且推断不 得写成明说。这一栏挡掉的是把自己上一轮的猜测当成我的要求,然后在它上面继续盖三层 —— 那正 是有损叙述放着不管就会自己长出来的东西。

顺带说一句我现在还不能声称的部分:上面所有读数的证据等级都是 SIM —— 对照页渲染、逐帧像素 度量、检测器零命中。这颗球还没有在真机的透明置顶窗口上验过,而柔边依赖的 ctx.filter = blur() 在老 WebView2 上不支持时是被静默忽略的。那一项排在待验清单第一位。

九版不是它的失败次数

还有一头我一开始也没想明白:我脑子里那个东西,在第一天并不存在。

它不是一张完成的图,是一组约束加一团感觉。独立互不重叠的元素环绕这句话,是在看到它合成一 个亮团之后我才有的;呼吸必须长在本体上这条,是在我自己删错一次之后才有的;球内不得有成对 元素这条,得先有七格基线渲染出一张卡通脸。我是靠看自己否掉了什么,才知道自己要什么。

所以按我脑子里的样子生成这个要求,两头都不成立:通道有损,源头也没定。九版不是它画不对的 次数,是那份规格被写出来的过程。规格现在存在了 —— 存在于裁决表、那两个呼吸读数和 60.00° 里,不在我脑子里。

收成一句

技能、视觉模型、原型图,优化的都是通道的另一端 —— 而丢失发生在我这一端,再强的重建也补不 回我根本没发出去的那部分。我这一端只有三件事能做:把好看换成两个能回答的问题,这个元素在 报告什么、这个数是多少;给每个新增的数配一张并排渲染;把否决攒下来。

最后一条最短也最省钱:同一处被否三次的时候别再调参数了,回去查自己的词。