Skip to content

A City With Thousands of Lights and Zero Light Actors

19 June 2026 · Devlog

A city at night has thousands of light sources: windows, lamp post bulbs, and pools of light under lamps and doors. The obvious way to build that in UE5 is one light actor per source, and it stops scaling long before the city is finished. In CityGen V1.3 I lit the whole city without a single point light. This article covers why actor-per-light doesn’t scale, the architectural decision that makes thousands of “lights” effectively free, the implementation pieces you can lift into any UE5 project, and where the approach breaks down.

Why actor-per-light doesn’t scale

The traditional way to light a UE5 city is an actor per light: one APointLight per window, one per lamp post bulb, one per door spill. A modest 200-building block ends up with 800–2,000 light actors, and every one of them brings a shadow map allocation, a light culling pass, a draw call contribution and a lighting pass per cluster. The editor doesn’t warn you. The frame rate does.

Here’s where the milliseconds go:

Cost Per shadow-casting point light At 1,000 lights
Shadow map render ~0.05–0.2 ms 50–200 ms
Light culling ~0.005 ms 5 ms
Lighting pass scene-dependent dominant frame cost

These numbers are order-of-magnitude; the exact cost depends on shadow resolution, culling and hardware. And that’s before the lights do anything visually different from an emissive material at most viewing distances.

Separating “appears lit” from “actually casts light”

The key insight is that these are two different requirements:

  • A window that looks lit only needs the appearance of light, and an emissive material gives you that for free.
  • A real light is only needed when something needs dynamic illumination (characters moving under it) or real-time shadows.
  • A city builder seen from 50 m up doesn’t need either.

So real lights become the rare, selective case instead of the default.

The architecture: one actor, one scalar, many materials

ACityGenTimeOfDay (drives clock)
│
▼
MPC_CityGen.Illuminate (0.0 → 1.0)
│
├── M_House_Windows (emissive *= Illuminate)
├── M_Light (lamp bulb emissive)
├── M_LampPostDecal (ground spill decal)
└── M_DoorSpill (door puddle decal)

One actor writes one scalar, and every material that needs to respond reads it. Zero lights, zero per-instance state. Adding a new “light source” later means authoring a material that samples the scalar.

ACityGenTimeOfDay: the clock that drives everything

  • TimeOfDay is a float (0–24h) that advances at MinutesPerSecond.
  • The sun and an optional moon are ADirectionalLight actors assigned in the Details panel.
  • Yaw is 15° × t + NorthYaw and elevation is -cos(t/24 × 2π) × 90°.
  • The moon is mirrored through the horizon (opposite pitch, +180° yaw), and its intensity inverse-scales with the day blend so it hits 0 at peak day.
  • A PreviewInEditor button lets you scrub the time in the Details panel and see the lighting update without entering PIE.

Lesson 1: directional light intensity is in lux

Real-world midday sun is about 100,000 lux, and full moonlight is 0.1–1 lux. My first pass used NightIntensity = 100, and night looked like an overcast afternoon. The correct defaults are DayIntensity = 75,000, NightIntensity = 0 and MoonIntensity = 1.0.

If your night looks weirdly bright, check your intensity units before anything else.

Lesson 2: pin ForwardShadingPriority

Two directional lights means competing primaries. UE5 picks one directional light as the primary for forward-shading paths (translucency, water, volumetric fog). By default that’s whichever is brightest, so it swaps mid-cycle as the sun and moon intensities cross, and the editor throws a warning every time.

The fix is to pin ForwardShadingPriority at BeginPlay: 1 on the sun, 0 on the moon. One line of code, and that warning is silenced for good.

Lesson 3: a high Min EV keeps night dark

Exposure is counter-intuitive. AutoExposureBias, MinBrightness and MaxBrightness clamp the camera’s EV adaptation. EV is logarithmic, and a higher EV value means more darkening applied to the scene. So a high Min Brightness forces the camera to a high EV, and dim scenes render dim. With a night Min EV of -3 the scene was blown out white; at 12 it was properly dark.

That cost me an hour, so I wrote a long code comment to make sure the next person doesn’t pay the same hour.

SkyLight: don’t recapture every frame

A UE5 SkyLight with Real Time Capture enabled re-captures the sky every frame, which costs roughly 1–2 ms of GPU time. For a slow day/night cycle, recapturing every 1–2 seconds is visually indistinguishable. So the actor has a SkyLight slot and calls RecaptureSky() on an interval. That saves the per-frame cost, and the sky still darkens at night because the sun has rotated.

The interval is tunable per asset: busy scenes can go down to 0.5 s, idle ones up to 5 s.

The Material Parameter Collection pattern

A Material Parameter Collection is the right tool for fanning one value out to many materials. There’s one UMaterialParameterCollection asset (MPC_CityGen) with one scalar parameter (Illuminate), and the actor writes the scalar each tick:

MPCI->SetScalarParameterValue(IlluminateParameterName, Illum);

Every material in the project that wants to participate reads the scalar through a CollectionParameter node. There’s zero coupling between the actor and the materials, so you can add new responsive materials without touching the actor.

The smooth ramp

Illuminate goes 0 → 1 → 0 over the lit window. The window opens HoursBeforeSunset hours before 18:00 and closes HoursAfterSunrise hours after 06:00. By default the city is lit from 17:00 to 07:00, with a 30-minute smoothstep at each edge.

The math is normalised to “hours since OnStart, wrapping at 24”:

TimeFromOnStart = Fmod(TimeOfDay - OnStart + 24, 24);
if (TimeFromOnStart < FadeHours) Illum = SmoothStep(0, FadeHours, TimeFromOnStart);
else if (TimeFromOnStart < Duration - Fade) Illum = 1.0;
else if (TimeFromOnStart < Duration) Illum = 1.0 - SmoothStep(Duration - Fade, Duration, TimeFromOnStart);
else Illum = 0.0;

Materials: emissive does the work

Windows with M_House_Windows

Emissive alone is enough. The base is a standard PBR material, and a black-and-white window mask texture separates glass from wall (1.0 = window glass, 0.0 = brick). The emissive is:

Emissive = WindowMask × WarmColor × CollectionParameter(Illuminate) × NightBrightness

  • At Illuminate = 0 there’s no emissive contribution, so the windows look like normal glass in daylight.
  • At Illuminate = 1 the windows put out warm light at the authored brightness.

No light actor, no shadow cost.

Lamp bulbs with M_Light

Same trick, different material. The lamp post bulb mesh has its own material, M_Light, with emissive = BulbColor × Illuminate × Intensity. That one parameter drives every lamp in the city. The material is authored once and applies to every SM_LampPost instance, and since those are already ISM-rendered, adding more lamps is free.

Decals: faking the pool of light

Lamp post spill

A real lamp would cast a circular pool of light on the ground beneath it. A real spot light means real cost; a decal is close to free. For each lamp instance I spawn a UDecalComponent alongside the ISM. The decal material samples the same Illuminate MPC scalar, so the pool fades in at dusk. It reads as “this lamp is illuminating the ground”, and the viewer can’t tell it isn’t a real light.

Door spill

Same pattern, different surface. House front doors get a small decal underneath, projecting onto the sidewalk and grass. It uses the same M_*Decal material family and the same MPC scalar, so the door lights up, the pool appears, and both ramp together with the rest of the city. It’s authored once per building scene and replicated across every instance through the existing ISM/scene mesh pipeline.

Atmosphere from ExponentialHeightFog

The air gets its time-of-day colour shift without any CityGen code. With one AExponentialHeightFog in the level, the inscattering colour is driven by the sun’s direction, shifting orange at sunset and blue at twilight. It’s a stock UE5 feature; you just have to know to enable it. Once the sun rotates, the fog colour follows.

The result

Scrub a camera through a full cycle and the 17:00 to 18:00 stretch is where it all comes together: windows, lamps, decals and fog ramp up at the same time. One actor, one scalar, a coordinated city.

Where this breaks down

This approach has clear limits:

  • No real shadows from these “lights”. A character won’t cast a shadow under a lamp.
  • No dynamic character illumination. Your hero walking past a window doesn’t get lit by it.
  • Light spill decals can stack visibly where two lamps overlap, because they add up.
  • First-person walks under lamps, where the player expects to be lit by the lamp, still need a few real lights placed selectively.

This is a cityscape technique. Use real lights where the player gets close.

Performance

These are rough numbers from my demo machine, with the same scene, the same camera angle and the same number of “light sources” (about 600 windows and 80 lamps):

  • Point light per source: about 22 fps, with shadow and culling cost dominant.
  • Emissive + decal: about 65 fps. The lighting is essentially free, and the frame is bound by terrain and foliage.

That’s roughly 3× the frame rate at zero visual cost. Your mileage will vary, but the ratio shouldn’t.

A pattern for any UE5 project

The “one MPC scalar, many materials” pattern isn’t specific to CityGen. You can use it for time of day, weather (rain wetness), season (snow coverage), team colours or alert states: anywhere you’d otherwise scatter actor references or rebuild meshes to change visual state. The cost of adding a new participating material is one node in the material graph. The value is read once at compile time and written once per tick, which is cheaper than a tick on a UPROPERTY.

Where to get it

The system ships in CityGen V1.3 (UE 5.4 / 5.6 / 5.7), available now on Fab. The source is part of the plugin, so read ACityGenTimeOfDay.cpp for the production code. There’s also a playable demo build on Patreon: a packaged Windows build with this exact system running. In the next video I’ll cover distance fog tuning and how to wire your own emissive materials to the MPC.