Skip to content
Color · GradientsBeginner

Linear sky

A vertical blend from a warm horizon to a deep zenith, with a start and an end you can move.

UVSurface
Open in Playground
  1. 01GoalColor
  2. 02TechniqueGradients
  3. 03ExecutionLinear sky
Full-screen quad

Parameters

#fcb880
#294dad
0.10
0.90

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.

  1. i.uv.yUV + VectorDecomposei.uv.yi.uv.yi.uv.y
    UV

    01 Height on the quad

  2. (x - a) / (b - a)Remap(x - a) / (b - a)(x - a) / (b - a)(x - a) / (b - a)

    02 Remap to the band

  3. clampClampclampclampclamp

    03 Clamp to 0–1

  4. mixMixmixmixmix

    04 Mix the two colors

How it works

A gradient needs a number that runs from 0 to 1 and two colors to blend by it. Here the number comes from the height on the quad, and the colors are a warm horizon and a deep blue overhead.

  1. Height. i.uv.y is the UV’s vertical coordinate: 0 at the bottom edge, 1 at the top. Shown as a color it is already a gradient, from black to white.
  2. Remap. A real sky doesn’t change color evenly from the ground to the top of the frame: it stays pale near the horizon and deepens higher up. (height - p_bottom) / (p_top - p_bottom) moves the blend into a band: 0 at the start height, 1 at the end height. This is the inverse of a mix: it asks how far along the band the pixel is.
  3. Clamp. Below the start the remap goes negative, and above the end it passes 1. clamp holds it at the ends, so the horizon color fills everything below the band and the zenith color everything above it. Without it, the mix in the next step would extrapolate past both colors.
  4. Mix. mix(p_horizon, p_zenith, t) blends the two colors channel by channel: each of red, green and blue moves in a straight line from one end to the other.

The colors arrive in linear light, because the frame decodes color params before the shader sees them and encodes the result once at the end. So the mix blends light the way overlapping light blends, and the middle stays bright. Mixing the sRGB values from a color picker directly would darken the middle; the RGB vs OKLab ramp card shows both, one above the other.

Move the start and end close together and the band turns into a hard horizon line; spread them to the edges and the gradient takes the whole height. An eight-bit screen has only 256 levels per channel, so a long, slow gradient between similar colors can show faint steps (banding); a tiny amount of noise added before the output hides them.

Make it in GLSL

The copied code is a complete GLSL ES 3.00 fragment shader for WebGL2. It reads v_uv from the vertex shader, so draw it on any quad or full-screen triangle whose vertex shader passes UVs from 0 at the bottom-left to 1 at the top-right. It also declares v_position, v_normal and v_view; a 2D vertex shader can write constants into them.

  1. Compile it with a vertex shader that outputs v_uv (and the other three varyings) and link the two.
  2. Upload the four uniforms: p_horizon and p_zenith as vec3 in linear light, p_bottom and p_top as floats between 0 and 1. Decode a picker’s sRGB value per channel with c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4.
  3. Draw. The shader’s main() clamps the result and encodes it to sRGB itself, because a WebGL2 canvas doesn’t.

To use the sky inside a bigger shader, take only effect() and call it with i.uv set; then encode once at the very end of your own shader, not inside the sky.

Make it in Godot

The shader assumes HDR 2D is off (the project default), so it decodes the color uniforms and encodes the result itself. With Rendering > Viewport > HDR 2D on, make u_toLinear() and u_toSrgb() return c unchanged.

The export is a canvas_item shader, so it draws on any 2D node.

  1. Add a ColorRect and set its layout to Full Rect (or the size of the panel you want behind your UI).
  2. In Material, create a ShaderMaterial, then a new Shader, and paste the code over the template.
  3. The four uniforms show under Shader Parameters: pick the horizon and zenith colors, and move the start and end between 0 and 1.

In VisualShader the same chain is UV → VectorDecompose (y) → Remap (input range from start to end, output 0 to 1) → Clamp → Mix between two ColorParameter nodes. For a sky that turns with a 3D camera, write a sky shader that mixes by EYEDIR.y instead: the same remap and mix, a different number for t.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

linear-sky.fragGLSL
#version 300 es// Gradients → Linear sky, 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_horizon; // Horizon color: vec3(0.99, 0.72, 0.5), sRGB; upload it decoded to linearuniform vec3 p_zenith; // Zenith color: vec3(0.16, 0.3, 0.68), sRGB; upload it decoded to linearuniform float p_bottom; // Gradient start: 0.1 (0.0 to 0.45)uniform float p_top; // Gradient end: 0.9 (0.55 to 1.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 colors arrive linear, so this mix blends light; mixing their sRGB values instead darkens the middle// (the RGB vs OKLab ramp card shows the difference).vec4 effect(Inputs i) {	float height = i.uv.y;	float ramp = (height - p_bottom) / (p_top - p_bottom);	float t = clamp(ramp, 0.0, 1.0);	vec3 sky = mix(p_horizon, p_zenith, t);	return vec4(sky, 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);}
linear-sky.gdshaderGodot shader
// Gradients → Linear sky, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform vec3 horizon : source_color = vec3(0.99, 0.72, 0.5);uniform vec3 zenith : source_color = vec3(0.16, 0.3, 0.68);uniform float bottom : hint_range(0.0, 0.45, 0.01) = 0.1;uniform float top : hint_range(0.55, 1.0, 0.01) = 0.9;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) {	return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}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 colors arrive linear, so this mix blends light; mixing their sRGB values instead darkens the middle// (the RGB vs OKLab ramp card shows the difference).vec4 effect(Inputs i) {	float height = i.uv.y;	float ramp = (height - bottom) / (top - bottom);	float t = clamp(ramp, 0.0, 1.0);	vec3 sky = mix(u_toLinear(horizon), u_toLinear(zenith), t);	return vec4(sky, 1.0);}void fragment() {	Inputs i;	// UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = 1.0 / SCREEN_PIXEL_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}

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