Pull to refresh

Нативная реализация OmniDirectional теней в DirectX11

Reading time13 min
Views24K
image

Привет. Продолжая рассказывать про различные технологии из графического геймдева — хотел бы рассказать о том, как в DirectX 11 удобно работать с тенями. Расскажу о создании Point-источника света с полным использованием инструментов GAPI DirectX11, затрону такие понятия, как: Hardware Depth Bias, GS Cubemap Render, Native Shadow Map Depth, Hardware PCF.
Исходя из легкого серфинга по интернету – я пришел к выводу, что большинство статей о тенях в DX11 неверны, реализованы не совсем красиво или с использованием устаревших подходов. В статье постараюсь сравнить реализацию теней в DirectX 9 и DirectX 11. Все ниже описанное так же справедливо и для OpenGL.


Введение


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

Еще в далеком 1978-ом году, Ланс Уильямс представил концепт метода создания теней, который назывался Projective shadowing (Shadow mapping) и на состояние 2015-ого года лучше технологий теней доступных в продакшене нет. Да, существует масса модификаций Projective shadowing, но они все, так или иначе основаны на последнем. Так в чем же суть этого метода и как можно получить тени от геометрии любой сложности, да еще и в реалтайме? Суть в том, что из позиции источника света рендерится вся сцена без текстур и запоминается только расстояние от источника света до фрагмента сцены, именуют эту вещь просто Shadow Map (карта теней). Это делается все до основного рендера сцены. И дальше, в самом просто случае – рисуется основная сцена и для каждого фрагмента (пикселя) этой сцены определяется – видит ли источник этот фрагмент (источник света можно назвать камерой) или нет. Если видит – свет попадает на фрагмент, если не видит – не попадает, все просто. Ну и скажу еще по поводу математики этого процесса, практически все реалтаймовые технологии рендеринга завязаны на матрицах, о них я писал чуть-чуть тут. Определение “видимости” фрагмента сцены источником света заключается в следующем: выполняется преобразование позиции фрагмента из пространства основной камеры (матрица вида и проекции основной камеры) в пространство источника света: как результат получается две сравнимые величины. Первая – расстояние из центра источника света до фрагмента сцены, а вторая – та самая глубина (расстояние) из Shadow map, который был создан ранее. Сравнивая их – можно определить, в тени ли фрагмент или нет.
Вот так выглядит самая простая реализация теней. Так же хочу отметить, что источники света бывают разными, следовательно, тени тоже будут разными. Выделяют четыре основных типа источника света:

  • Spot Light (он же Projection Light) — его лучше всего представить как обычный проектор, который имеет угол конуса света и направлен в определенное место. Матрицей такого источника света является Perspective Martix.
  • Point Light (он же Omnidirectional Light) – всенаправленный источник света, проще представить как точку (отсюда и Point), которая во всех направлениях испускает свет. Матрицей такого источника является Perspective Matrix с углом обзора (Field of View, FOV) в 90 градусов, при этом он имеет шесть видовых матриц, которые направленны каждый в свою сторону (стороны куба).
  • Directional Light (он же Sun Light) – бесконечно удаленный источник света, солнце на земле к примеру. Матрицей такого источника света является Orthographic Matrix.
  • Ambient Light – особый источник света, который не имеет позиции. Несет в себе информацию о равномерном свете полученным в результате переотражений света от других источников. В последние время сходит на нет и заменяется продвинутыми алгоритмами Global Illumination.

В теории все звучит хорошо и согласованно, но на практике появляются некоторые проблемы.
Первая и самая главная проблема – это дискретность карты теней. В памяти GPU они хранятся как текстуры особого формата, имеющие конечный размер (в играх, параметр “качество теней” зачастую связан с размером карты теней). Представить эту проблему можно, если расположить маленький фрагмент геометрии прям перед источником света и отбросить тень на большое полотно. Из-за этого расстояния между концами луча и его началом становятся несопоставимы (ведь карты теней конечного размера). Множеству разных позиций на полотне будет соответствовать один и тот же пиксель на карте теней, это приводит к такому эффекту, как Aliasing (тень выглядит ступенчато):
image

С этой проблемой существует множество средств борьбы, от нестандартных матриц проекции (например Trapezoidal Shadow Map, Geometry Pitch Shadow Map) до создания большого количества карт теней (4e: Cascaded Shadow Map). Но все эти алгоритмы являются довольно узкими для применения и не являются универсальными. Самый распространённый способ избавиться от сильного алиасинга – это Percentage Closer Filtering, который заключается в том, чтобы сделать несколько выборок с определенным константным смещением и интерполировать данные, но о нем подробнее позже.

В этой статье я рассмотрю один из самых сложных источников света – Omnidirectional Light. Т.к. свет он излучает во все стороны и придется делать необычную теневую карту – Cube Shadow Map.
Начнем. Что необходимо реализовать для точечного источника света?
  • Рендер сцены в специальную теневую карту – Cube Shadow Map
  • Шейдер для просчета освещения по какой-нибудь модели, например Ламберта
  • Фильтрация тени средствами DirectX11

Рендер теневой карты


В эпоху DirectX9 честные point-light с тенями мало кто делал, но если и делали – то с большой тратой ресурсов. Для того, чтобы посчитать тень для всенаправленного источника света — необходимо прорендрить мир вокруг источника света не один раз, тут два варианта – либо Dual Paraboloid Shadow Mapping (два раза), либо Cube Shadow Mapping (шесть раз). Остановимся на втором, т.к. он наиболее традиционен для point-light. Итак, в эпоху DirectX9 рендерили мир в специальную кубическую текстуру, которая в себе содержала шесть двухмерных текстур, каждый раз переключали активную сторону (текстуру) и мир рисовался заново. К сожалению – многие сейчас продолжают делать так же, даже на DirectX11. Вторая проблема была такова, что в DirectX9 нельзя было работать с хардварной глубиной из шейдера и приходилось писать глубину для дальнейшего использования вручную (зачастую в линейном виде).
В DirectX 9 Cube Shadow Map работал следующим образом:
  • Мир рендерился шесть раз – для каждой стороны отдельно
  • Мир рендерился в Render Target формата R32_Float и был обычной текстурой
  • В подавляющем большинстве глубина фрагментов записывалась в линейном формате
  • Тени вручную фильтровались при накладывании

Отдельно хочу сказать, почему в линейном формате? Дело в том, что в проверке принадлежности тени к текущему фрагменту нужно было сравнить две величины: первая – глубина записанная в теневой карте, а вторая – текущая глубина фрагмента в пространстве источника света. Если в случае Spot/Directional было все просто, мы брали точку и делали репроекцию этой точки в пространство источника света (путем умножения на матрицу вида/проекции источника света) и сравнивали эти две глубины. То в случае с Point-light все становится сложнее, у нас есть шесть разных видовых матриц, а это значит, что мы сначала должны определить – в каком фейсе лежит фрагмент и сделать репроекцию с помощью конкретной матрицы фейса. Это означало, что мы должны были использовать Dynamic Flow Control в шейдере, а это довольно тяжело для GPU. Поэтому поступали проще: хранили глубину в линейном формате (в теневой карте хранилось расстояние от источника света до фрагмента) и сравнивали с линейной глубиной при наложении. При таком рендере использовался хардвардный буфер глубины и Render Target, куда записывалась линейная глубина.

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

Как же работают такие же тени в DirectX11?
  • Мир рендерится один раз – геометрический шейдер автоматически выбирает нужную сторону для записи
  • Подход больше не использует Render Target и рендерит только нативную глубину в хардварный буфер
  • Глубина фрагментов имеет стандартный вид: p.z / p.w
  • Возможность использовать Hardware PCF

Практика



Теперь, рассмотрим как все это выглядит в реализации. Самое первое, что нужно для реализации Cube Shadows Mapping – это матрицы вида и проекции:
_projection = Matrix.PerspectiveFovRH(
                    MathUtil.DegreesToRadians(90.0f),
                    1.0f,
                    0.01f,
                    this.Transform.Scale.X);

Матрица проекции всегда имеет угол обзора в 90 градусов, aspect ratio в единицу (куб все же есть куб), far plane равен радиусу источника света.
Матриц вида у этого источника света – шесть:
            _view[0] = Matrix.LookAtRH(position, position + Vector3.Right, Vector3.Up);
            _view[1] = Matrix.LookAtRH(position, position + Vector3.Left, Vector3.Up);
            _view[2] = Matrix.LookAtRH(position, position + Vector3.Up, Vector3.BackwardRH);
            _view[3] = Matrix.LookAtRH(position, position + Vector3.Down, Vector3.ForwardRH);
            _view[4] = Matrix.LookAtRH(position, position + Vector3.BackwardLH, Vector3.Up);
            _view[5] = Matrix.LookAtRH(position, position + Vector3.ForwardLH, Vector3.Up);

Каждая видовая матрица описывает свой фейс, в DirectX11 порядок CubeTexture таков: Right, Left, Up, Down, Front, Back.
Далее идет особое описание Hardware Depth Buffer:
TextureDescription cubeDepthDescription = new TextureDescription()
				{
					ArraySize = 6,
					BindFlags = BindFlags.ShaderResource | BindFlags.DepthStencil,
					CpuAccessFlags = CpuAccessFlags.None,
					Depth = 1,
					Dimension = TextureDimension.TextureCube,
					Format = SharpDX.DXGI.Format.R32_Typeless,
					Height = CommonLight.SHADOW_CUBE_MAP_SIZE,
					MipLevels = 1,
					OptionFlags = ResourceOptionFlags.TextureCube,
SampleDescription = new SharpDX.DXGI.SampleDescription(1, 0),
					Usage = ResourceUsage.Default,
					Width = CommonLight.SHADOW_CUBE_MAP_SIZE
				};

Bind флаги – это шейдерный ресурс и то, что наша текстура является буфером глубины.
Так же важно отменить, что формат установлен в R32_Typeless, это обязательное требование при чтении хардварной глубины.
Из-за того, что мы не используем рендер в текстуру – нам достаточно заполнить хардварный буфер глубины данными:
_graphics.SetViewport(0f, 0f, (float)CommonLight.SHADOW_CUBE_MAP_SIZE, (float)CommonLight.SHADOW_CUBE_MAP_SIZE);
_graphics.SetRenderTargets((DepthStencilBuffer)light.ShadowMap);
_graphics.Clear((DepthStencilBuffer)light.ShadowMap, SharpDX.Direct3D11.DepthStencilClearFlags.Depth, 1f, 0);

_cubemapDepthResolver.Parameters["View"].SetValue(((OmnidirectionalLight)light).GetCubemapView());
_cubemapDepthResolver.Parameters["Projection"].SetValue(((OmnidirectionalLight)light).GetCubemapProjection());

scene.RenderScene(gameTime, _cubemapDepthResolver, false, 0);

Устанавливаем размер вьюпорта, устанавливаем буфер глубины, эффект и рендерим нашу сцену.
Стандартный шейдер нужен только один – вершинный, пиксельный отсутствует потому, что мы, опять же, не используем Render To Texture:
VertexOutput DefaultVS(VertexInput input)
{
	VertexOutput output = (VertexOutput)0;

	float4 worldPosition = mul(input.Position, World);
	output.Position = worldPosition;

	return output;
}

Вот только тут появляется еще один шейдер – геометрический, он то и будет выбирать нужный фейс для записи глубины:
[maxvertexcount(18)]
void DefaultGS( triangle VertexOutput input[3], inout TriangleStream<GeometryOutput> CubeMapStream )
{
	[unroll]
    for( int f = 0; f < 6; ++f )
    {
		{
	        GeometryOutput output = (GeometryOutput)0;

			output.RTIndex = f;

			[unroll]
			for( int v = 0; v < 3; ++v )
			{
				float4 worldPosition = input[v].Position;
				float4 viewPosition = mul(worldPosition, View[f]);
				output.Position = mul(viewPosition, Projection);

				CubeMapStream.Append( output );
			}

			CubeMapStream.RestartStrip();
        }
    }
}

Его задача состоит в том, чтобы на вход получать треугольник, а в ответ эмитить шесть, но каждый будет находится в своем фейсе (параметр RTIndex). Вот так выглядят структуры:
cbuffer Params : register(b0)
{
        float4x4 World;
	float4x4 View[6];
	float4x4 Projection;
};

struct VertexInput
{
	float4 Position : SV_POSITION;
	//uint InstanceID : SV_InstanceID;
};

struct VertexOutput
{
	float4 Position : SV_POSITION;   
	//uint InstanceID : SV_InstanceID;
};

struct GeometryOutput
{
	float4 Position : SV_POSITION;   
	uint RTIndex : SV_RenderTargetArrayIndex;
};


Человек, работавший с множественным рендером одной и той же модели может заметить, что взамен эмита новой геометрии – можно использовать инстансинг, выбирая нужный RTIndex исходя из InstanceID. Да, можно, но я получил заметный проигрыш в перфомансе. В подробности, почему так получилось – не вдавался. Оказалось куда проще заимитить новые треугольники, чем использовать полученные от инстансинга.
После этого процесса – мы можем получить хардварную кубическую глубину. При этом рендер этой геометрии был сделан в один проход.
Следующий этап – это накладывание тени, в моем примере используется Deferred Shading, но все справедливо и для Forward-рендеринга. Теперь опять о проблемах: мы должны перевести расстояние от источника света до фрагмента в пространство источника света (кубического буфера глубины), но просто так это сделать нельзя, ведь для этого необходимо знать, какую из шести видовых матриц использовать. Dynamic Flow Control использовать не хочется, поэтому можно прийти к интересному хаку, который основан на том, что все видовые матрицы одинаковые и имеют FOV в 90 градусов:
float _vectorToDepth(float3 vec, float n, float f)
{
    float3 AbsVec = abs(vec);
    float LocalZcomp = max(AbsVec.x, max(AbsVec.y, AbsVec.z));

    float NormZComp = (f+n) / (f-n) - (2*f*n)/(f-n)/LocalZcomp;
    return (NormZComp + 1.0) * 0.5;
}

Таким способом мы можем определить глубину в пространстве источника света определенного вектора.
Теперь, мы можем прочитать глубину из теневой карты по трехмерному вектору [FragmentPosition-LightPosition] и по этому же вектору получить глубину в пространстве источника света, сравнить их и определить: фрагмент в тени или нет.
После прохода шейдеров получения тени и рендеринга Light-карты — получаем тень с сильным алиасингом. Для этого тень хорошо бы обработать фильтром и на помощь приходит возможность DirectX11 – Hardware PCF, эта возможность реализуется с помощью использования специальных comprasion-сэмплеров:
SamplerComparisonState LightCubeShadowComparsionSampler : register(s0);

Описывается он следующим образом:
var dms4 = SharpDX.Direct3D11.SamplerStateDescription.Default();
dms4.AddressU = SharpDX.Direct3D11.TextureAddressMode.Clamp;
dms4.AddressV = SharpDX.Direct3D11.TextureAddressMode.Clamp;
dms4.Filter = SharpDX.Direct3D11.Filter.ComparisonMinMagMipLinear;
dms4.ComparisonFunction = SharpDX.Direct3D11.Comparison.Less;

И делается выборка так:
LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, lightVector, obtainedDepth).r

Где obtainedDepth – глубина, которая получена из функции _vectorToDepth.
На выходе получается сглаженный результат сравнения глубин (если фильтр у сэмплера был Linear), что эквивалентно 2x2 Bilinear PCF:
image

Так же, можно сделать еще дополнительный 3x3 HPCF и получить такой результат:
image

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

Поверхность начинает отбрасывать тень сама на себя, создавая неверную тень:
image

Эта проблема решается, если при сравнении сдвигать одну из глубин на некоторое маленькое значение (bias), чтобы хоть как-нибудь нивелировать эту проблему. Обычно делают что-то вроде: cD + 0.0001 < sD при проверке. Такой способ вреден, потому, что сдвигая таким способом – мы очень легко получаем эффект Питера Пэна:
Питер пэн
image

Для эффективного решения проблемы в DirectX11 есть стандартные средства, устанавливаются эти bias значения в Rasterizer State, параметры DepthBias и SlopeScaledDepthBias.
Вот таким нехитрым способом реализуются сглаженные point-light тени с использованием возможностей DirectX11.

Полный код выкладывать не буду, т.к. он очень сильно связан с движком, однако шейдерами обязательно поделюсь:
DeferredShading.fx
#include "..//pp_GBuffer.fxh"
#include "Lights.fxh"

float4 PointLightPS(float2 UV : TEXCOORD) : SV_TARGET
{
	SurfaceData surfaceData = GetSurfaceData(UV);

	float3 texelPosition = GetPosition(UV);
	float3 texelNormal = surfaceData.Normal;

	float3 vL = texelPosition - LightPosition;
	float3 L = normalize(vL);

	float3 lightColor = _calculationLight(texelNormal, L);

	float3 lightCookie = float3(1, 1, 1);
	
	if(IsLightCookie)
	{
		float3 rL = mul(float4(L, 1), LightRotation).xyz;
		lightCookie = LightCubeCookie.Sample(LightCubeCookieSampler, float3(rL.xy, -rL.z) ).rgb;
	}

	float shadowed = 1;

	if(IsLightShadow)
		shadowed = _sampleCubeShadowHPCF(L, vL);

	//if(IsLightShadow)
	//	shadowed = _sampleCubeShadowPCFSwizzle3x3(L, vL);

	float atten = _calcAtten(vL);

	return float4(lightColor * lightCookie * shadowed * atten, 1);
}

technique PointLightTechnique
{
	pass 
	{
		Profile = 10.0;
		PixelShader = PointLightPS;
	}
}


Lights.fxh
cbuffer LightSource : register(b1)
{
	float3 LightPosition;
	float LightRadius;
	float4 LightColor;
	float4x4 LightRotation;

	float2 LightNearFar;
	const bool IsLightCookie;
	const bool IsLightShadow;
};

TextureCube<float4> LightCubeCookie : register(t3);
SamplerState LightCubeCookieSampler : register(s1);
TextureCube<float> LightCubeShadowMap : register(t4);

SamplerComparisonState LightCubeShadowComparsionSampler : register(s2);
SamplerState LightCubeShadowPointSampler : register(s3);

float _calcAtten(float3 vL)
{
	float3 lVec = vL / LightRadius;
	return max(0.0, 1.0 - dot(lVec,lVec));
}

float3 _calculationLight(float3 N, float3 L)
{
	return LightColor.xyz * saturate(dot(N, -L)) * LightColor.w;
}

float _vectorToDepth(float3 vec, float n, float f)
{
    float3 AbsVec = abs(vec);
    float LocalZcomp = max(AbsVec.x, max(AbsVec.y, AbsVec.z));

    float NormZComp = (f+n) / (f-n) - (2*f*n)/(f-n)/LocalZcomp;
    return (NormZComp + 1.0) * 0.5;
}

float _sampleCubeShadowHPCF(float3 L, float3 vL)
{
	float sD = _vectorToDepth(vL, LightNearFar.x, LightNearFar.y);

	
	return LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, float3(L.xy, -L.z), sD).r;
}

float _sampleCubeShadowPCFSwizzle3x3(float3 L, float3 vL)
{
	float sD = _vectorToDepth(vL, LightNearFar.x, LightNearFar.y);

	float3 forward = float3(L.xy, -L.z);
	float3 right = float3( forward.z, -forward.x, forward.y );
	right -= forward * dot( right, forward );
	right = normalize(right);
	float3 up = cross(right, forward );

	float tapoffset = (1.0f / 512.0f);

	right *= tapoffset;
	up *= tapoffset;

	float3 v0;
	v0.x = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward - right - up, sD).r;
	v0.y = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward - up, sD).r;
	v0.z = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward + right - up, sD).r;
	
	float3 v1;
	v1.x = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward - right, sD).r;
	v1.y = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward, sD).r;
	v1.z = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward + right, sD).r;

	float3 v2;
	v2.x = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward - right + up, sD).r;
	v2.y = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward + up, sD).r;
	v2.z = LightCubeShadowMap.SampleCmpLevelZero(LightCubeShadowComparsionSampler, forward + right + up, sD).r;
	
	
	return dot(v0 + v1 + v2, .1111111f);
}


// UE4: https://github.com/EpicGames/UnrealEngine/blob/release/Engine/Shaders/ShadowProjectionCommon.usf
static const float2 DiscSamples5[]=
{ // 5 random points in disc with radius 2.500000
	float2(0.000000, 2.500000),
	float2(2.377641, 0.772542),
	float2(1.469463, -2.022543),
	float2(-1.469463, -2.022542),
	float2(-2.377641, 0.772543),
};

float _sampleCubeShadowPCFDisc5(float3 L, float3 vL)
{
	float3 SideVector = normalize(cross(L, float3(0, 0, 1)));
	float3 UpVector = cross(SideVector, L);

	SideVector *= 1.0 / 512.0;
	UpVector *= 1.0 / 512.0;
	
	float sD = _vectorToDepth(vL, LightNearFar.x, LightNearFar.y);

	float3 nlV = float3(L.xy, -L.z);

	float totalShadow = 0;

	[UNROLL] for(int i = 0; i < 5; ++i)
	{
			float3 SamplePos = nlV + SideVector * DiscSamples5[i].x + UpVector * DiscSamples5[i].y;
			totalShadow += LightCubeShadowMap.SampleCmpLevelZero(
				LightCubeShadowComparsionSampler, 
				SamplePos, 
				sD);
	}
	totalShadow /= 5;

	return totalShadow;

}


CubeDepthReslover.fxh
cbuffer Params : register(b0)
{
    float4x4 World;
	float4x4 View[6];
	float4x4 Projection;
};

struct VertexInput
{
	float4 Position : SV_POSITION;
	//uint InstanceID : SV_InstanceID;
};

struct VertexOutput
{
	float4 Position : SV_POSITION;   
	//uint InstanceID : SV_InstanceID;
};

struct GeometryOutput
{
	float4 Position : SV_POSITION;   
	uint RTIndex : SV_RenderTargetArrayIndex;
};

VertexOutput DefaultVS(VertexInput input)
{
	VertexOutput output = (VertexOutput)0;

	float4 worldPosition = mul(input.Position, World);
	output.Position = worldPosition;
	//output.InstanceID = input.InstanceID;

	return output;
}

[maxvertexcount(18)]
void DefaultGS( triangle VertexOutput input[3], inout TriangleStream<GeometryOutput> CubeMapStream )
{
	[unroll]
    for( int f = 0; f < 6; ++f )
    {
		{
	        GeometryOutput output = (GeometryOutput)0;

			output.RTIndex = f;

			[unroll]
			for( int v = 0; v < 3; ++v )
			{
				float4 worldPosition = input[v].Position;
				float4 viewPosition = mul(worldPosition, View[f]);
				output.Position = mul(viewPosition, Projection);

				CubeMapStream.Append( output );
			}

			CubeMapStream.RestartStrip();
        }
    }
}

technique CubeDepthResolver
{
	pass DefaultPass
	{
		Profile = 10.0;
		VertexShader = DefaultVS;
		GeometryShader = DefaultGS;
		PixelShader = null;
	}
}



Если возникнут вопросы или нужна будет помощь — с радостью помогу, контакты можно посмотреть в моем профиле.

Ближайшие планируемые статьи:
  • Реализация Deferred Rendered Water
  • Physically-Based Rendering без использования IBL

P.S.
Дорогой читатель, если ты любишь внимательно читать статьи и нашел очепятку или неточность- не спеши строчить комментарий, а лучше напиши мне личным сообщением, я обязательно скажу спасибо!
Tags:
Hubs:
Total votes 51: ↑49 and ↓2+47
Comments3

Articles