《游戏引擎实现原理》3.3 断言、日志分级与配置系统 CVar

内核要能在出错时立刻喊停、在发布后按需静音、在启动时被命令行改参数。本节用 TypeScript 从零实现 helio 的三件基础设施:分 debug/release 两档的 assert 与永远生效的 check、五级加分类过滤的 Logger、带类型化 get/set 与命令行解析的 CVarSystem,并在 Node 里真跑验证。

3.3 断言、日志分级与配置系统 CVar

内核代码里到处是「这个指针不该为空」「这个索引不该越界」这类假设。把假设写成注释,出错时没人看得见;写成 if (!x) console.log('oops'),发布版里又白白拖慢热路径。这一节把内核的三种「运行时反馈」从零做出来:断言、日志、配置。

断言与日志:两档行为 + 五级过滤

assert 与 check 的分工是本节的核心设计:assert 只在 debug 构建生效,release 下直接返回不抛错——它是给开发者的护栏;check 在所有构建档都生效,用来兜住「真出了就是 bug」的硬约束。注意一个 TypeScript 特有的坑:C++ 的 assert 是宏,release 下连条件都不求值;TS 没有宏,condition 是普通实参,调用点一定会先把它求值一次,想让昂贵表达式在 release 下彻底不跑,得自己用 if 包起来。

日志侧实现五级 trace/debug/info/warn/error,并且是双重过滤:低于最低级别的丢弃,命中被静音分类的也丢弃。下面 src/core/diagnostics.ts 层级 L1,完整可编译。

// file: src/core/diagnostics.ts
// tier: L1
// verify: npx vitest run src/core/diagnostics.test.ts
//
// helio 内核:断言 + 日志分级。
// assert 只在 debug 生效,check 永远生效;日志五级,按最低级别与分类双重过滤。

export type BuildMode = 'debug' | 'release';

let buildMode: BuildMode = 'debug';
export function setBuildMode(mode: BuildMode): void {
  buildMode = mode;
}

export class AssertionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AssertionError';
  }
}

/**
 * debug 专属断言;release 下直接返回不抛错。
 * 注意:TS 无宏,condition 是普通实参,调用点仍会求值它一次。
 */
export function assert(condition: boolean, message: string): void {
  if (buildMode === 'debug' && !condition) throw new AssertionError(message);
}

/** 所有构建档都生效的致命检查。 */
export function check(condition: boolean, message: string): void {
  if (!condition) throw new AssertionError(message);
}

/** 日志级别表,数值越大越严重。 */
export const LogLevel = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 } as const;
export type LogLevelName = keyof typeof LogLevel;
export interface LogRecord {
  level: LogLevelName;
  category: string;
  message: string;
}
export type LogSink = (record: LogRecord) => void;

/** 五级 + 分类过滤的日志器。 */
export class Logger {
  #min: LogLevelName = 'info';
  readonly #muted = new Set<string>();
  readonly #sink: LogSink;

  constructor(sink: LogSink) {
    this.#sink = sink;
  }

  setMinLevel(level: LogLevelName): void {
    this.#min = level;
  }
  /** 关掉某个分类(如 'asset')的全部日志。 */
  mute(category: string): void {
    this.#muted.add(category);
  }
  unmute(category: string): void {
    this.#muted.delete(category);
  }

  #emit(level: LogLevelName, category: string, message: string): void {
    if (LogLevel[level] < LogLevel[this.#min] || this.#muted.has(category)) return;
    this.#sink({ level, category, message });
  }

  trace(category: string, message: string): void { this.#emit('trace', category, message); }
  debug(category: string, message: string): void { this.#emit('debug', category, message); }
  info(category: string, message: string): void { this.#emit('info', category, message); }
  warn(category: string, message: string): void { this.#emit('warn', category, message); }
  error(category: string, message: string): void { this.#emit('error', category, message); }
}

日志器不直接写 console,而是把 LogSink 注入进来:测试里换成收集数组,线上换成写文件或转发远端——这也是上一节 PAL 的 PlatformLog 能接进来的地方。

配置系统 CVar:类型化读写与命令行解析

CVar(Console Variable)是「能被命令行改的引擎参数」。它的价值在于类型:r.shadowQuality 是整数,r.vsync 是布尔,game.timeScale 是浮点。注册时就绑定类型,之后 getNumber / getBool 各取所需;类型对不上直接抛错,绝不静默返回错类型——这类静默错误在引擎里往往表现为「渲染莫名变黑」,极难查。

命令行解析支持 name=value 与 +name=value 两种写法,返回值分 applied / rejected 两组,非法项不会被悄悄吞掉。下面 src/core/cvar.ts 层级 L1。

// file: src/core/cvar.ts
// tier: L1
// verify: npx vitest run src/core/cvar.test.ts
//
// helio 内核:配置系统 CVar。带类型定义、类型化 get/set、命令行字符串解析。

export const CVarType = { bool: 'bool', int: 'int', float: 'float', string: 'string' } as const;
export type CVarTypeName = (typeof CVarType)[keyof typeof CVarType];

/** 命令行字符串 → 强类型值;解析失败返回 undefined(由调用方拒绝)。 */
export function parseCVarValue(
  type: CVarTypeName,
  raw: string,
): string | number | boolean | undefined {
  if (type === CVarType.bool) {
    if (raw === 'true' || raw === '1') return true;
    if (raw === 'false' || raw === '0') return false;
    return undefined;
  }
  if (type === CVarType.int) return /^[+-]?\d+$/.test(raw) ? Number.parseInt(raw, 10) : undefined;
  if (type === CVarType.float) {
    const n = Number(raw);
    return Number.isFinite(n) ? n : undefined;
  }
  return raw;
}

export class CVarSystem {
  readonly #types = new Map<string, CVarTypeName>();
  readonly #values = new Map<string, string | number | boolean>();

  register(name: string, type: CVarTypeName, def: string | number | boolean): void {
    if (this.#types.has(name)) throw new Error(`cvar already registered: ${name}`);
    if (parseCVarValue(type, String(def)) === undefined) {
      throw new Error(`cvar ${name} default ${String(def)} does not match type ${type}`);
    }
    this.#types.set(name, type);
    this.#values.set(name, def);
  }

  #type(name: string): CVarTypeName {
    const t = this.#types.get(name);
    if (t === undefined) throw new Error(`unknown cvar: ${name}`);
    return t;
  }

  /** 类型化读取:类型不匹配直接抛错,绝不静默返回错类型。 */
  getNumber(name: string): number {
    const t = this.#type(name);
    if (t !== CVarType.int && t !== CVarType.float) {
      throw new Error(`cvar ${name} is ${t}, not numeric`);
    }
    return this.#values.get(name) as number;
  }

  getBool(name: string): boolean {
    const t = this.#type(name);
    if (t !== CVarType.bool) throw new Error(`cvar ${name} is ${t}, not bool`);
    return this.#values.get(name) as boolean;
  }

  getString(name: string): string {
    this.#type(name);
    return String(this.#values.get(name));
  }

  /** 字符串赋值:类型解析失败直接拒绝并返回 false,旧值不变。 */
  setFromString(name: string, raw: string): boolean {
    const t = this.#types.get(name);
    if (t === undefined) return false;
    const parsed = parseCVarValue(t, raw);
    if (parsed === undefined) return false;
    this.#values.set(name, parsed);
    return true;
  }

  /** 解析一行命令行,返回 applied / rejected 两组 cvar 名。 */
  applyCommandLine(line: string): { applied: string[]; rejected: string[] } {
    const applied: string[] = [];
    const rejected: string[] = [];
    for (const token of line.split(/\s+/).filter((t) => t.length > 0)) {
      const bare = token.startsWith('+') ? token.slice(1) : token;
      const eq = bare.indexOf('=');
      if (eq < 0) continue;
      const name = bare.slice(0, eq);
      if (this.setFromString(name, bare.slice(eq + 1))) applied.push(name);
      else rejected.push(token);
    }
    return { applied, rejected };
  }
}

parseCVarValue 是唯一的「字符串 → 值」入口:register 校验默认值、setFromString 校验命令行输入、applyCommandLine 校验每个 token,三条路径共用一套解析。

可运行产物:真跑断言与过滤

src/core/diagnostics.test.ts 与 src/core/cvar.test.ts 分别验日志级别过滤生效、非法 CVar 值被拒且旧值保留,层级均为 L1。

// file: src/core/diagnostics.test.ts
// tier: L1
// verify: npx vitest run src/core/diagnostics.test.ts
import { afterEach, describe, expect, it } from 'vitest';
import { AssertionError, Logger, assert, check, setBuildMode } from './diagnostics.ts';
import type { LogRecord } from './diagnostics.ts';

afterEach(() => setBuildMode('debug'));

describe('assert vs check', () => {
  it('debug 下 assert 失败抛 AssertionError', () => {
    setBuildMode('debug');
    expect(() => assert(1 + 1 === 3, 'math is broken')).toThrow(AssertionError);
  });

  it('release 下 assert 直接返回不抛错', () => {
    setBuildMode('release');
    expect(() => assert(false, 'skipped')).not.toThrow();
  });

  it('check 在任何构建档都生效', () => {
    setBuildMode('release');
    expect(() => check(false, 'always fatal')).toThrow(/always fatal/);
  });
});

describe('Logger', () => {
  function collect(): { records: LogRecord[]; sink: (r: LogRecord) => void } {
    const records: LogRecord[] = [];
    return { records, sink: (r) => records.push(r) };
  }

  it('默认 info 级别过滤掉 trace/debug', () => {
    const { records, sink } = collect();
    const log = new Logger(sink);
    log.trace('core', 't');
    log.debug('core', 'd');
    log.info('core', 'i');
    log.warn('core', 'w');
    log.error('core', 'e');
    expect(records.map((r) => r.level)).toEqual(['info', 'warn', 'error']);
  });

  it('分类 mute 后该分类完全静默', () => {
    const { records, sink } = collect();
    const log = new Logger(sink);
    log.mute('asset');
    log.error('asset', 'hidden');
    log.error('render', 'shown');
    expect(records.map((r) => r.category)).toEqual(['render']);
    log.unmute('asset');
    log.error('asset', 'back');
    expect(records.at(-1)?.category).toBe('asset');
  });
});
// file: src/core/cvar.test.ts
// tier: L1
// verify: npx vitest run src/core/cvar.test.ts
import { describe, expect, it } from 'vitest';
import { CVarSystem } from './cvar.ts';

function makeSystem(): CVarSystem {
  const c = new CVarSystem();
  c.register('r.vsync', 'bool', false);
  c.register('r.shadowQuality', 'int', 2);
  c.register('game.timeScale', 'float', 1.0);
  c.register('game.project', 'string', 'glimmer-isle');
  return c;
}

describe('CVarSystem', () => {
  it('注册后读到默认值', () => {
    const c = makeSystem();
    expect(c.getBool('r.vsync')).toBe(false);
    expect(c.getNumber('r.shadowQuality')).toBe(2);
    expect(c.getString('game.project')).toBe('glimmer-isle');
  });

  it('非法值被拒绝,旧值保留', () => {
    const c = makeSystem();
    expect(c.setFromString('r.shadowQuality', '3')).toBe(true);
    expect(c.setFromString('r.shadowQuality', 'ultra')).toBe(false);
    expect(c.setFromString('r.shadowQuality', '2.5')).toBe(false);
    expect(c.getNumber('r.shadowQuality')).toBe(3);
  });

  it('类型不匹配的 get 抛错', () => {
    const c = makeSystem();
    expect(() => c.getBool('r.shadowQuality')).toThrow(/is int, not bool/);
    expect(() => c.getNumber('game.project')).toThrow(/not numeric/);
  });

  it('未知 cvar 读取抛错、赋值返回 false', () => {
    const c = makeSystem();
    expect(() => c.getNumber('nope')).toThrow(/unknown cvar/);
    expect(c.setFromString('nope', '1')).toBe(false);
  });

  it('解析命令行并区分 applied / rejected', () => {
    const c = makeSystem();
    const r = c.applyCommandLine('r.vsync=true r.shadowQuality=4 game.timeScale=0.5 r.nope=1');
    expect(r.applied).toEqual(['r.vsync', 'r.shadowQuality', 'game.timeScale']);
    expect(r.rejected).toEqual(['r.nope=1']);
    expect(c.getBool('r.vsync')).toBe(true);
    expect(c.getNumber('game.timeScale')).toBeCloseTo(0.5);
  });
});

本机实跑:Test Files 4 passed、Tests 22 passed(本节贡献 10 条:断言 3 + 日志 2 + CVar 5),tsc --noEmit 零错误。「非法值被拒绝,旧值保留」尤其关键——r.shadowQuality 设为 3 后,再喂 ultra 和 2.5 都被拒,读回来仍是 3,证明拒绝是原子的。

小结

  • assert 分 debug/release 两档,check 永远生效;TS 无宏,release 下实参仍会被求值一次。
  • 日志五级 + 分类静音双重过滤,输出走注入的 LogSink,可对接 PAL 的平台日志。
  • CVar 注册时绑定类型,get 类型不匹配即抛错,set 解析失败即拒绝且旧值不变;三件设施合起来就是内核的「可观测面」。

下一章进入内存:为什么引擎宁可自己管内存,也不交给 JS 垃圾回收。

阅读导航:上一节:3.2 PAL 平台抽象层 · 下一节:4.1 为什么要自己管内存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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