《游戏引擎实现原理》5.2 特性开关与死代码消除

边界清楚了,下一步是让不该进包的模块根本不进包。本节用构建期常量开关把阴影、后处理、2D 叠层整块摇掉,并用 rolldown 实测四种开关组合的产物体积:全开 1140 字节,关掉 2D 叠层 903,只留阴影 515,移动端最小包 80 字节,整体降幅 93.0%。

5.2 特性开关与死代码消除

上一节把 src/ 的依赖方向管住了,但「边界干净」不等于「产物干净」。一个模块即使被正确分层,只要它被入口 import 到,就会被打进包——哪怕这个功能在移动端构建里根本不该存在。本节要做的,是把「这个模块要不要进包」也变成一条可执行的规则。

答案不是运行期的 if,而是构建期常量。让打包器在压缩前就知道某个分支永远为假,它会把分支连同分支独占的模块一起删掉,这就是死代码消除(DCE)。helio 用它来切阴影、后处理、2D 叠层这些「按目标平台可选」的重模块。

为什么必须在构建期做?因为「可选」这件事在游戏里通常有两个截然不同的含义。一种是同一个包在运行时决定要不要跑某功能——比如玩家在设置里关掉阴影,这时代码必须留在包里,只是不执行。另一种是这个平台根本不需要这份代码——移动端小包不做级联阴影,那这段代码连字节都不该出现。前者用运行期开关,后者必须用构建期开关。本节只做后者,因为它才是决定下载体积的那一半。

判断标准很简单:如果关掉这个功能后,你永远不会再在某个场景下需要它,那就是构建期开关;如果只是「默认关、玩家可开」,那它属于运行期配置。把两者混在一个 if 里,就会既省不了体积、又丢掉了运行期灵活性。

两种开关:模块常量与构建期 define

开关有两种写法,用途不同,混用会踩坑:

写法例子谁负责折叠适合
模块常量export const FEATURE_SHADOWS = false打包器的跨模块常量传播长期固定、随平台定制的功能
构建期 definetransform.define: { __FEATURE_X__: 'false' }打包器替换标识符后再摇由 CI 环境变量或构建配置驱动的开关

两者能生效的前提是同一条:被判断的必须是编译期就能确定的常量。if (process.env.FEATURE_X) 不行——process.env 是运行期对象,打包器不敢假设它的值;if (readFlagFromConfig()) 更不行,那是函数调用。只有写成 if (FEATURE_SHADOWS)、且 FEATURE_SHADOWS 能被证明是字面量 true/false 时,DCE 才会动手。

模块常量这条路还有一个隐蔽的好处:它同时约束了谁能开这个开关。FEATURE_SHADOWS 只有一个定义处,改它必须改那一行,diff 里一眼可见;而散落在各处的 process.env 判断,谁都可以偷偷加一个。

define 那条路的形状长这样,它把环境变量在打包时就固化成字面量:

// snippet: rolldown 的 transform.define,把环境变量固化成字面量
const shipPostfx = process.env.SHIP_POSTFX === '1';
const bundle = await build({
  input: 'src/apps/feature-frame.ts',
  output: { format: 'esm' },
  transform: { define: { __FEATURE_POSTFX__: String(shipPostfx) } },
});

源码里写 if (__FEATURE_POSTFX__),打包器先把它替换成 if (true) 或 if (false),再走同一套 DCE。注意 String(shipPostfx) 这一步:define 的值必须是源码文本,传布尔值会得到 "false" 这个字符串字面量,判断永远为真——这是最容易踩的一个坑。

两条路选哪条?默认用模块常量:它可被 tsc 检查、可被单测覆盖、diff 友好。只有当开关确实来自 CI 环境(比如同一条流水线出「完整版」和「试玩版」两个包)时,才用 define,并且把 define 的取值集中在一个构建脚本里,不要散落。

可运行产物:四种开关组合的产物体积

先看开关本身。三个开关都是模块级 const 字面量,另配一个 activeFeatures() 供运行期遥测。

// file: src/core/feature-flags.ts
// tier: L1
// verify: npx vitest run src/core/feature-flags.test.ts
//
// helio 工程化:构建期特性开关。
// 每个开关是模块级 const 字面量。打包器把它内联进 `if (FEATURE_X)`,
// 于是 false 分支连同「只被该分支用到的 import」一起被摇掉。
// 改一行字面量 + 重新打包 = 出包体积变化,这就是死代码消除。

export const FEATURE_SHADOWS = true;
export const FEATURE_POSTFX = true;
export const FEATURE_UI2D = false;

/** 运行期遥测用:列出这次构建真正编进去的特性。 */
export function activeFeatures(): string[] {
  const out: string[] = [];
  if (FEATURE_SHADOWS) out.push('shadows');
  if (FEATURE_POSTFX) out.push('postfx');
  if (FEATURE_UI2D) out.push('ui2d');
  return out;
}

开关本身也要单测,否则「开关是不是真的布尔字面量」只能靠肉眼确认——而它恰恰是 DCE 能否生效的前提。

// file: src/core/feature-flags.test.ts
// tier: L1
// verify: npx vitest run src/core/feature-flags.test.ts
import { describe, expect, it } from 'vitest';
import {
  FEATURE_POSTFX,
  FEATURE_SHADOWS,
  FEATURE_UI2D,
  activeFeatures,
} from './feature-flags.ts';

describe('feature-flags', () => {
  it('开关是布尔字面量,可被打包器常量折叠', () => {
    expect(typeof FEATURE_SHADOWS).toBe('boolean');
    expect(typeof FEATURE_POSTFX).toBe('boolean');
    expect(typeof FEATURE_UI2D).toBe('boolean');
  });

  it('activeFeatures 只列出编进来的特性', () => {
    expect(activeFeatures()).toEqual(['shadows', 'postfx']);
    expect(activeFeatures()).not.toContain('ui2d');
  });
});

入口按开关决定这一帧要跑哪些 pass。关键约定:每个重模块只被对应的那个 if 分支引用,否则它就会被别的分支拖住、摇不掉。

// file: src/apps/feature-frame.ts
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// 帧组装入口:按编译期开关决定这一帧要跑哪些 pass。
// 每个 if 里的重模块都只被该分支引用,关掉开关它就会从产物里消失。
import { FEATURE_SHADOWS, FEATURE_POSTFX, FEATURE_UI2D } from '../core/feature-flags.ts';
import { buildShadowCascades } from '../render/shadow/shadow-cascade-build.ts';
import { bloomPass } from '../render/postfx/bloom-pass.ts';
import { buildOverlay } from '../render/ui2d/ui2d-overlay.ts';

const LUMA = new Float32Array(256);

export function buildFrame(): string[] {
  const passes: string[] = ['base'];
  if (FEATURE_SHADOWS) {
    passes.push(`shadow:${buildShadowCascades(4, 200).length}`);
  }
  if (FEATURE_POSTFX) {
    passes.push(`bloom:${bloomPass(LUMA, 0.8).length}`);
  }
  if (FEATURE_UI2D) {
    passes.push(`ui2d:${buildOverlay(['hud', 'menu']).length}`);
  }
  return passes;
}

bloom-pass.ts 就是被 FEATURE_POSTFX 独占的重模块示例。它在源码里是一个完整的文件,但关掉开关后整个文件都不会出现在产物里。

// file: src/render/postfx/bloom-pass.ts
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// 重模块示例:泛光。只有开后处理的构建才该带上它。
export function bloomPass(luma: Float32Array, threshold: number): Float32Array {
  const out = new Float32Array(luma.length);
  for (let i = 0; i < luma.length; i++) {
    out[i] = Math.max(0, luma[i] - threshold);
  }
  return out;
}

打包器用 rolldown(Vite 8 的内置打包器,node_modules 里就有)。测量工具的做法是给打包器挂一个插件,把 feature-flags.ts 的内容按组合替换成字面量常量——等价于「改一行开关 + 重新打包」,只是不用手改文件。

// file: tools/feature-bundle.mjs
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// helio 工程化:用不同特性开关组合打包同一个入口,打印产物体积。
// 做法是给打包器挂一个插件,把 src/core/feature-flags.ts 的内容按组合
// 替换成字面量常量;打包器的 tree-shaking 负责把死分支和它的独占模块摇掉。

import { build } from 'rolldown';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const ENTRY = resolve(ROOT, 'src/apps/feature-frame.ts');
const FLAGS = resolve(ROOT, 'src/core/feature-flags.ts');

/** 用字面量常量覆盖 flags 模块,模拟「改一行开关 + 重新打包」。 */
function flagPlugin(flags) {
  return {
    name: 'flag-override',
    load(id) {
      if (id !== FLAGS) return null;
      return [
        `export const FEATURE_SHADOWS = ${flags.shadows};`,
        `export const FEATURE_POSTFX = ${flags.postfx};`,
        `export const FEATURE_UI2D = ${flags.ui2d};`,
        'export function activeFeatures() { return []; }',
      ].join('\n');
    },
  };
}

const combos = [
  { label: 'all-on', shadows: true, postfx: true, ui2d: true },
  { label: 'no-ui2d', shadows: true, postfx: true, ui2d: false },
  { label: 'shadows-only', shadows: true, postfx: false, ui2d: false },
  { label: 'mobile-min', shadows: false, postfx: false, ui2d: false },
];

const rows = [];
for (const combo of combos) {
  const bundle = await build({
    input: ENTRY,
    output: { format: 'esm' },
    platform: 'node',
    treeshake: true,
    plugins: [flagPlugin(combo)],
  });
  const code = bundle.output[0].code;
  rows.push({ label: combo.label, bytes: Buffer.byteLength(code), lines: code.split('\n').length - 1 });
}

const base = rows[0].bytes;
console.log('combo          bytes   vs all-on');
for (const r of rows) {
  const pct = (((r.bytes - base) / base) * 100).toFixed(1);
  console.log(`${r.label.padEnd(14)} ${String(r.bytes).padStart(5)}   ${pct.padStart(6)}%`);
}

本机实跑(node tools/feature-bundle.mjs):

combo          bytes   vs all-on
all-on          1140      0.0%
no-ui2d          903    -20.8%
shadows-only     515    -54.8%
mobile-min        80    -93.0%

四组数字来自同一次运行,可复现。全开 1140 字节,只关掉 2D 叠层降到 903(-20.8%),再关后处理降到 515(-54.8%),全关只剩 80 字节(-93.0%)。移动端最小包的实际产物长这样:

function buildFrame() {
	return ["base"];
}
export { buildFrame };

buildShadowCascades、bloomPass、buildOverlay 三个模块整体消失,连 LUMA 这个 Float32Array 常量都不再分配——因为 bloomPass 被摇掉后它成了无人引用的纯值,打包器顺手把初始化也删了。这不是「运行期少跑一个 if」,是代码根本没进包。

80 字节这个数字本身不重要(真实引擎里这几个 pass 会有几百行),重要的是它证明了链路是通的:开关 → 常量折叠 → 分支删除 → 模块图收缩 → 产物体积下降,五步都在同一条流水线上自动完成。以后每加一个可选特性,只要遵守「只被一个分支引用 + 顶层无副作用」,它就会自动获得同样的待遇,不需要谁记得手动去删。

反过来,测量工具的价值在于把「裁剪有没有生效」从主观判断变成一行数字。没有它,你只能靠肉眼看 bundle 里有没有那段代码;有了它,CI 里跑一次就能发现「这次改动让移动端包大了 12%」。本节的 tools/feature-bundle.mjs 正是为此存在——它是体积回归测试的原型。

开关在源码态也能直跑,只是那时三个模块都在,属于开发期行为:

$ node -e "import('./src/apps/feature-frame.ts').then(m=>console.log(m.buildFrame().join(' | ')))"
base | shadow:4 | bloom:256

摇不掉的三种情况

DCE 不是魔法,下面三种写法会让重模块顽固地留在包里:

  • 间接引用:const passes = [FEATURE_UI2D ? buildOverlay(['hud']) : []] 看着没问题,但如果 buildOverlay 同时出现在别的地方(比如一个调试函数),它就有了第二个引用点,摇不掉。
  • 副作用:模块顶层只要有一句 registry.register('bloom', bloomPass),打包器就默认这个模块「有副作用」,if (false) 也删不掉它。要让模块可摇,就得保证顶层没有可观察的副作用。
  • 运行时数据:开关值来自 localStorage 或 URL 参数时,它就不是常量了。这类「运行期特性开关」在游戏里确实有用(比如后台动态关掉某个特效),但它和本节的构建期开关是两码事,别指望它省体积。

把这三条反过来读,就是写可裁剪模块的三条纪律:重模块只被一个分支引用、顶层零副作用、开关是字面量常量。

还有一条工程纪律值得单独说:开关要有生命周期。一个特性开关如果永远开着,它就不该是开关,而应该是普通代码——留着它只会让每个 if 都多一层阅读成本。helio 的做法是给每个开关记一个「预期下线版本」:FEATURE_UI2D 这种长期存在的平台差异开关保留,FEATURE_NEW_WATER_SHADER 这种临时开关在特性稳定后连开关带旧分支一起删掉。开关是给「过渡期」用的,不是给「永久」用的。

下一节换个方向:既然有些模块可以被裁掉,那也有些模块是外部作者加进来的。我们做一套插件系统,并给引擎版本和插件接口版本定一套不破坏兼容的规则。

阅读导航:上一节:5.1 模块边界与依赖方向 · 下一节:5.3 插件系统与引擎版本 / API 稳定性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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