В этой небольшой заметке хотелось бы познакомить читателя с основными принципами написания osl-шэйдеров. Конечно, познакомить коротко с тем, что это такое, чем отличается от glsl-шэйдеров. Мы не будем разбирать синтаксис, который можно посмотреть в официальной документации. Я предлагаю понять основные принципы, написав несколько (ровно четыре) примеров шэйдеров для решения разных задач. Прямо вот так, пишем – и разбираемся, что это всё значит.
И ещё, прежде чем переходить к делу, отмечу, что текст статьи полностью авторский. Редко сейчас такое встретишь. Но никаких этих ваших искусственных интеллектов ни на какой стадии написании статьи не использовалось. Ни для редактуры, ни для, прости-господи, генерации. Так что все ошибки наши – человеческие. А не бездушной машины. Может быть это единственное достоинство статьи. Ну, уж какое есть. Что-то у меня AI-дисклеймер большой получился…
0. Что такое OSL
OSL – это язык для написания шэйдеров. Но не просто шэйдеров, а шэйдеров для рендеров, основанных на трассировке лучей.
Напомню коротко как происходит трассировка лучей. Чтобы понятно было, в каком там месте используются эти шэйдеры. В общем, есть камера, сквозь каждый пиксель итоговой картинки выпускается луч, и ищется его пересечение с объектами сцены. Когда найдено первое пересечение, чтобы понять какого цвета соответствующий пиксель изображения, надо посмотреть на цвет объекта и на его освещенность. А вот чтобы посчитать освещенность, запускается еще несколько рандомных лучей в направлениях, которые определяются материалом поверхности в первой точке. Так, например, если материал – это обычный Lambert, то выпускаются рандомные лучи по полусфере, если же это зеркальная поверхность, то в направлении по закону отражения. В общем выпускаются лучи, смотрится, с кем они пересекаются, рекурсивно определяется цвет их точек пересечений, из них собирается освещенность текущей точки – и получается пиксель изображения. Ну вот так всё считается, и в результате у нас красивые картинки.
Это, безусловно, очень упрощённый взгляд на трассировку лучей. Там используется много (впрочем, довольно очевидных) оптимизаций, чтобы вот так уж прямо совсем рандомно не сэмплировать всю сцену, а получать адекватные изображения как можно скорее. Но принцип в целом такой – рендер пускает лучи, считает столкновения, и в зависимости от материала точки столкновения пускает новые лучи и аккумулирует результат.
В какое место тут интегрируются osl-шэйдеры, спросит нетерпеливый читатель. А вот в это вот место, когда посчитана точка пересечения, и надо понять что делать дальше. Ну то есть из каких компонентов собирать итоговый цвет. Это называется closure. Мне не нравится переводить как «замыкание», отдаёт ненужными ассоциациями из программирования. В общем, это совокупность данных, которые описывают поверхность. И osl-шэйдеры вычисляют не цвет фрагмента (как в glsl-шэйдерах), они возвращают closure. А рендер уже смотрит, что же это он такое получил из шэйдера, и в зависимости от этого продолжает трассировку и аккумулирование цвета для пикселей изображения. Конечно, osl-шэйдер может вернуть и цвет, но это так, для утилитарных целей.
По сути качество интеграции osl-шэйдеров в систему рендеринга определяется тем, насколько полно рендер передаёт в шэйдер данные о геометрии, и насколько полно он понимает все возможные варианты того, что шэйдер возвращает. Вообще, какие closure поддерживаются – это зависит исключительно от рендера. Да, есть некий стандартный минимальный набор (ну там emission(), diffuse(N) и прочее), который должен понимать каждый уважающий себя рендер, но даже и они помимо этого могут придумывать свои closure, и osl-шэйдеры их могут возвращать, и всё будет рендериться, и будет хорошо.
Сам по себе язык osl-шэйдеров похож на C: фигурные скобки, точки-с-запятыми, for-ы и if-ы. В общем стандартный синтаксис. И можно не бояться писать как обычно, без этих всяких шэйдерных заморочек. Ну, может кто слыхал, что, к примеру, в glsl-шэйдерах не рекомендуется использовать if/else. Это, дескать, создаёт что-то страшное, шэйдеры на видеокарте начинают долго выполняться, всё становится плохо. А тут можно. Здорово.
В общем я предлагаю познакомиться с osl-шэйдерами на конкретных примерах. Не расписывая скучный синтаксис, правила и принципы. А сразу что-нибудь сделать, и по ходу дела разбираться в этих самых принципах. А, ну да, где эти шэйдеры можно выполнять. В Blender, в рендере Cycles. Другие взрослые рендеры (типа Arnold, RenderMan) тоже их понимают, но где этот Arnold? в Калифорнии? А Blender с Cycles вот – здесь рядом, бесплатно. Хоть объяснять шутки – это моветон, но если кто не понял, то рендер Arnold назван в честь Арнольда Шварцнеггера. И это правда.
Но это мы отвлеклись. Ну так вот, будем писать osl-шэйдеры в Blender и смотреть на результат их рендера рендером Cycles. Вот и всё, что я хотел сказать. Рассмотрим четыре небольших задачки:
-
Поверхностный шэйдер, который реализует базовую спецификацию GLTF
-
Эффект Ambient Occlusion
-
Шэйдер для рисования контурной обводки объектов
-
Построение диаграммы Вороного
Я думаю хватит, чтобы познакомиться и пощупать разные специфические концепции. Хотя не все. В стороне оставим концепцию частных производных. Да-да, а osl-шэйдерах можно считать и использовать частные производные любых значений. Ничего не будем говорить про шэйдеры объёмных сред (типа дыма и огня). Каких-то тем (вроде типов шумовых функций) коснёмся лишь вскользь. Но про всё это, может быть, как-нибудь в другой раз.
1. GLTF Standard Surface
В текстовом файле создаём пустой шэйдер. Для этого пишем ключевое слово shader, потом название gltf_surface и в скобках указываем единственный (и он же выходной) параметр output closure color BSDF = 0.0. И дальше пустое тело функции в фигурных скобках.
shader gltf_surface(
output closure color BSDF = 0.0) {
}
Для теста я использую модель из стандартных gltf-ассетов. Вот эта модель футуристического шлема. Будем использовать простой сетап материала, подключив выходной порт ноды Script с загруженным кодом к порту Surface материала этого нашего шлема.

Все черное, ну так это потому что шэйдер ничего не делает.
Продолжаем. Добавим входные параметры. Это цвет поверхности base_color, коэффициент металличности metallic и коэффициент шероховатости roughness.
shader gltf_surface(
color base_color = color(0.8),
float metallic = 0.0,
float roughness = 0.0,
output closure color BSDF = 0.0) {
}
Теперь сам шэйдер. В зависимости от значения металличности metallic надо посчитать диффузный цвет поверхности, и цвет металлического отражения. Для металлического отражения считаем так
color m_color = mix(color(0.04), base_color, metallic);
Для диффузного цвета так
color d_color = base_color * (1.0 - metallic);
Смысл в том, что чем больше металличность, тем меньше диффузного цвета. А вот с цветом отражения чуть хитрее. Даже при нулевом коэффициенте он не нулевой. Идея в том, что любой материал хоть чуть-чуть, да отражает окружающий мир, и поэтому у любого материала будет небольшой блик. Даже у сильно шероховатого он тоже есть, просто очень размытый.
Дальше формируем диффузный компонент. Основное тут – это использовать встроенный closure diffuse, которое определяется только нормалью в точке, в которой вычисляется шэйдер.
closure color diffuse_component = d_color * diffuse(N);
Компонент отражения вычисляется с помощью другого встроенного closure microfacet. Ему на вход надо подавать тип (ggx в нашем случае), нормаль, шероховатость (два значения по каждой из осей), и последняя константа 0 – это флаг о том, что режим должен быть для отражений, а не преломлений (тогда эта константа должна быть 1).
closure color specular_component = m_color * microfacet("ggx", N, roughness, roughness, 0);
И теперь считаем итоговый closure, просто складывая эти два closure.
BSDF = diffuse_component + specular_component;
Результат.

Делаем metallic = 1, и для roughness = 0 получаем идеальное зеркало, а для roughness = 0.05, уже не такое.

Итоговый код на текущем этапе.
shader gltf_surface(
color base_color = color(0.8),
float metallic = 0.0,
float roughness = 0.0,
output closure color BSDF = 0.0) {
color m_color = mix(color(0.04), base_color, metallic);
color d_color = base_color * (1.0 - metallic);
closure color diffuse_component = d_color * diffuse(N);
closure color specular_component = m_color * microfacet("ggx", N, roughness, roughness, 0);
BSDF = diffuse_component + specular_component;
}
Это было скучно. Теперь добавим текстур. Уберем все входные параметры шэйдера (кроме выходного BSDF), и вместо этого добавим три пути до текстур.
string basecolor_path = "SciFiHelmet_BaseColor.png",
string amr_path = "SciFiHelmet_MetallicRoughness.png",
string normal_path = "SciFiHelmet_Normal.png",
Здесь, понятно, basecolor_path – это пусть до диффузной текстуры, amr_path – это пусть до текстуры, которая в своих каналах хранит карты ambient occlusion, metallic и roughness (именно в таком порядке, такова спецификация gltf), ну и normal_path – путь до карты нормалей. Тут я сознаюсь, что немного сжульничал. Во-первых, для используемого gltf-ассета карта AO хранится отдельно от Metallic и Roughness. Но у той текстуры все равно красный канал пустой, вот я и сделал одной текстуру, чего строчки плодить с путями. И, во-вторых, я тут пишу короткие пути, фактически, просто имена текстур. OSL в Blender понимает относительные пути, но ищет их относительно exe-файла. Так что, чтобы все работало, надо эти текстуры положить рядом с blender.exe. Для учебных целей можно, чтобы покороче все было.
Хорошо, теперь нам надо загрузить диффузный цвет из текстуры. Для этого надо знать uv-координаты. Получаем их с помощью команды getattribute, передав ей на первое место имя атрибута, который хотим получить, а на второе место – имя переменной, куда сохранить результат.
point uv = point(0.0);
getattribute("geom:uv", uv);
В Blender данные о uv-координатах хранятся в атрибуте geom:uv. Это написано в документации.
И здесь я должен снова сознаться в некотором лукавстве. Дело в том, что если сделать вот как только что написал – ничего не будет работать. Данные о uv-координатах будут пустыми. Причина в том, что Blender шибко умный, и для экономии ресурсов не сохраняет атрибуты, если они не нужны шэйдеру. А при использовании osl-шэйдеров он никакие атрибуты не считает нужными, поэтому почти никакие и не сохраняет. И uv-координаты тоже. Решение не совсем красивое. Надо сделать вид, что этот атрибут нужен. Для этого надо использовать в шэйдерном дереве ноду, которая запрашивает uv-координаты. Тогда Blender увидит, что они нужны, сохранит их, а мы их раз – и получим. Но тогда пропадает смысл писать свои шэйдеры, если еще какими-то нодами надо что-то получать. Ну уж что поделать, для тренировки можно. Чтобы все эти ноды, которые не нужны для шэйдинга (но нужны для рендера) не мешались, можно просто наш шэйдер смешать с emissive-шэйдером, поставив тому нулевой вес (а нашему, значит, единичный), и подцепить те ненужные ноды в какие-нибудь порты этого emissive-шэйдера.
Разобрались. Дальше используем функцию texture, чтобы получить непосредственно цвет по этим uv-координатам.
color base_color = texture(basecolor_path, uv.x, uv.y);
Задаём тривиальные значения параметров металличности и шероховатости.
float metallic = 0.0;
float roughness = 0.0;
Рендерим.

Хорошо что ли? Плохо! Цвета не те. Это из-за того, что картинка с диффузной текстурой сохранена в sRGB, а надо чтобы было в линейном цветовом пространстве. Результат рендера движок сам переводит потом в нужное цветовое пространство. Вот и получаются такие блёклые цвета текстуры. В общем, надо сконвертировать обратно в линейное пространство. Формулы хорошо известны. Просто реализуем их. Пишем сначала функцию, которая конвертирует один канал.
float srgb_to_linear(float value) {
if (value < 0.04045) {
return (value < 0.0) ? 0.0 : value * (1.0 / 12.92);
}
else {
return pow((value + 0.055) * (1.0 / 1.055), 2.4);
}
}
И теперь используем её, чтобы сконвертировать все компоненты цвета.
color srgb_to_linear(color c) {
return color(srgb_to_linear(c.r),
srgb_to_linear(c.g),
srgb_to_linear(c.b));
}
Добавляем теперь преобразование base_color.
base_color = srgb_to_linear(base_color);
Рендерим.

Другое дело. Видно, кстати, небольшой резкий спекуляр. Это вот из-за коэффициента 0.04.
Теперь точно так же получаем цвет из текстуры для AO+Metallic+Roughness. Без конвертаций цветового пространства, все такие карты как правило рендерятся в линейном пространстве. Так что тут ничего дополнительного не надо.
color amr_color = texture(amr_path, uv.x, uv.y);
float ao = amr_color.r;
float metallic = amr_color.g;
float roughness = amr_color.b;
Рендерим.

Ещё лучше. Используем теперь ambient occlusion. Не будем ничего сложного выдумывать, просто подмешаем его как коэффициент в диффузном компоненте.
closure color diffuse_component = d_color * ao * diffuse(N);
И теперь самое сложное – карта нормалей. Фактически надо обратно декодировать направление нормали из текстуры и подменить ею нормаль N, которая по умолчанию берётся в точке вычисления шэйдера. Сделаем отдельную функцию, которая принимает путь до карты нормалей и силу эффекта, а возвращает как раз изменённую в соответствии с этой силой нормаль.
normal get_normal(string normal_path, float strength) {
normal n_vector = N;
return n_vector;
}
Пока всё просто – ничего ведь не делается. Функция возвращает собственно стандартную нормаль N. Теперь начинаем считать. Как и раньше получаем uv-координаты и в них сэмплируем текстуру. Тоже без коррекций.
point uv = point(0.0);
getattribute("geom:uv", uv);
color normal_color = texture(normal_path, uv.x, uv.y);
Вообще-то каждая координата вектора нормали ограничена отрезком [-1; 1], а в текстуре можно хранить цвета только из отрезка [0; 1]. Поэтому в текстуре эти цвета поджаты. Надо их подразжать.
color vector_color = 2.0 * (normal_color - color(0.5));
Получили, наконец, три координаты (как компоненты цвета). Но эти координаты – это коэффициенты в разложении по базису (касательный вектор, бикасательный вектор, нормаль). Нормаль-то мы знаем (это N). Вот надо извлечь из геометрии либо касательный вектор, либо бикасательный. Как это сделать – зависит от рендера. Но как правило все нормальные рендеры сохраняют касательные векторы, соответствующие каждой uv-развёртке. В Blender-е в целом точно так же. Получаем касательный вектор из атрибута geom:tangent.
vector tangent;
getattribute("geom:tangent", tangent);
Потом его знак из атрибута geom:tangent_sign.
float tangent_sign;
getattribute("geom:tangent_sign", tangent_sign);
Ситуация с атрибутами geom:tangent и geom:tangent_sign такая же, как с uv-координатами. Blender их сохраняет, только если видит на то запрос. Для этого надо куда-то всунуть ноду Normal Map, к которой, к тому же, надо подсоединить какую-нибудь текстуру. Без текстуры не будет сохранять. Я уже говорил, что Blender шибко умный?
Дальше вычисляем бикасательный вектор через векторное произведение.
vector bitangent = tangent_sign * cross(N, tangent);
И теперь координат итоговой нормали.
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * N);
Последнее. Если мы хотим регулировать степень карты нормалей, то интерполируем посчитанный вектор с оригинальной нормалью.
n_vector = normalize(N + (n_vector - N) * max(strength, 0.0));
Итоговая функция.
normal get_normal(string normal_path, float strength) {
normal n_vector = N;
point uv = point(0.0);
getattribute("geom:uv", uv);
color normal_color = texture(normal_path, uv.x, uv.y);
color vector_color = 2.0 * (normal_color - color(0.5));
vector tangent;
float tangent_sign;
getattribute("geom:tangent", tangent);
getattribute("geom:tangent_sign", tangent_sign);
vector bitangent = tangent_sign * cross(N, tangent);
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * N);
n_vector = normalize(N + (n_vector - N) * max(strength, 0.0));
return n_vector;
}
Теперь в самом шэйдере переопределяем нормаль.
N = get_normal(normal_path, 1.0);
Рендерим.

Готово. Практически полный шэйдер. Эффект свечения и полупрозрачность каждый теперь сможет добавить самостоятельно. В качестве упражнения. Всего-то и надо использовать еще два стандартных closure: emission() и transparent().
2. Ambient Occlusion
В этом примере мы разберём, как сделать эффект ambient occlusion с использованием функциональности трассировки лучей внутри шэйдера. Короче, методом trace. Принцип эффекта ao простой. Из каждой точки поверхности выпускает несколько рандомных лучей, и считается сколько из них пересекается с поверхностью. Если мало – точка не затенена, если много – затенена.
Поехали. Создаём сначала пустой шэйдер.
shader ambient_occlusion(
int only_local = 0,
float bias = 0.01,
int samples = 16,
float max_distance = 1.0,
output float ao = 0.0) {
}
У него три входных параметра:
-
only_local– это флаг, который будет показывать, надо ли считать эффект ao для объекта изолированно от остальных или же вместе с ними -
bias– отступ при трассировке, чтобы луч не натыкался на геометрию в окрестности той точки откуда он выпущен -
samples– определяет сколько рандомных лучей кидать чтобы посчитать степень ao в точке поверхности -
max_distance– на каком расстоянии определять наличие столкновений
Будем для теста использовать ту же самую модель шлема. И чтобы всё было красиво, будем сразу учитывать нормаль, полученную из карты нормалей. Для этого просто используем ранее написанную функцию get_normal.
normal get_normal(string normal_path, float strength) {
normal n_vector = N;
point uv = point(0.0);
getattribute("geom:uv", uv);
color normal_color = texture(normal_path, uv.x, uv.y);
color vector_color = 2.0 * (normal_color - color(0.5));
vector tangent;
float tangent_sign;
getattribute("geom:tangent", tangent);
getattribute("geom:tangent_sign", tangent_sign);
vector bitangent = tangent_sign * cross(N, tangent);
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * N);
n_vector = normalize(N + (n_vector - N) * max(strength, 0.0));
return n_vector;
}
В самом шэйдере пишем получение нормали.
N = get_normal("SciFiHelmet_Normal.png", 1.0);
Дальше нам надо посчитать ортонормированный базис в рассматриваемой точки. В принципе подойдёт любой, поэтому не обязательно привязываться к касательным векторам или еще чему такому-же. Используем фокус из вот этой статьи.
float sign_z = select(-1, 1, N.z >= 0.0);
float a = -1.0 / (sign_z + N.z);
vector bitangent = vector(a * N.x * N.y, sign_z + a * N.y * N.y, -N.y);
vector tangent = vector(1.0 + a * sign_z * N.x * N.x, sign_z * bitangent.x, -sign_z * N.x);
Если кто-то прицепится с вопросом, что всё это значит – читайте в статье. Но мы (те, кому это не интересно) будем исходить из того, что полученная тройка векторов (tangent, bitangent, N) является ортонормированным базисом в точке.
Теперь заводим переменную hits для подсчета лучей, пересекающих геометрию, и запускаем цикл по генерации этих самых рандомных лучей.
float hits = 0;
for(int i = 0; i < samples; i++) {
}
В теле цикла нам надо сгенерировать рандомное направление. Идея состоит в том, что надо сгенерировать два случайных угла, которые интерпретировать как координаты в сферической системе координат, и преобразовать их в координаты в ранее построенном ортонормированном базисе. Тем самым случайных луч будет построен.
Делаем это. Сначала используем функцию hashnoise для генерации случайной точки с координатами в пределах [0; 1). У этой функции есть несколько переопределений, она может принимать как одно число, так и два, точку с тремя координатами и точку с тремя координатами с числом. В первых двух случаях она возвращает одно число, в последних двух – точку с тремя координатами. Вот и хорошо, нам как раз надо два случайных числа. Две координаты из этих трёх ими и будут.
point random = hashnoise(P, float(i));
Понятно, что случайное значение надо вычислять не только от точки P, но и от итерации i цикла, чтобы разные значения получались при разных итерациях.
Далее считаем первый угол phi для сферических координат.
float phi = random.x * M_2PI;
И тут же его синус и косинус. Лишними не будут.
float sin_phi, cos_phi;
sincos(phi, sin_phi, cos_phi);
Дальше косинус второго угла сферической системы координат сразу берём случайным, и считаем его синус.
float z = random.y;
float sin_theta = sqrt(1.0 - z*z);
Всё, теперь декартовы координаты и итоговое направление.
float x = sin_theta * cos_phi;
float y = sin_theta * sin_phi;
vector direction = x * tangent + y * bitangent + z * N;
Ну, кто прогуливал аналитическую геометрию? Точно не мы.
Теперь выпускам луч из точки P в направлении direction.
int hit = select(
trace(P, direction, "mindist", bias, "maxdist", max_distance, "traceset", ""),
trace(P, direction, "mindist", bias, "maxdist", max_distance, "traceset", "__only_local__"),
only_local);
Тут надо пояснить, что происходит. Во-первых, используется функция trace. Она принимает координату точки, откуда начинается луч, направление, и дальше набор необязательных параметров, перечисленных парами. Сначала пишется имя (ключ) этого необязательного параметра, а потом значение. Мы используем mindist – расстояние, до которого игнорировать пересечения, maxdist – расстояние, после которого ничего не считать, traceset – какие объекты рассматривать при трассировке луча. Вообще-то стандарт OSL подразумевает некоторый синтаксис описания этих объектов своими именами. Но в Blender ничего этого не реализовано, кроме двух вариантов: либо пустая строка – тогда в трассировке участвуют все объекты сцены, либо строка only_local – и тогда рассматривается только текущий объект.
Функция select выполняет либо одну команду, либо другую в зависимости от значения флага only_local. Результат hit – 0, если луч ни с чем не пересёкся, и – 1, если пересёкся.
Последнее, считаем число пересечений выпущенных лучей.
hits += hit;
В конце, уже после цикла, считаем итоговый эффект ao.
ao = 1.0 - hits / samples;
Вот общий код.
normal get_normal(string normal_path, float strength) {
normal n_vector = N;
point uv = point(0.0);
getattribute("geom:uv", uv);
color normal_color = texture(normal_path, uv.x, uv.y);
color vector_color = 2.0 * (normal_color - color(0.5));
vector tangent;
float tangent_sign;
getattribute("geom:tangent", tangent);
getattribute("geom:tangent_sign", tangent_sign);
vector bitangent = tangent_sign * cross(N, tangent);
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * N);
n_vector = normalize(N + (n_vector - N) * max(strength, 0.0));
return n_vector;
}
shader ambient_occlusion(
int only_local = 0,
float bias = 0.01,
int samples = 16,
float max_distance = 1.0,
output float ao = 0.0) {
N = get_normal("SciFiHelmet_Normal.png", 1.0);
float sign_z = select(-1, 1, N.z >= 0.0);
float a = -1.0 / (sign_z + N.z);
vector bitangent = vector(a * N.x * N.y, sign_z + a * N.y * N.y, -N.y);
vector tangent = vector(1.0 + a * sign_z * N.x * N.x, sign_z * bitangent.x, -sign_z * N.x);
float hits = 0;
for(int i = 0; i < samples; i++) {
point random = hashnoise(P, float(i));
float phi = random.x * M_2PI;
float sin_phi, cos_phi;
sincos(phi, sin_phi, cos_phi);
float z = random.y;
float sin_theta = sqrt(1.0 - z*z);
float x = sin_theta * cos_phi;
float y = sin_theta * sin_phi;
vector direction = x * tangent + y * bitangent + z * N;
int hit = select(
trace(P, direction, "mindist", bias, "maxdist", max_distance, "traceset", ""),
trace(P, direction, "mindist", bias, "maxdist", max_distance, "traceset", "__only_local__"),
only_local);
hits += hit;
}
ao = 1.0 - hits / samples;
}
Результат рендера. Выглядит правдоподобно.

3. Line Art
В этом примере сделаем шэйдер, который рисует тонкую обводку объекта: его границу, а также перепады высот и заметные неровности геометрии. Так делают в мультяшном шэйдинге.
Шэйдер должен детектить, во-первых, границу объекта, и, во-вторых, резкую смену нормали. Сделаем это так. Для каждой точки, в которой вычисляется шэйдер будет кидать несколько рандомных лучей в окрестность этой точки, и смотреть, как в них устроена геометрия. Если какой-то из лучей уходит за пределы объекта – значит та точка, для которой всё вычисляется, близка к краю объекта, значит она должна быть покрашена чёрным. Типа часть линии. Или может быть нормаль в точке, куда попал рандомный луч, сильно отличается от нормали самой точки – тогда она на границе неровностей, тоже должна быть покрашена чёрным. Чтобы не было резких разрывов будем собирать число лучей которые задетектили тот или иной эффект и принимать решение о том, линия это или нет на основе полученных значений: до некоторого порога – не линия, после него – линия.
Поехали. Создаём пустой шэйдер.
shader line_art(
int samples = 8,
float size = 1.0,
float angle_threshold = 30.0,
float depth_threshold = 0.1,
float clip_threshold = 0.9,
output float value = 1.0) {
}
Параметры:
-
samples– сколько кидать рандомных лучей в окрестности точки. Чем больше – тем точнее и аккуратнее результат -
size– в каком радиусе от исходной точки кидать эти лучи. Чем больше – тем толще получится обводка -
angle_threshold– на сколько градусов должна поменяться нормаль, чтобы задетектить неровность поверхности -
depth_threshold– насколько должно отличаться расстояние от камеры до исходной точки и до рандомной точки пересечения, чтобы задетектить край объекта -
clip_threshold– какая доля рандомных лучей должна задетектить эффект, чтобы точка считалась частью линии -
value– выходной параметр маски: 1.0 – чистая поверхность, 0.0 – линия
Прежде чем запускать цикл рандомного сэмплирования, посчитаем несколько нужных значений. Значит, что нам надо. Ну, нужен базис в системе координат плоскости камеры (или экрана) чтобы в нём выбирать случайные точки, близкие к центру, ну и переменные для сбора лучей, которые задетектили тот или иной эффект.
point camera_position = transform("camera", "world", point(0.0, 0.0, 0.0));
vector basis_right = normalize(transform("camera", "world", vector(1.0, 0.0, 0.0)));
vector basis_up = normalize(transform("camera", "world", vector(0.0, 1.0, 0.0)));
float to_point_distance = distance(P, camera_position);
float sample_radius = size * to_point_distance * 0.01;
float normal_threshold = cos(radians(angle_threshold));
int surface_hits = 0;
int crease_hits = 0;
Что тут что, и какие фокусы использованы. camera_position – это пространственные координаты камеры, из которой делается рендер. Почему он так получен. Дело в том, что в OSL есть несколько систем координат, и командой transform можно легко пересчитывать координаты одного и того же объекта в разных системах координат. Системы координат такие: common, object, world, camera, screen, NDC, raster. По названия не понятно только, что за система координат common, NDC и raster. Common – это система координат, в которой рендер загружает координаты геометрических атрибутов (вроде P, N, I). Это зависит от рендера, но обычно он загружает глобальные координаты. Поэтому как правило ничего конвертировать не надо, но для надёжности имеет смысл каждый такой атрибут пробрасывать через команду
P = transform ("common", "world", P);
NDC – это система координат, растянутая на весь кадр рендера. Левый верхний угол имеет координаты (0, 0), нижний правый – (1, 1).
raster – похожая система координат, только в ней один пиксель имеет шаг 1. Ну то есть левый верхний угол имеет координаты (0, 0), а нижний правый – (width, height).
Теперь понятно, как посчитано значение camera_position. Начало координат в системе координат camera совпадает с камерой. А если перевести в систему координат world, то это будут её глобальные координаты. То, что надо.
Дальше, basis_right и basis_up считаются аналогично. Берем в системе координат camera единичные векторы направо и вверх, и получаем их глобальные координаты. Нормализуем, конечно.
Дальше считаем to_point_distance – это расстояние от камеры до точки, в который вычисляется шэйдер. Чтобы потом сравнивать с этой величиной для детекта краёв.
Считаем sample_radius в зависимости от величины to_point_distance, чтобы при приближении/удалении камеры ширина обводки не менялась.
Величина normal_threshold – это то значение, с которым будем сравнивать скалярное произведение текущей нормали и нормали в случайной точке.
Теперь начинаем цикл.
for (int i = 0; i < samples; i++) {
}
Для каждого шага цикла надо сгенерировать случайную точку в окрестности текущей. Будем использовать полярную систему координат. То есть надо сгенерировать два случайных числа. Это мы уже умеем делать
vector rnd = hashnoise(P, i);
float r_surface = sample_radius * sqrt(rnd.x);
float theta_surface = 2.0 * M_PI * rnd.y;
Теперь вычисляем координаты точки рядом с текущей.
point sample_surface = P + basis_right * r_surface * cos(theta_surface) + basis_up * r_surface * sin(theta_surface);
Дальше вектор направления для трассировки, и предположительное расстояние до точки пересечения (исходя из предположения, что точка sample_surface лежит на поверхности не очень далеко от исходной).
vector dir_surface = normalize(sample_surface - camera_position);
float expected_distance = distance(camera_position, sample_surface);
Трассируем.
if (trace(camera_position, dir_surface, "mindist", 0.001, "traceset", "__only_local__")) {
}
Если луч ушел в пустоту, то ничего тут делать не надо, переменная surface_hits не увеличивается. А вот если пересечение с объектом нашлось, надо посчитать, где конкретно. Для этого надо извлечь расстояние до точки пересечения. Делается это с помощью команды getmessage. Она принимает три аргумента: сначала пишем "trace", это означает, что надо взять результат последней трассировки, потом какой именно атрибут надо получить, в нашем случае “hitdist” (это он так называется), и потом переменная, куда сложить значение.
float hit_distance = 0.0;
getmessage("trace", "hitdist", hit_distance);
if (hit_distance < expected_distance + depth_threshold) {
surface_hits++;
}
Тут стоит отметить, что мы увеличиваем surface_hits только если результат сэмпла не дальше, чем текущая точка, плюс некий допуск. То есть можно ближе, даже на много, и это не будет учитываться. Дело в том, что если сэмпл попадает сильно ближе к камере, то у соответствующей близкой точки этот признак сработает для той точки, что ныне основная, и она будет обведена линий. Тем самым убирается двойная толщина внутренних линий по сравнению с граничными.
Сделаем пуазу, посмотрим результат. В самом конце шэйдера, после цикла считаем итоговое значение.
int is_boundary = (float(surface_hits) / float(samples)) < clip_threshold;
value = is_boundary ? 0.0 : 1.0;
Рендерим.

Эффект видно. По контуру черная линия, на краях объекта, которые нависают над собой – тоже видно. Кто-то скажет – ну не красиво же! И будет прав. Чтобы было красиво, надо уменьшить радиус и увеличить число сэмплов. Рендер будет более долгим, но и более аккуратным.

Вроде теперь нормально. Ну, более-или-менее.
Продолжаем. Теперь нормали. После первой трассировки вычисляем новую точку для сэмпла, используя в половину меньший радиус. Причина в том, что проверка нормалей происходит всегда внутри объекта, и поэтому надо брать вполовину меньший радиус, чтобы не было внутренних линий, в два раза толще внешних.
float r_crease = (sample_radius * 0.5) * sqrt(rnd.x);
float theta_crease = 2.0 * M_PI * rnd.y;
point sample_crease = P + basis_right * r_crease * cos(theta_crease) + basis_up * r_crease * sin(theta_crease);
vector dir_crease = normalize(sample_crease - camera_position);
Дальше делаем ещё одну трассировку.
if (trace(camera_position, dir_crease, "mindist", 0.001, "traceset", "__only_local__")) {
vector sample_normal;
getmessage("trace", "N", sample_normal);
if (dot(normalize(N), normalize(sample_normal)) < normal_threshold) {
crease_hits++;
}
}
С помощью команды getmessage получаем нормаль в точке пересечения (если она есть, а она есть, ведь trace вернул 1), и сравниваем скалярное произведение. Если оно меньше, чем normal_threshold, значит векторы развернуты больше, чем на угол angle_threshold, значит надо задетектить смену нормали.
Посмотрим результат. В конце добавляем вычисление ещё одного флага.
int is_crease = ((float(crease_hits) / float(samples)) >= (1.0 - clip_threshold));
И делаем условие
value = (is_boundary || is_crease) ? 0.0 : 1.0;
Рендерим.

Работает.
Чтобы совсем все было красиво, добавим учёт нормалей не просто от геометрии, а от карты нормалей. Нам понадобится уже ранее использовавшаяся функция для извлечения вектора нормали из текстуры, но перепишем так, чтобы можно было извлекать вектор по любым uv-координатам. Нам ведь это надо будет делать не только в точке, где считается шэйдер, но и в точках сэмплирования. Там что надо сделать подходящую под оба сценария функцию.
normal get_normal(string normal_path, normal input_n, point uv, vector tangent, float tangent_sign) {
normal n_vector = input_n;
color normal_color = texture(normal_path, uv.x, uv.y);
color vector_color = 2.0 * (normal_color - color(0.5));
vector bitangent = tangent_sign * cross(input_n, tangent);
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * input_n);
return n_vector;
}
Ничего объяснять не буду, всё уже объяснено до нас.
Теперь используем эту функцию в двух местах. Первое – это перед циклом сэмплирования надо посчитать актуальный вектор нормали.
point uv;
vector tangent;
float tangent_sign;
getattribute("geom:uv", uv);
getattribute("geom:tangent", tangent);
getattribute("geom:tangent_sign", tangent_sign);
N = get_normal("SciFiHelmet_Normal.png", N, uv, tangent, tangent_sign);
И второй раз вместо простого получения нормали в точке сэмплирования.
normal sample_normal;
point sample_uv;
vector sample_tangent;
float sample_tangent_sight;
getmessage("trace", "N", sample_normal);
getmessage("trace", "geom:uv", sample_uv);
getmessage("trace", "geom:tangent", sample_tangent);
getmessage("trace", "geom:tangent_sign", sample_tangent_sight);
sample_normal = get_normal("SciFiHelmet_Normal.png", sample_normal, sample_uv, sample_tangent, sample_tangent_sight);
Рендер.

Немного аккуратности.

итоговый шэйдер.
normal get_normal(string normal_path, normal input_n, point uv, vector tangent, float tangent_sign) {
normal n_vector = input_n;
color normal_color = texture(normal_path, uv.x, uv.y);
color vector_color = 2.0 * (normal_color - color(0.5));
vector bitangent = tangent_sign * cross(input_n, tangent);
n_vector = normalize(vector_color[0] * tangent + vector_color[1] * bitangent + vector_color[2] * input_n);
return n_vector;
}
shader line_art(
int samples = 8,
float size = 1.0,
float angle_threshold = 30.0,
float depth_threshold = 0.1,
float clip_threshold = 0.9,
output float value = 1.0) {
point camera_position = transform("camera", "world", point(0.0, 0.0, 0.0));
vector basis_right = normalize(transform("camera", "world", vector(1.0, 0.0, 0.0)));
vector basis_up = normalize(transform("camera", "world", vector(0.0, 1.0, 0.0)));
float to_point_distance = distance(P, camera_position);
float sample_radius = size * to_point_distance * 0.01;
float normal_threshold = cos(radians(angle_threshold));
point uv;
vector tangent;
float tangent_sign;
getattribute("geom:uv", uv);
getattribute("geom:tangent", tangent);
getattribute("geom:tangent_sign", tangent_sign);
N = get_normal("SciFiHelmet_Normal.png", N, uv, tangent, tangent_sign);
int surface_hits = 0;
int crease_hits = 0;
for (int i = 0; i < samples; i++) {
vector rnd = hashnoise(P, i);
float r_surface = sample_radius * sqrt(rnd.x);
float theta_surface = 2.0 * M_PI * rnd.y;
point sample_surface = P + basis_right * r_surface * cos(theta_surface) + basis_up * r_surface * sin(theta_surface);
vector dir_surface = normalize(sample_surface - camera_position);
float expected_distance = distance(camera_position, sample_surface);
if (trace(camera_position, dir_surface, "mindist", 0.001, "traceset", "__only_local__")) {
float hit_distance = 0.0;
getmessage("trace", "hitdist", hit_distance);
if (hit_distance < expected_distance + depth_threshold) {
surface_hits++;
}
}
float r_crease = (sample_radius * 0.5) * sqrt(rnd.x);
float theta_crease = 2.0 * M_PI * rnd.y;
point sample_crease = P + basis_right * r_crease * cos(theta_crease) + basis_up * r_crease * sin(theta_crease);
vector dir_crease = normalize(sample_crease - camera_position);
if (trace(camera_position, dir_crease, "mindist", 0.001, "traceset", "__only_local__")) {
normal sample_normal;
point sample_uv;
vector sample_tangent;
float sample_tangent_sight;
getmessage("trace", "N", sample_normal);
getmessage("trace", "geom:uv", sample_uv);
getmessage("trace", "geom:tangent", sample_tangent);
getmessage("trace", "geom:tangent_sign", sample_tangent_sight);
sample_normal = get_normal("SciFiHelmet_Normal.png", sample_normal, sample_uv, sample_tangent, sample_tangent_sight);
if (dot(normalize(N), normalize(sample_normal)) < normal_threshold) {
crease_hits++;
}
}
}
int is_boundary = (float(surface_hits) / float(samples)) < clip_threshold;
int is_crease = ((float(crease_hits) / float(samples)) >= (1.0 - clip_threshold));
value = (is_boundary || is_crease) ? 0.0 : 1.0;
}
4. Voronoi diagram
Сейчас сделаем шэйдер для стандартной диаграммы Вороного. Если кто не знает, что это такое – то вот ссылка. Теперь мы знаем, что это такое. Если бы мы не знали, что это такое, но мы знаем, что это такое.
Короче, кроме шуток, создаём пустой шейдер.
shader voronoi(
float size = 1.0,
float jitter = 1.0,
output float value = 0.0) {
}
У него всего два входных параметра:
-
sizeбудет отвечать за масштаб -
jitter– за хаотичность расположения точек
Коротко идею шэйдера. Разобьём все пространство на кубические ячейки со стороной 1. Для каждой точки, в которой вычисляется шэйдер, мы посмотрим на ячейку, в которую попадает точка, и ещё на 26 смежных с ней. В каждой из этих ячеек есть случайно расположенная точка – одна из вершин, по которым строится диаграмма (то есть это не вершина диаграммы, а её, с позволения сказать, центр). Далее находим два кратчайших расстояния до этих центров среди всех 27 ячеек. Тогда линии диаграммы – это в точности те точки поверхности, в которых эти два кратчайших расстояния совпадают. Вот их и будем отрисовывать.
Поехали программировать. Применяем масштаб
point p_scale = P / max(0.000001, size);
Считаем, в какую ячейку попала эта промасштабированная точка
point p_index = floor(p_scale);
Можно значение p_index воспринимать как нижняя угловая координата кубической ячейки, содержащей точку p_scale. Далее задаём две переменные, которые будут собирать значения кратчайших расстояний.
float f1 = 5.0;
float f2 = 5.0;
Можно инициализировать потенциальные минимумы вот этими значениями 5.0, так как настоящие найденные минимумы будут заведомо меньше. Дальше запускаем цикл по всем 27-ми ячейкам.
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
for (int k = -1; k <= 1; k++) {
}
}
}
Внутри цикла считаем координаты угла соответствующей ячейки.
point cell_index = p_index + vector(i, j, k);
Используем одну из шумовых функций OSL, чтобы получить рандомное значение (как 3d-вектор) для этой ячейки.
vector cell_offset = noise("cell", cell_index);
Ну да, это оказывается есть такая встроенная функция. Шум типа cell для поданной на вход точки в пространстве выдает рандомное значение, одно и то же для всей ячейки со стороной 1. Отлично просто.
Дальше считаем позицию рандомной точки внутри ячейки, и расстояние до неё от нашей текущей точки.
point cell_position = cell_index + jitter * cell_offset;
float distance = distance(cell_position, p_scale);
Ну и дальше сохраняем это найденное расстояние, если оно потенциально одно из двух минимальных
if (distance < f1) {
f2 = f1;
f1 = distance;
}
else if (distance < f2) {
f2 = distance;
}
Специалисты по шэйдерам, наверное, поморщатся в этом месте – ну разве можно такие прямолинейные if/else писать. Надо ведь что-то нечитаемое, вроде такого.
float new_f1 = step(distance, f1);
float new_f2 = mix(step(distance, f2), 0.0, new_f1);
f2 = mix(f2, f1, new_f1);
f1 = mix(f1, distance, new_f1);
f2 = mix(f2, distance, new_f2);
Нет, не надо нам такого. Если что, вот этот вот только что написанный фрагмент и тот, что до него, они имеют один и тот же смысл. Функция step(a, b) возвращает 1, если a <= b, и 0 – в противном случае. А функция mix (a, b, c) интерполирует значение между a и b в зависимости от значения c. Ну вот, теперь остаётся только сидеть, вглядываться в написанный код, и волосы на голове рвать от досады, что внимание постоянно куда-то срывается и ничего ухватить до конца не можешь. Говорю же, не надо нам такого.
Теперь конец, уже после всех циклов, вычисляем итоговое значение.
value = 1.0 - smoothstep(0.0, 0.01, f2 - f1);
Рендерим.

Толщину и мягкость линий контролирует второй параметр в функции smoothstep, который в нашем примере равен 0.01.
Полный код шэйдера.
shader voronoi(
float size = 1.0,
float jitter = 1.0,
output float value = 0.0) {
point p_scale = P / max(0.000001, size);
point p_index = floor(p_scale);
float f1 = 5.0;
float f2 = 5.0;
for( int i = -1; i <= 1; i++ ) {
for( int j = -1; j <= 1; j++ ) {
for( int k = -1; k <= 1; k++ ) {
point cell_index = p_index + vector(i, j, k);
vector cell_offset = noise("cell", cell_index);
point cell_position = cell_index + jitter * cell_offset;
float distance = distance(cell_position, p_scale);
if (distance < f1) {
f2 = f1;
f1 = distance;
}
else if (distance < f2) {
f2 = distance;
}
}
}
}
value = 1.0 - smoothstep(0.0, 0.01, f2 - f1);
}
Автор: Tugcga


