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

Компьютерная графика

Александр Труханов Энтузиазм и целеустремленность оказались дороже денег, которых не было

01.10.2020 20:16:38 | Автор: admin


Соавтор книг А я был в компьютерном городе и Энциклопедия профессора Фортрана подарил IT-музею DataArt два компьютера из 1990-х, успевших стать раритетами: Mac от Apple в идеальном состоянии и графическую станцию O2 Silicon Graphics. А в новом интервью нашему музейному проекту рассказал о видеоплате в рюкзаке с тушенкой, временах запретов на ввоз стратегической электроники в СССР, забытом на орбите спутнике и удивительном путешествии в Лондон.

Правда ли, что вы первым привезли в СССР графическую плату с видеовыходом?

Да, с платой видеовывода я был первым среди первых. В те времена существовала единственная плата с композитным видеовыходом для PC, и производила ее малюсенькая компания Vine Micros из Великобритании. Серьезную графику тогда делали на специализированных графических станциях компании Silicon Graphics, которые по вычислительной мощности относительно PC можно было назвать суперкомпьютерами. Ну и стоили они соответственно, да еще и были запрещены к ввозу в нашу страну.


В конце 1980-х компания Vine Micros была известна в Великобритании благодаря устройству, позволявшему переписывать игры с кассет на дискеты, делать скриншоты, останавливать игру и сохраняться в точках, не предусмотренных издателем. Их Master Replay подключался к домашнему компьютеру BBC Micro

Но давайте все по порядку. Эта история случилась на стыке эпох, когда разваливался Советский Союз, факс был роскошью, а интернета не было и в помине. Зато были энтузиазм, молодость, напор и огромное желание заниматься компьютерной графикой. По нынешним меркам, это сравнимо с идеей слетать на Марс с билетом в один конец. Мой друг тогда писал графический редактор для САПР в загадочном совместном предприятии, вроде бы организованном некой коммунисткой с Кипра. У него у единственного был полный лицензионный Borland Turbo С с документацией, купленный конторой за валюту. Добрый друг копировал и щедро раздавал софт всем своим приятелям и знакомым, которые тогда работали на ломаных урезанных сборках и без документации.

В те времена еще действовал Координационный комитет по экспортному контролю, объединявший 17 стран и составлявший списки стратегических технологий, не подлежащих экспорту в страны Восточного блока. Под запреты COCOM или КоКом попадали и 386-е процессоры импортировать их в СССР было запрещено. Тем не менее, процессоры все равно ввозили через третьи страны. Как известно из трудов классиков марксизма-ленинизма, нет такого преступления, на которое бы не пошел капиталист, если его прибыль превысит 100 %. Цитата не точная, т. к. основы марксизма-ленинизма я сдавал очень-очень давно, еще когда учился в МИФИ на факультете теоретической физики.


Иллюстрация к колонке, приветствующей планы администрации Джорджа Буша-старшего по смягчению ограничений экспорта в страны Восточного блока. Журнал Computerworld, 1990 г.

Запрещенные процессоры и компоненты в обход COCOM поступали в СССР через Индию. На их основе уже здесь в недрах всевозможных совместных предприятий собирались компьютеры, писался оригинальный софт под DOS, благодаря которому наши изделия прыгали, летали, плавали и перехватывали изделия вероятного противника. Шустрые индийские бизнесмены на компьютерной контрабанде в те времена сколачивали миллионные состояния, расслаблялись, теряли бдительность и выезжали из страны на Запад, дабы потратить нечестно заработанное легким трудом. По прилете спецслужбы их вязали прямо на трапах самолетов. Как сейчас помню британскую газету с фотографией на передовице, где на знакомого мне индуса (заезжал в нашу контору с заманчивыми предложениями) в аэропорту Хитроу надевают наручники. Сейчас фраза сесть за 386-й процессор звучит абсурдно, но попавшим тогда под раздачу компьютерным контрабандистам, севшим лет этак на 68, было совсем не смешно.


Реклама первого видеоконвертера Vine Micros из путеводителя по выставке Electron & BBC Micro user show, проходившей в Лондоне 912 мая 1985 года

Давайте вернемся к Vine Micros. Виноград в названии дань моде на фрукты, как яблоко у Apple?

Видимо, да, но мы тогда воспринимали это по другому. Мне кажется, для русских все, что связано со спиртом, вином и прочим алкоголем святое. Опыт общения с научными работниками в советских лабораториях подсказывал, что любой наш научник, чем бы он ни занимался искусственными алмазами или выращиванием кристаллов для микроэлектроники накануне дня рождения любого из соратников из подручных материалов легко мог собрать самогонный аппарат, а после торжества снова превратить его в вакуумную установку для экспериментов. И эта картинка с виноградом на эмблеме компании, созданной в британском гараже, однозначно говорила, что в графстве Кент сидит наш человек. Принял вина на грудь, почесал репу, взял плату EGA, перепрограммировал чип, потом поработал паяльником и выдернул из платы видеосигнал. Появилась возможность подключать видеомагнитофон и писать на него графику в реальном времени. Да, EGA это мало цветов, да, низкое разрешение, но это лучше, чем вообще ничего. Хоть и сделана плата была на любительском уровне, но низкая цена и возможность наложить бегущую строку на телекартинку открывала огромные возможности для той же телевизионной рекламы. Полноцветная графическая станция от Silicon Graphics стоила как дорогой автомобиль, а плата Vine Micros как крошечная деталька от этого автомобиля.

Под запрет COCOM эта платка не попадала?

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

По запросу военных в Англии, некая компания под руководством загадочного мистера Винтера стала делать видеоконверторы для записи изображения с монитора на видеомагнитофон. Стоила такая железка, как спортивный автомобиль, но вояки денег не жалели. Как я познакомился с мистером Винтером, я вам еще расскажу, а пока давайте вернемся к истории с Vine Micros. Согласно информации из полученного по почте компьютерного журнала, стоила их платка с видеовыходом аж 199 фунтов стерлингов. Я пошел с журналом к руководству совместного предприятия, где тогда работал, с просьбой выделить 200 фунтов на закупку. Ранее мне неоднократно отказывали, а коммерческий директор писал на моих служебных записках резолюции: Не вижу рынка. Мультикам денег не давать. Нашу небольшую группу софтописателей коммерческий отдел называл мультиками так они определяли, чем мы в их конторе занимаемся. Я решил предпринять ход конем и отправился на прием к самому-самому главному на предприятии Генеральному Директору. Между нами состоялся интересный диалог:

Ну и сколько стоит эта твоя плата? лениво поинтересовался босс.
199 фунтов! выпалил я. Денег на проживание мне не надо у знакомых англичан поживу. И на метро не надо пешком буду ходить. Нужно только 199 фунтов на покупку платы и самый дешевый билет до Лондона и обратно. Британская виза у меня есть.
Питаться ты святым духом собираешься? поинтересовался босс и пригубил кофе.
Это мои проблемы, отвечаю. Тушенку и сгущенку с собой привезу, буду консервами питаться.

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

Наконец босс принял решение: Я сейчас позвоню в бухгалтерию, чтобы оформили тебе командировку, купили авиабилеты и выдали 200 фунтов на руки. Чеки привезешь и отчитаешься. Ну и подарок какой-нибудь для моей секретарши из Лондона привези. Такой вариант устраивает?

Очень даже устраивает! воскликнул я и счастливый побежал в бухгалтерию.


Визитка Адександра Труханова, начало 1990-х. Обратите внимание, что электронной почты еще нет, зато указан телекс

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

Ну и как, справились с заданием?

Не без издержек, но справился. В Лондон я прилетел с рюкзаком, набитым банками с армейской тушенкой и сгущенкой. Там же лежали две пачки сигарет Космос для облегчения контакта с местным населением (сам я не курю) и фотоаппарат ФЭД для обмена на фунты стерлингов в комиссионном магазинчике напротив здания корпорации BBC. Остановился на квартире, которую снимали мои британские друзья. Сами друзья уехали к другим друзьям, а в пустующую комнату поселили меня. Компания в двухэтажном домике была достойная: молодой оператор с местного 4-го канала и фотокорреспондент из Австралии с подругой, которые получили разрешение на работу в Великобритании на два года. Первое, что они мне показали, была их еда в холодильнике. Пояснили, что на мою тушенку не претендуют, но и к их йогуртам тоже просят руки не тянуть. А в остальном мир, дружба, жвачка. Я им подарил пачку Космоса и набор значков с изображением Ленина в ассортименте: Ленин маленький (октябрятский значок), Ленин юный (комсомольский) и Ленин зрелый (профиль вождя с подписью КПСС). От Ленина и сигарет ребята отказались по их мнению, у Ильича слишком сомнительная репутация, а сигареты Космос слишком крепкие. Попросили поменять дары на значок с Юрием Гагариным первый космонавт вызывал у них неописуемый восторг и шел на ура. Вскоре запасы значков с Гагариным в моих закромах закончились.

К австралийцам по вечерам подваливали соотечественники. Они курили странные сигареты, от которых звенело в голове, пили пиво и беседовали о жизни в Великобритании. Я их развлекал анекдотами про советского разведчика Штирлица и его приключениями в нацистской Германии. Особой популярностью пользовался эпизод, когда Штирлиц шел по ночному Берлину, а навстречу ему шли проститутки. Проститутки, подумал Штирлиц. Штирлиц, подумали проститутки.

Эта многослойная история была сродни своеобразному британскому юмору, и австралийцам Штирлиц нравился. У большинства из них было разрешение на работу сроком на два года. Заработал денег в Англии и дуй обратно домой в свою Австралию. Собственно, австралийская тусовка мне плату и помогла купить. В их среде обнаружилась пара русских австралийцев, прибывших из Сиднея в Лондон туристами. Их бабушка Дуня во время Второй мировой войны эмигрировала в Австралию, и по-русски они говорили почти без акцента. Все понимали, что без платы я обратно не уеду русский компьютерный фанатик! денег на дорогу у меня нет, как и счета в банке, чтобы произвести оплату.


Судя по адресу, так выглядел офис компании Vine Micros в графстве Кент. В 2004 году ее вместе с правами на технологию CORIO приобрела корпорация tvONE

Но у ребят тур по Великобритании как раз пролегал через графство Кент. Я отдал им свои выстраданные 200 фунтов, и через неделю они вернулись с вожделенной платой. Выяснилось, что 199 фунтов цена без VAТ, и австралийцам пришлось еще и доплатить свои кровные. Я пытался что-то им подарить в качестве благодарности, но они со словами забудь! отказались. Все-таки русские они и в Австралии русские, за что им огромное спасибо. В Москву я вернулся счастливым, хотя и слегка социально изношенным.

Подарок секретарше босса привезли?

Секретарше нет, а боссу привез. Гуляя районе Сохо среди диковинных для советского человека секс-шопов, попал под рекламную акцию: узнав, что я из Москвы и приехал по бизнесу, продавец-афробританец оценил мой интерес к передовым секс-технологиям и подарил набор черных презервативов то ли с усиками, то ли с пупырышками. Я взял у него визитку и пообещал в случае успешного тестирования закупить большую партию. Уникальное по тем временам изделие я подарил по возвращении боссу. Тот был удивлен трижды: и моим возвращением с платой, и фактом подарка, и самим подарком. После этого доверие к моим идеям резко возросло. Меня стали приглашать переводчиком на встречи с зарубежными визитерами. Иногда такие переговоры проходили в бане под водку с коньяком. Один раз начальство в состоянии изрядного подпития чуть было не продало буржуинам уже выведенный из эксплуатации, но еще болтающийся на орбите спутник. С трудом их отговорил, но, как у нас водится, меня же крайним и сделали: Ты англичанам в бане про спутники переводил, вот сам с ними и разбирайся Когда я прочитал, что какому-то западному господину продали луноход, оставшийся на спутнике Земли, ничуть не удивился сам участвовал в подобном.

Если вернуться на Землю, как вы вообще передвигались по Лондону без копейки?

Денег не было не только на метро их не было совсем. В центр Лондона от станции Балем, где я жил, ходил пешком через индийский район. Там сплошь мелькали сари и чалмы. До Биг-Бена я доходил часа за два. Один раз меня в переулках окружили лондонские хулиганы, пытались наехать, запугать и деньги отобрать. Британская шпана тогда обувалась в тяжелые ботинки с металлическими подковками, чтобы бить жертвы ногами. Их сленга я не понимал, на угрозы не реагировал и оставался невозмутимым то есть в привычный образ жертвы не вписывался. Да и за свои 199 фунтов на покупку платы я бы бился не на жизнь, а на смерть. А если учесть, что еще школьником я изучал дзюдо в чуть ли не в единственной в СССР специализированной спортивной школе в городе Электросталь (тогда в Советском Союзе в основном распространено было самбо), а в студенческие времена посещал полулегальную секцию каратэ в общежитии МИФИ, то шпану ожидала масса сюрпризов. Видимо, вожак гопников это почувствовал и, прекратив угрожать, спросил: Откуда ты такой взялся? Я из Советского Союза, отвечаю и показываю значок с Лениным. Это тебе на память. Тут гопники растерялись. Видимо, вспомнили популярный тогда боевик Красная жара со Шварценеггером в роли советского милиционера. Вспомнили и с криками валим отсюда! растворились в подворотнях.


Центральная школа каратэ в Москве, середина 1980-х

Вообще в моих пеших походах по Лондону было немало смешных эпизодов. Как-то сидел я на лавочке с видом на Биг-Бен и вел неспешную беседу с британским пенсионером, присевшим рядом. Старичок поинтересовался откуда я, а когда узнал, что из Москвы, выдал удивительную фразу: О! Я слышал, у вас там большие проблемы в магазинах нет свежих апельсинов! Мне очень хотелось закричать: Дед, каких еще, блин, апельсинов! У нас вообще ни хрена в магазинах нет! Но сдержался. Тут меня пенсионер толкает в бок и показывает в сторону молодой парочки, направляющейся к нашей скамейке: Смотри, смотри, Алекс! Это американцы. Сейчас спросят, как называется река и что это за здание. Так это же Биг-Бен! я был удивлен до глубины души. Как можно этого не знать! Американцы они таки. Денег много, а не знают ни фига. За это мы их и не любим, проворчал дедок. Сейчас ты объяснишь им, что Биг-Бен это Биг-Бен, а Темза это Темза. Потом они начнут спрашивать, где можно купить чай в жестяной коробке в форме лондонской телефонной будки и сувенирного гвардейца в медвежьей шапке. Я дедушке ответил, что про Темзу я, конечно, отвечу, а про чай и сувениры пусть лучше он сам объяснит. На том и порешили.

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

Без денег только гуляли ни в музеи, ни в кино было не попасть?

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

Господа отправились в ложу, а нам указали на места в партере с табличками зарезервировано. Сели. У нас с ищущим себя ни билетов, ни контрамарок вообще ничего. Вокруг джентльмены во фраках и дамы в вечерних нарядах. У меня появилось ощущение, что мы сидим на чужих местах. И не у меня одного. Подходит к нам пожилой и элегантный, как рояль, джентльмен и показывает свои билетики. С ним дама с потрясающим колье на шее. Ищущий себя начинает сбивчиво объяснять, что мы творческая молодежь, прибывшая по приглашению Самого Композитора, а рядом с ним сидит человек специально приехавший на премьеру из Москвы. Его вежливо слушают, но не верят. Вот-вот прогонят. Тут во мне срабатывает инстинкт самосохранения я встаю в полный рост, раскланиваюсь и представляюсь: Чайковский из Москвы, друг сына композитора. Джентльмен смутился, пробормотал что-то про Лебединое озеро, прогрессивную молодежь и вместе с дамой удалился. Позже администратор посадила их на другие места. Вероятно, прогонять Чайковского, даже с приставкой псевдо-, в британских музыкальных кругах сочли плохим тоном.


Кадр из рекламы совместного предприятия, в котором работал Александр Труханов. Фото из личного архива сделано с лампового телевизора на пленочный фотоаппарат и напечатано в ванной комнате. По сценарию в кабаке бизнес-паре (мужчине и женщине) официант озвучивает меню компьютеры такие-то и такие-то за валюту и рубли. Начальство оплатило двух артистов и оператора, а на персонаже официант, как всегда, сэкономили предполагалось, что его роль исполнит коммерческий директор. Но тот отказался: Мне статус не позволяет халдея играть!. Ну и вызвали меня: Ты там с телевидением чего-то мутишь, вот иди и снимись в рекламе. Сходил, снялся...

То есть поездка получилась увлекательной?

Может создаться ложное впечатление, что она была легкой слетал на досуге в Лондон за платой, прогулялся, развеялся. На самом деле все было довольно жестко. Хорошо, когда есть крыша над головой, но, когда нет монет даже на кофе, становится не только голодно, но и грустно. Спасали энтузиазм и целеустремленность. Эти качества оказались дороже денег, которых не было. Именно они вкупе с верой в себя помогли выполнить миссию, в успех которой не верил никто. За исключением меня самого, разумеется. Я воспитывался на советских песнях и искренне считал, что мы рождены, чтоб сказку сделать былью, преодолеть пространство и простор. Я и преодолевал: и книжку вместе с Андреем написал, и с отрицательным балансом в кармане умудрился видеоплату из Лондона привезти. Справедливости ради отмечу, что кофе я наловчился пить в офисах в Сохо, куда заходил для знакомства с новыми технологиями. Там его предлагали бесплатно.


Видеостена в Музее естествознания в Лондоне, построенная по технологии компании Memotech под управлением Брайана Пайпа

В одной из компаний в Сохо я познакомился с Брайаном Пайпом, строившим видеостены из электролучевых трубок (LCD тогда еще не придумали). Оказалось, что его жена Лиза раньше учила русский. Несколько раз они подкармливали меня в ресторанчиках, за что им огромное спасибо. Мои британские друзья тоже старались помочь договорились со знакомыми в русской службе ВВС о моем визите в гнездо антисоветской пропаганды. Я посетил Буш-хаус и за скромное вознаграждение дал пару интервью под псевдонимом Ведерников. Рассказывал о жизни в Москве, отвечал на актуальные вопросы.


Сева Новгородцев в редакции BBC в знаменитом здании Буш-хаус в центре Лондона, правда, уже в начале 2000-х. Всемирная служба BBC переехала из Буш-хауса в 2012 году. Источник фото

После одного из таких интервью некий Сева (но не Новгородцев, тот работал в другом отделе) сказал: Слушай, Саш, у нас тут путаница произошла: интервью пошло в эфир под твоей настоящей фамилией. Я понимаю, что могут быть проблемы по возвращении в Москву, но ты уж извини Я напрягся, тут же потребовал от Севы политического убежища с койкой прямо у него в кабинете и работу на ВВС за еду. Сева похлопал меня по плечу и сказал, что пошутил. И добавил, что легко любить Россию, особенно когда живешь в Лондоне. Я ответил, что тоже пошутил, но адреналин в моей крови бурлил еще достаточно долго.

Вы передали в наш музей графическую станцию Silicon Graphics O2. Как она к вам попала?


Рабочую станцию O2 компания Silicon Graphics представила в 1996 году в качестве замены более ранней Indy. Ee подробный обзор уже выходил на Хабре

Это отголоски старой истории с графическими станциями компании Silicon Graphics. На РС тогда приличной графики не было из-за низкой мощности процессоров, и заинтересованные структуры использовали суперкомпьютеры от SGI Octane или более доступные Indigo. Но все они стоили очень дорого. Бюджетные станции О2 появились позже. Какие структуры потребляли продукцию Silicon Graphics, можно догадаться по установленному на вашем O2 авиационному тренажеру. Кроме военных, инженеров аэрокосмической отрасли и физиков-ядерщиков, небольшое количество станций SGI могли себе позволить и телевизионщики с киношниками. В одной из студий в Сохо (именно там находились студии графики в Лондоне) мне показали пленку с кадрами из Терминатора. И дали мощную лупу, чтобы я мог увидеть дискретность графики. В кадре, где робот-убийца выходит из пламени, разрешение примерно 300 на 200, но длился фрагмент со спецэффектом около секунды. За это время человеческий глаз не успевает заметить дискретность. Все это мне показал инженер, который написал программу для интерполяции (растягивания) просчитанного на силиконе кадра до киношного разрешения. Затем на специальном устройстве шел покадровый сброс изображение с графической станции записывали на кинопленку. Процесс небыстрый, но другого тогда не было.


Скорее всего, Александр вспоминает этот фрагмент фильма Терминатор-2

Станции SGI попадали под запреты COCOM? Советские киношники на них рассчитывать не могли?

Да, из-за наших реалий. Станции, якобы купленные для кино, потом загадочным образом оказывались в каком-нибудь закрытом научном городке. Мне известна одна такая история: СССР рухнул, кругом бардак. И тут в офис SGI приходит факс от некой российской структуры, условно назовем ее атомной станцией: Ваш компьютер вышел из строя. Как нам его починить?

Все как в анекдоте советских времен: американский самолет-шпион сфотографировал пашущий поле мирный советский трактор. На недружественный акт самолета-разведчика наш мирный трактор ответил залпом ракет типа земля-воздух.

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

Мы были первыми, кому удалось получить в Великобритании лицензию для ввоза в Россию четырех графических станции SGI для теле и кинопроизводства (в США это в принципе было невозможно). Идущим вслед за нами было уже проще прецедент был создан. Так вот, продавец клялся и божился, что ни о какой атомной станции он знать не знал, а продал Octane некому бюро для проектирования сельхозтехники или чего-то подобного. Был суд в Европе, но вроде тогда парню удалось отбиться. А может, та самая сельхозструктура ему отбиться помогла. Дело ясное, что дело темное будем считать, что все это слухи и неправда. Были санкции COCOM, и были те, кто эти самые санкции обходил. Извечное соревнование пули и брони. Но, как говорится, иных уж нет, а те далече.


Проходная вполне подходящего сельхозпредприятия. Кадр из фильма Карена Шахназарова Город Зеро, 1988 г.

Как же вы получили лицензию на ввоз?

Помог нам в этом загадочный мистер Винтер, производивший видеоконвертеры для нужд ихних госструктур. С ним я познакомился совершенно случайно. Когда я приехал в Лондон за платой Vine Micros, посетил пару книжных магазинов и переписал из журналов телефоны контор, связанных с компьютерной графикой. Так на мистера Винтера и вышел. Он был человеком со связями и в аэрокосмической сфере, и в банковской, и в области телекоммуникаций. Сейчас бы его можно было назвать инвестором в области новых технологий. Шустрый был мужичок.

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

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

Как так не можете найти? возмущаюсь я. Час назад я их сдал в соседнее окошко И паспорт мой тоже пропал?!
И паспорт найти не можем.
Как такое вообще возможно?! я был в полной растерянности. Что тут у вас за бардак?
Внезапно человек в окне задает мне вопрос: Что вы делали в Лондоне в таком-то году?
Я растерялся еще больше: Приезжал как турист, гулял, смотрел, развлекался. А в чем дело-то?
По нашей информации, вы встречались с представителем Silicon Graphics и приезжали в Лондон по приглашению SGI.
Да я не только с человеком из SGI встречался. Я еще и в студию Double Positive заходил, помогал с переводом продюсеру из Москвы. Но это было только в первый день моего визита. Потом я всю неделю гулял, смотрел на достопримечательности и развлекался.

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

Кстати, у станции SGI O2 с авиатренажером, переданной в ваш музей, есть имя собственное: ее зовут BRAVO! Давным давно я отдал станцию своему хорошему знакомому Дмитрию Гуслинскому, специалисту в области кинопроизводства. Дмитрий хранил ее у себя почти 15 лет! Пятнадцать!!! Под роспись получил под роспись вернул. О2 до сих пор в рабочем состоянии и с оригинальной клавиатурой. Сам по себе факт вызывает восхищение, за что Дмитрию огромное спасибо. Это история еще раз подтверждает, что главное достояние нашей страны живущие в ней удивительные люди. Ну а нужную технику мы так или иначе раздобудем.

Переданный в музей Mac тоже с историей?

Его мы использовали для чернового монтажа, просматривали на нем футажи. В какой-то момент мы создали студию компьютерной графики BEE EYE (Пчелкин глаз) с участием Димы Диброва. В той студии был создан первый российский рекламный ролик с наложением трехмерной компьютерной графики на видеоизображение: в кадре вокруг еще молодого Константина Райкина летал смоделированный на компе подсвечник с горящими свечами, в воздухе вспыхивали молнии, гремели раскаты грома. Режиссером проекта выступил человек теперь настолько известный, что я не стану называть его имени.


Видеоклип Сергея Минаева на песню Ваучер, сделанный студией Bee Eye

Раритетные компьютеры отправились в нашу коллекцию, а мы надеемся на продолжение общения с Александром об удивительном времени создания Профессора Фортрана.
Подробнее..

Из песочницы Обнаружение столкновений и теорема о разделяющей оси

04.07.2020 20:20:46 | Автор: admin
В наше время компьютеры представляют собой мощные вычислительные машины,
способные выполнять миллионы операций в секунду. И естественно не обойтись без симуляции реального или игрового мира. Одна из задач компьютерного моделирования и симуляции состоит в определении столкновения двух объектов, одно из решений которой реализуется теоремой о разделяющей оси.



Примечание. В статье будет приведен пример с 2 параллелепипедами(далее кубы), но идея для других выпуклых объектов будет сохранена.
Примечание. Вся реализация будет выполнена в Unity.

Акт 0. Общая теория


Для начала нужно познакомиться с "теоремой о разделяющей гиперплоскости".Именно она будет лежать в основе алгоритма.

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


Разделяющая ось (двумерный случай)


Разделяющая ось (трехмерный случай)
Можно заметить, что проекции на разделяющую ось не пересекаются.

Свойство. Потенциальная разделяющая ось будет находиться в следующих множествах:
  • Нормы плоскостей каждого куба(красные)
  • Векторное произведение ребер кубов $\{[\vec{x},\vec{y}] : xX, yY\}$,

где X ребра первого куба (зеленые), а Y второго (синие).



Каждый куб мы можем описать следующими входными данными:
  • Координаты центра куба
  • Размеры куба (высота, ширина, глубина)
  • Кватернион куба

Создадим для этого дополнительный класс, экземпляры которого будут предоставлять информацию о кубе.
public class Box{    public Vector3 Center;    public Vector3 Size;    public Quaternion Quaternion;    public Box(Vector3 center, Vector3 size, Quaternion quaternion)    {       this.Center = center;       this.Size = size;       this.Quaternion = quaternion;    }    // дополнительный конструктор, который    // позволяет сгенерировать объект на основе GameObject    public Box(GameObject obj)    {        Center = obj.transform.position;        Size = obj.transform.lossyScale;        Quaternion = obj.transform.rotation;    }}

Акт 1. Кватернионы


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

Кватернион это гиперкомплексное число, которое определяет вращение объекта в пространстве.

$w+xi+yj+zk$


Мнимая часть(x,y,z) представляет вектор, который определяет направление вращения
Вещественная часть(w) определяет угол, на который будет совершено вращение.

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

Рекомендую две статьи, в которых подробно рассказывается о кватернионах:

Раз
Два

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

Формула вращения вектора

$\vec{v}' = q * \vec{v} * \overline{q}$


$\vec{v}'$ искомый вектор
$\vec{v}$ исходный вектор
$q$ кватернион
$\overline{q}$ обратный кватернион

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

$q = w+xi+yj+zk$
$\overline{q} = w-xi-yj-zk$

Посчитаем $\vec{v} * \overline{q}$

$ M = \vec{v}*\overline{q} = (0 + v_xi + v_yj + v_zk)(q_w - q_xi - q_yj - q_zk) = $
$=\color{red}{v_xq_wi} + \color{purple}{v_xq_x} - \color{blue}{v_xq_yk} + \color{green}{v_xq_zj} +$
$+\color{green}{v_yq_wj} + \color{blue}{v_yq_xk} + \color{purple}{v_yq_y} - \color{red}{v_yq_zi} +$
$+\color{blue}{v_zq_wk} - \color{green}{v_zq_xj} + \color{red}{v_zq_yi} + \color{purple}{v_zq_z}$

Теперь выпишем отдельные компоненты и из этого произведения соберем новый кватернион
$M = u_w+u_xi+u_yj+u_zk$
$u_w = \color{purple}{v_xq_x + v_yq_y + v_zq_z}$
$u_xi = \color{red}{(v_xq_w - v_yq_z + v_zq_y)i}$
$u_yj = \color{green}{(v_xq_z + v_yq_w - v_zq_x)j}$
$u_zk = \color{blue}{(-v_xq_y + v_yq_x + v_zq_w)k}$

Посчитаем оставшуюся часть, т.е. $ q*M $ и получим искомый вектор.

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

$\vec{v}' = q*M = (q_w + q_xi + q_yj + q_zk)(u_w + u_xi + u_yj + u_zk) =$
$= \color{red}{q_wu_xi} + \color{green}{q_wu_yj} + \color{blue}{q_wu_zk} + $
$ +\color{red}{q_xu_wi} + \color{blue}{q_xu_yk} - \color{green}{q_xu_zj} +$
$+\color{green}{q_yu_wj} - \color{blue}{q_yu_xk} + \color{red}{q_yu_zi} +$
$+\color{blue}{q_zu_wk} +\color{green}{q_zu_xj} -\color{red}{q_zu_yi}$

Соберем компоненты вектора

$v_x' = \color{red}{q_wu_x + q_xu_w + q_yu_z - q_zu_y}$
$v_y' = \color{green}{q_wu_y - q_xu_z + q_yu_w + q_zu_x}$
$v_z' = \color{blue}{q_wu_z + q_xu_y - q_yu_x + q_zu_w}$

$v' = (v_x', v_y', v_z')$
Таким образом необходимый вектор получен

Напишем код:

private static Vector3 QuanRotation(Vector3 v,Quaternion q){        float u0 = v.x * q.x + v.y * q.y + v.z * q.z;        float u1 = v.x * q.w - v.y * q.z + v.z * q.y;        float u2 = v.x * q.z + v.y * q.w - v.z * q.x;        float u3 = -v.x * q.y + v.y * q.x + v.z * q.w;        Quaternion M = new Quaternion(u1,u2,u3,u0);                Vector3 resultVector;        resultVector.x = q.w * M.x + q.x * M.w + q.y * M.z - q.z * M.y;          resultVector.y = q.w * M.y - q.x * M.z + q.y * M.w + q.z * M.x;        resultVector.z = q.w * M.z + q.x * M.y - q.y * M.x + q.z * M.w;                return resultVector;}

Акт 2. Нахождение вершин куба


Зная как вращать вектор кватернионом, не составит труда найти все вершины куба.

Перейдем к функцию нахождении вершин куба. Определим базовые переменные.

private static Vector3[] GetPoint(Box box){        //Тут будут храниться координаты вершин        Vector3[] point = new Vector3[8];        //получаем координаты        //....        return point;}

Далее необходимо найти такую точку(опорную точку), от которой будет легче всего найти другие вершины.

Из центра покоординатно вычитаем половину размерности куба.Потом к опорной точке прибавляем по одной размерности куба.

//...        //первые четыре вершины        point[0] = box.Center - box.Size/2;        point[1] = point[0] + new Vector3(box.Size.x , 0, 0);        point[2] = point[0] + new Vector3(0, box.Size.y, 0);        point[3] = point[0] + new Vector3(0, 0, box.Size.z);//таким же образом находим оставшееся точки        point[4] = box.Center + box.Size / 2;        point[5] = point[4] - new Vector3(box.Size.x, 0, 0);        point[6] = point[4] - new Vector3(0, box.Size.y, 0);        point[7] = point[4] - new Vector3(0, 0, box.Size.z);//...


Можем видеть, как сформированы точки

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

//...        for (int i = 0; i < 8; i++)        {            point[i] -= box.Center;//перенос центра в начало координат            point[i] = QuanRotation(point[i], box.Quaternion);//поворот            point[i] += box.Center;//обратный перенос        }//...

полный код получения вершин
private static Vector3[] GetPoint(Box box){        Vector3[] point = new Vector3[8];                //получаем координаты вершин        point[0] = box.Center - box.Size/2;        point[1] = point[0] + new Vector3(box.Size.x , 0, 0);        point[2] = point[0] + new Vector3(0, box.Size.y, 0);        point[3] = point[0] + new Vector3(0, 0, box.Size.z);//таким же образом находим оставшееся точки        point[4] = box.Center + box.Size / 2;        point[5] = point[4] - new Vector3(box.Size.x, 0, 0);        point[6] = point[4] - new Vector3(0, box.Size.y, 0);        point[7] = point[4] - new Vector3(0, 0, box.Size.z);        //поворачиваем вершины кватернионом        for (int i = 0; i < 8; i++)        {            point[i] -= box.Center;//перенос центра в начало координат            point[i] = QuanRotation(point[i], box.Quaternion);//поворот            point[i] += box.Center;//обратный перенос        }                return point;}


Перейдем к проекциям.

Акт 3. Поиск разделяющих осей


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

  • Нормали плоскостей каждого куба(красные)
  • Векторное произведение ребер кубов $\{[\vec{x},\vec{y}[ : xX, yY\}$, где X ребра первого куба (зеленые), а Y второго (синие).



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



Необходимо найти нормали плоскостей, порожденные векторами:

  • $\vec{a}$ и $\vec{b}$
  • $\vec{b}$ и $\vec{c}$
  • $\vec{a}$ и $\vec{c}$

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

такой код позволяет получить эти вектора и найти нормали к плоскостям для двух кубов(понятный вариант)
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){        //ребра        Vector3 A;        Vector3 B;        //потенциальные разделяющие оси        List<Vector3> Axis = new List<Vector3>();        //нормали плоскостей первого куба        A = a[1] - a[0];        B = a[2] - a[0];        Axis.Add(Vector3.Cross(A,B).normalized);                A = a[2] - a[0];        B = a[3] - a[0];        Axis.Add(Vector3.Cross(A,B).normalized);                A = a[1] - a[0];        B = a[3] - a[0];        Axis.Add(Vector3.Cross(A,B).normalized);                //нормали второго куба        A = b[1] - b[0];        B = b[2] - b[0];        Axis.Add(Vector3.Cross(A,B).normalized);                A = b[1] - b[0];        B = b[3] - b[0];        Axis.Add(Vector3.Cross(A,B).normalized);                A = b[2] - b[0];        B = b[3] - b[0];        Axis.Add(Vector3.Cross(A,B).normalized);        //...}


Но можно сделать проще:
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){        //ребра        Vector3 A;        Vector3 B;//потенциальные разделяющие оси        List<Vector3> Axis = new List<Vector3>();//нормали плоскостей первого куба        for (int i = 1; i < 4; i++)        {            A = a[i] - a[0];            B = a[(i+1)%3+1] - a[0];            Axis.Add(Vector3.Cross(A,B).normalized);        }//нормали второго куба        for (int i = 1; i < 4; i++)        {            A = b[i] - b[0];            B = b[(i+1)%3+1] - b[0];            Axis.Add(Vector3.Cross(A,B).normalized);        }        //...}

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

private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){        //...        //получение нормалей        //...        //Теперь добавляем все векторные произведения        for (int i = 1; i < 4; i++)        {            A = a[i] - a[0];            for (int j = 1; j < 4; j++)            {                B = b[j] - b[0];                if (Vector3.Cross(A,B).magnitude != 0)                {                    Axis.Add(Vector3.Cross(A,B).normalized);                }            }        }        return Axis;}

Полный код
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){//ребра        Vector3 A;        Vector3 B;//потенциальные разделяющие оси        List<Vector3> Axis = new List<Vector3>();//нормали плоскостей первого куба        for (int i = 1; i < 4; i++)        {            A = a[i] - a[0];            B = a[(i+1)%3+1] - a[0];            Axis.Add(Vector3.Cross(A,B).normalized);        }//нормали второго куба        for (int i = 1; i < 4; i++)        {            A = b[i] - b[0];            B = b[(i+1)%3+1] - b[0];            Axis.Add(Vector3.Cross(A,B).normalized);        }        //Теперь добавляем все векторные произведения        for (int i = 1; i < 4; i++)        {            A = a[i] - a[0];            for (int j = 1; j < 4; j++)            {                B = b[j] - b[0];                if (Vector3.Cross(A,B).magnitude != 0)                {                    Axis.Add(Vector3.Cross(A,B).normalized);                }            }        }        return Axis;}


Акт 4. Проекции на оси


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

Но для начала напомним формулу скалярной проекции вектора v на единичный вектор a:

$proj_\left\langle \vec{a} \right\rangle \vec{v} = \frac{(\vec{v},\vec{a})}{| \vec{a} |}$



private static float ProjVector3(Vector3 v, Vector3 a){        a = a.normalized;        return Vector3.Dot(v, a) / a.magnitude;        }

Теперь опишем функцию, которая будет определять пересечение проекций на оси-кандидаты.

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

private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){        for (int j = 0; j < Axis.Count; j++)        {           //в этом цикле проверяем каждую ось           //будем определять пересечение проекций на разделяющие оси из списка кандидатов        }        //Если мы в цикле не нашли разделяющие оси, то кубы пересекаются, и нам нужно         //определить глубину и нормаль пересечения.}

Проекция на ось задается двумя точками, которые имеют максимальные и минимальные значения на самой оси:



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

private static void ProjAxis(out float min, out float max, Vector3[] points, Vector3 Axis){        max = ProjVector3(points[0], Axis);        min = ProjVector3(points[0], Axis);        for (int i = 1; i < points.Length; i++)        {            float tmp = ProjVector3(points[i], Axis);            if (tmp > max)            {                max = tmp;            }            if (tmp < min)            {                min= tmp;            }        }}

Итого, применив данную функцию( ProjAxis ), получим проекционные точки каждого куба.

private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){        for (int j = 0; j < Axis.Count; j++)        {            //проекции куба a            float max_a;            float min_a;            ProjAxis(out min_a,out max_a,a,Axis[j]);            //проекции куба b            float max_b;            float min_b;            ProjAxis(out min_b,out max_b,b,Axis[j]);                        //...        }        //...}

Далее, на основе проекционных вершин определяем пересечение проекций:



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

            float[] points = {min_a, max_a, min_b, max_b};            Array.Sort(points);

Заметим следующее свойство:

1) Если отрезки не пересекаются, то сумма отрезков будет меньше, чем отрезок сформированными крайними точками:



2) Если отрезки пересекаются, то сумма отрезков будет больше, чем отрезок сформированными крайними точками:



Вот таким простым условием мы проверили пересечение и непересечение отрезков.

Если пересечения нет, то глубина пересечения будет равна нулю:

            //...            //Сумма отрезков            float sum = (max_b - min_b) + (max_a - min_a);            //Длина крайних точек            float len = Math.Abs(p[3] - p[0]);                        if (sum <= len)            {                //разделяющая ось существует                // объекты непересекаются                return Vector3.zero;            }            //Предполагаем, что кубы пересекаются и рассматриваем текущую ось далее            //....

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

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

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

private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){        Vector3 norm = new Vector3(10000,10000,10000);        for (int j = 0; j < Axis.Count; j++)        {                //...        }        //в случае, когда нашелся вектор с минимальным пересечением, возвращаем его        return norm;{

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

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

//...if (sum <= len){   //разделяющая ось существует   // объекты не пересекаются   return new Vector3(0,0,0);}//Предполагаем, что кубы пересекаются и рассматриваем текущий вектор далее//пересечение проекций - это расстояние между 2 и 1 элементом в нашем массиве//(см. рисунок на котором изображен случай пересечения отрезков)float dl = Math.Abs(points[2] - points[1]);if (dl < norm.magnitude){   norm = Axis[j] * dl;   //ориентация нормали   if(points[0] != min_a)   norm = -norm;}//...

Весь код
private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){        Vector3 norm = new Vector3(10000,10000,10000);        for (int j = 0; j < Axis.Count; j++)        {            //проекции куба a            float max_a;            float min_a;            ProjAxis(out min_a,out max_a,a,Axis[j]);            //проекции куба b            float max_b;            float min_b;            ProjAxis(out min_b,out max_b,b,Axis[j]);            float[] points = {min_a, max_a, min_b, max_b};            Array.Sort(points);            float sum = (max_b - min_b) + (max_a - min_a);            float len = Math.Abs(points[3] - points[0]);                        if (sum <= len)            {                //разделяющая ось существует                // объекты не пересекаются                return new Vector3(0,0,0);            }            float dl = Math.Abs(points[2] - points[1]);            if (dl < norm.magnitude)            {                norm = Axis[j] * dl;                //ориентация нормы                if(points[0] != min_a)                    norm = -norm;            }        }        return norm;}


Заключение


Проект с реализацией и примером загружен на GitHub, и ознакомиться можно с ним здесь.

Моей целью было поделиться своим опытом в решение задач связанных с определением пересечений двух выпуклых объектов. А так же доступно и понятно рассказать о данной теореме.
Подробнее..

Armored Warfare Проект Армата. Хроматическая аберрация

09.07.2020 18:19:56 | Автор: admin


Armored Warfare: Проект Армата бесплатный танковый онлайн-экшн, разрабатываемый Allods Team, игровой студией MY.GAMES. Несмотря на то, что игра сделана на CryEngine, достаточно популярном движке с неплохим realtime renderом, для нашей игры приходится многое дорабатывать и создавать с нуля. В этой статье я хочу рассказать о том, как мы реализовывали хроматическую аберрацию для вида от первого лица, и что это такое.

Что такое хроматическая аберрация?


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


А вот уже линза с дефектом:


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


На картинке выше можно увидеть, что из-за дефекта выделяются фиолетовый и зелёный цвета. Не видно? А на этой картинке?


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


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


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

Боковая хроматическая аберрация с учётом разложения света


Начну я всё же с того, что отвечу на вопрос, который мог возникнуть в голове у многих из вас: а разве в CryEngine нет реализованной хроматической аберрации? Есть. Но применяется она на этапе пост-процессинга в одном шейдере с sharpening, а алгоритм выглядит так (ссылка на код):

screenColor.r = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 + 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).r;screenColor.b = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 - 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).b;

Что, в принципе, работает. Но у нас игра про танки. Нам этот эффект нужен только для вида от первого лица, и только для красоты, то есть чтобы в центре всё было в фокусе (привет боковой аберрации). Поэтому текущая реализация не устраивала как минимум тем, что её эффект был виден по всей картинке.

Так выглядела сама аберрация (внимание на левую сторону):


А так она выглядела, если перекрутить параметры:


Поэтому, своей целью мы поставили:

  1. Реализовать боковую хроматическую аберрацию, чтобы возле прицела всё было в фокусе, а по бокам если не видно характерных цветных дефектов, то хотя бы чтобы было размыто.
  2. Сэмплировать текстуру, умножая RGB-каналы на коэффициенты, соответствующие конкретной длине волны. Об этом я ещё не рассказывал, поэтому сейчас может быть не совсем понятно, о чём этот пункт. Но мы его обязательно рассмотрим во всех подробностях позже.

Для начала рассмотрим общий механизм и код для создания боковой хроматической аберрации.

half distanceStrength = pow(length(IN.baseTC - 0.5), falloff);half2 direction = normalize(IN.baseTC.xy - 0.5);half2 velocity = direction * blur * distanceStrength;

Итак, сначала строится круговая маска, отвечающая за дистанцию от центра экрана, потом считается направление от центра экрана, а далее это всё перемножается с blur. Blur и falloff это параметры, которые передаются извне и являются просто множителями для настройки аберрации. Также извне прокидывается параметр sampleCount, который отвечает не только за количество сэмплов, но и, по сути, за шаг между точками сэмплирования, так как

half2 offsetDecrement = velocity * stepMultiplier / half(sampleCount);

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

Видимый спектр света лежит в диапазоне длин волн от 380 нм (фиолетовый) до 780 нм (красный). И, о чудо, длину волны можно конвертировать в RGB-палитру. На Python код, который занимается этой магией, выглядит так:

def get_color(waveLength):    if waveLength >= 380 and waveLength < 440:        red = -(waveLength - 440.0) / (440.0 - 380.0)        green = 0.0        blue  = 1.0    elif waveLength >= 440 and waveLength < 490:        red   = 0.0        green = (waveLength - 440.0) / (490.0 - 440.0)        blue  = 1.0    elif waveLength >= 490 and waveLength < 510:        red   = 0.0        green = 1.0        blue  = -(waveLength - 510.0) / (510.0 - 490.0)    elif waveLength >= 510 and waveLength < 580:        red   = (waveLength - 510.0) / (580.0 - 510.0)        green = 1.0        blue  = 0.0    elif waveLength >= 580 and waveLength < 645:        red   = 1.0        green = -(waveLength - 645.0) / (645.0 - 580.0)        blue  = 0.0    elif waveLength >= 645 and waveLength < 781:        red   = 1.0        green = 0.0        blue  = 0.0    else:        red   = 0.0        green = 0.0        blue  = 0.0        factor = 0.0    if waveLength >= 380 and waveLength < 420:        factor = 0.3 + 0.7*(waveLength - 380.0) / (420.0 - 380.0)    elif waveLength >= 420 and waveLength < 701:        factor = 1.0    elif waveLength >= 701 and waveLength < 781:        factor = 0.3 + 0.7*(780.0 - waveLength) / (780.0 - 700.0)     gamma = 0.80    R = (red   * factor)**gamma if red > 0 else 0    G = (green * factor)**gamma if green > 0 else 0    B = (blue  * factor)**gamma if blue > 0 else 0        return R, G, B

В итоге мы получаем следующее распределение цвета:


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

half3 accumulator = (half3) 0;half2 offset = (half2) 0;half3 WeightSum = (half3) 0;half3 Weight = (half3) 0;half3 color;half waveLength; for (int i = 0; i < sampleCount; i++){    waveLength = lerp(startWaveLength, endWaveLength, (half)(i) / (sampleCount - 1.0));    Weight.r = GetRedWeight(waveLength);    Weight.g = GetGreenWeight(waveLength);    Weight.b = GetBlueWeight(waveLength);            offset -= offsetDecrement;            color = tex2Dlod(baseMap, half4(IN.baseTC + offset, 0, 0)).rgb;    accumulator.rgb += color.rgb * Weight.rgb;             WeightSum.rgb += Weight.rgb;} OUT.Color.rgb = half4(accumulator.rgb / WeightSum.rgb, 1.0);

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

Если до сих пор непонятно, то давайте рассмотрим конкретный пример, а именно на нашу первую попытку, и я объясню, что брать за startWaveLength и endWaveLength, и как будут реализованы функции GetRed(Green, Blue)Weight.

Аппроксимация всего диапазона видимого спектра


Итак, из графика выше мы знаем примерное соотношение и примерные значения RGB палитры для каждой длины волны. Например, для длины волны 380 нм (фиолетовый цвет) (см. тот же график) видим, что RGB(0.4, 0, 0.4). Вот именно эти значения мы и берём за веса, о которых я говорил ранее.

Попробуем теперь избавиться от функции получения цвета полиномом четвёртой степени, чтобы вычисления были дешевле (мы не студия Pixar, а игровая студия: чем дешевле вычисления, тем лучше). Этот полином четвёртой степени должен аппроксимировать полученные графики. Для построения полинома я воспользовался библиотекой SciPy:

wave_arange = numpy.arange(380, 780, 0.001)red_func = numpy.polynomial.polynomial.Polynomial.fit(wave_arange, red, 4)

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




Для того чтобы значения не выходили за предел отрезка [0, 1], используем функцию saturate. Для красного цвета, например, получается функция:

half GetRedWeight(half x){    return saturate(0.8004883122689207 +     1.3673160565954385 * (-2.9000047500568042 + 0.005000012500149485 * x) -     1.244631137356407 * pow(-2.9000047500568042 + 0.005000012500149485 * x, 2) - 1.6053230172845554 * pow(-2.9000047500568042 + 0.005000012500149485*x, 3)+ 1.055933936470091 * pow(-2.9000047500568042 + 0.005000012500149485*x, 4));}

Недостающие параметры startWaveLength и endWaveLength в данном случае являются 780 нм и 380 нм, соответственно. Результат на практике с sampleCount=3 получается следующий (см. края картинки):


Если же подкрутить значения, увеличить sampleCount до 400, то всё становится лучше:


К сожалению, у нас realtime render, в котором мы не можем позволить 400 сэмплов (примерно 3-4) в одном шейдере. Поэтому мы немного сократили диапазон длин волн.

Аппроксимация по части диапазона видимого спектра


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


Также нет необходимости интерполировать по всему отрезку, так как мы теперь можем получить полином только того участка, где значение меняется. Например, для красного цвета, это отрезок [510, 580], где значение веса меняется от 0 до 1. В этом случае можно получить полином второго порядка, который потом функцией saturate также свести к диапазону значений [0, 1]. По всем трём цветам мы получаем следующий результат с учётом сатурации:


В итоге мы получаем, например, для красного цвета следующий полином:

half GetRedWeight(half x){    return saturate(0.5764348105166407 +     0.4761860550080825 * (-15.571636738012254 + 0.0285718367412005 * x) -     0.06265740390367036 * pow(-15.571636738012254 + 0.0285718367412005 * x, 2));}

А на практике с sampleCount=3:


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


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

Оптимизация


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

Выглядит это примерно так:

bool isNotAberrated = abs(offsetDecrement.x * g_VS_ScreenSize.x) < 1.0 && abs(offsetDecrement.y * g_VS_ScreenSize.y) < 1.0;if (isNotAberrated){    OUT.Color.rgb = tex2Dlod(baseMap, half4(IN.baseTC, 0, 0)).rgb;    return OUT;}

Оптимизация небольшая, но очень гордая.

Заключение


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

Пишем простой 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'а:

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

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

Подробнее..

Перевод Сравнивайте

08.01.2021 00:07:24 | Автор: admin

Капитан очевидность

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

Важность референсов

Представьте, что вы работаете над чем-то (новая фича? крупная оптимизация, которая немного жертвует качеством? новый пайпалайн?) в течение нескольких дней. У вас есть некоторые многообещающие результаты. Арт-директору нравится. Осталась только пара мелких изменений, и вы представите всем свой результат. Вы смотрите на свои результаты ежедневно (ну, на самом деле, вы смотрите на них всё время), но вдруг вы решили попросить другого художника или программиста помочь вам оценить вашу работу, и вы начинаете обсуждать/спорить/сомневаться, должно ли оно выглядеть именно так?

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

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

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

НЕ автоматизированное тестирование

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

Референсная версия

Итак, что подразумевается под этой референсной версией? Я имею в виду, что вам нужно brute-force решение для задачи, над которой вы работаете. Наивная, без каких-либо аппроксимаций/оптимизаций, требующая, возможно, даже нескольких секунд вместо целевых 16/33 миллисекунд.

Годами люди, работавшие над 3D графикой в играх, не использовали референсные версии, и для этого есть прекрасное объяснение. Игры были далеки от CGI рендеринга, так что не было никаких причин для сравнения результатов. Хорошая графика в играх была результатом хитроумных хаков, трюков, оптимизаций и интересных арт решений. Таким образом, многие программисты и художники старой школы всё ещё имеют привычку просто проверить, что нечто выглядит норм, или довести это нечто до такого состояния. Хотя художественные решения всегда будут наиболее важной частью визуализации 3D, мы изучили всю мощь физически корректного шейдинга и начали использовать техники такие, как GI / AO /PBR / площадные источники света, и другие, и поэтому у нас нет пути назад, некоторые трюки должны быть переделаны в терминах физической/математической корректности. К счастью, мы можем сравнить их с эталонами.

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

Площадные источники света

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

Только подумайте, насколько просто реализовать цикл хотя бы размера 64x64, который интегрирует площадный источник света, суммируя точечные источники. Он будет работать 10 секунд на вашей GTX Titan, но вы сможете незамедлительно сравнить вашу аппроксимацию с эталоном. Вы увидите краевые случаи, где есть расхождение с ожидаемым результатом, и сможете оценить ваше решение с помощью вашей системы источников света.

Вы даже можете провести вычисления на CPU и создать сетку из 64x64 источников света, поддерживающих тени, и проверить ошибку в (полу-) тенях у этих площадных источников света. Насколько это полезно для проверки ваших PCSS полутеней?

(Анти) алиасинг

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

Возможно, у вас есть какой-то баг в MSAA, может, ваш AA на основе изображения весьма плох в движении, или, может, ваши веса в темпоральном AA совсем неверные? Возможно, ваша реализация AA для диффуза или спекуляра не точна, или в ней есть опечатка? Может быть, вертексный или пиксельный шейдер, сделанный художником, добавляет некоторый процедурный алиасинг? А может, у вас есть алиасинг шейдинга, связанный с нормалями геометрии (распространённые техники, такие как Toksvig, работают только в пространстве карты нормалей)? Возможно, ваш алгоритм карты теней добавляет некоторое мерцание/темпоральную нестабильность?

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

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

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

  • In-place суперсемплинг. Олдскульная техника, которая базируется либо на сшивании изображений/тайлов (тайловые скриншоты в Unreal Engine), либо на субпиксельных сдвигах (в игре Ведьмак 2 для скриншотов используется этот суперсемплинг, и кадр отрисовывается 256 раз!)

У меня довольно большой опыт со вторым методом (поскольку он хорошо работает с эффектами пост-процесса, которые требуют размытия, например, bloom), чтобы сделать всё правильно, надо не забывать маленький и простой трюк: добавьте отрицательное смещение mip-уровней (~log2 от уровня суперсемплинга по одной оси) и смещение для LODов геометрии. Факт, который я нахожу забавным: мы сделали этот вариант рендеринга в Ведьмак 2 в качестве настройки графики для игроков в будущем (мы по-настоящему гордились финальной графикой в игре и думали, что будет замечательно, если она будет выглядеть прекрасно и через 10 лет, верно?), но большая часть ПК энтузиастов возненавидели нас за это! Они выставляли все опции на максимум, чтобы протестировать свои ПК за 3-5k$ (и оправдать расходы), но эта опция внезапно (даже не смотря на предупреждение в меню) уменьшала производительность GPU, например, в 4 раза.

Глобальное освещение

Вероятно, наиболее спорная тема из-за сложности реализации. Я не буду здесь обсуждать все потенциальные проблемы, но разработка референсной реализации GI (глобального освещения) может занять недели, а отрисовка займёт секунды/минуты. Ваши материалы могут выглядеть иначе. CPU/GPU решения требуют совершенно разных реализаций.

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

Дела обстоят проще для тех, кто использует Maya/другой 3D редактор в качестве игрового движка, но, вероятно, это проблематично для всех остальных. Тем не менее, вы можете работать над этим шаг за шагом - сделать простой BVH/kd-Tree и вычислять AO с помощью трассировки лучей, это должно быть довольно просто для написания (максимум пара дней). Это поможет вам оценить ваш SSAO и алгоритмы AO большего масштаба. В будущем вы можете расширить своё решение для вычисления GI с несколькими отражениями света. С PBR и играми нового поколения, я думаю, это будет решающим фактором с позиции реального ускорения вашего R&D отдела и финального продакшена, поскольку художники, привыкшие работать с CGI/фильмами, будут получать те же правильные результаты прямо в игровом движке.

Функции ДФОС

Прекрасный пример представил Brian Karis на последнем курсе Physically Based Shading на конференции Siggraph 2013 по теме ДФОС окружения. Интегрируя по полной полусфере значения ДФОС для входящего излучения от вашей карты окружения, вы можете увидеть, как оно действительно должно выглядеть. Я рекомендую делать это без какой-либо выборки по значимости для начала, потому что вы можете допустить ошибку или ввести погрешность/смещение таким методом!

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

Реализация/удобство использования

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

  • Удобство реализации

  • Удобство сравнения

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

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

Каждый случай отличается от других : возможно написание референсного интегратора ДФОС займёт минуты, а референсы глобального освещения (скриншоты/смена режима в реальном времени) могут занять недели для завершения. По этой причине, я только могу дать вам совет подходить к этому разумно.

Ещё одна вещь для размышления - наличие в движке или редакторе поддержки/фреймворка, который делает использование сравнения различных пассов проще. Только взгляните на такое приложение для фотографий, как прекрасный Adobe Lightroom. Там есть как слайдер, чтобы разделить изображения с разными режимами, так и возможность расположить сравниваемые изображения на разных мониторах.

Также всегда доступна кнопка предпросмотр. Она может быть полезна в других местах: представьте, как наличие такой кнопки для освещения/эффектов пост-процесса сделает проще жизнь ваших художников по освещению! Один клик для сравнения с тем, что он сделал 10 минут назад - прекрасная помощь для ответа на классический вопрос двигаюсь ли я в правильном направлении?. Наличие такого инструмента в вашем цикле разработки, вероятно, не то что нужно сделать немедленно; вам понадобится помощь программистов, занимающихся инструментами, но я думаю, что это окупится довольно быстро.

Итог

Наличие референсных версий поможет вам в ваших разработках и оптимизациях. Эталонная версия - объективная референсная точка в отличие от суждений людей, которые могут быть смещены, субъективны или зависят от эмоциональных/не технических факторов (посмотрите список когнитивных искажений в психологии! Удивительная проблема, которую нужно учитывать, работая не только с другими людьми, но и в одиночку). Реализация референсной версии может занять различное время (от минут, до недель) и, возможно, иногда нужно сделать слишком много работы, так что к этому вопросу нужно подходить с умом (особенно если вы работаете на производстве, не в академической среде), но просто не забывать об этом поможет вам решить некоторые проблемы и объяснить их другим людям (художникам, другим программистам).

Подробнее..

Компьютерное зрение в промышленной дефектоскопии Часть 2 Генерируем стремные трубы чтобы порадовать нейронку

18.02.2021 18:15:13 | Автор: admin


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


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

Заметка от партнера IT-центра МАИ и организатора магистерской программы VR/AR & AI компании PHYGITALISM.


Введение


Напомним, что генератор фотореалистичных 3D моделей промышленных труб и их дефектов нужен был для того, чтобы обогатить набор обучающих данных для нейросетевых алгоритмов детекции дефектов на изображениях (дефекты труб на ТЭЦ, снятые при помощи беспилотников). Беспилотники и автоматическая детекция дефектов на практике должна минимизировать время и расходы на проведение сервисного обслуживания станции.


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


Если данные сгенерированы корректно, то генератор позволяет:


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

Поскольку генератор позволяет получать 3D объекты, он способен стать источником новых данных не только для алгоритмов классического компьютерного зрения (CV), но и для целого ряда задач геометрического глубокого обучения (3D ML, GDL).


Применение 3D ML подходов может дать преимущество при решении задач дефектоскопии, так как пространственные сканеры / камеры глубины (RGB-D, Lidar и пр.) позволяют находить менее очевидные человеческому глазу дефекты и реконструировать изучаемые объекты (например, вздутие трубы не всегда можно обнаружить, не потрогав трубу руками или чувствительным щупом).


Часть 1: Реализация генератора данных



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


Вся работа по созданию искусственного набора данных была осуществлена в Blender, с использованием скриптов на языке Python. Исключение составила лишь программа преобразования растровой разметки в формат Yolo, написанная на языке Rust.


Минутка текстовых шуток для IT-шников:
В нашем проекте Python генерировал змеевики, а Rust получал разметку ржавчины.


Рис.2 Рабочее окно Blender с плагином генератора труб.


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



Рис. 3 Пример сцены, из которой рендерились наборы изображений с разметкой поперечных трещин.


Задача создания генератора синтетических данных была разделена на следующие этапы:


  • Настройка цифрового двойника камеры БПЛА с накамерным светом (рендеринг итоговых изображений должен позволять добиться реалистичности в синтетических данных).
  • Создание инструмента для быстрого моделирования базовой геометрии труб.
  • Настройка процедурных материалов для различных поверхностей труб (металлический блеск, ржавчина и пр.).
  • Настройка процедурных материалов для различных дефектов труб (различные трещины, дыры, изгибы и пр.).
  • Настройка процедурной анимации позиции камеры, освещения и материалов для создания разнообразных изображений из одной сцены.
  • Настройка масок дефектов (битовые маски и ограничивающие прямоугольники для разных классов дефектов).
  • Рендер итоговых сцен.
  • Приведение разметки дефектов к форматам YOLO и MS COCO.

Настройка цифрового двойника камеры БПЛА с накамерным светом



Рис.4 Камера с осветителем на сцене в Blender.


Объект с самосветящимся материалом, имитирующий кольцевой осветитель, и всенаправленная лампа назначены дочерними объектами камеры. Размер сенсора, угол обзора, относительное отверстие объектива и разрешение получаемого изображения настроены в соответствии с реальными характеристиками камеры DJI Mavic 2 Zoom.


Инструмент для быстрого моделирования базовой геометрии труб



Рис.5 Сцена, наполненная змеевиками труб, полученными с помощью скрипта из этого раздела.


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


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


Код для процедурной генерации труб:
import bpyimport timefrom math import sin, cos, pi, radians# Генерация змеевиков в плоскости внутри прямоугольника со сторонами# sizeX, sizeYdef create_flat_curve(sizeX, sizeY):    points = []    for y in range(sizeY):        for x in range(sizeX):            if y % 2 == 0:                point = [x, y]            else:                point = [sizeX - x - 1, y]            points.append(point)    curve_name = "Pipe_Flat_" + str(time.time_ns())    curveData = bpy.data.curves.new(curve_name, type='CURVE')    curveData.dimensions = '3D'    curveData.resolution_u = 2    polyline = curveData.splines.new('NURBS')    polyline.points.add(len(points)-1)    for i, point in enumerate(points):        x,y = point        polyline.points[i].co = (x, y, 0, 1)    curveData.bevel_depth = 0.4    #polyline.use_endpoint_u = True    curveOB = bpy.data.objects.new(curve_name, curveData)    bpy.context.collection.objects.link(curveOB)# Генерация змеевиков в пространствеdef create_cyl_curve(radius, angle, height, density, horizontal):    phi = radians(angle)    steps = int(density * phi / (pi*2))     print("Steps:", steps)    points = []    if horizontal:        for z in range(height):            for step in range(steps):                if z % 2 == 0:                    x = radius * cos(step * phi / steps)                    y = radius * sin(step * phi / steps)                    else:                    x = radius * cos(phi - (step+1) * phi / steps)                    y = radius * sin(phi - (step+1) * phi / steps)                  point = [x, y, z]                points.append(point)    if not horizontal:        for step in range(steps):            for z in range(height):                x = radius * cos(step * phi / steps)                y = radius * sin(step * phi / steps)                    if step % 2 == 0:                    point = [x, y, height-z-1]                else:                    point = [x, y, z]                points.append(point)    print("Points:", len(points))    curve_name = "Pipe_Cylinder_" + str(time.time_ns())    curveData = bpy.data.curves.new(curve_name, type='CURVE')    curveData.dimensions = '3D'    curveData.resolution_u = 2    polyline = curveData.splines.new('NURBS')    polyline.points.add(len(points)-1)    for i, point in enumerate(points):        x,y,z = point        polyline.points[i].co = (x, y, z, 1)    curveData.bevel_depth = 0.3    #polyline.use_endpoint_u = True    curveOB = bpy.data.objects.new(curve_name, curveData)    bpy.context.collection.objects.link(curveOB)

Настройка материалов поверхностей труб


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


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

Запекание сгенерированных текстур, например из Substance Painter, было исключено чтобы не множить инструменты и сущности (такая вот бритва Оккама у нас вышла).



Рис.6 Группа нод (назовем ее супернодой) для настройки материалов, объединенные в одну большую ноду.


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


Примечание:

Здесь и далее для конструирования шейдеров используется интерфейс с нодами, поскольку разработчик данного решения CG художник, и этот подход был для него предпочтительным =)



Рис. 7 Содержание суперноды из рис.6: материалы внутри группы собирались преимущественно из шумов и градиентов.


Настройка материалов дефектов труб



Рис.8 Пример сгенерированных труб с дефектами коррозии (слева) и цветами побежалости (справа).


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



Рис.9 Создание дефекта Разрыв трубы в Blender.


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



Рис.10 Создание дефекта Выход трубы из ряда в Blender.


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


Анимация камеры, освещение и материалы


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



Рис.11 Применение шума на анимационных кривых позиции, поворота камеры, интенсивности и позиции источника света для процедурной съемки сцены.


Настройка масок дефектов


Для вывода черно-белых масок разметки дефектов использовался канал Arbitrary Output Value (AOV), в ноду которого подавался коэффициент смешивания базового материала и материала дефекта. Иногда использовалась бинарная математическая нода Greater Than (на выходе 0, если входное значение меньше порогового, иначе 1).


Рендер


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



Рис.12 Директории с сохраненными изображениями (слева) и разметкой (справа).


Приведение разметки к формату YOLO


Формат разметки YOLO предполагает обозначение участков изображения ограничивающими прямоугольниками. Текстовый файл должен содержать нормированные координаты центров ограничивающих прямоугольников и их габариты. Для получения такого вида разметки была написана программа, рекурсивно проходящая по соседним пикселям маски, значения которых отличны от нуля, и сохраняющая минимальные и максимальные координаты связанных пикселей, после чего абсолютные координаты вершин прямоугольников нормализовались. Выбор языка Rust для написания этой программы был обусловлен скоростью выполнения и возможностью с лёгкостью реализовать одновременную обработку нескольких изображений на разных потоках процессора. Ниже приведен код на Python для поиска группы пикселей изображения, относящейся к одному дефекту.



Рис.13 Сгенерированные трубы с дефектом трещины (слева) и соответствующая маска для данного изображения (справа).


Код с поиском масок дефектов на Python (предполагается что на входе имеется изображение как на рис.13 справа):
import bpyimport colorsysimage = bpy.data.images["two_cubes.png"]sizeX = 64sizeY = 64image.scale(sizeX, sizeY)pixels = image.pixelssize = [sizeX, sizeY]print(len(pixels))grid = [[ [0] for y in range(size[1])] for x in range(size[0])] print("LEN:", len(grid))print(len(pixels)/4, " == ", sizeX*sizeY)def rgb_to_hex(rgb):    hex_string = ""    for c in rgb:        hex_string += str(hex(max(min(int(c * 255 + 0.5), 255), 0)))[2::]     return hex_string.upper()def search_neighbours(grid, x, y, color, l,b,r,t):    grid[x][y] = 0    #print("FROM", x, y)    if x < l:        l = x    if x > r:        r = x    if y < b:        b = y    if y > t:        t = y    if x < size[0]-1:            if grid[x+1][y] == color:        #print("RIGHT")            l,b,r,t = search_neighbours(grid, x+1, y, color, l, b, r, t)    if y < size[1]:        if grid[x][y+1] == color:        #print("UP")            l,b,r,t = search_neighbours(grid, x, y+1, color, l, b, r, t)    if x > 0:        if grid[x-1][y] == color:        #print("LEFT")            l,b,r,t = search_neighbours(grid, x-1, y, color, l, b, r, t)    if y > 0:        if grid[x][y-1] == color:        #print("DOWN")            l,b,r,t = search_neighbours(grid, x, y-1, color, l, b, r, t)    return (l,b,r,t)  for i in range(0, int(len(pixels) / 4)):    if pixels[i*4] > 0:#sum(pixels[i*4:i*4+3]) > 0:        x = (i) % size[1]        y = int(i / size[1])        #color = pixels[i*4:i*4+2]        #hex_col = rgb_to_hex(pixels[i*4:i*4+3])        grid[x][y] = 1              #print(x,y)print("GRID FINISHED")        color = 1islands = []for y in range(size[1]):    for x in range(size[0]):        if grid[x][y] == color:            "LOOKING FOR NEW ISLAND"            print(search_neighbours(grid, x, y, color, x, y, x, y))

Часть 2: Создание масок и разметки в Blender



Рис.14 Исходная тестовая сцена в Blender.


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


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


1.Комбинированные Рендер-пассы


Рис. 15 Combined Pass: итоговый рендер сцены с учетом всех компонент.


2. Глубина сцены


Рис.16 Depth Pass: карта глубины для данной сцены.


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


3. Карты нормалей


Рис. 17 Normal Pass: карта нормалей сцены.


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


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


4. Диффузная составляющая


Рис. 18 Diffuse Color Pass: рассеивающая составляющая материалов (например высокое значение у зеркальных металлических поверхностей и низкое значение у шероховатых диэлектриков).


5. Бликовая составляющая


Рис. 19 Glossy Color Pass: отражающая способность материала (блики).


6. Имитирующая составляющая


Рис. 20 Emission Pass: самосвечение материалов.


7. Суммарная интенсивность света


Рис. 21 Ambient Occlusion Pass: суммарная интенсивность света в каждой точке.


8. Теневая составляющая


Рис. 22 Shadow Pass: тени (для каждой точки пространства просчитываются относительно источников света на сцене).


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


Маски можно создавать на индивидуальные объекты и их группы, материалы, а также на произвольные параметры материалов.


9. Маска индивидуальных объектов (instance segmentation)


Рис. 23 Cryptomatte Object Pass: разметка различных объектов случайным цветом.


10. Разметка объектов по классам материалов


Рис. 24 Cryptomatte Material Pass: разметка различных материалов случайными цветами.


В пассах Cryptomatte всем объектам и материалам присваиваются уникальные цвета.


Допустим, мы хотим создать две маски: на одной будут отмечены все обезьянки, на другой геометрические примитивы. Всем объектам нужно назначить Object ID (он же Pass Index), для обезьянок это будет 1, для примитивов 2, 0 останется для пола. Для удобства объекты разных классов можно распределить по коллекциям и написать скрипт, который присваивает всем объектам коллекции свой Object ID.



Чтобы получить необходимую маску, нужно использовать ноду ID Mask в композиторе.
Также в композиторе можно настроить одновременный вывод пассов и масок в отдельные файлы (см. рисунок ниже).



11. Разметка объектов по категориям (semantic segmentation)


Рис. 25 monkeys mask: маскирование объектов класса обезьяна.



Рис. 26 primitives mask: маскирование объектов класса геометрические примитивы.


Если мы хотим отметить каждый интересующий нас объект по отдельности, им нужно присвоить свои уникальные Object ID.


12. AOV - Arbitrary Output Value

Blender позволяет выводить в изображение любые параметры материалов. Для этого в настройках пассов нужно создать слой AOV, в который будут сохраняться значения из шейдеров в виде RGB или числа с плавающей точкой. В соответствующем материале нужно создать ноду Output/AOV и присвоить ей имя слоя.


Разберём этот материал:

Рис. 27 Combined Pass, умноженный на маску материала.



Здесь текстура шума подана на параметр Scale шейдера подповерхностного рассеивания (см. рис. выше). Допустим, мы хотим получить маску на те области поверхности, в которых параметр Scale больше 1.8.


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


Проделаем теперь подобное с другим материалом и выделим красные области, подав в AOV фактор смешивания синего и красного цветов:


AOV даёт более широкие возможности для разметки, этот подход можно использовать для обозначения областей объектов, подверженных смещению (Displacement). На объектах на изображении ниже использовалось смещение по нормали поверхности для имитации повреждений:


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



Отдельным примером может служить использование AOV для разметки повреждённых областей объектов, на которых основной материал заменяется на прозрачный. На этой обезьянке применено трёхмерное смещение (Vector Displacement), то есть каждый участок, подверженный такому эффекту смещается не по нормали к исходной поверхности, а по трём осям согласно значениям из цвета, подаваемого на вход (R,G и B соответствуют X, Y и Z).





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




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


Заключение



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


В будущем мы постараемся рассказать и про другие наши эксперименты связанные с 3D ML вобще и с Blender в частности, а пока можете подписаться на наш канал в Telegram 3D ML, где мы рассказываем несколько раз в неделю о новостях и достижениях в этой науке)

Подробнее..

Категории

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

  • Имя: Макс
    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