Мы начали разговор об освещении, тенях и рейтрейсинге в компьютере. В прошлый раз говорили о принципах рейтрейсинга — это когда компьютер моделирует то, как свет взаимодействует с трёхмерными объектами на разных поверхностях и при разных условиях. Такой подход позволяет получать реалистичные 3D-изображения.
Мы попробуем смоделировать поведение света с помощью браузера и трёхмерного движка WebGL. Пока что там не будет классического рейтрейсинга, но мы сделаем первый шаг в этом направлении — посмотрим, как влияет на сцену один источник света.
Чтобы было понятнее, что тут происходит:
Что делаем
Мы разберём уже готовый проект Александра Шульмана. Разбирать чужие проекты полезно, чтобы погрузиться в новую для себя отрасль.
Основные шаги его программы:
- Пишем служебные функции и подключаем движок WebGL.
- Создаём источник освещения и прописываем его параметры.
- Создаём слой с предметами в сцене.
- Поверх него создаём слой для теней.
- Рендерим сцену.
- Сдвигаем источник света и пересчитываем всё заново.
Весь код — это js-файл, который выполняется на странице. Как обычно, мы прокомментировали каждую строку кода — так будет легче понять, что происходит в данный момент и какая функция за что отвечает.
Подготовка
В проекте два блока с переменными — для технических параметров сцены и для шейдеров. Шейдер — это особая сущность, которая нужна для отрисовки света, теней, фактур и всякой красоты.
////by Ethan Alexander Shulman known as public_int_i 10100101010
//This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License
//http://creativecommons.org/licenses/by-nc-sa/4.0/
// переменные для обработки шейдеров
var quadVB;
var shVertex;
// вспомогательные переменные
var dfTex,alTex;
var uniTime;
// переменная для времени старта
var startTime;
// переменные для доступа к движку
var cE,gl;
// высота и ширина сцены
var WIDTH = 640;
var HEIGHT = 480;
// масштаб
var DF_SCALE = 1.0;
// финальная высота и ширина сцены
var DF_WIDTH = WIDTH*DF_SCALE;
var DF_HEIGHT = HEIGHT*DF_SCALE;
Служебные функции
Чтобы упростить написание кода, Александр делает собственные математические функции на основе встроенных. При этом он использует два подхода — сокращение и упорядочивание.
Сокращение — это когда вместо вызова длинного Math.abs() для модуля числа можно вызвать просто abs() и получить тот же результат. Для этого он просто создаёт переменные (которые на самом деле — функции) и работает через них:
// формируем удобные вызовы функций
var abs = Math.abs;
var floor = Math.floor;
var sqrt = Math.sqrt;
Упорядочивание — это когда вместо однотипных функций, но с разными параметрами используются новые функции с удобными названиями. Например, чтобы узнать расстояние до точки, нужно извлечь корень из суммы произведений координат, но проще сделать отдельную функцию len():
// расстояние до точки с указанными координатами
function len(x,y) {
return sqrt(x*x+y*y);
}
// расстояние до круга с указанными координатами
function circle(x,y,r) {
return len(x,y)-r;
}
// расстояние до куба с указанными координатами
function cube(x,y,w,h,r) {
return len(max(0.,abs(x)-w),
max(0.,abs(y)-h))-r;
}
// возвращает минимальное значение из двух
function min(a,b) {
return a<b?a:b;
}
// возвращает максимальное значение из двух
function max(a,b) {
return a>b?a:b;
/ возвращает расстояние до указанного объекта
function distanceField(x,y) {
var lx = x%.4-.2, ly = y%.4-.2;
var fid = (floor(x/.4)+floor(y/.4)*12.)%144.;
return min(cube(lx,ly,.014+(fid*8.213461)%1.*.1,.014+(fid*4.7234)%1.*.1,.001),
min(circle(x-.5,y-.5,.2), cube(x-.1,y-.5,.01,.34,.01)));
}
Коротко про шейдеры
Перед тем как двигаться дальше, нам нужно познакомиться с шейдерами.
Шейдеры — это специальные мини-программы,которые контролируют, как отображается каждый пиксель или вершина в трёхмерной сцене. Они используются для создания разных визуальных эффектов: например для изменения цвета, текстуры, освещения и настроек. Когда в шейдер поступают данные, он их обрабатывает и отдаёт результат, например пиксель.
Есть много шейдеров, нам нужны пока только вершинные и фрагментные.
Вершинные шейдеры используются для обработки вершин в трёхмерной модели. Они позволяют работать с координатами вершин, изменять их положение, поворачивать и масштабировать для создания различных эффектов визуализации. Например, с помощью вершинных шейдеров можно сделать эффект ряби на воде или качание травы на ветру.
Фрагментные шейдеры работают иначе — они действуют не на уровне модели, а на уровне пикселя. Фрагментные шейдеры обрабатывают данные о конкретном фрагменте текстуры — свойства материала, освещение, коэффициент поглощения и так далее. Они нужны для того, чтобы отрисовывать конкретные пиксели в нужном цвете и с нужным эффектом.
Получается, что вершинные и фрагментные шейдеры решают разные задачи: одни отвечают за геометрию трёхмерной модели, а другие — за то, как эта модель будет выглядеть.
Если коротко описать процесс формирования финальной картинки на экране из трёхмерной сцены, то она выглядит так:
- Описываем объекты.
- Создаём вершинные шейдеры, кладём в буфер и обрабатываем их.
- Создаём фрагментные шейдеры и тоже их обрабатываем через буфер.
- Объединяем всё и выводим итоговую картинку.
Готовим движок
Сейчас начнётся сложное, поэтому можно сильно не вникать, а просто следить за логикой проекта — что откуда берётся и что делает.
Движок можно запустить в тот момент, когда страница полностью загрузилась. Для этого создаём функцию window.onload = function() {} и делаем такую предварительную работу:
// что делаем, когда страница загрузилась
window.onload = function() {
// получаем доступ к окну
cE = document.getElementById("display");
// устанавливаем свои высоту и ширину
cE.width = WIDTH;
cE.height = HEIGHT;
// подключаем движок WbbGL
gl = cE.getContext("webgl");
// если не получилось
if (!gl) {
// пробуем использовать экспериментальную версию
gl =cE.getContext("experimental-webgl");
// если не сработало — выводим сообщение об ошибке
if (!gl) alert("Failed to load WebGL");
}
// координаты вершин в углах сцены
var quadVerts = new Float32Array([1.0,1.0,-1.0,1.0,1.0,-1.0,-1.0,-1.0]);
// создаём буфер вершин
quadVB = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,quadVB);
// координаты вершин в буфере
gl.bufferData(gl.ARRAY_BUFFER,quadVerts,gl.STATIC_DRAW);
// создаём вершинные шейдеры
var vso = gl.createShader(gl.VERTEX_SHADER);
// устанавливаем источник шейдеров и прописываем параметры
gl.shaderSource(vso,"precision mediump float; varying vec2 uv; attribute vec2 vertex; void main(void) {uv=.5*vertex+vec2(.5);gl_Position=vec4(vertex,0.,1.);}");
// компилируем шейдеры
gl.compileShader(vso);
// и сразу вызываем отладчик
debugShader(vso);
Вершинные шейдеры готовы — это значит, движок будет знать, какая форма у наших объектов, как они выглядят и что делают. Теперь делаем фрагментные шейдеры — они как раз отвечают за свет, тени и распределение освещения:
// создаём фрагментные шейдеры
var pso = gl.createShader(gl.FRAGMENT_SHADER);
// устанавливаем источник и прописываем параметры
// здесь же будет и движение
gl.shaderSource(pso,"\nprecision mediump float; varying vec2 uv; uniform sampler2D tex0,tex1; uniform float time;"+
"void main(void) {"+
"float sh=1.,occl=1.;"+
"vec2 lightPos = vec2(sin(time)*.5+.25,cos(time)*.4+.5);"+
"vec2 lightDir = lightPos-uv;"+
"float lightDist = length(lightDir); if (lightDist < .02) {gl_FragColor=vec4(1.,1.,0.,1.);return;}float sd=lightDist;"+
"lightDir = normalize(lightDir);"+
"vec2 rp = uv; float dst = texture2D(tex1,uv).x;"+
"for (int i = 0; i < 44; i++) {"+
"lightDist -= dst; if (lightDist < 0.) break;"+
"if (dst < .0004) {sh=0.;break;}"+
"rp += dst*lightDir; dst=texture2D(tex1,rp).x;"+
"occl -= max(0.,.02-dst)*8.;"+
"}"+
"vec3 dif = texture2D(tex0,uv).xyz; dif = .2*dif+max(0.,1.-sd/.8)*sh*max(0.,occl)*.8*dif;"+
"gl_FragColor=vec4(dif,1.);}");
// компилируем фрагментные шейдеры
gl.compileShader(pso);
// сразу вызываем отладчик шейдеров
debugShader(pso);
Теперь, когда шейдеры готовы, подключаем движок, создаём программу обработки сцены и связываем её с нашими шейдерами. Это позволит видеть в браузере то, что происходит внутри сцены: шейдеры посчитают то, что внутри неё, а WebGL отрисует это всё на странице:
// создаём объект программы шейдеров
var spr = gl.createProgram();
// прикрепляем к ней шейдеры
gl.attachShader(spr,vso);
gl.attachShader(spr,pso);
// связываем программу с webgl
gl.linkProgram(spr);
// указываем, какую программу будем использовать
gl.useProgram(spr);
// получаем текущее время
startTime = Date.now();
// получаем текущее положение шейдеров
uniTime = gl.getUniformLocation(spr,"time");
shVertex= gl.getAttribLocation(spr,"vertex");
// подключаем атрибут для исползования
gl.enableVertexAttribArray(shVertex);
// создаём текстуры
dfTex = gl.createTexture();
alTex = gl.createTexture();
Рисуем слои
Теперь нам нужно научить движок отрисовывать два слоя:
- Слой с непрозрачными объектами.
- Слой освещения, где будут двигаться тени.
С точки зрения кода это два почти одинаковых фрагмента, которые различаются переменными в паре мест и содержимым цикла. Технически можно было бы оптимизировать код и сделать из них одну функцию с универсальными параметрами, но тогда бы потерялась наглядность.
Логика первого слоя работает так:
- Перебираем все пиксели на картинке.
- Для каждого пикселя смотрим, попадает ли он внутрь объекта или нет.
- Если попадает — становится тёмным (это объект), если не попадает — светлым (это фон).
Это очень похоже на работу рейтрейсинга, когда нам нужно посчитать освещение с точки зрения каждого пикселя — мы тоже перебираем всё подряд и смотрим, как там отражается свет.
// формируем первый слой — фон и фигуры
// создаём холст для отрисовки всего
var bc =document.createElement("canvas");
// устанавливаем высоту и ширину
bc.width = WIDTH;
bc.height = HEIGHT;
// получаем доступ к холсту
var b2d = bc.getContext("2d"),u,v;
// перебираем все пиксели на холсте
for (var x = 0; x < WIDTH; x++) {
u = x/WIDTH;
for (var y = 0; y < HEIGHT ; y++) {
v = y/HEIGHT;
// получаем расстояние до объекта
var d = distanceField(u,v);
// если дистанция меньше погрешности
if (d < 0.0008) {
// это фигура
b2d.fillStyle = "#808080";
} else {
// это пустое пространство
b2d.fillStyle = "white";
}
// красим пиксель в свой цвет
b2d.fillRect(x,y,1,1);
}
}
// указываем активную текстуру и делаем её основной
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D,alTex);
// по правилам все текстуры квадратные, но не все объекты можно разбить на целое число квадратных элементов
// заполняем пиксели до краёв текстур, даже если они не помещаются в квадрат
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// добавляем картинку и передаём текстуры в шейдеры
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bc);
gl.uniform1i(gl.getUniformLocation(spr,"tex0"),0);
Второй слой — это тени, и здесь у нас меняется логика в цикле. Если раньше мы просто смотрели, попали или не попали в объект, то теперь считаем расстояние от источника света до всех остальных пикселей. Чем ближе — тем светлее. Но если на пути встретилась преграда — туда свет не идёт, поэтому там тоже будет тень:
// делаем то же самое для второго слоя — на нём будут тени
var dc = document.createElement("canvas");
dc.width = DF_WIDTH;
dc.height = DF_HEIGHT;
var d2d = dc.getContext("2d");
var floor = Math.floor;
for (var x = 0; x < DF_WIDTH; x++) {
u = x/DF_WIDTH;
for (var y = 0; y < DF_HEIGHT ; y++) {
v = y/DF_HEIGHT;
// считаем расстояние от источника света
var d = distanceField(u,v)*255;
d = floor(d);
// и заливаем картинку тенью, чем дальше — тем темнее
d2d.fillStyle = "rgb("+d+","+d+","+d+")";
d2d.fillRect(x,y,1,1);
}
}
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D,dfTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, dc);
gl.uniform1i(gl.getUniformLocation(spr,"tex1"),1);
Последнее, что нам осталось тут сделать, — сразу же отрисовать то, что у нас получилось к этому моменту:
// если сейчас отрисовка закончилась — запускаем новую
if (!window.requestAnimationFrame) window.requestAnimationFrame=window.setTimeout;
// рендерим готовую картинку
render();
// закончилась функция window.onload
}
Запускаем сцену
Так как наш источник света всё время движется, то нам нужно постоянно пересчитывать все шейдеры и выводить новый кадр на экран. Для этого пишем функцию, которая возьмёт это на себя, а, главное — будет делать это бесконечно:
// функция рендера
function render() {
// очищаем сцену
gl.clear(gl.COLOR_BUFFER_BIT);
// получаем новое положение шейдеров
gl.uniform1f(uniTime,(Date.now()-startTime)/2000.0);
// отправляем в буфер
gl.bindBuffer(gl.ARRAY_BUFFER,quadVB);
// передаём координаты
gl.vertexAttribPointer(shVertex, 2, gl.FLOAT, false, 0, 0);
// отрисовываем треугольники
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
// повторяем бесконечное количество раз
setTimeout(render);
}
Посмотреть на игру света и тени на странице проекта
////by Ethan Alexander Shulman known as public_int_i 10100101010
//This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License
//http://creativecommons.org/licenses/by-nc-sa/4.0/
// переменные для обработки шейдеров
var quadVB;
var shVertex;
// вспомогательные переменные
var dfTex,alTex;
var uniTime;
// переменные для доступа к движку
var cE,gl;
// высота и ширина сцены
var WIDTH = 640;
var HEIGHT = 480;
// масштаб
var DF_SCALE = 1.0;
// финальная высота и ширина сцены
var DF_WIDTH = WIDTH*DF_SCALE;
var DF_HEIGHT = HEIGHT*DF_SCALE;
// отладка шейдеров
function debugShader(s,t) {
// если шейдер не скомпилировался нормально
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
// возвращаем -1
return -1;
}
}
// формируем удобные вызовы функций
var abs = Math.abs;
var floor = Math.floor;
var sqrt = Math.sqrt;
// расстояние до точки с указанными координатами
function len(x,y) {
return sqrt(x*x+y*y);
}
// расстояние до круга с указанными координатами
function circle(x,y,r) {
return len(x,y)-r;
}
// расстояние до куба с указанными координатами
function cube(x,y,w,h,r) {
return len(max(0.,abs(x)-w),
max(0.,abs(y)-h))-r;
}
// возвращает минимальное значение из двух
function min(a,b) {
return a<b?a:b;
}
// возвращает максимальное значение из двух
function max(a,b) {
return a>b?a:b;
}
// возвращает расстояние до указанного объекта
function distanceField(x,y) {
var lx = x%.4-.2, ly = y%.4-.2;
var fid = (floor(x/.4)+floor(y/.4)*12.)%144.;
return min(cube(lx,ly,.014+(fid*8.213461)%1.*.1,.014+(fid*4.7234)%1.*.1,.001),
min(circle(x-.5,y-.5,.2), cube(x-.1,y-.5,.01,.34,.01)));
}
// что делаем, когда страница загрузилась
window.onload = function() {
// получаем доступ к окну
cE = document.getElementById("display");
// устанавливаем свои высоту и ширину
cE.width = WIDTH;
cE.height = HEIGHT;
// подключаем движок WbbGL
gl = cE.getContext("webgl");
// если не получилось
if (!gl) {
// пробуем использовать экспериментальную версию
gl =cE.getContext("experimental-webgl");
// если не сработало — выводим сообщение об ошибке
if (!gl) alert("Failed to load WebGL");
}
// координаты вершин в углах сцены
var quadVerts = new Float32Array([1.0,1.0,-1.0,1.0,1.0,-1.0,-1.0,-1.0]);
// создаём буфер вершин
quadVB = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,quadVB);
// координаты вершин в буфере
gl.bufferData(gl.ARRAY_BUFFER,quadVerts,gl.STATIC_DRAW);
// создаём вершинные шейдеры
var vso = gl.createShader(gl.VERTEX_SHADER);
// устанавливаем источник шейдеров и прописываем параметры
gl.shaderSource(vso,"precision mediump float; varying vec2 uv; attribute vec2 vertex; void main(void) {uv=.5*vertex+vec2(.5);gl_Position=vec4(vertex,0.,1.);}");
// компилируем шейдеры
gl.compileShader(vso);
// и сразу вызываем отладчик
debugShader(vso);
// создаём фрагментные шейдеры
var pso = gl.createShader(gl.FRAGMENT_SHADER);
// устанавливаем источник и прописываем параметры
// здесь же будет и движение
gl.shaderSource(pso,"\nprecision mediump float; varying vec2 uv; uniform sampler2D tex0,tex1; uniform float time;"+
"void main(void) {"+
"float sh=1.,occl=1.;"+
"vec2 lightPos = vec2(sin(time)*.5+.25,cos(time)*.4+.5);"+
"vec2 lightDir = lightPos-uv;"+
"float lightDist = length(lightDir); if (lightDist < .02) {gl_FragColor=vec4(1.,1.,0.,1.);return;}float sd=lightDist;"+
"lightDir = normalize(lightDir);"+
"vec2 rp = uv; float dst = texture2D(tex1,uv).x;"+
"for (int i = 0; i < 44; i++) {"+
"lightDist -= dst; if (lightDist < 0.) break;"+
"if (dst < .0004) {sh=0.;break;}"+
"rp += dst*lightDir; dst=texture2D(tex1,rp).x;"+
"occl -= max(0.,.02-dst)*8.;"+
"}"+
"vec3 dif = texture2D(tex0,uv).xyz; dif = .2*dif+max(0.,1.-sd/.8)*sh*max(0.,occl)*.8*dif;"+
"gl_FragColor=vec4(dif,1.);}");
// компилируем фрагментные шейдеры
gl.compileShader(pso);
// сразу вызываем отладчик шейдеров
debugShader(pso);
// создаём объект программы шейдеров
var spr = gl.createProgram();
// прикрепляем к ней шейдеры
gl.attachShader(spr,vso);
gl.attachShader(spr,pso);
// связываем программу с webgl
gl.linkProgram(spr);
// указываем, какую программу будем использовать
gl.useProgram(spr);
// получаем текущее время
startTime = Date.now();
// получаем текущее положение шейдеров
uniTime = gl.getUniformLocation(spr,"time");
shVertex= gl.getAttribLocation(spr,"vertex");
// подключаем атрибут для исползования
gl.enableVertexAttribArray(shVertex);
// создаём текстуры
dfTex = gl.createTexture();
alTex = gl.createTexture();
// формируем первый слой — фон и фигуры
// создаём холст для отрисовки всего
var bc =document.createElement("canvas");
// устанавливаем высоту и ширину
bc.width = WIDTH;
bc.height = HEIGHT;
// получаем доступ к холсту
var b2d = bc.getContext("2d"),u,v;
// перебираем все пиксели на холсте
for (var x = 0; x < WIDTH; x++) {
u = x/WIDTH;
for (var y = 0; y < HEIGHT ; y++) {
v = y/HEIGHT;
// получаем расстояние до объекта
var d = distanceField(u,v);
// если дистанция меньше погрешности
if (d < 0.0008) {
// это фигура
b2d.fillStyle = "#808080";
} else {
// это пустое пространство
b2d.fillStyle = "white";
}
// красим пиксель в свой цвет
b2d.fillRect(x,y,1,1);
}
}
// указываем активную текстуру и делаем её основной
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D,alTex);
// по правилам все текстуры квадратные, но не все объекты можно разбить на целое число квадратных элементов
// заполняем пиксели до краёв текстур, даже если они не помещаются в квадрат
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// добавляем картинку и передаём текстуры в шейдеры
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bc);
gl.uniform1i(gl.getUniformLocation(spr,"tex0"),0);
// делаем то же самое для второго слоя — на нём будут тени
var dc = document.createElement("canvas");
dc.width = DF_WIDTH;
dc.height = DF_HEIGHT;
var d2d = dc.getContext("2d");
var floor = Math.floor;
for (var x = 0; x < DF_WIDTH; x++) {
u = x/DF_WIDTH;
for (var y = 0; y < DF_HEIGHT ; y++) {
v = y/DF_HEIGHT;
// считаем расстояние от источника света
var d = distanceField(u,v)*255;
d = floor(d);
// и заливаем картинку тенью, чем дальше — тем темнее
d2d.fillStyle = "rgb("+d+","+d+","+d+")";
d2d.fillRect(x,y,1,1);
}
}
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D,dfTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, dc);
gl.uniform1i(gl.getUniformLocation(spr,"tex1"),1);
// если сейчас отрисовка закончилась — запускаем новую
if (!window.requestAnimationFrame) window.requestAnimationFrame=window.setTimeout;
// рендерим готовую картинку
render();
}
// переменная для времени старта
var startTime;
// функция рендера
function render() {
// очищаем сцену
gl.clear(gl.COLOR_BUFFER_BIT);
// получаем новое положение шейдеров
gl.uniform1f(uniTime,(Date.now()-startTime)/2000.0);
// отправляем в буфер
gl.bindBuffer(gl.ARRAY_BUFFER,quadVB);
// передаём координаты
gl.vertexAttribPointer(shVertex, 2, gl.FLOAT, false, 0, 0);
// отрисовываем треугольники
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
// повторяем бесконечное количество раз
setTimeout(render);
}