【WebGPU实时光追美少女】BRDF与蒙特卡洛积分

少女dtysky

世界Skill

时刻2021.10.01

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

在上一篇文章BVH与射线场景求交插值中,我论述了如何对场景进行划分、如何求得射线和场景中三角形的交点、如何判断射线和某点之间是否有遮挡、射线是否与光源相交,以及如何得到插值后的顶点和材质属性。有了这些数据,便可以进行最终的光照计算,这里会涉及到BRDF模型、直接光照、间接光照、蒙特卡洛积分、重要性采样、随机数生成等等的概念和实现。

长假第一天从学习开始,大家卷起来!

BRDF

BRDF,即双向反射分布函数,是一种针对物体表面的光照反射模型,其综合了漫反射和镜面反射两部分,给出了一种比较物理真实的算法。

从渲染方程说起

在一般的渲染中,光照本质上是一个针对“入射光线”(一般由方向、亮度、颜色等描述)击中“表面”(一般由法线、各种材质参数描述)后,求得表面最终颜色的计算。为了统一描述这个过程,在1986年,渲染方程被提出:

L0(p,w0)=Le(p,w0)+ξ2fr(p,wi,wo)LicosθdwiL_0(p,w_0) = L_e(p,w_0) + \int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{i}cos{\theta}dw_{i}

渲染方程描述了一个基于辐射度量学和能量守恒定律的光照模型。其中p是计算光照的点,wo是出射方向,Le部分可认为是自发光,后面部分是一个半球积分,其描述了此点入射半球内的光照累加和,其中wi是入射方向,fr是散射函数,Li是入射辐亮度(单位立体角单位面积的功率),cos是法线和入射光线的夹角。

至于为何要用辐亮度,可以参考闫老师的Games101。

可见,除了自发光这个比较简单的参数,其他计算都和“入射方向”、“光照点的材质属性”和“出射方向”有关。而其中最重要的就是fr,即散射函数。那么散射函数到底是什么呢?它描述的实际上是出射辐亮度和入射辐亮度微分的比例,也可以认为是一条光线从特定方向入射、出射时发生的能量吸收后剩余的能量。

不同的光照模型实际上就是在定义fr这个散射函数,尤其是对于PBR(基于物理的渲染),就是在寻找模型来尽量逼近物理真实。

BRDF模型

BRDF就是一种散射函数,其不考虑透射现象,只考虑非透明物体的表面反射和回弹,所以是双向反射分布函数。我们这里使用的BRDF模型定义为:

fr(p,wi,wo)=kdcπ+ksDFG4cosθicosθof_{r}(p,w_{i},w_{o}) = k_{d}\frac{c}{\pi} + k_{s}\frac{DFG}{4cos\theta_{i}cos\theta_{o}}

可见BRDF模型分为两部分,第一部分为漫反射,第二部分为高光反射,这两部分各自有一个系数,这个系数由菲涅尔系数决定。这两部分基于微表面模型,它将每一个着色点理解为很多朝向不同的微小表面的集合:

在这个模型下,每条光线入射某个着色点后,都将以一定的概率反射到不同的方向,并有不同程度的能量损耗。

菲涅尔方程

菲涅尔方程描述了这样的而一种现象——当光从一个介质入射到另一个不同折射率的介质的表面时,一部分会被反射、另一部分会被折射:

被菲涅尔反射的这部分就是高光反射,而另外么有被折射也没有被吸收的部分就是漫反射。菲涅尔系数的计算一般和材质属性相关,这个后面会说。

注意对于金属是没有漫反射的,因为这部分能量会被完全吸收掉,这在后面实际的计算中也会体现。

漫反射

漫反射部分从表现上来看,就是一条光线入射到着色点后,没有被镜面反射的部分在物体内部或微表面多次弹射,没有被吸收的部分会随机得反射到整个法线半球中,其出射只和入射方向与法线相关,和观察方向无关:

在我们使用的这个模型中,对其抽象比较简单,认为这个随机是均匀的,所以可以看到是一个定值c/pic是反照率,主要由baseColor和不同工作流的系数控制,除以pi是因为fr是将辐照度转换到辐亮度的比例,需要换算。

高光反射

和漫反射不同,BRDF的高光反射部分相对复杂。高光部分本身其实挺简单的,就是根据入射光线、法线、视线计算一个反射比例,从而确定出射光线的方向和强度。但由于微表面模型的存在,着色点并非是一个简单的镜面,我们需要能够模拟着色点表面粗糙的状况,这也就是这部分在BRDF的公式中比较复杂的原因。

如公式所示,高光反射主要分为三个部分:菲涅尔项F、法线分布D和几何遮蔽G。其中菲涅尔项在上面已经论述过;法线分布本质上描述了模型中各个微表面的分布状况,有不同实现;几何遮蔽则是为了描述微表面的反射光线可能被其他微表面遮挡的现象,也有不同的实现。但由于这里只是先说原理,详细的实现会放在后面讲:

现在我们有了一个理论上的光照模型,可以在光线的入射和出射方向都确定的情况下计算出入射和出射之间的比例,但对于路径追踪而言,还有以下问题要解决:

  1. 对于这样一个很难给出解析解的积分,如何求解。
  2. 对于实时渲染,几乎不可能一帧完成积分,如何处理。
  3. 如何通过已知的出射光线和材质属性,求取入射光线。

这些问题,都可以通过蒙特积分解决。

蒙特卡洛积分

蒙特卡洛积分是一种统计方法,用于求解一个难以求出解析解的积分的数值解。其基本思路是采样平均

如图,f(x)为要求解的积分,我们可以在其定义域上采样,如此便可以产生许多矩形样条,用这些样条去采样然后相加求平均,便可以证明其数学期望与希望得到的积分一致,这也证明通过蒙特卡洛积分得到的结果是无偏的。当然,这个无偏不代表有效性,所以我们必须要保证大量的样本才能使得结果最终收敛于真值。

采样与概率密度

在实际的实现中,我们往往希望尽量减少采样的次数,以最快的速度达到收敛。好在蒙特卡洛积分允许我们进行非均匀采样,即提供一个分布来取代均匀采样,如此一来只要保证无偏,就可以就可能提高有效性,来达到最快的收敛速度:

Fn=1Ni=1Nf(Xi)p(Xi)F_{n} = \frac{1}{N}\sum_{i=1}^{N}\frac{f(X_{i})}{p(X_{i})}

如公式,其中p(x)是需要提供分布的概率密度函数(PDF)。所以对于我们而言最重要的如何选择这个用于采样的概率分布,这就要提到重要性采样:

如图,重要性采样指的是对于被积函数,我们选取的采样分布能够满足随着被积函数而变化——被积函数值越大的地方,采样点选取概率越高。同时有了PDF之后,我们还需要一个方法来采样,然而不幸的是,目前计算机只能实现对均匀分布的采样,所以就需要有一个策略来通过均匀分布的输入来通过指定的PDF,来求取最终的采样值。

对于路径追踪的应用,已经有比较成熟的重要性采样的分布了,同时通过均匀分布求取采样点的方法也很成熟,下个章节就会说到。

随机数

在实际讨论重要性采样之前,我想先说说随机数,或者说是如何生成均匀分布的随机数,毕竟这是接下来所有采样的数据源头。对于路径追踪的应用而言,我们希望这个随机数尽可能均匀和无偏,所以这里选择的是一个四维Blue Noise(天蓝噪声):

如图,蓝噪声分布均匀且频率较高。理论上来讲,要对蓝噪声进行采样,最好使用分层等策略,但这里我图简单,就直接每帧用Math.random()传进来四个随机数,然后加一下算了...

fn getRandom(uv: vec2<f32>, i: i32) -> vec4<f32> {
  let noise: vec4<f32> = textureSampleLevel(u_noise, u_sampler, uv, 0.);

  return fract(material.u_randoms[i] + noise);
}

重要性采样

上一章节提到了重要性采样可以提高蒙特卡洛几分的采样有效性,来加快收敛速度,而重要性采样的核心有二:

  1. 找到合适的分布,求取PDF。
  2. 寻找通过均匀分布转换为该分布的方法。

在路径追踪中,我们一般需要对以下几种状况分别进行采样。接下来就从以上两点,论述这几种状况。

采样部分的代码都在buildin/shaders/sample.chuck.wgsl内。

漫反射半球采样

漫反射需要的是在半球空间内均匀采样,这里采用的是Cosine-Weighted Hemisphere Sampling,即先进行均匀的圆盘采样,然后转换到半球空间:

// 圆盘采样
fn sampleCircle(pi: vec2<f32>) -> vec2<f32> {
  let p: vec2<f32> = 2.0 * pi - 1.0;
  let greater: bool = abs(p.x) > abs(p.y);
  var r: f32;
  var theta: f32;

  if (greater) {
    r = p.x;
    theta = 0.25 * PI * p.y / p.x;
  } else {
    r = p.y;
    theta = PI * (0.5 - 0.25 * p.x / p.y);
  }

  return r * vec2<f32>(cos(theta), sin(theta));
}

// 半球采样
fn cosineSampleHemisphere(p: vec2<f32>) -> vec3<f32> {
  let h: vec2<f32> = sampleCircle(p);
  let z: f32 = sqrt(max(0.0, 1.0 - h.x * h.x - h.y * h.y));

  return vec3<f32>(h, z);
}

// 最终生成漫反射部分的入射光向量
fn calcDiffuseLightDir(basis: mat3x3<f32>, sign: f32, random: vec2<f32>) -> vec3<f32> {
  return basis * sign * cosineSampleHemisphere(random);
}

输入的random是一个均匀分布的随机向量,由前面章节论述的随机数提供,sign是后续要提到的一个法线的符号,而basis,顾名思义是通过法线生成的一个变换矩阵,用于cosineSampleHemisphere生成的本地单位空间的向量,转换到世界空间的法线半球内,这里使用的是pixar采用的一种算法

fn orthonormalBasis(normal: vec3<f32>) -> mat3x3<f32> {
  var zsign: f32 = 1.;
  if (normal.z < 0.) {
    zsign = -1.;
  }
  let a: f32 = -1.0 / (zsign + normal.z);
  let b: f32 = normal.x * normal.y * a;
  let s: vec3<f32> = vec3<f32>(1.0 + zsign * normal.x * normal.x * a, zsign * b, -zsign * normal.x);
  let t: vec3<f32> = vec3<f32>(b, zsign + normal.y * normal.y * a, -normal.y);

  return mat3x3<f32>(s, t, normal);
}

这种分布的PDF为:

$$\frac{cos\theta}{\pi}$$

其中角度为法线和入射光线的夹角。

高光GGX采样

高光反射比较复杂,业界提出过的模型也比较多,这里最终采用的是目前工业界比较通用的GGX分布:

fn calcSpecularLightDir(basis: mat3x3<f32>, ray: Ray, hit: HitPoint, random: vec2<f32>) -> vec3<f32> {
  let phi: f32 = PI * 2. * random.x;
  let alpha: f32 = hit.pbrData.alphaRoughness;
  let cosTheta: f32 = sqrt((1.0 - random.y) / (1.0 + (alpha * alpha - 1.0) * random.y));
  let sinTheta: f32 = sqrt(1.0 - cosTheta * cosTheta);
  let halfVector: vec3<f32> = basis * hit.sign * vec3<f32>(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);

  return reflect(ray.dir, halfVector);
}

由于是高光反射,所以分布和法线相关很合理。而这种分布的PDF为:

$$\frac{D \vec N \cdot \vec H}{4\vec L \cdot \vec H}$$

其中D是法线分布函数,N是法线,L是入射光线,H是半程向量。

直接光和间接光

在工程实现中,我们并非在路径追踪的每次迭代中直接求出入射光线,然后反向追踪,看其和物体还是光源相交。虽然在样本足够多的情况下得到的结果仍然是无偏的,但出于尽可能加快收敛速度的考量,我们需要思考如下事实:

对于某一着色点,光源往往是光的主要来源,提供了最多的能量,而从其他物体反射而来的能量则相对较少。

这里有两种解法,第一种是找到一个合适的分布,来使得采样采到光源的概率增加,但显然由于场景的动态性,这种分布十分难以找到。所以我们往往使用第二种方法——对渲染方程中的Li进行拆分,对光源单独采样:

$$\int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{e}cos{\theta}dw_{i} + \int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{s}cos{\theta}dw_{i}$$

如公式,我们将渲染方程的积分部分拆成了两个部分,第一个部分为直接光照,表示对光源的直接采样;第二个部分为间接光照,表示对其他部分的采样。间接光照的采样其实就是上面说的两种,而直接光照的采样,就是对面光源的采样。

面光源采样

本项目中我一共使用了discrect两种面光源,它们的采样和PDF本身都相对简单:

var samplePoint2D: vec2<f32>;
// 求出光源平面世界空间的法线
let normal: vec3<f32> = normalize((areaLight.worldTransform * vec4<f32>(0., 0., -1., 0.)).xyz);
var area: f32;
if (areaLight.areaMode == LIGHT_AREA_DISC) {
  // `disc`光源,进行圆盘采样并求得面积
  samplePoint2D = sampleCircle(random) * areaLight.areaSize.x;
  area = 2. * PI * areaLight.areaSize.x;
} else {
  // `rect`光源,可以非常简单得通过均匀分布变换采样而来,并求得面积
  samplePoint2D = (random) * areaLight.areaSize;
  area = samplePoint2D.x * samplePoint2D.y;
  samplePoint2D = samplePoint2D * 2. - areaLight.areaSize;
}

// 将采样点变换到世界空间,并求出光线向量
let samplePoint: vec4<f32> = areaLight.worldTransform * vec4<f32>(samplePoint2D.x, samplePoint2D.y, 0., 1.);
var sampleDir: vec3<f32> = samplePoint.xyz - hit.position;

// 算出光源平面和光线方向的夹角余弦
let cosine: f32 = dot(normal, sampleDir);
if (cosine > 0.) {
  // 证明光线是从光源背面而来,没有光照
  return vec3<f32>(0.);
}

// 通过投影面积和长度,求得最后的PDF
let maxT: f32 = length(sampleDir);
let pdf: f32 = maxT * maxT / (area * -cosine);
let directLight: vec3<f32> = areaLight.color.rgb / pdf;

可见在整个运算过程中,采样是比较简单的,最后PDF的求取中我们依靠了距离和投影面积,因为计算中有一个从本地空间投影到法线半球空间的过程。

环境光采样

理论上我们也需要支持环境光,环境光一半来自于一个环境贴图,或者说一般是是天空盒。对于环境贴图的采样,同样存在尽可能采样光源部分的问题,但这里我没做,有兴趣的同学可以自己查阅资料。对于环境贴图,我的做法就是很无脑得直接采样纹理颜色。

工程实现

原理到这就论述得差不多了,接下来是实际的工程实现。首先我们要给整个着色过程定义个框架。

光照部分的代码都在buildin/shaders/lighting.chuck.wgsl内。

框架

着色的过程从前面文章提到的GBuffer开始,GBuffer中存储了可以认为是第一次射线检测到的着色点的世界坐标,通过它可以很简单得重建出第一条射线:

let gBInfo: HitPoint = getGBInfo(baseIndex);

if (gBInfo.isLight) {
  textureStore(u_output, baseIndex, vec4<f32>(gBInfo.baseColor, 1.));
  return;
}

if (!gBInfo.hit) {
  // 处理没有被光栅化的像素,即等价为第一条射线没有相交的三角形
  // 直接采样环境贴图返回
  let t: vec4<f32> = global.u_skyVP * vec4<f32>(baseUV.x * 2. - 1., 1. - baseUV.y * 2., 1., 1.);
  let cubeUV: vec3<f32> = normalize(t.xyz / t.w);
  let bgColor: vec4<f32> = textureSampleLevel(u_envTexture, u_sampler, cubeUV, 0.);
  // rgbd
  textureStore(u_output, baseIndex, vec4<f32>(bgColor.rgb / bgColor.a * global.u_envColor.rgb, 1.));
  return;
}

let worldRay: Ray = genWorldRayByGBuffer(baseUV, gBInfo);

这是第一条出射光线,通过这个光线便可以进入实际的着色流程,然后不断反射、直接光照和间接光照,最后终止。这个过程中有两点需要注意:

终止条件

路颈追踪是理论上可以是一个无穷的过程,直到射线和场景不相交为止,但我们显然不能让其一直进行。在实际的测试中,一般每条光线进行七八次bounce(弹射)后就可以认为已经到极限了,每次bounce都是一次间接光照。对于实时而言,在不考虑透射的情况下,我们往往只会进行一次bounce,这意味着在最终效果中我们只会体现出一次的间接光照,从能量守恒的角度这自然是有瑕疵的,但效果也比传统得好得多。

由此来看,场景越亮、层次越丰富就代表越真实、效果越好。

当然如果不考虑这么苛刻的实时性,一般还有像是俄罗斯轮盘赌这样的策略来达成终止条件,也就是通过随机性来判定是否终止。

射线原点

在每次bounce过程中,我们都会生成一个由特定原点发出、到特定方向的射线。但由于浮点数运算精度等问题,如果只是刚好以着色点重心插值后的世界坐标为起点,可能会产生锯齿、乱纹等现象,这一般是由于不该自相交的时候自相交(比如普通反射)或该自相交的时候不自相交(比如阴影射线)。所以需要人为给射线原点加上一个偏移:

hit.position + light.next.dir * RAY_DIR_OFFSET + RAY_NORMAL_OFFSET * hit.normal

可见其实就是在法线方向、以及射线方向加了个微小的偏移。

光照累计

路径追踪是迭代累加的,在每次bounce的过程中,我们总是计算当前着色点的直接光照,然后计算间接光照的系数fr,而间接光照的能量部分则来自于下一次bounce,这就可以很清晰得得出一个计算步骤:

fn traceLight(startRay: Ray, gBInfo: HitPoint, baseUV: vec2<f32>, debugIndex: i32) -> vec3<f32> {
  var light: Light = calcLight(startRay, gBInfo, baseUV, 0, false, false, debugIndex);
  var lightColor: vec3<f32> = light.color;
  var throughEng: vec3<f32> = light.throughEng;
  var hit: HitPoint;
  var ray: Ray = light.next;
  var lightHited: vec4<f32>;
  var bounce: i32 = 0;

  loop {
    let preIsGlass = hit.isGlass;
    lightHited = hitTestLights(ray);
    hit = hitTest(ray);
    let isHitLight: bool = lightHited.a <= hit.hited;
    let isOut: bool = !hit.hit || isHitLight;

    if (isHitLight) {
      light.color = lightHited.rgb;
    } else {
      light = calcLight(ray, hit, baseUV, bounce, isLast, isOut);
      ray = light.next;
    }

    lightColor = lightColor + light.color * throughEng;
    throughEng = throughEng * light.throughEng;
    bounce = bounce + 1;

    if (max(throughEng.x, max(throughEng.y, throughEng.z)) < 0.01 || isOut) {
      break;
    }
  }

  return lightColor;
}

其中最值得关注的是lightColorthroughEng,第一个是当前直接光照的结果,第二个是出射(未被吸收)的能量比例。可见这里认为如果检测到了和光源相交,则直接累计,否则而进入光照计算阶段,计算完成后将当前的直接光照结果和累计至今的能量损耗计算,得到应当计入的能量。而光照的计算部分框架实现为:

fn calcLight(ray: Ray, hit: HitPoint, baseUV: vec2<f32>, bounce: i32, isLast: bool, isOut: bool) -> Light {
  var light: Light;
  // 计算随机数
  let random = getRandom(baseUV, bounce);

  if (isOut) {
    // 如果射线和场景无相交,则进行环境光照
    light.color = calcOutColor(ray, hit);
    return light;
  }

  // 直接光照
  light.color = calcDirectColor(ray, hit, random.zw);

  if (isLast) {
    // 最后一次测试,不再进行间接光照计算
    return light;
  }

  // 间接光照过程
  let probDiffuse: f32 = getDiffuseProb(hit);
  let isDiffuse: bool = random.z < probDiffuse;
  let nextDir: vec3<f32> = calcBrdfDir(ray, hit, isDiffuse, random.xy);

  if (isDiffuse) {
    light.throughEng = calcDiffuseFactor(ray, hit, nextDir.xyz, probDiffuse);
  } else {
    light.throughEng = calcSpecularFactor(ray, hit, nextDir.xyz, probDiffuse);
  }

  light.next = genRay(hit.position + light.next.dir * RAY_DIR_OFFSET + RAY_NORMAL_OFFSET * hit.normal, nextDir.xyz);
  return light;
}

可见光照计算切实被分为了直接光照间接光照环境光照(没有做特别处理,直接采样贴图)三部分。有了基本的框架,就可以进行真正的光照计算了。

直接光照

当射线和表面上的着色点相交后,首先要进行的是直接光照计算。这里分两步,第一步需要对光源进行采样,第二部则是用这个采样的结果进行计算。采样的部分在上面已经论述过(得到了入射光的方向和辐射度),接下来就是利用采样的结果进行fr的计算:

fn calcDirectColor(ray: Ray, hit: HitPoint, random: vec2<f32>) -> vec3<f32> {
  // 同上面光源采样部分的代码,求得了入射光方向`sampleLight`、距离`maxT`和辐射度`directLight`等

  let shadowInfo: FragmentInfo = hitTestShadow(sampleLight, maxT);

  if (shadowInfo.hit) {
    return vec3<f32>(0.);
  }

  let brdf = calcBrdfDirectOrSpecular(hit.pbrData, hit.normal, -ray.dir, sampleDir, true, 0.);

  return directLight * brdf;
}

可见这里主要有两步计算,第一步是进行阴影射线相交测试,这个在前面的文章《BVH与射线场景求交插值》中论述过,是为了判断光源和着色点之间是否有物体遮挡,如果有遮挡则证明在阴影范围内,直接返回。否则就进入下一步——计算fr,即代码中的brdf

直接光照的fr使用BRDF的高光反射模型。

间接光照

由于高光反射也被间接光照使用,所以在合理先论述间接光照。间接光照由漫反射和高光反射两部分构成,有了着色点和出射光线的信息后,可以按照上面章节论述的采样方式分别求得漫反射和高光反射的入射光线,但这里要注意一点,那就是比例。在框架一节的代码中,有这么一句话:

let probDiffuse: f32 = getDiffuseProb(hit);

这求取的是漫反射的比例,还记得一开始的BRDF方程中的kdks吗?其实就是它。在工程实现中,为了效率,我们往往不会在同一次bounce计算漫反射和高光反射将它们加权相加,而是将这个比例作为概率,来作为当次要进行漫反射还是高光反射的判据,而它的实现为:

fn getDiffuseProb(hit: HitPoint) -> f32 {
  let lumDiffuse: f32 = max(.01, dot(hit.pbrData.diffuseColor, vec3<f32>(0.2125, 0.7154, 0.0721)));
  let lumSpecular: f32 = max(.01, dot(hit.pbrData.specularColor, vec3<f32>(0.2125, 0.7154, 0.0721)));

  return lumDiffuse / (lumDiffuse + lumSpecular);
}

计算中分别求得了着色点的名为diffuseColorspecularColor的变量的亮度,然后算出了漫反射的权重。那么这两个变量又是如何求得的呢?这就涉及到PBR材质的处理了。

材质预处理

在前面的文章,我们了解了使用的材质模型,其中有金属和高光两种工作流,有不同的系数和贴图,现在我们需要将其处理为BRDF需要的参数:

fn pbrPrepareData(
  isSpecGloss: bool,
  baseColor: vec3<f32>,
  metal: f32, rough: f32,
  spec: vec3<f32>, gloss: f32
) -> PBRData {
  var pbr: PBRData;

  var specularColor: vec3<f32>;
  var roughness: f32;

  if (!isSpecGloss) {
    // 金属工作流
    roughness = clamp(rough, 0.04, 1.0);
    let metallic: f32 = clamp(metal, 0.0, 1.0);
    let f0: vec3<f32> = vec3<f32>(0.04, 0.04, 0.04);

    specularColor = mix(f0, baseColor, vec3<f32>(metallic));
    pbr.diffuseColor = (1.0 - metallic) * (baseColor * (vec3<f32>(1.0, 1.0, 1.0) - f0));
  }
  else {
  // 高光工作流
    roughness = 1.0 - gloss;
    specularColor = spec;
    pbr.diffuseColor = baseColor * (1.0 - max(max(specularColor.r, specularColor.g), specularColor.b));
  }

  pbr.baseColor = baseColor;
  pbr.specularColor = specularColor;
  pbr.roughness = roughness;

  let reflectance: f32 = max(max(specularColor.r, specularColor.g), specularColor.b);
  pbr.reflectance90 = vec3<f32>(clamp(reflectance * 25.0, 0.0, 1.0));
  pbr.reflectance0 = specularColor.rgb;
  pbr.alphaRoughness = roughness * roughness;

  return pbr;
}

这个方法完成了材质数据的预处理,其主要通过两种工作流,统一计算出了粗糙度、漫反射颜色、高光反射颜色、菲涅尔系数等等。这里可以看到比如金属工作流和高光工作流的差异,主要在于高光工作流可以自己控制f0,而金属工作流可以由金属度来控制漫反射比例(完全金属没有漫反射)。

高光反射

有了材质数据,可以先来求高光反射。我实现了一个方法,用于求解高光反射,针对直接光照间接光照的高光部分做了区分,但大部分计算都是一致的,区分主要在PDF部分。

fn calcBrdfDirectOrSpecular(
  pbr: PBRData, normal: vec3<f32>,
  viewDir: vec3<f32>, lightDir: vec3<f32>,
  isDirect: bool, probDiffuse: f32
)-> vec3<f32> {
  let H: vec3<f32> = normalize(lightDir + viewDir);
  let NdotV: f32 = clamp(abs(dot(normal, viewDir)), 0.001, 1.0);
  let NdotL: f32 = clamp(abs(dot(normal, lightDir)), 0.001, 1.0);
  let NdotH: f32 = clamp(abs(dot(normal, H)), 0.0, 1.0);
  let LdotH: f32 = clamp(abs(dot(lightDir, H)), 0.0, 1.0);
  let VdotH: f32 = clamp(dot(viewDir, H), 0.0, 1.0);
  // Calculate the shading terms for the microfacet specular shading model
  let F: vec3<f32> = pbrSpecularReflection(pbr.reflectance0, pbr.reflectance90, VdotH);
  let G: f32 = pbrGeometricOcclusion(NdotL, NdotV, pbr.alphaRoughness);
  let D: f32 = pbrMicrofacetDistribution(pbr.alphaRoughness, NdotH);

  let specular: vec3<f32> = F * G * D / (4.0 * NdotL * NdotV);

  if (isDirect) {
    let diffuse: vec3<f32> = NdotL * INV_PI * pbr.diffuseColor;
    return specular + diffuse;
  }

  let specularPdf: f32 = D * NdotH / (4.0 * LdotH);
  return NdotL * specular / (mix(specularPdf, 0., probDiffuse));
}

在求解的实现中,先不看PDF部分,主要计算的是specular,而其最主要的是菲涅尔项F、法线分布项D和几何遮蔽项G的实现。

F

菲涅尔项的求解实现为:

fn pbrSpecularReflection(reflectance0: vec3<f32>, reflectance90: vec3<f32>, VdotH: f32)-> vec3<f32> {
  return reflectance0 + (reflectance90 - reflectance0) * pow(clamp(1.0 - VdotH, 0.0, 1.0), 5.0);
}

这里采用的是Schilick近似方法。

D

法线分布我们采用的常见的GGX分布:

fn pbrMicrofacetDistribution(alphaRoughness: f32, NdotH: f32)-> f32 {
  let roughnessSq: f32 = alphaRoughness * alphaRoughness;
  let f: f32 = NdotH * NdotH * (roughnessSq - NdotH) + 1.0;
  return roughnessSq * INV_PI / (f * f);
}

G

几何遮蔽则使用Smith GGX近似:

fn pbrGeometricOcclusion(NdotL: f32, NdotV: f32, alphaRoughness: f32)-> f32 {
  let r: f32 = alphaRoughness * alphaRoughness;

  let attenuationL: f32 = 2.0 * NdotL / (NdotL + sqrt(r + (1.0 - r) * (NdotL * NdotL)));
  let attenuationV: f32 = 2.0 * NdotV / (NdotV + sqrt(r + (1.0 - r) * (NdotV * NdotV)));
  return attenuationL * attenuationV;
}

PDF

如果是直接光照,那么直接返回这个高光specular的值即可(因为直接光照的PDF已经在前面的计算除过了),但是对于间接光照,还是需要考虑PDF,以及漫反射的概率。前面提到过我们对高光使用的是GGX分布,其PDF公式翻译为代码为:

let specularPdf: f32 = D * NdotH / (4.0 * LdotH);

而在考虑漫反射概率后,其最终值为:

specular * NdotL / (mix(specularPdf, 0., probDiffuse));

漫反射

相对于高光,漫反射比较简单,其值根据原理所得即为hit.pbrData.diffuseColor * INV_PI,而PDF则为:

let diffusePdf: f32 = NdotL * INV_PI;

考虑到概率,最后的值为:

hit.pbrData.diffuseColor * INV_PI * NdotL / diffusePdf / probDiffuse;

化简后为:

hit.pbrData.diffuseColor / probDiffuse;

结果

至此,整个着色过程完成,由于是1SPP,并且只有一次bounce,在单帧着色后会得到一张充满噪点的结果:

那么接下来就自然要想到如何降噪了,下一文章将会提到。

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。