体积光介绍
首先,我们要确认一下什么是体积光。体积光通俗来说是我们能看见的”光路“,并不是所有灯光都会形成体积光效果,它是光照到大气中粒子散射后得到的效果(丁达尔效应)。我们有时候还会看到一束束光散开的效果,这是光在传播过程中遇到了障碍物(比如穿过云层、树木的光束)导致的。
根据物理原理,我们知道体积光是粒子散射的结果,如果我们用体素的思想来考虑体积光,我们所看到的某一点处的体积光颜色是眼睛到当前点的射线上,光路中所有粒子散射光的叠加。
体积光经常模拟Sun Shaft(太阳散射)的效果。
常用实现思路
常用的体积光实现思路包括:
- BillBoard贴片
BillBoard贴片很容易理解,用PHOTOSHOP生成一个随机的明暗条文,加上遮罩,让它看起来有光条的感觉。 - 径向模糊
径向模糊是一种后处理的方法,所谓后期处理就是在游戏画面渲染完毕之后,另外加一次渲染,类似于PHOTOSHOP,但处理的对象是每一帧游戏画面,因为速度要求多使用GPU计算。 - 光线追踪
近期伴随着渲染技术的进步,业界已经开始使用基于光线追踪、阴影贴图等更为精细的渲染技术来实现体积光的效果。
详细介绍可以参考下面这篇文章,上面介绍的内容来自这篇文章:
https://zhuanlan.zhihu.com/p/21425792
本文的重点是介绍通过径向模糊来实现体积光的效果。当然径向模糊的缺点十分明显,如果光源不在画面内,显然径向模糊是没办法执行的。因此径向模糊实现的体积光主要用来表现天空中日月星光散射的效果。
径向模糊实现体积光
径向模糊实现体积光的主要步骤大致如下:
- 正常渲染整个画面。
- 然后再一次渲染整个画面:使用指定颜色渲染发光的对象,使用黑色渲染其他对象(遮挡物)。
3.对第二次渲染的画面进行径向模糊。 - 把模糊的画面和正常渲染的画面通过相机混合(Additively blend)得到最终的结果。
示例说明
本文示例中,渲染四个圆环物体和一个球形的发光物体。四个圆环从上到下排列,发光物在在圆环中间周期性上下运动。
正常渲染整个画面
正常渲染整个画面不属于本文的重点内容,属于webgl的基本内容,此处不过多赘述。不过需要注意的一点是,发光物体使用纯色渲染,后面的效果才会好。
渲染发光物体和遮挡物
此处渲染的结果,我们称之为Occlusion buffer。为了获取Occlusion buffer,一般使用指定的颜色纯色绘制发光的球体,而使用黑色绘制其他的对象;我觉得更好的方式是,在渲染遮挡物的时候,通过colorMask指定不渲染颜色,只记录深度,因此起到遮挡的效果而不产生任何遮挡物的像素。代码如下所示:
1 frameBuffer2.bind(); 2 gl.colorMask(false, false, false, false); 3 for (var i = 0; i < 4; i++) { 4 mat4.identity(mMatrix); 5 ambientLightColor = hsva(i * 40, 1, 1, 1); 6 mat4.translate(mMatrix, mMatrix, [0.0, 10.0 * i - 20, 0.0]); 7 mat4.invert(invMatrix, mMatrix); 8 mat4.mul(mvpMatrix, tmpMatrix, mMatrix); 9 drawNormal(); // 绘制圆环 10 } 11 12 gl.colorMask(true, true, true, true); 13 for (var i = 0; i < 1; i++) { 14 mat4.identity(mMatrix); 15 ambientLightColor = hsva(i * 40, 1, 1, 1); 16 mat4.translate(mMatrix, mMatrix, [0.0, rad * 5 - 15, 0.0]); 17 mat4.scale(mMatrix, mMatrix, [1.1, 1.1,1.1]); 18 mat4.invert(invMatrix, mMatrix); 19 mat4.mul(mvpMatrix, tmpMatrix, mMatrix); 20 drawShpere(1); 21 } 22 frameBuffer2.unbind();
代码首先绑定一个framebuffer,因为Occlusion buffer是要绘制到贴图对象上的,有关framebuffer的内容此处不做详细说明,不明白的读者可以自行查找资料,也可以参考:渲染到纹理。
之后开始循环绘制遮挡物,也就是圆环,此处循环了4次,表示绘制四个圆环。
需要注意的是,在绘制遮挡物之前,通过colorMask指定不绘制颜色到颜色缓冲区,也就是实际上不真正绘制圆环对象:
1gl.colorMask(false, false, false, false);
既然不真正绘制圆环对象,为何要调用绘制代码呢,这是因为绘制的过程除了绘制颜色信息到颜色缓冲区,还会记录深度信息到深度缓冲区,而深度缓冲区可以记录最终的遮挡效果。如果对于基本原理不懂的读者,可以自行查询相关知识,此处不赘述。也可以参考专栏内容:
https://xiaozhuanlan.com/webgl
然后开始绘制发光球体,需要注意的是,在绘制之前需要恢复颜色缓冲区的写入,所以先调用下面的代码进行恢复:
1gl.colorMask(true, true, true, true);
然后绘制发光球体。
一个小技巧是,此处绘制发光球体的时候,适当的放大了球体的缩放比例:
mat4.scale(mMatrix, mMatrix, [1.1, 1.1,1.1]);
这是为了后期获取更明显的发光效果。
最终的绘制效果就是Occlusion buffer。如下图所示:
Occlusion buffer
可以看出值绘制了球体的部分,但是圆环对球体的遮挡仍然存在。
对Occlusion buffer进行径向模糊
上一节的内容,我们绘制了一个Occlusion buffer,此处对Occlusion buffer进行径向模糊,有关径向模糊的内容,可以关注我上一篇文章。代码如下所示:
1function drawCopy(vv) { 2 gl.useProgram(program2); 3 gl.uniform1i(program2.texture, 1); 4 gl.uniform2fv(program2.uCenterOffset, [vv[0],vv[1]]); 5 gl.uniform1f(program2.strength, (document.getElementById('range').value | 0) / 2); 6 7 gl.activeTexture(gl.TEXTURE0+1); // 激活gl.TEXTURE0 8 gl.bindTexture(gl.TEXTURE_2D, frameBuffer2.colorTexture); // 绑定贴图对象 9 10 gl.enableVertexAttribArray(program2.aPosition); 11 gl.enableVertexAttribArray(program2.aTexCoord); 12 13 gl.bindBuffer(gl.ARRAY_BUFFER, qdVerticesBuffer); //绑定缓冲区 14 // 把缓冲区分配给attribute变量 15 gl.vertexAttribPointer(program2.aPosition, 3, gl.FLOAT, false, 0, 0); 16 17 gl.bindBuffer(gl.ARRAY_BUFFER, qdStBuffer); //绑定缓冲区 18 gl.vertexAttribPointer(program2.aTexCoord, 2, gl.FLOAT, false, 0, 0); 19 20 21 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, qdIndexBuffer); 22 // gl.drawElements(gl.TRIANGLES,indices.length,gl.UNSIGNED_BYTE,0); 23 gl.drawElements(gl.TRIANGLES, qdIndices.length, gl.UNSIGNED_SHORT, 0); 24 }
需要注意的是:
- 径向模糊的中心点不是固定的canvas的中心点,而应该是发光球体位置在屏幕上面的投影坐标位置:
1 gl.uniform2fv(program2.uCenterOffset, [vv[0],vv[1]]);
vv的计算如下:
1 let vv = vec4.create(); 2 vv[3] = 1; 3 vec4.transformMat4(vv, vv, mvpMatrix); 4 vv[0] = vv[0] / vv[3]; 5 vv[1] = vv[1] / vv[3]; 6 vv[2] = vv[2] / vv[3]; 7 vv[3] = 1;
径向模糊的内容和正常绘制内容进行叠加
要进行叠加,有人使用如下的思路:
- 把正常的场景绘制到一个framebuffer上面
- 把模糊后的效果绘制到另外一个framebuffer上面。
- 把上面两次绘制的贴图对象传递给一个叠加的绘制程序,绘制正常的结果。一般来说,叠加程序会构造一个像素叠加方法,如下所示:
1 "void main() {", 2 3 "vec4 texel = texture2D( tDiffuse, vUv );", 4 "vec4 add = texture2D( tAdd, vUv );", 5 "gl_FragColor = texel + add * fCoeff;", 6 7 "}"
该方法的优点是,可以更加灵活的控制叠加算法,比如可以调整fCoeff参数调整体积光的强度;缺点也比较明显,多使用了两次framebuffer,性能消耗更大。
本示例不使用以上方法,而是使用如下思路:
- 正常绘制场景
- 开启webgl的addtive blend 功能
- 绘制模糊场景
代码如下所示:
1 gl.enable(gl.BLEND); 2 gl.blendEquation(gl.FUNC_ADD); 3 // gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 4 gl.blendFunc(gl.SRC_ALPHA, gl.ONE); 5 ... 6 drawCopy(vv); 7 drawCopy(vv);
其中gl.blendFunc(gl.SRC_ALPHA, gl.ONE);指定了相加的混合方式。
注意上面drawCopy方法调用了两次,是为了加强体积光的效果。drawCopy调用次数和前面说到的fCoeff参数的作用类似。虽然增加了调用次数,但是由于drawCopy只是简单的绘制了贴图的内容,其性能损耗并不会太大。
有关性能优化
如果需要优化性能,可以考虑减少framebuffer的尺寸。
另外还可以通过降低模糊迭代次数来提高性能。
效果图
上面就是“webgl径向模糊实现体积光”的主要内容,下面上一张图看看渲染的效果:
体积光