fragsplainer Bubblin
A Shader, Explained

Bubblin

A first-person drift through a froth of refracting glass bubbles.

You’re flying, slowly, through a tunnel packed with glass blobs — each one bending the light of the ones behind it. There’s no model and no mesh here: the whole scene is a cloud of raymarched noise, and every blob is treated as a lens that the ray refracts through on its way to your eye. Drag on the panel to swing the light around.


The render is split across two passes. A heavy buffer does all the marching and refracting; a trivial image pass just develops the result like a photograph. Nothing feeds back — the buffer is here purely to keep the expensive work on its own canvas.

Pass · Buffer A — march, refract, repeat

Every surface in the scene comes from one function. A scrolling block of 3-D simplex noise is thresholded: wherever it dips below zero is inside the glass, and the zero-crossing is the bubble skin. The density 0.60 dial slides that threshold, so the froth thickens or thins out.

The scene is a thresholded noise field → technical explainer

There are no spheres stored anywhere. map() twists a copy of the marching point, samples a single octave of simplex noise scrolling along the tunnel, and offsets it by the density bias. The result is read as a rough signed distance — negative inside a blob, positive in the gaps.

nfp.xy *= Rot(sin(TT+p.z));               // twist the field along the tunnel
float nf = simplex3d(nfp + vec3(0.,TT,0.)); // scrolling 3d simplex noise
nf += density - abs(nfp.y)*0.2;           // bias the threshold

Because it’s noise, not a real distance, the march has to creep — each step advances only 0.3× the reported distance so it never overshoots a thin membrane.

Walking the ray forward → technical explainer

Raymarching fires a ray per pixel and steps a point along it, asking the field “how far to the nearest surface?” at each step. The trick that makes this shader glass is the distanceFactor: while the ray is travelling inside a bubble it flips the sign of the field, so the same map() now measures distance to the far wall from within.

ds = mapr.x;
ds *= distanceFactor;   // flip sdf when inside the glass
d += max(ds, DELTA);    // advance the march
if(ds<0.) { hit=true; break; }

When a ray crosses a skin it doesn’t stop — the outer loop runs it up to four times, each pass refracting it onward to the next surface.

Bending the ray through the glass → technical explainer

This is the heart of it. At each hit the ray is refracted by the index ratio refraction 1.15. GLSL’s built-in refract returns a zero vector when the angle is too steep for light to escape — total internal reflection — and the shader falls back to a mirror bounce.

vec3 refraction = refract(rd, norm, refractionRatio);
if (dot(refraction, refraction) < DELTA) {  // total internal reflection
    rd = reflection;
} else {
    rd = refraction;
    refractionRatio = 1.0 / refractionRatio; // invert crossing the boundary
    distanceFactor = -distanceFactor;        // now marching inside
}

Each successful crossing inverts the index ratio (you’re now going glass→air instead of air→glass) and flips distanceFactor, so the loop seamlessly continues marching on the inside.

Colour, light, and a hand-held wobble → technical explainer

Surface colour is improvised from the hit normal and the procedural detail noise, tinted by three slightly-detuned sines that cycle along the tunnel depth. A single moving light adds a diffuse-plus-specular highlight on the outermost faces. The camera itself bobs camera bob 0.10 and tilts on lazy sine curves, so the drift never feels locked to a rail. fly speed 0.40 sets how fast you fall down the tunnel.

Pass · Image — develop the frame

The image pass is two lines: read Buffer A and lift it with a gamma curve. The pow(col, 0.7) brightens the murk and deepens the contrast, like pushing a print in the darkroom.

Gamma develop
vec3 col = texture(iChannel0,uv).rgb;
col = pow(col, vec3(.7));    // gamma correction (tweaked)
↗ Shadertoy
1 // with wisdom from:
2 // Nrx glass polyhedron - https://www.shadertoy.com/view/4slSzj
3 // artofcode raymarch - https://www.shadertoy.com/view/WtGXDD
4 // artofcode bending light - https://www.shadertoy.com/view/sllGDN
5
6 float nsin(float a){return sin(a)*0.5+0.5;}
7
8 float rand(vec2 n) {
9 return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);
10 }
11
12 mat2 Rot(in float a) {
13 return mat2(cos(a),-sin(a),
14 sin(a),cos(a));
15 }
16
17 float noise(vec2 p){
18 vec2 ip = floor(p);
19 vec2 u = fract(p);
20 u = u*u*(3.0-2.0*u);
21
22 float res = mix(
23 mix(rand(ip),rand(ip+vec2(1.0,0.0)),u.x),
24 mix(rand(ip+vec2(0.0,1.0)),rand(ip+vec2(1.0,1.0)),u.x),u.y);
25 return res*res;
26 }
27
28 // Procedural stand-in for the original texture-based normal map. The Shadertoy
29 // version sampled a bitmap in iChannel0; here we lift surface detail straight
30 // out of the value-noise gradient instead, so no texture is needed.
31 vec2 procNormalMap(in vec2 uv) {
32 float e = 0.02;
33 float p = noise(uv);
34 float h1 = noise(uv + vec2(e, 0.));
35 float v1 = noise(uv + vec2(0., e));
36 return (p - vec2(h1, v1));
37 }
38
39 // The whole scene is a single signed-distance field: a turbulent noise field
40 // thresholded into a froth of blobby surfaces. Negative inside, positive out.
41 vec2 map(vec3 p) {
42 float mater = -1.;
43 float d = 1000.;
44
45
46 vec3 nfp = p;
47 nfp.xy *= Rot(sin(TT+p.z)); // twist the field along the tunnel
48 float nf = simplex3d(nfp + vec3(0.,TT,0.)); // scrolling 3d simplex noise
49 nf += 0.60 - abs(nfp.y)*0.2; // bias the threshold (bubble density)
50
51
52 if (nf < 0.001) mater = 2.;
53 d = min(d, nf);
54
55 return vec2(d, mater);
56 }
57
58 vec3 getNorm(vec3 p) {
59 vec2 mapr = map(p);
60 vec2 e = vec2(.001, 0);
61
62 float d = mapr.x;
63 // gradient of the field = surface normal
64 vec3 n = d - vec3(
65 map(p-e.xyy).x,
66 map(p-e.yxy).x,
67 map(p-e.yyx).x);
68
69 return normalize(n);
70 }
71
72 vec3 getLightPos(in int i) {
73 vec3 lightPos = vec3(0, 5, 6);
74 if (i==1) lightPos.xz += vec2(sin(iTime), cos(iTime))*2.;
75 return lightPos;
76 }
77
78 float getLight(vec3 p, vec3 n, in vec3 rd) {
79 vec3 lp = getLightPos(1);
80 vec3 l = normalize(lp-p);
81
82 float dif = clamp(dot(n, l), 0., 1.);
83 float d = map(p+n*SURF_DIST*2.).x;
84 if(d<length(lp-p)) dif *= .1;
85
86 // Standard specular term.
87 float spec = pow(max( dot( reflect(-l, n), -rd ), 0.), 50.);
88
89 return dif + spec;
90 }
91
92 vec3 getCol(vec3 p, float mater, in vec3 rd, in vec3 norm) {
93 vec3 col = vec3(0.);
94 if (mater < 0.) {
95 return vec3(0.0);
96 } else if (mater < 0.5) {
97 col = norm;
98 col *= norm;
99 } else if (mater < 1.5) {
100 col = vec3(sin(norm.r*100.));
101 col *= norm;
102 } else if (mater < 2.5) {
103
104 vec2 txuv = norm.rg;
105 txuv.x += iTime * 0.02;
106 vec3 text = vec3(noise(txuv*6.0)); // procedural surface detail
107 col = mix(col,text*0.5,1.-col.r);
108 col *= length(p.xy/3.)-0.25;
109 col.r += sin(p.z+108.); // tint cycles along the tunnel
110 col.g += sin(p.z*1.01+112.);
111 col.b += sin(p.z*1.005+105.);
112 col *= sin(p.z*0.9)*0.5+0.5;
113 col /= 2.;
114
115 } else if (mater < 3.5) {
116 col = norm;
117 col = vec3(sin(norm.b*100.));
118 } else if (mater < 4.5) {
119 col = p * mat3(vec2(0.5), 0.1, vec2(0.2), 0.9, vec2(0.4), 1.);
120 } else {
121 col = vec3(0.9,0.85,0.8) * 0.1;
122 }
123 return col;
124 }
125 vec3 k = vec3(0.);
126
127 bool doesBounce(in float mater) {
128 if (mater > 1.5 && mater < 2.5) return true; // 2
129 if (mater > 4.5 && mater < 5.5) return true; // 5
130 return false;
131 }
132
133 vec3 materNorm(in float mater, in vec3 norm) {
134 vec3 mN = vec3(0.);
135 if (mater > 1.5 && mater < 2.5) {
136 vec2 txuv = norm.rg;
137 txuv.x += iTime * 0.02;
138 mN = procNormalMap(txuv).xyx;
139 }
140 return mN;
141 }
142
143 void mainImage( out vec4 fragColor, in vec2 fragCoord )
144 {
145 vec2 uv = fragCoord/iResolution.xy;
146 vec2 uvc = (fragCoord-.5*iResolution.xy)/iResolution.y;
147 vec3 p, ro, rd, col;
148 ro = vec3(0.);
149 ro.z += TT; // fly forward down the tunnel
150 ro -= 0.10*sin(vec3(iTime*1.,iTime*0.22,iTime*0.91)); // hand-held bob
151 rd = normalize(vec3(uvc.x, uvc.y, .8));
152 rd.yz *= Rot(sin(iTime)*0.05); // gentle look-around
153 rd.xy *= Rot(sin(iTime*1.1)*0.05);
154 rd.zx *= Rot(sin(iTime*0.9)*0.05);
155
156 float d = 0.;
157 bool hit = false;
158 float glow = 0.;
159 float mater = -1.;
160
161 float intensity = 0.;
162
163 vec3 backColor = vec3(54./255., 57./255., 63./255.);
164 col = vec3(0.);
165
166 float distanceFactor = 1.0; // +1 outside the glass, -1 inside
167 float refractionRatio = 1.0 / 1.15;
168 for (int rayIndex = 0; rayIndex < RAY_COUNT; ++rayIndex) {
169 d = 0.; // reset ray dist
170 float ds = 0.; // distance step
171
172 for(int i=0;i<MAX_STEP;i++){
173 p = ro + rd * d; // current point
174 vec2 mapr = map(p);
175 ds = mapr.x; // map distance
176 ds *= distanceFactor; // flip sdf when inside the glass
177 mater = mapr.y; // material index
178 ds *= 0.3;
179 d += max(ds, DELTA); // advance the march
180 if(ds<0.) { hit=true; break; } // crossed a surface
181 if(d>MAX_DIST) break; // escaped to infinity
182 glow += 1./ds; // accumulate glow in empty space
183 }
184
185 if (!hit) {col = backColor; break;}
186 vec3 norm = distanceFactor*getNorm(p);
187 // material color
188 col += getCol(p,mater,rd,norm) * AMBIENT;
189 // matte lighting
190 float light = getLight(p, norm, rd);
191 if (rayIndex==0) {
192 col += mix(vec3(light*light), vec3(0.), smoothstep(0.,0.5,ds)); // light outside
193 col = mix(col, backColor, smoothstep(0.,0.5,ds)); // background
194 }
195 // check for bounces
196 bool do_bounce = doesBounce(mater);
197 if (!do_bounce) {
198 break; // hit a matte surface — done bouncing
199 } else {
200 // perturb the normal with the procedural detail before refracting
201 norm += materNorm(mater, norm);
202
203
204 vec3 reflection = reflect(rd, norm);
205 if (distanceFactor > 0.) { // only light the outside faces
206 vec3 lp = getLightPos(1);
207 vec3 lightDirection = normalize(lp-p);
208 float reflectDiff = max (0.0, dot (norm, lightDirection));
209 float reflectSpec = pow (max (0.0, dot (reflection, lightDirection)), SPECULAR_POWER) * SPECULAR_INTENSITY;
210 float fade = pow (1.0 - d / RAY_LENGTH_MAX, FADE_POWER);
211
212 vec3 localColor = max(sin (k * k), 0.2);
213 localColor = (AMBIENT + reflectDiff) * localColor + reflectSpec;
214 localColor = mix(backColor, localColor, fade);
215
216 col = col * (1.0 - intensity) + localColor * intensity;
217 intensity *= REFRACT_FACTOR;
218 }
219
220 // Next ray: bend it through the surface (or bounce off at grazing angles).
221 ro = p;
222 vec3 refraction = refract(rd, norm, refractionRatio);
223 if (dot (refraction, refraction) < DELTA) { // total internal reflection
224 rd = reflection;
225 ro += rd * DELTA * 2.0; // step off the surface
226 } else {
227 rd = refraction; // continue refracted
228 ro += rd * DELTA * 2.0;
229 refractionRatio = 1.0 / refractionRatio; // invert ratio crossing the boundary
230 distanceFactor = -distanceFactor; // flip the sdf sign while inside
231 }
232
233 }
234 }
235
236 col *= 1.+smoothstep(0.3,1.,sin(TT*10.+p.z*2.)); // travelling shimmer
237
238 fragColor = vec4(col,1.);
239 }
240