Heat Haze
The air over a fire as a SwiftUI layer effect. Each point of the view is shown from a little way down the slope of a field of warm and cool pockets that rises from the fire, widens and fades as it climbs, so the room behind the flame wavers and the flame itself swims. The same file runs in SwiftUI as it is.
· SwiftUI Effects · SwiftUI Effect shader
Teaches A SwiftUI distortion effect, from Metal to an app
Builds on Flowing Color Field (hash21, value noise)
Watch
The idea
Every other episode so far draws its picture from nothing. This one doesn't draw anything: it takes a picture that is already there, a SwiftUI view, and moves it. That is what a SwiftUI effect is. You mark a Metal function [[ stitchable ]], and SwiftUI runs it once for every point of a view. A layer effect, as this one is, is handed the view itself as layer, and returns the colour a point should show; layer.sample(position) would show the view unchanged, and layer.sample(position + d) shows each point with the colour from d points away. Choosing d well is all a distortion is.
Heat haze is a good first one because the physics says exactly what d should be. Hot air is thinner than cool air, and light bends where the two meet, towards the cooler, denser side. Over a fire the hot air rises in pockets that mix as they go, so what is seen through them is bent this way and that, a few points at most, always changing. The subject is the fire picture Ray Kiln's SwiftUI previews already have, 320 by 200 points, and everything here is measured in points from the view's top left, as SwiftUI measures them. SwiftUI's y grows downwards, so up the view is towards smaller y.
It is built in five steps, and each step is a whole effect you can run on its own, in the playground or in an app.
Stage 1 · A view shown from somewhere else
The smallest distortion: every point of the view is shown from a little to the side. sin(position.y * 0.15 + time * 4.0) is a wave down the view that travels up it, a full wave about every 42 points, and strength is how far it pushes, up to 10 points. The flame bends into an S and the S climbs. So do the view's left and right edges: a point near an edge is shown from outside the view, where there is nothing, and the near-black behind the view shows through. Nothing yet says where the hot air is.
stage-1-sway.metal
// Stage 1 · A view shown from somewhere else
// A SwiftUI layer effect runs once for every point of a view and returns
// the colour that point shows. It can read the view itself, so a point can
// show its colour from a little way off: that is all a distortion is.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param volume 0.7 0.0 1.0 How loud the fire is
// Each row of the view is shown from a little to the side, by a wave that
// runs up the view.
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float strength) {
float sway = sin(position.y * 0.15 + time * 4.0);
return layer.sample(position + float2(sway, 0.0) * strength);
}
float2 sound(float t, constant Params& p) {
// A low hum for now: three slow sines a little apart.
float h = sin(t * 6.2831853 * 55.0) + 0.5 * sin(t * 6.2831853 * 82.7)
+ 0.3 * sin(t * 6.2831853 * 110.4);
float2 v = float2(h, h * 0.9) * 0.08;
return v * p.volume * smoothstep(0.0, 0.5, t);
}Stage 2 · Pockets of air
A wave is too tidy for air. vnoise is value noise, random values at the corners of a grid blended smoothly between them, made from hash21, an integer hash: integer arithmetic gives the same answer on every GPU, so SwiftUI's own picture and Ray Kiln's agree. air adds two sizes of it, the second about twice as fine and half as strong, and reads it at p + float2(0, t): as the clock runs, a point reads the noise from further down, so the pockets drift up the view. grain is the size of a pocket in points and rise how fast they climb, in points a second.
haze pushes each point by how warm the air is where it sits, reading the field at two places, one for each direction. The view now churns all over, edges and all, like something seen through water.
stage-2-air.metal37 new lines since stage 1
// Stage 2 · Pockets of air
// A wave is too regular for air. Value noise makes warm and cool pockets,
// two sizes of them, and moving the noise carries them upwards. Each point
// is pushed by how warm the air is where it sits.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param rise 40.0 0.0 120.0 How fast the hot air rises, in points a second
// @param grain 14.0 5.0 40.0 The size of the pockets of hot air, in points
// @param volume 0.7 0.0 1.0 How loud the fire is
// A whole-number point in, a random 0 to 1 out, the same on every GPU.
float hash21(float2 p) {
int2 i = int2(floor(p));
uint h = uint(i.x) * 1597334677u ^ uint(i.y) * 3812015801u;
h ^= h >> 16;
h *= 2246822519u;
h ^= h >> 13;
return float(h & 0xFFFFFFu) / 16777216.0;
}
// Value noise: random values at grid corners, blended smoothly.
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);
}
// Warm and cool pockets of air, two sizes, carried up the view.
float air(float2 p, float t) {
float2 q = p + float2(0.0, t);
return vnoise(q) + 0.5 * vnoise(q * 2.1 + float2(17.0, 5.0));
}
// How far the air moves a point, in points: the field read at two places,
// one for each direction, so the push wanders both ways.
float2 haze(float2 position, float t,
float strength, float rise, float grain) {
float2 p = position / grain;
float s = t * rise / grain;
float2 d = float2(air(p, s), air(p + float2(31.7, 0.0), s)) - 0.75;
return d * strength;
}
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float strength,
float rise, float grain) {
return layer.sample(position + haze(position, time, strength, rise, grain));
}
float2 sound(float t, constant Params& p) {
// The roar of the fire: smooth random values at three rates, darker to
// brighter, a different noise for each ear so the fire sounds wide.
float2 roar = 0.0;
for (int ch = 0; ch < 2; ch++) {
float s = float(ch) * 31.0;
roar[ch] = 0.6 * (vnoise(float2(t * 140.0, s + 1.0)) - 0.5)
+ 0.3 * (vnoise(float2(t * 420.0, s + 2.0)) - 0.5)
+ 0.12 * (vnoise(float2(t * 1300.0, s + 3.0)) - 0.5);
}
float2 v = tanh(roar * 0.55 * 1.6) / 1.6;
return v * p.volume * smoothstep(0.0, 0.5, t);
}6 lines from stage 1 that are gone
float time, float strength) {
float sway = sin(position.y * 0.15 + time * 4.0);
return layer.sample(position + float2(sway, 0.0) * strength);
⋯
float h = sin(t * 6.2831853 * 55.0) + 0.5 * sin(t * 6.2831853 * 82.7)
+ 0.3 * sin(t * 6.2831853 * 110.4);
float2 v = float2(h, h * 0.9) * 0.08;Stage 3 · Light bends down the slope
That push is the wrong one. Warm air on its own doesn't move what's behind it; light is bent where the warmth changes, so the push should be the slope of the field, not its value. bend measures the slope by reading air a small step either side, in each direction, and taking the difference. Flat air bends nothing, and the edges of the pockets bend the most, which is why haze looks like fine, quick ripples rather than a slow wobble.
The push is the slope times 0.6 * strength, and it is held to 15 points at most. That limit matters to SwiftUI: a layer effect says in advance how far past a point it may sample, maxSampleOffset, here 16 points each way, and the header line // @maxSampleOffset 16 16 gives the same number to Ray Kiln and to the Swift code. Every stage keeps the same one, so the view doesn't shift between them.
stage-3-slope.metal9 new lines since stage 2
// Stage 3 · Light bends down the slope
// Light isn't pushed by warm air but bent where warm meets cool, towards the
// cooler, denser side. So the push is the slope of the field, not its value:
// flat air bends nothing, and the edges of the pockets bend the most.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param rise 40.0 0.0 120.0 How fast the hot air rises, in points a second
// @param grain 14.0 5.0 40.0 The size of the pockets of hot air, in points
// @param volume 0.7 0.0 1.0 How loud the fire is
// A whole-number point in, a random 0 to 1 out, the same on every GPU.
float hash21(float2 p) {
int2 i = int2(floor(p));
uint h = uint(i.x) * 1597334677u ^ uint(i.y) * 3812015801u;
h ^= h >> 16;
h *= 2246822519u;
h ^= h >> 13;
return float(h & 0xFFFFFFu) / 16777216.0;
}
// Value noise: random values at grid corners, blended smoothly.
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);
}
// Warm and cool pockets of air, two sizes, carried up the view.
float air(float2 p, float t) {
float2 q = p + float2(0.0, t);
return vnoise(q) + 0.5 * vnoise(q * 2.1 + float2(17.0, 5.0));
}
// Light bends towards the cooler, denser air: down the slope of the field.
// The slope is the difference across a small step each way.
float2 bend(float2 p, float t) {
const float e = 0.05;
float dx = air(p + float2(e, 0), t) - air(p - float2(e, 0), t);
float dy = air(p + float2(0, e), t) - air(p - float2(0, e), t);
return float2(dx, dy) / (2.0 * e);
}
// How far the haze moves a point, in points. It never reaches
// past @maxSampleOffset, so SwiftUI has every point it is asked for.
float2 haze(float2 position, float t,
float strength, float rise, float grain) {
float2 p = position / grain;
float2 d = bend(p, t * rise / grain) * 0.6 * strength;
float n = length(d);
return n > 15.0 ? d * (15.0 / n) : d;
}
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float strength,
float rise, float grain) {
return layer.sample(position + haze(position, time, strength, rise, grain));
}
float2 sound(float t, constant Params& p) {
// The roar of the fire: smooth random values at three rates, darker to
// brighter, a different noise for each ear so the fire sounds wide.
float2 roar = 0.0;
for (int ch = 0; ch < 2; ch++) {
float s = float(ch) * 31.0;
roar[ch] = 0.6 * (vnoise(float2(t * 140.0, s + 1.0)) - 0.5)
+ 0.3 * (vnoise(float2(t * 420.0, s + 2.0)) - 0.5)
+ 0.12 * (vnoise(float2(t * 1300.0, s + 3.0)) - 0.5);
}
float2 v = tanh(roar * 0.55 * 1.6) / 1.6;
return v * p.volume * smoothstep(0.0, 0.5, t);
}3 lines from stage 2 that are gone
float s = t * rise / grain;
float2 d = float2(air(p, s), air(p + float2(31.7, 0.0), s)) - 0.75;
return d * strength;Stage 4 · Hot air above the fire
Only the air over the fire is hot. The effect now asks SwiftUI for the view's size, a float2 in points, and plume finds the fire's foot from it, 84% of the way down and in the middle. Above the foot is a column of hot air, a bell across, exp(-across * across), whose width grows from 7% of the view's width at the foot to 37% at the top. It comes on over the first dozen points above the foot, is strongest just above the flame, and fades to about a third of that towards the top of the view. The haze is the stage 3 push times the plume. The edges of the view are still now, the room either side of the fire is steady, and the flame and the dark above it swim.
stage-4-plume.metal14 new lines since stage 3
// Stage 4 · Hot air above the fire
// Only the air over the fire is hot. The effect asks SwiftUI for the view's
// size and finds the fire's foot from it: a column rising from there,
// widening as it climbs and fading towards the top, sets how strong it is.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param rise 40.0 0.0 120.0 How fast the hot air rises, in points a second
// @param grain 14.0 5.0 40.0 The size of the pockets of hot air, in points
// @param volume 0.7 0.0 1.0 How loud the fire is
// A whole-number point in, a random 0 to 1 out, the same on every GPU.
float hash21(float2 p) {
int2 i = int2(floor(p));
uint h = uint(i.x) * 1597334677u ^ uint(i.y) * 3812015801u;
h ^= h >> 16;
h *= 2246822519u;
h ^= h >> 13;
return float(h & 0xFFFFFFu) / 16777216.0;
}
// Value noise: random values at grid corners, blended smoothly.
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);
}
// Warm and cool pockets of air, two sizes, carried up the view.
float air(float2 p, float t) {
float2 q = p + float2(0.0, t);
return vnoise(q) + 0.5 * vnoise(q * 2.1 + float2(17.0, 5.0));
}
// Light bends towards the cooler, denser air: down the slope of the field.
// The slope is the difference across a small step each way.
float2 bend(float2 p, float t) {
const float e = 0.05;
float dx = air(p + float2(e, 0), t) - air(p - float2(e, 0), t);
float dy = air(p + float2(0, e), t) - air(p - float2(0, e), t);
return float2(dx, dy) / (2.0 * e);
}
// Where the hot air is: a column rising from the fire's foot, widening as it
// climbs, strongest just above the flame and fading out towards the top.
float plume(float2 position, float2 size) {
float foot = size.y * 0.84;
float up = clamp((foot - position.y) / foot, 0.0, 1.0);
float width = size.x * (0.07 + 0.30 * up);
float across = (position.x - size.x * 0.5) / width;
float start = smoothstep(0.0, 0.08, up);
float fade = 1.0 - smoothstep(0.55, 1.0, up);
return exp(-across * across) * start * (0.35 + 0.65 * fade);
}
// How far the haze moves a point, in points, at time t. It never reaches
// past @maxSampleOffset, so SwiftUI has every point it is asked for.
float2 haze(float2 position, float2 size, float t,
float strength, float rise, float grain) {
float2 p = position / grain;
float2 d = bend(p, t * rise / grain) * 0.6 * strength;
d *= plume(position, size);
float n = length(d);
return n > 15.0 ? d * (15.0 / n) : d;
}
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float2 size, float strength,
float rise, float grain) {
float2 d = haze(position, size, time, strength, rise, grain);
return layer.sample(position + d);
}
float2 sound(float t, constant Params& p) {
// The roar of the fire: smooth random values at three rates, darker to
// brighter, a different noise for each ear so the fire sounds wide.
float2 roar = 0.0;
for (int ch = 0; ch < 2; ch++) {
float s = float(ch) * 31.0;
roar[ch] = 0.6 * (vnoise(float2(t * 140.0, s + 1.0)) - 0.5)
+ 0.3 * (vnoise(float2(t * 420.0, s + 2.0)) - 0.5)
+ 0.12 * (vnoise(float2(t * 1300.0, s + 3.0)) - 0.5);
}
float2 v = tanh(roar * 0.55 * 1.6) / 1.6;
return v * p.volume * smoothstep(0.0, 0.5, t);
}3 lines from stage 3 that are gone
float2 haze(float2 position, float t,
⋯
float time, float strength,
⋯
return layer.sample(position + haze(position, time, strength, rise, grain));Stage 5 · Heat haze
Two touches, both only a layer effect can make. Real haze is a little soft, because the eye sees it over a moment, not an instant: the finished effect samples the view as the haze is now and as it will be 0.08 s later, and shimmer blends between the first alone and the two averaged. And hot air carries light: glow adds a faint warm tint, red and a little green, where the plume is and the air is warm, times the point's own alpha so a transparent view stays transparent.
shade(), from the finished shader
heat-haze.metal, the whole file31 new lines since stage 4
// Ray Kiln · Heat Haze
//
// @episode Heat Haze
// @length 60
// @variation 30
// @variations 1,2,3,4,5,7,9,11
// @still 21.0
// @teaches A SwiftUI distortion effect, from Metal to an app
// @category SwiftUI Effect
// @tags swiftui, layer effect, distortion, refraction, heat haze, value noise, procedural sound
// @final Heat haze | The air over the fire bends the light, rising and widening as it cools, so the room behind it wavers and the flame itself swims.
//
// The air over a fire is a SwiftUI layer effect: a function SwiftUI runs once
// for every point of a view, returning the colour that point shows. It reads
// the view itself through `layer.sample`, so it can show a point of the view
// from a little way off, which is all a distortion is. Hot air is thinner
// than cool air and light bends where the two meet, so each point of the view
// is shown from a little way down the slope of a field of warm and cool
// pockets that rises from the fire, widens and fades as it goes.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param rise 40.0 0.0 120.0 How fast the hot air rises, in points a second
// @param grain 14.0 5.0 40.0 The size of the pockets of hot air, in points
// @param shimmer 0.5 0.0 1.0 How much two moments of the haze blend, softening it
// @param glow 0.4 0.0 1.0 How much the hot air warms the light it carries
// @param volume 0.7 0.0 1.0 How loud the fire is
// A whole-number point in, a random 0 to 1 out, the same on every GPU.
float hash21(float2 p) {
int2 i = int2(floor(p));
uint h = uint(i.x) * 1597334677u ^ uint(i.y) * 3812015801u;
h ^= h >> 16;
h *= 2246822519u;
h ^= h >> 13;
return float(h & 0xFFFFFFu) / 16777216.0;
}
// Value noise: random values at grid corners, blended smoothly.
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);
}
// Warm and cool pockets of air, two sizes, carried up the view.
float air(float2 p, float t) {
float2 q = p + float2(0.0, t);
return vnoise(q) + 0.5 * vnoise(q * 2.1 + float2(17.0, 5.0));
}
// Light bends towards the cooler, denser air: down the slope of the field.
// The slope is the difference across a small step each way.
float2 bend(float2 p, float t) {
const float e = 0.05;
float dx = air(p + float2(e, 0), t) - air(p - float2(e, 0), t);
float dy = air(p + float2(0, e), t) - air(p - float2(0, e), t);
return float2(dx, dy) / (2.0 * e);
}
// Where the hot air is: a column rising from the fire's foot, widening as it
// climbs, strongest just above the flame and fading out towards the top.
float plume(float2 position, float2 size) {
float foot = size.y * 0.84;
float up = clamp((foot - position.y) / foot, 0.0, 1.0);
float width = size.x * (0.07 + 0.30 * up);
float across = (position.x - size.x * 0.5) / width;
float start = smoothstep(0.0, 0.08, up);
float fade = 1.0 - smoothstep(0.55, 1.0, up);
return exp(-across * across) * start * (0.35 + 0.65 * fade);
}
// How far the haze moves a point, in points, at time t. It never reaches
// past @maxSampleOffset, so SwiftUI has every point it is asked for.
float2 haze(float2 position, float2 size, float t,
float strength, float rise, float grain) {
float2 p = position / grain;
float2 d = bend(p, t * rise / grain) * 0.6 * strength;
d *= plume(position, size);
float n = length(d);
return n > 15.0 ? d * (15.0 / n) : d;
}
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float2 size, float strength,
float rise, float grain, float shimmer,
float glow) {
// The view as the haze shows it now, and a moment later.
float2 a = haze(position, size, time, strength, rise, grain);
float2 b = haze(position, size, time + 0.08, strength, rise, grain);
half4 now = layer.sample(position + a);
half4 soon = layer.sample(position + b);
half4 c = mix(now, (now + soon) * 0.5h, half(shimmer));
// The hot air warms what it carries, more where it is hotter.
float warm = air(position / grain, time * rise / grain);
float heat = plume(position, size) * warm;
c.rgb += half3(0.16, 0.06, 0.0) * half(glow * heat * 0.5) * c.a;
return c;
}
float2 sound(float t, constant Params& p) {
// The roar of the fire: smooth random values at three rates, darker to
// brighter, a different noise for each ear so the fire sounds wide.
float2 roar = 0.0;
for (int ch = 0; ch < 2; ch++) {
float s = float(ch) * 31.0;
roar[ch] = 0.6 * (vnoise(float2(t * 140.0, s + 1.0)) - 0.5)
+ 0.3 * (vnoise(float2(t * 420.0, s + 2.0)) - 0.5)
+ 0.12 * (vnoise(float2(t * 1300.0, s + 3.0)) - 0.5);
}
// The same air that bends the view swells the roar as it rises past the
// flame, and its slope leans the roar left or right.
float2 above = float2(160.0, 70.0) / p.grain;
float swell = air(above, t * p.rise / p.grain);
float2 lean = bend(above, t * p.rise / p.grain);
float pan = clamp(lean.x * 0.4, -0.6, 0.6);
float gain = (0.45 + 0.4 * swell) * (0.6 + 0.4 * p.strength / 10.0);
roar *= gain * float2(1.0 - pan, 1.0 + pan);
// Crackles: on each of twelve ticks a second, a chance of a sharp snap,
// each a burst of fast noise that dies in a few hundredths of a second.
float2 crackle = 0.0;
float tick = floor(t * 12.0);
for (int k = 0; k < 3; k++) {
float n = tick - float(k);
if (hash21(float2(n, 7.0)) > 0.3) continue;
float at = (n + hash21(float2(n, 11.0))) / 12.0;
float since = t - at;
if (since < 0.0) continue;
float snap = (vnoise(float2(t * 5200.0, n)) - 0.5) * exp(-since * 90.0);
snap *= smoothstep(0.0, 0.0015, since);
float side = hash21(float2(n, 13.0)) - 0.5;
crackle += snap * float2(0.5 - side, 0.5 + side) * 1.6;
}
// tanh rounds off the loudest moments instead of letting them clip.
float2 v = tanh((roar * 1.25 + crackle * 0.5) * 1.6) / 1.6;
// Fade in over the first half second, so the sound starts from silence.
return v * p.volume * smoothstep(0.0, 0.5, t);
}4 lines from stage 4 that are gone
float rise, float grain) {
float2 d = haze(position, size, time, strength, rise, grain);
return layer.sample(position + d);
⋯
float2 v = tanh(roar * 0.55 * 1.6) / 1.6;The sound
The fire, heard through the same air. The roar is three smooth random signals, made from the picture's own vnoise read along time at 140, 420 and 1300 values a second, the slow one loudest, with a different noise in each ear so the fire sounds wide. Then the haze itself plays it: sound reads air at a point 70 points down the view in its middle, just above the flame, with the picture's rise and grain, so as each warm pocket rises past, the roar swells, and the slope there, bend, leans the roar left or right. More strength makes it louder too. Crackles come on a grid of twelve ticks a second: on about three ticks in ten a snap lands somewhere in the tick, a burst of fast noise that dies away in about a hundredth of a second, placed at a random point between the ears. A tanh rounds off the loudest moments, and the sound fades in over the first half second.
None of this is SwiftUI's: it has no use for a sound, so the file it takes leaves sound out. At the defaults the peak is about −9.1 dBFS and the average (RMS) about −20.6 dBFS over the minute; with strength and volume at their highest, −5.6 dBFS and −16.0 dBFS.
New in Ray Kiln
SwiftUI effects as episodes. Heat Haze is the first episode that is a SwiftUI effect, and the whole path was made for it. An effect file can now have a sound of its own, float2 sound(float t, constant Params& p), which Ray Kiln plays under the picture and in the videos and which SwiftUI never sees: the standalone download above is the file exactly as SwiftUI takes it, with sound and the comments above it left out. // @backdrop dark draws the effect on the kiln's near-black, as the other episodes are, instead of the preview's light grey. A large picture, the 1080p walkthrough and the desktop wallpaper, is drawn from a copy of the subject at four times its size in points, so the view stays sharp at that size. And Run it live and the exercises draw the effect on its subject, in the room the app shows it in.
To use it in an app, take the standalone file and the SwiftUI code under it on this page: add the .metal file to your app's target, and put the modifier on any view. The SwiftUI effects page says more, and the playground runs it on the fire, a card, a button or a picture of your own. Here, the fire is the subject on purpose: try the playground's other subjects, and the haze still rises from a point 84% of the way down the middle, whatever is there.
Try this
- Set
strengthto 0: the fire as it is, still. Then bring it up slowly, and watch where the haze starts. - Set
riseto 0: the pockets stop, and the haze freezes into a fixed ripple. - Set
grainto 5: fine, quick shimmer, like air over a road in summer; at 40, slow, large waves, like looking through a window pane. - Set
shimmerto 0 and then 1: the haze sharpens, then softens. - In
bend, changeconst float e = 0.05to0.5: the slope is measured over a step ten times wider, and the finest ripples smooth away. - In
plume, change0.84to0.5: the hot air now rises from the middle of the view, and the flame's lower half stops swimming. - In
heatHaze, returnnowalone: no shimmer and no glow, and the effect becomes a plain distortion.
Parameters
Pick a parameter and drag the slider. Each frame is the same moment of the shader, with only that one setting changed.
| Parameter | Default | Range | What it does |
|---|---|---|---|
strength | 6.0 | 0.0 to 10.0 | How far the hot air bends the view, in points |
rise | 40.0 | 0.0 to 120.0 | How fast the hot air rises, in points a second |
grain | 14.0 | 5.0 to 40.0 | The size of the pockets of hot air, in points |
shimmer | 0.5 | 0.0 to 1.0 | How much two moments of the haze blend, softening it |
glow | 0.4 | 0.0 to 1.0 | How much the hot air warms the light it carries |
volume | 0.7 | 0.0 to 1.0 | How loud the fire is |
More from this shader
The same shader 8 more ways: each from a different moment, with different settings, one after another. Each chapter below says which settings moved most, then lists them all.
Wallpapers
A still from the shader, rendered at full size for a desktop screen. Free to use as your wallpaper.
A clip to post
Fifteen seconds of the shader with its sound, sized for posting: square, for feeds. Free to post, ideally with a link to this page.
Made from this episode
The full source
This is the whole episode: the effect, the parameters and the sound.
heat-haze.metal
// Ray Kiln · Heat Haze
//
// @episode Heat Haze
// @length 60
// @variation 30
// @variations 1,2,3,4,5,7,9,11
// @still 21.0
// @teaches A SwiftUI distortion effect, from Metal to an app
// @category SwiftUI Effect
// @tags swiftui, layer effect, distortion, refraction, heat haze, value noise, procedural sound
// @final Heat haze | The air over the fire bends the light, rising and widening as it cools, so the room behind it wavers and the flame itself swims.
//
// The air over a fire is a SwiftUI layer effect: a function SwiftUI runs once
// for every point of a view, returning the colour that point shows. It reads
// the view itself through `layer.sample`, so it can show a point of the view
// from a little way off, which is all a distortion is. Hot air is thinner
// than cool air and light bends where the two meet, so each point of the view
// is shown from a little way down the slope of a field of warm and cool
// pockets that rises from the fire, widens and fades as it goes.
//
// @subject photo
// @backdrop dark
// @maxSampleOffset 16 16
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// @param strength 6.0 0.0 10.0 How far the hot air bends the view, in points
// @param rise 40.0 0.0 120.0 How fast the hot air rises, in points a second
// @param grain 14.0 5.0 40.0 The size of the pockets of hot air, in points
// @param shimmer 0.5 0.0 1.0 How much two moments of the haze blend, softening it
// @param glow 0.4 0.0 1.0 How much the hot air warms the light it carries
// @param volume 0.7 0.0 1.0 How loud the fire is
// A whole-number point in, a random 0 to 1 out, the same on every GPU.
float hash21(float2 p) {
int2 i = int2(floor(p));
uint h = uint(i.x) * 1597334677u ^ uint(i.y) * 3812015801u;
h ^= h >> 16;
h *= 2246822519u;
h ^= h >> 13;
return float(h & 0xFFFFFFu) / 16777216.0;
}
// Value noise: random values at grid corners, blended smoothly.
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);
}
// Warm and cool pockets of air, two sizes, carried up the view.
float air(float2 p, float t) {
float2 q = p + float2(0.0, t);
return vnoise(q) + 0.5 * vnoise(q * 2.1 + float2(17.0, 5.0));
}
// Light bends towards the cooler, denser air: down the slope of the field.
// The slope is the difference across a small step each way.
float2 bend(float2 p, float t) {
const float e = 0.05;
float dx = air(p + float2(e, 0), t) - air(p - float2(e, 0), t);
float dy = air(p + float2(0, e), t) - air(p - float2(0, e), t);
return float2(dx, dy) / (2.0 * e);
}
// Where the hot air is: a column rising from the fire's foot, widening as it
// climbs, strongest just above the flame and fading out towards the top.
float plume(float2 position, float2 size) {
float foot = size.y * 0.84;
float up = clamp((foot - position.y) / foot, 0.0, 1.0);
float width = size.x * (0.07 + 0.30 * up);
float across = (position.x - size.x * 0.5) / width;
float start = smoothstep(0.0, 0.08, up);
float fade = 1.0 - smoothstep(0.55, 1.0, up);
return exp(-across * across) * start * (0.35 + 0.65 * fade);
}
// How far the haze moves a point, in points, at time t. It never reaches
// past @maxSampleOffset, so SwiftUI has every point it is asked for.
float2 haze(float2 position, float2 size, float t,
float strength, float rise, float grain) {
float2 p = position / grain;
float2 d = bend(p, t * rise / grain) * 0.6 * strength;
d *= plume(position, size);
float n = length(d);
return n > 15.0 ? d * (15.0 / n) : d;
}
[[ stitchable ]] half4 heatHaze(float2 position, SwiftUI::Layer layer,
float time, float2 size, float strength,
float rise, float grain, float shimmer,
float glow) {
// The view as the haze shows it now, and a moment later.
float2 a = haze(position, size, time, strength, rise, grain);
float2 b = haze(position, size, time + 0.08, strength, rise, grain);
half4 now = layer.sample(position + a);
half4 soon = layer.sample(position + b);
half4 c = mix(now, (now + soon) * 0.5h, half(shimmer));
// The hot air warms what it carries, more where it is hotter.
float warm = air(position / grain, time * rise / grain);
float heat = plume(position, size) * warm;
c.rgb += half3(0.16, 0.06, 0.0) * half(glow * heat * 0.5) * c.a;
return c;
}
float2 sound(float t, constant Params& p) {
// The roar of the fire: smooth random values at three rates, darker to
// brighter, a different noise for each ear so the fire sounds wide.
float2 roar = 0.0;
for (int ch = 0; ch < 2; ch++) {
float s = float(ch) * 31.0;
roar[ch] = 0.6 * (vnoise(float2(t * 140.0, s + 1.0)) - 0.5)
+ 0.3 * (vnoise(float2(t * 420.0, s + 2.0)) - 0.5)
+ 0.12 * (vnoise(float2(t * 1300.0, s + 3.0)) - 0.5);
}
// The same air that bends the view swells the roar as it rises past the
// flame, and its slope leans the roar left or right.
float2 above = float2(160.0, 70.0) / p.grain;
float swell = air(above, t * p.rise / p.grain);
float2 lean = bend(above, t * p.rise / p.grain);
float pan = clamp(lean.x * 0.4, -0.6, 0.6);
float gain = (0.45 + 0.4 * swell) * (0.6 + 0.4 * p.strength / 10.0);
roar *= gain * float2(1.0 - pan, 1.0 + pan);
// Crackles: on each of twelve ticks a second, a chance of a sharp snap,
// each a burst of fast noise that dies in a few hundredths of a second.
float2 crackle = 0.0;
float tick = floor(t * 12.0);
for (int k = 0; k < 3; k++) {
float n = tick - float(k);
if (hash21(float2(n, 7.0)) > 0.3) continue;
float at = (n + hash21(float2(n, 11.0))) / 12.0;
float since = t - at;
if (since < 0.0) continue;
float snap = (vnoise(float2(t * 5200.0, n)) - 0.5) * exp(-since * 90.0);
snap *= smoothstep(0.0, 0.0015, since);
float side = hash21(float2(n, 13.0)) - 0.5;
crackle += snap * float2(0.5 - side, 0.5 + side) * 1.6;
}
// tanh rounds off the loudest moments instead of letting them clip.
float2 v = tanh((roar * 1.25 + crackle * 0.5) * 1.6) / 1.6;
// Fade in over the first half second, so the sound starts from silence.
return v * p.volume * smoothstep(0.0, 0.5, t);
}Running it in your own project
This shader is a SwiftUI effect: a [[ stitchable ]] function SwiftUI runs on a view’s pixels. The standalone download above is the file as SwiftUI takes it. Add it to your app’s target, and Xcode compiles it into the app’s Metal library, where ShaderLibrary.heatHaze finds it. Its sound is Ray Kiln’s alone: SwiftUI has no use for it, so the standalone file leaves it out.
Then apply it to any view with .layerEffect. Flatten the view first with .compositingGroup(), so the effect sees one picture rather than each layer of the view on its own. The numbers below are the sliders’ defaults; start is when the view appeared, a Date you keep, and size the view’s size, from a GeometryReader or .visualEffect.
Using it, in SwiftUI
TimelineView(.animation) { context in
let time = context.date.timeIntervalSince(start)
yourView
.compositingGroup()
.layerEffect(
ShaderLibrary.heatHaze(.float(time), .float2(size), .float(6.0), .float(40.0), .float(14.0), .float(0.5), .float(0.4)),
maxSampleOffset: CGSize(width: 16, height: 16)
)
}Positions are in points from the view’s top left, as SwiftUI measures them, and maxSampleOffset is how far past its edges the effect may reach. The SwiftUI effects page has more of them, and the playground runs any on SwiftUI’s own pictures.
Press ? for keyboard shortcuts.