2.2 导出设置:单位、轴向与坐标系对齐

单位和轴向看着只是导出面板上的两个下拉框,写错了就是落地灯十七米高、角色躺在地上。本节把单位换算、Y-up/Z-up 轴向置换、左手系/右手系镜像拆成三个正交问题,用一份可跑的整数账算出六种 DCC 约定各自的置换矩阵、行列式与镜像标志,并给出厘米→米与坐标置换的对齐结果。

2.2 导出设置:单位、轴向与坐标系对齐

导出面板上最不起眼的两个下拉框——单位与轴向——是整条管线上最便宜也最贵的设置。便宜在于改一下就好;贵在于改错了要等导入之后才看得出来:模型缩放差一百倍、角色躺在地上、法线朝内、背面剔除把模型啃掉一半。站内 glTF 资产管线那篇 讲的是交换格式本身怎么表达这些信息(节点变换、矩阵分解、动画采样);本节不重推格式,只把美术侧要在导出设置里交代清楚的三件事算成一份能跑的整数账:单位、轴向、手性。

2.2.1 三个正交的问题

这三件事经常被混成一句「坐标系不对」,但它们的修法完全不同,必须拆开:

问题表现修法数学形态
单位模型差 100 倍导出时给缩放倍率,或导入时按 scaleFactor 补标量乘法
轴向模型躺着 / 面朝天上交换坐标分量(Y-up ↔ Z-up)置换矩阵
手性法线朝内、背面剔除啃模型翻转一个轴的分量符号行列式为 −1 的矩阵

关键在最后一列的数学形态:单位是标量,轴向是置换(把 x/y/z 三个分量换个位置),手性是符号翻转(把其中一个分量取负)。三者都是整数运算——这是本节能做成一笔「逐位一致」的账的原因。反过来,如果把它们混成一句「导出时注意坐标系」,出事故时就无法定位到底是哪一个错了。

2.2.2 对齐表(D 层)

下面这个块是 D 层,把六种 DCC 约定各自落成一个带符号置换矩阵(元素只取 −1/0/1),并声明单位倍率与探针坐标:

{
  "specVersion": 1,
  "note": "单位与轴向对齐表。全部是整数:单位倍率是整数,矩阵元素只取 -1/0/1(带符号置换矩阵)。行列式 +1 保手性,-1 表示镜像(必须翻转三角绕序与法线符号)。upAxis / handedness 是公开文档口径,非本机实测。",
  "canonical": "gltf",
  "unitScale": 100,
  "mmPerCm": 10,
  "conventions": [
    {"id": "blender", "upAxis": "Z", "handedness": "right", "matrix": [[1, 0, 0], [0, 0, 1], [0, -1, 0]]},
    {"id": "max", "upAxis": "Z", "handedness": "right", "matrix": [[1, 0, 0], [0, 0, 1], [0, -1, 0]]},
    {"id": "maya", "upAxis": "Y", "handedness": "right", "matrix": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]},
    {"id": "gltf", "upAxis": "Y", "handedness": "right", "matrix": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]},
    {"id": "unity", "upAxis": "Y", "handedness": "left", "matrix": [[1, 0, 0], [0, 1, 0], [0, 0, -1]]},
    {"id": "unreal", "upAxis": "Z", "handedness": "left", "matrix": [[1, 0, 0], [0, 0, 1], [0, 1, 0]]}
  ],
  "probes": [[100, 200, 300], [-50, 0, 250], [0, -120, 40], [-150, 25, 7]]
}

矩阵的读法:matrix 的第 j 列是局部坐标轴 eⱼ 在规范坐标(Y-up、右手)里的像。以 Blender 那行为例,[[1,0,0],[0,0,1],[0,-1,0]] 表示:局部 X → 规范 +X,局部 Y → 规范 −Z,局部 Z → 规范 +Y,于是坐标从 (x, y, z) 变成 (x, z, −y)。

unitScale 是 100,含义是美术在 DCC 里按厘米建模,引擎按米消费。这里刻意不依赖任何 DCC 的默认单位——不同软件的默认值并不一致,把单位的责任推给「软件的默认值」是管线事故的经典来源,一律在导出设置里显式声明。

2.2.3 S 层:单位 / 轴向 / 手性整数账

下面这段是 S 层脚本,读上面那份表,对每条约定算出行列式、镜像标志与探针坐标的对齐结果,并校验每条约定自报的 upAxis / handedness 与矩阵自洽:

#!/usr/bin/env python3
# file: sim/ta02_units.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta02_units.py
"""单位 / 轴向 / 手性对齐账(S 层):读 config/ta02_axis_align.json,对每条约定算三件事。

1. **单位**:厘米 → 米用整数地板除 ``cm // 100``(负数向负无穷,故 -50 → -1,不是 0);
2. **轴向**:``canonical = M · m``,M 是带符号置换矩阵(元素只取 -1/0/1),整数乘法;
3. **手性**:``det(M)`` 为 +1 保手性、-1 表示镜像(镜像必须翻转三角绕序与法线符号)。

同时校验每条约定自报的 upAxis / handedness 与矩阵自洽。落 out/ta02_units.json + CSV。
"""
import csv
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta02_axis_align.json")
OUT = os.path.join(ROOT, "out")
AXIS = {"X": 0, "Y": 1, "Z": 2}
UP = [0, 1, 0]


def det3(m):
    """3×3 整数行列式。"""
    return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))


def col(m, j):
    return [m[0][j], m[1][j], m[2][j]]


def mat_vec(m, v):
    return [sum(m[i][j] * v[j] for j in range(3)) for i in range(3)]


def cm_to_m(cm):
    """整数地板除:Python ``//`` 对负数向负无穷。"""
    return cm // 100


def is_signed_permutation(m):
    """每行每列恰好一个非零元素,且该元素是 +1 或 -1。"""
    for row in m:
        if sum(1 for x in row if x != 0) != 1 or any(abs(x) > 1 for x in row):
            return False
    return all(sum(1 for i in range(3) if m[i][j] != 0) == 1 for j in range(3))


def audit(conv, probes):
    m, d = conv["matrix"], det3(conv["matrix"])
    errs = []
    if not is_signed_permutation(m):
        errs.append("E_MATRIX: 不是带符号置换矩阵")
    if col(m, AXIS[conv["upAxis"]]) != UP:
        errs.append(f"E_UP: upAxis {conv['upAxis']} 未映射到 (0,1,0)")
    want = "right" if d > 0 else "left"
    if conv["handedness"] != want:
        errs.append(f"E_HAND: 自报 {conv['handedness']},矩阵 det={d} 推出 {want}")
    rows = [{"cm": p, "m": [cm_to_m(c) for c in p],
             "canonical": mat_vec(m, [cm_to_m(c) for c in p])} for p in probes]
    return {"id": conv["id"], "upAxis": conv["upAxis"], "handedness": conv["handedness"],
            "det": d, "mirror": d < 0, "windingFlip": d < 0, "errors": errs, "probes": rows}


def main():
    cfg = json.load(open(CFG, encoding="utf-8"))
    results = [audit(c, cfg["probes"]) for c in cfg["conventions"]]
    errs = [f"{r['id']}: {e}" for r in results for e in r["errors"]]
    distinct = {json.dumps(c["matrix"]) for c in cfg["conventions"]}
    os.makedirs(OUT, exist_ok=True)
    summary = {"project": "stardust-inn", "canonical": cfg["canonical"],
               "unitScale": cfg["unitScale"], "mmPerCm": cfg["mmPerCm"],
               "conventionCount": len(cfg["conventions"]), "distinctMatrices": len(distinct),
               "mirrorCount": sum(1 for r in results if r["mirror"]), "results": results}
    with open(os.path.join(OUT, "ta02_units.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    with open(os.path.join(OUT, "ta02_units.csv"), "w", encoding="utf-8", newline="") as f:
        wtr = csv.writer(f)
        wtr.writerow(["id", "det", "mirror", "probeCm", "probeM", "canonical"])
        for r in results:
            for p in r["probes"]:
                wtr.writerow([r["id"], r["det"], int(r["mirror"]), p["cm"], p["m"], p["canonical"]])
    print(f"conventions={len(results)} distinctMatrices={len(distinct)} "
          f"mirror={summary['mirrorCount']} errors={len(errs)}")
    for r in results:
        print(f"  {r['id']:8s} det={r['det']:+d} mirror={int(r['mirror'])} "
              f"probe0 {r['probes'][0]['cm']}cm -> {r['probes'][0]['m']}m -> {r['probes'][0]['canonical']}")
    for e in errs:
        print(f"FAIL {e}")
    print("wrote out/ta02_units.json + ta02_units.csv")
    return 1 if errs else 0


if __name__ == "__main__":
    sys.exit(main())

脚本里有三处刻意的整数选择。第一,厘米转米用 cm // 100 而不是 round 或截断:Python 的 // 对负数向负无穷取整,-50 // 100 == -1,而截断会给出 0——原点两侧的坐标会得到不同答案,这是把整数地板除写进纪律的直接原因。第二,行列式用整数展开式算,不引入浮点,det 只可能是 +1 或 -1。第三,is_signed_permutation 要求每行每列恰好一个非零元素且元素 ∈ {−1, +1},这拦得住「手写矩阵时把两个轴都缩放了」这类错误。

2.2.4 P 层:同一份表的 TS 侧与测试

同一份对齐表,TypeScript 侧独立实现一遍。它只读同一份 JSON,不 import Python 的任何东西:

// file: core/src/ta02/align.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 单位 / 轴向 / 手性对齐的 TS 侧实现:与 sim/ta02_units.py 共用 config/ta02_axis_align.json。
// 矩阵元素只取 -1/0/1、单位换算用整数地板除,故两侧逐位一致。只允许可擦除 TS 语法。

export type Convention = { id: string; upAxis: string; handedness: string; matrix: number[][] };
export const AXIS: Record<string, number> = { X: 0, Y: 1, Z: 2 };
export const UP = [0, 1, 0];

/** 3×3 整数行列式。 */
export function det3(m: number[][]): number {
  return (
    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
    m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
    m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
  );
}

/** 取第 j 列。 */
export function col(m: number[][], j: number): number[] {
  return [m[0][j], m[1][j], m[2][j]];
}

/** 整数矩阵 × 整数向量。 */
export function matVec(m: number[][], v: number[]): number[] {
  return [0, 1, 2].map((i) => m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]);
}

/** 厘米 → 米:整数地板除。**必须用 Math.floor**,Math.trunc 对负数会给出不同的整数。 */
export function cmToM(cm: number): number {
  return Math.floor(cm / 100);
}

/** 带符号置换矩阵:每行每列恰好一个非零元素,且元素 ∈ {-1, +1}。 */
export function isSignedPermutation(m: number[][]): boolean {
  for (const row of m) {
    if (row.filter((x) => x !== 0).length !== 1 || row.some((x) => Math.abs(x) > 1)) return false;
  }
  return [0, 1, 2].every((j) => [0, 1, 2].filter((i) => m[i][j] !== 0).length === 1);
}

/** 校验一条约定自报的 upAxis / handedness 与矩阵是否自洽。 */
export function auditConvention(c: Convention): string[] {
  const errs: string[] = [];
  const d = det3(c.matrix);
  if (!isSignedPermutation(c.matrix)) errs.push("E_MATRIX: 不是带符号置换矩阵");
  if (col(c.matrix, AXIS[c.upAxis]).join(",") !== UP.join(",")) {
    errs.push(`E_UP: upAxis ${c.upAxis} 未映射到 (0,1,0)`);
  }
  const want = d > 0 ? "right" : "left";
  if (c.handedness !== want) errs.push(`E_HAND: 自报 ${c.handedness},矩阵 det=${d} 推出 ${want}`);
  return errs;
}

/** 一条约定的对齐结果:行列式 / 是否镜像 / 是否需翻转绕序 / 探针换算。 */
export function alignConvention(c: Convention, probes: number[][]) {
  const d = det3(c.matrix);
  return {
    id: c.id, det: d, mirror: d < 0, windingFlip: d < 0, errors: auditConvention(c),
    probes: probes.map((p) => {
      const m = p.map(cmToM);
      return { cm: p, m, canonical: matVec(c.matrix, m) };
    }),
  };
}

测试读 Python 落盘的 JSON 做跨语言对照,并带四条负例断言:

// file: core/test/ta02_align.test.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 跨语言对照:读 sim/ta02_units.py 落盘的 out/ta02_units.json,断言 TS 侧复算的单位/轴向/
// 手性账与之一致(整数类,逐位一致),并带负例断言:镜像、错手性、非置换矩阵必须被判出来。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { alignConvention, cmToM, det3, isSignedPermutation } from "../src/ta02/align.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta02_axis_align.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta02_units.json"), "utf8"));
const mine = cfg.conventions.map((c: object) => alignConvention(c, cfg.probes));
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
const line = (r: { id: string; det: number; probes: { cm: number[]; m: number[]; canonical: number[] }[] }): string =>
  `${r.id}:${r.det}:` + r.probes.map((p) => `${p.cm.join("/")}->${p.m.join("/")}->${p.canonical.join("/")}`).join(";");

test("跨语言:TS 对齐账 == Python 对齐账(逐位一致)", () => {
  assert.deepEqual(mine.map(line), py.results.map(line));
  assert.deepEqual(mine.map((r) => r.mirror), py.results.map((r: { mirror: boolean }) => r.mirror));
  assert.equal(digest(mine.map(line)), digest(py.results.map(line)));
  assert.equal(py.conventionCount, cfg.conventions.length);
  console.log(`axis align digest = ${digest(mine.map(line))}`);
});

test("负例断言:镜像约定(左手系)必须被判出来并要求翻转绕序", () => {
  const mirrored = mine.filter((r) => r.mirror).map((r) => r.id).sort();
  assert.deepEqual(mirrored, ["unity", "unreal"]);
  for (const r of mine) assert.equal(r.windingFlip, r.mirror);
});

test("负例断言:单位换算是地板除,负数不能截断成 0", () => {
  assert.equal(cmToM(-50), -1);
  assert.notEqual(cmToM(-50), Math.trunc(-50 / 100));
  assert.deepEqual(cfg.probes[1].map(cmToM), [-1, 0, 2]);
});

test("负例断言:非置换矩阵 / 错手性 / 错 upAxis 都必须报错", () => {
  assert.ok(isSignedPermutation(cfg.conventions[0].matrix));
  assert.ok(!isSignedPermutation([[2, 0, 0], [0, 1, 0], [0, 0, 1]]));
  // det = -1 却自报右手,且自报 upAxis=Z 但第 2 列是 (0,0,-1):两条都要报
  const bad = alignConvention(
    { id: "bad", upAxis: "Z", handedness: "right", matrix: [[1, 0, 0], [0, 1, 0], [0, 0, -1]] }, []);
  assert.equal(bad.errors.length, 2);
  assert.ok(bad.errors.some((e) => e.startsWith("E_UP")));
  assert.ok(bad.errors.some((e) => e.startsWith("E_HAND")));
  assert.equal(det3([[1, 0, 0], [0, 0, 1], [0, -1, 0]]), 1);
});

2.2.5 实跑输出与符号翻转表

S 层 /opt/homebrew/bin/python3 sim/ta02_units.py:

conventions=6 distinctMatrices=4 mirror=2 errors=0
  blender  det=+1 mirror=0 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 3, -2]
  max      det=+1 mirror=0 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 3, -2]
  maya     det=+1 mirror=0 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 2, 3]
  gltf     det=+1 mirror=0 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 2, 3]
  unity    det=-1 mirror=1 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 2, -3]
  unreal   det=-1 mirror=1 probe0 [100, 200, 300]cm -> [1, 2, 3]m -> [1, 3, 2]
wrote out/ta02_units.json + ta02_units.csv

P 层 node --test 'core/test/ta02_*.test.ts':pass 11 / fail 0。把每条约定的 id:det:探针链 排序拼接后取 sha256 前 16 位,两侧算出的 digest 都是 6b57dada9bafb264——单位倍率、矩阵元素与坐标都是整数,逐位一致。

六个约定只落成 4 个互不相同的矩阵(Blender 与 3ds Max 相同,Maya 与 glTF 相同),其中 2 个是镜像。up 轴与符号翻转表(各 DCC 的 up 轴与手性是公开文档口径,非本机实测——本机无任何 DCC):

约定up 轴局部 X →局部 Y →局部 Z →det
Blender / 3ds MaxZ+X−Z+Y+1
Maya / glTFY+X+Y+Z+1
UnityY+X+Y−Z−1
UnrealZ+X+Z+Y−1

单位换算那一列平平无奇,但有一处必须看清:探针 [-50, 0, 250] 厘米换出来是 [-1, 0, 2] 米——−50 厘米是 −1 米,不是 0 米。如果换算是用截断写的,负坐标会被拉向原点,模型在原点附近被压扁;而这类错误在正坐标的测试用例里永远测不出来,所以探针里必须有一条负坐标。

2.2.6 镜像不是「转一下」,是「翻一面」

det = −1 的约定(Unity、Unreal)不能靠旋转对齐到右手系,只能镜像。镜像的后果是两个:三角面的绕序反了(背面剔除会把模型啃掉),法线的符号也要跟着翻。所以镜像约定下导出的资产,必须显式做一次「翻转绕序 + 翻转法线」的补偿;脚本里 windingFlip 字段就是给下游的这条指令。反过来,det = +1 的约定(Blender、3ds Max、Maya)导到规范坐标只需要置换与符号,不需要任何补偿——这也是为什么很多团队宁愿统一到 Blender 或 Maya 出资产:它们的 det 是 +1,少一类要单独排查的事故。

下一节换一个方向:前面两节都在算「能算的部分」,而管线上还有整整一个阶段算不了——真打开 DCC、真导出。下一节把它写成方法,而不是写成借口。

阅读导航:上一节:2.1 资产管线四阶段:DCC → 导出 → 导入 → 运行时 · 下一节:2.3 X 范式:真实 DCC 不可复现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则