i call it the tumbler
two shaders, one for the bubble, another for the blend. could just be 1 i forget why i did two.
// Shared bubble shader used across the app.
// Supports: tilt parallax, drag offset, barrel distortion, chromatic aberration,
// rim color bleed, glass overlay, specular highlights.
// Outputs fully opaque (alpha=1) with white background to avoid iOS EDR dimming.
export const bubbleShaderSource = Skia.RuntimeEffect.Make(`
uniform shader rock;
uniform shader glass;
uniform float2 resolution;
uniform float2 tilt;
uniform float time;
uniform float2 drag;
uniform float dragScale;
uniform float uBarrelStr;
uniform float uCaStr;
uniform float uFadeStart;
uniform float uFadeEnd;
uniform float uGlassMul;
uniform float uRimStr;
uniform float uRimCaStr;
uniform float uEdgePow;
uniform float uBarrelStart;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution;
float2 sphereCenter = float2(0.5, 0.468);
float sphereR = 0.432;
float2 fromSphere = uv - sphereCenter;
float sphereDist = length(fromSphere);
float snd = clamp(sphereDist / sphereR, 0.0, 1.0);
float z = sqrt(max(1.0 - snd * snd, 0.0));
float3 normal = normalize(float3(fromSphere / max(sphereR, 0.001), z));
float3 V = float3(0.0, 0.0, 1.0);
float edgeStretch = smoothstep(0.5, 1.0, snd);
// Barrel distortion
float barrelSnd = smoothstep(uBarrelStart, 1.0, snd);
float barrel = 1.0 - pow(barrelSnd, uEdgePow) * uBarrelStr;
float2 parallax = tilt * 0.05 * (1.0 - snd * 0.3);
float breath = sin(time * 2.5) * 0.018;
float2 dragOffset = drag / resolution;
float2 warpedUV = sphereCenter fromSphere * barrel parallax;
float2 rockUV = warpedUV float2(0.0, breath) dragOffset;
rockUV = sphereCenter (rockUV - sphereCenter) * (1.0 / max(dragScale, 0.01));
// Sample rock chromatic aberration
half4 rockSample = rock.eval(rockUV * resolution);
float caAmount = pow(edgeStretch, 2.0) * uCaStr;
float2 caOffset = float2(caAmount, 0.0);
half4 rockR = rock.eval((rockUV caOffset) * resolution);
half4 rockB = rock.eval((rockUV - caOffset) * resolution);
float caMask = pow(edgeStretch, 1.5) * max(rockSample.a, max(rockR.a, rockB.a));
rockSample.r = mix(rockSample.r, rockR.r, caMask);
rockSample.b = mix(rockSample.b, rockB.b, caMask);
// Rock alpha fade
float rockA = rockSample.a;
float edgeFade = 1.0 - smoothstep(uFadeStart, uFadeEnd, snd);
rockA *= edgeFade;
// Glass
half4 glassSample = glass.eval(fragCoord);
// Rim color bleed
float insideSphere = 1.0 - smoothstep(0.98, 1.0, snd);
float rimBlend = pow(smoothstep(0.55, 1.0, snd), 3.0) * insideSphere;
float2 toCenter = normalize(sphereCenter - uv float2(0.001));
float2 breathDrag = float2(0.0, breath) dragOffset;
float angle = atan(fromSphere.y, fromSphere.x);
half3 rimCol = half3(0.0);
float rimA = 0.0;
for (float a = -0.4; a <= 0.4; a = 0.2) {
float2 dir = float2(cos(angle 3.14159 a), sin(angle 3.14159 a));
for (float d = 0.05; d <= 0.25; d = 0.066) {
half4 s = rock.eval((uv dir * d breathDrag) * resolution);
rimCol = s.rgb * s.a;
rimA = s.a;
}
}
rimCol = rimA > 0.01 ? rimCol / rimA : half3(1.0);
float rimPresence = clamp(rimA / 10.0, 0.0, 1.0);
float spread = pow(rimBlend, 2.0) * uRimCaStr;
half4 pullR = rock.eval((uv toCenter * 0.12 float2(spread, 0.0) breathDrag) * resolution);
half4 pullB = rock.eval((uv toCenter * 0.12 - float2(spread, 0.0) breathDrag) * resolution);
rimCol.r = mix(rimCol.r, pullR.r, pow(rimBlend, 1.5) * pullR.a);
rimCol.b = mix(rimCol.b, pullB.b, pow(rimBlend, 1.5) * pullB.a);
float vertFade = 1.0 - max(0.0, -fromSphere.y / sphereR) * 0.5 - abs(fromSphere.y / sphereR) * 0.15;
float rimFade = rimBlend * rimBlend * (3.0 - 2.0 * rimBlend) * 0.5;
float gradualFade = pow(rimFade, 0.8);
// Composite
half3 result = mix(half3(1.0), rockSample.rgb * 0.88, rockA);
half3 rimMultiplied = result * (rimCol * 1.05);
result = mix(result, rimMultiplied, gradualFade * uRimStr * rimPresence * vertFade);
result *= mix(half3(1.0), glassSample.rgb, uGlassMul);
// Specular
float3 L1 = normalize(float3(tilt.x - 0.3, tilt.y - 0.4, 1.0));
float3 L2 = normalize(float3(tilt.x 0.5, tilt.y 0.3, 0.7));
float spec1 = pow(max(dot(reflect(-L1, normal), V), 0.0), 150.0);
float spec2 = pow(max(dot(reflect(-L2, normal), V), 0.0), 60.0);
result = spec1 * 0.4;
result = spec2 * 0.12;
result = clamp(result, half3(0.0), half3(1.0));
// Premultiply against white — fully opaque output avoids iOS EDR dimming
float alpha = smoothstep(sphereR 0.002, sphereR - 0.01, sphereDist);
result = mix(half3(1.0), result, alpha);
return half4(result, 1.0);
}
`)!;
// Default uniform values for a nice static/passive bubble
export const BUBBLE_DEFAULTS = {
tilt: [0, 0] as [number, number],
drag: [0, 0] as [number, number],
dragScale: 1,
uBarrelStr: 0.40,
uCaStr: 0.05,
uFadeStart: 0.71,
uFadeEnd: 1.10,
uGlassMul: 0.60,
uRimStr: 0.67,
uRimCaStr: 0.20,
uEdgePow: 1.70,
uBarrelStart: 0.91,
};
and then for the noisy blend:
// Polishing dissolve — wide soft wipe with noise-broken edge
const blendShader = Skia.RuntimeEffect.Make(`
uniform shader imageA;
uniform shader imageB;
uniform float blend;
uniform float2 resolution;
float hash(float2 p) {
float3 p3 = fract(float3(p.xyx) * 0.1031);
p3 = dot(p3, p3.yzx 33.33);
return fract((p3.x p3.y) * p3.z);
}
float noise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i float2(1,0)), f.x),
mix(hash(i float2(0,1)), hash(i float2(1,1)), f.x),
f.y
);
}
float fbm(float2 p) {
float v = 0.0;
float amp = 0.5;
for (int i = 0; i < 4; i ) {
v = amp * noise(p);
p *= 2.0;
amp *= 0.5;
}
return v;
}
half4 main(float2 fragCoord) {
half4 raw = imageA.eval(fragCoord);
half4 polished = imageB.eval(fragCoord);
float2 uv = fragCoord / resolution;
// Noise-dominant threshold — organic patches, not a directional wipe
float n = fbm(uv * 5.0);
float radial = length(uv - float2(0.5, 0.5)) * 0.15;
float threshold = n radial;
float t = blend * 1.6 - 0.15;
// Non-overlapping pixels: where raw exists but polished doesn't (or vice versa)
// fade these out/in earlier so they don't linger awkwardly
float rawOnly = raw.a * (1.0 - polished.a); // raw pixels with no polished match
float polOnly = polished.a * (1.0 - raw.a); // polished pixels with no raw match
// Shift threshold down for raw-only pixels (they disappear earlier)
// Shift threshold up for polished-only pixels (they appear earlier)
float adjust = -rawOnly * 0.3 polOnly * 0.3;
float adjThreshold = threshold adjust;
float mask = smoothstep(t - 0.09, t 0.09, adjThreshold);
float edgeBand = smoothstep(t - 0.09, t - 0.015, adjThreshold)
- smoothstep(t 0.015, t 0.09, adjThreshold);
edgeBand = max(edgeBand, 0.0);
// Polished underneath, raw on top — mask=1 means still raw
half4 result = mix(polished, raw, mask);
// Bright wet-gloss shimmer at the transition front
result.rgb = edgeBand * 0.15;
return result;
}
`)!;