资讯动态

Unity Shader反射与折射:环境映射原理与实现

发布时间:2026/10/21 23:59:25 来源:尧图企业网站定制
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这个参数直接对应物理上的折射率比值。下面这张表列出了常见材质的参考值材质折射率 neta (n_air / n)空气1.01.0水1.330.75酒精1.360.74玻璃1.50.67水晶1.540.65钻石2.420.41实际调参时我建议从 0.67 开始这是玻璃的标准值。如果你想要更强烈的折射效果可以把值调到 0.5 左右但要注意这已经偏离了物理真实属于艺术夸张。反过来如果你想让物体看起来更像塑料而不是玻璃可以把值调到 0.9 以上折射会变得很微弱。还有一个容易被忽略的点eta 的值应该始终小于 1假设光线从空气进入物体。如果你把 eta 设成大于 1refract函数计算出的方向会指向物体内部采样结果会完全错误。我见过有人把 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 三个通道分别使用略微不同的 etafloat3 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 代码。这个参数看似简单但在实际协作中能省下不少沟通成本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑