About the project

In this project I continued working on the lightmap setup I had started in the AC130 project. It got a whole lot more polished, divided into nice material functions, and got a nice interface. I try to make sure the systems I create can be used by anyone, by creating logical setups with good preset values. I want the setup to look as neat as possible, so that anyone could theoretically use my setups, and not feel lost or confused when looking at it.

With that out of the way, what is this lightmap setup? Well, it is entirely based on Mederic Chasse’s post about smoke lighting in Skull & Bones. It uses 4-6 grayscale smoke textures that are lit from different directions, and therefore cast different shadows. By transforming the light vector to tangent space, it is possible to use the Pythagorean Theorem to interpolate between the textures, and by extension, approximate the lighting in any direction. I will break down the entire setup, from texture creation in Houdini, to the final effect in Unreal.

This is a section of the final spritesheets, where one contain the lightmaps, and one contain the opacity and a gradient texture used for mapping colors. They are used in this simple particle emitter, where I tried to emulate Mederic’s result as closely as possible.


Creating the textures in Houdini

The setup for creating the textures in Houdini is fairly simple. It starts out by parenting four* distant lights to the camera to the light I render from. These lights are disabled in the viewport, because they don’t provide any valuable information. I also added a sky light to give me some lighting for the gradient texture. Then, under the extra image planes section in my Mantra node, I make sure to add two extra planes, one with the direct lighting that should be exporter per light, and one with the combined emission. These extra image planes are essentially extra textures that are stored inside the rendered exr image, which allows me to render all of the necessary textures at once; saving a lot of render time.

* I have a fifth for the back lighting, mostly for comparison purposes, but it could be added to a spare channel.


With the image sequence rendered, I can move on to compositing. The most important nodes below are CreateMergedLightmap and CreateMergedColor. These are Channel Copy nodes, that allows me to change what data is in what channel. This node has access to the image planes I made sure to render above, which allows me to easily choose what light texture to place in what channel. Then I use a Mosaic node to create the final sprite sheet.


Implementation in Unreal

I used a lot of material functions to keep the setup clean and reusable. The entire material looks like this, has support for two lights. It uses curves and a curve atlases to color the smoke using the gradient map.


The most important function is this one, which is essentially just wrapped in some other functions for the sake of making it easier to use. I added separate functions for using the technique with four and five pre-rendered lightmaps, where the one using four calculates both the front- and the back side, whereas the one using five only calculates the front.


Adding a point light

Implementing support for multiple lights is straight forward. The primary light’s values were accessed using the atmospheric light nodes, but every other light are passed to the shader through a material parameter collection. I chose to just support one, but it can easily be expanded. For a point light, the calculations are slightly different than for a directional light, and needs one more function, for the light attenuation. The light direction is calculated by subtracting the light position with the world position, normalizing and transforming it to tangent space. Then it can be used the same way as a directional light. The main difference is that the output of the lightmap function needs to be multiplied by the attenuation of the light, which could be described by the following function, which is based on Unreal’s attenuation formula. After that, all that has to be done is to multiply the result by the light’s color, and add it after the other light calculations.

float Attenuation(float distance, float range)
    float normalizedDistance = (distance / range);
    return pow(saturate(1 - pow(normalizedDistance, 4)), 2) / (pow(distance, 2) + 1);

Implementation in HLSL in our Custom Engine

This technique was surprisingly easy to implement in our engine. The only obstacle, a small one, was how to get the light vector into the tangent space of a particle, since they don’t have any tangents or bitangents. Fortunately, camera facing particles are very predictible, in the way that they are always aligned to the camera… Therefore, it is possible to use the camera’s up, right and forward vectors to construct the tangent space matrix. Doing this is very naive however, since it doesn’t account for the particle’s rotation, leading to very strange results. Instead, I also pass the particle rotation, and construct a rotation matrix to rotate the tangent space matrix accordingly.

// Create a matrix based on the cameras axis, since the particle will always be looking at it.
float3x3 tangentSpaceMatrix;
tangentSpaceMatrix[0] = CameraRight.xyz;
tangentSpaceMatrix[1] = -CameraUp.xyz;
tangentSpaceMatrix[2] = -CameraForward.xyz;

// Need to account for the particle's rotation
float s = sin(particleRotation);
float c = cos(particleRotation);
float3x3 rotation;
rotation[0] = float3(c, -s, 0);
rotation[1] = float3(s,  c, 0);
rotation[2] = float3(0,  0, 1);

tangentSpaceMatrix = mul(rotation, tangentSpaceMatrix);

Final thoughts

I think this technique was really fun to work with. The result looks really nice and convincing, even though I don’t think I have fully grasped the entire implementation that Mederic described, at least not in the Unreal implementation. Right now, my solution is unlit, so it will never behave quite correctly, since it won’t be shadowed by objects. In our own engine, I have access to the shadows and lighting, so I know I can make it work correctly there.