Digging Deep: Understanding Gilles Tran's MakeSnow Macro

by Mike Kost


Povray features a powerful scripting language that can be used to automate complex tasks. One of the best ways to understand a scripting language is to disect a script someone else has written. Due to the time of year and all the snow that's falling this winter, this Digging Deep will focus on understanding Gilles Tran's MakeSnow macro.

Quick Reading

Gilles Tran's MakeSnow webpage has a description, examples, and the MakeSnow.inc macro itself.

Also, several Povray functions (both built in and include file based) are going to be used and discussed
If you're not up on vector math, it'll be worth while brushing up with a few topics from the Wikipedia

The MakeSnow Macro

The MakeSnow macro creates a snow blob that overlays on an object and look like a layer of snow. The MakeSnow macro takes 6 parameters:
Now, lets get into the good stuff.

The Setup

If you havn't already, read through makesnow.inc to get a feel for what's coming.

To start with, lets walk through the macro's initial setup.
 0: #include "transforms.inc"
 1: #macro MakeSnow(Obj,Particles,Size,Thickness,MinHeight,MaxHeight,Direction)
 2:     #ifndef (T_Snow)
 3:         #local T_Snow=texture{
 4:             pigment{rgb 1}
 5:              finish{ambient 0 diffuse 1}
 6:          }
 7:     #end
 8:     #ifndef (rd)
 9:         #local rd=seed(2003);
10:     #end
11:     #local Obj2=object{Obj Reorient_Trans(-y,<-Direction.x,Direction.y,-Direction.z>)}
12:     #local Min=min_extent(Obj2);
13:     #local Max=max_extent(Obj2);

After an include (line 0) and the macro declaration (line 1), the macro proceeds to establish a couple user overridable variables it'll need later on. In lines 2 - 7, the T_Snow texture is created if it's not already defined before the macro was invoked. By using this method, it allows the user to override the snow texture by declaring it before invoking MakeSnow. In the same style, a random number seed, rd, is declared in lines 8 - 10. The user could override the declaration if they wanted a different random numbers to vary the snow distribution in their rendering.

Line 11 is the first complex function performed in the macro. The Direction vector that's passed into the MakeSnow macro defines the direction the snow is falling. What happens here is that another object, Obj2, is created that's a rotated version of the original object, Obj, that the macro's being used on. The result is that snow falling along the vector Direction at Obj will look the same as snow falling along the vector -y at Obj2. Believe it or not, this will make the math easier later.

To so what happens when you use the Reorient_Trans function, look at the two examples below. The first image is a cone pointing straight up toward the camera. The second image uses the Reorient_Trans function to change the direction the cone is pointing.

Regular cone pointing toward camera

Reoriented cone
Camera = <0, 5, 0>
No reorientation

Camera = <0, 5, 0>

Finally, we determine the space the object is in. This is done using the min_extent and max_extent functions. These functions essentially return the bounding box for Obj2. If we were to construct the box, box { Min, Max }, it would completely surround Obj2 with no bits sticking out. Also, Max.x - Min.x would tell me how wide Obj2 was in the X direction.

In the example below, the semi-transparent red square shows the bounding box around the cone created with the min_extent and max_extent function

Cone with bounding box

The Blob

This is where all the snow particles are blobbed together.
14:     blob{
15:         threshold 0.6
16:         #local i=0; // first layer
17:         #debug "first layer of snow\n"
18:         #while (i<Particles/3)
                -- Inner Loop Happens Here --
31:         #end
32:         #local  i=0;
33:         #debug "second layer of snow\n"
34:         #while (i<Particles*2/3) // second layer of smaller particles
                -- Inner Loop Happens Here --
47:         #end

This code declares the blob object and starts iterating on two inner loops. The first inner loop places 1/3 of the particles. The second inner loop places 2/3 of the particles, but these are 1/4 the size of the first loop. Debug messages are sent out to update how far things have progressed.

The Inner Loop

Below is the inner loop. This is a good hunk of script that places individual snow globs onto the object. All the interesting stuff inside the setup section will get used here.
19:     #local Start=<Min.x+rand(rd)*(Max.x-Min.x),(MinHeight+rand(rd)*(MaxHeight-MinHeight))*Max.y,Min.z+rand(rd)*(Max.z-Min.z)>;
20:     #local Norm=<0,0,0>;
21:     #local Inter= trace (Obj2, Start, -y, Norm );
22:     #if (vlength(Norm)!=0)
23:         #local Angle=pow((180-abs(degrees(acos(min(1,vdot(vnormalize(Norm),y))))))/180,2);
24:         #local FlakeSize=Angle*<1+rand(rd)*1.3,(Angle*0.5+rand(rd))*Thickness,1+rand(rd)*1.3>;
25:         sphere{0,Size*2,1 scale FlakeSize Reorient_Trans(y,Norm) translate Inter}
26:         #if (mod(i,1000)=0)
27:             #debug concat(str(i,0,0),"\n")
28:         #end
29:         #local i=i+1;
30:     #end

The inner loop is where most of the hard work is done. To get a handle on this, we'll start with a high level walk-through:
  1. Find the starting place for the snowflake (Line 19)
  2. Drop the snowflake toward the object (Line 21)
  3. If the snowflake hits the object, do the following (Line 22)
Now lets get into some detail.

The first task is to pick the starting point for the snowflake. To simplify the discussion, lets unravel Line 19 some.
#local startX = Min.x + rand(rd) * (Max.x - Min.x)
#local startZ = Min.z + rand(rd) * (Max.z - Min.z)
#local startY = (MinHeight + rand(rd) * (MaxHeight-MinHeight)) * Max.y
#local Start = <startX, startY, startZ>;

Because we're dropping the snowflake straight down (remember, we used Reorient_Trans to do this), our X & Z starting points are easy to find out. We start from the most negative point and add a random amount up to the width of the object [1]. The Y starting point is a little more difficult. To let snow appear on lower branches of a tree, for example, you can't always start above the object because the higher branches will prevent the snowflakes from getting that far down. To get around this, the MinHeight and MaxHeight values were added. If both MinHeight and MaxHeight are greater than 1, you'll always start above the highest point in the object. However, as MinHeight approaches 0, the starting height will range from MinHeight * Max.y to MaxHeight * Max.y. This gives flexibility to the artistic effect. It should be noted that this algorithm assumes that Min.y = 0 [2].

After all this, we assign our starting point, Start, and drop the snowflake at our object (lines 20 - 21). This is done with the trace() function. The trace function takes an object, a starting point, and a direction. It shoots a ray from that starting point in the specified direction at the object and returns the first place it would hit and the surface normal at the hit point. The point of impact is stored in Inter and the surface normal is stored in Norm in this case. It is good form to preinitialize normal storage with <0,0,0> before calling trace (line 20).

The trace() function may not hit anything. The only reliable way to verify a hit is to check whether the normal, Norm, is <0,0,0>. If it is, trace() missed the object. The vlength() function returns the length of a vector, and vlength(<0,0,0>) = 0, making it an easy test. Once we have a snowflake that has hit the object, we need to put a blob there. But how big a snowflake do we want?

Gilles Tran goes through some complicated mathematics to determine the snowflake size. Again, lets unravel all the math in lines 23 and 24:
#local strikeDot = vdot(normalize(Norm), y);
#local strikeAngleRad = acos(min(1, strikeDot));
#local strikeAngleDeg = abs(degrees(strikeAngleRad));
#local strike = (180 - strikeAngleDeg)/180;
#local Angle = pow(strike, 2);
#local FlakeSizeX = 1 + rand(rd) * 1.3;
#local FlakeSizeY = (Angle * 0.5 + rand(rd)) * Thickness;
#local FlakeSizeZ = 1 + rand(rd) * 1.3
#local FlakeSize = Angle * <FlakeSizeX, FlakeSizeY, FlakeSizeZ>

The math to get the value Angle is determining whether it was a direct hit or a glancing blow [3]. If the snowflake hits the surface direct on, Angle will be 1. If the snowflake hits with more of a glancing blow, then Angle will be close to 0. From this, we alter the size of the snowflakes - bigger for direct hits, smaller for glancing blows.

Next, lets place the blob sphere. Line 25 shows this happening. The blob sphere is reoriented using Reorient_Trans() to point the Y axis along the surface normal. This results in the snowflake thickness looking like it's growing away from the object. Next, the translate command places the snowflake at the intersection point, Inter.

It should be noted that the first inner loop and the second inner loop differ in how the snowflakes scale. The second inner loop makes the snowflakes 1/4 the size of the first inner loop as shown below
25:    sphere{0,Size*2,1 scale FlakeSize Reorient_Trans(y,Norm) translate Inter}

41:    sphere{0,Size*2,1 scale FlakeSize*0.25 Reorient_Trans(y,Norm) translate Inter}

And finally, some house keeping. Line 26 - 28 output a debug message to keep tabs on the MakeSnow progress. Line 29 increments the variable i that's tracking how many snow flakes have hit the object.

Closing Up

This final bit of code finishes off the macro
48:         texture{T_Snow}
49:         Reorient_Trans(<-Direction.x,Direction.y,-Direction.z>,-y)
50:     }
51: #end   

The texture (line 48) colors the blob that's just been created and the Reorient_Trans function (line 49) aligns the blob with the original object. Lines 50 and 51 close out the blob and the macro.

The Result

All of this combines to produced a script that's able to snow cover anything with a layer of snow

Sphere with no snow

Sphere with snow

Notes And Disclaimers

[1] Lets take the startX range for an example. rand(rd) is guaranteed to be 0 - 1. If rand() returns 0, startX = Min.x + 0 * (Max.x - Min.x) = Min.x. If rand() returns 1, startX = Min.x + Max.x - Min.x = Max.x. If 0 < rand() < 1, startX will end up between Min.x and Max.x. Thus, the starting point is inside the bounding box above the object.
Since MinHeight and MaxHeight are supposed to be >= 0 (per the description inside the MakeSnow.inc file), there could be problems if Obj2 extends below y=0. This is because the startY point is not a general solution. The general solution would be startY = Min.y + (MinHeight + rand(rd) * (MaxHeight-MinHeight) ) * (Max.y - Min.y). If you take Min.y = 0, the general solution reduces down to the startY derivation inside the MakeSnow macro
The full derivation of this glancing blow is a bit complicated. strikeDot is the dot product between the surface's normal and the incoming snowflake's direction. If they're perpendicular, the dot product is zero. This is a glancing blow. If they're parallel, the dot product is 1. This is a direct hit. It turns out that the dot product is defined as vdot(vect1, vect2) = vlength(vect1) * vlength(vect2) * cos(theta). Since Norm is normalized and y has a vlength of 1, vdot(y, Norm) = cos(theta). The next 2 lines convert strikeDot into a usable angle (in degrees) strikeAngleDeg by using the inverse cosine function. Because the acos function is being used, a direct hit returns 0 and a glancing blow returns 180. strike = (180 - strikeAngleDeg) / 180 reverses the relationship and scales it from 180 - 0 to 1.0 - 0.

Last edited: 2/5/2005

Copyright (C) 2005 Mike Kost