There are generally two methods for dealing with this. Nowadays, they are called forward rendering and deferred rendering. There is one variation on these two that I will discuss below.
Render each object once for every light that affects it. This includes the ambient light. You use an additive blend mode (
glBlendFunc(GL_ONE, GL_ONE)), so each light's contributions are added to each other. Since the contribution of different lights are additive, the framebuffer eventually gets the valu
You can get HDR by rendering to a floating-point framebuffer. You then take a final pass over the scene to down-scale the HDR lighting values to a visible range; this would also be where you implement bloom and other post-effects.
A common performance enhancement for this technique (if the scene has a lot of objects) is to use a "pre-pass", where you render all of the objects without drawing anything to the color framebuffer (use
glColorMask to turn off color writes). This just fills in the depth buffer. This way, if you render an object that is behind another, the GPU can quickly skip those fragments. It still has to run the vertex shader, but it can skip the typically more expensive fragment shader computations.
This is simpler to code and easier to visualize. And on some hardware (mainly mobile and embedded GPUs), it can be more efficient than the alternative. But on higher-end hardware, the alternative generally wins out for scenes with a lot of lights.
Deferred rendering is a bit more complicated.
The lighting equation you use to compute the light for a point on a surface uses the following surface parameters:
- Surface position
- Surface normals
- Surface diffuse color
- Surface specular color
- Surface specular shininess
- Possibly other surface parameters (depending on how complex your lighting equation is)
In forward rendering, these parameters get to the fragment shader's lighting function either by being passed directly from the vertex shader, being pulled from textures (usually through texture coordinates passed from the vertex shader), or generated from whole cloth in the fragment shader based on other parameters. The diffuse color may be computed by combining a per-vertex color with a texture, combining multiple textures, whatever.
In deferred rendering, we make this all explicit. In the first pass, we render all of the objects. But we don't render colors. Instead, we render surface parameters. So each pixel on the screen has a set of surface parameters. This is done via rendering to off-screen textures. One texture would store the diffuse color as its RGB, and possibly the specular shininess as the alpha. Another texture would store the specular color. A third would store the normal. And so on.
The position is usually not stored. It is instead reconstituted in the second pass by math that's too complex to get into here. Suffice it to say, we use the depth buffer and the screen-space fragment position as the input to figure out the camera-space position of the point on a surface.
So, now that these textures hold essentially all of the surface information for every visible pixel in the scene, we start rendering full-screen quads. Each light gets a full-screen quad render. We sample from the surface parameter textures (and reconstitute the position), then just use them to compute the contribution of that light. This is added (again
glBlendFunc(GL_ONE, GL_ONE)) to the image. We keep doing this until we run out of lights.
HDR again is a post-process step.
The biggest downside to deferred rendering is antialiasing. It requires a bit more work to antialias properly.
The biggest upside, if your GPU has a lot of memory bandwidth, is performance. We only render the actual geometry once (or 1 + 1 per light that has shadows, if we're doing shadow mapping). We never spend any time on hidden pixels or geometry that isn't visible after this. All of the lighting pass time is spent on things that are actually visible.
If your GPU doesn't have lots of memory bandwidth, then the light pass really can start to hurt. Pulling from 3-5 textures per screen pixel isn't fun.
This is sort of a variation on deferred rendering that has interesting tradeoffs.
Just as in deferred rendering, you render your surface parameters to a set of buffers. However, you have abbreviated surface data; the only surface data you care about this time is the depth buffer value (for reconstructing the position), normal, and the specular shininess.
Then for each light, you compute just the lighting results. No multiplication with surface colors, nothing. Just the dot(N, L), and the specular term, completely without the surface colors. The specular and diffuse terms should be kept in separate buffers. The specular and diffuse terms for each light are summed up within the two buffers.
Then, you re-render the geometry, using the total specular and diffuse lighting computations to do the final combination with the surface color, thus producing the overall reflectance.
The upsides here are that you get multisampling back (at least, easier than with deferred). You do less per-object rendering than forward rendering. But the main thing over deferred that this provides is an easier time to have different lighting equations for different surfaces.
With deferred rendering, you generally draw the entire scene with the same shader per-light. So every object must use the same material parameters. With light pre-pass, you can give each object a different shader, so it can do the final lighting step on its own.
This doesn't provide as much freedom as the forward rendering case. But it is still faster if you have the texture bandwidth to spare.