通販で酒を買うと気分がいい

クラフトビールがそこそこ好きなつもりだが、田舎に住んでいるのもあり、シュッと行けるビアバーが近くに無く大変さみしい。
京都に住んでいたときは(あんまり頻繁にいかなかったが)徒歩数分のとこにビアバーがあり、立地が良かったのだなぁと今になって思う。

関係ないけど、この前SMTP++10があり、京都に行った。2年間延期に延期を繰り返してきたがようやく開催できてめでたい。部活のような雰囲気がやはり良いなと思った。
SMTP++10(少し盛り上がるときめきパーティー) | 2022.08.19 京都木屋町 cafe la siesta
京都の町並みはやはりよく、またここに住みたいと思ってしまったが、30代前半のうちに東京都内に住まねば、という謎の目標もあるし、帰るにしてももうちょっと後かなぁと思う。

酒の話に戻ると、Amazonの欲しいものリストで酒を人から恵まれるのはタダで飲めて最高っぽいが、品ぞろえがすごく良いわけではないし、
なんか未だに欲しいものリストに抵抗がある。理由はわからんが、実は投げ銭とかも結構苦手で、"対価"みたいなのが明確であってほしいという気持ちがあるっぽい。

が、まあ酒が飲めるのは良いことやしな、と思って公開したところ、知り合いからDMで、住所が京都になってないか?と言われた。
AmazonのUIが終わっていて欲しいものリストに設定されている住所が確認できないので、ひとまず非公開にした。
自分には欲しいものリストは向いてないのだと思うし、心はいまだに京都にあるのかもしれない。

それで腹いせに、同僚に勧められたリカーズハセガワでビールをいくつか購入した。
来週の月曜に届くらしい。

来週美味い酒が届く。
そう思うとなんだか気分が良いし、THE佳境という感じの仕事も踏ん張れそう。

クリーンアーキテクチャのメモ

所感

原則

原則として内側の層にある人は外のことを知りません。外の人は内側を知っていていいです。 そど子が言っていたように、規則は破るためにあるので、例外もあります。

Domain層

根っこにあるロジックはここ、ということになっている。RPGだったらキャラクターのモデルや、ダメージ計算式、パーティの定義とかはここなんではなかろうか。

Domain Model

ValueObjectとかEntityとか、よくわからんので、なんかアプリにとって根っこっぽいクラスはこれや!!という気持ちでここに書くとよさそう。自然とprimitiveになる気がする。例:ユーザー、キャラクター、属性、パーティ、など。

Service

複数のDomainモデルの状態に対してTransactionを張りたいみたいなときにここに責任を持たせるんではなかろうか。 状態を持たないとされている。ユーザー削除したらユーザーが持ってるアレもコレも削除しないといけない、みたいなロジックはここで担保するのではないか。 例:「進化アイテムを使用してキャラを進化させる」みたいなロジックとか?

IRepository

Domainモデルを取得したり永続化したりするクラスのinterface。実装はInterfaceAdapter層に実装されるらしいです。

例:Monster[] IMonsterRepoistory.GetAllMonsters() みたいなのが書かれるんでは。上記の進化のロジックをServiceに書くために必要なinterfaceなど。

UseCase層

謎。UIとかがいきなりDomain層いじくり回すの怖いので、UseCaseを窓口にするといいんでは、みたいな気持ちがある。 想定しているユーザーの種類(無料会員、有料会員、管理人、など)に応じて分ける、みたいなのもどっかで見たが、謎。

UseCase

自然とAPI公開くんみたいになるわね。。。

例:MyItemUseCase.UseItem(Item item, int count)みたいなの書くんでは〜

OutputBoundary

UseCaseがリクエストの結果を出力する場所。MVPだったら、IPresenterみたいになる気がする。UIなどのPresentation層にこれの実装クラスがいるはず。 UseCaseがRxで値を配るような場合だと、Presentation層がUseCaseを購読することで値を流すことができるので、不要になりそう。push型だときつい、とかは普通にあるかもしれん。

あとで書くけど愚直にやると循環参照が発生して爆発する。

Interface Adapter層

名前がかっこいい。

Repository

Domain層にあるIRepositoryの実装クラス。保存先の詳細を知っており、隠蔽されている。外部ライブラリのwrapperをInfrastructureに書くということになってるけど、他で使わないならここに書いていいんでは、と思う。

例:

  • MySQL
  • APIサーバー
  • ファイルのI/O
  • Resources
  • StreamingAssets
  • Addressables
  • PlayerPrefs

Domain層では不要なRepositoryがあるはずで、例えばUIに表示するアセットのRepositoryなどがそれに当たりそう。UseCase経由で取得しなくてもいいような気もするし、よくわからんね。

Presentation

UIとか3Dモデルとかユーザーに見えるやつは全部ここや!!!!MonoBehaviourやuGUIの小難しいトリックは全部ここなのではないだろうか。MVPでもMVCでも一個のクラスで全部やるでも、なんでもいいと思う。

UseCaseがOutputBoundaryをinterfaceとして要求している場合、Presentationの誰かが実装する。 UnityではUI経由でUseCaseのメソッドを呼ぶことが多いと思うので、Presenter -> UseCase -> IPresenter = Presenter みたいな循環参照でDIコンテナが爆発する。

Controller

あんまわかってない。UseCaseを叩く口みたいなのをここに用意することがありそう。UseCaseの言葉とPresentationの言葉が違う場合、どこかで翻訳する必要があるが、それをここでやるんではなかろうか。Unityで作ってると、Presentation=Controllerになりがちな気がした。

Infrastructure層

InfraStructureではなくInfrastructure。目が滑る。外部ライブラリに依存するクラスが沢山いるとあとで爆発する可能性があるので、wrapperがいるとよさそう。

データの置き場所的な部分で、RepositoryとInfrastructureで迷うけど、ケースバイケースでいいのではと思っている。

  • PlayerPrefsを使う場合:Repositoryに直接書いてよさそう
  • API serverを使う場合、API serverのことを書いてるInfrastructureを用意して、Repositoryはそれとやりとりしてほしい

「Interface Adapter層からInfrastructure層に依存していいんですか?」と思う人はガルパンを観直すとよさそう。

Installer

全知全能。すべての実装を把握しており、DIしまくる。自前でDI書くと本当にめんどくさいので、VContainerとかを使うといいと思う。

まとめ

むずかし。。。

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

つくったやつ

所感

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

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

クラスターで働き始めて3か月経った

clusterというのはメタバースプラットフォームで、クラスター株式会社が作っています。

メタバースプラットフォーム cluster(クラスター)

今年の3月28日からそこでUnityエンジニアとして働いています。前職は京都でソーシャルゲームの開発をしている会社に3年勤めていました。 未経験で入ったのでエンジニアとしてのキャリアは現在3年と3カ月くらいです。

前回の記事で転職したということを書いたのですが、会社的にも入社エントリーはウェルカムということのようだったので、3カ月働いた感想とかを書いてみようと思います。

いつもは自分の書きたいことをそのまま書いているのですが、今回は同じように国内でゲーム開発をしているUnityエンジニアを読者に想定して書いてみます。 素直に書くと、メンバーをとても(!)募集しているので、そういうののきっかけになれば、という気持ちがあります。

epic

cluster を支える epic という開発フロー|cluster - メタバースプラットフォーム|note

何か新しい機能を開発したりするときには、epicとして取り扱われます。 epicにはPM(Product Manager)とエンジニアに加えて、デザイナーやQAも参加し、リリースすると解散します。

↑の記事に登場するPRDというのがゲーム制作における企画書や仕様書に近いように思います。 ゲーム制作ではプランナーやゲームデザイナーが仕様書を書いていましたが、クラスターではPMが書きます。 ゲームではマスタの設計のような詳細なデータ設計も仕様書に含まれることもありましたが、PRDはそういうよりはもうちょっと抽象度というか、やりたいことを明確にするためのもの、であるような印象があります。

入社してしばらく経って、epicのmemberをやったりownerをやったりするようになりましたが、 色んな職種の人と物を作る感じはゲーム開発で新機能を作っていた時と似ているように思いました。

設計

PRDの内容をどう実現するのかはエンジニアが考えます。考えた内容はdesign docという名前のドキュメントにします。

clusterの設計ってどうやってるの?|cluster - メタバースプラットフォーム|note

design docは必ずレビューされるようになっています。 これはとてもいい文化だなと思いました。

先に書いたように前職では私はソーシャルゲームの開発をしていました。 ソーシャルゲームの開発というのは一般的に、リリース後にも開発が続き、サービスが長く続いた方が良いということになっていると思います。 仕様がどんどん追加されるアプリケーションの開発では、将来負債にならないような設計はとても大事です。 自分は設計を考えたりそれを文章にしたりするのは好きな方なので、よくplantumlで図を書いたり、wikiに既存のコードの実装仕様をまとめたりしていました。

clusterの開発にも似ているところがあると思っていて、clusterはゲームというよりはプラットフォームで、世間的に「メタバース」の定義も曖昧で、UGCもあって、ユーザーが増えて、夢がひろがりんぐです。 将来のことを全て見通すのは難しいですが、思いを馳せながら開発するのは楽しく思います。

速度

設計をバチバチにやるぞ!と言われると、なかなか開発進まなさそうと思いそうですが、 実際には毎週(!)リリースしており、すごいスピード感です。これはスタートアップというのもあると思うんですが、 会社のバリューは加速・チャレンジ・リスペクトなのを体現しているなぁと思います。前職だと月にアプリの更新は2回くらいだったので、かなり驚きました。 epicが終わったら、次のepic、という感じでどんどん開発が進んでいます。

自分の中で、設計とリリースはある種の理想と現実のようなものだと思っているのですが、両立するぞ~という気迫があります。

メタバースプラットフォームは様々な要素で構成されているのでエンジニアも色々な畑の人がいます。 私がエンジニアになったのは、はてなの知り合いがいたからなのですが、なんとなくweb(と呼んでいいか自信がない)とゲーム業界ではそこそこの壁があると勝手に思っていました。 今は社内にwebの人もいればiOSの人もAndroidの人もいて、面白いなと思います。

リモートワーク

現在、出社は月に1度だけで、日々の仕事もフレックスになっています。家は好きなので嬉しいですが、人と会って話すことも好きなので、月一の出社を結構楽しみにしています。なんやかんや毎日の定例で同僚と話すことがあるので、孤独を感じることはあまりないです。

月1出社導入!〜クラスター社マルチワークスタイルへのチャレンジ〜|岩崎司|note

アバター

cluster(プラットフォーム)はもちろんですが、ビデオ通話もアバターです。

かわいい

メタバース

すっかり一般的に使われるようになったメタバースという単語ですが、近未来というよりは自分は懐かしさを感じています。 私が学部生だったとき(13年前!)、ちょうどニコニコ動画でボカロが流行っていて、毎日浴びるように聴いていました。 有名ボカロPがメジャーデビューして、みたいな話があって、おお~~と思った記憶があります。 うまく言えないんですが、今現在、なんとなく当時と似たような印象を抱いています。 ユーザーがものをつくるというのはやっぱいいなと思うし、それが積み重なって、どういうものになるのか楽しみですね。 そういう状況で、当事者として開発に関われるというのは面白いなと思います。

前職と今とでの大きな違いとして、開発するものがコンテンツからプラットフォームに変わりました。 自分はプラットフォームの開発の方が合っているように思っていて、それも良いなと感じています。 前職でもゲームデザイナーが自由に触れるようなデータ設計をしたり、アーティストが触りやすいようなエディタ拡張を作ったりしていたので、実は変わってないのかもしれないです。 人々が色々なものをどんどん作れるような仕組みみたいなのが好きなんだと思います。

おわりに

いい加減なことは書けんぞ、という気持ちで書いた結果、なんか小学生みたいな感想の羅列になってしまいましたが、 率直に書くと今の職場は大変楽しくやりがいを感じており、とてもよい感じです。

最初に書いたように、clusterは色々な職種で人を募集しています。

クラスター株式会社 の全ての求人一覧

エンジニアとしての仕事の雰囲気はTech Blogがあるので、興味を持った方はそれを読んでみるのが良いかもです。

Cluster Tech Blog|cluster - メタバースプラットフォーム|note

もうちょっと直接話聞いてみたいぞ、という知り合いの人はTwitterでDMを送ってください。 神田あたりで飲みましょう🍺

近況とか

転職した

「この数年間、とにかく前に進みたくて、届かないものに手を触れたくて、それが具体的に何を指すのかも、ほとんど脅迫的とも言えるようなその想いがどこから湧いてくるのかも分からずに、僕はただ働き続け、気づけば日々弾力を失っていく心がひたすらつらかった。そしてある朝、かつてあれほどまでに真剣で切実だった想いがきれいに失われていることに僕は気づき、もう限界だと知った時、会社を辞めた」

これは秒速5センチメートルの引用です。それとは全然関係ないんですが、3年勤めた会社を辞めました。今は有休消化中(28連休!)で、来週から仕事開始です。がんばっていきたい。

前職(一応今も所属していますが)はゲームを作る会社だったんですが、もともとはPHPRubyしか勉強してなくて、いてUnityは何もわからん状態でした。面接官が厳しそうだったから、みたいな理由でゲーム会社に入ったのですが、結果としてC#やUnityやCGの方が肌に合っていたという感じがします。Rubyはそれはそれで好きですが、静的な型がないとしんどいです。そういう意味で3月に少し触ったRustは良い感じです。C#で甘々だったメモリ周りで一生怒られてますが、賢さが詰まった言語という感じがしてよいです。Unity向けにdllを吐けるらしいので、CG関連でライブラリを作ったりできる日がくるといいですね。

職を転がす活動は思いのほかサクサク進んだというか、経験則ですがなんとなく自分は面接みたいなのが得意なんだと思っていて、実際の能力よりも、それらしく会話をすることに長けているように思います(詐欺では?)。一人でいるとき大体いつもブツクサ喋って自分の思考を整理しているので、会話ではそれを再生するだけ、みたいな感じがあり、逆に言うとその場で考えて会話ができているのか?というと、日常会話もちょっと怪しい感じがしています。これはコミュニケーションとは言えないのでは。。。?

あと、なんかアレですけど、前回よりも学歴みたいなものが少し意味を持つようになった感覚があって、無駄にならなかったという意味で、少し報われた気がしています。8年くらいやってたし、なんやかんやアカデミックコンプレックスはあるので。 コンプレックス的な話だと、CGの勉強をするときに論文を読んだりするのがやっぱり楽しくて、なんかやっぱりかっこいいなと思います。人類で最初にやった、というのはやっぱいいですよね。何かしらの形で人類の前進みたいなのに少しでも貢献できる日がまた来るといいなと思います。

次の仕事はゲームじゃないんですがUnityで、というかVRというかめたばーすというか。ありがとうVRChat(VRChatではないです)。そしてCGは多分全然使わなくて、多分ずっとUnityとC#とかなんだろうなと思っています。しばらくU#しか書いてないので、<T>、お前、、、みたいな気持ちになってますが、がんばりたいです。

趣味とか

趣味の方は、リアルタイムレンダリングの輪読を続けています。ようやくIBLに入ってきました。背景の違う人が沢山いて、でも共通言語を話すというのはやっぱり楽しいですし、学びが多いです。本当は読んだ内容を手元で実装したりしたいんですが、追い付かない。。。

VRCの方は🦀をときどきアプデしたりしてます。 shivaduke28.github.io

ドキュメントはamagiさんのMDMTを使っています。npmってさてはすごいな・・???ロゴはセールで買ったまま放置されていたAffinityで作りました。絵文字はDOTOWNで、フォントはPixerです。僕の作業は配置してちょっと弄っただけなんですが、かわいいのでお気に入りです🦀。なんだかんだ動かない発光マテリアルのdiffuseは🦀のようなものを使うしかないので、まだ少しは需要があるのかなぁと思っています。スペキュラはDirectional Specularを入れたりしましたが、やはりモニターには厳しいのでLTCかなぁと思います。LTCは僕も再実装してたんですが、なんと同時期に別にやってる人が2人も(!)いて、特にloxさんのやつは色々驚きがいっぱいでした。結果としてloxさんとお話したりできてよかったです。去年から追いかけてたものに、少しは近づけたんでしょうか。そうだといいなぁと思います。追いかけてばかりではだめなんだとは思うので、気合いれていくぞ~~。

後、最近はフォグの勉強をしています。フォグはボリュームなので積分がいっぱい出てくるんですが、なんか仮定が強いと積分が解析解をもつのでシュッと計算できて、みたいなのがあって、そういうのが楽しいです。ちゃんとやったらちゃんと良い感じになる、みたいなのが好きなんだと思います。スカイボックスの青の計算を勉強したいな~と思っています。

f:id:shiva_duke:20220325163712p:plain f:id:shiva_duke:20220325163808p:plain f:id:shiva_duke:20220325163925p:plain

f:id:shiva_duke:20220325163533j:plain これ、遮蔽を計算していないだけなんですけど、逆に光が漏れてるみたいに見えてちょっと面白いですね。

なんかでも、「仕組みの理解」に対する喜びみたいなのが根っこにある感じがあって、なかなかちゃんとものが作れないなという感じもあります。今年は何か一人でちゃんとしたやつ作りたい。


あと簡単なUdonを書いて人に渡すみたいなことをちょっとやったりしてました。技術的な難しさと需要みたいなものはあんまり関係がないのだなぁと思うことがあって、なんか面白いなと思いました。ともかく人に頼まれたりするとやっぱりうれしいです。頼まれると頑張っちゃう。自分はエディタ拡張とかツールは作れるけどコンテンツは一人だとなかなか作れないので、そういう部分をガシガシやってくれる人と一緒にやる作業は楽しいです。

こういうのとか

こういうのとか(Exceedに早く入れるべきだったやつ) f:id:shiva_duke:20220325164505p:plain

僕からすると、「MIDIが触れる」で終わりなんですけど、MIDIの使い方を知っている人と話すと、全然視野が広がるというか、そういうのがあって楽しいです。去年はぶいちゃに籠るような指向が内的に強かったんですけど、最近はなんか開き直ってきてる感じがあります。いいものや技術をどんどん食べていきたいです。

おわりに

今年は仕事が変わるのでとりあえず仕事を人並みにできるように頑張りたいです。慣れるまで趣味に割ける時間が減るかもですが、仕方なし。ぶいちゃは大好きなので、これからもよろしくお願いします。関東には多分4月中に引っ越す予定です。グッバイ鴨川。

VRChatでのカメラの描画順とイベント関数

※VRChatに限らない話ですが、VRChat以外ならCommandBufferを使ったり、SRPならレンダリングパイプライン側で制御などもあると思います。

カメラの描画順

シーンにカメラが複数ある場合、そのレンダリング順はdepthというパラメータで管理されています。 depthが小さいほど先にレンダリングされます。

Camera-depth - Unity スクリプトリファレンス

よくある状況として、シーン内を撮影しているカメラ(便宜上サブカメラと呼びます)があって、その映像がRenderTextureに出力されて、それを張り付けたスクリーンなどに描画される、みたいな場合があると思います。

この場合、サブカメラはメインカメラ(=アバターの目)よりも先にレンダリングされる必要があります。

  1. サブカメラがSceneを描画
  2. サブカメラのレンダリング結果がRenderTextureに書き込まれる
  3. メインカメラがスクリーンをレンダリング
  4. スクリーンのシェーダーがRenderTextureの色を使う(2の状態が反映されている)

1~2と3~4が逆になっていると、スクリーンに映される映像は1フレーム前のものになるので、注意が必要です。

カメラの描画結果をスクリプトで読み込む

カメラの描画結果(や、シェーダーの計算結果など)をU#から参照したくなるときがあると思います。 この場合、まずカメラの出力先をRenderTextureにして、RenerTextureの内容をU#スクリプトから読み込みます。

RenderTexture -> U#の読み込みには、間にTexture2Dを挟んで、 Texture2D.ReadPixelsTexture2D.GetPixelsを使います。

こんな感じで情報が流れます。

Camera --> RenderTexture -(ReadPixels)-> Texture2D -(GetPixels)-> U# Script

この辺はちょっと重めの関数なので、毎フレーム呼ぶ場合は解像度やオブジェクトの数を気にした方がよさそうです。

ドキュメントに書かれていますが、このメソッドは"アクティブなRenderTexure"を読み込みます。 アクティブなRenderTextureは常にひとつだけなので、一般的には下記の記事で説明されているように、 読み込みたいRenderTextureをアクティブにしてからReadPixelsを実行する、みたいなことをして使います。

UdonではRenderTexture.activeがサポートされていないので、「読み込みたいRenderTextureがアクティブになっているときにReadPixelsを実行する」必要があります。RenderTexture.activeがどのタイミングで何がセットされてるのかはあまりちゃんとわかってないんですが、

gyazo.com イベント関数の実行順序 - Unity マニュアル

↑のSceneRenderingのグループに含まれるイベント関数の中で実行すれば、いい感じにカメラの出力とかがactiveになってそうな感じがあります。

で、複数あるけど、どれを使うの?という話になるので、それについて書きます。

まず、これらのイベント関数は以下の2パターンが存在します。

  • 描画される側のレンダラーとかにアタッチされた場合に実行されるもの
  • 描画する側のカメラにアタッチされた場合に実行されるもの

前者は例えばOnRenderObjectとかOnWillRenderObjectとかが該当します。 これらは全カメラに対して実行されるので、

if (Camera.active == Camera.main)

とかでどのカメラをフィルターにかけるのですが、UdonではCamera.activeがサポートされていません。 代わりにカメラのレイヤーマスク機能で絞り込みをするとかがいいかもです。

後者はカメラごとに実行されるので、特定のカメラの描画結果を使う、みたいなときいは使いやすいです。 描画結果を使う場合は、OnPostRenderとかOnRenderImageが使えます。 Graphics.BlitがUdonで使えないので、やるならOnPostRenderがいいのかなと思います。

MainCameraに張り付けたUdonが動かない?

VRChatのワールドでは、VRC_SceneDescriptorのReference Cameraという項目で、「このカメラを使うよ」と宣言することができます。 これにより、ユーザーの目のカメラにポスプロがかかったりしているはずです。

これがシーンのメインカメラに相当するので、このカメラのレンダリング前にやりたいこと、みたいなのは結構需要があると思うのですが、このカメラに自前のU#スクリプトを張り付けてもどうも動かないっぽいです。無念。


KanikamaGIでのカメラ周り

--- 追記 ---

アスペクト比の調整はCamera.aspectを変更することで キャプチャカメラ=サンプリングカメラにできるようでした。

感謝。

ちょろっと試した感じ大丈夫そうだったので、v2.0.0とかに入れるかと思います。

→ ver 1.4.0でリリースされました。ありがたい! モニター簡略化 by shivaduke28 · Pull Request #68 · shivaduke28/kanikama · GitHub

---追記終わり---

https://user-images.githubusercontent.com/45098240/131208676-49e704dd-60f0-4c86-8c23-c4b6cc9c5858.png

モニターの設定 · shivaduke28/kanikama Wiki · GitHub

KanikamaGIでモニターの映像をGIに反映される場合、メインとは別に3つのサブカメラが使われます。

  • キャプチャーカメラ(depth -3)
  • サンプリングカメラ(depth -2)
  • 合成用ダミーカメラ(depth -1)
  • メインカメラ(depth 0)

キャプチャーカメラ

キャプチャーカメラは、モニターの前に配置されていて毎フレーム最初にカメラをレンダリングします。 レンダリング結果はアスペクト比がモニターと同じRenderTexture(大体短い辺が256くらい)に出力されます。

サンプリングカメラ

キャプチャカメラの出力先のRenderTextureを256x256のRawImageに張り付けて、サンプリングカメラが256x256のRenderTextureに出力します。

サンプリングカメラのOnRenderImage(本当はOnPostRenderでいい)で、256x256のTexture2Dを使ってReadPixels + GetPixelsを行い、色をU#の世界に持っていきます。このときにどうしてもmipmapを使いたかったので、テクスチャの解像度を2の冪(256)にする必要があり、そのためのRawImageを挟んでいます。(モニターの形が正方形ならキャプチャーカメラ=モニターカメラにできます。)

mipmapレベルの選択はモニターの分割数とかに依存していて、例えば3x2分割の場合だと、mipmapレベル6で読み込んでColor[16]を取得した後に、U#側で3x2になるように計算しなおしています。

kanikama/KanikamaColorSampler.cs at main · shivaduke28/kanikama · GitHub

ここの実装は気合で書かれていて大変厳しい。

合成用ダミーカメラ

depth-1の合成用ダミーカメラは、カメラとしての機能は一切使用しないので、1x1のRenderTextureに出力するようになっていて、 OnPreCullのタイミングで、ワールド内の光源やサンプリングカメラなどから色を収集します。 収集した色は、GIを受け取るRendererにMaterialPropertyBlockとして配ります。

モニターが1つの場合は、サンプリングカメラと合成用ダミーカメラを同じにすることができますが、モニターが複数ある場合や、それ以外のカメラを使って色を計算する仕組みがでてきても、depth=-2のカメラの後ろですべて終わらせておけば、後ろで合成されるようになっています。

ver 1.3.1でモニター映像の色の平均色をリアルタイムライトに反映させる仕組みを入れたのですが、このライトの更新もモニターの色確定とメインカメラの描画の間である、このタイミングで行う必要があります。

メインカメラ

depth 0のメインカメラがKanikamaGIの影響を受けるオブジェクトをレンダリングするときには、-1の時点で色が配られているので、GIが遅延なしで反映されるようになっています。

VRChatでランタイムでライトマップを合成する仕組みを作った

きっかけ

無 解説 - Imaginantia

を読んで、大変面白かったので、自分でもやってみたいな~と思って、ライトマップの合成の線形和をとるところを自分で作ってみました。 RealtimeGIが不安定なことがある、みたいな話もあるようなので、どっかで誰かの役に立つといいなぁ~という気持ちもあります。

作ったやつ

github.com

boothにも置きました

shivaduke28.booth.pm

なんかダサい名前にしたいな~と思っていて、30歳だし三十路ということで、KanimisoGIとかにしようかと思ってたのですが、 フェイクなんだしKanikanaでいいんじゃないですか、とSainaさんに言われて、それもそうだ、ということでカニカマになりました。 結構しっくりきているので、さすがです。

仕組み

買ったまま放置されていたリアルタイムレンダリングを眺めていると、確かに同じようなことが書いてました。

※リアルタイムレンダリング11.5.3の内容をあいまいな感じで書いてます。

例えば光源がn個あったとして、あるオブジェクトのある点p(少しあいまいな言い方)のライトマップの色を{L(p)}を書くことにします。 光源を{{x_1, \dots  x_n}}を書くことにして、{x_i}だけを点灯した状態でベイクしたライトマップの色を{L(p, x_i)}と書くことにすると、 {L(p) = \sum_i L(p, x_i)} が成り立ちます。

もっというと、ベイク時の { x_i } の色を { b_i } とすると、{x_i} の色が{c_i}のときの色は {\sum_i  \frac{c_i}{b_i}  L(p, x_i) }みたいに書けます。面倒なので、ベイク時のパラメータを"単位的"(ここでは白)にしておけば、 各光源の色 {{c_1, \dots , c_n}} が与えられたときの{p}に当たっている光の色は {L(p; c_1, \dots, c_n ) = \sum_i c_i  L(p, x_i)} となります。

これのいいところは、右辺の計算が大変な部分はすべて{L(p, x_i)} にすべて押し込められているので、事前に計算しておけば、 それらの線形和を計算するのはラインタイムでも(モバイル端末とかでも)全然いける、ということです。


実装

KanikamaGIは以下の3つで構成されています。

  1. 指定した光源 {x_i} に対してそれだけが白色で点灯している状態でライトマップ {L(\cdot, x_i)} をベイクする仕組み
  2. ランタイムで光源の色 {{c_1, \dots , c_n}} に対して{L(\cdot; c_1, \dots, c_n ) = \sum_i c_i  L(\cdot, x_i)} を計算する仕組み
  3. {p}でのシェーディングに{L(p ; c_1, \dots, c_n)}を反映させる仕組み

1. ベイク

とにかくベイクを簡単にしたいという気持ちがあって、これにすごい時間がとられました。Editor拡張の開発は世界で一番めんどくさい。

まず、動的に光量を変動させたい光源を指定できる必要があります。 シーンに配置されたオブジェクトをScriptableObjectから参照するのは厳しそうだったので、 Sceneに配置して、カニカマしたいLightとかRendererとかを登録するためのKanikamaSceneDescriptorクラスを用意しました。 AmbientLightをカニカマにするかどうかも指定できるようになっています。モニターの疑似GIにも対応してますが、これについてはwikiを参照してください。

Image from Gyazo

EditorOnlyオブジェクト

KanikamaSceneDescriptorは

  • Editorでのみ使うので、ランタイムアセンブリにできるだけ入れたくない
  • MonoBehaviourである必要がある(Editorアセンブリだとダメ)
  • U#ではなくC#で書きたい

みたいな状態にある必要があって、いろいろ苦戦したのですが、以下のような形に着地しました。

  • ターゲットプラットフォームを全てに指定した Kanikama.EditorOnlyアセンブリに入れる
  • GameObjectにEditorOnlyタグをつけてビルドに含まれないようにする
  • スクリプト自体は#if UNITY_EDITOR && !COMPILER_UDONSHARP#endif で囲む(これはEditorOnlyタグつけとけば不要っぽいっすね)

U#に継承がきたらユーザー定義のC#クラスやU#クラスを考慮した設計へ変更すると思います。

ベイク処理

以下の手順で行います。

  1. Scene内のGIに寄与するオブジェクトを全て収集し、GIに寄与しないように消灯する
  2. カニカマ光源に対して、ひとつずつ以下を繰り返す:「"単位的な光量"で点灯する」→「ライトマップをベイク」→「消灯」→「出力されたライトマップを別フォルダに移動」
  3. カニカマ光源を全て点灯してライトマップをベイクする
  4. 2の結果をTexture2DArrayにまとめて、CustomRenderTextureと合成用のマテリアルをライトマップインデックスの数だけ作る

ベイク処理は結構真面目に作っていて、Rendererが発光マテリアルを複数持っている場合とかにも対応しています。えらい。 また、地味にうれしいポイントとして、Mixed Lightにも対応しているので、直接光はリアルタイムで、間接光だけカニカマ、とかができます。 あと、どうしてもベイク時間が増えてイテレーションが苦痛なので、カニカマ光源の一部だけをベイクする、みたいなこともできるようになっています。 Image from Gyazo

Texture2DArrayを使う理由は、一度使ってみたかったのと、合成用のシェーダーが光源の数が増えても変更が必要ないようにしたかった、というのがあります。 シュッとした感じで作れたのでお気に入りです。

非同期周りはSystem.Threading.Tasks.Taskで書いていて、async/awaitはやはりサイコ~となりました。普段はUniTaskを使うのだけど、ライブラリということでこっちにしました。Sceneをいじる処理が入っているので、キャンセル時のロールバックなどが組み込まれています。

2. 合成

カニカマではライトマップの合成はCustomRenderTextureと専用のシェーダーによって行っていて、壁などのライトマップを使う側は合成結果への参照を持っていてそれを使っているだけになっています。使用側の提供側への依存がTexture型に吸収されていて最高!!!!!!という気持ちでした。。。

が、この記事を書いていて、オブジェクト側で合成した方が効率がいいことに気づいてしまったので、どうしたものかと思っています。 毎フレーム全レンダラーにMaterialPropertyBlockを配るのめんどくさいし、レンダラー側のシェーダーは簡単にしたかったし、合成処理をシステム内に隠蔽したいみたいな気持ちがありましたが、画面外の{p}に対して{L(p;x_1,\dots,x_n)}を計算するのは無駄なので、作り直した方がいい気がしてきました。モニターのGIへの反映が1F遅れるバグのもついでに対応できそう。

キェ~~~~~~!! 邪ッ!!!!!!!!!

3. シェーディング

オブジェクト側のシェーダーとしてKanikama/Standardシェーダーを用意しました。 UnityのStandardシェーダーのGIの計算処理に、カニカマから受け取ったライトマップテクスチャのサンプリング結果を加える処理を追加しただけです。

事前に線形和を取ってしまっているので、Directionalモードには対応できてないです。上に書いたようにライトマップの合成をシェーディング側でやる場合、DirectionalマップもTexture2dArrayに入れてしまえば、その場で法線とのハーフランバートの計算ができる気がしているので、うまいことやれるかもですね(多分)。

Bakery対応するときにSHに対応する必要があるはずなので、そこでまた何か手を考えるかもです。


感想

ギョームでUnityを触っているのですが、自分で(新規性とかはないですが)いちから何かを作って公開する、みたいなことはやったことがなかったので、どうにか一応形になってよかったです。 いつも作業通話をしてくれている人たちにはとても感謝しています。ありがとうございました。

苦手意識のあった色空間やテクスチャのフォーマットやHDRとかに向き合えたりもできてよかったです。SHにも興味がでてよかった。 あとAssemblyDefinitionを使ったことがなかったので、触れてよかった。シュッとした感じがあっていい。

実装したものについては、一応動くようになっているんだけど、Unity向けのC#ライブラリとしても、VRChatユーザー向けのアセットとしても、全体的に中途半端になってしまったなぁと思います。 きちんとAPIを定義して、それのUIとしてEditor拡張を作る、みたいなのができるとよかったですね。設計パワァ不足を感じる。 本当はベイク機構とカニカマ光源の間に抽象を挟んで、ユーザー定義のカニカマ光源が使えるようにしたい、とかがあるんですが、C#側で依存を切っても、U#側で抽象が使えないことでどうしても型依存が発生してしまうので、あきらめてます。U#に継承とインターフェースが入る予定らしいので、そのあとに色々対応します。Bakery対応するにしても、Unity/Bakeryの差を抽象で吸収する必要があるので。

さいごに

KanikamaGIはできるだけ簡単に使えて、なおかつ汎用的になるようにしているつもりですが、結構複雑な手続きが必要になっています、すいません。また、本当はワールドに合わせてワンオフで作った方がよいことの方が多いと思います。こういうのはできないんですか、とか、これがわからん、とか、これが不便、とか何かあれば私のTwitterかDiscordのKanikama鯖かgithubのissueとかで気軽に聞いてくれると嬉しいです。

Twitter シバヅケ (@shiva_duke28) | Twitter

Discord KanikamaGI