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.
- Lit core symbol xref (definition/representation reference): @@TOK_0_8394c928@@
- Function signature index (return type/parameter/definition file/line): @@TOK_1_20e1594e@@
- Structure index (field list/definition file/line): @@TOK_2_11a7626e@@
Additionally, “what pass Lit.shader actually has” is fixed here.
- Lit Pass/Include Map: @@TOK_3_14164784@@
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:
- Shader keyword:
_CLUSTER_LIGHT_LOOP(Select compilation variant/path) - 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):
- Definition:
<URP>/ShaderLibrary/RealtimeLights.hlsl:12(see @@TOK_11_11a7626e@@)
| 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
Lightdoesn'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 asLight.
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:
int count = GetAdditionalLightsCount();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_LOOPRelated:<URP>/ShaderLibrary/Core.hlsl:13and<URP>/ShaderLibrary/ForwardPlusKeyword.deprecated.hlsl:20USE_CLUSTER_LIGHT_LOOPRelated:<URP>/ShaderLibrary/Core.hlsl:14/:16
The point can be summarized (in concept form) as follows:
// <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):
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)
7.9 Practical implementation pattern (recommended): “Keep the URP loop and just replace the BRDF”
The safest customization is one of the following:
- Change only SurfaceData generation (Albedo/Normal/Roughness, etc.)
- 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.
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.
- Check keyword: Is there
_CLUSTER_LIGHT_LOOPvariant in the shader?- URP Lit uses
#pragma multi_compile _ _CLUSTER_LIGHT_LOOP.
- URP Lit uses
- Check loop implementation: Is there only a
GetAdditionalLightsCountbased for-loop?- If so, the loop may not run from Forward+ to count==0.
- Use URP provided loop: Switch to
LIGHT_LOOP_BEGIN/END - Frame Debugger: Verifies that the “Additional Lights” step is actually executed in the forward pass.
- Track definition location: Check definitions
_CLUSTER_LIGHT_LOOP,USE_CLUSTER_LIGHT_LOOP,LIGHT_LOOP_BEGIN/ENDin @@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
- Function: Search function name in @@TOK_8_20e1594e@@
- Structure: Search structure name in @@TOK_9_11a7626e@@
- Key symbol xref: @@TOK_10_8394c928@@
B) Find directly from source (rg)
From the URP project root:
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)
- Forward+ additional light (concept/setting): https://docs.unity3d.com/6000.3/Documentation/Manual/urp/rendering/additional-lights-fplus.html