Node materials - SethKitchen/ThreeMojo GitHub Wiki
materials/nodes.mojo and materials/glsl.mojo. A node material replaces parts of a surface's shading with a small graph of nodes: three.js's node materials and its Three Shading Language (TSL). You build a NodeGraph, or you compile GLSL source to one. You compile the graph to a NodeProgram, add the program to assets.programs, and give its id to a Material.

examples/graph.mojo draws this picture. TSL functions builds three.js's function library on a graph.
three.js: NodeMaterial and its outputs, from colorNode to depthNode. TSL's uniform, If, Loop, Fn, Discard, varying, dFdx, dFdy and its math. MaterialX's noise. ShaderMaterial and RawShaderMaterial in a subset of GLSL.
var graph = NodeGraph()
var tint = graph.uniform("tint", Color(255, 120, 40))
var wave = graph.sin(graph.mul(graph.time(), graph.float(2)))
var mixed = graph.mix(graph.vertex_color(), tint, graph.mul(wave, graph.float(0.5)))
graph.set_output(COLOR_NODE, mixed)
graph.set_output(OUTPUT_NODE, graph.mul(graph.lit(), graph.vec3(1, 0.9, 0.8)))
var program = assets.programs.add(graph.compile())
var paint = assets.materials.add(
Material(Color(255, 255, 255), kind=STANDARD, roughness=0.4, nodes=program)
)Every node method returns a NodeRef. Give the refs to the next node. set_output connects a node to one output. compile refuses a graph it cannot run, and returns the program.
Change a uniform between frames, as three.js changes uniform.value:
assets.programs.get(program).set_uniform("tint", Color(40, 120, 255))
renderer.time = Duration(1.5, SECOND)Renderer.time is the value of the time node, three.js's NodeFrame.time. It is zero by default. The caller moves it on between frames.
compile_shader_material(vertex, fragment) compiles a ShaderMaterial's two shaders to one program. Give the program's id to shader_material.
var program = compile_shader_material(
"""
uniform float lift;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 moved = position + normal * lift;
gl_Position = projectionMatrix * modelViewMatrix * vec4(moved, 1.0);
}
""",
"""
uniform sampler2D map;
varying vec2 vUv;
void main() {
if (vUv.x > 0.9) discard;
gl_FragColor = texture2D(map, vUv * 2.0);
}
""",
)
program.set_uniform("lift", Float32(0.1))
program.set_texture("map", texture)
var card = assets.materials.add(shader_material(assets.programs.add(program^)))Pass three.js's material.defines as defines, a list of NAME or NAME value. Each is a #define in both shaders before their first line, so an error still names the author's line.
The compiler lexes, parses and type checks the source, and builds the graph as it parses. shader_graph(vertex, fragment) returns the graph before it is compiled. compile_raw_shader_material reads the shaders as three.js's RawShaderMaterial does. See GLSL source for the subset.
compile_shader_toy(source) compiles a ShaderToy shader, as three.js's ShaderToyDecoder does. Its mainImage colors each pixel of the surface.
var toy = compile_shader_toy(
"""
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 col = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0, 2, 4));
fragColor = vec4(col, 1.0);
}
"""
)
toy.set_uniform("iResolution", Vector3(800, 600, 1))
var screen = assets.materials.add(shader_material(assets.programs.add(toy^)))fragCoord is the pixel's center, in pixels from the bottom left. iTime is Renderer.time. Set the other inputs yourself: iResolution, iTimeDelta, iFrameRate, iFrame, iMouse, iDate, iSampleRate, iChannelResolution[i] and iChannelTime[i] with set_uniform. Set iChannel0 to iChannel3 with set_texture. The source must fit the subset, and an error names the line of the source.
A material can be lit by some of the scene's lights alone. This is three.js's material.lightsNode = lights( [ light1, light2 ] ). Set the material's lights to a LightMask from lights_of, with the lights' places in scene.lights:
var red_only = Material(Color(255, 255, 255))
red_only.lights = lights_of([0])ALL_LIGHTS, the default, is every light. A mask names the first 64 lights. The mask works on any material, with a node graph or without one.
The renderer resolves the lights of each mask that a material names, as a Lighting of its own. The lights keep their shadows. light_masks(assets) lists the masks in the order the renderer uses. On the GPU, each mask's lights go up as a block of their own, and each triangle reads its block. GpuRenderer.draw takes the lightings as lightings.
A material can change the shadows that fall on it and the shadows that it casts. These are three.js's receivedShadowNode and castShadowNode.
Set RECEIVED_SHADOW_NODE, a vec3. The graph runs once for each light that casts a shadow on the surface. The shadow node is a vec3: what that light's shadow lets through, one for all of it. The light's color is multiplied by the output in place of the shadow. This graph turns each shadow red, as three.js's shadow.mix( color( 0xff0000 ), 1 ) does:
var graph = NodeGraph()
graph.set_output(
RECEIVED_SHADOW_NODE,
graph.mix(graph.vec3(1, 0, 0), graph.float(1), graph.shadow()),
)TSL's a.mix( b, t ) is mix( b, t, a ), so the example is mix( red, 1, shadow ). A graph that sets vec3(1, 1, 1) takes the shadows away. A light that casts no shadow on the surface does not run the graph. Only RECEIVED_SHADOW_NODE can read shadow.
Set the renderer's shadow_map_transmitted to True. This is three.js's renderer.shadowMap.transmitted. The shadow pass then keeps a color for each texel: what the light sees through the nearest caster there. By default that color is opaque black. Set CAST_SHADOW_NODE, a vec4, to give a caster a color and an alpha of its own:
var graph = NodeGraph()
graph.set_output(
CAST_SHADOW_NODE, graph.join([graph.vec3(1, 0, 0), graph.float(1)])
)
renderer.shadow_map_transmitted = TrueThe map's alpha and the alpha map's green multiply the alpha. The caster's alpha test, mask and depth node apply. A texel that no caster covers is clear, with an alpha of zero.
A surface in the shadow then gets mix( 1, mix( color, 1, shadow ), intensity * alpha ) of the light, per channel. The color is the texel's, read with a linear filter. So an opaque red caster lets red through, and a caster with no alpha casts no shadow. An opaque black caster casts the shadow that the depths alone cast. See lights.shadow.transmitted_shadow.
The shadow pass runs on the host, under its own shading mode, SHADE_SHADOW. set_shading and GpuRenderer.draw refuse that mode. The GPU reads the colors of each map from the light buffer, as it reads the depths.
Two of three.js's node materials have a lighting model of their own. MeshSSSNodeMaterial lets light through a surface from behind. VolumeNodeMaterial gathers light and shadow along a ray through a mesh. Both rasterizers run both models with the same functions, and the parity tests in tests/test_gpu.mojo hold them to the same pixels.
A PHYSICAL material whose graph sets THICKNESS_COLOR_NODE is three.js's MeshSSSNodeMaterial. Each light with a direction then shows through the surface toward the camera. Put the thickness in the color, as three.js's examples do:
var graph = NodeGraph()
var thickness = graph.swizzle(graph.texture(thickness_map, graph.uv()), "r")
graph.set_output(
THICKNESS_COLOR_NODE, graph.mul(graph.vec3(1, 0.4, 0.2), thickness)
)
graph.set_output(THICKNESS_SCALE_NODE, graph.float(16))
var program = assets.programs.add(graph.compile())
var skin = assets.materials.add(
Material(Color(255, 200, 180), kind=PHYSICAL, roughness=0.5, nodes=program)
)Each directional, point and spot light adds this light to the direct diffuse light:
(pow(saturate(dot(V, -H)), power) * scale + ambient) * color * attenuation * light
V is the direction to the camera. H is the direction to the light, bent along the normal by distortion and made unit length. The five numbers are the five other thickness outputs. An output that the graph does not set takes three.js's default.
The surface's color does not tint this light, and the one over pi of the diffuse lobe does not scale it. The shadows darken it, as they darken the light. The functions are in materials.mesh_sss_node_material.
A STANDARD material ignores the thickness outputs, as three.js's MeshStandardNodeMaterial ignores them. For a PHONG surface, use Material.set_scattering. See Materials.
A VOLUME material ports VolumeNodeMaterial within the limits below. Its ray gathers point, spot, and rectangle lights. Build it with volume_node_material:
var graph = NodeGraph()
var noise_map = graph.volume_uniform("noise", noise)
var at = graph.fract(graph.mul(graph.position_world(), graph.float(0.1)))
var grain = graph.swizzle(graph.texture_3d(noise_map, at), "r")
graph.set_output(SCATTERING_NODE, graph.add(graph.float(0.5), grain))
var fog = assets.materials.add(
volume_node_material(steps=20, nodes=assets.programs.add(graph.compile()))
)The material is drawn from its back faces, blended, with no depth test and no depth write, as three.js's is. steps is three.js's steps, 25 by default. Change it with set_steps. A fragment's ray works like this:
- The ray runs from the camera to the fragment when the camera is more than twice the mesh's bounding radius from it. Otherwise the ray runs from the fragment to the camera. The radius is the geometry's bounding sphere in world space, three.js's
modelRadius. - The ray takes
stepsequal steps. The first step isOFFSET_NODEof a step from the start, or at the start. - At each step, each point light and each spot light adds its color, times its falloff, beam, and map. Its shadow multiplies it twice. A rectangle adds the r186 volume LTC form factor times radiance, raised to the power 1.5 per channel. The form factor uses camera-space corners and the emitting side only. A directional light adds nothing.
-
SCATTERING_NODEmultiplies that density. In this output,position_world()is the position of the step. three.js hands the same position toscatteringNodeaspositionRay. - What the ray lets through is multiplied by
exp(-density * 0.01 * step)at each step, which is Beer's law.
An exactly zero-width or zero-height rectangle contributes no volume light. Its area is zero. The shared CPU and GPU helper returns zero before evaluating its edges, so rounding cannot leave a spurious contribution. This correction uses no near-zero cutoff. Nondegenerate rectangles retain the r186 formula.
An IES profile or projector frame shapes the beam at each ray step, as it does on a surface. An IES light with no profile uses its cone. See Lighting addons.
The surface's color is one minus what the ray lets through, plus EMISSIVE_NODE. Its alpha is the material's. A volume refuses an emissive color and an emissive map, because three.js's material has neither. The functions are in materials.volume_node_material.
Set scene_depth=True in volume_node_material, or call
material.set_volume_scene_depth(True), to gate scattering against opaque
scene depth. The default is False, which keeps the existing ungated ray.
Only a VOLUME material can enable this input. DEPTH_NODE keeps its generic
fragment-depth contract on every material. It does not enable this gate.
The renderer captures depth after opaque draws survive their depth, stencil,
mask, and alpha tests. The capture excludes all volumes, all blended draws,
and transmissive draws. It occurs before transmissive back faces. Later depth
writes cannot change this snapshot, including writes by a volume whose
depth_write or blend settings were changed.
The capture uses the current scene. Depth left in a caller-owned target by
an earlier render is not an extra occluder, even with auto_clear=False.
The snapshot holds signed camera-axis distances in meters. Positive values are in front of the camera. Each fragment reads its exact raster pixel from the top-left origin. Depth has no bilinear filter or mip level. Supersampling captures at the larger raster size before color resolve. Resize, viewport, and scissor settings apply to the opaque capture too.
Standard NDC depth, reversed depth, and logarithmic depth reconstruct the same camera-axis distance. Perspective and orthographic cameras are supported. A clear pixel uses the camera's far distance.
Each original ray sample at or before the captured distance contributes. The gate preserves step size, offset, and direction. It does not move the ray start to the near plane. Camera clipping still removes geometry outside the view.
The orthographic gate corrects a mixed-space comparison in r186. With near 1 meter and far 9 meters, a sample at distance 2 is before an opaque surface at distance 3. r186 compares perspective sample depth 0.5625 with orthographic scene depth 0.25 and rejects that sample. This port compares axial distances and admits it.
The same r186 expression also mixes depth spaces for reversed perspective cameras. With near 1 meter and far 9 meters, a sample at distance 3 has forward window depth 0.75. Reversed inversion reads that as distance 9/7, so r186 can admit it through an opaque surface at distance 2. This port rejects it. Independent projection and ordering tests cover both corrections. The source equations are in r186 VolumetricLightingModel and ViewportDepthNode.
Low-level rasterizer callers must pass a matching TransmissionTarget with
capture_volume_depth(opaque_target, to_screen, far) applied before drawing
volumes. to_screen maps camera space to raster pixels. Missing, nonfinite,
or wrong-sized snapshots raise an error before drawing. A reused GPU target
uploads the new snapshot on each draw.
CPU and GPU use one ray gate and one rectangle-light calculation. The
source-bound numerical fixtures target three.js r186. They check per-step
light and depth decisions independently. They do not establish complete
rendered r186 parity. This port retains one minus transmittance plus
EMISSIVE_NODE; r186 separately accumulates outgoing ray light and supports
ray emission. Those differences remain outside this feature.
tools/reference_volume_lighting.mojo writes the fixture
assets/volume_lighting/r186.json with --write and verifies it with
--check.
A graph sets one to twenty-five outputs. Each output replaces one part of the material's own shading. The material keeps every part that the graph does not set.
| Output | Type | three.js | What it replaces |
|---|---|---|---|
COLOR_NODE |
vec3 |
colorNode |
The diffuse color: the material's color, its vertex colors and its map. The lights multiply it. |
OPACITY_NODE |
float |
opacityNode |
The alpha: the opacity, the map's alpha and the alpha map. The alpha test reads it. |
EMISSIVE_NODE |
vec3 |
emissiveNode |
The light the surface gives off: the emissive color and the emissive map. |
NORMAL_NODE |
vec3 |
normalNode |
Nothing. It is an offset added to the world-space normal before the lights read it. |
POSITION_NODE |
vec3 |
positionNode |
Nothing. It is an offset added to each vertex's local position. |
OUTPUT_NODE |
vec3 |
outputNode |
The finished color, after the lights, the reflection and the fog. The lit node reads that color. |
MASK_NODE |
float |
maskNode |
Nothing. A fragment where it is zero is thrown away, before the alpha test. |
AO_NODE |
float |
aoNode |
The ambient occlusion map's value. It dims the indirect light. |
DEPTH_NODE |
float |
depthNode |
The fragment's depth: zero at the near plane, one at the far plane. The depth test reads it. |
BACKDROP_NODE |
vec3 |
backdropNode |
The diffuse light, mixed by BACKDROP_ALPHA_NODE where that is set. The specular light and the glow are added after it. See The scene behind. |
BACKDROP_ALPHA_NODE |
float |
backdropAlphaNode |
Nothing. It is how much of the backdrop replaces the diffuse light, zero to one. |
FRAGMENT_NODE |
vec4 |
fragmentNode |
All of the material's shading: the color and the alpha. The fog veils it. The alpha test, the alpha hash and the output node do not run. |
ROUGHNESS_NODE |
float |
roughnessNode |
A standard or physical surface's roughness, its map included. |
METALNESS_NODE |
float |
metalnessNode |
A standard or physical surface's metalness, its map included. |
SIZE_NODE |
float |
sizeNode |
A point's width in pixels: the material's size and its attenuation. Only a point reads it. See Points and lines. |
RECEIVED_SHADOW_NODE |
vec3 |
receivedShadowNode |
Each light's shadow on the surface. The shadow node reads it. See Shadows of its own. |
CAST_SHADOW_NODE |
vec4 |
castShadowNode |
The opaque black that a light sees through a caster, under shadow_map_transmitted. |
THICKNESS_COLOR_NODE |
vec3 |
thicknessColorNode |
Nothing. It lets light through a PHYSICAL surface from behind. See Subsurface scattering. |
THICKNESS_DISTORTION_NODE |
float |
thicknessDistortionNode |
The default 0.1. |
THICKNESS_AMBIENT_NODE |
float |
thicknessAmbientNode |
The default 0. |
THICKNESS_ATTENUATION_NODE |
float |
thicknessAttenuationNode |
The default 0.1. |
THICKNESS_POWER_NODE |
float |
thicknessPowerNode |
The default 2. |
THICKNESS_SCALE_NODE |
float |
thicknessScaleNode |
The default 10. |
SCATTERING_NODE |
float |
scatteringNode |
Nothing. It multiplies the density at each step of a VOLUME surface's ray. See Volumetric light. |
OFFSET_NODE |
float |
offsetNode |
Nothing. It moves the start of a VOLUME surface's ray by a part of one step. |
The alpha of the finished color stays what the fragment had. An output node changes only its color.
A float next to a vector is repeated into every component, as in GLSL. A condition is a float: zero is false and every other value is true. set_input(node, slot, source) rewires one input of a node, as a node editor does.
| Method | Type | three.js | Value |
|---|---|---|---|
float(x), vec2, vec3, vec4
|
as named |
float(), vec2() and the rest |
A constant. |
uint(UInt32), uvec2, uvec3, uvec4
|
as named |
uint(), uvec2() and the rest |
Exact unsigned 32-bit constants. |
int32(Int32) |
int |
int() |
An exact signed 32-bit constant. |
uniform_uint(name, value) |
unsigned scalar or vector | uniform() |
Exact unsigned values. Update with set_uniform_uint; read with read_unsigned. |
color(Color) |
vec3 |
color() |
A constant color, decoded from sRGB to linear. |
uniform(name, value) |
as the value | uniform() |
A named value the caller can change between frames. The value is a Float32, a Vector2, a Vector3, a Vector4, a Color, a Matrix3 or a Matrix4. |
texture_uniform(name, map) |
texture |
texture(map) |
A named texture the caller can change with set_texture. |
uv() |
vec2 |
uv() |
The coordinate the material's map is read at, through its transform. |
position_world() |
vec3 |
positionWorld |
The fragment's position in world space, in meters. |
position_view() |
vec3 |
positionView |
The fragment's position in the camera's space. The camera looks down minus z. |
normal_world() |
vec3 |
normalWorld |
The fragment's unit normal in world space, after any normal or bump map. |
normal_view() |
vec3 |
normalView |
The fragment's unit normal in the camera's space. |
vertex_color() |
vec3 |
materialColor times vertexColor()
|
The interpolated corner color: the material's color times the vertex colors. |
position_local() |
vec3 |
positionLocal |
The vertex's position in the model's space. A position node only. |
normal_local() |
vec3 |
normalLocal |
The vertex's normal in the model's space. Zero if the geometry has no normals. A position node only. |
camera_position() |
vec3 |
cameraPosition |
The camera's position in world space, from the frame's view. |
camera_view_matrix() |
mat4 |
cameraViewMatrix |
The frame's world-to-camera matrix. |
time() |
float |
time |
Renderer.time, in seconds. |
attribute(name, type) |
as named | attribute(name) |
A custom attribute of the geometry, a float or a vector, interpolated at the fragment. See Custom attributes. |
front_facing() |
float |
frontFacing |
One where the triangle is seen from its front, and zero where it is seen from its back. A fragment only. |
face_direction() |
float |
faceDirection |
One where the triangle is seen from its front, and minus one where it is seen from its back. A fragment only. |
gl_front_facing() |
float |
GLSL's gl_FrontFacing
|
As front_facing(), but one on every face that a BACK_SIDE material draws. See Facing. A fragment only. |
frag_coord() |
vec4 |
GLSL's gl_FragCoord
|
The pixel's center in pixels from the bottom left, the depth from zero to one, and one. A fragment only. |
screen_uv() |
vec2 |
screenUV |
Where the pixel is on the target: zero to one across from the left edge and up from the bottom edge, at the pixel's center. A fragment only. |
viewport_texture(uv) |
vec4 |
viewportSharedTexture(uv) |
The opaque scene behind the surface at a place on the target, linear, with straight alpha. See The scene behind. A fragment only. |
point_coord() |
vec2 |
GLSL's gl_PointCoord
|
Where the pixel is in its point: zero to one across from the left edge and down from the top edge. Zeros on a triangle and a line. A fragment only. |
texture(map, uv) |
vec4 |
texture(map, uv) |
A texture read at a vec2 coordinate, linear, with straight alpha. map is a TextureId or a texture uniform. |
cube_uniform(name, map) |
cubeTexture |
cubeTexture(map) |
A named cube texture the caller can change with set_cube. |
texture_cube(sampler, direction) |
vec4 |
cubeTexture(map, dir) |
A cube texture read in a vec3 direction of any length, linear, with straight alpha. The face the direction points at is read where it points. |
volume_uniform(name, map) |
texture3D |
texture3D(map) |
A named 3D texture from Assets.data_3d_textures. The caller can change it with set_volume. |
texture_3d(sampler, at) |
vec4 |
texture3D(map, uvw) |
A 3D texture read at a vec3 from zero to one on each axis, linear, with straight alpha. The texture's own wrap modes and filter apply. |
array_uniform(name, map) |
textureArray |
GLSL's sampler2DArray
|
A named array texture from Assets.data_array_textures. The caller can change it with set_array. |
texture_array(sampler, at) |
vec4 |
GLSL's texture of a sampler2DArray
|
An array texture read at a vec3 of u, v and the layer. The layer is rounded to the nearest and held inside the stack. Two layers are never blended. |
texture_level(map, uv, level) |
vec4 |
texture(map, uv).level(n) |
As texture, at the mip level that a float gives. Level zero is the full-size image. A texture with one level reads it at every level. |
texture_grad(map, uv, dx, dy) |
vec4 |
textureGrad |
A 2D texture read with explicit vec2 gradients, with straight alpha. map is a texture ID or texture uniform. |
texture_load(map, at, level) |
vec4 |
textureLoad(map, at, level) |
The texel of a texture uniform at a column and a row, counted as uv counts. A coordinate outside the image wraps, and a level outside the chain is held inside it. |
texture_size(map, level) |
vec2 |
textureSize(map, level) |
A texture uniform's width and height at a level, held inside the chain. |
lit() |
vec3 |
output |
The color that the material's own shading made. An output node only. |
| Method | Type | three.js |
|---|---|---|
add, sub, mul, div
|
the wider operand | same names |
min, max, mod, pow, step, atan2(y, x)
|
the wider operand | same names; atan(y, x)
|
mix(a, b, t), clamp(x, low, high), smoothstep(low, high, x)
|
the widest operand | same names |
abs, sign, floor, ceil, round, trunc, fract
|
the operand | same names |
sin, cos, tan, asin, acos, atan, radians, degrees
|
the operand | same names |
exp, exp2, log, log2, sqrt, inverse_sqrt, reciprocal
|
the operand |
inverseSqrt for the sixth |
negate, one_minus, saturate
|
the operand |
negate, oneMinus, saturate
|
dot(a, b), distance(a, b), length(a)
|
float |
same names |
normalize(a), reflect(i, n), refract(i, n, eta), faceforward(n, i, nref)
|
the operand |
faceForward for the last |
cross(a, b) |
vec3 |
cross |
remap(x, in_low, in_high, out_low, out_high) |
the widest operand | remap |
swizzle(a, "zyx") |
as long as the string | a.zyx |
join([a, b]) |
the sum of the widths |
vec3(a, b) of nodes |
integer(a), unsigned(a)
|
the operand |
int, uint
|
to_uint(a), to_int(a)
|
exact integer scalar or vector | explicit integer constructors |
uint_to_float(a), int_to_float(a)
|
float scalar or vector | explicit float constructors |
bit_and, bit_or, bit_xor, shift_left, shift_right
|
the wider operand |
bitAnd, bitOr, bitXor, shiftLeft, shiftRight
|
bit_not(a) |
the operand | bitNot |
The legacy integer and unsigned helpers return floats that hold whole numbers. integer truncates toward zero. unsigned also wraps a negative number up by 2 ** 32.
A bit operation on legacy float values truncates each component to a 32-bit integer, operates on it, and holds the result as a float again. A shift reads the low five bits of its count, as a GPU does. NaN is zero, and a number past 32 bits is held at the end of the range. A float holds a whole number exactly up to 2 ** 24. Exact integer nodes use separate types and bit-preserving operations. See The subset.
round takes a half to the even whole number. normalize leaves a zero vector at zero, where GLSL leaves it undefined. A division by zero gives what IEEE 754 gives, as a GPU does. mul of a mat3 or a mat4 and a vector of its width multiplies the matrix and the vector, on either side.
| Method | Type | three.js |
|---|---|---|
mat3(c0, c1, c2), mat4(c0, c1, c2, c3)
|
mat3, mat4
|
same names, of column nodes |
column(m, i) |
vec3 or vec4
|
m[i] in GLSL |
transpose(m), inverse(m)
|
the operand | same names |
determinant(m) |
float |
determinant |
mul also multiplies two matrices of one size. A register holds four floats, so a matrix is not a value an instruction writes. A matrix that the graph builds is a list of its columns, and mul expands it into vector operations. A uniform's columns are the uniform times each unit vector. inverse makes each row at right angles to every column but one, and scales it so that its dot with that one is one. A singular matrix gives what the divisions by zero give.
| Method | three.js |
|---|---|
less_than, less_than_equal, greater_than, greater_than_equal, equal, not_equal
|
lessThan and the rest |
logical_and, logical_or, logical_xor, logical_not
|
and, or, xor, not
|
select(condition, a, b) |
select |
Each gives one or zero per component. select gives a where the condition is not zero and b where it is.
| Method | three.js |
|---|---|
perlin_noise(p) |
mx_perlin_noise_float |
mx_noise_float(texcoord, amplitude, pivot) |
mx_noise_float |
mx_fractal_noise_float(position, octaves, lacunarity, diminish, amplitude) |
mx_fractal_noise_float |
perlin_noise_vec3(p) |
mx_perlin_noise_vec3 |
mx_noise_vec3(texcoord, amplitude, pivot), mx_noise_vec4(texcoord, amplitude, pivot)
|
mx_noise_vec3, mx_noise_vec4
|
cell_noise_float(p), cell_noise_vec3(p)
|
mx_cell_noise_float, mx_cell_noise_vec3
|
worley_noise(p, jitter, width) |
mx_worley_noise_float, _vec2 and _vec3 for a width of 1, 2 and 3 |
The noise is MaterialX's gradient noise, with the same hash and the same order of operations as three.js. The point is a vec2 or a vec3. The fractal noise adds a zero to a vec2, as three.js converts it.
-
perlin_noise_vec3grades each component by one byte of each corner's hash.mx_noise_vec4adds the Perlin noise of one component at the point moved by (19, 73). Avec3point moves by (19, 73, 0). - Cell noise gives one value from zero to one for each cell of whole numbers. The point is a
floatto avec4. - Worley noise gives the squared distances to the nearest points that jitter each cell, nearest first.
MaterialXNodesalways asks for this metric, so the others are not ported.
tools/mx_noise_reference.mojo transcribes three.js's functions in 32-bit floats and gives the values the tests check.
If, ElseIf, Else, Loop, End, Break, Continue, Return, Var and Discard are TSL's statements. The builder records them in the order you call them.
var sum = graph.Var(graph.float(0))
var index = graph.Loop(4)
graph.If(graph.greater_than(index, graph.float(1)))
graph.assign(sum, graph.add(graph.get(sum), index))
graph.Else()
graph.Discard()
graph.End()
graph.End()
graph.set_output(OPACITY_NODE, graph.get(sum))-
Var(value)makes a variable.assign(v, value)changes it, andget(v)reads what it holds at that point. -
If(condition)opens a branch.ElseIfandElsefollow, and oneEndcloses the chain. -
Loop(count, start, step)runs its bodycounttimes and returns the index, afloat.Endcloses it. -
Discard()throws the fragment away where the open branches run. Every discard joins the mask. -
Fn(name, inputs, output, body)is TSL'sFnwith itssetLayout.graph.call(fn, args)checks the arguments and the answer against the layout, and inlines the body. -
Break()leaves the innermost loop where the open branches run.Continue()skips the rest of this time through it. -
Return(value)gives the answer of theFnthat is being built, where the open branches run. The loops that it is in stop.
def first_square_over(mut graph: NodeGraph, args: List[NodeRef]) raises -> NodeRef:
var i = graph.Loop(8)
graph.If(graph.greater_than(graph.mul(i, i), args[0]))
graph.Return(i)
graph.End()
graph.End()
return graph.float(-1)The bytecode has no jumps. An If computes both branches, and each variable that a branch changes becomes a select of the two values. End unrolls a loop: it copies the body once for each run after the first. So every output stays a graph with no cycle.
Break, Continue and Return are flags, not jumps. Each loop holds two hidden variables: live, which a Break clears, and skip, which a Continue sets until the next time through. A Fn call holds returned and its answer. After one of these statements, each assignment and each discard takes effect only where the flags allow it. At its End, a loop keeps each variable as it was where a time through starts after a Break.
varying(a) computes a at each corner of the triangle and interpolates the three values with the fragment's perspective-correct weights. A varying of a node that is not linear across the triangle differs from the node itself. The value can read the world and view position and normal, the coordinates, the vertex color, the time, uniforms and math of them.
dfdx(a) and dfdy(a) are exact per triangle. The compiler lays out a again for the pixel to the right, or the pixel above, with the attributes that the triangle's plane gives there. The derivative is the value there less the value here. A hardware quad takes the difference inside a two by two block instead. fwidth(a) is abs(dfdx(a)) + abs(dfdy(a)).
A derivative reads the fragment's interpolated normal, before any normal map, bump map or normal node. dfdy looks up, as GLSL's window rows count upward.
The GLSL compiler takes a subset of GLSL ES 3.0. It refuses everything outside the subset with the shader, the line and the reason.
compile_shader_material declares what three.js's prefix declares:
- the uniforms
modelMatrix,modelViewMatrix,projectionMatrix,viewMatrix,normalMatrixandcameraPosition; - the attributes
position,normal,uvandcolor; - both spellings:
attribute,varying,texture2Dandgl_FragColor, andin,out,textureandpc_fragColor.
compile_raw_shader_material declares nothing. Each shader declares the built-ins it reads, with three.js's names and types. A raw shader is GLSL ES 1.0 unless its first line is #version 300 es.
The host places each vertex, so the vertex shader must write gl_Position as three.js's own shaders do:
-
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);, or - the same through
projectionMatrix * viewMatrix * modelMatrix, through a local variable, or through a function.
It writes gl_Position once, in main, outside every branch and loop. The point p becomes the position node, an offset from position. p reads position, normal, uniforms, constants and math of them.
A varying that the vertex shader writes becomes a varying node. A corner keeps the world position and normal, the coordinates and the color. So a varying reads these forms:
| GLSL | Node |
|---|---|
uv, color
|
uv(), vertex_color()
|
(modelMatrix * vec4(position, 1.0)).xyz |
position_world() |
(modelViewMatrix * vec4(position, 1.0)).xyz |
position_view() |
normalMatrix * normal |
normal_view(), made unit length |
modelMatrix * vec4(normal, 0.0) |
normal_world() with a zero w
|
mat3(modelMatrix) * normal, and the same through mat3(modelMatrix[0].xyz, modelMatrix[1].xyz, modelMatrix[2].xyz)
|
normal_world() |
viewMatrix * v, cameraPosition
|
camera_view_matrix(), camera_position()
|
The world and view positions are of the point that gl_Position draws.
gl_FragColor, pc_fragColor or the one out vec4 is the color and the opacity. gl_FragDepth is the depth node. discard throws the fragment away. gl_FragCoord is where the fragment is: the pixel's center in pixels from the bottom left, its depth from zero to one, and one for w. three.js's w is one over the clip-space w.
gl_FrontFacing is gl_front_facing(). It is true on every face that a BACK_SIDE material draws. See Facing.
gl_PointCoord is point_coord(), where the pixel is in its point. gl_PointSize in the vertex shader is the size node. It can read the world and view position, the coordinates, the color and the custom attributes. It cannot read position or normal. See Points and lines.
TSL's frontFacing and GLSL's gl_FrontFacing differ for a BACK_SIDE material. three.js's WebGL renderer turns the front face round for such a material, so gl_FrontFacing is true on the faces that it draws. TSL's frontFacing is false on those faces, because they are seen from their back. For FRONT_SIDE and DOUBLE_SIDE, the two agree. A mirrored mesh keeps the same rule, as Side states.
- Types:
void,bool,int,uint,float,vec2tovec4,ivec2toivec4,uvec2touvec4,bvec2tobvec4,mat2,mat3andmat4. Uniforms can also besampler2D,samplerCube,sampler3Dandsampler2DArray.uint,uvec2touvec4,sampler3Dandsampler2DArrayrequire GLSL ES 3.0 in raw shaders. - Uniforms: every type above but
void. Anint, aboolor a vector of them is a uniform of floats that you set. Anintdrops the fraction toward zero, and aboolis true where it is not zero. Useset_uniform_uintfor unsigned uniforms. Set auintwithUInt32. Setuvec2anduvec4withSIMD[DType.uint32, 2]andSIMD[DType.uint32, 4]. Setuvec3with threeUInt32arguments. The pinned Mojo compiler does not lower a three-lane SIMD value. These setters retain every bit and refuse a float uniform. Set amat2uniform with aVector4of its two columns. - Declarations:
uniform,attribute,varying,in,out,constglobals with constant values,precisionstatements, andlayout(...)on an output. - Attributes:
position,normal,uvandcolor, and custom attributes of afloator a vector. See Custom attributes. - Functions: functions with
in,outandinoutparameters. A call inlines the body. Areturncan come before the end of its function. Anoutorinoutargument must be a variable, and it gets the parameter's value when the call ends. - Statements: local variables,
ifandelse,switch, blocks,discard,break,continueand assignments. Compound assignments include+=,-=,*=,/=, integer%=and unsigned&=,|=,^=,<<=and>>=. Numeric scalars and vectors take statement-level++and--. - Loops:
for (int i = a; i < b; i++)with constanta,band step. The index can also be auintor afloat. The condition is<,<=,>,>=or!=. The step is++,--,+=or-=. A loop runs at most 1024 times. -
while (c)anddo { ... } while (c);when the compiler can prove they stop within 256 body executions. The body of adois a block in braces. See While and do. - Expressions: arithmetic, comparison and logical operators, and
?:. Unsigned bit operators are&,|,^,~,<<and>>. Swizzles, indexes, and scalar, vector and matrix constructors are supported. Swizzles usexyzw,rgbaorstpq. - Structs:
struct S { ... };at the top of a shader, of the types above but samplers and of other structs. A struct can be a local variable, aconst, a uniform, an array element, a parameter and a result.S(...)takes one value for each field. A uniform struct's fields are uniforms nameds.a, as three.js names them. - Arrays: local,
constand uniform arrays of one dimension, of at most 256 elements. An initializer isT[n](...)orT[](...).a.length()is the size. A uniform array's elements are uniforms nameda[0],a[1]and on, as three.js names them. - Matrices: a matrix times a vector or a matrix of its size, a column
m[i],transpose,determinantandinverse. Amat2also takes+,-,*and/of each component with amat2or afloat. - Built-ins of one value:
radians,degrees, the trigonometry,exp,log,exp2,log2,sqrt,inversesqrt,abs,sign,floor,ceil,trunc,round,roundEvenandfract. - Built-ins of more values:
pow,mod,min,max,clamp,mix,step,smoothstep,length,distance,dot,crossandnormalize. - Built-ins of comparison:
lessThan,lessThanEqual,greaterThan,greaterThanEqual,equalandnotEqualgive abvec.any,allandnottake abvec.mixtakes aboolor abvecto choose by. - Built-ins of light and surfaces:
faceforward,reflect,refract,dFdx,dFdy,fwidth,texture,texture2D,textureProj,texture2DProj,textureLod,textureGrad,texelFetch,textureSizeandtextureCube.textureof asamplerCubereads it in a direction, astextureCubedoes.textureof asampler3Dor asampler2DArrayreads it at avec3. - The preprocessor:
#versionin a raw shader, object-like#define,#undef, and#if,#ifdef,#ifndef,#elif,#elseand#endif. - An
#ifcondition: whole numbers, macros that are one whole number,defined,!,&&,||, the six comparisons and parentheses. A name that is not a macro is zero, as in the C preprocessor.
A local mat3 or mat4 gets its value where you declare it, and keeps that value. A register holds four floats, so such a local is a name for the matrix that its initializer builds. A mat2 is a vec4 of its two columns, so it is a variable like a vector. break must be in a loop or a switch of the same function, and continue in a loop.
An index can be a signed or unsigned scalar, including a value that is not constant. A chain of selects then picks the element, the component or the column. An index outside the array reads the first element and writes no element. GLSL leaves both undefined. You can write an array's element through such an index, but not a vector's component.
Signed and unsigned integer graph values retain all 32 bits. Registers and program words carry their bit patterns, without floating-point arithmetic. Swizzles, joins, selections, arrays, structs, calls and unrolled loops preserve those bits. Unsigned arithmetic wraps modulo 2 ** 32. Integer division drops the fraction toward zero.
uint(int(x)) preserves every bit of an unsigned x. A conversion to float can round, as GLSL permits. There is no implicit conversion between integer and float types. Write float(i).
An integer literal can be decimal, octal or hexadecimal. The u or U suffix selects unsigned values through 4294967295. Unsuffixed literals in this subset stop at 2147483647. A known float-to-integer conversion must be finite and in range. A negative signed integer converts to unsigned modulo 2 ** 32. A constructor of bvec tests the integer value, including a value whose payload has the floating-point sign bit.
Unsigned min, max, clamp and vector comparisons follow the GLSL signatures. A bit shift keeps the left operand's width. Its count can be signed or unsigned, scalar or the same vector width. Known shift counts outside 0 to 31 and known integer division by zero are refused.
A runtime invalid count, integer division by zero or invalid float-to-integer conversion gives zero. GLSL leaves these cases undefined. The fixed result prevents unsafe host conversions and unbounded GPU work.
The old node helpers integer and unsigned retain their float-backed TSL contract. Exact graph clients use uint, uvec2 to uvec4, int32, to_uint and to_int. GLSL signed integers now use the exact path too. This prevents unsigned-to-signed round trips from losing bits. Signed arithmetic wraps modulo 2 ** 32 in the cases where GLSL leaves overflow undefined.
textureGrad(sampler2D, vec2, vec2, vec2) reads a 2D texture with explicit gradients. The second argument is the sample coordinate. The third and fourth arguments give its change per screen pixel across and up. The result is a vec4. Raw shaders require GLSL ES 3.0. The port rejects other sampler overloads and vertex-stage calls.
NodeGraph.texture_grad has the same sampling behavior. Coordinates and gradients are already placed, as they are for texture_level. A shader that transforms its coordinates must pass the gradients it wants. The sampler does not add the texture's offset, repeat, or rotation a second time. The gradients choose the mip level and anisotropic footprint independently of the triangle's original UVs.
Both rasterizers use anisotropic_footprint. The texture keeps its wrap modes, filters, mip chain, flip_y, color-space conversion, and straight-alpha output. An anisotropic read averages premultiplied taps and takes at most MAX_ANISOTROPY taps, 16. Single-level computation and screen images retain their existing filters.
The compiler rejects nonfinite source-independent gradient expressions. The shared renderer interpreter has no exception channel. A nonfinite runtime coordinate or gradient therefore returns transparent black before a texture read. Large finite tap coordinates use the existing wrap mode before conversion to texel indices. This prevents index overflow without changing the wrap phase.
The shared footprint estimator computes its minor axis from the determinant divided by the major axis. This avoids subtracting nearly equal Gram eigenvalue terms. Compensated Float32 products retain thin footprints without device double precision. Equal axes remain equal, so a round footprint keeps one tap.
A rotated 16:1 footprint exposed cancellation in the old estimator. Its mip level was about 0.00000550343 instead of zero. The correction passes the unchanged texture test and its 0.000001 tolerance. Independent 100-digit controls cover rotations, axis swaps, nearly parallel columns and extreme scales.
The 28 real sampler cases in tests/test_texture_grad.mojo use independent Mesa GLES3 answers. They cover rectangular textures, gradient direction, mip selection, filters, wraps, coordinate transforms and constants. Other tests cover type, version and stage checks, nonfinite inputs, bounded anisotropy, alpha and color space. tests/test_gpu.mojo adds device parity checks. A compile-only result is not device-parity evidence.
Logical && and || gate calls on their right side. A conditional ?: gates its selected arm. Skipped calls do not change out or inout arguments and cannot discard a fragment. Logical ^^ evaluates both sides. Both conditional arms are still parsed and type checked.
A while or a do loop must provably stop within MAX_WHILE_COUNT body executions, 256. The compiler refuses a shader when a loop exceeds this limit or its exit cannot be proved. It never returns a program with a silently truncated loop. This is a bounded subset, not full GLSL loop support.
The bytecode has no jumps or runtime error channel. The compiler unrolls 256 body slots and one final condition-only slot. It checks a shared exhaustion flag after all enclosing loops and function calls are expanded. The flag is checked even when the shader does not use a loop's result.
- A
whileasks its condition before each body and once after the last body, unless abreakorreturnleaves it. - A
doruns its body once before its first condition. Acontinuestill reaches the condition. Abreakskips it. - Loops of 63, 64, 65, 128 and 256 bodies keep their final condition's effects. A finite loop of 257 bodies is refused, as a nonterminating loop is.
- The proof follows exact scalar integer constants and operations, finite scalar float constants, arithmetic, comparisons, logic and selects. It preserves the sign of zero. Uniforms, attributes, texture reads, unsupported operations, nonfinite values and subnormal values remain unknown. An unknown value can still be safe if both paths stop.
- Runtime data in a loop's result can be supported. For example,
while (i < 5) { sum += gain; i++; }can use a uniformgainwhenistarts at zero. The unbounded uniform conditionwhile (i < count)is refused. - The proof is conservative. It can refuse a bounded runtime-dependent exit, such as
while (flag) { break; }. Vector-based exits and unsupported scalar functions can also prevent a proof. A constant-countforis an alternative when its contract fits. Its existing 1024-iteration limit does not change. - Successful scalar proofs remove inactive selections and constant control work. Runtime values remain in the graph. Nested loops can exceed
MAX_GRAPH_NODESbefore the proof finishes. The existing graph, instruction and register limits still apply.
This changes the old contract, which stopped while and do after 64 bodies without an error. Existing callers that depended on truncation or on an unproved exit now get a compile error. The GLSL frontend uses the same validation before either renderer receives a program. It adds no runtime GPU error mechanism.
The reference is three.js r180, which passes shader source to WebGL, and the GLSL ES 3.00 specification. The independent GLSL3 fixtures in tests/test_glsl_loops.mojo use the recurrence i = i + 1, its triangular sum and condition-call counts. They cover 63, 64, 65, 128 and 256 bodies, explicit resource refusals beyond 256, and constant-count for controls. Independent Mesa GLES3 fixtures also cover unsigned boundary values, runtime uniforms and explicit gradients. These fixtures do not establish general expression side-effect parity or full GLSL support. See issue #614.
- A
#include,#pragma,#extension,#error,#line, and a#definewith arguments. A#versionin aShaderMaterial, as three.js writes its own. -
onBeforeCompileand shader chunks: see Why no chunks. - The non-square matrix types, and the samplers other than
sampler2D,samplerCube,sampler3Dandsampler2DArray. - Arrays of arrays, arrays of
mat3ormat4, arrays as varyings, attributes, parameters or fields, and an array read whole. - A struct declared in a function or with its variables, a struct as a varying, and a sampler in a struct.
- A struct's field written through an index that is not constant.
- Global variables that are not
const, and the qualifiersflat,centroidandinvariant. - A custom attribute of
int,boolor a matrix, and custom attributes of more than 8 floats in all. -
position,normal,uvorcolordeclared in aShaderMaterial: three.js declares them. - Recursion. A call inlines its function, so a function that calls itself has no end.
- A
dowhose body is not a block in braces, or awhileordowhose exit within 256 bodies cannot be proved. - A
switchof a value that is not anintoruint. Acaselabel must be a constant of the selector's type. - A declaration directly in a
switch, outside a block. - A
returnbefore the end of a function that returns a matrix, a struct or a transform, or of a vertex shader'smain. - Prototypes, overloads, and functions named like GLSL's own.
- Signed bit operators,
%of floats, and an assignment or++inside an expression. - A for loop that does not declare its index, reads a bound that is not constant, or runs more than 1024 times.
- A matrix times a matrix of another size, and an assignment to a local matrix.
- A sampler in a local variable, and a sampler array's index that is not a constant.
-
modelMatrix,modelViewMatrix,projectionMatrixandnormalMatrixin any form but the ones above, and in a fragment shader. - A column of one of these or of
viewMatrix, and a matrix constructor that reads one, but for the forms ofmat3(modelMatrix)above. - A
gl_Positionin any other form, written twice, in a branch or in a function. - A varying that reads
positionornormal, and a texture read in a vertex shader. - Every
gl_variable butgl_Position,gl_PointSize,gl_FragCoord,gl_FrontFacing,gl_PointCoord, the color outputs andgl_FragDepth.gl_FrontFacingandgl_PointCoordin a vertex shader, andgl_PointSizein a fragment shader. - A
gl_PointSizethat readspositionornormal. A point keeps its world and view position, not its local one. - The built-ins outside the list above, for example
sinh,isnan,outerProduct,floatBitsToUintandtextureOffset. - A vector compared with
<, and a scalar swizzled.
A custom attribute is a geometry attribute that a program reads by its name. A graph declares one with attribute(name, type), and a vertex shader with attribute or in. Each corner carries 8 floats for the custom attributes, so all of them together can hold at most 8 floats.
The renderer reads each attribute from the geometry for each vertex. A missing component is zero, and a missing fourth component is one. A geometry without the attribute gives zeros and one. WebGL fills a missing attribute in the same way.
A custom attribute is carried through a cut at the near plane, as the other attributes are. A position node cannot read one: the host moves each vertex before the corners are made.
onBeforeCompile edits the GLSL of three.js's built-in materials, chunk by chunk. This port's materials are Mojo code, not GLSL chunks. So there is no source for a callback to edit. Write the change as a node graph on the material instead: the outputs replace the parts that the chunks compute.
The graph refuses a type error when you build the node:
- two vectors of different sizes in one operation, for example a
vec3added to avec2; - a matrix or a texture in an operation other than
mulandtexture; - a texture read at a coordinate that is not a
vec2, or withNO_TEXTURE; - a swizzle of a component that the value does not have;
- a join of more than four components;
- an output given a node of the wrong type;
- a uniform with no name, or with the name of another uniform;
- a
NodeRefor aNodeVarthat names nothing in this graph; - an
Ifwhose condition is not afloat, anElseor anEndwith no block, and aLoopcount outside zero to 1024; - a
Fncall whose arguments or answer are not its layout's; - a rewire that changes an input's type or makes a cycle.
compile refuses these:
- a graph with no output and no discard, and a graph with a block that
Endnever closed; - a cycle, or an input that names no node, in a graph whose fields were edited;
- a position node that reads a fragment's attribute, a texture, a varying, a derivative or the lit color;
- a fragment output that reads the local position or the local normal;
- a
litnode in an output other thanOUTPUT_NODE; - a varying that reads a texture, a derivative or the lit color, and a derivative of a derivative;
- an output of more than
MAX_INSTRUCTIONS(4096) instructions, or that needs more thanMAX_REGISTERS(32) values at once.
A loop that would grow the graph past MAX_GRAPH_NODES (65536) nodes is refused at its End. NodeProgram.set_uniform refuses a name that no uniform has, and a value of the wrong type. set_texture refuses NO_TEXTURE.
shader_material(program) is three.js's ShaderMaterial as this port has it. The program is a node graph, compiled from GLSL or built by hand. Its COLOR_NODE is gl_FragColor.rgb, and its OPACITY_NODE is the alpha. Its uniforms are three.js's uniforms. The material is BASIC, so no light reaches it. Its fog is off, as three.js's is.
var graph = NodeGraph()
var stripes = graph.step(graph.float(0.5), graph.fract(graph.mul(graph.uv(), graph.float(8))))
graph.set_output(COLOR_NODE, graph.swizzle(stripes, "xyx"))
var card = assets.materials.add(shader_material(assets.programs.add(graph.compile())))Two of the materials in three.js's examples/jsm/materials come as node programs on a PHYSICAL material.
materials.wood is three.js's WoodNodeMaterial: procedural wood, from rings of warped distance around the trunk, noise and a smooth Voronoi of cells.
var params = wood_preset(WALNUT, GLOSS)
var plank = assets.materials.add(wood_material(assets.programs.add(wood_program(params)), params))-
wood_preset(genus, finish)is three.js'sGetWoodPreset. The ten genuses areTEAK,WALNUT,WHITE_OAK,PINE,POPLAR,MAPLE,RED_OAK,CHERRY,CEDARandMAHOGANY. The finishes areRAW,MATTE,SEMIGLOSSandGLOSS, each a clear coat. -
wood_program(params)is the color node. Its uniforms have three.js's names, fromcenterSizetotransformationMatrix. Change them withset_uniform. -
wood_material(id, params)is the physical material with the finish's clear coat.
A fragment here has no local position, so the program reads the world position through transformationMatrix. For a mesh at the origin, the two are the same. Otherwise, set transformationMatrix to the inverse of the mesh's world matrix. three.js darkens the color by the darkening of the preset that it loads first, which is one. This port does the same.
materials.post_processing_material is three.js's MeshPostProcessingMaterial: the ambient occlusion of a physical surface, read from a post-processing pass's target at the fragment's own pixel.
var occlusion = assets.programs.add(mesh_post_processing_program(gtao_target))
var wall = assets.materials.add(Material(Color(200, 200, 200), kind=PHYSICAL, nodes=occlusion))The program reads the target's texel at gl_FragCoord.xy * aoPassMapScale. With an ambient occlusion map, it takes the lower of the two values and applies aoMapIntensity, as three.js does. The result is the AO_NODE, so the physical shading dims the indirect light with it.
loaders.materialx is three.js's MaterialXLoader. It reads a MaterialX document's materials into the store as node materials.
var read = load_materialx("standard_surface_brass_tiled.mtlx", assets)
var brass = read.ids[0]- A
surfacematerialwhose shader is astandard_surfacebecomes aPHYSICALmaterial. A document with no surface material makes aBASICmaterial of eachnodegraph, with itsoutoutput as the color. - An image path is read after the document's folder and the element's
fileprefix. An image in thesrgb_texturecolor space is decoded from sRGB. Each image repeats, as three.js sets it. -
read.names[i]is the name of materialread.ids[i].
A surface input that is a value sets the material's own number. An input that a graph drives becomes a node output: the color, the opacity, the roughness, the metalness, the glow or the normal. The loader refuses a graph that drives any other input, because this port has no node output for it.
The library is three.js's: the math, the adjustments, the mix, the channels, the ramps, the splits and the noises. It also has place2d, rotate2d, rotate3d, the geometry nodes, image and tiledimage. The loader differs from three.js here:
-
positionandnormalin object space read the world ones. A fragment here has no object space, and for a mesh at the origin the two are the same. -
smoothstep,splitlrandsplittbtake their inputs as the MaterialX specification names them. three.js passes them in another order. -
emission_colormultipliesemission, as the specification says. three.js readsemissionColor, which no document names. - A
gltf_pbrsurface is left as a plain physical material, as three.js leaves it. -
normalmap,heighttonormal,tangent,frame, the unified noises and the matrix nodes are refused. The program has no tangents, no frame count and no matrices of those types.
object_to_json writes a node material's program in the material's nodes field, and read_object_json reads it back into assets.programs. three.js's loaders read the other fields of the material and ignore this one.
three.js writes each node of the graph with its type and its inputs. This port writes the compiled program instead: its words, its uniforms and its custom attributes. It also writes where the program keeps the id of each texture and cube that it reads. Each texture and cube goes to the file's textures or images list, and the program names it by its uuid. So the loader gives the program the ids that the textures get in its own store. A texture uniform that names no texture is written as null.
Programs with exact integer data use codeBits, an array of unsigned 32-bit JSON integers. This preserves subnormal, infinity-shaped and NaN-shaped integer payloads. Float-only programs retain the code array. The reader accepts either form and rejects a file that supplies both. Integer constant instructions carry a checked type tag in their unused B word. This tag is separate from operand registers.
The writer refuses a program that reads a 3D or an array texture, because object JSON has no form for those textures.
The reader checks the compiled program before a renderer can use it. Each instruction, register, constant, matrix, uniform and custom attribute must fit its storage. Every texture read must have an offset in the serialized texture list. Uniform spans cannot overlap, and a sampler cannot share storage with a numeric uniform. Only checked integer constant or uniform spans can hold nonfinite-shaped payload bits. Headers, instructions, samplers, matrices and float data must stay finite.
Invalid code raises an error at load time.
loaders.node_loader reads the node graph that three.js writes, and compiles it here. A node material that you make in three.js can load in this port. The module is three.js's NodeLoader, NodeMaterialLoader and NodeObjectLoader.
read_object_json and read_material_json read a three.js node material without more work:
- A type such as
MeshStandardNodeMaterialis read as the class that it extends, hereMeshStandardMaterial.NodeMaterialis read asMeshBasicMaterial. - The nodes are the file's
nodeslist, and the material's ownnodeslist where it has one. - Each texture node reads the texture that the file names. The loader sets the id that the texture gets in
assets.textures, and the transform of the texture.
Read a material that NodeMaterial.toJSON() wrote alone into a graph:
var read = read_node_material(text)
var program = read.graph.compile()
for i in range(len(read.textures)):
program.set_texture(read.textures[i].uniform, my_textures[read.textures[i].texture])
var id = assets.programs.add(program^)Use a NodeLoader to read one node, or a list of nodes. parse(document, item) reads what Node.toJSON() writes, and returns the node's NodeRef in loader.graph. parse_nodes(document, list) indexes a list by uuid. node(document, uuid) builds one node and the nodes that it reads.
The loader reads these node classes:
| Class | Node here |
|---|---|
VarNode, SubBuild
|
The input node. |
VaryingNode |
varying of the input. A varying of an attribute is the attribute. |
ConstNode |
A constant: a float, a bool (one or zero), a vector, a color or a matrix. |
UniformNode |
A uniform of the same type, named by the node's uuid. |
AttributeNode |
uv(), position_local(), normal_local() or vertex_color() for uv, position, normal and color. Another name is a custom attribute. |
VertexColorNode |
vertex_color(). |
OperatorNode |
Each op of TSL, from + to >>. |
MathNode |
Each method of TSL that has a node here. sinh to atanh are made from exp and log. transformDirection is the matrix times the direction, made unit length. |
ConditionalNode |
select. |
SplitNode, JoinNode
|
swizzle and join. A join of three vec3 columns is a mat3, and a join of four vec4 columns is a mat4. |
ConvertNode |
The conversion that three.js's node builder makes. |
FrontFacingNode, ScreenNode, PointUVNode
|
front_facing(), screen_uv() and point_coord(). |
TextureNode |
A texture uniform named by the node's uuid, read with texture, texture_level or texture_load. |
CubeTextureNode |
A cube uniform named by the node's uuid, read with texture_cube. With no coordinate, it reads in the view ray reflected by the normal, three.js's reflectVector. |
loader.textures lists each texture node. Each entry has the name of its uniform, the uuid of the texture that it reads, and whether it is a cube. A texture node with no coordinate reads uv() through the texture's transform, as three.js does. That transform is a mat3 uniform named after the node's uuid and .matrix. The object loader sets it from the texture's offset, repeat, rotation and center.
Each property of the material's inputNodes sets one output. The property has the name of the output in three.js, from colorNode to castShadowNode. The loader converts each node to the type of its output, as three.js does. It also makes these changes:
-
normalNodeis the normal in view space in three.js. The loader turns it into world space, and subtracts the normal. The sum is then three.js's normal. -
positionNodeis the local position in three.js. The loader subtracts the local position. - A
colorNodeof typevec4orfloathas an alpha. The alpha multiplies the opacity node, or the material'sopacity, as three.js'svec4(colorNode)does.
The loader refuses these things:
- A node class that is not in the table, when an output reads it. The error gives the class.
- A
Nodewith no class of its own. three.js writes each TSL function, such aspositionWorld,normalWorldorcameraPosition, as aNodewith no body. three.js's own loader cannot build these nodes again. - A custom attribute whose type the caller did not declare with
set_attribute. three.js does not write the type of an attribute. - A material property that has no output here, such as
clearcoatNode. - A texture node with a bias, a comparison, a depth, a gradient, a gather or an offset. A cube node with a level.
- A uuid that no node has, and nodes that read each other in a cycle.
A reachable gatherNode input is refused by name, even if its child is missing
or unsupported. Gather reads one channel from neighboring texels. It cannot
be replaced by an ordinary RGBA sample. Unused nodes in the library remain
unbuilt; an unreachable gather input does not cause refusal.
Five of the shaders in three.js's examples/jsm/shaders come as programs. Each compiles three.js's own GLSL through the subset, with three.js's default uniforms. Give the program's id to shader_material.
| Function | three.js | What it draws |
|---|---|---|
toon_shader_1() |
ToonShader1 |
Two tones, and a rim where the camera sees past the edge. |
toon_shader_2() |
ToonShader2 |
Bands of light, darker in steps. |
toon_shader_hatching() |
ToonShaderHatching |
Lines where the light is low. |
toon_shader_dotted() |
ToonShaderDotted |
Dots where the light is low. |
volume_render_shader(style) |
VolumeRenderShader1 |
A ray marched through a 3D texture. See Volume rendering. |
The toon shaders are in materials.toon_shaders. Each one lights by one direction and one color. Set uDirLightPos to the light's direction in view space. A uniform that you set from a Color is decoded from sRGB, as three.js's color management decodes it.
var program = toon_shader_1()
var light = Vector3(0.5, 0.5, 1)
light.normalize()
program.set_uniform("uDirLightPos", light)
var toon = assets.materials.add(shader_material(assets.programs.add(program^)))volume_render_shader(style) in materials.volume_shader marches a ray through a 3D texture of intensities. It colors the ray through a colormap. VOLUME_MIP shows the brightest value on the ray. VOLUME_ISO lights the first surface where the value passes u_renderthreshold.
Draw it on a box that spans the volume, from the box's back faces, as three.js's webgl2_materials_texture3d example does:
- Make a
boxof the volume's size in texels, and translate it by half the size minus one half. Its local coordinates then run from -0.5 to the size minus 0.5. - Set
u_datato the 3D texture withset_volume, andu_cmdatato the colormap withset_texture. - Set
u_sizeto the size in texels, andu_climto the two intensities that the colormap spans. - Set
u_world_to_localto the inverse of the box's world matrix. The default is the identity. - Draw it with
shader_material(id, side=BACK_SIDE).
The shader is three.js's, with these differences:
- The fragment shader takes the world position and the camera into the box's space with
u_world_to_local. three.js inverts the model-view matrix at each vertex instead. - The ray starts at the camera, not at the near plane. The two are the same for a camera outside the volume.
- The style is set when the program is compiled, not by
u_renderstyle. - A ray takes at most
VOLUME_MIP_STEPSsteps, 256, orVOLUME_ISO_STEPSsteps, 64. three.js takes up to 887. A step is one texel, so a volume whose longest diagonal is that many texels is marched whole. A program runs at mostMAX_INSTRUCTIONSinstructions, and an ISO step reads five texels. - The march keeps what it finds in one
vec2, and each step reads its place from the start. An ISO render refines each step and lights the first one that it finds. The steps and the colors are three.js's.
compile lays out each output as a list of instructions. A node is one instruction in each context it runs in. The contexts are the fragment, the pixel beside it for a derivative, and a corner for a varying. Each instruction writes one of MAX_REGISTERS registers of four floats. The compiler gives a register back when the last reader of its value has run.
The compiler lays out each node's inputs in their order. When that needs more than MAX_REGISTERS registers, it tries the input that needs the most registers first, and then the oldest input first. A chain of selects, as Break and Return make, can need either.
The uniforms, and the constants that an instruction reads, follow the instructions, each stored once. A header holds where each output starts, the frame's time and the camera's view matrix. Renderer.prepare_frame copies assets.programs and writes the time and the view into the copy.
Both rasterizers run the program with one function, run_nodes. The CPU reads the program from its list. The GPU kernel reads it from the fog buffer, after the fog's six floats, because the kernel has no free argument. A triangle's state column STATE_NODES tells the kernel where its program starts. Each backend gives the corners' attributes, and the weights at a pixel from the triangle's edge functions.
The parity tests in tests/test_gpu.mojo hold the two backends to the same pixels. See GPU backend.
A fragment runs the outputs in this order:
- The depth node, from the fragment's own interpolated attributes, before the depth and stencil tests.
- The normal node, after any normal or bump map. The sum is made unit length again.
- The lights, with the bent normal, and the ambient occlusion node where the indirect light is dimmed.
- The color, the opacity and the emissive nodes, after the maps. The color is multiplied by the arriving light.
- The mask, then the alpha test, the lights' sums, the reflection and the fog.
- The output node.
A fragment that the mask can throw away claims no depth until it survives, as a GPU does for a shader that can discard. The position node runs on the host, once per vertex, after the morph targets, the bones and the displacement map. So both rasterizers draw the same moved triangles, and the shadow pass casts them. A mesh with a position node is not culled by the bound of its geometry.
viewport_texture(screen_uv()) reads the opaque scene behind the fragment, as three.js's viewportSharedTexture() does. A backdrop node usually reads it, to filter what is behind a surface:
var behind = graph.viewport_texture(graph.screen_uv())
graph.set_output(BACKDROP_NODE, graph.mul(graph.swizzle(behind, "rgb"), graph.vec3(0.8, 1, 0.8)))The renderer draws the opaque scene first for a program that reads it, as it does for a transmissive surface. It uses the same target, three.js's transmissionRenderTarget. The triangle is drawn after that pass and is not in it.
- Only the texture view (
SHADE_TEXTURE) reads the scene.SHADE_LITreads opaque white, as it reads every texture. - A call to
rasterize_allorGpuRenderer.drawthat runs such a program in the texture view must pass aTransmissionTarget. A call without one is refused. - The read is the target's full-size level, bilinear. three.js reads the same level by default.
- A point and a line read opaque white.
A point and a Line run a node material too, as three.js runs a ShaderMaterial or a PointsNodeMaterial on them. The material must be BASIC. Each pixel runs the color, the opacity and the mask, then the fog and the output node. The lights, the emissive, the normal, the ambient occlusion and the depth nodes do not run, because a point and a line are unlit.
- A point is every corner of its program. Its attributes are the same at each of its pixels.
point_coord()is where the pixel is in the point, and a derivative of it is one over the point's width. - A line's two ends are its first two corners. A pixel weighs them as the line's color is weighed, with the perspective correction.
- The size node runs on the host, once per point, with the frame's time and view. It replaces the material's size and its attenuation. A size that is not above zero is refused.
- The position node moves a point or a line's vertices, as it moves a mesh's. It reads zero as the normal.
- A texture read on a point reads the level that the point's width chooses. On a line it reads the full-size level.
- A light's view runs no program on a point or a line: it draws their depth alone. The position node still moves them.
- A
NORMALS,DEPTH,DISTANCEorSHADOWmaterial refuses a node program. These kinds show data or a shadow, not a surface's color. - A wireframe refuses a node program. A line has no surface.
- A wide line refuses a node material. three.js draws it with its own
LineMaterialshader. - A sprite runs a node material on its square, as three.js's
SpriteNodeMaterialdoes. It refuses a position node, because a sprite has no vertices of its own to move. - A program id that is not in
assets.programsis refused when the mesh is drawn. A program that reads a texture that is not inassets.texturesis refused too, and so is a texture uniform that names no texture. - The uv view (
SHADE_UV) runs no program.SHADE_LITruns it, and a texture node reads opaque white there.
- three.js compiles a node graph to a GPU shader. This port interprets a bytecode per fragment, on both backends.
-
normalNodeandpositionNodereplace the normal and the position in three.js. Here they are offsets. Addnormal_world()orposition_local()to get a replacement. -
IfandLooprun as selects and unrolled copies, not as jumps. Both branches of anIfrun, and a texture that a branch reads is read. -
dFdxanddFdyare exact per triangle. A GPU takes them from the pixel quad, and a quad at a triangle's edge reads its neighbors' helper values. - A
Discardjoins the mask, so it acts before the alpha test, whichever output its branch builds. - The position node runs before the instance matrix. three.js runs it after the instance matrix.
- The position node reads the geometry's own normal, before the morph targets and the bones.
- A texture node reads its image at the mip level that the surface's own coordinates select. WebGPU measures the derivatives of the coordinate that the node computes.
- A GLSL uniform starts at zero until the caller sets it. three.js starts it at the value in
uniforms. -
normalMatrix * normalis made unit length, and a varying reads the world position of the moved vertex. - A point light's cube reads the color of its nearest texel. three.js reads the cube with a linear filter.
- A
SHADOWmaterial's mask reads the depths alone. The colors of a transmitted shadow do not change it. - The raycaster picks the geometry without the position node, as three.js's raycaster does.
loaders.node_loader.node_json_type_names() is the checked type list.
tests/test_node_loader.mojo tests the accepted types and rejects unsupported
classes with an explicit error.
| Serialized class or feature | Support |
|---|---|
VarNode, SubBuild, VaryingNode
|
Supported references and graph wrappers. |
ConstNode, UniformNode, AttributeNode, VertexColorNode
|
Supported values and inputs. Uniform update callbacks are not serialized. |
OperatorNode, MathNode, ConditionalNode
|
Supported operations listed by the loader. Unknown operations raise an error. |
SplitNode, JoinNode, ConvertNode
|
Supported lane and type operations. |
FrontFacingNode, ScreenNode, PointUVNode
|
Supported fragment inputs. |
TextureNode, CubeTextureNode
|
Supported sampled textures. |
| TSL function bodies | Not stored in three.js node JSON; the loader rejects them. |
ModelNode, MaterialNode, PropertyNode, StorageBufferNode
|
Rejected. Use explicit graph inputs, textures, or attributes where available. |
| Post-processing nodes | Use the corresponding composer passes. |
| Volume scene depth and rectangle lights | Native explicit depth capture and r186 volume LTC are supported. Serialized TSL depth expressions are not loaded. |
Volume ray receivedShadowNode
|
Not supported; see the limits below. |
- Arbitrary TSL expressions for a volume's
depthNode. Use the explicit opaque scene depth input.DEPTH_NODEstill sets fragment depth. - r186's separate outgoing-ray-light accumulation and
scatteringEmissiveNode. The accepted outgoing transmittance model remains unchanged. -
receivedShadowNodeon a volume's ray. The shadows at each step are not shaped by the graph. - three.js's node JSON does not keep the name of a uniform, the update of a uniform or the type of an attribute. So
timeis a plain uniform named by its uuid. Set it withset_uniformbefore each frame. - The node classes of three.js that are TSL functions, and the classes that have no node here, such as
ModelNode,MaterialNodeandPropertyNode. A material that uses them cannot load. - A material that reads a storage buffer. Compute nodes, storage buffers and
instancedArrayare on Compute nodes. A material reads what they compute as a texture or an attribute. - Post-processing nodes as nodes. Several of three.js's display nodes run as composer passes instead; see Post-processing.