Fast Flood Fill Lighting in a Blocky Voxel Game: Pt 2

Sunlight

We have learned the basics of voxel light propagation, but we have not yet covered the special case of sunlight. Assuming that you are using cubic chunks, how do we determine if a block is in sunlight or not? How do we deal with sunlight coming from unloaded chunks?

Propagation

First lets worry about propagating the sunlight. Unlike torchlight, sunlight rays should propagate downward from the top of our voxel world. We should propagate sunlight even if it is night time (I will cover how to do Day/Night cycles below). All air blocks that have a direct line of sight to the sky should have the maximum sunlight level (16). Because we don't want sunlight to conflict with torchlight, sunlight should be stored separately. Luckily, we showed how to do this in part 1 using bitwise operations.

Like with torchlight, we will also need a BFS queue for calculating the basic propagation.

        std::queue <LightNode> sunlightBfsQueue.

After generating a chunk, we need to calculating sunlight. The sunlight generation algorithm for a single chunk goes as follows:

1. Check if the chunk above this chunk is loaded. Call this chunk TOP.

 

        1.1 If TOP is loaded, check the sunlightMap for TOP. For all nonzero sunlight values, add a node to the sunlightBfsQueue.

 

        1.2 If TOP is not loaded, then we need to guess if this chunk is above ground. This process varies based on your architecture. If your terrain is generated from a heightmap, then you can simply check the position of the chunk against the heightmap. If the chunk's location is at or above the heightmap, then we can assume there is sunlight above the chunk. Otherwise, we assume there is no sunlight.

 

            1.2.1 If we assume that there is no sunlight, i.e. we are underground, then we are done.

 

            1.2.2 If we assume that there is sunlight, then we iterate the voxels at the very top of the chunk.

 

                1.2.3 If a voxel is transparent to light, such as glass or air, then we set the sunlight value to the maximum
and add a node to sunlightBfsQueue.

 

After generating a chunk, we may have a list of nodes in our sunklightBfsQueue. We should now do the standard BFS light propagation algorithm as highlighted in part 1, but with an additional constraint. Whenever we are propagating light downward in the -Y direction, we do NOT lower the light level by 1 if the light is currently at the max level (16). This simple constraint will cause light to propagate downard forever until it hits an opaque voxel, but of course it will still propagate sideways normally. I am not going to paste the example code here because it is very redundant. You should be able to figure out how to add the step to the pseudocode in part 1.

Removal

When we place a block in the path of a sunlight ray, we should block the sun ray and darken all voxels below it. Whenever the user places an opaque block in a voxel that has nonzero sunlight, we should remove the sunlight at that voxel. To do this, we simply use the same light removal algorithm as torchlight, but again with an additional constraint.

When the light level of the current node is at the maximum (16), then when checking the voxel below the current node we ALWAYS remove it, even if its light level is also at the maximum. This simple rule will cause an entire ray of sunlight to be removed if an opaque block is placed above it.

Day/Night Cycle

So we know how to set the light level, but how do we handle day/night transitions? This is also really easy! We first need to determine the brightness of sunlight based on the time of day. This can be done simply by checking how high the sun is in the sky. You can use any formula you want, for instance, if we have a vector called sunPosition which is the normalized vector to the sun, we could use:

    float sunlightIntensity = max(sunPosition.y * 0.96f + 0.6f, 0.02f);

Next, we simply use this sunlightIntensity value when calculating the brightness based on the voxel light level. This should be done in your shader program. For instance:

    float lightIntensity = torchlight + sunlight * sunlightIntensity;

    color = fragmentColor * lightIntensity;

And voila! We have a proper day/night cycle!

Colored Light

We know how to propagate torchlight of a single color, but what if we want users to be able to place lights with many different colors? We need to make sure these colors blend together correctly, since the player should be allowed to mix all of our colors. To do this, we are going to need to store more data in the torchlight.

Recall that we store torchlight in an array:

unsigned char lightMap[chunkWidth][chunkWidth][chunkWidth];

// Bits = SSSS TTTT

Where 4 bits are used for torchlight and the other 4 bits are sunlight. To do colored light, we could expand this lightMap so that each voxel gets 2 bytes (16 bits). Then we can store an RGB color channel in this data. 4 bits for red, 4 bits for green, 4 bits for blue, and 4 bits sunlight. Our new lightmap would look like this:

unsigned short lightMap[chunkWidth][chunkWidth][chunkWidth];

// Bits = SSSS RRRR GGGG BBBB

 We will need helper functions like in part 1 that allow us to get and set the different color channels.

    inline int Chunk::getRedLight(int x, int y, int z) {

        return (lightMap[y][z][x] >> 8) & 0xF;

    }

    inline void Chunk::setRedLight(int x, int y, int z, int val) {

        lightMap[y][z][x] = (lightMap[x][y][z] & 0xF0FF) | (val << 8);
    }

    inline int Chunk::getGreenLight(int x, int y, int z) {

        return (lightMap[y][z][x] >> 4) & 0xF;

    }

    inline void Chunk::setGreenLight(int x, int y, int z, int val) {

        lightMap[y][z][x] = (lightMap[x][y][z] & 0xFF0F) | (val << 4);
    }

    inline int Chunk::getBlueLight(int x, int y, int z) {

        return lightMap[y][z][x] & 0xF;

    }

    inline void Chunk::setBlueLight(int x, int y, int z, int val) {

        lightMap[y][z][x] = (lightMap[x][y][z] & 0xFFF0) | (val);
    }

Propagating and Removing Colored Light

Propagating and removing the colored light is as simple as can be. All we need to do is propagate all three channels separately using the same algorithms as in part 1! So instead of doing a single BFS, we do three BFS passes, one for each color channel. This will cause different colored lights to automatically mix together correctly!

A Word on Attenuation

Attenuation is the gradual loss of color as we get further from the light. Unfortunatly, this light color method is not perfect. It sufferes from an attenuation inaccuracy. Since each color channel attenuates independantly from the other, and each decreases at a linear rate of I - 1 per voxel traversed, we will get some strange behavior.

When all nonzero light color channels are equal, such as a magenta torch where (R,G,B) = (16,0,16) we get perfect attenuation. However, if we use a light color where the ratio of nonzero colors is not 1:1 it sort of breaks down. Take orange light for instance, (R,G,B) = (16,8,0). Since the ratio of red to green light is 2:1 we are going to get improper attenuation.

In reality, as a color attenuates the ratio of the color channels should remain equal as the brightness approaches zero. Unfortunatly, since each channel decreases by 1 at each voxel this is not true with our model. Lets pretend we are 8 voxels away from the original orange light source. At this point, our light color is (8,0,0). The green channel is completely gone! This is no longer orange light at all, it is now dark red light.

How big of an issue is this? Well it depends. The attenuation problem only occurs when the ratio of nonzero colors is not 1:1. If we constrain all the torches in our game to a select few colors this issue will never appear. Or, we could allow colors with non-zero ratio anyways as it isn't really that noticable in most cases. If we want to stick to only 1:1 colors, we can use the following 7 colors:

White (16,16,16)

Cyan (0,16,16)

Magenta (16,0,16)

Yellow (16,16,0)

Red (16,0,0)

Green (0,16,0)

Blue (0,0,16)

Color Filters

Because we are storing the RGB color channels, we can also do color filtering! For instance, if we have red stained glass and white light passes through it, it should become red light! Doing this is fairly easy, we just give red stained glass a light filter color value of (R,G,B) = (1.0f,0.0f,0.0f). We also need to add a step in light propagation that checks if the current block has a color filter value. If it does, we simply multiply the color by the color filter. For instance, if we have white light and want to pass it through a red color filter, we will end up with:

            newColor = (16,16,16) * (1.0f,0.0f,0.0f) = (16,0,0)

Gamma Correction

I am not going to go too into detail about gamma correction since there is an excellent GPU Gems post about it. But we definitely want to utilize gamma correction to make sure we correct the nonlinearity of our display. Adding some simple gamma correction to your shaders is easy:

        float gamma = 1.0 / 2.2; // This could be changeable in the options

     color = pow(color, gamma);

Conclusion

By this point you should have a firm grasp of how voxel light works, and how you can implement it in your games. Remember that voxel lighting has flaws, and is not necesarrily the perfect solution for lighting in your voxel game. You may want to use deferred shading instead, or a combination of the two. If you have any questions just let me know and I will be happy to elaborate!

Benjamin - Lead Developer