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

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

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

曾经冰材质困扰我比较长的时间,因为冰块不能简单用反射和折射模拟效果,也是广大 Shader 初学者一直困扰比较多的问题,美术渲染冰块的时候也不知道如何下手。
这里想关于冰块的材质展开来讲,里面也涉及到比较多的图形学知识点,要有一定的数学(高中水平)知识,我会尽量以简单易懂的角度来分析问题,希望能对大家有帮助。

课程案例工程文件

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

https://github.com/Kong2024/Unity_Learn_Ice

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

光线分析

本章节简单介绍下渲染方程,告诉大家渲染的基本原理,透过基本原理去分析光线的传播规律。

  渲染方程

1. 为什么要了解渲染方程?

渲染方程是科学地反映了光线在观测者、物体、光线之间的作用,我们必须从原理去了解渲染的本质,理论指导实践。
我们看到的这个花花世界,实质上是无数条光线进入眼睛的视网膜,而视网膜上有很多感应不同波长光线的细胞,从而在大脑上产生视觉。

视网膜上的感光细胞
而到达视网膜上的光线,在辐射度量学中用 Radiance(辐射率,用字母“L”表示)来表达此光线的能量。渲染就是要计算这些进入观测者眼睛的 Radiance,一个像素通常是表达对应 Radiance 的计算值。
渲染的目的就是要计算能够到达观察者眼睛的Radiance
2. 计算渲染点到观测者的 Radiance
如何计算渲染点到观测者的 Radiance,就成了研究渲染方程的关键。首先我们假设只有一个理想点光源,环境光为漆黑一片,这样渲染点 p 进入观测者眼睛的能量唯一贡献只有一个点光源。请看下图:

光线射向渲染点然后出射到观测者眼睛

我们可以得出进入观测者眼睛的 Radiance 公式:

N·ωi 是两个矢量的点乘,意思是入射方向在法线方向上的投影,Li(p,ωi) 是入射光线的能量(指向 p 点方向是 ωi 的入射光线)。直觉上告诉我们,当光线直射与斜射入表面,能量会不一样,例如太阳光入射地球不同纬度的表面,气温都会有非常明显的变化。

取自Games 101 闫令琦

而 N·ωi 两个矢量的点乘,就能反映入射角度与到达表面能量的变化,N·ωi 实质上是指 ωi 在 N 方向上的投影,显然当 ωi 直射表面的时候,投影长度最大;当 ωi 与 N 方向垂直时,投影长度为 0。

综上所述,Li(p,ωi)(N·ωi) 这物理量就能反映光源到达渲染点 p 的能量。前面再与一个函数 fr(p,ωi,ωr) 相乘,就能得到 Lo(p,ωo) 的值,而 fr 通常是指 BRDF(双向反射分布函数)。根据能量守恒法则,我们看到的 Lo(p,ωo),最大值是 Li(p,ωi)(N·ωi),这是镜面反射状态并且光线完全没有被吸收和折射的情况,因此 BRDF 的值会在 [0,1] 之间。根据不同的物质属性,光线到达表面会有不同情况的反射、折射、散射和吸收,反射出来的光线会有不同程度的损失,而 BRDF 通常就是我们通俗上讲的材质。

BRDF

现实环境不可能只有一个点光源,我们现在来研究多个光源情况下的Lo(p,ωo)值。

多光线入射到渲染点然后出射到观测者方向

此时 Lo(p,ωo) 就应该把所有方向的入射光线贡献加起上来,公司如下面所示:

3. 渲染方程

现实环境的光源可以认为是表面法线半球方向上有无限个连续的点光源(物体之间相互反弹的间接光也能传递能量,因此间接光源也认为是有贡献的光源),计算连续的量就不能简单用求和,需要用到积分。此时我们要计算 p 点进入观测者眼睛的 Radiance 就是所有半球方向 ωi 的定积分,再加上物体本来辐射出去的光线,就是渲染方程。

Lo(p,ωo) 是物体本身发出的光线,后面再加上所有半球方向的入射方向 ωi 的积分,就是渲染方程,而渲染基本上都是围绕这个方程去进行研究。

  冰材质的光线分析

1. 线可逆性质:

根据光线的可逆性质,我们可以认为光线是从观测者眼睛射出,然后把能到达的所有方向上的光源做一个相加或积分操作,其实这思路正是光线追踪的基本思路。

2. 冰材质的光线分析:

假设冰块的表面是光滑的,除了表面的反射和折射之外,冰块里面的杂质(气泡、裂缝、尘粒等),会把光线打散,散射到周围的环境中去,所以冰块中间是存在很多复杂的散射现象。

冰块中的散射现象

冰块的光线模型分析,假设冰块中的杂质是均匀分布的

假设光线从观测者射出,入射光线 ωi 的能量会分散到反射方向(ωrefle)、折射方向(ωrefra)、散射方向(ωd),因此思路是我们独立去计算反射、折射和散射方向的能量,再把它们加起来就行。

从实验可以看出入射光线的能量是反射光线与折射光线的能量加起上来

顺便提及一个知识点,上面所讲述的 BRDF 问题,其实只是单纯研究反射的问题,如果研究折射问题,就必须加上 BTDF(双向透射分布函数),BRDF 和 BTDF 加起来统称为 BSDF。

图中光线箭头的粗细就能反映光线能量的分布情况

3. 冰材质的反射与折射模拟思路:

反射与折射是最好模拟了,我们就通过一张 Cube Map 进行反射方向与折射方向的采样就行,效率也非常高,详细的方法将会在后续讲到。

4. 冰材质的散射模拟思路:

至于冰块里面的散射模拟,就没有那么简单,首先它涉及物体体积的问题,并不能简单通过表面计算。体积问题我们这里用物体厚度的思路来模拟,薄的地方散射能量弱,厚的地方散射能量强,而散射能量用环境光的平均颜色来模拟。

5. 最终结果:

最后我们把计算得出来的反射、折射、散射全部加起上来,就是我们想要的结果了。

建立 Shader 基本框架

本章节教大家构建最简单的 Shader,让大家简单了解渲染流水线是怎么一回事,并简单梳理下 Shader 的数据处理和管理方法。
  建立一个简单的 URP Unlit Shader
1. 参考 URP Unlit Shader
可以参考官方 URP 的 Unlit Shader,把没有用的 Properties 和 Pass 全部去掉,仅剩下一个 ForwardPass,hlsl 包含文件我们仅留存 Input 和 ForwardPass 文件,如下图:

2. Shader 文件

Shader "Unlit/Ice"{    Properties    {            }
SubShader { Tags {"RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue"="Geometry"}
Pass { Name "Unlit"
HLSLPROGRAM #pragma exclude_renderers gles gles3 glcore #pragma target 4.5 // -------------------------------------
#pragma vertex UnlitPassVertex #pragma fragment UnlitPassFragment
#include "IceInput.hlsl" #include "IceForwardPass.hlsl" ENDHLSL } }}
3. Input 包含文件
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
CBUFFER_START(UnityPerMaterial)
CBUFFER_END
4. ForwardPass 包含文件
struct Attributes{    float4 positionOS : POSITION;};
struct Varyings{ float4 positionCS : SV_POSITION;};
Varyings UnlitPassVertex(Attributes input){ Varyings output = (Varyings)0; VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); output.positionCS = vertexInput.positionCS; return output;}
half4 UnlitPassFragment(Varyings input) : SV_Target{ return half4(1,1,1,1);}
5. 得到最简单的 Shader
完成以上步骤,我们可以看到一个白色的 Shader,这基本上是最简单的 Shader 结构,我们在这基本结构上逐步加东西。

  构建 Shader 思路

1. 了解渲染流水线

对渲染流水线有个全局的了解能增加我们的全局思维,对每个细节的设计都能够清晰地定位是整个流程的哪个部分,帮助我们理解。

首先了解一下渲染流水线的大致过程:

渲染流水线 参考 Games 101 闫令琦

最初从应用程序里面,获取一系列的数据(物体空间的顶点数据、材质数据、变换矩阵、灯光数据等等);在顶点着色器,每个顶点数据通过逐顶点计算,得出经过变换的屏幕空间的顶点数据,此时的顶点数据依然是 3D 的,只不过变换了坐标系而已;接着在几何着色器组成三角形数据;紧接着就是光栅化阶段,此阶段把 3D 数据转换成 2D 数据,光栅化一般直接由硬件完成,不需要对其进行编程处理,之前的顶点数据通过三角形的重心坐标插值成片元数据,交给片元着色器进行逐像素计算;在片元着色器,计算是逐像素处理,一般纹理采样、颜色混合、深度剔除都在这里进行处理,片元着色器输出的是各种帧缓冲,一般不进行后处理的话会直接送给屏幕缓冲进行显示;最后阶段就是后处理,在这阶段是对各种缓冲区的数据进行计算并最终输出到屏幕缓冲。以上是大致流程,具体可以参考 Games 101 或者相关更详细的文献。

编写 Shader 实际上就是在渲染流水线中完成对顶点着色器和片元着色器的编程,从上述的基本 Shader 结构可以得知,顶点着色器对应函数 UnlitPassVertex, 片元着色器对应函数 UnlitPassFragment,接下来我们了解下这两个函数的输入和输出。

2. 建立数据结构体

在 Shader 中,有大量的 Position、Direction、纹理坐标等数据,相同的矢量数据还有不同坐标空间的区别,这使得编写函数参数的时候非常不方便,每次都要输入一大串参数和输出一大串参数。因此,建立数据结构体来管理输入输出参数非常有必要。

在 URP 的 Unlit Shader 中看到,顶点着色器的输入使用了结构体 Attributes,而顶点着色器的输出使用了结构体 Varyings。而在片元着色器,往往输入数据不能直接从 Varyings 得到,这是由于 Varyings 数据都是顶点数据通过三角形重心坐标插值而来,准确性存在比较大的偏差。

三角形中的任意一点的插值数值是三个顶点的数值通过重心坐标加权平均获得。

例如模型的表面法线信息,如果直接取 Varyings 的法线参数来计算高光,结果会非常不自然,这是由于插值后的法线信息,不再是归一化的方向信息,我们需要在片元着色器逐个像素归一化计算才能重新获得正确的法线信息。

直接从 Varyings 获取法线计算的高光和逐像素归一化法线后计算的高光

这就需要我们建立额外的结构体来管理片元函数的输入参数,在 URP 的 Lit Shader 是使用 IntputData 这个结构体,片元着色器需要的输入参数,我们都装在这个结构体里面。

建立一个新的包含文件 IceFn.hlsl,我们首先建立一个 InputData 结构体,在这里我们把这个 InputData 结构体命名为 IceInputData,避免和 URP 的 InputData 冲突。

3. IceFn 包含文件

struct IceInputData{};
void InitializeIceData(Varyings input, out IceInputData inputData){ inputData = (IceInputData)0;}

在定义结构体的后面接着定义一个初始化函数,用来初始化 IceInputData。

记得要在 ForwardPass 包含这个文件:

struct Attributes{    float4 positionOS : POSITION;};
struct Varyings{ float4 positionCS : SV_POSITION;};
#include "IceFn.hlsl"
到此为止,Ice Shader 的基本框架已经建立,接下来我们可以在这个框架上加入不同的函数来丰富整体效果,在此之前,我先简单了解下 Shader 中矢量的空间变换。
  Shader 中的矢量空间变换
1. 片元着色器输入数据 InputData
大致分为三类,位置、方向、纹理坐标。
位置通常为:CameraPosition、ObjectPosition、VertexPosition、ScreenPosition 等。
方向通常为:ViewDirection、CameraDirection、Normal 等。
纹理坐标:用来采样纹理,采样 Texture2D 用 float2,采样 CubeMap 或 Texture3D 用 float3,有时候为了达到特殊效果,位置和方向可以当做纹理坐标采样纹理使用。
2. 坐标空间变换
物体从模型网格到渲染到屏幕上,会经历一系列的坐标变换。以一个物体为例,顶点的位置通常是相对于物体坐标记录,但在最终在屏幕渲染,我们只关心屏幕像素的第几行第几列的像素是什么颜色,也就是所有数据都会转换成屏幕空间进行最后的渲染,这就会涉及到坐标空间变换。而矢量的计算,需要在相同坐标空间完成,例如绝大部分的矢量计算是在世界空间完成,因此物体坐标需要经过模型变换把顶点数据转换成世界空间,在这个空间完成绝大部分的矢量计算,再转换成屏幕空间在屏幕上显示。

模型从Local Space到Screen Space的变换

可以看到各种坐标空间的变换和 Matrix 矩阵有关,下面我们来看看 Unity 内部提供给我们有哪些矩阵。

Unity 提供的内置矩阵是 4x4 的矩阵,与矢量左乘(矩阵放左边)就能得到变换新坐标后的矢量数据。这里提一下矢量必须写成四维而不是三维,位置写成(x, y, z, 1),方向写成(x, y, z, 0),具体原理可以参考 Games 101 的齐次坐标矩阵变换。

例如模型空间的顶点位置变换到世界空间的顶点位置:

float3 positionWS = mul(UNITY_MATRIX_M, float4(positionOS, 1)).xyz;

例如模型空间的法线方向变换到相机空间的法线方向:

float3 normalVS = mul(UNITY_MATRIX_IT_MV, float4(normalOS, 0)).xyz;//或者更简单的写法float3 normalVS = mul((float3x3)UNITY_MATRIX_IT_MV, normalOS);
常规的空间变换可以使用 URP 提供的"Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl" 里面的方法,这里就不展开叙述了。
  细化 InputData
1. 首先我们先确定 InputData 需要什么参数
struct IceInputData{    // 当前相机的世界空间位置    float3 cameraPosition;    // 模型表面着色点的世界空间位置    float3 positionWS;    // 世界空间的视觉方向,模型着色点指向相机的方向    float3 viewDirectionWS;    // 切线空间的视觉方向,是viewDirectionWS的不同空间描述    float3 viewDirectionTS;    // 世界空间的顶点法线方向    float3 vertexNormalWS;    // 世界空间的像素法线方向(包含法线贴图信息)    float3 pixelNormalWS;    // 切线空间的法线方向    float3 normalTS;    // 系统的运行时间    float time;};
这里说明下法线方向为什么需要三个,顶点法线是计算物体厚度使用,不需要精确高频的法线信息;像素的法线方向用来求反射与折射,因此需求精确度比较高;切线空间的法线方向实质上就是法线贴图上的信息,计算裂缝的时候需要采样SDF图,而这部分计算是在切线空间完成。后续会详细讲到。
2. 接下来我们通过一个函数来获取 InputData
half3 BlendAngleCorrectedNormals(half3 baseNormal, half3 additionalNormal){    baseNormal.b += 1.0;    additionalNormal.rg *= -1.0;    half d = dot(baseNormal, additionalNormal);    half3 tempBase = baseNormal * d;    half3 tempAdd = additionalNormal * baseNormal.b;    return tempBase - tempAdd;}
void EffectNormal(float3x3 t2w, float2 uv, out half3 normalTS, out half3 normalWS){ normalTS = half3(0, 0, 1); #ifdef _USEGLASSNORMAL normalTS = UnpackNormal(tex2D(_GlassNormal, uv)); normalTS.rg *= _GlassNormalScale; normalTS = normalize(normalTS); #ifdef _USEDETAILNORMAL half3 detailNormalTS = UnpackNormal(tex2D(_DetailNormal, uv * _GlassDetailUVScale)); normalTS = BlendAngleCorrectedNormals(normalTS, detailNormalTS); normalTS = normalize(normalTS); #endif // _USEDETAILNORMAL #endif // _USEGLASSNORMAL normalWS = TransformTangentToWorld(normalTS, t2w);}
void InitializeIceData(Varyings input, out IceInputData inputData){ inputData = (IceInputData)0; // 切线空间到世界空间的转换矩阵,由于每个顶点的切线坐标不一样,因此无法通过Unity统一传递过来,需要每个顶点去计算 float3x3 t2w = float3x3(input.tangentWS.xyz, input.bitangentWS.xyz, input.normalWS.xyz); inputData.cameraPosition = GetCameraPositionWS(); inputData.positionWS = input.positionWS; // 在顶点着色器已经计算出来并储存在相关通道上 inputData.viewDirectionWS = normalize(float3(input.normalWS.w, input.tangentWS.w, input.bitangentWS.w)); inputData.viewDirectionTS = TransformWorldToTangent(inputData.viewDirectionWS, t2w); inputData.vertexNormalWS = normalize(input.normalWS.xyz); EffectNormal(t2w, input.uv0.zw, inputData.normalTS, inputData.pixelNormalWS); // _Time是Unity传递过来的时间参数,x:t/20 y:t z:t*2 w:t*3 inputData.time = _Time.x;}

详细可以参考源代码。

下一篇,我们将带来“反射”“折射”“散射”“合成”的开发步骤,敬请期待。

陈景光 2024 年度 Unity 价值专家提名人选。Unity 价值专家(UVP)是通过原创作品启发国内创作者的 Unity 专业人员,欢迎提名/自荐
https://unity.cn/uvp



阔别重逢,Unite 全球开发者大会终于回来了!

7月23日-25日,Unite 将于上海隆重开启1 场 Keynote 和多个专场,涵盖团结引擎、游戏生态、数字孪生、智能座舱等多重赛道,我们还准备了小而美的工作坊,覆盖小游戏、开源鸿蒙、Vision Pro 三大主题。

走过路过别错过。

扫码购票处

 点击“阅读原文”,学习完整课程 


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