Урок №26. Расширенные возможности GLSL в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 27 Июн 2020  | 

 1442

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

Встроенные GLSL-переменные

Как вы могли заметить, шейдеры чрезвычайно конвейеризированы, и если нам нужны данные из любого другого источника кроме текущего шейдера, то нам придется передавать данные по кругу. Мы уже научились делать это с помощью вершинных атрибутов, сэмплеров и uniform-переменных. Однако GLSL содержит несколько вспомогательных переменных, начинающихся с префикса gl_, которые предоставляют дополнительные средства для сбора и/или записи данных. Мы уже сталкивались с ними в предыдущих уроках: переменная gl_Position, которая является выходным вектором вершинного шейдера и переменная фрагментного шейдера gl_FragCoord.

Поэтому предлагаю рассмотреть несколько интересных встроенных входных и выходных GLSL-переменных и выяснить, чем они могут быть нам полезны. Обратите внимание, что мы разберем лишь часть из большого списка встроенных GLSL-переменных. Поэтому, если вы хотите ознакомиться со всем списком, то советую обратиться к соответствующему разделу OpenGL-wiki.

Переменные вершинного шейдера


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

Переменная gl_PointSize

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

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

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

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

В результате, с увеличением расстояния увеличиваются и размеры визуализированных точек:

Данный приём часто находит свое применение в таких методах компьютерной графики, как создание частиц.

Переменная gl_VertexID

gl_Position и gl_PointSize являются выходными переменными, так как их значения считываются в виде выходных данных вершинного шейдера; перезаписывая их, мы тем самым можем влиять на конечный результат. Также у вершинного шейдера есть и встроенная входная GLSL-переменная с названием gl_VertexID, доступная только для чтения.

Целочисленная переменная gl_VertexID содержит текущий идентификатор вершины, которую мы рисуем. При выполнении индексированного рендеринга (с помощью функции glDrawElements()) данная переменная будет содержать текущий индекс вершины. При рисовании без использования индексов (через функцию glDrawArrays()) данная переменная будет содержать номер текущей обрабатываемой вершины, начиная от момента вызова рендеринга.

Переменные фрагментного шейдера

Во фрагментном шейдере мы также имеем доступ к некоторым примечательным GLSL-переменным: входные переменные gl_FragCoord и gl_FrontFacing.

Переменная gl_FragCoord

С переменной gl_FragCoord мы уже встречались пару раз во время обсуждения тестирования глубины, т.к. значение z-компоненты вектора gl_FragCoord равно значению глубины заданного фрагмента. Однако, мы также можем использовать x-, y-компоненты этого вектора для создания некоторых интересных эффектов.

x-, y-компоненты переменной gl_FragCoord — это оконные (или экранные) координаты фрагмента, отсчитываемые от нижнего левого края окна. При помощи функции glViewport() мы определили размеры окна рендеринга, равные 800×600, а это значит, что оконные координаты фрагмента будут лежать в диапазоне [0; 800] для х-координаты и в диапазоне [0; 600] для у-координаты.

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

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

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

Переменная gl_FrontFacing

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

Переменная gl_FrontFacing имеет тип bool, и принимает значение true, если фрагмент является частью фронтальной грани, и false — если фрагмент является частью тыльной грани. Благодаря этому, можно, например, создать куб таким образом, что его внутренние текстуры будут отличаться от внешних текстур:

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

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

Переменная gl_FragDepth

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

Чтобы в шейдере задать значение глубины, мы должны записать любое значение от 0.0 до 1.0 в выходную переменную:

Если шейдер ничего не записывает в gl_FragDepth, то переменной автоматически будет установлено значение из gl_FragCoord.z.

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

При выполнении операции записи в переменную gl_FragDepth вы должны учитывать данный момент. Однако, начиная с OpenGL 4.2, мы можем занять позицию посредника между обеими сторонами, повторно объявляя в начале фрагментного шейдера переменную gl_FragDepth с использованием условия глубины:

Данное условие может принимать следующие значения:

Условие Описание
any Значение по умолчанию. Раннее тестирование глубины отключено.
greater Вы можете только увеличить (в сравнении с gl_FragCoord.z) значение глубины.
less Вы можете только уменьшить (в сравнении с gl_FragCoord.z) значение глубины.
unchanged Если вы будете выполнять запись в gl_FragDepth, то запись будет происходить непосредственно в gl_FragCoord.z.

Указав в качестве условия глубины greater или less, OpenGL может сделать предположение, что вы будете записывать только те значения глубины, которые больше или меньше значений глубины фрагмента. В таком случае, OpenGL может выполнить раннее тестирование глубины.

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

Обратите внимание, что данная функция доступна только в OpenGL версии 4.2 или выше.

Интерфейсные блоки


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

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

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

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

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

uniform-буферы

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

В данном случае OpenGL предлагаем нам воспользоваться инструментом под названием uniform-буферы (или «UBO» сокр. от англ. «Uniform Buffer Objects»), который позволяет нам объявить набор глобальных uniform-переменных, которые остаются неизменными при использовании любого количества шейдерных программ. При работе с uniform-буферами мы указываем лишь единожды соответствующие uniform-переменные в фиксированной памяти графического процессора. При этом нам всё ещё приходится вручную определять уникальные для каждого шейдера uniform-переменные.

Поскольку uniform-буфер является таким же буфером, как и любой другой буфер, то мы можем создать его с помощью вызова функции glGenBuffers(), затем привязать его к предназначению GL_UNIFORM_BUFFER и далее сохранить в буфер все соответствующие uniform-данные. Существуют определенные правила относительно того, как должны храниться данные для uniform-буферов. Во-первых, мы возьмем простой вершинный шейдер и сохраним наши матрицы projection (проекции) и view (вида) в так называемом uniform-блоке:

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

Для этого был объявлен uniform-блок с названием Matrices, который хранит две матрицы размера 4×4. Переменная в таком uniform-блоке может быть доступна и без непосредственного использования префикса в виде имени блока. Затем мы сохраняем значения матриц в буфере, и каждый шейдер, в котором объявлен описываемый uniform-блок, будет иметь доступ к матрицам.

Вы, вероятно, сейчас задаетесь вопросом: «А что означает часть layout (std140)?». Это означает то, что в настоящее время uniform-блок использует указанную компоновку размещения в памяти своего содержимого; таким образом, этот стейтмент устанавливает компоновку uniform-блока.

Компоновка uniform-блока


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

Представьте себе следующий uniform-блок:

Чтобы мы могли поместить вышеописанные переменные в буфер в соответствующем порядке, то нам нужно знать размер (в байтах) и смещение (от начала блока) каждой из этих переменных. Размер каждого из элементов четко указан в OpenGL и непосредственно соответствует типам данных в C++; векторы и матрицы представляют собой (большие) массивы элементов типа float. А вот то, что OpenGL четко не описывает — это интервал между переменными. Благодаря этому у аппаратного обеспечения компьютера появляется возможность позиционировать и выравнивать переменные так, как оно посчитает нужным. Например, переменная типа vec3 может разместиться рядом с переменной типа float. Но не все аппаратные средства компьютера могут справиться с этим.

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

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

Каждая переменная имеет базовое выравнивание, равное интервалу, который занимает переменная (включая внутренние отступы) внутри uniform-блока при использовании правил компоновки std140. Для каждой переменной мы вычисляем её выровненное смещение: байтовое смещение переменной от начала блока. Выровненное смещение байтов переменной должно быть кратно её базовому выравниванию. Это немного сложно, но мы скоро рассмотрим некоторые примеры, которые прояснят ситуацию.

Точные правила компоновки можно найти в спецификации к uniform-буферам OpenGL здесь, но мы перечислим наиболее распространенные правила чуть ниже. В GLSL каждый тип переменной, такой как int, float и bool, определяется в виде 4-х байтовой величины, причем каждая сущность из 4-х байт представлена как N.

Тип Правило компоновки
Скалярный, т.е.int или bool Каждый скалярный тип имеет базовое выравнивание, равное N.
Вектор 2N или 4N. Например, тип vec3 будет иметь базовое выравнивание, равное 4N.
Массив скаляров или векторов Каждый элемент будет иметь базовое смещение, эквивалентное выравниванию vec4.
Матрицы Хранятся в виде больших массивов векторов-столбцов, имеющих базовое выравнивание vec4.
Структура Эквивалентно размеру всех своих элементов, в соответствии с предыдущими правилами, но дополненное до кратности с размером vec4.

Как и с большинством спецификаций OpenGL, это легче всего понять на примере. Мы возьмем uniform-блок под названием ExampleBlock, который был представлен ранее, и вычислим выровненное смещение для каждого из его членов, используя компоновку std140:

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

Добавляя часть layout (std140) в определение uniform-блока, мы оповещаем OpenGL, что данный uniform-блок использует компоновку std140. Замечу, что на выбор есть также две другие компоновки, которые требуют от нас запрашивать смещение каждого элемента перед заполнением буферов. Мы уже рассмотрели общую компоновку, есть ещё packed-компоновка. Использование packed-компоновки не дает никакой гарантии, что компоновка памяти останется неизменной между программами, поскольку она позволяет компилятору оптимизировать uniform-переменные uniform-блока, что может привести к возникновению отличий для каждого шейдера.

Использование uniform-буферов

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

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

Теперь всякий раз, когда мы хотим обновить или вставить данные в буфер, мы привязываемся к uboExampleBlock и используем glBufferSubData() для обновления его памяти. Нам нужно только один раз обновить указанный uniform-буфер, и все шейдеры, использующие данный буфер, будут использовать и его обновленные данные. Но как OpenGL узнает, в каком соответствии состоят uniform-буферы и uniform-блоки?

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

Как вы можете видеть, у нас есть возможность привязать несколько uniform-буферов к разным точкам привязки. Поскольку шейдер A и шейдер В имеют uniform-блок, связанный с одной и той же точкой привязки 0, их uniform-блоки совместно используют одни и те же uniform-данные, расположенные в uboMatrices; требование состоит в том, чтобы оба шейдера определяли один и тот же uniform-блок Matrices.

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

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

Примечание: Начиная с OpenGL версии 4.2 и выше, можно также явно хранить в шейдере точку привязки uniform-блока, добавив ещё один layout-спецификатор, экономя на вызовах функций glGetUniformBlockIndex() и glUniformBlockBinding(). Следующий код явно устанавливает точку привязки uniform-блока Lights:

Затем нам также нужно привязать uniform-буфер к той же точке привязки, и это может быть выполнено либо с помощью glBindBufferBase(), либо с помощью glBindBufferRange():

В функцию glBindbufferBase() в качестве параметров передаются предназначение, индекс точки привязки и uniform-буфер. Эта функция связывает uboExampleBlock с точкой привязки 2; с этого момента обе стороны точки привязки будут иметь связь. Вы также можете использовать glBindBufferRange(), которая ожидает получить дополнительные параметры смещения и размера — таким образом, к точке привязки вы можете привязать только определенный диапазон uniform-буфера. Используя glBindBufferRange(), вы можете иметь несколько различных uniform-блоков, связанных с одним uniform-буфером.

Теперь, когда всё настроено, мы можем начать добавлять данные в uniform-буфер. Мы можем добавить все данные в виде однобайтового массива или обновить части буфера, используя glBufferSubData(). Для обновления uniform-переменной логического типа данных, мы можем обновить uniform-буфер следующим образом:

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

Простой пример


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

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

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

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

Далее мы создаем uniform-буфер и привязываем его к точке привязки 0:

Сначала мы выделяем достаточно памяти для нашего буфера, размер которого равен двукратному размеру glm::mat4. Размер типов GLM-матриц напрямую соответствует GLSL-типу mat4. Затем мы связываем определенный диапазон буфера (в данном случае, весь буфер) с точкой привязки 0.

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

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

И это всё, что нужно сделать с uniform-буферами. Каждый вершинный шейдер, содержащий uniform-блок Matrices, теперь будет содержать данные, хранящиеся в uboMatrices. Поэтому, если бы мы сейчас нарисовали 4 куба, используя 4 разных шейдера, их матрицы проекции и вида должны были б быть одинаковыми:

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

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

  Google Drive / Исходный код — Урок №26. Расширенные возможности GLSL в OpenGL

  GitHub / Исходный код — Урок №26. Расширенные возможности GLSL в OpenGL

Заключение

uniform-буферы имеют ряд преимуществ перед отдельными uniform-переменными:

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

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

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

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

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (5 оценок, среднее: 5,00 из 5)
Загрузка...

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *