Помните небезызвестный мем про "корованы"? Наверное, каждый, кто разрабатывает игры (или хотел бы этим заняться) раздумывает о неком "проекте мечты", где можно будет "грабить корованы" и "набигать". А ещё, чтобы погода менялась динамически, и на грязи следы от сапог оставались, и деревья росли в реальном времени. И ещё, чтобы ...
Понятно, что в реальном игровом проекте такая погоня за хотелками — смерти подобна. А вот в техно-демке — самое то.
Предыдущие статьи
Часть первая. Свет.
Часть вторая. Структура.
Часть третья. Глобальное освещение.
Оглавление
- Вступление
- Спрайты
- Полигоны
- Pixel perfect и целочисленная геометрия
- Старая структура проекта
- Region tree
- Менеджеры
- Постэффекты
- Мысли о будущем
Вступление
Напомню, что в прошлой части был опрос: на какую тему написать следующую статью. И динамическая вода показалась сообществу наиболее интересной. Но это долгая история и начать придется со структуры проекта и алгоритмах, которые используются под капотом, а затем рассказать о доработках освещения и постэффектах. Текст получится большой, так что вода переезжает дальше, в одну из ближайших статей. Кстати, эта статья содержит поменьше красивых рендеров, но куда больше технических хинтов. Не скучайте.
С момента написания предыдущей статьи было сделано очень многое. Переписан начисто весь код проекта, оптимизированы алгоритмы, добавлены новые источники света, реализовано фоновое освещение, реализована вода с бликами, волнами, кипением и замерзанием. Как вы могли заметить — ни слова о персонажах или геймплейной составляющей, это ещё впереди. Не буду забегать вперед и расскажу всё по порядку.
Спрайты
Наш проект про магов, а значит, без старых каменных за?мков не обойтись. Вот только рисовать каждый с нуля — себе дороже. Попробуем собирать их из небольших кусочков, например, вот таких:
Аккуратные кусочки, из которых можно сделать ВСЁ.
Чтобы у этих кусочков был общий контур, напишем какой-нибудь шейдер, а статический батчинг в Unity3d оптимизирует количество вызовов отрисовки. Вот только чтобы получить общий контур, придется использовать двухпроходный шейдер со stencil буфером: первая часть нарисует контуры, а вторая — заливку. А любые элементы, которые используют материалы с многопроходными шейдерами в батчинге не участвуют. Лучше отрисовывать каждый спрайт дважды, но с разными материалами. Количество вершин увеличится, зато вызовов отрисовки останется всего два.
Рендерим сплошной контур.
Добавляем текстуру.
Таким нехитрым способом можем создать вот такой за?мок:
Стены в редакторе. Текстуру и цвет контура можно настраивать отдельно.
- Убираем копипасту. Конечно, не стоит руками копировать спрайты. У меня есть класс Contour, который содержит все нужные настройки для спрайта и 2 материала. При появлении на сцене этот класс создает по два наследника с SpriteRenderer (для контура и фона).
Автоматизируем перенос. Изначально у меня уже были префабы-спрайты, которые использовались на сцене (несколько сотен элементов). Когда я решил обернуть их в Contour, изменения префабов почему-то не применялись к созданным объектам. К счастью, можно легко написать скрипт, который для каждого существующего элемента найдет соответствующий префаб (по имени), и создаст в нужной позиции элемент из этого префаба. Ключевые методы — UnityEditor.AssetDatabase.LoadAssetAtPath и UnityEditor.PrefabUtility.ConnectGameObjectToPrefab
Правильный drag'n'drop. Минус разделения на спрайты — теперь по умолчанию на сцене выбирается и используется в drag'n'drop'е один из спрайтов-наследников. Решается проблема добавлением атрибута [SelectionBase] перед классом Contour.
Отображение префабов. В меню проекта префабы с контурами теперь не отображаются как спрайты, и, если честно, я не нашел способа самому генерировать иконку. Поэтому в префабы я добавил ещё и SpriteRenderer, спрайт которому задаёт мой Contour. При добавлении на сцену я удаляю из объекта не нужный в геймплее SpriteRenderer.
Удаление из 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
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 = Quaternion.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. Контроллер камер, который умеет выравнивать текущую камеру и позиционировать камеры для постэффектов.
- IntVector2 — по сути, копия UnityEngine.Vector2, но с целочисленными координатами. Иногда нужно конвертировать данные из обычного UnityEngine.Vector2, с учётом масштаба или без (в одном юните 32 пикселя, только не нужно использовать магические числа!), поэтому добавляем следующие функции:
- Добавляйте для геометрии отрисовку гизмо. Например, в IntVector2, IntRect и IntLine добавлен метод OnDrawGizmos. Это очень полезно при отладке.
- Выравнивать камеры — ужасно. Я позиционировал их по нижнему левому углу, в то время как координаты камеры — это координаты её центра. А при изменении renderTarget камеры углы, очевидно, уезжают, так как меняется размеры экрана в пикселях. Так что важно не только как выравнивать камеры, но и когда.
Старая структура проекта
Помните, в прошлой статье был раздел про построение теней? И больша?я часть была посвящена объединению спрайтов в некие группы для оптимизации финального меша? Так вот, забудьте, это все неправда. :)
Изначально все модули писались независимо, в режиме прототипирования. И для каждого придумывались свои алгоритмы, структуры и типы данных. В результате возникло две крупных проблемы (помимо legacy-кода говнокода, будем честны):
- Дублирование кода. Одних только классов для целочисленных координат было штуки 3-4 и все — вложенные в другие классы (обычная точка, точка с нормалью, точка с какой-то метаинформацией).
- Не оптимальные решения. Каждому модулю были нужны одни и те же данные о спрайтах, но чуть чуть-чуть по-разному обработанные. И эти данные копировались туда-сюда со всякими преобразованиями, что не добавляло ни скорости, ни изящества кода.
Вот такие модули требуют какой-то информации о "твердых" спрайтах за?мков:
- Тени;
- Ветер;
- Рассеянное освещение;
- Трава;
- Частицы;
- Физика;
- Вода;
- Прочее (нити паутины и цепи светильников).
- 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 {
Dictionary<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.ReleaseTemporary, fps сильно увеличивается (особенно на мобильных девайсах). Unity3D не сразу удалят такие текстуры и переиспользует их по возможности. В ситуации, когда множество посэффектов отрисовываются на одинаковых по параметрам текстурах — идеальный вариант.
- Утечка текстур. Eсли постоянно создавать такие текстуры и забывать их удалять — есть шанс, что 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 использовать из другой. Это можно обойти с помощью следующего ниже кода.
static public void Blit(RenderBuffer colorBuffer, RenderBuffer depthBuffer, Material material) {
Blit(colorBuffer, depthBuffer, material, material.passCount);
}
static public 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);
}
Мысли о будущем
Аккуратные алгоритмы обрабатывают данные и создают траву, воду и свет, контроллеры следят за их обновлением, постэффекты — за рендерингом. Аккуратно, быстро и достаточно чисто. И самое главное, готова основа для написания действительно интересных штук! Например, замерзающей воды или светящейся плесени в подземельях. И ведь это вполне геймплейные фишки:
… лучники начали стрелять с противоположного берега. Маг взорвал файрбол над рекой, вызвав настоящее цунами. Но волна не добралась до берега — волшебник заморозил воду и спрятался от стрел за стеной льда.
… десятый поток пламени так накалил каменный пол, что тот начал светится, как будущий меч в руках кузнеца. «Ни одна живая душа не сможет пройти за мной» — решил маг.
В следующей статье попробую рассказать о новых постэффектах и освещении, а пока, на закуску, немного видео и картинок. Спасибо за ваше внимание.
Вот про такую воду я обещал вам рассказать :)
Зависимости эффектов до рефакторинга.
Ну почему все работают с радианами, а Unity3D с градусами? 0о
Небольшой, но красивый косяк в поиске поверхностей.
Это не ядерный реактор, просто некие рейкасты прорвались наружу.
Комментарии (10)
shaman4d
28.10.2016 17:32+1Спасибо за очень интересные статьи. А в какой программе вы строили майндкарту (1й скриншот в секции Баги и картинки)?
nightrain912
28.10.2016 17:35+1Здесь когда-то была статья-обзор разных вариантов, остановился на mindmeister.
TargetSan
28.10.2016 17:42+1Возможно глупый совет, но не пробовали ли вы делать минимальный размер субрегиона в RegionTree допустим 8х8 или 16х16, и делать там сплошную битовую маску? Мне кажется, это было бы эффективней и по памяти, и по времени доступа.
nightrain912
28.10.2016 17:46Я бы этот совет глупым не назвал. Пока не придумал, как можно было бы хранить битовую маску только в листьях (можно, конечно, через optional value делать), но даже если пихать её в каждый узел, получится большая экономия.
По скорости доступа при поиске коллизий точно даст выигрыш. Будут сложности при слиянии деревьев и ray'cast'е, но полагаю, оно того стоит.
Спасибо, как доберусь до оптимизаций, попробую этот подход.TargetSan
28.10.2016 18:05+1Увы, это идеально ложится на языки с поддержкой tagged unions — а Unity не тот случай.
Можно попробовать так, если надо в основном проверки на "пустой-полный" (псевдокод)
interface INode { bool isEmpty(Coord); } class BigNode: INode { INode topleft, topright, bottomleft, bottomright; bool IsEmpty(Coord position) { var subnode = selectChildFromCoord(position); return subnode ? subnode.isEmpty(position) : false; } } class SmallNode: INode { BitMaskBlob bitmask; bool isEmpty(Coord position) { return getBitFromPosition(position) != 0; } }
nightrain912
28.10.2016 18:11Да, как вариант.
Мне при разработке (подкапотные дела C# знаю плохо, это не c++, где с наследованием всё яснее), было неясно — если я буду использовать наследование (возможно, с виртуальными функциями), насколько это скажется на производительности по сравнению с «плоскими» узлами.
AgentFire
Весьма и весьма недурно. С нетерпением жду продолжения.
nightrain912
Спасибо, оно уже готовится.
К сожалению, в изначально посте поехала вёрстка и хабр съел окончание статьи. Так что для вас — небольшое продолжение есть уже сейчас :)