[MT97] (Fast, minimum storage ray-triangle intersection) → 安直レイトレーシング入門に反映すること。この論文はレイトレーシングやってる人には基本なんだろうな。
\({\bf O}\) を起点として \({\bf D}\) の方向に向かう視線上の点 \({\bf R}\left(t\right)\) は、以下のように定義されます。
\[ {\bf R}\left(t\right)={\bf O}+t{\bf D} \]
一方、3点 \({\bf V}_0\)、\({\bf V}_1\)、\({\bf V}_2\) を頂点とする三角形上の点 \({\bf S}\left(u, v\right)\) は、以下のように定義されます。
\[ {\bf S}\left(u, v\right)=\left(1-u-v\right){\bf V}_0+u{\bf V}_1+v{\bf V}_2 \]
この \(\left(u, v\right)\) はこの三角形上の重心座標で、これがこの三角形の内部にあるなら \(u \geq 0\)、\(v \geq 0\)、かつ \(u+v \leq 1\) すなわち \(1-u-v \geq 0\) を満たします。なお、この \(\left(u, v\right)\) は頂点色や頂点法線ベクトル、テクスチャ座標などの頂点属性の補間に用いることができます。
視線 \({\bf R}\left(t\right)\) がこの三角形と交差するかどうかを調べるには、\({\bf R}\left(t\right)={\bf S}\left(u, v\right)\) を解きます。
\[ {\bf O}+t{\bf D}=\left(1-u-v\right){\bf V}_0+u{\bf V}_1+v{\bf V}_2 \]
これを次のように行列とベクトルの積の形に変形します。
\[ \begin{pmatrix}-{\bf D}&{\bf V}_1-{\bf V}_0&{\bf V}_2-{\bf V}_0\end{pmatrix}\begin{pmatrix}t\\u\\v\end{pmatrix}={\bf O}-{\bf V}_0 \]
この \({\bf V}_1-{\bf V}_0={\bf E}_1\)、\({\bf V}_2-{\bf V}_0={\bf E}_2\)、\({\bf O}-{\bf V}_0={\bf T}\) とおき、クラメールの公式*1を用いて上式を解きます。
\[ \begin{pmatrix} t\\ u\\ v \end{pmatrix} =\frac{1}{\begin{vmatrix}-{\bf D}&{\bf E_1}&{\bf E}_2\end{vmatrix}} \begin{pmatrix} \begin{vmatrix}{\bf T}&{\bf E}_1&{\bf E}_2\end{vmatrix}\\ \begin{vmatrix}-{\bf D}&{\bf T}&{\bf E}_2\end{vmatrix}\\ \begin{vmatrix}-{\bf D}&{\bf E}_1&{\bf T}\end{vmatrix} \end{pmatrix} \]
\(t\) は視線 \({\bf R}\left(t\right)\) のパラメータですから、これより交点の位置を求めることができます。またスカラー三重積より \(\begin{vmatrix}{\bf A}&{\bf B}&{\bf C}\end{vmatrix}=-\left({\bf A}\times{\bf C}\right)\cdot{\bf B}=-\left({\bf C}\times{\bf B}\right)\cdot{\bf A}\) なので、上式は次のように書き換ええることができます。
\[ \begin{pmatrix} t\\ u\\ v \end{pmatrix} =\frac{1}{\left({\bf D}\times{\bf E}_2\right)\cdot{\bf E}_1} \begin{pmatrix} \left({\bf T}\times{\bf E}_1\right)\cdot{\bf E}_2\\ \left({\bf D}\times{\bf E}_2\right)\cdot{\bf T}\\ \left({\bf T}\times{\bf E}_1\right)\cdot{\bf D} \end{pmatrix} =\frac{1}{{\bf P}\cdot{\bf E}_1} \begin{pmatrix} {\bf Q}\cdot{\bf E}_2\\ {\bf P}\cdot{\bf T}\\ {\bf Q}\cdot{\bf D} \end{pmatrix} \]
ここで \({\bf P}={\bf D}\times{\bf E}_2\) および \({\bf Q}={\bf T}\times{\bf E}_1\) です。
考えてみれば \({\bf E}_1\times{\bf E}_2={\bf N}\) はこの三角形の法線ベクトルです。これは陰影付けのときにも使用するので、すべての三角形であらかじめ求めていることも多いように思います。もしこれを利用するなら、上式を次のように書き換えてもいいかもしれません。
\[ \begin{pmatrix} t\\ u\\ v \end{pmatrix} =\frac{-1}{\left({\bf E}_1\times{\bf E}_2\right)\cdot{\bf D}} \begin{pmatrix} \left({\bf E}_1\times{\bf E}_2\right)\cdot{\bf T}\\ \left({\bf T}\times{\bf D}\right)\cdot{\bf E}_2\\ -\left({\bf T}\times{\bf D}\right)\cdot{\bf E}_1 \end{pmatrix} =\frac{-1}{{\bf N}\cdot{\bf D}} \begin{pmatrix} {\bf N}\cdot{\bf T}\\ {\bf M}\cdot{\bf E}_2\\ -{\bf M}\cdot{\bf E}_1 \end{pmatrix} \]
ここで \({\bf M}={\bf T}\times{\bf D}\) です。
ということを考えてみたけど、三角形のデータに法線ベクトルを持たせるときは正規化しちゃうだろうし、SSE やシェーダを使えば外積って簡単に計算できるから、あんまり意味ないなぁ。\({\bf N}\cdot{\bf D}=0\) になるのは視線と三角形が平行のときで、\(t\) の式は視線 \({\bf R}\left(t\right)\) を三角形の平面の方程式に代入した \({\bf N}\cdot{\bf R}\left(t\right)-{\bf N}\cdot{\bf V}_0=0\) を変形して \(t\) を求めたものになるな。当たり前か。
ラスタライザを自分で書く私は古くさいラスタライザ野郎なんだろうな。でも、それならレイトレーシングのレンダラを書いている人はサンプリング野郎だよな。
*1 私は「クラメール」じゃなくて「クラーメル」って習った気がする。
1つの RealSense で取得した点群は整列しているので、それをもとに作った三角形メッシュも三角形が規則正しく並んだものになっています。そのため、このメッシュのインデックスを作っている CreateTriangleMeshIndex()
は、頂点番号を等間隔に生成しています。このように同じ図形を多数描く場合は、1つ1つを独立したデータとして描くより、一つの図形を GPU 内で複製して描いた方が効率が良くなります。GPU のこの機能を Geometry Instancing と呼びます。
いま描いているメッシュは、縦横に並んだ点群の隣接する 4 点が作る四角形を2つの三角形で描いています。したがって、1つの四角形を GPU 内で複製して描きます。四角形を1つしか使わないので、インデックスは必要ありません。そのためインデックスを格納する indexBuffer
は削除して、代わりに複製する四角形の数を記録するメンバ変数 instances
を追加します。
//[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] //public class RsPointCloudRenderer : MonoBehaviour public class TriangleMeshRenderer : MonoBehaviour { public RsFrameProvider Source; //private Mesh mesh; private GraphicsBuffer vertexBuffer = null; //private GraphicsBuffer indexBuffer = null; private int instances = 0; [SerializeField] private Material material; private Texture2D uvmap;
indexBuffer
を削除したので、それにインデックスを格納する処理も削除します。CreateTriangleMeshIndex()
も使わないので、この定義も削除しても構いません。代わりに四角形の数を instances
に求めておきます。
private void ResetMesh(int width, int height)
{
(中略)
//var indices = new int[vertices.Length];
//for (int i = 0; i < vertices.Length; i++)
// indices[i] = i;
//var indices = CreateTriangleMeshIndex(width - 1, height - 1);
//if (indexBuffer != null)
// indexBuffer.Release();
//indexBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Index,
// indices.Length, sizeof(int));
//indexBuffer.SetData(indices);
instances = (width - 1) * (height - 1);
//mesh.MarkDynamic();
//mesh.vertices = vertices;
indexBuffer
は削除したので、破棄する必要もなくなります。
void OnDestroy() { if (q != null) { q.Dispose(); q = null; } //if (mesh != null) // Destroy(null); //if (indexBuffer != null) // indexBuffer.Release(); if (vertexBuffer != null) { vertexBuffer.Release(); Destroy(null); } }
Graphics.DrawProceduralNow()
では三角形 MeshTopology.Triangles
ではなく四角形 MeshTopology.Quads
を描きます。四角形1つなので、頂点の数は 4 です。それを instances
個複製して描きます。MeshTopology.Quads
は、マニュアルには
Note that quad topology is emulated on many platforms, so it's more efficient to use a triangular mesh.
とか書かれていてあまり使う気がしないのですけど、これを三角形2つで表したりするとシェーダ内で頂点番号を求めるときに面倒なので、これを使います。
void OnRenderObject() { //if (indexBuffer != null) if (instances > 0) { material.SetPass(0); //Graphics.DrawProceduralNow(MeshTopology.Triangles, indexBuffer, indexBuffer.count); Graphics.DrawProceduralNow(MeshTopology.Quads, 4, instances); } }
Graphics.DrawProceduralNow()
は1つの四角形を instances
個複製して描画するので、バーテックスシェーダに渡される頂点番号 SV_VertexID
は 0~3 の範囲になります。そこでバーテックスシェーダの引数にインスタンスの番号 SV_InstanceID
を追加し、これと SV_VertexID
を組み合わせて実際の頂点番号を求めます。四角形の最初の頂点番号は SV_InstanceID
ですから、SV_VertexID
と実際の頂点番号との対応は次のようになります。_UVMap_TexelSize.z
は点群の横方向の点の数です。
SV_VertexID | 実際の頂点番号 |
---|---|
0 | SV_InstanceID |
1 | SV_InstanceID + 1 |
2 | SV_InstanceID + _UVMap_TexelSize.z + 1 |
3 | SV_InstanceID + _UVMap_TexelSize.z |
//v2f vert(appdata v) v2f vert(uint vertex_id : SV_VertexID, uint instance_id : SV_InstanceID) { // vertex_id は 0~3 なので instance_id と組み合わせて実際の頂点番号を求める uint b0 = vertex_id & 1; uint b1 = vertex_id >> 1; vertex_id = instance_id + b1 * _UVMap_TexelSize.z + (b0 ^ b1); v2f v; v.vertex = float4(_Vertex[vertex_id], 1.0);]]>
Unity のレンダリングパイプラインは他のゲームエンジンと同様、Deferred Rendering (遅延レンダリング) が標準になっています。Deferred Rendering はディスプレイへの表示を行う通常のフレームバッファに図形を直接描かずに、一旦、画面に表示されないフレームバッファ、いわゆるオフスクリーンバッファに描いた後に、それを使って最終的なレンダリング結果を生成する手法です。このオフスクリーンバッファには通常のフレームバッファが備えるカラーとデプスの他に、用途に応じて様々な用途を組み合わせて格納できるようになっています。
近年のハイクォリティなゲームでは凝ったマテリアルやリアルな照明効果、あるいは複雑な映像効果を実現するのが当たり前になっています。それにはレンダリングの途中経過など、様々な要素を組み合わせる必要があります。そこで、あらかじめオフスクリーンバッファにそういう要素を別々にレンダリングしておき、事後処理により最終的なレンダリング結果を得るようにします。こうすれば高度な映像表現が行えるだけでなく、そういう手間をかけた表現が隠面消去処理によって消されて無駄になってしまうことを避けることができ、レンダリングのパフォーマンスの向上も見込めます。なお、このようなオフスクリーンバッファを G-バッファと言います。これは日本発の技術です*1。
しかし点群の表示のように、レンダリングプリミティブ数が非常に多いにもかかわらず、それほど高度な映像効果が必要ない場合は、Deferred Rendering のオーバーヘッドが負担になります。その場合はグラフィックス API を使って直接通常のフレームバッファに描いたほうが良い場合もあります。それを Forward Rendering と言います。ちなみに、私は Forward Rendering という用語を初めて聞いた時は何か新しい技術課と思ったのですが、意味を知って「え、普通に API で直接描いているだけじゃん」と思いました。ゲームエンジンのパイプラインに組み込んだこと自体が新しかったのかもしれませんけど。
前回、Game Object の TriangleMesh
の Mesh Renderer に組み込んだスクリプト TriangleMeshRenderer
を修正します。Mesh Renderer は G-バッファにレンダリングするために使うので、Forward Rendering では使用しません。したがって Mesh Renderer や Mesh Filter は不要なのですが、ここで削除すると手順が増えるので残しておきます。一方 Mesh は使わないので、TriangleMeshRenderer
クラスからは削除します。代わりに、この Mesh に組み込んでいた頂点やインデックスのデータを保持する GraphicsBuffer
のメンバ vertexBuffer
と indexBuffer
を追加します。また Material
を保持するメンバ material
も追加しておきます。
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] // public class RsPointCloudRenderer : MonoBehaviour public class TriangleMeshRenderer : MonoBehaviour { public RsFrameProvider Source; //private Mesh mesh; private GraphicsBuffer vertexBuffer = null; private GraphicsBuffer indexBuffer = null; private Material material; private Texture2D uvmap;
RealSense に対応したメッシュの出たを作成するメソッド ResetMesh()
では、Mesh Renderer に組み込んでいた Material
を、メンバ変数 material
に保持するようにします。なお、Mesh Renderer を削除した場合は Resources.Load()
を使って読み込む必要があります。
private void ResetMesh(int width, int height) { Assert.IsTrue(SystemInfo.SupportsTextureFormat(TextureFormat.RGFloat)); uvmap = new Texture2D(width, height, TextureFormat.RGFloat, false, true) { wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Point, }; //GetComponent<MeshRenderer>().sharedMaterial.SetTexture("_UVMap", uvmap); material = GetComponent<MeshRenderer>().sharedMaterial; material.SetTexture("_UVMap", uvmap);
Mesh は使わないので、それに関連するコードは削除します。代わりに、頂点データを格納する GraphicsBuffer
を vertexBuffer
に確保します。また、それをシェーダに渡すために material
にセットします。
//if (mesh != null) // mesh.Clear(); //else // mesh = new Mesh() // { // indexFormat = IndexFormat.UInt32, // }; vertices = new Vector3[width * height]; if (vertexBuffer != null) vertexBuffer.Release(); vertexBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, vertices.Length, sizeof(float) * 3); material.SetBuffer("_Vertex", vertexBuffer);
同様にイデックスデータを格納する GraphicsBuffer
を indexBuffer
に確保します。
//var indices = new int[vertices.Length]; //for (int i = 0; i < vertices.Length; i++) // indices[i] = i; var indices = CreateTriangleMeshIndex(width - 1, height - 1); if (indexBuffer != null) indexBuffer.Release(); indexBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Index, indices.Length, sizeof(int)); indexBuffer.SetData(indices);
この後の Mesh に関連するコードは削除します。その結果、テクスチャ座標 UV を渡すことができなくなってしまいますが、これは後で何とかします。
//mesh.MarkDynamic(); //mesh.vertices = vertices; //var uvs = new Vector2[width * height]; //Array.Clear(uvs, 0, uvs.Length); //for (int j = 0; j < height; j++) //{ // for (int i = 0; i < width; i++) // { // uvs[i + j * width].x = i / (float)width; // uvs[i + j * width].y = j / (float)height; // } //} //mesh.uv = uvs; //mesh.SetIndices(indices, MeshTopology.Points, 0, false); //mesh.SetIndices(indices, MeshTopology.Triangles, 0, false); //mesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10f); //GetComponent().sharedMesh = mesh; }
OnDestroy()
ではもともと Mesh が作られていたら Dispose()
が呼ばれていたので、代わりに vertexBuffer
が作られていたら、それを開放するついでに Game Object を Dispose()
することにします。これでいいんでしょうか。
void OnDestroy() { if (q != null) { q.Dispose(); q = null; } //if (mesh != null) // Destroy(null); if (indexBuffer != null) indexBuffer.Release(); if (vertexBuffer != null) { vertexBuffer.Release(); Destroy(null); } }
RealSense から頂点データを取り出して Mesh を更新していた LastUpdate()
では、これまで points
に取り出した頂点の数が Mesh の頂点の数と比較して違っていたら Mesh を作り直していました。Mesh を使わなくなったので、代わりにこれを (頂点データの一時保管に使う) vertices
の長さと比較することにします。
protected void LateUpdate() { if (q != null) { Points points; if (q.PollForFrame<Points>(out points)) using (points) { //if (points.Count != mesh.vertexCount) if (points.Count != vertices.Length) { using (var p = points.GetProfile<VideoStreamProfile>()) ResetMesh(p.Width, p.Height); }
そのあと points
の頂点データを一時保管用の配列 vertices
にコピーして Mesh に設定していましたが、これも Mesh の代わりに GraphicsBuffer
の vertexBuffer
に格納するようにします。本当は uvmap
同様 points.VertexData
を直接 vertexBuffer
にコピーしたかったんですけど、points.VertexData
の先のデータを vertexBuffer.GetNativeBufferPtr()
の先にコピーする方法がわかりませんでした (Marshal.Copy()
を使う?)。
if (points.TextureData != IntPtr.Zero) { uvmap.LoadRawTextureData(points.TextureData, points.Count * sizeof(float) * 2); uvmap.Apply(); } if (points.VertexData != IntPtr.Zero) { points.CopyVertices(vertices); //mesh.vertices = vertices; //mesh.UploadMeshData(false); vertexBuffer.SetData(vertices); } } } }
最後に OnRenderObject()
メソッドを追加します。この Game Object TriangleMesh
では Mesh Renderer では描画しませんから、OnRenderObject()
で Graphics.DrawProceduralNow()
により直接描画します。
void OnRenderObject() { if (indexBuffer != null) { material.SetPass(0); Graphics.DrawProceduralNow(MeshTopology.Triangles, indexBuffer, indexBuffer.count); } } }
というわけで Mesh Renderer を使わず Mesh を削除してしまったので、テクスチャ座標 UV を渡していません。これを「後で何とかします」ということで、シェーダで何とかすることにします。マテリアルの TriangleMeshMat
に組み込んだシェーダの TriangleMesh
を修正します。バーテックスシェーダ vert()
に入力する頂点属性には位置 POSITION
もテクスチャ座標 TEXCOORD0
も存在しなくなったので、その構造体 appdata
は削除してしまいます。
Shader "Unlit/TriangleMesh" { Properties{ _MainTex("Texture", 2D) = "white" {} _UVMap("UV", 2D) = "" {} } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma geometry geom #pragma fragment frag #include "UnityCG.cginc" //struct appdata //{ // float4 vertex : POSITION; // float2 uv : TEXCOORD0; //}; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; };
代わりに「本当の」テクスチャ座標 UV が入っている _UVMap
のテクスチャサイズ _UVMap_TexelSize
と、GraphicsBuffer
の vertexBuffer
を受け取る StructuredBuffer
の _Vertex
を追加します。
sampler2D _MainTex; sampler2D _UVMap; float4 _UVMap_TexelSize; StructuredBuffer<float3> _Vertex;
またバーテックスシェーダ vert()
では頂点番号 SV_VertexID
を vertex_id
として受け取り、それを使って _Vertex
から頂点の位置を取り出します。こういう風にしたのは、このあと Compute Shader を使ってごにょごにょしたいと思っていることもあるからですね。
//v2f vert(appdata v) v2f vert(uint vertex_id : SV_VertexID) { v2f v; v.vertex = float4(_Vertex[vertex_id], 1.0);
そして _UVMap
をサンプリングするためのテクスチャ座標 UV を vertex_id
と _UVMap_TexelSize
を使って求めます。
if (all((float3)v.vertex == 0.0)) { v.vertex.w = 0.0; return v; } // UV を vertex_id から求める v.uv = float2(fmod(vertex_id, _UVMap_TexelSize.z) * _UVMap_TexelSize.x, floor(vertex_id * _UVMap_TexelSize.x) * _UVMap_TexelSize.y); v.vertex.y = -v.vertex.y; v.vertex = UnityObjectToClipPos(v.vertex); return v; }
これで Mesh Renderer は使わなくなったので、ここで削除します。Hierarchy ウィンドウで TriangleMesh
オブジェクトを選択し、Inspector で Triangle Mesh Renderer (Script) → Mesh Renderer → Mesh Filter の順に削除 (右上の ︙
から Remove Component を選択) してください。この結果 TriangleMesh
オブジェクトは Empty になります。
次に、スクリプトの TriangleMeshRenderer を修正します。まず、このスクリプトを組み込んだ時に Mesh Filter と Mesh Renderer が自動的に組み込まれないように、[RequireComponent ... ]
を削除します。また、このスクリプトから直接マテリアルを参照するために、メンバ変数 material
を [SerializeField]
にするか、public
にします。
//[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] //public class RsPointCloudRenderer : MonoBehaviour public class TriangleMeshRenderer : MonoBehaviour { public RsFrameProvider Source; //private Mesh mesh; private GraphicsBuffer vertexBuffer = null; private GraphicsBuffer indexBuffer = null; [SerializeField] private Material material; private Texture2D uvmap; [NonSerialized] private Vector3[] vertices; FrameQueue q;
マテリアルはこのスクリプトのプロパティで設定しますから、コンポーネントから取り出す必要はありません。
private void ResetMesh(int width, int height) { Assert.IsTrue(SystemInfo.SupportsTextureFormat(TextureFormat.RGFloat)); uvmap = new Texture2D(width, height, TextureFormat.RGFloat, false, true) { wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Point, }; //GetComponent().sharedMaterial.SetTexture("_UVMap", uvmap); //material = GetComponent ().sharedMaterial; material.SetTexture("_UVMap", uvmap);
この Triangle Mesh Render を Game Object の TriangleMesh
に追加します。
Triangle Mesh Renderer (Script) の Source に RsProcessingPipe
を選びます。
Material には TriangleMeshMat
を選びます。
*1 Saito, Takafumi, and Tokiichiro Takahashi. "Comprehensible rendering of 3-D shapes." Proceedings of the 17th annual conference on Computer graphics and interactive techniques. 1990.
なんと初めて Unity とか RealSense とかいう Blog カテゴリを使います。私としては画期的ですね。もちろん自分自身は OpenGL / GLSL を使ったプログラムを C や C++ でゴリゴリに書くってことを長らくやってきたわけです。でも、そういう実装を他の人に渡しても(自分がメンテする場合を除いて)結局活用してもら無かったりします。それで、かつては「Maya から使えんか」とか言われ、今は「Unity で何とかならんか」と言われるわけです。それで自分も遅まきながら Unity とか勉強したりしているんですが…
やっぱり難しいですね。これ、やっぱり CG やリアルタイムグラフィックスなんかを理解していて、その上で API やプログラミングの知識を持ってないと、ランタイムを使いこなせないんじゃないでしょうか。当たり前ですね。ゲームエンジンですから。でも、エディタがすごくよくできているせいで「誰でも」「簡単に」「ゲームが」作れるようになっています。素晴らしいです。それで「Unity でゲームを作ったことがあります」という学生さんに「じゃあ Unity を卒研の道具に使えば」と言ったりするんですけど(うちでは「研究テーマ」と「研究ツール」の両方を決めることを求めています)、なぜか途中で詰んでしまう人がたまに出ます。
実はそれがちょっと不思議だったんですけど、なんだかわかった気がしました。彼らにとっても Unity は実は難しかったんですね。私自身が低レイヤの実装ばかりしているせいかもしれませんけど、うちの学生さんたちの企画する研究内容が要素技術に偏る傾向があるみたいで、ゲームエンジンに追加する機能とかゲームエンジン自体を作るみたいな話になりがちなんですね。それなのに、そういうテーマに対して(彼らのこれまでの使い方で)ゲームエンジンを使うということは、今のゲームエンジンに無い機能をエディタを作って作るとか結構ややこしい話になってしまいます。本当はゲームエンジンの本体であるランタイム・ライブラリの機能を読み解いて使いこなさないといけないのに、それは「ガチプログラミング」になってしまって手も足も出なくなってしまうというのが、「詰む」理由のように思えます。
以前、他の大学の先生と「Unity を使うと卒論が企画書っぽくなりがち」という話をしたことがあります。確かに「~する」とは書いてあっても、どうやってやったか、何を考えてそうしたかという説明が不足しがちに感じます。だから読んでもおぼろげなイメージとか曖昧なビジョンみたいなものは感じとれても、具体的なものが見えてきません。確かに作ってはいるのですけど、多分、試行錯誤の結果なんだか知らないけどそうなったとか、やろうと思ってたことに似たものを見つけたので、そのコードをはめてみたらそれらしく動いたみたいなものの積み重ねでゴールまで来ちゃったんじゃないか、などと思ってしまいます。でも、それは今後の(AI 時代の?)「プログラミングスタイル」に通じるものなのかもしれませんけど。
自分自身のテーマは CG というかインタラクティブ CG だと思ってはいるんですけど、そういうものの応用を考えていると、いつの間にかゲームとか VR とかに関わることになってしまいます。それで VR から AR、MR、そして XR とかいう方向に歩いていくと (と言っても、これらはみんなおんなじだと思っているんですけどね)、CV や点群やセンサ類などにも関係してしまいます。もともと CG は数学・物理から認知科学とか芸術とかにも関わる総合科学だと思ってはいたのですけど、何か一つシステムを作ろうと思ったときに考えないといけない要素が結構たくさんあるんですよね。だから、そういうものをあらかじめパッケージにしたものを使わないと、手間と時間がかかりすぎます。それでも、本当に必要なものは、それらの基礎的な理論だなあといつも思います。時間がないという言い訳をしながら、それらの成果物を借りてごまかしていますけど。
というわけで、今回は自分自身が RealSense SDK (librealsense) の Unity Package の使い方を勉強しながら、Teams 上で学生さんに説明したので、その内容をここにメモっています。
Intel.RealSense.SDK-WIN10-2.*.*.*.exe
をダウンロードしてください。また Unity Package Intel.RealSense.unitypackage
もダウンロードしてください。Realsense Viewer
の起動を促されます。これを起動すると PC に接続している RealSense の Firmware のアップデートを促されますので、アップデートしておいてください。RealSense Viewer はこのあと終了してください。3D
か 3D (URP)
を選んでください。3D
は古いビルトインパイプライン、3D (URP)
は Universal Render Pipeline と言って自分でレンダリングパイプラインを記述できる Scriptable Render Pipeline で、今はもっぱらこれが使われるみたいです。ここでは 3D (URP)
を選ぶことにします (このプロジェクトはうっかり 3D
すなわちビルトインパイプラインで作っちゃったけど、3D (URP)
でも同じ手順でできると思います)。Assets
メニューから Import Package... >
Custum Package...
を選び、先ほどダウンロードした RealSense の Unity Package を開いてください。Import Unity Package
というウィンドウがポップアップしますから、そのまま Import
をクリックしてください。▶
をクリックすれば、エディタ内で実行を開始します。PointCloudDepthAndColor を実行すると、こんな具合になります。視点はマウスで動かせます。
ただ、これは実際には点で描いているので、クローズアップするとこういうことになります。
これを三角形のメッシュで描いて、クローズアップしても隙間が空かないようにしたいと思います。
File
メニューの New Scene
を選ぶか、とりあえず Project ウィンドウの Assets の中にある Scenes に作られている SampleScene を使ってください。PointCloud
、RsDevice
、RsProcessingPipe
の3つの Prefab を Hierarchy ウィンドウにドラッグ&ドロップします。RsDevice
が RealSense のインタフェースなので、RealSense が複数あるときは、これも複数 Hierarchy に置きます。その場合、センサごとに RsDevice
の名前を変えておいた方が良いでしょう (RsDevice0
, RsDevice1
, ...)。RsDevice
がどの RealSense を担当するかは、Requested Serial Number で指定すればいいんじゃないかと思います。これは RealSense Viewer で調べることができます。RsDevice
に連動して動かしたければ、PointCloud を RsDevice
の下の階層に置いておくと良いじゃないかと思います。RsDevice
の下に PointCloud
を置いた方が良いでしょう。RsProcessingPipe
が RsDevice
のデータを加工するパイプラインになります。したがって、この Source には使用する RealSense を担当する RsDevice
を指定し、Profile には使用する設定を選びます。いずれも ⊕
をクリックすれば、選択可能なものの一覧が出ます。検索欄に数文字打ち込めば、目的のものを見つけられると思います。RsDevice
ごとに RsProcessingPipe
の名前を変えておいた方が良いんじゃないかと思います (RsProcessingPipe0
, RsProcessingPipe1
, ...)。PointCloud
は、この RsProcessingPipe
からデータを受け取ります。これは Mesh Filter でメッシュを作成しない代わりに、RsPointCloudRenderer が Source に指定されている RsProcessingPipe
からデータを受け取って描画します。この状態で ▶
をクリックすると、黒い点群が表示されます。
RsDevice
の Color
ストリームを選択してください。RsProcessingPipe
、Stream には Color、Format には Rgb8 を選んでください。RealSense のカラーの生データは多分 RGB ではないので (確認してないけど YUV とか YUY2?)、RsDevice
で RGB に変換したものを使うんだと思います。この状態で ▶
をクリックすると、テクスチャが貼られます。
ただし、これは三角形メッシュでではなく点 (というか四角形) です。点のサイズは PointCloud
の RsPointCloudRenderer の Point Size で指定します。三角形のメッシュにするにはインデックスデータを作るなど、もう一工夫必要になります。
このままではカメラが遠いし動かすこともできないので、Project ウィンドウの Assets の RealSenseSDK2.0 の Misc の Utils にある OrbitCameraControl というスクリプトを組み込みます。Hierarchy ウィンドウの Main Camera
を選択し Inspector の一番下の Add Component
をクリックして、OrbitCameraControl を選んでください。
これでマウスを使ってカメラを操作することができるようになります。この OrbitCameraControl は結構便利で、他のものにも流用できると思います (他では Unity Editor のエラーが出ることがありますが、スクリプトの Start() の当該の部分を削除すると動きます)。
あと、スクリプトとシェーダについて、少し説明します。スクリプトは Assetes の RealSenseSDK2.0 の Scripts フォルダにあります。
まず RsDevice というスクリプトを開いてください。ダブルクリックすれば VisualStudio が起動すると思います。デフォルトの Multithread の場合、下記のように RealSense の起動時に開始されたスレッドで RealSense のフレームの取得を待ち、データが到着したら SampleEvent というイベントを発行するという処理を繰り返しています。
////// Worker Thread for multithreaded operations /// private void WaitForFrames() { while (!stopEvent.WaitOne(0)) { using (var frames = m_pipeline.WaitForFrames()) RaiseSampleEvent(frames); } }
なお Unity Thread の場合は、このスクリプトの最後の Update() で、次のようにフレームを取得して同様に SampleEvent を発行しています。
void Update() { if (!Streaming) return; if (processMode != ProcessMode.UnityThread) return; FrameSet frames; if (m_pipeline.PollForFrames(out frames)) { using (frames) RaiseSampleEvent(frames); } }
点群の描画を行っているのは RsPointCloudRenderer というスクリプトです。ここでは次のように入力フレーム frame がコンポジットフレーム (複数のデータが合成されているとき) はフレームセットのデプスのフレームから点の位置 Xyz32f を取り出してキュー q
に入れています。
private void OnNewSample(Frame frame) { if (q == null) return; try { if (frame.IsComposite) { using (var fs = frame.As<FrameSet>()) using (var points = fs.FirstOrDefault<Points>(Stream.Depth, Format.Xyz32f)) { if (points != null) { q.Enqueue(points); } } return; } if (frame.Is(Extension.Points)) { q.Enqueue(frame); } } catch (Exception e) { Debug.LogException(e); } }
そのあと (すべての Update()
より後に実行される) LateUpdate()
において、以下のように (メッシュサイズが変わったときにメッシュの作り直しをしたり、テクスチャがあればそれを更新したりした後で) points.CopyVertices(vertices);
により点群から頂点位置を取り出した後、それを使って mesh.vertices = vertices;
としてメッシュの頂点を更新しています。mesh.UploadMeshData(false);
はそのメッシュのデータがこれ以降使われず描画後に破棄しても問題ないことを Mesh Renderer に伝えています。
protected void LateUpdate() { if (q != null) { Points points; if (q.PollForFrame<Points>(out points)) using (points) { if (points.Count != mesh.vertexCount) { using (var p = points.GetProfile<VideoStreamProfile>()) ResetMesh(p.Width, p.Height); } if (points.TextureData != IntPtr.Zero) { uvmap.LoadRawTextureData(points.TextureData, points.Count * sizeof(float) * 2); uvmap.Apply(); } if (points.VertexData != IntPtr.Zero) { points.CopyVertices(vertices); mesh.vertices = vertices; mesh.UploadMeshData(false); } } } }
描画処理を Update()
ではなく LastUpdate()
で行っているのは、(デフォルトでは使われない) Unity Thread の場合に Upload()
で点群の収集を行っているために、それより後で描画するためだと思います。(デフォルトの) Multithread なら描画と並行して点群の収集が行われるので、Update()
で描画しても問題ない気がします。
点群自体はこの vertices
に入っているので、点群そのものを使うならこれか、その前の points を使えばいいと思いおます。デプスに対してごにょごにょしようとするなら RsDevice スクリプトでフレームをいじるか、RsProcessingPIpe
の ProcessFrame をいじる (この中で使っている Process っていう Abstruct クラスをオーバーライドする?) といいんじゃないでしょうか。
いよいよ本題に入ります。最初に Renderer スクリプトを作成します。
Assets
メニューから Create >
Folder
を選び、フォルダを作成してください。フォルダ名は Scripts にしておくことにします。Ctrl+C
した後、Assets 直下の Scripts を選択して Ctrl+V
してください。TriangleMeshRenderer
にすることにします。クラス名の変更は、クラス名を右クリックして "名前の変更 Ctrl+R
, Ctrl+R
" で行った方が無難だと思います。
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] // public class RsPointCloudRenderer : MonoBehaviour public class TriangleMeshRenderer : MonoBehaviour {
ResetMesh()
の前あたりに以下の CreateTriangleMeshIndex()
というメソッドを追加します。このインデックスの作り方については、メディアデザインセミナー2Bの資料などを参考にしてください。
private static int[] CreateTriangleMeshIndex(int slices, int stacks) { // 三角形の頂点の数 int vertices = slices * stacks * 6; // 三角形のデータ int[] indeces = new int[vertices]; // 三角形の頂点番号を求める for (int j = 0; j < stacks; ++j) { // 各段の左端の下側の頂点番号の格納先 int f0 = j * slices * 6; // 各段の左端の下側の頂点の頂点番号 int p0 = j * (slices + 1); for (int i = 0; i < slices; ++i) { // 左から i 番目の四角形の左下の頂点番号の格納先 int fi = f0 + i * 6; // 左から i 番目の四角形の左下の頂点番号 int pi = p0 + i; // 1つ目の三角形の頂点番号 indeces[fi + 0] = pi; indeces[fi + 1] = pi + 1; indeces[fi + 2] = pi + slices + 1; // 2つ目の三角形の頂点番号 indeces[fi + 3] = pi + slices + 2; indeces[fi + 4] = pi + slices + 1; indeces[fi + 5] = pi + 1; } } // 作成したインデックスを返す return indeces; }
ResetMesh()
で行っているインデックスの作成に CreateTriangleMeshIndex()
をいます。
//var indices = new int[vertices.Length]; //for (int i = 0; i < vertices.Length; i++) // indices[i] = i; var indices = CreateTriangleMeshIndex(width - 1, height - 1);
MeshTopology.Triangles
でメッシュに設定します。
//mesh.SetIndices(indices, MeshTopology.Points, 0, false); mesh.SetIndices(indices, MeshTopology.Triangles, 0, false);
次にシェーダを作成します。もちろん GLSL ではなく HLSL です。色々勝手が違います。
Assets
メニューから Create >
Folder
を選び、フォルダを作成してください。フォルダ名は Shaders にしておくことにします。Assets
メニューから Create >
Shader >
Unlit Shader
を選択して、シェーダを作成してください。シェーダ名は TriangleMesh にすることにします。Shader "Unlit/TriangleMesh" { Properties { _MainTex ("Texture", 2D) = "white" {} _UVMap("UV", 2D) = "" {} // 追加 }
SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // make fog work #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) float4 vertex : SV_POSITION; };
sampler2D _MainTex; sampler2D _UVMap; // 追加 float4 _MainTex_ST;
RsProcessingPipe
から得られる点群データは上下が反転しているので、バーテックスシェーダで y 座標値の符号を反転します。
v2f vert (appdata v) { // y 座標値を反転する v.vertex.y = -v.vertex.y; v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o; }
i.uv
を使って _UVMap をサンプリングし、カラーデータのテクスチャ座標 uv
を得ます。このテクスチャ座標が範囲外の時は、画素を捨てます。
fixed4 frag (v2f i) : SV_Target { // _UVMap をサンプリングしてテクスチャ座標 uv を得る float2 uv = tex2D(_UVMap, i.uv); if (any(float4(uv, 1.0 - uv) <= 0.0)) discard;
uv
を使ってカラーデータ _MainTex をサンプリングして、フラグメントの色を求めます。
// sample the texture fixed4 col = tex2D(_MainTex, uv); // uv をテクスチャ座標に使う // apply fog UNITY_APPLY_FOG(i.fogCoord, col); return col; } ENDCG } } }
多分、この用途ではフォグや TRANSFORM_TEX
によるテクスチャ座標の位置調整は不要だと思うので、削除しても構わない (むしろ削除した方がいい) んじゃないかと思います。
マテリアルを作成して作成したシェーダを組み込みます。
Assets
メニューから Create >
Folder
を選び、フォルダを作成してください。フォルダ名は Resources にしてください。Assets
メニューから Create >
Folder
を選び、フォルダを作成してください。フォルダ名は Materials にすることにします。このプロジェクトではこんなに深いフォルダを作る必要はないと思うのですが、Unity のスクリプトから参照するファイルのパスはこの Assets の下の Resources からの相対パスになっているみたいなので、それに合わせます。Assets
メニューから Create >
Material
を選択して、マテリアルを作成してください。マテリアル名は TriangleMeshMat にすることにします。RsDevice
の Color
ストリームを選択し、Inspector で RsStreamTextureRenderer の Source に RsProcessingPipe
、Stream に Color、Format に Rgb8 が選ばれていることを確認してください。空の Game Object を作って、そこに作成したスクリプトを組み込みます。
PointCloud
を選択し、上の Edit
メニューから Delete
を選ぶか、Delete キー
をタイプして削除します。GameObject
メニューから Create Empty
Ctrl+Shift+N
を選び、空の Game Object を作成します。オブジェクト名は、これも TriangleMesh
とかにしておきます。TriangleMesh
を選択した状態で上の Component
メニューから Mesh >
Mesh Filter
を選ぶか、Inspactor の一番下の Add Component
をクリックして Mesh Filter を選んで、Mesh Filter を追加します。TriangleMesh
を選択した状態で上の Component
メニューから Mesh >
Mesh Renderer
を選ぶか、Inspactor の一番下の Add Component
をクリックして Mesh Renderer を選んで、Mesh Renderer を追加します。Add Component
をクリックして、Triangle Mesh Renderer を選んでください。RsProcessingPipe
を選びます。これでようやくこういう結果が得られます (点群と色が若干ずれている気がするのは RealSense をしょっちゅう落っことしたりしたのにキャリブレーションし直していないからですかね?)。
でも残念ながら、視点を動かすと、これはこういう表示になっています。
これは RealSense が計測不能だったりした点の位置を (0, 0, 0) にしてしまうため、三角形のメッシュで表示したときに3つの頂点のうち1つが (0, 0, 0) の三角形がそこまで伸びて表示されるからです。そこで、これを避けるために、どれか1つの頂点でも (0, 0, 0) になっている三角形は描かないようにします。それには (あんまり使いたくないけど) ジオメトリシェーダを使います。
伸びてしまった三角形をジオメトリシェーダで削除します。
geom
という名前のジオメトリシェーダを含むことを宣言します。
Shader "Unlit/TriangleMesh" { Properties { _MainTex ("Texture", 2D) = "white" {} _UVMap("UV", 2D) = "" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma geometry geom // 追加 #pragma fragment frag // make fog work #pragma multi_compile_fog
#include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _UVMap; float4 _MainTex_ST; v2f vert (appdata v) { // 頂点の位置が (0, 0, 0) なら w も 0 にして出力する if (all((float3)v.vertex == 0.0)) { v.vertex.w = 0.0; return v; } v.vertex.y = -v.vertex.y; v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o; }
v[3]
のどれか一つでも w = 0 だったら頂点を一つも出力しません。[maxvertexcount(3)]
はこのシェーダが最大で3つの頂点を出力することを示しています。
[maxvertexcount(3)] void geom (triangle v2f v[3], inout TriangleStream<v2f> ts) { if (any(float3(v[0].vertex.w, v[1].vertex.w, v[2].vertex.w) == 0.0)) return; ts.Append(v[0]); ts.Append(v[1]); ts.Append(v[2]); }
fixed4 frag (v2f i) : SV_Target { float2 uv = tex2D(_UVMap, i.uv); if (any(float4(uv, 1.0 - uv) <= 0.0)) discard; // sample the texture fixed4 col = tex2D(_MainTex, uv); // apply fog UNITY_APPLY_FOG(i.fogCoord, col); return col; } ENDCG } } }
これで (0, 0, 0) に向かって伸びていた三角形が削除されます。
RealSense の D400 シリーズは (アクティブ) ステレオカメラなので、カメラに近い計測対象の周囲に視差による閉塞 (occulusion) により計測できない場所が生じることがあります (これが生じない LiDAR の L515 がディスコンなのは残念)。ここではその部分の三角形を単に削除しているので、そこに隙間 (穴) が生じています。
RealSense SDK にはそういう穴をふさぐ関数が用意されていますが (サンプルの PointCloudProcessingBlocks というシーンで使っています)、[このプログラム](https://github.com/tokoik/getdepth)ではそれを使わず、GPU を使って自前実装しています。今後はそれも Unity で実装しようと考えています。
以上はフォグの処理に手を加えずにコードの追加のみを行って実装しましたが、フォグの処理を削除すればシェーダの記述がもっと簡単になります。以下のバーテックスシェーダ vert()
は戻り値の型が v2f
ですが、戻り値として返している v
は appdata
型の引数です。v2f
と appdata
はシェーダセマンティクスが異なりますが、構造を同じにしておけば問題なくデータを渡すことができるようです (多分そういうことはしない方が良いと思うけど)。
Shader "Unlit/TriangleMesh" { Properties { _MainTex ("Texture", 2D) = "white" {} _UVMap("UV", 2D) = "" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma geometry geom #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; sampler2D _UVMap; v2f vert (appdata v) { if (all((float3)v.vertex == 0.0)) { v.vertex.w = 0.0; return v; } v.vertex.y = -v.vertex.y; v.vertex = UnityObjectToClipPos(v.vertex); return v; } [maxvertexcount(3)] void geom (triangle v2f v[3], inout TriangleStream<v2f> ts) { if (any(float3(v[0].vertex.w, v[1].vertex.w, v[2].vertex.w) == 0.0)) return; ts.Append(v[0]); ts.Append(v[1]); ts.Append(v[2]); } fixed4 frag (v2f i) : SV_Target { float2 uv = tex2D(_UVMap, i.uv); if (any(float4(uv, 1.0 - uv) <= 0.0)) discard; return tex2D(_MainTex, uv); } ENDCG } } }]]>
2019 年から丸 4 年ブログを更新しておりませんでした。もちろん、その間に何もしていなかったわけではなく、また書きたいと思っているネタもいくつもありました。でも、なんか毎日が自転車操業状態で、ブログを書くとか、それより大事な論文を書くとかいう作業に全然取り掛かれずにいました。コロナとかでいろいろあったってこともあるんですけど、この状況で逆に研究に集中できたという方もいらっしゃいますから、言い訳にはならんでしょうね。今もやらなきゃなんないことがいくつもあって、忙しい、時間が足りないという焦りで頭が一杯になっています。
でも、これは錯覚に過ぎないっていう話も聞きます。そういうことを ChatGTP に相談してみたりしたのですが、まあ、そのとおりやなっていう回答をもらったものの、気づきみたいなものは得られませんでした。もっと突っ込んで質問すればよかったのでしょうけど、それ以上聞きたいことを思いつきませんでした。
GPU のプログラミングモデルは、大量のデータを GPU の複数のプロセッサに分配し、各プロセッサはカーネルと呼ばれる共通したプログラムにより、分配された個々のデータに対して同一の処理を同時に実行するというものです。このような処理はデータ並列性 (data parallelism) と呼ばれます。
特に GPU では、分配されたデータが互いに依存していないとして、個々のプロセッサが他のプロセッサの実行状況を考慮することなく、並列に処理を実行します。バーテックスシェーダは CPU から供給された複数の頂点の頂点属性を同時に処理しますが、一つのプロセッサは一つの頂点だけを担当し、他の頂点の頂点属性を参照することはありません。フラグメントシェーダもラスタライザにより指定されたフラグメントのみを担当し、他のフラグメントの状況を考慮することはありません。これにより、各プロセッサは他のプロセッサの状況にかかわらず、フルスピードで動作することができます。これはストリームコンピューティング (stream computing) と呼ばれます。
しかし一般には、入力された複数のデータが相互に依存しているような処理はいくらでも存在します。例えば画像のフィルタリング処理では、入力画像の複数の画素のデータから出力画像の1画素の値を決定します。
このような応用に対応するために、コンピュートシェーダには複数のプロセッサがデータを共有する機能や(共有メモリ)、共有したデータに対する複数のプロセッサからのアクセスの競合を調停する機能(アトミック変数)、およびプロセッサが互いに処理の同期をとる仕組み(バリア)などが導入されています。また、プロセッサを入力データに対してどのように配分するかという設定も、プログラマに提供されています。
今回は 2018 年に書いたコードをネタにします。ずっと書きたかったのですが、先に書いた通り書くことがとっても億劫だったので、先延ばしにしていました。でも最近学生さんに説明する必要が出てきたので、いい加減にちゃんと書こうと思いました。元ネタは NVIDIA の(OpenGLの始祖であらせられる)Mark Kilgard 氏の 2012 年の SIGGRAPH での講演、NVIDIA OpenGL for 2012 です。
画像のフィルタリング処理では、例えば、画像中の \(\left(x,y\right)\) の位置にある画素の値 \(f\left(x,y\right)\) を、その位置を中心とした \(\left(2N+1\right)\times\left(2N+1\right)\) 画素の近傍の画素の平均値 \(h\left(x,y\right)\) で置き換えることにより、画像をぼかすことができます。
$$D=\left(2N+1\right)^2$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\sum_{i=-N}^{N}f\left(x+i,y+j\right)$$
この計算を横 \(W\) 画素、縦 \(H\) 画素の画像に対して画素ごとに独立に行うと、画素へのアクセス回数は(周辺部のはみ出しを無視するとして)\(\left(2N+1\right)^2\times W\times H\) 回になります。しかし、これは次のように、列ごとの和を求めてから、それを行ごとに合計しても同じです。
$$g\left(x,y\right)=\sum_{i=-N}^{N}f\left(x+i,y\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}g\left(x,y+j\right)$$
このとき、この \(g\left(x,y\right)\) を画像全体について保持しておけば、その分のメモリが必要になりますが、画素へのアクセスが \(2\left(2N+1\right)\times W\times H\) 回になるので、計算量のオーダーを下げることができます。
これはガウスフィルタのような重み付きフィルタの場合も同様です。
$$D=\sum_{j=-N}^{N}\sum_{i=-N}^{N}\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\sum_{i=-N}^{N}\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)f\left(x+i,y+j\right)$$
この各画素の重みは、
$$\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)=\exp\left(-\frac{i^2}{2\sigma^2}\right)\exp\left(-\frac{j^2}{2\sigma^2}\right)$$
ですから、\(j\) の項をくくり出して、
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\exp\left(-\frac{j^2}{2\sigma^2}\right)\sum_{i=-N}^{N}\exp\left(-\frac{i^2}{2\sigma^2}\right)f\left(x+i,y+j\right)$$
とすると、これも次のように2つのステップに分けることができます。
$$g\left(x,y\right)=\sum_{i=-N}^{N}\exp\left(-\frac{i^2}{2\sigma^2}\right)f\left(x+i,y\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\exp\left(-\frac{j^2}{2\sigma^2}\right)g\left(x,y+j\right)$$
さて、このような処理をコンピュートシェーダ上に実装するわけですが、そのためにはまず、コンピュートシェーダのプロセッサを、入力データに対してどのように配分するかを決めておく必要があります。
GPU には多数のプロセッサがあり、それぞれが並列に動作します。この一つのプロセッサに割り当てられた処理をスレッド (thread) といいます。そして、互いに協力して働くことのできるスレッドの集合をワークグループ (workgroup) といいます。一つのワークグループを構成するスレッドの数は、あらかじめ決めておく必要があります。これは (local_size_x, local_size_y, local_size_z) の 3次元の格子の数で指定します。
またコンピュートシェーダを起動する際は、このワークグループをいくつ使用するかを指定します。このワークグループの数も (num_groups_x, num_groups_y, num_groups_z) の3次元の格子の数で指定します。ここでは対象が画像なので、local_size_z = num_groups_z = 1 として、2次元の格子を構成するようにします。
ワークグループ同士も、互いに並行して動作します。これらがどれだけ並列に動作するかどうかは、GPU のプロセッサの数などのリソース次第です。世の中金次第ということですね。
いま、入力画像 image の (x,y) の位置にある画素を中心とした横 filterSize.x、縦 filterSize.y の領域 filter 内の各画素の値から、入力画像と同じサイズの出力画像 filtered の (x,y) の位置にある画素の値を決定することを考えます。これを出力画像のすべての画素に対して行います。
ですが、これを一つ一つの画素に対して順番にやっていては、CPU で処理するのと変わりありません。そこで、複数の画素の値を同時に決定するように、処理を並列化します。ワークグループ内の各スレッドは並行して動作しますから、それぞれに値を決定する画素を割り当てます。ここでは一つのワークグループが処理する画素の領域を tile と呼ぶことにします。この tile の横と縦のサイズを、それぞれ tileSize.x、tileSize.y とします。
コンピュートシェーダは、この tile を画像に敷き詰めていると考えて実行します。ここでは、この一つの tile に対して一つのワークグループを割り当てます。
個々のワークグループにはワークグループの番号が割り当てられ、組み込み変数 gl_WorkGroupID に格納されます。ワークグループは3次元の格子に配列されていると見立てていますから、gl_WorkGroupID も3次元のベクトルです。同様にワークグループ内の個々のスレッドにもスレッドの番号が割り当てられ、組み込み変数 gl_LocalInvocationID に格納されます。これも3次元のベクトルです。これらの番号は、各スレッドが担当する対象(画素など)を決定するための手掛かりに用いられます。
一つの tile が参照する入力画像の領域のサイズは、tile の最外周の画素に filter の中心を置いた時の、filter の範囲を含む領域です。filter の中心の画素から最外周の画素までの画素数 \(N\) は、横方向と縦方向それぞれ filterOffset.x = floor(filterSize.x / 2)、filterOffset.y = floor(filterSize.y / 2) ですから、これは横が neighborhoodSize.x = tileSize.x + filterOffset.x×2、縦が neighborhoodSize.y = tileSize.y +filterOffset.y×2になります。このサイズの作業用の配列を共有メモリに作成します。フィルタリングの処理は、画像をこの共有メモリにコピーして実行します。
さて、ここからがコンピュートシェーダのキモです。いま、このフィルタリングの実行に使うワークグループのサイズを、横 tileSize.x、縦 neighborhoodSize.y とします。つまり、横は tile の横のサイズと同じですが、縦は tile の縦のサイズに filter の縦方向のマージン分を加えたものにします。このサイズを用いる理由は後述します。ここではワークグループのサイズを横 local_size_x = 32、縦 local_size_y = 32 とし(local_size_z は省略しているので 1 になります)、filter のサイズ filterSize を横 filterSize.x = 11、縦 filterSize.y = 11 として、これらからタイルのサイズ tileSize を決めることにします。
#version 430 core // 非 0 ならガウスフィルタ、0 なら平均値(ボックス)フィルタ #define GAUSS 1 // ワークグループのサイズ layout (local_size_x = 32, local_size_y = 32) in; // フィルタのサイズ const ivec2 filterSize = ivec2(11, 11);
filter のマージン filterOffset を求めておきます。正の整数の除算なので、小数点以下は切り捨てられます。これはガウスフィルタの重みを計算するときに filter の中心位置としても使います(このため filterSize は奇数である必要があります)。
// フィルタのオフセット const ivec2 filterOffset = filterSize / 2;
そうすると、一度に処理するタイルの領域 tile のサイズ tileSize は、ワークグループのサイズの縦方向を filter のマージン分切り詰めたものになります。ワークグループのサイズが横 local_size_x = 32、縦 local_size_y = 32、フィルタ filter の縦のサイズが filterSize.y = 11 であれば、タイルのサイズ tileSize は横 tileSize.x = 32、縦 tileSize.y = 32 – floor(11/2) × 2 = 22 になります。
// 一度に処理するタイルの領域のサイズ const ivec2 tileSize = ivec2(gl_WorkGroupSize) - ivec2(0, filterOffset.y * 2);
また、tile のサイズに filter のマージンを加えて、tile が参照する入力画像の領域のサイズを求めます。
// 近傍を含む領域のサイズ const ivec2 neighborhoodSize = tileSize + filterOffset * 2;
このサイズの配列 pixel を、共有メモリ上に作成します。これに加えて、横のサイズが tile と同じサイズの vec2 の配列 row も作成しておきます。平均値(ボックス)フィルタやガウスフィルタでは、事前に重みの総和 \(D\) を求めることができますが、ここでは後々の都合のために row を vec2 にして、分子と分母のそれぞれの合計を別々に求められるようにしておきます。
// 処理する領域の近傍を含めたコピー shared float pixel[neighborhoodSize.y][neighborhoodSize.x]; shared vec2 row[neighborhoodSize.y][tileSize.x];
入力画像のフォーマットは、この後の都合から r32f (32bit float, 1 channel) とします。これを image というイメージユニットから入力します。結果は同様に r32f の filtered というイメージユニットに格納します。このほか、ガウスフィルタの重みの分散を unform 変数で取得します。
// 入力データを格納したイメージユニット layout (r32f) readonly uniform image2D image; // 出力データを格納するイメージユニット layout (r32f) writeonly uniform image2D filtered; // ガウスフィルタの重みの分散 (x: column, y: row, z: value) uniform vec2 variance;
イメージユニットから画素を取得する際、インデックスがイメージユニットの範囲から外れないようにする関数 clampLocation() を用意しておきます。
// インデックスがイメージの領域から外れないようにする ivec2 clampLocation(ivec2 xy) { return clamp(xy, ivec2(0), imageSize(image) - 1); }
スレッドの同期をとる関数 retirePhase() を用意します。これは共有メモリへのアクセスの完了と、スレッドのここまでの処理の完了を待ちます。
// 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ void retirePhase() { memoryBarrierShared(); barrier(); }
以降、実際の処理を説明します。コンピュートシェーダはバーテックスシェーダやフラグメントシェーダと異なり、処理する対象を自分で決めることができます。しかし、適切な対象を決めるためには、何らかの手掛かりが必要になります。そのために、ワークグループやスレッドに割り当てられる番号を用います。前者は組み込み変数 gl_WorkGroupID、後者は組み込み変数 gl_LocalInvocationID に格納されています。
void main(void) { // ワークグループが処理する領域の基準位置 const ivec2 tile_xy = ivec2(gl_WorkGroupID); // スレッドが処理する画素のワークグループにおける相対位置 const ivec2 thread_xy = ivec2(gl_LocalInvocationID);
これらより、スレッドが担当する入力画像中の画素の位置 dst_xy を求めます。ワーク区グループの番号に tile のサイズを乗じて、それにスレッドの位置を足します。これは結果を格納するイメージユニットの画素の位置です。
// スレッドに割り当てられた画素位置 const ivec2 dst_xy = tile_xy * tileSize + thread_xy;
スレッドが参照するイメージユニット image の領域の基準の画素位置は、格納する画素位置より filter の縦方向のマージン分下げたところにします。
// スレッドが読み出すイメージ上の画素位置 const ivec2 src_xy = ivec2(dst_xy.x, dst_xy.y - filterOffset.y);
x, y はスレッドが処理するタイル上の画素位置です。
// スレッドが処理する画素位置 const uint x = thread_xy.x; const uint y = thread_xy.y;
スレッドが参照するイメージユニット image の領域の各画素を、共有メモリ上の配列 pixel にコピーします。読み出す画素の位置の決定に先ほど定義した clampLocation() を使います。これにより image からはみ出した部分から読み出そうとしたときは、image の最外周の画素の値になります。これでテクスチャの GL_CLAMP_TO_EDGE に似た処理になります。
ワークグループの各スレッドは、自分がコピーする画素を1つ共有メモリ上の配列 pixel にコピーします。このコピーは並列に動作する多数のスレッドによって一気に実行されます。データ並列性というのは、逐次処理では繰り返しループで実行されていた複数のデータに対する処理を、このように多数のプロセッサによる並列処理に置き換えます。
ただし、ここで本当に1つの画素しかコピーしないでいると、filter の横のサイズ filterSize.x 分の画素がコピーされません。そこで、ワークグループの横のサイズ tileSize.x 分ずらした位置にある画素をもう一度コピーします。これを共有メモリ上の配列 pixel の横のサイズを超えない範囲で繰り返します。
// タイルごとに処理する1画素を共有メモリにコピーする for (int i = 0; i < neighborhoodSize.x; i += tileSize.x) { if (x + i < neighborhoodSize.x) { // 読み出し位置 const ivec2 read_at = clampLocation(ivec2(src_xy.x - filterOffset.x + i, src_xy.y)); // 共有メモリに書き込む pixel[y][x + i] = imageLoad(image, read_at).r; } }
そして、次の処理に移る前に、すべての画素のコピーが完了し、すべてのスレッドが次の処理に移ることができる次点に到達するのを待つ必要があります。これは、最初に pixel の左端の画素を担当したスレッドは複数の画素のコピーを行う必要があるのに対し、右端の画素を担当したスレッドは1つの画素しかコピーする必要がないために、すべてのスレッドの処理時間が同じにはならないためです。また、スレッドが本当に並列に動作している独立したプロセッサに割り当てられているかどうかも、GPU の仕様に依存します。
// 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ retirePhase();
セパラブルフィルタでは、処理を縦と横に分離して行います。そのため、横方向の処理に必要なスレッドの数(ワークグループのサイズ)は、横は tileSize.x ですが、縦はフィルタの縦方向のマージン分を含めた neighborhoodSize.y になります。また、この結果を使って次に縦方向の処理を行うので、結果を保存するために必要なもう一つの共有メモリ上の配列 row のサイズは、このワークグループのサイズと同じ横 tileSize.x、縦 neighborhoodSize.y になります。
変数 x と y は、ワークグループ内でのこのスレッドの「位置」に相当します。そこからフィルタの横のサイズ filterSize.x 分の画素の重み付け和を求めます。sum は vec2 型なので、第1要素 (sum.r) に分子となる画素値の重み付け和、第2要素 (sum.g) に重みの和を求めます。重みの和は事前に計算できるので、ここでこんな風に計算する必要はないのですが、今後この部分にちょっと細工するつもりなので、今はこうしておきます。
// 対象画素の値に重みを掛けたものの合計(分子)と重みの合計(分母) vec2 sum = vec2(0.0); // 横方向の重み付け和を求める for(int i = 0; i < filterSize.x; ++i) { const float c = pixel[y][x + i]; #if GAUSS const float d = float(i - filterOffset.x); const float e = exp(-0.5 * d * d / variance.x); sum += vec2(c, 1.0) * e; #else sum += vec2(c, 1.0); #endif }
横方向の画素値の重み付け和と重みの和が求まれば、これを共有メモリ上の配列 row に格納します。そのあと、他のスレッドの共有メモリへのアクセスの完了と、ここまでの処理の完了を待ちます。
// 横方向の重み付け和を保存する row[y][x] = sum; // 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ retirePhase();
次に、横方向の画素値の重み付け和と重みの和を縦方向に合計します。この処理には y が tileSize.y より小さいスレッドだけを使います。残りのスレッドの処理はここで完了します。
共有メモリ上の配列 row から横方向の画素値の重み付け和と重みの和を取り出し、その両方に縦方向の重みを乗じて、それぞれを合計します。
// tile の高さ分の数のスレッドを使って(そのほかのスレッドは終了) if (y < tileSize.y) { // 対象画素の値とその重みのペアを作る vec2 sum = vec2(0.0); // 縦方向の重み付け和を求める for (int j = 0; j < filterSize.y; ++j) { const vec2 c = row[y + j][x]; #if GAUSS const float d = float(j - filterOffset.y); const float e = exp(-0.5 * d * d / variance.y); sum += c * e; #else sum += c; #endif }
横方向の画素値の重み付け和と重みの和を縦方向に合計したら、画素値の重み付け和を重みの和で割って、出力画像 filtered の対応する画素に格納します。
// 分子を分母で割って保存する imageStore(filtered, dst_xy, vec4(sum.r / sum.g, 0.0, 0.0, 1.0)); } }
このコンピュートシェーダは、次のようにして起動します。
/// /// 計算を実行する /// /// @param width 画像の横の画素数 /// @param height 画像の縦の画素数 /// @param tile_size_x タイルの横方向のサイズ /// @param tile_size_y タイルの縦方向のサイズ /// void execute(GLuint width, GLuint height, GLuint tile_size_x = 1, GLuint tile_size_y = 1) const { glDispatchCompute((width + tile_size_x - 1) / tile_size_x, (height + tile_size_y - 1) / tile_size_y, 1); }
(まだ追記・修正するかもしれん)
Comments(6)
]]>学生さんに向けたフレームバッファオブジェクトの説明の続きです. どうも口で説明するより文章に書いた方が理解してもらえそうな気がするので, 目を "ひんむいて" よく読んでね. 君がやろうとしているように, レンダリング結果の画像を事後処理でコネコネしたければ, レンダリング結果を画面に出力せずに一旦どっかにとっておいて, それを使ってもう一度レンダリングする必要があります. そういうテクニックを遅延レンダリング (deferred rendering) といいます. んで, レンダリング結果を CPU を介さずに GPU 側にとっておく機能がフレームバッファオブジェクトです.
しかし通常のレンダリングでは, フレームバッファのカラーバッファにカラーデータ, すなわちレンダリングされた画像だけが出力されます. これを事後処理でコネコネしようと思っても, これだけでは情報が不足することがあります. レンダリングの途中には様々な中間結果が得られるので, 事後処理の時にもこれらを活用できると, できることが結構ひろがりんぐなのです.
ということで, フレームバッファオブジェクトに複数のバッファを組み込んで, レンダリング結果の出力先 (render target) を複数使えるようにする機能が, マルチプルレンダーターゲット (Multiple Render Targets, MRT) です.
前回と同様に, 単にティーポットを描くプログラムを作ります. ただし, 今回はプログラマブルシェーダを使います. 陰影計算は OpenGL の固定機能を真似て作っていますが, 手を抜いています.
プログラムの初期化時に関数 loadShader() を使ってシェーダのソースプログラムを読み込みます. loadShader() で使っている関数は別に用意することにします. あと, 陰影付けをプログラマブルシェーダで行うので, glEnable(GL_LIGHTING); なんてものはどうでもよくなります.
... /* ** シェーダオブジェクト */ #include "glsl.h" static GLuint pass1; /* ** シェーダプログラムの読み込み */ static GLuint loadShader(const char *vert, const char *frag) { ... } /* ** 初期化 */ static void init(void) { #if defined(WIN32) // GLEW の初期化 GLenum err = glewInit(); if (err != GLEW_OK) { fprintf(stderr, "Error: %s\n", glewGetErrorString(err)); exit(1); } #endif // シェーダプログラムの作成 pass1 = loadShader("pass1.vert", "pass1.frag"); ...
図形の描画の際にシェーダオブジェクト pass1 を指定して, プログラマブルシェーダを使って処理するようにします. 描画が終わったら, 次の段階 (事後処理) のためにプログラマブルシェーダの指定を解除して, 固定機能シェーダに戻しておきます.
... /* ** 画面表示 */ static void display(void) { // 透視変換行列の設定 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(30.0, (GLdouble)width / (GLdouble)height, 1.0, 10.0); // モデルビュー変換行列の設定 glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(3.0, 4.0, 5.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0); // ビューポートの設定 glViewport(0, 0, FBOWIDTH, FBOHEIGHT); // 隠面消去を有効にする glEnable(GL_DEPTH_TEST); // フレームバッファオブジェクトを結合する glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fb); // pass1 シェーダを有効にする glUseProgram(pass1); // カラーバッファとデプスバッファをクリア glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // シーンの描画 glutSolidTeapot(1.0); glFlush(); // シェーダを無効にする glUseProgram(0); // フレームバッファオブジェクトの結合を解除する glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0); ...
バーテックスシェーダは次のようになります. 陰影付けの手を抜いているので, 座標値の w 要素が 1 でないと正しく陰影付けできません. 光源も有限位置にないといけません (その割に減衰なし). また, 計算した陰影は通常 gl_FrontColor / gl_BackColor に代入してフラグメントシェーダに送りますが, ここでは陰影計算の要素ごとに分けて varying 変数に代入します.
#version 120 // // pass1.vert // varying vec4 ambient; // 環境光の反射光 A varying vec4 diffuse; // 拡散反射光 D varying vec4 specular; // 鏡面反射光 S void main(void) { vec3 position = vec3(gl_ModelViewMatrix * gl_Vertex); vec3 normal = normalize(gl_NormalMatrix * gl_Normal); vec3 light = normalize(gl_LightSource[0].position.xyz - position); // その点から光源に向かう光線ベクトル L vec3 view = -normalize(position); // その点から視点に向かう視線ベクトル V vec3 halfway = normalize(light + view); // 中間ベクトル H float nl = dot(normal, light); float nh = pow(dot(normal, halfway), gl_FrontMaterial.shininess); // 環境光 ambient = gl_FrontMaterial.ambient * gl_LightSource[0].ambient; // 拡散反射光 diffuse = gl_FrontMaterial.diffuse * vec4(vec3(nl), 1.0) * gl_LightSource[0].diffuse; // 鏡面反射光 specular = gl_FrontMaterial.specular * vec4(vec3(nh), 1.0) * gl_LightSource[0].specular; gl_Position = ftransform(); // 頂点位置の座標変換 }
フラグメントシェーダでは, varying 変数の値を合計して, 画素の色を決定します.
#version 120 // // pass1.frag // varying vec4 ambient; // 環境光の反射光 A varying vec4 diffuse; // 拡散反射光 D varying vec4 specular; // 鏡面反射光 S void main(void) { gl_FragColor = diffuse + specular + ambient; }
これで前回と同様ティーポットが描かれると思います.
ここからが本題です. それまで使っていたカラーバッファには拡散反射光を格納するものとし, 新たに鏡面反射光と環境光の反射光を格納するレンダーターゲットを追加します. そのために, まずレンダーターゲットに使うテクスチャを用意します.
... static int width, height; // スクリーンの幅と高さ #define FBOWIDTH 512 // フレームバッファオブジェクトの幅 #define FBOHEIGHT 512 // フレームバッファオブジェクトの高さ static GLuint fb; // フレームバッファオブジェクト static GLuint cb; // カラーバッファ用のテクスチャ static GLuint rb; // デプスバッファ用のレンダーバッファ static GLuint sb; // 鏡面反射光 static GLuint ab; // 環境光の反射光 ... /* ** 初期化 */ static void init(void) { #if defined(WIN32) // GLEW の初期化 GLenum err = glewInit(); if (err != GLEW_OK) { fprintf(stderr, "Error: %s\n", glewGetErrorString(err)); exit(1); } #endif // シェーダプログラムの作成 pass1 = loadShader("pass1.vert", "pass1.frag"); // カラーバッファ用のテクスチャを用意する glGenTextures(1, &cb); glBindTexture(GL_TEXTURE_2D, cb); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, FBOWIDTH, FBOHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); // 鏡面反射光を格納するテクスチャを用意する glGenTextures(1, &sb); glBindTexture(GL_TEXTURE_2D, sb); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, FBOWIDTH, FBOHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); // 環境光の反射光を格納するテクスチャを用意する glGenTextures(1, &ab); glBindTexture(GL_TEXTURE_2D, ab); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, FBOWIDTH, FBOHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); // デプスバッファ用のレンダーバッファを用意する glGenRenderbuffersEXT(1, &rb); glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, rb); glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_DEPTH_COMPONENT, FBOWIDTH, FBOHEIGHT); glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, 0);
これらをフレームバッファオブジェクトに追加します. もともとカラーバッファに使っていたテクスチャは GL_COLOR_ATTACHMENT0_EXT というバッファに割り当てていました. 残りのテクスチャはそれぞれ GL_COLOR_ATTACHMENT1_EXT と GL_COLOR_ATTACHMENT2_EXT に割り当てます.
// フレームバッファオブジェクトを作成する glGenFramebuffersEXT(1, &fb); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fb); // フレームバッファオブジェクトにカラーバッファとしてテクスチャを結合する glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, cb, 0); // フレームバッファオブジェクトに鏡面反射光の格納先のテクスチャを結合する glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT1_EXT, GL_TEXTURE_2D, sb, 0); // フレームバッファオブジェクトに環境光の反射光の格納先のテクスチャを結合する glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT2_EXT, GL_TEXTURE_2D, ab, 0); // フレームバッファオブジェクトにデプスバッファとしてレンダーバッファを結合する glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_DEPTH_ATTACHMENT_EXT, GL_RENDERBUFFER_EXT, rb); // フレームバッファオブジェクトの結合を解除する glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0);
glClearColor() で設定する背景色は, glClear() によって各カラーバッファを塗りつぶす値になります. glClear() によって, すべてのカラーバッファが同時に塗りつぶされます. 個々のバッファを個別に塗りつぶすには glClearBuffer*() を使います. なお, 事後処理の際にテクスチャの画素が背景かどうか判別できるように, 背景色のアルファ値を 0 にしておくと便利です. このほか, glEnable(GL_LIGHT0); は意味がないので削除します.
// 背景色 glClearColor(0.0f, 0.0f, 0.0f, 0.0f); }
あと, glDrawBuffers() に渡すバッファ名の配列 bufs を作っておきます. これにフレームバッファオブジェクトに組み込まれているバッファのうち, レンダーターゲットに使うもののバッファ名を列挙しておきます. この中に GL_FRONT や GL_BACK など, 画面表示を行うためのバッファを含めることもできます.
/* ** レンダーターゲットのリスト */ static const GLenum bufs[] = { GL_COLOR_ATTACHMENT0_EXT, // カラーバッファ (拡散反射光) GL_COLOR_ATTACHMENT1_EXT, // 鏡面反射光 GL_COLOR_ATTACHMENT2_EXT, // 環境光の反射光 };
そして描画時に glDrawBuffers() を使ってレンダーターゲットをしていすれば, 指定したバッファに結合されたテクスチャにレンダリング結果が入ります. 描画が終わったら glDrawBuffer() で GL_FRONT (ダブルバッファリングをしている場合は GL_BACK) を指定して, レンダーターゲットを一応もとに戻しておきます.
/*
** 画面表示
*/
static void display(void)
{
// 透視変換行列の設定
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluPerspective(30.0, (GLdouble)width / (GLdouble)height, 1.0, 10.0);
// モデルビュー変換行列の設定
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(3.0, 4.0, 5.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
// ビューポートの設定
glViewport(0, 0, FBOWIDTH, FBOHEIGHT);
// 隠面消去を有効にする
glEnable(GL_DEPTH_TEST);
// フレームバッファオブジェクトを結合する
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fb);
// レンダーターゲットを指定する
glDrawBuffers(sizeof bufs / sizeof bufs[0], bufs);
// pass1 シェーダを有効にする
glUseProgram(pass1);
// カラーバッファとデプスバッファをクリア
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// シーンの描画
glutSolidTeapot(1.0);
glFlush();
// pass1 シェーダを無効にする
glUseProgram(0);
// フレームバッファオブジェクトの結合を解除する
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0);
// レンダーターゲットを元に戻す
glDrawBuffer(GL_FRONT);
...
フラグメントシェーダでは, varying 変数の値を gl_FragColor ではなく配列変数 gl_FragData に格納します. 添え字 (0 〜 2) は glDrawBuffers() で指定したバッファ名の配列 bufs の要素に対応します.
#version 120 // // pass1.frag // varying vec4 ambient; // 環境光の反射光 A varying vec4 diffuse; // 拡散反射光 D varying vec4 specular; // 鏡面反射光 S void main(void) { gl_FragData[0] = diffuse; gl_FragData[1] = specular; gl_FragData[2] = ambient; }
ちなみに gl_FragData[0] は gl_FragColor と等価です. また, このプログラムでは光源や材質を全然指定していないので, 実は specular や ambient は 0 になってます. したがって, この状態でプログラムを実行しても, 結果は最初と全然変わりません.
レンダーターゲットに使ったテクスチャの値を参照するには, 普通にテクスチャマッピングを行います. マルチプルレンダーターゲットでは複数のテクスチャを使いますから, これらを同時に参照するためにマルチテクスチャの機能を使います.
まず, 表示領域全体を覆うポリゴンの描画にもプログラマブルシェーダを使うようにします. シェーダオブジェクトの名前を保持する変数 pass2 と, そこで使用する uniform 変数の場所を保持する変数 diffuse, specular, ambient を宣言しておきます.
... /* ** シェーダオブジェクト */ #include "glsl.h" static GLuint pass1, pass2; static GLint diffuse, specular, ambient; /* ** シェーダプログラムの読み込み */ static GLuint loadShader(const char *vert, const char *frag) { ... } /* ** 初期化 */ static void init(void) { #if defined(WIN32) // GLEW の初期化 GLenum err = glewInit(); if (err != GLEW_OK) { fprintf(stderr, "Error: %s\n", glewGetErrorString(err)); exit(1); } #endif // シェーダプログラムの作成 pass1 = loadShader("pass1.vert", "pass1.frag"); pass2 = loadShader("pass2.vert", "pass2.frag"); // pass2 シェーダの uniform 変数の場所を得る diffuse = glGetUniformLocation(pass2, "diffuse"); specular = glGetUniformLocation(pass2, "specular"); ambient = glGetUniformLocation(pass2, "ambient"); ...
このバーテックスシェーダは次のような内容にします. クリッピング空間に直接ポリゴンを描くので, モデルビュー変換や透視変換を行う必要はありません. また, このポリゴンいっぱいにテクスチャを貼り付けるので, ここで頂点の位置からテクスチャ座標を生成してしまいます. クリッピング空間の座標値は [-1, 1] の範囲にあるので, これをテクスチャ空間の座標値 [0, 1] に変換するために, 0.5 倍して 0.5 を足します. これを varying 変数 texcoord に代入して, フラグメントシェーダに渡します.
#version 120 // // pass2.vert // varying vec2 texcoord; void main(void) { texcoord = (gl_Position = gl_Vertex).xy * 0.5 + 0.5; }
フラグメントシェーダではテクスチャから値を取り出して, それをもとに画素の色を決定します. ここでは単に足しているだけです.
#version 120 // // pass2.frag // uniform sampler2D diffuse; // 拡散反射光のテクスチャユニット uniform sampler2D specular; // 鏡面反射光のテクスチャユニット uniform sampler2D ambient; // 環境光のテクスチャユニット varying vec2 texcoord; // テクスチャ座標 void main(void) { vec4 d = texture2D(diffuse, texcoord); vec4 s = texture2D(specular, texcoord); vec4 a = texture2D(ambient, texcoord); gl_FragColor = d + s + a; }
前述のとおりプログラマブルシェーダを使う場合は陰影付けをオン・オフする意味がなくなるので, glDisable(GL_LIGHTING); は削除します. モデルビュー変換行列や透視変換行列を単位行列に戻す必要もないので, これらも削除します. また, ここで固定機能シェーダに戻す必要もないので, glUseProgram(0); も削除します.
... /* ** 画面表示 */ static void display(void) { ... // シーンの描画 glutSolidTeapot(1.0); glFlush(); // レンダーターゲットを元に戻す glDrawBuffer(GL_FRONT); // フレームバッファオブジェクトの結合を解除する glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0); // ビューポートはウィンドウのサイズに合わせる glViewport(0, 0, width, height); // 隠面消去処理は行わない glDisable(GL_DEPTH_TEST);
プログラマブルシェーダを使う場合は, テクスチャマッピングの有効・無効の切り替えも意味を持ちません. 代わりに, ここでテクスチャユニットにテクスチャオブジェクトを割り当てておきます.
// テクスチャマッピングを有効にする glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, cb); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, sb); glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_2D, ab);
そしてシェーダプログラムを有効にして, そこで使っている uniform 変数に値を設定します.
// pass2 シェーダを有効にする glUseProgram(pass2); // uniform 変数に値 (テクスチャユニット番号) を設定する glUniform1i(diffuse, 0); glUniform1i(specular, 1); glUniform1i(ambient, 2);
ポリゴンを描くときに色を指定する必要はありません. またバーテックスシェーダでテクスチャ座標を生成しているので, glTexCoord*() でテクスチャ座標を指定する必要もありません.
// 正方形を描く glBegin(GL_TRIANGLE_FAN); glVertex2d(-1.0, -1.0); glVertex2d( 1.0, -1.0); glVertex2d( 1.0, 1.0); glVertex2d(-1.0, 1.0); glEnd();
最後に固定機能シェーダに戻しておきます
// シェーダを無効にする glUseProgram(0); glFlush(); }
このフラグメントシェーダ pass2.frag をいろいろいじってください.
(ああしんど)
Comments(2)
]]>このブログは「グチが多い」そうなんですけど,自分としては常にネタを仕込むことを心掛けております.しかし,久しぶりに書いた前回の記事はのっけから愚痴なってしまっていて,「うむむこれはまずい,やはり表向きにはリア充のふりをしておかなければならぬ」と思い直しております.あっ,でもリア充とかネアカとかネクラとか最近聞かんぞ,もしかしたら既に死語なのか?あっそうだ,関係ないけど(いやあるけど)この一連の記事は最初ぐっちに読まそうと思って書き始めたんだからね (グチだけに).ちゃんと読めよ.
ぐっちは粒子の運動に関して CPU でやるみたいな前提だったけど,やっぱりシェーダを使わんと色々限界があると思うんですよ.そこでコンピュートシェーダですよ(たかしはコンピュートシェーダでやろうとして四苦八苦してるみたいで,たった今も「どうやってデバッグしたらいいですか」とか聞きに来たけど).なので,とにかくコンピュートシェーダをこのサンプルプログラムに組み込んでみます.まず,Blob クラスにコンピュートシェーダのプログラムオブジェクト名を保持するメンバと,粒子の位置を更新するメソッドを追加します.Blob.h を次のように書き換えます.
//
// 粒子群オブジェクト
//
class Blob
{
// 頂点配列オブジェクト名
GLuint vao;
// 頂点バッファオブジェクト名
GLuint vbo;
// 頂点の数
const GLsizei count;
// 描画用のシェーダ
const GLuint drawShader;
// unform 変数の場所
const GLint mpLoc, mvLoc;
// 更新用のシェーダ
const GLuint updateShader;
public:
...
// 描画
void draw(const GgMatrix &mp, const GgMatrix &mv) const;
// 更新
void update() const;
};
次に,Blob.cpp を以下のように変更します.コンストラクタで追加したメンバに初期値を設定します.シェーダのソースプログラム update.comp については後述します.ggLoadComputeShader() 関数は 授業の宿題の補助プログラムで用意しています.詳細はドキュメント(PDF) を参照してください.
// コンストラクタ
Blob::Blob(const Particles &particles)
: count(static_cast<GLsizei>(particles.size()))
, drawShader(ggLoadShader("point.vert", "point.frag"))
, mpLoc(glGetUniformLocation(drawShader, "mp"))
, mvLoc(glGetUniformLocation(drawShader, "mv"))
, updateShader(ggLoadComputeShader("update.comp"))
{
...
}
update() メソッドの実装は次のようにします.実際の処理はコンピュートシェーダで行います.描画に使うシェーダは glDrawArrays() や glDrawElements() などで描画を実行(ドローコール)する際に起動されますが,コンピュートシェーダは描画処理とは関係なく独立して起動することができます.コンピュートシェーダの起動には glDispatchCompute() を使います.
コンピュートシェーダの起動に先立って,コンピュートシェーダの入出力データを準備します.ここでは,これにシェーダストレージバッファオブジェクト (Shader Storage Buffer Object, SSBO) を用います.これは頂点バッファオブジェクト(ここでは vbo)を glBindBufferBase() により GL_SHADER_STORAGE_BUFFER に結合したものです.glBindBufferBase() の第2引数は 結合ポイント (Binding Point, BP) と呼ばれ,コンピュートシェーダ側でこの番号を指定します.ここでは 0 番を指定しています.
// 描画 void Blob::draw() const { // 描画する頂点配列オブジェクトを指定する glBindVertexArray(vao); // 点で描画する glDrawArrays(GL_POINTS, 0, count); } // 更新 void Blob::update() const { // シェーダストレージバッファオブジェクトを 0 番の結合ポイントに結合する glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, vbo); // 更新用のシェーダプログラムの使用開始 glUseProgram(updateShader); // 計算を実行する glDispatchCompute(count, 1, 1); }
これを粒子群オブジェクトの描画のあとに実行します.flow.cpp を以下のように変更します.
... // // 描画 // while (window) { ... // 粒子群オブジェクトを描画する blob->draw(); // 粒子群オブジェクトを更新する blob->update(); // カラーバッファを入れ替える window.swapBuffers(); } }
GPU の性能の最大のキモは,その並列処理の能力にあります.コンピュートシェーダによる処理も,同じ処理を行う複数のスレッドによって並列に実行されます.一つのスレッドが一つの CPU コアによる処理に相当します.ワークグループはこのスレッドを複数まとめたものであり,同じワークグループに属するスレッドは,シェアードメモリを介してデータを共有することができます.一つのワークグループに属するスレッドの数は,コンピュートシェーダのソースプログラムにおいて layout 修飾子により指定します.コンピュートシェーダのソースプログラムについては,後で説明します.
glDispatchCompute() の三つの引数 num_groups_x, num_groups_y, num_groups_z は,このワークグループを起動する数を指定します.これらはいずれも,少なくとも 65536 まで指定できます.実際に指定可能な数は,glGetIntegeri_v() の第 1 引数 pname に GL_MAX_COMPUTE_WORK_GROUP_COUNT を指定して調べることができます.第 2 引数が 0 なら num_groups_x,1 なら num_groups_y,2 なら num_groups_z に指定できる最大値が得られます.
NVIDIA GeForce GTX 1080Ti では num_groups_x = 2147483647 = 231 - 1,num_groups_y = num_groups_z = 65536 でした.また Intel HD Graphics 5000 (Core i5 4250U 内蔵) はいずれも 65536 でした.
例えば 640 x 480 画素の画像を扱う場合,ワークグループのサイズを 32 x 32 x 1 スレッドで構成すれば,glDispatchCompute() では (640 / 32) x (480 / 32) x (1 / 1) = 20 x 15 x 1 のワークグループを起動すればよいことになります.また,この後に示すコンピュートシェーダのソースプログラムでは,一つのワークグループあたりスレッドを一つだけ起動している (ワークグループのサイズが 1 x 1 x 1) ため,粒子の数,すなわち count 個のワークグループを起動すればよいことになります.そのため update() メソッドでは,count x 1 x 1 のワークグループを起動しています.
Intel の HD Graphics 5000 のように num_groups_x が規格の最低値の 65536 しかないと,このようなプログラムでは割と簡単にこの制限に引っかかってしまいます.その場合は num_groups_x * num_groups_y * num_groups_z が count を超えるように設定し,コンピュートシェーダ内で GLSL の組み込み変数 gl_LocalInvocationIndex が count 未満の場合のみ計算を行うようにするなどの工夫が必要になります.
この glDispatchCompute() によって起動するコンピュートシェーダのソースプログラムは,次のようなものです.ソースファイル名は update.comp とします.この最初の layout 修飾子の local_size_x, local_size_y, local_size_z には,同時に実行するシェーダプログラムの数を指定します.local_size_x * local_size_y * local_size_z 個のスレッドが(本当に並列に実行されるかどうかは別にして)一つのワークグループとして一斉に起動します.このサンプルプログラムでは,一つのワークグループあたり 1 x 1 x 1 = 1 個のスレッドが実行されることになります.またワークグループ自体も並列に動作しますが,実際にどれだけの数のワークグループが並列に動作するかどうかも,GPU の能力次第のようです.
また,粒子データのデータ型として,Particle.h で定義している Particle 構造体と同じ構造の GLSL の構造体を Particle という名前で宣言しておきます.そして,それを要素としたバッファ Particles を定義します.std430 はこのバッファのメモリレイアウトが C/C++ 言語に準じたものであることを示し,binding には参照するシェーダストレージバッファオブジェクトが結合されている結合ポイント (BP) を指定します.このサンプルプログラムでは 0 番に粒子データを格納したシェーダストレージバッファオブジェクトを結合していました.
一つのスレッドが参照する粒子データの番号は,このサンプルプログラムでは 1 ワークグループあたり 1 スレッドにしていたので,ワークグループの位置がそのままスレッドの位置になります.また update() メソッドでは x 方向に count 個のワークグループを起動してましたから,ワークグループの位置の x 座標がスレッドの番号になります.シェーダストレージバッファオブジェクトからその番号のデータを取り出して,データを更新します.ここでは粒子の位置の z 座標を 0.1 だけずらします.
#version 430 core layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in; // 粒子データ struct Particle { vec4 position; vec4 velocity; }; // 粒子群データ layout(std430, binding = 0) buffer Particles { Particle particle[]; }; void main() { // ワークグループ ID をのまま頂点データのインデックスに使う const uint i = gl_WorkGroupID.x; // 位置を更新する particle[i].position.z += 0.1; }
この local_size_x,local_size_y,local_size_z については,それぞれ少なくとも 1024, 1024, 64 の数が指定できます.この最大値も同様に GL_MAX_COMPUTE_WORK_GROUP_SIZE によって調べることができます.また,これらの三つの値の積,すなわちワークグループ内で起動可能なスレッドの数は,少なくとも 1024 あります.この最大数は glGetIntegeriv() の第 1 引数 pname に GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS を指定して調べることができます.
NVIDIA GeForce GTX 1080Ti では 1536 でした.
これらに加えて,コンピュートシェーダ内のシェアードメモリの合計サイズにも制限があります.これは少なくとも 32KB あります.実際に使用できるシェアードメモリのサイズは,同様に GL_MAX_COMPUTE_SHARED_MEMORY_SIZE によって調べることができます.
glDispatchCompute() の引数に指定した num_groups_x, num_groups_y, num_groups_z は,GLSL の組み込み変数 gl_NumWorkGroups に格納されています.このようにワークグループやスレッドが 3 次元の格子状に配置されているのは,実際に 3 次元的に処理を行うというより,一つのワークグループやスレッドが,自分の処理するデータの領域や個々の要素を把握できるようにすることが目的のようです.一つのワークグループのデータ全体における 3 次元の位置は,GLSL の組み込み変数 gl_WorkGroupID で調べることができます.また,一つのスレッドのワークグループ内での 3 次元の位置は,gl_LocalInvocationID で調べることができます.一つのワークグループのサイズ local_size_x,local_size_y,local_size_z は gl_GlobalInvocationID で得られるので,一つのスレッドのデータ全体における位置は gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID になります.この値は gl_GlobalInvocationID に格納されています.さらに,このデータ全体を一次元に展開したときのスレッドの位置が gl_LocalInvocationIndex に入っています.
Windows 10 ってウィンドウ開くときこういうアニメーションしてたのね…
この粒子群に重力をかけてみます.粒子の位置を更新するシェーダプログラム update.comp を次のようい修正します.重力の加速度ベクトルを gravity という uniform 変数に入れておきます.また,更新するタイムステップも dt という uniform 変数に入れておきます.なお,これらを uniform 変数にしているのは,あとでこれをシェーダプログラム外から変更するかもしれないからです.しないかもしれないです.
#version 430 core layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in; // 粒子データ struct Particle { vec4 position; vec4 velocity; }; // 粒子群データ layout(std430, binding = 0) buffer Particles { Particle particle[]; }; // 重力加速度 uniform vec4 gravity = vec4(0.0, -9.8, 0.0, 0.0); // タイムステップ uniform float dt = 0.01666667;
粒子の現在の速度にタイムステップをかけたものを粒子の現在位置に加えて,粒子の位置を更新します.そのあと,重力加速度にタイムステップをかけたものを粒子の現在の速度に加えて,粒子の速度も更新します.これはオイラー法っていうやつです.
void main() { // ワークグループ ID をのまま頂点データのインデックスに使う const uint i = gl_WorkGroupID.x; // 位置を更新する particle[i].position += particle[i].velocity * dt; // 速度を更新する particle[i].velocity += gravity * dt; }
重力をかけると下に落ちて行って見得なくなってしまうので,地面を付けようと思います.地面の高さと跳ね返ったときの速度の減衰率を,それぞれ uniform 変数 height と attenuation で設定し,もし地面に落ちたら跳ね返るようにしてみます.
#version 430 core
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
...
// 重力加速度
uniform vec4 gravity = vec4(0.0, -9.8, 0.0, 0.0);
// 地面の高さ
uniform float height = -1.5;
// 減衰率
uniform float attenuation = 0.7;
// タイムステップ
uniform float dt = 0.01666667;
void main()
{
// ワークグループ ID をのまま頂点データのインデックスに使う
const uint i = gl_WorkGroupID.x;
// 位置を更新する
particle[i].position += particle[i].velocity * dt;
// 速度を更新する
particle[i].velocity += gravity * dt;
// もし地面に落ちたら
if (particle[i].position.y < height)
{
// y 方向の速度を反転する
particle[i].velocity.y = -attenuation * particle[i].velocity.y;
地面で跳ね返るので y 軸方向の速度の符号を反転し,それに減衰率 attenuation をかけます.ただし,このままだと地面に粒子がめり込んだまま,跳ね返っても二度と地表に出られないということになってしまいます.粒子の位置の更新は連続的に行っているわけではなく,画面の再表示タイミングに合わせて周期的に行っています.そのため,粒子が地面に落ちたと判定されたときの粒子の位置は,既に地面の下にあります.そのため,そこから粒子の向きを反転しても,減衰によってさらにその次のタイミングでも地面の下にあるということが起こります.
したがって,本当は粒子が地面で跳ね返ったあとの,次のタイミングでの位置を求める必要があります.しかし,めんどくさいので,粒子の高さ (y 座標値) を無理やり地面の高さにするという非常に安直な方法を採用します.こういうことをすると,本来は高さの異なる粒子が同じ高さにそろえられてしまって不自然に見えるため,良い子はここでちゃんと粒子の位置を計算するようにしましょう.宿題だ宿題!
// 高さを地面の高さに戻す particle[i].position.y = height; } }
ただですね,ここで動かしてみるとわかると思いますが,地面に落ちた粒子がいつまでもブルブル震えていると思います.粒子が地面付近で地表と地下を行ったり来たりしているのですね.そこで,ここでもう一つ安直な手段を採用します.速度を更新している部分と位置を更新している部分を入れ替え,速度を更新してから,その位置を使って位置を更新します.次のタイミングでの速度を使って現在の位置を更新するわけです.一応,これはシンプレクティック (Symplectic) 法っていう根拠のある方法らしいです.
void main() { // ワークグループ ID をのまま頂点データのインデックスに使う const uint i = gl_WorkGroupID.x; // 速度を更新する particle[i].velocity += gravity * dt; // 位置を更新する particle[i].position += particle[i].velocity * dt; // もし地面に落ちたら if (particle[i].position.y < height) { // 高さを地面の高さに戻す particle[i].position.y = height; // y 方向の速度を反転する particle[i].velocity.y = -attenuation * particle[i].velocity.y; } }
これで落ち着くと思います.
単位は繰り返し落としたりしないよう万全の注意を払っていただきたいものですが,このサンプルプログラムでは粒子は一度しか落とされないので,粒子が地面に落ちたあと止まってしまって面白くありません.そこで,粒子を繰り返し落とすようにしてみます.flow.cpp において定期的に粒子の初期値を設定する initialize() メソッドを呼び出すようにします.まず,繰り返しの間隔を決めます.
// // アプリケーション本体 // #include "GgApplication.h" ... // 一つの粒子群の中心からの距離の標準偏差 const GLfloat pDeviation(0.3f); // アニメーションの繰り返し間隔 const double interval(5.0); ...
そして図形の描画のループで経過時刻を調べ,それが繰り返しの間隔を超えていたら粒子の位置を元に戻し,時計をリセットします.関係ないけど,GLFW には今のところタイマーで関数を呼び出す機能はないみたいです.
... // // 描画 // while (window) { // 定期的に粒子群オブジェクトをリセットする if (glfwGetTime() > interval) { blob->initialize(initial); glfwSetTime(0.0); } ... // カラーバッファを入れ替える window.swapBuffers(); } }
最後に,粒子群の生成の際には速度を設定するようにしてみます.粒子が多少派手に散らばるように,一つの粒子群の中心からの距離の標準偏差 pDeviation も大きくしてみます.
//
// アプリケーション本体
//
#include "GgApplication.h"
...
// 一つの粒子群の中心からの距離の標準偏差
const GLfloat pDeviation(1.0f);
// アニメーションの繰り返し間隔
const double interval(5.0);
粒子の位置は粒子群の中心位置 (cx, cy, cz) とし,もともと粒子群の中心からの相対位置として求めていたもの (r * sp * ct, r * sp * st, r * cp) を速度に用います.
//
// 粒子群の生成
//
// paticles 粒子群の格納先
// count 粒子群の粒子数
// cx, cy, cz 粒子群の中心位置
// rn メルセンヌツイスタ法による乱数
// mean 粒子の粒子群の中心からの距離の平均値
// deviation 粒子の粒子群の中心からの距離の標準偏差
//
void generateParticles(Particles &particles, int count,
GLfloat cx, GLfloat cy, GLfloat cz,
std::mt19937 &rn, GLfloat mean, GLfloat deviation)
{
...
// 原点中心に直径方向に正規分布する粒子群を発生する
for (int i = 0; i < count; ++i)
{
// 緯度方向
const GLfloat cp(uniform(rn) - 1.0f);
const GLfloat sp(sqrt(1.0f - cp * cp));
// 経度方向
const GLfloat t(3.1415927f * uniform(rn));
const GLfloat ct(cos(t)), st(sin(t));
// 粒子の粒子群の中心からの距離 (半径)
const GLfloat r(normal(rn));
// 粒子を追加する
particles.emplace_back(cx, cy, cz, r * sp * ct, r * sp * st, r * cp);
}
}
こんな感じになります.これも一つの粒子群の中心からの距離の平均 pMean や一つの粒子群の中心からの距離の標準偏差 pDeviation を色々変えてみてください.
研究室のミーティングで学生さんに対して「当たり前田のクラッカー!」っていう具合に突っ込みを入れたら,「昭和テイストだなぁー」とか「世代が違いますよ,世代が」とか散々な言われ方をしました.私,やっぱり旧い人なんでしょうか?(聞くまでもないか)そういえば以前,奥さんに「おさじとって」と言ったら,「はい,スプーン」と訂正されてしまいました.昭和は遠くなったもんだ(違
シェーダプログラムの中でもテクスチャを参照することができます.もちろん,マルチテクスチャが使えます.複数のテクスチャを組み合わせた処理を手続きで書けるってあたりが,シェーダプログラミングの醍醐味でしょう.
とりあえず,前回のプログラムにテクスチャに使う画像を読み込む処理を追加します.テクスチャには,以前に使ったこの画像を使います.なお本題とは関係ありませんが,以下のプログラムでは OpenGL 1.4 で標準機能となったミップマップの自動生成機能を使用しています.
・・・ /* ** テクスチャ */ #define TEXWIDTH 256 /* テクスチャの幅 */ #define TEXHEIGHT 256 /* テクスチャの高さ */ static const char texture1[] = "dot.raw"; /* テクスチャファイル名 */ /* ** 初期化 */ static void init(void) { /* シェーダプログラムのコンパイル/リンク結果を得る変数 */ GLint compiled, linked; /* テクスチャの読み込みに使う配列 */ GLubyte texture[TEXHEIGHT][TEXWIDTH][4]; FILE *fp; ・・・ /* シェーダプログラムの適用 */ glUseProgram(gl2Program); /* テクスチャ画像の読み込み */ if ((fp = fopen(texture1, "rb")) != NULL) { fread(texture, sizeof texture, 1, fp); fclose(fp); } else { perror(texture1); } /* テクスチャ画像はバイト単位に詰め込まれている */ glPixelStorei(GL_UNPACK_ALIGNMENT, 4); glTexParameteri(GL_TEXTURE_2D, GL_GENERATE_MIPMAP, GL_TRUE); /* テクスチャを拡大・縮小する方法の指定 */ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); /* テクスチャの繰り返し方法の指定 */ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); /* テクスチャの割り当て */ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, TEXWIDTH, TEXHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, texture); /* 初期設定 */ glClearColor(0.3, 0.3, 1.0, 0.0); glEnable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); ・・・
図形描画の際にテクスチャ座標を設定しておきます.シェーダプログラムを使う場合は,glEnable(GL_TEXTURE_2D); を実行する必要はありません.
・・・ /* ** シーンの描画 */ static void scene(void) { static const GLfloat diffuse[] = { 0.6, 0.1, 0.1, 1.0 }; static const GLfloat specular[] = { 0.3, 0.3, 0.3, 1.0 }; /* 材質の設定 */ glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, diffuse); glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, specular); glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 100.0f); #if 1 /* 1枚の4角形を描く */ glNormal3d(0.0, 0.0, 1.0); glBegin(GL_QUADS); glTexCoord2d(0.0, 1.0); glVertex3d(-1.0, -1.0, 0.0); glTexCoord2d(1.0, 1.0); glVertex3d( 1.0, -1.0, 0.0); glTexCoord2d(1.0, 0.0); glVertex3d( 1.0, 1.0, 0.0); glTexCoord2d(0.0, 0.0); glVertex3d(-1.0, 1.0, 0.0); glEnd(); #else glutSolidTeapot(1.0); #endif } ・・・
これでテクスチャマッピングの準備は完了です.試しに関数 init() の最後にある glUseProgram(gl2Program); をコメントアウトしてプログラムをコンパイル・実行し,テクスチャが正しく貼れるか確かめてください(確かめたらプログラムを元に戻してください).
/* シェーダプログラムの適用 */ glUseProgram(gl2Program);
シェーダプログラムは前回作成した Phong シェーディングのものをもとにして作成します.phong.vert と phong.frag をそれぞれ texture.vert と texture.frag というファイル名にコピーしてください.
バーテックスシェーダでは,テクスチャ変換行列 gl_TextureMatrix[0] を処理対象の頂点のテクスチャ座標 gl_MultiTexCoord0 に掛けて,組み込み varying 変数 gl_TexCoord[0] に代入します.gl_MultiTexCoord0 はテクスチャユニット0に設定されたテクスチャ座標です.gl_TexCoord は配列変数になっていますが,添え字の番号とテクスチャユニットは無関係(ただし総数は同じ)なので,gl_TexCoord のどの要素にどのテクスチャユニットのテクスチャ座標を入れても構いません.
// texture.vert varying vec4 position; varying vec3 normal; void main(void) { position = gl_ModelViewMatrix * gl_Vertex; normal = normalize(gl_NormalMatrix * gl_Normal); gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0; gl_Position = ftransform(); }
なお,テクスチャ変換行列を使わない場合は,gl_TextureMatrix[0] を gl_MultiTexCoord0 にかける必要はありません.
2次元テクスチャは GLSL の組み込み関数 texture2DProj() を使ってサンプリングします.変数 texture は sampler2D 型の uniform 変数で,どのテクスチャユニットからどういう方法でテクスチャをサンプリングするかを指定します.この変数 texture の値(すなわちテクスチャユニット番号)の設定は,アプリケーションプログラム側で行います.
以下のプログラムでは,gl_TexCoord[0] に格納されているテクスチャ座標をテクスチャ変換行列 gl_TextureMatrix[0] により変換し,その結果を使って texture で指定されたテクスチャユニットが保持するテクスチャをサンプリングします.そして,サンプリングした色 color を使って拡散反射光強度と環境光の反射光強度を計算します.
// texture.frag uniform sampler2D texture; varying vec3 position; varying vec3 normal; void main (void) { vec4 color = texture2DProj(texture, gl_TexCoord[0]); vec3 light = normalize((gl_LightSource[0].position * position.w - gl_LightSource[0].position.w * position).xyz); vec3 fnormal = normalize(normal); float diffuse = max(dot(light, fnormal), 0.0); vec3 view = -normalize(position.xyz); vec3 halfway = normalize(light + view); float specular = pow(max(dot(fnormal, halfway), 0.0), gl_FrontMaterial.shininess); gl_FragColor = color * gl_LightSource[0].diffuse * diffuse + gl_FrontLightProduct[0].specular * specular + color * gl_LightSource[0].ambient; }
もちろん,gl_LightSource[0].diffuse と color * gl_LightSource[0].ambient は color でくくることができます.
// texture.frag uniform sampler2D texture; varying vec3 position; varying vec3 normal; void main (void) { vec4 color = texture2DProj(texture, gl_TexCoord[0]); vec3 light = normalize((gl_LightSource[0].position * position.w - gl_LightSource[0].position.w * position).xyz); vec3 fnormal = normalize(normal); float diffuse = max(dot(light, fnormal), 0.0); vec3 view = -normalize(position.xyz); vec3 halfway = normalize(light + view); float specular = pow(max(dot(fnormal, halfway), 0.0), gl_FrontMaterial.shininess); gl_FragColor = color * (gl_LightSource[0].diffuse * diffuse + gl_LightSource[0].ambient) + gl_FrontLightProduct[0].specular * specular; }
uniform 変数の値はアプリケーションプログラムで設定します.これは,まずglGetUniformLocation() 関数を使ってプログラムオブジェクト内から値を設定する uniform 変数を探し出し,その識別子を得ます.そして glUniform*() 関数を使って,その識別子に対して値を設定します.ここではフラグメントシェーダプログラムの texture という uniform 変数に値を設定しますから,次のような手続きになります.
・・・ /* ** 初期化 */ static void init(void) { ・・・ /* シェーダオブジェクトの作成 */ vertShader = glCreateShader(GL_VERTEX_SHADER); fragShader = glCreateShader(GL_FRAGMENT_SHADER); /* シェーダのソースプログラムの読み込み */ if (readShaderSource(vertShader, "texture.vert")) exit(1); if (readShaderSource(fragShader, "texture.frag")) exit(1); ・・・ /* シェーダプログラムの適用 */ glUseProgram(gl2Program); /* テクスチャユニット0を指定する */ glUniform1i(glGetUniformLocation(gl2Program, "texture"), 0); /* テクスチャ画像の読み込み */ if ((fp = fopen(texture1, "rb")) != NULL) { fread(texture, sizeof texture, 1, fp); fclose(fp); } else { perror(texture1); } ・・・
以上によりシェーダプログラムの中でテクスチャを参照することが可能になります.
Comments(12)
]]>定年であれなんであれ退職すれば当然このサイトは無くなるわけでして,それなりに色々作ってきただけにそのまま消してしまうのももったいない気がします.また,うちのファイアウォールと tDiary の相性が悪いみたいで,tDiary が 304 返してるのに何故かファイアウォールがそれを通してくれず,このサイトに外部から連続してアクセスできないというトラブルも抱えています.それでサイトを移転しようかななどと考えてみたりするのですが,なんだか結構な分量があって途方に暮れています.まあ,この領域では古い情報にそれほど価値があるとも思えないので,人知れず消えてしまうのも世の定めかなとも思います.
これは持ち回りの演習科目で,私は一つあたり2日かける課題を二つ用意しています.どちらも「どうやったらプログラムが書けるようになるか」ってことを念頭に置いているのですが,課題を消化することが目的化しないように,試行錯誤しながらプログラムを作る面白さみたいなものを味わってもらえないかと思いながら作り込んで(いるつもり)です.でも,なかなか思う通りにはいきません.アドバイスがあったらください.
このページには PDF を埋め込んでいますが,Safari だと多分見えません
前半の課題はビデオエフェクトを作ってみようというものです.OpenGL と OpenCV を組み合わせたテンプレートを用意しています.プロジェクトの中に OpenCV 一式を組み込んだので,サイズがやたらでかくなっています.
これ,学生さんが OpenCV と OpenGL を組み合わせて使うときのサンプルとして使えるようにと考えてたんだけど,やっぱり課題は課題であって,課題が終わったら存在を忘れられるのよね…
後半の課題はばね質点モデルでアニメーションを描くというものです.狙いは数式で表されたモデルをプログラムコードに置き換えるという「感覚」みたいなものを掴むところにあります.これは一つのプロジェクトで Windows と macOS の両方に対応しています.
Comments(11)
]]>固定機能ハードウェアは処理内容をハードウェアで実装しているので, 同じ処理内容 (かつ, 同程度のハードウェア量・クロック) なら, 一般的にプログラム可能ハードウェアよりも高速です. しかし, ハードウェアで実装すると「できること」が決まってしまうので, 多様な処理に対応するために, ある程度汎用的に作っておかなければなりません. そのため, 処理内容によっては不要な手順が含まれてしまうことがあります. その点でプログラム可能ハードウェアは, 目的を達成するのに最適な手順が選択できるため, 同じ目的に対して固定機能ハードウェアより高速な処理が行える可能性があります.
授業でやってる内容なので (まだの人もいるけど), どのあたりからおさらいを始めればいいのか悩ましいところですが, ざっと解説します. 適当に書いているので間違っているところがあるかもしれません. 教科書などで確認しておいてください.
光源から物体表面 (屈折率の異なる媒質の境界面) に入射した光は, そこで正反射光と屈折光に分かれます. 屈折光は物体の内部に進入します. 一方, 正反射光は物体の表面で反射するので, 物体の内部には進入しません.
現実の物体表面には滑らかに見えても微小な凹凸があり, それによって反射方向や屈折方向が変化するので, 正反射光や屈折光の方向は多少なりとも分散します.
物体の内部に進入した光は, そこで散乱と吸収を繰り返し, 入射光の色成分のうち吸収されずに残ったものが, 再び光の入射位置から外部に放射され (ると考え) ます. この光を拡散反射光 (diffuse light) と呼びます. 吸収により拡散反射光には物体の色が付きます. この物体の色に相当する材質の特性 (material) を拡散反射係数と呼びます. また散乱により拡散反射光は指向性を失い, 全ての方向に均等に放射され (ると考え) ます (完全拡散反射面). なお, この考え方は金属には当てはまりません. 金属内に進入した光が, 散乱により外部に放出されることはありません (後述).
この拡散反射光の強度は, 入射光の強度に比例します. そして, 入射光の強度は, 入射の密度に比例します. いま, 幅 d の平行光線が入射角 θ で物体表面を照らしているとすれば, 入射光の照射面積は d /cosθ になります. 密度は面積に反比例しますから, 入射角 θ の入射光の密度は, 正面から入射した場合の cosθ 倍になります. これを Lambert の余弦法則と呼びます. ここで照射面の法線単位ベクトルを n, 照射点から光源方向を向いた光源方向単位ベクトルを l とすれば, cosθ = n⋅l となります. ただし, cosθ < 0 (θ < -π/2 または θ > π/2) の場合は, 光源が物体表面の「裏側」にあることになるので, 入射光の密度は 0 とします.
余談ですが, 現実の物体では, 拡散反射光が入射光の入射位置から放射されるという仮定は成り立ちません. 物体内に進入した光は物体内で散乱して, 入射位置からずれたところから放射されます (表面下散乱, subsurface scattering). これを正確に再現することは結構大変なので (計算時間がかかる), リアルタイムレンダリングでは入射位置から放射されるという仮定を用いるのが一般的です (局所表面下散乱, local subsurface scattering). 実際, このように仮定しても大抵うまくいくのですが, 光が透過する半透明の物体はうまく表現できません. 例えば, 牛乳が石膏のように見えたり, 人肌がコンシーラーを塗りたくったように見えたりしてしまいます.
入射光の正反射光のうち, 視点に届くものを鏡面反射光 (specular light) と呼びます. 正反射光は正反射方向を軸に分散しますので, 鏡面反射光の強度は視点方向 v がこの軸から離れるにしたがって小さくなります.
この割合を求めるには二通りの方法が用いられています. 一つは視点方向 v と正反射方向 r = 2(n⋅l)n - l のなす角にもとづく方法であり, もう一つは光源方向 l と視点方向 v の中間方向 h = (l + v) / |l + v| と照射面の法線 n のなす角 (φ) にもとづく方法です. 前者は光源の映り込みをモデル化したものと考えられ, 後者は物体表面の微小な凹凸によって入射光が視線方向に正反射される割合をモデル化したものだと考えられます.
同じことを表現するのに二通りの方法がある背景には, 光源を「点」でモデル化していることがあります. 実際の光源は「形」, すなわち光の放射面積を持っていますが, そのような光源を用いた陰影計算はやはり大変になるので (これも計算時間がかかる), リアルタイムレンダリングでは光源を点だと仮定するのが一般的です (平行光線も光源が無限遠にあると考えるので形は無限に小さくなって点になります). しかし, 光源が点だと光が出ているのに姿が見えないことになってしまいます. そこで正反射光に「広がり」を持たせる必要があるのですが, そのための方法が二通りあるということでしょう.
これにより, 前者の方法では球状の光源が映り込んだようなハイライトが得られるのに対して (下図左), 後者の方法では l と v が作る平面に沿った方向に伸びたハイライトが得られます (下図右). ハイライトを光源の映り込みだと捉えれば前者の方がそれらしい結果になりますが, 現実を観察してみると後者のような状況がよく見られます (濡れた路面のヘッドライトの映り込みが縦に伸びているとか). また, 物体表面の微小な凹凸をモデル化する微小面理論 (microfacet theory) は, 後者の考え方をベースにしています (というか, 微小面理論から後者の考え方が出てきた).
正反射光は物体の内部を通過していない (と考える) ので, 非金属の物質では物体の色の影響を受けることなく光源と同じ色になります. これに対して, 金属の正反射光には色が付く場合があります (金色とか銅赤色とか). 金属の反射光は, 金属表面のごく浅い部分にある自由電子が入射光のエネルギーによって共鳴振動を起こし, その振動エネルギーを放出することによって発生します (キリヤ: Q&A). このエネルギーの吸収と放出が入射光の波長に対して選択的に行われるために, 反射光に色が付く (分光分布が変化する) と考えられます. したがって, この場合は鏡面反射光に色をつけて表現します (下図左). この鏡面反射光に色を付ける材質の特性を鏡面反射係数と呼びます. 金属でなければ, 鏡面反射係数には通常グレー (入射光の分光分布を変化させない色) を用います (下図右).
なお, 金属では拡散反射光が存在しませんが, 現実の金属には表面の汚れや腐食・酸化等による反射光や, 表面の微小な凹凸による正反射光の広がりなどによって拡散反射光 (のように見えるもの) が存在することがあります. また拡散反射光が全く無いと, (映り込みの処理をしない場合) 鏡面反射光が弱い部分が真っ黒になり, かえって不自然に見えてしまいます. そこで, 金属にも鏡面反射係数と似た色の拡散反射係数を設定する (ことによってごまかす) 場合があります.
鏡面反射係数と拡散反射係数の和が 1 を超えることは (通常) ありません (1 を超えたら入射光が増幅されることになります). この配分は Fresnel の式により入射光の入射角と波長に依存しますが, リアルタイムレンダリングでは一般的に定数が用いられます. ただし, 入射角が浅いときに鏡面反射光が強く出る現象 (Fresnel 反射) を再現する場合や, 微小面理論にもとづく陰影付けなどでは, Fresnel の式を取り入れます. また (陰影計算ではなく) 映り込みの処理により水面の表現を行う場合などでも, この配分を視線の入射角にしたがって変化させることがあります.
これまでは光源から照射点に直接届く直接光のみについて考えてきました. 照射点には直接光のほかに, 周囲の物体で1回以上反射した間接光も届きます. 間接光は直接光と異なり, 複数の複雑な経路を経ます.
したがって照射点における陰影を正確に求めるには, 直接光・間接光の区別なく, その点に届く全ての光について視点方向に向かう反射光の強度を求め, それらの総和 (積分) を求める必要があります. また, この点から放射された光が他の点に届き, それがめぐりめぐって再びこの点に届くことも考慮しなければなりません.
しかし, リアルタイムレンダリングではとてもそんなことしてられないので, 陰影を決める主要な要素である光源からの直接光のほかは, 環境光として定数にまとめてしまいます. ただし, これはかなりリアリティを損なう結果になります. 直接光が存在しなければ陰影は環境光によるものだけになりますが, これを定数にすると立体感が消えてしまいます.
照射点に到来する間接光の強さの方向分布は一様ではなく, また照射点の位置によっても変化するため, 間接光しか存在しない場合でも陰影は変化します. 例えば, 込み入ったところには光は届きにくいので, そのような領域は暗くなります.
このような陰影は大域照明 (global illumination) モデルを解くことによって得られます. しかしリアルタイムレンダリングでは, 複数の光源をシーン中の至る所に配置してそれらしく見せたりします.
リアルタイムレンダリングでは, これまで表面下散乱をはしょったり, 光源を点とみなしたり, 分光分布を赤・緑・青の三原色で表したり, 屈折率を定数にしたり, 環境光を定数にしたりして, 速度を稼いできました. しかし, このような「近似」を行った結果, 速度と引き換えにリアリティが犠牲になってしまいました. 速度とリアリティはトレードオフの関係にあります.
現在はグラフィックスハードウェアの性能や機能が大幅に向上したこともあって, リアリティを向上させる反面これまで時間がかかっていた処理をどうやってリアルタイムに実現するかというところが競われています. 前述の「近似」を行っていた対象に対しても, より精密な解をリアルタイムに求める手法が既にいくつも提案されています. 競争は激しいけど, やること (やれること) はまだいっぱいあります (と思いたい).
陰影計算には図形表面における法線ベクトルが必要になりますが, 今描いているのは半径 1 の単位球なので, 頂点の位置をそのままその点における法線ベクトルに使えます. また, 光源に向かうベクトル (光線ベクトル) が z 軸 (0, 0, 1) と一致していれば, 拡散反射光強度は光線単位ベクトルと法線単位ベクトルの内積に比例する (Lambert の余弦法則) ので, これは頂点の z 座標値になってしまいます. そこで, 試しにバーテックスシェーダで頂点の z 座標値を拡散反射色としてフラグメントシェーダに渡してみます.
バーテックスシェーダで vec3 型の varying 変数 diffuseColor を宣言し, これに頂点の座標値の z 成分 position.z を代入します. position.z は負の値になることがありますが, 今は気にしないことにします. vec3() によって diffuseColor の3つの要素の全てに同じ値が代入されます.
#version 120 // // simple.vert // invariant gl_Position; attribute vec3 position; uniform mat4 projectionMatrix; varying vec3 diffuseColor; void main(void) { diffuseColor = vec3(position.z); gl_Position = projectionMatrix * vec4(position, 1.0); }
フラグメントシェーダでは, varying 変数 diffuseColor をそのまま gl_FragColor に出力します. gl_FragColor に代入された値は [0, 1] にクランプされるので, diffuseColor が負になっていても (多分) 問題ありません.
#version 120 // // simple.frag // varying vec3 diffuseColor; void main(void) { gl_FragColor = vec4(diffuseColor, 1.0); }
これだけで, このような陰影を付けることができます.
それでは, 光源の方向 (光線ベクトル) を設定してみます. 光源の方向は, 例えば (10.0, 6.0, 3.0) とします. このベクトルを GLSL の組み込み関数 normalize() を使って正規化し, vec3 型の const 変数 (というか定数) lightDirection に格納しておきます. これと面の法線ベクトル position との内積を求めます. ベクトルの内積には GLSL の組み込み関数 dot() を用います.
#version 120 // // simple.vert // invariant gl_Position; attribute vec3 position; uniform mat4 projectionMatrix; varying vec3 diffuseColor; const vec3 lightDirection = normalize(vec3(10.0, 6.0, 3.0)); void main(void) { diffuseColor = vec3(dot(position, lightDirection)); gl_Position = projectionMatrix * vec4(position, 1.0); }
この正規化は main() の外で宣言している const 変数の初期化の際に行っているので, シェーダプログラムの呼び出しのたびに行われることは多分ない (ので, シェーダの負荷にはならない) と思うのですが, GLSL のコンパイラがどんなコードを吐いているのかは知りません.
次に, 光源の色と物体表面の色 (拡散反射係数) を設定してみます. 拡散反射光の放射輝度 (radiance) Ld は光源の放射照度 (irradiance) を EL, 光線の入射角を θi, 物体表面の拡散反射色を cd とするとき, Ld = (cd /π) EL cosθi で求められます. ここで cosθi = n⋅l です. また, 面倒なので kd = cd /π とし, これを拡散反射係数とします.
kd や EL は光の三原色 RGB ごとに指定します. で, これらに設定する値なんですが, ちょっと気をつけなければいけないことがあります. 前に gl_FragColor に代入された値は [0, 1] にクランプされると説明しましたが, このために, ここで計算する放射輝度 Ld がこの範囲に収まるようにしなければなりません. gl_FragColor に 0 を設定すると, 表示装置 (ディスプレイ) には最も暗い色が表示され, 1 を設定すれば最も明るい色が設定されます. しかし, 現実の世界にある「明るさ」を, 表示装置が全て表示可能なわけではありません. したがって, 現実の世界での「値」を直接 kd や EL に設定すると, 表示装置上で明るさが飽和してしまったり, 逆に暗くなりすぎたりしてしまいます. もちろん, これを回避する手法 (HDR レンダリング) も提案されていますが, OpenGL の固定機能ハードウェアでは, これらの値の範囲を [-1, 1] の範囲にクランプするという手段を採用していました.
じゃあ, ここではどうするかなんですけど, とりあえず kd は [0, 1] の間で設定すべきでしょう. 一方 EL は, おそらくとても大きな値を設定すべきだと思いますが, そうすると計算結果を gl_FragColor が許容できる範囲に収めるために, kd を十分小さくする必要があります. これは, 直接光の放射照度が間接光の放射照度に比べてずっと小さい (ことが多い) と考えれば合理的ですが, これらの値は計算結果が飽和しないように慎重に決めなければなりません.
そこで, やっぱりめんどくさいので, ここでも光源の (放射照度ではなく) 「明るさ」を [0, 1] の範囲で設定することにしたいと思います. ああ, 気弱だ.光源の明るさを黄色 (1.0, 1.0, 0.0) として const 変数 lightColor に設定し, 拡散反射係数 kd をシアン (0.0, 1.0, 1.0) として const 変数 diffuseMaterial に設定します. そして, 陰影を計算する際に, これらを掛け合わせます.
#version 120 // // simple.vert // invariant gl_Position; attribute vec3 position; uniform mat4 projectionMatrix; varying vec3 diffuseColor; const vec3 lightDirection = normalize(vec3(10.0, 6.0, 3.0)); const vec3 lightColor = vec3(1.0, 1.0, 0.0); const vec3 diffuseMaterial = vec3(0.0, 1.0, 1.0); void main(void) { diffuseColor = vec3(dot(lightDirection, position)) * lightColor * diffuseMaterial; gl_Position = projectionMatrix * vec4(position, 1.0); }
すると, こういう色になります.
材質の情報 (拡散反射係数しかないけど) や光源の情報は, 今のところシェーダプログラムに埋め込んでいます. 材質の情報はシェーダプログラムに埋め込んでしまっても構わないと思いますけど, 光源の情報は複数のシェーダプログラムの間で共有することが多いでしょうから, これは少し都合が悪いかも知れません. そこで, 光源の情報はアプリケーションプログラムから設定するようにします.
まず, バーテックスシェーダで光源の方向と位置を設定している const 変数 lightDirection と lightColor を uniform 変数に変更します. もちろん, これらを初期化している値は削除します.
#version 120 // // simple.vert // invariant gl_Position; attribute vec3 position; uniform mat4 projectionMatrix; varying vec3 diffuseColor; uniform vec3 lightDirection; uniform vec3 lightColor; const vec3 diffuseMaterial = vec3(0.0, 1.0, 1.0); void main(void) { diffuseColor = vec3(dot(lightDirection, position)) * lightColor * diffuseMaterial; gl_Position = projectionMatrix * vec4(position, 1.0); }
次に, メインプログラムで, 光源の方向と色を保持する配列変数 lightDirection と lightColor を宣言します. 光源の方向は正規化しておきます. 光源の色は, 今度は白にします. また, uniform 変数の場所を保持する変数 lightDirectionLocation と lightColorLocation も宣言しておきます.
... /* ** 図形 */ static GLuint points; extern GLuint wireCube(const GLuint *buffer); extern GLuint wireSphere(int slices, int stacks, const GLuint *buffer); extern GLuint solidSphere(int slices, int stacks, const GLuint *buffer); /* ** 光源 */ static GLfloat lightDirection[] = { 0.83f, 0.50f, 0.25f }; static GLfloat lightColor[] = { 1.0f, 1.0f, 1.0f }; static GLint lightDirectionLocation, lightColorLocation; /* ** 画面表示 */ ...
そして, シェーダプログラムをリンクした後に lightDirectionLocation と lightColorLocation に uniform 変数の場所を保存しておきます.
... /* ** 初期化 */ static void init(void) { ... /* uniform 変数 projectionMatrix の場所を得る */ projectionMatrixLocation = glGetUniformLocation(gl2Program, "projectionMatrix"); /* uniform 変数 lightDirection の場所を得る */ lightDirectionLocation = glGetUniformLocation(gl2Program, "lightDirection"); /* uniform 変数 lightDirection の場所を得る */ lightColorLocation = glGetUniformLocation(gl2Program, "lightColor"); /* 頂点バッファオブジェクトを2つ作る */ glGenBuffers(2, buffer); ...
最後に, 描画の際, シェーダプログラムを適用した後に, unform 変数 (の場所) に対して値を設定します.
... /* ** 画面表示 */ static void display(void) { ... /* シェーダプログラムを適用する */ glUseProgram(gl2Program); /* uniform 変数 projectionMatrix に行列を設定する */ glUniformMatrix4fv(projectionMatrixLocation, 1, GL_FALSE, projectionMatrix); /* uniform 変数 lightDirection に光源の方向を設定する */ glUniform3fv(lightDirectionLocation, 1, lightDirection); /* uniform 変数 lightColor に光源の色を設定する */ glUniform3fv(lightColorLocation, 1, lightColor); /* index が 0 の attribute 変数に頂点情報を対応付ける */ glEnableVertexAttribArray(0); ...
ここらでとりあえずひと区切り付けます.
Comments(3)
]]>大学の周辺の山で大規模な宅地造成が行われています.みるみるうちにいろんな建物が建っていくのですが,少し前に,大学からこういう建物が見えることに気づきました.
パルテノン神殿 (@_@;)
聞くところによると,これは住宅地の給水施設だという話です.この団地は国道からの入り口にロダンの「考える人」のレプリカを置いてみたり,なかなか楽しいものをいろいろ作っています.先日この建物の下あたりをのぞきに行ったら,「パルテノン公園」という公園がありましたから,やっぱりこれはパルテノン神殿なんでしょう.多分,この建物から撮影されたと思われる QuickTime VR の画像があります.
書き忘れたと思っていたことの四つ目です.これまでは学生さん向けのチュートリアルを前提に書いてきたので,何も考えずに glBegin() / glEnd() を使っていました.しかし実際のアプリケーションでは,もう頂点配列や Vertex Buffer Object (VBO) を使うのが当たり前だと思います.頂点配列はトゥーンシェーディングの時に使っていましたけど,説明していませんでしたので,改めて書きとめておこうと思います.
データは全て三角形とします.まず,頂点位置を保存する配列 vert と,その頂点における法線ベクトル norm,その頂点におけるテクスチャ座標 texc,およびどの頂点を結んで一つの三角形を構成するのかを表した頂点のインデックス face という三つの配列があったとします.
/* 頂点データ */ static GLfloat vert[][3] = { ... }; /* 法線データ */ static GLfloat norm[][3] = { ... }; /* テクスチャ座標 */ static GLfloat texc[][2] = { ... }; /* 頂点のインデックス */ static GLuint face[][3] = { ... };
また,このデータに含まれる三角形の数を nf とします.
/* 三角形の数 */ static int nf = sizeof face / sizeof face[0];
この図形は,glBegin() / glEnd() を使うと,次の手順で描くことができます.
/* ** 図形の表示 */ void display(void) { int i; ... glEnable(GL_TEXTURE_2D); glBegin(GL_TRIANGLES); for (i = 0; i < nf; ++i) { int i0 = face[i][0], i1 = face[i][1], i2 = face[i][2]; glTexCoord2fv(texc[i0]); glNormal3fv(norm[i0]); glVertex3fv(vert[i0]); glTexCoord2fv(texc[i1]); glNormal3fv(norm[i1]); glVertex3fv(vert[i1]); glTexCoord2fv(texc[i2]); glNormal3fv(norm[i2]); glVertex3fv(vert[i2]); } glEnd() glDisable(GL_TEXTURE_2D); ... }
頂点配列を使うと,これを次のように書くことができます.
/*
** 図形の表示
*/
void display(void)
{
...
最初にクライアント(アプリケーション)側に置く頂点,法線,およびテクスチャ座標の配列を有効にします.
/* 頂点データ,法線データ,テクスチャ座標の配列を有効にする */ glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glEnableClientState(GL_TEXTURE_COORD_ARRAY);
次に,これらのデータが格納されている場所を指定します.
/* 頂点データ,法線データ,テクスチャ座標の場所を指定する */ glVertexPointer(3, GL_FLOAT, 0, vert); glNormalPointer(GL_FLOAT, 0, norm); glTexCoordPointer(2, GL_FLOAT, 0, texc);
そして,テクスチャマッピングを有効にして,このデータを描画します.
/* 頂点のインデックスの場所を指定して図形を描画する */ glEnable(GL_TEXTURE_2D); glDrawElements(GL_TRIANGLES, nf * 3, GL_UNSIGNED_INT, face); glDisable(GL_TEXTURE_2D);
最後にクライアント側に置いた頂点,法線,およびテクスチャ座標の配列を無効にします.
/* 頂点データ,法線データ,テクスチャ座標の配列を無効にする */
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_NORMAL_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
...
}
この方法は glBegin(),glEnd(),およびその間の glTexCoord2fv(),glNormal3fv(),glVertex3fv() の繰り返しを,glDrawElements() 一つで実行できるので,OpenGL の API(gl* で始まる関数)の呼び出し(グラフィックスサブシステムの機能呼び出し)の回数を大幅に減じることができます.これはデータ量が大きくなるほど顕著になります.
しかし,glDrawElements() を使っても,描画のたびにクライアント側のメモリからサーバ(グラフィックスサブシステム)側のメモリ(ビデオカード上のメモリ等)へのデータ転送が起こります.このデータ転送は低速なバスを介して行われるので,グラフィックスサブシステムがデータ転送の完了を待つために,本来の性能が発揮できないような状況が発生する場合があります.そこで,グラフィックスサブシステム側に十分なメモリがあるときは,データをグラフィックスサブシステム上のメモリに置いたままにして描画を行うことにより,データ転送の回数を減じることができます.これは Vertex Buffer Object (VBO) を使うことにより実現できます.
頂点配列の描画に使う API には,インデックスデータを用いずに描画を行う glDrawArray() や,glDrawElements() および glDrawArray() で描く要素の1つだけを描く glArrayElement() などもありますが,ここでは割愛します.glDrawArray() を使って GL_TRIANGLE_STRIP を描けば効率が良さそうに思えるのですが,うまくデータを作るのは面倒くさそう…
]]>先日,久しぶりにあった知人に「現役プログラマなんですね」と言われてしまったけど,プログラミングは自分にとって表現の手段や考える道具みたいなものなので,こういう仕事をしている限りプログラミングと縁が切れることはないと思います.こういう歳になるとプログラミングは学生さんなんかに任せるべきっていう話になるのかもしれないけど,自分でプログラミングをしてないと発想そのものが出てきません.それにプログラミングをすればするほど自分の未熟さを思い知らされるので,やっぱり終わらない気がします.いつかはやめる時が来るのかもしれないけど,それまでは学生さんたちとガチでタメを張る感じでプログラミングしていたいと思います.
Oculus でリアルタイムボリュームレンダリング from Kohe Tokoi on Vimeo.
ずいぶん前 (今見たら 8 年前!) に 3D テクスチャを使ったプログラムを書いた*1んですけど,その時に「これでボリュームレンダリングもできるじゃん」って思いました.でも,その方法は (当時使っていた) 教科書にも載ってる良く知られた方法だったので,結局実装しないまま時間が経ってしまいました.先日来 Oculus Rift で遊んでいて,Oculus で雲の中に突入したらどんな感じかなと思って作ってみました.
オリジナルのアイデアもわずかばかり入ってるし,これを出発点としてまた色々考えることもできそうなので,ちゃんとした業績につなげる前にソースを公開するってのは職業的にどうなのって思わないわけでもないんですけど,ほとんどは既に知られた内容ですし,大したプログラムでもないので,気にしないことにします.
ボリュームデータは CPU 側で作りました (main.cpp の makeVolume() 関数).ボリュームのサイズが 32 × 32 × 32 だとデータの作成にあまり時間はかかりませんが,128 × 128 × 128 だと結構待たされます.GLSL にも noise 関数はあるので,それを使ってシェーダで作るべきなんでしょうけど,ここでは前述の以前作ったプログラムを流用しました.条件コンパイルってのは今風ではないんですけど,こらえてつかあさい.
// 作業用メモリ std::vector<GLubyte> texture; // ノイズ関数を初期化する const Noise3 noise(5, 5, 5); // ノイズ関数を使ってテクスチャを作る for (GLint k = 0; k < depth; ++k) { const double z(double(k) / double(depth - 1)); for (GLint j = 0; j < height; ++j) { const double y(double(j) / double(height - 1)); for (GLint i = 0; i < width; ++i) { const double x(double(i) / double(width - 1)); #if VOLUMETYPE == CHECKER texture.push_back(((i >> 4) + (j >> 4) + (k >> 4)) & 1 ? 0 : 255); #elif VOLUMETYPE == NOISE texture.push_back(GLubyte(noise.noise(x, y, z) * 255.0)); #elif VOLUMETYPE == PERLIN texture.push_back(GLubyte(noise.perlin(x, y, z, 4, 0.5) * 255.0)); #elif VOLUMETYPE == TURBULENCE texture.push_back(GLubyte(noise.turbulence(x, y, z, 4, 0.5) * 255.0)); #elif VOLUMETYPE == SPHERE const double px(2.0 * x - 1.0), py(2.0 * y - 1.0), pz(2.0 * z - 1.0); texture.push_back(GLubyte(255.0 - sqrt(px * px + py * py + pz * pz) * 127.5)); #else texture.push_back(255); #endif } } }
VOLUMETYPE == TURBULENCE だと,こんな具合のボリュームテクスチャができます.
これを 3D テクスチャに突っ込みます*2.ポイントは GL_CLAMP_TO_BORDER を指定して,境界色のアルファ値を 0 にしておくあたりです.
// テクスチャオブジェクトを作成して結合する GLuint tex; glGenTextures(1, &tex); glBindTexture(GL_TEXTURE_3D, tex); // テクスチャを割り当てる glTexImage3D(GL_TEXTURE_3D, 0, GL_R8, width, height, depth, 0, GL_RED, GL_UNSIGNED_BYTE, &texture[0]); // テクスチャの拡大・縮小に線形補間を用いる glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // テクスチャからはみ出た部分には境界色を用いる glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_BORDER); // テクスチャの境界色を設定する (ボリュームの外には何もない) static const GLfloat black[] = { 0.0f, 0.0f, 0.0f, 0.0f }; glTexParameterfv(GL_TEXTURE_3D, GL_TEXTURE_BORDER_COLOR, black);
このテクスチャをポリゴンにマッピングします.その際,ボリュームのセルの濃度をアルファ値にも設定しておきます.そしてアルファブレンディングを有効にすると,境界色のアルファ値を 0 にしているので,周囲の余計な部分が消えてボリュームテクスチャのところだけが見えるようになります.なお,アルファテストと違って,見えないところもレンダリングされています.
ポリゴンを複数枚並べるとボリューム全体を表示できそうです.アルファブレンディングを有効にすると,できそうな気がしてきます.
glDrawArraysInstanced() や glDrawElementsInstanced() を使えば,同じポリゴンを複数同時に描くことができます.ここでは,このポリゴン群をスライスと呼んでいます.
// スライスの描画 glBindVertexArray(slice); glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, slices);
同じところに複数のポリゴンを描いても仕方ないので,バーテックスシェーダで描画するインスタンスごとにポリゴンの位置をずらします (slice.vert).インスタンスは GLSL の組み込み変数 gl_InstanceID で識別できますから,これをもとにポリゴンの z 値を決定します.spacing はポリゴンの間隔で,1 / (ポリゴン数 - 1) です.gl_InstanceID に 0.5 を足しているのはテクスチャのサンプリング位置をセルの中心にするためで,ポリゴン群の中心 xy 平面上に移すために最後に 0.5 を引いています.
#version 150 core #extension GL_ARB_explicit_attrib_location : enable // テクスチャ座標の変換行列 uniform mat4 mt; // モデルビュー変換行列 uniform mat4 mw; // プロジェクション変換行列 uniform mat4 mp; // スライスの間隔 uniform float spacing; // [-0.5, 0.5] の正方形の頂点位置 layout (location = 0) in vec2 pv; // スライスの頂点位置 out vec4 p; // スライスのテクスチャ座標 out vec3 t; void main() { // スライスを gl_InstanceID でずらす p = vec4(pv, (float(gl_InstanceID) + 0.5) * spacing - 0.5, 1.0);テクスチャ座標はスライスの頂点座標をもとに決定します.ボリュームの回転表示はスライスを回転させるのではなく,テクスチャ座標の方を回転します.回転によってボリュームがスライスからはみ出ないように,回転後のテクスチャ座標を √3 倍 (すなわちマッピングされるボリュームのサイズを 1 / √3 倍) します.
// スライスのテクスチャ座標はスライスの中心を基準に √3 倍に拡大してから回転する t = (mat3(mt) * p.xyz) * 1.732 + 0.5; // 頂点位置を視点座標系に移す p = mw * p; // モデルビュープロジェクション変換をしてからラスタライザに送る gl_Position = mp * p; }
しかし,アルファブレンディングを有効にしても,並べるポリゴンの枚数が多くなると,結局中身が見えなくなってしまいます.
そこで,閾値を決めて,それを下回るアルファ値 (濃度) がマッピングされたフラグメントをフラグメントシェーダで破棄するようにします (slice.frag).
#version 150 core
#extension GL_ARB_explicit_attrib_location : enable
... (中略) ...
// テクスチャのサンプラ
uniform sampler3D volume;
// クリッピング座標系への変換行列
uniform mat4 mc;
// 閾値
uniform float threshold;
// スライスの表面上の位置
in vec4 p;
// テクスチャ座標
in vec3 t;
// フレームバッファに出力するデータ
layout (location = 0) out vec4 fc;
void main()
{
// 元のボリュームの濃度と閾値の差
float v = texture(volume, t).r - threshold;
// 濃度が閾値以下ならフラグメントを捨てる
if (v <= 0.0) discard;
すると,こんな風になります.
破棄しなかったフラグメントに関しては,そのフラグメントにマッピングされるセルの位置における濃度勾配を求めます.
// 濃度の勾配を求める vec3 g = vec3( textureOffset(volume, t, ivec3(-1, 0, 0)).r - textureOffset(volume, t, ivec3(1, 0, 0)).r, textureOffset(volume, t, ivec3(0, -1, 0)).r - textureOffset(volume, t, ivec3(0, 1, 0)).r, textureOffset(volume, t, ivec3(0, 0, -1)).r - textureOffset(volume, t, ivec3(0, 0, 1)).r );
これを正規化したあと,0.5 倍して 0.5 を足して [0, 1] の範囲に直してフラグメントの色に使ってみます.
// 勾配をそのままフラグメントの色に使う fc = vec4(normalize(g) * 0.5 + 0.5, v);
これは,こんな具合になります.この段階では意味はないけど,アルファブレンディングもしてみました.
濃度勾配はそのまま法線ベクトルとして使えるので,それを使って陰影付けを行います.
vec3 l = normalize((pl * p.w - p * pl.w).xyz); // 光線ベクトル vec3 n = normalize(g * mat3(mt)); // 法線ベクトル vec3 h = normalize(l - normalize(p.xyz)); // 中間ベクトル // 拡散反射光+環境光の反射光 vec4 idiff = max(dot(n, l), 0.0) * kdiff * ldiff + kamb * lamb; // 鏡面反射光 vec4 ispec = pow(max(dot(n, h), 0.0), kshi) * kspec * lspec; // フラグメントの色 fc = vec4((idiff + ispec).rgb, v); }
最終的には,こんな具合になります.キーボードの B のキーでアルファブレンディングの有効/無効を切り替えられます
すべてのスライスで勾配や陰影を求めているので,結構重いです.ボリュームデータが変化しないなら,勾配は事前に求めておくこともできます.これで結構速くなります*3.光源とボリュームデータ (およびスペキュラ*4を含めるなら視点) の位置関係が変化しないなら,さらに陰影も事前に計算しておくことができます.Forward Shading って言うんでしょうか.他にも色々考えてることはあるんですけど,まあ,私が思いつく程度のことだし,どうでもいいや.
勾配を事前計算とフレームごとの処理時間の計測を追加しました (9 月 15 日).勾配を事前に計算するには slice.frag の記号定数 GRADIENT を 0 にしてください.
#define GRADIENT 0 // 勾配を事前計算しないなら 1
フレームごとの処理時間を表示するなら,config.h の記号定数 BENCHMARK を 1 にしてください.
// 経過時間を表示するなら 1 #define BENCHMARK 1
Comments(4)
*1 この記事のコメントに OpenGL / GLSL には逆行列を求める方法が無いって書いてますけど,今は GLSL に inverse() っていう組み込み関数がありますね.
*2 ボリュームテクスチャを GLubyte (unsigned char) で作ってしまったので,テクスチャの internal format は GL_R8 (8 ビット 1 チャンネル) にしてます.
*3 最初は事前に勾配を求めていたのですが,一つのシェーダでできちゃうなぁと思ってまとめてしまいました.
*4 煙とか雲とかではあんまり考慮されることはないと思いますけど…
2007年は精神的に調子がすこぶる悪く,何もやる気が起きませんでした.思考力も無くなってしまってアイデアが全然湧いて来ず,結局ブログは全く更新できませんでした.もちろん研究の方も全然進まなかったというか,アイデアが何も出て来ないので何もできないつらい状況が続きました.授業だけを過去に作った資料でかろうじてこなした感じです.それでも年末にこのブログの内容の一部を本にしました(目次).前著では全く触れなかったテクスチャマッピングを中心にまとめたので,その続編として「GLUTによるOpenGL入門2 テクスチャマッピング」というタイトルになっています.今度は付録に CD-ROM が付いています.
Comments(4)
]]>shi3z さんの日記「僕が3Dプログラマをやめた理由 または3Dプログラミングを学ぶべき6つの理由」に, うなずくところがたくさんありました. 本当に, 3D プログラミングは難しいんです. 意義のある成果を出そうとすると, とてつもなく難しいところに分け入らなければなりません. それで, いつもへこたれてしまいます. だから1年生向けの授業で, つい「3D CG ってこんなに難しいんだよ, だからいっぱい勉強しよう」みたいなことを言って, 嫌われてしまいます. でも, CG って面白いんです. 私が CG に出会った 30 年前も, 今も変わらず面白いです. みんな, 3D CG プログラミングやろうよ.
図形を作成して頂点バッファオブジェクトに送っている部分を, 初期化の関数 init() に組み込んでしまったために, init() がかなり太ってしまいました. そこで, この部分を別の関数に分離して, ソースファイルも分けてしまいたいと思います. 行き当たりばったりでごめんなさい. 一応, このゼミ資料は全体的な見通しを立てて作っているつもりなんですけど, 走りながら作ってるんで, どうしても間違いがあったりつじつまが合わなくなってきたりして, 軌道修正しなきゃなんないことがあります. ごめんなさい.
メインプログラムの関数 init() の始めの方にあるデータ型 Position および Edge の定義と変数 position および edge の宣言 (下記のグレーの部分) を削除します.
... /* ** 初期化 */ static void init(void) { /* シェーダプログラムのコンパイル/リンク結果を得る変数 */ GLint compiled, linked; /* 頂点バッファオブジェクトのメモリを参照するポインタ */ typedef GLfloat Position[3]; Position *position; typedef GLuint Edge[2]; Edge *edge; /* 一時的な変換行列 */ GLfloat temp0[16], temp1[16]; ...
また, その下にある頂点バッファオブジェクトにデータを転送している箇所 (下記のグレーの部分) も削除します. この部分は他のソースプログラムに移すので, これはエディタの「編集」メニューにある「切り取り (cut)」で削除してください. emacs なら C-k とか C-SPC と C-w とか, vi なら "ad/^} とか…
... /* 頂点バッファオブジェクトを2つ作る */ glGenBuffers(2, buffer); /* 頂点バッファオブジェクトに8頂点分のメモリ領域を確保する */ glBindBuffer(GL_ARRAY_BUFFER, buffer[0]); glBufferData(GL_ARRAY_BUFFER, sizeof (Position) * 8, NULL, GL_STATIC_DRAW); /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間にマップする */ position = (Position *)glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY); /* 頂点バッファオブジェクトのメモリにデータを書き込む */ position[0][0] = -1.0f; position[0][1] = -1.0f; position[0][2] = -1.0f; position[1][0] = 1.0f; position[1][1] = -1.0f; position[1][2] = -1.0f; position[2][0] = 1.0f; position[2][1] = -1.0f; position[2][2] = 1.0f; position[3][0] = -1.0f; position[3][1] = -1.0f; position[3][2] = 1.0f; position[4][0] = -1.0f; position[4][1] = 1.0f; position[4][2] = -1.0f; position[5][0] = 1.0f; position[5][1] = 1.0f; position[5][2] = -1.0f; position[6][0] = 1.0f; position[6][1] = 1.0f; position[6][2] = 1.0f; position[7][0] = -1.0f; position[7][1] = 1.0f; position[7][2] = 1.0f; /* 頂点バッファオブジェクトに12稜線分のメモリ領域を確保する */ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffer[1]); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof (Edge) * 12, NULL, GL_STATIC_DRAW); /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間にマップする */ edge = (Edge *)glMapBuffer(GL_ELEMENT_ARRAY_BUFFER, GL_WRITE_ONLY); /* 頂点バッファオブジェクトのメモリにデータを書き込む */ edge[ 0][0] = 0; edge[ 0][1] = 1; edge[ 1][0] = 1; edge[ 1][1] = 2; edge[ 2][0] = 2; edge[ 2][1] = 3; edge[ 3][0] = 3; edge[ 3][1] = 0; edge[ 4][0] = 0; edge[ 4][1] = 4; edge[ 5][0] = 1; edge[ 5][1] = 5; edge[ 6][0] = 2; edge[ 6][1] = 6; edge[ 7][0] = 3; edge[ 7][1] = 7; edge[ 8][0] = 4; edge[ 8][1] = 5; edge[ 9][0] = 5; edge[ 9][1] = 6; edge[10][0] = 6; edge[10][1] = 7; edge[11][0] = 7; edge[11][1] = 4; /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間から切り離す */ glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER); /* 頂点バッファオブジェクトを解放する */ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間から切り離す */ glUnmapBuffer(GL_ARRAY_BUFFER); /* 頂点バッファオブジェクトを解放する */ glBindBuffer(GL_ARRAY_BUFFER, 0); }
次に, 別のソースファイルを作成し, そこに削除した部分を「貼り付け (paste)」してください. emacs なら C-y とか, vi なら "ap とか… (きりがないな) そして, その前後に以下の内容を追加し, 関数 wireCube() を完成させてください. この関数は, 引数に指定されたバッファオブジェクトに対して頂点情報 (座標値) と頂点の指標を設定します. 戻り値は glDrawElements() の第2引数に指定する, 描画する頂点の数 (GL_LINES の場合は稜線の数 × 2) です.
#include <math.h> #include <stdlib.h> #if defined(WIN32) # include "glew.h" # include "glut.h" # include "glext.h" #elif defined(__APPLE__) || defined(MACOSX) # include <GLUT/glut.h> #else # define GL_GLEXT_PROTOTYPES # include <GL/glut.h> #endif /* 頂点バッファオブジェクトのメモリを参照するポインタのデータ型 */ typedef GLfloat Position[3]; typedef GLuint Edge[2]; GLuint wireCube(const GLuint *buffer) { Position *position; Edge *edge; /* この部分に切り取ったプログラムを貼り付け */ return 24; }
メインプログラムの関数 init() の削除した部分では, 代わりにこの関数 wireCube() を呼び出しておきます. 変数 points に, この関数の戻り値を保存しておきます.
... /* ** 初期化 */ static void init(void) { ... /* 頂点バッファオブジェクトを2つ作る */ glGenBuffers(2, buffer); /* 図形をバッファオブジェクトに登録する */ points = wireCube(buffer); } ...
関数 wireCube() や変数 points を, プログラムの最初の部分で宣言しておきます.
... /* ** attribute 変数 position の頂点バッファオブジェクト */ static GLuint buffer[2]; /* ** 図形 */ static GLuint points; extern GLuint wireCube(const GLuint *buffer);
その後, 画面表示の glDrawElements() の第2引数は 24 という定数になっているので, これを変数 points に変更します.
/* ** 画面表示 */ static void display(void) { ... /* 図形を描く */ glDrawElements(GL_LINES, points, GL_UNSIGNED_INT, 0); ...
これで一度プログラムを実行し, 正しく動くことを確認してください.
それでは, 今度は球を描いてみましょう. と言っても, OpenGL では曲線は描けませんから, 線分で近似することになります.
球は経度方向と緯度方向に分割します. 経度方向の分割数を slices, 緯度方向の分割数を stacks とします. また半径は 1 とします. この図形の頂点の数は slices × (stacks - 1) + 2 になります. 一番上 (北極点) の頂点の位置は (0, 1, 0), 一番下 (南極点) の頂点の位置は (0, -1, 0) になります. 各頂点の位置は, 以下のように定めます.
また稜線の数は, slices × (stacks - 1) × 2 + slices になります. 各稜線の両端の頂点の指標 (番号) は, 以下のように定めます.
この形状のデータを頂点バッファオブジェクトに設定する関数 wireSphere() を作成してください. 引数 slices と stacks は, それぞれ球の経度方向の分割数と緯度方向の分割数です. 引数 buffer にはデータを設定する頂点バッファオブジェクトの名前を格納した配列を指定します. buffer[0] には頂点位置, buffer[1] には指標を格納します. なお, このプログラムは wireCube() と同じファイルに書いてください.
GLuint wireSphere(int slices, int stacks, const GLuint *buffer) { Position *position; Edge *edge; GLuint vertices = slices * (stacks - 1) + 2; GLuint edges = slices * (stacks - 1) * 2 + slices; /* 頂点バッファオブジェクトを有効にする */ glBindBuffer(GL_ARRAY_BUFFER, buffer[0]); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffer[1]); /* 頂点バッファオブジェクトにメモリ領域を確保する */ glBufferData(GL_ARRAY_BUFFER, sizeof (Position) * vertices, NULL, GL_STATIC_DRAW); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof (Edge) * edges, NULL, GL_STATIC_DRAW); /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間にマップする */ position = (Position *)glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY); edge = (Edge *)glMapBuffer(GL_ELEMENT_ARRAY_BUFFER, GL_WRITE_ONLY); /* ** 頂点バッファオブジェクトのメモリにデータを書き込むプログラムを, ** この部分に作成してください. 変数 position および edge に値を設 ** 定してください. */ /* 頂点バッファオブジェクトのメモリをプログラムのメモリ空間から切り離す */ glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER); glUnmapBuffer(GL_ARRAY_BUFFER); /* 頂点バッファオブジェクトを解放する */ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); glBindBuffer(GL_ARRAY_BUFFER, 0); return edges * 2; }
変数 position および edge が指している (頂点バッファオブジェクトの) メモリに値を設定するプログラムを考えてください. wireCube() のように, 代入文を並べるという書き方で実現するのは難しいと思います.
これができたら, メインプログラムに wireSphere() の宣言を追加し, wireCube() を呼び出している部分を wireSphere() に置き換えてください. slices と stacks には, ともに 3 以上の整数を設定してください. ここでは 16 と 8 を設定しています.
... /* ** 図形 */ static GLuint points; extern GLuint wireCube(const GLuint *buffer); extern GLuint wireSphere(int slices, int stacks, const GLuint *buffer); ... /* ** 初期化 */ static void init(void) { ... /* 頂点バッファオブジェクトを2つ作る */ glGenBuffers(2, buffer); /* 図形をバッファオブジェクトに登録する */ points = wireSphere(16, 8, buffer); } ...
これで下のような図形が描かれれば OK です.
こんなところです.
3D CG プログラミングが「難しそうだから, 自分には関係ないや」って感じで逃げられてしまうと, ちょっと悲しいです. それでも, うちの研究室には「難しそうだけど, やってみる」って人が集まってくれてるので, 嬉しいです. みなさんが難しいと思うことは, 実は私にとっても難しいことなんですが, 一緒に考えますんで, 気軽に聞いてください. 私をマニュアルとして使っていただいて構いませんから.
Comments(7)
]]>かなり以前,「『視点を移動するのではなく,物体をぐるぐる回す方法は?』に書いてある方法では思ったとおり回転できない」という指摘を受けました.
確かにそのとおりなんですが,もとより「手抜き」の方法ですし(言い訳),まともな方法が GLUT のサンプルなどに含まれている trackball.c や「宇治社中」さんあたりにあると思ってたんで,そのままにしてました.でも,自分が作っているもので使ってみて思ったとおり回転できないことがあるのはやっぱり面白くなかったので,ひとつまじめに考えてみました.
変換(行列)を累積的に合成するなんてことをするとロクな目にあわない気がしたので,ああいう手を抜いた実装になってたんですが,オブジェクトの「今見えている状態」に対してさらに回転を加えようと思えば,やはり避けて通ることはできません.そこで,回転をクォータニオンを使って表すことにします.そのために,まずクォータニオンの積と,クォータニオンから回転の変換行列を求める関数を用意しておきます.クォータニオンについて勉強したければ,金谷先生の本をどうぞ.
/* ** クォータニオンの積 r <- p x q */ void qmul(double r[], const double p[], const double q[]) { r[0] = p[0] * q[0] - p[1] * q[1] - p[2] * q[2] - p[3] * q[3]; r[1] = p[0] * q[1] + p[1] * q[0] + p[2] * q[3] - p[3] * q[2]; r[2] = p[0] * q[2] - p[1] * q[3] + p[2] * q[0] + p[3] * q[1]; r[3] = p[0] * q[3] + p[1] * q[2] - p[2] * q[1] + p[3] * q[0]; } /* ** 回転の変換行列 r <- クォータニオン q */ void qrot(double r[], double q[]) { double x2 = q[1] * q[1] * 2.0; double y2 = q[2] * q[2] * 2.0; double z2 = q[3] * q[3] * 2.0; double xy = q[1] * q[2] * 2.0; double yz = q[2] * q[3] * 2.0; double zx = q[3] * q[1] * 2.0; double xw = q[1] * q[0] * 2.0; double yw = q[2] * q[0] * 2.0; double zw = q[3] * q[0] * 2.0; r[ 0] = 1.0 - y2 - z2; r[ 1] = xy + zw; r[ 2] = zx - yw; r[ 4] = xy - zw; r[ 5] = 1.0 - z2 - x2; r[ 6] = yz + xw; r[ 8] = zx + yw; r[ 9] = yz - xw; r[10] = 1.0 - x2 - y2; r[ 3] = r[ 7] = r[11] = r[12] = r[13] = r[14] = 0.0; r[15] = 1.0; }
また,現在の回転を表すクォータニオン cq と,マウスのドラッグ中の回転を表すクォータニオン tq を用意します.cq の初期値は単位クォータニオンにしておきます.
/* 回転の初期値とドラッグ中の回転 (クォータニオン) */ static double cq[4] = { 1.0, 0.0, 0.0, 0.0 }; static double tq[4];
このほか,オブジェクトの回転に使う変換行列 rt を用意しておきます.
/* 回転の変換行列 */ static double rt[16];
この行列の初期値は単位行列にしておきます.cq は最初単位クォータニオンなので,これを使うこともできます.
void init (void) { ... /* 回転行列の初期化 */ qrot(rt, cq); }
マウスのドラッグにしたがって物体を回転させるためには,ドラッグ中のマウスの移動方向と移動量を検出する必要があります.そこで,マウスボタンを押したときにマウスの位置(ドラッグ開始点)を記録します.また,マウスボタンを離したときにはドラッグ中の回転のクォータニオンの内容を回転の初期値のクォータニオンに保存して,現在のオブジェクトの回転を「固定」します.
/* ドラッグ開始位置 */ static int cx, cy; ... void idle(void) { glutPostRedisplay(); } void mouse(int button, int state, int x, int y) { switch (button) { case GLUT_LEFT_BUTTON: switch (state) { case GLUT_DOWN: /* ドラッグ開始点位置を記録する */ cx = x; cy = y; /* アニメーション開始 */ glutIdleFunc(idle); break; case GLUT_UP: /* アニメーション終了 */ glutIdleFunc(0); /* ドラッグ終了時の回転を保存する */ cq[0] = tq[0]; cq[1] = tq[1]; cq[2] = tq[2]; cq[3] = tq[3]; break; default: break; } break; default: break; } }
マウスのドラッグ中には,現在のマウスポインタの位置のドラッグ開始点からの変位から回転軸ベクトルと回転角を求め,回転のクォータニオンを求めます.これにオブジェクトの現在の回転のクォータニオンを掛け,ドラッグ中の回転を表すクォータニオンを求めます.これを回転の変換行列に直します.
このとき回転量がウィンドウのサイズに依存しないように,マウスポインタの変位をウィンドウ内の相対的な位置に変換しておいたほうがいいでしょう.このためにウィンドウのサイズからスケールファクタ sx, sy を求めておきます.定数 SCALE はマウスポインタの位置から回転角への換算に用います.これを 2π にしておけば,マウスポインタをウィンドウの幅(あるいは高さ)分移動したときに,物体をちょうど一回転させることができます.
/* マウスの絶対位置→ウィンドウ内での相対位置の換算係数 */ static double sx, sy; /* マウスの相対位置→回転角の換算係数 */ #define SCALE (2.0 * 3.14159265358979323846) ... void resize(int w, int h) { /* マウスポインタ位置のウィンドウ内の相対的位置への換算用 */ sx = 1.0 / (double)w; sy = 1.0 / (double)h; ... } ... void motion(int x, int y) { /* マウスポインタの位置のドラッグ開始位置からの変位 (相対値) */ double dx = (x - cx) * sx; double dy = (y - cy) * sy; /* マウスポインタの位置のドラッグ開始位置からの距離 (相対値) */ double a = sqrt(dx * dx + dy * dy); if (a != 0.0) { /* マウスのドラッグに伴う回転のクォータニオン dq を求める */ double ar = a * SCALE * 0.5; double as = sin(ar) / a; double dq[4] = { cos(ar), dy * as, dx * as, 0.0 }; /* 回転の初期値 cq に dq を掛けて回転を合成する */ qmul(tq, dq, cq); /* クォータニオンから回転の変換行列を求める */ qrot(rt, tq); } }
あとは,求めた回転の変換行列を用いて,図形を描画します.
void display(void) { ... glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); /* モデルビュー変換行列の初期化 */ glLoadIdentity(); /* 視点の移動 */ gluLookAt(ex, ey, ez, tx, ty, tz, 0.0, 1.0, 0.0); ... /* 回転 */ glMultMatrixd(rt); ... /* 描画 */ glBegin(...); ... glEnd(); ... }
Comments(6)
]]>修論や卒論の提出が来週~再来週に控えているので, なかなか皆さんお忙しそうです. ほかにも, うちの学科では来週に課題の提出・発表を控えている方々もいらしたりして, 演習室もなかなかの活況を呈しているように見受けられます. 皆様におかれましては体調を崩されたりデータを失われたり単位を落とされたりしないよう, くれぐれもお気を付けくださいますようお願い申し上げる次第です.
Microsoft の Visual Studio には, NuGet というパッケージ管理機能が用意されています. 実のことを言うと, 私は今までこれを使ったことがありませんでした. 先日, 学生さんのプログラムをチェックしたら, そのプロジェクトで使っていたライブラリが NuGet で組み込まれていることに気づきました.
その時は「そんなもんもあるのかー」っていう感じでスルーしてたんですけど, 今日これで freeglut が組み込めるのかなと思って試したら, とっても簡単にプロジェクトに組み込むことができました. ライブラリのファイルをシステムのフォルダに追加したり, プロジェクトのプロパティの「VC++ ディレクトリ」を修正したりする必要もありません. もちろん管理者権限も必要ありません.
今書いてるもんもこれで書き換えたほうがいいかなぁ…
プロジェクトを選択し, 「プロジェクト」メニューから「NuGet パッケージの管理」を選んでください.
「NuGet パッケージの管理」のダイアログウィンドウの右上の検索フィールドで "freeglut" を検索してください. freeglut が見つかったら、それをインストールしてください.
「NuGet パッケージの管理」のダイアログウィンドウの右上の検索フィールドで "glfw" を検索してください. GLFW が見つかったら、それをインストールしてください.
「NuGet パッケージの管理」のダイアログウィンドウの右上の検索フィールドで "glew" を検索してください. GLEW は二つ見つかると思いますが, 最初の方は Multi Context ライブラリとそうでないものを別々にインストールできるのに対し, 後の方 (アイコンが帽子の方) が *私が確認した時は* バージョンが新しいものでした.
freeglut, GLFW, GLEW をそれぞれ個別にインストールする代わりに, 全部を一度にインストールするパッケージもあります. 「NuGet パッケージの管理」のダイアログウィンドウの右上の検索フィールドで "glut" を検索してください. Nupengl Core をインストールすれば, これらの 32bit 版と 64bit 版を全部組み込むことができます.
OpenCV もありました. *私が確認した時は* バージョン 2.4.10 でしたけど.
あ, 3 は下のほうにあるのかもしれない (見てない).
Comments(7)
]]>科研費は(例年通り)外れました.一所懸命書いたのに….それなのに,論文の採録が決まりました.別の国際会議の採択通知も来ました.恩師に「論文を書かない奴」と苦言を呈されている私にしては,奇跡的な出来事です.ただ,別刷り代の当てがありません.国際会議の参加費は校費の研究費から工面できないかと考えているのですが,そうすると後で困りそうです.旅費(オーストラリアのシドニーだ)については学長裁量経費に泣きつこうと思っていたのですが,今年は制度が変わっていることに気づかず,締め切りを逃していました.これはもう国際会議をキャンセルするしかないかなぁ.このところ物入りだったので,今下手に自腹を切ると家計がショートしそうです.
去年の年末から始まった気分の落ち込みは,ひどくなることはあっても,一向に解消される気配はありません.症状がひどくなったときにお医者さんに行ったら,「それはストレスが薬の効力を超えたからで,薬の量を増やしなさい」といわれてしまいました.しかし私には,何が一番のストレスになっているのかよくわかりません.確かに,英語の文章書きなんかはゴールがなかなか見えなくて結構つらい作業だったのですが,そういうのがストレスだったのでしょうか.でもストレスがかからないように何もしないでいても,余計に気分が悪くなってしまうことがありました.
国際会議の論文の方は,これから手直しして(通りやすいところに出したつもりだったんだけど,結構シビアなレビューコメントがついていてめげた)カメラレディを作ったりしないといけないんですが,締め切りまでに時間があまり無い上に,落ち込みのせいで手をつけること自体が億劫になってます.どうせ旅費も無いし.ああ,どうしよう.
それにしても研究費が足りません.そのせいで,研究自体,お金のかからない地味なテーマを選ばざるを得ないわけですが,それでも成果の公表とか学生さんの研究環境整備なんかにもお金がかかります.そこで恥を忍んで他人のおこぼれにあずかったり,他人のふんどしを借りたりもするのですが,結局自腹に食い込んでしまうことも時々ありました.それなら各種財団の助成金を申請しまくるってことをやるべきなんでしょうけど,そういうこともやっぱり億劫で,今のところ科研費の申請が精一杯の状況です.はい,おっしゃる通り,愚痴ってるだけじゃ何も始まらないってのは百も承知です.でも,なんとかこの憂鬱な気分が晴れないものかなぁ…
Comments(5)
]]>なんだかんだ言ってる間に, 大晦日になってしまいました. B4 & M2 の方は, 論文の進捗は具合いかがでしょうか. 研究内容についてはあんまり心配していませんけど, プログラムにこだわって文章書くのが後回しになっていないかが心配です. でも, こういうことを大晦日に書いたりして, 私もしかして鬼? ところで, 私は昨日の夜, 家族と一緒に「のだめカンタービレ」を見に行ってきました. 結構面白かったです. 焦っている (かも知れない) 学生さんを尻目にお気楽やってる私もしかして鬼?
ここ読んでる学生さんに質問していいですか? 演習で企画書を書いてもらったら, かなりの人が PowerPoint でも Word でもなく Illustrator で「描いて」きたんですけど, どうしてそういうことになるんでしょうか. ページものを作るのに 1 ページ 1 ページ Illustrator で描いて Acrobat でまとめたりするのは, すごく骨が折れると思うんですけど. 他の講義の文章しか書いてないレポートでも Illustrator を使っている人がいたんで, 以前から不思議に思ってました.
まあ, 1 ページごとにしっかり作りこんであれば, それも悪くはないと思います. また「パワポ不況 - 麻布論壇」という意見もありますから, 道具が押し付けてくる「流儀」に合わせなきゃなんない理由もありません. でも, 書きたいところに自由にものを配置することは, LaTeX をやったときに説明したと思うけど (覚えてないだろうなー), 実際にはとっても手間がかかる作業です. ワープロや LaTeX なんかは, そういう手間を減らして仕事の能率を上げようっていう道具ですよね (OA - オフィスオートメーションって言うくらいだし). そういうものに乗っからずに自分の思うとおりのことを自由に表現したいってのが, うちの学生さんの気質なのでしょうか. そういや就職活動でも, 推薦をとると束縛されるから自由応募でいきたいという学生さんが結構多いんだけど, この気質と関係あるのかな (関係ないか).
やまもんのプログラムをデバッグするかわりにサンプルプログラム書いてやるって約束したけど, いろいろいじっているうちに結局ゲームグラフィックス特論の宿題をシェーダで書き直しただけになってしまったんで, ついでだから冬休みゼミの課題にしてしまいます. うしし. 以下は今回の雛形プログラムです. このプログラムはマウスの右ボタンか左ボタンをドラックすればボーンを動かすことができますが, ボーンしか動きません. 宿題プログラムと同じですね. うしし.
スキニングは Kavan の Dual Quaternions で決まり! と言い切ってしまうと, こみやーまんの立場が無いんだけど, 体積を (近似的に) 維持できることくらいは売りになるかなぁと思っていたら, 今年の SIGGRAPH ASIA で体積を維持した変形手法に関する発表があったみたいで orz (まだ論文読んでない …でもまた後回し〜). まあ, どうでもいいけど SIGGRAPH ASIA 行きたかったなー行きたかったなー.
とりあえず, 骨格として使うボーンを定義します. もとのボーンは原点 (0, 0, 0) が根元で (0, 0, 1) が先端になっている線分です. このボーンの形は画面表示するために使うもので, 本質には関係ありません. また, これが z 軸の方向を向いているのにも理由があるんですけど, 細かいこだわりなので割愛します. LightWave でも Layout で何も考えずにボーンを追加すると, こういうボーンができるでしょ. このボーンを平行移動と回転により変形させたい (もとの) 形状の付近に配置します. ただしボーンの長さは, これを拡大縮小で決めてしまうと (私のやり方では) いろいろ都合が悪いので, 単に「長さ」として別に保持しておくことにします.
基準位置のボーンを対象形状付近に配置する変換を, それぞれ M0, M1 とします. この変換には, 拡大縮小を含まないものとします. ここに拡大縮小を入れてしまうと, 重みの計算が期待通りにできなくなる (拡大したボーンの影響が強くなってしまう) ので, 前述の通りボーン自体の長さを調整して対象形状に合わせます. また, このボーンを時刻 t に応じて変形する変換を B0(t), B1(t) とします. こちらの変換には, 拡大縮小も含むことができます. なお B0(t), B1(t) は, それぞれ M0, M1 からの相対的な変換ではなく, もとのボーンからの絶対的な変換です.
上図のように対象形状の点 P が二つのボーンの影響を受ける場合, 線形和によるバーテックスブレンディングでは, 時刻 t における変形後のその点の位置 u(t) を次式で求めます. ここで w0, w1 は, その点がそれぞれのボーンから受ける影響の重みであり, w0 + w1 = 1 です.
これは対象形状の点の位置を一旦もとのボーンの座標系に移動し, そこでアニメーションのためにボーンに加えた変換をその点に加えた後, もとの位置に戻すという処理になります. これをその点に影響を与えるすべてのボーンに対して行い, 得られた位置を加重平均して頂点の位置を求めます. 一般に 0〜n - 1 の n 本のボーンの影響を受ける点の位置は, 次式で求めることができます (Real-Time Rendering, Third Edition, P. 83).
(1月4日追記) 以上に対して詳しい説明をいただきましたので, 引用させていただきます. ありがとうございます. 私, 用語をぜんぜん知らないなぁ…
> ボーンを対象形状付近に配置する変換を M0, M1 とします.(中略)
> このボーンを時刻 t に応じて変形する変換を B0(t), B1(t) とします.
一番大事な行列 M と B の定義が馬鹿には解らないのでもう少し解説して欲しい。
M = ボーン(ジョイント)を原点としたジョイント空間からモデル空間への変換行列
M^-1 = その逆行列でモデル空間からジョイント空間への変換
P = モデル空間での頂点の座標とすれば、P' = M^-1 x P が
ジョイント空間での頂点の座標に変換できる。
従ってスキニングは
1. 基本姿勢(バインドポーズ)のモデル空間からジョイント空間に変換 M^-1xP
2. カレントポーズを計算して B = BxBxBx....
3. ジョイント空間からモデル空間に変換 P' = BxM^-1xP
となる。
BxM^-1はフレームの最初に計算しておけるので、ボーンの数だけ全部計算して保存しておく(=マトリックスパレット)。
あとはこれをGPUに送ってやれば、基本姿勢の頂点座標に行列一発かけるだけでアニメーション後の頂点座標になる。
バーテックスブレンディングの式自体はとっても簡単なんですが, 実装の際にはいろいろ考えないといけないことがあります. まず Bi(t) は, もとのボーンの座標系からの絶対的な変換である必要がありますが, プログラムの実装上は対象形状付近に配置したボーンに対してアニメーションを定義することになると思います. すなわち, ボーンに定義する変換 B'i(t) は Mi に対する相対的なものであり, Bi(t) = B'i(t) Mi として Bi(t) を求める必要があります.
さらにボーン同士が親子関係を持つ場合 (だいたい、みなさん、そーされています), 例えば上図において上のボーンが下のボーンに付随して動くような場合には, 上のボーンに定義する変換 M'1 も M0 からの相対的なものになりますから, M1 も M1 = M'1 M0 として求める必要があります. ボーンが枝分かれするなど複雑な階層構造を持っている場合, これをベタに書くとプログラムがぐちゃぐちゃになってしまいそうです.
そこでボーンを (実はやりたくなかったけど) クラスにまとめて, インスタンスごとに局所的な変換を持たせることにします. これにはボーンの初期位置を決める変換 M'i に用いる回転と平行移動のパラメータ (rotation と position) と, アニメーションを行うために用いる変換行列 B'i(t) を保持する配列 (animation) を用意します. これに加えてボーンの長さ (length) も保持しておきます (1月4日, ここも参考意見をもとに追加しました).
... class Bone { float position[4]; // このボーンを配置する位置 (平行移動成分) float rotation[4]; // このボーンを配置する角度 (回転成分) float animation[16]; // 配置したボーンに対して加える変形 (アニメーション) float length; // ボーンの長さ (拡大縮小成分) const Bone *parent; // 親のボーンへのポインタ ...
ポインタ変数 parent は, そのボーンの親になるボーンを指すのに使います. 根元のボーンには 0 (NULL) を入れておきます. 親のボーンに子供のボーンへのポインタを持たせようとすると可変長の配列なりリストなりを使う必要がでてきてめんどくさいので, 子供の方に親がどれだか教えておきます.
ボーンを描画する際に, 現在のボーンから根元のボーンまでの各ボーンの変換を累積した変換行列を求めます. とりあえず現在のボーンの長さを使って, 画面表示するボーンの形状を拡大縮小する変換行列を作成しておきます.
... /* ** ボーンの描画 */ static void drawBone(const Bone *b, float *bottom, float *top, float *blend) { ... /* ボーンの長さに合わせて拡大縮小する変換行列 */ Matrix scale; scale.loadScale(b->getLength(), b->getLength(), b->getLength());
現在のボーンから parent をたどって根元のボーンに至るまでの各ボーンに設定されている変換を積算していきます. 累積の順序が通常の座標変換と逆順になるので, コードが多少ダサい感じになってます.
/* ボーンを初期位置に配置する変換行列とアニメーション後の変換行列 */ Matrix initial, animated; initial.loadIdentity(); animated.loadIdentity(); /* ボーンを根元までたどって各ボーンの変換を累積する */ do { Matrix temp; temp.loadTranslate(b->getPosition()); // ボーンを物体付近に移動 temp.rotate(b->getRotation()); // ボーンを物体に沿わせて回転 initial = temp * initial; // M の累積 temp.multiply(b->getAnimation()); // M * B'(t) animated = temp * animated; // B(t) の累積 } while ((b = b->getParent()) != 0);
累積した変換は, ボーンに対するモデリング変換になります. 最後に, 現在の視野変換に initial と animated をかけて, ボーンに対するモデルビュー変換を求めます.
/* 現在の視野変換行列をかけておく */ initial = viewMatrix * initial; animated = viewMatrix * animated;
これで initial に各ボーンの初期位置を求める変換, すなわち Mi が格納され, animated に各ボーンのアニメーションの変換を累積したもの, すなわち Bi(t) が格納されます. この initial を使ってボーンの配置後の根元の位置を求め, uniform 変数としてバーテックスシェーダに渡す配列変数 bottom に格納します. 一方, ボーンの先端の位置は initial に先ほど求めた scale をかけたものを使って求め, uniform 変数としてバーテックスシェーダに渡す配列変数 top に格納しておきます.
/* ボーンの初期位置における根元と先端の位置を求める */ initial.projection(bottom, boneVertex[0]); (initial * scale).projection(top, boneVertex[5]);
アニメーションの変換 animated にボーンの初期位置を求める変換 initial の逆変換をかけます (Bi(t) M-1i). この変換は, 対象形状の初期位置における点の位置を, アニメーション後の位置に移動します. これもバーテックシェーダに uniform 変数として渡す変換行列の配列変数 blend に格納しておきます.
/* バーテックスブレンディング用の変換行列 */ memcpy(blend, (animated * initial.invert()).get(), sizeof blend[0] * 16);
最後にボーンの形状を描きます. ボーンを指定した長さになるように拡大縮小し, それにアニメーションの変換を加えた後, 投影変換を行います.
/* attribute 変数 position に頂点情報を対応付けてボーンを描画する */ glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, boneVertex); glUniformMatrix4fv(modelViewProjectionMatrixLocation, 1, GL_FALSE, (projectionMatrix * animated * scale).get()); glDrawElements(GL_LINE_LOOP, sizeof boneEdge / sizeof boneEdge[0], GL_UNSIGNED_INT, boneEdge); glDisableVertexAttribArray(0); } ...
ボーンの初期位置におけるボーンの根元と先端の位置 (bottom, top), および対象形状の初期位置における点の位置をアニメーション後の位置に移動する変換 (blend) をバーテックスシェーダに渡して, 図形 (点) の描画を行います.
... /* ** 画面表示 */ static void display(void) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glEnable(GL_DEPTH_TEST); /* バーテックスブレンディング用データ */ GLfloat bottom[BONES][4], top[BONES][4], blend[BONES][16];
まず, drawBone() を使ってボーンの形を描くとともに, bottom, top, および blend を求めます.
/* ** ボーンのアニメーション */ glUseProgram(bProgram); for (int i = 0; i < BONES; ++i) { Matrix animationMatrix; animationMatrix.loadRotate(0.0f, 1.0f, 0.0f, angle[i]); bone[i].setAnimation(animationMatrix.get()); drawBone(&bone[i], bottom[i], top[i], blend[i]); }
最初に modelViewMatrix に現在の視野変換行列 viewMatrix を格納しておき, それにこの対象形状に対するモデリング変換を適用します. modelViewMatrix にはこの図形に対するモデルビュー変換行列が格納されます. これと現在の投影変換行列 projectionMatrix を uniform 変数としてバーテックスシェーダに渡します.
/* ** 点を描く */ glUseProgram(pProgram); /* 点のモデリング変換/視野変換/投影変換 */ Matrix modelViewMatrix = viewMatrix; modelViewMatrix.translate(0.0f, 0.0f, -1.5f); modelViewMatrix.scale(0.3f, 0.3f, 3.0f); glUniformMatrix4fv(modelViewMatrixLocation, 1, GL_FALSE, modelViewMatrix.get()); glUniformMatrix4fv(projectionMatrixLocation, 1, GL_FALSE, projectionMatrix.get());
またバーテックスブレンディングを行うためのデータとして, 使用するボーンの数 BONES や各ボーンの根元と先端を格納した配列 bottom, top, および対象形状の初期位置における点の位置をアニメーション後の位置に移動する変換行列 blend を, それぞれ uniform 変数 numberOfBones, boneBottom, boneTop, blendMatrix に格納します.
/* バーテックスブレンディング用の uniform 変数の設定 */ glUniform1i(numberOfBonesLocation, BONES); glUniform4fv(boneBottomLocation, BONES, bottom[0]); glUniform4fv(boneTopLocation, BONES, top[0]); glUniformMatrix4fv(blendMatrixLocation, BONES, GL_FALSE, blend[0]);
そして点を描きます.
/* attribute 変数 position に頂点情報を対応付けて図形を描画する */ glEnableVertexAttribArray(0); glBindBuffer(GL_ARRAY_BUFFER, buffer[0]); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0); glDrawArrays(GL_POINTS, 0, points); glBindBuffer(GL_ARRAY_BUFFER, 0); glDisableVertexAttribArray(0); glDisable(GL_DEPTH_TEST); glutSwapBuffers(); }
ある点があるボーンから受ける影響の重みは, その点とそのボーンの距離をもとに決定します. これはボーンの両端点のそれぞれと点との距離の和を使うこともできる気がしますが, ここではボーンを通る直線と点との距離を使うことにします. いま, ボーンの根もとの位置を P0, 先端の位置を P1 とします. また, 点 P2 から P0P1 を通る直線に下ろした垂線の足を P とします.
このとき, 線分 P0P1 と P2P は直交するので, 次式が成り立ちます.
点 P は線分 P0P1 に対する内分比を t とすれば, 次式で求められます.
これを上式に代入して t を求めます. 高校の数学の復習ですね. でも, これに限らず, うまくコードを書くためには, 公式を持ってくるだけでなく, それを自分なりに噛み砕いておいた方がいいですよ, などと偉そうなこと言ってみたり. 私がうまいコードをかけるわけではないです.
この t を使って, 距離 d を求めます.
ここで V1= 0 だと t を求めることができませんが, これは線分の長さが 0 (すなわち点) だということなので, d = |V2| とします. また t < 0 や t > 1 のときは P が P0P1 の間にありませんが, t を [0, 1] の区間でクランプしてしまえば, t = 0 のときは d = |V2| となり, t > 1 のときは d = |P2 - P1| となりますから, ボーンの両端点の近い方との距離を得ることができます. 重みはこの距離に 1 を加えたもののベキ乗の逆数を用います. 1 を加えるのは, ( ) 内が必ず 1 以上になるようにするためです.
c を大きくするほど, 近くのボーンの影響力が強まり, 遠くのボーンの影響を受けにくくなります. 逆に c が小さすぎると, ボーンを動かしたときに離れたところの形まで変わってしまうことがあります. LightWave のデフォルトが確か -16 乗くらいになっていたと思うので, とりあえず c = 16 くらいにしておけばいいんじゃないでしょうか.
なお, 位置を同次座標で表している場合は, 重みの総和を求める必要がありません. 同次座標にスカラーをかけても実座標は変わりませんし, このとき同次座標の w の要素にも重みがかけられるので, 重みをかけた同次座標の総和から実座標を求めるための w の要素による除算に, 重みの総和による除算が含まれています. 実はこのことは, 宿題を解答してきた学生さんに指摘されて気づきました.
以上をもとに, 点を描画する際に使うバーテックスシェーダプログラム simple.vert を書き換えて, バーテックスブレンディングにより形状を変形するようにしてください.
重みは対象形状とボーンの初期位置との関係で決まるので, 事前に計算しておいて頂点情報 (attribute) として与えるということもできます. しかし, これだとひとつの頂点につきボーンの数 (÷4) だけ頂点情報が必要になるので, ここではバーテックスシェーダ側でその都度計算することにしました. この記事では, 他にも変換行列を積算するのに同じボーンを何度もたどっているなど, 効率の悪い部分があります. ここに書いてあることを鵜呑みにしないで, いろいろいじくってみてください.
あと, この記事では面を張るために必要な法線ベクトルの算出にも触れていません. これは頂点情報に法線ベクトルを与えておいて, その向きをボーンにより変換したものをブレンドすれば求めることがでできると思います. ただし, 法線の変換には法線変換行列 (normal transform matrix) を用いる必要があります. 法線変換行列は頂点位置の変換行列の左上 3x3 要素の随伴行列 (adjoint) を転置したものです. Matrix クラスの normal というメソッドでこれを計算できます (が, 実はまだ使ったことがないので正しいかどうかわかりません). このメソッドで得られたベクトルを正規化してください.
2ch の OpenGL 関連のスレッドはとても有益な情報源なので, 時々見ています. 見るたびに, 自分の無知を思い知らされます. 今回の記事の内容は, 本当に以前から学生さんに約束していたことでした (結果的に少し違ってきちゃったのですが). なので「名指し」されたときはさすがにビビリました. ヘタなことは書けなくなったなあと思ったんですが, 学生さん向けの課題なんだから, このスレッドを見なかったことにしても許されるよなとも考えました. 参考になるご意見ありがとうございました.
Comments(11)
]]>