The Future Is Bright: Updating Lighting Systems in Roblox
By: Arseny Kapoulkine
Tags: Graphics, Game Development, Lighting, Voxel

In 2012, we implemented a new lighting system that we’ve used since across all of Roblox — it uses voxels and is updated incrementally (and lazily) by the CPU. It has served us well but as we started to explore ways for Roblox graphics to look much better, we realized the system needs an update. Lighting is a complicated system, so we needed to be careful when deciding on a course for our next-generation technology and consider all the possible tradeoffs and factors. Two alternative systems have been prototyped in 2017 to help guide our decision (which will be discussed further in this document), we’ll call them “voxels” and “shadow maps.” It’s important to understand how both of them work to understand the limitations. A summary table is included at the end of this article.

A note on this article’s screenshots: the layout of these is always such that the screenshot on the left is using the voxels system, and the screenshot on the right is using the shadow maps system.

Implementation — Voxels

This would be a significant evolution of our original voxel system. First, the world data is voxelized into a set of voxel grids — each grid is centered around the character and has increasing voxel sizes all the way from 1 stud to 16 studs (with 5 grids in total). Each voxel contains occupancy information (0–100% full). After this, we compute lighting data for each voxel in each grid, based on the occupancy in each voxel, and on the light sources / sunlight direction. All of the above happens on the GPU, as the CPU is not fast enough to update so many voxels at such a high density.

The system stores all data in voxels, so specifically for each voxel we have:

Occupancy (multiple values describing how full each voxel is)

Skylight (how much of the sky is visible from the voxel)

Sun shadow (how much of the sun is occluded from the voxel)

Light object color/cone (the approximation of the color/cone of impact of local light sources on the voxel)

This information is later used to compute the color of each pixel at a given resolution; screen resolution and voxel resolution can be controlled independently. Parts of voxel grid(s) can be updated each frame as lights/objects move.

Implementation — Shadow Maps

The Shadow Maps method uses rasterization to compute most of the shadow effects and executes in three phases. First, for each shadow casting light we update a shadow map by rendering triangles of objects into a texture from the viewpoint of the light (you can think of this as casting a lot of rays from the light source into the scene and remembering the intersection results). Second, we build a spatial acceleration structure that we insert each visible light object into, that is essentially a frustum-shaped voxel grid (a.k.a. a froxel grid).

The grid covers the entire camera frustum, and within each froxel we record the list of all light objects that intersect it. Finally, when rendering the scene, for each pixel to compute the impact of all lights we look up the froxel our pixel is contained in, go over all lights, and for each light compute the impact of this light using the shadow maps built in step one to determine visibility.

The system stores all data in two structures:

Shadow atlas (all shadow maps from visible lights, packed into one big texture)

Light grid (froxel grid that can efficiently resolve a point in the camera into a list of lights)

The color of each pixel is computed dynamically and is not stored explicitly. Parts of the shadow atlas can be updated each frame as lights/objects move.

Performance — Voxels

The voxel technique is fundamentally more scalable — as long as we’re willing to degrade the output quality, we can reduce the number of voxel grids, and update fewer voxels each frame (leading to “light lag” as the light impact of the objects will update more slowly than the objects themselves) Voxels decouple all three sources of complexity: geometrical complexity, light complexity, and pixel count. Geometrical complexity only impacts voxelization cost, so putting more objects doesn’t make anything else slower. Light complexity only impacts light computation cost which does not depend on geometrical complexity or the number of pixels. Finally, computation of the final pixel color is constant-time in the number of voxels/lights/parts, so we can scale the resolution independently without increasing the lighting performance impact.

You can think of voxel worst-case performance as O(G)+O(L)+O(P), where G is # of triangles (geometry complexity), L is # of lights, P is # of pixels.

Unfortunately, the peak performance of voxels is suboptimal because the number of voxels scales as N3, and GPUs aren’t ideally suited for the update techniques we have to use to keep performance manageable. Given enough research into GPU computing we should be able to offset the performance loss, but the current baseline cost can be pretty high.

Performance — Shadow Maps

For shadow maps, the technique is more GPU-friendly as it’s designed around rasterization which is what GPUs excel at. The shadow atlas update cost can be partially mitigated by caching/delaying updates (leading to “shadow lag”). For the shadow maps that we do update, optimizing the efficiency of geometry submission (including mesh level of detail) reduces the cost.

However, the baseline cost of shadow update in complex scenes is significant since it depends on both the amount of geometric detail and the amount of shadow-casting lights. For a moving light with a large radius inside the building, we have to re-render the entire building each frame to update the shadow information for this light; many moving shadow casting lights in a building result in poor performance (we can choose to update only some lights each frame, resulting in visual artifacts).

Additionally, the technique does not provide a way to separate resolution from the light count — for each pixel we have to recompute the influence of all lights that cover this pixel. This step also can not be cached, leading to performance issues on high resolutions in densely lit scenes — 20 overlapping lights in a room at 4K resolution might require 160M light evaluations).

You can think of shadow map worst-case performance as O(GL)+O(LP), where G is # of triangles (geometry complexity), L is # of lights, P is # of pixels.

Performance — Evaluation

To put the theoretical results above in a more practical environment, here are results from some of the levels we have tested. These results use existing implementations — that have not been necessarily tuned for performance — and also assume no caching whatsoever, as if every light source/part in the world were moving.

Paris (sun shadows, very few non-shadow-casting lights)