教你使用Unity實現錄屏生成GIF的功能,錄個妹子跳舞的GIF吧

2021-06-20 13:00:01

在這裡插入圖片描述
在這裡插入圖片描述
在這裡插入圖片描述

一、前言

嗨,大家好,我是新發。
昨天有同學私信我問我如何在Unity中實現錄屏的功能,
在這裡插入圖片描述

作為一個熱心的博主,我今天就來實現這個功能吧,希望可以幫到這位同學~

二、思考與解決方案

1、思考

首先思考一下,要實現錄屏功能,我們需要先思考這兩個問題:
1 如何獲取螢幕影象資訊?
2 這些螢幕影象如何取樣並儲存為可播放格式的檔案呢?

2、解決方案

2.1、問題一的解決方案

先回答第一個問題,如何獲取螢幕影象資訊?
最好先了解一下Unity的渲染流程,我這裡簡單囉嗦幾句。
我們遊戲畫面的最終的呈現是由CPUGPU相互配合運算產生的效果,這個過程是一個流水線的模式,也稱之為渲染流水線,我們可將其分為三個階段:應用程式階段、幾何階段、光柵化階段,畫成圖是這樣子:
在這裡插入圖片描述
在最後一步螢幕影象這裡,Unity提供了後處理回撥介面給開發者:OnRenderImage回撥
關於後處理我之前寫了一篇演示的文章,感興趣的同學可以看看:https://blog.csdn.net/linxinfa/article/details/108283232

OnRenderImage介面如下:

// 注:OnRenderImage是Mono的函數,需要放在MonoBehaviour的指令碼中
void OnRenderImage(RenderTexture source, RenderTexture dest)

Unity會把當前渲染的影象儲存在source紋理中,我們可以使用Graphics.Blit和特定的Shader對當前影象進行處理,再把dest顯示在螢幕上。
Graphics.Blit函數模型如下,它的功能就是使用著色器將源紋理複製到目標渲染紋理上。

// Graphics.cs
public static void Blit (Texture source, RenderTexture dest);
public static void Blit (Texture source, RenderTexture dest, Material mat, int pass= -1);
public static void Blit (Texture source, Material mat, int pass= -1);
public static void Blit (Texture source, RenderTexture dest, Vector2 scale, Vector2 offset);

如果不做任何後處理,其實就是直接把source複製到dest上,如下:

void OnRenderImage(RenderTexture source, RenderTexture dest)
{
	Graphics.Blit(source, dest);
}

所以,這個sourcedest就是我們要拿的螢幕影象了。
不過,我在測試Graphics.Blit介面的時候,發現了一個祕密,當我通過RenderTexture.GetTemporary獲取臨時紋理,並Bilt一個null給它,即:

RenderTexture rt = RenderTexture.GetTemporary(with, height, 0);
Graphics.Blit(null, rt);

這樣子拿到的rt就是螢幕後備緩衝區的影象了,也就是我們螢幕顯示的影象了。
在這裡插入圖片描述

2.2、問題二的解決方案

要把影象幀儲存為可播放的格式的檔案,我們比較常見的就是視訊格式或者GIF格式。如果是儲存為視訊格式(比如.avi),需要用到OpenCV庫:OpenCVSharp,可以在GitHub上找到,
在這裡插入圖片描述
不過,因為我之前對GIF有做過一點點研究,實現起來比較簡單,所以我決定儲存為GIF格式,不使用OpenCV

三、擼起袖子開幹

1、找個妹子

在做錄屏功能之前,我們得先有螢幕內容,嘛,那就找個妹子模型吧。
關於找資源,我之前寫了一篇文章:《Unity遊戲開發——新發教你做遊戲(二):60個Unity免費資源獲取網站》,有了這些找資源的渠道,相信足夠你平時學習使用了~
我找了下面這個妹子模型,喜歡的可以自行從Asset Store上免費下載:傳送門
在這裡插入圖片描述
不過光有模型不會動,這不行,我們要讓她動起來~

2、給妹子加動畫

給人物模型加動畫,我給大家推薦一個寶藏網站Mixamohttps://www.mixamo.com/
MixamoAdobe旗下的一個產品,可以上傳靜態人形模型檔案,在網站上繫結人形模板動畫,並可以下載繫結動畫後的模型檔案,可以直接在Unity中使用。

我們點選UPLOAD CHARACTER按鈕,上傳我們的妹子模型FBX檔案。
在這裡插入圖片描述
.fbx檔案拖到如下框框中,
在這裡插入圖片描述
在這裡插入圖片描述
上傳成功後,選擇你喜歡的動作,
在這裡插入圖片描述
效果如下,這樣子看好像有點嚇人,

在這裡插入圖片描述
我們點選DOWNLOAD按鈕,
在這裡插入圖片描述
格式選擇FBXSkin選擇With Skin,點選DOWNLOAD下載,
在這裡插入圖片描述

FBX檔案匯入到Unity工程中,可以看到裡面有一個動畫檔案,
在這裡插入圖片描述
把動畫檔案拖給我們的模型妹子,
在這裡插入圖片描述
生成的動畫狀態機如下:
在這裡插入圖片描述
此時我們播放動畫會發現這個跳舞動畫不會迴圈播放,我們需要設定一下回圈播放,選中動畫檔案,
在這裡插入圖片描述
點選Edit按鈕,
在這裡插入圖片描述
勾選Loop Time,點選Apply按鈕,
在這裡插入圖片描述
重新播放動畫,可以迴圈播放了,如下:
在這裡插入圖片描述
可以多試幾個舞蹈動作,
在這裡插入圖片描述
在這裡插入圖片描述

3、程式設計

到了寫程式碼的環節了,不過寫程式碼之前,我們先設計一下程式模組,如下:
在這裡插入圖片描述

4、螢幕影象取樣:Recorder.cs

我們先從Recorder模組寫起,先定義我們需要的成員變數,

// Recorder.cs

public class Recorder : MonoBehaviour
{
   internal enum RecordingState
    {
        OnHold = 0,
        Recording = 1,
    }

    // 錄製狀態
    internal RecordingState CurrentState;
    
    /// <summary>
    /// 每秒取樣次數
    /// </summary>
    public int captureFrameRate = 20;

    /// <summary>
    /// 最幀數量
    /// </summary>
    public int maxCapturedFrames = 1000;

    /// <summary>
    /// 每秒播放幀數
    /// </summary>
    public int playbackFrameRate = 20;

    /// <summary>
    /// 生成的GIF是否迴圈播放
    /// </summary>
    public bool loopPlayback = true;

    /// <summary>
    /// 主攝像機
    /// </summary>
    public Camera capturedCamera;


    /// <summary>
    ///  計時器
    /// </summary>
    private float _elapsedTime;

    /// <summary>
    /// 生成的GIF尺寸與原圖的尺寸比例
    /// </summary>
    private static double RESIZE_RATIO = 0.5;

    /// <summary>
    /// 生成的GIF ID
    /// </summary>
    private string _captureId;
    /// <summary>
    /// GIF儲存路徑
    /// </summary>
    private string _resultFilePath;

    /// <summary>
    /// 生成的GIF儲存資料夾
    /// </summary>
    private const string GeneratedContentFolderName = "GifOutput";

    /// <summary>
    /// 錄製協程
    /// </summary>
    private Coroutine _recordCoroutine;
	// ...
}

用協程實現螢幕影象取樣,

/// <summary>
/// 執行錄製
/// </summary>
/// <returns></returns>
IEnumerator RunRecord()
{
    while (true)
    {
        yield return new WaitForEndOfFrame();
        _elapsedTime += Time.unscaledDeltaTime;
        if (_elapsedTime >= 1.0f / captureFrameRate)
        {
            _elapsedTime = 0;
            RenderTexture rt = GetTemporaryRenderTexture();
            Graphics.Blit(null, rt);
            // TODO 將rt存到幀佇列中
            
        }
    }
}

// 獲取臨時渲染紋理
private RenderTexture GetTemporaryRenderTexture()
{
    var rt = RenderTexture.GetTemporary(capturedCamera.pixelWidth, capturedCamera.pixelHeight, 0, RenderTextureFormat.ARGB32);
    rt.wrapMode = TextureWrapMode.Clamp;
    rt.filterMode = FilterMode.Bilinear;
    rt.anisoLevel = 0;
    return rt;
}

上面RenderTexture.GetTemporary是獲取臨時渲染紋理,因為每次使用的紋理尺寸是一樣的,我們不需要每次重複構建紋理物件,可以利用Unity提供給我們的臨時渲染紋理來重複使用,這樣可以提升效能。
給這張臨時紋理Blit一個null,即Graphics.Blit(null, rt);,此時rt就是螢幕影象了。

開始錄製和停止錄製就是開啟協程和停止協程,

/// <summary>
/// 開始錄製
/// </summary>
public void StartRecord()
{
    _recordCoroutine = StartCoroutine(RunRecord());
}

/// <summary>
/// 停止錄製
/// </summary>
public void StopRecord()
{
    StopCoroutine(_recordCoroutine);
}

5、幀佇列快取:StoreWorker.cs

先定義下針對列快取的成員,最關鍵的就是佇列變數StoredFrames

/// <summary>
/// 幀佇列快取
/// </summary>
public sealed class StoreWorker
{
	// 幀佇列
    public FixedSizedQueue<GifFrame> StoredFrames { get; private set; }

    internal static StoreWorker Instance
    {
        get { return _instance ?? (_instance = new StoreWorker()); }
    }

    private static StoreWorker _instance;
}

實現塞入幀資料的介面,如下:

/// <summary>
/// 快取幀資料到佇列中
/// </summary>
/// <param name="renderTexture"></param>
/// <param name="resizeRatio"></param>
internal void StoreFrame(RenderTexture renderTexture, double resizeRatio)
{
    var newWidth = Convert.ToInt32(renderTexture.width * resizeRatio);
    var newHeight = Convert.ToInt32(renderTexture.height * resizeRatio);
    renderTexture.filterMode = FilterMode.Bilinear;

    var resizedRenderTexture = RenderTexture.GetTemporary(newWidth, newHeight);
    resizedRenderTexture.filterMode = FilterMode.Bilinear;

    RenderTexture.active = resizedRenderTexture;
    Graphics.Blit(renderTexture, resizedRenderTexture);

    // 轉化為Texture2D
    var resizedTexture2D =
        new Texture2D(newWidth, newHeight, TextureFormat.RGBA32, false)
        {
            hideFlags = HideFlags.HideAndDontSave,
            wrapMode = TextureWrapMode.Clamp,
            filterMode = FilterMode.Bilinear,
            anisoLevel = 0
        };

    resizedTexture2D.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
    resizedTexture2D.Apply();
    RenderTexture.active = null;

    var frame = new GifFrame
    {
        Width = resizedTexture2D.width,
        Height = resizedTexture2D.height,
        Data = resizedTexture2D.GetPixels32()
    };

    resizedRenderTexture.Release();
    Object.Destroy(resizedTexture2D);

    StoredFrames.Enqueue(frame);
}

其中,GifFrame為幀資料結構,如下:

public class GifFrame
{
	public int Width;
	public int Height;
	public Color32[] Data;
}

我們要把取樣的影象RenderTexture轉成Color32[],上面用到的方法是先把取樣的RenderTexture賦值給RenderTexture.active,然後構建Texture2D物件,通過ReadPixels方法讀取畫素,最後再通過GetPixels32方法得到Color32[],畫成圖是這樣子:
在這裡插入圖片描述

6、生成GIF:GeneratorWorker.cs

生成GIF需要一些運算時間,為了不卡住主執行緒,我們使用Thread執行緒來處理。

internal sealed class GeneratorWorker
{
	private readonly Thread _thread;
	
	internal GeneratorWorker(...)
	{
		// ...
		_thread = new Thread(Run) { Priority = priority };
	}
    
	internal void Start()
	{
		// ...
         _thread.Start();
	}

	private void Run()
	{
		// TODO 開始執行
	}
}

核心的運算模組是GifEncoder,我們通過它來編碼生成GIF檔案,

// ...
private GifEncoder _encoder;

private void Run()
{
    var startTimestamp = DateTime.Now;
    _encoder.Start(_filePath);
    _encoder.BuildPalette(ref _capturedFrames);
    for (int i = 0; i < _capturedFrames.Count(); i++)
    {
        _encoder.AddFrame(_capturedFrames.ElementAt(i));
    }
    _encoder.Finish();
    Debug.Log("GIF生成完畢,耗時: " + (DateTime.Now - startTimestamp).Milliseconds + " 毫秒");
    _onFileSaved?.Invoke();
}

這個GIF編碼器GifEncoder就是按照GIF的編碼格式進行寫入即可。
比如,GIF檔案的頭部標識(header)為GIF89a
在這裡插入圖片描述
所以要給頭部寫入對應的位元組:

WriteString("GIF89a"); 

protected void WriteString(String s)
{
    char[] chars = s.ToCharArray();

    for (int i = 0; i < chars.Length; i++)
        m_FileStream.WriteByte((byte)chars[i]);
}

補充一下其他二進位制格式的頭部標識:

檔案格式頭部位元組尾部位元組
JPGFF D8FF D9
PNG89 50 4E 47 0D 0A 1A 0A
GIF 89a47 49 46 38 39 61
GIF 87a47 49 46 37 39 61
TGA未壓縮00 00 02 00 00
TGA壓縮00 00 10 00 00
BMP42 4D
PCX0A
TIFF4D 4D 或 49 49
ICO00 00 01 00 01 00 20 20
CUR00 00 02 00 01 00 20 20
IFF46 4F 52 4D
ANI52 49 4646

封裝其他寫入的介面:

protected void WriteGraphicCtrlExt()
{
    m_FileStream.WriteByte(0x21); // Extension introducer
    m_FileStream.WriteByte(0xf9); // GCE label
    m_FileStream.WriteByte(4);    // Data block size

    // Packed fields
    m_FileStream.WriteByte(Convert.ToByte(0 |     // 1:3 reserved
                                          0 |     // 4:6 disposal
                                          0 |     // 7   user input - 0 = none
                                          0));    // 8   transparency flag

    WriteShort(m_FrameDelay); // Delay x 1/100 sec
    m_FileStream.WriteByte(Convert.ToByte(0)); // Transparent color index
    m_FileStream.WriteByte(0); // Block terminator
}

// Writes Image Descriptor.
protected void WriteImageDesc()
{
    m_FileStream.WriteByte(0x2c); // Image separator
    WriteShort(0);                // Image position x,y = 0,0
    WriteShort(0);
    WriteShort(m_Width);          // image size
    WriteShort(m_Height);

    // Packed fields
    if (m_IsFirstFrame)
    {
        m_FileStream.WriteByte(0); // No LCT  - GCT is used for first (or only) frame
    }
    else
    {
        // Specify normal LCT
        m_FileStream.WriteByte(Convert.ToByte(0x80 |           // 1 local color table  1=yes
                                              0 |              // 2 interlace - 0=no
                                              0 |              // 3 sorted - 0=no
                                              0 |              // 4-5 reserved
                                              m_PaletteSize)); // 6-8 size of color table
    }
}

// Writes Logical Screen Descriptor.
protected void WriteLSD()
{
    // Logical screen size
    WriteShort(m_Width);
    WriteShort(m_Height);

    // Packed fields
    m_FileStream.WriteByte(Convert.ToByte(0x80 |           // 1   : global color table flag = 1 (gct used)
                                          0x70 |           // 2-4 : color resolution = 7
                                          0x00 |           // 5   : gct sort flag = 0
                                          m_PaletteSize)); // 6-8 : gct size

    m_FileStream.WriteByte(0); // Background color index
    m_FileStream.WriteByte(0); // Pixel aspect ratio - assume 1:1
}

// Writes Netscape application extension to define repeat count.
protected void WriteNetscapeExt()
{
    m_FileStream.WriteByte(0x21);    // Extension introducer
    m_FileStream.WriteByte(0xff);    // App extension label
    m_FileStream.WriteByte(11);      // Block size
    WriteString("NETSCAPE" + "2.0"); // App id + auth code
    m_FileStream.WriteByte(3);       // Sub-block size
    m_FileStream.WriteByte(1);       // Loop sub-block id
    WriteShort(m_Repeat);            // Loop count (extra iterations, 0=repeat forever)
    m_FileStream.WriteByte(0);       // Block terminator
}

// Write color table.
protected void WritePalette()
{
    m_FileStream.Write(m_ColorTab, 0, m_ColorTab.Length);
    int n = (3 * 256) - m_ColorTab.Length;

    for (int i = 0; i < n; i++)
        m_FileStream.WriteByte(0);
}

// Encodes and writes pixel data.
protected void WritePixels()
{
    LzwEncoder encoder = new LzwEncoder(m_Width, m_Height, m_IndexedPixels, m_ColorDepth);
    encoder.Encode(m_FileStream);
}

// Write 16-bit value to output stream, LSB first.
protected void WriteShort(int value)
{
    m_FileStream.WriteByte(Convert.ToByte(value & 0xff));
    m_FileStream.WriteByte(Convert.ToByte((value >> 8) & 0xff));
}

// Writes string to output stream.
protected void WriteString(String s)
{
    char[] chars = s.ToCharArray();

    for (int i = 0; i < chars.Length; i++)
        m_FileStream.WriteByte((byte)chars[i]);
}

封裝新增幀的方法:

public void AddFrame(GifFrame frame)
{
     if ((frame == null))
         throw new ArgumentNullException("Can't add a null frame to the gif.");

     if (!m_HasStarted)
         throw new InvalidOperationException("Call Start() before adding frames to the gif.");

     // Use first frame's size
     if (!m_IsSizeSet)
         SetSize(frame.Width, frame.Height);

     m_CurrentFrame = frame;
     GetImagePixels();
     AnalyzePixels();

     if (m_IsFirstFrame)
     {
         WriteLSD();
         WritePalette();

         if (m_Repeat >= 0)
             WriteNetscapeExt();
     }

     WriteGraphicCtrlExt();
     WriteImageDesc();

     if (!m_IsFirstFrame)
         WritePalette();

     WritePixels();
     m_IsFirstFrame = false;
 }

再封裝一個結束的方法,把GIF的結束標識0x3b寫入檔案末尾,

 public void Finish()
 {
     if (!m_HasStarted)
         throw new InvalidOperationException("Can't finish a non-started gif.");

     m_HasStarted = false;

     try
     {
         m_FileStream.WriteByte(0x3b); // Gif trailer
         m_FileStream.Flush();

         if (m_ShouldCloseStream)
         {
             m_FileStream.Close();
             m_FileStream.Dispose();
         }

     }
     catch (IOException e)
     {
         throw e;
     }

     // Reset for subsequent use
     m_FileStream = null;
     m_CurrentFrame = null;
     m_Pixels = null;
     m_IndexedPixels = null;
     m_ColorTab = null;
     m_ShouldCloseStream = false;
     m_IsFirstFrame = true;
 }

7、UI互動:RecordCtrler.cs

製作下簡單的UI,如下:
在這裡插入圖片描述
寫下UI互動的指令碼,

using UnityEngine;
using UnityEngine.UI;
using ScreenRecorder;

public class RecordCtrler : MonoBehaviour
{
    public Recorder capture;
    public Button btn;
    public Text txt;
    private bool recording = false;

    void Start()
    {
        btn.onClick.AddListener(() =>
        {
            recording = !recording;
            txt.text = recording ? "停止錄製" : "開始錄製";

            if (recording)
            {
                capture.StartRecord();
            }
            else
            {
                capture.StopRecord();
                // 生成gif
                capture.GenerateGif((gifBytes, gifSavePath) =>
                {
                	Debug.Log("gif生成成功,儲存路徑:" + gifSavePath);
                    // TODO 分享之類的
                });
            }
        });
    }
}

8、執行測試

給主攝像機新增Recorder元件,設定好引數,
在這裡插入圖片描述
MainPanel新增RecordCtrler元件,設定好引數,
在這裡插入圖片描述
最後執行Unity,效果如下:
在這裡插入圖片描述
看下生成GIF耗時488毫秒,
在這裡插入圖片描述
生成的GIF檔案儲存在GifOutput資料夾中:
在這裡插入圖片描述

在這裡插入圖片描述
如下:
在這裡插入圖片描述

四、工程原始碼

本工程原始碼已上傳到CodeChina,感興趣的同學可自行下載學習。
地址:https://codechina.csdn.net/linxinfa/ScreenRecordToGif
注:我使用的Unity版本:Unity 2020.1.14f1c1 (64-bit)

在這裡插入圖片描述
好了,今天就寫到這裡吧,如果有什麼技術上的問題,歡迎留言或私信,我是新發,拜拜~