├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── screenshot.png ├── src ├── assets │ ├── models │ │ └── DamagedHelmet.glb │ └── textures │ │ └── pisa.jpg ├── controls │ └── OrbitControls.js ├── index.js ├── loader │ ├── index.js │ └── resolvers │ │ ├── GLTFResolver.js │ │ ├── ImageResolver.js │ │ └── TextureResolver.js └── objects │ └── Helmet.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | #system 2 | *.log 3 | .DS_Store 4 | 5 | #node 6 | node_modules 7 | 8 | # build 9 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | threejs-starter-kit 2 | =================== 3 | 4 | My current worlflow for quick Three.js prototypes. 5 | 6 | ![screenshot](/screenshot.png) 7 | 8 | ## Usage 9 | After cloning install all node dependencies 10 | ```bash 11 | npm i 12 | ``` 13 | 14 | Then launch the main task to open the livereload server 15 | ```bash 16 | npm start 17 | ``` 18 | 19 | You are good to go ! 20 | 21 | ## Deployment 22 | ```bash 23 | npm run build 24 | ``` 25 | Then put the content of the `dist` folder on your server. 26 | You can also just run use [now](https://zeit.co/now) for a quick deployment. 27 | 28 | I usually include debug tools only in development mode. This can be done by using the `DEVELOPMENT` environment variable that is set by wepack. 29 | ```js 30 | if (DEVELOPMENT) { 31 | const gui = require('guigui') // will not be included in production 32 | } 33 | ``` 34 | 35 | ## Features 36 | - ES6 with [Babel](http://babeljs.io) and [Webpack](https://webpack.org) 37 | - [Glslify](https://github.com/glslify/glslify) webpack loader 38 | - Postprocessing with [vanruesc/postprocessing](https://github.com/vanruesc/postprocessing) 39 | - My personnal [GUI](http://github.com/superguigui/guigui#dev) 40 | - Extandable asset loader 41 | - Environment variable to exclude debug stuff in production build 42 | - Basic config for [now](https://zeit.co/now) deployment 43 | - Simple setup with my ideal file structure 44 | 45 | ## File Structure and coding style 46 | I like to create "Objects" classes in `src/objects` that contain elements from my scene. They usually extend `THREE.Object3D` so that they can be added to a parent, have positions and rotations etc... I also sometime extend `THREE.Mesh` directly but it can be a bit restrictive since in that case you need to prepare all geometries and material in the constructor before the call to `super()` without being able to use `this`. 47 | 48 | Also i like to avoid using the `THREE` global keyword and instead I import only the components that I need from `three`. This is pointless (for now) but it might be useful in the tree-shaking future / alternate reality. 49 | ```js 50 | import { Object3D, Mesh, MeshBasicMaterial } from 'three' 51 | ``` 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-starter-kit", 3 | "version": "0.2.1", 4 | "description": "My workflow for threejs demos", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --open", 8 | "build": "webpack --env.prod" 9 | }, 10 | "repository": "superguigui/threejs-starter-kit", 11 | "author": "Guillaume Gouessan ", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/core": "^7.11.6", 15 | "@babel/plugin-transform-runtime": "^7.11.5", 16 | "@babel/preset-env": "^7.11.5", 17 | "babel-loader": "^8.1.0", 18 | "copy-webpack-plugin": "^6.1.1", 19 | "glslify-loader": "^2.0.0", 20 | "guigui": "^2.0.2", 21 | "html-webpack-plugin": "^4.5.0", 22 | "raw-loader": "^4.0.1", 23 | "stats.js": "^0.17.0", 24 | "webpack": "^4.44.2", 25 | "webpack-cli": "^3.3.12", 26 | "webpack-dev-server": "^3.11.0" 27 | }, 28 | "dependencies": { 29 | "postprocessing": "^6.17.3", 30 | "three": "^0.120.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superguigui/threejs-starter-kit/ec559f476af33d0d46fd7427261aff0a9194f02d/screenshot.png -------------------------------------------------------------------------------- /src/assets/models/DamagedHelmet.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superguigui/threejs-starter-kit/ec559f476af33d0d46fd7427261aff0a9194f02d/src/assets/models/DamagedHelmet.glb -------------------------------------------------------------------------------- /src/assets/textures/pisa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superguigui/threejs-starter-kit/ec559f476af33d0d46fd7427261aff0a9194f02d/src/assets/textures/pisa.jpg -------------------------------------------------------------------------------- /src/controls/OrbitControls.js: -------------------------------------------------------------------------------- 1 | import { Vector2, Vector3, Quaternion, Spherical, EventDispatcher, MOUSE } from 'three' 2 | 3 | /** 4 | * @author qiao / https://github.com/qiao 5 | * @author mrdoob / http://mrdoob.com 6 | * @author alteredq / http://alteredqualia.com/ 7 | * @author WestLangley / http://github.com/WestLangley 8 | * @author erich666 / http://erichaines.com 9 | */ 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 | const OrbitControls = function(object, domElement) { 19 | this.object = object 20 | 21 | this.domElement = domElement !== undefined ? domElement : document 22 | 23 | // Set to false to disable this control 24 | this.enabled = true 25 | 26 | // "target" sets the location of focus, where the object orbits around 27 | this.target = new Vector3() 28 | 29 | // How far you can dolly in and out ( PerspectiveCamera only ) 30 | this.minDistance = 0 31 | this.maxDistance = Infinity 32 | 33 | // How far you can zoom in and out ( OrthographicCamera only ) 34 | this.minZoom = 0 35 | this.maxZoom = Infinity 36 | 37 | // How far you can orbit vertically, upper and lower limits. 38 | // Range is 0 to Math.PI radians. 39 | this.minPolarAngle = 0 // radians 40 | this.maxPolarAngle = Math.PI // radians 41 | 42 | // How far you can orbit horizontally, upper and lower limits. 43 | // If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ]. 44 | this.minAzimuthAngle = -Infinity // radians 45 | this.maxAzimuthAngle = Infinity // radians 46 | 47 | // Set to true to enable damping (inertia) 48 | // If damping is enabled, you must call controls.update() in your animation loop 49 | this.enableDamping = false 50 | this.dampingFactor = 0.25 51 | 52 | // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. 53 | // Set to false to disable zooming 54 | this.enableZoom = true 55 | this.zoomSpeed = 1.0 56 | 57 | // Set to false to disable rotating 58 | this.enableRotate = true 59 | this.rotateSpeed = 1.0 60 | 61 | // Set to false to disable panning 62 | this.enablePan = true 63 | this.panSpeed = 1.0 64 | this.screenSpacePanning = false // if true, pan in screen-space 65 | this.keyPanSpeed = 7.0 // pixels moved per arrow key push 66 | 67 | // Set to true to automatically rotate around the target 68 | // If auto-rotate is enabled, you must call controls.update() in your animation loop 69 | this.autoRotate = false 70 | this.autoRotateSpeed = 2.0 // 30 seconds per round when fps is 60 71 | 72 | // Set to false to disable use of the keys 73 | this.enableKeys = true 74 | 75 | // The four arrow keys 76 | this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 } 77 | 78 | // Mouse buttons 79 | this.mouseButtons = { LEFT: MOUSE.LEFT, MIDDLE: MOUSE.MIDDLE, RIGHT: MOUSE.RIGHT } 80 | 81 | // for reset 82 | this.target0 = this.target.clone() 83 | this.position0 = this.object.position.clone() 84 | this.zoom0 = this.object.zoom 85 | 86 | // 87 | // public methods 88 | // 89 | 90 | this.getPolarAngle = function() { 91 | return spherical.phi 92 | } 93 | 94 | this.getAzimuthalAngle = function() { 95 | return spherical.theta 96 | } 97 | 98 | this.saveState = function() { 99 | scope.target0.copy(scope.target) 100 | scope.position0.copy(scope.object.position) 101 | scope.zoom0 = scope.object.zoom 102 | } 103 | 104 | this.reset = function() { 105 | scope.target.copy(scope.target0) 106 | scope.object.position.copy(scope.position0) 107 | scope.object.zoom = scope.zoom0 108 | 109 | scope.object.updateProjectionMatrix() 110 | scope.dispatchEvent(changeEvent) 111 | 112 | scope.update() 113 | 114 | state = STATE.NONE 115 | } 116 | 117 | // this method is exposed, but perhaps it would be better if we can make it private... 118 | this.update = (function() { 119 | var offset = new Vector3() 120 | 121 | // so camera.up is the orbit axis 122 | var quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)) 123 | var quatInverse = quat.clone().inverse() 124 | 125 | var lastPosition = new Vector3() 126 | var lastQuaternion = new Quaternion() 127 | 128 | return function update() { 129 | var position = scope.object.position 130 | 131 | offset.copy(position).sub(scope.target) 132 | 133 | // rotate offset to "y-axis-is-up" space 134 | offset.applyQuaternion(quat) 135 | 136 | // angle from z-axis around y-axis 137 | spherical.setFromVector3(offset) 138 | 139 | if (scope.autoRotate && state === STATE.NONE) { 140 | rotateLeft(getAutoRotationAngle()) 141 | } 142 | 143 | spherical.theta += sphericalDelta.theta 144 | spherical.phi += sphericalDelta.phi 145 | 146 | // restrict theta to be between desired limits 147 | spherical.theta = Math.max(scope.minAzimuthAngle, Math.min(scope.maxAzimuthAngle, spherical.theta)) 148 | 149 | // restrict phi to be between desired limits 150 | spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi)) 151 | 152 | spherical.makeSafe() 153 | 154 | spherical.radius *= scale 155 | 156 | // restrict radius to be between desired limits 157 | spherical.radius = Math.max(scope.minDistance, Math.min(scope.maxDistance, spherical.radius)) 158 | 159 | // move target to panned location 160 | scope.target.add(panOffset) 161 | 162 | offset.setFromSpherical(spherical) 163 | 164 | // rotate offset back to "camera-up-vector-is-up" space 165 | offset.applyQuaternion(quatInverse) 166 | 167 | position.copy(scope.target).add(offset) 168 | 169 | scope.object.lookAt(scope.target) 170 | 171 | if (scope.enableDamping === true) { 172 | sphericalDelta.theta *= 1 - scope.dampingFactor 173 | sphericalDelta.phi *= 1 - scope.dampingFactor 174 | 175 | panOffset.multiplyScalar(1 - scope.dampingFactor) 176 | } else { 177 | sphericalDelta.set(0, 0, 0) 178 | 179 | panOffset.set(0, 0, 0) 180 | } 181 | 182 | scale = 1 183 | 184 | // update condition is: 185 | // min(camera displacement, camera rotation in radians)^2 > EPS 186 | // using small-angle approximation cos(x/2) = 1 - x^2 / 8 187 | 188 | if ( 189 | zoomChanged || 190 | lastPosition.distanceToSquared(scope.object.position) > EPS || 191 | 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS 192 | ) { 193 | scope.dispatchEvent(changeEvent) 194 | 195 | lastPosition.copy(scope.object.position) 196 | lastQuaternion.copy(scope.object.quaternion) 197 | zoomChanged = false 198 | 199 | return true 200 | } 201 | 202 | return false 203 | } 204 | })() 205 | 206 | this.dispose = function() { 207 | this.stop() 208 | // scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? 209 | } 210 | 211 | this.stop = function() { 212 | scope.domElement.removeEventListener('contextmenu', onContextMenu, false) 213 | scope.domElement.removeEventListener('mousedown', onMouseDown, false) 214 | scope.domElement.removeEventListener('wheel', onMouseWheel, false) 215 | 216 | scope.domElement.removeEventListener('touchstart', onTouchStart, false) 217 | scope.domElement.removeEventListener('touchend', onTouchEnd, false) 218 | scope.domElement.removeEventListener('touchmove', onTouchMove, false) 219 | 220 | document.removeEventListener('mousemove', onMouseMove, false) 221 | document.removeEventListener('mouseup', onMouseUp, false) 222 | 223 | window.removeEventListener('keydown', onKeyDown, false) 224 | } 225 | 226 | this.start = function() { 227 | scope.domElement.addEventListener('contextmenu', onContextMenu, false) 228 | 229 | scope.domElement.addEventListener('mousedown', onMouseDown, false) 230 | scope.domElement.addEventListener('wheel', onMouseWheel, false) 231 | 232 | scope.domElement.addEventListener('touchstart', onTouchStart, false) 233 | scope.domElement.addEventListener('touchend', onTouchEnd, false) 234 | scope.domElement.addEventListener('touchmove', onTouchMove, false) 235 | 236 | window.addEventListener('keydown', onKeyDown, false) 237 | } 238 | 239 | // 240 | // internals 241 | // 242 | 243 | var scope = this 244 | 245 | var changeEvent = { type: 'change' } 246 | var startEvent = { type: 'start' } 247 | var endEvent = { type: 'end' } 248 | 249 | var STATE = { NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY_PAN: 4 } 250 | 251 | var state = STATE.NONE 252 | 253 | var EPS = 0.000001 254 | 255 | // current position in spherical coordinates 256 | var spherical = new Spherical() 257 | var sphericalDelta = new Spherical() 258 | 259 | var scale = 1 260 | var panOffset = new Vector3() 261 | var zoomChanged = false 262 | 263 | var rotateStart = new Vector2() 264 | var rotateEnd = new Vector2() 265 | var rotateDelta = new Vector2() 266 | 267 | var panStart = new Vector2() 268 | var panEnd = new Vector2() 269 | var panDelta = new Vector2() 270 | 271 | var dollyStart = new Vector2() 272 | var dollyEnd = new Vector2() 273 | var dollyDelta = new Vector2() 274 | 275 | function getAutoRotationAngle() { 276 | return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed 277 | } 278 | 279 | function getZoomScale() { 280 | return Math.pow(0.95, scope.zoomSpeed) 281 | } 282 | 283 | function rotateLeft(angle) { 284 | sphericalDelta.theta -= angle 285 | } 286 | 287 | function rotateUp(angle) { 288 | sphericalDelta.phi -= angle 289 | } 290 | 291 | var panLeft = (function() { 292 | var v = new Vector3() 293 | 294 | return function panLeft(distance, objectMatrix) { 295 | v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix 296 | v.multiplyScalar(-distance) 297 | 298 | panOffset.add(v) 299 | } 300 | })() 301 | 302 | var panUp = (function() { 303 | var v = new Vector3() 304 | 305 | return function panUp(distance, objectMatrix) { 306 | if (scope.screenSpacePanning === true) { 307 | v.setFromMatrixColumn(objectMatrix, 1) 308 | } else { 309 | v.setFromMatrixColumn(objectMatrix, 0) 310 | v.crossVectors(scope.object.up, v) 311 | } 312 | 313 | v.multiplyScalar(distance) 314 | 315 | panOffset.add(v) 316 | } 317 | })() 318 | 319 | // deltaX and deltaY are in pixels; right and down are positive 320 | var pan = (function() { 321 | var offset = new Vector3() 322 | 323 | return function pan(deltaX, deltaY) { 324 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement 325 | 326 | if (scope.object.isPerspectiveCamera) { 327 | // perspective 328 | var position = scope.object.position 329 | offset.copy(position).sub(scope.target) 330 | var targetDistance = offset.length() 331 | 332 | // half of the fov is center to top of screen 333 | targetDistance *= Math.tan(((scope.object.fov / 2) * Math.PI) / 180.0) 334 | 335 | // we use only clientHeight here so aspect ratio does not distort speed 336 | panLeft((2 * deltaX * targetDistance) / element.clientHeight, scope.object.matrix) 337 | panUp((2 * deltaY * targetDistance) / element.clientHeight, scope.object.matrix) 338 | } else if (scope.object.isOrthographicCamera) { 339 | // orthographic 340 | panLeft( 341 | (deltaX * (scope.object.right - scope.object.left)) / scope.object.zoom / element.clientWidth, 342 | scope.object.matrix 343 | ) 344 | panUp( 345 | (deltaY * (scope.object.top - scope.object.bottom)) / scope.object.zoom / element.clientHeight, 346 | scope.object.matrix 347 | ) 348 | } else { 349 | // camera neither orthographic nor perspective 350 | console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.') 351 | scope.enablePan = false 352 | } 353 | } 354 | })() 355 | 356 | function dollyIn(dollyScale) { 357 | if (scope.object.isPerspectiveCamera) { 358 | scale /= dollyScale 359 | } else if (scope.object.isOrthographicCamera) { 360 | scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom * dollyScale)) 361 | scope.object.updateProjectionMatrix() 362 | zoomChanged = true 363 | } else { 364 | console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.') 365 | scope.enableZoom = false 366 | } 367 | } 368 | 369 | function dollyOut(dollyScale) { 370 | if (scope.object.isPerspectiveCamera) { 371 | scale *= dollyScale 372 | } else if (scope.object.isOrthographicCamera) { 373 | scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / dollyScale)) 374 | scope.object.updateProjectionMatrix() 375 | zoomChanged = true 376 | } else { 377 | console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.') 378 | scope.enableZoom = false 379 | } 380 | } 381 | 382 | // 383 | // event callbacks - update the object state 384 | // 385 | 386 | function handleMouseDownRotate(event) { 387 | // console.log( 'handleMouseDownRotate' ) 388 | 389 | rotateStart.set(event.clientX, event.clientY) 390 | } 391 | 392 | function handleMouseDownDolly(event) { 393 | // console.log( 'handleMouseDownDolly' ) 394 | 395 | dollyStart.set(event.clientX, event.clientY) 396 | } 397 | 398 | function handleMouseDownPan(event) { 399 | // console.log( 'handleMouseDownPan' ) 400 | 401 | panStart.set(event.clientX, event.clientY) 402 | } 403 | 404 | function handleMouseMoveRotate(event) { 405 | // console.log( 'handleMouseMoveRotate' ) 406 | 407 | rotateEnd.set(event.clientX, event.clientY) 408 | 409 | rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed) 410 | 411 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement 412 | 413 | rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height 414 | 415 | rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight) 416 | 417 | rotateStart.copy(rotateEnd) 418 | 419 | scope.update() 420 | } 421 | 422 | function handleMouseMoveDolly(event) { 423 | // console.log( 'handleMouseMoveDolly' ) 424 | 425 | dollyEnd.set(event.clientX, event.clientY) 426 | 427 | dollyDelta.subVectors(dollyEnd, dollyStart) 428 | 429 | if (dollyDelta.y > 0) { 430 | dollyIn(getZoomScale()) 431 | } else if (dollyDelta.y < 0) { 432 | dollyOut(getZoomScale()) 433 | } 434 | 435 | dollyStart.copy(dollyEnd) 436 | 437 | scope.update() 438 | } 439 | 440 | function handleMouseMovePan(event) { 441 | // console.log( 'handleMouseMovePan' ) 442 | 443 | panEnd.set(event.clientX, event.clientY) 444 | 445 | panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed) 446 | 447 | pan(panDelta.x, panDelta.y) 448 | 449 | panStart.copy(panEnd) 450 | 451 | scope.update() 452 | } 453 | 454 | function handleMouseUp(event) { 455 | // console.log( 'handleMouseUp' ) 456 | } 457 | 458 | function handleMouseWheel(event) { 459 | // console.log( 'handleMouseWheel' ) 460 | 461 | if (event.deltaY < 0) { 462 | dollyOut(getZoomScale()) 463 | } else if (event.deltaY > 0) { 464 | dollyIn(getZoomScale()) 465 | } 466 | 467 | scope.update() 468 | } 469 | 470 | function handleKeyDown(event) { 471 | // console.log( 'handleKeyDown' ) 472 | 473 | switch (event.keyCode) { 474 | case scope.keys.UP: 475 | pan(0, scope.keyPanSpeed) 476 | scope.update() 477 | break 478 | 479 | case scope.keys.BOTTOM: 480 | pan(0, -scope.keyPanSpeed) 481 | scope.update() 482 | break 483 | 484 | case scope.keys.LEFT: 485 | pan(scope.keyPanSpeed, 0) 486 | scope.update() 487 | break 488 | 489 | case scope.keys.RIGHT: 490 | pan(-scope.keyPanSpeed, 0) 491 | scope.update() 492 | break 493 | } 494 | } 495 | 496 | function handleTouchStartRotate(event) { 497 | // console.log( 'handleTouchStartRotate' ) 498 | 499 | rotateStart.set(event.touches[0].pageX, event.touches[0].pageY) 500 | } 501 | 502 | function handleTouchStartDollyPan(event) { 503 | // console.log( 'handleTouchStartDollyPan' ) 504 | 505 | if (scope.enableZoom) { 506 | var dx = event.touches[0].pageX - event.touches[1].pageX 507 | var dy = event.touches[0].pageY - event.touches[1].pageY 508 | 509 | var distance = Math.sqrt(dx * dx + dy * dy) 510 | 511 | dollyStart.set(0, distance) 512 | } 513 | 514 | if (scope.enablePan) { 515 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX) 516 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY) 517 | 518 | panStart.set(x, y) 519 | } 520 | } 521 | 522 | function handleTouchMoveRotate(event) { 523 | // console.log( 'handleTouchMoveRotate' ) 524 | 525 | rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY) 526 | 527 | rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed) 528 | 529 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement 530 | 531 | rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height 532 | 533 | rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight) 534 | 535 | rotateStart.copy(rotateEnd) 536 | 537 | scope.update() 538 | } 539 | 540 | function handleTouchMoveDollyPan(event) { 541 | // console.log( 'handleTouchMoveDollyPan' ) 542 | 543 | if (scope.enableZoom) { 544 | var dx = event.touches[0].pageX - event.touches[1].pageX 545 | var dy = event.touches[0].pageY - event.touches[1].pageY 546 | 547 | var distance = Math.sqrt(dx * dx + dy * dy) 548 | 549 | dollyEnd.set(0, distance) 550 | 551 | dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed)) 552 | 553 | dollyIn(dollyDelta.y) 554 | 555 | dollyStart.copy(dollyEnd) 556 | } 557 | 558 | if (scope.enablePan) { 559 | var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX) 560 | var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY) 561 | 562 | panEnd.set(x, y) 563 | 564 | panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed) 565 | 566 | pan(panDelta.x, panDelta.y) 567 | 568 | panStart.copy(panEnd) 569 | } 570 | 571 | scope.update() 572 | } 573 | 574 | function handleTouchEnd(event) { 575 | // console.log( 'handleTouchEnd' ) 576 | } 577 | 578 | // 579 | // event handlers - FSM: listen for events and reset state 580 | // 581 | 582 | function onMouseDown(event) { 583 | if (scope.enabled === false) return 584 | 585 | event.preventDefault() 586 | 587 | switch (event.button) { 588 | case scope.mouseButtons.LEFT: 589 | if (event.ctrlKey || event.metaKey || event.shiftKey) { 590 | if (scope.enablePan === false) return 591 | 592 | handleMouseDownPan(event) 593 | 594 | state = STATE.PAN 595 | } else { 596 | if (scope.enableRotate === false) return 597 | 598 | handleMouseDownRotate(event) 599 | 600 | state = STATE.ROTATE 601 | } 602 | 603 | break 604 | 605 | case scope.mouseButtons.MIDDLE: 606 | if (scope.enableZoom === false) return 607 | 608 | handleMouseDownDolly(event) 609 | 610 | state = STATE.DOLLY 611 | 612 | break 613 | 614 | case scope.mouseButtons.RIGHT: 615 | if (scope.enablePan === false) return 616 | 617 | handleMouseDownPan(event) 618 | 619 | state = STATE.PAN 620 | 621 | break 622 | } 623 | 624 | if (state !== STATE.NONE) { 625 | document.addEventListener('mousemove', onMouseMove, false) 626 | document.addEventListener('mouseup', onMouseUp, false) 627 | 628 | scope.dispatchEvent(startEvent) 629 | } 630 | } 631 | 632 | function onMouseMove(event) { 633 | if (scope.enabled === false) return 634 | 635 | event.preventDefault() 636 | 637 | switch (state) { 638 | case STATE.ROTATE: 639 | if (scope.enableRotate === false) return 640 | 641 | handleMouseMoveRotate(event) 642 | 643 | break 644 | 645 | case STATE.DOLLY: 646 | if (scope.enableZoom === false) return 647 | 648 | handleMouseMoveDolly(event) 649 | 650 | break 651 | 652 | case STATE.PAN: 653 | if (scope.enablePan === false) return 654 | 655 | handleMouseMovePan(event) 656 | 657 | break 658 | } 659 | } 660 | 661 | function onMouseUp(event) { 662 | if (scope.enabled === false) return 663 | 664 | handleMouseUp(event) 665 | 666 | document.removeEventListener('mousemove', onMouseMove, false) 667 | document.removeEventListener('mouseup', onMouseUp, false) 668 | 669 | scope.dispatchEvent(endEvent) 670 | 671 | state = STATE.NONE 672 | } 673 | 674 | function onMouseWheel(event) { 675 | if (scope.enabled === false || scope.enableZoom === false || (state !== STATE.NONE && state !== STATE.ROTATE)) 676 | return 677 | 678 | event.preventDefault() 679 | event.stopPropagation() 680 | 681 | scope.dispatchEvent(startEvent) 682 | 683 | handleMouseWheel(event) 684 | 685 | scope.dispatchEvent(endEvent) 686 | } 687 | 688 | function onKeyDown(event) { 689 | if (scope.enabled === false || scope.enableKeys === false || scope.enablePan === false) return 690 | 691 | handleKeyDown(event) 692 | } 693 | 694 | function onTouchStart(event) { 695 | if (scope.enabled === false) return 696 | 697 | event.preventDefault() 698 | 699 | switch (event.touches.length) { 700 | case 1: // one-fingered touch: rotate 701 | if (scope.enableRotate === false) return 702 | 703 | handleTouchStartRotate(event) 704 | 705 | state = STATE.TOUCH_ROTATE 706 | 707 | break 708 | 709 | case 2: // two-fingered touch: dolly-pan 710 | if (scope.enableZoom === false && scope.enablePan === false) return 711 | 712 | handleTouchStartDollyPan(event) 713 | 714 | state = STATE.TOUCH_DOLLY_PAN 715 | 716 | break 717 | 718 | default: 719 | state = STATE.NONE 720 | } 721 | 722 | if (state !== STATE.NONE) { 723 | scope.dispatchEvent(startEvent) 724 | } 725 | } 726 | 727 | function onTouchMove(event) { 728 | // console.log('OrbitControls.onTouchMove') 729 | if (scope.enabled === false) return 730 | 731 | event.preventDefault() 732 | event.stopPropagation() 733 | 734 | switch (event.touches.length) { 735 | case 1: // one-fingered touch: rotate 736 | if (scope.enableRotate === false) return 737 | if (state !== STATE.TOUCH_ROTATE) return // is this needed? 738 | 739 | handleTouchMoveRotate(event) 740 | 741 | break 742 | 743 | case 2: // two-fingered touch: dolly-pan 744 | if (scope.enableZoom === false && scope.enablePan === false) return 745 | if (state !== STATE.TOUCH_DOLLY_PAN) return // is this needed? 746 | 747 | handleTouchMoveDollyPan(event) 748 | 749 | break 750 | 751 | default: 752 | state = STATE.NONE 753 | } 754 | } 755 | 756 | function onTouchEnd(event) { 757 | if (scope.enabled === false) return 758 | 759 | handleTouchEnd(event) 760 | 761 | scope.dispatchEvent(endEvent) 762 | 763 | state = STATE.NONE 764 | } 765 | 766 | function onContextMenu(event) { 767 | if (scope.enabled === false) return 768 | 769 | event.preventDefault() 770 | } 771 | 772 | this.update() 773 | } 774 | 775 | OrbitControls.prototype = Object.create(EventDispatcher.prototype) 776 | OrbitControls.prototype.constructor = OrbitControls 777 | 778 | Object.defineProperties(OrbitControls.prototype, { 779 | center: { 780 | get: function() { 781 | console.warn('OrbitControls: .center has been renamed to .target') 782 | return this.target 783 | } 784 | }, 785 | 786 | // backward compatibility 787 | 788 | noZoom: { 789 | get: function() { 790 | console.warn('OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.') 791 | return !this.enableZoom 792 | }, 793 | 794 | set: function(value) { 795 | console.warn('OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.') 796 | this.enableZoom = !value 797 | } 798 | }, 799 | 800 | noRotate: { 801 | get: function() { 802 | console.warn('OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.') 803 | return !this.enableRotate 804 | }, 805 | 806 | set: function(value) { 807 | console.warn('OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.') 808 | this.enableRotate = !value 809 | } 810 | }, 811 | 812 | noPan: { 813 | get: function() { 814 | console.warn('OrbitControls: .noPan has been deprecated. Use .enablePan instead.') 815 | return !this.enablePan 816 | }, 817 | 818 | set: function(value) { 819 | console.warn('OrbitControls: .noPan has been deprecated. Use .enablePan instead.') 820 | this.enablePan = !value 821 | } 822 | }, 823 | 824 | noKeys: { 825 | get: function() { 826 | console.warn('OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.') 827 | return !this.enableKeys 828 | }, 829 | 830 | set: function(value) { 831 | console.warn('OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.') 832 | this.enableKeys = !value 833 | } 834 | }, 835 | 836 | staticMoving: { 837 | get: function() { 838 | console.warn('OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.') 839 | return !this.enableDamping 840 | }, 841 | 842 | set: function(value) { 843 | console.warn('OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.') 844 | this.enableDamping = !value 845 | } 846 | }, 847 | 848 | dynamicDampingFactor: { 849 | get: function() { 850 | console.warn('OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.') 851 | return this.dampingFactor 852 | }, 853 | 854 | set: function(value) { 855 | console.warn('OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.') 856 | this.dampingFactor = value 857 | } 858 | } 859 | }) 860 | 861 | export default OrbitControls 862 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | EffectComposer, 3 | BloomEffect, 4 | SMAAEffect, 5 | RenderPass, 6 | EffectPass 7 | } from 'postprocessing' 8 | import { WebGLRenderer, Scene, PerspectiveCamera, PointLight } from 'three' 9 | import Helmet from './objects/Helmet' 10 | import OrbitControls from './controls/OrbitControls' 11 | import { preloader } from './loader' 12 | import { TextureResolver } from './loader/resolvers/TextureResolver' 13 | import { ImageResolver } from './loader/resolvers/ImageResolver' 14 | import { GLTFResolver } from './loader/resolvers/GLTFResolver' 15 | 16 | /* Custom settings */ 17 | const SETTINGS = { 18 | useComposer: true 19 | } 20 | let composer 21 | let stats 22 | 23 | /* Init renderer and canvas */ 24 | const container = document.body 25 | const renderer = new WebGLRenderer() 26 | container.style.overflow = 'hidden' 27 | container.style.margin = 0 28 | container.appendChild(renderer.domElement) 29 | renderer.setClearColor(0x3d3b33) 30 | 31 | /* Main scene and camera */ 32 | const scene = new Scene() 33 | const camera = new PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000) 34 | const controls = new OrbitControls(camera) 35 | camera.position.z = 10 36 | controls.enableDamping = true 37 | controls.dampingFactor = 0.15 38 | controls.start() 39 | 40 | /* Lights */ 41 | const frontLight = new PointLight(0xFFFFFF, 1) 42 | const backLight = new PointLight(0xFFFFFF, 1) 43 | scene.add(frontLight) 44 | scene.add(backLight) 45 | frontLight.position.set(20, 20, 20) 46 | backLight.position.set(-20, -20, 20) 47 | 48 | /* Various event listeners */ 49 | window.addEventListener('resize', onResize) 50 | 51 | /* Preloader */ 52 | preloader.init(new ImageResolver(), new GLTFResolver(), new TextureResolver()) 53 | preloader.load([ 54 | { id: 'searchImage', type: 'image', url: SMAAEffect.searchImageDataURL }, 55 | { id: 'areaImage', type: 'image', url: SMAAEffect.areaImageDataURL }, 56 | { id: 'helmet', type: 'gltf', url: 'assets/models/DamagedHelmet.glb' }, 57 | { id: 'env', type: 'texture', url: 'assets/textures/pisa.jpg' } 58 | ]).then(() => { 59 | initPostProcessing() 60 | onResize() 61 | animate() 62 | 63 | /* Actual content of the scene */ 64 | const helmet = new Helmet() 65 | scene.add(helmet) 66 | }) 67 | 68 | /* some stuff with gui */ 69 | if (DEVELOPMENT) { 70 | const guigui = require('guigui') 71 | guigui.add(SETTINGS, 'useComposer') 72 | 73 | const Stats = require('stats.js') 74 | stats = new Stats() 75 | stats.showPanel(0) 76 | container.appendChild(stats.domElement) 77 | stats.domElement.style.position = 'absolute' 78 | stats.domElement.style.top = 0 79 | stats.domElement.style.left = 0 80 | } 81 | 82 | /* -------------------------------------------------------------------------------- */ 83 | function initPostProcessing () { 84 | composer = new EffectComposer(renderer) 85 | const bloomEffect = new BloomEffect() 86 | const smaaEffect = new SMAAEffect(preloader.get('searchImage'), preloader.get('areaImage')) 87 | const effectPass = new EffectPass(camera, smaaEffect, bloomEffect) 88 | const renderPass = new RenderPass(scene, camera) 89 | composer.addPass(renderPass) 90 | composer.addPass(effectPass) 91 | effectPass.renderToScreen = true 92 | } 93 | 94 | /** 95 | Resize canvas 96 | */ 97 | function onResize () { 98 | camera.aspect = window.innerWidth / window.innerHeight 99 | camera.updateProjectionMatrix() 100 | renderer.setSize(window.innerWidth, window.innerHeight) 101 | composer.setSize(window.innerWidth, window.innerHeight) 102 | } 103 | 104 | /** 105 | RAF 106 | */ 107 | function animate() { 108 | window.requestAnimationFrame(animate) 109 | render() 110 | } 111 | 112 | /** 113 | Render loop 114 | */ 115 | function render () { 116 | if (DEVELOPMENT) { 117 | stats.begin() 118 | } 119 | 120 | controls.update() 121 | if (SETTINGS.useComposer) { 122 | composer.render() 123 | } else { 124 | renderer.clear() 125 | renderer.render(scene, camera) 126 | } 127 | 128 | if (DEVELOPMENT) { 129 | stats.end() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/loader/index.js: -------------------------------------------------------------------------------- 1 | class Preloader { 2 | constructor() { 3 | this.resolvers = [] 4 | this.manifest = [] 5 | } 6 | /** 7 | * Pretty print warning messages 8 | * @param {...*} msgs you want to print 9 | */ 10 | warn(...msgs) { 11 | console.warn('[Preloader]', ...msgs) 12 | } 13 | 14 | /** 15 | * Initialize preloader with n resolvers, a resolver is an object that will define a load behavior for a given type. 16 | * A resolver must be an object with a resolve method,a get method and a type 17 | * @param {...Resolver} resolvers array of resolvers you want to use 18 | */ 19 | init(...resolvers) { 20 | resolvers.forEach(resolver => { 21 | if (!resolver.hasOwnProperty('type')) { 22 | this.warn('init()', 'This resolver shoud have a `type` property', resolver) 23 | } 24 | if (typeof resolver.resolve !== 'function') { 25 | this.warn('init()', 'This resolver should implement a `resolve` function', resolver) 26 | } 27 | if (typeof resolver.get !== 'function') { 28 | this.warn('init()', 'This resolver should implement a `get` function', resolver) 29 | } 30 | this.resolvers.push(resolver) 31 | }) 32 | } 33 | 34 | /** 35 | * Launch the loading of the given manifest. 36 | * @param {Array} manifest array of object to load, each object should be composed of a type (compatible with one of the resolvers used in init), an id and an url. 37 | * @returns {Promise} a promise that will be resolved when everything is loaded 38 | */ 39 | load(manifest, baseUrl = '/', cdn = null) { 40 | if (!Array.isArray(manifest)) { 41 | this.warn('load()', 'manifest should be an array', manifest) 42 | } 43 | 44 | // Clean urls 45 | manifest = manifest.map(item => { 46 | let url = item.url 47 | const isUrlBase64 = url.indexOf('data:') === 0 48 | const isUrlAbsolute = url.indexOf('http://') === 0 || url.indexOf('https://') === 0 49 | if (!isUrlAbsolute && !isUrlBase64) { 50 | url = item.cdn && cdn ? cdn + item.url : baseUrl + item.url 51 | } 52 | if (!isUrlBase64) { 53 | url += '?v=' + item.version 54 | } 55 | return Object.assign({}, item, { url }) 56 | }) 57 | 58 | // save manifest for later result retreivals 59 | this.manifest = this.manifest.concat(manifest) 60 | 61 | // find duplicate ids 62 | for (let i = 0, l = manifest.length; i < l; i++) { 63 | const item = manifest[i] 64 | let stop = false 65 | for (let j = 0, m = manifest.length; j < m; j++) { 66 | if (i !== j && manifest[j].id === item.id) { 67 | stop = true 68 | break 69 | } 70 | } 71 | if (stop) { 72 | this.warn('load()', 'This id is used twice in the manifest: `' + item.id + '`') 73 | break 74 | } 75 | } 76 | 77 | const promises = manifest.map(item => { 78 | const p = this.getResolverForType(item.type).resolve(item) 79 | if (typeof p.then !== 'function') { 80 | this.warn( 81 | 'resolver for type `' + 82 | item.type + 83 | '` does not return a promise in its resolve method, check its implementation' 84 | ) 85 | } 86 | return p 87 | }) 88 | 89 | return Promise.all(promises) 90 | } 91 | 92 | /** 93 | * Find resolver for a given type 94 | * @param {String} type 95 | */ 96 | getResolverForType(type) { 97 | const results = this.resolvers.filter(r => r.type === type) 98 | return results.length ? results[0] : null 99 | } 100 | 101 | /** 102 | * Returns a resolved content for a given item id from the manifest 103 | * @param {String} id item id from the manifest item 104 | * @param {...*} args arguments you want to pass to the resolver get method 105 | */ 106 | get(id, ...args) { 107 | const items = this.manifest.filter(item => item.id === id) 108 | if (items.length) { 109 | const item = items[0] 110 | const resolver = this.getResolverForType(item.type) 111 | return resolver.get(item, args) 112 | } 113 | return null 114 | } 115 | } 116 | 117 | const preloader = new Preloader() 118 | 119 | export { preloader } 120 | -------------------------------------------------------------------------------- /src/loader/resolvers/GLTFResolver.js: -------------------------------------------------------------------------------- 1 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' 2 | 3 | export class GLTFResolver { 4 | constructor() { 5 | this.type = 'gltf' 6 | this.loader = new GLTFLoader() 7 | } 8 | 9 | resolve(item) { 10 | return new Promise(resolve => { 11 | this.loader.load(item.url, scene => { 12 | resolve(Object.assign(item, { scene })) 13 | }) 14 | }) 15 | } 16 | 17 | get(item) { 18 | return item.scene 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/loader/resolvers/ImageResolver.js: -------------------------------------------------------------------------------- 1 | export class ImageResolver { 2 | constructor() { 3 | this.type = 'image' 4 | } 5 | 6 | resolve(item) { 7 | return new Promise(resolve => { 8 | const image = new Image() 9 | image.onload = () => { 10 | resolve(Object.assign(item, { image })) 11 | } 12 | image.src = item.url 13 | }) 14 | } 15 | 16 | get(item) { 17 | return item.image 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/loader/resolvers/TextureResolver.js: -------------------------------------------------------------------------------- 1 | import { TextureLoader } from 'three' 2 | 3 | export class TextureResolver { 4 | constructor(renderer) { 5 | this.type = 'texture' 6 | this.renderer = renderer 7 | this.loader = new TextureLoader() 8 | } 9 | 10 | resolve(item) { 11 | return new Promise(resolve => { 12 | this.loader.load(item.url, texture => { 13 | if (this.renderer) { 14 | this.renderer.setTexture2D(texture, 0) 15 | } 16 | resolve(Object.assign(item, { texture })) 17 | }) 18 | }) 19 | } 20 | 21 | get(item) { 22 | return item.texture 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/objects/Helmet.js: -------------------------------------------------------------------------------- 1 | import { Object3D, EquirectangularReflectionMapping } from 'three' 2 | import { preloader } from '../loader' 3 | 4 | export default class Torus extends Object3D { 5 | constructor () { 6 | super() 7 | 8 | this.scale.setScalar(2) 9 | this.rotation.y = Math.PI * -0.25 10 | 11 | const helmet = preloader.get('helmet') 12 | const envMap = preloader.get('env') 13 | envMap.mapping = EquirectangularReflectionMapping 14 | helmet.scene.children[0].material.envMap = preloader.get('env') 15 | 16 | this.add(helmet.scene) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const CopyWebpackPlugin = require('copy-webpack-plugin') 4 | const HtmlWebpackPlugin = require('html-webpack-plugin') 5 | 6 | module.exports = env => { 7 | const isProd = env && env.prod 8 | const config = { 9 | mode: isProd ? 'production' : 'development', 10 | performance: { hints: false }, 11 | entry: { 12 | build: './src/index.js' 13 | }, 14 | plugins: [ 15 | new webpack.DefinePlugin({ 16 | DEVELOPMENT: !isProd 17 | }), 18 | new CopyWebpackPlugin({ 19 | patterns: [{ from: 'src/assets', to: 'assets' }] 20 | }), 21 | new HtmlWebpackPlugin({ 22 | title: isProd ? 'Production' : 'Development', 23 | meta: { 24 | viewport: 25 | 'width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1.0, user-scalable=no' 26 | } 27 | }) 28 | ], 29 | output: { 30 | filename: '[name].js', 31 | path: path.resolve(__dirname, 'dist') 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(glsl|vs|fs|vert|frag)$/, 37 | use: ['raw-loader', 'glslify-loader'] 38 | }, 39 | { 40 | test: /\.js$/, 41 | exclude: /node_modules/, 42 | use: { 43 | loader: 'babel-loader', 44 | options: { 45 | compact: false, 46 | presets: [['@babel/preset-env']], 47 | plugins: [['@babel/transform-runtime']] 48 | } 49 | } 50 | } 51 | ] 52 | } 53 | } 54 | 55 | if (!isProd) { 56 | config.devtool = '#source-map' 57 | } 58 | 59 | return config 60 | } 61 | --------------------------------------------------------------------------------