前言
这是我小时候第一次接触 FFTA 的场景:这么一个小小的、仅仅 16MB 的 GBA 游戏,居然有着如此宏大完整的世界观和精致细腻的战斗画面。当 Mewt 翻开那本古书,现实中的 St. Ivalice 被悄然改写成了幻想世界 Ivalice。在漫长的暑假里,我也仿佛置身其中——组建队伍、学习“二刀流”、搭配职业与种族、踏上一场场冒险。那一刻,自己真的成了 Ivalice 的一员。这,就是我心中真正的“最终幻想”!
在战棋游戏(SRPG)的历史上,2003 年发售的《最终幻想战略版》(Final Fantasy Tactics Advance,简称 FFTA)在像素美学和系统深度上都堪称标杆。经典的 2.5D 等轴测视角(Isometric Projection)、菱形网格、精致的手绘斜 45 度建筑、错落有致的高低差,以及经典的 CT 速度时序战斗,构成了它独一无二的视觉与玩法体验。
时隔 20 多年,出于对这部作品的怀念,前段时间我萌生了一个想法:在浏览器中原汁原味地复刻 FFTA 的开篇战役——也就是圣伊瓦利斯小镇学校后院的那场经典打雪仗。
既然开了这个坑,我便不想只做一个死板的「2D Canvas 贴图画框」。我希望能够自由切换视角,从东、南、西、北四个不同的停靠点(Dock 0 ~ 3)全方位俯瞰整个棋盘。就像视频展示的这样:
这个看似在别的游戏中轻而易举的效果,在实际复刻过程中却异常复杂。在短短 26 秒的流畅动画背后,耗费我最多时间和心力的,不是从 ROM 中抽取地图和人物贴图,也不是写经典的 CT 速度时序战斗,而正是这个“切换视角”。—— 这其实是一个极其刁钻的图形学难题:如何还原原版 2D 像素场景中并不存在的「月球背面」?
一、真正的硬仗:原画盲区的「月球背面」
2.5D 像素游戏本质上是一场精心排练的单向舞台剧。
FFTA 的原画画师只绘制了观众席(Dock 0)能看到的那一侧。学校主楼的正面墙壁、右侧走廊、朝向东南的绿色屋顶以及小雪包都画得细致入微;但是在舞台后方,主楼的后墙、西北侧的屋檐、阴影背后的墙根,在游戏的 ROM 里是不存在的。

学校主楼的正面墙壁、右侧走廊、朝向东南的绿色屋顶以及小雪包
这就引出了核心矛盾:当我们在 Three.js 中按下旋转键,相机转到了建筑物的背后,这些在原版中根本不存在的表面,究竟该长成什么样?
为了填补这个「月球背面」,我实际上做了足足四次不同解法的探索,并都以失败告终。
二、前四次失败的探索:伪解法的幻灭
尝试 1:按高度差平铺立方体(立方体之墙)
最直觉的技术方案:既然我们已经从 ROM 提取出了每个格子的坐标 $(r, c)$ 和高度 $h$,只要在 Three.js 中根据这些参数实例化一堆 BoxGeometry,然后把提取出的整张 map_000_bg.png 作为投影贴图贴在正面不就行了吗?

尝试 1:立方体平铺 —— Dock 0(左)看似严丝合缝,旋转到 Dock 1(右)后暴露的无材质背面、侧壁黑块与马赛克阶梯屋顶
- 破绽:Dock 0 视角下确实严丝合缝,但相机稍微旋转到 Dock 1,整个世界立即露馅。建筑物的背面全是未定义材质的黑块,而且原本倾斜连续的斜屋顶被暴力切成了一级一级的马赛克阶梯。
- 反思:高度图(Height-Map)记录的只是人物行走的碰撞约束,它根本不是建筑物真实的三维表面几何。
尝试 2:Billboard 风格的水平镜像贴图
既然背面缺失,能不能学当年很多低成本 2D 游戏的做法:把正面提取出来的像素贴图,做一次水平翻转(Horizontal Flip),直接覆在建筑的背面和侧面?

尝试 2:粗暴水平镜像贴图 —— 正面的门窗和挂钟出现在建筑背面,采光面与阴影面阴阳割裂
- 破绽:对于对称的孤立树木或许还能蒙混过关,但对于建筑物是灾难性的:
- 空间结构错位:原本位于建筑右侧的门廊和正门,翻转后跑到了建筑后墙的左侧;
- 光源逻辑崩溃:原画的光源是从画面右上角打过来的,镜像贴图让背光面的阴影和高光完全错乱,整座建筑呈现出极其怪异的「阴阳脸」。
- 反思:局部可以对称,但建筑的功能布局和环境采光是强非对称的。
尝试 3:借助生成式 3D(Tripo AI 生成 GLB 模型)
生成式 3D 工具在 2026 年也是值得尝试的,我看了不少一键生成 3D 模型的效果,自己感觉还不错。如果能直接用 3D 模型重建场景,那将大大节省手工建模的成本。我把整张 map_000_bg.png 喂给了 Tripo AI,生成一个整张地图的三维 .glb 模型,直接作为视觉层替换掉 ROM 地形,只保留 ROM 网格负责移动和站位规则。(为了搞定 Tripo AI 的下载,我甚至自己写了一个 userscript 来绕过官方的订阅下载限制)
然而实际拉近却发现生成的模型坐标与原始地图的离散格子完全不对齐。

尝试 3:Tripo AI 生成模型与原始离散格子对齐情况 —— 明显错位
- 破绽:
- 风格断层(Style Drift):AI 生成的资产带有现代 PBR 材质与过度平滑的多边形倾向,哪怕强行压低分辨率,也完全失去了 16 色调色板的复古像素味,与地图上跳跃的角色 Sprite 格格不入;
- 坐标物理失准:战棋游戏是离散拓扑。人物的立足点必须踩在严格的格子平面上。AI 生成出来的模型表面存在细微的曲率和斜度,人物走上去只会“穿模”。
- 反思:生成式 3D 擅长「感官相似」,但战棋游戏需要「物理级严丝合缝」。
尝试 4:白模正交结构图引导的多视角图像生成
如果 AI 直接做 3D 不行,那让 AI 分别补齐 Dock 1、2、3 的另外三张 2D 像素图呢?
为了保证透视不崩,我甚至在代码里写了一个虚拟拍照功能(Virtual Camera Capture):先把纯色白模放置在场景中,以精确的 $30^\circ$ 俯角向四个方向渲染出纯几何的白模结构图,连同原图一起交给图像生成模型,要求它把结构图当作不可移动的骨架,只负责填色和补画像素。

尝试 4:交给图像生成模型的四向正交白模结构图(Dock 0 ~ 3),作为补绘其他视角的几何约束
- 破绽:
- 几何一致性(Consistency)幻灭:在 Dock 0 里明明有两扇窗户和一根粗烟囱,AI 生成的其他视角里有的多一个烟囱,有的少一扇窗户,完全不遵守同一个立体结构;
- 像素级透视失真:扩散模型对「严格正交无透视衰减」缺乏内在理解,生成的画面边缘总带有一丝肉眼难以察觉但贴上去就会严重错位的透视倾角;
- 色差漂移:同一个屋顶在不同视角下算出的瓦片红存在不可调和的色偏。

尝试 4:图像生成模型补绘的 Dock 0 ~ 3 四向输出 —— 可以看出每个视角都好像重新把学校制造了一遍
- 反思:AI 生成的多视角图像虽然在视觉上看似完整,但由于缺乏对三维结构的全局约束,仍然无法保证几何和像素级的一致性。距离可用还差得太远。
阶段总结
| 探索路线 | 尝试的做法 | 核心破绽 / 为什么行不通 | 留下的认知教训 |
|---|---|---|---|
| 1. 简单平铺 | 按高度差堆立方体,正面投影贴图 | 非 Dock 0 视角全是无材质的黑块与截断几何 | 2.5D 像素画是一张皮,背后是空的 |
| 2. Billboard 镜像 | 简单将正面贴图水平翻转贴到背面 | 门窗位置错位,采光面与阴影面阴阳割裂 | 空间结构与光源非对称,镜像会制造空间逻辑混乱 |
| 3. 文生 3D (Tripo) | 用单图 3D 工具直接生成 GLB 模型 | 现代平滑网格失去像素质感;无法咬合整数高程网格 | 生成式 3D 追求视觉相似,但战棋需要物理级对齐 |
| 4. 文生图补视角 | 导出四向白模结构图让 AI 绘制其他视角 | 视角透视微偏、关键构件(烟囱/窗户)不一致、色彩漂移 | 多张孤立的 2D 幻觉拼不出空间自洽的三维实体 |
三、破局之役:回归参数化几何与 UV 投影烘焙
在所有看似前沿的捷径全数撞墙后,我打开了 Codex,与 GPT-6 Astra 展开了一场深入推演。
我已经试了四种方案都失败了:立方体平铺、水平镜像、AI 生成 3D 模型、AI 补绘多视角图像。
有没有不依赖 AI 猜测或粗暴镜像的解法?
你可以把这件事想成:你有一张学校的照片,想把它做成能从四面看的纸模型。
照片拍到了正面和右侧,没有拍到背面和左侧。你转动照片,也不会看到学校背后的窗户——那些信息本来就没在照片里。
之前让 AI 猜或者镜像,相当于几张图片根本没有共同遵守同一个立体结构。正确的解法是:先用标尺搭好骨架,再把正面像素冻结烤上去,背面按结构自洽补色。
我其实没理解 GPT-6 Astra 的意思,为什么要先用标尺搭骨架,再把正面像素冻结烤上去,背面按结构自洽补色。但是我们可以尝试按照这个思路,用参数化几何体和 UV 投影烘焙来实现。
数据的地基:ROM 逆向与等轴测空间换算
要重构场景,首先必须拿到真实的物理尺寸。老游戏的等轴测场景并不是画师凭空画出来的,背后有严密的内存结构支持。
1. 地图高度与通走网格提取
通过分析 FFTA ROM,我们定位了全局地图指针表。每张地图占用 0x58 字节的配置结构,其中偏移 0x10 指向高度图容器数据(Height-Map Data)。
该数据被包装在 Mode 0x11 容器中,经由 GBA BIOS 的 LZ77 算法解压后,再解包为 16 格宽的紧凑记录流(Packed-record stream)。每一个格子包含两个决定性字段:
- $h$:地块高度(单位为阶梯高度);
- $p$:通行权限($p=0$ 为可行走,包括平地和可以站上去的雪堆高地;$p=1$ 为校舍、围栏、树木、雪人等阻挡格)。
2. 2:1 等轴测与 3D 正交相机的严格数学映射
GBA 的屏幕分辨率只有 $240 \times 160$。为了在有限的硬件上呈现立体感,它的钻石瓦片(Diamond Tile)高宽比是严格的 $1:2$(单个平面瓦片宽 32 像素、高 16 像素)。
许多 Web 3D 开发者在复现老游戏视角时习惯随意拖动视轨相机(OrbitControls),结果怎么调都觉得像素变形。其实这里的几何对应是完全确定且唯一的:
- 俯角(Elevation): 在正交投影下,菱形瓦片的垂直压缩率对应相机的俯角 $\theta$。因为瓦片高宽比为 $1:2$,满足: $$\sin\theta = 0.5 \implies \theta = 30^\circ$$ 即相机的俯仰角必须精准锁定为 $30^\circ$。
- 像素密度比例(PPU, Pixel Per Unit): 将三维世界中边长为 1 个单位的立方体在 $30^\circ$ 俯角和 $45^\circ$ 偏航角下投影,其菱形对角线投影宽度为 $\sqrt{2}$。为了让 32 像素的瓦片在屏幕上以 1:1 的原生未拉伸像素渲染: $$\text{PPU} = \frac{32}{\sqrt{2}} = \sqrt{512} \approx 22.63$$
- 垂直阶梯换算:
通过在原版背景图上精确卡尺测量,高度每增加 1 个单位,像素在屏幕上垂直上升 6 像素(
BG_H_SCALE = 6)。
有了这套严丝合缝的数学映射,我们将正交相机对准 Dock 0(默认方位角 $-135^\circ$),原画与三维坐标系便达成了亚像素级的精确重合。
1. 用代码「测绘」几何体与反投影方程
我们不再依赖碰撞网格自动生成面,而是写了一个专门的参数化构建脚本 tools/build_school.py。
我们拿原图作为基准蓝图,根据透视投影公式测绘出学校建筑的真实长、宽、屋檐高、屋脊高,用代码生成纯净的顶点数组。不管是主建筑屋顶、门廊支柱、棚屋斜面还是两根烟囱,它们都是参数化的确定多边形。
反投影数学模型
给定三维世界坐标点 $\mathbf{P} = (c, y, r)$,其中 $c$ 为地图列(沿 X 轴向)、$r$ 为地图行(沿 Z 轴向)、$y$ 为三维高程(Y 轴垂直向上),其投影到 2D 原始背景图(512x512)上的像素屏幕坐标 $(u, v)$ 满足以下封闭解析式:
$$\begin{cases} u = 256 + (c - r) \cdot 16 \\ v = 244 + (c + r) \cdot 8 - (y - y_{\text{base}}) \cdot V_P \end{cases}$$其中垂直投影比例系数 $V_P$(Vertical Pitch)由像素密度 $\text{PPU}$ 和相机俯角($30^\circ$)严格推导得出:
$$V_P = \text{PPU} \cdot \cos\left(\frac{\pi}{6}\right) = \sqrt{512} \cdot \frac{\sqrt{3}}{2} \approx 19.596$$原画中测量出的高度阶梯步长为 6 像素,刚好对应世界坐标高度单位 $\Delta y = \frac{6}{V_P} \approx 0.306$。在脚本中这一换算关系被固化为确定性函数:
1# Project 3D world coordinate (c, y, r) into 2D native screen pixel (u, v)
2def project(p):
3 c, y, r = p
4 return np.array([256 + (c - r) * 16, 244 + (c + r) * 8 - (y - BASE) * VP])法向量裁决与顶点绕序(Winding Order)修正
在 WebGL 中,当开启背面剔除(Back-face Culling)时,顶点的环绕顺序(顺时针 CW 与逆时针 CCW)决定了多边形正面法线的指向。在手工参数化生成三维点集时,极易因坐标遍历顺序搞反导致正面朝内,在旋转相机时导致整个墙面凭空隐形。
我们在多边形注册函数 add() 中引入了基于向量叉积的自动纠偏。给定顶点序列的前三个点 $\mathbf{p}_0, \mathbf{p}_1, \mathbf{p}_2$ 和预期向外的法向参考向量 $\mathbf{n}_{\text{out}}$,平面的几何法向量可由外积算得:
通过内积符号判定法线是否同向:
$$\mathbf{n}_{\text{calc}} \cdot \mathbf{n}_{\text{out}} < 0 \implies \text{顶点顺序反向,需整体翻转}$$ 1def add(name, points, kind, outward, donor=None):
2 points = np.array(points, dtype=float)
3 uv_pixels = (np.array([project(p) for p in points])
4 if donor is None else np.array(donor["sourcePixels"]))
5 # Ensure outward normal: flip vertex & UV winding order if misaligned
6 if np.dot(np.cross(points[1] - points[0], points[2] - points[0]), outward) < 0:
7 points = points[::-1]
8 uv_pixels = uv_pixels[::-1]
9 f = dict(name=name, vertices=points.tolist(), sourcePixels=uv_pixels.tolist(),
10 kind=kind, provenance="reused" if donor else "source",
11 donor=donor["name"] if donor else None)
12 FACES.append(f)
13 return f这一步彻底解决了「战棋网格贴合」与「几何体隐形」问题:建筑物与地面接触的每一条边,都严丝合缝地咬合在整数网格上。
2. 正面像素反投影与背面 UV 烘焙
有了精确的几何面片之后,贴图问题迎刃而解:
- 可见正面(Source Faces):
通过数学反投影公式
project(),直接将顶点对应的三维坐标投射到原始背景图map_000_bg.png上的像素坐标,精准裁剪出纹理并烘焙到统一的图集school_atlas.png中。- 遮挡去重:烟囱与天窗紧贴在屋顶与墙体上。为防止墙面烘焙时把烟囱的正面像素重复烤在主墙上(导致相机旋转后墙面上残留烟囱的「鬼影」),脚本在烘焙墙体之前先对烟囱覆盖的源区域执行光栅化剔除。
- 隐藏背面(Reused Faces): 背面不再简单镜像翻转,而是根据几何面的对应关系,复用正面已修复好的干净纹理区域,重新计算法线和 UV 排布。
- 隐藏侧面与地台(Patches):
对于原画中从来没出现过的向阴坡土坡与隐藏雪壁($-x$ 与 $-z$ 方向),我们在
ground.png中规划出若干可重复无缝拼接的贴片(Patches),在构建脚本中按高度阶梯步长(12 像素/高度单位)程序化平铺延伸: $$v_{\text{patch}} = 1 - \frac{y_{\text{rect}} + \min\left((h_{\text{top}} - h) \cdot \Delta h_{\text{step}}, h_{\text{rect}}\right)}{\text{TEX\_SIZE}}$$

构建脚本生成的完整材质图集 school_atlas.png:包含正反面烘焙纹理与道具面片
3. 角落小样(school-corner)的敏捷闭环
为了验证这套流水线,我们没有急于铺满整个大地图,而是先在独立页面中实现了一个仅包含 6 个地面格的「学校角落小样」。

尝试 5:school-corner 小样 —— B 键切换白模型骨架(右)与正反面 UV 烘焙纹理(左)的四向对齐验证
在这个轻量测试场中,我们固化了三项极其关键的验证机制:
- 按
Q/E验证 4 向旋转时瓦片与窗户的绝对锚定; - 按
B键一键切换「白模型 / 贴图材质」,肉眼比对几何体与像素轮廓是否存在半个像素的走位; - 移动测试角色,确认单位在斜坡与台阶上的脚底始终紧贴水平面。
验证完全跑通后,脚本才一键扩充到了整座校舍与后院操场,并生成可直接导入 Blender 的 .obj 和 .zip 备份资产。
五、消除违和感:消灭 Billboard 与多视角动态自洽
搞定了主建筑的月球背面,当相机真正开始旋转时,视野转向场景里的道具、活动角色与地形,还有几个致命的「出戏点」需要逐一拔除。
1. 为什么场景道具必须干掉摄像机 Billboard?
在等轴测游戏中,树木、雪人、风向鸡通常被做成永远面向相机的单片广告牌(Billboard)。
但在 3D 四向切换中,这种偷懒做法引发了严重的深度穿透 Bug:
- 雪人穿模惨剧:雪人的锚点被定在它脚下的瓦片中心,但在原画中,雪人实际上站在靠前的位置。当摄像机旋转到侧面时,回廊木栏的深度刚好挡在瓦片中心前方,导致木质围栏直接「插穿」了雪人的身体。
- 双生树割裂:场景右侧的两棵树在原画里是合在一起的一个大精灵图,转动视角时它们无法独立产生遮挡。
终极解法:全面弃用 Billboard,改为空间固定多边形(见 build_school.py 中的 FIXED_PROPS):
- 围栏改为沿网格边缘延伸的固定面片(
r/c); - 树木、雪人和风向鸡全部拆分为 $45^\circ$ 交叉十字双面片(Crossed Quads,
x/xx),树干各自精准扎根在自己的瓦片单元内。转动相机时,立体纵深感极为扎实,再也不会出现前后穿模。
2. 活动单位的视角相对朝向裁决(Camera-relative Facing Resolution)
道具固定为立体面片后,在场景里奔跑的 2D 像素精灵(Sprite)又带来了新的挑战。
在原本的 2D 游戏中,角色只有固定的四向逻辑朝向:$\text{Facing} \in \{0: \text{North}, 1: \text{East}, 2: \text{South}, 3: \text{West}\}$。
但在 3D 多视角系统中,同一个正在向「东」奔跑的角色,在 Dock 0(默认正向)看是正面偏右跑;当玩家按下 E 键把视角切到 Dock 1 时,他在屏幕上应该展现为背面偏右跑;再转到 Dock 2 则是背面偏左跑。
为了保证角色的世界朝向不变、仅仅改变视觉渲染帧,系统推导了摄像机相对朝向裁决公式:
$$\text{rel} = (\text{facing} - \text{camDock} + 4) \pmod 4$$通过索引查表 FACING_TABLE[rel],在瞬间动态决定抽取 front 还是 back 精灵条带,并设置纹理的 repeat.x 正负号来实现水平翻转(flip):
1// Relative facing lookup: maps (facing - camDock) to sprite slot + UV flip
2export const FACING_TABLE = [
3 { pose: "back", flip: true }, // 0: 视线背向偏右 (NE)
4 { pose: "front", flip: true }, // 1: 视线正向偏右 (SE)
5 { pose: "front", flip: false }, // 2: 视线正向偏左 (SW)
6 { pose: "back", flip: false }, // 3: 视线背向偏左 (NW)
7];无论相机如何旋转,角色的奔跑时钟和动作进度(Frame Index)保持连续,换面裁决在相机旋转过半的瞬间平滑过渡,不会造成动作重置或跳帧。

主角 Marche 在 4 个 Dock 下的视角相对朝向:世界朝向不变,仅视觉渲染帧随相机切换 front/back 与水平翻转
3. 雪堆的手绘像素描边:Inverted Hull 技巧
原版 GBA 场景里的雪堆并不是平的,画师用圆润的暗蓝色轮廓线(#485888)勾勒出可爱的馒头状雪包。
如果在 3D 建模中把描边画死在贴图上,相机一转侧面就会露出丑陋的黑边贴条。
最终我们采用了经典卡通渲染的 Inverted Hull(反向外壳膨胀) 技术: 为每个雪堆生成一个按中心等比放大 1.08 倍的几何穹顶外壳,材质设置为仅渲染背面(Back-Face Only),颜色赋予固定的深蓝像素色。无论相机停在哪个 Dock,视角边缘自然都会出现一圈均匀平滑、带有复古卡通感的手绘描边。

原版 GBA 雪堆像素描边(左)与 Inverted Hull 3D 实现的对比
六、结语与工程启示
仅仅完成了第一关(还只是打雪仗关),我就好像补上了一百节大学旷掉的计算机图形课。 也切实体会到生成式 AI(不管是生产 3D 模型还是生产多视角)都能快速得到好像「对了」的结果,但在面对 「战棋离散高度差」、「亚像素级无透视正交」、「多视点几何自洽」 等硬核工程约束时,其内生幻觉与对精确拓扑控制的缺失也暴露无遗。
这让我思考,也许正是因为没有这样的「捷径」,当年 FFTA 的开发团队,才能一步一脚印,利用算法与纹理映射,通过确定性的工程代码呈现出这样精致的「像素美学」。
就以这个小小的 DEMO,向那个黄金时代的像素匠人表达最真诚的致敬吧。
Hi,我是 CheerChen。