美文网首页
Shadertoy--光线追踪与Nayar反射模型

Shadertoy--光线追踪与Nayar反射模型

作者: 奔向火星005 | 来源:发表于2019-12-26 17:18 被阅读0次

    前言

    Shadertoy是一个神奇的网站,有无数图形学大神分享的用shader写的各种让人匪夷所思的效果,而代码仅仅只有数十行至数百行。虽然很多实现无法直接应用到我们开发的实时渲染上,但分析它们的实现可以让我们理解shader以及带来很多灵感。

    OrenNayar是一种微表面反射模型,本文主要分析shadertoy中实现的一个简易的OrenNayar模型的实现。要展示OrenNayar模型的效果,必须首先要在着色器里面实现一个简单的光线追踪器(其实我主要是想分析一下作者实现的光线追踪器....)

    当然分析过程纯属个人猜测,不代表原作者思路,原文地址如下:
    https://www.shadertoy.com/view/ldBGz3

    正文

    Lambert和OrenNayar模型的对比

    Lambert和OrenNayar都是一种光照反射模型,即描述特定材质的物体表面如何反射光线。(具体原理可以看《Physically Based Rendering》第五章和第八章相关内容)。
    在Lambert模型中,所有方向的反射光的辐射率都相等,辐射率大小与入射光与表面法线的夹角的余弦值成正比。OrenNayar是一种微表面反射模型,即表面是由无数个微小的表面构成,每个微表面被假设为是理想Lambert模型,OrenNayar模型中考虑了微表面之间的遮挡,阴影,法线方向等因素。对OrenNayar的原理感兴趣的同学可以看《Oren_SIGGRAPH94》文档(反正我是没怎么看懂)。该文档的开头引入了一张图片


    这张图片主要是展示理想Lambert模型的不足,左边为真实的圆柱体花瓶,右边是用理想Lambert模型渲染的圆柱体花瓶。可以看到,Lambert模型中,当靠近花瓶两侧边缘时,亮度显著下降,而真实的花瓶的亮度变化整体上基本是趋于平坦的。而用OrenNayar渲染的模型可以更接近真实。

    可以看下shadertoy中渲染出来的两个球


    可以看出,Lambert模型随着角度变化亮度会快速下降,而OrenNayar模型亮度变化趋于平缓。

    光线追踪原理

    简单画了个图,简单概况下光线追踪原理。我们之所以能看见物体,是因为光源发射的光经过物体表面的反射,最终有一部分进入了我们的眼睛。光线追踪为了模拟现象,从眼睛(或相机)位置出发,向屏幕上的每个像素上发射一条或多条射线,屏幕像素的颜色和亮度,取决于射线进入场景后与物体交点的反射辐射率(如果交点)或者背景颜色(如果没有交点)。

    光线追踪的具体实现由许多方式(比如path tracing),大部分是采用递归的方式,即光线碰到物体表面后,计算反射方向后继续前进,直到遇到光源或递归次数达到最大值。本文要分析的光线追踪非常简单,只检测一次碰撞,但用了特殊的技巧来模拟遮挡和阴影,后面会讲到。

    下面开始分析代码。

    第一步,创建相机空间,放置光源,球体等

    首先要明确一点,因为shadertoy中是在像素着色器中编程,像素着色器的代码本身就是对每一个像素进行操作的过程,因此我们不需要像在CPU中实现光追那样手动搭建一个虚拟相机的屏幕(上图的film平面),也不需要循环遍历每个像素(像素着色器在GPU中并行运行了)。

    粘贴相关代码:

    void mainImage( out vec4 fragColor, in vec2 fragCoord )
    {
       //第一步,创建相机空间,放置光源,球体等    
       vec2 p = (-iResolution.xy + 2.0*fragCoord.xy) / iResolution.y;  //屏幕坐标
    
            //1.1 光源
        vec3 lig = normalize( vec3( 0.6, 0.5, 0.4) );   //主光源
        vec3 bac = normalize( vec3(-0.6, 0.0,-0.4) ); //辅助光源
        vec3 bou = normalize( vec3( 0.0,-1.0, 0.0) );  //辅助光源
        
         // 1.2 相机矩阵
        float an = 0.6 - 0.5*iTime;  
        vec3 ro = vec3( 3.5*cos(an), 1.0, 3.5*sin(an) );   //相机位置,随时间移动
        vec3 ta = vec3( 0.0, 1.0, 0.0 );        //相机方向
    
        // camera matrix
        vec3 ww = normalize( ta - ro );
        vec3 uu = normalize( cross(ww,vec3(0.0,1.0,0.0) ) );
        vec3 vv = normalize( cross(uu,ww));
        // create view ray
        vec3 rd = normalize( p.x*uu + p.y*vv + 1.5*ww );  //(p.x,p.y, 1.5)是屏幕上的像素在相机空间的位置(或者说是相机空间下的射线方向,因为射线的原点是(0,0,0)),乘以lookat矩阵后,rd转换为世界空间的射线
    
        //1.3 放置球体
        vec4 sph1 = vec4(-1.2,1.0,0.0,1.0);
        vec4 sph2 = vec4( 1.2,1.0,0.0,1.0);
    
       //…….
    }
    
    1.1 光源

    在场景中放置的三个光源,lig,bac,bou,它们都是方向光,向量就代表光源的方向。lig是主光源,对效果贡献最大,是产生直接反射和阴影的主要来源。bac和bou主要是为了模拟真实场景的间接反射而加入的,对效果贡献较小。

    1.2 相机矩阵

    我们需要相机矩阵(或者叫lookat矩阵)来进行相机坐标系和世界坐标系之间的转换。原理不说了,重要的一点是在相机空间中,观察方向(从视点望向目标点)是+z轴。如下图


    1.3 放置球体

    两个球体,sph1和sph2,其中 sph1.xyz 为球体的在世界空间的坐标,sp1h.w 为球体的半径。

    第二步,判断光线是否与场景中物体是否相交,并求出遮挡系数。

    上一步计算的向量rd,就是从摄像头出发,并穿过当前像素的射线,我们现在要逐一判断它和场景中的物体是否相交,也就是光线追踪。

    1.1 判断射线与平面是否相交
    // 1.1 判断射线与平面是否相交   
    float h = (0.0-ro.y)/rd.y;
        if( h>0.0 ) 
        { 
               //求出两个球的遮挡影响系数occ
               //.....
            obj = 0.0;   //obj = 0.0标记光线与平面相交了
        }
    }
    

    除了两个球体外,场景中还有一个y == 0的平面。用 h = (0.0-ro.y) / rd.y 来判断射线和平面是否相交。如下图,比如左边的,ro.h > 0,rd.y < 0那么h = (0.0-ro.y)/rd.y 的结果h就是大于0,即射线会与平面相交。相反,如右边的ro和rd,计算出来h小于0,不相交。

    如果是相交的,那么接下来要干嘛的,我们先看下面这个图:


    首先要注意到,rd是从相机发出的射线,它实际上是经物体表面反射进入到相机的光线wo的取反。wo实际上是场景中所有光入射光线wi的总体作用(这就是BRDF)。这些wi,有可能是直接光照(图中的wi1),也有可能是间接光照(图中的wi2)。因为场景中还存在别的物体(两个球),光源有可能被球挡住了(比如图中的light2的一些光线就无法达到pos)。我们可以确定的是,如果直接光被遮挡得越多,那么到达pos的光的总能量肯定越少,导致wo强度越小。那么如何衡量遮挡的影响呢?作者用了一个方法来估计球体遮挡的影响,那就是oSphere函数。

    float oSphere( in vec3 pos, in vec3 nor, in vec4 sph )
    {
        vec3 di = sph.xyz - pos;
        float l = length(di);
        return 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l); 
    }
    

    如下图,di的长度l表示球与交点pos的距离,shp.w表示球的半径,dot(nor, di / l)表示di与平面法线的夹角。返回的遮挡系数等于 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) ,如果仔细观察这个公式不难发现,当夹角越小(dot越大),或者球半径sph.w越大,或者距离l越小时,max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) 越大,也就是 1.0 - max(0.0, dot(nor, di / l)) * sph.w * sph.w / (l * l) 这个遮挡系数越小。换句话说,当球半径越大,或者距离越近,或者球体位置接近平面的法线时,球体对于交点遮挡的光线就越多,返回的遮挡系数就越小

    tip:个人觉得这只是一种估计遮挡系数的技巧,一般的光线追踪都是通过生成shadow光线来判断可见性的(可以看《PBRT》的相关章节)

    知道了oSphere函数的作用后,我们应该就能理解当下面的代码了

    // 1.1 判断射线与平面是否相交   
    float h = (0.0-ro.y)/rd.y;
        if( h>0.0 ) 
        {       
                    tmin = h; 
            nor = vec3(0.0,1.0,0.0);   //平面法线
            pos = ro + h*rd;               //射线与平面交点
            occ = oSphere( pos, nor, sph1 ) *
                  oSphere( pos, nor, sph2 );  //求出两个球的遮挡影响系数occ
            obj = 0.0;   //obj = 0.0标记光线与平面相交了
        }
    }
    

    因为场景中一共有两个球,遮挡系数occ是sph1和sph2遮挡系数的乘积。

    1.2 判断射线与球体是否相交

        h = iSphere( ro, rd, sph1 );
        if( h>0.0 && h<tmin ) 
        { 
            tmin = h; 
            pos = ro + h*rd;
            nor = normalize(pos-sph1.xyz); 
            occ = (0.5 + 0.5*nor.y) *
                  oSphere( pos, nor, sph2 );
            obj = 1.0;
        }
        h = iSphere( ro, rd, sph2 );
        if( h>0.0 && h<tmin ) 
        { 
            tmin = h; 
            pos = ro + h*rd;
            nor = normalize(pos-sph2.xyz); 
            occ = (0.5 + 0.5*nor.y) *
                  oSphere( pos, nor, sph1 );
            obj = 2.0;
        }
    

    iSphere函数判断射线与球体是否相交:

    float iSphere( in vec3 ro, in vec3 rd, in vec4 sph )
    {
        vec3 oc = ro - sph.xyz;
        float b = dot( oc, rd );
        float c = dot( oc, oc ) - sph.w*sph.w;
        float h = b*b - c;
        if( h<0.0 ) return -1.0;
        return -b - sqrt( h );
    }
    

    推导过程不分析了,有兴趣的话可以看下我前面写的《pbrt笔记--第三章 Shapes》3.2.2节的推导。当iSphere函数返回值大于0时,则表示射线与球体相交。

    当相交时,同样要判断场景中其他物体的遮挡影响:occ = (0.5 + 0.5*nor.y) *
    oSphere( pos, nor, sph2 );其中(0.5 + 0.5*nor.y) 是平面遮挡的影响(总感觉怪怪的),oSphere( pos, nor, sph2 )是sph2的遮挡影响。另一个球的相交测试也类似,不赘述了。

    上面还有一个地方要注意的是,如果射线既与平面相交,又与球体相交,那么怎么处理呢?如下图:


    假设射线和平面有一个交点pos2,对应的射线的系数为h2;射线与球体也有一个交点pos1,对应的射线的系数为h1。这个时候,如果h1比h2小,说明射线先和球体相交,那么射线前进的方向就被挡住了,也就不存在pos2这个交点。代码中是通过tmin来记录的。

        h = iSphere( ro, rd, sph1 );
        if( h>0.0 && h<tmin )   //tmin是由上一步计算交点得出的,只有当h小于tmin时,球和射线才有交点
        { 
    ///...
        }
    
    1.3 计算阴影

    相关代码如下:

        vec3 col = vec3(0.93);
        if( tmin<100.0 )  //如果有交点
    {
             pos = ro + tmin*rd;   //计算出交点位置
    
            // shadows
            float sha = 1.0;
            sha *= ssSphere( pos, lig, sph1 );
            sha *= ssSphere( pos, lig, sph2 );
    }
    

    经过上面的步骤,如果射线与场景中的物体存在交点(if( tmin<100.0 )),我们求出最近的交点pos后,就开始制作阴影效果。

    阴影效果主要由ssSphere函数返回的sha系数来决定的。我们先不看ssSphere的实现,简单说下如何得出阴影效果,如下图:


    假设经过前面的计算,我们得出平面上一个交点pos(这个交点是相机发出的射线和平面的交点,而不是光源和平面的交点)。pos是否处于阴影中,实际上就是看主光源lig的光是否可以直接到达pos上。图中左边的pos被球挡住了,那么pos就应该处于阴影之中,反之,右边的pos被lig直接照射到,应该具有较强的亮度。现在我们看等式sha *= ssSphere( pos, lig, sph1 );pos为交点,lig为光源,sph1为球体1,我们大概已经猜到,ssSphere函数的作用就是lig和pos的光线是否和shp1有交点!(有交点说明被遮挡了)

    float ssSphere( in vec3 ro, in vec3 rd, in vec4 sph )
    {
        vec3 oc = sph.xyz - ro;
        float b = dot( oc, rd );  //rd和oc的夹角的余弦
        
        float res = 1.0;
        if( b>0.0 )   //
        {
            float h = dot(oc,oc) - b*b - sph.w*sph.w;  //注意h小于0的时候是有交点的
            res = clamp( 16.0 * h / b, 0.0, 1.0 );
        }
        return res;
    }
    

    首先会判断oc和rd的夹角的余弦值,当b小于0.0时,说明夹角超过了90度或者小于0度,由下图可以看出,球无论如何都无法遮挡住光线,返回值res就是1.0.


    如果夹角小于90度时,球就有可能挡住要到达ro的光线。那么就判断从ro出发,方向为rd的射线是否和球有交点。如果有交点(h<0时),那么公式clamp( 16.0 * h / b, 0.0, 1.0 );就会返回0。如果没有交点(h>0时,),res的值就为16.0 * h / b(为啥是这个还不是很清楚)。

    将各个光源的效果进行叠加

    代码如下:

            // integrate irradiance with brdf times visibility
            vec3 diffColor = vec3(0.18);
            if( obj>1.5 )   //sph1
            {
                lin += vec3(0.5,0.7,1.0)*diffColor*occ;
                lin += vec3(5.0,4.5,4.0)*diffColor*OrenNayar( lig, nor, rd, 1.0 )*sha;
                lin += vec3(1.5,1.5,1.5)*diffColor*OrenNayar( bac, nor, rd, 1.0 )*occ;
                lin += vec3(1.0,1.0,1.0)*diffColor*OrenNayar( bou, nor, rd, 1.0 )*occ;
            }
            else   //sph2
            {
                lin += vec3(0.5,0.7,1.0)*diffColor*occ;
                lin += vec3(5.0,4.5,4.0)*diffColor*Lambert( lig, nor )*sha;
                lin += vec3(1.5,1.5,1.5)*diffColor*Lambert( bac, nor )*occ;
                lin += vec3(1.0,1.0,1.0)*diffColor*Lambert( bou, nor )*occ;
            }
    

    有代码可知,对sph1使用的是OrenNayar模型的BRDF,对sph2使用的是Lambert模型的BRDF。

    gamma校正

    因为我们现在计算的亮度是在线性空间,而显示器会对该亮度调整到gamma2.2空间,所以我们在把亮度传给显示器前要把它做一次gamma校正。(gamma校正的问题可以参看https://zhuanlan.zhihu.com/p/66558476

        // gamma    
        col = pow( col, vec3(0.45) );
    

    相关文章

      网友评论

          本文标题:Shadertoy--光线追踪与Nayar反射模型

          本文链接:https://www.haomeiwen.com/subject/imfcoctx.html