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