NOTES · 期刊
拒绝常规建模!看看高级玩家是怎么用算法写出 3D 角色的
发布于 2026/08/27 11:19 · 更新于 2026/08/27 11:19
拒绝常规建模!看看高级玩家是怎么用算法写出 3D 角色的
昨天晚上看到一个很抽象的 Three.js 项目,让我迫不及待就想分享出来。
说它抽象,是因为它完全没有常规的 3D 建模套路。项目先在奶油色纸面上生成一批歪歪扭扭的铅笔角色,接着又把同一套创作思路,直接做成了 3D 的体素、光泽玩具和塑料植物。无论什么材质,这些角色都完全不讲究标准比例:脑袋可以大得离谱,眼睛只有两块黑色方格,嘴巴一条短线,头顶还会长出犄角、耳朵,或者一团根本说不清形状的东西。
偏偏就是这些不规整的组合,让角色有了自己的性格。
最让人意外的是体素部分。Three.js 当然可以加载精致模型,但它也能把一袋整数坐标变成这些抽象、古怪又可爱的立体角色。没有现成模型,没有贴图资源,脸上的每个方块都是代码写出来的,只能说太牛了🤔。
这个项目里有什么
首页我们把内容分成五组:游戏、2D 手绘、体素 3D、光泽 3D 和物件 3D。
最前面是两款游戏。
class photo 会给出十个随机角色,玩家从中挑选五个拍照。相同物种、服装图案或眼睛能够组成类似扑克牌的牌型,墙上的物品会继续改变分数与倍率。画面看着像一张角色选择页,实际考验的是组合与取舍。
the dark floor 的操作只有点击地面,队伍会一起向目标移动。角色只能攻击自己看得见的怪物,光是唯一的视野来源。灯和武器占用同一个手持位置,照明与攻击力无法同时拉满。规则不多,但每次换装备都会直接改变队伍在黑暗里的行动方式。
接下来是原始的 2D 手绘生成器。
editor 展示单个角色及其全部参数,还能直接查看 JSON 配方。crowd 一次摆出 35 个会眨眼、张望的角色,用来观察随机结果是否足够丰富。toy shop 把剑、球棒、魔杖、盾牌、帽子和灯等物品按不同品质铺成接触表。how it works 则用十一个实时步骤,从一条铅笔线讲到一只会呼吸、会眨眼的角色。
下面是对应上面四个描述对应的图片。最后一张如何工作是最有学习价值的地方。可以找到对应html源码自行学习。
角色编辑器:
多个角色:
商城:
如何做出来的:
体素区有两个页面。
voxel lab 展示单个角色,可以拖动视角绕到侧面和背后。voxel crowd 会让 20 个体素角色在深色平台上逐块出现,周围的灯光不断转动。方块很少,形状也很概括,但高低错落的轮廓、配色和表情让每个角色都能被迅速认出来。
角色编辑器:
多个体素角色:
光泽 3D 区换了另一种质感。gloss lab 生成有实体曲面的 Q 版角色,可以调整脸、颜色和表面材质。gloss sheet 把 35 张脸摆在墙上,让它们眨眼、转动视线。the photo 再把角色、植物和漂浮物组织成一张完整合照,通过灯光、阴影和环境光遮蔽做出玩具陈列架的感觉。
角色编辑器:
多个角色:
最后的 objects lab 负责生成草、盆栽、树和花。这个看起来就有点平平无奇了。我们直接一笔带过。
这些页面连在一起,能看出项目真正想做的事。它不只生成角色,还为角色准备了编辑、群体检查、物品、动画、场景和玩法。
所有变化都从一个JSON文件开始
角色的核心状态不是 Mesh,也不是导出的图片,而是一份普通 JSON:
{
seed,
species: 'human',
base: 'biped',
palette: 'graphite',
parts: {
eyes: { params: { /* 眼睛参数 */ }, rr: 0 },
skull: { params: { /* 头部参数 */ }, rr: 0 },
body: { params: { /* 身体参数 */ }, rr: 0 }
}
}seed 决定角色,每个部件再拥有自己的随机数流:
constpartRng = (recipe, id) =>
makeRng(hashStr(
`${recipe.seed}:v:${id}:${recipe.parts[id]?.rr || 0}`
));字符串中包含角色种子、部件名称和该部件的重投次数。只重投眼睛时,变化只会发生在眼睛上,头部、身体和配色仍保持原样。
这套方式还有一个实际好处:配方可以保存和传递。同一份 JSON 在另一台设备上仍能重建出同一个角色,不需要把模型和纹理一起打包。
体素角色不是模型文件,而是一张整数网格
体素生成器的坐标全部是整数。每个方块用 (x, y, z) 表示,再被压缩成一个整数键:
const KOFF = 128;
constkey = (x, y, z) =>
(((x + KOFF) & 255) << 16) |
(((y + KOFF) & 255) << 8) |
((z + KOFF) & 255);角色部件不会直接操作 Three.js。它们只使用 Carve 提供的几个简单动作:放置方块、给已有方块上色、堆叠圆盘、生成团块、画一条有粗细的立体线,以及左右镜像。
set(x, y, z, color) {
this.cells.set(key(this.m * x, y, z), color);
}
dab(x, y, z, color) {
if (this.taken(x, y, z)) this.set(x, y, z, color);
}
sym(fn) {
const m = this.m;
this.m = m; fn(1);
this.m = -m; fn(-1);
this.m = m;
}这里最关键的是 set 和 dab 的区别。
set 会增加一个实体方块,适合构造脑袋、身体、耳朵和四肢。dab 只给已经存在的方块重新上色,适合画眼睛、腮红、斑点和衣服图案。
这个限制很聪明。眼睛必须附着在头部已有的表面上,无论头部生成得多圆、多方或多扁,它都不会悬在半空。许多程序生成模型最容易出错的地方,就是各部件各算各的位置,参数稍微越界,五官便和身体分家。这里通过一个简单的 API 规则把问题提前消掉了。
一个指数,控制角色是圆润还是方正
体素角色的头部和身体主要由超椭圆与超椭球生成。
disc(y, cx, cz, rx, rz, color, n = 2) {
for (let x = Math.ceil(cx - rx); x <= Math.floor(cx + rx); x++) {
for (let z = Math.ceil(cz - rz); z <= Math.floor(cz + rz); z++) {
const u = Math.abs((x - cx) / rx);
const v = Math.abs((z - cz) / rz);
if (Math.pow(u ** n + v ** n, 2 / n) <= 1) {
this.set(x, y, z, color);
}
}
}
}参数 n 决定形状气质。接近 2 时更像球,增大到 4 会变成圆角方块,继续增大则越来越接近削掉边角的盒子。把不同尺寸的水平切片沿 Y 轴堆叠,就得到三维超椭球。
这也是这些角色抽象却不僵硬的原因。它们并非把标准立方体简单堆高,而是在整数网格上使用连续形状的数学轮廓。方块的硬边还在,整体剪影却有圆、扁、宽、窄和轻微夸张。
少量体素负责结构,超大的眼睛负责情绪,轮廓再给出物种差异。信息压缩得很厉害,角色反而更容易被记住。
方块怎样变成 Three.js 几何体
所有部件写完方块后,Rig 会先构造一张完整占用表 occ。后写入的部件拥有最终方块,头部、身体、眼睛和装饰由此成为一个整体。
第二遍才开始生成 Mesh。程序遍历每个方块的六个方向,如果相邻位置已经被占用,这个面位于模型内部,直接跳过:
for (const [cellKey, color] of cells) {
const [x, y, z] = unkey(cellKey);
for (const face ofFACES) {
const [nx, ny, nz] = face.n;
if (solid(x + nx, y + ny, z + nz)) continue;
// 只为看得见的表面写入顶点、颜色和法线
positions.push(/* ... */);
colors.push(/* ... */);
normals.push(nx, ny, nz);
}
}
const geometry = newTHREE.BufferGeometry();
geometry.setAttribute(
'position',
newTHREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute(
'color',
newTHREE.Float32BufferAttribute(colors, 3)
);
geometry.setAttribute(
'normal',
newTHREE.Float32BufferAttribute(normals, 3)
);内部面剔除之后,脑袋和身体不再像两个相互穿插的盒子,也不会浪费三角形去绘制永远看不见的表面。
顶点颜色里还烘入了两个细节。
第一个是固定方向的明暗,不同朝向的表面拥有不同亮度。第二个是体素常用的角落环境遮蔽:一个顶点周围的邻居越多,它的颜色越暗。这个变化非常细,却能把块与块之间的关系压出来,让角色看起来像一个完整实体,而不是一堆颜色相同的积木。
需要真实动态灯光的场景会改用 MeshLambertMaterial,保留顶点颜色和角落遮蔽,把表面受光交回 Three.js:
const material = lit
? newTHREE.MeshLambertMaterial({ vertexColors: true })
: newTHREE.MeshBasicMaterial({ vertexColors: true });这段切换很朴素,也很实用。单角色预览使用烘焙明暗,画面稳定且成本低;体素群像需要旋转的真实灯光,就让材质参与现场计算。
抽象体素也能眨眼和看向四周
眼睛是体素角色最重要的动画部件。
它支持睁眼、闭眼、向左、向右、向上、向下,以及生气、难过、开心和害怕等状态。每种状态必须占用相同的体素位置,只改变颜色。
states: [
'open', 'closed',
'left', 'right', 'up', 'down',
'angry', 'sad', 'happy', 'scared'
]闭眼时,原本的眼睛区域被肤色覆盖,中间只留下一条深色横线。看向左右时,瞳孔色块在眼白范围内平移。因为每个状态使用相同的几何占位,动画不需要重新计算模型,只要切换当前 Mesh 的 visible:
setState(state) {
ensure(state).visible = true;
meshes[current].visible = false;
current = state;
}方块没有变多,角色的情绪却已经出来了。
这种效果很能说明抽象造型的优势。写实角色需要复杂的眼球、眼睑和骨骼才能完成一次自然眨眼;体素角色只用几格颜色变化,就足以让人读出它正在看哪里、心情如何。观众会主动补完没有建出来的细节。
Three.js 强大的地方,在这个项目展现的淋漓尽致
只要你算法够牛,KINDERGRIMM 这个项目同时使用了几条差异很大的渲染路线。
手绘角色由 Canvas 实时绘制,再通过 CanvasTexture 贴到平面上。体素角色从整数网格生成 BufferGeometry。光泽角色使用真正的曲面、挤压轮廓和材质。合照场景继续加入阴影、环境光遮蔽和后期处理。
这些内容都可以进入同一套 Scene、Group、Mesh、Camera 和 Material 系统。Canvas 可以成为角色表面,Group 可以充当骨骼,整数坐标可以现场生成几何,Shader 可以负责黑暗与光照,渲染结果还能继续进入后期管线。
Three.js 的强大并不只体现在写实 3D 上。它更像一套开放的图形积木,允许开发者自己定义“什么是模型”“什么是动画”“什么是材质”。
所以它才能做出 KINDERGRIMM 里这些角色。
它们不精细,也不标准。头部可能只是一个圆角方块,眼睛只有几格,身体比例带着明显的随意感。但它们站在一起时,反而比许多结构正确的模型更有辨识度。转动视角能看到完整实体,灯光扫过去有真实明暗,眨眼时又保留了涂鸦一样的直接。
原来 Three.js 还可以做出这么抽象、这么古怪,又这么可爱的角色。
项目地址:https://github.com/albertobeiz/kindergrimm
预览地址:https://kindergrimm.vercel.app/
原文地址:https://mp.weixin.qq.com/s/7Dg2OesIurn6YMKB5A5sMA