本文最后更新于16 天前,其中的信息可能已经过时,如有错误请留言
Houdini平滑法线
Reference:(53 封私信 / 18 条消息) 少女前线2渲染还原分析—以Cheeta为例 – 知乎
法线外扩 https://zhuanlan.zhihu.com/p/508826073
| 名称 | 含义 |
|---|---|
| 法线外扩 | 顶点沿法线方向移动,是几何处理 |
| BackFace | 显示模型背面,是面剔除/渲染设置 |
| Inverted Hull | 将“法线外扩 + 正面剔除”组合起来的完整描边方案 |
| 平滑法线 | 让外扩方向更连续,改善描边质量 |
- 一般外部的轮廓线采用BackFace,内部的描边线采用法线外扩?
- 导入模型:
Convert to Y-up Coordinate System可以开,因为 Unity 和 Houdini 都是 Y-up,通常不会有问题- Import As New Scene

- 新建


节点说明
- Capture Override:这个节点保留骨骼蒙皮信息。后面的处理必须从它之后开始,否则容易丢失 Skin Weights
- Fuse:作用是把位置非常接近的点合并,方便后面重新计算平滑法线
- 很多游戏模型在 UV 接缝、硬边、材质分割处会有重复顶点。这些重复顶点虽然位置一样,但法线可能不一样。描边如果直接用这些法线,会在接缝处断裂
- Snap Distance = 0.0001
Fuse Snapped Points = 开
Remove Repeated Vertices and Degenerate Primitives = 开
Remove Unused Points from Degenerate Primitives = 开
- Normal 重新计算平滑法线
- Compute Normals = 开
Add Normals to = Points
Weighting Method = By Vertex Angle
Keep Original Normal Where Computed Normal Is Zero = 开
- Compute Normals = 开
- PolyFrame
- Entity = Points
Style = Texture UV Gradient
Tangent Name = tangent
Bitangent Name = tangentv
Make Frame Orthogonal = 开
- Entity = Points
- Attribute Wrangle
- 主要做4件事情
- 1. 读取原模型法线 N
2. 从第二输入读取平滑描边法线
3. 读取切线 tangent,构建切线空间
4. 把平滑描边法线转成切线空间,并写入 uv3 / uv4
- 1. 读取原模型法线 N
- Houdini 和 Unity 的 UV 命名对应关系大致是:
- Houdini uv → Unity UV0
- Houdini uv2 → Unity UV1
- Houdini uv3 → Unity UV2
- Houdini uv4 → Unity UV3
- 主要做4件事情

不同材质球怎么知道读取哪个 UV?
就比如正常渲染的皮肤材质球和用于描边效果的材质球
看 Shader 代码里的 TEXCOORD。
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
};
//然后采样贴图时:
float2 uv = TRANSFORM_TEX(input.uv, _BaseMap);
half4 baseTex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
TEXCOORD0 = Unity UV0,所以你的皮肤 BaseMap、NormalMap、RMO 等,默认都是读 UV0。

Unity 描边方案
核心原理
BackFace 描边法
- 1. 正常 Pass 先渲染角色本体。
- 2. Renderer Feature 额外把角色 Mesh 再画一遍。
- 3. 第二次绘制时只画背面:Cull Front。
- 4. 顶点沿法线方向在屏幕空间外扩。
- 5. 外扩出来的背面部分露在模型边缘,形成黑色轮廓线。
不是后处理描边,而是几何体外扩描边
描边方案
- 角色原本负责皮肤、衣服、头发、丝袜、Ramp、PBR、MatCap 等正常渲染
- GF2_OutlineOnly 材质:
只负责描边,不负责角色本体颜色。 - GF2CharacterOutlineFeature:
在 URP 渲染流程中,把指定 Layer 的角色 Mesh 额外绘制一遍,并强制使用 GF2_OutlineOnly 材质
这样做的好处是:不需要修改每个角色 Shader。身体、头发、丝袜、脸部都可以继续使用原本材质,描边由 Renderer Feature 统一处理
Renderer Feature
Renderer Feature 的作用是:找到指定 Layer 的角色物体,并用统一的 Outline 材质重新绘制一次。
// 设置要绘制的物体范围:指定 Layer + 指定渲染队列
filteringSettings = new FilteringSettings(
RenderQueueRange.all,
profile.outlineLayerMask
);
// 创建绘制设置,使用角色原本 Shader 的常规 Pass 作为筛选入口
DrawingSettings drawingSettings =
CreateDrawingSettings(
shaderTagIdList[0],
ref renderingData,
sortingCriteria
);
// 关键:用 OutlineOnly 材质覆盖原材质
// 这样角色本体 Shader 不需要加 Outline Pass
drawingSettings.overrideMaterial = profile.outlineMaterial;
drawingSettings.overrideMaterialPassIndex = profile.outlineMaterialPassIndex;
// 执行额外绘制
context.DrawRenderers(
renderingData.cullResults,
ref drawingSettings,
ref filteringSettings,
ref renderStateBlock
);
这段代码的关键点是:
overrideMaterial = profile.outlineMaterial
它表示:虽然场景里的角色原本用的是身体、头发、丝袜等材质,但 Renderer Feature 这次额外绘制时,会临时强制使用 GF2_OutlineOnly 材质。
所以最终效果是:
原材质画角色本体
OutlineOnly 材质额外画一遍描边
OutlineOnly Shader
- Cull Front ,这样距离摄像机近的正面永远不会被渲染,不会遮挡角色原本的材质
- 使用模型法线或平滑法线作为外扩方向
- MeshNormal:
直接用模型原法线外扩,简单稳定,但硬边和切缝处容易断。 - UV2_UV3_TangentNormal:
从 UV2 / UV3 读取预烘焙的平滑描边法线,线条更连续。
- MeshNormal:
// 把模型法线转换到世界空间
half3 meshNormalWS =
normalize(TransformObjectToWorldNormal(input.normalOS));
// 默认使用模型原始法线
half3 outlineNormalWS = meshNormalWS;
// 如果选择 UV 平滑法线,则从 UV 中读取并转回世界空间
if (_OutlineNormalSource > 0.5h)
{
half3 outlineNormalTS = GetOutlineNormalTS(input);
half3 smoothNormalWS =
OutlineTangentNormalToWorld(
outlineNormalTS,
meshNormalWS,
input.tangentOS
);
outlineNormalWS = normalize(
lerp(meshNormalWS, smoothNormalWS, _OutlineSmoothNormalStrength)
);
}
- 屏幕空间像素宽度描边
- 为了让描边宽度更接近“屏幕像素宽度”,不是直接在模型空间移动顶点,而是在裁剪空间中移动
positionCS.xy
- 为了让描边宽度更接近“屏幕像素宽度”,不是直接在模型空间移动顶点,而是在裁剪空间中移动
// 世界空间位置转裁剪空间
float4 positionCS = posInput.positionCS;
// 把描边法线转到观察空间
half3 normalVS =
normalize(mul((float3x3)UNITY_MATRIX_V, outlineNormalWS));
// 使用观察空间法线的 xy 作为屏幕扩张方向
float2 rawDir = normalVS.xy;
float rawLen = length(rawDir);
float2 dir = rawDir;
// 如果法线在屏幕上的投影太短,就不外扩,避免抖动和碎线
if (rawLen < _OutlineMinViewNormalLength)
{
dir = float2(0.0, 0.0);
}
else
{
dir /= rawLen;
}
// 将像素宽度换算为裁剪空间偏移
float2 pixelToClip =
float2(2.0 / _ScreenParams.x, 2.0 / _ScreenParams.y);
// 按屏幕像素宽度外扩
positionCS.xy += dir * pixelWidth * pixelToClip * positionCS.w;
- 距离衰减:用于解决远距离观察时描边过粗、过黑、糊成一团的问题
half GetOutlineDistanceFade(float3 positionWS)
{
float dist = distance(_WorldSpaceCameraPos.xyz, positionWS);
float t = saturate(
(dist - _OutlineDistanceFadeStart) /
(_OutlineDistanceFadeEnd - _OutlineDistanceFadeStart)
);
// 平滑过渡
t = t * t * (3.0 - 2.0 * t);
// 近处为 1,远处变为 _OutlineDistanceFadeMin
half fade = lerp(1.0h, _OutlineDistanceFadeMin, (half)t);
return lerp(1.0h, fade, _UseOutlineDistanceFade);
}
最终参与描边宽度计算:
float pixelWidth =
_OutlineWidthPixels *
widthMask *
distanceFade *
innerSuppress *
_UseOutline;
- 内侧线压制:BackFace 描边容易在腋下、手指缝、胸侧、小饰品等内凹区域产生黑块。因此增加了 Inner Line Suppress 来压制内侧杂线。
half GetInnerSuppressMask(float viewNormalXYLength)
{
half edgeMask = smoothstep(
_OutlineInnerSuppressThreshold,
1.0h,
saturate((half)viewNormalXYLength)
);
edgeMask = pow(saturate(edgeMask), _OutlineInnerSuppressPower);
half suppress = lerp(
1.0h,
edgeMask,
_OutlineInnerSuppressStrength
);
return lerp(1.0h, suppress, _UseOutlineInnerSuppress);
}
- 宽度 Mask:Mask 用来控制不同区域的描边粗细
- 当前还没有正式使用 Mask,所以通常保持:
Use Outline Width Mask = 0
如果后续在 Houdini 或 Unity Baker 中写入区域 Mask,可以打开: - Use Outline Width Mask = 1 Outline Width Mask Source = UV3Y
- 当前还没有正式使用 Mask,所以通常保持:
half GetOutlineWidthMask(Attributes input)
{
half rawMask = 1.0h;
if (_OutlineWidthMaskSource > 0.5h && _OutlineWidthMaskSource < 1.5h)
rawMask = input.color.a;
if (_OutlineWidthMaskSource > 1.5h && _OutlineWidthMaskSource < 2.5h)
rawMask = input.uv2.y;
if (_OutlineWidthMaskSource > 2.5h)
rawMask = input.uv3.y;
rawMask = saturate(rawMask);
rawMask = pow(rawMask, _OutlineWidthMaskPower);
rawMask = max(rawMask, _OutlineMinWidthMask);
return lerp(1.0h, rawMask, _UseOutlineWidthMask);
}
Inspector 中可调整参数说明
- Outline Shape
| 参数 | 作用 |
|---|---|
| Use Outline | 是否启用描边。1 为开启,0 为关闭。 |
| Outline Width Pixels | 描边宽度,近似屏幕像素单位。值越大描边越粗。 |
| Outline Color | 描边颜色。二游角色一般不要用纯黑,建议用深棕黑、深灰黑或深紫黑。 |
- Outline Normal Source
| 参数 | 作用 |
| Outline Normal Source | 选择描边外扩使用的法线来源。 |
| MeshNormal | 使用模型原始法线。简单稳定,但硬边、切缝处可能断。 |
| UV1_UV2_TangentNormal | 从 Unity UV1 / UV2 读取平滑描边法线。 |
| UV2_UV3_TangentNormal | 从 Unity UV2 / UV3 读取平滑描边法线,当前推荐预留方案。 |
| Outline Smooth Normal Strength | 平滑法线混合强度。0 为原始法线,1 为完全使用平滑法线。 |
| Outline Min View Normal Length | 抑制正对镜头方向的异常外扩。值过大可能导致部分线消失,值过小可能出现碎线。 |
| Outline Clip Space Z Offset | 深度偏移,用于缓解描边和本体深度冲突。一般保持 0。 |
- Outline Distance Fade
| 参数 | 作用 |
| Use Outline Distance Fade | 是否启用远距离描边衰减。 |
| Distance Fade Start | 从多远开始让描边变细。 |
| Distance Fade End | 到多远时达到最细状态。 |
| Distance Fade Min Width | 远距离最小保留描边宽度比例。值越低,远处描边越细。 |
推荐作用:
近距离保留清晰线条。
远距离自动变细,避免角色变成一团黑边。
- Outline Inner Line Suppress
| 参数 | 作用 |
| Use Inner Line Suppress | 是否启用内侧线压制。 |
| Inner Suppress Strength | 压制强度。越大,腋下、手指缝、小饰品黑线越少。 |
| Inner Suppress Threshold | 压制阈值。越大,压制范围越广。 |
| Inner Suppress Power | 压制曲线。越大,过渡越集中,线条更干净但可能更容易断。 |
调参思路:
腋下、手指缝黑块太重:
提高 Strength / Threshold。
外轮廓也被压没:
降低 Strength / Threshold。
- Outline Width Mask
| 参数 | 作用 |
| Use Outline Width Mask | 是否启用宽度 Mask。 |
| Outline Width Mask Source | 选择从哪里读取宽度 Mask。 |
| Constant | 不读 Mask,全身统一宽度。 |
| VertexColorA | 从顶点色 A 通道读取宽度。 |
| UV2Y | 从 Unity UV2.y 读取宽度。 |
| UV3Y | 从 Unity UV3.y 读取宽度,当前预留推荐通道。 |
| Outline Width Mask Power | 控制 Mask 曲线。大于 1 会让灰色区域更细。 |
| Outline Min Width Mask | Mask 最低保留值,防止某些地方完全断线。 |
当前阶段一般关闭:
Use Outline Width Mask = 0
后续需要手动画区域粗细时再打开。
- Advanced
| 参数 | 作用 |
| Outline Bitangent Sign | 修正切线空间副切线方向。只有使用 UV 平滑法线时才重要。描边方向异常时可在 1 和 -1 之间切换。 |
- Render State
| 参数 | 作用 |
| Outline Cull Mode | 描边绘制面。BackFace 描边应设置为 Front,即 Cull Front,只画背面。 |
| Outline ZTest | 深度测试方式。一般用 LessEqual,避免描边乱穿透。 |
| Debug Mode | 调试模式。0 为正常描边,其他模式用于检查法线、Mask、距离衰减、内线压制等。 |
迭代改进与问题修复记录
- 最初是在身体 Shader 里直接加入第二个 Outline Pass:
ForwardLit Pass:正常渲染身体。
Outline Pass:Cull Front 后沿法线外扩,生成描边。
优点是效果直接,身体材质自己就能描边。
问题是:如果脸、头发、丝袜、衣服都有不同 Shader,就需要每个 Shader 都复制一份 Outline Pass,维护成本高。
- 头发和丝袜特殊处理预留
Renderer Feature 中预留了不同 Profile:
Body / Opaque
Hair / Alpha Reserved
Stocking / Transparent Reserved
后续可以通过不同 Layer + 不同 OutlineOnly 材质,让身体、头发、丝袜分别使用不同描边参数
Reference:https://zhuanlan.zhihu.com/p/508826073






