Фактор DirectX

Пиксельные шейдеры и отражение света

Чарльз Петцольд

Исходный код можно скачать по ссылке

Charles PetzoldЕсли бы вы могли видеть фотоны… впрочем, вы можете их видеть или, по крайней мере, некоторые из них. Фотоны — это частицы, образующие электромагнитное излучение, а глаз чувствителен к фотонам с длинами волн в диапазоне видимого света.

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

В экстремально высококачественной трехмерной графике метод, называемый трассировкой лучей (ray tracing), способен реально смоделировать траектории мириад этих фотонов, имитируя эффекты отражения и теней. Но для более традиционных целей доступны гораздо более простые методы. Именно они и используются чаще всего при работе с Direct3D — или, как в моем случае, при написании собственных эффектов в Direct2D, применяющих трехмерную графику.

Повторное использование эффекта

Как вы видели в предыдущих выпусках моей рубрики, Direct2D-эффект — это, по сути, оболочка для кода, выполняемого GPU. Такой код называют шейдером, и самые важные из них — вершинные и пиксельные шейдеры. Код в каждом из этих шейдеров вызывается с частотой обновления видео на экране. Вершинный шейдер вызывается для каждой из трех вершин в каждом треугольнике, образующем графические объекты, которые отображаются эффектом, а пиксельный шейдер — для каждого пикселя в границах этих треугольников.

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

В номере за август я представил Direct2D-эффект RotatingTriangleEffect, который конструировал буфер вершин, состоящий из точек и цветов, и позволял применять к вершинам стандартные преобразования камеры и модели. Я использовал этот эффект для вращения трех треугольников. Это не требовало множества данных. Три треугольника дают всего девять вершин, и я тогда упомянул, что тот же эффект мог бы быть задействован для гораздо большего буфера вершин.

Давайте опробуем это: в пакете, сопутствующем этой статье (msdn.microsoft.com/magazine/msdnmag1014), есть программа ShadedCircularText, и она использует RotatingTriangleEffect безо всяких изменений.

Программа ShadedCircularText возвращает нас к задаче, которую я стал исследовать еще в начале этого года: отображение разбитого на треугольники (tessellated) двухмерного текста в трех измерениях. Конструктор класса ShadedCircularTextRenderer загружает файл шрифта, создает из него начертание шрифта, а затем вызывает GetGlyphRunOutline, чтобы получить геометрию траектории (path geometry) контуров символов. Эта геометрия траектории потом разбивается на треугольники с помощью созданной мной класса — InterrogableTessellationSink, который аккумулирует получаемые треугольники.

После регистрации RotatingTriangleEffect класс ShadedCircularTextRenderer создает объект ID2D1Effect, основанный на этом эффекте. Затем он преобразует треугольники текста в вершины на поверхности сферы, по сути, обертывая текст по экватору и сгибая его в направлении полюсов. Цвет каждой вершины основан на яркости цвета, получаемого по X-координате геометрии исходного текста. Это создает эффект, похожий на радугу, и результат показан на рис. 1.

Трехмерная текстовая радуга из ShadedCircularText
Рис. 1. Трехмерная текстовая радуга из ShadedCircularText

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

В правом нижнем углу отображается счетчик числа кадров в секунду (FPS), но вы обнаружите, что в этой программе ничего не вызывает его падения заметно ниже 60 — ну, разве что по каким-то внешним причинам.

Закраска по Гуро

Когда фотоны летают вокруг нас, они часто выбивают молекулы азота и кислорода из газов, присутствующих в атмосфере. Даже в пасмурный день без прямого солнечного света все равно присутствует немало рассеянного света (ambient light). Рассеянный свет, как правило, освещает объекты очень однородно.

Возможно, у вас есть объект, который имеет зеленовато-голубой цвет с RGB-значением (0, 0.5, 1.0). Если рассеянный свет имеет четверть полной интенсивности белого, вы можете назначить RGB-значение этому свету, равное (0.25, 0.25, 0.25). Воспринимаемый цвет этого объекта является результатом умножения красного, зеленого и синего компонентов этих чисел, или (0, 0.125, 0.25). Это по-прежнему зеленовато-голубой цвет, но намного более темный.

Однако простые 3D-сцены содержит не один рассеянный свет. В реальной жизни объекты обычно имеют массу цветовых вариаций на своих поверхностях, поэтому, даже если они освещаются однородно, у этих объектов все равно видны текстуры. В элементарной 3D-сцене зеленовато-голубой объект, освещаемый только рассеянным светом, будет выглядеть, как однородное цветовое пятно.

По этой причине простые 3D-сцены колоссально выигрывают от направленного света. Легче всего предположить, что этот свет приходит с дальнего расстояния (например, от Солнца), поэтому направление света определяется единственным вектором, применяемым ко всей сцене. Если источник света всего один, то обычно исходят из того, что он идет со стороны левого плеча наблюдателя, поэтому в правосторонней системе координат вектор, возможно, равен (1, –1, –1). Этот направленный свет тоже имеет цвет, допустим (0.75, 0.75, 0.75), а значит, в комбинации с рассеянным светом (0.25, 0.25, 0.25) иногда достигается максимальное освещение.

Сколько направленного света отражает некая поверхность, зависит от угла, под которым этот свет падает на поверхность. (Эту концепцию мы исследовали в майской рубрике «Фактор DirectX».) Максимальное отражение происходит, когда направленный свет падает перпендикулярно поверхности; отраженный свет уменьшается до нуля, когда свет проходит по касательной к поверхности или исходит откуда-то за поверхностью.

Закон Ламберта (Lambert Cosine Law), названный в честь немецкого математика и физика Иоганна Генриха Ламберта (1728–1777), гласит, что доля света, отраженного от поверхности, — это отрицательный косинус угла между направлением света и направлением вектора, перпендикулярного поверхности, который называется нормалью поверхности (surface normal). Если эти два вектора нормализованы, т. е. имеют величину 1, то этот косинус угла между двумя векторами равен скалярному произведению векторов.

Например, если свет падает на конкретную поверхность под углом 45 градусов, косинус равен примерно 0.7, поэтому умножим его на значение цвета направленного света (0.75, 0.75, 0.75) и на цвет объекта (0, 0.5, 1.0), чтобы получить цвет объекта от направленного света (0, 0.26, 0.53). И добавим его к цвету от рассеянного света.

Однако учтите, что объекты с кривыми поверхностями в 3D-сцене на самом деле не изогнуты. Сцена полностью состоит из плоских треугольников. Если освещение каждого треугольника основывается на нормали поверхности, перпендикулярной самому треугольнику, у каждого треугольника будет свой однородный цвет. Это хорошо подходит для платоновых тел (Platonic solids) вроде тех, которые отображались программой в моей рубрике за май 2014 года, но не годится для изогнутых поверхностей. В этом случае вам нужно, чтобы цвета треугольников смешивались друг с другом.

Это означает, что у каждого треугольника должен быть ступенчатый цвет (graduated color), а не однородной. Цвет от направленного освещения нельзя базировать на одной нормали поверхности для треугольника. Вместо этого у каждой вершины треугольника должен быть свой цвет на основе нормали поверхности в этой вершине. Эти цвета вершин можно затем интерполировать по всем пикселям треугольника. Далее цвета смежных треугольников смешиваются друг с другом, и тогда они напоминают кривую поверхность.

Этот тип закраски был изобретен французским ученым в области компьютерной техники, Анри Гуро (Henri Gouraud) (родился в 1944 г.), и впервые опубликован в 1971 году; с тех пор данный метод известен как закраска по Гуро (Gouraud shading).

Закраска по Гуро — это второй вариант, реализуемый программой ShadedCircularText. Сам эффект называется GouraudShadingEffect, и он требует буфера вершин с несколько большим объемом данных:

struct PositionNormalColorVertex
{
  DirectX::XMFLOAT3 position;
  DirectX::XMFLOAT3 normal;
  DirectX::XMFLOAT3 color;
  DirectX::XMFLOAT3 backColor;
};

Интересно, что, поскольку текст фактически обертывается вокруг сферы с центром в точке (0, 0, 0), нормаль поверхности в каждой вершине совпадает с позицией, но нормализована так, чтобы величина была равна 1. Этот эффект обеспечивает уникальность цветов для каждой вершины, но в данной программе каждая вершина получает одинаковый цвет — (0, 0.5, 1) — и один и тот же backColor (0.5, 0.5, 0.5), который является цветом, используемым, если наблюдатель видит поверхность сзади.

GouraudShadingEffect также требуется больше свойств эффекта. У него должна быть возможность для задания цветов рассеянного и направленного света, а также векторного направления для направленного света. GouraudShadingEffect передает все эти значения в более крупный буфер констант для вершинного шейдера. Сам вершинный шейдер показан на рис. 2.

Рис. 2. Вершинный шейдер для закраски по Гуро

// Входные данные по каждой вершине для вершинного шейдера
struct VertexShaderInput
{
  float3 position : MESH_POSITION;
  float3 normal : NORMAL;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Выходные данные по каждой вершине,
// получаемые от вершинного шейдера
struct VertexShaderOutput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 color : COLOR0;
};
// Буфер констант, предоставляемый эффектом
cbuffer VertexShaderConstantBuffer : register(b1)
{
  float4x4 modelMatrix;
  float4x4 viewMatrix;
  float4x4 projectionMatrix;
  float4 ambientLight;
  float4 directionalLight;
  float4 lightDirection;
};
// Вызывается для каждой вершины
VertexShaderOutput main(VertexShaderInput input)
{
  // Выходная структура
  VertexShaderOutput output;
  // Получаем входную вершину и включаем W-координату
  float4 pos = float4(input.position.xyz, 1.0f);
  // Сквозная передача выходного значения через
  // пространство конечной сцены
  // (не обязательно – можно удалить из обоих шейдеров)
  output.sceneSpaceOutput = pos;
  // Применяем преобразования к этой вершине
  pos = mul(pos, modelMatrix);
  pos = mul(pos, viewMatrix);
  pos = mul(pos, projectionMatrix);
  // Результат - clipSpaceOutput
  output.clipSpaceOutput = pos;
  // Применяем преобразование модели к нормали
  float4 normal = float4(input.normal, 0);
  normal = mul(normal, modelMatrix);
  // Находим угол между светом и нормалью
  float3 lightDir = normalize(lightDirection.xyz);
  float cosine = -dot(normal.xyz, lightDir);
  cosine = max(cosine, 0);
  // Применяем преобразование вида к нормали
  normal = mul(normal, viewMatrix);
  // Проверяем, указывает ли нормаль на наблюдателя
  if (normal.z > 0)
  {
    output.color = (ambientLight.xyz + cosine *
                    directionalLight.xyz) * input.color;
  }
  else
  {
    output.color = input.backColor;
  }
  return output;
}

Пиксельный шейдер — такой же, как для RotatingTriangleEffect, и показан на рис. 3. Интерполяция цветов вершин по всему треугольнику происходит «за кулисами» между вершинным и пиксельным шейдерами, поэтому пиксельный шейдер просто передает цвет для последующего отображения.

Рис. 3. Пиксельный шейдер для закраски по Гуро

// Входные данные по каждому пикселю для пиксельного шейдера
struct PixelShaderInput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 color : COLOR0;
};
// Вызывается для каждого пикселя
float4 main(PixelShaderInput input) : SV_TARGET
{
  // Просто возвращаем цвет с непрозрачностью, равной 1
  return float4(input.color, 1);
}

Результат показан на рис. 4 — на этот раз в Windows Phone 8.1, а не в Windows 8.1. Решение ShadedCircularText было создано в Visual с новым шаблоном Universal App, и его можно скомпилировать для любой из этих платформ. Весь код, кроме классов App и DirectXPage, является общим для обеих платформ. Разница в разметке двух программ дает ответ на вопрос, зачем нужны разные определения страницы, даже если функционал программы на обеих платформах одинаков.

Отображение модели закраски по Гуро
Рис. 4. Отображение модели закраски по Гуро

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

Усовершенствования Фонга

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

Но свет, отражаемый центром треугольника, должен на самом деле рассчитываться по нормали поверхности в этой точке. Иначе говоря, цвета не следует интерполировать по треугольнику; вместо этого по поверхности треугольника нужно интерполировать нормали поверхности, а отраженный свет вычислять для каждого пикселя по этой нормали.

И здесь я познакомлю вас с родившимся во Вьетнаме ученым в области компьютерных наук Буи Туонгом Фонгом (Pui Tuong Phong) (1942–1975 гг.), который умер от лейкемии в возрасте 32 лет. В своей докторской диссертации в 1973 году Фонг описал нечто отличное от алгоритма закраски. Вместо интерполяции цветов вершин по треугольнику интерполируются нормали вершин, и отраженный свет вычисляется по ним.

В практическом плане закраска по Фонгу требует перемещения расчета отраженного света из вершинного шейдера в пиксельный, а заодно и раздела буфера констант, выделенного для этой работы. Это сильно увеличивает объемы обработки каждого пикселя, но, к счастью, такая работа выполняется GPU, благодаря которому вы вряд ли заметите разницу.

Вершинный шейдер для модели закраски по Фонгу показан на рис. 5. Некоторые из входных данных, такие как цвет и фон, просто передаются в пиксельный шейдер. Но по-прежнему полезно применять все преобразования здесь. Преобразования мировых координат и камеры должны применяться к позициям, причем вычисляются и две нормали: одна — только с преобразованием модели для отраженного света, а другая — с преобразованием вида, чтобы определить, какой стороной смотрит поверхность на наблюдателя.

Рис. 5. Вершинный шейдер для модели закраски по Фонгу

// Входные данные по каждой вершине для вершинного шейдера
struct VertexShaderInput
{
  float3 position : MESH_POSITION;
  float3 normal : NORMAL;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Выходные данные по каждой вершине от вершинного шейдера
struct VertexShaderOutput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 normalModel : NORMAL0;
  float3 normalView : NORMAL1;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Буфер констант, предоставляемый эффектом
cbuffer VertexShaderConstantBuffer : register(b1)
{
  float4x4 modelMatrix;
  float4x4 viewMatrix;
  float4x4 projectionMatrix;
};
// Вызывается для каждой вершины
VertexShaderOutput main(VertexShaderInput input)
{
  // Выходная структура
  VertexShaderOutput output;
  // Получаем входную вершину и включаем W-координату
  float4 pos = float4(input.position.xyz, 1.0f);
  // Сквозная передача выходного значения через
  // пространство конечной сцены
  // (не обязательно – можно удалить из обоих шейдеров)
  output.sceneSpaceOutput = pos;
  // Применяем преобразования к этой вершине
  pos = mul(pos, modelMatrix);
  pos = mul(pos, viewMatrix);
  pos = mul(pos, projectionMatrix);
  // Результат - clipSpaceOutput
  output.clipSpaceOutput = pos;
  // Применяем преобразование модели к нормали
  float4 normal = float4(input.normal, 0);
  normal = mul(normal, modelMatrix);
  output.normalModel = normal.xyz;
  // Применяем преобразование вида к нормали
  normal = mul(normal, viewMatrix);
  output.normalView = normal.xyz;
  // Передаем цвета
  output.color = input.color;
  output.backColor = input.backColor;
  return output;
}

Поскольку вывод вершинного шейдера становится входом для пиксельного шейдера, эти нормали интерполируются по поверхности треугольника. Затем пиксельный шейдер может закончить работу, вычислив отраженный свет (рис. 6).

Рис. 6. Пиксельный шейдер для модели закраски по Фонгу

// Входные данные по каждому пикселю для пиксельного шейдера
struct PixelShaderInput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 normalModel : NORMAL0;
  float3 normalView : NORMAL1;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Буфер констант, предоставляемый эффектом
cbuffer PixelShaderConstantBuffer : register(b0)
{
  float4 ambientLight;
  float4 directionalLight;
  float4 lightDirection;
};
// Вызывается для каждого пикселя
float4 main(PixelShaderInput input) : SV_TARGET
{
  // Находим угол между светом и нормалью
  float3 lightDir = normalize(lightDirection.xyz);
  float cosine = -dot(input.normalModel, lightDir);
  cosine = max(cosine, 0);
  float3 color;
  // Проверяем, указывает ли нормаль на наблюдателя
  if (input.normalView.z > 0)
  {
    color = (ambientLight.xyz + cosine *
      directionalLight.xyz) * input.color;
  }
  else
  {
    color = input.backColor;
  }
  // Возвращаем цвет с непрозрачностью, равной 1
  return float4(color, 1);
}

Однако я не стану показывать вам экранный снимок с результатом. Визуально он очень похож на то, что выдает закраска по Гуро. Так что закраска по Гуро действительно обеспечивает хорошую аппроксимацию.

Зрительные блики

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

До сих пор в этой статье вы видели закраску, подходящую для диффузно отражающих (рассеивающих) поверхностей (diffuse surfaces). Это шероховатые и матовые поверхности, которые рассеивают отражаемый ими свет.

Поверхность, имеющая некоторый глянец, отражает свет несколько иначе. Если она наклонена так, как нужно, направленный свет может отразиться и попасть прямо в глаза наблюдателя. Это обычно воспринимается как яркий белый свет, называемый зрительным бликом (specular highlight). Весьма преувеличенный эффект зрительного блика можно увидеть на рис. 7. Если бы у фигуры были более резкие выраженные кривые, то белый свет был бы в большей мере локализован.

Отображение зрительного блика
Рис. 7. Отображение зрительного блика

Получение этого эффекта поначалу кажется слишком сложным в отношении вычислений, но на самом деле это всего несколько строк кода в пиксельном шейдере. Этот конкретный метод был разработан знатоком графики из NASA, Джимом Блинном (Jim Blinn) (родился в 1949 г.).

Для начала нам нужен вектор, указывающий направление, в котором смотрит наблюдатель 3D-сцены. Это очень легко, потому что преобразование камеры вида (view camera transform) уже подстроило все координаты так, чтобы наблюдатель смотрел прямо вдоль оси Z (в направлении уменьшения значений по ней):

float3 viewVector = float3(0, 0, -1);

Затем мы вычисляем вектор, который находится посередине между вектором вида и направлением света:

float3 halfway = -normalize(viewVector + lightDirection.xyz);

Обратите внимание на отрицательный знак. Он заставляет вектор указывать в противоположном направлении — посередине между источником света и наблюдателем.

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

Для уменьшения блика угол между вектором посередине (halfway vector) и нормалью поверхности должен отличаться от нуля. Это еще одно применение для косинуса между двумя векторами, что равнозначно скалярному умножению, если два вектора нормализованы:

float dotProduct = max(0.0f, dot(input.normalView, halfway));

Это значение dotProduct варьируется от 1 для максимально яркого блика, когда угол между двумя векторами равен нулю, до 0 для отсутствия блика, когда два вектора перпендикулярны.

Однако зрительный блик не должен быть виден для всех углов между 0 и 90 градусами. Он должен быть локализован. Блик должен проявляться лишь при очень малых углах между этими двумя векторами. Нам нужна функция, которая не повлияет на скалярное произведение, равное 1, но приведет к тому, что значения меньше 1 сильно уменьшатся. Это функция pow:

float specularLuminance = pow(dotProduct, 20);

Эта функция pow возводит результат скалярного умножение в степень 20. Если скалярное произведение равно 1, функция pow возвращает 1, а если оно равно 0.7 (когда угол между двумя векторами равен 45 градусам), функция возвращает 0.0008, что фактически равно нулю в плане освещения. Использование более высоких значений экспоненты приведет к еще большей локализации эффекта.

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

color += specularLuminance * directionalLight.xyz;

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

На прощание

И на этом рубрика «Фактор DirectX» закрывается. Это погружение в DirectX стало одной из самых трудных задач в моей карьере, но в то же время принесло наибольшее удовлетворение, и я надеюсь, что однажды у меня еще будет возможность вернуться к этой мощной технологии.


Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор MSDN Magazine и автор книги «Programming Windows, 6th edition» (O’Reilly Media, 2013) о написании приложений для Windows 8. Его веб-сайт находится по адресу charlespetzold.com.

Выражаю благодарность за рецензирование статьи эксперту Microsoft Дугу Эриксону (Doug Erickson).

На этом Чарльз Петцольд прекращает свою деятельность постоянного ведущего рубрики в журнале «MSDN Magazine». Чарльз покидает нас, чтобы присоединиться к группе в Xamarin, ведущем поставщике кросс-платформенного инструментария, использующего Microsoft .NET Framework. Чарльз был связан с журналом «MSDN Magazine» в течение десятилетий и вел множество регулярных авторских рубрик, в том числе «Подсистемы Foundations», «Экспериментальные UI» и «Фактор DirectX». Мы желаем ему успехов на новом поприще.