Miyabiarts.net

一年に一度の更新頻度

OpenTKでキューブを表示

OpenTKの紹介をするだけなのもなんだったので、簡単なサンプルコードも載せることにしました。
OpenGLもバージョン3以降になると、glBegin/glEndやglVertex*といった固定機能が使えなくなり、シェーダやVBOを使ってレンダリングすることが当たり前になっているので、今回はそちらに合わせるようにしています。

コードの方は、githubに置くようにしました。
リポジトリ:https://github.com/miyabiarts/CubeView

準備

最初は、ソリューションを作成した後に参照として、「OpenTK」「OpenTK.GLControl」を加えます。
これでOpenTKの機能、およびOpenGLのレンダリング領域を使う準備ができました。

GLControlの配置

OpenTKにおけるOpenGLのレンダリング領域は、ユーザコントロールGLControlを親ウィンドウ上に配置すれば良いです。
上の図が配置した結果で、GLControlのDockプロパティをFillにすることで、親ウィンドウのサイズでレンダリング出来るようにします。

メインループ

定期的にレンダリングするために、元のProgram.cs内のMain関数を以下のように書き換えます。

static void Main()
{
  Application.EnableVisualStyles();
  Application.SetCompatibleTextRenderingDefault(false);

  MainForm form = new MainForm();
  form.Show();
  while (form.Created)
  {
    form.Render();
    Application.DoEvents();
}

MainFormはGLControlを貼りつけたフォームで、定期的にレンダリングするためのメソッドRenderを定義しておきます。
他にもタイマーを使った呼び出し方などがありますが、一般的に良く使われる方法を使いました。

シェーダの読み込み

最近のOpenGLでは固定機能を使わずに、シェーダを使ってレンダリングするのが必須になり始めています。
そのため、自分でシェーダを用意し、レンダリングする前に準備する必要があります。
今回は、簡単なPhongシェーダで、拡散反射のみを扱います。
シェーダは、頂点シェーダとフラグメントシェーダのみを扱い、リソースとしてソリューションに含んでおきます。
なお、ジオメトリシェーダについては今回触れません。
各シェーダを以下に示します。

頂点シェーダ(phong.vert)

attribute vec3 position;
attribute vec3 normal;
uniform mat4 world;
uniform mat4 viewProjection;
uniform vec3 lightDir;
varying vec4 diffuseColor;
void main(void)
{
	gl_Position = viewProjection * world * vec4(position, 1.0);
	diffuseColor = vec4(max(0,dot(mat3(world) * normal,-lightDir)));
	diffuseColor.a = 1.0;
}

フラグメントシェーダ(phong.frag)

varying vec4 diffuseColor;
void main(void)
{
	gl_FragColor = diffuseColor;
}

シェーダについての詳細については、今回は説明を省きます。
シェーダのバージョンによっては、上のシェーダがそのまま動かない場合もありますので、その場合は適宜変更してください。
また、VisualStudioでシェーダを作成した場合は、BOM付きUTF-8のテキストファイルになるのですが、そのまま読み込むとBOMでエラーを起こすので、必ずBOM無しUTF-8などで保存してください。

以上の頂点シェーダ・フラグメントシェーダを読み込んで、int型で表されるprogramで管理します。
テストコード内では、CreateShaderでリソースから読み込んだ頂点シェーダ・フラグメントシェーダからprogramを作成しています。

int CreateShader(string vertexShaderCode, string fragmentShaderCode)
{
  int vshader = GL.CreateShader(ShaderType.VertexShader);
  int fshader = GL.CreateShader(ShaderType.FragmentShader);

  string info;
  int status_code;

  // Vertex shader
  GL.ShaderSource(vshader, vertexShaderCode);
  GL.CompileShader(vshader);
  GL.GetShaderInfoLog(vshader, out info);
  GL.GetShader(vshader, ShaderParameter.CompileStatus, out status_code);
  if (status_code != 1)
  {
    throw new ApplicationException(info);
  }

   // Fragment shader
  GL.ShaderSource(fshader, fragmentShaderCode);
  GL.CompileShader(fshader);
  GL.GetShaderInfoLog(fshader, out info);
  GL.GetShader(fshader, ShaderParameter.CompileStatus, out status_code);
  if (status_code != 1)
  {
    throw new ApplicationException(info);
  }

  int program = GL.CreateProgram();
  GL.AttachShader(program, vshader);
  GL.AttachShader(program, fshader);

  GL.LinkProgram(program);

  return program;
}

やっていることは、頂点シェーダ・フラグメントシェーダの順に文字列をコンパイルしてから、programに割り当てています。
シェーダにエラーが発見されれば、例外が飛びます。

キューブの作成・レンダリング

以前は、glBegin/glEnd/glVertex*あたりを使えば、とりあえず簡単にオブジェクトをレンダリングできたのですが、最近のOpenGLではVBOを作る必要があり、結構面倒くさいです。
面倒くさいですが、これからそうなっていくので、あまり嫌がらずやり方を覚えていきましょう。
今回はキューブを表示するため、Cubeクラスを定義します。

頂点形式

はじめに、頂点、および頂点インデックスから頂点バッファを作成します。
このとき、頂点の形式は用いるシェーダと同じものである必要があります。
このあたりは、DirectX(10以降)でもだいたい同じです。
今回用いるシェーダでは、頂点ではその位置と法線を用いていますため、Cubeクラス内で以下のようなVertex構造体を定義しておきます。

struct Vertex
{
  public Vector3 position;
  public Vector3 normal;

  public Vertex(Vector3 position, Vector3 normal)
  {
    this.position = position;
    this.normal = normal;
  }
  public static readonly int Stride = Marshal.SizeOf(default(Vertex));
}

データ定義

キューブの頂点、および頂点インデックスはコード内に静的に埋め込んでいます。
今回は1つの頂点につき1つの法線を割り当てるようにし、各頂点に接する面の法線の平均を取った形になります。
下のコードでは、方向を与えた後に大きさを正規化しています。
頂点インデックスは、この頂点へのインデックスの集合で、三角面リストとして与えています。

public Cube(int program)
{
  Vertex[] v = new Vertex[]{
      new Vertex(new Vector3(-1.0f, 1.0f,-1.0f), new Vector3(-1.0f, 1.0f,-1.0f)),
      new Vertex(new Vector3( 1.0f, 1.0f,-1.0f), new Vector3( 1.0f, 1.0f,-1.0f)),
      new Vertex(new Vector3( 1.0f, 1.0f, 1.0f), new Vector3( 1.0f, 1.0f, 1.0f)),
      new Vertex(new Vector3(-1.0f, 1.0f, 1.0f), new Vector3(-1.0f, 1.0f, 1.0f)),
      new Vertex(new Vector3(-1.0f,-1.0f,-1.0f), new Vector3(-1.0f,-1.0f,-1.0f)),
      new Vertex(new Vector3( 1.0f,-1.0f,-1.0f), new Vector3( 1.0f,-1.0f,-1.0f)),
      new Vertex(new Vector3( 1.0f,-1.0f, 1.0f), new Vector3( 1.0f,-1.0f, 1.0f)),
      new Vertex(new Vector3(-1.0f,-1.0f, 1.0f), new Vector3(-1.0f,-1.0f, 1.0f)),
  };
  foreach(Vertex vv in v )
  {
    vv.normal.Normalize();
  }

  uint[] indices = new uint[]{
      0,1,2,
      0,2,3,
      1,0,4,
      1,4,5,
      2,1,5,
      2,5,6,
      3,2,6,
      3,6,7,
      0,3,7,
      0,7,4,
      6,5,4,
      7,6,4
  };

頂点バッファの作成

以上のデータからVBO(頂点バッファ)、EBO(頂点インデックスバッファ)を作ります。
作り方は簡単で、GL.GenBuffersでハンドラを作成し、BindBufferで操作対象とし、GL.BufferDataで上のデータを書きこむだけです。

  // VBO作成
  GL.GenBuffers(1, out vbo);
  GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);
  GL.BufferData<Vertex>(BufferTarget.ArrayBuffer, new IntPtr(v.Length * Vertex.Stride), v, BufferUsageHint.StaticDraw);

  // EBO作成
  GL.GenBuffers(1, out ebo);
  GL.BindBuffer(BufferTarget.ElementArrayBuffer, ebo);
  GL.BufferData<uint>(BufferTarget.ElementArrayBuffer, new IntPtr(sizeof(uint) * indices.Length), indices, BufferUsageHint.StaticDraw);
  vertexCount = indices.Length;

シェーダとの関連付け

レンダリングするためのデータは準備できましたが、まだ頂点のどの要素がシェーダのどの変数に対応するかが分からないため、レンダリングができない状態です。
そのため、VAO(Vertex Array Object)を定義してやり、シェーダとの頂点のやりとりをできるようにしてやります。
まずはVAOのハンドラを作成し、有効にします。
また、関連付けるVBOも有効にします。
次に、シェーダから関連付ける頂点要素のシェーダ内におけるインデックスをGL.GetAttribLocationで取得します。
この時取得するのは、シェーダ内でattributeで定義されている変数で、取得にはその変数名を用います。
続いて、取得し頂点要素を有効にします。
最後に各要素がVBOをどのように参照すれば良いかをGL.VertexAttribPointerで設定します。
GL.VertexAttribPointerでは、頂点要素のインデックス・頂点要素を構成する型(floatなど)の数・頂点要素の型・頂点要素を正規化するか・同じ頂点要素間のバイト数・頂点要素のバイト数をパラメータとして与えます。
頂点要素の正規化は、法線や色の場合にtrueにします。
また、同じ頂点要素間のバイト数は頂点全体のバイト数を与えれば良く、頂点要素のバイト数は頂点が定義されている順番にしたがって与えてやる必要があります。
このあたりは、頂点が定義される順番を把握していなければならないため、多少煩雑に感じるかもしれません。

  // VAO作成
  GL.GenVertexArrays(1, out vao);
  GL.BindVertexArray(vao);

  GL.BindBuffer(BufferTarget.ArrayBuffer, vbo);

  // シェーダ内の頂点要素のインデックスを取得
  int positionLocation = GL.GetAttribLocation(program, "position");
  int normalLocation = GL.GetAttribLocation(program, "normal");

  // 頂点要素を有効にする
  GL.EnableVertexAttribArray(positionLocation);
  GL.EnableVertexAttribArray(normalLocation);

  // 頂点要素のサイズ・オフセットを設定
  GL.VertexAttribPointer(positionLocation, 3, VertexAttribPointerType.Float, false, Vertex.Stride, 0);
  GL.VertexAttribPointer(normalLocation, 3, VertexAttribPointerType.Float, true, Vertex.Stride, Vector3.SizeInBytes);
}

レンダリング

レンダリングは非常に簡単で、作成したVBO・EBOを設定し、GL.DrawElementsで呼び出すだけです。
GL.DrawElementsでは、レンダリングするプリミティブの数ではなく、含まれる全頂点数を与えてやる必要があるため、頂点バッファを作成する段階で頂点インデックスに含まれる頂点数を事前に保存しておくと楽です。

public void Render()
{
  GL.BindVertexArray(vao);
  GL.BindBuffer(BufferTarget.ElementArrayBuffer, ebo);
  GL.DrawElements(BeginMode.Triangles, vertexCount, DrawElementsType.UnsignedInt, 0);
}

シェーダへのパラメータ設定・レンダリング

今回は、GLControlをviewportという変数で用いています。
OpenTKのOpenGLでは、GLControlのMakeCurrentでレンダリング対象を設定し、レンダリングを行った最後にSwapBuffersを呼び出すことで画面へ出力します。
ここでは、シェーダで定義したパラメータに対して、実際の値を設定することで、レンダリングを行います。

下記のコードで、いくつかはOpenGLで良く使うものなので、シェーダまわりの説明をします。
まず、GL.UseProgramで使用したいシェーダを指定します。
シェーダは既に作成したprogramによって与えます。
今回シェーダが必要としているパラメータは、uniform変数で定義されたビュー行列・プロジェクション行列(をかけ合わせた行列)、ワールド行列、ライトの方向となります。
ビュー行列・プロジェクション行列は、OpenTKが持つ数学ライブラリを用いることによって簡単に求めることができます。
また、ワールド行列は単位行列にしています。(全くもって意味ないパラメータです。)
ライトの方向に関しては、動きがないと良く分からないのでキューブの中心に向かって回転するようにしました。

シェーダ内のuniform変数へのアクセスは、その型に応じてGL.Uniform*で与えてやります。
行列ならばGL.UniformMatrix4、3次元ベクトルならばGL.Uniform3といった感じです。
これらの第1引数として、対応するuniform変数のシェーダ内のインデックスが必要となりますので、GL.GetUniformLocationに変数名を指定することで取得できます。
今回は、Renderが呼び出されるたびに取得するようにしていますが、シェーダごとにHogeShaderクラスを作ってラッパしたほうがコードが綺麗になると思います。

全てのシェーダパラメータを設定したら、キューブのレンダリングを行いましょう。

public void Render()
{
  // ビューポート(GLControl)を有効にする
  viewport.MakeCurrent();

  // 各種設定
  GL.Enable(EnableCap.DepthTest);
  GL.Enable(EnableCap.CullFace);
  GL.FrontFace(FrontFaceDirection.Cw);
  GL.CullFace(CullFaceMode.Back);

  // レンダリング領域をクリア
  GL.Viewport(0, 0, viewport.Width, viewport.Height);
  GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

  // シェーダを設定
  GL.UseProgram(program);

  // ビュー・プロジェクション行列を設定
  Vector3 eyePos = new Vector3(5.0f, 5.0f, 5.0f);
  Vector3 lookAt = new Vector3(0.0f, 0.0f, 0.0f);
  Vector3 eyeUp = new Vector3(0.0f, 1.0f, 0.0f);
  Matrix4 viewMatrix = Matrix4.LookAt(eyePos, lookAt, eyeUp);
  Matrix4 projectionMatrix = Matrix4.CreatePerspectiveFieldOfView((float)System.Math.PI / 4.0f, (float)viewport.Width / (float)viewport.Height, 0.1f, 10.0f);
  Matrix4 viewProjectionMatrix = viewMatrix * projectionMatrix;
  GL.UniformMatrix4(GL.GetUniformLocation(program, "viewProjection"), false, ref viewProjectionMatrix);

  // ワールド行列を設定
  Matrix4 worldMatrix = Matrix4.Identity;
  GL.UniformMatrix4(GL.GetUniformLocation(program, "world"), false, ref worldMatrix);

  // ライトを設定
  Vector3 lightDir = new Vector3((float)Math.Cos((float)lightAngle), -1.0f, (float)Math.Sin((float)lightAngle));
  lightDir.Normalize();
  GL.Uniform3(GL.GetUniformLocation(program, "lightDir"), lightDir);
  lightAngle += 0.001f;

  // Cubeをレンダリング
  cube.Render();

  // バッファをスワップして画面表示
  viewport.SwapBuffers();
}

まとめ

実際コーディングしてみるとさほど難しいというわけではないですが、まとまった分かりやすい資料があまりないため、難しく感じてしまうかもしれません。
特に固定機能の廃止は、とりあえず絵を出してみたい人にとっては、余計に小難しくしている感があります。
とりあえず最初は写経でも良いので、絵が出るところまで頑張ってみることをお勧めします。
そのための助けになるならば幸いです。

ちなみにですが、オブジェクトの削除関連を思いっきりサボっています。すみません。

広告

Comments are closed.

%d人のブロガーが「いいね」をつけました。