之前调某个图像处理Demo时,我在屏幕边缘做一个烟雾扭曲效果,结果被一个叫fwidth的函数卡了一整晚。那时候我才意识到,GLSL里那些看似不起眼的内置函数,背后藏着很大的信息量。很多人写Shader都会遇到类似情况:sin、cos、mix这些用得挺熟,但一碰到faceforward、refract、matrixCompMult就开始发懵,更不用说dFdx这种只能在片元着色器里用的导数函数。其实GLSL核心内置函数并没有多么高深,它们只是把GPU硬件上通用的数学操作统一封装成语言级函数,但高效使用它们需要理解每个函数的语义、精度规则和适用的计算场景。
这篇文章我会从实际调Shader的经验出发,把GLSL里最常用、也最容易踩坑的核心内置函数分成几类拆开讲,内容包括函数语义、典型用法、性能注意点和调试技巧。无论你是刚学Shader的新手,还是想系统查漏补缺的老手,这份清单都能当一份随查随用的笔记。如果看完能少走一次我之前走过的弯路,那就值了。
1. 为什么建议你先系统梳理一遍GLSL内置函数
1.1 内置函数不是简单的“黑盒”
很多入门教程会把mix解释成“类似lerp”,然后就没了。但真实项目里,mix经常要和smoothstep、clamp一起用,才能在材质遮罩上做出干净的软边过渡。内置函数之间不是孤立的,它们的语义在GLSL规范里被定义为一种“纯函数”,即同样的输入总是产生同样的输出,不依赖任何全局状态,也没有副作用。这对编译器来说意味着可以做激进的优化,对我们来说意味着可以放心组合,不用担心某个函数悄悄改了变量。
更重要的一点是精度规则。GLSL内置函数是重载的,genType表示它可以同时支持float、vec2、vec3、vec4。比如sin(vec3)会返回一个vec3,每个分量独立计算。而且函数的返回值精度继承自输入的精度修饰符:highp输入返回highp结果,mediump输入返回mediump结果。这个规则在移动设备上尤其关键,因为如果你在片元着色器里声明了precision mediump float,然后去做大范围坐标运算,精度损失会直接反映在画面上。把内置函数当成黑盒是新手最容易犯的错,至少要了解它输入输出的精度规则和未定义边界条件。
1.2 分类是效率线索
GLSL核心内置函数大体可以分成这几类:
| 类别 | 典型函数 | 常见用途 |
|---|---|---|
| 角度与三角函数 | radians, degrees, sin, cos, tan, asin, acos, atan | 旋转、波形、方向角计算 |
| 指数与幂函数 | pow, exp, log, sqrt, inversesqrt | 光照衰减、曲线调整、归一化 |
| 取整与绝对值 | abs, sign, floor, ceil, fract, mod, min, max, clamp | 范围限制、周期纹理、量化 |
| 插值与阶跃 | mix, step, smoothstep | 渐变、遮罩、边缘过渡 |
| 向量几何函数 | dot, cross, length, distance, normalize, faceforward, reflect, refract | 光照、法线、反射折射 |
| 矩阵函数 | matrixCompMult, transpose | 颜色矩阵、坐标变换 |
| 纹理采样函数 | texture, textureLod, textureProj | 贴图采样、阴影、投影纹理 |
| 导数函数 | dFdx, dFdy, fwidth | 抗锯齿、屏幕空间效果 |
这个分类不光是为了查表方便,更重要的是理解GPU指令集擅长什么。sin、cos、pow通常对应专门的硬件指令,而floor、mod、fract多由基础算术指令组合。如果你在片元着色器的循环里大量调用pow,帧率会明显下降;这时换成乘法或查表技术,往往能救回来。把函数分类当成“成本地图”,写代码时自然会形成性能直觉。
1.3 内置函数也是跨平台兼容层
GLSL内置函数是OpenGL规范的一部分,不同GPU厂商只负责保证语义一致,具体实现可以不同。比如pow(x, y)在不同硬件上可能被编译成exp(y * log(x)),这就导致它在x接近0或负数时会带来精度差异。理解这个背景,你就明白为什么规范里会有那么多“未定义”条目。移动GPU和桌面GPU完全相同的一段Shader,显示效果可能有肉眼可见的差别,往往就是某个内置函数在不同驱动上的数值精度或边角行为不同。
所以系统梳理内置函数,本质上是在建立一套“跨平台安全操作守则”。哪些边界条件必须避开、哪些结果在不同设备上不可依赖、哪些函数性能成本高,这些知识比背函数签名重要得多。我下面展开的每个类别,都会穿插这类避坑经验。
2. 标量数学函数:从角度换算到波形生成
2.1 角度与三角函数
先记住一个前提:GLSL里的sin、cos、tan等三角函数,输入和输出默认都是弧度制。为了让美术参数更直观,通常会用radians()把角度转成弧度,比如radians(45.0)结果约等于0.7854。反过来用degrees()可以把弧度转回角度。我见过不少项目直接给sin传角度值,结果动画频率完全不对,差了一个57.2958的系数。
三角函数族里最值得注意的是atan的两个重载。atan(y, x)是象限感知版本,返回值范围是[-π, π],可以根据y和x的正负确定正确的方向角。而atan(p)只返回[-π/2, π/2],对应的是p = y/x。做极坐标扭曲、雷达扫描、光圈旋转这类效果时,几乎必用atan(y, x),因为它能自动处理x为0的情况,避免除零。我早期用atan(uv.y / uv.x)写旋转变换,屏幕右上和左下区域的方向角完全错乱,换成atan(uv.y, uv.x)后立刻正常。这个坑几乎每个Shader新手都会踩一次。
asin和acos用得相对少,它们的主要风险是输入必须严格在[-1, 1]区间,如果超出,结果未定义。实际计算中,两个单位向量的点乘结果可能因为浮点误差变成1.000001,直接用acos会返回NaN。稳妥做法是先clamp(dotResult, -1.0, 1.0)再交给acos。
2.2 幂函数、指数、对数与开方
pow(x, y)计算x的y次方,但规范明确:当x小于0时结果未定义。这也不难理解,因为GPU上pow通常通过exp(y * log(x))实现,负数取对数直接无解。如果你需要处理带符号的数,比如“负数的平方”,请写成x * x,不要写pow(x, 2.0)。另外pow(x, 2.0)在性能上也比一次乘法慢,能用乘法就用乘法。
exp(x)和log(x)在Shader里不像在CPU编程里那么常用,但在雾效、粒子和曲线调色中会碰到。比如指数衰减的雾密度可以用exp(-distance * density),脉冲波形可以用exp(-abs(x) * scale)。对数函数常用于压缩动态范围,比如把过亮的HDR值映射到可显示范围。不过GPU上的log精度不算高,做颜色校正时如果要求非常准,建议用highp并保留更多尾数。
sqrt(x)计算平方根,inversesqrt(x)计算1.0 / sqrt(x)。后者在归一化向量和光照衰减中非常常见。为什么大家爱用inversesqrt?因为GPU里求平方根倒数有专门的近似指令,通常比先sqrt再做除法更快。但注意,inversesqrt输入必须是正数,零或负数都会得到Inf或NaN。调用normalize时,如果向量长度恰好为0,内部就可能触发这种问题,所以对自己构造的动态向量要格外小心。
2.3 绝对值、取整、余数与范围控制
abs(x)和sign(x)最基础,也比较安全。sign(0.0)在不同设备上可能返回0,也可能返回1或者-1,规范里其实没有完全定义,所以不要依赖sign(0)的结果。floor(x)向下取整,ceil(x)向上取整,fract(x)返回小数部分,满足x = floor(x) + fract(x)。这三个函数是程序化纹理和网格动画的基石。
mod(x, y)的符号行为需要特别注意。GLSL规范定义的结果符号与y相同,而C语言的fmod符号与x相同。举例来说,mod(-1.0, 2.0)在GLSL里结果是1.0,而C的fmod(-1.0, 2.0)结果是-1.0。如果你在做重复UV时需要把负坐标镜像进0到1区间,mod的符号规则会直接决定平铺效果是否正常。我习惯在编辑器里先验证一遍,把所有边界值代入算一下,再写进Shader。
min、max、clamp是范围控制三兄弟。clamp(x, lo, hi)本质是min(max(x, lo), hi),但一次调用更清晰。这些函数在颜色校正中极其高频,比如把法线方向压缩到0到1便于存储,或者把过爆高光拉回可显示范围。要注意clamp里的边界参数顺序,如果lo > hi,结果未定义,不过正常人不会写反。
2.4 实操示例:三个函数组合出呼吸灯和循环波形
内置函数单独看都很简单,组合起来才出效果。比如做一个LED呼吸灯效果,核心逻辑是让亮度随时间在0到1之间平滑变化。可以这么写:
float breathing(float time, float speed) { float t = time * speed; float v = sin(t); // 范围[-1, 1] v = v * 0.5 + 0.5; // 映射到[0, 1] return v; }这已经用到了sin、乘法和加法。如果想让它在某个区间突然变暗,可以再加pow做曲线压缩:
float breathingWithGamma(float time, float speed, float gamma) { float v = sin(time * speed) * 0.5 + 0.5; return pow(v, gamma); }再用step或smoothstep就能把正弦波变成脉冲波:
float pulse = step(0.8, fract(time * speed));实际项目里,你会在一个uniform里控制speed和gamma,然后在片元着色器里把这些函数组合起来,最终效果就像呼吸灯一样柔和。这类组合本身就是GLSL核心内置函数的主要用法:以极少的指令构造出丰富的模拟信号。理解每个函数输出的值域和波形,比死记API重要得多。
3. 向量与矩阵几何函数:光照和形变的地基
3.1 点乘和叉乘别只看公式
dot(a, b)计算两个向量的点乘,结果是a.x*b.x + a.y*b.y + a.z*b.z。几何意义是length(a) * length(b) * cos(θ)。当两个向量都是单位向量时,点乘结果就是夹角的余弦值,这是光照计算里最常用的关系。比如Lambert漫反射:
float diffuse = max(dot(normal, lightDir), 0.0);这里max保证了法线和光线方向在背面时结果是0,而不是负值。
cross(a, b)返回一个同时垂直于a和b的向量,常用于从两个方向构建切线和法线关系。需要注意的是,叉乘的结果方向依赖于坐标系的手性。GLSL默认使用右手系,因此cross(axisZ, axisX)会指向轴Y的正方向。如果你在左手系软件里导出的模型,法线方向可能会反,需要在导入Shader前统一坐标约定。
3.2 长度、距离和归一化的性能思维
length(v)等于sqrt(dot(v, v)),distance(a, b)等于length(a - b),normalize(v)等于v / length(v)。这三个函数是几何计算的地基,但性能上有一个很经典的建议:如果只是比较距离,不要开根号,直接比较距离平方:
float distSq = dot(offset, offset); if (distSq < radiusSq) { ... }避免sqrt在片元着色器大量调用时特别有效。我在写某个光晕效果时,用了dot(uv, uv)代替distance(uv, vec2(0.0)),视觉结果几乎一致,但局部循环的负担明显下降。当然,如果你确实需要实际距离做衰减,length还是不可避免。
normalize函数内部通常用inversesqrt实现,所以它对零向量是非常敏感的。当向量分量都接近0时,结果可能是NaN或无穷大,后续所有依赖该向量的光照都会崩。稳妥做法是先判断长度是否大于某个极小值,再决定是否归一化,或者直接给向量加上一个epsilon。在顶点着色器里做归一化通常成本更低,因为顶点数量往往远小于片元数量,这也是优化Shader的关键思路。
3.3 反射、折射与面法线朝向
reflect(I, N)计算入射向量I关于法线N的反射方向,公式是I - 2.0 * dot(N, I) * N。需要注意,这里的I应该是指向表面方向还是从表面出发的方向,规范默认用它“指向表面”的入射方向,返回“离开表面”的反射方向。很多自己写反射计算的人在这里会对不上正负号,建议画个小图确认。使用内置函数的好处就是不用自己推公式,但参数语义必须理解。
refract(I, N, eta)根据斯涅尔定律计算折射方向,eta是折射率之比。如果发生全反射,函数返回vec3(0.0),所以调用后要检查结果是否接近零向量。这在做玻璃、水面折射时很实用。
faceforward(N, I, Nref)的作用是让法线N方向与视线方向I一致。具体规则是:如果dot(I, Nref) < 0.0,返回N,否则返回-N。也就是说,它会把法线“翻转”到朝向观察者的一面。在双面渲染、植物叶片、毛发这类需要双面光照的场景中,这个函数是救命的,否则背面法线和正面法线方向相反,光照会明显凹陷。我第一次用的时候没搞懂为什么返回的是负法线,后来画了个法线同侧视角的示意图就明白了。
3.4 矩阵内置函数与逐分量乘法
GLSL支持mat2、mat3、mat4,可以直接用列向量构造,也可以用transpose()做转置。比较容易被忽略的是matrixCompMult(A, B),它执行的是两个矩阵对应分量的乘法,而不是矩阵乘法。如果你需要的是标准矩阵乘法,直接用A * B;如果你需要把两个矩阵当作数值矩阵做逐元素运算,用matrixCompMult。颜色校正矩阵、一些后处理权重矩阵经常要用到逐分量操作,如果错误使用了A * B,就会把两次线性变换合在一起,画面结果完全不可控。我在某个调色效果里就犯过这个错,排查了很久才发现是把两者混用了。
需要说明的是,矩阵构造时也可以传入标量,如mat4(1.0)会构造不含旋转缩放的单位对角矩阵。但如果你用mat4(vec4(1.0), vec4(1.0), vec4(1.0), vec4(1.0)),得到的是所有元素都为1的矩阵,不是单位矩阵。这个差异很容易让人误解,尤其是从其他语言转过来的人。
3.5 实战场景:Blinn-Phong高光里的向量函数组合
把上面这些函数串起来看,一个经典的Blinn-Phong高光Shader就是向量内置函数的组合拳:
vec3 lightDir = normalize(lightPos - fragPos); vec3 viewDir = normalize(viewPos - fragPos); vec3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfDir), 0.0), shininess);这里用了normalize、加法、dot、max、pow。如果没有normalize,halfDir的长度随视角变化,高光边缘就会闪烁;如果没有max,背面方向会出现负值,高光会被错误计算;如果没有pow,高光就没有集中度。整个函数链路上,每个环节都不可少。实际调试时,可以通过把中间结果可视化到颜色通道来验证,比如把dot(normal, halfDir)直接输出,你能看到类似于转发波的黑白图像,再判断哪一步偏离了预期。
4. 插值函数:mix、step、smoothstep与clamp
4.1 四个函数的核心语义和差异
mix(x, y, a)执行线性插值:x * (1.0 - a) + y * a。当a等于0时返回x,a等于1时返回y。它支持标量和向量,并且a可以是向量,实现逐分量插值。step(edge, x)是硬边阶跃:x小于edge返回0,否则返回1。smoothstep(edge0, edge1, x)在edge0和edge1之间做三次Hermite插值,返回0到1的平滑过渡,起始和结束位置的切线都是0。clamp(x, lo, hi)则是夹紧函数。
这四个函数几乎可以替代Shader中80%的简单分支逻辑。GPU的分支虽然不像CPU那样是灾难,但动态分支在片元着色器里仍然可能导致两个分支都被执行,因为GPU按像素块并行执行。用mix和step做选择,在某些移动设备上更稳定,也更容易被编译器优化为算术指令。
4.2 用smoothstep实现软边、硬边和自定义缓动
smoothstep(0.0, 1.0, x)在x=0时返回0,在x=1时返回1,中间是平滑上升曲线。由于它的导数在端点为0,不会出现线性过渡那样的“尖角”,做边缘光、云层软边、遮罩泛光非常合适。如果你想要更宽的过渡带,可以把两个edge之间的距离拉大;想要更窄,就缩小距离。例如:
float softEdge = smoothstep(0.4, 0.6, uv.x);如果edge0和edge1之间的距离非常小,比如0.49到0.51,过渡带就会看起来很锐利,但仍然是连续函数。千万不要把两个edge设为相等,规范规定这种情况结果未定义。之前我见过有人把smoothstep(0.5, 0.5, x)当作硬边用,结果有些GPU返回全1,有些GPU返回全0,和预期完全不符。想要硬边就用step,想要软边就用smoothstep,这是最稳妥的分工。
smoothstep还有一个进阶玩法:把它嵌套使用或与其他函数组合,构造自定义缓动。比如做“两端更软、中间更陡”的过渡:
float t = smoothstep(0.0, 1.0, smoothstep(0.0, 1.0, x));这样做出的波谷更圆润,波峰更尖锐,常用在程序化生成地形的高度权重中。
4.3 范围重映射的标准写法
在Shader里经常需要把一个区间映射到另一个区间,比如把法线坐标从[-1,1]映射到[0,1]。标准写法是:
float remap(float value, float min1, float max1, float min2, float max2) { float t = (value - min1) / (max1 - min1); return mix(min2, max2, clamp(t, 0.0, 1.0)); }这个函数本质上是先归一化到t,再用mix完成目标区间映射。clamp保证了越界时的安全行为,不会让结果跑到区间外。如果不需要严格夹紧,可以去掉clamp。这个写法在调参和材质混合中太常用了,建议直接封装成自己的工具函数。很多Shader库里的remap函数就是这么实现的。
4.4 材质遮罩与多纹理混合
mix不只是用来插值颜色,还能做逐像素材质混合。比如有两张基础色贴图colorA和colorB,你可以用一张高度图作为遮罩:
vec3 color = mix(colorA, colorB, mask.r);如果遮罩边缘太生硬,先用smoothstep或clamp处理一下mask.r,就能得到干净的过渡。更复杂的多纹理渐变,可以逐级mix,比如先把草和泥混合,再把结果与岩石混合。在做地形渲染时,这种思路就是最基本的分层权重控制。比起用if判断UV区域,mix和smoothstep组合出来的过渡更自然,也更容易做tiling与全局变化。
5. 纹理采样与屏幕空间导数:后处理与抗锯齿的幕后功臣
5.1 从texture2D到texture:不同采样函数的适用场景
在旧版GLSL中,纹理采样函数叫texture2D,现代GLSL统一使用texture(sampler, uv),并且根据采样器类型自动匹配纹理目标。sampler2D对应2D纹理,samplerCube对应立方体纹理,sampler3D对应3D纹理。如果你还在写texture2D,在较新的GLSL版本里会编译失败,赶紧换成texture。
除了常规采样,还有几个变体很有用:
textureLod(sampler, uv, lod):手动指定mipmap层级,适合阴影贴图、延迟渲染等需要固定LOD的场景。textureProj(sampler, uvProj):投影纹理采样,会自动按uvProj.xy / uvProj.w计算UV,常用于贴花和阴影投影。texelFetch(sampler, ivec2, lod):直接按像素坐标读取纹素,不做任何过滤,适合后处理中精确取数据。
在实际项目里,纹理采样是最容易被忽略的性能瓶颈。无条件texture在GPU上会走硬件采样器,但如果采样点数过多,纹理带宽会被吃满。能合并到一张图里的数据就尽量合并,不要一次采十几张纹理。
5.2 导数函数的原理和限制
dFdx(value)和dFdy(value)分别返回value在屏幕x方向和y方向上的偏导数。fwidth(value)返回两者绝对值之和。它们之所以存在,是因为GPU在光栅化时通常按2x2像素块执行片元着色器,可以通过同一像素块内相邻像素的数据差值,估算出某个变量在屏幕空间中的变化率。
导数函数的用途非常广。最常见的目的是为程序化纹理选择mipmap级别,避免出现锯齿或摩尔纹。另一个用途是做屏幕空间边缘检测,比如提取轮廓线。还有后处理里的模糊半径调整,都需要知道像素在屏幕空间的导数。
但导数函数有限制:只能在片元着色器中使用,并且不能出现在非统一控制流(如依赖于动态值的if分支)内部。如果你在循环里或条件分支中调用dFdx,编译器可能直接报错,或者结果未定义。要处理这个限制,通常可以把导数计算提到分支之前,或者用mix把分支转换成算术选择。
5.3 用fwidth实现程序化纹理抗锯齿
程序化纹理的经典问题:一个简单的黑白棋盘格,在摄像机缩放时会剧烈闪烁,因为采样点跨越了纹理边界。用fwidth可以根据屏幕空间变化率动态调整过渡带宽度,从而实现抗锯齿。
核心思路是这样的:
float gridLine(vec2 uv, float thickness) { vec2 grid = abs(fract(uv - 0.5) - 0.5) / fwidth(uv); float line = 1.0 - min(min(grid.x, grid.y), 1.0); return smoothstep(thickness, 1.0, line); }这里fwidth(uv)给出了uv在相邻像素间的变化量,除以它可以让过渡带宽度以屏幕像素为单位稳定下来。如果没有这一步,棋盘格的边缘会随距离变化产生明显的闪烁。这个技巧可以推广到圆形、条纹、螺旋线等所有基于连续函数的程序化纹理。用smoothstep包一层,是为了让边缘不是生硬的0/1跳变,进一步减少锯齿。
5.4 纹理采样异常排查
纹理采样遇到问题时,我最常检查几个点:UV是否越界,mipmap级别是否选择错误,以及采样器类型是否和纹理目标匹配。UV越界时,默认行为依赖纹理环绕方式,如果你希望边缘透明但忘记设置CLAMP_TO_EDGE,就会出现奇怪的包边。mipmap的问题更隐蔽,比如在textureLod里手动传了一个负的LOD,采出来的纹理可能很模糊或完全不对。
另一个常见问题是textureProj忘掉除以w。投影纹理要求纹理坐标是齐次坐标,但如果在采样前没有正确构造vec4(uv, depth, w),画面会拉伸或扭曲。调试时可以先用固定值做静态图,逐步排查是UV问题还是采样参数问题,不要一上来就怀疑采样函数出故障。纹理采样内置函数本身很稳定,大多数异常都是参数构造或纹理资源状态引起的。
6. 常见问题排查与性能优化清单
6.1 精度修饰符是最大的隐形坑
移动端GLSL最常见的坑是精度选择。片元着色器里如果不写精度声明,某些编译器会默认用lowp或mediump,而mediump在旧设备上只有16位有效精度,算UV偏移和世界坐标时误差会被放大。我遇到过一次地平线附近纹理抖动的现象,最后发现是片元着色器把highp误写成了mediump,导致法线数据在传递过程中丢失了精度。
建议的精度策略是:需要参与几何计算的位置、法线、深度、UV偏移都用highp;纯颜色权重和简单遮罩可以用mediump;极少数对精度不敏感的发光效果可以用lowp。要特别注意的是,内置函数的返回值精度是由输入精度决定的,所以输入变量的精度声明比函数本身更重要。写代码时别图省事,把所有变量全声明成mediump,等出现离奇的闪烁或位移再排查,成本就高了。
6.2 安全使用atan、pow、normalize的边界保护
总结一下前面提到的边界问题,写成安全调用的几点建议:
- 算角度时优先用
atan(y, x),避免atan(y / x)带来的符号丢失和除零问题。 - 调用
pow前判断底数是否为负数,如果可能为负,改用乘法和条件选择。 - 调用
normalize或inversesqrt前,对向量长度做最小阈值保护,例如length < 1e-6时直接使用固定向量。
这些保护看着琐碎,但在不同GPU上的表现可能天差地别。CPU上浮点溢出最多是NaN,到了Shader里就可能渲染成整片黑屏或者诡异的彩色噪点。调试时如果遇到画面局部出现黑线和闪烁,优先怀疑这些边界函数,而不是光照算法整体写错了。
6.3 smoothstep的edge相等未定义
前面已经提过,这里再强调一次:smoothstep的两个edge必须满足edge0 < edge1,如果相等或反向,规范不保证结果。我见过一些简化代码,用smoothstep(radius, radius, dist)想做硬边边界,结果在一个平台上是圆形,在另一个平台上是满屏闪烁。用step才是正确的硬边工具。如果确实需要在两个很接近但不相等的值间做过渡,可以把radius * (1.0 - epsilon)和radius * (1.0 + epsilon)作为edge,得到的软边更可控。
6.4 调试技巧:把函数值可视化到颜色通道
调试内置函数的一个高效方法,是把函数输出直接映射到颜色输出上。比如想查看smoothstep的值,直接写:
color = vec3(smoothstep(0.2, 0.8, uv.x));屏幕上会显示从黑到白的渐变,你可以直观判断过渡位置和宽度。想查看向量函数方向,可以用颜色分量分别代表x、y、z,但要注意范围。例如normalize后的法线范围是[-1,1],输出前要先乘0.5加0.5,否则一半会是黑色。这个技巧在排查光照向量方向时极其高效,比盯着数值猜要快得多。
另一个技巧是用fwidth来判断某个值在屏幕空间的变化速度。如果某个函数值的fwidth非常大,说明这里可能出现锯齿或闪烁,应该考虑引入smoothstep进行平滑。实时观察导数,能快速发现程序化纹理的抗锯齿需求。
6.5 哪些内置函数需要省着用
从性能角度看,片元着色器里需要谨慎使用的内置函数包括pow、sqrt、sin、cos、exp、log、length以及各种纹理采样函数。不是说不能用,而是要控制数量。下面是一个经验优先级的清单:
| 高成本 | 建议 |
|---|---|
pow(x, 2.0) | 改成x * x,一般能省一个专用指令 |
| 循环内纹理采样 | 考虑展开循环、预计算,或减少采样次数 |
| 动态分支 | 尽量用mix/step/clamp替代 |
片元内重复normalize | 能在顶点着色器算好的,提前算好再插值 |
length(delta) | 只需比较远近时,用dot(delta, delta)代替 |
这些不是严格禁令,而是优化时的优先思考方向。大多数Shader在简单效果阶段不需要太纠结性能,但一旦片元数量上到千万级别,每一次多余的开方或采样都可能让帧率掉10%。我优化某个后处理效果时,把循环里的一个normalize提到了顶点阶段,画面效果没变,帧率却提高了接近两成。
6.6 内置函数调试速查表
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 角度扭曲错乱 | 用了atan(y/x)而不是atan(y,x) | 改用象限感知重载 |
| 高光边缘闪烁 | 向量未归一化,或pow底数为负 | 检查中间变量,避免直接pow |
| 程序化纹理闪烁 | 缺少fwidth抗锯齿 | 用fwidth控制过渡宽度 |
| 暗部出现黑线 | normalize或inversesqrt吃了零向量 | 加长度保护 |
| 数值范围不对 | 忘记clamp或精度丢失 | 用可视化颜色通道查看函数输出 |
mix结果偏暗 | 误用了x*(a)+y*(a)?实际是线性插值 | 确认a在[0,1]区间 |
这张表是我日常排查问题的起点。遇到显示异常,先定位到具体的内置函数,再检查它的输入参数是否满足约束条件,往往能节省大量时间。
最后说一个我自己的体会。GLSL内置函数最大的价值不是单拎出来用,而是像积木一样互相配合。刚开始学的时候,我总喜欢背函数列表,后来发现记不住也没关系,重要的是理解每个函数在坐标空间和数据流里的位置。你可以在一个纯函数式框架里慢慢搭自己的工具函数库,比如把smoothstep封装成softEdge,把mix + clamp封装成remap,后面写效果会快很多。做项目时遇到显示不对,先不要怀疑函数有bug,大概率是参数顺序、精度或坐标系没有对齐。希望这份梳理能帮你少走一些弯路。