水
水几乎用到了实时渲染的所有招数:深度图、不透明纹理、法线贴图、菲涅尔、环境反射、阴影、顶点动画,再加一台反射相机。本章从一块蓝色的半透明平面开始,一步一步把它变成一个完整的风格化水面。
SubmitRenderRequest,以及为什么不能在 beginCameraRendering 里提交请求19.1 一束光照到水面上会发生什么
动手之前先想清楚要模拟什么。看向一片水,你看到的颜色来自下面几条光路(图 19-1):
- 反射:天空、岸上的物体在水面上的倒影。反射有多强取决于视角——低头看脚下的水几乎看不到倒影,看远处的水面则像一面镜子,这是菲涅尔效应。
- 折射:水底的沙子、石头透过水面被看到,而且被水面的起伏弯折。
- 吸收与散射:光在水里走得越远,被吸收得越多,而且红光比蓝绿光吸收得快得多,所以深水发蓝绿;水中的微粒把光散射回来,给水体本身一个“颜色”。
- 高光:太阳在波光粼粼的水面上的镜面反射。
- 泡沫:浅滩、礁石、岸边的白色泡沫。
- 焦散:起伏的水面像无数个透镜,把阳光会聚到水底,形成游动的亮纹。
本章的做法是:先写一个包含所有功能的 Shader 框架 Water.shader,每一项特性对应一个 shader_feature_local 关键字;所有“积木”函数放在 WaterCore.hlsl 里。每个案例打开一个新关键字、讲清楚一个函数。最后全部打开,就是完整的水面。这样做的好处是你可以随时关掉某一项,看它到底贡献了什么——调水的时候这比什么都重要。
| 案例 | 关键字 / 函数 | 依赖 |
|---|---|---|
| 19.1 场景与最简单的水 | (无) | 透明队列 |
| 19.2 深度着色 | SampleWaterDepth | Depth Texture |
| 19.3 折射与吸收 | _REFRACTION、RefractUV、UnderwaterColor | Opaque Texture |
| 19.4 双法线流动 | _NORMALS、DualNormalTS | 可平铺法线贴图 |
| 19.5 菲涅尔与天空反射 | _REFLECTION、WaterFresnel | 天空/反射探针 |
| 19.6 太阳高光 | _SPECULAR、SunSpecular | 主光、阴影 |
| 19.7 岸边泡沫 | _FOAM、ShoreFoam | Depth Texture |
| 19.8 焦散 | _CAUSTICS、Caustics | Depth + Opaque |
| 19.9 Gerstner 波 | _GERSTNER、GerstnerWave | 细分网格 |
| 19.10 平面反射 | _PLANAR_REFLECTION、PlanarReflection.cs | 反射相机 + RT |
案例 19.1搭场景:一个有深有浅的水池入门单独打开 ↗
目标:准备一个能“考验”水面的场景,并放上最朴素的水:一块单色半透明平面。
水的效果几乎全部依赖“水下有什么”。一块水面放在空无一物的平地上,深度着色、折射、泡沫、焦散都无从体现。所以我们先搭一个小水池:地形是一张程序生成的高度场网格,中间深约 1.7 米,左后方是缓坡沙滩;水里有几块石头(一块完全淹没、顶部离水面很近,一块露出水面),三根从水底伸出水面的石柱,以及一座横跨在水面上方的木栈道。沙子用棋盘格纹理——规则的格子最能看出折射的扭曲和焦散的形状。
水面本身是一张 60 米见方的细分网格(Shot.Grid),放在 y=0。它被放在内置的 Water 层(第 4 层),后面平面反射的相机会排除这一层。
Shader 的框架如下(顶点、片元函数在后面的案例里逐段讲解):
// 第 19 章:完整的风格化水面。每个特性一个关键字,正文按案例逐个打开。 // 依赖:URP Asset 开启 Depth Texture 与 Opaque Texture;水面放在 Water 层(平面反射相机不渲染它)。 Shader "Cookbook/Ch19/Water" { Properties { [Header(Depth Color)] _ShallowColor ("Shallow Color (A=alpha)", Color) = (0.35,0.85,0.8,0.35) _DeepColor ("Deep / Scatter Color (A=alpha)", Color) = (0.03,0.22,0.35,0.95) _DepthMax ("Depth For Deep Color (m)", Float) = 2 _Absorption ("Absorption per meter (RGB)", Vector) = (0.9,0.32,0.22,0) _EdgeFade ("Shore Edge Fade (m)", Float) = 0.08 [Header(Refraction)] [Toggle(_REFRACTION)] _Refraction ("Refraction", Float) = 1 _RefractionStrength ("Refraction Strength", Range(0,0.2)) = 0.05 [Toggle] _RefractLeakFix ("Fix Foreground Leak", Float) = 1 [Header(Normals)] [Toggle(_NORMALS)] _Normals ("Dual Normals", Float) = 1 [NoScaleOffset][Normal] _NormalMap ("Normal Map", 2D) = "bump" {} _NormalTiling ("Tiling A/B (per meter)", Vector) = (0.18,0.31,0,0) _FlowA ("Flow A (xy dir*speed, z strength)", Vector) = (0.03,0.02,0.6,0) _FlowB ("Flow B (xy dir*speed, z strength)", Vector) = (-0.025,0.035,0.45,0) [Header(Reflection)] [Toggle(_REFLECTION)] _Reflection ("Reflection", Float) = 1 [Toggle(_PLANAR_REFLECTION)] _PlanarReflection ("Use Planar Reflection Texture", Float) = 0 _ReflectionStrength ("Reflection Strength", Range(0,1)) = 1 [Toggle] _UseFresnel ("Use Fresnel", Float) = 1 _Roughness ("Reflection Roughness", Range(0,1)) = 0.05 _PlanarDistortion ("Planar Reflection Distortion", Range(0,0.2)) = 0.04 [Header(Specular)] [Toggle(_SPECULAR)] _Specular ("Sun Specular", Float) = 1 _SpecPower ("Spec Power", Range(16,2048)) = 600 _SpecIntensity ("Spec Intensity", Range(0,4)) = 1 [Header(Foam)] [Toggle(_FOAM)] _Foam ("Shore Foam", Float) = 1 [NoScaleOffset] _FoamNoise ("Foam Noise", 2D) = "gray" {} _FoamColor ("Foam Color", Color) = (0.95,0.98,1,1) _FoamDistance ("Foam Distance (m)", Float) = 0.6 _FoamTiling ("Foam Tiling (per meter)", Float) = 0.35 _FoamSoftness ("Foam Softness", Range(0.001,0.3)) = 0.03 [Header(Caustics)] [Toggle(_CAUSTICS)] _Caustics ("Caustics", Float) = 1 _CausticScale ("Caustic Scale (cells per meter)", Float) = 1.6 _CausticIntensity ("Caustic Intensity", Range(0,4)) = 1.2 _CausticDepthFade ("Caustic Depth Fade (m)", Float) = 1.5 _CausticSplit ("Caustic RGB Split", Range(0,0.1)) = 0.025 [Header(Gerstner Waves)] [Toggle(_GERSTNER)] _Gerstner ("Gerstner Waves", Float) = 0 _WaveA ("Wave A (dir, steepness, wavelength)", Vector) = (1,0.3,0.18,7) _WaveB ("Wave B", Vector) = (0.4,1,0.15,4.3) _WaveC ("Wave C", Vector) = (-0.7,0.6,0.12,2.6) [Header(Time and Debug)] [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0 _Phase ("Phase (seconds)", Float) = 0 [Enum(Off,0,VerticalDepth,1,ViewPath,2,Normal,3,Foam,4,Caustics,5,Fresnel,6)] _DebugView ("Debug View", Float) = 0 } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent-100" "RenderPipeline"="UniversalPipeline" } Pass { Name "Water" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local _REFRACTION #pragma shader_feature_local _NORMALS #pragma shader_feature_local_fragment _REFLECTION #pragma shader_feature_local_fragment _PLANAR_REFLECTION #pragma shader_feature_local_fragment _SPECULAR #pragma shader_feature_local_fragment _FOAM #pragma shader_feature_local_fragment _CAUSTICS #pragma shader_feature_local_vertex _GERSTNER #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile _ _FORWARD_PLUS #pragma multi_compile_fog #include "Assets/Cookbook/Ch19/WaterCore.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; half3 normalWS : TEXCOORD1; half3 tangentWS : TEXCOORD2; half3 bitangentWS : TEXCOORD3; half fogFactor : TEXCOORD4; }; Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y float3 tangent = float3(1, 0, 0); float3 binormal = float3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); float3 p = positionWS; positionWS += GerstnerWave(_WaveA, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveB, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif o.positionWS = positionWS; o.positionCS = TransformWorldToHClip(positionWS); o.tangentWS = normalize(tangent); o.bitangentWS = normalize(binormal); o.normalWS = normalize(cross(binormal, tangent)); o.fogFactor = ComputeFogFactor(o.positionCS.z); return o; } half4 frag(Varyings i) : SV_Target { float t = WaterTime(); float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS); half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS); float surfaceEye = LinearEyeDepth(i.positionWS, UNITY_MATRIX_V); Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS)); // ---- 法线 ---- half3 normalTS = half3(0, 0, 1); #if defined(_NORMALS) normalTS = DualNormalTS(i.positionWS.xz, t); #endif half3x3 tbn = half3x3(i.tangentWS, i.bitangentWS, i.normalWS); half3 normalWS = normalize(mul(normalTS, tbn)); // ---- 深度 ---- WaterDepth wd = SampleWaterDepth(screenUV, i.positionWS); // 水体自身被照亮的颜色(散射色):主光 + 环境光 half3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation + SampleSH(half3(0, 1, 0)); half3 color; half alpha; #if defined(_REFRACTION) // ---- 折射 + 吸收(不透明合成:自己把“水下画面”算出来,alpha = 1)---- float2 uvR = RefractUV(screenUV, normalTS, surfaceEye, wd.vertical); WaterDepth wdR = SampleWaterDepth(uvR, i.positionWS); half3 sceneColor = SampleSceneColor(uvR); #if defined(_CAUSTICS) sceneColor += Caustics(wdR.sceneWS, wdR.vertical, SceneUpFacing(wd.sceneWS), mainLight, t); #endif color = UnderwaterColor(sceneColor, wdR.path, _DeepColor.rgb * waterLight); // 岸线处和未扰动的画面过渡,避免水面边缘出现一条硬线 color = lerp(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade)); alpha = 1; #else // ---- 最简单的版本:按竖直水深在浅色和深色之间插值,靠 alpha 混合透出水底 ---- half d01 = saturate(wd.vertical / _DepthMax); color = lerp(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight; alpha = lerp(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade); #endif // ---- 反射 ---- half fresnel = _UseFresnel > 0.5 ? WaterFresnel(normalWS, viewWS) : 1.0h; #if defined(_REFLECTION) #if defined(_PLANAR_REFLECTION) half3 reflection = SAMPLE_TEXTURE2D(_PlanarReflectionTex, sampler_PlanarReflectionTex, screenUV + normalTS.xy * _PlanarDistortion).rgb; #else half3 reflectDir = reflect(-viewWS, normalWS); // Forward+ 下只有带屏幕 UV 的 5 参数版本(反射探针按屏幕分块查找),4 参数版本被 #if 掉了 half3 reflection = GlossyEnvironmentReflection(reflectDir, i.positionWS, _Roughness, 1.0h, screenUV); #endif half kr = fresnel * _ReflectionStrength; color = lerp(color, reflection, kr); alpha = lerp(alpha, 1.0h, kr); #endif // ---- 高光 ---- #if defined(_SPECULAR) color += SunSpecular(mainLight, normalWS, viewWS); #endif // ---- 泡沫 ---- half foam = 0; #if defined(_FOAM) foam = ShoreFoam(i.positionWS.xz, wd.vertical, normalTS, t); color = lerp(color, _FoamColor.rgb * waterLight, foam); alpha = max(alpha, foam); #endif // ---- 调试视图:把中间量直接画出来 ---- if (_DebugView > 0.5) { half3 dbg = 0; if (_DebugView < 1.5) dbg = saturate(wd.vertical / _DepthMax); else if (_DebugView < 2.5) dbg = saturate(wd.path / _DepthMax); else if (_DebugView < 3.5) dbg = half3(normalTS.xy * 1.5h + 0.5h, 0.5h); // 放大切线空间 xy,看清纹理 else if (_DebugView < 4.5) dbg = foam; else if (_DebugView < 5.5) dbg = CausticPattern(wd.sceneWS.xz * _CausticScale, t) * 0.5; else dbg = fresnel; return half4(dbg, 1); } color = MixFog(color, i.fogFactor); return half4(color, alpha); } ENDHLSL } } }
// 第 19 章:完整的风格化水面。每个特性一个关键字,正文按案例逐个打开。(GLSL 版,对应 Ch19/Water.shader) // 依赖:URP Asset 开启 Depth Texture 与 Opaque Texture;水面放在 Water 层(平面反射相机不渲染它)。 Shader "Cookbook/Ch19/Water" { SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent-100" "RenderPipeline"="UniversalPipeline" } Pass { Name "Water" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back GLSLPROGRAM #pragma shader_feature_local _REFRACTION #pragma shader_feature_local _NORMALS #pragma shader_feature_local_fragment _REFLECTION #pragma shader_feature_local_fragment _PLANAR_REFLECTION #pragma shader_feature_local_fragment _SPECULAR #pragma shader_feature_local_fragment _FOAM #pragma shader_feature_local_fragment _CAUSTICS #pragma shader_feature_local_vertex _GERSTNER #pragma multi_compile_fog #include "Ch19/WaterCore.glsl" VARYING vec3 positionWS; VARYING vec3 normalWS; VARYING vec3 tangentWS; VARYING vec3 bitangentWS; VARYING float fogFactor; #ifdef VERTEX void main() { vec3 pWS = TransformObjectToWorld(positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y vec3 tangent = vec3(1, 0, 0); vec3 binormal = vec3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); vec3 p = pWS; pWS += GerstnerWave(_WaveA, p, t, tangent, binormal); pWS += GerstnerWave(_WaveB, p, t, tangent, binormal); pWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif positionWS = pWS; gl_Position = TransformWorldToHClip(pWS); tangentWS = normalize(tangent); bitangentWS = normalize(binormal); normalWS = normalize(cross(binormal, tangent)); fogFactor = ComputeFogFactor(gl_Position.z); } #endif #ifdef FRAGMENT void main() { float t = WaterTime(); vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord); vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS); float surfaceEye = LinearEyeDepth(positionWS, UNITY_MATRIX_V); Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS)); // ---- 法线 ---- vec3 normalTS = vec3(0, 0, 1); #if defined(_NORMALS) normalTS = DualNormalTS(positionWS.xz, t); #endif // HLSL 的 half3x3(T, B, N) 按行构造、mul(normalTS, tbn);GLSL 的 mat3(T, B, N) 按列,写成 M * v 结果相同 mat3 tbn = mat3(tangentWS, bitangentWS, normalWS); vec3 N = normalize(tbn * normalTS); // ---- 深度 ---- WaterDepth wd = SampleWaterDepth(screenUV, positionWS); // 水体自身被照亮的颜色(散射色):主光 + 环境光 vec3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation + SampleSH(vec3(0, 1, 0)); vec3 color; float alpha; #if defined(_REFRACTION) // ---- 折射 + 吸收(不透明合成:自己把“水下画面”算出来,alpha = 1)---- vec2 uvR = RefractUV(screenUV, normalTS, surfaceEye, wd.vertical); WaterDepth wdR = SampleWaterDepth(uvR, positionWS); vec3 sceneColor = SampleSceneColor(uvR); #if defined(_CAUSTICS) sceneColor += Caustics(wdR.sceneWS, wdR.vertical, SceneUpFacing(wd.sceneWS), mainLight, t); #endif color = UnderwaterColor(sceneColor, wdR.path, _DeepColor.rgb * waterLight); // 岸线处和未扰动的画面过渡,避免水面边缘出现一条硬线 color = mix(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade)); alpha = 1.0; #else // ---- 最简单的版本:按竖直水深在浅色和深色之间插值,靠 alpha 混合透出水底 ---- float d01 = saturate(wd.vertical / _DepthMax); color = mix(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight; alpha = mix(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade); #endif // ---- 反射 ---- float fresnel = _UseFresnel > 0.5 ? WaterFresnel(N, viewWS) : 1.0; #if defined(_REFLECTION) #if defined(_PLANAR_REFLECTION) vec3 reflection = texture(_PlanarReflectionTex, screenUV + normalTS.xy * _PlanarDistortion).rgb; #else vec3 reflectDir = reflect(-viewWS, N); // HLSL 版用 Forward+ 的 5 参数版本(带屏幕 UV);网页版没有反射探针分块,用 4 参数版本 vec3 reflection = GlossyEnvironmentReflection(reflectDir, positionWS, _Roughness, 1.0); #endif float kr = fresnel * _ReflectionStrength; color = mix(color, reflection, kr); alpha = mix(alpha, 1.0, kr); #endif // ---- 高光 ---- #if defined(_SPECULAR) color += SunSpecular(mainLight, N, viewWS); #endif // ---- 泡沫 ---- float foam = 0.0; #if defined(_FOAM) foam = ShoreFoam(positionWS.xz, wd.vertical, normalTS, t); color = mix(color, _FoamColor.rgb * waterLight, foam); alpha = max(alpha, foam); #endif // ---- 调试视图:把中间量直接画出来 ---- if (_DebugView > 0.5) { vec3 dbg = vec3(0.0); if (_DebugView < 1.5) dbg = vec3(saturate(wd.vertical / _DepthMax)); else if (_DebugView < 2.5) dbg = vec3(saturate(wd.path / _DepthMax)); else if (_DebugView < 3.5) dbg = vec3(normalTS.xy * 1.5 + 0.5, 0.5); // 放大切线空间 xy,看清纹理 else if (_DebugView < 4.5) dbg = vec3(foam); else if (_DebugView < 5.5) dbg = vec3(CausticPattern(wd.sceneWS.xz * _CausticScale, t) * 0.5); else dbg = vec3(fresnel); fragColor = vec4(dbg, 1.0); return; } color = MixFog(color, fogFactor); fragColor = vec4(color, alpha); } #endif ENDGLSL } } }
先看渲染状态:"Queue"="Transparent-100",比普通透明物体早一点画,这样水面之后再画的粒子、玻璃会正确地叠在水面上;Blend SrcAlpha OneMinusSrcAlpha 标准 Alpha 混合;ZWrite Off 不写深度。水面不需要 ShadowCaster(它不该在水底投下影子),也不需要 DepthOnly(它不能出现在深度图里,否则后面读深度图时读到的就是水面自己)。
关闭所有关键字时,片元走最简单的分支:颜色和 alpha 都是常数。

案例 19.2深度着色:深的地方更深入门单独打开 ↗
目标:让水的颜色和透明度随水深变化:浅处清澈泛青,深处浓重发蓝;岸边水深趋于 0 时平滑过渡,不出现硬边。
原理:从深度图重建水底
水面在透明队列里绘制时,深度图 _CameraDepthTexture 里存着水底(以及水中所有不透明物体)的深度。在水面的每个像素上,我们用同一个屏幕 UV 去采样深度图,就得到“透过这个像素看下去,水底在哪里”。有了原始深度,可以用 URP Core 库里的 ComputeWorldSpacePosition( 把它还原成世界坐标——这个函数用逆视图投影矩阵把 NDC 坐标变换回世界空间(第 1 章、第 28 章)。
有了水底的世界坐标,就能定义两种“水深”:
- 竖直水深:水面 y − 水底 y。它是水体本身的属性,与从哪个角度看无关,适合用来决定“这里是浅滩还是深潭”——比如水的颜色、岸边泡沫。
- 视线路径长度:从水面上这一点沿视线到水底的距离。斜着看时,视线在水里走得更长,被吸收得更多——这正是为什么远处的浅水看起来也比脚下的深。它适合用来计算吸收。
严格地说,“竖直水深”应该取水面这一点正下方的水底高度,但我们只能拿到沿视线方向的那个水底点。代码里用的是“水面 y − 重建点 y”:水底起伏平缓时二者很接近,而且计算极其便宜。这是实时水面普遍采用的近似。
// 水下深度:用水面这个像素的屏幕 UV 取场景深度,重建出水底的世界坐标 struct WaterDepth { float3 sceneWS; // 水底(或水中物体)表面的世界坐标 float vertical; // 竖直水深 = 水面 y - 水底 y(与视角无关) float path; // 视线在水里走过的距离(与视角有关,斜看时更长) float sceneEye; // 场景的观察空间深度 }; WaterDepth SampleWaterDepth(float2 uv, float3 surfaceWS) { WaterDepth o; float raw = SampleSceneDepth(uv); o.sceneWS = ComputeWorldSpacePosition(uv, raw, UNITY_MATRIX_I_VP); o.vertical = surfaceWS.y - o.sceneWS.y; o.path = distance(o.sceneWS, surfaceWS); o.sceneEye = LinearEyeDepth(raw, _ZBufferParams); return o; }
// 水下深度:用水面这个像素的屏幕 UV 取场景深度,重建出水底的世界坐标 struct WaterDepth { vec3 sceneWS; // 水底(或水中物体)表面的世界坐标 float vertical; // 竖直水深 = 水面 y - 水底 y(与视角无关) float path; // 视线在水里走过的距离(与视角有关,斜看时更长) float sceneEye; // 场景的观察空间深度 }; WaterDepth SampleWaterDepth(vec2 uv, vec3 surfaceWS) { WaterDepth o; float raw = SampleSceneDepth(uv); o.sceneWS = ComputeWorldSpacePosition(uv, raw, UNITY_MATRIX_I_VP); o.vertical = surfaceWS.y - o.sceneWS.y; o.path = distance(o.sceneWS, surfaceWS); o.sceneEye = LinearEyeDepth(raw, _ZBufferParams); return o; }
深度着色本身很简单:竖直水深除以一个“完全变深”的深度 _DepthMax,在浅色和深色之间插值,透明度也同样插值;再乘一个 saturate(,水深趋于 0 时 alpha 也趋于 0,岸线就从硬边变成了柔和的过渡。颜色还乘了一个 waterLight(主光 × 阴影 + 环境光),让水体在柱子的阴影里也会变暗。
对应的代码在上面完整 Shader 片元函数的“深度”部分和 #else 分支(没有 _REFRACTION 时)里:
WaterDepth wd = SampleWaterDepth(screenUV, i.positionWS); half3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation + SampleSH(half3(0, 1, 0)); // …… half d01 = saturate(wd.vertical / _DepthMax); color = lerp(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight; alpha = lerp(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade);
片元函数的其余部分会在后面的案例里逐段展开。

案例 19.3折射与吸收进阶单独打开 ↗
目标:水底的画面被水面的起伏弯折;光在水里按颜色被吸收,深处偏蓝绿;同时解决折射最常见的瑕疵——水面上方的物体“漏”进水里。
不再依赖 Alpha 混合
案例 19.2 的水靠 Alpha 混合透出水底:GPU 把水的颜色和已有画面按比例混合。要做折射,我们需要读取“旁边一点”的水底画面,Alpha 混合做不到。所以从这一步开始换一种思路:自己把水下画面算出来,输出 alpha = 1。水下画面从 _CameraOpaqueTexture 读取(第 18 章案例 18.8 讲过它的拷贝时机),采样位置按法线偏移。
折射偏移量用法线的切线空间 xy 分量:平静水面法线是 (0,0,1),没有偏移;起伏处 xy 越大偏移越大。这一步需要法线贴图,所以配图里提前打开了 _NORMALS(下一个案例讲它),暂时只用一层。
两个让折射更可信的细节
第一,越浅扰动越小。岸边几厘米深的水不应该把岸上的沙子扭得很厉害,否则岸线会抖成锯齿。偏移量乘 saturate(水深 × 2),半米以内逐渐减弱。
第二,远处的偏移要缩小。偏移是在屏幕空间加的,同样 0.05 的屏幕偏移,对近处的水只是几厘米,对远处的水可能是好几米。除以水面的观察深度做一个近似的透视补偿,远近的扭曲幅度看起来就一致了。
前景泄漏
屏幕空间折射有一个固有缺陷:偏移后的 UV 可能落在水面上方的物体上。图 19-5 里,水面像素 A 的折射 UV 偏到了 B,而 B 处的不透明纹理里是一个悬在水面上方的箱子——于是箱子的橙色出现在了水里。修正方法是在偏移后的位置再采样一次深度:如果那里的场景深度比水面本身还近,说明采到的是前景,就放弃偏移,退回原来的 UV。
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV float2 RefractUV(float2 screenUV, half3 normalTS, float surfaceEye, float verticalDepth) { // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害 float2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0); // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿 offset /= max(1.0, surfaceEye * 0.25); float2 uv = screenUV + offset; if (_RefractLeakFix > 0.5) { float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); if (eye < surfaceEye) uv = screenUV; } return uv; }
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV vec2 RefractUV(vec2 screenUV, vec3 normalTS, float surfaceEye, float verticalDepth) { // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害 vec2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0); // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿 offset /= max(1.0, surfaceEye * 0.25); vec2 uv = screenUV + offset; if (_RefractLeakFix > 0.5) { float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); if (eye < surfaceEye) uv = screenUV; } return uv; }
这个修正不是完美的:被拒绝的像素退回原 UV,失去了扭曲,在箱子的轮廓附近会出现一小圈“不扭曲”的区域。但比起前景颜色漏进水里,这点瑕疵几乎察觉不到。
Beer-Lambert 吸收
光在均匀介质中传播距离 d 后,剩下的比例(透射率)是
σ 是吸收系数,单位是“每米”。水对红光的吸收系数远大于蓝绿光,所以 σ 按 RGB 分别给(本例默认 (0.9, 0.32, 0.22),这是为了风格化效果调出来的数值,不是实测数据)。被吸收掉的那部分光并不是变成黑色——水中的微粒会把环境光散射回来,所以用“散射色”(水体被照亮的颜色)补上:
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。 // 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色) half3 UnderwaterColor(half3 sceneColor, float pathLength, half3 scatterColor) { half3 transmittance = exp(-_Absorption.rgb * pathLength); return sceneColor * transmittance + scatterColor * (1.0h - transmittance); }
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。 // 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色) vec3 UnderwaterColor(vec3 sceneColor, float pathLength, vec3 scatterColor) { vec3 transmittance = exp(-_Absorption.rgb * pathLength); return sceneColor * transmittance + scatterColor * (1.0 - transmittance); }
这里的 d 用的是视线路径长度,而不是竖直水深:从远处斜看浅水,视线在水里走得长,水的颜色会更浓——和真实世界一致。由于三个通道衰减速度不同,浅水几乎无色透明、稍深一点泛青绿、再深变成蓝色,这个颜色渐变是 Beer-Lambert 自动给出的,不需要手调渐变色。

折射分支里还有一行:color = lerp(。水深趋近 0 时,最终颜色过渡到“没有水”的原始画面,代替了案例 19.2 用 alpha 做的岸线柔化。
案例 19.4双法线流动进阶单独打开 ↗
目标:用法线贴图给水面加上细碎的波纹,并让波纹“流动”起来,同时避免一眼就能看出的重复平铺。
原理:两层不同尺度、不同方向的法线
一张法线贴图平铺在大面积水面上,重复感非常明显;让它沿一个方向滚动,看起来又像整片水面在平移,而不是在波动。经典做法是用同一张法线贴图采样两次:两次的平铺尺度不同(比如每米 0.18 次和 0.31 次,比例不是整数,两层的重复周期错开),流动方向和速度也不同。两层叠加后,任何一处的图案都在不断变化,重复与平移感都被打破了。
两层法线的混合用 Whiteout 方法:xy 相加、z 相乘再归一化。第 7 章对比过几种细节法线混合方法,Whiteout 比简单平均保留更多起伏,计算又比 RNM 便宜。
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感 half3 DualNormalTS(float2 xz, float t) { float2 uvA = xz * _NormalTiling.x + _FlowA.xy * t; float2 uvB = xz * _NormalTiling.y + _FlowB.xy * t; half3 nA = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvA), _FlowA.z); half3 nB = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvB), _FlowB.z); // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比) return normalize(half3(nA.xy + nB.xy, nA.z * nB.z)); }
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感 vec3 DualNormalTS(vec2 xz, float t) { vec2 uvA = xz * _NormalTiling.x + _FlowA.xy * t; vec2 uvB = xz * _NormalTiling.y + _FlowB.xy * t; vec3 nA = UnpackNormalScale(texture(_NormalMap, uvA), _FlowA.z); vec3 nB = UnpackNormalScale(texture(_NormalMap, uvB), _FlowB.z); // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比) return normalize(vec3(nA.xy + nB.xy, nA.z * nB.z)); }
采样坐标直接用世界坐标 xz,而不是网格 UV:这样水面的波纹尺度以“米”为单位,水面网格放大缩小都不会改变波纹大小,多块水面拼在一起也天然连续。
切线空间
平静水面的切线空间很简单:切线 +X、副切线 +Z、法线 +Y。顶点着色器把这三个向量传给片元,片元用 mul( 变换到世界空间。之所以不直接写死,是因为加入 Gerstner 波(案例 19.9)后,T、B、N 会随波面倾斜,到时只需要在顶点着色器里改它们。
法线贴图必须能无缝平铺
大面积平铺时,法线贴图左右、上下边界上任何一点不连续,都会在水面上形成一条条笔直的细缝,在高光和反射里格外刺眼。一个典型的坑是:用若干正弦波叠加高度场时,如果某个波在贴图周期内走的不是整数圈,边界处就会断开,求导后边缘一圈像素的法线明显错误。本章用 C# 程序生成一张可平铺的水面法线(配套的公共贴图 water_normal 也按同样的规则生成),顺便讲清楚“可平铺”的三个条件:
public static Texture2D Create(int size = 256, float strength = 3.5f) { if (s_Cached != null) return s_Cached; var h = new float[size, size]; // 整数波矢 (kx, ky):在一个贴图周期里正好走完整数个波长,左右/上下边界自然衔接。 // 方向大致集中在“风向”附近,看起来才像被风吹出来的水纹 int[,] k = { { 3, 1 }, { 2, 3 }, { 4, -1 }, { 5, 2 }, { 3, 5 }, { 6, -3 }, { 7, 4 }, { -2, 6 }, { 9, 2 }, { 8, 7 }, { 11, -4 }, { 5, 10 }, { 13, 3 }, { -7, 11 } }; var rnd = new System.Random(19); var phase = new float[k.GetLength(0)]; for (int i = 0; i < phase.Length; i++) phase[i] = (float)rnd.NextDouble() * Mathf.PI * 2; for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { float u = (float)x / size, v = (float)y / size, sum = 0; // 域扭曲:用周期噪声扰动相位,打破正弦叠加的“格子布”感(噪声是周期的,所以仍然无缝) float warp = PeriodicNoise(u, v, 4, 7) * 2.2f + PeriodicNoise(u, v, 8, 8) * 1.1f; for (int i = 0; i < phase.Length; i++) { float kx = k[i, 0], ky = k[i, 1]; float amp = 1f / Mathf.Sqrt(kx * kx + ky * ky); sum += amp * Mathf.Sin(2 * Mathf.PI * (kx * u + ky * v) + phase[i] + warp); } // 再叠几层周期性值噪声(fBm) sum += 0.3f * PeriodicNoise(u, v, 8, 1) + 0.15f * PeriodicNoise(u, v, 16, 2) + 0.08f * PeriodicNoise(u, v, 32, 3); h[x, y] = sum; } var px = new Color[size * size]; for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { // 中心差分,坐标按周期回绕 float dx = (h[(x + 1) % size, y] - h[(x - 1 + size) % size, y]) * 0.5f * strength; float dy = (h[x, (y + 1) % size] - h[x, (y - 1 + size) % size]) * 0.5f * strength; var n = new Vector3(-dx, -dy, 1).normalized; // R=x、G=y、B=z、A=1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG: // 先 a *= r 再取 (a, g),A=1 时得到的正好是 (r, g),所以这种未压缩的 RGBA 也能正确解码 px[y * size + x] = new Color(n.x * 0.5f + 0.5f, n.y * 0.5f + 0.5f, n.z * 0.5f + 0.5f, 1); } s_Cached = new Texture2D(size, size, TextureFormat.RGBA32, true, true) { name = "WaterNormal_Tileable", wrapMode = TextureWrapMode.Repeat, filterMode = FilterMode.Trilinear, anisoLevel = 4, hideFlags = HideFlags.DontSave }; s_Cached.SetPixels(px); s_Cached.Apply(true); return s_Cached; }
- 每个正弦波的波矢 (kx, ky) 都是整数,在一个贴图周期内正好走完整数个波长;
- 值噪声的格点索引对周期取模,u=0 与 u=1 取到同一组格点;
- 用中心差分求导时,坐标回绕(x+1 超出边界就回到 0)。
此外还用了一个技巧:用周期噪声扰动正弦的相位(域扭曲),否则十几个正弦叠加会呈现规则的“格子布”纹理。生成的贴图是未压缩的 RGBA,R、G、B 存法线的 xyz,A 存 1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG:先 a *= r,再取 (a, g) 作为 xy——A=1 时得到的正好是 (r, g);在定义了 UNITY_NO_DXT5nm 的平台上则走 UnpackNormalRGB 直接读 RGB,同样正确。所以这种贴图不用标记为 Normal Map 也能正确解码。如果你用的是导入的法线贴图,正常勾选 Texture Type = Normal Map 即可。

案例 19.5菲涅尔与天空反射进阶单独打开 ↗
目标:水面反射天空;低头看时几乎透明,看向远处时像镜子。
水的 F0
第 9 章讲过,电介质在垂直入射时的反射率 F0 由折射率决定:
水的折射率约 1.33,F0 只有 2%:正对着看,98% 的光都透进水里,所以脚下的水清澈见底。随着视线变平,Schlick 近似 F = F0 + (1 − F0)(1 − N·V)5 迅速上升,掠射时接近 100%。这条曲线就是水面“近处透、远处亮”的全部原因。
// Schlick 菲涅尔。水的折射率约 1.33,F0 = ((1.33-1)/(1.33+1))² ≈ 0.02:正看几乎全透,掠射角几乎全反 half WaterFresnel(half3 normalWS, half3 viewWS) { half NdotV = saturate(dot(normalWS, viewWS)); return 0.02h + 0.98h * pow(1.0h - NdotV, 5.0h); }
天空反射:GlossyEnvironmentReflection
反射颜色先用环境反射:反射方向 reflect(-V, N) 去查天空盒或反射探针。URP 14 的 GlobalIllumination.hlsl 提供了 GlossyEnvironmentReflection,它会根据粗糙度选择 mip 级别,并处理反射探针混合与盒体投影。这里有一个 URP 14 特有的坑:
// 摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl(节选) half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness, half occlusion, float2 normalizedScreenSpaceUV) { ... } #if !USE_FORWARD_PLUS half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness, half occlusion) { ... } #endif
4 参数的版本被 #if !USE_FORWARD_PLUS 包住了。Forward+ 下反射探针是按屏幕分块(cluster)查找的,必须传入屏幕 UV。如果你只写了 4 参数调用,在 Forward 路径下能编译,切换到 Forward+ 就会报 “no matching 4 parameter function”。所以本书统一使用 5 参数版本。
反射与水下颜色按菲涅尔混合:color = lerp(。注意 alpha 也同时向 1 插值——在没有折射的简单版本里,强反射处的水面不应该再透明。

案例 19.6太阳高光入门单独打开 ↗
目标:太阳在水面上拉出一条闪烁的亮带(日光路径),被柱子挡住的地方没有高光。
天空盒里虽然也画了太阳,但它是烘焙在立方体贴图里的,位置不一定和场景里的方向光一致(本例为了拍日光路径,把方向光转到了相机对面,而天空贴图里的太阳仍在原处);环境反射还会按粗糙度取较模糊的 mip,得不到锐利的闪点。所以单独用主光算一次镜面高光。水面非常光滑,用指数很高的 Blinn-Phong,乘以第 5 章介绍过的归一化系数 (n+8)/(8π):指数增大时高光面积变小、峰值变高,总能量大致守恒,调指数时不用反复回头调强度。
// 太阳高光:Blinn-Phong,指数很高(水面很光滑),乘上阴影,让高光不会出现在柱子的影子里 half3 SunSpecular(Light light, half3 normalWS, half3 viewWS) { half3 h = SafeNormalize(light.direction + viewWS); half spec = pow(saturate(dot(normalWS, h)), _SpecPower); // 能量归一化的近似系数 (n+8)/(8π),让指数变大时高光变小但更亮(第 5 章) spec *= (_SpecPower + 8.0h) / (8.0h * PI); return light.color * spec * _SpecIntensity * light.shadowAttenuation; }
// 太阳高光:Blinn-Phong,指数很高(水面很光滑),乘上阴影,让高光不会出现在柱子的影子里 vec3 SunSpecular(Light light, vec3 normalWS, vec3 viewWS) { vec3 h = SafeNormalize(light.direction + viewWS); float spec = pow(saturate(dot(normalWS, h)), _SpecPower); // 能量归一化的近似系数 (n+8)/(8π),让指数变大时高光变小但更亮(第 5 章) spec *= (_SpecPower + 8.0) / (8.0 * PI); return light.color * spec * _SpecIntensity * light.shadowAttenuation; }
高光乘了 light.shadowAttenuation:主光阴影在水面上同样有效(水面 Shader 里调用了 GetMainLight(),柱子的影子里不会有阳光的反射。透明物体虽然不投射阴影,但完全可以接收阴影。

高光的形状完全由法线决定。法线贴图的尺度、两层的强度、Gerstner 波的陡度都会改变日光路径的宽窄和碎裂程度。HDR 下高光的峰值远大于 1,开启 Bloom 后会有漂亮的光晕;如果画面里一片死白,先降低 _SpecIntensity 再看 Tonemapping 设置。
案例 19.7岸边泡沫进阶单独打开 ↗
目标:岸线、礁石和柱子周围出现白色泡沫:贴岸的一条不规则实心带,外面还有一圈圈向岸边推进的细泡沫线。
原理:竖直水深就是“离岸距离”
岸边泡沫出现在水浅的地方。竖直水深在岸线处为 0,向外逐渐增大,所以 shore = saturate(水深 / 泡沫带宽度) 就是一个“离岸距离”:岸线 0,泡沫带外沿 1。在它上面做两件事:
- 实心带:
shore小于某个阈值的地方全是泡沫。阈值被噪声上下扰动,带子的外沿就呈现不规则的锯齿。 - 推进线:
frac(是一组“等深线”,随时间减小,等深线就一圈圈向岸边移动;取其中很窄的一段作为细线,再用噪声把线打断成一截一截。这正是海浪拍岸时泡沫线的运动方式。shore × 2.5 − t × 0.3)
// 岸边泡沫 = 贴岸的一条实心带 + 一圈圈向岸边推进的细线,都用噪声打散 half ShoreFoam(float2 xz, float verticalDepth, half3 normalTS, float t) { half shore = saturate(verticalDepth / _FoamDistance); // 0 在岸线,1 在泡沫带外沿 float2 uv = xz * _FoamTiling + normalTS.xy * 0.1 + float2(t * 0.02, t * 0.015); half n = SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, uv).r; // ① 实心带:阈值被噪声上下扰动,边缘呈不规则的锯齿 half core = 1.0h - smoothstep(0.25h - _FoamSoftness, 0.25h + _FoamSoftness, shore + (n - 0.5h) * 0.35h); // ② 推进线:shore 是“离岸距离”,减去时间就得到一圈圈朝岸边移动的等深线 half ripple = frac(shore * 2.5h - t * 0.3h + n * 0.6h); half lines = smoothstep(0.8h, 0.9h, ripple) * step(0.45h, n) * (1.0h - shore); return saturate(core + lines); }
// 岸边泡沫 = 贴岸的一条实心带 + 一圈圈向岸边推进的细线,都用噪声打散 float ShoreFoam(vec2 xz, float verticalDepth, vec3 normalTS, float t) { float shore = saturate(verticalDepth / _FoamDistance); // 0 在岸线,1 在泡沫带外沿 vec2 uv = xz * _FoamTiling + normalTS.xy * 0.1 + vec2(t * 0.02, t * 0.015); float n = texture(_FoamNoise, uv).r; // ① 实心带:阈值被噪声上下扰动,边缘呈不规则的锯齿 float core = 1.0 - smoothstep(0.25 - _FoamSoftness, 0.25 + _FoamSoftness, shore + (n - 0.5) * 0.35); // ② 推进线:shore 是“离岸距离”,减去时间就得到一圈圈朝岸边移动的等深线 float ripple = fract(shore * 2.5 - t * 0.3 + n * 0.6); float lines = smoothstep(0.8, 0.9, ripple) * step(0.45, n) * (1.0 - shore); return saturate(core + lines); }
噪声采样坐标也加了一点法线扰动,泡沫会跟着水面波纹轻微晃动;泡沫颜色乘了 waterLight,在阴影里会变暗,不会像贴纸一样浮在画面上。

深度泡沫的局限
注意中图柱子周围的泡沫:只有柱子朝向相机的一侧有泡沫带。原因是泡沫靠“这个像素看下去,水底有多深”来判断:柱子前方的水面像素,看下去是柱子的水下部分,离水面很近,所以是浅水;柱子后方的水面像素被柱子本身挡住了,根本没有被画出来;而柱子两侧的水面像素,看下去是远处的水底,是深水。这是所有基于屏幕深度的泡沫(以及护盾交接高亮)的共同局限:它测的是“视线方向上的距离”,而不是“到物体表面的真实距离”。
要做到四周均匀的泡沫,需要真正的距离信息:例如离线烘焙一张水面的“到岸/到障碍物的距离场”贴图,或者用 SDF。动态物体(船、角色)周围的泡沫,常见做法是由物体自己在一张俯视的 RT 上绘制“泡沫源”,水面再采样这张 RT——这和第 20 章草地交互的思路一样。
案例 19.8焦散:程序化 Voronoi 投射到水下高阶单独打开 ↗
目标:水底出现游动的亮纹网;只出现在水下、只出现在朝上的面上、深处逐渐消失、柱子的阴影里没有;亮纹边缘带一点彩虹色的色散。
焦散的图案
真实焦散是起伏的水面把光线会聚到水底形成的亮线网。精确计算需要追踪大量光线,实时渲染里通常用一张循环的焦散贴图或程序化图案来“画”出来。Voronoi(Worley)噪声的 F2 − F1(到第二近特征点的距离减去到最近特征点的距离)在细胞边界处为 0、细胞内部较大,取反后就是一张亮线网,形态和焦散非常接近。让每个特征点绕自己的中心转圈,亮线网就“活”了起来;两层不同尺度的网叠加,交叉处更亮,形成焦散特有的亮节点。
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网 float2 VoronoiF1F2(float2 p, float t) { float2 cell = floor(p), f = frac(p); float F1 = 8.0, F2 = 8.0; [unroll] for (int j = -1; j <= 1; j++) [unroll] for (int i = -1; i <= 1; i++) { float2 g = float2(i, j); float2 o = Hash22(cell + g); o = 0.5 + 0.45 * sin(t + 6.2831 * o); // 特征点绕各自中心转圈,图案就“活”了 float2 r = g + o - f; float d = dot(r, r); if (d < F1) { F2 = F1; F1 = d; } else if (d < F2) { F2 = d; } } return sqrt(float2(F1, F2)); } half CausticPattern(float2 p, float t) { float2 v1 = VoronoiF1F2(p, t); float2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21); half c1 = 1.0h - smoothstep(0.0h, 0.18h, v1.y - v1.x); half c2 = 1.0h - smoothstep(0.0h, 0.18h, v2.y - v2.x); // 两层叠加:交叉处更亮,形成焦散特有的“亮节点” return c1 * c1 + c2 * c2 + 2.0h * c1 * c2; } // 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移 half3 Caustics(float3 sceneWS, float verticalDepth, half upFacing, Light light, float t) { float3 L = light.direction; float2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth; p *= _CausticScale; // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散 float2 split = float2(_CausticSplit, _CausticSplit * 0.5); half3 c = half3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t)); // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度” half fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade); // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing) fade *= upFacing; // 阴影要在水底那一点上查:柱子的影子里不该有焦散 half shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS)); return c * fade * _CausticIntensity * light.color * shadow; }
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网 vec2 VoronoiF1F2(vec2 p, float t) { vec2 cell = floor(p), f = fract(p); float F1 = 8.0, F2 = 8.0; for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) { vec2 g = vec2(i, j); vec2 o = Hash22(cell + g); o = 0.5 + 0.45 * sin(t + 6.2831 * o); // 特征点绕各自中心转圈,图案就“活”了 vec2 r = g + o - f; float d = dot(r, r); if (d < F1) { F2 = F1; F1 = d; } else if (d < F2) { F2 = d; } } return sqrt(vec2(F1, F2)); } float CausticPattern(vec2 p, float t) { vec2 v1 = VoronoiF1F2(p, t); vec2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21); float c1 = 1.0 - smoothstep(0.0, 0.18, v1.y - v1.x); float c2 = 1.0 - smoothstep(0.0, 0.18, v2.y - v2.x); // 两层叠加:交叉处更亮,形成焦散特有的“亮节点” return c1 * c1 + c2 * c2 + 2.0 * c1 * c2; } // 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移 vec3 Caustics(vec3 sceneWS, float verticalDepth, float upFacing, Light light, float t) { vec3 L = light.direction; vec2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth; p *= _CausticScale; // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散 vec2 split = vec2(_CausticSplit, _CausticSplit * 0.5); vec3 c = vec3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t)); // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度” float fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade); // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing) fade *= upFacing; // 阴影要在水底那一点上查:柱子的影子里不该有焦散 float shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS)); return c * fade * _CausticIntensity * light.color * shadow; }
投射到水下:在哪里查图案
焦散画在水底,而不是水面上。我们在水面 Shader 里已经重建了水底的世界坐标 sceneWS(案例 19.2),所以只要用它来查焦散图案,再把结果加到折射得到的水下颜色上——焦散自然只出现在“透过水面看到的地方”,岸上的沙滩不会有。
更进一步:光是从太阳方向斜射下来的。水底一点 P 接收到的光,是从水面上 P 沿光线方向往回追溯到的那一点进来的。所以查图案的位置应该是 P 沿光方向投影回水面的位置:
这样太阳斜照时,越深的地方图案偏移越多;石头侧面与顶面的图案也会错开,立体感更好。
三个衰减
- 深度衰减:刚入水的地方光还没被会聚,焦散很弱;太深的地方光被吸收殆尽。用
saturate(把它限制在“中间深度”。depth × 4) × exp(−depth / fade) - 朝向衰减:焦散只照在朝上的面上。我们不知道水底的法线(没有法线图),但可以用重建坐标的屏幕导数求:
ddx(sceneWS)、ddy(sceneWS)是相邻像素的世界坐标差,它们的叉积就是那个表面的法线。注意要用未经折射偏移的坐标求导:折射后每个像素的偏移量都不同,导数会变成噪声。 - 阴影:在水底点 P 上重新查一次主光阴影(
MainLightRealtimeShadow(),柱子影子里就没有焦散了。注意这是在水底点查阴影,不是在水面点。TransformWorldToShadowCoord( P))
// 用重建坐标的屏幕导数求水下表面的法线:ddx/ddy 是相邻像素的世界坐标差,叉积就是面法线。 // 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标: // 折射后的 UV 每个像素扰动不同,求出的导数全是噪声 half SceneUpFacing(float3 sceneWS) { float3 n = normalize(cross(ddy(sceneWS), ddx(sceneWS))); return saturate(abs(n.y) * 1.5 - 0.3); }
#ifdef FRAGMENT // dFdx/dFdy 只能在片元着色器里用 // 用重建坐标的屏幕导数求水下表面的法线:dFdx/dFdy 是相邻像素的世界坐标差,叉积就是面法线。 // 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标: // 折射后的 UV 每个像素扰动不同,求出的导数全是噪声 float SceneUpFacing(vec3 sceneWS) { vec3 n = normalize(cross(dFdy(sceneWS), dFdx(sceneWS))); return saturate(abs(n.y) * 1.5 - 0.3); } #endif
最后,R、G、B 三个通道在略微错开的位置查图案,亮线边缘出现彩色镶边,模拟不同波长折射角不同造成的色散——这和第 18 章全息色差是同一个技巧。

案例 19.9Gerstner 波进阶单独打开 ↗
目标:让水面真正起伏,有波峰波谷,并且波峰比波谷尖——这是 Gerstner 波和简单正弦波最明显的区别。
第 17 章已经推导过 Gerstner 波:水面上的每个点不只是上下运动,还会沿传播方向前后运动,做圆周运动。一个波由方向 d、陡度 Q、波长 L 决定:
相速度 c 来自深水波的色散关系 ω² = gk:长波跑得快、短波跑得慢,多个波叠加时自然会“错开”,不会整齐地同步。Q = 1 时波峰恰好变成尖角,再大就会自交打结,所以多个波叠加时它们的陡度之和最好不超过 1。
移植到水面 Shader 时要注意两件事。一是法线必须解析地求:位移函数对 x、z 求偏导得到切线 T 和副切线 B,法线 = B × T。函数里用 inout 把每个波的偏导累加进去,三个波叠加完一次性得到正确的切线空间。二是切线空间要同步给法线贴图:波面倾斜后,细节法线也要跟着倾斜,所以顶点着色器把 T、B、N 一起传给片元,片元用它们构造 TBN 矩阵(案例 19.4)。
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。 // 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖) float3 GerstnerWave(float4 wave, float3 p, float t, inout float3 tangent, inout float3 binormal) { float steep = wave.z; float k = 2.0 * PI / wave.w; float c = sqrt(9.8 / k); float2 d = normalize(wave.xy); float f = k * (dot(d, p.xz) - c * t); float a = steep / k; float s = sin(f), co = cos(f); tangent += float3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s); binormal += float3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s); return float3(d.x * a * co, a * s, d.y * a * co); }
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。 // 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖) vec3 GerstnerWave(vec4 wave, vec3 p, float t, inout vec3 tangent, inout vec3 binormal) { float steep = wave.z; float k = 2.0 * PI / wave.w; float c = sqrt(9.8 / k); vec2 d = normalize(wave.xy); float f = k * (dot(d, p.xz) - c * t); float a = steep / k; float s = sin(f), co = cos(f); tangent += vec3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s); binormal += vec3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s); return vec3(d.x * a * co, a * s, d.y * a * co); }
Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y float3 tangent = float3(1, 0, 0); float3 binormal = float3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); float3 p = positionWS; positionWS += GerstnerWave(_WaveA, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveB, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif o.positionWS = positionWS; o.positionCS = TransformWorldToHClip(positionWS); o.tangentWS = normalize(tangent); o.bitangentWS = normalize(binormal); o.normalWS = normalize(cross(binormal, tangent)); o.fogFactor = ComputeFogFactor(o.positionCS.z); return o; }
void main() { vec3 pWS = TransformObjectToWorld(positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y vec3 tangent = vec3(1, 0, 0); vec3 binormal = vec3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); vec3 p = pWS; pWS += GerstnerWave(_WaveA, p, t, tangent, binormal); pWS += GerstnerWave(_WaveB, p, t, tangent, binormal); pWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif positionWS = pWS; gl_Position = TransformWorldToHClip(pWS); tangentWS = normalize(tangent); bitangentWS = normalize(binormal); normalWS = normalize(cross(binormal, tangent)); fogFactor = ComputeFogFactor(gl_Position.z); }
还有一个容易忽略的影响:顶点被 Gerstner 波抬高或压低之后,片元里的 positionWS 是位移后的位置,所以水深、泡沫、焦散都会随波起伏自动变化——波谷处水变浅,泡沫带向外扩;波峰处水变深。这种联动是“免费”的,因为后续计算全部基于实际的水面位置。

网格密度与常见坑
- 网格要够密:顶点位移只能表现比网格间距大得多的波。本例 60 米的水面用了 400×400 的网格(每格 15 厘米)。大面积水面通常用以相机为中心的同心网格或曲面细分来分配顶点。
- 包围盒:顶点被抬高后可能超出网格原本的包围盒,相机边缘的水面被视锥剔除。本书的
Meshes.Grid已经把包围盒的高度设得和宽度一样;自己建网格时记得扩大mesh.bounds。 - 岸边:波浪会把水面抬到沙滩上方,或者在浅水处露出水底。小水池里浪不宜太大;真正的海岸线需要按水深衰减波高(用一张水深图或离岸距离图驱动振幅)。
- 物理交互:浮在水上的物体需要知道某一点的水面高度。Gerstner 波是解析函数,可以在 C# 里用同样的公式计算——注意它是水平位移过的,查询点 (x, z) 处的高度需要迭代求解,或者近似地忽略水平位移。
案例 19.10平面反射:C# 反射相机 + RenderTexture高阶单独打开 ↗
目标:水面倒映出岸上的柱子、石头和木栈道,而不只是天空。
为什么天空反射不够
案例 19.5 的反射来自天空盒(或反射探针),它只知道“某个方向上很远的地方是什么颜色”,不知道水面附近有一根柱子。反射探针可以捕捉场景,但它是从探针位置看出去的立方体贴图,对于一个大平面上近处物体的倒影,位置会严重错位。对于水面、镜面地板这类大平面,最准确的实时做法是平面反射:再架一台相机,从水面下方“镜像”的位置往上看,把画面渲染到一张 RT 里,水面采样它。
镜像矩阵与斜裁剪
平面用 (n, d) 表示,平面上的点满足 n·p + d = 0。点 p 关于平面的镜像是
写成 4×4 矩阵 R,反射相机的视图矩阵就是主相机视图矩阵乘以 R:worldToCameraMatrix = cam.。它的含义是“先把世界镜像一次,再用主相机去看”。这样渲染出来的画面里,倒影点恰好出现在主相机看到的对应水面像素位置上——所以水面直接用自己的屏幕 UV 采样反射 RT 即可,不需要任何额外的坐标变换。
还有两个必须处理的问题:
- 水面以下的东西不能进反射:反射相机在水面下方,它会看到水底、水中的柱子下半截。用
Camera.构造“斜裁剪投影”:把投影矩阵的近裁剪面替换成水面本身,水面以下的几何直接被裁掉。传入的平面必须是观察空间中的平面,所以先用反射相机的视图矩阵把它变换过去。CalculateObliqueMatrix - 三角形绕序反了:镜像矩阵的行列式为 −1,它把左手系变成右手系,所有三角形的顺时针/逆时针颠倒,正常的背面剔除会把正面剔掉。渲染反射前设置
GL.invertCulling = true,渲染后恢复。
void SetupReflectionCamera(Camera cam) { m_ReflCam.CopyFrom(cam); // 视场角、远近裁剪面、清屏方式等与主相机一致 m_ReflCam.enabled = false; m_ReflCam.targetTexture = null; // 由渲染请求临时指定 m_ReflCam.cullingMask = reflectLayers; m_ReflCam.cameraType = CameraType.Reflection; m_ReflCam.useOcclusionCulling = false; Vector3 normal = transform.up; Vector3 pos = transform.position; Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos)); Matrix4x4 reflection = ReflectionMatrix(plane); // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置 m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position); m_ReflCam.transform.rotation = Quaternion.LookRotation( Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal)); m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection; // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射 Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix, pos - normal * clipPlaneOffset, normal); m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane); } // 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n static Matrix4x4 ReflectionMatrix(Vector4 p) { var m = Matrix4x4.identity; m.m00 = 1 - 2 * p.x * p.x; m.m01 = -2 * p.x * p.y; m.m02 = -2 * p.x * p.z; m.m03 = -2 * p.w * p.x; m.m10 = -2 * p.y * p.x; m.m11 = 1 - 2 * p.y * p.y; m.m12 = -2 * p.y * p.z; m.m13 = -2 * p.w * p.y; m.m20 = -2 * p.z * p.x; m.m21 = -2 * p.z * p.y; m.m22 = 1 - 2 * p.z * p.z; m.m23 = -2 * p.w * p.z; return m; // 第四行保持 (0, 0, 0, 1) } // 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面) static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal) { Vector3 cpos = worldToCamera.MultiplyPoint(pos); Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized; return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal)); }
setupReflectionCamera(cam) { // 平面:法线 = 水面物体的 up,经过物体原点 const m = this.obj.matrix; const normal = vec3.normalize([m[4], m[5], m[6]]); const pos = [m[12], m[13], m[14]]; const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)]; const reflection = reflectionMatrix(plane); // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像 const view = mat4.mul(cam.view, reflection); // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射 const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal); const proj = calculateObliqueMatrix(cam.projection(), clipPlane); return { json: cam.json, view, projection: () => proj, fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far, orthographic: cam.orthographic, orthoSize: cam.orthoSize, clearFlags: cam.clearFlags, background: cam.background, cullingMask: this.reflectLayers, allowHDR: cam.allowHDR, allowMSAA: false, renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0, }; }
URP 14 里怎么让一台相机渲染一次
这是平面反射在 URP 里最容易写错的地方。网上流传最广的写法来自 URP 早期版本:订阅 RenderPipelineManager.,在回调里调用 UniversalRenderPipeline.。在 URP 14.0.12 里,这个函数已经被标记为过时:
// 摘自 URP 14 Runtime/UniversalRenderPipeline.cs [Obsolete("RenderSingleCamera is obsolete, please use RenderPipeline.SubmitRenderRequest with UniversalRenderer.SingleCameraRequest as RequestData type", false)] public static void RenderSingleCamera(ScriptableRenderContext context, Camera camera)
提示里说的新方式是 RenderPipeline.,请求类型是嵌套在 UniversalRenderPipeline 里的 SingleCameraRequest(提示文字里写的是 UniversalRenderer.,但在 14.0.12 源码中这个类实际定义在 UniversalRenderPipeline 内部,以源码为准)。读 ProcessRenderRequests 的实现可以看到,它临时把相机的 targetTexture 换成请求里的 destination,依次调用 BeginContextRendering、BeginCameraRendering、渲染、EndCameraRendering、EndContextRendering,最后把 targetTexture 恢复原样。
那么,能不能把新 API 直接放进 beginCameraRendering 回调里,替换掉旧的 RenderSingleCamera?不能。拍摄本章配图时实测,在回调里调用 SubmitRenderRequest,Unity 会报错:
Recursive rendering is not supported in SRP (are you calling Camera.Render from within a render pipeline?).
原因是 beginCameraRendering 发生在 SRP 渲染循环内部,而 SubmitRenderRequest 本身要启动一次完整的渲染循环——这是一次递归渲染。旧的 RenderSingleCamera 能在回调里用,是因为它直接把命令录进主相机正在使用的 context,并不开启新的循环;但它已过时;本书在 URP 14 下试验这种写法时,程序能运行,但主相机水面的折射与焦散全部失效、反射画面也不正确(推测是两台相机共用同一个 context 时,相机相关的全局纹理与状态互相覆盖),因此不建议继续使用。
正确的做法是在渲染循环之外提交请求:运行时在 LateUpdate 里为主相机渲染一次反射(LateUpdate 在所有相机渲染之前执行,此时主相机的位置已经被 Update 里的逻辑更新完毕);编辑器拍图时,在主相机 Render() 之前手动调用一次。
/// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。 /// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报 /// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。 public void RenderFor(Camera cam) { if (m_Rendering) return; m_Rendering = true; EnsureResources(cam); SetupReflectionCamera(cam); var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT }; if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request)) { // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面 GL.invertCulling = true; RenderPipeline.SubmitRenderRequest(m_ReflCam, request); GL.invertCulling = false; } Shader.SetGlobalTexture(s_TexId, m_RT); m_Rendering = false; }
renderFor(cam, time, dt) { if (this.rendering) return; this.rendering = true; const E = this.engine, gl = E.gl; const W = E.canvas.width, H = E.canvas.height; const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0); // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 }); const reflCam = this.setupReflectionCamera(cam); // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用 const view = Object.create(this.scene); view.camera = reflCam; const prevVP = E.prevViewProj; const frame = new Frame(E, view, w, h, time, dt); frame.requireDepth = false; // requiresDepthOption = Off frame.requireOpaque = false; // requiresColorOption = Off frame.setupCamera(); frame.setupLights(); frame.setupEnvironment(); frame.renderShadows(); // reflCam.renderShadows = false 时只是把阴影关掉 // 清屏(和主相机的清屏方式一致) frame.cameraTarget = rt; E.bindTarget(rt); gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255); const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0]; gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true), // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反 const rs = this.scene.renderers; rs.forEach((o) => (o.flip = !o.flip)); try { frame.renderOpaques(); frame.renderSkybox(); frame.renderTransparents(); } finally { rs.forEach((o) => (o.flip = !o.flip)); } E.prevViewProj = prevVP; E.setGlobal('_PlanarReflectionTex', rt.texture); this.rendering = false; }
完整的组件:
// 案例 19.9:平面反射。用一台“镜像相机”从水面下方往上看,把画面渲染进 RenderTexture, // 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。 // URP 14 推荐的渲染方式:RenderPipeline.SubmitRenderRequest + UniversalRenderPipeline.SingleCameraRequest // (UniversalRenderPipeline.RenderSingleCamera 在 14 里已标记为 Obsolete)。 using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace Cookbook.Ch19 { [ExecuteAlways] public class PlanarReflection : MonoBehaviour { public enum UpdateMode { /// 在 LateUpdate 里为 Camera.main 提交渲染请求(SubmitRenderRequest,推荐) LateUpdate, /// 不自动渲染,由外部调用 RenderFor(camera) Manual, } public UpdateMode updateMode = UpdateMode.LateUpdate; [Tooltip("反射相机渲染哪些层。水面自己必须排除在外,否则会把自己画进反射里")] public LayerMask reflectLayers = ~(1 << 4); // 4 = 内置的 Water 层 [Range(0.25f, 1f)] public float resolutionScale = 0.5f; [Tooltip("裁剪平面相对水面的偏移,略低于水面可以避免岸边出现缝隙")] public float clipPlaneOffset = 0.05f; public bool renderShadows = false; static readonly int s_TexId = Shader.PropertyToID("_PlanarReflectionTex"); Camera m_ReflCam; RenderTexture m_RT; bool m_Rendering; // 防止重入 void OnDisable() { if (m_ReflCam != null) DestroyImmediate(m_ReflCam.gameObject); if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); } m_ReflCam = null; m_RT = null; } void LateUpdate() { if (updateMode == UpdateMode.LateUpdate && Camera.main != null) RenderFor(Camera.main); } /// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。 /// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报 /// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。 public void RenderFor(Camera cam) { if (m_Rendering) return; m_Rendering = true; EnsureResources(cam); SetupReflectionCamera(cam); var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT }; if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request)) { // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面 GL.invertCulling = true; RenderPipeline.SubmitRenderRequest(m_ReflCam, request); GL.invertCulling = false; } Shader.SetGlobalTexture(s_TexId, m_RT); m_Rendering = false; } void EnsureResources(Camera cam) { int w = Mathf.Max(1, (int)(cam.pixelWidth * resolutionScale)); int h = Mathf.Max(1, (int)(cam.pixelHeight * resolutionScale)); if (m_RT == null || m_RT.width != w || m_RT.height != h) { if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); } var fmt = cam.allowHDR ? RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default; m_RT = new RenderTexture(w, h, 24, fmt) { name = "_PlanarReflectionTex", hideFlags = HideFlags.DontSave }; m_RT.Create(); } if (m_ReflCam == null) { var go = new GameObject("Planar Reflection Camera") { hideFlags = HideFlags.HideAndDontSave }; m_ReflCam = go.AddComponent<Camera>(); m_ReflCam.enabled = false; // 不参与正常的相机循环,只在我们提交请求时渲染 var data = go.AddComponent<UniversalAdditionalCameraData>(); data.renderPostProcessing = false; // 后处理留给主相机统一做 data.requiresColorOption = CameraOverrideOption.Off; data.requiresDepthOption = CameraOverrideOption.Off; data.SetRenderer(0); } m_ReflCam.GetComponent<UniversalAdditionalCameraData>().renderShadows = renderShadows; } void SetupReflectionCamera(Camera cam) { m_ReflCam.CopyFrom(cam); // 视场角、远近裁剪面、清屏方式等与主相机一致 m_ReflCam.enabled = false; m_ReflCam.targetTexture = null; // 由渲染请求临时指定 m_ReflCam.cullingMask = reflectLayers; m_ReflCam.cameraType = CameraType.Reflection; m_ReflCam.useOcclusionCulling = false; Vector3 normal = transform.up; Vector3 pos = transform.position; Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos)); Matrix4x4 reflection = ReflectionMatrix(plane); // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置 m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position); m_ReflCam.transform.rotation = Quaternion.LookRotation( Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal)); m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection; // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射 Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix, pos - normal * clipPlaneOffset, normal); m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane); } // 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n static Matrix4x4 ReflectionMatrix(Vector4 p) { var m = Matrix4x4.identity; m.m00 = 1 - 2 * p.x * p.x; m.m01 = -2 * p.x * p.y; m.m02 = -2 * p.x * p.z; m.m03 = -2 * p.w * p.x; m.m10 = -2 * p.y * p.x; m.m11 = 1 - 2 * p.y * p.y; m.m12 = -2 * p.y * p.z; m.m13 = -2 * p.w * p.y; m.m20 = -2 * p.z * p.x; m.m21 = -2 * p.z * p.y; m.m22 = 1 - 2 * p.z * p.z; m.m23 = -2 * p.w * p.z; return m; // 第四行保持 (0, 0, 0, 1) } // 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面) static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal) { Vector3 cpos = worldToCamera.MultiplyPoint(pos); Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized; return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal)); } } }
// 案例 19.9:平面反射(JS 版,对应 Ch19/PlanarReflection.cs)。 // 用一台“镜像相机”从水面下方往上看,把画面渲染进一张 RenderTexture, // 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。 // // C# 版用 RenderPipeline.SubmitRenderRequest 让 URP 为反射相机完整渲染一次; // 网页版在 update() 里(主相机渲染之前)用引擎的 Frame 为镜像相机画一遍:不透明 → 天空盒 → 半透明。 // 和 C# 版一样:不画阴影、不做后处理、不生成深度/不透明贴图。 import { Frame } from '../engine/pipeline.js'; import { mat4, vec3 } from '../engine/math.js'; import { layerMask } from '../engine/features.js'; export default class PlanarReflection { constructor(engine, json, obj, scene) { this.engine = engine; this.obj = obj; // 水面物体 this.scene = scene; const d = json.data || {}; this.enabled = json.enabled !== false && d.m_Enabled !== false; this.reflectLayers = layerMask(d.reflectLayers ?? ~(1 << 4)); // 4 = 内置的 Water 层 this.resolutionScale = d.resolutionScale ?? 0.5; this.clipPlaneOffset = d.clipPlaneOffset ?? 0.05; this.renderShadows = !!d.renderShadows; this.rendering = false; // 防止重入 } // C# 版的 LateUpdate / 拍图时的 RenderFor(Camera.main):每帧在主相机渲染之前调用 update(time, dt) { if (!this.enabled || !this.obj.active || !this.scene.camera) return; this.renderFor(this.scene.camera, time, dt); } renderFor(cam, time, dt) { if (this.rendering) return; this.rendering = true; const E = this.engine, gl = E.gl; const W = E.canvas.width, H = E.canvas.height; const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0); // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 }); const reflCam = this.setupReflectionCamera(cam); // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用 const view = Object.create(this.scene); view.camera = reflCam; const prevVP = E.prevViewProj; const frame = new Frame(E, view, w, h, time, dt); frame.requireDepth = false; // requiresDepthOption = Off frame.requireOpaque = false; // requiresColorOption = Off frame.setupCamera(); frame.setupLights(); frame.setupEnvironment(); frame.renderShadows(); // reflCam.renderShadows = false 时只是把阴影关掉 // 清屏(和主相机的清屏方式一致) frame.cameraTarget = rt; E.bindTarget(rt); gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255); const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0]; gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true), // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反 const rs = this.scene.renderers; rs.forEach((o) => (o.flip = !o.flip)); try { frame.renderOpaques(); frame.renderSkybox(); frame.renderTransparents(); } finally { rs.forEach((o) => (o.flip = !o.flip)); } E.prevViewProj = prevVP; E.setGlobal('_PlanarReflectionTex', rt.texture); this.rendering = false; } setupReflectionCamera(cam) { // 平面:法线 = 水面物体的 up,经过物体原点 const m = this.obj.matrix; const normal = vec3.normalize([m[4], m[5], m[6]]); const pos = [m[12], m[13], m[14]]; const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)]; const reflection = reflectionMatrix(plane); // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像 const view = mat4.mul(cam.view, reflection); // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射 const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal); const proj = calculateObliqueMatrix(cam.projection(), clipPlane); return { json: cam.json, view, projection: () => proj, fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far, orthographic: cam.orthographic, orthoSize: cam.orthoSize, clearFlags: cam.clearFlags, background: cam.background, cullingMask: this.reflectLayers, allowHDR: cam.allowHDR, allowMSAA: false, renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0, }; } } // 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n(列主序,下标 m[列*4+行]) function reflectionMatrix(p) { const [x, y, z, w] = p; return new Float32Array([ 1 - 2 * x * x, -2 * y * x, -2 * z * x, 0, -2 * x * y, 1 - 2 * y * y, -2 * z * y, 0, -2 * x * z, -2 * y * z, 1 - 2 * z * z, 0, -2 * w * x, -2 * w * y, -2 * w * z, 1, ]); } // 把世界空间的平面变换到观察空间(斜裁剪需要观察空间平面) function cameraSpacePlane(worldToCamera, pos, normal) { const cpos = mat4.transformPoint(worldToCamera, pos); const cn = vec3.normalize(mat4.transformDir(worldToCamera, normal)); return [cn[0], cn[1], cn[2], -vec3.dot(cpos, cn)]; } // Unity 的 Camera.CalculateObliqueMatrix(Lengyel 的斜裁剪近平面):替换投影矩阵第 3 行 function calculateObliqueMatrix(projection, c) { const P = new Float32Array(projection); const g = (r, col) => P[col * 4 + r]; const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0); const q = [(sgn(c[0]) + g(0, 2)) / g(0, 0), (sgn(c[1]) + g(1, 2)) / g(1, 1), -1, (1 + g(2, 2)) / g(2, 3)]; const s = 2 / (c[0] * q[0] + c[1] * q[1] + c[2] * q[2] + c[3] * q[3]); P[0 * 4 + 2] = c[0] * s; P[1 * 4 + 2] = c[1] * s; P[2 * 4 + 2] = c[2] * s + 1; P[3 * 4 + 2] = c[3] * s; return P; }
关键细节
- 反射相机本身:用
HideAndDontSave创建,enabled = false,它不会参与正常的相机循环,只在我们提交请求时渲染。挂上UniversalAdditionalCameraData,关闭后处理、关闭它自己的深度/不透明纹理需求、默认关闭阴影——反射里的阴影通常看不出来,省掉一整套阴影贴图的渲染。 - 层:
reflectLayers默认排除 Water 层,否则水面会把自己渲染进反射里(在镜像相机看来,水面正好在近裁剪面上,会产生各种闪烁)。 - 分辨率:默认半分辨率。水面的反射会被法线扰动,半分辨率几乎看不出差别,渲染开销却只有四分之一。
- Shader 端:RT 用
Shader.设为全局纹理。水面 Shader 在 CBUFFER 外声明SetGlobalTexture("_ PlanarReflectionTex", rt) TEXTURE2D(_,用屏幕 UV 加上一点法线扰动采样它,替换掉天空反射。PlanarReflectionTex)
在本书的拍摄脚本里,平面反射是这样触发的(摄影棚没有 LateUpdate,所以先给主相机挂一个临时 RT 确定分辨率与宽高比,手动渲染一次反射,再交给拍摄流程):
var pr = water.AddComponent<PlanarReflection>(); pr.updateMode = PlanarReflection.UpdateMode.Manual; var tmp = RenderTexture.GetTemporary(w, h, 24); s.Cam.targetTexture = tmp; s.Cam.aspect = (float)w / h; pr.RenderFor(s.Cam); // 在主相机渲染之前、渲染循环之外 s.Cam.targetTexture = null; RenderTexture.ReleaseTemporary(tmp);

屏幕空间反射(SSR)简介
平面反射的代价是“整个场景再渲染一遍”,而且只适用于一个平面——起伏很大的海面就不准确了。另一种思路是屏幕空间反射(Screen Space Reflection):沿反射方向在深度图里做光线步进,找到交点后从当前画面取颜色。它不需要额外的相机,适用于任意形状的表面,但只能反射屏幕上已经存在的东西:画面边缘、被遮挡的物体、相机背后的物体都反射不出来,需要退回到反射探针。URP 14 没有内置 SSR,第 28 章会介绍它的原理。实际项目里,水面常见的组合是:平面反射(或 SSR)负责近处物体,反射探针/天空负责其余部分,用菲涅尔和距离混合。
19.2 最终合成
把所有关键字打开,配合轻微的 Bloom 和 Tonemapping,就是本章的完整水面。最终版用了比案例 19.9 更平缓的波浪(陡度 0.05~0.07)——小水池里浪太大会漫上沙滩;太阳高光的强度也调低了,避免日光路径在 Bloom 下变成一片白。


调水时推荐的顺序,基本就是本章案例的顺序:先关掉所有特性,把深度和吸收调对——这决定了水的“底色”和通透感;再调法线(尺度、速度、强度),因为反射、高光、折射、泡沫的外观全部受它影响;然后是反射和菲涅尔;最后才是泡沫、焦散这些“点缀”。每加一项,都用调试视图(_DebugView)单独看它的遮罩是否合理。
| 参数 | 作用 | 调参建议 |
|---|---|---|
_Absorption | 每米吸收系数(RGB) | R 最大、B 最小得到蓝绿色水;整体放大让水更浑浊;想要泥水可以让 B 最大 |
_DeepColor | 散射色,深处的颜色 | 不要用纯黑;它乘了光照,阴影里会自动变暗 |
_RefractionStrength | 折射偏移 | 0.02~0.08;过大时前景泄漏修正的“无扭曲圈”会变明显 |
_NormalTiling/_FlowA/B | 两层法线的尺度、流向与强度 | 两层尺度比避开整数;方向夹角 60°~120°;远景可以降低强度防止闪烁 |
_SpecPower | 高光锐度 | 水面 500~2000;越大闪点越碎 |
_FoamDistance | 泡沫带宽度(米) | 与水池坡度相关:坡越缓,同样的宽度覆盖的水平距离越大 |
_CausticScale/_CausticDepthFade | 焦散尺度与深度衰减 | 每米 1~3 个细胞;深度衰减 1~2 米 |
性能与移动端
- 两张屏幕纹理:水面需要 Depth Texture 和 Opaque Texture。它们分别意味着一次深度拷贝(或深度预 Pass)和一次颜色拷贝。如果场景里只有水用到它们,可以只在有水的相机上开启(相机组件的 Depth Texture / Opaque Texture 覆盖选项)。
- 平面反射是最贵的一项:它把场景再渲染一遍。降低分辨率、只渲染必要的层(比如排除小物件和粒子)、关闭反射相机的阴影,或者在低端设备上退回天空/探针反射。
- 焦散:程序化 Voronoi 在移动端换成预烘焙贴图。
- 精度:颜色、法线、菲涅尔等可以用
half;但世界坐标、深度重建、Gerstner 相位必须用float——half只有约 3 位有效数字,几十米外的坐标会出现明显的阶梯和抖动。 - 大面积透明:水面通常占据大半个屏幕,片元着色器的每一行都很贵。用
shader_feature_local把不用的特性彻底剥离,而不是用材质参数在运行时“关掉”。
和 Built-in 管线相比:Built-in 的水通常用 GrabPass 抓取屏幕做折射,URP 不支持 GrabPass,改用 _CameraOpaqueTexture;Built-in 的平面反射可以在 OnWillRenderObject 里调用 Camera.Render(),在 SRP 里这些回调与 Camera.Render 的用法都不一样,需要按本章的 SubmitRenderRequest 方式改写。
本章小结
- 水面在透明队列中读取深度图重建水底世界坐标,由此得到竖直水深(决定颜色、泡沫)与视线路径长度(决定吸收)。
- 折射从
_CameraOpaqueTexture按法线偏移采样,需要用偏移点的深度判断并拒绝前景泄漏;Beer-Lambert 吸收 T = e−σd 按通道衰减,自动给出浅青深蓝的颜色渐变。 - 双层法线以不同尺度与方向流动,打破重复与平移感;法线贴图必须能无缝平铺。
- 水的 F0 约为 0.02,菲涅尔让近处通透、远处如镜;URP 14 Forward+ 下
GlossyEnvironmentReflection必须使用带屏幕 UV 的 5 参数版本。 - 泡沫、焦散都建立在深度重建之上;基于屏幕深度的泡沫测的是视线方向上的距离,有固有的局限。
- Gerstner 波在顶点阶段累加位移与偏导,解析得到法线与切线空间,并自动联动水深相关的效果。
- URP 14 的平面反射用
RenderPipeline.+SubmitRenderRequest UniversalRenderPipeline.,必须在渲染循环之外提交;在SingleCameraRequest beginCameraRendering回调里提交会触发递归渲染错误。
练习
- 给水面加一个“水下视角”:相机在水面以下时,水面的反射与折射角色互换(从水下看水面,会看到全反射的现象)。提示:用
IS_FRONT_VFACE判断看到的是水面的背面。 - 把焦散改为预烘焙贴图:写一个编辑器脚本,用本章的
CausticPattern公式(C# 版本)生成一张 256×256 可平铺的焦散贴图,Shader 改为采样两次并错开流动,比较效果与开销。 - 实现一个“离岸距离图”:在编辑器里从水池地形高度场计算每个水面点到岸线的水平距离,存成贴图,用它驱动泡沫与 Gerstner 波的振幅衰减,消除图 19-10 中泡沫只出现在柱子一侧的问题。
- 给
PlanarReflection加上“只在水面可见时渲染”的优化:用Renderer.isVisible或GeometryUtility.判断水面是否在主相机视锥内。TestPlanesAABB - 在 C# 里实现与 Shader 相同的 Gerstner 公式,写一个浮漂组件,让一个小球随波起伏。