You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
496 lines
22 KiB
496 lines
22 KiB
<!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="/manual/resources/lesson.css">
|
|
<link rel="stylesheet" href="/manual/resources/lang.css">
|
|
<!-- Import maps polyfill -->
|
|
<!-- Remove this when import maps will be widely supported -->
|
|
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
|
|
|
|
<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.mergeBufferGeometries(
|
|
- geometries, false);
|
|
- const material = new THREE.MeshBasicMaterial({
|
|
- vertexColors: true,
|
|
- });
|
|
- const mesh = new THREE.Mesh(mergedGeometry, material);
|
|
- scene.add(mesh);
|
|
- return mesh;
|
|
+ return BufferGeometryUtils.mergeBufferGeometries(
|
|
+ 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.min.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({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="/manual/resources/prettify.js"></script>
|
|
<script src="/manual/resources/lesson.js"></script>
|
|
|
|
|
|
|
|
|
|
</body></html>
|