Appearance
消失的头发
背景:用户隐藏头发材质后保存预设,重新加载时头发又出现了——预设没记住材质的启用状态。 过程:
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 里。透明度比完美更重要 |
教训:用户对一个系统的信任,不是来自它有多少功能,而是来自它说过的话算不算数。你说"保存预设",用户就相信再加载的时候一切都一样。少存了一个布尔值,丢的不是功能,是信任。