モーダルを閉じる工作HardwareHub ロゴ画像

工作HardwareHubは、ロボット工作や電子工作に関する情報やモノが行き交うコミュニティサイトです。さらに詳しく

利用規約プライバシーポリシー に同意したうえでログインしてください。

立方体を回転させるサンプル (OpenGL、Python)

モーダルを閉じる

ステッカーを選択してください

お支払い手続きへ
モーダルを閉じる

お支払い内容をご確認ください

購入商品
」ステッカーの表示権
メッセージ
料金
(税込)
決済方法
GooglePayマーク
決済プラットフォーム
確認事項

利用規約をご確認のうえお支払いください

※カード情報はGoogleアカウント内に保存されます。本サイトやStripeには保存されません

※記事の執筆者は購入者のユーザー名を知ることができます

※購入後のキャンセルはできません

作成日作成日
2019/05/06
最終更新最終更新
2023/11/17
記事区分記事区分
一般公開

目次

    C言語の基礎から応用まで幅広くサポート。初心者がつまずきやすいポイントを重点的に解説します。

    立方体を二つ配置して回転させてみます。ライブラリを用いずに OpenGL API を直接利用します。

    wget https://gist.githubusercontent.com/harubot/df886254396a449038ee542ed317f7b3/raw/92216e02d0210b9d81770562ddf7741339f1b286/opengl-setup2.py
    DISPLAY=:0 python opengl-setup2.py
    

    立方体の頂点バッファ (ローカル座標系)

    立方体の頂点情報を CPU で用意して GPU の頂点バッファに送ります。座標は同次座標系で表現すると行列による演算が簡単になります。

    頂点配列オブジェクトの作成 glGenVertexArrays

    vaoList = zeros(1, dtype=GLuint)
    glGenVertexArrays = loadGl('glGenVertexArrays', None, GLsizei, POINTER(GLuint))
    glGenVertexArrays(1, vaoList.ctypes.data_as(POINTER(GLuint)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGenVertexArrays failed')
    

    頂点配列オブジェクトのバインド glBindVertexArray

    glBindVertexArray = loadGl('glBindVertexArray', None, GLuint)
    glBindVertexArray(vaoList[0])
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBindVertexArray failed')
    

    頂点バッファオブジェクトの作成 glGenBuffers

    立方体を描く場合、頂点バッファを節約するために以下のように二つ用意するとよいです。同じ GL_ARRAY_BUFFER は複数回 GL_ELEMENT_ARRAY_BUFFER で参照されます。

    • 8 つの頂点座標を格納する頂点バッファ GL_ARRAY_BUFFER
    • 8 つの頂点座標の組み合わせを格納する頂点バッファ GL_ELEMENT_ARRAY_BUFFER

    作成時は区別されません。

    glGenBuffers = loadGl('glGenBuffers', None, GLsizei, POINTER(GLuint))
    
    buffers = zeros(2, dtype=GLuint)
    glGenBuffers(2, buffers.ctypes.data_as(POINTER(GLuint)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGenBuffers failed')
    
    vbo = buffers[0]
    ibo = buffers[1]
    

    頂点バッファオブジェクトのバインド glBindBuffer

    GL_ARRAY_BUFFER としてバインドします。

    GL_ARRAY_BUFFER = 0x8892
    glBindBuffer = loadGl('glBindBuffer', None, GLenum, GLuint)
    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBindBuffer failed')
    

    頂点バッファオブジェクトにデータを設定 glBufferData

    GL_ARRAY_BUFFER のデータを設定します。

    GLsizeiptr = c_uint
    GLvoid_p = c_void_p
    GL_STATIC_DRAW = 0x88E4
    
    from numpy import float32
    vertices = array([
        [-0.5,-0.5,-0.5, 1],
        [-0.5,-0.5, 0.5, 1],
        [-0.5, 0.5,-0.5, 1],
        [-0.5, 0.5, 0.5, 1],
        [ 0.5,-0.5,-0.5, 1],
        [ 0.5,-0.5, 0.5, 1],
        [ 0.5, 0.5,-0.5, 1],
        [ 0.5, 0.5, 0.5, 1]
    ], dtype=float32)
    
    glBufferData = loadGl('glBufferData', None, GLenum, GLsizeiptr, GLvoid_p, GLenum)
    glBufferData(GL_ARRAY_BUFFER,
                 GLsizeiptr(vertices.size * vertices.dtype.itemsize),
                 vertices.ctypes.data_as(GLvoid_p),
                 GL_STATIC_DRAW)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBufferData failed')
    

    頂点バッファオブジェクトと頂点属性の対応関係を設定 glVertexAttribPointer

    GL_ARRAY_BUFFER のデータをバーテックスシェーダの in 変数で参照できるようにします。

    GLboolean = c_uint
    GL_FLOAT = 0x1406
    
    glVertexAttribPointer = loadGl('glVertexAttribPointer', None, GLuint, GLint, GLenum, GLboolean, GLsizei, GLvoid_p)
    glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glVertexAttribPointer failed')
    

    glVertexAttribPointer の引数について補足

    • 0 position 頂点属性を指定しています。
    • 4 glBufferData で設定した座標データは4次元です。
    • GL_FLOAT 座標データの型です。
    • 0 glBufferData で格納したデータに複数の頂点属性用のデータが入っている場合は変更します。
    • 0 glBufferData で格納したデータに複数の頂点属性用のデータが入っている場合は変更します。

    頂点属性を有効化 glEnableVertexAttribArray

    glEnableVertexAttribArray = loadGl('glEnableVertexAttribArray', None, GLuint)
    glEnableVertexAttribArray(0)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glEnableVertexAttribArray failed')
    

    インデックスとなる頂点バッファオブジェクトのバインドおよびデータ設定

    GL_ELEMENT_ARRAY_BUFFER としてバインド

    GL_ELEMENT_ARRAY_BUFFER = 0x8893
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBindBuffer failed')
    

    GL_ELEMENT_ARRAY_BUFFER のデータ設定

    from numpy import uint32
    indices = array([
        [0,1],
        [0,2],
        [0,4],
        [1,3],
        [1,5],
        [2,3],
        [2,6],
        [3,7],
        [4,5],
        [4,6],
        [5,7],
        [6,7]
    ], dtype=uint32)
    
    glBufferData(GL_ELEMENT_ARRAY_BUFFER,
                 GLsizeiptr(indices.size * indices.dtype.itemsize),
                 indices.ctypes.data_as(GLvoid_p),
                 GL_STATIC_DRAW)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBufferData failed')
    

    補足

    後述のとおり、描画時は glDrawArrays ではなく glDrawElements を用います。

    モデル変換 (ローカル座標系 → ワールド座標系)

    STL ファイル等で表現されたオブジェクトのメッシュの各頂点は、オブジェクト内のローカル座標系における座標を持ちます。複数のオブジェクトを同じ環境に配置する場合、配置される環境のワールド座標系における、オブジェクトのメッシュの頂点の座標が必要になります。ローカル座標系からワールド座標系への変換をモデル変換とよびます。環境のことをシーンともよびます。

    同次変換行列を立方体二つについてそれぞれ以下のように設定することにします。一つ目はローカル座標系の座標をそのままワールド座標系でも用います。二つ目はローカル座標系の座標を x 軸まわりに 45 度回転して更に x 軸方向に 1.0 だけ平行移動します。これらの行列は OpenGL のバーテックスシェーダに uniform 変数で設定します。

    M1=(1000010000100001)M_1 = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}
    M2=(10010cosπ4sinπ400sinπ4cosπ400001)M_2 = \begin{pmatrix} 1 & 0 & 0 & 1 \\ 0 & \cos \frac{\pi}{4} & -\sin \frac{\pi}{4} & 0 \\ 0 & \sin \frac{\pi}{4} & \cos \frac{\pi}{4} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

    ビュー変換 (ワールド座標系 → 視点座標系)

    OpenGL で二次元の画面に表示するにあたり、シーンにおけるカメラの視点が必要になります。カメラから見たときの視点座標系への変換をビュー変換とよびます。モデル変換とまとめてモデルビュー変換ともよびます。

    ワールド座標系の座標を z 軸まわりに 45 度回転させて更に z 軸方向に -1 だけ平行移動したものを視点座標系とすると以下のような同次変換行列になります。OpenGL のバーテックスシェーダに uniform 変数で設定します。

    V=(cosπ4sinπ400sinπ4cosπ40000110001)V = \begin{pmatrix} \cos \frac{\pi}{4} & -\sin \frac{\pi}{4} & 0 & 0 \\ \sin \frac{\pi}{4} & \cos \frac{\pi}{4} & 0 & 0 \\ 0 & 0 & 1 & -1 \\ 0 & 0 & 0 & 1 \end{pmatrix}

    投影変換 (視点座標系 → 正規化デバイス座標系)

    視点座標系において最終的に画面に表示したい空間を切り出して、x,y,z[-1,1] の立方体内に収まるように新たな正規化デバイス座標系 (Normalized Device Coordinate; NDC) へ変換します。正規化デバイス座標系はクリッピング座標系ともよびます。

    • 視点座標系において切り出す空間を視体積 (View Volume) とよびます。
    • 正規化デバイス座標系における [-1,1] の立方体を標準視体積 (クリッピング領域、クリッピング空間) とよびます。

    クリッピング領域は立方体になっており z 方向の深度があります。クリッピング領域は次のステージで xy 平面に投影されます。投影される対象となるクリッピング領域内に収めるために投影変換を行います。投影変換には複数の種類があります。

    • 直行投影 (Orthogonal Projection) → 視体積が直方体、遠くも近くも同じ大きさになるように変換します。
    • 透視投影 (Perspective Projection) → 視体積が四角錐台、遠くのものが小さくなるように変換します。

    直行投影と透視投影の変換行列は最終的に以下のようになります。OpenGL のバーテックスシェーダに uniform 変数で設定して利用します。

    • n 視点座標系における視体積の前方面について、z 軸方向の原点からの距離 (near)
    • f 視点座標系における視体積の後方面について、z 軸方向の原点からの距離 (far)
    • r,l,t,b 視点座標系における視錐台の前方面について、辺の xy 座標 (right、left、top、bottom)

    直行投影

    P1=(2rl00r+lrl02tb0t+btb002fnf+nfn0001)P_1 = \begin{pmatrix} \frac{2}{r - l} & 0 & 0 & -\frac{r + l}{r - l} \\ 0 & \frac{2}{t - b} & 0 & -\frac{t + b}{t - b} \\ 0 & 0 & -\frac{2}{f - n} & -\frac{f+n}{f - n} \\ 0 & 0 & 0 & 1 \end{pmatrix}

    特に r+l=0r+l=0t+b=0t+b=0r=tr=t となる、前方面と後方面が正方形で中心が z 軸上の場合を考えると以下のようになります。

    P1=(1r00001r00002fnf+nfn0001)P_1 = \begin{pmatrix} \frac{1}{r} & 0 & 0 & 0 \\ 0 & \frac{1}{r} & 0 & 0 \\ 0 & 0 & -\frac{2}{f - n} & -\frac{f+n}{f - n} \\ 0 & 0 & 0 & 1 \end{pmatrix}

    透視投影

    透視投影における視体積を特に視錐台 (View Frustum) とよびます。

    P2=(2nrl0r+lrl002ntbt+btb000f+nfn2fnfn0010)P_2 = \begin{pmatrix} \frac{2n}{r - l} & 0 & \frac{r + l}{r - l} & 0 \\ 0 & \frac{2n}{t - b} & \frac{t + b}{t - b} & 0 \\ 0 & 0 & -\frac{f + n}{f - n} & -\frac{2fn}{f - n} \\ 0 & 0 & -1 & 0 \end{pmatrix}

    特に r+l=0r+l=0t+b=0t+b=0r=tr=t となる、前方面と後方面が正方形で中心が z 軸上の場合を考えると以下のようになります。

    P2=(nr0000nr0000f+nfn2fnfn0010)P_2 = \begin{pmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & \frac{n}{r} & 0 & 0 \\ 0 & 0 & -\frac{f + n}{f - n} & -\frac{2fn}{f - n} \\ 0 & 0 & -1 & 0 \end{pmatrix}

    ビューポート変換 (正規化デバイス座標系 → デバイス座標系) glViewport

    ビューポート変換では正規化デバイス座標系のクリッピング空間を切り出して xy 平面のビューポートに投影します。ビューポートのサイズは glViewport で指定します。xy 平面内における座標系をデバイス座標系とよびます。

    glViewport = loadGl('glViewport', None, GLint, GLint, GLsizei, GLsizei)
    glViewport(0, 0, 100, 100)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glViewport failed')
    

    glViewport の引数について

    • 0,0 描画するカラーバッファについて、描画範囲となる矩形の左下の座標です。
    • 100,100 描画するカラーバッファについて、描画範囲となる矩形の幅と高さです。

    第三引数と第四引数の値が異なる場合はビューポート変換によってアスペクト比が変化します。投影変換時に画面のアスペクト比を考慮しておくと、ビューポート変換でデバイスの画面に描画されたときにも縦横の比が保たれます。

    描画

    シェーダオブジェクト内にソースコードを設定 glShaderSource

    glShaderSource でシェーダオブジェクトにソースコードを設定します。

    vstring = """#version 130
    uniform mat4 model;
    uniform mat4 view;
    uniform mat4 projection;
    in vec4 position;
    void main() {
      gl_Position = projection * view * model * position;
    }
    """
    glShaderSource(vobj, 1,
                   byref(array(vstring, dtype=GLchar).ctypes.data_as(POINTER(GLchar))),
                   byref(GLint(len(vstring))))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glShaderSource failed')
    
    fstring = """#version 130
    out vec4 fragment;
    void main() {
      fragment = vec4(0.0, 1.0, 0.0, 1.0);
    }
    """
    glShaderSource(fobj, 1,
                   byref(array(fstring, dtype=GLchar).ctypes.data_as(POINTER(GLchar))),
                   byref(GLint(len(fstring))))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glShaderSource failed')
    

    ソースコードのコンパイル glCompileShader

    glCompileShader(vobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glCompileShader failed')
    
    glCompileShader(fobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glCompileShader failed')
    

    コンパイルが成功したかどうかの確認

    params = GLint(0)
    glGetShaderiv(GLuint(vobj), GL_COMPILE_STATUS, byref(params))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGetShaderiv failed')
    if not params.value == GL_TRUE:
        raise Exception('compilation failed')
    
    params = GLint(0)
    glGetShaderiv(GLuint(fobj), GL_COMPILE_STATUS, byref(params))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGetShaderiv failed')
    if not params.value == GL_TRUE:
        raise Exception('compilation failed')
    

    プログラムオブジェクトへのシェーダオブジェクトのアタッチ glAttachShader

    glAttachShader(program, vobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glAttachShader failed')
    
    glAttachShader(program, fobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glAttachShader failed')
    

    シェーダオブジェクトの削除フラグを設定 glDeleteShader

    glDeleteShader(vobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glDeleteShader failed')
    
    glDeleteShader(fobj)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glDeleteShader failed')
    

    プログラムオブジェクトの変数設定およびリンク glBindAttribLocationglBindFragDataLocationglLinkProgram

    glBindAttribLocation(program, 0, array('position', dtype=GLchar).ctypes.data_as(POINTER(GLchar)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBindAttribLocation failed')
    
    glBindFragDataLocation(program, 0, array('fragment', dtype=GLchar).ctypes.data_as(POINTER(GLchar)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glBindFragDataLocation failed')
    
    glLinkProgram(program)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glLinkProgram failed')
    

    プログラムをインストール glUseProgram

    glUseProgram(program)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glUseProgram failed')
    

    uniform 変数の location を取得 glGetUniformLocation

    glGetUniformLocation = loadGl('glGetUniformLocation', GLint, GLuint, POINTER(GLchar))
    
    modelLocation = glGetUniformLocation(program, array('model' + '\x00', dtype=GLchar).ctypes.data_as(POINTER(GLchar)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGetUniformLocation failed')
    
    viewLocation = glGetUniformLocation(program, array('view' + '\x00', dtype=GLchar).ctypes.data_as(POINTER(GLchar)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGetUniformLocation failed')
    
    projectionLocation = glGetUniformLocation(program, array('projection' + '\x00', dtype=GLchar).ctypes.data_as(POINTER(GLchar)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glGetUniformLocation failed')
    

    uniform 変数の設定および描画 glUniformglDrawElements

    ascontiguousarray で配列の領域がメモリに連続して確保されることを保証してから glUniform に配列のポインタを渡します。OpenGL の仕様上、行と列を転置したものを渡す必要があることにも注意します。

    描画時は glDrawArrays ではなく glDrawElements を用います。glDrawElements の第二引数 24 は GL_ELEMENT_ARRAY_BUFFER 頂点バッファの要素数です。

    ビュー変換、投影変換

    from numpy import ascontiguousarray
    from numpy import array
    from numpy import eye
    from numpy import sin
    from numpy import cos
    from numpy import pi
    
    view = array([
        [cos(pi/4), -sin(pi/4), 0, 0],
        [sin(pi/4), cos(pi/4), 0, 0],
        [0, 0, 1, -1],
        [0, 0, 0, 1]
    ], dtype=float32)
    
    r = 2.0
    n = 0.25
    f = 1.75
    
    projection = array([
        [1/r, 0, 0, 0],
        [0, 1/r, 0, 0],
        [0, 0, -2/(f-n), -(f+n)/(f-n)],
        [0, 0, 0, 1]
    ], dtype=float32)
    
    #projection = array([
    #    [n/r, 0, 0, 0],
    #    [0, n/r, 0, 0],
    #    [0, 0, -(f+n)/(f-n), -2*f*n/(f-n)],
    #    [0, 0, -1, 0]
    #], dtype=float32)
    
    glUniformMatrix4fv = loadGl('glUniformMatrix4fv', None, GLint, GLsizei, GLboolean, POINTER(GLfloat))
    
    glUniformMatrix4fv(viewLocation, 1, GL_FALSE, ascontiguousarray(view.T.reshape(16), dtype=float32).ctypes.data_as(POINTER(GLfloat)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glUniformMatrix4fv failed')
    
    glUniformMatrix4fv(projectionLocation, 1, GL_FALSE, ascontiguousarray(projection.T.reshape(16), dtype=float32).ctypes.data_as(POINTER(GLfloat)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glUniformMatrix4fv failed')
    

    モデル変換、描画

    GL_LINES = 0x0001
    GL_UNSIGNED_INT = 0x1405
    glDrawElements = loadGl('glDrawElements', None, GLenum, GLsizei, GLenum, GLvoid_p)
    

    立方体一つ目

    model1 = array(eye(4), dtype=float32)
    
    glUniformMatrix4fv(modelLocation, 1, GL_FALSE, ascontiguousarray(model1.T.reshape(16), dtype=float32).ctypes.data_as(POINTER(GLfloat)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glUniformMatrix4fv failed')
    
    glDrawElements(GL_LINES, 24, GL_UNSIGNED_INT, 0)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glDrawElements failed')
    

    立方体二つ目

    model2 = array([
        [1, 0, 0, 1],
        [0, cos(pi/4), -sin(pi/4), 0],
        [0, sin(pi/4), cos(pi/4), 0],
        [0, 0, 0, 1]
    ], dtype=float32)
    
    glUniformMatrix4fv(modelLocation, 1, GL_FALSE, ascontiguousarray(model2.T.reshape(16), dtype=float32).ctypes.data_as(POINTER(GLfloat)))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glUniformMatrix4fv failed')
    
    glDrawElements(GL_LINES, 24, GL_UNSIGNED_INT, 0)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glDrawElements failed')
    

    描画結果の確認

    glReadBuffer(GL_COLOR_ATTACHMENT0)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glReadBuffer failed')
    
    image = zeros((100, 100, 3), dtype=uint8)
    glReadPixels(0, 0, 100, 100, GL_RGB, GL_UNSIGNED_BYTE, image.ctypes.data_as(GLvoid_p))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glReadPixels failed')
    
    import matplotlib.pyplot as plt
    plt.imshow(image)
    plt.show()
    

    直行投影

    y軸が下を向いているため 45 度の回転が右回りになっています。

    透視投影

    後方の面が前方の面よりも小さく描画されていることが分かります。

    隠面消去 glEnable

    ビューポート変換時に二次元平面に投影されるにあたり、描画される順番によっては後方の部分が前方の描画結果を上書きしてしまうことがあります。これを回避するために隠面消去が必要になります。隠面消去の処理の一つにデプスバッファ法 (Zバッファ法) があります。その他の処理法に背面カリングなどがあります。

    デプスバッファ法は OpenGL に組込まれており既定では無効になっています。有効化すると新たに深度情報を格納するデプスバッファが用意され、描画したポリゴンの深度情報が格納されていきます。

    GL_DEPTH_TEST = 0x0B71
    
    glEnable = loadGl('glEnable', None, GLenum)
    glEnable(GL_DEPTH_TEST)
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glEnable failed')
    
    glClear(GLbitfield(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT))
    if not glGetError() == GL_NO_ERROR:
        raise Exception('glClear failed')
    

    テクスチャの適用 (参考)

    glEnable(GL_TEXTURE_2D) で有効化してテクスチャを利用できます。

    1. テクスチャオブジェクトの生成 glGenTextures
    2. テクスチャオブジェクトのバインド glBindTexture
    3. GL_MAX_TEXTURE_SIZE を越えていないことを glGetIntegerv で確認
    4. テクスチャ画像の設定 glTexImage2D
    5. テクスチャパラメータの設定 glTexParameter
    6. フラグメントシェーダで texture() に UV 座標を設定
    7. サンプラについて glGenSamplers
    Likeボタン(off)0
    詳細設定を開く/閉じる
    アカウント プロフィール画像

    C言語の基礎から応用まで幅広くサポート。初心者がつまずきやすいポイントを重点的に解説します。

    記事の執筆者にステッカーを贈る

    有益な情報に対するお礼として、またはコメント欄における質問への返答に対するお礼として、 記事の読者は、執筆者に有料のステッカーを贈ることができます。

    >>さらに詳しくステッカーを贈る
    ステッカーを贈る コンセプト画像

    Feedbacks

    Feedbacks コンセプト画像

      ログインするとコメントを投稿できます。

      ログインする

      関連記事