一、相关概念
SDF,百度下就知道它的名字,有向距离场。光从字面可能理解不了,如果我要说,它表示的是一种计算任意点到目标图形距离的方法,我想应该就都明了了。为何称为有向呢?由于点与物体关系无非就三种情况,在物体内,在物体表面,在物体外,有向即是通过这个距离对三种情况都能区别的表现。
Ray Marching,是模拟点在指定射线方向上不断行进,配合SDF检测碰撞的一种方法,具体接下来详细介绍。
二、具体原理解剖
1.SDF
对于计算任意点到某个物体的距离,目前针对更多的是一些规则图形,可参考iq大神的文章:
https://iquilezles.org/articles/distfunctions/
对于不规则的形状怎么办呢?我通过观察iq大神做的一些不规则物体的绘制,进行拆分,发现其创造不规则图形的方式其实是通过一些规则图形的叠加,任意两个相交的图形经过取并集都能形成一个不规则的图形,只是混合比例不一样罢了,万物的构成皆是如此。其原理推导和演示可在这里找到:
https://iquilezles.org/articles/smin/
2.Ray Marching
由于SDF得到的是点到物体的距离,所以Ray Marching做的也就是提供点了,不断地往前探,提供点去进行距离比对,如果小于或等于0,就说明当前点碰到了物体。那么有的同学就要说了,即使碰到了点又能怎样?碰到了点,我们可以着色啊!我们如果将片段着色器里的坐标作为射线方向的两个分量,那么做的工作其实是将物体投射到一个面上罢了,因此我们对它进行着色,如果同一个色,那么表现的就是一个带有颜色的影子。根据肉眼看东西的特点,我们看到的都是一个面,只是颜色不一导致的空间感罢了。因此对这个影子加入光照进行着色,他就会显得真实生动了,这就是Ray Marching成像的原理。
根据对Ray Marching方法的分析,需要给定一个最大距离和最小精度,理想上是这么干的,通过最小精度去递增,不断得到点,然后进行SDF比对。但是会有一个问题,精度过大或者过小都不行啊,过大,检测的距离不准确,可能错过,过小,检测次数过多,性能耗不起啊。
所以大神给的方法里面也考虑了这个问题,具体是这么干的:从起点就开始SDF检测距离,得到一个距离值,下一次直接步进这个距离值再进行检测,这样就省了距离值的步进次数,更快得到结果。那么有些同学又要问了,既然第一次能测到距离,我直接返回这个距离不就好了,为何要继续检测呢?起初,我也觉得纳闷,于是乎,我去尝试了下,会得到糊在一起的图像,就像这个:
边界很不清晰,大小远近甚至都有些不对,于是我开始将检测次数调大,比如调到128,我得到了这样的结果:
这才是正常的效果,差别实在是太大了。这是为什么呢?
因为代码中每次加的距离值都是上一次点到物体的最近距离,其方向与步进方向一般是不同的,因此每次测到的距离比实际距离基本都要小,所以只能通过多次迭代去无限接近,次数越多,误差越小。
3.计算碰撞点的法线
像前面所说,步进碰撞后得到的点的集合其实只是个影子范围,要进行着色才能更生动,而着色要模拟物体表面的样子,才能看出物体的轮廓,否则单色的话就只有个带颜色的影子了。我们之所以能看到物体有不同的轮廓,其实是其表面不平导致,因为表面不平,法线就不一样,环境光反射的颜色也就不一样了。因此这里要计算法线,通过法线控制每个位置的颜色强度以实现轮廓色差。对于法线的计算方法,我也是参考的iq大神的,这里有他的详细推导过程:
https://iquilezles.org/articles/normalsSDF/
三、代码实现
#version 430 core
#define PI 3.14159265359
precision highp float;
uniform sampler2D uTexture1;
in vec2 vTexPosition1;
out vec4 fragColor;
float sdCapsule( vec3 p, vec3 a, vec3 b, float r )
{
vec3 pa = p - a, ba = b - a;
float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
return length( pa - ba*h ) - r;
}
float map(vec3 pos)
{
//胶囊体与平面取并集
return min(pos.y - 0.3,sdCapsule(pos, vec3(0.0,0.0,0.0),vec3(1.0,0.8,0.5),0.2));
}
float RayMarch(vec3 ro, vec3 rd)
{
float Max_Steps = 128.0; //迭代次数
float Max_Dist = 10.0; //最大步进距离
float Surf_Dist = 0.001; //精度
float d0 = 0.;
for(int i = 0; i < Max_Steps; i++)
{
vec3 p = ro + rd*d0;
float ds = map(p);
d0+=ds;
if(d0>Max_Dist || ds < Surf_Dist)
break;
}
if( d0>Max_Dist ) d0=-1.0;
return d0;
}
vec3 calcNormal( in vec3 pos )
{
vec2 e = vec2(1.0,-1.0)*0.5773;
const float eps = 0.0005;
return normalize( e.xyy*map( pos + e.xyy*eps ) +
e.yyx*map( pos + e.yyx*eps ) +
e.yxy*map( pos + e.yxy*eps ) +
e.xxx*map( pos + e.xxx*eps ) );
}
void main()
{
vec2 textSize = textureSize(uTexture1,0);
vec4 nColor = vec4(0.0);
vec2 vpos = vTexPosition1 - 0.5;
float rate = textSize.y/textSize.x; //考虑视图尺寸
vec3 ro = vec3(0.0,1.0,5.0); //视点位置
float s_angXZ = PI / 6.0*vtime; //设置随时间转动角度
mat3 rotaXZ = mat3(cos(s_angXZ),0.0,sin(s_angXZ),
0.0,1.0,0.0,
-sin(s_angXZ),0.0,cos(s_angXZ));
ro *= rotaXZ; //绕y方向旋转
vec3 lookat = vec3(0.0); //相机看向原点
vec3 forward = normalize(lookat - ro); //相机朝向
vec3 right = normalize( cross(forward,vec3(0.0,1.0,0.0)));//相机右位置
vec3 up = normalize( cross(right,forward)); //相机上位置
vec3 rd = normalize(vpos.x*right+vpos.y*rate*up+forward); //根据屏幕坐标设置步进方向
float dist = RayMarch(ro,rd); //步进
vec3 col = vec3(0.0);
if(dist > -0.5) //根据需要过滤
{
vec3 pos = ro + dist*rd; //获得物体表面位置
vec3 nor = calcNormal(pos); //计算法线
float amb = clamp( 0.5+0.5*nor.y, 0.0, 1.0 ); //光方向与法线方向点积计算强度
col = vec3(0.0,0.3,0.3)*amb;
}
col = pow(col, vec3(0.4545)); //伽马矫正
nColor.rgb = col;
FragColor = nColor;
}
四、结尾
这里以SDF开始介绍,实际上是因为我学习过程中先接触的SDF,其实很多大佬都用射线求交的方式了,SDF更多用做多个图形混合了。Ray Marching很多人习惯地翻译为光线步进,更多是因为它后面还可以用来做阴影,反射等效果,而那才是通过光的方向步进的,本文还没有做那些,姑且翻译为射线步进吧。希望有大佬看到不吝指点一下!