5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Siv3DAdvent Calendar 2023

Day 10

タイルマップをシェーダーで描画してみよう

Last updated at Posted at 2023-12-09

はじめに

昔ながらのRPGマップのようなものを描画したい時、タイルマップを使うことがあります。

例えば上記のようなマップ画面を作る際には
元となるマップチップ画像と二次元配列等の配置情報を組み合わせて生成します。

マップチップ

mapchip01.png

mapchip02.png

配置情報

配置情報
{
	{ 6,  6,  6,  6,  3,  3,  3,  3,  3,  1,  1,  3,  3,  3,  3,  3,  3,  3,  3,  3,},
	{ 6,  6,  6,  3,  3,  3,  3,  3,  1,  1,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,},
	{ 6,  6,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  4,  4,  4,  4,},
	{ 3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  2,  2,  2,  2,  2,},
	{ 3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  3,  1,  2,  2,  2,  2,  2,},
	{ 3,  3,  3,  3,  3,  3,  3,  4,  4,  4,  4,  4,  4,  3,  3,  2,  2,  2,  2,  2,},
	{ 3,  3,  3,  3,  4,  4,  4,  4,  1,  1,  1,  1,  1,  1,  3,  1,  2,  2,  1,  2,},
	{ 3,  3,  3,  4,  4,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  1,  1,  2,  2,  2,},
	{ 3,  3,  3,  4,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  3,  3,  1,  1,  1,},
	{ 4,  4,  4,  4,  1,  1,  2,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  3,  3,  1,},
	{ 1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  5,  5,  5,  5,  5,  5,  1,  1,  3,  3,},
	{ 1,  1,  1,  1,  1,  1,  1,  1,  1,  5,  5,  4,  4,  4,  4,  5,  5,  1,  1,  3,},
	{ 1,  2,  1,  1,  1,  1,  1,  1,  5,  5,  4,  4,  4,  4,  4,  4,  5,  5,  1,  1,},
	{ 1,  1,  1,  1,  1,  1,  1,  1,  5,  4,  4,  4,  4,  4,  4,  4,  4,  5,  1,  1,},
	{ 1,  1,  1,  1,  2,  1,  1,  1,  5,  4,  4,  4,  4,  4,  4,  4,  4,  5,  1,  1,},
	{ 3,  3,  3,  3,  3,  3,  1,  1,  5,  5,  4,  4,  4,  4,  4,  4,  5,  5,  1,  1,},
	{ 3,  2,  2,  2,  2,  3,  1,  1,  1,  5,  5,  4,  4,  4,  4,  5,  5,  1,  1,  1,},
	{ 2,  2,  2,  2,  2,  3,  3,  3,  3,  1,  5,  5,  5,  5,  5,  5,  1,  1,  1,  1,},
	{ 2,  2,  2,  2,  2,  2,  2,  2,  3,  3,  1,  1,  1,  1,  1,  1,  1,  2,  1,  1,},
	{ 2,  2,  2,  2,  2,  2,  2,  2,  2,  3,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,},
}

今回は、このようなタイルマップの描画方法についての話になります。

TextureRegionで描画

タイルマップを普通に描画しようとするとどんな感じになるでしょうか?
以下はサンプルコードです。
(※紹介コードのクラス設計は今回用にテキトーです)

Main.cpp
# include <Siv3D.hpp>

// マップチップレイヤー
class MapChipLayer
{
public:
	MapChipLayer(FilePathView filePath, Grid<int32>&& data, int32 startTileIndex = 1) :
		m_texture(filePath),
		m_data(std::move(data)),
		m_startTileIndex(startTileIndex)
	{}
	int32 gid(int32 x, int32 y) const
	{
		return m_data[y][x];
	}
	int32 tileIndex(int32 x, int32 y, int32 anieFrame = 0) const
	{
		int32 gid = m_data[y][x];
		auto it = m_animes.find(gid);
		if (it != m_animes.end()) {
			gid = it->second[anieFrame % it->second.size()];
		}
		return gid - m_startTileIndex;
	}
	const Texture& texture() const
	{
		return m_texture;
	}
	void registAnime(int32 gid, Array<int32>&& animeIds)
	{
		m_animes.emplace(gid, std::move(animeIds));
	};
private:
	Grid<int32> m_data;
	int32 m_startTileIndex;
	Texture m_texture;
	HashTable<int32, Array<int32>> m_animes;
};
void Main()
{
	Window::Resize({ 640, 640 });

	Array<MapChipLayer> layers;
	// 背面
	MapChipLayer& backLayer = layers.emplace_back(
		U"mapchip01.png",
		Grid
		{
			{ 6,  6,  6,  6,  3,  3,  3,  3,  3,  1,  1,  3,  3,  3,  3,  3,  3,  3,  3,  3,},
			{ 6,  6,  6,  3,  3,  3,  3,  3,  1,  1,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,},
			{ 6,  6,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  4,  4,  4,  4,},
			{ 3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  2,  2,  2,  2,  2,},
			{ 3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  3,  4,  3,  1,  2,  2,  2,  2,  2,},
			{ 3,  3,  3,  3,  3,  3,  3,  4,  4,  4,  4,  4,  4,  3,  3,  2,  2,  2,  2,  2,},
			{ 3,  3,  3,  3,  4,  4,  4,  4,  1,  1,  1,  1,  1,  1,  3,  1,  2,  2,  1,  2,},
			{ 3,  3,  3,  4,  4,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  1,  1,  2,  2,  2,},
			{ 3,  3,  3,  4,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  3,  3,  1,  1,  1,},
			{ 4,  4,  4,  4,  1,  1,  2,  1,  1,  1,  1,  1,  1,  1,  1,  1,  3,  3,  3,  1,},
			{ 1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  5,  5,  5,  5,  5,  5,  1,  1,  3,  3,},
			{ 1,  1,  1,  1,  1,  1,  1,  1,  1,  5,  5,  4,  4,  4,  4,  5,  5,  1,  1,  3,},
			{ 1,  2,  1,  1,  1,  1,  1,  1,  5,  5,  4,  4,  4,  4,  4,  4,  5,  5,  1,  1,},
			{ 1,  1,  1,  1,  1,  1,  1,  1,  5,  4,  4,  4,  4,  4,  4,  4,  4,  5,  1,  1,},
			{ 1,  1,  1,  1,  2,  1,  1,  1,  5,  4,  4,  4,  4,  4,  4,  4,  4,  5,  1,  1,},
			{ 3,  3,  3,  3,  3,  3,  1,  1,  5,  5,  4,  4,  4,  4,  4,  4,  5,  5,  1,  1,},
			{ 3,  2,  2,  2,  2,  3,  1,  1,  1,  5,  5,  4,  4,  4,  4,  5,  5,  1,  1,  1,},
			{ 2,  2,  2,  2,  2,  3,  3,  3,  3,  1,  5,  5,  5,  5,  5,  5,  1,  1,  1,  1,},
			{ 2,  2,  2,  2,  2,  2,  2,  2,  3,  3,  1,  1,  1,  1,  1,  1,  1,  2,  1,  1,},
			{ 2,  2,  2,  2,  2,  2,  2,  2,  2,  3,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,},
		}
	);
	// アニメ登録
	backLayer.registAnime(3, { 3,7 });

	// 前面
	layers.emplace_back(
		U"mapchip02.png",
		Grid{
			{ 4,  4,  4,  4,  0,  0,  0,  0,  0,  2,  2,  0,  0,  0,  0,  0,  0,  0,  0,  0,},
			{ 4,  4,  4,  0,  0,  0,  0,  0,  2,  2,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,},
			{ 4,  4,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  1,  1,  1,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  1,  0,  1,  1,  1,  7,  1,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  0,  1,  1,  1,  1,  1,  0,  2,  2,  1,  1,  1,},
			{ 0,  0,  0,  0,  0,  0,  0,  0,  1,  2,  2,  2,  2,  2,  0,  0,  0,  2,  2,  1,},
			{ 0,  0,  0,  0,  0,  0,  0,  1,  2,  2,  3,  3,  3,  3,  3,  3,  0,  0,  0,  2,},
			{ 0,  0,  0,  0,  0,  0,  1,  2,  2,  3,  0,  0,  0,  0,  0,  0,  3,  3,  0,  0,},
			{ 1,  1,  1,  1,  1,  1,  2,  2,  3,  0,  0,  0,  0,  0,  0,  0,  0,  3,  3,  0,},
			{ 2,  2,  2,  2,  2,  2,  2,  3,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  3,  4,},
			{ 2,  2,  2,  2,  2,  2,  2,  3,  0,  0,  1,  0,  0,  0,  0,  0,  0,  0,  3,  4,},
			{ 2,  2,  2,  2,  2,  2,  2,  3,  0,  0,  0,  0,  5,  6,  0,  0,  0,  0,  3,  4,},
			{ 0,  0,  0,  0,  0,  0,  2,  3,  0,  0,  0,  0,  0,  0,  1,  0,  0,  0,  4,  4,},
			{ 0,  2,  2,  2,  2,  0,  2,  2,  3,  0,  0,  0,  0,  0,  0,  0,  0,  4,  4,  4,},
			{ 2,  2,  3,  3,  2,  0,  0,  0,  0,  3,  0,  0,  0,  0,  0,  0,  4,  4,  4,  4,},
			{ 2,  3,  3,  3,  3,  2,  2,  2,  0,  0,  3,  3,  3,  3,  3,  3,  4,  4,  4,  4,},
			{ 3,  3,  4,  4,  3,  3,  3,  2,  2,  0,  3,  3,  3,  3,  3,  4,  4,  4,  4,  4,},
		}
	);
	constexpr Size mapSize{ 20, 20 };
	constexpr Size tileSize{ 32,32 };

	// アニメ切り替え用
	Timer timer{ 0.5s, StartImmediately::Yes };
	int32 animeFrame = 0;
	while (System::Update())
	{
		if (timer.reachedZero()) {
			animeFrame++;
			timer.restart();
		}
		for (const MapChipLayer& layer : layers) {
			const Texture& texture = layer.texture();
			const Size tileCount = texture.size() / tileSize;
			for (auto [y, x] : step(mapSize)) {
				if (layer.gid(x, y) == 0) {
					// 0は描画しないとする
					continue;
				}
				const int32 index = layer.tileIndex(x, y, animeFrame);

				const int32 tileX = index % tileCount.x;
				const int32 tileY = index / tileCount.x;
				// タイル事に描画
				texture(tileX * tileSize.x, tileY * tileSize.y, tileSize).draw(Vec2{ x, y } *tileSize);
			}
		}
}

ポイントはこの辺りです。

				const int32 index = layer.tileIndex(x, y, animeFrame);

				const int32 tileX = index % tileCount.x;
				const int32 tileY = index / tileCount.x;
				// タイル事に描画
				texture(tileX * tileSize.x, tileY * tileSize.y, tileSize).draw(Vec2{ x, y } * tileSize);

配置情報からマップチップテクスチャのどこの領域を描画するのか特定して
texture(tileX * tileSize.x, tileY * tileSize.y, tileSize)TextureRegion単位で描画しています。

既にタイルマップを使っている人も、これに近い方法でやっている方は多いのではないでしょうか?

このやり方自体が大きな問題ではなかったりします。
ただ、タイル事に描画になるので、描画負荷としてはそれだけかかることになります。

シェーダーで描画する

以下のようなシェーダーを書きます

tilemap.hlsl
Texture2D       g_tileIdMap : register(t0);
Texture2D       g_mapChip : register(t1);
SamplerState    g_sampler0 : register(s0);

cbuffer PSConstants2D : register(b0)
{
    float4 g_colorAdd;
    float4 g_sdfParam;
    float4 g_sdfOutlineColor;
    float4 g_sdfShadowColor;
    float4 g_internal;
}
cbuffer Params : register(b1)
{
    float2 g_textureSize;
    float2 g_mapChipSize;
    float2 g_tileCount;
    float2 g_tileSize;
}
struct PSInput
{
    float4 position : SV_POSITION;
    float4 color    : COLOR0;
    float2 uv       : TEXCOORD0;
};

float4 PS(PSInput input) : SV_TARGET
{
    float2 uv = input.uv;
    float4 tileIdMapColor = g_tileIdMap.Sample(g_sampler0, uv);
    if (tileIdMapColor.a < 1.0)
    {
        // 描画しない
        discard;
    }
    // マップチップテクスチャのピクセルオフセットを計算
    float2 pixel = uv * g_textureSize;
    float2 ajustPixel = pixel % g_tileSize;

    // タイル番号
    float tileId = tileIdMapColor.r * 255.0;
    
    // タイル番号からマップチップテクスチャのuvを計算
    float x = tileId % g_tileCount.x;
    float y = floor(tileId / g_tileCount.x);
    float2 mapChipUv = (float2(x, y) * g_tileSize + ajustPixel) / g_mapChipSize;

    float4 result = g_mapChip.Sample(g_sampler0, mapChipUv);
    return (result * input.color) + g_colorAdd;
}

Textureに配置情報の値をいれておいて
シェーダー側で描画領域を計算します。

C++側は以下のようになります。

Main.cpp
void Main()
{
	Window::Resize({ 640, 640 });

	Array<MapChipLayer> layers;

	省略...
 
	constexpr Size mapSize{ 20, 20 };
	constexpr Size tileSize{ 32,32 };

	// アニメ切り替え用
	Timer timer{ 0.5s, StartImmediately::Yes };
	int32 animeFrame = 0;

	// シェーダー
	PixelShader tileMapShader = HLSL(U"tilemap.hlsl");
	struct ShaderParam
	{
		Float2 textureSize;
		Float2 mapChipSize;
		Float2 tileCount;
		Float2 tileSize;
	};
	ConstantBuffer<ShaderParam> cb;
	DynamicTexture tileIdMap;
	const Vec2 viewSize = mapSize * tileSize;

	while (System::Update())
	{
		if (timer.reachedZero()) {
			animeFrame++;
			timer.restart();
		}
		for (const MapChipLayer& layer : layers) {
			const Texture& texture = layer.texture();
			const Size tileCount = texture.size() / tileSize;

			Image image(mapSize); // タイル数分のイメージを作成
			image.fill(ColorF(0, 0));
			for (auto [y, x] : step(mapSize)) {
				if (layer.gid(x, y) == 0) {
					// 0は描画しないとする
					continue;
				}
				const int32 index = layer.tileIndex(x, y, animeFrame);
				// マップチップの情報を一旦イメージに設定する
				image[y][x] = Color(static_cast<int8>(index), 0, 0);
			}
			// ダイナミックテクスチャにマップ情報を渡す
			tileIdMap.fill(std::move(image));

			{
				ScopedCustomShader2D scopedShaader(tileMapShader);
				// Nearestサンプリングにしないと正しく色がとれない
				ScopedRenderStates2D scopedSampler(SamplerState::ClampNearest);
				Graphics2D::SetPSTexture(1, texture);

				cb->textureSize = viewSize;
				cb->mapChipSize = texture.size();
				cb->tileCount = tileCount;
				cb->tileSize = tileSize;
				Graphics2D::SetConstantBuffer(ShaderStage::Pixel, 1, cb);

				// 1回だけ描画
				tileIdMap.resized(viewSize).draw();
			}
			// 横着で1つのDynamicTextureを使いまわしているのでFlushしてる
			// DynamicTextureを分ければ不要
			Graphics2D::Flush();
		}
	}
}

変わったのはこの辺りです。

			Image image(mapSize); // タイル数分のイメージを作成
			image.fill(ColorF(0, 0));
			for (auto [y, x] : step(mapSize)) {
				if (layer.gid(x, y) == 0) {
					// 0は描画しないとする
					continue;
				}
				const int32 index = layer.tileIndex(x, y, animeFrame);
				// マップチップの情報を一旦イメージに設定する
				image[y][x] = Color(static_cast<int8>(index), 0, 0);
			}
			// ダイナミックテクスチャにマップ情報を渡す
			tileIdMap.fill(std::move(image));

			{
				ScopedCustomShader2D scopedShaader(tileMapShader);
				// Nearestサンプリングにしないと正しく色がとれない
				ScopedRenderStates2D scopedSampler(SamplerState::ClampNearest);
				Graphics2D::SetPSTexture(1, texture);

				cb->textureSize = viewSize;
				cb->mapChipSize = texture.size();
				cb->tileCount = tileCount;
				cb->tileSize = tileSize;
				Graphics2D::SetConstantBuffer(ShaderStage::Pixel, 1, cb);

				// 1回だけ描画
				tileIdMap.resized(viewSize).draw();
			}

直接描画していたのをやめて、Image経由でDynamicTextureに流し込んでいます。

こうすることでタイル事に描画していたのがレイヤー事に1回だけの描画になります。

複数のレイヤーをまとめることも可能

先のコードでは、Textureに一つしか情報をいれてないが、rgbaで4つ情報を渡すことが可能なので
1つのTextureに複数レイヤーの配置情報を含めることも可能です。

tilemap.hlsl
Texture2D       g_tileIdMap : register(t0);
Texture2D       g_mapChip0 : register(t1);
Texture2D       g_mapChip1 : register(t2);
SamplerState    g_sampler0 : register(s0);

cbuffer PSConstants2D : register(b0)
{
    float4 g_colorAdd;
    float4 g_sdfParam;
    float4 g_sdfOutlineColor;
    float4 g_sdfShadowColor;
    float4 g_internal;
}
cbuffer Params : register(b1)
{
    float2 g_mapChipSize0;
    float2 g_mapChipSize1;
    float2 g_tileCount0;
    float2 g_tileCount1;
    float2 g_textureSize;
    float2 g_tileSize;
}
struct PSInput
{
    float4 position : SV_POSITION;
    float4 color    : COLOR0;
    float2 uv       : TEXCOORD0;
};

static float2 tileCount[2] =
{
    g_tileCount0,
    g_tileCount1
};
static float2 mapChipSize[2] =
{
    g_mapChipSize0,
    g_mapChipSize1
};

float4 PS(PSInput input) : SV_TARGET
{
    float2 uv = input.uv;
    float4 tileIdMapColor = g_tileIdMap.Sample(g_sampler0, uv);
    // マップチップテクスチャのピクセルオフセットを計算
    float2 pixel = uv * g_textureSize;
    float2 ajustPixel = pixel % g_tileSize;

    // タイル番号
    float4 layerColor[2] =
    {
        float4(0, 0, 0, 0),
        float4(0, 0, 0, 0)
    };
    float tileId[2] =
    {
        tileIdMapColor.r * 255.0,
        tileIdMapColor.g * 255.0
    };
    [unroll(2)]
    for (int i = 0; i < 2; ++i)
    {
        if (tileId[i] <= 0)
        {
            continue;
        }
        float id = tileId[i] - 1.0;
        // タイル番号からマップチップテクスチャのuvを計算
        float x = id % tileCount[i].x;
        float y = floor(id / tileCount[i].x);
        float2 mapChipUv = (float2(x, y) * g_tileSize + ajustPixel) / mapChipSize[i];
        layerColor[i] = (i == 0 ? g_mapChip0 : g_mapChip1).Sample(g_sampler0, mapChipUv);
    }
    // アルファブレンド
    float4 result;
    result.a = layerColor[1].a + layerColor[0].a * (1 - layerColor[1].a);
    result.rgb = result.a == 0 ? float3(0, 0, 0) : (layerColor[1].rgb * layerColor[1].a + layerColor[0].rgb * layerColor[0].a * (1 - layerColor[1].a)) / result.a;
    return (result * input.color) + g_colorAdd;
}
Main.cpp
void Main()
{
	Window::Resize({ 640, 640 });

	Array<MapChipLayer> layers;

	省略...
 
	constexpr Size mapSize{ 20, 20 };
	constexpr Size tileSize{ 32,32 };

	// アニメ切り替え用
	Timer timer{ 0.5s, StartImmediately::Yes };
	int32 animeFrame = 0;

	// シェーダー
	PixelShader tileMapShader = HLSL(U"tilemap.hlsl");
	struct ShaderParam
	{
		Float2 mapChipSize[2];
		Float2 tileCount[2];
		Float2 textureSize;
		Float2 tileSize;
	};
	ConstantBuffer<ShaderParam> cb;
	DynamicTexture tileIdMap;
	const Vec2 viewSize = mapSize * tileSize;

	cb->textureSize = viewSize;
	cb->tileSize = tileSize;

	while (System::Update())
	{
		if (timer.reachedZero()) {
			animeFrame++;
			timer.restart();
		}
		Image image(mapSize); // タイル数分のイメージを作成
		image.fill(ColorF(0, 0));

		for (uint32 i = 0; i < 2; ++i) {
			const MapChipLayer& layer = layers[i];
			const Texture& texture = layer.texture();
			const Size tileCount = texture.size() / tileSize;

			cb->mapChipSize[i] = texture.size();
			cb->tileCount[i] = tileCount;
			Graphics2D::SetPSTexture(1 + i, texture);

			for (auto [y, x] : step(mapSize)) {
				if (layer.gid(x, y) == 0) {
					// 0は描画しないとする
					continue;
				}
				const int32 index = layer.tileIndex(x, y, animeFrame);
				// マップチップの情報を一旦イメージに設定する
				(i == 0 ? image[y][x].r : image[y][x].g) = static_cast<int8>(index + 1);
			}
		}
		// ダイナミックテクスチャにマップ情報を渡す
		tileIdMap.fill(std::move(image));

		{
			ScopedCustomShader2D scopedShaader(tileMapShader);
			// Nearestサンプリングにしないと正しく色がとれない
			ScopedRenderStates2D scopedSampler(SamplerState::ClampNearest);
			Graphics2D::SetConstantBuffer(ShaderStage::Pixel, 1, cb);
			// 1回だけ描画
			tileIdMap.resized(viewSize).draw();
		}
	}
}

rとgに別の情報をいれてます

(i == 0 ? image[y][x].r : image[y][x].g) = static_cast<int8>(index + 1);

余談

定数バッファ側を配列にしたら上手く動かなかった。

cbuffer Params : register(b1)
{
    float2 g_mapChipSize[2];
    float2 g_tileCount[2];
    float2 g_textureSize;
    float2 g_tileSize;
}

これについて原因を調べていたのだが、

シェーダーでの配列が16バイト単位で並ぶらしく、C++側とレイアウトがずれるからだったようだ。
知らなかった…

まとめ

  • タイルマップの描画でタイル毎にTextureRegionで描画すると描画数が多くなる
    • シェーダー側で計算することで描画数を減らせる
      • 複数レイヤーの配置情報を含めて計算することも可能
5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?