游戏场景渲染之体积光的表现形式探究

2016年07月22日 16:58 0 点赞 0 评论 更新于 2017-05-09 17:33
游戏场景渲染之体积光的表现形式探究

提高游戏的画面感,可从体积光开始。

散射是一种美丽的自然现象,在自然界中,当光穿过潮湿或含有杂质的介质时会产生散射。散射的光线进入人眼,使这些介质看起来像是拢住了光线,这就是所谓的体积光。在游戏中,体积光是一种常用的光照特效,主要用于表现光线照射到遮蔽物体时,在物体透光部分泄露出的光柱。因其在视觉上能给人很强的体积感,所以被称为体积光。体积光特效在现今的中高端游戏中极为常见,优秀的体积光特效在烘托游戏氛围、提高画面质感方面发挥着重要作用。例如,清晨透过树林间隙射出的体积光,能为游戏增色不少。

体积光的常见制作方式

作为一种常用特效,体积光的制作方式多种多样。不同游戏对于处理性能、画面要求和渲染质量的要求各不相同,因此会采用不同的体积光表现方式。早期游戏由于机能限制,常使用BillBoard贴片和径向模糊这两种方式。下面先简单介绍这两种方式,后续将重点介绍最新的基于光线追踪的方式。

BillBoard贴片

BillBoard贴片较容易理解,可使用PHOTOSHOP生成一个随机的明暗条文,并加上遮罩,使其呈现出光条的效果。将BillBoard放置在场景中光线会泄露出来的区域,就能实现最简单的体积光效果。若想做得更精细,还可加上UV动画、粒子和远景透明效果。

径向模糊

径向模糊是一种后处理方法,主要用于表现天空中日月星光散射的效果。所谓后期处理,是指在游戏画面渲染完毕之后,另外进行一次渲染,类似于PHOTOSHOP,但处理的对象是每一帧游戏画面。由于对速度有要求,多使用GPU进行计算。径向模糊体积光的主要流程如下:

  1. 渲染出整个画面。
  2. 抽取出画面中高亮的部分。
  3. 对高亮部分进行径向模糊。
  4. 将径向模糊后的高亮层和原图合并。

这个流程可以在PS中轻松实现,有兴趣的朋友可以打开PS尝试一下。很多时候,我们也会先在PS上试验一些效果,然后再用代码实现。下面简单介绍径向模糊的GPU实现,其实现非常简单,就是从原本像素的位置开始,向画面中心移动坐标,每移动一次就采样一次,将所有采样叠加在一起即可。代码如下:

vec2 position = gl_FragCoord.xy / resolution.xy;
position = (position - 0.5) * 2.;
position.y = position.y * resolution.y / resolution.x;
vec2 uv = position;
// 最后颜色
vec4 color = vec4(vec3(0.), 1.);
// 采样次数
const int stepNum = 12;
// 每次采样衰减
float decay = 0.9;
// 采样权重
float weight = 1.;
// 坐标移动方向
vec2 direction = normalize(uv);
for(int i = 0; i < stepNum; i++)
{
// 移动坐标
uv -= direction / float(stepNum);
vec4 sample = texture2D(iChannel0, uv);
sample *= weight;
color += sample;
weight *= decay;
}

这里可以调整移动步幅、采样的数量和移动的方向,从而得到不同的效果。由于实现简单、消耗小且效果不错,径向模糊在很多游戏中都有广泛应用。Webgl径向模糊体积光例子:https://www.shadertoy.com/view/MdG3RD

不过,径向模糊也有明显的缺点,如果光源不在画面内,显然无法执行径向模糊。

基于光线追踪的体积光算法

上述两种方式在游戏制作中已使用了很长时间,但这并不意味着体积光效果只能达到这个程度,其实还有很大的进步空间。近期,随着渲染技术的进步,业界开始使用基于光线追踪、阴影贴图等更为精细的渲染技术来实现体积光的效果。尤其是近年来,Nvidia、寒霜引擎等几家大厂连续在Siggraph和GDC上发表了多篇关于实时体积光渲染的技术论文。这些算法在模型的精密程度和计算效率方面相较于之前都有了质的飞跃,新一代的体积光已成为3A游戏的标准配置。

建立体积光模型

下面将从建模开始,逐步深入分析基于光线追踪的体积光算法的各个方面。我们先从如何描述体积光,即为算法建立模型入手。由于体积光是我们在生活中能看到的现象,我们可以从分析自然现象中获取建模的灵感。

首先,光自身并没有体积,我们也无法看见光的形状。在日常生活中看到的光柱实际上是空气中的尘埃。空气中布满了大量微小的尘埃,我们看到的光柱就是光线击中尘埃后散射到我们眼睛中的光线。有人可能会说,只要把尘埃模拟出来,放到现在的渲染引擎中就可以了。这确实是最正确的做法,但也是最不切实际的做法。因为实时渲染显然不可能在几毫秒内模拟光线在空气中上亿灰尘分子间发生的各种折射、反射和散射。这就好比让牛顿只用自己的三大定律去计算大海里每个水分子是如何碰撞的。

渲染,尤其是实时渲染,最重要的不是所谓的基于物理,而是找到足够近似于真实情况(骗过人眼)的计算方式。既然不能用蛮力模拟,我们就需要想一些巧妙的办法。我们先设计一个近似模型,这个模型可以非常简单,只要能表现出一些我们需要的光学性质即可,其他的可以逐步添加。

这里我们思考一下,是否需要尘埃数量如此巨大且光学特性又很复杂的东西。由于空气中的尘埃非常小,甚至难以看到,我们可以用一种匀质的物质代替。当然,这种物质要与原来渲染引擎的真空不同,它要能把光折射到观察者眼中,还要符合光传播随着距离增加而衰减的特性。

光线追踪算法

上面只是对这个近似模型的特性描述,要真正写出算法,就需要考虑如何看见这种物质,也就是如何渲染这种物质。这里我们使用光线追踪的算法框架。

光线追踪简单来说,就是设置一个虚拟的眼睛,这个眼睛在三维空间里位于屏幕外的一个点。在屏幕上的每个像素渲染时,从虚拟的眼睛开始做一条射线,通过所要渲染的像素,这条射线与屏幕里面的三维空间交汇的位置(也可以说射中哪个位置),像素就渲染那个位置的颜色。

需要注意的是,我们不能照搬光线跟踪的定义,要进行一些改变。因为我们要渲染的这种匀质物体不仅表面起作用,而是在射线经过的路径上每个点都会对像素的颜色产生贡献。我们从起点开始,沿着射线每次推进一点,采样每个点的亮度,所有经过的采样点上的亮度求和就是像素的颜色。

在匀质物体内部,每个采样点的亮度值如何计算呢?按照上面模型的规则,要符合光传播按距离增加而衰减的特性。这里使用一个简单的衰减公式,光的亮度和离光源的距离成平方反比,即得到公式 i = l/d^2。代码如下:

// ro视线起点,rd是视线方向
vec3 raymarch(vec3 ro, vec3 rd)
{
const int stepNum = 100;
// 光源强度
const float lightIntense = 100.;
// 推进步幅
float stepSize = 250. / stepNum;
vec3 light = vec3(0.0, 0.0, 0.0);
// 光源位置
vec3 lightPos = vec3(2., 2.,.5);
float t = 1.0;
vec3 p = vec3(0.0, 0.0, 0.0);
for(int i = 0; i < stepNum; i++)
{
vec3 p = ro + t * rd;
// 采样点光照亮度
float vLight = lightIntense / dot(p - lightPos, p - lightPos);
light += vLight;
// 继续推进
t += stepSize;
}
return light;
}

这里需要循环多次,显得比较浪费,是否有方法用一个公式算出来呢?其实,亮度值是一个空间函数,而光线追踪做的事情其实是求这个函数在视线线段上的线积分。我们设计的这个简单的模型完全可以用积分的解析解代替光线追踪。下面是推导过程: 设函数 L(t) = I/r^2,函数 x(t) 为视线的积分曲线,x(t) = ro + t * rds 为光源。改写 L(t)L(t) = I/|x(t) - s|^2,要求 L(t) 从 0 到介质深度 d 的积分。

L(t) = I/dot(p + t * rd - s, p + t * rd - s)
L(t) = I/(t^2 * dot(rd, rd) + 2 * dot(p - s, rd) * t + dot(p - s, p - s))

dot(p - s, p - s) = cdot(p - s, rd) = b,而且 dot(rd, rd) 一定等于 1,就得到 L(t) = I/(t^2 + 2bt + c^2)。 在裂项 L(t) = I/((t^2 + 2bt + b^2) + (c - b^2)),接着替换 u = (t + b)v = (c - b^2)^(1/2),积分转换为求 du/(u^2 + v^2)bb + d 的积分。 最后得到 I/v * arctan(u/v)bb + d,积分解析公式为 I/v * (arctan((b + d)/v) - arctan(b/v))。写成代码如下:

float InScatter(vec3 start, vec3 rd, vec3 lightPos, float d) {
vec3 q = start - lightPos;
float b = dot(rd, q);
float c = dot(q, q);
float iv = 1.0f / sqrt(c - b * b);
float l = iv * (atan((d + b) * iv) - atan(b * iv));
return l;
}

现在我们的模型还比较简陋,如果单独使用会略显单薄。此时就需要发挥艺术想象力,结合其他画面元素来达到漂亮的效果。下面是两个用例:

  • 用作低消耗的光晕,在气球周围的就是球状的体积光。
  • 把体积光框定在圆锥体内部,制造出探照灯的效果。水下部分的光带是结合了上面提到的贴片制作的。

散射函数

现在回顾一下我们刚才的模型,为什么视线上的所有采样点的亮度之和能用来表示光线在介质中散射的效果呢?其实我们是假设每个点上都有一个尘埃,光照射到尘埃上时,会把所有光准确地反射到我们的眼睛里。但如果研究得更精细一些,就会发现尘埃并没有自动跟踪系统,不可能正好把所有光都反射到眼睛里。

光的散射应该是向四面八方的,在一个以尘埃为球心的球体中,几乎所有方向都有可能反射到光线,而且每个方向散射出去的光线亮度应该是不一样的,而这些散射出去的光线亮度总和应该和射到尘埃上的那束光线亮度一样,也就是能量守恒。这种散射可以用一个公式来表示,称为HG公式。

这个公式是输入指定方向和光线入射方向夹角的cos值,求出指定方向散射光线的亮度。我们要计算的是朝着眼睛方向的散射光线亮度。代码如下:

float cosTheta = dot(lightDr, -rd);
float result = 1 / (4 * 3.14) * (1 - g * g) / pow(1 + g * g - 2 * g * cosTheta, 1.5);

透光

公式中的 g 值代表了介质散射性质,不同的 g 值对散射有不同的影响。透光现象是指光在介质内传播时,会被吸收一部分,剩下的部分才能透过介质到达观察者眼中。这里需要用到一个物理法则——Beer–Lambert法则,该法则描述的是入射光强度和透光强度的比值。这个公式可以简单地写成 Out = In * exp(-c * d),其中 c 是物质密度,d 是距离。透光强度随着介质的密度和光传播的距离的增加成指数下降。

阴影

体积光还有一个重要的元素——阴影,正是阴影的加入让体积光产生了各种形状。没有阴影的体积光其实就是雾。阴影的计算在实时渲染中比较复杂,这里只介绍一下简单的原理。

按照我们的模型图来看,每一次采样时,如果采样点被阴影遮住了,就直接认为采样结果为 0。所以需要知道一个关键信息,即采样点是否被光照到。计算过程简单描述为:连接采样点和光源点作一条线段,然后检测场景中这条线段是否与其他可见物体相交。如果有交点,则判定该采样点被阴影遮挡,不记入采样数据,反之则正常计算。线段和几何形状的求交公式可以在图形学的课本中快速找到,这里不再赘述。

最终公式

将上面三项加入我们的模型后,重新审视我们的模型。在每个采样点上都需要计算四个参数:来自光源的光照亮度 L、采样点到眼睛的透光率 T、光线在视线方向上的散射值 P、阴影值 V。如果是非均匀介质,比如有噪音的雾,还要计算采样点位置的介质密度 D。散射公式为 S = L * T * V * P * D。示例代码:https://www.shadertoy.com/view/XlBSRz

实际游戏开发中的算法优化

在实际游戏开发中,还有很多算法优化的部分。

使用3D纹理保存光照和阴影信息

因为游戏渲染时同样需要计算阴影和光照,没必要为了渲染体积光重新计算一遍。论文[1]提出的算法是在游戏渲染的同时将光照和阴影保存在一个3D纹理中,之后计算体积光时只需对3D纹理中的信息进行采样即可。3D纹理的保存方式有很多种,UE4使用了一种层级式样的3D纹理,由于同时保存了空间层级数据,在3D索引时更快。

使用随机采样减少采样次数

今年的独立游戏公司DeadPlay使用了随机采样的方法,很大程度上减少了光线追踪采样的次数,每个像素只采样了3次,使得高端的游戏特效在手机端也能顺畅运行。

添加噪点和抗锯齿算法柔滑画面

随机采样可以结合TemporalAA算法平滑画面,每一帧的随机采样生成的随机场和TemporalAA每帧的ID关联,这样把采样分散到时间维度上,平滑后同样能达到较高的精度。

了解更多请到点击:泰斗社区。

作者信息

孟子菇凉

孟子菇凉

共发布了 1189 篇文章