《游戏引擎实现原理》11.3 资产浏览器与关卡编辑

资产浏览器要把引擎眼里的扁平「路径→资产」解释成一棵可展开的树,关卡编辑则把《微光岛》的关卡数据装进编辑器并校验引用。本节实现资产树构建、类型判定、稳定 GUID、拖放载荷编解码与关卡加载,并在本机真跑 13 条 vitest 用例;拖放交互属 L4,需在浏览器中手动验证。

11.3 资产浏览器与关卡编辑

11.1 与 11.2 让视口能选中、能渲染。这一节补上编辑器另一块常用面板——资产浏览器,以及它最终服务的动作:把关卡数据装进编辑器。引擎眼里的资产是扁平的「路径 → 资源」,浏览器却要把它摆成一棵可展开的树;关卡文件又用路径引用这些资产,加载时必须逐条核对引用是否还在。

这也和站内的既有内容分工明确:资产管线怎么导入、怎么缓存,Defold 编辑器资产管线 与 glTF 资产管线 已经讲过;这一节只做编辑器侧的视图与加载。

从扁平路径到资产树

引擎的资产系统只认路径;浏览器的树是视图,不是新的真相来源。buildAssetTree 把路径列表建成树:中间目录自动补成 folder、同名目录只建一次、插入后按「目录在前」排序,保证同一批输入永远长出同一棵树。每个节点带一个由路径算出的 FNV-1a GUID——同一路径永远同值,跨会话稳定。

下面 src/editor/assets/asset-tree.ts 层级 L1,完整可编译。它含类型判定、GUID、建树、展平、按路径查找,以及拖放载荷的编解码。

// file: src/editor/assets/asset-tree.ts
// tier: L1
// verify: npx vitest run src/editor/assets/asset-tree.test.ts
//
// helio 编辑器 11.3:资产浏览器背后的数据模型。
// 引擎只知道扁平的「路径 → 资产」;浏览器要把它解释成一棵可展开的树。
// 这里把路径列表建成树、按扩展名判类型、用 FNV-1a 给每个资产算稳定 GUID,
// 再提供拖放载荷的编解码。纯数据,Node 26 直跑。

export type AssetKind = 'folder' | 'texture' | 'model' | 'scene' | 'script' | 'audio';

export interface AssetNode {
  readonly id: string;
  readonly name: string;
  readonly path: string;
  readonly kind: AssetKind;
  readonly children: AssetNode[];
}

/** 扩展名 → 资产类型。未登记的一律当作 script(文本)。 */
const EXT_KIND: Readonly<Record<string, AssetKind>> = {
  png: 'texture', jpg: 'texture', ktx2: 'texture',
  gltf: 'model', glb: 'model',
  level: 'scene',
  ts: 'script',
  wav: 'audio', ogg: 'audio',
};

/** 资产类型判定:取最后一个扩展名,未知扩展名回落为 script。 */
export function kindOf(name: string): AssetKind {
  const dot = name.lastIndexOf('.');
  if (dot < 0) return 'folder';
  return EXT_KIND[name.slice(dot + 1).toLowerCase()] ?? 'script';
}

/** FNV-1a 32 位哈希,输出 8 位十六进制——资产的稳定 GUID(同一路径永远同值)。 */
export function guidOf(path: string): string {
  let h = 0x811c9dc5;
  for (let i = 0; i < path.length; i++) {
    h ^= path.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h.toString(16).padStart(8, '0');
}

/**
 * 把扁平路径列表建成资产树。中间目录自动补成 folder 节点,
 * 同名目录只建一次;插入时按名字排序,保证树是确定的。
 */
export function buildAssetTree(paths: readonly string[], rootName = 'assets'): AssetNode {
  const root: AssetNode = { id: guidOf(rootName), name: rootName, path: rootName, kind: 'folder', children: [] };
  for (const p of paths) {
    const segs = p.split('/').filter((s) => s.length > 0);
    let cur = root;
    let acc = rootName;
    for (let i = 0; i < segs.length; i++) {
      const seg = segs[i]!;
      const isLeaf = i === segs.length - 1;
      acc = `${acc}/${seg}`;
      let child = cur.children.find((c) => c.name === seg);
      if (!child) {
        child = {
          id: guidOf(acc), name: seg, path: acc,
          kind: isLeaf ? kindOf(seg) : 'folder', children: [],
        };
        cur.children.push(child);
      }
      cur = child;
    }
  }
  sortTree(root);
  return root;
}

function sortTree(n: AssetNode): void {
  n.children.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'folder' ? -1 : 1));
  for (const c of n.children) sortTree(c);
}

/** 深度优先展平成列表(目录在前),供列表控件直接消费。 */
export function flattenTree(root: AssetNode): AssetNode[] {
  const out: AssetNode[] = [];
  const walk = (n: AssetNode): void => { out.push(n); for (const c of n.children) walk(c); };
  walk(root);
  return out;
}

/** 按路径查节点;找不到返回 null。 */
export function findNode(root: AssetNode, path: string): AssetNode | null {
  for (const n of flattenTree(root)) if (n.path === path) return n;
  return null;
}

/** 拖放载荷:带一个私有 MIME 前缀的 JSON,浏览器靠它区分「资产拖拽」与外部拖入。 */
export const ASSET_MIME = 'application/x-helio-asset';

export function encodeDragPayload(ids: readonly string[]): string {
  return `${ASSET_MIME}:${JSON.stringify(ids)}`;
}

/** 解析拖放载荷:前缀不符或 JSON 坏掉都返回空数组(当作外部拖入)。 */
export function decodeDragPayload(text: string): string[] {
  if (!text.startsWith(`${ASSET_MIME}:`)) return [];
  try {
    const v: unknown = JSON.parse(text.slice(ASSET_MIME.length + 1));
    if (!Array.isArray(v)) return [];
    return v.filter((x): x is string => typeof x === 'string');
  } catch {
    return [];
  }
}

为什么用哈希而不是自增 ID?因为资产是磁盘上的东西,重开编辑器、换台机器,路径没变,GUID 就该没变。自增 ID 每次重新枚举都会错位,关卡文件里存的引用就全废了。哈希碰撞概率在几万个资产的量级可以忽略;真要保险,可以在生成时做一次去重校验。

可运行产物:树、GUID 与拖放载荷的真跑

下面 src/editor/assets/asset-tree.test.ts 层级 L1,覆盖类型判定、GUID 稳定性、树的建法、拖放载荷编解码,以及下一小节的关卡加载。挑几条关键断言看:

// snippet: src/editor/assets/asset-tree.test.ts 节选
expect(kindOf('island.gltf')).toBe('model');
expect(kindOf('notes.txt')).toBe('script'); // 未知扩展名回落 script

// 同一路径永远同值,不同路径不同值
expect(guidOf('assets/art/island.gltf')).toBe(guidOf('assets/art/island.gltf'));
expect(guidOf('a')).not.toBe(guidOf('b'));

// 中间目录自动补成 folder,且只建一次
expect(findNode(root, 'assets/art/rocks')?.children.map((c) => c.name))
  .toEqual(['large.gltf', 'small.gltf']);

// 拖放载荷往返;外部拖入(无私有前缀)返回空数组
expect(decodeDragPayload(encodeDragPayload(['abc12345']))).toEqual(['abc12345']);
expect(decodeDragPayload('随便一段文字')).toEqual([]);

// 关卡加载:引用缺失的网格被收进 missing,其余照常加载
expect(missing).toEqual(['assets/art/missing.gltf']);

本机 npx vitest run src/editor/assets/asset-tree.test.ts:13 个用例全过(tsc --noEmit 零错误)。另一处真跑:对着一棵含 5 个资产的树,guidOf('assets/art/island.gltf') 稳定输出 355e1531;展平后节点数是 1 + 5 + 4——根 + 5 个文件 + 4 个目录(art、art/rocks、audio、scripts)。

关卡加载:把《微光岛》装进编辑器

有了资产树,就能加载关卡了。src/editor/assets/level-loader.ts 层级 L1:解析关卡 JSON,逐条对资产树校验 mesh 引用;形状不对直接抛(编辑器弹错误条),引用缺失不致命,收进 missing 让界面标红。这正好接上 8.3 场景序列化格式 定下的关卡文件形状。

// file: src/editor/assets/level-loader.ts
// tier: L1
// verify: npx vitest run src/editor/assets/asset-tree.test.ts
//
// helio 编辑器 11.3:把《微光岛》的关卡数据装进编辑器。
// 关卡文件是一段 JSON:一批实体,每个实体用资产路径引用网格。加载时校验
// 每个引用在资产树里确实存在,缺的收集起来交给界面提示。纯数据,Node 26 直跑。

import type { Vec3 } from '../../core/vec3.ts';
import { findNode, type AssetNode } from './asset-tree.ts';

export interface LevelEntity {
  readonly id: string;
  readonly name: string;
  readonly mesh: string;
  readonly position: Vec3;
}

export interface LevelDocument {
  readonly name: string;
  readonly entities: LevelEntity[];
}

export interface LoadResult {
  readonly document: LevelDocument;
  readonly missing: string[];
}

function asVec3(v: unknown): Vec3 {
  if (!Array.isArray(v) || v.length !== 3 || v.some((n) => typeof n !== 'number')) {
    throw new Error('position 必须是三个数字');
  }
  return [v[0] as number, v[1] as number, v[2] as number];
}

/**
 * 解析关卡 JSON 并对着资产树校验引用。形状不对直接抛(编辑器会弹错误条);
 * 引用缺失不算致命,收进 missing 让界面标红。
 */
export function parseLevel(raw: unknown, root: AssetNode): LoadResult {
  if (typeof raw !== 'object' || raw === null) throw new Error('关卡根必须是对象');
  const obj = raw as { name?: unknown; entities?: unknown };
  if (typeof obj.name !== 'string') throw new Error('关卡缺少 name');
  if (!Array.isArray(obj.entities)) throw new Error('关卡缺少 entities 数组');
  const entities: LevelEntity[] = [];
  const missing: string[] = [];
  for (const e of obj.entities) {
    if (typeof e !== 'object' || e === null) throw new Error('实体必须是对象');
    const ent = e as { id?: unknown; name?: unknown; mesh?: unknown; position?: unknown };
    if (typeof ent.id !== 'string' || typeof ent.name !== 'string' || typeof ent.mesh !== 'string') {
      throw new Error('实体缺少 id/name/mesh');
    }
    const position = asVec3(ent.position);
    entities.push({ id: ent.id, name: ent.name, mesh: ent.mesh, position });
    if (!findNode(root, ent.mesh)) missing.push(ent.mesh);
  }
  return { document: { name: obj.name, entities }, missing };
}

/** 《微光岛》关卡的最小样例,用作真跑输入与文档示例。 */
export const GLIMMER_ISLE_SAMPLE = {
  name: 'glimmer-isle',
  entities: [
    { id: 'e1', name: 'island', mesh: 'assets/art/island.gltf', position: [0, 0, 0] },
    { id: 'e2', name: 'rockR', mesh: 'assets/art/rocks/small.gltf', position: [3, 0, -2] },
  ],
} as const;

文件底部那份 GLIMMER_ISLE_SAMPLE 就是《微光岛》的最小关卡:两个实体,island 与 rockR,各自用路径引用网格。本机对着一棵含 5 个资产的树加载它,真实输出是 level glimmer-isle entities 2 missing 0——两个引用都在。把其中一个 mesh 改成不存在的路径,missing 会变成 ['assets/art/missing.gltf'],而实体本身照常加载——加载不该因为一个坏引用就整个失败。

拖放:L4,命令行不可复现

资产浏览器最常见的动作是把资产拖进视口生成实体。src/editor/assets/drag-drop.ts 层级 L4——依赖 DataTransfer、DragEvent,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。

// snippet: src/editor/assets/drag-drop.ts 的落点求解
const onDropEv = (e: DragEvent): void => {
  const ids = decodeDragPayload(e.dataTransfer?.getData(ASSET_MIME) ?? '');
  if (ids.length === 0) return;           // 外部拖入:直接忽略
  e.preventDefault();
  const rect = canvas.getBoundingClientRect();
  const ray = pickRay(getCamera(), e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height);
  const t = rayPlane(ray, [0, 0, 0], [0, 1, 0]); // 投到 y=0 地面
  const at: Vec3 = t === null ? [0, 0, 0] : [ray.origin[0] + ray.dir[0] * t, 0, ray.origin[2] + ray.dir[2] * t];
  onDrop(ids, at);
};

私有 MIME(application/x-helio-asset)是为了区分「内部资产拖拽」与「外部文件拖入」——只有前者才该被解析,后者交给别的处理链。落点用的正是 11.1 的 pickRay/rayPlane:把鼠标位置变成射线,投到 y=0 地面,得到一个世界坐标。

小结

  • 资产树是视图不是真相:引擎只认路径,树由路径列表建成,排序确定。
  • GUID 用路径哈希(FNV-1a),跨会话稳定;自增 ID 会在重新枚举时错位。
  • 关卡加载对着资产树校验引用:形状错直接抛,引用缺失收进 missing,不阻断加载。
  • 拖放是 L4,需在浏览器中手动验证;私有 MIME 区分内部拖拽与外部拖入。

全书到这里收口。卷三从资源、动画、物理、音频、输入、脚本一路走到编辑器:数据模型、命令栈、属性面板、视口与资产。每一节的代码都在本机真跑过——L1 有 vitest、L2 有 headless Chromium、L3/L4 明确标注不可命令行复现。这正是本卷的取舍:能把逻辑压到 L1 的,就用真跑的用例钉死;压不下去的,就诚实地说清楚它在哪一层。

阅读导航:上一节:11.2 场景视口与渲染接入 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径