OpenGLによるCOLLADAなはちゅねの描画

前回の記事では、モダンなOpenGLでティーポットを描画するための基礎知識について整理し、ティーポットが描画できたよ、ということまで書きました。

実は、前回のティーポットの描画では、ジオメトリ部分だけを読み込んで描画しています。シンプルなモデルではこれでも上手くいくことがあるのですが、複雑なモデルでは、ちゃんとCOLLADAを読み解かないと正しく描画できません。

ということで、今回は、ちゃんとモデルの構造を考えて描画してみます。

目標

前回はティーポットの描画を目標にしていましたが、今回は「はちゅね」さんを召喚したいと思います。はちゅねモデルはズザ氏が作成し、rect氏がCOLLADAに変換したものを利用します。以下からダウンロードできます。

このはちゅねモデルですが、ファイルの中身を見てると、ジオメトリが複数があることが分かります。何も考えずにこれらのジオメトリを描画するのは、残念ながらうまくいきません。また、COLLADAには色などの材質(マテリアル)情報も含まれているので、これもちゃんと反映させたい所です。

COLLADAの主要部分

ここでは、COLLADAを描画するのに最低限抑えとけば良さそうな部分だけを取り上げます。以下のリストの階層はCOLLADAのXMLの階層とは関係ありません。

  • scene:描画要素を包括する要素です。
    • node:モデルの一部分を表す要素です。子ノードをもち、ネストすることもあります。また、モデルの位置情報(モデル変換行列)をもちます。この行列をちゃんと適用しないと正しい位置に描画することが出来ません。
    • geometry node:ノードの一種で、恐らく末端のノードです。ジオメトリ情報とマテリアル情報を持ちます*1
  • geometry:頂点属性群やインデックスなどのデータを持つ要素です。
    • source:頂点属性の生のデータを持つ要素です。
    • triangles:ポリゴンの集合を表す要素で、インデックス情報を持ちます。ここのインデックスを元にsourceのデータを展開できます*2
  • material:マテリアル情報を持ちます。よく分かりませんが、材質に関する具体的な情報は、material要素内で参照されているeffect要素に入ってます。
  • effect:シェーディング関係の情報が入ってます。色情報やテクスチャもシェーダーに渡すべき情報なので、ここに含まれるようです。ライティングの手法*3まで指定できるようです。
  • asset:モデルの作者やコメントなど、一般的な情報が含まれる要素です。モデルの上方向を表すup_axisや、作者が想定している大きさを表すunitなんかは使えるケースが有るかもしれません。

描画方法

COLLADAの構造を踏まえると、すごく大雑把ですが、以下のような手順で処理を行えば描画できそうです。

1. 描画時に毎回行うのは無駄なので、ジオメトリ情報を前処理する。サクッとVBOを作ってしまえば良いかと思います。
2. 描画の手始めとして、scene要素を見る。sceneが持つノードを次に参照する。
3. ノードを辿る。辿る度に変形情報があればモデル行列に適用していく。pushとpopを忘れずに。
4. ジオメトリノードを見つけたら、geometryとmaterialを特定できるので、materialを適宜シェーダに送り、geometryをglDrawArraysする。

こんな感じで流れは見えるのですが、正直、コードに落としこむのは簡単ではないです。

参考になるかはわかりませんが、ライブラリっぽくしたので、コードをまるっと載せておきます。例のごとく、Python2.7でpycolladaでCOLLADAを読み込んでいます。mgluというのはまだ公開していないオレオレライブラリです。説明はしませんが、意味は感じとれるかと思います。

#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
import numpy as np
import collada
import mglu


class Collada(object):

    def __init__(self, file):
        self.mesh = collada.Collada(file)
    
    UP_AXIS = collada.asset.UP_AXIS
    
    @property
    def up(self):
        return self.mesh.assetInfo.upaxis
    
    @property
    def scenes(self):
        return self.mesh.scenes
    
    @property
    def geometries(self):
        return self.mesh.geometries
    
    @classmethod
    def extract(cls, vertices, indices):
        """
        :params vertices: 頂点属性の配列
        :type vertices: 2次元np.array
        :params indices: インデックス
        :type indices: 1次元np.array
        """
        result = [None] * indices.size
        for i in xrange(indices.size):
            result[i] = vertices[indices[i]]
        assert not None in result
        return np.float32(result)

    def load_geometry(self, geometry):
        buffers = []
        buf = {}
        for p in geometry.primitives:
            if p.normal is None:
                continue
            pos = self.extract(p.vertex, p.vertex_index.flatten())
            buf["position"] = mglu.VBO(pos)
            nor = self.extract(p.normal, p.normal_index.flatten())
            buf["normal"] = mglu.VBO(nor)
            buf["texcoord"] = []
            for i in range(len(p.texcoordset)):
                tex = self.extract(
                    p.texcoordset[i], p.texcoord_indexset[i].flatten())
                buf["texcoord"].append(mglu.VBO(tex))
            buffers.append(buf)
        return buffers
    

class ColladaRenderer(object):

    def render_node(self, node):
        self.node_begin(node.matrix)
        for child in node.children:
            # 普通のノードなら辿る
            if type(child) is collada.scene.Node:
                for n in node.children:
                    self.render_node(n)
                break
            # そうでなければ描画
            geometry_node = child
            for material_node in geometry_node.materials:
                material = material_node.target
                effect = material.effect
                color_attributes = \
                    ["specular", "ambient", "diffuse", "emission"]
                for attr in color_attributes:
                    color = effect.__getattribute__(attr)
                    if color is None:
                        break
                    elif type(color) is tuple:
                        renderer.__getattribute__("set_" + attr)(color)
                    else:
                        image = color.sampler.surface.image.pilimage
                        renderer.set_texture(image)
                if effect.shininess:
                    renderer.set_shininess(effect.shininess)
                # effect.reflective, effect.reflectivity
                # effect.transparent, effect.transparency
            self.render_geometry(geometry_node.geometry)
        self.node_end()
    
    def render_scene(self, scene):
        """オーバーライド不可
        """
        for node in scene.nodes:
            self.render_node(node)
    
    #-----------------------------
    
    def node_begin(m_matrix):
        pass
    
    def node_end():
        pass
    
    def set_ambient(self, color):
        pass
    
    def set_specular(self, color):
        pass
    
    def set_diffuse(self, color):
        pass
    
    def set_emission(self, color):
        pass
    
    def set_texture(self, pilimage):
        pass
    
    def set_shininess(self, shininess):
        pass
    
    def render_geometry(self, geometry):
        pass    

テンプレートメソッドパターン風にしました。set_*でシェーダーに値を渡し、render_geometryに描画コードを書きます。もっとマテリアルのセット系は増えると思います。また、マテリアル部分はかなり適当なので、間違ってる可能性大です。

ついでなので、殴り書きな使用例も載せちゃいます。mgluに関しては考えるより感じて下さい。色部分はまだバグ持ちなので、render_geometry内で適当にセットしています。

#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
import sys
import numpy as np
import pyglet
from pyglet.gl import *
import mglu
import mglu.mesh


class Camera(object):
    
    def __init__(self, position=[0, 0, 1], target=[0, 0, 0], up=[0, 1, 0],
            distance=10):
        self.position = np.float32(position)
        self.target = np.float32(target)
        self.up = np.float32(up)
        self.distance = distance
    
    def get_look_at_params(self):
        v = self.position * self.distance
        return np.concatenate((v, self.target, self.up))
        
    def get_direction(self):
        v = self.position - self.target
        return v / np.linalg.norm(v)
    

class HachuneRenderer(mglu.mesh.ColladaRenderer):
    
    def __init__(self):
        self.collada = mglu.mesh.Collada("mglu/data/negimiku/negimiku.dae")
        self.geometries = {}
        for g in self.collada.geometries:
            self.geometries[g.id] = self.collada.load_geometry(g)

    def prepare(self, shader, mvp):
        self.mvp = mvp
        self.shader = shader

    def render(self):
        self.mvp.m_push()
        self.mvp.m_scale(0.1, 0.1, 0.1)
        scene = self.collada.scenes[0]
        self.render_scene(scene)
        self.mvp.m_pop()

    def node_begin(self, m_matrix):
        self.mvp.m_push()
        self.mvp.m_matrix *= m_matrix
            
    def node_end(self):
        self.mvp.m_pop()

    def set_ambient(self, color):
        self.shader.uniform_nfv("u_ambientColor", np.float32(color))
    
    def set_specular(self, color):
        self.shader.uniform_nfv("u_specularColor", np.float32(color))
    
    def set_diffuse(self, color):
        self.shader.uniform_nfv("u_diffuseColor", np.float32(color))
    
    def set_emission(self, color):
        pass
    
    def set_texture(self, pilimage):
        pass
    
    def set_shininess(self, shininess):
        self.shader.uniform_1f("u_shininess", shininess)

    def render_geometry(self, geometry):
        # 色
        self.shader.uniform_1f("u_alpha", 1.0)
        self.set_ambient(np.float32([1, 1, 1]))
        self.set_specular(np.float32([0.5, 0.5, 0.5]))
        self.set_diffuse(np.float32([0.8, 0.8, 0.8]))
        self.set_shininess(100)
        # 変換行列
        self.shader.uniform_matrix("u_vMatrix", self.mvp.get_v())
        self.shader.uniform_matrix("u_mvMatrix", self.mvp.get_mv())
        self.shader.uniform_matrix("u_mvpMatrix", self.mvp.get_mvp())
        self.shader.uniform_matrix("u_normalMatrix",
            np.linalg.inv(self.mvp.get_mv()).T)
        # テクスチャ
        #glActiveTexture(GL_TEXTURE0)
        #self.teapot["texture"].bind()
        #self.shader.uniform_1i("u_texture", 0)
        self.shader.uniform_1i("u_useTexture", False)
        
        for buf in self.geometries[geometry.id]:
            self.shader.attribute("a_position", buf["position"])
            self.shader.attribute("a_normal", buf["normal"])
            self.shader.attribute("a_texcoord", buf["texcoord"][0])
            glDrawArrays(GL_TRIANGLES, 0, buf["position"].get_elements_num())


class App(pyglet.window.Window):
    
    VERTEX_SHADER = """\
#version 120
attribute vec3 a_position;
attribute vec3 a_normal;
attribute vec2 a_texcoord;
uniform mat4 u_vMatrix;
uniform mat4 u_mvMatrix;
uniform mat4 u_mvpMatrix;
uniform mat4 u_normalMatrix;
uniform vec3 u_lightPosition;
varying vec3 v_position;
varying vec3 v_normal;
varying vec3 v_lightPosition;
varying vec2 v_texcoord;

void main(void) {
    v_texcoord = a_texcoord;
    v_lightPosition = (u_vMatrix * vec4(u_lightPosition, 1.0)).xyz;
    v_position = (u_mvMatrix * vec4(a_position, 1.0)).xyz;
    v_normal = (u_normalMatrix * vec4(a_normal, 1.0)).xyz;
    gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
}
    """

    FRAGMENT_SHADER = """\
#version 120
// texture
uniform bool u_useTexture;
uniform sampler2D u_texture;
// material
uniform float u_alpha;
uniform vec3 u_diffuseColor;
uniform vec3 u_specularColor;
uniform vec3 u_ambientColor;
uniform float u_shininess;
// light
uniform vec3 u_lightAmbientColor;
uniform vec3 u_lightDiffuseColor;
uniform vec3 u_lightSpecularColor;
uniform vec3 u_eyeDirection;
// varying
varying vec3 v_position;
varying vec3 v_normal;
varying vec3 v_lightPosition;
varying vec2 v_texcoord;

void main(void) {
    vec3 normal = normalize(v_normal);
    vec3 lightDirection = normalize(v_lightPosition - v_position);
    float diffuse = clamp(dot(normal, lightDirection), 0.0, 1.0);
    vec3 halfDirection = normalize(lightDirection + u_eyeDirection);
    float specular = pow(
        clamp(dot(normal, halfDirection), 0.0001, 1.0),
        u_shininess
    );
    
    vec3 color = vec3(1.0);
    float alpha = u_alpha;
    
    if (u_useTexture) {
        vec4 texcolor = texture2D(u_texture, v_texcoord);
        color *= texcolor.rgb;
        alpha *= texcolor.a;
    }
    
    color *= u_lightAmbientColor * u_ambientColor
        + u_lightDiffuseColor * u_diffuseColor * diffuse
        + u_lightSpecularColor * u_specularColor * specular;
    
    gl_FragColor = vec4(color, alpha);
}
    """
    
    def __init__(self):
        super(self.__class__, self).__init__(
            resizable=True, visible=False)
        
        glEnable(GL_DEPTH_TEST)
        glDepthFunc(GL_LEQUAL)
        glClearColor(1.0, 1.0, 1.0, 1.0)
        glClearDepth(1.0)
        
        self.shader = mglu.Shader(self.VERTEX_SHADER, self.FRAGMENT_SHADER)
        self.mvp = mglu.ModelViewProjection()
        self.camera = Camera(distance=5)
        self.world = mglu.World()
        self.use_texture_flag = True
        
        self.init_resources()
        
        self.FPS = 15
        self.frame_count = 0
        #pyglet.clock.schedule_interval(self.__update, 1.0 / self.FPS)
        self.set_visible()
    
    def __update(self, dt):
        self.frame_count += 1
        self.update(dt)
    
    def on_resize(self, width, height):
        glViewport(0, 0, width, height)
        self.mvp.p_perspective(45, float(width)/height, 0.01, 1000)
    
    def on_key_press(self, symbol, modifiers):
        key = pyglet.window.key
        # 終了
        if symbol == key.ESCAPE:
            pyglet.app.exit()
        # 視点
        factor = 0.5
        dx = dy = 0
        if symbol == key.UP: dy = 1
        if symbol == key.DOWN: dy = -1
        if symbol == key.RIGHT: dx = 1
        if symbol == key.LEFT: dx = -1
        self.camera.target[0] += dx * factor
        self.camera.target[1] += dy * factor
        # フラグ
        if symbol == key.A:
            self.use_texture_flag = not self.use_texture_flag
    
    def on_mouse_press(self, x, y, button, modifiers):
        if button & pyglet.window.mouse.LEFT:
            self.world.drag_begin(x, y)
        
    def on_mouse_release(self, x, y, button, modifiers):
        if button & pyglet.window.mouse.LEFT:
            self.world.drag_end()
    
    def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
        if buttons & pyglet.window.mouse.LEFT:
            self.world.drag(x, y)
    
    def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
        self.camera.distance = max(self.camera.distance - scroll_y, 0.1)
    
    def init_resources(self):
        self.hachune_renderer = HachuneRenderer()
        
    def on_draw(self):
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        self.mvp.v_identity()
        self.mvp.v_look_at(*self.camera.get_look_at_params())
        self.mvp.m_identity()
        self.mvp.m_matrix *= self.world.matrix
        
        self.shader.use()

        # ライティング
        self.shader.uniform_nfv("u_lightPosition", np.float32([10, 10, 10]))
        self.shader.uniform_nfv("u_lightDiffuseColor",
            np.float32([1, 1, 1]))
        self.shader.uniform_nfv("u_lightSpecularColor",
            np.float32([1, 1, 1]))
        self.shader.uniform_nfv("u_lightAmbientColor",
            np.float32([0.2, 0.2, 0.2]))
        self.shader.uniform_nfv("u_eyeDirection", self.camera.get_direction())

        # ティーポットの描画
        self.hachune_renderer.prepare(self.shader, self.mvp)
        self.hachune_renderer.render()
        
        self.shader.stop()

    def update(self, dt):
        pass
        
        
if __name__ == "__main__":
    App()
    pyglet.app.run()
    sys.exit(0)

現状、すごくポリゴンなはちゅねが表示できています。

f:id:s-shin:20130927021350p:plain

とりあえず今回はここまでとします。次は、色とテクスチャを何とかしたい所です。

*1:正確にはgeometry要素とmaterial要素への参照を持っている

*2:なぜ展開するかは前回の記事を参照

*3:lambertやphongなど