Flowing Color Field
A field of noise folded over itself and run through a kiln-heat palette, with a pad and wind generated by the same shader.
Watch
The idea
A flowing field of colour from nothing but a formula: no images, no textures, no video. Every pixel asks one question, what is the noise value here, right now?, and the answer picks a colour from a ramp that runs from near-black through ember and gold to white-hot.
That is the whole shader. It is built in four small steps, and each step is a shader you can run on its own.
Stage 1 · Coordinates and time
A fragment shader runs once for every pixel. It is handed uv, where the pixel is on the screen, from 0 to 1 with (0, 0) at the bottom left, and u.time, the seconds since the start. Return a colour and you have a picture.
Here red follows uv.x, green follows uv.y, and blue breathes with the clock. Every later stage starts from these two inputs.
stage-1-gradient.metal
// Stage 1 · Coordinates and time
// The smallest shader there is: colour every pixel from where it is and what time it is.
// @param speed 0.5 0.0 2.0 How fast the blue cycles
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
// uv runs from 0 to 1 across the screen: x to the right, y upward.
// Red follows x, green follows y, and blue breathes with the clock.
float blue = 0.5 + 0.5 * sin(u.time * p.speed * 3.0);
return float4(uv.x, uv.y, blue, 1.0);
}Stage 2 · Noise
Random numbers alone look like static. To get something that flows, give each cell of a grid its own random number (hash21), then blend smoothly between neighbouring cells (vnoise). That is value noise.
One layer of it looks blobby. Stack five layers, each twice as fine and half as strong as the one before, and you get fractal noise (fbm): big clouds with smaller detail inside them. Adding u.time * p.speed to the coordinates makes the pattern drift.
stage-2-noise.metal
// Stage 2 · Noise
// Random values on a grid, blended smoothly between the grid points, then stacked at finer and finer scales.
// @param speed 0.35 0.0 2.0 How fast the noise drifts
// @param scale 2.2 0.5 6.0 Zoom of the noise: higher means finer detail
// A random number for each cell of a grid.
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// Value noise: those random numbers, blended smoothly between neighbouring cells.
float vnoise(float2 p) {
float2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// Fractal noise: five layers of value noise, each twice as fine and half as strong as the one before.
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise(p);
p = p * 2.03 + float2(17.1, 9.2);
a *= 0.5;
}
return v;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 q = (uv - 0.5) * float2(aspect, 1.0) * p.scale; // centred, and square whatever the window's shape
float n = fbm(q + u.time * p.speed);
return float4(float3(n), 1.0);
}Stage 3 · Folding the field over itself
Now the trick that makes it flow like liquid. Use noise to decide how far to shift each pixel's coordinates, then read the noise at the shifted spot. The picture is noise of noise. This is called domain warping.
The warp slider is how far the shift goes. At 0 you get stage 2 back. Push it up and the clouds start to fold and curl.
stage-3-warp.metal
// Stage 3 · Folding the field over itself
// Use noise to shift the coordinates, then read noise at the shifted spot. This is domain warping.
// @param speed 0.35 0.0 2.0 How fast the field drifts
// @param scale 2.2 0.5 6.0 Zoom of the noise: higher means finer detail
// @param warp 1.1 0.0 3.0 How far the field folds over itself
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(float2 p) {
float2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise(p);
p = p * 2.03 + float2(17.1, 9.2);
a *= 0.5;
}
return v;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 q = (uv - 0.5) * float2(aspect, 1.0) * p.scale;
float t = u.time * p.speed;
// Two noise values become a sideways shift for every pixel...
float2 w = float2(fbm(q + t), fbm(q + float2(5.2, 1.3) - t));
// ...and the picture is the noise read at the shifted spot.
float f = fbm(q + p.warp * w + t * 0.3);
return float4(float3(f), 1.0);
}Stage 4 · The kiln palette
The last step turns a number between 0 and 1 into a colour. The kiln function blends through five stops: near-black, ember, orange, gold and white-hot, using smoothstep so the transitions are soft. The heat slider slides the whole field along that ramp.
This is the finished picture, with the same palette as the channel banner and the intro.
the kiln palette and shade(), from the finished shader
// The Ray Kiln palette: near-black, ember, orange, gold, white-hot.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 q = (uv - 0.5) * float2(aspect, 1.0) * p.scale;
float t = u.time * p.speed;
// Warp the coordinates by noise, then sample noise at the warped spot.
float2 w = float2(fbm(q + t), fbm(q + float2(5.2, 1.3) - t));
float f = fbm(q + p.warp * w + t * 0.3);
float3 col = kiln(f * 1.35 - 0.12 + (p.heat - 0.5) * 0.7);
return float4(col, 1.0);
}The sound
The sound is in the same file. float2 sound(float t, constant Params& p) returns one stereo sample for any time t, and the host calls it 48,000 times a second on the GPU.
It is a slow D minor pad whose notes drift a few cents apart in each ear, so the chord slowly beats. Over it runs wind made of smooth noise, which moves faster as warp goes up, and now and then a soft high note from the D minor pentatonic scale. Because a sound is just a function of time, there is no state to keep: the noise comes from hashing the time.
Try this
- Set
warpto 0, then slowly raise it, and watch stage 2 turn into stage 3. - Set
scaleto 6 for fine grain, or 0.5 for a few huge clouds. - Change
heatwhile it runs: the same shapes, from deep ember to almost white. - Change the five
freqvalues insoundto a chord of your own.
Parameters
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.35 | 0.0 to 2.0 | How fast the field drifts |
scale | 2.2 | 0.5 to 6.0 | Zoom of the noise: higher means finer detail |
warp | 1.1 | 0.0 to 3.0 | How far the field folds over itself |
heat | 0.5 | 0.0 to 1.0 | Slides the palette from deep ember to white-hot |
volume | 0.8 | 0.0 to 1.0 | Loudness of the sound |
The full source
This is the whole episode: the picture, the parameters and the sound.
week-01-flowing-color-field.metal
// Ray Kiln · Week 1 · Flowing Color Field
//
// @episode 1 Flowing Color Field
// @length 60
// @short 30
// @still 14.5
// @teaches Coordinates, time, noise, color palettes
//
// The @episode lines tell `raykiln build` what to make: the main video is the intro plus 60 seconds, the Short
// is 30 seconds, and the thumbnail is the frame at 14.5 seconds.
//
// A field of noise, folded over itself, pushed through the kiln palette.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
// // @param name default min max description
// @param speed 0.35 0.0 2.0 How fast the field drifts
// @param scale 2.2 0.5 6.0 Zoom of the noise: higher means finer detail
// @param warp 1.1 0.0 3.0 How far the field folds over itself
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// Value noise: random values on a grid, blended smoothly between the grid points.
float vnoise(float2 p) {
float2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// Fractal noise: several octaves of value noise, each finer and fainter than the last.
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise(p);
p = p * 2.03 + float2(17.1, 9.2);
a *= 0.5;
}
return v;
}
// The Ray Kiln palette: near-black, ember, orange, gold, white-hot.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 q = (uv - 0.5) * float2(aspect, 1.0) * p.scale;
float t = u.time * p.speed;
// Warp the coordinates by noise, then sample noise at the warped spot.
float2 w = float2(fbm(q + t), fbm(q + float2(5.2, 1.3) - t));
float f = fbm(q + p.warp * w + t * 0.3);
float3 col = kiln(f * 1.35 - 0.12 + (p.heat - 0.5) * 0.7);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The sound is part of the shader: a function of time, run on the GPU 48,000 times a second and put in the video.
// It listens to the same sliders as the picture: `speed` and `warp` shape the wind in it.
float sh11(float x) { return fract(sin(x * 127.1) * 43758.5453); }
float snoise(float x) {
float i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(sh11(i), sh11(i + 1.0), f) * 2.0 - 1.0;
}
float2 sound(float t, constant Params& p) {
const float tau = 6.2831853;
// A slow pad in D minor (D2 A2 D3 F3 A3). Each note drifts a few cents against its neighbours, a different
// amount in each ear, so the chord slowly beats and breathes.
const float freq[5] = {73.42, 110.0, 146.83, 174.61, 220.0};
const float amp[5] = {1.0, 0.8, 0.6, 0.4, 0.3};
float2 pad = float2(0.0);
for (int i = 0; i < 5; i++) {
float drift = 0.0012 * (float(i) - 2.0);
pad.x += amp[i] * sin(tau * freq[i] * (1.0 + drift) * t);
pad.y += amp[i] * sin(tau * freq[i] * (1.0 - drift) * t + 1.0);
}
pad *= (0.65 + 0.35 * sin(tau * t / 11.0)) * 0.16;
// Wind: slow noise that swells and falls, moving faster as the field is folded harder.
float gust = 0.5 + 0.5 * snoise(t * 0.45);
float wind = (snoise(t * (38.0 + 50.0 * p.warp)) + 0.5 * snoise(t * 23.0 + 4.0)) * gust * (0.25 + 0.5 * p.speed) * 0.1;
// Sparks: now and then a soft high note from the D minor pentatonic, from a different place in the stereo field.
float2 sparks = float2(0.0);
const float note[4] = {587.33, 698.46, 880.0, 1174.66};
for (int k = 0; k < 4; k++) {
float period = 3.1 + 0.83 * float(k);
float cell = floor(t / period + float(k) * 0.37);
float since = t - (cell - float(k) * 0.37) * period;
float on = step(0.45, sh11(cell * 2.7 + float(k) * 9.0));
float e = on * exp(-since * 1.8) * (1.0 - exp(-since * 120.0));
float tone = sin(tau * note[k] * t) + 0.3 * sin(tau * note[k] * 2.0 * t);
float pan = 0.5 + 0.4 * sin(cell * 1.9 + float(k));
sparks += float2(tone * (1.0 - pan), tone * pan) * e * 0.05;
}
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((pad + wind + sparks) * 1.1) * fadeIn * p.volume;
}Running it in your own project
Every Ray Kiln shader is the same shape. The standalone download above is the shader with the small wrapper around it that the Ray Kiln host adds, so it compiles with the ordinary Metal compiler and runs in your own app. It defines three entry points: rk_vertex (a full-screen triangle), rk_fragment (calls the shader's shade) and, for shaders with sound, the compute kernel rk_sound.
The Mac app that runs these shaders, with hot reload, sliders, sound and the exporter that makes the videos, is open source: github.com/pablinme/RayKiln.
To draw it, pass the uniforms at fragment buffer 0 and the parameter values at buffer 1, in the order they are declared:
Drawing it, in Swift
struct Uniforms {
var resolution: SIMD2<Float>
var time: Float
var timeDelta: Float
var mouse: SIMD2<Float>
var frame: UInt32
var pad: UInt32 = 0
}
let library = try device.makeLibrary(source: standaloneSource, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "rk_vertex")
descriptor.fragmentFunction = library.makeFunction(name: "rk_fragment")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
// Each frame, inside a render pass:
var uniforms = Uniforms(resolution: size, time: time, timeDelta: dt, mouse: mouse, frame: frame)
var params: [Float] = [0.35, 2.2, 1.1, 0.5, 0.8] // the @param defaults, in the order they are declared
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(¶ms, length: params.count * MemoryLayout<Float>.stride, index: 1)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)Time is in seconds, mouse is in pixels from the bottom left, and uv in the shader runs 0 to 1 with (0, 0) at the bottom left. Write the output as sRGB: the shader's numbers go to the screen as they are.