|
体素化渲染是现代化渲染中重要的分支,也是发展比较快速的领域。它有传统顶点+面渲染无法比拟的真实性,但也有性能开销过大的一面,即使在用各种方式简化优化后,一般也只会用在pc/主机游戏伤。在3A游戏中属于标配。例如常见的体素云、体素雾、体素光,能很好的表达现实生活中云雾这种透明胶状的物体感。

游戏《Horizon: Zero Daw》(地平线)-体素云
学习过硬件的渲染api后,我们可以知道,正常渲染一个物体的方法,是传入预备好的顶点数据(VAO+VBO),然后通过顶点着色器处理过顶点数据,再经过像素着色器对三角面内的像素逐一处理。这个方式易懂高效,但是我们可以设想一下,如果我们想渲染一个立方体,如果摄像机进入到立方体内部,就会发现这个立方体是空心,四周依旧是面。而我们想渲染一个内部是实心的物体,点面渲染的方式就无法做到。比如我们想做一个空战游戏,飞机会在云中穿梭。如果用面的扰动来做一个外面看起来像云的物体,当飞机进入到云中的时候,就会穿帮。只能看到一个个面片。而体素化渲染就是为了解决这种特定问题而生的。
体素化渲染假定我们要渲染的不是一个个面,而是一个实心的物体。
(个人在学习的时候,购买了一个不错的资料,因为是收费的,就不放原文了,有条件的可以自行购买。The Unity Shaders Bible,作为入门资料极佳)
1.SDF
为了实现实时体素化渲染,我们必须借助硬件渲染api,因而本质上我们依旧是通过传入三角面数据来渲染,不过在算法思想上,采取体积化的概念。常用的算法有SDF。
SDF(Signed Distance Functions),有向向量场,有的教程中叫符号距离函数,我个人觉得有向向量场更为贴切。而利用其算法的渲染技术称之为Raymatch(步进射线,不同资料上的中译不同),它的思想很类似于我们编程中的递归。利用了极限的思想。

假设A为摄像机,B为要渲染的位置,而AB中间可能有障碍物,障碍物可能是半透,也可能是全遮蔽。而假定我们在已知一个点的坐标的时候,可以求出当前点的颜色值,那么我们要求的就是,当一束光从B点射向A后,最终得到的颜色。

不是很规范
如图所示,我们可以求出A到平面的距离d,以d为半径画一个圆,圆到B的交点作为A1,求出A1的色值,然后再求出A1到平面d1,再以d1为半径画一个圆,再得到焦点A2,以此不断类推,可以无限接近B点。我们设置一个阈值,比如类推次数或者半径的大小。将沿途所求得的色值进行叠加,就是最终所要的色值了。
乍看下来是不是觉得和体素化没什么联系。那接下来我们做一个示例,来做一个体素化渲染一个物体的横截面。
2.利用Raymatch渲染物体横截面
效果如下。

渲染球的横截面
https://www.zhihu.com/video/1565110133889146880
首先创建一个urp项目,用unity默认创建一个球体 Sphere,再新建一个unlit shader。
为了性能,我们先定义两个常量,MAX_STEP(最大步进次数)和MIN_Y_DIFF(最小到横截面的距离)。
#define MAX_STEP 30
#define MIN_Y_DIFF 0.001我们这里以物体空间坐标系下的Y轴做横截面,作为示例。
那么我们先定义变量Y轴横截面的Y坐标,横截面采样的纹理和纹理的伸缩值
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Y_CutOff("Y_CutOff",Range(-1,1)) = 1
_UVScale ("UV_Scale",float) = 1
}关键点来了,我们想渲染对应横截面上的点,是要通过渲染背面的像素来求出横截面的点的坐标。

A为摄像机点,当像素着色器渲染到B点时,我们已知AB点的坐标和P的Y坐标,利用SDF算法,从A点出发,不停的逼近P点,则可求出P的近似坐标。我们将P点的xz值作为uv值采样对应横截面贴图,即可渲染横截面。
因此,我们要屏蔽掉背部裁剪
Pass
{
...
Cull Off
...
}并且,在像素着色器中,也提供了很方便判断是否是背面的参数:
// FRONT_FACE_TYPE 在不同硬件api中定义不同值,具体可去D3D11.hlsl/GLES2.hlsl查看定义
half4 frag (v2f i,FRONT_FACE_TYPE face : SV_isFrontFace) : SV_Target
{
...
half4 col = IS_FRONT_VFACE(face,half4(1,1,1,1),CalBackColor(ro,rd)); // IS_FRONT_VFACE(face,正面色值,背面色值)
...
return col;
}首先,出发点是摄像机坐标,_WorldSpaceCameraPos;因为我们定义的是物体空间坐标系的Y值,所有将其转换到物体空间坐标系中。而射向的目标则是当前像素点的物体空间坐标。大于横截面Y值的部分,我们用硬透直接抛弃掉。
half4 frag (v2f i,FRONT_FACE_TYPE face : SV_isFrontFace) : SV_Target
{
// 射向目标
float3 pos = i.objPos.xyz / i.objPos.w;
// 出发点
float3 ro = TransformWorldToObject(_WorldSpaceCameraPos);
// 射向方向(归一化)
float3 rd = normalize(pos-ro);
if (pos.y > _Y_CutOff) {
discard;
}
half4 col = IS_FRONT_VFACE(face,half4(1,1,1,1),CalBackColor(ro,rd));// IS_FRONT_VFACE(face,正面色值,背面色值)
return col;
}下面我们来实现 CalBackColor
先定义一个sdf函数:
float planeSDF(in float3 pos,in float y)
{
return pos.y - y;
}该函数的作用是在已知坐标A和横截面Y的情况下,求出需要画的圆的半径。
下面就是循环步进。
float raymatch(in float3 ro,in float3 rd)
{
float t = 0;
for (int i = 0; i < MAX_STEP; i++) {
float3 pos = ro + rd * t;
if (abs(pos.y - _Y_CutOff) < MIN_Y_DIFF) {
break;
}
float p = planeSDF(pos,_Y_CutOff);
t += p;
}
return t;
}以上函数可以看出,每次都步进一个坐标(pos)距离横截面Y(_Y_CutOff)的距离作为半径(p)的圆。t即为所求的步进的总长度。当小于阈值MIN_Y_Diff的时候,中断循环。
下面就好算了。
float4 CalBackColor(in float3 ro,in float3 rd)
{
float t = raymatch(ro,rd);
float3 r_pos = ro + rd * t;
float4 col = tex2D(_MainTex, (r_pos.xz * _UVScale + 0.5));
return col;
}rd(射线方向)乘以t(步进的总长度),加上ro(出发点),即为横截面上的交点P的坐标或者近似坐标。拿坐标的xz值作为uv采样贴图,即可实现效果。
3.总结
可能有人会觉得为了实现这种效果,方法太过繁琐。确实,要实现类似效果,用一个三角函数计算p点即可。这么做唯一的好处就是摄像机处于球体内仍旧能保持效果不变。但一般项目中不会有这种需求。因此这种做法只是一种演示,不具备实用价值。真正的Raymatch使用一般还是在体素云/雾/光,这些特定场景下,这些情况下要如何实现,我们之后的教程一起探讨。 |
|