Обрезка полигонов.

Введение.

Конечно, все что мы визуализируем, сделано из треугольников, но кто-то спросил меня, как обрезать форму многоугольника на основе списка точек в шейдере, поэтому я объясню, как это сделать сейчас. Я объясню, как делать с однопроходным шейдером во фрагментном шейдере. По-другому, можно было бы генерировать треугольники на основе вашего многоугольника и использовать стенсил (stencil) буферы для обрезки, но я не буду объяснять это в этом уроке.
Поскольку этот урок объясняет простой метод, который не производит крутую графику, я объясню его на unlit шейдере, но он работает точно так же в поверхностных шейдерах. Основой для этого урока будет мой простой шейдер со свойствами, поэтому вы должны изучить его, прежде чем начинать этот урок.

Рисуем линию.

//Данные, которые используются для генерации пикселей фрагментным шейдером
struct v2f{
    float4 position : SV_POSITION;
    float3 worldPos : TEXCOORD0;
};

//вертексный шейдер
v2f vert(appdata v){
    v2f o;
    //Конвертируем позицию вершины из пространства объекта в пространство экрана
    o.position = UnityObjectToClipPos(v.vertex);
    //Расчитываем и назначаем позицию вершины в мире
    float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
    o.worldPos = worldPos.xyz;
    return o;
}
Затем мы можем перейти к фрагментному шейдеру. Здесь мы начнем с вычисления того, с какой стороны линии находится точка. Поскольку мы позже будем генерировать наши линии на основе точек, проще всего определить их как две точки, через которые проходит линия.
Чтобы вычислить, на какой стороне линии находится точка, мы генерируем два вектора, во-первых, вектор, который идет от любой точки линии до нашей точки и второй «нормаль к линии». Обычно понятие нормали к линии не имеет большого смысла, но здесь нам нужна левая и правая стороны линии, поэтому мы можем определить нормаль как вектор, который ортогонально расположен слева от направления линии.
Когда у нас есть эти векторы, мы можем вычислить их dot продукт и получить сторону, в которой находится точка. Если dot продукт положительный, вектор точки указывает в том же направлении, что и нормаль. Если точечный продукт отрицателен, вектор к точке указывает в противоположном направлении от нормали, а точка находится на другой стороне. Если dot продукт точно равен нулю, вектор к точке ортогонален нормальной линии и точка находится на линии.

Чтобы сделать это в коде шейдера, мы начинаем с определения двух точек, которые определяют линию, а затем вычисляют эти три вектора, которые нам нужны. Начнем с расчета направления линии. Мы получаем его путем вычитания первой точки линии из второй (при расчете разницы между двумя точками нам всегда нужно вычитать начало из цели, если мы заботимся о направлении). Затем мы поворачиваем точку на 90 градусов, меняя местами ее x и y компоненты и инвертируя новую часть x (если мы инвертируем y-часть, у нас будет вектор, указывающий направо от линии). И, наконец, мы вычитаем одну из точек, определяющих линию, из точки, которую мы проверяем, чтобы получить вектор к точке.
После этого мы берем dot продукт нормали к линии normal и вектора в точку что мы рисуем на экране.
float2 linePoint1 = float2(-1, 0);
float2 linePoint2 = float2(1, 1);

//переменные, нужные нам для расчетов
//направлении линии
float2 lineDirection = linePoint2 - linePoint1;
//нормаль к линии
float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
//вектор к проверяемой точке
float2 toPos = i.worldPos.xy - linePoint1;

//На какой стороне
float side = dot(toPos, lineNormal);
side = step(0, side);

return side;

Как вы можете видеть, на мы увидим небольшой градиент на линии, которую мы определили. Но мы не хотим градиента, нам нужна четкая дифференциация. Градиент здесь, потому что все цвета ниже 0 (справа от линии) считаются черными, все цвета между 0 и 1 (рядышком слева от линии) являются значениями шкалы серого и все цвета 1 и выше (слева от линии) отображаются как белые. Легким решением для этого является функция шага (step), которая принимает два значения и возвращает 0, если значение слева больше и 1 в противном случае. Поэтому, мы и использовали эту функцию, а не напрямую значение dot продукта.

Мы продолжаем добавив новую точку и две новые строки, которые образуют треугольник. Для этого лучше всего сделать расчеты, которые мы сделали до сих пор, так, чтобы их можно было повторно использовать. Для этого мы переводим все наши вычисления в новый метод и передаем ему информацию, которую мы используем в качестве аргументов - точку, которую хотим проверить, первую точку линии и вторую точку линии.
//возвращает 1 если точка слева от линии и 0 если справа
float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2){
    // переменные, нужные нам для расчетов
    float2 lineDirection = linePoint2 - linePoint1;
    float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
    float2 toPos = pos - linePoint1;

    // с какой стороны точка
    float side = dot(toPos, lineNormal);
    side = step(0, side);
    return side;
}

//фрагментный шейдер
fixed4 frag(v2f i) : SV_TARGET{
    float2 linePoint1 = float2(-1, 0);
    float2 linePoint2 = float2(1, 1);

    side = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);

    return side;
}

Рисуем полигон из нескольких линий.

Когда мы хотим объединить результаты нескольких линий, мы можем сделать это по-разному. Мы можем определить, что результат будет истинным, если он слева от всех строк, иначе – ложь. По другому мы можем сказать, что результат верен, если он слева от одной или нескольких строк и ложь только, если она находится справа от всех строк. Треугольники определяются по часовой стрелке, что означает, что слева от строк все находится снаружи. Т.е. для дифференциации внутри и снаружи многоугольника нам нужно найти объединение всех фрагментов «левой стороны». Мы делаем это, добавляя результаты строк, внешние стороны будут складываться и иметь значения 1 или выше, внутри многоугольника будет иметь значение 0 всюду.
//фрагментный шейдер
fixed4 frag(v2f i) : SV_TARGET{
    float2 linePoint1 = float2(-1, 0);
    float2 linePoint2 = float2(1, 1);
    float2 linePoint3 = float2(1, -1);

    float outsideTriangle = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);
    outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint2, linePoint3);
    outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint3, linePoint1);

    return outsideTriangle;
}

Теперь, когда мы можем успешно отобразить многоугольник, я хотел бы расширить его, чтобы мы могли легко отредактировать его без редактирования шейдерного кода. Для этого мы добавляем две новые переменные: массив позиций и насколько заполнен массив. Первый будет содержать все точки нашего многоугольника, второй нужен потому что шейдеры не поддерживают динамические массивы, поэтому мы должны выбрать длину для массива, а затем мы заполняем его более или менее.
//переменные углов
uniform float2 _corners[1000];
uniform uint _cornerCount;

Заполнение массива углов.

Для массивов нет свойств, поэтому мы должны заполнить их с помощью кода C#. Я добавил два атрибута в новый класс. Первый – чтобы он выполнялся в режиме редактирования, чтобы скрипт обновил наш полигон без нас, на старте игры. Второй требует компонент, чтобы убедиться, что скрипт находится в том же игровом объекте, что и рендеринг, который имеет материал с шейдером, который мы пишем.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Renderer))]
public class PolygonController : MonoBehaviour {
 

}
Затем мы добавляем в класс две переменные, материал с шейдером и массив точек, которые мы передадим затем шейдеру. Материал является приватным, потому что мы получим его через код, и он используется только в этом классе. Массив позиции также является приватным, потому что нам не нужен доступ извне, но мы даем ему атрибут сериализации, чтобы Unity запоминала его значение и показывала его в инспекторе.
[SerializeField]
private Vector2[] corners;

private Material _mat;
Затем мы напишем метод, который передаст информацию в шейдер. В нем мы сначала проверяем, загрузили ли мы уже материал, получаем рендерер в игровом объекте, и берем с него материал, если мы этого еще не сделали. Мы используем поле sharedmaterial рендерера, потому что, если бы мы использовали поле материала, мы создали бы копию материала, что мы не хотим здесь делать.
Затем мы выделяем новый массив из 4d векторов, который может содержать 1000 переменных. Причина, по которой мы используем 4d-векторы вместо требуемых векторов 2d, состоит в том, что API Unity позволяет нам передавать только 4d-векторы, а причина для длины 1000 заключается в том, что, как я уже упоминал ранее, шейдеры не поддерживают динамические длины массивов, поэтому мы выбираем максимум точек. Я выбрал 1000 случайным образом.
Затем мы заполняем этот массив позициями наших точек, 2d-векторы автоматически преобразуются в 4d-векторы с 0 в 3-й и 4-й позиции.
После того, как мы подготовили наш векторный массив, мы передаем его нашему материалу, а затем передадим ему количество позиций, которые мы фактически используем.
void UpdateMaterial(){
    //Проверяем есть ли у нас уже материал
    if(_mat == null)
    // если нет – получаем его
        _mat = GetComponent().sharedMaterial;

    //заполняем массив 
    Vector4[] vec4Corners = new Vector4[1000];
    for(int i=0;i < corners.Length;i++){
        vec4Corners[i] = corners[i];
    }

    //передаем массив материалу
    _mat.SetVectorArray("_corners", vec4Corners);
    _mat.SetInt("_cornerCount", corners.Length);
} 
Следующий шаг - на самом деле вызвать эту функцию. Мы делаем это двумя способами, в Start и в OnValidate. Первый будет автоматически вызываться Unity, когда игра начнется, а вторая будет автоматически вызываться Unity, когда переменная скрипта изменяется в инспекторе.
void Start(){
    UpdateMaterial();
}

void OnValidate(){
    UpdateMaterial();
}
После написания скрипта мы можем добавить его в наш проект. Мы просто добавляем его в качестве компонента в тот же игровой объект, что и рендер, с нашим материалом. И когда мы настраиваем наш скрипт, мы можем легко установить наши углы, добавив массив в инспектор.

Затем мы вернемся к нашему шейдеру, чтобы на самом деле использовать массив. Для этого мы создаем нашу внешнюю треугольную переменную как ноль.
Затем мы перебираем массив. Мы начинаем цикл с 0, потому что первый индекс массивов в hlsl адресуется 0, второй с 1 и т. д. ... Мы останавливаемся, когда значение итератора переместится по количеству углов, которые мы указали через C#, и мы увеличиваем итератор на 1 каждый цикл. Мы прямо указываем hlsl цикл for, альтернативой было бы развернуть его, что означает, просто скопировать содержимое, происходящее в цикле for друг под другом. Обычно разматывание выполняется быстрее в шейдерах, но у нас нет фиксированной длины в нашем случае, поэтому мы должны использовать цикл.
В цикле мы просто добавляем возвращаемое значение боковой функции одной строки. В качестве точек линии мы используем вершину угла полигона в позиции итератора и вершину угла в позиции итератора плюс один. Проблема, возникающая при использовании этого плюса, заключается в том, что в последнем пункте мы получаем массив в точке, которую мы не задали, но мы хотим вернуться к первой точке вместо этого. В этой позиции нам помогает оператор остатка по модулю. Мы добавляем один к итератору, а затем берем модуль с длиной допустимого массива, таким образом, он перескакивает обратно в 0, если достигнет конца массива.
//Фрагментный шейдер
fixed4 frag(v2f i) : SV_TARGET{

    float outsideTriangle = 0;
    
    [loop]
    for(uint index;index < _cornerCount;index++){
        outsideTriangle += isLeftOfLine(i.worldPos.xy, _corners[index], _corners[(index+1) % _cornerCount]);
    }

    return outsideTriangle;
}

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

Обрезка и цвет полигона.

Человек, который попросил этот урок, спросил, как обрезать многоугольник, так что это последнее, что мы собираемся добавить здесь. В hlsl есть функция для удаления многоугольников, называемых клипами. Мы передаем ему значение, и если это значение меньше 0, фрагмент не будет отображаться, иначе функция ничего не сделает.
Мы можем передать переменную externalTriangle в функцию клипа, но ничего не произойдет, потому что все значения равны 0 или выше. Чтобы фактически вырезать все за пределами многоугольника, мы можем просто инвертировать значение, а значения внутри полигона останутся 0, а все внешние значения будут отрицательными и будут обрезаны.
Поскольку мы теперь используем переменную externalTriangle для ее использования, мы можем теперь прекратить рисовать ее на экране и снова вывести цвет.
    clip(-outsideTriangle);
    return outsideTriangle;
}


Самым большим недостатком этого метода является то, что мы можем создавать только выпуклые многоугольники, он ломается, когда мы пытаемся использовать вогнутые.
Shader "Tutorial/014_Polygon"
{
    //показываем значения для редактирования в инспекторе
    Properties{
        _Color ("Color", Color) = (0, 0, 0, 1)
    }

    SubShader{
        //материал полностью непрозрачен и визуализируется с непрозрачными
        Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

        Pass{
            CGPROGRAM

            //включаем полезные функции
            #include "UnityCG.cginc"

            //определяем вертексный и фрагментный шейдер
            #pragma vertex vert
            #pragma fragment frag

            fixed4 _Color;

            //переменные для углов
            uniform float2 _corners[1000];
            uniform uint _cornerCount;

            //данные, которые предаются в вершинный шейдер
            struct appdata{
                float4 vertex : POSITION;
            };

            //данные, которые передаются в фрагментный шейдер
            struct v2f{
                float4 position : SV_POSITION;
                float3 worldPos : TEXCOORD0;
            };

            //вершинный шейдер
            v2f vert(appdata v){
                v2f o;
                //Конвертируем позицию вершины из пространства объекта в пространство экрана
                o.position = UnityObjectToClipPos(v.vertex);
                //Расчитываем и назначаем позицию вершины в мире
                float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.worldPos = worldPos.xyz;
                return o;
            }

            //возвращает 1 если точка слева от линии и 0 если справа
            float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2){
                //переменные, нужные нам для расчетов
                float2 lineDirection = linePoint2 - linePoint1;
                float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
                float2 toPos = pos - linePoint1;

                //На какой стороне
                float side = dot(toPos, lineNormal);
                side = step(0, side);
                return side;
            }

            //фрагментный шейдер
            fixed4 frag(v2f i) : SV_TARGET{

                float outsideTriangle = 0;
                
                [loop]
                for(uint index;index < _cornerCount;index++){
                    outsideTriangle += isLeftOfLine(i.worldPos.xy, _corners[index], _corners[(index+1) % _cornerCount]);
                }

                clip(-outsideTriangle);
                return _Color;
            }

            ENDCG
        }
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Renderer))]
public class PolygonController : MonoBehaviour {
 [SerializeField]
 private Vector2[] corners;

 private Material _mat;

 void Start(){
  UpdateMaterial();
 }

 void OnValidate(){
  UpdateMaterial();
 }
 
 void UpdateMaterial(){
  // Проверяем есть ли у нас уже материал
  if(_mat == null) 
      // если нет – получаем его
   _mat = GetComponent().sharedMaterial;
  
  // заполняем массив
  Vector4[] vec4Corners = new Vector4[1000];
  for(int i=0;i < corners.Length;i++){
   vec4Corners[i] = corners[i];
  }

  // передаем массив материалу
  _mat.SetVectorArray("_corners", vec4Corners);
  _mat.SetInt("_cornerCount", corners.Length);
 } 

}
Надеюсь, вы узнали что-то о том, как подойти к проблемам с несколькими точками и векторами.



Перевод Беляев В.А. ака seaman

Комментариев нет:

Отправить комментарий