glTFファイルの読み込み(WPF3Dで表示)

3Dデータ用のフォーマットは3dsCOLLADA等たくさん存在しますが、2015年にKhronos GroupよりglTFというフォーマットが公開されました。
GitHub - KhronosGroup/glTF: glTF – Runtime 3D Asset Delivery

JSONベースで記述されていますが、頂点データなどは別ファイルにバイナリとして保存されているため、高速に読み込むことが可能です。

今回はglTFを読み込んで表示してみたいと思います。

読み込むデータは下記リンクにあるDuckを利用して実装していきたいと思います。
https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/Duck/glTF
f:id:any-programming:20170814212650p:plain

glTFパース

現状すでにたくさんのパーサが存在していますが、今回はJSONライブラリを利用して自前でパースしてみたいと思います。

JSONパーサは下記のNewtonsoft.Jsonを利用しました。
NuGet Gallery | Json.NET 10.0.3

今回は必要最低限のデータをパースするよう、下記の様にGLTFクラスを準備しました。

public enum ComponentType
{
    Int8   = 5120,
    UInt8  = 5121,
    Int16  = 5122,
    UInt16 = 5123,
    Float  = 5126
}
public enum DataType
{
    VEC3,
    VEC2,
    SCALAR
}
public class Attributes
{
    public int Normal { get; set; }
    public int Position { get; set; }
    public int Texcoord_0 { get; set; }
}
public class Primitive
{
    public Attributes Attributes { get; set; }
    public int Indices { get; set; }
}
public class Mesh
{
    public Primitive[] Primitives { get; set; }
}
public class Accessor
{
    public int BufferView { get; set; }
    public int ByteOffset { get; set; }
    public ComponentType ComponentType { get; set; }
    public int Count { get; set; }
    public DataType Type { get; set; }
}
public class Image
{
    public string Uri { get; set; }
}
public class BufferView
{
    public int Buffer { get; set; }
    public int ByteOffset { get; set; }
    public int ByteLength { get; set; }
}
public class Buffer
{
    public string Uri { get; set; }
}
public class GLTF
{
    public Mesh[] Meshes { get; set; }
    public Accessor[] Accessors { get; set; }
    public Image[] Images { get; set; }
    public BufferView[] BufferViews { get; set; }
    public Buffer[] Buffers { get; set; }
}

gltfをテキストとして読み込み、GLTFとしてDeserializeObjectを呼ぶことで、各々のプロパティにデータがセットされます。

Jsonテキストとデシリアライズ先のクラスにおいて、階層とプロパティ名が同じ(デフォルトでは大文字小文字関係なし)場合、そこにデータがデシリアライズされるようになっており、Jsonテキスト側かクラス側のどちらかにしか存在しないプロパティは無視されるようになっています。

そのため上記の様にクラスを定義することで、読み込みたいデータのみをパース出来るようになっています。

string str = File.ReadAllText(path);
GLTF gltf = JsonConvert.DeserializeObject<GLTF>(str);

詳しいフォーマットはGitHubのglTFサイトを参照してください。

パースしたglTFからメッシュデータに変換

glTFでは最終的にAccessorで定義された条件でバッファを参照することとなります。

GLTFクラスからバッファデータを取得する拡張メソッドを下記の様に定義しました。

バイナリデータは別のファイルに格納されているため、ファイルの存在するディレクトリパスを渡しています。(通常はgltfファイルと同じ場所にあると思います。)

public static int Size(this ComponentType type) =>
    type == ComponentType.Float  ? 4 :
    type == ComponentType.Int16  ? 2 :
    type == ComponentType.UInt16 ? 2 :
    1;
public static int Size(this DataType type) =>
    type == DataType.SCALAR ? 1 :
    type == DataType.VEC2   ? 2 :
    3;
public static byte[][] AccessorBytes(this GLTF gltf, string directory)
{
    var buffers =
        gltf.Buffers
        .Select(x => File.ReadAllBytes($"{directory}\\{x.Uri}"))
        .ToArray();

    var bufferViews =
        gltf.BufferViews
        .Select(x => buffers[x.Buffer]
                     .Skip(x.ByteOffset)
                     .Take(x.ByteLength))
        .ToArray();

    var accessors =
        gltf.Accessors
        .Select(x => bufferViews[x.BufferView]
                     .Skip(x.ByteOffset)
                     .Take(x.Count * x.ComponentType.Size() * x.Type.Size())
                     .ToArray())
        .ToArray();

    return accessors;
}

さらにバッファデータから実際にDuckメッシュを作成する、Duckクラスを定義します。

Primitive.IndicesやPrimitive.Attributes.Positionなどにバッファのインデクスが格納されているので、そのバッファからPoint3D[]などを読み込みます。(WPF3Dで表示するため)

本来、テクスチャはMaterial→Texture→Imageの階層で参照するのですが、簡単のためImageから直接読み込んでいます。

class Duck
{
    public Point3D[] Positions { get; }
    public Vector3D[] Normals { get; }
    public Point[] Texcoords { get; }
    public int[] Indices { get; }
    public ImageSource Texture { get; }
    public Duck(string directory, GLTF gltf)
    {
        var accessorBytes = gltf.AccessorBytes(directory);

        var primitive = gltf.Meshes[0].Primitives[0];

        Indices = ConvertTo(accessorBytes[primitive.Indices], 2, (bytes, start) => 
            (int)BitConverter.ToUInt16(bytes, start)
        );
        Positions = ConvertTo(accessorBytes[primitive.Attributes.Position], 12, (bytes, start) =>
            new Point3D(BitConverter.ToSingle(bytes, start), 
                        BitConverter.ToSingle(bytes, start + 4), 
                        BitConverter.ToSingle(bytes, start + 8))
        );
        Normals = ConvertTo(accessorBytes[primitive.Attributes.Normal], 12, (bytes, start) =>
            new Vector3D(BitConverter.ToSingle(bytes, start), 
                         BitConverter.ToSingle(bytes, start + 4), 
                         BitConverter.ToSingle(bytes, start + 8))
        );
        Texcoords = ConvertTo(accessorBytes[primitive.Attributes.Texcoord_0], 8, (bytes, start) =>
            new Point(BitConverter.ToSingle(bytes, start), 
                      BitConverter.ToSingle(bytes, start + 4))
        );
        Texture = gltf.Images.Select(x => new BitmapImage(new Uri($"{directory}\\{x.Uri}"))).Single();
    }
    T[] ConvertTo<T>(byte[] bytes, int stepBytes, Func<byte[], int, T> convert)
    {
        T[] dst = new T[bytes.Length / stepBytes];
        for (int i = 0; i < dst.Length; ++i)
            dst[i] = convert(bytes, stepBytes * i);
        return dst;
    }
}


WPF3Dで表示

MeshGeometry3Dにデータをセットし、Viewport3Dを作成します。

詳しくは下記記事を参照してください。
WPFのクラスで3Dプログラミング - 何でもプログラミング

var model = new GeometryModel3D()
{
    Geometry = new MeshGeometry3D()
    {
        Positions = new Point3DCollection(mesh.Positions),
        Normals = new Vector3DCollection(mesh.Normals),
        TextureCoordinates = new PointCollection(mesh.Texcoords),
        TriangleIndices = new Int32Collection(mesh.Indices)
    },
    Material = new DiffuseMaterial(new ImageBrush(mesh.Texture)),
};

var light = new DirectionalLight(Colors.White, new Vector3D(0, 0, -1));

var camera = new PerspectiveCamera(new Point3D(0, 100, 300), new Vector3D(0, 0, -1), new Vector3D(0, 1, 0), 45);

Viewport3D viewport3D = new Viewport3D();
viewport3D.Camera = camera;
viewport3D.Children.Add(new ModelVisual3D() { Content = model });
viewport3D.Children.Add(new ModelVisual3D() { Content = light });

Content = viewport3D;

f:id:any-programming:20170814220540p:plain





WPFのクラスで3Dプログラミング

WPFで3Dプログラミングをする場合、通常は裏でDirect3Dを用意してレンダリングを行います。

ただ、とても簡単な3D描画を行いたい場合は、WPFが用意する3D描画クラスを利用することができます。

今回は簡単な実装をしてみたいと思います。

アプリケーション

今回はキーボードの上下左右で回転する立方体を表示してみたいと思います。
f:id:any-programming:20170801015918p:plain

コントロールの主体はViewport3Dで、そこにカメラやライト、メッシュを追加していく形となります。

法線は自動生成されるため、今回は作成していません。

回転はModel3DのTransformを更新することで行っています。

下記はC#で実装していますが、Xamlで記述することも可能です。

// Cubeの各三角形(12枚)を作成
var vertices = new []
{
    new Point3D(-1,  1,  1),
    new Point3D(-1, -1,  1),
    new Point3D( 1, -1,  1),
    new Point3D( 1,  1,  1),
    new Point3D(-1,  1, -1),
    new Point3D(-1, -1, -1),
    new Point3D( 1, -1, -1),
    new Point3D( 1,  1, -1)
};
Point3D[] face(int i1, int i2, int i3, int i4) =>
    new[] { i1, i2, i3, i1, i3, i4 }.Select(x => vertices[x]).ToArray();
Point3D[] positions = new[]
{
    face(0, 1, 2, 3),
    face(3, 2, 6, 7),
    face(7, 6, 5, 4),
    face(4, 5, 1, 0),
    face(0, 3, 7, 4),
    face(5, 6, 2, 1),
}.SelectMany(x => x).ToArray();

// 頂点座標と色をセット
var model = new GeometryModel3D()
{
    Geometry = new MeshGeometry3D() { Positions = new Point3DCollection(positions) },
    Material = new DiffuseMaterial(Brushes.LightGreen),
};

// ライト作成
var light = new DirectionalLight(Colors.White, new Vector3D(0, 0, -1));

// カメラ作成
var camera = new PerspectiveCamera(new Point3D(0, 0, 5), new Vector3D(0, 0, -1), new Vector3D(0, 1, 0), 45);

// 表示用コントロールの作成
Viewport3D viewport3D = new Viewport3D();
viewport3D.Camera = camera;
viewport3D.Children.Add(new ModelVisual3D() { Content = model });
viewport3D.Children.Add(new ModelVisual3D() { Content = light });

Content = viewport3D;

// KeyDownでCubeが回転するよう実装
var quaternion = new Quaternion();
KeyDown += (s, e) =>
{
    var q =
        e.Key == Key.Left  ? new Quaternion(new Vector3D(0, 1, 0), -1) :
        e.Key == Key.Right ? new Quaternion(new Vector3D(0, 1, 0),  1) :
        e.Key == Key.Up    ? new Quaternion(new Vector3D(1, 0, 0), -1) :
        e.Key == Key.Down  ? new Quaternion(new Vector3D(1, 0, 0),  1) :
        Quaternion.Identity;

    quaternion = q * quaternion;
    model.Transform = new RotateTransform3D(new QuaternionRotation3D(quaternion));
};


法線やインデックス

MeshGeometry3DにはNormalsとTriangleIndicesが設定可能となっています。

テクスチャ

MeshGeometry3DのTextureCoordinatesを設定し、DiffuseMaterialにImageBrushを設定します。

カリングOFF

GeometryModel3DのBackMaterialを設定するとOFFになります。

ライン描画

残念ながら対応していません。細いポリゴンを描くしかなさそうです。

オブジェクト毎のマウスイベントハンドリング

ModelVisual3Dの代わりにModelUIElement3Dを利用すると、MouseMoveなどが登録できるようになります。

2Dコントロール(Buttonなど)を3D上に配置

Viewport2DVisual3Dを用いると可能となります。





JavaScript(ES6)でElm Architecture(Virtual-DOMなし) --- 可変個コントロール

下記記事にてJavaScriptにElmのModelとUpdateの機構を取り入れてみました。
JavaScript(ES6)でElm Architecture(Virtual-DOMなし) - 何でもプログラミング

今回は引き続き、可変個のコントロールに対応してみたいと思います。

アプリケーションコード

カウンターを追加、削除できるアプリケーションを実装してみたいと思います。

f:id:any-programming:20170723141650p:plain

可変個のコントロールの定義として、htmlのtemplate要素を利用しています。

また可変個コントロールの動作の実装にbindItemsを利用しています。(実装はのちほど)

その他CellクラスやstartApp関数は下記記事を参照してください。
JavaScript(ES6)でElm Architecture(Virtual-DOMなし) - 何でもプログラミング

<button id="add">add</button>
<button id="remove">remove</button>

<!-- 可変個カウンタ -->
<div id="counts">
    <template>
        <div>                 
            <button class="dec">dec</button>
            <input type="text" class="count"></input>
            <button class="inc">inc</button>
        </div> 
    </template>
</div>
const initialModel = {
    counts : []
};

const message = {
    addCounter : Symbol(),
    removeCounter : Symbol(),
    increment : Symbol(),
    decrement : Symbol()
}

function update(model, msg){
    switch (msg.id) {
        case message.addCounter :
            return copy(model, {counts : model.counts.insertLast(0)});

        case message.removeCounter :
            return copy(model, {counts : model.counts.removeLast()});
        
        case message.increment : 
            return copy(model, {counts : model.counts.updateAt(msg.index, x => x + 1)});

        case message.decrement :
            return copy(model, {counts : model.counts.updateAt(msg.index, x => x - 1)});

        default : throw "invalid message"
    }
}

function bind(model, send) {
    byId("add").onclick = () => send({ id: message.addCounter });
    byId("remove").onclick = () => send({ id: message.removeCounter });

    // 可変個カウンタBinding
    bindItems(model.map(x => x.counts), "counts", (item, index, element) =>
    {
        const byClass = x => element.getElementsByClassName(x)[0];

        item.listen(x => byClass("count").value = x);
        byClass("dec").onclick = () => send({ id: message.decrement, index: index });
        byClass("inc").onclick = () => send({ id: message.increment, index: index });
    });
}

window.onload = () =>
    startApp(initialModel, update, bind);


bindItems

itemsのCell、親要素のID、コントロールのbindingを受け取る関数になります。

要素内のtemplateを取得し、実際にコントロールを配置するdiv要素を追加しています。

itemsが変更されたときに、変更の送出 or コントロールの作成 or コントロールの削除を行っています。

function bindItems(itemsCell, parentId, bind) {
    const parent = byId(parentId);
    const itemTemplate = parent.getElementsByTagName("template")[0];
    const panel = document.createElement("div");
    parent.appendChild(panel);
    const cells = [];
    itemsCell.listen(items =>
    {
        for (let i = 0; i < items.length; i++)
        {
            // コントロールが存在
            if (i < cells.length)
            {
                cells[i].send(items[i]);
            }
            // コントロール不足
            else
            {
                const cell = new Cell(items[i]);
                const element = document.importNode(itemTemplate.content, true);
                cells.push(cell);
                panel.appendChild(element);
                bind(cell, i, panel.lastElementChild);
            }
        }
        // コントロール過剰
        for (let i = 0; i < cells.length - items.length; i++)
        {
            cells.pop();
            panel.lastElementChild.remove();
        }
    });
}


外側のスコープのCellをbind内で利用するとリーク

スコープ外のCellを利用すると、コントロールの参照がCell内に残り続けてリークを起こしてしまいます。

そのため、スコープ外のCellを利用する場合は、一旦キャプチャしてbind関数の引数に追加する必要があります。

実装例ですが、C#のものが下記にありますので参照してください。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング





JavaScript(ES6)でElm Architecture(Virtual-DOMなし)

Elm自体がJavaScriptを生成するものなので実際には利用することはないと思いますが(さらに生のJavaScriptがよければReact.jsがあります)、ElmのModelとUpdateの仕組みを生のJavaScriptで実装してみました。

WPFC#で実装されたものは、下記記事にて参照できます。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング

アプリケーションコード

簡単なカウンタアプリを実装します。

f:id:any-programming:20170717201656p:plain

initialModel 状態の初期値を定義
message update関数で処理させる内容の識別子
update 現在のmodelと、messageを受け取って、新しいmodelを返す
bind modelのCell(現在値+変更通知)と、send(message)関数を、html要素にバインドする

CellはFunctional Rective Programming(FRP)でのBehaviorを意図しており、詳しくは下記を参照してください。
SodiumでFunctional Reactive Programming (F#) --- 導入 - 何でもプログラミング

<body>
    <button id="dec">dec</button>
    <input type="text" id="count"></input>
    <button id="inc">inc</button>
</body>
const initialModel = {
    count : 0,
};

const message = {
    increment : Symbol(),
    decrement : Symbol(),
}

function copy(obj, diff) {
    return Object.assign({}, obj, diff);
}

function update(model, msg){
    switch (msg.id) {
        case message.increment :   
            return copy(model, {count : model.count + 1});
        case message.decrement :
            return copy(model, {count : model.count - 1});
        default : throw "invalid message"
    }
}

function byId(name) {
    return document.getElementById(name);
}

function bind(model, send) {
    model.map(x => x.count).listen(x => byId("count").value = x);

    byId("dec").onclick = () => send({ id: message.decrement });
    byId("inc").onclick = () => send({ id: message.increment });
}

window.onload = () =>
    startApp(initialModel, update, bind);


Cellクラス

listenでコールバックを登録し、sendでデータを送出し、mapでデータの変換を行います。

listenした後、コールバックを解除する機能は実装してありませんので、利用する際(コントロールを動的に追加or削除)はリークに注意してください。

FRPのライブラリSodiumにJavaScript版がありますので、そちらのCellを利用することも可能です。

class Cell {
    constructor(initialValue) {
        this.value = initialValue;
        this.callbacks = [];
    }
    listen(f) {
        f(this.value);
        this.callbacks.push(f);
    }
    send(value) {
        if (this.value === value)
            return;
        this.value = value;
        this.callbacks.forEach(f => f(value));
    }
    map(f) {
        const cell = new Cell(f(this.value));
        this.callbacks.push(x => cell.send(f(x)));
        return cell;
    }
}


startApp

initialModelとupdateから、modelのCellとsend関数を作成し、bindを呼び出しています。

function startApp(initialModel, update, bind){
    const cell = new Cell(initialModel);
    const send = msg => cell.send(update(cell.value, msg));
    bind(cell, send);
}






WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール --- データによりコントロールの種類変更

下記記事にて可変個のコントロールを表示できるよう実装しました。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング

ただし、すべてのコントロールが同じで個数のみが可変であったため、今回は入力データによってコントロールが分岐するような実装をしてみたいと思います。

アプリケーションコード

ボタンを押すと対応した図形が追加され、図形上で左クリックでサイズが大きくなり、図形上で右クリックをすると削除されるようなアプリケーションを作成してみます。

f:id:any-programming:20170717193643p:plain

itemsとselectorとfactoryが渡せるwith_Childrenを用いてコントロールの切り替えを実現しています。(with_Childrenの実装は後述)

コード内で利用されているLINQオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング

図形モデル定義

interface IShape
{
    double Size { get; }
}
class Circle : IShape
{
    public double Size { get; }
    public Circle(double size) { Size = size; }
}
class Square : IShape
{
    public double Size { get; }
    public Square(double size) { Size = size; }
}
class Triangle : IShape
{
    public double Size { get; }
    public Triangle(double size) { Size = size; }
}

アプリケーションモデル定義

class Model
{
    public IShape[] Shapes { get; }
    public Model(IShape[] shapes) { Shapes = shapes; }
}

モデル更新メッセージ定義

abstract class Message { }
class AddCircle : Message { }
class AddSquare : Message { }
class AddTriangle : Message { }
class IncrementSize : Message
{
    public int Index { get; }
    public IncrementSize(int index) { Index = index; }
}
class RemoveShape : Message
{
    public int Index { get; }
    public RemoveShape(int index) { Index = index; }
}

モデル更新関数

public static Model Update(Model model, Message message)
{
    switch (message)
    {
        case AddCircle msg:
            return new Model(model.Shapes.InsertLast(new Circle(30)).ToArray());

        case AddSquare msg:
            return new Model(model.Shapes.InsertLast(new Square(30)).ToArray());

        case AddTriangle msg:
            return new Model(model.Shapes.InsertLast(new Triangle(30)).ToArray());

        case IncrementSize msg:
            {
                IShape increment(IShape src) =>
                    src is Circle ? new Circle(src.Size + 10) as IShape :
                    src is Square ? new Square(src.Size + 10) as IShape :
                    new Triangle(src.Size + 10) as IShape;
                return new Model(model.Shapes.UpdateAt(msg.Index, increment).ToArray());
            }

        case RemoveShape msg:
            return new Model(model.Shapes.RemoveAt(msg.Index).ToArray());

        default:
            throw new Exception("invalid message");
    }
}

View作成関数

enum Shape
{
    Circle,
    Square,
    Triangle
}
public static FrameworkElement Create(ICell<Model> model, Action<Message> send)
{
    return new StackPanel()
        .with_Margin(10, 10, 10, 10)
        .with_Children(
            new Button()
            .with_Content("Circle追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddCircle())),

            new Button()
            .with_Content("Square追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddSquare())),

            new Button()
            .with_Content("Triangle追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddTriangle())),

            new StackPanel()
            .with_Children(
                model.Map(x => x.Shapes),

                item =>
                    item is Circle ? Shape.Circle :
                    item is Square ? Shape.Square :
                    Shape.Triangle,
                    
                (id, item, index) =>
                {
                    var element =
                        id == Shape.Circle ? new Ellipse().with_Fill(Brushes.Red) as FrameworkElement:
                        id == Shape.Square ? new Rectangle().with_Fill(Brushes.Green) as FrameworkElement:
                        new Path().with_Data(Geometry.Parse("M0,100 H100 L50,0 Z")).with_Stretch(Stretch.Fill).with_Fill(Brushes.Blue) as FrameworkElement;

                    return element
                        .with_Margin(5, 5, 5, 5)
                        .with_Width(item.Map(x => x.Size))
                        .with_Height(item.Map(x => x.Size))
                        .with_MouseLeftButtonDown(x => send(new IncrementSize(index)))
                        .with_MouseRightButtonDown(x => send(new RemoveShape(index)));
                }
            )
        );
}


with_Children

固定コントロールの場合のwith_Childrenと比べて、入力データからコントロールの識別子を返すselector関数が追加され、factoryの引数にも識別子が渡されるようになっています。

新しいデータのコントロール識別子に変更がなければそのままデータを送出し、識別子が異なる場合はコントロールを作り直しています。

もっと賢い差分アルゴリズムを利用すると、コントロールの生成が最小限に抑えられると思います。

public static TPanel with_Children<TPanel, TItem, TViewID>(
    this TPanel element, 
    ICell<TItem[]> items, 
    Func<TItem, TViewID> selector, 
    Func<TViewID, ICell<TItem>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();
    List<TViewID> viewIDs = new List<TViewID>();
    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            // コントロールが存在
            if (i < sinks.Count)
            {
                TViewID viewID = selector(xs[i]);

                // ViewID変更なし 
                if (Equals(viewID, viewIDs[i]))
                {
                    sinks[i].Send(xs[i]);
                }
                // ViewIDが変わった
                else
                {
                    sinks[i] = new CellSink<TItem>(xs[i]);
                    viewIDs[i] = viewID;
                    element.Children.RemoveAt(i);
                    element.Children.Insert(i, factory(viewID, sinks[i], i));
                }
            }
            // コントロールが足りない
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                viewIDs.Add(selector(xs[i]));
                element.Children.Add(factory(viewIDs.Last(), sinks.Last(), i));
            }
        }
        // コントロールが過剰
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            viewIDs.RemoveAt(viewIDs.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


factoryの外側のスコープのCellを利用する場合

そのままCellを利用するとリークを起こしてしまうので、別途追加のCellを受け取るwith_Childrenが必要となります。

下記記事にても同様の実装がありますので、こちらを参照してください。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング





Throttle(一定時間リクエストがなければ実行をする)の実装

スライダーなどから大量のリクエストが送られてきたときに、全て処理する必要がない場合、一定時間リクエストがなければ最新の値で処理を行うことがあります。

ReactiveExtensionにもThrottleオペレータは用意されていますので、普段はこれを利用するので問題ないと思います。
GitHub - Reactive-Extensions/Rx.NET: The Reactive Extensions for .NET

今回はThrottle処理を行うクラスを実装してみたいと思います。

Throttleクラス

Delayで処理を遅らせたTaskを利用します。

リクエストが来たら、現在のTaskをキャンセルして新しいTaskを走らせる、とても簡単な内容になっています。

public class Throttle
{
    CancellationTokenSource _cancel = new CancellationTokenSource();
    int _milliseconds;
    public Throttle(int milliseconds) =>
        _milliseconds = milliseconds;
    public void Post(Action action)
    {
        // 現在のTaskをキャンセル
        _cancel.Cancel();
        _cancel = new CancellationTokenSource();

        // 指定時間待ったあと処理を行う
        Task.Delay(_milliseconds, _cancel.Token)
            .ContinueWith(task => action(), _cancel.Token);
    }
}


動作確認

スライダーを動かすと、値がテキストに反映されるアプリを作成してみます。

スライダーを動かしてから1秒以内にまたスライダーを動かす限り、テキストが更新されません。

現状Postは別スレッドで実行されるため、InvokeAsyncでUIスレッドでテキストを更新しています。

f:id:any-programming:20170714181130p:plain

var throttle = new Throttle(1000);

slider.ValueChanged += (s, e) =>
    throttle.Post(() =>
        textblock.Dispatcher.InvokeAsync(() => 
            textblock.Text = ((int)e.NewValue).ToString()));






WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール

下記記事にてXamlを利用しないElm Architectureを実装しました。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング

ただし、固定個のコントロールにしか対応していませんでした。

今回はGridなどのChildrenを動的に変更できるよう実装してみたいと思います。

アプリケーションコード

今回は可変個のカウンタを利用できるアプリケーションを作成してみます。
f:id:any-programming:20170712201450p:plain

動的にコントロールを作成するために、with_ChildrenにitemsのCellとFactoryを渡せるよう実装してあります。

利用されているLINQのオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング

class Model
{
    public int[] Counts { get; }
    public Model(int[] counts) { Counts = counts; }
}
abstract class Message { }
class AddCounter : Message { }
class RemoveCounter : Message { }
class Increment : Message
{
    public int Index { get; }
    public Increment(int index) { Index = index; }
}
class Decrement : Message
{
    public int Index { get; }
    public Decrement(int index) { Index = index; }
}

class Updater
{
    public static Model Update(Model model, Message message)
    {
        switch (message)
        {
            case AddCounter msg:
                return new Model(model.Counts.InsertAt(model.Counts.Length, 0).ToArray());

            case RemoveCounter msg:
                return new Model(model.Counts.RemoveAt(model.Counts.Length - 1).ToArray());

            case Increment msg:
                return new Model(model.Counts.UpdateAt(msg.Index, x => x + 1).ToArray());

            case Decrement msg:
                return new Model(model.Counts.UpdateAt(msg.Index, x => x - 1).ToArray());

            default:
                throw new Exception("invalid message");
        }
    }
}
public static FrameworkElement Create(ICell<Model> model, Action<Message> send)
{
    return new StackPanel()
        .with_Margin(10, 10, 10, 10)
        .with_Children(                
            new Button()
            .with_Content("カウンタ追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddCounter())),

            new Button()
            .with_Content("カウンタ削除")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new RemoveCounter())),

            new StackPanel()
            .with_Children(model.Map(x => x.Counts), (count, index) =>
            {
                return new Grid()
                    .with_Margin(5, 5, 5, 5)
                    .with_ColumnDefinitions(
                        new ColumnDefinition() { Width = new GridLength(50) },
                        new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) },
                        new ColumnDefinition() { Width = new GridLength(50) }
                    )
                    .with_Children(
                        new MyButton()
                        .with_GridColumn(0)
                        .with_Content("-")
                        .with_Click(() => send(new Decrement(index))),
 
                        new TextBlock()
                        .with_GridColumn(1)
                        .with_HorizontalAlignment(HorizontalAlignment.Center)
                        .with_VerticalAlignment(VerticalAlignment.Center)
                        .with_Text(count.Map(x => x.ToString())),

                        new Button()
                        .with_GridColumn(2)
                        .with_Content("+")
                        .with_Click(() => send(new Increment(index)))
                    );
            })
        );
}


with_Children

itemsの要素数が変動した時のみコントロールの追加、削除を行うよう実装してあります。

CellSinkとコントロールは1対1で準備されるため、コントロールを削除しても問題なくガーベッジコレクション対象となります。

public static TPanel with_Children<TPanel, TItem>(
    this TPanel element,
    ICell<TItem[]> items,
    Func<ICell<TItem>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();
    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            // コントロールが存在 : CellSink.Send
            if (i < sinks.Count)
            {
                sinks[i].Send(xs[i]);
            }
            // コントロールが足りない : CellSink、コントロール作成
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                element.Children.Add(factory(sinks.Last(), i));
            }
        }
        // コントロールが過剰 : CellSink、コントロール削除
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


factoryの外側のスコープのCellを利用するとリーク発生

今回の例では、factory内でCellは引数のcountしか利用しませんでした。

.with_Text(count.Map(x => x.ToString()))

もし下記のようにスコープ外のCellを利用した場合、コントロールを削除してもCellのコールバック内に参照が残り続けるため、メモリリークが発生してしまいます。

.with_Text(model.Map(x => x.Counts.Length.ToString()))


追加のCellを受け取るwith_Children

関数の引数とfactoryの引数に、新たにCellを1つ追加してあります。

各コントロールごとにCellSinkを用意することにより、本体のCellのほうにコントロールの参照が残らないようにしています。

parameterを2つ、3つ渡したい場合は、同様の実装方法でwith_Children関数を追加してください。

public static TPanel with_Children<TPanel, TItem, TParam>(
    this TPanel element,
    ICell<TItem[]> items,
    ICell<TParam> parameter,
    Func<ICell<TItem>, ICell<TParam>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();

    List<CellSink<TParam>> parameters = new List<CellSink<TParam>>();
    parameter.Listen(x => parameters.ForEach(sink => sink.Send(x)));

    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            if (i < sinks.Count)
            {
                sinks[i].Send(xs[i]);
            }
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                parameters.Add(new CellSink<TParam>(parameter.Value));
                element.Children.Add(factory(sinks.Last(), parameters.Last(), i));
            }
        }
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            parameters.RemoveAt(parameters.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


外側のCellを利用するのを防ぐには

リークのしないwith_Childrenを用意しても、外部のCellを利用する記述を禁止すること自体はできません。

対策例として、factoryのキャプチャ変数の型を確認して、ICellが存在したら例外を出す方法があげられます。

if (factory.Target != null &&
    factory.Target.GetType().GetFields().Any(t =>
        t.FieldType.IsGenericType && t.FieldType.GetGenericTypeDefinition() == typeof(ICell<>)))
    throw new Exception("factory can't capture ICell");

検討はしていませんが、Roslynを利用してコンパイルエラーにするのも可能かも知れません。