| 1 | +added | // The surface of fluted glass: identical flutes side by side, each a strip |
| 2 | +added | // of glass that bends what is under it. For a point, this works out how |
| 3 | +added | // steep the glass is and how far it moves the image, in one call, so light |
| 4 | +added | // read from the slope lands where the image bends. FlutedGlass bends and |
| 5 | +added | // lights the scene with it, and Mode 2 users call it on their own coordinates. |
| 6 | +added | import type { ShaderNodeObject } from 'three/tsl'; |
| 7 | +added | import { |
| 8 | +added | abs, |
| 9 | +added | clamp, |
| 10 | +added | cos, |
| 11 | +added | dot, |
| 12 | +added | float, |
| 13 | +added | fract, |
| 14 | +added | fwidth, |
| 15 | +added | max, |
| 16 | +added | mix, |
| 17 | +added | pow, |
| 18 | +added | select, |
| 19 | +added | sin, |
| 20 | +added | smoothstep, |
| 21 | +added | vec2, |
| 22 | +added | } from 'three/tsl'; |
| 23 | +added | import type { Node } from 'three/webgpu'; |
| 24 | +added | |
| 25 | +added | import type { TSLNode } from '../color-ramp/color-ramp.js'; |
| 26 | +added | |
| 27 | +added | export interface FluteSurfaceOptions { |
| 28 | +added | /** |
| 29 | +added | * The width of one flute, in the units of `position`. Values near 0 clamp |
| 30 | +added | * to a tiny width rather than dividing by zero. Default 40. |
| 31 | +added | */ |
| 32 | +added | fluteWidth?: TSLNode | number; |
| 33 | +added | /** |
| 34 | +added | * The direction the flutes run, in degrees counterclockwise from the |
| 35 | +added | * position's +x axis with y up: 0 runs them left to right and 90 runs them |
| 36 | +added | * bottom to top, the degrees LinearGradient's angle uses. Default 90, |
| 37 | +added | * vertical flutes. |
| 38 | +added | */ |
| 39 | +added | angle?: TSLNode | number; |
| 40 | +added | /** |
| 41 | +added | * How far the glass moves the image, in half flute widths per unit of |
| 42 | +added | * slope. 0 leaves it in place. At 1, a groove shows the image half a flute |
| 43 | +added | * width away, so each flute shows a copy of twice its own width, squeezed. |
| 44 | +added | * Default 1. |
| 45 | +added | */ |
| 46 | +added | refraction?: TSLNode | number; |
| 47 | +added | /** |
| 48 | +added | * The shape of each flute seen from its end, from 0 to 1. 0 is a pointed |
| 49 | +added | * prism, two flat faces meeting at a ridge. 0.5 is a round lens. 1 is a |
| 50 | +added | * flat top with steep walls. Values between give the shapes between, and |
| 51 | +added | * values outside 0 to 1 clamp. Default 0.5. |
| 52 | +added | */ |
| 53 | +added | crossSection?: TSLNode | number; |
| 54 | +added | } |
| 55 | +added | |
| 56 | +added | export interface FluteSurfaceResult { |
| 57 | +added | /** |
| 58 | +added | * How far to move the point before you sample the image under the glass, |
| 59 | +added | * as a vector in the units and frame of `position`. Add it to `position`. |
| 60 | +added | */ |
| 61 | +added | bend: ShaderNodeObject<Node>; |
| 62 | +added | /** |
| 63 | +added | * How steep the glass is, as a vector in the frame of `position`. It |
| 64 | +added | * points across the flute, away from the flute's center, and its length |
| 65 | +added | * runs from 0 at the center to 1 at a groove. |
| 66 | +added | */ |
| 67 | +added | slope: ShaderNodeObject<Node>; |
| 68 | +added | /** |
| 69 | +added | * How far the point sits from its flute's center, from 0 at the center to |
| 70 | +added | * 1 at a groove, whatever the cross-section. Unlike the slope it skips the |
| 71 | +added | * groove smoothing, so it reaches 1 at a groove from both sides with no |
| 72 | +added | * dip. Shading meant to be darkest on the groove reads this. |
| 73 | +added | */ |
| 74 | +added | fromCenter: ShaderNodeObject<Node>; |
| 75 | +added | /** |
| 76 | +added | * How steep the glass is, from 0 where it is flat to 1 at a groove. It is |
| 77 | +added | * the slope's length without the groove smoothing, so like `fromCenter` it |
| 78 | +added | * reaches 1 at a groove from both sides, where the slope dips to 0. |
| 79 | +added | * Shading meant to follow the shape of the glass reads this. |
| 80 | +added | */ |
| 81 | +added | steepness: ShaderNodeObject<Node>; |
| 82 | +added | } |
| 83 | +added | |
| 84 | +added | // --------------------------------------------- |
| 85 | +added | // Feel constants (tuned by eye at the build's visual gates) |
| 86 | +added | // --------------------------------------------- |
| 87 | +added | // The slope's exponent at each end of the cross-section dial. A cross-section |
| 88 | +added | // of 0.5 always has an exponent of 1. Lower PRISM_EXPONENT sharpens the |
| 89 | +added | // prism's faces toward perfectly flat, and higher FLAT_EXPONENT flattens the |
| 90 | +added | // top and steepens the walls. |
| 91 | +added | const PRISM_EXPONENT = 1 / 16; |
| 92 | +added | const FLAT_EXPONENT = 16; |
| 93 | +added | // How many screen pixels each groove and the prism's ridge smooth over on |
| 94 | +added | // either side. 0 brings back the stair steps; above about 2 the grooves read |
| 95 | +added | // as soft blurs rather than sharp breaks. |
| 96 | +added | const GROOVE_SMOOTHING = 1; |
| 97 | +added | |
| 98 | +added | /** |
| 99 | +added | * The bend and slope of fluted glass at `position`. A flute's center sits on |
| 100 | +added | * the position's origin, so center the position where the middle flute |
| 101 | +added | * should go. Call it from a fragment shader: it smooths each groove over the |
| 102 | +added | * screen-space rate of change of `position`, which only a fragment shader |
| 103 | +added | * can measure. |
| 104 | +added | * |
| 105 | +added | * @param position — Vec2 TSL node in any units with y up, such as `uv()` |
| 106 | +added | * scaled to pixels. `fluteWidth` and the returned bend share its units. |
| 107 | +added | */ |
| 108 | +added | export function fluteSurface( |
| 109 | +added | position: TSLNode, |
| 110 | +added | options: FluteSurfaceOptions = {}, |
| 111 | +added | ): FluteSurfaceResult { |
| 112 | +added | const fluteWidth = max(float(options.fluteWidth ?? 40), 1e-4); |
| 113 | +added | const angle = float(options.angle ?? 90); |
| 114 | +added | const refraction = float(options.refraction ?? 1); |
| 115 | +added | const crossSection = clamp(float(options.crossSection ?? 0.5), 0, 1); |
| 116 | +added | |
| 117 | +added | // --------------------------------------------- |
| 118 | +added | // The direction across the flutes |
| 119 | +added | // --------------------------------------------- |
| 120 | +added | // The flutes run along (cos, sin) of the angle. Across is that direction |
| 121 | +added | // turned a quarter turn clockwise, (sin, -cos), so at the default 90 |
| 122 | +added | // degrees it is (1, 0): across runs left to right over vertical flutes. |
| 123 | +added | const angleRadians = angle.mul(Math.PI / 180); |
| 124 | +added | const across = vec2(sin(angleRadians), cos(angleRadians).negate()); |
| 125 | +added | |
| 126 | +added | // --------------------------------------------- |
| 127 | +added | // Repeating space: which part of which flute |
| 128 | +added | // --------------------------------------------- |
| 129 | +added | // dot() projects the position onto the across direction: its signed |
| 130 | +added | // distance across the flutes, in the position's units. Dividing by the |
| 131 | +added | // width counts that distance in flutes. The +0.5 below centers a flute on |
| 132 | +added | // every whole number, so 2.25 lies a quarter flute right of the center of |
| 133 | +added | // flute 2, halfway to its groove. |
| 134 | +added | // |
| 135 | +added | // fract() keeps only the part after the decimal point, which is how a |
| 136 | +added | // shader repeats space: every flute gets the same 0-to-1 ramp, whatever |
| 137 | +added | // its number. The +0.5 shifts the ramp so its middle, not its start, lands |
| 138 | +added | // on the origin, which puts a flute's center there. At each groove the |
| 139 | +added | // ramp jumps from 1 back to 0, and that jump is what breaks the image into |
| 140 | +added | // strips. |
| 141 | +added | const flutePhase = dot(position, across).div(fluteWidth); |
| 142 | +added | const ramp = fract(flutePhase.add(0.5)); |
| 143 | +added | |
| 144 | +added | // The distance from the center, before any smoothing. ramp*2-1 runs from |
| 145 | +added | // -1 at one groove to 1 at the other, and abs() folds it to run 0 to 1 |
| 146 | +added | // from the center out on both sides. Where the ramp jumps from 1 to 0 at a |
| 147 | +added | // groove, this goes from 1 to 1, so it has no jump to smooth. |
| 148 | +added | const fromCenter = abs(ramp.mul(2).sub(1)); |
| 149 | +added | |
| 150 | +added | // --------------------------------------------- |
| 151 | +added | // Clean grooves: antialiasing from the screen-space rate of change |
| 152 | +added | // --------------------------------------------- |
| 153 | +added | // The scene has no MSAA, the GPU's own edge smoothing, so a jump in the |
| 154 | +added | // ramp lands on whole pixels and a slanted groove draws a staircase. The |
| 155 | +added | // fix is to replace the jump with a slope exactly one pixel wide, and to |
| 156 | +added | // know how wide a pixel is in flutes, the shader asks the GPU. |
| 157 | +added | // |
| 158 | +added | // The GPU shades pixels in 2x2 blocks, so each pixel can read how much a |
| 159 | +added | // value changes to its neighbor. fwidth() adds that change across and |
| 160 | +added | // down, so fwidth(flutePhase) is how many flutes one pixel spans: 0.025 for |
| 161 | +added | // a 40-pixel flute, and 0.125 for an 8-pixel one. It is taken of the phase |
| 162 | +added | // before fract(), which has no jump in it to throw the measure off. The max |
| 163 | +added | // keeps the band from shrinking to nothing, which would divide by zero in |
| 164 | +added | // smoothstep() when a flute is very wide. |
| 165 | +added | const rampPerPixel = max(fwidth(flutePhase).mul(GROOVE_SMOOTHING), 1e-5); |
| 166 | +added | |
| 167 | +added | // Paper's smoothFract. distanceToGroove is 0 at a groove and falls to -0.5 |
| 168 | +added | // at the flute's center. smoothstep() turns the last pixel before a groove |
| 169 | +added | // into a blend that climbs from 0 to 0.5, so mix() eases the ramp toward |
| 170 | +added | // its mirror image, 1 - ramp, and the two meet at 0.5. Both sides of a |
| 171 | +added | // groove now arrive at 0.5, the flute's center, where the glass is flat, so |
| 172 | +added | // the groove pixel shows the image right at the groove instead of either |
| 173 | +added | // flute's bent copy. Away from the grooves the blend is 0 and the ramp is |
| 174 | +added | // unchanged. |
| 175 | +added | const distanceToGroove = abs(ramp.sub(0.5)).sub(0.5); |
| 176 | +added | const grooveBlend = smoothstep(rampPerPixel.negate(), rampPerPixel, distanceToGroove); |
| 177 | +added | const smoothRamp = mix(ramp, ramp.oneMinus(), grooveBlend); |
| 178 | +added | |
| 179 | +added | // *2-1 stretches the ramp to run from -1 at one groove, through 0 at the |
| 180 | +added | // center, to 1 at the other groove. |
| 181 | +added | const fluteOffset = smoothRamp.mul(2).sub(1); |
| 182 | +added | |
| 183 | +added | // --------------------------------------------- |
| 184 | +added | // The cross-section: picking an exponent from the dial |
| 185 | +added | // --------------------------------------------- |
| 186 | +added | // The dial picks the exponent that shapes the slope below, on a doubling |
| 187 | +added | // scale. dialFromRound runs from -1 at a prism, through 0 at round, to 1 at |
| 188 | +added | // flat. The prism half raises PRISM_EXPONENT to its distance from round, |
| 189 | +added | // and the flat half raises FLAT_EXPONENT the same way. At round both give |
| 190 | +added | // 1, since anything to the power 0 is 1, so 0.5 is exactly round whatever |
| 191 | +added | // the ends are. Each equal step of the dial multiplies the exponent by the |
| 192 | +added | // same factor, so a sweep morphs the shape evenly. With ends of 1/16 and |
| 193 | +added | // 16, the exponent doubles every eighth of the dial. |
| 194 | +added | const dialFromRound = crossSection.mul(2).sub(1); |
| 195 | +added | const exponentEnd = select( |
| 196 | +added | dialFromRound.lessThan(0), |
| 197 | +added | float(PRISM_EXPONENT), |
| 198 | +added | float(FLAT_EXPONENT), |
| 199 | +added | ); |
| 200 | +added | const exponent = pow(exponentEnd, abs(dialFromRound)); |
| 201 | +added | |
| 202 | +added | // --------------------------------------------- |
| 203 | +added | // The slope: shaping a curve with an exponent |
| 204 | +added | // --------------------------------------------- |
| 205 | +added | // The slope's size is the distance from the flute's center, |offset|, |
| 206 | +added | // raised to the exponent. Because that distance runs from 0 to 1, a power |
| 207 | +added | // bends the curve between without moving its ends: the center stays flat |
| 208 | +added | // and the groove stays steepest. |
| 209 | +added | // |
| 210 | +added | // - An exponent of 1 leaves a straight line, so the slope grows in step |
| 211 | +added | // with the distance: a round lens. Strictly that draws a parabola, a |
| 212 | +added | // shallow stand-in for a circular arc, whose slope would shoot up near |
| 213 | +added | // the grooves. |
| 214 | +added | // - Below 1 the power lifts small values toward 1. x^(1/16) is already |
| 215 | +added | // 0.65 at x = 0.001 and 0.96 at 0.5, so the slope jumps to nearly full |
| 216 | +added | // just off the center and stays there: two flat faces meeting at a |
| 217 | +added | // ridge, a prism. |
| 218 | +added | // - Above 1 the power presses small values toward 0. x^16 is 0.00002 at |
| 219 | +added | // 0.5 and reaches 0.5 only at 0.96, so the glass stays flat across the |
| 220 | +added | // top and turns steep right at each groove. |
| 221 | +added | // |
| 222 | +added | // pow() needs a base of 0 or more, so the power is taken of fromCenter, |
| 223 | +added | // the distance without the groove smoothing, and the offset carries the |
| 224 | +added | // sign: offset * distance^(exponent - 1). Away from the grooves the offset |
| 225 | +added | // is ±distance, so that works out to ±distance^exponent, the curve above. |
| 226 | +added | // On a groove's own pixel the distance is about 1, and 1 to any power is |
| 227 | +added | // 1, so the slope follows the smoothed offset through 0 over the same |
| 228 | +added | // pixel whatever the exponent. Raising the smoothed offset itself would |
| 229 | +added | // undo the smoothing on a prism: x^(1/16) is still 0.82 at x = 0.045, so |
| 230 | +added | // the slope would stay near full until a sliver from the groove, and the |
| 231 | +added | // grooves would staircase again. The max() keeps the base off 0 at the |
| 232 | +added | // center, where a prism's negative power, exponent - 1, would shoot to |
| 233 | +added | // infinity. Pointing the result along `across` turns it into a vector, so |
| 234 | +added | // on either side of the center the slope points toward the nearer groove. |
| 235 | +added | const shapedSlope = fluteOffset.mul(pow(max(fromCenter, 1e-5), exponent.sub(1))); |
| 236 | +added | |
| 237 | +added | // A prism's ridge jumps too. Just off the center x^(1/16) leaps from 0 to |
| 238 | +added | // about 0.65 inside a sliver much thinner than a pixel, so the two faces |
| 239 | +added | // meet in a staircase of their own. The same pixel width, doubled because |
| 240 | +added | // the offset runs twice as fast as the ramp, fades the slope in over the |
| 241 | +added | // first pixel either side of the center. On a round or flat flute the |
| 242 | +added | // slope there is already close to 0, so the fade changes nothing you can |
| 243 | +added | // see. It also softens the groove pixel, where the smoothed offset passes |
| 244 | +added | // through 0 on its way between flutes. |
| 245 | +added | const ridgeBlend = smoothstep(0, rampPerPixel.mul(2), abs(fluteOffset)); |
| 246 | +added | const slopeAmount = shapedSlope.mul(ridgeBlend); |
| 247 | +added | const slope = across.mul(slopeAmount); |
| 248 | +added | |
| 249 | +added | // The steepness takes the same power of fromCenter rather than of the |
| 250 | +added | // smoothed offset. Away from the grooves the two are equal, so it is the |
| 251 | +added | // slope's length there. On a groove's own pixel, where the smoothing pulls |
| 252 | +added | // the slope through 0, it keeps climbing toward 1. The ridge fade reads |