引擎渲染管线重构实战:PBR、阴影与后处理

发布时间:2026/10/22 22:02:57
引擎渲染管线重构实战:PBR、阴影与后处理
引擎写到第三篇渲染这块终于到了值得拿出来说说的阶段。前两篇把窗口、循环、场景图、基础网格这些东西跑通之后跑起来确实是一个引擎但画面离“能用”还差着十万八千里。这次的核心工作只有一个把原来那套只能证明技术原理的渲染代码重构成一个能看、能调、能支撑后续功能的渲染管线。如果你也在自己写渲染器或者正打算对自己引擎的渲染模块做一次大改这篇的思路拆解和踩坑记录应该能帮你省几个通宵。1. 为什么要在这一步动渲染1.1 旧渲染到底卡在哪先说旧管线的状态。前两篇实现的渲染本质上还是“逐物体立即模式”场景里每有一个网格就从场景图拿模型矩阵绑一下当前 shader把 MVP 传进去调用一次 draw call。光照用的是单光源下的 Phong 模型在材质里直接计算没有阴影没有 HDR颜色计算完直接写回默认帧缓冲。这套东西拿来验证引擎架构没问题但作为产品就完全不够。第一材质表达力几乎为零。所有物体共享同一个 shader想做一个刷漆金属材质和一个磨砂塑料除了改颜色没有区别。第二渲染顺序完全没人管。半透明物体和实体会混在一起画半透明物体经常因为深度测试出现奇怪的遮挡。第三光只有一个平行光场景稍微复杂一点就回到“玩具”水平。第四没有后处理阶段全屏效果基本可以不用想。1.2 这次改进要解决什么所以这一版的渲染改进我给自己定了几个非常接地气的目标建立可复用的材质系统至少能表达金属、粗糙、自发光这些常见参数。引入实时阴影先做方向光的阴影贴图之后再考虑扩展。渲染到 HDR 浮点缓冲接上泛光Bloom和色调映射Tone Mapping解决亮部被硬截断的问题。把 draw call 和状态切换次数压下来目标是同一场景至少比旧管线少一半。不搞花活先把常用商业引擎里最基本的渲染特性补齐。做引擎最忌讳一上来就堆特效基础没打好后面出了 bug 都分不清是哪一层的问题。1.3 选型前向渲染还是延迟渲染这一节我想多说两句。做渲染改进时第一个绕不开的选择题是前向Forward还是延迟Deferred。我的选择是这版继续用前向渲染。原因很简单——当前场景规模有限物体数几十个量级光源数量也少。前向渲染实现简单天然支持 MSAA透明物体处理也直接。延迟渲染虽然能轻松支持几十上百个光源但它需要多张 GBuffer带宽开销大透明物体还得回退到前向做额外处理。很多人喜欢把“支持延迟渲染”挂在嘴边但一个渲染器的好坏从来不在技术名词而在于你站在目标场景里能不能稳定跑出好画面。后续如果真要做大场景加多光源我可以再增加一条延迟分支但基础材质和阴影先用前向打牢更实际。2. 渲染改进的整体架构调整2.1 从“画一个物体”变成“提交、排序、再绘制”旧管线的流程是遍历场景图每个节点立即画一次。新管线改成三阶段收集、排序、绘制。也就是说场景遍历的时候不碰任何图形 API 状态只生成一条RenderCommand等所有可见物体都收集完再统一做状态排序和提交。struct RenderCommand { Mesh* mesh; Material* material; glm::mat4 model; float depth; // 透明排序用 int pass; // 阴影 / 不透明 / 透明 / 后处理 };这个改动有两个好处。第一绘制顺序变得可控了不会再出现场景图顺序主导渲染顺序的被动情况。第二可以在提交阶段做优化比如剔除不可见物体、把静态物体合并成实例组。我实测下来50 个物体从原来每次逐个设置 uniform 的状态切换降到按 shader 种类切换 10 次左右draw call 省得不多但 CPU 侧的 API 开销明显降了。2.2 Shader、材质与纹理的缓存管理拆架构的时候发现一个特别要命的点旧代码每换一个物体就重新编译一遍 shader。shader 编译在运行时是很贵的几百个物体等于把同一段 GLSL 反复编译几百回。改法是做 ShaderProgram 缓存用“文件路径 宏定义”作为 key第一次加载编译之后直接复用。材质也不再是一堆散落的 uniform 变量而是渲染数据的载体。每个材质持有 baseColor、metallic、roughness、normal、emissive 这些参数绘制时一次性上传到 uniform buffer避免逐条调用 uniform 设置接口。这样不仅能减少 CPU 到 GPU 的传输次数也方便调试面板直接暴露参数。2.3 渲染顺序与状态切换排序策略的核心是减少状态变化和保证透明物体正确。我直接维护多个 RenderCommand 队列按顺序执行阴影 pass 排最前然后是不透明物体从前往后排序再是透明物体从远到近排序最后才做后处理。透明物体排序不能只看物体中心。我一开始按中心距离排结果一个大玻璃柜子插在物体中间时经常排错包裹中心离相机近但玻璃柜的某些面明明应该更远。后来改成按包围盒最近点距离排序稳定多了。纹理绑定顺序也容易踩坑。纹理单元的数量是有限的材质最好固定纹理槽位比如 slot 0 放 albedoslot 1 放 normalslot 2 放 metallic/roughness/ao。否则每次 draw 都可能绑错 unit同一张纹理在不同 pass 里换来换去性能很难看。3. 核心功能实现阴影、PBR、后处理3.1 方向光阴影贴图先跑通再谈优化阴影是渲染改进里最直接提升立体感的一笔。我用的方案是经典的 Shadow Map先从光源方向渲染一张深度贴图正常渲染时把片元变换到光源空间比较深度值判断到底在不在阴影里。首先创建阴影缓冲glGenFramebuffers(1, shadowFBO); glBindFramebuffer(GL_FRAMEBUFFER, shadowFBO); glGenTextures(1, shadowMap); glBindTexture(GL_TEXTURE_2D, shadowMap); glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, 2048, 2048, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE);方向光要用正交投影矩阵因为方向光近似无限远光线是平行的。光源视图矩阵用一个 distance 固定远处往下照的典型视角就好。这一步千万别直接拿某个物体的视图矩阵硬凑否则阴影范围会和场景对不上。渲染阴影后的采样阶段我用 3x3 PCF 做软阴影类似这样float shadow 0.0; vec2 texelSize 1.0 / vec2(textureSize(shadowMap, 0)); for (int x -1; x 1; x) { for (int y -1; y 1; y) { float depth texture(shadowMap, uv vec2(x, y) * texelSize).r; shadow (currentDepth - bias) depth ? 1.0 : 0.0; } } shadow / 9.0;这里最坑的其实是 bias。第一版我直接用固定 0.001斜面上的阴影出条纹调大 bias 后又出现物体和地面脱节的“浮空”失真。后来换成按斜率缩放的经验公式才稳定下来float bias max(0.005 * (1.0 - dot(normal, lightDir)), 0.0005);如果是新手我更建议先用 2048 分辨率的单张 Shadow Map 把流程跑通之后再上 CSM。CSM 需要把视锥体按深度切成多段每段生成一张 Shadow Map能避免远景阴影糊成一片。但第一步不要贪多单张能稳定显示已经很了不起。3.2 材质系统升级到 PBR下一步是把光照从 Phong 换成 PBR。PBR 的核心不是堆公式而是统一的能量守恒反射模型。我用的是金属度/粗糙度工作流 Cook-Torrance BRDFf kd * albedo / PI ks * (D * F * G) / (4 * NoV * NoL)其中 D 用 GGX 分布F 用 Schlick 近似G 用 Smith 几何遮蔽项。代码里大概长这样float D_GGX(float NoH, float roughness) { float a roughness * roughness; float a2 a * a; float d (NoH * a2 - NoH) * NoH 1.0; return a2 / (3.14159 * d * d); } vec3 F_Schlick(float cosTheta, vec3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }为什么非要换成 PBR因为金属度-粗糙度工作流的参数足够直观行业资产也基本都是按这套标准制作。与其自己发明一个光照模型不如接一个美术团队有共同认知的模型后续导入材质资源也省心。除了 BRDF纹理通道也要调对。albedo 贴图是 sRGBroughness、metallic、AO 这些要按线性数据用加载纹理时必须分开标记。否则颜色空间不统一整个画面会像蒙了一层灰高光位置也怪怪的。3.3 HDR 与后处理链旧管线直接把结果写到 LDR 屏幕帧缓冲亮度一旦超过 1 就被硬切掉。这次我把场景渲染到一个 RGBA16F 浮点缓冲上然后再用全屏三角形走一遍后处理链Bloom 先提取亮部再做 Tone Mapping最后输出 Gamma 校正。为什么要 HDR一句话让中间计算不截断亮度。Bloom 需要超过 1 的亮部信息才能抽高光Tone Mapping 需要范围更大的输入做映射。这是现代引擎渲染结果不“灰”、不“爆”的基础。创建 HDR 缓冲很简单内部格式换一下就行glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_FLOAT, nullptr);Bloom 我不做太复杂先按亮度阈值提取亮部比如 threshold1.0然后对高光纹理做四级降采样和上采样把模糊结果叠加回原画面。这个效果对自发光物体和强高光区域非常提神代价是几个全屏 pass。Tone Mapping 第一版直接用 Reinhard公式是color / (color 1.0)简单但画面有点“灰”高光区不够有张力。后来换成 ACES 近似vec3 aces(vec3 x) { const float a 2.51; const float b 0.03; const float c 2.43; const float d 0.59; const float e 0.14; return clamp((x * (a * x b)) / (x * (c * x d) e), 0.0, 1.0); }后处理顺序也很重要。Bloom 要在 Tone Mapping 之前做否则亮度已经被压缩超过 1 的高光提不出来泛光就会很弱甚至没有。3.4 加一块调试面板和数据统计渲染改进如果没有数据支撑就纯粹是玄学。我给每个 pass 加了 GPU 计时查询在调试面板里能看到阴影、不透明物体、Bloom、ToneMapping 各自的耗时。下面是我测试场景里的一组典型数据1080p 分辨率下shadow pass: 0.62 ms opaque pass: 1.18 ms bloom: 0.84 ms tonemap: 0.21 ms阴影 pass 经常被低估很多人以为它就是额外画一遍深度而已实际上它要给整个场景再跑一次顶点和片段处理成本不低。把每个 pass 的耗时和 draw call 数直接显示在调试面板里调起优来会直观很多。4. 实机效果与参数调优4.1 测试场景和参数为了验证这些改动我搭了个很简单的测试场景一个作为地面的平面几面不同角度的墙中间放一组金属度、粗糙度各不相同的球体再加一个自发光方块用来测泛光。相机控制沿用前两篇的自由视角。这种场景很能暴露问题斜面会导致阴影 bias 异常粗糙度不同的球能体现 BRDF 差异自发光方块能看出 Bloom 链有没有 bug。测试参数大概这样参数数值渲染分辨率1920 x 1080阴影贴图尺寸2048 x 2048阴影滤波3x3 PCFPBR 工作流金属度/粗糙度后处理Bloom ACES ToneMapping光源方向光为主 点光辅助4.2 性能数据用一块几年前的千元级独显跑分辨率和对画质的影响还是明显的配置帧率旧 Phong 管线无阴影无后处理143 fps新 PBR 阴影关后处理96 fps新 PBR 阴影 Bloom82 fps阴影 1024 分辨率 关闭 PCF110 fps结论很直观阴影和后处理是两头吃性能的大户。关掉 Bloom 能直接省 14 帧画面在自发光高亮区域的差异却很大所以最终取舍要跟着项目需求走。4.3 改进前后的画面差异直接看效果旧版所有物体像塑料球体的高光是一个容易曝掉的光斑新版球体金属和粗糙的过渡正常地面斜面上的阴影有了软边界自发光物体周围有泛光。老实说改进后才是“能拿出去演示”的状态。但改进不是没有代价。代码里多了两套渲染路径和很多边界条件出问题的概率明显变大。所以每次改完一个环节我都会先在固定场景下截图对比避免在调 A 的时候把 B 弄坏。4.4 参数调优经验我把这轮调参的经验汇总成几条供参考阴影分辨率2080 级别在 1080p 下边缘表现可以远景抖动的话优先上 CSM而不是直接拉到 4096带宽和显存压力会一下子变大。PCF 采样从 3x3 加宽到 5x5 能继续改善边缘但 shader 开销上升明显可以先看 GPU 耗时再决定。Bloom 阈值越高泛光越收敛阈值太低整个画面会像雾一样闷。先设在 1.0 附近再根据场景微调。调参永远抱着“刚好够好”的心态别为了截图把 Bloom 拉爆那会让画面失去真实感。5. 常见问题与排查技巧实录5.1 阴影闪烁、条纹这是 shadow map 最经典的坑。表现是斜面上有高频率明暗条纹或者相机一动阴影疯狂闪烁。检查顺序应该是先确认阴影 pass 用的是深度格式比如GL_DEPTH_COMPONENT24再确认采样比较时用了currentDepth - bias shadowDepth如果还有问题试着在阴影 pass 开启glCullFace(GL_FRONT)能减少部分因为深度误差产生的“浮空阴影”但要注意双面物体的表现。不建议一上来就调大 bias否则物体会和地面“脱开”。我最终固定用斜率缩放公式稳定且基本不出现条纹。5.2 PBR 场景一片惨白或者发灰这个问题我调了两天才彻底解决。最常见的原因是颜色空间错误纹理加载时没有区分 sRGB把线性值当 sRGB 再转一次结果整个画面发灰。检查两个点第一albedo 贴图的内部格式应该是GL_SRGB8_ALPHA8而不是GL_RGBA8。第二场景已经渲染到 HDR 缓冲但最终片段输出之前必须做 Tone Mapping如果直接输出原始线性颜色屏幕一定会一片惨白。还有一个容易忽略的点PBR 的光照强度不是旧管线那套随便填的 1.0。方向光可以按实际场景把亮度调到 3.0 甚至更高让金属高光保留细节而不是直接切白。5.3 后处理黑屏或全屏错乱黑屏先查帧缓冲完整性。常见原因是 float color attachment 没设置纹理过滤参数比如放大过滤默认是最近邻分辨率不匹配时会出现网格状线条但严格来说不是黑屏。更常见的坑是后处理 shader 的 uv 坐标和帧缓冲尺寸没对齐或者没有绑定 HDR 场景纹理。绑定完后在初始化阶段用状态查询打印一下帧缓冲不完整这个问题很多新手会漏。窗口尺寸变化时也要记得重建 HDR 缓冲和后处理相关缓冲否则画面会变形或出现黑边。5.4 性能突然下降一半最易被忽略的是 shadow pass 和 Bloom 的层数。你看着场景里只有几个球实际 shadow pass 得把整个场景再画一遍Bloom 每次降采样又是一次全屏 pass加起来数量可能超过主场景。排查先开 GPU 计时看每个 pass 的耗时。如果问题在阴影降 shadow map 到 1024 或者 2048。如果问题在 Bloom减少降采样级数比如四级改成三级或者把模糊目标缓冲直接降到半分辨率。不要一上来就怀疑某个 shader 里的高端数学函数瓶颈通常比想象中低端得多。5.5 透明物体排序错误PBR 上完之后半透明物体变多排序问题会重新变成焦点。我的做法是把透明队列单独抽出来按包围盒最近点距离从远到近排序而不是按物体中心。玻璃、粒子这种大量互相穿插的场景完全正确需要逐像素排序成本太高。工程上可以接受局部视觉错误只要不出现大面积遮挡关系完全反掉的情况。调的时候就盯住几个容易出错的物体来回移动相机观察。6. 接下来怎么扩展以及我踩完坑后的心得6.1 下一步渲染方向这版渲染改进做完下一个阶段我准备做两个方向一是把阴影从单张 Shadow Map 升级成 CSM并且加入 SSAO 环境遮蔽让物体接触点周围不再那么“飘”二是接入 IBL 环境光照让 PBR 材质在自然环境下反射更真实。再往后可以考虑延迟渲染和 TAA但前提是先把手上的资源、状态管理稳定下来不要在没跑透的模块上叠新东西。6.2 踩完坑后真正想说的话最后说点跟代码无关的体会。自己写引擎的渲染改进最花时间的往往不是写 shader而是把整个渲染流程调稳定。你必须对每个 pass 在干什么、每个资源的生命周期都很清楚不然一个 FBO 没重置、一个纹理没解绑就够你排查一整晚。我现在养成了两个习惯第一每次改渲染代码前先写一个固定场景的截图对比脚本把旧画面和新画面放到一起看差异。没有对比很多“改进”只是自我感动。第二坚决不搞“一次接一个特性立刻拉满参数”的模式。渲染器给整个引擎兜底兜底的东西稳比什么都重要。如果你也在自己写引擎我建议不要急着把所有流行技术塞进去。先选一条稳定的前向 PBR 阴影路线把工程结构理顺再一点点加特效。等这条线完全可控了你会发现后面加任何东西都安心得多。