(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("
", "")
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
--------------------------------------------------------------------------------