Category Archives: Programming

WebGL-калейдоскоп, ч.1

Захотелось сделать рисовалку калейдоскопа в виде HTML-страницы. Вдруг. Причём, более-менее честную, на основе трассировки лучей. Здесь я попробую описать всё «с нуля». В качестве графического API выбран WebGL2 (но объяснять в подробностях я не буду, иначе получится слишком длинный текст). По сути, основная работа будет выполняться в фрагментном (пиксельном) шейдере.

Начну с описания геометрии. Я решил ограничиться конфигурацией зеркал в виде треугольной призмы. Основание такой призмы — равносторонний треугольник, вписанный в круг радиуса два. Соответственно, круг, вписанный в треугольник, имеет радиус, равный единице. Ось зрения направлена параллельно сторонам призмы через центры вписанных в основания кругов. Экран (прямоугольник, в котором формируется картинка) вписываем во вписанный в треугольник единичный круг. Ось зрения проходит через центр (точку пересечения диагоналей) экрана.

Схема калейдоскопа в изометрической проекции

На схеме красным условно показан луч, выпущенный из камеры через некоторую точку на экране и испытывающий одно отражение от зеркальной стенки призмы до попадания на заднюю стенку калейдоскопа. Камера сдвинута на расстояние f от экрана, задняя стенка — на расстояние L. Таким образом, начало координат находится в центре экрана, а наш базис имеет левую ориентацию: ось 0x направлена вправо, ось 0y — вверх, ось 0z — вперёд. В рассматриваемой задаче ориентация базиса никакой роли не играет.

Фрагментный шейдер выполняется для каждого пикселя и получает координаты этого пикселя относительно нижнего левого угла (вертикальная ось направлена вверх) в виде предопределённой переменной gl_FragCoord (там ещё есть z-координата и величина 1/w в качестве четвёртой координаты, но здесь они нам не нужны). Координаты принимают значения от 0.5 до W−0.5 (по горизонтали) или до H−0.5 (по вертикали), где W и H суть размеры экрана в пикселях (разрешение), изменяясь с шагом 1.

В координатах нашей схемы это должно отображаться в прямоугольник с фиксированной диагональю длины два. Итак, требуется вывести нормировочный коэффициент. Поскольку длина диагонали в пикселях есть sqrt(W2 + H2), то надо отцентрированные координаты (когда вычли (W/2, H/2), чтобы переместить начало координат в центр экрана) умножать на 2/sqrt(W2 + H2). Наш фрагментный шейдер должен получать эти значения извне (в виде юниформов — глобальных констант, значения которых задаются внешним кодом):

// Половина разрешения канваса по горизонтали и вертикали,
// т.е. (0.5*W, 0.5*H), где W и H -- разрешение канваса.
uniform vec2  u_half_resolution;
    
// Коэффициент, на который надо домножить координаты пикселя,
// чтобы вписать их в единичный круг. 
// Вычисляется как 2 / sqrt(W*W + H*H).
uniform float u_resolution_scale;

void main()
{
  vec2 screen_position = 
     u_resolution_scale * (gl_FragCoord.xy - u_half_resolution);
  // TODO: написать всё остальное.

Заметим, что луч света проходит одно и то же расстояние вдоль оси 0z, независимо от количества отражений. Таким образом, задачу определения координат пересечения луча с задней стенкой можно свести к двумерной задаче отражений луча в равностороннем треугольнике.

Для начала, вычислим длину луча в трёхмерном пространстве. Очевидно, это (L + f)/cos(α), где α — угол между лучом и осью зрения. Соответственно, в проекции на треугольник луч будет иметь длину (L + f) tg(α). Найти этот угол не сложно: имеем прямоугольный треугольник, образованный точками с координатами (0, 0, -f), (0, 0, 0) и (x, y, 0), где (x, y) — координаты screen_position, полученные на предыдущем этапе. Противолежащий катет равен sqrt(x2 + y2), прилежащий катет — f, соответственно, tg(α) = sqrt(x2 + y2)/f. Итак, длина проекции луча равна (L/f + 1) sqrt(x2 + y2). Коэффициент (L/f + 1) есть константа, её тоже следует передавать в шейдер в виде юниформа.

// Половина разрешения канваса по горизонтали и вертикали, т.е.
// (0.5*W, 0.5*H), где W и H -- разрешение канваса.
uniform vec2  u_half_resolution;
    
// Коэффициент, на который надо домножить координаты пикселя,
// чтобы вписать их в единичный круг. 
// Вычисляется как 2 / sqrt(W*W + H*H).
uniform float u_resolution_scale;

// Отношение длины зеркал к расстоянию до камеры + 1.
uniform float u_length_to_focus_ratio_plus_one;

void main()
{
  vec2 screen_position = 
     u_resolution_scale * (gl_FragCoord.xy - u_half_resolution);

  float max_ray_length = 
     u_length_to_focus_ratio_plus_one * length(screen_position);
  // TODO: написать всё остальное.

По построению, наш луч обязательно должен пересечься либо с задней стенкой, либо отразиться от одного из зеркал. Отражение будем проверять, вычисляя точки пересечения луча с прямыми, проведёнными через стороны треугольника (уже в плоскости 0xy). В случае наличия отражения вычисляется новая точка начала луча и отражённое направление, далее весь процесс повторяется.

Итак, есть луч, заданный параметрически: p(s) = R + rs, s ≥ 0, пусть |r| = 1. Есть прямая, проходящая через точку A, имеющую единичную нормаль n. Чтобы найти точку пересечения, нужно подставить параметрическое выражение точки на луче в уравнение прямой: (p — A)·n = 0, где · обозначает скалярное произведение. Итак,

(R + rs − A)·n = 0,

(r·n)s = (A − R)·n,

s = ((A − R)·n) / (r·n).

Не имеет смысла вычислять s, если r·n ≥ 0 (пусть нормали «смотрят» внутрь треугольника). А раз должно получиться s > 0, то должно выполняться и (A − R)·n < 0. Из s, полученных для сторон треугольника, удовлетворяющих указанным условиям, выберем минимальное, т. е. дающее первое пересечение. По построению, эта точка будет лежать на треугольнике. Подставив s в p(s), получим новое значение R (положение точки на зеркале в проекции). Чтобы получить новое значение r следует отразить старое r, используя нормаль того зеркала, на которое мы попали (ну вы поняли, угол падения равен углу отражения). Для этого в GLSL даже есть специальная функция reflect.

Так как у нас три стороны, для каждой из них нужно задать свои значения A и n. Поскольку единичный круг касается всех трёх сторон треугольника, то в качестве точек A можно выбрать точки касания. Заодно автоматически получим n = −A. Итак, n0 = (0, 1), n1 = (-sqrt(3)/2, -1/2), n2 = (sqrt(3)/2, -1/2).

Если n = −A, то s = −(1 + R·n) / (r·n).

Я напишу функцию intersect, которая принимает R (ray_start), r (ray_dir), возвращает следующее значение R (точку на зеркале), следующее значение r (через параметр reflected_dir) и длину отрезка от старого R до нового R (length). Написана она без изощрений. Возможно, это можно как-то оптимизировать, но не к спеху.

// Нормали к сторонам правильного треугольника, 
// описанного вокруг единичного круга.
const vec2 n0 = vec2(0., 1.);
const vec2 n1 = -0.5 * vec2(+sqrt(3.), 1.);
const vec2 n2 = -0.5 * vec2(-sqrt(3.), 1.);

// Найти ближайшее отражение луча от зеркал.
// ray_start начало луча
// ray_dir единичный вектор направления луча
// reflected_dir отражённый вектор направления луча
// length расстояние между началом луча и точкой отражения
// @return точка отражения луча
vec2 intersect(
  in vec2 ray_start,
  in vec2 ray_dir,
  out vec2 reflected_dir,
  out float length)
{
  float divisor0 = dot(ray_dir, n0);
  float divisor1 = dot(ray_dir, n1);
  float divisor2 = dot(ray_dir, n2);

  float dividend0 = -1.0 - dot(ray_start, n0);
  float dividend1 = -1.0 - dot(ray_start, n1);
  float dividend2 = -1.0 - dot(ray_start, n2);

  vec2 n = n0;
  length = 100.;
  if (divisor0 < 0. && dividend0 < 0.)
  {
    length = dividend0 / divisor0;
  }

  if (divisor1 < 0. && dividend1 < 0.)
  {
    float s = dividend1 / divisor1;
    if (s < length)
    {
      length = s;
      n = n1;
    }
  }

  if (divisor2 < 0. && dividend2 < 0.)
  {
    float s = dividend2 / divisor2;
    if (s < length)
    {
      length = s;
      n = n2;
    }
  }

  reflected_dir = reflect(ray_dir, n);
  return ray_start + ray_dir * length;
}

Теперь напишем до конца функцию main фрагментного шейдера. Пусть на каждом отражении яркость луча понижается умножением на коэффициент затухания (attenuation_factor). Сделаем его константой:

// Коэффициент затухания на каждом отражении.
const float attenuation_factor    = 15. / 16.;
// Максимальное допустимое затухание.
const float attenuation_threshold = 1. / 256.;

Поскольку на практике почти везде используется 8 бит на канал, то при затухании в 256 раз итоговый цвет пикселя будет чёрным, поэтому зададим соответствующую константу attenuation_threshold.

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

// Трассировка луча.
// Накопленная длина.
float accumulated_length = 0.0;
// Затухание.
float attenuation = 1.0;
// Начальная точка луча и
// результирующая точка после цикла.
vec2 ray_start = vec2(0., 0.);
// Направление луча.
vec2 ray_dir = normalize(screen_position);
 
do
{
  // Длина следующего отрезка.
  float segment_length;
  // Отражённое направление луча.
  vec2 new_ray_dir;
  // Новое начало (на зеркале) луча.
  vec2 new_ray_start = intersect(
    ray_start, ray_dir, new_ray_dir, segment_length);
  
  // Остаток длины луча.
  float remaining_length = max_ray_length - accumulated_length;
  // Если луч закончился до пересечения,
  // то он попал на заднюю стенку.
  if (remaining_length <= segment_length)
  {
    ray_start += remaining_length * ray_dir;
    break;
  }

  // Подготовить следующую итерацию.
  accumulated_length += segment_length;
  ray_start = new_ray_start;
  ray_dir = normalize(new_ray_dir);
  attenuation *= attenuation_factor;
} while (attenuation > attenuation_threshold);

После цикла значения координат луча на задней стенке калейдоскопа находятся в переменной ray_start. В данный момент я не готов реализовать имитацию осколков цветного стекла, вместо этого я буду рисовать банальный белый круг на чёрном фоне. Координаты центра круга буду задавать юниформом u_center_offset. Затухание буду применять только к зелёному каналу.

  ray_start -= u_center_offset;
  if (attenuation > attenuation_threshold 
      && dot(ray_start, ray_start) <= 1.0)
    o_color = vec4(1., attenuation, 1., 1.);
  else
    o_color = vec4(0., 0., 0., 1.);
  // конец main.
}

// Дополнительные объявления в начале шейдера:
// out vec4 o_color;
//// Сдвиг центра круга (временно).
// uniform vec2  u_center_offset;

Теперь опишу всё остальное. Начнём с каркаса HTML-файла:

<!DOCTYPE html>
<html style="width: 100%; height: 100%; margin: 0;">
   <head>
      <meta charset="utf-8" />
      <title>Калейдоскоп-v1</title>
   </head>
   <body style="position: relative; width: 100%; height: 100%; margin: 0;">
   <canvas id="picto" style="position: absolute; display: inline-block; overflow: clip;">
   </canvas>
<script>
  // ...
</script>
   </body>
</html>

Всё, что есть на странице — объект canvas, растянутый на всё окно браузера. Обратите внимание на стилевые опции. На практике может быть трудно избежать каких-нибудь дефектов, например, появления полос прокрутки.

Теперь опишем содержимое скрипта. Его текст условно разбит на секции:

  • константы;
  • состояние системы;
  • инициализация;
  • обработчики событий;
  • вспомогательные функции.

Константы содержат определения f и L:

// Расстояние от камеры до экрана по умолчанию.
const DEFAULT_FOCUS   = 0.75;

// Длина калейдоскопа 
// (расстояние от экрана до пластинки, длина зеркальной призмы).
const DEFAULT_LENGTH  = 5.0;

а также тексты шейдеров. Фрагментный шейдер почти полностью дан выше, а вот вершинный шейдер приведём здесь. Он очень простой, и ничего не делает, поскольку мы будем отображать на экран квадрат [-1, 1]×[-1, 1]:

// Вершинный шейдер.
const VERTEX_SHADER_SOURCE = `#version 300 es

    in vec4 a_position;
     
    void main()
    {
      gl_Position = a_position;
    }
`;

// Фрагментный шейдер.
const FRAGMENT_SHADER_SOURCE = `#version 300 es

    precision highp float;
  // и так далее  ...
`;

Состояние системы пока задаётся лишь положением центра круга. Я также сделал переменными длину зеркал и расстояние до камеры:

// Расстояние от камеры до экрана.
let curFocus = DEFAULT_FOCUS;
// Длина зеркал.
let curLength = DEFAULT_LENGTH;

// Сдвиг центра круга.
let curXoffset = 0;
let curYoffset = 0;

Вспомогательных функций пока две: компиляция шейдера createShader, принимающая тип шейдера (вершинный или фрагментный) и исходный текст шейдера, и компоновка программы createProgram из скомпилированных вершинного и фрагментного шейдеров. В случае ошибок выводится окно с сообщением. Первоначальный код этих функций был потырен где-то в Интернете. Возможно, тут.

// Функция компиляции шейдера.
function createShader(type, source) 
{
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  if (success) 
    return shader;
 
  alert(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
}

// Функция компоновки шейдеров в единую программу:
function createProgram(vertexShader, fragmentShader) 
{
  const program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  const success = gl.getProgramParameter(program, gl.LINK_STATUS);
  if (success)
    return program;
 
  alert(gl.getProgramInfoLog(program));
  gl.deleteProgram(program);
}

Для простоты я не стал размещать инициализацию в отдельной функции.

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

document.body.scrollTop = 0;
document.body.style.overflow = 'hidden';

Теперь получим объект канваса и создадим контекст WebGL2 (тот самый объект gl, который я уже невозбранно использовал выше во вспомогательных функциях):

const picto = document.getElementById("picto");
const gl = picto.getContext("webgl2");
if (!gl)
  alert("Невозможно создать контекст WebGL2.");

Для работы нашей программы нет нужды в тесте глубины и блендинге, поэтому выключим их:

gl.disable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);

Создадим нашу программу и выберем её в качестве текущей для объекта gl (чтобы шейдеры запускались при отрисовке примитива):

// Скомпилировать шейдеры и создать программу.
const vertexShader    
  = createShader(gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const fragmentShader
  = createShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE);
const program
  = createProgram(vertexShader, fragmentShader);

// Программа у нас будет одна, 
// поэтому привяжем её один раз в инициализации.
gl.useProgram(program);

Получим дескрипторы параметров шейдеров (атрибута вершинного шейдера и юниформов фрагментного шейдера). Атрибут вершинного шейдера соответствует потоку входных данных, в нашем случае это — координаты вершин:

// Получить объект атрибута вершинного шейдера (входные данные).
const aPositionLocation 
  = gl.getAttribLocation(program, "a_position");

// Получить юниформы пиксельного шейдера.
const uHalfResolutionLocation   
  = gl.getUniformLocation(program, "u_half_resolution");
const uResolutionScaleLocation  
  = gl.getUniformLocation(program, "u_resolution_scale");
const uLengthToFocusRatioPlusOne       
  = gl.getUniformLocation(program, "u_length_to_focus_ratio_plus_one");

const uCenterOffset             
  = gl.getUniformLocation(program, "u_center_offset");

Теперь создадим буфер, в котором будут лежать координаты вершин квадрата. Отмечу, что поскольку мы подаём эти координаты на выход вершинного шейдера, то они заданы в «клиповом пространстве», что есть кубик [−1, 1]3. Поэтому квадрат с углами (−1, −1) ‒ (1, 1) точно соответствует экрану.

// Создать буфер на 4 вершины (на весь экран).
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// Заполнить буфер данными (координаты вершин).
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
      -1, -1,
      +1, -1,
      -1, +1,
      +1, +1,
    ]), gl.STATIC_DRAW);

Связывание буферов в набор потоков данных для вершинного шейдера (каждый поток соответствует своему атрибуту) выполняется с помощью объекта массива вершин (vertex array object, VAO):

// Использовать наш буфер в качестве вершинного (источник данных для вершинного шейдера).
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// Включить наш атрибут (переменную a_position).
gl.enableVertexAttribArray(aPositionLocation);

// Передать контексту информацию о формате буфера:
gl.vertexAttribPointer(
    aPositionLocation, 
    2,        // количество компонент вектора (по умолчанию дополняются y=0, z=0, w=1);
    gl.FLOAT, // формат компоненты;
    false,    // нормализация вкл/выкл;
    0,        // шаг между элементами (0 == использовать размер);
    0         // положение начала в буфере.
  );

В конце инициализации назначаем обработчики событий (их код будет представлен далее) и рисуем первую картинку:

// Перерисовывать на каждом изменении размеров окна.
window.onresize = repaint;
// Реакция на нажатие клавиши.
document.addEventListener('keydown', keyDown, false);

// Первая картинка.
repaint();

Теперь код обработчика нажатия клавиш. Стрелки перемещают круг, кнопки w/s изменяют длину зеркал. Добавлять кнопки для изменения расстояния до камеры не стал, поскольку картинка зависит лишь от отношения L/f. В принципе, можно вообще положить f = 1 и немного упростить программу.

// Функция-обработчик нажатия клавиши
function keyDown(event)
{
  const ARROW_STEP = 0.0625;
  switch (event.key) 
  {
  case 'ArrowLeft':
    curXoffset -= ARROW_STEP;
    break;
  case 'ArrowRight':
    curXoffset += ARROW_STEP;
    break;
  case 'ArrowUp':
    curYoffset += ARROW_STEP;
    break;
  case 'ArrowDown':
    curYoffset -= ARROW_STEP;
    break;
  case 'w': case 'W':
    curLength *= 1.25;
    break;
  case 's': case 'S':
    curLength /= 1.25;
    break;
  default:
    // Ignore.
    return;
  }
  
  repaint();
}

Наконец, функция repaint. Данная функция ничего не принимает и не возвращает. Так как она должна обрабатывать событие изменения размеров окна, то начнём с получения этих размеров и назначения их контексту:

// Разрешение окна.
const screenWidth  = window.innerWidth;
const screenHeight = window.innerHeight;
// Нормировочный коэффициент.
const screenScale  = 2.0 / Math.hypot(screenWidth, screenHeight);

// Канвас во всё окно.
gl.canvas.width = screenWidth;
gl.canvas.height = screenHeight;
gl.viewport(0, 0, screenWidth, screenHeight);

Я не сбрасываю содержимое буфера кадра (gl.clear), поскольку при каждой перерисовке и так заполняю его полностью заново попиксельно. Требуется задать значения всех юниформов, поскольку состояние системы могло измениться:

// Задать значения юниформам:
gl.uniform2f(uHalfResolutionLocation, 
  0.5 * screenWidth, 0.5 * screenHeight);
gl.uniform1f(uResolutionScaleLocation, 
  screenScale);
gl.uniform1f(uLengthToFocusRatioPlusOne, 
  curLength / curFocus + 1.0);
gl.uniform2f(uCenterOffset, 
  curXoffset, curYoffset);

После чего остаётся лишь запустить растеризацию и дождаться завершения рендеринга:

// Выполнить растеризацию (подать треугольники).
gl.drawArrays(
    gl.TRIANGLE_STRIP, // тип примитива (полоса из треугольников);
    0,                 // сдвиг начала примитива;
    4                  // количество вершин.
  );
  
gl.finish();

Всё целиком можно наблюдать здесь.

Надеюсь, что продолжение последует. Ещё надеюсь, что мои рассуждения не содержат каких-то жёстких ошибок, а то получающаяся картинка внушает некоторые подозрения…

UPD: мои подозрения не были напрасны: я забыл строчку, добавляющую segment_length к accumulated_length. Теперь это исправлено. Старый вариант с ошибкой тут.

Элементарная оптимизация шаблонов в C++

Речь идёт об оптимизации времени компиляции и размера объектного файла. Подстановка шаблонов и компоновка объектных файлов нередко являются самыми времязатратными операциями, выполняемыми при сборке проектов на C++, поэтому имеет смысл применять шаблоны обдуманно и использовать методики, позволяющие сократить объём кода, порождаемого компилятором. То, что описано здесь, применяется в C++, наверно, уже под 30 лет.

Пример 1

Пусть есть функция, записывающая произвольный кусок двоичных данных в файл:

void binary_dump(void const * data, size_t size)
{
  extern ofstream binary_dump_file;
  binary_dump_file.write(static_cast<char const*>(data), size);
  binary_dump_file.flush();
}

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

template <typename Data>
void binary_dump(Data const & data)
{
  extern ofstream binary_dump_file;
  binary_dump_file.write(
    static_cast<char const*>(&data), sizeof(Data));
  binary_dump_file.flush();
}

При вызове этой функции для каждого типа Data компилятор должен будет сгенерировать новое тело функции и соответствующий машинный код. Если в процессе этого могут быть применены какие-либо оптимизации, или если функция настолько проста, что время её работы сопоставимо со временем, затрачиваемым на вызов функции, то разумно оставить шаблон «как есть». Если же эти условия не выполняются, то можно попробовать отделить «нешаблонную» часть. Конечно, в этом примере функция довольно проста и состоит всего лишь из двух вызовов других функций (если компилятор не подставит эти вызовы!), но на то он и простой пример. Здесь нешаблонная часть есть попросту первая версия функции. Мы можем получить удобство шаблона, просто обернув её вызов:

void binary_dump(void const * data, size_t size)
{
  extern ofstream binary_dump_file;
  binary_dump_file.write(static_cast<char const*>(data), size);
  binary_dump_file.flush();
}

template <typename Data> // это почти макрос.
inline void binary_dump(Data const & data)
{
  binary_dump(&data, sizeof(Data));
}

Итак, теперь мы имеем удобство шаблона и дешевизну исходной функции: вызов шаблонной версии должен раскрываться компилятором в вызов нешаблонной версии с автоматической подстановкой правильного размера. Конечно, это всё равно влечёт некоторое увеличение времени компиляции, но оно будет меньше, чем в случае включения всего кода в шаблон. Размер объектного файла (по крайней мере, при включенной оптимизации и если не брать адрес специализаций binary_dump) в данном случае должен совпасть с размером объектного файла в случае использования только нешаблонной функции.

Пример 2

В качестве второго примера я попросту сошлюсь на часть стандартной библиотеки C++, а именно, iostreams. Речь идёт об использовании «наследования» для извлечения нешаблонной части класса-шаблона (т.е. всех членов, не зависящих от параметров шаблона) в нешаблонный базовый класс. Это именно приём оптимизации шаблонов. Итак, шаблон basic_ios наследует от нешаблона ios_base:

// Это просто пример, конкретная реализация может быть другой.
class ios_base
{
protected:
  ios_base();
public:
  ios_base(ios_base const&) = delete;
  virtual ~ios_base();
  using openmode = unsigned;
  static constexpr openmode binary = 1;
  static constexpr openmode in     = 2;
  static constexpr openmode out    = 4;
  static constexpr openmode app    = 8;
  static constexpr openmode trunc  = 16;
  static constexpr openmode ate    = 32;
  // и т.д.
};

template <typename CharT, typename Traits = char_traits<CharT>>
class basic_ios: public ios_base
{
protected:
  basic_ios();
public:
  using char_type = CharT;
  using traits_type = Traits;
  using int_type = typename Traits::int_type;
  using pos_type = typename Traits::pos_type;
  using off_type = typename Traits::off_type;
  basic_ios(basic_ios const&) = delete;
  explicit basic_ios(basic_streambuf<char_type, traits_type>*);
  // и т.д.
};

Далее, уже шаблонный класс basic_ios используется как базовый для классов basic_ostream и basic_istream, что так же можно рассматривать как оптимизацию, важную в контексте шаблонов (общая часть специализируется только один раз, а не в каждом из наследников). При этом, конечно, не следует забывать о том, что формально использование наследования предполагает выполнение принципа подстановки, здесь же оно используется для оптимизации, а не для выражения неких отношений между классами, отражающих родовидовые взаимоотношения объектов предметной области.

Заметка о map::find

Рассмотрим следующий код (C++17):

#include <map>
#include <string>
#include <string_view>

int main()
{
  using namespace std;
  map<string, int> m;
  string_view key = "none";
  return m.find(key) != m.end();
}

Этот код не компилируется. Первая реакция: как же так? Ведь в C++14 добавили версию find, принимающую ключ произвольного типа, лишь бы значение этого типа можно было сравнивать на меньше с key_type нашего map?

Ключевое слово здесь «сравнивать». Контейнер map делает это с помощью объекта типа key_compare, который передаётся третьим (необязательным) параметром шаблона. Этот тип по умолчанию есть less<key_type> (в примере — less<string>). Так как его в примере не видно, трудно сразу догадаться о причине данной проблемы. Как нетрудно видеть, эта версия принимает только key_type, а в нашем случае string_view не приводится неявно к string. (Кстати! если вы обращаетесь к такому map, передавая в качестве ключа си-строку, например, константу "…", то она будет неявно сконвертирована в string, причём, возможно неоднократно, что будет влечь ненужный и скрытый расход вычислительных ресурсов.)

Чтобы это исправить, рекомендуется заменить версию less по умолчанию, на универсальную версию less (доступную также начиная с C++14). Следующий код компилируется и работает так, как предполагалось (кроме того, он не влечёт того скрытого расхода вычислительных ресурсов, поскольку теперь ключ не конвертируется в значение key_type):

#include <functional>
#include <map>
#include <string>
#include <string_view>

int main()
{
  using namespace std;
  map<string, int, less<>> m;
  string_view key = "none";
  return m.find(key) != m.end();
}

SDL + Win32 консоль

Как продолжение я выложу код очень маленькой программы, которая создаёт окно средствами SDL.

В случае Win32 мы подключаемся к консоли родительского процесса (через AttachConsole) или создаём свою консоль (AllocConsole) перед созданием отдельного окна средствами SDL. После подключения консоли привязываем к ней стандартные потоки ввода-вывода с помощью функции стандартной библиотеки C freopen. Никаких изменений процесса сборки это не требует.

Для протоколирования используется отдельный файл, поскольку это на практике удобнее, чем консоль.

// To enable Win32 console attachment:
#define WIN32_CONSOLE_ENABLE
#include <SDL2/SDL.h>
#include <fstream>

#ifdef WIN32_CONSOLE_ENABLE
  #include <iostream>
  #include <cstdio>
  #define WIN32_LEAN_AND_MEAN
  #include <Windows.h>
  #include <shellapi.h>
#endif

using namespace std;

// Constants.
int const WINDOW_WIDTH  = 640;
int const WINDOW_HEIGHT = 480;

// Global state.
ofstream col("app.log"); // logging file.

SDL_Window  * window;  // our sole window.
SDL_Surface * surface; /* "surface" contains 
    the picture that is shown in the window. */

// Entry point.
int main(int argc, char* args[])
{
  col << "Application started.\n";
  #ifdef WIN32_CONSOLE_ENABLE
  // Either attach to a parent console or
  // create a separate Win32 console in order
  // to enable standard in/out streams.
  if (AttachConsole(-1) || AllocConsole())
  {
    freopen("CONIN$", "r", stdin);
    freopen("CONOUT$", "w", stderr);
    freopen("CONOUT$", "w", stdout);
    cout << "Hello there!\n";
    if (!col.is_open())
      cerr << "Failed to open the logging file.\n";
  }
  else // allocating console failed.
  {
    col << "Allocating console failed with error code "
        << GetLastError() << '\n';
    // Proceed or exit.
  }
  #endif

  // Initialization.
  // http://wiki.libsdl.org/SDL_Init
  int e = SDL_Init(SDL_INIT_VIDEO);
  if (e != 0)
  { // http://wiki.libsdl.org/SDL_GetError
    col << "SDL_Init failure: " << SDL_GetError();
    return 1;
  }

  // http://wiki.libsdl.org/SDL_CreateWindow
  window = SDL_CreateWindow("SDL minimal application",
      SDL_WINDOWPOS_UNDEFINED,
      SDL_WINDOWPOS_UNDEFINED, 
      WINDOW_WIDTH, 
      WINDOW_HEIGHT,
      SDL_WINDOW_SHOWN);
  if (!window)
  {
    col << "SDL_CreateWindow failure: " 
        << SDL_GetError();
    return 2;
  }

  // http://wiki.libsdl.org/SDL_GetWindowSurface
  surface = SDL_GetWindowSurface(window);
  if (!surface)
  {
    col << "SDL_GetWindowSurface failure: " 
        << SDL_GetError();
    return 3;
  }

  // Fill the window with some color...
  // http://wiki.libsdl.org/SDL_FillRect
  SDL_FillRect(surface, nullptr,
      SDL_MapRGB(surface->format, 0x4F, 0x00, 0xEE));
      // http://wiki.libsdl.org/SDL_MapRGB
  // http://wiki.libsdl.org/SDL_UpdateWindowSurface
  SDL_UpdateWindowSurface(window);
  // ... and wait for two seconds.
  // http://wiki.libsdl.org/SDL_Delay
  SDL_Delay(2000);
  
  // We are done, finalizing.
  // http://wiki.libsdl.org/SDL_DestroyWindow
  SDL_DestroyWindow(window);
  // http://wiki.libsdl.org/SDL_Quit
  SDL_Quit();
  col << "Application finished.\n";
  return 0;
}

UPD. Могут быть проблемы с вводом при присоединении консоли процесса-предка. В этом случае рекомендуется оставить AllocConsole: пусть будет своя, но нормально работающая консоль.

Visual Studio Code + SDL

Решил дополнить пост про использование Visual Studio Code вместе с SDL. Данная библиотека включена в состав сборки, которую я использую. Я просто приведу целиком файл tasks.json, который я использую для сборки программ на основе SDL.

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build lab",
            "type": "shell",
            "command": "${workspaceFolder}\\..\\mingw\\bin\\g++",
            "args": [
                //"-g", "-O0",
                "-O3",
                "${file}", 
                "-march=native", "-Wall", "-pedantic",
                "-std=c++17",
                "-I${workspaceFolder}\\..\\mingw\\include",
                "-I${workspaceFolder}\\..\\mingw\\include\\freetype2",
                    // Disable console:
                    "-mwindows", "-lmingw32", "-lSDL2main",
                    "-lSDL2",
                    // If FreeType is needed:
                    "-lfreetype", "-lz",
                    // Win32 API stuff:
                    "-limm32", "-lwinmm", "-lole32", "-loleaut32", "-lversion", "-lsetupapi",
                    //"-luser32", "-lgdi32",
                "-o${fileBasenameNoExtension}.exe"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

В случае возникновения ошибок компоновки (не найдена такая-то функция/символ), рекомендуется погуглить этот символ и добавить соответствующую библиотеку в список (под комментарий Win32 API stuff).

В данной сборке консоль и стандартный ввод-вывод (cin/cout/cerr) не будут доступны (подробнее об этом напишу позже).

В отличие от предыдущего варианта tasks.json, здесь каждый открытый .cpp файл будет собираться в отдельный .exe с тем же именем, используя макрос fileBasenameNoExtension.

Удаление из файла повторяющихся строк с сохранением порядка

Программа принимает текст построчно через стандартный поток ввода и выводит результат в стандартный поток вывода. Строки выводятся только один раз (когда встречаются впервые). Эту задачу очень просто решить, используя стандартную библиотеку C++. Данное решение даже можно считать эффективным при условии наличия достаточного количества оперативной памяти.

#include <string>
#include <unordered_set>
#include <iostream>
int main()
{
  using namespace std;
  ios::sync_with_stdio(false);
  unordered_set<string> lines;
  for (string line; getline(cin, line);)
    if (lines.insert(line).second)
      cout << line << '\n';
  return 0;
}

Выровненный new

Когда-то я писал на тему аллокации выровненных блоков памяти. Недавно я натолкнулся на небольшое нововведение C++17 в этой области: выровненный new. В заголовке <new> определён тип align_val_t, который позволяет передать оператору new желаемое значение выравнивания.
Например, я могу запросить 4кб страницу:

auto x = new(align_val_t{4096}) char[4096];

Разные версии оператора new можно посмотреть на cppreference.

Более того, в C++17 (наконец-то!) обычный new должен выделять корректно выровненную память, если тип использует alignas. Например, следующий код должен выводить два нуля:

struct alignas(64) Avx_block
{
  union
  {
    float  s[16];
    double d[8];
  };
};

int main()
{
  Avx_block block;
  cout << reinterpret_cast(&block) % alignof(Avx_block);

  auto p = new Avx_block;
  cout << reinterpret_cast(p) % alignof(Avx_block);
}

Об одном нововведении iostreams C++11

Наткнулся на нововведение в стандартной библиотеке C++ ещё стандарта 2011 года, о котором я не знал.

В стандарте 1998 года в случае, если происходит ошибка чтения в переменную числового типа, то её значение остаётся тем, которое было до операции чтения.

В C++11 иначе: если число вообще прочитать не удалось, то в переменную записывается нуль. Если считываемое число слишком велико, то в переменную идёт соответствующее её типу значение numeric_limits::max(). Если считываемое число отрицательно и слишком велико по абсолютной величине, то в переменную идёт numeric_limits::min(). В любом из трёх случаев поток в итоге устанавливается в ошибочное состояние (failbit).

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

 

Чтение файла в строку

Иногда бывает нужно прочитать всё содержимое потока ввода или файла в строку. В C++ это можно сделать по-разному. Для начала рассмотрим ситуацию произвольного потока ввода, размер данных в котором нельзя определить заранее стандартными средствами.

Я написал три функции, выполняющие чтение.

Вариант 1 наиболее краткий и элегантный — использовать итератор чтения из буфера потока:

string read_file_v1(istream & is)
{
  return string(istreambuf_iterator<char>{is}, {});
}

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

Вариант 2 через вывод буфера потока в строковый поток:

string read_file_v2(istream & is)
{
  stringstream buf;
  buf << is.rdbuf();
  return buf.str();
}

Недостатком данной реализации является, по крайней мере, тот факт, что строку нельзя «забрать» из строкового потока, можно только скопировать его содержимое в новую строку. Это значит, что если файл большой, то потребуется не менее, чем двукратный объём памяти. В худшем случае можно ожидать трёхкратный объём, так как хранилище строкового потока увеличивается в геометрической прогрессии (аналогично поведению vector::push_back).

Вариант 3 через чтение в список блоков памяти. Самый сложный код:

string read_file_v3(istream & is)
{
  constexpr streamsize BUF_SZ = 65536 - 4*sizeof(void*);
  list<array<char, BUF_SZ>> buffers;
  streamsize last_read_size = 0, total = 0;

  do
  {
    buffers.emplace_back();
    // Здесь хорошо подошла бы функция readsome,
    // но она может вообще ничего не прочитать!
    last_read_size = 
      is.read(buffers.back().data(), BUF_SZ)? 
                         BUF_SZ: is.gcount();
    total += last_read_size;
  } while (last_read_size == BUF_SZ);

  string result;
  if (total > 0)
  {
    result.resize(total);
    auto dest = result.data();
    {
      auto last = buffers.back().data();
      copy_n(last, last_read_size, 
        dest + (total - last_read_size));
      buffers.pop_back();
    }

    for (auto & block: buffers)
    {
      auto from = block.data();
      copy_n(from, BUF_SZ, dest);
      dest += BUF_SZ;
    }
  }

  return result;
}

Данный вариант потребляет объём памяти не меньший, чем на один блок (здесь 64 кб), и не меньший, чем двукратный размер файла, поскольку сначала файл целиком считывается в список блоков, откуда затем копируется в строку. Преимущество по сравнению с первыми двумя вариантами может заключаться в отсутствии перераспределения памяти и промежуточных копирований данных при переполнении строки (в первом варианте) или хранилища потока (во втором варианте).

Я не знаю практичного способа избежать перерасхода памяти при чтении неизвестного объёма данных из абстрактного потока ввода. Однако, если требуется прочитать файл, то можно узнать его размер заранее и выделить место в строке. До C++17 для этого обычно пользовались tellg — открыть файл, установив курсор чтения в конец (ios::ate), прочитать текущую позицию курсора. К сожалению, данный способ не обязан работать корректно: по стандарту tellg вполне может вернуть -1. В C++17 стандартная библиотека пополнилась библиотекой filesystem, в которой есть функция file_size. (Эта библиотека происходит из Boost, поэтому можно использовать Boost, если стандартная версия недоступна.)

Boost-версия (использовавшаяся мною в тесте, «вариант 4»):

string read_file_by_name(char const * fn)
{
  namespace fs = boost::filesystem;
  string result;
  if (auto const sz = fs::file_size(fn))
  {
    result.resize(sz);
    ifstream f(fn, ios::binary);
    f.read(result.data(), sz);
  }
  return result;
}

При использовании стандартной библиотеки, наверно, лучше написать несколько иначе:

namespace fs = std::filesystem;
string read_file_by_name(fs::path const & fn)
{
  string result;
  if (auto const sz = fs::file_size(fn))
  {
    result.resize(sz);
    ifstream f(fn, ios::binary);
    f.read(result.data(), sz);
  }
  return result;
}

Обратите внимание на флаг ios::binary. В общем случае его желательно ставить, если надо получать файл «как есть» байт-в-байт. Если у вас возникают непонятные проблемы при чтении файлов, попробуйте установить флаг binary.

К сожалению, стандартная библиотека не содержит средств отображения файлов в память. Использование таких средств, возможно, является наиболее эффективным способом чтения файлов в настоящее время.

Итак, теперь результаты замеров (секунды, g++ 8.2, Windows 7 x64, HDD, файл размером 1.1Gb, взят минимум из пяти прогонов):

№ вар. -O0, sync -O3, sync -O3
1 142.9 19.72 19.70
2 15.63 15.49 15.45
3 2.359 2.316 2.313
4 1.333 1.324 1.323

Как видно, от включения оптимизации значимо выигрывает лишь первый, самый медленный (ужасно медленный, при выключенной оптимизации) вариант. В случае остальных разницу между «оптимизированным» и «неоптимизированным» надо искать с лупой. Слово sync означает работу по умолчанию — синхронизация с вводом-выводом в режиме C не была выключена. В последнем столбце даны результаты для случая, когда перед тестом была выполнена строчка, выключающая данную синхронизацию:

ios::sync_with_stdio(false);

Следует отметить, что гигабайт с жёсткого диска нельзя «честно» считать за 1-2 секунды. В данном случае, очевидно, файл был закэширован операционной системой. Но для нашего теста так даже лучше.

Об использовании Visual Studio Code для учебных примеров

Visual Studio Code — редактор на основе платформы Electron, который можно использовать как минималистичную IDE. Мне требовалась поддержка C++17 и переносимость всего «на флешке». Что меня приятно удивило, так это «шустрость» VSCode.

Итак, порядок действий, предпринятых мной был таков:

  1. Скачать VSCode в архиве (в моём случае это была версия 1.27.1 для Windows/x64). Распаковать его, допустим, в папку vscode. Внимание! Путь к vscode не должен содержать не-ASCII символы и пробелы.
  2. Создать пустую папку vscode/data. Там будут плагины и общие настройки. При обновлении VSCode можно просто распаковать новую версию и переместить туда свою папку data из старой.
  3. Распаковать сборку MinGW рядом с vscode, допустим, в папку mingw (mingw/bin содержит g++.exe). В качестве таковой я брал сборку отсюда, там уже есть Boost и ряд других библиотек. Я к ним ещё добавляю Eigen и кое-что своё. Добавляю, просто складывая соответствующие папки в mingw/include.
  4. Создать рядом с vscode пустую папку для примеров. Допустим, cpp.
  5. Запустить VSCode (vscode/code.exe) и установить плагин C/C++ for Visual Studio Code от Microsoft (с Microsoft Intellisense).
  6. Открыть в VSCode папку cpp.
  7. Настройки создаются в виде JSON-файлов в папке cpp/.vscode. Ниже я опишу возможное их содержимое. Их можно создать непосредственно в VSCode, заполнить в нём же, и затем перезапустить VSCode.
  8. После того, как файлы настроек созданы, и VSCode перезапущен, следует протестировать его. Создать cpp/test.cpp с произвольным содержимым на C++ (например, helloworld). Terminal/Run build task… или Ctrl+Shift+B должно запускать сборку и завершаться без ошибок. F5 запускает сборку и результат в отладчике (gdb). Заголовочные файлы должны успешно открываться (правой кнопкой мыши и Go to definition). Intellisense должен работать адекватно.
  9. Полученный «пакет» в составе vscode, mingw, cpp можно упаковать в архив и развернуть на другой системе или просто скопировать на внешний носитель и запускать с него.

Итак, файлы настроек.

Плагин C/C++ использует файл c_cpp_properties.json.
Его содержимое в моём случае:

{
    "configurations": [
        {
            "name": "MinGW",
            "intelliSenseMode": "gcc-x64",
            "compilerPath": "${workspaceFolder}\\..\\mingw\\bin\\g++.exe",
            "cppStandard": "c++17",
            "cStandard": "c11",
            "includePath": [
                "${workspaceFolder}",
                "${workspaceFolder}\\..\\mingw\\include"
            ],
            "defines": [
                "__cplusplus=201703L"
            ]
        }
    ],
    "version": 4
}

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

Для сборки требуется создать «задачу сборки». В простейшем случае это компиляция одной единицы трансляции (cpp-файла) сразу в исполняемый файл (в моём случае всегда один и тот же с названием lab.exe).
Описание задач сборки располагается в файле tasks.json. Его содержимое в моём случае:

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build lab",
            "type": "shell",
            "command": "${workspaceFolder}\\..\\mingw\\bin\\g++",
            "args": [
                "-g", "-Og", // "debug" build
                //"-O3",
                "${file}", 
                "-march=native", "-Wall", "-pedantic",
                "-std=c++17",
                "-olab.exe"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

Наконец, описание запуска отладчика по F5 располагается в launch.json.
Его содержимое в моём случае:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(gdb) Launch",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}\\lab.exe",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": true,
            "MIMode": "gdb",
            "miDebuggerPath": "${workspaceFolder}\\..\\mingw\\bin\\gdb.exe",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
                {
                    "description": "Enable break on exception for gdb",
                    "text": "catch throw",
                    "ignoreFailures": true              
                }
            ],
            "preLaunchTask": "build lab"
        }
    ]
}

Вторая команда catch throw активирует перехват исключений отладчиком gdb.
Вообще, к сожалению, я не могу сказать, что с отладкой всё хорошо: есть ряд странностей. Например, точка останова, поставленная в одном месте, может сработать ниже (как будто поставленная в другом месте — может быть, это можно исправить, если заменить -Og на -O0). Точку останова нельзя поставить внутри шаблона (не срабатывает вообще). Не даёт ставить новые точки останова во время отладки. Если во время отладки нажать паузу, то продолжение может не срабатывать (останется только остановить исполнение).

Для меня проблемы с отладчиком не являются критичными, так как я привык к отладке через проверку условий и протоколирование. Но тем, кто привык к отладчику в «нормальной» Visual Studio такое, конечно, будет не по нраву.

UPD. Если всё это запускать на Windows 10, то в качестве терминала может быть по умолчанию выбран PowerShell. Почему-то он «не понимает» относительные пути через .. и не может запустить компилятор. В этом случае рекомендуется заменить его на cmd.exe (в VSCode можно выбрать терминал).

UPD2. Замена флага оптимизации -Og на -O0 всё же помогает отладчику, по крайней мере, точки останова не «убегают» с тех мест, куда они были поставлены.