book/07-forward-forwardplus-and-lights.md

07. Forward / Forward+ / Lights

Based on Unity 6.3 (6000.3) / URP 17.3.0, Loop (Forward vs Forward+) contract, signature, macro, and debugging routine of Additional Lights are fixed.

07. Forward / Forward+ / Lights: Handling Additional Lights “correctly” in URP

This chapter fixes how Additional Lights are handled in shaders in Unity 6.3(6000.3) / URP 17.3.0 and why “same code gives different results” (=loop contract changed) in Forward+ based on signature/definition location/call flow.

Principles of this chapter

  • It doesn't just explain the “concept”. (1) signature/return value (2) definition location (3) loop branch structure (4) verification method must be provided together.
  • Make your final judgment based on the local URP package source.

7.0 Accurate Reference (Generated, URP 17.3.0)

The “accuracy anchor” for this document is the automated creation below.

Additionally, “what pass Lit.shader actually has” is fixed here.

7.1 Difference between Forward and Forward+: Location of “Light Select”

The Forward family essentially “accumulates” light, but the selection of which light to calculate at the target pixel is implementation dependent.

  • Forward (classic additive light loop): “Run additional light indices 0..N-1 around pixels”
  • Forward+(Clustered Forward): “Checks the cluster based on the screen/WS position of the pixel, and only lights belonging to that cluster move forward.”

This means that in Forward+ the “loop target (light list)” is determined per-pixel differently, which makes the following two things very important:

  1. Shader keyword: _CLUSTER_LIGHT_LOOP (Select compilation variant/path)
  2. Code Path: USE_CLUSTER_LIGHT_LOOP / LIGHT_LOOP_BEGIN/END (actual loop implementation)

7.2 Light Structure: “Lighting contract” consumed by shader

URP's light functions ultimately return (or fill) the Light structure. If you only mess with BRDF without knowing “what this structure has,” debugging becomes very difficult.

Light structure from URP 17.3.0 (with exact definition location):

Field Type Meaning (practical interpretation)
direction half3 Light direction (conventionally “surface to light” or “light to surface” is a point of confusion — based on URP’s use of lighting functions)
color half3 Light color (including intensity)
distanceAttenuation float Distance attenuation (keep float due to platform precision issues)
shadowAttenuation half Shadow Attenuation(0..1)
layerMask uint For matching Light Layers / Rendering Layers

Tip: Why Light doesn't have something like "spot angle attenuation" himself
URP internally reads the source data from the light data buffer/texture (LightData, etc.) and provides the summary type required for final shading as Light.

7.3 Main light: GetMainLight(...) signature (including return value)

As of URP 17.3.0 (definition location/line is based on generated): API Returns Params Defined-in
GetMainLight Light (none) <URP>/ShaderLibrary/RealtimeLights.hlsl:81
GetMainLight Light float4 shadowCoord <URP>/ShaderLibrary/RealtimeLights.hlsl:102
GetMainLight Light float4 shadowCoord, float3 positionWS, half4 shadowMask <URP>/ShaderLibrary/RealtimeLights.hlsl:109
GetMainLight Light InputData inputData, half4 shadowMask, AmbientOcclusionFactor aoFactor <URP>/ShaderLibrary/RealtimeLights.hlsl:122

Summary:

  • When features like “Shadow/Blending/AO” are turned on, the overload does not change, but expands by calling the overload that gives more input.
  • Custom shaders must decide “what is needed from the current pass/function” and choose the appropriate overload.

7.4 Additional Lights (Forward): GetAdditionalLightsCount + GetAdditionalLight

The default pattern for a Forward (non-cluster) path is:

  1. int count = GetAdditionalLightsCount();
  2. for (i=0..count-1) { Light l = GetAdditionalLight(i, positionWS); ... }

As of URP 17.3.0 signature:

API Returns Params Defined-in
GetAdditionalLightsCount int (none) <URP>/ShaderLibrary/RealtimeLights.hlsl:271
GetAdditionalLight Light uint i, float3 positionWS <URP>/ShaderLibrary/RealtimeLights.hlsl:224
GetAdditionalLight Light uint i, float3 positionWS, half4 shadowMask <URP>/ShaderLibrary/RealtimeLights.hlsl:234
GetAdditionalLight Light uint i, InputData inputData, half4 shadowMask, AmbientOcclusionFactor aoFactor <URP>/ShaderLibrary/RealtimeLights.hlsl:257

The key to this path is:

  • GetAdditionalLightsCount() is not the “number of lights to be applied in the current pixel”, but gives a value closer to the maximum/visible additional lights in the forward path (affected by platform/settings/pipeline limitations).

7.5 Forward+ branching core: _CLUSTER_LIGHT_LOOP(keyword) → USE_CLUSTER_LIGHT_LOOP(macro)

In URP 17.3.0, _CLUSTER_LIGHT_LOOP is the “shader keyword for Forward+”, and USE_CLUSTER_LIGHT_LOOP is a macro that selects the actual loop implementation.

Definition location (based on Generated xref):

  • _CLUSTER_LIGHT_LOOP Related: <URP>/ShaderLibrary/Core.hlsl:13 and <URP>/ShaderLibrary/ForwardPlusKeyword.deprecated.hlsl:20
  • USE_CLUSTER_LIGHT_LOOP Related: <URP>/ShaderLibrary/Core.hlsl:14 / :16

The point can be summarized (in concept form) as follows:

HLSL
// <URP>/ShaderLibrary/Core.hlsl
#if defined(_CLUSTER_LIGHT_LOOP)
    #define USE_CLUSTER_LIGHT_LOOP 1
#else
    #define USE_CLUSTER_LIGHT_LOOP 0
#endif

Why split it in two?

  • _CLUSTER_LIGHT_LOOP: Keyword that divides “variant (compilation result)”
  • USE_CLUSTER_LIGHT_LOOP: Switch to replace loop implementation with “same API name” in URP internal includes

7.6 Why can GetAdditionalLightsCount() be 0 in Forward+?

This is not a “bug”, it is a contract.

The definition of GetAdditionalLightsCount() in URP 17.3.0 clearly states the following intent (summary):

  • If USE_CLUSTER_LIGHT_LOOP == 1, returns 0
  • Reason: Because “counting” in clustered requires bit list traversal and does not require a dictionary.

Definition location (exact):

  • <URP>/ShaderLibrary/RealtimeLights.hlsl:271

Therefore, the following code in Forward+ may fail (or loop 0 times):

HLSL
for (uint i = 0; i < GetAdditionalLightsCount(); ++i) { ... }

In Forward+, you must use the cluster loop macro provided by URP, not the “count-based loop”.

7.7 LIGHT_LOOP_BEGIN/END: Same call, different implementation (Forward vs Forward+)

URP requires only the “body of the light loop” to be written in the form LIGHT_LOOP_BEGIN(lightCount) ... LIGHT_LOOP_END, In Forward/Forward+, the loop implementation is replaced with a macro.

Definition location (based on Generated xref):

  • <URP>/ShaderLibrary/RealtimeLights.hlsl:28 (cluster implementation)
  • <URP>/ShaderLibrary/RealtimeLights.hlsl:36 (for-loop implementation)

In Forward+, even if the pixelLightCount value passed to LIGHT_LOOP_BEGIN(pixelLightCount) is 0, The macro traverses the actual light list using ClusterInit/ClusterNext.

7.8 Forward vs Forward+ Light Loop: Sequence Diagram (Core Only)

sequenceDiagram participant Frag as Fragment participant RL as RealtimeLights.hlsl participant CL as Clustering.hlsl Frag->>RL: Light main = GetMainLight(...) Frag->>RL: int count = GetAdditionalLightsCount() alt Forward (USE_CLUSTER_LIGHT_LOOP=0) Frag->>RL: for lightIndex in [0..count) Frag->>RL: GetAdditionalLight(lightIndex, ...) else Forward+ (USE_CLUSTER_LIGHT_LOOP=1) note over Frag,RL: count == 0 (계약) Frag->>RL: LIGHT_LOOP_BEGIN(count) RL->>CL: ClusterInit(screenUV, positionWS, ...) loop while ClusterNext(...) RL->>RL: GetAdditionalLight(lightIndex, ...) end Frag->>RL: LIGHT_LOOP_END end

7.9 Practical implementation pattern (recommended): “Keep the URP loop and just replace the BRDF”

The safest customization is one of the following:

  1. Change only SurfaceData generation (Albedo/Normal/Roughness, etc.)
  2. Change only the BRDF function (LightingPhysicallyBased series)

Conversely, implementing the following yourself “from scratch” is likely to break it.

  • Additional light loops (Forward/Forward+ branches)
  • Combining peripheral functions such as ShadowMask/AO/LightLayers/Decal/ProbeVolume etc.

If you need to “loop additional lights directly in my shader”, at least keep it that way.

HLSL
uint count = GetAdditionalLightsCount();
LIGHT_LOOP_BEGIN(count)
    Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
    // ... accumulate with your BRDF
LIGHT_LOOP_END

7.10 Verification routine (acceptance criteria): Reproduce/resolve “0 lights in Forward+”

Symptom: Forward+ is ON, but additional lights are not applied.

  1. Check keyword: Is there _CLUSTER_LIGHT_LOOP variant in the shader?
    • URP Lit uses #pragma multi_compile _ _CLUSTER_LIGHT_LOOP.
  2. Check loop implementation: Is there only a GetAdditionalLightsCount based for-loop?
    • If so, the loop may not run from Forward+ to count==0.
  3. Use URP provided loop: Switch to LIGHT_LOOP_BEGIN/END
  4. Frame Debugger: Verifies that the “Additional Lights” step is actually executed in the forward pass.
  5. Track definition location: Check definitions _CLUSTER_LIGHT_LOOP, USE_CLUSTER_LIGHT_LOOP, LIGHT_LOOP_BEGIN/END in @@TOK_4_8394c928@@

7.11 Search recipe (using local source/Generated together)

The fastest way to do this is to open the Generated file first, which will give you the “definition location” immediately, and then read the context from local sources.

A) Fastest way: Search in generated index

B) Find directly from source (rg)

From the URP project root:

POWERSHELL
rg -n "int GetAdditionalLightsCount\\(" Packages/com.unity.render-pipelines.universal/ShaderLibrary/RealtimeLights.hlsl
rg -n "LIGHT_LOOP_BEGIN" Packages/com.unity.render-pipelines.universal/ShaderLibrary/RealtimeLights.hlsl
rg -n "USE_CLUSTER_LIGHT_LOOP" Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl

Further reading (official/authoritative sources)