GameTech/URP 渲染实战

案例 28.5体积光:步进、抖动、模糊、合成高阶

Assets/Cookbook/Ch28/VolumetricLight.shader(节选:march)
// Henyey-Greenstein 相位函数:g>0 时向前散射为主,迎着光看最亮
float PhaseHG(float cosTheta, float g)
{
    float g2 = g * g;
    return INV_FOUR_PI * (1 - g2) / pow(max(1 + g2 - 2 * g * cosTheta, 1e-4), 1.5);
}

half4 FragMarch(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float depth = SampleDeviceDepth(uv);
    float3 endWS = IsSkyDepth(depth)
        ? ComputeWorldSpacePosition(uv, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP)
        : ReconstructPositionWS(uv, depth);

    float3 rayWS = endWS - _WorldSpaceCameraPos;
    float rayLength = min(length(rayWS), _MaxDistance);
    float3 dir = rayWS / max(length(rayWS), 1e-4);

    Light mainLight = GetMainLight();
    float phase = PhaseHG(dot(dir, mainLight.direction), _Anisotropy);

    int steps = (int)_Steps;
    float stepLength = rayLength / steps;
    // 抖动起点:把“条带”换成高频噪声,再交给模糊去掉
    float jitter = _Jitter > 0.5 ? InterleavedGradientNoise(input.positionCS.xy, 0) : 0.5;

    float scatter = 0;
    float transmittance = 1;
    float stepT = exp(-_Density * stepLength);    // 每步的透射率(均匀介质)
    for (int i = 0; i < steps; i++)
    {
        float t = (i + jitter) * stepLength;
        float3 p = _WorldSpaceCameraPos + dir * t;
        // 关键两行:世界坐标 → 阴影坐标(自动选级联)→ 比较阴影图
        float4 shadowCoord = TransformWorldToShadowCoord(p);
        float visibility = MainLightRealtimeShadow(shadowCoord);
        // 这一小段介质散射到相机方向的光,再乘上它到相机之间的透射率
        scatter += visibility * transmittance * (1 - stepT);
        transmittance *= stepT;
    }
    scatter *= phase * _Intensity * FOUR_PI;  // ×4π 让 g=0 时的强度约为 1,便于调参
    return half4(scatter, transmittance, 0, 1);
}
// Henyey-Greenstein 相位函数:g>0 时向前散射为主,迎着光看最亮
float PhaseHG(float cosTheta, float g)
{
    float g2 = g * g;
    return INV_FOUR_PI * (1.0 - g2) / pow(max(1.0 + g2 - 2.0 * g * cosTheta, 1e-4), 1.5);
}

vec4 FragMarch()
{
    vec2 uv = texcoord;
    float depth = SampleDeviceDepth(uv);
    vec3 endWS = IsSkyDepth(depth)
        ? ComputeWorldSpacePosition(uv, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP)
        : ReconstructPositionWS(uv, depth);

    vec3 rayWS = endWS - _WorldSpaceCameraPos;
    float rayLength = min(length(rayWS), _MaxDistance);
    vec3 dir = rayWS / max(length(rayWS), 1e-4);

    Light mainLight = GetMainLight();
    float phase = PhaseHG(dot(dir, mainLight.direction), _Anisotropy);

    int steps = int(_Steps);
    float stepLength = rayLength / float(steps);
    // 抖动起点:把“条带”换成高频噪声,再交给模糊去掉
    float jitter = _Jitter > 0.5 ? InterleavedGradientNoise(gl_FragCoord.xy, 0) : 0.5;

    float scatter = 0.0;
    float transmittance = 1.0;
    float stepT = exp(-_Density * stepLength);    // 每步的透射率(均匀介质)
    for (int i = 0; i < steps; i++)
    {
        float t = (float(i) + jitter) * stepLength;
        vec3 p = _WorldSpaceCameraPos + dir * t;
        // 关键两行:世界坐标 → 阴影坐标(自动选级联)→ 比较阴影图
        vec4 shadowCoord = TransformWorldToShadowCoord(p);
        float visibility = MainLightRealtimeShadow(shadowCoord);
        // 这一小段介质散射到相机方向的光,再乘上它到相机之间的透射率
        scatter += visibility * transmittance * (1.0 - stepT);
        transmittance *= stepT;
    }
    scatter *= phase * _Intensity * FOUR_PI;  // ×4π 让 g=0 时的强度约为 1,便于调参
    return vec4(scatter, transmittance, 0.0, 1.0);
}

逐行看几个要点:

Assets/Cookbook/Ch28/VolumetricLight.shader(节选:composite)
half4 Frag(Varyings input) : SV_Target
{
    half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, input.texcoord);
    half2 vol = SAMPLE_TEXTURE2D_X(_VolumetricResult, sampler_LinearClamp, input.texcoord).rg;
    half3 lightColor = GetMainLight().color * _Tint.rgb;
    // 场景被介质衰减(×透射率),再加上介质散射进视线的光
    color.rgb = color.rgb * vol.g + lightColor * vol.r;
    return color;
}
void main()
{
    vec4 color = SamplePointClamp(_BlitTexture, texcoord);
    vec2 vol = texture(_VolumetricResult, texcoord).rg;
    vec3 lightColor = GetMainLight().color * _Tint.rgb;
    // 场景被介质衰减(×透射率),再加上介质散射进视线的光
    color.rgb = color.rgb * vol.g + lightColor * vol.r;
    fragColor = color;
}
Assets/Cookbook/Ch28/VolumetricLightFeature.cs(节选:execute)
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    var mat = m_Settings.material;
    var cmd = CommandBufferPool.Get();
    using (new ProfilingScope(cmd, profilingSampler))
    {
        int w = m_Vol.rt.width, h = m_Vol.rt.height;
        mat.SetVector(s_TexelSize, new Vector4(1f / w, 1f / h, w, h));

        CoreUtils.SetRenderTarget(cmd, m_Vol);
        Blitter.BlitTexture(cmd, new Vector4(1, 1, 0, 0), mat, 0);       // 光线步进
        if (m_Settings.blur)
        {
            Blitter.BlitCameraTexture(cmd, m_Vol, m_VolTemp, mat, 1);      // 横向模糊
            Blitter.BlitCameraTexture(cmd, m_VolTemp, m_Vol, mat, 2);      // 纵向模糊
        }
        cmd.SetGlobalTexture(s_Result, m_Vol);
        Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Copy);
        Blitter.BlitCameraTexture(cmd, m_Copy, m_CameraColor, mat,
            m_Settings.output == Output.Composite ? 3 : 4);
    }
    context.ExecuteCommandBuffer(cmd);
    CommandBufferPool.Release(cmd);
}
// 案例 28.5:体积光 Renderer Feature(对应 Ch28/VolumetricLightFeature.cs)—— 半分辨率光线步进 → 模糊 → 合成。
import { featureMaterial, RenderPassEvent } from '../engine/features.js';
import { ensureDepth } from './Cookbook.Ch28.ScreenSpaceUtil.js';

const Output = { Composite: 0, ScatterOnly: 1 };

export default class VolumetricLightFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.scene = scene;
    this.settings = { material: null, downsample: 2, blur: true, output: Output.Composite, renderPassEvent: RenderPassEvent.BeforeRenderingTransparents, ...((data && data.settings) || {}) };
  }

  async init() { this.material = featureMaterial(this.scene, this.settings.material); }

  addPasses(frame, enqueue) {
    const s = this.settings, mat = this.material;
    if (!mat) return;
    // 没有主光阴影就没有“光束”,直接跳过
    if (frame.scene.asset.m_MainLightShadowsSupported === false) return;
    enqueue(s.renderPassEvent, (fr) => {
      ensureDepth(fr);   // ConfigureInput(Depth)
      const ds = Math.max(1, s.downsample | 0);
      const w = Math.max(1, Math.floor(fr.width / ds)), h = Math.max(1, Math.floor(fr.height / ds));
      // 散射值可能 >1,用半精度浮点
      const vol = fr.getTemporaryRT('_Volumetric', w, h, { color: 'RGBA16F' });
      const volTemp = fr.getTemporaryRT('_VolumetricTemp', w, h, { color: 'RGBA16F' });
      mat.setVector('_VolTexelSize', [1 / w, 1 / h, w, h]);
      fr.blit(null, vol, mat, 0);                       // 光线步进
      if (s.blur) {
        fr.blit(vol.texture, volTemp, mat, 1);          // 横向模糊
        fr.blit(volTemp.texture, vol, mat, 2);          // 纵向模糊
      }
      fr.setGlobal('_VolumetricResult', vol.texture);
      const copy = fr.getTemporaryRT('_VolumetricColorCopy', fr.width, fr.height, { filter: 'Point' });
      fr.blit(fr.cameraColorTexture(), copy);
      fr.blit(copy.texture, 'camera', mat, s.output === Output.Composite ? 3 : 4);
      fr.bindCamera();
    }, 'VolumetricLight');
  }
}

Feature 在半分辨率(downsample = 2)的 ARGBHalf 纹理上步进,散射值可能超过 1,需要浮点格式。AddRenderPasses 里还检查了 renderingData.shadowData.supportsMainLightShadows:主光不投影或阴影被关掉时,这个效果没有意义,直接不入队。

图 28-12左:没有体积光,阳光从柱廊后方低角度照来,只在地面上留下条状阴影;中:只显示散射项(未模糊),可以看到抖动起点带来的细密噪点,以及柱子在空气中投下的一道道暗带;右:模糊后合成,光束从柱间射向镜头

性能与质量提示: