NekoLab

CG练习(2) 渲染体积光

经常见到这样一种探照灯光的效果:

由于物理上的丁达尔效应,光源照射的体积被照亮。在Unity里面,我们可以用Command Buffer来自定义渲染物体的方式,结合物理和经验模型来模拟这个效果。并且在实现单色体积光之后,把功能扩展到彩色的灯光,类似舞台上彩色射灯的效果,结果如下:

实现了一个类似五毛特效的动图:

我的Github项目地址
改写了原始项目,增加了彩色体积光的支持。

物理原理

摘自维基百科:

当一束光线透过胶体,从入射光的垂直方向可以观察到胶体里出现的一条光亮的“通路”,这种现象叫丁达尔现象。光射到微粒上可以发生两种情况,一是当微粒直径大于入射光波长很多倍时,发生光的反射;二是微粒直径小于入射光的波长时,发生光的散射,散射出来的光称为乳光。

记起光学和电动力学课上讲过散射分三种类型:瑞利散射,米氏散射,拉曼散射。蓝色的天空和日落时的红光就是瑞利散射的结果,引起丁达尔效应的主要是米氏散射(Mie scattering)。然而,当我搜索米氏散射强度公式的时候,物理学理论给出的都是用多重积分或者级数展开表示的复杂公式。在图形学中模拟这种视觉效果,用不上第一性原理的精确计算。

图形学算法

本来想作一张图说明原理的,想想太麻烦了(编辑这篇文章已经够费劲了)。所以想象一下这个情景:光源照亮的体积是一个确定的几何体:spotlight是锥体,点光源是球体,平行光是柱体。从摄像机出发放射出一条条直射光线,光线经过被光源照亮的体积内时,采样这个位置的色彩值,在光线行进(march)的过程中采样值累加起来,直到走出光体积为止,就计算出了当前像素点的体积光强度。方法的核心就是raymarching,有点类似体积渲染(direct volume rendering)。

在实现上,首先我们需要为不同类型的光源计算光线与虚拟几何体的交点,作为raymarching的起点和终点。在行进过程中,如果被阴影遮挡则不能叠加亮度,所以需要一张shadow map作为参考。

类似舞台上的彩色灯光或者电影院的投影仪,在投射出彩色图案的同时能看到彩色的光束。我们可以把投影的画面当作一个texture(只考虑spotlight的情况,如果是点光源需要cube map),需要采样这张纹理图片上对应位置的颜色,每个采样步的颜色不一定相同。

shader最关键的代码及注释如下。为了让代码更清晰,这里省略了雾特效、噪声扰动等原作者添加的很多额外效果,并且只针对spotlight处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
float GetLightAttenuation(float3 wpos)
{
float atten = 0;

// wpos is current world position of the marching ray
float3 tolight = _LightPos.xyz - wpos;
half3 lightDir = normalize(tolight);

float4 uvCookie = mul(_MyLightMatrix0, float4(wpos, 1));

// negative bias because http://aras-p.info/blog/2010/01/07/screenspace-vs-mip-mapping/
// get light cookie attenuation
atten = tex2Dbias(_LightTexture0, float4(uvCookie.xy / uvCookie.w, 0, -8)).w;
atten *= uvCookie.w < 0;
float att = dot(tolight, tolight) * _LightPos.w;
atten *= tex2D(_LightTextureB0, att.rr).UNITY_ATTEN_CHANNEL;

// if the light casts shadow, sample the shadow texture
#if defined(SHADOWS_DEPTH)
float4 shadowCoord = mul(_MyWorld2Shadow, float4(wpos, 1));
atten *= saturate(UnitySampleShadowmap(shadowCoord));
#endif

return atten;
}
float4 GetColor(float3 wpos)
{
// convert current world position to light coordinates
// (imagine viewing from the light origin)
float4 posLight = mul(_MyLightMatrix0, float4(wpos, 1));

// posLight.w is the depth from current position to light position
// sample color from light projection texture
return tex2D(_LightProjectionTexture, posLight.xy / posLight.w + float2(0.5, 0.5));
}

[loop]
for (int i = 0; i < stepCount; ++i)
{
// current position is the ray marching position
float atten = GetLightAttenuation(currentPosition);

// density results from fog and noise, omitted here for simplicity
float density = GetDensity(currentPosition);

// _VolumetricLight.x is scattering coefficient
// _VolumetricLight.y is extinction coefficient
float scattering = _VolumetricLight.x * stepSize * density;
extinction += _VolumetricLight.y * stepSize * density;// +scattering;

float4 currentColor = GetColor(currentPosition);

float lightInten = atten * scattering * exp(-extinction) * currentColor.w;

// accumulate the color
vlight += currentColor * lightInten;

currentPosition += step;
}

使用Unity的Command Buffer

Unity渲染主要有Forward和Deferred两种Pipeline,遵循一定的渲染顺序,如下图所示:

在完成每个渲染步骤的前后,也就是图中绿点的位置,都可以插入一些自定义的渲染命令,也就是用command表示,来扩展渲染的功能。Command其实和OpenGL里面的glDraw, glClear之类的语句一样,设定参数执行一些绘图操作,一系列Command储存在摄像机的Command Buffer里面,到达规定的渲染步骤时候会执行。

在我们这里,由于算法需要获取Shadowmap,渲染体积光的Command应该在光源投射阴影之后进行,也就是LightEvent.AfterShadowMap。在摄像机方面,如果是Forward Path,执行时机是CameraEvent.AfterDepthTexture,如果是Deferred Path,是在渲染G-buffer之后计算光照之前的CameraEvent.BeforeLighting。我们加入的command只渲染体积光,所以在渲染之前要设定render target为新建的一张render texture,在体积光和场景几何体分别渲染到两个render texture以后,再用blit混合。

What’s Next

进行到这里已经可以让灯光照亮彩色的体积了,但是接触灯光的物体表面没有相应纹理,下一次我将会改进,实现出舞台灯的效果,让灯光投射出彩色图案。