[图形学]Unity Sharder入门精要(一)

前言

这一片文章写的是关于Unity Sharder入门精要第二章的读书笔记。

知识点

流水线

就是把一整套大步骤分为多个小步骤,然后可以一起执行。

渲染流水线

就是把三维场景中的内容渲染成一张二维图像。由CPU和GPU共同完成的。
这分为三个阶段:应用阶段,几何阶段,光栅化阶段

应用阶段:

这个阶段是由我们应用程序所主导的,因此通常由CPU负责实现。所以在这个阶段,我们开发者是有绝对的控制权。
三个主要任务:

  1. 准备场景数据
  2. 粗粒度的剔除工作
  3. 设置渲染状态
    场景数据:包括但不限于:摄像机位置,摄像机的视椎体,场景中的模型,光源
    粗粒度剔除工作:把那些完全不可见的物体剔除出去,这样就不需要再发给几何阶段出来,减少GPU工作。
    渲染状态设置:包括但不限制于:模型使用的材质,使用的纹理,使用的shader。
    在这个阶段重要的输出是渲染图元也就是渲染所需要的几何信息。这些图元被传递到下一个阶段-几何阶段
    三个阶段
  4. 把数据加载到显存中。
  5. 设置渲染状态
  6. 调用Draw Call
    把数据加载到显存中:渲染所有的数据都需要从硬盘加载到系统内存中,然后再把网格和纹理等数据又被加载到显存中。因为显卡对于显存的访问速度更快, 数据被加载好了以后,这些数据就可以被移除了,但是如果我们还需要做一些cpu的检测,那可以暂时不移除。
    设置渲染状态:渲染状态就是用来定义网格是被怎样渲染出来的,比如用了哪个定点着色器,用了什么光源属性,用了什么材质等等,如果我们没有更改渲染状态,那么所有的网格都讲使用同一种方法来进行渲染。
    调用Draw Call:Draw Call就是一个命令,从cpu发送到GPU,用来告诉GPU渲染哪个图元,这里面不包含任何材质信息,因为我们在前几步就已经把数据加载到了显存中了。当我们调用了Draw Call以后,GPU就会根据设置的渲染状态,和定点数据来进行计算,输出需要显示的像素。

    几何阶段

    这个阶段主要用于处理所有和我们要绘制的几何相关的事情,把从应用阶段保存到显存中的图元,在收到Draw Call指定的图元后,进行逐顶点,逐多边形的操作,在这个阶段有个最重要的任务就是把顶点坐标变换到屏幕空间中,然后再把这个坐标交到光栅器进行最后的处理。
    五个关键步骤

  7. 顶点着色器处理
  8. 曲面细分着色器处理
  9. 几何着色器处理
  10. 裁剪
  11. 屏幕映射处理
    顶点着色器
    这步是完全可编程的。当Draw Call通知需要被处理的图元时,这里就开始这一步,把图元的顶点进行变化,和着色。在这步我们是不能创建和销毁任何一个顶点,并且不能得到顶点和顶点间的关系,但是就是因为这样的相互独立性,所有我们可以利用GPU的特性进行并行化处理每一个顶点,这就意味着,处理顶点的速度将会变得很快。
    坐标转换:
    顶点着色器在这步改变顶点的位置,这个在顶点动画中是个非常有用的。需要注意的一点是,无论我们在顶点着色器中怎么改变顶点的位置,我们都需要把顶点坐标从模型空间转换到齐次剪裁空间,然后再得到归一化的设备坐标。
    曲面细分着色器
    这是一个可选的着色器,用于细分图元。
    几何着色器
    这也是一个可选着色器,可以被用来执行逐图元的着色操作,或者被用于产生更多的图元。
    裁剪
    用于裁剪那些不在摄像机视野方位内的顶点,并且剔除某些三角图元的面片,这一步是可配置的。一些图元的点有一部分在摄像机的视野外面,这个时候,我们就需要把摄像机视野外的点个裁剪掉,在和裁剪接触面来新建几个点,用来替换视野外的点。
    屏幕映射
    这一步是不可编程,也不可以配置的。这步负责把每个图元的坐标转换到屏幕坐标系中。这一步输入进来的坐标其实还是一个三维坐标,而我们的屏幕坐标系是一个二维坐标,所以我们要把图元显示出来,就得在这步来进行一次坐标转换,把三维坐标映射到屏幕坐标中,因为屏幕的坐标和屏幕的分辨率有很大的关系,所以这里将会进行一次矩阵计算。这步计算得到了顶点对应屏幕上的哪些像素以及距离这个像素有多远。这步只处理图元坐标中的x,y,对于z我们将保留原始值。只包含x,y的叫屏幕坐标系,加上一个z就是窗口坐标系。

    光栅化阶段

    把上一个阶段传递过来的数据产生屏幕上的像素,并渲染出最终的图案。主要的任务就是决定每个渲染图元中的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据进行插值,然后再进行逐像素处理。
    四个步骤

  12. 三角形设置
  13. 三角形遍历
  14. 片元着色器
  15. 逐片元操作
    三角形设置
    这个阶段计算光栅化一个三角网格所需要的所有信息。从上一个阶段输入进来的是三角网格的顶点,但是当我们要计算这个三角形所覆盖的像素情况时候,我们就需要计算每个边所对应的像素坐标。所以这步的最主要的功能就是为了能输出下一阶段所需要的三角网格的数据。
    三角形遍历
    这个阶段将会检查每个像素是否被一个三角网格所覆盖,如果被这个像素被覆盖了,那就生成一个片元,这样找到所有被三角网格所覆盖的过程就叫做三角形遍历(扫描变换)。使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。这样输出的就是一个片元序列。这个片元不是真正意义上的像素,这里面包含了很多状态,这些状态就是用于计算像素最终颜色的。
    片元着色器
    这是一个很重要的可编程着色器阶段。其中很重要的一个技术就是:纹理采样。为了方便在这个阶段进行纹理采样,我们在顶点着色器阶段输出了每个顶点对应的纹理坐标。然后经过对三角网格的三个顶点对应的纹理坐标进行插值以后,就可以得到覆盖在片元上的纹理坐标。这步虽然可以完成很多重要的效果,但是它只能影响到一个片元,在这步里是不可以将自己的任何结果发送给其他片元。但是有个例外就是可以访问导数信息。
    逐片元操作
    这一阶段的主要目的就是合并。这一步也是一个高度可配置性。
    两个重要任务
  16. 决定每个片元的可见性
  17. 进行颜色合并
    在做片元可见性时候,我们需要对片元做一系列的测试。只有通过了所有的测试才可以进行颜色合并。如果没有通过测试,那么这个片元在前面所有的处理都是白费的,因为这个片元会被舍弃掉。有两个最基本的测试-深度测试和模板测试。
    模板测试
    模板测试相关的就是模板缓冲。开启以后,GPU会首先读取模板缓冲区中该片元位置的模板值,然后将该值和读取到的参考值进行比较。这个比较函数可以由开发者指定。如果片元没有通过就会被舍弃。不管片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个操作也是由开发者决定的。
    深度测试
    如果开启了深度测试,那么gpu会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。这个比较函数也是开发者可以设置的。如果没有测试通过,那么这个片元就会被舍去。这里有个地方和模板测试不一样的,就是如果没有通过测试那么这个片元就没有权利更改深度缓冲区中的值。如果通过了测试还可以通过开启/关闭深度写入来决定是否要用到这个片元的深度值覆盖掉原有的深度值。
    合并
    我们所讨论的渲染过程是一个物体接着一个物体画到屏幕上的。而每个像素的颜色信息被存储在一个名为颜色缓冲的地方。因此,当我们片元通过了所有测试将要被渲染出来的时候,颜色缓冲区中往往会有上一次渲染后的颜色结果,那么我们这次的片元的颜色将要怎么渲染呢?是覆盖还是其他处理,这里就需要的用合并来解决了。当如果是不透明的物体,我们就可以关闭混合,这样颜色值就会覆盖掉颜色缓冲区中的颜色。但是对于半透明的物体,我们就需要使用混合来让这个物体看起来是透明的,gpu会取出原颜色和目标颜色,将两种颜色进行混合,原颜色是从片元着色器中取出来的颜色值,目标颜色是存在与颜色缓冲区中的颜色值,之后使用一个混合函数来进行混合操作。这个混合函数通常跟透明通道息息相关。
    为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲的策略。对于场景的渲染是在幕后发生的,就是后置缓冲。当场景已经被全部渲染到后置缓冲中,GPU就会交换后置缓冲区和前置缓冲区的内容。前置缓冲区就是我们所看到的图像。

    优化知识点

    一般在谈到优化,我们基本上都会考虑优化Draw Call这个属性,但是在我们这章的内容上,我们了解到Draw Call只是一个命令集,但是为什么我们要优化Draw Call的数量呢?这里我们需要考虑在发送Draw Call的之前CPU还做了什么操作,一个是加载数据,一个是设置渲染状态,加载数据的话,有人说是通用在内存上的交互,因为在Unity中我们读取AB包的时候,数据以及在内存上了,然后再从内存加载到显存上,但是在操作系统层面上,内存和显存的块都是在一个地方的,所以这步加载是没有什么消耗的。那最大的消耗就是在设置渲染状态了,看了下Unity官方的文档,Draw Call是资源密集型操作,主要开销是Draw Call之间的状态变化(列如切换到不同材质),而这种情况会导致图形驱动程序中执行密集型验证和转换步骤。所以我们减少的Draw Call的数量就是为了减少cpu上的消耗,导致卡顿。

[lua]关于lua中函数点和冒号的区别

前言

在lua中创建一个可以被外部访问的函数有两种方式,一种是table.function一种是table:function这两种函数的调用也是可以使用点和冒号两种方式调用的。

第一种使用table.function创建函数

local f = {}
function f.test(self,x,y) 
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require(f)
f.test(f,3,4)

打印出来的值是,self为f这个对象,x为3,y为4

使用冒号去访问

local f = require(f)
f:test(3,4)

打印出来的self还是这个对象,x为3,y为4

结论

当我们在创建一个方法的时候,我们时候点创建然后用冒号访问的时候,会默认把当前所被使用的对象给传进来。

第二种使用table:function创建函数

local f = {}
function f:test(x,y)
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require("test")
t.test(3,4)

打印出来的值为:3,4,nil;

local f = require("test")
t.test(f,x,y)

这个时候打印出来值为f,3,4

使用冒号去访问

local f = require("test")
t:test(3,4)

打印出来的是:f,3,4

结论

在这个测试中可以表明,使用冒号创建一个函数的时候,在使用点去调用的时候,会默认在第一个参数前加一个self的参数。

[Lua]关于lua元表(Metatable)

前言

在lua中最重要的一个数据结构就是table,而table有一个重要的功能就是设置元表(Metatable)在元表中设置元方法,就能帮我们实现面向对象的一些功能。

基础概念

lua中每个值都可以有一个元表,这个元表就是一个普通的table,它用于定义原始值在特定操作下的行为。如果你想改变一个值在特定操作下的行为,你可以在它的元表中设置对应域。
在元表的事件的键值是一个双下划线(__)加事件名的字符串;键关联的那些值被称为元方法。
可以用getmetatable函数来获取任何值的元表。lua使用直接访问的方式从元表中查询元方法。
可以使用setmetatable来替换一张表的元表,在lua中,你不可以改变表以外其他类型的值的元表;如果想改变这些非表类型的值的元表,需要是用c语言接口。
table和userdata有独立的元表,其他类型的值按类型共享元表;也就是说所有的数字都共享同一个元素,所有的字符串共享另一个元表等等。在默认情况下,值是没有元表的,但是字符串库在初始化的时候为字符串类型设置了元表。
元表决定了一个对象在数学运算,位运算,比较,连接,取长度,调用,索引时的行为。元表还可以定义一个函数,当table或者userdata被回收的时候调用它。

事件

  • add:+操作。如果任何不适数字的值做加法,lua都会尝试调用元方法。首先,lua检查第一个操作数,如果这个操作数没有为“add”事件的元方法,lua就会检查第二个操作数,一旦lua找到了元方法,它就将这两个操作数作为参数传入元方法里然后返回一个操作结果。如果找不到元方法,将抛出一个错误
local table1 = {
    [1] = 10
}
local table2 = {
    [1] = 20
}
--[
    a为+左边的,b为右边的值
--]
local mt = {
    __add = function(a,b)
        print(a[1],b[1])
    end
}
setmetatable(table2,mt)
local table3 = table1 + table2
  • __sub:-操作符。行为和“add”操作类型。

  • __mul:*操作符。行为和“add”操作类似。

  • __div:/操作符。行为和“add”操作类似。

  • __mod:%操作符。行为同上。

  • __pow:^(次方)操作符,行为同上

  • __unm:-(取反)操作,行为同上

  • __idiv://(向下取整除法)。行为同上

  • __band:&(按位与)操作。行为同上。lua会在任何一个操作数无法转换为整数时尝试取元方法

  • __bor:|(按位或)操作,行为同上。

  • __bxor:~(按位异或)操作,行为同上。

  • __bnot:~(按位非)操作,行为同上。

  • __shl:<<(左移)操作,行为同上。

  • __shr:>>(右移)操作,行为同上。

  • __concat:..(连接)操作,行为和“add”操作类似,不同的是lua在任何操作数即不是一个字符串,也不是一个数字的情况下尝试元方法。

  • __len:#(取长度)操作,如果对象不是字符串,lua会尝试它的元方法。如果有元方法,则调用它并将对象以参数形式传入,而返回值则作为结果。如果对象是一张表且没有元方法,lua使用表的取长度操作。其他情况均抛出异常

  • __eq:==(等于)操作。和“add”行为类似,不同的是lua仅在两个值都是表或都是userdata且他们不是同一个对象的时候才尝试元方法。返回结果转换为bool。

  • __lt:<(小于)操作。和“add”操作行为类似,不同的是lua仅在两个值不全为整数也不全为字符串时才尝试元方法。返回结果为bool。

  • le:<=(小于等于)操作。和其他操作不同,小于等于操作可能用到两个不同的事件。首先回去找两个操作数中的"le"元方法,如果都没有,那就会再次查找"__lt"。返回bool

  • __index:索引table[key]。当table不是表或者是表中不存在key这个键时,这个时间会被触发。这个事件的元方法可以是一张表,也可以是一个函数,如果是一个函数的话则以table和key作为参数调用它。如果是一个表,那就是在这个表里去key这个索引的值。

  • newindex:索引赋值table[key] = value.和索引事件类似,它发生在table不是表或是表table中不存在key这个键的时候调用对应的元方法
    这个方法可以是一个表也可以是一个函数,如果是函数的话则是以table,key和value作为参数传入。如果是一张表,则对这个表做索引赋值操作。一旦有了“
    newindex”元方法,lua就不再做最初的赋值操作。

  • __call:函数调用操作func(args)。当lua尝试调用一个非函数的值的时候会触发这个事件。查找func的元方法,如果找的到,func作为第一个参数传入,原来调用的参数(args)后依次排在后面。

方法

getmetatable(object)

这个方法是用来获取一个值的元表。否则,如果在该对象的元表中有“__metatable”域时返回其关联值,没有时返回该对象的元素。

setmetatable(table,metatable)

这个方法是用来设置一个值的元表。(不能在lua中改变其它类型的元表,只能在c中做),如果metatable是nil,则将table中的元表移除。如果元表有“__metatable”域,抛出一个错误。

rawequal(v1,v2)

在不触发任何元方法的情况下检查v1是否和v2相等,返回一个bool值

rawget(table,index)

在不触发任何元方法的情况下获取table[index]的值,table必须是一张表,index可以是任何值

rawlen(v)

在不触发任何元方法的情况下返回对象v的长度。v可以是表或字符串。它返回一个整数。

rawset(table,index,value)

在不触发任何元方法的情况下将table[index]设置为value.table必须是一张表,index可以使是nil与NaN之外的任何值。value可以是任何lua值。

实战

不能修改tale中的值

local list = {
    ["a"] = "a",
    ["b"] = "b"
}
local mt = {
    __index = list,
    __newindex = function (table,key,value)
        if list[key] then
            print("不能修改")
        end
        end,
}
local list2 = {}
local list1 = setmetatable(list2,mt)
list1["a"] = "c"

把list作为元表赋值给list1,当我要给list1赋值的时候,list1中表是空的,所以会去找元方法中的newindex,newindex函数的参数table代表着list2,key代表着“a”,value代表着“c”,做判断的时候,我们直接函数里面不处理,就不会添加到我们不想修改的list中的值了。

重点

有的时候,我们不想在lua中被设置了一个全局变量。因为lua其实也相当于一个表,它的全局变量存在_G中,首先我们可以先获取_G,然后给_G设置一个不能添加修改值的这个方法。