Webgl中采用PBR的实时光线追踪

Webgl中采用PBR的实时光线追踪

先看效果:

光线追踪_点击查看s.mecg.me

因为Nvdia的RTX发布,还有微软的DXR,以及一系列直接支持光线追踪的游戏大作的发布,光线追踪最近在图形领域也是一个比较热门的话题了,可能看题目大家会觉得奇怪,webgl怎么可以光线追踪?光线追踪从时间上来讲是一门相当古老的技术了,和具体的实现平台无关,不论你用什么语言都可以实现出来。

光线追踪是一个天然并行的系统的,我们可以利用GPU的并行能力来加速实现,使用GPU并行加速光线追踪这件事情上,可以使用的方案非常多,CUDA、opengl(webgl)、RTX、DXR,我专栏的上一篇文章我提到了GPU架构相关的,如果你了解GPU的架构,你会发现这些方案本质的区别不是非常大,实现框架都是紧贴GPU的这套硬件架构的在没有CUDA之前想要加速都是采用本篇类似的方案,cuda出现后用cuda更自然点,后面发现既然光线追踪这么特殊,NV专门做个东西来优化最耗时的部分(用硬件优化...)、微软则用DX实现相关软件层面的调用,开发者直接关注渲染相关的东西便好了,这就是RTX和DXR。

另外说是实时的光线追踪,只是听起来比较厉害的样子罢了,这个本身还是渐进的式的离线渲染,随之时间的增加会逐渐收敛,一般几秒之后就基本收敛,基本无噪点需要2分钟(还取决于显卡的速度、场景的结构),最终的目的还是渲染离线图片。

材质系统上我们还是使用前面讲到的基于物理的渲染(UE4),需要做一些改造把原来各种拟合、约等于的东西更换成完全物理的方式。

光线追踪(Ray Trace)

上面的这张照片很好的示意了光线追踪的原理,所谓光线追踪就是光线是从镜头出发,跟渲染图像的点呈一个射线,射线往前走,碰到物体后就根据物体的材质跟光线做计算,最终的颜色就是这条光线追踪出来的颜色,一条光线包含一个起始点,一个方向(指向屏幕像素点),一个最终颜色。这里我们会发现webgl(opengl)是天然适合做这个事情的,像素着色器对应一个像素点,恰好一个像素着色器可以计算一条光线,最重要的光线和光线都是不相关、互不影响的,这就是光线追踪是天然并行的原因。也就是实现这光线追踪只要写一个shader就可以了。

上述过程只是光线投射,光线碰到物体后,会根据物体本身的材质性质,进行反射或者折射继续向前走,直到碰到光源或者出场景,这就是光线追踪中的追踪。

射线碰到物体后,会发射折射或者反射,然后继续向前,直到碰到光源

不过这时候我们看一下渲染方程 L_o(\chi ,w_o,\lambda ,t)=\int _\Omega f_r(\chi ,w_i,w_o,\lambda ,t)L_i(\chi ,w_i,\lambda ,t)(w_i \cdot  n)dw_i

渲染方程的意思是一个点最终的颜色的是来自于法向半球内的一个积分,换句话说一个点最终的颜色要收到法线半球内各个角度的光照的和。这样第一束光线碰到到第一个点后会发散成多束光线,发散出来的每一条线,经过向前又会变成更多条光线,就这样一直递归下去,这样光线追踪就变成一颗光线数。如下图所示,这种递归的无限积分是非常难以求解的。

光线追踪的递归

路径追踪(Path Trace)

为了解决上述了问题,引用了数学上的一个工具-蒙特卡洛方法,这里再说一下蒙特卡洛方法,对于积分

F=\int_{a}^{b}f(x)dx \approx \frac{1}{N}\sum_{i=1}^{N}\frac{f(X_i)}{pdf(X_i)} ,其中 pdf(X_i) 是xi的取值概率, f(X_i) 是对应的结果,

当N越大,也就是采样越多,采样越精准,结果就越接近真正的积分结果。有个这样工具我们可以这么处理上面的追踪的过程,每次我们都选取众多反射中选一个,形成一条路径,这一条路径会算出一个结果,最终的结果只是N中的一个,这样我们把一个递归的树转换成一个求和的过程,只要N足够大整个结果就是无偏的,所以说路径追踪就是用蒙特卡洛方法来解决光线追踪的问题。

接下来我们看具体的做法。

生成光线

光线的基本数据结构如下,具体实现的可能会略有不同,但是大致类似

struct Ray {
    vec3 o;//起点
    vec3 d;//方向
    float t;//距离
    bool isHit;//是否击中
    vec3 pos;//击中点位置
    vec3 normal;//击中点法线
    int mID;//击中点材质
    vec2 uv;//击中点UV坐标
};

第一步是生成光线,光线的起点是镜头位置,方向是当前的像素点,在shader中的过程如下

vec3 getDir(){
    // u,v,w 为镜头的三个方向向量 d为成像平面到镜头的距离
    float d = 500.0;
    //这里有一个像素的随机,目的就是抗锯齿
    vec2 p = vec2(vPosition.x*SIZE + ran.x - 0.5,vPosition.y*SIZE + ran.y - 0.5) * 0.5;
    vec3 dir = u * (p.x) + v * (p.y) - w * d;
    dir = normalize(dir);
    return dir;
}

相交测试

这次个版本我们所有的场景都是以参数来描述模型而不是基于三角面的形式(因为webgl的限制,这些限制我会在后面说),剩下的问题就是怎么用程序解下面的一些问题:射线和立方体求解,射线和球求交,立方体和圆柱求交等等此类问题,这里推荐一个地址,这个大神把相关的东西都写了出来,我们为了简单可以直接拿过来用,如果想推导的同学,把立体几何拿出来重新写一遍也可以,这里我们就不在赘述了,我们列一下简单的示意。

//光线和球体求交
bool intersectSphere(inout Ray ray, vec3 sphereCenter, float sphereRadius,int mID) {
    vec3 toSphere = ray.o - sphereCenter;
    float a = dot(ray.d, ray.d);
    float b = 2.0 * dot(toSphere, ray.d);
    float c = dot(toSphere, toSphere) - sphereRadius*sphereRadius;
    float discriminant = b*b - 4.0*a*c;

    if(discriminant > 0.0) {
        float t = (-b - sqrt(discriminant)) / (2.0 * a);
        if(t > 0.0 && t < ray.t){
            ray.isHit = true;
            ray.t = t;
            ray.pos = ray.o + ray.d * t;
            ray.normal = normalize(ray.pos - sphereCenter);
            ray.mID = mID;
            ray.uv = getuv(ray.normal);
            return true;
        }
    }
    return false;
}
//光线和平板求交
void intersectPlane(inout Ray ray,vec3 a,vec3 n,float size,int mID){...}
//光线和aabb盒子求交 
void intersectBox(inout Ray ray, vec3 aa,vec3 bb,int mID){...}
...

追踪过程

整个追踪过程就是循环的过程,我以伪代码把过程呈现出来,具体实现会比下面略复杂,但是基本过程是一致的。

vec3 radiance = vec3(0.0);//累积的光照
vec3 throughput = vec3(1.0);//brdf值

for(int count=0;count<BOUND;count++){//这里采用固定步长
    testScene(ray);//光线和场景进行相交测试,求交过程看上一步
 
    if (ray.isHit){//如果有碰撞到物件
        m = getMatrial(ray.mID,ray.uv);//获取碰撞点的材质
        if(m.emissive< 0){//如果碰到的是光源,直接计算并且返回
             radiance += EmitterSample(ray,m) * throughput;
             break;
        }
        if(m.glass > 0.0){//如果是透明的材质,进行bsdf采样
            ray.d = BsdfDir(ray.normal,m.glass,ray.d);
            throughput *= m.baseColor;
        }else{//进行brdf采样
            radiance += directLight(ray.pos,lastPos,ray.normal,m) * throughput;//先进行直接光照
            ray.d = BrdfDir(ray.normal,normalize(reflect(ray.d, ray.normal)),r,m);//brdf采样,间接光照
            throughput *= brdf(ray.normal,-ray.d,newd.xyz,m);//计算brdf值
        }
        ray.isHit = false;
        ray.o = ray.pos + ray.d;
    }else{
        //整个场景外有HDR光照,这个按照需求换成纯色
        vec3 hdrColor = texture2D( hdrTexture, getuv(ray.d));
        radiance += hdrColor * throughput;
        break;
    }
}

大致说一下过程,光线先和场景求交,如果有交点,按照交点的材质进行分类

  • 如果是光源直接计算,光源直接可以取到 L_i
  • 如果是透明材质,进行bsdf采样,计算 f_r ,光线接着往前
  • 如果是普通材质,进行brdf采样,计算 f_r ,光线接着往前

如果无交点,就采样环境光照,这个地方可以也选择纯色,或者没有环境光照直接结束。

你可能意识到这里面跟前面说的有区别,前面说路径追踪一直往前走直到出场景或者碰到光源,这里面多了一步就是直接对光源采样,因为上面的方式收敛的速度非常慢,场景中的光照效果绝大部分还是直接光照贡献来的。我们可以这么理解这个问题:当前在p1点,将要去p2点,p2点会去p3点,那么原来p1点的光照来自于p2,从p2取到的光照也只是p2的一部分(仅仅来自于p3的部分),本质上p1点需要是来自于p2的全部光照,所以这时候加上p2的直接光照只会更接近于结果,而不会引入偏差。

因为蒙特卡洛的方式需要N是足够大的,整个框架也是渐进式的,我们需要每帧计算一个结果,然后把所有帧的结果加起来,再求一个均值就可以了,这里有一个计算累积和的技巧,我们只需要两个FBO,每帧的计算结果和上一帧叠加就好了,每次都翻转两个FBO,把这一帧的就结果当做下一帧的原始输入,用公式表示如下:

S_n=S_{n-1}*\frac{n-1}{n} + C_n*\frac{1}{n} , S_n 是n次的总和, C_n 是第n次的结果。

采样

光线追踪都会涉及到采样的问题,至少有几个地方都会用到采样:光线往前的时候,具体往哪个往哪个方向去?直接光照的时候怎么在面光源上采样?路径追踪本身理论的基础是蒙特卡洛采样,为了提升蒙特卡洛的效率,就要采样数值大的,具体可以理解为,要尽量采样对当前点光照方向贡献更大的方向,这就是重要性采样,在追踪中的采样我们会根据brdf公式来做重要性采样,还可以看到蒙特卡洛方法里面需要pdf,这个就是采样用的概率密度函数,这个函数越接近结果,收敛的速度就越快。

这里还要重点说一下pdf(probability density function)这个东西,后面的每一项公式计算都伴随着pdf的计算,这涉及到两个问题,为啥要pdf,因为积分拆分成求和要伴随这个pdf,什么样的pdf是最好的?越贴近结果的就越好,这样收敛速度就快。

还有个东西就是cdf(cumulative distribution function),有时候pdf如果不是公式的形式,没法直接使用的时候就得用到cdf这个工具,一个明显的例子就是如果我们要对一个HDRI的光照图进行直接光照,我们会怎么进行采样?举下面这个图,这个图中我们直接可以看到图中有几个重要的光照点,理论上我们在这些区域要多采样,在其他区域少采样,

那么我们怎么在shader中,怎么才能在这个区域中的概率中命中的高呢?就是把这个图转换成CDF图,越亮的区域占的面积越大,这样采样的时候还是用0-1的随机数,落到哪个区域就采样对应的点,就实现了重要性采样。我们最终的代码没有处理环境光的重要性采样,在切换HDRI的时候可以明显看到,在一些小光源的场景中收敛的速度慢的多,等后面有时间来处理这个吧。

cdf示意图,就是按照光照的总亮度来分配权重

BRDF

我们还采用UE4中的brdf模型,只不过我们这次要把基于物理渲染中的各种预计算、约等于、拆分、拟合等操作换成原始版本,直接来计算最真实的光照。

f (\mathrm{l},\mathrm{v})=\frac{c_{diffuse}}{\pi}+\frac{F(\mathrm{v},\mathrm{h})D(\mathrm{h})G(\mathrm{l},\mathrm{v},\mathrm{h})}{4(\mathrm{n}\cdot \mathrm{l})(\mathrm{n}\cdot \mathrm{v})}

  • l 入射光方向,也就是下一个光线的方向
  • v 视线方向,也就是上一个光线的方向
  • n 法线方向
  • h=(l+v)/2
  • F(v,h)=F_0+(1-F_0)(1-v\cdot h)^5
  • D(h)=\frac{\alpha ^2}{\pi ((\mathrm{n} \cdot  \mathrm{h})^2(\alpha ^2-1)+1)^2}
  • G(\mathrm{l},\mathrm{v},\mathrm{h})=G_{ggx}(\mathrm{l})G_{ggx}(\mathrm{v}) , G_{ggx}(\mathrm{k})=\frac{2(\mathrm{n} \cdot \mathrm{k})}{(\mathrm{n} \cdot \mathrm{k}) + \sqrt{\alpha^2+(1-\alpha^2)(\mathrm{n} \cdot \mathrm{k})^2 }}
  • \alpha = Roughness^2

最终使用的公式和参数我都列举了出来,具体的作用还是见我这篇基于物理的渲染里面的解释。本篇还是不讨论具体的实现直接用这些公式好了。还有两个比较重要的就是pdf

pdf_{d}=\frac{n \cdot l}{\pi} 漫反射部分的pdf

pdf_s=\frac{D(n \cdot l)}{4(v \cdot l)} 高光部分的pdf

这里还有个问题是漫反射和高光部分,在向前的路径是不一样的,这块我们分开计算,按照材质的属性按照概率随机选择一种,代码如下:

float probability = rand();
float diffuseRatio = 0.5 * (1.0 - Metallic);//漫反射和高光的比例和金属有一定关系
if (probability < diffuseRatio){//漫反射
   dir = CosineSampleHemisphere(E.x, E.y);//consine采样
}else{//高光反射
   dir = ImportanceSampleGGX(Roughness,V,E);//GGX重要性采样
}

consine采样和GGX重要性采样,在基于物理的渲染中有代码列出,不在赘述。

BTDF

除了brdf还得处理btdf,如果是透明的物体,光线照射上来可能会发生折射也可能会发生反射,两者的的概率取决于菲涅尔的值,菲涅尔的公式上面已经列出,直接取随机数和菲涅尔的进行比较来决定反射或者设置,,这其中 F_0=(\frac{n_1-n_2}{n_1+n_2})^2 ,n1和n2是折射面两侧介质的折射率。这块的代码如下

float n1 = 1.0;
float n2 = galss;
float R0 = (n1 - n2) / (n1 + n2);
R0 *= R0;
float theta = dot(-d, ffnormal);
float prob =  R0 + (1.0 - R0) * pow(theta,5.0);//计算菲涅尔的值

float eta = dot(N, ffnormal) > 0.0 ? (n1 / n2) : (n2 / n1);
float cos2t = 1.0 - eta * eta * (1.0 - theta * theta);
if (cos2t < 0.0 || rand() < prob) // 反射
{
    dir = normalize(reflect(d, ffnormal));
}
else
{
    dir = normalize(refract(d, ffnormal, eta));//折射
}

透明材质还有个特殊情况就是他本身是不接受直接光照的,显示都是靠周围的环境带来的光照来表现的。

直接光照

光源我们采用面光源,一种是球形,另外一种是矩形,对面光源我们同样要进行重要性采样,这里一个有个细小的区别,如果是直接光照要对光源进行重要性采样,而brdf是不需要再进行重要性采样的,是直接进行brdf计算的。这个积分是针对光源进行计算的,当采样确定后brdf也就确定了。光源还有个问题要计算是否被遮挡,遮挡的话就会在阴影区内,因为是面光源,所以非常容易生成相当漂亮的软阴影。

球形面光源的计算:

Ary2V3d SphereLight(vec3 pos,vec3 normal,vec3 lightpos,float lightsize,float intensity,vec3 color){
    float lightArea = 4.0 * PI * lightsize * lightsize;
    vec3 pointlight = lightpos+ uniformlyRandomVector(time - 53.0) * lightsize;
    vec3 lightdir = normalize(pointlight - pos);
    float d = length(pointlight - pos);
    Ary2V3d avd;
    avd.v0 = vec3(0.0,0.0,0.0);
    avd.v1 = lightdir;
    if(!shadow(pos,lightdir,d)){
        float pdf =  (d*d) / lightArea;
        avd.v0 = color*intensity / pdf;
    }
    return avd;
}

矩形面光源的计算:

Ary2V3d rectangleLight(vec3 pos,vec3 normal,vec3 lightpos,vec3 lightdirA,vec3 lightdirB,float intensity,vec3 color){
    float lightArea = length(cross(lightdirA,lightdirB));
    vec3 n = normalize(cross(lightdirA,lightdirB));
    vec3 pointlight = lightpos + lightdirA * area.x + lightdirB * area.y;

    vec3 lightdir = normalize(pointlight - pos);
    float s = saturate(dot(n,lightdir));
    float d = length(pointlight - pos);

    Ary2V3d avd;
    avd.v0 = vec3(0.0,0.0,0.0);
    avd.v1 = lightdir;
    if(!shadow(pos,lightdir,d)){
        float pdf = (d*d) /(lightArea*s) ;
        avd.v0 = color*intensity / pdf;
    }
    return avd;
}

环境光

环境光没有处理直接光照的重要性采样,所以会显得比较简单,当光线没有碰到任何东西的时候直接把碰到的环境光叠加上去就可以了。

景深

最开始我们使用的镜头是最简单的镜头,原理就是小孔成像,在光线追踪的框架下对镜头稍微进行改造,就可以实现单反的效果,这个镜头就是薄透镜模型,也是对物理相机的一种简化,从原理上讲这个推导过程还是挺复杂的,详情大家参考,PBRT里面就详述,计算结果是比较简单的:

vec3 getDir(){
    //u,v,w为相机的方向
    //dof.x dof.y 为相机的随机采样点 dof.z为焦距
    float f = dof.z;
    float d = 500.0;
    vec2 p = vec2(vPosition.x*SIZE + ran.x - 0.5,vPosition.y*SIZE + ran.y - 0.5) * 0.5;
    p = p * f / d;
    vec3 dir = u * (p.x-dof.x) + v * (p.y-dof.y) - w * f;
    dir = normalize(dir);
    return dir;
}

到这里我们基本把这个webgl关于光线追踪相关的核心点都列举了出来,可能还有很多点我无法一一列举。还有什么问题可以在本文下面留言,可能的话我也会在补充编辑这篇。

下面我说一下在webgl中实现的时候一些限制,因为不能再shader中while循环,不能申请动态数组,不能对变量数组进行赋值,所以对于动态场景的处理会比较麻烦(BVH也没法进行,BVH没法使用导致三角模型没法做),所以我的场景遍历,灯光遍历都是采用硬编码的形式,之所以你还能动态的添加删除物件,是因为场景中每多一个或者少一个物件我都会重新编译一下shader,所以这个时候UI都会卡一下,编译shader会比较耗时。

编辑操作

上述追踪做完后就顺便做了一下界面操作相关的东西,可以方便看到效果,操作也会比较简单。可以选定一些我随便做的预制场景,可以编辑场景中物件的位置、旋转、缩放等基本属性,编辑pbr的材质,设定hdr,编辑镜头等功能。

虽然场景中都是一些基本的模型(下一篇我将把这个逻辑放到OPENGL里面,就可以使用三角模型来渲染真实模型),但是在真实的光照下,会有相当的表现力。下面我把一些渲染好的效果贴上来。

场景链接_点击查看

非金属场景_点击查看

金属场景_点击查看

康奈尔盒子_点击查看

漫反射场景_点击查看

玻璃场景_点击查看

编辑于 2019-03-20

文章被以下专栏收录