Tonal Art Maps
在我们开始前,我们需要讨论一下实时绘制阴影线(hatch)的基本原理。这个特效基于Tonal Art Maps(简写为TAM),一系列表示光的强度的纹理。对于其中的某一张纹理, 最棘手的部分是它需要包括比它亮的纹理的所有信息。所以你第二亮的纹理需要包含你最亮的纹理的所有数据,在加上稍微暗一些的数据。
用语言去表达有点复杂,你直接看一下比较直观。下面的图引自一篇经典的论文(你可以在这找到它,这是我们今天要呈现的技术)。
你可以看到,美术人员在一张纸的某些部分画了许多铅笔线道。更暗的贴图在比它暗的贴图的基础上,多加了些笔道。如果你不遵循这种规则来创建贴图,贴图不会有好的渐变效果,最后你会得到很奇怪的效果。
为了得到”合适“的TAM,我们不仅需要遵循上面的规则制作贴图,还需要制作多级渐进纹理(mipmap)。不这样做的话,你的最后渲染出的模型可能发现不了这些线道。下面这张图展示了生成mipmap的细节:
我会跳过所有生成mipmap的过程,因为不想制作我自己的TAM生成工具,我只是想说清楚这个特效是怎么做出来的,并不会应用在商业项目。如果你想找一个TAM生成工具一探究竟,你可以在这找到它。
好了,上面谈论了许多生成贴图的事,现在我们已经有了TAM图像,我们可以开始制作特效了。
单个模型的着色器
现在我们有了TAM图像,我们需要创建使用它们的着色器。我上面引用的论文提供了一种方法,需要采样6张纹理,但你可以将这6张纹理打包,只用2张纹理。这是需要停下来考虑的一件很重要的事,但是很多编写实时阴影线(hatch)着色器的人都没有考虑这件事:不要使用6张纹理来作为阴影线(hatch)的查找表。把他们打包成2张纹理。
我写了一个难看但快速的Unity工具来打包他们。代码有点长,我不会贴出来,你可以在Github中找到它。
下面的图是我通过这个工具打包的结果:
这种方法能够充分利用纹理空间。现在我们要看看着色器是怎么工作的。
我们会相当漂亮的混合这2张纹理6个通道的数据。在我们开始前,先让我们看一下着色器的大致结构。记住,我们现在写的是渲染单个模型的着色器。下面是这段代码:
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _Hatch0;
sampler2D _Hatch1;
float4 _LightColor0;
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
o.nrm = mul(float4(v.norm, 0.0), unity_WorldToObject).xyz;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 color = tex2D(_MainTex, i.uv);
half3 diffuse = color.rgb * _LightColor0.rgb * dot(_WorldSpaceLightPos0, normalize(i.nrm));
//hatching logic goes here
return color;
}
你可以在Github上找到源代码,不过上面的代码对于理解这个特效已经足够了。现在呈现的是一个标准的漫反射着色器。实际项目中你可能需要一些光源,这个特效对任何光源都处理的很好,所以现在我使用最简单的情况来演示。
我们要做的第一件事是用一个标量来表示每个像素的亮度。点乘一个常向量(0.2326, 0.7152, 0.0722)就行。
half intensity = dot(diffuse, half3(0.2326, 0.7152, 0.0722));
这个常量来自亮度公式,要求是与之相乘颜色是线性空间的颜色。你关不关心它取决于你的平台。简单起见,我不会解释它,如果你没有使用线性空间,你得到的结果是不准确的。
注意我们使用的是half类型来计算这个值,他与fixed的差异不是很大,fixed类型的值只能精确到0.0039(1/256),但是我们需要更精确的类型来表示我们的亮度值。如果你还想更精确,half就不行了,它不能准确的存储0.7152,但是这个数太小了,反而没有必要(如果你对half类型感兴趣,你可以看看这)。
如果我们增加了上面的代码,我们会得到一张灰度图:
现在我们需要将这个亮度值来对阴影线条纹理采样。我们有6个通道,这意味着有6个亮度阶级(1/6, 2/6, 3/6, 4/6, 5/6, 6/6)。在这之间的亮度值会在挨着的亮度之间插值。比如亮度是1.5/6(0.25)会在1/6和2/6对应的纹理之间插值。下面的图说明了这个例子:
不幸的是,许多GPU(或者至少是手机GPU)不能很好的处理分支结构。如果直接使用if语句,可能是这样:
fixed3 rgb;
if (intensity > 1.0 && intensity < 2.0)
{
fixed3 hatch = tex2D(hatch0, uv);
rgb += hatch.r * (1.0 - intensity);
rgb += hatch.g * intensity;
}
else if (intensity == 2.0)
{
rgb = tex2D(hatch, uv).g;
}
else if ...
我们基本上不希望着色器这样做,因为这会带来不必要的性能损失。相反,我们希望着色器代码是这样的:
fixed3 rgb;
fixed3 hatch = tex2D(hatch0, uv);
rgb += hatch.r * weight0;
rgb += hatch,g * weight1;
rgb += hatch.b * weight2;
...
注意这两种情况得到了同样的结果,但第二种没有分支结构。我们做的是给予每个阶级的纹理一个权重,通过与这个权重相乘得到所需的结果。如果与计算得到的亮度值相邻阶级纹理的权重相加为1,其他的都为0的话就再好不过了。
让我们看一下怎么做。我们需要比较6个数值来决定这6张纹理的权重。我们用half类型存储这些比较值和这些值与待处理像素亮度值的差异。这会是这样:
half i = intensity * 6;
half3 intensity3 = half3(i,i,i);
half3 weights0 = intensity3 - half3(0,1,2);
half3 weights1 = intensity3 - half3(3,4,5);
对于上面的代码段,有几点可以讨论。首先,为什么我们用整数作为梯度值,而不是小数1/6?这是为了避免后续还要多乘6。我们知道我们最多只可能有2个非零权重,这些权重相加的结果为1。只要增量是1,我们待会得到的结果就能简单的在这两张纹理之间插值。如果用小数,我们还需要乘6才行。
让我们用0.75来一步一步的说明:
half i = 0.75 * 6; // 4.5
half3 intensity3 = half3(i,i,i); //(4.5,4.5,4.5)
half3 weights0 = intensity3 - half3(0,1,2); //(4.5,3.5,2.5)
half3 weights1 = intensity3 - half3(3,4,5); //(1.5,0.5,-0.5)
上面有的结果超出了(0,1)范围,这并不是我们想要的,所以让我们要把它限制在(0,1)区间。
half i = 0.75 * 6; // 4.5
half3 intensity3 = half3(i,i,i); //(4.5,4.5,4.5)
half3 weights0 = saturate(intensity3 - half3(0,1,2));
// weights0 = (1,1,1)
half3 weights1 = saturate(intensity3 - half3(3,4,5));
//weights1 = (1,0.5,0)
很好。但是还有一件事需要考虑。我们最多应该需要2个非零的权重,但是现在有5个。我们想要去掉比计算得到的亮度值低的纹理的权重,然后只剩下需要的2张纹理的权重,它们相加的结果为1。
幸运的是,所有这些工作只要进行一些减法就能完成:
weights0.xy -= weights0.yz;
weights0.z -= weights1.x;
weights1.xy -= weights1.zy;
很巧妙对吧?拿0.75来说会得到(0,0,0)和(0.5,0.5,0.0)两个向量。这表示4.5需要第4张纹理的50%和第5张纹理的50%,这正是我们想要的。
我们得到权重后,剩下的就是相乘并相加:
half3 hatching = half3(0.0, 0.0, 0.0);
hatching += hatch0.r * weightsA.x;
hatching += hatch0.g * weightsA.y;
hatching += hatch0.b * weightsA.z;
hatching += hatch1.r * weightsB.x;
hatching += hatch1.g * weightsB.y;
hatching += hatch1.b * weightsB.z;
我们可以进一步优化它,用向量乘然后再相加:
half3 hatching = half3(0.0, 0.0, 0.0);
hatch0 = hatch0 * weightsA;
hatch1 = hatch1 * weightsB;
half3 hatching = hatch0.r +
hatch0.g + hatch0.b +
hatch1.r + hatch1.g +
hatch1.b;
There are two things to note in the above. The first is how we’re handling black. Because our effect relies on keeping the relationship of less light == denser pencil strokes, we can’t treat black as a separate texture sample, because when we move between our darkest texture and pure black we won’t be adding any more strokes. Instead, when we’re blending between our darkest two texture samples, what we’re really doing is (darkestTexture 1.0 - i) + (2ndDarkest * i). This is expressed above but it isn’t immmediately obvious.
(没看懂,表示从1/6到1的亮度是有效的,不包括1/6以下?)
第二,你可能意识到,上面的代码以来一种假设:我们的亮度值不超过1。我们可以让非常亮的地方得到白色的结果。在我们进行数学运算之前,只需存储max(0, intensity - 1.0),最后再补偿回来就行。对于小于1的值,这会是0,对于很亮的地方,这会使结果趋近于白色。
总之,这个阴影线(Hatch)函数是这样的:
fixed3 Hatching(float2 _uv, half _intensity)
{
half3 hatch0 = tex2D(_Hatch0, _uv).rgb;
half3 hatch1 = tex2D(_Hatch1, _uv).rgb;
half3 overbright = max(0, _intensity - 1.0);
half3 weightsA = saturate((_intensity * 6.0) + half3(-0, -1, -2));
half3 weightsB = saturate((_intensity * 6.0) + half3(-3, -4, -5));
weightsA.xy -= weightsA.yz;
weightsA.z -= weightsB.x;
weightsB.xy -= weightsB.zy;
hatch0 = hatch0 * weightsA;
hatch1 = hatch1 * weightsB;
half3 hatching = overbright + hatch0.r +
hatch0.g + hatch0.b +
hatch1.r + hatch1.g +
hatch1.b;
return hatching;
}
如果我们把它加入像素着色器,会是这样:
fixed4 frag (v2f i) : SV_Target
{
fixed4 color = tex2D(_MainTex, i.uv);
fixed3 diffuse = color.rgb * _LightColor0.rgb * dot(_WorldSpaceLightPos0, normalize(i.nrm));
fixed intensity = dot(diffuse, fixed3(0.2326, 0.7152, 0.0722));
color.rgb = Hatching(i.uv * 8, intensity);
return color;
}
我们得到了一个不错的单个模型的阴影线条材质。
最后需要注意的是,我将传递个Hatch函数的UV坐标乘以8,我认为这样会得到更好的效果。你可能使用不一样的值,特别当你自己生成的TAM的时候。
后处理特效
现在我们有了基本的效果,我们接着要做更激动的事了!做一些可以使这个特效简单应用在实际项目中的事,和一些有趣的事,比如与晕影相组合。
但现在,我只是想把它变成一个普通的全屏素描效果:
这会非常直接。我们可以用渲染好了的场景图象,从中提取亮度值。场景中可以使用复杂的材质,或者有Unity的GI,这些我们都不用考虑。我们所需要的仅仅是渲染对象的UV。
像通常做图像处理的时候一样,我们要提前设置一下:
[RequireComponent(typeof(Camera))]
public class PencilSketchPostEffect : MonoBehaviour
{
public float bufferScale = 1.0f;
public Shader uvReplacementShader;
public Material compositeMat;
private Camera mainCam;
private int scaledWidth;
private int scaledHeight;
private Camera effectCamera;
void Start ()
{
Application.targetFrameRate = 120;
mainCam = GetComponent<Camera>();
effectCamera = new GameObject().AddComponent<Camera>();
}
void Update()
{
bufferScale = Mathf.Clamp(bufferScale, 0.0f, 1.0f);
scaledWidth = (int)(Screen.width * bufferScale);
scaledHeight = (int)(Screen.height * bufferScale);
}
如果你读过我以前的文章,这些都很好理解。我们用另一个相机渲染我们的特效,用一个变量改变我们UV缓存的大小,减小它可以提升性能。以下是在OnRenderImage函数中的代码:
private void OnRenderImage(RenderTexture src, RenderTexture dst)
{
effectCamera.CopyFrom(mainCam);
effectCamera.transform.position = transform.position;
effectCamera.transform.rotation = transform.rotation;
//redner scene into a UV buffer
RenderTexture uvBuffer = RenderTexture.GetTemporary(scaledWidth, scaledHeight, 24, RenderTextureFormat.ARGBFloat);
effectCamera.SetTargetBuffers(uvBuffer.colorBuffer, uvBuffer.depthBuffer);
effectCamera.RenderWithShader(uvReplacementShader, "");
compositeMat.SetTexture("_UVBuffer", uvBuffer);
//Composite pass with packed TAMs
Graphics.Blit(src, dst, compositeMat);
RenderTexture.ReleaseTemporary(uvBuffer);
}
这段代码和以前的文章一样。我们把主相机的配置复制给特效相机,然后创建我们用来存储UV的缓存,接着渲染场景的UV。
一旦我们的UV缓存绘制好了,我们将它传给混合(composite)着色器,混合(composite)着色器会做剩余的工作。
当渲染UV缓存时很容易出错。我们需要比默认的RT(RenderTexture)纹素精度更多数据来存储UV。还记得刚才我们用half3存储亮度值,而不是fixed3么?这同样适用于UV。如果没有这样做,你会得到混乱的结果:
左边是错误的精度,右边是正确的精度
因为我们用了float类型的缓存,这意味着我们的着色器需要返回float值,我们的UV着色器是这样的:
float4 frag (v2f i) : SV_Target
{
float2 uv = i.uv;
return float4(i.uv.x, i.uv.y, _MainTex_ST.x, _MainTex_ST.y);
}
我们可能利用Tiling,或者Offset来提高效果的准确性,所以我将它们也传到缓存中。
最后,混合(composite)着色器很简单,它的是这样的:
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
float4 uv = tex2D(_UVBuffer, i.uvFlipY);
half intensity = dot(col.rgb, float3(0.2326, 0.7152, 0.0722));
half3 hatch = Hatching(uv.xy * 8, intensity);
col.rgb = hatch;
return col;
}
如果使用上面的着色器,我们之前对非常亮的地方的调整现在这不会起作用了,这是因为缓存的精度:我们的主相机只会输出在1以内的值,所以不会产生亮度超过1的地方。但你可以使这种情况产生-你要使主相机使用高精度的帧缓存,还需要让每个模型的着色器输出half或者float类型的值-但这违背了我们不改变单个模型着色器的原则,这超出了这篇文章的讲解范围。
性能
在我的IPone6上,渲染上面的场景很快(几乎和渲染漫反射材质的着色器一样快)。然而开启这个特效花了4ms。这可能是由于我们使用了4张纹理(主相机、UV缓存、2张阴影线纹理)和一些着色器中无意义的数学计算(在全分辨率的情况下)。
我没做任何在PC平台做任何性能测试,因为手机通常落后电脑5年。我的朋友说手机需要4ms来运行,我感觉我的电脑也花不了多长时间。
结论
首先,我的所有代码都会在Github上。
如果你想在实际项目中使用这个特效,还有许多潜在的问题。例如,处理非统一缩放的模型时,会产生奇怪的问题,特别是你不想传递缩放因子到模型的材质中来关闭静态批处理。我想你可以把缩放因子编码到顶点颜色中,你需要在烘焙前调整你的模型。
你团队的美术人员可能想要用不同风格的阴影线条来自定义TAM,或者需要每个模型都有不同的TAM,现在的结果对他们来说很可能并不满意。