
こんにちは、トイロジックでツール業務を担当しているプログラマーのIです。主にプロジェクトで使用するGUI/CUIツールやDCCツールの作成・整備を行っています。
本記事では、社内エンジンである「Toylo Engine」が提供する「ShaderEditor」の高速化をどのように行ったのかをご紹介しようと思います。
また、旧ShaderEditorから開発効率向上のために対応したものなども一緒にご紹介できればと思っています。
弊社のツールは Visual Studio 2022 で .NET8 + WPF を使用して作成しています。また、ノードグラフの表示には「GraphX (※1)」というオープンソースを使用しています。
目次
ShaderEditorとは?
そもそもShaderEditor
ってなに?という方もいらっしゃるかと思います。
ShaderEditorはノードベースでマテリアルを作成するためのツールです。
Unreal Engineの「マテリアルエディタ ※1」を思い浮かべてもらえると分かりやすいかと思います。
Toylo Engineのマテリアルは、プログラマーがHLSLを書いて作るか、プログラマーもしくはTAがShaderEditorを使用して作成します。
実際に使われるようになったらわかる想定外
ShaderEditorを公開して、実際に使われ始めると作られるマテリアルが想定よりもかなり多いノード数が使用されるマテリアルが存在することが分かりました。
弊社のShaderEditorには「ユーザー関数」と呼ばれる、Unreal Engineのマテリアルエディタで言うところの折り畳み(※1)のような機能がります。
違うところとしては、ユーザー関数では指定したノードをユーザー関数ファイルとして保存するとこができ、それをノードグラフ上で参照ノードとして参照することができるという点です。
こちらのユーザー関数や、CustomCodeノード(※2)などを使用しても1000ノード以上ノードが存在しており、ファイルを開くのにも数分かかるレベル、ノードグラフのPANやZoom、ノードの追加移動などを行うのもかなりの重さでした。
もちろん、コード生成も数分かかるレベルでビルド時間にも影響を与えていました。
すべてのマテリアルこのレベルでノードが多いわけではないので、これほど重くなるのは一部のみでしたが、開発ツールとしては許容できるものではありませんでした。
現在のShaderEditorでは、同じマテリアルでも、数秒でファイルを開けますし、ほぼストレスないレベルで作業が行えるまで高速化を行うことができました。
どのように高速化を行ったのかの一部をご紹介できればと思います。
社内のツールフレームワークの設計見直しなども行っているため、今回の内容が高速化のすべてというわけではありません。
このようにして高速化を行った
.NETのバージョンを上げる
一番簡単ですぐにできる高速化として、.NETのバージョンを上げるというのがあります。
弊社では、.NET5 から .NET8 へバージョンを上げました。
.NETはバージョンが上がるごとに内部的に高速化が行われています。WPFもその対象です。
多少でありますが、バージョンを上げるだけで高速化に繋がります。
ノードのコントロールを軽量化
旧ShaderEditorのノードはソケットまで含めすべてXaml上でコントロールを組み合わせて表示していました。
これらは、ソケットから数値ボックスまですべてコントロールが作成されます。
もちろん、ソケットも1つだけというわけでもないため、入力/出力スロットで複数表示するためにItemsControl
が使用されていたりしました。
ノードの数が少なければそこまで気になりませんが、ノード数が多くなるほど、ノードコントロールを生成するコストや描画するコストが上がります。
そこで、ノードの中身の描画はコントロールを組み合わせるのではなく、OnRenderメソッドで自分で描画することにしました。
今回 OnRender メソッドで描画するようにしたのは以下の赤枠の部分ですね。
GraphXの「VertexControl」を継承した「NodeVertexControl」というカスタムコントロールを作成し、タイトル用のコンテンツと、ノードのコンテンツを受け取って表示するようにしています。
<ControlTemplate TargetType="{x:Type local:NodeVertexControl}">
<Border x:Name="RootBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border x:Name="NodeTitle"
Grid.Row="0"
Background="{TemplateBinding BorderBrush}">
<ContentPresenter Margin="5"
Content="{TemplateBinding TitleContent}"
ContentTemplate="{TemplateBinding TitleContentTemplate}"
ContentTemplateSelector="{TemplateBinding TitleContentTemplateSelector}"/>
</Border>
<ContentPresenter x:Name="PART_NodeContent"
Grid.Row="1"
Margin="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"/>
<local:CommentBubble x:Name="CommentBubble"
Grid.Row="0"
Background="{TemplateBinding CommentBubbleBackground}"
Foreground="{TemplateBinding CommentBubbleForeground}"
BubbleStroke="{TemplateBinding CommentBubbleStroke}"
BubbleStrokeThickness="{TemplateBinding CommentBubbleStrokeThickness}"
Comment="{TemplateBinding CommentBubble}"
IsHitTestVisible="False"
Visibility="Collapsed"/>
<!-- Selection -->
<Canvas x:Name="PART_Selection" Grid.Row="0" Grid.RowSpan="2"/>
</Grid>
</Border>
以下略
</ControlTemplate>
この「NodeContent」
の部分に「VertexControlContentCanvas」
という名のカスタムコントロールを表示しています。
これが何をしているかというと簡単です、OnRenderメソッドで各ソケットの描画や、ノードに表示する値の描画、マウスオーバー時のハイライトまですべて行っています。
<ControlTemplate TargetType="{x:Type local:VertexControlContentCanvas}">
<Border/>
</ControlTemplate>
このコントロールはTemplateにBorderしかもっていません。
表示する必要のあるソケットの情報はビューモデルからすべてわかります。
そこから、ノード内での各ソケットのテキストの位置や、接続ポイントの位置、ノード上に表示する値の位置などのレイアウトを計算し、OnRenderメソッドでそのレイアウトに沿って「drawingContext.DrawEllipse」
や「drawingContext.DrawText」
を使用して描画しているだけです。
例として
// 入力スロットの描画
foreach (ref readonly var layout in contentLayout.InputLayouts.AsSpan())
{
var color = layout.SlotBase.Context.DataTypeResolver.GetDataTypeColor(layout.SlotBase, out _);
var bounds = layout.Bounds;
var brush = VertexControlSocketBrushes.GetBrush(color);
drawingContext.DrawEllipse(brush, null, new Point(padding.Left + radius, padding.Top + bounds.Y + bounds.Height * 0.5), radius, radius);
drawingContext.DrawText(layout.Text, new Point(padding.Left + diameter + VertexControlLayoutConstants.SocketContentPadding, padding.Top + bounds.Y + (bounds.Height * 0.5) - (layout.TextSize.Height * 0.5)));
}
また、ノード上で値を編集したいという場合はあると思います。
例えば、
こちらの、数値ボックスなどですね。
このような数値を毎回プロパティエディタにマウスを移動して編集するのは効率としては良くありません。
しかし、この数値ボックスは常に表示されている必要はありません。
今回の高速化にあたり、このような数値は通常時ではただの数値として「drawingContext.DrawText」
を使用してノード上に描画しています。
ただし、編集を行う場合は、この数値の領域をダブルクリックすると編集エディタがポップアップするように「Adorner (※1)」を作成しました。
これを行うことで、常に表示する必要のないコントロールの数を最低限まで減らすことができます。
※1 装飾の概要 – WPF | Microsoft Learn
グリッドの描画
ノードグラフ背景のグリッドの描画を行っている場合、可能なのであれば、表示しない方が高速化に繋がります。
これに関しては、普通のFHDモニタくらいであればそんなに影響ありませんが、4Kモニタなど解像度が高くなればなるほど描画負荷が上がります。
弊社では、4Kほどの高解像度のモニタを使用することはあまりないため許容しています。
GraphXの「GraphArea」の設定/挙動を見直し
こちらに関しては、GraphXを使用していない場合はあまり参考にはなりません。その場合はスキップしてください。
基本的にGraphXを使用する場合、「GraphArea」
コントロールを継承したコントロールを作成していることが多いかと思います。(サンプルも大体そうなっている)
LogicCoreの初期化
GraphAreaでは、「LogicCore」
プロパティのセットアップを行うかと思います。
この際に「GXLogicCore」
クラスを新しく生成し、各プロパティの設定を行うかと思いますがその中の
logicCore.DefaultEdgeRoutingAlgorithm = EdgeRoutingAlgorithmTypeEnum.None;
この設定がノードを移動させる際の重さに繋がります。
このプロパティは必ず「EdgeRoutingAlgorithmTypeEnum.None」
を設定しておくようにしましょう。
サンプルだと「EdgeRoutingAlgorithmTypeEnum.SimpleER」
が設定されていたりします。
この場合、ノードを移動するたびにエッジ(リンク)のルート計算が走ります。これが重くなる原因に繋がっています。
また、
logicCore.AsyncAlgorithmCompute = true;
の設定を行っておくといいでしょう。
グラフのレイアウトを非同期で行うようになります。
ノードを移動するたびに発生するMeasure/Arrange
ノードの移動を行うたびにGraphAreaのMeasure/Arrangeが走ります。
これによって、ノードグラフ内のすべてのノード/エッジのMeasure/Arrangeが行われます。
簡単に言うと、配置と描画が行われるわけですね。
そこで、弊社ではGraphAreaの「MeasureOverride」と「ArrangeOverride」をオーバーライドし、ノードに限り更新があったものに関してのみMeasure/Arrangeを実行することにしました。
更新を行うかどうかは各ノードコントロールで判断しています。
例えば、ノードが移動した、サイズが変更されたなど場合ですね。
基本的にはGraphXのGraphAreaクラスのコードを参考にMeasure/Arrangeを実行するかどうかの分岐を入れているだけです。
protected override Size MeasureOverride(Size constraint)
{
if (UseLayoutCache == false)
{
return base.MeasureOverride(constraint);
}
// レイアウトキャッシュを使用する場合
var contentSize = ContentSize;
var topLeft = new Point(double.PositiveInfinity, double.PositiveInfinity);
var bottomRight = new Point(double.NegativeInfinity, double.NegativeInfinity);
for (var i = 0; i < InternalChildren.Count; ++i)
{
var child = InternalChildren[i];
if (child is not IGraphControlLayoutCache layoutCache || layoutCache.MeasureRequired())
{
child.Measure(constraint);
}
if (child.Visibility == Visibility.Collapsed)
continue;
var finalX = GetFinalX(child);
var finalY = GetFinalY(child);
if (double.IsNaN(finalX) || double.IsNaN(finalY))
{
if (child is not EdgeControl { Edge: IRoutingInfo routingInfo })
continue;
var points = routingInfo.RoutingPoints;
if (points is null || points.Length == 0)
continue;
foreach (ref readonly var point in points.AsSpan())
{
topLeft.X = Math.Min(topLeft.X, point.X);
topLeft.Y = Math.Min(topLeft.Y, point.Y);
bottomRight.X = Math.Max(bottomRight.X, point.X);
bottomRight.Y = Math.Max(bottomRight.Y, point.Y);
}
}
else
{
topLeft.X = Math.Min(topLeft.X, finalX);
topLeft.Y = Math.Min(topLeft.Y, finalY);
bottomRight.X = Math.Max(bottomRight.X, finalX + child.DesiredSize.Width);
bottomRight.Y = Math.Max(bottomRight.Y, finalY + child.DesiredSize.Height);
}
}
topLeft.X -= SideExpansionSize.Width * 0.5;
topLeft.Y -= SideExpansionSize.Height * 0.5;
bottomRight.X += SideExpansionSize.Width * 0.5;
bottomRight.Y += SideExpansionSize.Height * 0.5;
ref var topLeftField = ref __topLeft__(this);
ref var bottomRightField = ref __bottomRight__(this);
topLeftField = topLeft;
bottomRightField = bottomRight;
var contentSize2 = ContentSize;
if (contentSize != contentSize2)
{
OnContentSizeChanged(contentSize, contentSize2);
}
return DesignModeHelper.IsDesignMode ? DesignSize : (IsInPrintMode ? ContentSize.Size : new Size(10.0, 10.0));
}
protected override Size ArrangeOverride(Size arrangeSize)
{
if (UseLayoutCache == false)
return base.ArrangeOverride(arrangeSize);
// レイアウトキャッシュを使用する場合
for (var i = 0; i < Children.Count; ++i)
{
var child = Children[i];
if (child is IGraphControlLayoutCache layoutCache && layoutCache.ArrangeRequired() == false)
continue;
var x = GetX(child);
var y = GetY(child);
if (double.IsNaN(x) || double.IsNaN(y))
{
if (child is EdgeControl)
{
x = 0;
y = 0;
}
}
child.Arrange(new Rect(x, y, child.DesiredSize.Width, child.DesiredSize.Height));
}
return DesignModeHelper.IsDesignMode ? DesignSize : IsInPrintMode ? ContentSize.Size : new Size(10.0, 10.0);
}
しかしここで問題になるのが、「MeasureOverride」に関してプライベートフィールドが使用されているという点です。
「_topLeft」「_bottomRight」の値を変更しているんですね。しかもこれが「ContentSize」プロパティの値を返す際に使用されています。
ですので、これらのフィールドに値を設定することは必須というわけです。
しかし、C#はプライベートなフィールドにまでアクセスするための仕組みが用意されているんですね。(それってどうなの?とは思いますが)
最初に思いつくのはリフレクションを使用する方法かと思います。型情報からGetFieldメソッドで「FiledInfo」を受け取って、SetValueする方法です。
ただ、リフレクションというのは重いんです。あまり使用したくありません。
そこで、.NET8より使用できる「UnsafeAccessorAttribute (※1)」を使用することにしました。
このアトリビュートを使用すると特定の型のアクセスできないメンバーへアクセスするためのメソッドを作成できます。
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_topLeft")]
private static extern ref Point __topLeft__(GraphAreaBase target);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_bottomRight")]
private static extern ref Point __bottomRight__(GraphAreaBase target);
今回の場合、フィールドへのアクセスなので ref としてメソッドから値の参照を受け取ります。
ref var topLeftField = ref __topLeft__(this);
ref var bottomRightField = ref __bottomRight__(this);
これはリフレクションを使用するより高速にプライベートなフィールドへアクセスすることが可能です。
最初に戻りますが、これによってノードの配置/描画を必要な時だけに限定し、ノード移動時の高速化を行いました。
本当はエッジでも行いたかったのですが、Arrangeが行われないとマウスオーバーが検知できないなどの問題が発生したため、現在は無効化しています。
※1 UnsafeAccessorAttribute クラス (System.Runtime.CompilerServices) | Microsoft Learn
最後に
今回実際に行ったノードの表示に関しての高速化のいくつかを紹介させていただきました。すべてが皆様の役に立つ情報かは分かりませんが、参考になれば幸いです。
また、「その1」と付いていることからもうお分かりかと思いますが、「その2」に続きます。
次はコード生成部分の高速化と開発効率の向上のために行ったことに関して紹介できればなと思います。
もしご興味があれば次のブログもご覧ください。