Русский
Русский
English
Статистика
Реклама

Трассировка лучей

Куда идут лучи под водой?

17.03.2021 08:06:08 | Автор: admin

В военное время значение синуса может достигать четырех

(С) Willebrord Snel van Royen, Cyclometricus, 1621

Приветствую вас, глубокоуважаемые!

Представьте, каким был бы наш мир, если бы условия распространения звука в нем были бы несколько иными. Например, иногда ваш крик был бы не слышен на расстоянии десяти метров, но становился бы вполне различим на сотне-другой и, скажем, на паре километров? А иногда, негромкий разговор или даже шепот разносился бы на километры от вас. Уверен, что пословица слово - не воробей.. - заиграла бы совершенно новыми красками!

А еще, все эти эффекты сильно зависели бы от времени года и суток, погоды и даже наклона вашей головы. Как бы нам жилось в таком мире?

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

Для тех, кто услышал мой шепот в современном потоке информации - добро пожаловать под кат, у нас сегодня не только ликбез и культпросвет, но модель с красивыми картинками, исходным кодом на двух языках и онлайн-симулятором, а также видео, где за 15 минут отображаются все научные морские экспедиции с 1961 по 2010 год.

Псевдопритча

Для всех нас должна быть поучительной жизнь и судьба мореплавателя Кристобаля Колона, который четырежды топтал земли нового света, пребывая в упрямой уверенности, что ступает то по благословенной индийской земле, то по беднейшей части восточного Китая. Так до самой смерти и не узнавший, что он стал первым из европейцев, кто бороздил просторы карибского и саргассова морей. Кто знает, может быть его бы и не расстроило, что на тихоокеанскую плиту наползает Северо-Американская, а не Северо-Колумбийская, но вот то, что доливкой кипятка в эспрессо получают Американо, а не Колумбиано - точно бы опечалило.

Поэтому, не то, чтобы необходимо, но исключительно важно:

  • понимать, где ты находишься

  • иметь связь, как минимум, чтобы спросить у того, кто понимает.

Конечно, Америго Веспуччи, или, по другим данным, Ричард Америке обошлись судя по всему и без этого, но это лишь делает им честь, которая на нас автоматически не экстраполируется.

Океан прекрасен, опасен и неясен.

И именно поэтому, так живучи дежурные фразы про то, что большая часть океана до сих пор не исследована, и то, что поверхность марса картирована лучше, чем поверхность земли.

Но почему так происходит? Помимо того, что под водой, как известно, на нас оказывается огромное давление и благодаря эволюции мы утратили жабры, а вместе с ними и возможность дышать растворенным кислородом, под водой практически не распространяются радиоволны, а еще там в основном темно, холодно и страшно.

Все задачи, которые на земле и в воздухе решаются при помощи радиоволн: GPS, радар, спутниковая связь и мобильная связь перекладываются на узкие, рахитичные плечики гидроакустики.

Кратко напомню, что глубина Бездны Челленжера (самой глубокой точки земных океанов) определена с точностью +/- 10 метров - и на сегодняшний день это все, что может современная наука.

А все потому, что скорость звука в океане не остается неизменной: она очень сильно зависит от температуры, солености и глубины. Это приводит к искривлению траектории распространения звуковых волн в вертикальной плоскости, говоря научным языком - к рефракции акустического поля. Сложная стратифицированность присуща не только океанической среде, но вполне наблюдается и в более мелких водоемах - вспомните хотя бы теплый, прогретый слой воды в речке летом.

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

Это не наш профиль

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

Пару слов про источники данных. Наборы измерений CTD (температура/соленость) я брал у NOAA.

Я уже как-то рассказывал про эту кладезь и несметные сокровища в статье "Кто, как и на чем исследовал мировой океан". И было собирался написать вторую статью про эти данные, но утратил интерес. Поэтому предлагаю просто посмотреть результат работы - видео, которое в за пятнадцать минут отображает на карте 49 лет морских экспедиций с 1961 по 2010.

Примерно 20 дней в секунду. Цвет точки - температура поверхности, текущие измерения выделяются яркостью.

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

Вниз по вертикали направлена ось глубин, желтым отображается температурный профиль, а бирюзовым - профиль солености. Зеленый график - вычисленные по глубине, температуре и солености значения скорости звука.

А что же будет, если нам нужна не простоглубина, а, скажем, горизонтальная дальность? А вот здесь, как сказал поэт, к нам и повернется, черное лицо досуга.

Лучано Поворотти (итал. Luciano Pоvоrotti) - буквально: Луч рефрагирует

Я уже упомянул о изгибании траектории звуковых волн, но давайте сначала порассуждаем на стихийно-бытовом уровне и вспомним школьные уроки физики:

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

Этот феномен описывается законом Снеллиуса, или Снелля. Он гласит, что:

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

sin \theta/sin\theta_1=c/c_1

Пока я не забыл, для нашего рассказа удобно ввести понятие луча - перпендикуляра к касательной, проведенной в какой-либо точке к фронту волны. В курсе школьной физике мы как раз и рисовали лучи. Давайте я нарисую парочку, для демонтсрации закона Снелля.

Если кто-то не помнит, как он выводится, то может заглянуть под спойлер и освяжить память.

Вспомнить все!

Осознать закон Снелля очень просто. У нас есть два параллельных луча, которые падают на границу раздела под углом . Очевидно, что время хода второго луча на отрезке АB должно совпадать времени хода первого луча на отрезке АB - в противном случае пришлось бы принять, что они перестали быть параллельными.

Для обоих лучей это время равно отношению длины пройденного пути к скорости распространения волны:

t=A'B/c=AB'/c

Если взглянуть на два прямоугольных треугольника AAB и ABB, то окажется, что у них общая сторона AB, которую можно выразить двумя способами:

AB=A'B/sin\theta=AB'/sin\theta_1

Исключив из выражения отрезки AB и AB получаем закон Снелля, Снеллиуса или закон синусов:

sin\theta/sin\theta_1=c/c_1

Это очень глубокая штука. Я предлагаю тщательно ее обдумать, ведь фактически, волна решает оптимизационную задачу: она старается двигаться по пути наименьшего (экстремального) времени распространения. (Принцип Ферма).

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

Если вас пугают следовые количества математики, то можно смело пропустить спойлер ниже и сразу переходить к картинкам и обсуждению. Здесь же я кратко изложу, как и почему такие картинки получаются.

Лучи не боги изгибают

Детали реализации

Недавний случай из жизни

Где-то осенью 2020 года ко мне в линкедин постучался американский молодой разраб, с просьбой помочь ему с трассировкой лучей в подводе. В тот момент я не сильно интересовался темой (почему - станет понятно в конце статьи) и знал только, что качественную трассировку можно сделать винрарным приложением BELLHOP. Я вежливо отнекивался, ведь мне действительно нечем было ему помочь! Я упомянул невзначай про пресловутый BELLHOP, на что получил обескураживающий ответ: у меня не получилось запустить это приложение.

А ведь да, если просто запустить BELLHOP, то просто моргнет черное окно консоли. BELLHOP - утилита, работающая из командной строки, с кучей параметров, берущая на вход один файл и выдающая на выходе другой. По которому можно потом строить красивые картинки в Matlab.

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

Нам понадобится только вспомнить совсем простое правило, что:

Угол падения равен углу отражения

И договориться о том, что мы считаем дно и поверхность воды совершенно плоским, но каждый раз, когда звук отражается ото дна или поверхности, энергия, им переносимая уменьшается определенным образом, скажем умножается на какой-нибудь коэффициент от 0 до 1.

Итак, о входных и выходных параметрах.

В качестве входных у нас будут:

  • Профиль скорости звука, как набор значений скорости звука с привязкой к глубине

  • Число лучей

  • Положение источника на оси Z (т.е. глубина)

  • Диаграмма направленности - угол, в пределах которого лучи выходят из источника

  • Интервал интегрирования по глубине

  • Максимальные значения по горизонтальной координате и длине луча - по достижении одного из этих параметров симуляция для данного луча останавливается

  • Коэффициенты отражения для дна и поверхности

С выходом все предельно понятно: мы хотим иметь траектории движения лучей. То есть последовательные наборы точек, где сопоставляются горизонтальная и вертикальные координаты, к которым можно добавить параметр, определяющий энергию луча в данной точек Напомню, в нашей упрощенной модели луч теряет энергию только при отражениях ото дна и от поверхности.

Перед тем, как выполнить симуляцию нужно подготовить профиль скорости звука к удобному виду: доступные по базам NOAA профили собирались в разные годы и зачастую интервал по глубине может отличаться и для двух соседних измерений. Поэтому линейно интерполируем профиль через 0.1 м.

В проекте на C# это выполняется в методе BuildVSSP() класса Simulator.

После этого создаем список экземпляров класса Ray, каждому из которых передаем угол из заданного диапазона:

double dTheta_deg = thetaRange_deg / numRays;double theta_deg = -thetaRange_deg / 2;for (int i = 0; i < numRays; i++){   rays.Add(new Ray(Z0_m, profileZMax_m, theta_deg, GetSoundSpeed));   theta_deg += dTheta_deg;                }

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

Сама же симуляция выполняется таким образом:

bool finished = false;while (!finished){   finished = true;   foreach (var ray in rays)   {         if ((ray.XMax_m < maxX_m) && (ray.Path_m < maxPath_m))         {               ray.Step(dZ);               finished = false;          }                        }}

Сама же логика поведения луча заключена в методе Step() класса Ray, она-то и делает следующий шаг - решает каково будет следующее положение головы луча.

double z = rayPoints[rayPoints.Count - 1].Z_m;double x = rayPoints[rayPoints.Count - 1].X_m;double e = rayPoints[rayPoints.Count - 1].E;double theta_sign = Theta_rad > 0 ? 1 : -1;z += (dZ * theta_sign);if (z > ZMax_m){   if (z >= ZMax_m)   {      Theta_rad = -Theta_rad;      theta_sign = -theta_sign;      z = ZMax_m * 2 - z;      e *= EtaBottom;      // bottom reflection   }}else{   if (z <= 0)   {      Theta_rad = -Theta_rad;      theta_sign = -theta_sign;      z = -z;      e *= EtaSurface;      // surface reflection   }}            double rsn = dZ / Math.Abs(Math.Sin(Theta_rad));            double drn = Math.Sqrt(rsn * rsn - dZ * dZ);Path_m += Math.Abs(rsn);x += drn;c = getSoundSpeed(z);                        double cosTheta = Math.Cos(Theta_rad);double recipSinTheta = 1.0 / Math.Sin(Theta_rad);double cncp = cosTheta * c / cPrev;if ((Math.Abs(cncp) <= 1) && (!double.IsInfinity(recipSinTheta))){    prevTheta_deg_valid = Theta_rad;    Theta_rad = theta_sign * Math.Acos(cncp);}else{    Theta_rad = -prevTheta_deg_valid;}           cPrev = c;RayPoint newPoint = new RayPoint(x, z, e);rayPoints.Add(newPoint);return newPoint;

Сама логика достаточно проста. Позволю себе лишь упомянуть, что для удобства мы здесь переходим от угла падения (угол, который луч образует с вертикалью) к углу скольжения (угол, который луч образует с горизонталью) - мне просто удобнее так мыслить, коль скоро процесс у нас развивается слева направо. Закон синусов у нас в связи с этим заменяется на закон косинусов - синус угла падения это есть косинус угла скольжения.

В остальном там нет никаких высоких материй: берется самая последняя точка и последнее значение угла скольжения, в зависимости от его знака, вертикальная координата приращивается или вверх или вниз. Проверяется условие пересечения поверхности или дна.

Один скользкий момент связан с тем случаем, когда угол скольжения становится близок к вертикали или горизонтали. Лучевая теория решительно не может нам здесь адекватно помочь: согласно ей тут происходит фокусировка и уровень сигнала стремится к бесконечности. В реальности, конечно, такого произойти не может.

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

Ну что ж, достаточно о реализации. Скучная часть окончена, перейдем к картинкам.

Все, что угодно за ваши лучезарные улыбки

Взяв на себя смелость я быстренько накидал простое приложение на С#, который мне ближе всего при разработке чего-нибудь для десктопа. Проект, как обычно, положил на наш гихаб.

И собрался было уже писать статью, как вспомнил, что на одну из таких статей с исходниками и скомпиленным релизом кто-то написал в комментариях: Открыл ссылку - а там win exe :(

Мне очень хотелось, чтобы поиграться с моделью для читателей было бы совсем просто, настолько, насколько я вообще могу себе представить. Вооружившись блокнотом я погрузился на пару дней в чуждый для меня мир JavaScript. Этим я как бы покупаю у вас индульгенцию на неряшливый код :)

Результатом стала онлайн версия трассировщика лучей.

Так давайте же уже запустим это!

Устанавливаю в поле Z значение 0.1 (глубина первого профиля всего 38 метров и разрешение по умолчанию в 1 метр нам будет недостаточно) и загружаем предустановленный профиль GR-553 при помощи одноименной кнопки.

В заголовке профиля указано где, когда и кем он был получен:

NODC Cruise ID              ,,GR-553         ,,,Latitude                    ,,         39.682,decimal degrees,,Longitude                   ,,        19.8750,decimal degrees,,Year                        ,,           1991,,,Month                       ,,              9,,,Day                         ,,              5,,,Time                        ,,          13.17,decimal hours (UT),,METADATA,Country                     ,,             GR,NODC code,GREECEPlatform                    ,,          9628.,OCL code,AEGAEO (R/V;call sign SXYY;built 12.1985;ex.AEGAIO 10.1994;IMO8412429),Institute                   ,,          1445.,NODC code,HELLENIC CENTRE FOR MARINE RESEARCH (HCMR); former NATIONAL CMR (06.2003),Bottom depth                ,,            38.,meters,,

Профиль был получен славными эллинами, 5 сенября 1991 года на вот этом исследовательском судне вот здесь.

Оно кстати, до сих пор бороздит просторы.

Нажимаем кнопку RUN SIMULATION и через некоторое время скрипт генерирует такую красоту:

Сентябрь, греческий Корфу, жаркий (наверное) полдень - теплая вода у поверхности и прохладная у дна. Я не был в греции, но могу сказать, что у нас на р. Пичуга под Волгоградом в самое июльское пекло на 15-20 метрах температура обычно 13-14С.

Слева мы видим сам профиль, значения скорости звука (зеленый график) почти 1 в 1 повторяют график температуры. Соленость не влияет совсем, т.к. меняется лишь незначительно от 38.3 до 38.5 PSU. Скорость звука же уменьшается от 1537 м/с на поверхности, достигая минимума в 1528 м/с у дна.

Все это приводит к тому, что звук, от расположенного на глубине Z0 = 2 метра источника, придет к поверхности не далее чем в 400 метрах от положения этого самого источника. Это будут т.н. прямые лучи. Все остальное - отражения от дна и многократные поверхность-дно-поверхность.

Мы видим, что лучи загибаются вниз. А что там такого, что им туда так хочется попасть? Вот здесь мы подходим к самому основному, фактически, это самый минимум, который я хочу, чтобы вы запомнили. Из этого выводится все остальное.

Траектории лучей загибаются в сторону уменьшения скорости звука.

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

А помните, я обращал ваше внимание на то, что суть закона Снелля в том, что при преодолении границы раздела сред, волна идет путем экстремального (минимального) времени распространения? Ирония состоит в том, что волна, локально сокращая время распространения всегда стремится в область с меньшей скоростью звука, что в глобальном смысле наоборот увеличивает время распространения.

Чтобы закрепить, откроем следующий профиль NA-35, но немного изменим параметры симуляции: максимум по оси X установим равным 2000 метров. Что же мы видим?

Ого, глубина уже лодочная! А температура падает от 16С у поверхности до 6С у дна. И снова отрицательная рефракция, и снова звук стремится ко дну.

21 июня 1996 года. В России вовсю свирепствуют девяностые, я закончил 6 класс и наверное отдыхаю на даче с братьями, а судно под названием Dr. Fridtjof Nansen берет CTD пробы у берегов Намибии.

Необычайно прохладная для этих мест вода обязана холодному течению Бенгела.

Почему это важно? Мы, например, производим гидроакустические модемы, у которых среди характеристик указана т.н. энергетическая дальность - дальность, на которой два таких модема могут передать данные между собой. Для самых маленьких наших модемов она составляет 1000 метров. Длины лучей на картинке выше как раз и ограничены одним километром.

И сложно не заметить, что максимум, что можно получить опустив модемы чуть ниже поверхности воды - метров 400. А учитывая волнение и вовсе не гладкую поверхность воды - десятки метров. А вот если опустить приемник хотя бы метров на 10-20, можно уверенно получить фактическую дальность в районе километра.

Ладно, надоела уже эта отрицательная рефракция. Поехали дальше.

Профиль US-32437. Я поставил предел по оси Х и максимальную длину луча равными 2000 метров. Остальные условия те же.

30 января 2009 года. Исследовательское судно NOAA Delaware II на своем 41 году жизни берет пробы у восточной оконечности Лонг-Айленда. Невероятно, но факт: marinetraffic сообщает, что Delaware II до сих пор в строю в свои 53 года. Правда называется теперь Med Surveyor и ходит под флагом Панамы.

Ситуация с точностью до наоборот: минимум скорости звука на поверхности. В этом нет ничего удивительного: 30 января, северная атлантика. В нью-йорке, говорят, иногда идет снег. Когда упоминается Нью-Йорк, в моей голове сразу возникает Let it snow, let it snow, let it snow голосом Фрэнка Синатры.

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

Но иногда, это даже хуже: при обсуждении предыдущего профиля я привел совсем уж невероятный пример. И правда: зачем кому-то связываться по гидроакустике на километре в двух метрах ото дна? Вот например, связаться с каким-нибудь роботом или осуществлять его трекинг какой-нибудь навигационной системой - это реалистичный сценарий.

И что же мы видим на картинке? Если наш робот находится глубже 30 метров и отошел от нас метров на 400 - связь может стать очень неустойчивой и даже полностью пропасть. На дальности более 1000 метров и глубине более 20 ничего, кроме возможных отражений от поверхности мы не поймаем. Кстати, большой привет тем, кто работает с УКБ и использует вертикальный угол прихода сигнала для определения глубины объекта.

Но скорее всего не поймаем даже эти отражения: январь, атлантика. Наверняка ветрено и большие волны, они явно больше длины волны нашего сигнала - вот и на отражения от поверхности можно не рассчитывать.

Тем временем, мы подходим к следующему профилю из онлайн-трассировщика. Я уменьшил угол диаграммы направленности до 60 и установил глубину источника в 60 метров. Вот что получается:

Этот профиль особенно примечателен. Мы видим, что график скорости звука (и температуры) имеет экстремумы. То есть главный минимум находится ни у дна ни у поверхности. К чему это приводит?

Звук, как мы уже выяснили, загибается в сторону уменьшения скорости распространения. И, будучи излученным под небольшими углами скольжения в области минимума скорости звука оказывается заперт: он обречен постоянно пересекать горизонт минимума и постоянно возвращаться к нему. Такую область называют каналом, а условия распространения - канальными.

Впервые описали и объяснили условия канального распространения звука в океане в 1947 году советские ученые во главе с академиком Бреховских и доктором технических наук Розенбергом.

Иногда упоминают, что на звукоподводный канал был открыт и описан немного раньше, в 1944 году Американскими учеными, правда, все было описано в закрытых работах.

Но вернемся к нашему профилю.

19 ноября 1996, легкий ледокол George R. Pearkes 1986 года постройки (все еще в строю) брал пробы в дельте реки Сент-Лоуренс, где она впадает в одноименный залив.

Давайте попробуем поугадывать, откуда взялся такой интересный профиль с двумя впадинами. Я бы сказал, что теплый летний слой не успел еще исчезнуть совсем (ноябрь все-таки), а вода у поверхности уже начала значительно охлаждаться. А еще, если обратить внимание на профиль солености - она значительно ниже у поверхности, что по всей видимости, связано с впадением пресной речной воды в океанические воды.

А теперь посмотрим, что происходит с лучами в канале. Для этого я увеличу предел по оси Х и максимальную длину луча до, скажем, 20 километров.

Вот что мы получим:

Лучи, вышедшие из источника под небольшими углами к горизонту остаются в пределах канала, не касаясь ни дна ни поверхности: звукоподводный канал на мелководье!

Звук распространяется совсем неглубоко и в верхней точки выходит на глубины порядка 20-25 метров. Так он может проходить десятки километров. Напомню: по оси Z всего около 170 метров, а по оси Х - 20 000. То есть фактическое соотношение сторон у графика более 1:100, это примерно как столешница, или лужа на асфальте.

Мы переходим к заключительному профилю, самому глубокому из рассмотренных нами: SU-7464.

22 апреля 1991 года. Судно "Прилив" (на marinetraffic я его не нашел), еще под советским флагом, управляющая организация - Тихоокеанский Океанологический Институт, г. Владивосток. Место взятия проб - камчатский залив.

Этот профиль и выполненная по нему симуляция иллюстрируют еще один занимательный феномен: так называемый, приповерхностный звуковой канал. Такое бывает, когда самые холодные слои воды находятся под поверхностью, что приводит к формированию приповерхностного слоя с минимальной скоростью звука между немного прогретым поверхностным и более теплыми глубокими слоями. Здесь мы также можем наблюдать пресловутый слой скачка - резкого изменения скорости звука, от которого как от зеркала отражаются лучи, идущие под пологими и средними углами к горизонту.

Луч - линия имеющая начало, но не имеющая конца

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

Что же у нас в сухом остатке?

  • Звук распространяется в водоемах не по прямым линиям, а изгибается в виду наличия слоев воды разной плотности, а соответственно - с разной скоростью звука

  • Звук всегда поворачивает в сторону минимума скорости звука, от чего при определенных условиях в водоемах могут образовываться каналы

  • Примерный (примерный!) характер траекторий звуковых волн можно получить используя очень простые закономерности

  • Диапазон условий необычайно широк, а конкретные условия в конкретном месте склонны иногда к быстрым изменениям

Добавлю, что мало знать профиль скорости звука, нужно знать условия на поверхности, профиль и материал дна. Еще хорошо бы понимать где на пути вашего сигнала проплывает косяк макрели, или какие-то другие объемные неоднородности, на которых звук рассеивается.

Профиль же сам по себе не дает никаких гарантий: он взят вот в этой конкретной точке, а реальное распределение параметров в океане меняется непрерывно в пространстве и времени. Если вы не знаете какую-то часть исходных данных, то трассировка - это не путь, а только направление и поэтому не очень применима на практике.

Закончить свое повествование хочу такими аналогиями:

  • Гидроакустическая связь, особенно на дальние расстояния, это всегда немножко попытка скинуть ключи с балкона 16 этажа человеку с завязанными глазами.

  • А гидроакустическая навигация - попытка измерять что-то постоянно извивающейся или как минимум провисшей рулеткой =)

P.S.

Традиционно благодарю за внимание, буду признателен за конструктивную критику и указание на ошибки.

Обратная связь позволяет не думать о своем труде, как о напрасном.

Подробнее..

Пишем простой Path Tracer на старом добром GLSL

19.12.2020 16:19:22 | Автор: admin

На волне ажиотажа вокруг новых карточек от Nvidia с поддержкой RTX, я, сканируя хабр в поисках интересных статей, с удивлением обнаружил, что такая тема, как трассировка путей, здесь практически не освящена. "Так дело не пойдет" - подумал я и решил, что неплохо бы сделать что-нибудь небольшое на эту тему, да и так, чтоб другим полезно было. Тут как кстати API собственного движка нужно было протестировать, поэтому решил: запилю-ка я свой простенький path-tracer. Что же из этого вышло вы думаю уже догадались по превью к данной статье.

Немного теории

Трассировка пути является одним из частных случаев трассировки лучей и представляет собой наиболее точный с физической точки зрения способ рендеринга. Используя непосредственное моделирование распространения света для расчета освещения, этот алгоритм позволяет с минимальными усилиями получать весьма реалистичные сцены, для рендеринга которых традиционным способом ушло бы неимоверное количество усилий.

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

трассировка лучей от позиции наблюдателятрассировка лучей от позиции наблюдателя

Одним из первых вопросов, которым можно задаться при рассмотрении данного алгоритма: а до какого вообще момента нам нужно моделировать движение луча? С точки зрения физики, практически каждый объект, что нас окружает, отражает хоть сколько-то света. Соответственно, естественный способ моделирования пути фотона - вычисление траектории движения вплоть до тех пор, пока количество отраженного света от объекта станет настолько малым, что им можно пренебречь. Такой метод безусловно имеет место быть, однако является крайне непроизводительным, и поэтому в любом трассирующем лучи алгоритме все же приходится жертвовать физической достоверностью изображения ради получения картинки за хоть сколько-то вменяемое время. Чаще всего выбор числа отражений луча зависит от сцены - для грубых диффузных поверхностей требуется гораздо меньше итераций, нежели для зеркальных или металлических (можете вспомнить классический пример с двумя зеркалами в лифте - свет в них отражается огромное число раз, создавая эффект бесконечного туннеля, и мы должны уметь это моделировать).

различные материалы, отрисованные физически-корректным рендерингомразличные материалы, отрисованные физически-корректным рендерингом

Другой вопрос, который у нас возникает - какие объекты могут быть на нашей сцене, и какими свойствами они обладают. И тут уже все не так очевидно и чаще всего зависит от конкретной реализации трассировщика. Для примера: в классическом физически-корректном рендеринге для описания объектов используют заранее заготовленный материалы с варьирующимися параметрами: албедо (диффузная отражательная способность материала), шероховатость (параметр, аппроксимирующий неровности поверхности на микро-уровне) и металличность (параметр, задающий отражательную способность материала). Иногда к ним добавляют и другие свойства, такие как прозрачность и показатель преломления.

Я же для своего алгоритма решил условно выделить для материала объекта следующие параметры:

  • Отражательная способность (reflectance) - какое количество и какой волны свет отражает каждый объект

  • Шероховатость поверхности (roughness) - насколько сильно лучи рассеиваются при столкновении с объектом

  • Излучение энергии (emittance) - количество и длина волны света, которую излучает объект

  • Прозрачность (transparency/opacity) - отношение пропущенного сквозь объект света к отраженному

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

Реализуем наш алгоритм на GLSL

К сожалению (или к счастью?) сегодня мы не будем делать профессиональный трассировщик путей, и ограничимся лишь базовым алгоритмом с возможностью трассировать на сцене параллелепипеды и сферы. Для них относительно легко находить пересечения c лучем, рассчитывать нормали, да и в целом такого набора примитивов уже достаточно, чтобы отрендерить классический cornell-box.

один из вариантов cornell box'a для тестирования корректности рендерингаодин из вариантов cornell box'a для тестирования корректности рендеринга

Основной алгоритм мы будем делать во фрагментном GLSL шейдере. В принципе, даже если вы не знакомы с самим языком, код будет достаточно понятным, так как во многом совпадает с языком С: в нашем распоряжении есть функции и структуры, возможность писать условные конструкции и циклы, и ко всему прочему добавляется возможность пользоваться встроенными примитивами для математических расчетов - vec2, vec3, mat3 и т.д.

Ну что же, давайте к коду! Для начала зададим наши примитивы и структуру материала:

struct Material{    vec3 emmitance;    vec3 reflectance;    float roughness;    float opacity;};struct Box{    Material material;    vec3 halfSize;    mat3 rotation;    vec3 position;};struct Sphere{    Material material;    vec3 position;    float radius;};

Для примитивов реализуем проверки пересечения с лучем: будем принимать начало луча и его направление, а возвращать расстояние до ближайшей точки пересечения и нормаль объекта в этой точке, если, конечно, такое пересечение в принципе произошло:

bool IntersectRaySphere(vec3 origin, vec3 direction, Sphere sphere, out float fraction, out vec3 normal){    vec3 L = origin - sphere.position;    float a = dot(direction, direction);    float b = 2.0 * dot(L, direction);    float c = dot(L, L) - sphere.radius * sphere.radius;    float D = b * b - 4 * a * c;    if (D < 0.0) return false;    float r1 = (-b - sqrt(D)) / (2.0 * a);    float r2 = (-b + sqrt(D)) / (2.0 * a);    if (r1 > 0.0)        fraction = r1;    else if (r2 > 0.0)        fraction = r2;    else        return false;    normal = normalize(direction * fraction + L);    return true;}bool IntersectRayBox(vec3 origin, vec3 direction, Box box, out float fraction, out vec3 normal){    vec3 rd = box.rotation * direction;    vec3 ro = box.rotation * (origin - box.position);    vec3 m = vec3(1.0) / rd;    vec3 s = vec3((rd.x < 0.0) ? 1.0 : -1.0,        (rd.y < 0.0) ? 1.0 : -1.0,        (rd.z < 0.0) ? 1.0 : -1.0);    vec3 t1 = m * (-ro + s * box.halfSize);    vec3 t2 = m * (-ro - s * box.halfSize);    float tN = max(max(t1.x, t1.y), t1.z);    float tF = min(min(t2.x, t2.y), t2.z);    if (tN > tF || tF < 0.0) return false;    mat3 txi = transpose(box.rotation);    if (t1.x > t1.y && t1.x > t1.z)        normal = txi[0] * s.x;    else if (t1.y > t1.z)        normal = txi[1] * s.y;    else        normal = txi[2] * s.z;    fraction = tN;    return true;}

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

Хранить все наши объекты будем в обычных массивах, благо, GLSL создавать их также позволяет. Сцена у нас небольшая, поэтому никаких оптимизаций при поиске пересечения не реализуем - просто проходим по всем объектам и вычисляем ближайшее расстояние до точки пересечения:

#define FAR_DISTANCE 1000000.0#define SPHERE_COUNT 3#define BOX_COUNT 8Sphere spheres[SPHERE_COUNT];Box boxes[BOX_COUNT];bool CastRay(vec3 rayOrigin, vec3 rayDirection, out float fraction, out vec3 normal, out Material material){    float minDistance = FAR_DISTANCE;    for (int i = 0; i < SPHERE_COUNT; i++)    {        float D;        vec3 N;        if (IntersectRaySphere(rayOrigin, rayDirection, spheres[i], D, N) && D < minDistance)        {            minDistance = D;            normal = N;            material = spheres[i].material;        }    }    for (int i = 0; i < BOX_COUNT; i++)    {        float D;        vec3 N;        if (IntersectRayBox(rayOrigin, rayDirection, boxes[i], D, N) && D < minDistance)        {            minDistance = D;            normal = N;            material = boxes[i].material;        }    }    fraction = minDistance;    return minDistance != FAR_DISTANCE;}

Функция выше позволяет нам найти точку столкновения луча с поверхностью. Это работает, но мы получаем информацию только о прямом освещении. Мы же хотим учитывать еще и непрямое освещение, поэтому давайте немного подумаем над тем, как меняется поток света при столкновениях с объектами на нашей сцене.

Трассировка пути

В нашей реализации каждый объект может излучать свет, отражать свет, и поглощать (случай с преломлением пока опустим). В таком случае формулу для расчета отраженного света от поверхности можно задать следующим образом: L' = E + f*L, где E - излучаемый объектом свет (emittance), f - отражаемый объектом свет (reflectance), L - свет, упавший на объект, и L' - то, что объект в итоге излучает.

И в итоге такое выражение легко представить в виде итеративного алгоритма:

// максимальное количество отражений луча#define MAX_DEPTH 8vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){    vec3 L = vec3(0.0); // суммарное количество света    vec3 F = vec3(1.0); // коэффициент отражения    for (int i = 0; i < MAX_DEPTH; i++)    {        float fraction;        vec3 normal;        Material material;        bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material);        if (hit)        {            vec3 newRayOrigin = rayOrigin + fraction * rayDirection;            vec3 newRayDirection = ...            // рассчитываем, куда отразится луч            rayDirection = newRayDirection;            rayOrigin = newRayOrigin;            L += F * material.emmitance;            F *= material.reflectance;        }        else        {            // если столкновения не произошло - свет ничто не испускает            F = vec3(0.0);        }    }    // возвращаем суммарный вклад освещения    return L;}

Если бы мы писали наш код на условном C++, можно было бы напрямую получать L как результат работы рекурсивно вызываемой функции CastRay. Однако, GLSL не разрешает рекурсивные вызовы функций в любом виде, поэтому приходится развернуть наш алгоритм так, чтобы он работал итеративно. С каждым отражением мы уменьшаем коэффициент, на который умножается испускаемый или отражаемый объектом свет, и тем самым повторяем описанную выше формулу. В моей реализации потенциально каждый объект может излучать какое-то количество света, поэтому emittance учитывается при каждом столкновении. Если же луч ни с чем не сталкивается, мы считаем, что никакого света до нас не дошло. В принципе для таких случаев можно добавить выборку из карты окружения или задать "дневной свет", но после экспериментов с этим я понял, что больше всего мне нравится текущая реализация, с пустотой вокруг сцены.

Об отражениях

Теперь давайте решим следующий вопрос: а по какому же принципу луч отражается от объекта? Очевидно, что в нашем path-tracer'е это будет зависеть от нормали в точке падения и микро-рельефа поверхности. Если обратиться к реальному миру, мы увидим, что для гладких материалов (таких как отполированный металл, стекло, вода) отражение будет очень четким, так как все лучи, падающие на объект под одним углом, будут и отражаться примерно под одинаковым углом (см. specular на картинке ниже), когда как для шероховатых, неровных поверхностей мы наблюдаем очень размытые отражения, чаще всего диффузные (см. diffuse на картинке ниже), так как лучи распространяются по полусфере относительно нормали объекта. Именно этой закономерностью мы и воспользуемся, задав итоговое направление отраженного луча как D = normalize(a * R + (1 - a) * T), где a - коэффициент шероховатости/гладкости поверхности, R - идеально отраженный луч, T - луч, отраженный в случаном направлении в полусфере относительно нормали. Очевидно, что при коэффициенте a = 1 в такой формуле мы всегда будет получать идеальное отражение луча, а при a = 0, наоборот, равномерно распределенное по полусфере. При коэффициенте шероховатости, лежащем в интервале от 0 до 1, на выходе будем иметь некоторое распределение лучей, ориентированное по углу отражения, что в вполне корректно и как раз характерно для глянцевых поверхностей (см. glossy на картинке ниже).

распределение лучей для различных типов поверхностейраспределение лучей для различных типов поверхностей

Вернемся к коду. Давайте начнем в этот раз с самого сложного - напишем реализацию функции, которая бы возвращала нам случайный луч в полусфере относительно нормали. Для этого сначала возьмем какой-нибудь набор случайно распределенных чисел, и сгенерируем по ним луч, лежащий в на границе сферы единичного радиуса, а затем тривиальным образом спроецируем его в ту же полусферу, где находится нормаль объекта:

#define PI 3.1415926535vec3 RandomSpherePoint(vec2 rand){    float cosTheta = sqrt(1.0 - rand.x);    float sinTheta = sqrt(rand.x);    float phi = 2.0 * PI * rand.y;    return vec3(        cos(phi) * sinTheta,        sin(phi) * sinTheta,        cosTheta    );}vec3 RandomHemispherePoint(vec2 rand, vec3 n){    vec3 v = RandomSpherePoint(rand);    return dot(v, n) < 0.0 ? -v : v;}

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

vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal);vec3 randomVec = normalize(2.0 * Random3D() - 1.0);vec3 tangent = cross(randomVec, normal);vec3 bitangent = cross(normal, tangent);mat3 transform = mat3(tangent, bitangent, normal);vec3 newRayDirection = transform * hemisphereDistributedDirection;

Небольшое примечание: здесь и далее Random?D генерирует случайные числа в интервале от 0 до 1. В GLSL шейдере делать это можно разными способами. Я использую следующую функцию, генерирующую случайным шум без явных паттернов (любезно взята со StackOverflow по первому запросу):

float RandomNoise(vec2 co){    return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);}

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

отражения с различной шероховатостьюотражения с различной шероховатостью

После всех наших модификацийкод нашей функции TracePath будет выглядеть вот так:

vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){    vec3 L = vec3(0.0);    vec3 F = vec3(1.0);    for (int i = 0; i < MAX_DEPTH; i++)    {        float fraction;        vec3 normal;        Material material;        bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material);        if (hit)        {            vec3 newRayOrigin = rayOrigin + fraction * rayDirection;            vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal);            randomVec = normalize(2.0 * Random3D() - 1.0);            vec3 tangent = cross(randomVec, normal);            vec3 bitangent = cross(normal, tangent);            mat3 transform = mat3(tangent, bitangent, normal);                        vec3 newRayDirection = transform * hemisphereDistributedDirection;                            vec3 idealReflection = reflect(rayDirection, normal);            newRayDirection = normalize(mix(newRayDirection, idealReflection, material.roughness));                        // добавим небольшое смещение к позиции отраженного луча            // константа 0.8 тут взята произвольно            // главное, чтобы луч случайно не пересекался с тем же объектом, от которого отразился            newRayOrigin += normal * 0.8;            rayDirection = newRayDirection;            rayOrigin = newRayOrigin;            L += F * material.emmitance;            F *= material.reflectance;        }        else        {            F = vec3(0.0);        }    }    return L;}

Преломление света

Давайте рассмотрим еще такой важный для нас эффект, как преломление света. Все же помнят, как соломка, находящаяся в стакане, кажется сломанной в том месте, где она пересекается с водой? Этот эффект происходит потому, что свет, переходя между двумя средами с разными свойствами, меняет свою волновую скорость. Вдаваться в подробности того, как это работает с физической точки зрения мы не будем, вспомним лишь, что если свет падаем под углом a, то угол преломления b можно посчитать по следующей несложной формуле (см. закон Снеллиуса): b = arcsin(sin(a) * n1 / n2), где n1 - показатель преломления среды, из которой пришел луч, a n2 - показатель преломления среды, в которую луч вошел. И к счастью для нас, показатели преломления уже рассчитаны для интересующих нас сред, достаточно лишь открыть википедию, или, накрайняк, учебник по физике.

Угол падения, отражения и преломленияУгол падения, отражения и преломления

Стоит заметить следующий интересный факт: sin(a) принимает значения от 0 для 1 для острых углов. Относительный показатель преломления n1 / n2 может быть любым, в том числе большим 1. Но тогда выходит, что аргумент sin(a) * n1 / n2 не всегда находится в области определения функции arcsin. Что же происходит с углом преломления? Почему наша формула не работает для такого случая, хотя с физической точки зрения ситуация вполне возможная?

Ответ кроется в том, что в реальном мире при таких условиях луч света попросту не преломится, а отразится! Данный эффект абсолютно логичен, если вспомнить, что в момент столкновения луча с границей раздела двух сред, он по сути "делится на двое", и лишь часть света преломляется, когда как остальная отражается. И с увеличением наклона под которым падает этот луч, все больше и больше света будет отражаться, пока не дойдет до того вырожденного, критического момента. Данное явление в физике носит название внутреннее отражение, а частный его случай, когда абсолютно весь свет отражается от границы раздела сред, называется полным внутренним отражением. И этот факт нужно будет учитывать в реализации нашего трассировщика путей.

эффект Френеляэффект Френеля

Сколько же света в итоге будет отражено от поверхности, а сколько пройдет сквозь нее? Чтобы ответить на этот вопрос, нам необходимо обратиться к формулам Френеля, которые как раз и используются для расчета коэфициентов отрадения и пропускания. Но не спешите ужасаться - в нашем трассировщике мы не будем расписывать эти громоздкие выражения. Давайте воспользуемся более простой аппроксимирующей формулой за авторством Кристофе Шлика - аппроксимацией Шлика. Она достаточно простая в реализации и дает приемлимые визуальные результаты, поэтому не вижу причин не добавить ее в наш код:

float FresnelSchlick(float nIn, float nOut, vec3 direction, vec3 normal){    float R0 = ((nOut - nIn) * (nOut - nIn)) / ((nOut + nIn) * (nOut + nIn));    float fresnel = R0 + (1.0 - R0) * pow((1.0 - abs(dot(direction, normal))), 5.0);    return fresnel;}

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

vec3 IdealRefract(vec3 direction, vec3 normal, float nIn, float nOut){    // проверим, находимся ли мы внутри объекта    // если да - учтем это при рассчете сред и направления луча    bool fromOutside = dot(normal, direction) < 0.0;    float ratio = fromOutside ? nOut / nIn : nIn / nOut;    vec3 refraction, reflection;    refraction = fromOutside ? refract(direction, normal, ratio) : -refract(-direction, normal, ratio);    reflection = reflect(direction, normal);    // в случае полного внутренного отражения refract вернет нам 0.0    return refraction == vec3(0.0) ? reflection : refraction;}

Чтобы учитывать эффект френеля, будем просто генерировать случайное число, и на его основе решать, преломится ли луч или отразится. Также не забываем, что у наших объектов есть параметр плотности, который также отвечает за отношение преломленного света к отраженному. Соединить оба этих условия можно по-разному, но я взял самый примитивный вариант:

bool IsRefracted(float rand, vec3 direction, vec3 normal, float opacity, float nIn, float nOut){    float fresnel = FresnelSchlick(nIn, nOut, direction, normal);    return opacity > rand && fresnel < rand;}

Теперь наконец можно склеить все вместе: добавим в нашу функцию TracePath логику, которая бы рассчитывала преломление света и при этом учитывала и шереховатость объекта - ее для полупрозрачных тел никто не отменял:

#define N_IN 0.99#define N_OUT 1.0vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){    vec3 L = vec3(0.0);    vec3 F = vec3(1.0);    for (int i = 0; i < MAX_DEPTH; i++)    {        float fraction;        vec3 normal;        Material material;        bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material);        if (hit)        {            vec3 newRayOrigin = rayOrigin + fraction * rayDirection;            vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal);            randomVec = normalize(2.0 * Random3D() - 1.0);            vec3 tangent = cross(randomVec, normal);            vec3 bitangent = cross(normal, tangent);            mat3 transform = mat3(tangent, bitangent, normal);            vec3 newRayDirection = transform * hemisphereDistributedDirection;                            // проверяем, преломится ли луч. Если да, то меняем логику рассчета итогового направления            bool refracted = IsRefracted(Random1D(), rayDirection, normal, material.opacity, N_IN, N_OUT);            if (refracted)            {                vec3 idealRefraction = IdealRefract(rayDirection, normal, N_IN, N_OUT);                newRayDirection = normalize(mix(-newRayDirection, idealRefraction, material.roughness));                newRayOrigin += normal * (dot(newRayDirection, normal) < 0.0 ? -0.8 : 0.8);            }            else            {                vec3 idealReflection = reflect(rayDirection, normal);                newRayDirection = normalize(mix(newRayDirection, idealReflection, material.roughness));                newRayOrigin += normal * 0.8;            }            rayDirection = newRayDirection;            rayOrigin = newRayOrigin;            L += F * material.emmitance;            F *= material.reflectance;        }        else        {            F = vec3(0.0);        }    }    return L;}

Для коэффициентов преломления N_IN и N_OUT я взял два очень близких числаdoo. Это не совсем физически-корректно, однако создает желаемый эффект того, что поверхности сделаны из стекла (как шар на первом скриншоте статьи). Можете смело их изменить и посмотреть, как поменяется угол преломления лучей, проходящих сквозь объект.

Давайте уже запускать лучи!

Дело осталось за малым: инициализировать нашу сцену в начале шейдера, передать внутрь все параметры камеры, и запустить лучи по направлению взгляда. Начнем с камеры: от нее нам потребуется несколько параметров: direction - направление взгляда в трехмерном пространстве. up - направление "вверх" относительно взгляда (нужен чтобы задать матрицу перевода в мировое пространство), а также fov - угол обзора камеры. Также передадим для рассчета чисто утилитарные вещи - экранную позицию обрабатываемого пикселя (от 0 до 1 по x и y) и размер окна для рассчета отношения сторон. В математику в коде тоже особо углубляться не буду - о том, как переводить из пространство экрана в пространство мира можно почитать к примеру в этой замечательной статье.

vec3 GetRayDirection(vec2 texcoord, vec2 viewportSize, float fov, vec3 direction, vec3 up){    vec2 texDiff = 0.5 * vec2(1.0 - 2.0 * texcoord.x, 2.0 * texcoord.y - 1.0);    vec2 angleDiff = texDiff * vec2(viewportSize.x / viewportSize.y, 1.0) * tan(fov * 0.5);    vec3 rayDirection = normalize(vec3(angleDiff, 1.0f));    vec3 right = normalize(cross(up, direction));    mat3 viewToWorld = mat3(        right,        up,        direction    );    return viewToWorld * rayDirection;}

Как бы ни прискорбно это было заявлять, но законы, по которым отражаются наши лучи имеют некоторую случайность, и одного семпла на пиксель нам будет мало. И даже 16 семплов на пиксель не достаточно. Но не расстраивайтесь! Давайте найдем компромисс: каждый кадр будем считать от 4 до 16 лучей, но при этом результаты кадров аккамулировать в одну текстуру. В итоге мы делаем не так много работы каждый кадр, можем летать по нашей сцене (хоть и испытывая на своих глазах ужасные шумы), а при статичной картинке качество рендера будет постепенно расти, пока не упрется в точность float'а. Преимущества такого подхода видны невооруженным взглядом:

рендер одного кадра и нескольких, сложенных вместерендер одного кадра и нескольких, сложенных вместе

В итоге наша функция main будет выглядеть примерно следующим образом (в алгоритме нет ничего сложного - просто запускаем несколько лучей и считаем среднее от результата TracePath):

// ray_tracing_fragment.glslin vec2 TexCoord;out vec4 OutColor;uniform vec2 uViewportSize;uniform float uFOV;uniform vec3 uDirection;uniform vec3 uUp;uniform float uSamples;void main(){    // заполняем нашу сцену объектами    InitializeScene();    vec3 direction = GetRayDirection(TexCoord, uViewportSize, uFOV, uDirection, uUp);    vec3 totalColor = vec3(0.0);    for (int i = 0; i < uSamples; i++)    {        vec3 sampleColor = TracePath(uPosition, direction);        totalColor += sampleColor;    }    vec3 outputColor = totalColor / float(uSamples);    OutColor = vec4(outputColor, 1.0);}

Аккамулируем!

Давайте закроем вопрос с тем, как мы будем отображать результат работы трассировщика. Очевидно, что если мы решили накапливать кадры в одной текстуре, то классический вариант с форматом вида RGB (по байту на каждый канал) нам не подойдет. Лучше взять что-то вроде RGB32F (проще говоря формат, поддерживающий числа с плавающей точкой одинарной точности). Таким образом мы сможем накапливать достаточно большое количество кадров прежде чем упремся в потолок из-за потерь точности вычислений.

Также сходу напишем шейдер, принимающий нашу аккамулирующую текстуру и вычисляющий среднее от множества кадров. Тут же применим тональную коррекцию изображения, а затем гамма-коррекцию (в коде я использую самый простой вариант tone-mapping'а, вы можете взять что-то посложнее, к примеру кривую Рейнгарда):

// post_process_fragment.glslin vec2 TexCoord;out vec4 OutColor;uniform sampler2D uImage;uniform int uImageSamples;void main(){    vec3 color = texture(uImage, TexCoord).rgb;    color /= float(uImageSamples);    color = color / (color + vec3(1.0));    color = pow(color, vec3(1.0 / 2.2));    OutColor = vec4(color, 1.0);}

И на этом часть с GLSL кодом можно считать оконченной. Как будет выглядеть наш трассировщий снаружи - вопрос открытый и зависит полностью от вас. Ниже я конечно же приведу реализацию рендеринга в контексте API моего движка, на котором я и писал данный трассировщик, но информация эта скорее опциональная. Думаю вам не составит труда написать связующий код на том фреймворке или графическом API, с которым лично вам удобнее всего работать:

virtual void OnUpdate() override{    // получаем текущую камеру и текстуру, в которую осуществляется рендер    auto viewport = Rendering::GetViewport();    auto output = viewport->GetRenderTexture();    // получим текущие параметры камеры (позицию, угол обзора и т.д.)    auto viewportSize = Rendering::GetViewportSize();    auto cameraPosition = MxObject::GetByComponent(*viewport).Transform.GetPosition();    auto cameraRotation = Vector2{ viewport->GetHorizontalAngle(), viewport->GetVerticalAngle() };    auto cameraDirection = viewport->GetDirection();    auto cameraUpVector = viewport->GetDirectionUp();    auto cameraFOV = viewport->GetCamera<PerspectiveCamera>().GetFOV();    // проверим, что камера неподвижна. От этого зависит, нужно ли очищать предыдущий кадр    bool accumulateImage = oldCameraPosition == cameraPosition &&                           oldCameraDirection == cameraDirection &&                           oldFOV == cameraFOV;    // при движении снизим количество семплов ради приемлемой частоты кадров    int raySamples = accumulateImage ? 16 : 4;    // установим все униформы в шейдере, осуществляющем трассировку лучей    this->rayTracingShader->SetUniformInt("uSamples", raySamples);    this->rayTracingShader->SetUniformVec2("uViewportSize", viewportSize);    this->rayTracingShader->SetUniformVec3("uPosition", cameraPosition);    this->rayTracingShader->SetUniformVec3("uDirection", cameraDirection);    this->rayTracingShader->SetUniformVec3("uUp", cameraUpVector);    this->rayTracingShader->SetUniformFloat("uFOV", Radians(cameraFOV));    // меняем тип блендинга в зависимости от того, аккамулируем ли мы кадры в одну текстуру    // также считаем количество кадров, чтобы потом получить среднее значение    if (accumulateImage)    {        Rendering::GetController().GetRenderEngine().UseBlending(BlendFactor::ONE, BlendFactor::ONE);        Rendering::GetController().RenderToTextureNoClear(this->accumulationTexture, this->rayTracingShader);        accumulationFrames++;    }    else    {        Rendering::GetController().GetRenderEngine().UseBlending(BlendFactor::ONE, BlendFactor::ZERO);        Rendering::GetController().RenderToTexture(this->accumulationTexture, this->rayTracingShader);        accumulationFrames = 1;    }    // рассчитаем среднее от множества кадров и сохраним в рендер-текстуру камеры    this->accumulationTexture->Bind(0);    this->postProcessShader->SetUniformInt("uImage", this->accumulationTexture->GetBoundId());    this->postProcessShader->SetUniformInt("uImageSamples", this->accumulationFrames);    Rendering::GetController().RenderToTexture(output, this->postProcessShader);    // обновим сохраненные параметры камеры    this->oldCameraDirection = cameraDirection;    this->oldCameraPosition = cameraPosition;    this->oldFOV = cameraFOV;}

В заключение

Ну что же, на этом все! Всем спасибо за прочтение этой небольшой статьи. Хоть мы и не успели рассмотреть множество интересных эффектов в path-tracing'е, опустили обсуждение различных ускоряющих трассировку структур данных, не сделали денойзинг, но все равно итоговый результат получился весьма сносным. Надеюсь вам понравилось и вы нашли для себя что-то новое. Я же по традиции приведу в конце несколько скриншотов, полученных с помощью path-tracer'а:

эффект бесконечного туннеля от двух параллельных зеркалэффект бесконечного туннеля от двух параллельных зеркалпреломление света при взгляде сквозь прозрачную сферупреломление света при взгляде сквозь прозрачную сферунепрямое освещение и мягкие тенинепрямое освещение и мягкие тени

Ссылки на связанные ресурсы

Подробнее..

Перевод Трассировщик лучей с нуля за 100 строчек Python

10.03.2021 14:15:34 | Автор: admin
Рисунок 1Рисунок 1

В этом посте мы заглянем под капот алгоритмов компьютерной графики, пошагово разберем основные принципы трассировки лучей и напишем ее простую реализацию на Python. Никаких сторонних графических библиотек только NumPy и голый код в компиляторе.

Примечание: Эта статья ни в коем случае не является полным руководством/объяснением трассировки лучей, поскольку эта тема слишком обширна, а скорее просто введением для любопытствующих.

Предпосылки

Нам понадобится только самая базовая векторная геометрия:

  • Пусть у нас есть две точки A и B независимо от размерности: 1, 2, 3,, n, тогда вектор, идущий от A к B, может быть найден путем вычисления B A (поэлементно);

  • Длину вектора независимо от его размерности можно найти, вычислив квадратный корень из суммы возведенных в квадрат компонентов. Длина вектора v обозначается ||v||;

  • Единичный вектор это вектор длины 1: ||v|| = 1;

  • Для данного вектора другой вектор, идущий в том же направлении, но имеющий длину 1, может быть найден путем деления каждого компонента первого вектора на его длину это называется нормализацией: u = v/||v||;

  • Точечное произведение для векторов вычисляется как: <v, v> = ||v||.

Алгоритм трассировки лучей

Трассировка лучей это метод рендеринга, который имитирует путь света и пересечения с объектами и позволяет создавать изображения с высокой степенью реализма. Более оптимизированные варианты этого алгоритма используются в видеоиграх.

Чтобы объяснить работу этого алгоритма, сначала нужно настроить сцену:

  • Трехмерное пространство (мы собираемся использовать три координаты для позиционирования объектов в пространстве);

  • Объекты в этом пространстве (поскольку мы собираемся воспроизвести рис. 1, то возьмем в качестве объектов сферы);

  • Источник света (в нашем случае это одна точка, излучающая свет во всех направлениях);

  • Камера для наблюдения за сценой;

  • Экран, через который камера смотрит на объекты (четыре точки для четырех углов прямоугольного экрана).

Рисунок 2Рисунок 2

Экран это некая определенная вами геометрическая фигура (например, прямоугольник 3x2). Но сами по себе цифры 3 и 2 ни о чем нам не говорят и действительно начинают приобретать какое-то значение только при сравнении их с размерами других объектов. Здесь важно то, как вы разделите ваш прямоугольник на более мелкие квадраты (пиксели), как на рисунке выше. Это определит размер конечного изображения. Другими словами, можно взять прямоугольник 3x2 и разделить его на 300x200 пикселей.

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

для каждого пикселя p(x, y, z) экрана:

ассоциировать черный цвет с p

если луч (линия), начинающийся от камеры и идущий к точке p, пересекает любой объект сцены, то:

вычислить точку пересечения с ближайшим объектом

если между точкой пересечения и источником света нет объекта, тогда:

рассчитать цвет точки пересечения

сопоставить цвет точки пересечения с p

Рисунок 3Рисунок 3

Обратите внимание, что этот процесс на деле оказывается обратным реальному освещению. Ведь реальный свет выходит из источника во всех направлениях, отражается от объектов и попадает в камеру. Однако, поскольку не все лучи, выходящие из источника света, попадут в камеру, трассировка лучей выполняет обратный процесс для экономии времени вычислений (отслеживает лучи от камеры обратно к источнику света).

Настройка сцены

Перед тем, как начать писать код, нам нужно настроить сцену. В первую очередь определимся, где расположены камера и экран. Для этой цели примем некоторые упрощения, совместив объекты с координатными осями.

Рисунок 4Рисунок 4

Допустим, камера расположена в точке (x = 0, y = 0, z = 1), а экран является частью плоскости, образованной осями x и y. Теперь мы можем написать скелет нашего кода.

Посмотреть код
import numpy as npimport matplotlib.pyplot as pltwidth = 300height = 200camera = np.array([0, 0, 1])ratio = float(width) / heightscreen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизуimage = np.zeros((height, width, 3))for i, y in enumerate(np.linspace(screen[1], screen[3], height)):  for j, x in enumerate(np.linspace(screen[0], screen[2], width)):    # image[i, j] = ...    print("progress: %d/%d" % (i + 1, height))plt.imsave('image.png', image)
  • Камера это просто точка, имеющая три координаты;

  • С другой стороны, экран определяется четырьмя числами (или двумя точками): слева, сверху, справа, снизу. Он находится в диапазоне от -1 до 1 в направлении x и от -1/ratio до 1/ratio в направлении y, где ratio ширина изображения, деленная на его высоту. Это вытекает из того, что мы хотим, чтобы экран имел такое же соотношение сторон, что и фактическое изображение. При такой настройке экрана будет получено соотношение сторон (ширина к высоте): 2 /(2/ratio) = ratio, которое и является соотношением сторон желаемого изображения 300x200;

  • Цикл состоит из разделения экрана на точки в направлениях x и y, затем вычисляется цвет текущего пикселя;

  • Полученный код создаст как и ожидалось на данном этапе черное изображение.

Пересечение лучей

Следующий шаг алгоритма: если луч (линия), начинающийся от камеры и проходящий к точке p, пересекает объект сцены, тогда...

Разобьем его на две части. И начнем с определения того, какой луч (линия) начинается от камеры и идет к точке p?

Определение луча

Когда мы говорим луч, на самом деле мы имеем в виду скорее линию. Всякий раз при работе с геометрией лучше отдать предпочтение векторам, чем реальным линейным уравнениям: с ними легче работать, и они гораздо менее подвержены ошибкам, таким как деление на ноль.

Итак, поскольку луч начинается от камеры и идет в направлении текущего целевого пикселя, мы можем определить единичный вектор, указывающий в том же направлении. Поэтому мы определяем наш луч следующим уравнением:

Помните, что камера и пиксель это 3D-точки. При t = 0 вы окажетесь в положении камеры, но с увеличением t будете удаляться от нее в направлении пикселя. Это параметрическое уравнение, которое даст точку вдоль линии для любого t.

Конечно, точно так же мы можем переписать уравнение и определить луч, который начинается в исходной точке (O) и идет к месту назначения (D) как:

Для удобства определим d как вектор направления.

Теперь мы можем добавить к нашему коду вычисление луча:

Посмотреть код
import numpy as npimport matplotlib.pyplot as pltdef normalize(vector):    return vector / np.linalg.norm(vector)width = 300height = 200camera = np.array([0, 0, 1])ratio = float(width) / heightscreen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизуimage = np.zeros((height, width, 3))for i, y in enumerate(np.linspace(screen[1], screen[3], height)):    for j, x in enumerate(np.linspace(screen[0], screen[2], width)):        pixel = np.array([x, y, 0])        origin = camera        direction = normalize(pixel - origin)        # image[i, j] = ...    print("progress: %d/%d" % (i + 1, height))plt.imsave('image.png', image)
  • Мы добавили в код функцию normalize(vector), которая возвращает... собственно, нормализованный вектор;

  • Также мы добавили вычисление исходной точки и направления, которые вместе определяют луч. Обратите внимание, что пиксель имеет координату z = 0, поскольку он лежит на экране, который находится в плоскости, образованной осями x и y;

Теперь перейдем ко второй части, где луч пересекает объект сцены. Для простоты будем использовать только сферы.

Определение сферы

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

Следовательно, с учетом центра C сферы и ее радиуса r произвольная точка X лежит на сфере тогда, когда:

Для удобства возведем обе стороны в квадрат, чтобы избавиться от квадратного корня, обусловленного величиной X C:

После этого мы сможем определить некоторые сферы сразу после объявления экрана:

objects = [   { 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },   { 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },   { 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }]

Теперь вычислим пересечение луча и сферы.

Пересечение со сферой

Мы знаем уравнение лучей и знаем, какому условию должна удовлетворять точка, чтобы она лежала на сфере. Все, что нам нужно сделать, это подставить одно уравнение в другое и решить его относительно t. То есть, найти ответ на вопрос: для какого t точка луча ray(t) окажется на сфере?

Это обычное квадратное уравнение, которое просто решается относительно t. Мы будем вызывать коэффициенты, связанные с t, t, t, a, b и c, соответственно. Вычислим дискриминант этого уравнения:

Поскольку направление d является единичным вектором, получим a = 1. После вычисления дискриминанта у нас есть три варианта:

Рисунок 5Рисунок 5

Для обнаружения пересечений будем использовать только третий случай. Запишем функцию, отвечающую за обнаружение пересечения луча и сферы. Она возвращает расстояние от начала луча до ближайшей точки пересечения, если луч действительно пересекает сферу, иначе возвращает None:

Посмотреть код
def sphere_intersect(center, radius, ray_origin, ray_direction):   b = 2 * np.dot(ray_direction, ray_origin  center)   c = np.linalg.norm(ray_origin  center) ** 2  radius ** 2   delta = b ** 2  4 * c   if delta > 0:       t1 = (-b + np.sqrt(delta)) / 2       t2 = (-b  np.sqrt(delta)) / 2       if t1 > 0 and t2 > 0:           return min(t1, t2)   return None

Обратите внимание, что мы возвращаем только ближайшее пересечение из двух тогда, когда оба t1 и t2 положительны. Это связано с тем, что ответ уравнения может быть отрицательным, и в таком случае луч, пересекающий сферу, будет иметь не d в качестве вектора направления, а -d (например, если сфера находится за камерой и экраном).

Ближайший пересекаемый объект

Пока мы все еще не выполнили инструкцию из псевдокода: если луч (линия), начинающийся от камеры и идущий к точке p, пересекает любой объект сцены, то [...]. Теперь нам нужно вычислить точку пересечения с ближайшим объектом.

Напишем функцию, которая использует sphere_intersect() для поиска ближайшего объекта, с которым пересекается луч, если он существует. Просто перебираем все сферы, ищем пересечения и сохраняем ближайшую сферу:

Посмотреть код
def nearest_intersected_object(objects, ray_origin, ray_direction):   distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]   nearest_object = None   min_distance = np.inf   for index, distance in enumerate(distances):       if distance and distance < min_distance:           min_distance = distance           nearest_object = objects[index]   return nearest_object, min_distance

При вызове функции, если nearest_object = None, луч не пересекает никакого объекта, иначе его значением является ближайший пересекаемый объект, и мы получаем min_distance расстояние от начала луча до точки пересечения.

Точка пересечения

Чтобы вычислить точку пересечения, используем предыдущую функцию:

nearest_object, distance = nearest_intersected_object(objects, o, d)if nearest_object:intersection_point = o + d * distance

В результате получаем следующий код:

Посмотреть код
import numpy as npimport matplotlib.pyplot as pltdef normalize(vector):   return vector / np.linalg.norm(vector)def sphere_intersect(center, radius, ray_origin, ray_direction):   b = 2 * np.dot(ray_direction, ray_origin  center)   c = np.linalg.norm(ray_origin  center) ** 2  radius ** 2   delta = b ** 2  4 * c   if delta > 0:       t1 = (-b + np.sqrt(delta)) / 2       t2 = (-b  np.sqrt(delta)) / 2       if t1 > 0 and t2 > 0:           return min(t1, t2)   return Nonedef nearest_intersected_object(objects, ray_origin, ray_direction):   distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]   nearest_object = None   min_distance = np.inf   for index, distance in enumerate(distances):       if distance and distance < min_distance:           min_distance = distance           nearest_object = objects[index]   return nearest_object, min_distancewidth = 300height = 200camera = np.array([0, 0, 1])ratio = float(width) / heightscreen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизуobjects = [   { 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },   { 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },   { 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }]image = np.zeros((height, width, 3))for i, y in enumerate(np.linspace(screen[1], screen[3], height)):   for j, x in enumerate(np.linspace(screen[0], screen[2], width)):       pixel = np.array([x, y, 0])       origin = camera       direction = normalize(pixel  origin)       # проверка пересечений       nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)       if nearest_object is None:           continue       # вычисления пересечений между лучом и ближайшим объектом       intersection = origin + min_distance * direction       # image[i, j] = ...       print("%d/%d" % (i + 1, height))plt.imsave('image.png', image)

Пересечения света

Итак, мы нашли прямую линию, идущую от камеры к объекту, и знаем, что это за объект, а также на какую часть объекта мы смотрим. Но мы пока не знаем, освещена ли вообще эта конкретная точка. Возможно, свет не попадает конкретно на нее, и нет необходимости производить расчеты дальше, потому что мы ее не видим. Следовательно, следующий шаг проверить, нет ли никаких посторонних объектов между точкой пересечения и источником света.

У нас уже есть функция, которая нам может помочь: near_intersected_object(). И мы хотим знать, пересекает ли луч, который начинается в точке пересечения и идет к свету, объект сцены перед тем, как пересечь свет. Это практически та же задача, что мы решали раньше: нам просто нужно изменить начало и направление луча. Во-первых, нам нужно определить свет. Можно сделать это сразу вместе с объявлением объектов:

light = { 'position': np.array([5, 5, 5]) }

Чтобы проверить, затеняет ли объект точку пересечения, мы должны пропустить луч, который начинается в точке пересечения и идет к свету, и посмотреть, действительно ли ближайший возвращенный объект оказывается ближе, чем свет, к точке пересечения (другими словами, находится ли он между ними).

# ...intersection = origin + min_distance * directionintersection_to_light = normalize(light['position']  intersection)_, min_distance = nearest_intersected_object(objects, intersection, intersection_to_light)intersection_to_light_distance = np.linalg.norm(light['position']  intersection)is_shadowed = min_distance < intersection_to_light_distance

Что ж, это так не сработает. Нужно сделать небольшую корректировку. Если мы используем точку пересечения в качестве источника нового луча, мы можем в конечном итоге обнаружить сферу, на которой мы сейчас находимся, как объект между точкой пересечения и источником света. Быстрое и широко используемое решение этой проблемы сделать небольшой шаг, который уводит нас от поверхности сферы. Обычно для этого вычисляется вектор нормали к поверхности и производится отступ в направлении этой нормали.

Рисунок 6Рисунок 6

Этот трюк используется не только для сфер, но и для любых объектов.

Следовательно, правильный код будет таким:

Посмотреть код
# ...intersection = origin + min_distance * directionnormal_to_surface = normalize(intersection  nearest_object['center'])shifted_point = intersection + 1e-5 * normal_to_surfaceintersection_to_light = normalize(light['position']  shifted_point)_, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light)intersection_to_light_distance = np.linalg.norm(light['position']  intersection)is_shadowed = min_distance < intersection_to_light_distanceif is_shadowed:   continue

Модель отражения Блинна-Фонга

Итак, мы знаем, что луч света попал на объект, а отражение луча прямо в камеру. Вопрос: что увидит камера? На него и пытается ответить модель Блинна-Фонга.

Модель Блинна-Фонга это приближение к модели Фонга, требующее меньших вычислительных затрат.

Согласно этой модели, любой материал имеет четыре свойства:

  • Фоновый цвет (Ambient color): цвет, который имеет объект в отсутствие света;

  • Рассеянный цвет (Diffuse color): цвет, наиболее близкий к тому, что мы себе представляем;

  • Зеркальный цвет (Specular color): цвет блестящей части объекта, когда свет попадает на нее. В большинстве случаев это белый цвет;

  • Блеск (Shininess): коэффициент, показывающий, насколько блестящим является объект.

Примечание: Все цвета представлены в RGB в диапазоне 0 1.

Рисунок 7Рисунок 7

Добавим эти свойства к сферам:

objects = [   { 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },   { 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },   { 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }]

В нашем примере сферы имеют цвета красный, пурпурный и зеленый, соответственно.

Модель Блинн-Фонга утверждает, что свет также имеет три цветовых свойства: фоновый цвет, рассеянный и зеркальный. Их тоже добавим к модели:

light = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }

Учитывая эти свойства, модель Блинна-Фонга рассчитывает освещенность точки следующим образом:

где

  • ka, kd, ks фоновое, рассеянное, зеркальное свойства объекта;

  • ia, id, is фоновое, рассеянное, зеркальное свойства света;

  • L единичный вектор направления от точки пересечения к свету;

  • N единичный вектор нормали к поверхности объекта в точке пересечения;

  • V единичный вектор направления от точки пересечения к камере;

  • блеск объекта.

Посмотреть код
# ...if is_shadowed:   break# RGBillumination = np.zeros((3))# ambiantillumination += nearest_object['ambient'] * light['ambient']# diffuseillumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)# specularintersection_to_camera = normalize(camera  intersection)H = normalize(intersection_to_light + intersection_to_camera)illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)image[i, j] = np.clip(illumination, 0, 1)

Обратите внимание, что в конце мы определяем цвет между 0 и 1, чтобы убедиться, что он находится в правильном диапазоне.

Запускаем код

Увеличим ширину и высоту для получения более высокого разрешения (ценой увеличения времени вычислений).

Рисунок 8Рисунок 8

Можно заметить две вещи, которые отличают результат от первого изображения, показанного в начале:

  • Серый пол отсутствует;

  • Отсутствие отражений.

Фейковая плоскость

В идеале мы должны создать другой тип объекта плоскость, но поскольку мы достаточно ленивы, то можем просто добавить другую сферу. Ведь если вы стоите на сфере, имеющей бесконечно большой радиус (по сравнению с вашим размером), тогда вам будет казаться, что вы стоите на плоской поверхности.

Добавим эту сферу в список объектов и снова выполним рендеринг:

{ 'center': np.array([0, -9000, 0]), 'radius': 9000  0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }

Отражение

Сейчас мы рендерим лучи, которые выходят из источника света, ударяются о поверхность объекта и отражаются в камеру. Но что, если луч попадет в несколько объектов, прежде чем попасть в камеру? Появится отражение.

Каждый объект имеет коэффициент отражения в диапазоне от 0 до 1. Здесь 0 означает, что объект матовый, 1 что объект зеркальный. Добавим свойство отражения ко всем сферам:

{ 'center': np.array([-0.2, 0, -1]), ..., 'reflection': 0.5 }{ 'center': np.array([0.1, -0.3, 0]), ..., 'reflection': 0.5 }{ 'center': np.array([-0.3, 0, 0]), ..., 'reflection': 0.5 }{ 'center': np.array([0, -9000, 0]), ..., 'reflection': 0.5 }

Алгоритм

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

Рисунок 9Рисунок 9

Расчет цвета

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

где

  • c конечный цвет пикселя;

  • i освещенность, рассчитанная по модели Блинна-Фонга для точки пересечения;

  • r отражение от пересекаемого объекта.

Когда прекратить вычисление этой суммы (и отслеживание отраженных лучей, соответственно), решаете вы сами.

Отраженный луч

Прежде чем мы сможем все это записать в виде кода, нам нужно определить направление отраженного луча. Можно вычислить его следующим образом:

где

  • R нормализованный отраженный луч;

  • L единичный вектор направления отражаемого луча;

  • N единичный вектор направления нормали к поверхности хода луча.

Рисунок 10Рисунок 10

Добавим этот метод в начало кода вместе с функцией normalize():

def reflected(vector, axis):   return vector  2 * np.dot(vector, axis) * axis

Код

Посмотреть код
# глобальные переменныеmax_depth = 3# нужные данные для циклаcolor = np.zeros((3))reflection = 1for k in range(max_depth):   nearest_object, min_distance = # ...   # ...   illumination += # ...   # отражение   color += reflection * illumination   reflection *= nearest_object['reflection']   # начальная точка и направление нового луча   origin = shifted_point   direction = reflected(direction, normal_to_surface)image[i, j] = np.clip(color, 0, 1)

Важно: теперь, когда мы поместили пересечения в другой цикл для отражений, нужно добавить операторы break во избежание бесполезных вычислений.

Вот и все. Запускаем код и наблюдаем результат:

Рисунок 11Рисунок 11

Окончательный код

Итоговый код состоит из всего порядка сотни строк:

Посмотреть код
import numpy as npimport matplotlib.pyplot as pltdef normalize(vector):    return vector / np.linalg.norm(vector)def reflected(vector, axis):    return vector - 2 * np.dot(vector, axis) * axisdef sphere_intersect(center, radius, ray_origin, ray_direction):    b = 2 * np.dot(ray_direction, ray_origin - center)    c = np.linalg.norm(ray_origin - center) ** 2 - radius ** 2    delta = b ** 2 - 4 * c    if delta > 0:        t1 = (-b + np.sqrt(delta)) / 2        t2 = (-b - np.sqrt(delta)) / 2        if t1 > 0 and t2 > 0:            return min(t1, t2)    return Nonedef nearest_intersected_object(objects, ray_origin, ray_direction):    distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]    nearest_object = None    min_distance = np.inf    for index, distance in enumerate(distances):        if distance and distance < min_distance:            min_distance = distance            nearest_object = objects[index]    return nearest_object, min_distancewidth = 300height = 200max_depth = 3camera = np.array([0, 0, 1])ratio = float(width) / heightscreen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottomlight = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }objects = [    { 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },    { 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },    { 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },    { 'center': np.array([0, -9000, 0]), 'radius': 9000 - 0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 }]image = np.zeros((height, width, 3))for i, y in enumerate(np.linspace(screen[1], screen[3], height)):    for j, x in enumerate(np.linspace(screen[0], screen[2], width)):        # экран в начальной точке        pixel = np.array([x, y, 0])        origin = camera        direction = normalize(pixel - origin)        color = np.zeros((3))        reflection = 1        for k in range(max_depth):            # проверка пересечений            nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)            if nearest_object is None:                break            intersection = origin + min_distance * direction            normal_to_surface = normalize(intersection - nearest_object['center'])            shifted_point = intersection + 1e-5 * normal_to_surface            intersection_to_light = normalize(light['position'] - shifted_point)            _, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light)            intersection_to_light_distance = np.linalg.norm(light['position'] - intersection)            is_shadowed = min_distance < intersection_to_light_distance            if is_shadowed:                break            illumination = np.zeros((3))            # ambiant            illumination += nearest_object['ambient'] * light['ambient']            # diffuse            illumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)            # specular            intersection_to_camera = normalize(camera - intersection)            H = normalize(intersection_to_light + intersection_to_camera)            illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)            # reflection            color += reflection * illumination            reflection *= nearest_object['reflection']            origin = shifted_point            direction = reflected(direction, normal_to_surface)        image[i, j] = np.clip(color, 0, 1)    print("%d/%d" % (i + 1, height))plt.imsave('image.png', image)

Что дальше ?

Это очень упрощенная программа, предназначенная для ознакомления с основами трассировки лучей. Есть много способов улучшить ее и реализовать другие функции. Например:

  • Можно создать классы и выяснить, что является специфическим для сфер, а что нет, определить базовый класс и реализовать другие объекты, такие как плоскости или треугольники;

  • То же самое и со светом. Добавить сюда POO и сделать так, чтобы можно было добавить несколько источников света в сцену;

  • Отделить свойства материала от геометрических свойств, чтобы иметь возможность применять один материал (например, синий матовый) к любому типу объектов;

  • Найти способ правильно расположить экран при любом положении и направлении камеры;

  • Смоделировать свет по-другому. В настоящее время это одна точка, поэтому тени от объектов жесткие или четко очерченные. Чтобы получить мягкие тени , нужно смоделировать источник света как 2D- или 3D-объект: диск или сферу.

Бонус

Ниже приведена анимация трассировки лучей. По сути это просто несколько раз отрендеренная сцена с камерой в разных положениях:

Код написан на Kotlin (можно оценить, насколько медленный по сравнению с ним Python) и доступен на GitHub.

Подробнее..

Перевод Проблемы рейтрейсинга в играх нового поколения анализ трассировки лучей в ремастере Marvels Spider-Man

14.10.2020 12:12:47 | Автор: admin


По мере приближения запуска нового поколения консолей Insomniac Games начала публиковать больше материалов, раскрывающих подробности о Marvels Spider-Man и Marvel's Spider-Man: Miles Morales. Оба проекта задействуют технологию трассировки лучей. На взгляд Алекса Баттальи из Digital Foundry, выглядит она очень достойно, если не сказать, что превосходно. Для старта весьма недурно.

Тем не менее, читая комментарии в Интернете, он столкнулся с немалым количеством критики по отношению к реализации технологии, а также к частоте и разрешению кадров. Поэтому в новое видео Digital Foundry он решил представить своего рода пособие по трассировке лучей на консолях следующего поколения и объяснить, почему в Marvel's Spider-Man она выглядит именно так.

Итак, обо всем по порядку. Давайте обсудим, для чего именно нужна трассировка лучей в Marvels Spider-Man на PS5.

При частоте кадров 30FPS с разрешением 4К трассировка лучей выглядит замечательно. При этом, как заведено в играх Insomniac, динамическое разрешение может использоваться, а может и нет. Здесь трассировка лучей применяется для отражений в игре, подобных тем, которые видны на дверях машин.



Или на этом скриншоте, где герой и город вдалеке отражаются в окне здания.



Согласно пресс-релизу, трассировка лучей также используется для фонового затенения (Ambient Occlusion, AO). Его труднее определить на скриншотах или в видео. Как правило, AO имитирует тени от непрямого освещения в тех областях, куда свету, отражающемуся в окрестностях сцены, труднее добраться.



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



Отражения показываются в разрешении 1080p, тогда как остальная часть изображения в 4K. Комментаторы жалуются, например, на то, что листья в отражении более редкие либо вовсе отсутствуют по сравнению с теми, которые можно увидеть на реальном дереве. Или что некоторые внутриигровые объекты, такие как автомобили или пешеходы, могут не показываться в отражениях. Или на отсутствие теней. Или на пренебрежение мелкими деталями, например, на костюме Человека-паука.



На самом деле, все это неудивительно и относится к вынужденным мерам, казалось бы, воспринимающимся как само собой разумеющееся. Это связано с тем, что трассировка лучей производится в несколько шагов, от каждого из которых зависит общая производительность, имеющая очень ограниченный бюджет.

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



Разделим процесс работы с трассировкой лучей на четыре шага. Допустим, нам нужно уложиться в 8 мс для создания отражений для игры на 30 FPS при разрешении 4K.

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

На втором этапе мы запускаем лучи в структуру, созданную на первом.



Чтобы увидеть, попадает ли луч куда-либо, окружим все объекты невидимыми хитбоксами. Таким образом, луч, попавший в сцену, движется сквозь нее до тех пор, пока не зарегистрирует попадание в геометрию. Чем больше лучей мы запускаем, чем дальше они проходят или чем больше хитбоксов заденут, тем больше времени потребуется для этого шага. Эта часть пайплайна трассировки лучей может быть аппаратно ускорена на консолях и ПК.

На следующем этапе происходит затенение попадания или выбор цвета пикселя.



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



Третий шаг дает нам нужные цвета и оттенки, но они могут иметь не совсем удачные переходы либо быть шумными. Эта проблема решается на четвертом этапе. Как правило, чем точнее результат третьего шага очищается на четвертом, тем больше времени занимает процесс.



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



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



Как видно на скриншоте ниже, отражение выглядит почти в четверть исходного разрешения. Эта оптимизация выполняется не только для консольных графических процессоров, но и по отношению вообще ко всем ГП, поскольку трассировка лучей невероятно требовательна к ресурсам.



Посмотрим на скриншот из демо The Ghost Runner с включенной трассировкой лучей с разрешением 4K на видеокарте RTX2060 Super. Здесь мы видим некоторые отличия по сравнению с Человеком-пауком. Unreal Engine 4, на котором создана игра, использует метод трассировки лучей чуть более дорогой по времени, но физически более точный, чем в Человеке-пауке.



RTX 2060 Super работает аналогично RTX 2070, имеет такую же пропускную способность шины памяти, как и у PS5, и такой же ее объем 8 ГБ. RTX 2060 Super, скорее всего, станет отличной отправной точкой для сравнения с графическим процессором PlayStation 5.



Когда сцена вместе с отражениями рендерится в 4K, а количество самих отражений на экране столь невелико, как в случае со статичной лужей выше, графический процессор едва укладывается в 30 FPS. Если бы в сцене было больше динамики, частота кадров определенно была бы меньше. Уменьшение осевого разрешения трассировки лучей на 50% и, таким образом, общего разрешения до 1080p увеличивает производительность примерно на 27%, оставляя свободные ресурсы в графическом процессоре для повышения частоты кадров до 30 FPS.



В других сценах можно увидеть более впечатляющие результаты. Как, например, здесь. Отражающая стеклянная поверхность занимает почти весь экран. В этом случае отражения с исходным разрешением 4К приводят к частоте кадров 21 FPS. При этом понижение разрешения отражений до 1080p увеличивает частоту кадров на 58% до 33 FPS при необходимых 30 FPS.

Таким образом, на графическом процессоре с такой же производительностью, что и у PS5, необходимо использовать отражения с трассировкой лучей с более низким разрешением для возможности поддерживать постоянной частоту кадров 30 FPS при общем разрешении 4К. Тогда становится понятным, почему для снижения стоимости трассировки лучей в Человеке-пауке будут использоваться отражения только при 1080p.



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

Давайте попробуем сократить время первого шага, уменьшив динамичность либо количество объектов, по отношению к которым будет выполняться трассировка лучей. Так, на показанных ранее скриншотах некоторые динамические объекты могли отсутствовать в отражениях. Или здесь, в геймплейном видео Miles Morales, в массовых сценах порой можно заметить, что некоторые из NPC не отражаются на поверхностях.



По опыту других разработчиков мы знаем, что прорисовка множества персонажей на экране может оказаться очень ресурсоемкой. Например, отрисовка некоторых сцен в Metro Exodus с большим количеством персонажей на RTX 2080 Ti занимает более 4 мс. В нашем случае это окажется половиной отведенного бюджета непозволительная для наш роскошь.

Итак, уменьшение количества динамических объектов на первом этапе трассировки лучей приводит к ускорению процесса, и тогда мы вписываемся в отведенные 8 мс.



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

Например, взглянем на эти скриншоты Battlefield V.




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



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



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



Вернемся снова к нашему расчету. Решив учитывать в отражениях дальние объекты, мы снова оказались за рамками своего бюджета. Поэтому еще раз пересмотрим пайплайн на предмет возможных упрощений. Пожалуй, второй шаг вернет нас к желаемым 8 мс.

Снова посмотрим на наш скриншот с городом. В отражении на окне можно увидеть более редкие или вовсе отсутствующие листья по сравнению с тем, как они выглядят на изначальной модели дерева. Листья здесь классифицируются как прозрачные, а для прозрачных объектов второй шаг пайплайна занимает больше времени. Мы знаем это из опыта с Battlefield V, где частицы и отражения делают некоторые сцены чрезвычайно дорогими.

image

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



Теперь мы почти вписываемся в бюджет кадра, но все еще немного его превышаем.



Последняя оптимизация может быть сделана на третьем шаге, где мы используем затенение.

Вернемся снова к нашему скриншоту. Заметили ли вы отсутствие мелких деталей в отражении костюма Человека-паука? Которые видны на его настоящем костюме? Если мы упростим материал в отражении, потребуется меньше времени на его прорисовку.



В этом нет ничего такого, ведь многие игры, использующие трассировку лучей, такие как Wolfenstein: Youngblood, прибегают к тому же приему.



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



Надеемся, на этом примере нам удалось донести представление о том, с какими дилеммами сталкиваются разработчики игр, когда им приходится иметь дело с ограниченным бюджетом производительности и такими дорогостоящими технологиями, как трассировка лучей.
Подробнее..

Перевод Почему центр пикселя должен быть в (0,5 0,5)

15.06.2020 16:10:07 | Автор: admin
Сегодня, когда всё популярнее становится трассировка лучей (ray tracing) выполняемая из глаза камеры, этот урок нужно усвоить заново: код становится лучше, а жизнь проще, если центр пикселя находится в координате (0,5; 0.5). Если вы уверены, что делаете всё правильно, то продолжайте в том же духе, для вас в статье нет ничего нового. Прочитайте лучше вот это.

Смысл размещения центра пикселя в (0,5; 0,5) впервые объяснила (по крайней мере, мне) милая короткая статья Пола Хекберта Что такое координаты пикселя? из книги 1990 года Graphics Gems, стр. 246-248.

Сегодня эту статью найти трудновато, поэтому вкратце изложу её суть. Допустим, у нас есть экран с шириной и высотой 1000. Давайте рассмотрим только ось X. Может возникнуть искушение назначить 0,0 центром самого левого пикселя в строке, 1,0 центром следующего, и так далее. Можно даже использовать округление, при котором координаты с плавающей запятой 73,6 и 74,4 переносятся в центр 74,0.

Однако над этим стоит поразмыслить. При таком сопоставлении левый край будет находиться в координате -0,5, а правый в 999,5. С такой системой неудобно работать. Хуже того, если к значениям координат пикселей применяются различные операторы наподобие abs() или mod(), то такое сопоставление может привести к незначительным погрешностям на краях.

Проще работать с интервалом от 0,0 до 1000,0, в котором центр каждого пикселя имеет дробную часть 0,5. Например, тогда целочисленный пиксель 43 будет иметь красивый интервал значений значений входящих в него субпикселей от 43,0 до 43,99999. Вот чертёж из статьи Пола:


В OpenGL центр пикселя всегда имел дробную часть (0,5; 0,5). Поначалу DirectX этого не придерживался, но в версии DirectX 10 взялся за ум.

Операции для преобразования из целочисленных координат в координаты пикселя с плавающей запятой заключаются в прибавлении 0,5; для преобразования float в integer достаточно использовать floor().

Но это уже давняя история. Ведь сегодня все делают так, правда? Я вернулся к этой теме, потому что начал встречать в примерах (псевдо)кода генерации направления перспективной камеры для трассировки лучей такое:

 float3 ray_origin = camera->eye; float2 d = 2.0 *      ( float2(idx.x, idx.y) /        float2(width, height) ) - 1.0; float3 ray_direction =     d.x*camera->U + d.y*camera->V + camera->W;

Вектор idx это целочисленное местоположение пикселя, width и height разрешение экрана. Вектор d вычисляется и используется для генерации вектора в мировом пространстве при помощи перемножения двух векторов, U и V. Затем прибавляется вектор W направление камеры в мировом пространстве. U и V обозначают положительные направления осей X и Y плоскости отображения на расстоянии W от глаза. В представленном выше коде всё это выглядит красиво и симметрично; так оно по большей части и есть.


Вектор d должен обозначать пару значений от -1,0 до 1,0 в нормализованных координатах устройства (Normalized Device Coordinates, NDC) для точек на экране. Однако, здесь код даёт сбой. Продолжим наш пример: целочисленное местоположение пикселя (0; 0) переносится в (-1,0; -1,0). Кажется, это хорошо, правда? Но максимальное целочисленное местоположение пикселя равно (999; 999), что преобразуется в (0,998; 0,998). Суммарная разница 0,002 вызвана тем, что это неверное наложение сдвигает всю картинку на полпикселя. Эти центры пикселей должны находиться в 0,001 от каждого из краёв.

Вторая строка кода должна выглядеть так:

    float2 d = 2.0 *        ( ( float2(idx.x, idx.y) +                 float2( rand(seed), rand(seed) ) ) /            float2(width, height) ) - 1.0;

Тогда мы получим правильный интервал NDC для центров пикселей, от -0,999 до 0,999. Если мы вместо этого преобразуем угловые значения с плавающей запятой (0,0; 0,0) и (1000,0; 1000,0) этим способом (мы не прибавляем 0,5, потому что уже и так работаем с плавающей запятой), то получим полный интервал NDC, от -1,0 до 1,0, от края до края; это доказывает правильность кода.

Если 0,5 вас раздражает и вам не хватает симметрии, то для генерации случайных значений внутри пикселя, т.е. когда вы выполняете сглаживание испусканием случайных лучей через каждый пиксель, можно использовать такую изящную формулировку:

    float2 d = 2.0 *        ( ( float2(idx.x, idx.y) +                 float2( rand(seed), rand(seed) ) ) /            float2(width, height) ) - 1.0;

Мы просто прибавляем к каждому целочисленному значению местоположения пикселя случайное число из интервала [0.0,1.0). Средним этого случайного значения будет 0,5, то есть центр пикселя.

Так что скажу кратко: будьте внимательны. Реализуйте полупиксель правильно. По моему опыту, такие ошибки полупикселей всплывают во множестве разных мест (камеры, сэмплирование текстур и т.п.) на протяжении долгих лет моей работы над кодом растеризатора в Autodesk. Далее по конвейеру они не приносят ничего, кроме боли. Если мы не будем внимательны, они могут появиться и в трассировщиках лучей.
Подробнее..

От унитазов к блокбастерам об истории визуальных эффектов

23.05.2021 18:15:00 | Автор: admin

Компьютерная графика и визуальные эффекты не такое новое явление, как о нём принято думать. Спецэффекты в кино возникли практически одновременно с появлением кинематографа, и пока наш разум отчаянно отказывается осознавать, насколько глубока кроличья нора, где-то громко смеётся один Жорж Мельес. Очевидно, что с появлением вычислительной техники расширение арсенала художников было лишь вопросом времени. Тем не менее, компьютерной графике пришлось преодолеть долгий путь, по некоторым этапам которого было бы интересно пройтись.

Прибытие кошечки

Сладкая кукуруза, Карибский кризис, сексуальная революция, Берлинская стена, Второй Ватиканский собор и, конечно же, покорение космоса шестидесятые годы прошлого века оказались богаты на события. На фоне этого буйства остались незамеченными математики с горящими глазами, проводившие где-то в недрах университетских машинных залов первые эксперименты с компьютерной графикой. Одним из них стал Николай Николаевич Константинов. На создание мультфильма его вдохновила работа бывшего студента анимация движения молекулы ДНК из пластилина, и спустя несколько месяцев работы в 1968 году по экрану скромно прошлась Кошечка.

Кошечка не была первым компьютерным мультфильмом, однако она стала первым реалистично анимированным при помощи компьютера персонажем.

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

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

В БЭСМ-4, на которой создавалась Кошечка было несколько модулей памяти общей ёмкостью 16К 45-разрядных слов. Результаты работы программ печатались на рулонной бумаге суровым агрегатом под названием АЦПУ-128, так что ни о какой графике речи не шло. Полагать, что в конце 60-х существовали какие-либо графические пакеты, тоже было бы слишком наивно учёные сами писали нужные им программы. Через 6 лет Константинов и соавторы (Владимир Пономаренко, Виктор Минахин) опубликуют в журнале Проблемы кибернетики статью, из которой можно узнать много интересных подробностей: представление чисел, описание структур данных, вывод уравнений, описывающих движение.

Страница журнала. Здесь можно получше рассмотреть кадры.Страница журнала. Здесь можно получше рассмотреть кадры.

Как и многие другие ранние эксперименты, смелая идея в своё время не нашла отклика, и снова про кошечку вспомнят лишь почти сорок лет спустя стараниями учеников и сторонников Константинова.

Наследие Трона

Мы были в восторге от backlit-анимации. Свет не отражается от рисунка на целлулоиде, а попадает прямо в объектив камеры сквозь маску и светофильтр, и получается очень насыщенный, приятный глазу цвет. [Тогда] никто не делал backlit-персонажей. Эту технику применяли для логотипов, чтобы заставить предметы светиться и пульсировать. Мы же попытались создать такого персонажа Этот тест, он был из текучего неона, он был электронным. И раз он был электронным, мы прозвали его Трон.

Стивен Лисбергер, режиссёр

О Троне, наверное, не говорил только самый ленивый, но не упомянуть его тоже нельзя. Я бы мог долго рассказывать о Стивене Лисбергере, аниматоре, которого завораживали неон и эстетика первых аркадных автоматов, и его идее создать светящегося электронного персонажа. О том, как в дело вмешалась политика его студия чуть не закрылась из-за бойкота Олимпийских игр, и как в попытках остаться наплаву команда вернулась к старой задумке. Можно долго рассказывать о том, насколько новым тогда казался этот наивный сюжет, как в Disney изголодались по свежим идеям, и как они в конце-концов нашли друг друга.

Остановимся на самом главном. Картина Лисбергера не была первым художественным фильмом, в котором для создания визуальных эффектов применялась машинная графика. До Трона как минимум были Мир запада и Мир будущего (1973 и 1976 соответственно), Звёздные войны (1977) и второй полнометражный Star Trek (1982), однако именно в Троне визуальные эффекты сыграли решающую роль.

Если в уже набивших оскомину Звёздных войнах единственная сцена, в которой использовалась машинная графика, длится примерно полторы минуты, то Трон содержит около 15 минут чистой графики, не учитывая сцен, где актёры наложены на нарисованный машиной фон.

Над графикой для разных частей ленты независимо друг от друга трудились 4 фирмы, что в результате отразилось на визуальном стиле картины. Обычно, когда речь заходит о Троне, в первую очередь вспоминают Triple-I и её супер-ЭВМ (совместимый с PDP-10 Foonly F1), а также MAGI/Synthavision. Две эти компании создали большую часть визуальных эффектов, но лично моё внимание привлекла другая Robert Abel and Associates. Работавший над фильмом сотрудник Абеля по имени Билл Ковакс через два года станет одним из основателей Wavefront Technologies, крупного разработчика графического ПО.

Что такое Silicon Graphics

Человек Идея Венчурный фонд Признание Головокружение от успеха Закат. Это если совсем кратко. В 70-х годах Джеймс Кларк, будучи профессором Стэнфордского университета, вместе с Марком Ханной начал работу над Geometry Engine набором микросхем, призванным взять на себя обработку трёхмерной графики. Демонстрация разработки на выставке SIGGRAPH позволила привлечь необходимое финансирование, и в 1982 году рождается Silicon Graphics с приставкой Inc.

Чтобы понять, чем так интересны эти машины, мысленно вернёмся в 1992 год. За 386-й компьютер в конце 92-го просили около $1300 (на момент написания статьи эта сумма примерно соответствует $2300 или 170 000 рублей). Максимум своих возможностей чудо-машина обычно демонстрировала в играх вроде Doom или Седьмого гостя. Последний выпускался аж на двух компакт-дисках извольте потратить ещё долларов 300 на привод, если жаждете прикоснуться к прекрасному.

Кадр из переиздания The 7th Guest. Оригинальные 640x320 просто растянуты под современные мониторы.Кадр из переиздания The 7th Guest. Оригинальные 640x320 просто растянуты под современные мониторы.Alone in the Dark, 1992. Трёхмерные персонажи и элементы окружения на рисованном фоне.Alone in the Dark, 1992. Трёхмерные персонажи и элементы окружения на рисованном фоне.

Apple стагнирует вплоть до возвращения Джобса, менее дорогие Макинтоши конкурируют с Макинтошами подороже, что в будущем выльется в несколько линеек примерно одинаковых машин, но в разных ценовых диапазонах. Одно из самых доступных предложений Macintosh LC II, производительность сопоставима с персоналками.

Macintosh LC II. Motorola 68030, 16 МГц. От $1700 (сейчас это $3000 или 220 000 рублей).Macintosh LC II. Motorola 68030, 16 МГц. От $1700 (сейчас это $3000 или 220 000 рублей).Кроме LC II и более дорогого Quadra 700 Apple предлагала чёрно-белый Macintosh Classic II. Motorola 68030, 16 МГц, $1900 на старте продаж.Кроме LC II и более дорогого Quadra 700 Apple предлагала чёрно-белый Macintosh Classic II. Motorola 68030, 16 МГц, $1900 на старте продаж.

Найти цены SPARCstation от Sun уже сложнее, но готов поспорить, что было дорого как-никак кровавый enterprise. Сейчас нам важен тот факт, что в части этих машин в принципе не было аппаратной поддержки графики. К этому моменту за 10 лет своего развития аппараты SGI прошли путь от графических терминалов до производительных рабочих станций, способных выводить трёхмерную графику в реальном времени, что привлекло киностудии.

Конкретно это демо было записано в 1996 году, но оно очень хорошо передаёт суть, да и компьютеры Onyx были выпущены в 93-м. После выхода таких картин как Бездна (1989) или Терминатор 2 (1991) слова Silicon Graphics и визуальные эффекты фактически стали синонимами. Космические возможности, однако, стоили космических денег. Компьютер Indigo 2 в минимальной комплектации (MIPS R4000, 100 МГц) обошёлся бы в $25 000 ($45 000 или 3 330 000 рублей сейчас), и это далеко не предел. При этом крупные клиенты вроде Industrial Light & Magic нуждались во всё более производительных решениях деньги лились рекой. Так, между делом, в том же 1992 году SGI приобретает себе MIPS.

Голубой и пурпурный Indigo 2. Под монитором стоит менее дорогой Indy, а позади на полу O2Голубой и пурпурный Indigo 2. Под монитором стоит менее дорогой Indy, а позади на полу O2

И тут как гром среди ясного неба разносится известие об уходе самого Кларка. Совет директоров не был согласен с его идеями привнести разработки компании на рынок потребительской электроники. С руководством кроме Кларка конфликтовали и другие инженеры, что вылилось в основание такой никому не известной конторы как 3dfx или уход части сотрудников в ещё более неизвестную ATI.

По иронии судьбы изначально игрушечные персоналки развивались семимильными шагами, не без помощи вышеупомянутых контор (а там ещё nVidia подтянется), и признанный лидер недооценил это обстоятельство. С 1997 года компания начинает терпеть убытки. Ещё два года назад бывшая на пике своего развития она больше неспособна вести конкурентную борьбу с Intel-совместимыми системами. Масла в огонь подлили разработчики ПО, которые портировали свои графические пакеты на Windows NT и другие платформы, когда дело запахло жареным. В 2006 году несмотря на отчаянные попытки выжить Silicon Graphics объявляет о своём банкротстве и окончательно перестаёт существовать к 2009 году остатки компании распроданы, а вырученные средства уходят на выплаты кредиторам.

Несмотря на печальный конец, наследие Silicon Graphics продолжает жить. В 1995 году под крышей SGI произошло слияние Alias Research и Wavefront Technologies, из прежних наработок которых родилась Maya среда, фактически ставшая промышленным стандартом (и с которой я совершенно не умею работать). На этой же платформе родился такой графический пакет как Houdini жив, используется как одиночками, так и гигантами вроде Pixar.

Не собирается уходить и такая разработка как OpenGL, до недавнего времени бывшая единственной графической библиотекой, работающей практически везде.Не собирается уходить и такая разработка как OpenGL, до недавнего времени бывшая единственной графической библиотекой, работающей практически везде.

Без упоминания в художественных произведениях тоже не обошлось:

Аппаратный зал, находившийся за дверью с игривой надписью Машинное отделение, не произвёл на Татарского особого впечатления Никакой мебели в комнате не было; на одной стене висела цветная фотография Гагарина с голубем в руках, а у другой стояли металлические стеллажи со множеством однообразных синих ящиков, единственным украшением которых была похожая на снежинку эмблема Silicon Graphics.

Виктор Пелевин, Generation П

Но если Generation П вещь довольно специфичная, то с этим камео вы точно знакомы:

Бесконечность не предел

Для работы над Звёздными войнами Джорджу Лукасу требовалась студия, которая будет заниматься визуальными эффектами, ради чего в 1975 году режиссёр создаёт упомянутую ранее Industrial Light & Magic грядущего титана; наверное будет проще сказать, над чем они не работали.

В процессе развития в студии возникло подразделение, которое занималось компьютерной графикой. Лукас нанял несколько специалистов, среди которых были Элви Рэй Смит и Эдвин Катмулл, возглавившие новообразованную Graphics Group. Стараниями Катмулла в свою очередь будет нанят ведущий аниматор Джон Лассетер. Троица Смит-Катмулл-Лассетер лелеяла амбициозную мечту ни много, ни мало, снять первый полнометражный фильм, целиком нарисованный на компьютере.

Слева направо: Эдвин Катмулл, Элви Рэй Смит, Джон ЛассетерСлева направо: Эдвин Катмулл, Элви Рэй Смит, Джон Лассетер

В 1983 году в связи с финансовыми трудностями Лукас начал в срочном порядке избавляться от активов, в т.ч. и от Graphics Group. Смит, Катмулл и Лассетер, помня о своих планах, попытаются сохранить сформировавшуюся команду и в 1986-м решают самостоятельно выкупить подразделение. Инвестором и по совместительству главным акционером стал Ни за что не поверите Стив Джобс, тогда уже уволенный из Apple. Новая независимая компания стала называться Pixar.

Не SGI единым. Разработанный в ILM компьютер дал имя новообразованной студии.Не SGI единым. Разработанный в ILM компьютер дал имя новообразованной студии.

Катмулл, Лассетер и ко считали Джобса своим, однако вопреки ожиданиям и сложившейся репутации он окажется жёстким авторитарным руководителем выключить солнце, забыть про анимацию, сосредоточиться на железках, которые можно продать! Ради справедливости стоит отметить, что такой подход мог быть оправдан. Лукас хотел начать активно применять графику ещё в Empire Strikes Back (1980), но отказался от этой затеи, т.к. несмотря на огромный потенциал производительность вычислительных машин долго оставляла желать лучшего и получить результат требуемого качества было нереально. Анимационные короткометражки были лишь побочным продуктом, призванным рекламировать решения Pixar. Продажа оборудования не приносила достаточного дохода и компания работала в убыток. Весной 1988 года Джобс поднимает вопрос о значительном сокращении расходов, возможно, вместе с частью сотрудников. Катмуллу и Лассетеру удаётся убедить его выделить средства на ещё один короткометражный мультфильм из собственных активов. Оловянная игрушка, ради которой аниматоры буквально ночевали на рабочих местах, завоевала Оскара, привлекла к себе внимание Disney и, самое главное, сдружила Джобса с Лассетером.

Студия меняет курс и продаёт своё аппаратное подразделение, сконцентрировавшись на анимационном производстве и RenderMan, своём главном программном продукте. В мае 1991-го студия, находясь на грани банкротства, заключает соглашение с Disney о совместном производстве полнометражного мультфильма, сейчас известного нам как История Игрушек.

Джери, Катмулл и Кларк

Исторически сложилось так, что первые трёхмерные модели были полигональными они состояли из множества многоугольников различной конфигурации, которые принято называть полигонами. Так можно получить сложные и хорошо детализированные формы, но такие поверхности никогда не будут гладкими сколько бы полигонов у вас ни было, ваша модель остаётся многогранником. Тогда учёные и инженеры притащили в системы автоматизированного проектирования параметрические сплайновые поверхности (наибольшее распространение получили NURBS). Кроме гладкости они имели кучу других достоинств, например, их можно как угодно резать, между такими поверхностями можно легко создавать гладкие сопряжения, а ещё они кушают меньше ресурсов.

А теперь попробуйте вырезать что-нибудь подобное в полигональной модели.А теперь попробуйте вырезать что-нибудь подобное в полигональной модели.

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

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

Казалось бы, ничего страшного, но это усложняет правки, а при анимации возникает необходимость следить за тем, чтобы швы не разъехались, и оберегать их от перегибов. При работе над Историей игрушек аниматоры приложили немало усилий, чтобы скрыть швы на лице Вуди. В 1976 году уже знакомые нам Катмулл и Кларк нашли изящное решение проблемы. Вернёмся к полигонам. Давайте возьмём какой-нибудь объект и разделим каждую его грань на несколько полигонов поменьше, а затем усредним положение старых вершин. Повторим ещё раз. И ещё! Они доказали, что итерационный процесс сходится, результатом будет гладкая поверхность такая же, как набор гладко сшитых лоскутов, о которых говорилось раньше, только единое целое.

Catmull-Clark subdivision surface. Исходный объект, одна итерация, две итерации, финал.Catmull-Clark subdivision surface. Исходный объект, одна итерация, две итерации, финал.

Первым анимационным фильмом с применением сабдивов стала короткометражка Игра Джери 1997 года быстрые методы их вычисления появились далеко не сразу.

С их помощью были смоделированы голова, руки и одежда старичка-шахматиста.С их помощью были смоделированы голова, руки и одежда старичка-шахматиста.

The Spirits Within

В 1987 году тогда ещё малоизвестная студия Square испытывала серьёзные финансовые трудности. Первые игры японцев не снискали популярности у игроков и находящаяся в разработке Final Fantasy в прямом смысле могла стать final, но вместо этого её ждал успех. Вслед за ней из-под пера Square выйдет немало хитов (от номерных частей Final Fantasy до Chrono Trigger и Parasite Eve), однако японцы не захотели останавливаться на достигнутом.

В июле 2001 года Square Pictures выпускает на экраны без преувеличения прорывной полнометражный мультфильм. Последняя фантазия (в нашем прокате просто, без духов внутри) первой в истории использует фотореалистичные образы людей, для анимации которых применялся захват движений живых актёров.

87 место в Maxim Hot 100 2001. Увы, страница на сайте журнала недоступна, и я очень благодарен тому, кто сохранил это изображение.87 место в Maxim Hot 100 2001. Увы, страница на сайте журнала недоступна, и я очень благодарен тому, кто сохранил это изображение.

Вскоре после премьеры некоторые актёры, например Том Хэнкс, высказали опасения насчёт того, что их и их коллег вскоре заменят компьютерными моделями, а СМИ разразились жёлтыми заголовками (как минимум New York Times). Стоит отметить, что планы создателей действительно шли далеко вперёд: как вам концепция виртуальной актрисы и её взаимодействия с отснятым материалом? Забегая вперёд скажу, что они не сбылись, однако на Аки Росс в реальности можно посмотреть тут (первая минута):

Качественный прорыв обошёлся дорого. Просто приведу слова Троя Брукса, одного из руководителей проекта:

Лично мне кажется, что самой большой проблемой было общение. У нас работали художники более чем из 20 стран и большая часть первоначальной команды пришла из японской Square games. Не все из них говорили по-английски и совсем мало не японцев говорили по-японски у нас был большой штат переводчиков. Кроме того, с нами работали люди из области разработки игр, спецэффектов, анимации, телевидения, рекламы с самым разным опытом. Думаю, что фильм только выиграл от такого разнообразия, но организовать хотя бы повседневное общение было далеко не тривиальной задачей.

В том же интервью раскрываются и некоторые технические подробности: рабочие станции художников и аниматоров SGI Octane, композитинг и монтаж SGI Onyx. Моделирование и анимация производились в Maya, рендеринг Pixar RenderMan (Pentium 3, RedHat 6.2).

Подводя итоги: 4 года производства, 120 человеко-лет, 1200 компьютеров (рендер-ферма, без учёта рабочих станций), 24 года машинного времени и соответствующие всему этому великолепию $137 млн. бюджета. При кассовых сборах чуть более $85 млн. Square Pictures ждало неминуемое банкротство, хотя сама Square переживёт эту неудачу. Коммерческому провалу могли поспособствовать некоторые обстоятельства:

  • Во-первых, название Final Fantasy явно говорило о связи с одноимённой серией игр, сужая аудиторию; масла в огонь подливал тот факт, что автором сценария и режиссёром выступил Хиронобу Сакагути создатель оригинальной игры. При этом сам фильм не связан с какими-либо играми серии, если не считать отсылок вроде учёного по имени Сид или едва заметного силуэта ездовой птицы на пижаме главной героини.

  • За красивой картинкой прятался слабый сценарий с флёром Рен-ТВ. С другой стороны, концепцию живой планеты или что-то очень на неё похожее использовали в художественных произведениях задолго до Духов внутри (привет, Конан Дойл), и будут использовать после (привет, Кэмерон).

Субъективное мнение, которое можно смело пропустить

В отличие от других провалившихся проектов, за Final Fantasy почему-то особенно обидно и из головы не уходит мысль о том, что этот мультфильм сильно недооценили. В 2017 году сбылась мечта идиота я попал на московский CG Event. В своей речи на открытии конференции Сергей Цыпцын сравнил Духов внутри с короткометражным фильмом Adam теперь картинку аналогичного качества можно получать практически в реальном времени. Первый докладчик как раз был из Unity Technologies.

Не мне спорить с известными специалистами, однако соглашусь лишь частично: Adam безусловно хорош, но он про роботов. После Фантазии в принципе было немного анимационных фильмов в реалистичной стилистике, и ни одному из них не удалось превзойти первопроходца. Из полнометражных можно вспомнить три работы Земекиса и Тайну Единорога Спилберга по альбомам Эрже, но её можно сразу отбросить.

Weta Digital как всегда выдаёт конфетку, но сравнение будет некорректным, ибо персонажи стилизованные. От правдоподобных до откровенно гротескных.Weta Digital как всегда выдаёт конфетку, но сравнение будет некорректным, ибо персонажи стилизованные. От правдоподобных до откровенно гротескных.

Если вы всё ещё не забыли про Тома Хэнкса, то через 3 года он примет участие в похожем проекте, а именно Полярном экспрессе Роберта Земекиса, где отыграет и озвучит сразу несколько ролей.

Ирония в том, что три года спустя и при большем бюджете ($165 млн., сборы $306 млн.) картина окажется заметно слабее как минимум в техническом плане, но хотя бы окупит производство.

Я просто оставлю это здесь.Я просто оставлю это здесь.

Трассировка лучей и полный метр

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

Роберт Кук, один из создателей Pixar RenderMan

Упомянутые чуть раньше полигоны, сабдивы и прочие страшные слова, очевидно, не являются самоцелью. Чтобы увидеть описанные с их помощью структуру, объём и форму, их нужно как-то превратить в набор точек на экране. К решению этой задачи существует несколько принципиально разных подходов; наиболее известные растеризация и трассировка лучей.

О растеризации будет вернее сказать, что это множество техник, в основе которых лежит общая идея. Каждая поверхность делится на примитивы (например, каждый полигон можно разделить на несколько треугольников, а сплайновые поверхности аппроксимируются сеткой из полигонов), мы находим их проекцию на экран, а затем перебираем все пиксели изображения и проверяем, лежат ли они внутри какого-либо примитива. Если так, мы закрашиваем пиксель цветом примитива, внутри которого он лежит.

The Reyes Image Rendering Architecture. Cook et al., 1987The Reyes Image Rendering Architecture. Cook et al., 1987

Некоторые рендеры, например Mantra или пиксаровский RenderMan, доминировавшая на рынке система, использовали аналогичную технику, но наоборот. Поверхности дробились на полигоны сопоставимых с пикселями размеров, а цвет пикселя определялся тем, какие полигоны в него попадают. На практике в любом случае процесс обычно состоит из заметно большего числа стадий, в т.ч. программируемых, таких как расчёт освещения.

Альтернативный подход заключается в том, чтобы отследить путь попавших в виртуальную камеру лучей света. Впервые эту технику, сейчас известную нам как обратная трассировка лучей, продемонстрировали в лабораториях Белла в далёком 1979 году. Найти эту короткометражку (Compleat Angler) целиком непросто, однако этот шестисекундный фрагмент даёт неплохое представление:

Кратко процесс можно описать следующим образом: через каждый пиксель изображения проводится луч и находится точка его пересечения с поверхностью. Из неё в свою очередь выпускается т.н. теневой луч в сторону источника света. Если луч не достигает источника, то точка находится в тени, иначе для неё рассчитывается освещение, освещение от всех источников складывается. Если поверхность должна отражать или преломлять свет, то из всё той же точки выпускаются новые лучи и процесс повторяется рекурсивно.

Самая наглядная иллюстрация. Нагло позаимствовано из Вики.Самая наглядная иллюстрация. Нагло позаимствовано из Вики.

Этого недостаточно для получения реалистичных изображений, однако у нас уже есть тени, зеркальные отражения и преломления, которые в противном случае рассчитываются по отдельности. Усовершенствованные варианты техники идут дальше: мягкие тени, глянцевые отражения, глубина резкости и многое другое.

Распределённая трассировка лучей. Реальные поверхности не идеально гладкие, а значит не отражают весь свет только в одном направлении.Распределённая трассировка лучей. Реальные поверхности не идеально гладкие, а значит не отражают весь свет только в одном направлении.

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

Слева: только прямое. Справа: прямое + глобальное.Слева: только прямое. Справа: прямое + глобальное.

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

Несмотря на все свои достоинства, трассировка лучей долгое время считалась пусть и интересной, но практически бесполезной техникой за реализм придётся заплатить вычислительной сложностью. Написать трассировщик несложно, сложно написать эффективный трассировщик, который выдаст результат за адекватное время. Балом правят растеризаторы: мы вынуждены собирать воедино кучу малосвязанных друг с другом техник, и мириться с очень приблизительным результатом, но этот результат будет и он будет быстрым, вплоть до работы в реальном времени. Яркий пример всё те же игры, как современные, так и не очень. Тем не менее, со временем производительность компьютеров росла, вместе с ней росли и требования к качеству. Значительные перемены оставались вопросом времени.

Простите, не удержался.Простите, не удержался.

В 1998 году Маркос Фахардо напишет Arnold, шутки ради названный так в честь бывшего губернатора Калифорнии. Для своего времени замысел был довольно смелым: освещение в каждой точке рассчитывается методом Монте-Карло, эту технику называют трассировкой пути. Для нас это означает максимальное стремление к физической корректности и повышенный аппетит программы к вычислительным ресурсам. Дебютом системы стали несколько короткометражных мультфильмов, после которых в 2004 году на новичка своё внимание обратит Sony Imageworks. Студия включается в процесс разработки и делает Arnold своим основным инструментом. Первым полнометражным анимационным фильмом, полностью отрендеренным с его помощью методом трассировки пути становится Дом-монстр 2006 года, что бы там ни говорили в Pixar. Они в свою очередь не спешат отказываться от старых наработок, но в том же году добавляют в свой RenderMan поддержку трассировки лучей и активно используют новые инструменты. 10 лет спустя Pixar полностью откажется от старой архитектуры в пользу трассировки пути.

На арену выходит болгарский V-Ray, за последние 10 лет проделавший колоссальный путь от дизайна интерьеров и рекламы к высокобюджетным фильма. И похоже, останавливаться он не собирается.

Подробнее..

Перевод SuperRT чип для рейтрейсинга на Super Nintendo

17.12.2020 12:23:08 | Автор: admin
image

В продолжение темы, представляем вашему вниманию перевод оригинала статьи от Бена Картера.

Ссылки на видео по этой статье:


У меня наконец-то появились результаты работы над проектом, которым я занимался в свободное время примерно около года.

Его идея возникла, когда я пытался придумать интересный проект для изучения Verilog и проектирования FPGA. Мне пришла в голову мысль о создании простого трассировщика лучей (частично я вдохновлялся успехами моего до ужаса умного друга, создавшего собственный GPU). Немного позже (наверно, потому, что мой мозг ненавидит меня и наслаждается придумыванием глупых заданий) всё это превратилось в вопрос: а не будет ли интересно заставить SNES выполнять рейтрейсинг?. Так родилась идея чипа SuperRT.

Я хотел попробовать создать нечто, напоминающее чип Super FX, используемый в таких играх, как Star Fox. SNES в них выполняет игровую логику и передаёт описание сцены чипу в картридже, который занимается генерированием графики. Я намеренно ограничил себя использованием в конструкции единого самодельного чипа, а не ядра ARM на плате DE10 или любых других внешних вычислительных ресурсов.

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


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


С показанной здесь Super Nintendo (строго говоря, это Super Famicom) снята крышка, чтобы было место для подключения проводов, но во всём остальном она совершенно не модифицирована. К ней подключена печатная плата с копией ужасной игры Pachinko, которую я приобрёл за 100 йен в местном секонд-хенде. ROM игры извлечён и заменён на кабельную муфту. Далее она проходит через набор схем сдвига уровня для преобразования 5 вольт SNES в 3,3 В, а затем подключается к плату разработки DE10-Nano FPGA с Cyclone V FPGA. Платы схем сдвига уровня совершенно ужасны, а их сборка превратилась в кошмар из-за обязательных интегральных схем, которые продаются только в корпусах с поверхностным монтажом. Однако со своей работой они справляются.


Чип SuperRT создаёт сцену при помощи специализированного языка команд, исполняемых одним из трёх блоков паралелльного выполнения кода чипа (по сути, это специализированные процессоры CISC) для расчётов тестов пересечения лучей. Описание сцены позволяет создавать объекты при помощи подмножества операций CSG: в качестве базовых строительных блоков используются сферы и плоскости, а при помощи операций OR, AND и вычитания они применяются для построения нужной геометрии. AABB тоже поддерживаются и в основном используются для тестов усечения (при желании их тоже можно рендерить, но они обладают более низкой точностью позиционирования по сравнению с другими примитивами, поэтому не особо полезны, за исключением задач отладки).


Рендерер испускает до четырёх лучей на экранный пиксель, вычисляя прямые тени от направленного источника освещения и одного зеркального отражения. Каждая из поверхностей имеет цвет рассеянного освещения и свойство отражающей способности; к ним можно применять модификаторы на основании результатов CSG или специализированных функций. Это используется для генерирования паттерна шахматной доски на полу.


Цвет луча для каждого пикселя вычисляется движком лучей, обрабатывающим весь цикл жизни луча; он использует модуль движка исполнения для выполнения управляющей программы, которая описывает сцену столько раз, сколько необходимо для вычисления результатов луча. Сама управляющая программа загружается со SNES и хранится в локальном буфере ОЗУ на 4 КБ анимация реализуется записью в этот буфер модифицированных команд. Дизассемблированный буфер команд выглядит следующим образом:

0000 Start0001 Plane 0, -1, 0, Dist=-20002 SphereSub OH 2, 1, 5, Rad=50003 SphereSub OH 4, 1, 4, Rad=40004 SphereSub OH 5, 1, 9, Rad=90005 SphereSub OH 2, 1, 2, Rad=20006 SphereSub OH -0.5, 1, 2, Rad=20007 RegisterHitNoReset 0, 248, 0, Reflectiveness=00008 Checkerboard ORH 48, 152, 48, Reflectiveness=00009 ResetHitState0010 Plane 0, -1, 0, Dist=-2.1501460011 RegisterHit 0, 0, 248, Reflectiveness=1530012 AABB 4, -2.5, 11,    8, 3.5, 130013 ResetHitStateAndJump NH 440014 Origin 6, 2, 120015 Plane -0.2929688, 0, -0.9570313, Dist=0.24975590016 PlaneAnd OH 0.2919922, 0, 0.9560547, Dist=0.250017 PlaneAnd OH 0, 1, 0, Dist=10018 PlaneAnd OH 0, -1, 0, Dist=40019 PlaneAnd OH -0.9570313, 0, 0.2919922, Dist=-10020 PlaneAnd OH 0.9560547, 0, -0.2929688, Dist=1.4997560021 RegisterHit 248, 0, 0, Reflectiveness=0

Каждый движок исполнения это процессорный модуль с 14-тактным конвейером, и обычно за такт завершается выполнение одной команды, поэтому каждый модуль исполнения может вычислять примерно по 50 миллионов пересечений сфер, плоскостей или AABB. Исключением является то, что операциям ветвления нужно очищать весь конвейер, а следовательно, они тратят 16 тактов (14 тактов на очистку конвейера + 2 тактов задержки на получение команды). Чтобы по возможности избегать этого, используется система прогнозирования ветвления к счастью, часто пространственная связность соседних лучей приводит к высокому уровню совпадения прогнозов.


Пересечения в движке исполнения вычисляются двумя конвейерами один обрабатывает AABB, другой сферы и плоскости. Система в целом работает исключительно с 32-битной целочисленной математикой в формате фиксированной запятой 18.14; если известно, что значения находятся в интервале 1, то используется 16-битный (2.14) формат, а конвейер вычисления пересечений сфер/плоскостей имеет два дополнительных специализированных математических блока, вычисляющих операции обратных значений и квадратного корня.


При рендеринге кадра модуль преобразования PPU превращает буфер кадра в формат, который при помощи DMA можно передать напрямую во VRAM консоли SNES для отображения, ужав его до 256 цветов и заменив его на битовые плоскости тайлов символов. Экран имеет разрешение 200x160, то есть полный кадр занимает ровно 32000 байт данных изображений, которые из-за ограничений пропускной способности передаются во VRAM как два фрагмента по 16000 байт в следующих друг за другом кадрах. Следовательно, полное изображение можно обновлять только раз в два кадра, что ограничивает максимальную частоту кадров 30FPS. Однако тестовая сцена работает с частотой ближе к 20FPS (в основном из-за узких мест на стороне логики SNES).

Большое спасибо участникам этого треда на SNESdev за множество полезных идей о DMA полноэкранного чипа расширения, благодаря которым я придумал описанное мной решение.


В этом чипе также реализовано множество других базовых функций есть интерфейс с шиной картриджа SNES, а также небольшой ROM для программ, содержащий 32 КБ кода для SNES (он ограничен тем, что плата интерфейса пока подключена только к линиям адресной шины A консоли SNES, а поэтому доступное адресное пространство составляет всего 64 КБ, из которых 32 КБ используются для регистров ввода-вывода с отображением в память, применяемых для связи с чипом SuperRT). Также присутствует блок ускорения операций умножения, позволяющий SNES быстро выполнять операции умножения 16x16 бит.


Для отладки я использовал интерфейс HDMI платы DE10, выводя данные на второй монитор, а также геймпад Megadrive, подключённый к контактам GPIO для управления системой отладки. Однако из-за ограниченных ресурсов при включении всех трёх ядер движка лучей отладку приходится отключать.

Вот краткий обзор системы, в будущем я планирую опубликовать новые статьи с более подробным описанием работы отдельных компонентов. Однако если у вас есть вопросы или мысли, то свяжитесь со мной, и я постараюсь ответить!

Огромное спасибо Мэтту, Джеймин, Рику и всем тем, кто помогал советами, вдохновением и поддержкой!





На правах рекламы


Надёжный сервер в аренду и правильный выбор тарифного плана позволят меньше отвлекаться на неприятные уведомления мониторинга всё будет работать без сбоев и с очень высоким uptime!



Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru