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

2019-12-26  本文已影响0人  奔向火星005

前言

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) );
上一篇下一篇

猜你喜欢

热点阅读