Текстурирование и освещение
В прошлом разделе мы рассмотрели простейшую WebGL-программу, которая рисует вращающийся куб.
Его поверхность была разноцветной, но малореалистичной: на ней не хватало
- текстуры — рисунка деталей поверхности, заменяющего собой сложную высокодетализированную геометрию
- освещения, хотя бы примерно соответствующего тому, как освещены предметы в реальном мире
Начнём с небольшой модификации этой самой программы (если Вы не скомпоновали эту программу из кусков, приведённых в прошлом разделе, хотя бы один раз самостоятельно, рекомендуем сначала сделать это, а не копипастить нижеприведённый код)
let GL = null;
onload = () => {
const canvas = document.getElementById(`CANVAS`);
canvas.width = 600;
canvas.height = 600;
GL = canvas.getContext(`webgl2`);
const { cube, program, uniforms } = setupScene();
const renderFrame = time => {
const aspect = processResize();
GL.clearColor(0, 0, 0, 1);
GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);
GL.useProgram(program);
GL.bindVertexArray(cube);
GL.uniform3f(uniforms.axis,
Math.cos(time/3000), 2*Math.cos(time/1700), 3
);
GL.uniform1f(uniforms.angle, time/1000);
GL.uniform3f(uniforms.translation, 0, 0, 7);
GL.uniform1f(uniforms.aspect, aspect);
GL.drawArrays(GL.TRIANGLES, 0, 36);
GL.bindVertexArray(null);
requestAnimationFrame(renderFrame);
};
renderFrame(0);
}
function processResize() {
const width = GL.canvas.clientWidth;
const height = GL.canvas.clientHeight;
const aspect = width / height;
if (GL.canvas.height !== height || GL.canvas.width !== width) {
GL.canvas.width = width;
GL.canvas.height = height;
GL.viewport(0, 0, width, height);
}
return aspect;
}
function setupScene() {
const attributes = {
coord: 0,
index: 1,
};
const program = buildProgram(VS_SRC, FS_SRC, attributes);
const cube = createCubeVAO(attributes);
GL.enable(GL.DEPTH_TEST);
const uniforms = {};
for (const name of ['axis', 'angle', 'translation', 'aspect']) {
uniforms[name] = GL.getUniformLocation(program, name);
}
return { program, cube, uniforms };
}
function createCubeVAO(attributes) {
const vao = GL.createVertexArray();
GL.bindVertexArray(vao);
const buffer = GL.createBuffer();
GL.bindBuffer(GL.ARRAY_BUFFER, buffer);
GL.bufferData(GL.ARRAY_BUFFER, makeCube(), GL.STATIC_DRAW);
const { coord, index } = attributes;
GL.enableVertexAttribArray(coord);
GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 16, 0);
GL.enableVertexAttribArray(index);
GL.vertexAttribPointer(index, 1, GL.FLOAT, false, 16, 12);
GL.bindVertexArray(null);
return vao;
}
function compileShader(source, type) {
let glType = type;
if (type === 'vertex') { glType = GL.VERTEX_SHADER; }
else if (type === 'fragment') { glType = GL.FRAGMENT_SHADER; }
const shader = GL.createShader(glType);
GL.shaderSource(shader, source);
GL.compileShader(shader);
if (!GL.getShaderParameter(shader, GL.COMPILE_STATUS)) {
console.error(`SHADER TYPE ${type}`);
console.error(GL.getShaderInfoLog(shader));
return null;
}
return shader;
}
function makeCube() {
const v0 = [-1, -1, -1, 0];
const v1 = [-1, -1, 1, 1];
const v2 = [-1, 1, -1, 2];
const v3 = [-1, 1, 1, 3];
const v4 = [1, -1, -1, 4];
const v5 = [1, -1, 1, 5];
const v6 = [1, 1, -1, 6];
const v7 = [1, 1, 1, 7];
return Float32Array.from(
v0.concat(v1).concat(v2)
.concat(v1).concat(v2).concat(v3)
.concat(v0).concat(v1).concat(v4)
.concat(v1).concat(v4).concat(v5)
.concat(v0).concat(v2).concat(v4)
.concat(v2).concat(v4).concat(v6)
.concat(v7).concat(v3).concat(v6)
.concat(v3).concat(v6).concat(v2)
.concat(v7).concat(v5).concat(v3)
.concat(v5).concat(v3).concat(v1)
.concat(v7).concat(v5).concat(v6)
.concat(v5).concat(v6).concat(v4)
);
}
function buildProgram(vsSource, fsSource, attributes) {
const vs = compileShader(vsSource, 'vertex');
if (vs === null) { return null; }
const fs = compileShader(fsSource, 'fragment');
if (fs === null) { return null; }
const program = GL.createProgram();
for (const name in attributes) {
const index = attributes[name];
GL.bindAttribLocation(program, index, name);
}
GL.attachShader(program, vs);
GL.attachShader(program, fs);
GL.linkProgram(program);
if (!GL.getProgramParameter(program, GL.LINK_STATUS)) {
console.error(GL.getProgramInfoLog(program));
return null;
}
return program;
}
const VS_SRC = `#version 300 es
in vec3 coord;
in float index; // пока не убираем! (позже пригодится)
uniform vec3 axis;
uniform float angle;
uniform vec3 translation;
uniform float aspect;
// t = t.z e12 - t.y e13 + t.x e23 + t.w e
vec4 lapply(vec4 t, vec3 v) {
return vec4(t.w * v + cross(v, t.xyz), dot(v, t.xyz));
}
// v = v.x e1 + v.y e2 + v.z e3 + v.w e123
vec3 rapply(vec4 v, vec4 t) {
return t.w * v.xyz - v.w * t.xyz + cross(t.xyz, v.xyz);
}
vec3 rotate(vec3 coord, vec3 axis, float angle) {
axis = normalize(axis);
float cosine_half = cos(angle / 2.0);
float sine_half = sin(angle / 2.0);
vec4 rot_r = vec4(sine_half * axis, cosine_half);
vec4 rot_l = vec4(-rot_r.xyz, rot_r.w);
return rapply(lapply(rot_l, coord), rot_r);
}
void main() {
float scale = 2.3;
vec3 position = rotate(scale*coord, axis, angle) + translation;
gl_Position = vec4(position.x, position.y*aspect, -1, position.z);
}
`;
const FS_SRC = `#version 300 es
precision highp float;
out vec4 frag_color;
void main() {
frag_color = vec4(0.8, 0.5, 0.7, 1);
}
`;
Текстурирование
Для того, чтобы наложить рисунок на поверхность, нам потребуется этот самый рисунок.
В рамках браузера проще всего рисунки загрузить посредством img
-элементов.
Например, вставив в код страницы элемент вида
<img src="имя_файла" style="display:none" id="SOME_IMAGE">
Если лень искать картинку, можете воспользоваться кирпичами.
Общение с текстурами напоминает общение с буферами данных. А именно:
- нужно «создать» текстуру (методом
createTexture
) - дать ей роль (методом
bindTexture
) - перенести на неё изображение из оперативной памяти (методом
texImage2D
илиtexImage3D
, в зависимости от роли текстуры) - настроить её (методами
texParameteri
иtexParameterf
) - если нужно, сгенерировать MIP-карты (методом
generateMipmap
) - использовать в шейдере (при помощи
uniform
-переменной правильного типа)
Сейчас мы добавим в функцию настройки сцены следующий кусок:
const image = document.getElementById(`SOME_IMAGE`);
const texture = GL.createTexture();
GL.bindTexture(GL.TEXTURE_2D, texture);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGBA, GL.RGBA, GL.UNSIGNED_BYTE, image);
GL.generateMipmap(GL.TEXTURE_2D);
В вершинный шейдер — новую выходную переменную
out vec2 tex_coords;
и в конце main
вернём switch
(только немного другой):
switch (int(index)) {
case 0:
tex_coords = vec2(0, 0);
break;
case 1:
tex_coords = vec2(1, 0);
break;
case 2:
tex_coords = vec2(1, 0);
break;
case 3:
tex_coords = vec2(0, 0);
break;
case 4:
tex_coords = vec2(0, 1);
break;
case 5:
tex_coords = vec2(1, 1);
break;
case 6:
tex_coords = vec2(1, 1);
break;
case 7:
tex_coords = vec2(0, 1);
break;
default:
tex_coords = vec2(0, 0);
}
Во фрагментном же шейдере добавим две глобальные переменные:
in vec2 tex_coords;
uniform sampler2D tex;
и модифицируем main
следующим образом:
void main() {
frag_color = vec4(texture(tex, tex_coords).rgb, 1);
}
Опционально можно добавить в начало программы директиву
precision highp sampler2D;
которая может повысить качество отображения на некоторых мобильных устройствах.
Если теперь запустить то, что получилось, то мы увидим вращающийся куб, боковые стенки которого имеют на себе рисунок кирпича.
Внимание! Если Вы не видите вращающийся куб, загляните в
консоль браузера. Если после исправления всех ошибок/опечаток
Вы по прежнему ничего не видите, а бразуер ругается на origin
изображения, откройте страницу не с локальной файловой системы,
а запросом к веб-серверу. Проще всего в папке с файлами страницы
запустить стандартный интерпретатор Python с опцией -m http.server
.
Это приведёт к поднятию на порту 8000 файлового сервера,
выдающего файлы из той папки, из которой он был запущен. Соответственно,
страницу можно открыть, введя в адресной строке браузера
http://127.0.0.1:8000/как_там_оно_называется.html
Если файл называется index.html
, то можно и не указывать его имя:
питоновский файловый сервер по умолчанию выдаёт именно его.
Что произошло?
Мы создали текстуру:
const texture = GL.createTexture();
Дали ей роль TEXTURE_2D
:
GL.bindTexture(GL.TEXTURE_2D, texture);
Скопировали изображение в видеопамять:
GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGBA, GL.RGBA, GL.UNSIGNED_BYTE, image);
После чего автоматически сгенерировали MIP-карты:
GL.generateMipmap(GL.TEXTURE_2D);
Для того, чтобы объяснить, что это такое, нужно пояснить, как вообще работают текстуры в OpenGL.
Во фрагментном шейдере мы воспользовались функцией texture
,
которая, получив на вход объект типа sampler2D
и пару чисел,
называемых текстурными координатами, выдала четырёхмерный вектор
компонент цвета.
За то, как именно пикселям изображения сопоставляется этот вектор, отвечают в первую очередь следующие параметры:
TEXTURE_MAG_FILTER
TEXTURE_MIN_FILTER
TEXTURE_WRAP_S
TEXTURE_WRAP_T
которые можно настраивать методом texParameteri
.
Текстурные координаты — пара действительных чисел (горизонтальная называется S, вертикальная называется T).
Первым делом они загоняются в рамки от 0 до 1: за то, как это происходит,
отвечают параметры TEXTURE_WRAP_?
. Возможные значения:
REPEAT
(значение по-умолчанию) — координата берётся по модулю единицы (то есть вычисляется остаток от деления координаты на 1)CLAMP_TO_EDGE
— если координата меньше 0, она преобразуется в 0; если она больше 1, она преобразуется в 1MIRRORED_REPEAT
— координата берётся по модулю двойки, значения от 1 до 2 меняют знак и далее берутся по модулю 1
Далее по полученной паре чисел, находящихся в пределах от 0 до 1, вычисляется точка на текстуре: пара нулей соответствует левому верхнему углу изображения, пара единиц — правому нижнему углу.
Также неким (вообще говоря, зависящим от используемой видеокарты; подробности см. в стандарте OpenGL) образом определяется режим интерполяции: минификация или магнификация.
Грубо говоря, минификация выбирается, если переход от пикселя рисуемого изображения к соседнему по хотя бы из одному направлений вызывает изменение текстурных координат, соответствующее смещению более чем на один пиксель текстуры. В противном случае выбирается магнификация.
В зависимости от того, какой из режимов
был выбран, используется алгоритм, указанный в качестве значения параметра
TEXTURE_MAG_FILTER
(для магнификации) или же TEXTURE_MIN_FILTER
(для минификации). Основных алгоритмов два:
NEAREST
— выбирается цвет того пикселя текстуры, куда попала точка, заданная текстурными координатамиLINEAR
(по умолчанию) — берётся взвешенная сумма цветов пикселей квадрата 2x2, центр которого находится ближе всего к точке, заданной текстурными координатами
При минификации могут полностью теряться некоторые детали изображения. Для того, чтобы уменьшить потери, используется технология MIP (от лат. multum in parvo — «много в малом»).
По изображению строятся MIP-уровни — последовательность текстур, каждая следующая из которых по обоим размерам в два раза меньше предыдущей (если какой-то из размеров стал равен 1, то во всех последующих изображениях он остаётся равным 1). Стандартный способ построения MIP-уровня — изображения разбивается на непересекающиеся квадраты 2x2, в каждом из которых цвет осредняется.
Если хочется воспользоваться MIP, нужно сгенерировать MIP-уровни. Это
можно сделать как вручную при помощи texImage2D
со вторым входом,
отличным от нуля, так и автоматически — при помощи
функции generateMipmap
.
Для MIP доступно четыре возможных алгоритма минификации:
NEAREST_MIPMAP_NEAREST
— выбирается наиболее подходящий MIP-уровень, в рамках него применяется алгоритмNEAREST
LINEAR_MIPMAP_NEAREST
— выбирается наиболее подходящий MIP-уровень, в рамках него применяется алгоритмLINEAR
NEAREST_MIPMAP_LINEAR
(по умолчанию) — выбирается пара наиболее подходящих MIP-уровней, в каждом из них применяется алгоритмNEAREST
, затем между полученными значениями происходит линейная интерполяцияLINEAR_MIPMAP_LINEAR
— выбирается пара наиболее подходящих MIP-уровней, в каждом из них применяется алгоритмLINEAR
, затем между полученными значениями происходит линейная интерполяция; этот способ ещё называют трилинейной фильтрацией
В заключение ещё раз повторим основные этапы настройки текстуры:
- выбрать способ преобразования текстурных координат в диапазон от 0 до 1
- выбрать алгоритмы интерполяции значений при магнификации и минификации
- если при минификации используется MIP, нужно вызвать функцию
generateMipmap
Также отметим, что больше всего на нарисованное изображение влияет магнификационный фильтр (при этом он не особо влияет на скорость рисования). Его следует выбирать, исходя исключительно из эстетических соображений.
А вот минификационный фильтр рекомендуется выбирать из MIP-вариантов: это всего лишь на треть увеличивает потребление памяти, но значительно повышает скорость рисования маленьких или далёких от глаза объектов. Почти единственное исключение: длинные объекты, направленные вдаль (например, дорога с разметкой, уходящая к горизонту). В этом случае правильно было бы использовать в направлении взгляда больший MIP-уровень (т.е. с меньшим разрешением), чем в направлении поперёк взгляда (это называется анизотропной фильтрацией), но такой режим работы поддерживают далеко не все устройства. В отсутствии же анизотропной фильтрации наиболее адекватное изображение в такой ситуации будут давать минификационные фильтры, не использующие MIP (потеря части деталей в направлении взгляда менее заметна, чем размытие в направлении поперёк взгляда).
Кривые пол и потолок куба
На нашем кубе две грани отображаются странно (выбранные нами текстурные координаты, очевидно, не подходят для этих граней). Вместо того, чтобы для каждой вершины каждого треугольника передавать именно её текстурные координаты, воспользуемся дешёвым трюком: изменим фрагментный шейдер на следующий
#version 300 es
precision highp float;
precision highp sampler2D;
out vec4 frag_color;
in float cube_x;
in vec2 tex_coords;
uniform sampler2D tex;
void main() {
vec3 surface_color =
abs(cube_x) > 0.999
? vec3(0.7, 0.7, 0.7)
: texture(tex, tex_coords).rgb;
frag_color = vec4(surface_color, 1);
}
а в вершинный добавим глобальную переменную
out float cube_x;
которую внутри main
сделаем равной coord.x
.
Обратите внимание на > 0.999
вместо == 1.0
. Последнее равенство,
как ни странно, достигается лишь изредка, даже несмотря на то, что
интерполяция производится между тремя единицами. Арифметика с плавающей
точкой — странная вещь (и три числа, которые по идее должны
давать в сумме единицу, не всегда её дают).
Рассеянный свет
Сейчас наш куб выглядит так, как будто она равномерно освещён со всех сторон: такое освещение называется фоновым (английский термин — ambient).
Достигается фоновое освещение банально умножением каждой компоненты цвета пикселя на какое-нибудь число.
Сейчас мы добавим к фоновому освещению рассеянное: его интенсивность в точке поверхности пропорциональна косинусу угла между нормалью к поверхности в этой точке и лучом, направленным от точки к источнику света.
Заодно мы введём в нашу программу вторую текстуру и воспользуемся ей как текстурой данных. А именно, при помощи неё мы передадим в программу массив всех источников освещения.
Начнём как раз с создания второй текстуры:
const lights = GL.createTexture();
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.R32F, 4, 2, 0, GL.RED, GL.FLOAT,
Float32Array.from([
4, 3, -5, 130,
-4, 1, 3, 20,
])
);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MAG_FILTER, GL.NEAREST);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.NEAREST);
Метод activeTexture
выбирает текущую текстуру (с которой затем
работают bindTexture
, texImage2D
, texParameteri
и прочие
подобные функции). Всего доступно не менее 32 текстур (с номерами от
TEXTURE0
до TEXTURE31
).
Далее мы записываем данные в память видеокарты, выбрав формат хранения
R32F
— в видеопамяти хранится только красная компонента
32-битным числом с плавающей точкой.
Формат же данных в оперативной памяти задаётся парой RED/FLOAT
(означает эта пара по сути то же, что и R32F
).
Числа 4 и 2 — ширина и высота текстуры. В каждой строчке мы храним координаты источника света и его яркость.
Также, в отличие от рисунка поверхности, мы вынуждены переключить
режимы минификации и магнификации на NEAREST
: другие режимы
текстурами формата R32F
без MIP-карт не поддерживаются.
Модифицируем теперь вершинный шейдер, добавив в него
out vec3 position;
и убрав слово vec3
из соответствующей строчки функции main
.
Фрагментный же шейдер приведём целиком, так как в нём много изменений:
#version 300 es
precision highp float;
precision highp sampler2D; // обязательно!!!
out vec4 frag_color;
in float cube_x;
in vec3 position;
in vec2 tex_coords;
uniform sampler2D tex;
uniform sampler2D lights;
vec3 calculate_normal() {
vec3 dir1 = dFdx(position);
vec3 dir2 = dFdy(position);
return normalize(cross(dir2, dir1));
}
void main() {
vec3 surface_color =
abs(cube_x) > 0.999
? vec3(0.7, 0.7, 0.7)
: texture(tex, tex_coords).rgb;
float intensity = 0.3; // фоновая интенсивность
vec3 normal = calculate_normal();
ivec2 lights_size = textureSize(lights, 0);
for (int i = 0; i < lights_size.y; i++) {
vec3 light_pos = vec3(
texelFetch(lights, ivec2(0, i), 0).r,
texelFetch(lights, ivec2(1, i), 0).r,
texelFetch(lights, ivec2(2, i), 0).r
);
float light_intensity = texelFetch(lights, ivec2(3, i), 0).r;
// направление от точки поверхности на источник света:
vec3 direction = light_pos - position;
float normal_direction = dot(normal, direction);
if (normal_direction > 0.0) {
float inv_distance = inversesqrt(dot(direction, direction));
intensity +=
light_intensity
* normal_direction
* pow(inv_distance, 3.0);
}
}
frag_color = vec4(intensity*surface_color, 1);
}
Прокомментируем некоторые его части:
- в нём появилась входная переменная
in vec3 position
— это в точности координаты рисуемого пикселя в пространстве - также появилась вторая текстура
uniform sampler2D lights
— из управляющей программы нужно не забыть в этот uniform выставить номер текстуры, который у нас равен 1; вtex
же по умолчанию выставлен 0 (но можно и явно его выставить — это не занимает много времени) - функция
calculate_normal
вычисляет нормаль к поверхности в точке: она выбирает два независимых вектора, направленные в плоскости поверхности, вычисляя производные положения пикселя относительно экранных координат, затем считает их векторное произведение, автоматически получая вектор внешней нормали (если перемножить их в правильном порядке) - наконец, основной цикл забирает из каждой текстуры соответствующие
числа по их целочисленным координатам функцией
texelFetch
- длина вектора
direction
пропорциональна расстоянию до источника света; соответственно, итоговая интенсивность источника обратно пропорциональна квадрату расстояния — это физически правильная, но не слишком реалистично выглядящая модель (поскольку в реальности в точку приходит ещё свет от того же источника, отражённый от близлежащих поверхностей)
Для получения работоспособной программы осталось не забыть получить из
вершинной-фрагментной программы номер uniform-переменной lights
и
при рисовании кадра записать в эту переменную правильное значение командой
// обратите внимание на i
// |
// /------------/
// |
// V
GL.uniform1i(uniforms.lights, 1);
Кроме точечных источников света часто рассматривают «направленные» — для них вектор направления света один и тот же для всех точек освещаемой поверхности.
В реальности примерно так работают всевозможные далёкие очень мощные источники света типа солнца, луны или же прожекторов.
В качестве полезного упражнения рекомендуем добавить в программу поддержку таких источников.
Массив текстур и нормальные карты
А сейчас мы продемонстрируем ещё один способ использования нескольких
текстур — массив текстур. Все текстуры в массиве должны быть одного
размера, зато их может быть весьма много (верхний предел обычно не менее 2048),
и занимает массив ровно один номер текстуры (тот номер, который —
вход activeTexture
).
Заодно мы покажем такую технику, как карта нормалей. Карта нормалей — текстура, которая используется для построения нормалей к поверхности, отличных от нормали к треугольнику. Карты нормалей позволяют значительно повысить детализацию изображения, не усложняя его геометрию.
Для упрощения вычислений мы воспользуемся не полноценной картой нормалей, а картой высот. Предлагаем воспользоваться вот этой картинкой.
Сначала модифицируем наш код под использование массива текстур.
Первым делом нужно заменить в HTML-файле ссылку на изображение. Далее вместо
GL.bindTexture(GL.TEXTURE_2D, texture);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGBA, GL.RGBA, GL.UNSIGNED_BYTE, image);
GL.generateMipmap(GL.TEXTURE_2D);
нужно написать
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
// здесь --vv стоит 3D вместо 2D
GL.texImage3D(GL.TEXTURE_2D_ARRAY,
0, GL.RGBA,
image.width, image.height/2, 2, 0,
GL.RGBA, GL.UNSIGNED_BYTE, image);
GL.generateMipmap(GL.TEXTURE_2D_ARRAY);
Наконец, в начало фрагментного шейдера нужно добавить строчку
precision highp sampler2DArray;
поменять тип переменной tex
с sampler2D
на sampler2DArray
, а
формулу
texture(tex, tex_coords).rgb
поменять на
texture(tex, vec3(tex_coords, 0)).rgb
после чего всё должно заработать в точности как и прежде.
Наконец, введём подсчёт нормали. Приведём здесь новую половину кода фрагментного шейдера:
#version 300 es
precision highp float;
precision highp sampler2D;
precision highp sampler2DArray;
out vec4 frag_color;
in float cube_x;
in vec3 position;
in vec2 tex_coords;
uniform sampler2DArray tex;
uniform sampler2D lights;
// далее мы добавим внешнее управление выраженностью выпуклостей:
uniform float bump;
vec3 calculate_normal(bool top_bottom) {
vec3 dir1 = dFdx(position);
vec3 dir2 = dFdy(position);
vec3 geometry_normal = normalize(cross(dir2, dir1));
// чтобы не портило монотонно серые верх и низ
if (top_bottom) { return geometry_normal; }
// новая часть
float dheight_dx = bump * dFdx(texture(tex, vec3(tex_coords, 1)).r);
float dheight_dy = bump * dFdy(texture(tex, vec3(tex_coords, 1)).r);
vec3 mdir1 = dir1 + geometry_normal * dheight_dx;
vec3 mdir2 = dir2 + geometry_normal * dheight_dy;
return normalize(cross(mdir2, mdir1));
}
void main() {
bool top_bottom = abs(cube_x) > 0.999;
vec3 surface_color =
top_bottom
? vec3(0.7, 0.7, 0.7)
: texture(tex, vec3(tex_coords, 0)).rgb;
float intensity = 0.3; // фоновая интенсивность
vec3 normal = calculate_normal(top_bottom);
... // ДАЛЕЕ ВСЁ БЕЗ ИЗМЕНЕНИЙ
}
Наконец, добавим на страницу слайдер:
<div><input id="BUMP_CONTROL" type="range" min="0" max="0.7" step="0.1"></div>
В onload
добавим
const bumpControl = document.getElementById(`BUMP_CONTROL`);
а в функцию рисовния кадра
GL.uniform1f(uniforms.bump, parseFloat(bumpControl.value));
не забыв добавить bump
в список имён uniform-переменных
в функции setupScene
.