Unity自定义渲染管线中经典光照模型的实现与优化

发布时间:2026/7/25 19:19:21
Unity自定义渲染管线中经典光照模型的实现与优化 1. 项目概述为什么要在自定义管线中复刻经典光照模型如果你在Unity里写过Shader或者用过URP、HDRP那你肯定对PBR基于物理的渲染那一套流程不陌生。金属度、粗糙度、环境光遮蔽这些参数现在几乎是标配。但不知道你有没有这种感觉有时候PBR出来的效果虽然“正确”却少了点“味道”。那种早期3D游戏里角色身上清晰的高光边缘或者卡通渲染里那种干净利落的明暗分界在追求物理真实的PBR体系下反而很难精准地控制。这就是“经典经验光照模型”的价值所在。像兰伯特Lambert、冯氏Phong/Blinn-Phong这些模型它们不追求物理绝对正确而是用一些简洁的数学公式去模拟和夸张化我们视觉上觉得“好看”、“有风格”的光照效果。它们直接、可控是很多风格化渲染的基石。那么为什么要在“自定义管线”里做这件事呢Unity内置的URP/HDRP管线功能强大但它们是“黑盒”。你想改一下光照计算顺序或者插入一个自定义的阴影处理逻辑非常麻烦甚至不可能。自定义管线Scriptable Render Pipeline, SRP就是把渲染的控制权完全交还给你。从摄像机清除屏幕开始到物体绘制、光照计算、后处理每一个Pass你都可以自己定义和编排。所以“基于自定义管线实现经典经验光照模型”这个项目的核心目标就清晰了夺回渲染控制权在一个完全由自己搭建的渲染框架内精准复现并深度定制那些构成我们图形学记忆的经典光照公式为风格化、非真实感或特定性能要求的项目打下坚实基础。这不是简单的Shader编写而是一次从底层理解现代渲染管线架构并亲手将经典理论嵌入其中的系统性工程。2. 核心思路与管线架构设计在动手写代码之前我们必须把整个管线的蓝图规划好。使用Unity的SRP我们不是从零造轮子而是在一个高层次的框架下编排渲染指令。核心是继承自RenderPipeline的类以及一系列ScriptableRenderPass。2.1 管线核心类与渲染流程设计我的管线主类命名为ClassicLightingPipeline。它的核心是重写Render方法。在这个方法里我定义了整个一帧的渲染剧本上下文设置获取ScriptableRenderContext配置相机裁剪、视图/投影矩阵。剔除Culling根据相机视锥体计算哪些物体是可见的。绘制顺序规划这是自定义管线的精髓所在。我规划了以下几个主要阶段不透明物体Opaque Pass先画所有不透明物体这是标准操作利用深度测试避免过度绘制。天空盒Skybox Pass如果不透明物体用了深度写入画完它们再画天空盒效率更高。经典光照计算Lighting Pass这是本项目最关键的创新点。在URP里光照是在物体绘制时或在单独的Lighting Pass中由管线统一处理的。而在我的自定义管线里我选择在一个全屏的后处理Pass中集中计算经典光照。为什么为了灵活性和效果叠加。我利用上一阶段绘制得到的屏幕颜色缓存Albedo和世界空间法线缓存Normal在一个单独的Pass中统一应用兰伯特、冯氏等模型进行计算。这样我可以轻松地混合多种光照模型或者针对不同屏幕区域使用不同模型而无需修改每个物件的Shader。透明物体Transparent Pass最后按从后到前的顺序绘制透明物体它们需要混合。后处理与UI执行一些简单的全屏效果最后渲染UI。注意这种“延迟式”的光照思路先存G-Buffer再统一光照借鉴了延迟渲染的思想但它不是完整的延迟渲染管线。我这里只存储了颜色和法线没有深度、粗糙度等可以称之为一个“轻量级准延迟”架构专门为经典光照模型优化。2.2 渲染目标Render Texture策略为了实现上述“准延迟”光照我们需要创建和管理一些关键的渲染纹理Render Texture。_CameraColorTexture存储物体原始的漫反射颜色Albedo。_CameraNormalTexture存储物体在世界空间下的法线信息。这里有个关键点为了将法线一个三维向量存入RGBA8的纹理需要编码。我使用了常见的EncodeFloatRGBA和DecodeFloatRGBA或者更高效的Octahedron Normal Encoding方法以减少精度损失。_CameraDepthTexture可选的深度纹理用于一些需要深度信息的后期效果或软阴影模拟。在绘制不透明物体时我的Shader会同时输出颜色到_CameraColorTexture输出编码后的世界法线到_CameraNormalTexture。这些纹理就是后续“经典光照计算Pass”的输入。2.3 Shader与材质系统适配自定义管线意味着内置的Standard材质完全失效。我需要创建一套自己的Shader和材质。基础无光照Shader用于填充G-Buffer。这个Shader非常简单顶点着色器负责变换顶点到裁剪空间并输出世界空间法线片段着色器直接返回材质的Albedo颜色和编码后的世界法线。它不包含任何光照计算。经典光照计算Shader这是一个全屏的Image Effect Shader。它采样_CameraColorTexture和_CameraNormalTexture对每个像素解码出世界坐标需要结合深度纹理或通过屏幕UV和相机参数重构和世界法线然后根据场景中的光源信息方向、颜色、强度应用经典光照模型公式进行计算最终输出 lit color。材质球创建对应的材质使用上述Shader。将“基础无光照Shader”材质赋给场景中的物体将“经典光照计算Shader”材质用于一个全屏面片在Lighting Pass中渲染。3. 经典光照模型的原理与Shader实现现在我们进入最核心的部分在自定义管线的光照计算Pass中如何实现这些经典模型。假设我们已经从纹理中获取了当前像素的世界位置worldPos、世界法线worldNormal和基础颜色albedo。3.1 兰伯特漫反射模型兰伯特模型认为漫反射光的强度与表面法线和光线方向夹角的余弦成正比。公式极其简洁diffuse lightColor * albedo * max(0, dot(N, L))其中N是归一化的表面法线L是归一化的指向光源的方向向量。在我的全屏光照Shader中实现如下float3 CalculateLambert(float3 albedo, float3 normal, float3 lightDir, float3 lightColor) { float NdotL max(0, dot(normal, lightDir)); return lightColor * albedo * NdotL; }实操心得max(0, dot(N, L))这个操作至关重要它确保了当光线从背面照射时点积为负漫反射贡献为零这是符合物理直觉的。在早期固定管线中这个计算是由硬件自动完成的现在我们需要在Shader里明确写出来。3.2 冯氏与布林-冯氏镜面反射模型冯氏模型通过计算视线方向V关于法线N的反射方向R与光线方向L的点积的幂次方来模拟高光。specular_Phong lightColor * specularColor * pow(max(0, dot(R, V)), gloss)布林-冯氏是它的一个优化版本计算半角向量H normalize(L V)然后用N和H的点积。它在计算上更高效且高光更柔和、更自然。specular_BlinnPhong lightColor * specularColor * pow(max(0, dot(N, H)), gloss)这里gloss是高光指数控制高光点的集中程度。Shader实现片段float3 CalculateBlinnPhong(float3 normal, float3 viewDir, float3 lightDir, float3 lightColor, float3 specularColor, float gloss) { float3 halfVec normalize(lightDir viewDir); float NdotH max(0, dot(normal, halfVec)); float specularTerm pow(NdotH, gloss); return lightColor * specularColor * specularTerm; }注意事项gloss值通常很大如128、256pow函数对输入非常敏感。当NdotH接近0时pow可能产生精度问题或性能开销。一个常见的优化是使用saturate(NdotH)并确保gloss是合理的值。此外specularColor通常设置为白色或与光源同色但在风格化渲染中可以设置为任何颜色来创造特殊效果。3.3 在管线中集成与混合光照在我的ClassicLightingPass中我会遍历场景中的所有有效光源主要是方向光。对于每个像素计算它对每个光源的贡献并累加。float3 pixelColor float3(0, 0, 0); for (each light in visibleLights) { float3 lightDir ...; // 计算指向光源的方向 float3 lightColor ...; // 光源颜色和强度 // 计算衰减对于点光源/聚光灯 float attenuation CalculateAttenuation(worldPos, light); // 混合光照模型例如70%兰伯特 30%布林-冯 float3 diffuse CalculateLambert(albedo, worldNormal, lightDir, lightColor); float3 specular CalculateBlinnPhong(worldNormal, viewDir, lightDir, lightColor, _SpecularColor, _Gloss); pixelColor (diffuse * 0.7 specular * 0.3) * attenuation; } // 添加环境光 pixelColor albedo * _AmbientColor; return float4(pixelColor, 1.0);核心技巧这种在屏幕空间统一计算光照的方式使得动态修改光照模型混合比例、甚至根据一张遮罩纹理为不同物体区域分配不同模型成为可能。你只需要在Shader中添加几个参数或者在绘制G-Buffer时多输出一个“材质ID”纹理即可。4. 性能优化与高级特性拓展一个可用的管线还不够必须是高效的、功能丰富的。4.1 渲染状态管理与批处理在ClassicLightingPipeline.Render中正确设置渲染状态是性能的关键。对于不透明物体Pass我设置var drawSettings new DrawingSettings(..., new SortingSettings(camera) { criteria SortingCriteria.CommonOpaque }); var filterSettings new FilteringSettings(RenderQueueRange.opaque); context.DrawRenderers(cullingResults, ref drawSettings, ref filterSettings);同时要确保用于G-Buffer的Shader有正确的LightModeTag例如ClassicGBuffer并且在DrawingSettings中指定这个Shader Pass这样管线才能正确调用它。批处理SRP支持SRP Batcher。要启用它必须让自定义的Shader符合SRP Batcher的要求使用一个常量缓冲区CBUFFER来存储所有材质通用的属性如unity_ObjectToWorld,unity_WorldToObject而将材质独有的属性放在另一个CBUFFER中。这能大幅提升使用相同Shader但不同材质参数的物体的渲染效率。4.2 阴影映射集成经典光照模型没有阴影是不完整的。我实现了简单的平行光阴影映射。阴影贴图生成Pass在渲染主场景之前从光源视角渲染一次场景深度到一个渲染纹理ShadowMap。这个Pass使用一个只写深度的简单Shader。阴影采样在全屏光照计算Pass中将当前像素的世界坐标变换到光源的裁剪空间对比其深度值与ShadowMap中存储的深度值。如果当前像素深度大于阴影图中的值说明被其他物体遮挡则光照贡献衰减为0或一个很小的环境值。避坑指南阴影锯齿Aliasing是常见问题。我采用了Percentage-Closer Filtering (PCF)进行软阴影滤波。即在采样阴影时不是只采样一个点而是在周围多个点采样并比较然后取平均值这能有效柔化阴影边缘。float ShadowPCF(sampler2D shadowMap, float4 shadowCoord, float2 texelSize) { float shadow 0.0; for (int x -1; x 1; x) { for (int y -1; y 1; y) { float depth tex2D(shadowMap, shadowCoord.xy float2(x, y) * texelSize).r; shadow (shadowCoord.z depth) ? 0.0 : 1.0; } } return shadow / 9.0; }4.3 支持多光源与光照衰减方向光没有衰减。但对于点光源和聚光灯衰减是必须的。我采用了一个简化的物理衰减模型attenuation 1.0 / (distance * distance)并结合光源范围进行裁剪。 对于聚光灯还需要计算角度衰减使用光源方向与光线方向的点积并与聚光灯的内外锥角余弦值进行比较。在管线中我需要从cullingResults.visibleLights中获取所有光源信息位置、方向、颜色、范围、角度等并在光照计算Pass中通过结构化缓冲区StructuredBuffer或全局数组传递给Shader。5. 调试、常见问题与项目心得5.1 调试工具与可视化自定义管线开发调试至关重要。我做了几个辅助工具G-Buffer可视化在编辑器中创建几个调试用的Render Texture分别显示_CameraColorTexture、解码后的_CameraNormalTexture将法线从(-1,1)映射到(0,1)以便观看、深度纹理等。这能快速定位是G-Buffer数据错了还是光照计算错了。自定义Debug Pass在管线中插入一个额外的Pass用简单的线框或纯色覆盖绘制特定物体或区域来验证裁剪、排序是否正确。Frame DebuggerUnity自带的帧调试器是神器。它能清晰地展示你自定义管线中每一个Pass的绘制调用、渲染目标状态是排查渲染顺序和状态问题的首选。5.2 常见问题排查表问题现象可能原因排查步骤屏幕全黑主相机渲染目标设置错误光照计算Pass未执行Shader编译错误。1. 检查Camera组件的Render Pipeline Asset是否指向你的ClassicLightingPipeline。2. 在Frame Debugger中查看各个Pass是否按预期执行特别是光照计算Pass。3. 查看控制台是否有Shader编译错误。物体不显示物体Layer未被相机剔除层包含物体的Shader不匹配管线的LightModeTag。1. 检查相机Culling Mask。2. 检查物体材质使用的Shader其LightModeTag是否与DrawingSettings中设置的Pass Name一致。法线错误导致光照怪异法线编码/解码错误法线未归一化变换矩阵用错世界空间 vs 视图空间。1. 可视化法线纹理看颜色是否合理通常平滑表面呈淡蓝色。2. 在光照Shader中打印通过颜色dot(N, L)的值检查是否在[0,1]区间。3. 确认顶点Shader中法线变换使用的是逆转置矩阵transpose(inverse(worldMatrix))来处理非均匀缩放或直接保证模型没有非均匀缩放。阴影闪烁或失真阴影贴图分辨率不足深度值比较时的精度问题深度偏移。1. 提高阴影贴图分辨率。2. 在深度比较时添加一个小的偏移量Depth BiasshadowCoord.z - _Bias以消除自阴影Shadow Acne。性能低下未开启SRP Batcher全屏光照Pass计算复杂度高阴影采样次数过多。1. 在Player Settings中确认SRP Batcher已开启并确保Shader符合规范。2. 简化全屏光照Shader或考虑将部分计算移到低分辨率进行。3. 降低PCF采样次数或对远处阴影使用更低的采样质量。5.3 项目心得与展望走完整个项目最大的收获不是实现了几个光照模型而是彻底打通了从“CPU侧渲染指令调用”到“GPU侧Shader运算”的完整数据流和逻辑链。你清楚地知道每一帧每一个三角形每一个像素是如何被生产出来的。这种掌控感是使用现成管线无法比拟的。对于想尝试的开发者我的建议是从小目标开始。不要一上来就想做一个完整的、带阴影、多光源、后处理的管线。可以先实现一个最简单的、只画一个无光照茶壶的自定义管线。然后逐步加入相机矩阵、加入剔除、加入第二个Pass画一个纯色背景、再加入一个全屏的简单颜色变换Pass。每一步都确保你理解当前在做什么并用Frame Debugger验证。这个自定义管线项目完全可以作为一个强大的风格化渲染实验框架。未来我可以轻松地将兰伯特模型替换成Toon Shading的阶梯状漫反射将布林-冯模型替换成各向异性高光模型或者加入边缘光Rim Light、菲涅尔效果等。所有的这些风格化组件都可以在这个集中式的光照计算Pass里优雅地组合和调试。它不再是一个黑盒而是一个完全由你定义的、充满可能性的画布。