【Unity】WebGLで動画再生を独自実装する

こじゃらこじゃら

Unity標準のVideoPlayerを使わずにWebGLで動画再生する処理を自作したいの。

このはこのは

標準のVideoPlayerの内部実装を参考に自作する方法を見ていくね。

WebGL環境において、Unity標準のVideoPlayerのような動画再生機能を自作する方法の解説記事です。

次のような動画再生をjslibプラグインでネイティブ実装して実現するところを目指します。

Unityでは、動画再生機能はVideoPlayerコンポーネントとして提供されており、WebGL含めたあらゆるプラットフォームでの再生が可能です。

参考:Unity – Scripting API: VideoPlayer

通常の動画再生であれば、Unity標準のVideoPlayerコンポーネントで事足りる場面も多いと考えられますが、動画再生をネイティブ実装することで次のようなことが期待できるかもしれません。

ネイティブ実装で期待できる事
  • 動画再生の挙動を細かくカスタマイズできる
  • ブラウザ固有の不具合などに対応できる
  • VideoPlayerでは再生不可のフォーマットに対応する

単純な不具合回避目的であれば、例えばUnity 2022.1以降ではiOSやSafariなどでも動作するように修正されているため、Unityプロジェクトのアップグレードも一つの選択肢です。

本記事では、WebGL環境においてVideoPlayerコンポーネントのような動画再生機能をネイティブコードから独自実装する方法を解説していきます。

注意

本記事で解説する方法はWebGLプラットフォームでのビルド時のみ有効になります。それ以外のプラットフォームやUnityエディタ上では既存のVideoPlayerで代用して動かすことを目指します。

また、本記事で紹介する実装例は、あくまでも簡易的なものであり、VideoPlayerのような機能を完全に提供するものでない事をご了承ください。

動作環境
  • Unity 2023.2.20f1

スポンサーリンク

想定する状況

本記事では、前半に簡易的な実装例、後半に応用的な実装例を示す形で解説していきます。

前半では、以下のようにUnity UIで作成されたRaw Imageオブジェクトに対して単純に動画をレンダリングするものとします。

後半では、次のような操作を実装した例を紹介します。

実装する機能一覧
  • 再生
  • 停止
  • 一時停止
  • シーク
  • コマ送り
  • URL変更
  • 現在時刻の表示
  • ミュートの指定

動画再生の実装方法

本記事では、Unity提供のVideoPlayerコンポーネントの内部実装を参考に独自実装していくものとします。

内部実装のコードは以下ファイルより閲覧できます。

[Unityインストールディレクトリ]/Editor/Data/PlaybackEngines/WebGLSupport/BuildTools/lib/Video.js

WebGL環境で動画再生を独自実装するために、JavaScript側(jslibプラグイン)でネイティブ実装することとします。

video要素の作成

ここからはUnity C#ではなくJavaScriptでの実装方法の解説になります。

まず、動画再生するためにdocument.createElementメソッドでvideo要素を生成します。

const video = document.createElement('video');

参考:Document: createElement() メソッド – Web API | MDN

video要素はUnity側で内部的に使用したいため、非表示にしておきます。

video.style.display = 'none';

参考:style – HTML: ハイパーテキストマークアップ言語 | MDN

参考:display – CSS: カスケーディングスタイルシート | MDN

そして、video.src要素に動画再生用のURLを指定します。

video.src = UTF8ToString(url);

UTF8ToString関数はUnity C#のstring型オブジェクトのポインタをJavaScriptの文字列に変換するヘルパー関数です。

参考:Interaction with browser scripting – Unity マニュアル

参考:<video>: 動画埋め込み要素 – HTML: ハイパーテキストマークアップ言語 | MDN

このままでは、モバイルブラウザ等で自動再生ができないため、初期状態ではミュート、インライン再生を有効にしておきます。また、CORSによる取得設定も行っておきます。

video.muted = true;
video.setAttribute("muted", "");
video.setAttribute("playsinline", "");
video.crossOrigin = "anonymous";

動画の再生

video要素の初期化が出来たら、動画の再生を行います。

再生はvideo.playメソッドで行えます。

video.play();

テクスチャの転送

video要素を再生しただけでは、RenderTexture等に動画内容が転送されないため、video要素の内容が更新されるたびにテクスチャに画像転送する必要があります。

ただし、video要素の内容を直接転送しただけでは上下反転されて描画されるため、転送前にGLctx.pixelStoreiメソッド反転設定を行います。

GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);

参考:WebGLRenderingContext: pixelStorei() method – Web APIs | MDN

そして、GLctx.bindTextureメソッドで描画対象のテクスチャをバインドします。

GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[tex]);

参考:WebGLRenderingContext: bindTexture() method – Web APIs | MDN

バインドが出来たら、GLctx.texImage2Dメソッドでvideo要素の画像をテクスチャに適用します。

GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA, GLctx.UNSIGNED_BYTE, video);

参考:WebGLRenderingContext: texImage2D() method – Web APIs | MDN

適用が完了したら、最後に上下反転設定を元に戻します。

GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);

Unity C#側での準備

Unity C#では、JavaScriptに渡すためのテクスチャの準備を行います。

// レンダリング先のRenderTexture
// 例えばインスペクターから指定
RenderTexture targetRenderTexture;

// 動画のテクスチャ
Texture2D texture = new Texture2D(videoResolution.x, videoResolution.y, TextureFormat.RGBA32, false);

ただし、video要素から得られる画像データはsRGB色空間であるため、リニアワークフローの設定になっている場合、そのままでは正しい色が出力されません。

そのため、リニアワークフロー設定の場合、sRGBからLinearに色空間を変換する処理を施す必要があります。これはピクセルシェーダーで行うのが良いでしょう。

fixed4 sRGB = tex2D(_MainTex, i.uv);
const float3 c = sRGB.rgb;
return fixed4(c * (c * (c * 0.305306011 + 0.682171111) + 0.012522878), sRGB.a);

上記処理は、VideoPlayerコンポーネントの内部実装を参考にしています。

Tips

色空間のワークフローの設定は、トップメニューのEdit > Project Settings…からProject Settingsウィンドウを開き、左側からPlayerを選択し、Other Settings > Rendering > Color Space*の項目から確認できます。

参考:リニアのワークフローとガンマのワークフロー – Unity マニュアル

色空間のワークフローの設定状態は、QualitySettings.activeColorSpaceプロパティから取得できます。

// sRGB→Linear変換シェーダー
Shader sRGBToLinearShader;

・・・(中略)・・・

// リニアワークフローの場合のみマテリアルを準備
if (QualitySettings.activeColorSpace == ColorSpace.Linear)
    sRGBToLinearMaterial = new Material(sRGBToLinearShader);

参考:Unity – Scripting API: QualitySettings.activeColorSpace

video要素からの画像転送は、Texture2D.GetNativeTexturePtrメソッドから得られるテクスチャIDをJavaScript側に渡し、Texture2Dオブジェクトに反映してもらえば良いです。

// 動画のテクスチャ転送処理など
UpdateToTexture(videoInstanceID, texture.GetNativeTexturePtr());

参考:Texture-GetNativeTexturePtr – Unity スクリプトリファレンス

video要素からテクスチャを取得出来たら、RenderTextureに転送して完了です。

if (sRGBToLinearMaterial != null)
    Graphics.Blit(texture, targetRenderTexture, sRGBToLinearMaterial);
else
    Graphics.Blit(texture, targetRenderTexture);

Texture2Dの内容をRenderTextureに転送したい場合、Graphics.Blitメソッドを使えば良いです。必要に応じて第3引数にマテリアルを指定できます。

参考:Graphics-Blit – Unity スクリプトリファレンス

動画再生する最低限のスクリプト

ここまでの内容を踏まえて、実際にWebGL環境で再生するための実装例を示します。

1つ目の例はUnityエディタ上では動かず、WebGLビルドでしか動かない点にご注意ください。

スクリプトの実装例

以下、動画を自動再生するための最小限のスクリプトです。2つある事にご注意ください。

サンプルスクリプト(クリックで開きます)
SimpleWebGLVideoPlayer.jslib
var SimpleWebGLVideoPlayer = {
    $videos: {},
    $videoIdCounter: 0,

    SimpleWebGLVideoPlayer_Create: function (url) {
        const video = document.createElement('video');
        video.style.display = 'none';
        video.src = UTF8ToString(url);
        video.muted = true;
        video.setAttribute("muted", "");
        video.setAttribute("playsinline", "");
        video.crossOrigin = "anonymous";

        videos[++videoIdCounter] = video;

        return videoIdCounter;
    },

    SimpleWebGLVideoPlayer_UpdateToTexture: function (id, tex) {
        const video = videos[id];

        if (video.seeking) return false;
        if (video.currentTime === video.lastUpdateTextureTime) return false;

        video.lastUpdateTextureTime = video.currentTime;
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
        GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[tex]);

        GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA, GLctx.UNSIGNED_BYTE, video);
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);

        return true;
    },

    SimpleWebGLVideoPlayer_Destroy: function (id) {
        const video = videos[id];
        video.src = '';
        delete videos[id];
    },

    SimpleWebGLVideoPlayer_Play: function (id) {
        const video = videos[id];
        video.play();
    },

    SimpleWebGLVideoPlayer_GetWidth: function (id) {
        const video = videos[id];
        return video.videoWidth;
    },

    SimpleWebGLVideoPlayer_GetHeight: function (id) {
        const video = videos[id];
        return video.videoHeight;
    },
};

autoAddDeps(SimpleWebGLVideoPlayer, '$videos');
autoAddDeps(SimpleWebGLVideoPlayer, '$videoIdCounter');
mergeInto(LibraryManager.library, SimpleWebGLVideoPlayer);
SimpleWebGLVideoPlayer.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;

public class SimpleWebGLVideoPlayer : MonoBehaviour
{
    // 動画のURL
    [SerializeField] private string _url;

    // レンダリング先のRenderTexture
    [SerializeField] private RenderTexture _targetRenderTexture;

    // 色空間をsRGBからLinearに変換するシェーダ
    [SerializeField] private Shader _sRGBToLinearShader;

    // 内部管理用のインスタンスID
    private int _videoInstanceID;

    // 動画のテクスチャ
    private Texture2D _texture;

    // sRGBからLinearに変換するためのマテリアル
    private Material _sRGBToLinearMaterial;

    // 現在の動画解像度
    private Vector2Int _videoResolution;

    #region ネイティブ関数のインポート

    [DllImport("__Internal")]
    private static extern int SimpleWebGLVideoPlayer_Create(string url);

    [DllImport("__Internal")]
    private static extern void SimpleWebGLVideoPlayer_UpdateToTexture(int instanceID, IntPtr texture);

    [DllImport("__Internal")]
    private static extern void SimpleWebGLVideoPlayer_Destroy(int instanceID);

    [DllImport("__Internal")]
    private static extern void SimpleWebGLVideoPlayer_Play(int instanceID);

    [DllImport("__Internal")]
    private static extern int SimpleWebGLVideoPlayer_GetWidth(int instanceID);

    [DllImport("__Internal")]
    private static extern int SimpleWebGLVideoPlayer_GetHeight(int instanceID);

    #endregion

    /// <summary>
    /// 初期化
    /// </summary>
    private void Start()
    {
        // video要素のインスタンス生成
        _videoInstanceID = SimpleWebGLVideoPlayer_Create(_url);

        // 色空間がLinearの場合、動画のテクスチャをsRGBからLinearに変換する必要がある
        if (_sRGBToLinearShader != null &&
            QualitySettings.activeColorSpace == ColorSpace.Linear)
        {
            // sRGBからLinearに変換するためのマテリアルを作成
            _sRGBToLinearMaterial = new Material(_sRGBToLinearShader);
        }
        
        // 動画を再生
        SimpleWebGLVideoPlayer_Play(_videoInstanceID);
    }

    /// <summary>
    /// 後処理
    /// </summary>
    private void OnDestroy()
    {
        // video要素のインスタンスを破棄
        if (_videoInstanceID > 0)
        {
            SimpleWebGLVideoPlayer_Destroy(_videoInstanceID);
            _videoInstanceID = 0;
        }

        if (_texture != null)
        {
            // テクスチャを破棄
            Destroy(_texture);
            _texture = null;
        }

        if (_sRGBToLinearMaterial != null)
        {
            // マテリアルを破棄
            Destroy(_sRGBToLinearMaterial);
            _sRGBToLinearMaterial = null;
        }
    }

    /// <summary>
    /// フレーム更新
    /// </summary>
    public void Update()
    {
        // 動画の解像度を取得
        var resolution = new Vector2Int(
            SimpleWebGLVideoPlayer_GetWidth(_videoInstanceID),
            SimpleWebGLVideoPlayer_GetHeight(_videoInstanceID)
        );

        if (resolution.x <= 0 || resolution.y <= 0)
            return;

        // 動画の解像度が変更されたら、動画のテクスチャを再作成
        if (_videoResolution != resolution)
        {
            _videoResolution = resolution;

            if (_texture != null)
            {
                Destroy(_texture);
                _texture = null;
            }

            _texture = new Texture2D(_videoResolution.x, _videoResolution.y, TextureFormat.RGBA32, false);
        }

        SimpleWebGLVideoPlayer_UpdateToTexture(_videoInstanceID, _texture.GetNativeTexturePtr());

        if (_sRGBToLinearMaterial != null)
            Graphics.Blit(_texture, _targetRenderTexture, _sRGBToLinearMaterial);
        else
            Graphics.Blit(_texture, _targetRenderTexture);
    }
}

1つ目のスクリプトをSimpleWebGLVideoPlayer.jslibという名前でPluginsフォルダ配下に保存、2つ目のスクリプトをSimpleWebGLVideoPlayer.csとして適当なフォルダに保存してください。

例では、以下階層に保存するものとしました。

/Assets/Plugins/WebGL/SimpleWebGLVideoPlayer.jslib
/Assets/Scripts/SimpleWebGLVideoPlayer.cs

sRGBからLinearに変換するシェーダーの実装例

リニアワークフローの場合、sRGBからLinear空間に変換するためのシェーダーを実装して適用する必要があります。

以下、変換シェーダーの実装例です。

シェーダー(クリックで開きます)
sRGBToLinear.shader
Shader "Hidden/sRGBToLinear"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 sRGB = tex2D(_MainTex, i.uv);
                const float3 c = sRGB.rgb;
                return fixed4(c * (c * (c * 0.305306011 + 0.682171111) + 0.012522878), sRGB.a);
            }
            ENDCG
        }
    }
}

上記をsRGBToLinear.shaderという名前で適用な階層に保存してください。例では、以下階層に置くものとしました。

/Assets/Shaders/sRGBToLinear.shader

テクスチャの準備

描画先のRenderTextureを作成しておきます。例では、1920×1080の解像度のRenderTextureを予め作成しておくものとします。

UIの準備

例では、RenderTextureの内容を反映するためのRaw Imageオブジェクトを画面上に配置します。ここに先ほど作成したRenderTextureを適用します。

スクリプトの適用

RenderTextureに対して動画再生用のスクリプトを適用していきます。

例では、新しいゲームオブジェクトをヒエラルキー上に配置し、ここに実装例で示したSimpleWebGLVideoPlayerスクリプトを追加します。

 

そして、追加したスクリプト(コンポーネント)のインスペクターより、以下項目を設定してください。

  • Url – 動画のURL
  • Target Render Texture – 出力先のRenderTexture
  • SRGB To Linear Shader – sRGBからLinearに色空間を変換するシェーダー

実行結果

この状態でWebGLビルドすると、ミュート状態で動画が再生されるようになります。

スクリプトの説明

JavaScript側のコードでは、Unity C#から呼び出される関数として各種処理を実装しています。

例では、video要素のインスタンスを格納するオブジェクトと、インクリメンタルIDを管理する変数を定義しています。

var SimpleWebGLVideoPlayer = {
    $videos: {},
    $videoIdCounter: 0,

video要素の作成は、以下関数で行っています。

SimpleWebGLVideoPlayer_Create: function (url) {
    const video = document.createElement('video');
    video.style.display = 'none';
    video.src = UTF8ToString(url);
    video.muted = true;
    video.setAttribute("muted", "");
    video.setAttribute("playsinline", "");
    video.crossOrigin = "anonymous";

    videos[++videoIdCounter] = video;

    return videoIdCounter;
},

作成したvideo要素をvideosオブジェクトに格納して管理するようにしています。

video要素からテクスチャに反映する処理は、以下関数で行っています。

SimpleWebGLVideoPlayer_UpdateToTexture: function (id, tex) {
    const video = videos[id];

    if (video.seeking) return false;
    if (video.currentTime === video.lastUpdateTextureTime) return false;

    video.lastUpdateTextureTime = video.currentTime;
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
    GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[tex]);

    GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA, GLctx.UNSIGNED_BYTE, video);
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);

    return true;
},

Unity C#側で保持されたvideo要素のインスタンスIDとテクスチャのIDを受け取り、テクスチャが更新されていたら出力先のテクスチャにvideo要素の現在画像を適用する処理にしています。

その他、video要素のインスタンス破棄動画再生動画の幅と高さを取得する関数も定義しています。

SimpleWebGLVideoPlayer_Destroy: function (id) {
    const video = videos[id];
    video.src = '';
    delete videos[id];
},

SimpleWebGLVideoPlayer_Play: function (id) {
    const video = videos[id];
    video.play();
},

SimpleWebGLVideoPlayer_GetWidth: function (id) {
    const video = videos[id];
    return video.videoWidth;
},

SimpleWebGLVideoPlayer_GetHeight: function (id) {
    const video = videos[id];
    return video.videoHeight;
},

ここまで定義した変数を関数から参照できるようにするため、autoAddDeps関数を用いて紐づけています。

autoAddDeps(SimpleWebGLVideoPlayer, '$videos');
autoAddDeps(SimpleWebGLVideoPlayer, '$videoIdCounter');

そして、関数をネイティブプラグインとしてUnity C#から使えるようにするために、mergeInto関数でマージしています。

mergeInto(LibraryManager.library, SimpleWebGLVideoPlayer);

Unity C#側では、以下のように[DllImport(“__Internal”)]属性でjslib側の関数をインポートできます。

[DllImport("__Internal")]
private static extern int SimpleWebGLVideoPlayer_Create(string url);

[DllImport("__Internal")]
private static extern void SimpleWebGLVideoPlayer_UpdateToTexture(int instanceID, IntPtr texture);

[DllImport("__Internal")]
private static extern void SimpleWebGLVideoPlayer_Destroy(int instanceID);

[DllImport("__Internal")]
private static extern void SimpleWebGLVideoPlayer_Play(int instanceID);

[DllImport("__Internal")]
private static extern int SimpleWebGLVideoPlayer_GetWidth(int instanceID);

[DllImport("__Internal")]
private static extern int SimpleWebGLVideoPlayer_GetHeight(int instanceID);

参考:Interaction with browser scripting – Unity マニュアル

初期化処理では、jslibで定義した作成関数をコールして発行されたvideo要素に紐づくIDを保持するようにします。

// video要素のインスタンス生成
_videoInstanceID = SimpleWebGLVideoPlayer_Create(_url);

また、リニアワークフローの場合は色空間変換用のマテリアルを作成して使えるようにします。

// 色空間がLinearの場合、動画のテクスチャをsRGBからLinearに変換する必要がある
if (_sRGBToLinearShader != null &&
    QualitySettings.activeColorSpace == ColorSpace.Linear)
{
    // sRGBからLinearに変換するためのマテリアルを作成
    _sRGBToLinearMaterial = new Material(_sRGBToLinearShader);
}

Updateイベントでは、video要素のテクスチャ更新を行います。

まず、video要素の解像度が変更されたタイミングでTexture2Dオブジェクトを再作成(新規の場合は新規作成)します。

public void Update()
{
    // 動画の解像度を取得
    var resolution = new Vector2Int(
        SimpleWebGLVideoPlayer_GetWidth(_videoInstanceID),
        SimpleWebGLVideoPlayer_GetHeight(_videoInstanceID)
    );

    if (resolution.x <= 0 || resolution.y <= 0)
        return;

    // 動画の解像度が変更されたら、動画のテクスチャを再作成
    if (_videoResolution != resolution)
    {
        _videoResolution = resolution;

        if (_texture != null)
        {
            Destroy(_texture);
            _texture = null;
        }

        _texture = new Texture2D(_videoResolution.x, _videoResolution.y, TextureFormat.RGBA32, false);
    }

毎回作成しても動きますが、解像度に変化が無い場合は余計な処理コストになるため、変化した瞬間に行うのが良いでしょう。

そして、jslibの関数を通じて前述のTexture2Dオブジェクトに動画の内容を反映します。

SimpleWebGLVideoPlayer_UpdateToTexture(_videoInstanceID, _texture.GetNativeTexturePtr());

最後に、RenderTextureにTexture2Dの内容を転送して完了です。この時、リニアワークフローの場合はマテリアルが作成されるので、第3引数に色空間を変換するマテリアル指定しています。

if (_sRGBToLinearMaterial != null)
    Graphics.Blit(_texture, _targetRenderTexture, _sRGBToLinearMaterial);
else
    Graphics.Blit(_texture, _targetRenderTexture);

独自VideoPlayerの設計

ここまでは単純な再生例を紹介しました。

2つ目の例では、以下操作に対応した独自VideoPlayerを実装するものとします。

実装する機能一覧
  • 再生
  • 停止
  • 一時停止
  • シーク
  • コマ送り
  • URL変更
  • 現在時刻の表示
  • ミュートの指定

また、Unityエディタ上でも確認できるように、WebGLビルド実行時以外ではUnity標準のVideo Playerコンポーネントで代用するようにします。

スクリプトの実装例

まず、動画再生部分のスクリプトの完成形を示します。

以下、実装例です。スクリプトが全部で5つあることにご注意ください。

サンプルスクリプト(クリックで開きます)
WebGLVideoPlayer.jslib
var WebGLVideoControl = {
    $webGLVideoControlInstances: {},
    $webGLVideoControlIdCounter: 0,

    WebGLVideoControl_Create: function (url) {
        const video = document.createElement('video');
        video.style.display = 'none';
        video.src = UTF8ToString(url);
        video.muted = true;
        video.setAttribute("muted", "");
        video.setAttribute("playsinline", "");
        video.crossOrigin = "anonymous";

        webGLVideoControlInstances[++webGLVideoControlIdCounter] = video;

        return webGLVideoControlIdCounter;
    },

    WebGLVideoControl_UpdateToTexture: function (id, tex) {
        const video = webGLVideoControlInstances[id];

        if (video.seeking) return false;
        if (video.currentTime === video.lastUpdateTextureTime) return false;

        video.lastUpdateTextureTime = video.currentTime;
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
        GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[tex]);

        GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA, GLctx.UNSIGNED_BYTE, video);
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);

        return true;
    },

    WebGLVideoControl_Destroy: function (id) {
        const video = webGLVideoControlInstances[id];
        video.src = '';
        delete webGLVideoControlInstances[id];
    },

    WebGLVideoControl_Play: function (id, muted) {
        const video = webGLVideoControlInstances[id];
        video.muted = muted;
        video.play();
    },

    WebGLVideoControl_Stop: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
        video.currentTime = 0;
    },

    WebGLVideoControl_Pause: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
    },

    WebGLVideoControl_StepForward: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
        video.currentTime += 1 / 24;
    },

    WebGLVideoControl_GetURL: function (id) {
        const video = webGLVideoControlInstances[id];

        const result = video.src;
        var bufferSize = lengthBytesUTF8(result) + 1;
        var buffer = _malloc(bufferSize);
        stringToUTF8(result, buffer, bufferSize);
        return buffer;
    },

    WebGLVideoControl_SetURL: function (id, url) {
        const video = webGLVideoControlInstances[id];
        video.src = UTF8ToString(url);
    },

    WebGLVideoControl_GetTime: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.currentTime;
    },

    WebGLVideoControl_SetTime: function (id, time) {
        const video = webGLVideoControlInstances[id];

        if (video.readyState >= HTMLMediaElement.HAVE_METADATA)
            video.currentTime = time;
    },

    WebGLVideoControl_GetDuration: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.duration;
    },

    WebGLVideoControl_IsMuted: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.muted;
    },

    WebGLVideoControl_SetMuted: function (id, mute) {
        const video = webGLVideoControlInstances[id];
        video.muted = mute;
    },

    WebGLVideoControl_GetWidth: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.videoWidth;
    },

    WebGLVideoControl_GetHeight: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.videoHeight;
    },
};

autoAddDeps(WebGLVideoControl, '$webGLVideoControlInstances');
autoAddDeps(WebGLVideoControl, '$webGLVideoControlIdCounter');
mergeInto(LibraryManager.library, WebGLVideoControl);
IVideoControl.cs
using System;

public interface IVideoControl : IDisposable
{
    void Play();
    void Stop();
    void Pause();
    void StepForward();
    
    string URL { get; set; }
    double Time { get; set; }
    double Duration { get; }
    bool IsMuted { get; set; }
    
    void Update();
}
VideoPlayerControl.cs
#if UNITY_EDITOR || !UNITY_WEBGL
using UnityEngine;
using UnityEngine.Video;

public class VideoPlayerControl : IVideoControl
{
    // 動画再生用のVideoPlayer
    private VideoPlayer _videoPlayer;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="videoPlayerObject">VideoPlayerを追加するGameObject</param>
    /// <param name="url">動画のURL</param>
    /// <param name="targetRenderTexture">動画のレンダリング対象のRenderTexture </param>
    public VideoPlayerControl(GameObject videoPlayerObject, string url, RenderTexture targetRenderTexture)
    {
        // VideoPlayerコンポーネントを作成して初期化
        _videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
        _videoPlayer.playOnAwake = false;
        _videoPlayer.url = url;
        _videoPlayer.source = VideoSource.Url;
        _videoPlayer.renderMode = VideoRenderMode.RenderTexture;
        _videoPlayer.targetTexture = targetRenderTexture;
    }

    /// <summary>
    /// 後処理
    /// </summary>
    public void Dispose()
    {
        if (_videoPlayer == null) return;

        // VideoPlayerコンポーネントを削除
        Object.Destroy(_videoPlayer);
        _videoPlayer = null;
    }

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        _videoPlayer.Play();
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        _videoPlayer.Stop();
    }

    /// <summary>
    /// 動画を一時停止する
    /// </summary>
    public void Pause()
    {
        _videoPlayer.Pause();
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        if (!_videoPlayer.isPaused)
            _videoPlayer.Pause();

        _videoPlayer.StepForward();
    }

    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => _videoPlayer.url;
        set => _videoPlayer.url = value;
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => _videoPlayer.time;
        set => _videoPlayer.time = value;
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => _videoPlayer.length;

    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get
        {
            // どれか一つでもミュートされていないトラックがあれば、ミュートされていないと判定
            var isMuted = true;
            for (ushort i = 0; i < _videoPlayer.audioTrackCount; i++)
            {
                if (_videoPlayer.GetDirectAudioMute(i)) continue;

                isMuted = false;
                break;
            }

            return isMuted;
        }
        set
        {
            // 全てのオーディオトラックのミュート状態を設定
            for (ushort i = 0; i < _videoPlayer.audioTrackCount; i++)
            {
                _videoPlayer.SetDirectAudioMute(i, value);
            }
        }
    }

    public void Update()
    {
        // 何もしない
    }
}
#endif
WebGLVideoControl.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using Object = UnityEngine.Object;

public class WebGLVideoControl : IVideoControl
{
    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_Create(string url);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_UpdateToTexture(int instanceID, IntPtr texture);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Destroy(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Play(int instanceID, bool muted);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Stop(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Pause(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_StepForward(int instanceID);

    [DllImport("__Internal")]
    private static extern string WebGLVideoControl_GetURL(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetURL(int instanceID, string url);

    [DllImport("__Internal")]
    private static extern double WebGLVideoControl_GetTime(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetTime(int instanceID, double time);

    [DllImport("__Internal")]
    private static extern double WebGLVideoControl_GetDuration(int instanceID);

    [DllImport("__Internal")]
    private static extern bool WebGLVideoControl_IsMuted(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetMuted(int instanceID, bool muted);

    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_GetWidth(int instanceID);

    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_GetHeight(int instanceID);

    // レンダリング先のRenderTexture
    private readonly RenderTexture _targetRenderTexture;
    
    // 内部管理用のインスタンスID
    private int _videoInstanceID;
    
    // 動画のテクスチャ
    private Texture2D _texture;
    
    // sRGBからLinearに変換するためのマテリアル
    private Material _sRGBToLinearMaterial;
    
    // ミュート状態
    private bool _isMuted;
    
    // 現在の動画解像度
    private Vector2Int _videoResolution;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="url">動画のURL</param>
    /// <param name="targetRenderTexture">レンダリング先のRenderTexture</param>
    /// <param name="sRGBToLinearShader">sRGBからLinearに変換するためのシェーダー</param>
    public WebGLVideoControl(string url, RenderTexture targetRenderTexture, Shader sRGBToLinearShader)
    {
        // 引数を保持
        _targetRenderTexture = targetRenderTexture;
        _videoInstanceID = WebGLVideoControl_Create(url);

        // 色空間がLinearの場合、動画のテクスチャをsRGBからLinearに変換する必要がある
        if (sRGBToLinearShader != null &&
            QualitySettings.activeColorSpace == ColorSpace.Linear)
        {
            // sRGBからLinearに変換するためのマテリアルを作成
            _sRGBToLinearMaterial = new Material(sRGBToLinearShader);
        }
    }

    /// <summary>
    /// 後処理
    /// </summary>
    public void Dispose()
    {
        // video要素のインスタンスを破棄
        WebGLVideoControl_Destroy(_videoInstanceID);
        _videoInstanceID = 0;

        if (_texture != null)
        {
            // テクスチャを破棄
            Object.Destroy(_texture);
            _texture = null;
        }

        if (_sRGBToLinearMaterial != null)
        {
            // マテリアルを破棄
            Object.Destroy(_sRGBToLinearMaterial);
            _sRGBToLinearMaterial = null;
        }
    }

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        WebGLVideoControl_Play(_videoInstanceID, _isMuted);
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        WebGLVideoControl_Stop(_videoInstanceID);
    }

    /// <summary>
    /// 動画再生を一時停止する
    /// </summary>
    public void Pause()
    {
        WebGLVideoControl_Pause(_videoInstanceID);
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        WebGLVideoControl_StepForward(_videoInstanceID);
    }

    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => WebGLVideoControl_GetURL(_videoInstanceID);
        set => WebGLVideoControl_SetURL(_videoInstanceID, value);
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => WebGLVideoControl_GetTime(_videoInstanceID);
        set => WebGLVideoControl_SetTime(_videoInstanceID, value);
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => WebGLVideoControl_GetDuration(_videoInstanceID);

    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get
        {
            _isMuted = WebGLVideoControl_IsMuted(_videoInstanceID);
            return _isMuted;
        }
        set
        {
            _isMuted = value;
            WebGLVideoControl_SetMuted(_videoInstanceID, value);
        }
    }

    /// <summary>
    /// フレーム更新
    /// </summary>
    public void Update()
    {
        // 動画の解像度を取得
        var resolution = new Vector2Int(
            WebGLVideoControl_GetWidth(_videoInstanceID),
            WebGLVideoControl_GetHeight(_videoInstanceID)
        );
        
        if (resolution.x <= 0 || resolution.y <= 0)
            return;
        
        // 動画の解像度が変更されたら、動画のテクスチャを再作成
        if (_videoResolution != resolution)
        {
            _videoResolution = resolution;
            
            if (_texture != null)
            {
                Object.Destroy(_texture);
                _texture = null;
            }

            _texture = new Texture2D(_videoResolution.x, _videoResolution.y, TextureFormat.RGBA32, false);
        }

        WebGLVideoControl_UpdateToTexture(_videoInstanceID, _texture.GetNativeTexturePtr());

        if (_sRGBToLinearMaterial != null)
            Graphics.Blit(_texture, _targetRenderTexture, _sRGBToLinearMaterial);
        else
            Graphics.Blit(_texture, _targetRenderTexture);
    }
}
CustomVideoPlayer.cs
using UnityEngine;

public class CustomVideoPlayer : MonoBehaviour
{
    // 動画のURL
    [SerializeField] private string _url;
    
    // ミュート状態
    [SerializeField] private bool _isMuted;
    
    // レンダリング先のRenderTexture
    [SerializeField] private RenderTexture _targetRenderTexture;
    
    // 色空間をsRGBからLinearに変換するシェーダ
    [SerializeField] private Shader _sRGBToLinearShader;

    // 動画再生用のインスタンス
    private IVideoControl _videoControl;

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        _videoControl.Play();
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        _videoControl.Stop();
    }

    /// <summary>
    /// 動画再生を一時停止する
    /// </summary>
    public void Pause()
    {
        _videoControl.Pause();
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        _videoControl.StepForward();
    }
    
    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => _videoControl.URL;
        set => _videoControl.URL = value;
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => _videoControl.Time;
        set => _videoControl.Time = value;
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => _videoControl.Duration;
    
    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get => _isMuted;
        set
        {
            _isMuted = value;
            _videoControl.IsMuted = value;
        }
    }

    private void Awake()
    {
        // WebGLビルド時はWebGLVideoControlを使用
        // それ以外の環境ではVideoPlayerControlを使用
#if !UNITY_EDITOR && UNITY_WEBGL
        _videoControl = new WebGLVideoControl(_url, _targetRenderTexture, _sRGBToLinearShader);
#else
        _videoControl = new VideoPlayerControl(gameObject, _url, _targetRenderTexture);
#endif
    }

    private void OnDestroy()
    {
        // 後処理
        _videoControl.Dispose();
    }

    private void Update()
    {
        // フレームの更新処理
        _videoControl.Update();
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        if (_videoControl == null) return;
        if (_videoControl.URL == _url) return;

        // URLが変更されていたら、新しいURLを設定する
        _videoControl.URL = _url;
    }
#endif
}

.jslibファイルはPluginsフォルダ配下に保存することにご注意ください。

例では以下のようなパスに格納するものとします。

/Assets/Plugins/WebGL/WebGLVideoPlayer.jslib
/Assets/Scripts/IVideoControl.cs
/Assets/Scripts/VideoPlayerControl.cs
/Assets/Scripts/WebGLVideoControl.cs
/Assets/Scripts/CustomVideoPlayer.cs

UIの準備

1つ目の例に加えて、必要に応じて操作用のUIを追加します。

例では、以下UIを追加するものとします。

  • URL入力欄 – TextMeshPro – Input Field
  • 再生時刻 – TextMeshPro – Text
  • シークバー – Slider
  • 各種操作ボタン – Button
  • ミュート指定 – Toggle

また、例では、UI操作用に以下スクリプト経由で独自VideoPlayerの動画再生を操作するものとします。

サンプルスクリプト(クリックで開きます)
VideoControlUI.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class VideoControlUI : MonoBehaviour
{
    [SerializeField] private CustomVideoPlayer _videoPlayer;
    [SerializeField] private TMP_InputField _urlInputField;
    [SerializeField] private Slider _slider;
    [SerializeField] private TMP_Text _timeText;
    [SerializeField] private Toggle _muteToggle;

    private bool _isSeekingAutomatically;

    private void Start()
    {
        if (_videoPlayer == null) return;

        if (_urlInputField != null)
        {
            // 動画のURLをInputFieldに反映
            _urlInputField.text = _videoPlayer.URL;

            // 動画のURLが変更されたら、CustomVideoPlayerに反映
            _urlInputField.onEndEdit.AddListener(url => _videoPlayer.URL = url);
        }

        if (_slider != null)
        {
            // スライダーの値が変更されたら、動画の再生進捗を変更
            _slider.onValueChanged.AddListener(value =>
            {
                if (_isSeekingAutomatically) return;
                _videoPlayer.Time = value * _videoPlayer.Duration;
            });
        }

        if (_muteToggle != null)
        {
            // 最初にミュート設定を反映
            _muteToggle.isOn = _videoPlayer.IsMuted;
            
            // ミュート設定が変更されたら、CustomVideoPlayerに反映
            _muteToggle.onValueChanged.AddListener(isOn => _videoPlayer.IsMuted = isOn);
        }
    }

    // UIの更新処理
    private void Update()
    {
        if (_videoPlayer == null) return;
        if (_videoPlayer.Duration <= 0) return;

        // 動画の再生位置を取得
        var time = _videoPlayer.Time;

        if (_slider != null)
        {
            _isSeekingAutomatically = true;

            // 動画の再生進捗を計算
            var progress = Mathf.Clamp01((float) (time / _videoPlayer.Duration));

            // スライダーの値を更新
            if (!float.IsNaN(progress))
                _slider.value = progress;

            _isSeekingAutomatically = false;
        }

        if (_timeText != null)
        {
            // 動画の再生時間をテキストに反映
            var minutes = Mathf.FloorToInt((float) time / 60);
            var seconds = Mathf.FloorToInt((float) time % 60);
            _timeText.SetText("{0:00}:{1:00}", minutes, seconds);
        }
    }
}

スクリプトの適用

ここまで実装したスクリプトをオブジェクトに適用していきます。

まず、独自VideoPlayerであるCustomVideoPlayerスクリプトを適当なゲームオブジェクトに追加します。

そして、インスペクターより以下項目を設定してください。

  • Url – 動画のURL
  • Is Muted – ミュートの有無
  • Target Render Texture – 出力先のRenderTexture
  • SRGB To Linear Shader – sRGBからLinearに色空間を変換するシェーダー

このCustomVideoPlayerを操作するためのVideoControlUIスクリプトを適当なゲームオブジェクトに追加します。

例では、Canvasに追加するものとしました。

そして、インスペクターより各種要素をアタッチしてください。

UIの指定は任意です。

  • Video Player – Custom Video Playerコンポーネント
  • Url Input Field – URLの入力欄
  • Slider – シークバーのSlider
  • Time Text – 再生時刻表示用テキスト
  • Mute Toggle – ミュート指定のToggle

最後に、各種ボタンのOn ClickイベントにCustomVideoPlayerのメソッドを登録して完了です。

例では、以下ボタンにそれぞれメソッドを割り当てることとしました。

  • Play – CustomVideoPlayer.Play()
  • Stop – CustomVideoPlayer.Stop()
  • Pause – CustomVideoPlayer.Pause()
  • StepForward – CustomVideoPlayer.StepForward()

実行結果

設定したUIで動画再生を操作できるようになりました。

Unityエディタ時では、Unity標準のVideoPlayerコンポーネントが追加されていることが確認できます。

スクリプトの説明

2つ目の例では、WebGLビルド時とそれ以外の場合で挙動が異なるため、次のようなクラス設計にしました。

独自VideoPlayerのクラス図

動画再生に関わるロジックを疎結合にすることで、将来的には異なるプラットフォームへのネイティブ対応も視野に入れた設計にしています。

WebGLビルド時のjslibの実装は1つ目の例と基本的に一緒ですが、操作用の関数が新たに加わっています。

その中で特に厄介なのがコマ送りの実装で、video要素の仕様上フレームレートを取得できません。そのため、Unity標準のVideoPlayerの実装に倣って24フレーム固定として処理しています。

WebGLVideoControl_StepForward: function (id) {
    const video = webGLVideoControlInstances[id];
    video.pause();
    video.currentTime += 1 / 24;
},

また、動画の準備が完了していない場合はシークできないため、video要素のreadyStateプロパティの値をチェックしています。

WebGLVideoControl_SetTime: function (id, time) {
    const video = webGLVideoControlInstances[id];

    if (video.readyState >= HTMLMediaElement.HAVE_METADATA)
        video.currentTime = time;
},

これは、メディアの準備状態を返すプロパティで、値がHTMLMediaElement.HAVE_METADATA(=1)以上であればシーク可能とみなすことが出来ます。

参考:HTMLMediaElement: readyState プロパティ – Web API | MDN

シークはcurrentTimeプロパティに値を指定することで行えます。

参考:HTMLMediaElement: currentTime プロパティ – Web API | MDN

どの操作インタフェースを使うかは、CustomVideoPlayerクラスの初期化処理でプラットフォーム環境に応じて分岐しています。

    private void Awake()
    {
        // WebGLビルド時はWebGLVideoControlを使用
        // それ以外の環境ではVideoPlayerControlを使用
#if !UNITY_EDITOR && UNITY_WEBGL
        _videoControl = new WebGLVideoControl(_url, _targetRenderTexture, _sRGBToLinearShader);
#else
        _videoControl = new VideoPlayerControl(gameObject, _url, _targetRenderTexture);
#endif
    }

さいごに

本記事では、Unity標準のVideoPlayerの内部実装を参考に、WebGL環境における動画再生の独自実装ができることを示しました。

リニアワークフローの場合は受け取った画像データをそのままレンダリングすると、色空間の不一致で色味がおかしくなるため、シェーダーによる変換が必要です。

Unity標準のVideoPlayerでは、この色空間の変換にも対応しているので、特別な事情が無い限りこれで事足りる場面も多いかもしれません。

関連記事

参考サイト

スポンサーリンク