The history of light and shadow in one small but proud game
In short, the essence of the article can be illustrated as follows:
Below is a small history of the implementation of lighting in the game with improvised means. They meet, as you know, according to clothes, and when the team does not have the fact that the art director, and even just an artist, an ordinary programmer has to dodge in different ways. At the moment when the game looked like in the picture below, it became clear that you need to add something that visually makes a more diverse and lively image, and at the same time manage the skills of a programmer: ')
The technical conditions at the time of the start of work were: - year 2012 - XNA Framework 4.0 Refresh. Rich Profile, not giving the opportunity to use their shaders. - Mobile phone based on Windows Phone 7: Nokia Lumia 800 (2011 release) - Everything should produce 60fps on the phone and leave a good margin for the rest of the game logic (AI, physics, music)
This is me to the fact that the capacity is limited, so I had to save, where possible. Go!
Day 0. Prototype lighting in the game
To begin with, just to test the idea itself, it was decided to draw the lighting by hand. This is the minimum work:
We take the map and draw the light and shadows manually in the paint
Use the resulting texture as the so-called lightmap.
We select the correct mode of mixing.
If anyone is interested, I used a simple, non-luminous Blend Mode with the following parameters
On this screen is not so obvious, but, nevertheless, it became more pleasant to look. So, it is decided that we are doing the lighting.
Day 1: Simple static shadows Since the game is essentially 2D and the camera almost always looks at one angle, the lighting is done in the simplest and most static:
When loading a level, an illumination texture is generated, which is drawn on top of the level, since the game is “almost” 2d, there is no need to scan for geometry. Since 3d geometry is all static, its lighting is “baked” in the color of vertices.
The generation of the lighting texture buffer (light map) is quite simple: For each source of light:
Clear the temporary buffer
We draw the lighting texture into a temporary buffer (a normal gradient circle using the color blending of the light source), then apply absolutely black shadows to the obstacles in the lighting area
The resulting temporary buffer is mixed with the general lighting buffer (using the usual additive blend)
The result looks interesting, though harsh.
Day 2: Add a penumbra
Usually the light source is not a point source, and therefore the shadow from it is not quite clear, and, moreover, it tends to be more and more blurry with increasing distance from the source. Here the idea was spied on by the magnificent game FEAR. For each light source, the illumination map is drawn several times with a slight offset, and, more precisely, a turn relative to the light source.
Day 3: Smooth shadows
Here we just blurred the lighting texture a bit. Since the game is mobile and the shaders are very limited, it was decided to use the hardware interpolation capabilities.
Example
To get slightly smoother shadows:
We paint the original texture several times with a smaller scale (1/2, 1/4, etc.) into different buffers.
We mix all these buffers with the appropriate scale (2 for texture ½ size, 4 for texture, etc.) using additive blend mode and alpha 1 / N, where N is the number of buffers
There was an idea to mix more "intellectually" to further emphasize the clarity of the shadow at the beginning and the blurriness of the penumbra. But the result of even simple mixing + half-shade from the last point seemed sufficient to us, and we stopped at this option.
Day 4: Occlusion shadow
To create the illusion of self-shadowing of the walls, we had to use another texture (good for low resolution), which the distance map helped to generate, in which the distance to the nearest wall was recorded in each cell.
For example, here is a physical map of the level, where the walls are shown in red:
In this texture, the color of the pixel was chosen according to a simple rule: if the distance to the nearest wall is greater than a certain threshold value - a transparent color, otherwise black. Since the texture is small (1 pixel per 1 game cell ~ 1.5m), hardware interpolation with increasing texture (it stretches about 50 times) ensures smooth transitions between colors. And due to the fact that all the walls in the game are square and strictly along the grid, the small size of the texture does not create any visual artifacts.
Or in the game:
The difference, as you can see, is not striking, but the depth of the picture seems to add.
Day 5. Dynamic shadows
Static shadows are good, but dynamic ones are better. Here are just spending a lot of resources neither their own nor the machine desire was not. The idea was to use 1-2 sprites per dynamic shadow and only change the angle and scale depending on the relative position of the object and the light source. And due to the fact that all game objects are rectangular, the calculation of all this is not so complicated. There is no need to trace the rays in size. The shadows are approximate, so it is sufficient to simply draw a rectangular shadow with a width equal to the projection of the dimensional rectangle [it is highlighted in red in the screenshot below] on an axis perpendicular to the beam from the light source to the center of the object.
And in order to get a cone, we draw two sprites with rotation based on the angular size of our object.
For the shadow sprite, a 4x4 pixel texture with a gradient was used (the red dot is the center of rotation).
As a result, we get something like this:
Due to the gradient on the texture we get the penumbra, and since the texture is drawn two with the usual alpha blending, we get a more saturated shadow in the center.
And, as an example, the comparison of static shadow and dynamic:
Small tricks: 1. Since our shadow is simplified and does not take into account the walls, it is necessary to take care that it does not “shine through” the walls. For this, again, the Distance grid came in handy. For each object, the maximum shadow length was limited to the value from the Distance grid + to the minimum wall size. Of course, this leads to not quite correct behavior of these shadows near the walls, but this effect is much less noticeable than the artifact of the form.
2. At a short distance from the light source, the angular size becomes too large so that the two textures rendered can “simulate a shadow” without breaking. There are two options: either to increase the number of drawings, or when a certain angle is exceeded, to divert the shadow into transparency until it completely disappears. We chose the second option as more economical in terms of resources.
3. At a large distance from the light source, the two shadow textures almost merge when drawing, therefore, when exceeding a certain angular size of an object, one drawing with x2 alpha is enough.
4. As you can see, this implementation of shadows only works adequately with one light source, so if there are more sources, then we just .... do not show the shadow.
5. Corollary 4. Since the source of light for such shadows is always chosen one, when it is changed (or disappears), the unpleasant effect of a dramatic change in shadow will occur. To get rid of it, it is enough to add a smooth transition: that is, the old shadow goes into transparency for some time, and the new (if necessary), on the contrary, manifests itself from full transparency. The game is dynamic, so such transitions often do not particularly attract the attention of their unnaturalness.
Day 6. The effect of dirty lenses
The final touch was the addition of a full-screen “dirty lens” effect.
Reference
This was not so easy in the absence of full access to the shaders and the need to maintain performance.
Method # 1 is simple and fast. They took the texture of dirty glass and used blend mode, which manifests itself in bright areas.
And although this method was fast and in some situations allowed to get the desired picture:
In many situations, the result was sad:
The reason for this behavior is clear, we do not take into account the actual illumination of the area, but simply use the color of the pixel. Therefore, on maps with contrasting lighting, everything looks more or less, but on well-lit everything is terrible.
Method # 2 is slow but beautiful. We draw into the buffer all the light spots from all light sources (smaller sizes and without shadows) in the camera projection, then draw the texture of dirty glass with blend mode from mode # 1. After that, the received buffer is already in use.
However, rendering an additional buffer every frame is not at all fast. The advantage of a separate buffer is that it can not be updated every frame, but only when the camera is moving, but even after such optimizations, the performance on mobile devices was far from desired.
Method # 3 - fast and beautiful We did not have full access to the shaders, but we did have access to one pre-installed dual texture shader. It mixes two textures taking into account texture coordinates through multiplication (more precisely, via Modulate2X blend mode blogs.msdn.com/b/shawnhar/archive/2010/08/04/dualtextureeffect.aspx ). The first texture was a prepared texture containing all the light spots of interest to us (due to the fact that the game is essentially 2d, it is enough to prepare it once for a level), the second is dirty glass. And the only thing that needs to be updated every frame is the texture coordinates of the first texture. They are calculated by screen projection into texture coordinates 1 (these are simply world coordinates with scale).
The final result, in fact, does not differ from method 2, but does not require unnecessary rendering to the buffer.
Total:
Thus, for the final frame we needed:
A) Once at the start of the card
Calculate static lighting map
Calculate shading map for occlusion shadow
Prepare a bright point buffer for the dirty lens effect.
Prepare a cache of the nearest light source for all points for dynamic shadows.
B) For each frame
Draw a static lighting map
Calculate the angle and width of dynamic shadows and draw 1-2 sprites per object
Prepare a bright point buffer for the dirty lens effect.
Project 4 points to world coordinates, update texture coordinates, and draw one texture with a dual texture shader for a dirty lens effect.