2D магія в деталях. Частина друга. Структура


Пам'ятаєте відомий мем про "короваї"? Напевно, кожен, хто розробляє ігри (або хотів би цим зайнятися) роздумує про якийсь "проект мрії", де можна буде "грабувати коровани" і "набигать". А ще, щоб погода змінювалася динамічно, і бруду на сліди від чобіт залишалися, і дерева росли в реальному часі. І ще, щоб ...
Зрозуміло, що в реальному ігровому проекті така гонитва за бажаннями — смерті подібна. А ось в техно-демо версії — саме те.
Попередні статті
Частина перша. Світло.
Частина друга. Структура.
Зміст
  1. Вступ
  2. Спрайт
  3. Полігони
  4. Pixel perfect і цілочисельна геометрія
  5. Стара структура проекту
  6. Region tree
  7. Менеджери
  8. Постэффекты
  9. Думки про майбутнє
Вступ
Нагадаю, що в минулій частині був опитування: на яку тему написати таку статтю. І динамічна вода здалася спільноти найбільш цікавою. Але це довга історія і почати доведеться з структури проекту та алгоритми, які використовуються під капотом, а потім розповісти про доопрацювання освітлення і постэффектах. Текст вийде великий, так що вода переїжджає далі, в одну з найближчих статей. До речі, ця стаття містить поменше красивих рендерів, але куди більше технічних хінтів. Не сумуйте.
З моменту написання попередньої статті було зроблено дуже багато. Переписаний начисто весь код проекту, оптимізовані алгоритми, додані нові джерела світла, реалізовано фонове освітлення, реалізована вода з відблисками, хвилями, кипінням і замерзанням. Як ви могли помітити — ні слова про персонажів або геймплейної складової, це ще попереду. Не буду забігати вперед і розповім все по порядку.
Спрайт
Наш проект про магів, а значить, без старих кам'яних замків не обійтися. Ось тільки малювати кожен з нуля — собі дорожче. Спробуємо збирати їх з невеликих шматочків, наприклад, ось таких:

Акуратні шматочки, з яких можна зробити ВСЕ.
Щоб у цих шматочків був загальний контур, напишемо який-небудь шейдер, а статичний батчинг в Unity3d оптимізує кількість викликів відтворення. От тільки щоб отримати загальний контур, доведеться використовувати двухпроходний шейдер з stencil буфером: перша частина намалює контури, а друга — заливку. А будь-які елементи, які використовують матеріали з многопроходными шейдерами в батчинге не беруть участь. Краще перетворювати кожен спрайт двічі, але з різними матеріалами. Кількість вершин збільшиться, зате викликів відтворення залишиться всього два.

Рендерим суцільний контур.

Додаємо текстуру.
Таким нехитрим способом можемо створити ось такий замок:

Стіни в редакторі. Текстуру і колір контуру можна налаштувати окремо.
Хінти і підводні камені
  1. Прибираємо копипасту. Звичайно, не варто руками копіювати спрайт. У мене є клас Contour, який містить всі необхідні налаштування для спрайту і 2 матеріалу. При появі на сцені цей клас створює за два спадкоємця з SpriteRenderer (для контуру і фону).


  1. Автоматизуємо перенесення. Спочатку у мене вже були префабы-спрайт, які використовувалися на сцені (кілька сотень елементів). Коли я вирішив обернути їх у Contour, зміни префабов чомусь не застосовувалися до створених об'єктів. На щастя, можна легко написати скрипт, який для кожного існуючого елемента знайде відповідний префаб (по імені), і створить в потрібній позиції елемент з цього префаба. Ключові методи — UnityEditor.AssetDatabase.LoadAssetAtPath і UnityEditor.PrefabUtility.ConnectGameObjectToPrefab
  2. Правильний drag'n'drop. Мінус поділу на спрайт — тепер за замовчуванням на сцені вибирається і використовується в drag' ' drop'е один з спрайтів-спадкоємців. Вирішується проблема додаванням атрибута [SelectionBase] перед класом Contour.
  3. Відображення префабов. В меню проекту префабы з контурами тепер не відображаються як спрайт, і, якщо чесно, я не знайшов способу самому генерувати іконку. Тому в префабы я додав ще й SpriteRenderer, спрайт якому задає мій Contour. При додаванні на сцену я видаляю з об'єкта не потрібний в геймплеї SpriteRenderer.
  4. Видалення з OnValidate. При додаванні об'єкта на сцену викликається OnValidate і саме там я видаляю SpriteRenderer. От тільки ні Destroy ні DestroyImmediate у цьому методі не працюють (без чаклунства з власним редактором класу), тому використовую такий милицю:
    #if UNITY_EDITOR
    void OnValidate() {
    if (UnityEditor.PrefabUtility.GetPrefabParent(gameObject) == null && UnityEditor.PrefabUtility.GetPrefabObject(gameObject) != null) {
    var renderer = GetComponent<SpriteRenderer>();
    renderer.sprite = sprite;
    return;
    } else {
    var renderer = GetComponent<SpriteRenderer>();
    UnityEditor.EditorApplication.delayCall+=()=>
    {
    if (renderer == null)
    return;
    
    DestroyImmediate(renderer);
    };
    }
    }
    #endif


Поділюся кодом: клас Contour
using UnityEngine;
using System.Collections;
using NewEngine.Core.Components;

namespace NewEngine.Core.Static {
[SelectionBase]
public class Contour : MonoBehaviour {
public interface SpriteSettings {
Color Color { get; set; }

int SortingLayerId { get; set; }

string SortingLayerName { get; set; }

int SortingOrder { get; set; }

Material Material { get; set; }
}

[System.Serializable]
class SpriteSettingsImpl : SpriteSettings {
[SerializeField] Material material;
[SerializeField] SortingLayer sortingLayer;
[SerializeField] int sortingOrder;
[SerializeField] Color color = Color.white;
SpriteRenderer spriteRenderer;

public Color Color {
set { color = value; }
get { return color; }
}

public int SortingLayerId {
set {
// TODO мб внутрішні функції дозволяють обійтися без циклу?

foreach (var layer in SortingLayer.layers) {
if (layer.id != value)
continue;

sortingLayer = layer;

if (spriteRenderer != null)
spriteRenderer.sortingLayerID = sortingLayer.id;

return;
}

sortingLayer = new SortingLayer();

if (spriteRenderer != null)
spriteRenderer.sortingLayerID = sortingLayer.id;
}

get {
return sortingLayer.id;
}
}

public string SortingLayerName {
set {
// TODO мб внутрішні функції дозволяють обійтися без циклу?

foreach (var layer in SortingLayer.layers) {
if (layer.name != value)
continue;

sortingLayer = layer;

if (spriteRenderer != null)
spriteRenderer.sortingLayerID = sortingLayer.id;

return;
}

sortingLayer = new SortingLayer();

if (spriteRenderer != null)
spriteRenderer.sortingLayerID = sortingLayer.id;
}

get {
return sortingLayer.name;
}
}

public int SortingOrder {
set {
sortingOrder = value;

if (spriteRenderer != null)
spriteRenderer.sortingOrder = sortingOrder;
}
get { return sortingOrder; }
}

public Material Material {
set {
material = value; 

if (spriteRenderer != null)
spriteRenderer.sharedMaterial = material;
}
get { return material; }
}

public SpriteRenderer SpriteRenderer {
set { 
spriteRenderer = value;

if (spriteRenderer == null)
return;

spriteRenderer.color = color;
spriteRenderer.sortingOrder = sortingOrder;
spriteRenderer.sortingLayerID = sortingLayer.id;
spriteRenderer.material = material;
}
}
}

[SerializeField] SpriteSettingsImpl fillSettings;
[SerializeField] SpriteSettingsImpl contourSettings;
[SerializeField] Sprite sprite;

[SerializeField] bool flipX;
[SerializeField] bool flipY;

SpriteRenderer fillSprite;
SpriteRenderer contourSprite;

void OnValidate() {
#if UNITY_EDITOR
if (IsPrefab) {
var renderer = this.GetRequiredComponent<SpriteRenderer>();
renderer.sprite = sprite;
return;
} else {
var renderer = this.GetRequiredComponent<SpriteRenderer>();
UnityEditor.EditorApplication.delayCall+=()=>
{
if (renderer == null)
return;

DestroyImmediate(renderer);
};
}
#endif 
var tmpFill = FillSprite;
var tmpContour = ContourSprite;

ApplySettings(fillSprite, fillSettings);
ApplySettings(contourSprite, contourSettings);
}

public SpriteRenderer FillSprite {
get {
if (IsPrefab)
return null;

if (fillSprite == null)
fillSprite = Create(fillSettings, "fill");

return fillSprite;
}
}

public SpriteRenderer ContourSprite {
get {
if (IsPrefab)
return null;

if (contourSprite == null)
contourSprite = Create(contourSettings, "contour");

return contourSprite;
}
}

public SpriteSettings FillSettings { get { return fillSettings; } }

public SpriteSettings ContourSettings { get { return contourSettings; } }

public bool FlipX {
get {
return flipX;
}
set {
flipX = value;
FillSprite.flipX = flipX;
ContourSprite.flipX = flipX;
}
}

public bool FlipY {
get {
return flipY;
}
set {
flipY = value;
FillSprite.flipY = flipY;
ContourSprite.flipY = flipY;
}
}

public Sprite Sprite {
get {
return sprite;
}
set {
sprite = value;
FillSprite.sprite = sprite;
ContourSprite.sprite = sprite;
}
}

SpriteRenderer Create(SpriteSettingsImpl settings, string spriteName) {
var child = transform.FindChild(spriteName);
var obj = child == null ? null : child.gameObject;

if (obj == null) {
obj = new GameObject();
obj.name = spriteName;
obj.transform.parent = transform;
}

var sprite = obj.GetRequiredComponent<SpriteRenderer>();
if (sprite == null) {
sprite = obj.AddComponent<SpriteRenderer>();
sprite.receiveShadows = false;
sprite.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
sprite.useLightProbes = false;
}

ApplySettings(sprite, settings);
return sprite;
}

void ApplySettings(SpriteRenderer spriteRenderer, SpriteSettingsImpl settings) {
spriteRenderer.flipX = flipX;
spriteRenderer.flipY = flipY;
spriteRenderer.sprite = sprite;
settings.SpriteRenderer = spriteRenderer;

spriteRenderer.transform.localPosition = Vector3.zero;
spriteRenderer.transform.localScale = Vector3.one;
spriteRenderer.transform.localRotation = Кватерніонів.identity;
}

bool IsPrefab {
get {
#if UNITY_EDITOR
return UnityEditor.PrefabUtility.GetPrefabParent(gameObject) == null && UnityEditor.PrefabUtility.GetPrefabObject(gameObject) != null;
#else
return false;
#endif
}
}
}

}
#endif

Шейдер для заливки
Shader "NewEngine/Game/Foreground/Contour"
{
Properties
{
_MainTex ("MainTex", 2D) = "white" {}
[MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
}
SubShader
{
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
"PreviewType"="Plane"
"CanUseSpriteAtlas"="True"
}
Cull Off
Lighting Off
ZWrite On
ZTest Off
Fog { Mode Off }
Blend One OneMinusSrcAlpha

Pass
{
// Використовується в подальшому
Stencil
{
WriteMask 7
Ref 6
Pass Replace
}

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float4 color : COLOR;
};

sampler2D _MainTex;

v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
#ifdef PIXELSNAP_ON
v.vertex = UnityPixelSnap (v.vertex);
#endif
o.uv = v.uv;
o.color = v.color;
return o;
}

fixed4 frag (v2f i) : SV_Target0
{
fixed4 color = tex2D(_MainTex, i.uv) * i.color;

if (color.a == 0)
discard;

return i.color * color.a;
}
ENDCG
}
}
}

Шейдер для контуру. Магічні числа і дивні умови додаються
Shader "NewEngine/Game/Foreground/Fill"
{
Properties
{
_MainTex ("MainTex", 2D) = "white" {}
_BackgroundTex ("BackgroundTex", 2D) = "white" {}
_MaskColor ("MaskColor", Color) = (0, 0, 0, 0)
[MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
}
SubShader
{
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
"PreviewType"="Plane"
"CanUseSpriteAtlas"="True"
}
Cull Off
Lighting Off
ZWrite On
ZTest Off
Fog { Mode Off }
Blend One OneMinusSrcAlpha

Pass
{
Stencil
{
WriteMask 7
Ref 2
Pass Replace
}

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 uv : TEXCOORD0;
};

struct v2f
{
float4 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float4 color : COLOR;
};

sampler2D _BackgroundTex;
sampler2D _MainTex;
float4 _BackgroundTex_ST;
float4 _BackgroundTex_TexelSize;
fixed4 _MaskColor;

v2f vert (appdata v) 
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv.xy = v.uv;

o.uv.zw = mul(_Object2World, v.vertex) * fixed4(1 / _BackgroundTex_TexelSize.zw * 32, 1, 1);
o.color = v.color;
return o;
}

fixed4 frag (v2f i) : SV_Target
{
fixed4 backgroundColor = tex2D(_BackgroundTex, i.uv.zw) * i.color;
fixed4 mask = tex2D(_MainTex, i.uv.xy);

if (mask.a == 0 || backgroundColor.a == 0 || length(mask - _MaskColor) > 0.00001 /* threshold */)
discard;

return backgroundColor * backgroundColor.a;
}
ENDCG
}
}
}

Полігони
Правда, іноді доведеться робити мішанину з спрайтів: якщо ми хочемо отримати великий і складний об'єкт, як поверхню землі з ямами і печерами. Замість цього будемо генерувати такі елементи на льоту, у редакторі (як PolygonCollider2D). Рендерим полігони через стандартний MeshRenderer з двома матеріалами (для поділу контуру і заливки використовуються submeshes).
Не стану стверджувати, що написати акуратний редактор для полігонів просто. Але вся інформація доступна в інтернеті, а в AssetStore є готові рішення.

Редактор, невідмітний від редактора в PolygonCollider2D.
Для максимальної гнучкості класи для полігонів вибудувані так:
  • Core.Shapes.Shape — основний клас, що містить точки полігону і потрібну математику. Не MonoBehaviour;
  • Core.Shapes.EditableShape — спадкоємець MonoBehaviour для зберігання і редагування Shape;
  • Core.Shapes.ShapeRenderer — відображає полігон з EditableShape з допомогою MeshRenderer;
  • Core.Shapes.ShapeCollider2D — створює фізичний полігон з EditableShape з допомогою PolygonCollider2D.
Черговий хинтЧомусь я дуже довго втрачав з уваги атрибут RequireComponent. Це дуже зручний механізм для автоматичного додавання потрібних компонентів. Наприклад, ShapeRenderer вимагає EditableShape і MeshRenderer, при додаванні його в GameObject автоматично створюються всі залежності.
Pixel perfect і цілочисельна геометрія
Використовуючи великі пікселі ми вбиваємо цілу зграю зайців:
  • Можемо рендери невелике зображення (піксель в піксель) і розтягувати його під екран;
  • Можемо використовувати складні постэффекты (невеликі текстури збільшать продуктивність);
  • Можемо працювати з цілочисловою арифметикою (швидше і приємніше);
  • Можемо маскувати недоробки або баги :)
Правда за цих зайців доведеться заплатити, реалізувавши підтримку "цілочисельний геометрії", а саме:
  • Базові речі, такі як:
    • IntVector2 — по суті, копія UnityEngine.Vector2, але з цілочисельними координатами. Іноді потрібно конвертувати дані з звичайного UnityEngine.Vector2, з урахуванням масштабу або без (в одному юніті 32 пікселя, тільки не треба використовувати магічні числа!), тому додаємо наступні функції:
      public Vector2 ToPixels();
      public Vector2 ToUnits();
      public static IntVector2 FromPixels(Vector2 v);
      public static IntVector2 FromUnits(Vector2 v);
      public static IntVector2 FromUnitsRound(Vector2 v);
      public static IntVector2 FromUnitsCeil(Vector2 v);
    • IntRect. Нічим не відрізняється по функціональності від UnityEngine.Rect. Доступний метод LineCollision для пошуку перетинів з відрізками прямих (алгоритм Ліанга-Панськи;
    • IntLine. Відрізок прямої з цілочисельним початком і кінцем. Використовується в деяких алгоритмах;
    • IntMatrix. Матриця для двовимірних перетворень, де повороти кратні 90°, а зміщення і масштаб — целочисленны;
    • IntAngle. Невеликий клас для округлення градусів до 90°, вибору напрямку. Уявляєте, яка там красива таблиця косинусів?
    • Poser. Елемент, який позиціонує GameObject кратно ігровому "пікселю", що забороняє повороти, не кратні прямому куту і масштабування на дробове значення (з урахуванням того, що в елементі може бути SpriteRender і тоді доведеться враховувати pivot спрайту). Цей клас працює в редакторі завдяки атрибуту [ExecuteInEditMode]. Важливий момент: при зміні стану (позиції, повороту, масштабування, спрайту і т. д.) Poser повідомляє про це клас PoserListener, який вміє відслідковувати зміни в редакторі всіх елементів Poser з визначеним тегом. Це знадобиться в подальшому;
    • CameraManager. Контролер камер, який вміє вирівнювати поточну камеру і позиціонувати камери для постэффектов.
Як зазвичай, трохи різних штук
  1. Додавайте геометрії для малювання гизмо. Наприклад, в IntVector2, IntRect і IntLine доданий метод OnDrawGizmos. Це дуже корисно при налагодженні.
  2. Вирівнювати камери — жахливо. Я позиціонував їх по нижньому лівому кутку, в той час як координати камери — це координати її центру. А при зміні renderTarget камери кути, очевидно, виїжджають, так як змінюється розміри екрану в пікселях. Так що важливо не тільки вирівнювати камери, але і коли.

Стара структура проекту
Пам'ятаєте, у попередній статті був розділ про побудова тіней? І велика частина була присвячена об'єднанню спрайтів в якісь групи для оптимізації фінального меша? Так ось, забудьте, це все неправда. :)
Спочатку всі модулі писалися незалежно, в режимі прототипування. І для кожного придумувалися свої алгоритми, структури і типи даних. У результаті виникло дві великих проблеми (крім legacy-коду говнокода, будемо чесні):
  1. Дублювання коду. Одних тільки класів для цілочисельних координат було штуки 3-4 і все — вкладені в інші класи (звичайна крапка, крапка з нормаллю, точка з якоюсь метаінформацією).
  2. Не оптимальні рішення. Кожному модулю були потрібні одні і ті ж дані про спрайтах, але трохи трохи по-різному оброблені. І ці дані копіювалися туди-сюди зі всякими перетвореннями, що не додавало ні швидкості, ні витонченості коду.
Ось такі модулі вимагають якоїсь інформації про "твердих" спрайтах замків:
  • Тіні;
  • Вітер;
  • Розсіяне освітлення;
  • Трава;
  • Частинки;
  • Фізика;
  • Вода;
  • Інше (нитки павутини і ланцюга світильників).
А структури використовувалися такі.
  • UnityEngine.Sprite. Графічні спрайт. База для всього іншого;

  • Contour. Контур об'єкта (або групи об'єктів). Являє собою масив вертикальних і горизонтальних ліній з нормалями;
    • UnityEngine.Sprite[] → Contour: перетворимо кожен спрайт в контури, для отримання списку зовнішніх сторін.
  • Rects. Набір прямокутників;
    • UnityEngine.Sprite[] → Rects: заповнюємо спрайт прямокутниками, щоб потім використовувати їх для об'єднання всіх об'єктів в один.
  • Shape. Клас, що містить Contour і Rects;
  • Shapes. Shape Набір і aabb для швидкого пошуку;
  • Batch. Shape Набір і функції доступу до загального Contour;
    • Для побудови мешей;
    • Для перевірки точки на твердість;
    • Для рейкаста.
  • WindGrid. Кеш колізій для вітру Batch → Shapes;
  • QuadTree. Базова (і косячная) реалізація деревини квадрантів для швидкого пошуку порожніх обсягів;
  • WaterVolumes. Прямокутники води з Batch → Contour.
Все це божевілля використовується:
  • Тіні. Batch → загальний Contour: для побудови тіньового меша;
  • Вітер. WindGrid → Batch → Shapes: для розрахунку вітру (пошук колізій);
  • Розсіяне освітлення.
    • Batch → Shapes:
    • Для raycast'а (прямий пошук освітленості);
    • Для testPoint'а (пошук твердих об'єктів).
    • QuadTree → Batch → Shapes:
    • Для пошуку порожніх обсягів.
  • Трава.
    • Batch → Shapes: метод CircleQuery для пошуку найближчих поверхонь;
    • Batch → загальний Contour: для посадки трави;
  • Частинки. Batch → Shapes: метод PopPoint (знаходження найближчого відкритого простору) для виштовхування частинок із стін;
  • Фізика. Batch → загальний Contour: для побудови коллайдерів;
  • Вода. WaterVolumes → Batch → загальний контур: пошук місць, де може бути створена вода;
  • Інше. Batch → Shapes: raycast для пошуку точки кріплення світильників і павутини.

Region tree
Після деякого аналізу і тривалого гугления знаходимо рішення — Region tree, іноді званий Volume tree.
Розбиваємо двовимірне простір на 4 частини до тих пір, поки кожен аркуш не виявиться або повністю порожнім, або повністю заповненим. Виставляємо листу біт заповнювання (або будь-яким іншим способом відрізняємо порожні вузли від заповнених).
Можливості, які дає це дерево, покривають всі наші потреби:
  • Побудова дерева. Можна заповнювати дерево, вказуючи тверді точки, прямокутники або навіть інші дерева з певним зміщенням. Тому для спрайтів предрасчитываются власні Region tree, а потім, при додаванні на сцену, будується загальна дерево.
  • Перевірка твердості точки. Рекурсивно спускаємося по дереву, поки у сайту є нащадки (в моїй версії дерева вузол порожній, якщо Node = null, повний — якщо Node.children == null, в іншому випадку в Node.children — масив нащадків);
  • Raycast. Рекурсивно перевіряємо перетину променя з квадратами вузлів.
  • Пошук найкоротшого шляху до порожнього простору. Знаходимо лист в заданій точці, піднімаємося вгору по дереву, перевіряючи вузли сусідів (якщо вузол порожній, відразу вважаємо відстань до нього, якщо є нащадки — спускаємося рекурсивно вниз і знову шукаємо найближчий порожній).
  • Пошук відрізків, що лежать на кордоні. Тут складніше, якщо коротко — отримуємо всі заповнені квадрати з дерева, прибираємо сторони, що належать декількох квадратах, оптимізуємо результат.
Візуально це виглядає так (ура, картинки!):

Візуальна частина. Стіни з спрайтів, земля — полігони.

Предрасчитанный region tree.

Предрасчитанные поверхні.
Менеджери
Як виявилося, ця дерево квадрантів реалізує майже всі можливості, які потрібні модулів. Тепер потрібно зв'язати ці модулі один з одним.
Хвилинка болюВ "прототипної" версії всі контроллери/менеджери самі реалізовували відповідний функціонал (розрахунок освітлення, обробку фізики частинок і т. д) і успадковувалися від MonoBehaviour. Виникало кілька проблем: складний і розрісся код, сильна залежність менеджерів один від одного, відсутність якогось загального потоку даних між контролерами.
Наприклад, коли в редакторі я пересував якийсь елемент, менеджери не підчіплювали ці зміни автоматично. Доводилося спочатку тицьнути галку в менеджері дерева, потім у менеджері світла, потім в менеджері води і т. д. І все заради того, щоб подивитися, чи добре виглядає новий замок. Так собі, правда?
По-перше, по максимуму позбудемося MonoBehaviour. Всі об'єкти по можливості представляються звичайними з# класами.
По-друге, рознесемо код з різних просторів імен, одне ім'я — один функціонал.
І, по-третє, для кожного функціоналу реалізуємо MonoBehaviour-менеджер, який буде зберігати потрібні настройки, управляти генерацією контенту і т. д.

Менеджери. Причесані і краватках.
Отже, на сцені лежать контролери, у кожного одна сфера впливу, код акуратно упакований в простір імен NewEngine.Core (Core.Geom, Core.Illumination, Core.Rendering і т. д.). Рухаємо спрайт в редакторі і… ніякої реакції. Пам'ятайте, вище був описаний клас PoserListener? Він вміє слухати зміни позиції, спрайту, розміру у об'єктів типу Poser. Всі менеджери, які залежать відповідних GameObject'ов успадковуємо від цього класу.
Тепер, коли ми рухаємо шматок стіни (c тегом "foreground") повідомляється Core.Quad.QuadManager, а коли міняємо опорні точки для води (тег "waterLayer") Core.Water.WaterManager відразу ж дізнається про зміни.
Залишилося зв'язати контролери між собою, адже вищеописаного WaterManager потрібно знати, коли буде перебудовано дерево квадрантів QuadManager, ShadowMeshManager'важливо підхопити зміни в SurfaceManager'е. Для цього скористаємося дуже зручним UnityEvent. Його єдиний недолік — за замовчуванням, якщо ми створюємо подія-generic з яким-небудь своїм аргументом, Unity3D не відображає його в редакторі. Це виправляється елементарно:
public class TreeManager : MonoBehaviour {
[System.Serializable]
public class UpdateTreeEvent : UnityEvent<TreeManager> {
}

public UpdateTreeEvent onUpdateTree;
...
}

І тепер ми можемо пов'язувати менеджери прямо в редакторі, не забруднюючи код дивними залежностями:

При необхідності залежно виконуються в редакторі.
Постэффекты.
Отже, всі модулі на старті генерують необхідний контент, оновлюють його, але рендери не вміють. Час це виправити!
насправді, рендеринг у цьому проекті сильно відрізняється від того, що показують на youtube ролики з промовистими назвами "Запилим свою мега-круту гру на Unity3D з трьох боксів, одного спрайту і компонента RigidBody2D". Насправді, просто промалювати потрібно тільки самі спрайт стін і фону. А ось світло, воду та інше доведеться робити через постэффекты.
Що це означає? Що візуалізація елементів ми будемо робити не на екран, а в буфери, потім з допомогою різних шейдерів зводити ці всі одну картинку. І всього-то.
Ефектів виходить чимало. На даний момент це:
  • Рендеринг сцени. Не зовсім постэффект, просто побудова геометрії основної сцени.
  • Глобальне освітлення. Розраховується один раз, на старті рівня;
  • Звичайне освітлення. Включає в себе джерела світла, тіні, каустику у воді;
  • Світлові декалі. Невеликі спрайт, які створюють ефект люмінесценції.
  • Постэффект відомості. Поєднує результати всіх попередніх постэффектов в одну картинку (наприклад, застосовує ефекти освітлення до отрендеренной сцені).
  • І, нарешті, рендеринг на екран. Який просто виводить отриману текстуру з урахуванням pixel perfect та різниці в розмірах біля екрану і текстури.
У загальному випадку постэффект — це якийсь скрипт, який вміє генерувати певну текстуру/текстури і, можливо, додаткові дані (наприклад, параметри шейдерів). Іноді постэффект працює тільки з модулями, що іноді йому доводиться викликати рендеринг сцени з певними настройками камери.
Важливий момент: постэффекты можуть залежати один від одного. Тому, по-перше, важливо викликати ефекти в правильному порядку, по-друге, потрібно вміти зберігати і передавати ефектів різношерсті дані про один одного.
Робимо базовий клас для ефекту. Приблизно такий:
namespace NewEngine.Core.Render {
public abstract class PostEffect {
public int OrderId { get; set; } // Знадобиться для впорядкування ефектів.

public abstract void Apply(PipelineContext context); // Обробка ефекту. Вихідні дані про інші ефекти знаходяться в PipelineContext, результат зберігається туди ж.
public abstract void Clear(); // Видалення тимчасових текстур. Виходить швидше, ніж при GC, чому поясню пізніше.

protected Camera CreateCamera(); // Деяких постэффектам потрібна внутрішня камера. Наприклад, щоб промалювати джерела світла.
public List<Camera> Cameras { get; }
}
}

І ще робимо клас контексту, для зв'язування даних з постэффектов.
namespace NewEngine.Core.Render {
public class PipelineContext {
Словник<System.Type, PostEffectContext>;
Camera camera;
Geom.IntRect viewRect;

public PipelineContext(CameraManager cameraManager);
public void Set<Context>(Context value) where Context : PostEffectContext;
public Context Get<Context>() where Context : PostEffectContext;

public Camera Camera { get; }
public Geom.IntRect ViewRect { get; }
}
}

По суті, тепер в менеджері візуалізації нам потрібно пройтися в правильному порядку по всім активним ефектів, викликаючи у них Apply і збираючи дані PipelineContext. В результаті у нас отрисуется красивий кадр. Ефекти потрапляють в менеджер через аналог Poser, який повідомляє слухачеві про додавання/видалення ефектів зі сцени. Залишилося тільки правильно їх сортувати.
І було б круто зробити красиві атрибути, які додавалися б у постэффекты і автоматично визначали залежності і порядок, як-то так:
[RequiredPostEffect(typeof(WaterPostEffect))]
[RequiredPostEffect(typeof(IlluminationPostEffect))]
public class MergerPostEffect : PostEffect {
}

Але, по правді кажучи, мені було лінь це робити, і я написав черговий PropertyDrawer:

Просто перетягуємо всі постэффекты в загальному списку. Список створюється автоматично з ефектів на сцені в редакторі.
Нова порція хінтів
  • Камери. Ще одна стадія, яку проходять ефекти — вирівнювання камер. Перед рендерингом всі створені камери проходять цей етап;

  • Виклик постэффектов. Постэффекты повинні звідкись викликатися. У Unity3D є спеціальне місце — метод OnPostRender;
  • GetTemporary. Як з'ясувалося, створювати RenderTexture і зберігати його протягом життя постэффекта — погана ідея. Повільно, пам'ять не переиспользуется. Але якщо створювати текстури через RenderTexture.GetTemporary, а після використання прибирати через RenderTexture.ReleaseTemporaryfps сильно збільшується (особливо на мобільних девайсах). Unity3D не відразу видаляють такі текстури і переиспользует їх по можливості. У ситуації, коли безліч посэффектов відмальовує на однакових за параметрами текстурах — ідеальний варіант.
  • Витік текстур. Якщо постійно створювати такі текстури і забувати їх видаляти — є шанс, що Unity3D почне падати (точніше, це збільшить шанси: Unity3D і так любить падати). В попередньому відео було видно лічильник використовуваних текстур і чарівна кнопка "Show texture leaks", яка показує кількість створених і не віддалених текстур з конкретними місцями в коді, де відбулося виділення пам'яті (з допомогою System.Diagnostics.StackTrace()). Не забувайте обертати такі штуки #ifdef і використовувати тільки в редакторі.
  • Підтримка форматів текстур. девайси Зазвичай підтримують не весь список TextureFormat і вже тим більше, RenderTextureFormat. Методи SystemInfo.SupportsTextureFormat і SystemInfo.supportsRenderTextures дозволять прозоро (при наявності своєї обгортки з кешем) підбирати максимально підходящий доступний формат.
  • Blit. Іноді потрібно застосувати якийсь шейдер до набору текстур і вивести в іншу текстуру. Для цього навіть не потрібна Camera (ми не рендерим сцену). У Unity3D є відмінний метод Graphics.Blit.
  • Ще один Blit. Правда _Graphics.Blit_не допомагає, якщо треба промалювати результат шейдера на одній текстурі, а depth buffer і stencil buffer використовувати з іншою. Це можна обійти ось таким кодом:
    public static void Blit(RenderBuffer colorBuffer, RenderBuffer depthBuffer, Material material) {
    Blit(colorBuffer, depthBuffer, material, material.passCount);
    }
public static void Blit(RenderBuffer colorBuffer, RenderBuffer depthBuffer, Material material, int passCount) {
Graphics.SetRenderTarget(colorBuffer, depthBuffer);
GL.PushMatrix();
GL.LoadOrtho();
for (int i = 0; i < passCount; ++i) {
material.SetPass(i);
GL.Begin(GL.QUADS);
GL.TexCoord(new Vector3(0, 0, 0));
GL.Vertex3(0, 0, 0);
GL.TexCoord(new Vector3(0, 1, 0));
GL.Vertex3(0, 1, 0);
GL.TexCoord(new Vector3(1, 1, 0));
GL.Vertex3(1, 1, 0);
GL.TexCoord(new Vector3(1, 0, 0));
GL.Vertex3(1, 0, 0);
GL.End();

}
GL.PopMatrix();
Graphics.SetRenderTarget(null);
}
<!--</spoiler>-->
## Думки про майбутнє
Акуратні алгоритми обробляють дані та створюють траву, воду і світло, контролери стежать за їх оновленням, постэффекты - за рендерингом. Акуратно, швидко і досить чисто. І найголовніше, готова основа для написання дійсно цікавих штук! Наприклад, замерзлої води або світиться цвілі в підземеллях. І це цілком геймплейні фішки:
<blockquote>...лучники почали стріляти з протилежного берега. Маг підірвав файрбол над річкою, викликавши справжнє цунамі. Але хвиля не дісталася до берега - чарівник заморозив воду і сховався від стріл за стіною льоду.</blockquote>

<blockquote>...десятий потік полум'я так загострив кам'яну підлогу, що той почав світиться, як майбутній меч в руках коваля. "Жодна жива душа не зможе пройти за мною" - вирішив маг.</blockquote>

В цій статті спробую розповісти про нові постэффектах і освітленні, а поки, на закуску, трохи відео і картинок. Спасибі за вашу увагу.

<oembed>https://www.youtube.com/watch?v=XPdhSoub0wU</oembed>
_Вот про таку воду я обіцяв вам розповісти :)_

<!--<spoiler title="Баги і картинки">-->

<img src="https://habrastorage.org/files/d9a/432/9b8/d9a4329b82af44cbb1ec43a41d745a0b.png"/>
_Зависимости ефектів до рефакторінгу._

<img src="https://habrastorage.org/files/fa6/1f0/754/fa61f0754e2548f19f14f3cd49fcc234.png"/>
_Ну чому всі працюють з радіанами, а Unity3D з градусами? 0о_

<img src="https://habrastorage.org/files/c4a/cd2/391/c4acd2391a514e73980e0a346b52c7c4.png"/>
_Небольшой, але красивий косяк в пошуку поверхонь._

<img src="https://habrastorage.org/files/a67/63a/828/a6763a8281ce43ed9c6408aae9e0a2c5.png"/>
_Это не ядерний реактор, просто якісь рейкасты прорвалися назовні._

<!--</spoiler>-->

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.