URP水面反射实现:从Planar Reflection原理到Shader实战

发布时间:2026/8/4 3:19:28
URP水面反射实现:从Planar Reflection原理到Shader实战 1. 项目概述为什么URP的水面反射是个“坑”做水面效果镜面反射几乎是绕不开的一环。在Unity内置管线或者老旧的Build-in管线里你可能随手抄个Planar Reflection的脚本再配个简单的Shader就能跑起来效果也还凑合。但一旦项目升级到URPUniversal Render Pipeline这套老方法就立刻“水土不服”了。我最近在一个海洋模拟项目里就为了给水面加上一个真实、高效且可控的镜面反射前前后后折腾了小一周踩遍了从C#脚本到Shader代码的每一个坑。简单来说URP的渲染架构和内置管线有根本性的不同。它取消了传统的Camera.RenderWithShader这类“万能”方法引入了更现代的CommandBuffer和Render Pass概念对渲染目标Render Texture的管理也更加严格。如果你直接把内置管线的反射脚本搬过来大概率会遇到黑屏、反射错乱、性能骤降或者干脆编译报错的问题。这个指南的目的就是把我趟过的这些坑以及最终稳定可用的解决方案从脚本到Shader完整地梳理给你。无论你是从内置管线迁移过来还是直接在URP项目里开发都能找到一条清晰的路径。2. 核心思路与方案选型为什么不用Screen Space Reflection提到反射很多人第一个想到的可能是屏幕空间反射SSR。SSR确实很强大能处理复杂表面的反射但它有几个硬伤不适合水面这个特定场景。首先SSR依赖深度和法线信息对于大面积、平静或规律波动的水面来说属于“杀鸡用牛刀”计算开销大。其次SSR无法反射屏幕外的物体当摄像机看向水面边缘时反射内容会突然消失非常突兀。最后URP内置的SSR方案如果需要配置复杂且对移动平台不友好。因此对于经典的水面我们依然采用平面反射Planar Reflection方案。其核心原理非常直观在水面位置假设一个虚拟的“反射摄像机”这个摄像机是主摄像机关于水面对称的镜像。我们用这个反射摄像机去渲染场景得到一张渲染纹理Render Texture这张纹理就是反射贴图。最后在Shader中根据水面像素点的位置和法线去采样这张反射贴图并混合到最终颜色上。在URP中实现这个方案关键点在于如何合法地、高效地获取到这张反射贴图。这涉及到URP渲染流程的介入、CommandBuffer的正确使用以及Shader中如何与URP的灯光和阴影数据对接。2.1 方案对比Blit Pass vs. Camera Stacking在URP中主要有两种思路来渲染反射贴图Blit Pass方案在URP的Renderer中自定义一个ScriptableRenderPass。在这个Pass里我们设置反射相机的渲染目标然后通过ScriptableRenderContext.ExecuteCommandBuffer来调度渲染命令。这是最符合URP设计哲学的方式能与URP的渲染管线深度集成便于管理渲染状态和资源。Camera Stacking方案创建一个额外的Camera将其设为渲染反射的专用相机然后通过代码控制其渲染时机和输出目标。这种方式更接近传统思维但需要小心处理相机堆叠、渲染层Culling Mask以及如何避免它干扰主渲染流程。经过实践Blit Pass方案是更优选择。原因在于它避免了额外相机带来的管理复杂度如清除状态、渲染顺序冲突并且能更精细地控制渲染发生在管线的哪个阶段例如在渲染不透明物体之后、天空盒之前。本指南将主要围绕这种方案展开。3. 脚本实现构建URP兼容的反射渲染器脚本部分是我们的“导演”负责创建反射摄像机、安排渲染任务、管理渲染纹理。核心是一个继承自MonoBehaviour的管理器脚本以及一个自定义的ScriptableRenderPass。3.1 反射管理器脚本PlanarReflectionRenderer这个脚本挂载在水面物体或一个空物体上负责全局控制和资源管理。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class PlanarReflectionRenderer : MonoBehaviour { [Header(反射平面设置)] public Transform reflectionPlane; // 水面物体的Transform public LayerMask reflectionLayers -1; // 哪些层级的物体参与反射 [Header(渲染纹理设置)] public int textureSize 512; // 反射贴图分辨率 public float clipPlaneOffset 0.07f; // 裁剪平面偏移防止Z-fighting private Camera m_ReflectionCamera; private RenderTexture m_ReflectionTexture; private static PlanarReflectionPass m_PlanarReflectionPass; private bool m_IsRendering false; void OnEnable() { // 确保有一个URP的Renderer Data资源 var urpAsset GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; if (urpAsset null) { Debug.LogError(当前未使用URP渲染管线); return; } // 创建或获取自定义的Renderer Feature var rendererData urpAsset.scriptableRendererData as UniversalRendererData; if (rendererData null) return; // 查找或添加我们的自定义Pass bool passExists false; foreach (var feature in rendererData.rendererFeatures) { if (feature is PlanarReflectionFeature) { m_PlanarReflectionPass (feature as PlanarReflectionFeature).pass; passExists true; break; } } if (!passExists) { var reflectionFeature ScriptableObject.CreateInstancePlanarReflectionFeature(); reflectionFeature.pass new PlanarReflectionPass(this); m_PlanarReflectionPass reflectionFeature.pass; rendererData.rendererFeatures.Add(reflectionFeature); rendererData.SetDirty(); } CreateRenderTexture(); CreateReflectionCamera(); } void CreateRenderTexture() { if (m_ReflectionTexture ! null) RenderTexture.ReleaseTemporary(m_ReflectionTexture); // 使用RenderTexture.GetTemporary进行分配便于Unity引擎管理内存和复用 m_ReflectionTexture RenderTexture.GetTemporary(textureSize, textureSize, 16, RenderTextureFormat.DefaultHDR); m_ReflectionTexture.name PlanarReflectionRT; m_ReflectionTexture.filterMode FilterMode.Bilinear; m_ReflectionTexture.wrapMode TextureWrapMode.Clamp; if (m_PlanarReflectionPass ! null) m_PlanarReflectionPass.SetRenderTarget(m_ReflectionTexture); } void CreateReflectionCamera() { if (m_ReflectionCamera ! null) return; GameObject go new GameObject(Planar Reflection Camera, typeof(Camera)); m_ReflectionCamera go.GetComponentCamera(); m_ReflectionCamera.enabled false; // 禁用自动渲染由我们手动控制 m_ReflectionCamera.transform.SetParent(transform, false); m_ReflectionCamera.gameObject.hideFlags HideFlags.HideAndDontSave; } // 提供给RenderPass调用的核心方法计算反射矩阵并渲染 public void RenderReflection(ScriptableRenderContext context, Camera mainCamera) { if (m_IsRendering || reflectionPlane null || m_ReflectionCamera null || m_ReflectionTexture null) return; m_IsRendering true; // 1. 计算水面平面的世界空间方程 (ax by cz d 0) Vector3 planePos reflectionPlane.position; Vector3 planeNormal reflectionPlane.up; // 假设水面朝上 float planeDistance -Vector3.Dot(planeNormal, planePos); Vector4 reflectionPlaneWorld new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, planeDistance); // 2. 计算将主摄像机变换到反射空间的矩阵 Matrix4x4 reflectionMatrix Matrix4x4.zero; CalculateReflectionMatrix(ref reflectionMatrix, reflectionPlaneWorld); // 3. 设置反射相机的位置和旋转主摄像机关于平面对称 Vector3 oldMainCamPos mainCamera.transform.position; Vector3 newMainCamPos reflectionMatrix.MultiplyPoint(oldMainCamPos); m_ReflectionCamera.transform.position newMainCamPos; Vector3 oldMainCamForward mainCamera.transform.forward; Vector3 newMainCamForward reflectionMatrix.MultiplyVector(oldMainCamForward); m_ReflectionCamera.transform.rotation Quaternion.LookRotation(newMainCamForward, reflectionPlane.up); // 4. 设置反射相机的投影矩阵应用斜裁剪防止渲染到水面以下的部分 Camera mainCam mainCamera; m_ReflectionCamera.CopyFrom(mainCam); // 复制FoV、近远裁剪面等基础设置 m_ReflectionCamera.cullingMask reflectionLayers; m_ReflectionCamera.targetTexture m_ReflectionTexture; // 计算并设置斜投影矩阵关键避免反射穿帮 Matrix4x4 projectionMatrix mainCam.projectionMatrix; CalculateObliqueMatrix(ref projectionMatrix, reflectionPlaneWorld, m_ReflectionCamera); m_ReflectionCamera.projectionMatrix projectionMatrix; // 5. 设置渲染状态并执行渲染 // 反转剔除模式因为我们在镜像世界中观察背面 var oldCulling GL.invertCulling; GL.invertCulling !oldCulling; // 手动调用URP的渲染流程来渲染这个相机 UniversalRenderPipeline.RenderSingleCamera(context, m_ReflectionCamera); GL.invertCulling oldCulling; m_IsRendering false; } // 计算反射矩阵的辅助函数 static void CalculateReflectionMatrix(ref Matrix4x4 reflectionMat, Vector4 plane) { reflectionMat.m00 (1F - 2F * plane[0] * plane[0]); reflectionMat.m01 (-2F * plane[0] * plane[1]); reflectionMat.m02 (-2F * plane[0] * plane[2]); reflectionMat.m03 (-2F * plane[3] * plane[0]); reflectionMat.m10 (-2F * plane[1] * plane[0]); reflectionMat.m11 (1F - 2F * plane[1] * plane[1]); reflectionMat.m12 (-2F * plane[1] * plane[2]); reflectionMat.m13 (-2F * plane[3] * plane[1]); /* ... 省略m20, m21, m22, m23 ... */ reflectionMat.m33 1F; } // 计算斜投影矩阵的辅助函数核心防坑点 static void CalculateObliqueMatrix(ref Matrix4x4 projection, Vector4 clipPlane, Camera camera) { Vector4 q projection.inverse * new Vector4( Mathf.Sign(clipPlane.x), Mathf.Sign(clipPlane.y), 1.0f, 1.0f ); Vector4 c clipPlane * (2.0f / Vector4.Dot(clipPlane, q)); projection[2] c.x - projection[3]; projection[6] c.y - projection[7]; projection[10] c.z - projection[11]; projection[14] c.w - projection[15]; } void OnDisable() { if (m_ReflectionTexture ! null) { RenderTexture.ReleaseTemporary(m_ReflectionTexture); m_ReflectionTexture null; } if (m_ReflectionCamera ! null) { DestroyImmediate(m_ReflectionCamera.gameObject); m_ReflectionCamera null; } } }注意CalculateReflectionMatrix和CalculateObliqueMatrix是数学核心。斜投影矩阵的计算是为了让反射相机的近裁剪面与水面平面对齐这样就不会渲染到水面以下的物体否则反射中会出现水底或穿帮的模型这是实现干净反射的关键一步。3.2 自定义Render PassPlanarReflectionPass这个ScriptableRenderPass负责在URP的渲染流程中插入我们的反射渲染任务。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class PlanarReflectionPass : ScriptableRenderPass { private PlanarReflectionRenderer m_Renderer; private RenderTargetHandle m_ReflectionRT; public PlanarReflectionPass(PlanarReflectionRenderer renderer) { m_Renderer renderer; m_ReflectionRT.Init(_PlanarReflectionTexture); // 建议在渲染不透明物体之后执行这样反射中能包含场景中的主要物体 renderPassEvent RenderPassEvent.AfterRenderingOpaques; } public void SetRenderTarget(RenderTexture rt) { // 将外部创建的RT传递给Pass m_ReflectionRT.SetRenderTexture(rt); } public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) { // 如果RT有效将其配置为本次Pass的渲染目标 if (m_ReflectionRT.HasRenderTexture()) { ConfigureTarget(m_ReflectionRT.Identifier()); // 清除颜色和深度 ConfigureClear(ClearFlag.All, Color.black); } } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_Renderer null || !m_ReflectionRT.HasRenderTexture()) return; Camera camera renderingData.cameraData.camera; // 可以在这里添加一些条件例如只对主相机生效或者根据相机与水面距离判断是否需要渲染 if (camera.cameraType ! CameraType.Game) return; // 调用管理器进行实际的反射渲染 CommandBuffer cmd CommandBufferPool.Get(Planar Reflection); using (new ProfilingScope(cmd, new ProfilingSampler(PlanarReflection))) { m_Renderer.RenderReflection(context, camera); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void FrameCleanup(CommandBuffer cmd) { // 清理工作这里我们不需要特别清理因为RT由管理器管理生命周期 } }为了让这个Pass能被URP识别我们还需要一个简单的ScriptableRendererFeature作为包装。public class PlanarReflectionFeature : ScriptableRendererFeature { public PlanarReflectionPass pass; public override void Create() { // Pass在管理器中创建 } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (pass ! null) renderer.EnqueuePass(pass); } }脚本部分避坑指南裁剪平面偏移Clip Plane OffsetclipPlaneOffset这个参数至关重要。由于浮点数精度问题直接用水面作为裁剪平面可能会导致反射相机渲染出的像素与水面本身产生深度冲突Z-fighting在反射边缘出现闪烁的像素。一个小的偏移值如0.07可以有效地避免这个问题。渲染纹理格式使用RenderTextureFormat.DefaultHDR。水面反射通常包含高亮的光源如太阳使用HDR格式可以保留更宽的亮度范围避免反射的高光区域过曝成一片白色后续在Shader中可以进行色调映射Tonemapping。剔除Culling反转在渲染反射相机前反转GL.invertCulling是必须的。因为反射相机位于一个镜像世界模型的正面和背面关系相对于这个相机是反的。如果不反转所有背面剔除的模型在反射中都会消失。性能优化可以通过计算主摄像机与水面的距离、夹角来判断是否需要更新反射贴图。如果摄像机离水面很远或根本看不到水面可以跳过渲染或者降低textureSize甚至使用上一帧的反射贴图但要注意摄像机移动时的拖影问题。4. Shader实现在URP Lit Shader中采样反射脚本生成了反射贴图接下来就需要在Shader中用它。URP推荐使用ShaderGraph但为了更清晰地理解原理和应对复杂定制我们直接编写HLSL Shader。我们将创建一个基于URP Lit Shader模板的变体。4.1 Shader属性与变量声明Shader Custom/Water/PlanarReflectionURP { Properties { // 基础颜色与平滑度 _BaseColor(Base Color, Color) (0.2, 0.6, 0.8, 1.0) _BaseMap(Base Map, 2D) white {} _Smoothness(Smoothness, Range(0.0, 1.0)) 0.9 // 法线贴图用于模拟波浪细节 _BumpMap(Normal Map, 2D) bump {} _BumpScale(Normal Scale, Float) 1.0 // 反射相关 _ReflectionTex(Reflection Texture, 2D) black {} _ReflectionDistortion(Reflection Distortion, Range(0.0, 0.1)) 0.01 _ReflectionStrength(Reflection Strength, Range(0.0, 1.0)) 0.8 _ReflectionFresnelPower(Fresnel Power, Float) 5.0 // 边缘泡沫/深度色 _DepthGradientShallow(Shallow Color, Color) (0.2, 0.6, 0.8, 0.5) _DepthGradientDeep(Deep Color, Color) (0.0, 0.2, 0.4, 1.0) _DepthMaxDistance(Depth Max Distance, Float) 10 } SubShader { Tags { RenderPipelineUniversalPipeline RenderTypeTransparent QueueTransparent } // 深度写入关闭混合模式用于透明 ZWrite Off Blend SrcAlpha OneMinusSrcAlpha HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); TEXTURE2D(_ReflectionTex); SAMPLER(sampler_ReflectionTex); // 声明场景深度纹理 TEXTURE2D_X_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; float4 _BaseColor; float _Smoothness; float4 _BumpMap_ST; float _BumpScale; float _ReflectionDistortion; float _ReflectionStrength; float _ReflectionFresnelPower; float4 _DepthGradientShallow; float4 _DepthGradientDeep; float _DepthMaxDistance; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float3 tangentWS : TEXCOORD3; float3 bitangentWS : TEXCOORD4; float4 screenPos : TEXCOORD5; // 用于屏幕空间计算 }; ENDHLSL Pass { Name ForwardLit Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION Varyings vert(Attributes input) { Varyings output; VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS vertexInput.positionCS; output.positionWS vertexInput.positionWS; output.normalWS normalInput.normalWS; output.tangentWS normalInput.tangentWS; output.bitangentWS normalInput.bitangentWS; output.uv TRANSFORM_TEX(input.texcoord, _BaseMap); output.screenPos ComputeScreenPos(output.positionCS); return output; } // 计算菲涅尔效应Fresnel Effect float CalculateFresnel(float3 viewDirWS, float3 normalWS, float power) { float cosTheta saturate(dot(viewDirWS, normalWS)); return pow(1.0 - cosTheta, power); } // 根据深度混合水面颜色 float4 CalculateDepthColor(float3 positionWS, float3 cameraPosWS) { // 计算水面点到摄像机的水平距离近似深度 float3 vecToCamera cameraPosWS - positionWS; // 忽略Y轴高度差主要考虑水平面的深度感 vecToCamera.y 0; float depth length(vecToCamera); depth saturate(depth / _DepthMaxDistance); return lerp(_DepthGradientShallow, _DepthGradientDeep, depth); } half4 frag(Varyings input) : SV_Target { // 1. 采样法线贴图并变换到世界空间 float3 normalTS UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv * _BumpMap_ST.xy _BumpMap_ST.zw), _BumpScale); float3x3 TBN float3x3(normalize(input.tangentWS), normalize(input.bitangentWS), normalize(input.normalWS)); float3 normalWS normalize(mul(normalTS, TBN)); // 2. 获取基础光照信息URP函数 InputData inputData (InputData)0; inputData.positionWS input.positionWS; inputData.normalWS normalWS; inputData.viewDirectionWS GetWorldSpaceNormalizeViewDir(input.positionWS); inputData.shadowCoord TransformWorldToShadowCoord(input.positionWS); // 阴影坐标 inputData.fogCoord 0; // 如有需要可计算雾效 inputData.vertexLighting 0; inputData.bakedGI 0; // 我们主要用实时反射可以忽略烘焙GI SurfaceData surfaceData; surfaceData.albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; surfaceData.specular 0.0; // 金属工作流中设为0高光由平滑度控制 surfaceData.metallic 0.0; // 水是非金属 surfaceData.smoothness _Smoothness; surfaceData.normalTS normalTS; surfaceData.emission 0; surfaceData.occlusion 1.0; surfaceData.alpha _BaseColor.a; // 3. 计算URP标准光照包含主光、附加光、阴影 half4 color UniversalFragmentPBR(inputData, surfaceData); // 4. 计算并采样反射 // 4.1 计算屏幕空间UV用于采样反射贴图和深度纹理 float2 screenUV input.screenPos.xy / input.screenPos.w; // 4.2 用扰动后的法线扭曲反射UV float2 reflectionUV screenUV normalWS.xz * _ReflectionDistortion; // 采样我们脚本渲染的反射贴图 half3 reflectionColor SAMPLE_TEXTURE2D(_ReflectionTex, sampler_ReflectionTex, reflectionUV).rgb; // 4.3 计算菲涅尔因子视角与法线夹角越大反射越强 float fresnel CalculateFresnel(inputData.viewDirectionWS, normalWS, _ReflectionFresnelPower); float reflectionFactor fresnel * _ReflectionStrength; // 5. 混合反射颜色到光照结果 color.rgb lerp(color.rgb, reflectionColor, reflectionFactor); // 6. 应用基于深度的颜色渐变模拟水深度 float4 depthColor CalculateDepthColor(input.positionWS, GetCameraPositionWS()); color.rgb lerp(color.rgb, depthColor.rgb, depthColor.a); // 7. 可选简单的屏幕空间边缘泡沫/深度过渡 // 采样场景深度 float sceneDepth SampleSceneDepth(screenUV); float surfaceDepth input.screenPos.w; // 水面像素的深度 // 线性化深度需要近裁剪面距离这里简化处理 // 实际项目中应从_ProjectionParams.y获取近裁剪面距离进行线性化 float depthDifference sceneDepth - surfaceDepth; // 在交界处depthDifference很小的地方添加白色泡沫效果 float foam 1.0 - saturate(depthDifference * 50.0); // 系数可调 color.rgb foam * 0.3; return color; } ENDHLSL } } FallBack Universal Render Pipeline/Lit }4.2 Shader部分核心解析与避坑反射UV的扭曲reflectionUV screenUV normalWS.xz * _ReflectionDistortion;这是模拟水面波动对反射影像的扭曲。我们使用世界空间法线的XZ分量即水平方向的扰动来偏移采样坐标。_ReflectionDistortion控制扭曲强度值太大会导致反射失真严重太小则像镜子一样死板。菲涅尔效应Fresnel这是让反射看起来自然的关键。CalculateFresnel函数计算视线方向与法线夹角的函数。当垂直看向水面时夹角小你更多看到水下的颜色基础色当掠射看向水面时夹角大反射更强烈。_ReflectionFresnelPower控制过渡的锐利程度值越大反射与基础色的分界线越明显。与URP光照模型的整合我们使用了UniversalFragmentPBR这个URP核心函数来计算标准光照漫反射、高光、阴影。这意味着我们的水面能正确接收场景中的动态光影这是很多自定义水Shader忽略的一点。反射是叠加在这个光照结果之上的。深度纹理采样与边缘泡沫通过声明和采样_CameraDepthTexture我们可以比较水面的深度和场景中它后面物体的深度。在两者非常接近的地方即水与岸边或物体的交界处我们通过foam计算添加一些白色像素来模拟泡沫或浪花这个技巧能极大地增强水体的真实感。性能考量这个Shader使用了法线贴图、屏幕空间反射采样、深度纹理采样和完整的PBR光照计算对移动端是中等偏上的负载。如果针对低端设备可以考虑去掉法线贴图扭曲或者使用一张低频率、低分辨率的法线图。简化或移除深度边缘泡沫计算。降低反射贴图的分辨率在脚本的textureSize中设置。5. 整合与调试让一切运转起来现在我们将脚本和Shader组合起来。创建材质在Project窗口右键选择Create - Material将Shader选择为我们刚写的Custom/Water/PlanarReflectionURP。配置水面物体创建一个Quad或Plane作为水面将上一步创建的材质赋给它。添加反射管理器给水面物体或场景中任意一个GameObject添加PlanarReflectionRenderer组件。将水面物体的Transform拖拽到Reflection Plane字段。调整Texture Size如1024Clip Plane Offset如0.07。在Reflection Layers中选择你希望出现在反射中的物体层级通常包括地形、建筑、树木等可以排除水面自身和天空盒。配置URP Renderer确保你的URP Asset中使用的Renderer包含了我们的PlanarReflectionFeature。脚本的OnEnable方法会尝试自动添加但最好在Project中找到你的UniversalRendererData文件确认Renderer Features列表里已经有了它。5.1 常见问题与排查技巧即使按照步骤操作你也可能会遇到一些问题。这里是我踩坑后总结的排查清单问题现象可能原因解决方案反射为全黑1. 反射相机未正确渲染。2. 反射贴图未成功传递到Shader。1. 检查PlanarReflectionRenderer脚本的OnEnable日志确认URP Asset存在。2. 在Frame Debugger中查看渲染事件确认PlanarReflectionPass被执行且绘制了物体。3. 在材质面板检查_ReflectionTex属性是否绑定了一张有效的Render Texture运行时可见。反射错位或扭曲异常1. 反射平面reflectionPlane的Transform尤其是旋转设置错误。2. 法线方向错误。1. 确保reflectionPlane指向的水面物体的Transform.up方向是水面的法线方向通常为(0,1,0)。2. 在Shader中可视化normalWS例如return float4(normalWS*0.50.5, 1.0)检查法线贴图是否正确应用和变换。反射物体闪烁Z-fighting裁剪平面精度问题。增大PlanarReflectionRenderer组件上的Clip Plane Offset值例如从0.07调整到0.1或0.15。反射中有水面自身或其他不该出现的物体1. 反射相机的剔除掩码Culling Mask设置不当。2. 水面物体未设置为在反射层不可见。1. 仔细检查Reflection Layers确保排除了水所在的层通常是Water层。2. 确保水面物体不在Reflection Layers包含的层中。也可以为水面单独设一个层。性能开销过大1. 反射贴图分辨率过高。2. 反射更新每帧都在进行。3. Shader复杂度高。1. 降低Texture Size如512甚至256。2. 在PlanarReflectionPass.Execute中添加距离判断当摄像机远离水面时跳过渲染。3. 简化Shader如禁用法线贴图扭曲或边缘泡沫。反射边缘有硬边反射贴图Wrap Mode为Clamp屏幕UV超出[0,1]范围时采样到边缘颜色。这是平面反射的固有局限。可以尝试在Shader中对屏幕UV进行小幅度的钳制如screenUV clamp(screenUV, 0.01, 0.99)或者使用一个渐变的遮罩在屏幕边缘淡出反射。更好的方案是使用反射探针Reflection Probe作为远景反射的补充。一个重要的调试技巧在PlanarReflectionRenderer.RenderReflection方法中临时将m_ReflectionCamera.targetTexture设置为null并启用相机。这样反射相机的视图会直接渲染到Game窗口你可能需要调整视图布局。这能让你直观地看到反射相机“看到”的内容是否正确是排查反射内容问题的终极手段。6. 进阶优化与扩展思路当基础功能稳定后可以考虑以下方向提升效果和性能动态纹理分辨率根据摄像机与水面的距离或相对速度动态调整textureSize。近距离时用高清1024远距离时用低清256。反射模糊Blur粗糙的水面反射应该是模糊的。可以在渲染到反射贴图后立即使用一个简单的后处理Blur Pass如高斯模糊对m_ReflectionTexture进行处理然后再提供给Shader采样。这能显著提升真实感。与SSR/反射探针混合对于近处的水面使用本文的平面反射。对于远处或复杂角落可以混合使用一个低分辨率的全局反射探针Box Projection或屏幕空间反射作为补充形成完整的反射解决方案。焦散Caustics在Shader中添加焦散效果。这通常需要一张基于场景深度和灯光位置动态生成的焦散贴图采样后以Additive方式叠加到最终颜色上模拟水底的光斑。波浪顶点动画在Vertex Shader中根据时间和噪声纹理对顶点位置进行偏移让水面动起来。注意这会影响反射UV的计算可能需要将顶点动画也同步到反射相机的渲染中通过自定义Shader或替换水面渲染的Mesh否则反射中的水面会是静止的导致“灵魂出窍”般的错位感。这是一个更高级的话题通常需要将顶点动画逻辑写成公共函数在渲染主水面和反射水面时调用相同的计算。实现URP下的高质量水面反射确实比内置管线要繁琐但理解其原理并遵循URP的渲染框架后获得的控制力和性能潜力也是巨大的。这套方案经过了项目验证在PC和主流移动设备上都有不错的表现。最关键的是它为你提供了一个清晰、可扩展的基底你可以在此基础上放心地去添加波浪、焦散、流动等更多细节而不用担心底层渲染管线再给你使绊子。