9Function calls as arguments

MDL employs two different programming language paradigms, each appropriate for the type of information it needs to describe:

This chapter explores the possibilities in MDL for the procedural definition of material struct parameters that are provided by the imperative paradigm. For more information on specific language features and for additional examples of MDL syntax in function definition, see the MDL language specification: NVIDIA Material Definition Language (version 1.6.2, August 5, 2020).

The general syntax of user-defined functions will be familiar to programmers using any of the wide variety of languages that have been influenced by the C and C++ languages. Readers familiar with C-style syntax may consider skipping to the next section; control structures like for and if are identical in form to C. However, there are important limitations to MDL with regard to standard C programming techniques, as well as useful extensions. This section will briefly present the language features required to understand the example functions developed in the following sections. Where there are important differences between MDL's language for function definition and C/C++, these will be noted in the examples in this and later chapters.

The previous chapters have already presented the high-level data types of MDL that are specific to the domain of appearance definition: distribution function objects (bsdf, edf, vdf) and the various struct types for material definition (for example, material and material_surface). The struct is a compound type; it combines in one datum a set of other data values. In user-defined functions, the simple (non-compound) types are frequently used: int (integer), float and double (approximations of the mathematical concept of real numbers), and bool (the Boolean values of true and false). The color type is also a primitive type in MDL and will often be the value that is produced by a function. A few programming elements were introduced in “The struct type”. This section provides a more general description of the fundamentals required for writing MDL functions.

A name that refers to a value is called a variable. To use a variable in an MDL function, the variable must first be declared, which associates the variable's name with a data type. The data that the variable represents is called the variable's value. If a value is specified at the time the variable is declared, this specification initializes the variable.

To declare a variable and give it a value, the constructor for that data type is used to create the value. The constructor syntax specifies the initial value or values for the variable as required by the variable's data type. The full form of a variable declaration uses the equal sign to associate the name with its initial value:

data-type variable-name = data-type ( arguments );

For example, the following line declares a variable of type float named total and initializes the variable to 0.0:

float total = float(0.0);

For simple (non-compound) types, explicitly specifying the constructor is unnecessary:

float total = 0.0;

An abbreviated form removes the redundant use of the type name:

float total(0.0);

This abbreviated form is most useful for compound types:

color blue(0.0, 0.0, 1.0);

A variable must have a value; if it is not initialized when it is declared, the default value for the data type is used as the initial value. Default values are typically a value that can be thought of as “zero” for the type; numeric zero for int, float and double, or “black” for the color type (color(0.0, 0.0, 0.0), also abbreviated as color(0.0)). This idea of a “zero equivalent” for the default value is also used by the components of the material struct; if no emission value is defined for the material_surface struct, then no light is emitted.

The equals sign (=) is used in variable declaration in a different sense here than in mathematics; total is not equal to 0.0, total currently has a value of 0.0, a value that can be changed later in the function. However, in talking about variables, it would be typical to say something like, “at the beginning, the total is zero,” with the variable name representing the concept of “total” throughout the function.

Variable declarations are also provided by the let-expression, as described in “Simplifying a material's structure with temporary variables”.

The declaration of variables is one example of a statement in MDL; it is the simplest unit of a user-defined function. Other statements define when and how many times a set of statements should be executed. The syntax of the looping and conditional statements—control flow – will be very familiar to C/C++ programmers.

The most flexible loop structure is the for statement:

for ( variable-declaration ; termination-condition ; end-of-loop-action ) {
    statements
}

The curly braces that surround the statements of the for loop create a block. Wherever a single statement can appear, a sequence of statements can be provided instead by surrounding the sequence with curly braces.

For example, this code fragment will sum up all the integers from 1 to 42:

int sum = 0;
for (int i = 1; i < 43; i = i + 1) {
    sum = sum + i;
}

This can be read as, “Start with i equal to 0, and while i is less than 43, add i to sum and add 1 to i.”

In this case, the curly braces were not strictly necessary because only a single statement was executed by the loop. The for loop could have therefore been written without the braces:

int sum = 0;
for (int i = 1; i < 43; i = i + 1)
    sum = sum + i;

Because they occur so frequently in structures like loops, MDL also provides C's set of increment and decrement abbreviations, for example:

Abbreviation Equivalent to
++i i = i + 1
--i i = i - 1
i += 10 i = i + 10
i -= 10 i = i - 10

The previous example can be rewritten using the increment abbreviations:

int sum = 0;
for (int i = 1; i < 43; ++i)
    sum += i;

The if statement is the other important control flow structure and enables conditional execution of sequences of one or more statements. The if statement has two forms. In the first form, a Boolean expression is evaluated—the condition—to determine whether it is true or false. If the statement is true, the next statement (or block) is executed. If not, no statements are executed.

if ( Boolean-expression ) {
    statements-if-expression-is-true...
}

In the second form of the if statement, a second statement/block is preceded by else. This second statement/block is executed only if the Boolean expression is false.

if ( Boolean-expression ) {
    statements-if-expression-is-true...
} else {
    statements-if-expression-is-false...
}

MDL also provides a conditional expression that uses the characters “?” and “:”.

Boolean-expression ? value-if-expression-is-true : value-if-expression-is-false

This example initializes color variable height_color to be white if the y coordinate of the current position is greater than 0.0 and black if it is not.

float3 position(0.0);
...
color height_color = position.y > 0.0 ? color(1) : color(0);

The “...” in the example would include the calculation of the current point using MDL's renderer state functions, which are described in the next section.

Several of the user-defined functions developed later in this chapter depend upon the rendering state, data used by the rendering system in calculations that depend upon attributes of the geometric structures that are being rendered. These values of the rendering state are available through functions that are not part of the MDL language, but are defined in a separate software component called a module. The module for renderer state functions, called the state module, is one of a set of standard modules that are defined by the MDL specification. In addition to the state module, the examples in this chapter will also use the math module, which provides a set of mathematical functions (for minimum, maximum, cosine, sine, etc.).

The current point in space that is being rendered—the point on a surface or in a volume for which the material struct is being evaluated—is acquired by using the position state function. The module name state is used as a prefix to the function name and is separated from it by two colon characters (::).

float3 pos = state::position();

The float3 type defines the x, y, and z coordinates of the point in 3D space. The float3 value returned by a call to state::position is a point in internal space, an implementation-dependent coordinate system. Two other spaces are defined by MDL. Object space is the coordinate system of the object being rendered, a local coordinate system for the geometric structures of the object. World space defines the global coordinate system within which all objects are positioned.

The state function transform_point can be used to convert the space in which a point is defined to one of the other two spaces.

state::transform_point(original-space, target-space, point-to-transform)

To specify the coordinate spaces in the arguments of state::transform_point, MDL defines the coordinate_space enum type in the state module. The type definition contains the names that can be used as arguments to transform_point:

enum coordinate_space {
  coordinate_internal,
  coordinate_object,
  coordinate_world
};

Combining these elements of the state module, a float3 variable that provides the current position in object space can be declared and initialized as follows:

float3 object_position = state::transform_point(
    state::coordinate_internal,
    state::coordinate_object,
    state::position());

Note that the state names are prefixed by the state module name when they are used in a user-defined functions. An enum type is also used to define the scatter mode for BSDFs in “Specular interaction at a surface”, but are defined in the df (distribution function) module and are therefore prefixed with the df:: module name.

Another type of “space” is texture coordinate space, which defines a coordinate system for the surface of a polygon in a modeling system. Associating a point on the surface of an object with the value of a function or a color in an image are examples of the traditional computer graphics technique called texture mapping. By convention, the two-dimensional texture coordinate axes are called u and v (rather than x and y). MDL extends the texture coordinates to three dimensions, where the coordinates are called u, v, and w.

MDL supports texture mapping through the state function texture_coordinate. Several examples in this chapter will use the result of texture_coordinate in defining the surface color as part of the definition of a material.

These various components of MDL—variable declarations, control flow, functions provided by the standard MDL modules—can be combined in user-defined functions, described in the next section.

To augment the set of functions defined by the MDL standard modules, the author of a material can define custom functions to be used by the arguments of the material struct. A function consists of one or more statements (the function's body) that calculate a value that is considered to be the value of the function. The value that the function calculates is said to be “returned” from the function when the function is executed. Executing a function is also described as calling the function.

type-of-return-value function-name ( parameters ) {
    statements
}

One of the statements in the function defines the value returned by the function using the word return.

return expression ;

For example, the following function returns the color black if the y position of the object point is equal or below a value (defined by an argument), but returns white if the point is above that value:

Listing 9.1
color white_above_value(float y_value = 0.0) {
    float3 object_position = state::transform_point(
        state::coordinate_internal,
        state::coordinate_object,
        state::position());
    
color result;
Declare the variable "result" to be of type "color"
    
if (object_position.y > y_value)
    result = color(1);
else
    result = color(0);
Calculate the value of "result"
    
return result;
Provide "result" as the value of the function
}

A function may contain more than one return statement, but the function terminates as soon as the first return statement is executed. Given this property of the return statement, the previous function could be rewritten as:

Listing 9.2
color white_above_value(float y_value = 0.0) {
    float3 object_position = state::transform_point(
        state::coordinate_internal,
        state::coordinate_object,
        state::position());
    
if (object_position.y > y_value)
    return color(1);
else
    return color(0);
Alternate "return" statements
}

More than one return statement is considered by many programmers as an example of bad style. With multiple return statements, the value returned by the function is not immediately apparent, especially in larger functions. However, many programmers also believe that large functions are also an example of bad style, so that functions should be small enough that their behavior can be understood and easily debugged.

Like material constructors, the parameters of a user-defined function can be assigned default values. For example, calling function white_above_value, the default value for parameter y_value is 0.0.

white_above_value()   is equivalent to   white_above_value(0.0)

The float3 type of object_position is a compound type. As used in the preceding white_above_value function, it represents a three-dimensional point. The three components of the float3 type represent the x, y, and z coordinates of the point. To use one of these three values in an expression, the dot operator (.) is used. In this case, the y coordinate is specified with object_position.y.

Using the variable result to define the function's return value is not strictly necessary in this case. Using a conditional expression allows the function to be more compactly written.

Listing 9.3
color white_above_value(float y_value = 0.0) {
    float3 object_position = state::transform_point(
        state::coordinate_internal,
        state::coordinate_object,
        state::position());
    return
       
object_position.y > y_value
   ? color(1.0)
   : color(0.0);
Conditional expression
}

Note that an MDL compiler will typically perform optimizations to produce more efficient code for execution by the rendering system.

As the first example of using standard and user-defined functions, this section will treat a surface property of an object as a color. The state function texture_coordinate returns a float3 value that identifies a point in the texture coordinate space. By using a float3 value in the color type constructor, the u, v and w values are converted to values of red, green, and blue.

Listing 9.4
material uv_as_color_material() =
let {
   
color diffuse_color =
   color(state::texture_coordinate(0));
Calling the state function texture_coordinate
   bsdf diffuse_bsdf = df::diffuse_reflection_bsdf(
      tint: diffuse_color);
} in material(
   surface: material_surface(
      scattering: diffuse_bsdf));

Typically, the values of an object's texture coordinates are in the range of 0.0 to 1.0, producing colors that are combinations of red and green. The objects in the example scene all have texture coordinates in the 0.0 to 1.0 range.

Figure 9.1
uv_as_color_material()

To demonstrate the use of a user-defined function as the value of a parameter in a material, the call to state::texture_coordinate can be wrapped in a function with a name that describes its intended use as a color.

Listing 9.5
color uv_as_color() {
   
return color(state::texture_coordinate(0));
Calling the state function
}

Now the user-defined function takes the place of the color constructor in the let-expression.

Listing 9.6
material uv_as_color () =
let {
   
color diffuse_color = uv_as_color();
Call the user-defined function uv_as_color
   bsdf diffuse_bsdf = df::diffuse_reflection_bsdf(
      tint: diffuse_color);
} in
material(
   surface: material_surface(
      scattering: diffuse_bsdf));

Though the body of the uv_as_color function only contains a return statement, the name provides an explanation of the value produced by the function.

The texture coordinates of an object's surface are frequently used in mapping the pixel values of a digital image to the surface of an object. An image to be used in this way is represented in MDL as an instance of the type texture_2d. A constructor for a texture_2d can take the filename of an image file as an argument.

texture_2d( image-filename )

A coordinate system is defined for texture_2d images in which the origin is at the lower left corner and the two axes, called u and v, range from 0.0 to 1.0. This range is used for u and v for images of any aspect ratio.

Fig. 9.2 – The uv coordinate system scales to fit the image

Given an instance of texture_2d, a color value can be sampled from the image with the state function lookup_color in the tex module.

tex::lookup_color( instance-of-texture_2d, uv-coordinates )

For example, the following code example initializes the variable center_color with the color of the center of the image file background.png.

color center_color =
    tex::lookup_color(texture_2d("background.png"), float2(0.5, 0.5));

In the previous section, the texture coordinate in the renderer state as provided by function state::texture_coordinate was displayed as a color. In the more typical use of state::texture_coordinate it provides the mapping from colors in an image to colors on the surface of a geometric object. This mapping can be encapsulated in a function:

Listing 9.7
color texture_2d_lookup(uniform texture_2d texture) {
   
float3 uvw = state::texture_coordinate(0);
uv coordinates
   
return tex::lookup_color(texture, float2(uvw.x, uvw.y));
Color lookup in texture
}

To use this texture function in a material, consider this material definition:

Listing 9.8
material generic_diffuse_material(
   
color diffuse_color = color(1.0)) =
Defining a material parameter of type color
let {
   bsdf diffuse_bsdf = df::diffuse_reflection_bsdf(
      
tint: diffuse_color);
Using the color parameter for the tint field value
} in
material(
   surface: material_surface(
      scattering: diffuse_bsdf));

The material's single parameter defines the color for the diffuse reflection BSDF. For example, assume that Figure 9.3Image file uv.png is available in the file system as a file named “uv.png”.

Fig. 9.3 – Image file uv.png

The image in file uv.png can be used as the value of the diffuse reflection color in the following way:

Listing 9.9
material tex2d_diffuse() =
   generic_diffuse_material(
      
diffuse_color:
   texture_2d_lookup(texture_2d("uv.png")));
Color argument value from a user-defined function

Treating the uv coordinates as colors made possible a visual representation of their change across the surface of the object. Using the uv.png image as a texture map displays the pattern of uv coordinates even more clearly.

Figure 9.4
tex2d_diffuse()

The position and size of the texture map image across the surface of the objects can be modified by using the uv coordinate values as factors in an arithmetic expression. For example, the following function, texture_2d_lookup_scaled, adds u and v scaling factors as arguments to the texture_2d_lookup function. To keep the u and v coordinates in the range of 0.0 to 1.0, the frac function in the math standard module is called on the scaled value. The frac function returns the fractional component of its input, keeping the values in the 0.0 to 1.0 range.

Listing 9.10
color texture_2d_lookup_scaled(
   uniform texture_2d texture,
   
float u_scale = 1.0,
Scaling factor in u direction
   
float v_scale = 1.0)
Scaling factor in v direction
{
   float3 uvw = state::texture_coordinate(0);
   color result = tex::lookup_color(
      texture,
      float2(
         
math::frac(uvw.x * u_scale),
Scaling of u coordinate
         
math::frac(uvw.y * v_scale)));
Scaling of v coordinate
   return result;
}

The u and v scaling factors can be added as parameters to a material which are in turn passed as arguments to the texture_2d_lookup_scaled function:

Listing 9.11
material tex2d_diffuse_scaled(
   
float u_scale = 1.0,
Scaling factor in u direction
   
float v_scale = 1.0) =
Scaling factor in v direction
   generic_diffuse_material(
      diffuse_color: texture_2d_lookup_scaled(
         
texture_2d("uv.png"), u_scale, v_scale));
Arguments to user-defined function

Rendering with scale factors of 10 for the u coordinate and 5 for the v coordinate produces Figure 9.5:

Figure 9.5
tex2d_diffuse_scaled(
  u_scale: 10,
  v_scale: 5)

Rendering with materials that display non-visual surface attributes as colors can be an important step in a production pipeline to verify assumptions about those attributes. The next section describes the visualization of other attributes of the rendering state.

The section “Standard functions and MDL modules” described the world and object coordinate spaces that are part of the rendering state. Wrapping state::transform_point in a utility function can simplify acquiring the current position in those spaces. For example, the following function returns the current object position:

Listing 9.12
float3 object_position() {
   return state::transform_point(
      
state::coordinate_internal,
Transform the original point from this coordinate system...
      
state::coordinate_object,
...to a resulting point in this coordinate system.
      state::position());
}

Similarly, the following function returns the current world position:

Listing 9.13
float3 world_position() {
   return state::transform_point(
      
state::coordinate_internal,
Original point's coordinate system
      
state::coordinate_world,
New coordinate system for the point
      state::position());
}

For symmetry, the following function for texture space complete the set of coordinate space utility functions:

Listing 9.14
float3 uvw_position() {
   return state::texture_coordinate(0);
}

Now these transforming functions can be combined in a single function that includes how color values should be mapped to object as a parameter to the function. When a function requires a parameter that specifies a selection among a set of possible choices, a new enum data type can be defined that describes the set. The following code defines an enum data type called mapping_mode with three possible values: uvw, object, and world.

Listing 9.15
enum mapping_mode {
   uvw,
   object,
   world
};

The new mapping_mode type can be used as the type of a parameter to a user-defined function. Note that a user-defined enum can declare the type of a function's parameter in the same way as a native MDL type (like float or int). The enum type is useful for selecting among a set of alternatives using the MDL switch statement.

switch ( expression ) {
   case value-1 :
       statements 
       break;
   case value-2 :
       statements 
       break;
   ...
   case value-n :
       statements 
       break;
}

The value of each case statement is compared in turn to the switch expression. If the two are equal, the statements following the case statement, the case block, are executed. If only that case block should be executed, the case block ends with a break statement, which terminates execution of the entire switch statement. Without the break statement, the statements of the next case block will also be executed and so forth for the entire switch statement.

For a generalized function that returns a point in one of the three spaces—texture, object, and world—the mapping_mode enum can be used as the expression in a case statement:

Listing 9.16
float3 position(mapping_mode mode) {
   float3 result;
   switch (mode) {
      case uvw:
         result = uvw_position();
         break;
      case world:
         result = world_position();
         break;
      case object:
         result = object_position();
         break;
   }
   return result;
}

Using the position function, the material uv_as_color can be rewritten as follows to produce a new material called uvw_space_as_color.

Listing 9.17
material uvw_space_as_color() =
   generic_diffuse_material(
      diffuse_color: color(position(uvw)));

Rendering with uvw_space_as_color produces Figure 9.6:

Figure 9.6
uvw_space_as_color()

However, converting the u and v coordinates to a color assumes that the coordinate values will be in the range of 0.0 to 1.0. Coordinate values in in object and world space will be outside this range, with negative numbers and numbers greater than 1.0. To address this, the math::frac function can be used (as in the texture image example) to convert all numbers into the 0.0 to 1.0 range. (The fractional component of a negative number returned by math::frac is positive.) The new function position_to_color also includes a scaling parameter.

Listing 9.18
color position_to_color(mapping_mode mode, float scale) {
   return color(math::frac(scale * position(mode)));
}

The mapping_mode enum can be used as a parameter to a material, as well as a scaling factor. These material parameters are passed to the position_to_color function that provides the value for the diffuse color:

Listing 9.19
material space_as_color(
   mapping_mode mode = uvw,
   float scale = 1.0) =
generic_diffuse_material(
   diffuse_color: position_to_color(mode, scale));

Rendering object space coordinates as colors with a scaling factor of 4 produces Figure 9.7:

Figure 9.7
space_as_color(
  mode: "object",
  scale: 4)

Rendering with world space colors displays the same coordinate system for all three of the objects:

Figure 9.8
space_as_color(
  mode: "world",
  scale: 4)

In the examples of this section, the coordinates of the three spaces were treated as colors, a useful visualization tool for visualizing the non-visual attributes of geometric objects. In the next section, coordinate values are used as the initial arguments for calculations that are more complex than simply converting between data types.

In this section, functions use the state functions of the coordinate spaces to select between two colors for stripe and checkerboard patterns.

A “stripe” is defined as the regular repetition of a region at right angles to either the x or y axis. To create a function to determine if a point is within a stripe, the geometry of the stripe can be described in the following way:

Figure 9.9

The parameters interval and thickness are used to calculate the lower and upper boundaries of the stripe near the input value f.

Listing 9.20
bool stripe(float f, float interval, float thickness) {
   float position = math::abs(f);
   float radius = thickness / 2.0;
   float lower_center = int(position / interval) * interval;
   float upper_center = lower_center + interval;
   float lower_bound = lower_center + radius;
   float upper_bound = upper_center - radius;
   return (position < lower_bound) || (position > upper_bound);

}

Function horizontal_stripe_pattern uses stripe to determine if the y coordinate of a point is within the region of a stripe, and returns the appropriate color that was supplied as an input argument.

Listing 9.21
color horizontal_stripe_pattern(
   mapping_mode mode=uvw,
   float interval=0.2,
   float thickness=0.1,
   color stripe_color = color(0.0),
   color bg_color = color(1.0))
{
   return 
      stripe(position(mode).y, interval, thickness)
         ? stripe_color
         : bg_color;
}

Function horizontal_stripe_pattern provides the diffuse color in material horizontal_stripes:

Listing 9.22
material horizontal_stripes(
   color stripe_color = color(0.0),
   color bg_color = color(1.0)) = 
generic_diffuse_material(
   diffuse_color: horizontal_stripe_pattern(
      stripe_color: stripe_color,
      bg_color: bg_color));

Rendering with material horizontal_stripes produces Figure 9.10:

Figure 9.10
horizontal_stripes(
  stripe_color:
    color(.4, .5, .3),
  bg_color:
    color(.8, .7, .1))

Note that material horizontal_stripes is an example of a material that hides several of the parameters of the function it contains. All the parameters of a function can be exposed in the material that uses it. More specific materials can be made from the generalized material in a manner similar to the encapsulation of parameters in the creation of layered materials.

For example, the following material makes all the parameters of function horizontal_stripe_pattern available as material parameters:

Listing 9.23
material horizontal_stripes_component(
   color stripe_color = color(0.0),
   color bg_color = color(1.0),
   mapping_mode mode = uvw,
   float interval = 0.2,
   float thickness = 0.1) =
generic_diffuse_material(
   diffuse_color: horizontal_stripe_pattern(
      stripe_color: stripe_color,
      bg_color: bg_color,
      mode: mode,
      interval: interval,
      thickness: thickness));

More specific materials can then be built from this fully parameterized component material. For example, the following material hides all the parameters of the horizontal_stripes_component material:

Listing 9.24
material red_stripes_on_yellow() =
   horizontal_stripes_component(
      stripe_color: color(.5, .2, .0),
      bg_color: color(0.8, 0.7, 0.1),
      interval: 0.05,
      thickness: 0.0075);

Rendering with the red_stripes_on_yellow material produces Figure 9.11:

Figure 9.11
red_stripes_on_yellow()

To construct a checkerboard pattern of two colors, A and B, the stripe function can be used to create two stripes at right angles to each other. If a point is either in one stripe or the other, but not both, then it is assigned color A, else it is assigned color B.

The logical condition “one or the other but not both” is called exclusive or. MDL's logical operators can be used to define a general exclusive or function for two Boolean inputs:

Listing 9.25
bool exclusive_or(bool a, bool b) {
   return (a || b) && !(a && b);
}

Implementing a checkboard pattern then simply requires determining whether a point is in the vertical stripes (by using the x coordinate of the point) or in the horizontal stripes (by using the y coordinate).

Listing 9.26
color checkerboard_pattern(
   float interval=0.2,
   mapping_mode mode = uvw,
   color color_1 = color(1.0),
   color color_2 = color(0.0))
{
   float3 point = position(mode);
   float thickness = interval / 2.0;
   bool vertical_stripe = 
      stripe(point.x, interval, thickness);
   bool horizontal_stripe = 
      stripe(point.y, interval, thickness);
   return 
      exclusive_or(horizontal_stripe, vertical_stripe) 
         ? color_1 
         : color_2;
}

The two colors of the checkerboard function are the arguments of the checkerboard material.

Listing 9.27
material checkerboard(
   color color_1 = color(0.0),
   color color_2 = color(1.0),
   mapping_mode mode = uvw) =
generic_diffuse_material(
   diffuse_color: checkerboard_pattern(
      color_1: color_1,
      color_2: color_2,
      mode: mode));

Using the checkerboard material produces Figure 9.12:

Figure 9.12
checkerboard(
  color_1:
    color(.3, .4, .5),
  color_2:
    color(.8, .7, .1))

The next chapter demonstrates the use of functions to implement the widely used Perlin noise algorithm.