图形混合
来来来,又开始 Raymarching 的知识学习了,这次来看看 Raymarching 的图像混合,这个基本知识点来源于之前我们学习的SDF的“布尔运算”。
上次我们做SDF图形好像还专门处理了3D和2D的区分?
那么对于图形混合而言,有一个好消息,那就是布尔运算不分3D还是2D都可以生效!
事不宜迟,把SDF 布尔运算相关的内容里的函数都搬出来吧。
比如我准备做一个大球一个小球的叠加态:
float map (vec3 p) {
float baseShape = sdShape(p - vec3(0., 0., -2.), 1.);
float littleSphere = sdShape(p - vec3(0., 1., -2.), 0.5);
float shape = opSmoothUnion(baseShape, littleSphere, .5);
return shape;
}把半径从 1 变成 0.5,再把位置往上平移 1。构造之后的结果是这样的:

这就是一个叠加之后的样子啦,类似于一个柚子!
我们可以给它添加一个 iTime 做成一个位移动画,观察动态混合的效果。
- 对小球进行修改,把 y 轴往上平移 0.8,并且加上正弦函数
sin对iTime的结果,因为sin的结果是正负规律循环的,所以我们再加上一个abs求绝对值,这样一来就可以达到我们想要的效果了:
float map (vec3 p) {
float baseShape = sdShape(p - vec3(0., 0., -2.), 1.);
float littleSphere = sdShape(p - vec3(0., .8 + abs(sin(iTime)), -2.), 0.5);
float shape = opSmoothUnion(baseShape, littleSphere, .5);
return shape;
}
其实,我们可以把这个看做是一个新的“建模”方法。
相信大家都听说过,如果有关注游戏的朋友们就更容易解释了,传统的建模,基本都会用到一个专有名词“面数”
指的是3D模型中组成该模型表面的多边形的数量。
一般来说,用的最多的也就是三角形网格进行的建模。而如果用Raymarching来进行建模,有这些优势:
- 你可以使用复杂的数学公式来创建复杂的图像,而不会受到三角形网格的限制。
Raymarching建模存储的是SDF函数的公式,而传统建模模型大小存储的是全部顶点的数据,公式的大小肯定比顶点数据的要小很多。
图形材质
我们通过map方法来存储了SDF函数的距离。尽管这样能够进行建模,但是物体的材质之间还是无法区分开来。我们再部署一个 plane 来看看吧:
根据 iq 的博客里的函数:
float sdPlane (vec3 p, vec3 n, float h) {
return dot(p,n) + h;
}我们把它写在 map 函数里:
float map (vec3 p) {
float baseShape = sdShape(p - vec3(0., 0., -2.), 1.);
float littleSphere = sdShape(p - vec3(0., .8 + abs(sin(iTime)), -2.), 0.5);
float mixedShape = opSmoothUnion(baseShape, littleSphere, .5);
float plane = sdPlane(p - vec3(0., -1., 0.), vec3(0., 1., 0.), .1);
float finalShape = opUnion(mixedShape, plane );
return finalShape;
}这里再次讲解一下:
对于 sdPlane 的具体参数
- 首先是第二个参数
n,这个是法向量,想必大家都很熟悉了,就是垂直于物体的向量,我们设置为vec3(0., 1., 0.)的意思就是法向量位于 y 轴,那么整个平面就应该是平行于xz平面 - 第三个参数
h就是偏移量,会在y轴上,距离原点 0.1 个单位的位置。 p - vec3(0., -1., 0.)是因为第三个参数h的意思就是相对于y轴往y轴正方向移动了一个单位,如果我们希望平面位于Y = 0的位置的话,那么p - vec3(0., -1., 0.)表示将点p向上平移 1 个单位(在Y轴上),以便新的平面看起来像是在Y = 0的位置。
所以说,现在就长这样了:
可以看到,中间部分的凹陷处有点奇怪,这个就是因为光线行进的还不够,有部分的地方并没有检测出来,我们可以进一步增加光线的步数
// for(int i=0;i<64;i++)
for(int i=0;i<256;i++)这样一来凹陷处的问题就解决了:
那么我们现在抛出问题:
平面和球体使用的是一个材质,我们该怎么做,把它们俩的材质分开呢?比如把地面改成白色的?
我的做法就是引入“材质编号”这个概念。
先在顶部 copy 一下opUnion这个函数的二维形式。
vec2 opUnion (vec2 d1, vec2 d2) {
return (d1.x < d2.x) ? d1 : d2;
}然后把map函数也改造成二维的,第一个参数代表的是距离,第二个参数代表的是材质:
vec2 map(vec3 p) {
vec2 d = vec2(1e10, 0.);
float baseShape = sdShape(p - vec3(0., 0., -2.), 1.);
float littleSphere = sdShape(p - vec3(0., .8 + abs(sin(iTime)), -2.), 0.5);
float mixedShape = opSmoothUnion(baseShape, littleSphere, .5);
// 给两个球的混合体设置为材质编号1.
d = opUnion(d, vec2(mixedShape, 1.));
float plane = sdPlane(p - vec3(0., -1., 0.), vec3(0., 1., 0.), 1.);
// 给平面设置为材质编号2.
d = opUnion(d, vec2(plane, 2.));
return d;
}calcNormal之前是直接获得map函数的距离的,而现在map函数的距离变成了第一维度,因此我们要取它的x维度来获取它的距离:
vec3 calcNormal(vec3 p) {
const float h = .0001;
const vec2 k = vec2(1., -1.);
return normalize(k.xyy * map(p + k.xyy * h).x
+ k.yyx * map(p + k.yyx * h).x
+ k.yxy * map(p + k.yxy * h).x
+ k.xxx * map(p + k.xxx * h).x);
}mainImage的Raymarching循环中,原先是直接获取map的距离d的,这次我们将map的结果存进res变量里,分别将距离和材质编号存入dist和material变量:
// float dist = map(p);
vec2 res = map(p);
float dist = res.x;
float material = res.y;
depth += dist;那么,有了材质编号material,我们就可以区分材质了:
if (dist < 0.001) {
// col = vec3(1.);
col = vec3(0.);
vec3 normal = calcNormal(p);
vec3 objectColor=vec3(1.);
vec3 lightColor=vec3(.875,.286,.333);
// 区分材质
if( material == 2.){
lightColor=vec3(1.);
}
...
}
如此一来,就成功地区分了球体和平面的材质了。
阴影
在一个健全的3D建模里,我们不能忽视一个非常重要的因素:阴影。
在Raymarching里,阴影就跟法向量是一样的,是可以算出来的。这个公式我们就不推导了,在iq的博客里是有提到的,直接拿来用吧:(注意函数里的map函数调用结果要取第一维度x,因为我们才把map改造成了具有第二维度材质的函数)
float softshadow( in vec3 ro, in vec3 rd, float mint, float maxt, float k )
{
float res = 1.0;
float t = mint;
for( int i=0; i<256 && t<maxt; i++ )
{
float h = map(ro + rd*t).x;
if( h<0.001 )
return 0.0;
res = min( res, k*h/t );
t += h;
}
return res;
}在漫反射光照部分,我们用softshadow函数计算出阴影shadow,并将它与下面的光照颜色相乘。
// 漫反射
vec3 lightPos=vec3(10., 10., 10.);
vec3 lightDir=normalize(lightPos-p);
float diff=dot(normal,lightDir);
diff=max(diff,0.);
vec3 diffuse=lightColor*diff;
float shadow = softshadow(p, lightDir, 0.1, 10., 16.);
col+=diffuse*objectColor*shadow;调整了一下光源的位置,从侧面照过来阴影更加明显:
抗锯齿
有没有发现,截图里的这个3D模型,锯齿感非常的严重?
我们接下来要对代码进行处理,让他来达成抗锯齿的效果。
我们把mainImage主函数的所有代码都提出来,放到另一个函数getSceneColor中,并且在主函数中调用这个函数获取渲染的结果。
vec3 getSceneColor(vec2 fragCoord){
vec2 uv=fragCoord/iResolution.xy;
uv=(uv-.5)*2.;
uv.x*=iResolution.x/iResolution.y;
vec3 ro=vec3(0.,0.,1.);
vec3 rd=normalize(vec3(uv,0.)-ro);
vec3 col=vec3(0.);
float depth=0.;
for(int i=0;i<256;i++){
...
if(dist < 0.01){
...
break;
}
}
return col;
}
void mainImage(out vec4 fragColor,in vec2 fragCoord){
vec3 tot=vec3(0.);
tot+=getSceneColor(fragCoord);
fragColor=vec4(tot,1.);
}在主函数内,我们要多次渲染场景,并且每次渲染的UV坐标带上一定的偏移量,这样就能起到抗锯齿的效果。
void mainImage(out vec4 fragColor,in vec2 fragCoord){
vec3 tot = vec3(0.);
float AA_size = 2.;
float count = 0.;
for (float aaY = 0.; aaY < AA_size; aaY++) {
for (float aaX = 0.; aaX < AA_size; aaX++) {
tot += getSceneColor(fragCoord + vec2(aaX, aaY) / AA_size);
count++;
}
}
tot /= count;
fragColor=vec4(tot,1.);
}
这样一来我们就达成了抗锯齿的效果了。
为什么增加渲染会起到抗锯齿的作用呢?说白了就是:
因为多次采样平均化能够让多个样本点的颜色平滑地混合在一起,把锯齿给模糊掉了
上述代码的步骤可以总结为:
- 循环遍历采样:代码通过两个嵌套的
for循环,分别在aaX和aaY方向上进行采样。每个循环内部偏移fragCoord,并调用getSceneColor函数计算该位置的颜色。 - 采样次数和范围:
AA_size变量控制每个像素区域内的采样数量。代码中AA_size=2.0,表示每个像素会进行2x2=4次采样。 - 累加和平均:将所有采样结果累加后,最终对采样结果进行平均处理,以此获得最终的颜色值。
这里实现了 2x2 超级采样抗锯齿(SSAA)。通过在每个像素区域内进行多次光线采样,超级采样抗锯齿技术能够有效减少图像中的锯齿现象,并平滑边缘,从而提高图像质量。
不过,这方法虽然效果显著,但会增加计算开销,因为每个像素的颜色都要通过多个样本的计算结果得出。
注意,这里的AA_size越大,抗锯齿的效果越好,但是要随时注意自己的电脑的GPU是否扛得住。