Stupidly Simple World Lighting

Hello.

Whenever I write little side 3D projects, at some point I have to decide how the hell I’m going to write my world lighting. For daft little side projects, I generally can’t be arsed to write some proper lightmap solution because that complicates your object instancing since you require each object to have a unique set of texture co-ordinates into your computed lightmap and, moreover, you have to actually compute the lightmap. Doing all your lighting in the shader by passing a big array of light positions in works, but is hardly the most efficient of solutions – particularly if most of the lights never move or change colour. And if you want to have a lot of light in your scene, you have to start worrying about quadtrees or some other way of organising the data so you don’t have to pass in and calculate lighting for all the lights which don’t actually contribute anything to the object you’re drawing. Ugh. It’s one thing after another slowing your progress of “I just want to play around with [whatever the thing you want to play around with is]”.

So I came up with a solution which, if I dare say so myself, produces fairly nice lighting (along with some other benefits) in the world’s most ridiculously simple way imaginable. I don’t claim to have invented this because I’d imagine that back before graphics cards were bonkersly powerful, solutions similar to this were possibly fairly common. I don’t know – all I know is that if it’s been done before it’s coincidence and because the results are cheap and effective. But I wanted to write this post to make the point that nice lighting doesn’t have to mean using Unreal – nice (if simple) results can be achieved using cheap and easy effects which are both fun and interesting to write, and potentially result in a distinctive and interesting look for your game.

Before I get into how I did it, here’s an image of the results so you can judge for yourself how it looks:

lighting

The little coloured cubes represent the lights

A couple of things to note:

  1. There’s a kind of ambient occlusion effect going on where the walls hit the ground, and where there’s a corner of a wall
  2. Light bleeds in through the window and door frames
  3. All the objects (every wall segment etc) are literal instances – they have no unique parameters beyond their world matrix
  4. The lighting isn’t actually correct – but it doesn’t (at least to me) look obviously incorrect
  5. This is designed for tile/grid-based games
  6. There are further things I would do to hide the way it works that I haven’t applied here because that’s a) extra homework on your part and b) I wanted this to be a fair test – can you predict the method with all the clues on show above?

Because I know how I wrote this, to me it’s flupping obvious how this works simply by looking at the screenshot above. However in a sample of one programmer, they were not able to guess the technique since they were deceived by the what-appears-to-be-something-like-ambient-occlusion and the light bleeding into presuming it was more complicated than it is.

So, anyway, this is what I’m doing.

Firstly, this is actually a lightmap. It’s just a ludicrously simple lightmap. Here is what it looks like:

lightmap

Yep, that’s it. It’s one pixel per tile, and it generates that by simply rattling through the list of scene lights and adding the amount that light contributes to the pixel. The lights have a radius, so if the light’s radius is 5 tiles, you only need to update an 11×11 set of pixels for that light which is hardly the slowest thing on the planet. This can all be done on level load in a fraction of a second until you need to animate a light or two – in which case you just need to subtract the light values from that 11×11 (for example) region and then add the light values to a different region. If I really wanted, I could do this on the graphics card by stamping tiny light blob textures down on a render target – but when the lightmap is this small, it didn’t seem necessary. The final pass is stamping over the lightmap a precomputed image where all exterior tiles are white, and wall tiles are a semi-transparent black.

The level is, obviously, 3D – so a 2D lightmap like this would work perfectly well for the floor tiles but the walls would simply inherit the light from the floor tiles and look… a bit shit. This is where the tile drawing shader comes in, which looks like this:

shader

Most of that vertex shader is perfectly ordinary put the vertices into the correct place in perspective stuff along with some lines I don’t actually need (I don’t need the actual normal as an output, for example – its inclusion is a relic which can be removed entirely). The only addition is the line:

output.TexCoordLM = ((world + mul(input.Normal, World) * 0.5).xz + float2(0.5, 0.5)) / LightMapSize;

Which generates the texture co-ordinate of the vertex into the lightmap. Take the world position, add on a half-length world oriented normal (the object may be rotated), discard the y component because the lightmap is 2D, add on half a pixel, then divide the result by the dimensions of the lightmap to give me 0..1 values. Done. The pixel shader then simply reads that pixel value from the lightmap texture and multiplies the lighting value onto the colour of the object’s texture. Simple.

So why does it work? Adding on a bit of the vertex’s normal means that a wall, for example, instead of getting its light value from the tile it’s sitting on, gets it instead from a tile a bit in front of where it’s sitting. This gives me something which horribly approximates a proper lighting calculation without any of the actual accuracy (or complexity) of a lighting calculation, but doesn’t appear to be obviously horribly incorrect. The ambient occlusion effect happens for free purely as a result of interpolation – if you set the texture lookup to be nearest as opposed to linear it’d disappear.

So there we go. Embarassingly simple, but not too shabby.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.