嗨,大家好,我是新發。
昨天有同學私信我問我如何在Unity
中實現錄屏的功能,
作為一個熱心的博主,我今天就來實現這個功能吧,希望可以幫到這位同學~
首先思考一下,要實現錄屏功能,我們需要先思考這兩個問題:
1 如何獲取螢幕影象資訊?
2 這些螢幕影象如何取樣並儲存為可播放格式的檔案呢?
先回答第一個問題,如何獲取螢幕影象資訊?
最好先了解一下Unity
的渲染流程,我這裡簡單囉嗦幾句。
我們遊戲畫面的最終的呈現是由CPU
與GPU
相互配合運算產生的效果,這個過程是一個流水線的模式,也稱之為渲染流水線
,我們可將其分為三個階段:應用程式階段、幾何階段、光柵化階段,畫成圖是這樣子:
在最後一步螢幕影象
這裡,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);
}
所以,這個source
或dest
就是我們要拿的螢幕影象了。
不過,我在測試Graphics.Blit
介面的時候,發現了一個祕密,當我通過RenderTexture.GetTemporary
獲取臨時紋理,並Bilt
一個null
給它,即:
RenderTexture rt = RenderTexture.GetTemporary(with, height, 0);
Graphics.Blit(null, rt);
這樣子拿到的rt
就是螢幕後備緩衝區的影象了,也就是我們螢幕顯示的影象了。
要把影象幀儲存為可播放的格式的檔案,我們比較常見的就是視訊格式或者GIF
格式。如果是儲存為視訊格式(比如.avi
),需要用到OpenCV
庫:OpenCVSharp
,可以在GitHub
上找到,
不過,因為我之前對GIF
有做過一點點研究,實現起來比較簡單,所以我決定儲存為GIF
格式,不使用OpenCV
。
在做錄屏功能之前,我們得先有螢幕內容,嘛,那就找個妹子模型吧。
關於找資源,我之前寫了一篇文章:《Unity遊戲開發——新發教你做遊戲(二):60個Unity免費資源獲取網站》,有了這些找資源的渠道,相信足夠你平時學習使用了~
我找了下面這個妹子模型,喜歡的可以自行從Asset Store
上免費下載:傳送門。
不過光有模型不會動,這不行,我們要讓她動起來~
給人物模型加動畫,我給大家推薦一個寶藏網站Mixamo
:https://www.mixamo.com/
Mixamo
是Adobe
旗下的一個產品,可以上傳靜態人形模型檔案,在網站上繫結人形模板動畫,並可以下載繫結動畫後的模型檔案,可以直接在Unity
中使用。
我們點選UPLOAD CHARACTER
按鈕,上傳我們的妹子模型FBX
檔案。
把.fbx
檔案拖到如下框框中,
上傳成功後,選擇你喜歡的動作,
效果如下,這樣子看好像有點嚇人,
我們點選DOWNLOAD
按鈕,
格式選擇FBX
,Skin
選擇With Skin
,點選DOWNLOAD
下載,
把FBX
檔案匯入到Unity
工程中,可以看到裡面有一個動畫檔案,
把動畫檔案拖給我們的模型妹子,
生成的動畫狀態機如下:
此時我們播放動畫會發現這個跳舞動畫不會迴圈播放,我們需要設定一下回圈播放,選中動畫檔案,
點選Edit
按鈕,
勾選Loop Time
,點選Apply
按鈕,
重新播放動畫,可以迴圈播放了,如下:
可以多試幾個舞蹈動作,
到了寫程式碼的環節了,不過寫程式碼之前,我們先設計一下程式模組,如下:
我們先從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);
}
先定義下針對列快取的成員,最關鍵的就是佇列變數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[]
,畫成圖是這樣子:
生成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]);
}
補充一下其他二進位制格式的頭部標識:
檔案格式 | 頭部位元組 | 尾部位元組 |
---|---|---|
JPG | FF D8 | FF D9 |
PNG | 89 50 4E 47 0D 0A 1A 0A | |
GIF 89a | 47 49 46 38 39 61 | |
GIF 87a | 47 49 46 37 39 61 | |
TGA未壓縮 | 00 00 02 00 00 | |
TGA壓縮 | 00 00 10 00 00 | |
BMP | 42 4D | |
PCX | 0A | |
TIFF | 4D 4D 或 49 49 | |
ICO | 00 00 01 00 01 00 20 20 | |
CUR | 00 00 02 00 01 00 20 20 | |
IFF | 46 4F 52 4D | |
ANI | 52 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;
}
製作下簡單的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 分享之類的
});
}
});
}
}
給主攝像機新增Recorder
元件,設定好引數,
給MainPanel
新增RecordCtrler
元件,設定好引數,
最後執行Unity
,效果如下:
看下生成GIF
耗時488
毫秒,
生成的GIF
檔案儲存在GifOutput
資料夾中:
如下:
本工程原始碼已上傳到CodeChina
,感興趣的同學可自行下載學習。
地址:https://codechina.csdn.net/linxinfa/ScreenRecordToGif
注:我使用的Unity
版本:Unity 2020.1.14f1c1 (64-bit)
。
好了,今天就寫到這裡吧,如果有什麼技術上的問題,歡迎留言或私信,我是新發,拜拜~