Pixel-Perfect Outline Shaders for Unity
Table of Contents
请尊重原作者的工作,转载时请务必注明转载自:www.xionggf.com
How outline shaders work
我第一次在电子游戏中看到3D物体的 轮廓线(outline) 是2003年育碧的《XIII》。这是我听说过的第一个具有 cel-shaded风格的 3D 游戏。(它基于一本图画小说。)
一旦我看到它,我必须知道它是如何完成的。《XIII》是一款使用虚幻2引擎的研发的游戏,我能够打开我信赖的 UnrealEd 并四处看看。
虚幻2引擎使用了固定功能的图形管道,这意味着XIII厚实的 墨水状轮廓(thick, ink-like outlines) 没有涉及着色器魔法。轮廓实际上是每个网格的一部分:外壳,悬停在角色的表面,具有倒置的面法线,因此只有内部可见,覆盖着黑色纹理。
自 2003 年和固定功能的图形管道以来,我们已经取得了长足的进步,但我们处理轮廓的方式几乎没有改变。最大的变化是轮廓不再需要成为网格的一部分;我们可以在单独的通道中使用顶点着色器来渲染网格的略微肥大的版本,并剔除正面(front face culled)。
换句话说,在我们的outline pass中,我们将取顶点的法线,乘以一个小数(我们不想增肥一个完整的单位!)得到一个偏移值,并将在变换顶点到裁剪空间之前,其此偏移值添加到顶点之前的位置。
这正是XIII在 2003 年在没有任何着色器的情况下完成的结果,但现在我们使用GPU自动地执行轮廓线绘制操作,来简化我们的创作工作流程。但我们可以做得更好。
Limitations of the classic technique
对于XIII受图形小说启发的美学而言,这些墨色轮廓恰到好处。但就像任何 15 年前的预着色技术一样,它们也有局限性,这使得它们作为通用轮廓解决方案的用处不大:
- 根据形状和视角,轮廓在对象表面上的厚度会有所不同。
- 当物体远离相机时,轮廓会被缩短(使用透视投影时)
- 轮廓宽度以对象空间单位指定,而不是以像素为单位。
请注意,这些是限制(limition),不一定是问题(problems)。在某些情况下,它们中的一些或全部可能是可取的。但他们每个人都应该在我们的控制之下。
我专注于技术,电脑艺术的。当我们作为艺术家以高水平的技术进行操作时,我们正在对我们制作的艺术——即我们代码的输出——施加精确的控制。
例如,我们可能希望将物体轮廓作为用户界面的一部分,以表明该物体已被选中。我们可能希望这些元素具有稳定的屏幕空间宽度。
或者我们可能正在模仿矢量或像素艺术风格,或者将 3D 网格与矢量或像素艺术精灵混合。我们需要能够以像素为单位匹配我们的轮廓宽度。
在本教程的过程中,我们将探索经典技术,然后对其进行改进,特别关注我们如何变换顶点位置,以更适应我们的需求,并为我们提供对最终外观的更多艺术控制。
Building the classic outline shader
下面是作为现代 Unity 着色器的经典XIII风格轮廓的代码。
Shader "Tutorial/Outline"
{
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
_Glossiness ("Smoothness", Range(0, 1)) = 0.5
_Metallic ("Metallic", Range(0, 1)) = 0
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.03
}
Subshader
{
Tags
{
"RenderType" = "Opaque"
}
CGPROGRAM
#pragma surface surf Standard fullforwardshadows
Input
{
float4 color : COLOR
}
half4 _Color;
half _Glossiness;
half _Metallic;
void surf(Input IN, inout SufaceStandardOutput o)
{
o.Albedo = _Color.rgb * IN.color.rgb;
o.Smoothness = _Glossiness;
o.Metallic = _Metallic;
o.Alpha = _Color.a * IN.color.a;
}
ENDCG
Pass {
Cull Front // 再绘制一次模型,且将朝向摄像机的面被剔除掉,只保留背向摄像机的面,
// 因为是沿着法线外扩,所以外扩的那个背面,也是在原始模型的背面之后
// 能被原始模型的背面给挡住大部分
CGPROGRAM
#pragma vertex VertexProgram
#pragma fragment FragmentProgram
half _OutlineWidth;
float4 VertexProgram(
float4 position : POSITION,
float3 normal : NORMAL) : SV_POSITION
{
position.xyz += normal * _OutlineWidth;
return UnityObjectToClipPos(position);
}
half4 _OutlineColor;
half4 FragmentProgram() : SV_TARGET {
return _OutlineColor;
}
ENDCG
}
}
}
这个着色器的第一部分(CGPROGRAM块外的 Pass块)是一个基本的表面着色器。没错:这是渲染对象本身的部分,它与这项技术无关,甚至可以用表面着色器来完成。这个阶段的渲染操作的 唯一重要的事情是我们写入深度缓冲区,表面着色器将始终这样做。
The outline pass
接下来,在我们的Pass块内部,是实际绘制轮廓线的部分,实际上只有两行重要的代码,使得这个魔术起作用:
Cull Front
这行代码将正面朝向摄像机的网格剔除,只渲染背向相机的网格面,且背向相机的网格面中的大部分,将被我们在第一阶段绘制的对象(就是待绘制物体本身)所遮挡住。
这行代码将每个顶点的位置,沿其法线平移一小段距离,其距离值由_OutlineWidth属性指定。
这正是XIII上的美术师在他们的建模包中所做的,我们只是在顶点着色器中将其自动化。
这是我们应用于球体的经典轮廓着色器。看起来很棒!但不幸的是,球体模型本身有点“完美理想主义”。因为它具有超平滑的法线,没有锐角,并且其所有顶点都与对象中心等距(球体的定义!)。这意味着我们甚至不需要沿法线平移轮廓的顶点位置——直接对他们等比例放大一下就行。
如果使用一个圆环模型的话,可以更好地了解正在发生的事情。查看在圆环中心的孔处是如何绘制轮廓线的,因为那里的法线面向朝向里。
Scaling and foreshortening
因为我们要在对象空间中平移顶点位置,所以在我们变换到世界空间的过程发生的任何缩放(scaling),以及透视除法(perspective diviision) 引起的任何透视短缩(foreshortening)。都会影响轮廓线的宽度。
scale 1 Camera distance 1× | scale 0.5 Camera distance 1× | scale 1 Camera distance 2× |
当我们努力改进对轮廓形状的控制时,为了实现像素完美的最终目标,我们需要学习抵消缩放和透视的影响。
Handling sharp edges
前面代码中实现的普通偏移技术在具有锐利边缘(sharp edges) 的对象上会失效。在我们继续改进这项技术之前,我们需要找到缓解这个问题的方法。
3D 网格中的锐边是通过沿边复制顶点来实现的。这个立方体的每一边都有四个独特的顶点,其法线垂直于相邻边的法线。当我们沿着它们的法线平移这些顶点的位置时,相当于爆炸立方体的边。
平滑的斜面边缘提供稳定的法线以沿挤出轮廓,并在美感上发生显着变化 | 将平滑的法线烘焙成顶点颜色并使用它们来挤出轮廓使我们能够保持锋利的边缘,但会牺牲宝贵的顶点通道 |
解决此问题的一种方法是使立方体的边缘平滑,并将它们倒角。这将影响立方体本身的外观。
另一种选择是使用单独的网格,该网格具有专门用于绘制轮廓的平滑法线。这要求我们在脚本中创作或生成一个单独的网格,为每个具有我们想要勾勒轮廓的锐利边缘的网格。(我们也可以使用这些网格来优化碰撞和阴影投射。)
最后,我们可以将平滑法线数据存储在我们不使用的网格的另一个通道中,例如在顶点颜色中。
您如何处理这取决于您,并且可能因情况而异。它会影响着色器的结构,但不会影响我们将用于操作轮廓形状的特定算法。
Grazing angles
关于这一经典技术,我想指出的最后一件事是,轮廓的粗细不仅会根据对象的比例和距离而变化,还会根据观察对象的角度而变化。尽管我们看到了一些透视效果,但这种宽度差异不仅仅归因于透视缩短。因为我们沿法线在三个维度上挤压轮廓,所以一些轮廓的宽度被用于朝向或远离相机行进,这不会影响轮廓的表观厚度。
对于像XIII的图形小说灵感艺术这样的墨色轮廓,轮廓粗细的这种变化增加了轮廓是手绘的错觉。
但是,当我们使用轮廓作为用户界面元素(例如显示哪些对象被选中),或实现机械绘图的外观时,这种变化是不可取的。我们需要学习如何控制它。
Working in clip space
我们对经典轮廓技术的第一个演变是在转换我们的顶点位置之前将我们的顶点位置和法线转换为剪辑空间 。
这允许我们绕过模型转换到世界空间,从而抵消任何对象缩放(只要我们在转换后规范化我们的法线)。
这可能看起来没那么有用。毕竟,在 Unity 中缩放对象并不是最佳实践。然而,它也让我们在以后绕过透视缩短。
float4 VertexProgram( float4 position : POSITION,
float3 normal : NORMAL) : SV_POSITION
{
float4 clipPosition = UnityObjectToClipPos(position);
float3 clipNormal = mul((float3x3) UNITY_MATRIX_VP, mul((float3x3) UNITY_MATRIX_M, normal));
clipPosition.xyz += normalize(clipNormal) * _OutlineWidth; // _OutlineWidth为0.015
return clipPosition;
}
现在我们在裁剪空间中,缩放对象不会影响轮廓的相对宽度,尽管它们仍然被透视缩短了。
scale 0.5 camera distance 1x | scale 0.25 camera distance 1x | scale 1 camera distance 2x |
Extruding in two dimensions
移动到剪辑空间后合乎逻辑的下一步是开始在二维中工作。请记住,在剪辑空间中,我们的位置XX 和 是是 组件对应于顶点在屏幕上的水平和垂直位置。
通过仅在两个维度上进行拉伸,我们的_OutlineWidth 属性将允许我们指定我们希望轮廓占据的屏幕部分,而不是与对象表面的三维距离。它更接近于对着色器的输出进行直接艺术控制,而不仅仅是输入。
float2 offset = normalize(clipNormal.xy) * _OutlineWidth;
clipPosition.xy += offset;
通过规范化 XX 和 是是组件,我们确保我们的偏移量完全占据_OutlineWidth剪辑空间(这不是完全的屏幕空间)。这应该会大大减少掠射角轮廓宽度的变化量,但它仍然不会减轻透视缩短。
Eliminate foreshortening
透视究竟是如何发生的?我们没有缩短顶点着色器中的偏移量;_OutlineWidth无论与相机的距离如何,它的长度都应该完全相等。那么为什么会变小呢?
事实上,所有的透视都发生在顶点着色器之后,在我们无法控制的部分图形管道中。在称为“透视划分”的步骤中,XX 和 是是 每个顶点位置的组成部分,包括我们的轮廓,除以它们的 瓦瓦成分。自从(0, 0)( 0 , 0 ) 位于屏幕中央,这意味着更大 瓦瓦 值使我们的位置更靠近屏幕中心(因此看起来更小更远)。
这 瓦瓦组件本身由投影变换填充。当我们使用正交投影时,瓦瓦 分量是恒定的,但是当我们使用透视投影时,它会根据相机空间而增加 zz 组件(与相机的距离)。
正确的投影是必不可少的,所以我们不能消除 瓦瓦组件完全。相反,我们需要抵消透视划分(除以瓦瓦) 通过将我们的偏移量预乘 瓦瓦.
float2 offset = normalize(clipNormal.xy) * _OutlineWidth * clipPosition.w;
clipPosition.xy += offset;
如果我们将剪辑空间位置乘以 瓦瓦 而不仅仅是偏移量(或者如果我们设置 瓦瓦 到 11),无论距离相机多远,我们的书在屏幕上都将保持相同的大小。记住一个巧妙的技巧!
现在我们已经减少了透视,我们真正在屏幕空间中操作。我们_OutlineWidth现在与轮廓将覆盖的屏幕数量直接相关(1屏幕的 50%)。
Accounting for aspect ratio
所以如果一个_OutlineWidthof1相当于我们屏幕的 50%,我们就有问题了!大多数时候,我们屏幕的宽度和高度并不相同。
如果我们的屏幕宽度大于高度(通常是这种情况),那么我们的轮廓在水平方向上会比在垂直方向上更粗。
如果我们想要一个书法外观,那很酷,尽管我们可能希望对其进行更精确的控制,而不是将其留给用户的纵横比。
然而,根据屏幕尺寸标准化我们的偏移使我们非常接近能够以像素为单位指定我们的轮廓宽度,我们不妨这样做。
Pixel perfection
我们需要划分我们的偏移量 XX 和 是是组件分别由屏幕宽度和高度组成。Unity 将这些提供给我们作为XX 和 是是_ScreenParams变量的组成部分。现在我们_OutlineWidth 将以像素为单位测量轮廓的宽度。
float2 offset = normalize(clipNormal.xy) / _ScreenParams.xy * _OutlineWidth * clipPosition.w;
没那么快!在剪辑空间中,我们的XX 和 是是 坐标范围从 -w- w 到 +w+ w. 透视分割后,这些将是-1− 1 到 +1+ 1,对于总范围 22. 为了使_OutlineWidth中1等于1个像素,我们需要通过划分我们的屏幕宽度和高度22, 或乘以我们的偏移量22 (可能更简单)。
float2 offset = normalize(clipNormal.xy) / _ScreenParams.xy * _OutlineWidth * clipPosition.w * 2;
浮点除法很昂贵。预先计算屏幕宽度和高度的倒数并将它们乘以 CPU 上以像素为单位的轮廓宽度,然后传递该向量(我们仍然需要单独的XX 和 是是值)代替 _OutlineWidth属性。这可以在自定义着色器 GUI 中完成。
这是我们的圣杯,是对着色器输出的终极艺术控制。像素完美的屏幕空间轮廓。关闭抗锯齿后,我们可以看到它们有多脆。
分辨率 512x512 | 分辨率 256x256 | 分辨率 128x128 |
光栅化器最终不会像天才像素艺术家那样完全放置像素,但对于将动态 3D 网格与像素艺术精灵混合,它已经足够接近了。
supersample 8x _OutlineWidth 1 | supersample 8x _OutlineWidth 1.5 |
开启抗锯齿功能后,我们可以获得可爱、精确的矢量艺术轮廓。我真的很喜欢1.5这里的像素重量。
supersample 8x _OutlineWidth 1 | supersample 8x _OutlineWidth 1.5 |