【shader基础】RayMarching绘制3D图形

573 阅读12分钟

上一篇2D符号距离函数及其变换操作,用shader画个掘金logo介绍了怎么简单画2D图形,接下来学习怎么用shader画3D图形! 20250123_094929.gif

1.RayMarching光线步进算法

image.png

RayMarching算法: 为了找到视图光线和场景之间的交点,从相机开始,一点一点地沿着视图光线移动一个点,如果点到场景的SDF距离为负数,则点在场景表面内,存在相交。如果不为负,则沿着光线方向继续移动一段安全的最大步长(点到场景表面的SDF距离)。 简单来说就是,计算画布上的每个像素点坐标,沿着视线方向,一步一步往前移动,直到与场景相交,计算出每个像素点坐标与场景的距离。

image.png

const float minDistance = 0.01;//最小距离
const float maxDistance = 100.0;//最远距离
const int rayCount = 128;//光线步进移动次数
//pos像素点位置,direction视线方向
float raymarching(vec3 pos, vec3 direction) {
    float t = 0.0;
    for(int i = 0; i < rayCount; i++) {
        //移动后的点坐标
        vec3 p = pos + direction * t;
        //坐标点与场景距离
        float d = scene(p);

        if(d < minDistance //判断是否在场景内
        || t > maxDistance//判断是否在可视范围外
        )//符合条件,停止移动
            break;
        //增加距离成为下一个点做准备
        t += d;

    }
    return t;
}

2.3D符号距离函数

使用3D符号距离函数绘制场景。

//https://iquilezles.org/articles/distfunctions/
//球体,p坐标点,s半径
float sdSphere(vec3 p, float s) {
    return length(p) - s;
}
//平面,p坐标点 
float sdPlane(vec3 p) {
    return p.y;
}
//场景形状组合
float scene(vec3 p) {
    return min(sdSphere(p - vec3(0., 1., 0.), 1.), sdPlane(p));
}

更多的3D形状请看iq大神的3D SDF

  • 结合上面的rayMarching算法,可以获取相机到三维场景的距离,绘制3D形状
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = getUV(fragCoord);
    //相机位置
    vec3 ro = vec3(0., 1., -3.);
    //视线方向
    vec3 rd = normalize(vec3(uv, 1.0));
    //采用光线步进算法,计算与场景相交点距离
    float d = raymarching(ro, rd);
    //因为d的值有可能大于1,要缩小一下
    //d距离越远的值越大,(1.-d)后,距离越远值越小,符合近大远小的规律
    float m = (1.0 - d * 0.1);
     //背景与场景颜色混合成最终显示颜色
    vec3 color = vec3(m);
    fragColor = vec4(color, 1.);
}

image.png

3.添加光照

使用SDF距离计算出相交点,计算出相交点的法向量

//https://iquilezles.org/articles/normalsSDF/
vec3 calcNormal(in vec3 p) // for function f(p)
{
    const float eps = 0.0005; // or some other value
    const vec2 h = vec2(eps, 0);
    return normalize(vec3(scene(p + h.xyy) - scene(p - h.xyy), scene(p + h.yxy) - scene(p - h.yxy), scene(p + h.yyx) - scene(p - h.yyx)));
}
// nor是法向量,color是物体颜色
vec3 setLight(vec3 p, vec3 color) {
 //计算相交点的法向量
    vec3 nor = calcNormal(p);
 //平行光方向
    const vec3 lightDirection = vec3(1., 2., -3.);
    //平行光照强度和颜色,强度1.0 
    float intensity = 1.0;
    vec3 lightColor = intensity * vec3(1.0, 1.0, 1.);
//平行光方向,归一化
    vec3 lightNor = normalize(lightDirection - p);
 //计算光线方向和法向量的点积
    float nDotL = max(dot(lightNor, nor), 0.0);
//计算漫反射颜色
    vec3 diffuse = lightColor * nDotL;
    //环境光
    vec3 amb = vec3(0.3);
    return (amb + diffuse) * color;
}
  • 绘制有光照的3D形状
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec3 color = vec3(0.);
    vec2 uv = getUV(fragCoord);
    //相机位置
    vec3 ro = vec3(0., 1., -3.);
    //视线方向
    vec3 rd = normalize(vec3(uv, 1.0));
    //采用光线步进算法,计算与场景相交点距离
    float d = raymarching(ro, rd);
    if(d < maxDistance) {//可视范围内进行着色处理
     //相交点坐标
        vec3 p = ro + rd * d;
        color = setLight(p, vec3(1.0));
    }
    fragColor = vec4(color, 1.);
}

image.png

  • 将平行光改成点光源光,点光源的光线方向等于光源位置减去坐标点的单位向量。
 //点光源位置
    const vec3 lightPos = vec3(2., 2., -2.);
    //光照强度和颜色,强度1.0 
    float intensity = 1.5;
    vec3 lightColor = intensity * vec3(1.0, 1.0, 1.);
//光线方向,归一化
    vec3 lightNor = normalize(lightPos - p);
 //计算光线方向和法向量的点积
    float nDotL = max(dot(lightNor, nor), 0.0);
//计算漫反射颜色
    vec3 diffuse = lightColor * nDotL;

image.png

4.添加阴影

简单阴影的计算过程类似raymarching,只不过返回的不是距离,而是0.1和1,沿光线方向与场景相交就是0.1,颜色变深呈现阴影,沿光线方向与场景没有相交就是1,保持原来颜色,没有阴影。

//阴影 ro场景坐标点,rd光线方向
float calcShadow(in vec3 ro, in vec3 rd) {
    float t = 0.;
    for(int i = 0; i < rayCount && t < maxDistance; i++) {
        float h = scene(ro + rd * t);
        if(h < minDistance)
            return 0.1;
        t += h;
    }
    return 1.0;
}
  • 阴影是相对于点光源和平行光产生的,因此作用于光的漫反射。
//相交点偏移一点点
    vec3 p1 = p + nor * minDistance;
//阴影,从相交点偏移点出发,沿着光线方向,与场景是否相交
    float shadow = calcShadow(p1, lightNor);
    //光的漫反射与阴影距离相乘,得到阴影投射后的颜色
    diffuse *= shadow;

image.png

  • 可以看到上图的阴影有点生硬,可以使用柔和阴影优化一下效果,更多柔和阴影函数请看iq大神的soft shadow
//柔和阴影 
float softshadow(in vec3 ro, in vec3 rd, float k) {
    float res = 1.0;
    float t = minDistance;
    for(int i = 0; i < 256 && t < maxDistance; i++) {
        float h = scene(ro + rd * t);
        if(h < minDistance)
            return minDistance;
        res = min(res, k * h / t);
        t += h;
    }
    return res;
}
  • 柔和阴影使用
float shadow = softshadow(p1, lightNor, 1.);
diffuse *= shadow 

image.png

可以看到阴影边缘柔和渐变

5.添加摄像机

  • 让摄像机看向球体,围绕其运动
// target相机看向的目标坐标,position相机位置 
mat3 setCamera(vec3 target, vec3 position) {
    //z轴向量
    vec3 z = normalize(target - position);
     //x轴向量
    vec3 x = normalize(cross(z, vec3(0.0, 1.0, 0.0)));
    //y轴向量
    vec3 y = normalize(cross(x, z));
    return mat3(x, y, z);
}
  • 因为raymarching是基于摄像机沿着视线方向与场景相交距离计算的,所以可以通过相机矩阵来改变视线方向。
float a = iTime * PI * 0.5;
    //相机位置动起来
    vec3 ro = vec3(3. * sin(a), 1., 3. * cos(a));
    //相机矩阵
    mat3 camera = setCamera(vec3(0.), ro);

    //视线方向
    vec3 rd = normalize(camera * vec3(uv, 1.0));
  • 让鼠标操作相机,iMouse.z大于0的时候为鼠标按下,当鼠标按下时取鼠标x轴坐标,相对于画布的比例,取横向360度的对应旋转弧度。
//默认自动旋转
    float a = iTime * PI * 0.2;
    //按下鼠标跟随鼠标旋转
    if(iMouse.z > 0.1) {
        a = (iMouse.x / iResolution.x) * PI * 2.;
    }

20250121_215857.gif

  • 除了左右横向旋转,还可以上下纵向旋转,在横向操作相机的基础上,乘以纵向的旋转值,纵向旋转角度的范围[-90,90],对应sin的范围是[-1,1]。 这里限制视角范围[0,90]避免钻平面下什么都看不见。
//默认自动旋转
    float x = iTime * PI * 0.2;
    float y = 0.5;
    //按下鼠标跟随鼠标旋转
    if(iMouse.z > 0.1) {
        //左右旋转镜头
        x = (iMouse.x / iResolution.x) * PI * 2.;
        //上下旋转镜头
        y = (iMouse.y / iResolution.y) * PI * 0.5;
    }
    //相机位置动起来
    vec3 ro = vec3(3. * sin(x) * cos(y), 2. * sin(y) + 1., 3. * cos(x) * cos(y));
    //相机矩阵
    mat3 camera = setCamera(vec3(0.), ro);

20250122_112737.gif

可以看到背面阴影部分场景颜色太接近,难以分辨细节,这时候可以添加环境光遮蔽。

6.添加环境光遮蔽OA

环境光遮蔽(AO)是来描绘物体和物体相交或靠近的时候遮挡周围漫反射光线的效果,增加明暗的层次感,特别是阴影的细节。

float calcAO(in vec3 pos, in vec3 nor) {
    float occ = 0.0;
    float sca = 1.0;
    for(int i = 0; i < 5; i++) {
        float hr = 0.01 + 0.12 * float(i) / 4.0;
        vec3 aopos = nor * hr + pos;
        float dd = scene(aopos);
        occ += (hr - dd) * sca;
        sca *= 0.95;
    }
    return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}
  • 给环境光添加环境光遮蔽作用
 //环境光
    vec3 amb = vec3(0.3);

    //环境光遮蔽,丰富阴影细节
    amb *= calcAO(p, nor);

20250121_220429.gif

可以看到背面阴影部分球体底部与平面有清晰的层次感。

7.添加抗锯齿

虽然上面的效果已经很不错,但是仔细看,会发现有些许锯齿。

Anti-Aliasing抗锯齿实现过程非常简单,向周围上下左右偏移一个像素,分别计算颜色值,加起来再取平均值。

  • 大概增加了两层遍历计算周围像素颜色值,其他逻辑不变。
//Anti-Aliasing抗锯齿
const int AA = 2;
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec3 color = vec3(0.);
    float isColor=0.;
    float a = iTime * PI * 0.2;
    //相机位置动起来
    vec3 ro = vec3(3. * sin(a), 1., 3. * cos(a));
    mat3 camera = setCamera(vec3(0.), ro);
    
    vec3 total = vec3(0.);
    //抗锯齿:上下左右偏移一个像素,分别计算颜色值,加起来再取平均值
    for(int i = 0; i < AA; i++) {
        for(int j = 0; j < AA; j++) {
            vec2 offset = vec2(float(i), float(j)) / float(AA) - 0.5;
            vec2 uv = getUV(fragCoord + offset);
    //视线方向
            vec3 rd = normalize(camera * vec3(uv, 1.0));
    //采用光线步进算法,计算与场景相交点距离
            float d = raymarching(ro, rd);
            if(d < maxDistance) {//可视范围内进行着色处理
     //相交点坐标
                vec3 p = ro + rd * d;
                total += setLight(p, vec3(1.0));
                isColor++;
            }
        }
    }
    if(isColor== float(AA * AA))
    color = total /isColor;
    fragColor = vec4(color, 1.);
}

8.平滑最小值

让物体之间平滑过渡融合,更多平滑最小值请看iq大神的smoothmin


//平滑最小值
float smin(float a, float b, float k) {
    float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h);
}
//场景形状融合
float scene(vec3 p) {
    float t = sin(PI * iTime * 0.2) + 0.5;
    return smin(sdSphere(p - vec3(0., t, 0.), 1.), sdPlane(p), 1.0);
}

20250121_223718.gif

9.给场景物体赋不同颜色

  • 重写一下min函数,给场景对应物体添加标志数值,对应raymarching,calcNormal等函数也要调整一下
vec2 minV(vec2 a, vec2 b) {
    return a.x < b.x ? a : b;
}
//场景形状组合
vec2 scene(vec3 p) {
    vec2 a = vec2(sdSphere(p - vec3(0., 1., 0.), 1.), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);
    return minV(a, b);
}
  • f第一个值x是与场景相交的距离,第二个值y是标志数值
vec2 f = raymarching(ro, rd);
            float d = f.x;
            if(d < maxDistance) {//可视范围内进行着色处理
     //相交点坐标
                vec3 p = ro + rd * d;
                total += setLight(p, f.y);
               }
  • 标志数值传入函数中判断并赋予对应颜色
// nor是法向量,t物体数值标志
vec3 setLight(vec3 p, float t) {
    vec3 color = vec3(0.);
    if(t > 1.5) {//球体 红色
        color = vec3(1.0, 0.0, 0.0);
    } else {//平面 白色
        color = vec3(1.);
    }
    //...
    }

image.png

  • 当然还可以给物体赋予图案纹理,因为平面的y坐标固定,x和z坐标可以看做是2D平米的uv,那么可以给平面画上黑白格子.
//黑白网格
vec3 groundGrid(vec3 p) {
    vec3 c1 = vec3(1.0);
    vec3 c2 = vec3(0.);
    float s = mod(floor(p.x + floor(p.z)), 2.);
    return mix(c1, c2, s);
}

// nor是法向量,t物体数值标志
vec3 setLight(vec3 p, float t) {
    vec3 color = vec3(0.);
    if(t > 1.5) {//球体 红色
        color = vec3(1.0, 0.0, 0.0);
    } else {//平面 黑白网格
        color = groundGrid(p);
    }
  }

image.png

10.3D布尔运算

跟二维的布尔运算一样的逻辑,二维的布尔运算请看我之前的文章[2D符号距离函数及其变换操作,用shader画个掘金logo]

  • 联合,只需要简简单单的min函数就能实现。
//联合
float unionShape(vec3 p) {
    return min(sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
    vec2 a = vec2(unionShape(p), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);

    return minV(a, b);
}

20250121_225558.gif

  • 相交,长方体和球体相交的部分,类似一个圆鼓的形状
//相交
float intersectionShape(vec3 p) {
    return max(sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
    vec2 a = vec2(intersectionShape(p), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);

    return minV(a, b);
}

20250121_225432.gif

  • 差,长方体减去球体的部分,形成一个有圆洞的墙面
//差
float subtractionShape(vec3 p) {
    return max(-1. * sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
    vec2 a = vec2(subtractionShape(p), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);

    return minV(a, b);
}

20250121_225727.gif

  • 当然,A-B与B-A的是不一样的,下面是球体减去长方体的部分,变成两片圆大饼
float subtractionShape(vec3 p) {
    return max(sdSphere(p - vec3(0., 1., 0.), 1.), -1. * sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}

20250121_230051.gif

11.变换操作

  • 移动操作,对应加减xyz坐标
//场景形状组合
vec2 scene(vec3 p) {
    vec3 p1 = p - vec3(0., cos(iTime * PI * 0.5) * 0.8 + 1.3, -0.5);
    vec2 a = vec2(sdSphere(p1, 0.5), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);
    return minV(a, b);
}

20250121_231413.gif

  • 缩放操作
//场景形状组合
vec2 scene(vec3 p) { 
    float s = cos(iTime * PI * 0.5) * 0.5 + 0.8;
    vec3 p1 = p - vec3(0., s, -0.5);
    vec2 a = vec2(sdSphere(p1, s), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);
    return minV(a, b);
}

20250121_231758.gif

//绕v向量方向上的旋转rad弧度
mat3 rotation(vec3 v, float rad) {
    vec3 n = normalize(v);
    float cosa = cos(rad);
    float sina = sin(rad);
    float x = n.x, y = n.y, z = n.z;
    return mat3(pow(x, 2.) * (1. - cosa) + cosa, x * y * (1. - cosa) - z * sina, x * z * (1. - cosa) + y * sina, x * y * (1. - cosa) + z * sina, pow(y, 2.) * (1. - cosa) + cosa, y * z * (1. - cosa) - x * sina, x * z * (1. - cosa) - y * sina, y * z * (1. - cosa) + x * sina, pow(z, 2.) * (1. - cosa) + cosa);
}

//场景形状组合
vec2 scene(vec3 p) {
    vec3 p1 = rotation(vec3(1., 1., 1.), iTime * PI * 0.5) * (p - vec3(0., 1., 0.));
    vec2 a = vec2(sdBox(p1, vec3(0.5)), 2.0);
    vec2 b = vec2(sdPlane(p), 1.);
    return minV(a, b);
}

20250121_232415.gif

12.用shader画个3D哆啦A梦

就是运用灵活运用上面的布尔运算,平滑最小值等操作3D SDF形状,即可拼接出来。

具体代码我放shaderToy上面了,请自行取用~

-https://www.shadertoy.com/view/MX3fWB

微信截图_20250123091337.png

好开心!iq大神评论说Nice~

Github地址

https://github.com/xiaolidan00/awesome-bg

参考