The Forward+ Rendering Path in the Universal Render Pipeline (URP) removes the traditional pixel light limit (default max 8) of standard Forward rendering, letting developers place hundreds or even thousands of light sources in a scene. However, when placing many dynamic point or spot lights under Forward+, you may encounter issues where lights flicker or pop out when moving the camera, or certain lights shut off entirely at specific angles. This article explains the internal tile-culling mechanism behind these Forward+-specific limits and details how to solve them in production.
1. Root Cause: Screen Partitioning and the "Max Lights Per Tile" Limit
This flickering and disappearance issue is caused by Tiled Culling, the technique Forward+ uses to calculate light impact, combined with internal URP limits.
Forward+ rendering divides the screen into a grid of pixels (typically 16x16 pixel tiles in Unity URP). It then computes on the GPU which lights overlap each tile, building a "light index buffer."
However, this buffer has hard limits:
- Max Lights Per Tile: In URP 14 (Unity 2022.3) and Unity 6, the maximum number of additional lights per 16x16 pixel region is fixed at 32.
- Max Visible Lights: The total number of dynamic additional lights active in the frame is capped at 256 (depending on platform and settings).
If many lights crowd into a tight space, or if light Ranges are too wide and cover too many tiles, a tile can exceed the 32-light limit. Any extra lights are dropped from the culling list.
As the camera moves, the relative positions of objects and lights change on the screen, shifting tile boundaries. A light included in the buffer in one frame gets dropped in the next, causing noticeable flickering or sudden blackouts.
2. Solution 1: Optimize Light Density and Range (Best Practice)
The recommended fix is to reduce light overlaps within any single 16x16 pixel screen space. This also boosts overall rendering performance. Use these optimizations first:
| Action | Implementation & Effect |
|---|---|
| Minimize Light Range | Wider ranges cross more tiles. Keep light ranges as small as possible and increase Intensity to compensate. |
| Configure Culling Masks | Exclude unnecessary layers (like background or ground) from the light's Culling Mask to save slot space in tiles. |
| Disable Shadows on Minor Lights | Shadow-casting lights add high CPU/GPU overhead. Disable shadows unless the light is a primary source. |
| Bake Static Lights | Bake static environment lights into lightmaps or light probes. Baked lights are excluded from dynamic tile culling calculations. |
3. Solution 2: Adjust URP Asset Additional Lights Limit
If your project demands a high number of active lights, verify your URP Asset settings:
- Select your active Universal Render Pipeline Asset (e.g., `URP-HighQuality`) in the Project window.
- Open the Lighting section in the Inspector.
- Check the Additional Lights settings.
- Increase the Per Object Limit based on your active light count (e.g., from 8 to 16 or 32). Note: Forward+ uses this parameter for shader compilation compatibility and platform fallbacks.
Forward+ utilizes Constant Buffers (CBuffers) and StructuredBuffers on the GPU. On older mobile devices or WebGL 2.0, if the required buffer size exceeds device capabilities, URP falls back to standard Forward rendering (limiting additional lights to 8 per object). Design limits with target platform specs in mind.
4. Solution 3: Dynamic Light Culling via C# Script
You can manage active lights dynamically by enabling only lights close to the player and disabling (`enabled = false`) or shifting far-off lights to hidden layers.
Here is a lightweight script that manages active lights based on player distance to prevent tile buffer overflows:
using UnityEngine;
using System.Collections.Generic;
public class DynamicLightCuller : MonoBehaviour
{
public Transform playerTransform;
public float cullDistance = 25f; // Distance threshold to enable lights
public float updateInterval = 0.5f; // Update interval in seconds
private List<Light> dynamicLights = new List<Light>();
private float nextUpdateTime = 0f;
void Start()
{
// Cache all dynamic lights in the scene (filtering by tag is recommended)
Light[] lights = FindObjectsByType<Light>(FindObjectsSortMode.None);
foreach (Light l in lights)
{
// Exclude directional and baked lights
if (l.type != LightType.Directional && l.lightmapBakeType == LightmapBakeType.Realtime)
{
dynamicLights.Add(l);
}
}
}
void Update()
{
if (playerTransform == null) return;
// Run at intervals to save CPU performance
if (Time.time >= nextUpdateTime)
{
nextUpdateTime = Time.time + updateInterval;
CullLights();
}
}
void CullLights()
{
Vector3 playerPos = playerTransform.position;
float sqrCullDistance = cullDistance * cullDistance;
for (int i = 0; i < dynamicLights.Count; i++)
{\
Light light = dynamicLights[i];
if (light == null) continue;
// Compare squared distance (avoids costly square root calculation)
float sqrDist = (light.transform.position - playerPos).sqrMagnitude;
// Toggle light to keep active lights per tile below the threshold
bool shouldBeActive = sqrDist < sqrCullDistance;
if (light.enabled != shouldBeActive)
{
light.enabled = shouldBeActive;
}
}
}
}
By using this distance-culling script, even scenes with thousands of lights can run seamlessly because only lights near the player remain active, keeping tile limits well below 32 and completely eliminating flickering.