忍者ブログ

Memeplexes

プログラミング、3DCGとその他いろいろについて

かんたんXNA その17 ポイントスプライト

このページは古いです
最新版はこちら

3Dゲームでビームや火花、爆発や煙、水しぶきや雪などを表現する方法として
ポイントスプライトがあります。

これはパーティクル(粒子)をいっぱい表示することでそれらを表現する方法です。

例えばXNA Creators Clubのサンプル、Particle 3D Sampleでは
実際にそのようにして爆発や煙を表現しています。
3DParticles.JPG
この爆発と煙は、実は簡単な小さい画像をたくさん並べたものなのです。
explosion.png(爆発に使われた画像explosion.png)
smoke.png(煙に使われた画像smoke.png)

それぞれの小さな炎(や煙)は位置と大きさの情報を持っており、
向きの情報は持っていません。
(これらの情報は、実際には頂点ではありませんが、頂点データとして扱います)
ポイントスプライトは常にカメラの方向を向くからです。

頂点データをある方法で描画してやるとポイントスプライトになります。

いろいろな方法がありますが、例えば前回出てきた
GraphicsDevice.RenderStateのFillModeプロパティを
FillMode.Pointにしてやるだけで簡単なポイントスプライトになります。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;


public class MyGame : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    BasicEffect effect;

    VertexPositionColor[] vertices = new VertexPositionColor[3];

    public MyGame()
    {
        graphics = new GraphicsDeviceManager(this);

        vertices[0] = new VertexPositionColor(new Vector3(0, 1, 0), Color.White);
        vertices[1] = new VertexPositionColor(new Vector3(1, 0, 0), Color.Red);
        vertices[2] = new VertexPositionColor(new Vector3(-1, 0, 0), Color.Navy);
    }

    protected override void LoadGraphicsContent(bool loadAllContent)
    {
        if (loadAllContent)
        {
            effect = new BasicEffect(graphics.GraphicsDevice, null);
            effect.Projection = Matrix.CreatePerspectiveFieldOfView(
                MathHelper.ToRadians(45),
                Window.ClientBounds.Width / (float)Window.ClientBounds.Height,
                1,
                100
                );
            effect.View = Matrix.CreateLookAt(
                new Vector3(0, 0, 3),
                new Vector3(0, 0, 0),
                new Vector3(0, 1, 0)
                );
            effect.VertexColorEnabled = true;
        }
    }

    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

        graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(
            graphics.GraphicsDevice,
            VertexPositionColor.VertexElements
            );

        graphics.GraphicsDevice.RenderState.FillMode = FillMode.Point;
        graphics.GraphicsDevice.RenderState.PointSize = 20;

        effect.Begin();

        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();

            graphics.GraphicsDevice.DrawUserPrimitives<VertexPositionColor>(
                PrimitiveType.TriangleList,
                vertices,
                0,
                1
                );

            pass.End();
        }

        effect.End();
    }
}

simplestPointSprite.JPG
このプログラムは三角形を描画する代わりに、
大きさ20のポイントスプライトを3つ描画しています。

ポイントスプライトの大きさはRenderState.PointSizeプロパティで設定しています。

ただ、この方法はあまりスマートではなかったかもしれません。
というのもこれは結局「三角形の描画の代わり」なので
3の倍数(3、6、9、12、・・・)だけしかポイントスプライトを
表示できないからです。

煙を表示しようとお微妙な数の
ポイントスプライトを描画できるべきです。

まっとうなやり方は、
GraphicsDevice.DrawUserPrimitivesの引数
primitiveTypeをPrimitiveType.PointListに書き換えてやることです。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;


public class MyGame : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    BasicEffect effect;

    VertexPositionColor[] vertices = new VertexPositionColor[3];

    public MyGame()
    {
        graphics = new GraphicsDeviceManager(this);

        vertices[0] = new VertexPositionColor(new Vector3(0, 1, 0), Color.White);
        vertices[1] = new VertexPositionColor(new Vector3(1, 0, 0), Color.Red);
        vertices[2] = new VertexPositionColor(new Vector3(-1, 0, 0), Color.Navy);
    }

    protected override void LoadGraphicsContent(bool loadAllContent)
    {
        if (loadAllContent)
        {
            effect = new BasicEffect(graphics.GraphicsDevice, null);
            effect.Projection = Matrix.CreatePerspectiveFieldOfView(
                MathHelper.ToRadians(45),
                Window.ClientBounds.Width / (float)Window.ClientBounds.Height,
                1,
                100
                );
            effect.View = Matrix.CreateLookAt(
                new Vector3(0, 0, 3),
                new Vector3(0, 0, 0),
                new Vector3(0, 1, 0)
                );
            effect.VertexColorEnabled = true;
        }
    }

    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

        graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(
            graphics.GraphicsDevice,
            VertexPositionColor.VertexElements
            );

        graphics.GraphicsDevice.RenderState.PointSize = 20;

        effect.Begin();

        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();

            graphics.GraphicsDevice.DrawUserPrimitives<VertexPositionColor>(
                PrimitiveType.PointList,
                vertices,
                0,
                3
                );

            pass.End();
        }

        effect.End();
    }
}

これも上と同じ表示結果になります。
違うのはソースコードの中身で、これは頂点データをポイントのリストとして表示しています。
(そのため引数primitiveCountは1ではなく3です。)
テクスチャを貼る

さて、ここまでは色付きの点しか表示していませんでした。
しかし現実にはそんな地味なものでゲームは作れません。
最低でもXNA Creators Clubのサンプルのように、
テクスチャ付きのポイントスプライトでなければなりません。

どんなテクスチャを貼るかは問題ですが、ここでは
Photoshopの「ぼかし(放射状)」を何度も繰り返して
やっつけた画像を使ってみます。
(別にペイントででっちあげてもかまわなかったような気もしますが)
blueFire.jpg(ちなみに、背景が黒なのは後で透過処理をするときにそこが透明であると解釈されるからです)

なお、この画像をポイントスプライトに表示させるのにするべきことは、
テクスチャをオンにしてセットすることくらいで特別なことはいりません。
それでそれぞれのポイントスプライトに上の青いぼやけた炎が描かれます。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content;


public class MyGame : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    BasicEffect effect;

    VertexPositionColor[] vertices = new VertexPositionColor[3];

    ContentManager content;
    Texture2D texture;

    public MyGame()
    {
        graphics = new GraphicsDeviceManager(this);
        content = new ContentManager(Services);

        vertices[0] = new VertexPositionColor(new Vector3(0, 1, 0), Color.White);
        vertices[1] = new VertexPositionColor(new Vector3(1, 0, 0), Color.Red);
        vertices[2] = new VertexPositionColor(new Vector3(-1, 0, 0), Color.Navy);
    }

    protected override void LoadGraphicsContent(bool loadAllContent)
    {
        if (loadAllContent)
        {
            effect = new BasicEffect(graphics.GraphicsDevice, null);
            effect.Projection = Matrix.CreatePerspectiveFieldOfView(
                MathHelper.ToRadians(45),
                Window.ClientBounds.Width / (float)Window.ClientBounds.Height,
                1,
                100
                );
            effect.View = Matrix.CreateLookAt(
                new Vector3(0, 0, 3),
                new Vector3(0, 0, 0),
                new Vector3(0, 1, 0)
                );
            effect.VertexColorEnabled = true;

            texture = content.Load<Texture2D>("blueFire");
        }
    }

    protected override void UnloadGraphicsContent(bool unloadAllContent)
    {
        if (unloadAllContent) { content.Unload(); }
    }

    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

        graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(
            graphics.GraphicsDevice,
            VertexPositionColor.VertexElements
            );

        graphics.GraphicsDevice.RenderState.PointSpriteEnable = true;
        graphics.GraphicsDevice.RenderState.PointSize = 100;
        
        effect.Texture = texture;
        effect.TextureEnabled = true;

        effect.Begin();

        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();

            graphics.GraphicsDevice.DrawUserPrimitives<VertexPositionColor>(
                PrimitiveType.PointList,
                vertices,
                0,
                3
                );

            pass.End();
        }

        effect.End();
    }
}

pointSpriteWithTexture.JPG※そのままでは見にくいのでサイズを100にしました
3つのポイントスプライトに画像が表示されたと思います。
それぞれのポイントスプライトの色は違うため、それぞれ違った感じに描かれているはずです。
まともに描画されているのは白だけですね。

なお、注意すべきなのはRenderState.PointSpriteEnableプロパティ
をtrueにセットしなければならないことです。
名前からして前のサンプルからtrueにしなければならなかったようにも思えますが、
実際に問題が出るのはこのサンプルからです。

これはなぜかというと、このプロパティが制御するのは
ポイントスプライトにテクスチャがマップされるかどうか
ということだからです。

これをtrueにしなければ全てのポイントスプライトが、
テクスチャが表示されずに、真っ黒になってしまいます。
pointSpriteFailed.JPG
透過

さて、これでややましになってきましたが、まだ使い物になりません。
テクスチャに黒い縁がついているからです。
このようなポイントスプライトをどんなにならべても絶対に炎には見えないでしょう。

実用レベルにするには黒い部分を透過することが必要です。

透過を行うにはRenderState.AlphaBlendEnableプロパティをtrueにします。
そしてそれだけではまだダメで、さらに
RenderState.SourceBlend
RenderState.DestinationBlend
といったプロパティをセットして、どのような透過を行うのかを指定する必要があります。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content;
using System;


public class MyGame : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    BasicEffect effect;

    VertexPositionColor[] vertices = new VertexPositionColor[20];

    ContentManager content;
    Texture2D texture;

    public MyGame()
    {
        graphics = new GraphicsDeviceManager(this);
        content = new ContentManager(Services);

        for (int i = 0; i < vertices.Length; i++)
        {
            vertices[i] = new VertexPositionColor(
                new Vector3(
                    (float)Math.Cos(2 * Math.PI * i/vertices.Length),
                    0,
                    (float)Math.Sin(2 * Math.PI * i / vertices.Length)),
                Color.White
                );
        }
    }

    protected override void LoadGraphicsContent(bool loadAllContent)
    {
        if (loadAllContent)
        {
            effect = new BasicEffect(graphics.GraphicsDevice, null);
            effect.Projection = Matrix.CreatePerspectiveFieldOfView(
                MathHelper.ToRadians(45),
                Window.ClientBounds.Width / (float)Window.ClientBounds.Height,
                1,
                100
                );
            effect.View = Matrix.CreateLookAt(
                new Vector3(0, 3, 3),
                new Vector3(0, 0, 0),
                new Vector3(0, 1, 0)
                );

            texture = content.Load<Texture2D>("blueFire");
        }
    }

    protected override void UnloadGraphicsContent(bool unloadAllContent)
    {
        if (unloadAllContent) { content.Unload(); }
    }

    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

        graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(
            graphics.GraphicsDevice,
            VertexPositionColor.VertexElements
            );

        graphics.GraphicsDevice.RenderState.AlphaBlendEnable = true;
        graphics.GraphicsDevice.RenderState.SourceBlend = Blend.One;
        graphics.GraphicsDevice.RenderState.DestinationBlend = Blend.One;

        graphics.GraphicsDevice.RenderState.PointSpriteEnable = true;
        graphics.GraphicsDevice.RenderState.PointSize = 100;
        
        effect.Texture = texture;
        effect.TextureEnabled = true;

        effect.Begin();

        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();

            graphics.GraphicsDevice.DrawUserPrimitives<VertexPositionColor>(
                PrimitiveType.PointList,
                vertices,
                0,
                vertices.Length
                );

            pass.End();
        }

        effect.End();

        graphics.GraphicsDevice.RenderState.AlphaBlendEnable = false;
    }
}


pointSpriteCircle.JPG
ここでは、20個のポイントスプライトで円を作り、透過して描画してあります。
ゲームでミニラの放射熱線を描画したいのなら
こんな感じにするといいかもしれません。

さて、これには明らかにおかしなところがあります。
それは円の左側が上手く表示されなくなっているというところです。

奥のポイントスプライトが手前のものに隠れています。
これはデプス・バッファ(深度バッファ)がすでに手前のものになっているためです。

どういうことかというと、これは深度バッファの仕組みに関係しています。
3D空間では手前に物がある場合、より奥のものは見えません。
(もしそうでなければX線CTやMRIは必要ありません!
ついでに言うと、これは2Dでも1Dでも言える話です)


これをコンピュータで実現するために、
ピクセルは3つの色のデータに加えて、その点のカメラからの距離(深度)の情報を持っています。
(と言っても実際の距離ではなく、相対的な、0.0から1.0までの値ですが)

それぞれのピクセルを描画するたびに、深度バッファを更新していき、
もし今描画しようとしている点の深度が深度バッファの値よりも大きければ
(すなわちその点の手前に既に物があるのなら)
描画しないようになっています。

そうすれば奥のものは手前のものにうまく隠されることになります。

ここで問題になっているのはデプス・バッファのこの性質なのです。

いくらテクスチャが透過されるとはいえ、深度バッファが更新されないわけではありません。
もう既に描いたポイントスプライトの奥に新たにポイントスプライトを描画しようとすると、
深度バッファがそのポイントスプライトの深度よりも小さいため、
手前のものに隠れてしまうと解釈されて、描画されなくなってしまうのです。

この問題はどうやって解決すればいいのでしょうか?

1つの方法として、描画するときに深度バッファを更新しないと言う方法があります。
そうすれば奥のものが隠れることはなくなるでしょう。

XNA Creators SampleのParticle 3D Sampleで行われているのはこの方法です。

深度バッファの更新を一時的にストップするには、
RenderState.DepthBufferWriteEnableプロパティをfalseにします。
(用が終わったらまたtrueに戻してあげましょう。
でないと透過を使っていないほかの部分がおかしなことになるはずです。
まぁこのサンプルでは問題ないですが)


using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content;
using System;


public class MyGame : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    BasicEffect effect;

    VertexPositionColor[] vertices = new VertexPositionColor[20];

    ContentManager content;
    Texture2D texture;

    public MyGame()
    {
        graphics = new GraphicsDeviceManager(this);
        content = new ContentManager(Services);

        for (int i = 0; i < vertices.Length; i++)
        {
            vertices[i] = new VertexPositionColor(
                new Vector3(
                    (float)Math.Cos(2 * Math.PI * i/vertices.Length),
                    0,
                    (float)Math.Sin(2 * Math.PI * i / vertices.Length)),
                Color.White
                );
        }
    }

    protected override void LoadGraphicsContent(bool loadAllContent)
    {
        if (loadAllContent)
        {
            effect = new BasicEffect(graphics.GraphicsDevice, null);
            effect.Projection = Matrix.CreatePerspectiveFieldOfView(
                MathHelper.ToRadians(45),
                Window.ClientBounds.Width / (float)Window.ClientBounds.Height,
                1,
                100
                );
            effect.View = Matrix.CreateLookAt(
                new Vector3(0, 3, 3),
                new Vector3(0, 0, 0),
                new Vector3(0, 1, 0)
                );

            texture = content.Load<Texture2D>("blueFire");
        }
    }

    protected override void UnloadGraphicsContent(bool unloadAllContent)
    {
        if (unloadAllContent) { content.Unload(); }
    }

    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

        graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(
            graphics.GraphicsDevice,
            VertexPositionColor.VertexElements
            );

        graphics.GraphicsDevice.RenderState.AlphaBlendEnable = true;
        graphics.GraphicsDevice.RenderState.SourceBlend = Blend.One;
        graphics.GraphicsDevice.RenderState.DestinationBlend = Blend.One;
        graphics.GraphicsDevice.RenderState.DepthBufferWriteEnable = false;

        graphics.GraphicsDevice.RenderState.PointSpriteEnable = true;
        graphics.GraphicsDevice.RenderState.PointSize = 100;
        
        effect.Texture = texture;
        effect.TextureEnabled = true;

        effect.Begin();

        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();

            graphics.GraphicsDevice.DrawUserPrimitives<VertexPositionColor>(
                PrimitiveType.PointList,
                vertices,
                0,
                vertices.Length
                );

            pass.End();
        }

        effect.End();

        graphics.GraphicsDevice.RenderState.AlphaBlendEnable = false;
        graphics.GraphicsDevice.RenderState.DepthBufferWriteEnable = true;
    }
}

pointSpriteCircleWithoutDepthBufferWriting.JPG
上手くいきました!

こうしてみるとミニラの放射熱線というよりもガスコンロの炎に似ているような気もします。
ともかく、これをいろんな風に動かしていくことによって
爆発や煙、水滴や雪などを描画できるようになるのです。

さて、このポイントスプライトの記事はここで終わりですが、課題がまだ1つ残っています。
それは遠近法がなってないということです。
上の画像を注意深く見るとわかることですが、
円の手前と奥の密度が明らかに違います。
奥のポイントスプライトと手前のポイントスプライトの大きさが同じなのです。
これでは、1メートル先にある炎の大きさと、1万キロ先にある炎の大きさが同じと言うことになってしまいます。

この解決法は簡単ですが、技術としては難しいので後に回します。
(解決法が簡単と言うのは、こういうことです。
物はカメラからの距離が2倍になると単純に大きさは1/2、
3倍になると1/3、4倍になると1/4になるので計算は簡単です。)
(技術としては難しいと言うのは・・・
おそらくXNAを難しくしている一番の戦犯、HLSLを使わなければならないと言うことです。)

拍手[1回]

PR