WebGLU: упрощаем работу с WebGL

в 20:15, , рубрики: WebGL, Веб-разработка, метки:

Когда-то 3D в браузере было большой проблемой. К чему только не прибегали для создания объемной динамичной трехмерной графики в браузере: использованию псевдо-3D в SVG, построениям в canvas, использованию flash… Однако, прогресс не стоит на месте: наконец-то все современные браузеры стали поддерживать облегченную версию OpenGL (OpenGL ES 2.0) — WebGL. Это — довольно молодая технология, ей всего-то чуть больше года от роду. Однако, уже сейчас можно оценить ее мощь по всевозможным браузерным играм и примерам.
Из-за сравнительной молодости этой технологии, руководств по работе с ней не так уж и много. Почитать кое-что о работе с ней можно здесь (здесь — перевод на русский). Здесь можно узнать кое-что об основах WebGL.
Для облегчения работы с WebGL разработан ряд библиотек (правда, большинство из них еще довольно сырые). Применению одной из них — webGLU — для формирования простой сцены, освещенной одним источником-фонарем, и посвящена эта статья. Здесь можно посмотреть пример, а отсюда скачать полный архив для запуска его на своей машине.
Для начала немного напомню о разнице между OpenGL и WebGL. Так как WebGL базируется на GLES, эта технология значительно уступает OpenGL: в ней нет множества удобных расширений (ARB и т.п.), нет встроенной поддержки освещения, да даже GL_QUADS в ней не поддерживаются… Однако, что поделаешь: больше никаких технологий, позволяющих воплотить 3D в вебе без сторонних плагинов нет.
Для расчета положения вершин и их цвета на итоговом изображении используются шейдеры. При простейшем построении трехмерных сцен шейдеры вызываются для каждой вершины, с которой они связаны. Но более сложные сцены описать таким образом невозможно. В этом случае прибегают к написанию шейдеров, формирующих текстуру, которая и образует итоговую сцену (пример можно увидеть здесь. Возможно, в следующей статье я коснусь такого способа описания трехмерных сцен.
Библиотека WebGLU содержит скудную документацию и несколько примеров. Этого маловато для полноценной работы с библиотекой, поэтому приходится и ее исходники читать. Кроме того, библиотека довольно сырая, так что иногда приходится и поковыряться в ее коде.
Итак, для того, чтобы начать работу с WebGLU, нам необходимо подключить скрипт webglu.js и инициализировать его:

<script type="text/javascript" src="./src/webglu.js"></script> 
… 
$W.initialize(); 

$W — основной объект пространства имен WebGLU. Бóльшую часть работы мы будем вести с ним. Кроме этого объекта есть объект $G (пространство имен GameGLU), упрощающий работу с управлением сценой при помощи мыши и клавиатуры. В WebGLU есть и кое-какие экспериментальные функции (например, CrazyGLU — для работы с псевдобуфером выбора и кое-какой физики). Все исходные файлы подгружаются WebGLU при запуске соответствующих функций (например, функция useControlProfiles(); подгружает файл ControlProfiles.js), так что вручную подгружать скрипты помимо webglu.js не нужно.
После инициализации WebGLU мы можем приступить к созданию объектов сцены. Для создания объекта WebGLU предоставляет интерфейс $W.Object(type, flags), где type — тип объекта (как в OpenGL):

  • $W.GL.POINTS — каждая вершина отображается точкой,
  • $W.GL.LINES — каждая пара из вершин 2n-1 и 2n соединяется линией,
  • $W.GL.LINE_LOOP — все вершины по порядку соединяются отрезками с замыканием на первую вершину,
  • $W.GL.LINE_STRIP — все вершины соединяются отрезками без замыкания,
  • $W.GL.TRIANGLES — каждая тройка вершин (по порядку) образует треугольник,
  • $W.GL.TRIANGLE_STRIP — вершины по порядку соединяются треугольниками,
  • $W.GL.TRIANGLE_FAN — вершины соединяются треугольниками вокруг общей — первой — вершины;

flags — необязательные флаги: по умолчанию они равны $W.RENDERABLE | $W.PICKABLE, однако, если мы рисуем объект, который является дочерним, то следует указать явно $W.PICKABLE. В простейшем случае для каждой вершины объекта необходимо указать цвет, однако, мы вольны написать и сообственный шейдер (что мы и сделаем далее), позволяющий указывать общий цвет для всего объекта.
Итак, например, для создания цветных координатных осей, мы сделаем так:

var originLines = new $W.Object($W.GL.LINES); 
originLines.vertexCount = 6; 
originLines.fillArray("vertex", 
	[[0,0,0], [3,0,0], [0,0,0], [0,3,0], [0,0,0], [0,0,3]]); 
with ($W.constants.colors){ 
  originLines.fillArray("color", 
	[ RED,     RED,      GREEN,   GREEN,    BLUE,    BLUE]); 
} 

Шейдер по-умолчанию использует следующие массивы для характеристики каждой вершины объекта:

  • «vertex» — координаты вершин,
  • «color» — цвета вершин,
  • «normal» — нормали к вершинам,
  • «texCoord» — координаты текстуры в данной вершине,
  • «wglu_elements» — индексы координат вершин (для сложных объектов).

Соответствующим образом (кроме индексов) названы соответствующие переменные в шейдерах (напомню, что переменные, характеризующие отдельную вершину в шейдере, имеют атрибут attribute).
Подключение шейдеров в WebGL реализуется при помощи метода Material. Единственным аргументом этого метода является путь к JSON-файлу описания шейдеров. Например, наши шейдеры, реализующие освещение, подключаются так:

var lights = new $W.Material({path:$W.paths.materials + "light.json"}); 

Сам файл light.json выглядит так:

{ 
    name: "light", 
    program: { 
        name: "light", 
        shaders: [ 
            {name:"light_vs", path:$W.paths.shaders+"light.vert"}, 
            {name:"light_fs", path:$W.paths.shaders+"light.frag"} 
        ] 
    } 
} 

Здесь name — общее имя «материала»; program → name — по-видимому, характеризует имя программы (возможно, создатель WebGL предполагал, что для одного материала может использоваться несколько программ, пока же этот параметр особой роли не играет); shaders — используемые шейдеры с указанием пути к ним.
Переменные, являющиеся общими для каждого объекта или всей системы (с атрибутом uniform), связываются с соответствующими переменными JavaScript при помощи метода setUniformAction(n, f) объекта Materal. Аргументы этого метода имеют слеюдующее значение: n — имя переменной в шейдере (в методе оно указывается как строка); f — функция типа function(u, o, m), где u — объект uniform (), o — сам объект, m — материал. Например, связывание параметра «color» с цветом объекта выполняется так:

lights.setUniformAction('color', function(uniform, object, material){ 
	$W.GL.uniform4fv(uniform.location, object.color); }); 

Для того, чтобы задать объекту нестандартный материал, используется свойство объекта setMaterial(mat), где mat — нужный нам материал. Так как JavaScript позволяет «на лету» добавлять свойства в уже определенные объекты, мы можем легко вносить изменения в объекты для координирования их со своими шейдерами.
Создадим при помощи WebGLU вот такую сцену: WebGLU: упрощаем работу с WebGL Здесь использовано наследование объектов: основной вертикальный цилиндр наследует второй цилиндр и верхнюю окружность. Та, в свою очередь, наследует нижнюю окружность и набор разноцветных сфер разного размера, размещаемых случайным образом в плоскости между двумя окружностями. Вся сцена освещается одним направленным источником света (оранжевым «фонариком»), обозначенным небольшой оранжевой сферой.
Для использования освещения нам обязательно нужно правильно просчитать нормали ко всем вершинам, которые будут обрабатываться нашими шейдерами-«осветителями». Сферу мы можем нарисовать при помощи функции genSphere(n1,n2,rad) библиотеки WebGLU, а вот цилиндры придется рисовать самостоятельно. Проще всего это сделать, заполнив боковую поверхность цилиндра связанными треугольниками:

function drawCylinder(R, H, n, flags){ 
	var v = [], norm = []; 
	var C = new $W.Object($W.GL.TRIANGLE_STRIP, flags); 
	C.vertexCount = n * 2+2; 
	for(var i = -1; i < n; i++){ 
		var a = _2PI/n*i; 
		var cc = Math.cos(a), ss = Math.sin(a); 
		v = v.concat([R*cc, R*ss,0.]); 
		v = v.concat([R*cc, R*ss,H]); 
		norm = norm.concat([-cc, -ss, 0.]); 
		norm = norm.concat([-cc, -ss, 0.]); 
	} 
	C.fillArray("vertex", v); 
	C.fillArray("normal", norm); 
	return C; 
} 

Этот способ, как вы убедитесь дальше, довольно примитивен: из-за того, что мы не помещаем вершины на поверхность цилиндра между его торцами, освещение для него рассчитывается неверно: если «фонарь» освещает только середину поверхности цилиндра, не захватывая его торцы, цилиндр отображается неосвещенным. Чтобы нарисовать цилиндр правильно, нужно добавить дополнительные промежуточные вершины и заполнить массив индексов для правильного отображения треугольников. Другой вариант — нарисовать объект составным (с дочерними объектами), из нескольких прямоугольников, каждый из которых состоит из набора треугольников.
Окружности мы будем рисовать при помощи функции

function drawCircle(R, n, w, flags){ 
	var v = []; 
	var C = new $W.Object($W.GL.LINE_LOOP, flags); 
	C.vertexCount = n; 
	for(var i = 0; i < n; i++){ 
		var a = _2PI/n*i; 
		v = v.concat([R*Math.cos(a), R*Math.sin(a),0.]); 
	} 
	C.fillArray("vertex", v); 
	if(typeof(w) != "undefined") C.WD = w; 
	else C.WD = 1.; 
	C.draw = function(){ // переопределяем для возможности изменения ширины линии 
		var oldw = $W.GL.getParameter($W.GL.LINE_WIDTH); 
		$W.GL.lineWidth(this.WD); 
		this.drawAt( 
			this.animatedPosition().elements, 
			this.animatedRotation().matrix(), 
			this.animatedScale().elements 
		); 
		$W.GL.lineWidth(oldw); 
	}; 
	return C; 
} 

Чтобы иметь возможность менять толщину линий, которыми рисуются окружности, нам нужно будет переопределить функцию draw() данного объекта (т.к. функция $W.GL.lineWidth(w) устанавливает толщину линии w глобально — вплоть до следующего вызова этой функции). Если поменять $W.GL.LINE_LOOP у этого объекта на $W.GL.POINTS, окружности будут нарисованы точками. Размер точек будет зависеть от свойства WD объекта благодаря тому, что мы используем для него «материал» points, в котором указано

    gl_PointSize = WD; 

Здесь можно посмотреть код фрагментного шейдера для отображения точек разного размера, а здесь — шейдера вершин.
Итак, объекты мы создали. Настала очередь создать шейдеры для расчета освещения. В любом справочнике по OpenGL для расчета итогового цвета вершины при освещении несколькими источниками можно найти следующую формулу:

  result_Color = mat_emission 
  		+ lmodel_ambient * mat_ambient 
  		Sum_i(D * S * [l_ambient * mat_ambient + max{dot(L,n),0}*l_diffuse*mat_diffuse 
  			+ max{dot(s,n),0}^mat_shininess * l_specular * mat_specular 
  			) 

Здесь

  • result_Color — итоговый цвет вершины,
  • mat_X — свойства материала,
  • l_X — свойства i-го источника света
  • X:
    • emission — излучаемый свет (т.е. материал — источник света),
    • lmodel_ambient — общий рассеянный свет модели освещения (не зависит от источников, т.е. это — фоновый свет)
    • ambient — фоновый свет (цвет материала вне источников света, рассеянная световая составляющая i-го источника света)
    • D — коэффициент ослабления света, D = 1/(kc + kl*d + kq*d^2),
      • d — расстояние от источника до вершины,
      • kc — постоянный коэффициент ослабления («серый фильтр»),
      • kl — линейный коэффициент ослабления,
      • kq — квадратичный коэффициент ослабления,
    • S — эффект прожектора, вычисляется так:
      = 1, если источник света — не прожектор (бесконечно удаленный параллельный пучок),
      = 0, если источник — прожектор, но вершина вне конуса излучения,
      = max{dot(v,dd),0}^GL_SPOT_EXPONENT в остальных случаях, здесь v — нормированный вектор от прожектора (GL_POSITION) к вершине, dd (GL_SPOT_DIRECTION) — ориентация прожектора
    • L = -v (нормированный вектор от вершины к источнику),
    • n — нормаль к вершине,
    • diffuse — рассеянный свет, не зависит от угла падения/отражения,
    • s — нормированный вектор, равный сумме L и вектора от вершины к глазу,
    • shininess — степень блеска (от 0 до 128, чем больше, тем более «блестящей» является поверхность),
    • specular — цвет зеркального компонента.

    Мы можем упростить эту формулу, если примем во внимание то, что обычно рассеянный и фоновый цвет материала совпадают, фоновый цвет прожектора совпадает с рассеянным цветом общего фонового освещения, а постоянный и линейный коэффициенты ослабления можно опустить. В итоге у нас получится такой шейдер фрагментов. Шейдер вершин должен будет помимо расчета координаты отображения вершины пересчитать, исходя из текущей для данного объекта модельно-видовой матрицы, положение вершины в пространстве и ориентацию ее нормали (это необходимо сделать, т.к. каждый объект мы можем двигать, масштабировать и вращать).
    Теперь нам остается определить свойства нашего «фонаря»:

    	light = { 
    		position: [0.,2.,1.5], 
    		target:   [0.,0.,-2.], 
    		color:    [1.,.5,0.,1.], 
    		fieldAngle: 60., 
    		exponent: 5., 
    		distanceFalloffRatio: .02 
    	}; 
    

    при помощи setUniformAction(…) связать свойства «фонаря» и свойства объектов с переменными шейдеров и задать индивидуальные свойства каждому объекту, использующему данный «материал».
    После того, как мы все это сделаем, анимируем сцену при помощи функции $W.start(T);, где T — минимальный интервал между отрисовкой сцены. Если наша сцена слишком сложная, нам придется отрисовывать ее после каждого изменения вручную при помощи функций $W.util.defaultUpdate(); и $W.util.defaultDraw();. Эти функции не влияют на компиляцию шейдеров (которую необходимо выполнять лишь при внесении кардинальных изменений в сами шейдеры), поэтому наша сцена будет «подвисать» лишь в момент начальной загрузки (при инициализации), а также немного притормаживать при изменении размера окна.
    Напоследок скажу, что функция вращения сцены (точнее — перемещения камеры вокруг сцены) из WebGLU не очень удобная, поэтому стоит определить свою функцию перемещения. Здесь (а также по указанному в самом начале адресу примера) вы можете посмотреть, как выглядит итоговый html-файл.

    Статья получилась довольно большой, при том, что я не упомянул о работе с буфером выбора (если нам необходимо реализовать отождествление объектов по щелчку мыши), отображении объектов при смешивании (а это нужно для использовании компонента прозрачности в цвете объекта), отсечении «тыльных» поверхностей фигур и многом другом. Надеюсь, эта моя статья о WebGL не последняя (а может быть мое начинание продолжит кто-нибудь еще).

Автор: Eddy_Em


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js