该专题将会分析 LOMCN 基于韩版传奇 2,使用 .NET 重写的传奇源码(服务端 + 客户端),分析数据交互、状态管理和客户端渲染等技术,此外笔者还会分享将客户端部分移植到 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();
}
传奇采用了 D3D9,从现在来看这是一个非常古老的、来自 2002 年的 Direct X 版本,目前的 GPU 调试工具似乎都已经不再支持,调试起来比较困难;
传奇采用了 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);
// ...
}
总的来说传奇的渲染管线设计的还是比较简单的,从大流程上而言主要分为两步渲染:
分层渲染游戏场景
渲染游戏的 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();
}
经过上面的分析我们知道,游戏场景的渲染主要分为两步:
判断 TextureValid 为 false 时,通过 CreateTexture 离屏渲染到 ControlTexture;
将 ControlTexture 绘制到 MainSurface,采用的透明度混合方式为 SourceAlpha InverseSourceAlpha。
通过 DrawBackground 绘制背景,大部分场景是没有背景的;
通过 DrawFloor 绘制地图背景;
通过 DrawObjects 绘制地图前景和游戏内对象;
通过 DrawLights 绘制光源;
通过 DrawName 绘制 hover 到掉落物、怪物和玩家的悬浮文字。
这里的核心步骤是 DrawFloor 绘制地图背景,DrawObjects 绘制地图前景和游戏内对象,这三个图层的可视化效果如下:
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 方向向上的偏移,而景物绘制的时候这个向上偏移的 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 会在地图上产生黑色背景:
而采用正确的 Additive 方式进行混合,则可以得到明亮的特效:
在这篇文章中,我们着重分析了传奇客户端基于 Direct 3D9 构建的 2D 渲染管线,以及渲染过程中的一些细节。在接下来的文章中我们将继续深入客户端渲染,分析装备、技能和动画的渲染方式。
评论区
共 3 条评论热门最新