2D平面画出3D世界的Shader技术RayMarching的基本思路介绍

又两周没有写文章了,工作中各种被Push, 但又需要这份工来养家糊口。 什么时候可以存到500万然后在银行吃利息生活啊,不过玩了几个月的Shader, 终于要开始进入3D的世界啦,开心。照例看一些相关的Shader, 下面的happy jumping, Rainforest , temple都是使用IQ大神使用raymarching技术实时渲染出来的视觉效果,代码量都在600行左右,非常了不起吧。 不过我相信,我跟着学一个月,也能看懂并做一些些改动了。本节大部份内容都是对于最后学习资料的学习过程,部分图片使用学习资料的视频截图,这些图片会带上出处

www.shadertoy.com/view/3lsSzf

www.shadertoy.com/view/ldScDh

www.shadertoy.com/view/4ttSWf

RayMarching基本介绍

Raymarching 是一种在计算机图形学中用来渲染场景的技术,特别是在实时渲染和非实时渲染中的程序式图形和视觉特效的生成。与经典的光线投射(Raycasting)或光线追踪(Raytracing)相比,Raymarching 通过在场景中逐步"行进"来近似相交检测,并被广泛用于渲染体积材料(如烟雾、云)和复杂的几何形状(如分形)。

基本原理

Raymarching 的基本原理是:对于场景中的每一个像素,发出一条从相机位置出发并穿过像素的光线。然后,沿着这条光线,分步前进一定的距离,并检查光线上的点是否击中了场景中的任何对象。

这个"前进步数"可以固定或者动态调整。动态调整步数的一个常见方法是使用所谓的"有符号距离场"(Signed Distance Fields, SDFs)。这是一种可以快速确定点到场景中最近表面距离的技术,因此光线可以安全地多走这段距离,不会错过任何物体。

优缺点

优点

  • 柔和的阴影和模糊效果:由于 Raymarching 是连续采样场景的,它可以很自然地产生柔和阴影和模糊效果(例如轻微地调整步进距离)。
  • 复杂体积效果:能够以几何无关的方式渲染复杂的体积效果,因为它不需要传统网格来定义形状。
  • 非欧几里得和程序化场景:Raymarching 特别适合处理复杂的分形几何结构和程序化材料,这些通常难以用传统模型表示。

缺点

  • 性能开销:由于需要对每个像素的光线进行大量计算,Raymarching 可能非常消耗性能,尤其是在场景复杂或者光线需要行进很多步才能找到物体的时候。
  • 优化困难:要让 Raymarching 运行地足够快以用于实时应用,可能需要进行大量的优化和技巧性写法。

应用领域

Raymarching 在视觉效果产业中被广泛使用,尤其是在那些需要高度定制渲染算法的场合。这包括实时图形(如视频游戏)、动态艺术、VR/AR 和电影特效。

技术要点

在实现 Raymarching 时,通常会遵循以下的一些步骤:

  1. 场景表示:将场景表示成距离场(通常是有符号距离场 SDF)。
  2. 光线步进:从相机位置出发,对于每个像素逐步沿光线方向前进。
  3. 距离评估:利用 SDF 评估当前点到物体表面的最小距离,以此来决定下一步进的大小。
  4. 渲染和着色:当光线最终靠近物体的时候(即距离小于预设阈值),计算着色和反照率。

Raymarching 通常在着色器(Shader)中实现,着色器编程使得开发者能够对 GPU 的并行计算能力进行利用,高效执行大量的光线步进和渲染计算。由于 GPU 的强大计算能力,Raymarching 已经可能在实时应用中达到令人印象深刻的视觉效果。

代码实现

在现实世界中我们的视线实际上去接受世界上物体反射给我们的光,我们的眼睛是一个接收器,视觉中对于距离和物体形体的判断需要依靠大脑中非常复杂的机制。 而在代码世界中,我们没有大脑那么强的能力,但是却又拥有了一些奇妙的其他能力。 代码的Ray可以做任何变换,例如加入一个sin函数就能让你的视线在空间中旋转, 而物体形体的判断是通过距离函数来确定。当你的视线到了某个物体的表面,视线距离物体表面的距离接近于0.

下图是一个非常完整代码实现思路,图中的照相机,屏幕和实际物体就是代码中需要处理的问题。最核心的是Ray和Distance这两个抽象的概念。

Camera

相机表示

在纯shader表示相机的方式与传统3D图形中的相机表示有所不同。相机通常是隐式定义的,最简单的camera就是cameraPosition(rayOrgin)和rayDirection两个变量

glsl 复制代码
    vec3 rayOrgin       = vec3(0., 1., 0.);
    vec3 rayDirection   = normalize(vec3(uv.x, uv.y, 1.));

这里可以思考一个有趣的问题,如果是按照传统的3D图形相机,通常有以下的结构

glsl 复制代码
struct Camera {
    vec3 position;  // 相机的位置
    vec3 target;    // 相机指向的目标点(在世界空间中)
    vec3 up;        // 相机的上方向
    float fov;      // 相机的视野(Field of View)
    float aspectRatio;  // 宽高比
};

那么对应ray的配置落到camera的配置,应该值是什么呢?

glsl 复制代码
position = (0, 1, 0)
target =   (0, 0, 1)
aspectRatio = iResolution.x / iResolution.y
up = (0, 1, 0)
fov = 90

碰撞检测

确定射线后,接下去的问题是如何确定这些射线于3d object是否相交。以下图从P0点不断的往P4方向行进,每次探一探距离,如果P点于边缘点小于某个小值,则说明已经相交了。 下探的距离使用经验值,即上一个点离表面的最短距离。 www.shadertoy.com/view/4dKyRz

glsl 复制代码
#define MAX_STEPS 80;
#define MAX_DIST 100;
#define SURFACE_DIST 0.001;

float RayMarch(vec3 ro, vec3 rd) {
    float distanceFromOrigin = 0.;
    for (int i=0; i<MAX_STEPS; i++){
        vec3 p = ro + distanceFromOrigin*rd;
        float distanceFromSurface = GetDist(p);
        distanceFromOrigin += distanceFromSurface;
        if(distanceFromSurface<SURFACE_DIST || distanceFromOrigin>MAX_DIST) break;
    }
    return distanceFromOrigin;
}

以上图为例,这个世界有地平面和球两个物体,GetDist函数应该是

ini 复制代码
#define MAX_STEPS 80
#define MAX_DIST 100.
#define SURFACE_DIST 0.001

float GetDist(vec3 p) {
    vec4 sphere = vec4(0., 1., 6., 1.);
    float distanceSphere = length(p - sphere.xyz) - sphere.w;
    float distancePlane  = p.y;
    return min(distanceSphere, distancePlane);
}

最后讲RayMarching获取的值,变成颜色变获得以下的3D世界。 明显画面没有体积感。在传统素描技法中需要明暗对比,透视,纹理来表现体积感。接下来就是光照

Lighting

基础光照模型

一个常用的简单光照模型是Phong光照模型,它由环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和高光(Specular Lighting)三个组成部分构成。

  • 环境光照 (Ambient Lighting) 环境光照是一个全局光照,假设光线从每个方向均匀的照射到物体上,它提供了一个没有方向性的基础亮度,以模拟间接光的效果,避免完全处于黑暗中。
glsl 复制代码
vec3 ambientColor = vec3(0.1, 0.1, 0.1); // 根据场景情况调整环境光亮度
vec3 ambient = ambientColor * surfaceColor; // surfaceColor 是物体的颜色
  • 漫反射光照 (Diffuse Lighting) 漫反射光照根据表面法线和光源方向的角度差来计算,角度差越小(也就是这两个方向越贴近)漫反射光照越强,这表现了光线如何照射到表面并向各个方向均匀散射。
glsl 复制代码
vec3 lightDir = normalize(lightPos - fragPos); // 计算光线方向
vec3 norm = normalize(normal); // 计算法线方向
float diff = max(dot(norm, lightDir), 0.0);    // 计算法线和光线的点乘,取最大值以避免负数
vec3 diffuse = diff * lightColor * surfaceColor;  // lightColor 是光源颜色
  • 高光 (Specular Lighting) 高光表现的是光线照射到表面后,沿着一个反射方向产生的亮点效果。高光大小取决于观察方向和反射方向的接近程度,它通常在镜面反射方向附近最亮。
glsl 复制代码
vec3 viewDir = normalize(viewPos - fragPos);   // 观察方向
vec3 reflectDir = reflect(-lightDir, norm);    // 反射方向,传入的光线方向需要取反
float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess); // shininess 是高光的反射程度
vec3 specular = spec * lightColor;

法向量计算

更详细的数值优化方法查看IQ的文章: iquilezles.org/articles/no...

Diffuse和Specular都需要法线支持。在本文的场景中,球面和地平面的发现都非常好计算,球面上某一点的法线是一个从球心指向该点的向量。由于球体在每个点上都是均匀的(完全对称),法线向量可以通过简单地从球心指向表面点的方向得到,并且该向量应当单位化(即变为单位向量)。而平面的法向量其实就是(0, 1, 0)。 以上法向量的计算方法是分析方法,在SDF场景有更加通用的数值方法。回到法向量的定义, 在曲线中有

  1. 导数:在二维空间中,对于曲线 y = f(x),函数 f(x) 在某点 x 的导数 f'(x) 表示在该点位置曲线的斜率,也代表了曲线的瞬时变化率。
  2. 切线:曲线上某一点的切线是在该点与曲线相切的直线,其斜率等于函数在这一点的导数。切线与曲线在切点处拥有相同的方向。
  3. 法向量:对于平面曲线,法向量通常定义为指向曲线"外侧"的垂直于切线的向量。在二维中,如果切线的方向由向量 (dx, dy) 给出,法向量可以是 (dy, -dx) 或 (-dy, dx),取决于法向量的方向

而在曲面中

  1. 偏导数:在三维空间中,对于曲面 z = f(x, y),函数 f 的偏导数 ∂f/∂x 和 ∂f/∂y 描述了沿 x 和 y 方向的局部斜率。这些局部斜率可以用于确定在给定点的切平面。
  2. 切平面与切线:曲面上某一点的切平面是在该点接触曲面的平面,它包含所有可能经过该点的切线。对于曲面上的一条特定曲线,其切线就位于这个切平面上。
  3. 法向量:曲面上某一点的法向量是垂直于切平面的向量。如果知道曲面在该点的偏导数,法向量可以使用交叉积来计算。例如,如果曲面可以表示为参数形式 r(u, v) (u 和 v 是参数),那么法向量可以通过 r_u 与 r_v 的交叉积计算得出,其中 r_u 和 r_v 分别是曲面向量函数关于参数 u 和 v 的偏导数向量。

导数的含义其实就是微分趋于0的极限值,而用一个非常小的微分h可以得到导数的近视值。假设我们的曲面函数为f(p)
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> f ( p ) = 0 n = n o r m a l i z e ( Δ f ( p ) ) Δ f ( p ) = { d f ( p ) d x , d f ( p ) d y , d f ( p ) d z } d f ( p ) d x ≈ f ( p + { h , 0 , 0 } ) − f ( p ) h \begin{aligned} f(p) = 0 \\ n = normalize(\Delta f(p)) \\ \Delta f(p) = \lbrace \frac{df(p)}{dx} , \frac{df(p)}{dy} ,\frac{df(p)}{dz} \rbrace \\ \frac{df(p)}{dx} \approx \frac{f(p+\lbrace h,0,0 \rbrace)-f(p)}{h} \end{aligned} </math>f(p)=0n=normalize(Δf(p))Δf(p)={dxdf(p),dydf(p),dzdf(p)}dxdf(p)≈hf(p+{h,0,0})−f(p)

当然上面的近视值只考虑了正数方向,如果增加负数方向可以表示为
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> d f ( p ) d x ≈ f ( p + { h , 0 , 0 } ) − f ( p − { h , 0 , 0 } ) 2 h \frac{df(p)}{dx} \approx \frac{f(p+\lbrace h,0,0 \rbrace)-f(p-\lbrace h,0,0 \rbrace)}{2h} </math>dxdf(p)≈2hf(p+{h,0,0})−f(p−{h,0,0})

所以整体的法向量近视值为
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> n ≈ n o r m a l i z e ( { f ( p + { h , 0 , 0 } ) − f ( p − { h , 0 , 0 } ) , f ( p + { 0 , h , 0 } ) − f ( p − { 0 , h , 0 } ) , f ( p + { 0 , 0 , h } ) − f ( p − { 0 , 0 , h } ) } ) n \approx normalize(\begin{aligned}\lbrace f(p+\lbrace h,0,0 \rbrace) &- f(p-\lbrace h,0,0 \rbrace), \\ f(p+\lbrace 0,h,0 \rbrace) &- f(p-\lbrace 0,h,0 \rbrace), \\ f(p+\lbrace 0,0,h \rbrace) &- f(p-\lbrace 0,0,h \rbrace) \rbrace \end{aligned} ) </math>n≈normalize({f(p+{h,0,0})f(p+{0,h,0})f(p+{0,0,h})−f(p−{h,0,0}),−f(p−{0,h,0}),−f(p−{0,0,h})})

于是有normal的代码为

glsl 复制代码
#define NORMAL_DELTA 0.01

vec3 GetNormal(vec3 p) {
    vec2 e = vec2(NORMAL_DELTA, 0.);
    return normalize(vec3(
        GetDist(p+e. xyy) - GetDist(p-e.xyy),
        GetDist(p+e.yxy) - GetDist(p-e.yxy),
        GetDist(p+e.yyx) - GetDist(p-e.yyx)
    )); 
}

最后得到一个有体积感的空间物体啦

Study Materials

相关推荐
什么鬼昵称16 分钟前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色34 分钟前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河1 小时前
CSS总结
前端·css
BigYe程普1 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H2 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍2 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai2 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默2 小时前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
2401_857297912 小时前
招联金融2025校招内推
java·前端·算法·金融·求职招聘