【GAD翻译馆】一种铅笔素描效果

发表于2017-09-15
评论15 8.6k浏览

者: 刘超(君临天下) 审校:梁君(君儿)

有很多的效果一直模糊的存在在我的脑海中,而且存在有一天,我将会实现它们这种想法。这些效果包括:使用遗传算法将图像转换为三角面片,Portals,程序云以及这周末我决定实现的效果:Real Time Hatching(或者类似的东西)!

      RealTime Hatching是一种幻想的(更加简洁)的描述渲染效果的一种方式,使得场景看起来像是手绘的一样。这个效果实际上非常简单,但是它是非常有趣的并且提供了很好的用来讨论整数/半浮点数/浮点数的精度问题。

      我将会介绍该效果基本的原理,使得你可以写一个着色器来将该效果附着在单个物体上,以及如何将它转换为后置效果使得可以用于整个场景的渲染,在这个过程中会走一些弯路。本文提供的所有代码都可以用于Unity5.5,如果你使用不同的版本,可能使用会有所不同。


色调艺术映射

      在我们进行任何事情之前,我们需要讨论Real Time Hatching背后的基本原理。整个效果基于色调艺术映射(Tonal Art Maps, TAMs)的概念。这里有一些纹理,与你想要在不同的光照强度下的艺术效果一一对应。关于它们棘手的部分是,为了使它们看起来是正确的,每一个纹理都需要包含存储在所有映射中的所有的信息,不同的映射对应着较亮的色调。因此,你的第二个最亮的映射需要包含你的最亮的映射的所有的纹理数据,这些增加的数据使得映射变得更黑了。

      使用文字描述时有点复杂,但是当你看到这些纹理时,问题变得更加直观了。接下来的部分大量的引用了这篇文章(链接,介绍了我们今天将要用到的技术)。

如你所见,每一个映射都代表了一个艺术家在一张纸上的素描线条。较暗的映射包含了所有的来自较亮区域的素描线条。如果当你创建你自己的映射时没有遵守这些规则,这些线条将不能很好的彼此融合在一起,那么最终你将会得到一些看起来非常奇怪的线条。

为了让我们使用一个“合适”的TAM,我们需要更进一步的根据上述规则对hatching纹理进行简化。我们也需要提供自定义的mips,这里提供一个他们给出的示例:

来自:http://hhoppe.com/hatching.pdf

实际上,我将跳过所有的关于自定义mip纹理的内容,因为我不想创建自己的TAM生成器,因为我对本文实现的效果的兴趣点仅仅是弄清楚它是如何工作的,而不是使用它创建一个商业产品。确信的是,只要你肯花时间来创建自定义的mips,那么效果肯定看起来不错。如果你想要一个看起来可以工作的TAM生成器,我找到一个链接

好吧,这些并不是直接的输出,而是大量的源码,但是不管如此,我们现在有了我们自己的TAM图像。我们可以真正的开始创建这个效果了。


单个物体着色器

      现在我们有了自己的TAM,我们需要创建一个着色器来使用它们。我之前引用的这篇文章介绍了一种使用6个纹理查询表将一组TAMs映射到一个物体上的方法,因为(非常重要),你可以将6个查询表存储在2个纹理中。这是非常重要的一件事情,因为当人们进行实时hatching渲染时,这将会节省大量的时间:不要为hatching添加6个纹理查询表到你的着色器中。而是将这些纹理存储在2RGB纹理通道中。

      对于如何存储TAM纹理,我写了一个临时使用的Unity工具。源码放在这里有些费时,但是你可以在文章的最后通过github进行访问,或者通过gist访问。

      我使用这个工具对上述6TAM图像进行了存储,效果如下:

      这将会有效的节省存储空间。现在我们需要了解着色器是如何工作的。

      显然,我们将要在两个纹理中对6个通道进行融合,但是如何操作才能做得漂亮呢?在我们开始之前,我们先了解一下着色器的基本架构。再次重申一下,我们将要写一个用于单个对象的着色器。下面是一些设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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上找到。但是希望上述代码对于理解来说是足够用的。这里展示的是一个标准的漫反射着色器。当你想要在实际工程中添加不止一个定向光源时,hatching能够对于任何输入的光源进行很好的逻辑处理,因此这里使用单个光源进行介绍。

      我们需要做的第一件事情是获取一个标量值来表示所有的光照应用中使用的着色片段。这里需要和以下常向量(0.23260.715200722)进行点积操作。

1
half intensity = dot(diffuse, half3(0.2326, 0.7152, 0.0722));

该常向量的值来自于光照函数,并且在理论上我们将要相乘的这个向量需要转换到线性空间中。根据你使用的平台,你可能会需要用到它,也可能不会用到。为了简单起见,我将会忽略它,仅考虑大多数光源的光照计算。如果没有在线性空间中进行计算,那么你将为了提高性能而牺牲了正确性。

      另外,请注意,我们使用半浮点数来计算该值。尽管你可能无法看到使用浮点数相对于使用整数带来的计算精度上的差别,但是11位整数变量精度仅仅可以达到大约0.0039(也就是1/256),并且我们需要用到的光照强度值的精度要远高于此。如果你使用半浮点数,你将无法使用一个半浮点数值来完整的正确的存储0.7152,相差较大。(如果你对此感兴趣,你可以在这里找到更多的关于半浮点数精度的对比)

      如果我们将这一行代码加入到我们的着色器中,并将结果进行输出,我们将会得到一个非常漂亮的灰度效果:

      现在我们需要做的是将标量强度值转换为一个hatch采样纹理。我们有6hatch通道。这意味着将会有6个不同强度的值被映射到仅包含1hatch纹理的采样器上(1/6 2/6 3/6 4/6 5/6 6/6)。任何不属于这些精确值的值需要借助于两个纹理进行混合。其含义是指对于光照强度值为1.5/60.25)将会需要我们通过两个对应值为1/62/6的纹理进行混合。其过程如下图所示:

      然而,对于我们很不幸的是,这些GPU(或者至少是移动设备上的GPU)在处理分支逻辑时并不是很优秀。因此将上述过程通过if控制语句来实现看起来更加简单:

1
2
3
4
5
6
7
8
9
10
11
12
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 ...

我们真的不想在着色器中这样编程,因为这样将造成很大一部分不必要的性能浪费。取而代之的是,我们想要像如下代码一样进行编程:

1
2
3
4
5
6
fixed3 rgb;
fixed3 hatch = tex2D(hatch0, uv);
rgb = hatch.r * weight0;
rgb = hatch,g * weight1;
rgb = hatch.b * weight2;
...

      注意在这两种情况下,我们是如何对相同数量的纹理采样器进行操作的,但是第二种情况不包含任何的分支操作。我们需要做的是计算我们用来进行乘法操作的权值,因此我们只需要从hatch纹理中获取我们想要的纹理即可。如果这些创建的权值能够保证对于我们想要的纹理来说加权和为1,而对于其它纹理加权和为0,这将是非常好的结果。

      让我们看一下如何才能做到这点。再次强调,我们有6个需要计算权值的纹理,因此我们需要将强度值和6个数字进行比较来决定这些权值。我们将强度值之间的差值和每一个比较结果存储在2half3中。如下所示:

1
2
3
4
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进行上述验证:

1
2
3
4
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.1,2.5)
half3 weights1 = intensity3 - half3(3,4,5); //(1.5,0.5,-0.5)

      可以看出,有一些权值是在0-1范围之外的,这对于我们后续的操作没有任何帮助,因此我们使用数学中的saturate函数重新计算得到如下结果:

1
2
3
4
5
6
7
8
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

      幸运的是,所有的这些仅需要一些相减操作即可完成:

1
2
3
weights0.xy -= weights0.yz;
weights0.z -= weights1.x;
weights1.xy -= weights1.zy;

      这样操作很漂亮,对吧?使用我们的示例值0.75,我们得到了2个权值向量(0,0,0)(0.5,0.5,0.0),这意味着对于一个4.5的输入是通过第4个和第5个纹理采样器的50%的混合得到的,这正是我们想要得到的结果。

      至此,我们得到了想要的权值,剩下的工作仅仅是一些乘法和加法操作:

1
2
3
4
5
6
7
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;

      在将这些数字相加之前,我们可以通过对乘法操作进行矢量化来进一步优化:

1
2
3
4
5
6
7
8
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;

      在上述操作中,我们需要注意两件事情。第一件事情是如何处理黑色。因为我们的效果依赖如下关系的维持,较少的光照==更密集的铅笔线条,我们无法将黑色视为单独的纹理采样,因为当我们在最黑的纹理和纯黑色之间进行移动时,我们不会再增加任何线条。取而代之的时,当我们在两个最黑的纹理采样器之间进行混合时,我们真正要做的是(darkestTexture * 1.0 - i) (2ndDarkest * i)。这是上述的公式表示,但不是很直观。

      第二件事情是,你可能已经意识到,上述的所有都依赖一个非常大的假设:我们的光照强度永远不会超过1.0。当然,这些都是废话,但是这个假设使得我们的数学计算变得更加容易,并且当光照非常明亮时使得我们更加接近纯白色。在我们数学计算的开始,我们仅需要存储max(0, intensity-1.0),并且将它添加至最后。对于小于1.0的值,这将为0,并且对于任何非常亮的东西,将会让我们得到纯白色。

      总体来讲,hatching函数看起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
}

      将上述代码用于像素着色器,如下所示:

1
2
3
4
5
6
7
8
9
10
11
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材质作为结束。

      最后需要注意的一件事情是,我们将输入的UVs乘以8,然后将结果传给hatch函数。这是一个很好的技巧,因为我认为这对我使用的hatch纹理看起来更好。当然,你也可以生成你自己的TAM


后处理效果

      现在我们得到了基本的效果,是时候做一些更让人兴奋的事情了。将这部分工作转至后处理效果可以使得在工程应用中更加的简单。并且做一些有趣的事情,例如和其它效果的集成,或者做一些小插图。

      但是现在,我只想将它变成一个普通的全屏铅笔画效果。

      这是非常简单的。我们已经在主程序中使用光照对整个场景进行了渲染,这意味着我们可以从这里提升光照强度值。它的优势是可以让我们的铅笔画场景使用更加复杂的材质或者使用Unity的动态GI,这将不需要我们进行任何思考。除此之外,我们唯一需要的事情是我们用于着色的物体的UVs

      和图形学的处理一样,我们首先需要做一些设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[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);
}

      如果你对我之前的博客熟悉的话,这些内容看起来也会非常熟悉。所有的这些我们正在做的是设置我们的效果使用第二台相机,并且更新一些变量来扩展我们需要创建的任何缓冲区。最有趣的内容从OnRenderImage开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}

      再次声明,在大多数情况下,这和之前的效果设置是一样的。我们将所需要的设置从主相机拷贝至效果相机,创建一个临时的缓冲区来渲染UVs,然后再渲染场景的UVs

      一旦我们得到了UV缓存,我们将它传递给混合着色器,然后完成剩余的工作。

      当进行UV缓存区的渲染时,非常容易出错。使用UV时,我们需要比我们能够存储在默认RT纹理中更高的精度。是否还记得我之前的讨论需要将光照固定值存储在half3中,因为fixed3无法提供足够的精度?对于UV要加倍小心。如果你忘记了这些,尝试着将UV输出至一个常规的缓存区,将会发生混乱:

左图:错误精度,有图:正确精度

      由于我们使用浮点数缓存区,这意味着我们的片段着色器需要返回一个浮点数,因此,我们的UV替换着色器看起来如下所示:

1
2
3
4
5
6
float4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv;
 
    return float4(i.uv.x, i.uv.y, _MainTex_ST.x, _MainTex_ST.y);
}

      在这里我花点时间将平铺和偏移信息从主纹理导出,以便后续希望可以使用这些信息来获得更精确的效果。

      最终,混合着色器非常的简单,现在hatching函数看起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.0,因此,额外的信息在被我们使用之前就被截断了。你当然可以实现这一点——你会需要主相机来渲染高精度的缓存区,并且使用一个着色器处理独立的单元来输出halfs值和floats值,但是这违背了不去改变着色对象这一原则。


性能

      iphone6上,渲染你在本文的开始看到的那一个gif图片中的场景,对于每一个机器人使用hatching着色器,执行是相当快的(几乎与使用漫反射着色器进行渲染的速度是完全一样的)。然而,在开启后处理效果之后,对于渲染时间增加了4ms的延迟。这可能是由于我们进行了4个纹理的查找操作(主相机、UV缓存区、2hatch纹理)并且在混合着色器中有着很多不可忽略的数学运算(有着较高的分辨率)。

      我没有在桌面设备上进行任何的测试,主要是因为我在移动设备上工作了半年之后,我能够很容易的在手机上进行开发。我得直觉告诉我手机能够在4ms内响应任何操作,而我的笔记本基本上不可能,但是这也仅仅是我得直觉而已。


结论

      首先,本文提到的所有的代码都能够在github上找到。它是基于GPL协议的,因为据我所知,我用到的hatch图像都是基于GPL协议发布的。

      如果你在实际的工程项目中使用这个效果,你将会遇到很多潜在的问题。例如,处理非均匀的对象尺度可能会产生一些奇怪的问题,尤其如果你不想要通过传递尺度给不同的对象材质来影响静态批处理时。我认为你能够通过将对象的尺度进行编码来存储到它们对应的颜色向量中来解决这一问题,但是如果在准备阶段得知目标对象的尺度,你应该尽可能的先调整网格的尺寸。

      实际上,本文介绍的效果可能无法让你的艺术团队感到高兴。我想你可能会遇到一些艺术家想要使用不同类型的线条来自定义TAMs,然后将他们映射到每一个对象上来控制使用不同类型的线条。

      这件事情是非常的有趣的!如果你有任何的问题,请在twitter上给我发消息,我非常乐意可以看到更多的项目来使用这种类型的效果,因此如果你构建了任何相关的项目请发送截图给我。


 【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

标签: