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:
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 ),
THREE.Math1Node.SIN
);
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:
- Decide where in the shader to inject the new uniform inputs.
- Decide where in the shader to inject the animation.
- Deal with broken code in future releases of three.js, because some minor change in the source shader broke a regular expression used to inject things.
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:
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.