Imitating the Canvas Engine (10): Light Direction Transformations

Previous Post:
Imitating the Canvas Engine (9): Transforming the Shadow Map to Screen Space - Memories of Melon Pan

The shadow bleed effect makes use of some simple blurs, but before we get into them, let's start thinking about what we want our shadows to look like.

Valkyria Chronicles doesn't do this, but I wanted the pencil lines to go in the direction of the light. Also, the steeper the angle the light made on the scene, the longer the shadow bleed I wanted - the shadows should bleed more if the light comes directly from the right or left, than if the light comes in at a shallow angle into the scene.

This part involves a small amount of math, but it's not bad once you figure it out. First, let's look at our shadowing texture. I also call it a pencil scratch texture, or a shadow fill texture.

I'm reusing this texture from the XNA Nonphotorealistic Rendering tutorial. The real texture is actually a lot more involved, but I just reduced it to what part of it I'm using - the blue channel. This channel has streaks going left and right, as opposed to the others which both streak diagonally.

We use this texture as a mask, multiplying the scene color by the inverse blue value (that's 1 - Blue) in order to get some dark streaks that look like pencil lines. The fact that this channel only has streaks going horizontally makes our math easier. All we have to do is rotate this to the direction of the light relative to the screen.

If we think about the screen, it's kinda like the near plane of the camera's viewing frustum. We can start by projecting the light's direction vector onto that normal plane.

As it turns out, the rejection of the light vector from the near plane's normal is parallel to the planar projection vector that we're looking for.

In simpler terms, we can project the light vector onto the near plane's normal vector, which is equal to the camera's direction. Once we have this normal vector projection, we can subtract it from the original light vector to get the rejection. While technically we're not calculating the planar projection, the rejection is going to point in the same direction. Since they're both vectors, that means they're equal.

The only thing is that the view frustum, and its near plane, might face in some arbitrary direction in the scene. That projected vector we just calculated might point straight up, but if our camera's tilted a little bit or if it isn't facing straight, then we can't say that projected vector is relative to the screen.

Buuuut... for those of you who know what billboarding is, we can apply the same sort of calculation to get our projected vector into screen space. For those that don't - billboarding is a rendering technique where you can have an object always face the camera, no matter where it is in the scene. To pull it off, you multiply the object's world-view-projection matrix by the transpose of the camera's view matrix. It makes intuitive sense if you think about looking at the near plane from the origin. If you paint the projected light vector on the near plane and force it to face us, we'll get the direction of the light relative to the screen.

So, let's put that all together.

Pn = dot(vl, vn) * n
Pp = vl - Pn


Ps = Pp * Vt


For the variables:

  • vl, the light direction vector
  • vn, the near plane's normal vector, or the camera direction
  • Pn, the projection of the light vector onto the normal vector
  • Pp, the planar projection vector
  • Ps, the screen projection vector
  • Vt, the camera's view matrix, transposed



Where dot(v1, v2) is the dot product between two vectors v1 and v2.

Notice we're not normalizing Pp or Ps - we're using the length of these vectors to determine how much shadow bleed there should be.

Now that we have the direction the light is going relative to the screen, we know which direction to bleed shadows towards. Related to this is how much to rotate the shadow fill texture, since we want it to streak in that direction. This is just a 2D rotation matrix based on the vector we just calculated. It's going to have a z component to it, but since it's a vector in screen space coordinates, we can safely ignore it exists.

vr = normalize([Ps.x, Ps.y])
R = [vr.x, vr.y, -vr.y, vr.x]


For the variables:

  • vr, a vector used for rotation calculations
  • R, a 2x2 rotation matrix as a row-major stream



Where normalize(v) converts a vector v to its normalized form.

The rotation matrix R is expressed as a one-dimensional array so that we can slip it into HLSL easier. The first two indices are the first row, and the last two are the second row. For those of you who don't know what a 2D rotation matrix looks like, you can construct one by hand using sines and cosines as long as you know the rotation angle.

The only catch is that since our screen space coordinates start at the top-left of the screen (and not the bottom-left), we have to logically flip our angles around - up becomes down and down becomes up. We can just negate the sines in the rotation matrix to do this. Thus, the first row of the rotation matrix is [vr.x, vr.y], and not [vr.x, -vr.y]. Ditto for the second row.

What we're doing here takes our screen projection vector Ps, puts its x and y components into a 2D vector, then normalizes it. If you do this, you'll get the values for both the cosine and sine of the angle that 2D vector makes with the vector [1.0, 0.0]. Just take a look.

That's the classic unit circle! Since our shadow fill texture runs left to right, the vector [1.0, 0.0] can be used to represent the direction it wants to run without any rotation applied to it. This is the exact same vector that the unit circle works off of to get its own values... which is why the normalized [Ps.x, Ps.y] vector gets us the cosine and sine of our rotation angle. We can just take those values and slip them into our 2D rotation matrix like they are.

The last remaining bit of preparation before we start blurring is to figure out how much shadows should bleed off the model. The calculation is similar, though we're not normalizing [Ps.x, Ps.y] for it.

vd = [Ps.x, Ps.y]
D = length(vd) * w


For the variables:

  • vd, the displacement vector
  • D, the displacement distance for our directional blur
  • w, an arbitrary weight



Where length(v) is the length of a vector v.

If Ps.x and Ps.y are both small, we know that the light makes very shallow contact with the scene, and we shouldn't bleed much. When they get large, the light makes a very steep angle with the scene, and there should be lots of bleed. Pretty simple stuff.

Having said all of this, this really only works if you have a single light for the scene. If you have multiple lights that can cast shadows in your scene, it's much easier to forget all this and have your streaks go in some predetermined diagonal direction and bleed some predetermined amount like Valkyria Chronicles does.

Next Post:
Imitating the Canvas Engine (11): Directional and Gaussian Blur - Memories of Melon Pan