Skip to content

Custom Shading and Lights in Shaders

A Three.js guide for coding agents. Also covers custom shader lighting, lights in shaders, shader light model, phong shading glsl, ambient light shader, directional light shader, and 26 more.

Show all 32 aliases

custom shader lighting, lights in shaders, shader light model, phong shading glsl, ambient light shader, directional light shader, point light shader, specular highlight glsl, light decay, diffuse shading dot product, normal handling vertex fragment, modelMatrix normal, normalize vNormal, how do I add lighting to a ShaderMaterial, how do I compute normals for animated vertices, recompute normals neighbors, raging sea shading, wave normals cross product, halftone shading, halftone dots shader, gl_FragCoord grid, screen space pattern, earth shader, day night texture mix, sun direction uniform, atmosphere fresnel, atmosphere glow mesh, twilight color, specular map clouds, fresnel dot viewDirection normal, grid artifact specular, light follows object rotation bug

Core model: accumulate light, multiply color

  • Three.js built-in lights do not reach custom ShaderMaterial code. Implement lighting yourself with a cheap Phong-style model: not physically based, but performant and convincing
  • Structure: each light type is a pure GLSL function returning a vec3. Accumulate into one light variable with +=, then multiply the material color by it. Never add light to color: with zero light an object must be black
vec3 light = vec3(0.0);
light += ambientLight(vec3(1.0), 0.03);
light += directionalLight(vec3(0.1, 0.1, 1.0), 1.0, normal, vec3(0.0, 0.0, 3.0), viewDirection, 20.0);
light += pointLight(vec3(1.0, 0.1, 0.1), 1.0, normal, vec3(0.0, 2.5, 0.0), viewDirection, 20.0, vPosition, 0.25);
color *= light;
  • Because each light is a function call, adding another light is just calling the function again with different parameters
  • Keep light functions in src/shaders/includes/*.glsl and pull them in with #include ../includes/directionalLight.glsl (works with vite-plugin-glsl)
  • Compute in the fragment shader (per-fragment, Phong style) to avoid the faceted artifacts of per-vertex (Gouraud) shading
  • End the fragment shader with #include <tonemapping_fragment> then #include <colorspace_fragment> so renderer tone mapping (THREE.ACESFilmicToneMapping) and color space apply

Ambient light

Uniform light regardless of orientation. Fakes bounced light so shadowed areas are not pure black. Keep intensity low, around 0.02 to 0.1.

vec3 ambientLight(vec3 lightColor, float lightIntensity)
{
    return lightColor * lightIntensity;
}
  • Color and intensity could be one parameter (color is not clamped to 1), but separating them lets you tweak intensity without touching color
  • Sanity check the multiply model: a pure red light on a pure blue object renders black, which is physically right

Directional light: diffuse plus specular

Parallel rays, constant intensity with distance. Two terms: diffuse (surface facing the light) and specular (reflection aligned with the view).

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal,
                      vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    vec3 lightDirection = normalize(lightPosition);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // Diffuse
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    return lightColor * lightIntensity * (shading + specular);
}

Key decisions baked into that function:

  • lightDirection here is the vector from surface toward the light (normalized light position). reflect() wants the incoming ray, so negate it: reflect(- lightDirection, normal)
  • dot(normal, lightDirection) gives 1 facing the light, 0 at 90 degrees, negative behind. Clamp with max(0.0, ...) or the negative diffuse subtracts from other lights and tints shadow areas wrong (negative light does not exist)
  • Specular compares the reflected ray to the view direction. dot returns 1 when aligned the wrong way for our vectors, so negate it, clamp with max(0.0, ...), then sharpen with pow(specular, specularPower) (20 to 32 is a tight highlight)
  • Clamp BEFORE pow: an unclamped negative dot with an even power creates a phantom highlight on the back of the object, and with an odd power a black hole
  • Tint the specular by the light: lightColor * lightIntensity * (shading + specular) keeps the highlight consistent with the light color and intensity instead of always white

View direction

Computed once in main(), shared by all lights and by fresnel effects:

vec3 viewDirection = normalize(vPosition - cameraPosition);

cameraPosition is a Three.js built-in uniform. vPosition is modelPosition.xyz passed as a varying (world space, translation included).

Point light: direction from position, decay with distance

Same as directional plus two changes: the light direction is per-fragment, and intensity decays with distance.

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal,
                vec3 lightPosition, vec3 viewDirection, float specularPower,
                vec3 position, float lightDecay)
{
    vec3 lightDelta = lightPosition - position;
    float lightDistance = length(lightDelta);
    vec3 lightDirection = normalize(lightDelta);
    vec3 lightReflection = reflect(- lightDirection, normal);

    float shading = max(0.0, dot(normal, lightDirection));

    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    float decay = 1.0 - lightDistance * lightDecay;
    decay = max(0.0, decay);

    return lightColor * lightIntensity * decay * (shading + specular);
}
  • lightDelta does double duty: normalized it is the direction, its length() is the distance
  • Linear decay 1.0 - distance * lightDecay is a shortcut, not physical falloff. With lightDecay at 1.0 the light dies within one unit; use small values (0.2 to 0.3) for scene-scale lights
  • Clamp decay to 0 or distant fragments receive negative light and corrupt the other lights in the sum
  • Debug tip: add a cheap helper mesh (MeshBasicMaterial plane or small icosahedron) at the light position with the light color so you can see where the light is supposed to be

Normal handling: the two classic mistakes

Both mistakes produce recognizable symptoms. Memorize them.

  1. Light follows the object's rotation. You forgot to apply the model transform to the normal. In the vertex shader multiply by modelMatrix with the fourth component at 0.0 (rotation and scale only, no translation):
vec4 modelNormal = modelMatrix * vec4(normal, 0.0);
vNormal = modelNormal.xyz;
vPosition = modelPosition.xyz;
  1. Grid or facet artifacts in the specular. Varyings are interpolated between vertices, and interpolating unit vectors yields vectors shorter than 1. Re-normalize in the fragment shader before any lighting math:
vec3 normal = normalize(vNormal);

Animated surfaces: recompute normals from neighbors (raging sea)

The geometry's normal attribute is static. If the vertex shader displaces vertices (waves), those normals all point straight up and lighting reads as one giant fake reflection. Debug by outputting gl_FragColor = vec4(normal, 1.0): solid green means every normal is +Y.

Fix: ignore the attribute and compute the normal in the vertex shader from two theoretical neighbors.

  1. Extract the displacement into a reusable function so it can run for any position:
float waveElevation(vec3 position)
{
    float elevation = sin(position.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) *
                      sin(position.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) *
                      uBigWavesElevation;
    for(float i = 1.0; i <= uSmallIterations; i++)
        elevation -= abs(perlinClassic3D(vec3(position.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
    return elevation;
}
  1. Build neighbor positions, displace all three, cross the two edge vectors:
float shift = 0.01;
vec3 modelPositionA = modelPosition.xyz + vec3(shift, 0.0, 0.0);
vec3 modelPositionB = modelPosition.xyz + vec3(0.0, 0.0, - shift);

float elevation = waveElevation(modelPosition.xyz);
modelPosition.y += elevation;
modelPositionA.y += waveElevation(modelPositionA);
modelPositionB.y += waveElevation(modelPositionB);

vec3 toA = normalize(modelPositionA - modelPosition.xyz);
vec3 toB = normalize(modelPositionB - modelPosition.xyz);
vNormal = cross(toA, toB);

Gotchas:

  • Neighbor B goes on NEGATIVE z. Cross product order and sign decide whether the normal points up or down (right-hand rule: thumb toA, index toB, middle finger is the cross)
  • shift is a quality dial: small enough to catch the smallest wave detail, but too small catches sub-visible noise. Tune it visually, 0.01 is a good start
  • Cost: the elevation function (with its noise loop) runs three times per vertex. Watch vertex counts and noise iterations
  • Since the attribute is unused, free the memory: geometry.deleteAttribute('normal')
  • This neighbor trick relies on a grid (plane) parameterization. Arbitrary geometry needs a different approach (tangent space, covered by later techniques)
  • Enhance elevation-based color gradients with smoothstep(0.0, 1.0, mixStrength) before the mix for a nicer curve

Halftone shading: screen-space dot grid

Halftone builds shading from a grid of discs of varying size, fixed to the SCREEN (like CSS position: fixed), layered on top of a subtle ambient plus directional base.

Screen-space grid UV

gl_FragCoord.xy gives pixel coordinates (0,0 bottom left, up to resolution). Normalize with a resolution uniform, then tile with multiply plus mod:

// JS: uResolution = new THREE.Uniform(new THREE.Vector2(
//   sizes.width * sizes.pixelRatio, sizes.height * sizes.pixelRatio))
// and update it in the resize handler.
vec2 uv = gl_FragCoord.xy / uResolution.y;  // divide by y ONLY
uv *= repetitions;                          // repetitions = vertical cell count
uv = mod(uv, 1.0);
  • Divide by uResolution.y only, not the full vector: dividing x by width and y by height stretches cells by the aspect ratio. Same divisor keeps cells square, and repetitions becomes the number of vertical cells
  • Remember pixel ratio in the uniform or the grid density changes across devices

Discs sized by a directional intensity

vec3 halftone(vec3 color, float repetitions, vec3 direction, float low, float high,
              vec3 pointColor, vec3 normal)
{
    float intensity = dot(normal, direction);
    intensity = smoothstep(low, high, intensity);

    vec2 uv = gl_FragCoord.xy / uResolution.y;
    uv *= repetitions;
    uv = mod(uv, 1.0);

    float point = distance(uv, vec2(0.5));
    point = 1.0 - step(0.5 * intensity, point);

    return mix(color, pointColor, point);
}
  • intensity is a directional-light-style dot: faces toward direction get big discs, faces away get none. smoothstep(low, high, ...) remaps and clamps the -1..1 dot into a usable ramp (shadow example: low -0.8, high 1.5)
  • distance(uv, vec2(0.5)) then 1.0 - step(radius, d) draws a sharp disc per cell; the radius 0.5 * intensity is the whole effect
  • Call it twice for the classic look: a shadow pass pointing down (vec3(0.0, -1.0, 0.0), purple dots) and a light pass pointing toward the light (vec3(1.0, 1.0, 0.0), pale dots, low 0.5), each with its own repetitions and color uniforms
  • direction must be normalized if you expose it as a tweak

Earth shaders

A realistic Earth is a stack of small mixes on one sphere, plus a separate halo mesh.

Textures and sun uniform

  • Three textures: day.jpg and night.jpg (sRGB, so set texture.colorSpace = THREE.SRGBColorSpace), and specularClouds.jpg (linear) with the specular map packed in the RED channel and clouds in the GREEN channel. Channel packing saves GPU memory; sample once and swizzle .rg
  • Set texture.anisotropy = 8 on all three so textures stay sharp at grazing angles (default is 1; query the hardware cap with renderer.capabilities.getMaxAnisotropy(), 8 is broadly safe but has a performance cost)
  • Drive the sun from JS with a THREE.Spherical(1, phi, theta) converted via sunDirection.setFromSpherical(sunSpherical). Radius 1 means it is already normalized. Copy it into a uSunDirection uniform on BOTH the earth material and the atmosphere material in one updateSun() function, and place a small debug mesh at sunDirection * 5

Day/night, clouds, atmosphere tint, specular

float sunOrientation = dot(uSunDirection, normal);

// Day / night
float dayMix = smoothstep(- 0.25, 0.5, sunOrientation);
vec3 dayColor = texture(uDayTexture, vUv).rgb;
vec3 nightColor = texture(uNightTexture, vUv).rgb;
vec3 color = mix(nightColor, dayColor, dayMix);

// Clouds (green channel), dense-only, hidden at night
vec2 specularCloudColor = texture(uSpecularCloudsTexture, vUv).rg;
float cloudsMix = smoothstep(0.5, 1.0, specularCloudColor.g);
cloudsMix *= dayMix;
color = mix(color, vec3(1.0), cloudsMix);

// Fresnel (normals face away from view, so + 1.0 recenters 0..1)
float fresnel = dot(viewDirection, normal) + 1.0;
fresnel = pow(fresnel, 2.0);

// Atmosphere tint: twilight orange near the terminator, blue on the day side
float atmosphereDayMix = smoothstep(- 0.5, 1.0, sunOrientation);
vec3 atmosphereColor = mix(uAtmosphereTwilightColor, uAtmosphereDayColor, atmosphereDayMix);
color = mix(color, atmosphereColor, fresnel * atmosphereDayMix);

// Sun specular, water only, tinted at the edges
vec3 reflection = reflect(- uSunDirection, normal);
float specular = - dot(reflection, viewDirection);
specular = max(specular, 0.0);
specular = pow(specular, 32.0);
specular *= specularCloudColor.r;                      // specular map: oceans reflect, land does not
vec3 specularColor = mix(vec3(1.0), atmosphereColor, fresnel);
color += specular * specularColor;
  • One sunOrientation dot feeds three different smoothstep remaps (day mix, atmosphere mix, halo alpha). That reuse is the whole architecture
  • Multiplying cloudsMix by dayMix hides clouds at night instead of darkening them, so city lights stay visible
  • Multiplying the atmosphere mix factor by atmosphereDayMix kills the glow on the night side
  • Tinting specular with mix(white, atmosphereColor, fresnel) makes the sun glint warm near the twilight edge instead of blowing out white
  • Suggested defaults: atmosphere day #00aaff, twilight #ff6600

Atmosphere halo: a second mesh

Fake the volumetric glow with a slightly larger sphere showing only its inside:

const atmosphereMaterial = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    transparent: true,
    vertexShader: atmosphereVertexShader,
    fragmentShader: atmosphereFragmentShader,
    uniforms: { uSunDirection, uAtmosphereDayColor, uAtmosphereTwilightColor }
})
const atmosphere = new THREE.Mesh(earthGeometry, atmosphereMaterial)
atmosphere.scale.set(1.04, 1.04, 1.04)
  • Reuse the earth geometry; 1.04 scale reads better than the realistic 1.015
  • The fragment shader is a stripped copy of the earth one: keep sunOrientation and the atmosphere color mix, ADD the color (color += atmosphereColor, mixing against black just yields black), then fade with two alphas:
float edgeAlpha = dot(viewDirection, normal);      // BackSide flips normals, so this is positive
edgeAlpha = smoothstep(0.0, 0.5, edgeAlpha);       // 1 near the limb, 0 at the outer edge
float dayAlpha = smoothstep(- 0.5, 0.0, sunOrientation);  // invisible on the night side
gl_FragColor = vec4(color, edgeAlpha * dayAlpha);

Gotchas checklist

  • Multiplied color by light, not added
  • Diffuse dot clamped with max(0.0, ...)
  • Specular dot negated, clamped BEFORE pow
  • Normal transformed with modelMatrix * vec4(normal, 0.0) (w = 0, no translation)
  • vNormal re-normalized in the fragment shader
  • reflect() fed the incoming direction (negated toward-light vector)
  • Point light decay clamped to 0
  • Displaced surfaces recompute normals (neighbors + cross); static normal attribute deleted
  • Neighbor offsets chosen so the cross product points outward (right-hand rule)
  • Screen-space grids divide gl_FragCoord.xy by uResolution.y only, uniform includes pixel ratio and updates on resize
  • sRGB color textures flagged with THREE.SRGBColorSpace; data textures (specular, clouds) left linear
  • Fresnel on a front-side mesh needs + 1.0 after dot(viewDirection, normal); on a BackSide mesh it does not
  • Tone mapping and colorspace chunks included at the end of every fragment shader

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "custom-shading-lights" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems