SimpleVolumetricLighting(SpotLight)

基于Froxel的聚光灯体积光实现

Github源码传送门————>SimpleVolumetricLighting

Bilibili教程传送门————>小祥带你实现聚光灯体积光(FrustumVoxel)

实机

思路

在地球Online中,体积光其实就是达利园丁达尔效应的视觉体现,光线通过含有微小悬浮颗粒的区域时(尘埃,水雾,烟雾等),光被不断散射出现可见光束。所以我们要实现的其实就是视线方向所有微粒散射出的光衰减累计的结果。以前最常用的体积光实现是Mesh+RayMarching的思路,因为是逐像素采样和计算所以开销巨大。目前最常用的是Froxel(Frustum Voxel)的实现,也就是将视锥体分割成一定数量的体素,逐体素进行光照计算。一方面规定好体素密度,性能不会受屏幕分辨率影响,另一方面灯光之间的计算互不影响,对于多灯光场景非常友好。

基于Froxel的实现分三步走:

  • 遍历灯光,按照不同的衰减方式,计算这些灯光对于当前体素的光照贡献
  • 沿着视线方向逐层累积体素光照,模拟光线散射衰减
  • 应用体积光到相机画面

LightInjection

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
float3 FrustumRay(float2 positionUV, float4 frustumRays[4])
{
float3 ray0 = lerp(frustumRays[1].xyz, frustumRays[2].xyz, positionUV.x);
float3 ray1 = lerp(frustumRays[0].xyz, frustumRays[3].xyz, positionUV.x);
return lerp(ray0, ray1, positionUV.y);
}

float3 SpotLights(float3 voxelPosition)
{
float3 color = 0;
for (int i = 0; i < _SpotLightCount; i++)
{
SpotLightParameters light = _SpotLightParameters[i];
float3 toLight = light.position - voxelPosition;
float distance = dot(toLight, toLight);
float attenuation = 1.0 / pow(1.0 + sqrt(distance) / 0.25, 2.5);

float3 L = normalize(toLight);
float3 D = normalize(light.direction);
float cosTheta = dot(-L, D);
float spotAttenuation = saturate((cosTheta - light.outerCos) / (light.innerCos - light.outerCos));

//TODO: Other Lights Shadow
float4 shadowCoord = mul(_AdditionalLightsWorldToShadow[i], float4(voxelPosition, 1.0));
float shadow = _AdditionalShadowsTexture.SampleCmpLevelZero(sampler_AdditionalShadowsTexture, shadowCoord.xy / shadowCoord.w, shadowCoord.z / shadowCoord.w);

color += light.color * attenuation * spotAttenuation * shadow;
}
return color;
}

#pragma kernel LightInjection
[numthreads(16,2,16)]
void LightInjection(uint3 id : SV_DispatchThreadID)
{
float3 color = 0;
float2 positionUV = id.xy / (_Resolution.xy - 1);
positionUV.y = 1.0 - positionUV.y;

float z = id.z / (_Resolution.z - 1);
float nearOverFarClip = _NearClipPlane / _FarClipPlane;
z = nearOverFarClip + z * (1 - nearOverFarClip);
float3 voxelPosition = FrustumRay(positionUV, _FrustumRays) * z + _WorldSpaceCameraPos;

color += SpotLights(voxelPosition);

// TODO: Calculate Extinction Coefficient
float extinctionCoefficient = 1;
float intensity = 100;
float4 result;
result.rgb = color * extinctionCoefficient * intensity;
result.a = extinctionCoefficient;

_LightInjectionTexture[id] = result;
}
  • z = nearOverFarClip + z * (1 - nearOverFarClip); z本来的意义是三维纹理的深度(层数),除以纹理深度之后归一化到[0,1],但我们关注的是视锥体范围,通过这一步将深度映射到近裁剪平面到远裁剪平面内[$\frac{near}{far}$,1],后面计算体素坐标就能让体素落在视锥体内
  • FrustumRay()方法根据屏幕空间的UV坐标以及相机到视锥体四个角对应的向量,插值得到该像素在摄像机视锥体中的射线方向
  • SpotLights()方法用于计算某个体素上所有聚光灯(SpotLight)对该位置的光照贡献。也就是计算灯光到当前体素的距离,然后根据衰减公式1.0 / pow(1.0 + sqrt(distance) / 0.25, 2.5)计算得到衰减后的光照值
  • 对于聚光灯而言还需要限制光照范围,float cosTheta = dot(-L, D);计算体素与聚光灯中心轴的夹角余弦,再结合内外锥角(innerCos,outerCos)计算光照对应到当前聚光灯的衰减
  • 还需要考虑体积光被物体遮挡之后的光照衰减,将体素世界坐标转换到灯光空间后,采样聚光灯对应的阴影贴图,作为阴影衰减参与到最终的光照颜色计算,这样就得到了所有聚光灯在当前体素累积的光照色值

LightScattering

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
float4 ScatterStep(float3 accumulatedLight, float accumulatedTransmittance, float3 sliceLight, float sliceExtinctionCoefficient)
{
// TODO: Create LUT
sliceExtinctionCoefficient = max(sliceExtinctionCoefficient, 1e-6);
float sliceTransmittance = exp(-sliceExtinctionCoefficient / _Resolution.z);
float3 sliceScatteredLight = sliceLight * (1.0 - sliceTransmittance) / sliceExtinctionCoefficient;
accumulatedLight += sliceScatteredLight * accumulatedTransmittance;
accumulatedTransmittance *= sliceTransmittance;
return float4(accumulatedLight, accumulatedTransmittance);
}

#pragma kernel LightScattering
[numthreads(32, 2, 1)]
void LightScattering(uint3 id : SV_DispatchThreadID)
{
float4 accumulation = float4(0, 0, 0, 1);
int3 position = int3(id.xy, 0);
int steps = _Resolution.z;

// Serial Computation is Too Slow
for (int z = 0; z < steps; z++)
{
position.z = z;
float4 slice = _LightInjectionTexture[position];
accumulation = ScatterStep(accumulation.rgb, accumulation.a, slice.rgb, slice.a);
_LightScatteringTexture[position] = accumulation;
}
}

这里使用Beer-Lambert吸收定律的离散化实现

  • float sliceTransmittance = exp(-sliceExtinctionCoefficient / _Resolution.z);按照公式1计算当前层的透射率
  • float3 sliceScatteredLight = sliceLight * (1.0 - sliceTransmittance) / sliceExtinctionCoefficient;按照公式2计算当前切片散射到视线方向的光照
  • accumulatedLight += sliceScatteredLight * accumulatedTransmittance;累积散射光
  • accumulatedTransmittance *= sliceTransmittance;更新透射率

BlendVolumetricLighting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
half4 SampleScatter(float depth, float2uv)
{
float z = (depth - _NearOverFarClip) / (1 - _NearOverFarClip);
if(z < 0.0) return half4(0, 0, 0, 1);
half3 voxel = half3(uv, z);
half2 reslution = half2(1.0 / _Resolution.x, 1.0 / _Resolution.y);
voxel.xy+= cellNoise(voxel.xy * _ScreenSize.xy) * reslution;
return _LightScatteringTexture.SampleLevel(sampler_LightScatteringTexture, voxel, 0);
}

half4 Frag(Varyings input) : SV_Target
{
float2 uv = input.texcoord;

float depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_PointClamp, uv);
depth = Linear01Depth(depth, _ZBufferParams);

half4 lightColor = SampleScatter(depth, uv);
half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);

return color * lightColor.a + lightColor;
}
  • 从散射累积纹理采样当前屏幕像素对应的灯光色值,混合到相机画面即可

优化方向

  • 其他不同类型的灯光,光照衰减阴影计算都都不一样,可以按需修改添加
  • 可以添加扰动或者其他一些效果来计算得到一个随机性更强的体素消光系数,可以使体积光更真实不死板
  • 目前逐层进行散射光照的积分累积其实是一个串行过程速度太慢了,主要性能瓶颈在这个地方,这个我看了HDRP的体积光实现似乎也没有看到有好的解决方案
  • 散射积分的步骤可以提前计算离散化的函数值存到LUT后续直接查表使用

完结撒花~

pid:134717473


SimpleVolumetricLighting(SpotLight)
https://baifabaiquan.cn/2025/09/06/SimpleVolumetricLighting/
作者
白发败犬
发布于
2025年9月6日
许可协议