雁过留声

a tiger in me sniffs roses

0%

图形学和Shader简单介绍

一次组内分享

先看一张图
?
如果这是一张用软件建模渲染的图,有人会说没什么稀奇。但其实这是一位叫iq大佬用各种数学公式通过Shader绘制的,是不是觉得不明觉厉。

Inigo Quilez,是一位用使用代码、数学、艺术进行shader编程,创建精美图像的大佬,同时也是一位滑雪高手。他说:你可以在电脑绘图方面打败我,但在滑雪方面不行

图形学被称为计算机的三大浪漫之一

1. 基础

1.1 什么是Shader

着色器(Shader)是运行在GPU上的小程序,是一种在GPU上的编程方式。原本用于图像浓淡处理,现在广泛应用在电影后期处理、计算机成像、电子游戏等领域,着色器常被用来制作各种特效。除了普通的光照模型,着色器还可以调整图像的色相、饱和度、亮度、对比度,生成模糊、高光、有体积光源、失焦、卡通渲染、色调分离、畸变、凹凸贴图、色键(即所谓的蓝幕、绿幕抠像效果)、边缘检测等效果。

因为有CUDA和ROCm架构的存在,除了Shader,还可以使用C/C++来针对GPU编程实现大规模并行计算。比如深度学习和挖矿。

1.2 渲染管线(流水线)

因为Shader是工作在渲染流水线的各个阶段,所以要了解Shader, 几乎没有不提渲染流水线。

什么是流水线?流水线解决什么问题?

这是一个工厂生产的例子
?

可见流水线提提高单位时间的产量,如果把显卡比作一个工厂,比如显卡RX5802048SP, 他有2048个流处理单元,像工厂里的2048条生产线同时开工,显著提高图形的处理能力。

完整的渲染流水线是由CPU + GPU合作完成的,《Real-Time Rendering》一书中将渲染流程分为三个阶段:应用阶段(Application)几何阶段(Geometry)光栅化阶段(Rasterizer Stage)

  • 应用阶段
    a. 准备渲染数据,加载到显存
    b. 设置渲染状态(如顶点属性,纹理,Shader等)
    c. 调用Draw call

CPU 和 GPU怎么协同工作?

  • 几何阶段
    将渲染图元进行逐顶点,逐片元操作,把顶点坐标变换到屏幕空间,再交给光栅器处理

  • 光栅化阶段
    得到片元,计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色,并决定哪些像素需要显示到屏幕上

注意这些只是概念上的流水线,是功能上的划分。一般我们说的GPU流水线包含几何阶段和光栅化阶段,即各个图形平台广泛应用的光栅化渲染流水线(Rasterization-Based Rendering Pipeline), Shader正是工作在这一流水线上。

?

1.2 渲染管线各阶段描述

  • 顶点着色器
    a. 坐标变换,把顶点坐标从模型空间变化到齐次裁剪空间
    b. 逐顶点光照
    这个阶段并不知道顶点间的关系也不知道顶点是否处于同一个三角形,因此可以并形化处理

?

  • 曲面细分着色器 (Tessellation Shader):使用合适的细分算法,生成更高精度网格,从而提高画面的细节。
  • 几何着色器(Geometry Shader): 几何着色器接收片元(逐片元)的一组顶点,然后可以对其进行变换,控制生成和销毁图元。
    这两类着色器非常强大,但是也有它的局限
    • opengl 3.1+, shader model 4.0; directx 10+
    • 不能很好并行处理

几何着色器可以用于细分吗?

  • 裁剪
    将在NDC坐标下(硬件做透视除法得到)的图元根据是否完全在视野内,部分在视野内,完成在视野外进行裁剪

?

  • 屏幕映射(Screen Mapping)
    将三维坐标映射到屏幕上,即屏幕坐标。

三维坐标中的Z在屏幕坐标系里会怎么样?

窗口坐标中的值会被传递到下一阶段,即光栅化

  • 三角形设置
    从三角形顶点数据计算三角形网络表示数据的过程,该过程在专门为其设计的硬件上执行,得到的数据是下一阶段的输入。

  • 三角形遍历
    如果一个像素被一个三角形网格所覆盖,就生成一个片元。这个过程就叫Triangle Traversal。显然片元已经和设备相关了。注意一个片元并不是一个像素,它是一个状态的集合,如屏幕坐标,深度,法线,纹理。最终是否要显示到屏幕上,显示什么颜色,还需要最后阶段的逐片元操作(Per-Frragment Operations)

?

  • 片元着色器
    这里也是一个完全可编程的阶段,片元着色器执行逐像素的着色计算,将前面插值获得的着色数据作为输入。然后将一种或者多种颜色输出给下一个阶段。这里最典型的,最重要的技术之一就是纹理贴图(Texturing)。

  • 逐片元操作
    在DirectX中叫融合(Output-Merging), 合并阶段不是可编程的,但是是高度可配置的。
    这里作用是:
    a. 决定片元可见性
    b. 和颜色缓冲区中颜色混合
    这里有许多缓冲区完成不同的功能,如stencil缓冲区,z缓冲区,alpha缓冲区(deprecated), color缓冲区, frame缓冲区…

下图是一个颜色缓冲区(帧缓冲)的逻辑:
?
最后会根据混合操作的配置来更新目标颜色:
C_result=C_source∗F_source+C_destination∗F_destination

f source f destination
SrcAlpha OneMinusSrcAlpha 正常
OneMinusDstAlpha One 柔和相加(Soft Addtive)
DstColor Zero 正片叠底(Multiply),即相乘
DstColor SrcColor 两倍相乘(2x Multiply)

1.3 图形学中的数学

  1. 笛卡尔坐标、点、向量
  • 坐标系分左手和右手
  • 点积的几何意义之一,就是可以表示投影,这在光照计算中很有用
  • 叉积结果是一个向量,可以用来计算一个平面,三角形矢量,判断面的朝向
  1. 矩阵
  • 向量可以看成nX1的列矩阵[x, y, z]T,或者1Xn的行矩阵[x, y, z]

  • 矩阵的几何意义在图形中是变换(transform)

  • 线性变换

    1
    2
    f(x) + f(y) = f(x+y)
    kf(x) = f(kx)

线性变换有:旋转、缩放、镜像、正交投影、错切,线性变换可以用M33矩阵表示出来
但是平移不是线线的,于是引入了仿射变换(affine transform),合并了线性变换和平移,并引入了齐次坐标空间,把3x3矩阵扩展到了4x4。

齐次坐标变为了[x,y,z,w]
?

  • 坐标空间变换也用矩阵来表示

局部空间(Local Space,或者称为物体空间(Object Space))
世界空间(World Space)
观察空间(View Space,或者称为视觉空间(Eye Space))
裁剪空间(Clip Space)
屏幕空间(Screen Space)

2. Shader 编程

2.1 Shader编程语言

可编程渲染管线出现之前,Shader是用汇编语言来实现的。随着发展,出现了更高级的Shading Language

  • GLSL: OpenGL, 由于opengl是良好的跨平台性, GLSL也有良好的跨平台性,但是这个跨平台性是由各个硬件产商自己实现的。

  • HLSL: DirectX, 基本只用于微软自己的产品。

  • cg(C for Graphic), Nvidia设计的真正跨平台的GPU编程语言,它会根据平台的不同,编译成相应的中间语言(计算机中没有增加中间层解决不了的问题T_T)。由于nvidia和microsoft的合作,他们在标准硬件光照语言的语法和语义上达成了一致,因此Cg和HLSL(DX9+)很像。比如在Unity上,我们一般说使用Cg/HLSL或者GLSL编写Shader。因为CG不再被维护,Unity也使用HLSL编译器来编译

2.2 cg/hlsl介绍

2.2.1 语法

HLSL
CG

列举一些与c不同的地方:

  1. 关键字

    • uniform 修饰顶层变量,提供了一种和外部语言沟通的机制
    1
    uniform float4 position
    • in/out/inout 定义函数参数,指定它是输入输出参数
  2. 变量

    • half 16位浮点数
    • fixed [-2, 2] 之间的浮点数
  3. 向量和矩阵

    • 向量 type, 如:
      1
      2
      3
      4
      5
      int1 iVector = 1;
      float3 v = float3(0.2, 0.3, 0.4);
      float3 v = {0.2, 0.3, 0.4};
      float3x3 m = float3x3(1, 2, 3, 4, 5, 6, 7, 8, 9)
      float3x3 m = float3x3(v, v, v);
  4. swizzle

    1
    2
    float3 v = float3(0.2, 0.3, 0.4);
    float4 a = v.xxyz;

    矩阵也可以swizzle

    1
    2
    3
    4
    float4x4 myMatrix;
    float myFloatScalar;
    // Set myFloatScalar to myMatrix[3][2]
    myFloatScalar = myMatrix._m32;
  5. 语义(Semantics)
    可以用在函数参数,返回值,结构体内。语义定义了shader在流水线不同阶段解释(读写)数据的方式,
    有些语义只能用于顶点着色器,有些只能用于片元着色器,有些在两个地方都能用:

  • POSITION 物体空间的顶点坐标,作为顶点着色器的输入
  • SV_Target Render target, 有多个,渲染的最终位置
  • COLOR 一般用来传递漫反射或高光颜色

2.2.2 常用函数

  1. clamp/saturate 截取
    clamp(x, min, max)
    将x截断到[min, max]内
    clamp(1, 2, 3) //2
    ?

  2. fmod(x, y) 浮点数取余

    1
    2
    3
    4
    5
    a = 9.2;
    b = 3.7;
    c = 2;
    fmod(a,c); //1.2
    fmod(b,c); //1.8

    常用于限制范围,产生循环

?

  1. smoothstep 平滑阶梯函数

将x映射到[0,1]并且做一个平滑处理,t1是可以大于t2的,它决定了曲线的走向

1
2
3
4
float smoothstep(float t1, float t2, float x) {
x = saturate((x - t1) / (t2 - t1), 0.0, 1.0);
return x * x * (3 - 2 * x);
}

?
常用于边界的平滑过度

  1. lerp 插值, 也叫mix
    lerp(x, y, t) 在x, y之间进行插值,即x + (y-x)*t
    1
    lerp(1, 2, 0.5) //1.5
    ?
    lerp经常用来混合2个数据,比如颜色之类

2.3 Shader Lab

Unity中使用自己的Shader Lab来进行Shader编程。粗略地可以把Shader Lab理解成Java中的Spring或者JS里的React。它最终会被编译成目标平台的Shader。

Shader Lab有三类着色器:

  1. 表面着色器:使用CG/GLSL语法,可以看作是下面一种更高的抽象。比如可以直接使用预定义的光照模型
  2. 顶点片元着色器:使用CG/GLSL语法,最灵活的,但是自己要做的事情要多一些
  3. 固定函数着色器:为兼容最老旧的显卡而存在,一般不会用了

unity 2018还可以使用Shader Graph可视化方式帮你生成Shader,不用写代码!

2.3.1 ShaderLab 语法

一个基本的具有固定的结构

1
2
3
4
5
6
Shader "<name>" {
<optional: Material properties>
<optional: custom editor>
<One or more SubShader definitions>
<optional: fallback>
}
  1. Shader Name是Shader标识
1
2
Shader "Examples/ShaderSyntax" {
}
  1. properties是材质和shader的桥梁
    property是对uniform变量更方便的使用
1
2
3
4
Properties {
_VarName ("display name", PropertyType) = DefaultValue
...
}

颜色
_Color (“Color”, Color) = (1,0,0,0)

小数
_Radius(“Dot Width”, Float) = 1

  1. SubShader是shader逻辑实现的地方

SubShader至少有一个,当unity加载Shader时,依次为目标平台选择一个可运行的SubShader,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
SubShader {
[Tags] //Tags { "RenderType" = "Opaque" }
[RenderSetup] //LOD 150, Cull On
Pass {
[Name] //Name "pass_name"
[Tags] ////Tags { "LightMode" = "ForwardBase" }
[RenderSetup] //这里可以使用SubShader的渲染设置
CGPROGRAM
//cg code
ENDCG
}
}
  1. Fallback
    当没有SubShader可用时,使用Fallback ‘ShaderName’指定的Shader,也可以使用Fallback Off关闭该功能

2.3.2 ShaderLab 常用变量/函数介绍

unity 内置的变量/函数可以简化计算,并且抹平不同平台的差异, 使用时候需要:
#include "UnityCG.cginc"

只介绍下边会用到的:

  • UNITY_MATRIX_MVP/UnityObjectToClipPos
    模型视图投影矩阵,将模型坐标变换到裁剪空间

  • ComputeScreenPos
    从裁剪空间计算屏幕空间的坐标
    注意该坐标是中心坐标用小数表示,如果视口是[0,0,480,600], 那么ScreenPos = [0.5,0.5,480.5,600.5]

  • _ScreenParams
    屏幕参数, float4类型,含义如下:
    x 是当前渲染目标在像素值中宽度
    y 是当前渲染目标在像素值中的高度
    z 是 1.0 + 1.0/width
    w 是 1.0 + 1.0/height

  • _Time
    float4类型:
    x t/20
    y t
    z t2
    w t
    3
    t 自场景加载以来时间,一般用于Shader中产生动画

3. 实践一下

如何用fragment shader画一个心形?

3.1 绘制基础

在这之前,先了解下坐标系设置。并在屏幕上绘制一个简单的圆,理解shader绘图机制,结合前面提到要点,理解工作原理

  1. 新建一个顶点片元着色器
    在Project View中->右键->Create->Shaer->Unlit Shader. 修改生成的代码,在顶点着色器中添加:
    1
    2
    3
    4
    5
    6
    7
    v2f vert (float4 v : POSITION)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v);
    o.scrPos = ComputeScreenPos(o.vertex);
    return o;
    }

注意这里使用ComputeScreenPost而不是VPOS语义,实现平台无关的坐标系,经测试VPOS在Windows下的左上角是原点

  1. 理解坐标系
    在片元着色器中加入如下代码:
    1
    2
    3
    4
    5
    6
    fixed4 frag (v2f i) : SV_Target
    {
    //这里是齐次坐标系,除以w将得到ndc坐标,范围在[0,1]
    float2 fragCoord = i.scrPos.xy / i.scrPos.w;
    return fixed4(fragCoord, 0, 1);
    }
    会得到
    ?
    可以看到左路下方为(0,0), 右上角为(1,1), 为了使用方便我们将坐标系移到屏幕中心并归一化
1
2
float2 fragCoord = i.scrPos.xy / i.scrPos.w * _ScreenParams.xy;
fragCoord = (2 * fragCoord - _ScreenParams.xy) / min(_ScreenParams.x, _ScreenParams.y);

这样坐标中心是[0,0], 当我们在[-2, 1]之间绘制时,就能保证所有东西在屏内。

3.2 心的数学

  1. atan2(y, x)
    返回向量x,y的与x轴的夹角并能正确处理0值。
    当点(x, y) 落入第一象限时,atan2(y, x)的范围是 0 ~ pi/2;
    当点(x, y) 落入第二象限时,atan2(y, x)的范围是 pi/2 ~ pi;
    当点(x, y) 落入第三象限时,atan2(y, x)的范围是 -pi ~ -pi/2;
    当点(x, y) 落入第四象限时,atan2(y, x)的范围是 -pi/2~0.
    可见返回[-pi, pi]

atan计算屏幕上任一点的与x的角,如果用极坐标方程表示,
r=theta 如图所示:
?
问题转化为如果length(p) <= theta则落在心形线内。

1
2
3
4
5
float a = atan2(p.x, p.y) / 3.1415926;
float r = length(p);
float d = a - r;

return lerp(bcol, _Color, smoothstep(-0.01, 0.01, d));

会得到半个心:
?

1
a = abs(a);

得到一个完整的心,然后交换x, y轴,atan2(p.y, p.x), 将心翻转过来,并调整一下位置。

但是这个心太胖了,用下边这个函数调整一下:
y = (13.0x - 22.0xx + 10.0xxx)/(6.0-5.0*x);
?

1
2
3
4
5
6
7
float a = atan2(p.x, p.y) / 3.1415926;
float r = length(p);
a = abs(a);
a = (13.0*a - 22.0*a*a + 10.0*a*a*a)/(6.0-5.0*a);
float d = a - r;

return lerp(bcol, _Color, smoothstep(-0.01, 0.01, d));

?

3.3 让心跳动起来

y=(x^0.20.5 + 0.5)-(x^0.20.5 + 0.5)0.2sin(x6.82313)e^(-x4)
函数图产生一个非线性的动画变换

?

1
2
3
4
float tt = fmod(_Time.y,1.5)/1.5;
float ss = pow(tt,.2)*0.5 + 0.5;
ss = 1.0 + ss*0.5*sin(tt*6.2831*3.0 + p.y*0.5)*exp(-tt*4.0);
p *= float2(0.5,1.5) + ss*float2(0.5,-0.5);

最后一行给x,y方向不同的跳动因子和方向