← 返回 8秒笔记 8秒笔记

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