123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- <!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 visa 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>
|