ランタイムシェーダーグラフの夢

つくったやつ

所感

もともと"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が入ってるがビジネスロジックということで。。。

なんか長くなったので、また暇なときに続きを書くかもしれん。