NEP Lighting Engine Part 3: Light channels

by Cuchaz

This is the fourth and final installment in a four-part series on the lighting engine in my upcoming indie game, Non-Essential Personnel. If you ended up here first, maybe try starting at the beginning instead.

Tossing colored lights into our approach so far looks really bad

A render from the game without any blending between colored lightsRed vs. Blue
Incidentally, this is a kind of discrete Voronoi diagram.

Since we decided that we were only going to care about the closest light to each block, that means lights just end before they get to blocks that are closer to other lights. It looks bad. We need to let the light travel farther before stopping it so it can blend with the other lights.

But if we start blending multiple lights at a single block, then we run back into that problem of having too many light at each block, right?

Well, kind of. Storing every possible light that could shine on a block at that block is a collossaly bad idea, so let's not do that. But storing... say... a max of 16 lights at each block wouldn't be so bad. If we have a limited number of slots for lights, then things can't get out of hand.

That's where light channels come in.

If we have, say N spots in general at each block for lights, then a light channel is all the information about lighting we cram into that slot. It looks much better if all the lights are the same color, so let's add that restriction too. All the lights in a light channel have to be the same color.

I totally made up light channels. They're not a real thing.

At least not a thing that I knew about before I started working on NEP.

Anyway, remember all that business about doing BFS searches, and setting light pointers, and finding corners from the previous post? Let's write all of that stuff to our light channel. For NEP, the lighting engine keeps track of a few different pieces of information for each block and by this point in the blog post, I've mentioned all of them at least once.

  • Light source position - The position in the world of the light source at the root of the BFS tree for this block.
  • "Visible" light source position - The position in the world of the light source actually lighting this block. i.e., it could be a hacky "corner" light or it could just be the actual light source.
  • Upstream BFS neighbor - Which neighbor is "upstream" in the BFS tree so we can do some traversal.
  • BFS distance to light source - Distance along the BFS tree to the root. It's the sum of a bunch of 5s and 7s from our weighted neighborhood. This is the part that's useful for rendering shadows.
  • Opacity penalty - Extra penalty for traveling through solid blocks so the shader can remember to make them darker

For NEP, I can actually cram all this information into 8 bytes (one long integer) for each block with a little room to spare. If you're familiar with Minecraft modding at all, at this point you're probably gaping in horror at the blantant disregard for the storage requirements of such a light system. Coming from a game that spends at most 3 bytes per block (and sometimes less) for lighting, block identity, AND block data, 8 bytes per block PER CHANNEL must seem like a lot of space on top of whatever block identity/data I'm storing that I haven't mentioned at all.

A screenshot of a Minecraft world with a really far render distance SO. MANY. BLOCKS.

Dimensions are hard. Three dimensions are way way harder than two. Turns out, most geometric algorithms have an exponential dependence on the number of dimensions. Being merely a 2D game lets NEP take a lot of liberties with engine design that a full 3D voxel game would have a hard time dealing with.

Like storing lots of per-block data. =)

This scene for instance uses four different light channels. One for the sky light, and one for each of the red, green, and blue colors. All the colors blend pretty nicely.

A render from the game showing a rainbow of colors R + G + B = RGB. It projects a nice smooth rainbow across the top of the cavern.
It's kind of fun to try to pronounce RGB out loud. There aren't any vowels.

16x8 per block is a bit much though, isn't it?

Yeah, it's enough to make our 2D world as big as a 3D world in terms of storage. Luckily, there's another trick we can do to bring the space requirements down to something more manageable. We can take advantage of sparsity in less used light channels.

When a mathematician or a computer scientist tells you something is sparse, you should get really excited. That means it's over 9000 times easier to deal with than something that isn't sparse (i.e., dense). The sky light channel in NEP is probably going to be constantly filled with the various comings and goings of sky light. Unless you're deep underground, of course. Then it's likely to be very quiet. Same for the colored light channels. Unless you happen to be standing next to a red light, the red light channel in your part of the world is probably going to be very boring.

In the parts of the world where there's not much lighting activity going on, there's not much sense in writing down all those zeroes in the light channel. In those cases, we can just skip writing down light information entirely. If nothing's lit up, there's no sense in wasting space on lighting info. Therefore, with the right optimizations to take advantage of sparsity, light channels that are mostly empty take up almost no space at all.

So how many light channels can we have?

A well-designed lighting engine could probably handle hundreds of light channels simultaneously as long as they weren't all used in the same place at the same time. I wonder where I could find an engine like that...

The trouble is, we can't actually send hundreds of lights to the shaders. Well, maybe we could, but you wouldn't want to wait for the shader to actually compute hundreds of lights for each pixel. It would take way too long. Even on a GPU.

On my development machine (which is modest by today's standards) with the current implementation of NEP's engine, I can send about 16 point lights, 16 directional lights, and 16 diffuse lights to the shader before the OpenGL driver starts complaining about allocating things. Even if we have hundreds of light channels in a world, they're probably not all used at the same time. And if they are, we can just render the 16 most intense lights for each block. Maybe no one will even notice the dimmer lights are even missing.

That's it! Thanks for coming!

NEP's game engine (incidentally, it has a name. I'm calling it the Horde engine) has a lighting system that can handle thousands of simultaneous light sources without a hiccup. Light channels means those thousands of different light sources can have a handful of different colors too. All the shader tricks mean the lights look very smooth and the engine even makes some attempt to model complicated atmospheric effects like scattering. I'm hoping this will make for a pretty sweet-looking game even if I'm doing my best to ruin the asethetic with my mediocre art skills. Everything will probably work out just fine. =)

Staying in touch

If you want to follow along with NEP's development, then please feel free to bookmark this blog and come back every now and then to check for updates.

If you're feeling more social today, you can also follow me on Twitter. I'll generally tweet when something interesting happens, so you can get updates that way too.