文章目录
-
- [0. 效果预览](#0. 效果预览)
- [1. 原理简述](#1. 原理简述)
- [2. 功能点](#2. 功能点)
- [3. 完整 Shader(可直接用)](#3. 完整 Shader(可直接用))
- [4. 使用方法](#4. 使用方法)
-
- [4.1 C# 反射脚本](# 反射脚本)
- [4.2 搭建步骤](#4.2 搭建步骤)
- [5. 参数说明](#5. 参数说明)
-
- [Shader 参数](#Shader 参数)
- [C# 脚本参数](# 脚本参数)
- [6. 变体与扩展](#6. 变体与扩展)
-
- [6.1 菲涅尔控制反射强度](#6.1 菲涅尔控制反射强度)
- [6.2 法线扰动倒影(水面波纹)](#6.2 法线扰动倒影(水面波纹))
- [6.3 模糊反射(粗糙地面)](#6.3 模糊反射(粗糙地面))
- [7. 常见问题](#7. 常见问题)
- [8. 性能建议](#8. 性能建议)
0. 效果预览

平面反射(Planar Reflection)是让地面、水面、镜面产生实时倒影的经典技术:用一个镜像摄像机从反射角度渲染场景,把结果投影到反射平面上。效果真实、原理直观,是中级 Shader 的标志性练习。
1. 原理简述
平面反射的本质:在反射平面下方放一个"镜像摄像机",沿平面法线翻转主摄像机的位置和朝向,渲染到 RenderTexture,再把这张 RT 投影到反射平面的 UV 上。
三步流程:
1. 计算反射矩阵:沿反射平面翻转主摄像机 → 得到镜像摄像机的 View 矩阵
2. 镜像摄像机渲染到 RenderTexture(用斜裁剪面避免渲染平面以下的物体)
3. 反射平面的 Shader 用屏幕空间 UV 采样这张 RT → 显示倒影
反射矩阵的数学:
给定反射平面方程 ax + by + cz + d = 0(法线 (a,b,c),距离 d),反射矩阵为:
hlsl
// 反射矩阵:将点沿平面镜像翻转
// M = I - 2 * n * nT(n 是平面法线,nT 是转置)
float4 plane = float4(normal.x, normal.y, normal.z, -dot(normal, pointOnPlane));
Matrix4x4 reflectionMatrix;
reflectionMatrix.m00 = 1 - 2 * plane.x * plane.x;
reflectionMatrix.m01 = - 2 * plane.x * plane.y;
reflectionMatrix.m02 = - 2 * plane.x * plane.z;
reflectionMatrix.m03 = - 2 * plane.x * plane.w;
// ... 其余行类似
斜裁剪面(Oblique Clip Plane):
镜像摄像机如果用普通近裁面,会渲染到反射平面"下方"的物体(实际在平面上方),产生穿帮。解决方案是把近裁面设为反射平面本身------只渲染平面"上方"的内容。Unity 提供 Camera.CalculateObliqueMatrix() 直接算。
2. 功能点
- 实时平面反射:镜像摄像机渲染场景倒影到 RenderTexture
- 反射矩阵计算:C# 脚本自动计算反射平面的镜像变换
- 斜裁剪面:避免渲染反射平面以下的物体,消除穿帮
- 屏幕空间 UV 投影:Shader 用屏幕坐标采样 RT,倒影自动对齐
- 反射强度可调 :
_ReflectionStrength控制倒影和基础色的混合 - RT 分辨率可调:在性能和画质之间取舍
- 主贴图支持:保留地面/水面原始纹理
3. 完整 Shader(可直接用)
hlsl
Shader "Custom/PlanarReflection_URP"
{
Properties
{
// 主贴图(地面/水面纹理)
_BaseMap ("Base Map", 2D) = "white" {}
// 主颜色叠乘
_BaseColor ("Base Color", Color) = (1,1,1,1)
// 反射 RenderTexture(由 C# 脚本设置,不需要手动拖)
_ReflectionTex ("Reflection Texture", 2D) = "black" {}
// 反射强度(0=无反射,1=完全镜面)
_ReflectionStrength ("Reflection Strength", Range(0, 1)) = 0.5
// 反射颜色叠乘(可以给倒影加色调)
_ReflectionTint ("Reflection Tint", Color) = (1,1,1,1)
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalRenderPipeline"
"Queue" = "Geometry"
"RenderType" = "Opaque"
}
Pass
{
Name "PlanarReflectionPass"
Tags { "LightMode" = "UniversalForward" }
Cull Back
ZWrite On
Blend Off
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// =========================================================
// 贴图声明
// =========================================================
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
TEXTURE2D(_ReflectionTex); SAMPLER(sampler_ReflectionTex);
// =========================================================
// 材质属性
// =========================================================
float4 _BaseMap_ST;
float4 _BaseColor;
float _ReflectionStrength;
float4 _ReflectionTint;
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
float4 screenPos : TEXCOORD1; // 屏幕空间坐标(用于采样反射 RT)
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings vert(Attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
// 计算屏幕空间坐标(透视除法在 frag 中做)
o.screenPos = ComputeScreenPos(o.positionHCS);
return o;
}
half4 frag(Varyings i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
// 1) 采样主贴图
half4 baseCol = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
baseCol *= (half4)_BaseColor;
// 2) 采样反射 RenderTexture
// 用屏幕空间 UV(透视除法)
float2 reflUV = i.screenPos.xy / i.screenPos.w;
half4 reflCol = SAMPLE_TEXTURE2D(_ReflectionTex, sampler_ReflectionTex, reflUV);
reflCol *= (half4)_ReflectionTint;
// 3) 混合基础色和反射
half3 finalColor = lerp(baseCol.rgb, reflCol.rgb, _ReflectionStrength);
return half4(finalColor, baseCol.a);
}
ENDHLSL
}
}
}
4. 使用方法
4.1 C# 反射脚本
这个脚本是平面反射的核心------它创建镜像摄像机、计算反射矩阵、渲染到 RT、传给 Shader。
csharp
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
/// <summary>
/// 平面反射控制器
/// 挂载到反射平面对象上(如地面 Plane)
/// </summary>
[ExecuteAlways]
public class PlanarReflection : MonoBehaviour
{
[Header("===== 反射设置 =====")]
[SerializeField] private int _textureSize = 512; // RT 分辨率
[SerializeField] private float _clipPlaneOffset = 0.01f; // 裁剪面偏移(防止闪烁)
[SerializeField] private LayerMask _reflectionLayers = -1; // 反射哪些层
private Camera _reflectionCamera;
private RenderTexture _reflectionRT;
private Material _material;
// 缓存 Shader 属性 ID,避免每帧字符串查找
private static readonly int ReflectionTexID = Shader.PropertyToID("_ReflectionTex");
void OnEnable()
{
// 获取反射平面的材质
_material = GetComponent<Renderer>().sharedMaterial;
CreateReflectionCamera();
CreateRenderTexture();
// 注册到 URP 渲染回调
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
CleanUp();
}
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera cam)
{
// 只处理主摄像机(跳过反射摄像机自身和其他摄像机)
if (cam.cameraType != CameraType.Game && cam.cameraType != CameraType.SceneView)
return;
if (cam == _reflectionCamera)
return;
RenderReflection(cam);
}
private void RenderReflection(Camera mainCamera)
{
// ===== 1) 计算反射平面 =====
Vector3 planePos = transform.position;
Vector3 planeNormal = transform.up;
// ===== 2) 计算反射矩阵 =====
float d = -Vector3.Dot(planeNormal, planePos) - _clipPlaneOffset;
Vector4 reflectionPlane = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, d);
Matrix4x4 reflectionMatrix = CalculateReflectionMatrix(reflectionPlane);
// ===== 3) 设置镜像摄像机 =====
_reflectionCamera.cullingMask = _reflectionLayers;
_reflectionCamera.targetTexture = _reflectionRT;
// 镜像摄像机的世界到相机矩阵 = 主摄像机矩阵 × 反射矩阵
_reflectionCamera.worldToCameraMatrix = mainCamera.worldToCameraMatrix * reflectionMatrix;
// ===== 4) 斜裁剪面(只渲染反射平面上方的物体) =====
Vector4 clipPlane = CameraSpacePlane(_reflectionCamera, planePos, planeNormal);
_reflectionCamera.projectionMatrix = mainCamera.CalculateObliqueMatrix(clipPlane);
// ===== 5) 翻转剔除方向(反射后三角形绕序反转) =====
GL.invertCulling = true;
// ===== 6) 渲染(URP 14+ 推荐的 RenderRequest API) =====
var request = new UniversalRenderPipeline.SingleCameraRequest();
if (RenderPipeline.SupportsRenderRequest(_reflectionCamera, request))
RenderPipeline.SubmitRenderRequest(_reflectionCamera, request);
GL.invertCulling = false;
// ===== 7) 传递 RT 给材质 =====
_material.SetTexture(ReflectionTexID, _reflectionRT);
}
// ===== 反射矩阵计算 =====
private static Matrix4x4 CalculateReflectionMatrix(Vector4 plane)
{
Matrix4x4 m = Matrix4x4.identity;
m.m00 = 1f - 2f * plane.x * plane.x;
m.m01 = - 2f * plane.x * plane.y;
m.m02 = - 2f * plane.x * plane.z;
m.m03 = - 2f * plane.x * plane.w;
m.m10 = - 2f * plane.y * plane.x;
m.m11 = 1f - 2f * plane.y * plane.y;
m.m12 = - 2f * plane.y * plane.z;
m.m13 = - 2f * plane.y * plane.w;
m.m20 = - 2f * plane.z * plane.x;
m.m21 = - 2f * plane.z * plane.y;
m.m22 = 1f - 2f * plane.z * plane.z;
m.m23 = - 2f * plane.z * plane.w;
m.m30 = 0; m.m31 = 0; m.m32 = 0; m.m33 = 1;
return m;
}
// ===== 将世界空间平面转到摄像机空间 =====
private Vector4 CameraSpacePlane(Camera cam, Vector3 pos, Vector3 normal)
{
Matrix4x4 worldToCam = cam.worldToCameraMatrix;
Vector3 camPos = worldToCam.MultiplyPoint(pos);
Vector3 camNormal = worldToCam.MultiplyVector(normal).normalized;
return new Vector4(camNormal.x, camNormal.y, camNormal.z, -Vector3.Dot(camPos, camNormal));
}
// ===== 创建反射摄像机 =====
private void CreateReflectionCamera()
{
if (_reflectionCamera != null) return;
GameObject go = new GameObject("ReflectionCamera");
go.hideFlags = HideFlags.HideAndDontSave;
_reflectionCamera = go.AddComponent<Camera>();
_reflectionCamera.enabled = false; // 手动渲染,不自动渲染
// URP 需要 UniversalAdditionalCameraData
var cameraData = go.AddComponent<UniversalAdditionalCameraData>();
cameraData.requiresColorOption = CameraOverrideOption.Off;
cameraData.requiresDepthOption = CameraOverrideOption.Off;
cameraData.renderShadows = false;
}
// ===== 创建 RenderTexture =====
private void CreateRenderTexture()
{
if (_reflectionRT != null && _reflectionRT.width == _textureSize) return;
if (_reflectionRT != null)
_reflectionRT.Release();
_reflectionRT = new RenderTexture(_textureSize, _textureSize, 16, RenderTextureFormat.ARGB32);
_reflectionRT.name = "PlanarReflectionRT";
}
private void CleanUp()
{
if (_reflectionRT != null)
{
_reflectionRT.Release();
_reflectionRT = null;
}
if (_reflectionCamera != null)
{
DestroyImmediate(_reflectionCamera.gameObject);
_reflectionCamera = null;
}
}
}
4.2 搭建步骤
-
在
Assets/Shaders/下新建PlanarReflection_URP.shader,粘贴 Shader 代码。 -
新建材质,Shader 选择
Custom/PlanarReflection_URP。 -
创建一个 Plane(或任意平面网格)作为反射面,赋上材质。
-
将
PlanarReflection.cs脚本挂到这个 Plane 上。 -
Inspector 中配置:
Texture Size:512(性能优先)或 1024(画质优先)Reflection Layers:选择需要产生倒影的层(排除反射面自身的层)- 材质的
Reflection Strength:0.5 = 半透明倒影,1.0 = 完全镜面
-
在反射面上方放几个物体(球体、方块、角色),运行场景观察倒影效果。

5. 参数说明
Shader 参数
| 参数 | 类型 | 范围/默认值 | 说明 |
|---|---|---|---|
_BaseMap |
2D | white | 反射面主贴图 |
_BaseColor |
Color | (1,1,1,1) | 主颜色叠乘 |
_ReflectionTex |
2D | black | 反射 RT(脚本自动设置) |
_ReflectionStrength |
Range(0,1) | 0.5 | 反射强度:0=无反射,1=完全镜面 |
_ReflectionTint |
Color | (1,1,1,1) | 反射颜色叠乘(给倒影加色调) |
C# 脚本参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
_textureSize |
int | 512 | RT 分辨率(越大越清晰,越耗性能) |
_clipPlaneOffset |
float | 0.01 | 裁剪面偏移(防止反射面边缘闪烁) |
_reflectionLayers |
LayerMask | Everything | 哪些层的物体产生倒影 |
6. 变体与扩展
6.1 菲涅尔控制反射强度
真实世界中,正面看反射弱、掠射角看反射强。用菲涅尔项调制反射强度:
hlsl
// 在 Varyings 中加 normalWS 和 viewDirWS
float NdotV = saturate(dot(normalWS, viewDirWS));
float fresnel = pow(1.0 - NdotV, 3.0);
float reflStrength = lerp(_ReflectionStrength * 0.3, _ReflectionStrength, fresnel);
half3 finalColor = lerp(baseCol.rgb, reflCol.rgb, reflStrength);
6.2 法线扰动倒影(水面波纹)
用法线贴图偏移反射 UV,让倒影产生水面波纹效果:
hlsl
// 采样法线贴图
float3 bumpNormal = UnpackNormal(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv));
// 用法线的 XY 偏移反射 UV
float2 reflUV = i.screenPos.xy / i.screenPos.w;
reflUV += bumpNormal.xy * _DistortionStrength;
half4 reflCol = SAMPLE_TEXTURE2D(_ReflectionTex, sampler_ReflectionTex, reflUV);
配合 _Time 滚动法线贴图 UV,就能做出动态水面倒影。
6.3 模糊反射(粗糙地面)
对反射 RT 做模糊采样,模拟粗糙表面的模糊倒影:
hlsl
// 简单的 3x3 均值模糊
float2 texelSize = 1.0 / float2(_textureSize, _textureSize);
half3 blur = half3(0,0,0);
for (int x = -1; x <= 1; x++)
for (int y = -1; y <= 1; y++)
blur += SAMPLE_TEXTURE2D(_ReflectionTex, sampler_ReflectionTex,
reflUV + float2(x, y) * texelSize * _BlurRadius).rgb;
blur /= 9.0;
更好的方案是在 C# 端对 RT 做高斯模糊后处理,再传给 Shader。
7. 常见问题
Q: 倒影上下颠倒 / 位置偏移?
A: 检查反射平面的 transform.up 是否正确指向上方。反射矩阵依赖平面法线方向,如果平面旋转了,法线方向也要跟着变。脚本中用 transform.up 自动获取。
Q: 倒影中出现反射面自身(无限递归)?
A: 把反射面放到单独的 Layer,在 _reflectionLayers 中排除这个 Layer。否则镜像摄像机会渲染反射面本身,产生递归反射。
Q: 反射面边缘有闪烁/锯齿?
A: 调大 _clipPlaneOffset(如 0.02~0.05)。斜裁剪面和反射面完全重合时,浮点精度问题会导致边缘像素闪烁。偏移一小段距离可以消除。
Q: 性能开销大吗?
A: 平面反射 = 额外渲染一次场景,相当于多一个摄像机。开销取决于 RT 分辨率和场景复杂度。512×512 的 RT 在大多数场景下可以接受,1024 以上需要注意帧率。
Q: 多个反射面怎么办?
A: 每个反射面需要独立的镜像摄像机和 RT。多个反射面 = 多次额外渲染,性能成倍增加。实际项目中通常只对一个主要反射面(如地面/水面)做实时反射,其他用 Cubemap 或 SSR 替代。
8. 性能建议
- RT 分辨率是关键:512×512 是性价比最高的选择,1024 用于特写镜头。不要用全屏分辨率,没必要。
- Layer 过滤 :
_reflectionLayers只勾选需要产生倒影的物体层,排除粒子、UI、小道具等不重要的层,大幅减少镜像摄像机的渲染量。 - 关闭阴影 :镜像摄像机的
renderShadows = false,倒影中不需要阴影,省一大块开销。 - 距离剔除 :给镜像摄像机设较小的
farClipPlane,远处物体不需要产生倒影。 - 按需渲染:不需要每帧都更新反射。可以隔帧渲染(每 2-3 帧更新一次 RT),或者只在摄像机移动时更新。