Browse Source

translate 4 chapters: cameras; 2optimize; rendering on demand

Roy Kid 4 years ago
parent
commit
a3da2a0e11

+ 531 - 0
threejs/lessons/zh_cn/threejs-cameras.md

@@ -0,0 +1,531 @@
+Title: Three.js 摄像机
+Description: 设置摄像机
+TOC: 摄像机
+
+本文是关于 three.js 系列文章的一部分。第一篇文章是 [three.js 基础](threejs-fundamentals.html)。如果你还没看过而且对three.js 还不熟悉,那应该从那里开始,并且了解如何[设置开发环境](threejs-setup.html)。上一篇文章介绍了 three.js 中的 [纹理](threejs-textures.html)。
+
+我们开始谈谈three.js中的摄像机. 我们已经在[第一篇文章](threejs-fundamentals.html) 中涉及到了摄像机的一些知识, 这里我们要更深入一些. 
+
+在three.js中最常用的摄像机并且之前我们一直用的摄像机是`透视摄像机 PerspectiveCamera`, 它可以提供一个近大远小的3D视觉效果. 
+
+`PerspectiveCamera` 定义了一个 *视锥frustum*. [*frustum* 是一个切掉顶的三角锥或者说实心金字塔型](https://en.wikipedia.org/wiki/Frustum).
+说到*实心体solid*, 在这里通常是指一个立方体, 一个圆锥, 一个球, 一个圆柱或锥台.
+
+<div class="spread">
+  <div><div data-diagram="shapeCube"></div><div>立方体</div></div>
+  <div><div data-diagram="shapeCone"></div><div>圆锥</div></div>
+  <div><div data-diagram="shapeSphere"></div><div>球</div></div>
+  <div><div data-diagram="shapeCylinder"></div><div>圆柱</div></div>
+  <div><div data-diagram="shapeFrustum"></div><div>锥台</div></div>
+</div>
+
+重新讲一遍这些东西是因为我好久没有在意过了. 很多书或者文章提到*锥台*这个东西的时候我扫一眼就过去了. 再了解一下不同几何体会让下面的一些表述变得更为感性...吧&#128517;
+
+`PerspectiveCamera`通过四个属性来定义一个视锥. `near`定义了视锥的前端, `far`定义了后端, `fov`是视野, 通过计算正确的高度来从摄像机的位置获得指定的以`near`为单位的视野, 定义的是视锥的前端和后端的高度. `aspect`间接地定义了视锥前端和后端的宽度, 实际上视锥的宽度是通过高度乘以aspect来得到的. 
+
+<img src="resources/frustum-3d.svg" width="500" class="threejs_center"/>
+
+我们借用[上一篇文章](threejs-lights.html)的场景. 其中包含一个地平面, 一个球和一个立方体, 我们可以在其中调整摄像机的设置. 
+我们通过`MinMaxGUIHelper`来调整`near`, `far`的设置. 显然`near`应该总是比`far`要小. dat.GUI有`min`和`max`两个属性可调, 然后这两个属性将决定摄像机的设置. 
+
+
+```js
+class MinMaxGUIHelper {
+  constructor(obj, minProp, maxProp, minDif) {
+    this.obj = obj;
+    this.minProp = minProp;
+    this.maxProp = maxProp;
+    this.minDif = minDif;
+  }
+  get min() {
+    return this.obj[this.minProp];
+  }
+  set min(v) {
+    this.obj[this.minProp] = v;
+    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
+  }
+  get max() {
+    return this.obj[this.maxProp];
+  }
+  set max(v) {
+    this.obj[this.maxProp] = v;
+    this.min = this.min;  // 这将调用min的setter
+  }
+}
+```
+
+现在我们可以将GUI设置为
+
+```js
+function updateCamera() {
+  camera.updateProjectionMatrix();
+}
+
+const gui = new GUI();
+gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
+const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
+```
+
+任何时候摄像机的设置变动, 我们需要调用摄像机的[`updateProjectionMatrix`](PerspectiveCamera.updateProjectionMatrix)来更新设置. 我们写一个函数`updataCamera`, 当dat.GUI改变了属性的时候会调用它来更新参数. 
+
+{{{example url="../threejs-cameras-perspective.html" }}}
+
+现在可以调整这些数值来观察这些参数是如何影响摄像机的. 注意我们并没有改变`aspect`, 因为这个参数来自于窗口的大小. 如果想调整`aspect`, 只需要开个新窗口然后调整窗口大小就可以了. 
+
+即便是这样, 观察参数对视野的影响还是挺麻烦的. 所以我们来设置两台摄像机吧! 一台是跟上面一样展现出摄像机中看到的实际场景, 另一个则是用来观察这个实际工作的摄像机, 然后画出摄像机的视锥. 
+
+我们需要用到three.js的剪函数(scissor function)来画两个场景和两个摄像机. 
+
+首先让我们用HTML和CSS来定义两个肩并肩的元素. 这也将帮助我们将两个摄像机赋予不同的`OrbitControls`. 
+
+
+```html
+<body>
+  <canvas id="c"></canvas>
++  <div class="split">
++     <div id="view1" tabindex="1"></div>
++     <div id="view2" tabindex="2"></div>
++  </div>
+</body>
+```
+
+CSS将控制两个视窗并排显示在canvas中
+
+```css
+.split {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+}
+.split>div {
+  width: 100%;
+  height: 100%;
+}
+```
+
+接下来将添加一个`CameraHelper`, 它可以把摄像机的视锥画出来
+
+```js
+const cameraHelper = new THREE.CameraHelper(camera);
+
+...
+
+scene.add(cameraHelper);
+```
+
+我们现在需要查找到刚刚定义的两个元素
+
+```js
+const view1Elem = document.querySelector('#view1');
+const view2Elem = document.querySelector('#view2');
+```
+
+现在只给第一个视窗中的摄像机分配`OrbitControls`
+
+```js
+-const controls = new OrbitControls(camera, canvas);
++const controls = new OrbitControls(camera, view1Elem);
+```
+
+我们定义第二个`PerspectiveCamera`和`OrbitControls`. 
+
+
+```js
+const camera2 = new THREE.PerspectiveCamera(
+  60,  // fov
+  2,   // aspect
+  0.1, // near
+  500, // far
+);
+camera2.position.set(40, 10, 30);
+camera2.lookAt(0, 5, 0);
+
+const controls2 = new OrbitControls(camera2, view2Elem);
+controls2.target.set(0, 5, 0);
+controls2.update();
+```
+
+最后我们需要
+
+最后,我们需要使用剪刀功能从每个摄影机的视角渲染场景,以仅渲染画布的一部分。
+这个函数接受一个元素, 计算这个元素在canvas上的重叠面积, 这将设置剪刀函数和视角长宽并返回aspect
+
+```js
+function setScissorForElement(elem) {
+  const canvasRect = canvas.getBoundingClientRect();
+  const elemRect = elem.getBoundingClientRect();
+
+  // 计算canvas的尺寸
+  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
+  const left = Math.max(0, elemRect.left - canvasRect.left);
+  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
+  const top = Math.max(0, elemRect.top - canvasRect.top);
+
+  const width = Math.min(canvasRect.width, right - left);
+  const height = Math.min(canvasRect.height, bottom - top);
+
+  // 设置剪函数以仅渲染一部分场景
+  const positiveYUpBottom = canvasRect.height - bottom;
+  renderer.setScissor(left, positiveYUpBottom, width, height);
+  renderer.setViewport(left, positiveYUpBottom, width, height);
+
+  // 返回aspect
+  return width / height;
+}
+```
+
+我们用这个函数在`render`中绘制两遍场景
+
+```js
+  function render() {
+
+-    if (resizeRendererToDisplaySize(renderer)) {
+-      const canvas = renderer.domElement;
+-      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+-      camera.updateProjectionMatrix();
+-    }
+
++    resizeRendererToDisplaySize(renderer);
++
++    // 启用剪刀函数
++    renderer.setScissorTest(true);
++
++    // 渲染主视野
++    {
++      const aspect = setScissorForElement(view1Elem);
++
++      // 用计算出的aspect修改摄像机参数
++      camera.aspect = aspect;
++      camera.updateProjectionMatrix();
++      cameraHelper.update();
++
++      // 来原视野中不要绘制cameraHelper
++      cameraHelper.visible = false;
++
++      scene.background.set(0x000000);
++
++      // 渲染
++      renderer.render(scene, camera);
++    }
++
++    // 渲染第二台摄像机
++    {
++      const aspect = setScissorForElement(view2Elem);
++
++      // 调整aspect
++      camera2.aspect = aspect;
++      camera2.updateProjectionMatrix();
++
++      // 在第二台摄像机中绘制cameraHelper
++      cameraHelper.visible = true;
++
++      scene.background.set(0x000040);
++
++      renderer.render(scene, camera2);
++    }
+
+-    renderer.render(scene, camera);
+
+    requestAnimationFrame(render);
+  }
+
+  requestAnimationFrame(render);
+}
+```
+
+上面的代码还将主辅摄像机的背景色区分开以利观察. 
+
+我们可以移除`updateCamera`了, 因为所有的东西在`render`中更新过了.
+
+
+```js
+-function updateCamera() {
+-  camera.updateProjectionMatrix();
+-}
+
+const gui = new GUI();
+-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
++gui.add(camera, 'fov', 1, 180);
+const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
+-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
+-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
++gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
++gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');
+```
+
+现在我们就可以在辅摄像机中观察到主摄像机的视锥轮廓了. 
+
+{{{example url="../threejs-cameras-perspective-2-scenes.html" }}}
+
+左侧可以看到主摄像机的视角, 右侧则是辅摄像机观察主摄像机和主摄像机的视锥轮廓. 可以调整`near`, `far`, `fov`和用鼠标移动摄像机来观察视锥轮廓和场景之间的关系. 
+
+将`near`调整到大概20左右, 前景就会在视锥中消失. `far`低于35时, 远景也不复存在. 
+
+这带来一个问题, 为什么不把`near`设置到0.0000000001然后将`far`设置成100000000, 使得一切都可以尽收眼底? 原因是你的GPU 8太行, 没有足够的精度来决定什么在前什么在后. 更糟的是, 在默认情况下, 离摄像机近的将会更清晰, 远的模糊, 从`near`到`far`逐渐过渡. 
+
+从上面的例子出发, 我们向场景中添加20个球
+
+```js
+{
+  const sphereRadius = 3;
+  const sphereWidthDivisions = 32;
+  const sphereHeightDivisions = 16;
+  const sphereGeo = new THREE.SphereBufferGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
+  const numSpheres = 20;
+  for (let i = 0; i < numSpheres; ++i) {
+    const sphereMat = new THREE.MeshPhongMaterial();
+    sphereMat.color.setHSL(i * .73, 1, 0.5);
+    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
+    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
+    scene.add(mesh);
+  }
+}
+```
+
+把 `near` 设置成0.00001
+
+```js
+const fov = 45;
+const aspect = 2;  // canvas 默认
+-const near = 0.1;
++const near = 0.00001;
+const far = 100;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+```
+
+调整一下GUI使得能设置到0.00001
+
+```js
+-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
++gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);
+```
+
+你觉得会发生什么?
+
+{{{example url="../threejs-cameras-z-fighting.html" }}}
+
+这就是一个典型的*z冲突*的例子. GPU没有足够的精度来决定哪个像素在前哪个在后. 
+
+以防你的机器太好出现不了我说的情况, 我把我看到的截图放在这
+
+<div class="threejs_center"><img src="resources/images/z-fighting.png" style="width: 570px;"></div>
+
+解决的方法之一是告诉three.js使用不同的方法计算像素的前后关系. 我们可以在创建`WebGLRenderer`时开启`logarithmicDepthBuffer`
+
+```js
+-const renderer = new THREE.WebGLRenderer({canvas});
++const renderer = new THREE.WebGLRenderer({
++  canvas,
++  logarithmicDepthBuffer: true,
++});
+```
+
+这看起来就行了
+
+{{{example url="../threejs-cameras-logarithmic-depth-buffer.html" }}}
+
+如果这不行的话, 那你就遇到了*为什么不能无脑使用这种解决方案*的情况了. 到2018年9月, 绝大多数台式机可以但是几乎没有移动设备支持这个功能. 
+
+另一个最好别用这种解决方案的原因是这会大大降低运行速度. 
+
+即便是现在跑得好好地, 选择太小的`near`和太大的`far`最终也会遇到同样的问题. 
+
+所以说你需要选择好好抉择`near`和`far`的设置, 来和你的场景配合. 既不丢失重要的近景, 也不让远处的东西消失不见. 如果你想渲染一个巨大的场景, 不但能看清面前的人的眼睫毛又想看到50公里以外的玩意, 你得自己想一个*厉害的*方案, 这里就不涉及了. 现在, 好好地选个需要的参数就行. 
+
+第二种常见的摄像机是`正交摄像机 OrthographicCamera`, 和指定一个视锥不同的是, 它需要设置`left`, `right`
+`top`, `bottom`, `near`, 和`far`指定一个长方体, 使得视野是平行的而不是透视的. 
+
+我们来把上面的例子改成`OrthographicCamera`, 首先来设置摄像机
+
+
+```js
+const left = -1;
+const right = 1;
+const top = 1;
+const bottom = -1;
+const near = 5;
+const far = 50;
+const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
+camera.zoom = 0.2;
+```
+
+我们将`left`和`bottom`设置成 -1 `right` 和 `top`设成 1, 这样就使盒子宽为两个单位, 高两个单位. 我们接下来通过调整`left`和`top`来选择其aspect. 我们将用`zoom`属性来调整相机到底展现多少的单位大小. 
+
+
+给GUI添加`zoom`设置
+
+```js
+const gui = new GUI();
++gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();
+```
+
+`listen`调用告诉dat.GUI去监视属性的变化. 写在这里是因为`OrbitControls`同样可以控制缩放. 在这个例子中, 鼠标滚轮将会通过`OrbitControls`控件来控制缩放. 
+
+最后更改aspect然后更新摄像机
+
+```js
+{
+  const aspect = setScissorForElement(view1Elem);
+
+  // 使用aspect更新摄像机
+-  camera.aspect = aspect;
++  camera.left   = -aspect;
++  camera.right  =  aspect;
+  camera.updateProjectionMatrix();
+  cameraHelper.update();
+
+  // 在主摄像机中不绘制视野辅助线
+  cameraHelper.visible = false;
+
+  scene.background.set(0x000000);
+  renderer.render(scene, camera);
+}
+```
+
+现在就可以看到`OrthographicCamera`工作了. 
+
+{{{example url="../threejs-cameras-orthographic-2-scenes.html" }}}
+
+大多数情况下, 绘制2D图像的时候会用到`OrthographicCamera`. 你可以自己决定摄像机的视野大小. 比如说你想让canvas的一个像素匹配摄像机的一个单位, 你可以这么做
+
+将原点置于中心, 令一个像素等于一个单位
+
+```js
+camera.left = -canvas.width / 2;
+camera.right = canvas.width / 2;
+camera.top = canvas.height / 2;
+camera.bottom = -canvas.height / 2;
+camera.near = -1;
+camera.far = 1;
+camera.zoom = 1;
+```
+
+或者如果我们想让原点在左上, 就像是2D canvas
+
+```js
+camera.left = 0;
+camera.right = canvas.width;
+camera.top = 0;
+camera.bottom = canvas.height;
+camera.near = -1;
+camera.far = 1;
+camera.zoom = 1;
+```
+
+这样左上角就成了0,0
+
+试试, 这样设置摄像机
+
+```js
+const left = 0;
+const right = 300;  // 默认的canvas大小
+const top = 0;
+const bottom = 150;  // 默认的canvas大小
+const near = -1;
+const far = 1;
+const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
+camera.zoom = 1;
+```
+
+然后我们载入六个材质, 生成六个平面, 一一对应. 把每一个平面绑定到父对象`THREE.Object3D`上, 以便调整每个平面和左上角原点的相对关系
+
+```js
+const loader = new THREE.TextureLoader();
+const textures = [
+  loader.load('resources/images/flower-1.jpg'),
+  loader.load('resources/images/flower-2.jpg'),
+  loader.load('resources/images/flower-3.jpg'),
+  loader.load('resources/images/flower-4.jpg'),
+  loader.load('resources/images/flower-5.jpg'),
+  loader.load('resources/images/flower-6.jpg'),
+];
+const planeSize = 256;
+const planeGeo = new THREE.PlaneBufferGeometry(planeSize, planeSize);
+const planes = textures.map((texture) => {
+  const planePivot = new THREE.Object3D();
+  scene.add(planePivot);
+  texture.magFilter = THREE.NearestFilter;
+  const planeMat = new THREE.MeshBasicMaterial({
+    map: texture,
+    side: THREE.DoubleSide,
+  });
+  const mesh = new THREE.Mesh(planeGeo, planeMat);
+  planePivot.add(mesh);
+  // 调整平面使得左上角为原点
+  mesh.position.set(planeSize / 2, planeSize / 2, 0);
+  return planePivot;
+});
+```
+
+然后当canvas更新后我们更新摄像机设置
+
+```js
+function render() {
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    camera.right = canvas.width;
+    camera.bottom = canvas.height;
+    camera.updateProjectionMatrix();
+  }
+
+  ...
+```
+
+`planes` 是`THREE.Mesh`的数组, 每一个对应一个平面. 
+现在让它随着时间移动
+
+
+```js
+function render(time) {
+  time *= 0.001;  // 转换为秒;
+
+  ...
+
+  const distAcross = Math.max(20, canvas.width - planeSize);
+  const distDown = Math.max(20, canvas.height - planeSize);
+
+  // 来回运动的总距离
+  const xRange = distAcross * 2;
+  const yRange = distDown * 2;
+  const speed = 180;
+
+  planes.forEach((plane, ndx) => {
+    // 为每个平面单独计算时间
+    const t = time * speed + ndx * 300;
+
+    // 在0到最远距离之间获取一个值
+    const xt = t % xRange;
+    const yt = t % yRange;
+
+    // 0到距离的一半, 向前运动
+    // 另一半的时候往回运动
+    const x = xt < distAcross ? xt : xRange - xt;
+    const y = yt < distDown   ? yt : yRange - yt;
+
+    plane.position.set(x, y, 0);
+  });
+
+  renderer.render(scene, camera);
+```
+
+你可以看到图片在其中弹跳, 和边际完美契合, 就是2D canvas的效果一样
+
+{{{example url="../threejs-cameras-orthographic-canvas-top-left-origin.html" }}}
+
+另一个常见的用途是用`OrthographicCamera`来展示模型的三视图.
+
+<div class="threejs_center"><img src="resources/images/quad-viewport.png" style="width: 574px;"></div>
+
+上面的截图展示了一个透视图和三个正交视角.
+
+这就是摄像机的基础. 我们在其他的文章中会介绍另外的一些摄像机用法. 现在, 我们移步到[阴影](threejs-shadows.html).
+
+
+<canvas id="c"></canvas>
+<script type="module" src="resources/threejs-cameras.js"></script>

+ 1 - 1
threejs/lessons/zh_cn/threejs-fundamentals.md

@@ -1,4 +1,4 @@
-Title: Three.js基础
+Title: Three.js 基础
 Description: 学习Three.js的第一篇文章
 TOC: 基础
 

+ 657 - 0
threejs/lessons/zh_cn/threejs-optimize-lots-of-objects-animated.md

@@ -0,0 +1,657 @@
+Title: Three.js 优化对象的同时保持动画效果
+Description: 使用morphtarget优化
+TOC: 优化对象的同时保持动画效果
+
+本文是关于 three.js 系列文章的一部分. 第一篇文章是 [three.js 基础](threejs-fundamentals.html). 如果你还没看过而且对three.js 还不熟悉,那应该从那里开始.
+
+在上一章中, 我们合并了19000个对象到一个几何体中. 这带来的好处是优化掉19000次绘制操作但是缺点是没有办法再单独操作某一个了. 
+
+根据我们想达成的目标的不同, 有不同的解决方案可选. 本例中我们绘制大量的数据, 然后还能在这些数据集间设置动画
+
+第一件事是获取数据集. 理想中我们可能需要预处理这些数据, 但是我们现在只需要载入两个数据集然后产生更多的. 
+
+这是我们之前的载入代码
+
+```js
+loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
+  .then(parseData)
+  .then(addBoxes)
+  .then(render);
+```
+
+稍微改成这样
+
+```js
+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();
+```
+
+上面的代码将会加载`fileInfos`中的所有文件, 加载完成后每一个`fileInfos`中的对象都会有一个带着载入文件的`file`属性. 我们稍后使用`name`和`hueRange`. `name`是显示在界面上的字段, `hueRange`是色调. 
+
+上面的两个文件显然是每个地区2010年男人和女人的数量. 注意了, 我不知道这些数据对不对, 但是不影响好吧. 重要的是如何去展示这些不同的数据. 
+
+让我们再产生两组数据. 一组是男人数量比女人多的, 另一组反过来. 
+
+首先,让我们编写一个函数,在给定一个二维数组的情况下,像以前一样映射生成一个新的二维数组
+
+```js
+function mapValues(data, fn) {
+  return data.map((row, rowNdx) => {
+    return row.map((value, colNdx) => {
+      return fn(value, rowNdx, colNdx);
+    });
+  });
+}
+```
+
+就像普通的`Array.map`函数, `mapValues`函数对数组的数组每一个值调用了`fn`. 这将会将每个值和它的索引传进去. 
+
+现在让我们编写一些代码来生成一个新文件,它是两个文件之间的比较
+
+```js
+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;
+  });
+  // 生成baseFile的一个副本, 然后用新文件的min max 和 data替代原来的
+  return {...baseFile, min, max, data};
+}
+```
+
+上面的代码基于传入的`compareFn`用`mapValues`生成一个新的数据集. 这同样追踪`min`和`max`的比较结果. 最后这将会生成一个新文件, 除了`min`, `max`和`data`所有的属性都和`baseFile`一样. 
+
+然后我们用上面的代码生成两个新数据集
+
+```js
+{
+  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);
+    }),
+  });
+}
+```
+
+现在我们写一个UI来选择数据集. 首先是html部分
+
+```html
+<body>
+  <canvas id="c"></canvas>
++  <div id="ui"></div>
+</body>
+```
+
+CSS部分, 让其显示在左侧
+
+```css
+#ui {
+  position: absolute;
+  left: 1em;
+  top: 1em;
+}
+#ui>div {
+  font-size: 20pt;
+  padding: 1em;
+  display: inline-block;
+}
+#ui>div.selected {
+  color: red;
+}
+```
+
+我们遍历整个文件, 对于每一个数据集都生成了合并了的box, 
+
+然后我们可以遍历每个文件, 并为每组数据生成合并了的box和一个元素, 当鼠标悬停在上面时, 该元素将显示该集合并隐藏所有其他元素
+
+```js
+// 展示选中的元素, 隐藏其他的
+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);
+  });
+});
+// 起始展示第一组数据
+showFileInfo(fileInfos, fileInfos[0]);
+```
+
+和之前例子有所不同的是, 我们还需要让`addBoxes`获取`hueRange`
+
+```js
+-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);
+
+  ...
+```
+
+把鼠标放到标签上可以在四组不同的数据集之间切换. 
+
+
+{{{example url="../threejs-lots-of-objects-multiple-data-sets.html" }}}
+
+咋回事, 怎么还有一些点非常突出??!! 而且切换得很生硬也没有动画啊
+
+有这么一些想法
+
+*  通过使用`Material.opacity`做消失过渡
+
+  这个解决方案的问题是立方体完全重叠了, 意思是在Z轴方向冲突. 我们可以通过改变depth函数和使用blending来修复. 我们应该试一试
+
+*  放大我们想看到的集合,缩小其他集合
+
+  因为所有盒子的原点都在地球的中心, 如果我们把它们缩小到1.0以下, 它们就会沉入地球. 这听起来是个好主意, 但问题是所有的较低的盒子几乎会立即消失,直到新的数据集扩展到1.0才被替换. 这使得过渡非常不漂亮. 我们可以用一个神奇的自定义着色器来解决这个问题. 
+
+*  使用Morphtargets
+
+   所谓*变形目标morphtargets*是一种给每个顶点提供多个值, 以及使他们进行变形或者说lerp(线性插值)的方法. morphtargets通常用于3D角色的面部动画, 但这并不是唯一的用途. 
+
+我们试试morphtargets
+
+我们还是给每一个数据集做一个几何体, 但这次我们提取`position`属性, 把他们作为morphtargets.
+
+首先我们改动一下`addBoxes`来生成并返回一个合并的几何体. 
+
+
+```js
+-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);
+}
+```
+
+不过, 我们还有一件事要做. 变形目标的顶点数必须完全相同. 一个目标中的顶点#123需要在所有其他目标中有一个对应的顶点#123. 但是, 由于现在不同的数据集可能有一些没有数据的数据点, 因此不会为该点生成几何体, 这意味着另一个数据集没有相应的顶点. 所以, 我们需要检查所有的数据集,如果任何一个数据集中有数据, 就总是生成一些东西; 或者如果任何一个数据集中缺少数据, 就什么也不生成. 让我们以后者为准. 
+
+```js
++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;
+
+  ...
+```
+
+现在我们改动一下代码, 把调用`addBoxes`的改成使用`makeBoxes`生成变形目标.
+
+```js
++// 对每一个数据集生成几何体
++const geometries = fileInfos.map((info) => {
++  return makeBoxes(info.file, info.hueRange, fileInfos);
++});
++
++// 以第一个几何体作为基准, 将其他的作为变形目标
++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;
++});
++const material = new THREE.MeshBasicMaterial({
++  vertexColors: true,
++  morphTargets: 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);
+});
+// 展示第一组数据集
+showFileInfo(fileInfos, fileInfos[0]);
+```
+
+以上我们为每一组数据集创建了几何体, 以第一个作为基准, 获取了`position`属性, 将其他的几何体作为其变形目标
+
+现在我们需要改变显示和隐藏各种数据集的方式. 我们需要改动变形目标的influence, 而不是简单地显示和隐藏mesh. 对于我们我们想看到的数据集, influence应该是1, 不想看到的是0. 但是我们又不能直接将他们设置成1和0, 这将会显示开与闭的两种情况, 和现在这种没有区别. 我们也可以写一段自定义的动画效果, 听起来不难. 但是我们模仿的WebGL globe用了一个[动画库](https://github.com/tweenjs/tween.js/), 我们也用这一个. 
+
+我们这里首先引入它
+
+```js
+import * as THREE from './resources/three/r122/build/three.module.js';
+import {BufferGeometryUtils} from './resources/threejs/r122/examples/jsm/utils/BufferGeometryUtils.js';
+import {OrbitControls} from './resources/threejs/r122/examples/jsm/controls/OrbitControls.js';
++import {TWEEN} from './resources/threejs/r122/examples/jsm/libs/tween.min.js';
+```
+
+然后创建一个`Tween`来使influence变化
+
+```js
+// 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' : '';
++    const targets = {};
++    fileInfos.forEach((info, i) => {
++      targets[i] = info === fileInfo ? 1 : 0;
++    });
++    const durationInMs = 1000;
++    new TWEEN.Tween(mesh.morphTargetInfluences)
++      .to(targets, durationInMs)
++      .start();
+  });
+  requestRenderIfNotRequested();
+}
+```
+
+我们也可以在每一帧的render函数中调用`TWEEN.update`, 但这会带来一个问题. "tween.js"是为了连续渲染而设计的, 但是我们采用的是[按需渲染](threejs-rendering-on-demand.html). 我们可以再切换回连续渲染的方式, 但是为了省电和省资源起见, 还是按需渲染比较好. 所以我们看看是否能让它在按需渲染下工作. 
+
+我们需要`TweenManaget`来完成这件事. 我们将用它来创建`Tween`并追踪他们. 这里会有一个`update`方法, 如果我们二次调用它的时候返回`true`, 如果所有动画结束后则会返回`false`.
+
+```js
+class TweenManger {
+  constructor() {
+    this.numTweensRunning = 0;
+  }
+  _handleComplete() {
+    --this.numTweensRunning;
+    console.assert(this.numTweensRunning >= 0);
+  }
+  createTween(targetObject) {
+    const self = this;
+    ++this.numTweensRunning;
+    let userCompleteFn = () => {};
+    // 创建一个新的Tween, 并应用我们自己的回调函数
+    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
+      self._handleComplete();
+      userCompleteFn.call(this, ...args);
+    });
+    // 用我们自己的onComplete代替它的, 
+    // 因此, 如果用户提供回调, 我们可以调用用户的回调
+    tween.onComplete = (fn) => {
+      userCompleteFn = fn;
+      return tween;
+    };
+    return tween;
+  }
+  update() {
+    TWEEN.update();
+    return this.numTweensRunning > 0;
+  }
+}
+```
+
+我们需要以下代码来使用
+
+```js
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
++  const tweenManager = new TweenManger();
+
+  ...
+```
+
+这是如何创建Tween
+
+```js
+// show the selected data, hide the rest
+function showFileInfo(fileInfos, fileInfo) {
+  fileInfos.forEach((info) => {
+    const visible = fileInfo === info;
+    info.elem.className = visible ? 'selected' : '';
+    const targets = {};
+    fileInfos.forEach((info, i) => {
+      targets[i] = info === fileInfo ? 1 : 0;
+    });
+    const durationInMs = 1000;
+-    new TWEEN.Tween(mesh.morphTargetInfluences)
++    tweenManager.createTween(mesh.morphTargetInfluences)
+      .to(targets, durationInMs)
+      .start();
+  });
+  requestRenderIfNotRequested();
+}
+```
+我们需要改动render函数来更新tween, 让动画还在跑的时候保持渲染
+
+
+```js
+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();
+```
+
+这样我们就可以在数据集中以动画的方式过渡了
+
+{{{example url="../threejs-lots-of-objects-morphtargets.html" }}}
+
+看起来挺好的, 但是失去了色彩.
+
+Three.js不支持颜色的变形, 但事实上这是[webgl globe](https://github.com/dataarts/webgl-globe)下的一个issue. 基本上它只为第一个数据集生成颜色, 任何其他数据集使用相同的颜色. 即使它们有很大的不同. 
+
+让我们看看是否可以做到让颜色也随之变化. 这个操作方法可能鲁棒性不足. 最好的方式是自己写着色器, 但是我觉得在这里还是讲一下如何修改内置的着色器为好
+
+我们需要做的第一件事是让代码从每个数据集的几何体中提取颜色. 
+
+```js
+
+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;
+});
++const colorAttributes = geometries.map((geometry, ndx) => {
++  const attribute = geometry.getAttribute('color');
++  const name = `morphColor${ndx}`;
++  attribute.name = `color${ndx}`;  // debug需要
++  return {name, attribute};
++});
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: true,
+  morphTargets: true,
+});
+```
+
+We then need to modify the three.js shader. Three.js materials have an
+`Material.onBeforeCompile` property we can assign a function. It gives us a
+chance to modify the material's shader before it is passed to WebGL. In fact the
+shader that is provided is actually a special three.js only syntax of shader
+that lists a bunch of shader *chunks* that three.js will substitute with the
+actual GLSL code for each chunk. Here is what the unmodified vertex shader code
+looks like as passed to `onBeforeCompile`.
+
+我们需要改动three.js的着色器. Three.js的材质有一个`Material.onBeforeCompile`属性, 我们可以为其赋一个函数. 这给了我们一个在传递给WebGL之前修改材质着色器的机会. 实际上, 提供的着色器就是一个特殊语法的three.js, 然后将会被GLSL替换. 以下是未修改的顶点着色器代码, 看起来将要传给`onBeforeCompile`. (In fact the shader that is provided is actually a special three.js only syntax of shader that lists a bunch of shader *chunks* that three.js will substitute with the actual GLSL code for each chunk. Here is what the unmodified vertex shader code looks like as passed to `onBeforeCompile`.)
+
+
+```glsl
+#include <common>
+#include <uv_pars_vertex>
+#include <uv2_pars_vertex>
+#include <envmap_pars_vertex>
+#include <color_pars_vertex>
+#include <fog_pars_vertex>
+#include <morphtarget_pars_vertex>
+#include <skinning_pars_vertex>
+#include <logdepthbuf_pars_vertex>
+#include <clipping_planes_pars_vertex>
+void main() {
+	#include <uv_vertex>
+	#include <uv2_vertex>
+	#include <color_vertex>
+	#include <skinbase_vertex>
+	#ifdef USE_ENVMAP
+	#include <beginnormal_vertex>
+	#include <morphnormal_vertex>
+	#include <skinnormal_vertex>
+	#include <defaultnormal_vertex>
+	#endif
+	#include <begin_vertex>
+	#include <morphtarget_vertex>
+	#include <skinning_vertex>
+	#include <project_vertex>
+	#include <logdepthbuf_vertex>
+	#include <worldpos_vertex>
+	#include <clipping_planes_vertex>
+	#include <envmap_vertex>
+	#include <fog_vertex>
+}
+```
+
+我们需要替换一下的部分 [`morphtarget_pars_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphtarget_pars_vertex.glsl.js), [`morphnormal_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphnormal_vertex.glsl.js), [`morphtarget_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/morphtarget_vertex.glsl.js), [`color_pars_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_pars_vertex.glsl.js), [`color_vertex` chunk](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/color_vertex.glsl.js)
+
+我们需要把待替换写成一个简单的数组, 在`Material.onBeforeCompile`中应用它们.
+
+```js
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: true,
+  morphTargets: true,
+});
++const vertexShaderReplacements = [
++  {
++    from: '#include <morphtarget_pars_vertex>',
++    to: `
++      uniform float morphTargetInfluences[8];
++    `,
++  },
++  {
++    from: '#include <morphnormal_vertex>',
++    to: `
++    `,
++  },
++  {
++    from: '#include <morphtarget_vertex>',
++    to: `
++      transformed += (morphTarget0 - position) * morphTargetInfluences[0];
++      transformed += (morphTarget1 - position) * morphTargetInfluences[1];
++      transformed += (morphTarget2 - position) * morphTargetInfluences[2];
++      transformed += (morphTarget3 - position) * morphTargetInfluences[3];
++    `,
++  },
++  {
++    from: '#include <color_pars_vertex>',
++    to: `
++      varying vec3 vColor;
++      attribute vec3 morphColor0;
++      attribute vec3 morphColor1;
++      attribute vec3 morphColor2;
++      attribute vec3 morphColor3;
++    `,
++  },
++  {
++    from: '#include <color_vertex>',
++    to: `
++      vColor.xyz = morphColor0 * morphTargetInfluences[0] +
++                   morphColor1 * morphTargetInfluences[1] +
++                   morphColor2 * morphTargetInfluences[2] +
++                   morphColor3 * morphTargetInfluences[3];
++    `,
++  },
++];
++material.onBeforeCompile = (shader) => {
++  vertexShaderReplacements.forEach((rep) => {
++    shader.vertexShader = shader.vertexShader.replace(rep.from, rep.to);
++  });
++};
+```
+
+Three.js会给这些变形对象排序, 然后之后采用最高的influence. 这使得它可以采用更多的变形目标而只有几个可以被在同一时刻使用.不幸的是three.js不提供任何方法来知道将使用多少变形目标, 也不知道变形目标将分配给哪些属性. 所以, 我们必须研究代码并重现它在这里的作用. 如果three.js修改了它的算法, 接下来的代码也得重构. 
+
+首先我们需要移除所有的颜色属性. 如果我们之前没有赋予这个属性那么移除它就一点事没有. 然后我们将会计算那些目标three.js将会用到, 最终把这些目标赋给three.js可能会用到的属性. 
+
+```js
+
+const mesh = new THREE.Mesh(baseGeometry, material);
+scene.add(mesh);
+
++function updateMorphTargets() {
++  // 移除所有的颜色属性
++  for (const {name} of colorAttributes) {
++    baseGeometry.deleteAttribute(name);
++  }
++
++  // 没有提供可以查询这个的方法, 我们只能寄希望于它不会改变
++  const maxInfluences = 8;
++
++  // three.js没有提供查询哪个morphtarget会被使用的方法
++  // 也没有那个属性说明被使用, 所以只能靠猜
++  // 如果算法改了, 那这些都得重构
++  mesh.morphTargetInfluences
++    .map((influence, i) => [i, influence])            // 将索引映射到influence
++    .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))  // 降幂排序
++    .slice(0, maxInfluences)                          // 只要最大的influence
++    .sort((a, b) => a[0] - b[0])                      // 按索引排序
++    .filter(a => !!a[1])                              // 移除没有influence的
++    .forEach(([ndx], i) => {                          // 赋予属性
++      const name = `morphColor${i}`;
++      baseGeometry.setAttribute(name, colorAttributes[ndx].attribute);
++    });
++}
+```
+
+我们将会在`loadAll`函数中返回这个函数. 这将不会让我们泄露任何的变量
+
+```js
+async function loadAll() {
+  ...
+
++  return updateMorphTargets;
+}
+
++// 使用无操作的update直到所有数据准备完成
++let updateMorphTargets = () => {};
+-loadAll();
++loadAll().then(fn => {
++  updateMorphTargets = fn;
++});
+```
+
+最终我们需要调用`updateMorphTargets`, 直到我们最终让所有的数值都在渲染前被tween manager更新
+
+```js
+function render() {
+
+  ...
+
+  if (tweenManager.update()) {
+    requestRenderIfNotRequested();
+  }
+
++  updateMorphTargets();
+
+  controls.update();
+  renderer.render(scene, camera);
+}
+```
+
+然后我们的颜色就可以像尺寸一样动起来了. 
+
+{{{example url="../threejs-lots-of-objects-morphtargets-w-colors.html" }}}
+
+我希望上面讲的这些能有用. 通过threejs提供的方法或者自己写着色器来使用变形对象是一种常见的移动大量对象的手段. 作为一个例子, 我们可以给每一个立方体一个随机目标, 然后从这个位置变换到另一个位置. 这可能是一种超酷的介绍地球的方法. 
+
+接下来你可能感兴趣的是给地球上的一个位置添加标签, 这将在[3D中排布HTML元素](threejs-align-html-elements-to-3d.html)中涉及. 
+
+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 `amountGreaterThan` from
+`Math.max(a - b, 0)` to something like `(a - b)` "raw difference" or `a / (a +
+b)` "percent" and you'll see what I mean.
+
+注: 我们可以试着用图表表示男性的百分比或女性的百分比或原始差异. 但根据我们显示信息的方式, 也就是从地球表面生长出来的立方体的显示方式, 我们希望大多数立方体都是矮的. 如果我们使用其中一个做基准, 大多数立方体的高度大约是它们最大高度的1/2. 效果会很差. 自己动手改一下`amountGreaterThan`中的`Math.max(a - b, 0)` 到`(a - b)` "原始差异"或者 `a / (a + b)`"百分比", 你就会明白我什么意思了. 

+ 461 - 0
threejs/lessons/zh_cn/threejs-optimize-lots-of-objects.md

@@ -0,0 +1,461 @@
+Title: Three.js 大量对象的优化
+Description: 通过合并进行优化
+TOC: 大量对象的优化
+
+本文是关于 three.js 系列文章的一部分. 第一篇文章是 [three.js 基础](threejs-fundamentals.html). 如果你还没看过而且对three.js 还不熟悉,那应该从那里开始.
+
+three.js的优化有很多种方式. 常见的一种叫做*合并几何体*. 每一个你创建的`Mesh`代表一个(或多个)请求系统渲染的命令. 即便是画出来的结果一样, 画两个几何体总是比画一个要费时费力. 所以最好的方式就是将这些mesh合并起来. 
+
+让我们来展示一个应用这种优化方式的优秀范例. 让我们来重新创建一个[WebGL Globe](https://globe.chromeexperiments.com/).
+
+第一件事是获取一些数据. WebGL Globe说他们的数据是来自[SEDAC](http://sedac.ciesin.columbia.edu/gpw/). 点开这个网站我们可以看到[网格化的人口统计学数据](https://beta.sedac.ciesin.columbia.edu/data/set/gpw-v4-basic-demographic-characteristics-rev10). 我这里下载的是以60分为解析度的数据. 打开可以看到
+
+```txt
+ ncols         360
+ nrows         145
+ xllcorner     -180
+ yllcorner     -60
+ cellsize      0.99999999999994
+ NODATA_value  -9999
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+ 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
+ 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
+ -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
+```
+
+上面的数据首先是几行键值对, 然后是网格化的数据. 
+
+为了保证我们的理解没有偏差, 我们先做个2D图
+
+先用这么几行代码载入数据
+
+```js
+async function loadFile(url) {
+  const res = await fetch(url);
+  return res.text();
+}
+```
+
+上面的代码返回了一个带有指定`url`下文件内容的`Promise`
+
+然后写几行数据来解析文件内容
+
+```js
+function parseData(text) {
+  const data = [];
+  const settings = {data};
+  let max;
+  let min;
+  // 对每一行进行切分
+  text.split('\n').forEach((line) => {
+    // split the line by whitespace
+    const parts = line.trim().split(/\s+/);
+    if (parts.length === 2) {
+      // 长度为2的必定是键值对
+      settings[parts[0]] = parseFloat(parts[1]);
+    } else if (parts.length > 2) {
+      // 长度超过2的肯定是网格数据
+      const values = parts.map((v) => {
+        const value = parseFloat(v);
+        if (value === settings.NODATA_value) {
+          return undefined;
+        }
+        max = Math.max(max === undefined ? value : max, value);
+        min = Math.min(min === undefined ? value : min, value);
+        return value;
+      });
+      data.push(values);
+    }
+  });
+  return Object.assign(settings, {min, max});
+}
+```
+
+上面的代码返回了一个有着全部键值对的对象, 然后`data`属性是网格化的数据. `min` 和 `max` 中是 `data` 中的极值
+
+下面是绘图函数
+
+```js
+function drawData(file) {
+  const {min, max, data} = file;
+  const range = max - min;
+  const ctx = document.querySelector('canvas').getContext('2d');
+  // 新建一个和网格数据尺寸相等的canvas
+  ctx.canvas.width = ncols;
+  ctx.canvas.height = nrows;
+  // 但是以两倍大小绘制防止太小
+  ctx.canvas.style.width = px(ncols * 2);
+  ctx.canvas.style.height = px(nrows * 2);
+  // 用黑灰色填充
+  ctx.fillStyle = '#444';
+  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+  // 绘制数据点
+  data.forEach((row, latNdx) => {
+    row.forEach((value, lonNdx) => {
+      if (value === undefined) {
+        return;
+      }
+      const amount = (value - min) / range;
+      const hue = 1;
+      const saturation = 1;
+      const lightness = amount;
+      ctx.fillStyle = hsl(hue, saturation, lightness);
+      ctx.fillRect(lonNdx, latNdx, 1, 1);
+    });
+  });
+}
+
+function px(v) {
+  return `${v | 0}px`;
+}
+
+function hsl(h, s, l) {
+  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
+}
+```
+
+然后把上面的代码都合并起来
+
+```js
+loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
+  .then(parseData)
+  .then(drawData);
+```
+
+得到了下面的结果
+
+{{{example url="../gpw-data-viewer.html" }}}
+
+嗯... 看起来没什么问题
+
+试试3D效果. 从[按需渲染](threejs-rendering-on-demand.html)出发, 我们让每一个数据都画成一个box
+
+首先先画一个地球, 这是sphere表面的贴图
+
+<div class="threejs_center"><img src="../resources/images/world.jpg" style="width: 600px"></div>
+
+用这些代码生成地球
+
+```js
+{
+  const loader = new THREE.TextureLoader();
+  const texture = loader.load('resources/images/world.jpg', render);
+  const geometry = new THREE.SphereBufferGeometry(1, 64, 32);
+  const material = new THREE.MeshBasicMaterial({map: texture});
+  scene.add(new THREE.Mesh(geometry, material));
+}
+```
+看过来, 当材质加载完成后才调用`render`方法. 我们这么做是因为使用了[按需渲染](threejs-rendering-on-demand.html)中的方法, 而不是连续渲染. 这样我们仅仅需要在材质加载后渲染一遍就好. 
+
+然后我们需要对代码做一些改动, 每个数据都画一个点, 而不是每个
+
+然后我们需要修改上面每个数据点画一个点的代码, 改为每个数据点画一个框
+
+```js
+function addBoxes(file) {
+  const {min, max, data} = file;
+  const range = max - min;
+
+  // 新建一个box geometry
+  const boxWidth = 1;
+  const boxHeight = 1;
+  const boxDepth = 1;
+  const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+  // 沿着z轴缩放
+  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
+
+  // 位置辅助器可以方便地在球面上定位
+  // 经度辅助器可以在XZ平面的法向旋转
+  const lonHelper = new THREE.Object3D();
+  scene.add(lonHelper);
+  // 纬度辅助器可以在XZ平面旋转
+  const latHelper = new THREE.Object3D();
+  lonHelper.add(latHelper);
+  // 组合起来得到的位置辅助器可以在球面上定位
+  const positionHelper = new THREE.Object3D();
+  positionHelper.position.z = 1;
+  latHelper.add(positionHelper);
+
+  const lonFudge = Math.PI * .5;
+  const latFudge = Math.PI * -0.135;
+  data.forEach((row, latNdx) => {
+    row.forEach((value, lonNdx) => {
+      if (value === undefined) {
+        return;
+      }
+      const amount = (value - min) / range;
+      const material = new THREE.MeshBasicMaterial();
+      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
+      const saturation = 1;
+      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
+      material.color.setHSL(hue, saturation, lightness);
+      const mesh = new THREE.Mesh(geometry, material);
+      scene.add(mesh);
+
+      // 调整辅助器使其指向经纬度
+      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
+      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
+
+      // 使用world matrix来操作辅助器
+      positionHelper.updateWorldMatrix(true, false);
+      mesh.applyMatrix4(positionHelper.matrixWorld);
+
+      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
+    });
+  });
+}
+```
+上面的代码直截了当得从2D测试方法中改动过来
+
+我们新建一个长方体, 然后沿着Z轴缩放. 如果我们不这么做, 它就会以中心为参照放大, 使得根部不在球面上. 我们这么做之后, 就可以达到从球面上长出来的效果. 
+
+
+<div class="spread">
+  <div>
+    <div data-diagram="scaleCenter" style="height: 250px"></div>
+    <div class="code">default</div>
+  </div>
+  <div>
+    <div data-diagram="scalePositiveZ" style="height: 250px"></div>
+    <div class="code">adjusted</div>
+  </div>
+</div>
+
+当然, 我们可以像[场景图](threejs-scenegraph.html)一章中讲得, 通过添加到一个父对象来解决上面的问题. 但是要考虑到我们体系几何体非常得多, 所以会大大拖累运行的速度. 
+
+上面的位置辅助器`positionHelper`是由`lonHelper`, `latHelper`逐级组合而来. 这个小东西可以帮助我们计算球面上的经纬度来放置几何体. 
+
+<div class="spread">
+  <div data-diagram="lonLatPos" style="width: 600px; height: 400px;"></div>
+</div>
+
+上面的<span style="color: green;">绿条条</span>代表`lonHelper`, 在赤道上以经度的变化旋转. The <span style="color: blue;">
+蓝条条</span>代表 `latHelper`, 在赤道上下以纬度的变化旋转. <span style="color: red;">红球球</span> 就是位置辅助器实际指向的位置. 
+
+我们倒是可以计算所有的球面位置, 但是需要涉及到很多数学和库的调用, 所以就...可以但没必要.
+
+每一个数据我们都创建了一个`MeshBasicMaterial`和一个`Mesh`, 然后我们从位置辅助器中取得world matrix并应用到新的`Mesh`上. 最后, 我们在它的新位置上缩放. 
+
+上面, 我们给每一个新box都创建了一个位置辅助器, 但是这将会使运行速度大大下降. 
+
+这最多有360x145=52000个盒子需要被创建. 有些点数据被标为 “NO_DATA” 所以实际的盒子数大概是19000左右. 如果我们每个盒子加上三个辅助器, 全局就大概80000个节点. 使用一组辅助器来调整mesh的位置我们可以节约60000个节点的计算. 
+
+注意`lonFudge`是π/2也就是四分之一圈, 也就是说在在一周上是以不同的偏移开始. 也能说得通. 但是我不知道为什么`latFudge`要乘以个 π * -0.135, 似乎就是一个能让盒子和材质对齐的数.
+
+最后一步是调用loader
+
+```
+loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
+  .then(parseData)
+-  .then(drawData)
++  .then(addBoxes)
++  .then(render);
+```
+
+当数据载入和解析完成, 我们再进行渲染
+
+{{{example url="../threejs-lots-of-objects-slow.html" }}}
+
+拖拽一下这个球你就会发现很卡
+
+我们在[开启调试工具](threejs-debugging-javascript.html)中提到过怎么打开帧率监视器
+
+<div class="threejs_center"><img src="resources/images/bring-up-fps-meter.gif"></div>
+
+在我机器上大概是20帧每秒
+
+<div class="threejs_center"><img src="resources/images/fps-meter.gif"></div>
+
+这不太行, 我寻思很多人机器上会更慢. 我们得想办法优化它一下子.
+
+此时此景, 我们可以通过合并所有的盒子到一个geometry来实现, 一下子就可以省下18999个操作
+
+
+```js
+function addBoxes(file) {
+  const {min, max, data} = file;
+  const range = max - min;
+
+-  // 新建一个几何体
+-  const boxWidth = 1;
+-  const boxHeight = 1;
+-  const boxDepth = 1;
+-  const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+-  // 沿着Z轴缩放
+-  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));
+
+  // 位置辅助器可以方便地在球面上定位
+  // 经度辅助器可以在XZ平面的法向旋转
+  const lonHelper = new THREE.Object3D();
+  scene.add(lonHelper);
+  // 纬度辅助器可以在XZ平面旋转
+  const latHelper = new THREE.Object3D();
+  lonHelper.add(latHelper);
+  // 组合起来得到的位置辅助器可以在球面上定位
+  const positionHelper = new THREE.Object3D();
+  positionHelper.position.z = 1;
+  latHelper.add(positionHelper);
++  // 用来定位盒子的中心, 以便接下来沿着Z轴缩放
++  const originHelper = new THREE.Object3D();
++  originHelper.position.z = 0.5;
++  positionHelper.add(originHelper);
+
+  const lonFudge = Math.PI * .5;
+  const latFudge = Math.PI * -0.135;
++  const geometries = [];
+  data.forEach((row, latNdx) => {
+    row.forEach((value, lonNdx) => {
+      if (value === undefined) {
+        return;
+      }
+      const amount = (value - min) / range;
+
+-      const material = new THREE.MeshBasicMaterial();
+-      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
+-      const saturation = 1;
+-      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
+-      material.color.setHSL(hue, saturation, lightness);
+-      const mesh = new THREE.Mesh(geometry, material);
+-      scene.add(mesh);
+
++      const boxWidth = 1;
++      const boxHeight = 1;
++      const boxDepth = 1;
++      const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+      // 调整位置辅助器的指向
+      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
+      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
+
+      // 使用world matrix来操作辅助器
+      positionHelper.updateWorldMatrix(true, false);
+      mesh.applyMatrix4(positionHelper.matrixWorld);
+      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
+
++      // 使用位置辅助器和world matrix 来定位
++      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
++      originHelper.updateWorldMatrix(true, false);
++      geometry.applyMatrix4(originHelper.matrixWorld);
++
++      geometries.push(geometry);
+    });
+  });
+
++  const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(
++      geometries, false);
++  const material = new THREE.MeshBasicMaterial({color:'red'});
++  const mesh = new THREE.Mesh(mergedGeometry, material);
++  scene.add(mesh);
+
+}
+```
+
+我们移除了之前用来改变盒子几何中心的代码, 取而代之的是`originHelper`. 这次我们要为每个长方体创建新的几何体, 因为我们要使用“applyMatrix”来移动每个长方体几何体的顶点, 所以我们最好只移动一次, 而不是两次.
+
+最后, 我们将所有几何体的数组传入`BufferGeometryUtils.mergeBufferGeometries`, 这个方法将会将其合并到一个mesh中
+
+别忘了引入`BufferGeometryUtils`
+
+```js
+import {BufferGeometryUtils} from './resources/threejs/r122/examples/jsm/utils/BufferGeometryUtils.js';
+```
+
+现在, 至少在我的机器上, 可以跑到60帧每秒了
+
+{{{example url="../threejs-lots-of-objects-merged.html" }}}
+
+虽然可以了, 但是我们这是一整个mesh, 所以我们只能应用一个材质, 意味着我们只能有一种颜色的盒子. 我们之前可是能有不同颜色的盒子. 我们可以通过使用顶点着色法来解决. 
+
+顶点着色向每个顶点添加一种颜色. 通过设定每个盒子的每个顶点的所有颜色来指定每个盒子的颜色. 
+
+
+```js
++const color = new THREE.Color();
+
+const lonFudge = Math.PI * .5;
+const latFudge = Math.PI * -0.135;
+const geometries = [];
+data.forEach((row, latNdx) => {
+  row.forEach((value, lonNdx) => {
+    if (value === undefined) {
+      return;
+    }
+    const amount = (value - min) / range;
+
+    const boxWidth = 1;
+    const boxHeight = 1;
+    const boxDepth = 1;
+    const geometry = new THREE.BoxBufferGeometry(boxWidth, boxHeight, boxDepth);
+
+    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
+    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;
+
+    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
+    originHelper.updateWorldMatrix(true, false);
+    geometry.applyMatrix4(originHelper.matrixWorld);
+
++    // 计算颜色
++    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
++    const saturation = 1;
++    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
++    color.setHSL(hue, saturation, lightness);
++    // 以0到255之间的值数组形式获取颜色
++    const rgb = color.toArray().map(v => v * 255);
++
++    // 创建一个数组来存储每个顶点的颜色
++    const numVerts = geometry.getAttribute('position').count;
++    const itemSize = 3;  // r, g, b
++    const colors = new Uint8Array(itemSize * numVerts);
++
++    // 将颜色复制到每个顶点的颜色数组中
++    colors.forEach((v, ndx) => {
++      colors[ndx] = rgb[ndx % 3];
++    });
++
++    const normalized = true;
++    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
++    geometry.setAttribute('color', colorAttrib);
+
+    geometries.push(geometry);
+  });
+});
+```
+
+上面的代码中, 我们查找几何体中的`position`属性来获取所需的数量和顶点. 然后创建一个`Uint8Array`来输入颜色. 接下来通过调用`geometry.setAttribute`来将其设定为一个属性. 
+
+最后告诉three.js使用顶点上色. 
+
+```js
+const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(
+    geometries, false);
+-const material = new THREE.MeshBasicMaterial({color:'red'});
++const material = new THREE.MeshBasicMaterial({
++  vertexColors: true,
++});
+const mesh = new THREE.Mesh(mergedGeometry, material);
+scene.add(mesh);
+```
+
+我们的彩色世界回来啦!
+
+{{{example url="../threejs-lots-of-objects-merged-vertexcolors.html" }}}
+
+合并几何体是一个常见的优化手段. 比如, 可以将一百多棵树合并成一个几何体, 一堆石头合并成一块石头, 零零碎碎的栅栏合并成一个栅栏的mesh. 另一个例子是Minecraft并不是一个一个方块去绘制, 而是创建一组合并了的方块, 当然之前选择性地移除那些看不见的. 
+
+这么做带来的问题是, 合并起来简单, 分离难. 接下来我们再引入一种优化方案
+[优化大量动画对象](threejs-optimize-lots-of-objects-animated.html).
+
+<canvas id="c"></canvas>
+<script type="module" src="resources/threejs-lots-of-objects.js"></script>

+ 196 - 0
threejs/lessons/zh_cn/threejs-rendering-on-demand.md

@@ -0,0 +1,196 @@
+Title: Three.js 按需渲染
+Description: 如何节约资源
+TOC: 按需渲染
+
+这一章的主题即便已经很明显了, 但... 以防万一, 还是说大多数情况下three.js给出的例子都是连续渲染的. 换言之他们使用了`requestAnimationFrame`循环或者写成*rAF loop*
+
+```js
+function render() {
+  ...
+  requestAnimationFrame(render);
+}
+requestAnimationFrame(render);
+```
+
+有些场景连续渲染是有意义的, 但是有些情况下不需要一直动呢? 这种情况下不断地渲染会浪费电, 对于移动设备来说属实不能接受. 
+
+显而易见的解决方法是一开始的时候渲染一次, 只有当什么东西改变了以后再次渲染. 这种改变包括纹理的变化, 或者再入了模型, 其他源传来了什么数据, 用户调整了设置或者是动了摄像机. 
+
+我们以[响应式设计](threejs-responsive.html)这一章为例, 稍作修改以满足按需渲染.
+
+首先我们添加`OrbitControls`, 这样当摄像机改变之后场景就可以随之渲染
+
+First we'll add in the `OrbitControls` so there is something that could change
+that we can render in response to.
+
+```js
+import * as THREE from './resources/three/r122/build/three.module.js';
++import {OrbitControls} from './resources/threejs/r122/examples/jsm/controls/OrbitControls.js';
+```
+
+然后
+
+```js
+const fov = 75;
+const aspect = 2;  // the canvas default
+const near = 0.1;
+const far = 5;
+const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+camera.position.z = 2;
+
++const controls = new OrbitControls(camera, canvas);
++controls.target.set(0, 0, 0);
++controls.update();
+```
+我们不需要在渲染那三个正方体了所以不再追踪
+
+```js
+-const cubes = [
+-  makeInstance(geometry, 0x44aa88,  0),
+-  makeInstance(geometry, 0x8844aa, -2),
+-  makeInstance(geometry, 0xaa8844,  2),
+-];
++makeInstance(geometry, 0x44aa88,  0);
++makeInstance(geometry, 0x8844aa, -2);
++makeInstance(geometry, 0xaa8844,  2);
+```
+把这些代码移除, 然后调用`requestAnimationFrame`
+
+```js
+-function render(time) {
+-  time *= 0.001;
++function render() {
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
+-  cubes.forEach((cube, ndx) => {
+-    const speed = 1 + ndx * .1;
+-    const rot = time * speed;
+-    cube.rotation.x = rot;
+-    cube.rotation.y = rot;
+-  });
+
+  renderer.render(scene, camera);
+
+-  requestAnimationFrame(render);
+}
+
+-requestAnimationFrame(render);
+```
+
+我们这次只需要渲染一次
+
+```js
+render();
+```
+我们需要在`OrbitControls`改变摄像机设置的时候渲染场景.
+幸好`OrbitControls`提供了一个`change`事件来监听变化
+
+```js
+controls.addEventListener('change', render);
+```
+
+我们同样需要捕捉到用户改变窗口大小的情况. 在之前连续渲染的时候这种情况是自动处理的, 但是现在是按需渲染, 我们需要在窗口改变的时候显式`resize`窗口大小
+
+```js
+window.addEventListener('resize', render);
+```
+然后我们就实现了按需渲染的功能
+
+{{{example url="../threejs-render-on-demand.html" }}}
+`OrbitControls` 有个选项可以增加某种惯性, 让整个画面显得不那么僵硬. 我们启用`enableDamping`来实现它
+
+```js
+controls.enableDamping = true;
+```
+开启`enableDamping`, 我们需要在渲染函数中调用`controls.update`, 让`OrbitControls`可以丝滑地让摄像机移动. 但是, 这就意味着我们不能直接地在`change`事件中调用`render`, 如此这般会导致死循环. 控制器响应一个`change`事件然后调用`render`, 然后`render`调用`controls.update`. 这个方法会再发出另一个`change`事件. 
+
+我们可以通过使用`requestAnimationFrame`调用`render`, 但是需要确保仅仅在需要一个新帧的时候才执行. 如果没有请求
+
+我们可以通过使用`requestAnimationFrame`调用`render`来解决这个问题, 但是我们需要确保我们只在还没有请求一个新帧的情况下请求一个新帧, 我们可以通过一个变量来跟踪我们已经请求的帧
+
+```js
++let renderRequested = false;
+
+function render() {
++  renderRequested = false;
+
+  if (resizeRendererToDisplaySize(renderer)) {
+    const canvas = renderer.domElement;
+    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.updateProjectionMatrix();
+  }
+
+  renderer.render(scene, camera);
+}
+render();
+
++function requestRenderIfNotRequested() {
++  if (!renderRequested) {
++    renderRequested = true;
++    requestAnimationFrame(render);
++  }
++}
+
+-controls.addEventListener('change', render);
++controls.addEventListener('change', requestRenderIfNotRequested);
+```
+我们应该会在改变窗口大小的时候用到`requestRenderIfNotRequested`
+
+```js
+-window.addEventListener('resize', render);
++window.addEventListener('resize', requestRenderIfNotRequested);
+```
+
+可能很难看出来有什么不同. 试着点一下下面的例子, 然后用方向键移动, 或者拖拽旋转. 然后在上面的例子中做同样的事, 你应该能感觉出来区别. 上面的像是一帧帧在放幻灯片, 下面则是丝滑柔顺.
+
+{{{example url="../threejs-render-on-demand-w-damping.html" }}}
+
+让我们加一个简单的GUI
+
+```js
+import * as THREE from './resources/three/r122/build/three.module.js';
+import {OrbitControls} from './resources/threejs/r122/examples/jsm/controls/OrbitControls.js';
++import {GUI} from '../3rdparty/dat.gui.module.js';
+```
+
+这个控制器可以改变每个立方体的颜色和在x方向缩放. 为了设置颜色我们用了`ColorGUIHelper`, 这个在[光线](threejs-lights.html)一章提到过
+
+
+```js
+const gui = new GUI();
+```
+对每一个立方体, 我们建一个折叠菜单, 一个是`material.color`, 另一个是`cube.scale.x`
+
+```js
+function makeInstance(geometry, color, x) {
+  const material = new THREE.MeshPhongMaterial({color});
+
+  const cube = new THREE.Mesh(geometry, material);
+  scene.add(cube);
+
+  cube.position.x = x;
+
++  const folder = gui.addFolder(`Cube${x}`);
++  folder.addColor(new ColorGUIHelper(material, 'color'), 'value')
++      .name('color')
++      .onChange(requestRenderIfNotRequested);
++  folder.add(cube.scale, 'x', .1, 1.5)
++      .name('scale x')
++      .onChange(requestRenderIfNotRequested);
++  folder.open();
+
+  return cube;
+}
+```
+
+上面的GUI用了一个`onChange`方法, 在数值改变的时候调用传入一个回调函数. 这个例子中, 我们仅仅需要它调用`requestRenderIfNotRequested`. `folder.open`是使折叠菜单展开的方法
+
+
+{{{example url="../threejs-render-on-demand-w-gui.html" }}}
+
+我希望这篇文章能在将连续渲染改成按需渲染的时候给你一些启发. 按需渲染不像是连续渲染那么常见, 因为3D游戏或者艺术创作中必须要让场景动出来. 但是有些场合, 例如地图浏览器, 3D编辑器, 3D图产生器等等的, 可能还是按需渲染比较好.