UE5.1 以降の PixelStreaming Webクライアントをカスタマイズする方法

 Typescriptを真面目に読むべし、の巻

UnrealEngine の PixelStreaming はゲーム画面をブラウザに配信して操作する機能です。 そのため、UE 側のアプリだけでなくブラウザ側で実行される Web クライアントが必要になります。

EpicGames 公式の PixelStreaming の Web クライアントは、SignallingWebServer に含まれていて github(The official home for the Pixel Streaming servers and frontend!) にてソースコードが公開されています。 (※閲覧するためには、あらかじめ github アカウントと EpicGames アカウントと連携し、github 上で EpicGames の Organization に参加している必要があります。以降のリンクも同様です)

UE のエディタから実行する時、あるいは PixelStreaming を有効にしたパッケージにはこの github から SignallingWebServer・SFU・MatchMaker 等の一式をダウンロードするバッチファイルが含まれています。

配信と操作を試すだけなら十分な機能が実装されていますが、WebRTC の DataChannel を通じた UE 側との連携を試すためには、下記対応が必要となります。

実は UE5.0 までの PixelStreaming では、Web クライアントのソースはシンプルな HTML と(ある意味)牧歌的な Javascript で書かれており、 改修も簡単でしたが、5.1 以降ではFrontendというフォルダに切り出され Typescript でガッツり書き直され、ガチガチにクラス化されているため、真面目に読む必要があります。

今回急ぎ対応する機会があったので、その方法をこちらにまとめておきます。

Frontend ビルド方法

Pixel Streaming Frontend によると ui-library ディレクトリで npm run build するよう書かれていますが、SignallingWebServer 起動時にビルド済のものがなかったら再ビルドする処理が入っているので、

  • SignallingWebServer/Public フォルダを削除して SignallingWebServer/platform_scripts/cmd/Start_SignallingServer.ps1 を実行する

で十分のようです。

修正の方針

  • Application の中では this.stream で PixelStreaming が取れます。
  • PixelStreaming に emitUIInteraction(string | object) が実装されているので、Application クラス内では this.stream.emitUIInteraction("FooBar"); のように書けます。
  • 左端のボタンをクリックすると、右側に設定パネルが表示される仕組みになっているので、同様にデバッグボタンを新設し、これをクリックするとデバッグパネルが開くようにします。 DataChannel のイベントを送るテスト用のボタンはこのデバッグパネルの中に並べていきます。
  • UE の PixelInputComponent から受信した DataChannel 通信を受け取るイベントハンドラもこのデバッグパネル内で実装します。

デバッグパネル用クラスの実装

Frontend/ui-library/src/UI/StatsPanel.ts を参考にデバッグパネル用のクラス DebugPanel.ts を実装します。 DataChannel 通信には PixelStreaming オブジェクトが必要なので、コンストラクタの引数に渡してメンバに保持しておきます。

import { Logger } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.2";
import { PixelStreaming } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.2";

// デバッグパネル
export class DebugPanel {
  _rootElement: HTMLElement;
  _debugCloseButton: HTMLElement;

  // FooカテゴリのGUI
  _debugContentFooElement: HTMLElement;
  _fooHoge1Button: HTMLInputElement;
  _fooHoge2Button: HTMLInputElement;

  // BarカテゴリのGUI
  _debugContentBarElement: HTMLElement;
  _barHelloWorldButton: HTMLInputElement;
  _barHellWorldButton: HTMLInputElement;

  // PixelStreamingオブジェクト(DataChannelの送受信に必要)
  _stream: PixelStreaming;

  constructor(stream: PixelStreaming) {
    this._stream = stream;

    // UEのPixelInputComponentから受信したDataChannel通信を受け取るイベントハンドラを設定
    this._stream.addResponseEventListener(
      "handle_responses",
      this.handleResponseFunction
    );
  }

  // パネルのUI要素を生成
  public get rootElement(): HTMLElement {
    if (!this._rootElement) {
      this._rootElement = document.createElement("div");
      this._rootElement.id = "debug-panel";
      this._rootElement.classList.add("panel-wrap");

      const panelElem = document.createElement("div");
      panelElem.classList.add("panel");
      this._rootElement.appendChild(panelElem);

      panelElem.appendChild(this.debugCloseButton);

      // Debugのタイトル表示
      const debugHeading = document.createElement("div");
      debugHeading.id = "debugHeading";
      debugHeading.textContent = "Debug";
      panelElem.appendChild(debugHeading);

      // Fooカテゴリ表示
      const fooHeading = document.createElement("div");
      fooHeading.id = "fooHeading";
      fooHeading.textContent = "Foo";
      panelElem.appendChild(fooHeading);

      // FooカテゴリのUIコンテナ
      panelElem.appendChild(this.debugContentFooElement);
      this.debugContentFooElement.appendChild(this.fooHoge1Button);
      this.debugContentFooElement.appendChild(this.fooHoge2Button);

      // Barカテゴリ表示
      const barHeading = document.createElement("div");
      barHeading.id = "barHeading";
      barHeading.textContent = "Bar";
      panelElem.appendChild(barHeading);

      // BarカテゴリのUIコンテナ
      panelElem.appendChild(this.debugContentBarElement);
      this.debugContentBarElement.appendChild(this.barHelloWorldButton);
      this.debugContentBarElement.appendChild(this.barHellWorldButton);
    }
    return this._rootElement;
  }

  // Fooカテゴリのコンテナ生成
  public get debugContentFooElement(): HTMLElement {
    if (!this._debugContentFooElement) {
      this._debugContentFooElement = document.createElement("div");
      this._debugContentFooElement.id = "debugContentFoo";
    }
    return this._debugContentFooElement;
  }

  // Barカテゴリのコンテナ生成
  public get debugContentBarElement(): HTMLElement {
    if (!this._debugContentBarElement) {
      this._debugContentBarElement = document.createElement("div");
      this._debugContentBarElement.id = "debugContentBar";
    }
    return this._debugContentBarElement;
  }

  // 閉じるボタン
  public get debugCloseButton(): HTMLElement {
    if (!this._debugCloseButton) {
      this._debugCloseButton = document.createElement("div");
      this._debugCloseButton.id = "debugClose";
    }
    return this._debugCloseButton;
  }

  // Hoge1ボタン生成
  public get fooHoge1Button(): HTMLInputElement {
    if (!this._fooHoge1Button) {
      this._fooHoge1Button = this.createFooButton("Hoge1", "Hoge!");
    }
    return this._fooHoge1Button;
  }

  // Hoge2ボタン生成
  public get fooHoge2Button(): HTMLInputElement {
    if (!this._fooHoge2Button) {
      this._fooHoge2Button = this.createFooButton("Hoge2", "HogeHoge!");
    }
    return this._fooHoge2Button;
  }

  // HelloWorldボタン生成
  public get barHelloWorldButton(): HTMLInputElement {
    if (!this._barHelloWorldButton) {
      this._barHelloWorldButton = this.createBarButton(
        "HelloWorld",
        "Hello",
        "World!"
      );
    }
    return this._barHelloWorldButton;
  }

  // HellWorldボタン生成
  public get barHellWorldButton(): HTMLInputElement {
    if (!this._barHellWorldButton) {
      this._barHellWorldButton = this.createBarButton(
        "HellWorld",
        "Hell",
        "World!"
      );
    }
    return this._barHellWorldButton;
  }

  // Fooカテゴリのボタンを生成
  private createFooButton(name: string, param1ID: string): HTMLInputElement {
    const descriptor = {
      EventID: "EVENT_FOO",
      Param1ID: param1ID,
    };
    return this.createDebugButton(name, descriptor);
  }

  // Barカテゴリのボタンを生成
  private createBarButton(
    name: string,
    param1ID: string,
    param2ID: string
  ): HTMLInputElement {
    const descriptor = {
      EventID: "EVENT_BAR",
      Param1ID: param1ID,
      Param2ID: param2ID,
    };
    return this.createDebugButton(name, descriptor);
  }

  // ボタン生成
  private createDebugButton(
    name: string,
    descriptor: object
  ): HTMLInputElement {
    const button = document.createElement("input");
    button.type = "button";
    button.value = name;
    button.id = "btn-start-latency-test";
    button.classList.add("streamTools-button");
    button.classList.add("btn-flat");
    button.onclick = () => {
      // DataChannel通信でUEのPixelStreamingInputコンポーネントに送信
      this._stream.emitUIInteraction(descriptor);
    };
    return button;
  }

  // UEのPixelStreamingInputコンポーネントからDataChannel通信で受け取ったイベントを処理
  public handleResponseFunction(response: string): void {
    Logger.Info(
      Logger.GetStackTrace(),
      "Received DataChannel Event: " + response
    );
  }

  // パネルを表示
  public show(): void {
    if (!this.rootElement.classList.contains("panel-wrap-visible")) {
      this.rootElement.classList.add("panel-wrap-visible");
    }
  }

  // パネルの表示状態を切り替える
  public toggleVisibility(): void {
    this.rootElement.classList.toggle("panel-wrap-visible");
  }

  // パネルを隠す
  public hide(): void {
    if (this.rootElement.classList.contains("panel-wrap-visible")) {
      this.rootElement.classList.remove("panel-wrap-visible");
    }
  }
}

デバッグアイコンの実装

Frontend/ui-library/src/UI/StatsIcon.ts をコピーして DebugIcon.ts にリネームし、クラス名を文字列置換で修正します。 StatsIcon は SVGElement が使われていますが、DebugIcon の方は手頃な SVG アイコンがないので HTMLElement にしました。

export class DebugIcon {
  _rootElement: HTMLButtonElement;
  _debugIcon: HTMLElement;
  _tooltipText: HTMLElement;

  public get rootElement(): HTMLButtonElement {
    if (!this._rootElement) {
      this._rootElement = document.createElement("button");
      this._rootElement.type = "button";
      this._rootElement.classList.add("UiTool");
      this._rootElement.id = "debugBtn";
      this._rootElement.appendChild(this.debugIcon);
      this._rootElement.appendChild(this.tooltipText);
    }
    return this._rootElement;
  }

  public get tooltipText(): HTMLElement {
    if (!this._tooltipText) {
      this._tooltipText = document.createElement("span");
      this._tooltipText.classList.add("tooltiptext");
      this._tooltipText.innerHTML = "Debug";
    }
    return this._tooltipText;
  }

  public get debugIcon(): HTMLElement {
    if (!this._debugIcon) {
      this._debugIcon = document.createElement("span");
      this._debugIcon.innerHTML = "Debug";
    }
    return this._debugIcon;
  }
}

スタイルシートの修正

追加した UI には独自の id を振っているため、スタイルシートのスタイルが適用されません。 そのため、スタイルシートを定義するFrontend/ui-library/src/Styles/PixelStreamingApplicationStyles.tsにも修正が必要となります。

          :
        '#minimizeIcon': {
            display: 'none'
        },
        '#settingsBtn, #statsBtn, #debugBtn': { // #debug~ を追記
            cursor: 'pointer'
        },
          :
        '.panel': {
            overflowY: 'auto',
            padding: '1em'
        },
        '#settingsHeading, #statsHeading, #debugHeading': { // #debug~ を追記
            display: 'inline-block',
            fontSize: '2em',
            marginBlockStart: '0.67em',
            marginBlockEnd: '0.67em',
            marginInlineStart: '0px',
            marginInlineEnd: '0px',
            position: 'relative',
            padding: '0 0 0 2rem'
        },
        '#settingsClose, #statsClose, #debugClose': { // #debug~ を追記
            margin: '0.5rem',
            paddingTop: '0.5rem',
            paddingBottom: '0.5rem',
            paddingRight: '0.5rem',
            fontSize: '2em',
            float: 'right'
        },
        '#settingsClose:after, #statsClose:after, #debugClose:after': { // #debug~ を追記
            paddingLeft: '0.5rem',
            display: 'inline-block',
            content: '"\\00d7"'
        },
        '#settingsClose:hover, #statsClose:hover, #debugClose:hover': { // #debug~ を追記
            color: 'var(--color3)',
            transition: 'ease 0.3s'
        },
        '#settingsContent, #statsContent, #debugContent': { // #debug~ を追記
            marginLeft: '2rem',
            marginRight: '2rem'
        },

デバッグパネル・デバッグアイコンの組み込み

作成した DebugPanel.tsDebugIcon.ts をアプリケーション本体に込み込んで行きます。 基本的に StatsPanel や StatsIcon で文字列検索し、その直後に DebugPanel や DebugIcon の処理を追記していけば OK です。

Frontend/ui-library/src/UI/Controls.ts の修正箇所は下記の通りとなります。

          :
import { StatsIcon } from './StatsIcon';
import { DebugIcon } from './DebugIcon'; // 追加 --------------------------
import { XRIcon } from './XRIcon';
          :
export type ControlsUIConfiguration = {
    //[Property in keyof Controls as `${Property}Type`]? : UIElementType;
    statsButtonType? : UIElementConfig,
    debugButtonType? : UIElementConfig, // 追加 --------------------------
    fullscreenButtonType? : UIElementConfig,
          :
}
          :
/**
 * Element containing various controls like stats, settings, fullscreen.
 */
export class Controls {
    statsIcon: StatsIcon;
    debugIcon: DebugIcon; // 追加 --------------------------
    fullscreenIcon: FullScreenIcon;
        :
    constructor(config? : ControlsUIConfiguration) {
        if (!config || shouldCreateButton(config.statsButtonType)) {
            this.statsIcon = new StatsIcon();
        }
        // ここから追加 -----------------------------------------------------
        if (!config || shouldCreateButton(config.debugButtonType)) {
            this.debugIcon = new DebugIcon();
        }
        // ここまで追加 -----------------------------------------------------
        if (!config || shouldCreateButton(config.settingsButtonType)){
            this.settingsIcon = new SettingsIcon();
        }
            :
    }
            :
    public get rootElement(): HTMLElement {
        if (!this._rootElement) {
                  :
            if (!!this.statsIcon) {
                this._rootElement.appendChild(this.statsIcon.rootElement);
            }
            // ここから追加 -----------------------------------------------------
            if (!!this.debugIcon) {
                this._rootElement.appendChild(this.debugIcon.rootElement);
            }
            // ここまで追加 -----------------------------------------------------
            if (!!this.xrIcon) {
                WebXRController.isSessionSupported('immersive-vr').then(
                (supported: boolean) => {
                    if (supported) {
                        this._rootElement.appendChild(this.xrIcon.rootElement);
                    }
                });
            };
        }
        return this._rootElement;
    }
}

同様にFrontend/ui-library/src/Application/Application.tsも修正していきます。

          :
import { StatsPanel } from '../UI/StatsPanel';
import { DebugPanel } from '../UI/DebugPanel'; // 追加 --------------------------
import { VideoQpIndicator } from '../UI/VideoQpIndicator';
          :
export interface UIOptions {
    stream: PixelStreaming;
          :
    /** By default, a stats panel and associate visibility toggle button will be made.
      * If needed, this behaviour can be configured. */
    statsPanelConfig?: PanelConfiguration;
    // ここから追加 -----------------------------------------------------
    /** By default, a stats panel and associate visibility toggle button will be made.
      * If needed, this behaviour can be configured. */
    debugPanelConfig?: PanelConfiguration;
    // ここまで追加 -----------------------------------------------------
    /** If needed, the full screen button can be external or disabled. */
    fullScreenControlsConfig? : UIElementConfig,
          :
}

export class Application {
    stream: PixelStreaming;
          :
    settingsPanel: SettingsPanel;
    statsPanel: StatsPanel;
    debugPanel: DebugPanel; // 追加 --------------------------
    videoQpIndicator: VideoQpIndicator;
          :
    constructor(options: UIOptions) {
        this._options = options;
          :
        if (isPanelEnabled(options.statsPanelConfig)) {
            // Add stats panel
            this.statsPanel = new StatsPanel();
            this.uiFeaturesElement.appendChild(this.statsPanel.rootElement);
        }

        // ここから追加 -----------------------------------------------------
        if (isPanelEnabled(options.debugPanelConfig)) {
            // Add debug panel
            this.debugPanel = new DebugPanel(this.stream);
            this.uiFeaturesElement.appendChild(this.debugPanel.rootElement);
        }
        // ここまで追加 -----------------------------------------------------

        if (isPanelEnabled(options.settingsPanelConfig)) {
          :
        }
          :
    }
          :
    public createButtons() {
          :
        if (!!this.statsPanel) {
            this.statsPanel.statsCloseButton.onclick = () => this.statsClicked();
        }

        // ここから追加 -----------------------------------------------------
        // setup the debug button
        const debugButton : HTMLElement | undefined =
            !!controls.debugIcon ? controls.debugIcon.rootElement :
            this._options.debugPanelConfig.visibilityButtonConfig.customElement;
        if (!!debugButton) debugButton.onclick = () => this.debugClicked()

        if (!!this.debugPanel) {
            this.debugPanel.debugCloseButton.onclick = () => this.debugClicked();
        }
        // ここまで追加 -----------------------------------------------------
          :
     }
          :
    /**
     * Shows or hides the settings panel if clicked
     */
    settingsClicked() {
        this.statsPanel.hide();
        this.debugPanel.hide(); // 追加 --------------------------
        this.settingsPanel.toggleVisibility();
    }

    /**
     * Shows or hides the stats panel if clicked
     */
    statsClicked() {
        this.settingsPanel.hide();
        this.debugPanel.hide(); // 追加 --------------------------
        this.statsPanel.toggleVisibility();
    }

    // ここから追加 -----------------------------------------------------
    /**
     * Shows or hides the debug panel if clicked
     */
    debugClicked() {
        this.settingsPanel.hide();
        this.statsPanel.hide();
        this.debugPanel.toggleVisibility();
    }
    // ここまで追加 -----------------------------------------------------
      :
}

実行例

ビルド(Public フォルダ削除して Start_SignallingServer.ps1)し、ブラウザで http://localhost:3000/ を開くと Debug ボタンが追加されており、Debug パネルが表示されます。 もしブラウザが真っ白になる時は、Console ログにエラーが出てないか確認して下さい。

文責:ともたこ/Tomotaka Ogino Twitter/github/Qiita