Дизайн

Текстури для 64k intro: як це робиться сьогодні

Ця стаття є другою частиною серії про створення H – Immersion. Першу частину можна прочитати тут: Занурення в Immersion.

При створенні анімації лише в 64 КБ складно використовувати готові зображення. Ми не можемо зберігати їх традиційним способом, тому що це недостатньо ефективно, навіть якщо застосовувати стискання, наприклад JPEG. Альтернативне рішення полягає в процедурній генерації, тобто в написанні коду, що описує створення зображень під час виконання програми. Нашої реалізацією такого рішення став генератор текстур — фундаментальна частина нашого тулчейна. У цьому пості ми розповімо, як розробляли і використовували його в H – Immersion.


Прожектори субмарини висвітлюють деталі морського дна.

Рання версія

Генерація текстур стала одним з найперших елементів нашої кодової бази: в нашому першому інтро B – Incubation вже використовувалися процедурні текстури. Код складався з набору функцій, що виконують заливку, фільтрацію, перетворення і комбінування текстур, а також з одного великого циклу, обходящего всі текстури. Ці функції були написані на чистому C++, але пізніше було додано взаємодія C API, щоб їх можна було обчислити інтерпретатор C PicoC. В той час ми використовували PicoC для того, щоб знизити час, займане кожною ітерацією: так нам вдавалося змінювати і перезавантажувати текстури в процесі виконання програми. Перехід на підмножина C був невеликий жертвою порівняно з тим, що тепер ми могли змінювати код і бачити результат відразу, не заморочуючись закриттям, перекомпиляцией і повторної завантаженням всього демо.


За допомогою простого патерну, невеликого шуму і деформацій ми можемо отримати стилізовану текстуру деревини.


У цій сцені з F – Felix’s workshop були використані різні текстури деревини.

Якийсь час ми досліджували можливості цього генератора, і в результаті виклали його на веб-сервер з невеликим скриптом на PHP і простим веб-інтерфейсом. Ми могли писати код текстур в текстовому полі скрипт передавав його в генератор, який потім скидав результат як файл PNG для відображення на сторінці. Дуже скоро ми почали робити начерки прямо на роботі під час обідньої перерви і ділитися своїми невеликими шедеврами з іншими членами групи. Така взаємодія сильно мотивувало нас до творчого процесу.


Веб-галерея нашого старого генератора текстур. Всі текстури можна редагувати у браузері.

Повний редизайн

Довгий час генератор текстур майже не змінювався; ми вважали, що він гарний, і наша ефективність перестала підвищуватися. Але одного разу ми виявили, що на Інтернет-форумах є безліч художників, що демонструють свої повністю процедурно згенеровані текстури, а також влаштовують челленджи на різні теми. Процедурний контент колись був «фішкою» демо-сцени, але Allegorithmic, ShaderToy і подібні їм інструменти зробили його доступним для широкої публіки. Ми не звертали на це уваги, і вони почали з легкістю класти нас на лопатки. Неприйнятно!


Fabric Couch. Повністю процедурна текстура тканини, створена в Substance Designer. Автор: Imanol Delgado. www.artstation.com/imanoldelgado


Forest Floor. Повністю процедурна текстура лісової грунту, створена в Substance Designer. Автор: Daniel Thiger. www.artstation.com/dete

Нам давно вже потрібно було переглянути свої інструменти. На щастя, багаторічна робота з одним і тим же генератором текстур дозволила нам усвідомити його недоліки. Крім того, наш зароджується генератор мешей теж підказав нам, як повинен виглядати конвеєр процедурного контенту.

Найважливішою архітектурною помилкою була реалізація генерування як безлічі операцій з об’єктами текстур. З точки зору високорівневої перспективи це може бути і правильний підхід, але з точки зору реалізації такі функції як texture.DoSomething() або Combine(textureA, textureB) мають серйозні недоліки.

По-перше, ОВП-стиль вимагає оголошувати ці функції як частину API, якими б простими вони не були. Це серйозна проблема, тому що вона погано масштабується і, що більш важливо, створює непотрібні тертя в процесі творчості. Ми не хотіли міняти API кожен раз, коли потрібно буде спробувати щось нове. Це ускладнює експериментування і обмежує творчу свободу.

По-друге, з точки зору продуктивності такий підхід вимагає обробляти в циклах дані текстур стільки разів, скільки є операцій. Це б не було особливо важливо, якщо б ці операції були витратними щодо витрат доступу до великих фрагментів пам’яті, проте зазвичай це не так. За винятком зовсім малої частки операцій, наприклад, генерація шуму Перлина або виконання заливки, в основному вони насправді дуже прості і вимагають лише кілька інструкцій на точку текстури. Тобто ми обходили дані текстур для виконання тривіальних операцій, що надзвичайно неефективно з точки зору кешування.

Нова структура вирішує ці проблеми завдяки реорганізації логіки. Більшість функцій на практиці незалежно виконує одну і ту ж операцію для кожного елемента текстури. Тому замість написання функції texture.DoSomething(), обходящей всі елементи, ми можемо написати texture.ApplyFunction(f), де f(element) працює тільки для окремого елемента текстури. Потім f(element) можна написати у відповідності з конкретною текстурою.

Це здається незначним зміною. Однак така структура спрощує API, робить код генерування більш гнучким і виразним, більш дружнім до кешу і з легкістю дозволяє реалізувати паралельну обробку. Багато хто з читачів уже зрозуміли, що по суті це шейдер. Проте реалізація за фактом залишається кодом C++, що виконуються в процесорі. Ми як і раніше зберігаємо можливість виконувати операції за межами циклу, але використовуємо цей варіант тільки при необхідності, наприклад, виконуючи згортку.

Було:

// Логіка знаходиться на рівні текстур.
// API роздутий.
// Все що є - це API.
// Генерування текстури проходить за кілька проходів.
class ProceduralTexture {
 void DoSomething(parameters) {
 for (int i = 0; i < size; i++) {
 // Тут подробиці реалізації.
 (*this)[i] = ...
}
}
 void PerlinNoise(parameters) { ... }
 void Voronoi(parameters) { ... }
 void Filter(parameters) { ... }
 void GenerateNormalMap() { ... }
};

void GenerateSomeTexture(texture t) {
t.PerlinNoise(someParameter);
t.Filter(someOtherParameter);
 ... // і т. д.
t.GenerateNormalMap();
}

Стало:

// Логіка зазвичай знаходиться на рівні елементів текстур.
// API мінімальний.
// Операції пишуться по мірі необхідності.
// Кількість проходів при генеруванні текстур знижено.
class ProceduralTexture {
 void ApplyFunction(functionPointer f) {
 for (int i = 0; i < size; i++) {
 // Реалізація передається як параметр.
 (*this)[i] = f((*this)[i]);
}
}
};

void GenerateNormalMap(ProceduralTexture t) { ... }

void SomeTextureGenerationPass(void* out, PixelInfo in) {
 result = PerlinNoise(in);
 result = Filter(result);
 ... // і т. д.
 *out = result;
}

void GenerateSomeTexture(texture t) {
t.ApplyFunction(SomeTextureGenerationPass);
GenerateNormalMap(t);
}

Паралелізація

Для генерації текстур потрібен час, і очевидний кандидат для зниження цього часу — паралельне виконання коду. Принаймні, можна навчитися генерувати кілька текстур одночасно. Саме так ми зробили для F – Felix’s workshop, і це дуже знизило час завантаження.

Однак це не економить час там, де воно необхідно найбільше. Раніше багато часу займає генерування однієї текстури. Це відноситься і до зміни, коли ми продовжуємо перезавантажувати текстуру знову і знову перед кожною модифікацією. Замість цього краще розпаралелити внутрішній код генерування текстур. Оскільки тепер код по суті складається з однієї великої функції, застосовуваної в кожному циклі до текселу, паралелізація стає простою і ефективною. Знижуються витрати на експерименти, налаштування та нариси, що безпосередньо впливає на творчий процес.


Ілюстрація ідеї, досліджену і відкинутою нами для H – Immersion: мозаїчне прикраса з облицюванням з орихалка. Тут вона показана в нашому інтерактивному інструменті для редагування.

Генерування на стороні GPU

Якщо це все ще не очевидно, то скажу, що генерування текстур повністю виконується в ЦП. Можливо, хтось з вас зараз читає ці рядки і дивується «але чому?!». Здається, що очевидним кроком є генерація текстур в відеопроцесорі. Для початку він на порядок збільшить швидкість генерації. Так чому ж ми його не використовуємо?

Основна причина в тому, що мета нашого невеликого редизайну полягала в тому, щоб залишитися на CPU. Перехід на GPU означав би набагато більше роботи. Нам би довелося вирішувати додаткові проблеми, для яких у нас поки недостатньо досвіду. Працюючи з CPU, ми маємо чітке розуміння того, що хочемо, і знаємо, як виправити попередні помилки.

Проте хороші новини полягають в тому, що завдяки новій структурі експерименти з GPU тепер здаються досить тривіальними. Тестування поєднань обох типів процесорів буде цікавим експериментом на майбутнє.

Генерування текстур і фізично точний шейдинг

Ще одне обмеження старого дизайну полягало в тому, що текстура розглядалася тільки як RGB-зображення. Якщо нам потрібно було згенерувати більше інформації, припустимо diffuse-структуру і текстуру нормалей для тієї ж поверхні, то нам нічого не заважало це зробити, але API особливо і не допомагав. Особливо важливо це стало у контексті фізично точного шейдинга (Physically Based Shading, PBR).

У традиційному конвеєрі без PBR зазвичай використовують кольорові текстури, які запечене безліч інформації. Такі текстури часто являють собою остаточний зовнішній вигляд поверхні: у них вже є певний обсяг, затемнені тріщини, і на них можуть бути навіть відображення. Якщо одночасно використовується кілька текстур, то для додавання карт нормалей або отражаемості поверхні зазвичай поєднуються деталі великої та дрібної масштабів.

В PBR-конвеєрі поверхні зазвичай використовують набори з декількох текстур, що представляють фізичні значення, а не потрібний художній результат. Дифузна колірна текстура, яка ближче всього до того, що часто називають «кольором поверхні, зазвичай плоска і нецікава. Колір specular визначається коефіцієнтом заломлення поверхні. Велика частина деталей і варіативності береться з текстур нормалей і roughness (шорсткості) (які хтось може вважати одним і тим же, але з двома різними масштабами). Сприймана відображає здатність поверхні стає наслідком рівня її roughness. На цьому етапі логічніше буде думати з точки зору не текстур, а матеріалів.










Нова структура дозволяє нам оголошувати для текстур довільні формати пікселів. Зробивши її частиною API, ми дозволяємо йому займатися всім boilerplate-кодом. Після оголошення формату пікселів ми можемо зосередитися на творчому коді, не витрачаючи зайвих зусиль на обробку цих даних. Під час виконання він згенерує кілька текстур і прозорим чином передасть їх у GPU.

У деяких PBR-конвеєрах кольору diffuse і specular не передаються безпосередньо. Замість них використовуються параметри «base color» і «metalness», що має свої переваги і недоліки. В H – Immersion ми використовуємо модель diffuse+specular, а матеріал зазвичай складається з п’яти шарів:

  1. Колір Diffuse (RGB; 0: Vantablack; 1: fresh snow).
  2. Колір Specular (RGB: частка відбитого під 90° світла, також відома як F0 або R0).
  3. Roughness (A; 0: ідеально гладкий; 1: схожий на гуму).
  4. Нормалі (XYZ; одиничний вектор).
  5. Підйом рельєфу (A; використовується для parallax occlusion mapping).

При використанні інформація про випущенні світла додавалася безпосередньо в шейдер. Нам не здалося необхідним наявність ambient occlusion, тому що в більшості сцен взагалі немає навколишнього освітлення. Проте не здивуюся, що у нас з’являться додаткове шари або інші типи інформації, наприклад, анізотропія або непрозорість.



На зображеннях вище показаний недавній експеримент з генерацією локального ambient occlusion на підставі висоти. Для кожного напрямку ми проходимо задану відстань і зберігаємо найбільший нахил (різниця висот, поділена на відстань). Потім ми обчислюємо occlusion з середнього нахилу.

Обмеження і робота на майбутнє

Як бачите, нова структура стала серйозним поліпшенням у порівнянні зі старою. Крім того, вона заохочує творчу виразність. Однак у неї все одно є обмеження, які ми хочемо усунути в майбутньому.

Наприклад, хоча в цьому інтро проблем не виникало, ми помітили, що перешкодою може стати виділення пам’яті. При генерації текстур використовується один масив із значень float. При великих текстурах з безліччю шарів можна швидко прийти до проблеми з виділенням пам’яті. Є різні способи її вирішення, але всі вони мають свої недоліки. Наприклад, ми можемо генерувати текстури потайлово, при цьому масштабованість буде краще, однак реалізація деяких операцій, наприклад згортки, стає менш очевидною.

Крім того, в цій статті, незважаючи на використане слово «матеріали», ми говорили лише про текстурах, але не про шейдери. Однак застосування матеріалів повинно призводити і до шейдерам. Таке протиріччя відображає обмеження існуючої структури: генерування текстур і шейдинг — це дві окремі частини, розділені мостом. Ми намагалися зробити так, щоб цей міст було перетнути як можна простіше, але насправді хочемо, щоб ці частини стали єдиним. Наприклад, якщо у матеріалу є і статичні, і динамічні параметри, то ми хочемо описувати їх в одному місці. Це складна тема і ми поки не знаємо, чи знайдеться гарне рішення, але давайте не будемо забігати вперед.


Експеримент по створенню текстури тканини, схожої на показану вище роботу Imadol Delgado.

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close