├── .gitignore ├── 02_bubbles.html ├── 03_flowers.html ├── 04_eyes.html ├── LICENSE ├── README.md ├── css └── base.css ├── favicon.ico ├── img ├── flower.png ├── leaf.png └── smoke.png ├── index.html ├── js ├── 01_clouds.js ├── 02_bubbles.js ├── 03_flowers.js └── 04_eyes.js └── models └── eye-realistic.glb /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .parcel-cache 6 | package-lock.json -------------------------------------------------------------------------------- /02_bubbles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typing Effects with Three.js | Demo 2 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Typing Effects with Three.js

19 | 24 | 30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 50 | 51 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /03_flowers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typing Effects with Three.js | Demo 3 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Typing Effects with Three.js

19 | 24 | 30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /04_eyes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typing Effects with Three.js | Demo 4 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Typing Effects with Three.js

19 | 24 | 30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2009 - 2021 [Codrops](https://tympanus.net/codrops) 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typing Effects with Three.js 2 | 3 | Demos for the tutorial on how to create interactive 3D type effects in Three.js. 4 | 5 | ![Image Title](https://tympanus.net/codrops/wp-content/uploads/2022/11/3DType.jpg) 6 | 7 | [Article on Codrops](https://tympanus.net/codrops/?p=65590) 8 | 9 | [Demo](http://tympanus.net/Development/3DTypeEffects/) 10 | 11 | 12 | ## Installation 13 | 14 | No package manager is needed, just open it on localhost (or any web server, really) like you do with Three.js /examples 15 | 16 | ## Credits 17 | 18 | - [Realistic Eye Model](https://www.turbosquid.com/3d-models/3d-16-colors-of-realistic-eye-demo-free-model-1765448) by [Davlet](https://www.turbosquid.com/Search/Artists/Davlet) via [Turbosquid](https://www.turbosquid.com/) 19 | 20 | ## Misc 21 | 22 | Follow Ksenia: [Twitter](https://twitter.com/uuuuuulala), [Codepen](https://codepen.io/ksenia-k), [website](https://ksenia-k.com/) 23 | 24 | Follow Codrops: [Twitter](http://www.twitter.com/codrops), [Facebook](http://www.facebook.com/codrops), [GitHub](https://github.com/codrops), [Instagram](https://www.instagram.com/codropsss/) 25 | 26 | ## License 27 | [MIT](LICENSE) 28 | 29 | Made with :blue_heart: by [Codrops](http://www.codrops.com) 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /css/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::after, 3 | *::before { 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | font-size: 13px; 9 | --color-text: #111; 10 | --color-bg: #fff; 11 | --color-link: #000; 12 | --color-link-hover: #333; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | color: var(--color-text); 18 | background-color: var(--color-bg); 19 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | .demo-1 { 25 | background-color: #a6ccf2; 26 | } 27 | 28 | .demo-2 { 29 | background-color: #9d74c1; 30 | } 31 | 32 | .demo-3 { 33 | background-color: #eef3c7; 34 | } 35 | 36 | .demo-4 { 37 | background-color: #FF8796; 38 | } 39 | 40 | /* Page Loader */ 41 | .js .loading::before, 42 | .js .loading::after { 43 | content: ''; 44 | position: fixed; 45 | z-index: 1000; 46 | } 47 | 48 | .js .loading::before { 49 | top: 0; 50 | left: 0; 51 | width: 100%; 52 | height: 100%; 53 | background: var(--color-bg); 54 | } 55 | 56 | .js .loading::after { 57 | top: 50%; 58 | left: 50%; 59 | width: 60px; 60 | height: 60px; 61 | margin: -30px 0 0 -30px; 62 | border-radius: 50%; 63 | opacity: 0.4; 64 | background: var(--color-link); 65 | animation: loaderAnim 0.7s linear infinite alternate forwards; 66 | 67 | } 68 | 69 | @keyframes loaderAnim { 70 | to { 71 | opacity: 1; 72 | transform: scale3d(0.5,0.5,1); 73 | } 74 | } 75 | 76 | a { 77 | text-decoration: underline; 78 | color: var(--color-link); 79 | outline: none; 80 | cursor: pointer; 81 | } 82 | 83 | a:hover { 84 | color: var(--color-link-hover); 85 | outline: none; 86 | text-decoration: none; 87 | } 88 | 89 | /* Better focus styles from https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */ 90 | a:focus { 91 | /* Provide a fallback style for browsers 92 | that don't support :focus-visible */ 93 | outline: none; 94 | background: lightgrey; 95 | } 96 | 97 | a:focus:not(:focus-visible) { 98 | /* Remove the focus indicator on mouse-focus for browsers 99 | that do support :focus-visible */ 100 | background: transparent; 101 | } 102 | 103 | a:focus-visible { 104 | /* Draw a very noticeable focus style for 105 | keyboard-focus on browsers that do support 106 | :focus-visible */ 107 | outline: 2px solid red; 108 | background: transparent; 109 | } 110 | 111 | .unbutton { 112 | background: none; 113 | border: 0; 114 | padding: 0; 115 | margin: 0; 116 | font: inherit; 117 | cursor: pointer; 118 | } 119 | 120 | .unbutton:focus { 121 | outline: none; 122 | } 123 | 124 | .frame { 125 | position: fixed; 126 | top: 0; 127 | left: 0; 128 | width: 100%; 129 | padding: 2rem; 130 | z-index: 1; 131 | pointer-events: none; 132 | text-transform: uppercase; 133 | display: grid; 134 | grid-gap: 1rem; 135 | grid-template-areas: 'title' 'links' 'demos' 'demos'; 136 | } 137 | 138 | .frame a { 139 | pointer-events: auto; 140 | } 141 | 142 | .frame__title { 143 | margin: 0; 144 | font-weight: normal; 145 | font-size: 1rem; 146 | grid-area: title; 147 | } 148 | 149 | .frame__links, 150 | .frame__demos { 151 | display: flex; 152 | align-items: center; 153 | gap: 1rem; 154 | flex-wrap: wrap; 155 | } 156 | 157 | .frame__links { 158 | grid-area: links; 159 | } 160 | 161 | .frame__demos { 162 | grid-area: demos; 163 | } 164 | 165 | .frame__demo { 166 | background: rgba(255,255,255,0.4); 167 | border: 1px solid black; 168 | padding: 0.2rem 1rem; 169 | border-radius: 5px; 170 | text-decoration: none; 171 | line-height: 2; 172 | white-space: nowrap; 173 | } 174 | 175 | .frame__demo--current, 176 | .frame__demo:hover { 177 | color: var(--color-link-hover); 178 | background: white; 179 | } 180 | 181 | .content { 182 | display: flex; 183 | flex-direction: column; 184 | width: 100vw; 185 | height: calc(100vh - 13rem); 186 | justify-content: flex-start; 187 | align-items: center; 188 | } 189 | 190 | .container { 191 | position: fixed; 192 | top: 0; 193 | left: 0; 194 | } 195 | 196 | #text-input { 197 | position: fixed; 198 | top: 0; 199 | left: 0; 200 | opacity: 0; 201 | pointer-events: none; 202 | } 203 | 204 | @media screen and (min-width: 53em) { 205 | .frame { 206 | grid-template-areas: 'title links' 'demos demos'; 207 | align-content: space-between; 208 | height: 100%; 209 | grid-gap: 0; 210 | } 211 | .frame__links { 212 | justify-self: end; 213 | } 214 | .content { 215 | height: 100vh; 216 | justify-content: center; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/WebGL-typing-tutorial/88daf57fb293a3df6fdc0a90740d07b335761069/favicon.ico -------------------------------------------------------------------------------- /img/flower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/WebGL-typing-tutorial/88daf57fb293a3df6fdc0a90740d07b335761069/img/flower.png -------------------------------------------------------------------------------- /img/leaf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/WebGL-typing-tutorial/88daf57fb293a3df6fdc0a90740d07b335761069/img/leaf.png -------------------------------------------------------------------------------- /img/smoke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/WebGL-typing-tutorial/88daf57fb293a3df6fdc0a90740d07b335761069/img/smoke.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typing Effects with Three.js | Demo 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

Typing Effects with Three.js

18 | 23 | 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /js/01_clouds.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "https://cdn.skypack.dev/three@0.133.1/build/three.module"; 2 | import { OrbitControls } from 'https://cdn.skypack.dev/three@0.133.1/examples/jsm/controls/OrbitControls' 3 | 4 | // DOM selectors 5 | const containerEl = document.querySelector('.container'); 6 | const textInputEl = document.querySelector('#text-input'); 7 | 8 | // Settings 9 | const fontName = 'Verdana'; 10 | const textureFontSize = 60; 11 | const fontScaleFactor = .08; 12 | 13 | // We need to keep the style of editable
(hidden inout field) and canvas 14 | textInputEl.style.fontSize = textureFontSize + 'px'; 15 | textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName; 16 | textInputEl.style.lineHeight = 1.1 * textureFontSize + 'px'; 17 | 18 | // 3D scene related globals 19 | let scene, camera, renderer, textCanvas, textCtx, particleGeometry, particleMaterial, instancedMesh, dummy, clock, cursorMesh; 20 | 21 | // String to show 22 | let string = 'FLUFFY'; 23 | 24 | // Coordinates data per 2D canvas and 3D scene 25 | let textureCoordinates = []; 26 | 27 | // 1d-array of data objects to store and change params of each instance 28 | let particles = []; 29 | 30 | // Parameters of whole string per 2D canvas and 3D scene 31 | let stringBox = { 32 | wTexture: 0, 33 | wScene: 0, 34 | hTexture: 0, 35 | hScene: 0, 36 | caretPosScene: [] 37 | }; 38 | 39 | // --------------------------------------------------------------- 40 | 41 | textInputEl.innerHTML = string; 42 | textInputEl.focus(); 43 | 44 | init(); 45 | createEvents(); 46 | setCaretToEndOfInput(); 47 | handleInput(); 48 | refreshText(); 49 | render(); 50 | 51 | // --------------------------------------------------------------- 52 | 53 | function init() { 54 | camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .1, 1000); 55 | camera.position.z = 20; 56 | 57 | scene = new THREE.Scene(); 58 | 59 | renderer = new THREE.WebGLRenderer({ 60 | alpha: true 61 | }); 62 | renderer.setPixelRatio(window.devicePixelRatio); 63 | renderer.setSize(window.innerWidth, window.innerHeight); 64 | containerEl.appendChild(renderer.domElement); 65 | 66 | const orbit = new OrbitControls(camera, renderer.domElement); 67 | orbit.enablePan = false; 68 | 69 | textCanvas = document.createElement('canvas'); 70 | textCanvas.width = textCanvas.height = 0; 71 | textCtx = textCanvas.getContext('2d'); 72 | particleGeometry = new THREE.PlaneGeometry(1, 1); 73 | const texture = new THREE.TextureLoader().load('./img/smoke.png'); 74 | particleMaterial = new THREE.MeshBasicMaterial({ 75 | color: 0xffffff, 76 | alphaMap: texture, 77 | depthTest: false, 78 | opacity: .3, 79 | transparent: true, 80 | }); 81 | 82 | dummy = new THREE.Object3D(); 83 | clock = new THREE.Clock(); 84 | 85 | const cursorGeometry = new THREE.BoxGeometry(.15, 4.5, .03); 86 | cursorGeometry.translate(.2, -2.7, 0) 87 | const cursorMaterial = new THREE.MeshBasicMaterial({ 88 | color: 0xffffff, 89 | transparent: true, 90 | }); 91 | cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial); 92 | scene.add(cursorMesh); 93 | } 94 | 95 | 96 | // --------------------------------------------------------------- 97 | 98 | function createEvents() { 99 | document.addEventListener('keyup', () => { 100 | handleInput(); 101 | refreshText(); 102 | }); 103 | 104 | textInputEl.addEventListener('focus', () => { 105 | clock.elapsedTime = 0; 106 | }); 107 | 108 | window.addEventListener('resize', () => { 109 | camera.aspect = window.innerWidth / window.innerHeight; 110 | camera.updateProjectionMatrix(); 111 | renderer.setSize(window.innerWidth, window.innerHeight); 112 | }); 113 | } 114 | 115 | function setCaretToEndOfInput() { 116 | document.execCommand('selectAll', false, null); 117 | document.getSelection().collapseToEnd(); 118 | } 119 | 120 | function handleInput() { 121 | if (isNewLine(textInputEl.firstChild)) { 122 | textInputEl.firstChild.remove(); 123 | } 124 | if (isNewLine(textInputEl.lastChild)) { 125 | if (isNewLine(textInputEl.lastChild.previousSibling)) { 126 | textInputEl.lastChild.remove(); 127 | } 128 | } 129 | 130 | string = textInputEl.innerHTML 131 | .replaceAll("

", "\n") 132 | .replaceAll("

", "") 133 | .replaceAll("
", "\n") 134 | .replaceAll("
", "") 135 | .replaceAll("
", "") 136 | .replaceAll("
", "") 137 | .replaceAll(" ", " "); 138 | 139 | stringBox.wTexture = textInputEl.clientWidth; 140 | stringBox.wScene = stringBox.wTexture * fontScaleFactor 141 | stringBox.hTexture = textInputEl.clientHeight; 142 | stringBox.hScene = stringBox.hTexture * fontScaleFactor 143 | stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor); 144 | 145 | function isNewLine(el) { 146 | if (el) { 147 | if (el.tagName) { 148 | if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') { 149 | if (el.innerHTML === '
' || el.innerHTML === '
') { 150 | return true; 151 | } 152 | } 153 | } 154 | } 155 | return false 156 | } 157 | 158 | function getCaretCoordinates() { 159 | const range = window.getSelection().getRangeAt(0); 160 | const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0); 161 | if (needsToWorkAroundNewlineBug) { 162 | return [ 163 | range.startContainer.offsetLeft, 164 | range.startContainer.offsetTop 165 | ] 166 | } else { 167 | const rects = range.getClientRects(); 168 | if (rects[0]) { 169 | return [ rects[0].left, rects[0].top ] 170 | } else { 171 | document.execCommand('selectAll', false, null); 172 | return [ 173 | 0, 0 174 | ] 175 | } 176 | } 177 | } 178 | } 179 | 180 | // --------------------------------------------------------------- 181 | 182 | function render() { 183 | requestAnimationFrame(render); 184 | updateParticlesMatrices(); 185 | updateCursorOpacity(); 186 | renderer.render(scene, camera); 187 | } 188 | 189 | // --------------------------------------------------------------- 190 | 191 | function refreshText() { 192 | sampleCoordinates(); 193 | 194 | particles = textureCoordinates.map((c, cIdx) => { 195 | const x = c.x * fontScaleFactor; 196 | const y = c.y * fontScaleFactor; 197 | let p = (c.old && particles[cIdx]) ? particles[cIdx] : new Particle([x, y]); 198 | if (c.toDelete) { 199 | p.toDelete = true; 200 | p.scale = p.maxScale; 201 | } 202 | return p; 203 | }); 204 | 205 | recreateInstancedMesh(); 206 | makeTextFitScreen(); 207 | updateCursorPosition(); 208 | } 209 | 210 | // --------------------------------------------------------------- 211 | // Input string to textureCoordinates 212 | 213 | function sampleCoordinates() { 214 | 215 | // Draw text 216 | const lines = string.split(`\n`); 217 | const linesNumber = lines.length; 218 | textCanvas.width = stringBox.wTexture; 219 | textCanvas.height = stringBox.hTexture; 220 | textCtx.font = '100 ' + textureFontSize + 'px ' + fontName; 221 | textCtx.fillStyle = '#2a9d8f'; 222 | textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height); 223 | for (let i = 0; i < linesNumber; i++) { 224 | textCtx.fillText(lines[i], 0, (i + .8) * stringBox.hTexture / linesNumber); 225 | } 226 | 227 | // Sample coordinates 228 | if (stringBox.wTexture > 0) { 229 | 230 | // Image data to 2d array 231 | const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height); 232 | const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width)); 233 | for (let i = 0; i < textCanvas.height; i++) { 234 | for (let j = 0; j < textCanvas.width; j++) { 235 | imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0; 236 | } 237 | } 238 | 239 | if (textureCoordinates.length !== 0) { 240 | 241 | // Clean up: delete coordinates and particles which disappeared on the prev step 242 | // We need to keep same indexes for coordinates and particles to reuse old particles properly 243 | textureCoordinates = textureCoordinates.filter(c => !c.toDelete); 244 | particles = particles.filter(c => !c.toDelete); 245 | 246 | // Go through existing coordinates (old to keep, toDelete for fade-out animation) 247 | textureCoordinates.forEach(c => { 248 | if (imageMask[c.y]) { 249 | if (imageMask[c.y][c.x]) { 250 | c.old = true; 251 | if (!c.toDelete) { 252 | imageMask[c.y][c.x] = false; 253 | } 254 | } else { 255 | c.toDelete = true; 256 | } 257 | } else { 258 | c.toDelete = true; 259 | } 260 | }); 261 | } 262 | 263 | // Add new coordinates 264 | for (let i = 0; i < textCanvas.height; i++) { 265 | for (let j = 0; j < textCanvas.width; j++) { 266 | if (imageMask[i][j]) { 267 | textureCoordinates.push({ 268 | x: j, 269 | y: i, 270 | old: false, 271 | toDelete: false 272 | }) 273 | } 274 | } 275 | } 276 | 277 | } else { 278 | textureCoordinates = []; 279 | } 280 | } 281 | 282 | 283 | // --------------------------------------------------------------- 284 | // Handling params of each particle 285 | 286 | function Particle([x, y]) { 287 | this.x = x + .15 * (Math.random() - .5); 288 | this.y = y + .15 * (Math.random() - .5); 289 | this.z = 0; 290 | 291 | this.isGrowing = true; 292 | this.toDelete = false; 293 | 294 | this.scale = 0; 295 | this.maxScale = .1 + 1.5 * Math.pow(Math.random(), 10); 296 | this.deltaScale = .03 + .03 * Math.random(); 297 | this.age = Math.PI * Math.random(); 298 | this.ageDelta = .01 + .02 * Math.random(); 299 | this.rotationZ = .5 * Math.random() * Math.PI; 300 | this.deltaRotation = .01 * (Math.random() - .5); 301 | 302 | this.grow = function () { 303 | this.age += this.ageDelta; 304 | this.rotationZ += this.deltaRotation; 305 | if (this.isGrowing) { 306 | this.scale += this.deltaScale; 307 | if (this.scale >= this.maxScale) { 308 | this.isGrowing = false; 309 | } 310 | } else if (this.toDelete) { 311 | this.scale -= this.deltaScale; 312 | if (this.scale <= 0) { 313 | this.scale = 0; 314 | this.deltaScale = 0; 315 | } 316 | } else { 317 | this.scale = this.maxScale + .2 * Math.sin(this.age); 318 | } 319 | } 320 | } 321 | 322 | // --------------------------------------------------------------- 323 | // Handle instances 324 | 325 | function recreateInstancedMesh() { 326 | scene.remove(instancedMesh); 327 | instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, particles.length); 328 | scene.add(instancedMesh); 329 | 330 | instancedMesh.position.x = -.5 * stringBox.wScene; 331 | instancedMesh.position.y = -.6 * stringBox.hScene; 332 | } 333 | 334 | function updateParticlesMatrices() { 335 | let idx = 0; 336 | particles.forEach(p => { 337 | p.grow(); 338 | dummy.quaternion.copy(camera.quaternion); 339 | dummy.rotation.z += p.rotationZ; 340 | dummy.scale.set(p.scale, p.scale, p.scale); 341 | dummy.position.set(p.x, stringBox.hScene - p.y, p.z); 342 | dummy.updateMatrix(); 343 | instancedMesh.setMatrixAt(idx, dummy.matrix); 344 | idx ++; 345 | }) 346 | instancedMesh.instanceMatrix.needsUpdate = true; 347 | } 348 | 349 | // --------------------------------------------------------------- 350 | // Move camera so the text is always visible 351 | 352 | function makeTextFitScreen() { 353 | const fov = camera.fov * (Math.PI / 180); 354 | const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect); 355 | const dx = Math.abs(.7 * stringBox.wScene / Math.tan(.5 * fovH)); 356 | const dy = Math.abs(.6 * stringBox.hScene / Math.tan(.5 * fov)); 357 | const factor = Math.max(dx, dy) / camera.position.length(); 358 | if (factor > 1) { 359 | camera.position.x *= factor; 360 | camera.position.y *= factor; 361 | camera.position.z *= factor; 362 | } 363 | } 364 | 365 | // --------------------------------------------------------------- 366 | // Cursor related 367 | 368 | function updateCursorPosition() { 369 | cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0]; 370 | cursorMesh.position.y = .4 * stringBox.hScene - stringBox.caretPosScene[1]; 371 | } 372 | 373 | function updateCursorOpacity() { 374 | let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2); 375 | 376 | if (document.hasFocus() && document.activeElement === textInputEl) { 377 | cursorMesh.material.opacity = .7 * roundPulse(2 * clock.getElapsedTime()); 378 | } else { 379 | cursorMesh.material.opacity = 0; 380 | } 381 | } -------------------------------------------------------------------------------- /js/02_bubbles.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "https://cdn.skypack.dev/three@0.133.1/build/three.module"; 2 | import { OrbitControls } from 'https://cdn.skypack.dev/three@0.133.1/examples/jsm/controls/OrbitControls' 3 | 4 | // DOM selectors 5 | const containerEl = document.querySelector('.container'); 6 | const textInputEl = document.querySelector('#text-input'); 7 | 8 | // Settings 9 | const fontName = 'system-ui'; 10 | const textureFontSize = 70; 11 | const fontScaleFactor = .06; 12 | 13 | // We need to keep the style of editable
(hidden inout field) and canvas 14 | textInputEl.style.fontSize = textureFontSize + 'px'; 15 | textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName; 16 | textInputEl.style.lineHeight = 1 * textureFontSize + 'px'; 17 | 18 | // 3D scene related globals 19 | let scene, camera, renderer, textCanvas, textCtx, particleGeometry, particleMaterial, instancedMesh, dummy, clock, cursorMesh; 20 | 21 | // String to show 22 | let string = 'Bubblerific'; 23 | 24 | // Coordinates data per 2D canvas and 3D scene 25 | let textureCoordinates = []; 26 | 27 | // 1d-array of data objects to store and change params of each instance 28 | let particles = []; 29 | 30 | // Parameters of whole string per 2D canvas and 3D scene 31 | let stringBox = { 32 | wTexture: 0, 33 | wScene: 0, 34 | hTexture: 0, 35 | hScene: 0, 36 | caretPosScene: [] 37 | }; 38 | 39 | // --------------------------------------------------------------- 40 | 41 | textInputEl.innerHTML = string; 42 | textInputEl.focus(); 43 | 44 | init(); 45 | createEvents(); 46 | setCaretToEndOfInput(); 47 | handleInput(); 48 | refreshText(); 49 | render(); 50 | 51 | // --------------------------------------------------------------- 52 | 53 | function init() { 54 | camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .1, 1000); 55 | camera.position.z = 20; 56 | 57 | scene = new THREE.Scene(); 58 | 59 | renderer = new THREE.WebGLRenderer({ 60 | alpha: true 61 | }); 62 | renderer.setPixelRatio(window.devicePixelRatio); 63 | renderer.setSize(window.innerWidth, window.innerHeight); 64 | containerEl.appendChild(renderer.domElement); 65 | 66 | const orbit = new OrbitControls(camera, renderer.domElement); 67 | orbit.enablePan = false; 68 | 69 | textCanvas = document.createElement('canvas'); 70 | textCanvas.width = textCanvas.height = 0; 71 | textCtx = textCanvas.getContext('2d'); 72 | 73 | particleGeometry = new THREE.IcosahedronGeometry(.2, 3); 74 | particleMaterial = new THREE.ShaderMaterial({ 75 | vertexShader: document.getElementById("vertexShader").textContent, 76 | fragmentShader: document.getElementById("fragmentShader").textContent, 77 | transparent: true, 78 | }); 79 | 80 | dummy = new THREE.Object3D(); 81 | clock = new THREE.Clock(); 82 | 83 | const cursorGeometry = new THREE.BoxGeometry(.12, 4.5, .03); 84 | cursorGeometry.translate(.1, -1.8, 0) 85 | const cursorMaterial = new THREE.MeshBasicMaterial({ 86 | color: 0xffffff, 87 | transparent: true, 88 | }); 89 | cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial); 90 | scene.add(cursorMesh); 91 | } 92 | 93 | 94 | // --------------------------------------------------------------- 95 | 96 | function createEvents() { 97 | document.addEventListener('keyup', () => { 98 | handleInput(); 99 | refreshText(); 100 | }); 101 | 102 | textInputEl.addEventListener('focus', () => { 103 | clock.elapsedTime = 0; 104 | }); 105 | 106 | window.addEventListener('resize', () => { 107 | camera.aspect = window.innerWidth / window.innerHeight; 108 | camera.updateProjectionMatrix(); 109 | renderer.setSize(window.innerWidth, window.innerHeight); 110 | }); 111 | } 112 | 113 | function setCaretToEndOfInput() { 114 | document.execCommand('selectAll', false, null); 115 | document.getSelection().collapseToEnd(); 116 | } 117 | 118 | function handleInput() { 119 | if (isNewLine(textInputEl.firstChild)) { 120 | textInputEl.firstChild.remove(); 121 | } 122 | if (isNewLine(textInputEl.lastChild)) { 123 | if (isNewLine(textInputEl.lastChild.previousSibling)) { 124 | textInputEl.lastChild.remove(); 125 | } 126 | } 127 | 128 | string = textInputEl.innerHTML 129 | .replaceAll("

", "\n") 130 | .replaceAll("

", "") 131 | .replaceAll("
", "\n") 132 | .replaceAll("
", "") 133 | .replaceAll("
", "") 134 | .replaceAll("
", "") 135 | .replaceAll(" ", " "); 136 | 137 | stringBox.wTexture = textInputEl.clientWidth; 138 | stringBox.wScene = stringBox.wTexture * fontScaleFactor 139 | stringBox.hTexture = textInputEl.clientHeight; 140 | stringBox.hScene = stringBox.hTexture * fontScaleFactor 141 | stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor); 142 | 143 | function isNewLine(el) { 144 | if (el) { 145 | if (el.tagName) { 146 | if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') { 147 | if (el.innerHTML === '
' || el.innerHTML === '
') { 148 | return true; 149 | } 150 | } 151 | } 152 | } 153 | return false 154 | } 155 | 156 | function getCaretCoordinates() { 157 | const range = window.getSelection().getRangeAt(0); 158 | const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0); 159 | if (needsToWorkAroundNewlineBug) { 160 | return [ 161 | range.startContainer.offsetLeft, 162 | range.startContainer.offsetTop 163 | ] 164 | } else { 165 | const rects = range.getClientRects(); 166 | if (rects[0]) { 167 | return [rects[0].left, rects[0].top] 168 | } else { 169 | document.execCommand('selectAll', false, null); 170 | return [ 171 | 0, 0 172 | ] 173 | } 174 | } 175 | } 176 | } 177 | 178 | // --------------------------------------------------------------- 179 | 180 | function render() { 181 | requestAnimationFrame(render); 182 | updateParticlesMatrices(); 183 | updateCursorOpacity(); 184 | renderer.render(scene, camera); 185 | } 186 | 187 | // --------------------------------------------------------------- 188 | 189 | function refreshText() { 190 | sampleCoordinates(); 191 | 192 | particles = textureCoordinates.map((c, cIdx) => { 193 | const x = c.x * fontScaleFactor; 194 | const y = c.y * fontScaleFactor; 195 | let p = (c.old && particles[cIdx]) ? particles[cIdx] : new Particle([x, y]); 196 | if (c.toDelete) { 197 | p.toDelete = true; 198 | } 199 | return p; 200 | }); 201 | 202 | recreateInstancedMesh(); 203 | makeTextFitScreen(); 204 | updateCursorPosition(); 205 | } 206 | 207 | // --------------------------------------------------------------- 208 | // Input string to textureCoordinates 209 | 210 | function sampleCoordinates() { 211 | 212 | // Draw text 213 | const lines = string.split(`\n`); 214 | const linesNumber = lines.length; 215 | textCanvas.width = stringBox.wTexture; 216 | textCanvas.height = stringBox.hTexture; 217 | textCtx.font = '100 ' + textureFontSize + 'px ' + fontName; 218 | textCtx.fillStyle = '#2a9d8f'; 219 | textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height); 220 | for (let i = 0; i < linesNumber; i++) { 221 | textCtx.fillText(lines[i], 0, (i + .8) * stringBox.hTexture / linesNumber); 222 | } 223 | 224 | // Sample coordinates 225 | if (stringBox.wTexture > 0) { 226 | 227 | // Image data to 2d array 228 | const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height); 229 | const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width)); 230 | for (let i = 0; i < textCanvas.height; i++) { 231 | for (let j = 0; j < textCanvas.width; j++) { 232 | imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0; 233 | } 234 | } 235 | 236 | if (textureCoordinates.length !== 0) { 237 | 238 | // Clean up: delete coordinates and particles which disappeared on the prev step 239 | // We need to keep same indexes for coordinates and particles to reuse old particles properly 240 | textureCoordinates = textureCoordinates.filter(c => !c.toDelete); 241 | particles = particles.filter(c => !c.toDelete); 242 | 243 | // Go through existing coordinates (old to keep, toDelete for fade-out animation) 244 | textureCoordinates.forEach(c => { 245 | if (imageMask[c.y]) { 246 | if (imageMask[c.y][c.x]) { 247 | c.old = true; 248 | if (!c.toDelete) { 249 | imageMask[c.y][c.x] = false; 250 | } 251 | } else { 252 | c.toDelete = true; 253 | } 254 | } else { 255 | c.toDelete = true; 256 | } 257 | }); 258 | } 259 | 260 | // Add new coordinates 261 | for (let i = 0; i < textCanvas.height; i++) { 262 | for (let j = 0; j < textCanvas.width; j++) { 263 | if (imageMask[i][j]) { 264 | textureCoordinates.push({ 265 | x: j, 266 | y: i, 267 | old: false, 268 | toDelete: false 269 | }) 270 | } 271 | } 272 | } 273 | 274 | } else { 275 | textureCoordinates = []; 276 | } 277 | } 278 | 279 | 280 | // --------------------------------------------------------------- 281 | // Handling params of each particle 282 | 283 | function Particle([x, y]) { 284 | this.x = x + .2 * (Math.random() - .5); 285 | this.y = y + .2 * (Math.random() - .5); 286 | this.z = 0; 287 | this.scale = .1 * Math.random(); 288 | this.maxScale = Math.pow(Math.random(), 3); 289 | 290 | this.deltaScale = .1 * .1 * Math.random(); 291 | 292 | this.toDelete = false; 293 | 294 | this.isFlying = Math.random() < .06; 295 | 296 | this.grow = function () { 297 | this.scale += this.deltaScale; 298 | if (this.scale >= this.maxScale) { 299 | this.scale = 0; 300 | } else if (this.toDelete) { 301 | this.deltaScale += .5; 302 | } 303 | if (this.isFlying) { 304 | this.y -= (7 * this.deltaScale); 305 | } 306 | } 307 | } 308 | 309 | // --------------------------------------------------------------- 310 | // Handle instances 311 | 312 | function recreateInstancedMesh() { 313 | scene.remove(instancedMesh); 314 | instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, particles.length); 315 | scene.add(instancedMesh); 316 | 317 | instancedMesh.position.x = -.5 * stringBox.wScene; 318 | instancedMesh.position.y = -.6 * stringBox.hScene; 319 | } 320 | 321 | function updateParticlesMatrices() { 322 | let idx = 0; 323 | particles.forEach(p => { 324 | p.grow(); 325 | dummy.scale.set(p.scale, p.scale, p.scale); 326 | dummy.position.set(p.x, stringBox.hScene - p.y, p.z); 327 | dummy.updateMatrix(); 328 | instancedMesh.setMatrixAt(idx, dummy.matrix); 329 | idx ++; 330 | }) 331 | instancedMesh.instanceMatrix.needsUpdate = true; 332 | } 333 | 334 | // --------------------------------------------------------------- 335 | // Move camera so the text is always visible 336 | 337 | function makeTextFitScreen() { 338 | const fov = camera.fov * (Math.PI / 180); 339 | const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect); 340 | const dx = Math.abs(.7 * stringBox.wScene / Math.tan(.5 * fovH)); 341 | const dy = Math.abs(.6 * stringBox.hScene / Math.tan(.5 * fov)); 342 | const factor = Math.max(dx, dy) / camera.position.length(); 343 | if (factor > 1) { 344 | camera.position.x *= factor; 345 | camera.position.y *= factor; 346 | camera.position.z *= factor; 347 | } 348 | } 349 | 350 | // --------------------------------------------------------------- 351 | // Cursor related 352 | 353 | function updateCursorPosition() { 354 | cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0]; 355 | cursorMesh.position.y = .4 * stringBox.hScene - stringBox.caretPosScene[1]; 356 | } 357 | 358 | function updateCursorOpacity() { 359 | let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2); 360 | 361 | if (document.hasFocus() && document.activeElement === textInputEl) { 362 | cursorMesh.material.opacity = .3 * roundPulse(2 * clock.getElapsedTime()); 363 | } else { 364 | cursorMesh.material.opacity = 0; 365 | } 366 | } -------------------------------------------------------------------------------- /js/03_flowers.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "https://cdn.skypack.dev/three@0.133.1/build/three.module"; 2 | import { OrbitControls } from 'https://cdn.skypack.dev/three@0.133.1/examples/jsm/controls/OrbitControls' 3 | 4 | // DOM selectors 5 | const containerEl = document.querySelector('.container'); 6 | const textInputEl = document.querySelector('#text-input'); 7 | 8 | // Settings 9 | const fontName = 'Baskerville'; 10 | const textureFontSize = 100; 11 | const fontScaleFactor = .085; 12 | 13 | // We need to keep the style of editable
(hidden inout field) and canvas 14 | textInputEl.style.fontSize = textureFontSize + 'px'; 15 | textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName; 16 | textInputEl.style.lineHeight = 0.9 * textureFontSize + 'px'; 17 | 18 | // 3D scene related globals 19 | let scene, camera, renderer, textCanvas, textCtx, particleGeometry, dummy, clock, cursorMesh; 20 | let flowerInstancedMesh, leafInstancedMesh, flowerMaterial, leafMaterial; 21 | 22 | // String to show 23 | let string = 'Blossom'; 24 | 25 | // Coordinates data per 2D canvas and 3D scene 26 | let textureCoordinates = []; 27 | 28 | // 1d-array of data objects to store and change params of each instance 29 | let particles = []; 30 | 31 | // Parameters of whole string per 2D canvas and 3D scene 32 | let stringBox = { 33 | wTexture: 0, 34 | wScene: 0, 35 | hTexture: 0, 36 | hScene: 0, 37 | caretPosScene: [] 38 | }; 39 | 40 | // --------------------------------------------------------------- 41 | 42 | textInputEl.innerHTML = string; 43 | textInputEl.focus(); 44 | 45 | init(); 46 | createEvents(); 47 | setCaretToEndOfInput(); 48 | handleInput(); 49 | refreshText(); 50 | render(); 51 | 52 | // --------------------------------------------------------------- 53 | 54 | function init() { 55 | camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .1, 1000); 56 | camera.position.z = 35; 57 | 58 | scene = new THREE.Scene(); 59 | 60 | renderer = new THREE.WebGLRenderer({ 61 | alpha: true 62 | }); 63 | renderer.setPixelRatio(window.devicePixelRatio); 64 | renderer.setSize(window.innerWidth, window.innerHeight); 65 | containerEl.appendChild(renderer.domElement); 66 | 67 | const orbit = new OrbitControls(camera, renderer.domElement); 68 | orbit.enablePan = false; 69 | 70 | textCanvas = document.createElement('canvas'); 71 | textCanvas.width = textCanvas.height = 0; 72 | textCtx = textCanvas.getContext('2d'); 73 | particleGeometry = new THREE.PlaneGeometry(1.2, 1.2); 74 | const flowerTexture = new THREE.TextureLoader().load('./img/flower.png'); 75 | flowerMaterial = new THREE.MeshBasicMaterial({ 76 | alphaMap: flowerTexture, 77 | opacity: .3, 78 | depthTest: false, 79 | transparent: true, 80 | }); 81 | const leafTexture = new THREE.TextureLoader().load('./img/leaf.png'); 82 | leafMaterial = new THREE.MeshBasicMaterial({ 83 | alphaMap: leafTexture, 84 | opacity: .35, 85 | depthTest: false, 86 | transparent: true, 87 | }); 88 | 89 | dummy = new THREE.Object3D(); 90 | clock = new THREE.Clock(); 91 | 92 | const cursorGeometry = new THREE.BoxGeometry(.09, 6.5, .03); 93 | cursorGeometry.translate(0, -4.4, 0) 94 | const cursorMaterial = new THREE.MeshBasicMaterial({ 95 | color: 0x000000, 96 | transparent: true, 97 | }); 98 | cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial); 99 | scene.add(cursorMesh); 100 | } 101 | 102 | 103 | // --------------------------------------------------------------- 104 | 105 | function createEvents() { 106 | document.addEventListener('keyup', () => { 107 | handleInput(); 108 | refreshText(); 109 | }); 110 | 111 | textInputEl.addEventListener('focus', () => { 112 | clock.elapsedTime = 0; 113 | }); 114 | 115 | window.addEventListener('resize', () => { 116 | camera.aspect = window.innerWidth / window.innerHeight; 117 | camera.updateProjectionMatrix(); 118 | renderer.setSize(window.innerWidth, window.innerHeight); 119 | }); 120 | } 121 | 122 | function setCaretToEndOfInput() { 123 | document.execCommand('selectAll', false, null); 124 | document.getSelection().collapseToEnd(); 125 | } 126 | 127 | function handleInput() { 128 | if (isNewLine(textInputEl.firstChild)) { 129 | textInputEl.firstChild.remove(); 130 | } 131 | if (isNewLine(textInputEl.lastChild)) { 132 | if (isNewLine(textInputEl.lastChild.previousSibling)) { 133 | textInputEl.lastChild.remove(); 134 | } 135 | } 136 | 137 | string = textInputEl.innerHTML 138 | .replaceAll("

", "\n") 139 | .replaceAll("

", "") 140 | .replaceAll("
", "\n") 141 | .replaceAll("
", "") 142 | .replaceAll("
", "") 143 | .replaceAll("
", "") 144 | .replaceAll(" ", " "); 145 | 146 | stringBox.wTexture = textInputEl.clientWidth; 147 | stringBox.wScene = stringBox.wTexture * fontScaleFactor 148 | stringBox.hTexture = textInputEl.clientHeight; 149 | stringBox.hScene = stringBox.hTexture * fontScaleFactor 150 | stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor); 151 | 152 | function isNewLine(el) { 153 | if (el) { 154 | if (el.tagName) { 155 | if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') { 156 | if (el.innerHTML === '
' || el.innerHTML === '
') { 157 | return true; 158 | } 159 | } 160 | } 161 | } 162 | return false 163 | } 164 | 165 | function getCaretCoordinates() { 166 | const range = window.getSelection().getRangeAt(0); 167 | const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0); 168 | if (needsToWorkAroundNewlineBug) { 169 | return [ 170 | range.startContainer.offsetLeft, 171 | range.startContainer.offsetTop 172 | ] 173 | } else { 174 | const rects = range.getClientRects(); 175 | if (rects[0]) { 176 | return [rects[0].left, rects[0].top] 177 | } else { 178 | document.execCommand('selectAll', false, null); 179 | return [ 180 | 0, 0 181 | ] 182 | } 183 | } 184 | } 185 | } 186 | 187 | // --------------------------------------------------------------- 188 | 189 | function render() { 190 | requestAnimationFrame(render); 191 | updateParticlesMatrices(); 192 | updateCursorOpacity(); 193 | renderer.render(scene, camera); 194 | } 195 | 196 | // --------------------------------------------------------------- 197 | 198 | function refreshText() { 199 | sampleCoordinates(); 200 | 201 | particles = textureCoordinates.map((c, cIdx) => { 202 | const x = c.x * fontScaleFactor; 203 | const y = c.y * fontScaleFactor; 204 | let p = (c.old && particles[cIdx]) ? particles[cIdx] : Math.random() > .2 ? new Flower([x, y]) : new Leaf([x, y]); 205 | if (c.toDelete) { 206 | p.toDelete = true; 207 | p.scale = p.maxScale; 208 | } 209 | return p; 210 | }); 211 | 212 | recreateInstancedMesh(); 213 | makeTextFitScreen(); 214 | updateCursorPosition(); 215 | } 216 | 217 | // --------------------------------------------------------------- 218 | // Input string to textureCoordinates 219 | 220 | function sampleCoordinates() { 221 | // Draw text 222 | const lines = string.split(`\n`); 223 | const linesNumber = lines.length; 224 | textCanvas.width = stringBox.wTexture; 225 | textCanvas.height = stringBox.hTexture; 226 | textCtx.font = '100 ' + textureFontSize + 'px ' + fontName; 227 | textCtx.fillStyle = '#2a9d8f'; 228 | textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height); 229 | for (let i = 0; i < linesNumber; i++) { 230 | textCtx.fillText(lines[i], 0, (i + .8) * stringBox.hTexture / linesNumber); 231 | } 232 | 233 | // Sample coordinates 234 | if (stringBox.wTexture > 0) { 235 | 236 | // Image data to 2d array 237 | const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height); 238 | const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width)); 239 | for (let i = 0; i < textCanvas.height; i++) { 240 | for (let j = 0; j < textCanvas.width; j++) { 241 | imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0; 242 | } 243 | } 244 | 245 | if (textureCoordinates.length !== 0) { 246 | 247 | // Clean up: delete coordinates and particles which disappeared on the prev step 248 | // We need to keep same indexes for coordinates and particles to reuse old particles properly 249 | textureCoordinates = textureCoordinates.filter(c => !c.toDelete); 250 | particles = particles.filter(c => !c.toDelete); 251 | 252 | // Go through existing coordinates (old to keep, toDelete for fade-out animation) 253 | textureCoordinates.forEach(c => { 254 | if (imageMask[c.y]) { 255 | if (imageMask[c.y][c.x]) { 256 | c.old = true; 257 | if (!c.toDelete) { 258 | imageMask[c.y][c.x] = false; 259 | } 260 | } else { 261 | c.toDelete = true; 262 | } 263 | } else { 264 | c.toDelete = true; 265 | } 266 | }); 267 | } 268 | 269 | // Add new coordinates 270 | for (let i = 0; i < textCanvas.height; i++) { 271 | for (let j = 0; j < textCanvas.width; j++) { 272 | if (imageMask[i][j]) { 273 | textureCoordinates.push({ 274 | x: j, 275 | y: i, 276 | old: false, 277 | toDelete: false 278 | }) 279 | } 280 | } 281 | } 282 | 283 | } else { 284 | textureCoordinates = []; 285 | } 286 | } 287 | 288 | 289 | // --------------------------------------------------------------- 290 | // Handling params of each particle 291 | 292 | function Flower([x, y]) { 293 | this.type = 0; 294 | this.x = x + .2 * (Math.random() - .5); 295 | this.y = y + .2 * (Math.random() - .5); 296 | this.z = 0; 297 | 298 | this.color = Math.random() * 60; 299 | 300 | this.isGrowing = true; 301 | this.toDelete = false; 302 | 303 | this.scale = 0; 304 | this.maxScale = .9 * Math.pow(Math.random(), 20); 305 | this.deltaScale = .03 + .1 * Math.random(); 306 | this.age = Math.PI * Math.random(); 307 | this.ageDelta = .01 + .02 * Math.random(); 308 | this.rotationZ = .5 * Math.random() * Math.PI; 309 | 310 | this.grow = function () { 311 | this.age += this.ageDelta; 312 | if (this.isGrowing) { 313 | this.deltaScale *= .99; 314 | this.scale += this.deltaScale; 315 | if (this.scale >= this.maxScale) { 316 | this.isGrowing = false; 317 | } 318 | } else if (this.toDelete) { 319 | this.deltaScale *= 1.1; 320 | this.scale -= this.deltaScale; 321 | if (this.scale <= 0) { 322 | this.scale = 0; 323 | this.deltaScale = 0; 324 | } 325 | } else { 326 | this.scale = this.maxScale + .2 * Math.sin(this.age); 327 | this.rotationZ += .001 * Math.cos(this.age); 328 | } 329 | } 330 | } 331 | 332 | function Leaf([x, y]) { 333 | this.type = 1; 334 | this.x = x; 335 | this.y = y; 336 | this.z = 0; 337 | 338 | this.rotationZ = .6 * (Math.random() - .5) * Math.PI; 339 | 340 | this.color = 100 + Math.random() * 50; 341 | 342 | this.isGrowing = true; 343 | this.toDelete = false; 344 | 345 | this.scale = 0; 346 | this.maxScale = .1 + .7 * Math.pow(Math.random(), 7); 347 | this.deltaScale = .03 + .03 * Math.random(); 348 | this.age = Math.PI * Math.random(); 349 | 350 | this.grow = function () { 351 | if (this.isGrowing) { 352 | this.deltaScale *= .99; 353 | this.scale += this.deltaScale; 354 | if (this.scale >= this.maxScale) { 355 | this.isGrowing = false; 356 | } 357 | } 358 | if (this.toDelete) { 359 | this.deltaScale *= 1.1; 360 | this.scale -= this.deltaScale; 361 | if (this.scale <= 0) { 362 | this.scale = 0; 363 | } 364 | } 365 | } 366 | } 367 | 368 | // --------------------------------------------------------------- 369 | // Handle instances 370 | 371 | function recreateInstancedMesh() { 372 | scene.remove(flowerInstancedMesh, leafInstancedMesh); 373 | const totalNumberOfFlowers = particles.filter(v => v.type === 0).length; 374 | const totalNumberOfLeafs = particles.filter(v => v.type === 1).length; 375 | flowerInstancedMesh = new THREE.InstancedMesh(particleGeometry, flowerMaterial, totalNumberOfFlowers); 376 | leafInstancedMesh = new THREE.InstancedMesh(particleGeometry, leafMaterial, totalNumberOfLeafs); 377 | scene.add(flowerInstancedMesh, leafInstancedMesh); 378 | 379 | let flowerIdx = 0; 380 | let leafIdx = 0; 381 | particles.forEach(p => { 382 | if (p.type === 0) { 383 | flowerInstancedMesh.setColorAt(flowerIdx, new THREE.Color("hsl(" + p.color + ", 100%, 50%)")); 384 | flowerIdx ++; 385 | } else { 386 | leafInstancedMesh.setColorAt(leafIdx, new THREE.Color("hsl(" + p.color + ", 100%, 20%)")); 387 | leafIdx ++; 388 | } 389 | }) 390 | 391 | leafInstancedMesh.position.x = flowerInstancedMesh.position.x = -.5 * stringBox.wScene; 392 | leafInstancedMesh.position.y = flowerInstancedMesh.position.y = -.6 * stringBox.hScene; 393 | } 394 | 395 | function updateParticlesMatrices() { 396 | let flowerIdx = 0; 397 | let leafIdx = 0; 398 | particles.forEach(p => { 399 | p.grow(); 400 | dummy.quaternion.copy(camera.quaternion); 401 | dummy.rotation.z += p.rotationZ; 402 | dummy.scale.set(p.scale, p.scale, p.scale); 403 | dummy.position.set(p.x, stringBox.hScene - p.y, p.z); 404 | if (p.type === 1) { 405 | dummy.position.y += .5 * p.scale; 406 | } 407 | dummy.updateMatrix(); 408 | if (p.type === 0) { 409 | flowerInstancedMesh.setMatrixAt(flowerIdx, dummy.matrix); 410 | flowerIdx ++; 411 | } else { 412 | leafInstancedMesh.setMatrixAt(leafIdx, dummy.matrix); 413 | leafIdx ++; 414 | } 415 | }) 416 | flowerInstancedMesh.instanceMatrix.needsUpdate = true; 417 | leafInstancedMesh.instanceMatrix.needsUpdate = true;} 418 | 419 | // --------------------------------------------------------------- 420 | // Move camera so the text is always visible 421 | 422 | function makeTextFitScreen() { 423 | const fov = camera.fov * (Math.PI / 180); 424 | const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect); 425 | const dx = Math.abs(.7 * stringBox.wScene / Math.tan(.5 * fovH)); 426 | const dy = Math.abs(.6 * stringBox.hScene / Math.tan(.5 * fov)); 427 | const factor = Math.max(dx, dy) / camera.position.length(); 428 | if (factor > 1) { 429 | camera.position.x *= factor; 430 | camera.position.y *= factor; 431 | camera.position.z *= factor; 432 | } 433 | } 434 | 435 | // --------------------------------------------------------------- 436 | // Cursor related 437 | 438 | function updateCursorPosition() { 439 | cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0]; 440 | cursorMesh.position.y = .4 * stringBox.hScene - stringBox.caretPosScene[1]; 441 | } 442 | 443 | function updateCursorOpacity() { 444 | let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2); 445 | 446 | if (document.hasFocus() && document.activeElement === textInputEl) { 447 | cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime()); 448 | } else { 449 | cursorMesh.material.opacity = 0; 450 | } 451 | } -------------------------------------------------------------------------------- /js/04_eyes.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "https://cdn.skypack.dev/three@0.133.1/build/three.module"; 2 | import { OrbitControls } from 'https://cdn.skypack.dev/three@0.133.1/examples/jsm/controls/OrbitControls' 3 | import { GLTFLoader } from 'https://cdn.skypack.dev/three@0.133.1/examples/jsm/loaders/GLTFLoader.js'; 4 | 5 | // DOM selectors 6 | const containerEl = document.querySelector('.container'); 7 | const textInputEl = document.querySelector('#text-input'); 8 | 9 | // Settings 10 | const fontName = 'system-ui'; 11 | const textureFontSize = 50; 12 | const fontScaleFactor = .15; 13 | 14 | // We need to keep the style of editable
(hidden inout field) and canvas 15 | textInputEl.style.fontSize = textureFontSize + 'px'; 16 | textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName; 17 | textInputEl.style.lineHeight = 1.1 * textureFontSize + 'px'; 18 | textInputEl.style.fontWeight = 100; 19 | 20 | // 3D scene related globals 21 | let scene, camera, renderer, textCanvas, textCtx, particleGeometry, particleMaterial, instancedMesh, dummy, clock, cursorMesh; 22 | let rayCaster, mouse, trackingPlane; 23 | let intersect = new THREE.Vector3(10, 3, 7); 24 | let intersectTarget = intersect.clone(); 25 | 26 | // String to show 27 | let string = "Gaze"; 28 | 29 | // Coordinates data per 2D canvas and 3D scene 30 | let textureCoordinates = []; 31 | 32 | // 1d-array of data objects to store and change params of each instance 33 | let particles = []; 34 | 35 | // Parameters of whole string per 2D canvas and 3D scene 36 | let stringBox = { 37 | wTexture: 0, 38 | wScene: 0, 39 | hTexture: 0, 40 | hScene: 0, 41 | caretPosScene: [] 42 | }; 43 | 44 | // --------------------------------------------------------------- 45 | 46 | init(); 47 | createEvents(); 48 | 49 | // --------------------------------------------------------------- 50 | 51 | function init() { 52 | camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .1, 1000); 53 | camera.position.z = 20; 54 | 55 | scene = new THREE.Scene(); 56 | 57 | renderer = new THREE.WebGLRenderer({ 58 | alpha: true 59 | }); 60 | renderer.setPixelRatio(window.devicePixelRatio); 61 | renderer.setSize(window.innerWidth, window.innerHeight); 62 | renderer.shadowMap.enabled = true; 63 | containerEl.appendChild(renderer.domElement); 64 | 65 | const orbit = new OrbitControls(camera, renderer.domElement); 66 | orbit.enablePan = false; 67 | 68 | textCanvas = document.createElement('canvas'); 69 | textCanvas.width = textCanvas.height = 0; 70 | textCtx = textCanvas.getContext('2d'); 71 | 72 | const ambientLight = new THREE.AmbientLight(0xffffff, 1.5); 73 | scene.add(ambientLight); 74 | const pointLight = new THREE.PointLight(0xffffff, 1.2); 75 | pointLight.position.set(-20, 10, 20); 76 | pointLight.castShadow = true; 77 | pointLight.shadow.mapSize.width = pointLight.shadow.mapSize.height = 2048; 78 | scene.add(pointLight); 79 | 80 | const planeGeometry = new THREE.PlaneGeometry(1000, 1000); 81 | const trackingPlaneMaterial = new THREE.MeshBasicMaterial({ visible: false }); 82 | trackingPlane = new THREE.Mesh(planeGeometry, trackingPlaneMaterial); 83 | trackingPlane.position.z = 6; 84 | scene.add(trackingPlane); 85 | 86 | const shadowPlaneMaterial = new THREE.ShadowMaterial({ 87 | opacity: .2 88 | }); 89 | const shadowPlaneMesh = new THREE.Mesh(planeGeometry, shadowPlaneMaterial); 90 | shadowPlaneMesh.position.z = -.2; 91 | shadowPlaneMesh.receiveShadow = true; 92 | scene.add(shadowPlaneMesh); 93 | 94 | dummy = new THREE.Object3D(); 95 | clock = new THREE.Clock(); 96 | 97 | const cursorGeometry = new THREE.BoxGeometry(.09, 6, .03); 98 | cursorGeometry.translate(0, -4.3, 0) 99 | const cursorMaterial = new THREE.MeshBasicMaterial({ 100 | color: 0x111123, 101 | transparent: true, 102 | }); 103 | cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial); 104 | scene.add(cursorMesh); 105 | 106 | rayCaster = new THREE.Raycaster(); 107 | mouse = new THREE.Vector2(0); 108 | 109 | const modalLoader = new GLTFLoader(); 110 | modalLoader.load( './models/eye-realistic.glb', gltf => { 111 | particleGeometry = gltf.scene.children[2].geometry; 112 | particleMaterial = gltf.scene.children[2].material; 113 | textInputEl.innerHTML = string; 114 | textInputEl.focus(); 115 | setCaretToEndOfInput(); 116 | handleInput(); 117 | refreshText(); 118 | render(); 119 | }); 120 | } 121 | 122 | 123 | // --------------------------------------------------------------- 124 | 125 | function createEvents() { 126 | document.addEventListener('keyup', () => { 127 | handleInput(); 128 | refreshText(); 129 | }); 130 | 131 | textInputEl.addEventListener('focus', () => { 132 | clock.elapsedTime = 0; 133 | }); 134 | 135 | window.addEventListener('mousemove', e => { 136 | mouse.x = (e.clientX / window.innerWidth) * 2 - 1; 137 | mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; 138 | rayCaster.setFromCamera(mouse, camera); 139 | intersectTarget = rayCaster.intersectObject(trackingPlane)[0].point; 140 | intersectTarget.x += .5 * stringBox.wScene; 141 | intersectTarget.y += .5 * stringBox.hScene; 142 | }); 143 | 144 | window.addEventListener('resize', () => { 145 | camera.aspect = window.innerWidth / window.innerHeight; 146 | camera.updateProjectionMatrix(); 147 | renderer.setSize(window.innerWidth, window.innerHeight); 148 | }); 149 | } 150 | 151 | function setCaretToEndOfInput() { 152 | document.execCommand('selectAll', false, null); 153 | document.getSelection().collapseToEnd(); 154 | } 155 | 156 | function handleInput() { 157 | if (isNewLine(textInputEl.firstChild)) { 158 | textInputEl.firstChild.remove(); 159 | } 160 | if (isNewLine(textInputEl.lastChild)) { 161 | if (isNewLine(textInputEl.lastChild.previousSibling)) { 162 | textInputEl.lastChild.remove(); 163 | } 164 | } 165 | 166 | string = textInputEl.innerHTML 167 | .replaceAll("

", "\n") 168 | .replaceAll("

", "") 169 | .replaceAll("
", "\n") 170 | .replaceAll("
", "") 171 | .replaceAll("
", "") 172 | .replaceAll("
", "") 173 | .replaceAll(" ", " "); 174 | 175 | stringBox.wTexture = textInputEl.clientWidth; 176 | stringBox.wScene = stringBox.wTexture * fontScaleFactor 177 | stringBox.hTexture = textInputEl.clientHeight; 178 | stringBox.hScene = stringBox.hTexture * fontScaleFactor 179 | stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor); 180 | 181 | function isNewLine(el) { 182 | if (el) { 183 | if (el.tagName) { 184 | if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') { 185 | if (el.innerHTML === '
' || el.innerHTML === '
') { 186 | return true; 187 | } 188 | } 189 | } 190 | } 191 | return false 192 | } 193 | 194 | function getCaretCoordinates() { 195 | const range = window.getSelection().getRangeAt(0); 196 | const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0); 197 | if (needsToWorkAroundNewlineBug) { 198 | return [ 199 | range.startContainer.offsetLeft, 200 | range.startContainer.offsetTop 201 | ] 202 | } else { 203 | const rects = range.getClientRects(); 204 | if (rects[0]) { 205 | return [rects[0].left, rects[0].top] 206 | } else { 207 | document.execCommand('selectAll', false, null); 208 | return [ 209 | 0, 0 210 | ] 211 | } 212 | } 213 | } 214 | } 215 | 216 | // --------------------------------------------------------------- 217 | 218 | function render() { 219 | requestAnimationFrame(render); 220 | updateParticlesMatrices(); 221 | updateCursorOpacity(); 222 | 223 | let lerp = (start, end, amt) => (1 - amt) * start + amt * end; 224 | intersect.x = lerp(intersect.x, intersectTarget.x, .1); 225 | intersect.y = lerp(intersect.y, intersectTarget.y, .1); 226 | 227 | renderer.render(scene, camera); 228 | } 229 | 230 | // --------------------------------------------------------------- 231 | 232 | function refreshText() { 233 | sampleCoordinates(); 234 | 235 | particles = textureCoordinates.map((c, cIdx) => { 236 | const x = c.x * fontScaleFactor; 237 | const y = c.y * fontScaleFactor; 238 | let p = (c.old && particles[cIdx]) ? particles[cIdx] : new Particle([x, y]); 239 | if (c.toDelete) { 240 | p.toDelete = true; 241 | p.scale = p.maxScale; 242 | } 243 | return p; 244 | }); 245 | 246 | recreateInstancedMesh(); 247 | makeTextFitScreen(); 248 | updateCursorPosition(); 249 | } 250 | 251 | // --------------------------------------------------------------- 252 | // Input string to textureCoordinates 253 | 254 | function sampleCoordinates() { 255 | 256 | // Draw text 257 | const lines = string.split(`\n`); 258 | const linesNumber = lines.length; 259 | textCanvas.width = stringBox.wTexture; 260 | textCanvas.height = stringBox.hTexture; 261 | textCtx.font = '100 ' + textureFontSize + 'px ' + fontName; 262 | textCtx.fillStyle = '#2a9d8f'; 263 | textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height); 264 | for (let i = 0; i < linesNumber; i++) { 265 | textCtx.fillText(lines[i], 0, (i + .8) * stringBox.hTexture / linesNumber); 266 | } 267 | 268 | // Sample coordinates 269 | if (stringBox.wTexture > 0) { 270 | 271 | // Image data to 2d array 272 | const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height); 273 | const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width)); 274 | for (let i = 0; i < textCanvas.height; i++) { 275 | for (let j = 0; j < textCanvas.width; j++) { 276 | imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0; 277 | } 278 | } 279 | 280 | if (textureCoordinates.length !== 0) { 281 | 282 | // Clean up: delete coordinates and particles which disappeared on the prev step 283 | // We need to keep same indexes for coordinates and particles to reuse old particles properly 284 | textureCoordinates = textureCoordinates.filter(c => !c.toDelete); 285 | particles = particles.filter(c => !c.toDelete); 286 | 287 | // Go through existing coordinates (old to keep, toDelete for fade-out animation) 288 | textureCoordinates.forEach(c => { 289 | if (imageMask[c.y]) { 290 | if (imageMask[c.y][c.x]) { 291 | c.old = true; 292 | if (!c.toDelete) { 293 | imageMask[c.y][c.x] = false; 294 | } 295 | } else { 296 | c.toDelete = true; 297 | } 298 | } else { 299 | c.toDelete = true; 300 | } 301 | }); 302 | } 303 | 304 | // Add new coordinates 305 | for (let i = 0; i < textCanvas.height; i++) { 306 | for (let j = 0; j < textCanvas.width; j++) { 307 | if (imageMask[i][j]) { 308 | textureCoordinates.push({ 309 | x: j, 310 | y: i, 311 | old: false, 312 | toDelete: false 313 | }) 314 | } 315 | } 316 | } 317 | 318 | } else { 319 | textureCoordinates = []; 320 | } 321 | } 322 | 323 | 324 | // --------------------------------------------------------------- 325 | // Handling params of each particle 326 | 327 | function Particle([x, y]) { 328 | this.x = x + .1 * (Math.random() - .5); 329 | this.y = y + .1 * (Math.random() - .5); 330 | this.z = 0; 331 | 332 | this.isGrowing = true; 333 | this.toDelete = false; 334 | 335 | this.scale = 0; 336 | this.maxScale = 20 * (.2 + Math.pow(Math.random(), 5)); 337 | this.deltaScale = 1; 338 | 339 | this.grow = function () { 340 | if (this.isGrowing) { 341 | this.deltaScale *= .99; 342 | this.scale += this.deltaScale; 343 | if (this.scale >= this.maxScale) { 344 | this.isGrowing = false; 345 | } 346 | } 347 | if (this.toDelete) { 348 | this.deltaScale *= 1.1; 349 | this.scale -= this.deltaScale; 350 | if (this.scale <= 0) { 351 | this.scale = 0; 352 | } 353 | } 354 | } 355 | } 356 | 357 | // --------------------------------------------------------------- 358 | // Handle instances 359 | 360 | function recreateInstancedMesh() { 361 | scene.remove(instancedMesh); 362 | instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, particles.length); 363 | scene.add(instancedMesh); 364 | 365 | instancedMesh.position.x = -.5 * stringBox.wScene; 366 | instancedMesh.position.y = -.6 * stringBox.hScene; 367 | 368 | instancedMesh.castShadow = true; 369 | } 370 | 371 | function updateParticlesMatrices() { 372 | let idx = 0; 373 | particles.forEach(p => { 374 | p.grow(); 375 | dummy.position.set(p.x, stringBox.hScene - p.y, p.z); 376 | dummy.lookAt(intersect); 377 | dummy.rotation.y = Math.max(dummy.rotation.y, -1) 378 | dummy.rotation.y = Math.min(dummy.rotation.y, 1) 379 | dummy.scale.set(p.scale, p.scale, p.scale); 380 | dummy.updateMatrix(); 381 | instancedMesh.setMatrixAt(idx, dummy.matrix); 382 | idx ++; 383 | }) 384 | instancedMesh.instanceMatrix.needsUpdate = true; 385 | } 386 | 387 | // --------------------------------------------------------------- 388 | // Move camera so the text is always visible 389 | 390 | function makeTextFitScreen() { 391 | const fov = camera.fov * (Math.PI / 180); 392 | const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect); 393 | const dx = Math.abs(.7 * stringBox.wScene / Math.tan(.5 * fovH)); 394 | const dy = Math.abs(.6 * stringBox.hScene / Math.tan(.5 * fov)); 395 | const factor = Math.max(dx, dy) / camera.position.length(); 396 | if (factor > 1) { 397 | camera.position.x *= factor; 398 | camera.position.y *= factor; 399 | camera.position.z *= factor; 400 | } 401 | } 402 | 403 | // --------------------------------------------------------------- 404 | // Cursor related 405 | 406 | function updateCursorPosition() { 407 | cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0]; 408 | cursorMesh.position.y = .4 * stringBox.hScene - stringBox.caretPosScene[1]; 409 | } 410 | 411 | function updateCursorOpacity() { 412 | let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2); 413 | 414 | if (document.hasFocus() && document.activeElement === textInputEl) { 415 | cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime()); 416 | } else { 417 | cursorMesh.material.opacity = 0; 418 | } 419 | } -------------------------------------------------------------------------------- /models/eye-realistic.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/WebGL-typing-tutorial/88daf57fb293a3df6fdc0a90740d07b335761069/models/eye-realistic.glb --------------------------------------------------------------------------------