React + Three.js 造了一个"乙烯基娃娃"3D 角色编辑器:配方驱动、程序化生成、还能跳舞

我用 React + Three.js 造了一个"乙烯基娃娃"3D 角色编辑器:配方驱动、程序化生成、还能跳舞

没有一张建模贴图,没有一个外部模型文件。整个角色------大头、画上去的眼睛、一体成型的头发、浮空的手套和鞋------全部由一段 JSON"配方"在运行时算出来。本文完整拆解这个编辑器的设计:从超椭球头部、面部贴片系统、雕刻发块,到 Rayman 式解剖和摄影棚级打光。

技术栈:React 19 + @react-three/fiber + three.js + zustand + Vite + TypeScript


一、先说结论:可爱是有公式的

做这个项目之前,我反复问一个问题:为什么有些程序化角色一看就"丑",而有些一眼就"萌"?把参考设计(Ferriz 风格的乙烯基娃娃)拆透之后,我得到的答案是几条硬规则:

  1. 头是主角。头占总高约 3/4,身体只是头长出来的底座。
  2. 脸要填满。小五官缩在大脸中央是"丑"的第一来源,眼睛要大到刚好被头容纳。
  3. 眼睛是"画上去"的。墨线描边 + 白底 + 大瞳孔三层贴片,而不是两个球。
  4. 没有手臂和腿。手是浮在身体两侧的手套,鞋浮在身体下方------间隙本身就是角色感。
  5. 光泽来自环境。乙烯基质感不在几何里,而在 clearcoat 反射的大柔光箱里。

下面逐条展开,全部对应真实代码。


二、配方驱动:一个 seed 决定一个角色

整个系统的输入是一个 CharacterRecipe

ts 复制代码
export interface CharacterRecipe {
  name: string;
  seed: number;          // 同一种子必然生成同一角色
  skin: string; blush: string;
  age: 'child' | 'teen' | 'adult';
  expression: 'default' | 'happy' | 'surprised' | 'angry' | 'sad';
  head:  { size; width; height; depth };
  eyes:  { style; size; spacing; color; pupilY; lid; wink };
  mouth: { style; size; teeth; tongue };
  hair:  { style; color; volume };
  body:  { height; width; limb };
  outfit: { top; bottom; shoes; topStyle; bottomStyle; sleeves };
  accessory: { earrings; hat; hatColor };
}

随机生成用 mulberry32(seed) 做确定性随机源,权重表就是美术方向

ts 复制代码
const EYE_DEAL: [EyeStyleId, number][] = [
  ['pupil', 20], ['bead', 14], ['oval', 9], ['googly', 8], ['slab', 8],
  ['round', 5], ['sleepy', 6], ['happy', 6], ['orb', 5],
  ['sparkle', 3], ['diamond', 2], ['star', 1],
];

白底瞳孔系扛线,奇异眼型只是点缀。腮红也不是随便挑一个粉色,而是把肤色向暖粉提亮,深肤浅肤都不淤青:

ts 复制代码
const blush = tint(mix(skin, '#E8705A', 0.45), 0.22);

配方驱动的好处:角色库 48 个预设只需要存 48 个种子,缩略图按需烘焙,存档只存一段 JSON。


三、Layout:所有部件的"单一事实来源"

程序化角色最容易死在"各部件各算各的":眼睛算自己的位置,嘴巴算自己的,头发再算一套,最后互相穿模。解法是把所有测量集中到一个 computeLayout(),部件只问它要答案。

头部是一个超椭球(superellipsoid):

ts 复制代码
const r  = 0.45 * recipe.head.size;
const rx = r * Math.max(0.8, recipe.head.width);   // 宽
const ry = r * Math.min(tall, wide);               // 永远不高于宽:扁头才萌

面部部件通过面部坐标系 落位:at(ax, ay) 把 (−1...1) 的脸坐标映射到超椭球表面点 + 法线,部件永远不知道头是怎么造的:

ts 复制代码
const at: FaceAt = (ax, ay) => {
  const u = ax * SPREAD, v = ay * SPREAD, cv = Math.cos(v);
  const d = [Math.sin(u) * cv, Math.sin(v), Math.cos(u) * cv];
  const t = surfT(d[0], d[1], d[2], rx, ry, rz, exp); // 推到隐式表面
  return { p: [d[0]*t, d[1]*t, d[2]*t], n: surfN(...) }; // 法线是梯度,不是中心方向
};

注意法线用的是隐式曲面的梯度而不是"从球心指出去的方向"------否则在压扁的头上所有五官都会歪。

眼睛的"容下钳制"

眼睛可以任意大,但必须被头容纳。每种眼型发布自己的 reach/span(伸多远、跨多宽),layout 把尺寸钳到四个约束的最小值:

ts 复制代码
eyeSize = Math.min(
  eyeSize,
  Math.min(roomUp, roomDn) / reachY,        // 不越过头顶与下巴
  (hwAt(y) * 0.95 - cxOff) / spanX,         // 不越过脸颊
  cxOff / (spanX * 1.1),                    // 不碰上另一只眼
);

嘴巴同理:从眼睛下方向上"顶"出来,用二分法在真实曲面上解出 mouthY,放不下就缩 mouthFit大眼和大嘴可以共存,因为它们永远不打架。


四、脸是"画上去"的:三层贴片 + 表情即偏移

眼睛:墨线 + 白底 + 大瞳孔

主打眼型 pupil 不是球,是三块挤出贴片叠在头表面:

ts 复制代码
pupil: { outline: 'ellipse', wf: .92, hf: 1.14, sclera: true, pupilF: .62, border: .3 }
  • border:墨线描边(比白底大一圈的 ink 贴片)------这是"手绘感"的灵魂;
  • 白底 sclera;
  • 大瞳孔 pupil,且瞳孔是脸上唯一独立运动的部件(扫视时它走,白底微随)。

表情即偏移,不重造几何

五种表情不重建任何网格,只是一张偏移表:

ts 复制代码
happy: { eyeSY: .78, browLift: .16, mouthSY: 1.14, sagMul: 1.5 }

笑 = 白底 Y 向压扁(弯眼)+ 眉毛抬起 + 嘴拉高 + 微笑线 sag 加深。眨眼同理:白底 Y 压缩 + 瞳孔回中 + 眼睑带下落,全部是每帧的 scale/position 写入。

自主眨眼与扫视

编辑器里的角色是"活"的:临界阻尼弹簧的闭式解驱动扫视(被吸引→盯住→放开),眨眼随机间隔 1.4~4.8 秒。弹簧用闭式解而不是积分,帧率再低也不会抖

ts 复制代码
const step = (x, v, tgt, om, h) => {
  const dx = x - tgt, e = Math.exp(-om * h), b = v + om * dx;
  return [tgt + (dx + b * h) * e, (v - om * b * h) * e];
};

五、头发:一体成型的"雕刻发块"

头发不是插卡片,也不是粒子,而是一整块雕刻出来的壳

  • 环面拓扑:外表面从发脚(hem)爬到头顶(crown),再折回内表面贴着头皮------一块有厚度的"头发头盔";
  • 流场凹槽:从发旋(whorl)出发的流向场,在壳上刻出一缕一缕的沟槽;
  • scallop 发脚:边缘不是齐的,是一瓣一瓣的;
  • 碎发:鬓角 momiage、额前发丝,确定性抖动(来自索引与种子,绝不来自 rng------每帧重建也不能闪)。

发型表只有几个数:

ts 复制代码
bob:  { front: .22, side: -.95, back: -1.15, vol: .19, n: 16, jag: .09, open: .34 },
long: { front: .16, side: -1.95, back: -2.25, vol: .18, n: 16, jag: .10, open: .38 },

front/side/back 是发脚在头高上的位置(+1 头顶、−1 头底、<−1 垂落),"一圈水平是帽子,排序即读感"。19 种发型共用一个生成器,差异全在这张表里。

控制笼走 Catmull-Clark 细分:少量控制点 + 两级细分 = 圆润又便宜的网格。手套和鞋也是同一套笼子工艺------"有名字的部位(掌心、拇指、鞋头、鞋跟)就值得一个控制笼"。


六、Rayman 解剖:为什么没有手臂和腿

这是整个项目里最反直觉、也最重要的一课。我第一版给角色加了写实的梨形躯干和两段式手臂/腿(2-bone IK),结果用户的评价是:

回去读参考设计才看懂:

THE FRAME --- RAYMAN anatomy: no arms, no legs, no neck. The torso is an upright pill, the hands are balls FLOATING beside it, the feet are shoes floating under it, and the air in the gaps is what makes it a character and not a figurine. (间隙是承重墙------它说的是"角色",不是"手办"。)

于是最终解剖是:

ts 复制代码
const rxT = 0.27 * r;            // 躯干半宽:只有头的 1/4 宽
const ryT = 0.31 * r;            // 躯干半高
const footTop = footS * 1.42;    // 鞋顶
const tCy = footTop + gap + ryT; // 躯干中心:鞋上浮一段"空气"
const headBase = tCy + ryT - ry * 0.22; // 头沉入躯干 0.22ry:唯一插座式关节
  • 躯干是竖立的小药丸;
  • 手套浮在躯干两侧,鞋浮在下方,什么都不碰
  • 唯一的连接是头沉进躯干的插座。

挥手、走路怎么表达?靠浮空肢体的位移通道:动作片段里手往上飘就是挥手,鞋交替前移就是迈步。比例上头占 ~3/4 总高,脸永远扛着整个角色。


七、服装:壳裹在药丸外,泡泡袖裹在手套外

服装是躯干剖面的"外扩壳":widthAt(y) 线性插值出任意高度的半宽,壳环 = 宽 × 1.1 + 4mm,嵌套半径差防 z-fighting。上衣裹住整个药丸(crop 款到胸线 + 下摆罗纹圈),短裤/喇叭裙挂在下半,喇叭裙用 LatheGeometry 车出来。

两个坑:

  1. 环形剖面的 y 必须升序,降序会让面法线翻转,整块网格不可见;
  2. LatheGeometry 的剖面点要自下而上给,否则裙子翻面消失。

泡泡袖不挂在肩上(会悬浮),而是 attach: 'handL' 裹在浮空手套外面------手挥起来袖子跟着走,天然正确。


八、光泽的一半在环境里:PMREM 柔光箱

乙烯基娃娃的质感不是调 roughness 调出来的。项目复刻了一个"摄影棚":三个过曝柔光箱 + 灰色房间 + 两根暗立柱(给镜面反射找结构),用 PMREMGenerator.fromScene 预滤波成环境贴图,所有 clearcoat 材质共享:

ts 复制代码
box(9, 5, 5.2, 5.1, 4.9, 0, 8.5, 2, 0, Math.PI / 2); // 头顶大柔光箱
box(4, 7, 2.4, 2.3, 2.2, -8, 3, 4, Math.PI / 3);     // 暖补光
box(3, 6, 1.4, 1.5, 1.7, 8, 2, 3, -Math.PI / 3);     // 冷轮廓光

"没有东西可反射的镀铬读起来像平塑料"------立柱和亮带就是给抛光面准备的反射内容。同一张环境图也喂给缩略图烘焙器,保证角色库小图和主视口"所见即所得"。


九、动画:Phase 关键帧 + 时间轴

动作是 Clip = Phase[] 的关键帧模型:每个 Phase 是 { at, pose, move },pose 写骨骼欧拉角,move 写浮空手脚的位移。采样时相位之间平滑插值,时间轴 UI 直接编辑菱形关键帧。内置四支片段:待机 / 挥手 / 行走 / 跳舞。待机时角色还会自主眨眼、扫视------动画层和"生命层"叠加,角色才活


十、角色库:一个隐藏渲染器烘焙 48 张缩略图

角色库不实例化 48 个 Canvas。只有一个 192px 的隐藏 WebGLRenderer,用纯 three 的 buildStaticCharacter()(与编辑器共用 faceBuild/outfitBuild/ghair 纯函数)顺序烘焙 dataURL,每 4 张让出一次主线程,网格渐进出现。编辑器组件和烘焙器共用同一批纯函数,是"所见即所得"的工程保障。


十一、踩坑笔记

  1. 法线方向:超椭球表面法线用隐式梯度;环形网格顶点序决定法线朝向, Lathe/环剖面方向错了整块消失。
  2. 确定性 :逐缕抖动只能来自 index + seed 的哈希,不能来自 rng,否则重建即闪烁。
  3. 钳制优于小心:眼睛/嘴巴的"容下钳制"让样式表敢写夸张值,小头大头都安全。

结语

这个编辑器最让我着迷的不是某个技巧,而是一种秩序:配方是数据,layout 是测量,部件是样式表,环境是灯光师------每一层只干一件事,可爱由此变得可工程化。

项目基于 React 19 + R3F + three.js,核心代码约 6000 行,无外部模型与贴图。如果你也在做程序化角色,希望这篇拆解能帮你少走我走过的弯路:先读懂"可爱"的结构,再动手写代码。

欢迎在评论区交流你的程序化生成经验。

相关推荐
繁华若梦75917 分钟前
全项目 Skills 适配:把「会写代码的 Agent」变成「会按你们规范干活的同事」
前端
江华森31 分钟前
云原生从0到1:Kubernetes 工作负载实战——Deployment/Service/滚动更新/弹性伸缩
前端·后端
江华森38 分钟前
《云原生从0到1:4台华为云ECS搭建Kubernetes 1.28集群实录(上)——环境与踩坑全记录》
前端·后端
codeY42 分钟前
Vite 前端发布后「点击菜单没反应」?旧版本资源 404 的完整排查与修复实录
前端·前端工程化
90后的晨仔1 小时前
uni-app Vue3 状态管理 Pinia 完全指南:从概念到实战的深度解析
前端
mqiqe1 小时前
AgentScope Java 2.0 集成 Chat Completions Web:一行依赖让你的 Agent 变身 OpenAI 兼容服务
java·开发语言·前端
程序员小八7771 小时前
后端开发初学TypeScript
前端·javascript·typescript
90后的晨仔1 小时前
Puppeteer 与 Playwright 深度实战指南:从零到精通,全面提升开发效率
前端
sunphp开发者1 小时前
阿里云CDN加速配置问题
前端·阿里云·云计算