Blog
设计约 12 分钟

设计一只水母:形状、像素与光。

AirJelly 的水母不是一张精灵图。伞体按曲线实时计算,像素感来自低分辨率渲染,身上的光则来自一张小光照图。下面把这几部分拆开,看看它们怎么做,又让这个角色多了哪些可能。

Hua · AirJelly 团队 ·

打开水母实验室,你会看到它在办公室里游来游去,身后拖着几串珠子一样的触手,脚下还带着一小片彩光。看起来像精灵图,其实是页面运行时现画的。

伞体、脸、触手、像素颗粒和发光都来自一小组参数。实验室把这些参数直接做成了滑块,所以文中讲到的东西,你都可以当场试。

  • 伞体是一行一行算出来的。五种伞形对应五条宽度曲线;一张脸每只眼睛只用一到三个像素。
  • 整个世界先画在一张只有视口四分之一大小的 canvas 上,再关闭平滑放大回来。
  • 发光来自一张 1/16 分辨率的光照图,里面有平方衰减、分级亮度和一个 4×4 抖动矩阵。

文中的图都直接截自实验室。我们从头到尾用了同一只桃色水母,脸也是作者自己的手绘预设,没有另外画示意图。

一、形象是一个函数

像素角色通常从图像编辑器开始。我们的水母从一个问题开始:对「一只水母」最小的描述是什么,同时还能让每个人做出来的水母都跟我的不一样?

答案是一条宽度曲线。伞体从上到下一行一行地画,钟形、圆顶、团子、灯笼、水滴五种形状之间唯一的差别,就是那个决定「每一行有多宽」的函数。圆顶张得快、肩线柔和;团子中间最宽、两头收紧;灯笼在前六分之一张开、在最后五分之一收口;水滴在大约四分之三处到达最宽然后收窄;普通的钟形就是一条直直的喇叭线。

钟形
圆顶
团子
灯笼
水滴
五种体型,同一组滑块,只有宽度曲线不同。(这里把伞加高了以便看清曲线;作者自己的伞又矮又扁。)

曲线底下还有两条小规则。「圆润度」滑块(1–4)决定伞顶用几行完成张开。每一行还必须是奇数宽。那根正中的像素列比想象中重要:眼睛和嘴可以稳稳落在中间,不会偏掉半格。

剩下的身体就顺理成章了。宽度、顶宽、高度、圆润度和压扁是五根滑块。因为形状是算出来的而不是存出来的,伞体还能形变:拖动它的时候,你抓住的那一行比离光标远的行倾斜得更多。就是这一条规则,让它摸起来像软橡皮而不是硬纸片。

静止
抓着伞顶拖
抓着伞底拖
往右拖,一次抓伞顶、一次抓伞底。伞体倾斜、触手拖尾、水面留下尾流——都是拖动中途截的。

旋转用同样的思路,但方向反过来:不是把像素正向旋转出去(那会留下空洞),而是遍历旋转后的包围盒,把每个屏幕格映射回伞体空间,问一句「它在第 r 行里面吗?」。最近邻采样,不抗锯齿,于是歪着的水母和正着的一样锐利。

脸

眼睛在伞高 54% 的位置,间距是伞宽的 18%。这两个比例就是整套排版系统。表情只是决定哪一到三个像素点亮:一个点、一条横线、一个 ^,或者往上、往里挪了一格的点。好奇是一只眼比另一只高一点。卡住是两只眼往鼻子中间挤。困是两条横线。眩晕——850 毫秒内急转三次触发——是两条更宽的横线加一阵摇晃。

中性
好奇
专注
开心
卡住
得意
困
平静
惊讶
眩晕
九种表情加眩晕脸(关掉手绘脸,露出内置的眼睛)。每一种都只是每只眼睛一到三个像素,外加眼下两行一张可有可无的嘴。

让这张脸成立的不是像素,而是它们往哪儿看。眼睛是全身唯一有自己运动规则的部位:你往哪边扯伞体,眼睛就往反方向甩一格;用键盘游动时,眼睛会比身体先一步看向转弯的方向。嘴只在说话或表情非中性时才出现;说话时按一个快速的正弦一张一合。这一切都不需要画任何一帧动画。

触手

没有口腕,也没有触手精灵图。每根触手是一串方形珠子,用 Verlet 积分模拟,锚在伞体 28% 高度处,每帧跑三轮距离约束把珠子拉回「间距」。让它们有水下感的,是三个不同频率的正弦波叠加——一股慢慢的漂、一股稍快的摆、一股向尾端加权的卷——于是根部安静,尾梢摇曳。光标从中划过,它们会分开、打旋;停下来,它们按自己的节奏归位。

触手滑块(数量 3–12、节数 4–18、重力、阻尼、张力、水流、卷曲、避让)直接暴露,四个一键「运动预设」——安静、活泼、狂野、漂流——不过是这六个数字的四组存档。

颜色

默认自带七套三色调色板——伞体、触手、眼睛——每一项也都能自由取色:

调色板伞体触手眼睛
ocean#8ae2ff#8ae2ff#303030
sunset#ffb38a#ff8fc7#3a2a2a
lime#b6f5a0#8ce6c0#26331f
grape#c7a2ff#a98cff#2c2640
rose#ff9ec4#ffc0dd#3a2630
gold#ffe08a#ffd06a#3a3320
mono#cfd8e3#aab4c2#20242b

伞色不只给伞上色。引擎会把它写进页面的主题色变量(触手色写进辅色),于是整个实验室 UI 会跟着你正在做的那只水母换主题。黑、白、灰和语义红永远不变,其它一切都跟着你走。而且——第三部分会讲——它还会成为你随身带着的那束光的颜色。

如果程序化伞体不够用,可以关掉它,在 6×6 到 28×28 的网格上自己画一张,再在上面画最多八张自定义的脸。我们十三位同事就是这么干的;他们的水母现在作为 NPC 住在办公室场景里,每一只都有自己的身体、触手数、表情和台词。

二、像素感是一种分辨率

网页上做「像素风」最常见的方法是全分辨率渲染再后处理——颜色吸附、叠一层网格、也许再模糊一下重新量化。我们反过来:这个世界真的就那么小。它画在一张只有窗口四分之一大小的 canvas 上——默认比例下一个世界格子等于四个屏幕像素——然后让浏览器不做平滑地放大。

引擎里每一次绘制都是把打包好的 32 位颜色值直接写进那个小 buffer——伞体、触手珠子、地形 tile、连文字也是。世界层里没有任何矢量路径,所以什么都不可能被意外抗锯齿。每个锚点的位置都吸附到格子上:伞顶、眼睛所在行、每块地形 tile、光照网格用的相机偏移。

这给「像素大小」滑块带来一个很好的副作用。因为它改的是一个世界格子占几个屏幕像素(4 × 0.7 到 4 × 1.6),所以一根滑块同时改变了画面颗粒度和水母的视觉大小。不需要单独的缩放。

scale 0.7
scale 1.0
scale 1.6
像素大小滑块拨到 0.7、1.0、1.6。颗粒和角色一起变大——背后的房间文字也跟着变粗。

两张 canvas 夹一层真文字

有一个结构上的决定我们特别喜欢:世界是两张 canvas,中间夹着一层 HTML。房间地板和标题画在下层 canvas。办公室里那些博客式的文案,是压在它上面的普通、可选中、可访问的 HTML。上层 canvas——水波、物品、传送门、水母本身——凡是没画到的格子都保持透明,于是文字从空水域里透出来,而水母从文字上面游过去。

首页 · 水母游过真实的 HTML 文案
首页房间。标题、段落和链接是夹在两张 canvas 之间的普通 HTML;水母和水波像素画在它们上面。没有一个字形被光栅化。

效果是文字像沉在场景里而不是贴在上面。背景 canvas 还带缓存:只有相机移动整格或场景变化时才重画。

抖动,不用渐变

画面需要渐变的地方——主要是光照——我们用有序抖动而不是平滑插值。一个 4×4 Bayer 矩阵,按世界坐标索引,所以滚动时图案不会爬;它先把光切成平的色带,再把接缝打散。「光照色阶」暴露成滑块(2–8,默认 3)。设成 2 时地板像双色木刻;设成 8 时接近平滑。三级是它还看起来像手工做的那个点。

2 级
3 级(默认)
8 级
办公室里光照色阶设为 2、3、8(水母光调大了好看清地板)。二级像木刻,八级接近平滑,三级是默认。

调色——全局色调和饱和度拉动——走一张 32×32×32 的查找表,顺手把调色板悄悄量化成了每通道五位。UI 里所有预览 canvas 都关了平滑;引擎唯一用 CSS 模糊的地方是 UI 转场,从不用在世界上。

三、发光是一张光照场

不发光的水母不算水母。最显然的做法——在场景上叠一圈柔和的径向光晕,或者跑一遍模糊——第一天就被否掉了,因为它们产生的是平滑的亚像素渐变,放在硬像素网格上就是不对。平滑的光晕浮在世界上面,不属于它。

所以我们不画光晕,我们照亮房间。每一盏光——水母、灯、发光的物品——都被盖章到一张小小的光照图上,分辨率是 canvas 的四分之一(屏幕的十六分之一),衰减用 (1 − d/r)²:不物理,但柔和、便宜、好调。然后地板和家具按照收到多少光被提亮,用的还是那三级平的色阶和同样的抖动。光照图保留两份——一份给地板,一份给物体、半径放大 1.6 倍,于是道具坐在一圈比脚下地面稍大的光晕里。

光照合成:关
光照合成:开
同一个位置,关掉和打开光照 pass。打开后地板出现色带和抖动,水母的桃色光洒在周围一切上——水母自己身上没有画光晕。(发光半径和强度调大了,方便在页面上看清。)

着色 pass 把每个像素乘以「环境光 + 光照场」,再钳到一个上限。这个上限——1.25,也就是光最多能把一个颜色推到自身的 125%——就是全部的「泛光」预算。发光核心那一点点过曝的感觉就来自这里,这也是灯笼和镜头眩光的区别。

实现里还有三个容易忽略的细节:

  • 伞的颜色也拿来做灯光颜色。选一只玫瑰色水母,附近的地板会变成玫瑰色,实验室的强调色也跟着换。
  • 颜色会按峰值通道归一化,所以深藏青色的伞仍会投出足够亮的藏青色,而不是几乎照不亮房间。
  • 闪烁每 8 帧更新一次。轻微的卡顿放在像素世界里,比顺滑的呼吸灯更合适。

还有一条规则让灯不会看起来出错:凡是发光的东西都不再被照亮。带光照组件的物品绘制时会打上自发光遮罩,着色 pass 把这些像素原样复制过去——所以一盏灯永远不会被自己的环境光压暗。

旋钮

以上全部都暴露在 场景 → 光照 面板里。这是我们最终发出去的那组数字:

控件范围默认
环境光亮度0.2 – 10.55
环境光颜色—#6d8fae
光照色阶2 – 83
高光上限1 – 1.51.25
水母发光 · 半径8 – 8032
水母发光 · 强度0 – 1.50.6
水母发光 · 闪烁0 – 0.60.05

地板只有在关卡、着色参数或灯光位置变化时才重新着色;视口外的灯在盖章前就被剔除;调色表按参数缓存。实际上整个世界——背景、水面模拟、物品、触手、光照、水母——在笔记本上轻松塞进一帧,毕竟在这个分辨率下,一个 1440px 宽的窗口横向只有 360 个格子。

四、关于它怎么动的几件事

严格说不算外观,但它们决定了这个角色读起来的感觉,所以简单讲讲:

  • 键盘推力会积累也会衰减,上限略低于每帧一格。速度还会影响伞体形变,所以游得快时,伞会被「水流」明显压扁。
  • 滚轮的冲量进入同一个速度量,系数刚好让一格滚轮等于稳态下前进一格。
  • 相机在游动时慢跟,静止时快跟。松开拖拽后的 1.6 秒里又会变得很慢,免得水母突然弹回画面中心。
  • 水平速度决定目标倾角,再由一根阻尼弹簧追上去。停下时伞体会稍稍过冲,看起来像还泡在水里。
  • 光标会推开 40 px 内的触手珠子;到了 58 px 内,还会加上一点切向漩涡和径向脉动,越靠尾端越明显。
  • 每游两格,二维波动模拟里就多一个 soft 脉冲。这些波能触发物品,物品也能把脉冲继续传下去。

五、为什么把角色做成数据

我们一开始没想总结什么「AI 角色理论」,只是想把水母编辑器做好。做到后来才发现:让人方便捏水母的这组参数,Agent 也刚好看得懂。

参数比精灵图更容易被 Agent 使用

一张精灵图对模型来说是不透明的。我们的水母只有三十来个数字和几个枚举,放进 prompt、JSON 或一次工具调用都不费劲。

  • Agent 可以读取参数,知道用户的水母长什么样。
  • 它也能把「给我来一只困困的、紫色的、触手长长的」直接写成预设,不必重新生成图片。每位同事的 NPC 都只是一行数据。
  • 表情、发光和运动本来就是运行时状态,所以 Agent 干活时可以专注,没转过弯时也可以真的晕一下。
  • 参数可以改得很远,最后画出来的仍然像同一种水母。

这对 AirJelly 已经够有用了。水母可以当作 Agent 的身体,因为它的外观和动作都是 Agent 能读取、也能修改的数据。我们不用让模型去猜一张不透明的图片该怎么改。

为什么我们还是喜欢像素(也包括 ASCII)

先说实用的原因。像素精灵是一小格调色板索引,ASCII art 本身就是文字,两者都能用很短的数据描述,也能一格一格地改。我们需要的是精确修改;和一幅画相比,16×16 的格子留给模型自由发挥的空间少得多。

还有一个很主观的原因:我们就是喜欢硬网格。它不掩饰自己是机器画的,错一格往往有点可爱,不太会掉进那种似是而非的诡异感里。对一个「住在电脑里的 AI」来说,这个语气挺合适。终端在 AI 工具里看起来顺眼,可能也是同一个原因。

所以最后做出来的是一只由数字描述、画在格子上的角色。它看起来有点复古,但真正好用的地方,是人和 Agent 都能准确地改它。

最后说一件事。要是你在实验室里调出了一只喜欢的水母,欢迎把参数导出来,发到 xiaohua@airjelly.ai——我们会让它出现在官网上。办公室里已经住了十三只,永远有位置再多一只。