韓版《傳奇 2》源碼分析與 Unity 重製(三):客戶端渲染


3樓貓 發佈時間:2023-04-03 21:55:51 作者:Sou1gh0st Language

專題介紹

該專題將會分析 LOMCN 基於韓版傳奇 2,使用 .NET 重寫的傳奇源碼(服務端 + 客戶端),分析數據交互、狀態管理和客戶端渲染等技術,此外筆者還會分享將客戶端部分移植到 Unity 和服務端用現代編程語言重寫的全過程。

系列文章

韓版傳奇 2 源碼分析與 Unity 重製(一)服務端 TCP 狀態管理
韓版傳奇 2 源碼分析與 Unity 重製(二)客戶端啟動與交互流程

概覽

在這一篇文章中,我們將開始分析傳奇客戶端的 2D 渲染管線,瞭解傳奇早期的美術資產設計與渲染流程。

底層圖形接口

可能傳奇在設計之初沒有考慮到跨平臺用途,或是為了做到極致性能,開發者直接使用了 Direct 3D 的圖形接口進行 2D 渲染管線的開發,在客戶端的 Main Form 被加載的時候會進行 D3D 的初始化,開發者封裝了 DXManager 來管理 RenderState:
// CMain.cs private void CMain_Load(object sender, EventArgs e) { this.Text = GameLanguage.GameName; try { ClientSize = new Size(Settings.ScreenWidth, Settings.ScreenHeight); DXManager.Create(); SoundManager.Create(); CenterToScreen(); } catch (Exception ex) { SaveError(ex.ToString()); } } ​ // DXManager.cs using SlimDX; using SlimDX.Direct3D9; using Blend = SlimDX.Direct3D9.Blend; ​ public static void Create() { Parameters = new PresentParameters { BackBufferFormat = Format.X8R8G8B8, PresentFlags = PresentFlags.LockableBackBuffer, BackBufferWidth = Settings.ScreenWidth, BackBufferHeight = Settings.ScreenHeight, SwapEffect = SwapEffect.Discard, PresentationInterval = Settings.FPSCap ? PresentInterval.One : PresentInterval.Immediate, Windowed = !Settings.FullScreen, }; ​ Direct3D d3d = new Direct3D(); ​ Capabilities devCaps = d3d.GetDeviceCaps(0, DeviceType.Hardware); DeviceType devType = DeviceType.Reference; CreateFlags devFlags = CreateFlags.HardwareVertexProcessing; ​ if (devCaps.VertexShaderVersion.Major >= 2 && devCaps.PixelShaderVersion.Major >= 2) devType = DeviceType.Hardware; ​ if ((devCaps.DeviceCaps & DeviceCaps.HWTransformAndLight) != 0) devFlags = CreateFlags.HardwareVertexProcessing; ​ if ((devCaps.DeviceCaps & DeviceCaps.PureDevice) != 0) devFlags |= CreateFlags.PureDevice; ​ Device = new Device(d3d, d3d.Adapters.DefaultAdapter.Adapter, devType, Program.Form.Handle, devFlags, Parameters); ​ Device.SetDialogBoxMode(true); ​ LoadTextures(); LoadPixelsShaders(); }
這裡有幾個點需要注意一下:
  1. 傳奇採用了 D3D9,從現在來看這是一個非常古老的、來自 2002 年的 Direct X 版本,目前的 GPU 調試工具似乎都已經不再支持,調試起來比較困難;
  2. 傳奇採用了 SlimDX 來實現在 .NET 環境中直接訪問 Direct X 接口

渲染循環

在上一篇文章中我們提到,客戶端的事件循環會逐幀調用 UpdateEnviroment 和 RenderEnvironment,其中前者用於處理網絡數據包和更新狀態,後者用於渲染,在這裡我們重點來看 RenderEnvironment 的實現。
這裡首先做了清屏,然後開啟 Scene,這裡的 Scene 用於分組和管理每一幀的 Draw Call,然後開啟透明度混合,設置 Render Target,提交當前 Scene 的 Draw Call 然後通過 EndScene 提交 Command Buffer,最後通過 Present 進行 swap 上屏:
// CMain.cs private static void RenderEnvironment() { try { if (DXManager.DeviceLost) { DXManager.AttemptReset(); Thread.Sleep(1); return; } ​ DXManager.Device.Clear(ClearFlags.Target, Color.CornflowerBlue, 0, 0); DXManager.Device.BeginScene(); DXManager.Sprite.Begin(SpriteFlags.AlphaBlend); DXManager.SetSurface(DXManager.MainSurface); ​ if (MirScene.ActiveScene != null) MirScene.ActiveScene.Draw(); ​ DXManager.Sprite.End(); DXManager.Device.EndScene(); DXManager.Device.Present(); } catch (Direct3D9Exception ex) { DXManager.DeviceLost = true; } catch (Exception ex) { SaveError(ex.ToString()); ​ DXManager.AttemptRecovery(); } }
這裡的 RenderTarget 設置方法 SetSurface 是 DXManager 封裝出來的,我們來看一下具體的實現:
// DXManager.cs public static void SetSurface(Surface surface) { if (CurrentSurface == surface) return; ​ Sprite.Flush(); CurrentSurface = surface; Device.SetRenderTarget(0, surface); }
這裡實際上就是將 RT0 綁定到目標紋理,傳奇沒有采用 multi target 模式,因此默認情況下片段著色器的輸出將直接通過 RT0 綁定的 Attachment 進行輸出。再回去看一眼渲染循環中的代碼會發現 RT0 會被綁定到 DXManager.MainSurface,而 MainSurface 實際上就是 BackBuffer,因此渲染內容會在 swap 後直接上屏:
// DXManager.cs private static unsafe void LoadTextures() { Sprite = new Sprite(Device); TextSprite = new Sprite(Device); Line = new Line(Device) { Width = 1F }; ​ MainSurface = Device.GetBackBuffer(0, 0); CurrentSurface = MainSurface; Device.SetRenderTarget(0, MainSurface); ​ // ... }

渲染管線

總的來說傳奇的渲染管線設計的還是比較簡單的,從大流程上而言主要分為兩步渲染:
  1. 分層渲染遊戲場景
  2. 渲染遊戲的 GUI
// GameScene.cs protected internal override void DrawControl() { // Draw Game Scene if (MapControl != null && !MapControl.IsDisposed) MapControl.DrawControl(); ​ // Draw GUI base.DrawControl(); // ... }
在這裡我們首先分析遊戲場景的渲染,它是通過調用 MapControl.DrawControl 實現的,通過下面的代碼可以發現,傳奇設計了幀緩存 ControlTexture,在不需要播放動畫和移動物體的情況下可以複用之前的渲染結果:
// GameScene.cs - MapControl protected internal override void DrawControl() { if (!DrawControlTexture) return; ​ if (!TextureValid) CreateTexture(); ​ if (ControlTexture == null || ControlTexture.Disposed) return; ​ float oldOpacity = DXManager.Opacity; ​ DXManager.SetOpacity(Opacity); DXManager.Sprite.Draw(ControlTexture, new Rectangle(0, 0, Settings.ScreenWidth, Settings.ScreenHeight), Vector3.Zero, Vector3.Zero, Color.White); DXManager.SetOpacity(oldOpacity); ​ CleanTime = CMain.Time + Settings.CleanDelay; }
這裡的 SetOpacity 實際上是調整 BlendMode,由於 MapControl 在初始化時採用的 Opacity 默認值為 1.0,因此會將 BlendMode 設置為 SourceAlpha InverseSourceAlpha,即通過 dst.rgb = src.rgb * src.a + dst.rgb + (1 - src.a) 進行顏色混合:
public static void SetOpacity(float opacity) { if (Opacity == opacity) return; ​ Sprite.Flush(); Device.SetRenderState(RenderState.AlphaBlendEnable, true); if (opacity >= 1 || opacity < 0) { Device.SetRenderState(RenderState.SourceBlend, SlimDX.Direct3D9.Blend.SourceAlpha); Device.SetRenderState(RenderState.DestinationBlend, SlimDX.Direct3D9.Blend.InverseSourceAlpha); Device.SetRenderState(RenderState.SourceBlendAlpha, Blend.One); Device.SetRenderState(RenderState.BlendFactor, Color.FromArgb(255, 255, 255, 255).ToArgb()); } else { Device.SetRenderState(RenderState.SourceBlend, Blend.BlendFactor); Device.SetRenderState(RenderState.DestinationBlend, Blend.InverseBlendFactor); Device.SetRenderState(RenderState.SourceBlendAlpha, Blend.SourceAlpha); Device.SetRenderState(RenderState.BlendFactor, Color.FromArgb((byte)(255 * opacity), (byte)(255 * opacity), (byte)(255 * opacity), (byte)(255 * opacity)).ToArgb()); } Opacity = opacity; Sprite.Flush(); }
經過上面的分析我們知道,遊戲場景的渲染主要分為兩步:
  1. 判斷 TextureValid 為 false 時,通過 CreateTexture 離屏渲染到 ControlTexture;
  2. 將 ControlTexture 繪製到 MainSurface,採用的透明度混合方式為 SourceAlpha InverseSourceAlpha。

遊戲場景渲染

遊戲內場景渲染包括以下幾個步驟:
  1. 通過 DrawBackground 繪製背景,大部分場景是沒有背景的;
  2. 通過 DrawFloor 繪製地圖背景;
  3. 通過 DrawObjects 繪製地圖前景和遊戲內對象;
  4. 通過 DrawLights 繪製光源;
  5. 通過 DrawName 繪製 hover 到掉落物、怪物和玩家的懸浮文字。
這裡的核心步驟是 DrawFloor 繪製地圖背景,DrawObjects 繪製地圖前景和遊戲內對象,這三個圖層的可視化效果如下:
最終的視覺效果如下:

Tilemap 地圖繪製

Direct 3D9 的 Sprite 的 UV 座標系原點為左上角,向右為 x 軸,向下為 y 軸,傳奇採用了 Tilemap 地圖,按照從左到右、從上到下的順序進行繪製,每個 Tile 的固定大小為 48 x 32 px,首先根據分辨率計算 x 和 y 軸方向所需的 Tile 數量,這裡的 OffsetX 和 OffsetY 分別是半屏下的 Tile 數量,為了避免出現黑邊加上 4 作為 x, y 方向的 half extent:
public const int CellWidth = 48; public const int CellHeight = 32; ​ OffSetX = Settings.ScreenWidth / 2 / CellWidth; OffSetY = Settings.ScreenHeight / 2 / CellHeight - 1; ViewRangeX = OffSetX + 4; ViewRangeY = OffSetY + 4;
下面我們來看 DrawFloor 的實現,在這裡一共有三個子圖層的繪製,我們先簡化代碼只看 Back 層的繪製。Tilemap 的繪製採用了雙層循環,以用戶當前座標為中心,向左減去 ViewRangeY 得到 minY,向右加上 ViewRangeY 得到 maxY,在內層循環中則是從 minX 迭代到 maxX,因此貼 Tile 的順序為自上而下、從左到右:
private void DrawFloor() { if (DXManager.FloorTexture == null || DXManager.FloorTexture.Disposed) { DXManager.FloorTexture = new Texture(DXManager.Device, Settings.ScreenWidth, Settings.ScreenHeight, 1, Usage.RenderTarget, Format.A8R8G8B8, Pool.Default); DXManager.FloorSurface = DXManager.FloorTexture.GetSurfaceLevel(0); } ​ Surface oldSurface = DXManager.CurrentSurface; DXManager.SetSurface(DXManager.FloorSurface); DXManager.Device.Clear(ClearFlags.Target, Color.Empty, 0, 0); //Color.Black ​ int index; int drawY, drawX; ​ for (int y = User.Movement.Y - ViewRangeY; y <= User.Movement.Y + ViewRangeY; y++) { if (y <= 0 || y % 2 == 1) continue; if (y >= Height) break; ​ drawY = (y - User.Movement.Y + OffSetY) * CellHeight + User.OffSetMove.Y; //Moving OffSet ​ for (int x = User.Movement.X - ViewRangeX; x <= User.Movement.X + ViewRangeX; x++) { if (x <= 0 || x % 2 == 1) continue; if (x >= Width) break; drawX = (x - User.Movement.X + OffSetX) * CellWidth - OffSetX + User.OffSetMove.X; //Moving OffSet if ((M2CellInfo[x, y].BackImage == 0) || (M2CellInfo[x, y].BackIndex == -1)) continue; index = (M2CellInfo[x, y].BackImage & 0x1FFFFFFF) - 1; Libraries.MapLibs[M2CellInfo[x, y].BackIndex].Draw(index, drawX, drawY); } } ​ // ... ​ DXManager.SetSurface(oldSurface); ​ FloorValid = true; }
需要注意的是循環中的 x, y 實際上是 Tile 座標,在繪製時我們需要將其換算為屏幕座標,換算的過程很簡單,只需要將 Tile 座標乘以 CellWH 即可。通過 Tile 座標可以在 Library 中查詢 Cell 信息,獲取到圖片的 Index 並完成繪製,這裡有一個細節是實際的 Tile 分辨率為 96 x 64,是 CellWH 48 x 32 的 2 倍,因此在貼 Tile 時在 x, y 方向都會產生 50% 的覆蓋,這應該是為了避免出現裂縫:

遮擋關係處理

通過查看 DrawObjects 的代碼我們可以發現在邏輯順序上是先繪製的地圖元素,後繪製的人物,但人物卻可以被地圖的元素擋住:
private void DrawObjects() { // ... for (int y = User.Movement.Y - ViewRangeY; y <= User.Movement.Y + ViewRangeY + 25; y++) { if (y <= 0) continue; if (y >= Height) break; int drawY = (y - User.Movement.Y + OffSetY + 1) * CellHeight + User.OffSetMove.Y; ​ for (int x = User.Movement.X - ViewRangeX; x <= User.Movement.X + ViewRangeX; x++) { if (x < 0) continue; if (x >= Width) break; int drawX = (x - User.Movement.X + OffSetX) * CellWidth - OffSetX + User.OffSetMove.X; // Draw Tilemap if (blend) { if ((fileIndex > 99) & (fileIndex < 199)) Libraries.MapLibs[fileIndex].DrawBlend(index, new Point(drawX, drawY - (3 * CellHeight)), Color.White, true); else Libraries.MapLibs[fileIndex].DrawBlend(index, new Point(drawX, drawY - s.Height), Color.White, (index >= 2723 && index <= 2732)); } else Libraries.MapLibs[fileIndex].Draw(index, drawX, drawY - s.Height); } ​ // Draw Objects for (int x = User.Movement.X - ViewRangeX; x <= User.Movement.X + ViewRangeX; x++) { if (x < 0) continue; if (x >= Width) break; M2CellInfo[x, y].DrawObjects(); } } // ... }
按照先繪製 Tile,再繪製 Objects 的順序,理論上 Object 會擋住 Tile 從而產生錯誤的視覺效果,但實際上 Player 可以正確被景物遮擋:
默認配置下,同樣的 z-order 後繪製的 Sprite 會遮擋前面繪製的 Sprite,這裡看起來非常反直覺的現象實際上是美術資產上做的 trick,在角色繪製的時候有一個 Y 方向向上的偏移,使得角色實際上被繪製的被同一行的 Tile 要更早:
但這還不是全部仔細觀察傳奇的地圖可以發現,當角色完全被景物擋住時,會有一個半透明的身影被渲染出來以避免玩家看不到自己的角色(仔細看樹後的角色):
這是怎麼實現的呢?實際上在 DrawObjects 對 Tile 和 Objects 的繪製結束後,會開啟 AlphaBlend,將 SourceFactor 指定為 0.4 對玩家的角色進行一次透明度混合:
private void DrawObjects() { // draw Tile / Objects // ... ​ // draw player again with alpha blend factor 0.4 DXManager.Sprite.Flush(); float oldOpacity = DXManager.Opacity; DXManager.SetOpacity(0.4F); ​ MapObject.User.DrawMount(); ​ MapObject.User.DrawBody(); ​ if ((MapObject.User.Direction == MirDirection.Up) || (MapObject.User.Direction == MirDirection.UpLeft) || (MapObject.User.Direction == MirDirection.UpRight) || (MapObject.User.Direction == MirDirection.Right) || (MapObject.User.Direction == MirDirection.Left)) { MapObject.User.DrawHead(); MapObject.User.DrawWings(); } else { MapObject.User.DrawWings(); MapObject.User.DrawHead(); } ​ DXManager.SetOpacity(oldOpacity); }

技能與特效的透明度混合

在上面的渲染中,地圖和角色的透明度混合大都採用了 SourceAlpha InverseSourceAlpha 的常規混合方式,而對於場景中的技能和特效,為了讓他們變得更亮,需要採用 Additive 的混合方式,即 SourceAlpha One:
Device.SetRenderState(RenderState.AlphaBlendEnable, true); Device.SetRenderState(RenderState.SourceBlend, Blend.SourceAlpha); Device.SetRenderState(RenderState.DestinationBlend, Blend.One);
注意這裡的 DestinationBlend 一定要設置為 One,不可以為 InverseSourceAlpha,這是因為資產中的特效的 Alpha 通道在很大的區以內都接近 1,如果採用 InverseSourceAlpha 會在地圖上產生黑色背景:
1 / 3
而採用正確的 Additive 方式進行混合,則可以得到明亮的特效:

下一步

在這篇文章中,我們著重分析了傳奇客戶端基於 Direct 3D9 構建的 2D 渲染管線,以及渲染過程中的一些細節。在接下來的文章中我們將繼續深入客戶端渲染,分析裝備、技能和動畫的渲染方式。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com