
本文承接上回关于视差算法的原理讲解。这次要实现的效果从原理上讲属于是视差算法的超级低配版,但在伪造冰块、宝石等有一定通透感的材质等方面有着不错的效果。
个人在原神中也发现了诸如下图几个地方使用了类似的技术(至少从效果上来说,本文的方法可以达到相同的效果)。



可以发现,这种效果的共性在于,产生了好像表面之下一定距离还有一层的空间错觉。这给人感觉,这些“透明物体”是有厚度、有内部结构的。但实际上,这些效果都只是在一个没有厚度的片上实现的,甚至于物体本身并不透明。
可能会有人问,“为什么不直接用两层模型,然后用半透明模式渲染外层呢?”因为若是分开渲染,那么两个模型的效果就是各算各的,也就因此不会产生折射扭曲的效果(其实也可以用RenderTexture去实时渲染内层的全屏截图传给外层做扭曲,但如果内层只是上面这种简单的结构,那用RT就有点杀鸡用牛刀了。而且显然,RT的性能开销不可忽略)。
在当时,个人因为知识有限所以没有实现思路。直到以前在研究视差算法时突然就想明白了原理,这次便简单实现一下。
如果基于物理过程进行本文的效果实现,因为效果上是存在上下两层的,所以下层的光在通过上层时,理论上会发生折射现象,为此就需要先了解向量折射的物理规律。
其实有关折射的内容,高中物理里就讲过相关的概念。本文采用相对折射公式,即
其中n1n2是介质的折射率,θ1θ2是入射角和折射角,图下图所示。

前言中已经说过,这个算法本质上还是一种在平面上伪造出空间感的障眼法,更进一步说就是在表层想办法拿到下层的位置(UV坐标)信息。
假想我们要做处理的平面下面真的还有一层,考虑最简单的,表面形状是平面的情况,那这个过程应该是下面这样。

在3D的情况下,我们可以通过射线平面求交点的老办法获得折射向量与下层的假想平面的交点。甚至因为两层的间距是已知的(即高度坐标已知),所以求交的算法也可以进一步简化。
至于求交的射线,也就是折射向量,则可以直接通过HLSL库里现成的方法来计算,即refract(dirIN, nDir, ratio)。其中,dirIN为入射向量(在shader中是视向量的反向),nDir为法向量,ratio为上面的相对折射率,一般范围在0~1间(根据第一节中的物理过程也可知道,ratio越小,折射效果会越明显)。
如果使用法线贴图,上面的蓝色虚线代表的法向量则使用法线图计算得来的向量。
需要注意的是,这个过程同上次的视差算法一样,因为本质上是对贴图的显示进行偏移,所以求交计算也是在切空间下进行的。关于这点只需要和视差算法一样,对计算得到的折射向量再使用TransformWorldToTangent方法进行空间转换即可。
本文的效果实现,在光照模型上使用blinnPhong模拟高光,同时为了让效果更“卡通”一些,还对高光做了一个阶梯化处理(其实原神本体的场景渲染效果,个人觉得相对来说还是蛮写实的,这里是个自定义改动)。
模仿前言的三个游戏内的效果,使用SD制作程序纹理,在引擎内的效果如下(图1的溶解效果是模仿凌华的E随性做的)。



代码如下(删掉了图1的溶解效果)。
//URP
Shader "Scene/Ice"
{
Properties
{
[Header(Texture)]
_FaceTex ("表层贴图", 2D) = "black" {}
_InnerTex ("里层贴图", 2D) = "white" {}
_NormalMap ("法线图", 2D) = "bump" {}
[Space(20)]
[Header(Color)]
_FaceCol ("表层色调", Color) = (1.0, 1.0, 1.0, 1.0)
_InnerCol ("里层色调", color) = (1.0, 1.0, 1.0, 1.0)
[HDR] _SpecularCol ("高光色", color) = (1.0, 1.0, 1.0, 1.0)
_AmbCol ("环境色", color) = (1.0, 1.0, 1.0, 1.0)
[Space(20)]
[Header(Material)]
_NormalInt ("法线强度", range(0, 10)) = 1.0
_Rough ("粗糙度", range(0.001, 1)) = 0.1
_FresnelPow ("菲涅尔次幂", range(1, 10)) = 5.0
_Terrace ("阶梯化次数", range(1, 5)) = 3
[Space(20)]
[Header(Refract)]
_RefractRatio ("折射率", range(0, 1)) = 0.5
_Thick ("层厚度", range(0, 5)) = 0.1
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
"Queue" = "Geometry"
}
Pass
{
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
CBUFFER_START(UnityPerMaterial)
//纹理
sampler2D _FaceTex; float4 _FaceTex_ST;
sampler2D _InnerTex; float4 _InnerTex_ST;
sampler2D _NormalMap; float4 _NormalMap_ST;
//颜色
half4 _FaceCol;
half3 _InnerCol;
half3 _SpecularCol;
half3 _AmbCol;
//材质
half _NormalInt;
half _Rough;
half _FresnelPow;
uint _Terrace;
//折射
half _RefractRatio;
half _Thick;
CBUFFER_END
//计算任意平面交点
float3 GetPosAnyPlaneCrossDir(float3 posPlane, float3 posRay, float3 nDirPlane, float3 nDirRay)
{
float3 deltaPos = posPlane - posRay;
float temp = dot(nDirPlane, deltaPos) / dot(nDirPlane, nDirRay);
return temp * nDirRay + posRay;
}
struct a2v
{
half4 posOS : POSITION;
float2 uv0 : TEXCOORD0;
half3 nDirOS : NORMAL;
half4 tDirOS : TANGENT;
};
struct v2f
{
half4 posCS : SV_POSITION;
float2 uv_Front : TEXCOORD0;
float2 uv_Normal : TEXCOORD1;
float2 uv0 : TEXCOORD6;
half3 nDirWS : TEXCOORD2;
half3 tDirWS : TEXCOORD3;
half3 bDirWS : TEXCOORD4;
half3 vDirWS : TEXCOORD5;
};
v2f vert (a2v i)
{
v2f o;
//坐标
o.posCS = TransformObjectToHClip(i.posOS.xyz);
//向量
o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
o.tDirWS = TransformObjectToWorldDir(i.tDirOS.xyz);
o.bDirWS = cross(o.nDirWS, o.tDirWS) * i.tDirOS.w;
o.vDirWS = GetCameraPositionWS() - TransformObjectToWorld(i.posOS);
//uv
o.uv_Front = TRANSFORM_TEX(i.uv0, _FaceTex);
o.uv_Normal = TRANSFORM_TEX(i.uv0, _NormalMap);
o.uv0 = i.uv0;
return o;
}
half4 frag(v2f i) : SV_Target
{
//法线转换
half3 nDirTS = UnpackNormal(tex2D(_NormalMap, i.uv_Normal));
nDirTS.xy *= _NormalInt;
half3x3 TBN = half3x3(normalize(i.tDirWS), normalize(i.bDirWS), normalize(i.nDirWS));
//向量准备
Light light = GetMainLight();
half3 nDirWS = normalize(mul(nDirTS, TBN));
half3 lDirWS = light.direction;
half3 vDirWS = normalize(i.vDirWS);
half3 hDirWS = normalize(lDirWS + vDirWS);
//光照
half lambert = saturate(dot(nDirWS, lDirWS));
half blinn = lambert * pow(saturate(dot(nDirWS, hDirWS)), 1.0 / (_Rough*_Rough));
blinn = floor((_Terrace + 1) * blinn) / (_Terrace + 1);//色阶阶梯化
half3 specularCol = blinn * _SpecularCol;
//折射
half3 refDirTS = TransformWorldToTangent(refract(-vDirWS, nDirWS, _RefractRatio), TBN);//视向量反方向经折射后的切空间方向
float2 uv_Back = GetPosAnyPlaneCrossDir(half3(0, 0, -_Thick), half3(i.uv0, 0), half3(0,0,1), refDirTS);
half3 refractCol = tex2D(_InnerTex, _InnerTex_ST.xy * uv_Back + _InnerTex_ST.zw).rgb * _InnerCol;
//表层采样
half4 var_FaceTex = tex2D(_FaceTex, i.uv_Front);
half3 faceCol = var_FaceTex.rgb * _FaceCol.rgb;
half faceOpacity = var_FaceTex.a * _FaceCol.a;
//环境光
half fresnel = pow(1.0 - saturate(dot(nDirWS, vDirWS)), _FresnelPow);
half3 ambCol = fresnel * _AmbCol;
//混合
half3 finalCol = faceCol * faceOpacity + (1.0 - faceOpacity) * refractCol;
finalCol += specularCol + ambCol;
return half4(finalCol, 1.0);
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
如果比较细心的盆友可能会发现,其实前面在计算折射向量的时候是存在物理层面上的错误的。当时在计算折射向量时只考虑了法线贴图对法向量的扰动,而没有考虑法线贴图对应的表面起伏高度,取而代之的是统一用两层之间的间隔(即代码中的_Thick)来求交。进一步考虑,根据具体的起伏程度甚至可能发生多次折射的情况。
下面将对此做出一些小优化。不过优化方向并不是考虑表面起伏高度的算法,而是直接放弃折射的计算,转而采用一些障眼法来模拟折射的扭曲效果(没错,前面几节只是帮各位复习一下高中物理知识(雾))。在保证“看上去是那么回事”的基础上尽可能提升性能_(:з」∠)_(所谓做游戏效果渲染很大程度上就是在欺骗玩家的眼睛- -)
总的来说只需要两步:
1、因为在不考虑折射扭曲的前提下,里层的UV坐标变化是线性的,所以可以把有关射线和平面求交点(即里层的UV坐标)的计算全部放在顶点shader阶段,中间的空缺将会在光栅化阶段的线性插值中自动补齐。(绝大部分情况下,顶点阶段的性能开销是要低于像素阶段的。粗略的解释,因为屏幕上同一区域内的像素数一般总是比模型顶点数多的,而顶点/像素阶段的性能开销与顶点/像素的数量呈正相关)
2、使用烂大街的扭曲效果的套路,在像素阶段用法线贴图采样后的法向量的xy来扰动顶点阶段计算来的里层UV坐标,以此对里层纹理进行采样。
下面是采用上述优化方案的渲染效果。

原本的“折射率”参数在修改后的代码中负责调控扭曲强度。优化后的完整代码如下
//URP
Shader "Scene/Ice"
{
Properties
{
[Header(Texture)]
_FaceTex ("表层贴图", 2D) = "black" {}
_InnerTex ("里层贴图", 2D) = "white" {}
_NormalMap ("法线图", 2D) = "bump" {}
[Space(20)]
[Header(Color)]
_FaceCol ("表层色调", Color) = (1.0, 1.0, 1.0, 1.0)
_InnerCol ("里层色调", color) = (1.0, 1.0, 1.0, 1.0)
[HDR] _SpecularCol ("高光色", color) = (1.0, 1.0, 1.0, 1.0)
_AmbCol ("环境色", color) = (1.0, 1.0, 1.0, 1.0)
[Space(20)]
[Header(Material)]
_NormalInt ("法线强度", range(0, 10)) = 1.0
_Rough ("粗糙度", range(0.001, 1)) = 0.1
_FresnelPow ("菲涅尔次幂", range(1, 10)) = 5.0
_Terrace ("阶梯化次数", range(1, 5)) = 3
[Space(20)]
[Header(Refract)]
_RefractRatio ("折射率", range(0, 1)) = 0.5
_Thick ("层厚度", range(0, 5)) = 0.1
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
"Queue" = "Geometry"
}
Pass
{
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
CBUFFER_START(UnityPerMaterial)
//纹理
sampler2D _FaceTex; float4 _FaceTex_ST;
sampler2D _InnerTex; float4 _InnerTex_ST;
sampler2D _NormalMap; float4 _NormalMap_ST;
//颜色
half4 _FaceCol;
half3 _InnerCol;
half3 _SpecularCol;
half3 _AmbCol;
//材质
half _NormalInt;
half _Rough;
half _FresnelPow;
uint _Terrace;
//折射
half _RefractRatio;
half _Thick;
CBUFFER_END
//计算任意平面交点
float3 GetPosAnyPlaneCrossDir(float3 posPlane, float3 posRay, float3 nDirPlane, float3 nDirRay)
{
float3 deltaPos = posPlane - posRay;
float temp = dot(nDirPlane, deltaPos) / dot(nDirPlane, nDirRay);
return temp * nDirRay + posRay;
}
struct a2v
{
half4 posOS : POSITION;
float2 uv0 : TEXCOORD0;
half3 nDirOS : NORMAL;
half4 tDirOS : TANGENT;
};
struct v2f
{
half4 posCS : SV_POSITION;
float2 uv_Front : TEXCOORD0;
float2 uv_Normal : TEXCOORD1;
float2 uv_Back : TEXCOORD6;
half3 nDirWS : TEXCOORD2;
half3 tDirWS : TEXCOORD3;
half3 bDirWS : TEXCOORD4;
half3 vDirWS : TEXCOORD5;
};
v2f vert (a2v i)
{
v2f o;
//坐标
o.posCS = TransformObjectToHClip(i.posOS.xyz);
//向量
o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
o.tDirWS = TransformObjectToWorldDir(i.tDirOS.xyz);
o.bDirWS = cross(o.nDirWS, o.tDirWS) * i.tDirOS.w;
o.vDirWS = GetCameraPositionWS() - TransformObjectToWorld(i.posOS);
//uv
o.uv_Front = TRANSFORM_TEX(i.uv0, _FaceTex);
o.uv_Normal = TRANSFORM_TEX(i.uv0, _NormalMap);
//里层UV
half3x3 TBN = half3x3(normalize(o.tDirWS), normalize(o.bDirWS), normalize(o.nDirWS));
half3 vDirTS = TransformWorldToTangent(o.vDirWS, TBN);
o.uv_Back = GetPosAnyPlaneCrossDir(half3(0, 0, -_Thick), half3(i.uv0, 0), half3(0,0,1), -vDirTS);
o.uv_Back = _InnerTex_ST.xy * o.uv_Back + _InnerTex_ST.zw;
return o;
}
half4 frag(v2f i) : SV_Target
{
//法线转换
half3 nDirTS = UnpackNormal(tex2D(_NormalMap, i.uv_Normal));
float2 warpUV = nDirTS.xy;
nDirTS.xy *= _NormalInt;
half3x3 TBN = half3x3(normalize(i.tDirWS), normalize(i.bDirWS), normalize(i.nDirWS));
//向量准备
Light light = GetMainLight();
half3 nDirWS = normalize(mul(nDirTS, TBN));
half3 lDirWS = light.direction;
half3 vDirWS = normalize(i.vDirWS);
half3 hDirWS = normalize(lDirWS + vDirWS);
//光照
half lambert = saturate(dot(nDirWS, lDirWS));
half blinn = lambert * pow(saturate(dot(nDirWS, hDirWS)), 1.0 / (_Rough*_Rough));
blinn = floor((_Terrace + 1) * blinn) / (_Terrace + 1);//色阶阶梯化
half3 specularCol = blinn * _SpecularCol;
//折射
float2 uv_Back = i.uv_Back + _RefractRatio * warpUV;
half3 refractCol = tex2D(_InnerTex, uv_Back).rgb * _InnerCol;
//表层采样
half4 var_FaceTex = tex2D(_FaceTex, i.uv_Front);
half3 faceCol = var_FaceTex.rgb * _FaceCol.rgb;
half faceOpacity = var_FaceTex.a * _FaceCol.a;
//环境光
half fresnel = pow(1.0 - saturate(dot(nDirWS, vDirWS)), _FresnelPow);
half3 ambCol = fresnel * _AmbCol;
//混合
half3 finalCol = faceCol * faceOpacity + (1.0 - faceOpacity) * refractCol;
finalCol += specularCol + ambCol;
return half4(finalCol, 1.0);
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
更进一步,在这个算法的基础上其实还可以推广到不止2层时的情况,借此可以实现一些诸如“冰块中的悬浮物”这种效果(当然层数越多,采样次数越多,性能开销越大)或是在冰层之下有明显凹凸起伏的表面(在表层之下做视差算法)。但如果需要做折射扭曲的“里层”过于复杂,那还是老老实实用RenderTexture或者颜色缓冲之类的吧。