Shader Objects and Co-Shaders

April 2007 (version 1.1)

Contents

Introduction

Shaders can now be objects with member variables and multiple methods. For example, here is the outline of a shader that defines both displacement and surface methods, along with a member variable that allows the surface method to avoid repeating a texture lookup that is performed by the displacement method:

class myshader(string mapname="", ...)
{
    varying color Ct = (1,1,1);

    public void displacement(output point P; output normal N) {
        if (mapnamp != "")
            Ct = texture(mapname, ...);
        ...
    }
    public void surface(output color Ci, Oi) {
        ...
        Ci = Ct * ... ;
    }
}

The introduction of shader objects provides many new capabilities and performance benefits:

The sections that follow describe these capabilities in more detail. An extended example (an area light shader) is presented in the Appendix.

Member variables

Persistent data is useful in many kinds of shaders. For example, surface shaders often recompute uniform values that do not depend on data from geometric primitives. Such values can now be computed once, when the instance is first created, and saved in member variables.

A shader definition can now have a syntax that is similar to a C++ class definition. A shader class definition declares shader parameters and contains member variables, functions, and methods. For example:

class myshader(... params ...)
{
    // member variables
    uniform float y = 1;
    varying float z = 2;

    // method definition
    public void surface(output color Ci, Oi) {
        ...
    }
}

Methods

A method definition looks like a function definition, but is preceded by the public keyword. Methods that are called by the renderer must define certain arguments. For example, an RiSurface shader can define these methods:

public void displacement(output point P; output normal N);
public void opacity(output color Oi);
public void surface(output color Ci, Oi);

and the following method can be defined by RiAtmosphere, RiInterior and RiExterior shaders:

public void volume(output color Ci, Oi);

Note that Ci and Oi are no longer global variables (although they are still supported in traditional shader definitions). Similarly L and Cl must be defined as output parameters of a light method:

public void light(output vector L; output color Cl);

Of course, traditional shaders can be used alongside method-based shaders. Backwards compatibility is discussed in more detail in a later section.

Shading pipeline

Traditionally, the shading pipeline has operated as follows:

  • The displacement shader (if any) is executed.
  • The surface shader is executed.
  • Interior, exterior, and atmosphere shaders (if any) are then executed.

The shading pipeline is slightly different if the surface shader defines certain methods:

  • If the surface shader defines a displacement method, it is called.
    • Otherwise the displacement shader (if any) is executed.
  • The opacity method (if any) of the surface shader is called.
  • The surface method is called.
  • Interior, exterior, and atmosphere shaders (if any) are then executed.

Note that volume shaders do not share member variables with surface shaders. While it is possible to define a shader that contains both surface and volume methods and use the same shader definition in RiSurface and RiAtmosphere calls, separate shader instances will result, and each instance will have its own member variables. The atmosphere shader must use message passing (discussed below) to access the surface shader's public member variables.

In anticipation of future relighting tools, the surface method can be replaced by these three methods (any of which may be omitted):

public void prelighting(output color Ci, Oi);
public void lighting(output color Ci, Oi);
public void postlighting(output color Ci, Oi);

The prelighting method typically performs texture lookups and BRDF calculations that are independent of the lights. The lighting method contains the illuminance loops that call the lights, and the postlighting method performs any postprocessing that is necessary after the lights are executed. A future relighting tool can cache the state of the shader after prelighting, and it can cache the contributions of the lights. After a light is interactively modified (e.g. changing its position or intensity), the lighting method can be called with only the modified light, re-calculating its contribution.

User-defined methods

In addition to the standard methods described above, shaders can define methods with arbitrary parameters and return values. For example, here is a shader that provides getter and setter methods for its internal state:

class myshader()
{
    string state = "ready";

    public string GetState() {
        return state;
    }
    public void SetState(string newstate) {
        state = newstate;
    }
    ...
}

The use of such methods is the subject of the next section.

Co-shaders

The method-based shading pipeline is a fixed pipeline. Custom shading pipelines can be implemented using co-shaders. The term itself is analogous to "co-routines", implying cooperative computation by modular components. Co-shaders are simply shader objects with no explicitly specified purpose; they are not executed directly by the renderer, but rather are called from other shaders. Co-shader objects are created like lights in RIB, by specifying the shader name, a handle name, and any desired parameter values:

Shader "basic" "baselayer" "float Ks" [.5]
Shader "rust" "rustlayer" "float Ks" [0]

Co-shader objects are accumulated in a list that is similar to the light list. The usual scoping rules apply: additions to the current list of co-shaders are local to the current attribute scope.

In the shading language, a co-shader can be obtained from its handle, yielding a shader (which might be null if the handle was not found). Method calls use the obvious syntax:

shader baselayer = getshader("baselayer");
if (baselayer != null)
    baselayer->surface(Ci, Oi);

Other ways of obtaining co-shaders are described below. First, let's revisit member variables in more detail

Member variables, revisited

Member variables can be constant, uniform, or varying. A constant member variable is initialized once, when the instance is first created, and is read-only thereafter. Uniform member variables are typically re-initialized each time a new batch of points is shaded, but not always: the value of a uniform member variable can be computed once and persist indefinitely thereafter. Varying member variables are not valid for more than one execution of a shader, since the number of points usually changes each time the shader is executed.

The initial value specified in a member variable definition must be a constant or a simple constant expression (e.g. "sqrt(2)" or "degrees(PI/2)"). Alternatively, an initialization method (similar to a C++ constructor) may be used to initialize member variables. Two initialization methods may be used: the construct method is executed when the instance is first constructed, and the begin method is called each time the shader is prepared for execution on a new batch of points.

class myshader()
{
    constant float _maxdepth;
    uniform float _raydepth;
    uniform float _sides;
    varying point _origP;

    public void construct() {
        option("trace:maxdepth", _maxdepth);
    }

    public void begin() {
        rayinfo("depth", _raydepth);
        _sides = 2;
        attribute("Sides", _sides);
        _origP = P;
    }

    normal shadingnormal(normal N) {
        normal Ns = normalize(N);
        if (_sides == 2 || _raydepth > 0)
            Ns = faceforward(Ns, I, Ns);
        return Ns;
    }

    public void displacement(output point P; output normal N) {
        normal Ns = shadingnormal(N);
        ...
    }
}

It is more efficient to specify a constant initial value in a member variable definition than to assign it in an initialization method. An initialization method is typically used only when the initial value of a member variable requires calculation, depends on shader parameters, etc.

Scoping

Member variables can be used without extern declarations. In addition, extern declarations are no longer required for shader parameters, although they may still be used for documentation purposes. (The only time extern declarations are required is when a nested function uses variables in an enclosing function.)

Also, note that member variables can be used freely in ordinary (private) functions (not just public methods), provided the functions are defined inside the shader class definition. For example:

class myshader() {
    string state = "ready";

    // This is an ordinary (private) function, not a public method.
    void proceed() {
        state = "executing";  // OK to access member variable.
        ...
    }
    ...
}

To avoid confusion, we recommend that a naming convention (such as an underscore prefix) be used to distinguish between member variables and local variables.

Public variables and message passing

Unlike shader parameters, member variables are private by default. Delaring a member variable with a public keyword makes it visible to other shaders:

class myshader(float a = 0) // a is public
{
    float b = 1;            // b is private
    public float c = 2;     // c is public
}

"Message passing" is the traditional term for communication between shaders. For example, a light shader traditionally uses a surface() call to access surface shader parameter values:

float surfaceKd = 0;
surface("Kd", surfaceKd);

Message passing functions now provide access to all the public variables of a shader, namely its parameters and its public member variables.

A more general built-in function called getvar operates on an arbitrary shader:

shader baselayer = getshader("baselayer");
float baseKd = 0;
getvar(baselayer, "Kd", baseKd);

The getvar function operates like the surface function: if the requested variable is not found, it returns zero and does not modify the output parameter. It returns one for success.

The output parameter of getvar can be omitted, in which case it simply checks whether the specified variable exists:

float hasKd = getvar(baselayer, "Kd");

It is also possible to check for the existence of a method:

float hasSurface = hasmethod(baselayer, "surface");

For convenience, an "arrow operator" can also be used to get the value of a member variable or shader parameter:

float baseKd = baselayer->Kd;

When this syntax is used, a warning is reported if the specified variable does not exist or does not have the expected type, in which case it returns zero(s) (or the empty string, etc.) The arrow operator is useful when a "strongly typed" programming style is adopted: rather than testing each variable access for errors, the shader is tested once (e.g. to verify its category), after which it is assumed to satisfy a particular interface.

The shader compiler does not know the types of variables defined by other shaders, so the arrow operator cannot be used in contexts where the expected type is ambiguous (e.g. passed to an overloaded operator or function).

Note that the arrow operator cannot be used to modify a public variable; a method call should be used instead:

baselayer->state = 0;          // Error
baselayer->SetState(0);        // OK

Despite the new lightweight syntax, these operations are expensive, requiring a hash table lookup, a run-time type test, and a data copy. The shader compiler is not yet capable of optimizing repeated uses of the same public variable, so users should code conservatively. For example:

if (foo->x > 0)
    y *= foo->x;    // inefficient: repeats lookup and copy.

float x = foo->x;   // better: avoids duplicating work.
if (x > 0)
    y *= x;

Iterating with co-shaders and lights

As mentioned previously, co-shader objects are created like lights in RIB, and they are accumulated in a list that is similar to the light list. The entire co-shader list (or a subset, filtered by category) can be obtained using the getshaders() function, which returns a variable-length array:

shader layers[] = getshaders();
uniform float i, n = arraylength(layers);
for (i = 0; i < n; i += 1) {
    color layerC, layerO;
    layers[i]->surface(layerC, layerO);
    ...
}

As this example demonstrates, shaders are first-class values: they can be stored in arrays, passed to functions, compared for equality, etc. However, they cannot be declared as shader parameters (strings specifying shader handles should be used instead).

Lights can also be used as co-shaders. A particular light instance can be obtained by specifying its handle:

shader mylight = getlight("mylight");

The entire light list can also be obtained by the getlights() function (which can filter by category, like getshaders). This can provide a more flexible alternative to illuminance loops. For example, diffuse illumination can be calculated as follows:

shader lights[] = getlights("category", "diffuse");
uniform float i, n = arraylength(lights);
for (i = 0; i < n; i += 1) {
    vector L;
    color Cl;
    lights[i]->light(L, Cl);
    C += Cl * (Nn . normalize(-L));
}

Note that the L vector computed by a light method points from the light to the surface; illuminance loops automatically flip the L vector, but when a light method is called directly this is the caller's responsibility.

Explicit illumination loops such as this are not equivalent to illuminance loops in one important respect: the renderer does not attempt to cache the results of executing the light. This can be more of a benefit than a drawback, since illuminance caching is ad hoc and can deliver incorrect results (unless one is careful to invalidate the cache in certain situations).

Another advantage of an explicit illumination loop is that it gives the caller more control over execution of the light. For example, the caller can inspect various properties of the light to determine whether it should be executed, or to determine which method should be executed. Higher performance can also be achieved by using method parameters instead of shader parameters to convey inputs and outputs, since method parameters are passed by position. This avoids hash-table lookups and allows outputs to be computed "in place", rather than being copied from shader outputs.

Predefined shader objects

Special global variables called surface and light can be used to access the current surface shader and the current light. (The current light can be used only within an illuminance loop.) This allows access to shader parameters, member variables, and arbitrary methods:

public void light(output vector L; output color Cl) {
    ...
    float surfaceKd = surface->Kd;
    if (surfaceKd > 0)
        surface->GetMoreInfo(...);
    ...
}

The current shader is also available as a global variable called this, which is useful when calling a co-shader that performs a callback:

baselayer->coroutine(..., this);

Do not use this to access member variables or call methods in the current shader, since it introduces an expensive level of indirection and defeats compile-time type checking and inlining:

if (this->Kd > 0)           // No! Just use Kd.
    this->proceed(...);     // No! Just call proceed(...)

(The only exception to this rule is for recursive method calls, which actually work. They cost the same as ordinary co-shader method calls.)

Optional method parameters

It is often useful to pass additional arguments to a light method. Such parameters must be treated as optional, since not all light methods would necessarily accept those parameters.

To accomplish this, a method definition can specify any number of optional parameters following the required parameters. An optional argument is declared by including a default value in the definition of a formal parameter. For example:

public void foo(float x; float y = 1; float z = 2) {
    ...
}

To avoid ambiguity, optional parameters must follow required parameters (i.e. they cannot be intermingled). Default values of method parameters must be constants (or constant expressions such as MAX_SIZE+1).

A method call passes required parameters by position, whereas optional parameters are passed as name/value pairs. Optional parameters can be passed in any order, and undefined parameters are ignored (but type errors are not ignored.) For example:

bar->foo(0);
bar->foo(0, "y", 3);
bar->foo(0, "z", 4, "y", 3);

Optional parameters are slightly more expensive than required parameters, so they should not be used except when necessary.

For backwards compatibility, method calls with optional arguments also work when calling traditional shaders, except that the optional argument values are passed as shader parameters. For example, the following traditional shader

light foo(float y = 1; float z = 2)
{
    ...
}

could be called as follows:

lights[i]->light(L, Cl, "y", 3, "z", 2);

The area light shader in the Appendix demonstrates the usefulness of optional method parameters. It defines a light method that takes P as an optional input parameter and produces resizable arrays of colors and and directions as output parameters:

public void light( output vector L;
                   output color Cl;
                   point P = (0,0,0);
                   output color lightcolors[] = { };
                   output vector lightdirs[] = { } );

Backwards compatibility

Many of the new features described in this document are orthogonal and can be incrementally adopted. A traditional shader definition, such as a surface shader, is generally equivalent to a class definition with a single method.

However RiSurface calls treat the old and new styles of shader definitions differently. Only shaders with class definitions are executed using the new method-based pipeline.

Conclusion

Shader objects are a convenient way to retain state and factor functionality into separate methods. The RenderMan shading pipeline can take advantage of explicitly defined shading stages, for example by executing the opacity method but postponing the bulk of shading until visibility has been determined. Member variables allow state to be shared between different stages of the shading pipeline in a principled and efficient way. Furthermore, method calls and message passing of member variables allow complicated illumination models to be expressed more clearly with improved performance. Finally, co-shaders allow custom shading pipelines to be expressed in the shading language itself.

Appendix A: Glossy surface with area light

//
//
// A surface with Ward isotropic glossy reflection, illuminated
// by area light sources.
// An example of the new shader object system in PRMan 13.5.
// Per Christensen, 2007.
//

#include "normals.h"
#include "wardglossy.h"

//
// Surface shader class definition
//
class glossy_area( // Texture param
                  string texturename = "";
                  // Ward diffuse and glossy param
                  float Kd = 0.5, Kg = 0.5;
                  float alpha_u = 0.05;
                  // Russian roulette threshold
                  float RRthreshold = 0.01; )
{
    // Ward isotropic glossy BRDF
    public void surface(output color Ci, Oi) {
        color refl, sumrefl = 0;
        color trans;
        normal Ns = shadingnormal(N);
        vector In = normalize(I);
        vector lightDir;
        color tex = 1;
        float dot_i, maxrefl;
        uniform float s;

        shader lights[] = getlights();
        uniform float i, nlights = arraylength(lights);

        // Texture
        if (texturename != "")
            tex = texture(texturename);

        // For each light source ...
        for (i = 0; i < nlights; i += 1) {

            // Get lightcolors, lightdirs from light.
            vector L;                   // unused, but required argument
            color Cl;                   // also unused.
            color lightcolors[];        // note use of resizable arrays
            vector lightdirs[];
            lights[i]->light(L, Cl,
                             "P", P,
                             "lightcolors", lightcolors,
                             "lightdirs", lightdirs);
            uniform float nsamples = arraylength(lightcolors);

            // Evaluate glossy (and diffuse) BRDF for the light
            // directions
            color reflcoeffs[nsamples];
            evaluateWardGlossy(P, Ns, In,
                               Kd * tex * Cs, color(Kg), alpha_u,
                               nsamples, lightdirs, reflcoeffs);

            // Accumulate illumination; shoot shadow rays only where
            // they matter
            for (s = 0; s < nsamples; s += 1) {

                // Compute cosine term
                lightDir = normalize(lightdirs[s]);
                dot_i = lightDir . Ns;

                // Reflected light is the product of incident
                // illumination, a cosine term, and the BRDF
                refl = lightcolors[s] * dot_i * reflcoeffs[s];

                // Compute shadow -- but only if the potentially
                // reflected light is bright enough to matter.  This
                // is sometimes an important optimization!  For
                // example, no shadow rays need to be traced at points
                // with a black texture and no glossy highlight or if
                // the light source is dark-ish.  The point is that we
                // need information both from the surface and the
                // light source to determine whether shadow rays need
                // to be traced.
                maxrefl = max(refl[0], refl[1], refl[2]);
                if (maxrefl > RRthreshold)
                    trans = transmission(P, P + lightdirs[s]);
                else if (maxrefl > RRthreshold * random())
                    // Russian roulette
                    trans = transmission(P, P + lightdirs[s]);
                else
                    trans = 1; // don't bother tracing shadow rays

                sumrefl += trans * refl;
            }
        }

        // Set Ci and Oi
        Ci = sumrefl * Os;
        Oi = Os;
    }
}
//
// normals.h
//
// Compute normalized shading normal with appropriate orientation.
// We ensure that the normal faces forward if Sides is 2 or if the
// shader evaluation is caused by a ray hit.
//
normal
shadingnormal(normal N)
{
    normal Ns = normalize(N);
    uniform float sides = 2;
    uniform float raydepth;
    attribute("Sides", sides);
    rayinfo("depth", raydepth);
    if (sides == 2 || raydepth > 0)
        Ns = faceforward(Ns, I, Ns);
    return Ns;
}
//
// wardglossy.h
//
// Evaluate Ward's isotropic glossy BRDF for the given light directions
//
void
evaluateWardGlossy( vector P, Nn, In;
                    color Cd, Cg;
                    float alpha;
                    uniform float samples;
                    vector lightdirs[];
                    output color reflcoeffs[] )
{
    vector lightDir, reflDir, h;
    float alpha2, dot_i, dot_r;
    float delta, e, c;
    uniform float s;

    alpha2 = alpha*alpha;
    reflDir = -In; // direction toward eye
    dot_r = reflDir . Nn;

    for (s = 0; s < samples; s += 1) {
        lightDir = normalize(lightdirs[s]);
        dot_i = lightDir . Nn;
        if (dot_i <= 0 || dot_r <= 0) {
            reflcoeffs[s] = 0;
        } else {
            reflcoeffs[s] = Cd; // diffuse comp
            // (cosine term is elsewhere)
            h = normalize(reflDir + lightDir);
            delta = acos(h . Nn);
            e = tan(delta) / alpha;
            c = 1 / sqrt(dot_i * dot_r);
            reflcoeffs[s] +=  //  glossy comp
                Cg * c * exp(-e*e) / (4 * PI * alpha2);
        }
    }
}

Appendix B: Simple area light shader

//
// rectarealight.sl - Simple area light source shader.
// An example of the new shader object system in PRMan 13.5.
// Per Christensen, 2007.
//
// Generate (unstratified) illumination directions on rectangular  area light
// source
//

class
rectarealight(
       float intensity = 1;
       color lightcolor = 1;
       uniform float samples = 16;
       point center = point "shader" (0,0,0); // center of rectangle
       vector udir = vector "shader" (1,0,0); // axis of rectangle
       vector vdir = vector "shader" (0,1,0)) // axis of rectangle
{
    public void light( output vector L;         // unused
                       output color Cl;         // unused
                       point P = (0,0,0);
                       output color lightcolors[] = { };
                       output vector lightdirs[] = { } )
    {
       vector rnd;
       point samplepos;
       float rnd1, rnd2;
       uniform float s;

       resize(lightcolors, samples);   // note use of resizable arrays
       resize(lightdirs, samples);

       for (s = 0; s < samples; s += 1) {
           rnd1 = 2 * random() - 1;
           rnd2 = 2 * random() - 1;
           samplepos = center + rnd1 * udir + rnd2 * vdir;
           lightdirs[s] = samplepos - P;
           lightcolors[s] = intensity * lightcolor / samples;
       }

       // Clear L and Cl, even though they're unused.
       L = (0,0,0);
       Cl = (0,0,0);
    }
}

Appendix C: Area light example RIB

FrameBegin 0
Display "glossypot.tif" "tiff" "rgba"
Projection "perspective" "fov" 25
Translate 0 0 12

WorldBegin
# make objects visible to transmission rays
Attribute "visibility" "int transmission" 1

LightSource "rectarealight" 1
      "intensity" 2
      "center" [-10 10 -13]
      "udir" [2.5 0 0] "vdir" [0 0 2.5]
      "samples" 16

LightSource "rectarealight" 2
      "intensity" 0.2
      "center" [30 30 -10]
      "udir" [1.5 0 0] "vdir" [0 0 1.5]
      "samples" 4

Surface "glossy_area" "texturename" "grid.tex"
      "Kd" 0.3 "Kg" 0.7 "alpha_u" 0.1
      "RRthreshold" 0.01

    AttributeBegin
      Translate 0 -1 0
      Rotate -90  1 0 0
      #ReverseOrientation
      Geometry "teapot"   # standard "Utah" Bezier patch teapot
    AttributeEnd

WorldEnd
FrameEnd
 

Pixar Animation Studios
(510) 752-3000 (voice)  (510) 752-3151 (fax)
Copyright © 1996- Pixar. All rights reserved.
RenderMan® is a registered trademark of Pixar.