从原理到实践,手把手教你开发冰Shader(二)

文摘   2024-06-21 20:01   上海  
这篇文章来自 2024 年度 Unity 价值专家提名人选陈景光。陈景光是超次元技美总监,曾深度参与负责 B 站、腾讯、阿里、网易等企业相关的虚拟技术合作,主导渲染工作流设计、性能优化工作,为超过 20 个平台 S 级虚拟偶像演出项目提供视效服务。

点击阅读原文,可以访问陈景光在 Unity 中文课堂的个人主页,阅读更多技术干货。

上一篇,我们介绍了光线分析、Shader 框架(点击回看),本篇,继续揭晓“反射”“折射”“散射”“合成”的开发步骤。

课程案例工程文件

课程案例工程文件 GitHub 地址:

https://github.com/Kong2024/Unity_Learn_Ice

工程文件包含 Shader 源代码和 ShaderGraph 版本 Shader,对代码不熟悉的美术同学也可以通过 ShaderGraph 学习其原理。

反射

本章节通过采样 CubeMap 的方式实现反射效果,介绍 CubeMap 的基本工作原理,教大家如何计算反射方向和菲涅尔效果。

  CubeMap(立方体纹理)

1. 什么是 CubeMap

CubeMap 是一张由 6 个面组成的纹理图,它将 6 个纹理组合成一张单一的纹理,因此 CubeMap 本质上是属于 Texture2D,只是采样方式与 Texture2D 不同,CubeMap 和 Texture3D 是需要区分开来,虽然 CubeMap 和 Texture3D 都需要用三维向量来采样。

CubeMap的6个面


实际使用中,相当于把 CubeMap 折叠成一个立方体,画面朝向里面,摄像机摆在立方体的正中间,通过视觉方向来对 CubeMap 索引和采样。

CubeMap原理示意图


2. CubeMap 的采样
作为反射的采样,也就是我们要找出反射方向,通过反射方向来做索引采样,就能获得反射的效果。


反射采样原理图


  反射方向的计算

1. 反射方向分析

反射方向示意图


设 L 为入射单位向量,N 为物体表面单位法向量,R 为反射单位向量,N' 是入射方向 L 在法线 N 上的投影,由于 L 和 N 方向夹角大于 90 度,点乘结果是负数,因此点乘的时候 L 方向需要取反。
则 N' = (-L · N)N
通过矢量加减法则很容易得出 2N' = R - L =〉 R = 2N' + L
最后计算出 R 方向,直接用归一化的 R 进行索引采样就可以了。
2. 代码实现
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;}
_ReflectionTexture 是材质 Property 的 Texture2D,另外 hlsl 提供了可以直接获取反射方向的函数 reflect,这样可以让代码更简单一些:
half4 EffectReflection(IceInputData inputData){    half4 color = texCUBE(_ReflectionTexture, reflect(-inputData.viewDirectionWS, inputData.pixelNormalWS));
return color;}

单纯采样 CubeMap 出来的效果:

可以看到反射出来的光线很平均,没有任何的衰减,因此接下来我们需要加入 BRDF 函数
  反射BRDF的计算
1. 菲涅尔公式
对于冰块的反射,我们认为是镜面反射,不存在 Glossy 反射,那么 BRDF 项我们单纯考虑光线在两种介质中传播时反射和折射的规律就可以了,而描述这个规律的公式就是菲涅尔公式。

菲涅尔公式

参考出处:

https://zhuanlan.zhihu.com/p/480405520
由于菲涅尔公式考虑了垂直偏振光和水平偏振光的情况,不太适用于图形学,图形学大多情况是采用石里克近似,我们就用这个公式来计算反射的 BRDF。
菲涅尔公式的石里克近似,ηi是入射介质的折射率,ηt是出射介质的折射率

现实生活中的反射现象 Games101:闫令琪

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 是用来模拟光线吸收 color.rgb *= fresnel * _ReflectionColor.rgb; color.a = fresnel; return color;}
加入 BRDF 函数后的效果:

加入菲涅尔公式的镜面反射,效果自然多了

折射

本章节教大家如何计算折射方向,通过折射定律推导三维空间的折射方向。

  折射方向的计算

1. 折射方向分析

在反射章节已经分析过 CubeMap 如何采样,现在计算折射只需要找到折射方向,再用折射方向去采样 CubeMap 就可以了。但是折射方向在三维空间并没有反射那么简单。先画图分析:

折射方向示意图

公式推导最麻烦,我们直接上结果:


推导过程参考:
https://www.cnblogs.com/night-ride-depart/p/7429618.html
ω 是空气折射率与水折射率的比值,cosθi 可以写成入射方向 L 与法线 N 的点乘,由于 L 与 N 的夹角大于 90 度,因此点乘结果是负数,需要再乘以 -1。
2. 代码实现
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);}
我们用 hlsl 自带的求折射方向的 refract 函数计算,简化代码:
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);}
单纯采样 CubeMap 出来的折射效果:

同样折射出来的光线很平均,没有衰减,因此之后需要继续加BTDF函数
3. 模拟光的色散现象
在现实中,光在不同介质的折射率会根据波长不同而有所区别,一般查表得出的折射率是可见光(360-400nm ~ 760-830nm)比较靠中间的波长 589nm 的折射率,如果考虑 RGB 不同波长的光的折射率,就会产生光的色散现象。 

从图得知光的波长越大,折射率越小,因此红光偏移角度是最小的


我们查出红光在水中的折射率 nr = 1.3311,绿光在水中的折射率 ng = 1.3371,蓝光在水中的折射率 nb = 1.3428。
代码实现:
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);}

最终色散效果:

色散经验效果

3. 透射函数 BTDF 的计算
这个很好算,因为菲涅尔公司的结果就是光线的折射与反射所占总光能的比例,假如光线只有反射和折射,没有其他的损失,那么 BTDF 的结果就是 1 减去之前计算出来的菲涅尔。
由于之前反射的时候已经计算过菲涅尔,我们不需要在折射函数里面计算菲涅尔,从反射函数得出结果的 a 通道获取即可。下面是片元函数的展示:
  透射函数 BTDF 的计算
这个很好算,因为菲涅尔公司的结果就是光线的折射与反射所占总光能的比例,假如光线只有反射和折射,没有其他的损失,那么 BTDF 的结果就是 1 减去之前计算出来的菲涅尔。
由于之前反射的时候已经计算过菲涅尔,我们不需要在折射函数里面计算菲涅尔,从反射函数得出结果的 a 通道获取即可。下面是片元函数的展示:
half4 UnlitPassFragment(Varyings input) : SV_Target{    IceInputData inputData;    InitializeIceData(input, inputData);    half4 finalColor = 1;    half4 reflection = EffectReflection(inputData);    finalColor.rgb = EffectRefraction(inputData).rgb * (1 - reflection.a);
return finalColor;}

考虑 BTDF 的折射效果

散射

本章节是全课程最难的一章,计算裂缝和杂质使用了比较多的数学算法,其中包括 SDF、Ray Raymarching、视差偏移、射线与物体相交等。

  分析

1. 裂缝

冰的裂缝

可以看到裂缝从接近冰表面的时候最深色,沿着表面法线方向往里面生长,颜色越来越浅浅,慢慢消失。这种现象已经脱离了物体表面,但是我们 Shader 的计算都是在表面进行,这就需要用一种手段来模拟效果,后面再展开。
2. 杂质(小颗粒)
假如冰块完全由水分子组成,那么通常情况下是透明的。但实际上即使是超纯水,也会产生不透明的冰块,这是因为水中溶解的气体,由于温度降低,挤压出来的气体无法逃逸,就会在冰块内部形成空腔。 

冰块中的气泡

假设环境光照均匀,杂质在冰块中也是均匀分布,那么杂质颜色乘以视觉归一化的物体厚度即可。
  裂缝
1. SDF(有向距离场)
有向距离场 Signed Distance Field,简称 SDF。顾名思义,SDF 是一个场,场内的每一个点,表达了距离最近物体的距离,由于分物体内和物体外,因此距离值区分正负,因此命名为有向距离场。
简单举一个 2D SDF 的例子,在 2D 平面空间中存在一个长方形,我们用这个长方形生成 SDF 图,具体如下图:

原图

2D SDF 图

2D 平面内的每一个点记录一个浮点值,刚好接触到物体的地方是 0,物体外部是记录正数的距离量,物体内部是记录负数的距离量,SDF 图通常只记录 [-1,1] 区间的距离,归一化之后是 [0,1] 的区间取值,0.5 的数值就是刚好接触物体的地方,[0.5,1] 是物体内部,[0,0.5] 是物体外部。
2. SDF Raymarching 算法
以相机位置作为起始点,往场景发射一条射线,场景中通过 SDF 记录了空间中每一点到物体的最近距离,射线采用步进方式,每次步进的距离为当前点到物体表面的最近距离,即函数 y = SDF(p) 的值,假如当点 p 到物体距离为 y<0(或者设定一个阈值),代表光线击中了物体表面,光线停止步进。这样就能得出 Shading Point 的数值,此算法被广泛应用于隐式几何体的渲染,大家所熟悉的 Shader Toy 就是用 Raymarching 作为根基的。

SDF Raymarching 算法原理图

射线先从原点 O 出发,以当前 O 点的 SDF 值沿射线方向 Dir 步进,到达 P1 点,由于每次步进的距离是 SDF,因此步进距离是安全的,确保射线不会穿过物体;接着在 P1 点继续往 Dir 方向步进,这次步进的距离是 P1 点的 SDF,到达 P2 点......如此类推,只要步进次数足够多,一定能够到达 Shading Point。
伪代码:
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 计算的方法后续课程再提供。

通过一张无缝的裂缝纹理,生成一张裂缝 SDF 图,图片的每个像素记录了距离裂缝的最近距离

裂缝视觉方向分析图

所有计算是在切线空间完成,因此 V 方向必须是在切线空间的,其中 Vxy 是 V 方向在物体表面的投影,-kVxy 是 Vxy 的相反方向。从上面分析图可以看出,V 的反向延长方向是 -kV,假设 -kV 能与裂缝相交,那么 -kVxy 就是在物体表面 P 点到裂缝位置的最短距离,现在我们要计算这个距离值。
代码实现:
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;}
相比于之前的伪代码,这段计算全部在物体表面完成,是属于在切线空间的二维计算,SDF 值通过物体表面的位置(UV 坐标)采样 SDF 图获得。

通过视线方向计算物体表面每个像素点到裂缝的最短距离

到目前为止,已经很接近我们想要的裂缝效果,只是想办法把颜色反转过来,并控制下能看到的最大深度就行。方法很简单,用 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)的深度相减得出厚度数据,但是实际效果不理想,渲染凹物体会有不连续的效果。

物体前后深度相减,凹物体会出现不和谐的不连续效果

反正遇到 Shader 计算体积问题都是大难题,这里只能再一次使用经验模型,这里介绍一种通过物体顶点法线连初略估算厚度的方法。前面讲过,最初计算厚度的思路是射线与物体相交,而其中射线与球体相交是最简单的,下面通过射线与球体相交衍生出通过物体表面模拟物体厚度的方法。
模拟物体厚度示意图

假设视线射向物体表面一点 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;}
上述代码是最基本的求射线相交物体的厚度函数,但输入过于复杂,不直观,我们需要转化成简单的通过一个半径系数和物体缩放能控制整体的厚度,先看下在 Shader 里如何获取物体的缩放参数。
原理很简单,设定一个归一化的方向向量,把它从物体空间转换成世界空间,再求这个方向向量的长度,这样就得出物体的缩放参数,当物体缩小时厚度变小,物体放大时厚度变大,这样就能非常方便地控制物体厚度数值。
在 Unity URP 的源代码中,SpaceTransforms.hlsl 有这样一个函数:

只要把 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;}
5. 雾
厚度不能直接渲染出来,必须通过转换成光的能量才能被渲染,现在我们引入雾这个参数量,因为雾越厚,光线散射就越厉害,雾与杂质的基本色相乘,那么就是最终杂质的效果了。
根据 D3D 的 Fog 模型,得出通过厚度和密度得出雾的函数。
D3DFog:
https://learn.microsoft.com/en-us/windows/win32/direct3d9/d3dfogmode
代码:
/////////////// Fogfloat 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的函数图像

工具网站:

https://zh.numberempire.com

6. 最终杂质效果

// Thicknessfloat thickness = ShapeVolume(inputData);thickness = max(0, thickness);
// Foghalf fog = ExponentialDensity(thickness + _FogBase, _FogDensity, _UseFogExp2);fog = 1 - saturate(fog);
// Dust Colorhalf4 dustColor = 1;dustColor.rgb = EffectDust(input, inputData) + _FogColor.rgb;dustColor.rgb *= fog;dustColor.a = fog;
杂质的最终效果

  散射最终效果

1. AlphaBlending

要把两个颜色混合起来,简单加起来会越加越亮,所以要在两个颜色前面加一个混合因子。

a 和 b 就称为混合因子,当 a 和 b 取不同值,就会有不同的混合效果,通常 a+b=1

现在我们来看看 AlphaBlending 的公式:

这里采用的是预相乘模式,即是在参加混合之前,Alphasrc * Colorsrc 已经提前完成,因此混合因子 a = 1, b = (1 - Alphasrc)

代码:

// Premultiply Modehalf4 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;    dustColor.rgb = EffectDust(input, inputData) + _FogColor.rgb;    dustColor.rgb *= fog;    dustColor.a = fog;        // Cracks Color    float depthCracks = EffectCracks(input, inputData);    half4 cracksColor = _CracksColor;    cracksColor.a *= depthCracks;    cracksColor.rgb *= cracksColor.a;        // Blend Cracks and Dust    half4 cracksDustColor = AlphaBlending(cracksColor, dustColor);        return cracksDustColor;}
效果:

裂缝与杂质颜色混合

3. 散射颜色

裂缝与杂质颜色再加一层表面的颜色细节,就完成了整个散射合成部分。

代码:

////////////////// Glasshalf4 EffectGlass(Varyings input){    half glassAlpha = tex2D(_GlassTexture, input.uv0.xy).r * _GlassColor.a;    half4 glassColor = half4(_GlassColor.rgb * glassAlpha, glassAlpha);        return glassColor;}
//////////////////// Diffusehalf4 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; dustColor.rgb = EffectDust(input, inputData) + _FogColor.rgb; dustColor.rgb *= fog; dustColor.a = fog; // Cracks Color float depthCracks = EffectCracks(input, inputData); half4 cracksColor = _CracksColor; cracksColor.a *= depthCracks; cracksColor.rgb *= cracksColor.a; // Blend Cracks and Dust half4 cracksDustColor = AlphaBlending(cracksColor, dustColor); // Glass half4 glassColor = EffectGlass(input); // Diffuse half4 diffuse = AlphaBlending(glassColor, cracksDustColor); return diffuse;}
效果:
最终散射颜色,表面与次表面多层效果叠加,显得非常有层次感

应用在模型的近距离细节效果

合成

本章会把散射、反射、折射三个结果混合,并告诉大家拓展 Shader 的思路。
  散射、反射、折射三色混合
反射和折射有考虑到 BRDF 与 BTDF,可以直接加上去,可是散射怎么考虑BRDF?散射只是简单的收集周围环境光的模拟模型,并没有考虑任何的衰减。但从能量守恒的角度去考虑,入射光线减去反射的那部分能量,就是散射与折射的那部分能量,也就是说散射与折射加起来的能量是恒等的,散射强折射就弱,折射强散射就弱。那么混合散射与折射的时候就不能直接相加,可以用 AlphaBlending 来混合。最后加上反射的颜色就大功告成了!
代码:
// 最终合成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;}
最终效果:

散射、反射、折射三色混合效果

  材质主要参数说明
1. FogColor

控制雾的厚度和颜色能够使冰块在清晰与浑浊之间调整效果。

薄雾到厚雾的效果过渡,冰块变得浑浊,雾越厚越不容易看到折射光线

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中文课堂学习完整课程 


Unity官方开发者服务平台
Unity引擎官方开发者服务平台,分享技术干货、学习课程、产品信息、前沿案例、活动资讯、直播信息等内容。
 最新文章