Pygletによる「手抜き」OpenGL入門

総目次

9. モデルデータの読み込み

9.1 Wavefront.OBJファイルの読み込みとマテリアル表現

OpenGLは線分や三角形などの文字通りプリミティブな図形しか扱うことができないので、球やトーラスなどの基本的な物体でさえ、三角形に分割しつつ、頂点座標や法線ベクトルを計算しなければならなくなる。 数式で表現できる空間図形ならまだしも、複雑な物体のモデリングは専用のツールを使わないと事実上不可能である。 そこで、Blender等のツールを使って生成しファイルに保存した3次元データを読み込んで使う手段を Pygletは提供している。

用いることができるのは、3Dモデルのジオメトリを保存するためのテキストベースのフォーマットとして歴史のある Wavefront.objで、頂点の座標、テクスチャー座標、法線ベクトル、マテリアル情報などを受け渡すことができる。 ファイルの拡張子は、.objで、マテリアルやテクスチャー情報は、拡張子.mtlの別ファイルに保存される。 テクスチャーを用いる場合は、別途テクスチャー画像が必要である(テクスチャー画像へのパスがmtlファイルに含まれる)。

練習用に、Blenderを使って5種類の正多面体モデルを OBJ ファイルとして用意した。以下は、正12面体(dodecahedron)のOBJファイルを読み込んで表示するコードの例である。

関数 load_model_from_obj_file(filename, shader, batch, has_normal)でOBJファイルを読み込んでModelオブジェクトを生成している。 法線情報を含まないOBJファイルに対応するため、引数にhas_normal=Falseとすることで、法線を計算するようにしている。

実行するには、あらかじめ2つのファイル dodecahedron.objdodecahedron.mtl を コードと同じディレクトリのダウンロードしておくこと。

prog-9-1.py

正12多面体のOBJファイル
dodecahedron.obj
dodecahedron.mtl


###
### MyMaterialGroupの宣言のところまでは prog-8-2.pyと同じ
###
    
window = pyglet.window.Window(width=1280, height=720, resizable=True)
window.set_caption('Polyhedrons')

batch = pyglet.graphics.Batch()
vert_shader = Shader(vertex_source, 'vertex')
frag_shader = Shader(fragment_source, 'fragment')
shader = ShaderProgram(vert_shader, frag_shader)

time=0
def update(dt):
    global time
    time += dt

@window.event
def on_draw():
    window.clear()
    angle = time * 0.25
    rot_mat = Mat4.from_rotation(angle,Vec3(0,1,0)) @ Mat4.from_rotation(angle/2,Vec3(0,0,1)) @ Mat4.from_rotation(angle/3,Vec3(1,0,0))
    model.matrix = Mat4.from_translation(Vec3(0,0,0)) @ rot_mat
    shader['light_position']=Vec3(-10,20,20)
    shader['light_color']=Vec4(1.0, 1.0, 1.0, 1.0)
    batch.draw()

@window.event
def on_resize(width, height):
    window.viewport = (0, 0, width, height)
    window.projection = Mat4.perspective_projection(window.aspect_ratio, z_near=0.1, z_far=255, fov=60)
    return pyglet.event.EVENT_HANDLED

def setup():
    glClearColor(0.3, 0.3, 0.5, 1.0)
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_CULL_FACE)
    on_resize(*window.size)

def load_model_from_obj_file(filename, shader, batch, has_normal=False):
    file = open(filename,"rb")    
    mesh_list = pyglet.model.codecs.obj.parse_obj_file(filename=filename, file=file)
    vertex_lists=[ ]
    groups=[ ]
    for mesh in mesh_list:
        material = mesh.material
        count = len(mesh.vertices) // 3
        group = MyMaterialGroup(material=material, program=shader, order=0)
        normals=[ ]        
        if has_normal:
            normals[:] = mesh.normals 
        else:
            for k in range(0,count,3):
                dx0 = mesh.vertices[(k+1)*3+0] - mesh.vertices[k*3+0]
                dy0 = mesh.vertices[(k+1)*3+1] - mesh.vertices[k*3+1]
                dz0 = mesh.vertices[(k+1)*3+2] - mesh.vertices[k*3+2]
                dx1 = mesh.vertices[(k+2)*3+0] - mesh.vertices[k*3+0]
                dy1 = mesh.vertices[(k+2)*3+1] - mesh.vertices[k*3+1]
                dz1 = mesh.vertices[(k+2)*3+2] - mesh.vertices[k*3+2]
                nx = dy0*dz1 - dz0*dy1
                ny = dz0*dx1 - dx0*dz1
                nz = dx0*dy1 - dy0*dx1
                n = math.sqrt(nx*nx + ny*ny + nz*nz)
                if n>0:
                    nx /= n
                    ny /= n
                    nz /= n
                normals.extend([nx,ny,nz,nx,ny,nz,nx,ny,nz])
            
        vertex_list = shader.vertex_list(count, GL_TRIANGLES, batch=batch, group=group, \
                                         position=('f', mesh.vertices), normals = ('f', normals))
        vertex_list.diffuse_colors[:] = material.diffuse * count
        vertex_list.ambient_colors[:] = material.ambient * count
        vertex_list.specular_colors[:] = material.specular * count
        vertex_list.emission_colors[:] = material.emission * count
        vertex_list.shininess[:] = [material.shininess] * count
        vertex_lists.append(vertex_list)
        groups.append(group)
        
    return  pyglet.model.Model(vertex_lists=vertex_lists, groups=groups, batch=batch)

# OpenGLの初期設定
setup()

# 多面体データの読み込み
model = load_model_from_obj_file(filename="dodecahedron.obj", shader=shader, batch=batch)

# 視点を設定
window.view = Mat4.look_at(position=Vec3(0,0,5), target=Vec3(0,0,0), up=Vec3(0,1,0))

pyglet.clock.schedule_interval(update,1/60)
pyglet.app.run()

prog-9-1.pyの実行の様子

マテリアル情報を含まないOBJファイルへの対応

ネット上を探すと、3次元モデルとして、objファイル(頂点情報)のみが提供されている(mtlファイルが無い)ケースもあり、 そのようなファイルを上掲のコードで表示しようとすると、物体の表面は真っ白になって表示されるはずである (Pyglet内部で、デフォルトカラーを白色に設定しているため)。 そんたときは、テキストエディタを使って 何々.mtl というファイルを作成し、

newmtl Material

Ka 0.000000 0.000000 0.000000
Kd 0.500000 0.700001 0.900000
Ks 1.000000 1.000000 1.000000
Ke 0.000000 0.000000 0.000000
Ns 10
d 1.000000

のように書いて保存しておく。ここで、newmtlの行がマテリアルの名称、 Kaがambient, Kdがdiffuse, Ksがspecular、KeがemissionのRGB値を表す。 dは透過度(アルファ値)である。 また、Nsはspecularに対応する。

次に objファイルをテキストエディタで開いて、冒頭部分に

mtllib mtlのファイル名.mtl
usemtl Material

と追加しておけば、mtlファイルに書かれたマテリアルの属性が描画に反映されるはずだ。

objファイルのサイズが大きな場合、Linuxであればターミナルで

sed -i '1s/^/mtllib foo.mtl\nusemtl Material\n/' foo.obj 

macOSの場合はsedの仕様が少し違うようなので

sed -i "" '1s/^/mtllib foo.mtl\nusemtl Material\n/' foo.obj 

のようにする手もあるだろう。ここで、foo のところは適宜変更すること。

正4面体
tetrahedron.obj
tetrahedron.mtl

正6面体
hexahedron.obj
hexahedron.mtl

正8面体
octahedron.obj
octahedron.mtl

正12面体
dodecahedron.obj
dodecahedron.mtl

正20面体
icosahedron.obj
icosahedron.mtl

icon-pc 練習:プラトンの立体

prog-9-1.pyを修正して、下図のように、5つの正多面体を並べて表示してみなさい。

icon-hint ヒント

リンクをクリックしてOBJファイルとMTLファイルをダウンロードして使うこと。 各立方体は原点に配置され、辺の長さは1である。

icon-pc 練習:いろいろな多面体

全ての面が正多角形でできている凸多面体は、正多面体を含め、思いの外たくさんの種類が存在する。

こちらのページからOBJファイルがダウンロードできるので、 マテリアル情報を補足しつつ、そのうちのいくつかを表示してみなさい。


9.2 テクスチャーを持ったモデルの読み込み

OBJファイルにはテクスチャー座標を記述することができて、MTLファイルとテクスチャー画像(JPEGやPNG形式)と組み合わせることで、 モデルにテクスチャーを貼り付けることができる。ここでは、Pygletの機能を使って、球体の上に、惑星(木星)の表面画像を貼り付けてみよう。

球体の頂点座標、法線ベクトル、そしてテクスチャー画像をOBJファイル jupiter.obj として用意したので、それをまずダウンロードする。

つぎに、テクスチャーファイルの情報を含むMTLファイル jupiter.mtl も同じディレクトリに保存しておく。

最後に、木星の表面画像を Solar Texturesサイトからダウンロードする。 画像の一覧の中から、2Kの木星の画像(2k_jupiter.jpg)をダウンロードして、こちらも同じフォルダに保存する(ダウンロード)。

以上の準備ができたら、データファイルと同じディレクトリーで以下のコードを動かせば、木星のような球体が表示されるはずだ。 もしエラーが出るようなら、必要なファイルが正しい場所に置かれているかを確認すること。

prog-9-2.py

jupiter.obj
jupiter.mtl
2k_jupiter.jpg


import numpy as np
import math
import pyglet
from pyglet.gl import *
from pyglet.math import Mat4, Vec3, Vec4
    
window = pyglet.window.Window(width=1280, height=720, resizable=True)
window.set_caption('Jupiter')

batch = pyglet.graphics.Batch()

time=0
def update(dt):
    global time
    time += dt*0.3

@window.event
def on_draw():
    window.clear()
    rot_mat = Mat4.from_rotation(time,Vec3(0,1,0)) 
    model_jupiter.matrix = Mat4.from_translation(Vec3(0,0,0)) @ rot_mat
    batch.draw()

@window.event
def on_resize(width, height):
    window.viewport = (0, 0, width, height)
    window.projection = Mat4.perspective_projection(window.aspect_ratio, z_near=0.1, z_far=255, fov=60)
    return pyglet.event.EVENT_HANDLED

def setup():
    glClearColor(0.3, 0.3, 0.5, 1.0)
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_CULL_FACE)
    on_resize(*window.size)
    
# OpenGLの初期設定
setup()

# OBJファイルの読み込み
model_jupiter = pyglet.model.load("jupiter.obj", batch=batch)

# 視点を設定
window.view = Mat4.look_at(position=Vec3(0,0,3), target=Vec3(0,0,0), up=Vec3(0,1,0))

pyglet.clock.schedule_interval(update,1/60)
pyglet.app.run()

9.3 独自シェーダーを使ったテクスチャー表示

前節で示した方法でOBJファイルを読み込むと、Pyglet内部であらかじめ定義されたシェーダーが使われるので、質感や光源の特性等を細かくコントロールすることはできない。

そこで、少し長くなるが、独自にシェーダーを用意しOBJファイルの情報を使ってModelオブジェクトを生成するコードの例も示しておく。

prog-9-3.py

jupiter.obj
jupiter.mtl
2k_jupiter.jpg


import numpy as np
import math
import pyglet
from pyglet.gl import *
from pyglet.math import Mat4, Vec3, Vec4
from pyglet.graphics.shader import Shader, ShaderProgram

# シェーダー
# Vertex shader
vertex_source = """#version 330 core
    in vec3 position;
    in vec3 normals;
    in vec4 diffuse_colors;
    in vec4 ambient_colors;
    in vec4 specular_colors;
    in vec4 emission_colors;
    in float shininess;
    in vec2 tex_coords;

    out vec4 vertex_diffuse;
    out vec4 vertex_ambient;
    out vec4 vertex_specular;
    out vec4 vertex_emission;
    out float vertex_shininess;
    out vec3 vertex_normals;
    out vec3 vertex_position;
    out vec2 texture_coords;

    uniform WindowBlock
    {
        mat4 projection;
        mat4 view;
    } window;

    uniform mat4 model;

    void main()
    {
        mat4 modelview = window.view * model;
        vec4 pos = modelview * vec4(position, 1.0);
        gl_Position = window.projection * pos;
        mat3 normal_matrix = transpose(inverse(mat3(modelview)));
        vertex_position = pos.xyz;
        vertex_diffuse = diffuse_colors;
        vertex_ambient = ambient_colors;
        vertex_specular = specular_colors;
        vertex_emission = emission_colors;
        vertex_shininess = shininess;
        vertex_normals = normal_matrix * normals;
        texture_coords = tex_coords;
    }
"""

fragment_source = """#version 330 core
    in vec4 vertex_diffuse;
    in vec4 vertex_ambient;
    in vec4 vertex_specular;
    in vec4 vertex_emission;
    in float vertex_shininess;
    in vec3 vertex_normals;
    in vec3 vertex_position;
    in vec2 texture_coords; 
    out vec4 final_colors;

    uniform vec3 light_position;
    uniform vec4 light_color;

    uniform sampler2D texture_2d;

    void main()
    {
        vec3 normal = normalize(vertex_normals);
        vec3 light_dir = normalize(light_position - vertex_position);
        vec3 refrect_dir = normalize(-light_dir + 2 * dot(light_dir, normal) * normal);
        // vec3 refrect_dir = reflect(-light_dir, normal);
        vec3 view_dir = -normalize(vertex_position);
        float spec = pow(max(dot(view_dir, refrect_dir), 0.0), vertex_shininess);
        float diff = max(dot(normal, light_dir), 0.0);

        final_colors = vertex_ambient * light_color
                     + (texture(texture_2d, texture_coords)) * vertex_diffuse * diff
                     + vertex_specular * spec * light_color
                     + vertex_emission ;
    }
"""
    
window = pyglet.window.Window(width=1280, height=720, resizable=True)
window.set_caption('Polyhedrons')

batch = pyglet.graphics.Batch()
vert_shader = Shader(vertex_source, 'vertex')
frag_shader = Shader(fragment_source, 'fragment')
shader = ShaderProgram(vert_shader, frag_shader)

time=0
def update(dt):
    global time
    time += dt*0.3

@window.event
def on_draw():
    window.clear()
    rot_mat = Mat4.from_rotation(time,Vec3(0,1,0)) 
    model_jupiter.matrix = Mat4.from_translation(Vec3(0,0,0)) @ rot_mat
    shader['light_position']=Vec3(-20,0,30)
    shader['light_color']=Vec4(1.0, 1.0, 1.0, 1.0)
    batch.draw()

@window.event
def on_resize(width, height):
    window.viewport = (0, 0, width, height)
    window.projection = Mat4.perspective_projection(window.aspect_ratio, z_near=0.1, z_far=255, fov=60)
    return pyglet.event.EVENT_HANDLED

def setup():
    glClearColor(0.3, 0.3, 0.5, 1.0)
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_CULL_FACE)
    on_resize(*window.size)

def load_texed_model_from_obj_file(filename, shader, batch, has_normal=False):
    file = open(filename,"rb")    
    mesh_list = pyglet.model.codecs.obj.parse_obj_file(filename=filename, file=file)
    vertex_lists=[ ]
    groups=[ ]
    for mesh in mesh_list:
        material = mesh.material
        img = pyglet.image.load(material.texture_name)
        texture = img.get_texture()
        count = len(mesh.vertices) // 3
        group = pyglet.model.TexturedMaterialGroup(material=material, program=shader, texture=texture, order=0)
        normals=[ ]        
        if has_normal:
            normals[:] = mesh.normals
        else:
            for k in range(0,count,3):
                dx0 = mesh.vertices[(k+1)*3+0] - mesh.vertices[k*3+0]
                dy0 = mesh.vertices[(k+1)*3+1] - mesh.vertices[k*3+1]
                dz0 = mesh.vertices[(k+1)*3+2] - mesh.vertices[k*3+2]
                dx1 = mesh.vertices[(k+2)*3+0] - mesh.vertices[k*3+0]
                dy1 = mesh.vertices[(k+2)*3+1] - mesh.vertices[k*3+1]
                dz1 = mesh.vertices[(k+2)*3+2] - mesh.vertices[k*3+2]
                nx = dy0*dz1 - dz0*dy1
                ny = dz0*dx1 - dx0*dz1
                nz = dx0*dy1 - dy0*dx1
                n = math.sqrt(nx*nx + ny*ny + nz*nz)
                if n>0:
                    nx /= n
                    ny /= n
                    nz /= n
                normals.extend([nx,ny,nz,nx,ny,nz,nx,ny,nz])
                
        vertex_list = shader.vertex_list(count, GL_TRIANGLES, batch=batch, group=group, \
                                         position=('f', mesh.vertices), \
                                         normals = ('f', normals), \
                                         tex_coords = ('f', mesh.tex_coords) )
        vertex_list.diffuse_colors[:] = material.diffuse * count
        vertex_list.ambient_colors[:] = material.ambient * count
        vertex_list.specular_colors[:] = material.specular * count
        vertex_list.emission_colors[:] = material.emission * count
        vertex_list.shininess[:] = [material.shininess] * count
        vertex_lists.append(vertex_list)
        groups.append(group)
        
    return  pyglet.model.Model(vertex_lists=vertex_lists, groups=groups, batch=batch)

# OpenGLの初期設定
setup()

# マテリアルデータの読み込み
model_jupiter = load_texed_model_from_obj_file(filename="jupiter.obj", shader=shader, batch=batch, has_normal=True)

# 視点を設定
window.view = Mat4.look_at(position=Vec3(0,0,3), target=Vec3(0,0,0), up=Vec3(0,1,0))

pyglet.clock.schedule_interval(update,1/60)
pyglet.app.run()

prog-9-3.pyの実行画面のスナップショット

次のセクションへ

(このあたりから先はこれから)