The Dot Product – For 3D Artists

The Dot Product is a useful little function which is helpful for game artists to understand. In this post I’m going to look at at what the Dot Product is, how it’s used, and more importantly, what it’s useful for.

TL;DR

The Dot Product can be used when working out if two vectors are pointing in the same direction – so lighting or edge glows, or if a point in space is in front or behind something – Backface culling. These two fundamental vector operations are the basis of many shader effects, as well as many physics calculations.

The dot product (or scalar product as it’s also known) is shown mathematically with the dot symbol, represents quite a simple mathematical operation, where a and b are 3D direction vectors;

(axbx + ayby + azbz) = The cosine of the angle of the two vectors (some number)

But rather then writing the above each time, it’s written mathematically as a⋅b, in HLSL as dot(a, b), or perhaps more relevantly to artists, you’ll have a simple dot node.

Broadly in 3D graphics, the dot product is useful for two main things;

  1. Checking the direction of one unit vector relative to another – are they facing the same way?
  2. Projecting a vector onto a unit vector – how far along the unit vector is the other? How far has something travelled in a specific direction?

The Order Doesn’t Matter, it’s “Commutative”

When a mathematical function is “commutative”, it means the order of the inputs does not matter. This means that;
dot(a, b) is the same as dot(b, a)
Not all mathematical functions are Commutative, the Cross (or Vector) Product for example is not. So when using that (which I’ll look at another day), we need to be careful which way around put the values.

Unit Vectors and Vector Normalisation

When discussing the dot product, it’s important to understand the role of vector normalisation and unit vectors. Normalisation is the process of converting a vector to a unit vector – a vector with a length of one, which describes only the direction information of the original vector, not the length.

(1, 0, 0) (0, 1, 0) (0, 0, 1) are unit vectors for each of the 3 cardinal axis, X, Y, and Z.

(1, 1, 1) would not be a unit vector, as it has a length of 1.73.

Vector’s Length (Or Magnitude)

A vector’s length can be found by √(x² + y² + z²).
For (0, 1, 0), the length is √(0² + 1² + 0²) = √1 = 1
For (1, 1, 1), the length is √(1² + 1² + 1²) = √3 ≈ 1.73
But most of the time you’ll have a Length() or Magnitude() function, or node, a nomalize property

Normalisation ensures that the dot product only describes the (cosine of the) angle between the two vectors, not their length. This is particularly important in lighting calculations, direction checks, and other graphical computations where only the direction matters.

Normalisation

Normalisation is done by dividing each component of the vector by the vector’s length.
For example, to normalise the vector (1, 1, 1), you divide each component by its magnitude (√3 or 1.73), resulting in (1/1.73, 1/1.73, 1/1.73), which becomes roughly (0.57, 0.57, 0.57).

1. Direction Checking

The dot product of two unit vectors will result in a -1.0 to 1.0 scalar value.
If both vectors are;

  • Pointing in the same direction, the result will be 1.0
  • Perpendicular (90 degrees) to each other, the result will be 0.0
  • Pointing in opposite directions, the result will be -1.0
Dot product visualised for direction checking

Diffuse Shading

This aspect of the dot product gives us one of the most basic shading methods in computer graphics, and one many 3D artists will be familiar with, Diffuse (Lambertian) shading.

Diffuse Shading

Diffuse shading is the clamped (or saturated) 0.0 - 1.0 value of the dot product between the light direction vector, and the surface normal which is being shaded. This value is then multiplied over the material’s colour to give a final value. In HLSL it’ll look something similar to;

color.rgb *= saturate(dot(lightDir, IN.normal));

Shadergraph example of diffuse shading calculation

Negative values are discarded here with the saturate() function as we don’t want our colour value being scaled by negative lighting terms, 0 is as dark as it can be.

Saturate()

A Saturate() function is basically a Clamp01 – a function which stops a range exceeding 0.0 - 1.0. This can often also be useful if you want to stop colours getting overly bright.

Why it’s called Saturate() is largely based on graphics programming legacy, and just (from what I gather), the ‘done’ way.
It doesn’t do anything fancy, any values outside of this normalized range are binned.

Worth noting that you’d very likely never implement the above in shader graph, or any node-based shader tool as they generally have a pre-written lighting model you’ll already use. Diffuse shading is a pretty old and primitive lighting model.

Half Lambert

Not the same as Diffuse Lighting, but very similar, and very well known, the “Half Lambert” shading model was used by Valve heavily over the years, most notably with Team Fortress 2.

Couldn’t find the original presentation, but slide from which shared by Freya Holmer on Twitter shows clearly what I’m referring to.

The benefit of this lighting model, is we don’t have shading approaching total darkness as fast, and in such an unrealistic manner. This lighting model can appear cartoony, but it in some ways begins to mimic the idea of Sub Surface Scattering, albeit in a very naïve way.

The half lambert is very similar to Diffuse lighting, except instead of clamping the dot product value, we remap it into a 0.0 - 1.0 range instead. This means that the shading can still go to pure black, but only on faces pointing directly away from the light source.

color.rgb *= dot(lightDir, IN.normal) * 0.5 + 0.5;

Remapping

Whilst there is a Remap node in Shadergraph, and it’s simple enough to introduce in HLSL.
Remapping from -1.0 - 1.0 to 0.0 - 1.0 can be achieved by multiplying by 0.5 then adding 0.5.
It’s super common to want to remap many value ranges into 0.0 - 1.0, in this case this is also known as normalisation.
Think of your normalised range as a percentage value, it’s a very common and useful way do all kinds of shader math.

Half Lambert in Shadergraph

Edge Glow (Fresnel Approximation)

Image of an emissive glowing edge, based on view direction (rotated here just to make it clearer in the example)

The same principle applies as diffuse shading, except we’re using the view direction rather then the light direction, and we’ll apply this value to our final pixel colour differently.

float vDotN = dot(viewDir, In.normal);
float edge = 1-pow(saturate(vDotN), _glowPower);
float3 finalGlow = edge * _glowColor;

// Assuming a surface shader with an emission channel
surface.emission = finalGlow;

One Minus

There’s a sneaky little 1- in here, also known as the “One Minus” in more English-speaking regions. When you put 1- before a 0.0 - 1.0 value (or a normalised range), it effectively inverts the value. So what was previously 0.0 becomes 1.0, and vice versa. This only works like this with normalised ranges, and is a helpful tool to reverse or flip value ranges.
This is not to be confused by multiplying something by -1 which negates the value instead. So 0.5 would become -0.5, and -1.0 would then become 1.0.

In the above example, the result of the dot product when multiplying the colour, would give you the “centre” and not the “edge”, so we One Minus 1- the result of the Pow() function which gives us the edge we want.

The shader graph version of the edge glow.

And it looks like this, note the power lets us expand or contract the glow.

Power changing with sine-time to show the growing and contracting of the edge glow

Raise to the Pow()

The pow() function or “raise to the power” is where you multiply an input value, by itself a number of times.
A common example is x2 or squaring something. x2 would mean x * x
x3, cubing something would be x * x * x.
Well the power is xy, x multiplied by itself, and repeat y number of times.

Front or Behind Check

Another application of the dot product in this manner is to determine if something is in front, or behind a surface. To do this;

  • Take an object’s position, subtract that from the vertex position and normalize it (to make it a unit vector).
  • Dot this with the vertex normal
  • If the resulting value is positive, the object is in front of the vertex’s face, if it’s negative it’s behind. If it’s 0 then it’s exactly on the surface.

One of the most common uses of this is to help with back-face culling, which might look something like;

float faceDot = dot(normalize(vertex.position - camera.position), vertex.normal);
bool isBackFacing = faceDot < 0;
Once again, in Shadergraph. Note that Shadergraph has a Is Front Face node as well, although it’s worth reading the documentation if you plan to use it.

The above example would appear in some form in a rendering pipeline, although bear in mind it might be heavily simplified. The Shadergraph version won’t actually work in most cases, as the Unity’s renderer has already culled back faces and thus the pixel shader won’t be invoked.

2. Distance Checking

This is marginally more complicated, but also pretty useful application. It’s main use is to check how far a point in space is, along a specified direction. The math of this is the same as above, except only one of your vectors would be unit vectors.

Distance From Plane

This could be used for finding how far away a vertex of a mesh is from an arbitrary plane in space is. In more practical terms, if you have a “scanning” effect on an object, and you want it’s shader to glow where the scanning progress, you’d use this.

uniform float3 _planeNomal;
uniform float3 _planePos;
uniform float _glowDistance;
...
float3 delta = _planePos - IN.pos;
float distance = abs(dot(_planeNormal, delta));
float glow = 1-saruate(distance / _glowDistance);

This is a little more of a complex example, but hopefully the animated Shadergraph example below should help out. We use our dot product as above, but then introduce a new function, abs() or Absolute.

abs()

Absolute is a simple and handy function. It removes the - minus (or sign) of a number, effectively saying “I don’t care if it’s positive or negative, only how big it is”.

Out dot product can be positive (vertex position is in front of the plane), or negative (behind the plane). In this example, we don’t mind if it’s ahead or behind, only how near it is. This is what the abs() function tells us. We then divide the result of the abs() function by _glowDistance which turns this distance into a normalised range, a 0.0 - 1.0 value. The glow distance allows us to change how thick our glow line will be.

However, if our distance is greater then our _glowDistance, then our value will be outside of a normalised range, so we then saturate() it to keep it within our desired values. Once our distance is further then this, we don’t want to know anyway as this area of our mesh would not be glowing.

The final step is to one minus the value, so the area where we want our glow is white, not black as it was, and thus ready to be masked as desired.

Scanning “plane” example in Shadergraph

This dot product is also used in the point on plane calculation as seen in the fishbowl water, but I went into this in more specifics in the post, so I strongly suggest a read for a real world example.

The point on plane tests from I Am Fish

To Wrap Up

These are a few of the more common applications of the dot product, there are more ways to use it, but this should give a good practical overview with some examples of how any why it is useful. With this tool under our belt, we can begin to move to more complex concepts to build more interesting and dynamic effects.

Next time in my new “Math for Artists” series we’ll take a look at the Cross Product, the Dot Product's older, hairier sibling who’s old enough to by alcohol and stay out past midnight. We’ll see how that relates to this, and what more complex effects we can achieve.