Рисуем на модели в Unity3d

Хотите добавить в вашу игру немного вандализма, в смысле уличного искусства, или добавить мейкап в редактор персонажей? Тогда вам должно понравиться.

Почему не цпу и SetPixels?

Во-первых: это очень, очень медленно. Ладно бы только перебирать массивы на цпу, но чтобы загрузить результат в видеопамять нужно использовать Apply, который сразу же угашивает фпс в ноль (если просто вызывать Apply в апдейте, на 4096 текстуре, на моей 3060 фпс просаживается до 15, и это без каких либо вычислений)
Во-вторых: непонятно как рисовать кистью если у модели есть несколько UV островов.

Что нам понадобится:

Кисть, холст, немного говнокода и костыли(куда же без них)

Кисть

Вот она:

Рисуем на модели в Unity3d

Ну, на самом деле, от куба на нужна только world2object матрица которую мы скормим в шейдер.

Shader.SetGlobalMatrix("_BrushMatrix", brushTr.worldToLocalMatrix);

Ставим наш кубик через рейкаст на модельку, поворачиваем по нормали и передаём в шейдер.

Также, в шейдере нам понадобятся текстура кисти и её цвет.

Shader.SetGlobalColor("_BrushColor", brushColor); Shader.SetGlobalTexture("_BrushTex", brushTex);

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

struct v2f { ......... float4 worldPos : TEXCOORD1; //добавляем в выходную структуру переменную содержащуюмировые координаты }; float4x4 _BrushMatrix; //объявляем переменную для матрицы кисти v2f vert (appdata v) { .................. o.worldPos = mul(unity_ObjectToWorld, v.vertex);// преобразуем локальные координаты в мировые return o; }

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

float3 inBrushSpace = mul(_BrushMatrix, i.worldPos); float2 brushUV = inBrushSpace.xy+float2(0.5, 0.5); //начало координат UV находится в углу, а у кисти в центре, поэтому координаты нужно сместить fixed brushMask = tex2D(_MainTex, brushUV).r;
Всё вроде работает, но кисть бесконечно большая и рисует насквозь.

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

float3 absCord = abs(inBrushSpace); brushMask *= absCord.x>0.5 || absCord.y>0.5 || absCord.z>0.5 ? 0:1; return fixed4(1-brushMask.xxx, 1);

Холст

Кисть проецируется, а как мазки попадут в текстуру холста?
Нужно рисовать модель не как обычно, умножая координаты на MVP матрицу, а использовать UV координаты вместо позиций вертексов, тогда будет выводиться развёрнутая версия меша.

v2f vert (appdata v) { ............ o.vertex = float4(v.uv.x - 0.5, -v.uv.y + 0.5, 0, 0.5); }

Осталось только отрисовать модельку в рендертекстуру и использовать эту текстуру как основу для следующего мазка.

fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 inBrushSpace = mul(_BrushMatrix, i.worldPos); float2 brushUV = inBrushSpace.xy+float2(0.5, 0.5); fixed brushMask = tex2D(_BrushTex, brushUV).r; float3 absCord = abs(inBrushSpace); brushMask *= absCord.x>0.5 || absCord.y>0.5 || absCord.z>0.5 ? 0:1; fixed4 resultCol = lerp(col, _BrushColor, brushMask); return resultCol; }

Вообще будет работать и с одной текстурой, но писать в ту же текстуру из которой читаем- не очень хорошая идея, а если попробовать это сделать через Blit, то он просто будет спамить ошибку.

Поэтому делаем даблбуфферинг, создаём две текстуры и флаг который будет менять их местами.

private bool bufferFlag; private RenderTexture FrontTex => bufferFlag ? renderTextures[1] : renderTextures[0]; private RenderTexture BackTex => bufferFlag ? renderTextures[0] : renderTextures[1]; private void FlipBuffer() { bufferFlag = !bufferFlag; outputMat.mainTexture = FrontTex; } void Start() { renderTextures = new RenderTexture[2]; for (int i = 0; i < renderTextures.Length; i++) { //renderTextures[i] = new RenderTexture(rttSize, rttSize, GraphicsFormat.B5G5R5A1_UNormPack16, GraphicsFormat.None); renderTextures[i] = new RenderTexture(rttSize, rttSize, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear); renderTextures[i].autoGenerateMips = false; renderTextures[i].Create(); } }

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

Говнокод

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

Сверху кисть рисовала в одно и то же место 
Сверху кисть рисовала в одно и то же место 
private bool UpdateDelda() { var delta = Vector3.Distance(brushTr.position, prevBrushPos); if (delta / brushSize > spacing) { prevBrushPos = brushTr.position; return true; } return false; }

Чтобы отрисовать модельку с нужным шейдером есть несколько вариантов, я выбрал самый ленивый- создать дополнительную камеру, назначить ей маску слоёв в которой будет только моделька, на которой мы рисуем, но не будет курсора-кубика. И отрисовать её через RenderWithShader, который применяет назначений шейдер ко всему, что видит камера.

brushCamera.targetTexture = BackTex;//назначаем камере бекбуффер в качестве таргета brushCamera.RenderWithShader(brushShader, string.Empty);//string.Empty- не использовать теги при замене шейдеров FlipBuffer();//меняем буфферы местами

Т.к. текстуры у нас две, нужно назначать текстуру фронтбуфера на материале, который выводит финальную версию модели.

private void FlipBuffer() { bufferFlag = !bufferFlag; outputMat.mainTexture = FrontTex; }
Мазки сохраняются, но есть заметный шов на краях UV островов.

Костыли

Чтобы убрать швы, мы сначала зальём острова сплошным цветом с единичной альфой.

fixed4 frag(v2f i) : SV_Target { return 1; }

А потом закрасим прозрачные пиксели, цветом непрозрачных соседей.

Массив сдвигов в вершинном шейдере:

o.taps[0] = o.uv + _MainTex_TexelSize * half2(0, 1) * _BlurStep; o.taps[1] = o.uv + _MainTex_TexelSize * half2(1, 1) * _BlurStep; o.taps[2] = o.uv + _MainTex_TexelSize * half2(1, 0) * _BlurStep; o.taps[3] = o.uv + _MainTex_TexelSize * half2(1, -1) * _BlurStep; o.taps[4] = o.uv + _MainTex_TexelSize * half2(0, -1) * _BlurStep; o.taps[5] = o.uv + _MainTex_TexelSize * half2(-1, -1) * _BlurStep; o.taps[6] = o.uv + _MainTex_TexelSize * half2(-1, 0) * _BlurStep; o.taps[7] = o.uv + _MainTex_TexelSize * half2(-1, 1) * _BlurStep;

Закрашиваем пиксель если у него есть непрозрачные соседи:

fixed4 frag (v2f i) : SV_Target { fixed4 color = tex2D(_MainTex, i.uv); if(color.a > 0.0001) return color; color = tex2D(_MainTex, i.taps[0]); for(int iter = 1; iter < 8; iter++) { fixed4 s = tex2D(_MainTex, i.taps[iter]); color = lerp(color, s, s.a); } return fixed4(color.rgb, 0); }

Рисуем текстуру холста через Blit, применяя материал с шейдером краёв:

Graphics.Blit(FrontTex, BackTex, blurMaterial); FlipBuffer();
Теперь швы закрашиваются, но нарисованный цвет не совпадает с тем, который мы назначили кисти. 
Теперь швы закрашиваются, но нарисованный цвет не совпадает с тем, который мы назначили кисти. 

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

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

o.Albedo = GammaToLinearSpace(o.Albedo);

Результат

Исходник проекта

Теги:

1313
1 комментарий

Окей, прикольно

1
Ответить