创造球会04游乐场上新了一个新功能:球员头像显示。这篇文章聊聊背后的一些技术细节。

与前几作不同,从 3 代开始,球员形象和立绘进入全面 3D 时代。无论是真人还是架空,都没有前作那样的预设照片。有的只是模型和纹理,在需要的时候实时渲染出来。

借助 Ghidra 和 AI,成功把世嘉当年的 3D 渲染引擎还原出来,在现代显卡上运行和渲染。

球员脸部系统

在《球会04》中,游戏数据文件存储了庞大的角色数据库,共包含 15,034 条面部外观记录(包含球员、教练与球探)。每条记录的大小固定为严格的 64 字节。

通过逆向分析世嘉的逻辑,整个脸部资产管线采用的是截然不同的“双轨制”:

1
2
3
                    ┌─ [team_id > 1] ──► J联赛真实球员 ──► 专属贴图索引 (jfacetex.bin)
64-Byte 球员记录 ──┤
                    └─ [team_id <= 1] ─► 海外/架空球员 ──► 动态图层烘焙 (13图层拼接)
  1. 真实球员(Real Player):拥有专属的照片级贴图。

  2. 海外及架空球员(Fictional Player):游戏不可能为上万人单独绘制贴图。世嘉在这里实现了一套极其精巧的动态图层烘焙机制——根据 64 字节配置中的肤色、眼型、眉形、胡须、法令纹等参数,从基础图集中截取零件并动态合成一张 128×128 的脸皮。

从二进制到 3D 网格变形

在世嘉的原版程序中,人脸模型的生成由一套极其严密的变形系统驱动。

1. 基础网格解析(HSB / MDB 格式)

特写级的高模脸皮存储在 kao.hsb 中,头发模型则存储在 hairmdl1.bin(MDB 格式)。

2. 43 维局部滑条与稀疏差值流(Morph Shape Keys)

每个球员记录从第 21 字节开始存储了 43 个有符号字节(int8),作为面部微调滑条。

在叠加完所有滑条变形后,模型还会强制叠加一段硬编码、权重固定为 0x42 (66) 的全局基准校准流,最终重塑出完整的 3D 头像面部轮廓。

3. 纹理解析(BMR 格式)

2D 纹理字典与 UV 图集切割器存储在 BMR 格式的容器中。

材质烘焙与发型处理

1. 架空球员 13 图层绝对定位烘焙

对于架空球员,贴图是在内存中实时合成的。逆向定位到图元布局表,可以发现它将 128×128 的画布划分为两大部分:

  • 左侧 3/4 (96px 宽):底膜脸皮、眼眶眼白、眼型轮廓、法令纹、眉毛、鼻梁鼻翼、嘴唇、胡须。
  • 右侧 1/4 (32px 宽):独立保留列,用于放置眼球 (16×16)、上下牙齿 (16×16)、下颌与口腔立体阴影。

而对于真实球员,贴图是预先保存好的:

2. 发型透贴与 Alpha 阈值

PS2 的 GS (Graphics Synthesizer) 拥有特有的 Alpha 测试硬件。逆向发现程序中有一张专门的 AREF 阈值表:每种发型在渲染时,纹理 Alpha 通道低于阈值的部分必须直接舍弃(Alpha Cutoff),以显示发丝与其后的皮肤透视效果。

验证与还原

在进入下一步之前,将模型导入到 Blender 中进行全方位的观察。注意球员刘海及后脑发丝处的透视细节。

离屏渲染管线

既然我们已经在内存中拥有了完整的 3D 网格、法线、材质与发型,一个很自然的想法浮现出来:为什么不直接把模型打包成 glTF,扔到前端用 Three.js / Babylon.js 进行实时的全 3D 渲染和交互呢?

经过深思熟虑,最终否决了前端实时 3D 方案,主要基于以下现实考量:

  1. 尺寸与交互需求严重不匹配:球员头像的展示框通常只有 128×128 像素,其本质就是一个辅助识别球员的名片缩略图。为一个邮票大小的头像引入一整套庞大的 WebGL 运行时、加载轨道控制器(OrbitControls)和复杂的着色器,属于典型的“杀鸡用牛刀”。
  2. 确定性与极致轻量:离线预先烘焙为 2D 静态图像,可以让前端回归最纯粹、高效的图片渲染与浏览器原生缓存机制。

因此,最佳工程路径非常明确:在后台批量离屏出图。由于部署环境通常是没有物理显示器的无头服务器(Headless),我们选用基于 EGL / CGL 的现代化 Python 无头 OpenGL 库 —— ModernGL。

1. 动态自适应“摄影棚”相机

为了让 15,034 个球员的头像构图保持一致,相机不能写死坐标,而是根据每个网格的包围盒计算自适应 MVP 矩阵:

1
2
3
4
5
# 计算包围球与视锥距离
radius = max(float(np.linalg.norm(maximum - target)), 0.01)
distance = radius / (math.sin(vertical_fov / 2.0) * 1.2) / zoom
# 经典 45° 视场角与固定机位朝向
eye = target + direction * distance

2. 着色器实现

管线非常纯粹:

  • 顶点着色器:负责 MVP 空间变换与 UV / 法线传递。
  • 片元着色器:实现简单的半兰伯特漫反射(Diffuse),并加入 Alpha Discard(精准剔除头发与透明五官边界),输出到 4x MSAA 抗锯齿的 Framebuffer,再 Blit 到解析缓冲(Resolve Framebuffer),最终压缩成 WebP 字节流。

专为 Web 端设计的紧凑归档格式

渲染出 15,000 多张 WebP 图片后,如何发布到前端网页?如果直接存成 15,000 个分散的小文件,会引发两大灾难:

  1. 本地文件系统灾难:极小的文件塞满 Inode,打包、解压、传输速度慢如龟速;
  2. HTTP 性能灾难:前端请求数万张小图会造成可怕的连接握手开销。

为此,设计了一套定制的轻量级紧凑归档规范 FAVI (Fast Avatar Index):

1
2
3
4
5
avatars/
 ├── index.bin              <-- 固定尺寸头部 + 每人8字节的查找表
 ├── avatars-000.pack       <-- 2MB 分片二进制包
 ├── avatars-001.pack
 └── ...
  • 定长索引表 (index.bin):采用 Header (64B) + Entry (8B * N) 结构,每个 Entry 仅占 8 字节。
  • 分片存储 (avatars-xxx.pack):每个分片限制在 2MB 左右。前端加载时,只需先下载一份仅几十 KB 的 index.bin,便能通过计算偏移量结合 HTTP Range Request 直接按需切割加载单张 WebP,或者在客户端直接按分片整包预缓存,极大降低了 CDN 请求压力。

已知问题

  • 左右手坐标系未转换,目前头像与游戏中呈现左右镜像
  • 球员脸型太长太尖
  • 光源位置或法线计算错误导致某些球员脸部太暗

结语

从用 Ghidra 逆向机器码提取算法公式、复刻 PS2 浮点特性,到通过 ModernGL 离屏着色、定制 FAVI 紧凑格式,这套管线最终在几分钟内完成了对全部 15,034 名球会角色的自动化提取与高质量 WebP 生成。

现在,这些二十年前绿茵场上的熟悉面孔,终于在现代 Web 技术栈下获得了新生。欢迎大家去体验:创造球会04游乐场。


欢迎关注我的公众号,第一时间获取最新文章。