2013年4月1日月曜日

wpf : 3Dで表裏のある板を描画

簡単なカードゲームでも作ろうかと考えています。 ちょっとだけ凝って3D表示にして、カードの裏返しなどを表現できるようにするつもりです。 ただ、いわゆる板ポリの表裏に絵を描いただけではさすがに寂しいのでカードにちょっと厚みを持たせたいですね。 カードの選択ができるようにマウスイベントも欲しいです。

というわけで適当にwpfの3D表示のサンプルコードを検索。 こんなサイトが見つかりました。

e-manualさんのコードを元にカード(コード内ではPlateクラス)を表示するサンプルプログラムを作成。 それにマウスイベントのコードを追加してみました。

とりあえず、

  • カードの回転
  • カードの移動
  • カードのマウスイベント

ができました。

ちなみに、本格的な3Dゲームを作るにはwpfは重いらしいですね。 簡単なカードゲームを作るだけならwpfで十分です。

  • サンプルコード ... データURIスキームでのコード配布。一部のブラウザでのみダウンロード可。拡張子zipで保存してください。

まずはカード1枚分のモデルを表すコードです。

// Plate.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Media3D;

namespace 適当なネームスペース
{
    // ModelUIElement3Dのラッパー
    class Plate
    {
        private static readonly ScaleTransform3D SCALE_TRANSFORM = new ScaleTransform3D(1, 1, 0.05);

        // 注) RegisterName用。
        //     Plateクラスを大量に使うことは考えていない。
        //     大量に使う場合はStoryboardまわりを改修する事。
        private static int _id;
        private static string ID
        {
            get
            {
                lock(SCALE_TRANSFORM)
                {
                    _id++;
                    if (0xffff < _id)
                    {
                        _id = 0;
                    }
                    return _id.ToString("x4");
                }
            }
        }

        private readonly ModelUIElement3D _model;
        public ModelUIElement3D Model
        {
            get
            {
                return _model;
            }
        }

        private readonly VisualBrush _brushFront = new VisualBrush();
        private readonly VisualBrush _brushBack = new VisualBrush();
        private readonly AxisAngleRotation3D _angleRotation = new AxisAngleRotation3D(new Vector3D(1, 1, 0), 0);
        private readonly TranslateTransform3D _translateTransform = new TranslateTransform3D();
        private readonly Panel3D _owner;
        private readonly string _name;
        private readonly string _nameAngleRotation;
        private readonly string _nameTranslateTransform;
        private Storyboard _storyboard;

        public Plate(Panel3D owner)
        {
            _owner = owner;
            _name = "plate_" + ID;
            _nameAngleRotation = _name + "_angle";
            _nameTranslateTransform = _name + "_translate";

            var l = new Label();
            l.Content = "表";
            l.Background = Brushes.White;
            _brushFront.Visual = l;
            l = new Label();
            l.Content = "裏側ですよ";
            l.Background = Brushes.White;
            _brushBack.Visual = l;

            _model = new ModelUIElement3D();
            var group = new Model3DGroup();

            var surface = CreateSurface(_brushFront);
            group.Children.Add(surface);

            surface = CreateSurface(null);
            surface.Transform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0,1,0),90));
            group.Children.Add(surface);

            surface = CreateSurface(_brushBack);
            surface.Transform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 1, 0), 180));
            group.Children.Add(surface);

            surface = CreateSurface(null);
            surface.Transform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 1, 0), -90));
            group.Children.Add(surface);

            surface = CreateSurface(null);
            surface.Transform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(1, 0, 0), 90));
            group.Children.Add(surface);

            surface = CreateSurface(null);
            surface.Transform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(1, 0, 0), -90));
            group.Children.Add(surface);

            var transGroup = new Transform3DGroup();
            transGroup.Children.Add(SCALE_TRANSFORM);
            transGroup.Children.Add(new RotateTransform3D(_angleRotation));
            transGroup.Children.Add(_translateTransform);
            group.Transform = transGroup;

            _model.Model = group;

            _model.MouseUp += (sender, evt) =>
            {
                MessageBox.Show("MouseUp");
            };
        }

        public void OnLoaded()
        {
            _storyboard = new Storyboard();

            _owner.RegisterName(_nameAngleRotation, _angleRotation); // Loadedイベント後じゃないと例外発生
            var animation = new DoubleAnimation();
            animation.From = 0;
            animation.To = 360;
            animation.RepeatBehavior = RepeatBehavior.Forever;
            animation.Duration = TimeSpan.FromMilliseconds(5000);
            Storyboard.SetTargetName(animation, _nameAngleRotation);
            Storyboard.SetTargetProperty(animation, new PropertyPath(AxisAngleRotation3D.AngleProperty));
            _storyboard.Children.Add(animation);

            _owner.RegisterName(_nameTranslateTransform, _translateTransform);
            animation = new DoubleAnimation();
            animation.From = -3;
            animation.To = 3;
            animation.RepeatBehavior = RepeatBehavior.Forever;
            animation.Duration = TimeSpan.FromMilliseconds(9000);
            Storyboard.SetTargetName(animation, _nameTranslateTransform);
            Storyboard.SetTargetProperty(animation, new PropertyPath(TranslateTransform3D.OffsetYProperty));
            _storyboard.Children.Add(animation);

            _storyboard.Begin(_owner);
        }

        private GeometryModel3D CreateSurface(Brush brush)
        {
            var geometry = new GeometryModel3D();
            geometry.Geometry = CreateMesh();
            geometry.Material = new DiffuseMaterial(brush != null ? brush : Brushes.LawnGreen);

            return geometry;
        }

        private MeshGeometry3D CreateMesh()
        {
            var mesh = new MeshGeometry3D();
            mesh.Positions = new Point3DCollection(new Point3D[]{
                new Point3D(-1,-1,1),
                new Point3D(1,-1,1),
                new Point3D(1,1,1),
                new Point3D(-1,1,1)
            });
            mesh.TriangleIndices = new Int32Collection(new int[] { 0, 1, 2, 0, 2, 3 });
            mesh.TextureCoordinates = new PointCollection(new Point[] {
                new Point(0,1),
                new Point(1,1),
                new Point(1,0),
                new Point(0,0)
            });
            return mesh;
        }
    }
}

3DCGについて本格的に勉強する気は全く無いのでほぼ参考コードをパクッただけですね。 一応、xamlのコードを全部csのコードに書き換えたけど。 ホントはModelUIElement3Dを継承させたかったんですが、ModelUIElement3Dがsealedだったのでこんな形になりました。

Geometryについて、ホントは1つのGeometryで直方体を作るのがいいんでしょうけど、このサンプルでは参考にしたサンプルコードそのままに6つの板ポリを組み合わせた立方体を作っています。 立方体をScaleTransform3Dで潰して板状にしているだけです。

このやり方だと、テクスチャの設定が楽でいいですね。 ブラシを設定したら勝手に引き伸ばされて表示されました。 (テクスチャの引き伸ばしの詳細については未確認です。) 1つのGeometryで直方体を作ったらマッピングの調節とかが面倒くさそうです。 いや、「こんな小さなオブジェクトならマッピングなんてちょっとの手間だろ調べろよ」って話なんですけどね。 それすらも面倒くさくって……

テクスチャの設定が楽な変わりに、少々短所もあります。

  • 頂点や辺が繋がっていないので、切れ目が正確にレンダリングされない可能性がある。
  • 頂点のデータがダブっているのでメモリの使用量が増える。

これについては、シンプルなオブジェクトを数十個しか使わないならたいした短所にはならないハズ。 このサンプルのブラシをImageBrushにしたら簡単なカードゲームは作れそうですね。

オブジェクトの回転や移動にはStoryboardを使用しています。 Storyboardはxamlで書く場合や、c#のコードでもFrameworkElementを使う場合はシンプルなコードで済みます。 しかし、AxisAngleRotation3DなどのFrameworkElementではないクラスにはそのまま使えません。

  • オブジェクトの親(祖先)へのRegisterName。
  • アニメーションクラスへのSetTargetName。

が必要になります。 RegisterNameはLoadedイベントの後にしましょう。 名前がかぶらないように注意。 アニメーションクラスのパラメータ設定やターゲットプロパティの設定はFrameworkElementに使うときと同じです。

おそらく、自作クラスをStoryboardで使うときも、依存プロパティを作っておけば使える……のかな?

Plateクラスについては以上です。 次はPlateを表示するViewport3Dです。

// Panel3D.cs
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Media3D;

namespace 適当なネームスペース
{
    class Panel3D : Viewport3D
    {
        public Panel3D()
        {
            //Camera = new OrthographicCamera(new Point3D(0, 0, 5), new Vector3D(0, 0, -5), new Vector3D(0, 1, 0), 30);
            Camera = new PerspectiveCamera(new Point3D(0, 0, 10), new Vector3D(0, 0, -10), new Vector3D(0, 1, 0), 30);

            var modelLight = new ModelVisual3D();
            //modelLight.Content = new AmbientLight(Colors.White);
            modelLight.Content = new DirectionalLight(Colors.White, new Vector3D(-3, 3, -100));
            this.Children.Add(modelLight);

            var container = new ContainerUIElement3D();

            var testPlate = new Plate(this);
            container.Children.Add(testPlate.Model);

            this.Children.Add(container);
            Loaded += (sender, evt) =>
            {
                testPlate.OnLoaded();
            };
        }
    }
}

まずはカメラを設定。そしてChildrenにライトを追加です。 ライトは、本格的な3Dアプリでは指向性ライト、環境光、点光源やスポットライトなどを複数組み合わせて使います。 ライトが増えればそれだけ処理も重くなります。 その他色々な光源や高度な光の表現もあるみたいだけどここではノータッチで。 そういうのはwpfでやることでもないでしょうしね。

UIで3Dを使うだけなら環境光(AmbientLight)に白色を設定して使うといいでしょう。 白いAmbientLightを使うと一切の影が付きません。 影で立体感を表せないですが、テクスチャに影が付くこともなく、テクスチャがそのまま表示されます。 例えば画像アルバムのサムネイルを3Dでくるくる回したいときなど、影が付いたらサムネイルが見づらくなりますよね? そんなときは白いAmbientLightが役に立ちます。 ただし、単色のオブジェクトの折れ目とか曲面とかは全く見分けがつかなくなるので、それには注意。

で、このサンプルコードではちょっとだけ影も付けたいのでシンプルに指向性ライト(DirectionalLight)を使っています。 指向性ライトは光線とオブジェクトの角度で影が付きます。 板ポリの場合、手前から奥に指向性ライトを当てれば、正面から見たときはそのまま表示、回ったときだけ影が付きます。 光量の減衰とか設定できればそれっぽくなるかと思ったんですが、どうせ調整が面倒になるので断念。 単純なゲームを作るだけならコレでいいでしょう。

子にPlateクラスしか登録する予定が無いのでContainerUIElement3Dを使っています。 PlateクラスがModelUIElement3Dを継承できれば良かったんですけど、継承できないのでPlateクラスそのものではなく、Plateクラスが持つModelUIElement3Dをコンテナに登録しています。

そんなに使いまわすものでもないでしょうから、xamlから使えるようにはしていません。 C#のコード、MainWindowの初期化などで

grid.Children.Add(new Panel3D());

のようにして使います。