ModelKit

ModelKit

*自定义模型用于钓鱼mod,无需AssetBundles — 这样游戏更新不会破坏你的美术资源。* 这是一个供mod作者使用的库。如果你是玩家,只有在某些内容要求你安装时才需要它;它本身不会产生任何效果。

它解决的问题

Unity AssetBundle 是针对游戏构建时所使用的精确引擎版本编译的。当游戏更新时,每个包含 bundle 的模组中的所有自定义模型都可能停止加载,唯一的解决办法是让每位作者重新构建并重新发布。对于像这样频繁打补丁的游戏来说,这对每个发布美术资源的作者都是一种反复出现的负担。

ModelKit 改为加载 [b]Wavefront OBJ、MTL 和 PNG[/b]。它们是文本和图片,没有引擎版本,不需要构建管线,而且每个建模工具都已经支持导出这些格式。以这种方式加载的模型在游戏更新后依然能正常工作,因为其中没有任何会过时的内容。

这也让玩家无需你做任何事就能重新美化你的模组:将加载器指向一个文件夹,他们放入的任何文件都会覆盖你发布的内容。


你得到什么

[b]OBJ 读取器[/b] — 支持位置、法线、纹理坐标、任意大小的 n 边形,每种材质对应一个子网格。支持基于 1 的索引和负索引、缺失法线、空组以及超过 65k 顶点限制的网格。

[b]MTL 读取器[/b] — 支持 [code]map_Kd[/code] 纹理和 [code]Kd[/code] 颜色,并自动去除导出器生成的绝对路径。

[b]纹理加载[/b] — PNG/JPG,默认使用点过滤(平滑后的 16×16 纹理看起来会模糊成一团),带有自动 [b]镂空检测[/b],让树叶和格栅能呈现空洞而不是不透明的矩形。

[b]材质处理[/b] — 这部分并不显而易见,详见下文。

[code].gz[/code] [b]支持[/b]** — OBJ 文本压缩率大约为 6:1。发布 [code]model.obj.gz[/code] 即可透明读取。


为什么材质是困难的一半

网格在任何引擎中都是网格。而 [b]材质[/b]** 必须匹配游戏构建时所使用的渲染管线,而你在编译时无法知道这一点。

可靠的方法是克隆游戏已渲染的某个材质——克隆体保证与管线兼容,因为游戏此刻正在用它绘制。有三件事让这比听起来更难,而每一件都会制造出看起来像是“模型坏了”的 bug:

  1. [b]属性名在不同管线间有差异。[/b]** 内置管线使用 [code]_MainTex[/code][code]_Color[/code];URP 使用 [code]_BaseMap[/code][code]_BaseColor[/code]。材质会静默忽略它没有的属性,因此你会得到一个没有纹理的模型,而且没有任何报错。ModelKit 每次都同时设置两者。

  2. [b]你克隆的源可能使用了 Shader Graph 着色器。[/b]** 这些着色器可能把你的模型渲染成彩虹色,而且可能根本没有你在设置的纹理属性。ModelKit 只从已知的普通光照着色器克隆,否则就从零构建一个。

  3. [b]克隆体会继承原材质的一切内容。[/b]** 自发光、法线贴图、金属贴图、纹理偏移。如果不处理,你的模型会发光、滚动纹理,或顶着别人的凹凸贴图。[code]Materials.Calm()[/code] 会清除所有这些。

如果你曾经和这些问题搏斗过,这就是这个库存在的意义。


使用方法

添加 [code]ModelKit[/code] 作为依赖项,引用 [code]ModelKit.dll[/code],然后:

using HtfModelKit;

// 首选玩家文件夹,你的内置副本作为回退。
// 玩家可以通过放入文件来覆盖你的美术资源;如果他们没有放,
// 就会使用你的版本,他们无需安装任何东西。
var source = AssetSource
    .Folder(myModelFolder)
    .Then(AssetSource.Embedded(Assembly.GetExecutingAssembly(), "MyMod.Assets."));

// 替换游戏对象的外观:
GameObject[] parts = ModelKit.Replace(someStall, "mystall.obj", source);
ModelKit.Place(parts, scale: 1.2f, yaw: 90f, heightOffset: 0.35f);

或者如果你想把网格用于其他用途,可以分两步:

ModelData model = ModelKit.Load("mystall.obj", source);
GameObject[] parts = ModelKit.Attach(target, model, null);

用一行代码将库的日志指向你自己的日志器:

ModelKit.UseLogger(Log.Info, Log.Warn);

将模型嵌入你的 DLL:

mcs ... -resource:models/mystall.obj,MyMod.Assets.mystall.obj

你传给 [code]AssetSource.Embedded[/code] 的前缀就是文件名之前的任何部分——上面例子中的 [code]"MyMod.Assets."[/code]。子文件夹对应点号。


两个承诺

[code]Replace[/code] [b]只改变外观。[/b]** 碰撞体、脚本、悬停文本以及对象上的所有其他内容都保持原样。模型加载失败只会让你失去外观,不会影响其他任何东西——对象仍然正常工作,日志会记录出错的原因。另一种选择是,因为美术文件缺失而导致模组弄坏一个原本正常的游戏对象。

[b]不会抛出任何异常。[/b]** 每个入口点都会返回 [code]null[/code] 或空数组并记录原因。一个坏的模型应该只是让你损失一个模型,而不是损失正在绘制它的功能。


API

调用 功能
[code]ModelKit.Load(name, source)[/code] 解析模型,按名称缓存
[code]ModelKit.Replace(target, name, source)[/code] 加载并替换,隐藏原始渲染器
[code]ModelKit.Attach(target, model, template)[/code] 将其构建为子对象,不触碰其他任何内容
[code]ModelKit.Place(parts, scale, yaw, height)[/code] 让它正确定位在所装饰的对象上
[code]ModelKit.BoundsOf(parts)[/code] 世界空间边界,用于取景或裁剪检查
[code]ModelKit.Available(name, source)[/code] 检查是否存在,不进行构建
[code]ModelKit.Forget(name)[/code] 丢弃缓存,以便重新读取已更改的文件
[code]ModelKit.UseLogger(info, warn)[/code] 将日志行发送到你的日志器
[code]AssetSource.Folder(path)[/code] / [code].Embedded(asm, prefix)[/code] / [code].Then(other)[/code] 文件的来源
[code]Materials.FindTemplate[/code] / [code]Build[/code] / [code]Calm[/code] / [code]EnableCutout[/code] 单独使用材质层

[code]Forget[/code] 是让迭代成为可能的关键:修改模型、重新加载世界、查看结果——无需重新构建,无需重启。


它不做什么

不支持动画、蒙皮、平滑组或曲线。这是为 [b]静态道具[/b]** 设计的。如果你需要骨骼绑定的角色,你需要 bundle,并且必须接受随之而来的更新负担。

它也不了解《How to Fish》本身——它从不引用 [code]Assembly-CSharp[/code],只引用 [code]UnityEngine[/code]。这是刻意为之,也是模型能在更新后继续存活的同一原因。


鸣谢

提取自 [b]How To Fish - Extended[/b]**,这段代码在其中负责加载 Lucky Charm 摊位。单独发布是因为 AssetBundle 的问题是所有人都要面对的,而这一半问题已经解决了。

[b]Nexus 上的 Extended 模组[/b] — https://www.nexusmods.com/howtofish/mods/70[b]Discord[/b] — https://discord.gg/CgXJJBFqkY

欢迎提交 bug 报告和拉取请求。如果你用它发布作品,请告诉我们——了解哪些功能需要支持是很有用的。