图形混合

来来来,又开始 Raymarching 的知识学习了,这次来看看 Raymarching 的图像混合,这个基本知识点来源于之前我们学习的SDF的“布尔运算”。

上次我们做SDF图形好像还专门处理了3D2D的区分?

那么对于图形混合而言,有一个好消息,那就是布尔运算不分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 做成一个位移动画,观察动态混合的效果。

  1. 对小球进行修改,把 y 轴往上平移 0.8,并且加上正弦函数siniTime的结果,因为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 的具体参数

  1. 首先是第二个参数n,这个是法向量,想必大家都很熟悉了,就是垂直于物体的向量,我们设置为vec3(0., 1., 0.)的意思就是法向量位于 y 轴,那么整个平面就应该是平行于xz平面
  2. 第三个参数h就是偏移量,会在 y 轴上,距离原点 0.1 个单位的位置。
  3. 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);
}

mainImageRaymarching循环中,原先是直接获取map的距离d的,这次我们将map的结果存进res变量里,分别将距离和材质编号存入distmaterial变量:

// 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 循环,分别在 aaXaaY 方向上进行采样。每个循环内部偏移 fragCoord,并调用 getSceneColor 函数计算该位置的颜色。
  • 采样次数和范围:AA_size 变量控制每个像素区域内的采样数量。代码中 AA_size=2.0,表示每个像素会进行 2x2=4 次采样。
  • 累加和平均:将所有采样结果累加后,最终对采样结果进行平均处理,以此获得最终的颜色值。

这里实现了 2x2 超级采样抗锯齿(SSAA)。通过在每个像素区域内进行多次光线采样,超级采样抗锯齿技术能够有效减少图像中的锯齿现象,并平滑边缘,从而提高图像质量。
不过,这方法虽然效果显著,但会增加计算开销,因为每个像素的颜色都要通过多个样本的计算结果得出。

注意,这里的AA_size越大,抗锯齿的效果越好,但是要随时注意自己的电脑的GPU是否扛得住。
最后修改:2024 年 08 月 14 日
收款不要了,给孩子补充点点赞数吧