一些对辐射度量学的理解

随着硬件性能的提升,PBS/PBR已经越来越成为一种趋势。

Unity内置的standard shader都已经默认使用PBR算法。

而学习PBR过程中,有一门必提的知识是“辐射度量学”。

本来我以为我都学会了,毕竟path tracer都写出来了,并且效果看起来很正常。

但是最近在学习全局光照时,突然对“辐射度量学”突然产生了两个疑惑。

  1. 光线在传播过程中,Radiance是如何体现出,能量随着距离增加而衰减的?

    回忆在写path tracing时,代码并没有刻意去计算距离对光线能量的衰减。

    而从数学公式上看,在计算光线弹射时,反射方程的输入和输出都是Radiance。

    某一单位表面弹射出的某一个方向上的光线A的Radiance值,会在计算被光线A击中物体表面时直接使用。

    那么衰减到底在哪里体现的呢?

  2. 为什么一个物体表面弹射出的光线的Radiance,可以直接被光线击中的物体直接拿来计算?

    先看一下定义:

    入射Radiance, 单位面积单位立体角接收到的flux(功率)。 L(p,\omega)=\frac{dE\left(p\right)}{d\omega\cos\theta}

    出射Radiance, 单位面和单位立体角发出的flux(功率)。 L(p,\omega)=\frac{dI\left(p,\omega\right)}{dA\cos\theta}

    从定义上看,入射Radiance使用的是接收面接收方向的立体角,而出射Radiance是出射面出射方向上的立体角。

    那么,凭什么一个物体表面的出射光线Radiance可以直接被照射物体用来计算,而且还可以随距离智能衰减?


是的,所有答案的问题指向立体角,是立体角将他们神奇的联系到一起的。

先复习一下,立体角的定义:d\Omega=\frac{dA}{r^2}


然后回答第一个问题(这里仅考虑 单位表面与光线垂直的情况,所以去掉了cos项):

假设在平面DE上有一个单位表面A发光表面B发出了一条光线照向A。

根据Irradiance的定义,dE(p)\;=L(p,\omega)\;\ast\;d\omega

代入立体角公式就可以得出,单位表面A接收由表面B照射过来的功率为(HG表面积)/(HG离表面A的距离) * Radiance。如果将表面B由HG移动到JI位置,由于JI离表面A更远,单位表面A接收到的单位表面B照射出的功率更小。

那么path tracer是如何利用这一性质的呢,想象一下,从单位表面A发出720根射线,离发光表面B离表面A越近,被表面A的射线击中的次数就会越多,表面A就接收到表面B的能量就越多。


在回答问题1时,假定了第2个问题是毫无疑问的。但是入射Radiance和出射Radiance为什么一定是一样的呢?

我们先来化简一下入射Radiance出射Radiance的公式。

入射Radiance, L(p_2,\omega_2)=\frac{d^2\phi(p_2,\omega_2)}{d\omega_2dA_2\cos\theta_2}

出射Radiance, L(p_1,\omega_1)=\frac{d^2\phi(p_1,\omega_1)}{d\omega_1dA_1\cos\theta_1}

可以看到入射Radiance和出射Radiance公式完全一样,只不过作为接收平面,他使用是接收平面的面积,立体角,法线与光线的夹角。而作为出射平面,使用的是另一组参数而已。

到止前为止接收Radiance出射Radiance仅仅是公式相同而已,没有任何迹象表明他们会是同一个值。

借用《Fundamentals of Optics and Radiometry for Color Reproduction》中的一张图,来解释这奇迹的变换。

假设P1点所在的微平面ds1发出一条光线\xrightarrow[{P_2P_1}]{}照向点P2所在的平面ds2。

由于ds1在立体角d\omega_1方向上发出的能量被ds2全部吸收,所以\phi(p_2,\omega_2) = \phi(p_1,\omega_1)

根据立体角的定义:

d\omega_2dA_2\cos\theta_2

= \frac{ds_1\cos\theta_1}{r^2}dA_2\cos\theta_2

= ds_1\cos\theta_1\frac{dA_2\cos\theta_2}{r^2}

= ds_1\cos\theta_1d\omega_1

可以得出接收Radiance出射Radiance是完全相同的量。

嗯,这就是大名鼎鼎的radiance invariance

ps. 在研究这两个问题过程中,我还有另外一个疑惑,不过这个疑惑随着这两个问题的解决一起解决了。这个问题就是“对于一个微小平面dA, 他在整个单球上发射出的radiance是均匀(完全相等)的么?答案显然否定的,因为dA在整个半球上发射出的I是相同的,根据radiance公式,radiance必然不是均匀的。”

pps. 但是根据Lambert’s Law的漫反射模型,I_\theta=I_n*\cos\theta,也就是说物体跟光线的夹角会影响接收到的I_\theta,代入Radiance公式后,radiance在漫反射模型下是均匀的。

深度缓冲和半透明渲染

在场景渲染方式上,一般分为“不透明渲染”和“半透明渲染”。


“不透明渲染”意思是,如果A物体被B物体挡住,那么A物体将完全不可见。

至少有两种方式达到这种效果(假设相机的朝向为Z轴正方向):

  1. 由于屏幕上每一个相素都是由场景中某一世界坐标(Fragment)投影得到,在绘制时以Fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时直接覆盖FrameBuffer中的颜色值。

  2. 物体以任意顺序渲染,但是在渲染某一个Fragment时,会将其在相机空间的Z坐标写入一个叫做深度缓冲区的地方,在渲染Fragment之前会先使用相机空间下的Z坐标与深度缓冲区中当前Fragment所对应的深度值进行比较,如果Z更小,则替换之前Fragment所对应的相素颜色值,如果Z更大,则什么也不做。

虽然方式2的描述过长,但是方式2反而会更快。

因为方式1需要排序,那么排序之前,需要为所有物体生成Fragment数据,这会需要海量的内存。另外由于需要按顺序渲染,物体与物体之间的穿插关系可能会频繁打断DrawCall,这会显著降低我们图形程序的效率(Nvidia发出的资料表明,DrawCall过高会喂不饱GPU,导致CPU性能急剧下降)。

因此在实际项目中, 都是采用方式2来绘制“不透明物体“, 这就是大名鼎鼎的ZTest.


而”半透明渲染“, 则是如果A物体被B物体挡住,那么透过B可以看到A物体。

而要做到这一步,只能采用画家算法,绘制时以fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时,与当前FrameBuffer中的颜色值进行alpha混合。(与不透明渲染中的方式1的惟一差别,就在于最后写入颜色值的行为)。

但是前面说过了,如果要对所有物体的所有Fragment进行排序需要海量的内存,所以一般可行的实现方式都是将物体在相机空间下的Z坐标进行排序,然后由大到小进行绘制(ps.可能是为了性能考虑,Unity的透视相机在半透明渲染中,直接采用了世界坐标系下物体离相机的距离进行排序,然后由大到小渲染。显然他与Z坐标排序并不等价,以致于我们发现在拖动相机过程中,不同sprite之间的穿插关系像‘多米诺骨牌’般进行变化)。

这种折衷会导致:如果物体没有相互交插,都可以得到正确的结果。而一旦物体有交插,就会产生错误的结果。为了解决这个问题,还需要同时配合ZTest进行使用,这时就需要非常小心,如果处理不当就会产生一些非常微妙的效果。即使ZTest通过了,由于alpha值的影响,物体的渲染顺序依然会影响最终的渲染结果。


是的,之的所以会有这篇文章,就是因为我们在最近开发2.5D地形时,遇到了一些困境。

我们在地图上刷了大量的山和树(都是面片),这些山和树会有各种复杂的穿插关系。

最开始我们将所有的山和树以半透明的方式进行渲染,然后发现DrawCall被频繁打断,最坏的情况需要增加50+DrawCall。

此时摆在我们面前的只有两条路:

  1. 动态图集法,将当前屏幕内地形所用到的资源,动态合并成几张1024*1024的图集。但是如果算法不好,动态图集反而可能会加剧DrawCall的增加。

  2. 使用ZTest, 将所有山和树改为不透明渲染,然后开启ZWrite和ZTest来解决遮挡关系。由于所有的半透明纹理的alpha的值只有两种(0和1)。只要我将alpha=0的Fragment给clip掉,整个渲染流程跟”不透明“渲染流程完全一样,这样我们有几种材质就只需要几个DrawCall。

我第一反应,就选择了方案2。因为我觉得他很简单。但是这个世界是平衡的,怎么可能有如此完美的事情。

在我改写shader之后,很快就发现了问题。

  1. 山有巨大的阴影区,这块阴影的alpha值不为0,而且阴影不应该可以直接遮挡后面的物体。

  2. 由于需要抗锯齿,所以我们使用的山和树纹理的alpha并不像我想的那样非0即1,而是会有一个极窄的alpha过度区。

问题1相对较容易解决,由于不透明物体也可以指定渲染顺序,我们将山的渲染延后。由于山的阴影alpha通道的值不为1也不为0,所以会与之前写入FrameBuffer中的颜色值进行alpha混合形成阴影效果。

问题2就颇为复杂,而且很多问题几乎无解。考虑这样一个场景(在相机空间下,由远到近):树x2,山x1,树x2。

我们相机空间下最远的树称为”树群A", 将最近的树称为“树群B"。

由于树群A和树群B之间隔了山,所以树群A和树群B之间不用考虑遮挡关系。

但是树群A/B中有可能有多种树,以至于有多个DrawCall, 树群A/B中树与树之间的会有”半透明(alpha值为(0,1)之间的值)“ 穿插问题,ZTest是解决不了的。

那么一种可能的渲染顺序是,树群A,树群B,山。

由于先渲染树群A,只要树群A的结果是正确的,那么在渲染山时,山的半透明部分与树群A在FrameBuffer中留下的颜色值进行混合的结果一定是正确的。

但是树群B先于山渲染,所以树群B的”半透明(alpha值为(0,1)之间的值)“部分是与地表颜色进行混合,而山又比树群B中的所有树都更远,所以山的ZTest会失败。这时看上去就像是树把山给透视了(直接看到地表)。

最终我们还是采用了ZTest。 为了解决山的”半透明(alpha值为(0,1)之间的值)“问题,我们在刷树时刻意避过了山的边缘部分。

而树与树的遮挡部分,我们尽量让同一种树聚簇种植,这样在同一个DC内,画家算法还是解决”半透明“混合问题。