Wolfenstein 3D: трасиране на лъчите с помощта на WebGL1

Материалът е на програмиста Райнер Нийхов от Холандия, известен със своите разработки в в областта на компютърната графика. Дадени са много примери, показващи тънкостите на рейтрейсинга

0
754

След появата на графичните карти Nvidia RTX с хардуерна поддръжка на рейтрейсинга, технологията за трасиране на лъчите отново стана популярна. През последните няколко месеца моят Twitter се напълни са безкраен поток от сравнения на графиката с включен и изключен RTX.

След като се нарадвах на голямото количество красиви изображения, ми се прииска самостоятелно да опитам да комбинирам класическия проактивен рендер (forward renderer) с технологията за трасиране на лъчите.

Страдайки от синдрома за невъзприемане на чуждите разработки, аз създадох собствен хибриден рендиращ енджин, базиран на WebGL1, Всеки може да опита демото с нивата на Wolfenstein 3D, в които наслагах сфери за по-добро показване възможностите на трасирането на лъчите.

Прототипът

Започнах този интересен проект със създаването на прототипа, опитвайки се да пресъздам глобално осветяване с трасиране на лъчите на Metro Exodus.

Прототипът използва проактивен рендер, който пресъздава цялата геометрия на сцената. Шейдърът, използван за растеризацията на геометрията не само пресмята прякото осветяване, но и генерира случайни лъчи от повърхността на вече рендираната геометрия, за да може да се покаже дифузията.

На показаното по-горе изображение се вижда, как всичките сфери са коректно осветени само от непрякото осветяване (лъчите светлина се отразяват от стената зад камерата). Самият източник на светлина е скрит зад кафявата стена в горната част на изображението.

Wolfenstein 3D

В прототипа се използва съвсем опростена схема. В нея е поставен само един източник на светлина и се рендират само няколко сфери и кубове. Благодарение на това, кодът за трасирането на лъчите в шейдъра е съвсем опростен. Цикълът за цялостна проверка на пресичанията, в които лъчът се тества за пресичането с всички кубове и сфери в сцената, е достатъчно бърз и програмата го изпълнява в реално време.

След създаването на този прототип реших да направя нещо по-сложно, да добавя повече геометрия в сцената и повече източници на светлина.

Проблемът със сложните сцени е в това, че така или иначе трябва да имам възможност за трасиране на лъчите в реално време. Обикновено за ускоряването на рейтрейсинга се използва структурата bounding volume hierarchy (BVH), но решението да използвам WebGL1 не дава подобна възможност. Във WebGL1 е невъзможно зареждането на 16-битови данни в текстурите и в шейдърите не могат да се използват двоични операции. Това усложнява предварителните изчисления и използването на BVH във WebGL1 шейдърите.

Това е и причината за да използвам в това демо ниво от Wolfenstein 3D. През 2013 година направих в Shadertoy един WebGL шейдър, който не само рендира подобни на Wolfenstein нива, но и може да се използва като бърза и несложна структура за ускорение и с нейното използване трасирането на лъчите ще се изчислява и изпълнява много бързо.

По-горе е показан скрийншот на това демо, може да се опита и режима в цял екран.

Кратко описание

В демото се използва хибриден енджин за рендиране. За рендирането на всички полигони в кадъра енджинът използва традиционна растеризация, а след това комбинира получения резултат със сенките, дифузията и отраженията, които се създават чрез трасиране на лъчите.

Сенки
Плюс дифузия
И плюс отражение

Проактивното рендиране

Картите на Wolfenstein изцяло се записват в двуизмерна мрежа с размер 64х64. Карата използвана в демото се базира на първото ниво на Wolfenstein 3D.

При стартирането се създава цялата геометрия, необходима за работата на проактивното рендиране. Изображенията на стените се генерират от данните в картата. Създават се и плоскостите на пода и тавана, подава се информацията за източниците на светлина, разположението на вратите и поставените на случайни места сфери.

Всички текстури, използвани за стените и вратите са компресирани в един общ каталог на текстурите, като това дава възможност всички стени да се изобразят само само с един цикъл на изрисуването.

Сенки и осветяване

Директното осветяване се пресмята в шейдъра, който се използва за проактивното рендиране. Всеки фрагмент може да бъде осветен от максимум четири източника на светлина. За да се разбере, кои точно светлинни източници могат да окажат влияние на конкретен елемент в шейдъра, при стартирането на демото, текстурите се изчисляват предварително. Текстурата на стените е с размер 64 на 128 и кодирана в позициите на 4-те най-близки източници на светлина за всяка една позиция от мрежата на картата.

 

varying vec3 vWorldPos;
varying vec3 vNormal;

void main(void) {
    vec3 ro = vWorldPos;
    vec3 normal = normalize(vNormal);
    vec3 light = vec3(0);

    for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) {
        light += sampleLight(i, ro, normal);
    }

За получаването на меки сенки за всеки фрагмент и светлинен източник се генерира случаен елемент в позицията на източника на светлина.

След добавянето и на спомагателните отражения, към пресметнатия цвят на фрагмента се добавя и дифузия (diffuse GI).

Трасирането на лъчите и дифузията

Въпреки че в прототипа кодът за трасиране на лъчите за diffuse GI не е обединен с кода на проактивното рендиране, в демото реших да ги разделя.

След разделянето на кода се налага изчертаване на цялата геометрия за втори път с помощта на втори шейдър, който обаче само генерира случайни лъчи за целите на дифузното осветяване. Полученото по този начин дифузно осветяване просто се добавя към прякото осветяване на фрагментите, получено при цикъла на проактивното рендиране.

Чрез този подход става възможно генерирането на по-малко лъчи за целите на дифузното осветяване. Това може да стане, ако намалим значението на показания в горната илюстрация параметър Buffer Scale.

Така например, ако Buffer Scale поставим на 0,5, то за целите на дифузното осветяване ще се генерира само един лъч на всеки четири екранни пиксела. Това дава огромно повишаване на производителността. Възможно е регулирането и на други параметри, които регулират качеството на дифузията и оказват влияние върху бързодействието.

Трасиращият лъч

За да имаме възможност да пускаме трасиращи лъчи в сцената, цялата геометрия на нивото в играта трябва да има формат, подходящ за рейтрейсинг алгоритъма, заложен в шейдъра. Нивото на Wolfenstein е кодирано в масив от 64х64 клетки и е сравнително лесно да се вмъкнат всички данни в една 64х64 текстура.

И така:

  • В канала на червения цвят на текстурата се записват всички обекти, намиращи се в съответната клетка от картата на нивото. Ако значението на червения канал на текстурата е равно на нула, то в тази клетка няма никакви обекти. В противен случай, нейното значение може да бъде от 1 до 64 – това са например врата, светлинен източник или сфера, които ще трябва да бъдат проверени за пресичане с трасиращия лъч
  • Ако в съответната клетка на нивото на играта се намира сфера, то зеленият, синият и алфа каналите се използват за запис на радиуса и относителните координати на сферата в тази клетка.

Генерирането на трасиращия лъч в сцената става чрез обхождане на текстурата с помощта на следния код:

bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max,
              inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) {
    vec3 pos = floor(ro);
    vec3 ri = 1.0/rd;    
    vec3 rs = sign(rd);
    vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri;

    for( int i=0; i<MAXSTEPS; i++ )	{
        vec3 mm = step(dis.xyz, dis.zyx);
        dis += mm * rs * ri;
        pos += mm * rs;

        vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.));

	if (isWall(mapType)) {
            ...
            return true;
	}
    }
    return false;
}

Съшият код за трасиране с лъчи на клетката на нивото можем да видим в този Wolfenstein шейдър на Shadertoy.

След изчисляването на точката на пресичането със стената или врата (става с помощта на алгоритъма на паралелепипеда) информацията в текстурата, получена при действието на проактивното рендиране, дава албедото в точките на пресичане.

С вратите нещата са много по-сложни, понеже те се движат. За да може графичното изобразяване на сцената, генерирана от централния процесор (при проактивното рендиране) да съвпада със сцената на графичния процесор (от трасирането на лъчите), всички врати се отварят и затварят автоматично и детерминирано в зависимост от разстоянието на камерата до вратата.

Дифузията

Разсеяното глобално осветяване (diffuse GI) се изчислява чрез прокарване на лъчи в шейдъра, които се използва за изрисуването на цялата геометрия в Diffuse GI Render Target. Направлението на тези лъчи зависи от нормалата към повърхността.

Ако имаме направлението на лъча rd и началната точка ro, отразеното осветяване можем да изчислим с помощта на следния цикъл:

vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) {
    vec3 emitted = vec3(0);
    vec3 recPos, recNormal, recColor;

    for (int i=0; i<MAX_RECURSION; i++) {
        if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) {
//            if (isLightHit) { // direct light sampling code
//                return  vec3(0);
//            }
            col *= recColor;
            for (int i=0; i<2; i++) {
                emitted += col * sampleLight(i, recPos, recNormal);
            }
        } else {
            return emitted;
        }
        rd = cosWeightedRandomHemisphereDirection(recNormal);
        ro = recPos;
    }
    return emitted;
}

За да се намали шума, в цикъла е добавено семплиране на директното осветяване. Това много прилича на техниката, използвана в шейдъра Yet another Cornell Box на Shadertoy.

Отражението

Благодарение на възможностите за трасиране на сцената с лъчи, в шейдъра е съвсем лесно да се добавят и отражения. В моето демо отраженията се добавят чрез извикване на метода getBounceCol, показан по-горе, но в който камерата играе ролята и на лъча:

#ifdef REFLECTION
    col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15);
#endif

Отраженията се добавят по време на цикъла на проактивното рендиране и следователно един екранен пиксел винаги ще има точно един отразен лъч.

Времевото изглаждане (Temporal anti-aliasing)

Тъй като и за меките сенки в цикъла на проактивното рендиране, и за апроксимацията на diffuse GI се използва примерно един сампъл на пиксел, крайният резултат е твърде шумен. За намаляването на този шум се използва времево изглаждане (Temporal anti-aliasing), реализирано чрез TAA алгоритъма (Temporal Reprojection Anti-Aliasing in INSIDE) на компанията Playdead.

Шумът

Шумът е основата на всички алгоритми, използвани за изчисляването на diffuse GI и меките сенки. Използването на качествен шум оказва много силно влияние на качеството на изображението, като некачественият шум създава артефакти и влошава изображенията.

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

Вероятно, използването на качествен шум е най-важният аспект при повишаване качеството на изображението в това демо. Но би могъл да се използва например син шум.

Пробвах с шум на основата на златното сечение, но нищо не се получи. Засега оставям алгоритъма Hash without Sine, който не се слави с добра репутация:

vec2 hash2() {
    vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3);
    p3 += dot(p3, p3.yzx + 19.19);
    return fract((p3.xx+p3.yz)*p3.zy);
}

Намаляване количеството на шума

Дори и с включен TAA, в демото остава много шум. Най-трудно се рендира тавана, понеже той се осветява само от непряка светлина. Нещата се влошават и от това, че таванът е просто една голяма плоска повърхност с един и същи цвят – ако имаше текстура или геометрични детайли, то шумът нямаше да се забелязва толкова.

Не исках да губя много време за тази част на демото и пробвах само с един филтър за понижаване на шума: Median3x3 на Морган МакГайвър и Кайл Уитсън. За съжаление този филтър не работи много добре, когато има предимно точки: той маха всички далечни детайли и окръглява пикселите на близките стени.

Демото:

Да се играе демото е възможно ето тук.

ДОБАВИ КОМЕНТАР

  Абонирай се  
Извести ме за