对资源的规范化我们能做什么

游戏开发中,资源的管理是一项非常精细的活,不同类型的资源往往拥有完全不同的管理方式和配置方式,本文希望从 5 个不同的管理级别对资源管理进行解构

  • 致命资源错误不让保存
  • 保存了不让提交
  • 提交了可以快速发现问题
  • 发现问题可以快速修复
  • 版本问题记录

对于可以使用 AssetPostprocessor 解决的问题,这里就不做介绍了,这种问题一般都非常简单,如果不清楚如何使用,可以搜一下相关实现

本文提到的工具,后面如果时间充足,整理完毕后,应该会开源,涉及代码的部分因为会比较冗长,可能会只贴原理部分

致命资源错误不让保存

这里不让保存的原理非常简单,我们需要监听 prefab 保存的事件,在保存前,检查自定义的规则,一旦发现错误,直接抛一个异常即可阻止保存

需要注意的是,虽然我们会监听足够的保存事件,但仍然有取巧的办法突破这个限制,对于这种情况,一般都是 git blame 找到是谁干的,定性为恶意行为

要实现这个过程,我们需要使用 InitializeOnLoadInitializeOnLoadMethod 在 Editor 编译时,自动注册我们的监听函数,这里需要监听如下几个事件

  1. PrefabUtility.prefabInstanceUpdated
    • ApplyAll Prefab 保存事件
  2. UnityEditor.SceneManagement.PrefabStage.prefabSaving
    • Open Prefab 保存事件
  3. UnityEditor.SceneManagement.PrefabStage.prefabStageOpened
    • Open Prefab 打开事件
  4. Selection.selectionChanged
    • 选中 Prefab 的事件

这里的 1 和 2 对应两种不同情况的 prefab 保存事件,可以统一处理过程,3 和 4 是为了处理某些情况下无法获取到当前 prefab 路径的问题,临时缓存一下为接下来有效性验证做准备

selectionChanged 比较特殊,在 Unity Assets 中,我们可以直接点击某个 prefab 对最外层的 GameObject 进行修改,所以这个事件是为了阻止这类错误改动

至于后续的处理过程就比较简单,这里我们可以通过自定义 ScriptObject 配置,来粒度控制每种 prefab 的具体处理过程,这里就不在赘述了

Preset Prefab 只读

在说这个规则之前,我们要明白 Unity prefab 嵌套规则,当 A 嵌套了 B,此时在 A 中 修改 B 的属性,我们 apply 的时候,有两个分支,一个是 apply 到 A,另一个是 apply 到 B,这两个分支对应的结果会 完全不一致

如果我们 apply 到 A,那么当我们单独编辑 B 时,可能会导致部分属性无法直接同步到 A 上,而这个并不是我们希望看到的,如果我们 apply 到 B 上,大部分情况下所有嵌套了 B 的 prefab 都会同步这个改动

这个规则就是为了禁止在 A 上直接修改 B 中核心组件

受到 UGUI-Editor 这个工具的启发,我们可以将一些非常通用的组件做成一个个的 preset,通过 preset 窗口将已有的内容拖到对应的 UI 中

比如 UI 中通用的确认、取消按钮,可以作为 preset 资源供开发者选取

比较可惜的是这个库作者应该是不维护了,我自己是改动了一下,适配了 2021,因为引入了 preset 的制作流程,那么我们应当更进一步,只要发现 prefab 中存在 preset 资源,需要按照规则将部分组件设为只读,不可修改

这里对通用的关闭按钮增加了一个规则,这个 prefab 下的所有 Image 组件会被设为 readonly

核心代码也非常简单,就一行

component.hideFlags = HideFlags.NotEditable;

在致命错误禁止保存的过程中,我们监听了足够多的 prefab 回调,在 prefab 打开时,我们调用这个 NoEdit 函数,对 preset 中的预制按照规则将每个 commponet 设为 NotEditable 即可

基于这个流程,我们可以保证凡是 preset 中的资源,不可以在任何嵌套的 prefab 中直接修改,仅允许直接打开这个 preset 时进行修改

GameObject Copy

我们有部分 prefab 的制作流程是交给美术进行的,美术在制作部分资源时往往没有程序那么严谨,通常会特别喜欢从现成的组件中直接 copy,这个时候可能会导致一些极其难查的问题出现,曾经也是非常头大...

比如游戏中的红点,通过直接挂载 Mono 脚步的方式来实现,每个脚本上会标记当前红点的路径,此时这个 GameObject 被复制并保存到其他 UI 上,这个问题直接让你查到裂开

跟上面一样,我们仍然将所有的规则都配置到 ScriptObject 中

这里的规则就是,禁止 View.RedDotUI 组件的复制

ScriptObject 的代码比较简单我就不贴了,下方为禁止复制的处理过程,稍微有些绕,不贴代码很难讲清楚处理过程

internal class CopyAutomation
{
    static CopyAutomation() { EditorApplication.hierarchyWindowItemOnGUI += _HierarchyWindowItemOnGUI; }

private static double _threshold = 0.1f;
private static double _last_tick = 0;

private static void _HierarchyWindowItemOnGUI(int instance_id, Rect selection_rect)
{
    Event e = Event.current;

    // 如果是无效的指令
    // 直接退出
    if(e.type != UnityEngine.EventType.ValidateCommand)
    {
        return;
    }

    // 这里 id = 0时, 说明没有选择任何对象
    // Hierarchy 传入 id = 当前选择对象
    if(Selection.activeInstanceID != 0 && instance_id != Selection.activeInstanceID)
    {
        return;
    }
    
    switch(e.commandName)
    {
        // 只处理如下两种指令
        case"Duplicate":
        case"Paste":

            // 需要等 Hierarchy 已经刷新后, 再进行校验
            // 此时 Selection 当前选择的对象就是 复制好的
            EditorApplication.delayCall += _ValidateComponent;
            break;
    }
}

private static void _ValidateComponent()
{
    EditorApplication.delayCall -= _ValidateComponent;

    // 如果是 Prefab 实例, 那么不做任何校验
    if(PrefabUtility.IsPartOfPrefabInstance(Selection.activeObject))
    {
        return;
    }

    CopyConfigs configs = CopyConfigs.default_configs;

    if(!configs.enable)
    {
        return;
    }

    double tick = TimeSpan.FromTicks(DateTime.Now.Ticks).TotalSeconds;

    // 此处阻止短时间内的多次检测
    if(_last_tick - tick > _threshold)
    {
        return;
    }

    _last_tick = tick;

    var components = Selection.activeGameObject.GetComponentsInChildren<Component>();

    HashSet<string> error_com = new HashSet<string>();

    foreach(var component in components)
    {
        Type type = component.GetType();

        foreach(CopyConfig config in configs.configs)
        {
            if(!configs.enable)
            {
                continue;
            }

            if(!string.Equals(type.FullName, config.type))
            {
                continue;
            }


            if(!error_com.Contains(type.FullName))
            {
                error_com.Add(type.FullName);
            }

            UnityEngine.Object.DestroyImmediate(component);
        }
    }

    if(error_com.Count <= 0)
    {
        return;
    }

    string all_name = string.Empty;

    foreach(string name in error_com)
    {
        all_name = \$"{all_name}, {name}";
    }

    EditorUtility.DisplayDialog("警告", \$"当前检测到不允许复制的组件 {all_name}, 已经自动移除", "确定");
}


private static double _threshold = 0.1f;
private static double _last_tick = 0;

private static void _HierarchyWindowItemOnGUI(int instance_id, Rect selection_rect)
{
    Event e = Event.current;

    // 如果是无效的指令
    // 直接退出
    if(e.type != UnityEngine.EventType.ValidateCommand)
    {
        return;
    }

    // 这里 id = 0时, 说明没有选择任何对象
    // Hierarchy 传入 id = 当前选择对象
    if(Selection.activeInstanceID != 0 && instance_id != Selection.activeInstanceID)
    {
        return;
    }
    
    switch(e.commandName)
    {
        // 只处理如下两种指令
        case"Duplicate":
        case"Paste":

            // 需要等 Hierarchy 已经刷新后, 再进行校验
            // 此时 Selection 当前选择的对象就是 复制好的
            EditorApplication.delayCall += _ValidateComponent;
            break;
    }
}

private static void _ValidateComponent()
{
    EditorApplication.delayCall -= _ValidateComponent;

    // 如果是 Prefab 实例, 那么不做任何校验
    if(PrefabUtility.IsPartOfPrefabInstance(Selection.activeObject))
    {
        return;
    }

    CopyConfigs configs = CopyConfigs.default_configs;

    if(!configs.enable)
    {
        return;
    }

    double tick = TimeSpan.FromTicks(DateTime.Now.Ticks).TotalSeconds;

    // 此处阻止短时间内的多次检测
    if(_last_tick - tick > _threshold)
    {
        return;
    }

    _last_tick = tick;

    var components = Selection.activeGameObject.GetComponentsInChildren<Component>();

    HashSet<string> error_com = new HashSet<string>();

    foreach(var component in components)
    {
        Type type = component.GetType();

        foreach(CopyConfig config in configs.configs)
        {
            if(!configs.enable)
            {
                continue;
            }

            if(!string.Equals(type.FullName, config.type))
            {
                continue;
            }


            if(!error_com.Contains(type.FullName))
            {
                error_com.Add(type.FullName);
            }

            UnityEngine.Object.DestroyImmediate(component);
        }
    }

    if(error_com.Count <= 0)
    {
        return;
    }

    string all_name = string.Empty;

    foreach(string name in error_com)
    {
        all_name = \$"{all_name}, {name}";
    }

    EditorUtility.DisplayDialog("警告", \$"当前检测到不允许复制的组件 {all_name}, 已经自动移除", "确定");
}
}

保存了不让提交

不让提交这个事情我们就需要 git hook 这个老朋友出场了,这个过程非常简单,在运行 Unity 时,检查 .git/hook/pre-commit 脚本是否为最新的,不是就复制一份过来

sync 脚本参考
hook 脚本参考

HookConfig.conf 这个文件是为了照顾一些 GUI 的用户,确实需要修改一些禁止修改的文件时,通过开关临时关闭这条规则

对于命令行用户跳过 hook 就很简单了 git commit -m "xxx" -n

提交了可以快速发现问题

资源规范的制定往往是一点点建立的,某些资源在最开始通过了上述所有验证,但是新增规则后,现有的资源就存在问题,此时感知问题的存在是非常非常重要的,而且更重要的是,这个感知过程编写的成本要足够低

这个时候就需要 Odin Validator 3.1 版本出马了

Odin 的团队真是神了,期待一下 基于 UIToolkit 的版本,如果你还没听过 Odin 那可真的太遗憾了...

这条检测规则为:凡是发现 ParticleSystem 中 max particles 数量大于 30,直接报错

具体的代码会贴在下面

发现问题可以快速修复

当我们已经拥有感知问题的能力后,你一定不希望一些小的问题都需要自己亲力亲为来修复,这条规则同样是基于 Odin Validator 3.1 在感知问题的同时,Odin 给了一个快速 Fix 的方式

注意这里红框的部分,凡是有这个小扳手的错误,我们都可以直接点击 Execute 一键修复

下方代码定义了两组规则,首先粒子禁止开启 prewarmmaxParticles 默认不允许超过 30,如果是ParticleSystemRenderMode.Mesh 的方式,则会限制为 5

这里对 ParticalSystem 属性的修改,在不同版本的 Unity 中会不一致,这里是 Unity 2021,是通过 SerializedObject 来间接修改的

#if UNITY_EDITOR
using Sirenix.OdinInspector.Editor.Validation;
using UnityEngine;
using UnityEditor;


[assembly:

RegisterValidationRule(typeof(ParticleSystemValidator), Name = "粒子合法校验", Description = "Some description text.")]




public class ParticleSystemValidator : RootObjectValidator<ParticleSystem>

{

protected override void Validate(ValidationResult result)

{

ParticleSystem ps = Object;



    if(ps.main.prewarm)
    {
        result.AddError(&quot;ParticleSystem should not prewarm!&quot;).
               WithFix(
                   Fix.Create(
                       &quot;Disable Prewarm&quot;,
                       () =&gt;
                       {
                           var so = new SerializedObject(ps);

                           var prewarm = so.FindProperty(&quot;prewarm&quot;);
                           if(prewarm is null || !prewarm.boolValue)
                           {
                               return;
                           }

                           prewarm.boolValue = false;
                           so.ApplyModifiedProperties();
                       }
                   )
               );

        return;
    }

    var renderer = ps.GetComponent&lt;ParticleSystemRenderer&gt;();

    int max_limit = 30;

    if(renderer.renderMode == ParticleSystemRenderMode.Mesh)
    {
        max_limit = 5;
    }

    if(ps.main.maxParticles &gt; max_limit)
    {
        result.AddError($&quot;ParticleSystem max particles &gt; {max_limit}!&quot;).
               WithFix(
                   Fix.Create(
                       $&quot;Down to {max_limit}&quot;,
                       () =&gt;
                       {
                           var so = new SerializedObject(ps);

                           var max = so.FindProperty(&quot;InitialModule.maxNumParticles&quot;);

                           if(max is null || max.intValue &lt;= max_limit)
                           {
                               return;
                           }

                           max.intValue = max_limit;
                           so.ApplyModifiedProperties();
                       }
                   )
               );
    }
}




}



endif

    if(ps.main.prewarm)
    {
        result.AddError(&quot;ParticleSystem should not prewarm!&quot;).
               WithFix(
                   Fix.Create(
                       &quot;Disable Prewarm&quot;,
                       () =&gt;
                       {
                           var so = new SerializedObject(ps);

                           var prewarm = so.FindProperty(&quot;prewarm&quot;);
                           if(prewarm is null || !prewarm.boolValue)
                           {
                               return;
                           }

                           prewarm.boolValue = false;
                           so.ApplyModifiedProperties();
                       }
                   )
               );

        return;
    }

    var renderer = ps.GetComponent&lt;ParticleSystemRenderer&gt;();

    int max_limit = 30;

    if(renderer.renderMode == ParticleSystemRenderMode.Mesh)
    {
        max_limit = 5;
    }

    if(ps.main.maxParticles &gt; max_limit)
    {
        result.AddError($&quot;ParticleSystem max particles &gt; {max_limit}!&quot;).
               WithFix(
                   Fix.Create(
                       $&quot;Down to {max_limit}&quot;,
                       () =&gt;
                       {
                           var so = new SerializedObject(ps);

                           var max = so.FindProperty(&quot;InitialModule.maxNumParticles&quot;);

                           if(max is null || max.intValue &lt;= max_limit)
                           {
                               return;
                           }

                           max.intValue = max_limit;
                           so.ApplyModifiedProperties();
                       }
                   )
               );
    }
}

版本问题记录

我们公司采购了 UWA Pipeline,所以自然会将 UWA 的资源检测纳入到整体流程中,在资源检测这一项中,UWA 扮演了非常重要的一个角色,比如上面的粒子规则就是参考 UWA 资源监测中的内容 确定并实现的。以及每天一次的资源报告,让我们可以对项目中总体问题的变化做到心中有数

UWA 的资源检测更像一个知识库,可以有效的帮助提升团队的平均水平,每条是否存在问题、应该怎么解决、为什么需要优化,都列的非常清楚

在项目的前期,可以快速建立整体资源的标准,在中后期,可以将报告的优化任务轮流交给组内的开发者

posted @   lac_123_1234  阅读(111)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
点击右上角即可分享
微信分享提示