なんとなく

誰得感満載な記事が多いかも。Mono関係とLinuxのサーバ関係、レビューとか。

Terrainの描画について

はじめに

この記事は、セカンドライフ技術系 Advent Calendar 2018 - Adventar向けの記事です。

wireframeでのTerrainの描画
wireframeでのTerrainの描画

今回は、SecondLife(OpenSimも含む) のTerrain(地形)についてみていきたいと思います。LibOpenMetaverseそのものの話というよりはLibOpenMetaverseを使用し得られたデータを利用して、描画するまでにどういうことをやっているかという話です。

公式ViewerやFirestormといったよく使われているViewerではなく、Radegastとその3DSceneをAndroidに移植したものの話になり、言語としては、C#とOpenTKにラップされたOpenGL ES 3.1 とシェーダーとかなり偏りのあるものです。

前半では、RadegastでのTerrainの描画について簡単に解説し、それを踏まえ後半では、Terrainのテクスチャについて、以前に移植したAndroid向けの実装の改善について触れます。

RadegastでのTerrainの描画について

サードパーティViewerであるRadegastでのTerrainの描画の流れについて説明していきます。

処理内容

概要

大まかな流れとしては、

  • SimのHeightMapを得て、Meshとして頂点データ、法線データ、UVデータ、そして頂点インデックスを生成

  • SimのMeshに貼り付けるためのテクスチャを4つ取得し、生成に必要なデータを取得し、テクスチャを生成

  • 上記2つで生成されたMeshとテクスチャを使って、SimのTerrainを描画

です。

LibOpenMetaverseを使って取得するデータ

Terrainを描画するためにLibOpenMetaverseを使ってSimから取得するデータは2種類あって、

  • HeightMapのデータ
  • Texture生成のためのデータ

です。

これらは、OpenMetaverse.Simulatorクラス*1に存在します。

まず、高さのデータであるHeightMapのデータについてですが、Simを16x16のPatchに分割した状態で得られます。

Simulatorクラスに

public readonly TerrainPatch[] Terrain;

として存在します。

次にTexture生成のためのデータについてですが、

  • Simのテクスチャ(4つ)のUUID
  • 4分割された高さの始点とレンジの四隅の値

です。

Simulatorには4つの画像が定義されており、Simulatorクラスに

public UUID TerrainDetail0
public UUID TerrainDetail1
public UUID TerrainDetail2
public UUID TerrainDetail3

として存在します。

4分割された高さの始点とレンジの四隅の値については、詳しくはテクスチャの生成についてで説明します。 高さの始点(1区分目の高さ)の四隅の値については、Simulatorクラスに

public float TerrainStartHeight00
public float TerrainStartHeight01
public float TerrainStartHeight10
public float TerrainStartHeight11

として存在します。

また、高さのレンジ(3区分目の高さ)の四隅の値については

public float TerrainHeightRange00
public float TerrainHeightRange01
public float TerrainHeightRange10
public float TerrainHeightRange11

として存在します。

HeightMapからMeshの作成

概要でも記したようにSimのHeightMapを得て、Meshとして頂点データ、法線データ、UVデータ、そして頂点インデックスを生成します。

ソースは、https://github.com/radegastdev/radegast/blob/master/Radegast/GUI/Rendering/RenderTerrain.cs

を参照してください。

ログインした状態で、Client.Network.CurrentSimでSimを得られますので、そのTerrainでデータを取得できます。 しかし、16x16のPatchに分割された状態で得られるので、そのままではHeightMapとしては使えません。PatchのインデックスとSimのサイズは256x256なのを考慮してSimのHeightMapを作成しています。

Simulator sim { get { return Instance.Client.Network.CurrentSim; } }

~略~
int step = 1;
for (int x = 0; x < 256; x += step)
{
    for (int y = 0; y < 256; y += step)
    {
        float z = 0;
        int patchNr = ((int)x / 16) * 16 + (int)y / 16;
        if (sim.Terrain[patchNr] != null
        && sim.Terrain[patchNr].Data != null)
        {
            float[] data = sim.Terrain[patchNr].Data;
            z = data[(int)x % 16 * 16 + (int)y % 16];
        }
        heightTable[x, y] = z;
      }
}

そして、生成されたHeightMapからMeshとして頂点データ、法線データ、UVデータを生成しています。

なお、LibOpenMetaverseにおいては、PrimMesherというライブラリを使用して、この処理を行っています。PrimMesherはDLLとして存在しますが、大元のソースコードがどれなのかは示されておらず、確かではありません。おそらく、これだろうなと言うのは https://github.com/lkalif/PrimMesher です。

HeightMapからのMesh化については、 OpenMetaverse.Rendering.MeshmerizerRで

public OMVR.Face TerrainMesh(float[,] zMap, float xBegin, float xEnd, float yBegin, float yEnd)

と定義されています*2

TerrainMeshで、PrimMesher.SculptMeshから頂点データ、法線データ、UVデータを生成し、さらに頂点インデックスを生成し、描画に必要なデータを準備しています。

PrimMesherでの処理は、以下URIを参照ください。 https://github.com/lkalif/PrimMesher/blob/master/PrimMesher/SculptMesh.cs#L84

なお、このPrimMesherでは、スカルプテッドプリムの生成も行います。

テクスチャの作成

Texture生成のためのデータについては、LibOpenMetaverseを使って取得するデータにおいて、

  • Simのテクスチャ(4つ)のUUID
  • 4分割された高さの始点とレンジの四隅の値

として、詳しくは説明しておりませんでした。

http://wiki.secondlife.com/wiki/Creating_Terrain_Textureshttp://wiki.secondlife.com/wiki/Creating_Terrain_Textures

を見ると説明があります。

Simのテクスチャ(4つ)については、高さの区分ごとに4つあります*3

さらに高さの区分において、Simの四隅での値があり、1区分の高さと3区分の高さをそれぞれで指定できるようになっています。LibOpenMetaverseを使って取得するデータにおいて4分割された高さの始点とレンジの四隅の値と記したのはこれのことです。テクスチャ生成時に高さの区分の勾配になります。

また、LibOpenMetaverseでの4分割された高さの始点とレンジの四隅の値である、それぞれの変数の指す方角は以下のようになっています。

名前 方角
startHeight00 SW(Southwest)
startHeight01 SE(Southeast)
startHeight10 NW(Northwest)
startHeight11 NE(Northeast)
名前 方角
HeightRange00 SW(Southwest)
HeightRange01 SE(Southeast)
HeightRange10 NW(Northwest)
HeightRange11 NE(Northeast)

基本的には、HeightMapで得られた高さをもとに4つのテクスチャを割り当て、相互のテクスチャを混ぜ合わせるということで、テクスチャを生成しますが、さらに4分割された高さの始点とレンジの四隅の値を使って勾配を生み出しています。後述しますが、さらにノイズを加えテクスチャを生成しています。

それではテクスチャの生成について見ていきましょう。

ソースは https://github.com/radegastdev/radegast/blob/master/Radegast/GUI/Rendering/TerrainSplat.cs

を参照してください。

まずは、Simのテクスチャ(4つ)のUUIDを使って、テクスチャを得ます。jpeg2000の形式で取得できるので、Bitmapにしています。

次にテクスチャを生成する前段階として、

  • 四隅の勾配とノイズによる隣り合うテクスチャ同士を混ぜ合う配分
  • 高さに応じて使用するテクスチャを指定するため値

を生成します。

四隅の勾配は、以下のように線形補間する形で得ています。

// Use bilinear interpolation between the four corners of start height and
// height range to select the current values at this position
float startHeight = ImageUtils.Bilinear(
    startHeights[0],
    startHeights[2],
    startHeights[1],
    startHeights[3],
    pctX, pctY);
startHeight = Utils.Clamp(startHeight, 0f, 255f);

float heightRange = ImageUtils.Bilinear(
    heightRanges[0],
    heightRanges[2],
    heightRanges[1],
    heightRanges[3],
    pctX, pctY);
heightRange = Utils.Clamp(heightRange, 0f, 255f);

ノイズについてはよくわからないのですが、低周波と少々の高周波を加えていますので、おそらく雲のようなノイズが生成されることは予想できます。 しかし、コメントにあるように http://opensimulator.org/wiki/Terrain_Splatting を見ても、なぜその値で、生成しているのかはわかりませんでした。

// Generate two frequencies of perlin noise based on our global position
// The magic values were taken from http://opensimulator.org/wiki/Terrain_Splatting
Vector3 vec = new Vector3
(
    newX * 0.20319f,
    newY * 0.20319f,
    height * 0.25f
);

float lowFreq = Perlin.noise2(vec.X * 0.222222f, vec.Y * 0.222222f) * 6.5f;
float highFreq = Perlin.turbulence2(vec.X, vec.Y, 2f) * 2.25f;
float noise = (lowFreq + highFreq) * 2f;

そして、高さとノイズから線形補間した1区分めの高さを引いたものを線形補間した3区分めの値で割って4をかけてあげ、それを0から3でクランプしてあげると、整数部は0~3で、使用するテクスチャを指し、小数部は隣り合うテクスチャ同士を混ぜ合わせる割合を示すものを生成することができます。

// Combine the current height, generated noise, start height, and height range parameters, then scale all of it
float layer = ((height + noise - startHeight) / heightRange) * 4f;
if (Single.IsNaN(layer))
    layer = 0f;
layermap[newY * RegionSize + newX] = Utils.Clamp(layer, 0f, 3f);

生成されたものを使い整数部を4つのテクスチャからそのテクスチャと隣り合うテクスチャを指定し、相互の割合をBilinearによって合成し、テクスチャが生成されます。

RiverbrookというSIMをデフォルトのテクスチャで生成してみました。

f:id:takeshich:20181203221111p:plain:w300

Meshの描画、テクスチャのbind

上述したMesh化したデータと生成されたテクスチャを使い、描画します。

テクスチャのbindについてですが、GL.GenTexturesやGL.BindTextureおよびパラメータの設定については、RHelp.GLLoadImageで行っています*4

そして、Meshの描画についてですが、PrimMesherで生成したものは描画しやすいように格納された配列になっていますので、VBOを使い、描画しています*5

TerrainにおけるAndroidに移植したRadegastの3DSceneの高速化について

2016年のAdvent CalendarでRadegastの3DSceneをAndroidに移植してみる - なんとなくを書いたのですが、実はその直後から、技術的なところに関係するけど技術ではないところでいろいろあり、1年ほど遠ざかっておりました。昨年のAdvent Calendarを書いたら、戻りたい気分になり、今年(2018)に入ってAndroidの実機を持ったことから、いろいろとリハビリをしておりました*6

Terrainにおける問題点

一番の問題点は2016年に移植した際にポインタを使わずGetPixelを用いたためテクスチャの生成において時間がかかる(1分以上!)というものなのですが、生成するBitmapが2048x2048と大きく、少し小さくしたところでそれなりに時間が掛かるし、ポインタを使ったものに改修しても大幅な改善は見込めないという点でした。

もう一点の問題点は、TerrainのMeshについて頂点の数(256x256)がそれなりにあり、他のオブジェクトは距離範囲を決め、表示させないようにすることができますが、TerrainのMeshはサイズが大きいのでそれもできないし、オクルージョンカリングすることはできないし、負荷といった点ではそれなりのものになっているという点です。(今回はこれについては触れません。できるのであれば次回にふれるつもりでいろいろ試している最中です。)

改善内容

思いついたのは、CPU側で処理しているテクスチャの生成をシェーダーを使ってGPUで処理するというものです。速度もそれなりのものが出るはずなので、なるべくGPU側で処理させてしまおうと思いやってみました。

Radegastのところで説明したようにテクスチャを生成する前段階として、

  • 四隅の勾配とノイズによる隣り合うテクスチャ同士を混ぜ合う配分
  • 高さに応じて使用するテクスチャを指定するため値

を生成します。

まずは、ノイズについてですが、ノイズの生成をシェーダーで行うか、CPU側で行うかについて検討が必要になりました。そのため、どちらも実装してみました。

まず、シェーダーでのノイズの実装ですが、

https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83

にある、Classic Perlin 2D Noise by Stefan Gustavson を利用しました。CPU側での作成と同様にしようと思いましたが、Seedをどう設定すればよいのかまでは理解が足りず、ノイズは生成できるものの同様のものを生成できるところには至っていません。

さらにノイズの生成に必要なturbulenceは以下のように実装しました。

float turbulence2(vec2 xxxyy, float freq)
{
    float t;
    vec2 vec;

    for (t = 0.0; freq >= 1.0; freq *= 0.5)
    {
        vec.x = freq * xxxyy.x;
        vec.y = freq * xxxyy.y;
        t += cnoise(vec) / freq;
    }
    return t;
}

ノイズの生成は、CPU側と同様に以下のように実装しました。

vec2 vec = vec2(textureCoordinate.x * 0.20319 * 255.0,(1.0 - textureCoordinate.y) * 0.20319 * 255.0);
float lowFreq = cnoise(vec * 0.222222) * 6.5;
float highFreq = turbulence2(vec, 2.0) * 2.25;
float adnoise = (lowFreq + highFreq) * 2.0;

一方CPU側でのノイズの生成は同様にして、Float配列として生成し、シェーダにテクスチャとして、以下のように渡しました。

GL.BindTexture(TextureTarget.Texture2D, detailTextureid[4]);
GL.TexImage2D(TextureTarget.Texture2D, 0, (PixelInternalFormat)All.R16f, 256, 256, 0, (OpenTK.Graphics.ES31.PixelFormat)All.Red, PixelType.Float, TerrainSplat.GenNoiseTexture(256, 256));

シェーダー側では、以下のように受け取っています。

vec2 uvs = vec2(textureCoordinate.x,1.0 - textureCoordinate.y);
float adnoise = texture(u_Tex5,uvs).x;

ノイズを生成できるようになったら、次は、四隅の勾配とノイズによる隣り合うテクスチャ同士を混ぜ合う配分と高さに応じて使用するテクスチャを指定するため値を生成します。

Radegastのコードとやっていることは同じで、四隅の値を線形補間して、整数部はテクスチャを指定し、小数部はその割合を算出します。

以下のように実装しました。

uniform lowp sampler2DArray u_Tex1;
~略~
float StartHeight = mix(mix(u_StartHeight.x,u_StartHeight.z,textureCoordinate.x),mix(u_StartHeight.y,u_StartHeight.w,1.0 - textureCoordinate.y),1.0 - textureCoordinate.y);
float HeightRange = mix(mix(u_HeightRange.x,u_HeightRange.z,textureCoordinate.x),mix(u_HeightRange.y,u_HeightRange.w,1.0 - textureCoordinate.y),1.0 - textureCoordinate.y);
float layer = clamp(((Height + adnoise - StartHeight)/HeightRange) *4.0 ,0.0,3.0);

//整数部と少数部を取得
int l0 = int(floor(layer));
float Grad = fract(layer);
int l1 = min(l0+1,3);

//color = mix(texs[l0],texs[l1],Grad);
color = mix(texture(u_Tex1,vec3(textureCoordinate,l0)),texture(u_Tex1,vec3(textureCoordinate,l1)),Grad);

4つのテクスチャについては、CPU側で以下のようにし、シェーダーに送っています。

terrainTexture = GL.GenTexture();
GL.BindTexture(TextureTarget.Texture2DArray, terrainTexture);

GL.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
GL.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel,4);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);

int count = 4;
for (int i = 0; i < count; i++)
{
    using (var texturebmp = detailTexture[i])
    {
        if (i == 0)
        {
            GL.TexImage3D(TextureTarget3D.Texture2DArray, 0, TextureComponentCount.Rgba,
                texturebmp.Width, texturebmp.Height, count, 0,
                OpenTK.Graphics.ES31.PixelFormat.Rgba, PixelType.UnsignedByte, texturebmp.LockPixels());
        }
        else
        {
            GL.TexSubImage3D(TextureTarget3D.Texture2DArray, 0, 0, 0, i, texturebmp.Width, texturebmp.Height, 1, OpenTK.Graphics.ES31.PixelFormat.Rgba, PixelType.UnsignedByte, texturebmp.LockPixels());
        }

        texturebmp.UnlockPixels();
    }
}

テクスチャを適用しないイメージは以下になります。ノイズと区分がはっきりわかるのではないでしょうか。

f:id:takeshich:20181205163906p:plain:w300

黒、緑、青、黄色っぽい色の4区分で

名前 方角
startHeight00 SW(Southwest) 0f
startHeight01 SE(Southeast) 18f
startHeight10 NW(Northwest) 18f
startHeight11 NE(Northeast) 18f
名前 方角
HeightRange00 SW(Southwest) 1f
HeightRange01 SE(Southeast) 21f
HeightRange10 NW(Northwest) 21f
HeightRange11 NE(Northeast) 21f

という値を使い、HeightMapは11月ごろのAkibaのデータを使用しています。

そして、実際にテクスチャを指定すると以下のようなイメージになります。どちらもCPU側でノイズを生成したものです。

f:id:takeshich:20181205164009p:plain:w300

また、Radegastで生成したRiverbrookのものと比べてみます。似ているものができているので、よしとしましょうw

f:id:takeshich:20181203221111p:plain:w300 f:id:takeshich:20181206223500j:plain:w300

シェーダーに書き換えたことによって、30FPS程度で描画できていますから、CPU側で生成していたよりも大幅に改善できました。また、シェーダーで都度ノイズを生成する場合と1度CPU側でノイズを生成してそれを利用する場合では、若干、シェーダーで都度ノイズを生成する場合のほうが遅いようです。きちんと計測はしていません。

Androidに移植したRadegastの3DSceneでTerrainだけではないのを描画したものもスクリーンショットを撮ろうと思ったのですが、Xamarin.Androidのバグ*7で、LibOpenMetaverseのテクスチャを取得するところが全く動かない状態に陥っており、なんとか修復しているものの2016年には動いたものがAndroidのバージョンアップなどで動かなかったりとかいろいろあって、現状撮れない状態です。

まとめ

SecondLifeを利用されている方でもTerrainはあまり気にされない方も多いのかもしれません。土地やSimを持っていないとテクスチャの描画に関係するところはいじらないものとも思われます。Viewer内部での実装に触れ、そこから高速化についても触れることができたのでよかったと思います。一方、Terrainにおける速度がでない問題点としてあげた部分について今回は触れることができませんでした。 そこについてはいろいろと検証中なので、次回に書きたいと思います。Terrainは奥が深いです。Terrainの描画については論文がいろいろあるようです。