Compute Shader学习笔记(二)之 后处理效果

前言

初步认识了Compute Shader,实现一些简单的效果。所有的代码都在:

https://github.com/Remyuu/Unity-Compute-Shader-Learngithub.com/Remyuu/Unity-Compute-Shader-Learn

main分支是初始代码,可以下载完整的工程跟着我敲一遍。PS:每一个版本的代码我都单独开了分支。

这一篇文章学习如何使用Compute Shader制作:

上一篇文章没有提及GPU的架构,是因为我觉得一上来就解释一大堆名词根本听不懂QAQ。有了实际编写Compute Shader的经验,就可以将抽象的概念和实际的代码联系起来。

CUDA在GPU上的执行程序可以用三层架构来说明:

Thread是GPU最基础的单元,不同Thread中自然就会有信息交换。为了有效地支持大量并行线程的运行,并解决这些线程之间的数据交换需求,内存被设计成多个层次。因此存储角度也可以分为三层:

如果超过内存大小限制,则会被推到容量更大但是更慢的存储空间上。

Shared Memory和L1 cache共享同一个物理空间,但是功能上有区别:前者需要手动管理,后者由硬件自动管理。我的理解是,Shared Memory 功能上类似于一个可编程的L1缓存。

在NVIDIA的CUDA架构中,流式多处理器(Streaming Multiprocessor, SM)是GPU上的一个处理单元,负责执行分配给它的线程块(Blocks)中的线程。流处理器(Stream Processors),也称为“CUDA核心”,是SM内的处理元件,每个流处理器可以并行处理多个线程。总的来说:

即,GPU包含多个SM(也就是多处理器),每个SM包含多个流处理器。每个流处理器负责执行一个或多个线程(Thread)的计算指令。

在GPU中,Thread(线程)是执行计算的最小单元,Warp(纬度)是CUDA中的基本执行单位。

在NVIDIA的CUDA架构中,每个Warp通常包含32个线程(AMD有64个)。Block(块)是一个线程组,包含多个线程。在CUDA中,一个Block可以包含多个WarpKernel(内核)是在GPU上执行的一个函数,你可以将其视为一段特定的代码,这段代码被所有激活的线程并行执行。总的来说:

但在日常开发中,通常需要同时执行的线程(Threads)远超过32个。

为了解决软件需求与硬件架构之间的数量不匹配问题,GPU采用了一种策略:将属于同一个块(Block)的线程分组。这种分组被称为“Warp”,每个Warp包含固定数量的线程。当需要执行的线程数量超过一个Warp所能包含的数量时,GPU会调度额外的Warp。这样做的原则是确保没有任何线程被遗漏,即便这意味着需要启动更多的Warp。

举个例子,如果一个块(Block)有128个线程(Thread),并且我的显卡身穿皮夹克(Nvidia每个Warp有32个Thread),那么一个块(Block)就会有 128/32=4 个Warp。举一个极端的例子,如果有129个线程,那么就会开5个Warp。有31个线程位置将直接空闲!因此我们在写Compute Shader时,[numthreads(a,b,c)] 中的 abc 最好是32的倍数,减少CUDA核心的浪费。

读到这里,想必你一定会很混乱。我按照个人的理解画了个图。若有错误请指出。

L3 后处理效果

当前构建基于BIRP管线,SRP管线只需要修改几处代码。

这一章关键在于构建一个抽象基类管理Compute Shader所需的资源(第一节)。然后基于这个抽象基类,编写一些简单的后处理效果,比如高斯模糊、灰阶效果、低分辨率像素效果以及夜视仪效果等等。这一章的知识点的小总结:

1. 介绍与准备工作

后处理效果需要准备两张贴图,一个只读,另一个可读写。至于贴图从哪来,都说是后处理了,那肯定从相机身上获取贴图,也就是Camera组件上的Target Texture。

由于后续会实现多种后处理效果,因此抽象出一个基类,减少后期工作量。

在基类中封装以下特性:

抽象类完整代码链接:https://pastebin.com/9pYvHHsh

首先,当脚本实例被激活或者附加到活着的GO的时候,调用 OnEnable() 。在里面写初始化的操作。检查硬件是否支持、检查Compute Shader是否在Inspector上绑定、获取指定的Kernel、获取当前GO的Camera组件、创建纹理以及设置初始化状态为真。

if (!SystemInfo.supportsComputeShaders)
    ...
if (!shader)
    ...
kernelHandle = shader.FindKernel(kernelName);
thisCamera = GetComponent<Camera>();
if (!thisCamera)
    ...
CreateTextures();
init = true;

创建两个纹理 CreateTextures() ,一个Source一个Destination,尺寸为摄像机分辨率。

texSize.x = thisCamera.pixelWidth;
texSize.y = thisCamera.pixelHeight;
if (shader)
{
    uint x, y;
    shader.GetKernelThreadGroupSizes(kernelHandle, out x, out y, out _);
    groupSize.x = Mathf.CeilToInt((float)texSize.x / (float)x);
    groupSize.y = Mathf.CeilToInt((float)texSize.y / (float)y);
}
CreateTexture(ref output);
CreateTexture(ref renderedSource);
shader.SetTexture(kernelHandle, "source", renderedSource);
shader.SetTexture(kernelHandle, "outputrt", output);

具体纹理的创建:

protected void CreateTexture(ref RenderTexture textureToMake, int divide=1)
{
    textureToMake = new RenderTexture(texSize.x/divide, texSize.y/divide, 0);
    textureToMake.enableRandomWrite = true;
    textureToMake.Create();
}

这样就完成初始化了,当摄像机完成场景渲染并准备显示到屏幕上时,Unity会调用 OnRenderImage() ,这个时候就开始调用Compute Shader开始计算了。若当前没初始化好或者没shader,就Blit一下,把source直接拷给destination,即啥也不干。 CheckResolution(out _) 这个方法检查渲染纹理的分辨率是否需要更新,如果要,就重新生成一下Texture。完事之后,就到了老生常谈的Dispatch阶段啦。这里就需要将source贴图通过Buffer传给GPU,计算完毕后,传回给destination。

protected virtual void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    if (!init || shader == null)
    {
        Graphics.Blit(source, destination);
    }
    else
    {
        CheckResolution(out _);
        DispatchWithSource(ref source, ref destination);
    }
}

注意看,这里我们没有用什么 SetData() 或者是 GetData() 之类的操作。因为现在所有数据都在GPU上,我们直接命令GPU自产自销就好了,CPU不要趟这滩浑水。如果将纹理取回内存,再传给GPU,性能就相当糟糕。

protected virtual void DispatchWithSource(ref RenderTexture source, ref RenderTexture destination)
{
    Graphics.Blit(source, renderedSource);
    shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);
    Graphics.Blit(output, destination);
}

我不信邪,非得传回CPU再传回GPU,测试结果相当震惊,性能竟然差了4倍以上。因此我们需要减少CPU和GPU之间的通信,这是使用Compute Shader时非常需要关心的。

// 笨蛋方法
protected virtual void DispatchWithSource(ref RenderTexture source, ref RenderTexture destination)
{
    // 将源贴图Blit到用于处理的贴图
    Graphics.Blit(source, renderedSource);
    // 使用计算着色器处理贴图
    shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);
    // 将输出贴图复制到一个Texture2D对象中,以便读取数据到CPU
    Texture2D tempTexture = new Texture2D(renderedSource.width, renderedSource.height, TextureFormat.RGBA32, false);
    RenderTexture.active = output;
    tempTexture.ReadPixels(new Rect(0, 0, output.width, output.height), 0, 0);
    tempTexture.Apply();
    RenderTexture.active = null;
    // 将Texture2D数据传回GPU到一个新的RenderTexture
    RenderTexture tempRenderTexture = RenderTexture.GetTemporary(output.width, output.height);
    Graphics.Blit(tempTexture, tempRenderTexture);
    // 最终将处理后的贴图Blit到目标贴图
    Graphics.Blit(tempRenderTexture, destination);
    // 清理资源
    RenderTexture.ReleaseTemporary(tempRenderTexture);
    Destroy(tempTexture);
}

接下来开始编写第一个后处理效果。

小插曲:奇怪的BUG

另外插播一个奇怪bug。

在Compute Shader中,如果最终输出的贴图结果名字是output,那么在某些API比如Metal中,就会出问题。解决方法是,改个名字。

RWTexture2D<float4> outputrt;

添加图片注释,不超过 140 字(可选)

2. RingHighlight效果

创建RingHighlight类,继承自刚刚编写的基类。

重载初始化方法,指定Kernel。

protected override void Init()
{
    center = new Vector4();
    kernelName = "Highlight";
    base.Init();
}

重载渲染方法。想要实现聚焦某个角色的效果,则需要给Compute Shader传入角色的屏幕空间的坐标 center 。并且,如果在Dispatch之前,屏幕分辨率发生改变,那么重新初始化。

protected void SetProperties()
{
    float rad = (radius / 100.0f) * texSize.y;
    shader.SetFloat("radius", rad);
    shader.SetFloat("edgeWidth", rad * softenEdge / 100.0f);
    shader.SetFloat("shade", shade);
}
protected override void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    if (!init || shader == null)
    {
        Graphics.Blit(source, destination);
    }
    else
    {
        if (trackedObject && thisCamera)
        {
            Vector3 pos = thisCamera.WorldToScreenPoint(trackedObject.position);
            center.x = pos.x;
            center.y = pos.y;
            shader.SetVector("center", center);
        }
        bool resChange = false;
        CheckResolution(out resChange);
        if (resChange) SetProperties();
        DispatchWithSource(ref source, ref destination);
    }
}

并且改变Inspector面板的时候可以实时看到参数变化效果,添加 OnValidate() 方法。

private void OnValidate()
{
    if(!init)
        Init();
    SetProperties();
}

GPU中,该怎么制作一个圆内没有阴影,圆的边缘平滑过渡,过渡层外是阴影的效果呢?基于上一篇文章判断一个点是否在圆内的方法,我们用 smoothstep() ,处理过渡层即可。

#pragma kernel Highlight

Texture2D<float4> source;
RWTexture2D<float4> outputrt;
float radius;
float edgeWidth;
float shade;
float4 center;

float inCircle( float2 pt, float2 center, float radius, float edgeWidth ){
    float len = length(pt - center);
    return 1.0 - smoothstep(radius-edgeWidth, radius, len);
}

[numthreads(8, 8, 1)]
void Highlight(uint3 id : SV_DispatchThreadID)
{
    float4 srcColor = source[id.xy];
    float4 shadedSrcColor = srcColor * shade;
    float highlight = inCircle( (float2)id.xy, center.xy, radius, edgeWidth);
    float4 color = lerp( shadedSrcColor, srcColor, highlight );

    outputrt[id.xy] = color;

}

当前版本代码:

3. 模糊效果

模糊效果原理很简单,每一个像素采样周边的 n*n 个像素加权平均就可以得到最终效果。

但是有效率问题。众所周知,减少对纹理的采样次数对优化非常重要。如果每个像素都需要采样20*20个周边像素,那么渲染一个像素就需要采样400次,显然是无法接受的。并且,对于单个像素而言,采集周边一整个矩形像素的操作在Compute Shader中很难处理。怎么解决呢?

通常做法是,横着采样一遍,再竖着采样一遍。什么意思呢?对于每一个像素,只在x方向上采样20个像素,y方向上采样20个像素,总共采样20+20个像素,再加权平均。这种方法不仅减少了采样次数,还更符合Compute Shader的逻辑。横着采样,设置一个Kernel;竖着采样,设置另一个Kernel。

#pragma kernel HorzPass
#pragma kernel Highlight

由于Dispatch是顺序执行的,因此我们计算完水平的模糊后,利用计算好的结果再垂直采样一遍。

shader.Dispatch(kernelHorzPassID, groupSize.x, groupSize.y, 1);
shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);

做完模糊操作之后,再结合上一节的RingHighlight,完工!

有一点不同的是,再计算完水平模糊后,怎么将结果传给下一个Kernel呢?答案呼之欲出了,直接使用 shared 关键词。具体步骤如下。

CPU中声明存储水平模糊纹理的引用,制作水平纹理的kernel,并绑定。

RenderTexture horzOutput = null;
int kernelHorzPassID;
protected override void Init()
{
    ...
    kernelHorzPassID = shader.FindKernel("HorzPass");
    ...
}

还需要额外在GPU中开辟空间,用来存储第一个kernel的结果。

protected override void CreateTextures()
{
    base.CreateTextures();
    shader.SetTexture(kernelHorzPassID, "source", renderedSource);
    CreateTexture(ref horzOutput);
    shader.SetTexture(kernelHorzPassID, "horzOutput", horzOutput);
    shader.SetTexture(kernelHandle, "horzOutput", horzOutput);
}

GPU上这样设置:

shared Texture2D<float4> source;
shared RWTexture2D<float4> horzOutput;
RWTexture2D<float4> outputrt;

另外有个疑问, shared 这个关键词好像加不加都一样,实际测试不同的kernel都可以访问到。那请问shared还有什么意义呢?

在Unity中,变量前加shared表示这个资源不是每次调用都重新初始化,而是保持其状态,供不同的shader或dispatch调用使用。这有助于在不同的shader调用之间共享数据。标记了 shared 可以帮助编译器优化出更高性能的代码。

在计算边界的像素时,会遇到可用像素数量不足的情况。要么就是左边剩下的像素不足 blurRadius ,要么右边剩余像素不足。因此先算出安全的左索引,然后再计算从左到右最大可以取多少。

[numthreads(8, 8, 1)]
void HorzPass(uint3 id : SV_DispatchThreadID)
{
    int left = max(0, (int)id.x-blurRadius);
    int count = min(blurRadius, (int)id.x) + min(blurRadius, source.Length.x - (int)id.x);
    float4 color = 0;
    uint2 index = uint2((uint)left, id.y);
    [unroll(100)]
    for(int x=0; x<count; x++){
        color += source[index];
        index.x++;
    }
    color /= (float)count;
    horzOutput[id.xy] = color;
}
[numthreads(8, 8, 1)]
void Highlight(uint3 id : SV_DispatchThreadID)
{
    //Vert blur
    int top = max(0, (int)id.y-blurRadius);
    int count = min(blurRadius, (int)id.y) + min(blurRadius, source.Length.y - (int)id.y);
    float4 blurColor = 0;
    uint2 index = uint2(id.x, (uint)top);
    [unroll(100)]
    for(int y=0; y<count; y++){
        blurColor += horzOutput[index];
        index.y++;
    }
    blurColor /= (float)count;
    float4 srcColor = source[id.xy];
    float4 shadedBlurColor = blurColor * shade;
    float highlight = inCircle( (float2)id.xy, center.xy, radius, edgeWidth);
    float4 color = lerp( shadedBlurColor, srcColor, highlight );
    outputrt[id.xy] = color;
}

当前版本代码:

4. 高斯模糊

和上面不同的是,采样之后不再是取平均值,而是用一个高斯函数加权求得。

其中, 是标准差,控制宽度。

有关更多Blur的内容:https://www.gamedeveloper.com/programming/four-tricks-for-fast-blurring-in-software-and-hardware#close-modal

由于这个计算量还有不小的,如果每一个像素都去计算一次这个式子就非常耗。我们用预计算的方式,将计算结果通过Buffer的方式传到GPU上。由于两个kernel都需要使用,在Buffer声明的时候加一个shared。

float[] SetWeightsArray(int radius, float sigma)
{
    int total = radius * 2 + 1;
    float[] weights = new float[total];
    float sum = 0.0f;
    for (int n=0; n<radius; n++)
    {
        float weight = 0.39894f * Mathf.Exp(-0.5f * n * n / (sigma * sigma)) / sigma;
        weights[radius + n] = weight;
        weights[radius - n] = weight;
        if (n != 0)
            sum += weight * 2.0f;
        else
            sum += weight;
    }
    // normalize kernels
    for (int i=0; i<total; i++) weights[i] /= sum;
    return weights;
}
private void UpdateWeightsBuffer()
{
    if (weightsBuffer != null)
        weightsBuffer.Dispose();
    float sigma = (float)blurRadius / 1.5f;
    weightsBuffer = new ComputeBuffer(blurRadius * 2 + 1, sizeof(float));
    float[] blurWeights = SetWeightsArray(blurRadius, sigma);
    weightsBuffer.SetData(blurWeights);
    shader.SetBuffer(kernelHorzPassID, "weights", weightsBuffer);
    shader.SetBuffer(kernelHandle, "weights", weightsBuffer);
}

完整代码:

5. 低分辨率效果

GPU:真是酣畅淋漓的计算啊。

让一张高清的纹理边模糊,同时不修改分辨率。实现方法很简单,每 n*n 个像素,都只取左下角的像素颜色即可。利用整数的特性,id.x索引先除n,再乘上n就可以了。

uint2 index = (uint2(id.x, id.y)/3) * 3;
float3 srcColor = source[index].rgb;
float3 finalColor = srcColor;

效果已经放在上面了。但是这个效果太锐利了,通过添加噪声,柔化锯齿。

uint2 index = (uint2(id.x, id.y)/3) * 3;
float noise = random(id.xy, time);
float3 srcColor = lerp(source[id.xy].rgb, source[index],noise);
float3 finalColor = srcColor;

每 n*n 个格子的像素不在只取左下角的颜色,而是取原本颜色和左下角颜色的随机插值结果。效果一下子就精细了不少。当n比较大的时候,还能看到下面这样的效果。只能说不太好看,但是在一些故障风格道路中还是可以继续探索。

如果想要得到噪声感的画面,可以尝试lerp的两端添加系数,比如:

float3 srcColor = lerp(source[id.xy].rgb * 2, source[index],noise);

6. 灰阶效果与染色

Grayscale Effect & Tinted

将彩色图像转换为灰阶图像的过程涉及将每个像素的RGB值转换为一个单一的颜色值。这个颜色值是RGB值的加权平均值。这里有两种方法,一种是简单平均,一种是符合人眼感知的加权平均。

  1. 平均值法(简单但不准确):

这种方法对所有颜色通道给予相同的权重。 2. 加权平均法(更准确, 反映人眼感知):

这种方法根据人眼对绿色更敏感、对红色次之、对蓝色最不敏感的特点, 给予不同颜色通道不同的权重。(下面的截图效果不太好,我也没看出来lol)

加权后,再简单地颜色混合(乘法),最后lerp得到可控的染色强度结果。

uint2 index = (uint2(id.x, id.y)/6) * 6;
float noise = random(id.xy, time);
float3 srcColor = lerp(source[id.xy].rgb, source[index],noise);
// float3 finalColor = srcColor;
float3 grayScale = (srcColor.r+srcColor.g+srcColor.b)/3.0;
// float3 grayScale = srcColor.r*0.299f+srcColor.g*0.587f+srcColor.b*0.114f;
float3 tinted = grayScale * tintColor.rgb;
float3 finalColor = lerp(srcColor, tinted, tintStrength);
outputrt[id.xy] = float4(finalColor, 1);

染一个废土颜色:

7. 屏幕扫描线效果

首先 uvY 将坐标归一化到 [0,1] 。

lines 是控制扫描线数量的一个参数。

然后增加一个时间偏移,系数控制偏移速度。可以开放一个参数控制线条偏移的速度。

float uvY = (float)id.y/(float)source.Length.y;
float scanline = saturate(frac(uvY * lines + time * 3));

这个“线”看起来不太够“线”,减个肥。

float uvY = (float)id.y/(float)source.Length.y;
float scanline = saturate(smoothstep(0.1,0.2,frac(uvY * lines + time * 3)));

然后lerp上颜色。

float uvY = (float)id.y/(float)source.Length.y;
float scanline = saturate(smoothstep(0.1, 0.2, frac(uvY * lines + time*3)) + 0.3);
finalColor = lerp(source[id.xy].rgb*0.5, finalColor, scanline);

“减肥”前后,各取所需吧!

8. 夜视仪效果

这一节总结上面所有内容,实现一个夜视仪的效果。先做一个单眼效果。

float2 pt = (float2)id.xy;
float2 center = (float2)(source.Length >> 1);
float inVision = inCircle(pt, center, radius, edgeWidth);
float3 blackColor = float3(0,0,0);
finalColor = lerp(blackColor, finalColor, inVision);

双眼效果不同点在于有两个圆心,计算得到的两个遮罩vision用 max() 或者是 saturate() 合并即可。

float2 pt = (float2)id.xy;
float2 centerLeft = float2(source.Length.x / 3.0, source.Length.y /2);
float2 centerRight = float2(source.Length.x / 3.0 * 2.0, source.Length.y /2);
float inVisionLeft = inCircle(pt, centerLeft, radius, edgeWidth);
float inVisionRight = inCircle(pt, centerRight, radius, edgeWidth);
float3 blackColor = float3(0,0,0);
// float inVision = max(inVisionLeft, inVisionRight);
float inVision = saturate(inVisionLeft + inVisionRight);
finalColor = lerp(blackColor, finalColor, inVision);

当前版本代码:

9. 平缓过渡线条

思考一下,我们应该怎么在屏幕上画一条平滑过渡的直线。

smoothstep() 函数可以完成这个操作,熟悉这个函数的读者可以略过这一段。这个函数用来创建平滑的渐变。smoothstep(edge0, edge1, x) 函数在x在 edge0 和 edge1 之间时,输出值从0渐变到1。如果 x < edge0 ,返回0;如果 x > edge1 ,返回1。其输出值是根据Hermite插值计算的:

float onLine(float position, float center, float lineWidth, float edgeWidth) {
    float halfWidth = lineWidth / 2.0;
    float edge0 = center - halfWidth - edgeWidth;
    float edge1 = center - halfWidth;
    float edge2 = center + halfWidth;
    float edge3 = center + halfWidth + edgeWidth;
    return smoothstep(edge0, edge1, position) - smoothstep(edge2, edge3, position);
}

上面代码中,传入的参数都已经归一化 [0,1]。position 是考察的点的位置,center 是线的中心位置,lineWidth 是线的实际宽度,edgeWidth 是边缘的宽度,用于平滑过渡。我实在对我的表达能力感到不悦!至于怎么算的,我给大家画个图理解吧!

大概就是:, ,。

思考一下,怎么画一个平滑过渡的圆。

对于每个点,先计算与圆心的距离向量,结果返回给 position ,并且计算其长度返回给 len 。

模仿上面两个 smoothstep 做差的方法,通过减去外边缘插值结果来生成一个环形的线条效果。

float circle(float2 position, float2 center, float radius, float lineWidth, float edgeWidth){
    position -= center;
    float len = length(position);
    //Change true to false to soften the edge
    float result = smoothstep(radius - lineWidth / 2.0 - edgeWidth, radius - lineWidth / 2.0, len) - smoothstep(radius + lineWidth / 2.0, radius + lineWidth / 2.0 + edgeWidth, len);
    return result;
}

10. 扫描线效果

然后一条横线、一条竖线,套娃几个圆,做一个雷达扫描的效果。

float3 color = float3(0.0f,0.0f,0.0f);
color += onLine(uv.y, center.y, 0.002, 0.001) * axisColor.rgb;//xAxis
color += onLine(uv.x, center.x, 0.002, 0.001) * axisColor.rgb;//yAxis
color += circle(uv, center, 0.2f, 0.002, 0.001) * axisColor.rgb;
color += circle(uv, center, 0.3f, 0.002, 0.001) * axisColor.rgb;
color += circle(uv, center, 0.4f, 0.002, 0.001) * axisColor.rgb;

再画一个扫描线,并且带有轨迹。

float sweep(float2 position, float2 center, float radius, float lineWidth, float edgeWidth) {
    float2 direction = position - center;
    float theta = time + 6.3;
    float2 circlePoint = float2(cos(theta), -sin(theta)) * radius;
    float projection = clamp(dot(direction, circlePoint) / dot(circlePoint, circlePoint), 0.0, 1.0);
    float lineDistance = length(direction - circlePoint * projection);
    float gradient = 0.0;
    const float maxGradientAngle = PI * 0.5;
    if (length(direction) < radius) {
        float angle = fmod(theta + atan2(direction.y, direction.x), PI2);
        gradient = clamp(maxGradientAngle - angle, 0.0, maxGradientAngle) / maxGradientAngle * 0.5;
    }
    return gradient + 1.0 - smoothstep(lineWidth, lineWidth + edgeWidth, lineDistance);
}

添加到颜色中。

...
color += sweep(uv, center, 0.45f, 0.003, 0.001) * sweepColor.rgb;
...

当前版本代码:

11. 渐变背景阴影效果

这个效果可以用在字幕或者是一些说明性文字之下。虽然可以直接在UI Canvas中加一张贴图,但是使用Compute Shader可以实现更加灵活的效果以及资源的优化。

字幕、对话文字背景一般都在屏幕下方,上方不作处理。同时需要较高的对比度,因此对原有画面做一个灰度处理、并且指定一个阴影。

if (id.y<(uint)tintHeight){
    float3 grayScale = (srcColor.r + srcColor.g + srcColor.b) * 0.33 * tintColor.rgb;
    float3 shaded = lerp(srcColor.rgb, grayScale, tintStrength) * shade;
    ... // 接下文
}else{
    color = srcColor;
}

渐变效果。

...// 接上文
    float srcAmount = smoothstep(tintHeight-edgeWidth, (float)tintHeight, (float)id.y);
    ...// 接下文

最后再lerp起来。

...// 接上文
    color = lerp(float4(shaded, 1), srcColor, srcAmount);

12. 总结/小测试

If id.xy = [ 100, 30 ]. What would be the return value of inCircle((float2)id.xy, float2(130, 40), 40, 0.1)

When creating a blur effect which answer describes our approach best?

Which answer would create a blocky low resolution version of the source image?

What is smoothstep(5, 10, 6); ?

If an and b are both vectors. Which answer best describes dot(a,b)/dot(b,b); ?

What is _MainTex_TexelSize.x? If _MainTex is 512 x 256 pixel resolution.

13. 利用Blit结合Material做后处理

除了使用Compute Shader制作后处理,还有一种简单的方法。

// .cs
Graphics.Blit(source, dest, material, passIndex);
// .shader
Pass{
    CGPROGRAM
    #pragma vertex vert_img
    #pragma fragment frag
    fixed4 frag(v2f_img input) : SV_Target{
        return tex2D(_MainTex, input.uv);
    }
    ENDCG
}

通过结合Shader来处理图像数据。

那么问题来了,两者有什么区别?而且传进来的不是一张纹理吗,哪来的顶点?

答:

第一个问题。这种方法称为“屏幕空间着色”,完全集成在Unity的图形管线中,性能其实比Compute Shader更高。而Compute Shader提供了对GPU资源的更细粒度控制。它不受图形管线的限制,可以直接访问和修改纹理、缓冲区等资源。

第二个问题。注意看 vert_img 。在UnityCG中可以找到如下定义:

Unity会自动将传进来的纹理自动转换为两个三角形(一个充满屏幕的矩形),我们用材质的方法编写后处理时直接在frag上写就好了。

下一章将会学习如何将Material、Shader、Compute Shader还有C#联系起来。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据