想实现类似ShaderToy的3D效果?或则不想通过任何顶点数据建立各种复杂的3D形状(如体积云,体积雾等)?那么一起开启RayMarching系列吧。
介绍
相信玩过ShaderToy的伙伴都对上图印象深刻,除了上图所呈现的效果外,ShaderToy的绝大多数3D效果都使用到了RayMarching技术,该算法通常与Signed distance functions(有符号距离函数,简称SDF)结合来实现各种炫酷的效果。
原理
在开始RayMarching原理分析之前,我们不得不先分析下有符号距离函数的概念,因为有符号距离函数是RayMarching算法中创建3D形状最通用的方式。
有符号距离函数
为了更细致的分析有符号距离函数(简称为SDF),我们假设空间中有一球体,同时空间中还存在另一点A,当点A在球体内部时,则A点到球心的距离小于球的半径,即length(A) - r < 0;当点A在球体表面上时,则A点到球心的距离等于球的半径,即length(A) - r = 0; 当点A远离球体表面时,则A点到球心的距离大于球的半径,即length(A) - r > 0;上述的表述可用以下公式表示(假设球体的半径为1):
float sphereSDF(float3 p) { return length(p) - 1.0; }
上式是球体的SDF公式,更多的SDF公式可查阅SDF距离函数。
RayMarching算法
通过上述SDF的简单分析后我们已经有了建立各种基础形状的SDF函数,那么我们如何渲染他们呢?
RayMarching翻译为中文的意思为光线行进,要介绍它就需要先介绍下RayTracing(光线追踪),从字面上看这类型的技术与光线相关,在现实世界中光线的行进方向从光源开始,在经过一系列的反射,散射或折射的过程后进入眼睛。但是在计算机世界中,如果要从光源位置开始计算光线经过的一系列路径,最后进入人眼的全过程,会变得异常复杂,因为所有行进的光线不会全部进入人眼,这往往增加了巨大的工作量,因为我们最终想要的仅仅是被光源影响后的直接或间接光照后的结果图像,那么为了避免无谓的运算,通过从眼睛或相机发出射线来反方向追踪光线就变成了最直接的处理方式。
上图是来自维基百科RayTracing的图示,与RayTracing一样,我们为相机选择一个位置,在相机前放置一个网格,通过网格中的每个点发送来自相机的射线,每个网格点对应于输出图像中的一个像素。在光线行进中,整个场景是根据SDF函数定义的。为了找到视线和场景之间的交点,我们从摄影机开始,沿着视线一点一点地移动。在每个步骤中,我们会获取点与物体中心的距离,直到获取到的点与物体中心的距离小于物体表面到物体中心的距离时,代表此时的点已经在物体的内部了。反之就将沿着射线不断增加最大数量。
实验
- 新建Unlit着色器后,在片元着色器中定义相机位置与行进方向。
// 定义球体SDF,半径为1
float sdfSphere(float3 p) {
return length(p) - 1;
}
// 获取当前行进点与物体中心的距离
float getDist(float3 p) {
float sdf = sdfSphere(p);
return sdf;
}
fixed4 startRay(float3 ro, float3 rd, fixed4 col) {
float dStart = 0;
float dSphere;
fixed4 resultCol = fixed4(1.0,1.0,1.0,1.0);
for(int i = 0; i <= _MaxDist; i++) {
// 当行进距离超出了最大距离时返回背景色
if (dStart > _MaxDist) {
resultCol = col;
break;
}
// 从相机位置开始行进
float3 p = ro + rd * dStart;
dSphere = getDist(p);
dStart += dSphere;
// 当行进点与物体中心距离小于半径时,表示已经碰触到了物体
// 小于0.01表示接近于物体表面
if(dSphere < 0.01) {
resultCol = fixed4(1,1,1,1);
break;
}
}
return resultCol;
}
float3 getRayDir(float2 uv) {
// uv坐标原点移至屏幕中心,uv范围为-0.5~0.5
float2 coord = uv - 0.5;
// 使画面的x方向不至于拉伸
coord.x *= screenSize.x / screenSize.y;
return normalize(float3(coord.x, coord.y, 1));
}
fixed4 frag (v2f i) : SV_Target {
// 建立网格,确立行进方向
float3 rd = getRayDir(i.uv);
// 相机位置
float3 rayOri = _WorldSpaceCameraPos;
fixed4 col = tex2D(_MainTex, i.uv);
fixed4 result = startRay(rayOri, rd, col);
return result;
}
- 新建C#脚本,传递屏幕尺寸。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class RayMarching : MonoBehaviour {
public Material mat;
private void OnRenderImage(RenderTexture src, RenderTexture dest) {
if (!mat) {
Graphics.Blit(src, dest);
return;
}
mat.SetVector("screenSize", new Vector2(Screen.currentResolution.width, Screen.currentResolution.height));
Graphics.Blit(src, dest, _mat);
}
}
- 此时在屏幕上输出的结果图像如下:
注意,上面那个白色圆形,确实是个球,为了证明它是个球,下一篇将会引入基础的光照模型,除了这些我们还发现转动相机并没有改变模型的位置,显然是一个bug,下篇实验也会对此进行分析并提出解决方案。我们可以试着在场景中增加一个Cube,发现白色球体一直在立方体前方,这些优化的点,将会在后续的RayMarching系列实验中一并解决。
如果觉得有用,就用微信扫描下面的二维码,关注我们共同交流吧。