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.

Meet the Cast

I finished drawing the portraits for the primary cast this week, so today’s post will be a quick one to introduce them. As with everything described in this blog, names and details are subject to change.

This blog has been hacked by Unit. "password123", David? You can do better. —Unit

Spoiler warning: I’m not going to give away any major plot points here, but there is obviously some spoilage potential in knowing who the characters in NEON STRUCT are.

Hello dear reader. Don't believe the intel here! We know the truth. —Unit

Jillian Cleary
The protagonist of NEON STRUCT and the character you’ll be playing as. Jill is a senior field agent for the Agency, a monolithic U.S. intelligence-gathering agency.

Vinod Chhibber
As Jill’s handler at the Agency, Vinod provides guidance, intel, and support while Jill is in the field. An advocate of privacy rights, he is deeply concerned about secret mass surveillance programs in development at the Agency.
We can trust him. —Unit

Frank Furtwengler
The current Director of the Agency, Frank is responsible for implementing its broadest surveillance programs.
Possible ties to Oculi Mundi? —Unit

Alyssa Lawrence
No intel available.
Possible smokescreen? Should warn AL she's on file. —Unit

Grace and Phil Bouchard
Old college friends and former roommates of Jill’s. They had a falling-out with Jill around the time they got engaged and have not spoken with her in years.

Peter Tannhäuser
Entrepreneur. Venture capitalist. Founder and CEO of TannCo, a private media corporation based in Cologne. Depending on whom you ask, Peter is either a fierce advocate of liberty and transparency, or an anarcho-capitalist bent on taking down the state for his own gain.

Beatrix Cheung
British Consul to Germany.
What's her connection to Dir. Furtwengler? —Unit

PAX, Procrastination, Persistence, and Plans

Despite missing last week’s devlog update, I don’t have a whole lot of substantial progress to talk about this week. I was at PAX Prime over the weekend (just for fun—Minor Key Games wasn’t showing anything there), and my work focus has remained somewhat disrupted since I got back.

Most of my schedule was planned around the approximately two dozen levels in NEON STRUCT—which is also why many of my previous updates have been level-centric—but I have a modest backlog of tech and gameplay tasks to tackle as well. For months, I had been putting off that work to focus on the fun parts of level building. But since returning from Seattle, I’ve found it difficult to get excited about building levels, so I decided to revisit those overdue tasks.

As the primary developer on NEON STRUCT, it doesn’t really matter what order I do my tasks in. It all has to get done for the game to be finished. And I’m constantly busy, so there are no “pipeline stalls” even if one task is blocked by some other task. With this perspective, “procrastination” isn’t necessarily a bad thing. As I mentioned last month, periodic task switching keeps me energized and allows more time for me to reflect on the needs of a particular level or feature before continuing with its implementation.

At my last job, I often quoted Joel Spolsky’s “fire and motion” as a shorthand for, “You have to move forward a little bit, every day.” My progress this week has definitely been only moving forward a little bit, every day; but the little bits are adding up, and small progress is better than no progress at all.

One of these “little bits” is a non-combat enemy AI behavior. It is sometimes useful to have characters in stealth levels who regard the player as an enemy, but do not carry weapons or attack the player. When they discover the player, they will flee and alert other AIs in the vicinity.

This feature primarily depended on a new A* path search mode. My existing modes included a basic path search (where the heuristic minimizes distance to a destination) and a wander mode (where the heuristic is constant and the path expands randomly until a given distance is reached). In order to make characters run away from the player or any other entity, I added a flee mode (in which the heuristic maximizes distance from the target, up to a given distance).

The rest of the implementation was just small changes to the AI motion component (to periodically recheck the flee target’s location and repath if needed) and the character’s behavior tree to actually invoke the behavior.

Another recent “little bit” is the addition of flying drones. They operate as a hybrid of non-combat characters and mobile security cameras. I already had a good chunk of AI flight code from Eldritch, and it took only a few tweaks to produce the desired motion for these drones. In Eldritch, flying enemies would always descend to the player’s level when attacking. In NEON STRUCT, that behavior looked strange; my expectation was that drones would hover some meters above their target, so I simply added a vertical offset.

As these and other little bits come online, I am contemplating putting together an IGF build. The timing of the IGF isn’t ideal for me—NEON STRUCT is only 30-40% finished, and what I would submit would be only a slice of the final game—but it provides an incentive to make a tighter, more polished playtest build than I might otherwise commit to.