Shader中为什么尽量不要写除法和逻辑判断

简介:

在看过的很多书和文章中,作者都会告诉大家在Shader中尽量不要写除法和逻辑判断,特别是在片元着色器中,因为会消耗更多的资源增加渲染的时间。但是很多人应该都只是记下了不要写除法和逻辑判断,写Shader的时候也会记住不写if或者三目运算,除法却是很少会去注意的一个点。

之前从事App的开发,基本不会去关注数字的加减乘除,毕竟在App里这方面的计算非常的少,而且资源消耗相对于繁杂的业务线和各种逻辑判断来说估计也可以忽略了。在写代码和后期优化的时候也只是对于if这种硬判断利用语言特性和代码设计来做一些替换,对于计算机底层到底是如何实现加减乘除和逻辑判断一直也没有特地的去了解,直到学习Shader开发基本都在数字计算才之后才有了好奇心想去弄明白。

数的加减乘除

大家都知道计算机是使用二进制的,我们写的代码 int a = b + c,会被编译成二进制后再进行计算,所以所有的计算都是在二进制的基础上进行的。

加法

加法二进制的数据最终会走到物理层,也就是计算机的全加器逻辑电路:

其中,异或门的输出 Y = A ^ B ,与非门的输出就是先与再非,即 Y = !(A & B) 。我们再来看一下上面全加器的逻辑,通过推导可以得到下面公式:

Sum = A ^ B ^ Cin
Cout = (A ^ B & Cin) | (A & B)

其实看到这里的时候我也是有点蒙圈的,但是在看完代码后理解起来就比较容易了,下面是用代码模拟的一个加法器:

public int GetSum(int a, int b){
    if(b == 0){
        return a;
    }
    int sum, carry;
    sum = a ^ b;
    carry = (a & b) << 1;
    return GetSum(sum, carry);
}

这是一个递归函数,其中的位运算符就不介绍了,不太懂的可以看一下:

按位与、或、非、异或总结​www.jianshu.com/p/cfb7df8d3a8b正在上传…重新上传取消

来计算一个2+3的过程过一遍代码会更清楚计算过程:

数字小只用写出4位方便理解
第一轮计算
a = 2 = 0010, b = 3 = 0011
sum = a^b = 0001
carry = (a&b)<<1 = 0100
第二轮计算
a = 0001, b = 0100
sum = 0101
carry = 0000
第三轮计算
a = 0101 = 5, b = 0000 = 0
因为b==0,return a
得到的就是2+3=5的结果

总结下来加法就是不断的异或+移位的操作结果。

减法

在计算机中只有加法,没有减法。那么怎么在计算机中计算减法呢,就是加负数,将减数变为负数,然后加起来得到最后的结果。这就涉及到了补码。

  • 补码

计算机中的数字是有符号的,用最高位的“0”代表+,“1”代表“-”,剩下的数位代表了数的值。例如Byte类型的数据占了1个字节,1个字节是8位,也就是0|000 0000,取值范围为-128~127。因为有7位来表达数字,所以正整数为0~127,为什么负数能到-128,看完补码是什么就明白了。

补码规定,正数和0的补码就是其原码,负数的补码就是其正数的原码取反再加1。举例子来看:

-2的补码:
二进制的2原码为 0000 0010,其反码为 1111 1101,再加1得到其补码:1111 1110。
因此我们看-128:
二进制128的原码为 1000 0000,反码为 0111 1111,再加1得补码:1000 0000。

-128原码和补码是一样的,所以1000 0000会直接被理解为是-128,因为首位有1被标记称为了负数。

如果在声明变量的时候在类型前面加了unsigned,如unsigned int,将变量标记为无符号int,那么1000 0000就可以代表正整数128

了解了负数和补码,我们继续看计算机是怎么计算减法的,用3-2来看一下减法的过程:

a = 3 = 0000 0011, b = 2 = 0000 0010
c = a - b = a + (-b)
c = 0000 0011 + 1111 1110
c = 1 0000 0001
因为我们是8位计算的,最高位因进位得到的1被舍弃,得到的结果就是0000 0001 = 1,即3-2=1
最高位因为计算而进位得到的1被称为溢出,在平时开发过程中如果两数相加或者相乘得到的结果太大,超过了声明变量类型的最大值就会溢出,这种溢出在某些情况下会导致计算结果错误

再用2-3来看一下如何得到负数的结果:

a = 2 = 0000 0010, b = 0000 0011
c = a - b = a + (-b)
c = 0000 0010 + 1111 1101
c = 1111 1111
反向取原码,减1 = 1111 1110,取反 = 0000 0001
得到的就是-1

减法就是在加法的基础上,执行加法前对减数进行变负数的操作。

乘法

在计算机中乘法有两种计算方式,一种就是循环加法,如果2+3就是2+2+2。还有一种是乘法器,乘法器分为模拟乘法器和硬件乘法器,想具体了解的可以网上查一下。

循环加法就不说了,用2*3来看一乘法器是如何计算乘法的:

2 * 3 = 0000 0010 * 0000 0011
3的第0位是1,2左移0位,结果为0000 0010 = 2
3的第1位是1,2左移1位,结果为0000 0100 = 4
3的其他位都是0,因此不再移位
两次左移的结果相加,即0000 0010 + 0000 0100 = 2 + 4 = 6

再用5*7计算一次:

5 * 7 = 0000 0101 * 0000 0111
7的第0位是1,5左移0位,结果为0000 0101 = 5
7的第1位是1,5左移1位,结果为0000 1010 = 10
7的第2位是1,5左移2位,结果为0001 0100 = 20
7的其他为都是0,不再移位
移位结果相加,即5+10+20 = 35

可以看出乘法器是根据乘数的bit位的0与1,来将被乘数进行左移多少位,然后累加得到最终的结果。

除法

除法可以通过循环减法来实现,比如5/2,5一直减2,知道减完的结果小于2,得到的就是除法的结果,余数就是剩下的1。除法和乘法一样现在有了专门做除法的逻辑电路。

用50 / 20来看一下除法器的原理:

50 / 20 = 0011 0010 / 0001 0100
取50的最高位001|1 0010,001 < 0001 0100
左移一位0011| 0010, 0011 < 0001 0100
左移一位0011 0|010,0 0110 < 0001 0100
左移一位0011 00|10, 00 1100 < 0001 0100
左移一位0011 001|0, 001 1001 > 0001 0100, 商1,余101|0
将大于20的数位记录下来就是0000 0010,结果为2,余数1010 = 10
所以结果就是50 / 20 = 2 余 10

可以看出除法器是根据从被除数高位开始取数,每次移动一位取到数后和除数比较,如果大于除数就记录当前被除数的数位,然后将记录的结果变为二进制数就是最后的结果。

总结

在没有乘法器和除法器的时候,计算乘法和除法只能用循环加法和循环减法的方式。到后来计算机有了乘法器和除法器后,也不是所有的乘法和除法都会使用新的逻辑电路,不同的编译器会有自己的优化处理方式,会根据一些条件来判断应该是用循环还是使用新的逻辑电路。

粗暴的对加减乘除进行一个资源消耗的比较:

加法:加法

减法:变负数+加法

乘法:累加: 加法+加法+·····;乘法器:位数倍的 (移位+加法)

除法:累减:减法+减法+减法······; 减法器:位数倍的 (移位+比较)

再次粗暴的可以看出虽然现在有了乘法器和除法器,计算的效率虽然比累加和累减已经优化的非常多了,但是依旧是非常消耗的一个操作,特别是除法还要进行大于小于的判断。所以在片元着色器中写除法是一个非常吃资源的操作,可以在尽可能的情况下将除法转换为其他运算的组合。

感谢评论区朋友提醒:在除法优化上可以使用乘以除数的倒数( 即 * rcp(x) ),来代替除法。

以上是以整数来简单看一下加减乘除的基本原理,浮点型在表示和计算上都比整形的更复杂,在《计算机组成与设计:硬件、软件接口》的第3章有对整数、浮点数表示和计算的详细讲解,以后有时间会总结一下。

逻辑判断

逻辑判断是所有程序中必不可少的操作,很多人会用简单粗暴的if-else进行业务的划分,设计能力差的人能够写出几百行的嵌套if-else来处理业务,代码可阅读性、维护性是灾难级别的。

但是一般这样的逻辑都是跑在CPU上的,CPU作为计算机控制和运算核心,主要功能就是解释计算机发出的指令以及处理电脑软件的数据。而GPU作为图像处理器,是一种专门用来进行图像运算的微处理器,专为执行复杂的数学和几何计算而生的。

网上找来的架构图

用大家经常打的比方,CPU的核心是大学生,GPU的核心是小学生,小学生擅长进行简单的加减乘除,大学生除了加减乘除更擅长逻辑处理。

GPU的工作大部分就是,计算量大,但是没什么技术含量,而且要重复很多次,所以它集成了成百上千个小学生,大家一起算加减乘除,速度自然是很快的,但是没有逻辑控制单元,在处理一些像if-else这样的操作时效率就会大打折扣。上图是一个很老的架构,现在的GPU已经升级到可以做一些稍微复杂的工作,但是对于不擅长的逻辑控制还是交给CPU比较好。

关于if-else、三目运算的底层实现和效率比对可以看一下:

If-else 三目运算符 底层实现 效率差异​blog.csdn.net/cFarmerReally/article/details/54583895?utm_source=blogxgwz33正在上传…重新上传取消

在Unity Shader中写的三目运算最后也会被编译为if-else,在Shader中的每个片元都会执行逻辑判断的每个分支,但只在每个片元应该采取的分支上写入寄存器。所以在Shader中不推荐使用逻辑判断,可以用Shader变体来进行不同状态下的渲染分支,用一些函数代替if-else的判断。例如可以用setp()函数来做大小与的判断:

a = step(b, c);// 等价于 if (b <= c) a = 1 else a = 0

举一个具体的例子,给角色加一个轮廓光,根据NdotV的结果来决定光越往边界越强,然后用一个变量来控制光在角色表面的范围。

刚开始写的时候用的就是三目运算:

fixed3 c = (1 - NdotV) < _StatusColorEdge ? fixed3(0,0,0) : blendColor;

后来优化一次变为clamp和ceil函数的组合,clamp函数将NdotV和边界控制的结果限制在0-1的范围,ceil将结果向下取整变为0或者1,然后对颜色进行插值:

fixed temp = ceil( clamp(1 - NdotV - _StatusColorEdge, 0, 1));
fixed3 c = lerp(fixed3(0,0,0), blendColor, temp);

最后用step函数代替两个函数,同时减法也换成了加法:

fixed temp = step(NdotV + _StatusColorEdge, 1);
fixed3 c = lerp(fixed3(0,0,0), blendColor, temp);

补充:

在Shader内做if判断的时候,分为两种情况:

if (a > b)
x
else
x
  • b为const float 或者#define,静态条件,同一批像素拿到的都是同样的值,这类编译器会做优化,直接进行批量计算;
  • b为动态数值,也就是只有在运行的时候才会知道具体的值,并且同一批像素会在一个区间内浮动,那么同一时间有的返回true有的返回false,就会导致GPU做分批处理,无法最大化利用同时计算。

静态的判断条件影响不大,确实比调用函数的开销要小。动态判断条件和三目运算代替if的情况暂时还没有查到比较靠谱的资料,查清楚后再做更新。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在OpenGL实现高效的图像亮度均衡可以通过使用fragment shader来实现。具体实现步骤如下: 1. 将图像的RGB通道转换为亮度值(灰度值),这可以通过将RGB通道加权平均来实现。一种常用的方法是使用以下公式: `luminance = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;` 2. 计算图像的亮度直方图。可以使用glHistogram函数来计算亮度直方图。 3. 计算亮度直方图的累积分布函数(CDF)。可以使用glHistogram函数计算累积分布函数。 4. 将CDF映射到0到1的范围内。这可以通过将CDF的每个值除以像素总数来实现。 5. 将CDF映射到0到255的范围内。这可以通过将CDF的每个值乘以255来实现。 6. 将图像每个像素的亮度值替换为CDF值。可以使用fragment shader来实现这一步骤。 下面是一个简单的fragment shader代码,可以实现亮度均衡: ``` uniform sampler2D tex; uniform float cdf[256]; void main() { vec4 color = texture2D(tex, gl_TexCoord[0].xy); float luminance = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; float cdf_value = cdf[int(luminance * 255.0)]; gl_FragColor = vec4(vec3(cdf_value), color.a); } ``` 在上面的代码,我们传入了一个名为`cdf`的uniform数组,该数组存储了映射到0到255的CDF值。在shader,我们首先计算像素的亮度值,并将其乘以255,以将值映射到0到255的范围内。然后,我们使用这个值来查找相应的CDF值,并将其用于替换像素的亮度值。最后,我们使用新的亮度值生成一个新的颜色,并将其输出为shader的输出。 在使用这个shader之前,我们还需要计算图像的亮度直方图和CDF。下面是一个示例代码,可以实现计算亮度直方图和CDF的功能: ``` GLuint histogram_buffer; glGenBuffers(1, &histogram_buffer); glBindBuffer(GL_PIXEL_PACK_BUFFER, histogram_buffer); glBufferData(GL_PIXEL_PACK_BUFFER, 256 * sizeof(GLuint), NULL, GL_DYNAMIC_COPY); glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); void calculate_histogram(GLuint tex_id) { glBindBuffer(GL_PIXEL_PACK_BUFFER, histogram_buffer); glReadPixels(0, 0, width, height, GL_RED_INTEGER, GL_UNSIGNED_INT, 0); glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); GLuint* histogram_data = (GLuint*)glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY); GLuint num_pixels = width * height; float cdf[256]; GLuint cumulative_sum = 0; for (int i = 0; i < 256; i++) { cumulative_sum += histogram_data[i]; cdf[i] = (float)cumulative_sum / (float)num_pixels; } glUnmapBuffer(GL_PIXEL_PACK_BUFFER); glBindBuffer(GL_UNIFORM_BUFFER, cdf_buffer); glBufferSubData(GL_UNIFORM_BUFFER, 0, 256 * sizeof(float), cdf); glBindBuffer(GL_UNIFORM_BUFFER, 0); } ``` 在上面的代码,我们首先创建一个名为`histogram_buffer`的像素包装缓冲区,用于存储图像的亮度直方图。然后,我们使用glReadPixels函数将图像的亮度直方图读取到这个缓冲区。接着,我们计算亮度直方图的CDF,并将其存储在一个名为`cdf`的数组。最后,我们将CDF存储到一个名为`cdf_buffer`的统一缓冲区,以便在shader使用。 在使用这个代码之前,我们还需要创建一个名为`cdf_buffer`的统一缓冲区,并将其绑定到shader的`cdf` uniform数组。这可以通过以下代码实现: ``` GLuint cdf_buffer; glGenBuffers(1, &cdf_buffer); glBindBuffer(GL_UNIFORM_BUFFER, cdf_buffer); glBufferData(GL_UNIFORM_BUFFER, 256 * sizeof(float), NULL, GL_DYNAMIC_DRAW); glBindBufferBase(GL_UNIFORM_BUFFER, 0, cdf_buffer); GLuint cdf_location = glGetUniformLocation(shader_program, "cdf"); glUniform1fv(cdf_location, 256, NULL); glBindBuffer(GL_UNIFORM_BUFFER, 0); ``` 在上面的代码,我们首先创建一个名为`cdf_buffer`的统一缓冲区,并将其绑定到0号绑定点上。然后,我们使用glUniform1fv函数将缓冲区的数据绑定到shader的`cdf` uniform数组。最后,我们将`cdf_buffer`解绑,以便在使用它时不会影响其他操作。 完整的代码可以参考以下链接:https://github.com/JoeyDeVries/LearnOpenGL/blob/master/src/6.pbr/2.2.2.ibl_irradiance_conversion/ibl_irradiance_conversion.cpp

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值