Всем привет, видела на просторах интернета достаточно мало материалов на отложенный рендеринг, при том что сама его идея-то достаточно прикольная, так что решила вставить свои пять копеек сама.
Сразу скажу, что этот док не рассчитан на то, чтобы дать вам готовый код, который можно вставить себе в проект и всё магическим образом заработает. Так что если пришли посмотреть на промпты — вероятно, здесь их будет очень мало.
Ну, и так же, если вам что-то непонятно, всегда можете в комментарии задать уточняющий вопрос, и я постараюсь покрыть этот момент более подробно, потому что, походу, я просто люблю объяснять вещи и наблюдать за тем, что люди реально начинают что-то понимать…
Deferred rendering: что такое и зачем надо
Зачем
Представьте, что вы художник. Ещё лучше — художник-фрилансер, которого просят нарисовать такой-то арт. И вам говорят: нарисуй красивую девочку. Ну, окей, мысль понятна, нарисовали, раскрасили. Отдаёте на выданье, а вам в ответ: слушай, а можешь вот ещё ей очки нарисовать, чтобы прям круто было? Ну, предположим… А ещё вот знаешь, а чё она в топе, хочу типа вся такая домашняя, в розовом худаке. И вот ты рисуешь этот худак поверху топа, где ты там заморочился со складками, и начинаешь так потихонечку повышать ценник. Потому что ну а зачем старался идеально отрисовать то, что в итоге поперекрывали другими объектами и получается зря усилия тратил?

Без шуток, при прямом, forward рендере это вы заказчик-дурак, а художник — ваша видеокарта. Которая имеет свою стоимость — производительность. Вы заставляете её считать освещение, чтобы идеально отрисовывать то, что не будет видно — она жрёт фпс. На карте с тремя объектами проблема не ощущается, а вот в каком-нибудь хорошо так обставленном уровне уже будет ощутимо и неприятно.

Вернёмся к нашей аллегории с художником — явно напрашивается претензия: может ты сразу мне скажешь, что конкретно должно быть на рисунке? Чтобы я не пытался придумать, как отрисовать такие-то складки на футболке, которую не будет видно?
С отложенным рендером такая же суть: ладно уж эти ваши данные, которые мы тащим из текстур, типа цвета и прочих смешных штук для красивой отрисовки, это просто вытащить тексель и использовать его по назначению. Но мы ж хотим, чтоб было там красивое освещение, ощущение 3дшности, все дела. Это надо что-то считать — и вот было бы неплохо, если бы это нужно было делать только тогда, когда это имело смысл — то есть когда часть объекта реально видна на экране.
Что такое
Поэтому рендер разделили на два прохода — проход геометрического буфера, GBuffer pass, и проход освещения, Lighting pass. Такая договорённость: давай мы сначала соберём информацию о том, что за сцену мы хотим отрисовать, а потом уже будем раскрашивать то, что нам конкретно видно.
(О всяких проходах прозрачности мы здесь не говорим, это отдельные техники, которые ведут себя как отдельные проходы в любом рендере).
Как имплементировать
Сначала мы собираем информацию — то, что мы и так умеем делать, потому что этот процесс в любых проходах одинаковый — нужно как-то понять, как отображать объекты конкретно в нашем экране, здесь ничего нового. Новое здесь то, что нам нужно научиться эти данные сохранять.
Дальше нам нужно сделать так, чтобы эти данные можно было использовать как опору в нашем следующем проходе.
И соответственно сделать второй проход, где мы уже из данных, собранных в первом проходе, собираем конфетку с освещением.
Также нам нужно научиться обращаться с локальными источниками света наиболее оптимальным образом. Способов несколько, я возьму самый понятный мне — volume lighting, заключающийся в восприятии источников света как локальных мешей.
В процессе всего этого главного движа будут ответвления на разбор разных штук, базовое понимание которых необходимо для того, чтобы вообще собрать этот пайплайн. Ответвление скорее всего будет хаотичное, но оно будет иметь заголовки, поэтому можете ориентироваться по оглавлению.
Итак, начнём.
Отдельное ответвление: RenderDoc
Кто всё ещё не скачал — серьёзно, скачайте, это лучшая штука для дебага рендера.
Парочка штук, которые точно не будет лишним знать из этого приложения:
Working Directory
Позволяет не таскать туда сюда файлы, если у вас есть какие-то локальные пути. Просто поставьте папку, в которой находится ваш проект, а не экзешник.

Texture Viewer
То, куда вы будете смотреть чаще всего во время работы над . Позволяет посмотреть, какие текстуры использовались при рендере и что в итоге получилось при таком-то draw вызове. В инпутах для текстур берутся названия из шейдера, так что с ориентированием не будет никаких проблем. Если текстуры в инпутах нет — значит при рендере она не использовалась.

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

Event Browser
Все предыдущие штуки привязаны к вызовам отрисовки, на каждый вызов разные данные. Чтоб найти конкретно отрисовку того, что нужно вам, надо будет пошариться здесь — это несложно, включите Texture Viewer и просто кликайтесь, на главном экране будет показываться, что отрисовалось на тот момент.

Pixel Content
При просмотре любой текстуры можно нажать правой кнопкой мыши на пиксель и отобразятся данные, где он находится и какого он цвета, а также появится вот такое окошко: при нажатии на Debug вас перенесёт в пиксельный шейдер, который можно будет продебажить и посмотреть, как он дошёл до такого цвета. Имеет смысл, понятное дело, если вы смотрите на пиксель текстуры вывода — ту, в которую вы что-то рисовали.

И вот теперь, когда основной инструментарий главного нашего союзника покрыт, можно реально начинать разбираться, а чё нам делать.
GBuffer
Давайте посмотрим сейчас не на сцену целиком, а на обыкновенный объект — я для примера возьму то, что отрисовывала, когда училась самостоятельно делать OpenGL.
Мы имеем на него такие данные: его геометрию и всякие текстурки, чтоб красиво его отобразить. То есть всё, что нам нужно знать для его отрисовки — это где он, а ещё из каких текстур брать его данные и как их конкретно применять.

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

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

Но это так, спойлеры.
То есть, если вкратце: GBuffer нам нужен просто для того, чтобы в процессе отрисовки отфильтровать, какие данные в итоге реально нам будут видны, так как объекты могут друг друга перекрывать.
Мировые координаты
Чтобы рисовать локальные источники света, а также хз, делать там какой-нибудь туман и прочие прелести, нам нужно знать, а где конкретно в мире находится такой-то рисуемый нами пиксель.
Простой вариант — сохранять эти данные также в текстуру. Но так делать не надо.
Потому что во-первых вас за это забуллят.
А во-вторых смотрите, немного душной математики: скажем у вас картинка 800х600, это 800*600 пикселей. Нам надо держать в этих пикселях три флотовых значения, то есть один пиксель держит в себе 12 байт, и тогда нам нужно отдельно на текстуру для мировых координат выделить 12*800*600 байт, то есть примерно 5.5 Мб. Та же математика для окна 1920х1080: 23.7 Мб. Неиронично звучит как-то жирно…
Заставить ГПУ провести пару лишних умножений банально выгоднее. Концепт простой: почти все трансформации можно обратить в обратную сторону. Если вы умножили что-то на матричку, умножьте это на обратную матричку и получите изначальный вариант.
Чтобы получить NDC координаты, которые потом преобразовались в экранные, мы умножали наши мировые координаты на матричку ViewProj. Следовательно, чтобы получить мировые координаты, мы должны наши экранные координаты вернуть в NDC пространство, а потом просто умножить на инвертированную матричку ViewProj. Объективно звучит не так уж и сложно?..
На деле это муторная штука, просто потому что оси в NDC пространстве и в UV координатах не совсем совпадают по направлению…
Что я имею в виду: вот NDC пространство:

И вот UV пространство:

Как вы видите, в NDC ось ординат направлена вверх, а у UV вниз.
Почему из-за этого возникает проблема: вот мы поняли, что мы в такой-то координате экрана xy, решили перевести её в NDC.
Мы находим, что относительно x мы в x/width, а по y — в y/height. Относительно z, т.к. глубина нам всё ещё нужна — в gDepth.sample(x, y)/gDepth.load(x, y), вытаскиваем из буфера глубины.
Теперь у нас есть UV координаты пикселя на экране, нам надо растянуть их на NDC пространство: в HLSL они такие: [-1, 1], [-1, 1], [0, 1], то есть x и y могут быть негативными, глубина нет.
Растягиваем наш [0, 1] x: 2x-1. С глубиной вобще всё класс, она изначально хранится в этом промежутке. А ось игрек нам надо блин перевернуть, потому что она у нас сейчас направлена не в ту сторону) поэтому мы по факту делаем вот это: (1 - y) * 2 - 1, что превращается в простое 1-2y. И вот теперь, ура, у нас есть координаты в NDC пространстве.
Умножаем их на обратную матрицу, чтобы обратить трансформацию, ну и вообще нормализуем по w-координате.
И, в силу того, что это реально сложная штука, держите код:

(texcoord здесь не uv, а полноценные нормальные координаты. Нам надо будет научиться читать буфер глубины вместе с остальными, а также сторить информацию о размере окна, чтобы проворачивать такую штуку)
Так что да, лучше не тратить память, а заставить ГПУ самому бэктрэкать обратно к мировым координатам и жить счастливо. Главное переверните ось ординат, она в своё время столько жизни мне попортила.
Как сторить данные со стороны шейдера
Есть волшебные буквы, к которым мы с вами ещё вернёмся: SV, Shader View. Они используются в семантике для шейдера. И вот, когда мы писали пиксельный шейдер, мы писали, чем конкретно по семантике является наше возвращаемое значение:

Маленькая напоминалка, что такое семантика:

Мы помечаем через двоеточие, какой смысл несёт в себе та или иная часть вертексов и прочих штук, что мы таскаем через шейдера.
SV Target, Shader View Target, означает для шейдера, что вот сюда надо засторить данные. Вопросов не возникает, пока эти данные являются цветами, ну картинку нарисовали, прикольно. Но вообще-то там не обязательно должны быть цвета.
А что делать, если нам нужно засторить не один цвет, а два? Сделать struct, в котором мы скажем: у нас тут вот есть одно свойство, тоже float4 SV_Target, а есть ещё второе, и сказать в пиксельном шейдере, что возвращаемое значение — это теперь тот самый struct. А если три цвета? Не поверите, то же самое. А если десять? Всё ещё.
Вообще необязательно сторить по float4, можно настроить и другие форматы — вкину маленький спойлер, что такое нужно прописывать в дескрипторах к текстурам — но я этим не занималась.
Релевантные куски кода, чтобы вы понимали, насколько это просто выглядит в шейдере:



В целом, не выглядит, как что-то сложное, согласны?
Как всё это настроить со стороны ЦПУ
Здесь уже будет понеприятнее…
Пожалуй начну с просто списка того, что нужно учитывать во внимание, чтобы это реально работало:
Надо создать текстуры
Надо создать к ним подходящие дескрипторы (об этом всём немного позже, потому что это достойно отдельного ответвления)
Надо привязать их как рендер таргеты для конкретно этого вызова отрисовки
Текстуры и дескрипторы
Вообще скажу честно, я реально поняла ценность дескрипторов только когда начала изучать отложенный рендер.
Чтобы, опять-таки, они заимели какой-то смысл, влепим ещё одну аллегорию. Далеко ходить не будем, возьмём в пример меня.
Вот есть я. Я могу быть тестировщиком у себя на основной работе, а могу быть доставщиком на второй. На своей основной работе у меня есть звание тестировщик, и мои характеристики, которые интересны другим людям, это насколько я внимательная и как хорошо отслеживаю корнер-кейсы. На своей второй работе у меня есть звание доставщик и людям уже всё равно на мои первые характеристики, им интересно, успею ли я за 12 минут донести их 8-килограммовый заказ. Также давайте учтём, что я не работаю две работы одновременно — нет, я заканчиваю основную, переодеваюсь в униформу, доезжаю на велике до точки и теперь я готова делать вторую.
Так вот, к чему вся эта демагогия: я ресурс (текстура), мои работы — это мои дескрипторы. Ну, и у дескрипторов есть кучи, здесь я ничё нового не скажу, дескрипторы просто надо где-то хранить и всё.
Создание
Сначала нам надо создать текстуры, а потом просто сказать, мол, мы к тебе будем обращаться вот в таких случаях — и на каждый случай нацепить дескрипторы, чтобы потом обращаться только! по ним.
Итого, сначала мы создаём текстуру:

Шпаргалка по форматам хранимых текстур:

Шпаргалка по флагам, в зависимости от того что за текстура (глубина или другое)

Шпаргалка по формату clearValue для буфера глубины (он отличается)

И ура, мы создали нашу текстуру через create committed resource

Штука, которая показалась лично удобной мне: у каждой текстуры есть один дескриптор для записи (RTV или DSV) и один дескриптор для чтения (SRV), поэтому можно их компоновать в структуры.

Создали мы текстуру, ну здорово, теперь давайте дадим ей дескрипторы и накидаем их в нужные нам дескрипторные кучи (кучи делятся по типам дескрипторов, то есть есть отдельная для RTVшек, DSV и SRV).


В целом, из основного здесь что — привязываем дескриптор к текстуре, пихаем нужное описание, кидаем в подходящую кучу — не путаемся в форматах.
Как составлять описания к дескрипторам, я всё ещё не очень знаю, но надеюсь, главный смысл зачем оно нужно я передать смогла.
В любом случае, создание текстур и дескрипторов к ним мы покрыли.
Теперь время показать, где можно ими пользоваться.
Важный момент: OnResize
В буфере геометрии мы храним текстуры. У текстур есть размер, который идентичен размеру окна, в которое мы что-то рисуем.
Если мы меняем окно, размер текстур нам тоже нужно поменять.
Поэтому при каждом обновлении размера окна нужно пересоздавать все текстуры и создавать к ним дескрипторы заново.
Подключение
Здесь мы можем просто-напросто открыть любую главу луны (кстати да, книга Луны Франка - база моего фреймворка) и посмотреть на то, что конкретно происходит в функции Draw(). А конкретно — как блин ставятся рендер таргеты. Ну, и, во-первых, первое, что нам нужно сделать, это убедиться, что теперь текстуры готовы быть тем, чем мы хотим их видеть — вот помните я упоминала, что мне перед тем, как работать вторую работу, надо переодеться и там оказаться?

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

Как Visual Studio вежливо нам показывает, ставятся они с помощью того, что мы говорим, сколько у нас будет дескрипторов, как раз-таки передадим наши дескрипторы, скажем, один он там или нет, ну и пихнём буфер глубины заодно.
Итого что получается:
в NumRenderTargetDescriptors ставим количество RTVшек, в которые будем что-то сохранять (буфер глубины не учитываем, он не RTV и идёт отдельно)
в pRenderTargetDescriptors передаём как массив все дескрипторы наших RTV (во пошло поехало, не зря создавали).
у нас не один хендл, так что RTsSingleHandleDescriptorRange уходит в false.
ну и добавляем наш буфер глубины.
И на этом могу вас поздравить, проход буфера геометрии на этом можно считать завершённым! Пока что отдебажить, работает ли всё хорошо, вероятно, будет сложно, но первую часть мы покрыли!
Lighting pass: directional light
Теперь прикинем, как нам это рисовать.
Начнём с того, что прямой свет пытается осветить всё, поэтому можно нарисовать его с помощью одного большого треугольника, который захватывает весь экран, и просто пройтись по каждому пикселю и посчитать что нужно посчитать.
Draw call: виды
Мы обычно используем DrawIndexedInstanced, когда что-то рисуем. Есть ещё другой вариант функции: DrawInstanced: разница в том, что в первом случае мы говорим: смотри, у нас есть буфер вершин, можешь по нему пройтись вот в таком порядке, который указан в нашем буфере индексов? Во втором варианте мы говорим: короче просто сделай столько-то инстансов за раз, не буду я тебя этими буферами грузить.
Выглядит этот вызов примерно так:

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

PosH: SV_Position
Это ещё одна штука, ценность которой я осознала только когда начала разбираться с этой лабой.
Был момент, когда мне для учёбы нужно было написать рендер-систему самостоятельно, и я сидела, разбиралась, а как это всё в экранные координаты переводить... Сейчас мы по сути этого не делаем: да, доводим до NDC пространства, но на этом как будто бы всё?.. И вообще зачем нам PosH, мы же его никогда не используем потом в пиксельном шейдере…
Волшебные буквы SV, помните? Это тот самый параметр, который конвейер хавает под капотом и понимает, какой конкретно пиксель мы имеем в виду — то есть, по сути, если мы не будем отдавать SV_Position, то скорее всего нам ничего на экране не нарисует, потому что а кто его знает где это рисовать.
Что ещё более забавно, обычно все PosW, Normal и ещё куча других штук, которые мы переносим в VertexOut в пиксельном шейдере в общем-то остаются теми же, либо, ну, немного интерполированными, потому что мы где-то в середине треугольника. Но не PosH. PosH, на выходе имевший значение в районе [0.4, 0.3], в пиксельном шейдере может стать [853, 304].
Честно говоря, когда я впервые это заметила, смотрела я на это как-то так:

На самом деле в пиксельном шейдере значение в PosH уже переведено в полноценные экранные координаты — не приблизительные UVшки, а по факту: вот такой по счёту пиксель сейчас рисую. Что так-то экстра хорошо, потому что на максимально точное вытаскивание данных без всяких смягчений нам нужна функция texture.Load(), которая требует конкретно координаты пикселя, а не просто UV. Так что вот, можете брать на заметку.
Создание большого треугольника
Собственно, что такое PosH мы разобрались, теперь давайте его собирать — напоминаю, что нам нужно собрать NDC пространство.
Наш экран по игреку и иксу находится в пределах -1 до 1, следовательно, если мы хотим охватить его весь, нам нужно сделать треугольник, который выходит за пределы экрана: то есть что-то вот такое.

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

Связка ГПУ и ЦПУ: Root Signature
Если мы что-то регистрируем в шейдере, значит, мы передаём это через ЦПУ. Тогда нам надо обозначить в соглашении, что вот мы будем передавать столько-то таких-то вещей.
Этим занимается рут сигнатура. В принципе сигнатура — это всегда о том, что мы туда-то будем передавать такие-то аргументы.
Давайте здесь на самом деле просто поковыряем некоторые примеры: например, возьмём опять-таки 9 лабу Луны.
У нас есть такие данные, зарегистрированные в шейдере:

То есть две текстуры, куча сэмплеров и три константных буфера.
Сэмплеры мы передаём отдельно, итого у нас получается по сути 5 параметров — или 4.
Текстуры как параметры
Важный момент: текстуры, в зависимости от их взаимного расположения, могут выходить либо за один параметр, либо за несколько. У вас чаще всего они уйдут за один параметр. В чём суть: если дескрипторы к этим текстурам лежат друг за другом в кучке, можно сказать, мол, там будет столько-то дескрипторов, поставить указатель на первый, ну а дальше конвейер разберётся, по массивчикам бегал, оффсеты знает.
Итого, представим, что наши текстуры здесь, сидят рядом друг с другом в своей куче, и тогда параметров получается в сумме 4. Один из них — descriptor table — то есть несколько дескрипторов, а все остальные константные буферы.
Создание рут сигнатуры для таких данных выглядит вот так:

То есть, мы создаём ренж для дескрипторов, чтобы пихнуть его как один параметр.
Задачка на подумать: вам нужно пропихнуть 5 текстур, стоящих рядом, в шейдер. Как это будет выглядеть в шейдере и как это будет выглядеть в рут сигнатуре?
Задачка на подумать х2: вам нужно пропихнуть 5 текстур, 2 из них стоят рядом, ещё 3 стоят другой кучкой где-то отдельно. Вопрос тот же.
Подключение текстур в вызов отрисовки
Во-первых!! Всегда перед установкой конкретных параметров в начале самом ставим релевантную кучу, из которой потом берём дескрипторы.

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

Сразу говорю, ставьте её только единожды как она реально понадобилась, если потом её не меняли можете ничё не делать, чтобы не перенагружать ЦПУ и ГПУ бесполезными командами.
Потом ставим нужные нам данные в нужный нам слот сигнатуры:

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

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

Если вы будете хранить текстуры как отдельные параметры, потому что у вас они не находятся рядом, вам нужно будет каждую делать как параметр, вытаскивать и ставить отдельно рут параметр.
И, в целом, на этом мне пожалуй больше нечего сказать по поводу дескрипторов и текстур.
А, ну кроме того, что когда вы начнёте использовать их как SRV, вам нужно будет поменять перед этим их состояние с помощью resource barrier transition на состояние shader resource view.
Константные буфера
Как конкретно прописывать, да и ставить их, было описано выше, вместе с текстурами. Тем не менее, есть один очень важный пункт, который нужно иметь в виду, когда создаёшь константные буфера — или структурированные, кстати, тоже.
Присмотритесь в этот скрин:

Я специально сгруппировала их по кучкам в 4 флота, потому что когда передаёшь что-то на ГПУ, оно будет сжиматься в кучки 16 байт — что, собсна, представляет собой 4 флота/4 инта. Так что имейте это в виду, когда заполняете констант буфер:
Если добавите условно сюда ещё float mood, но не добавите ещё float3 pad, скорее всего ваш mood будет невалиден.
Если сделаете условно float3 eyePos float2 renderTargetSize, второе разрежется сжатием надвое и тоже перестанет быть валидным. Поэтому в скрине есть cbPerObjectPad1, просто паддинг который выравнивает данные, чтобы они читались корректно
Если впишете в констант буфер булеву переменную, она скорее всего не будет работать как надо, потому что bool весит один байт, а у конвейера отступы в 4) и всё после этого буля полетит в тартарары.
Задачка на подумать: нужно избавиться от nearZ и gFarZ, потому что они больше не нужны. Как будете это делать, чтобы по предыдущим трём правилам ничего не поломалось?
Задачка на подумать х2: нужно добавить переменную, которая будет просто принимать значения правда ложь. Какой тип дадите? Что добавите? Нужны ли будут паддинги?
Со стороны ЦПУ оно будет выглядеть практически также, ясное дело пофиг на названия, но все отступы должны совпадать:

И, как будто, о константных буферах на самом деле тоже всё…
И тогда в целом прямой свет тоже закрыт… просто вот ставишь текстурки, ставишь констант буферы, делаешь вызов рисования…
Более общая картина: PSO
Думаю, ещё стоит упомянуть один момент, что вот это вот всё, что мы здесь выше рассматривали, является банальной настройкой PSO. Если в итоге глянуть на функцию Draw(), можно увидеть, что там ну конкретно для рисования же реально просто вот функции DrawIndexInstanced есть где-то одной строчкой кода в цикле, а всё остальное — просто подключение нужных вещей.
Соответственно, что конкретно можно сказать про PSO: это большая конфигурация конкретно такого-то прохода. То есть это штука, которая говорит: вот у меня есть такие-то шейдеры, информация в них будет поступать так-то, с таким-то форматом вершин (Input Layout) и такими-то параметрами (Root Signature).
Потом, когда мы подготавливаемся чёто рисовать, мы говорим: вот наш PSO, вот наша сигнатура, вот к этой сигнатуре данные для параметров, вот из этой кучи брать дескрипторы, ну а теперь можно и вызвать отрисовку.

Краткое саммари
Про локальные источники может быть я напишу часть позже, а может быть нет (в целом там из непокрытого только структурные буферы, DrawIndexedInstanced и математика, помогающая решать, что освещать а что не освещать + концепция как в принципе это освещение работает и почему боксы это классно).
Но кроме этого давайте рекап того, что нужно делать, если хочешь добавить ту или иную штуку в свой пайплайн:
Константный буфер
Хочешь добавить что-либо в имеющийся константный буфер:
Добавь это на обеих сторонах в нужную структуру, убедись что сжатие в 16 байт ничего не поломает, при необходимости добавь паддинги
Хочешь добавить новый константный буфер:
То же, что в первом случае + новый параметр в рут сигнатуру + установка данных в этот параметр перед вызовом отрисовки (частота зависит от частоты смены данных, на весь проход это распространяется или только на в данный момент рисующийся объект)
Текстуры
Для новой текстуры всегда нужен дескриптор, в зависимости от компоновки текстур будет либо нужен новый параметр в рут сигнатуру, либо нет, и либо нужно будет отдельно его ставить, либо нет. Одна текстура может иметь много дескрипторов, даже на самом деле одинаковых, но хранящихся в разных кучах — это может быть полезным, правда я всё ещё это не делала хотя надо.
Перед использованием текстур всегда убеждайтесь, что они в подходящем для корректного использования состоянии.
Новый проход с новыми шейдерами
Если вы пишете прям новые шейдера в отдельном новом файлике, это означает, что вам точно понадобится новый PSO, рут сигнатура и все прилегающие для этого.
Какие‑то дефолтные вещи типа «компильте свой шейдер», «создавайте константный буфер» и прочее говорить не хочу, потому что, на самом деле, всё, что остаётся — это пошариться по функциям из Луны, в частности buildRootSignature, buildPSO, Draw и DrawObjectsм.