瀏覽代碼

Initial commit.

simondevyoutube 5 年之前
父節點
當前提交
25f3563a07
共有 19 個文件被更改,包括 1668 次插入0 次删除
  1. 47 0
      base.css
  2. 15 0
      demo.html
  3. 13 0
      index.html
  4. 二進制
      resources/heightmap-hi.png
  5. 二進制
      resources/heightmap-simondev.jpg
  6. 二進制
      resources/heightmap-test.jpg
  7. 二進制
      resources/waternormals.jpg
  8. 227 0
      src/controls.js
  9. 82 0
      src/demo.js
  10. 60 0
      src/game.js
  11. 109 0
      src/graphics.js
  12. 75 0
      src/main.js
  13. 38 0
      src/math.js
  14. 92 0
      src/noise.js
  15. 92 0
      src/quadtree.js
  16. 117 0
      src/sky.js
  17. 76 0
      src/spline.js
  18. 604 0
      src/terrain.js
  19. 21 0
      src/utils.js

+ 47 - 0
base.css

@@ -0,0 +1,47 @@
+.header {
+  font-size: 3em;
+  color: white;
+  background: #404040;
+  text-align: center;
+  height: 2.5em;
+  text-shadow: 4px 4px 4px black;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+#error {
+  font-size: 2em;
+  color: red;
+  height: 50px;
+  text-shadow: 2px 2px 2px black;
+  margin: 2em;
+  display: none;
+}
+
+.container {
+  width: 100% !important;
+  height: 100% !important;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+  position: absolute;
+}
+
+.visible {
+  display: block;
+}
+
+#target {
+  width: 100% !important;
+  height: 100% !important;
+  position: absolute;
+}
+
+body {
+  background: #000000;
+  margin: 0;
+  padding: 0;
+  overscroll-behavior: none;
+}

+ 15 - 0
demo.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Noise</title>
+  <link rel="stylesheet" type="text/css" href="base.css">
+  <script src="https://github.com/mrdoob/three.js/blob/r112/build/three.module.js"></script>
+</head>
+<body>
+  <div class="container">
+    <canvas id="canvas" width="512px" height="512px"></canvas>
+  </div>
+  <script src="./src/demo.js" type="module">
+  </script>
+</body>
+</html>

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Procedural Terrain</title>
+  <link rel="stylesheet" type="text/css" href="base.css">
+  <script src="https://github.com/mrdoob/three.js/blob/r112/build/three.module.js"></script>
+</head>
+<body>
+  <div id="target"></div>
+  <script src="./src/main.js" type="module">
+  </script>
+</body>
+</html>

二進制
resources/heightmap-hi.png


二進制
resources/heightmap-simondev.jpg


二進制
resources/heightmap-test.jpg


二進制
resources/waternormals.jpg


+ 227 - 0
src/controls.js

@@ -0,0 +1,227 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+import {PointerLockControls} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/controls/PointerLockControls.js';
+
+
+export const controls = (function() {
+  return {
+    // FPSControls was adapted heavily from a threejs example. Movement control
+    // and collision detection was completely rewritten, but credit to original
+    // class for the setup code.
+    FPSControls: class {
+      constructor(params) {
+        this._cells = params.cells;
+        this._Init(params);
+      }
+
+      _Init(params) {
+        this._radius = 2;
+        this._enabled = false;
+        this._move = {
+          forward: false,
+          backward: false,
+          left: false,
+          right: false,
+          up: false,
+          down: false,
+        };
+        this._standing = true;
+        this._velocity = new THREE.Vector3(0, 0, 0);
+        this._decceleration = new THREE.Vector3(-10, -10, -10);
+        this._acceleration = new THREE.Vector3(250, 100, 250);
+
+        this._SetupPointerLock();
+
+        this._controls = new PointerLockControls(
+            params.camera, document.body);
+        params.scene.add(this._controls.getObject());
+
+        document.addEventListener('keydown', (e) => this._onKeyDown(e), false);
+        document.addEventListener('keyup', (e) => this._onKeyUp(e), false);
+      }
+
+      _onKeyDown(event) {
+        switch (event.keyCode) {
+          case 38: // up
+          case 87: // w
+            this._move.forward = true;
+            break;
+          case 37: // left
+          case 65: // a
+            this._move.left = true;
+            break;
+          case 40: // down
+          case 83: // s
+            this._move.backward = true;
+            break;
+          case 39: // right
+          case 68: // d
+            this._move.right = true;
+            break;
+          case 33: // PG_UP
+            this._move.up = true;
+            break;
+          case 34: // PG_DOWN
+            this._move.down = true;
+            break;
+        }
+      }
+
+      _onKeyUp(event) {
+        switch(event.keyCode) {
+          case 38: // up
+          case 87: // w
+            this._move.forward = false;
+            break;
+          case 37: // left
+          case 65: // a
+            this._move.left = false;
+            break;
+          case 40: // down
+          case 83: // s
+            this._move.backward = false;
+            break;
+          case 39: // right
+          case 68: // d
+            this._move.right = false;
+            break;
+          case 33: // PG_UP
+            this._move.up = false;
+            break;
+          case 34: // PG_DOWN
+            this._move.down = false;
+            break;
+        }
+      }
+
+      _SetupPointerLock() {
+        const hasPointerLock = (
+            'pointerLockElement' in document ||
+            'mozPointerLockElement' in document ||
+            'webkitPointerLockElement' in document);
+        if (hasPointerLock) {
+          const lockChange = (event) => {
+            if (document.pointerLockElement === document.body ||
+                document.mozPointerLockElement === document.body ||
+                document.webkitPointerLockElement === document.body ) {
+              this._enabled = true;
+              this._controls.enabled = true;
+            } else {
+              this._controls.enabled = false;
+            }
+          };
+          const lockError = (event) => {
+            console.log(event);
+          };
+
+          document.addEventListener('pointerlockchange', lockChange, false);
+          document.addEventListener('webkitpointerlockchange', lockChange, false);
+          document.addEventListener('mozpointerlockchange', lockChange, false);
+          document.addEventListener('pointerlockerror', lockError, false);
+          document.addEventListener('mozpointerlockerror', lockError, false);
+          document.addEventListener('webkitpointerlockerror', lockError, false);
+
+          document.getElementById('target').addEventListener('click', (event) => {
+            document.body.requestPointerLock = (
+                document.body.requestPointerLock ||
+                document.body.mozRequestPointerLock ||
+                document.body.webkitRequestPointerLock);
+
+            if (/Firefox/i.test(navigator.userAgent)) {
+              const fullScreenChange = (event) => {
+                if (document.fullscreenElement === document.body ||
+                    document.mozFullscreenElement === document.body ||
+                    document.mozFullScreenElement === document.body) {
+                  document.removeEventListener('fullscreenchange', fullScreenChange);
+                  document.removeEventListener('mozfullscreenchange', fullScreenChange);
+                  document.body.requestPointerLock();
+                }
+              };
+              document.addEventListener(
+                  'fullscreenchange', fullScreenChange, false);
+              document.addEventListener(
+                  'mozfullscreenchange', fullScreenChange, false);
+              document.body.requestFullscreen = (
+                  document.body.requestFullscreen ||
+                  document.body.mozRequestFullscreen ||
+                  document.body.mozRequestFullScreen ||
+                  document.body.webkitRequestFullscreen);
+              document.body.requestFullscreen();
+            } else {
+              document.body.requestPointerLock();
+            }
+          }, false);
+        }
+      }
+
+      _FindIntersections(boxes, position) {
+        const sphere = new THREE.Sphere(position, this._radius);
+
+        const intersections = boxes.filter(b => {
+          return sphere.intersectsBox(b);
+        });
+
+        return intersections;
+      }
+
+      Update(timeInSeconds) {
+        if (!this._enabled) {
+          return;
+        }
+
+        const frameDecceleration = new THREE.Vector3(
+            this._velocity.x * this._decceleration.x,
+            this._velocity.y * this._decceleration.y,
+            this._velocity.z * this._decceleration.z
+        );
+        frameDecceleration.multiplyScalar(timeInSeconds);
+
+        this._velocity.add(frameDecceleration);
+
+        if (this._move.forward) {
+          this._velocity.z -= this._acceleration.z * timeInSeconds;
+        }
+        if (this._move.backward) {
+          this._velocity.z += this._acceleration.z * timeInSeconds;
+        }
+        if (this._move.left) {
+          this._velocity.x -= this._acceleration.x * timeInSeconds;
+        }
+        if (this._move.right) {
+          this._velocity.x += this._acceleration.x * timeInSeconds;
+        }
+        if (this._move.up) {
+          this._velocity.y += this._acceleration.y * timeInSeconds;
+        }
+        if (this._move.down) {
+          this._velocity.y -= this._acceleration.y * timeInSeconds;
+        }
+
+        const controlObject = this._controls.getObject();
+
+        const oldPosition = new THREE.Vector3();
+        oldPosition.copy(controlObject.position);
+
+        const forward = new THREE.Vector3(0, 0, 1);
+        forward.applyQuaternion(controlObject.quaternion);
+        forward.y = 0;
+        forward.normalize();
+
+        const updown = new THREE.Vector3(0, 1, 0);
+
+        const sideways = new THREE.Vector3(1, 0, 0);
+        sideways.applyQuaternion(controlObject.quaternion);
+        sideways.normalize();
+
+        sideways.multiplyScalar(this._velocity.x * timeInSeconds);
+        updown.multiplyScalar(this._velocity.y * timeInSeconds);
+        forward.multiplyScalar(this._velocity.z * timeInSeconds);
+
+        controlObject.position.add(forward);
+        controlObject.position.add(sideways);
+        controlObject.position.add(updown);
+
+        oldPosition.copy(controlObject.position);
+      }
+    }
+  };
+})();

+ 82 - 0
src/demo.js

@@ -0,0 +1,82 @@
+import {game} from './game.js';
+import {graphics} from './graphics.js';
+import {math} from './math.js';
+import {noise} from './noise.js';
+
+
+window.onload = function() {
+  function _Perlin() {
+    const canvas = document.getElementById("canvas"); 
+    const context = canvas.getContext("2d");
+  
+    const imgData = context.createImageData(canvas.width, canvas.height);
+  
+    const params = {
+      scale: 32,
+      noiseType: 'simplex',
+      persistence: 0.5,
+      octaves: 1,
+      lacunarity: 1,
+      exponentiation: 1,
+      height: 255
+    };
+    const noiseGen = new noise.Noise(params);
+
+    for (let x = 0; x < canvas.width; x++) {
+      for (let y = 0; y < canvas.height; y++) {
+        const pixelIndex = (y * canvas.width + x) * 4;
+
+        const n = noiseGen.Get(x, y);
+
+        imgData.data[pixelIndex] = n;
+        imgData.data[pixelIndex+1] = n;
+        imgData.data[pixelIndex+2] = n;
+        imgData.data[pixelIndex+3] = 255;
+      }
+    }
+  
+    context.putImageData(imgData, 0, 0);
+}
+
+
+function _Randomness() {
+  const canvas = document.getElementById("canvas"); 
+  const context = canvas.getContext("2d");
+
+  const imgData = context.createImageData(canvas.width, canvas.height);
+
+  const params = {
+    scale: 32,
+    noiseType: 'simplex',
+    persistence: 0.5,
+    octaves: 1,
+    lacunarity: 2,
+    exponentiation: 1,
+    height: 1
+  };
+  const noiseGen = new noise.Noise(params);
+  let foo = '';
+
+  for (let x = 0; x < canvas.width; x++) {
+    for (let y = 0; y < canvas.height; y++) {
+      const pixelIndex = (y * canvas.width + x) * 4;
+
+      const n = noiseGen.Get(x, y);
+      if (x == 0) {
+        foo += n + '\n';
+      }
+
+      imgData.data[pixelIndex] = n;
+      imgData.data[pixelIndex+1] = n;
+      imgData.data[pixelIndex+2] = n;
+      imgData.data[pixelIndex+3] = 255;
+    }
+  }
+  console.log(foo);
+
+  context.putImageData(imgData, 0, 0);
+}
+
+_Randomness();
+  
+};

+ 60 - 0
src/game.js

@@ -0,0 +1,60 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+import {WEBGL} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/WebGL.js';
+import {graphics} from './graphics.js';
+
+
+export const game = (function() {
+  return {
+    Game: class {
+      constructor() {
+        this._Initialize();
+      }
+
+      _Initialize() {
+        this._graphics = new graphics.Graphics(this);
+        if (!this._graphics.Initialize()) {
+          this._DisplayError('WebGL2 is not available.');
+          return;
+        }
+
+        this._previousRAF = null;
+        this._minFrameTime = 1.0 / 10.0;
+        this._entities = {};
+
+        this._OnInitialize();
+        this._RAF();
+      }
+
+      _DisplayError(errorText) {
+        const error = document.getElementById('error');
+        error.innerText = errorText;
+      }
+
+      _RAF() {
+        requestAnimationFrame((t) => {
+          if (this._previousRAF === null) {
+            this._previousRAF = t;
+          }
+          this._Render(t - this._previousRAF);
+          this._previousRAF = t;
+        });
+      }
+
+      _StepEntities(timeInSeconds) {
+        for (let k in this._entities) {
+          this._entities[k].Update(timeInSeconds);
+        }
+      }
+
+      _Render(timeInMS) {
+        const timeInSeconds = Math.min(timeInMS * 0.001, this._minFrameTime);
+
+        this._OnStep(timeInSeconds);
+        this._StepEntities(timeInSeconds);
+        this._graphics.Render(timeInSeconds);
+
+        this._RAF();
+      }
+    }
+  };
+})();

+ 109 - 0
src/graphics.js

@@ -0,0 +1,109 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+import Stats from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/libs/stats.module.js';
+import {WEBGL} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/WebGL.js';
+
+
+export const graphics = (function() {
+
+  function _GetImageData(image) {
+    const canvas = document.createElement('canvas');
+    canvas.width = image.width;
+    canvas.height = image.height;
+
+    const context = canvas.getContext( '2d' );
+    context.drawImage(image, 0, 0);
+
+    return context.getImageData(0, 0, image.width, image.height);
+  }
+
+  function _GetPixel(imagedata, x, y) {
+    const position = (x + imagedata.width * y) * 4;
+    const data = imagedata.data;
+    return {
+        r: data[position],
+        g: data[position + 1],
+        b: data[position + 2],
+        a: data[position + 3]
+    };
+  }
+
+  class _Graphics {
+    constructor(game) {
+    }
+
+    Initialize() {
+      if (!WEBGL.isWebGL2Available()) {
+        return false;
+      }
+
+      this._threejs = new THREE.WebGLRenderer({
+          antialias: true,
+      });
+      this._threejs.setPixelRatio(window.devicePixelRatio);
+      this._threejs.setSize(window.innerWidth, window.innerHeight);
+
+      const target = document.getElementById('target');
+      target.appendChild(this._threejs.domElement);
+
+      this._stats = new Stats();
+      //target.appendChild(this._stats.dom);
+
+      window.addEventListener('resize', () => {
+        this._OnWindowResize();
+      }, false);
+
+      const fov = 60;
+      const aspect = 1920 / 1080;
+      const near = 1;
+      const far = 25000.0;
+      this._camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+      this._camera.position.set(75, 20, 0);
+
+      this._scene = new THREE.Scene();
+      this._scene.background = new THREE.Color(0xaaaaaa);
+
+      this._CreateLights();
+
+      return true;
+    }
+
+    _CreateLights() {
+      let light = new THREE.DirectionalLight(0x808080, 1, 100);
+      light.position.set(-100, 100, -100);
+      light.target.position.set(0, 0, 0);
+      light.castShadow = false;
+      this._scene.add(light);
+
+      light = new THREE.DirectionalLight(0x404040, 1.5, 100);
+      light.position.set(100, 100, -100);
+      light.target.position.set(0, 0, 0);
+      light.castShadow = false;
+      this._scene.add(light);
+    }
+
+    _OnWindowResize() {
+      this._camera.aspect = window.innerWidth / window.innerHeight;
+      this._camera.updateProjectionMatrix();
+      this._threejs.setSize(window.innerWidth, window.innerHeight);
+    }
+
+    get Scene() {
+      return this._scene;
+    }
+
+    get Camera() {
+      return this._camera;
+    }
+
+    Render(timeInSeconds) {
+      this._threejs.render(this._scene, this._camera);
+      this._stats.update();
+    }
+  }
+
+  return {
+    Graphics: _Graphics,
+    GetPixel: _GetPixel,
+    GetImageData: _GetImageData,
+  };
+})();

+ 75 - 0
src/main.js

@@ -0,0 +1,75 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+import {GUI} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/libs/dat.gui.module.js';
+import {controls} from './controls.js';
+import {game} from './game.js';
+import {sky} from './sky.js';
+import {terrain} from './terrain.js';
+
+
+let _APP = null;
+
+
+
+class ProceduralTerrain_Demo extends game.Game {
+  constructor() {
+    super();
+  }
+
+  _OnInitialize() {
+    this._CreateGUI();
+
+    this._userCamera = new THREE.Object3D();
+    this._userCamera.position.set(475, 75, 900);
+
+    this._entities['_terrain'] = new terrain.TerrainChunkManager({
+      camera: this._userCamera,
+      scene: this._graphics.Scene,
+      gui: this._gui,
+      guiParams: this._guiParams,
+    });
+
+    this._entities['_sky'] = new sky.TerrainSky({
+      camera: this._graphics.Camera,
+      scene: this._graphics.Scene,
+      gui: this._gui,
+      guiParams: this._guiParams,
+    });
+
+    this._entities['_controls'] = new controls.FPSControls(
+      {
+        scene: this._graphics.Scene,
+        camera: this._userCamera
+      });
+
+    this._graphics.Camera.position.copy(this._userCamera.position);
+
+    this._LoadBackground();
+  }
+
+  _CreateGUI() {
+    this._guiParams = {
+      general: {
+      },
+    };
+    this._gui = new GUI();
+
+    const generalRollup = this._gui.addFolder('General');
+    this._gui.close();
+  }
+
+  _LoadBackground() {
+    this._graphics.Scene.background = new THREE.Color(0x000000);
+  }
+
+  _OnStep(_) {
+    this._graphics._camera.position.copy(this._userCamera.position);
+    this._graphics._camera.quaternion.copy(this._userCamera.quaternion);
+  }
+}
+
+
+function _Main() {
+  _APP = new ProceduralTerrain_Demo();
+}
+
+_Main();

+ 38 - 0
src/math.js

@@ -0,0 +1,38 @@
+export const math = (function() {
+  return {
+    rand_range: function(a, b) {
+      return Math.random() * (b - a) + a;
+    },
+
+    rand_normalish: function() {
+      const r = Math.random() + Math.random() + Math.random() + Math.random();
+      return (r / 4.0) * 2.0 - 1;
+    },
+
+    rand_int: function(a, b) {
+      return Math.round(Math.random() * (b - a) + a);
+    },
+
+    lerp: function(x, a, b) {
+      return x * (b - a) + a;
+    },
+
+    smoothstep: function(x, a, b) {
+      x = x * x * (3.0 - 2.0 * x);
+      return x * (b - a) + a;
+    },
+
+    smootherstep: function(x, a, b) {
+      x = x * x * x * (x * (x * 6 - 15) + 10);
+      return x * (b - a) + a;
+    },
+
+    clamp: function(x, a, b) {
+      return Math.min(Math.max(x, a), b);
+    },
+
+    sat: function(x) {
+      return Math.min(Math.max(x, 0.0), 1.0);
+    },
+  };
+})();

+ 92 - 0
src/noise.js

@@ -0,0 +1,92 @@
+import 'https://cdn.jsdelivr.net/npm/[email protected]/simplex-noise.js';
+import perlin from 'https://cdn.jsdelivr.net/gh/mikechambers/es6-perlin-module/perlin.js';
+
+import {math} from './math.js';
+
+export const noise = (function() {
+
+  class _PerlinWrapper {
+    constructor() {
+    }
+
+    noise2D(x, y) {
+      return perlin(x, y) * 2.0 - 1.0;
+    }
+  }
+
+  class _RandomWrapper {
+    constructor() {
+      this._values = {};
+    }
+
+    _Rand(x, y) {
+      const k = x + '.' + y;
+      if (!(k in this._values)) {
+        this._values[k] = Math.random() * 2 - 1;
+      }
+      return this._values[k];
+    }
+
+    noise2D(x, y) {
+      // Bilinear filter
+      const x1 = Math.floor(x);
+      const y1 = Math.floor(y);
+      const x2 = x1 + 1;
+      const y2 = y1 + 1;
+    
+      const xp = x - x1;
+      const yp = y - y1;
+    
+      const p11 = this._Rand(x1, y1);
+      const p21 = this._Rand(x2, y1);
+      const p12 = this._Rand(x1, y2);
+      const p22 = this._Rand(x2, y2);
+    
+      const px1 = math.lerp(xp, p11, p21);
+      const px2 = math.lerp(xp, p12, p22);
+    
+      return math.lerp(yp, px1, px2);
+    }
+  }
+
+  class _NoiseGenerator {
+    constructor(params) {
+      this._params = params;
+      this._Init();
+    }
+
+    _Init() {
+      this._noise = {
+        simplex: new SimplexNoise(this._params.seed),
+        perlin: new _PerlinWrapper(),
+        rand: new _RandomWrapper(),
+      };
+    }
+
+    Get(x, y) {
+      const xs = x / this._params.scale;
+      const ys = y / this._params.scale;
+      const noiseFunc = this._noise[this._params.noiseType];
+      const G = 2.0 ** (-this._params.persistence);
+      let amplitude = 1.0;
+      let frequency = 1.0;
+      let normalization = 0;
+      let total = 0;
+      for (let o = 0; o < this._params.octaves; o++) {
+        const noiseValue = noiseFunc.noise2D(
+            xs * frequency, ys * frequency) * 0.5 + 0.5;
+        total += noiseValue * amplitude;
+        normalization += amplitude;
+        amplitude *= G;
+        frequency *= this._params.lacunarity;
+      }
+      total /= normalization;
+      return Math.pow(
+          total, this._params.exponentiation) * this._params.height;
+    }
+  }
+
+  return {
+    Noise: _NoiseGenerator
+  }
+})();

+ 92 - 0
src/quadtree.js

@@ -0,0 +1,92 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+
+
+export const quadtree = (function() {
+
+  const _MIN_NODE_SIZE = 500;
+
+  class QuadTree {
+    constructor(params) {
+      const b = new THREE.Box2(params.min, params.max);
+      this._root = {
+        bounds: b,
+        children: [],
+        center: b.getCenter(new THREE.Vector2()),
+        size: b.getSize(new THREE.Vector2()),
+      };
+    }
+
+    GetChildren() {
+      const children = [];
+      this._GetChildren(this._root, children);
+      return children;
+    }
+
+    _GetChildren(node, target) {
+      if (node.children.length == 0) {
+        target.push(node);
+        return;
+      }
+
+      for (let c of node.children) {
+        this._GetChildren(c, target);
+      }
+    }
+
+    Insert(pos) {
+      this._Insert(this._root, new THREE.Vector2(pos.x, pos.z));
+    }
+
+    _Insert(child, pos) {
+      const distToChild = this._DistanceToChild(child, pos);
+
+      if (distToChild < child.size.x && child.size.x > _MIN_NODE_SIZE) {
+        child.children = this._CreateChildren(child);
+
+        for (let c of child.children) {
+          this._Insert(c, pos);
+        }
+      }
+    }
+
+    _DistanceToChild(child, pos) {
+      return child.center.distanceTo(pos);
+    }
+
+    _CreateChildren(child) {
+      const midpoint = child.bounds.getCenter(new THREE.Vector2());
+
+      // Bottom left
+      const b1 = new THREE.Box2(child.bounds.min, midpoint);
+
+      // Bottom right
+      const b2 = new THREE.Box2(
+        new THREE.Vector2(midpoint.x, child.bounds.min.y),
+        new THREE.Vector2(child.bounds.max.x, midpoint.y));
+
+      // Top left
+      const b3 = new THREE.Box2(
+        new THREE.Vector2(child.bounds.min.x, midpoint.y),
+        new THREE.Vector2(midpoint.x, child.bounds.max.y));
+
+      // Top right
+      const b4 = new THREE.Box2(midpoint, child.bounds.max);
+
+      const children = [b1, b2, b3, b4].map(
+          b => {
+            return {
+              bounds: b,
+              children: [],
+              center: b.getCenter(new THREE.Vector2()),
+              size: b.getSize(new THREE.Vector2())
+            };
+          });
+
+      return children;
+    }
+  }
+
+  return {
+    QuadTree: QuadTree
+  }
+})();

+ 117 - 0
src/sky.js

@@ -0,0 +1,117 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+
+import {Sky} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/objects/Sky.js';
+import {Water} from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/objects/Water.js';
+
+
+export const sky = (function() {
+
+  class TerrainSky {
+    constructor(params) {
+      this._params = params;
+      this._Init(params);
+    }
+
+    _Init(params) {
+      const waterGeometry = new THREE.PlaneBufferGeometry(10000, 10000, 100, 100);
+
+      this._water = new Water(
+        waterGeometry,
+        {
+          textureWidth: 2048,
+          textureHeight: 2048,
+          waterNormals: new THREE.TextureLoader().load( 'resources/waternormals.jpg', function ( texture ) {
+
+            texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
+
+          } ),
+          alpha: 0.5,
+          sunDirection: new THREE.Vector3(1, 0, 0),
+          sunColor: 0xffffff,
+          waterColor: 0x001e0f,
+          distortionScale: 0.0,
+          fog: undefined
+        }
+      );
+      this._water.rotation.x = - Math.PI / 2;
+      this._water.position.y = 4;
+
+      this._sky = new Sky();
+      this._sky.scale.setScalar(10000);
+
+      this._group = new THREE.Group();
+      this._group.add(this._water);
+      this._group.add(this._sky);
+
+      params.scene.add(this._group);
+
+      params.guiParams.sky = {
+        turbidity: 10.0,
+        rayleigh: 2,
+        mieCoefficient: 0.005,
+        mieDirectionalG: 0.8,
+        luminance: 1,
+      };
+
+      params.guiParams.sun = {
+        inclination: 0.31,
+        azimuth: 0.25,
+      };
+
+      const onShaderChange = () => {
+        for (let k in params.guiParams.sky) {
+          this._sky.material.uniforms[k].value = params.guiParams.sky[k];
+        }
+        for (let k in params.guiParams.general) {
+          this._sky.material.uniforms[k].value = params.guiParams.general[k];
+        }
+      };
+
+      const onSunChange = () => {
+        var theta = Math.PI * (params.guiParams.sun.inclination - 0.5);
+        var phi = 2 * Math.PI * (params.guiParams.sun.azimuth - 0.5);
+
+        const sunPosition = new THREE.Vector3();
+        sunPosition.x = Math.cos(phi);
+        sunPosition.y = Math.sin(phi) * Math.sin(theta);
+        sunPosition.z = Math.sin(phi) * Math.cos(theta);
+
+        this._sky.material.uniforms['sunPosition'].value.copy(sunPosition);
+        this._water.material.uniforms['sunDirection'].value.copy(sunPosition.normalize());
+      };
+
+      const skyRollup = params.gui.addFolder('Sky');
+      skyRollup.add(params.guiParams.sky, "turbidity", 0.1, 30.0).onChange(
+          onShaderChange);
+      skyRollup.add(params.guiParams.sky, "rayleigh", 0.1, 4.0).onChange(
+          onShaderChange);
+      skyRollup.add(params.guiParams.sky, "mieCoefficient", 0.0001, 0.1).onChange(
+          onShaderChange);
+      skyRollup.add(params.guiParams.sky, "mieDirectionalG", 0.0, 1.0).onChange(
+          onShaderChange);
+      skyRollup.add(params.guiParams.sky, "luminance", 0.0, 2.0).onChange(
+          onShaderChange);
+
+      const sunRollup = params.gui.addFolder('Sun');
+      sunRollup.add(params.guiParams.sun, "inclination", 0.0, 1.0).onChange(
+          onSunChange);
+      sunRollup.add(params.guiParams.sun, "azimuth", 0.0, 1.0).onChange(
+          onSunChange);
+
+      onShaderChange();
+      onSunChange();
+    }
+
+    Update(timeInSeconds) {
+      this._water.material.uniforms['time'].value += timeInSeconds;
+
+      this._group.position.x = this._params.camera.position.x;
+      this._group.position.z = this._params.camera.position.z;
+    }
+  }
+
+
+  return {
+    TerrainSky: TerrainSky
+  }
+})();

+ 76 - 0
src/spline.js

@@ -0,0 +1,76 @@
+export const spline = (function() {
+
+  class _CubicHermiteSpline {
+    constructor(lerp) {
+      this._points = [];
+      this._lerp = lerp;
+    }
+
+    AddPoint(t, d) {
+      this._points.push([t, d]);
+    }
+
+    Get(t) {
+      let p1 = 0;
+
+      for (let i = 0; i < this._points.length; i++) {
+        if (this._points[i][0] >= t) {
+          break;
+        }
+        p1 = i;
+      }
+
+      const p0 = Math.max(0, p1 - 1);
+      const p2 = Math.min(this._points.length - 1, p1 + 1);
+      const p3 = Math.min(this._points.length - 1, p1 + 2);
+
+      if (p1 == p2) {
+        return this._points[p1][1];
+      }
+
+      return this._lerp(
+          (t - this._points[p1][0]) / (
+              this._points[p2][0] - this._points[p1][0]),
+          this._points[p0][1], this._points[p1][1],
+          this._points[p2][1], this._points[p3][1]);
+    }
+  };
+
+  class _LinearSpline {
+    constructor(lerp) {
+      this._points = [];
+      this._lerp = lerp;
+    }
+
+    AddPoint(t, d) {
+      this._points.push([t, d]);
+    }
+
+    Get(t) {
+      let p1 = 0;
+
+      for (let i = 0; i < this._points.length; i++) {
+        if (this._points[i][0] >= t) {
+          break;
+        }
+        p1 = i;
+      }
+
+      const p2 = Math.min(this._points.length - 1, p1 + 1);
+
+      if (p1 == p2) {
+        return this._points[p1][1];
+      }
+
+      return this._lerp(
+          (t - this._points[p1][0]) / (
+              this._points[p2][0] - this._points[p1][0]),
+          this._points[p1][1], this._points[p2][1]);
+    }
+  }
+
+  return {
+    CubicHermiteSpline: _CubicHermiteSpline,
+    LinearSpline: _LinearSpline,
+  };
+})();

+ 604 - 0
src/terrain.js

@@ -0,0 +1,604 @@
+import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js';
+
+import {graphics} from './graphics.js';
+import {math} from './math.js';
+import {noise} from './noise.js';
+import {quadtree} from './quadtree.js';
+import {spline} from './spline.js';
+import {utils} from './utils.js';
+
+
+export const terrain = (function() {
+
+  class HeightGenerator {
+    constructor(generator, position, minRadius, maxRadius) {
+      this._position = position.clone();
+      this._radius = [minRadius, maxRadius];
+      this._generator = generator;
+    }
+  
+    Get(x, y) {
+      const distance = this._position.distanceTo(new THREE.Vector2(x, y));
+      let normalization = 1.0 - math.sat(
+          (distance - this._radius[0]) / (this._radius[1] - this._radius[0]));
+      normalization = normalization * normalization * (3 - 2 * normalization);
+  
+      return [this._generator.Get(x, y), normalization];
+    }
+  }
+  
+  
+  class FixedHeightGenerator {
+    constructor() {}
+  
+    Get() {
+      return [50, 1];
+    }
+  }
+  
+  
+  class Heightmap {
+    constructor(params, img) {
+      this._params = params;
+      this._data = graphics.GetImageData(img);
+    }
+  
+    Get(x, y) {
+      const _GetPixelAsFloat = (x, y) => {
+        const position = (x + this._data.width * y) * 4;
+        const data = this._data.data;
+        return data[position] / 255.0;
+      }
+  
+      // Bilinear filter
+      const offset = new THREE.Vector2(-250, -250);
+      const dimensions = new THREE.Vector2(500, 500);
+  
+      const xf = 1.0 - math.sat((x - offset.x) / dimensions.x);
+      const yf = math.sat((y - offset.y) / dimensions.y);
+      const w = this._data.width - 1;
+      const h = this._data.height - 1;
+  
+      const x1 = Math.floor(xf * w);
+      const y1 = Math.floor(yf * h);
+      const x2 = math.clamp(x1 + 1, 0, w);
+      const y2 = math.clamp(y1 + 1, 0, h);
+  
+      const xp = xf * w - x1;
+      const yp = yf * h - y1;
+  
+      const p11 = _GetPixelAsFloat(x1, y1);
+      const p21 = _GetPixelAsFloat(x2, y1);
+      const p12 = _GetPixelAsFloat(x1, y2);
+      const p22 = _GetPixelAsFloat(x2, y2);
+  
+      const px1 = math.lerp(xp, p11, p21);
+      const px2 = math.lerp(xp, p12, p22);
+  
+      return math.lerp(yp, px1, px2) * this._params.height;
+    }
+  }
+  
+  const _WHITE = new THREE.Color(0x808080);
+  const _OCEAN = new THREE.Color(0xd9d592);
+  const _BEACH = new THREE.Color(0xd9d592);
+  const _SNOW = new THREE.Color(0xFFFFFF);
+  const _FOREST_TROPICAL = new THREE.Color(0x4f9f0f);
+  const _FOREST_TEMPERATE = new THREE.Color(0x2b960e);
+  const _FOREST_BOREAL = new THREE.Color(0x29c100);
+  
+  const _GREEN = new THREE.Color(0x80FF80);
+  const _RED = new THREE.Color(0xFF8080);
+  const _BLACK = new THREE.Color(0x000000);
+  
+  const _MIN_CELL_SIZE = 500;
+  const _FIXED_GRID_SIZE = 10;
+  const _MIN_CELL_RESOLUTION = 64;
+  
+  
+  // Cross-blended Hypsometric Tints
+  // http://www.shadedrelief.com/hypso/hypso.html
+  class HyposemetricTints {
+    constructor(params) {
+      const _colourLerp = (t, p0, p1) => {
+        const c = p0.clone();
+  
+        return c.lerpHSL(p1, t);
+      };
+      this._colourSpline = [
+        new spline.LinearSpline(_colourLerp),
+        new spline.LinearSpline(_colourLerp)
+      ];
+      // Arid
+      this._colourSpline[0].AddPoint(0.0, new THREE.Color(0xb7a67d));
+      this._colourSpline[0].AddPoint(0.5, new THREE.Color(0xf1e1bc));
+      this._colourSpline[0].AddPoint(1.0, _SNOW);
+  
+      // Humid
+      this._colourSpline[1].AddPoint(0.0, _FOREST_BOREAL);
+      this._colourSpline[1].AddPoint(0.5, new THREE.Color(0xcee59c));
+      this._colourSpline[1].AddPoint(1.0, _SNOW);
+  
+      this._params = params;
+    }
+  
+    Get(x, y, z) {
+      const m = this._params.biomeGenerator.Get(x, z);
+      const h = y / 100.0;
+  
+      if (h < 0.05) {
+        return _OCEAN;
+      }
+  
+      const c1 = this._colourSpline[0].Get(h);
+      const c2 = this._colourSpline[1].Get(h);
+  
+      return c1.lerpHSL(c2, m);
+    }
+  }
+  
+  
+  class FixedColourGenerator {
+    constructor(params) {
+      this._params = params;
+    }
+  
+    Get() {
+      return this._params.colour;
+    }
+  }
+  
+
+  class TerrainChunk {
+    constructor(params) {
+      this._params = params;
+      this._Init(params);
+    }
+    
+    Destroy() {
+      this._params.group.remove(this._plane);
+    }
+
+    Hide() {
+      this._plane.visible = false;
+    }
+
+    Show() {
+      this._plane.visible = true;
+    }
+
+    _Init(params) {
+      const size = new THREE.Vector3(params.width, 0, params.width);
+
+      this._plane = new THREE.Mesh(
+          new THREE.PlaneGeometry(size.x, size.z, params.resolution, params.resolution),
+          params.material);
+      this._plane.castShadow = false;
+      this._plane.receiveShadow = true;
+      this._plane.rotation.x = -Math.PI / 2;
+      this._params.group.add(this._plane);
+    }
+
+    _GenerateHeight(v) {
+      const offset = this._params.offset;
+      const heightPairs = [];
+      let normalization = 0;
+      let z = 0;
+      for (let gen of this._params.heightGenerators) {
+        heightPairs.push(gen.Get(v.x + offset.x, -v.y + offset.y));
+        normalization += heightPairs[heightPairs.length-1][1];
+      }
+
+      if (normalization > 0) {
+        for (let h of heightPairs) {
+          z += h[0] * h[1] / normalization;
+        }
+      }
+
+      return z;
+    }
+
+    *_Rebuild() {
+      const NUM_STEPS = 2000;
+      const colours = [];
+      const offset = this._params.offset;
+      let count = 0;
+
+      for (let v of this._plane.geometry.vertices) {
+        v.z = this._GenerateHeight(v);
+        colours.push(this._params.colourGenerator.Get(v.x + offset.x, v.z, -v.y + offset.y));
+
+        count++;
+        if (count > NUM_STEPS) {
+          count = 0;
+          yield;
+        }
+      }
+
+      for (let f of this._plane.geometry.faces) {
+        const vs = [f.a, f.b, f.c];
+
+        const vertexColours = [];
+        for (let v of vs) {
+          vertexColours.push(colours[v]);
+        }
+        f.vertexColors = vertexColours;
+
+        count++;
+        if (count > NUM_STEPS) {
+          count = 0;
+          yield;
+        }
+      }
+
+      yield;
+      this._plane.geometry.elementsNeedUpdate = true;
+      this._plane.geometry.verticesNeedUpdate = true;
+      this._plane.geometry.computeVertexNormals();
+      this._plane.position.set(offset.x, 0, offset.y);
+    }
+  }
+
+  class TerrainChunkRebuilder {
+    constructor(params) {
+      this._pool = {};
+      this._params = params;
+      this._Reset();
+    }
+
+    AllocateChunk(params) {
+      const w = params.width;
+
+      if (!(w in this._pool)) {
+        this._pool[w] = [];
+      }
+
+      let c = null;
+      if (this._pool[w].length > 0) {
+        c = this._pool[w].pop();
+        c._params = params;
+      } else {
+        c = new TerrainChunk(params);
+      }
+
+      c.Hide();
+
+      this._queued.push(c);
+
+      return c;    
+    }
+
+    _RecycleChunks(chunks) {
+      for (let c of chunks) {
+        if (!(c.chunk._params.width in this._pool)) {
+          this._pool[c.chunk._params.width] = [];
+        }
+
+        c.chunk.Hide();
+        this._pool[c.chunk._params.width].push(c.chunk);
+      }
+    }
+
+    _Reset() {
+      this._active = null;
+      this._queued = [];
+      this._old = [];
+      this._new = [];
+    }
+
+    get Busy() {
+      return this._active;
+    }
+
+    Update2() {
+      for (let b of this._queued) {
+        b._Rebuild().next();
+        this._new.push(b);
+      }
+      this._queued = [];
+
+      if (this._active) {
+        return;
+      }
+
+      if (!this._queued.length) {
+        this._RecycleChunks(this._old);
+        for (let b of this._new) {
+          b.Show();
+        }
+        this._Reset();
+      }
+    }
+
+    Update() {
+      if (this._active) {
+        const r = this._active.next();
+        if (r.done) {
+          this._active = null;
+        }
+      } else {
+        const b = this._queued.pop();
+        if (b) {
+          this._active = b._Rebuild();
+          this._new.push(b);
+        }
+      }
+
+      if (this._active) {
+        return;
+      }
+
+      if (!this._queued.length) {
+        this._RecycleChunks(this._old);
+        for (let b of this._new) {
+          b.Show();
+        }
+        this._Reset();
+      }
+    }
+  }
+
+  class TerrainChunkManager {
+    constructor(params) {
+      this._Init(params);
+    }
+
+    _Init(params) {
+      this._params = params;
+
+      this._material = new THREE.MeshStandardMaterial({
+        wireframe: false,
+        wireframeLinewidth: 1,
+        color: 0xFFFFFF,
+        side: THREE.FrontSide,
+        vertexColors: THREE.VertexColors,
+      });
+      this._builder = new TerrainChunkRebuilder();
+
+      this._InitNoise(params);
+      this._InitBiomes(params);
+      this._InitTerrain(params);
+    }
+
+    _InitNoise(params) {
+      params.guiParams.noise = {
+        octaves: 6,
+        persistence: 0.707,
+        lacunarity: 1.8,
+        exponentiation: 4.5,
+        height: 300.0,
+        scale: 1100.0,
+        noiseType: 'simplex',
+        seed: 1
+      };
+
+      const onNoiseChanged = () => {
+        for (let k in this._chunks) {
+          this._chunks[k].chunk.Rebuild();
+        }
+      };
+
+      const noiseRollup = params.gui.addFolder('Terrain.Noise');
+      noiseRollup.add(params.guiParams.noise, "noiseType", ['simplex', 'perlin', 'rand']).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "scale", 32.0, 4096.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "octaves", 1, 20, 1).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "persistence", 0.25, 1.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "lacunarity", 0.01, 4.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "exponentiation", 0.1, 10.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.noise, "height", 0, 512).onChange(
+          onNoiseChanged);
+
+      this._noise = new noise.Noise(params.guiParams.noise);
+
+      params.guiParams.heightmap = {
+        height: 16,
+      };
+
+      const heightmapRollup = params.gui.addFolder('Terrain.Heightmap');
+      heightmapRollup.add(params.guiParams.heightmap, "height", 0, 128).onChange(
+          onNoiseChanged);
+    }
+
+    _InitBiomes(params) {
+      params.guiParams.biomes = {
+        octaves: 2,
+        persistence: 0.5,
+        lacunarity: 2.0,
+        exponentiation: 3.9,
+        scale: 2048.0,
+        noiseType: 'simplex',
+        seed: 2,
+        exponentiation: 1,
+        height: 1
+      };
+
+      const onNoiseChanged = () => {
+        for (let k in this._chunks) {
+          this._chunks[k].chunk.Rebuild();
+        }
+      };
+
+      const noiseRollup = params.gui.addFolder('Terrain.Biomes');
+      noiseRollup.add(params.guiParams.biomes, "scale", 64.0, 4096.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.biomes, "octaves", 1, 20, 1).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.biomes, "persistence", 0.01, 1.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.biomes, "lacunarity", 0.01, 4.0).onChange(
+          onNoiseChanged);
+      noiseRollup.add(params.guiParams.biomes, "exponentiation", 0.1, 10.0).onChange(
+          onNoiseChanged);
+
+      this._biomes = new noise.Noise(params.guiParams.biomes);
+    }
+
+    _InitTerrain(params) {
+      params.guiParams.terrain= {
+        wireframe: false,
+      };
+
+      this._group = new THREE.Group()
+      params.scene.add(this._group);
+
+      const terrainRollup = params.gui.addFolder('Terrain');
+      terrainRollup.add(params.guiParams.terrain, "wireframe").onChange(() => {
+        for (let k in this._chunks) {
+          this._chunks[k].chunk._plane.material.wireframe = params.guiParams.terrain.wireframe;
+        }
+      });
+
+      this._chunks = {};
+      this._params = params;
+    }
+
+    _CellIndex(p) {
+      const xp = p.x + _MIN_CELL_SIZE * 0.5;
+      const yp = p.z + _MIN_CELL_SIZE * 0.5;
+      const x = Math.floor(xp / _MIN_CELL_SIZE);
+      const z = Math.floor(yp / _MIN_CELL_SIZE);
+      return [x, z];
+    }
+
+    _CreateTerrainChunk(offset, width) {
+      const params = {
+        group: this._group,
+        material: this._material,
+        width: width,
+        offset: new THREE.Vector3(offset.x, offset.y, 0),
+        resolution: _MIN_CELL_RESOLUTION,
+        biomeGenerator: this._biomes,
+        colourGenerator: new HyposemetricTints({biomeGenerator: this._biomes}),
+        heightGenerators: [new HeightGenerator(this._noise, offset, 100000, 100000 + 1)],
+      };
+
+      return this._builder.AllocateChunk(params);
+    }
+
+    Update(_) {
+      this._builder.Update();
+      if (!this._builder.Busy) {
+        this._UpdateVisibleChunks_Quadtree();
+      }
+    }
+
+    _UpdateVisibleChunks_Quadtree() {
+      function _Key(c) {
+        return c.position[0] + '/' + c.position[1] + ' [' + c.dimensions[0] + ']';
+      }
+
+      const q = new quadtree.QuadTree({
+        min: new THREE.Vector2(-32000, -32000),
+        max: new THREE.Vector2(32000, 32000),
+      });
+      q.Insert(this._params.camera.position);
+
+      const children = q.GetChildren();
+
+      let newTerrainChunks = {};
+      const center = new THREE.Vector2();
+      const dimensions = new THREE.Vector2();
+      for (let c of children) {
+        c.bounds.getCenter(center);
+        c.bounds.getSize(dimensions);
+
+        const child = {
+          position: [center.x, center.y],
+          bounds: c.bounds,
+          dimensions: [dimensions.x, dimensions.y],
+        };
+
+        const k = _Key(child);
+        newTerrainChunks[k] = child;
+      }
+
+      const intersection = utils.DictIntersection(this._chunks, newTerrainChunks);
+      const difference = utils.DictDifference(newTerrainChunks, this._chunks);
+      const recycle = Object.values(utils.DictDifference(this._chunks, newTerrainChunks));
+
+      this._builder._old.push(...recycle);
+
+      newTerrainChunks = intersection;
+
+      for (let k in difference) {
+        const [xp, zp] = difference[k].position;
+
+        const offset = new THREE.Vector2(xp, zp);
+        newTerrainChunks[k] = {
+          position: [xp, zp],
+          chunk: this._CreateTerrainChunk(offset, difference[k].dimensions[0]),
+        };
+      }
+
+      this._chunks = newTerrainChunks;
+    }
+
+    _UpdateVisibleChunks_FixedGrid() {
+      function _Key(xc, zc) {
+        return xc + '/' + zc;
+      }
+
+      const [xc, zc] = this._CellIndex(this._params.camera.position);
+
+      const keys = {};
+
+      for (let x = -_FIXED_GRID_SIZE; x <= _FIXED_GRID_SIZE; x++) {
+        for (let z = -_FIXED_GRID_SIZE; z <= _FIXED_GRID_SIZE; z++) {
+          const k = _Key(x + xc, z + zc);
+          keys[k] = {
+            position: [x + xc, z + zc]
+          };
+        }
+      }
+      
+      const difference = utils.DictDifference(keys, this._chunks);
+      const recycle = Object.values(utils.DictDifference(this._chunks, keys));
+
+      for (let k in difference) {
+        if (k in this._chunks) {
+          continue;
+        }
+
+        const [xp, zp] = difference[k].position;
+
+        const offset = new THREE.Vector2(xp * _MIN_CELL_SIZE, zp * _MIN_CELL_SIZE);
+        this._chunks[k] = {
+          position: [xc, zc],
+          chunk: this._CreateTerrainChunk(offset, _MIN_CELL_SIZE),
+        };
+      }
+    }
+
+    _UpdateVisibleChunks_Single() {
+      function _Key(xc, zc) {
+        return xc + '/' + zc;
+      }
+
+      // Check the camera's position.
+      const [xc, zc] = this._CellIndex(this._params.camera.position);
+      const newChunkKey = _Key(xc, zc);
+
+      // We're still in the bounds of the previous chunk of terrain.
+      if (newChunkKey in this._chunks) {
+        return;
+      }
+
+      // Create a new chunk of terrain.
+      const offset = new THREE.Vector2(xc * _MIN_CELL_SIZE, zc * _MIN_CELL_SIZE);
+      this._chunks[newChunkKey] = {
+        position: [xc, zc],
+        chunk: this._CreateTerrainChunk(offset, _MIN_CELL_SIZE),
+      };
+    }
+  }
+
+  return {
+    TerrainChunkManager: TerrainChunkManager
+  }
+})();

+ 21 - 0
src/utils.js

@@ -0,0 +1,21 @@
+export const utils = (function() {
+  return {
+    DictIntersection: function(dictA, dictB) {
+      const intersection = {};
+      for (let k in dictB) {
+        if (k in dictA) {
+          intersection[k] = dictA[k];
+        }
+      }
+      return intersection
+    },
+
+    DictDifference: function(dictA, dictB) {
+      const diff = {...dictA};
+      for (let k in dictB) {
+        delete diff[k];
+      }
+      return diff;
+    }
+  };
+})();