単純なシェーダープログラムの作成

今までのサンプルは古い OpenGL の仕様に基づいたコードなので、いずれ無用の存在になる(私のノートはこういうパターンがけっこう多い)。全てを忘れて最新バージョンの OpenGL が推奨する様式でコードを書くことにしたい。残念ながら私の環境は OpenGL 3.1 までの機能しかサポートしていないので、今後ここに記すコードはせいぜい 3.1 程度の新しさとなる。

私の OpenGL の知識はバージョン 1.4 程度で止まっており、ましてやシェーダーなどは触ったこともない。ゆえに、ここでは初歩的な事項の確認にとどまる。

Warning

私の環境でプログラムが適切なグラフィックドライバーを取得できない不具合が発生しており、本稿のスクリプトを実行すると、次のエラーメッセージが生じて異常終了する。ちなみに description のテキストは「無効な列挙」だ。

bash$ ./shaderdemo.py
freeglut (./shaderdemo.py): OpenGL >2.1 context requested but wglCreateContextAttribsARB is not available! Falling back to legacycontext creation
freeglut (./shaderdemo.py): fgInitGL2: fghGenBuffers is NULL
Vendor: Microsoft Corporation
Renderer: GDI Generic
Version: 1.1.0
Traceback (most recent call last):
  File "./shaderdemo.py", line 214, in <module>
    sys.exit(main(sys.argv))
  File "./shaderdemo.py", line 211, in main
    app.run(sys.argv)
  File "D:\home\yojyo\devel\all-note\notebook\source\_sample\pyopengl\appbase.py", line 69, in run
    self.init_glut(args)
  File "D:\home\yojyo\devel\all-note\notebook\source\_sample\pyopengl\appbase.py", line 120, in init_glut
    GL.glGetString(i[1]).decode()),
  File "errorchecker.pyx", line 53, in OpenGL_accelerate.errorchecker._ErrorChecker.glCheckError (src\errorchecker.c:1218)
OpenGL.error.GLError: GLError(
        err = 1280,
        description = b'\x96\xb3\x8c\xf8\x82\xc8\x97\xf1\x8b\x93',
        baseOperation = glGetString,
        cArguments = (GL_SHADING_LANGUAGE_VERSION,)
)

サンプルプログラムを探す

なにぶん知識がないものだから Google で GLSL 等の単語を検索して、色々と漁ってみるしかない。次のようなサイトが取っ掛かりになる。

GLSL 1.2 Tutorial

バージョンが 1.2 と古いが、参考になった。

rndblnch / opengl-programmable

話が早いことに PyOpenGL で書かれたプログラムも発見できた。

サンプルコード全体

プログラムをスクリプトファイルの形で構成する。全体を以下に示す:

#!/usr/bin/env python
"""shaderdemo.py: Demonstrate GLSL.

References:
  * rndblnch / opengl-programmable
    <http://bitbucket.org/rndblnch/opengl-programmable>
  * OpenGLBook.com
    <http://openglbook.com/chapter-4-entering-the-third-dimension.html>
  * Tutorials for modern OpenGL (3.3+)
    <http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/>
"""
# pylint: disable=unused-argument, no-self-use, invalid-name, no-member
import sys
import os
import colorsys
from ctypes import c_void_p

import numpy as np
from PIL import Image
import OpenGL.GL as GL

from modernapp import ModernApp

VERT_SHADER_SOURCE = """
#version 330 core

uniform mat4 camera;
uniform mat4 projection;
uniform mat4 rotation;

layout(location=0) in vec4 in_Position;
layout(location=1) in vec4 in_Color;
layout(location=2) in vec2 in_TexCoord;

out vec4 ex_Color;
out vec2 ex_TexCoord;

void main(void)
{
    gl_Position = projection * camera * rotation * in_Position;
    ex_Color = in_Color;
    ex_TexCoord = in_TexCoord;
}
"""

FRAG_SHADER_SOURCE = """
#version 330 core

uniform sampler2D texture_sampler;

in vec4 ex_Color;
in vec2 ex_TexCoord;
out vec4 out_Color;

void main(void)
{
    out_Color = texture(texture_sampler, ex_TexCoord).rgba;
    if(out_Color.a < 0.9){
        out_Color = ex_Color;
    }
}
"""

class ShaderDemoApp(ModernApp):
    """A GLSL demonstration."""

    def __init__(self, **kwargs):
        """Initialize an instance of class ShaderDemoApp."""

        kwargs.setdefault('context_version', (3, 1))
        super(ShaderDemoApp, self).__init__(**kwargs)

        self.buffer_id = 0
        self.index_buffer_id = 0
        self.num_triangles = 24
        self.texture = 0

        self.vao_id = 0

    def get_shader_sources(self):
        """Initialize shader source."""

        return {
            GL.GL_VERTEX_SHADER: VERT_SHADER_SOURCE,
            GL.GL_FRAGMENT_SHADER: FRAG_SHADER_SOURCE,}

    def init_object(self):
        """Initialize the object to be displayed."""

        num_triangles = self.num_triangles
        vertices = np.array(
            [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0])
        for i in range(num_triangles):
            theta = 2 * np.pi * i / num_triangles
            pos = np.array([np.cos(theta), np.sin(theta), 1.0, 1.0])
            color = colorsys.hsv_to_rgb(i / num_triangles, 1.0, 1.0)
            tex = pos[0:2]
            vertices = np.hstack((vertices, pos, color, [1.0], tex))

        vertices = vertices.astype(np.float32)
        indices = np.hstack(
            (range(num_triangles + 1), 1)).astype(np.uint8)

        self.vao_id = GL.glGenVertexArrays(1)
        GL.glBindVertexArray(self.vao_id)

        # Create buffer objects data.
        self.buffer_id = GL.glGenBuffers(1)
        GL.glBindBuffer(
            GL.GL_ARRAY_BUFFER, self.buffer_id)
        GL.glBufferData(
            GL.GL_ARRAY_BUFFER, vertices, GL.GL_STATIC_DRAW)

        self.index_buffer_id = GL.glGenBuffers(1)
        GL.glBindBuffer(
            GL.GL_ELEMENT_ARRAY_BUFFER, self.index_buffer_id)
        GL.glBufferData(
            GL.GL_ELEMENT_ARRAY_BUFFER, indices, GL.GL_STATIC_DRAW)

        # xyzwrgbauv := [float32] * 4 + [float32] * 4 + [float32] * 2
        vsize = np.nbytes[np.float32] * 4
        csize = np.nbytes[np.float32] * 4
        tsize = np.nbytes[np.float32] * 2
        unit_size = vsize + csize + tsize
        GL.glVertexAttribPointer(
            0, 4, GL.GL_FLOAT, GL.GL_FALSE, unit_size, None)
        GL.glVertexAttribPointer(
            1, 4, GL.GL_FLOAT, GL.GL_FALSE, unit_size, c_void_p(vsize))
        if self.texture:
            GL.glVertexAttribPointer(
                2, 2, GL.GL_FLOAT, GL.GL_FALSE,
                unit_size, c_void_p(vsize + csize))
        GL.glEnableVertexAttribArray(0)
        GL.glEnableVertexAttribArray(1)
        if self.texture:
            GL.glEnableVertexAttribArray(2)

    def init_texture(self):
        """Initialize textures."""

        source_path = os.path.join(
            os.path.dirname(__file__), '../../_images/illvelo.png')
        img = Image.open(source_path).resize((256, 256))
        assert img.mode == 'RGBA'

        tex_id = GL.glGetUniformLocation(
            self.program_manager.program_id, b"texture_sampler")
        GL.glUniform1i(tex_id, 0)

        GL.glActiveTexture(GL.GL_TEXTURE0)
        self.texture = GL.glGenTextures(1)
        GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture)

        GL.glTexImage2D(
            GL.GL_TEXTURE_2D, 0, GL.GL_RGBA,
            img.size[0], img.size[1],
            0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, img.tobytes())

        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)

    def do_render(self):
        """Render the object."""

        GL.glDrawElements(
            GL.GL_TRIANGLE_FAN, self.num_triangles + 2,
            GL.GL_UNSIGNED_BYTE, None)

    def cleanup(self):
        """The clean up callback function."""

        super(ShaderDemoApp, self).cleanup()
        self.destroy_vbo()

        GL.glDeleteTextures(1, [self.texture])

    def destroy_vbo(self):
        """Clean up VAO and VBO."""

        if self.texture:
            GL.glDisableVertexAttribArray(2)

        GL.glDisableVertexAttribArray(1)
        GL.glDisableVertexAttribArray(0)

        GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
        GL.glDeleteBuffers(1, [self.buffer_id])

        GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
        GL.glDeleteBuffers(1, [self.index_buffer_id])

        GL.glBindVertexArray(0)
        GL.glDeleteVertexArrays(1, [self.vao_id])

def main(args):
    """The main function."""

    app = ShaderDemoApp(
        window_title=b'Shader Demo',
        window_size=(300, 300),
        camera_eye=(2., 2., 2.),
        camera_center=(0., 0., 0.),
        camera_up=(0., 0., 1.))
    app.run(sys.argv)

if __name__ == "__main__":
    sys.exit(main(sys.argv))

これはイルベロの描かれた正多角形を描画するだけのプログラムである。シェーダープログラム、テクスチャー、座標変換機能はベースクラスで実装済みである。残念ながら、照光処理は実装していない。

ポイント解説

本節では上記コードを要所に絞って解説していく。解説するポイントの順序は、スクリプトの先頭から末尾に向かってとする。

インポート

import os
import colorsys
from ctypes import c_void_p

import numpy as np
from PIL import Image
import OpenGL.GL as GL

from modernapp import ModernApp

  • 頂点の RGB を指定するのを間接的に行う予定で、標準モジュールの colorsys をインポートする。

  • 後述するバッファーデータのオフセット指定のために、どうしても謎のクラス ctypes.c_void_p をインポートする必要がある。

  • OpenGL 3.1 を志向しているので、GLU は決してインポートしない。

  • クラス ModenApp のサブクラスをつくるため、当然インポートする。

シェーダーコード

シェーダーコード自身については、OpenGL でも PyOpenGL でも何ら変わらない。Python の場合はトリプルクォート記法が便利だ。

頂点シェーダーコード

#version 330 core

uniform mat4 camera;
uniform mat4 projection;
uniform mat4 rotation;

layout(location=0) in vec4 in_Position;
layout(location=1) in vec4 in_Color;
layout(location=2) in vec2 in_TexCoord;

out vec4 ex_Color;
out vec2 ex_TexCoord;

void main(void)
{
    gl_Position = projection * camera * rotation * in_Position;
    ex_Color = in_Color;
    ex_TexCoord = in_TexCoord;
}
  • OpenGL コンテキストバージョンを 3.1 以上に上げると、シェーダーコードの先頭行を #version 330 のようにバージョンの宣言をするのがほぼ必須となる。明示的に指定しないと、コンパイラーはかなり古いシェーダーコードとみなす。結果、コンパイルエラーを引き起こす。

  • layout(location=0) in vec4 ... の行の宣言により、Python コードからシェーダーに何らかの四次元ベクトルデータを引き渡すこと示す。

    • 関数呼び出し glVertexAttribPointer(0, 4, ...) で、頂点シェーダー内 location = 0 のデータに四次元ベクトルデータをセットする。

    • 関数呼び出し glEnableVertexAttribArray(0) で、このやりとりを有効にするという手続きになるのだろう。

フラグメントシェーダーコード

#version 330 core

uniform sampler2D texture_sampler;

in vec4 ex_Color;
in vec2 ex_TexCoord;
out vec4 out_Color;

void main(void)
{
    out_Color = texture(texture_sampler, ex_TexCoord).rgba;
    if(out_Color.a < 0.9){
        out_Color = ex_Color;
    }
}
  • 頂点シェーダーの出力がフラグメントシェーダーの入力となる。

  • 最新バージョンの GLSL では gl_FragColor が廃止されたらしいので、本シェーダーの出力を out ... の行で別途宣言する。

クラス ShaderDemoApp

OpenGL 3.0 以降準拠の PyOpenGL スクリプトのためのベースクラス ModenApp のサブクラスを定義する。

メソッド __init__

        """Initialize an instance of class ShaderDemoApp."""

        kwargs.setdefault('context_version', (3, 1))
        super(ShaderDemoApp, self).__init__(**kwargs)

        self.buffer_id = 0
        self.index_buffer_id = 0
        self.num_triangles = 24
        self.texture = 0

        self.vao_id = 0

  • コンテキストバージョンは、コンストラクターに明示的に指示のない限り、本稿の意図通りに 3.1 とする。

  • メンバーデータ buffer_id および index_buffer_id を、関数 glGenBuffers 系の ID を管理するために宣言する。プログラムがそれらの生成時と削除時に参照する。

  • メンバーデータ num_triangles は描画する多角形の頂点数を示すものだ。扱いとしては定数である。

  • メンバーデータ texture は関数 glGenTextures の戻り値をキープしておくためのものだ。

  • メンバーデータ vao_id は頂点配列オブジェクト用だ。

メソッド get_shader_sources

        """Initialize shader source."""

        return {
            GL.GL_VERTEX_SHADER: VERT_SHADER_SOURCE,
            GL.GL_FRAGMENT_SHADER: FRAG_SHADER_SOURCE,}

メソッド get_shader_source は前述のシェーダープログラム初期化メソッドから呼び出すためのテンプレートメソッドとする。頂点シェーダーやフラグメントシェーダーのソースコードをメンバーデータの辞書に設定する。

メソッド init_object

        """Initialize the object to be displayed."""

        num_triangles = self.num_triangles
        vertices = np.array(
            [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0])
        for i in range(num_triangles):
            theta = 2 * np.pi * i / num_triangles
            pos = np.array([np.cos(theta), np.sin(theta), 1.0, 1.0])
            color = colorsys.hsv_to_rgb(i / num_triangles, 1.0, 1.0)
            tex = pos[0:2]
            vertices = np.hstack((vertices, pos, color, [1.0], tex))

        vertices = vertices.astype(np.float32)
        indices = np.hstack(
            (range(num_triangles + 1), 1)).astype(np.uint8)

        self.vao_id = GL.glGenVertexArrays(1)
        GL.glBindVertexArray(self.vao_id)

        # Create buffer objects data.
        self.buffer_id = GL.glGenBuffers(1)
        GL.glBindBuffer(
            GL.GL_ARRAY_BUFFER, self.buffer_id)
        GL.glBufferData(
            GL.GL_ARRAY_BUFFER, vertices, GL.GL_STATIC_DRAW)

        self.index_buffer_id = GL.glGenBuffers(1)
        GL.glBindBuffer(
            GL.GL_ELEMENT_ARRAY_BUFFER, self.index_buffer_id)
        GL.glBufferData(
            GL.GL_ELEMENT_ARRAY_BUFFER, indices, GL.GL_STATIC_DRAW)

        # xyzwrgbauv := [float32] * 4 + [float32] * 4 + [float32] * 2
        vsize = np.nbytes[np.float32] * 4
        csize = np.nbytes[np.float32] * 4
        tsize = np.nbytes[np.float32] * 2
        unit_size = vsize + csize + tsize
        GL.glVertexAttribPointer(
            0, 4, GL.GL_FLOAT, GL.GL_FALSE, unit_size, None)
        GL.glVertexAttribPointer(
            1, 4, GL.GL_FLOAT, GL.GL_FALSE, unit_size, c_void_p(vsize))
        if self.texture:
            GL.glVertexAttribPointer(
                2, 2, GL.GL_FLOAT, GL.GL_FALSE,
                unit_size, c_void_p(vsize + csize))
        GL.glEnableVertexAttribArray(0)
        GL.glEnableVertexAttribArray(1)
        if self.texture:
            GL.glEnableVertexAttribArray(2)

主に頂点バッファーオブジェクトを定義する。

次の段階を踏んでバッファーオブジェクトを構築していく。

  1. NumPy の配列を活用して、描画するべき頂点の各種属性を定義する。

  2. 定義した頂点データのアドレス的なものを PyOpenGL を用いて指定する。

まずは本コードで想定している vertices の「メモリレイアウト」について説明する。

例えば三角形を N 個描くものとする。このとき、座標と色のペアが N 個あることになる。サンプルコードにおける想定はこのようなものだ。

  • 点の座標と色の表現をそれぞれ (x, y, z, w) および (R, G, B, A) で表現することにした。そして、一つの頂点データを (x, y, z, w, R, G, B, A) で表現することにした。

  • 点・色の各成分の型を OpenGL の GL_FLOAT に相当する、NumPy の np.float32 で表現することにした。

  • 頂点データ全体を (x0, y0, z0, w0, R0, G0, B0, A0, ..., ) のように直列して表現することにした。

結局は GL_FLOAT 型の 4 * 8 * N 要素からなる配列を作成することになるが、プログラムでは

  • np.array を配列バッファーとして、

  • np.array.hstack を配列の単純連結操作として、

  • np.array.astype を高精度の型 (e.g. float64) から低精度の型 (e.g. float32) へキャストするための操作として

適宜応用した。

描画するイメージ自体についてのプランはこうだ。

  • 平面 z = 1 上の単位円周上に正 24 角形を描く。

  • GL_TRIANGLE_FAN で原点 (0, 0) を中心としてファンとして描く。

  • 頂点の色は配列のインデックスをベースに HSV で決める。中心は白にしておく。

  • 頂点配列オブジェクトを新たに導入する。関数 glGenVertexArrays, glBindVertexArray を用いる。

  • 関数 glGenBuffers での実引数に注意。1 の場合はリストではなく、単なる整数が戻る。

  • 関数 glBufferData の第二引数には NumPy の配列オブジェクトを直接指定できる。便利。

  • 旧式の関数 glVertexPointer, glColorPointer に代え、現代的な glVertexAttribPointer を用いる。

    細かい説明は PyOpenGL プログラムに頻出する技法・注意点ctypes の説明を参照。

  • 以前メソッド do_render で呼び出していた glEnableClientState の代わるものとして、関数 glEnableVertexAttribArray を導入する。

書かずもがなだが、本コードでは VBO を動的に切り替えるようなことはしないため、 VertexAttrib 系関数の呼び出しを初期化メソッドで済ませている。仮に複数種類の VAO なり VBO なりを用意して、何か動的に切り替えるのであれば、バインド関数の呼び出しを伴った上で、VertexAttrib 系関数の呼び出しをメソッド do_render に配置するかもしれない。

メソッド init_texture

        """Initialize textures."""

        source_path = os.path.join(
            os.path.dirname(__file__), '../../_images/illvelo.png')
        img = Image.open(source_path).resize((256, 256))
        assert img.mode == 'RGBA'

        tex_id = GL.glGetUniformLocation(
            self.program_manager.program_id, b"texture_sampler")
        GL.glUniform1i(tex_id, 0)

        GL.glActiveTexture(GL.GL_TEXTURE0)
        self.texture = GL.glGenTextures(1)
        GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture)

        GL.glTexImage2D(
            GL.GL_TEXTURE_2D, 0, GL.GL_RGBA,
            img.size[0], img.size[1],
            0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, img.tobytes())

        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
        GL.glTexParameterf(
            GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)

前半部では、ファイルシステムにある PNG ファイルからテクスチャーパターンを生成している。これをフラグメントシェーダーの texture_sampler に送る。

後半部、関数 glTexImage2D 呼び出し以降は従来の OpenGL での手続き様式と同じである。

メソッド do_render

        """Render the object."""

        GL.glDrawElements(
            GL.GL_TRIANGLE_FAN, self.num_triangles + 2,
            GL.GL_UNSIGNED_BYTE, None)

上述のメソッドでがんばったおかげで、関数 glDrawElements の最後の引数は None で済むようになった。

メソッド cleanup, destroy_vbo

        """The clean up callback function."""

        super(ShaderDemoApp, self).cleanup()
        self.destroy_vbo()

        GL.glDeleteTextures(1, [self.texture])

    def destroy_vbo(self):
        """Clean up VAO and VBO."""

        if self.texture:
            GL.glDisableVertexAttribArray(2)

        GL.glDisableVertexAttribArray(1)
        GL.glDisableVertexAttribArray(0)

        GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
        GL.glDeleteBuffers(1, [self.buffer_id])

        GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
        GL.glDeleteBuffers(1, [self.index_buffer_id])

        GL.glBindVertexArray(0)
        GL.glDeleteVertexArrays(1, [self.vao_id])

  • 関数 glDisableVertexAttribArray により、VBO を効かなくする。

  • 関数 glBindBuffer0 を指定して VBO を効かなくしてから、関数 glDeleteBuffers により、VBO を片付ける。

  • 関数 glBindVertexArray0 を指定して VAO を効かなくしてから、関数 glDeleteVertexArrays を用いて VAO を片付ける。

  • 関数 glDeleteTextures でテクスチャーを片付ける。

一点注意。関数 glDeleteXXXs の第二引数は array-like を要求するので、困ったことに関数 glGenXXXs で「一個だけ」名前を生成したときには、ここに示したように利用したモノが一個だけでも、無理矢理それを含む array-like オブジェクトを作る必要がある。

関数 main

    """The main function."""

    app = ShaderDemoApp(
        window_title=b'Shader Demo',
        window_size=(300, 300),
        camera_eye=(2., 2., 2.),
        camera_center=(0., 0., 0.),
        camera_up=(0., 0., 1.))
    app.run(sys.argv)

このように各種パラメーターを指定して、クラス ShaderDemoApp のオブジェクトを生成、実行をする。

実行する

スクリプトを実行すると、いつものように描画ウィンドウが出現する。それに加えて Python 関数 print による情報がコンソールウィンドウに出力する。

表示されるグラフィックは次のようなものだ。マウスドラッグでズームや回転を試すこともできる。

初期状態
ズームと回転