{
  "license": "CC0-1.0",
  "shader": {
    "id": "dp2sdrd0",
    "title": "Ribbon Weather",
    "license": "CC0-1.0",
    "provenance_type": "witnessed_generation",
    "made_with": "glslop human-loop studio",
    "status": "active",
    "compile_ok": true,
    "render_hash": "04021878980848a4",
    "tags": [
      "ribbons",
      "turbulence",
      "silk",
      "flocking",
      "multipass",
      "generative"
    ],
    "parents": [],
    "author": "zazi5inxhmgi",
    "author_name": "ufffd",
    "author_url": "/u/ufffd",
    "created_at": "2026-08-05T06:02:06.480Z",
    "poster_url": "/posters/dp2sdrd0.avif",
    "stats": {
      "slops": 0,
      "views": 165,
      "forks": 1
    },
    "self": "/api/v1/shaders/dp2sdrd0",
    "isf": "/*{\r\n  \"DESCRIPTION\": \"Streamer III: ruled-strip silk in slow-motion divergence-free turbulence. Anisotropic drag lines each ribbon up with its own path so the tail retraces where the head has been; the tips steer as a bounded-turn-rate flock; and no hard constraint remains in the position solve, so nothing grips and releases between frames.\",\r\n  \"INPUTS\": [\r\n    { \"NAME\": \"speed\", \"TYPE\": \"float\", \"DEFAULT\": 0.78, \"MIN\": 0.0, \"MAX\": 1.6 },\r\n    { \"NAME\": \"ribbonWidth\", \"TYPE\": \"float\", \"DEFAULT\": 0.054, \"MIN\": 0.020, \"MAX\": 0.095 },\r\n    { \"NAME\": \"stiffness\", \"TYPE\": \"float\", \"DEFAULT\": 0.64, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"wind\", \"TYPE\": \"float\", \"DEFAULT\": 0.72, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"mess\", \"TYPE\": \"float\", \"DEFAULT\": 0.70, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"twist\", \"TYPE\": \"float\", \"DEFAULT\": 0.68, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"depth\", \"TYPE\": \"float\", \"DEFAULT\": 0.76, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"sheen\", \"TYPE\": \"float\", \"DEFAULT\": 0.74, \"MIN\": 0.0, \"MAX\": 1.0 },\r\n    { \"NAME\": \"seed\", \"TYPE\": \"float\", \"DEFAULT\": 23.0, \"MIN\": 0.0, \"MAX\": 30.0 }\r\n  ],\r\n  \"PASSES\": [\r\n    { \"TARGET\": \"spines\", \"PERSISTENT\": true, \"FLOAT\": true },\r\n    { \"TARGET\": \"spineDepth\", \"PERSISTENT\": true, \"FLOAT\": true },\r\n    { \"TARGET\": \"ribbonLayer\", \"FLOAT\": true },\r\n    {}\r\n  ]\r\n}*/\r\n\r\n// A conforming GLSL ES 1.00 host declares `precision mediump float;` for\r\n// fragment shaders, since ES 1.00 gives fragment stages no default float\r\n// precision and highp is optional there. mediump guarantees only about 10 bits\r\n// of mantissa. This shader integrates node positions incrementally across\r\n// thousands of frames in a persistent float target, so mediump would quantize\r\n// the spine and reintroduce exactly the frame-to-frame stutter the motion work\r\n// removed. Ask for highp where the hardware advertises it; the guard is\r\n// required because referencing highp without it is an error on parts that lack\r\n// it. Desktop drivers usually promote mediump to fp32 anyway, so the absence of\r\n// this line would likely go unnoticed until the piece ran on a phone.\r\n#ifdef GL_FRAGMENT_PRECISION_HIGH\r\nprecision highp float;\r\n#endif\r\n\r\n#define PI 3.14159265359\r\n#define RIBBONS 7\r\n#define NODES 28\r\n\r\nfloat hash11(float p){return fract(sin(p*127.1+seed*29.17)*43758.5453);}\r\n\r\nvec3 spectrum(float x){\r\n  x=clamp(x,0.0,1.0)*6.0;\r\n  vec3 r=vec3(0.98,0.018,0.055),o=vec3(1.0,0.26,0.008),y=vec3(1.0,0.76,0.008);\r\n  vec3 g=vec3(0.008,0.73,0.18),b=vec3(0.008,0.29,0.99),i=vec3(0.15,0.045,0.61),v=vec3(0.67,0.035,0.87);\r\n  if(x<1.0)return mix(r,o,smoothstep(0.0,1.0,x));\r\n  if(x<2.0)return mix(o,y,smoothstep(1.0,2.0,x));\r\n  if(x<3.0)return mix(y,g,smoothstep(2.0,3.0,x));\r\n  if(x<4.0)return mix(g,b,smoothstep(3.0,4.0,x));\r\n  if(x<5.0)return mix(b,i,smoothstep(4.0,5.0,x));\r\n  return mix(i,v,smoothstep(5.0,6.0,x));\r\n}\r\n\r\nvec2 nodeUv(int node,int ribbon){\r\n  return (vec2(float(node),float(ribbon))+0.5)/RENDERSIZE;\r\n}\r\n\r\n// Limiting a 2D quantity has to bound its magnitude, not its components.\r\n// clamp() on a vec2 bounds it to a square, so a saturating value gets pushed\r\n// out to the corners of that box and direction snaps onto 45 degree diagonals\r\n// - which is what was making the tails trace fast diamond shapes. Bounding the\r\n// length leaves direction untouched. Used for velocities and for steering.\r\nvec2 limitMagnitude(vec2 value,float limit){\r\n  float magnitude=length(value);\r\n  return magnitude>limit?value*(limit/max(magnitude,0.0001)):value;\r\n}\r\n\r\n// Starting layout only. The heads are free-flying boids once the simulation\r\n// is running, so all this has to do is spread seven of them around a rosette\r\n// at roughly the golden angle.\r\nvec2 headSeedPosition(float fi){\r\n  float angle=fi*2.39+0.6;\r\n  return 0.5+vec2(cos(angle),sin(angle))*(0.135+0.085*hash11(fi+17.0));\r\n}\r\n\r\n// One octave of a divergence-free field: a unit vector perpendicular to the\r\n// wavevector, modulated by cos(k.p). Because that vector is orthogonal to k,\r\n// the divergence is identically zero however the amplitude is scaled, so\r\n// octaves can be summed and weighted freely and the result still swirls\r\n// without piling material into sinks. Rotating the wavevector over time keeps\r\n// the pattern reorganising.\r\nvec2 curlOctave(vec2 p,float angle,float freq,float phase,float amplitude){\r\n  vec2 direction=vec2(cos(angle),sin(angle));\r\n  return vec2(-direction.y,direction.x)*(cos(dot(direction,p)*freq+phase)*amplitude);\r\n}\r\n\r\n// Shed vortices were the wrong medium for a constant flurry: a travelling bump\r\n// with a sin(life*PI) envelope means every point in the frame spends most of\r\n// its time between vortices, in dead air, which is exactly the lull that reads\r\n// as stop-and-go. A spectrum of always-on octaves has shear everywhere at all\r\n// times. The octaves beat against each other instead of switching on and off,\r\n// so the field reorganises continuously without ever going quiet.\r\n// `detail` low-passes the field in space. The fine octaves are what make a\r\n// free end buzz: eddies a fraction of a ribbon long shake it at a rate the eye\r\n// reads as noise rather than as flow. Fading them out along the chain leaves\r\n// the tail riding broad currents only, so it drapes and sweeps instead of\r\n// flailing, while the head keeps the full spectrum and stays lively.\r\nvec2 windForce(vec2 p,float fi,float t,float detail){\r\n  // Seven spines sharing one field move as a rigid sheet. Sampling each ribbon\r\n  // at its own offset decorrelates them while keeping every ribbon's own flow\r\n  // divergence-free and coherent along its length.\r\n  p+=vec2(hash11(fi+31.0),hash11(fi+41.0))*mess*0.42;\r\n  // The largest cell is deliberately about one ribbon long. Make it wider than\r\n  // the frame and the whole cast sits inside a single eddy and orbits it\r\n  // together, which drifts everything into one corner even though the field is\r\n  // divergence-free - zero divergence forbids compression, not advection.\r\n  // Slow phase rates. Turbulence photographed in slow motion still has all its\r\n  // spatial structure - the eddies are the same size - it just evolves gently,\r\n  // so the large cells are what move the silk and the small ones only texture\r\n  // it. Speeding the phases up is what turns weather into vibration.\r\n  vec2 flow=curlOctave(p,0.70+t*0.05,5.2,t*0.19,1.00);\r\n  flow+=curlOctave(p,2.31-t*0.07,8.6,-t*0.29,0.55*detail);\r\n  flow+=curlOctave(p,4.12+t*0.09,13.5,t*0.44,0.26*detail*detail);\r\n  flow+=curlOctave(p,5.63-t*0.11,19.0,-t*0.60,0.10*detail*detail*detail);\r\n  return flow*2.0;\r\n}\r\n\r\nvec2 quadraticBezier(vec2 p0,vec2 p1,vec2 p2,float u){\r\n  vec2 a=mix(p0,p1,u);\r\n  vec2 b=mix(p1,p2,u);\r\n  return mix(a,b,u);\r\n}\r\n\r\nvec2 quadraticBezierD(vec2 p0,vec2 p1,vec2 p2,float u){\r\n  return 2.0*mix(p1-p0,p2-p1,u);\r\n}\r\n\r\nvec2 quadraticBezierDD(vec2 p0,vec2 p1,vec2 p2){\r\n  return 2.0*(p0-2.0*p1+p2);\r\n}\r\n\r\nfloat quadraticBezier1(float p0,float p1,float p2,float u){\r\n  return mix(mix(p0,p1,u),mix(p1,p2,u),u);\r\n}\r\n\r\nfloat quadraticBezierD1(float p0,float p1,float p2,float u){\r\n  return 2.0*mix(p1-p0,p2-p1,u);\r\n}\r\n\r\nvec2 projectDepth(vec2 point,float z){\r\n  return 0.5+(point-0.5)*(1.0+z*0.20);\r\n}\r\n\r\nvoid considerQuadraticRoot(\r\n  vec2 point,vec2 p0,vec2 p1,vec2 p2,float root,\r\n  inout float bestDistanceSquared,inout float bestU\r\n){\r\n  float u=clamp(root,0.0,1.0);\r\n  vec2 delta=quadraticBezier(p0,p1,p2,u)-point;\r\n  float distanceSquared=dot(delta,delta);\r\n  if(distanceSquared<bestDistanceSquared){\r\n    bestDistanceSquared=distanceSquared;\r\n    bestU=u;\r\n  }\r\n}\r\n\r\nvoid refineQuadraticRoot(\r\n  vec2 point,vec2 p0,vec2 p1,vec2 p2,float seed,\r\n  inout float bestDistanceSquared,inout float bestU\r\n){\r\n  float u=clamp(seed,0.0,1.0);\r\n  for(int solve=0;solve<4;solve++){\r\n    vec2 curve=quadraticBezier(p0,p1,p2,u);\r\n    vec2 firstDerivative=quadraticBezierD(p0,p1,p2,u);\r\n    vec2 secondDerivative=quadraticBezierDD(p0,p1,p2);\r\n    float numerator=dot(curve-point,firstDerivative);\r\n    float denominator=dot(firstDerivative,firstDerivative)+dot(curve-point,secondDerivative);\r\n    float safeDenominator=(denominator<0.0?-1.0:1.0)*max(abs(denominator),0.0000001);\r\n    u=clamp(u-clamp(numerator/safeDenominator,-0.34,0.34),0.0,1.0);\r\n  }\r\n  considerQuadraticRoot(point,p0,p1,p2,u,bestDistanceSquared,bestU);\r\n}\r\n\r\n// Foot of the normal: the u whose ruling passes through this pixel, solving\r\n// dot(p-B(u),B'(u))=0. Endpoints are deliberately NOT offered as candidates.\r\n// An endpoint \"hit\" is a round cap, and unioning caps across sections is what\r\n// turns a flat strip into a sweep of disks. Short convex pieces rarely have\r\n// competing minima, so two safeguarded seeds cover both basins.\r\nfloat footOfNormal(vec2 point,vec2 p0,vec2 p1,vec2 p2){\r\n  vec2 chord=p2-p0;\r\n  float chordSeed=dot(point-p0,chord)/max(dot(chord,chord),0.00000001);\r\n  float bestDistanceSquared=1000000.0;\r\n  float bestU=0.5;\r\n  refineQuadraticRoot(point,p0,p1,p2,chordSeed,bestDistanceSquared,bestU);\r\n  refineQuadraticRoot(point,p0,p1,p2,chordSeed<0.5?0.78:0.22,bestDistanceSquared,bestU);\r\n  return bestU;\r\n}\r\n\r\nvoid updateSpines(vec2 uv,float t){\r\n  ivec2 cell=ivec2(floor(uv*RENDERSIZE));\r\n  int node=cell.x;\r\n  int ribbon=cell.y;\r\n  if(node>=NODES||ribbon>=RIBBONS){gl_FragColor=vec4(0.0);return;}\r\n  float fi=float(ribbon);\r\n  float dt=min(TIMEDELTA,1.0/30.0);\r\n  vec2 head=headSeedPosition(fi);\r\n  float segmentLength=0.036+0.004*sin(fi*1.7);\r\n  vec2 initialDirection=normalize(vec2(cos(fi*2.17+0.4),sin(fi*1.63+0.8)));\r\n  float cruise=0.10+0.17*speed;\r\n  // Seed on an empty buffer, not on a frame number. FRAMEINDEX is not portable\r\n  // as an \"is this the first frame\" test: a host that renders a poster at t=8\r\n  // may hand the shader FRAMEINDEX 480 on the very first frame it ever draws,\r\n  // so a FRAMEINDEX<2 branch never runs, the persistent target keeps its\r\n  // cleared contents, and every node reads position (0,0) - which puts the\r\n  // whole piece in the bottom-left corner. That is exactly what glslop's poster\r\n  // renderer did. A cleared float target is exactly zero, and a live node is\r\n  // never at the origin with precisely zero velocity, so the buffer's own\r\n  // contents are the reliable signal. FRAMEINDEX is kept as a fast path.\r\n  vec4 stored=IMG_NORM_PIXEL(spines,nodeUv(node,ribbon));\r\n  if(FRAMEINDEX<2||dot(stored,stored)<=0.0){\r\n    vec2 initial=head-initialDirection*segmentLength*float(node);\r\n    // Seed the tips already at cruise and tangential, so the flock is milling\r\n    // from the first frame instead of accelerating out of a dead start.\r\n    vec2 seedVelocity=vec2(-initialDirection.y,initialDirection.x)*cruise;\r\n    gl_FragColor=vec4(clamp(initial,vec2(-0.35),vec2(1.35)),seedVelocity);\r\n    return;\r\n  }\r\n  if(node==0){\r\n    // The tips are boids. Seven of them is small enough that all-pairs costs\r\n    // six texture reads at one node per ribbon, so no spatial structure is\r\n    // needed. The reason it is worth doing at all is that steering separates\r\n    // heading from pace: speed is renormalised to a fixed cruise every step,\r\n    // so a tip only ever changes direction, never rate. That gets a decided,\r\n    // constant lead without the uniformity of putting every head on a rail.\r\n    vec4 headState=IMG_NORM_PIXEL(spines,nodeUv(0,ribbon));\r\n    vec2 headPos=headState.xy;\r\n    vec2 headVel=headState.zw;\r\n\r\n    vec2 separation=vec2(0.0);\r\n    vec2 alignment=vec2(0.0);\r\n    vec2 cohesion=vec2(0.0);\r\n    float neighbours=0.0;\r\n    for(int k=0;k<RIBBONS;k++){\r\n      // Masked rather than broken out of, to stay inside the ES 1.00 loop form.\r\n      float notSelf=step(0.5,abs(float(k)-fi));\r\n      vec4 otherState=IMG_NORM_PIXEL(spines,nodeUv(0,k));\r\n      vec2 offset=headPos-otherState.xy;\r\n      float gap=max(length(offset),0.0001);\r\n      separation+=offset/gap*(notSelf*max(0.42-gap,0.0));\r\n      alignment+=otherState.zw*notSelf;\r\n      cohesion+=otherState.xy*notSelf;\r\n      neighbours+=notSelf;\r\n    }\r\n    alignment/=max(neighbours,1.0);\r\n    cohesion/=max(neighbours,1.0);\r\n\r\n    // Steering authority scales with cruise. A path's turn radius is\r\n    // cruise^2/acceleration, so holding these gains fixed while the tips speed\r\n    // up flattens every trajectory - and since the body is towed along the\r\n    // path the head traces, flat paths mean straight ribbons that sprawl\r\n    // across the frame. Scaling keeps the shape of the motion invariant to\r\n    // pace, including when the speed input is moved.\r\n    float authority=cruise/0.35;\r\n    vec2 steer=separation*(13.5*authority);\r\n    // Alignment kept low. Strong alignment is what makes a flock dart as one\r\n    // body, and a synchronised dart is the opposite of weather - it reads as\r\n    // seven ribbons on one string. Separation and the wind do the work; this\r\n    // only stops heads from crossing head-on.\r\n    steer+=(alignment-headVel)*0.45;\r\n    steer+=(cohesion-headPos)*(0.08*authority);\r\n    // Soft containment. Boids in an unbounded field commit to a heading and\r\n    // leave; a turn-back that engages only near the rim keeps them milling in\r\n    // frame without flattening the flock against a wall.\r\n    vec2 fromCentre=headPos-0.5;\r\n    float outward=length(fromCentre);\r\n    steer-=fromCentre/max(outward,0.0001)*smoothstep(0.36,0.54,outward)*(6.0*authority);\r\n    // The wind leads the tips now rather than merely nudging them, so a head\r\n    // is steered by the same weather its body is hanging in.\r\n    steer+=windForce(headPos,fi,t,1.0)*(0.40+0.85*wind)*authority;\r\n    // A slow per-tip bias perpendicular to travel. Once speed is fixed, cross\r\n    // acceleration is pure turning, and this term is written as perp(velocity)\r\n    // so its magnitude is already proportional to cruise - which makes the\r\n    // coefficient an angular rate in rad/s directly, independent of pace. At\r\n    // this rate a tip curls through a radius of roughly a sixth of the frame,\r\n    // so the ribbon behind it lays down loops rather than a straight wake. The\r\n    // rate is well under the wind's, so it sets the size of the loops without\r\n    // competing with the weather for who is steering.\r\n    steer+=vec2(-headVel.y,headVel.x)*sin(t*(0.29+0.11*hash11(fi+3.0))+fi*2.2)*1.25;\r\n\r\n    // Bound the steering. This is what classic boids clamp and I had not.\r\n    // Because speed is renormalised below, acceleration is converted entirely\r\n    // into turning, at a rate of |steer|/cruise - so the gains above are really\r\n    // angular rates, and unclamped they are enormous: separation alone reaches\r\n    // about 38 rad/s and containment about 17, either of which reverses a\r\n    // heading in a handful of frames. Most of the time no term is near its\r\n    // peak and the motion is calm, but when a head passes close to another or\r\n    // crosses the containment rim, it snaps to a new direction - and the body,\r\n    // which now faithfully follows the head's path, is dragged around that\r\n    // corner. That is the occasional spaz between long chill stretches. The\r\n    // curl term above never caused it because writing it as perp(velocity)\r\n    // already made its coefficient an angular rate.\r\n    steer=limitMagnitude(steer,cruise*2.2);\r\n\r\n    headVel+=steer*dt;\r\n    float pace=length(headVel);\r\n    headVel=pace>0.0001?headVel/pace*cruise:initialDirection*cruise;\r\n    headPos=clamp(headPos+headVel*dt,vec2(-0.30),vec2(1.30));\r\n    gl_FragColor=vec4(headPos,headVel);\r\n    return;\r\n  }\r\n\r\n  vec4 self=IMG_NORM_PIXEL(spines,nodeUv(node,ribbon));\r\n  vec2 pos=self.xy;\r\n  vec2 vel=self.zw;\r\n  vec2 prev=IMG_NORM_PIXEL(spines,nodeUv(node-1,ribbon)).xy;\r\n  vec2 toPrev=prev-pos;\r\n  float prevDistance=max(length(toPrev),0.0001);\r\n  float springStrength=34.0+stiffness*54.0;\r\n  vec2 force=toPrev/prevDistance*(prevDistance-segmentLength)*springStrength;\r\n  if(node<NODES-1){\r\n    vec2 next=IMG_NORM_PIXEL(spines,nodeUv(node+1,ribbon)).xy;\r\n    vec2 toNext=next-pos;\r\n    float nextDistance=max(length(toNext),0.0001);\r\n    force+=toNext/nextDistance*(nextDistance-segmentLength)*springStrength*0.62;\r\n    // Light straightening only. Curvature is limited in the position solve\r\n    // below, where it can be enforced hard without being stiff, and the\r\n    // transverse wave is dissipated by drag rather than by bending stiffness.\r\n    force+=(prev+next-2.0*pos)*(5.0+stiffness*7.0);\r\n  }\r\n  float tail=float(node)/float(NODES-1);\r\n  float detail=mix(1.0,0.12,tail);\r\n  // Wind authority is now near-uniform along the chain. It used to more than\r\n  // double from head to tail, which is backwards: a ribbon is smaller than the\r\n  // eddies carrying it, so every part of it sits in much the same flow. Giving\r\n  // the free end the most forcing, the least damping and the largest ripple\r\n  // stacked three amplifiers at exactly the point that already reflects waves.\r\n  force+=windForce(pos,fi,t,detail)*(0.22+0.50*wind)*(0.90+0.10*tail);\r\n\r\n  vec2 tangent=toPrev/prevDistance;\r\n  vec2 lateral=vec2(-tangent.y,tangent.x);\r\n  // A slight, slow undulation - the \"variance\" on top of following, not a\r\n  // driver of it. Kept small deliberately: with anisotropic drag below, any\r\n  // across-path force is strongly resisted, so this now reads as the ribbon\r\n  // breathing rather than as something shaking it.\r\n  float ripple=sin(t*(0.78+0.16*hash11(fi+13.0))-tail*(3.4+2.2*twist)+fi*1.77);\r\n  force+=lateral*ripple*(0.03+0.07*tail*(1.30-tail))*(0.55+0.85*mess);\r\n\r\n  vel+=force*dt;\r\n\r\n  // Anisotropic drag - the piece that was actually missing. A ribbon sliding\r\n  // along its own length pushes almost no air; moving broadside it pushes a\r\n  // whole face of it. Damping isotropically models a bead on a string, free to\r\n  // swing sideways exactly as easily as it trails, so a small turn at the head\r\n  // launches a transverse wave that travels down the chain, and because the\r\n  // free end has nothing beyond it to carry the wave onward, it reflects there\r\n  // and doubles - the thrash. Resisting across-path motion several times\r\n  // harder than along-path motion dissipates that wave as it travels, which is\r\n  // why a real streamer lines itself up with its own path and the tail simply\r\n  // follows where the head has already been.\r\n  vec2 alongVelocity=tangent*dot(vel,tangent);\r\n  vec2 acrossVelocity=vel-alongVelocity;\r\n  // Both grades now rise toward the free end rather than falling. The far end\r\n  // is where the wave arrives with nowhere to go, so it wants the most\r\n  // absorption, not the least.\r\n  float alongDrag=mix(2.2,2.9,tail)*(1.0+stiffness*0.30);\r\n  float acrossDrag=mix(7.0,17.0,tail)*(1.0+stiffness*0.30);\r\n  vel=alongVelocity*exp(-dt*alongDrag)+acrossVelocity*exp(-dt*acrossDrag);\r\n  vel=limitMagnitude(vel,0.85);\r\n  vec2 predicted=pos+vel*dt;\r\n\r\n  // Length constraint, projected the way position-based dynamics requires:\r\n  // move the predicted position onto the segment-length circle around the\r\n  // predecessor, then derive velocity from the displacement that actually\r\n  // happened. Correcting position while leaving the old velocity in place\r\n  // leaves the node still driving into its neighbour, so the chain keeps\r\n  // collapsing and the ribbon renders as a balloon.\r\n  vec2 toPrevSettled=predicted-prev;\r\n  float settledDistance=max(length(toPrevSettled),0.0001);\r\n  vec2 corrected=prev+toPrevSettled/settledDistance*mix(settledDistance,segmentLength,0.72);\r\n\r\n  // There was a curvature-limiting projection here, capping the turn per joint\r\n  // so a hairpin could not close tighter than the strip is wide and render as\r\n  // a solid paddle. It has been removed, and its removal is the fix for the\r\n  // stick-slip stutter: a hard `if (turn < limit) snap to limit` grips on one\r\n  // frame and releases the next for any joint sitting near the threshold,\r\n  // which is the same mechanism as a rubber band dragged across a table.\r\n  // Smoothing the engagement did not help - it only spread a partial\r\n  // correction across many more joints and measured worse.\r\n  //\r\n  // It is safe to delete rather than repair because the anisotropic drag added\r\n  // above already prevents the folding it was catching: a fold IS across-path\r\n  // motion, which is now the heavily resisted direction, so the coils never\r\n  // form in the first place. Verified over 100 s at defaults and at maximum\r\n  // wind and mess with zero stiffness and maximum speed - no coils, no bulbs.\r\n  // If an extreme setting ever reintroduces them, the lever is drag, not a\r\n  // constraint.\r\n\r\n  vel=limitMagnitude((corrected-pos)/max(dt,0.001),1.05);\r\n  pos=corrected;\r\n  pos=clamp(pos,vec2(-0.30),vec2(1.30));\r\n  gl_FragColor=vec4(pos,vel);\r\n}\r\n\r\nvoid updateSpineDepth(vec2 uv,float t){\r\n  ivec2 cell=ivec2(floor(uv*RENDERSIZE));\r\n  int node=cell.x;\r\n  int ribbon=cell.y;\r\n  if(node>=NODES||ribbon>=RIBBONS){gl_FragColor=vec4(0.0);return;}\r\n  float fi=float(ribbon);\r\n  float along=float(node)/float(NODES-1);\r\n  float dt=min(TIMEDELTA,1.0/30.0);\r\n  float targetHead=0.42*sin(t*0.31+fi*1.37)+0.10*cos(t*0.17-fi*0.77);\r\n\r\n  // Cumulative arc length from the head, carried in the spare .z channel. Twist\r\n  // parameterized by node index instead kinks at every joint, because the\r\n  // springs stretch and sections are not equal length; the kink shows directly\r\n  // in a ruled silhouette. This pass is 28x7 pixels, so the prefix sum is free.\r\n  // Masked rather than broken out of, to stay inside the ES 1.00 loop form.\r\n  float arc=0.0;\r\n  vec2 walkPoint=IMG_NORM_PIXEL(spines,nodeUv(0,ribbon)).xy;\r\n  float walkDepth=IMG_NORM_PIXEL(spineDepth,nodeUv(0,ribbon)).x;\r\n  for(int k=1;k<NODES;k++){\r\n    vec2 nextPoint=IMG_NORM_PIXEL(spines,nodeUv(k,ribbon)).xy;\r\n    float nextDepth=IMG_NORM_PIXEL(spineDepth,nodeUv(k,ribbon)).x;\r\n    arc+=step(float(k),float(node))*length(vec3(nextPoint-walkPoint,(nextDepth-walkDepth)*0.32));\r\n    walkPoint=nextPoint;\r\n    walkDepth=nextDepth;\r\n  }\r\n\r\n  // Same portability problem as the spine pass, but this buffer cannot use an\r\n  // all-zero test: node 0 legitimately stores arc 0, and its depth and velocity\r\n  // both pass through zero in normal motion, so a zero test would re-seed it\r\n  // mid-flight. The w channel was unused, so it carries an explicit\r\n  // initialised flag instead. Every write below sets it.\r\n  vec4 state=IMG_NORM_PIXEL(spineDepth,nodeUv(node,ribbon));\r\n  if(FRAMEINDEX<2||state.w<0.5){\r\n    float initial=targetHead*0.45+0.20*sin(fi*1.31+along*5.4);\r\n    gl_FragColor=vec4(initial,0.0,arc,1.0);\r\n    return;\r\n  }\r\n  float z=state.x;\r\n  float velocity=state.y;\r\n  if(node==0){\r\n    velocity=clamp((targetHead-z)/max(dt,0.001),-1.4,1.4);\r\n    gl_FragColor=vec4(targetHead,velocity,arc,1.0);\r\n    return;\r\n  }\r\n  float previous=IMG_NORM_PIXEL(spineDepth,nodeUv(node-1,ribbon)).x;\r\n  float force=(previous-z)*(24.0+stiffness*34.0);\r\n  if(node<NODES-1){\r\n    float next=IMG_NORM_PIXEL(spineDepth,nodeUv(node+1,ribbon)).x;\r\n    force+=(next-z)*(13.0+stiffness*18.0);\r\n    force+=(previous+next-2.0*z)*(5.0+stiffness*8.0);\r\n  }\r\n  // Long and slow, matching the in-plane undulation. Thirteen wavelengths of\r\n  // depth wave along a ribbon is a corrugation, not a swell, and it was adding\r\n  // fast apparent motion through the width projection even where the ribbon\r\n  // was barely moving in plane.\r\n  force+=sin(t*(0.34+fi*0.012)-along*(2.8+twist*2.2)+fi*1.11)*(0.20+0.44*mess);\r\n  velocity+=force*dt;\r\n  velocity*=exp(-dt*(3.4+stiffness*1.5));\r\n  velocity=clamp(velocity,-1.15,1.15);\r\n  z=clamp(z+velocity*dt,-0.62,0.62);\r\n  gl_FragColor=vec4(z,velocity,arc,1.0);\r\n}\r\n\r\nvec3 shadeCloth(\r\n  float fi,float facing,float edge,vec3 normal,float along,float sceneDepth,float t\r\n){\r\n  vec3 ink=spectrum(fi/6.0);\r\n  vec3 back=mix(ink,vec3(0.020,0.012,0.038),0.46);\r\n  bool frontFacing=facing>=0.0;\r\n  vec3 cloth=frontFacing?ink:back;\r\n  vec3 lightDirection=normalize(vec3(-0.35,0.55,0.76));\r\n  cloth*=0.68+0.27*abs(dot(normal,lightDirection));\r\n  cloth*=mix(0.72,1.10,smoothstep(-0.75,0.75,sceneDepth));\r\n  float broadGlint=pow(max(0.0,abs(dot(normal,normalize(vec3(0.25,0.28,0.93))))),18.0);\r\n  cloth+=mix(ink,vec3(1.0),0.78)*broadGlint*sheen*0.17;\r\n  vec3 edgeInk=mix(ink,vec3(0.006,0.008,0.018),frontFacing?0.42:0.55);\r\n  cloth=mix(cloth,edgeInk,edge*0.55);\r\n  // Real silk flares as it turns edge-on. Paying the vanishing moment out as a\r\n  // grazing highlight keeps the fold reading as material rather than a hole.\r\n  cloth+=mix(ink,vec3(1.0),0.60)*pow(1.0-abs(facing),3.0)*sheen*0.50;\r\n  cloth*=0.97+0.03*sin(along*18.0-t*1.1+fi);\r\n  return cloth;\r\n}\r\n\r\nvoid renderRibbons(vec2 uv,float t){\r\n  float pixelSize=1.0/min(RENDERSIZE.x,RENDERSIZE.y);\r\n  float frontZ=-1000.0;\r\n  float frontMask=0.0;\r\n  vec3 frontColor=vec3(0.0);\r\n  float backZ=-1000.0;\r\n  float backMask=0.0;\r\n  vec3 backColor=vec3(0.0);\r\n  float shadow=0.0;\r\n\r\n  for(int r=0;r<RIBBONS;r++){\r\n    float fi=float(r);\r\n    float ribbonArcLength=max(IMG_NORM_PIXEL(spineDepth,nodeUv(NODES-1,r)).z,0.0001);\r\n    float ribbonScore=-1000.0;\r\n    float ribbonSurfaceDistance=-20.0;\r\n    float ribbonSignedSide=0.0;\r\n    float ribbonBaseWidth=0.0;\r\n    float ribbonEffectiveProjection=1.0;\r\n    float ribbonFacing=1.0;\r\n    float ribbonAlong=0.0;\r\n    float ribbonPhase=0.0;\r\n    vec2 ribbonTangent=vec2(1.0,0.0);\r\n    float ribbonSceneDepth=0.0;\r\n    float ribbonDepthTangent=0.0;\r\n    for(int j=0;j<NODES;j++){\r\n      // Ternaries, not min/max: GLSL ES 1.00 has no integer overload of\r\n      // min/max, so max(j-1,0) silently resolves to the float version and then\r\n      // fails to assign back to an int. Our local renderer compiles as ES 3.00,\r\n      // where the integer overloads do exist, so it accepts this and the error\r\n      // only appears on a conforming ES 1.00 host.\r\n      int previousIndex=j>0?j-1:0;\r\n      int nextIndex=j<NODES-1?j+1:NODES-1;\r\n      vec4 previousState=IMG_NORM_PIXEL(spineDepth,nodeUv(previousIndex,r));\r\n      vec4 controlState=IMG_NORM_PIXEL(spineDepth,nodeUv(j,r));\r\n      vec4 nextState=IMG_NORM_PIXEL(spineDepth,nodeUv(nextIndex,r));\r\n      float previousZ=previousState.x;\r\n      float controlZ=controlState.x;\r\n      float nextZ=nextState.x;\r\n      vec2 previous=projectDepth(IMG_NORM_PIXEL(spines,nodeUv(previousIndex,r)).xy,previousZ);\r\n      vec2 control=projectDepth(IMG_NORM_PIXEL(spines,nodeUv(j,r)).xy,controlZ);\r\n      vec2 next=projectDepth(IMG_NORM_PIXEL(spines,nodeUv(nextIndex,r)).xy,nextZ);\r\n\r\n      // Midpoint-to-midpoint quadratic B-spline section. Neighboring pieces\r\n      // share the same endpoint and first derivative exactly. Repeated endpoint\r\n      // controls create clean first/last caps without a special raster path.\r\n      vec2 p0=j==0?control:0.5*(previous+control);\r\n      vec2 p1=control;\r\n      vec2 p2=j==NODES-1?control:0.5*(control+next);\r\n      float z0=j==0?controlZ:0.5*(previousZ+controlZ);\r\n      float z1=controlZ;\r\n      float z2=j==NODES-1?controlZ:0.5*(controlZ+nextZ);\r\n      // Arc length rides the same midpoint construction as position, so the\r\n      // twist phase advances uniformly in space instead of per node index.\r\n      float a0=j==0?controlState.z:0.5*(previousState.z+controlState.z);\r\n      float a1=controlState.z;\r\n      float a2=j==NODES-1?controlState.z:0.5*(controlState.z+nextState.z);\r\n      float boundsPadding=ribbonWidth+0.040;\r\n      vec2 boundsMin=min(min(p0,p1),p2)-vec2(boundsPadding);\r\n      vec2 boundsMax=max(max(p0,p1),p2)+vec2(boundsPadding);\r\n      if(any(lessThan(uv,boundsMin))||any(greaterThan(uv,boundsMax)))continue;\r\n\r\n      float u=footOfNormal(uv,p0,p1,p2);\r\n      vec2 nearest=quadraticBezier(p0,p1,p2,u);\r\n      vec2 tangentVector=quadraticBezierD(p0,p1,p2,u);\r\n      if(dot(tangentVector,tangentVector)<0.00000001)tangentVector=p2-p0;\r\n      vec2 tangent=normalize(tangentVector+vec2(0.00001,0.0));\r\n      vec2 normal=vec2(-tangent.y,tangent.x);\r\n      vec2 fromCurve=uv-nearest;\r\n\r\n      // Stationarity means the longitudinal residual vanishes; a non-zero one\r\n      // says the solve clamped and this pixel's ruling lies past the piece.\r\n      float overshoot=max(abs(dot(fromCurve,tangent))-1.5*pixelSize,0.0);\r\n\r\n      float along=quadraticBezier1(a0,a1,a2,u)/ribbonArcLength;\r\n      float phase=along*(3.2+twist*7.2)-t*(0.76+fi*0.028)+fi*1.71;\r\n      phase+=0.30*sin(along*8.2-t*0.48+fi*0.73);\r\n      float facing=cos(phase);\r\n      float taper=1.0-smoothstep(0.74,1.0,along)*0.55;\r\n      // Perspective has to reach the width too. projectDepth spreads the\r\n      // centreline by the same factor, so leaving width alone drew near ribbons\r\n      // too narrow and far ones too fat.\r\n      float curveDepth=quadraticBezier1(z0,z1,z2,u);\r\n      float baseWidth=ribbonWidth*taper*(1.0+curveDepth*0.20);\r\n      float projection=abs(facing);\r\n      // Projected half extent of a rectangular cross-section: the width edge\r\n      // foreshortens as |cos|, the thickness edge fills in as |sin|. A ribbon\r\n      // turning edge-on becomes a thin square-ended sliver, which is what silk\r\n      // does, instead of collapsing to a resolution-dependent minimum line.\r\n      float width=max(baseWidth*projection+ribbonWidth*0.060*abs(sin(phase)),pixelSize);\r\n      float effectiveProjection=width/max(baseWidth,0.001);\r\n\r\n      // Ruled-strip coverage: the lateral material coordinate against the local\r\n      // half width, unioned as square-ended rectangles in each piece's own\r\n      // frame. Testing distance-to-centreline instead unions circles, which\r\n      // inflates the silhouette near width maxima and can never form the sharp\r\n      // crossing where a twisting ribbon passes edge-on. Neighbouring pieces\r\n      // share their end ruling exactly, so rectangles butt together with no gap\r\n      // and no cap bulge, and an interior w-|v| always outranks a neighbour's\r\n      // clamped exterior distance.\r\n      float signedSide=dot(fromCurve,normal);\r\n      float lateralOut=abs(signedSide)-width;\r\n      float surfaceDistance=overshoot>0.0\r\n        ? -length(vec2(overshoot,max(lateralOut,0.0)))\r\n        : -lateralOut;\r\n\r\n      float physicalSide=clamp(signedSide/effectiveProjection,-baseWidth,baseWidth);\r\n      float sectionZ=curveDepth*1.65\r\n        +physicalSide/max(baseWidth,0.001)*sin(phase)*0.16*depth;\r\n\r\n      // Coverage is the union of every piece; which piece gets *shaded* is a\r\n      // separate question, answered by depth. Taking the deepest-inside sample\r\n      // instead makes a self-crossing show whichever layer happens to be more\r\n      // interior, and lets the winner flip wherever two pieces nearly tie.\r\n      // Interior samples all score above 100, so they always outrank exterior\r\n      // ones and the boundary still finds a frame to shade.\r\n      ribbonSurfaceDistance=max(ribbonSurfaceDistance,surfaceDistance);\r\n      float sectionScore=surfaceDistance>0.0?100.0+sectionZ:surfaceDistance;\r\n      if(sectionScore>ribbonScore){\r\n        ribbonScore=sectionScore;\r\n        ribbonSignedSide=signedSide;\r\n        ribbonBaseWidth=baseWidth;\r\n        ribbonEffectiveProjection=effectiveProjection;\r\n        ribbonFacing=facing;\r\n        ribbonAlong=along;\r\n        ribbonPhase=phase;\r\n        ribbonTangent=tangent;\r\n        ribbonSceneDepth=sectionZ;\r\n        ribbonDepthTangent=quadraticBezierD1(z0,z1,z2,u);\r\n      }\r\n    }\r\n\r\n    if(ribbonSurfaceDistance>-10.0){\r\n      // No global cap planes. They were infinite half-space clips, so a ribbon\r\n      // that curled back past its own head or tail plane got sliced straight\r\n      // through mid-body, amputating whole spans. Square ends come free from\r\n      // the rectangle union: the end pieces already terminate flat, normal to\r\n      // the tangent at the end node.\r\n      // A fixed pixel footprint stays stable across the bounding-box branches;\r\n      // screen derivatives of the rejected sentinel would draw the boxes.\r\n      float aa=max(1.15/min(RENDERSIZE.x,RENDERSIZE.y),0.0008);\r\n      float mask=smoothstep(-aa,aa,ribbonSurfaceDistance);\r\n      float width=ribbonBaseWidth*ribbonEffectiveProjection;\r\n      // The trim is a fraction of the *current* half width, never a fixed\r\n      // radius. A fixed band swallows the whole cross-section once a ribbon\r\n      // turns edge-on, which reads as the material fading out instead of\r\n      // narrowing. Cloth has a crisp cut edge, not a shaded falloff.\r\n      float halfWidth=max(width,aa);\r\n      float edge=1.0-smoothstep(0.0,min(0.20*halfWidth,aa*1.6),abs(ribbonSurfaceDistance));\r\n\r\n      // Width rotates out of the image plane, so an edge-on face becomes a line\r\n      // while its two physical edges separate in depth rather than swelling.\r\n      vec3 centerTangent=normalize(vec3(ribbonTangent,ribbonDepthTangent*0.32));\r\n      vec3 widthAxis=normalize(\r\n        vec3(-ribbonTangent.y,ribbonTangent.x,sin(ribbonPhase)*0.75)\r\n      );\r\n      vec3 planeNormal=normalize(cross(centerTangent,widthAxis));\r\n      if(ribbonFacing<0.0)planeNormal=-planeNormal;\r\n      shadow=max(shadow,smoothstep(-0.032,0.004,ribbonSurfaceDistance)*0.20);\r\n\r\n      if(mask>0.004){\r\n        vec3 cloth=shadeCloth(\r\n          fi,ribbonFacing,edge,planeNormal,ribbonAlong,ribbonSceneDepth,t\r\n        );\r\n        // Keep the two frontmost layers rather than one argmax winner, so a\r\n        // crossing edge antialiases against the ribbon behind it. A single\r\n        // winner picked by depth-plus-coverage lets a fully covered far ribbon\r\n        // beat a half-covered near one, which flips hard along the seam.\r\n        if(ribbonSceneDepth>frontZ){\r\n          backZ=frontZ;backMask=frontMask;backColor=frontColor;\r\n          frontZ=ribbonSceneDepth;frontMask=mask;frontColor=cloth;\r\n        }else if(ribbonSceneDepth>backZ){\r\n          backZ=ribbonSceneDepth;backMask=mask;backColor=cloth;\r\n        }\r\n      }\r\n    }\r\n  }\r\n\r\n  float alpha=frontMask+(1.0-frontMask)*backMask;\r\n  if(alpha<=0.0){gl_FragColor=vec4(0.0,0.0,0.0,shadow);return;}\r\n  vec3 premultiplied=frontColor*frontMask+(1.0-frontMask)*backColor*backMask;\r\n  gl_FragColor=vec4(premultiplied/alpha,alpha);\r\n}\r\n\r\nvoid compose(vec2 uv){\r\n  vec4 layer=IMG_NORM_PIXEL(ribbonLayer,uv);\r\n  vec2 px=1.0/RENDERSIZE;\r\n  float softShadow=0.0;\r\n  softShadow=max(softShadow,IMG_NORM_PIXEL(ribbonLayer,uv+px*vec2(-6.0,7.0)).a*0.24);\r\n  softShadow=max(softShadow,IMG_NORM_PIXEL(ribbonLayer,uv+px*vec2(-10.0,12.0)).a*0.13);\r\n  softShadow=max(softShadow,IMG_NORM_PIXEL(ribbonLayer,uv+px*vec2(-15.0,18.0)).a*0.07);\r\n  softShadow=max(softShadow,layer.a*0.0);\r\n  vec3 bg=mix(vec3(0.006,0.008,0.021),vec3(0.035,0.016,0.050),uv.y);\r\n  bg+=vec3(0.025,0.03,0.067)*pow(max(0.0,1.0-length(uv-0.5)*1.42),4.0);\r\n  bg*=1.0-softShadow*(1.0-layer.a);\r\n  vec3 color=mix(bg,layer.rgb,layer.a);\r\n  color=1.0-exp(-max(color,0.0)*1.22);\r\n  gl_FragColor=vec4(pow(color,vec3(0.93)),1.0);\r\n}\r\n\r\nvoid main(){\r\n  vec2 uv=isf_FragNormCoord;\r\n  float t=TIME*speed;\r\n  if(PASSINDEX==0){updateSpines(uv,t);return;}\r\n  if(PASSINDEX==1){updateSpineDepth(uv,t);return;}\r\n  if(PASSINDEX==2){renderRibbons(uv,t);return;}\r\n  compose(uv);\r\n}\r\n",
    "generator": {
      "model": "claude-opus-5",
      "provider": "anthropic",
      "prompt": null,
      "params": null
    }
  }
}