В предыдущем туториале мы научились создавать и перемещать простые фигуры с помощью функций расстояний со знаком. В этой статье мы научимся комбинировать несколько фигур для создания более сложных полей расстояний. Большинству описанных здесь техник я научился из библиотеки функций расстояний со знаком на glsl, которую можно найти здесь (http://mercury.sexy/hg_sdf). Также существует несколько способов комбинирования фигур, которые я здесь не рассматриваю.
Подготовка
Для визуализации полей расстояний со знаком (signed distance fields, SDF) мы будем использовать одну простую конфигурацию, а затем применим к ней операторы. Для отображения полей расстояний в ней будет использоваться визуализация линий расстояний из первого туториала. Ради упрощения мы будем задавать все параметры за исключением параметров визуализации в коде, но вы можете заменить любое значение свойством, чтобы сделать его настраиваемым.
Основной шейдер, с которого мы начнём, выглядит так:
Shader "Tutorial/035_2D_SDF_Combinations/Champfer Union"{
Properties{
_InsideColor("Inside Color", Color) = (.5, 0, 0, 1)
_OutsideColor("Outside Color", Color) = (0, .5, 0, 1)
_LineDistance("Mayor Line Distance", Range(0, 2)) = 1
_LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05
[IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4
_SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01
}
SubShader{
//the material is completely non-transparent and is rendered at the same time as the other opaque geometry
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
Pass{
CGPROGRAM
#include "UnityCG.cginc"
#include "2D_SDF.cginc"
#pragma vertex vert
#pragma fragment frag
struct appdata{
float4 vertex : POSITION;
};
struct v2f{
float4 position : SV_POSITION;
float4 worldPos : TEXCOORD0;
};
v2f vert(appdata v){
v2f o;
//calculate the position in clip space to render the object
o.position = UnityObjectToClipPos(v.vertex);
//calculate world position of vertex
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}
float scene(float2 position) {
const float PI = 3.14159;
float2 squarePosition = position;
squarePosition = translate(squarePosition, float2(1, 0));
squarePosition = rotate(squarePosition, .125);
float squareShape = rectangle(squarePosition, float2(2, 2));
float2 circlePosition = position;
circlePosition = translate(circlePosition, float2(-1.5, 0));
float circleShape = circle(circlePosition, 2.5);
float combination = combination_function(circleShape, squareShape);
return combination;
}
float4 _InsideColor;
float4 _OutsideColor;
float _LineDistance;
float _LineThickness;
float _SubLines;
float _SubLineThickness;
fixed4 frag(v2f i) : SV_TARGET{
float dist = scene(i.worldPos.xz);
fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist));
float distanceChange = fwidth(dist) * 0.5;
float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance);
float distanceBetweenSubLines = _LineDistance / _SubLines;
float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines;
float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance);
return col * majorLines * subLines;
}
ENDCG
}
}
FallBack "Standard" //fallback adds a shadow pass so we get shadows on other objects
}
А функция 2D_SDF.cginc в одной папке с шейдером, которую мы будем расширять, поначалу выглядит так:
#ifndef SDF_2D
#define SDF_2D
//transforms
float2 rotate(float2 samplePosition, float rotation){
const float PI = 3.14159;
float angle = rotation * PI * 2 * -1;
float sine, cosine;
sincos(angle, sine, cosine);
return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x);
}
float2 translate(float2 samplePosition, float2 offset){
//move samplepoint in the opposite direction that we want to move shapes in
return samplePosition - offset;
}
float2 scale(float2 samplePosition, float scale){
return samplePosition / scale;
}
//shapes
float circle(float2 samplePosition, float radius){
//get distance from center and grow it according to radius
return length(samplePosition) - radius;
}
float rectangle(float2 samplePosition, float2 halfSize){
float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize;
float outsideDistance = length(max(componentWiseEdgeDistance, 0));
float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0);
return outsideDistance + insideDistance;
}
#endif
Простые сочетания
Мы начнём с нескольких простых способов сочетания двух фигур для создания одной большой фигуры, сопряжений, пересечений и вычитаний, а также со способа преобразования одной фигуры в другую.
Сопряжение
Самый простой оператор — это сопряжение. С его помощью мы можем сложить две фигуры вместе и получить расстояние со знаком соединённой фигуры. Когда у нас есть расстояние со знаком двух фигур, мы можем скомбинировать их, взяв меньшее из двух значение с помощью функции min
.
Из-за выбора меньшего из двух значений конечная фигура будет ниже 0 (видима) там, где одна из двух входящих фигур имеет расстояние до ребра меньше 0; то же самое относится ко всем другим значениям расстояний, показывая сочетание двух фигур.
Здесь я назову функцию для создания сопряжения «merge», частично потому, что мы выполняем их слияние (merging), частично потому, что ключевое слово union в hlsl зарезервировано, поэтому его нельзя использовать в качестве названия функции.
//in 2D_SDF.cginc include file
float merge(float shape1, float shape2){
return min(shape1, shape2);
}
//in scene function in shader
float combination = merge(circleShape, squareShape);
Пересечение
Ещё один распространённый способ соединения фигур — использование областей, в которых две фигуры накладываются друг на друга. Для этого мы берём максимальное значение расстояний двух фигур, которые мы хотим объединить. При использовании наибольшего из двух значений мы получаем значение больше 0 (вне фигуры), когда любое из расстояний до двух фигур находится за пределами фигуры, а другие расстояния тоже выстраиваются аналогично.
//in 2D_SDF.cginc include file
float intersect(float shape1, float shape2){
return max(shape1, shape2);
}
//in scene function in shader
float combination = intersect(circleShape, squareShape);
Вычитание
Однако часто мы не хотим обрабатывать обе фигуры одинаково, и нам нужно вычесть из одной фигуры другую. Это довольно легко сделать, выполнив пересечение между фигурой, которую мы хотим изменить, и всем кроме фигуры, которую мы хотим вычесть. Мы получаем обратные значения для внутренней и внешней частей фигуры, инвертировав расстояние со знаком. То, что находилось на 1 единицу снаружи фигуры, теперь находится на 1 единицу внутри.
//in 2D_SDF.cginc include file
float subtract(float base, float subtraction){
return intersect(base, -subtraction);
}
//in scene function in shader
float combination = subtract(squareShape, circleShape);
Интерполяция
Неочевидный способ комбинирования двух фигур — это интерполяция между ними. Она также в определённой степени возможна для полигональных мешей с blendshapes, но гораздо более ограничена, чем то, что мы можем делать с полями расстояний со знаком. Простой интерполяцией между расстояниями двух фигур мы добиваемся плавного перетекания одной в другую. Для интерполяции можно просто использовать метод lerp
.
//in 2D_SDF.cginc include file
float interpolate(float shape1, float shape2, float amount){
return lerp(shape1, shape2, amount);
}
//in scene function in shader
float pulse = sin(_Time.y) * 0.5 + 0.5;
float combination = interpolate(circleShape, pulse);
Другие соединения
Получив простые соединения, мы уже имеем всё необходимое для простого комбинирования фигур, но удивительное свойство полей расстояний со знаком заключается в том, что мы можем не ограничиваться этим, есть много разных способов комбинирования фигур и выполнения интересных действий в местах их соединения. Здесь я снова объясню только некоторые из таких техник, но вы можете найти многие другие в библиотеке http://mercury.sexy/hg_sdf (напишите мне, если вам известны другие полезные SDF-библиотеки).
Скругление
Мы можем интерпретировать поверхность двух комбинируемых фигур как оси x и y позиции в системе координат, а затем вычислить расстояние до точки начала координат этой позиции. Если мы так сделаем, то получим очень странную фигуру, но если ограничить ось значениями ниже 0, то получим нечто, напоминающее плавное сопряжение внутренних расстояний двух фигур.
float round_merge(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1, shape2);
intersectionSpace = min(intersectionSpace, 0);
return length(intersectionSpace);
}
Это красиво, но мы не можем с помощью этого изменить линию там, где расстояние равно 0, поэтому такая операция пока не более ценна, чем обычное сопряжение. Но прежде чем соединять две фигуры, мы можем их немного увеличить. Аналогично тому, как мы создавали круг, для увеличения фигуры мы вычитаем из её расстояния, чтобы вытолкнуть дальше наружу линию, в которой расстояние со знаком равно 0.
float radius = max(sin(_Time.y * 5) * 0.5 + 0.4, 0);
float combination = round_intersect(squareShape, circleShape, radius);
float round_merge(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
intersectionSpace = min(intersectionSpace, 0);
return length(intersectionSpace);
}
Это просто увеличивает фигуру и обеспечивает плавность переходов внутри, но мы не хотим увеличивать фигуры, нам нужен только плавный переход. Решение заключается в том, чтобы снова вычитать радиус после вычисления длины. Большинство частей будут выглядеть так же, как раньше, кроме перехода между фигурами, который красиво сглаживается в соответствии с радиусом. Внешнюю часть фигуры мы пока проигнорируем.
float round_merge(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
intersectionSpace = min(intersectionSpace, 0);
return length(intersectionSpace) - radius;
}
Последний этап — исправление внешней части фигуры. Кроме того, пока внутренности фигуры зелёные, а этот цвет мы используем для внешней части. Первым шагом мы поменяем местами внешнюю и внутреннюю части, просто обратив их расстояние со знаком. Затем мы заменим часть, где вычитается радиус. Сначала мы изменим его с вычитания на сложение. Это нужно, потому что до комбинирования с радиусом мы обратили расстояние вектора, поэтому в соответствии с этим нужно обратить используемую математическую операцию. Затем мы заменим радиус обычным сопряжением, что даст нам правильные значения снаружи фигуры, но не близко к краям и внутри фигуры. Чтобы избежать этого, мы берём максимум между значением и радиусом, получая таким образом положительную величину правильных значений снаружи фигуры, а также нужное нам прибавление радиуса внутри фигуры.
float round_merge(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
intersectionSpace = min(intersectionSpace, 0);
float insideDistance = -length(intersectionSpace);
float simpleUnion = merge(shape1, shape2);
float outsideDistance = max(simpleUnion, radius);
return insideDistance + outsideDistance;
}
Для создания пересечения нам нужно выполнить противоположное — уменьшить фигуры на величину радиуса, обеспечить, чтобы все компоненты вектора были больше 0, взять длину и не менять её знак. Так мы создадим наружнюю часть фигуры. Затем чтобы создать внутреннюю часть, мы берём обычное пересечение и обеспечиваем, чтобы оно не было меньше минус радиуса. Затем мы, как и раньше, складываем внутренние и наружные значения.
float round_intersect(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 + radius, shape2 + radius);
intersectionSpace = max(intersectionSpace, 0);
float outsideDistance = length(intersectionSpace);
float simpleIntersection = intersect(shape1, shape2);
float insideDistance = min(simpleIntersection, -radius);
return outsideDistance + insideDistance;
}
И в качестве последнего пункта вычитание снова может быть описано как пересечение между базовой фигурой и всем, кроме фигуры, которую мы вычитаем.
float round_subtract(float base, float subtraction, float radius){
round_intersect(base, -subtraction, radius);
}
Здесь, и особенно при вычитании можно заметить артефакты, возникающие из-за допущения о том, что мы можем использовать две фигуры в качестве координат, но для большинства применений поля расстояний всё равно достаточно хороши.
Скос
Также мы можем скосить переход, чтобы придать ему угол наподобие фаски. Чтобы добиться такого эффекта, мы сначала создаём новую фигуру, сложив имеющиеся две. Если мы снова допустим, что точка, в которой встречаются две фигуры, ортогональна, то эта операция даст нам диагональную линию, проходящую через точку встречи двух поверхностей.
Поскольку мы просто сложили два компонента, расстояние со знаком этой новой линии имеет неверный масштаб, но мы можем это исправить, разделив его на диагональ единичного квадрата, то есть на квадратный корень из 2. Деление на корень из 2 — это то же самое, что умножение на квадратный корень 0.5, и мы просто можем записать это значение в код, чтобы не вычислять каждый раз один и тот же корень.
Теперь, получив фигуру, имеющую форму нужного скоса, мы расширим её, чтобы скос выходил за пределы фигуры. Точно так же, как раньше, мы вычитаем нужную нам величину, чтобы увеличить фигуру. Затем мы объединяем фигуру скоса с выходным результатом обычного слияния, получив скошенный переход.
float champferSize = sin(_Time.y * 5) * 0.3 + 0.3;
float combination = champfer_merge(circleShape, squareShape, champferSize);
float champfer_merge(float shape1, float shape2, float champferSize){
const float SQRT_05 = 0.70710678118;
float simpleMerge = merge(shape1, shape2);
float champfer = (shape1 + shape2) * SQRT_05;
champfer = champfer - champferSize;
return merge(simpleMerge, champfer);
}
Для получения пересечённого скоса мы, как и раньше, складываем две фигуры, но затем уменьшаем фигуру, прибавив величину скоса и выполняем пересечение с обычной пересечённой фигурой.
float champfer_intersect(float shape1, float shape2, float champferSize){
const float SQRT_05 = 0.70710678118;
float simpleIntersect = intersect(shape1, shape2);
float champfer = (shape1 + shape2) * SQRT_05;
champfer = champfer + champferSize;
return intersect(simpleIntersect, champfer);
}
И аналогично с предыдущими вычитаниями мы также можем выполнить здесь пересечение с инвертированной второй фигурой.
float champfer_subtract(float base, float subtraction, float champferSize){
return champfer_intersect(base, -subtraction, champferSize);
}
Скруглённое пересечение
Пока мы использовали только булевы операторы (если не считать интерполяции). Но мы можем комбинировать фигуры и другими способами, например, создав новые скруглённые фигуры в местах наложения границ двух фигур.
Чтобы это сделать, нам снова нужно интерпретировать две фигуры как оси x и y точки. Затем мы просто вычисляем расстояние этой точки до точки начала координат. Там, где накладываются границы двух фигур, расстояние до обеих фигур будет равно 0, что даёт нам расстояние 0 до точки начала нашей выдуманной системы координат. Тогда, если у нас есть расстояние до точки начала координат, мы можем выполнить с ней те же операции, что и для кругов, и вычесть радиус.
float round_border(float shape1, float shape2, float radius){
float2 position = float2(shape1, shape2);
float distanceFromBorderIntersection = length(position);
return distanceFromBorderIntersection - radius;
}
Выемка на границе
Последнее, что я объясню — способ создания выемки в одной фигуре в позиции границы другой фигуры.
Мы начнём с вычисления формы границы окружности. Это можно сделать, получив абсолютное значение расстояния первой фигуры, при этом и внутренняя, и внешняя части будут считаться внутренней частью фигуры, но граница всё равно имеет значение 0. Если мы увеличим эту фигуру, вычтя ширину выемки, то получим фигуру вдоль границы предыдущей фигуры.
float depth = max(sin(_Time.y * 5) * 0.5 + 0.4, 0);
float combination = groove_border(squareShape, circleShape, .3, depth);
float groove_border(float base, float groove, float width, float depth){
float circleBorder = abs(groove) - width;
return circleBorder;
}
Теперь нам нужно, чтобы граница круга уходила вглубь только на указанную нами величину. Для этого мы вычитаем из неё уменьшенную версию базовой фигуры. Величина уменьшения базовой фигуры является глубиной выемки.
float groove_border(float base, float groove, float width, float depth){
float circleBorder = abs(groove) - width;
float grooveShape = subtract(circleBorder, base + depth);
return grooveShape;
}
Последним шагом мы вычитаем выемку из базовой фигуры и возвращаем результат.
float groove_border(float base, float groove, float width, float depth){
float circleBorder = abs(groove) - width;
float grooveShape = subtract(circleBorder, base + depth);
return subtract(base, grooveShape);
}
Исходники
Библиотека
#ifndef SDF_2D
#define SDF_2D
//transforms
float2 rotate(float2 samplePosition, float rotation){
const float PI = 3.14159;
float angle = rotation * PI * 2 * -1;
float sine, cosine;
sincos(angle, sine, cosine);
return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x);
}
float2 translate(float2 samplePosition, float2 offset){
//move samplepoint in the opposite direction that we want to move shapes in
return samplePosition - offset;
}
float2 scale(float2 samplePosition, float scale){
return samplePosition / scale;
}
//combinations
///basic
float merge(float shape1, float shape2){
return min(shape1, shape2);
}
float intersect(float shape1, float shape2){
return max(shape1, shape2);
}
float subtract(float base, float subtraction){
return intersect(base, -subtraction);
}
float interpolate(float shape1, float shape2, float amount){
return lerp(shape1, shape2, amount);
}
/// round
float round_merge(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 - radius, shape2 - radius);
intersectionSpace = min(intersectionSpace, 0);
float insideDistance = -length(intersectionSpace);
float simpleUnion = merge(shape1, shape2);
float outsideDistance = max(simpleUnion, radius);
return insideDistance + outsideDistance;
}
float round_intersect(float shape1, float shape2, float radius){
float2 intersectionSpace = float2(shape1 + radius, shape2 + radius);
intersectionSpace = max(intersectionSpace, 0);
float outsideDistance = length(intersectionSpace);
float simpleIntersection = intersect(shape1, shape2);
float insideDistance = min(simpleIntersection, -radius);
return outsideDistance + insideDistance;
}
float round_subtract(float base, float subtraction, float radius){
return round_intersect(base, -subtraction, radius);
}
///champfer
float champfer_merge(float shape1, float shape2, float champferSize){
const float SQRT_05 = 0.70710678118;
float simpleMerge = merge(shape1, shape2);
float champfer = (shape1 + shape2) * SQRT_05;
champfer = champfer - champferSize;
return merge(simpleMerge, champfer);
}
float champfer_intersect(float shape1, float shape2, float champferSize){
const float SQRT_05 = 0.70710678118;
float simpleIntersect = intersect(shape1, shape2);
float champfer = (shape1 + shape2) * SQRT_05;
champfer = champfer + champferSize;
return intersect(simpleIntersect, champfer);
}
float champfer_subtract(float base, float subtraction, float champferSize){
return champfer_intersect(base, -subtraction, champferSize);
}
/// round border intersection
float round_border(float shape1, float shape2, float radius){
float2 position = float2(shape1, shape2);
float distanceFromBorderIntersection = length(position);
return distanceFromBorderIntersection - radius;
}
float groove_border(float base, float groove, float width, float depth){
float circleBorder = abs(groove) - width;
float grooveShape = subtract(circleBorder, base + depth);
return subtract(base, grooveShape);
}
//shapes
float circle(float2 samplePosition, float radius){
//get distance from center and grow it according to radius
return length(samplePosition) - radius;
}
float rectangle(float2 samplePosition, float2 halfSize){
float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize;
float outsideDistance = length(max(componentWiseEdgeDistance, 0));
float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0);
return outsideDistance + insideDistance;
}
#endif
Основа шейдера
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Simple/sdf_union.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Simple/sdf_intersect.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Simple/sdf_subtract.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Simple/sdf_interpolate.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Fancy/sdf_round.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Fancy/sdf_champfer.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Fancy/sdf_border_intersection.shader
- https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/035_SDF_combining_repeating/Fancy/sdf_groove.shader
Shader "Tutorial/035_2D_SDF_Combinations/Round"{
Properties{
_InsideColor("Inside Color", Color) = (.5, 0, 0, 1)
_OutsideColor("Outside Color", Color) = (0, .5, 0, 1)
_LineDistance("Mayor Line Distance", Range(0, 2)) = 1
_LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05
[IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4
_SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01
}
SubShader{
//the material is completely non-transparent and is rendered at the same time as the other opaque geometry
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
Pass{
CGPROGRAM
#include "UnityCG.cginc"
#include "2D_SDF.cginc"
#pragma vertex vert
#pragma fragment frag
struct appdata{
float4 vertex : POSITION;
};
struct v2f{
float4 position : SV_POSITION;
float4 worldPos : TEXCOORD0;
};
v2f vert(appdata v){
v2f o;
//calculate the position in clip space to render the object
o.position = UnityObjectToClipPos(v.vertex);
//calculate world position of vertex
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}
float scene(float2 position) {
const float PI = 3.14159;
float2 squarePosition = position;
squarePosition = translate(squarePosition, float2(1, 0));
squarePosition = rotate(squarePosition, .125);
float squareShape = rectangle(squarePosition, float2(2, 2));
float2 circlePosition = position;
circlePosition = translate(circlePosition, float2(-1.5, 0));
float circleShape = circle(circlePosition, 2.5);
float combination = /* combination calculation here */;
return combination;
}
float4 _InsideColor;
float4 _OutsideColor;
float _LineDistance;
float _LineThickness;
float _SubLines;
float _SubLineThickness;
fixed4 frag(v2f i) : SV_TARGET{
float dist = scene(i.worldPos.xz);
fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist));
float distanceChange = fwidth(dist) * 0.5;
float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance);
float distanceBetweenSubLines = _LineDistance / _SubLines;
float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines;
float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance);
return col * majorLines * subLines;
}
ENDCG
}
}
FallBack "Standard" //fallback adds a shadow pass so we get shadows on other objects
}
Автор: PatientZero