Знакомимся с WebGL и BabylonJS (часть 1)

author

WebGL привлекает к себе все больше внимания. Веб-разработчики пробуют себя в мире 3D графики, разработчики игр хотят освоить новую платформу. Основные браузеры уже поддерживают WebGL на хорошем уровне и в вебе начинают появляться серьезные 3D проекты. В этом уроке мы рассмотрим основные концепции 3D графики на примере создания космической сцены. Узнаем что такое шейдеры, как моделировать объекты, накладывать текстуры, создавать окружение и ставить свет. В конце анимируем статичную сцену и добавим эффекты пост-обработки.

Result

Рис. 1. Результат, который мы получим в конце урока.

Работать будем с фреймворком BabylonJS, который облегчит нашу задачу и поможет быстрее добиться результата.


WebGL

Разберем алгоритм работы с WebGL.

Image

Рис. 2. Основные части WebGl приложения.

Чтобы создать любую WebGL сцену, необходимо проделать следующие операции:

  • Создаем на странице элемент Canvas
  • Получаем контекст WebGL из Canvas для отрисовки графики
  • Инициализируем и компилируем шейдеры
  • Создаем необходимые буферы / текстуры / переменные / матрицы
  • Делаем необходимые расчеты
  • Рисуем результат

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


Шейдеры (Shaders)

Шейдеры это программы, написанные на C-подобном языке OpenGL ES Shading Language или GLSL. Шейдеры выполняют роли преобразователя «сырых» координат и матриц в финальные координаты и окрашивают их в нужный цвет.

image

Рис. 3. Слева набор вершин и камера до применения шейдеров. Справа — после.

Каждый материал описывается своими парами шейдеров: Вершинный (Vertex Shader) и Фрагментный (Fragment Shader).

Вершинный шейдер


attribute vec3 aPosition;
void main(void) {
  gl_Position = vec4(aPosition, 1.0);
}

Перед нами пример вершинного шейдера. Он состоит из объявленных переменных до функции и самой функции main. Взаимодействие между JS кодом и шейдерными программами происходит по следующему алгоритму.

  • Объявляется переменная в JS коде;
  • Создается ссылка на требуемый атрибут в шейдерной программе;
  • Объявленное через JS код значение передается в шейдерную программу через ссылку;

Первые два пункта, можно поменять местами, без ущерба для работы программы.

Переменные задаются посредством JS перед запуском отрисовки, а сама main() функция устанавливает преобразованные координаты в глобальную переменную gl_Position типа vec4 (массив 4 значений).

vec4(aPosition, 1.0); — такую запись следует трактовать так: преобразовать значение aPosition типа vec3 (массив 3 значений) и значения 1.0 к vec4(массиву 4 значений). Так мы получаем искомое значение gl_Position, где первые 3 значения массива это координаты xyz, а о четвертом значении мы узнаем немного позже, когда будем писать свой шейдер.

Чаще всего в шейдер передают матрицы преобразования (Model, View, Projection) и потом умножают матрицы на координаты точки, например:

vec4 outPosition = worldViewProjection * vec4(aPosition, 1.0);
gl_Position = outPosition;

В примере worldViewProjection это матрица модели-вида-проекции, необходимая для корректного отображения точки в пространстве. Так мы получаем конечные координаты вершины. В этом и заключается задача вершинного шейдера.

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

  • Матрица модели — это матрица, которая преобразует локальные координаты (относительно центра модели) в глобальные координаты. Образуется путем перемножения матрицы переноса, поворота и масштабирования. Которые в свою очередь нужны для смещения точки по осям, поворота вокруг осей и масштабирования.
  • Матрица вида — это матрица, которая переносит точку из глобальной системы координат в систему координат камеры. Мы переносим не камеру, а мир относительно камеры.
  • Матрица проекции — это матрица, которая дает нам необходимые плоскости отсечения (мин. и макс. координаты отображения), а также создает перспективу для реальности отображения.

Если их перемножить мы получим матрицу модели-вида-проекции (тип mat4), умножение которой на позицию точки (тип vec4) дает нам конечные координаты с учетом камеры, проекции и положения на сцене, то есть те самые конечные координаты gl_Position.

Фрагментный шейдер

void main(void) {
  gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}

Если задача вершинного шейдера получить координату и вернуть ее преобразованное значение, то задача фрагментного шейдера — закрашивать в нужный цвет. В данном примере vec4(0.0, 1.0, 0.0, 1.0) эквивалент rgba(0,255,0,1) (зеленый цвет). Цвет можно также получить из текстуры посредством передачи UV координат (о них позже).

Фрагментный шейдер состоит из функции и некоторых принимаемых параметров. Он устанавливает глобальной переменной gl_FragColor цветовое значение, параметры вида: vec4(red, green, blue, alpha).

Оба шейдера выполняются для всех вершин и на выходе мы имеем закрашенный участок, соответствующий вершинам и их цветам. Вот еще несколько интересных моментов о GLSL:

  • GLSL типизированный язык и многие параметры требуют именно переменных типа float, поэтому vec2(0,1) часто будет ошибкой, нужно писать так vec2(0.0, 1.0);
  • GLSL работает с особыми типами, такими как mat4/3/2/1, vec4/3/2/1, упрощающими работу с массивами и матрицами;
  • GLSL имеет множество встроенных функций, например dot(vec3, vec3) — скалярное произведение векторов;
  • GLSL имеет несколько типов квалификаторов (для указания точности расчетов, а значит и влияния на быстродействие)
  • GLSL имеет несколько спец. типов переменных
    • Attribute — глобальный атрибут для передачи координат в вершинный шейдер
    • Uniform — переменные которые можно использовать в обоих шейдерах
    • Varying — переменные через которые вершинный шейдер может общаться с фрагментным

В последующих частях мы напишем пару шейдеров и освоим некоторые моменты работы с ними.

Во время нашей работы мы столкнемся с необходимостью производить вычисления с матрицами, подготавливать буферы вершин, нормалей, индексов и не только. Упросить жизнь нам поможет фреймворк под названием BabylonJS.


BabylonJS

BabylonJS — фреймворк для работы с 3D графикой. С его помощью можно строить примитивы, накладывать текстуры, импортировать шейдеры или модели, подключать физические движки, добавлять тени, анимировать объекты.

Одна из возможностей BabylonJS — экспорт моделей из 3D-редакторов, например Blender. Благодаря этому мы можем создать 3D модель не кодом, а в предназначанной для этого программе.

Попробовать BabylonJS можно в песочнице.


Делаем космос. Окружение и материалы

Начнем работу с создания страницы и подключения всех необходимых библиотек. Создаем папку web, в ней папку js и файл index.html. В нем мы опишем верстку страницы и код взаимодействия с BabylonJS. Что получится в итоге можно посмотреть здесь.

Скачиваем из репозитория библиотеку babylon.js. Для управления камерой нам пригодится библиотека hand.js. Обе библиотеки кладем в папку js. Получим такую структуру проекта:

-web/
--js/
---babylon.js
---hand.minified-1.2.js
--index.html

Далее пишем код страницы

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title></title>
    <script src="js/babylon.js"></script>
    <script src="js/hand.minified-1.2.js"></script>
    <style>
        html, body {
            width: 100%;
            height: 100%;
            padding: 0;
            margin: 0;
            overflow: hidden;
        }

        #canvas {
            width: 100%;
            height: 100%;
        }
    </style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>

</script>
</body>

Мы добавили элемент canvas, в котором BabylonJS отобразит нашу сцену. Нам не нужно задавать элементу canvas аттрибуты width и height, т.к. BabylonJS сам задаст их во время инициализации движка, исходя из стилевых размеров canvas.

Весь джаваскрипт код запишем внутри тега <script>.

Подключаем BabylonJS:

//здесь будут основные конфиги
var config = {

};

//проверяем, поддерживается ли работа фреймворка
if (BABYLON.Engine.isSupported()) {
    var canvas = document.getElementById("canvas"); //находим канвас, в котором будем рисовать сцену
    var engine = new BABYLON.Engine(canvas, true); //создаем движок
    var scene = new BABYLON.Scene(engine); //создаем сцену


    engine.runRenderLoop(function() { //инициируем перерисовку
        scene.render(); //перерисовываем сцену (60 раз в секунду)
    });
}

Если сейчас открыть страницу в браузере, мы увидим синию страницу (цвет по-умолчанию) с ошибкой в консоли Uncaught Error: No camera defined. Камера в 3D определяется матрицей вида и реализует либо ортогональную, либо перспективную проекцию (которая ближе к реальности).

(в мире 3D не камера перемещается по миру, а мир перемещается относительно камеры)

В BabylonJS есть несколько видов камер, например:

  • FreeCamera (камера, которую можно свободно перемещать по сцене)
  • ArcRotateCamera (камера, которая смотрит на точку и вращающается вокруг, находясь всегда на определенном радиусе)
  • FollowCamera (камера, которая находится всегда позади объекта)

Нам понадобится ArcRotateCamera, для перемещения вокруг нашей будущей планеты.

Мы задаем ей имя, повороты alpha и beta, радиус вращения и позицию. В движке BabylonJS многие параметры будут задаваться с помощью объекта Vector(4/3/2/1). Например позиция камеры как раз может задаваться как new BABYLON.Vector3(0.0, 0.0, 0.0);.

//создаем камеру, которая вращается вокруг заданной цели (это может быть меш или точка)
var camera = new BABYLON.ArcRotateCamera("Camera", -Math.PI / 2, 3*Math.PI / 7, 110, new BABYLON.Vector3(55, 5, 55), scene);

scene.activeCamera = camera; //задаем сцене активную камеру, т.е. ту через которую мы видим сцену

camera.attachControl(canvas, true); //добавляем возможность управления камерой

Пока что у нас пустая сцена.

image

Рис. 4. Базовая сцена.

Начнем наполнять сцену объектами с элемента skybox. Skybox — это огромный куб обтянутый соответствующей текстурой. Все объекты должны располагаться внутри него, а он должен имитировать окружение (в нашем случае — космос). Создадим куб:

//создаем скайбокс
var skybox = BABYLON.Mesh.CreateBox("universe", 10000.0, scene);

Мы создали наш первый меш (модель куба размером 10000), но он не видим. Камера находится внутри куба, а видимость изнутри поумолчанию отключена. Надо визуализировать куб.

В кубе 6 граней и на каждую нужна подходящая текстура.

image

Рис. 5. Текстуры сторон скайбокса.

Скачаем текстуры из папки universe в репозитории и добавим в папку web. Каждая текстура принадлежит определенной грани. Чтобы применить их, надо объявить материал для куба. Материал — это объект который, отвечает за оформление меша, которому он задан. Например, возьмем кирпич. Он шероховат, имеет оранжевый цвет, на солнце не имеет бликов и не излучает свет. Весь комплект этих характеристик можно назвать материалом.

Тоже самое с материалом в BabylonJS. Материалу можно задать текстуру, силу отражения света, цвет отражения, видимость, прозрачность и т.д..

А теперь, когда с материалом более-менее понятно объявим его и назначим нашему скайбоксу.

В BabylonJS есть несколько видов материалов, но самый общий — StandartMaterial, он нам прекрасно подойдет.

BABYLON.StandardMaterial("universe", scene);

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

var skyboxMaterial = new BABYLON.StandardMaterial("universe", scene); //создаем материал
skyboxMaterial.backFaceCulling = false; //Включаем видимость меша изнутри
skyboxMaterial.reflectionTexture = new BABYLON.CubeTexture("textures/universe/universe", scene); //задаем текстуру скайбокса как текстуру отражения
skyboxMaterial.reflectionTexture.coordinatesMode = BABYLON.Texture.SKYBOX_MODE; //настраиваем скайбокс текстуру так, чтобы грани были повернуты правильно друг к другу
skyboxMaterial.disableLighting = true; //отключаем влияние света
skybox.material = skyboxMaterial; //задаем матерал мешу

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

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

image

Рис. 6. Сцена с готовым скайбоксом.

Зажмите ЛКМ или понажимайте на стрелочки, чтобы камера вращалась

У стандартного материала есть еще много параметров, для кастомизации отображения:

skyboxMaterial.diffuseColor = new BABYLON.Color3(0.0, 0.5, 0.4); //цвет который получится при освещении

/*или*/

skyboxMaterial.diffuseTexture = new BABYLON.Texture("texture.png", scene); //текстура, которая видна при освещении

Мы можем задать как цвет, так и текстуру.

skyboxMaterial.emissiveColor = new BABYLON.Color3(0, 0.2, 0.2); //собственный цвет, которым "светится" меш

/*или*/

skyboxMaterial.emissiveTexture = new BABYLON.Texture("texture.png", scene); //собственная текстура, которой "светится" меш
skyboxMaterial.specularColor = new BABYLON.Color3(0, 0.2, 0.2); //цвет блика

/*или*/

skyboxMaterial.specularTexture = new BABYLON.Texture("texture.png", scene); //текстура блика

Другие возможности можно изучить на сайте генератора материалов BabylonJS.


Создаем базовое окружение

Теперь приступим к наполнению сцены. Мы создадим 2 меша, символизирующие планеты (Луну и Землю) и установим освещение.

Для начала обновим конфиг

//здесь будут основные конфиги
var config = {
    PLANET_RADIUS: 50, //радиус земли
    PLANET_V: 300, // количество вершин
    MOON_RADIUS: 25, //радиус луны
};

Теперь создадим меши (сферы). Делать это будем с помощью метода BABYLON.Mesh.CreateSphere, похожий на BABYLON.Mesh.CreateBox, который мы использовали ранее.

 //Земля
 var planet = BABYLON.Mesh.CreateSphere("planet", config.PLANET_V, config.PLANET_RADIUS, scene, true);
 planet.position = new BABYLON.Vector3(-250.0, -10,0, -250.0); // задаем позицию на сцене

 var moon = BABYLON.Mesh.CreateSphere("moon", 25, config.MOON_RADIUS, scene); //Луна
 moon.parent = planet; //задаем родителя &mdash; Землю
 moon.position = new BABYLON.Vector3(-102.0, 0,0, 0.0); //задаем позицию луны

 camera.target = planet; //Задаем точку вращения камеры

Мы установили Землю как родителя Луны, поэтому Луна отображается в системе координат Земли. Также мы установили target камере, чтобы она вращалась относительно Земли.

Вот что получилось на данном этапе:

image

Рис. 7. Два сферических меша.

Меши черные, потому что на сцене нет источника света. При этом, отсутствие источника света ни коем образом не затрагивает скайбокс, ведь свет на него никак не влияет.

Добавим источник света.

//создаем точечный источник света в точке 0,0,0
var lightSourceMesh = new BABYLON.PointLight("Omni", new BABYLON.Vector3(0.0, 0,0, 0.0), scene);
/*цвет света*/
lightSourceMesh.diffuse = new BABYLON.Color3(0.5, 0.5, 0.5);

В BabylonJS есть несколько видов источников света, но нам нужен точечный, имитирующий Солнце. При добавлении источника света все меши со стандартным материалом и которые способны воспринимать свет, будут автоматически отрендерены с расчетом влияния света.

 //создаем точечный источник света в точке 0,0,0
 var lightSourceMesh = new BABYLON.PointLight("Omni", new BABYLON.Vector3(0.0, 0,0, 0.0), scene);
 /*цвет света*/
 lightSourceMesh.diffuse = new BABYLON.Color3(0.5, 0.5, 0.5);

Мы создали источник света в точке 0,0,0 цветом 0.5,0.5,0.5 и получили результат:

image

Рис. 8. Освещенные меши.

Код этой части проекта здесь.


Накладываем текстуры

Перейдем к материалам с текстурами. Начнем с Луны, потому как она будет у нас проще чем Земля. Создадим StandartMaterial и зададим основную текстуру


var moonMat = new BABYLON.StandardMaterial("moonMat", scene); //Материал Луны
moonMat.diffuseTexture = new BABYLON.Texture("textures/moon.jpg", scene); //задаем текстуру планеты

moon.material = moonMat; //задаем материал

Текстуры можно взять здесь

image

Рис. 9. Диффузная текстура.

Результат:

image

Продолжим улучшать вид Луны. Добавим bump текстуру. Это текстура которая добавляет диффузной текстуре объем, когда она освещена. Это никак не скажется на геометрии, но меш будет выглядеть не как гладкий шар, а как будто у него есть неровности. Эта текстура называется картой нормалей.

image

Рис. 10. Карта нормалей.

moonMat.bumpTexture = new BABYLON.Texture("textures/moon_bump.jpg", scene);

image

Рис. 11. Меш с применением карты нормалей.

Теперь рассмотрим карту освещения (specular map). Это текстура, которая определяет степень влияния света на определенные фрагменты меша.

image

Рис. 12. Карта освещения.

Применим ее, чтобы избавится от яркого блика в центре Луны.

moonMat.specularTexture = new BABYLON.Texture("textures/moon_spec.jpg", scene);

image

Рис. 13. Результат применения карты освещения.

Для текстурирования Земли мы:

  • Применим карту высот
  • Создадим шейдерный материал (ShaderMaterial)
  • Напишем кастомные шейдеры

Карта высот — это черно-белая тектура, где светлые фрагменты определяют высоту на которую будут смещаться вершины. В отличие от карты нормалей, это затронет геометрию меша.

image

Рис. 14. Карта высот.

Карта намеренно перевернута по горизонтали, чтобы вершины смещались соответственно текстуре Земли, которую мы применим позже. Применим карту высот, а также повернем меш на 180 по оси z:

planet.rotation.z = Math.PI;
planet.applyDisplacementMap("/textures/earth-height.png", 0, 1); //применяем карту высот - смещение => от 0 для черных фрагментов до 1 для белых

Параметр конфига PLANET_V можно и увеличить, тем самым увеличив детализацию планеты, но это повлияет на производительность сцены.

image

Рис. 15. Результат применения карты высот.

(Это не относится к материалу, а влияет на геометрию, поэтому материал еще не нужен)

Пора переходить к текстурам. Стандартный материал нам уже не подойдет, надо переходить на шейдерный. Будем не просто накладывать текстуру, а отображать ее в зависимости от освещения.

Для реализации нам понадобятся:

  1. ShaderMaterial
  2. Дневная и ночная текстура Земли
  3. Шейдеры

Объявим материал

var planetMat = new BABYLON.ShaderMaterial("planetMat", scene, {
            vertexElement: "vertexPlanet",
            fragmentElement: "fragmentPlanet",
        },
        {
            attributes: ["position", "normal", "uv"],
            uniforms: ["world", "worldView", "worldViewProjection", "diffuseTexture", "nightTexture"],
        });

Мы задали параметры:

  • Имя
  • Сцена
  • Набор шейдеров (тип шейдера : id шейдера)
  • Передаваемые в шейдер атрибуты

Описание шейдера можно положить в файл, а можно написать прямо в разметке HTML. Чтобы описать шейдер в разметке, нужно создать элемент script с типом application/vertexShader или application/fragmentShader.

<script type="application/vertexShader" id="vertexPlanet">
    precision highp float;

    // Attributes
    attribute vec3 position;
    attribute vec3 normal;
    attribute vec2 uv;

    // Uniforms
    uniform mat4 world;
    uniform mat4 worldViewProjection;

    // Varying
    varying vec2 vUV;
    varying vec3 vPositionW;
    varying vec3 vNormalW;

    void main(void) {
        vec4 outPosition = worldViewProjection * vec4(position, 1.0);
        gl_Position = outPosition;

        vPositionW = vec3(world * vec4(position, 1.0));
        vNormalW = normalize(vec3(world * vec4(normal, 0.0)));

        vUV = uv;
    }
</script>

Разберем построчно этот вершинный шейдера.

//...
precision highp float;
//...

В GLSL квалификатор точности precision определяет количество байт выделяемых под переменную. В данном случае float. Это оказывает влияние не только на точность расчетов, но и на производительность. Мы задаем повышенную точность переменных типа float для более корректного отображения нашей планеты. В BabylonJS лучше всегда использовать квалификатор highp.

//...
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
//...

Это переменные типа attribute, которые принимает только вершинный шейдер. То, что они должны быть переданы в шейдер мы указали ранее в материале:

... attributes: [position, normal, uv], ...

position — переменная атрибут типа vec3 (т.е. массив из 3 элементов типа float). Это координаты текущей вершины.

normal — атрибут типа vec3, его смысл — вектор нормали, это вектор перпендикулярный грани (см. рисунок ниже). Он нужен нам для определения ориентации поверхности, и задается для каждой вершины.

Определение ориентации нужно, чтобы понять — какая сторона плоскости является внешней, а какая внутренней.

image

Рис. 16. Нормаль к 4 плоскости (она также является нормалью ко всем четырем вершинам).

Uv – атрибут типа vec2, представляет собой координату текстуры, которая соответствует вершине (см. рис ниже).

image

Рис. 17. UV координаты. Они отсчитываются слева-направо и снизу-вверх от 0.0 до 1.0.

UV координаты задаются для каждой вершины. Не каждая вершина соответствует всем возможным координатам. Например при задании двум вершинам uv координат ([0.1, 0,1] и [0.5, 0.5]) область между ними будет закрашена как и ожидается. Значения цвета каждого фрагмента между вершинами будут интерполироваться относительно конечных координат (наглядный пример интерполяции — градиент). Но это работа фрагментного шейдера, а не вершинного. В данном случае мы будем передавать UV координату (координату текселя) фрагментному шейдеру посредством varying переменной.

//...
// Uniforms
uniform mat4 world;
uniform mat4 worldViewProjection;
//...

Uniform переменные — это переменные, которые могут быть переданы в оба шейдера.

World — переменная типа mat4 (матрица 4 на 4, наполненная значениями типа float), при умножении вектора координат на которую мы получаем вершину в глобальной системе координат (относительно центра сцены, а не относительно центра модели)

worldViewProjection — матрица 4 на 4, матрица мира-вида-проекции. После умножения на нее вершина перемещается в нужное положение относительно камеры, т.е свое конечное положение.

//...
 // Varying
varying vec2 vUV;
varying vec3 vPositionW;
varying vec3 vNormalW;
//...

Varying переменные передают значения из одного шейдера в другой при условии, что они названы одинаково. Передавать мы будем координату текселя, координату в мировой системе координат и ее нормаль единичной длины.

vUV — это uv координаты.

vPositionW — это позиция вершины в глобальной системе координат

vNormalW — это нормализованная нормаль вершины

Теперь к расчетам:

//...
void main(void) {
    vec4 outPosition = worldViewProjection * vec4(position, 1.0);
    gl_Position = outPosition;

    vPositionW = vec3(world * vec4(position, 1.0));
    vNormalW = normalize(vec3(world * vec4(normal, 0.0)));

    vUV = uv;
}
//...

Сначала мы описываем, то что должен делать вершинный шейдер — определить конечную позицию каждой вершины. Для этого умножаем матрицу вида проекции (предоставляемую и уже рассчитанную BabylonJS) на позицию вершины.

worldViewProjection * vec4(position, 1.0)

Умножаем переменную mat4 на переменную vec4, которую формируем из атрибута position (x,y,z — 3 значения) и значения 1.0, которое станет 4-ым параметром. В данном случае vec4 — это позиция. Если же задать 0.0, то значением vec4 станет направлением.

Затем делаем необходимые для нашего кастомного отображения действия:

//...
vPositionW = vec3(world * vec4(position, 1.0));
vNormalW = normalize(vec3(world * vec4(normal, 0.0)));

vUV = uv;
//...

Передаем во фрагментный шейдер глобальную координату вершины, ее нормализованную нормаль и координату текселя (это varying переменные). Зачем? Выясним, когда будем разбирать и улучшать фрагментный шейдер. А пока мы уже разобрали объявление ShaderMaterial и одного из двух шейдеров. Нам надо чтобы в зависимости от освещенности использовалась одна или другая текстура, поэтому объявим их в материале

var diffuseTexture = new BABYLON.Texture("textures/earth-diffuse.jpg", scene);
var nightTexture = new BABYLON.Texture("textures/earth-night-o2.png", scene);

planetMat.setVector3("vLightPosition", lightSourceMesh.position); //задаем позицию источника света
planetMat.setTexture("diffuseTexture", diffuseTexture); //задаем базовую текстуру ландшафта материалу
planetMat.setTexture("nightTexture", nightTexture);//задаем ночную текстуру материалу

image

Рис. 18. Текстура ландшафта.

image

Рис. 19. Текстура ночных городов.

Таким образом мы задали обе текстуры. Чтобы они передавались в шейдере мы уже настроили нужные параметры этой строчкой

 uniforms: ["world", "worldView", "worldViewProjection", "diffuseTexture", "nightTexture"]

Все необходимые параметры настроены и будут передаваться в шейдеры. Напишем фрагментный шейдер в элементе <script> с типом «application/fragmentShader» и посмотрим как он работает. Сначала каркас, а расчет позже

<script type="application/fragmentShader" id="fragmentPlanet">
    precision highp float;

    // Varying
    varying vec2 vUV;
    varying vec3 vPositionW;
    varying vec3 vNormalW;

    // Refs
    uniform vec3 lightPosition;
    uniform sampler2D diffuseTexture;
    uniform sampler2D nightTexture;


    void main(void) {
        //calculating
    }
</script>

Необходимые переменные, для отрисовки каждого фрагмента:

varying vec2 vUV — uv координаты текстуры

varying vec3 vPositionW — позиция вершины на сцене

varying vec3 vNormalW — нормализованная нормаль вершины

Нормализованный вектор — это вектор, длина которого равна 1 (для его получения каждую координату нужно разделить на длину вектора). В GLSL есть специальная функция нормализации normalize(). Т.е. vNormalW — это нормаль.

uniform vec3 lightPosition — позиция источника света

uniform sampler2D diffuseTexture — переданная в шейдер текстура ландшафта

uniform sampler2D nightTexture — переданная в шейдер ночная текстура

С глобальными данными шейдера разобрались, переходим к главной функции main определяющей конечный цвет фрагментов. Сначала опишем задачи, которые должен выполнять шейдер:

  • Окрашивать меш в нужную текстуру
  • Учитывать положение источника освещения и делать ярче ту часть меша, которая непосредственно освещена
  • Смешивать текстуры, в зависимости от степени освещенности

Приступим к написанию шейдера:

//...
void main(void) {
    vec3 direction = lightPosition - vPositionW;
    vec3 lightVectorW = normalize(direction);

//...
}

Первым делом мы определяем вектор от источника света до вершины и нормализуем. Происходит это просто: вычитаем из координат источника света (lightPosition) координаты вершины (vPositionW), а потом применяем встроенную функцию нормализации вектора (normalize). Теперь у нас есть lightVectorW. Его будем использовать для определения коэффициента освещенности фрагмента соответствующего текущей вершине. Поможет нам в этом скалярное произведение векторов, а именно тот факт, что скалярное произведение нормализованных векторов дает нам косинус угла между ними. А так как нормализованная нормаль вершины у нас уже есть (vNormalW, ее нам рассчитал вершинный шейдер), то остается только выполнить скалярное произведение и получить косинус угла.

//...
// diffuse
float lightDiffuse = max(0.05, dot(vNormalW, lightVectorW));
//...

Теперь выводим коэффициент освещенности lightDiffuse с помощью следующих операций:

  1. Используем функцию dot() - это встроенная функция расчета скалярного произведения
  2. Ее результат кладем во встроенную функцию max(), которая вернет наибольшее из чисел 0.05 (чтобы неосвещенная часть меша была не совсем черной) и результат выполнения dot. Поэтому результат будет не меньше 0.05, но и не больше 1.0 (т.к. значение cos всегда меньше 1).

Почему именно так (схематичный рисунок):

image

Рис. 20. Углы между нормалями вершин и вектором направления света на эти вершины.

Чем больше угол между вектором направления света на вершину и нормалью вершины, тем ниже коэффициент освещенности и тем темнее будет фрагмент.

//...
vec3 color;
vec4 nightColor = texture2D(nightTexture, vUV).rgba;
vec3 diffuseColor = texture2D(diffuseTexture, vUV).rgb;

Мы объявили переменную для конечного цвета vec3 color, переменную vec4 nightColor — в которую мы поместили цвет соответствующий uv координатам текстуры, переменную vec3 diffuseColor — с которой сделали тоже самое, только цвет будет браться из базовой текстуры.

Между переменными цветов, взятых из текстур есть небольшие отличия. Например .rgb — способ обращения к 1,2 и 3 элементу массива (можно также использовать x/y/z) в результате которого получим vec3 с этими значениями. texture2D возвращает vec4, где 4 значение — это альфа составляющая. Но для diffuseTexture она всегда равно 1.0, а вот для nightColor большая часть точек прозрачна. Точки отвечающие за «ночные города» почти полностью непрозрачны, мы это используем далее, когда будем получать результирующий цвет.

//...
color = diffuseColor * lightDiffuse + (nightColor.rgb * nightColor.a * pow((1.0 - lightDiffuse), 6.0));
gl_FragColor = vec4(color, 1.0);

Умножаем diffuseColor на lightDiffuse и получаем цвет обычной текстуры под влиянием светового коэффициента. Так если точка напрямую освещена, то значения будут примерно такие — vec3(0.0, 0.4, 0.5) * 0.9. Так мы получим почти тот же цвет, что есть на текстуре, если же lightDiffuse будет ближе к 0, то и цвет будет почти черным.

Прибавляем составляющую ночной текстуры + (nightColor.rgb * nightColor.a * pow((1.0, lightDiffuse), 6.0)). Умножаем оригинальный цвет точки на альфа-составляющую nightColor.rgb * nightColor.a (которая будет = 0 почти везде кроме городов, а значит в этом случае влияния на результирующий цвет оказано не будет вообще). А потом домножаем на обратный коэффициент освещенности в степени 6.0 pow((1.0, lightDiffuse), 6.0). Обратный потому что в этой части — чем меньше сам коэффициент, тем больше его влияние (мы ведь вычитаем из максимального значения коэффициента равного 1.0).

Степень 6, нужная для экспоненциального роста значения, чтобы светимость городов была заметна, когда значение коэффициента осещения lightDiffuse близко к нулю. Если же степень убрать, то светимость городов будет расти линейно, что приведет к нежелательному проявлению света городов на освещенной части Земли.

Чем меньше коэффициент — тем сильнее выражена безовая текстура, чем больше — тем больше выражена ночная.

Наконец gl_FragColor = vec4(color, 1.0); задает финальный цвет фрагмента — vec4(vec3, float). Именно так можно составлять нужный тип, GLSL все разберет и создаст нужную переменную vec4 из vec3 и еще одной переменной.

Финальный код шейдера:

precision highp float;

// Varying
varying vec2 vUV;
varying vec3 vPositionW;
varying vec3 vNormalW;

// Refs
uniform vec3 lightPosition;
uniform sampler2D diffuseTexture;
uniform sampler2D nightTexture;


void main(void) {
    vec3 direction = lightPosition - vPositionW;
    vec3 lightVectorW = normalize(direction);

    // diffuse
    float lightDiffuse = max(0.05, dot(vNormalW, lightVectorW));

    vec3 color;
    vec4 nightColor = texture2D(nightTexture, vUV).rgba;
    vec3 diffuseColor = texture2D(diffuseTexture, vUV).rgb;

    color = diffuseColor * lightDiffuse + (nightColor.rgb * nightColor.a * pow((1.0 - lightDiffuse), 6.0));
    gl_FragColor = vec4(color, 1.0);
}

Добавим еще одну строчку в runRenderLoop для того, чтобы заставить планету вращаться и разглядеть результат со всех сторон.

planet.rotation.y+= 0.001; //поворот на 0.001 радиана

image

Рис. 21. Сцена с Землей.

Код примера можно посмотреть здесь.

Продолжение

Плюсануть
Отправить
Поделиться