All of the Lights

A few friends have commented on or asked about the lighting in NEON STRUCT, so I’m going to provide a brief overview of how it works.

For context, let’s first look at the lighting in Eldritch to see how the system developed.

In Eldritch, there are few lights and usually a bright directional ambient term. (I’ve chosen a dark level here to illustrate other lighting characteristics, but levels like R’lyeh were almost entirely lit by the ambient term.)

Lighting is semi-static and baked into the voxel grid. Each 1m3 cell acts as a low frequency light probe and stores the incoming light on each axis: X±, Y±, Z±. Each light value is represented as a 4-dimensional vector of floats, and each cell contains six of these. 4 bytes * 4 * 6 = 96 bytes per cell. In Eldritch, these values were stored in a flat array, totaling about 6MB of light data per map. Lights can be added, removed, or changed at runtime, but this requires resolving all light sources influencing the local voxels and rebuilding the local voxel hull meshes. This can usually be done within the span of a 60Hz frame without causing a hitch, mostly because Eldritch has few lights, and they have relatively small radii.

When the voxel hull meshes are constructed, Eldritch queries the singular probe on the front face of each voxel and lights the voxel by baking that light value into the mesh’s vertex color. Using only the local light value produces discontinuities between voxels, causing the “blocky” effect indicated in the image above. (The most recent update to Eldritch adds light smoothing from NEON STRUCT as described below, but the blockiness was present in all prior versions.)

Objects are also lit using the voxel light grid, but in a different manner because the lighting cannot be baked into the vertex stream. Each tick, the renderer looks up the light probe nearest to each object’s centroid and passes its values to the pixel shader. After transformation, the mesh is lit by multiplying the components of its per-pixel normal by the corresponding values in the light probe, like so:

float4 LightProbe[6]; // X+/-, Y+/-, Z+/-
float4 GetLight( float3 InNormal )
{
    float3 NormalSq = InNormal * InNormal;
    int3 IsNeg = InNormal < 0.0f;
    return
        NormalSq.x * LightProbe[IsNeg.x] +
        NormalSq.y * LightProbe[IsNeg.y + 2] +
        NormalSq.z * LightProbe[IsNeg.z + 4];
}

This produces soft per-pixel lighting which roughly approximates the directions of incoming light.

NEON STRUCT builds upon Eldritch‘s lighting system, with a few important changes. The two most apparent differences are smooth world lighting (no more blocky discontinuities between voxels) and cel shading on objects.

Smoothed world lighting is simple in theory: just blend the light values between each voxel. In practice, it was somewhat trickier than I expected. Each corner of a voxel’s face is lit by the local light probe and the three probes adjacent to that corner, but only if the adjacent voxels are non-solid. If an adjacent voxel contains geometry, it needs to be ignored in the smoothing operation or else it will darken the voxel’s face in an undesired way. (Voxel lighting in both games already has an ambient occlusion factor to darken concave edges and corners.)

Object lighting in NEON STRUCT is similar to that in Eldritch, but the shader is modified to produce a pseudo cel shaded effect. Unlike typical cel shading, in which the color is quantized to produce discontinuities, I chose to quantize the direction of the per-pixel normal to the cardinal axes. This produces discontinuities in color even when the colors in each direction are relatively similar, so objects in low light scenes will retain some definition and not be completely flattened out.

float4 LightProbe[6]; // X+/-, Y+/-, Z+/-
float4 GetCelLight( float3 InNormal )
{
    float3 NormalSq = InNormal * InNormal;
    int3 IsNeg = InNormal < 0.0f;
    int UseX = NormalSq.x >= NormalSq.y && NormalSq.x >= NormalSq.z;
    int UseY = ( 1 - UseX ) * ( NormalSq.y >= NormalSq.z );
    int UseZ = 1 - ( UseX + UseY );

    return
        UseX * LightCube[IsNeg.x] +
        UseY * LightCube[IsNeg.y + 2] +
        UseZ * LightCube[IsNeg.z + 4];
}

The less visible changes since Eldritch include significant optimizations for adding/removing/changing lights, as described in a couple of previous posts. Furthermore, the flat array of lighting data (which was over 6MB for Eldritch‘s small levels) needed to be converted into a sparse array for NEON STRUCT‘s larger levels. Now, any voxel which is lit only by the ambient term does not store a light probe.

And while it’s not strictly part of the lighting system, the addition of bloom in post processing contributes a lot to the apparent brightness of the scene. Post process effects could be the subject of a future post, but in brief, NEON STRUCT‘s post chain starts with chromatic aberration (RGB separation), adds bloom, subtracts film grain noise, and finally applies color grading.