点击阅读原文,可以访问陈景光在 Unity 中文课堂的个人主页,阅读更多技术干货。
上一篇,我们介绍了光线分析、Shader 框架(点击回看),本篇,继续揭晓“反射”“折射”“散射”“合成”的开发步骤。
课程案例工程文件
课程案例工程文件 GitHub 地址:
工程文件包含 Shader 源代码和 ShaderGraph 版本 Shader,对代码不熟悉的美术同学也可以通过 ShaderGraph 学习其原理。
反射
本章节通过采样 CubeMap 的方式实现反射效果,介绍 CubeMap 的基本工作原理,教大家如何计算反射方向和菲涅尔效果。
CubeMap(立方体纹理)
1. 什么是 CubeMap
CubeMap的6个面
实际使用中,相当于把 CubeMap 折叠成一个立方体,画面朝向里面,摄像机摆在立方体的正中间,通过视觉方向来对 CubeMap 索引和采样。
CubeMap原理示意图
反射采样原理图
反射方向的计算
1. 反射方向分析
反射方向示意图
half4 EffectReflection(IceInputData inputData)
{
入射方向
half3 inputDir = -inputData.viewDirectionWS;
反射方向
half3 refDir = dot(-inputDir, inputData.vertexNormalWS) * inputData.vertexNormalWS;
refDir = 2 * refDir + inputDir;
采样CubeMap
half4 color = texCUBE(_ReflectionTexture, refDir);
return color;
}
half4 EffectReflection(IceInputData inputData)
{
half4 color = texCUBE(_ReflectionTexture, reflect(-inputData.viewDirectionWS, inputData.pixelNormalWS));
return color;
}
单纯采样 CubeMap 出来的效果:
菲涅尔公式
参考出处:
2. 代码实现
half4 EffectReflection(IceInputData inputData)
{
half4 color = texCUBE(_ReflectionTexture, reflect(-inputData.viewDirectionWS, inputData.pixelNormalWS));
Fresnel
((n1 - n2)/(n1 + n2))^2,n1是空气的折射率1.0,n2是冰的折射率1.333
half R0 = 0.02;
cosθ,可以转换成出射方向与法线方向的点乘
half fresnel = dot(inputData.viewDirectionWS, inputData.pixelNormalWS);
fresnel = 1 - max(0, fresnel);
fresnel = pow(fresnel, 5);
fresnel = R0 + (1 - R0) * fresnel;
_ReflectionColor 是用来模拟光线吸收
*= fresnel * _ReflectionColor.rgb;
fresnel; =
return color;
}
折射
本章节教大家如何计算折射方向,通过折射定律推导三维空间的折射方向。
折射方向的计算
1. 折射方向分析
在反射章节已经分析过 CubeMap 如何采样,现在计算折射只需要找到折射方向,再用折射方向去采样 CubeMap 就可以了。但是折射方向在三维空间并没有反射那么简单。先画图分析:
公式推导最麻烦,我们直接上结果:
half4 EffectRefraction(IceInputData inputData)
{
// n1/n2
float w = 0.75;
float cosi = dot(inputData.pixelNormalWS, inputData.viewDirectionWS);
float cost = 1.0 - w * w * (1.0 - cosi * cosi);
cost = sqrt(cost);
// 计算出折射角
float3 refractDir = w * (-inputData.viewDirectionWS) + inputData.pixelNormalWS * (w * cosi - cost);
// 采样CubeMap
half3 color = texCUBE(_ReflectionTexture, refractDir).rgb;
// 模拟颜色吸收,乘以一个折射颜色
color *= _RefractionColor.rgb;
return half4(color, 1.0);
}
half4 EffectRefraction(IceInputData inputData)
{
n1/n2
float w = 0.75;
float3 L = -inputData.viewDirectionWS;
float3 N = inputData.pixelNormalWS;
采样CubeMap
half3 color = texCUBE(_ReflectionTexture, refract(L, N, w)).rgb;
模拟颜色吸收,乘以一个折射颜色
color *= _RefractionColor.rgb;
return half4(color, 1.0);
}
half4 EffectRefraction(IceInputData inputData)
{
n1/nr
float wr = 0.7513;
n1/ng
float wg = 0.7479;
n1/nb
float wb = 0.7447;
float3 L = -inputData.viewDirectionWS;
float3 N = inputData.pixelNormalWS;
half3 refractR = texCUBE(_ReflectionTexture, refract(L, N, wr)).rgb;
half3 refractG = texCUBE(_ReflectionTexture, refract(L, N, wg)).rgb;
half3 refractB = texCUBE(_ReflectionTexture, refract(L, N, wb)).rgb;
half3 color = half3(refractR.r, refractG.g, refractB.b);
模拟颜色吸收,乘以一个折射颜色
color *= _RefractionColor.rgb;
return half4(color, 1.0);
}
好像和之前的差别不大
没错,水和冰的色散效果确实不明显,以上计算只是让大家了解色散的原理,下面我们就为 RGB 折射率乘上一个经验系数吧,毕竟最终好看就行。
GB 折射率乘以经验系数:
half4 EffectRefraction(IceInputData inputData)
{
n1/n2
float w = 0.75;
float3 L = -inputData.viewDirectionWS;
float3 N = inputData.pixelNormalWS;
half3 refractR = texCUBE(_ReflectionTexture, refract(L, N, w)).rgb;
half3 refractG = texCUBE(_ReflectionTexture, refract(L, N, w * 0.975)).rgb;
half3 refractB = texCUBE(_ReflectionTexture, refract(L, N, w * 0.95)).rgb;
half3 color = half3(refractR.r, refractG.g, refractB.b);
模拟颜色吸收,乘以一个折射颜色
color *= _RefractionColor.rgb;
return half4(color, 1.0);
}
最终色散效果:
色散经验效果
half4 UnlitPassFragment(Varyings input) : SV_Target
{
IceInputData inputData;
inputData);
half4 finalColor = 1;
half4 reflection = EffectReflection(inputData);
EffectRefraction(inputData).rgb * (1 - reflection.a); =
return finalColor;
}
散射
本章节是全课程最难的一章,计算裂缝和杂质使用了比较多的数学算法,其中包括 SDF、Ray Raymarching、视差偏移、射线与物体相交等。
分析
1. 裂缝
冰的裂缝
冰块中的气泡
原图
2D SDF 图
SDF Raymarching 算法原理图
float Raymarching(float3 rayOri, float3 rayDir, float maxDepth, int maxIteration)
{
// 沿着射线方向移动的距离
float t = 0;
// 在有效步进数内展开循环
for(int i = 0; i < maxIteration; i++)
{
// 当超过最大步进距离则退出
if (t > maxDepth) break;
// 计算当前 p 点的位置
float3 p = rayOri + rayDir * t;
// 获取当前 p 点的 SDF 值
float d = SDF(p);
// 当 SDF 值少于等于0说明射线已经击中物体,退出循环
if (d <= 0) break;
// 更新移动距离
t += d;
}
// 返回移动距离
return t;
}
3. 裂缝具体实现方法
裂缝我们采用上述的 SDF Raymarching 算法实现,首先要获得记录物体表面裂缝的 SDF 图,SDF 计算的方法后续课程再提供。
裂缝视觉方向分析图
half SDF_Raymarching(sampler2D tex, float2 baseUV, float2 offsetUV, int numSteps, half stepSize, half edgeWidth)
{
// 起始点
float2 p = baseUV;
// 归一化后获得方向
float2 dir = normalize(offsetUV);
// 起始点到终点的距离
float value = 0.0;
for (int i = 0; i < numSteps && i < 10; i++)
{
// 通过采样 SDF 图求得当前 p 点与最近裂缝距离
// 循环内只能对纹理的单一 MipMap 等级采样
float distance = 1 - tex2Dlod(tex, float4(p, 0,0)).r;
// 加入 edgeWidth 微调宽度效果
distance -= edgeWidth;
// 当距离少于0,说明已经击中物体,退出循环
if (distance < 0.0)
break;
// 不为0的时候,记录当前 t ,更新 p 点位置
float t = distance * stepSize;
p += dir * t;
value += t;
}
return value;
}
通过视线方向计算物体表面每个像素点到裂缝的最短距离
到目前为止,已经很接近我们想要的裂缝效果,只是想办法把颜色反转过来,并控制下能看到的最大深度就行。方法很简单,用 V 在物体表面的投影长度减去上面计算出来的那个裂缝最短距离就行(|Vxy| - |kVxy|)。
代码实现:
half EffectCracks(Varyings input, IceInputData inputData)
{
baseUV 在切线方向上做一些法线的扰动
float2 baseUV = input.uv + inputData.normalTS.xy * _CracksDistortion;
-Vxy V在物体表面的投影方向的反方向,乘以_CracksHeight做高度微调
float2 offsetUV = -inputData.viewDirectionTS.xy * _CracksHeight;
int numSteps = _CracksDepthIterations;
half stepSize = _CracksDepthStepSize;
half edgeWidth = _CracksWidth;
half depthCracks = SDF_Raymarching(_CracksSDFTexture, baseUV, offsetUV, numSteps, stepSize, edgeWidth);
viewDirectionTS 在物体表面的投影长度 减去 SDF_Raymarching 的距离
depthCracks = length(offsetUV) - depthCracks;
depthCracks *= _CracksDepthScale;
depthCracks = saturate(depthCracks);
return depthCracks;
}
裂缝的最终效果
杂质
1. 基本色
杂质会涉及体积问题,假如杂质是在物体内部均匀分布,那么传统单纯从纹理坐标采样杂质纹理,是不足以模拟效果,有没有一种简单的办法模拟体积感?
我们假设物体有两层,分别是表面层和次表面层,表面层的计算最简单,直接纹理坐标采样。而次表面层,其实可以看作是通过纹理坐标加视觉偏移值就能模拟出纵深感。用这种思路,只要控制不同的视觉偏移去多次采样,就能模拟多层次表面层,模拟出体积感。
视觉偏移产生次表面视觉效果原理图
Vxy 是 V 方向在物体表面的投影,看图得知我们需要反方向(-Vxy)偏移,k 是偏移系数,取值范围 k > 0,0 的时候没有偏移,就是表面层的效果,当 k 取值越大的时候,次表层的深度就越深。当然次表面层越多,采样次数就越高,这个案例我们用两层足够,下面来看代码实现:
half3 EffectDust(Varyings input, IceInputData inputData)
{
基本UV加入时间参数模拟流动效果
float2 dustUV = input.uv + inputData.time * _DustTextureUVAnim.xy;
视觉偏移增加 _DustDepthShift 参数作为 k 系数
float2 viewUV = -inputData.viewDirectionTS.xy * _DustDepthShift;
Layer1, _DustLayerBetween 作为两个 Layer 之间的偏差参数控制
float2 uv1 = dustUV + viewUV * _DustLayerBetween;
float3 color1 = tex2D(_DustTexture, uv1).rgb;
Layer2
float2 uv2 = dustUV + viewUV;
float3 color2 = tex2D(_DustTexture, uv2).rgb;
FinalColor
float3 finalColor = color1 * color1 + color2 * color2;
finalColor *= _DustColor.rgb;
return finalColor;
}
模拟杂质的效果
2. 厚度
第一章光线分析的时候就已经讲过,假设冰块里面的杂质是均匀分布,那么光线是与物体厚度有关联。计算物体厚度的思路是通过射线求物体交点,假设 A 是射线进入物体的点,B 是射线射出物体的点,那么 AB 的距离就是物体的厚度。
渲染物体厚度思路图
但在 Shader 里面,显式几何体非常难计算交点。我曾经想过通过计算物体背面(Back)的深度存储在深度缓冲,在计算物体 Shader 的时候再用采样刚刚深度缓冲的深度,与前面(Front)的深度相减得出厚度数据,但是实际效果不理想,渲染凹物体会有不连续的效果。
物体前后深度相减,凹物体会出现不和谐的不连续效果
假设视线射向物体表面一点 P,在 P 点沿法线相反方向距离 R 的一点作为球心,作一个球体,因此球体的半径是 R,球体、物体表面、射线三者相交于 P 点,射线经过球体的距离为物体 P 点的厚度。有点像菲涅尔,都是和视线与物体法线方向的函数,只不过输入控制的参数不一样,针对的使用环境也不一样。
3. 射线与球体相交
// t = -b ± sqrt(b^2 - c)
void IntersectionShere(float3 rayOrigin, float3 rayDirection, float3 position, float radius,
out float frontDist, out float backDist, out float thickness)
{
float3 oc = rayOrigin - position;
float b = dot(oc, rayDirection);
float c = dot(oc, oc) - radius * radius;
float h = b * b - c;
h = sqrt(h);
frontDist = -b - h;
backDist = -b + h;
thickness = h * 2.0;
}
参考出处:
https://zhuanlan.zhihu.com/p/405042067
4. 计算厚度代码
根据射线与球体相交的原理,我们可以得出计算厚度的代码:
// 从球体相交衍生出来的方法
void IntersectionMesh(float3 rayOrigin, float3 rayDirection, float radius, IceInputData inputData, out float thickness)
{
// Shading Point 沿法线反方向以半径 radius 长度偏移,得出球心
float3 offsetPosWS = inputData.positionWS - inputData.vertexNormalWS * radius;
float3 oc = rayOrigin - offsetPosWS;
float b = dot(rayDirection, oc);
float c = dot(oc, oc) - radius * radius;
float h = b * b - c;
h = sqrt(h);
thickness = h * 2.0;
}
只要把 doNormalize 设置成 false,就能输出非归一化的世界方向,这时候再求长度就能得到物体缩放参数。
// 获取物体缩放值
float3 ObjectScale()
{
float3 output = 0;
float3 worldDir = TransformObjectToWorldDir(float3(1,0,0), false);
output.x = length(worldDir);
worldDir = TransformObjectToWorldDir(float3(0,1,0), false);
output.y = length(worldDir);
worldDir = TransformObjectToWorldDir(float3(0,0,1), false);
output.z = length(worldDir);
return output;
}
// 最终求物体厚度的函数
float ShapeVolume(IceInputData inputData)
{
float3 rayOrigin = inputData.cameraPosition;
float3 rayDirection = -inputData.viewDirectionWS;
float radius = _ShapeSphereRadius * ObjectScale().x;
float thickness = 0;
IntersectionMesh(rayOrigin, rayDirection, radius, inputData, thickness);
return thickness;
}
/////////////// Fog
float ExponentialDensity(float depth, float density, bool useExp2)
{
// D3DFOG_EXP f = 1/ e ^ (depth * density)
// D3DFOG_EXP2 f = 1/ e ^ ((depth * density)^2)
float value = depth * density;
value = useExp2 ? value * value : value;
value = pow(2.718, value);
value = 1 / value;
// 深度小于0则跳过
if (depth <= 0) value = 1;
return value;
}
通过函数图像可以得知函数值在 [0,1] 之间,因为厚度和密度只能是 >0 的正数。
D3DFog的函数图像
6. 最终杂质效果
Thickness
float thickness = ShapeVolume(inputData);
thickness = max(0, thickness);
Fog
half fog = ExponentialDensity(thickness + _FogBase, _FogDensity, _UseFogExp2);
fog = 1 - saturate(fog);
Dust Color
half4 dustColor = 1;
EffectDust(input, inputData) + _FogColor.rgb; =
*= fog;
fog; =
散射最终效果
1. AlphaBlending
要把两个颜色混合起来,简单加起来会越加越亮,所以要在两个颜色前面加一个混合因子。
a 和 b 就称为混合因子,当 a 和 b 取不同值,就会有不同的混合效果,通常 a+b=1
现在我们来看看 AlphaBlending 的公式:
这里采用的是预相乘模式,即是在参加混合之前,Alphasrc * Colorsrc 已经提前完成,因此混合因子 a = 1, b = (1 - Alphasrc)
代码:
Premultiply Mode
half4 AlphaBlending(half4 srcColor, half4 dstColor)
{
half4 finalColor;
half oneMinutesAlpha = 1 - srcColor.a;
finalColor = srcColor + oneMinutesAlpha * dstColor;
return finalColor;
}
2. 混合裂缝与杂质颜色:
代码:
half4 DiffuseEffect(Varyings input, IceInputData inputData)
{
Thickness
float thickness = ShapeVolume(inputData);
thickness = max(0, thickness);
Fog
half fog = ExponentialDensity(thickness + _FogBase, _FogDensity, _UseFogExp2);
fog = 1 - saturate(fog);
Dust Color
half4 dustColor = 1;
EffectDust(input, inputData) + _FogColor.rgb; =
*= fog;
fog; =
Cracks Color
float depthCracks = EffectCracks(input, inputData);
half4 cracksColor = _CracksColor;
*= depthCracks;
*= cracksColor.a;
Blend Cracks and Dust
half4 cracksDustColor = AlphaBlending(cracksColor, dustColor);
return cracksDustColor;
}
3. 散射颜色
裂缝与杂质颜色再加一层表面的颜色细节,就完成了整个散射合成部分。
代码:
Glass
half4 EffectGlass(Varyings input)
{
half glassAlpha = tex2D(_GlassTexture, input.uv0.xy).r * _GlassColor.a;
half4 glassColor = half4(_GlassColor.rgb * glassAlpha, glassAlpha);
return glassColor;
}
Diffuse
half4 DiffuseEffect(Varyings input, IceInputData inputData)
{
Thickness
float thickness = ShapeVolume(inputData);
thickness = max(0, thickness);
Fog
half fog = ExponentialDensity(thickness + _FogBase, _FogDensity, _UseFogExp2);
fog = 1 - saturate(fog);
Dust Color
half4 dustColor = 1;
EffectDust(input, inputData) + _FogColor.rgb; =
*= fog;
fog; =
Cracks Color
float depthCracks = EffectCracks(input, inputData);
half4 cracksColor = _CracksColor;
*= depthCracks;
*= cracksColor.a;
Blend Cracks and Dust
half4 cracksDustColor = AlphaBlending(cracksColor, dustColor);
Glass
half4 glassColor = EffectGlass(input);
Diffuse
half4 diffuse = AlphaBlending(glassColor, cracksDustColor);
return diffuse;
}
合成
// 最终合成
void CombinedEffects(Varyings input, IceInputData inputData, out half3 color)
{
// Diffuse
half4 diffuse = DiffuseEffect(input, inputData);
// Reflection
half4 reflection = EffectReflection(inputData);
// Refraction
half4 refraction = EffectRefraction(inputData) * (1-reflection.a);
// FinalColor
color = AlphaBlending(diffuse, refraction).rgb;
color += reflection.rgb;
}
散射、反射、折射三色混合效果
控制雾的厚度和颜色能够使冰块在清晰与浑浊之间调整效果。
薄雾到厚雾的效果过渡,冰块变得浑浊,雾越厚越不容易看到折射光线
2. Cracks
控制裂缝的形态和颜色深度,Cracks Color 黑色裂缝消失,Cracks Color 白色裂缝最明显,其余参数调整裂缝的形态。
3. Glass
控制表面细节,包括表面的颜色、法线和细节法线。
4. Reflection 与 Refraction
折射与反射共用一张 CubeMap,换不同的 CubeMap 会有不一样的效果。
尝试换不同的 CubeMap 呈现不同的效果
拓展
此案例提供的是基本的思路和原理,各位同学可以根据自己的需要拓展源代码。例如加入灯光参数去控制效果,通过雾的强弱和 Shading Point 到点光源的距离模拟更真实的光影变化;CubeMap 接入场景的 Reflection Probes 与环境产生更细致的光影交互;折射可通过采样 Opaque Texture 能与环境有更好的过渡……可以拓展的思路很多,等着各位同学去发掘了。
陈景光是 2024 年度 Unity 价值专家提名人选。Unity 价值专家(UVP)是通过原创作品启发国内创作者的 Unity 专业人员,请点击这里提名/自荐。
阔别重逢,Unite 全球开发者大会终于回来了!
7月23日-25日,Unite 将于上海隆重开启。1 场 Keynote 和多个专场,涵盖团结引擎、游戏生态、数字孪生、智能座舱等多重赛道,我们还准备了小而美的工作坊,覆盖小游戏、开源鸿蒙、Vision Pro 三大主题。
走过路过别错过。
点击“阅读原文”,去Unity中文课堂学习完整课程