1. 从一张立绘到实时三渲二:这个项目的整体设计思路
1.1 为什么选三渲二而不是全写实或纯手绘
把一张 20 周年初音同人立绘做成能在引擎里实时跑起来的三渲二模型,这件事听起来像是“建模 + 贴图 + 渲染”三件套的常规组合,但真正动手之后你会发现,难点根本不在建模本身,而在于如何让 3D 模型在实时渲染下保持 2D 立绘的视觉语言。全写实渲染会把初音那种干净的赛璐璐质感和标志性的青绿色双马尾“洗掉”,纯手绘又没法实时交互,所以三渲二(NPR,Non-Photorealistic Rendering)几乎是唯一合理的路线。
我选 Unity 作为实时渲染平台,理由很直接:它的 Shader Graph 和自定义 HLSL 管线对 NPR 的支持足够灵活,社区里关于卡通描边、色阶分离、面部阴影控制的方案非常成熟,而且实时预览迭代速度快,改一个参数就能立刻看到效果,不用像离线渲染那样等半天。对于个人同人项目来说,时间成本比什么都重要。
整个项目的核心链路可以拆成四段:立绘拆解与参考分析 → 3D 建模与拓扑 → UV 与贴图绘制 → Unity NPR 材质与实时渲染调优。每一段都有坑,而且坑和坑之间是连锁的。比如建模阶段如果拓扑没考虑好描边法线,后面在 Unity 里做外扩描边就会到处破面;贴图阶段如果色阶没有提前分层,Shader 里再怎么调也出不来干净的赛璐璐块面。
提示:三渲二不是“把 3D 做得像 2D”,而是“用 3D 的数据结构去驱动 2D 的视觉规则”。想清楚这句话,后面很多取舍就顺了。
1.2 目标效果与性能预算的平衡
做同人项目最容易犯的错,是一上来就追求“和立绘一模一样”。立绘是静态的、可以无限堆细节的,而实时渲染每一帧都要在 16.6 毫秒内完成。我的目标定得很务实:在 1080p 下稳定 60 帧,模型面数控制在 3 万三角面以内,贴图总显存占用不超过 200MB。这个预算对于一台中端显卡的 PC 来说非常宽松,也给 Shader 里的多 pass 描边留足了余量。
具体到视觉目标,我列了三条硬指标:第一,面部阴影必须干净,不能出现写实渲染那种脏脏的过渡;第二,头发和裙摆的描边要均匀,不能因为模型曲率变化就忽粗忽细;第三,高光要可控,初音头发上那种条带状高光必须能跟着视角滑动,而不是死贴在贴图上。这三条决定了后面 Shader 的架构方向。
性能预算的分配上,我把大头给了描边和阴影。描边用背面外扩法,多一个 pass,但换来的是边缘稳定;阴影用基于 SDF 的面部阴影贴图,比实时计算省得多,而且能精确控制鼻梁和刘海投下的阴影形状。这两块加起来大概吃掉 40% 的 GPU 时间,剩下的留给主光照和后期。
1.3 工具链选型:Blender + Unity 的组合逻辑
建模我用的 Blender,不是 Maya 或 3ds Max。原因很实际:Blender 的镜像修改器和细分工作流对角色建模非常友好,而且导出 FBX 到 Unity 的兼容性经过这么多年已经相当稳定。更重要的是,Blender 里可以方便地自定义法线传递和顶点色,这两样东西在三渲二里是刚需。
Unity 版本我锁在 2022.3 LTS。这个版本对 Shader Graph 的支持已经完整,URP 管线也足够成熟,而且社区里大量的 NPR 教程都是基于这个版本,遇到问题搜得到答案。我不建议用太新的版本,同人项目没必要当小白鼠,稳定压倒一切。
贴图绘制我用的是 Substance Painter 加 Photoshop 的组合。Painter 负责基础色和粗糙度,Photoshop 负责色阶分层和手绘细节。这里有个关键点:三渲二的贴图不是越精细越好,而是要“分层清晰”。比如皮肤的贴图,我会把亮部、暗部、阴影过渡区分别画在不同图层,导出时合并成一张,但在 Shader 里通过阈值来分离,这样比纯靠光照计算出来的色阶干净得多。
2. 立绘拆解与建模阶段的核心细节
2.1 从 2D 立绘提取 3D 结构信息
拿到一张立绘,第一件事不是打开 Blender,而是在 Photoshop 里把角色“拆”了。我会把立绘分成几个层:头部(含头发)、躯干、手臂、裙摆、腿部、配饰。每一层单独看,分析它的体积关系和遮挡关系。比如初音的双马尾,在立绘里是左右各一束,但实际 3D 化的时候要考虑它和肩膀、背部的空间关系,不能简单贴上去。
这一步最容易被忽略的是透视修正。立绘通常是带一点透视的,尤其是脸部和手部。如果直接按立绘的轮廓去建模,做出来的模型会“扁”。我的做法是先在 Blender 里建一个粗略的方块人,把立绘作为背景图贴上去,然后不断调整摄像机焦距和模型比例,直到方块人的轮廓和立绘大致重合。这个过程叫“对位”,大概要花两三个小时,但能省掉后面大量的返工。
对位完成后,我会在立绘上标注几个关键点:眼角、鼻尖、嘴角、下巴、肩点、肘点、腕点、胯点、膝点、踝点。这些点就是 3D 建模时的“锚点”,保证比例不跑偏。初音的头身比大概是 1:6.5,比标准二次元角色的 1:7 稍微矮一点,显得更可爱,这个比例在对位时就要定死。
2.2 拓扑布线:为描边和形变留后路
拓扑这个词听起来很技术,说白了就是模型的网格怎么排布。三渲二对拓扑的要求比写实渲染更苛刻,因为描边是沿着法线外扩的,如果布线不合理,外扩出来的描边就会在关节处打结或者断裂。
我的布线原则有三条。第一,关节处必须有三条以上的环形边,尤其是肘部和膝盖,这样弯曲时描边才能平滑过渡。第二,面部布线要沿着肌肉走向,眼轮匝肌和口轮匝肌的位置要留出足够的边,方便后面做表情形变。第三,头发和裙摆的布线要顺着发丝和褶皱的方向,这样描边才会像手绘线条一样有方向感。
具体到初音这个模型,头发是最麻烦的。双马尾的每一束头发我都单独建了一个子网格,布线沿着发束的纵向走,横向只留必要的环边。这样做的代价是面数上去了,但换来的是描边可以精确控制。实测下来,头发部分占了总面数的 40% 左右,但视觉贡献超过 60%,这笔账划算。
注意:不要用自动拓扑工具生成三渲二模型的网格。自动拓扑出来的布线是均匀的,但三渲二需要的是“有方向”的布线。宁可手动多花两天,也不要后面在 Shader 里跟破面搏斗。
2.3 法线处理与顶点色预计算
法线在三渲二里的重要性怎么强调都不过分。写实渲染里法线是用来算光照的,三渲二里法线还要用来控制描边方向和阴影阈值。我的做法是在 Blender 里手动调整关键部位的法线,尤其是面部和头发。
面部法线我会做一次平滑传递,把鼻梁和颧骨的法线稍微“压平”,这样在 Shader 里做面部阴影时不会出现奇怪的明暗交界线。头发的法线则相反,要强化纵向,让描边沿着发丝方向走。Blender 的“法线编辑”模式可以手动刷,虽然费时间,但效果立竿见影。
顶点色是我预计算的一个“作弊”手段。我会在 Blender 里给模型刷上顶点色,比如面部的阴影区域刷成偏冷的色调,头发的高光区域刷成偏暖的色调。然后在 Unity Shader 里直接读取顶点色来微调颜色,这样比纯靠光照计算出来的颜色更有“手绘感”。顶点色的数据量很小,几乎不占性能,但视觉提升很明显。
3. Unity NPR 材质与 Shader 实现
3.1 基础色阶分离:让光照变成“块面”
三渲二的核心视觉特征是色阶分离,也就是把连续的光照过渡变成几个离散的色块。在 Unity 里实现这个,最直接的方法是在 Shader 里对光照结果做阈值化。
我的基础 Shader 结构是这样的:先算标准的 Lambert 光照,得到一个 0 到 1 的亮度值,然后用一个smoothstep函数把它映射到三个区间:亮部、中间部、暗部。每个区间对应一个颜色,这三个颜色我在贴图里已经画好了,Shader 里只需要按亮度值去采样。
float NdotL = dot(normal, lightDir); float shadowStep = smoothstep(_ShadowThreshold - _ShadowSmooth, _ShadowThreshold + _ShadowSmooth, NdotL); float3 baseColor = lerp(_ShadowColor, _LightColor, shadowStep);这里的_ShadowThreshold和_ShadowSmooth是两个关键参数。阈值决定明暗交界线的位置,平滑度决定过渡的软硬。对于初音这种赛璐璐风格,平滑度要设得很小,大概 0.02 左右,这样交界线才会锐利。但也不能完全为 0,否则会有锯齿。
实测下来,单纯靠 NdotL 做色阶分离,面部会出现“阴阳脸”的问题,也就是鼻梁两侧的明暗交界线太生硬。解决办法是引入一张面部 SDF 阴影贴图,用 UV 坐标去采样,而不是用 NdotL。这张贴图是我在 Photoshop 里手绘的,画的就是面部阴影应该有的形状,然后在 Shader 里根据光照方向去偏移采样坐标。这样无论光从哪个方向来,面部的阴影都是“画好的”那个形状,非常干净。
3.2 描边方案对比:背面外扩 vs 后期边缘检测
描边是三渲二的灵魂,也是性能杀手。我试过三种方案,最后选了背面外扩法。
第一种是后期边缘检测,在屏幕空间用深度和法线做 Sobel 算子。优点是性能好,一个全屏 Pass 搞定。缺点是描边粗细不受模型控制,近处粗远处细,而且模型内部的结构线(比如衣服褶皱)检测不出来。对于初音这种细节多的角色,后期描边会显得很“糊”。
第二种是正面外扩法,把模型正面沿法线外扩一圈,渲染成黑色。优点是描边均匀,缺点是正面外扩会覆盖模型本身的细节,而且遇到凹面会穿帮。
第三种就是背面外扩法,把模型背面沿法线外扩,只渲染背面,颜色设为描边色。这个方案的好处是描边在模型后面,不会遮挡正面细节,而且粗细可以通过外扩距离精确控制。缺点是每个模型要多一个 Pass,性能开销大概增加 30%。
我最终选了背面外扩,因为初音的头发和裙摆需要精确的描边控制,后期边缘检测达不到要求。为了控制性能,我把描边 Pass 的顶点数做了优化,头发部分用了简化版的网格来做描边,视觉上看不出差别,但省了不少顶点开销。
// 背面外扩描边 Pass Pass { Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag float _OutlineWidth; v2f vert(appdata v) { v2f o; float3 normal = normalize(v.normal); float3 pos = v.vertex.xyz + normal * _OutlineWidth; o.pos = UnityObjectToClipPos(pos); return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDCG }提示:外扩距离不要设成固定值,最好根据模型到摄像机的距离做动态调整。我的做法是在顶点着色器里算一下视距,然后按比例缩放
_OutlineWidth,这样远处看描边不会太粗,近处看也不会太细。
3.3 面部阴影与头发高光的特殊处理
面部阴影是三渲二里最考验功力的地方。写实渲染里,面部阴影是算出来的;三渲二里,面部阴影是“画”出来的。我用了一张 512x512 的 SDF 贴图,上面画了面部在不同光照角度下应该有的阴影形状。Shader 里根据光照方向计算一个 UV 偏移,去采样这张贴图,然后和基础色阶分离的结果做混合。
具体来说,我会把光照方向投影到面部的前向平面上,得到一个 2D 方向向量,然后用这个向量去偏移 SDF 贴图的 UV。这样当光从左边来,阴影就出现在右边;光从右边来,阴影就出现在左边。因为 SDF 贴图是手绘的,所以阴影形状永远是“好看”的,不会出现写实渲染那种鼻梁阴影断裂的问题。
头发高光我用的是各向异性高光加条带遮罩。初音的头发上有那种标志性的条带状高光,这个在写实渲染里很难做,但在 NPR 里很简单:在贴图里画一张高光遮罩,标出哪些区域应该有高光,然后在 Shader 里用视线方向和发丝方向的点积来控制高光的滑动。发丝方向我存在顶点色的一个通道里,这样不用额外的贴图。
float3 hairDir = normalize(v.vertexColor.rgb * 2 - 1); float3 viewDir = normalize(WorldSpaceViewDir(v.vertex)); float spec = pow(saturate(dot(hairDir, viewDir)), _HairSpecPower); float3 hairSpec = _HairSpecColor * spec * tex2D(_HairSpecMask, i.uv).r;这套组合下来,头发的高光会随着视角滑动,而且形状是可控的,不会像写实高光那样散开。
3.4 实时渲染性能调优的实操记录
性能调优我是在 Unity Profiler 里一点点抠出来的。第一步是看 GPU 时间,发现描边 Pass 占了 35%,主光照 Pass 占了 40%,后期占了 25%。描边 Pass 的开销主要来自顶点数,我把头发和裙摆的描边网格做了简化,顶点数砍了 40%,视觉上几乎看不出差别。
第二步是优化 Shader 的指令数。原来的 Shader 里用了很多pow和smoothstep,这些在移动端很贵。我把一些固定的计算移到了顶点着色器里,比如视线方向和光照方向的点积,这样像素着色器的负担就轻了。另外,我把一些常量参数打包成了一个float4,减少了常量缓冲区的切换。
第三步是贴图压缩。我把所有贴图都设成了 ASTC 6x6 格式,显存占用从 300MB 降到了 180MB。对于面部 SDF 贴图,我用了更高的压缩质量,因为它的精度直接影响阴影形状。头发的高光遮罩则用了更低的精度,因为高光本身就有一定的模糊容忍度。
实测下来,在 1080p 下,这个模型在 GTX 1060 上能稳定 60 帧,在核显上大概 30 帧左右。对于同人项目来说,这个性能已经足够了。
4. 常见问题与排查技巧实录
4.1 描边断裂与穿帮的排查思路
描边断裂是最常见的问题,表现是描边在某些角度下突然断开,或者出现奇怪的尖角。原因通常有三个:法线不连续、外扩距离过大、模型有非流形边。
排查的时候,我会先在 Blender 里打开“法线显示”,看看断裂处的法线是不是突然翻转了。如果是,就手动刷一下法线,让它平滑过渡。如果法线没问题,就检查外扩距离,把_OutlineWidth调小一半看看还断不断。如果还断,那就是模型有非流形边,比如两个面共享一条边但方向相反,这种在 Blender 里用“选择非流形”就能找出来。
还有一个隐蔽的坑是UV 接缝处的描边断裂。因为描边是在模型空间做的,UV 接缝处的顶点会被复制成两个,法线可能不一致。解决办法是在 Blender 里对 UV 接缝处的顶点做一次“法线平均”,或者在 Shader 里用顶点位置而不是 UV 来判断是否在接缝处。
4.2 面部阴影出现脏块的解决方法
面部阴影脏块通常是因为 SDF 贴图的采样出了问题。我遇到过两种情况:一种是阴影边缘有锯齿,原因是 SDF 贴图的精度不够,解决办法是把贴图从 256 提到 512,或者在 Shader 里加一点模糊。另一种是阴影形状扭曲,原因是光照方向的投影计算有误,比如把世界空间的方向直接拿去偏移 UV 了,应该先转换到模型空间。
还有一个容易被忽略的点是面部法线的朝向。如果面部法线不是朝向正前方,而是稍微偏上或偏下,那么光照方向的投影就会偏,阴影形状也会跟着偏。我的做法是在 Blender 里把面部法线统一刷成朝向正前方,稍微带一点向上的倾斜,这样阴影形状最稳定。
4.3 实时渲染掉帧的性能瓶颈定位
掉帧的时候,第一步永远是打开 Unity Profiler,看是 CPU 还是 GPU 的问题。如果是 GPU 问题,就看是哪个 Pass 最耗时。我遇到过的典型瓶颈有三个:描边 Pass 的顶点数过多、Shader 的指令数过多、贴图的采样次数过多。
描边 Pass 的顶点数问题,解决办法是用简化网格。Shader 指令数问题,解决办法是把一些计算移到顶点着色器,或者用查找表代替实时计算。贴图采样次数问题,解决办法是把多张遮罩贴图打包成一张,用不同的通道来区分。
还有一个隐蔽的瓶颈是透明材质的 Overdraw。初音的裙摆和头发如果用了透明材质,重叠部分会重复渲染,导致 GPU 负担加重。我的做法是尽量用 Alpha Test 代替 Alpha Blend,虽然边缘会有锯齿,但性能好很多。如果必须用 Alpha Blend,就把透明部分的面数降到最低。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 描边断裂 | 法线不连续 | Blender 查看法线显示 | 手动刷平滑法线 |
| 描边过粗/过细 | 外扩距离固定 | 调整_OutlineWidth | 按视距动态缩放 |
| 面部阴影脏块 | SDF 贴图精度不足 | 检查贴图分辨率 | 提高到 512 或加模糊 |
| 面部阴影扭曲 | 光照方向投影错误 | 检查空间转换 | 转换到模型空间再偏移 |
| 头发高光散开 | 各向异性方向错误 | 检查顶点色通道 | 重新刷发丝方向 |
| 掉帧严重 | 描边 Pass 顶点过多 | Profiler 看 GPU 时间 | 简化描边网格 |
| 透明材质 Overdraw | Alpha Blend 重叠 | Profiler 看 Overdraw | 改用 Alpha Test |
| 贴图显存过高 | 未压缩或压缩格式不当 | 检查贴图设置 | 改用 ASTC 6x6 |
注意:排查问题的时候,一次只改一个变量。我见过太多人同时调了五个参数,结果问题解决了也不知道是哪个参数起的作用,下次遇到同样的问题还是不会。
5. 从模型到实时交互的扩展思路
5.1 表情形变与 Blendshape 的接入
模型做完之后,如果想让它在 Unity 里“活”起来,下一步就是接表情形变。Blender 里的 Shape Key 导出到 Unity 就是 Blendshape,可以通过脚本控制权重。对于初音这个模型,我做了五个基础表情:微笑、张嘴、眨眼、皱眉、闭眼。每个表情的 Shape Key 都是在 Blender 里手动刷的,重点是不要破坏描边的连续性。
刷 Shape Key 的时候,我会把描边用的外扩网格也一起刷,保证表情变化时描边不会穿帮。具体做法是在 Blender 里把外扩网格和主网格绑定,用同样的 Shape Key 驱动。这样导出到 Unity 后,描边会跟着表情一起动,不会出现“脸动了但描边没动”的诡异情况。
5.2 实时交互场景的搭建建议
如果想把模型放进一个实时交互场景里,比如让它跟着鼠标转头,或者根据音乐节奏摆动,那就需要在 Unity 里搭一个简单的控制脚本。我的做法是用 Cinemachine 做摄像机控制,用 Timeline 做动画序列,然后用一个简单的 C# 脚本把鼠标输入映射到头部旋转上。
public class HeadLookAt : MonoBehaviour { public Transform target; public float rotationSpeed = 5f; public float maxAngle = 30f; void Update() { Vector3 dir = target.position - transform.position; Quaternion targetRot = Quaternion.LookRotation(dir); float angle = Quaternion.Angle(transform.rotation, targetRot); if (angle < maxAngle) { transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, Time.deltaTime * rotationSpeed); } } }这个脚本很简单,但效果很好。配合 Blendshape 的眨眼动画,模型看起来就像活的一样。如果要做更复杂的交互,比如根据音乐节奏摆动,可以用 Unity 的 AudioSource 获取频谱数据,然后映射到骨骼旋转上。
5.3 后续可扩展的方向
这个模型做完之后,还有很多可以扩展的方向。比如可以接面部捕捉,用 ARKit 或 Live Link Face 驱动 Blendshape,让模型实时模仿真人表情。也可以接物理模拟,给双马尾和裙摆加上 Spring Bone,让它们在移动时自然摆动。还可以接后期特效,比如 Bloom 和 Color Grading,让画面更有氛围感。
我个人最想尝试的是实时换装。把衣服和配饰做成独立的网格,通过脚本控制显示和隐藏,这样就能在运行时切换不同的造型。这个功能对于同人项目来说很有吸引力,因为可以展示不同版本的初音。
不过这些都是后话了。先把基础的三渲二渲染跑通,把描边和阴影调好,后面的扩展都是水到渠成的事。我在这个项目里踩过的坑,大部分都集中在描边和面部阴影上,这两块搞定了,剩下的就是体力活。
最后分享一个小技巧:在 Unity 里调 NPR 材质的时候,把 Game 视图和 Scene 视图并排放在一起,一边调参数一边看实时效果。Scene 视图里可以看到描边的外扩网格,方便排查穿帮问题。这个习惯帮我省了很多来回切换的时间。