{
  "license": "CC0-1.0",
  "shader": {
    "id": "kw7lf6hf",
    "title": "Drifting Orbs",
    "license": "CC0-1.0",
    "provenance_type": "witnessed_generation",
    "made_with": "FXCanvas",
    "status": "active",
    "compile_ok": true,
    "render_hash": "021e1e001c3e2200",
    "tags": [
      "blobs",
      "orbs",
      "drifting",
      "dankies"
    ],
    "parents": [],
    "author": "eg0d068pxlu9",
    "author_name": "fxcanvas",
    "author_url": "/u/fxcanvas",
    "created_at": "2026-07-31T17:27:10.030Z",
    "poster_url": "/posters/kw7lf6hf.avif",
    "stats": {
      "slops": 1,
      "views": 148,
      "forks": 0
    },
    "self": "/api/v1/shaders/kw7lf6hf",
    "isf": "/*{\n    \"ISFVSN\": \"2\",\n    \"DESCRIPTION\": \"Raymarched glass metaballs drifting through a 5-stop vertical colour ramp. Exported from FXCanvas (source 'Blobs', preset 'Drifting Orbs').\",\n    \"CREDIT\": \"SpotLab / FXCanvas\",\n    \"CATEGORIES\": [\"Generator\", \"3D\", \"Geometry\"],\n    \"INPUTS\": [\n        {\n            \"NAME\": \"speed\",\n            \"LABEL\": \"Speed\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.0,\n            \"MAX\": 2.0,\n            \"DEFAULT\": 0.5\n        },\n        {\n            \"NAME\": \"zoom\",\n            \"LABEL\": \"Zoom\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.5,\n            \"MAX\": 4.0,\n            \"DEFAULT\": 1.0\n        },\n        {\n            \"NAME\": \"refraction\",\n            \"LABEL\": \"Refraction\",\n            \"TYPE\": \"float\",\n            \"MIN\": 1.0,\n            \"MAX\": 2.0,\n            \"DEFAULT\": 1.33\n        },\n        {\n            \"NAME\": \"blobCount\",\n            \"LABEL\": \"Blob Count\",\n            \"TYPE\": \"float\",\n            \"MIN\": 1.0,\n            \"MAX\": 10.0,\n            \"DEFAULT\": 10.0\n        },\n        {\n            \"NAME\": \"blobSize\",\n            \"LABEL\": \"Blob Size\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.5,\n            \"MAX\": 2.0,\n            \"DEFAULT\": 0.5\n        },\n        {\n            \"NAME\": \"spread\",\n            \"LABEL\": \"Spread\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.3,\n            \"MAX\": 2.0,\n            \"DEFAULT\": 1.45\n        },\n        {\n            \"NAME\": \"absorption\",\n            \"LABEL\": \"Absorption\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.0,\n            \"MAX\": 0.2,\n            \"DEFAULT\": 0.05\n        },\n        {\n            \"NAME\": \"rotateX\",\n            \"LABEL\": \"Rotate X\",\n            \"TYPE\": \"float\",\n            \"MIN\": -1.0,\n            \"MAX\": 1.0,\n            \"DEFAULT\": 0.3\n        },\n        {\n            \"NAME\": \"rotateY\",\n            \"LABEL\": \"Rotate Y\",\n            \"TYPE\": \"float\",\n            \"MIN\": -1.0,\n            \"MAX\": 1.0,\n            \"DEFAULT\": 0.0\n        },\n        {\n            \"NAME\": \"opacity\",\n            \"LABEL\": \"Opacity\",\n            \"TYPE\": \"float\",\n            \"MIN\": 0.0,\n            \"MAX\": 1.0,\n            \"DEFAULT\": 1.0\n        },\n        {\n            \"NAME\": \"color1\",\n            \"LABEL\": \"Colour 1 (bottom)\",\n            \"TYPE\": \"color\",\n            \"DEFAULT\": [1.0, 0.0, 0.0, 1.0]\n        },\n        {\n            \"NAME\": \"color2\",\n            \"LABEL\": \"Colour 2\",\n            \"TYPE\": \"color\",\n            \"DEFAULT\": [1.0, 0.75, 0.0, 1.0]\n        },\n        {\n            \"NAME\": \"color3\",\n            \"LABEL\": \"Colour 3\",\n            \"TYPE\": \"color\",\n            \"DEFAULT\": [0.0, 1.0, 0.25, 1.0]\n        },\n        {\n            \"NAME\": \"color4\",\n            \"LABEL\": \"Colour 4\",\n            \"TYPE\": \"color\",\n            \"DEFAULT\": [0.0, 0.85, 1.0, 1.0]\n        },\n        {\n            \"NAME\": \"color5\",\n            \"LABEL\": \"Colour 5 (top)\",\n            \"TYPE\": \"color\",\n            \"DEFAULT\": [0.35, 0.0, 1.0, 1.0]\n        }\n    ]\n}*/\n\n// Drifting Orbs - ISF port of the FXCanvas \"Blobs\" source.\n//\n// Differences from the in-app shader, all forced by the ISF host contract:\n//   * The app feeds `accumulatedOffset`, a CPU-side integral of speed over time.\n//     With a constant speed that is just speed * time, so this uses TIME * speed.\n//   * The app's palette arrives as a uniform vec3[20] plus a transition pair.\n//     ISF has no array inputs, so the palette is five explicit colour stops\n//     sampled with the same BOUNDED mapping the source uses: the ends stay\n//     distinct, because the scalar is world-space height and does not wrap.\n//   * vTexCoord -> isf_FragNormCoord, resolution -> RENDERSIZE, out -> gl_FragColor.\n\nconst float PI = 3.1415926536;\nconst float EYE_DISTANCE = 7.5;\nconst float MIN_DISTANCE = 2.0;\nconst float MAX_DISTANCE = 15.0;\nconst float MIN_STEPSIZE = 0.2;\nconst int MAX_STEPS = 40;\n\n// Interior march (refraction / absorption). Fixed step: the adaptive stepsize()\n// below is tuned for approaching the surface from outside. The step has to stay\n// smaller than the thin gaps between neighbouring lobes - stepping over one\n// misses the exit, jumps the path length to a much later crossing, and shows up\n// as a hard-edged band in both the lensing and the absorption.\nconst int MAX_INTERNAL_STEPS = 40;\nconst float INTERNAL_STEPSIZE = 0.12;\nconst float ABSORPTION_SCALE = 3.0;       // maps the 0..0.2 slider onto useful optical depth\nconst float FRESNEL_GAIN = 0.5;           // strength of the grazing-angle sheen\nconst float FRESNEL_DEPTH_FALLOFF = 1.2;  // how fast the sheen dies off into thick material\n\n// Blob positions and sizes (xyz = position, w = size)\nvec4 blobs[10];\nint activeBlobs;\n\nfloat sq(float x) { return x * x; }\nfloat sq(vec3 x) { return dot(x, x); }\n\n// Bounded 5-stop ramp. Stand-in for fxcPaletteBounded() in the app's injected\n// preamble: bottom of the field is colour 1, top is colour 5, no wrap.\nvec3 fxcPaletteBounded(float t) {\n    float s = clamp(t, 0.0, 1.0) * 4.0;\n    if (s < 1.0) return mix(color1.rgb, color2.rgb, s);\n    if (s < 2.0) return mix(color2.rgb, color3.rgb, s - 1.0);\n    if (s < 3.0) return mix(color3.rgb, color4.rgb, s - 2.0);\n    return mix(color4.rgb, color5.rgb, clamp(s - 3.0, 0.0, 1.0));\n}\n\n// Metaball field function\nfloat f(vec3 p) {\n    float sum = 0.0;\n    for (int i = 0; i < 10; i++) {\n        if (i >= activeBlobs) break;\n        sum += blobs[i].w / sq(blobs[i].xyz - p);\n    }\n    return 1.0 - sum;\n}\n\n// Gradient of field (for normals)\nvec3 fd(vec3 p) {\n    vec3 grad = vec3(0.0);\n    for (int i = 0; i < 10; i++) {\n        if (i >= activeBlobs) break;\n        vec3 d = blobs[i].xyz - p;\n        float sqd = sq(d);\n        grad += blobs[i].w * d / sq(sqd);\n    }\n    return 2.0 * grad;\n}\n\n// Adaptive step size\nfloat stepsize(vec3 p) {\n    float minDist = 1000.0;\n    for (int i = 0; i < 10; i++) {\n        if (i >= activeBlobs) break;\n        minDist = min(minDist, sq(p - blobs[i].xyz));\n    }\n    float md = sqrt(minDist);\n    return max(MIN_STEPSIZE, abs(md - 1.0) * 0.667);\n}\n\n// Palette colour for a point in the field (vertical gradient).\nvec3 blobColor(vec3 p) {\n    return fxcPaletteBounded((p.y + 3.0) / 6.0);\n}\n\n// Darker core, brighter rim\nvec3 shade(vec3 color, float facing, float rim) {\n    return color * (0.6 + 0.4 * facing + 0.3 * rim * rim);\n}\n\n// Fresnel reflectance (Schlick) for a dielectric whose normal-incidence\n// reflectance is f0\nfloat fresnelSchlick(float cosTheta, float f0) {\n    return f0 + (1.0 - f0) * pow(1.0 - clamp(cosTheta, 0.0, 1.0), 5.0);\n}\n\n// March inside the metaball from p along dir until the field turns positive\n// (we've left the body). Returns the distance travelled and writes the refined\n// exit point to exitPos. If the ray never leaves within the step budget the path\n// length saturates, which is the right answer for absorption anyway.\nfloat marchInside(vec3 p, vec3 dir, out vec3 exitPos) {\n    float k = INTERNAL_STEPSIZE * 0.5;  // start just inside the surface\n\n    for (int i = 0; i < MAX_INTERNAL_STEPS; ++i) {\n        float kNext = k + INTERNAL_STEPSIZE;\n\n        if (f(p + dir * kNext) >= 0.0) {\n            // Crossed back out between k and kNext - refine\n            float kLo = k;\n            float kHi = kNext;\n            for (int b = 0; b < 6; b++) {\n                float kMid = (kLo + kHi) * 0.5;\n                if (f(p + dir * kMid) >= 0.0) {\n                    kHi = kMid;\n                } else {\n                    kLo = kMid;\n                }\n            }\n            exitPos = p + dir * kHi;\n            return kHi;\n        }\n\n        k = kNext;\n    }\n\n    exitPos = p + dir * k;\n    return k;\n}\n\n// Raymarch: find the surface and colour it\nvec4 ray(vec3 p, vec3 d) {\n    float k = MIN_DISTANCE;\n\n    for (int i = 0; i < MAX_STEPS; ++i) {\n        if (k > MAX_DISTANCE) {\n            return vec4(0.0);  // transparent background\n        }\n\n        vec3 pos = p + d * k;\n        float fVal = f(pos);\n\n        if (fVal < 0.0) {\n            // Refine the hit\n            float kLo = max(k - 0.5, 0.0);\n            float kHi = k;\n            for (int b = 0; b < 8; b++) {\n                float kMid = (kLo + kHi) * 0.5;\n                if (f(p + d * kMid) < 0.0) {\n                    kHi = kMid;\n                } else {\n                    kLo = kMid;\n                }\n            }\n            pos = p + d * kHi;\n\n            vec3 grad = fd(pos);\n            if (length(grad) < 0.01) {\n                k += 0.1;\n                continue;\n            }\n            vec3 n = -normalize(grad);\n\n            vec3 baseColor = blobColor(pos);\n\n            float facing = max(0.0, dot(n, -d));\n            float rim = 1.0 - facing;\n\n            vec3 color = shade(baseColor, facing, rim);\n\n            // Glass: bend the view ray through the body and attenuate it over\n            // the interior path. Both tests are uniform-driven, so the whole\n            // draw takes the same branch. At refraction 1.0 with absorption 0.0\n            // this is skipped entirely and the render is the plain shaded\n            // surface above.\n            float glass = clamp(refraction - 1.0, 0.0, 1.0);\n            if (glass > 0.0 || absorption > 0.0) {\n                // Entering the denser medium, so eta <= 1 and refract() can't\n                // total-internal-reflect. Guard anyway; 0 means fall straight\n                // through, which is also what eta == 1 gives us.\n                vec3 rd = refract(d, n, 1.0 / max(refraction, 1.0));\n                if (dot(rd, rd) < 0.5) rd = d;\n\n                vec3 exitPos;\n                float pathLen = marchInside(pos, rd, exitPos);\n\n                // Beer-Lambert. One knob absorbs the complement of the palette\n                // colour, so thick regions deepen in hue rather than going grey.\n                vec3 sigma = absorption * ABSORPTION_SCALE * (1.0 - baseColor);\n                vec3 transmittance = exp(-sigma * pathLen);\n\n                // Integrate the palette along the interior path rather than\n                // reading only the far face. Where the bent ray clips a\n                // neighbouring lobe tangentially, which lobe it exits through\n                // flips abruptly, and a lone exit sample turns that into a\n                // hard-edged band. How far in we look scales with the IOR, so a\n                // low IOR stays a subtle warp and only real glass sees all the\n                // way through.\n                vec3 lensPos = mix(pos, exitPos, glass);\n                vec3 lensed = (blobColor(mix(pos, lensPos, 0.34))\n                             + blobColor(mix(pos, lensPos, 0.67))\n                             + blobColor(lensPos)) / 3.0;\n                vec3 interior = shade(lensed, facing, rim);\n                color = mix(color, interior, glass) * transmittance;\n\n                // Grazing-angle sheen, strength set by the index of refraction.\n                // Schlick only knows the view angle, and the creases where two\n                // lobes merge graze just as hard as a real silhouette - left\n                // unchecked the sheen fills in exactly the shadowed seams that\n                // let you tell touching blobs apart. A true silhouette is thin\n                // glass and a crease has deep material behind it, so fade the\n                // sheen out with the interior path length already measured.\n                float f0 = sq((refraction - 1.0) / (refraction + 1.0));\n                float thinEdge = exp(-pathLen * FRESNEL_DEPTH_FALLOFF);\n                color += color * fresnelSchlick(facing, f0) * glass * FRESNEL_GAIN * thinEdge;\n            }\n\n            // Subtle highlight\n            vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));\n            float spec = pow(max(0.0, dot(reflect(d, n), lightDir)), 16.0);\n            color += vec3(0.2) * spec;\n\n            return vec4(color, 1.0);  // fully opaque where we hit a blob\n        }\n\n        k += stepsize(pos);\n    }\n\n    return vec4(0.0);  // transparent background\n}\n\nvoid main() {\n    float t = TIME * speed;\n    activeBlobs = int(clamp(floor(blobCount + 0.5), 1.0, 10.0));\n\n    // Animate blob positions\n    vec4 vs1 = cos(t * vec4(0.87, 1.13, 1.2, 1.0) + vec4(0.0, 3.32, 0.97, 2.85)) * vec4(-1.7, 2.1, 2.37, -1.9) * spread;\n    vec4 vs2 = cos(t * vec4(1.07, 0.93, 1.1, 0.81) + vec4(0.3, 3.02, 1.15, 2.97)) * vec4(1.77, -1.81, 1.47, 1.9) * spread;\n    vec4 vs3 = cos(t * vec4(0.97, 1.03, 0.89, 1.17) + vec4(1.2, 2.1, 0.5, 3.7)) * vec4(1.5, 1.9, -1.8, 1.6) * spread;\n    vec4 vs4 = cos(t * vec4(0.79, 1.21, 0.95, 1.05) + vec4(2.1, 0.8, 1.9, 0.3)) * vec4(-1.6, 1.7, 2.0, -1.5) * spread;\n    vec4 vs5 = cos(t * vec4(1.15, 0.85, 1.08, 0.92) + vec4(0.7, 2.5, 1.3, 3.1)) * vec4(1.8, -2.0, 1.4, 1.9) * spread;\n\n    blobs[0] = vec4(vs1.x, 0.0, vs1.y, 1.0 * blobSize);\n    blobs[1] = vec4(vs1.z, vs1.w, vs2.z, 0.9 * blobSize);\n    blobs[2] = vec4(vs2.x, vs2.y, vs2.w, 0.8 * blobSize);\n    blobs[3] = vec4(vs3.x, vs3.y, vs3.z, 0.85 * blobSize);\n    blobs[4] = vec4(vs3.w, vs1.x * 0.5, vs2.x * 0.5, 0.75 * blobSize);\n    blobs[5] = vec4(vs4.x, vs4.y, vs4.z, 0.82 * blobSize);\n    blobs[6] = vec4(vs4.w, vs3.x * 0.6, vs1.z * 0.7, 0.78 * blobSize);\n    blobs[7] = vec4(vs5.x, vs5.y, vs5.z, 0.88 * blobSize);\n    blobs[8] = vec4(vs5.w, vs4.x * 0.5, vs2.y * 0.6, 0.72 * blobSize);\n    blobs[9] = vec4(vs1.y * 0.8, vs5.x * 0.7, vs3.w * 0.9, 0.7 * blobSize);\n\n    // Camera rotation\n    vec2 r = vec2(-rotateX, rotateY) * PI;\n    vec4 cs = cos(vec4(r.y, r.x, r.y - PI * 0.5, r.x - PI * 0.5));\n\n    vec3 forward = -vec3(cs.x * cs.y, cs.w, cs.z * cs.y);\n    vec3 up = vec3(cs.x * cs.w, -cs.y, cs.z * cs.w);\n    vec3 left = cross(up, forward);\n\n    vec3 eye = -forward * EYE_DISTANCE;\n\n    vec2 uv = (isf_FragNormCoord - 0.5) / zoom;\n    uv.x *= RENDERSIZE.x / RENDERSIZE.y;\n\n    vec3 dir = normalize(forward + uv.y * up + uv.x * left);\n\n    // Opacity is the last operation and belongs in ALPHA ONLY. Scaling rgb here\n    // as well premultiplies, and the downstream composite applies alpha again.\n    vec4 lit = ray(eye, dir);\n    gl_FragColor = vec4(lit.rgb, lit.a * clamp(opacity, 0.0, 1.0));\n}\n",
    "generator": {
      "model": "claude-opus-5",
      "provider": null,
      "prompt": null,
      "params": null
    }
  }
}