├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── app ├── assets │ └── font │ │ ├── ProximaNovaBold.woff │ │ └── ProximaNovaRegular.woff ├── iframe.html ├── index.html └── vendor │ ├── three.js │ └── three.min.js ├── blogpost ├── comp.jpg ├── cylinder.png ├── demo1.jpg ├── demo2.jpg ├── final.jpg ├── frenet1.jpg ├── frenet2.jpg ├── loop1.gif ├── loop2.gif ├── p1.jpg ├── p2.jpg ├── p3.jpg ├── parametric.png ├── post.md ├── shader.png └── xmas.jpg ├── index.js ├── lib ├── components │ └── createTubes.js ├── createApp.js ├── geom │ └── createTubeGeometry.js ├── shaders │ ├── tube.frag │ └── tube.vert └── util │ ├── isMobile.js │ ├── query.js │ └── random.js └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: [ "es2015" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # parametric-curves 2 | 3 | Fast animated curve rendering on the GPU with parametric equations. 4 | 5 | 6 | 7 | The vertex shader supports local Frenet-Serret frames (approximations) and more robust Parallel Transport frames (at expense of performance). 8 | 9 | #### Live Demo 10 | 11 | http://parametric-curves.surge.sh/ 12 | 13 | #### Blog Post 14 | 15 | https://mattdesl.svbtle.com/shaping-curves-with-parametric-equations 16 | 17 | #### Running Locally 18 | 19 | The demo can be run locally with: 20 | 21 | ```sh 22 | git clone https://github.com/mattdesl/parametric-curves.git 23 | cd parametric-curves 24 | npm install 25 | npm run start 26 | ``` 27 | 28 | Then open `localhost:9966`. 29 | 30 | ## License 31 | 32 | MIT, see [LICENSE.md](http://github.com/mattdesl/parametric-curves/blob/master/LICENSE.md) for details. 33 | -------------------------------------------------------------------------------- /app/assets/font/ProximaNovaBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/app/assets/font/ProximaNovaBold.woff -------------------------------------------------------------------------------- /app/assets/font/ProximaNovaRegular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/app/assets/font/ProximaNovaRegular.woff -------------------------------------------------------------------------------- /app/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | iFrame test 7 | 24 | 25 | 26 |
(more content)
27 | 28 | 29 |
(more content)
30 | 34 | 35 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | parametric-curves 7 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /blogpost/comp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/comp.jpg -------------------------------------------------------------------------------- /blogpost/cylinder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/cylinder.png -------------------------------------------------------------------------------- /blogpost/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/demo1.jpg -------------------------------------------------------------------------------- /blogpost/demo2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/demo2.jpg -------------------------------------------------------------------------------- /blogpost/final.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/final.jpg -------------------------------------------------------------------------------- /blogpost/frenet1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/frenet1.jpg -------------------------------------------------------------------------------- /blogpost/frenet2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/frenet2.jpg -------------------------------------------------------------------------------- /blogpost/loop1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/loop1.gif -------------------------------------------------------------------------------- /blogpost/loop2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/loop2.gif -------------------------------------------------------------------------------- /blogpost/p1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/p1.jpg -------------------------------------------------------------------------------- /blogpost/p2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/p2.jpg -------------------------------------------------------------------------------- /blogpost/p3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/p3.jpg -------------------------------------------------------------------------------- /blogpost/parametric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/parametric.png -------------------------------------------------------------------------------- /blogpost/post.md: -------------------------------------------------------------------------------- 1 | # Shaping Curves with Parametric Equations 2 | 3 | ![intro](https://github.com/mattdesl/parametric-curves/blob/master/blogpost/demo2.jpg?raw=true) 4 | 5 | This post explores a technique to render volumetric curves on the GPU — ideal for shapes like ribbons, tubes and rope. The curves are defined by a parametric equation in the vertex shader, allowing us to animate hundreds and even thousands of curves with minimal overhead. 6 | 7 | Parametric curves aren't a novel idea in WebGL; ThreeJS already supports something called ExtrudeGeometry. You can read about some of its implementation details [here](http://www.lab4games.net/zz85/blog/2012/04/24/spline-extrusions-tubes-and-knots-of-sorts/). This class can be used to extrude a 3D curve or path into a volumetric line, like a 3D tube. However, since the code runs on the CPU and generates a new geometry, it isn't well suited for animating the curve every frame, let alone several hundred curves. 8 | 9 | Instead, let's see what we can accomplish with just a vertex shader. The technique presented here has various downsides and isn't very robust, but it can look great in certain cases and tends to be fast to compute. At the end of this post, we'll end up with something like the WebGL scene below — use your mouse on desktop or tap on mobile to interact with it. 10 | 11 | 12 | 13 | I used this technique for the swirling 3D lines in my [Christmas Experiment](https://christmasexperiments.com/2016/19/sugar/) this year. The same experiment uses parametric equations for the bouncing surface — so the concepts here will carry over nicely to other areas. 14 | 15 | ![xmas](https://github.com/mattdesl/parametric-curves/blob/master/blogpost/xmas.jpg?raw=true) 16 | 17 | I'm also using volumetric lines for a "neon tube" effect in an upcoming demo; you can see some screenshots here: 18 | 19 | ![volume](https://github.com/mattdesl/parametric-curves/blob/master/blogpost/demo1.jpg?raw=true) 20 | 21 | # Source Code 22 | 23 | You can follow along with the source code for the interactive demo here: 24 | 25 | https://github.com/mattdesl/parametric-curves/ 26 | 27 | # Building the Tube Geometry 28 | 29 | For this to work, we're going to re-purpose a `THREE.CylinderGeometry` so that our UVs, end caps and faces all line up like a regular cylinder geometry. If it goes to plan, we should be able to render a simple cylinder with our volumetric curve code. 30 | 31 | ![cylinder](https://github.com/mattdesl/parametric-curves/blob/master/blogpost/cylinder.png?raw=true) 32 | 33 | However, our geometry won't work with the built-in ThreeJS shaders because it's composed of the following custom vertex attributes: 34 | 35 | - `position` a *one dimensional* float along the X axis in the range -0.5 to 0.5, telling us the distance of the vertex along the curve 36 | - `angle` a float in radians in the range -π to π, telling us how far *around* the tube this vertex is 37 | 38 | When building the geometry, we also have to decide how much to subdivide our curve (i.e. length segments) and how many sides our tube should have (i.e. radial segments). 39 | 40 | Some pseudocode: 41 | 42 | ```js 43 | const temp = new THREE.Vector2(); 44 | 45 | const baseGeometry = new THREE.CylinderGeometry( 46 | 1, 1, 1, // radius and length 47 | numSides, 48 | subdivisions 49 | ); 50 | 51 | // will be used as vertex attributes 52 | const angles = []; 53 | const positions = []; 54 | 55 | // get vertex attributes 56 | for each face in baseGeometry: 57 | for each vertex in face: 58 | // normalize the 2D position 59 | temp.set(vertex.y, vertex.z).normalize(); 60 | 61 | // find the radial angle around the tube 62 | const angle = Math.atan2(temp.y, temp.x); 63 | angles.push(angle); 64 | 65 | // copy the X position 66 | positions.push(vertex.x); 67 | ``` 68 | 69 | See [createTubeGeometry.js](https://github.com/mattdesl/parametric-curves/blob/master/lib/geom/createTubeGeometry.js) for the final source. The full function also copies the `uv` attribute so that we can texture the tube if desired (e.g. adding normal mapping). We can refine the geometry later with the `subdivisions` and `numSides` parameters. 70 | 71 | With this utility function, we can create a new ThreeJS geometry: 72 | 73 | ```js 74 | const createTubeGeometry = require('../util/createTubeGeometry'); 75 | 76 | // tweak these to your liking 77 | const numSides = 8; 78 | const subdivisions = 50; 79 | 80 | // create the ThreeJS Geometry 81 | const geometry = createTubeGeometry(numSides, subdivisions); 82 | ``` 83 | 84 | # Setting up the Shader 85 | 86 | The next step is to create a material that encapsulates our snazzy vertex shader. We'll be using [glslify](https://github.com/stackgl/glslify) here to pre-process our shaders and make our lives a bit easier. 87 | 88 | ```js 89 | const glslify = require('glslify'); 90 | 91 | const vert = glslify(__dirname + '/../shaders/tube.vert'); 92 | const frag = glslify(__dirname + '/../shaders/tube.frag'); 93 | const material = new THREE.RawShaderMaterial({ 94 | vertexShader: vert, 95 | fragmentShader: frag, 96 | side: THREE.FrontSide, 97 | extensions: { 98 | deriviatives: true 99 | }, 100 | defines: { 101 | lengthSegments: subdivisions.toFixed(1), 102 | FLAT_SHADED: false 103 | }, 104 | uniforms: { 105 | thickness: { type: 'f', value: 1 }, 106 | time: { type: 'f', value: 0 }, 107 | radialSegments: { type: 'f', value: numSides } 108 | } 109 | }); 110 | ``` 111 | 112 | Notice the `extensions` and `defines` fields; these will later be used for flat normals. `FLAT_SHADED` should be false if you plan to have a smooth tube-like geometry, or set to true if you are looking for something like a triangular prism or twisted rectangle. 113 | 114 | The shader will also need to know the `subdivisions` we used in `createTubeGeometry`, which is passed as a `#define` constant so we can use it in loop expressions. 115 | 116 | Before we move onto the GLSL, we already have all the features we need to construct a 3D object. We can add it to our scene like so: 117 | 118 | ```js 119 | const mesh = new THREE.Mesh(geometry, material); 120 | mesh.frustumCulled = false; 121 | scene.add(mesh); 122 | ``` 123 | 124 | The only gotcha is that we should disable frustum culling, since our geometry only contains a 1-dimensional `position` attribute which causes issues with ThreeJS's built-in frustum culling. 125 | 126 | # The Vertex Shader 127 | 128 | There are two key functions: the parametric function, and solving the Frenet-Serret frame. 129 | 130 | You can follow along with the complete shader here: 131 | [shaders/tube.vert](https://github.com/mattdesl/parametric-curves/blob/master/lib/shaders/tube.vert) 132 | 133 | ## The Parametric Function 134 | 135 | The parametric function is the most important one, as it allows us to manipulate the design and shape of our curves. Later, we'll explore how we can create some more interesting functions, but for now we want to just build a tube that undulates along the Y axis like a wave. 136 | 137 | 138 | 139 | > **Tip:** You can enter parametric equations into Google to see how they look! 140 | 141 | The input to this function will be *t*, the arc length of the curve normalized to `[0.0 .. 1.0]` range, where `0.0` is the start point of the curve and `1.0` is the end point. However, many equations will also work with inputs below zero and above one, which might be useful if you're altering the *t* parameter before computing the curve. 142 | 143 | The function will return the 3D position of the curve at *t* distance along it, in world units. 144 | 145 | For the *x* axis, we can use `t * 2.0 - 1.0` to get a value from -1.0 (start cap) to 1.0 (end cap). For the *y* axis, we will use `sin(t + time)` which will make the tube appear to glide slowly up and down. 146 | 147 | ```glsl 148 | vec3 sample (float t) { 149 | float x = t * 2.0 - 1.0; 150 | float y = sin(t + time); 151 | return vec3(x, y, 0.0); 152 | } 153 | ``` 154 | 155 | Using this as our parametric equation will give us the following animated curve: 156 | 157 | 158 | 159 | ## Solving the Frenet-Serret Frame 160 | 161 | Once we have a `sample(t)` function, we can use it to construct the normals and position for the tube geometry at each vertex. 162 | 163 | By sampling the current and next point in the curve, we can find the **T**angent, **N**ormal and **B**inormal, also called the [Frenet-Serret Frame or TNB Frame](https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas). 164 | 165 | With the computed frame, we can extrude away from the center line of the curve using the `angle` attribute we stored earlier. We multiply the extrusion by `volume`, a 2D vector which acts as the radius (or thickness) of our tube. Since it's 2D, we could "pinch" the tube to look more like a flat or oval shape. 166 | 167 | ```glsl 168 | void createTube (float t, vec2 volume, out vec3 pos, out vec3 normal) { 169 | // find next sample along curve 170 | float nextT = t + (1.0 / lengthSegments); 171 | 172 | // sample the curve in two places 173 | vec3 cur = sample(t); 174 | vec3 next = sample(nextT); 175 | 176 | // compute the Frenet-Serret frame 177 | vec3 T = normalize(next - cur); 178 | vec3 B = normalize(cross(T, next + cur)); 179 | vec3 N = -normalize(cross(B, T)); 180 | 181 | // extrude outward to create a tube 182 | float tubeAngle = angle; 183 | float circX = cos(tubeAngle); 184 | float circY = sin(tubeAngle); 185 | 186 | // compute position and normal 187 | normal.xyz = normalize(B * circX + N * circY); 188 | pos.xyz = cur + B * volume.x * circX + N * volume.y * circY; 189 | } 190 | ``` 191 | 192 | # The Fragment Shader 193 | 194 | The fragment shader is fairly basic: it decides whether to use the smooth normal we computed above, or whether to approximate a flat normal using [glsl-face-normal](https://github.com/stackgl/glsl-face-normal). 195 | 196 | The "shading" is just rendering the Y-normal in a 0 to 1 range to give the tube some depth. 197 | 198 | ```glsl 199 | #extension GL_OES_standard_derivatives : enable 200 | precision highp float; 201 | 202 | varying vec3 vNormal; 203 | varying vec2 vUv; 204 | varying vec3 vViewPosition; 205 | 206 | #pragma glslify: faceNormal = require('glsl-face-normal'); 207 | 208 | void main () { 209 | vec3 normal = vNormal; 210 | #ifdef FLAT_SHADED 211 | normal = faceNormal(vViewPosition); 212 | #endif 213 | 214 | float diffuse = normal.y * 0.5 + 0.5; 215 | gl_FragColor = vec4(vec3(diffuse), 1.0); 216 | } 217 | ``` 218 | 219 | With all that in place, we get a shaded tube that can fly around in 3D space. 220 | 221 | 222 | 223 | # Designing with Math 224 | 225 | Ok! Let's kick it up a notch by changing `sample(t)`, our parametric equation. 226 | 227 | We can start with a circle, where *t* is an angle from 0 to 2π. 228 | 229 | ```glsl 230 | vec3 sample (float t) { 231 | float angle = t * 2.0 * PI; 232 | vec2 rot = vec2(cos(angle), sin(angle)); 233 | return vec3(rot, 0.0); 234 | } 235 | ``` 236 | 237 | 238 | 239 | --- 240 | 241 | If we give some depth to the *z* parameter, we can create a corkscrew. 242 | 243 | ```glsl 244 | vec3 sample (float t) { 245 | float angle = t * 2.0 * PI; 246 | vec2 rot = vec2(cos(angle), sin(angle)); 247 | float z = t * 2.0 - 1.0; 248 | return vec3(rot, z); 249 | } 250 | ``` 251 | 252 | 253 | 254 | > **Tip:** Try multiplying `angle` by a whole number to add more twists! 255 | 256 | --- 257 | 258 | We can also use 3D spherical coordinates as a base, instead of a 2D circle. 259 | 260 | ```glsl 261 | vec3 spherical (float r, float phi, float theta) { 262 | return vec3( 263 | r * cos(phi) * cos(theta), 264 | r * cos(phi) * sin(theta), 265 | r * sin(phi) 266 | ); 267 | } 268 | 269 | vec3 sample (float t) { 270 | float angle = t * 2.0 * PI; 271 | 272 | float radius = 1.0; 273 | float phi = t * 2.0 * PI; 274 | float theta = (t * 2.0 - 1.0); 275 | 276 | return spherical(radius, time + phi, theta); 277 | } 278 | ``` 279 | 280 | Which gives us: 281 | 282 | 283 | 284 | --- 285 | 286 | The `r` (radius), `phi` and `theta` parameters can be a function of `t` to create some interesting shapes like [torus knots](http://paulbourke.net/geometry/knots/). 287 | 288 | After some trial and error, we end up with something like this: 289 | 290 | ```glsl 291 | vec3 sample (float t) { 292 | float beta = t * PI; 293 | 294 | float r = sin(beta * 2.0) * 0.75; 295 | float phi = sin(beta * 8.0 + time); 296 | float theta = 4.0 * beta; 297 | 298 | return spherical(r, phi, theta); 299 | } 300 | ``` 301 | 302 | 303 | 304 | # Multiple Instances 305 | 306 | Things really start to take shape once you add in more curve meshes. For performance, they should all share the same geometry we created earlier. 307 | 308 | 309 | 310 | Our final parametric function looks very similar to our last step, but with some angles offset by an `index` uniform. The `index` float ranges from 0.0 to 1.0 and is the result of `meshIndex / (totalMeshes - 1)`. The image above uses 40 curves with 300 subdivisions and a random thickness per curve mesh. 311 | 312 | ```glsl 313 | // import an easing function for nicer animations 314 | #pragma glslify: ease = require('glsl-easings/exponential-in-out'); 315 | 316 | vec3 sample (float t) { 317 | float beta = t * PI; 318 | 319 | float ripple = ease(sin(t * 2.0 * PI + time)) * 0.25; 320 | float noise = time + index * ripple * 8.0; 321 | 322 | float r = sin(index * 0.75 + beta * 2.0) * 0.75; 323 | float theta = 4.0 * beta + index * 0.25; 324 | float phi = sin(index * 2.0 + beta * 8.0 + noise); 325 | 326 | return spherical(r, phi, theta); 327 | } 328 | ``` 329 | 330 | We're also modulating the per-vertex `volume` before solving the Frenet-Serret frame. This gives each curve some variety in thickness along its length. 331 | 332 | ```glsl 333 | // build our tube geometry 334 | vec2 volume = vec2(thickness); 335 | 336 | // animate the curve thickness 337 | float vOff = index * 20.0 + time * 2.5; 338 | float vAngle = t * lengthSegments * 0.5 + vOff; 339 | float vMod = sin(vAngle) * 0.5 + 0.5; 340 | volume += 0.01 * vMod; 341 | 342 | ... createTube(...); 343 | ``` 344 | 345 | Then we add some fake rim lighting in the fragment step and mix it with the Z-normal of the tube: 346 | 347 | ```glsl 348 | ... 349 | vec3 V = normalize(vViewPosition); 350 | float vDotN = 1.0 - max(dot(V, normal), 0.0); 351 | float rim = smoothstep(0.5, 1.0, vDotN); 352 | 353 | float diffuse = normal.z * 0.5 + 0.5; 354 | diffuse += rim * 2.0; 355 | ... 356 | ``` 357 | 358 | Lastly, we add a small effect for color transitions, which you can see in the final shaders [here](https://github.com/mattdesl/parametric-curves/tree/master/lib/shaders). 359 | 360 | # Gotchas 361 | 362 | ## Twists & Vanishing Curves 363 | 364 | As I mentioned in the intro, this technique has some serious downsides. One is that, depending on your equation, the Frenet-Serret frame might lead to chaotic twists in rotation. Below is a particularly bad edge case that shows a lot of twists: 365 | 366 | ```glsl 367 | vec3 sample (float t) { 368 | return vec3(t * 2.0 - 1.0, t * 2.0 - 1.0, 0.0); 369 | } 370 | ``` 371 | 372 | 373 | 374 | To solve this, we need to use [Parallel Transport frames](https://pdfs.semanticscholar.org/7e65/2313c1f8183a0f43acce58ae8d8caf370a6b.pdf). For each vertex, we need to solve all the Frenet-Serret frames that have come before it. This is extremely expensive, and depending on your subdivision and number of sides, you might only be able to render a handful of curves before you reach a vertex shader bottleneck. 375 | 376 | The final vertex shader provides a `ROBUST` define flag [that solves this](https://github.com/mattdesl/parametric-curves/blob/45f321fd43af3a0786aa2dd4016931cc39325944/lib/shaders/tube.vert#L66-L162) issue, at the expense of performance: 377 | 378 | 379 | 380 | See [here](https://github.com/mattdesl/parametric-curves/blob/45f321fd43af3a0786aa2dd4016931cc39325944/lib/components/createTubes.js#L30-L31) to enable the flag. 381 | 382 | A similar problem arises with exactly straight lines, which will disappear entirely using our fast Frenet-Serret approach. 383 | 384 | ```glsl 385 | vec3 sample (float t) { 386 | return vec3(t * 2.0 - 1.0, 0.0, 0.0); 387 | } 388 | ``` 389 | 390 | Again, you can enable the `ROBUST` define at the cost of performance, or jitter your components slightly so the line is no longer exactly straight. 391 | 392 | ## End Cap Normals 393 | 394 | Another unsolved problem in this demo is the normals of the end caps. They look a little puffy when using smooth normals, but ideally they should appear flat. I'd be curious to hear if others have an idea of how to solve this. 395 | 396 | ## Closed Curves 397 | 398 | This demo does not attempt to render closed curves — it just so happens that, with the fast Frenet-Serret approach, the curve seems to close naturally. The same parametric equations with the `ROBUST` flag will *not* close properly, as Parallel Transport requires an additional (expensive) pass over the segments to close the curve properly. 399 | 400 | # Next Steps 401 | 402 | There are lots of interesting things we can do from here, like: 403 | 404 | - use a [custom MeshStandardMaterial](https://gist.github.com/mattdesl/034c5daf2cf5a01c458bc9584cbe6744) for shading and reflections 405 | - modulating the *t* parameter before sending it to the parametric equation, e.g. to make it appear like each curve is being drawn in. 406 | - use instanced buffer geometry to reduce the number of draw calls 407 | - use noise and texture reads in our parametric equation for a variety of effects 408 | - try extruding `volume` with a different shape, not just a circle (i.e. a star or rounded box) 409 | 410 | # Further Reading 411 | 412 | - [Parallel Transport Approach to Curve Framing](https://pdfs.semanticscholar.org/7e65/2313c1f8183a0f43acce58ae8d8caf370a6b.pdf) 413 | - [Spline Extrustions, Tubes and Knots of Sorts](http://www.lab4games.net/zz85/blog/2012/04/24/spline-extrusions-tubes-and-knots-of-sorts/) 414 | 415 | Enjoy! -------------------------------------------------------------------------------- /blogpost/shader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/shader.png -------------------------------------------------------------------------------- /blogpost/xmas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/parametric-curves/deec8372ef141af367833af9375966856a0e31f0/blogpost/xmas.jpg -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('fastclick')(document.body); 2 | 3 | const query = require('./lib/util/query'); 4 | const isMobile = require('./lib/util/isMobile'); 5 | const createApp = require('./lib/createApp'); 6 | const createLoop = require('raf-loop'); 7 | const classes = require('element-class'); 8 | 9 | // some hand-picked colors 10 | const palettes = [ '#f7803c', '#b3204d', '#cbe86b', '#2b4e72', '#d4ee5e', '#ff003c', '#e6ac27', '#d95b43', '#a3a948', '#838689', '#556270', '#292c37', '#fa6900', '#eb7b59', '#ff4e50', '#9d9d93', '#00a8c6', '#2b4e72', '#e4844a', '#9cc4e4', '#515151' ]; 11 | let paletteIndex = 0; 12 | 13 | const createTubes = require('./lib/components/createTubes'); 14 | 15 | const infoElement = document.querySelector('.info-container'); 16 | 17 | const app = createApp({ 18 | canvas: document.querySelector('#canvas'), 19 | alpha: true 20 | }); 21 | 22 | const background = 'hsl(0, 0%, 100%)'; 23 | document.body.style.background = background; 24 | app.renderer.setClearColor(0xffffff, 0); 25 | 26 | setupCursor(); 27 | start(); 28 | 29 | function setupCursor () { 30 | if (query.orbitControls) { 31 | const onMouseGrab = () => classes(app.canvas).add('grabbing'); 32 | const onMouseUngrab = () => classes(app.canvas).remove('grabbing'); 33 | app.canvas.addEventListener('mousedown', onMouseGrab, false); 34 | document.addEventListener('mouseup', onMouseUngrab, false); 35 | } 36 | classes(app.canvas).add(query.orbitControls ? 'grab' : 'clickable'); 37 | } 38 | 39 | function start () { 40 | const line = createTubes(app); 41 | app.scene.add(line.object3d); 42 | 43 | const skipFrames = query.skipFrames; 44 | let intervalTime = 0; 45 | 46 | // no context menu on mobile... 47 | if (isMobile) app.canvas.oncontextmenu = () => false; 48 | 49 | app.canvas.addEventListener('touchstart', tap); 50 | if (!isMobile) app.canvas.addEventListener('mousedown', tap); 51 | if (isMobile) infoElement.textContent = 'tap to interact'; 52 | infoElement.style.visibility = 'visible'; 53 | 54 | if (query.renderOnce) tick(0); 55 | else createLoop(tick).start(); 56 | 57 | function tick (dt = 0) { 58 | intervalTime += dt; 59 | if (intervalTime > 1000 / 20) { 60 | intervalTime = 0; 61 | } else if (skipFrames) { 62 | return; 63 | } 64 | line.update(dt); 65 | app.tick(dt); 66 | app.render(); 67 | } 68 | 69 | function tap (ev) { 70 | // ev.preventDefault(); 71 | line.setPalette(palettes[paletteIndex++ % palettes.length]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/components/createTubes.js: -------------------------------------------------------------------------------- 1 | const createTubeGeometry = require('../geom/createTubeGeometry'); 2 | const glslify = require('glslify'); 3 | const path = require('path'); 4 | const newArray = require('new-array'); 5 | const tweenr = require('tweenr')(); 6 | const isMobile = require('../util/isMobile'); 7 | 8 | const { 9 | randomFloat 10 | } = require('../util/random'); 11 | 12 | module.exports = function (app) { 13 | const totalMeshes = isMobile ? 30 : 40; 14 | const isSquare = false; 15 | const subdivisions = isMobile ? 200 : 300; 16 | 17 | const numSides = isSquare ? 4 : 8; 18 | const openEnded = false; 19 | const geometry = createTubeGeometry(numSides, subdivisions, openEnded); 20 | 21 | const baseMaterial = new THREE.RawShaderMaterial({ 22 | vertexShader: glslify(path.resolve(__dirname, '../shaders/tube.vert')), 23 | fragmentShader: glslify(path.resolve(__dirname, '../shaders/tube.frag')), 24 | side: THREE.FrontSide, 25 | extensions: { 26 | deriviatives: true 27 | }, 28 | defines: { 29 | lengthSegments: subdivisions.toFixed(1), 30 | ROBUST: false, 31 | ROBUST_NORMALS: true, // can be disabled for a slight optimization 32 | FLAT_SHADED: isSquare 33 | }, 34 | uniforms: { 35 | thickness: { type: 'f', value: 1 }, 36 | time: { type: 'f', value: 0 }, 37 | color: { type: 'c', value: new THREE.Color('#303030') }, 38 | animateRadius: { type: 'f', value: 0 }, 39 | animateStrength: { type: 'f', value: 0 }, 40 | index: { type: 'f', value: 0 }, 41 | totalMeshes: { type: 'f', value: totalMeshes }, 42 | radialSegments: { type: 'f', value: numSides } 43 | } 44 | }); 45 | 46 | const lines = newArray(totalMeshes).map((_, i) => { 47 | const t = totalMeshes <= 1 ? 0 : i / (totalMeshes - 1); 48 | 49 | const material = baseMaterial.clone(); 50 | material.uniforms = THREE.UniformsUtils.clone(material.uniforms); 51 | material.uniforms.index.value = t; 52 | material.uniforms.thickness.value = randomFloat(0.005, 0.0075); 53 | 54 | const mesh = new THREE.Mesh(geometry, material); 55 | mesh.frustumCulled = false; // to avoid ThreeJS errors 56 | return mesh; 57 | }); 58 | 59 | // add to a parent container 60 | const container = new THREE.Object3D(); 61 | lines.forEach(mesh => container.add(mesh)); 62 | 63 | return { 64 | object3d: container, 65 | update, 66 | setPalette 67 | }; 68 | 69 | // animate in a new color palette 70 | function setPalette (palette) { 71 | tweenr.cancel(); 72 | lines.forEach((mesh, i) => { 73 | const uniforms = mesh.material.uniforms; 74 | uniforms.color.value.set(palette); 75 | 76 | const delay = i * 0.004; 77 | uniforms.animateRadius.value = 0; 78 | uniforms.animateStrength.value = 1; 79 | tweenr.to(uniforms.animateRadius, { value: 1, duration: 0.5, delay, ease: 'epxoOut' }); 80 | tweenr.to(uniforms.animateStrength, { value: 0, duration: 1, delay, ease: 'expoInOut' }); 81 | }); 82 | } 83 | 84 | function update (dt) { 85 | dt = dt / 1000; 86 | lines.forEach(mesh => { 87 | mesh.material.uniforms.time.value += dt; 88 | }); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /lib/createApp.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a generic "ThreeJS Application" 3 | helper which sets up a renderer and camera 4 | controls. 5 | */ 6 | 7 | const createControls = require('orbit-controls'); 8 | const assign = require('object-assign'); 9 | const defined = require('defined'); 10 | const mouseEventOffset = require('mouse-event-offset'); 11 | const tweenr = require('tweenr')(); 12 | const isMobile = require('./util/isMobile'); 13 | 14 | const query = require('./util/query'); 15 | 16 | module.exports = createApp; 17 | function createApp (opt = {}) { 18 | // Scale for retina 19 | const dpr = defined(query.dpr, Math.min(2, window.devicePixelRatio)); 20 | 21 | const cameraDistance = isMobile ? 2 : 1.75; 22 | const theta = 0 * Math.PI / 180; 23 | const angleOffset = 20; 24 | const mouseOffset = new THREE.Vector2(); 25 | const tmpQuat1 = new THREE.Quaternion(); 26 | const tmpQuat2 = new THREE.Quaternion(); 27 | const AXIS_X = new THREE.Vector3(1, 0, 0); 28 | const AXIS_Y = new THREE.Vector3(0, 1, 0); 29 | 30 | // Our WebGL renderer with alpha and device-scaled 31 | const renderer = new THREE.WebGLRenderer(assign({ 32 | alpha: false, 33 | stencil: false, 34 | antialias: true // default enabled 35 | }, opt)); 36 | renderer.setPixelRatio(dpr); 37 | renderer.gammaFactor = 2.2; 38 | renderer.gammaOutput = true; 39 | renderer.gammaInput = true; 40 | renderer.sortObjects = false; 41 | 42 | // Add the to DOM body 43 | const canvas = renderer.domElement; 44 | 45 | // perspective camera 46 | const near = 0.1; 47 | const far = 10; 48 | const fieldOfView = 65; 49 | const camera = new THREE.PerspectiveCamera(fieldOfView, 1, near, far); 50 | const target = new THREE.Vector3(); 51 | 52 | // 3D scene 53 | const scene = new THREE.Scene(); 54 | 55 | // slick 3D orbit controller with damping 56 | const useOrbitControls = query.orbitControls; 57 | let controls; 58 | if (useOrbitControls) { 59 | controls = createControls(assign({ 60 | canvas, 61 | theta, 62 | distanceBounds: [ 0.5, 5 ], 63 | distance: cameraDistance 64 | }, opt)); 65 | } 66 | 67 | // Update renderer size 68 | window.addEventListener('resize', resize); 69 | 70 | const app = assign({}, { 71 | tick, 72 | camera, 73 | scene, 74 | renderer, 75 | canvas, 76 | render 77 | }); 78 | 79 | app.width = 0; 80 | app.height = 0; 81 | app.top = 0; 82 | app.left = 0; 83 | 84 | // Setup initial size & aspect ratio 85 | resize(); 86 | tick(); 87 | createMouseParallax(); 88 | return app; 89 | 90 | function tick (dt = 0) { 91 | const aspect = app.width / app.height; 92 | 93 | if (useOrbitControls) { 94 | // update camera controls 95 | controls.update(); 96 | camera.position.fromArray(controls.position); 97 | camera.up.fromArray(controls.up); 98 | target.fromArray(controls.direction).add(camera.position); 99 | camera.lookAt(target); 100 | } else { 101 | const phi = Math.PI / 2; 102 | camera.position.x = Math.sin(phi) * Math.sin(theta); 103 | camera.position.y = Math.cos(phi); 104 | camera.position.z = Math.sin(phi) * Math.cos(theta); 105 | 106 | const radius = cameraDistance; 107 | const radianOffset = angleOffset * Math.PI / 180; 108 | const xOff = mouseOffset.y * radianOffset; 109 | const yOff = mouseOffset.x * radianOffset; 110 | tmpQuat1.setFromAxisAngle(AXIS_X, -xOff); 111 | tmpQuat2.setFromAxisAngle(AXIS_Y, -yOff); 112 | tmpQuat1.multiply(tmpQuat2); 113 | camera.position.applyQuaternion(tmpQuat1); 114 | camera.position.multiplyScalar(radius); 115 | 116 | target.set(0, 0, 0); 117 | camera.lookAt(target); 118 | } 119 | 120 | // Update camera matrices 121 | camera.aspect = aspect; 122 | camera.updateProjectionMatrix(); 123 | } 124 | 125 | function render () { 126 | renderer.render(scene, camera); 127 | } 128 | 129 | function resize () { 130 | let width = defined(query.width, window.innerWidth); 131 | let height = defined(query.height, window.innerHeight); 132 | 133 | app.width = width; 134 | app.height = height; 135 | renderer.setSize(width, height); 136 | tick(0); 137 | render(); 138 | } 139 | 140 | function createMouseParallax () { 141 | const tmp = [ 0, 0 ]; 142 | window.addEventListener('mousemove', ev => { 143 | mouseEventOffset(ev, app.canvas, tmp); 144 | tweenr.cancel().to(mouseOffset, { 145 | x: (tmp[0] / app.width * 2 - 1), 146 | y: (tmp[1] / app.height * 2 - 1), 147 | ease: 'expoOut', 148 | duration: 0.5 149 | }); 150 | }); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/geom/createTubeGeometry.js: -------------------------------------------------------------------------------- 1 | module.exports = createLineGeometry; 2 | function createLineGeometry (numSides = 8, subdivisions = 50, openEnded = false) { 3 | // create a base CylinderGeometry which handles UVs, end caps and faces 4 | const radius = 1; 5 | const length = 1; 6 | const baseGeometry = new THREE.CylinderGeometry(radius, radius, length, numSides, subdivisions, openEnded); 7 | 8 | // fix the orientation so X can act as arc length 9 | baseGeometry.rotateZ(Math.PI / 2); 10 | 11 | // compute the radial angle for each position for later extrusion 12 | const tmpVec = new THREE.Vector2(); 13 | const xPositions = []; 14 | const angles = []; 15 | const uvs = []; 16 | const vertices = baseGeometry.vertices; 17 | const faceVertexUvs = baseGeometry.faceVertexUvs[0]; 18 | 19 | // Now go through each face and un-index the geometry. 20 | baseGeometry.faces.forEach((face, i) => { 21 | const { a, b, c } = face; 22 | const v0 = vertices[a]; 23 | const v1 = vertices[b]; 24 | const v2 = vertices[c]; 25 | const verts = [ v0, v1, v2 ]; 26 | const faceUvs = faceVertexUvs[i]; 27 | 28 | // For each vertex in this face... 29 | verts.forEach((v, j) => { 30 | tmpVec.set(v.y, v.z).normalize(); 31 | 32 | // the radial angle around the tube 33 | const angle = Math.atan2(tmpVec.y, tmpVec.x); 34 | angles.push(angle); 35 | 36 | // "arc length" in range [-0.5 .. 0.5] 37 | xPositions.push(v.x); 38 | 39 | // copy over the UV for this vertex 40 | uvs.push(faceUvs[j].toArray()); 41 | }); 42 | }); 43 | 44 | // build typed arrays for our attributes 45 | const posArray = new Float32Array(xPositions); 46 | const angleArray = new Float32Array(angles); 47 | const uvArray = new Float32Array(uvs.length * 2); 48 | 49 | // unroll UVs 50 | for (let i = 0; i < posArray.length; i++) { 51 | const [ u, v ] = uvs[i]; 52 | uvArray[i * 2 + 0] = u; 53 | uvArray[i * 2 + 1] = v; 54 | } 55 | 56 | const geometry = new THREE.BufferGeometry(); 57 | geometry.addAttribute('position', new THREE.BufferAttribute(posArray, 1)); 58 | geometry.addAttribute('angle', new THREE.BufferAttribute(angleArray, 1)); 59 | geometry.addAttribute('uv', new THREE.BufferAttribute(uvArray, 2)); 60 | 61 | // dispose old geometry since we no longer need it 62 | baseGeometry.dispose(); 63 | return geometry; 64 | } 65 | -------------------------------------------------------------------------------- /lib/shaders/tube.frag: -------------------------------------------------------------------------------- 1 | #extension GL_OES_standard_derivatives : enable 2 | precision highp float; 3 | 4 | varying vec3 vNormal; 5 | varying vec2 vUv; 6 | varying vec3 vViewPosition; 7 | 8 | uniform vec3 color; 9 | uniform float animateRadius; 10 | uniform float animateStrength; 11 | 12 | #pragma glslify: faceNormal = require('glsl-face-normal'); 13 | 14 | void main () { 15 | // handle flat and smooth normals 16 | vec3 normal = vNormal; 17 | #ifdef FLAT_SHADED 18 | normal = faceNormal(vViewPosition); 19 | #endif 20 | 21 | // Z-normal "fake" shading 22 | float diffuse = normal.z * 0.5 + 0.5; 23 | 24 | // add some "rim lighting" 25 | vec3 V = normalize(vViewPosition); 26 | float vDotN = 1.0 - max(dot(V, normal), 0.0); 27 | float rim = smoothstep(0.5, 1.0, vDotN); 28 | diffuse += rim * 2.0; 29 | 30 | // we'll animate in the new color from the center point 31 | float distFromCenter = clamp(length(vViewPosition) / 5.0, 0.0, 1.0); 32 | float edge = 0.05; 33 | float t = animateRadius; 34 | vec3 curColor = mix(color, #fff, smoothstep(t - edge, t + edge, vUv.y) * animateStrength); 35 | 36 | // final color 37 | gl_FragColor = vec4(diffuse * curColor, 1.0); 38 | } 39 | -------------------------------------------------------------------------------- /lib/shaders/tube.vert: -------------------------------------------------------------------------------- 1 | // attributes of our mesh 2 | attribute float position; 3 | attribute float angle; 4 | attribute vec2 uv; 5 | 6 | // built-in uniforms from ThreeJS camera and Object3D 7 | uniform mat4 projectionMatrix; 8 | uniform mat4 modelViewMatrix; 9 | uniform mat3 normalMatrix; 10 | 11 | // custom uniforms to build up our tubes 12 | uniform float thickness; 13 | uniform float time; 14 | uniform float animateRadius; 15 | uniform float animateStrength; 16 | uniform float index; 17 | uniform float radialSegments; 18 | 19 | // pass a few things along to the vertex shader 20 | varying vec2 vUv; 21 | varying vec3 vViewPosition; 22 | varying vec3 vNormal; 23 | 24 | // Import a couple utilities 25 | #pragma glslify: PI = require('glsl-pi'); 26 | #pragma glslify: ease = require('glsl-easings/exponential-in-out'); 27 | 28 | // Some constants for the robust version 29 | #ifdef ROBUST 30 | const float MAX_NUMBER = 1.79769313e+308; 31 | const float EPSILON = 1.19209290e-7; 32 | #endif 33 | 34 | // Angles to spherical coordinates 35 | vec3 spherical (float r, float phi, float theta) { 36 | return r * vec3( 37 | cos(phi) * cos(theta), 38 | cos(phi) * sin(theta), 39 | sin(phi) 40 | ); 41 | } 42 | 43 | // Flying a curve along a sine wave 44 | // vec3 sample (float t) { 45 | // float x = t * 2.0 - 1.0; 46 | // float y = sin(t + time); 47 | // return vec3(x, y, 0.0); 48 | // } 49 | 50 | // Creates an animated torus knot 51 | vec3 sample (float t) { 52 | float beta = t * PI; 53 | 54 | float ripple = ease(sin(t * 2.0 * PI + time) * 0.5 + 0.5) * 0.5; 55 | float noise = time + index * ripple * 8.0; 56 | 57 | // animate radius on click 58 | float radiusAnimation = animateRadius * animateStrength * 0.25; 59 | float r = sin(index * 0.75 + beta * 2.0) * (0.75 + radiusAnimation); 60 | float theta = 4.0 * beta + index * 0.25; 61 | float phi = sin(index * 2.0 + beta * 8.0 + noise); 62 | 63 | return spherical(r, phi, theta); 64 | } 65 | 66 | #ifdef ROBUST 67 | // ------ 68 | // Robust handling of Frenet-Serret frames with Parallel Transport 69 | // ------ 70 | vec3 getTangent (vec3 a, vec3 b) { 71 | return normalize(b - a); 72 | } 73 | 74 | void rotateByAxisAngle (inout vec3 normal, vec3 axis, float angle) { 75 | // http://www.euclideanspace.com/maths/geometry/rotations/conversions/angleToQuaternion/index.htm 76 | // assumes axis is normalized 77 | float halfAngle = angle / 2.0; 78 | float s = sin(halfAngle); 79 | vec4 quat = vec4(axis * s, cos(halfAngle)); 80 | normal = normal + 2.0 * cross(quat.xyz, cross(quat.xyz, normal) + quat.w * normal); 81 | } 82 | 83 | void createTube (float t, vec2 volume, out vec3 outPosition, out vec3 outNormal) { 84 | // Reference: 85 | // https://github.com/mrdoob/three.js/blob/b07565918713771e77b8701105f2645b1e5009a7/src/extras/core/Curve.js#L268 86 | float nextT = t + (1.0 / lengthSegments); 87 | 88 | // find first tangent 89 | vec3 point0 = sample(0.0); 90 | vec3 point1 = sample(1.0 / lengthSegments); 91 | 92 | vec3 lastTangent = getTangent(point0, point1); 93 | vec3 absTangent = abs(lastTangent); 94 | #ifdef ROBUST_NORMAL 95 | float min = MAX_NUMBER; 96 | vec3 tmpNormal = vec3(0.0); 97 | if (absTangent.x <= min) { 98 | min = absTangent.x; 99 | tmpNormal.x = 1.0; 100 | } 101 | if (absTangent.y <= min) { 102 | min = absTangent.y; 103 | tmpNormal.y = 1.0; 104 | } 105 | if (absTangent.z <= min) { 106 | tmpNormal.z = 1.0; 107 | } 108 | #else 109 | vec3 tmpNormal = vec3(1.0, 0.0, 0.0); 110 | #endif 111 | vec3 tmpVec = normalize(cross(lastTangent, tmpNormal)); 112 | vec3 lastNormal = cross(lastTangent, tmpVec); 113 | vec3 lastBinormal = cross(lastTangent, lastNormal); 114 | vec3 lastPoint = point0; 115 | 116 | vec3 normal; 117 | vec3 tangent; 118 | vec3 binormal; 119 | vec3 point; 120 | float maxLen = (lengthSegments - 1.0); 121 | float epSq = EPSILON * EPSILON; 122 | for (float i = 1.0; i < lengthSegments; i += 1.0) { 123 | float u = i / maxLen; 124 | // could avoid additional sample here at expense of ternary 125 | // point = i == 1.0 ? point1 : sample(u); 126 | point = sample(u); 127 | tangent = getTangent(lastPoint, point); 128 | normal = lastNormal; 129 | binormal = lastBinormal; 130 | 131 | tmpVec = cross(lastTangent, tangent); 132 | if ((tmpVec.x * tmpVec.x + tmpVec.y * tmpVec.y + tmpVec.z * tmpVec.z) > epSq) { 133 | tmpVec = normalize(tmpVec); 134 | float tangentDot = dot(lastTangent, tangent); 135 | float theta = acos(clamp(tangentDot, -1.0, 1.0)); // clamp for floating pt errors 136 | rotateByAxisAngle(normal, tmpVec, theta); 137 | } 138 | 139 | binormal = cross(tangent, normal); 140 | if (u >= t) break; 141 | 142 | lastPoint = point; 143 | lastTangent = tangent; 144 | lastNormal = normal; 145 | lastBinormal = binormal; 146 | } 147 | 148 | // extrude outward to create a tube 149 | float tubeAngle = angle; 150 | float circX = cos(tubeAngle); 151 | float circY = sin(tubeAngle); 152 | 153 | // compute the TBN matrix 154 | vec3 T = tangent; 155 | vec3 B = binormal; 156 | vec3 N = -normal; 157 | 158 | // extrude the path & create a new normal 159 | outNormal.xyz = normalize(B * circX + N * circY); 160 | outPosition.xyz = point + B * volume.x * circX + N * volume.y * circY; 161 | } 162 | #else 163 | // ------ 164 | // Fast version; computes the local Frenet-Serret frame 165 | // ------ 166 | void createTube (float t, vec2 volume, out vec3 offset, out vec3 normal) { 167 | // find next sample along curve 168 | float nextT = t + (1.0 / lengthSegments); 169 | 170 | // sample the curve in two places 171 | vec3 current = sample(t); 172 | vec3 next = sample(nextT); 173 | 174 | // compute the TBN matrix 175 | vec3 T = normalize(next - current); 176 | vec3 B = normalize(cross(T, next + current)); 177 | vec3 N = -normalize(cross(B, T)); 178 | 179 | // extrude outward to create a tube 180 | float tubeAngle = angle; 181 | float circX = cos(tubeAngle); 182 | float circY = sin(tubeAngle); 183 | 184 | // compute position and normal 185 | normal.xyz = normalize(B * circX + N * circY); 186 | offset.xyz = current + B * volume.x * circX + N * volume.y * circY; 187 | } 188 | #endif 189 | 190 | void main() { 191 | // current position to sample at 192 | // [-0.5 .. 0.5] to [0.0 .. 1.0] 193 | float t = (position * 2.0) * 0.5 + 0.5; 194 | 195 | // build our tube geometry 196 | vec2 volume = vec2(thickness); 197 | 198 | // animate the per-vertex curve thickness 199 | float volumeAngle = t * lengthSegments * 0.5 + index * 20.0 + time * 2.5; 200 | float volumeMod = sin(volumeAngle) * 0.5 + 0.5; 201 | volume += 0.01 * volumeMod; 202 | 203 | // build our geometry 204 | vec3 transformed; 205 | vec3 objectNormal; 206 | createTube(t, volume, transformed, objectNormal); 207 | 208 | // pass the normal and UV along 209 | vec3 transformedNormal = normalMatrix * objectNormal; 210 | vNormal = normalize(transformedNormal); 211 | vUv = uv.yx; // swizzle this to match expectations 212 | 213 | // project our vertex position 214 | vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0); 215 | vViewPosition = -mvPosition.xyz; 216 | gl_Position = projectionMatrix * mvPosition; 217 | } 218 | -------------------------------------------------------------------------------- /lib/util/isMobile.js: -------------------------------------------------------------------------------- 1 | module.exports = /(iPhone|iPad|iPod|Android)/i.test(window.navigator.userAgent); 2 | -------------------------------------------------------------------------------- /lib/util/query.js: -------------------------------------------------------------------------------- 1 | const qs = require('query-string'); 2 | 3 | module.exports = parseOptions(); 4 | function parseOptions () { 5 | if (typeof window === 'undefined') return {}; 6 | const parsed = qs.parse(window.location.search); 7 | Object.keys(parsed).forEach(key => { 8 | if (parsed[key] === null) parsed[key] = true; 9 | if (parsed[key] === 'false') parsed[key] = false; 10 | if (parsed[key] === 'true') parsed[key] = true; 11 | if (isNumber(parsed[key])) { 12 | parsed[key] = Number(parsed[key]); 13 | } 14 | }); 15 | return parsed; 16 | } 17 | 18 | function isNumber (x) { 19 | if (typeof x === 'number') return true; 20 | if (/^0x[0-9a-f]+$/i.test(x)) return true; 21 | return /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$/.test(x); 22 | } 23 | -------------------------------------------------------------------------------- /lib/util/random.js: -------------------------------------------------------------------------------- 1 | const SEED = String(require('./query').seed || '2'); 2 | const seedRandom = require('seed-random'); 3 | const SimplexNoise = require('simplex-noise'); 4 | 5 | module.exports.random = seedRandom(SEED); 6 | module.exports.simplex = new SimplexNoise(module.exports.random); 7 | 8 | module.exports.randomSign = () => module.exports.random() > 0.5 ? 1 : -1; 9 | 10 | module.exports.randomFloat = function (min, max) { 11 | if (max === undefined) { 12 | max = min; 13 | min = 0; 14 | } 15 | 16 | if (typeof min !== 'number' || typeof max !== 'number') { 17 | throw new TypeError('Expected all arguments to be numbers'); 18 | } 19 | 20 | return module.exports.random() * (max - min) + min; 21 | }; 22 | 23 | module.exports.randomCircle = function (out, scale) { 24 | scale = scale || 1.0; 25 | var r = module.exports.random() * 2.0 * Math.PI; 26 | out[0] = Math.cos(r) * scale; 27 | out[1] = Math.sin(r) * scale; 28 | return out; 29 | }; 30 | 31 | module.exports.randomSphere = function (out, scale) { 32 | scale = scale || 1.0; 33 | var r = module.exports.random() * 2.0 * Math.PI; 34 | var z = (module.exports.random() * 2.0) - 1.0; 35 | var zScale = Math.sqrt(1.0 - z * z) * scale; 36 | out[0] = Math.cos(r) * zScale; 37 | out[1] = Math.sin(r) * zScale; 38 | out[2] = z * scale; 39 | return out; 40 | }; 41 | 42 | module.exports.shuffle = function (arr) { 43 | if (!Array.isArray(arr)) { 44 | throw new TypeError('Expected Array, got ' + typeof arr); 45 | } 46 | 47 | var rand; 48 | var tmp; 49 | var len = arr.length; 50 | var ret = arr.slice(); 51 | 52 | while (len) { 53 | rand = Math.floor(module.exports.random() * len--); 54 | tmp = ret[len]; 55 | ret[len] = ret[rand]; 56 | ret[rand] = tmp; 57 | } 58 | 59 | return ret; 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parametric-curves", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "semistandard": { 8 | "global": [ 9 | "THREE" 10 | ] 11 | }, 12 | "author": { 13 | "name": "Matt DesLauriers", 14 | "email": "dave.des@gmail.com", 15 | "url": "https://github.com/mattdesl" 16 | }, 17 | "dependencies": { 18 | "defined": "^1.0.0", 19 | "element-class": "^0.2.2", 20 | "fastclick": "^1.0.6", 21 | "glsl-easings": "^1.0.0", 22 | "glsl-face-normal": "^1.0.2", 23 | "glsl-pi": "^1.0.0", 24 | "glslify-hex": "^2.1.1", 25 | "mouse-event-offset": "^3.0.2", 26 | "new-array": "^1.0.0", 27 | "object-assign": "^4.1.0", 28 | "orbit-controls": "^1.1.1", 29 | "query-string": "^4.2.3", 30 | "raf-loop": "^1.1.3", 31 | "seed-random": "^2.2.0", 32 | "simplex-noise": "^2.2.0", 33 | "tweenr": "^2.2.0" 34 | }, 35 | "devDependencies": { 36 | "babel-preset-es2015": "^6.18.0", 37 | "babelify": "^7.3.0", 38 | "browserify": "^13.1.1", 39 | "budo": "^9.2.2", 40 | "cross-env": "^3.1.3", 41 | "glslify": "^6.0.1", 42 | "rimraf": "^2.5.4", 43 | "surge": "^0.18.0", 44 | "uglify-js": "^2.7.5" 45 | }, 46 | "glslify": { 47 | "transform": [ 48 | "glslify-hex" 49 | ] 50 | }, 51 | "scripts": { 52 | "start": "budo index.js:bundle.js --live --dir app -- -t babelify -t glslify", 53 | "deploy:upload": "surge -p app -d parametric-curves.surge.sh", 54 | "deploy": "npm run build && npm run deploy:upload", 55 | "build": "browserify index.js -t babelify -t glslify | uglifyjs -m -c warnings=false > app/bundle.js" 56 | }, 57 | "keywords": [], 58 | "repository": { 59 | "type": "git", 60 | "url": "git://github.com/mattdesl/parametric-curves.git" 61 | }, 62 | "homepage": "https://github.com/mattdesl/parametric-curves", 63 | "bugs": { 64 | "url": "https://github.com/mattdesl/parametric-curves/issues" 65 | } 66 | } 67 | --------------------------------------------------------------------------------