图形混合
来来来,又开始 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是否扛得住。