编辑
2026-03-27
C#
00

目录

🤔 先聊聊,为什么不用 GDI+?
👩‍💻 先看一下效果
🏗 整体架构,先想清楚再动手
🎯 Sprite 数据模型:别小看这一层
⚡ SpriteBatch:批量渲染的核心逻辑
🔄 游戏循环:Timer 的使用陷阱
🖥 界面设计:深色主题的 WinForms
Sprite 类
SpriteRenderer
📊 实测数据:优化前后的对比
🧩 三个可以直接扩展的方向
💬 最后说一句
SkiaSharp WinForms 游戏开发 性能优化`

🤔 先聊聊,为什么不用 GDI+?

说真的,刚接到这个需求的时候,我第一反应是——WinForms 嘛,Graphics.DrawImage 不就完了?

然后我就被打脸了。

项目里需要同屏渲染 300+ 个 Sprite,每个都有旋转、缩放、透明度变化。用 GDI+ 跑起来,帧率直接掉到个位数。那一刻我盯着任务管理器,CPU 占用 80%,GPU 占用 3%——这反差,看得我心里一紧。

问题很明显:GDI+ 是纯软件光栅化,它根本不走 GPU。而 SkiaSharp 底层是 Google 的 Skia 图形引擎,配合 SKGLControl 可以直接走 OpenGL 硬件加速。同样的 300 个 Sprite,换了渲染后端,帧率从 8fps 飙到 60fps 稳定不掉。

这就是今天这篇文章的起点。


👩‍💻 先看一下效果

image.png


🏗 整体架构,先想清楚再动手

很多人上来就写代码,写着写着发现结构乱了,再重构就很痛苦。我吃过这个亏,所以现在养成了一个习惯——先把模块边界画清楚

这套系统拆成四个核心类:

Sprite → 数据模型,描述"一个精灵是什么" SpriteSheet → 图集管理,解决"纹理从哪来" SpriteBatch → 批量渲染,解决"怎么画得快" SpriteRenderer → 游戏循环,解决"什么时候画"

这四层的关系,有点像餐厅运营:Sprite 是菜单上的每道菜,SpriteSheet 是食材仓库,SpriteBatch 是厨房的出餐流水线,SpriteRenderer 是那个掐着表控制出餐节奏的主厨。

分层之后,每个模块的职责非常单一,改一处不会牵连其他地方。这在后期加功能的时候,省了我大量时间。


🎯 Sprite 数据模型:别小看这一层

Sprite 类看起来只是个数据容器,但里面有几个细节值得展开说。

锚点设计是第一个坑。很多初学者直接用左上角作为旋转中心,结果 Sprite 一旋转就"飞"出去了,因为它是绕着角落转的。正确做法是引入 AnchorX / AnchorY,默认 0.5f 表示以自身中心为旋转轴:

csharp
// 渲染时先把原点移到锚点位置 canvas.Translate(sprite.X, sprite.Y); canvas.RotateDegrees(sprite.Rotation); canvas.Scale(sprite.ScaleX, sprite.ScaleY); // 再把绘制原点偏移回去 canvas.Translate(-sprite.Width * sprite.AnchorX, -sprite.Height * sprite.AnchorY);

变换顺序是平移 → 旋转 → 缩放,这个顺序不能乱。矩阵变换不满足交换律,先缩放再平移和先平移再缩放,结果完全不同。我见过不少人在这里绕晕了,其实记住一句话就够了:变换是从右往左应用的,写代码时按"你想要的最终效果"从外到内写就对了。

增量更新是第二个值得关注的点。Sprite.Update(float deltaSeconds) 里用的是帧间隔时间而不是固定步长:

csharp
public void Update(float deltaSeconds) { if (!IsAnimated) return; X += SpeedX * deltaSeconds; Y += SpeedY * deltaSeconds; }

为什么这么写?因为帧率不是恒定的。如果你用固定步长,60fps 的机器和 30fps 的机器上,Sprite 的移动速度就会差一倍。用 deltaSeconds 做时间归一化,无论帧率高低,Sprite 在现实时间里的位移是一致的。这是游戏开发里的基本常识,但 WinForms 开发者往往不熟悉。


⚡ SpriteBatch:批量渲染的核心逻辑

这是整个系统性能的关键所在,值得多花点篇幅。

SpriteBatch 的工作模式是收集 → 排序 → 统一提交,仿照 XNA/MonoGame 的设计思路。调用方不直接画,而是先 Begin(),然后把所有 Sprite 丢进队列,最后 End() 时统一处理:

csharp
_batch.Begin(); foreach (var s in _sprites) _batch.Draw(s); // 只是入队,不真正绘制 _batch.End(canvas); // 这里才真正执行绘制

End() 里有两层排序优化,这是性能提升的核心:

csharp
var sorted = _queue .OrderBy(s => s.ZOrder) // 第一排序:层级 .ThenBy(s => s.Texture?.GetHashCode() ?? 0) // 第二排序:纹理 .ToList();

第一层按 ZOrder 排序,保证渲染顺序正确,后面的层级覆盖前面的。这个没什么争议。

第二层按纹理哈希排序才是真正的性能优化手段。GPU 渲染时,切换纹理(texture binding)是相对昂贵的操作。如果 100 个 Sprite 用同一张图集,但渲染顺序是 A纹理→B纹理→A纹理→B纹理交替出现,就会产生 100 次纹理切换。而排序之后变成 50次A + 50次B,纹理切换降到 2 次。在 Sprite 数量多的场景下,这个差距非常显著。

当然,这里有个取舍:纹理排序会破坏同一 ZOrder 内的原始顺序。如果你的游戏逻辑严格依赖同层内的绘制顺序,需要在 ZOrder 上再细分,或者放弃纹理排序。实际项目里,大多数情况下同层内的顺序无所谓,可以放心优化。

还有一个容易被忽略的细节——Canvas 状态隔离。每个 Sprite 都有自己的变换矩阵,如果不做隔离,上一个 Sprite 的变换会"污染"下一个:

csharp
// 用 using 确保变换自动还原,哪怕中途抛异常也安全 using var _ = new SKAutoCanvasRestore(canvas); // 在这里做所有变换操作 canvas.Translate(...); canvas.RotateDegrees(...); // 离开 using 块时,canvas 状态自动恢复

SKAutoCanvasRestore 本质上是调用了 canvas.Save()canvas.Restore(),但用 using 包裹后,异常安全性更好,代码也更简洁。这是 SkiaSharp 开发里我最推荐的写法之一。


🔄 游戏循环:Timer 的使用陷阱

SpriteRendererSystem.Windows.Forms.Timer 驱动游戏循环,目标帧率 60fps,对应 Interval = 16ms

这里有个坑我必须提:WinForms Timer 的精度是 15~16ms,不是精确的 1ms。这意味着你设 Interval = 16,实际触发间隔可能是 15ms 也可能是 31ms(漏了一帧)。对于游戏来说这不是大问题,但如果你需要精确计时,应该考虑 System.Timers.Timer 或者 Stopwatch + 独立线程。

另一个坑是跨线程 UI 更新。Timer 的 Tick 事件在 UI 线程上触发,所以直接更新 Label 没问题。但如果你把渲染逻辑放到后台线程,更新 UI 时必须用 BeginInvoke

csharp
private void OnRendererUpdate(float delta) { // 如果不在 UI 线程,marshal 回去 if (InvokeRequired) { BeginInvoke(new Action<float>(OnRendererUpdate), delta); return; } // 安全地更新 UI 控件 lblFpsValue.Text = $"{_renderer.FPS:F1}"; }

BeginInvoke 而不是 Invoke,原因是 Invoke 是同步的,会阻塞调用线程等待 UI 更新完成,容易造成死锁。BeginInvoke 是异步投递,调用线程立刻返回继续干活,UI 线程有空的时候再处理。


🖥 界面设计:深色主题的 WinForms

WinForms 默认是那个经典的 Windows 灰白风格,做游戏工具显然不合适。全部在 Designer.cs 里手写,用 TableLayoutPanel 做主布局,左侧 270px 固定宽度的控制面板 + 右侧自适应的渲染画布。

配色方案参考了 Catppuccin Mocha,整体以深蓝紫为底色:

背景色 #181825 (24, 24, 37) 面板色 #24243A (36, 36, 54) 主文字 #CDD6F4 (205, 214, 244) 强调蓝 #89B4FA (137, 180, 250) 强调绿 #A6E3A1 (166, 227, 161)

按钮全部用 FlatStyle.Flat + 自定义 BackColor,去掉默认边框,配合 Cursor = Cursors.Hand 提升交互感。这几行代码不多,但视觉效果差别很大。

GroupBox 的标题用 emoji 前缀(比如 📊 渲染统计),在深色背景下既有层次感又有可读性,这个小技巧在工具类应用里挺实用的。


Sprite 类

c#
using SkiaSharp; using System; namespace SpriteRenderSystem.Engine { /// <summary> /// 单个 Sprite 数据模型,描述一个可渲染的精灵对象 /// </summary> public class Sprite { private static int _idCounter = 0; public int Id { get; } = ++_idCounter; public string Name { get; set; } // 位置与变换 public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public float Rotation { get; set; } // 角度,单位:度 public float ScaleX { get; set; } = 1f; public float ScaleY { get; set; } = 1f; public float Alpha { get; set; } = 1f; // 0~1 // 颜色(无纹理时使用纯色填充) public SKColor Color { get; set; } = SKColors.White; // 纹理(可选) public SKBitmap Texture { get; set; } // 纹理源矩形(SpriteSheet 裁剪区域,null 表示整张图) public SKRectI? SourceRect { get; set; } // 是否可见 public bool Visible { get; set; } = true; // 渲染层级(越大越靠前) public int ZOrder { get; set; } = 0; // 是否激活动画 public bool IsAnimated { get; set; } = false; // 动画速度(像素/秒) public float SpeedX { get; set; } = 0f; public float SpeedY { get; set; } = 0f; // 中心锚点(相对于宽高的比例,0.5 = 中心) public float AnchorX { get; set; } = 0.5f; public float AnchorY { get; set; } = 0.5f; public Sprite(string name, float x, float y, float w, float h) { Name = name; X = x; Y = y; Width = w; Height = h; } /// <summary> /// 获取世界坐标下的包围矩形(未旋转) /// </summary> public SKRect GetBounds() => new SKRect(X - Width * AnchorX * ScaleX, Y - Height * AnchorY * ScaleY, X + Width * (1 - AnchorX) * ScaleX, Y + Height * (1 - AnchorY) * ScaleY); /// <summary> /// 更新动画状态(基于帧间隔时间) /// </summary> public void Update(float deltaSeconds) { if (!IsAnimated) return; X += SpeedX * deltaSeconds; Y += SpeedY * deltaSeconds; } public override string ToString() => $"[{Id}] {Name} ({X:F1},{Y:F1})"; } }

SpriteRenderer

c#
using SkiaSharp; using SkiaSharp.Views.Desktop; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Windows.Forms; using Timer = System.Windows.Forms.Timer; namespace SpriteRenderSystem.Engine { /// <summary> /// SpriteRenderer:游戏循环驱动的渲染器 /// 管理 Sprite 集合、帧率控制、批量渲染调度 /// </summary> public class SpriteRenderer : IDisposable { private readonly SKGLControl _glControl; private readonly SpriteBatch _batch = new(); private readonly List<Sprite> _sprites = new(); private readonly Timer _loopTimer; private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); private long _lastTick; private int _frameCount; private long _fpsTimer; private float _currentFps; private bool _showDebug = true; // 背景色 public SKColor BackgroundColor { get; set; } = new SKColor(30, 30, 46); // 是否显示调试信息 public bool ShowDebug { get => _showDebug; set => _showDebug = value; } // 事件:每帧更新回调(deltaSeconds) public event Action<float> OnUpdate; // 统计信息 public float FPS => _currentFps; public int SpriteCount => _sprites.Count; public int DrawCallCount => _batch.DrawCallCount; public SpriteRenderer(SKGLControl glControl, int targetFps = 60) { _glControl = glControl; _glControl.PaintSurface += OnPaintSurface; _loopTimer = new Timer { Interval = 1000 / targetFps }; _loopTimer.Tick += OnTick; _lastTick = _stopwatch.ElapsedMilliseconds; } public void Start() => _loopTimer.Start(); public void Stop() => _loopTimer.Stop(); public void AddSprite(Sprite sprite) => _sprites.Add(sprite); public void RemoveSprite(Sprite sprite) => _sprites.Remove(sprite); public void ClearSprites() => _sprites.Clear(); public IReadOnlyList<Sprite> Sprites => _sprites; private void OnTick(object sender, EventArgs e) { long now = _stopwatch.ElapsedMilliseconds; float delta = (now - _lastTick) / 1000f; _lastTick = now; // 更新所有 Sprite 动画 foreach (var s in _sprites) s.Update(delta); // 边界反弹 BounceSprites(); // FPS 统计 _frameCount++; _fpsTimer += (long)(delta * 1000); if (_fpsTimer >= 500) { _currentFps = _frameCount * 1000f / _fpsTimer; _frameCount = 0; _fpsTimer = 0; } OnUpdate?.Invoke(delta); _glControl.Invalidate(); } private void BounceSprites() { float w = _glControl.Width; float h = _glControl.Height; foreach (var s in _sprites) { if (!s.IsAnimated) continue; float hw = s.Width * s.ScaleX * s.AnchorX; float hh = s.Height * s.ScaleY * s.AnchorY; if (s.X - hw < 0 || s.X + hw > w) s.SpeedX = -s.SpeedX; if (s.Y - hh < 0 || s.Y + hh > h) s.SpeedY = -s.SpeedY; } } private void OnPaintSurface(object sender, SKPaintGLSurfaceEventArgs e) { var canvas = e.Surface.Canvas; canvas.Clear(BackgroundColor); _batch.Begin(); foreach (var s in _sprites.OrderBy(x => x.ZOrder)) _batch.Draw(s); _batch.End(canvas); if (_showDebug) DrawDebugOverlay(canvas); } private void DrawDebugOverlay(SKCanvas canvas) { using var paint = new SKPaint { IsAntialias = true, TextSize = 13f, Typeface = SKTypeface.FromFamilyName("Consolas") }; // 半透明背景 paint.Color = new SKColor(0, 0, 0, 160); canvas.DrawRoundRect(new SKRect(8, 8, 220, 90), 6, 6, paint); // 文字 paint.Color = new SKColor(180, 255, 180); canvas.DrawText($"FPS: {_currentFps:F1}", 16, 28, paint); paint.Color = new SKColor(180, 220, 255); canvas.DrawText($"Sprites: {_sprites.Count}", 16, 46, paint); paint.Color = new SKColor(255, 220, 150); canvas.DrawText($"DrawCalls: {_batch.DrawCallCount}", 16, 64, paint); paint.Color = new SKColor(200, 200, 200); canvas.DrawText($"Resolution: {_glControl.Width}x{_glControl.Height}", 16, 82, paint); } public void Dispose() { _loopTimer.Stop(); _loopTimer.Dispose(); _glControl.PaintSurface -= OnPaintSurface; } } }

📊 实测数据:优化前后的对比

在我的测试机(i7-12700H + RTX 3060)上,不同数量 Sprite 的帧率对比:

Sprite 数量GDI+ 帧率SkiaSharp GPU 帧率DrawCall 优化后
5045 fps60 fps60 fps
20018 fps60 fps60 fps
5007 fps58 fps60 fps
10003 fps41 fps55 fps

1000 个 Sprite 时,DrawCall 排序优化带来的额外提升大约 14fps,在中低端显卡上这个差距会更明显。


🧩 三个可以直接扩展的方向

这套基础系统搭好之后,后续扩展很自然:

帧动画支持SpriteSheet 已经有了按网格切割的 SliceGrid 方法,只需要在 Sprite 里加一个 currentFrame 计数器,每隔固定时间切换 SourceRect,帧动画就有了。

空间分区剔除:当 Sprite 数量超过 2000 个,即使 GPU 性能足够,CPU 端的遍历和排序也会成为瓶颈。可以引入简单的四叉树,只把当前视口内的 Sprite 加入批次,视口外的直接跳过。

Shader 特效:SkiaSharp 支持 SKRuntimeEffect,可以编写 SkSL(类 GLSL)着色器,实现描边、发光、溶解等效果,而且 API 和 Unity ShaderGraph 的思路很接近,上手不难。


💬 最后说一句

这套系统的代码量其实不大,核心逻辑四个文件加起来也就 500 行左右。但它把 Sprite 渲染里最重要的几个概念都覆盖到了——变换矩阵、批量提交、纹理排序、游戏循环、线程安全。

如果你之前只用过 GDI+ 做 WinForms 绘图,这套代码可以作为一个很好的切入点,感受一下 GPU 加速渲染和传统软件渲染的差距。

有问题欢迎在评论区聊,特别是遇到 SkiaSharp 在特定显卡上 OpenGL 初始化失败的问题——这个坑我也踩过,下篇文章可以专门讲一讲。


#C# #SkiaSharp #WinForms #游戏开发 #性能优化

相关信息

通过网盘分享的文件:AppSpriteRenderBaiscSystem.zip 链接: https://pan.baidu.com/s/1sB6SRjv-_Bigzu56A5bmpw?pwd=wbai 提取码: wbai --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!