Three.js NodeMaterial introduction

Node-based materials have been an experimental part of the three.js library for a few years now under three/examples/js/nodes/, thanks to the efforts of Sunag. There are great examples, but NodeMaterial is still a work in progress and not a drop-in replacement for the default materials yet. Nevertheless, NodeMaterial can already be used to achieve some nice effects, without writing custom materials from scratch.

A classic material, like THREE.MeshStandardMaterial, has a discrete number of inputs (color, opacity, metalness, roughness, ...), each accepting a simple scalar value. Node-based materials have mostly the same inputs, but each input can accept a complex expression. This gives the opportunity to adjust — or even radically alter — how the property behaves. Here's a simple example:

A sphere, with geometry displaced over time in the vertex shader. Source code in an Observable notebook.

The GLSL for this effect is concise. Use the vertex y position and the current time as inputs to a sin() wave, then map that to a scale varying from 0.8 to 1.2, displacing the vertex by +/-20%.

vPosition *= sin( vPosition.y * time ) * 0.2 + 1.0;

Custom effects like this will require us to modify the MeshStandardMaterial shader somehow. With NodeMaterial, we can do that in a declarative way, allowing three.js to build the shader for us:

const material = new THREE.StandardNodeMaterial();

// Basic material properties.
material.color = new THREE.ColorNode( 0xffffff * Math.random() );
material.metalness = new THREE.FloatNode( 0.0 );
material.roughness = new THREE.FloatNode( 1.0 );  

const { MUL, ADD } = THREE.OperatorNode;
const localPosition = new THREE.PositionNode();
const localY = new THREE.SwitchNode( localPosition, 'y' );

// Modulate vertex position based on time and height.
// GLSL: vPosition *= sin( vPosition.y * time ) * 0.2 + 1.0;
let offset = new THREE.Math1Node(
  new THREE.OperatorNode( localY, time, MUL ),
offset = new THREE.OperatorNode( offset, new THREE.FloatNode( 0.2 ), MUL );
offset = new THREE.OperatorNode( offset, new THREE.FloatNode( 1.0 ), ADD );

material.position = new THREE.OperatorNode( localPosition, offset, MUL );

This is more code than the plain GLSL above, but consider what we didn't have to do:

Node-based materials have gained popularity in tools like Shader Forge, Unity, Unreal, Houdini, and Blender, for their expressive flexibility. While those tools provide a user interface for constructing the shader graph, no such UI exists for THREE.NodeMaterial just yet. As an experiment I've explored parsing a node graph created in another tool, Shade for iOS, and converting that to equivalent three.js nodes:

A node-based shader, from the Shade for iOS examples, using an instanced glTF grass mesh with a procedural wind animation. Live demo.

Node-based materials are declarative, optimizable, and composable. They are relatively easy to reuse and share. For example, a developer could write a series of new nodes (composed of the core nodes) for complex behaviors. Published on NPM, those nodes would be accessible to all three.js users:

import { StandardNodeMaterial } from 'three/examples/js/nodes/';
import { GrassWindNode } from '@donmccurdy/three-grass-wind'; // not on NPM.

const material = new THREE.StandardNodeMaterial();
material.position = new GrassWindNode({ windSpeed: 5.0 });

In time I'm hopeful that node-based materials will encourage more creative and reusable materials in the three.js community, and enable third-party libraries like THREE.BAS to integrate more easily with the three.js core library.