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

Рендеринг

Перевод Как обеспечить глассморфизм с помощью HTML и CSS

27.04.2021 16:10:26 | Автор: admin

Вы, наверное, подумаете ну вот еще один тренд дизайна? Разве они у нас не каждый год появляются или около того?

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

Но давайте поговорим немного больше о глассморфизме.

Что такое глассморфизм?

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

Вот пример:

Это пример из библиотеки CSS UI, основанной на глассморфизме, называемой ui.glass.

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

Другим примером является редизайн Facebook Messenger App с использованием глассморфизма для MacOS:

Редизайн был выполнен Mikoaj Gaziowski на Dribbble.

Глассморфизм также используется такими компаниями как Apple и Microsoft

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

Microsoft также использует этот стиль в Fluent Design System, но они называют его "акриловым материалом", а не глассморфизмом.

Вот как это выглядит:

Итак, теперь, когда я вкратце познакомил вас с гласcморфизмом, позвольте показать, как можно применить этот эффект, используя только HTML и CSS.

Давайте начнем

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

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

Начнем с создания HTML-файла со следующим кодом:

<!DOCTYPE html><html lang="en"><head>  <meta charset="UTF-8">  <meta http-equiv="X-UA-Compatible" content="IE=edge">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <title>Glassmorphism effect</title></head><body>    <!-- code goes here --></body></html>

Здорово! Теперь давайте также добавим пользовательский стиль шрифта, включая Inter из Google Fonts:

<link rel="preconnect" href="http://personeltest.ru/aways/fonts.gstatic.com"><link href="http://personeltest.ru/aways/fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">

Настройка некоторых основных стилей и фона для тега body:

body {  padding: 4.5rem;  margin: 0;  background: #edc0bf;  background: linear-gradient(90deg, #edc0bf 0,#c4caef 58%);  font-family: 'Inter', sans-serif;}

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

<div class="card">    <h3 class="card-title">Glassmorphism is awesome</h3>    <p>A modern CSS UI library based on the glassmorphism design principles that will help you quickly design and build beautiful websites and applications.</p>    <a href="http://personeltest.ru/aways/ui.glass">Read more</a></div>

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

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

.card {  width: 400px;  height: auto;  padding: 2rem;  border-radius: 1rem;}.card-title {  margin-top: 0;  margin-bottom: .5rem;  font-size: 1.2rem;}p, a {  font-size: 1rem;}a {  color: #4d4ae8;  text-decoration: none;}

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

Создание эффекта глассморфизма, используя HTML и CSS

Для применения эффекта нужны только два важных свойства CSS: прозрачный фон и свойства backdrop-filter: blur(10px); . Степень размытости или прозрачности может быть изменена в зависимости от ваших предпочтений.

Добавьте к элементу .card следующие стили:

.card {/* other styles */background: rgba(255, 255, 255, .7);-webkit-backdrop-filter: blur(10px);backdrop-filter: blur(10px);}

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

Давайте добавим изображение сразу после запуска тега body :

<img class="shape" src="http://personeltest.ru/aways/s3.us-east-2.amazonaws.com/ui.glass/shape.svg" alt="">

Затем примените следующие стили к элементу .shape с помощью CSS:

.shape {  position: absolute;  width: 150px;  top: .5rem;  left: .5rem;}

Потрясающе! Окончательный результат должен выглядеть так:

Если вам нужен код для этого туториала, посмотрите на этот код.

Поддержка браузера

Одним из недостатков нового тренда дизайна является то, что Internet Explorer не поддерживает свойство backdrop-filter, а Firefox отключает его по умолчанию.

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

Вы можете проверить поддержку браузеров на сайте caniuse.com.

Заключение

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

Мы с моим другом из Themesberg работали над новой библиотекой CSS UI, которая будет использовать новое направление глассморфизма в дизайне, называемое ui.glass. Она будет иметь открытый исходный код под лицензией MIT.

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

Спасибо за чтение! Оставьте свои мысли о глассморфизме в разделе комментариев ниже.


Прямо сейчас в OTUS открыт набор на онлайн-курс "HTML/CSS".

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

- Узнать подробнее о курсе "HTML/CSS"

- Смотреть вебинар CSS Reset ненужный артефакт или спасательный круг

Подробнее..

Перевод Доступный toggle

14.05.2021 14:06:19 | Автор: admin

Перевод подготовлен в рамках онлайн-курса"HTML/CSS".

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


Toggles (или их еще называют "тумблеры"/"переключатели") широко используются в современных интерфейсах. Они, как правило, относительно просты, и их можно рассматривать как простые флажки (checkbox). Тем не менее, их часто делают недоступными тем или иным способом.

В этой статье я покажу небольшую имплементацию доступного toggle на HTML + CSS, которую вы можете применить в своих проектах и доработать по своему усмотрению.

  • Разметка

  • Стилизация

  • Контейнер

  • The toggle и дескриптор handle

  • Стили для фокуса

  • Проверенное состояние

  • Отключенное состояние

  • Поддержка право-лево

  • Иконки

  • Вариант кнопки

  • Завершение

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

Разметка

Как всегда, давайте начнем с HTML. В данном случае мы начнем с самых основ, а именно с правильно обозначенного флажка. Это input с <label>, с правильными атрибутами и видимой меткой.

Если toggle вызывает немедленное действие (например, переключение темы) и поэтому зависит от JavaScript, то вместо него следует использовать <button>. Обратитесь к варианту кнопки для получения дополнительной информации о разметке - стили, по сути, одинаковы. Спасибо Adrian Roselli за то, что он обратил наше внимание на это!

<label class="Toggle" for="toggle">  <input type="checkbox" name="toggle" id="toggle" class="Toggle__input" />  This is the label</label>

Стоит отметить, что это не единственный способ разметки такого компонента интерфейса. Например, вместо него можно использовать 2 radio inputs. Sara Soueidan более подробно рассказывает о проектировании и создании toggle.

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

Мы будем использовать небольшой контейнер между вводом данных и текстовой меткой, который будет содержать 2 иконки: галочку и крестик (взяты из иконок Material UI). Затем мы создадим toggle handle с псевдоэлементом, который будет охватывать одну из иконок за раз.

<label class="Toggle" for="toggle">  <input type="checkbox" name="toggle" id="toggle" class="Toggle__input" />  <span class="Toggle__display" hidden>    <svg      aria-hidden="true"      focusable="false"      class="Toggle__icon Toggle__icon--checkmark"      width="18"      height="14"      viewBox="0 0 18 14"      fill="none"      xmlns="http://personeltest.ru/away/www.w3.org/2000/svg"    >      <path        d="M6.08471 10.6237L2.29164 6.83059L1 8.11313L6.08471 13.1978L17 2.28255L15.7175 1L6.08471 10.6237Z"        fill="currentcolor"        stroke="currentcolor"      />    </svg>    <svg      aria-hidden="true"      focusable="false"      class="Toggle__icon Toggle__icon--cross"      width="13"      height="13"      viewBox="0 0 13 13"      fill="none"      xmlns="http://personeltest.ru/away/www.w3.org/2000/svg"    >      <path        d="M11.167 0L6.5 4.667L1.833 0L0 1.833L4.667 6.5L0 11.167L1.833 13L6.5 8.333L11.167 13L13 11.167L8.333 6.5L13 1.833L11.167 0Z"        fill="currentcolor"      />    </svg>  </span>  This is the label</label>

Следует отметить несколько моментов, связанных с нашей разметкой:

  • Мы используем aria-hidden="true" для наших SVG, потому что они не должны обнаруживаться вспомогательными технологиями, так как являются сугубо декоративными.

  • Мы также используем focusable="false" для наших SVG, чтобы избежать проблем с Internet Explorer, где SVG по умолчанию фокусируются.

  • Мы используем hidden для контейнера .Toggle__display , чтобы скрыть его, когда CSS недоступен, поскольку он должен вернуться к базовому флажку. Его значение отображения будет переопределено в CSS.

Стили

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

  • Контейнер - это обертка <label>, которая содержит как toggle, так и текстовую метку (.Toggle).

  • Toggle - это визуальный тумблер, зеленый или красный в зависимости от статуса, с 2 иконками (.Toggle__display).

  • Handle - это круглый диск, охватывающий одну из иконок и перемещающийся влево и вправо при взаимодействии с toggle (.Toggle__display::before).

  • Input - Вход - это HTML <input>, который визуально скрыт, но остается доступным и фокусируемым (.Toggle__input).

Контейнер

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

/** * 1. Vertically center the toggle and the label. `flex` could be used if a  *    block-level display is preferred. * 2. Make sure the toggle remains clean and functional even if the label is *    too wide to fit on one line. Thanks @jouni_kantola for the heads up! * 3. Grant a position context for the visually hidden and absolutely *    positioned input. * 4. Provide spacing between the toggle and the text regardless of layout *    direction. If browser support is considered insufficient, use *    a right margin on `.Toggle__display` in LTR, and left margin in RTL. *    See: https://caniuse.com/flexbox-gap */.Toggle {  display: inline-flex; /* 1 */  align-items: center; /* 1 */  flex-wrap: wrap; /* 2 */  position: relative; /* 3 */  gap: 1ch; /* 4 */}

Toggle и handle

Затем - наш toggle. Чтобы облегчить настройку его стилей, мы используем некоторые пользовательские свойства CSS для перемещения вокруг handle и его диаметра.

/** * 1. Vertically center the icons and space them evenly in the available  *    horizontal space, essentially giving something like: [   ] * 2. Size the display according to the size of the handle. `box-sizing` *    could use `border-box` but the border would have to be considered *    in the `width` computation. Either way works. * 3. For the toggle to be visible in Windows High-Contrast Mode, we apply a *    thin semi-transparent (or fully transparent) border. *    Kind thanks to Adrian Roselli for the tip: *    https://twitter.com/aardrian/status/1379786724222631938?s=20 * 4. Grant a position context for the pseudo-element making the handle. * 5. Give a pill-like shape with rounded corners, regardless of the size. * 6. The default state is considered unchecked, hence why this pale red is *    used as a background color. */.Toggle__display {  --offset: 0.25em;  --diameter: 1.8em;  display: inline-flex; /* 1 */  align-items: center; /* 1 */  justify-content: space-around; /* 1 */  width: calc(var(--diameter) * 2 + var(--offset) * 2); /* 2 */  height: calc(var(--diameter) + var(--offset) * 2); /* 2 */  box-sizing: content-box; /* 2 */  border: 0.1em solid rgb(0 0 0 / 0.2); /* 3 */  position: relative; /* 4 */  border-radius: 100vw; /* 5 */  background-color: #fbe4e2; /* 6 */  transition: 250ms;  cursor: pointer;}/** * 1. Size the round handle according to the diameter custom property. * 2. For the handle to be visible in Windows High-Contrast Mode, we apply a *    thin semi-transparent (or fully transparent) border. *    Kind thanks to Adrian Roselli for the tip: *    https://twitter.com/aardrian/status/1379786724222631938?s=20 * 3. Absolutely position the handle on top of the icons, vertically centered *    within the container and offset by the spacing amount on the left. * 4. Give the handle a solid background to hide the icon underneath. This *    could be dark in a dark mode theme, as long as its solid. */.Toggle__display::before {  content: '';  width: var(--diameter); /* 1 */  height: var(--diameter); /* 1 */  border-radius: 50%; /* 1 */  box-sizing: border-box; /* 2 */  border: 0.1 solid rgb(0 0 0 / 0.2); /* 2 */  position: absolute; /* 3 */  z-index: 2; /* 3 */  top: 50%; /* 3 */  left: var(--offset); /* 3 */  transform: translate(0, -50%); /* 3 */  background-color: #fff; /* 4 */  transition: inherit;}

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

@media (prefers-reduced-motion: reduce) {  .Toggle__display {    transition-duration: 0ms;  }}

Стили для фокуса

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

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

/** * 1. When the input is focused, provide the display the default outline *    styles from the browser to mimic a native control. This can be *    customised to have a custom focus outline. */.Toggle__input:focus + .Toggle__display {  outline: 1px dotted #212121; /* 1 */  outline: 1px auto -webkit-focus-ring-color; /* 1 */}

Я заметил одну интересную вещь: при нажатии на родной флажок или его метку контур фокуса не появляется. Это происходит только при фокусировке флажка с помощью клавиатуры. Мы можем имитировать это поведение, удалив стили, которые мы только что применили, когда селектор :focus-visible не подходит.

/** * 1. When the toggle is interacted with with a mouse click (and therefore *    the focus does not have to be visible as per browsers heuristics), *    remove the focus outline. This is the native checkboxs behaviour where *    the focus is not visible when clicking it. */.Toggle__input:focus:not(:focus-visible) + .Toggle__display {  outline: 0; /* 1 */}

Проверенное состояние

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

/** * 1. When the input is checked, change the display background color to a *    pale green instead.  */.Toggle__input:checked + .Toggle__display {  background-color: #e3f5eb; /* 1 */}/** * 1. When the input is checked, slide the handle to the right so it covers *    the cross icon instead of the checkmark one. */.Toggle__input:checked + .Toggle__display::before {  transform: translate(100%, -50%); /* 1 */}

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

Отключенное состояние

Наконец, мы можем добавить несколько пользовательских стилей, чтобы сделать отключенный toggle более явным.

/** * 1. When the input is disabled, tweak the toggle styles so it looks dimmed  *    with less sharp colors, softer opacity and a relevant cursor. */.Toggle__input:disabled + .Toggle__display {  opacity: 0.6; /* 1 */  filter: grayscale(40%); /* 1 */  cursor: not-allowed; /* 1 */}

Поддержка право-лево

Изначально я забыл о поддержке право-лево, и Adrian Roselli был достаточно любезен, чтобы указать мне на это, поэтому я обновил код. В идеале мы должны использовать псевдо-класс :dir() , но, к сожалению, на данный момент браузеры поддерживают его довольно плохо, поэтому нам приходится полагаться на селектор атрибута [dir].

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

/** * 1. Flip the original position of the unchecked toggle in RTL. */[dir='rtl'] .Toggle__display::before {  left: auto; /* 1 */  right: var(--offset); /* 1 */}/** * 1. Move the handle in the correct direction in RTL. */[dir='rtl'] .Toggle__input:checked + .Toggle__display::before {  transform: translate(-100%, -50%); /* 1 */}

Иконки

Наконец, мы применим некоторые стили к нашим иконкам, как рекомендует Florens Verschelde в своем фантастическом руководстве по SVG-иконкам:

.Toggle__icon {  display: inline-block;  width: 1em;  height: 1em;  color: inherit;  fill: currentcolor;  vertical-align: middle;}/** * 1. The cross looks visually bigger than the checkmark so we adjust its *    size. This might not be needed depending on the icons. */.Toggle__icon--cross {  color: #e74c3c;  font-size: 85%; /* 1 */}.Toggle__icon--checkmark {  color: #1fb978;}

Вариант кнопки

Как упоминалось ранее, использование флажка не обязательно является наиболее подходящей разметкой. Если toggle имеет немедленный эффект (и поэтому полагается на JavaScript), и если он не может иметь неопределенное состояние, то вместо него следует использовать элемент <button> с атрибутом aria-pressed.

Adrian Roselli в своем материале о toggles предлагает дерево решений для выбора между флажком и кнопкой.

К счастью, наш код легко адаптировать, чтобы он работал так же, как и кнопка. Во-первых, мы изменим HTML таким образом, что <label> станет <button>, а <input> будет удален.

<button class="Toggle" type="button" aria-pressed="false">  <span class="Toggle__display" hidden>    <!-- The toggle does not change at all -->  </span>  This is the label</button>

Затем нам нужно убедиться, что <button> не похожа на саму кнопку. Для этого мы сбросим стили кнопки по умолчанию, включая контур фокуса, поскольку он применяется при toggle (переключении).

/** * 1. Reset default <button> styles. */button.Toggle {  border: 0; /* 1 */  padding: 0; /* 1 */  background: transparent; /* 1 */  font: inherit; /* 1 */}/** * 1. The focus styles are applied on the toggle instead of the container, so *    the default focus outline can be safely removed. */.Toggle:focus {  outline: 0; /* 1 */}

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

+ .Toggle:focus .Toggle__display,.Toggle__input:focus + .Toggle__display {  /*  */}+ .Toggle:focus:not(:focus-visible) .Toggle__display,.Toggle__input:focus:not(:focus-visible) + .Toggle__display {  /*  */}+ .Toggle[aria-pressed="true"] .Toggle__display::before,.Toggle__input:checked + .Toggle__display::before {  /*  */}+ .Toggle[disabled] .Toggle__display,.Toggle__input:disabled + .Toggle__display {  /*  */}+ [dir="rtl"] .Toggle[aria-pressed="true"] + .Toggle__display::before,[dir="rtl"] .Toggle__input:checked + .Toggle__display::before {  /*  */}

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

Заключение

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

  • Мы используем реальный элемент формы флажка, который мы стилизуем под toggle.

  • Он передает свой статус с помощью иконографии и цвета.

  • Он не оставляет артефактов, когда CSS недоступен.

  • Он имеет собственные стили по фокусу и может быть настроен.

  • У него есть отключенное состояние.

  • При необходимости он имеет поддержку право-лево.

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

Здорово! Не стесняйтесь играть с кодом на CodePen, и я надеюсь, что это поможет вам сделать ваши toggles доступными. А также, я рекомендую прочитать эти статьи, чтобы продвинуться дальше:

Примечание

Dion упоминает, что toggle может выглядеть наоборот, и это мнение поддерживает Rawrmonstar, а Mikael Kundert упоминает, что использование флажков обычно проще.


Узнать подробнее о курсе"HTML/CSS"

Смотреть открытый урокCSS Reset ненужный артефакт или спасательный круг

Подробнее..

Код на React и TypeScript, который работает быстро. Доклад Яндекса

12.01.2021 12:17:05 | Автор: admin
Евангелисты Svelte и других библиотек любят показывать примеры тормозящих компонентов на React. React и TypeScript дают много возможностей создавать медленный код. После доклада Виктора victor-homyakov вы сможете писать более производительные компоненты без усложнения кода.

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

О преждевременной оптимизации




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

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



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



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



О чем же будет мой доклад? В первую очередь, наши языки, TypeScript и JavaScript, и наши библиотеки и фреймворки подразумевают, что практически у каждой задачи есть несколько вариантов решений. Все они правильные, все дают нужный результат, но не все одинаково эффективны.

Видеть эти варианты заранее и выбирать нужные, а не выбирать заведомо плохой, это не преждевременная оптимизация. Те тривиальные приемы, про которые я расскажу, дают при консистентном использовании до 5% производительности кода. Это по данным реальных проектов, которые переходили со старых стеков на использование React и TypeScript. Первая часть про React.

React


Лишние ререндеры


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


Источник

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



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

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



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

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


Источник

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

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

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

const Foo = () => (    <Consumer>{({foo, update}) => (...)}</Consumer>);const Bar = () => (    <Consumer>{({bar, update}) => (...)}</Consumer>);const App = () => (    <Provider value={...}>        <Foo />        <Bar />    </Provider>);

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

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


Ссылка со слайда

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

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


Источник

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


Ссылка со слайда

Разработчиками React и контекста предусмотрен способ, как это предотвратить.

Есть битовые маски. При задании контекста мы указываем функцию, которая указывает в битовой маске, что именно изменилось в контексте. И в конкретном Context Consumer мы можем указать битовую маску, которая будет фильтровать изменения и ререндерить вложенный компонент, только если изменились те биты, которые нам нужны.


Ссылка со слайда

Пакет, который называется Why Did You Render, это однозначный must have для всех, кто борется с лишними ререндерами. Он лежит в NPM, ставится довольно легко и в режиме разработчика позволяет в консоли Developer Tools браузера отследить все компоненты, которые перерендериваются, хотя фактически содержимое props и state у них не изменилось.

Вот пример скриншота. Это тот же антипаттерн, когда мы генерируем на каждый рендер новый объект в атрибуте style. При этом в консоли выведется предупреждение, что props фактически не изменились, а изменились только по ссылке, и вы этого ререндера могли избежать.

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



  • Пакет Why Did You Render. Это must have в любом проекте, у любого разработчика на React.
  • В Developer Tools браузера Chrome можно включить опцию Paint flashing. Тогда он будет подсвечивать те области экрана, которые перерисовались. Вы визуально заметите, что и как часто у вас ререндерится.
  • Самое убойное средство это в каждый рендер вставить console.log. Это позволяет оценить, сколько вообще у вас ререндеров: и нужных, и ненужных.
  • И еще одна вещь: часто забываемый второй параметр в React.memo. Это функция, которая позволит вручную написать код сравнения props с предыдущими и самому возвращать true/false, то есть дополнительно к сравнению по ссылке сравнивать какое-то содержимое. Функция аналогична методу shouldComponentUpdate для классовых компонентов.

HTML-комментарии


Следующий интересный момент комментарии в HTML-коде, который сгенерирован на сервере.

ReactDOMServer.renderToString(    <div>{someVar}bar</div>);<div data-reactroot="">foo<!-- -->bar</div>

В местах склейки статического текста и текста из JavaScriptовых переменных React вставляет HTML-комментарий. Это сделано, чтобы безболезненно гидрировать такие места на клиенте.

ReactDOMServer.renderToString(    <div>{`${someVar}bar`}</div>);<div data-reactroot="">foobar</div>

Если вам нужно удалить такой комментарий, то вы склеиваете строки в JS-коде и вставляете в JSX всю склеенную строку, как в этом примере. Почему это важно?


Источник

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

То есть при server-side rendering мы вынуждены потратить лишние ресурсы процессора, лишнюю память и лишнее время на то, чтобы их отрендерить. Мы должны передать на клиент эту лишнюю разметку, а браузер должен эти три килобайта распарсить. И пока страница будет открыта, браузер будет держать их в памяти, потому что они присутствуют в дереве DOM документа.

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

HOC


function withEmptyFc(WrappedComponent) {    return props => <WrappedComponent {...props} />;}function withEmptyCc(WrappedComponent) {    class EmptyHoc extends React.Component {        render() {            return <WrappedComponent {...this.props} />;        }    }    return EmptyHoc;}

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



Если замерить производительность server-side rendering, то пустая кнопка, классическая кнопка HTML, рендерится 0,9 микросекунды. Если мы ее обернем в пустой HOC, который не делает ничего, то увидим, что это уже добавляет замедление в рендеринг.

А если мы в этот HOC добавим еще и полезной нагрузки (приведен пример реального HOC из нашего проекта), то увидим, что скорость рендеринга замедлилась еще больше. Почему так происходит?



При server side rendering и при первом рендеринге на клиенте HOC всегда делает вызов React.createElement. Это довольно сложная функция, которая выполняет довольно много работы внутри самой библиотеки React. Она не может не занимать дополнительного времени.

Также происходит копирование props. Мы снаружи HOC получили какие-то props и должны сформировать новые props для вложенного в HOC компонента. Это тоже занимает время.



При ререндере у нас никуда не делся React.createElement. Также HOC добавляет обертку в дереве. Сравнение с предыдущим деревом и обход дерева замедляет работу с ним.



В итоге на продакшене это может выглядеть как результат угара по HOC. Только половина разметки в дереве это полезная нагрузка, а оставшаяся половина это context consumer, context provider и разнообразные HOC.

То есть React работает с деревом, которое стало в два раза больше, чем без HOC. Он не может не тратить дополнительное время на обработку этого дерева.



И еще один важный момент. Если мы напишем слишком сложный HOC, то можем наткнуться на полную замену дерева при ререндере вместо update предыдущего. Расскажу про это немножко подробнее.

switch (workInProgress.tag) {  case IndeterminateComponent: {    //     return mountIndeterminateComponent();  }  case FunctionComponent: {    //     return updateFunctionComponent();  }  case ClassComponent: {    //     return updateClassComponent();  }

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

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



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

Изоморфный код



Про изоморфный код. Евангелисты изоморфизма не очень любят углубляться в детали того, как же их изоморфный код работает на наших серверах и наших клиентах. Проблема в том, что мы контролируем наш бэкенд, можем на нем доставить свежую Node.js, которая понимает последний диалект ECMAScript. В то же время на клиенте до сих пор значительная доля древних браузеров, например Internet Explorer 11 или старые Android: четвертый и немножко новее. Поэтому клиентам до сих пор все равно очень часто нужно отдавать ES5.

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

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

Мы бы хотели, когда пишем изоморфный код на TypeScript, так настроить сборку, чтобы наш TypeScript компилировался в максимально свежий диалект для Node.js. Чтобы именно этот скомпилированный код исполнялся на Node.js при server side rendering. И чтобы для браузеров TypeScript компилировался в подходящий диалект, ES5 или чуть более новый, если вы собираете разные версии кода для старых и новых браузеров.



Если же мы пишем сразу на ECMAScript, то можем нативно писать для Node.js, и в этом случае бонусом будет то, что нам не нужны никакие системы сборки и бандлинга. Мы сразу пишем код, который нативно понимается Node.js. Node.js умеет использовать модульные системы: CommonJS через require, ESM через import. И нам надо только скомпилировать в ES5 для браузеров и собрать в бандлы.

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

TypeScript


Дизайн языка


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



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

Spread operator


О чем я хотел бы сказать в первую очередь, это оператор Spread.


Он очень часто используется в коде на React. Но то, что его легко написать, не означает, что его так же легко выполнять.



Потому что при компиляции такого кода TypeScript запишет в модуль на ES5, во-первых, реализацию метода __assign, а во-вторых, его вызов. То есть фактически воткнет полифил для Object.assign.

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

И еще одна проблема: Object.assign, если вы знаете, означает клонирование объекта. Клонирование объекта выполняется не за константное время. Чем сложнее объект, чем больше в нем полей, тем больше времени будет занимать клонирование. И с этим связан такой пример фейла.

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



Проблема в том, что на каждой итерации мы выполняем клонирование предыдущего объекта. И соответственно, на N+1 итерации мы вынуждены будем склонировать объект, в котором уже N полей.

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

Бывает еще вот такой фейл при использовании spread с массивами.



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

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



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

// TS:res = {...obj, a: 1};// компилируется в ES5:res = __assign(__assign({}, obj), {a: 1});// хотелось бы:res = __assign({}, obj, {a: 1});// илиres = __assign({}, obj);res.a = 1;

Если же порядок поменяется, это будет означать уже два вложенных вызова assign. Хотя мы хотели бы один вызов или вообще запись поля a в объект результата. Почему так происходит? Напоминаю, что генерация оптимального кода не цель написания и развития языка TypeScript. Он просто обязан учитывать гипотетические крайние случаи: например, когда в объекте есть getter и поэтому он строит универсальный код, который в любых случаях работает правильно, но медленно.



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

Rest operator


Двоюродный родственник Spread-оператора это Rest. Те же три точечки, но по-другому.


У нас в коде это чаще всего используется в деструктурировании. Вот один из примеров. Здесь под капотом, чтобы получить объект otherProps, надо выполнить следующую нетривиальную работу: из объекта props скопировать в новый объект otherProps все поля, название которых не равно prop1, prop2 или prop3.

Чувствуете, к чему я клоню? При компиляции в ES5 получается примерно такой код:

var blackList = ['prop1', 'prop2', 'prop3'];var otherProps = {};// Цикл по всем полямfor (var p in props)    if (        hasOwnProperty(props, p) &&        // Вложенный цикл  поиск в массиве indexOf(p)        blackList.indexOf(p) < 0    )        otherProps[p] = props[p];

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

Нативная поддержка Rest в новых Node.js и новых браузерах не спасает. Вот пример бенчмарка (к сожалению, сейчас сайт jsperf.com лежит), который показывает, что даже примитивная реализация Rest с помощью вспомогательных функций чаще всего работает не медленнее, а даже быстрее нативного кода, который сейчас реализован в Node.js и браузерах.



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

// хотелось бы ES5:Component.prototype.fn1 = function(path) {    utils.fn2.apply(utils, arguments);};

Мы бы хотели, чтобы TypeScript понимал такие кейсы и генерировал вызов apply, передавая в него arguments.

// получаем замедление в ES5:Component.prototype.fn1 = function(path) {    var vars = [];    for (var _i = 1; _i < arguments.length; _i++) {        vars[_i - 1] = arguments[_i];    }    utils.fn2.apply(utils, __spreadArrays([path], vars));};

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

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

=> вместо bind


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



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


Источник

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



  • Самый очевидный минус: каждый конструктор тратит время на создание этой новой функции.
  • Ее код не шарится между экземплярами. Он существует в стольких же экземплярах, сколько у вас создано экземпляров MyComponent.
  • Вместо N вызовов одной функции вы получаете по одному вызову N функций в каждом из независимых экземпляров. То есть оптимизатор на такую функцию внимания не обращает, не хочет ее инлайнить или оптимизировать. Она выполняется медленно.

Это только минусы в производительности. Но я еще не закончил.



С наследованием такого кода появляются проблемы:

  • Если в классе-потомке мы создадим метод onClick, он будет затерт в конструкторе предка.
  • Если мы все-таки как-то создадим метод, то все равно не сможем вызвать super.onClick, потому что на прототипе метода не существует.
  • Хоть как-то переопределить onClick в классе-потомке, опять же, можно только через стрелочную функцию.

Это еще не все минусы.


Источник

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

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

@boundMethod вместо bind


Хорошо, тогда разработчики говорят: у нас есть декораторы. В частности, такой интересный декоратор @boundMethod, который вместо нас магически привязывает контекст к нашему методу.

import {boundMethod} from 'autobind-decorator';class Component {    @boundMethod    method(): number {        return this.value;    }}

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

const boundFn = fn.bind(this);Object.defineProperty(this, key, {    get() {        return boundFn;    },    set(value) {        fn = value;        delete this[key];    }});

Он все равно вызывает bind. И в придачу определяет getter и setter с именем вашего метода. Можно сразу сказать, что getter и setter никогда не работали быстрее, чем обычное чтение и запись поля.

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

class Base extends Component {    @boundMethod    method() {}}class Child extends Base {    method = debounce(super.method, 100);}

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



В DevTools это выглядит примерно так. Мы видим, что в памяти накапливаются старые экземпляры компонента Child. И если посмотреть в одном экземпляре, как у него выглядит этот метод, то мы увидим целую цепочку из bind-function-debounced-bind-function-debounced- и так далее. И в каждом из этих debounced в замыканиях содержатся предыдущие экземпляры Child. Вот вам утечка памяти на ровном месте, когда можно было ее избежать.


Ссылка со слайда

Задним числом хотелось бы сказать: перед тем, как вы решили использовать эту библиотеку в продакшене, хотелось бы посмотреть на то, как работает ее код. Одного знания, что ее код вместо одного вызова bind делает такие вещи, как getter и setter, было бы достаточно, чтобы не хотеть ее использовать.

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

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

TL;DR


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

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

У меня все. Вот ссылка на документ со всеми упомянутыми материалами.
Подробнее..

Перевод Освещение в VFX и видеоиграх сравнение подходов к рендерингу

06.08.2020 10:11:52 | Автор: admin
Сяоя Чжао художница по спецэффектам, успевшая поработать как в кинопроизводстве, так и в игровой разработке (Halo Infinite), и над созданием рекламных кампаний для таких брендов, как Audi, Nike и PlayStation.

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



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





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

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


Процесс освещения в рекламе


  1. Организуем съемку, подготавливаем и калибруем HDRI. На этом шаге необходимо убедиться, что у нас есть HDRI-снимки каждой локации, готовые для освещения. В названии каждого файла должны быть указаны время и место съемки.
  2. Создаем риги освещения для каждой локации или кадра. Теперь, когда у нас есть заранее подготовленные HDRI, можно добавить камеру, задний фон (backplate) для каждого кадра, хромовый шар, прямое освещение и плоскости отражения для того, чтобы убедиться, что мы можем точно совместить CG-шары с теми, что мы использовали при живой съемке.
  3. Рендерим первый проход освещения с анимацией и составляем предварительную композицию для рендеринга с заранее отснятым задним фоном. На этом этапе нужно убедиться, что все настройки рендеринга выставлены правильно. Добавляем последовательность анимации и накладываем слои рендера, чтобы удостовериться, что он не вредит композиции освещения.
  4. Производим второй проход с учетом замечаний руководства.
  5. Просчитываем последний проход с учетом всех заключительных замечаний, затем отдаем все проходы в отдел композитинга, помогаем довести проект до финального вида и сдать его в срок.




Процесс освещения в видеоиграх


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

Следующие шаги по освещению обычно характерны для видеоигр:

  1. Проход по освещению блокаутов (примитивов). Это просто базовая поддержка геймплея. Убедитесь, что игроки могут увидеть все ассеты вокруг себя. Никаких темных областей. На данном этапе вам не нужно соответствовать настроению концепции игры.
  2. Промежуточный проход. Здесь мы начинаем формировать внешний облик окружения, запекаем карты отражений, добавляем отскоки непрямого освещения.
  3. Бета-проход по освещению (завершающий). Учитываем все замечания свыше, проверяем производительность, не выходим ли мы за рамки бюджета, затем продолжаем тестировать отдельные блоки игры.
  4. Устранение багов освещения. Исправление ошибок освещения в соответствии с пожеланиями игровых дизайнеров и директоров.

Обычно эти этапы зависят от прогресса в работе команды художников по окружению.




О проблемах освещения в видеоиграх


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

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

Из песочницы Основы компьютерной геометрии. Написание простого 3D-рендера

21.09.2020 22:05:33 | Автор: admin
Привет меня зовут Давид, а вот я собственной персоной отрендеренный своим самописным рендером:

image

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

Идея


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

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

Выбор языка изначально падал на c++ или rust, но я остановился на c# из-за простоты написания кода и широких возможностей для оптимизации. Итоговым продуктом данной статьи будет рендер, способный выдавать подобные картинки:

image

image

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

Математика


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

Повороты вектора. Матрица поворота


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

  • Поворот относительно начала координат
  • Поворот относительно некоторой точки

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

Давайте выведем формулы для вращения вектора в двумерном пространстве. Обозначим координаты исходного вектора {x, y}. Координаты нового вектора, повернутого на угол f, обозначим как {x y}.

image

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

image

Заметьте, что мы можем использовать формулы косинуса и синуса суммы для того, чтобы разложить значения x' и y'. Для тех, кто подзабыл я напомню эти формулы:

image

Разложив координаты повернутого вектора через них получим:

image

Здесь нетрудно заметить, что множители l * cos a и l * sin a это координаты исходного вектора: x = l * cos a, y = l * sin a. Заменим их на x и y:

image

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

image

Умножьте и проверьте что результат эквивалентен тому, что мы вывели.

Поворот в трехмерном пространстве


Мы рассмотрели поворот в двумерном пространстве, а так же вывели матрицу для него. Теперь возникает вопрос, а как получить подобные преобразования для трех измерений? В двумерном случае мы вращали вектора на плоскости, здесь же бесконечное количество плоскостей относительно которых мы можем это сделать. Однако существует три базовых типа вращений, при помощи которых можно выразить любой поворот вектора в трехмерном пространстве это XY, XZ, YZ вращения.

XY вращение.

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

image

Заметьте, что при таком вращении z координаты векторов не меняются, а меняются x и x координаты поэтому это и называется XY вращением.

image

Нетрудно вывести и формулы для такого вращения: z координата остается прежней, а x и y изменяются по тем же принципам, что и в 2д вращении.

image

То же в виде матрицы:

image

Для XZ и YZ вращений все аналогично:

image
image

Проекция


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

В том понимании которое мы используем здесь проекция на вектор это тоже вектор. Его координаты точка пересечения перпендикуляра опущенного из вектора a на b с вектором b.

image

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

image

Направление вектора проекции по определению совпадает с вектором b, значит проекция определяется формулой:

image

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

Теперь представим все через скалярное произведение:

image

Получаем удобную формулу для нахождения проекции:

image

Системы координат. Базисы


Многие привыкли работать в стандартной системе координат XYZ, в ней любые 2 оси будут перпендикулярны друг другу, а координатные оси можно представить в виде единичных векторов:

image

На деле же систем координат бесконечное множество, каждая из них является базисом. Базис n-мерного пространства является набором векторов {v1, v2 vn} через которые представляются все вектора этого пространства. При этом ни один вектор из базиса нельзя представить через другие его вектора. По сути каждый базис является отдельной системой координат, в которой вектора будут иметь свои, уникальные координаты.

Давайте разберем, что из себя представляет базис для двумерного пространства. Возьмём для примера всем знакомую декартову систему координат из векторов X {1, 0}, Y {0, 1}, которая является одним из базисов для двумерного пространства:

image


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

image


Теперь возьмём другой базис:

image


Через его вектора также можно представить любой 2д вектор:

image


А вот такой набор векторов не является базисом двухмерного пространства:

image


В нем два вектора {1,1} и {2,2} лежат на одной прямой. Какие бы их комбинации вы не брали получать будете только вектора, лежащие на общей прямой y = x. Для наших целей такие дефектные не пригодятся, однако, понимать разницу, я считаю, стоит. По определению все базисы объединяет одно свойство ни один из векторов базиса нельзя представить в виде суммы других векторов базиса с коэффициентами или же ни один вектор базиса не является линейной комбинацией других. Вот пример набора из 3-х векторов который так же не является базисом:

image


Через него можно выразить любой вектор двумерной плоскости, однако вектор {1, 1} в нем является лишним так как сам может быть выражен через вектора {1, 0} и {0,1} как {1,0} + {0,1}.

Вообще любой базис n-мерного пространства будет содержать ровно n векторов, для 2д это n соответственно равно 2.

Перейдем к 3д. Трехмерный базис будет содержать в себе 3 вектора:

image


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

  • 1)2 вектора не лежат на одной прямой
  • 2)3-й не лежит на плоскости образованной двумя другими.


С данного момента базисы, с которыми мы работаем будут ортогональными (любые их вектора перпендикулярны) и нормированными (длина любого вектора базиса 1). Другие нам просто не понадобятся. К примеру стандартный базис

image


удовлетворяет этим критериям.

Переход в другой базис


До сих пор мы записывали разложение вектора как сумму векторов базиса с коэффициентами:

image

Снова рассмотрим стандартный базис вектор {1, 3, 6} в нем можно записать так:

image

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

image


Этот базис получен из стандартного применением к нему XY вращения на 45 градусов. Возьмем вектор a в стандартной системе имеющий координаты {0 ,1, 1}

image


Через вектора нового базиса его можно разложить таким образом:

image


Если вы посчитаете эту сумму, то получите {0, 1, 1} вектор а в стандартном базисе. Исходя из этого выражения в новом базисе вектор а имеет координаты {0.7, 0.7, 1} коэффициенты разложения. Это будет виднее если взглянуть с другого ракурса:

image


Но как находить эти коэффициенты? Вообще универсальный метод это решение довольно сложной системы линейных уравнений. Однако как я сказал ранее использовать мы будем только ортогональные и нормированные базисы, а для них есть весьма читерский способ. Заключается он в нахождении проекций на вектора базиса. Давайте с его помощью найдем разложение вектора a в базисе X{0.7, 0.7, 0} Y{-0.7, 0.7, 0} Z{0, 0, 1}

image


Для начала найдем коэффициент для y. Первым шагом мы находим проекцию вектора a на вектор y (как это делать я разбирал выше):

image


Второй шаг: делим длину найденной проекции на длину вектора y, тем самым мы узнаем сколько векторов y помещается в векторе проекции это число и будет коэффициентом для y, а также y координатой вектора a в новом базисе! Для x и z повторим аналогичные операции:

image


Теперь мы имеем формулы для перехода из стандартного базиса в новый:

image


Ну а так как мы используем только нормированные базисы и длины их векторов равны 1 отпадет необходимость делить на длину вектора в формуле перехода:

image


Раскроем x-координату через формулу проекции:

image


Заметьте, что знаменатель (x', x') и вектор x' в случае нормированного базиса так же равен 1 и их можно отбросить. Получим:

image


Мы видим, что координата x базисе выражается как скалярное произведение (a, x), координата y соответственно как (a, y), координата z (a, z). Теперь можно составить матрицу перехода к новым координатам:

image


Системы координат со смещенным центром


У всех систем координат которые мы рассмотрели выше началом координат была точка {0,0,0}. Помимо этого существуют еще системы со смещенной точкой начала координат:

image


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

Пишем геометрический движок. Создание проволочного рендера.



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

image


Полигональная графика


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

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

    abstract class Primitive    {        public Vector3[] Vertices { get; protected set; }        public int[] Indexes { get; protected set; }    }

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

   public class Cube : Primitive      {        public Cube(Vector3 center, float sideLen)        {            var d = sideLen / 2;            Vertices = new Vector3[]                {                    new Vector3(center.X - d , center.Y - d, center.Z - d) ,                    new Vector3(center.X - d , center.Y - d, center.Z) ,                    new Vector3(center.X - d , center.Y , center.Z - d) ,                    new Vector3(center.X - d , center.Y , center.Z) ,                    new Vector3(center.X + d , center.Y - d, center.Z - d) ,                    new Vector3(center.X + d , center.Y - d, center.Z) ,                    new Vector3(center.X + d , center.Y + d, center.Z - d) ,                    new Vector3(center.X + d , center.Y + d, center.Z + d) ,                };            Indexes = new int[]                {                    1,2,4 ,                    1,3,4 ,                    1,2,6 ,                    1,5,6 ,                    5,6,8 ,                    5,7,8 ,                    8,4,3 ,                    8,7,3 ,                    4,2,8 ,                    2,8,6 ,                    3,1,7 ,                    1,7,5                };        }    }int Main(){        var cube = new Cube(new Vector3(0, 0, 0), 2);}

image

Реализуем системы координат


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

image

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

При работе с объектом мы будем производить операции с его вершинами в локальной системе координат, при рендеринге будем предварительно переводить все объекты сцены в единую систему координат глобальную. Добавим системы координат в код. Для этого создадим объект класса Pivot (стержень, точка опоры) который будет представлять локальный базис объекта и его центральную точку. Перевод точки в систему координат представленную Pivot будет производиться в 2 шага:

  • 1)Представление точки относительно центра новых координат
  • 2)Разложение по векторам нового базиса

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

  • 1)Разложение по векторам глобального базиса
  • 2)Представление относительно глобального центра

Напишем класс для представления систем координат:

    public class Pivot    {        //точка центра        public Vector3 Center { get; private set; }        //вектора локального базиса - локальные координатные оси        public Vector3 XAxis { get; private set; }        public Vector3 YAxis { get; private set; }        public Vector3 ZAxis { get; private set; }        //Матрица перевода в локальные координаты        public Matrix3x3 LocalCoordsMatrix => new Matrix3x3            (                XAxis.X, YAxis.X, ZAxis.X,                XAxis.Y, YAxis.Y, ZAxis.Y,                XAxis.Z, YAxis.Z, ZAxis.Z            );        //Матрица перевода в глобальные координаты        public Matrix3x3 GlobalCoordsMatrix => new Matrix3x3            (                XAxis.X , XAxis.Y , XAxis.Z,                YAxis.X , YAxis.Y , YAxis.Z,                ZAxis.X , ZAxis.Y , ZAxis.Z            );        public Vector3 ToLocalCoords(Vector3 global)        {            //Находим позицию вектора относительно точки центра и раскладываем в локальном базисе            return LocalCoordsMatrix * (global - Center);        }        public Vector3 ToGlobalCoords(Vector3 local)        {            //В точности да наоборот - раскладываем локальный вектор в глобальном базисе и находим позицию относительно глобального центра            return (GlobalCoordsMatrix * local)  + Center;        }        public void Move(Vector3 v)        {            Center += v;        }        public void Rotate(float angle, Axis axis)        {            XAxis = XAxis.Rotate(angle, axis);            YAxis = YAxis.Rotate(angle, axis);            ZAxis = ZAxis.Rotate(angle, axis);        }    }

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

    public abstract class Primitive    {        //Локальный базис объекта        public Pivot Pivot { get; protected set; }        //Локальные вершины        public Vector3[] LocalVertices { get; protected set; }        //Глобальные вершины        public Vector3[] GlobalVertices { get; protected set; }        //Индексы вершин        public int[] Indexes { get; protected set; }        public void Move(Vector3 v)        {            Pivot.Move(v);            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] += v;        }        public void Rotate(float angle, Axis axis)        {            Pivot.Rotate(angle , axis);            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);        }        public void Scale(float k)        {            for (int i = 0; i < LocalVertices.Length; i++)                LocalVertices[i] *= k;            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);        }    }

image

Вращение и перемещение объекта с помощью локальных координат

Рисование полигонов. Камера


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

image

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

image

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

image


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

image


Теперь вернемся к нашей камере. Представьте, что к оси z координат камеры прикреплена проекционная плоскость на расстоянии z' от начала координат. Формула такой плоскости z = z', ее можно задать одним числом z'. На эту плоскость падают лучи от вершин различных объектов. Попадая на плоскость луч будет оставлять на ней точку. Соединяя такие точки можно нарисовать объект.

image


Такая плоскость будет представлять экран. Координату проекции вершины объекта на экран будем находить в 2 этапа:

  • 1)Переводим вершину в локальные координаты камеры
  • 2)Находим проекцию точки через отношение подобных треугольников

image


Проекция будет 2-мерным вектором, ее координаты x' и y' и будут определять позицию точки на экране компьютера.

Класс камеры 1
public class Camera{    //локальные координаты камеры    public Pivot Pivot { get; private set; }    //расстояние до проекционной плоскости    public float ScreenDist { get; private set; }    public Camera(Vector3 center, float screenDist)    {        Pivot = new Pivot(center);        ScreenDist = screenDist;    }    public void Move(Vector3 v)    {        Pivot.Move(v);    }    public void Rotate(float angle, Axis axis)    {        Pivot.Rotate(angle, axis);    }    public Vector2 ScreenProection(Vector3 v)    {        var local = Pivot.ToLocalCoords(v);        //через подобные треугольники находим проекцию        var delta = ScreenDist / local.Z;        var proection = new Vector2(local.X, local.Y) * delta;        return proection;    }}


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

Отсекаем невидимые полигоны


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

image


Для отсечения невидимых вершин в open gl используются метод усекающей пирамиды. Заключается он в задании двух плоскостей ближней(near plane) и дальней(far plane). Все, что лежит между этими двумя плоскостями будет подлежать дальнейшей обработке. Я же использую упрощенный вариант с одной усекающей плоскостью z'. Все вершины, лежащие позади нее будут невидимыми.

Добавим в камеру два новых поля ширину и высоту экрана.
Теперь каждую спроецированную точку будем проверять на попадание в область экрана. Так же отсечем точки позади камеры. Если точка лежит сзади или ее проекция не попадает на экран то метод вернет точку {float.NaN, float.NaN}.

Код камеры 2
public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию    var delta = ScreenDist / local.Z;    var proection = new Vector2(local.X, local.Y) * delta;    //если точка принадлежит экранной области - вернем ее    if (proection.X >= 0 && proection.X < ScreenWidth && proection.Y >= 0 && proection.Y < ScreenHeight)    {        return proection;    }    return new Vector2(float.NaN, float.NaN);}


Переводим в экранные координаты


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

image


Код камеры 3
public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию    var delta = ScreenDist / local.Z;    var proection = new Vector2(local.X, local.Y) * delta;    //этот код нужен для перевода проекции в экранные координаты    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);    var screenCoords = new Vector2(screen.X, -screen.Y);    //если точка принадлежит экранной области - вернем ее    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)    {        return screenCoords;    }    return new Vector2(float.NaN, float.NaN);}


Корректируем размер спроецированного изображения


Если вы используете предыдущий код для того, чтобы нарисовать объект то получите что-то вроде этого:

image


Почему то все объекты рисуются очень маленькими. Для того, чтобы понять причину вспомните как мы вычисляли проекцию умножали x и y координаты на дельту отношения z' / z. Это значит, что размер объекта на экране зависит от расстояния до проекционной плоскости z'. А ведь z' мы можем задать сколь угодно маленьким значением. Значит нам нужно корректировать размер проекции в зависимости от текущего значения z'. Для этого добавим в камеру еще одно поле угол ее обзора.

image


Он нам нужен для сопоставления углового размера экрана с его шириной. Угол будет сопоставлен с шириной экрана таким образом: максимальный угол в пределах которого смотрит камера это левый или правый край экрана. Тогда максимальный угол от оси z камеры составляет o / 2. Проекция, которая попала на правый край экрана должна иметь координату x = width / 2, а на левый: x = -width / 2. Зная это выведем формулу для нахождения коэффициента растяжения проекции:

image


Код камеры 4
public float ObserveRange { get; private set; }public float Scale => ScreenWidth / (float)(2 * ScreenDist * Math.Tan(ObserveRange / 2));public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию и умножаем ее на коэффициент растяжения    var delta = ScreenDist / local.Z * Scale;    var proection = new Vector2(local.X, local.Y) * delta;    //этот код нужен для перевода проекции в экранные координаты    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);    var screenCoords = new Vector2(screen.X, -screen.Y);    //если точка принадлежит экранной области - вернем ее    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)    {        return screenCoords;    }    return new Vector2(float.NaN, float.NaN);}


Вот такой простой код отрисовки я использовал для теста:

Код рисования объектов
public DrawObject(Primitive primitive , Camera camera){    for (int i = 0; i < primitive.Indexes.Length; i+=3)    {        var color = randomColor();        // индексы вершин полигона        var i1 = primitive.Indexes[i];        var i2 = primitive.Indexes[i+ 1];        var i3 = primitive.Indexes[i+ 2];        // вершины полигона        var v1 = primitive.GlobalVertices[i1];        var v2 = primitive.GlobalVertices[i2];        var v3 = primitive.GlobalVertices[i3];        // рисуем полигон        DrawPolygon(v1,v2,v3 , camera , color);    }}public void DrawPolygon(Vector3 v1, Vector3 v2, Vector3 v3, Camera camera , color){    //проекции вершин    var p1 = camera.ScreenProection(v1);    var p2 = camera.ScreenProection(v2);    var p3 = camera.ScreenProection(v3);    //рисуем полигон    DrawLine(p1, p2 , color);    DrawLine(p2, p3 , color);    DrawLine(p3, p2 , color);}


Давайте проверим рендер на сцене и кубов:

image


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

Результат работы рендера

image

image


Растеризация полигонов. Наводим красоту.



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

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

image


Современное искусство, полигоны сзади нарисовались поверх передних, одним словом каша. К тому же как таким образом текстурировать объекты? Да, никак. Значит нам нужно написать свой имба-растерайзер, который будет уметь в отсечение невидимых точек, текстуры и даже в шейдеры! Но для того чтобы это сделать стоит понять как вообще красить треугольники.

Алгоритм Брезенхема для рисования линии.


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

Имеется отрезок соединяющий точки {x1, y1} и {x2, y2}. Чтобы нарисовать отрезок между ними нужно закрасить все пиксели которые попадают на него. Для двух точек отрезка можно найти x-координаты пикселей в которых они лежат: нужно лишь взять целые части от координат x1 и x2. Чтобы закрасить пиксели на отрезке запускаем цикл от x1 до x2 и на каждой итерации вычисляем y координату пикселя который попадает на прямую. Вот код:

void Brezenkhem(Vector2 p1 , Vector2 p2){    int x1 = Floor(p1.X);    int x2 = Floor(p2.X);    if (x1 > x2) {Swap(x1, x2); Swap(p1 , p2);}    float d = (p2.Y - p1.Y) / (x2 - x1);    float y = p1.Y;    for (int i = x1; i <= x2; i++)    {        int pixelY = Floor(y);        FillPixel(i , pixelY);        y += d;    }}

image

Картинка из вики

Растеризация треугольника. Алгоритм заливки


Линии рисовать мы умеем, а вот с треугольниками будет чуть посложнее(не намного)! Задача рисования треугольника сводится к нескольким задачам рисования линий. Для начала разобьем треугольник на две части предварительно отсортировав точки в порядке возрастания x:

image


Заметьте теперь у нас есть две части в которых явно выражены нижняя и верхняя границы. все что осталось это залить все пиксели находящиеся между ними! Сделать это можно в 2 цикла: от x1 до x2 и от x3 до x2.

void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3){    //хардкодим BubbleSort для упорядочивания по x    if (v1.X > v2.X) { Swap(v1, v2); }    if (v2.X > v3.X) { Swap(v2, v3); }    if (v1.X > v2.X) { Swap(v1, v2); }    //узнаем на сколько увеличивается y границ при увеличении x    //избегаем деления на 0: если x1 == x2 значит эта часть треугольника - линия    var steps12 = max(v2.X - v1.X , 1);    var steps13 = max(v3.X - v1.X , 1);    var upDelta = (v2.Y - v1.Y) / steps12;    var downDelta = (v3.Y - v1.Y) / steps13;    //верхняя граница должна быть выше нижней    if (upDelta < downDelta) Swap(upDelta , downDelta);    //изначально у координаты границ равны y1    var up = v1.Y;    var down = v1.Y;    for (int i = (int)v1.X; i <= (int)v2.X; i++)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i , g);        }        up += upDelta;        down += downDelta;    }    //все то же самое для другой части треугольника    var steps32 = max(v2.X - v3.X , 1);    var steps31 = max(v1.X - v3.X , 1);    upDelta = (v2.Y - v3.Y) / steps32;    downDelta = (v1.Y - v3.Y) / steps31;    if (upDelta < downDelta) Swap(upDelta, downDelta);    up = v3.Y;    down = v3.Y;    for (int i = (int)v3.X; i >=(int)v2.X; i--)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i, g);        }        up += upDelta;        down += downDelta;    }}

Несомненно этот код можно отрефакторить и не дублировать цикл:

void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3){    if (v1.X > v2.X) { Swap(v1, v2); }    if (v2.X > v3.X) { Swap(v2, v3); }    if (v1.X > v2.X) { Swap(v1, v2); }    var steps12 = max(v2.X - v1.X , 1);    var steps13 = max(v3.X - v1.X , 1);    var steps32 = max(v2.X - v3.X , 1);    var steps31 = max(v1.X - v3.X , 1);    var upDelta = (v2.Y - v1.Y) / steps12;    var downDelta = (v3.Y - v1.Y) / steps13;    if (upDelta < downDelta) Swap(upDelta , downDelta);    TrianglePart(v1.X , v2.X , v1.Y , upDelta , downDelta);    upDelta = (v2.Y - v3.Y) / steps32;    downDelta = (v1.Y - v3.Y) / steps31;    if (upDelta < downDelta) Swap(upDelta, downDelta);    TrianglePart(v3.X, v2.X, v3.Y, upDelta, downDelta);}void TrianglePart(float x1 , float x2 , float y1  , float upDelta , float downDelta){    float up = y1, down = y1;    for (int i = (int)x1; i <= (int)x2; i++)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i , g);        }        up += upDelta; down += downDelta;    }}

Отсечение невидимых точек.


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

image


Для того, чтобы понять видима точки или нет, в рендеринге применяют механизм zbuffer-а(буфера глубины). zbuffer можно представить как двумерный массив (можно сжать в одномерный) с размерностью width * height. Для каждого пикселя на экране он хранит значение z координаты на исходном полигоне откуда эта точка была спроецирована. Соответственно чем ближе точка к наблюдателю, тем меньше ее z координата. В конечном итоге если проекции нескольких точек совпадают растеризировать нужно точку с минимальной z координатой:

image


Теперь возникает вопрос как находить z-координаты точек на исходном полигоне? Это можно сделать несколькими способами. Например можно пускать луч из начала координат камеры, проходящий через точку на проекционной плоскости {x, y, z'}, и находить его пересечение с полигоном. Но искать пересечения крайне затратная операция, поэтому будем использовать другой способ. Для рисования треугольника мы интерполировали координаты его проекций, теперь, помимо этого, мы будем интерполировать также и координаты исходного полигона. Для отсечения невидимых точек будем использовать в методе растеризации состояние zbuffer-а для текущего фрейма.

Мой zbuffer будет иметь вид Vector3[] он будет содержать не только z координаты, но и интерполированные значения точек полигона(фрагменты) для каждого пикселя экрана. Это сделано в целях экономии памяти так как в дальнейшем нам все равно пригодятся эти значения для написания шейдеров! А пока что имеем следующий код для определения видимых вершин(фрагментов):

Код
public void ComputePoly(Vector3 v1, Vector3 v2, Vector3 v3 , Vector3[] zbuffer){    //находим проекцию полигона    var v1p = Camera.ScreenProection(v1);    var v2p = Camera.ScreenProection(v2);    var v3p = Camera.ScreenProection(v3);    //упорядочиваем точки по x - координате    //Заметьте, также меняем исходные точки - они должны соответствовать проекциям    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }    if (v2p.X > v3p.X) { Swap(v2p, v3p); Swap(v2p, v3p); }    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }    //считаем количество шагов для построения линии алгоритмом Брезенхема    int x12 = Math.Max((int)v2p.X - (int)v1p.X, 1);    int x13 = Math.Max((int)v3p.X - (int)v1p.X, 1);    //теперь помимо проекций будем интерполировать и исходные точки    float dy12 = (v2p.Y - v1p.Y) / x12; var dr12 = (v2 - v1) / x12;    float dy13 = (v3p.Y - v1p.Y) / x13; var dr13 = (v3 - v1) / x13;    Vector3 deltaUp, deltaDown; float deltaUpY, deltaDownY;    if (dy12 > dy13) { deltaUp = dr12; deltaDown = dr13; deltaUpY = dy12; deltaDownY = dy13;}    else { deltaUp = dr13; deltaDown = dr12; deltaUpY = dy13; deltaDownY = dy12;}    TrianglePart(v1 , deltaUp , deltaDown , x12 , 1 , v1p , deltaUpY , deltaDownY , zbuffer);    //вторую часть треугольника аналогично - думаю вы поняли}public void ComputePolyPart(Vector3 start, Vector3 deltaUp, Vector3 deltaDown,    int xSteps, int xDir, Vector2 pixelStart, float deltaUpPixel, float deltaDownPixel , Vector3[] zbuffer){    int pixelStartX = (int)pixelStart.X;    Vector3 up = start - deltaUp, down = start - deltaDown;    float pixelUp = pixelStart.Y - deltaUpPixel, pixelDown = pixelStart.Y - deltaDownPixel;    for (int i = 0; i <= xSteps; i++)    {        up += deltaUp; pixelUp += deltaUpPixel;        down += deltaDown; pixelDown += deltaDownPixel;        int steps = ((int)pixelUp - (int)pixelDown);        var delta = steps == 0 ? Vector3.Zero : (up - down) / steps;        Vector3 position = down - delta;        for (int g = 0; g <= steps; g++)        {            position += delta;            var proection = new Point(pixelStartX + i * xDir, (int)pixelDown + g);            int index = proection.Y * Width + proection.X;            //проверка на глубину            if (zbuffer[index].Z == 0 || zbuffer[index].Z > position.Z)            {                zbuffer[index] = position;            }        }    }}


image

Анимация шагов растеризатора(при перезаписи глубины в zbuffer-е пиксель выделяется красным):

Для удобства я вынес весь код в отдельный модуль Rasterizer:

Класс растеризатора
    public class Rasterizer    {        public Vertex[] ZBuffer;        public int[] VisibleIndexes;        public int VisibleCount;        public int Width;        public int Height;        public Camera Camera;        public Rasterizer(Camera camera)        {            Shaders = shaders;            Width = camera.ScreenWidth;            Height = camera.ScreenHeight;            Camera = camera;        }        public Bitmap Rasterize(IEnumerable<Primitive> primitives)        {            var buffer = new Bitmap(Width , Height);            ComputeVisibleVertices(primitives);            for (int i = 0; i < VisibleCount; i++)            {                var vec = ZBuffer[index];                var proec = Camera.ScreenProection(vec);                buffer.SetPixel(proec.X , proec.Y);            }            return buffer.Bitmap;        }        public void ComputeVisibleVertices(IEnumerable<Primitive> primitives)        {            VisibleCount = 0;            VisibleIndexes = new int[Width * Height];            ZBuffer = new Vertex[Width * Height];            foreach (var prim in primitives)            {                foreach (var poly in prim.GetPolys())                {                    MakeLocal(poly);                    ComputePoly(poly.Item1, poly.Item2, poly.Item3);                }            }        }        public void MakeLocal(Poly poly)        {            poly.Item1.Position = Camera.Pivot.ToLocalCoords(poly.Item1.Position);            poly.Item2.Position = Camera.Pivot.ToLocalCoords(poly.Item2.Position);            poly.Item3.Position = Camera.Pivot.ToLocalCoords(poly.Item3.Position);        }    }


Теперь проверим работу рендера. Для этого я использую модель Сильваны из известной RPG WOW:

image


Не очень понятно, правда? А все потому что здесь нет ни текстур ни освещения. Но вскоре мы это исправим.

Текстуры! Нормали! Освещение! Мотор!


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

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

image

Заметьте, что начало текстуры (левый нижний пиксель) в текстурных координатах имеет значение {0, 0}, конец (правый верхний пиксель) {1, 1}. Учитывайте систему координат текстуры и возможность выхода за границы картинки когда текстурная координата равна 1.

Сразу создадим класс для представления данных вершины:

  public class Vertex    {        public Vector3 Position { get; set; }        public Color Color { get; set; }        public Vector2 TextureCoord { get; set; }        public Vector3 Normal { get; set; }        public Vertex(Vector3 pos , Color color , Vector2 texCoord , Vector3 normal)        {            Position = pos;            Color = color;            TextureCoord = texCoord;            Normal = normal;        }    }

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

текстурированная модель
image


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

Освещение



С текстурами все стало гораздо веселее, но по настоящему весело будет когда мы реализуем освещение для сцены. Для имитации дешевого освещения я буду использовать модель Фонга.

Модель Фонга


В общем случае этот метод имитирует наличие 3х составляющих освещения: фоновая(ambient), рассеянная(diffuse) и зеркальная(reflect). Сумма этих трех компонент в итоге даст имитацию физического поведения света.

image

Модель Фонга

Для расчета освещения по Фонгу нам будут нужны нормали к поверхностям, для этого я и добавил их в классе Vertex. Где же брать значения этих нормалей? Нет, ничего вычислять нам не нужно. Дело в том, что великодушные 3д редакторы часто сами считают их и предоставляют вместе с данными модели в контексте формата OBJ. Распарсив файл модели мы получаем значение нормалей для 3х вершин каждого полигона.

image

Картинка из вики

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

Фоновый свет (Ambient)


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

Рассеянный свет (Diffuse)


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

Код
public interface IShader    {        void ComputeShader(Vertex vertex, Camera camera);    }    public struct Light    {        public Vector3 Pos;        public float Intensivity;    }public class PhongModelShader : IShader    {        public static float DiffuseCoef = 0.1f;        public Light[] Lights { get; set; }        public PhongModelShader(params Light[] lights)        {            Lights = lights;        }        public void ComputeShader(Vertex vertex, Camera camera)        {            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)            {                return;            }            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);            foreach (var light in Lights)            {                var ldir = Vector3.Normalize(light.Pos - gPos);                var diffuseVal = Math.Max(VectorMath.Cross(ldir, vertex.Normal), 0) * light.Intensivity;                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R * diffuseVal * DiffuseCoef),                    (int)Math.Min(255, vertex.Color.G * diffuseVal * DiffuseCoef,                    (int)Math.Min(255, vertex.Color.B * diffuseVal * DiffuseCoef));            }        }    }


Давайте применим рассеянный свет и рассеем тьму:

image

Зеркальный свет (Reflect)


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

image

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

код
    public class PhongModelShader : IShader    {        public static float DiffuseCoef = 0.1f;        public static float ReflectCoef = 0.2f;        public Light[] Lights { get; set; }        public PhongModelShader(params Light[] lights)        {            Lights = lights;        }        public void ComputeShader(Vertex vertex, Camera camera)        {            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)            {                return;            }            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);            foreach (var light in Lights)            {                var ldir = Vector3.Normalize(light.Pos - gPos);                //Следующие три строчки нужны чтобы найти отраженный от поверхности луч                var proection = VectorMath.Proection(ldir, -vertex.Normal);                var d = ldir - proection;                var reflect = proection - d;                var diffuseVal = Math.Max(VectorMath.Cross(ldir, -vertex.Normal), 0) * light.Intensivity;                //луч от наблюдателя                var eye = Vector3.Normalize(-vertex.Position);                var reflectVal = Math.Max(VectorMath.Cross(reflect, eye), 0) * light.Intensivity;                var total = diffuseVal * DiffuseCoef + reflectVal * ReflectCoef;                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R * total),                    (int)Math.Min(255, vertex.Color.G * total),                    (int)Math.Min(255, vertex.Color.B * total));            }        }    }


Теперь картинка выглядит следующим образом:

image


Тени


Конечной точкой моего изложения будет реализация теней для рендера. Первая тупиковая идея которая зародилась у меня в черепушке для каждой точки проверять не лежит ли между ней и светом какой-нибудь полигон. Если лежит значит не нужно освещать пиксель. Модель Сильваны содержит 220к с лихвой полигонов. Если так для каждой точки проверять пересечение со всеми этими полигонами, то нужно сделать максимум 220000 * 1920 * 1080 * 219999 вызовов метода пересечения! За 10 минут мой компьютер смог осилить 10-у часть всех вычислений (2600 полигонов из 220000), после чего у меня случился сдвиг и я отправился на поиски нового метода.

В интернете мне попался очень простой и красивый способ, который выполняет те же вычисления в тысячи раз быстрее. Называется он Shadow mapping(построение карты теней). Вспомните как мы определяли видимые наблюдателю точки использовали zbuffer. Shadow mapping делает тоже самое! В первом проходе наша камера будет находиться в позиции света и смотреть на объект. Таким образом мы сформируем карту глубин для источника света. Карта глубин это знакомый нам zbuffer. Во втором проходе мы используем эту карту, чтобы определять вершины которые должны освещаться. Сейчас я нарушу правила хорошего кода и пойду читерским путем просто передам шейдеру новый объект растеризатора и он используя его создаст нам карту глубин.

Код
public class ShadowMappingShader : IShader{    public Enviroment Enviroment { get; set; }    public Rasterizer Rasterizer { get; set; }    public Camera Camera => Rasterizer.Camera;    public Pivot Pivot => Camera.Pivot;    public Vertex[] ZBuffer => Rasterizer.ZBuffer;    public float LightIntensivity { get; set; }    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)    {        Enviroment = enviroment;        LightIntensivity = lightIntensivity;        Rasterizer = rasterizer;        //я добвил события в объекты рендера, привязав к ним перерасчет карты теней        //теперь при вращении/движении камеры либо при изменение сцены шейдер будет перезаписывать глубину        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);        UpdateVisible(Enviroment.Primitives);    }    public void ComputeShader(Vertex vertex, Camera camera)    {        //вычисляем глобальные координаты вершины        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);        //дистанция до света        var lghDir = Pivot.Center - gPos;        var distance = lghDir.Length();        var local = Pivot.ToLocalCoords(gPos);        var proectToLight = Camera.ScreenProection(local).ToPoint();        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0            && proectToLight.Y < Camera.ScreenHeight)        {            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;            if (ZBuffer[index] == null || ZBuffer[index].Position.Z >= local.Z)            {                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));            }        }        else        {            vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));        }    }    public void UpdateDepthMap(IEnumerable<Primitive> primitives)    {        Rasterizer.ComputeVisibleVertices(primitives);    }}


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

image


Многие из вас наверное заметили артефакты данного шейдера(необработанные светом черные точки). Опять же обратившись в всезнающую сеть я нашел описание этого эффекта с противным названием shadow acne(да простят меня люди с комплексом внешности). Суть таких зазоров заключается в том, что для определения тени мы используем ограниченное разрешение карты глубин. Это значит, что несколько вершин при рендеринге получают одно значение из карты глубин. Такому артефакту наиболее подвержены поверхности на которые свет падает под пологим углом. Эффект можно исправить, увеличив разрешение рендера для света, однако существует более элегантный способ. Заключается он в том, чтобы добавлять определенный сдвиг для глубины в зависимости от угла между лучом света и поверхностью. Это можно сделать при помощи скалярного произведения.

Улучшенные тени
public class ShadowMappingShader : IShader{    public Enviroment Enviroment { get; set; }    public Rasterizer Rasterizer { get; set; }    public Camera Camera => Rasterizer.Camera;    public Pivot Pivot => Camera.Pivot;    public Vertex[] ZBuffer => Rasterizer.ZBuffer;    public float LightIntensivity { get; set; }    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)    {        Enviroment = enviroment;        LightIntensivity = lightIntensivity;        Rasterizer = rasterizer;        //я добвил события в объекты рендера, привязав к ним перерасчет карты теней        //теперь при вращении/движении камеры либо при изменение сцены шейдер будет перезаписывать глубину        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);        UpdateVisible(Enviroment.Primitives);    }    public void ComputeShader(Vertex vertex, Camera camera)    {        //вычисляем глобальные координаты вершины        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);        //дистанция до света        var lghDir = Pivot.Center - gPos;        var distance = lghDir.Length();        var local = Pivot.ToLocalCoords(gPos);        var proectToLight = Camera.ScreenProection(local).ToPoint();        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0            && proectToLight.Y < Camera.ScreenHeight)        {            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;            var n = Vector3.Normalize(vertex.Normal);            var ld = Vector3.Normalize(lghDir);            //вычисляем сдвиг глубины            float bias = (float)Math.Max(10 * (1.0 - VectorMath.Cross(n, ld)), 0.05);            if (ZBuffer[index] == null || ZBuffer[index].Position.Z + bias >= local.Z)            {                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));            }        }        else        {            vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));        }    }    public void UpdateDepthMap(IEnumerable<Primitive> primitives)    {        Rasterizer.ComputeVisibleVertices(primitives);    }}

image


Бонус

Играем с нормалями


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

image


Двигаем свет


Реализовав простой цикл получим набор отрендеренных картинок с разными позициями света на сцене:

            float angle = (float)Math.PI / 90;            var shader = (preparer.Shaders[0] as PhongModelShader);            for (int i = 0; i < 180; i+=2)            {                shader.Lights[0] = = new Light()                    {                        Pos = shader.Lights[0].Pos.Rotate(angle , Axis.X) ,                        Intensivity = shader.Lights[0].Intensivity                    };                Draw();            }

image

Производительность


Для теста использовалась следующие конфигурации:

  • Модель Сильваны: 220к полигонов.
  • Разрешение экрана: 1920x1080.
  • Шейдеры: Phong model shader
  • Конфигурация компьютера: cpu core i7 4790, 8 gb ram

FPS рендеринга составлял 1-2 кадр/сек. Это далеко не realtime. Однако стоит все же учитывать, что вся обработка происходила без использования многопоточности, т.е. на одном ядре cpu.

Заключение


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

Перевод Рендеринг кадра Cyberpunk 2077

18.12.2020 14:16:13 | Автор: admin

Введение


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

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

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

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

Эта статья ни в коем случае не является серьёзной попыткой реверс-инжиниринга.

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

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

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

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

Захваты выполнялись при высоких настройках графики, без RTX и DLSS, поскольку RenderDoc их не поддерживает (возможно, пока?). Я отключил Motion blur и другие неинтересные постэффекты и сделал так, чтобы игрок перемещался на всех захватах. Это даёт чуть больше понимания о передаче доступа к данным предыдущих кадров.

Кроме того, не использовалась никакая инсайдерская информация, так проще и веселее.

Основы


На первый взгляд, описать ядро рендеринга Cyberpunk 2077 можно очень кратко. Это классический отложенный рендерер (deferred renderer) с довольно ванильной схемой g-буфера. Мы не видим здесь безумного количества буферов, как, например, в Spiderman компании Suckerpunch, выпущенной на PS4. Нет здесь и сложной упаковки битов и реинтерпретации каналов.


  • Нормали формата 10.10.10.2 с 2-битным альфа-каналом, зарезервированным для того, чтобы помечать волосы.
  • Albedo в формате 10.10.10.2. Непонятно, что здесь делает альфа-канал, похоже для всего отрисовываемого он равен единице, но, возможно, так только в тех захватах, которые у меня есть.
  • Metalness, Roughness, Translucency и Emissive в формате 8.8.8.8 (в порядке RGBA)
  • Z-буфер и стенсил-буфер. Последний, похоже, используется для изоляции типов объектов/материалов. Движущиеся объекты помечены. Кожа, автомобили, растительность, волосы, дороги. Сложно понять значение каждой части, но общий смысл вам ясен...

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


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

По сути, во всех отрисовках геометрии используется инстансинг и некая разновидность bindless-текстур. Могу предположить, что это была большая часть обновления движка из The Witcher 3 для адаптации под современное оборудование.

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

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

Титры в начале игры не упоминают технологию Umbra (которая использовалась The Witcher 3), поэтому предположу, что CDPr реализовала собственную систему видимости. Её эффективность очень сложно оценить, поскольку видимость это проблема балансировки GPU и CPU, однако, похоже, в захвате присутствует довольно много отрисовок, не вносящих вклад в изображение, хотя за это я не ручаюсь. Кроме того, похоже на то, что иногда рендеринг может отображать скрытые комнаты, поэтому, движок, кажется, не использует систему ячеек и порталов. Думаю, что для таких больших миров художникам непрактично выполнять большой объём ручной работы для вычисления видимости.


Наконец, я не вижу никакого усечения, выполняемого на стороне GPU, с использованием пирамид глубин и тому подобного; нет усечения на уровне треугольников или кластеров, как и нет прогнозируемых отрисовок (predicated draws), поэтому предполагаю, что всё усечение по пирамиде видимости и перекрытию выполняется на стороне CPU.

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

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

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

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


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


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

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

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


Освещение, часть 1: аналитические источники освещения


Очевидно, что анализ отложенного рендеринга не может закончиться на g-буфере; мы разделили затенение на две части, поэтому теперь нужно посмотреть, как реализовано освещение.

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


Тем не менее, достаточно очевидно, что после выполнения всей работы с буфером глубин/g-буфером, наступает черёд этапа суммирования всего, на котором выполняется множество операций, связанных с глубинами.

Сначала он упаковывает нормали и roughness в RGBA8 при помощи кодирования нормалей на основе таблиц поиска по принципу best-fit (эта техника создана компанией Crytek), затем создаёт mip-пирамиду min-max значений глубин.


Затем пирамида используется для создания чего-то, напоминающего объёмную (volumetric) текстуру для кластерного освещения.


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

Кажется, кластеры это фрагменты 32x32 пикселя в экранном пространстве (фрокселы, froxels) с 64 z-срезами. Однако похоже, что освещение выполняется с разрешением в 16x16 тайлов, и целиком реализовано при помощи косвенного выполнения вычислительных шейдеров.

Рискну предположить, что так получилось из-за того, что вычислительные шейдеры специализируются на материалах и свете, присутствующих в тайле, а затем выполняются в соответствующем порядке такая схема часто используется в современных системах отложенного рендеринга (см., например, презентации Call of Duty Black Ops 3 и Uncharted 4 на эту тему).

На выходе прохода аналитического освещения получаются два буфера RGBA16; похоже, это вычисления diffuse и specular. Учитывая выбранные мной опции освещения сцены, не удивлюсь, что в ней присутствуют только прожекторные/точечные/сферические источники света и линейные/капсульные источники. Большинство источников освещения в Cyberpunk неоновые, поэтому поддержка линейных источников просто обязательна.

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


Освещение, часть 2: тени


Но увиденное нами выше не означает, что в Cyberpunk 2077 используются простые тени, всё совсем наоборот определённо использовалось множество трюков, и большинство из них не так просто подвергнуть реверс-инжинирингу!

Во-первых, перед предварительным проходом глубин происходит серия отрисовок в нечто, напоминающее карту теней (shadowmap). Подозреваю, что это CSM (каскадные карты теней), но в изученном мной захвате они нигде не используются, в них только выполняется рендеринг. Это указывает на то, что система обновляет карты теней на протяжении нескольких кадров (вероятно, с учётом только статичных объектов?).


Эти эффекты, растянутые на несколько кадров, сложно передать в захвате, поэтому нельзя понять, существуют ли другие системы кэширования (например, см. тени Black Ops 3, сжатые в деревья квадрантов).

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


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

Если кто-то возьмётся за эту работу, то ему хорошенько придётся потрудиться над реверс-инжинирингом!


Все остальные тени в сцене представлены в некой форме VSM (variance shadow maps), многократно вычисляемых инкрементно в течение времени. Я видел, что используются карты размером 512x512 и 256x256, а в моих захватах на кадр рендерились пять карт теней, но предполагаю, что это зависит от настроек. Большинство из них, похоже, используются только как render target, поэтому, опять же, может оказаться так, что для завершения их рендеринга требуется несколько кадров. Одна из них размывается (VSM) в срез массива текстур я видел некоторые такие массивы с 10 срезами и с 20 срезами.


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

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

Освещение, часть 3: всё остальное...


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


Во-первых, есть очень хороший проход SSAO половинного разрешения. Он вычисляется сразу после описанного выше общего прохода суммирования и использует упакованные в RGBA8 нормали и roughness, а не из g-буфера.

Похоже, что он вычисляет наклонные нормали и конусы апертур. Конкретную технику вычисления определить невозможно, но это определённо что-то типа HBAO-GTAO. Сначала глубина, нормали/roughness и векторы движения подвергаются даунсэмплингу до половинного разрешения. Затем проход вычисляет Ambient Occlusion текущего кадра, а последующие выполняют двунаправленную фильтрацию временное репроецирование. Паттерн дизеринга тоже довольно равномерный, предположу, что это градиентный шум Жорже.

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


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


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

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


Здесь используется стандартное объёмное освещение, вычисленное в 3D-текстуре с временным репроецированием. Raymarching ограничивается глубиной сцены, вероятно, для экономии ресурсов, но это, в свою очередь, иногда может приводить к утечкам и артефактам репроецирования. Однако в большинстве случаев они малозаметны.


Здесь всё снова становится очень интересным. Сначала используется потрясающий проход отражений в экранном пространстве (Screen-Space Reflection), на котором опять используется буфер упакованных нормалей/roughness, а значит, поддерживаются размытые отражения; всё это выполняется в полном разрешении (по крайней мере, при моих настройках графики).

В нём применяются данные цвета предыдущего кадра до композитинга UI (для репроецирования используются векторы движения). И получается довольно много шума, даже если для дизеринга применяется текстура синего шума!


Далее идёт косвенное diffuse/ambient GI (Global Illumination). Используется g-буфер и серия объёмных текстур 64x64x64, которые сложно расшифровать. Исходя из входных и выходных данных, можно предположить, что объём центрирован относительно камеры и содержит индексы какого-то вычисленного свечения, возможно, сферических гармоник или чего-то подобного.

Освещение очень мягкое/низкочастотное и косвенные тени в этом проходе не особо заметны. Возможно, это даже динамическое GI!

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


Наконец, выполняется общий композитинг всего: зондов specular, SSR, SSAO, diffuse GI, аналитического освещения. Этот проход снова создаёт два буфера, один из которых походит на окончательное освещение, а второй содержит только то, что похоже на части specular.

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



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

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

Всё остальное


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


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

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


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


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

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



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

Разумеется, там есть эффект глубины поля зрения, тональная коррекция и автоматическая выдержка Также присутствуют все эффекты деградации изображения, которые можно ожидать от игр и которые вы скорее всего захотите отключить: зерно плёнки, lens flares, motion blur, хроматическая аберрация Даже композитинг UI выполняется нетривиально, всё реализовано на вычислительных шейдерах, но у меня нет времени на анализ Теперь, сняв этот груз с души, я могу, наконец, попробовать насладиться игрой! Пока!
Подробнее..

Перевод Рендеринг острова из Моаны менее чем за 10 000 строк кода на Swift

18.01.2021 14:21:57 | Автор: admin
Остров из Моаны (2048 858 пикселей, 64 spp), отрендеренный при помощи Gonzales в хранилище Google Cloud с 8 виртуальными ЦП и 64 ГБ памяти примерно за 26 часов. Остров из Моаны (2048 858 пикселей, 64 spp), отрендеренный при помощи Gonzales в хранилище Google Cloud с 8 виртуальными ЦП и 64 ГБ памяти примерно за 26 часов. В памяти он занимает около 60 ГБ. Удаление шума при помощи OpenImageDenoise.Остров из Моаны (2048 858 пикселей, 64 spp), отрендеренный при помощи Gonzales в хранилище Google Cloud с 8 виртуальными ЦП и 64 ГБ памяти примерно за 26 часов. Остров из Моаны (2048 858 пикселей, 64 spp), отрендеренный при помощи Gonzales в хранилище Google Cloud с 8 виртуальными ЦП и 64 ГБ памяти примерно за 26 часов. В памяти он занимает около 60 ГБ. Удаление шума при помощи OpenImageDenoise.

После того, как Walt Disney Animation Studios выложила в сеть описание сцены острова из Моаны, много кто пытался его отрендерить своими силами, исключающими оригинальный Hyperion. Это лишь малая часть списка таких движков:

Андреас Вендледер из Бабельсбергского киноуниверситета представил другой, написанный им рендерер Gonzales. Он в значительной степени вдохновлен PBRT, написан на Swift (с несколькими строками кода на C ++ для вызова OpenEXR и Ptex) и оптимизирован для проведения рендеринга в (сравнительно) разумные сроки на бесплатном хранилище Google Cloud (8 виртуальных ЦП, 64 ГБ RAM). Насколько автору известно, это единственный рендерер, написанный не на C/C++, способный на рендеринг этой сцены. Написан он с помощью vi и командной строки Swift в Ubuntu Linux и Xcode на macOS, так что скомпилировать его на этих платформах не должно составить труда.

Так почему именно Swift?

Как пишет Вендледер, ему всегда было неудобно работать с заголовочными файлами и препроцессором на C и C++. Однажды объявив и определив что-либо (переменную, функцию), нет нужды делать это повторно. Кроме того, текстовое включение заголовочных файлов приносит с собой множество проблем, таких как необходимость добавления деталей реализации в эти самые файлы (на ум приходят, например, шаблоны) или медленное время компиляции ввиду многократного включения заголовков и вытекающего отсюда комбинаторного взрыва. Когда он начинал работать на C++, модули там еще не были доступны, поэтому он перепробовал написание кода на Python (слишком медленный), Go (слишком похож на C) и некоторых других языках программирования, но в конце концов остановился Rust и Swift. В результате предпочтение отдал Swift из-за его удобочитаемости (просто не нравились fn main и impl trait). Тем более, тот факт, что он был написан разработчиками LLVM и Clang, вселил уверенность, что он а) не будет заброшен в будущем и б) будет соответствовать поставленным целям по производительности. Короче говоря, был нужен скомпилированный язык без указателей, модулей, концепций, диапазонов, читаемых шаблонов, и нужен он был прямо сейчас. Кроме того, компиляторы были изобретены, чтобы облегчить жизнь программистам, сделав программы более читабельными, и порой, смотря на код, основанный на шаблонах, у автора складывалось ощущение, что мы движемся назад во времени. Все любят, когда их код легко читается.

Кое-какие заметки

Парсинг пережил несколько воплощений. Сначала то была простая строка (file.availableData, кодировка: .utf8), но она оказалась слишком велика, чтобы поместиться в памяти. Данные здесь не использовались по аналогичным причинам. В то же время еще и Scanner переехал из Foundation. В конце концов Вендледер остановился на чтении InputStream в 64-КБ массиве UnsafeMutablePointer <UInt8>.

Массивы всё. Короче говоря, никогда не используйте их при срочной необходимости. То есть, вообще не создавайте их. Это должно было быть ясно с самого начала, но урок был извлечен быстро, поскольку они всегда появлялись в верхней части анализа, выполненного с помощью perf. Для массивов фиксированного размера это можно преодолеть с помощью кортежей или внутреннего FixedArray в Swift. Даже если используется только массив, геттеры индекса, как правило, появляются в начале выполнения perf.

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

  • Perf: этот инструмент Linux дает вам ценную информацию о том, на что тратится время. Просто запустите его, посмотрите на функцию, отображаемую вверху, и подумайте, что здесь вообще происходит. Подсказка: обычно не то, что подразумевалось. В данном случае это всегда был либо swiftretain, либо release, который снова и снова говорил не выделять объекты в куче (heap).

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

  • Профилирование с Xcode: в основном здесь использовались Time Profiler, Leaks и Allocations, которые дают примерно ту же информацию, что и Perf и Valgrind, но с другой точки зрения. Порой бывает полезно взглянуть на одно и то же с двух разных ракурсов. Это напоминает старые добрые времена, когда мы загружали наше ПО в три разных компилятора (Visual Studio, GCC и один из IRIX, как там его? MIPSPro?).

Хотя Swift позволяет легко писать читаемый и компактный код, вам все равно придется задуматься о низкоуровневых операциях, таких как выделение памяти и тому подобное. Сам автор часто переключался между структурами и классами, чтобы посмотреть, как это повлияет на память и производительность. Отсутствие указателей, new и shared_pointers приятно тем, что большую часть времени можно просто переключаться между уже упомянутыми структурами и классами, не меняя ничего в системе.

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

О программировании на основе протоколов: текущая версия Gonzales показывает 23 протокола, 57 структур, 47 заключительных классов и 2 незавершенных класса. Наследование здесь почти не используется. Два оставшихся незавершенных класса это TrowbridgeReitzDistribution и Texture: оба они самому Вендледеру не нравятся, и он думает о том, как изменить их в будущем. В целом программирование на основе протоколов приводит к хорошему коду: например, раньше в Gonzalez был класс Primitive, такой как в PBRT, но вскоре он изменился на протокол, унаследованный от таких протоколов, как Boundable, Intersectable, Emitting (его уже нет) и других. Теперь и его тоже нет: BoundingHierarchyBuild просто зависит от экзистенциального типа Boundable и возвращает иерархию Intersectables, которую использует BoundingHierarchy. Все примитивы теперь хранятся как массив экзистенциальных типов, состоящий из композиции протоколов Boundable и Intersectable (var primitives = Boundable&Intersectable).

С другой стороны, примитивы в BoundingHierarchy хранятся как [AnyObject&Intersectable]. На это есть две причины:

1. Тут требуются только пересечения;

2. AnyObject заставляет хранимые объекты быть ссылочными типами (или классами), что экономит память, поскольку макет протоколов как для структур, так и для классов (OpaqueExistentialContainer) использует 40 байтов ведь Swift пытается хранить встроенные структуры, тогда как протоколы только для классов (ClassExistentialContainer) используют всего 16 байт, ведь там должен храниться только указатель, как это можно увидеть в документации Swift или проверить здесь. Стоит подчеркнуть, что это не только академическое обсуждение: автор столкнулся с этим лично, ведь оно обнаружилось во время его работы в верхней части запуска проверки памяти.

Одна из причин, по которой вы можете отрендерить этот остров менее чем за 10 000 строк возможность писать компактный код на Swift. Один из таких примеров списки параметров. В PBRT вы можете прикреплять произвольные параметры к объектам, что приводит к примерно 1000 строкам кода в paramset.[h|cpp]. В Swift вы можете добиться того же примерно за три строчки:

protocol Parameter {}

extension Array: Parameter {}

typealias ParameterDictionary = Dictionary<String, Parameter>

На самом деле, это, конечно, не совсем так, но вы поняли суть. (Кроме того, скорее всего, это изменилось бы в PBRT-v4.)

О взаимодействии C ++ для поддержки Ptex и OpenEXR: Интегрирование с C ++ для Swift уже на подходе, но оно было недоступно, когда автор только начинал со всем этим заниматься, и все еще не доступно на данный момент. Поскольку он использует OpenEXR и Ptex только для чтения текстур и записи изображений, он прибег еще к extern "C". Одна карта модулей и несколько строк кода на C ++ (100 для Ptex, 82 для OpenEXR) и вас уже есть поддержка чтения и записи изображений OpenEXR и текстур Ptex.

Этот код публикуется сейчас потому, что получилось добиться его работы на Google Compute Engine с 8 виртуальными ЦП и 64 ГБ памяти, бесплатной в течение трех месяцев, поэтому, пожалуйста, загрузите код и получите учетную запись, чтобы запустить его. Тем не менее, предстоит еще многое сделать, поскольку сейчас он оптимизирован только для получения одного изображения. Ниже приводится большой список задач, отсортированный от легко реализуемых до крупных проектов, за которые автор мог бы или не мог бы взяться в будущем.

Список дел

  • Дифференциалы лучей прямого освещения. Это должно быть относительно просто. Посмотреть, как это делает PBRT-v3, реализовать дифференциальную генерацию в камере, прокачать ее через систему и использовать в вызове Ptex. Там все это обрабатывается автоматически.

  • Лучшие иерархии: в Gonzalez реализована только простейшая ограничивающая иерархия, что приятно, поскольку она занимает всего 177 строк кода, но в то же время это приводит к не оптимальному времени рендеринга. Оптимизированные иерархии SAH должны быть намного лучше в этом плане. Их также не должно быть сложно реализовать.

  • Ускоренный парсинг: нужно встроить быстрый синтаксический анализатор pbrt Инго Вальда, который анализировал бы Моану за секунды, а не за полчаса. Или даже лучше: написать самому парсер для формата pbf на Swift.

  • Ускоренное создание иерархии: сейчас оно происходит медленнее, чем хотелось бы. Что с этим можно сделать?

  • Идея об ускоренном парсинге, генерации иерархии и форматах сцены. LLVM имеет три различных формата битового кода: in-memory, machine readable (двоичный) и human readable, и он может без потерь преобразовывать их между собой. Может ли у нас происходить то же самое? Подобно PBRT (читаемому человеком), PBF или USD (машиночитаемому) и BHF (формату двоичной иерархии), где ограничивающие иерархии уже созданы и могут быть просто отображены в памяти.

  • Задачка начального уровня: сам автор пытался отрендерить только Моану, но должно оказаться довольно легко улучшить Gonzales так, чтобы он мог рендерить другие сцены, добавляя функции или исправляя ошибки. Существует множество сцен, на которых можно потренироваться. Также есть много экспортеров для PBRT, которые должны работать и для Gonzales.

  • Bump mapping: должно оказаться довольно просто.

  • Displacement mapping: не так просто.

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

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

  • Шумоподавление: сейчас используется OpenImageDenoise, но, конечно, было бы неплохо иметь встроенный шумоподавитель в Swift. Кроме того, beauty, albedo и normal image сейчас пишутся отдельно, и это нужно переделать.

  • USD: написать парсер для Universal Scene Description Pixar.

  • Лучший сэмплинг: использовать discrepany-based sampling или multi-jittered sampling.

  • Трассировка пути: изучить PxrUnified и реализовать управляемую трассировку пути (автор уже смотрел ее, но выглядит она запутанно) и Manifold Next Event Estimation. Кажется, где-то была такая реализация, но уже забыл, где. (Эх, если бы только Weta последовала примеру Диснея и опубликовала голову Гэндальфа из той статьи!)

  • Подповерхностное рассеяние. Уже в PBRT.

  • Ускоренный рендеринг: у Embree есть трассировщик пути. Изучить его внимательно и постараться сделать Gonzales быстрее.

  • Рендеринг на ГП: достаточно объемная задача, и PBRT-v4, очевидно, делает это так же, как некоторые из упомянутых выше рендереров. Возможно, стоит просто сделать так же, как они, и использовать Optix для рендеринга на видеокарте, но лучше бы найти решение, не использующее закрытый исходный код. Это означало бы, что вам нужно реализовать свой собственный Optix. Но если посмотреть на то, как развиваются ГП и ЦП, в далеком будущем вполне возможно использовать один и тот же (Swift) код на них обоих; у вас могут быть экземпляры с 448 ЦП в облаке, а новейшие графические процессоры имеют несколько тысяч микро-ЦП, и они выглядят все более и более одинаково. Интересно, понадобится ли программирование для AVX в будущем, поскольку сейчас оно кажется все менее необходимым, ведь вы можете просто добавить больше ядер для решения проблемы. В то же время память становится все больше и больше похожей на NUMA, так что расположение ваших данных рядом с ALU становится все более важным. Может быть, однажды у нас появятся узлы рендеринга в облаке, каждый из которых будет отвечать за свою часть сцены: геометрически разбивать эту сцену и отправлять только части ее в ЦП. Затем возвращенные пересечения можно было бы просто отсортировать по значению t луча, что напоминает архитектуры сортировки по первому/среднему/последнему, такие как Chromium.

На этом пока все. Автор был бы очень рад получить комментарии о том, что можно было бы сделать лучше или более элегантно, отчеты об ошибках или даже пулл-реквесты. Также спасибо Мэтту Фарру и PBRT наиболее ценному ресурсу в известной вселенной (по крайней мере, касательно рендеринга)

Подробнее..

Перевод Рендеринг в веб

22.03.2021 20:04:15 | Автор: admin

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


о переводе

Переведя почти всю статью я внезапно обнаружил, что в том же источнике она присутствует и на русском языке (сразу не обнаружил потому что в выпадающем списке переводов нет русского языка, перевод нашелся через Google - https://developers.google.com/web/updates/2019/02/rendering-on-the-web?hl=ru). Однако, при ближайшем рассмотрении перевод "рендеринг сервера" вместо "статический рендеринг" сразу дал понять что перевод был сделан машиной.
Непосредственно перед публикацией своего перевода здесь, я ещё раз зашел на тот русский перевод и ... обнаружил что его кто-то уже существенно улучшил :))) Однако, на мой пристальный взгляд, он всё равно не лишён недостатков.
В итоге, я всё же решил опубликовать свой вариант перевода, так как, на мой взгляд, статья в любом случае достойна того чтобы привлечь к ней внимание, а какой перевод прочесть, в итоге каждый выберет сам.
Спасибо!

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

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

Терминология

Рендеринг

  • SSR: Server-Side Rendering - рендеринг в HTML клиентского или универсального приложения на сервере.

  • CSR: Client-Side Rendering - рендеринг приложения в браузере, обычно используя DOM

  • Rehydration (регидратация): "загрузка" JavaScript отображениий на клиенте таким образом, чтобы они повторно использовали отрендеренное на сервере DOM-дерево и данные HTML-а

  • Prerendering (пре-рендеринг): выполнение клиентского приложения во время сборки для захвата его начального состояния в виде статического HTML.

Performance

  • TTFB:Time to First Byte - время между нажатием на ссылку и временем прихода первого бита контента

  • FP:First Paint - время когда первый пиксель становится виден пользователю

  • FCP:First Contentful Paint - время до показа пользователю запрошенного контента (тела статьи и т.п.)

  • TTI:Time To Interactive - время до момента когда страница становится интерактивной (начинают работать события и т.д.)

Server Rendering (Серверный рендеринг)

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

Серверный рендеринг обычно даёт быстрый First Paint (FP) и First Contentful Paint (FCP). Выполнение логики страницы и её рендеринг на сервере позволяют избежать отправки большого количества JavaScript клиенту, что помогает достичь быстрого Time to Interactive (TTI). Это имеет смысл потому, что при серверном рендеринге вы на самом деле просто посылаете текст и ссылки в браузер пользователя. Такой подход может хорошо работать для широкого спектра устройств и сетевых условий и открывает интересные возможности для оптимизации браузера, например можно выполнять разбор потоковых (streaming) документов.

При серверном рендеринге пользователи вряд ли будут вынуждены ждать, пока CPU-зависимый JavaScript будет выполнен, прежде чем они смогут использовать ваш сайт. Даже когда стороннего JS не избежать, использование серверного рендеринга для уменьшения собственных JS costs (JS затрат) может дать вам больше "budget" (бюжета) для остального. Однако, есть один основной недостаток такого подхода: генерация страниц на сервере занимает время, что часто может привести к замедлению Time to First Byte (TTFB).

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

Многие современные фреймворки, библиотеки и архитектуры позволяют отрисовывать одно и то же приложение как на клиенте, так и на сервере. Эти инструменты могут быть использованы для Server Rendering, однако важно отметить, что архитектуры, где рендеринг происходит как на сервере, так и на клиенте, являются собственным классом решений с очень различными характеристиками производительности и компромисами. React пользователи могут использовать для серверного рендеринга renderToString() или решения, построенные на нем, такие как Next.js. Пользователи Vue могут ознакомиться с руководством по серверному рендерингу Vue или познакомиться с Nuxt. В Angular есть Universal. Однако большинство популярных решений используют ту или иную форму гидратации (hydration), поэтому перед выбором инструмента следует ознакомиться с используемыми подходами.

Static Rendering (Статический рендеринг)

Статический рендеринг происходит во время сборки и даёт быстрый FP, FCP и TTI - это если предопложить, что количество клиентского JS невелико. В отличие от серверного рендеринга, ему также удаётся достичь стабильно быстрого TTFB, так как HTML для страницы не нужно генерировать "на лету". Как правило, статический рендеринг означает создание отдельного HTML-файла для каждого URL заранее. С HTML генерируемым заранее, статический рендеринг может быть развернут на нескольких CDN, чтобы воспользоваться преимуществами edge-кеширования.

Решения для статического рендеринга бывают разных форм и размеров. Такие инструменты как Gatsby разработаны для того, чтобы разработчики чувствовали, что их приложение отрисовывается динамически, а не генерируется на этапе сборки. Другие, такие как Jekyll и Metalsmith, принимают их статическую природу, предоставляя подход более заточенный на шаблоны.

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

React пользователи могут быть знакомы с Gatsby, Next.js static export или Navi - все они дают удобство для использующих эти компоненты. Однако, важно понимать разницу между статическим рендерингом и пре-рендингом: статический рендеринг страниц интерактивен без необходимости выполнения большого количества клиентского JS, в то время как пре-рендеринг улучшает FP (First Paint) или FCP (First Contentful Paint) одностраничного приложения (SPA), которое должно быть загружено на клиенте для того, чтобы страницы были по-настоящему интерактивными.

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

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

Серверный рендеринг против статического

Серверный рендеринг не является серебряной пулей - его динамическая природа может сопровождаться значительными накладными расходами. Многие решения для серверного рендеринга don't flush early, могут задерживать TTFB или задваивать отправленные данные (например, в inlane стэйте, используемом JS на клиенте). В React, renderToString() может быть медленным, так как он синхронный и однопоточный. Получение "правильного" рендеринга сервера может включать в себя поиск или создание решения для компонентного кеширования, управление потреблением памяти, применение memoization техник, и многие другие вопросы. Как правило, вы обрабатываете/пересобираете одно и то же приложение несколько раз - один раз на клиенте и один раз на сервере. То, что серверный рендеринг может заставить что-то появиться раньше, не означает, что у вас вдруг стало меньше работы.

Серверный рендеринг генерирует HTML по требованию для каждого URL, но это может быть медленнее, чем просто обслуживание статически отрендереного контента. Если вы готовы сделать дополнительные усилия, то серверный рендеринг + [HTML кеширование] (https://freecontent.manning.com/caching-in-react/) может значительно сократить время серверного рендеринга. Положительной стороной серверного рендеринга является возможность получать более "живые" данные и отвечать на более полный набор запросов, чем это возможно при статическом рендеринге. Страницы, требующие персонализации, являются хорошим примером типа запроса, который плохо работает со статическим рендерингом.

Серверный рендеринг также может представлять интересные решения при построении PWA. Лучше ли использовать full-page service worker кеширование, или просто рендерить на сервере отдельные фрагменты контента?

Client-Side Rendering (CSR)

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

Клиентский рендеринг может быть сложным в части получения и быстроты на мобильных устройствах. Он может достигать производительности чистого сервер-рендеринга, если делать минимальную работу, сохраняя компактным JavaScript бюджет и доставляя объёмы в как можно меньшем количестве RTTs. Критические скрипты и данные могут быть доставлены быстрее с помощью HTTP/2 Server Push или <link rel=preload>, что заставит парсер работать на вас быстрее. Такие шаблоны, как PRPL, стоит рассмотреть, чтобы первоначальная и последующая навигация чувствовалась быстрыми.

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

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

Комбинация серверного рендеринга и клиентского через регидратацию

Часто называемый Universal Rendering или просто "SSR", этот подход пытается сгладить компромиссы клиентского и серверного редеринга, делая и то, и другое. Навигационные запросы, такие как полная загрузка страницы или перезагрузка, обрабатываются сервером, который рендерит приложение в HTML, затем JavaScript и данные, используемые для рендеринга, встраиваются в результирующий документ. При тщательной реализации, это даёт быстрый FCP (First Contentful Paint) такой же, как Server Rendering, а далее "усиливает это" путем рендеринга опять же на клиенте с помощью техники, называемой (re)hydration ((ре)гидратация). Это новое решение, но оно может иметь некоторые существенные недостатки в производительности.

Основной недостаток SSR с регидратацией (rehydration) заключается в том, что она может оказать значительное негативное влияние на TTI (Time To Interactive), даже если она улучшает FP (First Paint). SSR-страницы часто выглядят обманчиво полностью загруженными и интерактивными, но на самом деле не могут реагировать на ввод, пока не будет выполнен JS на стороне клиента и не будут прикреплены обработчики событий. Это может занять секунды или даже минуты на мобильном устройстве.

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

= Проблема регидратации: Одно приложение по цене двух

Проблемы с регидратацией часто могут быть хуже, чем задержка интерактивности из-за JS. Для того, чтобы JavaScript на стороне клиента мог точно "определить" ("pick up") то место, где остановился сервер, без необходимости повторно запрашивать все данные, использованные сервером для рендеринга этого HTML, текущие SSR решения обычно сериализуют ответ из зависимых данных UI в документ в виде тегов script. Полученный HTML-документ содержит высокий уровень дублирования:

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

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

Но всё же надежда на SSR с регидратацией есть. В краткосрочной перспективе, только использование SSR для высоко кешируемого содержимого может уменьшить задержку TTFB (Time to First Byte), давая результаты, схожие с пре-рендерингом. Регидратация инкрементальная, прогрессивная или частичная, может быть ключом к тому, чтобы сделать эту технику более жизнеспособной в будущем.

Потоковый серверный рендеринг и прогрессивная регидратация

Серверный рендеринг за последние несколько лет претерпел ряд изменений.

Потоковый серверный рендеринг (Streaming server rendering) позволяет посылать HTML в чанках, которые браузер может прогрессивно рендерить по мере получения. Это может обеспечить быстрый FP (First Paint) и FCP (First Contentful Paint), так как разметка поступает к пользователям быстрее. В React, потоковость, будучи асинхронной в renderToNodeStream() - по сравнению с синхронным renderToString - означает, что backpressure обрабатывается хорошо.

Прогрессивная регидратация также заслуживает внимания, и кое-что в React было исследовано. При таком подходе отдельные части приложения, возвращаемого с сервера, "загружаются" постепенно, вместо текущего общепринятого подхода когда инициализируется сразу всё приложение. Это может помочь уменьшить количество JavaScript, необходимого для того, чтобы сделать страницы интерактивными, так как обновление на клиентской стороне низкоприоритетных частей страницы может быть отложено, чтобы предотвратить блокировку основного потока. Это также может помочь избежать одной из наиболее распространенных ловушек SSR Rehydration, когда отрендеренный на сервере DOM разрушается, а затем сразу же восстанавливается - чаще всего потому, что начальный синхронный рендеринг на стороне клиента требует данных, которые были не совсем готовы, возможно, ожидая завершения Promise.

= Частичная регидратация

Частичная регидратация оказалась трудной для осуществления. Этот подход является продолжением идеи прогрессивной регидратации, когда отдельные части (компоненты / виджеты / деревья), подлежащие прогрессивной регидратации, анализируются, а те, которые обладают низкой интерактивностью или не обладают реактивностью помечаются. Для каждой из этих наиболее статических частей соответствующий код JavaScript затем трансформируется в инертные ссылки и декоративную функциональность, уменьшая их влияние на стороне клиента до почти нулевого уровня. Подход, основанный на частичной гидратации, имеет свои собственные проблемы и компромиссы. Он создает некоторые интересные вызовы для кеширования, а навигация на стороне клиента означает, что мы не можем иметь HTML рендерящийся на сервере для инертных частей приложения и доступный без полной загрузки страницы.

= Трисоморфный рендеринг (Trisomorphic Rendering)

Если service workers, являются подходящим вариантом для вас, то "трисоморфный" рендеринг также может быть вам интересен. Это метод, при котором вы можете использовать потоковый серверный рендеринг для начальных/не-JS навигаций, а затем попросить ваш service worker взять на себя рендеринг HTML для навигации после того как он будет смонтирован. Это может поддерживать кешированные компоненты и шаблоны в актуальном состоянии и позволяет использовать навигацию в стиле SPA для рендеринга новых UI-частей в той же сессии. Такой подход лучше всего работает, когда вы можете поделиться одним и тем же шаблоном и кодом маршрутизации между сервером, клиентской страницей и service worker.

SEO соображения

Команды часто учитывают влияние SEO при выборе стратегии для рендеринга в вебе. Серверный рендеринг часто выбирается для обеспечения поисковым роботам возможности лёгкого "полного поиска". Поисковые роботы могут понимать JavaScript, но часто существуют ограничения, о которых стоит знать в части того как они рендерят. Рендеринг на стороне клиента может работать, но часто не без дополнительного тестирования и трудной работы. В последнее время динамический рендеринг также стал вариантом, заслуживающим внимания, если ваша архитектура в значительной степени ориентирована на клиентский JavaScript.

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

Заключение...

При принятии решения о подходе к рендерингу, измеряйте и понимайте, каковы ваши "узкие места". Подумайте, может ли статический рендеринг или серверный рендеринг дать вам хотя бы 90% возможностей. Совершенно нормально обычно отправлять HTML с минимальным количеством JS, чтобы получить интерактивный опыт. Вот удобная инфографика, показывающая спектр возможностей в разрезе сервер-клиент:

Благодарности

Спасибо всем этим людям за отзывы и вдохновение:
Jeffrey Posnick, Houssein Djirdeh, Shubhie Panicker, Chris Harrelson, and Sebastian Markbge

Подробнее..

Перевод Выжимаем из Gears Tactics максимальную производительность с минимальными артефактами при помощи VRS

10.07.2020 12:22:13 | Автор: admin
Gears Tactics динамичная пошаговая стратегия во вселенной одной из самых известных игровых франшиз Gears of War. Кроме того, это первая игра, поддерживающая одну из основных функций DirectX 12 Ultimate Variable Rate Shading (VRS).

VRS позволяет Gears Tactics добиться значительного прироста производительности до 18,9%! на широком спектре оборудования без заметной потери качества изображения.

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

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

image

Поскольку главной целью было включить в реализацию самый широкий спектр оборудования, речь пойдет только о первом уровне поддержки VRS (Tier 1), что позволяет устанавливать shading rate для каждого объекта. Для получения дополнительной информации о VRS можете почитать этот пост.

В случае Gears Tactics VRS не распространяется на уровень поддержки выше первого, поэтому нужно было определить, на каких проходах рендеринга можно его задействовать (оговоримся, что Gears Tactics создавалась на Unreal Engine 4), а также сформулировать критерии, когда следует уменьшить shading rate размер области пикселей, к которой применяется единовременный рендеринг. Чем он выше (самый высокий 11), тем выше точность прорисовки и, соответственно, выше нагрузка на GPU.

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


Оценка рендеринг-пайплайна


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

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

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

Вот они:

  • Композиция после освещения (Composition after lighting): Освещение для подповерхностных рассеивающих материалов.
  • Фильтр полупрозрачных объемов (Filter Translucent Volumes): Сглаживает полупрозрачные сетки внутри объема, чтобы избежать проблем с наложением.
  • Затухание света (Light attenuation): Затухание это внешние границы при расчете спада заданного освещения. Этот проход повторяется для затененных источников света при учете рассчитанного спада с целью рендеринга теневых проекций.
  • Постановка световых композиций (Light composition tasks (PreLighting)): Отвечает за окклюзию окружающего света.
  • Блум световых лучей (Light Shaft Bloom): Эффект блума (свечения), возникающий при рендеринге световых лучей.
  • Отражения пространства экрана (Screen Space Reflections): Процесс повторного использования информации о пространстве экрана для создания отражений.
  • Временной анти-алиасинг отражений пространства экрана (SSR Temporal AA): Сглаживание результатов прохода по отражениям пространства экрана.
  • Прямое отложенное освещение (Direct Deferred Lighting): Отвечает за рендеринг любых прямых источников света в буфер цвета сцены.

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

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

  • Полупрозрачное освещение (Translucency);
  • Отложенное освещение (Deferred Lighting);
  • Композиция после освещения (Composition after lighting).

image
Пример, показывающий изображение до и после Composition after lighting с грубым размером пикселя 44

После исключения этих проходов началась работа над корректировкой shading rate на основе других факторов. В первую очередь были исключены динамические объекты и объекты с маской прозрачности.

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

image
VRS использовался на наборе маскированных объектов, что привело к недопустимой потере детализации

Динамические техники


Итак, мы составили хороший набор проходов рендеринга, к которым можно применить VRS. Однако повсеместное применение самого малого shading rate (44 или 22 в зависимости от аппаратной поддержки) на этих проходах приведет к существенной потере качества. Поэтому следующим шагом явилось исследование динамических методов, которые могли бы изменить shading rate в зависимости от конкретного прохода. В результате их сформировалось три. Каждый из них использовался в ключевых проходах непосредственно перед прорисовкой сетки.

Вот эти методы: определение размера объекта, маскировка глубины резкости и маскировка Туман войны.

Определение размера объекта


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

Маскировка глубины резкости


Для большинства графических процессоров глубина резкости в Gears Tactics по умолчанию выключена, но включается во время кат-сцен. Этот метод определяет степень размытия сетки после применения глубины резкости в процессе постобработки. И, поскольку на этом этапе детали намеренно устраняются, на всех предыдущих ключевых проходах можно выставить небольшой shading rate.

image
Сетки, выделенные красным, из-за глубины резкости имеют самый грубый размер пикселя

Маскировка Туман войны


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

image
Интенсивность VRS растет пропорционально интенсивности тумана войны

Соотношение качества и производительности


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

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

Здесь прослеживается еще одно преимущество использования shading rate, для которых нужны дополнительные мощности. Размеры пикселей более 2 требуют дополнительной поддержки shading rate со стороны аппаратного обеспечения, поэтому любой проход, использующий shading rate размером 24, 42 или 44, ограничен уровнем Performance.

image

Результаты тестов


Тестирование проводилось на Intel Gen 11 и Intel Xe, а также на NVIDIA Turing. На всех поддерживаемых средствах наблюдается схожий прирост производительности.

Тестируемое оборудование


Операционная система: Windows 10 Pro 64-bit (10.0, Build 18362) (18362.19h1_release.190318-1202)
Процессор: Intel Core(TM) i9-9900X CPU @ 3.50GHz (20 CPUs), ~3.5GHz
Память: 98304 MB RAM
Видеокарта: NVIDIA GeForce RTX 2080 SUPER

Все тесты проводились при настройках игры Ultra с разрешением 4K









image
Все техники при настройках VRS On

image
Включение всех техник при VRS Performance




Расширение VRS


Можно получить дополнительные преимущества, расширив описанные здесь методы применения VRS.

Дальнейшие методы маскирования


Дополнительные методы маскирования могут повысить эффективность VRS или уменьшить количество артефактов. Эти методы могут варьироваться в зависимости от случаев, когда области затенены или размыты во время игры:

  • Размытие в движении (Motion Blur): Камеры, использующие быстро движущиеся сцены, могут легко использовать VRS в большинстве сеток с включенной функцией размытия в движении.
  • Частицы (Particles): Полигональные сетки, спрятанные за толстым слоем частиц, можно использовать для маскирования VRS высокой интенсивности.

Динамический VRS


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

Динамическое масштабирование разрешения (DRS)


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

Вместо заключения


Умелое использование Tier 1 VRS позволяет добиться значительного прироста производительности на широком спектре оборудования с минимальным влиянием на качество изображения.
Подробнее..

Перевод Как создаётся изображение формата RAW?

24.08.2020 12:15:17 | Автор: admin

Рис. 1: фото на Nikon D610 с объективом AF-S 24-120mm f/4 и параметрами 24mm f/8 ISO100

Каковы базовые шаги создания изображения формата RAW на низком уровне? В данной статье я опишу, что происходит под капотом цифровой камеры, где необработанные данные превращаются в пригодное для просмотра изображение формата RAW иногда этот процесс называют рендерингом. Для демонстрации преобразования информации с изображения на каждом шаге я буду использовать приведённую в начале статьи фотографию, сделанную на Nikon D610 с объективом AF-S 24-120mm f/4 и параметрами 24mm f/8 ISO100.

Рендеринг это преобразование RAW и редактирование


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

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

Пример чистого конвертера RAW dcraw от Дэвида Коффина. Пример чистого редактора Photoshop от Adobe. Большинство конвертеров RAW на самом деле сочетают в себе функции преобразования RAW с функциями редактора (к примеру, Capture NX, LR, C1).

7 шагов базового преобразования RAW


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

  1. Загрузить линейные данные из файла RAW и вычесть уровни чёрного.
  2. Провести балансировку белого.
  3. Подправить линейную яркость.
  4. Обрезать данные изображения.
  5. Восстановить исходное изображение из мозаики (дебайеризация).
  6. Применить преобразования и коррекции цветов.
  7. Применить гамму.

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

+ Отображение тона: адаптация к динамическому диапазону устройства вывода


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

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

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

1. Загрузить линейные данные из файла RAW и вычесть уровни чёрного


Первый шаг в преобразовании RAW простая загрузка данных из файла RAW в память. Поскольку форматы RAW отличаются от камеры к камере, большинство конвертеров используют вариации программ LibRaw/dcraw с открытым кодом. Следующие команды для LibRaw или dcraw перепакует RAW в линейный 16-битный TIF, который смогут прочесть приложения для обработки (Matlab, ImageJ, PS, и т.д.). Установка программы не требуется, нужно только, чтобы исполняемый файл был в переменной PATH или находился в той же директории.

unprocessed_raw -T  yourRawFile

или

dcraw -d -4 -T  yourRawFile

Если ваша фотоматрица хорошо спроектирована и находится в рамках спецификаций, записанные данные будут находиться в линейном соотношении с яркостью света, однако обычно они хранятся со смещением, зависящим от камеры и канала. Т.н. уровни чёрного имеют величины в несколько сотен или тысяч DN, и их нужно вычесть из оригинальных необработанных данных, чтобы нулевая интенсивность пикселей совпадала с нулевым количеством света. dcraw с приведённой выше командной строкой сделает это для вас (хотя и применит некие средние значения). При использовании unprocessed_raw вам нужно знать значение уровня чёрного для вашей камеры и вычесть его соответственно (или можно использовать параметр B, который, правда, в текущей версии LibRaw, похоже, не поддерживается).

Оказывается, что Nikon вычитает уровни чёрного из каждого канала перед записью в файлы D610, поэтому для нашего справочного кадра любая из команд сработает. Я загрузил её командой dcraw -d -4 T, которая также масштабирует данные до 16 бит (см. шаг 3 по коррекции яркости далее).

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


Рис 2: матрица цветного фильтра Байера: расположение RGGB.

Байеровская матрица для D610 имеет раскладку RGGB. Необработанные данные выглядят на этом этапе следующим образом, как недоэкспонированное чёрно-белое изображение:


Рис. 3: Шаг 1: линейное необработанное изображение с вычтенными уровнями чёрного. Должно выглядеть таким же ярким, как следующий рис. 4. Если нет ваш браузер неправильно обрабатывает цвета (я использую Chrome, и он, очевидно, обрабатывает их неправильно).

Если фото чёрное, то ваш браузер не знает, как показывать правильно размеченные линейные данные. Интересно, что редактор WordPress показывает изображение верно, но после публикации в Chrome оно выглядит неправильно (примечание: к 2019 году это, наконец, исправили) [статья 2016 года / прим. перев.]. Вот, как должно выглядеть это изображение:


Рис. 4: то же фото, но в виде CFA grayscale

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

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


Рис 5: CFA-пикселизация видна на равномерно серой карточке (сверху) и на окрашенных листьях.

2. Данные о балансе белого


Из-за спектрального распределения энергии источника света (в данном случае это частично закрытое облаками небо, свет которого проникает сквозь листву) и спектральной чувствительности фильтров, расположенных на матрице, различные цветные пиксели записывают пропорционально большие или меньшие значения даже при одном и том же освещении. Особенно это очевидно в нейтральных частях изображения, где, казалось бы, должны проявляться одинаковые средние значения по всем цветовым плоскостям. В данном случае на нейтрально серой карточке красные и синие пиксели записали значения в 48,8% и 75,4% от зелёного значения соответственно, поэтому они кажутся темнее.

Поэтому следующий шаг применить баланс белого к линейным данным CFA, умножив каждый красный пиксель на 2,0493 (1/48,8%), а каждый синий на 1,3256 (1/75,4%). В терминах Adobe мы получим чёрно-белое изображение, нейтральное относительно камеры. Тогда гарантированно, за исключением шума, все пиксели покажут одинаковые линейные значения на нейтральных частях изображения.


Рис. 6: после балансировки белого пикселизация серой карточки исчезает, но на цветных объектах она ещё видна.

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

3. Корректировка линейной яркости


Большинство современных цифровых камер с заменяемыми объективами дают необработанные данные размерностью 12-14 линейных битов. Стандартная битовая глубина файлов (jpeg, TIFF, PNG) обычно задаётся в виде множителей 8, и любимой глубиной большинства редакторов сегодня являются 16 бит. Мы не можем просто взять 14-битные данные с вычтенным уровнем чёрного и сохранить их в 16 бит всё попадёт в нижние 25% линейного диапазона и будет слишком тёмным. В идеале нам нужно обрезать данные после балансировки белого и вычитания чёрного, чтобы соответствовать обрезанным данным 16-битного файла, и соответствующим образом их масштабировать (см. шаг 4). Простой способ примерного приведения данных просто умножить 14-битные данные на 4, масштабируя их до 16 бит. Именно это было сделано на шаге 1 после вычитания чёрного (dcraw -d -4 -T делает это автоматически, а с unprocessed_raw нужно будет сделать это вручную).

Говоря о масштабировании, мы можем также захотеть подправить нашу яркость. Этот шаг субъективен, и его, вероятно, не стоит делать, если вы гонитесь за честным рендером изображения, таким, какое оно получилось на матрице и записалось в файл RAW. Однако ни у кого не получается идеально выставить экспозицию, а разные камеры часто измеряют среднее серое в разных процентах от необработанных данных, поэтому полезно иметь возможность подправлять яркость. Поэтому у многих конвертеров относительные ползунки называются коррекция экспозиции или компенсация экспозиции. В Adobe есть связанная с этим метка DNG, нормализующая экспонометр для разных камер, BaselineExposure. Для D610 она равняется 0,35 шага.

На мой вкус наше изображение темновато, и карточка WhiBal, которая должна иметь 50% отражающей способности, в полном масштабе даёт всего 17%. Линейная коррекция яркости это умножение каждого пикселя в данных на константу. Если мы посчитаем, что на данном снимке не нужно сохранять самые яркие участки изображения яркостью выше 100% рассеянного белого света, то эта константа в данном примере будет равняться 50/17, примерно 1,5 шага коррекции. В данном случае я решил применить субъективно-консервативную коррекцию в +1,1 шага линейной яркости, умножив все данные в файле на 2,14, и получилось следующее:


Рис. 7: CFA-изображение после вычитания уровней чёрного, балансировки белого, обрезки, коррекции линейной яркости на 1,1 шаг

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

4. Убедиться в ровной обрезке данных по балансу белого


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


Рис. 8: Сверху вниз: гистограммы R, G, B после применения множителей баланса белого к необработанным данным, до обрезки. Обязательно обрезать все три до уровня меньшего из каналов, чтобы в ярких областях не появилось ложных цветов. Данные изображения отложены на отрезке 0-1, где 1 полный масштаб.

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


Рис. 9: Слева: правильно обрезанное изображение после применения баланса белого. Справа: изображение не обрезано. Жёлтой стрелкой показана область, близкая к максимальной яркости, где неполная информация от цветовых каналов даёт розоватый оттенок.

Вместо обрезки можно было бы сделать определённые предположения о недостающих цветах и дополнить относительные данные. В продвинутых конвертерах RAW этим занимаются алгоритмы или ползунки с названиями типа реконструкция ярких участков. Способов сделать это существует множество. К примеру, если не хватает всего одного канала, как в случае с зелёным от 1,0 до 1,2 на рис. 8, проще всего предположить, что яркие участки находятся в районе нейтрально белого цвета, а в необработанных данных изображения сделан правильный баланс белого. Тогда в любом квартете, где зелёный цвет будет обрезан, а два других не будут, то значение зелёного будет равняться среднему значению двух других каналов. Для данного снимка такая стратегия смогла бы реконструировать не более 1/4 шага в ярких участках (log2(1,2)). Затем потребовалось бы провести сжатие ярких участков и/или повторную нормализацию новой полной шкалы до 1,0.

5. Дебайеризация данных CFA


До сего момента CFA-изображение находилось на одной чёрно-белой плоскости, из-за чего цветные участки казались пикселизированными, как видно на рис. 6. Пришло время дебайеризации разделения красных, зелёных и синих пикселей, показанных на рис. 2, на отдельные полноразмерные цветовые плоскости, путём аппроксимации отсутствующих данных (на рис. ниже они показаны в виде белых квадратов).


Рис. 10: дебайеризация заполнение отсутствующих данных в каждой цветовой плоскости с использованием информации из всех трёх плоскостей.

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

В данном испытании я решил схитрить и просто сжать каждый квартет RGGB в единый пиксель RGB, оставив значения R и B для каждого квартета такими, какие они есть в необработанных данных, и усреднив G по двум (это эквивалентно режиму dcraw h). Это алгоритм дебайеризации 22 по ближайшим соседям. Он даёт более удобное в работе изображение вдвое меньшего размера (по линейным измерениям, или вчетверо по площади).


Рис. 11: необработанное RGB-изображение после вычитания уровней чёрного, балансировки белого, обрезки, коррекции линейной яркости и дебайеризации 22 по ближайшим соседям (эквивалент dcraw h).

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

Figure 11 shows that our raw capture data is now in RGB format with three fully populated color planes. The colors are those recorded by the camera (can we say camera space?) as shown by your browser through your video path and monitor. They seem a bit bland but not too far off. The tones are not in a standard RGB color space, so imaging software and hardware down the line do not necessarily know what to make of them. The next step is therefore to convert these colors to a generally understood, colorimetric, standard color space.

6. Преобразование и коррекция цветов


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

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

К счастью для нас, измерением и расчётом этих матриц занимается несколько прекрасных лабораторий а потом они выкладывают расчёты в открытый доступ. К примеру, DXOmark.com выпускает матрицы для преобразования данных из необработанного фото после балансировки белого в sRGB для двух источников для любой камеры из их базы данных. Вот, например, их матрица для Nikon D610 и стандартного источника света D50:


Рис 12: цветовая матрица от DXO lab для источника света D50: от данных, прошедших балансировку белого и дебайеризации к sRGB.

Какая из компромиссных матриц будет лучшей, зависит от спектрального распределения энергии источника света в момент снятия кадра, поэтому реальную матрицу обычно интерполируют на основе парочки ссылок. В мире Adobe сегодня это стандартные источники света A и D65, отвечающие за предельные варианты типичного освещения в повседневной фотографии, от лампы накаливания до дневной тени и съёмки в помещении. Затем преобразованные данные адаптируются к источнику света, соответствующему итоговому цветовому пространству для sRGB это, например, D65. В итоге получается некая матрица, как, например, та, что показана на рис. 12. Потом остаётся просто перемножить её на RGB-значения каждого дебайеризованного пикселя после шага 5.

В спецификации своего DNG Converter Adobe советует более гибкий процесс. Вместо прямого перехода от CFA камеры к колориметрическому цветовому пространству Adobe сначала преобразует данные в цветовое пространство Profile Connection (XYZ D50), умножая данные после балансировки белого и дебайеризации на интерполированную линейную прямую матрицу [forward matrix], а потом уже приходит в итоговое цветовое пространство типа sRGB. Иногда Adobe также применяет дополнительную нелинейную цветовую коррекцию с использованием специальных профилей в XYZ (на языке DNG это HSV-коррекции через ProPhoto RGB, HueSatMap и LookTable).

Прямые матрицы камеры, сделавшей снимок, записываются в каждый DNG-файл, хвала Adobe. Я скачал оттуда матрицы для D610, а матрицы XYZD50 -> sRGBD65 с сайта Брюса Линдблума, и получил итоговое изображение:


Рис. 13: Честно сконвертированное изображение. Необработанные данные, вычтены уровни чёрного, произведена балансировка белого, обрезка, подправлена яркость, дебайеризация через ближайшего соседа 22, цвет подкорректирован и преобразован в цветовое пространство sRGB.

Теперь цвета такие, какие ожидают встретить программы и устройства в цветовое пространство sRGB. Если вам интересно, то данное изображение практически идентично тому, что выдаёт конвертер необработанных данных Capture NX-D от Nikon с профилем Flat. Однако выглядит оно не очень резко из-за плохой контрастности наших мониторов (см. Отображение тона).

7. Применение гаммы


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

+ Отображение тона


В 2016 году почти всегда требуется ещё и коррекция тона, чтобы выбрать, как именно втиснуть большой динамический диапазон камеры в небольшой диапазон отображающего устройства. К примеру, в зависимости от вашей устойчивости к шуму, динамический диапазон моего D610 имеет 12 шагов, при том, что у моего совсем неплохого монитора коэффициент контрастности равен 500:1, или порядка 9 шагов. Это значит, что три нижних шага с камеры не будут видны на мониторе из-за его подсветки.

Кривая RGB субъективно перераспределит тона по диапазону, так, чтобы некоторые тени стали виднее за счёт некоторых самых ярких участков (поэтому эту кривую называют градационной кривой). На момент написания статьи такую кривую обычно применяет Adobe в ACR/LR во время рендеринга перед тем, как показать изображение в первый раз:


Рис. 14: градационная кривая применяемая ACR/LR ближе к концу процесса рендеринга в Process Version 3 (2012-2016). Горизонтальная ось нелинейна.

В данном случае я её не использовал. Я просто применил кривую увеличения контраста и добавил немного резкости в Photoshop CS5 к рис. 13, чтобы получить итоговое изображение:


Рис. 15: Итоговое sRGB изображение. Изначально необработанные данные, вычтены уровни чёрного, произведена балансировка белого, обрезка, подправлена яркость, проведена дебайеризация, цвет подкорректирован, применена градационная кривая

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

Подытожим


Итак, для базовой конвертации RAW с линейной подстройкой яркости и цветов требуется:

  1. Загрузить линейные данные из файла RAW и вычесть уровни чёрного.
  2. Провести балансировку белого.
  3. Подправить линейную яркость.
  4. Обрезать данные изображения.
  5. Провести дебайеризацию.
  6. Применить преобразования и коррекции цветов.
  7. Применить гамму.

И всё покров тайны с конвертертации необработанных данных сорван.

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

s = raw2RGB(DSC_4022 , ROI , 1.1)

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

Подробнее..

Категории

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

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