1. “终于是2D的了”:2D技术为什么依然值得投入
1.1 标题里的梗与技术现实
“大型纪录片《终于是2D的了,就只冲这一点也要狠狠支持》持续为您播出!”这个标题在游戏玩家圈里经常出现,往往是某个系列作品从 3D 回归 2D,或者某个新作终于采用了 2D 画风时,玩家发出的“肯定”之声。它带着调侃,也带着真实期待:2D 不是落后,而是一种更清晰、更风格化、更能聚焦玩法本身的表达方式。
这个道理放在开发者视角同样成立。很多项目在立项时盲目追求 3D,结果资产制作成本翻倍、渲染优化困难、玩法反而被镜头控制淹没。后来团队重新评估,选择 2D 方案,项目才“活过来”。所以“终于是 2D 的了”不只是玩家的呐喊,也是一种技术上的“返璞归真”。本文就用一篇完整实战教程,带你从坐标系、碰撞检测、物理回滚到工程规范,把 2D 开发的全流程走一遍。
1.2 2D 开发的应用全景
2D 技术并不仅限于游戏。我们日常能接触到的场景至少包括:
- 2D 游戏:平台跳跃、像素 RPG、塔防、卡牌、休闲小游戏。
- 地图与图表系统:CAD 制图、室内设计、数据可视化、地理信息系统。
- 机器视觉:工业 2D 缺陷检测、医学图像分割、OCR 文本区域定位。
- Web 交互:Canvas 动画、H5 小游戏、低代码绘图工具。
从搜索引擎的热词也能看出,2D 相关需求非常密集:“2D图纸基准选取”“Unity 2D碰撞检测”“简单2D我的世界”“Missformer:2D医学图像分割”等,都是开发者真实会搜索的问题。本文会覆盖其中几个核心点,并给出可复现的代码和工程建议。
1.3 本文你将学到什么
读完本文,你可以掌握:
- 2D 开发中坐标系与“基准选取”的核心思想。
- 用 HTML5 Canvas 实现一个简易 2D 方块世界,并手动编写 AABB 碰撞检测。
- 在 Unity 中正确配置 2D 碰撞体,区分碰撞事件和触发事件。
- 理解 Godot 2D 物理跨平台回滚时“回滚不干净”的常见原因和解决思路。
- 面对 2D 项目时,应该遵循的命名、基准、性能和异常处理规范。
说明一下,本文示例以“能跑起来”为目标,不会依赖某个特定版本的高级 API,但你需要根据自己本地的引擎版本做少量适配。
2. 2D 开发第一课:坐标系与基准选取
2.1 屏幕坐标、世界坐标与局部坐标
很多新手做 2D 开发时,第一个翻车点就是坐标系混乱。
在网页 Canvas 中,坐标系默认是“左上角为原点,x 轴向右,y 轴向下”。这一点和数学课上常见的坐标系不同,很多刚上手的朋友画图时发现图形上下颠倒,就是这个原因。
在 Unity 中,2D 场景虽然看起来是平面,但依然存在一个三维世界坐标,只不过 Unity 会把镜头放在 z 轴负方向,让所有物体沿 xOy 平面排布。Sprite 的每一张图片都有自己的局部坐标,局部坐标的原点由 Sprite 的 Pivot 决定,默认是图片中心。
在 Godot 中,2D 节点Node2D也有自己的局部坐标,子节点在父节点坐标系中定位。每个节点的 Position、Scale、Rotation 都是相对于父节点的。
这里先记住一个结论:做 2D 开发前,先确认三个坐标系分别是什么,再确认一个对象在每个坐标系中的原点位于哪里。
2.2 基准选取决定了后续对齐与碰撞
热搜词里有一个很务实的问题:2D 图纸基准选取。
在机械制图或 CAD 图纸中,基准是用来标注尺寸、公差和对准关系的参考点或参考线。选错了基准,图纸看起来尺寸都对,但加工出来的零件无法装配。
这个思想在 2D 游戏开发中完全一致。我把“基准”理解为一个 Sprite 或 Collider 的“锚点中心”。举例来说:
- 一个角色角色的左手持武器,如果武器 Sprite 的 Pivot 设置在中心,那么武器旋转时,枪口位置会偏离预期,子弹发射点就会偏。
- 一个平台跳跃游戏的 BoxCollider2D 的 Offset 如果和 Sprite 图片中的实际方块区域不一致,玩家会“隔空碰撞”或“踩在空气上”。
- 一张 2D 地图在 Canvas 中绘制时,如果瓦片地图的原点不在左上角,坐标换算就会整体偏移。
因此,基准选取不是美术问题,而是逻辑问题。开发者在创建 2D 资产时,就应该明确:
- 图片的锚点放在哪里?
- 碰撞体的偏移对齐哪个点?
- 地图原点是左上角还是左下角?
- 旋转中心是否与视觉重心一致?
2.3 最小示例:在 Canvas 中设置基准点
下面用 Canvas 展示一个最简单的基准点效果。我们绘制一个方块,并让它按不同基准点旋转。
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>2D 基准点示例</title> <style> canvas { border: 1px solid #ccc; display: block; margin: 16px auto; } </style> </head> <body> <canvas id="canvas" width="300" height="200"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 左侧:以左上角为基准点绘制并旋转 ctx.save(); ctx.translate(50, 50); ctx.fillStyle = '#4CAF50'; ctx.fillRect(0, 0, 60, 60); ctx.strokeStyle = '#333'; ctx.strokeRect(0, 0, 60, 60); ctx.rotate(Math.PI / 8); ctx.globalAlpha = 0.5; ctx.fillStyle = '#FF9800'; ctx.fillRect(0, 0, 60, 60); ctx.globalAlpha = 1; ctx.restore(); // 右侧:以中心为基准点绘制并旋转 ctx.save(); ctx.translate(200, 80); ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); ctx.fillStyle = '#E91E63'; ctx.fill(); ctx.stroke(); ctx.fillStyle = '#2196F3'; ctx.fillRect(-30, -30, 60, 60); ctx.strokeStyle = '#333'; ctx.strokeRect(-30, -30, 60, 60); ctx.rotate(-Math.PI / 8); ctx.globalAlpha = 0.5; ctx.fillStyle = '#FF9800'; ctx.fillRect(-30, -30, 60, 60); ctx.globalAlpha = 1; ctx.restore(); </script> </body> </html>在这段代码中,左侧代码以(50, 50)为局部坐标系原点,方块在 x 和 y 上都是正方向绘制,所以旋转时绕“左上角”发生;右侧代码把原点放在(200, 80),绘制时记录方块中心为局部(0, 0),所以旋转时绕“中心”发生。
从这个小例子可以看出:修改基准点,就是修改绘制和旋转的参照位置。后续所有 2D 对象的碰撞、旋转、拖拽,都遵循同样的逻辑。
3. 环境准备与版本说明
3.1 浏览器端:无需安装
Canvas 和 JavaScript 部分不需要额外安装环境。你只需要一个现代浏览器,例如 Chrome、Edge、Firefox。推荐使用 VS Code 写代码,在浏览器中打开 HTML 文件即可运行。如果你需要加载本地图片,建议用python -m http.server或 VS Code 的 Live Server 起一个本地静态服务,避免浏览器对file://协议的限制。
python -m http.server 8080然后在浏览器中访问http://localhost:8080/index.html。
3.2 Unity 2D 项目环境
Unity 2D 开发需要安装 Unity Hub 和编辑器。建议使用近期的 LTS(长期支持)版本,例如 2021 LTS 或 2022 LTS。打开 Unity Hub 后,创建项目时选择 “2D Core” 模板。需要注意:
- 2D 模板默认使用 Sprite Renderer 支持。
- 相机默认正交投影(Orthographic),而不是透视投影(Perspective)。
- 素材导入时,如果图片不是 Sprite 类型,需要把 Texture Type 设置为 Sprite(2D and UI),否则无法拖入场景。
版本号不用纠结,本文示例使用的基础 API 在各版本中保持一致。
3.3 Godot 2D 项目环境
Godot 4.x 是目前的主流版本。从官网下载安装后,创建项目时选择 2D Scene 即可。Godot 自带强大的 2D 物理引擎,但有一个老问题:同一套输入回放逻辑在不同的操作系统或硬件平台上,物理模拟结果可能不一致,导致回滚不干净。这是很多网络同步游戏开发者的痛点,第 6 节会详细讲。
4. 实战:用 Canvas 做一个简易 2D 方块世界
4.1 需求拆解
热搜词中有一个“简单2d我的世界”,我们把它改造成适合教程的案例:实现一个由方块组成的 2D 小世界。
需求如下:
- 画一个 8×6 的方块网格。
- 利用键盘方向键控制一个小人移动。
- 小人不能穿过方块(AABB 碰撞检测)。
- 鼠标点击一个方块时,可以切换这个方块的“可用/不可用”状态。
从功能拆分来看,整个项目包括三个核心模块:
- 地图数据:用二维数组存储每个格子是否可通行。
- 渲染模块:遍历二维数组,把每个格子绘制到 Canvas 上。
- 角色模块:根据键盘输入更新位置,然后与地图做碰撞检测。
4.2 HTML 与 CSS 骨架
先创建一个index.html文件。
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>简单 2D 方块世界</title> <style> body { background: #222; color: #eee; font-family: "Microsoft YaHei", sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; } #gameContainer { background: #1e1e1e; padding: 16px; border-radius: 8px; } canvas { display: block; background: #000; } .tips { margin-top: 8px; font-size: 14px; color: #aaa; text-align: center; } </style> </head> <body> <div id="gameContainer"> <canvas id="gameCanvas" width="480" height="360"></canvas> <div class="tips"> 方向键移动 | 鼠标点击方块切换状态 </div> </div> <script src="game.js"></script> </body> </html>这段代码定义了一个 480×360 的画布,格子宽度设为 60px,也就是横向 8 个格子、纵向 6 个格子。使用外部game.js管理逻辑,结构更清晰。
4.3 JavaScript 核心逻辑
创建game.js,代码如下。
const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const COLS = 8; const ROWS = 6; const TILE = 60; // 地图数据:1 表示障碍物,0 表示可通行 const map = [ [1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1, 1], ]; // 玩家初始位置(像素坐标,中心点位于格子内部) const player = { x: 2 * TILE + TILE / 2, y: 2 * TILE + TILE / 2, size: 40, // 碰撞盒边长 speed: 3, }; function drawMap() { for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const x = col * TILE; const y = row * TILE; if (map[row][col] === 1) { ctx.fillStyle = '#724c2f'; ctx.fillRect(x, y, TILE, TILE); ctx.strokeStyle = '#4a2f1a'; ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1); } else { ctx.fillStyle = '#3a3a3a'; ctx.fillRect(x, y, TILE, TILE); ctx.strokeStyle = '#555'; ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1); } } } } function drawPlayer() { const x = player.x - player.size / 2; const y = player.y - player.size / 2; ctx.fillStyle = '#ffcc00'; ctx.fillRect(x, y, player.size, player.size); ctx.strokeStyle = '#fff'; ctx.strokeRect(x, y, player.size, player.size); } // 判断玩家是否与障碍物碰撞 function collidesWithMap(px, py) { const half = player.size / 2; const left = px - half; const right = px + half; const top = py - half; const bottom = py + half; const startCol = Math.floor(left / TILE); const endCol = Math.floor(right / TILE); const startRow = Math.floor(top / TILE); const endRow = Math.floor(bottom / TILE); for (let row = startRow; row <= endRow; row++) { for (let col = startCol; col <= endCol; col++) { if (map[row][col] === 1) { return true; } } } return false; } function movePlayer(dx, dy) { const nextX = player.x + dx * player.speed; const nextY = player.y + dy * player.speed; // 分轴检测,保证可以沿墙滑动 if (!collidesWithMap(nextX, player.y)) { player.x = nextX; } if (!collidesWithMap(player.x, nextY)) { player.y = nextY; } } let keys = {}; document.addEventListener('keydown', (e) => { keys[e.code] = true; }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const col = Math.floor(mx / TILE); const row = Math.floor(my / TILE); if (col >= 0 && col < COLS && row >= 0 && row < ROWS) { map[row][col] = map[row][col] === 1 ? 0 : 1; } }); function gameLoop() { let dx = 0; let dy = 0; if (keys['ArrowLeft']) dx = -1; if (keys['ArrowRight']) dx = 1; if (keys['ArrowUp']) dy = -1; if (keys['ArrowDown']) dy = 1; movePlayer(dx, dy); ctx.clearRect(0, 0, canvas.width, canvas.height); drawMap(); drawPlayer(); requestAnimationFrame(gameLoop); } gameLoop();4.4 运行与验证
用浏览器直接打开index.html,或者使用本地服务器启动后访问。页面会出现一个暗色背景的方块地图,角色是一个黄色方块。
演示逻辑:
- 按下方向键,角色可以在地图内移动。
- 碰到棕色的障碍方块时,角色无法继续前进。
- 用鼠标点击任意方块,棕色方块会变成暗色可通行方块,再次点击则变回障碍物。
- 注意地图外圈都是障碍物,角色不会走出屏幕。
4.5 小细节:碰撞检测为什么用 AABB
上面的collidesWithMap函数使用了最简单的 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)碰撞检测。做法是把玩家角色抽象成一个矩形,把地图上的每个格子当作一个矩形,然后检查两个矩形在 x 轴和 y 轴上是否有重叠。
在 2D 游戏中,AABB 的特点是“计算快、实现简单”,很适合大量物体的粗略碰撞检测。但也有局限:如果物体旋转后,AABB 会变大,不适合精确模拟。这时候可以考虑圆形碰撞体、Polygon Collider2D 或物理引擎内置的碰撞体组件。
这里的关键点是:碰撞检测的基准,其实就是碰撞盒的位置。角色的player.x和player.y默认存的是中心点,所以在绘制矩形时,要减去size / 2来得到左上角。如果基准不一致,就会出现一个角色“看起来没碰到障碍物,却被挡住了”的诡异现象。
5. 实战:Unity 2D 碰撞检测与触发事件
5.1 场景搭建要点
在 Unity 中创建新场景,然后按下面步骤搭建:
- 在 Hierarchy 窗口右键创建 2D Object → Sprites → Square。
- 给方块添加
Box Collider 2D。 - 创建一个玩家物体,可以使用简单 Sprite 或空物体挂
Sprite Renderer。 - 给玩家添加
Rigidbody2D和Box Collider2D。
这里有一个新手很容易踩的坑:Rigidbody2D是刚体组件,负责物理模拟;Collider2D是碰撞体,负责外形。只有碰撞体没有刚体时,物体不会受到物理作用。只有刚体没有碰撞体时,物理系统无法计算碰撞。
在 2D 项目中,如果玩家由脚本控制移动,建议把Rigidbody2D的Body Type设为Dynamic,但把Gravity Scale设为 0。不要直接修改transform.position移动刚体,应该使用Rigidbody2D.MovePosition或直接设置linearVelocity。
5.2 玩家移动脚本
在Assets下创建Scripts文件夹,然后新建一个PlayerController.cs。
using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; private Rigidbody2D rb; private Vector2 inputDirection; void Start() { rb = GetComponent<Rigidbody2D>(); } void Update() { float horizontal = Input.GetAxisRaw("Horizontal"); float vertical = Input.GetAxisRaw("Vertical"); inputDirection = new Vector2(horizontal, vertical).normalized; } void FixedUpdate() { // 物理运算应在 FixedUpdate 中进行 rb.MovePosition(rb.position + inputDirection * moveSpeed * Time.fixedDeltaTime); } }这里为什么使用FixedUpdate?因为 Unity 的物理引擎以固定步长运行,MovePosition是一个物理移动接口。如果在Update中移动,帧率变化会导致移动速度不稳定,也容易与物理碰撞产生抖动。
5.3 碰撞体与基准偏移的关系
在 Unity Inspector 中选中玩家物体,可以看到Box Collider2D有一个Offset属性。这个 Offset 就是碰撞体相对于物体 Transform 原点的偏移。
如果 Sprite 图片的 Pivot 在中心,物体 Transform 原点在中心,碰撞体也自动在中心,那么碰撞的基准是合适的。如果美术导出图片时留了空白边,或在图片中角色位置偏左,那么 Sprite 的中心点和玩家视觉中心就不一致,碰撞体看起来会“偏”。
这里可以用热搜词中的“2D图纸基准选取”来类比:图纸选了错误的基准会导致尺寸链失准,碰撞体选了错误的偏移会导致玩家明明站在平台上,却掉下去。
正确的做法:
- 在导入 Sprite 时,设置正确的 Pivot 位置。
- 在编辑模式下,根据实际游戏逻辑调整 Collider2D 的 Offset。
- 不要在运行时频繁修改碰撞体 Offset,容易造成物理状态混乱。
5.4 触发事件与碰撞事件
碰撞检测分为两类:
- 碰撞事件:两个物体发生实际碰撞,互不相穿。
- 触发事件:作为“区域”使用,可以相互穿过,但会触发回调。
碰撞回调示例:
using UnityEngine; public class CollisionLogger : MonoBehaviour { void OnCollisionEnter2D(Collision2D collision) { Debug.Log($"碰撞开始:{collision.gameObject.name}"); } void OnCollisionExit2D(Collision2D collision) { Debug.Log($"碰撞结束:{collision.gameObject.name}"); } }触发回调示例:
using UnityEngine; public class TriggerLogger : MonoBehaviour { void OnTriggerEnter2D(Collider2D other) { Debug.Log($"进入触发区域:{other.gameObject.name}"); } void OnTriggerStay2D(Collider2D other) { // 在触发区域内持续调用 } void OnTriggerExit2D(Collider2D other) { Debug.Log($"离开触发区域:{other.gameObject.name}"); } }使用触发事件前,必须把 Collider2D 组件上的Is Trigger勾选为 true。注意:如果两个物体都带碰撞体,其中一个勾选了 Is Trigger,就不会产生物理阻挡,但会触发回调。
判断是否回调触发可以根据应用场景练习:
- 敌人突然出现、宝箱检测、玩家进入区域提示:使用 Trigger。
- 角色踩到地面、墙阻挡、子弹击中目标:使用 Collision。
5.5 运行验证与结果
把PlayerController挂到玩家物体上,把CollisionLogger挂到其中一个方块上。运行游戏后,按下 WASD 或方向键移动玩家,观察 Console 窗口中的日志输出。
预期结果:
- 玩家靠近方块时,日志打印“碰撞开始”。
- 玩家离开方块后,日志打印“碰撞结束”。
- 如果玩家在移动过程中无法穿过方块,说明 Box Collider2D 配置正确。
如果玩家直接穿过方块,最常见原因有三个:
- 玩家物体没有挂 Rigidbody2D。
- 碰撞体组件被禁用。
- 在脚本中直接修改了
transform.position,绕过了物理系统。
6. 进阶:Godot 2D 物理跨平台回滚不一致问题
6.1 现象与原因
“Godot physics 2d 跨平台 rollback 时回滚不干净”是一个比较专业的网络热词,常见于使用 Godot 开发联网同步游戏或帧同步游戏的团队。
简单解释一下现象:为了保证所有客户端看到相同的游戏状态,游戏引擎会定期记录“足够的信息”,当网络延迟或丢包后,客户端需要回滚到某个旧状态,然后重新模拟这段时间的输入。按理说,只要输入相同,最终的物理状态就应该相同。但 Godot 2D 物理在不同平台、不同机器上运行的结果却可能不一样,导致回滚后出现瞬移、物体轻微错位、关闭弹开角度不同等问题。
根本原因通常包括:
- 浮点数精度差异:不同平台的 CPU 对浮点运算有微小差异,经过多次物理模拟后误差放大。
- 物理步长不一致:如果使用变帧率或
_physics_process(delta)的 delta 不一致,物理模拟结果会不同。 - 刷新率差异:高刷新率屏幕会在一次物理 tick 内插入更多帧,导致时间分配差异。
- 使用非确定性随机函数:例如
randf()如果没有使用固定种子,在同一时间内不同客户端会产生不同随机数。 - 使用了依赖平台状态的功能:例如
OS.get_ticks_msec()、Time.get_ticks_usec()、物理设备返回的实时输入顺序。
6.2 回滚设计的基本思路
一个比较稳妥的思路是“只回滚输入,不回滚物理状态”。做法如下:
- 每个客户端只广播玩家的输入指令,不直接广播物理坐标。
- 服务器或主机维护固定步长的逻辑时钟。
- 每条输入指令带有准确的逻辑帧编号。
- 客户端在回滚时,先恢复逻辑帧对应的“状态快照”,再按照固定步长重放输入。
- 重放期间禁用所有实时时钟,只使用逻辑帧时间。
这个方法的核心是:确保输入是确定性的,而不是依赖实时物理状态。回滚不是“还原物理模拟结果”,而是“用相同的确定性输入重算一遍”。
GDScript 片段如下,仅演示固定步长思路:
# 示例思路,需按 Godot 实际版本调整 const TICK_RATE := 60 const TICK_TIME := 1.0 / 60.0 var accumulator := 0.0 func _physics_process(delta: float) -> void: accumulator += delta while accumulator >= TICK_TIME: _apply_network_inputs() _step_simulation(TICK_TIME) accumulator -= TICK_TIME看起来很简单,但实际项目中会遇到一个关键问题:_physics_process(delta)本身是引擎按真实时间调用的,如果两次调用之间的 delta 有抖动,累积器会把“额外时间”积攒起来,但物理状态重放必须保证每次TICK_TIME完全相等。因此,真正的回滚逻辑不该直接依赖引擎自带物理,而是自己维护一个确定性物理步骤。
6.3 避免回滚不干净的检查清单
如果你正在做 Godot 2D 帧同步或回滚同步,建议按下表自检:
| 检查项 | 建议 |
|---|---|
| 物理步长是否固定 | 使用自定义逻辑时钟,严格固定 tick 间隔 |
| 浮点数是否统一 | 避免在不同平台依赖不同精度,必要时用定点数替代浮点数 |
| 随机数是否可控 | 所有随机数使用种子随逻辑帧生成 |
| 输入是否确定性 | 输入指令带顺序编号,不直接采样系统事件 |
| 是否混用实时时间 | 禁用Time.get_ticks_usec()作为逻辑时间 |
| 是否直接修改物理节点状态 | 回滚时先恢复快照,再重新模拟,不直接设置位置 |
不要寄希望于物理引擎自动保持一致。2D 物理引擎的确定性本身就不是所有引擎都保证的,尤其是跨平台场景下,需要开发者自己构建一层“确定性封装”。
7. 常见问题与排查思路
7.1 表格速查
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Canvas 绘制模糊 | Canvas 尺寸与 CSS 尺寸不一致 | 用canvas.width与canvas.height控制物理像素,避免拉伸 |
| Canvas 点击位置偏移 | 没有考虑页面滚动或 Canvas 缩放 | 使用getBoundingClientRect()换算鼠标坐标 |
| Unity 中角色穿墙 | 没有 Rigidbody2D 或直接修改 Transform | 在FixedUpdate中使用MovePosition |
| Unity 碰撞回调不触发 | 两个物体都没有刚体,或碰撞层级忽略 | 确保至少一个物体挂 Rigidbody2D,检查 Layer Collision Matrix |
| Trigger 不触发 | 忘记勾选Is Trigger | 勾选对应 Collider 的Is Trigger属性 |
| Godot 跨平台回滚位置偏移 | 回滚依赖物理引擎实时状态 | 改为固定逻辑帧 + 确定性输入重放 |
| 2D 医学图像分割中点选错位 | 使用了 UI 坐标当作图像坐标 | 区分像素坐标、模型输入坐标和世界坐标 |
7.2 深挖:2D 图纸基准选取
如果你是在做 CAD 或机械制图相关的 2D 功能,基准选取的问题会更加突出。一个典型场景是:在图纸上设计一个零件,基准放在左下角,但程序读取时默认把图片左上角当作原点,导致标注尺寸全部偏移。
解决思路:
- 在图纸文件格式中明确记录基准点坐标。
- 程序加载图纸时先读基准点,再计算所有几何对象相对基准点的位置。
- 在导出图纸时,统一约定基准位置,例如固定在左下角。
- 在 UI 中提供“重新对基准”功能,方便用户手动校准。
这个原则和游戏开发里的 Pivot、Collider Offset 完全一致:2D 世界里的坐标系是一种约定,代码需要把约定转换成唯一合法的坐标体系。
7.3 深挖:2D 医学图像分割与缺陷检测
热搜词中有一个 “Missformer: An Effective Transformer for 2D Medical Image Segmentation”。这说明 2D 技术也大量出现在深度学习和机器视觉领域。注意,这里的“2D”通常指单张二维图像输入,而不是视频或三维体数据。
在 2D 医学图像分割或工业 2D 缺陷检测中,常见问题包括:
- 图像坐标与真实世界坐标混淆。
- 标注数据和原图分辨率不一致。
- 类别不平衡,导致少数类分割效果差。
- 模型输出是概率图,需要设置阈值并做连通域过滤。
这些项目对基准和坐标的要求同样严格。比如在缺陷检测中,一个缺陷框在图像坐标系中的中心点,和物理相机坐标系下的实际位置,两者之间的换算关系就是“2D 图纸基准选取”的活生生案例。
建议实际项目中使用字典结构统一存储图像元信息,包括图像尺寸、基准点、像素间距、坐标原点位置,不要分散在多个变量中。
8. 最佳实践与工程建议
8.1 统一基准,消灭魔法数字
无论你是写 Canvas、Unity 还是 Godot,都建议把“基准点”作为对象设计的一部分,而不是随手写一个数字。可以这样做:
- Sprite 图片导入时,统一使用固定 Pivot 规范,例如人物素材中心点放在角色脚底或身体中心,武器素材放在握把处。
- 碰撞体 Offset 在编辑器中配置后,导出到团队文档。
- 地图瓦片原点统一为左上角。
- 所有配置项集中到一个配置文件中,不散落在代码里。
一句话总结:“2D图纸基准选取”不是设计师一个人的问题,而是前端、后端、美术、测试之间需要共同维护的接口约定。
8.2 物理步长与确定性优先
如果你的 2D 项目涉及网络同步,请从项目初期就考虑确定性:
- 使用固定逻辑帧,不把实时帧率作为逻辑时间。
- 所有物理模拟只依赖逻辑输入和固定种子随机数。
- 回滚功能需要与状态快照配合,快照要记录对象的坐标、旋转、线速度、角速度。
- 不要在物理模拟中插入平台相关的 API。
如果项目只是单机小游戏,可以适当降低确定性要求,但仍建议固定步长,避免高刷新率屏幕下角色移动速度异常。
8.3 资源与目录结构
2D 项目很容易出现资源混乱的问题。可以参考下面的目录划分:
Assets/ Scenes/ Scripts/ Sprites/ Player/ Enemy/ Tiles/ Animations/ Prefabs/ Data/Canvas 项目同理:
project/ assets/ images/ sounds/ src/ game.js map.js player.js index.html命名规范:
- 文件名使用英文,不要带空格和中文。
- 同类型资源使用相同前缀,例如
player_idle_0.png、player_run_0.png。 - 碰撞体配置和 Sprite 资源配置写在文档里,方便团队同步。
8.4 2D 检测或分割类项目的额外提醒
如果你的“2D”项目是 Machine Vision,那么最佳实践会有些不同:
- 在存储数据时,保存原始图像尺寸,不要因为显示缩放而丢失元数据。
- 使用独立的坐标转换模块,统一处理像素坐标、模型输入坐标和世界坐标。
- 训练集和测试集划分要参考同一基准信息,不要在同一张图上多次标注。
- 对模型输出做后处理时,保持阈值、最小面积、最大面积等参数可配置。
这部分虽然和游戏开发路径不同,但核心思维一致:坐标系、基准、输入和输出之间的映射必须可追溯。
8.5 异常处理、日志与回滚
在开发过程中,不要只用Debug.Log打印零散信息。建议:
- 使用统一的日志格式,例如
[时间][级别][模块] 消息。 - 在回滚逻辑中,打印帧号和输入指令,方便对比不同客户端是否一致。
- 捕获异常时保留上下文信息,不要吞掉异常。
- 对破坏性操作,例如清空地图、重置玩家位置,加条件判断或二次确认。
生产环境中的 2D 游戏或工具,命名、日志、数据校验和可重复运行能力同样重要。一个“看起来能跑”的小项目,和“能稳定上线”的工程产品,差距往往就在这些细节里。
9. 总结与学习路线
本文从一个玩梗的标题出发,实际上拆解了 2D 开发中最重要的几个技术点。
你已经看到了:
- 2D 坐标系和基准选取是 2D 开发的地基,所有碰撞、旋转、对齐都依赖它。
- Canvas 实战里,我们用纯 JavaScript 实现了一个可交互的 2D 方块世界,并写出了 AABB 碰撞检测。
- Unity 2D 实战中,我们理解了 Rigidbody2D、Collider2D、碰撞事件和触发事件的区别。
- Godot 2D 物理跨平台回滚不一致的问题,核心原因在于不确定性和浮点差异,解决思路是固定逻辑帧和确定性输入重放。
如果你打算继续深入学习,可以从这几个方向中选择一个展开:
- 学习更多 2D 碰撞算法,例如圆形碰撞分离轴定理(SAT)、像素级碰撞检测、空间哈希优化。
- 学习 Unity 或 Godot 的动画状态机,给 2D 角色加入待机、跑步、攻击动画。
- 学习 2D 地图工具 Tiled,将地图数据导出后接入游戏引擎。
- 如果对视觉感兴趣,可以研究 2D 缺陷检测或医学图像分割中的坐标变换和模型后处理。
实话说,2D 开发的门槛并不比 3D 低多少。它只是把复杂度从渲染和模型,转移到了设计、手感、确定性这些更“细腻”的地方。正因如此,“终于是 2D 的了”才常常意味着团队终于找到了最适合自己的技术路线。
希望这篇文章能帮你少踩一些坑。如果你在搭建过程中有自己的踩坑经历,也欢迎在评论区分享,后续我可以结合更多真实问题补充分章教程。