打开水母实验室,你会看到它在办公室里游来游去,身后拖着几串珠子一样的触手,脚下还带着一小片彩光。看起来像精灵图,其实是页面运行时现画的。
伞体、脸、触手、像素颗粒和发光都来自一小组参数。实验室把这些参数直接做成了滑块,所以文中讲到的东西,你都可以当场试。
- 伞体是一行一行算出来的。五种伞形对应五条宽度曲线;一张脸每只眼睛只用一到三个像素。
- 整个世界先画在一张只有视口四分之一大小的 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),所以一根滑块同时改变了画面颗粒度和水母的视觉大小。不需要单独的缩放。
两张 canvas 夹一层真文字
有一个结构上的决定我们特别喜欢:世界是两张 canvas,中间夹着一层 HTML。房间地板和标题画在下层 canvas。办公室里那些博客式的文案,是压在它上面的普通、可选中、可访问的 HTML。上层 canvas——水波、物品、传送门、水母本身——凡是没画到的格子都保持透明,于是文字从空水域里透出来,而水母从文字上面游过去。
效果是文字像沉在场景里而不是贴在上面。背景 canvas 还带缓存:只有相机移动整格或场景变化时才重画。
抖动,不用渐变
画面需要渐变的地方——主要是光照——我们用有序抖动而不是平滑插值。一个 4×4 Bayer 矩阵,按世界坐标索引,所以滚动时图案不会爬;它先把光切成平的色带,再把接缝打散。「光照色阶」暴露成滑块(2–8,默认 3)。设成 2 时地板像双色木刻;设成 8 时接近平滑。三级是它还看起来像手工做的那个点。
调色——全局色调和饱和度拉动——走一张 32×32×32 的查找表,顺手把调色板悄悄量化成了每通道五位。UI 里所有预览 canvas 都关了平滑;引擎唯一用 CSS 模糊的地方是 UI 转场,从不用在世界上。
三、发光是一张光照场
不发光的水母不算水母。最显然的做法——在场景上叠一圈柔和的径向光晕,或者跑一遍模糊——第一天就被否掉了,因为它们产生的是平滑的亚像素渐变,放在硬像素网格上就是不对。平滑的光晕浮在世界上面,不属于它。
所以我们不画光晕,我们照亮房间。每一盏光——水母、灯、发光的物品——都被盖章到一张小小的光照图上,分辨率是 canvas 的四分之一(屏幕的十六分之一),衰减用 (1 − d/r)²:不物理,但柔和、便宜、好调。然后地板和家具按照收到多少光被提亮,用的还是那三级平的色阶和同样的抖动。光照图保留两份——一份给地板,一份给物体、半径放大 1.6 倍,于是道具坐在一圈比脚下地面稍大的光晕里。
着色 pass 把每个像素乘以「环境光 + 光照场」,再钳到一个上限。这个上限——1.25,也就是光最多能把一个颜色推到自身的 125%——就是全部的「泛光」预算。发光核心那一点点过曝的感觉就来自这里,这也是灯笼和镜头眩光的区别。
实现里还有三个容易忽略的细节:
- 伞的颜色也拿来做灯光颜色。选一只玫瑰色水母,附近的地板会变成玫瑰色,实验室的强调色也跟着换。
- 颜色会按峰值通道归一化,所以深藏青色的伞仍会投出足够亮的藏青色,而不是几乎照不亮房间。
- 闪烁每 8 帧更新一次。轻微的卡顿放在像素世界里,比顺滑的呼吸灯更合适。
还有一条规则让灯不会看起来出错:凡是发光的东西都不再被照亮。带光照组件的物品绘制时会打上自发光遮罩,着色 pass 把这些像素原样复制过去——所以一盏灯永远不会被自己的环境光压暗。
旋钮
以上全部都暴露在 场景 → 光照 面板里。这是我们最终发出去的那组数字:
| 控件 | 范围 | 默认 |
|---|---|---|
| 环境光亮度 | 0.2 – 1 | 0.55 |
| 环境光颜色 | — | #6d8fae |
| 光照色阶 | 2 – 8 | 3 |
| 高光上限 | 1 – 1.5 | 1.25 |
| 水母发光 · 半径 | 8 – 80 | 32 |
| 水母发光 · 强度 | 0 – 1.5 | 0.6 |
| 水母发光 · 闪烁 | 0 – 0.6 | 0.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——我们会让它出现在官网上。办公室里已经住了十三只,永远有位置再多一只。


























