前言:在做项目的时候遇到了要使用光线步进实现特定效果,就简单的学习了一下,如果有写的不对的地方请多指教。
参考:
深度相关以及屏幕射线插值重建:blog.csdn.net/puppet_master/article/details/77489948.
光线步进理解:
blog.csdn.net/qq_38275140/article/details/91049023
https://www.jianshu.com/p/46e161b911dd
SDF:
https://www.iquilezles.org/www/articles/distfunctions/distfunctions.htm
视频讲解光线步进:
https://www.youtube.com/watch?v=oPnft4z9iJs&list=PL3POsQzaCw53iK_EhOYR39h1J9Lvg-m-g
概念
光线步进和光线追踪类似,逆向追踪的思想,基于后处理,从屏幕发射射线,然后求射线和物体的交点。通过SDF判断是否到达物体表面以及得到每次前进步长。
和光追的不同
光线追踪:
1、视角方向发射射线
2、一次性算出射线与场景最近物体交点
3、获取交点材质颜色
4、若材质包含反射或折射,则改变射线方向
5、寻找下一个交点,即重复2(直到找不到交点或达到最大追踪次数)。
光线步进:
1、视角方向发射射线
2、一步步(SDF返回值控制前进距离级判定是否到达物体表面)的前进,不断的向交点趋近(受到最大步长以及最大步进次数限制)
3、计算交点的材质颜色
4、若材质包含反射或折射,则改变射线方向
5、步进寻找下一个交点,即重复2(直到达到光线限定最大距离或达到最大迭代次数)。
ray marching的独到之处:
为什么要有Raymarching
利用Ray Marching渲染软阴影、虚焦、漫反射、环境光遮蔽等比传统光线跟踪更为方便快捷,在实时渲染的情况下可以获得不错的图像质量。
目前,Ray Marching的应用主要局限于volume field的渲染上:例如在医学图像处理中,将CT机上获得的若干断层照片渲染成三维模型;或是渲染云彩、火焰、烟雾等效果。Ray Marching的机理使得我们可以对volume field做采样,以离散值的叠加逼近一团连续体积场的外观。
光线步进
屏幕射线插值方式重建
首先我们要得到,摄像机(这里使用的是scene摄像机)在每个像素的射线发出方向。
平面的图
上图中,A为相机位置,G为空间中我们要重建的一点,那么该点的世界坐标为A(worldPos) + 向量AG,我们要做的就是求得向量AG即可。根据三角形相似的原理,三角形AGH相似于三角形AFC,则得到AH / AC = AG /
AF。由于三角形相似就是比例关系,所以我们可以把AH /AC看做01区间的比值,那么AC就相当于远裁剪面距离,即为1,AH就是我们深度图采样后变换到01区间的深度值,即Linear01Depth的结果d。那么,AG= AF * d。所以下一步就是求AF,即求出相机到屏幕空间每个像素点对应的射线方向。看到上面的立体图,其实我们可以根据相机的各种参数,求得视锥体对应四个边界射线的值,这个操作在vertex阶段进行,由于我们的后处理实际上就是渲染了一个Quad,上下左右四个顶点,把这个射线传递给pixel阶段时,就会自动进行插值计算,也就是说在顶点阶段的方向值到pixel阶段就变成了逐像素的射线方向。
那么我们要求的其实就相当于AB这条向量的值,以上下平面为例,三维向量只比二维多一个维度,我们已知远裁剪面距离F,相机的三个方向(相机transform.forward,.right,.up),AB = AC+ CB,|BC| = tan(0.5fov) * |AC|,|AC|= Far,AC = transorm.forward * Far,CB = transform.up * tan(0.5fov) * Far。
因为只求方向就可,所以在下面的代码中令 far = 1;
//返回一个矩阵,分别表示四个点的向量,在shader里插值后可以得到各像素点的方向
private Matrix4x4 CamFrustum(Camera cam)
{
Matrix4x4 frustum = Matrix4x4.identity;
float fov = Mathf.Tan((cam.fieldOfView*0.5f)*Mathf.Deg2Rad);
Vector3 goUp = Vector3.up * fov;
Vector3 goRight = Vector3.right * fov * cam.aspect;
Vector3 TL = (-Vector3.forward - goRight + goUp);
Vector3 TR = (-Vector3.forward + goRight + goUp);
Vector3 BR = (-Vector3.forward + goRight - goUp);
Vector3 BL = (-Vector3.forward - goRight - goUp);
//顺序为左上,右上,右下,左下。
frustum.SetRow(0, TL);
frustum.SetRow(1, TR);
frustum.SetRow(2, BR);
frustum.SetRow(3, BL);
return frustum;
}
传入shader,在顶点函数中计算好,4个顶点对应的向量,通过插值我们就可以在片元函数中得到每个像素的方向了。
使用GL把shader的输出渲染在一个Quad上面,这里就需要对齐(通过GL.Vertex3的z与视锥体行号对应来存储每个顶点对应的index值)
RenderTexture.active = destination;
_raymarchMaterial.SetTexture("_MainTex", source);
// Graphics.Blit(source, destination, _raymarchMaterial);
GL.PushMatrix();
GL.LoadOrtho();
_raymarchMaterial.SetPass(0);
GL.Begin(GL.QUADS);
//BL
GL.MultiTexCoord2(0, 0.0f, 0.0f);
GL.Vertex3(0.0f, 0.0f, 3.0f);
//BR
GL.MultiTexCoord2(0, 1.0f, 0.0f);
GL.Vertex3(1.0f, 0.0f, 2.0f);
//TR
GL.MultiTexCoord2(0, 1.0f, 1.0f);
GL.Vertex3(1.0f, 1.0f, 1.0f);
//TL
GL.MultiTexCoord2(0, 0.0f, 1.0f);
GL.Vertex3(0.0f, 1.0f, 0.0f);
GL.End();
GL.PopMatrix();
}
不使用GL也是可以的,将GL注释掉,在GL.PushMatrix()上面加上Graphics.Blit(source, destination, _raymarchMaterial);,
顶点着色器中也可以使用int index=(int)dot(v.uv,float2(1,2));
或用x、y来判断对应视锥体的哪个角。)
v2f vert (appdata v)
{
v2f o;
half index = v.vertex.z;
v.vertex.z = 0;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.ray = _CamFrustum[(int)index].xyz;
/*int index =0; //这样写也行
if (v.vertex.x < 0.5 && v.vertex.y > 0.5)
index = 0;
else if (v.vertex.x > 0.5 && v.vertex.y > 0.5)
index = 1;
else if(v.vertex.x > 0.5 && v.vertex.y < 0.5)
index = 2;
else if(v.vertex.x < 0.5 && v.vertex.y < 0.5)
index =3;
v.vertex.z = 0;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.ray = _CamFrustum[index].xyz;*/
o.ray /= abs(o.ray.z);
o.ray = mul(_CamToWorld,o.ray);
return o;
}
SDF(signed distance functions)
首先来介绍一下距离场(distance field):
距离场是三维空间中一个标量场,任意一点的数值代表离该点最近的模型表面的点的距离下界。通过距离场可以很方便地估计光线应当前进的幅度:只需让光线前进当前点距离场大小的数值即可,即所谓Sphere Tracing。
我们可以看个最简单的球例子:
传入的参数 p代表光线的“头”现在所在的位置,_sphere1.xyz代表球心的位置,w代表球的半径。
用向量的长度 – 球的半径得到 光线该点距离球表面的最近距离。
当距离场由多个物体构成,会返回union,取小值,依然是得到离模型最近的距离值。
SDF(signed distance functions)有很多写好的,也包括bool运算,平滑并交差集等,可以直接用,https://www.iquilezles.org/www/articles/distfunctions/distfunctions.htm
步进过程
搬运一张图:
在上图可以看到,每次前进的都是最新的点到距离场的最短距离,因为这个最短距离没有小于我们设定的判断阈值(判断光线到了物体表面),故一直走,走到最右边正方体表面停下。
fixed4 raymarching(float3 ro,float3 rd,float depth) //ro 射线初始位置,rd射线前进方向,depth在深度图采样得到
{
fixed4 result = fixed4(1,1,1,1);
const int max_iteration = _MaxIterations; //规定光线最多走几步
float t = 0; //光线走的长度
for(int i = 0; i<max_iteration;i++)
{
if(t>_maxDistance || t>= depth) //最大边界 || 有物体遮挡
{
result = fixed4(rd,0);
break;
}
float3 p =ro +rd*t; //当前位置, rayOrigin + rayDirection*t
float d = distanceField(p);
if(d<_Accuracy) //当走到物体表面时,_Accuracy为阈值,越小越精确,一般0.1 - 0.001之间。
{
float3 nor = getNormal(p);
float s = Shading(p,nor);
result = fixed4(_mainColor.rgb*s,1);
break;
}
t+=d;//没走到表面,加上距离场返回的距离值,走下一步。
}
return result;
}
深度
在视空间,每个顶点的原始的Z值是视空间的深度,但是经过透视投影变换以及透视投影,转化到屏幕空间后,需要保证在屏幕空间的深度与1/z成正比才可以在屏幕空间逐像素地进行插值进而获得屏幕上任意一点像素的屏幕空间深度值,简单来说,这个转化的过程主要是为了从顶点数据获得屏幕空间任意一点的逐像素数据。而得到屏幕空间深度之后,我们要使用时,经过变换的这个屏幕空间的东西,又不是很直观,最直观的还是视空间的深度,所以我们要进行一步变换,把屏幕空间的深度再转换回原始的视空间深度。
这个深度是float,为什么还要乘i.ray向量的长度呢?
图中问号dist,为点到摄像机的欧式距离,是我们要求的,求出来后能在步进过程中 和射线所走长度比较来判断是否存在遮挡。
Near是近裁剪面的深度,这里为方便看做了1。
|TL|为摄像机到近裁剪面某一点的距离,即length(i.ray)。
阴影
硬阴影:朝光线方向再来一次光线步进,如果撞到了物体就说明光线被该物体挡住了,自身位于阴影中。
软阴影:考虑当阴影射线没有击中任何物体,但是非常接近的时候会发生什么,阴影下的半影。离击中物体的距离越近(h),让它的颜色越深;距离阴影的点越近(离阴影越近说明t,射线所走的距离越大),也就越暗。
注意result的初始值为1,代表没有阴影。
如图所示,t表示射线沿着光线方向(向上的)走的路程,而h是每走一步和物体的距离,简单地为前进过程中的每一个步长计算一个半影因子h/t,然后取所有半影中最暗的那个。k值是阴影的软化程度,在这里我们也可以知晓其实它就是加速h/t趋向于1,值越大,k*h/t就会越快的趋近1,阴影也就越锐利。
除以sinSun 是为了消除k=1时,整个地面都变暗的情况(远处虽然不受阴影影响,但min(k*h/t)为步进刚开始时的最小值,即光线与地面夹角的sin值。)
AO
特别的地方就在于是步进过程中是固定步长,不像之前每次加上到物体的最短距离。
float AmbientOcclusion(float3 p ,float3 n)
{
float step = _AoStepsize; //每次前进的步长,这里使用固定步长
float ao = 0.0;
float dist;
for(int i=1;i<=_AoIterations;i++)
{
dist = step*i; //如果附近没有其他物体 dist<DistanceField 最终结果为负数并截为0 返回值=1 即无环境光遮蔽
//如果附近有物体,那么法线步进就会靠近该物体 从而dist>DistanceField 结果大于0,返回值<1
ao += max(0.0,(dist - distanceField(p + n*dist).w)/dist);
}
return(1.0 - ao*_AoIntensity);
}
反射
对于场景中的物体:
场景物体的反射我们利用反射探针来完成,因为是imageEffect,所以内置的unity_SpecCube0无法正常配置,我们需要手动把光照探针的贴图传进去。
因为我的场景只有个天空盒,就直接传入的天空的cubemap。
对于距离场物体:
在开始计算距离场反射之前我们先让距离场物体能够有自己的颜色。
思路很简单,我们让距离场函数返回float4类型,xyz存储颜色,w存储距离
要改动的地方较多,首先所有调用DistanceField地方取值都要加上.w ,还有距离场的各个操作函数也要适应float4类型。
并对raymarching函数传入参数,返回值进行修改,使其内部不在调用shading,只是单纯的来判断有没有到表面。
bool raymarching(float3 ro,float3 rd,float depth,float maxDistance,int maxIterations,inout float3 p,inout fixed3 dColor) //ro 射线初始位置,rd射线前进方向,depth在深度图采样得到
{ //maxIterations规定光线最多走几步
float t = 0; //光线走的长度 //inout 类似引用,保存碰撞点的位置、颜色信息
bool hit;
for(int i = 0; i<maxIterations;i++)
{
if(t>_maxDistance || t>= depth) //最大边界 || 有物体遮挡
{
hit = false;
break;
}
p =ro +rd*t; //当前位置, rayOrigin + rayDirection*t
float4 d = distanceField(p);
if(d.w <_Accuracy) //当走到物体表面时,_Accuracy为阈值,越小越精确,一般0.1 - 0.001之间。
{
dColor = d.rgb;
hit = true;
break;
}
t += d.w; //没走到表面,加上距离场返回的距离值,走下一步。
}
return hit;
}
反射也是根据反射方向进行光线步进。
结尾
主要是根据各位大佬的总结加入了一些个人的理解,确实挺有意思的。
工程放在了这里(跟着视频做的):
https://github.com/ZhanYang-Feng/Raymarching.git