法线外扩描边实现与优化
描边(Outline)是风格渲染经常采用的一种效果。常见的有法线外扩描边和后处理描边,当然也有很多风格化的项目会采用美术绘制的描边。
本文写作的目的是为了之前自研引擎法线外扩描边的实现和优化方案。想要完善的了解描边技术可以参考网易的描边文章以及文末的参考。
那接下来使用unity逐步实现法线外扩描边,看看会有什么问题以及如何优化。
思路#
法线外扩的意思是就是将顶点沿法线的方向外扩一段距离。通过借用网易游学文章中的这张图,可以很容易明白它的实现。
分为两个pass:
- 正常绘制
- 描边pass:绘制背面,关闭深度写入,开启深度比较,沿着法线将顶点往外偏移一些,并绘制描边颜色
- 结合起来:正常渲染,由于深度比较,描边pass只有法线外扩的部分能通过深度测试,也就表现出描边的效果
下面按照这个思路实现描边。
基础版#
思路:描边pass中的vertex shader计算中,将顶点沿法线方向偏移一些。
(positionOS + normalOS * value)得到模型空间下法线外扩的顶点
然后再fragment shader中绘制描边颜色
Pass
{
Name "Outline"
ZWrite On
Cull Front
HLSLPROGRAM
#pragma vertex VertexOutline
#pragma fragment FragOutline
CBUFFER_START(UnityPerMaterial)
half _OutlineWidth;
half4 _OutlineColor;
CBUFFER_END
struct Attribute
{
half3 position : POSITION;
half3 normal : NORMAL;
};
struct V2F
{
half4 position: SV_POSITION;
};
V2F VertexOutline(Attribute attr)
{
V2F v2f;
v2f.position = TransformObjectToHClip(attr.position + attr.normal * _OutlineWidth);
return v2f;
}
half4 FragOutline(V2F input) : SV_TARGET
{
return _OutlineColor;
}
ENDHLSL
}
但是当摄像机距离拉远后描边会变的越来越细,会断断续续;当摄像机拉进时,描边又会变得很粗。这两种情况非常影响视觉效果。
这是因为法线外扩运算是基于世界空间的,外扩的距离是固定的,自然就会有近大远小的效果。
去掉透视影响#
想要去掉近大远小的效果,可以在shader中将做一个乘以w分量的操作,抵消掉透视除法,那么法线外扩的距离在屏幕空间就是固定的。
pos = TransformObjectToHClip(attr.position);
pos.xy += TransformWorldToHClipDir(TransformObjectToWorldDir(attr.normal)).xy * pos.w * _OutlineWidth;
拉远后的效果
但此时让物体拥有形变,也就是缩放,部分位置的描边几乎消失。
这是因为带缩放的矩阵对面上的三个顶点及其法线变换之后,变换后的法线不再垂直于表面。这种现象叫非统一缩放。在这里简单提一下原因以及解决方式,更详细的可以看这篇文章。
如果想让变换后的法线垂直于变换后的表面,就不能用原来的变换矩阵了,需要用新的变换矩阵。
假设表面变换前后的切线法线分别为
对于1、2,将向量转置可以得到
将3、4带入6,得到
想要满足这个式子,只需要令
即
经过这个矩阵就可以让法线与变换后的表面相互垂直。
在vertex shader的变换中,这个
half3 viewNormal = mul((half3x3) UNITY_MATRIX_IT_MV, attr.normal);
half3 clipNormal = normalize(mul((half3x3) UNITY_MATRIX_P, viewNormal));
pos.xy += clipNormal.xy * _OutlineWidth * pos.w;
经过处理之后描边变得正常了。
屏幕宽高比#
在屏幕宽高比较大的时候,横向和纵向的描边粗细不一样。这是因为现在的描边宽度基于NDC空间计算的,变换到屏幕上乘以屏幕宽高会被拉伸。
需要在shader里根据屏幕宽高比处理一下描边宽度。
half3 viewNormal = mul((half3x3) UNITY_MATRIX_IT_MV, attr.normal);
half3 clipNormal = normalize(mul((half3x3) UNITY_MATRIX_P, viewNormal));
float4 screenParam = GetScaledScreenParams();
half aspect = screenParam.y / screenParam.x;
clipNormal.x *= aspect;
pos.xy += clipNormal.xy * _OutlineWidth * pos.w;
应用后的描边效果如下
随着相机距离,减弱描边效果#
现在的描边是固定宽度的,当摄像机拉的很远时,物体已经很小了,但是描边仍然很粗,这是错误的效果。所以我们会对描边宽度根据距离加个衰减处理。这里采用的是smoothstep,有经验的同学可以用表现更好的衰减方式。
half dis = distance(_WorldSpaceCameraPos, TransformObjectToWorld(attr.position));
float multiper = 1.0 - smoothstep(0, 1, dis / _OutlineMaxDistance);
pos.xy += clipNormal.xy * _OutlineWidth * pos.w * multiper;
角色描边效果#
当然这只是一个最简单的描边实现,想要在项目里实现好的效果,需要根据美术的需求,定制描边的实现,如描边宽度,衰减等。
平滑法线#
法线外扩描边有一个严重的效果问题:描边会断裂。以一个正方体的描边为例
这是立方体虽然有6个面,但是有24个顶点,也就是相同空间位置的点,在不同的面上是不同的顶点,自然这个顶点的法线也是不同的。经过法线外扩就会看到同一个空间位置顶点处的描边会出现断裂。
想要解决这种情况,就需要把同一个空间位置的不同顶点求各个面的平均法线,作为顶点的法线,这种法线叫平滑法线。求完平滑法线后,转换到TBN空间再保存的mesh中。在shader中将平滑法线转换到TBN空间,再想应用正常的法线一样做描边计算即可。
// 计算TBN 用来做smoothNormal
half3 normal = attr.normal;
half4 tangent = attr.tangent;
half3 binormal = normalize(cross(normal, tangent.xyz) * tangent.w);
half3x3 tbn = half3x3(tangent.xyz, binormal, normal);
tbn = transpose(tbn);
half3 smoothNormal = mul(tbn, attr.smoothNormal);
half3 viewNormal = mul((half3x3) UNITY_MATRIX_IT_MV, attr.normal);
half3 clipNormal = normalize(mul((half3x3) UNITY_MATRIX_P, viewNormal));
float4 screenParam = GetScaledScreenParams();
half aspect = screenParam.y / screenParam.x;
clipNormal.x *= aspect;
half dis = distance(_WorldSpaceCameraPos, TransformObjectToWorld(attr.position));
float multiper = 1.0 - smoothstep(0, 1, dis / _OutlineMaxDistance);
pos.xy += clipNormal.xy * _OutlineWidth * pos.w * multiper;
处理后的效果是这样的:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了