Skip to main content
GDN8 PATIO

TSL / NodeMaterial

TSL (Three.js Shading Language) is a Three.js system for assembling shader logic in JavaScript. Instead of writing GLSL or WGSL as strings you connect values and calculations as Nodes and the renderer generates shaders from that graph.

NodeMaterial is where those Nodes connect to a Three.js Material. If TSL is how you describe calculations NodeMaterial is the interface that assigns results to color vertex position roughness and similar slots.

Position as of July 17, 2026

WebGPURenderer and NodeMaterial are at a stage where they can be used in production on new projects under conditions. Three.js still marks WebGPURenderer as experimental and for pure WebGL2 work WebGLRenderer may still be safer. WebGPURenderer falls back to WebGL2 when WebGPU is unavailable. Even then ShaderMaterial and RawShaderMaterial are not available. Materials must be implemented with TSL and NodeMaterial. When adopting this in practice you also need to verify the WebGL2 path with forceWebGL: true on real devices not only the WebGPU path.

Projects with large existing GLSL assets or projects that need strict visual parity across many devices are safer with WebGLRenderer. When a new implementation has a reason to use TSL Compute MRT or newer post-processing and time is available to verify each backend it becomes a candidate. This article explains the structure of NodeMaterial. It does not recommend migrating every project to WebGPURenderer.

Relationship between TSL and NodeMaterial

NodeMaterial is the base class for all Node Materials. In practice choose a Material closer to the goal rather than using the base class directly.

  • MeshBasicNodeMaterial: surfaces that do not receive lights
  • MeshStandardNodeMaterial: PBR surfaces using roughness and metalness
  • MeshPhysicalNodeMaterial: surfaces that include transmission clearcoat and related features
  • SpriteNodeMaterial PointsNodeMaterial and various Line Node Materials: processing specific to those draw primitives

For an object that receives lights and an environment map start with MeshStandardNodeMaterial. A custom look does not mean you must replace the entire vertex and fragment shaders from the start. Being able to keep the existing lighting model and swap only the needed parts into Nodes is a major advantage of NodeMaterial.

import * as THREE from 'three/webgpu';
import { color } from 'three/tsl';

const material = new THREE.MeshStandardNodeMaterial( {
	color: 0xffffff,
	roughness: 0.55,
	metalness: 0.1
} );

material.colorNode = color( 0x5d72ff );

What is replaced here is the base surface color. The Material’s response to lights and environment maps remains.

Where to connect on the Material

NodeMaterial provides connection points by role. Common ones are:

Property What it replaces
colorNode Material color and color map
opacityNode Opacity and alpha map
positionNode Vertex position in local space
normalNode Normals used for lighting
emissiveNode Emissive color
roughnessNode Roughness on MeshStandardNodeMaterial
metalnessNode Metalness on MeshStandardNodeMaterial
fragmentNode The entire built-in fragment stage
vertexNode The entire built-in vertex stage

Narrower slots such as colorNode or roughnessNode make it easier to keep standard Material behavior. fragmentNode and vertexNode offer more freedom but replace the built-in stages. Start with the closest slot for the goal and reserve full replacement for when it is truly needed.

Build color and surface from the same Node

In TSL uv() and time are also Nodes. You can chain calculations and reuse the result on multiple Material properties.

import * as THREE from 'three/webgpu';
import { color, mix, time, uv } from 'three/tsl';

const material = new THREE.MeshStandardNodeMaterial();

const band = uv().y
	.mul( 16 )
	.add( time.mul( 2 ) )
	.sin()
	.mul( 0.5 )
	.add( 0.5 );

material.colorNode = mix(
	color( 0x171a20 ),
	color( 0xff7657 ),
	band
);

material.roughnessNode = band.oneMinus().mul( 0.7 ).add( 0.15 );

band is a stripe that oscillates between 0 and 1. The same value drives color mixing and roughness so brightness and reflection do not drift apart. Defining one relationship as a Node and expanding it into color and surface is an advantage of building Materials as a graph.

Setting colorNode replaces the original material.color * material.map. To process existing color or textures as a base use materialColor.

import { materialColor } from 'three/tsl';

material.colorNode = materialColor.mul( band.mul( 0.3 ).add( 0.7 ) );

This keeps the Material’s configured color and map and only adds brightness variation. Node properties replace existing values rather than adding to them. Keeping that in mind early helps avoid unintended double work.

Import Node functions such as noise

In TSL shader-oriented operations can also be imported as JavaScript functions. Three.js itself includes triNoise3D for generating 3D noise.

import {
	color,
	mix,
	positionLocal,
	time,
	triNoise3D
} from 'three/tsl';

const noise = triNoise3D(
	positionLocal.mul( 2 ),
	0.25,
	time
).saturate();

material.colorNode = mix(
	color( 0x16181d ),
	color( 0xb9ff66 ),
	noise
);

The result of triNoise3D() is also a Node. You can feed it into color mixing pass it to positionNode to move vertices or pass it to roughnessNode to change surface reflection.

With ShaderMaterial you embed a GLSL noise function in the shader string and wire arguments and return values yourself. With TSL you import implemented Node functions and compose them like any other Node. The same idea applies beyond noise to UV transforms color adjustment blending fog and screen coordinates.

External libraries such as tsl-textures also provide procedural textures as Node functions. Bringing features in at the function level rather than the Material level makes reuse across expressions easier.

Easy imports do not make GPU work free. Cost still depends on how many noise layers you stack and how many vertices or pixels you evaluate. With external libraries also check compatibility with the Three.js version in use.

Move vertices with positionNode

Vertex deformation follows the same idea. positionLocal is the local-space vertex position after Material processing. Use this Node as the starting point when you want to keep the original shape and add deformation.

import { normalLocal, positionLocal, time } from 'three/tsl';

const wave = positionLocal.x
	.mul( 4 )
	.add( time.mul( 1.5 ) )
	.sin()
	.mul( 0.08 );

material.positionNode = positionLocal.add( normalLocal.mul( wave ) );

Each vertex moves along its local normal. You do not rebuild model-view-projection transforms yourself as you would when writing the same logic in ShaderMaterial. You describe only the change in vertex position.

Even if vertices move a lot on the GPU normals and bounding data on the Geometry are not rebuilt automatically. For large deformations correct normals with normalNode and also check the bounding sphere used for frustum culling and how shadows look.

Pass dynamic values with uniform()

For values updated from JavaScript such as scroll position or pointer coordinates use uniform(). You keep the Node structure and change only the values sent to the GPU.

import { color, mix, smoothstep, uniform, uv } from 'three/tsl';

const progress = uniform( 0 );
const edge = smoothstep(
	progress.sub( 0.08 ),
	progress.add( 0.08 ),
	uv().x
);

material.colorNode = mix(
	color( 0x111318 ),
	color( 0xd8ff4f ),
	edge
);

function update( scrollProgress ) {
	progress.value = scrollProgress;
}

Update progress.value instead of creating new Nodes or Materials every frame. The same uniform can be shared across Materials and post-processing. Separating the graph that defines appearance from values that change in the application clarifies the range of motion control.

Differences from ShaderMaterial / RawShaderMaterial

ShaderMaterial writes vertex and fragment stages directly in GLSL. You control the full draw path but also take on more of the wiring for lighting fog shadows and tone mapping. Rewriting built-in Materials with onBeforeCompile() also depends on insertion points in Three.js internal shader chunks.

RawShaderMaterial also writes GLSL directly but Three.js does not auto-prepend built-in uniform and attribute definitions. Use it when you want to manage the full source including those declarations.

NodeMaterial swaps processing in semantic units such as color position normal and roughness. The Node System analyzes the connected graph and generates WGSL for WebGPU or GLSL for the WebGL 2 backend. That does not mean performance or results are identical across backends but Material logic can be separated from backend-specific strings.

The same gradient in two styles

Compare a simple case that maps UV bottom to black and top to white. To keep conditions equal without lighting the NodeMaterial side uses MeshBasicNodeMaterial.

With ShaderMaterial UV is passed from the vertex shader to the fragment shader and color is interpolated with GLSL mix(). This example assumes WebGLRenderer.

import * as THREE from 'three';

const material = new THREE.ShaderMaterial( {
	vertexShader: /* glsl */`
		varying vec2 vUv;

		void main() {
			vUv = uv;
			gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
		}
	`,
	fragmentShader: /* glsl */`
		varying vec2 vUv;

		void main() {
			vec3 color = mix( vec3( 0.0 ), vec3( 1.0 ), vUv.y );

			gl_FragColor = vec4( color, 1.0 );
		}
	`
} );

With NodeMaterial uv().y connects directly into the color mix. Model-view-projection transforms and UV plumbing are assembled by the Node System. This example assumes WebGPURenderer.

import * as THREE from 'three/webgpu';
import { color, mix, uv } from 'three/tsl';

const material = new THREE.MeshBasicNodeMaterial();

material.colorNode = mix(
	color( 0x000000 ),
	color( 0xffffff ),
	uv().y
);

Both codes perform the same calculation. ShaderMaterial describes whole shader stages. NodeMaterial describes only the color you want to change.

Adding light influence also shows the difference. With ShaderMaterial you receive light data and implement a reflection model. With NodeMaterial you switch from MeshBasicNodeMaterial to MeshStandardNodeMaterial keep the same colorNode and reuse the existing PBR lighting.

The same vertex deformation in two styles

Next is an example that moves vertices in a wave along the local normal. Use a Geometry with enough vertices to see the deformation.

With ShaderMaterial you build the deformed position from position and normal then apply model-view-projection. The fragment stage simply outputs white for comparison.

import * as THREE from 'three';

const material = new THREE.ShaderMaterial( {
	vertexShader: /* glsl */`
		void main() {
			float wave = sin( position.x * 4.0 ) * 0.1;
			vec3 transformed = position + normal * wave;

			gl_Position = projectionMatrix
				* modelViewMatrix
				* vec4( transformed, 1.0 );
		}
	`,
	fragmentShader: /* glsl */`
		void main() {
			gl_FragColor = vec4( 1.0 );
		}
	`
} );

With NodeMaterial you connect the deformed local position to positionNode. Coordinate transforms are filled in by the Node System.

import * as THREE from 'three/webgpu';
import { normalLocal, positionLocal } from 'three/tsl';

const material = new THREE.MeshBasicNodeMaterial();
const wave = positionLocal.x.mul( 4 ).sin().mul( 0.1 );

material.positionNode = positionLocal.add( normalLocal.mul( wave ) );

Again the calculation is the same. ShaderMaterial describes the full vertex shader. NodeMaterial describes only the changed vertex position. Adding time-based motion means adding time on both sides but ShaderMaterial needs uniform declaration and updates while NodeMaterial connects time from three/tsl into the Node graph.

What increases with RawShaderMaterial

In the previous ShaderMaterial you could use position normal modelViewMatrix and projectionMatrix without declaring them. Three.js adds those definitions at the top of the shader.

With RawShaderMaterial you write all precision attribute and uniform declarations needed for the same vertex deformation.

import * as THREE from 'three';

const material = new THREE.RawShaderMaterial( {
	vertexShader: /* glsl */`
		precision highp float;

		attribute vec3 position;
		attribute vec3 normal;

		uniform mat4 modelViewMatrix;
		uniform mat4 projectionMatrix;

		void main() {
			float wave = sin( position.x * 4.0 ) * 0.1;
			vec3 transformed = position + normal * wave;

			gl_Position = projectionMatrix
				* modelViewMatrix
				* vec4( transformed, 1.0 );
		}
	`,
	fragmentShader: /* glsl */`
		precision highp float;

		void main() {
			gl_FragColor = vec4( 1.0 );
		}
	`
} );

Three.js still uploads Geometry attributes and camera/object matrices to the GPU but you declare which types and names the GLSL side uses. Source is longer than ShaderMaterial but the GLSL entry point is explicit without relying on definitions Three.js injects.

RawShaderMaterial fits when you want to manage existing GLSL including declarations or keep auto-inserted shader code minimal. If you only need standard Three.js matrices and attributes ShaderMaterial is more concise. Both are WebGLRenderer-only and cannot be used with WebGPURenderer.

Pros and cons

NodeMaterial

Pros:

  • Write only the parts you change such as color or vertex position
  • Easier to keep standard Material lights environment maps shadows and related behavior
  • Nodes and uniforms can be shared across Materials and post-processing
  • Node functions such as noise or UV transforms can be imported and connected directly to color or deformation
  • Logic can be split with JavaScript imports and functions without string replacement in shaders
  • The same graph can generate WGSL for WebGPU and GLSL for WebGL 2

Cons:

  • GLSL cannot be pasted as-is. Logic must be decomposed into Nodes for porting
  • Generated shaders sit in the middle so the full pipeline is harder to follow than writing GLSL directly
  • Distinguishing ordinary JavaScript computation from Nodes evaluated on the GPU takes practice
  • Available features and performance differ between WebGPU and WebGL 2 so both backends need verification
  • WebGPURenderer and TSL keep evolving so API tracking is required across Three.js versions

Short Node code and cheap GPU work are not the same. Even with a simple graph texture lookups branches and vertex counts still cost. Judge final performance from the generated path and real devices.

ShaderMaterial

Pros:

  • Direct GLSL control over vertex and fragment shaders
  • Easier reuse of existing GLSL and WebGL knowledge
  • GPU code and stage-to-stage data flow are explicit in source

Cons:

  • Requires non-visual boilerplate such as transforms varyings and uniforms
  • Lighting fog and shadows expand the amount of wiring to Three.js internals
  • Shader strings are harder to split and compose as JavaScript modules
  • Cannot be used with WebGPURenderer

RawShaderMaterial

Pros:

  • Full control of the GLSL entry point including attribute uniform and precision
  • Can build shaders without depending on built-in definitions Three.js auto-injects
  • Easier to keep structure when porting existing GLSL that includes declarations

Cons:

  • Declarations that ShaderMaterial can omit become required so code grows longer
  • Wrong matrix or attribute declarations lead to compile errors or broken draws
  • Wiring into Three.js standard features must also be managed by you
  • Like ShaderMaterial it only works with WebGLRenderer

WebGPURenderer does not support ShaderMaterial RawShaderMaterial or changing built-in Materials through onBeforeCompile(). When migrating existing WebGL looks it is usually easier to fit NodeMaterial by first splitting the work into roles such as color position normal and post-processing rather than translating GLSL line by line into TSL syntax.

Criteria for using NodeMaterial

NodeMaterial is not only a way to rewrite GLSL more briefly. It is a structure for swapping the parts you need while keeping standard Material processing and reusing that computation in other Materials or post-processing.

In implementation the following order helps keep things clear:

  1. Choose the Node Material closest to the look
  2. Pick the smallest connection such as colorNode or positionNode
  3. Share repeated calculations as Nodes
  4. Make only values that change from outside into uniforms
  5. Consider fragmentNode or vertexNode only where standard processing is not enough

What matters with NodeMaterial is less what you can write and more that you can choose which stage of the Material to change. The point of TSL is being able to rebuild color and light shape and motion as one relationship instead of managing them as separate shader strings.

References

  • ※ Content and code are based on Three.js official documentation as of July 17, 2026.
  • ※ This article is an AI translation of the Japanese original.