🎬 开场:一个"每秒算几十亿次"的战场
小王写了个漂亮的水面 Shader,PC 上流畅得很。
一放到手机上——帧率暴跌,手机发烫烫手!🔥他很委屈:“我代码逻辑没错啊,为什么这么卡?”
老鸟拿过来一看,摇摇头:“逻辑没错,但你的 Shader
里塞满了昂贵的计算。你要明白——
这段代码每一帧、每个像素都要跑一遍!”“一个 1080p 屏幕 = 200万像素,每秒 60 帧
=每秒要跑 1.2 亿次你的片元着色器!
你多写一个昂贵计算,就乘以 1.2 亿!”小王倒吸一口凉气:“原来 Shader 优化是在跟’亿次’较劲!”
🤔 第一幕:为什么 Shader 优化如此重要?
核心认知:Shader 是"海量并行"执行的
你的Shader代码,不是跑一次! 顶点着色器:每个顶点跑一次 (模型10万顶点 = 跑10万次) 片元着色器:每个像素跑一次 (1080p = 200万像素/帧) (60帧 = 每秒1.2亿次!) ↓ 你省下1条指令 → 省下上亿次计算 你多写1条昂贵指令 → 增加上亿次负担生动比喻:给一亿人发东西
Shader优化就像"给一亿人发传单": 传单上多印一个字(多一次计算) = 要多印一亿个字 ↓ 在这种规模下: 省一点点 × 一亿 = 省很多 浪费一点点 × 一亿 = 灾难 ↓ 所以Shader里"斤斤计较"是值得的!优化的两个战场
【顶点着色器】 执行次数 = 顶点数(相对少) 优化重点:能在顶点算的别放片元 【片元着色器】 执行次数 = 像素数(海量!) 优化重点:这是主战场,重点开刀! ↓ 核心策略:把计算从"片元"挪到"顶点" 或挪到"CPU/预计算"📐 第二幕:优化铁律——计算搬家
铁律1:能预计算的,别在Shader里算
计算频率从低到高: 预计算(离线) → 一次 CPU每帧 → 每帧几次 顶点着色器 → 每顶点(几万次) 片元着色器 → 每像素(几百万次!) ↓ 能往"低频"挪,就往低频挪!生动理解"计算搬家"
就像做菜准备食材: 片元着色器里算 = 每上一道菜现切菜 (客人多时忙死) 预计算好 = 提前把菜切好备着 (上菜时直接用) ↓ 把"重复不变的计算"提前做好 Shader里直接用结果铁律2:顶点能算的,别放片元
✅ 经典优化:计算挪到顶点着色器 顶点着色器算 → 每顶点算一次(几万次) ↓ GPU自动插值 片元拿到插值结果 → 直接用 对比: 片元里算 → 每像素算(几百万次) ↓ 能挪到顶点,省下几十倍计算!例子:光照计算的取舍
逐顶点光照(Gouraud): 顶点算光照 → 插值到片元 便宜! 但精度低(高光可能不准) 逐像素光照(Phong): 片元算光照 贵! 但精度高(高光准确) ↓ 移动端/远处物体:用逐顶点 近处重要物体:用逐像素 ↓ 按需选择,不要一律逐像素🔬 第三幕:昂贵操作黑名单
昂贵操作1:复杂数学函数
❌ 昂贵的数学函数: sin, cos, tan (三角函数) pow (幂运算) exp, log (指数对数) sqrt (开方) normalize (归一化,含开方) ↓ 这些比加减乘除贵很多!✅ 优化手段: - 能避免就避免 - 用查找表(纹理)代替 - 用廉价近似替代 - 合并同类计算昂贵操作2:除法
❌ 除法比乘法贵 a / b → 较慢✅ 优化: 预计算倒数,改用乘法 float invB = 1.0 / b; // 算一次 a * invB; // 之后用乘法 或多个除以同一个数: (a + c + d) / b → (a + c + d) * (1.0/b) // 只除一次昂贵操作3:分支(if/else)
❌ 动态分支可能很贵 GPU是"一群线程一起走"的 if (条件) // 不同线程条件可能不同 { 贵计算A } else { 贵计算B } ↓ 最坏情况:A和B都要算! (因为线程们步调不一致)✅ 优化分支: - 用数学代替分支 step()、lerp()、min/max - 例: if(x>0) a else b → lerp(b, a, step(0, x)) - 让分支条件在一批内一致 (uniform分支比动态分支便宜)昂贵操作4:纹理采样
❌ 纹理采样有开销(尤其带宽) 每次tex2D()都要: - 访问显存(带宽) - 可能的过滤计算 ↓ 采样次数多 = 带宽压力大 移动端带宽宝贵!✅ 优化采样: - 减少采样次数 - 合并多张贴图到一张(打包通道) 如:R存金属度, G存粗糙度, B存AO - 用合适的贴图分辨率(别过大) - 注意dependent texture read(依赖采样)昂贵操作黑名单总结
按贵的程度(移动端): 分支(处理不当) ★★★★★ 复杂函数(sin/pow/exp) ★★★★ 纹理采样(带宽) ★★★★ 除法 ★★★ normalize/sqrt ★★★ 乘法 ★ 加减 ★🎯 第四幕:精度优化——移动端的关键
精度类型:float / half / fixed
Shader里数据精度可选: float (高精度, 32位) → 慢, 占带宽 half (中精度, 16位) → 快, 移动端友好 fixed (低精度, 常见于颜色) → 最快 ↓ 移动端: 能用half就别用float!生动理解精度
精度就像用多大的秤: float = 精密电子秤(测细菌都行) 但慢、贵 half = 普通厨房秤 够用,快,便宜 ↓ 称白菜用不着测细菌的秤! 颜色、方向等用half足够精度选择原则
✅ 用half的场景: - 颜色(0~1范围, 精度够) - 法线、方向 - UV(小范围时) - 大部分中间计算 ⚠️ 需要float的场景: - 世界坐标(数值大, 需要精度) - 时间累积 - 深度相关精确计算精度优化例子
❌ 全用float: float4 color = tex2D(...); float3 normal = ...; ✅ 该用half用half: half4 color = tex2D(...); // 颜色half够 half3 normal = ...; // 法线half够 ↓ 移动端性能明显提升 带宽压力减小🛠️ 第五幕:具体优化技巧集
技巧1:MAD 合并(乘加)
✅ GPU对"乘加"有硬件优化 a * b + c → 一条MAD指令(超快) 所以尽量凑成乘加形式: ❌ (a + b) * c → 展开成 a*c + b*c 如果能凑MAD更好技巧2:向量化计算
✅ 一次算多个分量 GPU擅长向量运算(4个一起算): ❌ 分开算: float r = ...; float g = ...; float b = ...; ✅ 打包算: float3 rgb = ...; // 一次算3个 ↓ 充分利用GPU的向量单元技巧3:用查找表(LUT)代替计算
✅ 复杂计算 → 预算进纹理 比如复杂的颜色映射、曲线: 把结果预计算存进一张纹理 ↓ Shader里采样纹理取结果 用"一次采样"代替"一堆计算" ↓ Color Grading(颜色分级)常用此法技巧4:减少插值变量
✅ 顶点传给片元的变量要精简 顶点→片元的插值变量(varying) 每个都占带宽、占插值器资源 ↓ - 合并能合并的(打包进float4) - 删掉用不到的 - 减少数量技巧5:避免动态循环
❌ 循环次数不确定: for (int i = 0; i < 动态变量; i++) ✅ 用固定次数(能展开): for (int i = 0; i < 4; i++) // 编译期展开 ↓ 固定循环编译器能优化展开 动态循环开销大技巧6:discard 要谨慎
⚠️ discard(Alpha Test)的代价 discard会破坏Early-Z / HSR! (前面讲过) ↓ - 移动端尽量少用 - 必须用时,放对渲染队列 - 能用实体网格代替就代替📊 第六幕:如何定位 Shader 瓶颈
判断:是顶点瓶颈还是片元瓶颈?
测试方法: 改变屏幕分辨率: 分辨率降低,帧率明显上升 → 片元着色器瓶颈(像素相关) 改变模型面数/数量: 面数降低,帧率明显上升 → 顶点着色器瓶颈(顶点相关) ↓ 定位了瓶颈,才知道优化哪里工具1:GPU Profiler
Unity Profiler → GPU模块 或平台专用工具: - Xcode GPU工具(iOS) - Snapdragon Profiler(Android) - RenderDoc(通用抓帧) ↓ 看每个DrawCall/Shader的GPU耗时工具2:Frame Debugger
看渲染每一步 分析哪个Pass/Shader耗时工具3:Overdraw 视图
Scene视图 → 渲染模式 → Overdraw 看哪里Overdraw严重(颜色越红越浪费) ↓ 透明物体、粒子重叠区域 往往是Overdraw重灾区优化流程
1. Profiler确认是GPU瓶颈 2. 判断顶点瓶颈还是片元瓶颈 3. 对症下药: - 片元瓶颈 → 简化片元计算/降Overdraw - 顶点瓶颈 → 简化模型/减顶点计算 4. 改完再测,对比效果 ↓ 测量-优化-再测量,循环⚠️ 第七幕:优化误区
误区1:过早优化
❌ 一上来就死抠每条指令 正确顺序: 先保证功能正确 用Profiler找到真正的瓶颈 再针对瓶颈优化 ↓ 不要优化不是瓶颈的地方(浪费精力)误区2:盲目相信"少代码=快"
❌ "代码短就一定快" 不一定! - 一次纹理采样(1行) 可能比 十几行数学 更贵 - 关键看指令的"代价",不是行数 ↓ 要理解每种操作的真实开销误区3:忽视平台差异
❌ PC上快 = 手机上快 大错特错! - 移动GPU弱很多 - 带宽是移动端大瓶颈 - 精度(half)在移动端影响大 ↓ 一定要在目标设备上实测!误区4:牺牲太多画质
❌ 为性能把画质砍到没法看 优化是"性价比": 用最小的画质代价,换最大的性能 ↓ 找到画质和性能的平衡点 不是一味求快✅ Shader 优化检查清单
计算搬家: □ 能预计算的移到CPU/离线了吗? □ 顶点能算的没放片元吗? □ 逐像素计算是否必要(能否逐顶点)? 昂贵操作: □ 减少了sin/cos/pow/exp等? □ 除法改成乘法了吗? □ 分支能用数学(lerp/step)代替吗? □ 纹理采样次数最小化了吗? 精度优化: □ 移动端该用half的用half了吗? □ 只在必要处用float? 技巧应用: □ 用了向量化计算? □ 复杂映射用LUT纹理了吗? □ 插值变量精简了吗? □ 避免了动态循环? □ 谨慎使用discard? 定位验证: □ 确认是GPU瓶颈? □ 分清顶点/片元瓶颈? □ 在目标设备上实测了? □ Overdraw检查了吗?🎬 一句话总结
Shader 优化计算的核心:
永远记住"这段代码每帧每像素跑上亿次",
核心策略是"计算搬家"——
能预计算就别实时算,能顶点算就别片元算。警惕昂贵操作(复杂函数、除法、分支、纹理采样),
移动端善用低精度(half),
用向量化、LUT、MAD 等技巧榨干性能。先用 Profiler 定位真正瓶颈,在目标设备上实测,
找到画质与性能的最佳平衡点!
核心口诀:每帧跑上亿,计算要搬家,贵函数少用,精度用half,先测再优化,实机才算数!
💡 优化优先级速查表
| 优化手段 | 收益 | 难度 | 优先级 |
|---|---|---|---|
| 降低 Overdraw | 极高 | 中 | ⭐⭐⭐⭐⭐ |
| 计算挪到顶点/CPU | 高 | 中 | ⭐⭐⭐⭐⭐ |
| 减少纹理采样 | 高 | 中 | ⭐⭐⭐⭐ |
| 用 half 精度 | 高(移动) | 低 | ⭐⭐⭐⭐ |
| 减少昂贵函数 | 中高 | 中 | ⭐⭐⭐⭐ |
| 除法改乘法 | 中 | 低 | ⭐⭐⭐ |
| 分支优化 | 中 | 中 | ⭐⭐⭐ |
| 向量化 | 中 | 低 | ⭐⭐⭐ |
💡 记住黄金法则:
80% 的性能问题来自 20% 的代码。
用 Profiler 找到那 20%,集中火力优化,
而不是平均用力抠每一行!
🔮 延伸:现代 Shader 优化新方向
【趋势变化】 1. Shader变体(Variant)管理 过多变体导致编译慢、包体大 → 精简变体,善用#pragma 2. 计算着色器(Compute Shader) 把并行计算搬到GPU → 粒子、物理、大规模数据处理 3. 移动端专项 TBDR架构特性利用 → 减少带宽、善用片上缓存 4. 可变分辨率渲染 重要区域高清,边缘降分辨率 → 省下大量片元计算告诉我方向!😊