Core model: accumulate light, multiply color
- Three.js built-in lights do not reach custom
ShaderMaterialcode. 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 onelightvariable 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/*.glsland pull them in with#include ../includes/directionalLight.glsl(works withvite-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:
lightDirectionhere 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 withmax(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.
dotreturns 1 when aligned the wrong way for our vectors, so negate it, clamp withmax(0.0, ...), then sharpen withpow(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);
}
lightDeltadoes double duty: normalized it is the direction, itslength()is the distance- Linear decay
1.0 - distance * lightDecayis a shortcut, not physical falloff. WithlightDecayat 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 (
MeshBasicMaterialplane 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.
- Light follows the object's rotation. You forgot to apply the model transform to the normal. In the vertex shader multiply by
modelMatrixwith the fourth component at0.0(rotation and scale only, no translation):
vec4 modelNormal = modelMatrix * vec4(normal, 0.0);
vNormal = modelNormal.xyz;
vPosition = modelPosition.xyz;
- 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.
- 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;
}
- 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)
shiftis 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 themixfor 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.yonly, not the full vector: dividing x by width and y by height stretches cells by the aspect ratio. Same divisor keeps cells square, andrepetitionsbecomes 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);
}
intensityis a directional-light-style dot: faces towarddirectionget 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))then1.0 - step(radius, d)draws a sharp disc per cell; the radius0.5 * intensityis 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 directionmust 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.jpgandnight.jpg(sRGB, so settexture.colorSpace = THREE.SRGBColorSpace), andspecularClouds.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 = 8on all three so textures stay sharp at grazing angles (default is 1; query the hardware cap withrenderer.capabilities.getMaxAnisotropy(), 8 is broadly safe but has a performance cost) - Drive the sun from JS with a
THREE.Spherical(1, phi, theta)converted viasunDirection.setFromSpherical(sunSpherical). Radius 1 means it is already normalized. Copy it into auSunDirectionuniform on BOTH the earth material and the atmosphere material in oneupdateSun()function, and place a small debug mesh atsunDirection * 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
sunOrientationdot feeds three differentsmoothstepremaps (day mix, atmosphere mix, halo alpha). That reuse is the whole architecture - Multiplying
cloudsMixbydayMixhides clouds at night instead of darkening them, so city lights stay visible - Multiplying the atmosphere mix factor by
atmosphereDayMixkills 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
sunOrientationand 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) vNormalre-normalized in the fragment shaderreflect()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.xybyuResolution.yonly, 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.0afterdot(viewDirection, normal); on a BackSide mesh it does not - Tone mapping and colorspace chunks included at the end of every fragment shader