-
-
Notifications
You must be signed in to change notification settings - Fork 35.7k
/
Copy pathoptimize-lots-of-objects-animated.html
492 lines (464 loc) · 21.5 KB
/
optimize-lots-of-objects-animated.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8">
<title>Optimize Lots of Objects Animated</title>
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@threejs">
<meta name="twitter:title" content="Three.js – Optimize Lots of Objects Animated">
<meta property="og:image" content="https://threejs.org/files/share.png">
<link rel="shortcut icon" href="../../files/favicon_white.ico" media="(prefers-color-scheme: dark)">
<link rel="shortcut icon" href="../../files/favicon.ico" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="../resources/lesson.css">
<link rel="stylesheet" href="../resources/lang.css">
<script type="importmap">
{
"imports": {
"three": "../../build/three.module.js"
}
}
</script>
</head>
<body>
<div class="container">
<div class="lesson-title">
<h1>Optimize Lots of Objects Animated</h1>
</div>
<div class="lesson">
<div class="lesson-main">
<p>This article is a continuation of <a href="optimize-lots-of-objects.html">an article about optimizing lots of objects
</a>. If you haven't read that
yet please read it before proceeding. </p>
<p>In the previous article we merged around 19000 cubes into a
single geometry. This had the advantage that it optimized our drawing
of 19000 cubes but it had the disadvantage of make it harder to
move any individual cube.</p>
<p>Depending on what we are trying to accomplish there are different solutions.
In this case let's graph multiple sets of data and animate between the sets.</p>
<p>The first thing we need to do is get multiple sets of data. Ideally we'd
probably pre-process data offline but in this case let's load 2 sets of
data and generate 2 more</p>
<p>Here's our old loading code</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
.then(parseData)
.then(addBoxes)
.then(render);
</pre>
<p>Let's change it to something like this</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">async function loadData(info) {
const text = await loadFile(info.url);
info.file = parseData(text);
}
async function loadAll() {
const fileInfos = [
{name: 'men', hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
{name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
];
await Promise.all(fileInfos.map(loadData));
...
}
loadAll();
</pre>
<p>The code above will load all the files in <code class="notranslate" translate="no">fileInfos</code> and when done each object
in <code class="notranslate" translate="no">fileInfos</code> will have a <code class="notranslate" translate="no">file</code> property with the loaded file. <code class="notranslate" translate="no">name</code> and <code class="notranslate" translate="no">hueRange</code>
we'll use later. <code class="notranslate" translate="no">name</code> will be for a UI field. <code class="notranslate" translate="no">hueRange</code> will be used to
choose a range of hues to map over.</p>
<p>The two files above are apparently the number of men per area and the number of
women per area as of 2010. Note, I have no idea if this data is correct but
it's not important really. The important part is showing different sets
of data.</p>
<p>Let's generate 2 more sets of data. One being the places where the number
men are greater than the number of women and vice versa, the places where
the number of women are greater than the number of men. </p>
<p>The first thing let's write a function that given a 2 dimensional array
of arrays like we had before will map over it to generate a new 2 dimensional
array of arrays</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function mapValues(data, fn) {
return data.map((row, rowNdx) => {
return row.map((value, colNdx) => {
return fn(value, rowNdx, colNdx);
});
});
}
</pre>
<p>Like the normal <code class="notranslate" translate="no">Array.map</code> function the <code class="notranslate" translate="no">mapValues</code> function calls a function
<code class="notranslate" translate="no">fn</code> for each value in the array of arrays. It passes it the value and both the
row and column indices.</p>
<p>Now let's make some code to generate a new file that is a comparison between 2
files</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function makeDiffFile(baseFile, otherFile, compareFn) {
let min;
let max;
const baseData = baseFile.data;
const otherData = otherFile.data;
const data = mapValues(baseData, (base, rowNdx, colNdx) => {
const other = otherData[rowNdx][colNdx];
if (base === undefined || other === undefined) {
return undefined;
}
const value = compareFn(base, other);
min = Math.min(min === undefined ? value : min, value);
max = Math.max(max === undefined ? value : max, value);
return value;
});
// make a copy of baseFile and replace min, max, and data
// with the new data
return {...baseFile, min, max, data};
}
</pre>
<p>The code above uses <code class="notranslate" translate="no">mapValues</code> to generate a new set of data that is
a comparison based on the <code class="notranslate" translate="no">compareFn</code> function passed in. It also tracks
the <code class="notranslate" translate="no">min</code> and <code class="notranslate" translate="no">max</code> comparison results. Finally it makes a new file with
all the same properties as <code class="notranslate" translate="no">baseFile</code> except with a new <code class="notranslate" translate="no">min</code>, <code class="notranslate" translate="no">max</code> and <code class="notranslate" translate="no">data</code>.</p>
<p>Then let's use that to make 2 new sets of data</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">{
const menInfo = fileInfos[0];
const womenInfo = fileInfos[1];
const menFile = menInfo.file;
const womenFile = womenInfo.file;
function amountGreaterThan(a, b) {
return Math.max(a - b, 0);
}
fileInfos.push({
name: '>50%men',
hueRange: [0.6, 1.1],
file: makeDiffFile(menFile, womenFile, (men, women) => {
return amountGreaterThan(men, women);
}),
});
fileInfos.push({
name: '>50% women',
hueRange: [0.0, 0.4],
file: makeDiffFile(womenFile, menFile, (women, men) => {
return amountGreaterThan(women, men);
}),
});
}
</pre>
<p>Now let's generate a UI to select between these sets of data. First we need
some UI html</p>
<pre class="prettyprint showlinemods notranslate lang-html" translate="no"><body>
<canvas id="c"></canvas>
+ <div id="ui"></div>
</body>
</pre>
<p>and some CSS to make it appear in the top left area</p>
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">#ui {
position: absolute;
left: 1em;
top: 1em;
}
#ui>div {
font-size: 20pt;
padding: 1em;
display: inline-block;
}
#ui>div.selected {
color: red;
}
</pre>
<p>Then we can go over each file and generate a set of merged boxes per
set of data and an element which when hovered over will show that set
and hide all others.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">// show the selected data, hide the rest
function showFileInfo(fileInfos, fileInfo) {
fileInfos.forEach((info) => {
const visible = fileInfo === info;
info.root.visible = visible;
info.elem.className = visible ? 'selected' : '';
});
requestRenderIfNotRequested();
}
const uiElem = document.querySelector('#ui');
fileInfos.forEach((info) => {
const boxes = addBoxes(info.file, info.hueRange);
info.root = boxes;
const div = document.createElement('div');
info.elem = div;
div.textContent = info.name;
uiElem.appendChild(div);
div.addEventListener('mouseover', () => {
showFileInfo(fileInfos, info);
});
});
// show the first set of data
showFileInfo(fileInfos, fileInfos[0]);
</pre>
<p>The one more change we need from the previous example is we need to make
<code class="notranslate" translate="no">addBoxes</code> take a <code class="notranslate" translate="no">hueRange</code></p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">-function addBoxes(file) {
+function addBoxes(file, hueRange) {
...
// compute a color
- const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
+ const hue = THREE.MathUtils.lerp(...hueRange, amount);
...
</pre>
<p>and with that we should be able to show 4 sets of data. Hover the mouse over the labels
or touch them to switch sets</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/lots-of-objects-multiple-data-sets.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/lots-of-objects-multiple-data-sets.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Note, there are a few strange data points that really stick out. I wonder what's up
with those!??! In any case how do we animate between these 4 sets of data.</p>
<p>Lots of ideas.</p>
<ul>
<li><p>Just fade between them using <a href="/docs/#api/en/materials/Material.opacity"><code class="notranslate" translate="no">Material.opacity</code></a></p>
<p>The problem with this solution is the cubes perfectly overlap which
means there will be z-fighting issues. It's possible we could fix
that by changing the depth function and using blending. We should
probably look into it.</p>
</li>
<li><p>Scale up the set we want to see and scale down the other sets</p>
<p>Because all the boxes have their origin at the center of the planet
if we scale them below 1.0 they will sink into the planet. At first that
sounds like a good idea but the issue is all the low height boxes
will disappear almost immediately and not be replaced until the new
data set scales up to 1.0. This makes the transition not very pleasant.
We could maybe fix that with a fancy custom shader.</p>
</li>
<li><p>Use Morphtargets</p>
<p>Morphtargets are a way were we supply multiple values for each vertex
in the geometry and <em>morph</em> or lerp (linear interpolate) between them.
Morphtargets are most commonly used for facial animation of 3D characters
but that's not their only use.</p>
</li>
</ul>
<p>Let's try morphtargets.</p>
<p>We'll still make a geometry for each set of data but we'll then extract
the <code class="notranslate" translate="no">position</code> attribute from each one and use them as morphtargets.</p>
<p>First let's change <code class="notranslate" translate="no">addBoxes</code> to just make and return the merged geometry.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">-function addBoxes(file, hueRange) {
+function makeBoxes(file, hueRange) {
const {min, max, data} = file;
const range = max - min;
...
- const mergedGeometry = BufferGeometryUtils.mergeGeometries(
- geometries, false);
- const material = new THREE.MeshBasicMaterial({
- vertexColors: true,
- });
- const mesh = new THREE.Mesh(mergedGeometry, material);
- scene.add(mesh);
- return mesh;
+ return BufferGeometryUtils.mergeGeometries(
+ geometries, false);
}
</pre>
<p>There's one more thing we need to do here though. Morphtargets are required to
all have exactly the same number of vertices. Vertex #123 in one target needs
have a corresponding Vertex #123 in all other targets. But, as it is now
different data sets might have some data points with no data so no box will be
generated for that point which would mean no corresponding vertices for another
set. So, we need to check across all data sets and either always generate
something if there is data in any set or, generate nothing if there is data
missing in any set. Let's do the latter.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
+ for (const fileInfo of fileInfos) {
+ if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
+ return true;
+ }
+ }
+ return false;
+}
-function makeBoxes(file, hueRange) {
+function makeBoxes(file, hueRange, fileInfos) {
const {min, max, data} = file;
const range = max - min;
...
const geometries = [];
data.forEach((row, latNdx) => {
row.forEach((value, lonNdx) => {
+ if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
+ return;
+ }
const amount = (value - min) / range;
...
</pre>
<p>Now we'll change the code that was calling <code class="notranslate" translate="no">addBoxes</code> to use <code class="notranslate" translate="no">makeBoxes</code>
and setup morphtargets</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+// make geometry for each data set
+const geometries = fileInfos.map((info) => {
+ return makeBoxes(info.file, info.hueRange, fileInfos);
+});
+
+// use the first geometry as the base
+// and add all the geometries as morphtargets
+const baseGeometry = geometries[0];
+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
+ const attribute = geometry.getAttribute('position');
+ const name = `target${ndx}`;
+ attribute.name = name;
+ return attribute;
+});
+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
+ const attribute = geometry.getAttribute('color');
+ const name = `target${ndx}`;
+ attribute.name = name;
+ return attribute;
+});
+const material = new THREE.MeshBasicMaterial({
+ vertexColors: true,
+});
+const mesh = new THREE.Mesh(baseGeometry, material);
+scene.add(mesh);
const uiElem = document.querySelector('#ui');
fileInfos.forEach((info) => {
- const boxes = addBoxes(info.file, info.hueRange);
- info.root = boxes;
const div = document.createElement('div');
info.elem = div;
div.textContent = info.name;
uiElem.appendChild(div);
function show() {
showFileInfo(fileInfos, info);
}
div.addEventListener('mouseover', show);
div.addEventListener('touchstart', show);
});
// show the first set of data
showFileInfo(fileInfos, fileInfos[0]);
</pre>
<p>Above we make geometry for each data set, use the first one as the base,
then get a <code class="notranslate" translate="no">position</code> attribute from each geometry and add it as
a morphtarget to the base geometry for <code class="notranslate" translate="no">position</code>.</p>
<p>Now we need to change how we're showing and hiding the various data sets.
Instead of showing or hiding a mesh we need to change the influence of the
morphtargets. For the data set we want to see we need to have an influence of 1
and for all the ones we don't want to see to we need to have an influence of 0.</p>
<p>We could just set them to 0 or 1 directly but if we did that we wouldn't see any
animation, it would just snap which would be no different than what we already
have. We could also write some custom animation code which would be easy but
because the original webgl globe uses
<a href="https://github.com/tweenjs/tween.js/">an animation library</a> let's use the same one here.</p>
<p>We need to include the library</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import TWEEN from 'three/addons/libs/tween.module.js';
</pre>
<p>And then create a <code class="notranslate" translate="no">Tween</code> to animate the influences.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">// show the selected data, hide the rest
function showFileInfo(fileInfos, fileInfo) {
+ const targets = {};
- fileInfos.forEach((info) => {
+ fileInfos.forEach((info, i) => {
const visible = fileInfo === info;
- info.root.visible = visible;
info.elem.className = visible ? 'selected' : '';
+ targets[i] = visible ? 1 : 0;
});
+ const durationInMs = 1000;
+ new TWEEN.Tween(mesh.morphTargetInfluences)
+ .to(targets, durationInMs)
+ .start();
requestRenderIfNotRequested();
}
</pre>
<p>We're also suppose to call <code class="notranslate" translate="no">TWEEN.update</code> every frame inside our render loop
but that points out a problem. "tween.js" is designed for continuous rendering
but we are <a href="rendering-on-demand.html">rendering on demand</a>. We could
switch to continuous rendering but it's sometimes nice to only render on demand
as it well stop using the user's power when nothing is happening
so let's see if we can make it animate on demand.</p>
<p>We'll make a <code class="notranslate" translate="no">TweenManager</code> to help. We'll use it to create the <code class="notranslate" translate="no">Tween</code>s and
track them. It will have an <code class="notranslate" translate="no">update</code> method that will return <code class="notranslate" translate="no">true</code>
if we need to call it again and <code class="notranslate" translate="no">false</code> if all the animations are finished.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">class TweenManger {
constructor() {
this.numTweensRunning = 0;
}
_handleComplete() {
--this.numTweensRunning;
console.assert(this.numTweensRunning >= 0);
}
createTween(targetObject) {
const self = this;
++this.numTweensRunning;
let userCompleteFn = () => {};
// create a new tween and install our own onComplete callback
const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
self._handleComplete();
userCompleteFn.call(this, ...args);
});
// replace the tween's onComplete function with our own
// so we can call the user's callback if they supply one.
tween.onComplete = (fn) => {
userCompleteFn = fn;
return tween;
};
return tween;
}
update() {
TWEEN.update();
return this.numTweensRunning > 0;
}
}
</pre>
<p>To use it we'll create one </p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function main() {
const canvas = document.querySelector('#c');
const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
+ const tweenManager = new TweenManger();
...
</pre>
<p>We'll use it to create our <code class="notranslate" translate="no">Tween</code>s.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">// show the selected data, hide the rest
function showFileInfo(fileInfos, fileInfo) {
const targets = {};
fileInfos.forEach((info, i) => {
const visible = fileInfo === info;
info.elem.className = visible ? 'selected' : '';
targets[i] = visible ? 1 : 0;
});
const durationInMs = 1000;
- new TWEEN.Tween(mesh.morphTargetInfluences)
+ tweenManager.createTween(mesh.morphTargetInfluences)
.to(targets, durationInMs)
.start();
requestRenderIfNotRequested();
}
</pre>
<p>Then we'll update our render loop to update the tweens and keep rendering
if there are still animations running.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render() {
renderRequested = false;
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
+ if (tweenManager.update()) {
+ requestRenderIfNotRequested();
+ }
controls.update();
renderer.render(scene, camera);
}
render();
</pre>
<p>And with that we should be animating between data sets.</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/lots-of-objects-morphtargets.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/lots-of-objects-morphtargets.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>I hope going through this was helpful. Using morphtargets is a common technique to
move lots of objects. As an example we could give every cube a random place in
another target and morph from that to their first positions on the globe. That
might be a cool way to introduce the globe.</p>
<p>Next you might be interested in adding labels to a globe which is covered
in <a href="align-html-elements-to-3d.html">Aligning HTML Elements to 3D</a>.</p>
<p>Note: We could try to just graph percent of men or percent of women or the raw
difference but based on how we are displaying the info, cubes that grow from the
surface of the earth, we'd prefer most cubes to be low. If we used one of these
other comparisons most cubes would be about 1/2 their maximum height which would
not make a good visualization. Feel free to change the <code class="notranslate" translate="no">amountGreaterThan</code> from
<a href="/docs/#api/en/math/Math.max(a - b, 0)"><code class="notranslate" translate="no">Math.max(a - b, 0)</code></a> to something like <code class="notranslate" translate="no">(a - b)</code> "raw difference" or <code class="notranslate" translate="no">a / (a +
b)</code> "percent" and you'll see what I mean.</p>
</div>
</div>
</div>
<script src="../resources/prettify.js"></script>
<script src="../resources/lesson.js"></script>
</body></html>