Planet Shaders · Lesson 01 · 20 min
A planet is a function of direction
Write your first .gdshader, dodge the trap that ruins every sphere, and put bands on a gas giant.
You already write GLSL. In the GPU programming course you gave up the loop and learned to think in invocation ids, and none of that is going to be re-taught here. What you have never done is write a shader whose output is a picture — and the rendering side has its own single hard idea, which is not syntax.
Today’s win is a banded gas giant turning in a Godot scene, built from four lines of maths and no texture. Getting there means meeting that idea head-on, plus the one trap that ruins almost every first attempt at a procedural sphere.
01 You still don’t write the loop. Now you don’t pick the element either.
In a compute shader you at least chose the workload: you dispatched n groups, and gl_GlobalInvocationID told each invocation which of your elements it owned. The mapping from invocation to data was yours to design.
A fragment shader takes even that away. You put a mesh in a scene; the rasterizer works out which pixels the mesh’s triangles cover, and runs your fragment() function once for each of them. You never say how many. You never say which. A planet that fills the screen might run your function two million times this frame and four hundred times next frame, when the ship flies away, and the code does not change.
What you get instead is context: for the pixel you happen to be shading, the renderer hands you interpolated values describing the point on the surface underneath it. Your job is to answer one question — what colour is the surface here? — and write it to ALBEDO, the base colour Godot then feeds into its lighting.
Godot's shading language is not GLSL. It is a language that looks like GLSL, compiled by Godot into whatever the renderer needs. Files end in .gdshader, start with a shader_type line instead of #version, and expose engine built-ins in UPPER_CASE rather than gl_ names. Types, vectors, swizzles and the standard library carry over unchanged, so everything you know still applies (Godot: Shading language).
02 The input you want is not the one you are offered
So: the renderer hands you context about the surface point. The obvious candidate is UV — the texture coordinate, the thing every sphere mesh already carries. Reach for it and your planet will be ruined in two specific ways.
Godot’s SphereMesh uses an equirectangular mapping, the world-map layout: U runs once around the equator, V runs pole to pole. That mapping is a rectangle stretched over a ball, and a rectangle does not fit a ball. Look at what happens when you draw an ordinary checkerboard through those coordinates:
Two failures, both fatal for a planet. At the top and bottom, every column of the map converges on a single point, so detail smears into a fan — the pole pinch. And down one side runs a hard vertical line where U wraps from 1.0 back to 0.0 and the pattern fails to meet itself — the seam. The checkerboard makes both obvious; noise, which never lines up at the wrap, makes the seam worse.
You cannot fix this by choosing a cleverer pattern. It is the parameterization that is wrong.
The fix is to stop describing the surface point by where it sits on a map and start describing it by which way it faces. Every point on a sphere is a direction from the centre. Directions have no edges, no wrap and no poles — the north pole is only special on the map, not on the ball. So the input we want is a unit vector.
Getting one is almost free, because on a sphere centred on its own origin, the model-space position of a surface point is its direction, up to length. But there is a catch worth knowing before it bites you:
VERTEX— Position of the vertex, in model space. — vertex built-ins
VERTEX— Position of the fragment (pixel), in view space. — fragment built-ins
Same name, different space, depending on which function you read it in. If you read VERTEX in fragment() you get a position relative to the camera, so your planet’s markings would swim around every time the camera moved. What you want is the model-space value, and the way to move a value from the vertex stage to the fragment stage is a varying: declare it at global scope, write it in vertex(), read the interpolated result in fragment().
varying vec3 model_pos;
void vertex() {
model_pos = VERTEX; // model space here
}
void fragment() {
vec3 dir = normalize(model_pos); // a direction, seamless and pole-free
}
Three lines, and the trap is gone for the rest of the course. Authoring in model space also means the markings are welded to the planet: rotate the mesh, move it across the system map, scale it for a distant view, and the storm you painted stays exactly where you put it.
03 The contract for the whole course
Everything from here — bands, storms, continents, ice caps, clouds — is one function:
vec3 planet_color(vec3 dir)
Direction in, colour out. That is the entire interface, and it is what makes a planet shader tractable: no state, no neighbours, no order of evaluation. Ask about any direction and get the same answer every time.
Keep it as a separate function rather than inlining it into fragment(). It is the piece you will grow every lesson, the piece you paste into the lab below to iterate quickly, and the piece you will eventually generate variants of from a seed.
04 Bands, from one sine wave
A gas giant’s most recognisable feature is horizontal banding, and banding is a function of latitude alone. Latitude comes straight out of the direction:
float lat = asin(clamp(dir.y, -1.0, 1.0)); // -PI/2 south … +PI/2 north
The clamp is not superstition — interpolating positions across a triangle and normalizing can leave dir.y a hair outside [-1, 1], and asin of 1.0000001 is undefined.
Then: one sine wave along latitude, remapped from -1…1 into 0…1, hardened with smoothstep, and used to blend two colours. That is the whole planet.
Move band_freq and you change how many bands there are. Move softness and you slide between a soft airbrushed gradient and crisp painterly edges — that slider alone is most of the difference between “realistic” and “stylized”, which is why it is exposed on day one rather than hard-coded.
Why asin(dir.y) and not just dir.y? dir.y is the sine of latitude, not latitude. Bands built from it come out evenly spaced in screen height, which reads flat, like a striped disc. Bands built from asin(dir.y) are evenly spaced in angle, so they crowd towards the poles the way markings on a ball actually do. Both are legitimate looks; the second reads more like a sphere. Try swapping it in the lab.
05 Put it in Godot
Two files. The shader is the lab’s function with uniforms declared Godot’s way, wrapped in the varying from section 02.
res://planet/planet.gdshader
shader_type spatial;
uniform float band_freq : hint_range(1.0, 40.0) = 11.0;
uniform float softness : hint_range(0.005, 0.5) = 0.35;
uniform vec3 color_a : source_color = vec3(0.91, 0.81, 0.63);
uniform vec3 color_b : source_color = vec3(0.64, 0.38, 0.23);
varying vec3 model_pos;
void vertex() {
model_pos = VERTEX;
}
vec3 planet_color(vec3 dir) {
float lat = asin(clamp(dir.y, -1.0, 1.0));
float t = sin(lat * band_freq) * 0.5 + 0.5;
t = smoothstep(0.5 - softness, 0.5 + softness, t);
return mix(color_a, color_b, t);
}
void fragment() {
vec3 dir = normalize(model_pos);
ALBEDO = planet_color(dir);
ROUGHNESS = 1.0;
SPECULAR = 0.0;
}
hint_range gives you a slider in the inspector; source_color tells Godot the value is colour data and needs colour-space handling, and gives you a colour picker instead of three number boxes. The default value goes after the hint, which is the opposite of what most people guess (Godot: Shading language).
ROUGHNESS = 1.0 with SPECULAR = 0.0 gives a matte, chalky surface. A gas giant with a glossy highlight on it looks like a snooker ball, and that one line is what prevents it.
res://planet/planet_demo.gd
New 3D scene, Node3D root, attach this, press play.
extends Node3D
func _ready() -> void:
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0 # must be 2 * radius, or you get an egg
sphere.radial_segments = 64
sphere.rings = 32
var material := ShaderMaterial.new()
material.shader = load("res://planet/planet.gdshader")
var planet := MeshInstance3D.new()
planet.mesh = sphere
planet.material_override = material
add_child(planet)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-20.0, -55.0, 0.0)
add_child(sun)
var camera := Camera3D.new()
camera.position = Vector3(0.0, 0.0, 3.4)
camera.current = true
add_child(camera)
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.02, 0.02, 0.04)
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.25, 0.28, 0.40)
environment.ambient_light_energy = 0.15
var world := WorldEnvironment.new()
world.environment = environment
add_child(world)
You should get a banded sphere with a soft day/night terminator across it, lit by the directional light. Select the MeshInstance3D while the scene is running and the four uniforms appear under Material Override → Shader Parameters, live.
Notice what you did not have to write: nothing about lighting. You supplied an albedo, and Godot’s renderer did the rest. Custom lighting is lesson 9’s business; for now, free shading is a good trade.
06 Try these
Three edits, in the lab above or in Godot. The second one is the interesting one.
-
See the trap for yourself. In the bands lab, replace
asin(clamp(dir.y, -1.0, 1.0))with plaindir.yand multiplyband_freqby about 1.6. Same bands, differently distributed. Decide which one you actually want for your game. -
Break the regularity. Evenly spaced bands read as manufactured. Sum a second sine at a ratio that never repeats cleanly:
float lat = asin(clamp(dir.y, -1.0, 1.0)); float wave = sin(lat * band_freq) + 0.6 * sin(lat * band_freq * 2.37 + 1.7); float t = wave / 1.6 * 0.5 + 0.5;Two sines, and it stops looking like a beach ball. Hold on to that instinct — it is the seed of the noise you build next lesson.
-
Confirm the pinch is about the mapping, not the mesh. In Godot, swap
fragment()toALBEDO = vec3(fract(UV.x * 23.0) < 0.5 ? 1.0 : 0.0);and look at the poles. Then put it back.
07 Retrieval
Answer from memory, before scrolling back. One attempt each.
08 Where to go from here
Primary source — read this one. Godot: Spatial shaders shader reference. Skim the whole built-in table once. You do not need to memorise it, but you do need to know it exists and that every entry names a coordinate space — that column is the one that will save you.
If you want the gentler on-ramp. Godot: Your first 3D shader walks the same ground more slowly, with vertex displacement instead of colour.
If you want to see where this is going. Deep-Fold/PixelPlanets is a full set of Godot planet shaders — gas giants, rocky worlds, clouds, rings — by a working artist. The style is pixel-art and this course is not, but the structure of the maths is exactly what you are building towards. There is a web demo if you would rather look than read.
Keep for reference. The glossary now has the pipeline vocabulary (fragment, rasterizer, varying), the three coordinate spaces, and the pole pinch / seam definitions.
Next lesson builds the noise toolkit — hash, value noise, fbm — in 3D, so it can be sampled by direction with no seam and no pinch. That is the machine that turns these two sines into weather.
Stuck, curious, or suspicious of something above? Ask. I am your teacher for this course, not just the author of the page — if the pole pinch does not make sense, if the varying feels like ceremony, or if you want to know why your planet looks wrong, say so and we will work it out.