Отражения и тени
Этот раздел посвящён кубическим текстурам.
Кубические текстуры представляют собой шестёрку изображений, цвета из которых получаются следующим образом:
- запрос к кубической текстуре осуществляется при помощи трёхмерного вектора
- луч, проведённый из нуля координат в направлении этого вектора, пересекается с кубом, центрированным в нуле координат и ориентированным по осям координат
- результирующий цвет получается таким, каким бы он был в соответствующей точке куба, если бы на грани этого куба были натянуты вышеупомянутые шесть изображений
Мы покажем два интересных применения кубических текстур:
- отражения (не очень точные, но неплохо выглядящие)
- тени (точные, но не очень качественные)
Программа-заготовка
Начнём со следующей программы:
let GL = null;
onload = () => {
const canvas = document.getElementById(`CANVAS`);
canvas.width = 600;
canvas.height = 600;
GL = canvas.getContext(`webgl2`);
let angleX = 0;
let tanY = 0;
let angleY = 0;
const camSpeed = 0.003;
const camBoundY = 0.9;
canvas.onclick = event => {
canvas.requestPointerLock();
};
canvas.onmousemove = event => {
if (document.pointerLockElement !== canvas) { return; }
angleX += event.movementX*camSpeed;
tanY -= event.movementY*camSpeed / camBoundY;
if (tanY < -3) { tanY = -3; }
else if (tanY > 3) { tanY = 3; }
angleY = Math.atan(tanY) * camBoundY;
};
const { room, teapot } = setupScene();
const renderFrame = time => {
const aspect = processResize();
GL.clearColor(0, 0, 0, 1);
GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);
const cameraPos = [
2*Math.sin(angleX)*Math.cos(angleY),
2*Math.sin(angleY),
-2*Math.cos(angleX)*Math.cos(angleY)
];
const teapotPos = [0.8*Math.sin(time/1300), 0, 0];
room.begin();
GL.uniform1f(room.uniforms.aspect, aspect);
GL.uniform1f(room.uniforms.size, 3.5);
GL.uniform3f(room.uniforms.camera_pos, ...cameraPos);
room.draw();
teapot.begin()
GL.uniform1f(teapot.uniforms.aspect, aspect);
GL.uniform1f(teapot.uniforms.size, 0.27);
GL.uniform3f(teapot.uniforms.camera_pos, ...cameraPos);
GL.uniform3f(teapot.uniforms.axis,
Math.cos(time/3000), 2*Math.cos(time/1700), 3
);
GL.uniform1f(teapot.uniforms.angle, time/1000);
GL.uniform3f(teapot.uniforms.pos, ...teapotPos);
teapot.draw();
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 setupLights() {
const lights = GL.createTexture();
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.R32F, 4, 2, 0, GL.RED, GL.FLOAT,
Float32Array.from([
1, 1.5, -1, 7,
-1, 1, 1, 5,
])
);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MAG_FILTER, GL.NEAREST);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.NEAREST);
return lights;
}
function setupRoom(lights) {
const attributes = {
coord: 0,
tex_coords: 1,
};
const {
program,
uniforms
} = buildProgram(ROOM_VS, ROOM_FS, attributes,
['camera_pos', 'aspect', 'size', 'tex', 'lights']
);
const vao = createCubeVAO(attributes);
const image = document.getElementById(`SOME_IMAGE`);
const texture = GL.createTexture();
const numImages = image.height / image.width;
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
GL.texImage3D(GL.TEXTURE_2D_ARRAY,
0, GL.RGBA,
image.width,
image.width,
numImages, 0,
GL.RGBA, GL.UNSIGNED_BYTE, image);
GL.generateMipmap(GL.TEXTURE_2D_ARRAY);
const begin = () => {
GL.useProgram(program);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.bindVertexArray(vao);
GL.uniform1i(uniforms.tex, 0);
GL.uniform1i(uniforms.lights, 1);
};
const draw = () => {
GL.drawArrays(GL.TRIANGLES, 0, 36);
};
return { uniforms, begin, draw };
}
function setupTeapot(lights) {
const attributes = {
coord: 0,
normal: 1,
};
const {
program,
uniforms
} = buildProgram(TEAPOT_VS, TEAPOT_FS, attributes,
['camera_pos', 'aspect', 'size', 'pos', 'axis', 'angle', 'lights']
);
const vao = createTeapotVAO(attributes);
const begin = () => {
GL.useProgram(program);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.bindVertexArray(vao);
GL.uniform1i(uniforms.lights, 0);
};
const draw = () => {
GL.drawArrays(GL.TRIANGLES, 0, TEAPOT_DATA.length / 6);
};
return { uniforms, begin, draw };
}
function setupScene() {
GL.enable(GL.DEPTH_TEST);
const lights = setupLights();
return {
room: setupRoom(lights),
teapot: setupTeapot(lights),
};
}
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, tex_coords } = attributes;
GL.enableVertexAttribArray(coord);
GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 24, 0);
GL.enableVertexAttribArray(tex_coords);
GL.vertexAttribPointer(tex_coords, 3, GL.FLOAT, false, 24, 12);
GL.bindVertexArray(null);
return vao;
}
function createTeapotVAO(attributes) {
const vao = GL.createVertexArray();
GL.bindVertexArray(vao);
const buffer = GL.createBuffer();
GL.bindBuffer(GL.ARRAY_BUFFER, buffer);
GL.bufferData(
GL.ARRAY_BUFFER, Float32Array.from(TEAPOT_DATA), GL.STATIC_DRAW
);
const { coord, normal } = attributes;
GL.enableVertexAttribArray(coord);
GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 24, 0);
GL.enableVertexAttribArray(normal);
GL.vertexAttribPointer(normal, 3, GL.FLOAT, false, 24, 12);
GL.bindVertexArray(null);
return vao;
}
function makeCube() {
// геометрические координаты
const v0 = [-1, -1, -1];
const v1 = [-1, -1, 1];
const v2 = [-1, 1, -1];
const v3 = [-1, 1, 1];
const v4 = [1, -1, -1];
const v5 = [1, -1, 1];
const v6 = [1, 1, -1];
const v7 = [1, 1, 1];
// текстурные координаты
const t0 = [0, 0, 0];
const t1 = [2, 0, 0];
const t2 = [0, 2, 0];
const t3 = [2, 2, 0];
const t4 = [0, 0, 2];
const t5 = [0, 2, 2];
const t6 = [2, 0, 2];
const t7 = [2, 2, 2];
return Float32Array.from([]
.concat(v0).concat(t4).concat(v1).concat(t5).concat(v2).concat(t6)
.concat(v1).concat(t5).concat(v2).concat(t6).concat(v3).concat(t7)
.concat(v0).concat(t0).concat(v1).concat(t1).concat(v4).concat(t2)
.concat(v1).concat(t1).concat(v4).concat(t2).concat(v5).concat(t3)
.concat(v0).concat(t0).concat(v2).concat(t1).concat(v4).concat(t2)
.concat(v2).concat(t1).concat(v4).concat(t2).concat(v6).concat(t3)
.concat(v7).concat(t2).concat(v3).concat(t0).concat(v6).concat(t3)
.concat(v3).concat(t0).concat(v6).concat(t3).concat(v2).concat(t1)
.concat(v7).concat(t2).concat(v5).concat(t3).concat(v3).concat(t0)
.concat(v5).concat(t3).concat(v3).concat(t0).concat(v1).concat(t1)
.concat(v7).concat(t7).concat(v5).concat(t5).concat(v6).concat(t6)
.concat(v5).concat(t5).concat(v6).concat(t6).concat(v4).concat(t4)
);
}
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 buildProgram(vsSource, fsSource, attributes, uniformNames) {
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;
}
const uniforms = {};
for (const name of uniformNames) {
uniforms[name] = GL.getUniformLocation(program, name);
}
return { program, uniforms };
}
/*
Алгебра тензоров в базисе e23, -e13, e12,
противоположном стандартному кватернионному.
Везде:
t = t.x e23 - t.y e13 + t.z e12 + t.w e
v = v.x e1 + v.y e2 + v.z e3 + v.w e123
*/
const TENSOR_ALGEBRA = `
vec4 tmul(vec4 t1, vec4 t2) {
return vec4(
t1.w * t2.xyz + t1.xyz * t2.w - cross(t1.xyz, t2.xyz),
t1.w * t2.w - dot(t1.xyz, t2.xyz)
);
}
vec4 lapply(vec4 t, vec3 v) {
return vec4(t.w * v + cross(v, t.xyz), dot(v, t.xyz));
}
vec3 rapply(vec4 v, vec4 t) {
return t.w * v.xyz - v.w * t.xyz + cross(t.xyz, v.xyz);
}
vec4 rotor(vec3 axis, float angle) {
axis = normalize(axis);
float cosine_half = cos(angle / 2.0);
float sine_half = sin(angle / 2.0);
return vec4(sine_half * axis, cosine_half);
}
vec3 rotate(vec4 t, vec3 v) {
return rapply(lapply(vec4(-t.xyz, t.w), v), t);
}
vec3 rotate(vec3 coord, vec3 axis, float angle) {
return rotate(rotor(axis, angle), coord);
}
vec3 apply_camera(vec3 v) {
vec4 camera_rot = tmul(
rotor(vec3(0, 1, 0), atan(camera_pos.x, -camera_pos.z)),
rotor(vec3(1, 0, 0), atan(-camera_pos.y, length(camera_pos.xz)))
);
return rotate(camera_rot, v - camera_pos);
}
`;
const LIGHT_CALCULATION = `
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);
}
}
`;
const ROOM_VS = `#version 300 es
in vec3 coord;
in float index;
in vec3 tex_coords;
out vec3 frag_tex_coords;
out vec3 position;
uniform float size;
uniform float aspect;
uniform vec3 camera_pos;
${TENSOR_ALGEBRA}
void main() {
vec3 axis = vec3(0, 0, 1);
float angle = radians(90.0);
position = rotate(size*coord, axis, angle);
vec3 cam_position = apply_camera(position);
gl_Position = vec4(
cam_position.x, cam_position.y*aspect, -0.1, cam_position.z
);
frag_tex_coords = tex_coords;
}
`;
const ROOM_FS = `#version 300 es
precision highp float;
precision highp sampler2D;
precision highp sampler2DArray;
out vec4 frag_color;
in vec3 position;
in vec3 frag_tex_coords;
uniform sampler2DArray tex;
uniform sampler2D lights;
vec3 calculate_normal() {
vec3 dir1 = dFdx(position);
vec3 dir2 = dFdy(position);
vec3 geometry_normal = normalize(cross(dir2, dir1));
float bump = 0.7;
float dheight_dx = bump * dFdx(
texture(tex, frag_tex_coords + vec3(0,0,1)).r
);
float dheight_dy = bump * dFdy(
texture(tex, frag_tex_coords + vec3(0,0,1)).r
);
vec3 mdir1 = dir1 + geometry_normal * dheight_dx;
vec3 mdir2 = dir2 + geometry_normal * dheight_dy;
return normalize(cross(mdir2, mdir1));
}
void main() {
vec3 surface_color = texture(tex, frag_tex_coords).rgb;
${LIGHT_CALCULATION}
frag_color = vec4(intensity*surface_color, 1);
}
`;
const TEAPOT_VS = `#version 300 es
in vec3 coord;
in vec3 normal;
out vec3 position;
out vec3 frag_normal;
uniform float size;
uniform float aspect;
uniform vec3 pos;
uniform vec3 axis;
uniform float angle;
uniform vec3 camera_pos;
${TENSOR_ALGEBRA}
void main() {
position = rotate(size*(coord - vec3(0,1.2,0)), axis, angle) + pos;
frag_normal = rotate(normal, axis, angle);
vec3 cam_position = apply_camera(position);
gl_Position = vec4(
cam_position.x, cam_position.y*aspect, -0.1, cam_position.z
);
}
`;
const TEAPOT_FS = `#version 300 es
precision highp float;
precision highp sampler2D;
out vec4 frag_color;
in vec3 position;
in vec3 frag_normal;
uniform sampler2D lights;
uniform vec3 camera_pos;
vec3 calculate_normal() {
return normalize(frag_normal);
}
void main() {
vec3 surface_color = vec3(0.3, 0.6, 0.1);
${LIGHT_CALCULATION}
frag_color = vec4(intensity*surface_color, 1);
}
`;
Для работы ей понадобится:
- текстура стен и пола комнаты — этот
файл нужно вставить в HTML-страничку в
img
-элемент с идентификаторомSOME_IMAGE
- трёхмерная геометрия чайника — этот файл нужно
загрузить при помощи тега
script
Эта программа несколько отличается от той, что предлагалось Вам сделать в прошлом разделе. Перечислим наиболее значимые отличия.
Камера
Первое, что бросается в глаза, — управление наблюдателем (или камерой, как этого наблюдателя традиционно называют), взгляд которого направлен в нуль координат.
Оно достигается засчёт uniform-переменной camera_pos
, в которой хранится
положение камеры.
Визуализация мира через камеру устроена весьма просто: произвести некоторые движения камеры — то же самое, что произвести движения мира, обратные им, в обратном порядке.
То есть, например,
- подвинуть камеру вперёд на 3 метра
- повернуть камеру направо на 30 градусов
- повернуть камеру вверх на 40 градусов
всё равно, что
- повернуть мир вниз на 40 градусов
- повернуть мир налево на 30 градусов
- подвинуть мир назад на 3 метра
Соответствующий набор действий производится в функции apply_camera
всех вершинных шейдеров.
Чуть больше тензорной алгебры
Как можно заметить, функция apply_camera
определена внутри некоторой
общей для всех используемых нами вершинных шейдеров части, названной
TENSOR_ALGEBRA
.
В этой части функция поворота rotate
разделена на две:
- генерирующую тензор поворота
- применяющую тензор поворота
Старая версия функции поворота тоже оставлена со старым же названием (GLSL поддерживает перегрузку функций), но теперь она выражена через новую функцию.
Также добавлено явное произведение тензоров смешанного 0 и 2 ранга,
некоторые отголоски которого просматривались в lapply
и rapply
.
В настоящем примере оно излишне (хотя мы и показываем, как его можно использовать), но при более длинных композициях поворотов (и особенно — при анимации длинных сочленений, в которых каждое следующее звено повёрнуто относительно предыдущего) оно позволяет уменьшить общее количество вычислений.
Более прямолинейная процедура сборки шейдеров
Теперь не нужно после сборки программы запрашивать у этой программы номера uniform-переменных. Это делает сама функция сборки программы, возвращая в качестве результата работы не только программу, но и словарь индексов uniform-переменных.
Упрощённая процедура рисования
Теперь заключается в том, чтобы вызвать метод begin
соответствующей
части геометрии, выставить значения uniform-переменных и вызвать
метод draw
.
Сами методы begin
и draw
определены для каждого вида геометрии
в соответствующей функции с названием вида setupТИП_ГЕОМЕТРИИ
(конкретно setupRoom
и setupTeapot
).
У куба теперь есть полноценные пол и потолок
И этим всё сказано.
Изменена ближняя плоскость обрезания
Сейчас во всех вершинных шейдерах в качестве z
-компоненты всех вершин
стоит не -1
, а -0.1
. Соответственно, мы не видим лишь те точки, которые
находятся на удалении, меньшем 0.1
от нас.
Визуализация зеркальной поверхности
Для визуализации отражений можно применять следующую методологию:
- всё окружение отражающего объекта рисуется с нескольких ракурсов так, чтобы эти ракурсы захватывали всевозможные направления
- нарисованное окружение записывается в кубическую текстуру
- при расчёте цвета пикселя отражающей поверхности выпускается луч из глаза наблюдателя, отражается от поверхности и результирующее направление запрашивается у кубической текстуры
Такой результат не слишком точен для точек поверхности, далёких от той, из которой делались рисунки окружения, но в том случае, когда отражающий объект достаточно мал по сравнению с размерами окружения, отражение получается довольно адекватным.
Буфер кадра
Для того, чтобы рисовать изображение не на холст, а в какую-нибудь текстуру, нужно создать т.н. буфер кадра:
const framebuffer = GL.createFramebuffer();
GL.bindFramebuffer(GL.FRAMEBUFFER, framebuffer);
// какая-то настройка
GL.bindFramebuffer(null); // НЕ ЗАБЫВАТЬ!
Метод bindFramebuffer
имеет двоякий эффект:
- инициализирует соответствующий буфер кадра
- выбирает этот буфер кадра в качестве того, на который рисуют методы
наподобие
drawArrays
Поэтому после настройки буфера кадра нужно не забыть снять с него выбор.
Есть очень важный момент: перед рисованием на буфере кадра
необходимо выполнить метод viewport
, в который передать размеры буфера
кадра (выраженные в пикселях).
Ещё более важно не забыть опять вызвать viewport
с размерами
холста после снятия выбора с буфера кадра.
Для того, чтобы появилась возможность на буфер кадра рисовать, нужно прикрепить к нему текстуры, на которые будет сохраняться нарисованное изображение. Эти текстуры бывают двух видов:
- обычная двумерная или кубическая текстура (прикрепляется методом
framebufferTexture2D
) - так называемый
Renderbuffer
— его нельзя использовать в качестве текстуры, но рисование на него более эффективно (прикрепляется методомframebufferRenderbuffer
)
Соответственно, выбор между Renderbuffer
и обычной текстурой довольно
очевиден:
- ту часть картинки, которая нам понадобится в дальнейшем, нужно рисовать в обычную текстуру
- ту часть картинки, которая нужна только в процессе рисования на
настоящий буфер кадра, нужно рисовать в
Renderbuffer
В нашем случае отражений цвет окружения нам понадобится при рисовании отражающей поверхности, а вот массив глубин пикселей окружения нам совершенно бесполезен.
Соответственно, setupTeapot
с учётом вышесказанного примет следующий вид:
function setupTeapot(lights) {
const attributes = { coord: 0, normal: 1, };
const {
program,
uniforms
} = buildProgram(TEAPOT_VS, TEAPOT_FS, attributes, [
'camera_pos', 'aspect', 'size', 'pos', 'axis', 'angle',
'lights', 'reflections'
]);
const vao = createTeapotVAO(attributes);
const reflections = GL.createTexture();
const resolution = 1024;
GL.bindTexture(GL.TEXTURE_CUBE_MAP, reflections);
GL.texStorage2D(GL.TEXTURE_CUBE_MAP, 1, GL.RGBA8, resolution, resolution);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_MIN_FILTER, GL.LINEAR);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_WRAP_S, GL.CLAMP_TO_EDGE);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_WRAP_T, GL.CLAMP_TO_EDGE);
const framebuffer = GL.createFramebuffer();
GL.bindFramebuffer(GL.FRAMEBUFFER, framebuffer);
const depthbuffer = GL.createRenderbuffer();
GL.bindRenderbuffer(GL.RENDERBUFFER, depthbuffer);
GL.renderbufferStorage(
GL.RENDERBUFFER, GL.DEPTH_COMPONENT24, resolution, resolution
);
GL.framebufferRenderbuffer(
GL.FRAMEBUFFER, GL.DEPTH_ATTACHMENT, GL.RENDERBUFFER, depthbuffer
);
GL.bindFramebuffer(GL.FRAMEBUFFER, null);
const begin = () => {
GL.useProgram(program);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_CUBE_MAP, reflections);
GL.bindVertexArray(vao);
GL.uniform1i(uniforms.lights, 0);
GL.uniform1i(uniforms.reflections, 1);
};
const draw = () => {
GL.drawArrays(GL.TRIANGLES, 0, TEAPOT_DATA.length / 6);
};
return { uniforms, begin, draw, framebuffer, reflections, resolution };
}
Обратите внимание на следующее:
- настройки кубической текстуры — у нас нет MIP-уровней, поскольку мы
перерисовываем текстуру каждый кадр, что влечёт необходимость нестандартного
минификационного фильтра; также для кубических текстур
настоятельно рекомендуется режим
CLAMP_TO_EDGE
по всем координатам (без него на рёбрах куба могут возникнуть артефакты) - то, что пока мы не привязываем текстуру
reflections
к цветовой компоненте буфера кадра — это мы будем делать по 6 раз уже в процессе рисования сцены - ОЧЕНЬ ВАЖНО! при рисовании в текстуру текстурные координаты \((0,0)\) окажутся у левого нижнего угла изображения. А используются текстуры обычно в предположении, что соответствующий угол левый верхний. Поэтому в большинстве случаев имеет смысл сразу рисовать в текстуру изображение, перевёрнутое по вертикали.
Ракурсы
Теперь нам нужно добавить возможность рисовать сцену в шести ракурсах:
- смотря вперёд
- смотря назад
- смотря вправо
- смотря влево
- смотря вверх
- смотря вниз
Добавим во все вершинные шейдеры новую переменную uniform int camera_dir
(не забыв также её добавить в setup
соответствующих объектов) и
apply_camera
заменим на
vec3 apply_camera(vec3 v) {
vec4 camera_rot = camera_dir == 0 ? tmul(
rotor(vec3(0, 1, 0), atan(camera_pos.x, -camera_pos.z)),
rotor(vec3(1, 0, 0), atan(-camera_pos.y, length(camera_pos.xz)))
) : vec4(0,0,0,1);
vec3 result = rotate(camera_rot, v - camera_pos);
switch (camera_dir) {
// Все ракурсы перевёрнуты по y!
case 1:
result.y = -result.y;
break;
case 2:
result = -result;
break;
case 3:
result = vec3(-result.zy, result.x);
break;
case 4:
result = vec3(result.z, -result.yx);
break;
case 5:
result.zy = result.yz;
break;
case 6:
result.zy = -result.yz;
break;
}
return result;
}
Если camera_dir
отличен от 0, то камера будет направлена не к нулю координат,
а в одном из вышеописанных шести направлений.
Также, из-за того, что теперь мы рисуем при camera_dir
перевёрнутое
изображение, необходимо модифицировать функцю calculate_normal
фрагментного
шейдера комнаты. А именно, её последнюю строчку заменить на
return normalize(camera_dir > 0
? cross(mdir1, mdir2)
: cross(mdir2, mdir1)
);
добавив также в начало фрагментного шейдера декларацию
precision highp int;
и
uniform int camera_dir;
Теперь можно и нарисовать все 6 ракурсов. Для этого непосредственно
перед кодом, рисующим чайник, вместо той части, которая рисует
комнату, добавим вызов функции drawEnvironment
, определённой так:
function drawEnvironment(room, teapot, aspect, cameraPos, teapotPos) {
room.begin();
GL.uniform1f(room.uniforms.aspect, aspect);
GL.uniform1f(room.uniforms.size, 3.5);
GL.uniform3f(room.uniforms.camera_pos, ...cameraPos);
GL.uniform1i(room.uniforms.camera_dir, 0);
room.draw();
const directionToFace = {
1: GL.TEXTURE_CUBE_MAP_POSITIVE_Z,
2: GL.TEXTURE_CUBE_MAP_NEGATIVE_Z,
3: GL.TEXTURE_CUBE_MAP_POSITIVE_X,
4: GL.TEXTURE_CUBE_MAP_NEGATIVE_X,
5: GL.TEXTURE_CUBE_MAP_POSITIVE_Y,
6: GL.TEXTURE_CUBE_MAP_NEGATIVE_Y,
};
GL.bindFramebuffer(GL.FRAMEBUFFER, teapot.framebuffer);
GL.viewport(0, 0, teapot.resolution, teapot.resolution);
for (let i = 1; i <= 6; i++) {
GL.uniform1f(room.uniforms.aspect, 1); // тут единица!
GL.uniform3f(room.uniforms.camera_pos, ...teapotPos);
GL.uniform1i(room.uniforms.camera_dir, i);
GL.framebufferTexture2D(GL.FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
directionToFace[i], teapot.reflections, 0);
GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);
room.draw();
}
GL.bindFramebuffer(GL.FRAMEBUFFER, null);
GL.viewport(0, 0, GL.canvas.width, GL.canvas.height);
}
Осталось в процедуру рисования чайника не забыть дописать
GL.uniform1i(teapot.uniforms.camera_dir, 0);
Во фрагментном шейдере чайника дописать декларацию
precision highp samplerCube;
определить переменную
uniform samplerCube reflections;
и, наконец, модифицировать расчёт цвета пикселей поверхности чайника следующим образом:
float alpha = 0.3;
vec3 to_camera = camera_pos - position;
vec3 reflected = 2.0*dot(normal, to_camera)*normal - to_camera;
vec3 reflected_color = texture(reflections, reflected).rgb;
frag_color = vec4(
alpha*intensity*surface_color +
(1.0-alpha)*reflected_color,
1);
Число alpha
отвечает за то, какую долю от цвета составляет рассеянный свет,
а какую — отражённый.
Расчёт теней
Похожую технику можно применить для расчёта теней от источников освещения.
А именно, для каждого точечного источника можно нарисовать сцену в 6 ракурсах, центрированных в этом источнике, с тривиальным фрагментным шейдером. Наша задача — лишь заполнить глубинный буфер информацией о расстояниях от источника света до освещаемых им поверхностей.
Затем при рисовании итоговой картинки нужно по отдельности учесть каждый из этих источников света, а результирующие цвета правильно смешать.
Итого процедура рисования всей сцены приобретёт в нашем примере следующий вид:
- визуализируем комнату для отражений ровно так же, как и делали до этого, но пока не рисуем её на основной картинке
- для каждого источника света рисуем сцену в 6 ракурсах
- рисуем комнату и чайник с фоновым освещением и без отражений
- для каждого источника света: рисуем комнату и чайник с тенями от этого источника света
- наконец, рисуем частично прозрачные отражения на чайнике
Единственный кардинально новый момент во всём этом: смешение цветов,
включаемое при помощи GL.enable(GL.BLEND)
и обычно настраиваемое при помощи
методов blendColor
, blendEquation
и blendFuncSeparate
.
Смешение цветов
Для \(i\)-й компоненты пикселя её итоговое значение вычисляется по формуле:
цвет[i] = op(src[i]*f(src)[i], dst[i]*g(dst)[i])
где src
— цвет только что нарисованного пикселя, dst
—
цвет пикселя на изображении, функции f
и g
задаются при помощи
blendFuncSeparate
, бинарная операция op
задаётся при помощи
blendEquation
.
Возможных комбинаций функций f
, g
и op
очень много.
Здесь мы приведём лишь наиболее распространённые комбинации
f
и g
в предположении, что op
— сумма (значение по умолчанию).
Каждую комбинацию мы будем приводить как четвёрку
входов для blendFuncSeparate
:
ONE
,ZERO
,ONE
,ZERO
— полная замена цвета пикселя (значение по умолчанию)ONE
,ZERO
,ZERO
,ONE
— замена цвета пикселя с сохранением старой альфа-компонентыSRC_ALPHA
,ONE_MINUS_SRC_ALPHA
,ZERO
,ONE
— альфа-компонента как непрозрачность (это — традиционная семантика альфа-компоненты)ONE
,ONE
,ZERO
,ONE
— аддитивное смешение цветов (аппроксимация смешения света от нескольких источников)DST_COLOR
,ZERO
,ZERO
,ONE
— мультипликативное смешение цветов (аппроксимация смешения нескольких красок)
Также обратим внимание на то, что при смешении цветов настоятельно рекомендуется переключить режим проверки глубины со стандартного «перерисовывать пиксель, если его глубина меньше сохранённой» на «перерисовывать пиксель, если его глубина не больше сохранённой» командой
GL.depthFunc(GL.LEQUAL);
Источники освещения, текстуры и шейдеры для них
Для начала модифицируем setupLights
: для каждого источника света
создадим отдельную текстуру, в которую будем записывать расстояния
до точек сцены.
function setupLights(lightSources) {
const lightsData = [];
const shadowmaps = [];
const resolution = 1024;
for (const source of lightSources) {
lightsData.push(source.x, source.y, source.z, source.intensity);
const shadowmap = GL.createTexture();
GL.bindTexture(GL.TEXTURE_CUBE_MAP, shadowmap);
GL.texStorage2D(GL.TEXTURE_CUBE_MAP, 1, GL.DEPTH_COMPONENT32F, resolution, resolution);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_MIN_FILTER, GL.NEAREST);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_MAG_FILTER, GL.NEAREST);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_WRAP_S, GL.CLAMP_TO_EDGE);
GL.texParameteri(GL.TEXTURE_CUBE_MAP, GL.TEXTURE_WRAP_T, GL.CLAMP_TO_EDGE);
shadowmaps.push(shadowmap);
}
const lightsTexture = GL.createTexture();
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_2D, lightsTexture);
GL.texImage2D(GL.TEXTURE_2D, 0, GL.R32F, 4, lightSources.length, 0, GL.RED, GL.FLOAT,
Float32Array.from(lightsData)
);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MAG_FILTER, GL.NEAREST);
GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.NEAREST);
const framebuffer = GL.createFramebuffer();
return {
sources: lightSources,
dataTexture: lightsTexture,
shadowmaps,
resolution,
framebuffer,
};
}
Обратите внимание на DEPTH_COMPONENT32F
в качестве внутреннего
формата текстуры. Эта константа плохо документирована! Точнее,
она отсутствует в документации к texImage2D
и texStorage2D
на
MDN. В официальной документации
к функции glTexStorage2D
она присутствует.
Также обратите внимание на NEAREST
-фильтры кубической карты
расстояний: это — требование последнего пункта параграфа 3.8.13
стандарта OpenGL ES 3. Правда, оно, возможно, противоречит параграфу 3.8.8 (в котором
слово «can» употребляется то ли в значении
«может, но не обязан», то ли в значении
«можно считать, что»), в результате чего
при некоторых комбинациях ОС и браузера линейная фильтрация
вполне работает (см. ответ от одного из
разработчиков Firefox по ссылке).
Также модифицируем соответствующим образом setupScene
:
function setupScene() {
GL.enable(GL.DEPTH_TEST);
const lightSources = [
{ x: 1, y: 1.5, z: -1, intensity: 7 },
{ x: -1, y: 1, z: 1, intensity: 5 },
];
const lights = setupLights(lightSources);
return {
room: setupRoom(lights.dataTexture),
teapot: setupTeapot(lights.dataTexture),
lights,
};
}
Затем напишем простой шейдер, единственное назначение которого — записать в компоненту глубины расстояние до точки (точнее, величину, обратно пропорциональную расстоянию, — в функции расчёта освещения уже есть величина такого рода, да и верхнего ограничения на расстояние у такой величины нет).
const SHADOWMAP_FS = `#version 300 es
precision highp float;
in vec3 position;
uniform vec3 camera_pos;
void main() {
vec3 diff = position - camera_pos;
gl_FragDepth = 1.0 - 0.1*inversesqrt(dot(diff, diff));
}
`;
От единицы мы её отнимаем, чтобы её можно было использовать как
глубину при стандартной проверке глубины — при рисовании
сцены относительно источников света проверка глубины, очевидно,
требуется. Причём, в отличие от z
-компоненты, для которой
результат её деления на w
-компоненту должен оказаться в пределах от
-1
до 1
, gl_FragDepth
обязан быть от 0 до 1.
Теперь напишем функцию drawShadowmaps
, которая рисует сцену с точки
зрения каждого источника света в 6 ракурсах.
function drawShadowmaps(scene) {
const {
lights, room, teapot, teapotPos, teapotAxis, teapotAngle
} = scene;
GL.bindFramebuffer(GL.FRAMEBUFFER, lights.framebuffer);
GL.viewport(0, 0, lights.resolution, lights.resolution);
for (let i = 0; i < lights.sources.length; i++) {
const light = lights.sources[i];
const shadowmap = lights.shadowmaps[i];
for (let dir = 1; dir <= 6; dir++) {
GL.framebufferTexture2D(GL.FRAMEBUFFER, GL.DEPTH_ATTACHMENT,
DIRECTION_TO_FACE[dir], shadowmap, 0);
GL.clear(GL.DEPTH_BUFFER_BIT);
room.beginShadow();
GL.uniform3f(room.shadowUniforms.camera_pos,
light.x, light.y, light.z);
GL.uniform1i(room.shadowUniforms.camera_dir, dir);
room.draw();
teapot.beginShadow();
GL.uniform3f(teapot.shadowUniforms.camera_pos,
light.x, light.y, light.z);
GL.uniform1i(teapot.shadowUniforms.camera_dir, dir);
GL.uniform3f(teapot.shadowUniforms.axis, ...teapotAxis);
GL.uniform1f(teapot.shadowUniforms.angle, teapotAngle);
GL.uniform3f(teapot.shadowUniforms.pos, ...teapotPos);
teapot.draw();
}
}
GL.bindFramebuffer(GL.FRAMEBUFFER, null);
GL.viewport(0, 0, GL.canvas.width, GL.canvas.height);
}
Общее для drawShadowmaps
и drawEnvironment
соответствие между
номером ракурса и соответствующей OpenGL-константой, вынесем
в глобальную переменную
let DIRECTION_TO_FACE = null; // это нужно вставить в начало
// это нужно вставить в соответствующее место onload:
DIRECTION_TO_FACE = {
1: GL.TEXTURE_CUBE_MAP_POSITIVE_Z,
2: GL.TEXTURE_CUBE_MAP_NEGATIVE_Z,
3: GL.TEXTURE_CUBE_MAP_POSITIVE_X,
4: GL.TEXTURE_CUBE_MAP_NEGATIVE_X,
5: GL.TEXTURE_CUBE_MAP_POSITIVE_Y,
6: GL.TEXTURE_CUBE_MAP_NEGATIVE_Y,
};
В функции drawShadowmaps
используются методы
room.beginShadow
и teapot.beginShadow
, которые мы реализуем,
модифицировав соответствующим образом setupRoom
и setupTeapot
.
Приведём полный код для нового setupRoom
, оставив setupTeapot
в качестве
упражнения для читателя
function setupRoom(lights) {
const attributes = {
coord: 0,
tex_coords: 1,
};
const {
program,
uniforms
} = buildProgram(ROOM_VS, ROOM_FS, attributes,
['camera_pos', 'camera_dir', 'aspect', 'size', 'tex', 'lights']
);
const {
program: shadowProgram,
uniforms: shadowUniforms,
} = buildProgram(ROOM_VS, SHADOWMAP_FS, attributes, [
'camera_pos', 'camera_dir', 'aspect', 'size',
]);
const vao = createCubeVAO(attributes);
const image = document.getElementById(`SOME_IMAGE`);
const texture = GL.createTexture();
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
GL.texImage3D(GL.TEXTURE_2D_ARRAY,
0, GL.RGBA,
image.width,
image.height/4,
4, 0,
GL.RGBA, GL.UNSIGNED_BYTE, image);
GL.generateMipmap(GL.TEXTURE_2D_ARRAY);
const roomSize = 3.5;
const begin = () => {
GL.useProgram(program);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_2D, lights);
GL.bindVertexArray(vao);
GL.uniform1i(uniforms.tex, 0);
GL.uniform1i(uniforms.lights, 1);
GL.uniform1f(uniforms.size, roomSize);
};
const beginShadow = () => {
GL.useProgram(shadowProgram);
GL.bindVertexArray(vao);
GL.uniform1f(shadowUniforms.aspect, 1);
GL.uniform1f(shadowUniforms.size, roomSize);
};
const draw = () => {
GL.drawArrays(GL.TRIANGLES, 0, 36);
};
return {
uniforms, shadowUniforms, begin, beginShadow, draw,
};
}
Обратите внимание на то, что общий для двух программ размер комнаты
мы вынесли в отдельную переменную и теперь выставляем в обеих
begin
-функциях. То же самое рекомендуем сделать и для чайника.
Наконец, реализуем шейдеры для освещения предмета одним источником:
const SINGLE_LIGHT_CALCULATION = `
float intensity = 0.3;
if (light.w > 0.0) {
vec3 normal = calculate_normal();
vec3 direction = light.xyz - position;
float light_inv_distance = 10.0*(1.0 - texture(shadowmap, -direction).r);
float normal_direction = dot(normal, direction);
float inv_distance = inversesqrt(dot(direction, direction));
if (normal_direction > 0.0 && inv_distance/light_inv_distance > 0.99) {
intensity =
light.w
* normal_direction
* pow(inv_distance, 3.0);
} else {
intensity = 0.0;
}
}
`;
const SINGLE_LIGHT_ROOM_FS = `#version 300 es
precision highp float;
precision highp sampler2DArray;
precision highp samplerCube;
out vec4 frag_color;
in vec3 position;
in vec3 frag_tex_coords;
uniform sampler2DArray tex;
uniform samplerCube shadowmap;
uniform vec4 light;
vec3 calculate_normal() {
vec3 dir1 = dFdx(position);
vec3 dir2 = dFdy(position);
vec3 geometry_normal = normalize(cross(dir2, dir1));
float bump = 0.7;
float dheight_dx = bump * dFdx(
texture(tex, frag_tex_coords + vec3(0,0,1)).r
);
float dheight_dy = bump * dFdy(
texture(tex, frag_tex_coords + vec3(0,0,1)).r
);
vec3 mdir1 = dir1 + geometry_normal * dheight_dx;
vec3 mdir2 = dir2 + geometry_normal * dheight_dy;
return normalize(cross(mdir2, mdir1));
}
void main() {
vec3 surface_color = texture(tex, frag_tex_coords).rgb;
${SINGLE_LIGHT_CALCULATION}
frag_color = vec4(intensity*surface_color, 1);
}
`;
const SINGLE_LIGHT_TEAPOT_FS = `#version 300 es
precision highp float;
precision highp samplerCube;
out vec4 frag_color;
in vec3 position;
in vec3 frag_normal;
uniform samplerCube shadowmap;
uniform vec3 camera_pos;
uniform vec4 light;
vec3 calculate_normal() {
return normalize(frag_normal);
}
void main() {
vec3 surface_color = vec3(0.3, 0.6, 0.1);
${SINGLE_LIGHT_CALCULATION}
frag_color = vec4(intensity*surface_color, 1);
}
`;
А основной шейдер для чайника поправим, добавив вход alpha
и
убрав оттуда освещение вообще:
const TEAPOT_FS = `#version 300 es
precision highp float;
precision highp samplerCube;
out vec4 frag_color;
in vec3 position;
in vec3 frag_normal;
uniform samplerCube reflections;
uniform vec3 camera_pos;
uniform float alpha;
vec3 calculate_normal() {
return normalize(frag_normal);
}
void main() {
vec3 normal = calculate_normal();
vec3 to_camera = camera_pos - position;
vec3 reflected = 2.0*dot(normal, to_camera)*normal - to_camera;
vec3 reflected_color = texture(reflections, reflected).rgb;
frag_color = vec4(reflected_color, alpha);
}
`;
В функции setupTeapot
же модифицируем метод begin
соответствующим
образом:
const begin = () => {
GL.useProgram(program);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_CUBE_MAP, reflections);
GL.bindVertexArray(vao);
GL.uniform1i(uniforms.reflections, 0);
GL.uniform1f(uniforms.alpha, 0.4);
GL.uniform1f(uniforms.size, teapotSize);
}
Также добавим в setupRoom
и setupTeapot
сборку
программы для освещения одним источником и метод beginSingleLight
:
// для комнаты; для чайника напишите самостоятельно!
function setupRoom(lights) {
... // всё, как и прежде
const {
program: singleLightProgram,
uniforms: singleLightUniforms,
} = buildProgram(ROOM_VS, SINGLE_LIGHT_ROOM_FS, attributes, [
'camera_pos', 'camera_dir', 'aspect', 'size', 'light', 'tex', 'shadowmap',
]);
... // всё, как и прежде
const beginSingleLight = (light, shadowmap) => {
GL.useProgram(singleLightProgram);
GL.activeTexture(GL.TEXTURE0);
GL.bindTexture(GL.TEXTURE_2D_ARRAY, texture);
GL.activeTexture(GL.TEXTURE1);
GL.bindTexture(GL.TEXTURE_CUBE_MAP, shadowmap);
GL.bindVertexArray(vao);
GL.uniform1i(singleLightUniforms.tex, 0);
GL.uniform1i(singleLightUniforms.shadowmap, 1);
GL.uniform1f(singleLightUniforms.size, roomSize);
GL.uniform4f(singleLightUniforms.light,
light.x, light.y, light.z, light.intensity);
};
... // всё, как и прежде
return {
uniforms, shadowUniforms, begin, beginShadow, draw,
singleLightUniforms, beginSingleLight, // два новых поля
};
}
Теперь можно рисовать всю сцену (не забыв убрать из drawEnvironment
первоначальное рисование комнаты):
const scene = {
lights, room, teapot, aspect,
cameraPos, teapotPos, teapotAxis, teapotAngle
};
GL.blendFuncSeparate(GL.ONE, GL.ZERO, GL.ONE, GL.ZERO);
drawShadowmaps(scene);
drawEnvironment(scene);
drawObjects(scene);
drawReflections(scene);
Вот — новые определения вспомогательных функций (кроме
drawShadowmaps
, которая приведена выше):
function drawEnvironment(scene) {
const { room, teapot, cameraPos, teapotPos } = scene;
room.begin();
GL.bindFramebuffer(GL.FRAMEBUFFER, teapot.framebuffer);
GL.viewport(0, 0, teapot.resolution, teapot.resolution);
for (let i = 1; i <= 6; i++) {
GL.uniform1f(room.uniforms.aspect, 1);
GL.uniform3f(room.uniforms.camera_pos, ...teapotPos);
GL.uniform1i(room.uniforms.camera_dir, i);
GL.framebufferTexture2D(GL.FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
DIRECTION_TO_FACE[i], teapot.reflections, 0);
GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);
room.draw();
}
GL.bindFramebuffer(GL.FRAMEBUFFER, null);
GL.viewport(0, 0, GL.canvas.width, GL.canvas.height);
}
function drawObjects(scene) {
const {
lights, room, teapot, aspect,
cameraPos, teapotPos, teapotAxis, teapotAngle
} = scene;
for (let i = -1; i < lights.sources.length; i++) {
if (i == 0) {
GL.blendFuncSeparate(GL.ONE, GL.ONE, GL.ZERO, GL.ONE);
}
const light = i < 0
? { x: 0, y: 0, z: 0, intensity: 0 }
: lights.sources[i];
const shadowmap = i < 0
? null
: lights.shadowmaps[i];
room.beginSingleLight(light, shadowmap);
GL.uniform1f(room.singleLightUniforms.aspect, aspect);
GL.uniform3f(room.singleLightUniforms.camera_pos, ...cameraPos);
GL.uniform1i(room.singleLightUniforms.camera_dir, 0);
room.draw();
teapot.beginSingleLight(light, shadowmap);
GL.uniform1f(teapot.singleLightUniforms.aspect, aspect);
GL.uniform3f(teapot.singleLightUniforms.camera_pos, ...cameraPos);
GL.uniform1i(teapot.singleLightUniforms.camera_dir, 0);
GL.uniform3f(teapot.singleLightUniforms.axis, ...teapotAxis);
GL.uniform1f(teapot.singleLightUniforms.angle, teapotAngle);
GL.uniform3f(teapot.singleLightUniforms.pos, ...teapotPos);
teapot.draw();
}
}
function drawReflections(scene) {
GL.blendFuncSeparate(
GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ZERO, GL.ONE
);
const {
teapot, teapotAxis, teapotAngle, teapotPos, aspect, cameraPos
} = scene;
teapot.begin();
GL.uniform1f(teapot.uniforms.aspect, aspect);
GL.uniform3f(teapot.uniforms.camera_pos, ...cameraPos);
GL.uniform1i(teapot.uniforms.camera_dir, 0);
GL.uniform3f(teapot.uniforms.axis, ...teapotAxis);
GL.uniform1f(teapot.uniforms.angle, teapotAngle);
GL.uniform3f(teapot.uniforms.pos, ...teapotPos);
teapot.draw();
}
В setupScene
включим cмешение цветов при помощи GL.enable(GL.BLEND)
и переключим проверку глубины на GL.depthFunc(GL.LEQUAL)
, и всё готово!