1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Swiftとmetalを使ってリアルタイムレンダリング環境を実装した時のメモです。
できるだけ詳しく順序通りに書いていきます。

環境
Macbook Air M2
MacOS 26.5.1
Xcode ver26.5

目次

  • 大まかな全体構成
  • 2.本編
    • 2-1.描画環境構築
    • 2-2.描画設定
    • 2-3.レンダリング
    • 2-4.描画
  • 3.まとめ

1.大まかな全体構成

ChatGPT Image 2026年6月18日 14_04_53.png
※AI生成です。

今回のゴール↓
画面収録 2026-06-29 14.04.31.gif

個人的な解釈ですが、流れとしては
①描画環境構築(App,ContentView)
②描画設定(MetalView,MTKView)
③レンダリング(Renderer,Shader,その他データ)
④描画

となります。一つずつまとめていきます。

2.本編

描画環境構築

まずレンダリング環境を作るための基盤を作成していきます。今回はXcodeです。

①Create New Project
スクリーンショット 2026-06-18 14.24.06.png

②Appを選択
スクリーンショット 2026-06-18 14.24.15.png
ここでMetal Libraryを選ぶとAppを選んだ時より基盤構築が少し面倒になるのでAppを選びましょう。

ContentViewとApp

プロジェクトを作成すると、ContentViewと"プロジェクト名"Appができているはずです。

AppはそのままでOK。ContentView側を変更します。

ContentView.swift

import SwiftUI

struct ContentView: View {

    var body: some View {
        MetalView()
            .frame(minWidth: 640, minHeight: 480)
    }
}

次作成するMetalView.swiftに繋ぐための処理に変えます。

.frameは SwiftUI側でMetalViewの表示サイズを指定するためのmodifierです。ここでは最小幅640、最小高さ480を指定しています。

描画設定

この章ではレンダリングするための設定をしていきます。厳密には違うところがややありますが、大体この理解でOKです。
今ある二つのファイルに加え、metalView.swiftを作成します。

metalView
import SwiftUI
import MetalKit

struct MetalView: NSViewRepresentable {

    func makeNSView(context: Context) -> MTKView {

        let view = MTKView()

        view.device = MTLCreateSystemDefaultDevice()
        view.enableSetNeedsDisplay = false
        view.isPaused = false
        view.preferredFramesPerSecond = 60
        view.colorPixelFormat = .bgra8Unorm
        view.depthStencilPixelFormat = .depth32Float
        view.clearColor = MTLClearColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1.0)

        if let renderer = Renderer(metalView: view) {
            view.delegate = renderer
            context.coordinator.renderer = renderer
        }

        return view
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    final class Coordinator {
        var renderer: Renderer?
    }
    
    func updateNSView(_ nsView: MTKView, context: Context) {
    // 今回は特に更新するものがないので空でOK
    }
}

説明していきます。

func makeNSView(context: Context) -> MTKView

makeNSViewは、SwiftUIMetalViewの中身として使う AppKit側のMTKViewを作る関数です。ここで作った MTKView が、Metalで描画するためのキャンバスになります。

let view = MTKView()

Metal用の描画ビューを作ります。ここから色々設定してく

view.device = MTLCreateSystemDefaultDevice()

どのGPUを使うか設定しています。今回は標準GPUを取得。

view.enableSetNeedsDisplay = false
view.isPaused = false
view.preferredFramesPerSecond = 60

タイミング設定です。

enableSetNeedsDisplay = false
→ 必要な時だけ手動描画する方式ではない

isPaused = false
→ 描画ループを止めない

preferredFramesPerSecond = 60
→ 1秒間に60回くらい描画したい(絶対じゃない)

view.colorPixelFormat = .bgra8Unorm

色の形式です。BGRAをそれぞれ8bitで管理するものです。

view.depthStencilPixelFormat = .depth32Float

奥行きの設定です。深度バッファと呼ばれてるやつです。今回は32bitのfloatで管理します。

view.clearColor = MTLClearColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1.0)

毎フレームの描画前の塗り潰しに使われる色設定です、今回は青みがかった黒。

if let renderer = Renderer(metalView: view) {
    view.delegate = renderer
    context.coordinator.renderer = renderer
}

ここではRendererを作成しMTKViewのdelegateに設定しています。これによって、MTKViewは毎フレームの描画処理をRendererに任せるようになります。

さらに、context.coordinator.renderer = rendererという文があると思います。
view.delegate = renderer だけだと、renderer がローカル変数なので、設定が終わった後解放されてしまう可能性があります。

そこで、
context.coordinator.renderer = renderer
として、SwiftUI の Coordinator に保持させています。

では、この Coordinator はどこで作られているのかというと、下の部分です。

func makeCoordinator() -> Coordinator {
    Coordinator()
}

final class Coordinator {
    var renderer: Renderer?
}

Coordinator
└─ renderer を保持

これによって Renderer が生き残り、描画が続きます。

func updateNSView(_ nsView: MTKView, context: Context) {

}

updateNSViewは、SwiftUI側の状態が変わったときに、既存のMTKViewを更新するための関数です。今回は更新する状態がないので空実装にしています。

レンダリング

ここでついに実際の描画処理を書いていきます。まず全体のコードです。

Renderer
import MetalKit

final class Renderer: NSObject, MTKViewDelegate {

    private let device: MTLDevice
    private let commandQueue: MTLCommandQueue
    private let pipelineState: MTLRenderPipelineState
    private let depthStencilState: MTLDepthStencilState
    private let vertexBuffer: MTLBuffer
    private let indexBuffer: MTLBuffer
    private var time: Float = 0

    init?(metalView: MTKView) {
        guard let device = metalView.device else {
            return nil
        }

        self.device = device

        guard let commandQueue = device.makeCommandQueue() else {
            return nil
        }

        self.commandQueue = commandQueue

        guard let vertexBuffer = device.makeBuffer(
            bytes: CubeMesh.vertices,
            length: MemoryLayout<Vertex>.stride * CubeMesh.vertices.count,
            options: []
        ),
        let indexBuffer = device.makeBuffer(
            bytes: CubeMesh.indices,
            length: MemoryLayout<UInt16>.stride * CubeMesh.indices.count,
            options: []
        ) else {
            return nil
        }

        self.vertexBuffer = vertexBuffer
        self.indexBuffer = indexBuffer

        guard let library = device.makeDefaultLibrary(),
              let vertexFunction = library.makeFunction(name: "vertex_main"),
              let fragmentFunction = library.makeFunction(name: "fragment_main") else {
            return nil
        }

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
        descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat

        do {
            self.pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
        } catch {
            print(error)
            return nil
        }

        let depthDescriptor = MTLDepthStencilDescriptor()
        depthDescriptor.depthCompareFunction = .less
        depthDescriptor.isDepthWriteEnabled = true

        guard let depthStencilState = device.makeDepthStencilState(descriptor: depthDescriptor) else {
            return nil
        }

        self.depthStencilState = depthStencilState

        super.init()
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {

    }

    func draw(in view: MTKView) {
        guard let drawable = view.currentDrawable,
              let renderPassDescriptor = view.currentRenderPassDescriptor else {
            return
        }

        guard let commandBuffer = commandQueue.makeCommandBuffer() else {
            return
        }

        guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
            return
        }

        time += 1 / Float(view.preferredFramesPerSecond)
        var uniforms = makeUniforms(drawableSize: view.drawableSize)

        encoder.setRenderPipelineState(pipelineState)
        encoder.setDepthStencilState(depthStencilState)
        encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 1)
        encoder.drawIndexedPrimitives(
            type: .triangle,
            indexCount: CubeMesh.indices.count,
            indexType: .uint16,
            indexBuffer: indexBuffer,
            indexBufferOffset: 0
        )
        encoder.endEncoding()

        commandBuffer.present(drawable)
        commandBuffer.commit()
    }

    private func makeUniforms(drawableSize: CGSize) -> Uniforms {
        let width = max(Float(drawableSize.width), 1)
        let height = max(Float(drawableSize.height), 1)
        let aspectRatio = width / height

        let modelMatrix = Matrix4x4.rotationY(time) * Matrix4x4.rotationX(time * 0.65)
        let viewMatrix = Matrix4x4.translation(x: 0, y: 0, z: -5)
        let projectionMatrix = Matrix4x4.perspective(
            fovYRadians: .pi / 3,
            aspectRatio: aspectRatio,
            nearZ: 0.1,
            farZ: 100
        )

        return Uniforms(
            modelViewProjectionMatrix: projectionMatrix * viewMatrix * modelMatrix,
            modelMatrix: modelMatrix
        )
    }
}

レンダリングは初期化処理と動的処理に別れます。まず初期の処理から

初期化処理

流れはこんな感じ
①GPUデバイスを受け取る
②CommandQueueを作る
③VertexBuffer / IndexBufferを作る
④Shaderを取得する
⑤RenderPipelineStateを作る
⑥DepthStencilStateを作る

①GPUデバイスを受け取る

guard let device = metalView.device else {
    return nil
}

self.device = device

MetalView側で設定した MTLDevice を受け取っています。

MTLDeviceは、MetalでGPUを扱うための入口です。
この後のCommandQueueやBuffer、PipelineStateなども、このdeviceから作ります。

②CommandQueueを作る

guard let commandQueue = device.makeCommandQueue() else {
    return nil
}

self.commandQueue = commandQueue

CommandQueueとは、GPUへ命令を送る待機列です。ここに放り込むとスタックしている命令がなければ問答無用でGPUが命令を処理します。

③VertexBuffer / IndexBufferを作る

guard let vertexBuffer = device.makeBuffer(
    bytes: CubeMesh.vertices,
    length: MemoryLayout<Vertex>.stride * CubeMesh.vertices.count,
    options: []
),
let indexBuffer = device.makeBuffer(
    bytes: CubeMesh.indices,
    length: MemoryLayout<UInt16>.stride * CubeMesh.indices.count,
    options: []
) else {
    return nil
}

self.vertexBuffer = vertexBuffer
self.indexBuffer = indexBuffer

GPUへ送るデータをBufferとして作成しています。vertexBufferには描画する各頂点の位置と法線が、indexBufferには三角形を構成する3頂点がどれかを指定しています。

データをどこから取得しているかは後々出てきます。先に見ておきたい方はこちらを

④Shaderを取得する

guard let library = device.makeDefaultLibrary(),
      let vertexFunction = library.makeFunction(name: "vertex_main"),
      let fragmentFunction = library.makeFunction(name: "fragment_main") else {
    return nil
}

vertexFunctionfragmentFunctionを取得しています。簡単にいうと、vertexFunctionは頂点ごとの計算。fragmentFunctionはピクセル単位の計算です。

⑤RenderPipelineStateを作る

let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat

do {
    self.pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
} catch {
    print(error)
    return nil
}

何を使って描画するか設定しています。

頂点処理には vertex_main を使う
色の処理には fragment_main を使う
色の出力形式は MTKView と合わせる
深度バッファ形式も MTKView と合わせる

などなど。一度作ったら使い回すので毎フレーム作りません。

⑥DepthStencilStateの作成

let depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true

guard let depthStencilState = device.makeDepthStencilState(descriptor: depthDescriptor) else {
    return nil
}

self.depthStencilState = depthStencilState

奥行き判定の設定です。
depthDescriptor.depthCompareFunction = .lessは手前にあるものを優先する設定。
depthDescriptor.isDepthWriteEnabled = trueは描画したピクセルの奥行きを深度バッファに書き込む設定。

ここまでが初期化処理になります。設定するものや作成しておくものがやや多いですが、動的処理はこんなものではありません。では逝きましょう。

動的処理

①Drawableを取得
②RenderPassDescriptorを取得
③CommandBufferを作る
④RenderCommandEncoderを作る
⑤Uniformsを作る
⑥PipelineStateやBufferをEncoderに設定
⑦drawIndexedPrimitivesで描画命令を記録
⑧endEncodingで記録終了
⑨presentで表示予約、commitでGPUへ送信

①Drawableを取得

guard let drawable = view.currentDrawable,
      let renderPassDescriptor = view.currentRenderPassDescriptor else {
    return
}

ここでは描画先となる画面を取得しています。

currentDrawableは、最終的にGPUが描画結果を書き込むテクスチャです。

一緒に取得しているrenderPassDescriptorは、その画面へどのように描画するかという設定が入っています。

どちらか取得できなければ描画できないため、guardで終了しています。

②CommandBufferを作る

guard let commandBuffer = commandQueue.makeCommandBuffer() else {
    return
}

CommandBufferはGPUへ送る命令書です。
先ほど初期化時に作成したCommandQueueから新しい命令書を一枚受け取っています。

この後行う処理は全てこのCommandBufferへ記録されます。
まだこの時点ではGPUは何も実行していません。

③RenderCommandEncoderを作る

guard let encoder =
    commandBuffer.makeRenderCommandEncoder(
        descriptor: renderPassDescriptor
    ) else {
    return
}

ここでRenderCommandEncoderを作成します。Encoderとは、CommandBufferという命令書に対して、何を、どのように、どれだけ描画するのかなど情報を書き込む設計者のような役割を果たします。

このあと出てくるencoder.set〜という処理は、すべてEncoderへ命令を書いているだけであり、この時点ではまだ描画は行われていません。

④Uniformsを作る

time += 1 / Float(view.preferredFramesPerSecond)
var uniforms = makeUniforms(drawableSize: view.drawableSize)

毎フレーム時間を進め、Shaderへ渡すUniformsを作成しています。
Uniformsには様々な行列が入っており、描画全体で共通して使うデータになります。

今回は時間を使って回転行列を作っているため、立方体が回転します。
makeUniforms()の中身については後ほど詳しく見ていきます。

⑤描画に必要な設定を行う

encoder.setRenderPipelineState(pipelineState)
encoder.setDepthStencilState(depthStencilState)
encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
encoder.setVertexBytes(
    &uniforms,
    length: MemoryLayout<Uniforms>.stride,
    index: 1
)

ここでは描画に必要な情報をGPUへ渡しています。

encoder.setRenderPipelineState(pipelineState)

どのShaderを使って描画するか設定しています。
初期化時に作成したPipelineStateをそのまま利用しています。

encoder.setDepthStencilState(depthStencilState)

奥行き判定の設定を適用します。

encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)

立方体の頂点データをVertex Shaderへ渡しています。位置や法線などの情報がここに入っています。

encoder.setVertexBytes(...)

先ほど作成したUniformsをVertex Shaderへ渡しています。
これによってShader側では行列計算を行えるようになります。

⑥描画命令を書く

encoder.drawIndexedPrimitives(
    type: .triangle,
    indexCount: CubeMesh.indices.count,
    indexType: .uint16,
    indexBuffer: indexBuffer,
    indexBufferOffset: 0
)

やっとここまで来たぞ!描画命令の時間だ!

今回は三角形で描画する、IndexBufferを使用する、IndexBuffer内のすべてのインデックスを使う
という内容になっています。

VertexBufferには頂点情報しかありません。
その頂点をどの順番で結び、三角形を作るかはIndexBufferが担当しています。

⑦命令を書き終える

encoder.endEncoding()

描画命令はしっかりと「ここで終わり」と宣言してあげなければいけません。
これ以降、このEncoderへ命令を追加することはできません。

⑧画面へ表示予約

commandBuffer.present(drawable)

描画が終わったら、このDrawableを画面へ表示するよう予約します。

⑨GPUへ送信

commandBuffer.commit()

最後にCommandBufferをコミットし、GPUの描画待機列へ並ばせます。先客がいなかったらすぐ描画が実行されます。待機列とはcommandQueueです。

データ達

さて、ここまでにいくつか参照スクリプトが突然生えてきていたと思います。
ここでまとめて紹介

①Math3D
②RenderGeometry
③shader

math3D

import simd

typealias Matrix4x4 = simd_float4x4

func * (lhs: Matrix4x4, rhs: SIMD4<Float>) -> SIMD4<Float> {
    simd_mul(lhs, rhs)
}

extension Matrix4x4 {
    static var identity: Matrix4x4 {
        matrix_identity_float4x4
    }

    static func translation(x: Float, y: Float, z: Float) -> Matrix4x4 {
        Matrix4x4(
            SIMD4<Float>(1, 0, 0, 0),
            SIMD4<Float>(0, 1, 0, 0),
            SIMD4<Float>(0, 0, 1, 0),
            SIMD4<Float>(x, y, z, 1)
        )
    }

    static func rotationX(_ radians: Float) -> Matrix4x4 {
        let c = cos(radians)
        let s = sin(radians)

        return Matrix4x4(
            SIMD4<Float>(1, 0, 0, 0),
            SIMD4<Float>(0, c, s, 0),
            SIMD4<Float>(0, -s, c, 0),
            SIMD4<Float>(0, 0, 0, 1)
        )
    }

    static func rotationY(_ radians: Float) -> Matrix4x4 {
        let c = cos(radians)
        let s = sin(radians)

        return Matrix4x4(
            SIMD4<Float>(c, 0, -s, 0),
            SIMD4<Float>(0, 1, 0, 0),
            SIMD4<Float>(s, 0, c, 0),
            SIMD4<Float>(0, 0, 0, 1)
        )
    }

    static func perspective(fovYRadians: Float, aspectRatio: Float, nearZ: Float, farZ: Float) -> Matrix4x4 {
        let yScale = 1 / tan(fovYRadians * 0.5)
        let xScale = yScale / aspectRatio
        let zScale = farZ / (nearZ - farZ)
        let wzScale = nearZ * zScale

        return Matrix4x4(
            SIMD4<Float>(xScale, 0, 0, 0),
            SIMD4<Float>(0, yScale, 0, 0),
            SIMD4<Float>(0, 0, zScale, -1),
            SIMD4<Float>(0, 0, wzScale, 0)
        )
    }
}

座標変換行列です。
mebiusbox様のメモ書きに座標変換行列で必要な知識が全てが載っています。崇めよ。

②RenderGeometry

import simd

struct Vertex {
    let position: SIMD3<Float>
    let normal: SIMD3<Float>
}

struct Uniforms {
    var modelViewProjectionMatrix: Matrix4x4
    var modelMatrix: Matrix4x4
}

enum CubeMesh {
    static let vertices: [Vertex] = [
        Vertex(position: SIMD3<Float>(-1, -1,  1), normal: SIMD3<Float>(0, 0, 1)),
        Vertex(position: SIMD3<Float>( 1, -1,  1), normal: SIMD3<Float>(0, 0, 1)),
        Vertex(position: SIMD3<Float>( 1,  1,  1), normal: SIMD3<Float>(0, 0, 1)),
        Vertex(position: SIMD3<Float>(-1,  1,  1), normal: SIMD3<Float>(0, 0, 1)),

        Vertex(position: SIMD3<Float>( 1, -1, -1), normal: SIMD3<Float>(0, 0, -1)),
        Vertex(position: SIMD3<Float>(-1, -1, -1), normal: SIMD3<Float>(0, 0, -1)),
        Vertex(position: SIMD3<Float>(-1,  1, -1), normal: SIMD3<Float>(0, 0, -1)),
        Vertex(position: SIMD3<Float>( 1,  1, -1), normal: SIMD3<Float>(0, 0, -1)),

        Vertex(position: SIMD3<Float>(-1, -1, -1), normal: SIMD3<Float>(-1, 0, 0)),
        Vertex(position: SIMD3<Float>(-1, -1,  1), normal: SIMD3<Float>(-1, 0, 0)),
        Vertex(position: SIMD3<Float>(-1,  1,  1), normal: SIMD3<Float>(-1, 0, 0)),
        Vertex(position: SIMD3<Float>(-1,  1, -1), normal: SIMD3<Float>(-1, 0, 0)),

        Vertex(position: SIMD3<Float>( 1, -1,  1), normal: SIMD3<Float>(1, 0, 0)),
        Vertex(position: SIMD3<Float>( 1, -1, -1), normal: SIMD3<Float>(1, 0, 0)),
        Vertex(position: SIMD3<Float>( 1,  1, -1), normal: SIMD3<Float>(1, 0, 0)),
        Vertex(position: SIMD3<Float>( 1,  1,  1), normal: SIMD3<Float>(1, 0, 0)),

        Vertex(position: SIMD3<Float>(-1,  1,  1), normal: SIMD3<Float>(0, 1, 0)),
        Vertex(position: SIMD3<Float>( 1,  1,  1), normal: SIMD3<Float>(0, 1, 0)),
        Vertex(position: SIMD3<Float>( 1,  1, -1), normal: SIMD3<Float>(0, 1, 0)),
        Vertex(position: SIMD3<Float>(-1,  1, -1), normal: SIMD3<Float>(0, 1, 0)),

        Vertex(position: SIMD3<Float>(-1, -1, -1), normal: SIMD3<Float>(0, -1, 0)),
        Vertex(position: SIMD3<Float>( 1, -1, -1), normal: SIMD3<Float>(0, -1, 0)),
        Vertex(position: SIMD3<Float>( 1, -1,  1), normal: SIMD3<Float>(0, -1, 0)),
        Vertex(position: SIMD3<Float>(-1, -1,  1), normal: SIMD3<Float>(0, -1, 0))
    ]

    static let indices: [UInt16] = [
        0, 1, 2, 0, 2, 3,
        4, 5, 6, 4, 6, 7,
        8, 9, 10, 8, 10, 11,
        12, 13, 14, 12, 14, 15,
        16, 17, 18, 16, 18, 19,
        20, 21, 22, 20, 22, 23
    ]
}

頂点座標と三角形を構成する3点指定です。

③shader

#include <metal_stdlib>
using namespace metal;

struct VertexIn {
    float3 position;
    float3 normal;	
};

struct Uniforms {
    float4x4 modelViewProjectionMatrix;
    float4x4 modelMatrix;
};

struct VertexOut {
    float4 position [[position]];
    float3 normal;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
                             constant VertexIn *vertices [[buffer(0)]],
                             constant Uniforms &uniforms [[buffer(1)]]) {
    VertexIn input = vertices[vertexID];

    VertexOut out;
    out.position = uniforms.modelViewProjectionMatrix * float4(input.position, 1.0);
    out.normal = normalize((uniforms.modelMatrix * float4(input.normal, 0.0)).xyz);
    return out;
}

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
    float3 lightDirection = normalize(float3(0.4, 0.8, 0.6));
    float diffuse = saturate(dot(in.normal, lightDirection));
    float color = (0.25 + diffuse * 0.75);

    return float4(color,color,color, 1.0);
}
	

shaderです。法線と光源の位置からmeshの明るさを計算する一般的なものです。
shaderについてはそれだけで記事は何個も書けるため、説明を省きます。

最終的に構成はこのようになります。

RenderingProject
├─ ContentView.swift
├─ MetalView.swift
├─ Renderer.swift
├─ RenderGeometry.swift
├─ Math3D.swift
└─ rendering_project.metal

まとめ

さて、これで今回実装する内容は終了です。お疲れ様でした。
この記事からレンダリングに触り始めた方は是非、様々な描画に触れてみてください。思っているより美しいですよ。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?