Unity ShaderGraph平面镜反射实现:从原理到实战 1. 项目概述从“水面”到“镜面”的ShaderGraph进阶在Unity里做水面倒影你可能用过现成的插件或者简单的反射探针。但当你需要的是一个完美的、无失真的平面镜反射效果比如光滑的大理石地板、干净的玻璃幕墙或者科幻场景里的能量屏障时标准方案就显得捉襟见肘了。反射探针有延迟且对动态物体支持有限屏幕空间反射SSR效果虽好但性能开销大边缘容易出错。这时候一个基于ShaderGraph定制的、纯粹的平面镜反射Shader就成了更优雅的解决方案。这个项目要做的就是抛开那些“差不多就行”的方案用ShaderGraph手搓一个物理正确的完美平面镜反射。它不依赖后处理不依赖昂贵的实时计算核心思想是利用着色器的数学魔法在片元着色器阶段直接计算出每个像素对应的反射内容。听起来有点玄乎其实拆解开来就是摄像机空间变换、平面反射矩阵计算和采样技巧的组合拳。相比于网上零散的代码片段用ShaderGraph可视化地搭建这个效果不仅能让你彻底理解其原理还能获得极大的灵活性和可控性比如轻松调整反射强度、模糊、扭曲甚至叠加菲涅尔效应。无论你是想为你的独立游戏增添一抹真实的奢华感还是为数字孪生应用构建高保真的虚拟环境这个技术都能派上用场。它特别适合那些对反射质量有要求且反射平面相对规整如地面、墙面的场景。接下来我会带你从零开始一步步拆解这个效果把每个节点背后的数学和逻辑都讲透让你不仅能复现更能真正掌握它。2. 核心原理拆解反射的数学本质在动手连接节点之前我们必须先搞清楚镜子反射在计算机图形学里到底是怎么一回事。这无关乎Unity还是其他引擎而是基础的几何光学和线性代数。2.1 世界空间、视图空间与裁剪空间理解空间变换是Shader编程的基石。我们的模型顶点最初位于模型空间。通过模型矩阵Model Matrix变换它来到了世界空间这是所有物体共享的绝对坐标系。然后通过视图矩阵View Matrix由摄像机决定世界坐标被转换到视图空间也叫摄像机空间。在这个空间里摄像机位于原点看向Z轴负方向。最后通过投影矩阵Projection Matrix视图空间坐标被转换为裁剪空间坐标用于决定哪些部分在屏幕内。对于反射计算我们最关心的是视图空间。因为反射本质上是一个关于反射平面的对称操作而这个对称变换在视图空间里计算最为直观和高效。我们需要知道当前渲染的像素点在视图空间中相对于反射平面的位置然后找到它对称的那个点再去获取那个点的颜色信息。2.2 平面反射矩阵推导这是整个效果最核心的数学部分。假设我们有一个无限大的理想平面其方程为n·x d 0其中n是平面的单位法向量在世界空间或视图空间x是平面上任意点的坐标d是原点到平面的有符号距离。对于一个点p我们想找到它关于这个平面的镜像点p。推导过程如下从点p到平面的最短距离向量是(n·p d) * n注意这里的d是负的取决于方程形式但原理一致。镜像点p等于p减去两倍的该距离向量p p - 2 * (n·p d) * n。将这个公式写成矩阵形式就是著名的平面反射矩阵。在Shader中我们不会直接构造这个4x4矩阵而是利用ShaderGraph提供的节点按步骤实现这个公式。关键在于我们需要在视图空间中进行这个计算。这意味着我们不仅需要平面的法向量n还需要将平面的世界空间法向量转换到视图空间同时也要将待反射的点即当前像素对应的视图空间位置进行同样的处理。注意网上很多教程直接在世界空间计算反射然后将反射后的世界坐标再转换到裁剪空间去采样。这在理论上是可行的但会引入不必要的计算复杂度并且对ShaderGraph节点流的组织不够友好。我们采用在视图空间完成核心反射计算的方法逻辑更清晰节点更简洁。2.3 采样与后处理从坐标到颜色计算出反射点的视图空间坐标后我们需要将它“映射”回2D屏幕以便从当前的摄像机渲染结果通常是一张屏幕纹理如_CameraOpaqueTexture中采样颜色。这个过程是反向的将反射点的视图空间坐标通过摄像机的投影矩阵在Shader中可以通过UNITY_MATRIX_P获取变换到裁剪空间。将裁剪空间坐标进行透视除法即xyz分量除以w分量得到标准化设备坐标其范围在[-1, 1]之间DirectX风格下Y轴可能向上。将NDC坐标转换到屏幕UV坐标范围在[0, 1]之间。这个转换需要考虑平台差异如DirectX和OpenGL的纹理V坐标方向相反。最终我们用这个计算出的UV坐标去采样_CameraOpaqueTexture得到的颜色就是该点的反射颜色。如果UV坐标超出了[0,1]范围说明反射点不在当前摄像机视野内我们需要进行处理通常可以丢弃或混合一个底色。3. ShaderGraph节点化实现全流程理论铺垫完毕现在打开Unity创建一个新的Unlit Shader Graph我们开始实战搭建。我将把整个过程分解为几个功能模块并用节点一一实现。3.1 建立基础框架与输入参数首先创建几个必要的属性Property作为我们调整效果的入口_ReflectionPlaneHeight(Float): 反射平面的世界空间Y轴高度。这是我们控制镜子位置的主要参数。_ReflectionStrength(Vector1): 反射强度范围0-1用于控制反射颜色的混合程度。_ReflectionDistortion(Float): 模拟非完美镜面的扭曲效果可以为0。_ReflectionColor(Color): 反射颜色色调可以用来给反射染色。_ReflectionBlur(Float): 反射模糊强度非必需但可以提升真实感。在ShaderGraph中将这些属性暴露出来。然后我们需要获取几个关键的系统值Position节点设置为View空间。这是我们当前渲染像素在视图空间中的坐标。Scene Color节点这对应着_CameraOpaqueTexture包含了不透明物体的渲染结果是我们采样的来源。View Direction节点视图空间下的视线方向用于后续可能的菲涅尔效应计算。3.2 计算视图空间下的平面法向量我们的平面是水平的沿Y轴所以在世界空间其法向量是(0, 1, 0)。但我们需要视图空间的法向量。使用Vector3节点创建(0, 1, 0)。使用Transform节点将From设置为WorldTo设置为View输入上面的向量。这一步相当于用视图矩阵的逆转置矩阵用于变换法向量对世界法线进行变换。用Normalize节点对结果进行归一化确保是单位向量得到ViewSpacePlaneNormal。3.3 核心反射坐标计算这是最关键的步骤组。我们需要根据2.2节的公式计算当前像素点关于反射平面的镜像点在视图空间中的坐标。计算点到平面的距离公式是dot(n, p) d。这里的p是Position (View)n是上一步得到的ViewSpacePlaneNormal。d是平面在视图空间中的常数项。我们不能直接使用世界空间的_ReflectionPlaneHeight。我们需要一个在视图空间中、位于反射平面上的参考点。一个简单的方法是在世界空间创建一个点(0, _ReflectionPlaneHeight, 0)然后用Transform节点World to View将其转换到视图空间得到ViewSpacePlanePoint。根据平面方程n·x d 0可以推出d -dot(n, ViewSpacePlanePoint)。在ShaderGraph中用Dot Product节点计算dot(ViewSpacePlaneNormal, ViewSpacePlanePoint)然后通过Multiply乘以 -1 得到d。应用反射公式计算dot(ViewSpacePlaneNormal, PositionView)。加上d。乘以2再乘以ViewSpacePlaneNormal。这一步得到的是“两倍的距离向量”。用PositionView减去这个向量就得到了反射点的视图空间坐标ReflectedPositionView。节点流大致如下Position (View) - Dot Product with ViewSpacePlaneNormal - Add d - Multiply 2 - Multiply ViewSpacePlaneNormal - Subtract from Position (View)3.4 从反射坐标到采样UV现在有了ReflectedPositionView需要将其转换为可以用于Scene Color节点采样的UV坐标。视图空间到裁剪空间使用Transform节点将ReflectedPositionView从View空间转换到Clip空间。本质上这是乘以了投影矩阵UNITY_MATRIX_P。透视除法裁剪空间坐标是齐次坐标(x, y, z, w)。我们需要进行透视除法得到NDC坐标float3 ndc clipPos.xyz / clipPos.w。在ShaderGraph中可以用Divide节点分别用clipPos.x、clipPos.y、clipPos.z除以clipPos.w。NDC到屏幕UVNDC的xy范围是[-1, 1]。需要映射到[0, 1]。公式是uv ndc.xy * 0.5 0.5。重要处理平台差异。在DirectX类平台如Windows和Metal上纹理V坐标0在顶部而在OpenGL类平台如一些移动端上纹理V坐标0在底部。Unity的Scene Color纹理通常遵循屏幕缓冲区惯例。为了兼容我们需要对Y坐标进行翻转uv.y 1 - uv.y在需要的情况下。一个稳健的方法是使用Compute Screen Pos相关的函数但在ShaderGraph中我们可以使用Flipbook节点的一个“歪门邪道”或者更直接地在最后采样前根据_ProjectionParams.x在ShaderGraph中可通过Projection Params节点的R分量获取其为-1或1来翻转Yuv.y (1 - _ProjectionParams.x) * 0.5 _ProjectionParams.x * uv.y。如果_ProjectionParams.x为1表示未翻转公式结果为uv.y如果为-1表示已翻转公式结果为1 - uv.y。这样能自动适配平台。应用扭曲与边界处理如果需要扭曲效果可以用一个噪声图采样将噪声值加到计算出的UV上强度由_ReflectionDistortion控制。边界处理采样UV可能超出[0,1]。如果直接采样纹理会按照Wrap模式通常是Repeat或Clamp处理导致边缘出现不希望的拉伸或重复。更专业的做法是计算UV的越界程度然后让反射颜色淡出。例如可以用Smoothstep节点当UV的x或y分量接近0或1时将反射强度衰减为0。3.5 采样、混合与输出采样场景颜色将处理好的最终UV连接到Scene Color节点的UV端口得到反射颜色ReflectionColor。颜色处理将ReflectionColor与_ReflectionColor属性相乘进行色调调整。混合到最终颜色获取物体表面的基础颜色BaseColor可以是纹理采样或纯色。使用Lerp线性插值节点在BaseColor和ReflectionColor之间进行混合。混合因子Alpha由_ReflectionStrength控制。但更真实的做法是引入菲涅尔效应视线与法线夹角越大掠射角反射越强。我们可以用Dot Product计算View Direction和Normal视图空间的点积然后用Fresnel Effect节点或Power、Smoothstep等节点根据点积计算一个菲涅尔系数Fresnel。最终的混合因子可以是_ReflectionStrength * Fresnel。将混合后的颜色连接到主着色器的Base Color输出端口。可选模糊处理实现真正的模糊开销较大。一个廉价的近似是进行多次采样比如4次或8次每次对UV施加一个微小的随机偏移可以用一个小的噪声纹理或者正弦时间函数然后取平均值。这需要在片元着色器中执行多次采样对性能有影响可根据需求选择。4. 关键参数详解与视觉微调节点连好了但出来的效果可能生硬或不真实。以下几个参数和技巧是让倒影“活”起来的关键。4.1 反射强度与菲涅尔效应单纯用一个_ReflectionStrength滑块控制全局强度是初级的做法。在现实世界中反射的强度取决于观察角度这就是菲涅尔效应。在ShaderGraph中Fresnel Effect节点提供了便捷的实现。它需要两个输入Normal表面法线和View Dir视线方向以及一个Power参数控制边缘衰减的锐利程度。实操心得将菲涅尔节点的输出与_ReflectionStrength相乘作为最终的混合因子。这样在正面看镜子时反射会弱一些可能看到物体本身的颜色或纹理在侧面看时反射会非常强烈几乎完全像镜子。这个简单的物理规律能极大提升视觉效果的真实度。Power值通常设置在3到10之间值越大菲涅尔过渡越尖锐。4.2 平面高度与深度冲突Z-Fighting_ReflectionPlaneHeight定义了镜面的位置。一个常见的问题是深度冲突反射物体的渲染深度与反射平面本身的深度非常接近导致GPU在判断谁在前谁在后时出现闪烁或像素抖动。解决方案微调平面高度稍微将反射平面的渲染高度在Shader中参与计算的高度与实际网格的高度错开一点点。例如如果网格在Y0可以让Shader中的_ReflectionPlaneHeight略高于0如0.001。这通常能缓解大部分问题。使用深度偏移在材质的Inspector面板可以调整Render Queue确保反射材质在反射物体之后渲染或者使用Offset参数Factor, Units来人为地让反射平面在深度测试中“退后”一点。在ShaderGraph中可以在主节点设置中调整Depth Offset。在Shader中丢弃边缘像素对于反射平面本身的边缘可以计算其屏幕空间导数或使用一个渐变让边缘的反射强度逐渐降为0也能避免硬边缘处的冲突。4.3 模糊与扭曲模拟真实材质完美的镜面是理想模型。现实中地板有细微划痕水面有波纹玻璃有杂质。这些都会导致反射模糊或扭曲。模糊如前所述多采样平均是方法之一。更性能友好的做法是在后期对_CameraOpaqueTexture进行一次模糊比如高斯模糊然后用这个模糊后的纹理作为反射采样的源。但这需要自定义渲染纹理或后处理超出了单个ShaderGraph的范围。在ShaderGraph内一个取巧的办法是采样_CameraOpaqueTexture的Mipmap通过Sample Scene Color节点的LOD输入一个非零值可以获取到较低级别的模糊图像。扭曲使用一张法线贴图或噪声图采样其RG通道作为UV的偏移量。强度由_ReflectionDistortion控制。为了动画效果可以让这张纹理的UV随时间滚动。注意扭曲的幅度不宜过大否则会破坏镜面反射的认知。4.4 性能考量与优化点这个Shader是逐像素计算的且涉及一次Scene Color纹理采样可能多次如果做模糊对性能有一定影响。控制使用范围不要在所有物体上使用。仅用于明确需要高质量镜面反射的表面。简化计算如果反射平面完全是水平的法线恒为(0,1,0)那么视图空间法线的计算可以简化。甚至反射矩阵公式也可以推导出更简化的版本减少点乘和乘法次数。但在ShaderGraph中这些优化可能不如手写Shader代码明显可读性优先。LOD与剔除对于远处的反射表面可以使用更简化的版本比如降低模糊采样次数甚至关闭菲涅尔或者根据距离动态降低_ReflectionStrength。替代方案评估对于移动端或性能敏感场景仍需评估此方案与反射探针烘焙或混合烘焙和平面反射探针Planar Reflection ProbeUnity内置但只对有限区域有效的优劣。本方案的优势在于无限平面和动态物体的完美实时反射劣势是额外的每像素计算开销。5. 常见问题排查与实战调试技巧即使按照步骤搭建第一次运行时也可能遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。5.1 反射内容错乱或拉伸现象反射的图像上下颠倒、左右镜像或者被严重拉伸变形。排查检查空间转换链这是最常见的原因。逐步检查每个转换节点的From/To空间设置是否正确。确保从View到Clip的转换使用的是正确的Transform节点设置为View到Clip。检查UV的Y方向重点检查从NDC到屏幕UV时Y坐标的处理是否正确。使用_ProjectionParams.x进行平台自适应翻转是关键。可以在计算UV后先输出UV作为颜色临时连接到Base Color在Game视图观察UV分布是否在[0,1]范围内且方向正确。检查反射平面法向量确保视图空间法向量计算正确。可以输出ViewSpacePlaneNormal作为一个颜色例如将(normal.x*0.50.5, normal.y*0.50.5, normal.z*0.50.5)连接到颜色观察其在不同视角下的变化是否符合预期对于水平面视图空间法线会随着摄像机旋转而变化。5.2 反射边缘出现硬边或接缝现象在反射平面的边界处反射图像突然截断或者与非反射区域过渡不自然。排查与解决边界衰减这是必须的。在计算完最终采样UV后不要直接采样。计算UV到[0,1]边界的距离使用Smoothstep函数生成一个边缘衰减因子edgeFactor。例如float2 edgeDist min(uv, 1.0 - uv); // 计算到四条边的最小距离 float edgeFactor smoothstep(0.0, 0.1, min(edgeDist.x, edgeDist.y)); // 在边缘0.1范围内衰减将edgeFactor乘入最终的反射强度中。这样越靠近屏幕边缘的反射强度越弱直到消失过渡非常平滑。深度冲突导致的硬边如4.2节所述调整平面高度或深度偏移。检查平面网格确保用作反射平面的网格本身是连续的UV没有接缝。如果网格由多块拼接而成可能在接缝处出现问题。5.3 反射中缺失动态物体或自身现象静态场景反射正确但移动的玩家角色、车辆等不出现或者反射平面自身在反射中缺失。排查动态物体_CameraOpaqueTexture捕获的是当前帧摄像机渲染的不透明物体。确保你的动态物体使用的Shader是“不透明”Opaque的渲染队列。半透明Transparent物体通常不会被捕获到这张纹理中。这是该技术的一个局限。自身缺失反射平面这是正确的物理现象你在镜子里是看不到镜子本身的除非看它的边缘。如果你希望看到反射平面网格比如一个地板在反射中出现那意味着你需要渲染两次这个地板一次作为实体一次作为反射内容。这通常不是你想要的效果反而会导致视觉错误。所以反射中看不到自身是正常的。5.4 性能热点分析与优化现象游戏帧率下降GPU Profiler显示该Shader耗时较高。排查与优化过度绘制确保反射平面不要被其他不透明物体大面积遮挡。被遮挡的像素仍然会执行完整的Shader计算造成浪费。合理规划场景层次。复杂采样检查是否开启了多采样模糊。如果开了尝试减少采样次数如从8次降到4次或者改用性能更好的模糊方法如降采样后模糊再上采样。Shader变体在ShaderGraph中确保没有不必要的分支或基于关键字的切换。一个复杂的、产生很多变体的Shader在加载和编译时也会影响性能。降级方案为低端设备准备一个简化版Shader例如关闭模糊和扭曲使用一个更简单的菲涅尔计算甚至用 Cubemap 反射探针作为备选。调试ShaderGraph的一个强大工具是使用Master Preview窗口中的Property视图。你可以将中间任何一个向量或标量输出到Base Color然后通过调整属性值在预览中实时观察该值的分布通常会映射到RGB颜色。这是定位计算错误最直观的方法。例如将反射后的视图空间坐标的X分量映射到红色通道就能立刻看到计算是否正确对称。