├── .gitattributes ├── LICENSE ├── OrbitControls.js ├── README.md ├── fragment-shader.js ├── index.html ├── main.js ├── matcap.png ├── matcap1.jpg ├── matcap2.png ├── matcap3.png ├── three.module.js └── vertex-shader.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 thespite and makio135 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OrbitControls.js: -------------------------------------------------------------------------------- 1 | import { 2 | EventDispatcher, 3 | MOUSE, 4 | Quaternion, 5 | Spherical, 6 | TOUCH, 7 | Vector2, 8 | Vector3, 9 | } from "./three.module.js"; 10 | 11 | // This set of controls performs orbiting, dollying (zooming), and panning. 12 | // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). 13 | // 14 | // Orbit - left mouse / touch: one-finger move 15 | // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish 16 | // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move 17 | 18 | var OrbitControls = function (object, domElement) { 19 | if (domElement === undefined) 20 | console.warn( 21 | 'THREE.OrbitControls: The second parameter "domElement" is now mandatory.' 22 | ); 23 | if (domElement === document) 24 | console.error( 25 | 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.' 26 | ); 27 | 28 | this.object = object; 29 | this.domElement = domElement; 30 | 31 | // Set to false to disable this control 32 | this.enabled = true; 33 | 34 | // "target" sets the location of focus, where the object orbits around 35 | this.target = new Vector3(); 36 | 37 | // How far you can dolly in and out ( PerspectiveCamera only ) 38 | this.minDistance = 0; 39 | this.maxDistance = Infinity; 40 | 41 | // How far you can zoom in and out ( OrthographicCamera only ) 42 | this.minZoom = 0; 43 | this.maxZoom = Infinity; 44 | 45 | // How far you can orbit vertically, upper and lower limits. 46 | // Range is 0 to Math.PI radians. 47 | this.minPolarAngle = 0; // radians 48 | this.maxPolarAngle = Math.PI; // radians 49 | 50 | // How far you can orbit horizontally, upper and lower limits. 51 | // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) 52 | this.minAzimuthAngle = -Infinity; // radians 53 | this.maxAzimuthAngle = Infinity; // radians 54 | 55 | // Set to true to enable damping (inertia) 56 | // If damping is enabled, you must call controls.update() in your animation loop 57 | this.enableDamping = false; 58 | this.dampingFactor = 0.05; 59 | 60 | // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. 61 | // Set to false to disable zooming 62 | this.enableZoom = true; 63 | this.zoomSpeed = 1.0; 64 | 65 | // Set to false to disable rotating 66 | this.enableRotate = true; 67 | this.rotateSpeed = 1.0; 68 | 69 | // Set to false to disable panning 70 | this.enablePan = true; 71 | this.panSpeed = 1.0; 72 | this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up 73 | this.keyPanSpeed = 7.0; // pixels moved per arrow key push 74 | 75 | // Set to true to automatically rotate around the target 76 | // If auto-rotate is enabled, you must call controls.update() in your animation loop 77 | this.autoRotate = false; 78 | this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60 79 | 80 | // Set to false to disable use of the keys 81 | this.enableKeys = true; 82 | 83 | // The four arrow keys 84 | this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 }; 85 | 86 | // Mouse buttons 87 | this.mouseButtons = { 88 | LEFT: MOUSE.ROTATE, 89 | MIDDLE: MOUSE.DOLLY, 90 | RIGHT: MOUSE.PAN, 91 | }; 92 | 93 | // Touch fingers 94 | this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }; 95 | 96 | // for reset 97 | this.target0 = this.target.clone(); 98 | this.position0 = this.object.position.clone(); 99 | this.zoom0 = this.object.zoom; 100 | 101 | // 102 | // public methods 103 | // 104 | 105 | this.getPolarAngle = function () { 106 | return spherical.phi; 107 | }; 108 | 109 | this.getAzimuthalAngle = function () { 110 | return spherical.theta; 111 | }; 112 | 113 | this.saveState = function () { 114 | scope.target0.copy(scope.target); 115 | scope.position0.copy(scope.object.position); 116 | scope.zoom0 = scope.object.zoom; 117 | }; 118 | 119 | this.reset = function () { 120 | scope.target.copy(scope.target0); 121 | scope.object.position.copy(scope.position0); 122 | scope.object.zoom = scope.zoom0; 123 | 124 | scope.object.updateProjectionMatrix(); 125 | scope.dispatchEvent(changeEvent); 126 | 127 | scope.update(); 128 | 129 | state = STATE.NONE; 130 | }; 131 | 132 | // this method is exposed, but perhaps it would be better if we can make it private... 133 | this.update = (function () { 134 | var offset = new Vector3(); 135 | 136 | // so camera.up is the orbit axis 137 | var quat = new Quaternion().setFromUnitVectors( 138 | object.up, 139 | new Vector3(0, 1, 0) 140 | ); 141 | var quatInverse = quat.clone().invert(); 142 | 143 | var lastPosition = new Vector3(); 144 | var lastQuaternion = new Quaternion(); 145 | 146 | var twoPI = 2 * Math.PI; 147 | 148 | return function update() { 149 | var position = scope.object.position; 150 | 151 | offset.copy(position).sub(scope.target); 152 | 153 | // rotate offset to "y-axis-is-up" space 154 | offset.applyQuaternion(quat); 155 | 156 | // angle from z-axis around y-axis 157 | spherical.setFromVector3(offset); 158 | 159 | if (scope.autoRotate && state === STATE.NONE) { 160 | rotateLeft(getAutoRotationAngle()); 161 | } 162 | 163 | if (scope.enableDamping) { 164 | spherical.theta += sphericalDelta.theta * scope.dampingFactor; 165 | spherical.phi += sphericalDelta.phi * scope.dampingFactor; 166 | } else { 167 | spherical.theta += sphericalDelta.theta; 168 | spherical.phi += sphericalDelta.phi; 169 | } 170 | 171 | // restrict theta to be between desired limits 172 | 173 | var min = scope.minAzimuthAngle; 174 | var max = scope.maxAzimuthAngle; 175 | 176 | if (isFinite(min) && isFinite(max)) { 177 | if (min < -Math.PI) min += twoPI; 178 | else if (min > Math.PI) min -= twoPI; 179 | 180 | if (max < -Math.PI) max += twoPI; 181 | else if (max > Math.PI) max -= twoPI; 182 | 183 | if (min <= max) { 184 | spherical.theta = Math.max(min, Math.min(max, spherical.theta)); 185 | } else { 186 | spherical.theta = 187 | spherical.theta > (min + max) / 2 188 | ? Math.max(min, spherical.theta) 189 | : Math.min(max, spherical.theta); 190 | } 191 | } 192 | 193 | // restrict phi to be between desired limits 194 | spherical.phi = Math.max( 195 | scope.minPolarAngle, 196 | Math.min(scope.maxPolarAngle, spherical.phi) 197 | ); 198 | 199 | spherical.makeSafe(); 200 | 201 | spherical.radius *= scale; 202 | 203 | // restrict radius to be between desired limits 204 | spherical.radius = Math.max( 205 | scope.minDistance, 206 | Math.min(scope.maxDistance, spherical.radius) 207 | ); 208 | 209 | // move target to panned location 210 | 211 | if (scope.enableDamping === true) { 212 | scope.target.addScaledVector(panOffset, scope.dampingFactor); 213 | } else { 214 | scope.target.add(panOffset); 215 | } 216 | 217 | offset.setFromSpherical(spherical); 218 | 219 | // rotate offset back to "camera-up-vector-is-up" space 220 | offset.applyQuaternion(quatInverse); 221 | 222 | position.copy(scope.target).add(offset); 223 | 224 | scope.object.lookAt(scope.target); 225 | 226 | if (scope.enableDamping === true) { 227 | sphericalDelta.theta *= 1 - scope.dampingFactor; 228 | sphericalDelta.phi *= 1 - scope.dampingFactor; 229 | 230 | panOffset.multiplyScalar(1 - scope.dampingFactor); 231 | } else { 232 | sphericalDelta.set(0, 0, 0); 233 | 234 | panOffset.set(0, 0, 0); 235 | } 236 | 237 | scale = 1; 238 | 239 | // update condition is: 240 | // min(camera displacement, camera rotation in radians)^2 > EPS 241 | // using small-angle approximation cos(x/2) = 1 - x^2 / 8 242 | 243 | if ( 244 | zoomChanged || 245 | lastPosition.distanceToSquared(scope.object.position) > EPS || 246 | 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS 247 | ) { 248 | scope.dispatchEvent(changeEvent); 249 | 250 | lastPosition.copy(scope.object.position); 251 | lastQuaternion.copy(scope.object.quaternion); 252 | zoomChanged = false; 253 | 254 | return true; 255 | } 256 | 257 | return false; 258 | }; 259 | })(); 260 | 261 | this.dispose = function () { 262 | scope.domElement.removeEventListener("contextmenu", onContextMenu, false); 263 | 264 | scope.domElement.removeEventListener("pointerdown", onPointerDown, false); 265 | scope.domElement.removeEventListener("wheel", onMouseWheel, false); 266 | 267 | scope.domElement.removeEventListener("touchstart", onTouchStart, false); 268 | scope.domElement.removeEventListener("touchend", onTouchEnd, false); 269 | scope.domElement.removeEventListener("touchmove", onTouchMove, false); 270 | 271 | scope.domElement.ownerDocument.removeEventListener( 272 | "pointermove", 273 | onPointerMove, 274 | false 275 | ); 276 | scope.domElement.ownerDocument.removeEventListener( 277 | "pointerup", 278 | onPointerUp, 279 | false 280 | ); 281 | 282 | scope.domElement.removeEventListener("keydown", onKeyDown, false); 283 | 284 | //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? 285 | }; 286 | 287 | // 288 | // internals 289 | // 290 | 291 | var scope = this; 292 | 293 | var changeEvent = { type: "change" }; 294 | var startEvent = { type: "start" }; 295 | var endEvent = { type: "end" }; 296 | 297 | var STATE = { 298 | NONE: -1, 299 | ROTATE: 0, 300 | DOLLY: 1, 301 | PAN: 2, 302 | TOUCH_ROTATE: 3, 303 | TOUCH_PAN: 4, 304 | TOUCH_DOLLY_PAN: 5, 305 | TOUCH_DOLLY_ROTATE: 6, 306 | }; 307 | 308 | var state = STATE.NONE; 309 | 310 | var EPS = 0.000001; 311 | 312 | // current position in spherical coordinates 313 | var spherical = new Spherical(); 314 | var sphericalDelta = new Spherical(); 315 | 316 | var scale = 1; 317 | var panOffset = new Vector3(); 318 | var zoomChanged = false; 319 | 320 | var rotateStart = new Vector2(); 321 | var rotateEnd = new Vector2(); 322 | var rotateDelta = new Vector2(); 323 | 324 | var panStart = new Vector2(); 325 | var panEnd = new Vector2(); 326 | var panDelta = new Vector2(); 327 | 328 | var dollyStart = new Vector2(); 329 | var dollyEnd = new Vector2(); 330 | var dollyDelta = new Vector2(); 331 | 332 | function getAutoRotationAngle() { 333 | return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed; 334 | } 335 | 336 | function getZoomScale() { 337 | return Math.pow(0.95, scope.zoomSpeed); 338 | } 339 | 340 | function rotateLeft(angle) { 341 | sphericalDelta.theta -= angle; 342 | } 343 | 344 | function rotateUp(angle) { 345 | sphericalDelta.phi -= angle; 346 | } 347 | 348 | var panLeft = (function () { 349 | var v = new Vector3(); 350 | 351 | return function panLeft(distance, objectMatrix) { 352 | v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix 353 | v.multiplyScalar(-distance); 354 | 355 | panOffset.add(v); 356 | }; 357 | })(); 358 | 359 | var panUp = (function () { 360 | var v = new Vector3(); 361 | 362 | return function panUp(distance, objectMatrix) { 363 | if (scope.screenSpacePanning === true) { 364 | v.setFromMatrixColumn(objectMatrix, 1); 365 | } else { 366 | v.setFromMatrixColumn(objectMatrix, 0); 367 | v.crossVectors(scope.object.up, v); 368 | } 369 | 370 | v.multiplyScalar(distance); 371 | 372 | panOffset.add(v); 373 | }; 374 | })(); 375 | 376 | // deltaX and deltaY are in pixels; right and down are positive 377 | var pan = (function () { 378 | var offset = new Vector3(); 379 | 380 | return function pan(deltaX, deltaY) { 381 | var element = scope.domElement; 382 | 383 | if (scope.object.isPerspectiveCamera) { 384 | // perspective 385 | var position = scope.object.position; 386 | offset.copy(position).sub(scope.target); 387 | var targetDistance = offset.length(); 388 | 389 | // half of the fov is center to top of screen 390 | targetDistance *= Math.tan(((scope.object.fov / 2) * Math.PI) / 180.0); 391 | 392 | // we use only clientHeight here so aspect ratio does not distort speed 393 | panLeft( 394 | (2 * deltaX * targetDistance) / element.clientHeight, 395 | scope.object.matrix 396 | ); 397 | panUp( 398 | (2 * deltaY * targetDistance) / element.clientHeight, 399 | scope.object.matrix 400 | ); 401 | } else if (scope.object.isOrthographicCamera) { 402 | // orthographic 403 | panLeft( 404 | (deltaX * (scope.object.right - scope.object.left)) / 405 | scope.object.zoom / 406 | element.clientWidth, 407 | scope.object.matrix 408 | ); 409 | panUp( 410 | (deltaY * (scope.object.top - scope.object.bottom)) / 411 | scope.object.zoom / 412 | element.clientHeight, 413 | scope.object.matrix 414 | ); 415 | } else { 416 | // camera neither orthographic nor perspective 417 | console.warn( 418 | "WARNING: OrbitControls.js encountered an unknown camera type - pan disabled." 419 | ); 420 | scope.enablePan = false; 421 | } 422 | }; 423 | })(); 424 | 425 | function dollyOut(dollyScale) { 426 | if (scope.object.isPerspectiveCamera) { 427 | scale /= dollyScale; 428 | } else if (scope.object.isOrthographicCamera) { 429 | scope.object.zoom = Math.max( 430 | scope.minZoom, 431 | Math.min(scope.maxZoom, scope.object.zoom * dollyScale) 432 | ); 433 | scope.object.updateProjectionMatrix(); 434 | zoomChanged = true; 435 | } else { 436 | console.warn( 437 | "WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled." 438 | ); 439 | scope.enableZoom = false; 440 | } 441 | } 442 | 443 | function dollyIn(dollyScale) { 444 | if (scope.object.isPerspectiveCamera) { 445 | scale *= dollyScale; 446 | } else if (scope.object.isOrthographicCamera) { 447 | scope.object.zoom = Math.max( 448 | scope.minZoom, 449 | Math.min(scope.maxZoom, scope.object.zoom / dollyScale) 450 | ); 451 | scope.object.updateProjectionMatrix(); 452 | zoomChanged = true; 453 | } else { 454 | console.warn( 455 | "WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled." 456 | ); 457 | scope.enableZoom = false; 458 | } 459 | } 460 | 461 | // 462 | // event callbacks - update the object state 463 | // 464 | 465 | function handleMouseDownRotate(event) { 466 | rotateStart.set(event.clientX, event.clientY); 467 | } 468 | 469 | function handleMouseDownDolly(event) { 470 | dollyStart.set(event.clientX, event.clientY); 471 | } 472 | 473 | function handleMouseDownPan(event) { 474 | panStart.set(event.clientX, event.clientY); 475 | } 476 | 477 | function handleMouseMoveRotate(event) { 478 | rotateEnd.set(event.clientX, event.clientY); 479 | 480 | rotateDelta 481 | .subVectors(rotateEnd, rotateStart) 482 | .multiplyScalar(scope.rotateSpeed); 483 | 484 | var element = scope.domElement; 485 | 486 | rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight); // yes, height 487 | 488 | rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight); 489 | 490 | rotateStart.copy(rotateEnd); 491 | 492 | scope.update(); 493 | } 494 | 495 | function handleMouseMoveDolly(event) { 496 | dollyEnd.set(event.clientX, event.clientY); 497 | 498 | dollyDelta.subVectors(dollyEnd, dollyStart); 499 | 500 | if (dollyDelta.y > 0) { 501 | dollyOut(getZoomScale()); 502 | } else if (dollyDelta.y < 0) { 503 | dollyIn(getZoomScale()); 504 | } 505 | 506 | dollyStart.copy(dollyEnd); 507 | 508 | scope.update(); 509 | } 510 | 511 | function handleMouseMovePan(event) { 512 | panEnd.set(event.clientX, event.clientY); 513 | 514 | panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); 515 | 516 | pan(panDelta.x, panDelta.y); 517 | 518 | panStart.copy(panEnd); 519 | 520 | scope.update(); 521 | } 522 | 523 | function handleMouseUp(/*event*/) { 524 | // no-op 525 | } 526 | 527 | function handleMouseWheel(event) { 528 | if (event.deltaY < 0) { 529 | dollyIn(getZoomScale()); 530 | } else if (event.deltaY > 0) { 531 | dollyOut(getZoomScale()); 532 | } 533 | 534 | scope.update(); 535 | } 536 | 537 | function handleKeyDown(event) { 538 | var needsUpdate = false; 539 | 540 | switch (event.keyCode) { 541 | case scope.keys.UP: 542 | pan(0, scope.keyPanSpeed); 543 | needsUpdate = true; 544 | break; 545 | 546 | case scope.keys.BOTTOM: 547 | pan(0, -scope.keyPanSpeed); 548 | needsUpdate = true; 549 | break; 550 | 551 | case scope.keys.LEFT: 552 | pan(scope.keyPanSpeed, 0); 553 | needsUpdate = true; 554 | break; 555 | 556 | case scope.keys.RIGHT: 557 | pan(-scope.keyPanSpeed, 0); 558 | needsUpdate = true; 559 | break; 560 | } 561 | 562 | if (needsUpdate) { 563 | // prevent the browser from scrolling on cursor keys 564 | event.preventDefault(); 565 | 566 | scope.update(); 567 | } 568 | } 569 | 570 | function handleTouchStartRotate(event) { 571 | if (event.touches.length == 1) { 572 | rotateStart.set(event.touches[0].pageX, event.touches[0].pageY); 573 | } else { 574 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); 575 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); 576 | 577 | rotateStart.set(x, y); 578 | } 579 | } 580 | 581 | function handleTouchStartPan(event) { 582 | if (event.touches.length == 1) { 583 | panStart.set(event.touches[0].pageX, event.touches[0].pageY); 584 | } else { 585 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); 586 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); 587 | 588 | panStart.set(x, y); 589 | } 590 | } 591 | 592 | function handleTouchStartDolly(event) { 593 | var dx = event.touches[0].pageX - event.touches[1].pageX; 594 | var dy = event.touches[0].pageY - event.touches[1].pageY; 595 | 596 | var distance = Math.sqrt(dx * dx + dy * dy); 597 | 598 | dollyStart.set(0, distance); 599 | } 600 | 601 | function handleTouchStartDollyPan(event) { 602 | if (scope.enableZoom) handleTouchStartDolly(event); 603 | 604 | if (scope.enablePan) handleTouchStartPan(event); 605 | } 606 | 607 | function handleTouchStartDollyRotate(event) { 608 | if (scope.enableZoom) handleTouchStartDolly(event); 609 | 610 | if (scope.enableRotate) handleTouchStartRotate(event); 611 | } 612 | 613 | function handleTouchMoveRotate(event) { 614 | if (event.touches.length == 1) { 615 | rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY); 616 | } else { 617 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); 618 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); 619 | 620 | rotateEnd.set(x, y); 621 | } 622 | 623 | rotateDelta 624 | .subVectors(rotateEnd, rotateStart) 625 | .multiplyScalar(scope.rotateSpeed); 626 | 627 | var element = scope.domElement; 628 | 629 | rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight); // yes, height 630 | 631 | rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight); 632 | 633 | rotateStart.copy(rotateEnd); 634 | } 635 | 636 | function handleTouchMovePan(event) { 637 | if (event.touches.length == 1) { 638 | panEnd.set(event.touches[0].pageX, event.touches[0].pageY); 639 | } else { 640 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); 641 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); 642 | 643 | panEnd.set(x, y); 644 | } 645 | 646 | panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); 647 | 648 | pan(panDelta.x, panDelta.y); 649 | 650 | panStart.copy(panEnd); 651 | } 652 | 653 | function handleTouchMoveDolly(event) { 654 | var dx = event.touches[0].pageX - event.touches[1].pageX; 655 | var dy = event.touches[0].pageY - event.touches[1].pageY; 656 | 657 | var distance = Math.sqrt(dx * dx + dy * dy); 658 | 659 | dollyEnd.set(0, distance); 660 | 661 | dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed)); 662 | 663 | dollyOut(dollyDelta.y); 664 | 665 | dollyStart.copy(dollyEnd); 666 | } 667 | 668 | function handleTouchMoveDollyPan(event) { 669 | if (scope.enableZoom) handleTouchMoveDolly(event); 670 | 671 | if (scope.enablePan) handleTouchMovePan(event); 672 | } 673 | 674 | function handleTouchMoveDollyRotate(event) { 675 | if (scope.enableZoom) handleTouchMoveDolly(event); 676 | 677 | if (scope.enableRotate) handleTouchMoveRotate(event); 678 | } 679 | 680 | function handleTouchEnd(/*event*/) { 681 | // no-op 682 | } 683 | 684 | // 685 | // event handlers - FSM: listen for events and reset state 686 | // 687 | 688 | function onPointerDown(event) { 689 | if (scope.enabled === false) return; 690 | 691 | switch (event.pointerType) { 692 | case "mouse": 693 | case "pen": 694 | onMouseDown(event); 695 | break; 696 | 697 | // TODO touch 698 | } 699 | } 700 | 701 | function onPointerMove(event) { 702 | if (scope.enabled === false) return; 703 | 704 | switch (event.pointerType) { 705 | case "mouse": 706 | case "pen": 707 | onMouseMove(event); 708 | break; 709 | 710 | // TODO touch 711 | } 712 | } 713 | 714 | function onPointerUp(event) { 715 | switch (event.pointerType) { 716 | case "mouse": 717 | case "pen": 718 | onMouseUp(event); 719 | break; 720 | 721 | // TODO touch 722 | } 723 | } 724 | 725 | function onMouseDown(event) { 726 | // Prevent the browser from scrolling. 727 | event.preventDefault(); 728 | 729 | // Manually set the focus since calling preventDefault above 730 | // prevents the browser from setting it automatically. 731 | 732 | scope.domElement.focus ? scope.domElement.focus() : window.focus(); 733 | 734 | var mouseAction; 735 | 736 | switch (event.button) { 737 | case 0: 738 | mouseAction = scope.mouseButtons.LEFT; 739 | break; 740 | 741 | case 1: 742 | mouseAction = scope.mouseButtons.MIDDLE; 743 | break; 744 | 745 | case 2: 746 | mouseAction = scope.mouseButtons.RIGHT; 747 | break; 748 | 749 | default: 750 | mouseAction = -1; 751 | } 752 | 753 | switch (mouseAction) { 754 | case MOUSE.DOLLY: 755 | if (scope.enableZoom === false) return; 756 | 757 | handleMouseDownDolly(event); 758 | 759 | state = STATE.DOLLY; 760 | 761 | break; 762 | 763 | case MOUSE.ROTATE: 764 | if (event.ctrlKey || event.metaKey || event.shiftKey) { 765 | if (scope.enablePan === false) return; 766 | 767 | handleMouseDownPan(event); 768 | 769 | state = STATE.PAN; 770 | } else { 771 | if (scope.enableRotate === false) return; 772 | 773 | handleMouseDownRotate(event); 774 | 775 | state = STATE.ROTATE; 776 | } 777 | 778 | break; 779 | 780 | case MOUSE.PAN: 781 | if (event.ctrlKey || event.metaKey || event.shiftKey) { 782 | if (scope.enableRotate === false) return; 783 | 784 | handleMouseDownRotate(event); 785 | 786 | state = STATE.ROTATE; 787 | } else { 788 | if (scope.enablePan === false) return; 789 | 790 | handleMouseDownPan(event); 791 | 792 | state = STATE.PAN; 793 | } 794 | 795 | break; 796 | 797 | default: 798 | state = STATE.NONE; 799 | } 800 | 801 | if (state !== STATE.NONE) { 802 | scope.domElement.ownerDocument.addEventListener( 803 | "pointermove", 804 | onPointerMove, 805 | false 806 | ); 807 | scope.domElement.ownerDocument.addEventListener( 808 | "pointerup", 809 | onPointerUp, 810 | false 811 | ); 812 | 813 | scope.dispatchEvent(startEvent); 814 | } 815 | } 816 | 817 | function onMouseMove(event) { 818 | if (scope.enabled === false) return; 819 | 820 | event.preventDefault(); 821 | 822 | switch (state) { 823 | case STATE.ROTATE: 824 | if (scope.enableRotate === false) return; 825 | 826 | handleMouseMoveRotate(event); 827 | 828 | break; 829 | 830 | case STATE.DOLLY: 831 | if (scope.enableZoom === false) return; 832 | 833 | handleMouseMoveDolly(event); 834 | 835 | break; 836 | 837 | case STATE.PAN: 838 | if (scope.enablePan === false) return; 839 | 840 | handleMouseMovePan(event); 841 | 842 | break; 843 | } 844 | } 845 | 846 | function onMouseUp(event) { 847 | scope.domElement.ownerDocument.removeEventListener( 848 | "pointermove", 849 | onPointerMove, 850 | false 851 | ); 852 | scope.domElement.ownerDocument.removeEventListener( 853 | "pointerup", 854 | onPointerUp, 855 | false 856 | ); 857 | 858 | if (scope.enabled === false) return; 859 | 860 | handleMouseUp(event); 861 | 862 | scope.dispatchEvent(endEvent); 863 | 864 | state = STATE.NONE; 865 | } 866 | 867 | function onMouseWheel(event) { 868 | if ( 869 | scope.enabled === false || 870 | scope.enableZoom === false || 871 | (state !== STATE.NONE && state !== STATE.ROTATE) 872 | ) 873 | return; 874 | 875 | event.preventDefault(); 876 | event.stopPropagation(); 877 | 878 | scope.dispatchEvent(startEvent); 879 | 880 | handleMouseWheel(event); 881 | 882 | scope.dispatchEvent(endEvent); 883 | } 884 | 885 | function onKeyDown(event) { 886 | if ( 887 | scope.enabled === false || 888 | scope.enableKeys === false || 889 | scope.enablePan === false 890 | ) 891 | return; 892 | 893 | handleKeyDown(event); 894 | } 895 | 896 | function onTouchStart(event) { 897 | if (scope.enabled === false) return; 898 | 899 | event.preventDefault(); // prevent scrolling 900 | 901 | switch (event.touches.length) { 902 | case 1: 903 | switch (scope.touches.ONE) { 904 | case TOUCH.ROTATE: 905 | if (scope.enableRotate === false) return; 906 | 907 | handleTouchStartRotate(event); 908 | 909 | state = STATE.TOUCH_ROTATE; 910 | 911 | break; 912 | 913 | case TOUCH.PAN: 914 | if (scope.enablePan === false) return; 915 | 916 | handleTouchStartPan(event); 917 | 918 | state = STATE.TOUCH_PAN; 919 | 920 | break; 921 | 922 | default: 923 | state = STATE.NONE; 924 | } 925 | 926 | break; 927 | 928 | case 2: 929 | switch (scope.touches.TWO) { 930 | case TOUCH.DOLLY_PAN: 931 | if (scope.enableZoom === false && scope.enablePan === false) return; 932 | 933 | handleTouchStartDollyPan(event); 934 | 935 | state = STATE.TOUCH_DOLLY_PAN; 936 | 937 | break; 938 | 939 | case TOUCH.DOLLY_ROTATE: 940 | if (scope.enableZoom === false && scope.enableRotate === false) 941 | return; 942 | 943 | handleTouchStartDollyRotate(event); 944 | 945 | state = STATE.TOUCH_DOLLY_ROTATE; 946 | 947 | break; 948 | 949 | default: 950 | state = STATE.NONE; 951 | } 952 | 953 | break; 954 | 955 | default: 956 | state = STATE.NONE; 957 | } 958 | 959 | if (state !== STATE.NONE) { 960 | scope.dispatchEvent(startEvent); 961 | } 962 | } 963 | 964 | function onTouchMove(event) { 965 | if (scope.enabled === false) return; 966 | 967 | event.preventDefault(); // prevent scrolling 968 | event.stopPropagation(); 969 | 970 | switch (state) { 971 | case STATE.TOUCH_ROTATE: 972 | if (scope.enableRotate === false) return; 973 | 974 | handleTouchMoveRotate(event); 975 | 976 | scope.update(); 977 | 978 | break; 979 | 980 | case STATE.TOUCH_PAN: 981 | if (scope.enablePan === false) return; 982 | 983 | handleTouchMovePan(event); 984 | 985 | scope.update(); 986 | 987 | break; 988 | 989 | case STATE.TOUCH_DOLLY_PAN: 990 | if (scope.enableZoom === false && scope.enablePan === false) return; 991 | 992 | handleTouchMoveDollyPan(event); 993 | 994 | scope.update(); 995 | 996 | break; 997 | 998 | case STATE.TOUCH_DOLLY_ROTATE: 999 | if (scope.enableZoom === false && scope.enableRotate === false) return; 1000 | 1001 | handleTouchMoveDollyRotate(event); 1002 | 1003 | scope.update(); 1004 | 1005 | break; 1006 | 1007 | default: 1008 | state = STATE.NONE; 1009 | } 1010 | } 1011 | 1012 | function onTouchEnd(event) { 1013 | if (scope.enabled === false) return; 1014 | 1015 | handleTouchEnd(event); 1016 | 1017 | scope.dispatchEvent(endEvent); 1018 | 1019 | state = STATE.NONE; 1020 | } 1021 | 1022 | function onContextMenu(event) { 1023 | if (scope.enabled === false) return; 1024 | 1025 | event.preventDefault(); 1026 | } 1027 | 1028 | // 1029 | 1030 | scope.domElement.addEventListener("contextmenu", onContextMenu, false); 1031 | 1032 | scope.domElement.addEventListener("pointerdown", onPointerDown, false); 1033 | scope.domElement.addEventListener("wheel", onMouseWheel, false); 1034 | 1035 | scope.domElement.addEventListener("touchstart", onTouchStart, false); 1036 | scope.domElement.addEventListener("touchend", onTouchEnd, false); 1037 | scope.domElement.addEventListener("touchmove", onTouchMove, false); 1038 | 1039 | scope.domElement.addEventListener("keydown", onKeyDown, false); 1040 | 1041 | // force an update at start 1042 | 1043 | this.update(); 1044 | }; 1045 | 1046 | OrbitControls.prototype = Object.create(EventDispatcher.prototype); 1047 | OrbitControls.prototype.constructor = OrbitControls; 1048 | 1049 | // This set of controls performs orbiting, dollying (zooming), and panning. 1050 | // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). 1051 | // This is very similar to OrbitControls, another set of touch behavior 1052 | // 1053 | // Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate 1054 | // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish 1055 | // Pan - left mouse, or arrow keys / touch: one-finger move 1056 | 1057 | var MapControls = function (object, domElement) { 1058 | OrbitControls.call(this, object, domElement); 1059 | 1060 | this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up 1061 | 1062 | this.mouseButtons.LEFT = MOUSE.PAN; 1063 | this.mouseButtons.RIGHT = MOUSE.ROTATE; 1064 | 1065 | this.touches.ONE = TOUCH.PAN; 1066 | this.touches.TWO = TOUCH.DOLLY_ROTATE; 1067 | }; 1068 | 1069 | MapControls.prototype = Object.create(EventDispatcher.prototype); 1070 | MapControls.prototype.constructor = MapControls; 1071 | 1072 | export { OrbitControls, MapControls }; 1073 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ourobouros, the GPU version 2 | 3 | This is a GPU "port" of https://twitter.com/MAKIO135/status/1383163336905396225, a hic et nunc NFT by https://twitter.com/MAKIO135. The original NFT is a capture of https://observablehq.com/d/0f7ad63e053a2787?ui=classic, which was a bit sluggish. So we took it as a challenge to move it to the GPU, as an example of how to deal with parametric curves in a vertex shader. 4 | 5 | Here's the thread with some more details https://twitter.com/thespite/status/1383359844497825792 6 | 7 | 8 | -------------------------------------------------------------------------------- /fragment-shader.js: -------------------------------------------------------------------------------- 1 | const shader = `#version 300 es 2 | precision highp float; 3 | 4 | // texture uniforms. 5 | uniform sampler2D matCapMap; 6 | 7 | // varyings. 8 | in vec3 pos; 9 | in vec3 normal; 10 | 11 | // output. 12 | out vec4 color; 13 | 14 | void main() { 15 | // calculate matcap coordinates. 16 | vec3 n = normalize(normal); 17 | vec3 eye = normalize(pos.xyz); 18 | vec3 r = reflect( eye, normal ); 19 | float m = 2. * sqrt( pow( r.x, 2. ) + pow( r.y, 2. ) + pow( r.z + 1., 2. ) ); 20 | vec2 vN = r.xy / m + .5; 21 | 22 | // lookup matcap. 23 | vec3 mat = texture(matCapMap, vN).rgb; 24 | 25 | // return matcap. 26 | color = vec4(mat, 1.); 27 | 28 | // return normal. 29 | // color = vec4(.5 + .5 * n, 1.); 30 | } 31 | `; 32 | 33 | export { shader }; 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ouroboros GPU 5 | 6 | 7 | 11 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | import { 2 | Scene, 3 | TextureLoader, 4 | Vector3, 5 | Mesh, 6 | WebGLRenderer, 7 | PerspectiveCamera, 8 | RawShaderMaterial, 9 | BufferAttribute, 10 | BufferGeometry, 11 | } from "./three.module.js"; 12 | import { OrbitControls } from "./OrbitControls.js"; 13 | import { shader as vertexShader } from "./vertex-shader.js"; 14 | import { shader as fragmentShader } from "./fragment-shader.js"; 15 | 16 | // Create renderer, camera, attach events, controls, etc. 17 | const renderer = new WebGLRenderer({ antialias: true }); 18 | renderer.setSize(1, 1); 19 | document.body.append(renderer.domElement); 20 | 21 | const camera = new PerspectiveCamera(75, 1, 0.1, 150); 22 | camera.position.set(0, 20, 30).multiplyScalar(1.05); 23 | camera.lookAt(new Vector3(0, 0, 10)); 24 | 25 | const controls = new OrbitControls(camera, renderer.domElement); 26 | 27 | const scene = new Scene(); 28 | 29 | function onResize() { 30 | renderer.setSize(window.innerWidth, window.innerHeight); 31 | camera.aspect = window.innerWidth / window.innerHeight; 32 | camera.updateProjectionMatrix(); 33 | } 34 | 35 | onResize(); 36 | 37 | window.addEventListener("resize", onResize); 38 | 39 | // We generate a geometry that will hold information for the vertex shader 40 | // to generate the shape we want. It'll be a closed circle of SEGMENTS segments, 41 | // and each segment a ring of SIDES sides. 42 | 43 | const SIDES = 20; 44 | const SEGMENTS = 200; 45 | const geometry = new BufferGeometry(); 46 | 47 | const indices = []; 48 | const vertices = new Float32Array(SIDES * SEGMENTS * 3); 49 | 50 | // We assign the values we need, instead of (x, y, z) values: 51 | // x is the segment number [0, SEGMENTS-1] 52 | // y is the side number [0, SIDES-1] 53 | // z is not used but we leave it empty. 54 | // We could use only a vec2, but BufferGeometry.computeBoundingSphere doesn't like it. 55 | let ptr = 0; 56 | for (let segment = 0; segment < SEGMENTS; segment++) { 57 | for (let side = 0; side < SIDES; side++) { 58 | vertices[ptr] = segment; 59 | vertices[ptr + 1] = side; 60 | vertices[ptr + 2] = 0; 61 | ptr += 3; 62 | } 63 | } 64 | 65 | // We generate the indices for each triangle. 66 | const MAX = SEGMENTS * SIDES; 67 | for (let segment = 0; segment < SEGMENTS + 1; segment++) { 68 | for (let f = 0; f < SIDES; f++) { 69 | const a = (segment * SIDES + ((f + 1) % SIDES)) % MAX; 70 | const b = (segment * SIDES + f) % MAX; 71 | const c = (segment * SIDES + f + SIDES) % MAX; 72 | const d = (segment * SIDES + ((f + 1) % SIDES) + SIDES) % MAX; 73 | 74 | indices.push(a, b, d); 75 | indices.push(b, c, d); 76 | } 77 | } 78 | geometry.setIndex(indices); 79 | geometry.setAttribute("position", new BufferAttribute(vertices, 3)); 80 | 81 | // Load textures. 82 | const loader = new TextureLoader(); 83 | const matcap1 = loader.load("matcap1.jpg"); 84 | const matcap2 = loader.load("matcap2.png"); 85 | const matcap3 = loader.load("matcap3.png"); 86 | 87 | const TAU = 2 * Math.PI; 88 | 89 | // Generate a few meshes, rotated around the y axis. 90 | // We generate a new material for each because they have different matcaps, 91 | // and they have different index values for the animation. 92 | // It's still just a few draw calls, several orders of magnitude fewer than originally. 93 | const meshes = []; 94 | const PARTS = 9; 95 | for (let i = 0; i < PARTS; i++) { 96 | const geoMat = new RawShaderMaterial({ 97 | uniforms: { 98 | SEGMENTS: { value: SEGMENTS }, 99 | SIDES: { value: SIDES }, 100 | matCapMap: { 101 | value: i % 3 === 0 ? matcap1 : i % 3 === 1 ? matcap2 : matcap3, 102 | }, 103 | time: { value: 0 }, 104 | index: { value: i }, 105 | }, 106 | vertexShader, 107 | fragmentShader, 108 | // wireframe: true, 109 | }); 110 | const angle = (i * TAU) / PARTS; 111 | const t = new Mesh(geometry, geoMat); 112 | scene.add(t); 113 | meshes.push(t); 114 | } 115 | 116 | // Render. Assigns the time to each material every frame and draws. 117 | function render() { 118 | meshes.forEach((m) => { 119 | m.material.uniforms.time.value = 0.0005 * performance.now(); 120 | }); 121 | renderer.setAnimationLoop(render); 122 | renderer.render(scene, camera); 123 | } 124 | 125 | // Start rendering. 126 | render(); 127 | -------------------------------------------------------------------------------- /matcap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spite/makio-torus/0c12129b077a5dff5fbf20cd08872c4ed96a96a1/matcap.png -------------------------------------------------------------------------------- /matcap1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spite/makio-torus/0c12129b077a5dff5fbf20cd08872c4ed96a96a1/matcap1.jpg -------------------------------------------------------------------------------- /matcap2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spite/makio-torus/0c12129b077a5dff5fbf20cd08872c4ed96a96a1/matcap2.png -------------------------------------------------------------------------------- /matcap3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spite/makio-torus/0c12129b077a5dff5fbf20cd08872c4ed96a96a1/matcap3.png -------------------------------------------------------------------------------- /vertex-shader.js: -------------------------------------------------------------------------------- 1 | const shader = `#version 300 es 2 | precision highp float; 3 | 4 | // attributes. 5 | in vec3 position; 6 | 7 | // uniforms for vertex transformation. 8 | uniform mat4 projectionMatrix; 9 | uniform mat4 modelViewMatrix; 10 | uniform mat3 normalMatrix; 11 | 12 | // uniforms for the effect. 13 | uniform float SEGMENTS; 14 | uniform float SIDES; 15 | uniform float time; 16 | uniform float index; 17 | 18 | // varyings. 19 | out vec3 pos; 20 | out vec3 normal; 21 | 22 | const float PI = 3.1415926535897932384626433832795; 23 | const float TAU = 2. * PI; 24 | 25 | // creates a quaternion out of an axis vector and a rotation angle. 26 | vec4 quat(vec3 axis, float angle) { 27 | float halfAngle = angle / 2.; 28 | float s = sin( halfAngle ); 29 | 30 | vec4 q = vec4(axis.x * s, axis.y * s, axis.z * s, cos( halfAngle )); 31 | return q; 32 | } 33 | 34 | // applies a quaternion q to a vec3 v. 35 | vec3 applyQuat( vec4 q, vec3 v ){ 36 | return v + 2.0*cross(cross(v, q.xyz ) + q.w*v, q.xyz); 37 | } 38 | 39 | // returns the base point to generate a ring around. 40 | vec3 getBasePoint(float alpha) { 41 | float r = 17.; 42 | vec3 p = vec3(r * cos(alpha), 0., r * sin(alpha)); 43 | vec3 dir = vec3(cos(alpha+PI/2.), 0., sin(alpha+PI/2.)); 44 | 45 | float a = 2.*alpha; 46 | a += index/9. * TAU; 47 | p += applyQuat(quat(dir, a), vec3(0., 6., 0.)); 48 | p.y += sin(alpha * 3.) * 4.; 49 | return p; 50 | } 51 | 52 | void main() { 53 | // get the base point, and calculate the orientation of the ring dir. 54 | float alpha = TAU * position.x / SEGMENTS; 55 | vec3 base = getBasePoint(alpha); 56 | vec3 prevBase = getBasePoint(alpha - TAU / SEGMENTS); 57 | vec3 dir = normalize(base - prevBase); 58 | 59 | // calculate the radius based on the effect. 60 | float beta = TAU * position.y / SIDES; 61 | float animStep = mod(3.*position.x + time, SEGMENTS) / SEGMENTS; 62 | float tubeRadius = max(0., pow(sin(alpha * 3. + (1. - time) * TAU) + 1.2, 2.)) * 0.3; 63 | 64 | // distribute each side of the ring around the base point. 65 | vec3 tubeDir = tubeRadius * vec3(0., 1., 0.); 66 | tubeDir = applyQuat(quat(dir, beta), tubeDir); 67 | vec3 newPosition = base + tubeDir; 68 | 69 | // the normal is the direction we pulled the vertex. 70 | normal = normalMatrix * normalize(tubeDir); 71 | 72 | // project the position. 73 | vec4 mvp = modelViewMatrix * vec4(newPosition, 1.); 74 | pos = mvp.xyz; 75 | gl_Position = projectionMatrix * mvp; 76 | } 77 | `; 78 | 79 | export { shader }; 80 | --------------------------------------------------------------------------------