简要

终于有时间把预定了很久的 《unity-shader 入门精要》这本书看完了。 最后再回头来回顾一下整本书的内容, 对不熟悉的部分做一个简单的知识整理。

第二章 渲染流水线

渲染流水线的任务是把3d场景渲染到一张纹理或者屏幕上。

概念化阶段

它分为 应用阶段,几何阶段,光栅化阶段三个阶段。其中每个阶段都是一个流水线, 包含了很多子工作想。

应用阶段

粗粒度的剔除工作。
准备渲染需要的几何信息(准备模型数据,设置渲染状态),包括纹理,材质,shader, 等数据,并把这些数据传递到下一阶段。这部分工作通常在 CPU 进行。

几何阶段

负责把顶点坐标变换到屏幕空间中, 再交给光栅器处理。

光栅化阶段

使用上一阶段的数据来产生屏幕上的像素,并渲染出最终图像。

  1. 决定哪些像素应该会被绘制。
  2. 对逐顶点数据进行插值,得到逐像素数据
  3. 逐像素处理计算

    CPU 与 GPU 通信

  4. 把数据加载到显存中
  5. 设置渲染状态
  6. 调用 DrawCall

    GPU 流水线

    主要包含概念化阶段中的几何阶段与光栅化阶段。包含多个阶段,其中 顶点着色器,几何着色器,片元着色器三个部分是可编程的。剩余的部分也大都是可配置的。
    下面这几行,包含了 GPU 流水线的所有阶段。
    顶点数据 =>
    顶点着色器 => 曲面细分着色器 => 几何着色器 => 裁剪 => 屏幕映射 =>
    三角形设置 => 三角形遍历 => 片元着色器 => 逐片元操作 =>
    屏幕图像

    顶点着色器

    主要的工作是完成坐标变换,需要把顶点坐标从模型空间转换到 齐次裁剪空间。
    齐次裁剪空间的坐标经硬件处理后变换归一化的设备坐标(NDC)。

    裁剪

    根据 NDC 对可视的图元进行裁剪。不在 ((1,1,1), (-1,-1,-1))立方体内的图元会被裁剪掉。

    屏幕映射

    主要工作是把图元的坐标转换到 屏幕坐标系, 会保留 z 坐标。

    三角形设置,三角形遍历

    这两个阶段就是常说的光栅化阶段。
    它会根据上面得到的顶点信息,组装成三角形,并检查每个像素是否在一个三角形中。
    并使用这个三角形的3个顶点信息对整个覆盖区域的像素进行插值, 得到片元信息,并传递给片元着色器使用。

    片元着色器

    片元着色器的任务是根据输入数据,得到一个颜色值。可以是直接从纹理采样得来,也可以经过了光照,雾化处理。

    逐片元操作

    主要任务有:
  7. 决定每个片元的可见性, 深度测试,模板测试。
  8. 混合。
  9. 写入颜色缓冲区

第 3 章 unity shader 基础

shader 与材质

unity shader 是对底层硬件平台着色器的封装, 它定义了渲染所需的各种代码, 属性 和状态设置指令。
材质需要使用shader, 并且能对shader的属性进行设置。unity 提供了材质面板让我们能方便的对 shader 的属性进行修改。

ShaderLab

unity 提供的一种编写 Unity Shader 的说明性语言。它使用了一些嵌套在花括号内部的语义来描述一个Unity Shader文件的结构。
主要包含以下语义:

  1. Properties

    1
    2
    3
    4
    Properties{
    Name ("display name", PropertyType) = DefaultValue
    //更多属性
    }
  2. Subshader

    1
    2
    3
    4
    5
    6
    7
    SubShader{
    [Tags]
    [RenderSetUp]
    Pass {
    }
    //other pass
    }
  3. Pass

    1
    2
    3
    4
    5
    6
    7
    8
    Pass{
    [Name]
    [Tags]
    [RenderSetUp]
    [Code]
    }
    UsePass "path/SHADERNAME"
    GrabPass name
  4. Fallback

    表面着色器

    表面着色器是unity抽象的一种着色器,它让我们按照约定定义一个表面,选用合适的光照模型,并设置一些其他属性, 然后使用这些条件,自动生成出最终的着色器代码。

第 5 章 初识 Unity Shader

内置着色器资源可以在unity官网下载页面下载。unity 提供了很多变量以及宏,功能函数, 帮助我们更方便的实现出想要的效果。

CG/HLSL 语义

语义实际上就是一个赋给Shader输入和输出的字符串,这个字符串表达这个参数的含义,让 Shader 知道从哪里读取数据,并把数据输出到哪里。
unity 支持的常用语义:

  1. 应用阶段传递给顶点着色器:
    POSITION, NORMAL, TANGENT, TEXCOORDn, COLOR
  2. 顶点着色器传递给片元着色器:
    SV_POSITION, COLOR0, COLOR1, TEXCOORD0 - TEXCOORD7
  3. 片元着色器输出
    SV_Target

    Debug

  4. 使用假色图像
  5. Visual Studio
  6. FrameDebugger

    shader 编写实践

  7. 尽可能使用精度较低的类型
  8. 尽可能避免不必要的计算, 或者通过与计算的方式来提供更多的数据
  9. 慎用分支和循环
    如果不可避免需要使用分支语句,那么:
  • 尽可能把计算向上移动, 从片元着色器到顶点着色器到CPU。
  • 分支条件判断语句中的条件变量最好是常数。
  • 每个分支中包含的操作指令数尽可能少。
  • 分支的嵌套层数尽可能少。
  1. 不要除 0

第 6 章 Unity 中的基础光照

标准光照模型 (Phong 光照模型)

分为四个部分:

  • 自发光 emissive
  • 环境光 ambient
  • 漫反射 diffuse
    兰伯特光照模型, 因为漫反射光照符合 lambert's law, 反射光强度与表面法线与光源方向之间的夹角的余弦值成正比。

    1
    C_diffuse = (C_light · M_diffuse) * max(0, n · l)
  • 高光反射 specular
    Phong 模型

    1
    2
    r = 2(n · l) * n - l //反射方向
    C_specular = (C_light · M_specular) * pow(max(0, v * r), gloss);

Blinn 模型
避免计算 r, 引入 h = normalize(n + l);

1
C_specular = (C_light · M_specular) * pow(max(0, n * h), gloss);

逐顶点还是逐像素

逐像素着色 Phong 着色(Phong shading)
逐顶点着色 高洛德 着色(Gouraud shading)

半兰伯特(Half Lambert)光照模型

与兰伯特模型相比,它没有直接使用 max 操作来防止点积为负值, 而是使用了一个缩放函数来调整点积结果的范围,通常情况下 a = b = 0.5;

1
C_diffuse = (C_light · M_diffuse) * (a * (n · l) + b)

第 7 章 基础纹理

单张纹理

使用 _纹理名_ST 的方式来声明一个纹理的属性, xy存储的缩放值, zw存储的偏移值。
使用 tex2D(纹理名, uv)来对纹理进行采样。

纹理属性

Wrap Node

  • Repeat
  • Clamp

    Filter Mode

  • Point
  • Bilinear
  • Trilinear
    纹理被放大时, 采样方法,越往下效果越好,但是消耗也越大。
    Mip Maps
    多级纹理映射, 当纹理缩小的时候, 多个纹理像素对应一个目标像素,也会产生锯齿。 使用 Mip Maps 能比较好的解决这个问题。

凹凸映射

凹凸映射用来为模型提供更多的细节,能够在不改变顶点位置的情况下,让模型看起来像是 “凹凸不平”的。

高度纹理

法线纹理

模型空间法线纹理

实现简单

切线空间法线纹理

  • 自由度很高
  • 可以进行 uv 动画
  • 方便复用
  • 方便压缩
    大多数情况下, 都是使用切线空间下的法线纹理。
    法线主要用于计算表面光照,因为计算光照需要将各个矢量统一在一个坐标空间。因为法线纹理中的法线是存储在切线空间的,所以我们有两个选择。
    在切线空间下计算
    在顶点着色器中,将 观察方向和光源方向 变换到切线空间,然后传到片元着色器中计算。
    在世界空间下计算
    在顶点着色器中,计算出切线空间到世界空间的变换矩阵,然后再片元着色器中使用该矩阵将得到的法线变换到世界空间下, 然后参与计算。
    第一种方法性能优于第二种方法, 因为计算量更少。
    第二种方法通用性优于第一种方法, 因为在实现很多其他效果的时候,都需要将法线变换到世界空间下参与计算。

渐变纹理

可以用渐变纹理,实现更可控的漫反射效果,比如用在卡通渲染中,可以实现大色块的阴影效果。
只需要在实现的时候,根据半兰伯特值,去渐变纹理中采样,然后用这个值参与漫反射颜色计算即可。

遮罩纹理

也是用于美术人员可能更好的控制某个特性的计算, 比如高光反射。
思路就是将计算需要表面属性存储在贴图的某一个通道中, 然后在渲染的时候, 将该信息提取出来参与计算即可。
一张贴图的多个通道可以存储多种不同的表面属性,主要看shader中怎么利用。

第 8 章 透明效果

透明度测试

透明度测试是对于一个像素, 如果一个片元的透明度不满足条件,那么它就会被舍弃。
实现简单, 但是效果很极端,要么完全不透明, 要么完全透明。
不需要什么特殊的处理。

透明度混合

但是对于透明物体, 则不能往深度缓冲中写入深度值,因为如果启用了, 那么透明物体后面的不透明物体就会在深度测试时无法通过,被抛弃掉, 而其实它是可以被看到的。
另外,对于透明物体,深度测试时开启的,这意味着当一个不透明物体出现在透明物体的前面是, 它仍然是可以挡住透明物体的。

渲染顺序

因为深度缓冲的存在, 所以不透明物体不需要关心它的渲染顺序。
对于特定的混合参数,两种不同的颜色进行混合,顺序会影响到最终的颜色值, 所以透明度混合需要保证深度值。
基于上文的描述,渲染引擎通常会对物体进行排序,再渲染

  1. 先渲染所有不透明物体,并开启深度写入 和深度测试, 从前往后渲染, 因为可以通过深度测试, 过滤掉很多不需要渲染的片元。
  2. 将半透明物体按照他们里摄像机的远近进行排序, 然后从后往前顺序渲染这些半透明物体,并开启他们的深度测试,但是关闭深度写入。

unity 中的渲染顺序

在 SubShader 中使用 Queue 标签指定一个索引,索引值越小表示越早被渲染。 unity定义了几个默认的渲染队列, 我们也可以根据需要使用任意索引值。

  • BackGround 1000
    背景, 如 skybox
  • Geometry 2000
    默认队列, 不透明物体使用这个队列
  • AlphaTest 2450
    半透明测试使用这个队列
  • Transparent 2500
    透明物体使用这个队列
  • Overlay 4000
    常用于实现一些叠加效果,如光晕。

混合命令

混合等式

混合等式就是将源颜色,目标颜色以及混合因子经过运算之后得到目标颜色的过程。
下面是使用加法操作进行混合是的混合等式。

1
2
O_rgb = SrcFactor x S_rgb + DstFactor x D_rgb
O_a = SrcFactorA x S_a + DstFactorA x D_a

通过 Blend SrcFactor DstFactor, SrcFactorA DstFactorA 可以设置混合因子。 alpha因子与颜色因子一致时,后半部分可以省略。
支持的混合因子:

  • One
  • Zero
  • SrcColor
  • SrcAlpha
  • DstColor
  • DstAlpha
  • OnMinusSrcColor
  • OnMinusSrcAlpha
  • OnMinusDstColor
  • OnMinusDstAlpha

最常用的普通混合因子组合为 : Blend SrcAlpha OneMinusSrcAlpha

混合操作

通过 BlendOp BlendOperation 命令设置混合操作。
支持的混合操作用

  • Add
  • Sub
  • RevSub
  • Min
  • Max

透明度测试的双面渲染

只需要设置 Cull Off 就行了

透明度混合的双面渲染

为了保证正面和背面的渲染顺序, 可以使用两个 pass, 第一个 pass 只渲染背面,第二个 pass 只渲染正面。就能保证渲染顺序了

第 9 章 更复杂的光照

unity 渲染路径

前向渲染

前向渲染是传统的渲染方式, 也是最常用的渲染路径。
对于每个对象,我们需要渲染该对象的渲染片元,并计算两个缓冲区的信息, 利用深度缓冲来决定片元是否可见, 如果可见, 就更新颜色缓冲区中的颜色值。
对于每个逐像素光源, 都需要进行一次完整的渲染流程。
如果一个物体在多个逐像素光源的影响区域内, 那么该物体就需要执行多个 Pass, 每个 Pass 计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。
通常,渲染引擎会限制每个物体的逐像素光照的数目。

unity 中的前向渲染

unity 中, 前向渲染路径中有三中处理光照的方式: 逐顶点处理, 逐像素处理,球谐函数处理。
决定一个光源使用哪种处理模式取决于它的类型(是否Important)和渲染模式。
在前向渲染中, unity 会根据场景中哥哥光源的设置以及这些光源对物体的影响程度(如距离物体的远近,光照强度)对这些光源进行一个重要度排序。
其中,一定数目的光源会按逐像素的方式处理,然后最多有4个光源按逐顶点的方式处理,剩下的光源可以按SH方式处理。
Unity使用的判断规则如下。

  • 场景中最亮的平行光总是按逐像素处理的。
  • 渲染模式被设置成Not Important的光源,会按逐顶点或者SH处理。
  • 渲染模式被设置成Important的光源,会按逐像素处理。
  • 如果根据以上规则得到的逐像素光源数量小于Quality Setting中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。

Base Pass

1
2
Tags {"LightMode"="ForawdBase"}
#pragma multi_compile_fwdbase

一个逐像素的平行光以及所有逐顶点的 SH 的光源,在这个 Pass 计算
可实现的光照效果: 光照纹理,环境光,自发光, 阴影

####

1
2
3
Tags {"LightMode"="ForawdAdd"}
Blend One One
#pragma multi_compile_fwdadd

其他影响该物体的逐像素光源,每个光源执行一次该 Pass
可实现的光照效果: 默认情况下不支持阴影, 不过把编译命令修改为#pragma multi_compile_fwdadd_fullshadows 可以开启阴影效果

逐顶点的照明渲染路径

其实就是使用了逐顶点的方式来计算光照,在前向渲染中同样可以实现。不同的是, 这个路径, 一个 pass 最多访问到 8 个逐顶点光源。

延迟渲染路径

因为当场景中光源数量变多时, 前向渲染路劲的效率会大幅下降。所以延迟渲染重新流行起来。延迟渲染还会使用额外的缓冲区(G-buffer)来存储我们关心的表面信息, 如发现,位置,材质属性的。
延迟渲染主要包含两个Pass,在第一个 Pass 中, 我们不进行光照计算,而是仅仅计算哪些片元是可见的, 这主要通过深度缓冲技术来实现。当发现一个片元是可见的。 我们就把它的相关信息存储到 G-buffer中。
然后在第二个 Pass 中,我们利用 G-buffer 中的各个片元信息, 进行真正的光照计算。
由于真正可见的片元数量远小于场景中的物体包含的渲染片元, 所以能节省大量的光照计算时间。

unity 光源类型

  • 平行光
  • 点光源
  • 聚光灯

unity 阴影

在实时渲染中,我们最常使用的是一种名为Shadow Map的技术。
这种技术理解起来非常简单,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。而Unity就是使用的这种技术。
在渲染时, 通过把坐标转换到光源空间下, 用 (x,y) 坐标去阴影贴图采样。如果顶点坐标的 z 值小于采样得到的 z 值, 表示改点位于阴影中。
需要 Shader 中或者它的 FallBack 中包含LightMode被设为ShadowCaster 的Pass。

透明物体的阴影

透明度测试阴影

  • 将 FallBack 改为 Transparent/Cutout/VertexLit, 并且提供 _Cutoff 属性用于做透明度测试。
  • 因为正面被半透明测试过滤掉之后, 背面就会漏出来, 但是默认情况下, 物体渲染到深度图和阴影映射纹理中仅考虑物体的正面,所以阴影会显示错误。
    因此需要将 MeshRenderer 组件的 Cast Shadows 属性设为 TwoSide。 才能显示正确的结果。

    透明度混合阴影

    unity 中所有内置透明度混合的 Unity Shader,如 Transparent/VertextLit等,都没有包含阴影投射的 Pass, 所以这些半透明物体都不会接受来自其他物体的投影。
    因为要为这些半透明产生正确的阴影,需要做更严格的处理,可能会影响性能。
    当然如果我们一定要为它们生成阴影的话,也还是有办法的。 就是将它们的 FallBack 设置为 “VertexLit”, “Diffuse” 等。

统一管理光照衰减和阴影

  1. 引入 “Lighting.cginc” 和 “AutoLight.cginc”
  2. 在 v2f 结构体中使用内置宏 SHADOW_COORDS(idx)声明阴影坐标。
  3. 在顶点着色器中使用内之后 TRANSFER_SHADOW(o) 计算并向片元着色器传递阴影坐标。
  4. 使用 UNITY_LIGHT_ATTENYUATION(atten, i, i.worldPos)内置宏来计算光照衰减和阴影。

第 10 章 高级纹理

立方体纹理

对立方体纹理采用的时候, 我们需要提供一个三维的纹理坐标。这个方向矢量从立方体中心出发,当它向外延伸,会与立方体相交,采样结果就是由该店计算而来的。
常用于天空盒,或者环境映射纹理。

创建立方体纹理

使用 Camera.RenderToCubemap 函数,可以把该摄像机周围的观察到的场景图像存储到6张图像中, 从而可以创建出该位置上对应的立方体纹理。

反射

使用立方体纹理, 可以模拟出金属物体反射周围环境的效果。
原理就是,通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理进行采样即可。
在 unity 中,可以使用 reflect(-worldViewDir, worldNormal) 计算得到该顶点出的反射方向。

折射

斯涅尔定律(Snell’s Law)

当光线从 材质1 以入射角 θ_1 方向斜摄入介质2 是,我们可以用下面的公式来计算折射光线与法线的夹角 θ_2 。
η_2 * sin(θ_1) = η_2 * sin(θ_2)
在 unity 中,我们使用 CG 的 refract 函数来计算折射方向。

1
2
RefractRatio = 入射光线所在材质 与 出射光线所在材质 的折射率之间的比值。
fixed3 refractDir = refract(-normalize(worldViewDir), normalize(worldNormal), RefractRatio);

菲涅尔反射(Fresnel reflection)

菲涅尔反射描述了一种光学现象,当光线照射到物体表面上时,一部分发射反射,一部分进入物体发生折射或散射。
反射光和入射光存在一定比例,这个比例可以通过菲涅尔等式来计算。
真实世界的菲涅尔等式是非常复杂的。我们通常使用近似公式来计算。
常用的近似公式有:

Schlick 菲涅尔近似等式

1
2
F_schlick(v,n) = F_0 + (1 - F_0)(1 - v·n)^5
其中 F_0 是一个反射系数,用于控制菲涅尔反射的强度。

Empricial 菲涅尔近似等式

1
2
F_empricial(v,n) = max(0, min(1, bias + scale * (1 - v · n)^power))
其中 bias, scale, power 是控制项。

渲染纹理 (Render Texture)

程序纹理

常用 Substance 软件在 Unity 外部生成。

第 11 章 让画面动起来

unity 中的时间变量

  • _Time
    t 是子场景加载开始所进过的时间
    4 个分量值分别为 (t/20, t, 2t, 3t)
  • _SinTime, _CosTime
    时间的 sin, cos 值, 4个分量值分别为 (t/8, t/4, t/2, t)
  • unity_DeltaTime
    dt 是时间增量,4个分量值分别为dt, 1/dt, smoothDt, 1/smoothDt

滚动背景

在顶点着色器中使用时间对顶点的 uv 进行变换

1
2
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex) + frac(speed * _Time.y)
speed 为纹理滚动速度

顶点动画

在顶点着色器中使用时间对顶点坐标进行变换。

1
o.pos = UnityObjectToClipPos(v.vertex + offset);

广告牌

广告牌会根据视角方向来旋转被着色的多变形,然后使它好像总是面向摄像机。
广告牌技术的核心, 就是要构建出一个旋转矩阵,我们通常使用 表面法线(normal), 指向上的方向(up), 指向右的方向(normal) 三个向量作为基向量来构建这个矩阵。

  1. 通过计算得到目标的法线方向(也就是视角方向,因为要始终朝向摄像机) 和 指向上的方向。
  2. 然后使用上面两个向量叉乘,得到向右的方向right。
  3. 因为初始的两个向量不一定是垂直的, 所以我们需要选择其一, 然后用它和right叉乘得到另一个。
  4. 另外还需要指定一个位置,作为坐标轴的原点, 即旋转中心点。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float3 center = float3(0, 0, 0);
//取视角方向作为法向量
float3 normal = ObjSpaceViewDir(center);
normal = normalize(normal);
//选取一个粗略的上方向, 不能与法线平行
float3 up = abs(normal.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);
float3 right = normalize(cross(normal, up));
//得到最终的正方向
up = normalize(cross(right, normal));

float3 centerOffset = v.vertex.xyz - center;
//使用矩阵变化顶点坐标
float3 localPos = center
+ right * centerOffset.x
+ up * centerOffset.y
+ normal * centerOffset.z;

o.pos = UnityObjectToClipPos(localPos);

注意事项

  • 批处理会导致模型空间下的顶点计算错误。
    我们可以通过 "DisableBatching"=True tag 来对该 Shader 的批处理, 取消了批处理, 可能会带来性能下降。
    另外有一种做法是,把顶点到锚点的距离,存储在顶点颜色中,然后在世界空间下进行计算。

  • 顶点动画会导致默认的阴影效果错误。 因此需要重新实现ShadowCasterpass。

第 12 章 屏幕后处理效果

基础

当我们在脚本中实现了 OnRenderImage(RenderTexture src, RenderTexture dest) 函数之后, unity 会把当前渲染得到的图像储存在第一个参数对应的源渲染纹理上, 经过函数之后,再把目标渲染纹理显示到屏幕上。
在OnRenderImage 函数中,我们通常是利用 Graphics.Blit 函数来完成对渲染纹理的处理

调整屏幕的亮度,饱和度和对比度

亮度

直接把源颜色乘以亮度系数就可以了。

1
fixed3 finalColor = tex.rgb * _Brightness;

饱和度

首先计算该像素对应的亮度值, 使用该亮度值创建一个饱和度为 0 的颜色值,然后使用系数在两个颜色直接进行插值得到目标颜色。

1
2
3
fixed luminance = 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
fixed3 luminanceColor = fixed3(luminance, luminance, luminance);
fixed3 finalColor = lerp(luminanceColor, color, _Saturation);

对比度

创建一个对比度为 0 的颜色, 再使用系数进行插值得到目标颜色。

1
2
fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
fixed3 finalColor = lerp(avgColor, color, _Contrast);

卷积

卷积就是使用一个卷积核对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构,每个方格内都有一个权重值。对像素进行卷积时,我们会把卷积核的中心放置于该像素上,然后对每个方格计算它的权重与它覆盖的像素值的乘积,并求和,得到的结果就是该位置的卷积值。

边缘检测

像素与它相邻的像素,在某个属性(通常是颜色,亮度,法线)上的插值,称为梯度, 当梯度的绝对值大于某个值的时候,可以认为这个像素是一个边缘像素。
对每个像素使用边缘检测算子进行卷积操作,可以得到它的梯度值。通常我们会计算像素两个方向的梯度值,然后用这两个方向梯度值的平方和的平方根作为整体的梯度值。为了提高效率,通常使用绝对值操作来代替开根号操作。

1
G = |G_x| + |G_y|

常用边缘检测算子

sobel 算子

1
2
3
4
G_x             G_y
-1 -2 -1 -1 0 1
0 0 0 -2 0 2
1 2 1 -1 0 1

Prewitt 算子

1
2
3
4
G_x             G_y
-1 -1 -1 -1 0 1
0 0 0 -1 0 1
1 1 1 -1 0 1

Roberts 算子

1
2
3
G_x             G_y
-1 0 0 -1
0 1 1 0

高斯模糊

高斯方程:

1
2
G(x, y) = (1 / (2πσ^2)) * e^(-(x^2 + y^2)/2σ^2);
其中 σ 是标准方差,一般取值为 1, x,y 表示当前位置到卷积核中心的整数距离;

一维高斯核

1
2
横轴,数轴数值一致
0.0545 0.2442 0.4026 0.2442 0.0545

Bloom 眩光效果

Bloom 特效会让画面中较亮的区域扩散到周围区域中,造成一种朦胧的效果。
我们首先根据一个阈值提取出图像中的较亮区域,然后把它们存储在一张纹理中。
再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线的扩散效果。
最后再将齐和原图进行混合得到最终的效果。

运动模糊

累积缓存

累积缓存的实现方法是当物体快速移动产生多张图片后, 我们连续采样, 然后取他们的平均值作为最后的运动模糊图像。这种方法性能很差, 因为需要在同一帧渲染多次图像。
另一种简化的办法是,保存上一帧的图像, 然后再把这一帧的图像绘制上去, 从而实现一种运动轨迹的感觉, 这种办法性能更好,但是模糊效果略有影响

速度缓存

在一个缓存中存储各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

深度缓存和法线纹理

基础

当使用延迟渲染时,深度纹理可以直接访问,因为这些信息都被渲染到了 G-buffer 中。
当无法直接获取到深度缓存时,深度和法线纹理都是通过投射阴影的那个 pass 渲染得到的。
这其中 unity 使用了着色器替换技术。
在 unity 中,设置相机的 depthTextureMode 可以设置生成深度纹理的规则。
可以组合多种模式, 让它同时产生一张深度纹理 和 深度+法线纹理。
生成的深度纹理可以在 shader 中通过 _CameraDepthTexture 使用它。
unity 封装了对深度纹理的采样方法 SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)。
深度+法线纹理则是 _CameraDepthNormalTexture。

对深度纹理采样得到的深度值是非线性的,可以回想一下透视投影将平截头体投影到NDC的过程。
unity 提供了两个辅助函数来帮助我们把深度值转换成线性的。
LinearEyeDepth 转换到视角空间下的深度值。
Linear01Depth 返回一个范围在 [0, 1] 之间的线性深度值。

基于 深度缓存的运动模糊

思路是

  1. 根据深度纹理,在片元着色器中为每个像素计算其在世界空间下的位置。
    使用当前的 VP 矩阵的逆矩阵对 NDC 坐标进行转换。
  2. 然后再使用前一帧的 VP 矩阵对其进行变换,可以得到上一帧的 NDC 坐标。
  3. 计算着两帧的位置差,生成该像素的速度。
  4. 根据速度方向, 计算高斯模糊。

全局雾效

重建世界坐标

通过计算像素到摄像机的偏移量, 计算出像素的世界坐标。
偏移量=深度值 * 像素到摄像机的射线
像素到摄像机的射线 可以由 顶点着色器输出的 顶点到摄像机的射线 属性插值而来
顶点到摄像机的射线, 可以在 CPU 计算好,传给 shader.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
halfHeight = near * tan(fov/2);
toUp = camera.up * halfHeight;
toRight = camera.right * halfHeight;

TL = camera.forward * near + toTop - toRight;
TR = camera.forward * near + toTop + toRight;
BL = camera.forward * near - toTop - toRight;
BR = camera.forward * near - toTop + toRight;

scale = |TL| / near
Ray_tl = normalize(TL) * scale;
Ray_xx = normalize(XX) * scale; //其他四个射线同理

worldPos = _WorldSpaceCameraPos + linearDepth * Ray_pixel;

雾的计算

雾的计算公式

1
float3 afterFog = f * fogColor + (1 - f) * origColor;

其中 f 可以有三种计算方式。

  1. 线性

    1
    f = (d_max - |z|) / (d_max - d_min)
  2. 指数

    1
    f = e^(-d * |z|)
  3. 指数平方

    1
    f = e^(-(d - |z|)^2)

基于深度+法线缓存的边缘检测

因为基于颜色值的边缘检测,可能会受到光照和纹理的影响。
为了得到更好的效果,我们可以基于深度+法线缓存来实现基于法线和深度的边缘检测。

第 14 章 非真实感渲染

卡通风格的渲染

渲染轮廓线

  • 基于观察角度和表面法线的轮廓线渲染
  • 过程式几何轮廓线渲染
    使用两个 pass 渲染,第一个 pass 渲染背面,并使它的轮廓可见, 第二个 pass 在渲染它的正面。
    我们可以在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此来让背部轮廓线可见。
    1
    viewPos = viewPos + viewNormal * _outline;

对于内凹的模型,可能发生背面面片遮挡正面面片的情况, 对于这种情况, 我们可以在扩张背面顶点前,对它的 z 分量进行处理,然后把法线归一化够在对顶点进行扩张,这样可以使扩张后的背面更加扁平化,从而减少了遮挡正面的可能。

1
2
3
viewNormal.z = -0.5;
viewNormal = normailize(viewNormal);
viewPos = viewPos + viewNormal * _outline;

  • 基于图像处理的轮廓线渲染
    使用上文的边缘检测技术来识别轮廓边
  • 基于轮廓边检测的轮廓线渲染
    需要先检测出精确的轮廓边,然后直接渲染它们。
    检测一条边是够是轮廓边,需要检查和这条边相邻的两个面片满足以下条件, 它的本质在于检查两个向量的三角形面片是否一个朝正面,一个朝背面。
    需要在几何着色器中实现, 本书中没有涉及。
    除了相对复杂外, 它还有动画连贯性的问题, 在帧与帧之间会出现跳跃性。
    1
    (n_0 · v > 0) != (n_1 · v > 0

高光

卡通渲染需要有一块块分割明显的纯色区域,为了实现这种效果。

1
2
3
4
5
fixed w = fwidth(spec) * 2.0; //获取领域像素之间的近似到数值
fixed spec = dot(worldNormal, worldHalfDir);
//smoothstep, 当 value 值 < -w 时, 返回0, > w 时, 返回1。否则在 [0, 1]之间插值
spec = lerp(0, 1, smoothstep(-w, w, spec + _specularScale - 1));
fixed specular = Color_spec * spec * step(0.0001, _specularScale);

素描风格的渲染

使用多张填充不同笔触密度的线的纹理,然后根据光照结果(normal · lightDir)来决定从哪个纹理中采样。

第 15 章 噪声

噪声就是一张提供随机的纹理图, 适当的噪声能让渲染结果更真实,更灵活。

消融效果

从噪声纹理中采样, 然后取它的 r 通道,并与阈值进行比较,小于时,不显示这个像素。
随着阈值越来越大, 物体整个的消失掉。
通过调整噪声纹理的偏移值, 可以达到不同的消融效果。

水波效果

噪声纹理作为一个法线贴图, 为我们提供凹凸不平的水面效果。
对采样的坐标做一个随时间变化的偏移, 就可以得到不断波动的效果。

带噪声的全局无效

使用与消融效果类似的噪声纹理。只不过这个系数作为雾浓度的系数。

第 16 章 unity 中的渲染优化

基础

移动平台的 GPU 架构与 PC 平台有很大的不同,移动设备上的 GPU 架构专注于尽可能使用更小的带宽和功能。
IOS 上的 PowerVR 芯片使用了基于瓦片的延迟渲染架构,它把所有的图像装入瓦片中,然后由硬件找到可见的片元,只有这些可见的片元才会执行片元着色器。
Adreno 和 Mali 则会使用 Early-Z 技术进行一个低精度的深度检测, 来剔除那些不需要渲染的片元。
Tegra 则使用了传统的架构设计,在这些设备上, overdraw 更可能造成性能瓶颈。
因此,有时候我们需要对不同的硬件做不同的优化,以提高性能。

影响性能的因素

CPU

  1. 问题
  • 过多的 drawcall
  • 复杂的游戏逻辑或物理模拟
  1. 优化手段
    使用批处理技术减少 drawcall
    使用遮挡剔除技术,减少要提交渲染的物体

GPU

  1. 问题
  • 过多的顶点
  • 过多的逐顶点计算
  • 过多的片元
  • 过多的逐片元计算
  1. 优化手段
  • 减少需要处理的顶点数目
    优化几何体
    使用模型的 LOD 技术
    使用遮挡剔除技术
  • 减少需要处理的片元数目
    控制绘制顺序
    警惕透明物体
    减少试试光照
  • 减少计算复杂度
    使用 Shader 的 LOD 技术
    优化代码逻辑

    带宽

  1. 问题
  • 使用了过大且未压缩的纹理
  • 分辨率过高的帧缓存
  1. 优化
  • 减少纹理大小
    mip maps, 使用压缩纹理
  • 利用分辨率缩放

渲染分析工具

  1. stats 窗口
  2. 性能分析器的渲染部分
  3. FrameDebugger
  4. 特定平台的硬件供应商自家提供的工具
    unity 移动优化
  • Adreno (Qualcomm)
  • NVPerfHUD (NVIDIA)
  • PVRTune, PVRUniSCo (PowerVR)

减少 drawcall

动态批处理

unity 运行时会对共享了同一个材质并满足一些条件的模型进行动态合批。
主要限制:

  • 顶点数据规模要小于 900
  • 使用 lightmap 的纹理需要注意
  • 多 pass 的 shader 会终端批处理

    静态批处理

    场景中勾选了 static 的物体,会被合并到一个网格中。

    共享材质

第 17 章 表面着色器

表面着色器是 unity 提供的一种抽象的着色器, 它能极大的减少我们编写 shader 的工作量。
通过编译指令, 我们可以制定着色器的顶点修改函数,表面函数,光照模型, 阴影,环境光等各种配置。
表面着色器支持最多4种自定义的关键函数: 顶点修改函数,表面函数, 光照函数, 最终颜色修改函数。

表面函数

表面函数抽象出了表面的概念,一个对象的表面属性定义了它的反射率,光滑度,透明度等值。
表面函数的任务就是,根据输入的 Input 数据来计算并填充到输出结构体中, 供光照函数使用。

光照函数

unity 内置了基于物理的光照模型函数 Standard 和 StandardSpecular,以及非基于物理的光照模型函数 Lambert, BlinnPhong。
我们也可以自己指定需要用的光照函数。

两个结构体

Input

Input 结构体作为表面函数的输入结构, 顶点修改函数的输出结构, 它包含了很多表面属性的数据来源。
Input 结构体支持很多内置的变量名,通过这些变量名,我们告诉 unity 需要使用的数据信息, unity 会在背后为我们准备好数据,我们只要在表面函数中使用就行。
如果我们需要增加自定义的数据,则需要在顶点修改函数中计算好这些变量。

SurfaceOutput

根据光照模型的不一样,表面函数可以输出三种不同的结构体, SurfaceOutput, SurfaceOutputStandard, SurfaceOutputStandardSpecular。
与 Input 结构不一样, 这个结构里的变量是定义好的, 不能增加也不能减少。

  • fixed3 Albedo:对光源的反射率。通常由纹理采样和颜色属性的乘积计算而得。
  • fixed3 Normal:表面法线方向。
  • fixed3 Emission:自发光。
  • half Specular:高光反射中的指数部分的系数,影响高光反射的计算。
  • fixed Gloss:高光反射中的强度系数。
  • fixed Alpha:透明通道。如果开启了透明度的话,会使用该值进行颜色混合。

第 18 章 基于物理的渲染

BRDF 双向反射分布函数

常用 f(I,v) 来表示, 其中 I 为入射方向, v 为观察方向(出射方向)。
f(I,v) 表示了当一束光线沿入射方向 I 到达表面某点时,有多少部分能被反射到观察方向v上。
BRDF 决定了着色过程是否是基于物理的。这可以由BRDF是否满足两个特性来判断:它是否满足交换律(reciprocity)和能量守恒(energy conservation)。

反射等式

给定观察视角v,该方向上的出射辐射率L_o(v)等于所有入射方向的辐射率(L_i(I))积分乘以它的BRDF值f(I, v),再乘以一个余弦值(n· I)

1
L_o(v) = ∫_Ω(f(I,v) * L_i(I) (n · I))d_ω_i

对于一个精确光源,指的是那些方向确定、大小为无线小的光源,我们可以用下面的简化等式来代替上面的等式。

1
L_o(v) = πf(I_c, v) * c_light(n · I_c)

对于多个精确光源, 我们只需要把它们逐个代入上式,然后把它们的结果相加即可。

BRD F可以用于描述两种不同的物理现象:表面反射和次表面散射。我们用两个部分来描述它们 高光反射项 和漫反射项。

漫反射项

  1. lambert 模型的漫反射项为:

    1
    f_lambert(I, v) = c_diffu / π;
  2. Disney 的实现

    1
    f_diff(I, v) = (baseColor / π) * (1 + (F_D90 - 1)(1 - n · I)^5) * (1 + (F_D90 - 1)(1 - n · v)^5);

其中,F_D90=0.5+2 roughness(h·I)^2
在Disney的实现中,baseColor是表面颜色,通常由纹理采样得到,roughness是表面的粗糙度。
这也是 unity 中使用的漫反射项

高光反射项

微面元理论

在基于物理的渲染中,BDRF中的高光反射项大多数都是建立在微面元理论(microfacet theory)的假设上的。
微面元理论认为,物体表面实际是由许多人眼看不到的微面元组成的,虽然物体表面并不是光学平滑的,但这些微面元可以被认为是光学平滑的,也就是说它们具有完美的高光反射。
微面元理论认为,只有一部分微面元的法线等于半角向量,只有这部分微面元的光能被反射到观察方向上。
另外这部分微面元,还有一部分会由于表面的凹凸不平被挡住,所有这些被遮挡住的微面元不会添加到高光反射项的计算中。

微面元模型

综合以上理论,有了 Torrance-Sparrow 微面元模型。

1
f_spec = (F(I, n) * G(I, v, h) * D(h)) / (4(n, I) * (n · v))

  • D(h)是微面元的法线分布函数(normal distribution function, NDF),它用于计算有多少比例的微面元的法线满足m=h,只有这部分微面元才会把光线从I方向反射到v上。
  • G(I, v, h)是阴影——遮掩函数(shadowing-masking function),它用于计算那些满足m=h的微面元中有多少不会被遮挡而能被人眼看到。
  • F(I, h)则是这些活跃微面元的菲涅尔反射(Fresnel reflectance)函数,它可以告诉我们每个活跃的微面元会把多少入射光线反射到观察方向上,即表示了反射光线占入射光线的比率。
  • 分母4(n · I)(n · v)是用于校正从微面元的局部空间到整体宏观表面数量差异的校正因子。

unity 中的 PBS 实现

Unity 5一共实现了两种PBS模型。一种是基于GGX模型的,另一种则是基于归一化的Blinn—Phong模型的。
这两种模型使用了不同的公式来计算高光反射项中的法线分布函数D(h)和阴影—遮掩函数G(I,v,h)。

GGX 模型

【基于物理渲染】业界主流GGX

  1. 法线分布函数

    1
    2
    D_GGX = π * a / ( d * d + 1e-7);
    其中 a = roughness^2, d = (nh * a - nh) * nh + 1, nh = n · h
  2. 阴影遮掩函数

    1
    2
    G(I,v,h) = 1 / ((( n · I)(1 - k) + k) * (( n · v)(1 - k) + k))
    其中 k = (roughness ^ 2) / 2
  3. 菲涅尔函数

    1
    2
    F(I, h) = F0 + (1 - F0)*(1 - I · h)^5
    其中 F0 表示高光反射系数, 在 unity 中往往就是高光反射颜色。

gamma 校正

gamma 校正是因为显示器使用 8位数据来表示一个通道,那么最多只能表示 256 种不同的亮度值。
为了尽可能多的表示数据,我们利用了人眼对于图像的暗部更加敏感的特点, 对图像数据进行了校正。这样就可以用更多的空间来储存更多的暗部区域。

gamma 编码

1
L_out = L_in^0.454

gamma 解码

1
L_out = L_in^2.2

线性空间

unity 中提供了两种颜色空间, gamma 空间 和 线性空间。
对于 gamma 空间, unity 不会对输入输出的颜色做任何处理。
对于线性空间,unity 会对输入输出颜色做处理。

  1. unity 会把纹理设置为 sRGB模式,在这种模式下, 硬件对纹理采样时,会自动将它转换到线性空间中。
  2. 在Shader写入颜色缓冲前自动进行伽马校正或是保持线性在后面进行伽马校正,这取决于当前的渲染配置

https://leafflow.top/2023/02/07/game_dev/unity/unity-shader入门精要笔记摘要/
作者
vollen
发布于
2023年2月7日
许可协议