Coding Blender Materials With Nodes & Python

Jeremy Behreandt
15 min readMay 29, 2018

This tutorial, a follow-up to Creative Coding in Blender, focuses on creating patterns on materials in Blender’s Cycles renderer. There are a few reasons why patterning can be challenging at first. Tutorials for OpenGL Shading Language (GLSL) abound, but the node-based visual programming in Blender has a slightly different toolset. While Open Shading Language provides an alternative, it has a few drawbacks:

  • OSL requires that a render be done on the CPU, not the GPU.
  • There’s a potential dependency, where .osl files may have to be tracked along with the .blend file.
  • Documentation is segmented — and inconsistent — between the OSL language specification and Blender’s documentation.
  • OSL may encourage a ‘kitchen sink’ or ‘black box’ style, in which we don’t see how the logic of an OSL script node works and cannot quickly revise it. We have to switch back and forth between the node and text editor (internal or external) to change the pattern.
  • OSL can be problematic in collaborative scenarios, where one or more collaborators focus less on the scripting and more on aesthetics.

For these reasons, we’ll group built-in nodes into utility functions that facilitate translating shader patterns from other languages into Blender. There are several add-ons, such as Jacques Lucke’s Animation Nodes and Skorupa & Zaal’s Node Wrangler, which include functionality developed here. This article aims not provide a recipe to simulate a realistic texture or be comprehensive, but to illustrate how a tool set can be developed.

Knowledge of OSL is not necessary to follow along, but code snippets are included to bridge the gap between visual and script-based programming. After we create a node-based tool set, we’ll look into how to generate nodes and materials with Python, then append them to generated objects.

This was written for Blender v. 2.79.

Creating Node Groups

We create the node groups below by selecting nodes in the node editor and pressing Ctrl-G. To edit a group that has already been created we either click on the linked white boxes in the upper right corner of a node group instance or press Tab. To return to the main material from editing the group, we either press Tab again or click on the bent arrow in the node editor header, labeled ‘Go to parent node tree.’

The inputs on the left side of a group node viewed from the outside are represented by a Group Input node inside the group; the outputs on the right, by the Group Output node. The interface between inside and outside is in the properties region, which can be opened by going to View > Properties or by pressing N. To add new inputs or output sockets, we link nodes to the clear unlinked socket at the bottom of either the Group Input or Group Output node. The + buttons on the bottom of the interface do the same.

The interface will let us delete sockets, switch their order with the down and up arrows, change their names and default values. Depending on the data type of the input or output, the minimum and maximum bound of the value can also be set. Take care when adding new values that minimums and maximums are suitable.

We can assign a fake user to the node group by clicking on the F on the right side of the drop-down menu. The number to the left will tell us the number of users the group node has. If we create these groups in the startup Blender file (to which we can return with Ctrl-N), then press Ctrl-U to update it, we can store custom group nodes on a long-term basis.

Comparisons

The comparisons ≥ and ≤ are not included in the math node, but can be derived by subtracting the return value of > or < from 1. The step function common to shader languages is, in effect, a less-than comparison.

Not Equals (neq)

neq(a, b): max(lt(a, b), gt(a, b))

In-Bounds

inbounds(v, l, u): min(lt(l, v), gt(u, v))

We can use v >= lower if we want the lower bound to be inclusive; v <= upper if we want the upper bound to be inclusive.

Out-of-Bounds

outofbounds(v, l, u): max(lt(v, l), gt(v, u))

Approximates (approx)

approx(a, b, tolerance): gt(tolerance, abs(sub(a, b)))

Because very large and very small input numbers are not anticipated, this simplifies a more robust approximation function, which can be found here.

Logic Gates

These logic gates follow Python’s example when converting from a real number to a boolean: any non-zero value is true.

The same holds in JavaScript. There are more gates than those depicted below, such as imply.

And

and depends on neq.

and(a, b): min(neq(a, b), neq(a, b))

Inclusive Or

or depends on neq.

or(a, b): max(neq(a, 0.0), neq(b, 0.0))

Not And (nand)

nand depends on approx.

nand(a, b): max(approx(a, 0.0, epsilon), approx(b, 0.0, epsilon))

Not Or (nor)

nor depends on approx.

nor(a, b): min(approx(a, 0.0, epsilon), approx(b, 0.0, epsilon))

Exclusive Or (xor)

xor depends on or and nand.

xor(a, b): mult(or(a, b), nand(a, b))

Real Number Operations

Clamp

clamp(v, min, max): min(max(value, min), max)

CosineSine

cossin(radians): (cos(radians), sin(radians))

This could be parlayed into a CosSinTan function.

Exponent (exp)

2.71828 approximates the constant e. exp allows us to derive hyperbolic functions.

Hyperbolic Cosine (cosh)

cosh depends on exp.

cosh(v): mult(add(exp(v), exp(mult(v, -1.0))), 0.5)

The hyperbolic secant, sech(v), is div(1.0, cosh(v)).

Hyperbolic Sine (sinh)

sinh depends on exp.

sinh(v): mult(sub(exp(v), exp(mult(v, -1.0))), 0.5)

The hyperbolic cosecant, csch(v), is div(1.0, sinh(v)).

Hyperbolic Tangent (tanh)

tanh depends on cosh and sinh.

tanh(v): div(sinh(v), cosh(v))

The hyperbolic cotangent, coth(v), is div(cosh(v), sinh(v)).

Map

map(v, lb0, ub0, lb1, ub1): lb1 + (ub1 —lb1) * (v — lb0) / (ub0 —lb0)

While not a function in shader languages, map allows a value in one range to be converted to to the equivalent value in another range.

Mix

mix(a, b, t): add(mult(a, sub(1.0, t)), mult(b, t))

Mod / Floor Mod

mod depends on mix.

mod(a, b): n = fmod(a, b); mix(n, add(n, b), lt(n, 0.0))

The built-in modulo function is fmod, represented in some programming languages by the % operator. The distinction between a truncation modulo and floor modulo is relevant when working with periodic ranges: when b is positive and a is negative, fmod will return a negative value; mod, a positive value. For example, mod is handy when traveling counter-clockwise on the color wheel from orange to magenta.

Quantize

quantize depends on floor.

quantize(v, d): mult(delta, floor(add(div(v, d), 0.5)))

Quantizing a value reduces it from a larger to a smaller set, in effect converting smoother transitions to sharper, more distinct ones.

The above example illustrates, using Vermeer’s View of Delft, the outcome of applying quantize to the components of a color and vector. A quantized color has a reduced color palette (seen on the top row). A quantized texture coordinate crenelates the image; not pictured, a vector could also be treated as a direction, where its polar or spherical coordinates are quantized.

Radians

radians(degrees): mult(degrees, div(pi, 180.0))

This multiplies the input degrees by pi / 180.0 (0.017).

Sign

sign(v): sub(mult(sub(1.0, lt(v, 0.0)), gt(v, 0.0)), lt(v, 0.0))

Fraction (fract)

fract depends on sign.

fract(v): fmod(v, sign(v))

Returns the fractional part of a real number. In OSL, fract is derived from trunc.

SmoothStep

smoothstep(edge0, edge1, x): t: div(sub(x, edge0), sub(edge1, edge0)); mult(pow(t, 2.0), sub(3.0, mult(t, 2.0)))

Threshold

threshold depends on inbounds.

inbounds(v, l, u): mult(inbounds(v, l, u) ,v)

Compared to clamp, this returns 0 when the value is out-of-bounds.

Integer Conversions

Ceil

ceil depends on mix.

ceil(v): sub(mix(v, add(v, 1.0), gt(v, 0.0)), fmod(v, 1.0))

ceil biases toward the greater value, so ceil(-4.25) equals -4, while ceil(4.25) equals 5.

Floor

floor depends on mix.

ceil(v): sub(mix(v, sub(v, 1.0), lt(v, 0.0)), fmod(v, 1.0))

floor biases toward the lesser value, so floor(-4.25) equals -5, while floor(4.25) equals 4.

Truncate (trunc)

trunc depends on fract.

trunc(v): sub(v, fract(v))

Vector Operations

There are many useful vector operations — such as step, mix and fract — which can be described as the separation of a vector into its components, application of real number operations above, and recombination. We omit most of them below, save for uniform and nonuniform scaling.

Magnitude (mag)

mag(v): pow(dot(v, v), 0.5)

Minkowski Distance

minkowski(a, b, p): diff: pow(abs(sub(a, b)), p), pow(add(diff.x, diff.y, diff.z), div(1, p))

Minkowski distance is a generalization of Euclidean and Manhattan distance, where minkowski(a, b, 2.0) = euclidean(a, b) and minkowski(a, b, 1.0) = manhattan(a, b). (The result of raising a negative base to an even exponent is positive, so the Pythagorean theorem can dispense with abs.)

Multiply by a Uniform Scale (mult)

mult(v, s): combine(mult(v.x, s), mult(v.y, s), mult(v.z, s))

Multiply by a Nonuniform Scale (scale)

scale(v, w, h, d): combine(mult(v.x, w), mult(v.y, h), mult(v.z, d))

Multiplying two vectors component-wise is undefined, but is often supported as a shortcut for multiplying a scale matrix and a spatial coordinate. In the above, height is assigned to the y axis, but could be assigned to z instead.

Reflect

reflect depends on mult.

reflect(i, n): sub(i, mult(n, mult(2, dot(i, n))))

Refract

refract depends on mult.

refract(i, n, e): idn: dot(i, n), k: sub(1, mult(pow(e, 2), sub(1, pow(idn, 2)))), mult(sub(mult(i, e), mult(n, add(mult(e, idn), pow(k, 0.5)))), lt(0, k))

Rescale

rescale depends on mult.

rescale(v, s): mult(norm(v), s)

Rotate

rotate depends on cossin.

Vector rotation is handled by the vector mapping node; that node, however, is unwieldy to use, as translation, rotation and scale are not input sockets. Furthermore, we may need to rotate texture coordinates in the range (0, 0) .. (1, 1) around a pivot (0.5, 0.5).

Tile

tile depends on mult.

tile(v, s): fract(mult(v, s))

Shader Operations

With volume shader nodes in particular, we don’t always need the entire volume of an object to be filled with scattering and/or absorption. For this reason we mix the volume shader with a holdout by a factor.

Shaped Absorb Volume

shapedabsorb(c, d, f, l, u): mix(threshold(f, l, u), holdout, absorb(c, d))

Shaped Light

shapedlight(temp, strength, f, l, u): mix(threshold(f, l, u), holdout, emission(blackbody(temp), strength))

Shaped Scatter Volume

shapedscatter(c, d, a, f, l, u): mix(threshold(f, l, u), holdout, scatter(hsv(1.0, 1.0, 1.0, 1.0, c), d, a))

Scripting Nodes With Python

Python is not needed to create the above, but if we invest the time to write some utility functions, they can help automate the process. A first step is to create an empty group with input and output nodes. (We can reference the info editor’s report console any time we want to find out the name of a property in Python.)

Appending a group to bpy.data.node_groups is a different procedure than appending an instance of a group node to the node tree of a particular material. Node groups can be thought of as a repository for custom function definitions, which are then called by various materials.

Courtesy the NodeSocketStandard class, scripted nodes may use a greater variety of data types than are seen when creating nodes manually. These include integer and Boolean data types. To maintain consistency with out-of-the-box nodes, however, we avoid using them here.

Example 1. In-Bounds

Because the math node is the cornerstone of so many groups above, it’s helpful to dedicate a function to that. Operations included in the math node can be found here.

Math nodes can be color-coded for further clarity, or parented to a frame. If no name for the node is provide, the function defaults to the operation. With this function in place, we can create the inbounds operation from above.

For input and output sockets, we create a list of dictionaries. Each entry in a dictionary is a key-value pair, where the key is usually a data-type that is easy to sort and check for equivalence. In this case, the key is a string. When creating a new link between nodes, the input socket receiving the noodle is supplied first, then the output socket.

Example 2. Shaped Scatter Volume

The shaped scatter group depends on threshold and inbounds. To create a group node which depends on others, we need another helper function.

This function checks to see if the prerequisite node group exists, and, as a backup, creates it if not. Since attempting to find a node group by name could raise an error, we enclose this in a try block. Earlier, we used the dictionary get function to supply a default alternative when an item could not be located. In this case, however, the alternative requires a function to create the node group. More on exception handling in Python can be read here.

With that in place, we can create the the shaped absorption volume.

Here and with threshold above, we reference outputs and inputs by number rather than by name. Accessing an element by index can be helpful when two node sockets have the same name; for example, a mix shader labels both inputs as Shader.

Arranging Nodes

If we look in the node editor at operations generated through Python, we find a clump of nodes on top of each other, making them hard to manually adjust. We can create a script to automatically arrange them.

This depends on functions to calculate a node’s depth or priority and functions to find the width and height of nodes at a given depth. When nodes have been grouped into depths, they are sorted, then eased from the left edge to the right edge. For positioning, positive on the y-axis is up.

There are many ways to write the heuristic we supply as the second parameter to arrange_nodes. Called calc_priority, this will generate the key for each entry in the depth_nodes dictionary. We’ll look at two candidates, one in which we arrange nodes by their type, another in which we look at the nodes input and output sockets.

Arranging By Type

Checking Strings for equivalence is a crude solution, as typically we have to deal with mismatching cases ('Wednesday' vs. 'WEDNESDAY'), partial matches ('Wed' vs. 'Wednesday'), trimmable characters ('VECT_MATH' vs. 'VECT MATH') and so on. Since we are using Strings internal to Blender, we keep our checks simple.

The above does not include all possible node types.

Arranging By Connected Sockets

Alternatively, we could sift through each node’s input and output sockets and assign a score based on whether the socket is connected and, in the case of outputs, how many connections it has.

Not all links are valid, as when the output of a node eventually feeds into one of its inputs, creating an undesirable loop. We may also wish to consider adding to or subtracting from result if a node is unlinked.

An approach not featured here would be to use a function that begins at a pole of the node tree (the material output, for example), and crawls link by link through the nodes, judging priority by distance from that pole.

Putting It All Together

As an example of how these node groups can be strung together and animated, we can modulate the Minkowski distance of a texture coordinate from the center. The Manhattan distance will generate grid-like patterns; the Euclidean, curvilinear patterns.

That modulated distance is supplied to a color gradient. To convert from a smooth gradient to a distinct pattern, we quantize the value. To add variety, the texture coordinate is rotated on the y-axis and tiled before it is measured.

Tiled Ring Pattern

We can also lump everything into a monolithic group node. Here we create a circle which can be shown as stroke only, fill only or stroke and fill.

While the volumes of all four are similar, the geometries that intersect that volume are different. A challenge when creating patterns, as this quote from Edwin A. Abbott’s Flatland reminds us,

You cannot indeed see more than one of my sections, or Circles, at a time; for you have no power to raise your eye out of the plane of Flatland; but you can at least see that, as I rise in Space, so my sections become smaller. See now, I will rise; and the effect upon your eye will be that my Circle will become smaller and smaller till it dwindles to a point and finally vanishes.

is that we must consider the play of volume against surface.

To see how we can apply this pattern to objects created with Python, we next generate a ring of material samples.

Generating Swatches

To facilitate animation within our material we create a function that inserts a keyframe for an input socket’s default value.

Because we don’t want a keyframe inserted when the value is constant, we only do so if there is more than one entry in frame_entries.

For now, the base color of the principled shader will be linked to a combine HSV node. The colorsys module and Blender’s Color class in the mathutils module may also assist in the setting of colors. A geometry node provides the tangent and normal to the principled shader.

Before we can append nodes to the material, we have to ensure that it uses nodes. We then delete the diffuse shader node. For the sampler itself, we create an empty object that will serve as a parent to the others. We then arrange UV spheres in a ring.

By switching into edit mode upon creating each sphere, we can set UV coordinates according to a spherical projection. We toggle back into object mode and set the sphere’s shading to smooth. Next, we add the pattern.

So as to rotate the pattern based on a swatch’s position in the ring, we add a rotatey group node. The higher a principled shader’s Transmission the closer its appearance will be to that of glass. The stroke of the ring pattern is characterized by a rough metallic look. By supplying this value to the material output’s displacement as well, we also give the stroke a raised edge.

A shaped-absorption and shaped-light node are thrown into the mix. A map node governs the temperature range, which in turn changes the light’s color based on the horizontal position of the input vector.

The absorption volume is shaped by the fill of the ring pattern, while the light is governed by the stroke.

To mix in a little chaos with form, we add a noise and voronoi texture. The noise texture’s factor feeds into the voronoi’s scale, which in turn scales the vector supplied to the ring group.

The full Python script for this sampler can be found here.

Conclusion

Many possibilities open up from here. We could translate techniques from Vivo and Lowe’s Book of Shaders, draw inspiration from GenerateMe’s Folds, or develop signed distance fields based on Inigo Quilez’s research. Hundreds of procedurally generated textures have been shared on Blender Artists; groups like Blender procedural textures provide forums to ask questions and share experiments. The concept of generating material samplers has recently been combined with artificial intelligence in a recent paper, “Gaussian Material Synthesis” by Zsolnai-Fehér, Wonka and Wimmer.

--

--