1. 从光照模型到环境映射:反射与折射到底在解决什么问题
很多刚接触 Unity Shader 的朋友,在把 Lambert 和 Blinn-Phong 光照模型跑通之后,都会遇到一个共同的瓶颈:物体看起来“干巴巴”的,像塑料玩具,完全没有金属的质感、玻璃的通透感,更别说水面那种波光粼粼的效果。这个瓶颈的根源其实很简单——前面那些光照模型只考虑了光源直接打在物体表面的贡献,而真实世界里,物体表面还会反射周围环境的光,透明物体还会让光线穿透并发生偏折。反射和折射这两件事,就是让 Shader 从“能看”跨越到“好看”的关键一步。
这篇文章面向的是已经写过至少一个自定义 Shader、理解顶点/片元着色器基本流程、知道法线和切线空间是什么的读者。如果你还没写过完整的 Shader,建议先把基础光照跑一遍再回来。我会从环境映射的基本原理讲起,把立方体贴图采样、反射向量计算、菲涅尔效应、折射率这些概念拆开揉碎,然后给出可以直接复制使用的完整 Shader 代码,最后把我自己在实际项目里踩过的坑和排查经验整理出来。核心关键词就三个:反射、折射、环境映射,整篇文章围绕它们展开。
先说清楚一个前提:反射和折射在 Shader 里并不是两种完全独立的技术,它们共享同一套底层机制——用方向向量去采样一张环境贴图。区别在于,反射用的是反射方向,折射用的是折射方向,而折射方向的计算需要引入折射率。理解了这一点,后面所有的代码和参数调整都会变得顺理成章。
2. 环境映射的核心原理与方案选型
2.1 为什么需要立方体贴图而不是普通 2D 贴图
反射和折射的本质是“查询某个方向上的环境颜色”。如果用普通 2D 贴图,我们只能通过 UV 坐标去查,而 UV 是二维的,没法直接表达“从某个方向看过去”这种三维信息。立方体贴图(Cubemap)就是为解决这个问题而生的:它由六个面组成,每个面对应一个方向轴(正 X、负 X、正 Y、负 Y、正 Z、负 Z),采样时传入一个三维向量,硬件会自动判断这个向量落在哪个面、面内的具体位置在哪里,然后返回对应颜色。
在 Unity 里,texCUBE就是采样立方体贴图的函数,传入一个方向向量即可。这个方向向量不需要归一化,硬件只关心它的方向。理解这一点很重要,因为后面计算反射向量和折射向量时,我们关心的也只是方向。
提示:立方体贴图的采样开销比普通 2D 贴图略高,但在现代 GPU 上这个差异基本可以忽略。真正影响性能的是采样次数和是否在片元着色器里做复杂运算。
2.2 反射向量的推导与 reflect 函数
反射向量的计算遵循一个非常经典的物理规律:入射角等于反射角。在 Shader 里,Unity 提供了内置函数reflect(I, N),其中 I 是入射方向(注意是从光源指向表面,还是从表面指向光源,方向搞反了结果会完全错误),N 是表面法线。
这里有一个新手最容易搞混的点:reflect函数期望的入射向量 I 是指向表面的方向,而我们在片元着色器里拿到的视角方向viewDir通常是从表面指向摄像机的方向。所以实际使用时,要么把viewDir取反再传进去,要么直接用-viewDir。我见过太多人在这里卡住,渲染出来的反射完全是乱的,排查半天才发现是方向反了。
反射向量的数学表达式是:
R = I - 2 * dot(I, N) * N其中 I 是归一化的入射方向,N 是归一化的法线。reflect函数内部做的就是这件事。理解这个公式的意义在于,当你需要手动优化或者调试时,可以自己展开计算,验证结果是否正确。
2.3 折射向量与斯涅尔定律
折射比反射复杂一点,因为它涉及到两种介质的折射率。斯涅尔定律告诉我们:
n1 * sin(θ1) = n2 * sin(θ2)其中 n1 是入射介质的折射率,n2 是折射介质的折射率,θ1 是入射角,θ2 是折射角。在 Shader 里,Unity 提供了refract(I, N, eta)函数,其中 eta 是入射介质折射率与折射介质折射率的比值,即n1 / n2。
空气的折射率约为 1.0,水的折射率约为 1.33,玻璃的折射率约为 1.5。所以当光线从空气射入玻璃时,eta 大约是1.0 / 1.5 ≈ 0.667。这个值直接决定了折射的强度,eta 越接近 1,折射越弱;eta 偏离 1 越多,折射越明显。
注意:
refract函数在入射角超过临界角时会返回零向量,这对应的是全反射现象。如果你发现折射部分突然变黑,很可能就是触发了全反射,需要检查 eta 和法线的设置。
2.4 反射与折射的混合策略
真实世界中,透明物体表面既有反射也有折射,两者的比例由菲涅尔效应决定:视线越接近掠射角,反射越强;视线越接近垂直,折射越强。在 Shader 里,我们可以用菲涅尔公式来动态混合反射和折射的颜色。
最简单的菲涅尔近似是:
fresnel = pow(1 - dot(viewDir, normal), power)其中 power 控制菲涅尔效应的强度。这个值越大,反射越集中在边缘。实际项目中,我通常会把 power 设在 3 到 5 之间,具体取决于材质的“通透感”需求。
混合公式就是:
finalColor = lerp(refractColor, reflectColor, fresnel)这样就能得到一个既有反射又有折射的透明材质效果。下面这张表总结了反射和折射在关键参数上的差异:
| 对比项 | 反射 | 折射 |
|---|---|---|
| 采样方向 | 反射向量 R | 折射向量 T |
| 核心函数 | reflect(I, N) | refract(I, N, eta) |
| 关键参数 | 法线精度 | 折射率 eta |
| 典型应用 | 金属、镜面、水面 | 玻璃、水晶、液体 |
| 性能开销 | 较低 | 略高(涉及除法) |
3. 完整 Shader 实现与逐行解析
3.1 属性块与渲染状态设置
先看属性块。我们需要一张立方体贴图作为环境源,一个反射强度控制参数,一个折射率参数,以及菲涅尔相关的控制项。
Properties { _CubeMap ("Environment Cubemap", Cube) = "_Skybox" {} _ReflectIntensity ("Reflect Intensity", Range(0, 1)) = 0.5 _RefractRatio ("Refract Ratio (eta)", Range(0.1, 1.0)) = 0.67 _FresnelPower ("Fresnel Power", Range(1, 10)) = 4.0 _TintColor ("Tint Color", Color) = (1, 1, 1, 1) }这里有几个设计决策需要解释。_ReflectIntensity用来整体调节反射的强弱,方便美术在不改代码的情况下调整效果。_RefractRatio就是 eta,默认值 0.67 对应空气到玻璃的折射。_FresnelPower控制边缘反射的集中程度。_TintColor给材质一个整体色调,模拟有色玻璃的效果。
渲染状态方面,透明物体需要设置正确的混合模式和渲染队列:
SubShader { Tags { "Queue" = "Transparent" "RenderType" = "Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back ... }ZWrite Off是透明物体的标准做法,避免深度写入导致的排序问题。Cull Back只渲染正面,如果你需要看到物体内部,可以改成Cull Off,但要注意渲染顺序。
3.2 顶点着色器的输入输出结构
顶点着色器的任务很明确:把顶点位置从模型空间转到裁剪空间,把法线和视角方向准备好传给片元着色器。
struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 worldViewDir : TEXCOORD1; float3 worldReflect : TEXCOORD2; };这里我把反射向量放在顶点着色器里计算,因为对于大多数场景,顶点级别的反射方向插值到片元已经足够精确。如果你的模型面数很低但需要高精度反射,可以把反射计算移到片元着色器,代价是每个像素都要算一次。
顶点着色器的实现:
v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldViewDir = UnityWorldSpaceViewDir(worldPos); float3 worldReflect = reflect(-o.worldViewDir, o.worldNormal); o.worldReflect = worldReflect; return o; }注意reflect的第一个参数是-o.worldViewDir,因为UnityWorldSpaceViewDir返回的是从顶点指向摄像机的方向,而reflect需要的是入射方向,也就是从摄像机指向顶点的方向,所以取反。
3.3 片元着色器中的反射与折射计算
片元着色器是核心逻辑所在。我们需要计算折射向量、采样立方体贴图、计算菲涅尔系数、混合两种颜色。
fixed4 frag (v2f i) : SV_Target { float3 N = normalize(i.worldNormal); float3 V = normalize(i.worldViewDir); // 反射采样 float3 reflectDir = reflect(-V, N); fixed3 reflectColor = texCUBE(_CubeMap, reflectDir).rgb * _ReflectIntensity; // 折射采样 float3 refractDir = refract(-V, N, _RefractRatio); fixed3 refractColor = texCUBE(_CubeMap, refractDir).rgb; // 菲涅尔混合 float fresnel = pow(1.0 - saturate(dot(V, N)), _FresnelPower); fixed3 finalColor = lerp(refractColor, reflectColor, fresnel); finalColor *= _TintColor.rgb; return fixed4(finalColor, 1.0); }逐段解释一下。reflect(-V, N)得到反射方向,采样立方体贴图得到反射颜色,乘以_ReflectIntensity控制强度。refract(-V, N, _RefractRatio)得到折射方向,同样采样。菲涅尔项用dot(V, N)计算视线与法线的夹角余弦,1 - dot就是掠射程度的度量,再用pow放大边缘效果。最后用lerp混合,乘以色调。
提示:
refract函数在发生全反射时返回零向量,此时texCUBE采样会返回立方体贴图某个面的边缘颜色,可能产生视觉瑕疵。稳妥的做法是判断折射向量的长度,如果接近零就用反射颜色替代。
3.4 折射率参数的物理意义与调参经验
_RefractRatio这个参数直接对应物理上的折射率比值。下面这张表列出了常见材质的参考值:
| 材质 | 折射率 n | eta (n_air / n) |
|---|---|---|
| 空气 | 1.0 | 1.0 |
| 水 | 1.33 | 0.75 |
| 酒精 | 1.36 | 0.74 |
| 玻璃 | 1.5 | 0.67 |
| 水晶 | 1.54 | 0.65 |
| 钻石 | 2.42 | 0.41 |
实际调参时,我建议从 0.67 开始,这是玻璃的标准值。如果你想要更强烈的折射效果,可以把值调到 0.5 左右,但要注意这已经偏离了物理真实,属于艺术夸张。反过来,如果你想让物体看起来更像塑料而不是玻璃,可以把值调到 0.9 以上,折射会变得很微弱。
还有一个容易被忽略的点:eta 的值应该始终小于 1(假设光线从空气进入物体)。如果你把 eta 设成大于 1,refract函数计算出的方向会指向物体内部,采样结果会完全错误。我见过有人把 eta 设成 1.5,然后纳闷为什么折射看起来像反射,就是这个原因。
4. 实操过程中的常见问题与排查技巧
4.1 反射方向错误导致的画面撕裂
这是最常见的问题,表现为反射内容完全错位,或者物体表面出现奇怪的条纹。根本原因通常是方向向量没有归一化,或者reflect的入射方向搞反了。
排查步骤很简单:先把反射颜色直接输出,不混合折射,看看反射是否正确。如果反射是错的,检查三个地方——worldNormal是否归一化、worldViewDir是否归一化、reflect的第一个参数是否取了反。我个人的习惯是在片元着色器里对 N 和 V 都做一次normalize,虽然顶点着色器传过来的已经是归一化的,但插值之后可能会有微小偏差,再归一化一次更保险。
4.2 折射全反射导致的黑色区域
当入射角超过临界角时,refract返回零向量,采样立方体贴图会得到不可预期的结果。解决办法是在采样前判断:
float3 refractDir = refract(-V, N, _RefractRatio); if (length(refractDir) < 0.001) { refractDir = reflect(-V, N); }这样在全反射时自动退化为反射,视觉上更合理。临界角的大小由 eta 决定,eta 越小,临界角越小,越容易发生全反射。所以如果你把 eta 调得很小,发现大面积黑色,就是这个原因。
4.3 立方体贴图接缝处的可见缝隙
立方体贴图六个面之间的接缝在低分辨率下可能可见,尤其是当反射方向接近面的边界时。解决办法有两个:一是提高立方体贴图的分辨率,二是开启立方体贴图的无缝过滤。在 Unity 里,选中立方体贴图资源,在 Inspector 里把Generate Mip Maps打开,并把Filter Mode设为Trilinear,能明显改善接缝问题。
4.4 透明物体排序问题
透明物体的渲染顺序一直是老大难。如果场景里有多个透明物体互相重叠,可能会出现前面的物体被后面的遮挡,或者混合结果不对。根本原因是ZWrite Off之后深度缓冲不更新,GPU 只能按渲染队列的顺序绘制。
实用的缓解方案是:给不同的透明物体设置不同的Queue值,手动控制绘制顺序。比如水面设Queue = Transparent,玻璃设Queue = Transparent + 1,这样玻璃会在水面之后绘制。另外,如果物体是凸面体,可以尝试Cull Front先渲染背面再渲染正面,能改善一些排序问题。
4.5 性能优化:什么时候该用反射探针
如果你的场景里有大量使用反射材质的物体,每个物体都采样同一张立方体贴图,性能开销其实还好。但如果每个物体需要不同的环境反射,就需要用到反射探针(Reflection Probe)。反射探针会在场景中某个位置渲染一张立方体贴图,物体采样最近的探针。
使用反射探针时要注意:探针的Box Projection选项能让反射更贴合场景,但会增加一些计算量。另外,探针的分辨率不要设得太高,128 或 256 通常就够了,再高对视觉提升有限但内存占用翻倍。
下面这张表总结了我遇到过的典型问题及解决方案:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 反射错位 | 方向向量未归一化或取反错误 | 检查 normalize 和 reflect 参数 |
| 折射区域全黑 | 全反射导致零向量 | 判断长度并退化为反射 |
| 接缝可见 | 立方体贴图分辨率低 | 提高分辨率并开启无缝过滤 |
| 透明排序错误 | ZWrite Off 导致深度不更新 | 手动设置 Queue 值 |
| 边缘反射过强 | 菲涅尔 power 太小 | 增大 _FresnelPower |
| 整体太暗 | 立方体贴图本身偏暗 | 调整 _TintColor 或换贴图 |
5. 进阶扩展:从基础反射折射到更真实的效果
5.1 基于菲涅尔方程的精确混合
前面用的菲涅尔近似虽然简单,但在某些角度下不够准确。更精确的做法是使用 Schlick 近似:
F = F0 + (1 - F0) * pow(1 - dot(V, N), 5)其中 F0 是垂直入射时的反射率,对于玻璃大约是 0.04,对于金属则取决于材质。这个公式比简单的pow更符合物理规律,尤其是在垂直入射时也能保留少量反射,而不是完全消失。
实际使用时,可以把 F0 也做成一个可调参数,让美术根据材质类型调整。金属的 F0 可以设到 0.5 以上,非金属则保持在 0.04 左右。
5.2 色散效果:让折射产生彩虹边缘
真实世界中,不同波长的光折射率不同,这就是色散现象,也是棱镜产生彩虹的原因。在 Shader 里模拟色散,可以对 R、G、B 三个通道分别使用略微不同的 eta:
float3 refractR = refract(-V, N, _RefractRatio * 0.98); float3 refractG = refract(-V, N, _RefractRatio); float3 refractB = refract(-V, N, _RefractRatio * 1.02); fixed3 refractColor; refractColor.r = texCUBE(_CubeMap, refractR).r; refractColor.g = texCUBE(_CubeMap, refractG).g; refractColor.b = texCUBE(_CubeMap, refractB).b;这样每个通道的折射方向略有不同,叠加起来就会在边缘产生彩色的色散效果。代价是采样次数变成三次,性能开销增加。如果目标平台性能有限,可以只在高端画质下开启。
5.3 结合法线贴图模拟表面细节
基础的反射折射假设表面是光滑的,但真实物体表面往往有细微的凹凸。把法线贴图引入进来,可以让反射和折射产生扭曲,模拟出水面波纹、磨砂玻璃等效果。
做法是在片元着色器里采样法线贴图,把切线空间的法线转到世界空间,然后用这个扰动后的法线去计算反射和折射方向。这样反射内容会随着表面凹凸产生自然的扭曲,真实感大幅提升。
注意:法线贴图的扰动强度要适度,过强会导致反射内容完全破碎,看起来像噪声而不是材质细节。我通常把强度控制在 0.1 到 0.3 之间。
5.4 渲染纹理实现动态反射
立方体贴图是静态的,如果场景中有动态物体,反射内容不会更新。这时候可以用渲染纹理(Render Texture)配合一个额外的摄像机来实时捕捉场景,实现动态反射。代价是需要额外的渲染 Pass,性能开销较大,通常只用于水面、镜子等关键物体。
实现思路是:在物体位置放置一个摄像机,朝向反射方向,把渲染结果输出到 Render Texture,然后在 Shader 里采样这张纹理。要注意摄像机的裁剪平面和主摄像机保持一致,否则会出现反射内容缺失。
6. 我在实际项目中的几点体会
反射和折射这两个效果,看起来代码量不大,但真正调出好看的效果需要反复试验。我个人的经验是,不要一上来就追求物理精确,先把视觉效果调到位,再回头检查物理合理性。美术同学往往更关心“看起来像不像”,而不是“物理上对不对”。
另一个体会是,立方体贴图的质量直接决定了最终效果的上限。一张低动态范围、分辨率不足的环境贴图,再好的 Shader 也救不回来。如果项目预算允许,建议用 HDR 环境贴图,反射的高光部分会自然很多。
最后说一个容易被忽略的细节:反射和折射的混合比例不要只依赖菲涅尔,有时候手动加一个全局的反射强度控制,能让美术在特定场景下快速调整效果,而不需要改 Shader 代码。这个参数看似简单,但在实际协作中能省下不少沟通成本。