つくったやつ
ぷれびゅーがかわいい pic.twitter.com/NoMs3bk7mg
— しばづけ (@shiva_duke28) 2022年8月13日
所感
もともと"Shade In Time"という名前のpjで、UniRxと設計の勉強用に始めたプロジェクトであった。 Shade In Timeという名前は、JIT(Just In Time)コンパイラから来ていて、ランタイムでシェーダーグラフを動かすんや、という思想が滲み出ている。 略すとSITだがShITにもできて、ガハハwという思惑もあった。
グラフの要件としてはこんな感じ。
- グラフというのは0個以上のノードを持っていて、ノードは0個以上のスロットを持っている。
- スロットは値の型をもつ。
- スロットには入力スロットと出力スロットの2種類がある。
- 以下の場合に2つのスロットを接続してエッジを作ることができる。
- スロットの片方が入力、片方が出力である
- 2つのスロットの値の型が互換性をもつ。
- 出力スロットは複数の入力スロットと接続できる。
- 入力スロットは最大で1つの出力スロットと接続できる。
public interface INode : IDisposable { Guid Id { get; } string Name { get; } IReadOnlyList<ISlot> Slots { get; } Vector2 Position { get; } }
PositionというのはUIにおけるノードの位置を示している。これはどう見てもPresentationの関心ごとであり、Domainモデルっぽいやつが保持しているのはおかしい。言い訳をすると、グラフのシリアライズとデシリアライズを行うときに、ノードの位置も一緒に保存しないと、とてもじゃないが不便であり、Domainの永続化とPresentationの永続化を別々にやって合成してUIの復元、みたいなのをやる気力がなかった。丁寧な設計、めんどくさい。
ISlot
はスロットのモデルである。
public enum SlotType { Input = 1, Output = 2 } public enum SlotValueType { Int = 1, Float = 2, Texture = 3, Material = 4, Vector2 = 5, Vector3 = 6, Vector4 = 7, Boolean = 8, } public interface ISlot : IDisposable, IEquatable<ISlot> { Guid NodeId { get; } int SlotIndex { get; } string Name { get; } SlotType Type { get; } SlotValueType ValueType { get; } }
値の受け渡し
二つのノードが接続されているとき、値は片方のノードの出力スロットから出て、もう片方の入力スロットに入る。
向きが固定で値が流れるだけなので、出力をIObservable
、入力をIObserver
にして、エッジの生成と破棄をSubscribe
とその返り値のDispose
で実装することにした。あとになって思うと、これは良い案だったと思う。Rxのオペレータが自由に使えて便利だし、書いていて楽しい。
出力スロット
public abstract class OutputSlot<T> : ISlot { public abstract Guid NodeId { get; } public abstract int SlotIndex { get; } public abstract string Name { get; } public SlotType Type => SlotType.Output; public abstract SlotValueType ValueType { get; } public abstract void Dispose(); // 出力 public abstract IObservable<T> ValueAsObservable(); // ... }
入力スロット
public interface IInputSlot { IObservable<int> OnConnectedCountChangeAsObservable(); void OnConnect(); void OnDisconnect(); } public abstract class InputSlot<T> : ISlot, IInputSlot { public abstract Guid NodeId { get; } public abstract int SlotIndex { get; } public abstract string Name { get; } public SlotType Type => SlotType.Input; public abstract SlotValueType ValueType { get; } public abstract void Dispose(); // 入力 public abstract IObserver<T> ValueObserver(); readonly IntReactiveProperty connectedCount = new(0); public IObservable<int> OnConnectedCountChangeAsObservable() => connectedCount.AsObservable(); public void OnConnect() { connectedCount.Value += 1; } public void OnDisconnect() { connectedCount.Value -= 1; } // ...
エッジ
上記の通り、Subscribe/Disposeで接続と接続解除を行う。
public interface IEdge : IDisposable { Guid Id { get; } ISlot OutputSlot { get; } ISlot InputSlot { get; } void Connect(); } public class Edge<T> : IEdge { readonly InputSlot<T> inputSlot; readonly OutputSlot<T> outputSlot; IDisposable disposable; public Edge(Guid id, OutputSlot<T> outputSlot, InputSlot<T> inputSlot) { Id = id; this.outputSlot = outputSlot; this.inputSlot = inputSlot; } public Guid Id { get; } public ISlot OutputSlot => outputSlot; public ISlot InputSlot => inputSlot; // 接続 public void Connect() { disposable = outputSlot.ValueAsObservable().Subscribe(inputSlot.ValueObserver()); } // 接続解除 public void Dispose() { inputSlot?.OnDisconnect(); disposable?.Dispose(); inputSlot?.ValueObserver().OnNext(default); } }
型の互換性と暗黙的型変換
2つのISlot
が与えられたとき、それらが接続可能かどうかのナイーブな判定は以下で行える。
bool CanConnect(ISlot a, ISlot b) { return a.SlotType != b.SlotTpe && a.SlotValueType == b.SlotValueType; }
とはいえ暗黙的型変換したい。これが結構面倒でSubscribe時に型を変換してあげる必要がある。
IObservable<int> a = new Subject<int>(); IObserver<float> b = new Subject<float>(); // a.Subscribe(b); // error a.Select(x => (float) x).Subscribe(b); // ok
結果としてEdge<T, TR>
みたいなのが必要になってしまった。
public class Edge<T, TR> : IEdge { readonly Func<T, TR> converter; readonly InputSlot<TR> inputSlot; readonly OutputSlot<T> outputSlot; IDisposable disposable; public Edge(Guid id, OutputSlot<T> outputSlot, InputSlot<TR> inputSlot, Func<T, TR> converter) { Id = id; this.outputSlot = outputSlot; this.inputSlot = inputSlot; this.converter = converter; } public Guid Id { get; } public ISlot OutputSlot => outputSlot; public ISlot InputSlot => inputSlot; public void Connect() { disposable = outputSlot.ValueAsObservable().Select(x => converter(x)).Subscribe(inputSlot.ValueObserver()); } public void Dispose() { inputSlot?.OnDisconnect(); disposable?.Dispose(); inputSlot?.ValueObserver().OnNext(default); } }
スロットの値の型の互換性は全て自前で書く必要があり、型変換も全て書く羽目になった。 厳しいが、ここで全部書けばあとはノードを作るだけ、という感じ。
public static class EdgeFactory { public static IEdge Create(ISlot output, ISlot input) { if (!SlotUtils.CanConnect(output.ValueType, input.ValueType)) { throw new Exception("Input value type and output value type are not compatible."); } return output.ValueType switch { SlotValueType.Texture => CreateEdge<Texture>(output, input), SlotValueType.Material => CreateEdge<Material>(output, input), SlotValueType.Float => input.ValueType switch { SlotValueType.Float => CreateEdge<float>(output, input), SlotValueType.Int => CreateEdge<float, int>(output, input, Mathf.FloorToInt), SlotValueType.Boolean => CreateEdge<float, bool>(output, input, x => x != 0), _ => throw new ArgumentOutOfRangeException() }, SlotValueType.Int => input.ValueType switch { SlotValueType.Int => CreateEdge<int>(output, input), SlotValueType.Float => CreateEdge<int, float>(output, input, x => x), SlotValueType.Boolean => CreateEdge<int, bool>(output, input, x => x != 0), _ => throw new ArgumentOutOfRangeException() }, SlotValueType.Boolean => input.ValueType switch { SlotValueType.Boolean => CreateEdge<bool>(output, input), SlotValueType.Int => CreateEdge<bool, int>(output, input, x => x ? 1 : 0), SlotValueType.Float => CreateEdge<bool, float>(output, input, x => x ? 1f : 0f), _ => throw new ArgumentOutOfRangeException() }, SlotValueType.Vector2 => input.ValueType switch { SlotValueType.Vector2 => CreateEdge<Vector2>(output, input), SlotValueType.Vector3 => CreateEdge<Vector2, Vector3>(output, input, x => x), SlotValueType.Vector4 => CreateEdge<Vector2, Vector4>(output, input, x => x), _ => throw new ArgumentOutOfRangeException() }, SlotValueType.Vector3 => input.ValueType switch { SlotValueType.Vector2 => CreateEdge<Vector3, Vector2>(output, input, x => x), SlotValueType.Vector3 => CreateEdge<Vector3>(output, input), SlotValueType.Vector4 => CreateEdge<Vector3, Vector4>(output, input, x => x), _ => throw new ArgumentOutOfRangeException() }, SlotValueType.Vector4 => input.ValueType switch { SlotValueType.Vector2 => CreateEdge<Vector4, Vector2>(output, input, x => x), SlotValueType.Vector3 => CreateEdge<Vector4, Vector3>(output, input, x => x), SlotValueType.Vector4 => CreateEdge<Vector4>(output, input), _ => throw new ArgumentOutOfRangeException() }, _ => throw new ArgumentOutOfRangeException($"{output.ValueType} is out of range.") }; } static IEdge CreateEdge<T>(ISlot output, ISlot input) { var inputSlot = (InputSlot<T>) input; inputSlot.OnConnect(); return new Edge<T>(Guid.NewGuid(), (OutputSlot<T>) output, inputSlot); } static IEdge CreateEdge<T, TR>(ISlot output, ISlot input, Func<T, TR> converter) { var inputSlot = (InputSlot<TR>) input; inputSlot.OnConnect(); return new Edge<T, TR>(Guid.NewGuid(), (OutputSlot<T>) output, inputSlot, converter); } }
おわっとる。。。という感じがあるが、Domainレイヤーは割とコンパクトになったと思う。FactoryやらUtilsが入ってるがビジネスロジックということで。。。
なんか長くなったので、また暇なときに続きを書くかもしれん。