Основы three.js

В этой статье рассмотрим основы three.js — одного из самых быстрых 3D JavaScript движков.

Мы создадим простую сцену и разберём основные ключевые элементы движка.

Что будем делать?

В итоге у нас получится городок из разноцветных прямоугольников с тенями:

Управление: можно покрутить мышкой, колесом поприближать.

Версия в отдельно вкладке: ссылка.

Подробности и код

Я сделал эту демку взяв за основу проект шаблона Phaser2 на TypeScript под VSCode.

Всё что мне нужно было сделать — это приспособить threejs для TS и написать код.

TS расширения (файлы d.ts) для threejs и stats я взял из npm:

Прописываете их npm install в консоли и затем копируете файлы (оттуда куда они установились) в папку d.ts проекта.

Далее нужно изменить файл tsconfig.json, чтобы не было ошибок в чтении three js d.ts модулей:

Код файла tsconfig.json
{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "es2016",
      "es2015"
    ],
    "noImplicitAny": false,
    "module": "amd",
    "removeComments": true,
    "outFile": "build/game.js",
    "noEmitOnError": true,
    "sourceMap": true,
    "watch": true
  },
  "include": [
    "src/**/*"
  ]
}

Всё, основа готова, можно писать непосредственно код приложения.

Приведу код основных файлов.

Код файла index.html
<!DOCTYPE html>

<html lang="en">

<head>
  <meta charset="utf-8" />
  <meta http-equiv="pragma" content="no-cache" />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1.0, maximum-scale=1.0,  minimum-scale=1.0, target-densitydpi=device-dpi"
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

  <title>THREE JS Simple Scene for Cyber-Code</title>

  <style>
    html {
      overflow: hidden;
    }

    body {
      background-color: #6077bb;
      font-family: 'Segoe UI', sans-serif;
      border: none;
      padding: 0;
      margin: 0;
    }

    #game {
      margin: 0;
      padding: 0;
    }
  </style>

  <script src="js/three.min.js"></script>
  <script src="js/detector.min.js"></script>
  <script src="js/controls/OrbitControls.js"></script>
  <script src="js/stats.min.js"></script>
  <script src="game.js"></script>
</head>

<body>
  <div id="game"></div>
</body>

</html>
Код файла Config.ts
namespace Config {
  export const DOM_PARENT_ID = 'game';

  export const SHADOW_MAP_WIDTH = 2048;
  export const SHADOW_MAP_HEIGHT = 2048;

  export const CAMERA_NEAR = 1;
  export const CAMERA_FAR = 6000;

}
Код файла Params.ts
namespace Params {
  export var isTapToStartBtn = false;
  
  export var wnd_w = 0;
  export var wnd_h = 0;

}
Код файла GameEngine.ts
module MyGame {

  export class GameEngine {

    private renderer: THREE.WebGLRenderer;
    private camera: THREE.PerspectiveCamera;
    private scene: THREE.Scene;
    private lignt_dir: THREE.DirectionalLight;
    private lignt_amb: THREE.AmbientLight;
    private controls: THREE.OrbitControls;

    // utils
    private stats: Stats;
    private clock = new THREE.Clock(true);

    constructor() {
      Params.wnd_w = window.innerWidth;
      Params.wnd_h = window.innerHeight;

      var parent = document.getElementById(Config.DOM_PARENT_ID);

      // RENDERER

      this.renderer = new THREE.WebGLRenderer({
        antialias: true
      });
      this.renderer.setPixelRatio(window.devicePixelRatio);
      this.renderer.setSize(Params.wnd_w, Params.wnd_h);
      this.renderer.shadowMap.enabled = true;
      //this.renderer.shadowMap.type = THREE.PCFShadowMap;
      this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      parent.appendChild(this.renderer.domElement);

      // SCENE

      this.scene = new THREE.Scene();
      this.scene.background = new THREE.Color(0x6077bb);

      // LIGHTS

      this.lignt_amb = new THREE.AmbientLight(0x323232);
      this.scene.add(this.lignt_amb);

      this.lignt_dir = new THREE.DirectionalLight(0xFFFFFF, 2);
      this.lignt_dir.position.set(-500, 600, 750);
      this.lignt_dir.target.position.set(0, 0, 0);
      // light shadow setting
      this.lignt_dir.shadow.camera.near = 1;
      this.lignt_dir.shadow.camera.far = 8000;
      this.lignt_dir.shadow.camera.left = -3000;
      this.lignt_dir.shadow.camera.bottom = -3000;
      this.lignt_dir.shadow.camera.right = 3000;
      this.lignt_dir.shadow.camera.top = 3000;
      this.lignt_dir.shadow.bias = 0.0001;
      this.lignt_dir.shadow.mapSize.width = Config.SHADOW_MAP_WIDTH;
      this.lignt_dir.shadow.mapSize.height = Config.SHADOW_MAP_HEIGHT;
      this.lignt_dir.castShadow = true;
      this.scene.add(this.lignt_dir);

      // CAMERA

      this.camera = new THREE.PerspectiveCamera(45, Params.wnd_w / Params.wnd_h, Config.CAMERA_NEAR, Config.CAMERA_FAR);
      this.camera.position.set(0, 500, 600);

      // CAMERA CONTROL

      this.controls = new THREE.OrbitControls(this.camera);
      this.controls.enablePan = false;
      //this.controls.maxPolarAngle = 10;
      //this.controls.minPolarAngle = 0;

      this.stats = new Stats();
      parent.appendChild(this.stats.dom);

      this.createScene();
    }

    onWindowResize() {
      Params.wnd_w = window.innerWidth;
      Params.wnd_h = window.innerHeight;
      this.renderer.setSize(Params.wnd_w, Params.wnd_h);
      this.camera.aspect = Params.wnd_w / Params.wnd_h;
      this.camera.updateProjectionMatrix();
    }

    private createScene() {

      // HELPERS

      var ah = new THREE.AxesHelper(500);
      //this.scene.add(ah);

      var dh = new THREE.DirectionalLightHelper(this.lignt_dir, 100);
      //this.scene.add(dh);

      // GROUND

      var geometry = new THREE.PlaneBufferGeometry(100, 100);
      var planeMaterial = new THREE.MeshLambertMaterial({ color: 0x505050 });
      var ground = new THREE.Mesh(geometry, planeMaterial);
      ground.position.set(0, 0, 0);
      ground.rotation.x = -Math.PI / 2;
      ground.scale.set(10, 10, 10);
      ground.castShadow = false;
      ground.receiveShadow = true;
      this.scene.add(ground);

      // BUILDINGS

      var b_size = 20;
      var road_size = 16;
      var city_size = 26;
      var b_colors = [0x434c83, 0x546d50, 0xa44f3b, 0x7f3d4b];
      for (let i = 0; i < city_size; i++) {
        for (let j = 0; j < city_size; j++) {
          if (MyMath.randomInRange(0, 10) < 3)
            continue;
          var b_h = MyMath.randomIntInRange(b_size, b_size * 6);
          var b_geom = new THREE.BoxGeometry(b_size, b_h, b_size);
          var cid = MyMath.randomIntInRange(0, b_colors.length - 1);
          var b_mat = new THREE.MeshLambertMaterial({ color: b_colors[cid] });
          var building = new THREE.Mesh(b_geom, b_mat);
          var pos_x = -city_size / 2 * (b_size + road_size) + i * (b_size + road_size);
          var pos_z = -city_size / 2 * (b_size + road_size) + j * (b_size + road_size);
          building.position.set(pos_x, b_h / 2, pos_z);
          building.castShadow = true;
          building.receiveShadow = true;
          this.scene.add(building);
        }
      }

    }

    animate() {
      this.render();
      this.stats.update();
    }

    private render() {
      var dt = this.clock.getDelta();
      this.controls.update();
      this.renderer.render(this.scene, this.camera);
    }

  }
}

var gameEng: MyGame.GameEngine;

window.onload = () => {
  if (!Detector.webgl) {
    document.body.appendChild(Detector.getWebGLErrorMessage());
  }
  else {
    gameEng = new MyGame.GameEngine();
    window.addEventListener('resize', () => {
      gameEng.onWindowResize();
    }, false);
    window.addEventListener('keydown', () => {

    }, false);
    animate();
  }
};

function animate() {
  requestAnimationFrame(this.animate);
  gameEng.animate();
}

В коде я старался соблюдать логический приоритет объектов по мере их создания, разберём их поподробнее.

Рендер

Рендер — основной элемент отображения. В нём настраиваются такие вещи как: antialias (сглаживание), тени и их тип, PixelRatio и другие основные элементы. Его нужно инициализировать и расположить на DOM элементе страницы, в моём случае это div с id=»game». Главная функция рендера — это render(scene, camera), в которую передаются следующие основные объекты: сцена и камера.

Эту функцию надо вызывать тогда, когда мы хотим отрисовать текущее состояние сцены, как правило это делают в событии AnimationFrame().

Современные браузеры, кроме IE9-, поддерживают стандарт Animation timing. Он позволяет синхронизировать наши анимации со встроенными механизмами обновления страницы. То есть, сгруппированы будут не только наши, но и CSS-анимации и другие браузерные перерисовки.

При этом графический ускоритель будет использован максимально эффективно, и исключена повторная обработка одних и тех же участков страницы. А значит – меньше будет загрузка CPU, да и сама анимация станет более плавной. Для этого используется функция requestAnimationFrame.

Сцена

Сцена — это root контейнер для всего, что может быть отображено нашим рендером (отрендерино). Если мы хотим, чтобы какой-то объект отобразился, то нужно добавить его на сцену.

Свет

Без источника света наша сцена будет просто чёрной, не освещаемой. Поэтому источник света необходим.

В Three.js существует 6 источников света, некоторые из них являются базовыми и имеются в любом 3D движке:

рассмотрим их поподробнее.

AmbientLight

Этот источник света освещает все объекты на сцене равномерно одинаково, его нельзя использовать как источник который отбрасывает тени, т.к. у него нет направления. Его можно использовать для придания оттенка теневой стороне объектов. Что я и сделал в данном примере, я создал этот источник с цветом 0x323232, чтобы теневая сторона объекта была не полностью чёрная, а мягко тёмно-серая.

DirrectionLight

Свет, который излучается в определенном направлении. Этот свет будет вести себя так, как если бы он был бесконечно далеко и его лучи параллельны. Т.е. этот источник имитирует наше Солнце. Ему можно задать позицию и направление свечения.

В данном примере я как раз применяю такой источник света для основного освещения.

PointLight

Свет, который излучается из одной точки во всех направлениях. Его можно использовать, например, для имитации света, излучаемого лампочкой.

SpotLight

Этот свет излучается из одной точки в одном направлении, вдоль конуса, который увеличивается в размере по мере удаления от источника. Другими словами — лучший вариант для имитации света прожектора или фонарика.

RectAreaLight

Источник света излучающийся с поверхности прямоугольника. По сути подобен SpotLight-у, только светит не из точки, а с прямоугольной плоскости.

Можно имитировать свет от окна или от неоновой полоски.

Документация, демка.

HemisphereLight

Этот источник света имитирует свечение небесной сферы, он подобен AmbientLight, только имеет 2 цвета освещения, один цвет освещает предметы сверху, а второй снизу.

Документация, демка.

Камера

Камера позволяет нам видеть нашу сцену. Существует 2 основных типа камеры: перспективной проекции и ортогональной проекции.

В данном примере я применил перспективную камеру.

Ортогональная камера создаёт впечатление 2D, вот пример:

Для управления камеры мышкой и тачем я использовал готовое решение — OrbitControls

Игровые объекты на сцене

Когда все основные элементы инициализированы, можно наполнять нашу сцену объектами. Данное действо происходит у меня в функции createScene().

Сначала я создаю 2 хелпера для системы координат и для источника света, можете раскомментировать их и посмотреть как они выглядят.

Затем я создаю плоскость, которая по идеи является землёй, а затем создаю рандомные прямоугольники-здания.

Можно обратить внимание, что все объекты — это меши.

Меш или сетка — этими терминами называют совокупность вершин, рёбер и полигонов, которые составляют один 3D объект. Слово меш походит от английского mesh — ячейка сети. А слово сетка — от английского wireframe, что переводится как каркас/проволочный каркас.

Так вот, меши создаются из 2-х составляющих, которые необходимы для определения их отображения: геометрия и текстура.

Для земли я использовал PlaneBufferGeometry (геометрию плоскости) и MeshLambertMaterial (материал без отбликов). А для домиков я использовал BoxGeometry (геометрия параллелепипеда) и такой же тип материала.

Далее я расставляю домики и придаю им рандомную высоту и цвет из заранее определённого массива. Так же я указываю мешу домика, чтобы он отбрасывал (castShadow) и принимал тень (receiveShadow), а плоскости земли только чтобы принимала.

В следующих статьях я обязательно разберу подробно все типы геометрии и материалов.

А видос?

Я записал видео, как с нуля сделал эту демку, взяв за основу свой шаблон TypeScript Phaser2:


Видео ускорено в 3 раза. Иногда я подглядывал в свой прошлогодний проект на three.js, чтобы вспомнить особенности добавления модулей и оформления теней.

Где скачать?

Как обычно весь код проекта залит в репозиторий.