{
  "license": "CC0-1.0",
  "shader": {
    "id": "vht28vg2",
    "title": "Streamer V",
    "license": "CC0-1.0",
    "provenance_type": "witnessed_generation",
    "made_with": "glslop",
    "status": "active",
    "compile_ok": true,
    "render_hash": "0c3936e818084860",
    "tags": [
      "glow",
      "flow",
      "ribbon",
      "boid",
      "twirl"
    ],
    "parents": [
      "dp2sdrd0"
    ],
    "author": "zazi5inxhmgi",
    "author_name": "ufffd",
    "author_url": "/u/ufffd",
    "created_at": "2026-08-05T21:30:33.404Z",
    "poster_url": "/posters/vht28vg2.avif",
    "stats": {
      "slops": 0,
      "views": 128,
      "forks": 0
    },
    "self": "/api/v1/shaders/vht28vg2",
    "isf": "/*{\n  \"DESCRIPTION\": \"Streamer V: the same silk, properly photographed. Anisotropic two-lobe sheen runs highlights across the weave instead of across the screen; a warm key from behind pushes light through the dye so backlit ribbons glow rather than going black; the edge-on crossing of each travelling twist flares with thin-film colour; depth defocuses the silhouette and drains its contrast; three translucent layers tint each other where they cross; and a quarter-res separable blur serves as glow, as soft occlusion and as the colour the ribbons spill back into the room.\",\n  \"INPUTS\": [\n    { \"NAME\": \"speed\", \"TYPE\": \"float\", \"DEFAULT\": 0.78, \"MIN\": 0.0, \"MAX\": 1.6 },\n    { \"NAME\": \"ribbonWidth\", \"TYPE\": \"float\", \"DEFAULT\": 0.054, \"MIN\": 0.020, \"MAX\": 0.095 },\n    { \"NAME\": \"stiffness\", \"TYPE\": \"float\", \"DEFAULT\": 0.64, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"wind\", \"TYPE\": \"float\", \"DEFAULT\": 0.72, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"mess\", \"TYPE\": \"float\", \"DEFAULT\": 0.70, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"twist\", \"TYPE\": \"float\", \"DEFAULT\": 0.68, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"depth\", \"TYPE\": \"float\", \"DEFAULT\": 0.76, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"sheen\", \"TYPE\": \"float\", \"DEFAULT\": 0.74, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"bloom\", \"TYPE\": \"float\", \"DEFAULT\": 0.62, \"MIN\": 0.0, \"MAX\": 1.0 },\n    { \"NAME\": \"seed\", \"TYPE\": \"float\", \"DEFAULT\": 23.0, \"MIN\": 0.0, \"MAX\": 30.0 }\n  ],\n  \"PASSES\": [\n    { \"TARGET\": \"spines\", \"PERSISTENT\": true, \"FLOAT\": true },\n    { \"TARGET\": \"spineDepth\", \"PERSISTENT\": true, \"FLOAT\": true },\n    { \"TARGET\": \"spineTwist\", \"PERSISTENT\": true, \"FLOAT\": true },\n    { \"TARGET\": \"ribbonLayer\", \"FLOAT\": true },\n    { \"TARGET\": \"glowA\", \"FLOAT\": true, \"WIDTH\": \"$WIDTH/4.0\", \"HEIGHT\": \"$HEIGHT/4.0\" },\n    { \"TARGET\": \"glowB\", \"FLOAT\": true, \"WIDTH\": \"$WIDTH/4.0\", \"HEIGHT\": \"$HEIGHT/4.0\" },\n    {}\n  ]\n}*/\n\n// A conforming GLSL ES 1.00 host declares `precision mediump float;` for\n// fragment shaders, since ES 1.00 gives fragment stages no default float\n// precision and highp is optional there. mediump guarantees only about 10 bits\n// of mantissa. This shader integrates node positions incrementally across\n// thousands of frames in a persistent float target, so mediump would quantize\n// the spine and reintroduce exactly the frame-to-frame stutter the motion work\n// removed. Ask for highp where the hardware advertises it; the guard is\n// required because referencing highp without it is an error on parts that lack\n// it. Desktop drivers usually promote mediump to fp32 anyway, so the absence of\n// this line would likely go unnoticed until the piece ran on a phone.\n#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#endif\n\n#define PI 3.14159265359\n#define TAU 6.28318530718\n#define RIBBONS 7\n#define NODES 28\n\nfloat hash11(float p){return fract(sin(p*127.1+seed*29.17)*43758.5453);}\nfloat hash21(vec2 p){return fract(sin(dot(p,vec2(41.73,289.31))+seed*11.37)*43758.5453);}\n\nvec3 spectrum(float x){\n  x=clamp(x,0.0,1.0)*6.0;\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);\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);\n  if(x<1.0)return mix(r,o,smoothstep(0.0,1.0,x));\n  if(x<2.0)return mix(o,y,smoothstep(1.0,2.0,x));\n  if(x<3.0)return mix(y,g,smoothstep(2.0,3.0,x));\n  if(x<4.0)return mix(g,b,smoothstep(3.0,4.0,x));\n  if(x<5.0)return mix(b,i,smoothstep(4.0,5.0,x));\n  return mix(i,v,smoothstep(5.0,6.0,x));\n}\n\nvec2 nodeUv(int node,int ribbon){\n  return (vec2(float(node),float(ribbon))+0.5)/RENDERSIZE;\n}\n\n// Limiting a 2D quantity has to bound its magnitude, not its components.\n// clamp() on a vec2 bounds it to a square, so a saturating value gets pushed\n// out to the corners of that box and direction snaps onto 45 degree diagonals.\n// It also has to bound it *smoothly*: a hard rescale grips on one frame and\n// releases on the next for anything sitting near the threshold, which is the\n// rubber-band-on-a-table mechanism behind intermittent friction in the motion.\n// Exact identity below a knee at 62% of the limit, a rational compressor above\n// it that approaches the limit asymptotically, continuous in value and slope\n// where the two meet.\nvec2 softLimit(vec2 value,float limit){\n  float magnitude=length(value);\n  float knee=limit*0.62;\n  if(magnitude<=knee)return value;\n  float over=(magnitude-knee)/max(limit-knee,0.0001);\n  float bounded=knee+(limit-knee)*over/(1.0+over);\n  return value*(bounded/max(magnitude,0.0001));\n}\n\nfloat softLimit1(float value,float limit){\n  float magnitude=abs(value);\n  float knee=limit*0.62;\n  if(magnitude<=knee)return value;\n  float over=(magnitude-knee)/max(limit-knee,0.0001);\n  float bounded=knee+(limit-knee)*over/(1.0+over);\n  return value*(bounded/max(magnitude,0.0001));\n}\n\nvec2 headSeedPosition(float fi){\n  float angle=fi*2.39+0.6;\n  return 0.5+vec2(cos(angle),sin(angle))*(0.135+0.085*hash11(fi+17.0));\n}\n\nvec2 curlOctave(vec2 p,float angle,float freq,float phase,float amplitude){\n  vec2 direction=vec2(cos(angle),sin(angle));\n  return vec2(-direction.y,direction.x)*(cos(dot(direction,p)*freq+phase)*amplitude);\n}\n\nvec2 windForce(vec2 p,float fi,float t,float detail){\n  p+=vec2(hash11(fi+31.0),hash11(fi+41.0))*mess*0.42;\n  vec2 flow=curlOctave(p,0.70+t*0.05,5.2,t*0.19,1.00);\n  flow+=curlOctave(p,2.31-t*0.07,8.6,-t*0.29,0.55*detail);\n  flow+=curlOctave(p,4.12+t*0.09,13.5,t*0.44,0.26*detail*detail);\n  flow+=curlOctave(p,5.63-t*0.11,19.0,-t*0.60,0.10*detail*detail*detail);\n  return flow*2.0;\n}\n\nvec2 quadraticBezier(vec2 p0,vec2 p1,vec2 p2,float u){\n  vec2 a=mix(p0,p1,u);\n  vec2 b=mix(p1,p2,u);\n  return mix(a,b,u);\n}\n\nvec2 quadraticBezierD(vec2 p0,vec2 p1,vec2 p2,float u){\n  return 2.0*mix(p1-p0,p2-p1,u);\n}\n\nvec2 quadraticBezierDD(vec2 p0,vec2 p1,vec2 p2){\n  return 2.0*(p0-2.0*p1+p2);\n}\n\nfloat quadraticBezier1(float p0,float p1,float p2,float u){\n  return mix(mix(p0,p1,u),mix(p1,p2,u),u);\n}\n\nfloat quadraticBezierD1(float p0,float p1,float p2,float u){\n  return 2.0*mix(p1-p0,p2-p1,u);\n}\n\nvec2 projectDepth(vec2 point,float z){\n  return 0.5+(point-0.5)*(1.0+z*0.20);\n}\n\nvoid considerQuadraticRoot(\n  vec2 point,vec2 p0,vec2 p1,vec2 p2,float root,\n  inout float bestDistanceSquared,inout float bestU\n){\n  float u=clamp(root,0.0,1.0);\n  vec2 delta=quadraticBezier(p0,p1,p2,u)-point;\n  float distanceSquared=dot(delta,delta);\n  if(distanceSquared<bestDistanceSquared){\n    bestDistanceSquared=distanceSquared;\n    bestU=u;\n  }\n}\n\nvoid refineQuadraticRoot(\n  vec2 point,vec2 p0,vec2 p1,vec2 p2,float startU,\n  inout float bestDistanceSquared,inout float bestU\n){\n  float u=clamp(startU,0.0,1.0);\n  for(int solve=0;solve<4;solve++){\n    vec2 curve=quadraticBezier(p0,p1,p2,u);\n    vec2 firstDerivative=quadraticBezierD(p0,p1,p2,u);\n    vec2 secondDerivative=quadraticBezierDD(p0,p1,p2);\n    float numerator=dot(curve-point,firstDerivative);\n    float denominator=dot(firstDerivative,firstDerivative)+dot(curve-point,secondDerivative);\n    float safeDenominator=(denominator<0.0?-1.0:1.0)*max(abs(denominator),0.0000001);\n    u=clamp(u-clamp(numerator/safeDenominator,-0.34,0.34),0.0,1.0);\n  }\n  considerQuadraticRoot(point,p0,p1,p2,u,bestDistanceSquared,bestU);\n}\n\n// Foot of the normal: the u whose ruling passes through this pixel, solving\n// dot(p-B(u),B'(u))=0. Endpoints are deliberately NOT offered as candidates.\n// An endpoint \"hit\" is a round cap, and unioning caps across sections is what\n// turns a flat strip into a sweep of disks.\nfloat footOfNormal(vec2 point,vec2 p0,vec2 p1,vec2 p2){\n  vec2 chord=p2-p0;\n  float chordSeed=dot(point-p0,chord)/max(dot(chord,chord),0.00000001);\n  float bestDistanceSquared=1000000.0;\n  float bestU=0.5;\n  refineQuadraticRoot(point,p0,p1,p2,chordSeed,bestDistanceSquared,bestU);\n  refineQuadraticRoot(point,p0,p1,p2,chordSeed<0.5?0.78:0.22,bestDistanceSquared,bestU);\n  return bestU;\n}\n\nfloat stepTime(){\n  return clamp(TIMEDELTA,1.0/240.0,1.0/30.0);\n}\n\n// ---------------------------------------------------------------------------\n// SIMULATION - unchanged from Streamer IV.\n// ---------------------------------------------------------------------------\n\nvoid updateSpines(vec2 uv,float t){\n  ivec2 cell=ivec2(floor(uv*RENDERSIZE));\n  int node=cell.x;\n  int ribbon=cell.y;\n  if(node>=NODES||ribbon>=RIBBONS){gl_FragColor=vec4(0.0);return;}\n  float fi=float(ribbon);\n  float tail=float(node)/float(NODES-1);\n  float dt=stepTime();\n  vec2 head=headSeedPosition(fi);\n  float segmentLength=0.036+0.004*sin(fi*1.7);\n  vec2 initialDirection=normalize(vec2(cos(fi*2.17+0.4),sin(fi*1.63+0.8)));\n  float cruise=0.10+0.17*speed;\n  // Seed on an empty buffer, not on a frame number. FRAMEINDEX is not portable\n  // as an \"is this the first frame\" test: a host rendering a poster at t=8 may\n  // hand the shader FRAMEINDEX 480 on the very first frame it ever draws, the\n  // persistent target keeps its cleared contents, and every node reads (0,0).\n  vec4 stored=IMG_NORM_PIXEL(spines,nodeUv(node,ribbon));\n  if(FRAMEINDEX<2||dot(stored,stored)<=0.0){\n    vec2 initial=head-initialDirection*segmentLength*float(node);\n    vec2 seedVelocity=vec2(-initialDirection.y,initialDirection.x)*cruise;\n    gl_FragColor=vec4(clamp(initial,vec2(-0.45),vec2(1.45)),seedVelocity);\n    return;\n  }\n  if(node==0){\n    vec4 headState=IMG_NORM_PIXEL(spines,nodeUv(0,ribbon));\n    vec2 headPos=headState.xy;\n    vec2 headVel=headState.zw;\n\n    vec2 separation=vec2(0.0);\n    vec2 alignment=vec2(0.0);\n    vec2 cohesion=vec2(0.0);\n    float neighbours=0.0;\n    for(int k=0;k<RIBBONS;k++){\n      float notSelf=step(0.5,abs(float(k)-fi));\n      vec4 otherState=IMG_NORM_PIXEL(spines,nodeUv(0,k));\n      vec2 offset=headPos-otherState.xy;\n      float gap=max(length(offset),0.0001);\n      separation+=offset/gap*(notSelf*max(0.42-gap,0.0));\n      alignment+=otherState.zw*notSelf;\n      cohesion+=otherState.xy*notSelf;\n      neighbours+=notSelf;\n    }\n    alignment/=max(neighbours,1.0);\n    cohesion/=max(neighbours,1.0);\n\n    float authority=cruise/0.35;\n    vec2 steer=separation*(13.5*authority);\n    steer+=(alignment-headVel)*0.45;\n    steer+=(cohesion-headPos)*(0.08*authority);\n    vec2 fromCentre=headPos-0.5;\n    float outward=length(fromCentre);\n    steer-=fromCentre/max(outward,0.0001)*smoothstep(0.36,0.54,outward)*(6.0*authority);\n    steer+=windForce(headPos,fi,t,1.0)*(0.40+0.85*wind)*authority;\n    steer+=vec2(-headVel.y,headVel.x)*sin(t*(0.29+0.11*hash11(fi+3.0))+fi*2.2)*1.25;\n    steer=softLimit(steer,cruise*2.2);\n\n    headVel+=steer*dt;\n    float pace=length(headVel);\n    headVel=pace>0.0001?headVel/pace*cruise:initialDirection*cruise;\n    headPos=clamp(headPos+headVel*dt,vec2(-0.45),vec2(1.45));\n    gl_FragColor=vec4(headPos,headVel);\n    return;\n  }\n\n  vec4 self=IMG_NORM_PIXEL(spines,nodeUv(node,ribbon));\n  vec2 pos=self.xy;\n  vec2 vel=self.zw;\n  vec4 previousState=IMG_NORM_PIXEL(spines,nodeUv(node-1,ribbon));\n  vec2 prev=previousState.xy;\n  vec2 toPrev=prev-pos;\n  float prevDistance=max(length(toPrev),0.0001);\n  vec2 tangent=toPrev/prevDistance;\n  float springStrength=34.0+stiffness*54.0;\n  vec2 force=tangent*(prevDistance-segmentLength)*springStrength;\n\n  vec2 neighbourVelocity=previousState.zw;\n  float neighbourWeight=1.0;\n  if(node<NODES-1){\n    vec4 nextState=IMG_NORM_PIXEL(spines,nodeUv(node+1,ribbon));\n    vec2 next=nextState.xy;\n    vec2 toNext=next-pos;\n    float nextDistance=max(length(toNext),0.0001);\n    force+=toNext/nextDistance*(nextDistance-segmentLength)*springStrength*0.62;\n    force+=(prev+next-2.0*pos)*(5.0+stiffness*7.0);\n    neighbourVelocity+=nextState.zw;\n    neighbourWeight=2.0;\n  }\n  neighbourVelocity/=neighbourWeight;\n\n  float detail=mix(1.0,0.12,tail);\n  force+=windForce(pos,fi,t,detail)*(0.22+0.50*wind)*(0.90+0.10*tail);\n\n  vec2 fromCentre=pos-0.5;\n  float outward=length(fromCentre);\n  force-=fromCentre/max(outward,0.0001)*smoothstep(0.74,1.02,outward)*3.0;\n\n  vec2 lateral=vec2(-tangent.y,tangent.x);\n  float ripple=sin(t*(0.78+0.16*hash11(fi+13.0))-tail*(3.4+2.2*twist)+fi*1.77);\n  force+=lateral*ripple*(0.03+0.07*tail*(1.30-tail))*(0.55+0.85*mess);\n\n  vel+=force*dt;\n\n  // Internal viscosity: damping against the neighbours rather than against the\n  // room. A transverse wave IS the difference between a node and its\n  // neighbours, so it is dissipated hard; bulk translation is invisible to this\n  // term and passes through untouched, which is why the silk no longer wades.\n  vec2 relative=neighbourVelocity-vel;\n  vec2 relativeAlong=tangent*dot(relative,tangent);\n  vec2 relativeAcross=relative-relativeAlong;\n  vel+=relativeAlong*(1.0-exp(-dt*(7.0+stiffness*9.0)));\n  vel+=relativeAcross*(1.0-exp(-dt*mix(16.0,29.0,tail)));\n\n  vec2 alongVelocity=tangent*dot(vel,tangent);\n  vec2 acrossVelocity=vel-alongVelocity;\n  float alongDrag=mix(1.5,2.0,tail)*(1.0+stiffness*0.30);\n  float acrossDrag=mix(4.2,7.6,tail)*(1.0+stiffness*0.30);\n  vel=alongVelocity*exp(-dt*alongDrag)+acrossVelocity*exp(-dt*acrossDrag);\n  vel=softLimit(vel,0.85);\n  vec2 predicted=pos+vel*dt;\n\n  vec2 toPrevSettled=predicted-prev;\n  float settledDistance=max(length(toPrevSettled),0.0001);\n  vec2 corrected=prev+toPrevSettled/settledDistance*mix(settledDistance,segmentLength,0.72);\n\n  vel=softLimit((corrected-pos)/dt,1.05);\n  pos=corrected;\n  pos=clamp(pos,vec2(-0.45),vec2(1.45));\n  gl_FragColor=vec4(pos,vel);\n}\n\nvoid updateSpineDepth(vec2 uv,float t){\n  ivec2 cell=ivec2(floor(uv*RENDERSIZE));\n  int node=cell.x;\n  int ribbon=cell.y;\n  if(node>=NODES||ribbon>=RIBBONS){gl_FragColor=vec4(0.0);return;}\n  float fi=float(ribbon);\n  float along=float(node)/float(NODES-1);\n  float dt=stepTime();\n  float targetHead=0.42*sin(t*0.31+fi*1.37)+0.10*cos(t*0.17-fi*0.77);\n\n  // Cumulative arc length from the head, carried in the spare .z channel. Twist\n  // parameterised by node index instead kinks at every joint, because the\n  // springs stretch and sections are not equal length.\n  float arc=0.0;\n  vec2 walkPoint=IMG_NORM_PIXEL(spines,nodeUv(0,ribbon)).xy;\n  float walkDepth=IMG_NORM_PIXEL(spineDepth,nodeUv(0,ribbon)).x;\n  for(int k=1;k<NODES;k++){\n    vec2 nextPoint=IMG_NORM_PIXEL(spines,nodeUv(k,ribbon)).xy;\n    float nextDepth=IMG_NORM_PIXEL(spineDepth,nodeUv(k,ribbon)).x;\n    arc+=step(float(k),float(node))*length(vec3(nextPoint-walkPoint,(nextDepth-walkDepth)*0.32));\n    walkPoint=nextPoint;\n    walkDepth=nextDepth;\n  }\n\n  vec4 state=IMG_NORM_PIXEL(spineDepth,nodeUv(node,ribbon));\n  if(FRAMEINDEX<2||state.w<0.5){\n    float initial=targetHead*0.45+0.20*sin(fi*1.31+along*5.4);\n    gl_FragColor=vec4(initial,0.0,arc,1.0);\n    return;\n  }\n  float z=state.x;\n  float velocity=state.y;\n  if(node==0){\n    velocity=softLimit1((targetHead-z)/dt,1.4);\n    gl_FragColor=vec4(targetHead,velocity,arc,1.0);\n    return;\n  }\n  float previous=IMG_NORM_PIXEL(spineDepth,nodeUv(node-1,ribbon)).x;\n  float force=(previous-z)*(24.0+stiffness*34.0);\n  if(node<NODES-1){\n    float next=IMG_NORM_PIXEL(spineDepth,nodeUv(node+1,ribbon)).x;\n    force+=(next-z)*(13.0+stiffness*18.0);\n    force+=(previous+next-2.0*z)*(5.0+stiffness*8.0);\n  }\n  force+=sin(t*(0.34+fi*0.012)-along*(2.8+twist*2.2)+fi*1.11)*(0.20+0.44*mess);\n  force-=sign(z)*max(abs(z)-0.44,0.0)*38.0;\n  velocity+=force*dt;\n  velocity*=exp(-dt*(3.4+stiffness*1.5));\n  velocity=softLimit1(velocity,1.15);\n  z=clamp(z+velocity*dt,-0.80,0.80);\n  gl_FragColor=vec4(z,velocity,arc,1.0);\n}\n\n// Torsional wave equation on the chain: second order, so a twist is\n// transported at a finite speed with its shape intact rather than diffused.\nvoid updateTwist(vec2 uv,float t){\n  ivec2 cell=ivec2(floor(uv*RENDERSIZE));\n  int node=cell.x;\n  int ribbon=cell.y;\n  if(node>=NODES||ribbon>=RIBBONS){gl_FragColor=vec4(0.0);return;}\n  float fi=float(ribbon);\n  float along=float(node)/float(NODES-1);\n  float dt=stepTime();\n\n  float rate=0.34+0.16*hash11(fi+7.0);\n  float amplitude=0.9+3.6*twist;\n  float drive=sin(t*rate+fi*2.31)+0.42*sin(t*rate*2.7-fi*1.13);\n  float headTwist=drive*amplitude*0.70;\n  vec2 h0=IMG_NORM_PIXEL(spines,nodeUv(0,ribbon)).xy;\n  vec2 h1=IMG_NORM_PIXEL(spines,nodeUv(1,ribbon)).xy;\n  vec2 h2=IMG_NORM_PIXEL(spines,nodeUv(2,ribbon)).xy;\n  vec2 leadA=normalize(h0-h1+vec2(0.00001,0.0));\n  vec2 leadB=normalize(h1-h2+vec2(0.00001,0.0));\n  headTwist+=(leadA.x*leadB.y-leadA.y*leadB.x)*(1.8+2.6*twist);\n\n  vec4 state=IMG_NORM_PIXEL(spineTwist,nodeUv(node,ribbon));\n  if(FRAMEINDEX<2||state.w<0.5){\n    gl_FragColor=vec4(headTwist,0.0,0.0,1.0);\n    return;\n  }\n  if(node==0){\n    gl_FragColor=vec4(headTwist,softLimit1((headTwist-state.x)/dt,9.0),0.0,1.0);\n    return;\n  }\n\n  float angle=state.x;\n  float angular=state.y;\n  float previousAngle=IMG_NORM_PIXEL(spineTwist,nodeUv(node-1,ribbon)).x;\n  float nextAngle=angle;\n  if(node<NODES-1)nextAngle=IMG_NORM_PIXEL(spineTwist,nodeUv(node+1,ribbon)).x;\n\n  float waveSpeedSquared=30.0+stiffness*44.0;\n  float torque=(previousAngle+nextAngle-2.0*angle)*waveSpeedSquared;\n\n  vec2 position=IMG_NORM_PIXEL(spines,nodeUv(node,ribbon)).xy;\n  vec2 upstream=IMG_NORM_PIXEL(spines,nodeUv(node-1,ribbon)).xy;\n  vec2 axis=upstream-position;\n  axis/=max(length(axis),0.0001);\n  vec2 flow=windForce(position,fi,t,mix(1.0,0.12,along));\n  torque+=(flow.x*axis.y-flow.y*axis.x)*(0.30+0.60*wind)*(0.30+0.80*twist);\n  torque-=(angle-headTwist)*(0.06*along);\n\n  angular+=torque*dt;\n  angular*=exp(-dt*mix(0.55,1.90,along*along));\n  angular=softLimit1(angular,7.0);\n  angle+=angular*dt;\n  gl_FragColor=vec4(angle,angular,0.0,1.0);\n}\n\n// ---------------------------------------------------------------------------\n// MATERIAL\n// ---------------------------------------------------------------------------\n\n// Kajiya-Kay, which is the right primitive here and was the conspicuous thing\n// missing. A ribbon of woven silk is not a shiny plane - it is a bundle of\n// parallel fibres, and light scattering off a cylinder does not return a point\n// highlight, it returns a *cone*, which shows on screen as a bright band lying\n// perpendicular to the fibre direction. That single fact is most of why silk\n// looks like silk and satin paint looks like plastic: the highlight belongs to\n// the material's own direction, not to the screen. So the specular is built\n// from the angle between the fibre and the half vector rather than between the\n// normal and the half vector, and because the ribbon's tangent is already in\n// hand from the ruled-strip solve, it costs nothing to get right.\n// The shift term tilts the fibre toward or away from the normal before the\n// test, which is how Marschner's two lobes are separated cheaply: a tight\n// near-white one shifted toward the surface, and a broad one shifted the other\n// way, tinted by the dye it has just travelled through.\nfloat strandSpecular(vec3 fibre,vec3 normal,vec3 lightDirection,vec3 view,float shift,float sharpness){\n  vec3 shifted=normalize(fibre+normal*shift);\n  vec3 halfVector=normalize(lightDirection+view);\n  float alignment=dot(shifted,halfVector);\n  float perpendicular=sqrt(max(0.0,1.0-alignment*alignment));\n  return pow(max(perpendicular,0.0),sharpness);\n}\n\nvec3 shadeSilk(\n  float fi,float facing,float edge,vec3 normal,vec3 fibre,\n  float along,float arcPosition,float acrossCoord,float sceneDepth,\n  float flow,float weaveFade,float t\n){\n  vec3 ink=spectrum(fi/6.0);\n  vec3 reverse=mix(spectrum(fi/6.0+0.12),vec3(0.020,0.012,0.038),0.52);\n  bool front=facing>=0.0;\n  vec3 base=front?ink:reverse;\n\n  vec3 view=vec3(0.0,0.0,1.0);\n  // A two-light rig, which is the whole reason the piece can now read as an\n  // object in a room. Cool key high and to camera left; warm rim low, right and\n  // *behind*. The rim is doing the important work - see the transmission term.\n  vec3 keyDirection=normalize(vec3(-0.40,0.58,0.71));\n  vec3 keyColour=vec3(0.84,0.90,1.00);\n  vec3 rimDirection=normalize(vec3(0.58,-0.22,-0.78));\n  vec3 rimColour=vec3(1.00,0.66,0.34);\n\n  // Woven micro-structure. Two frequencies, one running the length and one\n  // across, multiplied so they interfere the way a plain weave does. It is\n  // faded out entirely once the ribbon is only a few pixels wide, because at\n  // that point it is not texture any more, it is aliasing.\n  float lengthwise=0.5+0.5*sin(acrossCoord*13.0+arcPosition*2.0);\n  float picks=0.5+0.5*sin(arcPosition*74.0+acrossCoord*1.4);\n  float woven=mix(1.0,0.84+0.32*lengthwise*picks,weaveFade);\n\n  // Wrapped diffuse: cloth has no sharp terminator, light creeps round it. The\n  // ambient is a sky/ground gradient rather than a constant, so the underside\n  // of a fold is cooler and darker without needing a second light.\n  float wrapped=dot(normal,keyDirection)*0.5+0.5;\n  vec3 colour=base*mix(vec3(0.036,0.030,0.055),vec3(0.13,0.14,0.18),0.5+0.5*normal.y);\n  colour+=base*keyColour*(wrapped*wrapped*0.95)*woven;\n\n  // Transmission. The single biggest change to how the material reads. Silk is\n  // thin and open enough to be lit from behind, and dyed silk lit from behind\n  // does not go dark - it saturates and glows, because the light that arrives\n  // has been filtered by the dye twice over. The reverse side used to be a\n  // simple darkening, which is what made twists read as the ribbon going\n  // *away*; now a twist that turns its back to camera turns its face to the rim\n  // light and lights up instead. The dye is normalised to full chroma first, so\n  // what comes through is the hue at its purest rather than the surface colour\n  // dimmed, and path length is folded in through `facing`: a face-on section is\n  // thin and glows, an edge-on one is looking through the long way and stays\n  // dense.\n  float backlit=max(0.0,-dot(normal,rimDirection));\n  vec3 dye=ink/max(max(ink.r,max(ink.g,ink.b)),0.0001);\n  float thinness=mix(0.26,1.0,abs(facing));\n  colour+=mix(dye,vec3(1.0,0.86,0.70),0.20)*rimColour\n          *pow(backlit,2.6)*thinness*(0.50+0.85*sheen);\n\n  // Three anisotropic lobes. The primary is the specular proper. The secondary\n  // is broad and carries the dye, which is what gives satin its coloured\n  // second sheen just off the main highlight. The third belongs to the rim\n  // light and draws a bright thread down the length of any backlit ribbon -\n  // that thread following the curve is the clearest read the eye gets on the\n  // shape of the spine.\n  float primary=strandSpecular(fibre,normal,keyDirection,view,0.10,96.0);\n  float secondary=strandSpecular(fibre,normal,keyDirection,view,-0.18,14.0);\n  float rimStrand=strandSpecular(fibre,normal,rimDirection,view,0.04,42.0);\n  colour+=keyColour*primary*(0.30+0.95*sheen)*woven;\n  colour+=mix(base,vec3(1.0),0.30)*secondary*(0.12+0.36*sheen)*woven;\n  colour+=rimColour*rimStrand*(0.18+0.58*sheen);\n\n  // The grazing flare was white. Making it a thin-film interference colour\n  // instead is the pay-off for having built a real travelling twist: the flare\n  // marks exactly the moving line where the strip passes edge-on, so the\n  // crossing now arrives as a band of shifting spectral colour that runs the\n  // length of the ribbon and off the tip. The phase advances with arc length so\n  // the band is not a flat wash, and drifts very slowly in time so a ribbon\n  // held still still breathes.\n  float grazing=1.0-abs(facing);\n  float film=grazing*2.1+arcPosition*1.5+fi*0.23+t*0.045;\n  vec3 interference=0.5+0.5*cos(TAU*(film+vec3(0.0,0.30,0.58)));\n  colour+=mix(vec3(1.0),interference,0.66)*pow(grazing,2.8)*(0.30+0.75*sheen);\n\n  // The motion, displayed. Node speed is already sitting unused in the spine\n  // buffer, so a section travelling fast gets a small lift in sheen and a\n  // breath of its own dye - the visual equivalent of silk catching the light as\n  // it is snapped. It is deliberately slight; it should be felt as the fabric\n  // being alive rather than seen as a speed ramp.\n  colour*=1.0+flow*0.20;\n  colour+=mix(dye,vec3(1.0),0.45)*flow*flow*0.11;\n\n  // Cut edge. A trim of near-black, then a hair of key light caught on the\n  // cut itself, which keeps the boundary crisp against the glow without a\n  // hard outline.\n  vec3 edgeInk=mix(base,vec3(0.005,0.007,0.016),front?0.44:0.58);\n  colour=mix(colour,edgeInk,edge*0.50);\n  colour+=keyColour*edge*0.055;\n\n  // Aerial perspective. Distance drains contrast and pulls colour toward the\n  // room, which is the cue that sells seven overlapping ribbons as being at\n  // seven different distances rather than as a flat tangle.\n  float far=smoothstep(0.85,-1.20,sceneDepth);\n  colour=mix(colour,vec3(0.030,0.018,0.052),far*0.40);\n  colour*=mix(1.14,0.66,far);\n  colour*=0.98+0.02*sin(along*22.0-t*0.9+fi);\n  return colour;\n}\n\n// ---------------------------------------------------------------------------\n// RASTER\n// ---------------------------------------------------------------------------\n\nvoid renderRibbons(vec2 uv,float t){\n  float pixelSize=1.0/min(RENDERSIZE.x,RENDERSIZE.y);\n  float sharpAa=max(1.15/min(RENDERSIZE.x,RENDERSIZE.y),0.0008);\n  // A slow rack focus. The plane of focus breathes through the volume the\n  // ribbons occupy, so over a minute every ribbon comes forward into focus and\n  // falls back out again. Nothing else in the piece has a cycle this long, and\n  // having one is a large part of why it now bears watching rather than\n  // glancing at.\n  float focalPlane=0.34*sin(t*0.063)+0.10*sin(t*0.021);\n\n  vec3 layerColour0=vec3(0.0),layerColour1=vec3(0.0),layerColour2=vec3(0.0);\n  float layerAlpha0=0.0,layerAlpha1=0.0,layerAlpha2=0.0;\n  float layerZ0=-1000.0,layerZ1=-1000.0,layerZ2=-1000.0;\n\n  for(int r=0;r<RIBBONS;r++){\n    float fi=float(r);\n    float ribbonArcLength=max(IMG_NORM_PIXEL(spineDepth,nodeUv(NODES-1,r)).z,0.0001);\n    float ribbonScore=-1000.0;\n    float ribbonSurfaceDistance=-20.0;\n    float ribbonSignedSide=0.0;\n    float ribbonBaseWidth=0.0;\n    float ribbonEffectiveProjection=1.0;\n    float ribbonFacing=1.0;\n    float ribbonAlong=0.0;\n    float ribbonPhase=0.0;\n    float ribbonFlow=0.0;\n    vec2 ribbonTangent=vec2(1.0,0.0);\n    float ribbonSceneDepth=0.0;\n    float ribbonDepthTangent=0.0;\n    for(int j=0;j<NODES;j++){\n      // Ternaries, not min/max: GLSL ES 1.00 has no integer overload of\n      // min/max, so max(j-1,0) silently resolves to the float version and then\n      // fails to assign back to an int. A host compiling as ES 3.00 accepts it\n      // and the error only appears on a conforming ES 1.00 one.\n      int previousIndex=j>0?j-1:0;\n      int nextIndex=j<NODES-1?j+1:NODES-1;\n      vec4 previousState=IMG_NORM_PIXEL(spineDepth,nodeUv(previousIndex,r));\n      vec4 controlState=IMG_NORM_PIXEL(spineDepth,nodeUv(j,r));\n      vec4 nextState=IMG_NORM_PIXEL(spineDepth,nodeUv(nextIndex,r));\n      // Full vec4 rather than .xy - the velocity in zw is free here and drives\n      // the motion sheen below.\n      vec4 previousSpine=IMG_NORM_PIXEL(spines,nodeUv(previousIndex,r));\n      vec4 controlSpine=IMG_NORM_PIXEL(spines,nodeUv(j,r));\n      vec4 nextSpine=IMG_NORM_PIXEL(spines,nodeUv(nextIndex,r));\n      float previousZ=previousState.x;\n      float controlZ=controlState.x;\n      float nextZ=nextState.x;\n      vec2 previous=projectDepth(previousSpine.xy,previousZ);\n      vec2 control=projectDepth(controlSpine.xy,controlZ);\n      vec2 next=projectDepth(nextSpine.xy,nextZ);\n\n      // Midpoint-to-midpoint quadratic B-spline section. Neighbouring pieces\n      // share the same endpoint and first derivative exactly. Repeated endpoint\n      // controls create clean first/last caps without a special raster path.\n      vec2 p0=j==0?control:0.5*(previous+control);\n      vec2 p1=control;\n      vec2 p2=j==NODES-1?control:0.5*(control+next);\n      // Padding has to cover the defocus, or a blurred ribbon gets its own\n      // bounding box cut into its halo as a straight line.\n      float boundsPadding=ribbonWidth+0.075;\n      vec2 boundsMin=min(min(p0,p1),p2)-vec2(boundsPadding);\n      vec2 boundsMax=max(max(p0,p1),p2)+vec2(boundsPadding);\n      if(any(lessThan(uv,boundsMin))||any(greaterThan(uv,boundsMax)))continue;\n\n      // Everything below is behind the reject. This body runs 196 times per\n      // pixel and the great majority of iterations bail here, so the twist tap\n      // in particular must not be hoisted above it.\n      float z0=j==0?controlZ:0.5*(previousZ+controlZ);\n      float z1=controlZ;\n      float z2=j==NODES-1?controlZ:0.5*(controlZ+nextZ);\n      float a0=j==0?controlState.z:0.5*(previousState.z+controlState.z);\n      float a1=controlState.z;\n      float a2=j==NODES-1?controlState.z:0.5*(controlState.z+nextState.z);\n      float twistPrevious=IMG_NORM_PIXEL(spineTwist,nodeUv(previousIndex,r)).x;\n      float twistControl=IMG_NORM_PIXEL(spineTwist,nodeUv(j,r)).x;\n      float twistNext=IMG_NORM_PIXEL(spineTwist,nodeUv(nextIndex,r)).x;\n      float f0=j==0?twistControl:0.5*(twistPrevious+twistControl);\n      float f1=twistControl;\n      float f2=j==NODES-1?twistControl:0.5*(twistControl+twistNext);\n      float v0=length(previousSpine.zw);\n      float v1=length(controlSpine.zw);\n      float v2=length(nextSpine.zw);\n      float s0=j==0?v1:0.5*(v0+v1);\n      float s1=v1;\n      float s2=j==NODES-1?v1:0.5*(v1+v2);\n\n      float u=footOfNormal(uv,p0,p1,p2);\n      vec2 nearest=quadraticBezier(p0,p1,p2,u);\n      vec2 tangentVector=quadraticBezierD(p0,p1,p2,u);\n      if(dot(tangentVector,tangentVector)<0.00000001)tangentVector=p2-p0;\n      vec2 tangent=normalize(tangentVector+vec2(0.00001,0.0));\n      vec2 normal=vec2(-tangent.y,tangent.x);\n      vec2 fromCurve=uv-nearest;\n\n      // Stationarity means the longitudinal residual vanishes; a non-zero one\n      // says the solve clamped and this pixel's ruling lies past the piece.\n      float overshoot=max(abs(dot(fromCurve,tangent))-1.5*pixelSize,0.0);\n\n      float along=quadraticBezier1(a0,a1,a2,u)/ribbonArcLength;\n      // Twist off the torsion pass, carried on the same midpoint quadratic as\n      // position, depth and arc, so it is C1 across every joint. Phase drives\n      // the width projection, so a per-node phase would notch the silhouette at\n      // all twenty-eight of them. The along term that remains is a permanent\n      // set in the material - a helix the ribbon was made with.\n      float phase=quadraticBezier1(f0,f1,f2,u)+along*(0.7+1.9*twist)+fi*1.71;\n      float facing=cos(phase);\n      float taper=1.0-smoothstep(0.74,1.0,along)*0.55;\n      float curveDepth=quadraticBezier1(z0,z1,z2,u);\n      float baseWidth=ribbonWidth*taper*(1.0+curveDepth*0.20);\n      float projection=abs(facing);\n      // Projected half extent of a rectangular cross-section: the width edge\n      // foreshortens as |cos|, the thickness edge fills in as |sin|. A ribbon\n      // turning edge-on becomes a thin square-ended sliver, which is what silk\n      // does, instead of collapsing to a resolution-dependent minimum line.\n      float width=max(baseWidth*projection+ribbonWidth*0.060*abs(sin(phase)),pixelSize);\n      float effectiveProjection=width/max(baseWidth,0.001);\n\n      // Ruled-strip coverage: the lateral material coordinate against the local\n      // half width, unioned as square-ended rectangles in each piece's own\n      // frame. Distance-to-centreline instead unions circles, which inflates\n      // the silhouette near width maxima and can never form the sharp crossing\n      // where a twisting ribbon passes edge-on.\n      float signedSide=dot(fromCurve,normal);\n      float lateralOut=abs(signedSide)-width;\n      float surfaceDistance=overshoot>0.0\n        ? -length(vec2(overshoot,max(lateralOut,0.0)))\n        : -lateralOut;\n\n      float physicalSide=clamp(signedSide/effectiveProjection,-baseWidth,baseWidth);\n      float sectionZ=curveDepth*1.65\n        +physicalSide/max(baseWidth,0.001)*sin(phase)*0.16*depth;\n\n      // Coverage is the union of every piece; which piece gets *shaded* is a\n      // separate question, answered by depth.\n      ribbonSurfaceDistance=max(ribbonSurfaceDistance,surfaceDistance);\n      float sectionScore=surfaceDistance>0.0?100.0+sectionZ:surfaceDistance;\n      if(sectionScore>ribbonScore){\n        ribbonScore=sectionScore;\n        ribbonSignedSide=signedSide;\n        ribbonBaseWidth=baseWidth;\n        ribbonEffectiveProjection=effectiveProjection;\n        ribbonFacing=facing;\n        ribbonAlong=along;\n        ribbonPhase=phase;\n        ribbonTangent=tangent;\n        ribbonSceneDepth=sectionZ;\n        ribbonDepthTangent=quadraticBezierD1(z0,z1,z2,u);\n        ribbonFlow=quadraticBezier1(s0,s1,s2,u);\n      }\n    }\n\n    if(ribbonSurfaceDistance>-10.0){\n      // Defocus. The circle of confusion grows with the square of the distance\n      // from the focal plane and is spent widening the coverage transition,\n      // which is a crude model of a lens and an accurate one of what a lens\n      // does to a *silhouette* - and a silhouette is all a ruled strip has.\n      // Opacity is paid down alongside it, because a blurred ribbon spreads its\n      // light over more pixels and cannot stay as dense; without that, defocus\n      // reads as a soft-edged sticker rather than as something out of focus.\n      float confusion=abs(ribbonSceneDepth-focalPlane);\n      float softness=sharpAa+confusion*confusion*0.0062*(0.45+0.85*depth);\n      float mask=smoothstep(-softness,softness,ribbonSurfaceDistance);\n      float width=ribbonBaseWidth*ribbonEffectiveProjection;\n      float halfWidth=max(width,sharpAa);\n      float spread=clamp(halfWidth/max(halfWidth+softness*0.80,0.0001),0.34,1.0);\n\n      // The trim is a fraction of the *current* half width, never a fixed\n      // radius. A fixed band swallows the whole cross-section once a ribbon\n      // turns edge-on, which reads as the material fading out instead of\n      // narrowing. Cloth has a crisp cut edge, not a shaded falloff.\n      float edge=1.0-smoothstep(0.0,min(0.22*halfWidth,softness*1.3),abs(ribbonSurfaceDistance));\n\n      // The width axis is the strip's material across-direction, and twist\n      // rotates it out of the image plane: cosine into the in-plane normal,\n      // sine into depth. This is what makes the shading frame sweep as a twist\n      // passes, so the roll reads as light rolling along the ribbon and not\n      // only as a change of width.\n      vec3 centerTangent=normalize(vec3(ribbonTangent,ribbonDepthTangent*0.32));\n      vec3 widthAxis=normalize(\n        vec3(-ribbonTangent.y,ribbonTangent.x,0.0)*cos(ribbonPhase)\n        +vec3(0.0,0.0,0.88)*sin(ribbonPhase)\n      );\n      vec3 planeNormal=normalize(cross(centerTangent,widthAxis));\n      if(ribbonFacing<0.0)planeNormal=-planeNormal;\n\n      if(mask>0.003){\n        float acrossCoord=clamp(\n          ribbonSignedSide/max(ribbonEffectiveProjection*ribbonBaseWidth,0.0001),-1.0,1.0\n        );\n        float arcPosition=ribbonAlong*ribbonArcLength;\n        // Weave fades out below a few pixels of screen width and again with\n        // defocus. Both are the same judgement: structure finer than the\n        // sampling is noise, and noise on cloth reads as dirt.\n        float weaveFade=smoothstep(2.5,7.0,halfWidth/pixelSize)\n                       *(1.0-smoothstep(sharpAa*1.5,sharpAa*7.0,softness));\n        float flow=smoothstep(0.045,0.38,ribbonFlow);\n        vec3 cloth=shadeSilk(\n          fi,ribbonFacing,edge,planeNormal,centerTangent,\n          ribbonAlong,arcPosition,acrossCoord,ribbonSceneDepth,flow,weaveFade,t\n        );\n\n        // Silk is not opaque. A face-on section is a single thin layer and\n        // passes some of what is behind it; turned edge-on you are looking\n        // through the long axis of the weave and it closes up. Letting seven\n        // saturated ribbons tint each other where they cross is worth far more\n        // than the depth precision it costs, and it is the reason the tangle\n        // now has colour *inside* it rather than only at the surface.\n        float opacity=mix(0.99,0.80,abs(ribbonFacing))*spread;\n        float alpha=mask*opacity;\n\n        // Three layers, insertion-sorted by depth. Two was enough when the\n        // material was opaque and only the nearest mattered; with translucency\n        // the third is visible wherever two ribbons overlap, which at seven\n        // ribbons is most of the interesting parts of the frame.\n        if(ribbonSceneDepth>layerZ0){\n          layerZ2=layerZ1;layerAlpha2=layerAlpha1;layerColour2=layerColour1;\n          layerZ1=layerZ0;layerAlpha1=layerAlpha0;layerColour1=layerColour0;\n          layerZ0=ribbonSceneDepth;layerAlpha0=alpha;layerColour0=cloth;\n        }else if(ribbonSceneDepth>layerZ1){\n          layerZ2=layerZ1;layerAlpha2=layerAlpha1;layerColour2=layerColour1;\n          layerZ1=ribbonSceneDepth;layerAlpha1=alpha;layerColour1=cloth;\n        }else if(ribbonSceneDepth>layerZ2){\n          layerZ2=ribbonSceneDepth;layerAlpha2=alpha;layerColour2=cloth;\n        }\n      }\n    }\n  }\n\n  // Front to back, accumulating premultiplied colour against remaining\n  // transmittance. Order matters and is guaranteed by the sort above.\n  vec3 accumulated=vec3(0.0);\n  float transmittance=1.0;\n  accumulated+=layerColour0*layerAlpha0*transmittance;transmittance*=1.0-layerAlpha0;\n  accumulated+=layerColour1*layerAlpha1*transmittance;transmittance*=1.0-layerAlpha1;\n  accumulated+=layerColour2*layerAlpha2*transmittance;transmittance*=1.0-layerAlpha2;\n  float coverage=1.0-transmittance;\n  if(coverage<=0.0005){gl_FragColor=vec4(0.0);return;}\n  gl_FragColor=vec4(accumulated/coverage,coverage);\n}\n\n// ---------------------------------------------------------------------------\n// GLOW - one separable blur at quarter resolution, doing three jobs.\n// The rgb carries a soft-kneed bright pass, which is the bloom. The alpha\n// carries blurred coverage, which is a far better soft shadow than the\n// three-tap offset it replaces - a real penumbra rather than three ghosts.\n// And the same rgb, sampled at the shadow's offset, is the colour the silk\n// bounces back into the room. Quarter resolution is not a compromise here:\n// the bilinear upsample on the way out is extra smoothing for free, and the\n// blur radius in source pixels is four times what the tap count suggests.\n// If a host ignores the WIDTH/HEIGHT expressions the buffers come back full\n// size and the only consequence is a tighter halo, which is a safe failure.\n// ---------------------------------------------------------------------------\n\nvec4 blurLine(vec2 uv,vec2 direction,bool source){\n  vec2 texel=1.0/RENDERSIZE;\n  vec3 sum=vec3(0.0);\n  float alphaSum=0.0;\n  float weightSum=0.0;\n  for(int i=-6;i<=6;i++){\n    float offset=float(i)*1.15;\n    float weight=exp(-offset*offset/(2.0*3.15*3.15));\n    vec2 at=uv+direction*texel*offset;\n    vec3 tapColour;\n    float tapAlpha;\n    if(source){\n      vec4 tap=IMG_NORM_PIXEL(ribbonLayer,at);\n      vec3 premultiplied=tap.rgb*tap.a;\n      float luminance=dot(premultiplied,vec3(0.30,0.59,0.11));\n      // Soft knee rather than a hard threshold: a hard one makes the glow pop\n      // on and off as a highlight crosses it, which on material this specular\n      // is very visible. The additive floor keeps a faint halo on everything,\n      // which is what gives the dark room its air.\n      tapColour=premultiplied*(smoothstep(0.14,0.78,luminance)*0.95+0.17);\n      tapAlpha=tap.a;\n    }else{\n      vec4 tap=IMG_NORM_PIXEL(glowA,at);\n      tapColour=tap.rgb;\n      tapAlpha=tap.a;\n    }\n    sum+=tapColour*weight;\n    alphaSum+=tapAlpha*weight;\n    weightSum+=weight;\n  }\n  return vec4(sum/weightSum,alphaSum/weightSum);\n}\n\n// ---------------------------------------------------------------------------\n// COMPOSE\n// ---------------------------------------------------------------------------\n\n// The ACES fit. The old exponential rolloff desaturated everything on the way\n// up, so a saturated ribbon in a specular flare went pale before it went\n// bright. This one keeps chroma into the highlights, which matters when the\n// brightest thing in frame is meant to be coloured light coming through dye.\nvec3 filmic(vec3 x){\n  vec3 numerator=x*(2.51*x+0.03);\n  vec3 denominator=x*(2.43*x+0.59)+0.14;\n  return clamp(numerator/denominator,0.0,1.0);\n}\n\n// Dust in the light. Three sparse drifting layers, one cell lookup each, most\n// cells empty. It costs almost nothing and it does more for the sense of a\n// lit volume than anything else in this pass - the void stops being a colour\n// and starts being air.\nvec3 dustMotes(vec2 p,float t){\n  vec3 total=vec3(0.0);\n  for(int layer=0;layer<3;layer++){\n    float fl=float(layer);\n    float scale=11.0+fl*9.0;\n    vec2 q=p*scale+vec2(t*(0.05+fl*0.03),t*(0.021+fl*0.014));\n    vec2 id=floor(q);\n    vec2 f=fract(q)-0.5;\n    float h=hash21(id+fl*37.0);\n    if(h<0.80)continue;\n    vec2 centre=(vec2(hash11(h*131.0),hash11(h*277.0))-0.5)*0.60;\n    float d=length(f-centre);\n    float twinkle=0.35+0.65*pow(0.5+0.5*sin(t*(0.9+h*2.3)+h*41.0),2.0);\n    float core=smoothstep(0.055,0.0,d)*twinkle/(1.0+fl*1.3);\n    total+=mix(vec3(0.55,0.68,1.0),vec3(1.0,0.72,0.45),hash11(h*59.0))*core;\n  }\n  return total*0.16;\n}\n\nvoid compose(vec2 uv,float t){\n  vec4 layer=IMG_NORM_PIXEL(ribbonLayer,uv);\n  vec4 glow=IMG_NORM_PIXEL(glowB,uv);\n  // The shadow is offset opposite the key, which puts it down and to the\n  // right. One tap into an already-wide blur; the penumbra comes from the\n  // blur, not from the sampling.\n  vec4 occluder=IMG_NORM_PIXEL(glowB,uv+vec2(0.016,-0.021));\n\n  float aspect=RENDERSIZE.x/RENDERSIZE.y;\n  vec2 centred=vec2((uv.x-0.5)*aspect,uv.y-0.5);\n\n  // The room. A vertical gradient, a very large slow cloud so the void is not\n  // perfectly flat, then a pool of cool light where the key is and a warm\n  // counter-glow low and right where the rim is. Painting the two lights into\n  // the background is what stops the ribbons looking pasted onto black.\n  vec3 room=mix(vec3(0.004,0.006,0.018),vec3(0.033,0.015,0.050),smoothstep(-0.12,1.06,uv.y));\n  float cloud=sin(uv.x*2.1+t*0.05)*sin(uv.y*1.7-t*0.037)*0.5+0.5;\n  room+=vec3(0.014,0.008,0.028)*cloud;\n  room+=vec3(0.034,0.040,0.078)*pow(max(0.0,1.0-length(centred-vec2(-0.20,0.20))*1.20),4.0);\n  room+=vec3(0.058,0.028,0.012)*pow(max(0.0,1.0-length(centred-vec2(0.32,-0.28))*1.45),5.0);\n  room+=dustMotes(centred,t);\n\n  // Occlusion, then bounce. The silk both blocks the room light and throws its\n  // own colour back into it, and doing the second is what makes the shadow feel\n  // like it belongs to a coloured object rather than being a grey smudge.\n  float occlusion=occluder.a*(1.0-layer.a);\n  room*=1.0-occlusion*0.78;\n  room+=occluder.rgb*occlusion*0.30;\n\n  vec3 colour=mix(room,layer.rgb,layer.a);\n  colour+=glow.rgb*(0.22+1.05*bloom);\n\n  // Vignette before the tonemap, so it darkens light rather than darkening\n  // pixels - the falloff stays in the highlights instead of crushing the\n  // corners flat.\n  colour*=1.0-smoothstep(0.40,1.02,length(centred))*0.58;\n\n  colour=filmic(colour*1.08);\n\n  // Split tone: shadows toward blue, highlights toward amber. A very small\n  // amount of it, doing the job a print does - separating the two lights in\n  // the mix so the eye reads depth from colour temperature as well as value.\n  float luminance=dot(colour,vec3(0.30,0.59,0.11));\n  colour*=mix(vec3(0.88,0.94,1.12),vec3(1.06,1.00,0.93),smoothstep(0.04,0.72,luminance));\n  colour=pow(max(colour,0.0),vec3(0.95));\n\n  // Grain, weighted into the shadows where film actually has it, and a\n  // sub-LSB dither on top. The dither is not decorative: a gradient this dark\n  // and this smooth bands severely on an 8-bit output, and the banding is the\n  // one artefact that instantly gives away that the background is generated.\n  float grain=hash21(gl_FragCoord.xy+floor(TIME*24.0)*vec2(7.13,3.71))-0.5;\n  colour+=grain*(0.010+0.032*(1.0-luminance));\n  colour+=(hash21(gl_FragCoord.xy*1.37+11.0)-0.5)*(1.0/255.0);\n\n  gl_FragColor=vec4(clamp(colour,0.0,1.0),1.0);\n}\n\nvoid main(){\n  vec2 uv=isf_FragNormCoord;\n  float t=TIME*speed;\n  if(PASSINDEX==0){updateSpines(uv,t);return;}\n  if(PASSINDEX==1){updateSpineDepth(uv,t);return;}\n  if(PASSINDEX==2){updateTwist(uv,t);return;}\n  if(PASSINDEX==3){renderRibbons(uv,t);return;}\n  if(PASSINDEX==4){gl_FragColor=blurLine(uv,vec2(1.0,0.0),true);return;}\n  if(PASSINDEX==5){gl_FragColor=blurLine(uv,vec2(0.0,1.0),false);return;}\n  compose(uv,t);\n}\n",
    "generator": {
      "model": "Opus 5",
      "provider": null,
      "prompt": null,
      "params": null
    }
  }
}