Skip to content

消失的头发

背景:用户隐藏头发材质后保存预设,重新加载时头发又出现了——预设没记住材质的启用状态。 过程ModelPresetFile 新增 materialEnabled 字段,getMatState/applyMatState 同步支持启用/禁用。


第三颗红石子的故事,要从一根头发说起。

用户在材质面板里找到头发的材质,点了一下"隐藏"——头发消失了。然后他保存了预设,关掉软件,第二天打开,加载预设——

头发又长回来了。

"预设是坏的。"用户说。

预设其实没坏。它只是忘了记住一件事——那根头发是被关掉的。


预设里缺了什么

外交官打开 model-preset.ts,翻到 serializeModelPreset 函数。

预设文件里存了什么?他一个一个数:

  • 模型路径 ✅
  • 模型位置、旋转、缩放 ✅
  • 可见性 ✅
  • 材质分类参数(皮肤/头发/衣服的颜色、反光、透明度)✅
  • 逐材质覆写参数 ✅
  • 表情/ morph 权重 ✅
  • VMD 动作 ✅
  • 物理参数 ✅

"看起来挺全的,"AI 同行者说,"缺了什么?"

"缺了一个布尔值,"外交官说,"材质的启用状态。"

他指着 getMatState 的返回类型——那里面有 categories(分类参数)和 overrides(逐材质覆写),但没有 enabled(材质是否启用)。

"材质可以被关掉,对吧?"

"可以,在材质详情页有个开关。"

"关掉之后,材质就不渲染了——头发消失了,衣服消失了,想让哪里消失就让哪里消失。"

"然后呢?"

"然后,这个状态没有被存进预设,"外交官耸耸肩,"保存预设的时候,只存了材质的参数——颜色、反光、透明度——但是没有存'这个材质是开着的还是关着的'。所以加载预设的时候,所有材质默认都是开着的。"

"那根头发就长回来了。"

"那根头发就长回来了,"外交官点头,"用户会觉得预设是不可靠的——'我明明关掉了,怎么又出来了?'。一次不可靠,次次不可靠。信任是易耗品。"


为什么会漏掉

"这么重要的东西,为什么会漏掉?"AI 问。

"因为它是附加功能,"外交官翻了翻代码的提交记录,"最早的材质系统只有参数调节——调颜色、调反光、调透明度。后来加了'隐藏材质'的功能,就是点一下开关让材质不渲染。功能加进去了,但是序列化忘了跟。"

"常见的遗忘模式。"

"太常见了,"外交官叹了口气,"一个系统,核心功能先做——比如参数调节——序列化/反序列化一起做了,没问题。后来加了个小功能——比如隐藏材质——改了 UI,改了运行时逻辑,唯独忘了改序列化。因为序列化'不是这个功能的一部分'。"

"但它是。"

"它是,"外交官说,"任何有状态的功能,都默认包含序列化。除非你明确说'这个状态不用存'。否则,用户的直觉就是'我改了什么,保存再加载应该还在'。"

他在笔记本上写下一行字:

功能的完整定义 = UI + 运行时 + 序列化 + 撤销


怎么加:三个地方要改

"加一个 materialEnabled 字段,难吗?"AI 问。

"不难,但要改三个地方,"外交官竖起三根手指,"少一个都不行。"

第一处:getMatState——要把 enabled 状态读出来。

"序列化的时候,要收集每个材质的启用状态。材质索引号到布尔值的映射——{ 0: true, 1: false, 2: true } 这样。"

"用材质索引当 key?不用材质名?"

"用索引,"外交官摇头,"材质名可能重复,索引是稳定的——PMX 文件里第几个材质就是第几个。跟现有的 overrides 保持一致。"

第二处:ModelPresetFile——类型定义里要加字段。

"加一个可选字段 materialEnabled?: Record<number, boolean>。可选是为了兼容旧预设——旧预设里没有这个字段,加载的时候所有材质默认启用。"

"向后兼容。"

"向后兼容,"外交官确认,"任何序列化字段的新增,第一原则就是不破坏旧数据。旧数据没有的字段,用合理的默认值。"

第三处:applyMatState——要把 enabled 状态写回去。

"反序列化的时候,读 materialEnabled,逐个材质设置启用/禁用。如果没有这个字段,就什么都不做——所有材质保持默认的启用状态。"

"三处改完就好了?"

"三处改完,功能就对了,"外交官说,"但还有一件事——测试。"


测试的价值

"为什么要写测试?"AI 问,"不就是一个布尔值的存和读吗?这么简单的逻辑,写代码的时候就知道对不对了。"

外交官看着他,笑了笑。

"我问你一个问题,"他说,"现在我们加了 materialEnabled,六个月以后,有人重构材质系统,把启用状态的实现换了——比如从 mesh.isVisible 改成 material.disableColorWrite——他会不会记得去改序列化?"

AI 沉默了。

"大概率不会,"外交官说,"因为序列化在另一个文件里。改材质系统的人可能根本不知道有 materialEnabled 这回事。直到有一天用户报告'预设里隐藏的材质又出来了'——大家才发现序列化断了。"

"那测试怎么防止这个?"

"测试会挂掉,"外交官说,"serialize 一个有关掉材质的模型,再 deserialize,断言材质还是关着的。只要序列化和运行时不同步,这个测试就会红。红了,就有人去修。"

他敲了敲桌面:

"测试不是为了证明现在是对的——现在对不对,写代码的人自己知道。测试是为了防止以后变错。是写给六个月后的自己看的。"


旧预设怎么办

"那旧的预设文件呢?"AI 问,"里面没有 materialEnabled,加载的时候所有材质都是开的。用户如果在旧预设里手动关过材质——"

"找不回来了,"外交官直接说,"旧预设里没存,就是没了。我们不能从空气里变出数据。"

"但用户会觉得是 bug 啊——'我之前保存的预设,怎么升级后不一样了?'"

"这就是技术债的利息,"外交官说,"之前欠的债,现在要还。利息就是用户的一次困惑。"

他顿了顿:

"但我们可以做一件事——迁移说明。在 changelog 里写清楚:'材质启用状态现在会被保存到预设中。旧版本预设不含此信息,加载后所有材质默认为启用。' 写清楚了,用户就知道是怎么回事。不知道的才会困惑,知道了就是'哦,以前没这个功能,现在有了'。"

"透明度也是一种尊重。"

"对,"外交官点头,"技术债不可怕,可怕的是不告诉用户你欠了债。"


第三根桩子

改完三处代码、写完测试的时候,已经是下午了。

外交官在测试环境里跑了一遍:加载模型,关掉头发材质,保存预设,重置场景,加载预设——

头发还是关着的。

"成了,"AI 说。

外交官点点头,把第三颗红石子放在桌上,和前两颗摆成一条线。

三颗红石子,三根桩子。

  • 第一根:XSS——安全的桩子
  • 第二根:路径匹配——健壮性的桩子
  • 第三根:材质启用——完整性的桩子

"三根桩子打完了,"外交官说,"高风险三项清了。"

"接下来呢?"

"接下来是中优项,"外交官伸手扒拉了一下桌上的黄石子,"快赢项先做——加载占位、缩略图优化、布料缓存。改动小,见效快,士气高。"

他拿起一颗黄石子,在手里掂了掂。

"而且——"他笑了笑,"黄石子的故事比红石子有意思。红石子都是'修 bug',黄石子是'让东西变好'。修 bug 是还债,变好是赚钱。人总是喜欢赚钱的。"

窗外的阳光透过玻璃照进来,三颗红石子的影子在桌上拉得很长——像三根深深扎进地基里的桩。

高风险的警报解除了。

接下来,是让联邦变得更精致的工作。


附录:序列化功能设计原则

原则说明
功能 = 运行时 + 序列化任何有状态的功能,默认包含序列化。不保存是例外,需要明确说明
新增字段必须可选旧文件要能正常加载,用合理默认值。向后兼容是第一原则
序列化要有测试测试不是证明现在对,是防止以后错。写给六个月后的自己看
用稳定标识做 key材质用索引、模型用 ID——不要用可能重复或变化的东西当键
欠的债要说清楚旧数据丢失的信息,写在 changelog 里。透明度比完美更重要

教训:用户对一个系统的信任,不是来自它有多少功能,而是来自它说过的话算不算数。你说"保存预设",用户就相信再加载的时候一切都一样。少存了一个布尔值,丢的不是功能,是信任。