Single sun
One distant light and a flat fill, shaded by how squarely each point faces the sun.
- 01GoalLight
- 02TechniqueDiffuse (Lambert)
- 03ExecutionSingle sun
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
dotDotProductdotdotdot Normal01 Facing the sun
maxMaxmaxmaxmax In02 No negative light
+MultiplyAdd+++ In03 Sun plus ambient
*Multiply*** In04 Light the surface color
How it works
A sun is so far away that its light arrives in parallel rays: every point on the mesh sees it from the same direction.
How much of that light a point catches depends only on the angle between its surface and the rays. Face the sun
squarely and the point gathers a full beam; tilt it and the same beam spreads over more area, so each bit of surface
gets less. That spreading follows the cosine of the angle, which is Lambert’s cosine lawA matte surface's brightness follows the cosine of the angle between its normal and the direction toward the light, and looks the same from every viewing angle. In a shader it is the clamped dot product of the two unit vectors. Lambert's cosine law A matte surface's brightness follows the cosine of the angle between its normal and the direction toward the light, and looks the same from every viewing angle. In a shader it is the clamped dot product of the two unit vectors.diffuse = max(N · L, 0)
Step by step
- Facing the sun.
sunDirection()turns the two angles into a unit vector pointing toward the sun, and the dot productMultiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite).FundamentalDot product
Multiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite).
dot(a, b) = |a| |b| cos θSourceswith the surface normalA unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.- dot (opens in a new tab)Reference
- Dot product (opens in a new tab)Reference
FundamentalNormal
A unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.
Sourcesis the cosine of the angle between them: 1 where the surface faces the sun, 0 where the rays graze it, negative on the far side. - No negative light.
max(lambert, 0.0)clamps the far side to zero. The preview of the first step already looked black there, because the screen can’t show values under zero; the clamp matters in the next step, where a negative value would subtract from the ambient light and carve a hole darker than the shadow side should be. - Sun plus ambient. The sun’s color, scaled by the diffuse factor, plus a constant ambient color. Real shadows aren’t black: the sky and nearby surfaces bounce light into them. A flat ambient term is the cheapest stand-in; a bluish one reads as sky.
- Light the surface color. The surface’s own color (its albedo) multiplies the light. A red surface reflects the red part of whatever light reaches it, so a warm sun and a cool ambient tint the lit and shadowed sides differently.
Everything here is linear, as lighting must be: the color params arrive decoded from sRGB, and the frame encodes the result once at the end. Multiplying sRGB values instead darkens the half-lit band and shifts the terminator.
Orbit the preview: the shading doesn’t move with the camera, because nothing in it reads the view direction. That is the difference between diffuse and specular light.
Make it in GLSL
The sun direction is the same for every pixel. In your own renderer, compute it once on the CPU and pass it as a uniform vec3; that removes 8 of the 13 operations.
The copy button gives a complete WebGL2 fragment shader. Your vertex shader writes the world-space normal to the
v_normal varying (and v_position, v_uv, v_view, which the frame declares even though this card ignores them);
the generated main() normalizes it again per pixel. Upload the color params as linear values: decode the sRGB colors
from a picker first, as the header comments say. main() encodes the result to sRGB once, so draw into the default
canvas framebuffer, not an sRGB one, or the image is encoded twice and looks pale.
To use only the lighting in a shader of your own, copy sunDirection() and the lines of effect(), and replace
i.normal with your normalized world normal. Nothing reads the view direction or time, so the result is static and
bakes cleanly into a texture or vertex colors.
Make it in Godot
The exported shader is unshaded and carries its own sun, so the scene's DirectionalLight3D doesn't move it. To have Godot's lights drive the same math instead, drop unshaded, set ALBEDO to the surface color in fragment(), and add a light() function with DIFFUSE_LIGHT += clamp(dot(NORMAL, LIGHT), 0.0, 1.0) * ATTENUATION * LIGHT_COLOR / PI; which is Lambert for every light, with shadows through ATTENUATION.
Make a MeshInstance3D (a SphereMesh shows the terminator best), give it a new ShaderMaterial, and paste the shader into a new Shader. The sun angles and colors appear under Shader Parameters in the inspector; azimuth turns around the world’s Y axis starting from +Z.
In VisualShader, the chain is an Input node for the normal (transform it to world space with a TransformVecMult by INV_VIEW_MATRIX, or keep everything in view space and transform the sun instead), a Vector3Parameter for the sun direction, DotProduct, a FloatOp set to Max with 0, then a MultiplyAdd with the sun color and the ambient color, and a VectorOp Multiply with the albedo into the Albedo port of an unshaded material.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// Diffuse (Lambert) → Single sun, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_albedo; // Surface color: vec3(0.85, 0.62, 0.42), sRGB; upload it decoded to linearuniform vec3 p_sunColor; // Sun color: vec3(1.0, 0.95, 0.85), sRGB; upload it decoded to linearuniform vec3 p_ambient; // Ambient light: vec3(0.3, 0.33, 0.42), sRGB; upload it decoded to linearuniform float p_azimuth; // Sun azimuth (degrees): -20.0 (-180.0 to 180.0)uniform float p_elevation; // Sun elevation (degrees): 40.0 (-30.0 to 90.0)in vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// The angles turn the same way as the preview's orbit camera: azimuth around the vertical axis from +Z toward +X,// elevation up from the horizon.vec3 sunDirection(float azimuthDegrees, float elevationDegrees) { float a = radians(azimuthDegrees); float e = radians(elevationDegrees); return vec3(cos(e) * sin(a), sin(e), cos(e) * cos(a));}vec4 effect(Inputs i) { vec3 toSun = sunDirection(p_azimuth, p_elevation); float lambert = dot(i.normal, toSun); float diffuse = max(lambert, 0.0); vec3 lighting = p_sunColor * diffuse + p_ambient; vec3 color = p_albedo * lighting; return vec4(color, 1.0);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1 fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}// Diffuse (Lambert) → Single sun, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform vec3 albedo : source_color = vec3(0.85, 0.62, 0.42);uniform vec3 sunColor : source_color = vec3(1.0, 0.95, 0.85);uniform vec3 ambient : source_color = vec3(0.3, 0.33, 0.42);uniform float azimuth : hint_range(-180.0, 180.0, 1.0) = -20.0;uniform float elevation : hint_range(-30.0, 90.0, 1.0) = 40.0;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// The angles turn the same way as the preview's orbit camera: azimuth around the vertical axis from +Z toward +X,// elevation up from the horizon.vec3 sunDirection(float azimuthDegrees, float elevationDegrees) { float a = radians(azimuthDegrees); float e = radians(elevationDegrees); return vec3(cos(e) * sin(a), sin(e), cos(e) * cos(a));}vec4 effect(Inputs i) { vec3 toSun = sunDirection(azimuth, elevation); float lambert = dot(i.normal, toSun); float diffuse = max(lambert, 0.0); vec3 lighting = sunColor * diffuse + ambient; vec3 color = albedo * lighting; return vec4(color, 1.0);}void fragment() { Inputs i; // Godot's UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // VERTEX, NORMAL and VIEW are in view space here; the effect works in world space i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz); i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = VIEWPORT_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); ALBEDO = frag_out.rgb;}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.
Further reading
- Lambertian reflectance (opens in a new tab)Reference
- Basic Lighting (opens in a new tab)Article
- dot (opens in a new tab)Reference
- max (opens in a new tab)Reference