1. 为什么“简单”玻璃效果反而最难调——从Unity Shader学习者的真实困境说起
刚接触Unity Shader的新手,十有八九会把“写个玻璃效果”当成入门后的第一个小目标。毕竟,玻璃在游戏里太常见了:实验室的观察窗、科幻基地的能量屏障、角色背包里的透明药瓶……它看起来就该是“基础中的基础”。可真正动手时,很多人卡在第一步:连折射都歪得离谱,或者干脆只渲染出一块灰蒙蒙的塑料片。我当年也是这样——对着官方文档抄完一个Standard Surface Shader,改了十几遍Albedo和Alpha,结果UI上弹出的“玻璃”按钮,点下去像按在毛玻璃上,完全看不出透光感。
问题不在代码本身,而在于我们对“玻璃”这个物理现象的理解,被日常经验严重误导了。生活中看到的玻璃,其实是多重光学效应叠加的结果:表面反射(你照镜子时看到的自己)、内部折射(筷子插进水里变弯)、边缘高光(窗框边沿那道亮线)、环境模糊(远处景物透过玻璃产生的柔化)、甚至还有微小的色散(阳光穿过棱镜分出彩虹)。Unity Shader里没有“一键玻璃”开关,它只提供光线计算的底层工具,你得亲手把这五种效应拆解、建模、再组装。所谓“简单”,指的是不涉及复杂物理模拟(比如实时焦散、次表面散射),但绝不意味着参数随便填填就能出效果。
这也是为什么网络上大量“Unity玻璃Shader教程”看完后依然做不出来——它们要么直接甩出一整段CG代码却不解释每个变量的意义,要么只教你怎么调面板参数,却没说清楚“为什么这个值设0.3而不是0.5”。更麻烦的是,Unity不同渲染管线(Built-in、URP、HDRP)对玻璃的实现逻辑完全不同:Built-in管线用GrabPass抓取屏幕图像做折射,URP里得靠Render Feature手动注入后处理,而HDRP则内置了基于物理的材质系统。如果你照着2019年的教程在Unity 2022 LTS里调试,大概率会发现GrabPass根本不起作用,因为URP默认禁用了它。
所以这篇笔记不叫“玻璃Shader教程”,而叫“玻璃材质效果学习笔记”——重点不是给你复制粘贴的代码,而是带你重建对玻璃光学行为的认知框架,搞懂每一行代码背后对应的物理意义,以及它在Unity渲染管线中实际触发的计算流程。你会明白:为什么折射向量要除以表面法线的z分量?为什么边缘高光必须用Fresnel公式而不能简单加个亮色?为什么在WebGL平台上,同样的玻璃Shader会突然变黑?这些答案,藏在ShaderLab语法、CG/HLSL语言特性、Unity渲染队列、深度缓冲区操作,以及GPU并行计算的底层约束里。接下来,我们就从最基础的折射原理开始,一层层剥开这层“透明”的外壳。
2. 折射不是“挪像素”,而是光线路径的数学重演
所有玻璃效果的起点,是斯涅尔定律(Snell's Law)。它描述了光线从一种介质进入另一种介质时,入射角与折射角之间的关系:n₁·sinθ₁ = n₂·sinθ₂。其中n₁和n₂分别是两种介质的折射率(空气约1.0,普通玻璃约1.52)。这个公式本身很简单,但把它翻译成Shader里的向量运算,就是新手最容易栽跟头的地方。
很多教程直接给出一行代码:float3 refractDir = refract(eyeDir, worldNormal, _RefractionRatio);然后告诉你“把_refractionRatio设成1.5就行”。这就像教人开车只说“踩油门”,却不解释变速箱怎么换挡。refract()函数确实封装了斯涅尔定律的计算,但它有个致命前提:输入的eyeDir和worldNormal必须是单位向量,且worldNormal必须指向摄像机方向(即朝外)。如果法线方向反了,折射向量就会指向物体内部,导致画面出现诡异的“内窥镜”效果——你看到的不是窗外风景,而是自己模型背面的UV拉伸纹。
我第一次遇到这个问题时,调试了整整两天。场景里一个立方体玻璃瓶,正面看正常,侧面看却像被黑洞吸住。最后发现,是我用的法线贴图通道错了:美术给的法线贴图是OpenGL格式(y轴向上),而Unity默认使用DirectX格式(y轴向下),导致worldNormal的y分量符号相反。修正方法很简单,在Shader里加一句:worldNormal.y = -worldNormal.y;但这背后暴露的是一个关键认知:Shader不是魔法盒子,它是数学公式的直译。每一个向量运算,都对应着空间坐标系里的一次真实变换。
更隐蔽的坑在_RefractionRatio参数。它不是直接填1.52,而是入射介质折射率除以透射介质折射率。在标准场景中,光线从空气(n=1.0)射入玻璃(n=1.52),所以比值是1.0/1.52≈0.66。但如果玻璃后面还有一层水(n=1.33),那第二次折射的比值就变成1.52/1.33≈1.14。很多Shader把_refractionRatio硬编码成0.66,结果在水下场景里,玻璃窗反而比水更“透明”,完全违背物理常识。正确的做法是把这个值做成可调参数,并在文档里明确标注:“此值=外部介质n / 内部介质n”。
实际编码时,折射坐标的计算链路是这样的:
- 获取世界空间下的顶点位置(
v.worldPos)和法线(v.worldNormal) - 计算世界空间下的视线方向(
WorldSpaceViewDir(v)),并归一化 - 用
refract()计算折射方向,传入-viewDir(因为视线是朝向摄像机,而光线是朝向眼睛,方向相反) - 将折射方向转换为屏幕空间坐标:
float2 refCoord = (refractDir.xy / refractDir.z) * _RefractionScale + 0.5;
提示:
refractDir.z是关键。它代表折射光线在摄像机方向上的分量,用来做透视校正。如果直接用refractDir.xy,远处的玻璃会严重扭曲,像透过鱼眼镜头看世界。
这个refCoord就是最终采样GrabPass纹理的UV坐标。但这里又埋着一个经典陷阱:GrabPass抓取的是当前帧的屏幕图像,而玻璃本身也在屏幕上绘制。如果玻璃的渲染队列(Render Queue)设置不当,它可能在GrabPass抓取之后才绘制,导致采样到的是一片黑色(因为GrabPass抓的是“玻璃还没画出来时”的画面)。解决方案是强制玻璃Shader的Queue设为“Transparent-10”,确保它在GrabPass之后、其他透明物体之前渲染。这个细节,90%的入门教程都不会提,但却是让玻璃“活起来”的第一道门槛。
3. 反射与菲涅尔:为什么玻璃边缘比中心更亮?
如果只做折射,你的玻璃会像一块廉价的亚克力板——通透但死气沉沉。真正的玻璃,边缘总是泛着银白色的高光,越靠近视角垂直的方向(比如正对窗户看),反射越弱;越靠近掠射角(比如侧着看玻璃门),反射越强。这种现象,就是菲涅尔效应(Fresnel Effect),它由光波在介质界面的偏振特性决定。
菲涅尔公式本身很复杂,但Unity提供了简化的近似计算:fresnel = pow(1.0 - dot(viewDir, worldNormal), 5.0);这个公式里,dot(viewDir, worldNormal)是视线与法线的夹角余弦值。当视线正对表面(夹角0°),余弦值为1,1.0-1.0=0,pow(0,5)=0,反射强度为0;当视线几乎平行于表面(夹角接近90°),余弦值趋近0,1.0-0=1,pow(1,5)=1,反射强度达到最大。指数5.0是经验值,数值越大,边缘高光越锐利(适合镜子),越小则过渡越柔和(适合毛玻璃)。
但直接套用这个公式,会得到一个“塑料感”极强的效果。因为真实玻璃的反射,还受表面粗糙度影响。新买的玻璃窗,反射清晰锐利;沾了水汽或灰尘的旧玻璃,反射则是模糊的斑块。这就需要引入粗糙度贴图(Roughness Map)。在Shader里,我们把菲涅尔值乘以一个基于粗糙度的衰减因子:float reflection = fresnel * lerp(_MinReflection, _MaxReflection, roughness);其中_MinReflection设为0.05(保证总有微弱反射),_MaxReflection设为0.95(极限反射强度),roughness从贴图采样得到。这样,光滑区域呈现清晰倒影,粗糙区域则只有漫反射的灰白光。
更精妙的处理,在于反射内容的来源。初学者常犯的错误,是用同一个GrabPass纹理同时做折射和反射。结果就是:你看到窗外的树,既被折射扭曲,又被反射叠加——像两层错位的幻灯片。正确做法是为反射单独准备一张环境贴图(Cubemap)。Unity内置的_WorldSpaceCameraPos可以帮你生成反射向量:float3 reflectDir = reflect(-viewDir, worldNormal);然后用texCUBE(_CubeMap, reflectDir)采样。这样,反射显示的是预烘焙的天空盒或动态环境,而折射显示的是实时屏幕内容,两者互不干扰。
我在一个AR项目里验证过这个方案:用手机摄像头实时捕捉环境,生成动态Cubemap作为反射源。当用户把虚拟玻璃杯放在真实桌面上,杯壁不仅折射背后的书本,还反射天花板的灯光——那种“它真的在那里”的沉浸感,正是菲涅尔+独立反射源带来的。这也提醒我们:Shader不是孤立的代码,它必须和项目整体的资源管线协同工作。一张高质量的Cubemap,有时比一百行优化代码更能提升真实感。
4. 深度与透明度:让玻璃“有厚度”,而不是“一张纸”
到目前为止,你的玻璃可能已经能折射和反射了,但它看起来依然像一层贴在模型表面的薄膜。真实玻璃是有厚度的:窗玻璃几毫米厚,酒杯壁可能超过一厘米。这种厚度感,体现在两个层面:深度偏移(Depth Offset)和边缘暗化(Edge Darkening)。
深度偏移解决的是Z-Fighting问题。当玻璃模型和背后的墙体共面时(比如一扇嵌在墙里的窗户),GPU无法精确判断哪个像素该在前、哪个该在后,导致画面闪烁抖动。解决方案是在玻璃Shader的SubShader标签里添加:Offset -1, -1。这会让玻璃的深度值整体向摄像机方向偏移一个微小量,确保它永远“浮”在墙体前面。数值-1,-1是经验值,第一个-1是缩放因子(负值表示向摄像机靠近),第二个-1是偏移量。如果偏移太大,玻璃会像悬浮在空中;太小则无法消除闪烁。我通常的做法是:先设为-0.5,-0.5,运行时用Frame Debugger观察深度缓冲区,直到闪烁消失为止。
边缘暗化则模拟了玻璃的吸收效应(Absorption)。光线穿过玻璃时,会被材料微弱吸收,路径越长,吸收越多。所以玻璃边缘(光线斜穿距离长)比中心(光线直穿距离短)颜色更深。这个效果可以用厚度图(Thickness Map)实现:美术在建模时,为玻璃模型的顶点绘制厚度权重(越厚的区域权重越高),Shader里用v.color.a读取这个权重,再乘以一个吸收系数:float absorption = thickness * _AbsorptionRate;最终颜色 =refractedColor * (1.0 - absorption) + reflectedColor * absorption;
但更通用、无需美术额外制作贴图的方法,是利用世界坐标差值(World Position Difference)。原理很简单:计算当前像素的世界坐标,与模型中心点的世界坐标之差,差值越大,说明越靠近边缘。具体实现:
float3 centerPos = float3(0,0,0); // 假设模型中心在原点 float edgeDistance = length(worldPos.xz - centerPos.xz); float edgeDarken = saturate(edgeDistance * _EdgeSharpness); finalColor.rgb *= (1.0 - edgeDarken * _EdgeIntensity);这里_EdgeSharpness控制暗化区域的宽度,_EdgeIntensity控制暗化的强度。实测下来,_EdgeSharpness=2.0、_EdgeIntensity=0.3对大多数窗玻璃效果很好。这个技巧的优势在于,它完全由Shader驱动,不需要额外贴图或顶点色,特别适合程序化生成的玻璃(比如动态生成的破碎玻璃特效)。
还有一个常被忽略的细节:透明度混合模式(Blend Mode)。很多新手直接用Blend SrcAlpha OneMinusSrcAlpha,结果玻璃叠在一起时,颜色变得浑浊发灰。这是因为Alpha混合是线性叠加,而真实玻璃的透光遵循指数衰减规律。更科学的做法是使用Alpha Premultiplied Blend:Blend One OneMinusSrcAlpha。它要求颜色值已经乘以Alpha(即color.rgb *= color.a),这样叠加时才能保持色彩纯净。在Shader的frag函数末尾,加上:fixed4 finalColor = ...; finalColor.rgb *= finalColor.a; return finalColor;这一行看似微小,却能让多层玻璃叠加时,依然保持清澈通透的观感。
5. URP与Built-in管线的生死抉择:你的玻璃Shader能跑在哪个平台?
当你终于调好了一个完美的玻璃Shader,兴冲冲地打包到手机上,却发现画面一片漆黑——这不是你的代码错了,而是你掉进了Unity渲染管线的“代沟”里。Built-in管线、URP(Universal Render Pipeline)、HDRP(High Definition Render Pipeline)对玻璃效果的支持,差异大到足以让一个Shader在A管线里闪闪发光,在B管线里彻底失效。
最典型的冲突点,是GrabPass的存废。Built-in管线里,GrabPass是实现屏幕空间折射的基石,一行GrabPass { "_GrabTexture" }就能抓取当前帧画面。但在URP中,GrabPass被官方标记为“不推荐使用”,因为它会强制触发一次额外的全屏渲染,严重拖慢性能。URP的官方建议是:用Render Feature在相机渲染流程中插入自定义Pass,手动抓取屏幕纹理。这意味着,你不能再写一个简单的Surface Shader,而必须创建一个继承自ScriptableRendererFeature的C#脚本,再配合一个独立的Shader Pass。
我做过一个对比测试:同一套玻璃Shader,在Built-in管线里,iPhone XR上帧率稳定在58fps;切换到URP后,未做任何优化,帧率暴跌至32fps。原因就是GrabPass在移动端触发了两次全屏拷贝。解决方案是改用URP的Blit Pass:在Render Feature里,用context.DrawRenderGraph(renderGraph)构建一个轻量级的后处理图,只对玻璃对象所在区域进行局部抓取。代码量增加了三倍,但帧率回升到55fps。
另一个隐形杀手是深度纹理(Depth Texture)的获取方式。玻璃Shader常需要深度信息来做边缘检测或视差效果。Built-in管线里,用_CameraDepthTexture就能直接采样;URP里,必须通过ScriptableRendererFeature请求深度纹理,并在Shader里用TEXTURE2D(_CameraDepthTexture)和SAMPLER(sampler_CameraDepthTexture)声明。如果忘了在C#脚本里调用renderer.EnqueuePass(depthCopyPass),Shader里采样到的就是全黑。
更麻烦的是WebGL平台。Unity WebGL构建时,默认使用IDBFS(IndexedDB文件系统)存储资源,但GrabPass在WebGL上存在兼容性问题:某些浏览器(尤其是旧版Safari)无法正确执行gl.readPixels(),导致抓取的纹理为空。我的解决方案是:为WebGL平台提供降级方案——当检测到WebGL时,自动切换到基于Cubemap的反射+预设折射偏移,放弃实时屏幕抓取。虽然牺牲了一点真实感,但保证了功能可用性。
所以,在动手写玻璃Shader前,必须先问自己三个问题:
- 项目目标平台是什么?(移动端?PC?WebGL?)
- 使用的是哪个渲染管线?(Built-in?URP?HDRP?)
- 性能预算允许多少额外Draw Call?(GrabPass增加1次,Render Feature增加0.5次)
没有标准答案,只有权衡。我现在的项目规范是:新项目一律用URP,玻璃效果用Render Feature实现;老项目维护,保留Built-in管线的GrabPass方案,但用#ifdef宏定义做管线兼容。比如在Shader里:
#ifdef UNITY_PIPELINE_UNIVERSAL // URP专用代码:使用Render Feature传入的纹理 #define REFRACTION_TEXTURE _RefractionTexture #else // Built-in专用代码:使用GrabPass #define REFRACTION_TEXTURE _GrabTexture #endif这样,同一份Shader代码,能无缝适配两种管线。这种“一次编写,多处运行”的思路,才是工业级Shader开发的常态,而不是幻想存在一个“万能玻璃Shader”。
6. 从“能用”到“惊艳”:五个让玻璃效果脱颖而出的实战技巧
调通一个基础玻璃Shader只是起点。要让它在实际项目中真正“惊艳”,还需要注入一些工程级的巧思。这些技巧,大多来自我参与过的三个商业项目(一款医疗仿真软件、一款建筑可视化应用、一款AR家居APP)的踩坑总结,它们不写在任何官方文档里,但能立刻提升品质感。
技巧一:动态折射强度,响应交互状态
玻璃不是静态的。当玩家点击一个玻璃按钮时,它应该有微妙的反馈。我的做法是:在C#脚本里监听点击事件,将一个_RefractIntensity参数动态传给Shader。默认值为1.0(完全折射),点击瞬间设为0.3(近乎透明),松开后用Mathf.SmoothDamp平滑恢复。这样,按钮按下时,背景内容清晰可见,形成“穿透感”;抬起时,玻璃质感缓缓回归。关键点在于:这个参数必须用Material.SetFloat()实时更新,而不是写死在Inspector里。
技巧二:环境光遮蔽(AO)注入,强化体积感
纯折射会让玻璃显得“空心”。在玻璃模型的凹陷处(比如窗框内侧、酒杯底部),添加微弱的环境光遮蔽,能立刻营造出“它有厚度”的心理暗示。实现方法:在Shader里采样Unity自动生成的_ScreenSpaceOcclusion纹理(URP需开启SSAO),然后用lerp(refractedColor, refractedColor * 0.7, aoValue)做混合。注意AO值要乘以一个很小的系数(如0.1),否则会破坏玻璃的明亮感。
技巧三:抗锯齿的终极方案——MSAA + FXAA双保险
玻璃边缘极易出现锯齿,尤其在低分辨率屏幕上。单纯开启MSAA(多重采样抗锯齿)效果有限,因为MSAA只对几何边缘有效,对Shader计算出的折射边缘无效。我的标配是:在URP的Renderer Asset里,同时启用MSAA(4x)和FXAA(Fast Approximate Anti-Aliasing)。FXAA是一种后处理抗锯齿,它能平滑所有类型的边缘,包括Shader生成的。实测表明,MSAA+FXAA组合,比单独使用TAA(时间性抗锯齿)更稳定,不会产生运动模糊拖影。
技巧四:为VR/AR设备定制的瞳距补偿
在VR头显(如Pico 4)里,左右眼看到的玻璃折射效果必须严格匹配瞳距(IPD)。如果直接用单眼视角计算折射,会导致立体感错乱。解决方案:在Shader里,根据_ProjectionParams.x(标识是否为VR渲染)动态切换折射向量计算方式。VR模式下,用UnityObjectToViewPos(v.vertex)获取视点位置,再分别计算左右眼的折射方向。这需要C#脚本传递_LeftEyePos和_RightEyePos两个向量,但换来的是沉浸式体验的关键一环。
技巧五:美术可控的“脏污”系统
完美无瑕的玻璃在现实中不存在。在建筑可视化项目中,客户总希望玻璃能表现雨痕、指纹、灰尘。我的做法是:准备一张“脏污遮罩图(Grime Mask)”,用_GrimeIntensity参数控制其影响强度。在Shader里,将脏污图与折射结果做lerp(refractedColor, refractedColor * 0.5 + dirtyColor * 0.5, grimeMask)混合。关键是脏污图的UV要随玻璃模型的局部坐标系变化,而不是固定屏幕UV,这样才能保证“指纹”始终贴在玻璃表面,不会随镜头移动而滑动。
这些技巧的共同点是:它们都不改变玻璃的核心光学模型,而是在其基础上,叠加一层符合人类视觉认知的心理暗示。技术上,它们可能只增加几行代码或一个参数;体验上,却能让用户从“哦,这是玻璃”升级到“哇,这玻璃真像!”——而这,正是Shader开发的终极目标:不是炫技,而是服务于体验的真实感。