在《Real Time Rendering, third edition》一书中,作者把轮廓线的实现方法分成5种类型
- 基于观察角度和表面法线的轮廓线渲染
- 过程式几何轮廓线渲染,使用两个Pass渲染
- 基于图像处理的轮廓线渲染(屏幕后处理)
- 基于轮廓边检测的轮廓线渲染
- 混合了上述的几种渲染方法
基于观察角度和表面法线的轮廓线渲染
原理:法线和视线垂直的地方认为是边缘,这种方法和实现边缘光类似,可以参考这篇文章,Shader边缘光
优点:这种方法简单快速,可以在一个Pass中就得到渲染结果
缺点:局限性很大,轮廓效果很难控制,很多模型渲染出来的描边效果都不尽如人意
过程式几何轮廓线渲染,使用两个Pass渲染
顶点外扩
原理:第一个Pass渲染背面的面片,并使用某些技术让它的轮廓可见;第二个Pass再正常渲染正面的面片
优点:快速有效,并且适用于绝大多数表面平滑的模型
缺点:不适合表面法线不连续的模型,如立方体轮廓线会出现断裂的情况。
如图,第一个Pass使用轮廓线颜色渲染整个背面的面片,把模型顶点沿着法线方向向外扩张一段距离,第二个Pass渲染正面,shader实现如下
Shader "MyCustom/ToneBasedShading"
{
Properties
{
_MainColor ("[主颜色] Main Color", Color) = (1, 1, 1, 1)
_MainTex ("[主纹理] Main Tex", 2D) = "white" {}
_Ramp ("[漫反射色调的渐变纹理] Ramp Texture", 2D) = "white" {}
_Outline ("[轮廓线宽度] Outline", Range(0, 1)) = 0.1
_OutlineColor ("[轮廓线颜色] Outline Color", Color) = (0, 0, 0, 1)
_SpecularColor ("[高光的颜色] Specular Color", Color) = (1, 1, 1, 1)
_SpecularScale ("[高光反射的阈值] Specular Scale", Range(0, 0.1)) = 0.01
}
SubShader
{
Tags { "RenderType" = "Opaque" "Queue" = "Geometry"}
//第一个Pass使用轮廓线颜色渲染整个背面的面片
Pass
{
//定义pass名称后,可以在其他shader里引用
NAME "Outline"
//剔除正面,只渲染背面
Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
float _Outline;
fixed4 _OutlineColor;
struct v2f
{
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
//顶点和法线转到观察空间
o.vertex = mul(UNITY_MATRIX_MV, v.vertex);
float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal));
//对z分量进行处理,防止内凹的模型扩张顶点后出现背面面片遮挡正面面片的情况
normal.z = -0.5;
//在观察空间下把模型顶点沿着法线方向向外扩张一段距离
o.vertex = o.vertex + float4(normal, 0) * _Outline;
//转换到裁剪空间
o.vertex = mul(UNITY_MATRIX_P, o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//背面都使用轮廓线的颜色渲染
return float4(_OutlineColor.rgb, 1);
}
ENDCG
}
//第二个Pass渲染正面
Pass
{
Tags { "LightMode" = "ForwardBase" }
Cull Back
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
#include "UnityShaderVariables.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
//阴影贴图放到TEXCOORD3
SHADOW_COORDS(3)
};
fixed4 _MainColor;
sampler2D _MainTex;
sampler2D _Ramp;
float4 _MainTex_ST;
float4 _SpecularColor;
fixed _SpecularScale;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//半角向量
fixed3 worldHalfDir = normalize(worldLightDir + worldViewDir);
//反照率
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _MainColor.rgb;
//计算当前世界坐标下的阴影值,光照衰减和阴影值相乘后的结果存储到atten
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
//计算半兰伯特漫反射系数
fixed diff = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
//和阴影值相乘得到最终的漫反射系数
diff = diff * atten;
//漫反射系数对渐变纹理_Ramp进行采样,和反照率光照颜色相乘,得到最后的漫反射光照
fixed3 diffuse = _LightColor0.rgb * albedo * tex2D(_Ramp, float2(diff, diff)).rgb;
//卡通渲染中,高光是一块分界明显的纯色区域,所有不能用Blinn-Phong的公式去计算光照
fixed spec = dot(worldNormal, worldHalfDir);
//和阈值进行比较,小于阈值则高光反射系数为0,否则为1,这种方法会在高光区域的边界造成锯齿
//原因是高光区域边缘是由0突变到1。为了处理抗锯齿,在边界处很小的一块区域内,进行平滑处理
//spec =step(threshold, spec);
//邻域像素之间的近似导数值,w是一个很小的值,当spec-_SpecularScale小于-w时,返回0,
//大于w时,返回1,否则在0到1之间进行插值。这样我们可以在[−w, w]区间内,即高光区域的边界处,
//得到从0到1平滑变化的spec值
fixed w = fwidth(spec) * 2.0;
//step(0.000 1, _SpecularScale),是为了_SpecularScale为0时,可以完全消除高光反射的光照
fixed3 specular = _SpecularColor.rgb
* lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1))
* step(0.0001, _SpecularScale);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
Ramp控制颜色过渡
对立方体进行扩边时,轮廓线出现了断裂
断裂解决方法:在dcc软件里, 将模型连续的法线烘焙到模型的顶点色里,在进行外扩描外扩时,朝顶点色映射后的方向扩
模板缓冲实现描边
基于图像处理的轮廓线渲染(屏幕后处理)
原理:该方法细分为两种边缘检测,一种是基于颜色的边缘检测,根据相邻像素颜色变化来判断,变化越大越可能是边缘点。另一种是基于法线和深度值的边缘检测,相邻位置差值越大,越可能是边缘。
优点:可以适用于任何种类的模型。
缺点:基于颜色的边缘检测会产生很多我们不希望得到的边缘线,物体的纹理、阴影等位置也被描上黑边。
基于法线和深度值的边缘检测,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。
基于颜色的边缘检测
原理是利用一些边缘检测算子对图像进行卷积(convolution)操作,卷积操作指的就是使用一个卷积核(kernel)对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如2×2、3×3的方形区域),该区域内每个方格都有一个权重值。
当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。卷积实际上是对卷积核覆盖的像素进行加权平均操作。
这是几种常见的用于边缘检测的卷积核(也被称为边缘检测算子),如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界。这种相邻像素之间的差值可以用梯度(gradient)
来表示,可以想象得到,边缘处的梯度绝对值会比较大。卷积核检测水平和竖直方向上的边缘信息,得到两个方向上的梯度值Gx和Gy,整体的梯度G = |Gx| + |Gy|,注意旧版书上的Gx和Gy写反了,这一点作者在勘误中指出。
为了实现屏幕后处理,需要把脚本绑定到相机上,获取相机看到的图像再进行处理。
首先定义屏幕后处理基类,检查一系列条件是否满足
using UnityEngine;
using System.Collections;
//编辑器状态下也可以执行该脚本来查看效果
[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
public class PostEffectsBase : MonoBehaviour
{
// Called when start
protected void CheckResources()
{
bool isSupported = CheckSupport();
if (isSupported == false)
NotSupported();
}
// Called in CheckResources to check support on this platform
protected bool CheckSupport()
{
if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false)
{
Debug.LogWarning("This platform does not support image effects or render textures.");
return false;
}
return true;
}
// Called when the platform doesn't support this effect
protected void NotSupported()
{
enabled = false;
}
protected void Start()
{
CheckResources();
}
// Called when need to create the material used by this effect
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material)
{
if (shader == null)
return null;
if (shader.isSupported && material && material.shader == shader)
return material;
if (!shader.isSupported)
return null;
material = new Material(shader);
material.hideFlags = HideFlags.DontSave;
if (material)
return material;
return null;
}
}
实际的边缘检测脚本,主要是给shader传递参数的
using UnityEngine;
using System.Collections;
public class EdgeDetection : PostEffectsBase
{
//该效果需要用的Shader
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material
{
get
{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
//调整边缘线强度,当edgesOnly值为0时,边缘将会叠加在原渲染图像上;
//当edgesOnly值为1时,则会只显示边缘,不显示原渲染图像。
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
/// <summary>
/// Unity会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,
/// 再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上,OnRenderImage函数会在所有的
/// 不透明和透明的Pass执行完毕后被调用
/// </summary>
void OnRenderImage (RenderTexture src, RenderTexture dest)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
//使用材质对src纹理进行处理,src纹理会被传递给Shader中名为_MainTex的纹理属性。
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
使用Sobel算子来实现边缘检测
Shader "MyCustom/Edge Detection"
{
Properties
{
_MainTex ("[纹理] Base (RGB)", 2D) = "white" {}
//当edgesOnly值为0时,边缘将会叠加在原图上;当值为1时,则会只显示边缘,不显示原渲染图像
_EdgeOnly ("[边缘线强度] Edge Only", Float) = 1.0
_EdgeColor ("[描边颜色] Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("[背景颜色] Background Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
//屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,关闭深度写入,
//是为了防止它“挡住”在其后面被渲染的物体。这些设置可以认为是用于屏幕后处理的标配
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex vert
#pragma fragment fragSobel
sampler2D _MainTex;
//xxx_TexelSize是Unity为我们提供的访问xxx纹理对应的每个纹素的大小
//例如,一张512×512大小的纹理,该值大约为0.001953(即1/512)
uniform half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
struct v2f
{
float4 pos : SV_POSITION;
half2 uv[9] : TEXCOORD0;
};
v2f vert(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1); //当前像素左下角
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
//计算该像素对应的亮度
fixed Luminance(fixed4 color)
{
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
//计算梯度值
half Sobel(v2f i)
{
const half Gx[9] = {-1, 0, 1,
-2, 0, 2,
-1, 0, 1};
const half Gy[9] = {-1, -2, -1,
0, 0, 0,
1, 2, 1};
half texColor;
half edgeX = 0;
half edgeY = 0;
for (int it = 0; it < 9; it++)
{
texColor = Luminance(tex2D(_MainTex, i.uv[it]));
edgeX += texColor * Gx[it];
edgeY += texColor * Gy[it];
}
half edge = 1 - abs(edgeX) - abs(edgeY);
return edge;
}
fixed4 fragSobel(v2f i) : SV_Target
{
// 用Sobel函数计算当前像素的梯度值edge,edge值越小,表明该位置越可能是一个边缘点
half edge = Sobel(i);
// edge越小颜色越接近描边颜色,越大越接近原图的颜色
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
}
}
FallBack Off
}
脚本上引用shader就可以实现效果了
改变Edges Only可以看到切换的效果。物体的纹理、阴影等信息均会影响边缘检测的结果,使得结果包含许多非预期的描边。
基于法线和深度值的边缘检测
本节使用Roberts算子进行边缘检测,Roberts算子的本质就是计算左上角和右下角的差值,乘以右上角和左下角的差值,如果超过某个阈值(可由参数控制),就认为它们之间存在一条边。
后处理脚本与前面的基本一样,只是改了几个参数
using UnityEngine;
public class EdgeDetectNormalsAndDepth : PostEffectsBase
{
//该效果需要用的Shader
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material
{
get
{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
//调整边缘线强度,当edgesOnly值为0时,边缘将会叠加在原渲染图像上;
//当edgesOnly值为1时,则会只显示边缘,不显示原渲染图像。
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
//用于控制对深度+法线纹理采样时,使用的采样距离。sampleDistance值越大,描边越宽。
public float sampleDistance = 1.0f;
//sensitivityDepth和sensitivityNormals将会影响当邻域的深度值或法线值相差多少时,会被认为存在一条边界。
//如果把灵敏度调得很大,那么可能即使是深度或法线上很小的变化也会形成一条边。
public float sensitivityDepth = 1.0f;
public float sensitivityNormals = 1.0f;
void OnEnable()
{
//生成深度+法线纹理
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}
//[ImageEffectOpaque]属性控制在不透明的Pass(即渲染队列小于等于2 500的Pass,如Background、
//Geometry和AlphaTest)执行后调用该函数,而不对透明物体(Transparent)产生影响
[ImageEffectOpaque]
void OnRenderImage (RenderTexture src, RenderTexture dest)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0f, 0f));
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
使用Roberts算子实现边缘检测
Shader "MyCustom/Edge Detection Normals And Depth"
{
Properties
{
_MainTex ("[纹理] Base (RGB)", 2D) = "white" {}
_EdgeOnly ("[边缘线强度] Edge Only", Float) = 1.0
_EdgeColor ("[描边颜色] Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("[背景颜色] Background Color", Color) = (1, 1, 1, 1)
_SampleDistance ("[采样距离] Sample Distance", Float) = 1.0
//xy分量分别对应了法线和深度的检测灵敏度,zw分量没有实际用途
_Sensitivity ("[灵敏度] Sensitivity", Vector) = (1, 1, 1, 1)
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
//深度+法线纹理
sampler2D _CameraDepthNormalsTexture;
struct v2f
{
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};
v2f vert(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
//数组的第一个元素存储了屏幕图像的采样纹理。
o.uv[0] = uv;
//当我们需要同时处理多张渲染图像(前提是开启了抗锯齿),这些图像在竖直方向的朝向就可能是不同的。
//这时,我们就需要自己在顶点着色器中翻转某些渲染纹理
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
uv.y = 1 - uv.y;
#endif
//数组中剩余的4个元素则存储了使用Roberts算子时需要采样的纹理坐标,
//我们还使用了_SampleDistance来控制采样距离。
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;
return o;
}
//计算对角线上两个纹理的差值,返回0时表明这两点之间存在一条边界,反之则返回1
half CheckSame(half4 sample1, half4 sample2)
{
//对_CameraDepthNormalsTexture采样后的结果中存储着深度和法线信息,其中xy分量存储法线信息,
//zw分量存储深度信息,使用Unity提供的函数就可以解码出法线和深度信息,
//解码得到视角空间下的法线方向
//float3 sample1Normal = DecodeViewNormalStereo(sample1);
//这里用xy分量代表法线值,没有解码得到真正的法线值,因为我们只需要比较两个采样值之间的差异
half2 sample1Normal = sample1.xy;
//解码深度值,范围[0, 1]
float sample1Depth = DecodeFloatRG(sample1.zw);
half2 sample2Normal = sample2.xy;
float sample2Depth = DecodeFloatRG(sample2.zw);
//比较法线之间的差异
half2 diffNormal = abs(sample1Normal - sample2Normal) * _Sensitivity.x;
//把差异值的每个分量相加再和一个阈值比较,如果它们的和小于阈值,则返回1,说明差异不明显
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
//比较深度之间的差异
float diffDepth = abs(sample1Depth - sample2Depth) * _Sensitivity.y;
int isSameDepth = diffDepth < 0.1 * sample1Depth;
//法线或深度有差异就返回0
return isSameNormal * isSameDepth ? 1.0 : 0.0;
}
fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target
{
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);
half edge = 1.0;
edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);
// edge越小颜色越接近描边颜色,越大越接近原图的颜色
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
}
FallBack Off
}
同样脚本上引用shader
EdgeOnly从0变化到1的效果
基于轮廓边检测的轮廓线渲染
原理:检测一条edge是否是轮廓边的公式很简单,只需要检查和一条edge相邻的两个三角形是否满足:
(n0⋅v>0) != (n1⋅v>0)
其中,n0和n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意顶点的方向。上述公式的本质在于检查两个相邻的三角面片是否一个朝正面、一个朝背面。我们可以在几何着色器(Geometry Shader)的帮助下实现上面的检测过程。
优点:精确的检测轮廓线,可以渲染出独特风格的轮廓线,例如水墨风格等
缺点:计算量大,实现相对复杂,它还有动画连贯性的问题。也就是说,由于是逐帧单独提取轮廓,所以在帧与帧之间会出现跳跃性。
参考《Shader 入门精要》