前言

随着AI越来越强大,纯AI参与制作的流程(AI Vibe Coding,AI自动拼UI,AI自动生图,AI自动生成Psd分层等)越来越多。虽然Unity 6已经推出了官方AI和CLI等AI相关工具,但目前国内使用Unity官方AI有很多门槛限制,所以本章节主要以使用第三方Github的unity-mcp+Codex的方式去搭建AI自动化流程。

本章节主要研究一下几个问题:

  1. AI自动拆分效果图(解决UI图片资源快速生成问题)
  2. AI自动拼UI(节省拼UI时间,提升开发效率)
  3. AI生成UI逻辑代码(节省些基础UI代码逻辑的时间,提升开发效率)

MCP for Unity

Unity MCP是通过使用Model Context Protocol协议让Unity Editor担任一个Server的角色,然后暴露工具,场景管理,脚本编辑,Console数据读取等能力给AI。

MCP for Unity 通过 Model Context Protocol 把 Claude、Cursor、VS Code、本地大模型等 AI 助手接入 Unity 编辑器,让它们直接帮你管理资源、搭场景、写脚本、跑测试,把开发流程里的重复活儿都包了。

从介绍可以看出,要想让CodeX,Claude Code之类的AI工具能访问Unity像人一样去使用Unity工具或者暴露的接口去做事,我们首先需要接入Unity MCP,这样AI工具才能和Unity通信使用对应工具做事。

Unity for MCP会给我们提供一下工具能力(以下说明来源getting-started):

  • 40+ Unity Editor tools exposed over MCP — manage_scene, manage_script, manage_gameobject, manage_material, manage_physics, run_tests, and more.(通过MCP暴露很多Unity编辑器工具支持操作场景,脚本,GameObject,材质,物理,执行测试等)
  • 25+ read-only resources for state introspection — editor_state, gameobject_components, project_info, unity_instances, etc.(能只读部分GameObject组件数据,项目信息等)
  • Auto-configuration for popular MCP clients — Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Cline, Codex, Qwen, Gemini CLI, Copilot CLI, OpenClaw.(能自动配置适配常规AI工具,比如Codex)
  • Multi-instance support — drive several Unity Editors from a single session via set_active_instance.(单对话能驱动多个Unity编辑器实例)
  • Two transports — HTTP (multi-agent, default) and stdio (single-agent legacy).(支持HTTP和stdio通信方式)

安装MCP for Unity

安装MCP for Unity的要求:

  1. Unity版本Unity 2021.3 LTS or newer
  2. Python版本Python 3.10+ with uv
  3. An MCP client

检查Python版本和UV工具是否满足条件:

CheckPythonAndUVTools

至于Unity我本机使用功能的Unity 6.000.3.19f1,MCP client本机用的Codex。

通过Package Manager->Add package form git URL安装MCP for Unity:

MCP for Unity git URL:”https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main“

InstallMCPForUnity

启动MCP for Unity Server

MCP for Unity是作为让Unity Editor担任Server的角色,所以安装完成后还需要我们主动启动MCP for Unity。

安装好MCP for Unity如果没自动弹出后续使用引导,可以自行打开MCP for Unity设置界面:

Window->MCP for Unity->Local Setup Window

MCPForUnitySetupWindow

可以看到我们已经满足了MCP for Unity的使用条件,点击Next配置MCP Client:

ConfigureMCPClients

我本人主要用Codex和Copilot,所以自选了上述对应的MCP Client。

点击Configure Selected确认MCP Client配置:

MCPClientConfigurationSuccess

可以看到MCP Client也配置成功了。

然后通过Window->MCP for Unity->Toggle MCP Window就能打开MCP for Unity的详细信息窗口:

MCPForUnityEditorWindow

从面板上看,MCP for Unity的Server已经启动起来了,但还没有激活连接。

除了点击MCP for Unity的Start Server按钮也可以点击Copy复制手动启动的命令在PowerShell里执行:

1
C:\Users\TonyTang\.local\bin\uvx.exe --from "mcpforunityserver==10.2.0" mcp-for-unity --transport http --http-url http://127.0.0.1:8080 --project-scoped-tools

但貌似启动失败了,好像是8080端口被占用了(应该是因为我们已经启动了一个MCP for Unity的Server占用了8080端口):

RunMCPForUnityFailed

8080端口挺常见的,之前Jenkins搭建好像也是8080端口,为了避免端口冲突,我将MCP for Unity窗口上HTTP连接的端口修改成8081,MCP for Unity Server启动起来后我们点击MCP for Unity的Connect按钮,确认Server启动起来且能连接成功:

MCPForUnityServerConnectSuccess

配置Codex Client

要想Codex能使用MCP for Unity,我们首先要在MCP for Unity的Editor Window选中Codex并点击Configure配置相关配置到Codex的config.toml配置文件。

MCPForUnityCodexConfigContent

为了Codex这个MCP Client能很好的使用MCP for Unity,我们还需要通过MCP for Unity选中Codex作为Client后,点击Install Skills:

InstallCodexMCPForUnitySkills

安装Codex MCP for Unity Skills成功:

InstallCodexMCPForUnitySkillsSuccess

打开Codex的Customize窗口点击Skills可以看到MCP for Unity给我们安装了一个Unity MCP Orchestrator的Skill:

MCPForUnityCodexSkillInstalled

MCP for Unity使用

准备工作完成了,接下来我们测试下通过Codex用自然语言驱动Unity做事。

UsingCodexAndMCPToOperateUnity

CodeCreateRedCubeDialog

CodexCreateRedCubeInUnity

通过始终允许MCP for Unity使用功能一系列的工具后,Codex最终真的创建除了符合我们需求的红色Cube并保存到了我们描述的目录和指定的名字。

走完这一步正面我们已经打通了Codex+MCP for Unity的AI自动化做事的基础流程。

MCP for Unity自带的相关工具在MCP for Unity的Editor Window->Tools下可以看到:

MCPForUnityEditorWindowTools

自定义MCP for Unity工具

实际开发项目过程中,只使用MCP for Unity提供的自带工具还不满足需求,比如我们要想支持打包工具的调用。

  1. 首先我们需要再Editor下定义一个静态class且需要定义McpForUnityTool标签**
  2. 然后定义一个Class作为工具类型,工具类型里定义工具参数成员,工具参数成员必须带上ToolParameter属性标签
  3. 带McpForUnityTool标签的静态class定义静态方法HandleCommand(JObject params)方法进行工具调用处理,需要返回SuccessResponse类表示成功,ErrorReesponse表示出错

实战定义一个BuildToolMcp.cs文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/// <summary>
/// BuildToolMcp.cs
/// 打包工具MCP for Unity
/// </summary>
[McpForUnityTool("打包工具Mcp for Unity")]
public static class BuildToolMcp
{
/// <summary>
/// 打包工具参数
/// </summary>
public class BuildToolParams
{
[ToolParameter("版本号")]
public double VersionCode
{
get;
set;
}

[ToolParameter("资源版本号")]
public int ResourceCode
{
get;
set;
}

[ToolParameter("是否重新构建AB", Required = false)]
public bool RebuildAB
{
get;
set;
}
}

/// <summary>
/// 处理打包工具命令
/// </summary>
/// <param name="parameters"></param>
/// <returns></returns>
public static object HandleCommand(JObject parameters)
{
var buildToolParams = parameters.ToObject<BuildToolParams>();
if(buildToolParams.VersionCode <= 0)
{
return new ErrorResponse($"版本号:{buildToolParams.VersionCode}必须大于0!");
}
else if(buildToolParams.ResourceCode <= 0)
{
return new ErrorResponse($"资源版本号:{buildToolParams.ResourceCode}必须大于0!");
}

DoBuild(buildToolParams);

return new SuccessResponse("Custom tool executed successfully!", new
{
buildToolParams.VersionCode,
buildToolParams.ResourceCode,
buildToolParams.RebuildAB,
});
}

/// <summary>
/// 执行打包操作
/// </summary>
/// <param name="buildToolParams"></param>
private static void DoBuild(BuildToolParams buildToolParams)
{
Debug.Log($"执行Mcp for Unity打包!打包参数: 版本号={buildToolParams.VersionCode}, 资源版本号={buildToolParams.ResourceCode}, 是否重新构建AB={buildToolParams.RebuildAB}");
}
}

工具定义好后,我们需要确认下MCP for Unity是否自动检测添加了该自定义工具,打开MCP for Unity的Editor窗口->Tools->Custom Tools:

CreateCustomToolForMCPForUnity

可以看到MCP for Unity已经自动检测到了我们定义的自定义工具,接下来我们去Codex测试主动使用自定义工具触发打包。

UsingCustomMcpToolInCodex

UsingCustomMcpToolInCodexLog

可以看到我们成功使用Codex自然语言驱动调用了我们给MCP for Unity定义的自定义打包工具,打包参数也和我们描述的一样,日志正确。

至此打通了MCP for Unity自定义工具使用功能的流程,未来更多DIY的需求想支持AI使用,我们可以自定义MCP for Unity工具即可。

如果Codex没发现该工具,可以重新启动下MCP for Unity Server和Codex的连接

如果有那种复杂的工具任务,没法单帧同步执行完成的,可以使用RequiresPolling标签属性去定义静态类工具,详情参考:

Adding Custom Tools to MCP for Unity

AI自动化实战

创建指定字体的TMP字体文件

首先考虑到主要支持英文和中文,所以我需要下载Noto Sans Simplified Chinese字体文件作为游戏字体,下载地址:Noto+Sans+SC

下载好后放到项目对应目录:

SaveNotoSansSCVariableFontAsset

下载好后,我直接Codex通过MCP for Unity让AI自动制作一个基于NotoSansSC-Variable.ttf字体的TMP包含常用中文字的TMP字体文件:

CodexCreateNotoSansSCTMPFontAsset

CodexCreateNotoSansSCTMPFontAssetResult

可以看到通过Codex+MCP for Unity,真的制作了符合需求的TMP字体文件。

接下来我们让他基于创建的TMP字体文件,新创建一个有Outline的材质球用于有描边的字体使用情况:

CodexCreateTMPMaterial

CodexCreateTMPMaterialAsset

可以看到成功通过AI创建了基于前面创建的TMP字体文件的Outline材质球。

但我发现即使我选择了Bold+50字号+Outline材质球,TMP文字依然看起来很细:

NotoSansSCVariableSDFTMPPreview

根据AI的说法是我下载的字体是包含Bold的,只是默认字体的wght值很小所以很细,要想支持粗一点的文字显示,我们需要将NotoSansSC-Variable.ttf使用相关工具制作一个wght=700的Bold字体文件:

CreateNonoSansSCBoldFontAsset

有了粗体的字体文件,我们重新走一遍前面的TMP字体Asset和Outline材质球流程:

CreateNonoSansSCBoldRelativeAssets

NonoScansSCBoldTMPResult

可以看到字体在没勾选Bold的情况,只是Outline材质都已经显示的比较粗了。

至此我们成功利用AI帮我制作了TMP字体Asset和字体材质球Asset。

AI自动拆分效果图

一般工作流是我们从美术那里得到效果图(png)资源,美术将切个好的单个元素图片也给我们,让我们利用所有单个图片元素和效果图进行UI窗口效果还原。

而AI自动拆分效果图这一步主要是解决我们得到效果图(无论是美术给的还是AI自动生成的),我们直接利用AI实现自动拆分单个元素图片,当我们把单个元素图片拆分得到后,我们才能进行下一步UI拼接效果图还原。

切图工具制作

根据AI的说法要想实现快速根据效果图自动切割拆分图片元素,且实现切割图片元素宽高是2或者4的倍数,要求切割的图片元素尽量少留空白,同时切割元素空白部分用透明提到,导出单张图片格式统一为png这个功能,我们需要先让AI制作一个能实现上述切割需求的脚本工具。

CodexCreateCropImagesTool

可以看到生成的工具启动入口是crop.ps1这个是PowerShell的脚本程序,为了方便跨平台我后续又让AI优化成纯Python的版本:

OptimizeCodexCreateCropImagesTool

可以看到这下支持上述需求的切割工具纯Python化算是完成了,接下来就是让AI使用这个工具去自动分析效果图切割出我们想要的所有单张元素图了。

AI自动拆分图片元素

这里我在网上下载了一张休闲游戏的UI主界面效果图用来测试:

UITemplate

为了避免切割出文字和带锁的元素图,我故意在描述词里去避免了这一点,因为游戏里锁定的置灰效果一般是通过切换置灰材质去实现的,而文本通常不做在图里而是文本单独添加。

UseImageCropperToolSplitUI

UIPictureSplitResult

SplitPictureInfo

可以看到AI根据我们的需求真的切割出了不带文字,单独切割锁图片,不太灰色元素图,图片宽高为2或4的倍数的所有单张元素图。

但我发现AI切割出来的图片元素取名都是带数字?_abc.png的方式,实际开发中并不希望看到数字开头,所以让AI继续优化一版即可,描述词大概如下:

1
目前切割出来的元素图片名字都是数字开头,能去掉数字开头直接按图片元素含义取对应名字吗?除非有雷同元素可以+数字结尾区分。

后面我发现切割出来的元素图没有保持在参考图里的相对像素大小,所以我有继续调优描述:

1
将附件效果图拆分出现的元素图片,要求切割的元素图片周边不要留太多透明空白区域,拆分的图片要符合宽高是2或者4的倍数,拆分的图片不能带文字,保证拆分后的图片内容的相对像素大小占比相对效果图里的大小不变,顺便再把效果图里的各文字的颜色信息和字号大小给输出一份介绍文档了。

制作过程中Codex需要自己造Python工具去辅助约束校验等,通过提示词允许他执行制造工具,后续工具会成为Skill的工作流的固定工具:

1
如果现有skill不支持,那就先单独写脚本完成我上述任务要求。

制作UI效果图元素拆分Skill

为了方便以后重用这个图片切割元素流程工具,顺势将这个流程用Codex做成Skill方便重用:

CreateUIPerfectSplitterSkill

UIPerfectSplitterSkillUnderCodex

可以看到我成功利用Codex的创建Skill的技能生成了ui-perfect-splitter的UI效果图元素切割Skill,这个Skill的核心目的就是限定根据根据效果图保持图片切割元素像素相对大小的正确切割,同时输出符合2或者4宽高倍数的元素切割图片还有一些不带文字的切割限制设定:

1
2
3
4
5
6
7
8
9
10
---
name: ui-perfect-splitter
description: 将用户上传的原始 UI 效果图拆成无文字透明 PNG,保持元素在原图中的真实像素大小与坐标,紧裁并按 2 或 4 的倍数补边,输出布局清单、回拼验收图及文字颜色字号说明。适用于要求像素比例准确的游戏或应用 UI 切图;不用于普通网格切图或重新设计整个界面
---

# UI 像素保真拆图

以用户上传的原始效果图为唯一像素基准。交付能按坐标回拼的素材,而不是凭视觉近似重新生成一套大小不同的图标

******

内容比较多就只粘贴了Skill的开头部分内容。

使用ui-perfect-splitter Skill

新下载的UI效果图:

UITemplate2

使用Codex拆分指令:

UseUIPerfectSplitterSkill

上面使用网上下的UI效果图资源,没注意图片大小问题,一般来说UI效果图使我们游戏选择的参考分辨率为基准来制作,所以我通过Codex将效果图修改成我要的参考分辨率1080X1920后再重新进行了UI元素切割流程。

最新制作的1080X1920的UI效果图资源:

UITemplate2New

最终拆分图片元素部分效果:

UIPictureSplitFinalResult

接下来将相关拆分出来的图片元素资源放到项目对应目录下:

NewImportUISplitPicutres

后续就是准备让Codex+MCP for Unity利用这些资源和效果图进行自动拼UI了。

AI自动拼UI

通过提示词描述让Codex利用对应效果图和切好的图片拼接UI预制件:

CreateUIPrefabByCodex

UI预制件拼出来了,但我发现很多Image节点都单独创建在一个独立的空节点下然后Anchors值设置很奇怪,同时部分Image设置的Sprite并非切割好的默认大小:

ImageAnchorsAndNewNodeProblem

这里我强制给AI说明Image组件就直接做在排版的父节点上,没必要单独再创建一个节点进行防止Image组件,其次图片大小尽量用原图大小(实在对不上效果图大小用Scale调整匹配),修改后:

AfterOptImageNode

经过一系列调优(比如锚点数字奇怪,统一到0或0.5或者1)自动拼UI,最终UI拼接效果:

FinalAutoCreateUIResult

对一下效果图:

UITemplate2New

可以看到AI自动拼的UI窗口效果已经八九不离十了,只有部分Slider组件或者是一些背景适配和细节字体位置还需要调整。

后续我也将这个拼UI的流程制作成了ui-create-window-prefab的Skill,方便后续重用。

重用已有UI预制件拼UI

但真实开发环境中,我们不仅仅是让AI自己用最原始的组件进行效果图还原,我们还会开发很多通用预制件,很多效果图对应部件都应该使用对应通用预制件制作而非使用原始组件拼接,那么如何让AI知道我们的通用预制件和使用规则进行UI自动拼接了?

AI给的思路是在自动拼UI的Skill里添加已有预制件服用规则描述,让自动拼UI前先去读取AGENTS.md里指定的UI预制件目录表描述信息和规则。UI预制件目录表里会有详细当前项目预制件的信息介绍供AI读取。AI自动UI时就会优先使用符合的已有预制件进行UI制作拼接还原。

Skill添加已有预制件重用规则描述

首先让AI把以下预制件重用规则写入ui-create-window-prefab的Skill里(为了避免不必要的AI自动查找预制件重用匹配过程的token和后续问题,我强制要求了只从UI预制件目录表里去匹配)。

ui-create-window-prefab的Skill相关描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
## 已有预制件复用

- 制作前必须读取适用的项目 AGENTS.md,并读取其中明确指定的 UI 预制件目录表;只有目录表存在且可读取时,才考虑复用其中明确列出的预制件。AGENTS.md 未指定目录表、指定文件不存在或无法读取时,跳过已有预制件复用,简要说明原因,直接使用指定素材和基础组件拼接,不为此阻塞制作或要求用户补建目录表
- 按效果图识别功能区域,为复用区域记录“区域描述或标注编号 → 源 Prefab 路径(可附 GUID)→ 实例父节点与名称 → 布局、内容和状态配置”。图像坐标需注明参考分辨率,再换算为 UI 布局;不要将图像比例坐标直接写为任意比例锚点
- 仅在上述目录表列出的资源内匹配用途、样式和状态;本次任务提供的区域映射用于选择表内资源,不扩展候选范围。没有适用条目或目录表为空时,直接使用指定素材与基础组件制作;条目资源不存在或不支持所需配置时,报告该条目问题并对该区域改用指定素材与基础组件,不搜索表外替代 Prefab
- 不扫描公共 UI 目录、素材目录中的其他 Prefab 或同类窗口寻找候选,不依据名称或外观相似引入表外预制件,也不自动创建或补全目录表。仅对表内与当前区域相关的候选检查实际层级、脚本组件、序列化引用、尺寸策略、支持状态、配置入口和运行时依赖;目录表明确关联的示例可按需查看
- 目录表需明确列出可复用资源的路径或 GUID,可补充用途与不适用场景、允许修改的实例属性、绑定入口和示例;不能把一个待扫描目录视为已列出的资源清单。预览图是可选辅助资料,不要求每个 Prefab 都有独立 PNG;缺少预览时可在隔离的临时 UI 环境实例化查看,不把 Project 缩略图当作已导出的预览文件
- 多个候选在功能和视觉上无法区分时,列出具体区域与候选差异并询问,继续完成不依赖该选择的区域;匹配明确时直接执行,不增加逐项确认流程
- 使用支持保留 Prefab 连接的 Unity MCP 接口或 Unity 编辑器 API 实例化,将其作为目标窗口的嵌套 Prefab 保存;不要 Unpack、复制成普通节点或按外观重新搭建来冒充复用,也不为复用自动生成 Variant
- 仅修改用户、项目约定或组件配置入口允许的实例属性,如位置、允许调整的尺寸、文字、图标与状态;未声明的结构性修改不默认允许。保留脚本、动画、内部节点名称、序列化引用和必要事件配置,不 Apply 回源资源
- 复用实例根节点仅在确认没有名称依赖且允许重命名时按窗口节点规范命名;否则保留原名,需要窗口侧布局或命名入口时可使用规范命名的外层容器。内部节点沿用源 Prefab 的命名、锚点和组件实现,不为统一 TMP/TUI 类型或通用样式规则重写其内部结构
- 窗口侧布局使用实例根节点允许覆盖的 RectTransform,或使用外层容器;组件内部尺寸与锚点交由其既有布局体系管理。检查布局驱动和尺寸限制,避免强行覆盖后在布局更新时被改回
- 先检查复用组件的封装与绑定约定:由独立控制组件或独立 Binder 管理的内部节点保留其绑定,窗口绑定对外入口;没有独立封装、项目约定由窗口管理的按钮、Toggle、文本和动态图片,按自动绑定规则加入窗口 Binder。实际生成器会合并的嵌套绑定仍检查最终作用域的名称冲突
- 复用授权涵盖所选预制件已有的 Sprite、字体、材质及其他必要资源引用,不代表可以任意取用其他目录的图片重新设计;不通过新建全局对象、修改窗口注册或资源加载配置来补足未接入的运行时依赖

添加项目已有预制件目录读取规则

然后我去手动给项目的AGENTS.md和PrefabCatalog.md添加规则和预制件说明信息。

AGENTS.md的UI制作相关描述:

1
2
3
4
## UI 制作

- UI 预制件目录表:Docs/UI/PrefabCatalog.md
- 自动拼接 UI 时必须读取并遵循该目录表

PrefabCatalog.md(预制件目录表描述信息文档)(以下只是示例,详细内容根据项目实际预制件而定):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# UI 预制件目录表

## currencyCounter

- 资源:Assets/UI/Common/CurrencyCounter.prefab
- GUID:填写实际资源 GUID
- 用途:顶部金币、钻石等货币数量显示
- 外观:左侧货币图标,中间数值,右侧加号
- 不适用:体力倒计时、商品购买按钮
- 推荐尺寸:240 × 72,单位为设计画布 UI 单位
- 尺寸策略:允许调整宽度;保持高度和图标比例
- 使用示例:WindowMainUI 中的金币栏
- 预览图:Docs/UI/Previews/CurrencyCounter.png

### 配置入口

- tmpAmount:修改显示数量
- imgCurrencyIcon:修改货币图标
- btnAdd:控制加号是否显示

### 允许的实例修改

- 根节点位置、锚点和允许范围内的尺寸
- 数值文字
- 货币图标
- 加号显隐

### 必须保留

- 组件脚本、内部节点名称和序列化引用
- 按钮已有的必要回调
- 内部 ComponentBinder 数据

制作通用预制件

制作以下几个预制件:

  1. 一个带图标和文本的通用按钮预制件(CommonIconBtn.prefab)

  2. 一个带底板,带图标和文本的通用按钮预制件(CommonBgBtn.prefab)

  3. 一个通用的红点预制件(RedDot.prefab)

  4. 一个通用资源栏预制件(CommonResBar.prefab)

CreateCommonPrefab

然后在Docs/UI/PrefabCatalog.md文档里编写预制件说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# UI 预制件目录表

## CommonIconBtn

- 资源:Assets/Resources/prefabs/UI/Common/CommonIconBtn.prefab
- 用途:仅包含一个图标和文本的按钮显示
- 外观:中间图标,底部文本描述
- 不适用:包含图标和文本以外节点或组件的按钮情况
- 推荐尺寸:116 × 120,单位为设计画布 UI 单位
- 尺寸策略:允许修改成适配图标大小的尺寸
- 使用示例:无
- 预览图:无

### 配置入口

- CommonIconBtn:修改图标
- tmpDes:修改文本描述

### 允许的实例修改

- 根节点图标的Sprite和大小
- tmpDes文本描述

### 必须保留

- 组件脚本、内部节点名称和序列化引用
- 按钮已有的必要回调
- 内部 ComponentBinder 数据



## CommonBgBtn

- 资源:Assets/Resources/prefabs/UI/Common/CommonBgBtn.prefab
- 用途:仅包含一个底板,一个图标和文本的按钮显示
- 外观:中间图标,底部文本描述,最里层背景图标
- 不适用:包含背景,图标和文本以外节点或组件的按钮情况
- 推荐尺寸:100 × 124,单位为设计画布 UI 单位
- 尺寸策略:允许修改成适配底板和图标大小的尺寸
- 使用示例:无
- 预览图:无

### 配置入口

- CommonBgBtn:修改底板
- imgIcon:修改图标
- tmpDes:修改文本描述

### 允许的实例修改

- 根节点底板图标的Sprite和大小
- imgIcon图标的Sprite和大小
- tmpDes文本描述

### 必须保留

- 组件脚本、内部节点名称和序列化引用
- 按钮已有的必要回调
- 内部 ComponentBinder 数据



## CommonResBar

- 资源:Assets/Resources/prefabs/UI/Common/CommonResBar.prefab
- 用途:顶部金币、钻石等货币数量显示
- 外观:左侧货币图标,中间数值,右侧加号,背景底板图标
- 不适用:体力倒计时、商品购买按钮
- 推荐尺寸:240 × 56,单位为设计画布 UI 单位
- 尺寸策略:允许调整宽度,保持高度
- 使用示例:无
- 预览图:无

### 配置入口

- imgResIcon:修改货币图标
- imgResAmount:修改货币数量文本
- btnResAdd:控制加号按钮是否显示

### 允许的实例修改

- 允许调整根节点宽度,保持高度
- 货币数量文本
- 货币图标
- 加号显隐

### 必须保留

- 组件脚本、内部节点名称和序列化引用
- 按钮已有的必要回调
- 内部 ComponentBinder 数据



## RedDot

- 资源:Assets/Resources/prefabs/UI/Common/RedDot.prefab
- 用途:各入口的红点显示
- 外观:中间显示红点图标
- 不适用:非红点显示
- 推荐尺寸:36 × 36,单位为设计画布 UI 单位
- 尺寸策略:允许调整宽度和高度保持相同比例
- 使用示例:无
- 预览图:无

### 配置入口

- imgRedDot:修改红点图标

### 允许的实例修改

- 根节点宽高保持比例调整
- 红点图标

### 必须保留

- 组件脚本、内部节点名称和序列化引用
- 内部 ComponentBinder 数据

AI自动用已有预制件拼UI

然后基于已经制作的通用预制件和相关描述文件,我们重新AI自动拼接一次效果图:

UseCommonPrefabToAutoCreatePrefab

可以看到Codex真的实现了按需使用指定规则的通用预制件进行效果图还原,但不知道为什么AI给每一层使用通用预制件的地方都单独定义了一个独立父节点,这一点我让AI将此问题优化写入最新的ui-create-window-prefab的Skill。

然后我们再次重新AI自动拼接一次效果图:

UseCommonPrefabToAutoCreatePrefabSuccess

可以看到AI成功使用符合要求的预制件制作还原了效果图!

但我发现通用预制件虽然AI自动切换了底板,图标和文本,但底板和图标的显示大小确没有修改,所以细节上还并没有正确还原效果图:

AutoCreatePrefabSizeProblem

通过进一步优化ui-create-window-prefab的Skill,再次重新AI自动拼接一次效果图:

AutoCreatePrefabSizeProblemFix

可以看到图标的有效大小问题已经成功解决,可以说目前自动拼出来的预制件已经符合90%可用程度了。

逻辑代码自动化

这一步需要引入窗口和组件绑定相关代码,所以会根据我真实搭建的项目框架实践。

逻辑代码自动化目前分为以下两步:

  1. 模版+组件绑定代码生成
  2. AI生成逻辑代码

模版+组件绑定代码生成

一般来说每个项目为了快速开发,都有配套的代码生成工具,比如窗口代码生成工具,子组件代码生成工具。

我的项目使用了之前我写的ComponentBinder,根据ComponentBinder选择的组件绑定类型,会使用不同的代码模版进行配套的组件绑定partial class生成,详细参见截图:

ComponentBinderInspector

窗口模版文件WindowUITemplate.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
* Description: #FileName#.cs
* Author: #Author#
* Create Date: #CreatedDate#
*/

using UnityEngine;
using UnityEngine.UI;
using TH.Modules.UI;
using TMPro;

namespace Game.Modules.UI
{
/// <summary>
/// #ClassName#.cs
/// #ClassName#窗口
/// </summary>
public partial class #ClassName# : BaseWindow
{
public #ClassName#()
{

}

/// <summary>
/// 添加监听
/// </summary>
protected override void AddListeners()
{
base.AddListeners();
}

/// <summary>
/// 添加事件监听
/// </summary>
protected override void AddEvents()
{
base.AddEvents();
}

/// <summary>
/// 窗口显示
/// </summary>
protected override void OnShow()
{
base.OnShow();
}

/// <summary>
/// 窗口销毁
/// </summary>
protected override void OnDestroy()
{
base.OnDestroy();
}
}
}

窗口组件绑定模版文件WindowUIBinder.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* Description: #FileName#.cs
* Author: #Author#
* Create Date: #CreatedDate#
*/

using UnityEngine;
using UnityEngine.UI;
using TH.Modules.UI;
using TUI;
using TMPro;

namespace Game.Modules.UI
{
/// <summary>
/// #ClassName#窗口的组件绑定
/// </summary>
public partial class #ClassName# : BaseWindow
{
#MEMBER_DEFINITION_LOOP#
/// <summary> #NodeDes# /// </summary>
private #NodeType# #NodeName#;#MEMBER_DEFINITION_LOOP#

/// <summary>
/// 缓存组件
/// </summary>
protected override void CacheComponents()
{
base.CacheComponents();
#MEMBER_INIT_LOOP#
#NodeName# = #NodeMemberName#.NodeDatas[#NodeIndex#].NodeTarget as #NodeType#;#MEMBER_INIT_LOOP#
}

/// <summary>
/// 释放组件
/// </summary>
protected override void ClearComponents()
{
base.ClearComponents();
#MEMBER_DISPOSE_LOOP#
#NodeName# = null;#MEMBER_DISPOSE_LOOP#
}
}
}

最终窗口代码生成WindowTravelHandbook.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*
* Description: WindowTravelHandbook.cs
* Author: TONYTANG
* Create Date: 2026//10/13
*/

using UnityEngine;
using TH.Modules.UI;
using System.Collections.ObjectModel;

namespace Game.Modules.UI
{
/// <summary>
/// WindowTravelHandbook.cs
/// </summary>
public partial class WindowTravelHandbook : BaseWindow
{

public WindowTravelHandbook()
{

}

/// <summary>
/// 添加监听
/// </summary>
protected override void AddListeners()
{
base.AddListeners();
}

/// <summary>
/// 移除监听
/// </summary>
protected override void RemoveListeners()
{
base.RemoveListeners();
}

/// <summary>
/// 添加事件监听
/// </summary>
protected override void AddEvents()
{
base.AddEvents();
}

/// <summary>
/// 窗口显示
/// </summary>
protected override void OnShow()
{
base.OnShow();
}

/// <summary>
/// 窗口销毁
/// </summary>
protected override void OnDestroy()
{
base.OnDestroy();
}
}
}

最终窗口组件绑定代码生成WindowTravelHandbookBinder.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/*
* Description: WindowTravelHandbookBinder.cs
* Author: TONYTANG
* Create Date: 2026//10/19
*/

using UnityEngine;
using UnityEngine.UI;
using TH.Modules.UI;
using TUI;
using TMPro;

namespace Game.Modules.UI
{
/// <summary>
/// WindowTravelHandbook窗口的组件绑定
/// </summary>
public partial class WindowTravelHandbook : BaseWindow
{

/// <summary> 返回图鉴 /// </summary>
private TButton tbtnBackButton;
/// <summary> 图鉴入口文字 /// </summary>
private TextMeshProUGUI tmpBackTitleText;
/// <summary> 伙伴详情标题 /// </summary>
private TextMeshProUGUI tmpPageTitleText;
/// <summary> 收藏状态 /// </summary>
private TImage timgBookmarkStateIcon;
/// <summary> 收藏状态 /// </summary>
private TButton tbtnBookmarkButton;
/// <summary> 伙伴场景图 /// </summary>
private TImage timgPortraitSceneBackground;
/// <summary> 伙伴立绘 /// </summary>
private TImage timgCompanionPortrait;
/// <summary> 伙伴图鉴编号 /// </summary>
private TextMeshProUGUI tmpCompanionNumberText;
/// <summary> 伙伴中文名称 /// </summary>
private TextMeshProUGUI tmpCompanionNameText;
/// <summary> 伙伴所属国旗 /// </summary>
private TImage timgCountryFlagIcon;
/// <summary> 伙伴所属国家 /// </summary>
private TextMeshProUGUI tmpCountryNameText;
/// <summary> 外形特点标题 /// </summary>
private TextMeshProUGUI tmpFeaturesTitleText;
/// <summary> 外形特点一 /// </summary>
private TextMeshProUGUI tmpFeature1Text;
/// <summary> 外形特点二 /// </summary>
private TextMeshProUGUI tmpFeature2Text;
/// <summary> 外形特点描述 /// </summary>
private TextMeshProUGUI tmpFeaturesDescriptionText;
/// <summary> 图鉴介绍标题 /// </summary>
private TextMeshProUGUI tmpIntroductionTitleText;
/// <summary> 图鉴介绍内容 /// </summary>
private TextMeshProUGUI tmpIntroductionDescriptionText;
/// <summary> 旅行知识标题 /// </summary>
private TextMeshProUGUI tmpTravelTipTitleText;
/// <summary> 旅行知识内容 /// </summary>
private TextMeshProUGUI tmpTravelTipDescriptionText;
/// <summary> 背景图图片 /// </summary>
private TImage timgPageBackground;

/// <summary>
/// 缓存组件
/// </summary>
protected override void CacheComponents()
{
base.CacheComponents();

tbtnBackButton = mComponentBinder.NodeDatas[0].NodeTarget as TButton;
tmpBackTitleText = mComponentBinder.NodeDatas[1].NodeTarget as TextMeshProUGUI;
tmpPageTitleText = mComponentBinder.NodeDatas[2].NodeTarget as TextMeshProUGUI;
timgBookmarkStateIcon = mComponentBinder.NodeDatas[3].NodeTarget as TImage;
tbtnBookmarkButton = mComponentBinder.NodeDatas[4].NodeTarget as TButton;
timgPortraitSceneBackground = mComponentBinder.NodeDatas[5].NodeTarget as TImage;
timgCompanionPortrait = mComponentBinder.NodeDatas[6].NodeTarget as TImage;
tmpCompanionNumberText = mComponentBinder.NodeDatas[7].NodeTarget as TextMeshProUGUI;
tmpCompanionNameText = mComponentBinder.NodeDatas[8].NodeTarget as TextMeshProUGUI;
timgCountryFlagIcon = mComponentBinder.NodeDatas[9].NodeTarget as TImage;
tmpCountryNameText = mComponentBinder.NodeDatas[10].NodeTarget as TextMeshProUGUI;
tmpFeaturesTitleText = mComponentBinder.NodeDatas[11].NodeTarget as TextMeshProUGUI;
tmpFeature1Text = mComponentBinder.NodeDatas[12].NodeTarget as TextMeshProUGUI;
tmpFeature2Text = mComponentBinder.NodeDatas[13].NodeTarget as TextMeshProUGUI;
tmpFeaturesDescriptionText = mComponentBinder.NodeDatas[14].NodeTarget as TextMeshProUGUI;
tmpIntroductionTitleText = mComponentBinder.NodeDatas[15].NodeTarget as TextMeshProUGUI;
tmpIntroductionDescriptionText = mComponentBinder.NodeDatas[16].NodeTarget as TextMeshProUGUI;
tmpTravelTipTitleText = mComponentBinder.NodeDatas[17].NodeTarget as TextMeshProUGUI;
tmpTravelTipDescriptionText = mComponentBinder.NodeDatas[18].NodeTarget as TextMeshProUGUI;
timgPageBackground = mComponentBinder.NodeDatas[19].NodeTarget as TImage;
}

/// <summary>
/// 释放组件
/// </summary>
protected override void ClearComponents()
{
base.ClearComponents();

tbtnBackButton = null;
tmpBackTitleText = null;
tmpPageTitleText = null;
timgBookmarkStateIcon = null;
tbtnBookmarkButton = null;
timgPortraitSceneBackground = null;
timgCompanionPortrait = null;
tmpCompanionNumberText = null;
tmpCompanionNameText = null;
timgCountryFlagIcon = null;
tmpCountryNameText = null;
tmpFeaturesTitleText = null;
tmpFeature1Text = null;
tmpFeature2Text = null;
tmpFeaturesDescriptionText = null;
tmpIntroductionTitleText = null;
tmpIntroductionDescriptionText = null;
tmpTravelTipTitleText = null;
tmpTravelTipDescriptionText = null;
timgPageBackground = null;
}
}
}

这样绑定生成后,我们最基本的窗口代码就有了,同时也能通过直接访问生成的对应变量直接访问绑定的组件而不需要写任何一行代码。

上面因为我绑定ComponentBinder的预制件叫WindowTravelHandbook,所以生成的窗口代码也叫WindowTravelHandbook

AI生成逻辑代码

上一步只是完成了代码模版和组件绑定的初步生成,真正的逻辑代码还需要借助AI进行根据需求描述进行代码生成。

这一步需要结合代码生成+实际逻辑需求进行和AI对话生成,TODO。

Reference

MCP for Unity

MCP for Unity Getting Started

前言

本章节是深入UGUI多分辨率和异形屏的UI适配解决方案深入实战学习。

用到的东西:

Unity6000.3.19f1,CodeX,VSCode,Copilot,URP

UGUI适配

UGUI的UI适配核心目的是让我们的UI内容在不同的分辨率和特殊屏幕能在合适的位置显示给玩家,不至于出现核心内容看不到或者UI按钮被遮挡等显示问题。

适配结果有几个基础需求要满足:

  1. UI必须充满屏幕,不能有黑边
  2. UI必须在屏幕中,不能超出屏幕
  3. UI在多分辨率下的位置和尺寸保持一致
  4. UI不能变形
  5. 重要信息UI不能出现在特殊位置(比如刘海屏矩形刘海,水滴屏摄像头,挖孔屏特殊挖空等)

多分辨率适配

先不考虑特殊屏幕的前提下,来看看UGUI的多分辨率适配方案是怎样的。

设计分辨率这里我选择1920X1080(16:9),所以我把Canvas Scaler的Reference Resolution设置成1920X1080,Canvas Scales的Screen Match Mode设置成Match Width Or Height,Match设置成0(Width适配)

SetReferenceResolution

此时策划说我们要做一个窗口,包含一张全屏背景图(美术出了一张1920X1080的背景图),左上角显示玩家名字文本,左下,左中,右下,右中,右上和上中分别显示一个操作按钮。

所以我做了一个背景图片设置成固定宽高为1920X1080的Image和根据位置设置指定轴心(Pivot)和锚点(Anchors)去适配目标位置显示,如果设备分辨率就是1920X1080则内容完美显示:

1920X1080ResolutionDisplay

此时如果我将分辨率调整成2520X1080(21:9):

2520X1080ResolutionDisplay

此时设备分辨率宽高比例大于设计分辨率宽高比例,可以看到背景图因为前面的适配方式是**Match Width Or Height(Match值为0适配宽度)**的自适应后背景图变成宽度铺满显示但高度上下被裁剪显示,这样能符合背景图片不拉伸但铺满的需求,只是因为上下被裁剪不少所以设计全屏1920X1080背景图时要注意上下流出一部分可裁剪空间。

如果我将分辨率调整成2304X1440(16:10):

2304X1440ResolutionDisplay1

此时设备分辨率宽高比例小于设计分辨率宽高比例,可以看到背景图因为前面的适配方式是**Match Width Or Height(Match值为0适配宽度)**的自适应后背景图编程宽度铺满但高度显示不全镂空显示,这并不符合我们不希望背景图铺满的需求。

此时为了全屏背景不拉伸能铺满,我们应该采取Match Width Or Height(Match值为1适配高度):

2304X1440ResolutionDisplay2

可以看到修改成Match值为1适配高度后,高度铺满了符合背景铺满不拉伸的需求,只是背景图左右两边被裁剪了一部分,只是因为左右被裁剪不少所以设计全屏1920X1080背景图时要注意左右流出一部分可裁剪空间。

从上面可以看出关于屏幕适配,我们的Canvas Scaler的Match Width Or Height的Match值不是一层不变的。在屏幕宽高分辨率大于设计宽高分辨率时我们要适配宽度(Match值为0)从而实现上下裁剪全屏铺满效果。在屏幕宽高分辨率小于设计宽高分辨率时我们要适配高度(Match值为1)从而实现左右裁剪全屏铺满效果。

上述规则其实等价于将Canvas Scaler的Screen Match Mode设置成Shrink,至于为什么市面上大部分都选择代码动态计算Match Width Or Height的Match值,个人暂时还没想明白,目前我暂时直接使用Shrink的设置方式。

Note:

  1. 2026/09/20截止主流移动端设备分辨率为16:9,极端分辨率分别为21:9和16:10(含手机和平板)

异形屏适配

前面解决的多分辨率背景适配的问题,但后来移动设备除了很多各式各样的屏幕,比如刘海屏矩形刘海,水滴屏摄像头,挖孔屏特殊挖空等。

  • 刘海屏: 在屏幕上方中间位置,有一块矩形刘海

    LiuHaiPingExample

  • 水滴屏:在屏幕上方中间有一个水滴状摄像头

    ShuiDiPingExample

  • 挖孔屏:屏幕上方不规则的挖空,一般是摄像头位置

    WaKongPingExample

可以看出上述形状各异的屏幕,导致特殊区域如果显示UI会被遮挡看不到或无法操作。

Unity Editor提供了快速模拟移动设备显示的工具(Unity Editor->Window->General->Device Simulator),让我们来模拟看看效果:

AppleIphoneXSMaxDeviceSimulator

可以看到在Apple Iphone XS Max设备上模拟最右侧被所谓的刘海屏挡住了部分按钮显示,黄色的区域是我勾上Safe Area会指出来的安全区域

要解决上述问题,这里要引出一个概念安全区域。

  • 安全区域:指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners)、齐刘海(sensor housing)、小黑条(Home Indicator)的影响。

下方蓝色区域即为安全区域:

SafeAreaExample

安全区域已经给出了安全显示内容的范围,那么我们处理异形屏的显示方案最简单的就是将窗口内容显示都放到这个安全区域即可。

ScreenSafeAreaService.cs(负责统一处理安全区域的计算处理,挂载到游戏一开始就能启动的GameObejct身上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
using System;
using UnityEngine;
using Screen = UnityEngine.Device.Screen;

/// <summary>
/// ScreenSafeAreaService.cs
/// 全局安全区状态服务。挂在启动场景的专用根对象上,早于窗口初始化。
/// 仅数据变化时通知窗口;无效的临时屏幕状态不会覆盖最后一次有效状态。
/// </summary>
[DefaultExecutionOrder(-1000)]
[DisallowMultipleComponent]
public sealed class ScreenSafeAreaService : MonoBehaviour
{
/// <summary>
/// 屏幕安全区快照结构体。包含屏幕宽高和安全区矩形信息
/// </summary>
public readonly struct Snapshot
{
/// <summary>
/// 屏幕宽度
/// </summary>
public readonly int ScreenWidth;

/// <summary>
/// 屏幕高度
/// </summary>
public readonly int ScreenHeight;

/// <summary>
/// 安全区矩形
/// </summary>
public readonly Rect SafeArea;

/// <summary>
/// 安全区最小锚点(左下角)
/// </summary>
public Vector2 AnchorMin
{
get
{
return new Vector2(SafeArea.xMin / ScreenWidth, SafeArea.yMin / ScreenHeight);
}
}

/// <summary>
/// 安全区最大锚点(右上角)
/// </summary>
public Vector2 AnchorMax
{
get
{
return new Vector2(SafeArea.xMax / ScreenWidth, SafeArea.yMax / ScreenHeight);
}
}

/// <summary>
/// 构造函数
/// </summary>
/// <param name="screenWidth">屏幕宽度</param>
/// <param name="screenHeight">屏幕高度</param>
/// <param name="safeArea">安全区矩形</param>
public Snapshot(int screenWidth, int screenHeight, Rect safeArea)
{
ScreenWidth = screenWidth;
ScreenHeight = screenHeight;
SafeArea = safeArea;
}
}

/// <summary>
/// 获取全局唯一的屏幕安全区服务实例
/// </summary>
public static ScreenSafeAreaService Instance { get; private set; }

/// <summary>
/// 当屏幕安全区发生变化时触发,传递最新的快照信息
/// </summary>
public event Action<Snapshot> Changed;

/// <summary>
/// 是否已经有有效的屏幕安全区快照
/// </summary>
public bool HasSnapshot
{
get;
private set;
}

/// <summary>
/// 当前的屏幕安全区快照信息
/// </summary>
public Snapshot Current
{
get;
private set;
}

private void Awake()
{
if (Instance != null && Instance != this)
{
// 只移除重复组件,不删除对象上的其他业务组件。
enabled = false;
Destroy(this);
return;
}

Debug.Log($"ScreenSafeAreaService.Instance初始化完成!");
Instance = this;
DontDestroyOnLoad(gameObject);
CheckForChanges();
}

private void Update()
{
CheckForChanges();
}

private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}

Changed = null;
}

/// <summary>
/// 检查屏幕安全区是否发生变化,如果发生变化则更新快照并触发 Changed 事件
/// </summary>
private void CheckForChanges()
{
int width = Screen.width;
int height = Screen.height;

if (width <= 0 || height <= 0)
{
return;
}

Rect raw = Screen.safeArea;
float xMin = Mathf.Clamp(raw.xMin, 0f, width);
float yMin = Mathf.Clamp(raw.yMin, 0f, height);
float xMax = Mathf.Clamp(raw.xMax, xMin, width);
float yMax = Mathf.Clamp(raw.yMax, yMin, height);

// 取反判断也会拒绝 NaN。
if (!(xMax > xMin) || !(yMax > yMin))
{
return;
}

Rect safeArea = Rect.MinMaxRect(xMin, yMin, xMax, yMax);

if (HasSnapshot &&
Current.ScreenWidth == width &&
Current.ScreenHeight == height &&
Current.SafeArea.Equals(safeArea))
{
return;
}

Current = new Snapshot(width, height, safeArea);
HasSnapshot = true;
Changed?.Invoke(Current);
Debug.Log($"检测到屏幕分辨率变化,最新宽度: {width}, 最新高度: {height}, 安全区: {safeArea}");
}
}

WindowSafeAreaAdapter.cs(负责单个窗口的安全区域RectTransform设置,挂载到每一个需要安全区适配的窗口根节点上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using UnityEngine;

/// <summary>
/// WindowSafeAreaAdapter.cs
/// 窗口创建时订阅,销毁时取消;隐藏期间仍接收普通 C# 事件。
/// 仅支持主显示器的根 Overlay Canvas,其直接子节点作为内容根节点。
/// 不绘制黑边、不裁剪内容、不改变相机视口。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(Canvas))]
public sealed class WindowSafeAreaAdapter : MonoBehaviour
{
/// <summary>
/// 屏幕安全区服务实例
/// </summary>
private ScreenSafeAreaService service;

/// <summary>
/// 内容RectTransform
/// </summary>
private RectTransform mContentRoot;

private void Awake()
{
mContentRoot = GetComponent<RectTransform>();

service = ScreenSafeAreaService.Instance;
if (service == null)
{
Debug.LogError($"必须先初始化并启用,再创建窗口。", this);
service = null;
return;
}

service.Changed += Apply;

// 只读取服务缓存,不要求服务重新采样。
if (service.HasSnapshot)
{
Apply(service.Current);
}
}

/// <summary>
/// 响应销毁
/// </summary>
private void OnDestroy()
{
if (service != null)
{
service.Changed -= Apply;
}
service = null;
}

/// <summary>
/// 应用屏幕安全区快照
/// </summary>
/// <param name="snapshot">屏幕安全区快照</param>
private void Apply(ScreenSafeAreaService.Snapshot snapshot)
{
if (mContentRoot == null)
{
return;
}

Vector2 min = snapshot.AnchorMin;
Vector2 max = snapshot.AnchorMax;

if (mContentRoot.anchorMin == min &&
mContentRoot.anchorMax == max &&
mContentRoot.offsetMin == Vector2.zero &&
mContentRoot.offsetMax == Vector2.zero)
{
return;
}

mContentRoot.anchorMin = min;
mContentRoot.anchorMax = max;
mContentRoot.offsetMin = Vector2.zero;
mContentRoot.offsetMax = Vector2.zero;
Debug.Log($"应用屏幕安全区快照,AnchorMin: {min}, AnchorMax: {max},OffsetMin: {mContentRoot.offsetMin}, OffsetMax: {mContentRoot.offsetMax}");
}
}

上面放了详细代码,就不详细介绍了,核心就是根据屏幕宽高和安全区域大小计算出窗口应该显示的位置大小从而实现将窗口只显示到安全区域内的显示。

AppleIphoneXSMaxUIAdaptionLog

AppleIphoneXSMaxSimulatorDisplay

AppleIphoneXSMaxSceneDisplay

通过Unity的Device Simulator,我成功在Editor模拟了Apple Iphone XS Max的真机适配效果,可以看到因为我们通过得到的Screen.SafeArea计算出窗口的RectTransform显示范围,从而将所有有Pivot和Anchor适配的内容显示到了安全区域内,而背景图因为没设置适配所以只受Canvas Scaler的Screen Match Mode(Shrink)影响。

Note:

  1. 老版Untiy(Unity 6之前)屏幕宽高分辨率用UnityEngine.Screen.Width和UnityEngine.Screen.Height获取,新版Unity(含Unity 6和之后)屏幕宽高分辨率用UnityEngine.Device.Screen.Width和UnityEngine.Device.Screen.Height获取

UGUI适配方案

通过前面的学习我们得到UGUI适配方案如下:

  1. 将Canvas Scaler的UI Scale Mode设置成Scale With Screen Size,Screen Match Mode设置成Shrink(这个可以换成通过比较设备分辨率比例和设计分辨率比例的大小动态设置Screen Match Mode为Match Width Or Height的Match值来实现相同效果),Reference Resolution设置成参考分辨率,背景图Image不设置适配,直接设置成设计分辨率大小,从而实现背景图自动适配铺满不拉伸的显示效果。
  2. 利用Screen.SafeArea的安全区域信息,动态计算窗口应该显示的范围大小,从而避免UI内容显示到特殊区域影响操作或看不到的问题

Reference

Unity UI适配方案

【游戏交互】手游适配设计

前言

本章节是深入2D游戏开发学习,深入学习2D游戏开发中需要学习的知识,同时学习Unity 6000版本+URP的一些相关知识。

用到的东西:

Unity6000.3.19f1,CodeX,VSCode,Copilot,URP

创建项目

Unity Hub->新建项目->Universal 2D Template

URP

The Universal Render Pipeline (URP) is a prebuilt Scriptable Render Pipeline, made by Unity. URP provides artist-friendly workflows that let you quickly and easily create optimized graphics across a range of platforms, from mobile to high-end consoles and PCs.

从介绍看URP是提供了美术工作流友好,可以快速创建跨平台的可编程渲染管线。

要求和兼容

Render pipeline compatibility

Projects made using URP are not compatible with the High Definition Render Pipeline (HDRP) or the Built-in Render Pipeline. Before you start development, you must decide which render pipeline to use in your Project.

从介绍可以看出URP和HDRP或内置渲染管线不兼容,在开发一开始就要选好使用URP还是HDRP或者内置渲染管线。

Graphics API Compatibility

  • DirectX 11 (feature level 11_0 and later)
  • DirectX 12
  • Vulkan
  • Metal
  • OpenGL ES 3.0 and later
  • OpenGL Core
  • WebGL2
  • WebGPU

看得出URP对渲染版本要求还是比较新的,PC和移动端至少要OpenGL ES 3.0,但就目前2026/09/10这个时间点来说,个人觉的不考虑兼容太多旧机器,大部分发达国家和中国都能大部分满足。

System Requirements

关于开发机器的系统要求详情参考:

System requirements for Unity 6.3

这里主要关注下移动端运行时的系统要求:

Operating system Operating system version CPU Graphics API Additional requirements
Android 7.1 (API 25)+ Customized versions of Android must include all supported Google standard APIs. ARMv7 with Neon Support (32-bit) or ARM64 OpenGL ES 3.0+, Vulkan 1GB+ RAMSupported hardware devices must meet or exceed Google’s Android Compatibility Definition (Version 9.0) limited to the following Device Types:Handheld (Section 2.2)Television (Section 2.3)Tablets (Section 2.6)Hardware must be running Android OS natively. Android within a container or emulator isn’t supported.For development: Android SDK (15/API 35), Android NDK (r27c) and OpenJDK (17), which are installed by default with Unity Hub.
iOS/iPadOS 15+ A8 SoC+ Metal Xcode version 16 or later.For development and debugging: refer to Apple documentation on Xcode support.For App Store submission: refer to Apple’s submission guidelines for the required Xcode version.

看起来移动端运行时系统要求也还好,就2026/09/10这个时间点来说大部分国家应该都能满足。

URP Rendering

URP渲染场景通过以下组件:

  1. URP Renderer
  2. Shaing models for shaders shiiped with URP
  3. Camera
  4. URP asset

URP渲染流程预览:

URPRenderingLoop

如果想将非URP的Asset转换到URP的Asset(比如Shader),官方提供了Render Pipeline Converter Window,详情参考:

Convert assets and quality levels from the Built-In Render Pipeline to URP

关于内置Shader转换到URP Shader的详细说明参考:

Convert custom shaders for URP compatibility

先不说转换问题,URP默认就提供了一系列的Shader供我们使用,比如我们原来使用的UI/Default和Sprite/Default和Particles下的Shader在URP下分别对应关系如何:

UI/Default保持不变依然可以继续使用

Sprite/Default对应Universal Render Pipeline/2D/Sprite-Unlit-Default(不受光照)或者Universal Render Pipeline/2D/Sprite-Lit-Default(受光照)

Particles对应Universal Render Pipeline/Unlit(配合设置不同的Surface Type,Blending Mode,Color Mode等)

具体尝试了下好像直接替换恢复Base Map和Tink Color啥的并不能直接正确显示,后续准备直接下载支持URP的特效来使用,这里先不处理特效Shader升级问题了

URP Renderer

URP Renderer包含以下两个方式:

  1. Universal Renderer
  2. 2D Renderer

Shading models in URP

A shading model defines how a Material’s color varies depending on factors such as surface orientation, viewer direction, and lighting. Your choice of a shading model depends on the artistic direction and performance budget of your application.

从介绍来看Shading models有点像光照模型选择,会决定如何计算最终渲染颜色。

URP包含以下Shading models:

  1. Physically Based Shading
  2. Simple Shading
  3. Baked Lit Shading
  4. No lighting

Camera

URP里的Camera的Render Type有两类:

  1. Base

    general purpose Camera that renders to a render target (a screen, or a Render Texture)

    个人理解对应原来Camera的非overlay方式,就是之前最常见的场景摄像机方式

  2. Overlay

    renders on top of another Camera’s output. You can combine the output of a Base Camera with the output of one or more Overlay Cameras. This is called Camera stacking.

    个人理解对应原来Camera的overlay方式,在Base之后渲染,利用添加到Base Camera Stack上实现先渲染Base Camera再渲染Overlay画面的效果,个人这个对应之前UI摄像机的方式。

选择Base后,再下方Enviroment的Background Type对应了原来的Clear Flag选项设置:

BaseCameraBackgroundTypeSetting

因为URP的Base Camera只有Skybox和Solid Color选项,要模拟以前UI摄像机选择Depth Only的效果,我们需要将URP的Camera设置成Overlay+Clear Depth的设定:

URPUICameraInspectorSetting

但仅仅这样设置UI摄像机还不够,还无法正常渲染UI效果。在URP里Overlay摄像机要想渲染还需要添加到Base Camera的Stack里才行,这里一个Camera Stack概念需要先了解下。

首先Camera Stack是仅对URP的Camera设置成Base Render Type而言才有的。

A camera stack allows to composite results of several cameras together. The camera stack consists of a Base camera and any number of additional Overlay cameras.

You can use the stack property add Overlay cameras to the stack and they will render in the order as defined in the stack.

从介绍可以看出,Camera Stack适用于组装不同摄像机渲染顺序的一个概念,所以Overlay的UI摄像机要想正确显示我们还需要将UI Camera添加到Base Camera的Stack里:

AddBaseCameraStack

至此终于能看到UI画面渲染了:

UIOverlayCameraRendering

这里遇到一个问题,Overlay Camera借助Base Camera Stack渲染叠加,但没法像以前一样选中对应摄像机进行Scene场景然选画面预览了,有知道如何解决的请告知一声。

OverlayCameraScenePreviewFailed

Note:

  1. URP摄像机必须要有UniversalAdditionalCameraData组件
  2. Overlay摄像机必须添加到Base Camera Stack里进行渲染输出

URP Asset

常规的URP配置需要我们自己创建URP Asset并在Edit->Project Settings->Graphics->Set Default Render Pipeline Asset指定,但由于我是直接通过Universal 2D Template创建的项目,所以默认Edit->Project Settings->Quality里所有Levels都配置好了Render Pipeline Asset:

SetDefaultRenderPipelineAssetArea

QualityRenderPipelineAssetArea

这里简单看下Universal Render Pipeline Asset的Inspector面板参数:

UniversalRenderPipelineAssetInspector

可以看到URP Asset里指定了一个Renderer2D(Renderer 2D Data)的Asset作为渲染器数据(第0个是默认选择):

也就是说将使用Renderer 2D作为摄像机的默认URP渲染。

URP摄像机是否使用这个Renderer 2D作为渲染器是在Camera的Renderer上设置的:

URPCameraRendererSettingInspector

简单看下Renderer 2D Data的Inspector面板:

Renderer2DDataInspector

输入系统

老版Unity用的是老的输入系统,我们会给EventSystem组件挂载StandloneInputModule组件,而新Input System需要挂在InputSystemUIInputModule组件:

NewInputSystemUIInputModule

老版输入系统直接访问UnityEngine.Input的相关接口,新版Input System要访问UnityEngine.InputSystem下的相关类和接口。

比如检测左键鼠标按下:

老版Input System代码:

1
Input.GetMouseButtonDown(0)

新版Input System代码:

1
Mouse.current.leftButton.wasPressedThisFrame

比如检测移动端手指按下检测:

老版Input System代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var touchNum = Input.touchCount;
if (touchNum > 0)
{
for (int i = 0; i < touchNum; i++)
{
Touch touch = Input.GetTouch(i);
// 检测手指松开(完成点击)
if (touch.phase == TouchPhase.Ended)
{
PlayEffectAtScreenPos(touch.position);
}
}
}

新版Input System代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;

var touches = Touch.activeTouches;
for (int i = 0; i < touches.Count; i++)
{
Touch touch = touches[i];

if (touch.phase == TouchPhase.Ended)
{
Vector2 screenPosition = touch.screenPosition;
Debug.Log($"触摸开始:{screenPosition}");
}
}

而且新版Input System直接提供了像InputAction和EnhancedTouch这类更高级的输入抽象:

比如用InputAction监听鼠标点击和屏幕点击:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// <summary>
/// 鼠标与触摸共用的点击动作
/// </summary>
private InputAction mClickAction;

/// <summary>
/// 创建点击动作并注册回调
/// </summary>
private void Awake()
{
mClickAction = new InputAction();
mClickAction.AddBinding("<Mouse>/leftButton");
mClickAction.AddBinding("<Touchscreen>/primaryTouch/press");
mClickAction.performed += HandleClick;
}

/// <summary>
/// 解除回调并释放动作资源
/// </summary>
private void OnDestroy()
{
mClickAction.performed -= HandleClick;
mClickAction.Dispose();
}

/// <summary>
/// 获取触发点击的设备位置并处理业务
/// </summary>
/// <param name="context">本次点击的输入上下文</param>
private void HandleClick(InputAction.CallbackContext context)
{
Vector2 screenPosition;
if (context.control.device is Touchscreen touchscreen)
{
screenPosition = touchscreen.primaryTouch.position.ReadValue();
}
else if (context.control.device is Mouse mouse)
{
screenPosition = mouse.position.ReadValue();
}
else
{
return;
}

// 在这里处理点击逻辑,screenPosition 为屏幕像素坐标
Debug.Log($"点击位置:{screenPosition}");
}

比如用EnhancedTouch监听多指触碰检测缩放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/// <summary>
/// 用于避免两指距离过小时产生不稳定的缩放比例
/// </summary>
private const float MinimumDistance = 1f;

/// <summary>
/// 是否正在跟踪双指手势
/// </summary>
private bool mIsTracking;

/// <summary>
/// 第一个触点的标识
/// </summary>
private int mFirstTouchId;

/// <summary>
/// 第二个触点的标识
/// </summary>
private int mSecondTouchId;

/// <summary>
/// 上一帧的两指距离,单位为屏幕像素
/// </summary>
private float mPreviousDistance;

/// <summary>
/// 缩放事件,参数依次为距离比例和两指的屏幕中心位置
/// 比例大于一表示张开,小于一表示靠拢
/// </summary>
public event Action<float, Vector2> ZoomRequested;

/// <summary>
/// 启用触摸记录并重置手势
/// </summary>
private void OnEnable()
{
EnhancedTouchSupport.Enable();
mIsTracking = false;
}

/// <summary>
/// 查找触点并计算双指距离变化
/// </summary>
private void Update()
{
if (!mIsTracking)
{
TryBeginTracking();
return;
}

Vector2 firstPosition;
Vector2 secondPosition;
bool hasFirstTouch = TryGetPosition(mFirstTouchId, out firstPosition);
bool hasSecondTouch = TryGetPosition(mSecondTouchId, out secondPosition);

if (!hasFirstTouch || !hasSecondTouch)
{
// 任一触点结束后重新选取,避免使用旧距离造成跳变
mIsTracking = false;
TryBeginTracking();
return;
}

float currentDistance = Vector2.Distance(firstPosition,secondPosition);

if (currentDistance < MinimumDistance || mPreviousDistance < MinimumDistance)
{
mPreviousDistance = currentDistance;
return;
}

float scale = currentDistance / mPreviousDistance;
mPreviousDistance = currentDistance;

if (Mathf.Approximately(scale, 1f))
{
return;
}

Vector2 center = (firstPosition + secondPosition) * 0.5f;
ZoomRequested?.Invoke(scale, center);
}

/// <summary>
/// 清理手势并配对释放触摸记录
/// </summary>
private void OnDisable()
{
mIsTracking = false;
EnhancedTouchSupport.Disable();
}

/// <summary>
/// 选择两个仍按住的触点并记录初始距离
/// </summary>
private void TryBeginTracking()
{
var touches = EnhancedTouch.activeTouches;
bool hasFirstTouch = false;
Vector2 firstPosition = Vector2.zero;

for (int i = 0; i < touches.Count; i++)
{
EnhancedTouch touch = touches[i];
if (!IsPressed(touch))
{
continue;
}
if (!hasFirstTouch)
{
mFirstTouchId = touch.touchId;
firstPosition = touch.screenPosition;
hasFirstTouch = true;
continue;
}
mSecondTouchId = touch.touchId;
mPreviousDistance = Vector2.Distance(firstPosition,touch.screenPosition);
mIsTracking = true;
return;
}
}

/// <summary>
/// 获取指定触点的位置,忽略已结束或已取消的触点
/// </summary>
/// <param name="touchId">需要查找的触摸标识</param>
/// <param name="position">触点的屏幕像素坐标</param>
/// <returns>
/// 是否找到仍按住的触点
/// </returns>
private bool TryGetPosition(int touchId, out Vector2 position)
{
var touches = EnhancedTouch.activeTouches;
for (int i = 0; i < touches.Count; i++)
{
EnhancedTouch touch = touches[i];
if (touch.touchId == touchId && IsPressed(touch))
{
position = touch.screenPosition;
return true;
}
}
position = Vector2.zero;
return false;
}

/// <summary>
/// 判断触点是否仍处于接触状态
/// </summary>
/// <param name="touch">需要检查的触点</param>
/// <returns>
/// 是否仍按住屏幕
/// </returns>
private bool IsPressed(EnhancedTouch touch)
{
return touch.phase == InputTouchPhase.Began
|| touch.phase == InputTouchPhase.Moved
|| touch.phase == InputTouchPhase.Stationary;
}

新版Input System支持了inputactions的配置文件:

InputActionAssetInspector

具体的配置面板:

InputActionAssetConfig

比如前面提到的InputAction监听按钮左键点击和屏幕点击的AddBinding路径信息在这里就能查看到:

UIClickLeftButtonConfigInfo

UIClickTouchPressConfigInfo

正交摄像机Size选择

一般来说2D游戏我们都不会有透视(近大远小)的需求,所以我们一般会选择Orthographic(正交)摄像机。

在理解正交摄像机Size选择和Sprite像素实际占屏幕空间大小之前,我们要理解一个概念PPU(Pixel Per Unit)。

PPU(Pixel Per Unit)决定图片在世界里有多大,正交摄像机Size决定画面能看到多大的世界,两者共同决定图片最终在屏幕显示多大。

PPU决定多少个素材像素,对应 1 个 Unity 世界单位。

精灵世界宽度 = 精灵素材宽度 / PPU
精灵世界高度 = 精灵素材高度 / PPU

比如我们PPU设置100,一张图刚好100X100大小像素,那么这张图放到Unity世界里就占1X1个世界单位大小。

正交摄像机的Size是指可见高度的一半,即可见世界高度 = 2 × Size

比如我们屏幕像素是1920X1080,此时可见高度像素是1080,假设我们希望Sprite的1个像素就正好对应屏幕显示的1个像素,也就意味着:可见世界高度X100=1080,反过来也就是说Size = 1080 / 2 / 100 = 5.4

所以我们将Sprite的PPU设置成100,正交摄像机Size设置成5.4,此时在1920X1080的屏幕上就刚好1个像素对应1个像素,且100X100的Sprite刚才显示成1个世界单位大小。

但考虑到屏幕像素大小是动态变得,如果希望在不同机型上都希望保持1个像素对应1个像素,那么就要根据屏幕高像素去动态计算正交摄像机的Size(Size = 屏幕高像素 / 2 / 100)

Sprite Flight小游戏

Sprite Flight是一个2D物理碰撞弹射游戏,我们会制作障碍物(Obstacle)和边界(Border)去阻碍和限制运动范围。

自适应边界

SpriteFlightBorders

可以看到我们制作了4条边作为限制运动范围的边界。

为了确保100个像素对应1个Unity单位,不同分辨率我们需要动态计算正交Size。

分辨率1920*1080,Size=1080/2/100=5.4

分辨率960*640,Size=640/2/100=3.2

确保了100个像素对应1个Unity单位后,每条边默认占了1个Unity单位(100像素)那么4条边要想准确的计算卡在屏幕边界的位置就等于:

左边边位置X=-1(左侧反方向) * Size * 宽高比 + 0.5(因为边自身占1个单位,以中心来算的)

右边边位置X=1(右侧正方向) * Size * 宽高比 - 0.5(因为边自身占1个单位,以中心来算的)

上边边位置Y=1(上边正方向) * Size - 0.5(因为边自身占1个单位,以中心来算的)

下边边位置Y=-1(下边反方向)* Size + 0.5(因为边自身占1个单位,以中心来算的)

以1920*1080分辨率来算:

左边边位置X=-1 * 5.4 * 16 / 9 +0.5=-9.1

右边边位置X=1 * 5.4 * 16 / 9 -0.5=9.1

上边边位置Y=1 * 5.4-0.5=4.9

下边边位置Y=-1 * 5.4+0.5=-4.9

分辨率1920*1080实际效果:

1920X1080EdgeAdaption

1920X1080CameraSize

1920X1080BorderLeftPosition

1920X1080BorderTopPosition

分辨率960*640实际效果:

960X640EdgeAdaption

960X640CameraSize

960X640BorderTopPosition

960X640BorderLeftPosition

可以看到正如我们前面计算的公式一样,摄像机正交Size个每条边的位置正如预期的值一致。

2D搜集金币小游戏

参考教程:

2D Adventure Game: Robot Repair

教程只是参考学习,实际并没有完全按照教程流程走,我个人以制作一个2D搜集金币的小游戏作为学习Demo,这里我个人没用SpriteRenderer+序列帧的方式,我直接使用Spine作为2D角色。

创建角色

Spine相关知识学习参考:

Spine学习

导入Spine资源

这里直接使用Spine包里的Demo官方的Spineboy作为Spine资源,用Spineboy资源制作角色Prefab。

ImportSpineResAndCreateSpinePrefab

Note:

  1. 导入Spine资源之前请先安装Spine包

加载Spine并播放动画

资源加载每个项目有自己的资源加载框架这里就不展示了,这里只要讲讲Spine的动画播放,这里我使用的SkeletonAnimation组件进行Spine动画播放控制:

1
SkelectonAnimation.AnimationState.SetAnimation(0, mPlayAnimName, mIsLoopAnim);

LoadSpineAndPlayAnimation

控制Spine移动

目前使用的是Unity 6000.3.19.f1版本,使用的是新版Input System,这里结合InputAction来实现WSAD(含上下左右)键盘点击和屏幕触摸控制操作的通用输入控制实现:

InputController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/// <summary>
/// 键盘移动动作
/// </summary>
private InputAction mKeyboardMoveAction;

/// <summary>
/// 触摸移动动作
/// </summary>
private InputAction mTouchMoveAction;

/// <summary>
/// 点击动作
/// </summary>
private InputAction mClickAction;

/// <summary>
/// 当前正在处理的触摸点
/// </summary>
private TouchControl mActiveTouch;

mKeyboardMoveAction = new InputAction("KeyboardMove", InputActionType.Value,"Vector2");
mKeyboardMoveAction.AddCompositeBinding("2DVector")
.With("Up", "<Keyboard>/w")
.With("Down", "<Keyboard>/s")
.With("Left", "<Keyboard>/a")
.With("Right", "<Keyboard>/d")
.With("Up", "<Keyboard>/upArrow")
.With("Down", "<Keyboard>/downArrow")
.With("Left", "<Keyboard>/leftArrow")
.With("Right", "<Keyboard>/rightArrow");
mKeyboardMoveAction.started += HandKeyboardStartMoveAction;
mKeyboardMoveAction.performed += HandKeyboardMoveAction;
mKeyboardMoveAction.canceled += HandKeyboardMoveCanceledAction;

mTouchMoveAction = new InputAction("TouchMove", InputActionType.Value, "Vector2");
mTouchMoveAction.AddBinding("<Touchscreen>/primaryTouch/press");
mTouchMoveAction.started += HandleTouchStartMoveAction;
mTouchMoveAction.performed += HandleTouchMoveAction;
mTouchMoveAction.canceled += HandleTouchMoveCanceledAction;

mClickAction = new InputAction("Click", InputActionType.Button);
mClickAction.AddBinding("<Mouse>/leftButton");
mClickAction.AddBinding("<Touchscreen>/primaryTouch/press");
mClickAction.performed += HandleClickAction;

SetEnable(true);

/// <summary>
/// 设置输入控制器是否启用
/// </summary>
/// <param name="isEnable"></param>
public void SetEnable(bool isEnable)
{
mIsEnable = isEnable;
if(mIsEnable)
{
mKeyboardMoveAction.Enable();
mTouchMoveAction.Enable();
mClickAction.Enable();
EnhancedTouchSupport.Enable();
}
else
{
mKeyboardMoveAction.Disable();
mTouchMoveAction.Disable();
mClickAction.Disable();
EnhancedTouchSupport.Disable();
}
}

然后编写CCPlayerControlController.cs去响应输入时间进行角色控制移动:

CCPlayerControlController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/// <summary>
/// CCPlayerControlController.cs
/// 手机金币玩家控制器的控制器,负责处理玩家的输入和控制逻辑
/// </summary>
public class CCPlayerControlController : BaseController
{
******

/// <summary>
/// 添加事件订阅
/// </summary>
private void AddEvents()
{
mWorld?.Events.AddListener(EventId.InputMoveStart, OnInputMoveStart);
mWorld?.Events.AddListener(EventId.InputMove, OnInputMove);
mWorld?.Events.AddListener(EventId.CancelInputMove, CancelInputMove);
mWorld?.Events.AddListener(EventId.InputZoom, OnInputZoom);
mWorld?.Events.AddListener(EventId.InputClick, OnInputClick);
}

/// <summary>
/// 响应更新
/// </summary>
/// <param name="deltaTime"></param>
public void Update(float deltaTime)
{
}

/// <summary>
/// 响应输入移动开始事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputMoveStart(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
Debug.Log($"CCPlayerControlController响应输入移动开始事件触发");
UpdatePlayerStartMove();
}

/// <summary>
/// 响应输入移动事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputMove(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
var moveDirection = ParamUtilities.ParseArg<Vector2>(eventParam.EventParams, 0);
Debug.Log($"CCPlayerControlController响应输入移动事件触发,moveDirection:{moveDirection}");
UpdatePlayerMove(moveDirection);
}

/// <summary>
/// 响应取消输入移动事件
/// </summary>
/// <param name="eventParam"></param>
private void CancelInputMove(EventParam eventParam)
{
Debug.Log($"CCPlayerControlController响应取消输入移动事件触发");
StopPlayerMove();
}

/// <summary>
/// 响应点击输入事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputClick(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
var screenPosition = ParamUtilities.ParseArg<Vector2>(eventParam.EventParams, 0);
Debug.Log($"CCPlayerControlController响应点击输入事件触发,screenPosition:{screenPosition}");
PlayPlayerAttack();
}

******
}

目前我采用的Rigidbody2D设置速度的方式控制角色移动。

Spine添加碰撞体

给做好的Spine预制件添加CapuleCollider 2D并设置好合适的大小位置。

AddPlayerCapsuleCollider2D

后续还会给地图Tilemap加碰撞体,从而实现碰撞交互和限制移动范围。

制作场景

目前场景还是一片漆黑,制作场景前我们要学习一个2D游戏里的重要概念Tilemap

Tilemap

2D游戏的场景往往会使用Tilemap的技术进行场景编辑,那什么是Tilemap了?

A Tilemap consists of a grid overlay and a number of other components working together. The entire system allows you to paint levels using tiles and brush tools and define rules for how tiles behave. With Tilemaps, you can create platforms with dynamic edges, animated tiles, randomized tile placements, and more.

从介绍来看,Tilemap支持我们像绘画一样去使用tiles和brush工具去编辑关卡地图。

安装Tilemap

使用Tilemap前要先通过Package Manager安装对应包:

InstallTilemapPackage

创建Tilemap

相关文档:

Tilemaps

参考教程:

Introduction to Tilemaps

CreateTilemapFromMenu

Tilemap由两部分组成:

  1. Grid

    TilemapGridComponentInspector

    相关参数介绍:

    • Cell Size: 单个Square网格的大小配置
    • Cell Gap: Square网格之间的间隔
    • Cell Layout: 地块tiles的排版格式
    • Cell Swizzle: 网格的朝向配置
  2. Tilemap

    • Tilemap组件

      TilemapComponentInspector

      相关参数介绍:

      • Animation Frame Rate: 地块Tile的动画帧率
      • Color: 配置地块Tiles颜色
      • Tile Anchor: 配置地块Tile的锚点
      • Orientation: 地块Tile的朝向
    • Timemap Renderer组件

      TilemapRendererComponentInspector

      相关参数介绍:

      • Sort Order: 配置地块Tile渲染顺序(比如左下角开始)
      • Mode: 地块Tile渲染模式(比如Chunk Mode(支持Sprite Batch Renderer),Individual Mode(Sprite根据位置和Sort order决定渲染顺序))
      • Detect Chunk Culling: 配置Tilemap Renderer如何检测Chunk边界
      • Chunk Culling Bounds: 手动配置Chunk边界大小配置
      • Mask Interaction: 配置Tilemap和Mask的交互渲染模式
      • Material: 配置地块Tile渲染材质
      • Sorting Layer: 配置地块Tile渲染层级
      • Order in Layer: 配置地块Tile渲染Order值

Note:

  1. Grid和Tilemap是1对多关系,只允许场景里有1个Grid,但可以有多个Tilemap
Tile Palette

Tilemap里我们通过将Sprite制作成一个一个的Tile来编辑绘制地图,而Tile Palette(地块调色板)就是我们最常用到的工具。

打开Tile Pallette:

Window->2D->Tile Palette

TilePalletteWindow

使用Tile Pallette创建用于我们自己游戏的Tile Pallette,我们点击激活的Tile Pallette下拉框后选择Create New Tile Pallette:

CreateNewTilePaletteOperation

CreateNewPaletteUI

创建成功后会发现目前还没有任何的Tile内容:

CreateTilePaletteSuccess

在编辑添加Tile Asset之前,我们选中刚创建的Palette进行基础的Palette设置:

CollectCoinPaletteSettings

这里我想自定义Cell Size大小所哟Cell Sizing我设置成了Manual,同时我是正交照射2D显示所以配置了Sort Mode为Orthographic

然后选中Palette Prefab设置Grid的Cell Size到1,1,1:

SetCollectCoinPaletteCellSize

接下来我将免费资源:

Adventure Game: Robot Repair | 2D Sample Project里的一部分资源用来作为制作Tile Palette的资源

TilePaletteResourcePrepare

添加Sprite作为Tile(直接拖动Sprite到Tile Palette里):

1024X1024SpriteSetting

1024X1024TileInPalette

可以看到一张1024X1024的Sprite,在100PPU的情况下占Palette的10X10的大小,符合我们的预期,因为我们把Palette的Cell Size设置成了1X1也就是1X1个Unity单位,因为100PPU也就是1X1个Unity单位像素大小为100X100像素。

Palette的Grid Size设置和实际运行时设置的Grid Size可以不一致,Palette为了方便编辑,设置Automatic好像会自动取最大Sprite占1个格子的方式计算Grid Size,这样做的好处是Tile Palette排版很好排,不会像设置成Manual后1X1的Grid Size容易占多个格子重叠问题,缺点是格子很大,一些小图会很小看不清,但为了方便排版,最后我还是设置成了Automatic的方式。

最终完成Tile Asset的导入:

CollectCoinPaletteCompleteSpriteImport

Note:

  1. 自定义Tile Asset Create通过继承TileTemplate脚本进行编写(这个要走Assets->2D->Tile Palette->New Tile Set创建后方可设置)
Using Tile Asset

在前面我们创建好Tile Palette和对应的Tile Asset后,接下来我们就能通过Tile Map进行绘制我们的自定义地图了。

首先我们要单独创建一个Tilemap预制件:

CreateTilemapFromMenu

设置成我们想要的Cell Size大小:

SetTilemapCellSize

然后打开Tile Palette选中我们刚才创建的Tile Palette作为素材Palette:

ChooseTilePaletteToPaint

然后激活Tilemap的绘笔:

TilemapActivePainter

ActivePickTool

选中要绘制的Tile Asset在Scene里左键绘制:

SelectTileAssetToPaint

绘制地图完成:

CompleteTilemapEdit

要想编辑好的Tilemap显示的Spine人物模型之后,我们需要设置Tilemap Renderer组件的Sorting Layer和Order:

SetTilemapRendererLayerAndOrder

SetSpineRendererLayerAndOrder

然后把Tilemap编辑好的预制件和Spine模型预制件加载出来显示:

TilemapAndSpineCharacterRender

可以看到因为我把角色的Layer调整的比Tilemap高,所以角色显示在了上层。

但这样一来角色会凌驾于所以场景物体之上:

SpinePlayerRendererOrderProblem

Tilemap渲染顺序

像前面说到的Tile渲染层级问题,我们需要制作Tilemap时就规划好层级。

首先我们要拆分出Tilemap的背景层,中间层和顶层层级设计

2DRenderLayerPrepare

Background用于Tilemap背景,Middle用于中间层所有物体显示,Front用于显示在顶层的物体。

重新制作Tilemap,将背景,场景物体和顶层物体归类,分别创建3个Tilemap进行编辑:

NewTilemapEdit

将角色的Renderer Layer修改到Middle层(和场景物件显示在同一层):

NewSpineRendererLayerSetting

此时我们会发现,虽然场景物件和角色在同一层Layer渲染了,但玩家角色和场景物件没法根据玩家所在位置(Y值)决定是显示在场景物件前面还是后面:

TransparencySortModeProblem

为了解决这个问题,我们需要修改Camera渲染层级的依据方式,因为我创建的Universal 2D Template,摄像机默认用的Renderer2D Asset的渲染方式,我们需要将Renderer2D Asset的Transparency Sort Mode从Default(根据深度排序)修改成Custom Axis+Transparency Sort Axis(0,1,0):

CustomAxisTransparencySortModeSetting

上述设置表示我们的摄像机渲染顺序根据Y轴坐标比较来判定渲染先后顺序(同Layer同Order的情况下),最后我们就会得到角色如果在物件下方就渲染整个,角色在物件上方就只渲染未被遮挡部分:

CharacterStayBelowItem

CharacterStayAboveItem

SpinePlayerRendererOrderProblem

但是部分跟角色同Layer的Tile我发现遮挡层级并不完全正确:

TileRendererOrderProblem

这是因为我们物品Sprite的Pivot默认指定的Center:

TileCenterPivotProblem

摄像机根据Y比较渲染顺序是根据Tile所在位置的Y值来比较的,而Y值的值是根据Tile的Pivot所在位置的Y值来判定的。

修复后会发现Tile网上移动了一点,应该因为Pivot修改了影响了位置,但是和玩家的层级问题表现已经正确了:

TileRendererOrderProblemFix

Note:

  1. Tile的Sprite Pivot除了影响位置判定点,还影响旋转,旋转是围绕Pivot转的
Tilemap碰撞

Tilemap层级渲染正确了,接下来我们如何给Tilemap的Tile添加碰撞,从而实现Tile的阻挡,交互。

要给绘制好的Tilemap的Tile元素添加碰撞组件,我们需要给挂载Tilemap组件节点添加Tilemap Collisder 2D组件:

AddTilemapCollider2D

可以看到我给MiddleTilemap添加了Tilemap Collider 2D组件后,对应创建的Tile就自动构建了碰撞体。

运行游戏就会发现因为添加了碰撞体,场景物体就能阻挡角色移动穿透了:

TilemapCollider2DWorking

Tilemap Collider 2D的碰撞体构成类型默认是Composite Operation为None(每个Tile独立一个碰撞体),如果场景带碰撞体的物体多了,碰撞体会成为性能瓶颈。

优化Tilemap碰撞体问题我们可以使用Composition Collider 2D组件加Tilemap Collider 2D的Composite Operation为Merge(合并成一个大的碰撞体)的方式实现一个Tilemap所有碰撞体合并成一个碰撞体的能力,从而优化碰撞图数量问题:

MergeTilemapCollider2D

TilemapCollider2DMergePreview

可以看到我成功将Tilemap Collider 2D的多个碰撞体合并成了一个碰撞体。

借助Tilemap碰撞体的设计,我们可以专门定义一层Wall的Layer层级,专门用于构建地图边界围墙,然后利用Composite Collider 2D合成一个整体的碰撞体限制地图移动范围:

AddWallIntoTilemap

LimitPlayerMoveRange

可以看到我通过构建边缘碰撞体Tilemap,成功将玩家限制在了指定范围内移动。

摄像机

摄像机渲染

前面我们了解了URP里只有Base和Overlay两类摄像机,要想模拟以前UI摄像机选择Depth Only的效果,我们需要将URP的UI Camera设置成Overlay+Clear Depth的设定,场景摄像机设置成Base+Skybox(或Solid Color)渲染方式。

但仅仅这样设置UI摄像机还不够,还无法正常渲染UI效果。在URP里Overlay摄像机要想渲染还需要添加到Base Camera的Stack里才行:

GameUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 查找主摄像机并将UI摄像机添加到其摄像机堆栈
/// </summary>
/// <param name="mainCameraName"></param>
/// <returns></returns>
public static Camera FindMainCameraAndAddUICameraStack(string mainCameraName)
{
var mainCamera = GameObject.Find(mainCameraName)?.GetComponent<Camera>();
if(mainCamera == null)
{
Debug.LogError($"找不到主摄像机:{mainCameraName},添加UICamera Stack失败!");
return null;
}
var uiCamera = CameraManager.Singleton.GetUICamera();
UnityUtilities.AddOverlayCameraToStack(mainCamera, uiCamera);
return mainCamera;
}

UnityUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 将Overlay摄像机添加到Base摄像机的渲染堆栈中
/// </summary>
/// <param name="baseCamera"></param>
/// <param name="overlayCamera"></param>
/// <returns></returns>
public static bool AddOverlayCameraToStack(Camera baseCamera, Camera overlayCamera)
{
if (baseCamera == null || overlayCamera == null)
{
Debug.LogError("Base camera或者overlay camera为空,添加Overlay摄像机到Base摄像机堆栈失败");
return false;
}

var transitionUACD = baseCamera.GetUniversalAdditionalCameraData();
if(transitionUACD == null)
{
Debug.LogError("Base camera的UniversalAdditionalCameraData为空,添加Overlay摄像机到Base摄像机堆栈失败");
return false;
}
transitionUACD.cameraStack.Add(overlayCamera);
return true;
}

添加到Base摄像机Stack面板:

AddCameraStackInspector

至此主摄像机(Base)+UI摄像机(Overlay)才能正确同时渲染主场景和UI画面:

MainCameraAndUICameraRendering

摄像机跟随

玩家角色在场景里通过键盘或触屏自由移动,这个时候我们的摄像机没法看到完整的地图,所以就需要摄像机跟随角色移动,确保角色在摄像机中间显示从而实现摄像机跟随移动的效果。

而摄像机的功能现成的轮子最强的莫过于官方的Cinemachine,这里我们使用Cinemachine Brain配合Cinemachine Camera+Cinemachine Follow组件实现相机跟随主角保持主角的屏幕中央的效果。

首先在场景摄像机上挂上Cinemachine Brain组件用于支持Cinemachine Camera(好像等价于以前的Virtual Camera)的虚拟摄像机控制。

CinemactionBrainComponentInspector

场景里创建一个挂载Cinemachine Camera的虚拟摄像机节点:

CinemachineCameraFollowLookAtInspector

将Cinemachine Camera的Position Control和Rotation Control设置成Follow和None(不影响摄像机旋转),设置后会自动挂载上Cinemachine Follow和Cinemachine Hard Look At组件。

然后在代码里我们等主角实体对象加载完成后设置Cinemachine Camera的Follow对象为主角实体对象即可。

1
2
3
var playerTransform = mPlayerView.Transform;
mMainCameraView.CinemachineCamera.Follow = playerTransform;
mMainCameraView.CinemachineCamera.LookAt = playerTransform;

但只是设置跟随目标和看向对象会出现主角达到关卡边缘的时候,因为要保持主角在摄像机中心导致摄像机照射到关卡地图无效的区域:

InvalideCameraAreaProblem

要解决摄像机跟随照射无效区域问题看下面的摄像机移动范围限制方案。

摄像机移动范围限制

要想实现Cinemachine的摄像机移动限制,我们可以在Cinemachine Camera所在节点添加CinemachineConfiner2D组件,然后给CinemachineConfiner2D组件指定**专门制作的摄像机范围限制碰撞器(自己编辑的刚好包围Tilemap的Box Collider 2D)**作为移动限定范围,从而实现摄像机移动范围限制到制作的地图城墙范围以内避免看到有效Tilemap地图以外的区域。

CameraAreaBoxCollider2D

我尝试过直接用背景Tilemap的CompositionCollider2D作为摄像机移动范围限制碰状况,但我发现会出现边界不够精确的情况,所以采用的单独制作碰撞体作为摄像机移动范围限制。

1
2
var cameraMoveAreaBoxCollider2D = mTileMapView.CollectCoinMap.CameraMoveAreaBoxCollider2D;
mMainCameraView.SetBoundingShape2D(cameraMoveAreaBoxCollider2D);

设置后可以看到即使主角移动到了地图边缘,摄像机也最多移动到刚好看到地图边界的位置不会出现看到无效地图区域的问题:

InvalideCameraAreaProblemFix

玩法

随机刷道具

有了玩家显示+玩家移动控制+地图显示+摄像机跟随,接下来要实现的就是场景随机刷玩法道具(金币和加时器)的功能,场景时3D照射的正交Tilemap,所以我们的物品渲染要走SpirteRenderer的方式,因为物品要显示在Tilemap之上,我将物品的层级选用了Front(角色在Middle,Tilemap主要使用了Wall(边界城墙),Background(地图背景)和Middle(地图装饰物))。

CoinSpriteRenderInspector

关于游戏开始,游戏结束,物品刷新都属于玩法逻辑,我写在了CCController.cs里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/// <summary>
/// 更新游戏时间
/// </summary>
/// <param name="deltaTime"></param>
public void Update(float deltaTime)
{
if(CollectCoinManager.Singleton.IsWaitLoadGame())
{
return;
}
if(!IsGameStart)
{
if(GameStartTimeLeft >= 0)
{
DecreaseGameStartTime(deltaTime);
}
CheckGameStart();
return;
}
if(!IsGameOver)
{
CheckCoinSpawn(deltaTime);
CheckTimeSpawn(deltaTime);
if(GameTimeLeft >= 0)
{
DecreaseGameTime(deltaTime);
}
CheckGameOver();
return;
}
}

/// <summary>
/// 检查游戏是否可以开始
/// </summary>
private void CheckGameStart()
{
if (!IsGameStart && GameStartTimeLeft <= 0f)
{
Debug.Log("游戏开始");
IsGameStart = true;
CollectCoinManager.Singleton.StartGame();
//CreateObstacleEntities();
}
}

/// <summary>
/// 检查游戏是否结束
/// </summary>
private void CheckGameOver()
{
if (!IsGameOver)
{
if(GameTimeLeft <= 0f)
{
// 游戏结束逻辑
Debug.Log("游戏剩余时间归零,游戏结束");
IsGameOver = true;
CollectCoinManager.Singleton.OverGame();
return;
}
}
}

/// <summary>
/// 检查是否需要生成新的金币
/// </summary>
/// <param name="deltaTime">帧间隔时间</param>
private void CheckCoinSpawn(float deltaTime)
{
if(CollectCoinMap == null)
{
return;
}
mLastCoinSpawnTimePassed += deltaTime;
if(mLastCoinSpawnTimePassed < CollectCoinMap.SpawnCoinInterval)
{
return;
}
var coinNumExit = mWorld.GetEntityNumber(EntityType.CCCoin);
if(coinNumExit >= CollectCoinMap.MaxSpawnCoinNumInMap)
{
return;
}
mLastCoinSpawnTimePassed = 0f;
var randomSpawnPos = GetRandomSpawnPosition();
var coinScore = GetRandomCoinScore();
CreateCoinEntity(randomSpawnPos, coinScore);
}

/// <summary>
/// 检查是否需要生成新的时间道具
/// </summary>
/// <param name="deltaTime">帧间隔时间</param>
private void CheckTimeSpawn(float deltaTime)
{
if(CollectCoinMap == null)
{
return;
}
mLastTimeSpawnTimePassed += deltaTime;
if(mLastTimeSpawnTimePassed < CollectCoinMap.SpawnTimeInterval)
{
return;
}
var timeNumExit = mWorld.GetEntityNumber(EntityType.CCTime);
if(timeNumExit >= CollectCoinMap.MaxSpawnTimeNumInMap)
{
return;
}
mLastTimeSpawnTimePassed = 0f;
var randomSpawnPos = GetRandomSpawnPosition();
var timeAddSeconds = GetRandomTimeAddSeconds();
CreateTimeEntity(randomSpawnPos, timeAddSeconds);
}

为了准确控制场景物品的刷新细节数据逻辑,我将金币和计时器的刷新相关数据配置在了地图Prefab上:

CollectCoinMapInspector

为了增加趣味性,物品的刷新数值(金币数量,加时器加时秒数,刷新物品位置)我都采用了随机的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/// <summary>
/// 获取一个随机生成位置
/// </summary>
/// <returns></returns>
private Vector3 GetRandomSpawnPosition()
{
if(CollectCoinMap == null || CollectCoinMap.BackgroundTilemap == null)
{
Debug.LogError("地图数据或背景TileMap未设置,获取随机生成位置失败!");
return Vector3.zero;
}
var cellDataCount = mValideMapCellDatas.Count;
if (cellDataCount <= 0)
{
Debug.LogError("没有可用的地图格子数据,获取随机生成位置失败!");
return Vector3.zero;
}
var backgroundTilemap = CollectCoinMap.BackgroundTilemap;
int index = Random.Range(0, cellDataCount);
Vector3 spawnPosition = backgroundTilemap.GetCellCenterWorld(mValideMapCellDatas[index]);
return spawnPosition;
}

/// <summary>
/// 获取一个随机的金币分数
/// </summary>
/// <returns></returns>
private int GetRandomCoinScore()
{
if(CollectCoinMap == null)
{
Debug.LogError("地图数据未设置,获取随机金币分数失败!");
return 0;
}
return Random.Range(CollectCoinMap.CointMinScoreNum, CollectCoinMap.CointMaxScoreNum);
}

/// <summary>
/// 获取一个随机的时间道具增加秒数
/// </summary>
/// <returns></returns>
private int GetRandomTimeAddSeconds()
{
if(CollectCoinMap == null)
{
Debug.LogError("地图数据未设置,获取随机时间道具增加秒数失败!");
return 0;
}
return Random.Range(CollectCoinMap.TimerMinAddSeconds, CollectCoinMap.TimerMaxAddSeconds);
}

其中关于随机刷新位置我是通过利用背景Tilemap的格子信息+排除中间层有碰撞的装饰物的格子信息来初始化得到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// <summary>
/// 初始化有效的地图格子数据
/// </summary>
private void InitValidMapCellDatas()
{
mValideMapCellDatas.Clear();
if(CollectCoinMap == null)
{
Debug.LogError("CollectCoin地图数据不存在,初始化地图格子数据失败。");
return;
}
// 遍历地图格子,收集有效的格子数据
var backgroundTilemap = CollectCoinMap.BackgroundTilemap;
if(backgroundTilemap == null)
{
Debug.LogError("CollectCoin地图的背景Tilemap不存在,初始化地图格子数据失败。");
return;
}
var middleTilemap = CollectCoinMap.MiddleTilemap;
if(middleTilemap == null)
{
Debug.LogError("CollectCoin地图的中间层Tilemap不存在,初始化地图格子数据失败。");
return;
}
var middleCollider = middleTilemap.GetComponent<CompositeCollider2D>();
if (middleCollider == null)
{
Debug.LogError("Middle 层缺少 CompositeCollider2D");
return;
}
// 遍历中间层Tilemap的格子,但要排除Middle层有碰撞体积的单元格子
// 收集有效的格子数据
foreach (Vector3Int cell in backgroundTilemap.cellBounds.allPositionsWithin)
{
if (!backgroundTilemap.HasTile(cell))
{
continue;
}
var worldPosition = backgroundTilemap.CellToWorld(cell);
if(middleCollider.OverlapPoint(worldPosition))
{
continue;
}
mValideMapCellDatas.Add(cell);
}
}

道具刷新效果:

RandomCreateItems

这些逻辑都完成后我们就能看到场景里刷新出对应物品,但此时的物品还没有碰撞后玩家搜集的逻辑,所以还需要给玩家添加碰撞相关逻辑。

玩家碰撞道具搜集

因为游戏玩法主要是根据玩家碰撞进行道具搜集,所以碰撞检测逻辑主要写在玩家身上,碰撞要求两个物体要有碰状况,且至少有一方有Rigidbody组件。

PlayerComponentsInpsector

CoinComponentsInspector

可以看到我给道具添加了Box Collider 2Da并设置了IsTrigger,走触发器的方式,同时玩家身上添加了Rigidbody和Capsule Collider 2D。

我编写了一个MonoBridge.cs的脚本来负责Monobehaviour的统一生命周期(包含碰撞和触发相关接口)转接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
/// <summary>
/// MonoBridge.cs
/// 将 Unity 的 2D 和 3D 碰撞及触发消息同步转发给逻辑层
/// 订阅者负责配对解绑及自身状态检查,对象回池时应调用 ClearCallbacks
/// </summary>
public class MonoBridge : MonoBehaviour
{
/// <summary>
/// 碰撞开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionEnter;

/// <summary>
/// 碰撞持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionStay;

/// <summary>
/// 碰撞结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionExit;

/// <summary>
/// 触发开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerEnter;

/// <summary>
/// 触发持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerStay;

/// <summary>
/// 触发结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerExit;

/// <summary>
/// 碰撞开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionEnter2D;

/// <summary>
/// 碰撞持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionStay2D;

/// <summary>
/// 碰撞结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionExit2D;

/// <summary>
/// 触发开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerEnter2D;

/// <summary>
/// 触发持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerStay2D;

/// <summary>
/// 触发结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerExit2D;

/// <summary>
/// 对象销毁时释放所有订阅者引用
/// </summary>
private void OnDestroy()
{
ClearCallbacks();
}

/// <summary>
/// 接收 Unity 的碰撞开始消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionEnter(Collision collision)
{
CollisionEnter?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞持续消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionStay(Collision collision)
{
CollisionStay?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞结束消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionExit(Collision collision)
{
CollisionExit?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的触发开始消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerEnter(Collider other)
{
TriggerEnter?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发持续消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerStay(Collider other)
{
TriggerStay?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发结束消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerExit(Collider other)
{
TriggerExit?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的碰撞开始消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log($"OnCollisionEnter2D() collision.otherCollider.gameObject.name: {collision.otherCollider.gameObject.name} collision.gameObject.name: {collision.gameObject.name}");
CollisionEnter2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞持续消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionStay2D(Collision2D collision)
{
CollisionStay2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞结束消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionExit2D(Collision2D collision)
{
CollisionExit2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的触发开始消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerEnter2D(Collider2D other)
{
TriggerEnter2D?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发持续消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerStay2D(Collider2D other)
{
TriggerStay2D?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发结束消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerExit2D(Collider2D other)
{
TriggerExit2D?.Invoke(other);
}

/// <summary>
/// 清除全部回调,供对象回池或解除整组绑定时使用
/// 单个订阅者停止监听时应自行解绑,避免移除其他模块的订阅
/// </summary>
public void ClearCallbacks()
{
CollisionEnter = null;
CollisionStay = null;
CollisionExit = null;
TriggerEnter = null;
TriggerStay = null;
TriggerExit = null;
CollisionEnter2D = null;
CollisionStay2D = null;
CollisionExit2D = null;
TriggerEnter2D = null;
TriggerStay2D = null;
TriggerExit2D = null;
}
}

逻辑层通过获取这个MonoBridge组件进行对应的接口监听:

CCPlayerCollisionComponent.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// 绑定玩家触发器逻辑的处理逻辑
/// </summary>
private void BindTriggers()
{
if(mIsTriggerBind)
{
return;
}
if(mMonoBridge2DComponent == null)
{
return;
}
mMonoBridge2DComponent.AddTriggerEnter2D(OnPlayerTriggerEnter2D);
mIsTriggerBind = true;
Debug.Log($"玩家触发器逻辑绑定成功。");
}

/// <summary>
/// 玩家与触发器发生交互时的处理逻辑
/// </summary>
/// <param name="collider"></param>
private void OnPlayerTriggerEnter2D(Collider2D collider)
{
var triggerGoName = collider.gameObject.name;
Debug.Log($"玩家与触发器 {triggerGoName} 发生了交互。");
(var result, var entityId, var componentId, var resourceName) = ComponentUtilities.ParseComponentGoName(triggerGoName);
if(!result)
{
return;
}
Debug.Log($"解析到的触发器信息 - EntityId: {entityId}, ComponentId: {componentId}, ResourceName: {resourceName}");
var triggerEnterEntity = World.GetEntity(entityId);
if(triggerEnterEntity == null)
{
Debug.LogError($"未能找到触发器物体对应的实体,EntityId: {entityId}");
return;
}
var triggerEnterEntityType = triggerEnterEntity.EntityType;
if(triggerEnterEntityType == EntityType.CCCoin)
{
OnCoinTriggerEnter(triggerEnterEntity);
}
else if(triggerEnterEntityType == EntityType.CCTime)
{
OnTimeTriggerEnter(triggerEnterEntity);
}
else
{
Debug.LogWarning($"未处理的触发器类型: {triggerEnterEntityType}");
}
}

但物体无论是碰撞器还是触发器,我们通过相关接口能拿到的只有进入碰撞或触发的对象或碰撞组件,那我们如何识别是碰撞的逻辑层面的哪个对象了?

答案是给道具实体对象取名时就携带逻辑数据信息:

ComponentUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// 获取组件对应的GameObject名称取名
/// </summary>
/// <param name="component">要获取名称的组件</param>
/// <param name="resourceName">组件对应的资源名称</param>
/// <returns></returns>
public static string GetComponentGoName(Component component, string resourceName)
{
if(component == null)
{
Debug.LogError($"不能传空component,获取GameObject名称失败");
return string.Empty;
}
// 返回格式为 "组件Go前缀|EntityType|所属EntityId|组件Id|资源名"
var prefix = EntityFramworkConst.ComponentGoNamePrefix;
var separator = EntityFramworkConst.ComponentGoNameSeparator;
return $"{prefix}{separator}{component.OwnerId.Value}{separator}{component.Id.Value}{separator}{resourceName}";
}

/// <summary>
/// 解析指定GameObject名称,返回所属EntityId、组件Id和资源名信息
/// </summary>
/// <param name="goName"></param>
/// <returns></returns>
public static (bool, EntityId, ComponentId, string) ParseComponentGoName(string goName)
{
var separator = EntityFramworkConst.ComponentGoNameSeparator;
var parts = goName.Split(separator);
if(parts.Length != 4)
{
return (false, default, default, string.Empty);
}
var prefix = parts[0];
if(!string.Equals(prefix, EntityFramworkConst.ComponentGoNamePrefix))
{
return (false, default, default, string.Empty);
}
if(!ulong.TryParse(parts[1], out var entityId))
{
Debug.LogError($"无效的EntityId: {parts[1]},解析组件Go名字失败!");
return (false, default, default, string.Empty);
}
if(!ulong.TryParse(parts[2], out var componentId))
{
Debug.LogError($"无效的ComponentId: {parts[2]},解析组件Go名字失败!");
return (false, default, default, string.Empty);
}
var resourceName = parts[3];
var realEntityId = new EntityId(entityId);
var realComponentId = new ComponentId(componentId);
return (true, realEntityId, realComponentId, resourceName);
}

EntityFrameworkConst.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// EntityFramworkConst.cs
/// 实体框架相关的常量定义
/// </summary>
public static class EntityFramworkConst
{
/// <summary>
/// 组件对应的GameObject名称的前缀
/// </summary>
public const string ComponentGoNamePrefix = "EF";

/// <summary>
/// 组件对应的GameObject名称中的分隔符
/// </summary>
public const char ComponentGoNameSeparator = '|';
}

可以看到我们在给实体对象取名时就定义了一下规则:

组件Go前缀|EntityType|所属EntityId|组件Id|资源名

所以当有物体进入碰撞时我按对应方式解析,如果不符合解析规则则说明不是我们游戏逻辑创建的对象不符合碰撞处理,符合解析规则则解析出我们需要的逻辑对象数据,从而知道碰撞的道具是什么。

道具信息HUD

有上述道具刷新和收集逻辑,核心玩法有了,但有一点很不方便的是,我们金币和加时器是随机刷新的数值,但从游戏画面缺看不出来到底具体数值是多少,这个时候我们需要给场景物品加上额外的信息展示,而我采用的是用UGUI绘制HUD数据的方式展示。

UGUI绘制HUD信息展示的核心是利用3D物体挂点结合摄像机进行3D坐标转UI坐标的转换,从而实现HUD的挂点跟随效果。

首先给道具预制件制作了HUD挂点:

CoinMonoHudPointInspector

然后是抽象HUD跟随组件功能:

HUDFollowComponent.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/// <summary>
/// HUDFollowComponent.cs
/// 负责HUD跟随逻辑的组件
/// </summary>
public class HUDFollowComponent : EntityFramework.Component, IInitializable<HUDFollowComponentData>
{
/// <summary>
/// HUD跟随目标
/// </summary>
[EntityFrameworkDebugDisplay(Order = 101)]
public Transform FollowTarget
{
get;
private set;
}

/// <summary>
/// 是否为静态目标
/// </summary>
[EntityFrameworkDebugDisplay(Order = 102)]
public bool IsStaticTarget
{
get;
private set;
}

/// <summary>
/// HUD的Transform组件
/// </summary>
[EntityFrameworkDebugDisplay(Order = 103)]
public Transform HudTransform
{
get;
private set;
}

/// <summary>
/// HUD的RectTransform组件
/// </summary>
private RectTransform mHudRectTransform;

/// <summary>
/// 主摄像机引用
/// </summary>
private Camera mMainCamera;

/// <summary>
/// UI摄像机引用
/// </summary>
private Camera mUICamera;

/// <summary>
/// 初始化HUDFollowComponent组件
/// </summary>
/// <param name="data"></param>
public void Initialize(HUDFollowComponentData data)
{
FollowTarget = data?.FollowTarget;
IsStaticTarget = data?.IsStaticTarget ?? false;
mMainCamera = CameraManager.Singleton.GetMainCamera();
mUICamera = CameraManager.Singleton.GetUICamera();
UpdateHUDPos();
}

/// <summary>
/// 绑定HUD跟随的目标及其静态属性
/// </summary>
/// <param name="followTarget"></param>
/// <param name="isStaticTarget"></param>
public void BindFollowTarget(Transform followTarget, bool isStaticTarget = false)
{
FollowTarget = followTarget;
IsStaticTarget = isStaticTarget;
UpdateHUDPos();
}

/// <summary>
/// 绑定HUD的Transform组件
/// </summary>
/// <param name="hudTransform">HUD的Transform组件</param>
public void BindHudTransform(Transform hudTransform)
{
HudTransform = hudTransform;
var hudParent = HudTransform?.parent;
mHudRectTransform = hudParent as RectTransform;
UpdateHUDPos();
}

/// <summary>
/// 每帧更新HUD的位置,使其跟随目标
/// </summary>
/// <param name="deltaTime"></param>
protected override void OnLateUpdate(float deltaTime)
{
base.OnLateUpdate(deltaTime);
if(IsStaticTarget)
{
return;
}
UpdateHUDPos();
}

/// <summary>
/// 更新HUD的位置,使其跟随目标
/// </summary>
private void UpdateHUDPos()
{
if(FollowTarget == null || HudTransform == null ||
mMainCamera == null || mUICamera == null ||
mHudRectTransform == null)
{
return;
}
var followTargetWorldPos = FollowTarget.position;
var result = UnityUtilities.WorldToRectWorldPos(mMainCamera, followTargetWorldPos, mUICamera, mHudRectTransform, out var uiWorldPosition);
if(result)
{
HudTransform.position = uiWorldPosition;
//Debug.Log($"更新Owner EntityId:{OwnerId}的HUDFollowComponent的HudTransform位置:{uiWorldPosition}");
}
}
}

UnityUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/// <summary>
/// UnityUtilities.cs
/// Unity工具类,提供一些常用的Unity相关辅助方法
/// </summary>
public static class UnityUtilities
{
/// <summary>
/// 指定世界摄像机和3D坐标转换到指定UI摄像机和2D RectTransform下的世界坐标
/// </summary>
/// <param name="worldCamera">渲染目标的游戏相机</param>
/// <param name="worldPosition">目标在场景中的世界坐标</param>
/// <param name="uiCamera">用于渲染UI的相机</param>
/// <param name="rect">目标在HUD中的RectTransform</param>
/// <param name="uiWorldPosition">输出的UI世界坐标</param>
/// <returns>将指定世界坐标转换到UI世界坐标,转换成功返回true,失败返回false</returns>
public static bool WorldToRectWorldPos(Camera worldCamera, Vector3 worldPosition,
Camera uiCamera, RectTransform rect,
out Vector3 uiWorldPosition)
{
uiWorldPosition = Vector3.zero;
if (worldCamera == null || rect == null)
{
Debug.LogError("Camera或者RectTransform为空,坐标转换失败");
return false;
}
Vector3 screenPosition = worldCamera.WorldToScreenPoint(worldPosition);
var screenToRectResult = RectTransformUtility.ScreenPointToWorldPointInRectangle(rect, screenPosition, uiCamera,
out uiWorldPosition);
if(!screenToRectResult)
{
Debug.LogError("屏幕坐标转换到RectTransform失败");
return false;
}
return true;
}
}

关于跟随显示有一个要注意的点,因为摄像机Cinemachine Brain使用的是Update Method为Smart Update和Blend Update Method为Late Update:

CinemachineBrainUpdateSetting

要想3D算2D位置不出现晃动,这个3D计算必须在摄像机计算移动之后,像目前我这里就是必须在Late Update之后,游戏逻辑的Update是由GameLauncher.cs脚本驱动过来的,也就是GameLauncher.cs和Cinemachine Brain的更新顺序,这里我们可以通过Project Settings->Script Execution Order进行自定义的脚本执行顺序配置:

GameLuancherScriptExecutionOrder

然后就是制作一个HUD的挂载UI窗口:

WindowCollectCoinHudUI

通过将HUD挂载到UI窗口节点上,然后通过3D算2D计算位置后,最终HUD效果:

HudFinalResult

可以看到我们成功将金币的随机数值信息通过HUD的方式跟随显示出来了。

音乐和音效

操作,玩法,摄像机跟随都实现了,游戏还是看起来没那么激情,这个时候我们需要将背景音乐和对应玩法时机的音效加上才有感觉。

  1. 进入游戏就播放背景音乐
  2. 吃到金币和加时道具播放收集音效
  3. 玩家移动播放行走音效。

这个代码没什么好说的,主要就是在对应时机播放对应背景音乐和音效,除了玩家移动播放行走音效需要自行逻辑处理间隔播放控制,因为玩家移动操作是每帧都在同步的。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 检测是否需要播放玩家行走声音
/// </summary>
private void CheckPlayPlayerWalkSound()
{
var newRealtimeSinceStartup = Time.realtimeSinceStartup;
if(newRealtimeSinceStartup - mLastPlayPlayerWalkSoundTime < CollectCoinConst.PlayWalkSoundInterval)
{
return;
}
mLastPlayPlayerWalkSoundTime = Time.realtimeSinceStartup;
AudioManager.Singleton.PlaySFXSound("player_walk.wav");
}

Reference

2D Adventure Game: Robot Repair

前言

在游戏开发过程中,无论是做手游还是端游还是小游戏,我们或多或少都会需要接入第三方SDK去支持一些原生功能,不会全部都自己从0开发(比如广告SDK,统计SDK,支付SDK等),本章节就是为了深入SDK接入实战,打通SDK接入流程,同时深入Unity+原生开发,了解原生开发过程中的理论和实践知识。

原生开发

原生开发是指除了Unity提供的官方功能,我们还能在不同平台自己进行原生功能开发。

Android原生开发

Android我们通过自定义MainActivity和编写原生Android功能代码实现原生功能,然后在Unity测通过AndroidJavaClass去访问原生Android Activty和调用原生代码功能。Untiy支持我们自己导出**AAR(包含代码和资源)或者Jar(仅仅包含代码)**然后放到Plugins/Android/libs目录下直接使用,接下来我将深入从0实战如何搭建一个能够自定义开发原生功能流程框架搭建。

Unity导出Gradle工程

为了方便参考Unity能正确使用的Gradle工程相关设置,在搭建我们自己的原生Android工程前,我通过代码打包给Unity Android设置好相关打包配置,然后通过Unity自带的Gradle打包导出一个Android工程。

导出后我使用Android Studio打开发现报错:

UnityExportGradleProjectOpenFailed

这是因为Unity 2022.3.62f3导出的Gradle工程的JDK默认使用的是JAVA_HOME指向的,而之前我为了配置Jenkins打包将JAVA_HOME指向了最新的Java 21。但Unity 2022.3.62f3导出的Gradle工程使用的是Gradle 7.5.1,Gradle 7.5.1不支持运行在Java 21上,所以导致出问题。

UnityExportGradleProjectUseEnvDefaultJSDK

所以我们需要将导出Gradle工程的JDK指向Unity对应版本的JDK(对于我本地来说是以下路径):

1
G:\Software\UnityAllVersions\2022.3.62f3\2022.3.62f3\Editor\Data\PlaybackEngines\AndroidPlayer\OpenJDK

修改后重新打开Gradle工程经历漫长的Gradle依赖下载后会发现工程能正确打开了:

AfterSettingGradleProjectJDK

接下来我们就能创建我们的原生Android工程,并参考导出的Gradle工程进行配置设置了。

Unity 2022.3.62f3用的Gradle版本是7.51,Android Gradle Plugin是7.4.2,JDK是11

Note:

  1. Gradle相关东西大部分都需要翻墙下载,请确保网络环境支持
  2. Gradle 是通用构建系统。它不只用于 Android,也能构建普通 Java、Kotlin、Groovy 等项目
  3. Android Gradle Plugin,简称 AGP,是运行在 Gradle 上的 Android 构建插件

创建原生Android工程

Android工程分为两大类,一类是Application(用于打包APK),另一类是library(用于导出AAR或Jar)

我们安装好Android Studio后,File->New->New Project->新建一个Empty Activity的NativeFramworkAndroidProject Android工程:

CreateNativeFramworkAndroidProject

创建后会发现工程里有很多kotlin相关的内容,比如MainActivity.kt,app/build.gradle里有很多kotlin相关内容,这个好像是因为Android官方最新推的就是kotlin所以默认都带了。我们只是想扩展Unity的原生MainActivity去扩展编写原生功能,并不是想完全当做Android原生开发,所以我希望的还是走Android+java相关的原生开发方式,后续创建Android Library后会完全放弃并删除Android App相关。

创建完Android工程后我发现后台任务一直提示ImportingNativeFrameworkAndroidProject” Gradle Project,我打开View->Tool Window->Build看到好像在处理SDK 36相关的东西(AI说是工程默认用的AGP 9.0所以会去找SDK 36),但我本机还没安装SDK 36:

ImportingGradleProjectWithSDK36

为了让项目Import成功后使用New Module创建Android Library这里需要先打开Tools->SDK Manager->勾选SDK版本并安装(SDK 36):

InstallSDK36

这里只是为了让Android工程导入通过,后续我会根据Unity导出的Gradle工程去严格修改Android相关工具配置!

安装完成后打开NativeFrameworkAndroidProject的Android工程经历漫长的导入,最终Android工程导入完成打开成功了:

OpenAndroidProjectSuccess

Note:

  1. 创建Android工程的Build configuration language选项要选Groovy DSL,使用Groovy语言来编写.gradle文件(考虑到Unity导出Gradle工程也是用的Groovy),虽然Android官方推荐选Kotlin DSL
  2. 主要包名要和我们游戏里的包名保持一致
  3. Minimus SDK最好也保持和项目设置一致

确保正确的Gradle,Android Gradle Plugin和JDK

接下来我们先确保前面提到的Gradle,Android Gradle Plugin和JDK使用和Unity对应版本一致先。

在修改各个目标文件之前,我们先看下AI给出的项目结构和各文件的作用说明:

AndroidFolderStructure

AndroidProjectReponsibility

  • 保持根gradle.properties内容和导出Gradle工程gradle.properties一致:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    org.gradle.jvmargs=-Xmx4096M
    org.gradle.parallel=true
    unityStreamingAssets=UnityServicesProjectConfiguration.json, google-services-desktop.json
    # Android Resolver Properties Start
    android.useAndroidX=true
    android.enableJetifier=true
    # Android Resolver Properties End
    unityTemplateVersion=7
    android.useAndroidX=true
    android.enableJetifier=true
    org.gradle.welcome=never
    android.aapt2FromMavenOverride=G\:\\Software\\UnityAllVersions\\2022.3.62f3\\2022.3.62f3\\Editor\\Data\\PlaybackEngines\\AndroidPlayer\\SDK\\build-tools\\34.0.0\\aapt2.exe

    我们会在build.gradle里指定buildToolsVersion版本,但如果不强制指定aapt2工具,Android Gradle Tool会选用默认依赖的相关工具导致出错,为了确保跟Unity版本使用工具一致这里还是需要明确指定aapt2FromMavenOverride工具

  • 保持JDK版本一致

    将项目的Gradle JDK设置(File->Settings->Build,Execution,Deployment->Build Tools->Gradle->Gradle JDK)成我们Unity对应版本需要的JDK路径:

    SetUnityGradleJDKLocation

  • 保持Gradle版本一致

    打开Android Project根目录下的gradle-wrapper.properties保持和Unity导出的Gradle工程设置一致如下:

    1
    distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
  • 保持Android Gradle Plugin版本一致

    打开我们Android Project根目录下的build.gradle文件修改成如下:

    1
    2
    3
    4
    5
    // Top-level build file where you can add configuration options common to all sub-projects/modules.
    plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    }

    如果发现Sync Gradle报错如下:

    1
    org.gradle.api.GradleException: Error resolving plugin [id: 'com.android.application', version: '9.0.1']

    这是因为我们创建的Android工程里的libs.version.toml里还起订了Android Gradle Plugin的版本信息,将libs.versions.toml里AGP版本修改到7.4.2:

    1
    2
    [versions]
    agp = "7.4.2"

    真正关联上Application和Library使用哪个Android Gradle Plugin版本的配置是在libs.version.toml:

    ConnectAndroidGradlePluginVersionConfig

    再次Sync Gradle报错如下:

    1
    2
     problem occurred evaluating project ':app'.
    > Could not find method compileSdk() for arguments [build_6jjk1t0mf4mcnif7jl7frtd5s$_run_closure1$_closure3@519d74c7] on extension 'android' of type com.android.build.gradle.internal.dsl.BaseAppModuleExtension.

    这是因为我们将Android Gradle Plugin版本改到7.4.2后,不识别app/build.gradle(unitynativeframework/build.gradle里同理)里的以下写法:

    1
    2
    3
    compileSdk {
    version = release(36)
    }

    需要修改成如下:

    1
    compileSdkVersion 36

    最终终于通过了Sync Gradle同步。

    我们通过View->Tool Windows->Gradle->Execute Gradle Task输入**:unitynativeframework:assembleRelease**执行Android Library生成AAR,报错如下:

    GenerateAndroidLibraryFailedLog

    根据AI的说法是我们虽然指定了AGP版本7.4.2,但没有显示指定Build Tools的版本,所以他默认去找配套的30.0.3版本Build Tools了,同时发现找不到,所以解决问题的关键是要配置跟Unity版本配套的Build Tools版本,在对应build.gradle里分别指定以下文本:

    1
    buildToolsVersion '34.0.0'

Note:

  1. 注意将部分build.gradle里指定的minSdk和targetSdk也同步成我们目标需要的值

添加Android Library

接下来我们创建一个Android Liabrary:

CreateAndroidLibraryProject

添加Unity对应版本的classes.jar到Android Library的lib目录下:

AddUnityClassJarToAndroidLibrary

添加自定义的MainActivity.java文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.tonytang.unitynativeframework;

import android.os.Bundle;

import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.util.Log;
import android.util.DisplayMetrics;

import com.unity3d.player.UnityPlayer;
import com.unity3d.player.UnityPlayerActivity;

public class MainActivity extends UnityPlayerActivity {

public final static String mUnityCallBackHandler = "GameLauncher";

public Context mContext = null;

public Activity mCurrentActivity = null;

private int mWidth;

private int mHeight;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

Log.d("JNI", "onCreate()");
mContext = this.getApplicationContext();
mCurrentActivity = this;

DisplayMetrics dm = getResources().getDisplayMetrics();
mWidth = dm.widthPixels;
mHeight = dm.heightPixels;
Log.d("JNI","mWidth :" + mWidth);
Log.d("JNI","mHeight :" + mHeight);
}

protected void onStart()
{
super.onStart();

Log.d("JNI", "onStart()");
}

protected void onResume()
{
super.onResume();

Log.d("JNI", "onResume()");
}

protected void onPause()
{
super.onPause();

Log.d("JNI", "onPause()");
}

protected void onStop()
{
super.onStop();

Log.d("JNI", "onStop()");
}

protected void onDestroy()
{
super.onDestroy();

Log.d("JNI", "onDestroy()");
}

public void onWindowFocusChanged(boolean hasFocus)
{
super.onWindowFocusChanged(hasFocus);

Log.d("JNI", "onWindowFocusChanged(" + hasFocus + ")");
}

@Override
public void onConfigurationChanged(Configuration newConfig)
{
super.onConfigurationChanged(newConfig);
Log.d("JNI","onConfigurationChanged()");
}

//Unity call part
public void javaMethod(String csparam)
{
Log.d("JNI", "javaMethod() with parameter:" + csparam);
UnityPlayer.UnitySendMessage(mUnityCallBackHandler,"resUnityMsg", "java param");
}
}

在Android Library的build.gradle里添加以下配置,将classes.jar仅编译但不导出避免和Unity的classes.jar出现双份冲突:

1
2
3
4
dependencies {
******
compileOnly files('libs/classes.jar')
}

Unity对应版本的classes.jar路径在对应版本安装目录下,不确实哪个可以直接拷贝导出的Gradle工程里的classes.jar

将导出的Gradle工程下的unityLibrary的AndroidManifest.xml拷贝到Unity项目Plugins/Android目录下作为基准AndroidManifest.xml使用,但要将android:name=”com.unity3d.player.UnityPlayerActivity”修改成我们自己的包名android:name=”com.tonytang.unitynativeframework.MainActivity”,这一步是将默认的Unity MainActivity修改成我们自定义的MainActivity的关键:

CoreDataInAndroidManifestXML

*符号包着的那一段是指定将com.tonytang.unitynativeframework.MainActivity作为启动Activity的相关配置

为什么我们的Android Library工程不需要配置像Unity导出的Gradle工程相关的NDK配置?为什么 Unity 导出工程需要 NDK?

Unity 导出的 unityLibrary/build.gradle 不只是普通 Android Library,它包含:

  • Unity Player 运行时;
  • IL2CPP 生成的 C++;
  • Unity 原生库;
  • 游戏程序集和资源;
  • 多个 CPU 架构的 .so;
  • IL2CPP 编译任务。

而我们编译Android Library的工程只有Android Java代码,是构建一个纯Java Android Library的AAR所以不需要使用NDK工具,如果未来需要包含C++或者.so或者使用JNI啥的才需要NDK。

导出Android Library AAR

打开Gradle窗口(View-> Tool Windows -> Gradle),准备导出Library AAR:

点击Execute Gradle Task并输入:unitynativeframework:assembleRelease

等待执行完成后发现AAR生成成功:

AndroidLibraryBuildSuccess

导出成功后我们将导出的unitynativeframework-release.aar放到Unity工程的Plugins/Android/libs目录下等待使用。

CopyAARToAndroidPluginsLibFolder

为什么Build Gradle Task输入:unitynativeframework:assembleRelease会有效?

因为assembleRelease 就是 Android Gradle Plugin 自动创建的任务,不需要你手写。

assemble:装配、打包构建产物。

Release:构建 release 变体。

对于Android Library来说,

1
2
3
4
5
assembleRelease
→ 编译 Release 代码和资源
→ 处理 Manifest
→ 打包 classes.jar
→ 生成 Release AAR

对 Android Application 来说:

1
2
assembleRelease
→ 生成 Release APK

Task的名称来源于build.gradle里:

1
2
3
4
5
buildTypes {
release {
minifyEnabled false
}
}

以上配置Android Gradle Plugin就会创建assembleRelease的任务。

Unity访问原生代码

Android原生访问代码功能实例:

AndroidNativeManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
******

#if UNITY_ANDROID
/// <summary>
/// AndroidNativeManager.cs
/// Android原生管理类
/// </summary>
public class AndroidNativeManager : NativeManager
{
/// <summary>
/// Android主Activity对象
/// </summary>
private AndroidJavaObject mAndroidActivity;

/// <summary>
/// 初始化
/// </summary>
public override void init()
{
Debug.Log("AndroidNativeManager:init()");
Debug.Log($"Application.platform:{Application.platform}");
if (Application.platform == RuntimePlatform.Android)
{
// 这里使用using的目的是确保AndroidJavaClass对象尽快被删除
// Unity访问原生Android的Java对象抽象(源码查看Unity官方的UntiyPlayer.java文件)
using (AndroidJavaClass jc = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
{
// 获得当前Activity(这里是为了获得MainActivity)
mAndroidActivity = jc.GetStatic<AndroidJavaObject>("currentActivity");
if (mAndroidActivity == null)
{
Debug.Log("获取UnityMainActivity::mAndroidActivity失败!");
}
else
{
Debug.Log("获取UnityMainActivity::mAndroidActivity成功!");
}
}
}
}

/// <summary>
/// 调用原生方法
/// </summary>
public override void callNativeMethod()
{
Debug.Log("AndroidNativeManager:callNativeMethod()");
if (mAndroidActivity != null)
{
mAndroidActivity.Call("javaMethod", "cs param");
}
}
}
#endif

然后在按钮点击的时候调用:

GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 游戏入口
/// </summary>
public class GameLauncher : MonoBehaviour
{
******

/// <summary>
/// 调用原生方法
/// </summary>
public void onCallNativeMethodClick()
{
Debug.Log("onCallNativeMethodClick()");
LogCallNativeMethodBtnClickEvent();
NativeManager.Singleton.callNativeMethod();
}
}

打包安装运行:

CustomMainActivityRunningLog

从日志可以看出我们成功实现了导出自定义AAR+指定我们自定义的MainActivity作为游戏启动Activity的功能。

我们点击按钮执行调用原生Android的javaMethod方法:

CallAndroidNativeMethodSuccess

从截图可以看出我们成功调用了我们在Android的MainActivity里自定义的javaMethod方法,同时我们通过UnityPlayer.UnitySendMessage的方式成功回调到了Unity测的NativeMessageHandler.resUnityMsg()方法和传参

至此我们想要实现搭建Android Library导出AAR(含自定义MainActivity入口)的框架流程就算正式打通了。

如果打包后发现访问原生Java代码提示方法不存在,可能是因为我们打包时开启了Java代码压缩/混淆(PlayerSettings.Android.minifyRelease = true),此时需要去Android Library工程添加consumer-rules.pro指定需要不混淆的规则。

comsumer-rules.pro

1
2
3
# Unity invokes this Activity and its bridge methods through the Android manifest
# and AndroidJavaObject/JNI, so R8 cannot discover the call sites statically.
-keep class com.tonytang.unitynativeframework.MainActivity { *; }

IOS原生开发

IOS原生开发同理,IOS也有属于自己的程序启动入口(UnityAppController.mm),结合以前的学习(详情参考:Unity原生])我们可以知道要想自定义IOS原生入口编写功能,我们需要编写一个继承至UnityAppController的类作为程序入口,目前IOS好像支持Objective-C和Swift,以下是以Objective-C为例:

AppController.h

1
2
3
4
5
6
7
8
// 自定义Unity AppCOntroller
//导入Unity的UnityAppController.h,自定义AppController需要继承至UnityAppController
#import "UnityAppController.h"

@interface AppController : UnityAppController
@property(nonatomic,strong) UINavigationController *naVC;
-(void) createUI;
@end

AppController.mm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//自身的头文件
#import "AppController.h"
//导入我们自己写接口的类的.h文件,用于实现自定义的UIViewController
#import "UnityViewController.h"
//其他文件
#import <Foundation/Foundation.h>

@implementation AppController
-(void) createUI
{
NSLog(@"AppController:createUI()");
_rootController = [[UIViewController alloc]init];
_rootView = [[UIView alloc]initWithFrame:[UIScreen mainScreen].bounds];
_rootController.view = _rootView;

//自定义viewcontroller添加到现有的viewcontroller上
UnityViewController *vc = [[UnityViewController alloc]init];
self.naVC =[[UINavigationController alloc]initWithRootViewController:vc];

//添加自定义view到rootView上
[_rootView addSubview:self.naVC.view];

_window.rootViewController = _rootController;
[_window bringSubviewToFront:_rootView];
[_window makeKeyAndVisible];
}
@end

//这是一个固定写法,因为这个代码,所以启动界面是从这里启动
IMPL_APP_CONTROLLER_SUBCLASS(AppController);

通过上面的代码,我们完成了下面两件事:

  1. 自定义Unity IOS程序入口
  2. 添加了自定义的View(绑定到自定义UIViewController上)到rootView上

成功绑定到自定义UIViewController上后,我们就能通过自定义的UIViewController去和IOS打交道了。

UnityViewController.h

1
2
3
4
5
6
7
//自定义的UIViewController
#import "UnityAppController.h"

@interface UnityViewController : UIViewController
//权限申请
- (void)audioAuthAction;
@end

UnityViewController.mm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//包含自身头文件
#import "UnityViewController.h"

//其他头文件
#import "UnityAppController+ViewHandling.h"
#import <UI/UnityView.h>
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>

@implementation UnityViewController

- (void)viewDidLoad
{
NSLog(@"UnityViewController:viewDidLoad()");
[super viewDidLoad];
[self.view addSubview:GetAppController().unityView];
GetAppController().unityView.frame = self.view.frame;

// Do any additional setup after loading the view.
// 权限申请相关
[self audioAuthAction];
}

//权限申请
- (void)audioAuthAction
{
//麦克风权限申请
NSLog(@"UnityViewController:audioAuthAction()");
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
NSLog(@"%@",granted ? @"麦克风准许":@"麦克风不准许");
}];
}

-(void)viewWillAppear:(BOOL)animated{
//隐藏导航条view
NSLog(@"UnityViewController:viewWillAppear()");
self.navigationController.navigationBar.hidden = YES;
}

-(void)viewDidDisappear:(BOOL)animated
{
NSLog(@"UnityViewController:viewDidDisappear()");
self.navigationController.navigationBar.hidden = NO;
}
@end

可以看到通过自定义的UIViewController,我们就可以直接编写对应原生功能(e.g. 权限申请)代码了。

SDK

SDK 是「Software Development Kit」的縮寫,中文稱為「軟體開發工具套件」

很多跨平台功能需要处理原生的东西,且这些功能可以说是对大部分游戏都需求 统一的,所以很多时候这些功能(比如广告SDK,统计SDK,支付SDK等)会被做成SDK供我们直接集成使用。

Firebase

Firebase 是一组面向移动端的云服务和运营工具。它把账号、数据存储、云函数、崩溃收集、数据分析、远程配置、推送等常见能力组合在一起,并提供 Unity/C++ SDK。

Firebase能提供很多我游戏开发中会用到的一些SDK功能,比如崩溃统计,数据大大点,消息推送,登录授权等。

在游戏开发过程中如果我们的游戏走海外移动端,基本都会选Firebase。但在国内受限于网络的原因,部分Firebase功能我们需要选择其他替代品。

AI给出的国内外推荐服务选择:

能力 海外版 中国大陆版
崩溃监控 Firebase Crashlytics Bugly、国内 APM、Sentry
数据分析 Firebase Analytics 数数、神策、火山、友盟或自建
远程配置 Firebase Remote Config 国内 CDN/配置中心/自建
Android 推送 FCM 厂商推送或聚合推送
iOS 推送 FCM/APNs 优先评估直接 APNs
登录 Firebase Auth 国内账号、渠道账号、自研
云存档 Firestore 国内后端和数据库
云函数 Cloud Functions 国内云函数或自建服务
文件存储 Firebase Storage 国内对象存储和 CDN
实时战斗 专用游戏服务器 专用游戏服务器

Note:

  1. Firebase在PC端支持不太完善

Firebase Unity接入

接下来以实战Unity接入Firebase的崩溃监控和数据功能为例。

Firebase接入条件限制:

  • 安装 Unity 2021 LTS 或更高版本。更早的版本也可能兼容,但不享受积极支持。

  • (仅限 Apple 平台)安装以下各项:

    • Xcode 16.2 版或更高版本
    • CocoaPods 1.12.0 或更高版本
  • 确保您的 Unity 项目满足以下要求:

    • 对于 iOS - 目标为 iOS 15 或更高版本
    • 对于 tvOS - 目标为 tvOS 15 或更高版本
    • 对于 Android - 目标 API 级别为 23 (Marshmallow) 或更高版本
  • 设置一台实体设备或使用模拟器运行您的应用。

    • 对于 Apple 平台 - 设置实体设备或使用 iOS 或 tvOS 模拟器。

      您是要用 Cloud Messaging?

    • 对于 Android - 模拟器必须使用包含 Google Play 的模拟器映像。

  • 使用您的 Google 账号登录 Firebase

详细Firebase接入Unity文档

登录Firebase创建项目

Firebase项目创建

CreateFirebaseProject

创建Firebase项目成功后可以看到我们创建的项目:

CreateFirebaseProjectSuccess

在Firebase中注册应用

打开我们创建的Firebase项目:

OpenFirebaseProject

点击添加应用后选择对应平台,这里以Android平台为例:

RegisterFirebaseAndroidApp

根据要求下载google-services.json文件并放到项目Assets目录下:

DownloadGoogleServicesJsonFile

SaveGoogleServicesJsonToAssetFolder

google-services.json内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"project_info": {
"project_number": "******",
"project_id": "unitynativeframework",
"storage_bucket": "unitynativeframework.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "******",
"android_client_info": {
"package_name": "com.TonyTang.UnityNativeFramework"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "******"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

可以看到根据我们注册应用传入的项目包名,Firebase生成了project_number,mobilesdk_app_id,api_key等相关的值。

添加Firebase Unity SDK

下载Firebase Unity SDK

DownloadAllFirebaseUnitySDK

可以看到里面包含了各种Firebase提供的SDK包,这里我以**数据统计SDK(FirebaseAnalytics.unitypackage)**作为实战SDK。

将FirebaseAnalytics.unitypackage导入Unity。

导入过程中会问一些是否激活特定额外功能,这个根据需求选择,我目前都选择了No,但最后提示Enable Android Auto-resolution时要选择Enable,让他自行解决下Android的一些依赖冲突:

EnableAndroidAutoResolution

AI的说法是这一步会根据项目所有第三方插件配置文件进行分析,去下载相关依赖AAR,更新Unity的Gradle模版等操作。

FirebaseAnalyticImportAndroidStaff

FirebaseApp.androidlib不是Firebase Analytics SDK本体,它是Firebase项目配置资料库(比如AndroidManifest.xml(定义Firebase的Android Activity)和google-services.xml(定义一些Android资源icon名称))

mainTemplate.gradle 这是Unity Android 构建中的主 Android Library 模块 Gradle 模板,主要用于生成unityLibrary/build.gradle。

settingsTemplate.gradle是根Gradle工程,主要负责配置一些内容去决定哪里寻找插件和依赖、工程包含哪些模块。

gradleTemplate.properties是Unity 生成gradle.properties的模版,它控制整个 Gradle 构建进程的属性。

确认Google Play服务版本要求

需要再项目启动时找个时机添加一下CSharp代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using Firebase.Extensions;

private void Start () {
Debug.Log("GameLauncher:Start()");
******

FireBaseInitilization();
}

/// <summary>
/// Firebase初始化
/// </summary>
private void FireBaseInitilization()
{
// 检查Google Play服务版本要求
Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task => {
var dependencyStatus = task.Result;
if (dependencyStatus == Firebase.DependencyStatus.Available) {
// Create and hold a reference to your FirebaseApp,
// where app is a Firebase.FirebaseApp property of your application class.
var app = Firebase.FirebaseApp.DefaultInstance;
Debug.Log($"Firebase app.Name:{app.Name} dependencyStatus:{dependencyStatus}");
OnFirebaseCheckComplete();
// Set a flag here to indicate whether Firebase is ready to use by your app.
} else {
UnityEngine.Debug.LogError(System.String.Format(
"Could not resolve all Firebase dependencies: {0}", dependencyStatus));
// Firebase Unity SDK is not safe to use here.
}
});
}

FireBaseInitilization()里的代码就是Firebase Analytics检查Google Play服务版本要求的核心代码,注意看我们在打点登录事件调用是放在检查通过以后,原因是在Google Play服务版本要求检查通过之前,我们是不允许使用打点功能的。

打包测试Firebase Analytics

打包失败提示如下:

FirebaseAnalyticsBuildFailed

字面意思我们设置的Android最低版本是22,但Firebase Analytics要求Android最低版本是23,所以修改我们打包时设置的Android最低版本到23即可:

1
PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel23;

再次触发打包,依然报错:

FirebaseBuildErrorLog2

根据AI的说法是Unity 2022.3自带的AGP 7.4.2/D8版本较久。旧D8在解析时出错,建议我将minSdkVersion设置成24后终于打包成功了。

安装测试可以看到Firebase Analytic的Google Play服务检查通过了:

FirebaseCheckGooglePlayServiceSuccess

Note:

  1. 我使用的Unity版本是2022.3.62f3,Unity Firebase SDK版本是13.15.0
Unity版Firebase Analytics使用入门

Unity版Google Analytics使用入门

Unity接入Firebase Analytics成功后,我们就能使用Firebase Analytics进行数据打点。

定义一个FBAParameters去定义我们Firebase Analytics自定义打点的参数名常量定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// FBAParameters.cs
/// Firebase Analytics参数定义静态类
/// </summary>
public class FBAParameters
{
/// <summary>
/// 设备唯一标识符
/// </summary>
public const string DeviceUid = "device_uid";

/// <summary>
/// 调用原生方法按钮点击事件
/// </summary>
public const string CallNativeMethodBtnClick = "call_native_method_btn_click";
}

比如我打点一个登录事件打点,打点数据参数是用户设备UID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/// <summary>
/// 设备唯一标识符
/// </summary>
private string DeviceUid;

private void Start () {
Debug.Log("GameLauncher:Start()");
DeviceUid = SystemInfo.deviceUniqueIdentifier;
Debug.Log($"Device UID: {DeviceUid}");

******
FireBaseInitilization();
}

/// <summary>
/// Firebase初始化
/// </summary>
private void FireBaseInitilization()
{
// 检查Google Play服务版本要求
Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task => {
var dependencyStatus = task.Result;
if (dependencyStatus == Firebase.DependencyStatus.Available) {
// Create and hold a reference to your FirebaseApp,
// where app is a Firebase.FirebaseApp property of your application class.
var app = Firebase.FirebaseApp.DefaultInstance;
Debug.Log($"Firebase app.Name:{app.Name} dependencyStatus:{dependencyStatus}");
OnFirebaseCheckComplete();
// Set a flag here to indicate whether Firebase is ready to use by your app.
} else {
UnityEngine.Debug.LogError(System.String.Format(
"Could not resolve all Firebase dependencies: {0}", dependencyStatus));
// Firebase Unity SDK is not safe to use here.
}
});
}

/// <summary>
/// Firebase检查完成回调
/// </summary>
private void OnFirebaseCheckComplete()
{
Debug.Log("OnFirebaseCheckComplete()");
LogLoginEvent();
TryCustomEvent();
}

/// <summary>
/// 打点登录事件
/// </summary>
private void LogLoginEvent()
{
Debug.Log("LogLoginEvent()");
FirebaseAnalytics.LogEvent(FirebaseAnalytics.EventLogin, FBAParameters.DeviceUid, DeviceUid);
}

/// <summary>
/// 尝试记录自定义事件
/// </summary>
private void TryCustomEvent()
{
Debug.Log("TryCustomEvent()");
FirebaseAnalytics.LogEvent(FBAParameters.CustomEvent, FBAParameters.DeviceUid, DeviceUid);
}

同时在打一个DIY的调用本地方法按钮点击次数统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 点击调用原生方法按钮事件
/// </summary>
private void LogCallNativeMethodBtnClickEvent()
{
Debug.Log("LogCallNativeMethodBtnClickEvent()");
FirebaseAnalytics.LogEvent(FBAParameters.CallNativeMethodBtnClick, FBAParameters.DeviceUid, DeviceUid);
}

/// <summary>
/// 调用原生方法
/// </summary>
public void onCallNativeMethodClick()
{
Debug.Log("onCallNativeMethodClick()");
LogCallNativeMethodBtnClickEvent();
}

打包后验证:

FirebaseAnalyticLogEventRuntime

可以看到登录日志打点已经出发了,紧接着我点了两次调用Java方法按钮去触发原生方法按钮点击统计打点。(因为代码有点问题反复修改代码测试了多次,导致部分事件统计了多次)

我们打开Firebase对应项目,搜索Events(如果刚发送的没能立刻看到可以搜索Realtime Analytics):

FirebaseAnalyticEventsPage

可以看到无论是官方定义的事件明还是我们自定义的事件名都成功发送统计到了Firebase Analytics后台。

但奇怪的一点是我在Firebase Analytics Events页面上好像只有部分事件能看到我传递的自定义参数:

FirebaseEventLogParamsDetail

CustomLogEventParams

总的来说,Firebase Analytics的功能集成和使用算是基本成功,Firebase Analytics的接入告一段落。

AdMob

Reference

将 Firebase 添加到您的 Unity 项目

Modify Gradle project files with Gradle template files

Gradle for Android

Android App Manifest

Create a custom activity

前言

游戏开发过程中打包是一个很频繁和复杂的事。打包除了涉及资源打包流程,还涉及到APK,IPA,小游戏打包等平台分类。不同平台接入第三方SDK等库又不一样,不同渠道又有不同的SDK要接入。每次打包如果都是打开Unity的打包窗口去设置这些详细打包参数,那对于打包维护成本来说就很高。本章节就是详细深入**CI/CD(持续集成和持续交付/部署)**的实战,目标是打造易用性,易扩展维护性,支持快速持续打包部署交付的自动化打包流程。

Jenkins

The leading open source automation server, Jenkins provides hundreds of plugins to support building, deploying and automating any project.

Jenkins是一个用Java编写的开源的持续集成工具。

从介绍可以看出Jenkins是一个免费开源的支持数百个插件用于打包,部署,自动化的自动化服务器。

以前学习了解过Jenkins的打包流程,这一次准备从0到1搭建Unity游戏开发的全自动化打包流程(包含不同打包需求,不同平台,不同渠道,不同SDK等)。

Jenkins官网文档

Jenkins安装

因为Jenkins是基于Java编写的,所以我们要先安装Java环境:

Java下载地址

关于Java环境变量设置参考网上说明,最终配置好后:

JavaHomeEnvSettting

JavaBinEnvSetting

后续会将Path里的全路径修改成访问JAVA_HOME的相对路径方便统一修改Java版本。

验证是否设置Java环境变量成功可以在PowerShell里输入”java -version”查看java版本信息:

JavaVersionInPowerShell

搭建Jenkins自动化打包第一步安装Jenkins:

Jenkins下载地址

考虑到C盘硬盘容量有限,我安装到了其他盘:

JenkinsInstallLocation

为了确保后续Jenkins权限服务正常,优先选择Logon Type为Run service as local or domain user:,选择这个后需要输入本机的用户名和密码(这里的用户名和密码我尝试了各种用户名和密码好像都不对),考虑到为了避免使用现有用户名可能因为现有用户名的一些权限修改影响Jenkins运行,最终我决定新建一个用户名作为Jenkins独立使用的用户名,Win10家庭版添加用户名流程:

设置->账户->家庭和其他用户->将其他人添加到这台电脑->输入新用户名和密码

Win10AddNewUserForJenkins

然后我们使用这个用户名和密码作为Jenkins安装的用户名,如果确认用户名密码都数对了,但还是提示Test Credential失败如下:

TestCredentialsFailed

按照官网的说法是指定的用户名没有Logon as a service的权限解决办法

如果是家庭版Win10好像没有secpol.msc这个时候我们要使用以下方案:

How To Enable Group Policy Editor (gpedit.msc) In Windows 10 Home

经过测试上述方案命令行有报错,无法正确安装gpedit.msc,也就无法给用户名添加Logon as a service权限,最后AI的建议是先通过LocalSystem模式安装,安装完成后就能有services.msc工具,同时也能从LocalSystem指定切换用户,相当于最终又回到Run service as local or domain user的方式!

  1. 以LocalSystem安装Jenkins

    ChooseRunServiceAsLocalSystem

  2. 设置默认Jenkins端口号到8080

    SetJenkinsPort

  3. 设置Java SDK位置

    ChooseJavaJDKLocaltion

    这一步Jenkins对Java版本有要求,需要下对应版本的Java JDK

    JenkinsJavaVersionRequired

    这里本人安装一个Java 21,安装完成后要将JAVA_HOME重新指向最新的Java 21目录,这样本机默认使用的才是Java 21,为了保持Java Path配置一致性顺带将Path里的路修改成%JAVA_HOME%\bin的方式:

    JavaHomeAndPathConfiguration

    打开PowerShell输入”java -version”测试是否配置成功:

    TestJava21Configuration

  4. Jenkins安装成功后我们就能在**本地服务(Ctrl+R然后输入services.msc运行)**看到Jenkins服务了,属于自动启动也就意味着开机Jenkins服务就会自动运行起来

    JenkinsServiceInfo

  5. 本地服务里选中Jenkins右键->属性->登录,输入要切换的用户名和密码进行用户名切换,从而切换到前面我们无法使用的Run service as local or domain的方式:

    ChangeJenkinsUserAccountInfo

    这一步貌似直接使用现成的用户名和密码好像无法正确启动Jenkins,AI说因为我使用的账户是MicrosoftAccount账户,需要单独创建一个用户作为Jenkins的用户

  6. 创建新用户,设置->账号->家庭和其他用户->将其他人添加到这台电脑

    CreateJenkinsBuildUser

  7. 然后我们再次去设置Jenkins服务的用户到jenkins-build这个用户并启动Jenkins服务

    ChangeJenkinsUserAndLaunch

    可以看到经历千辛万苦我们终于给Jenkins指定新用户且启动起来了。

  8. 本地测试下Jenkins是否运作正常,在浏览器输入localhost:8080进行测试

    RunJenkinsWithPassword

    默认密码存在于C:\ProgramData\Jenkins.jenkins\secrets\initialAdminPassword我们复制输入后进入Jenkins初始化设置界面

  9. 由于第一次安装也不确定安装哪些Jenkins插件,所以这里我默认安装推荐的插件模式(安装好后进入Jenkins后续我们还能根据自己需求去自行删除和安装插件)

    InstallRecomendedPlugins

    安装过程中会发现有大量插件安装失败:

    JenkinsRecomendPluginsInstallFailed

    这个我们可以后续再自行安装。

  10. Jenkins插件安装完成后,我们需要创建我们第一个Jenkins管理员账号

    CreateJenkinsAdminAccount

  11. 接下来配置Jenkins的访问URL

    JenkinsURLConfig

  12. 完成Jenkins设置可以开始使用Jenkins了

    CompleteJenkinsSetting

  13. 考虑到刚才有很多插件安装失败了,所以开始Jenkins使用的第一件事就是打开Manage Jenkins进行插件安装

    ReinstallJenkinsPlugin

    关于到底哪些插件安装失败了,可以打开Download progress查看到:

    JenkinsPluginDownloadProgress

    部分插件貌似需要重启Jenkins,但重启Jenkins的我发现又报错了:

    ReLaunchJenkinsFailed

    核心原因是我们前面设置的用户jenkins-build没有对C盘jenkins目录和安装Jenkins目录(含jenkins.exe)的读写权限:

    JenkinsFolderAuthSettingProblem

    所以我们需要对这两个目录右键->属性->安全->编辑->添加jenkins-build用户(并设置对应权限)

    SetJenkinBuildUserAuth

    上面为了确保jenkins-build用户权限足够,我把对应两个目录的所有权限都勾上了。

    然后再次重启Jenkins会发现成功启动:

    ReLaunchJenkinsSuccess

  14. 然后再次打开Jenkins Manage Plugins惊奇的发现起始之前大部分插件已经安装成功了!

在真正开始使用Jenkins之前我们先了解下Managing Jenkins和Jenkins Pipeline。

Note:

  1. Jenkins作为Service启动在Windows上,必须确保Jenkins服务账户拥有Log on as a service 权限
  2. Jenkins的安装对Windows的.NET Framwork版本也有要求,Jenkins 2.238起要求.Net Framework 4.0,之前要求.Net Framework 2.0

修改Jenkins主目录

在了解Manageing Jenkins和Jenkins Pipeline之前,我先修改Jenkins的默认工作目录(因为我的C盘硬盘不够了),默认是在**%ProgramData%\Jenkins.jenkins**路径。

迁移流程如下:

  1. 打开PowerShell(必须以管理员身份运行)输入”net stop Jenkins”将Jenkins服务停止

    StopJenkinsService

  2. 复制所有%ProgramData%\Jenkins.jenkins目录下的文件到新的Jenkins目录

  3. 修改Jenkins目录下的jenkins.xml里的的value到新路径

    1
    <env name="JENKINS_HOME" value="G:\Software\JenkinsHome"/>
  4. 打开PowerShell(必须以管理员身份运行)输入”net start Jenkins”将Jenkins服务开启

    StartJenkinsService

  5. 然后再次在页面上打开jenkins的Manage Jenkins配置可以看到Jenkins的工作目录已经切换到我们的新目录了

    ChangeJenkinsFolderSuccess

    注意给新的Jenkins目录添加jenkins-build等使用用户的操作权限(这一步请先停止jenkins服务先,如果有部分文件权限修改报错如果能正常启起来Jenkins可以先不管,AI说是有写保护文件无法添加完全控制权限)

至此我们成功切换了Jenkins的工作目录,再也不用担心Jenkins的使用让我的C盘不够用了。

Managing Jenkins

打开Jenkins,点击Manage Jenkins我们会看到Jenkins的管理界面:

ManageJenkinsPage

接下来分别理解下这个页面上的各个入口是做什么的。

System Configuration

系统配置

给Jenkins Controller配置全局设置和路径

比如配置Jenkins主目录,Jenkins URL,系统管理员邮箱等

目前我只添加了一个系统管理员邮件地址

全局工具配置

工具配置,包括它们的位置和自动化安装器

插件管理

添加,删除,禁用或启用Jenkins功能扩展插件

推荐把Jenkins推荐安装的插件都先安了,方便后续直接开工搭建自动化打包流程

节点和云管理

添加,删除,控制和监视系统运行任务的节点

这里我们需要深入理解下Jenkins的几个概念Controller,Nodes,Agents,Executors

Controller

The Jenkins controller is the Jenkins service itself and where Jenkins is installed. It is also a web server that also acts as a “brain” for deciding how, when, and where to run tasks. Management tasks such as configuration, authorization, and authentication are executed on the controller, which serves HTTP requests. Files written when a Pipeline executes are written to the filesystem on the controller, unless they are off-loaded to an artifact repository such as Nexus or Artifactory.

从介绍来看,Jenkins Controller是Jenkins本身,是作为一个总管理者和调度中心的角色存在,比如管理任务调度,任务配置,任务权限等。

Nodes

Nodes are the “machines” on which build agents run. Jenkins monitors each attached node for disk space, free temp space, free swap, clock time/sync, and response time. A node is taken offline if any of these values go outside the configured threshold. Jenkins supports two types of nodes:

从介绍来看,Jenkins Nodes是构建机器,Jenkins负责管理这些构建机器的磁盘空间,系统时间,磁盘释放等。

Jenkins Nodes分为一下两种:

  1. agents

  2. build-in node

    build-in node好像是Controller里默认的一个节点,从安全,性能,可扩展性等方面来说官方不建议直接使用build-in node

    我想这就是为什么我在Manage Jenkins页面看到以下警告的原因:

    BuildinNodeWarning

Agents

Agents manage the task execution on behalf of the Jenkins controller by using executors.

从介绍看,Agents是通过使用Executors帮Controllers管理任务执行的。

Executors

An executor is a slot for the execution of tasks. Effectively, it is a thread in the agent. The number of executors on a node defines the number of concurrent tasks that can run. In other words, this determines the number of concurrent Pipeline stages that can execute at the same time.

从介绍看,Excecutor是真正执行任务的执行者,agent有多少个executor就表示能同时执行多少个任务。

官方建议如下:

通过上面的介绍,可以看出当我们搭建CI/CD工作流的时候,为了安全,性能和可扩展性最好不要直接使用Build-in node,我们应该去创建我们自己的Agent去针对性配置。

Clouds

添加,移除,配置远端提供Agent能力的实例

Appearance

配置Jenkins外观样子

Security

Security

保护Jenkins,配置谁被允许访问和使用Jenkins系统

这里有一项关于Git验证方式的配置Host Key Verification Strategy有以下几个选项:

  1. Know hosts file(default)

    使用.ssh\known_hosts文件进行验证(我们单独给Jenkins配置了jenkins-build用户,所以这个文件可能压根没有,也就是后续我们添加了SSH Credential后依然无法正常访问Git SSH地址的原因)

  2. Accept first connection

    第一次连接时接受并记录GitHub提供的Host Key,后续Host Key变化时拒绝连接。

  3. Manually provided keys

    利用手动添加到这里的Host key进行验证(后续为了避免不同WIndows用户名的SSH配置问题,这里会采用这个方案)

  4. No verification(not recommended)

    不验证Host Key

Credentials

配置证书相关

这里用于全局存放打包相关证书的地方,Agent Pipeline里通过全局变量访问,不要把证书信息直接写进Agent打包流程里!

Credential Providers

配置证书提供者和类型

Users

创建,删除,修改可以登录到Jenkins的用户信息

Status Information

系统信息

显示系统环境信息

系统日志

所有Jenkins通过java.util.loggins捕获到的日志信息

负载统计

显示Jenkins的资源使用情况

About Jenkins

查看Jenkins的版本和许可证信息

Jenkins Pipeline

在真正开始使用Jenkins之前我们先了解下Jenkins Pipeline,。

这里直接搬运下以前Jenkins Pipeline的学习记录。

什么是Pipeline

Jenkins Pipeline is a suite of plugins which supports implementing and integrating continuous delivery pipelines into Jenkins. Pipeline provides an extensible set of tools for modeling simple-to-complex delivery pipelines “as code”.(这里的Pipeline是一套插件,用于支持持续自动化集成编写。好比脚本文件定义一系列的自动化流程,但并不等价于脚本。)

那么Pipeline是用什么语言编写了?
Jenkins是基于Java的,Pipeline的编写是基于Groovy。
Groovy是Java平台上设计的面向对象编程语言。这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用。

这里了解几个Pipeline的概念Node,Stage,Step:

  1. Node

    这个就是前面讲到的Node概念,用于运行Pipeline的Node

  2. Stage

    A stage block defines a conceptually distinct subset of tasks performed through the entire Pipeline (e.g. “Build”, “Test” and “Deploy” stages), which is used by many plugins to visualize or present Jenkins Pipeline status/progress.

    Stage是将Pipeline细分成几个任务流程(比如Build,Test,Deploy)

  3. Step

    A single task. Fundamentally, a step tells Jenkins what to do at a particular point in time (or “step” in the process).

    Step是运行任务的最小单位,是告诉Jenkins在什么时间点执行什么的任务步骤,比如执行指定shell命令或者python脚本

为什么要Pipeline

为什么需要Pipeline?

  1. Code – 可管理可编辑可视化的自动化流程脚本
  2. Durable – 可持续化开发
  3. Pausable – 可以自动也可以停止等待输入
  4. Versatile – 支持复杂的持续交付需求
  5. Extensible – “The Pipeline plugin supports custom extensions to its DSL [5: Domain-Specific
    Language] and multiple options for integration with other plugins.”(支持DSL语言的自定义扩展)

如何使用Pipeline

接下来让我们看看如何创建一个Pipeline。

首先我们必须安装Pipeline插件(这一步在安装推荐插件的时候完成了,没安装的可以去Manage Plugins的插件管理界面自行安装)

关于创建Pipeline有两种方式:

  1. Through the classic UI(Jenkins提供的UI操作)
  2. In SCM(可以直接手动编写Jenkinsfile文件)

前者生成的Jenkinsfile文件在Jenkins的目录,后者的好处是可以打包流水线流程可以直接进入Git版本管理,就算电脑出问题了Jenkins的流水线也不会丢。

这里我们先了解几个Pipeline里编写的几个大的概念:

  1. Node(这个就是我们创建Agent对应的Node概念,在Jenkinsfile里表示指定流程可动态申请一个Executor执行,并在node流程块执行完后释放)
  2. environment(表示当前Pipeline流程里可全局访问的环境变量)
  3. parameters(表示Pipeline流程里可以通过页面配置显示的参数数据)
  4. Stage(定义一个流程块,包含一系列相关任务,比如Build,Test,Deploy等流程块
  5. Step(单个任务步骤,包含具体的任务执行)
  6. post (支持在特定step后指定运行状态下执行特定操作,比如将我们打包产物归档)

详细Pipeline语法关键词介绍参考

Pipeline工具

Snippet Generator
PipelineSnippetGenerator
这是一个很有用帮助快速编写Pipeline的工具,他可以帮助我们把我们熟悉的自动化脚本转换成Pipeline格式的语句,也可以快速生成一些特定功能的脚本(比如邮件功能等)。(Jenkins提供)

脚本命令行(Jenkins->Manage Jenkins->脚本命令行)

可以进行Jenkins代码编写和快速测试!

比如访问所有node并打印node名字:

1
2
3
Jenkins.get().nodes.each { node ->
println("节点名称:${node.nodeName}")
}

JenkinsConsoleCommandRun

Pipiline全局环境变量

Unity打包CI/CD+Pipeline实战

系统配置

有些配置(比如磁盘监控警告限制配置,管理员通知邮箱)不归属单个Agent而是全局生效的

ConfigGlobalSystemManageEmailAdress

全局工具配置

有些东西是属于所有Agent都要统一使用的工具(比如Git,JDK,Gradle等),这些东西应该配置在全局而非但Agent里。

为了明确给Jenkins指定Git使用还是配置上路径避免用默认的。

比如配置Git:

Manage Plugins->全局工具配置->Git installations

ConfigureGlobalGitInstallations

如果不确定本地安装Git位置,可以打开cmd输入where git查看

如果不配置Git将会使用系统默认能找到的Git

如果发现配置了全局Git,但打包日志里依然有”Selected Git installation does not exist. Using Default. The recommended git tool is:None”日志,但Git.exe工具拉去地址后续又显示正确可以先不管,具体原因不明但功能看起来正常。

配置全局Git后发现打包Git拉取报错如下:

GlobalGitConfigurationBuildFailed

这是因为我们给Jenkins创建的jenkins-build用于没有配置过Git相关的.ssh配置,所以默认访问了SSH 22端口。

那为什么用默认的Git没报错?

AI给出的回答是我们创建的Agent是在TonyTang用户名下通过bat运行的,所以能读取到TonyTang\.ssh\config的配置,所以读取了443端口。(不确定是否是这个原因)

TonyTang\.ssh\config内容如下:

1
2
3
Host github.com
Hostname ssh.github.com
Port 443

总之我们给Pipeline配置Git地址时,准确写明是使用443端口即可:

原本Git地址:

1
git@github.com:TonyTang1990/UnityNativeFramework.git

修改到SSH 443端口后Git地址:

1
ssh://git@ssh.github.com:443/TonyTang1990/UnityNativeFramework.git

主动配置SSH 443的Git地址时,全局Security的git的Host Keys也要配置成对应的包含ssh和443端口的地址:

GitHostKeyWithSSHAnd443

Note:

  1. Unity打包自带下载了JDK,NDK,Gradle,所以一般Agent要使用对应的JDK,NDK而非全局配置的。

Security

这里重点讲一下前面提到的关于Git Host Key Verification Stratefy(Git Host Key验证策略)配置,这里为了避免不同Windows用户带来的SSH访问验证问题,这里选择Manually provided keys策略:

ConfigHostKeyVerificationStrategy

这里我们将我们访问Git的**Host Key(注意不是SSH的公钥)**配置上即可。

具体Git的Host Key值是哪一个在这里查看:

GitHubs SSH key fingerprints

选择符合加密方式的那个key fingerprints即可,这里我用的是ED25519算法,所以Host key为:

1
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

Credentials

像重要的一些比如打包Keystore,Apple证书,Git SSH用户名秘钥等都不要直接配置在Agent里,避免泄露,这些东西都统一配置在全局Credentials里,然后Agent里指向使用。

这里简单了解下Jenkins添加Credentials的方式类型:

  1. Secret Text(比如API token或者GitHub个人访问token)
  2. Username and password(比如配置用户名和密码,格式”用户名:密码”)
  3. Secret file(包含Secret内容的文件)
  4. SSH Username with private key(SSH链接包含用户名和私有Key,适用于Github SSH链接时的授权)
  5. Certificate(某些证书文件,暂时不清楚具体用处)
  6. Docker Host Certificate Authentication(Docker的证书授权)

每一个在Jenkins Credential里配置的Credential都有一个Credential ID,我们通过Credential ID可以在Jenkinsfile里访问

添加SSH Credential

这里我首先添加我的Git证书,考虑到用SSH访问,这里添加SSH username with private key的方式:

Jenkins->Manage Jenkins->Credentials->Add Credentials

AddSSHUsernameWithPrivateKey

给SSH username with private key配置生效范围和访问ID:

这里考虑到为了方便范围就配成Global,ID根据自己需求取一个唯一值后续会在Jenkinsfile里使用访问Credential,关于Github如何生成SSH Key这个自行搜索了解下。

ConfigSSHUsernameWithPrivateKey

SSH Key默认是在C:\\User\\用户名\.ssh\目录下的,要求填的密码是创建私钥时的一个密码,如果忘记密码可以在改目录下输入以下命令”ssh-keygen -y -f 私钥文件”进行尝试。

添加Github SSH的时候注意Username要填写git(AI说的具体不确定是不是必须)

添加完成后就能看到Jenkins->Manage Jenkins->Credentials下方出现我们配置的Credential了:

AfterSSHConfigComplete

在Jenkinsfile里访问Credential:

1
credentials('***')

上面的配置是配置了SSH Credential的相关信息,但并不保证我们新建Windows的jenkins-build用户就能正确使用,因为我之前是在TonyTang这个用户下工作,所以所有的git SSH key相关都是操作保存在C:\\User\\TonyTang\\.ssh目录下的,现在Jenkins以jenkins-build用户在工作,默认全局的Security的Host Key Verification Stratety(Git的验证策略配置)是Known hosts file,也就是使用C:\\User\\USER_NAME\.ssh目录下的known_hosts文件进行验证。所以我们前面Credentials需要将Security的Host Key Verification Stratety修改成SSH Username with private key。

配置Git Host Key Verification Configuration的Manually provideed keys时注意要把http和ssh访问git的Host Keys都添加上。

GitHttpAndSSHHostKeyConfiguration

创建Agent

结合前面提到的官方不建议使用默认的build-in Node,所以我们首先要先创建我们自己的Agent,打开Jenkins主页->Set up an agent:

CreateNewAgentEntrance

接下来分别讲讲创建Agent里的各个参数的含义。

  1. 名字(Agent的唯一Id)

    比如我的项目名称是UnityNativeFramework,所以我创建的Agent名称叫UnityNativeFrameworkBuild

    CreateUnityNativeFrameworkBuildAgent

  2. 描述(Agent的一个文本描述)

  3. Number of executors(Agent拥有多少的Executors,官方建议1个Agent对应1个Executor最佳)

  4. 远程工作目录(Agent所在工作目录,不要放到JENKINS_HOME下)

  5. 标签(用于Pipeline指定Agent Lable时的识别标签,符合相同标签的可以被公用,比如多个工程同一个项目建了多个Agent,指定相同的标签能自动利用空闲Agent触发打包)

  6. 用法(限定节点的使用方式)

  7. 启动方式(限定Agent的启动方式)

  8. 可用性(限定Agent的可用状态)

关于节点属性选项:

  1. Disable deferred wipeout on this node

    来至Workspace Cleanup插件,用于删除整个Workspace时采用延迟擦除的方式优化速度(不推荐勾选,避免一些奇怪的问题)

  2. Disk Space Monitoring Thresholds

    用于设置该Agent的磁盘空间阈值,避免特定硬盘空间情况可以发出警告或直接终止打包(推荐勾选,设置合理阈值)

  3. Enviroment variables

    配置只运行在当前Agent的一些环境变量数据,比如Unity.exe路径,后续Pipeline里编写时可以通过%*%访问环境变量值,这里不要放证书相关数据,证书相关放到配置Security的Credentials里(推荐勾选,按需设置必要的环境变量)

  4. Tool Locations

    用于覆盖 Jenkins全局工具在这个Node上的实际安装位置。(不推荐勾选,,当前Agent和全局Controller配置工具没有特殊情况都使用统一的,比如Git使用统一配置)

最终设置结果:

FinalAgentSetting1

FinalAgentSetting2.PNG

Note:

  1. Agent的远程工作目录不要放到JENKINS_HOME
  2. 一个Agent推荐1个Executor,这是官方建议最稳妥的方式,避免一些I/O,CPU或内存的负载问题
  3. 新建一个统一放Jenkins Agent的目录,记着把该目录的修改权限添加给jenkins-build用户
  4. 标签里的名字默认不能与Agent名同名,因为默认Jenkinsfile里就能用Agent名字指定特定Agent

启动Agent

创建完成后会发现我们新建的Unity NativeFrameworkBuild还处于离线状态:

AfterCreateAgentButDisplayOffline

这是因为我们只是保存了Agent配置,Agent进程还没启动。

点击UnityNativeFrameworkBuild的Agent名字,点击状态,我们会看到启动Agent的办法:

RunAgentWithCommandLine

多行命令复制粘贴到PowerShell会有问题,需要自己一行一行的复制执行:

LaunchAgentFromPowerShell

可以看到我们成功启动了我们新建的UnityNativeFrameworkBuild的Agent:

LaunchAgentSuccess

我们新建的Agent的启动方式之前设置的是Launch agent by connecting it to the controller,为了避免每次重启电脑我们自己创建的Agent都要重新执行命令启动,我们需要利用WinSW将长期Unity构建的Jenkins Agent注册为Windows服务,考虑到注册服务也挺麻烦的,这里暂时将启动命令做成start-UnityNavitveFrameworkBuild-agent.bat运行:

1
2
3
4
@echo off
curl.exe -sO http://localhost:8080/jnlpJars/agent.jar
java -jar agent.jar -url http://localhost:8080/ -secret 00577401b16f73577726ed1cc37efdd02c39a31f1a9f5dec1c94dfcb426351a2 -name UnityNavitveFrameworkBuild -webSocket -workDir "G:\Software\JenkinsAgent\UnityNavitveFrameworkBuild"
pause

Note:

  1. 如果不想每个Agent都独立注册Windows服务,可以选择Luanch agent via SSH的方式,配置好SSH Server和SSH Key让所有Agent一次性自动全启动
  2. bat启动这个方式目前的bat代码是窗口关闭Agent就断开连接离线

搭建打包流程

搭建打包流程前,我们需要深入理解一个概念Pipeline,详情参考前面的介绍。

Jenkins搭建打包流程只要通过定义Pipeline,Pipeline有两种方式定义:

  1. 通过网页可视化界面

    这种方式生成的Jenkinsfile默认在JENKINS_HOME文件夹下,不方便可视化+版本管理

  2. 手写Jenkinsfile文件

    这种方式好处是可以提交版本管理,同一个项目多个Agent可以快速重用同一个Agent,不同考虑Jenkinsfile流程同步问题

创建Pipeline

In SCM Jenkinsfile官方文档

创建Jenkinsfile流程如何:

  1. 确保Pipeline插件安装好
    InstallPipelinePlugins

  2. New Item – 新建Pipeline(选择Pipeline类型)
    CreateNewItemEntrance

    SelectPipelineItemType

  3. Configure Pipeline

    这里简单讲几个重要的基础配置含义:

    1. Discard old builds(丢弃旧的构建,用于自动丢弃时长过久的构建,确保硬盘空间够)

      为了避免构建的包被意外清除,不推荐勾选

    2. Do not allow concurrent builds(多个构建发起时的响应策略,用于确保不会同时并发执行多个构建或者后一个覆盖前一个构建等设置)

      一般来说单个Unity工程只允许启动一个Batch mode打包,推荐勾选

    3. Do not allow the pipeline to resume if the controller restarts(是否不允许Pipeline在controller重启后继续)

      一般来说打包流水线必须要完整执行才不容易出问题,推荐勾选

    4. GitHub project(用于关联一个Github项目URL,为部分插件提供上下文)

      一般来说项目打包都是走自己的Git操作流程,不推荐勾选

    5. This project is parameterized(这个项目是否允许参数配置化打包)

      参数配置化打包也就是Jenkins里常见的页面可视化参数配置,然后打包时传入Unity进行对应打包参数控制,推荐勾上

    这里简单讲几个重要触发时机配置介绍:

    1. Build after other projects are build(是否在其他工程打包完成后自动触发打包)

      适用于多个打包流程顺序合作构建的情况,目前Untiy打包暂时不需要,不推荐勾选

    2. Build periodically(固定间隔时间或者固定时间自动构建)

      主要用于每隔一段时间自动触发构建进行连续打包测试跟进

      Unity开发过程中提交情况比较复杂,每隔一段时间打包很可能出现提交不完全导致打包失败等情况,建议还是自己主动找合适时机触发打包,不推荐勾选

    关于Pipeline的详细配置介绍:

    1. 定义选择Pipeline script form SCM而非Pipeline script(因为我们打算走本地jenkinsfile的方式而非网页可视化配置界面)
    2. SCM类型选择Git,目前我使用的是Git,Jenkins显示支持的选项也只有Git,其他方式估计要安装对应插件
    3. Repository URL(jenkinsfile文件所在git地址,一般来说jenkinsfile文件都是放到Git项目根目录)
    4. Credentials(Git地址访问权限证书,这个也就是我们需要配置Git证书之后才能选择的了,证书添加参考前面的Credentials)

    最终Pipeline创建配置如下:

    PipelineConfiguration1

    PipelineConfiguration2

    PipelineConfiguration3

    PipelineConfiguration4

如果想Jenkins Pipeline启用Git LFS,除了本地安装Git LFS还要在Pipeline仓库地址下方添加Additional Behaviours(Git LFS pull after checkout)

EnablePipelineGitLfs

Pipeline打包
No Jenkinsfile打包报错

我们直接点击Build Now会发现没有打包参数界面直接就开始打包并报错:

NoJenkinsfileBuildError

这是因为我们选择了SCM Jenkinsfile的方式,但我们还没有自己在仓库根目录创建Jenkinsfile文件上传导致的,所以我们需要在Git仓库下方自己创建Jenkinsfile并编写内容上传。

Jenkinsfile编写

Jenkinsfile初版内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
pipeline {
agent {
// 指定用UnityNavitveFrameworkBuild的Agent的标签打包
// 方便后续创建多个符合的Agent做Agent池
label 'windows && unity && android'
}

parameters {
// bool值的是否生成开发版本参数
booleanParam(
name: 'DEVELOPMENT_BUILD',
defaultValue: true,
description: '是否生成Development Build'
)

// 版本号
string(
name: 'VERSION_CODE',
defaultValue: '1',
description: '版本号(格式:*.**)'
)

// 资源版本号
string(
name: 'RESOURCE_CODE',
defaultValue: '1',
description: '资源版本号(格式:*)'
)
}

options {
// 让Pipeline打包输出Log带时间戳信息
timestamps()

// 禁止同时并发多个Build
disableConcurrentBuilds()

// 配置Pipeline打包超时时长
timeout(
time: 120,
unit: 'MINUTES'
)
}

environment {
// 环境变量配置
UNITY_PROJECT_DIR = 'UnityNativeFramework'
}
stages {
// 验证Pipeline相关环境变量数据流程
stage('Verify Pipeline') {
steps {
// 打印打包相关变量信息
bat '''
echo NODE_NAME=%NODE_NAME%
echo NODE_LABELS=%NODE_LABELS%
echo WORKSPACE=%WORKSPACE%
echo UNITY_PROJECT_DIR=%UNITY_PROJECT_DIR%
echo DEVELOPMENT_BUILD=%DEVELOPMENT_BUILD%
echo VERSION_CODE=%VERSION_CODE%
echo RESOURCE_CODE=%RESOURCE_CODE%
'''
}
}

// 打包流程
stage('Build') {
steps {
dir("${env.UNITY_PROJECT_DIR}") {
bat '''
@echo off
rem 将Windows CMD代码页切换为UTF-8
chcp 65001 >nul

rem 强制Python标准输入输出使用UTF-8
set PYTHONIOENCODING=utf-8

echo '进入项目工程目录!'
echo Current directory: %CD%

python -u "BuildScripts\\BatchBuildAndroid.py" ^
-isDevelopment %DEVELOPMENT_BUILD% ^
-versionCode %VERSION_CODE% ^
-resourceVersionCode %RESOURCE_CODE%
echo '打包结束!'
'''
}
}
}
}
}

Jenkinsfile负责编写打包流程,将所有打包流程串联起来。

Jenkins将可视化配置的打包参数一个一个传递给BatchBuildAndroid.py打包脚本打包。

Note:

  1. 详细的Pipeline参数语法参考
  2. Pipiline全局环境变量查看(需要启动Jenkins服务)
打包脚本编写

BatchBuildAndroid.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#!/usr/bin/env python3
"""将 Jenkins 参数逐个转交给 Unity Batch Mode。"""

import argparse
import subprocess
import sys
from pathlib import Path


# 构建机固定配置:更换 Unity 版本或静态打包入口时只修改这里。
UNITY_EDITOR = Path(
r"G:\Software\UnityAllVersions\2022.3.62f3\2022.3.62f3\Editor\Unity.exe"
)
UNITY_EXECUTE_METHOD = "BuildTool.DoBuildFromCommands"

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-isDevelopment", required=True)
parser.add_argument("-versionCode", required=True)
parser.add_argument("-resourceVersionCode", required=True)
return parser.parse_args()

def run() -> int:
args = parse_args()
project_path = Path.cwd().resolve()
unity_editor = UNITY_EDITOR.resolve()
print(f'project_path:{project_path}')
print(f'unity_editor:{unity_editor}')
if not unity_editor.is_file():
print(f"Unity Editor executable was not found: {unity_editor}", file=sys.stderr)
return 1

required_directories = ("Assets", "Packages", "ProjectSettings")
missing = [name for name in required_directories if not (project_path / name).is_dir()]
if missing:
print(
f"Not a valid Unity project: {project_path}. "
f"Missing directories: {', '.join(missing)}",
file=sys.stderr,
)
return 1

command = [
str(unity_editor),
"-batchmode",
"-nographics",
"-quit",
"-logFile",
"-",
"-projectPath",
str(project_path),
"-executeMethod",
UNITY_EXECUTE_METHOD,
"-buildTarget",
"Android",
"-isDevelopment",
args.isDevelopment,
"-versionCode",
args.versionCode,
"-resourceVersionCode",
args.resourceVersionCode,
]

print(f"Unity Command: {subprocess.list2cmdline(command)}", flush=True)

# 将Unity打包日志获取输出到控制台(以便Jenkins可以获取到Unity Batch Mode打包的日志)
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)

if process.stdout is not None:
for line in process.stdout:
print(line, end="", flush=True)

return_code = process.wait()

if return_code != 0:
print(
f"Unity build failed with exit code {return_code}.",
file=sys.stderr,
flush=True,
)

return return_code


if __name__ == "__main__":
raise SystemExit(run())

BatchBuildAndroid.py脚本负责接收Jenkins打包传过来的参数并解析,然后利用Unity Batch Build模式将Unity启动起来触发打包并将打包参数传递进打包代码。

Android KeyStore创建

在编写CSharp打包脚本前,我们打包Android还需要准备KeyStore去签名。

Android KeyStore创建,除了传统Keytool工具我们也可以利用Unity Keystore Manager创建属于我们自己的Android KeyStore。

Edit->Project Settings->Player->Android Platform->Publishing Settings->Keystore Manager->Keystore->Create New->Anywhere

AndroidKeystoreCreate

按需输入Keystore基础信息后点击Add Key:

SaveKeystoreInformation

KeystoreSaveLocation

记住UnityNativeFramworkAndroidKeyStore这个名字,这个是我们配置的credentialsId后续在Jenkinsfile里访问会用到。

Note:

  1. 如果有不同渠道就创建多个KeyStore。
多渠道打包

Android多渠道打包:

常规来说我们游戏发行特别是国内Android会有很多不同的渠道要发行,不同渠道除了常规的包名,Keystore,图标等不一样,甚至有些要求游戏功能也有细分。

所以当前设想的是支持Android多渠道打包采用所有相关**打包静态资源(比如图标)放到一个独立的Editor/BuildChannles/渠道名/StaticRes(比如图标)**目录下。

Android渠道Icon存储位置:

AndroidChannelIconsSaveLocation

不同渠道打包时通过AssetDatabase去读取并设置,至于不同渠道的功能差异可以通过根据渠道动态得出打包宏去控制。

首先我们抽象一个能包含Android原生打包所有相关资源信息的类,然后将所有数据都配置进去。

AllPlatformChannelConfig.cs(包含所有平台的渠道信息配置(比如包名,公司名,应用程序名,Keystore相关数据等))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[CreateAssetMenu(fileName = "AllPlatformChannelConfig", menuName = "Build/Config/AllPlatformChannelConfig")]
public sealed class AllPlatformChannelConfig : ScriptableObject
{
/// <summary>
/// 加载所有平台的渠道配置
/// </summary>
/// <returns></returns>
public static AllPlatformChannelConfig LoadAllPlatformChannelConfig()
{
var allPlatformChannelConfig = AssetDatabase.LoadAssetAtPath<AllPlatformChannelConfig>(PlatformChannelPath.AllPlatformChannelConfigAssetPath);
if(allPlatformChannelConfig == null)
{
Debug.LogError($"未找到AllPlatformChannelConfig,路径为:{PlatformChannelPath.AllPlatformChannelConfigAssetPath},请自行创建一个并配置相关数据用于打包!");
}
return allPlatformChannelConfig;
}

/// <summary>
/// Android平台渠道配置
/// </summary>
[Header("Android平台渠道配置")]
public AndroidPlatformChannelConfig AndroidPlatformChannelConfig = new AndroidPlatformChannelConfig();

/// <summary>
/// IOS平台渠道配置
/// </summary>
[Header("IOS平台渠道配置")]
public IOSPlatformChannelConfig IOSPlatformChannelConfig = new IOSPlatformChannelConfig();

/// <summary>
/// Windows平台渠道配置
/// </summary>
[Header("Windows平台渠道配置")]
public WindowsPlatformChannelConfig WindowsPlatformChannelConfig = new WindowsPlatformChannelConfig();

******
}

PlatformChannelConfig.cs(平台渠道配置抽象类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[Serializable]
public abstract class PlatformChannelConfig<T> where T : ChannelConfig
{
/// <summary>
/// 打包平台
/// </summary>
public abstract BuildPlatform BuildPlatform
{
get;
}

/// <summary>
/// 平台所有渠道配置
/// </summary>
[Header("平台所有渠道配置")]
public List<T> ChannelCofigs = new List<T>();

/// <summary>
/// 获取指定渠道的配置
/// </summary>
/// <param name="channel"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
/// <exception cref="InvalidOperationException"></exception>
public T GetChannelConfig(Channel channel)
{
if (ChannelCofigs == null)
{
Debug.LogError($"没有配置任何渠道的配置,无法获取{BuildPlatform}平台指定渠道的配置,channel={channel}");
return null;
}

foreach (var config in ChannelCofigs)
{
if (config == null)
{
continue;
}

if (config.Channel == channel)
{
return config;
}
}

Debug.LogError($"没有找到对应的渠道配置,channel={channel}");
return null;
}
}

AndroidPlatformChannelConfig.cs(Android平台渠道抽象类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// AndroidPlatformChannelConfig.cs
/// Android平台渠道配置
/// </summary>
[Serializable]
public class AndroidPlatformChannelConfig : PlatformChannelConfig<AndroidChannelConfig>
{
/// <summary>
/// Android平台
/// </summary>
public override BuildPlatform BuildPlatform
{
get { return BuildPlatform.Android; }
}
}

ChannelConfig.cs(渠道公共配置抽象类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

/// <summary>
/// ChannelConfig.cs
/// 单个渠道配置
/// </summary>
[Serializable]
public abstract class ChannelConfig
{
/// <summary>
/// 渠道
/// </summary>
[Header("渠道")]
public Channel Channel;

/// <summary>
/// 应用程序标识符
/// </summary>
[Header("应用程序标识符")]
public string ApplicationIdentifier;

/// <summary>
/// 公司名称
/// </summary>
[Header("公司名称")]
public string CompanyName;

/// <summary>
/// 应用程序名称
/// </summary>
[Header("应用程序名称")]
public string ProductName;

/// <summary>
/// 内容是否有效
/// </summary>
/// <returns></returns>
public virtual bool IsContentValide()
{
return true;
}
}

AndroidChannelConfig.cs(Android渠道配置类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
[Serializable]
public class AndroidChannelConfig : ChannelConfig
{
/// <summary>
/// Keystore文件相对路径
/// </summary>
[Header("Keystore文件相对路径")]
public string KeystoreFileRelativePath;

/// <summary>
/// Keystore文件密码
/// </summary>
[Header("Keystore文件密码")]
public string KeystorePassword;

/// <summary>
/// Keystore别名
/// </summary>
[Header("Keystore别名")]
public string KeyAlias;

/// <summary>
/// Keystore别名密码
/// </summary>
[Header("Keystore别名密码")]
public string KeyAliasPassword;

/// <summary>
/// Android图标集合
/// </summary>
[Header("Android图标集合")]
public AndroidIconSet iconSet = new AndroidIconSet();

/// <summary>
/// 内容是否有效
/// </summary>
/// <returns></returns>
public override bool IsContentValide()
{
var baseResult = base.IsContentValide();
if(!baseResult)
{
return false;
}
if(string.IsNullOrEmpty(KeystoreFileRelativePath))
{
Debug.LogError($"渠道:{Channel}配置参数KeystoreFileRelativePath为空,打包失败!");
return false;
}
else if(string.IsNullOrEmpty(KeystorePassword))
{
Debug.LogError($"渠道:{Channel}配置参数KeystorePassword为空,打包失败!");
return false;
}
else if(string.IsNullOrEmpty(KeyAlias))
{
Debug.LogError($"渠道:{Channel}配置参数KeyAlias为空,打包失败!");
return false;
}
else if(string.IsNullOrEmpty(KeyAliasPassword))
{
Debug.LogError($"渠道:{Channel}配置参数KeyAliasPassword为空,打包失败!");
return false;
}
else if(IconSet == null || !IconSet.IsContentValide())
{
Debug.LogError($"渠道:{Channel}配置参数IconSet无效,打包失败!");
return false;
}
return true;
}
}

AndroidIconSet.cs(Android平台图标集合类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
[Serializable]
public class AndroidIconSet
{
/// <summary>
/// 图标集合
/// Android 7.0/API 24 及更早设备,以及不支持 Adaptive Icon 的启动器
/// 单层普通方形图标
/// </summary>
[Header("图标集合")]
public Texture2D[] LegacyIcons;

/// <summary>
/// 自适应图标背景图标集合
/// Android 8.0/API 26
/// 起前景层 + 背景层,由系统裁成圆形、圆角矩形等形状
/// </summary>
[Header("自适应图标背景图标集合")]
public Texture2D[] AdaptiveBackgroundIcons;

/// <summary>
/// 自适应图标前景图标集合
/// Android 8.0/API 26
/// 起前景层 + 背景层,由系统裁成圆形、圆角矩形等形状
/// </summary>
[Header("自适应图标前景图标集合")]
public Texture2D[] AdaptiveForegroundIcons;

/// <summary>
/// 偏好圆形图标集合
/// Android 7.1/API 25 起,部分启动器偏好圆形图标
/// 单层圆形适配图标
/// </summary>
[Header("偏好圆形图标集合")]
public Texture2D[] RoundIcons;

/// <summary>
/// 内容是否有效
/// </summary>
/// <returns></returns>
public bool IsContentValide()
{
// Android要求Legacy Icons 6个,AdaptiveForeground Icons 6个,AdaptiveBackground Icons 6个,Round Icons 6个
if(!IsValideNumberTextures(LegacyIcons, 6))
{
Debug.LogError("Legacy Icons 数量不正确或有空纹理Icon!");
return false;
}
else if(!IsValideNumberTextures(AdaptiveForegroundIcons, 6))
{
Debug.LogError("Adaptive Foreground Icons 数量不正确或有空纹理Icon!");
return false;
}
else if(!IsValideNumberTextures(AdaptiveBackgroundIcons, 6))
{
Debug.LogError("Adaptive Background Icons 数量不正确或有空纹理Icon!");
return false;
}
else if(!IsValideNumberTextures(RoundIcons, 6))
{
Debug.LogError("Round Icons 数量不正确或有空纹理Icon!");
return false;
}
return true;
}

/// <summary>
/// 指定纹理数组是否符合指定数量要求且无空纹理
/// </summary>
/// <param name="textures"></param>
/// <param name="requiredNumber"></param>
/// <returns></returns>
private bool IsValideNumberTextures(Texture2D[] textures, int requiredNumber)
{
if(textures == null || textures.Length != requiredNumber)
{
return false;
}
for(int i = 0; i < textures.Length; i++)
{
if(textures[i] == null)
{
Debug.LogError($"纹理数组中第 {i} 个纹理为空, 打包失败!");
return false;
}
}
return true;
}
}

我们在Assets/Editor/Build/Config创建一个AllPlatformChannelConfig.asset用于配置各个平台的渠道相关数据。

这里配置一个Android平台None渠道的相关打包数据为例,其他渠道添加渠道配置配置对应数据即可:

AllPlatformChannelConfigInspector

AndroidChannelConfigAndResLocation

看到我已经完成了Android平台None渠道的所有打包数据配置,接下来就是打包流程里去读取配置并设置对应打包参数了,继续请看CSharp打包编写部分。

渠道挂钩的AAR这类渠道特有的动态资源,我们先统一放到项目目录下的BuildChannels/渠道名/DynamicRes(比如渠道AAR)(注意这里是Assets外层的目录)。然后每次打包Android渠道包的时候我们先将Plugins/Android/Channel目录清空,然后根据渠道类型复制对应的DynamicRes到该目录下,然后触发Asset保存刷新后接着触发APK打包。

上述动态资源复制方案待实战测试。

目前为了方便,我只支持不同平台不同渠道静态资源配置(比如包名,图标,KeyStore位置,密码,Aliases和Aliases密码等信息),然后打包时根据渠道去读取对应渠道数据进行动态打包参数设置。

Note:

  1. 配置Android渠道图标时,注意按照对应图标数量要求和图标大小的顺序设置
  2. Android渠道图标资源注意设置类型Default+Alpha is Transparency+None Compression

IOS多渠道打包:

IOS一般来说如果不考虑越狱版本支持,官方就一个AppStore,IOS多渠道打包暂时不列入考虑。

AppStore打包只需要在Unity导出XCode工程后通过固定的签名证书打包生成IPA即可。

Window多渠道打包:

Windows不同渠道是指不同发行平台(e.g. Steam,Microsoft Store等),这一步发生在打包生成可执行EXE程序以后,我们可以做成脚本工具,根据不同渠道调用不同脚本工具打包即可。

CSharp打包编写

打包设计到多平台打包,但流程比较固有化,所以我们需要抽象对象和抽象固有流程去支持不同平台的打包DIY代码细节编码。

我这里首先抽象了PlatformBuildPipeline类表示平台打包抽象,里面包含基础数据传入和打包流程定义抽象。

PlatformBuildPipeline.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
public abstract class PlatformBuildPipeline
{
/// <summary>
/// 打包输出目录路径
/// </summary>
protected string mOutputFolderPath;

/// <summary>
/// 打包参数
/// </summary>
protected BuildParameters mBuildParameters;

/// <summary>
/// 对应的打包分组
/// </summary>
protected BuildTargetGroup buildTargetGroup;

private PlatformBuildPipeline()
{

}

/// <summary>
/// BuildPipeline构造函数
/// </summary>
/// <param name="outputFolderPath"></param>
/// <param name="buildParameters"></param>
public PlatformBuildPipeline(string outputFolderPath, BuildParameters buildParameters)
{
******
}

/// <summary>
/// 初始化平台渠道配置
/// </summary>
protected abstract void InitPlatformChannelConfig();

/// <summary>
/// 执行打包
/// </summary>
/// <returns></returns>
public BuildResult DoBuild()
{
******
}

/// <summary>
/// 执行打包前通用处理
/// </summary>
protected bool DoCommonBuildPreProcess()
{
******
}

/// <summary>
/// 执行资源打包
/// </summary>
protected virtual bool DoBuildResource()
{
******
}

/// <summary>
/// 执行打包目标通用前处理
/// </summary>
/// <returns></returns>
protected bool DoBuildTargetCommonPreProcess()
{
******
}

/// <summary>
/// 执行打包App前预处理
/// </summary>
protected abstract bool DoBuildTargetPreProcess();

/// <summary>
/// 执行真正App打包方法
/// </summary>
/// <returns></returns>
protected abstract BuildResult DoRealAppBuild();

/// <summary>
/// 执行打包App后处理
/// </summary>
protected abstract bool DoBuildTargetPostProcess();

/// <summary>
/// 检查打包输出路径是否有效
/// </summary>
/// <returns></returns>
protected bool CheckOutputPath()
{
******
}

/// <summary>
/// 检查打包参数是否有效
/// </summary>
/// <returns></returns>
protected virtual bool CheckBuildParameters()
{
******
}

/// <summary>
/// 检查内部版本配置文件是否存在
/// </summary>
/// <returns></returns>
protected bool CheckInnerVersionConfigExist()
{
******
}

/// <summary>
/// 检查平台渠道配置是否存在
/// </summary>
/// <returns></returns>
protected abstract bool CheckPlatformChannelConfigExist();

/// <summary>
/// 检查平台渠道配置参数是否正确
/// </summary>
/// <returns></returns>
protected abstract bool CheckPlatformChannelConfigParams();

/// <summary>
/// 保存新的内部版本配置文件
/// </summary>
/// <returns></returns>
protected bool SaveNewInnerConfigVersion()
{
*******
}
}

根据传入的打包平台(BuildTarget)构建不同*BuildPipeline子类,每个子类初始化自己的平台渠道配置并检查,然后子类在特定的流程接口里去细化打包代码,实现不同平台的DIY打包流程编码。

以下以AndroidBuildPipeline.cs为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
public class AndroidBuildPipeline : PlatformBuildPipeline
{
/// <summary>
/// Android平台的对应渠道配置
/// </summary>
protected AndroidChannelConfig mTargetChannelConfig;

/// <summary>
/// AndroidBuildPipeline构造函数
/// </summary>
/// <param name="outputPath"></param>
/// <param name="buildParameters"></param>
public AndroidBuildPipeline(string outputPath, BuildParameters buildParameters) : base(outputPath, buildParameters)
{
}

/// <summary>
/// 初始化Android平台的渠道配置
/// </summary>
protected override void InitPlatformChannelConfig()
{
var allPlatformChannelConfig = AllPlatformChannelConfig.LoadAllPlatformChannelConfig();
if(allPlatformChannelConfig != null)
{
mTargetChannelConfig = allPlatformChannelConfig.AndroidPlatformChannelConfig.GetChannelConfig(mBuildParameters.Channel);
}
}

/// <summary>
/// 检查Android平台的渠道配置是否存在
/// </summary>
/// <returns></returns>
protected override bool CheckPlatformChannelConfigExist()
{
if(mTargetChannelConfig == null)
{
Debug.LogError($"Android平台的渠道:{mBuildParameters.Channel}配置不存在,打包失败!");
return false;
}
return true;
}

/// <summary>
/// 检查Android平台的渠道配置参数是否正确
/// </summary>
/// <returns></returns>
protected override bool CheckPlatformChannelConfigParams()
{
return mTargetChannelConfig.IsContentValide();
}

/// <summary>
/// 执行打包App前处理
/// </summary>
/// <returns></returns>
/// <exception cref="System.NotImplementedException"></exception>
protected override bool DoBuildTargetPreProcess()
{
MakeSureBuildOutputFolderExists();
var buildOutputFolderPath = GetBuildOutputFolderPath();
Debug.Log($"buildOutputFolderPath:{buildOutputFolderPath}");
if (!Directory.Exists(buildOutputFolderPath))
{
Directory.CreateDirectory(buildOutputFolderPath);
}

// 生成 AAB;false 则生成 APK
EditorUserBuildSettings.buildAppBundle = true;
Debug.Log($"打包设置生成AAB为:{EditorUserBuildSettings.buildAppBundle}");
// Google Play 发布通常至少需要 ARM64,ARMv7主要是为了兼容老机器
PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64 | AndroidArchitecture.ARMv7;
Debug.Log($"打包设置目标架构为:{PlayerSettings.Android.targetArchitectures}");
PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel22;
Debug.Log($"打包设置最小SDK版本为:{PlayerSettings.Android.minSdkVersion}");
// 使用 Unity 安装的最高 Target API
PlayerSettings.Android.targetSdkVersion = AndroidSdkVersions.AndroidApiLevelAuto;
Debug.Log($"打包设置目标SDK版本为:{PlayerSettings.Android.targetSdkVersion}");
// Release 包开启代码压缩/混淆
PlayerSettings.Android.minifyRelease = true;
Debug.Log($"打包设置Release包代码压缩为:{PlayerSettings.Android.minifyRelease}");
PlayerSettings.Android.minifyDebug = false;
Debug.Log($"打包设置Debug包代码压缩为:{PlayerSettings.Android.minifyDebug}");
// 正式包使用自定义签名文件
PlayerSettings.Android.useCustomKeystore = true;
Debug.Log($"打包设置使用自定义签名文件为:{PlayerSettings.Android.useCustomKeystore}");

// 设置签名文件路径
PlayerSettings.Android.keystoreName = mTargetChannelConfig.KeystoreFileRelativePath;
PlayerSettings.Android.keystorePass = mTargetChannelConfig.KeystorePassword;
PlayerSettings.Android.keyaliasName = mTargetChannelConfig.KeyAlias;
PlayerSettings.Android.keyaliasPass = mTargetChannelConfig.KeyAliasPassword;
Debug.Log($"打包设置签名文件路径为:{PlayerSettings.Android.keystoreName}");
Debug.Log($"打包设置签名文件密码为:{PlayerSettings.Android.keystorePass}");
Debug.Log($"打包设置密钥别名为:{PlayerSettings.Android.keyaliasName}");
Debug.Log($"打包设置密钥别名密码为:{PlayerSettings.Android.keyaliasPass}");

// 设置Android平台打包Icons图标信息
var iconSet = mTargetChannelConfig.IconSet;
var configLegacyIcons = iconSet.LegacyIcons;
PlatformIcon[] legacyPlatformIcons = PlayerSettings.GetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Legacy);
for(int index = 0, length = legacyPlatformIcons.Length; index < length; index++)
{
var legacyPlatformIcon = legacyPlatformIcons[index];
legacyPlatformIcon.SetTexture(configLegacyIcons[index]);
}
PlayerSettings.SetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Legacy, legacyPlatformIcons);

PlatformIcon[] adaptivePlatformIcons = PlayerSettings.GetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Adaptive);
var configAdaptiveBackgroundIcons = iconSet.AdaptiveBackgroundIcons;
var configAdaptiveForegroundIcons = iconSet.AdaptiveForegroundIcons;
for(int index = 0, length = adaptivePlatformIcons.Length; index < length; index++)
{
var adaptivePlatformIcon = adaptivePlatformIcons[index];
var configBackgroundIcon = configAdaptiveBackgroundIcons[index];
var configForegroundIcon = configAdaptiveForegroundIcons[index];
adaptivePlatformIcon.SetTexture(configBackgroundIcon, 0);
adaptivePlatformIcon.SetTexture(configForegroundIcon, 1);
}
PlayerSettings.SetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Adaptive, adaptivePlatformIcons);

PlatformIcon[] roundPlatformIcons = PlayerSettings.GetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Round);
var configRoundIcons = iconSet.RoundIcons;
for(int index = 0, length = roundPlatformIcons.Length; index < length; index++)
{
var roundPlatformIcon = roundPlatformIcons[index];
roundPlatformIcon.SetTexture(configRoundIcons[index]);
}
PlayerSettings.SetPlatformIcons(mBuildTargetGroup, AndroidPlatformIconKind.Round, roundPlatformIcons);
return true;
}

/// <summary>
/// 执行打包App
/// </summary>
/// <returns></returns>
protected override BuildResult DoRealAppBuild()
{
var buildTarget = mBuildParameters.BuildTarget;
var targetVersionCode = mBuildParameters.VersionCode;
var targetResourceVersionCode = mBuildParameters.ResourceVersionCode;
var isDevelopment = mBuildParameters.IsDevelopment;
Debug.Log($"打包修改版本号从:{PlayerSettings.bundleVersion}到{targetVersionCode}");
PlayerSettings.bundleVersion = targetVersionCode.ToString();
var buildNumer = BuildEditorUtilities.CalculateBuildNumber(targetVersionCode, targetResourceVersionCode);
Debug.Log($"打包修改Android构建版本号从:{PlayerSettings.Android.bundleVersionCode}到{buildNumer}");
PlayerSettings.Android.bundleVersionCode = buildNumer;
BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions();
var now = DateTime.Now;
var timeStamp = $"{now.Year}_{now.Month}_{now.Day}_{now.Hour}_{now.Minute}_{now.Second}";
var buildPostFix = BuildEditorUtilities.GetCorrespondingBuildFilePostfix(buildTarget);
var outputFileName = $"{PlayerSettings.productName}_{timeStamp}{buildPostFix}";
// 输出目录结构:Build/版本号/资源版本号/时间戳/包名.apk
var buildOutputPath = Path.Combine(mOutputFolderPath, $"{targetVersionCode}/{targetResourceVersionCode}/");
var locationPathName = Path.Combine(buildOutputPath, outputFileName);
Debug.Log($"打包输出路径:{locationPathName}");
buildPlayerOptions.locationPathName = locationPathName;
buildPlayerOptions.scenes = BuildEditorUtilities.GetBuildSceneArray();
buildPlayerOptions.target = buildTarget;
buildPlayerOptions.options = BuildOptions.StrictMode;
if(isDevelopment)
{
buildPlayerOptions.options |= BuildOptions.Development;
}
Debug.Log($"打包平台:{Enum.GetName(typeof(BuildTarget), buildTarget)}");
Debug.Log($"开发版本:{isDevelopment}");
Debug.Log($"打包输出路径:{buildPlayerOptions.locationPathName}");
buildPlayerOptions.targetGroup = mBuildTargetGroup;
// 确保设置成打包APK而非Gradle工程和AAB包
EditorUserBuildSettings.exportAsGoogleAndroidProject = false;
EditorUserBuildSettings.buildAppBundle = false;
var buildReport = BuildPipeline.BuildPlayer(buildPlayerOptions);
var buildResult = buildReport.summary.result;
var isBuildSuccess = buildResult == UnityEditor.Build.Reporting.BuildResult.Succeeded;
if(!isBuildSuccess)
{
Debug.LogError($"打包失败,打包结果:{buildResult}!");
return BuildResult.Build_Failed;
}
return BuildResult.Success;
}

/// <summary>
/// 执行打包App后处理
/// </summary>
/// <returns></returns>
/// <exception cref="System.NotImplementedException"></exception>
protected override bool DoBuildTargetPostProcess()
{
******
}

/// <summary>
/// 确保构建输出文件夹存在
/// </summary>
private void MakeSureBuildOutputFolderExists()
{
******
}

/// <summary>
/// 获取打包输出文件夹路径
/// </summary>
/// <returns></returns>
private string GetBuildOutputFolderPath()
{
******
}
}

BuildTool.cs(打包工具脚本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
/// <summary>
/// BuildTool.cs
/// 打包工具静态类
/// </summary>
public static class BuildTool
{
/// <summary>
/// 执行Android打包
/// </summary>
[MenuItem("Build/Android/打Inner包(None Channel+No SDK)")]
static void DoBuildInnerAndroid()
{
Debug.Log("BuildTool.DoBuildInnerAndroid()");
var innerVersionConfig = VersionUtilities.ReadInnerVersionConfig();
var versionCode = innerVersionConfig != null ? innerVersionConfig.VersionCode : 1;
var resourceVersionCode = innerVersionConfig != null ? innerVersionConfig.ResourceVersionCode : 1;
var buildParameters = new BuildParameters(BuildTarget.Android, DevelopmentMode.Inner,
Channel.None, true, versionCode, resourceVersionCode,
false, SDKType.All);
DoBuild(buildParameters);
}

******

#region 命令行打包
/// <summary>
/// 执行从命令行打包
/// </summary>
public static void DoBuildFromCommands()
{
Debug.Log("BuildTool.DoBuildFromCommands()");
var commandArgs = Environment.GetCommandLineArgs();
var buildParameters = BuildCommand.ParseCommandArgsToBuildParameters(commandArgs);
try
{
var buildResult = DoBuild(buildParameters);
EditorApplication.Exit((int)buildResult);
}
catch (Exception ex)
{
Debug.LogError($"BuildTool.DoBuildFromCommands() Exception:{ex}");
EditorApplication.Exit((int)BuildResult.Build_Exception);
}
}
#endregion

/// <summary>
/// 根据指定输出路径和打包参数创建打包管线对象
/// </summary>
/// <param name="outputFolderPath"></param>
/// <param name="buildParameters"></param>
/// <returns></returns>
private static PlatformBuildPipeline CreateBuildPipeline(string outputFolderPath, BuildParameters buildParameters)
{
var buildTarget = buildParameters.BuildTarget;
if(buildTarget == BuildTarget.Android)
{
return new AndroidBuildPipeline(outputFolderPath, buildParameters);
}
else if(buildTarget == BuildTarget.iOS)
{
return new IOSBuildPipeline(outputFolderPath, buildParameters);
}
else if(buildTarget == BuildTarget.StandaloneWindows || buildTarget == BuildTarget.StandaloneWindows64)
{
return new WindowsBuildPipeline(outputFolderPath, buildParameters);
}
else
{
Debug.LogError($"不支持的打包平台:{buildTarget},创建打包管线对象失败!");
return null;
}
}

/// <summary>
/// 执行打包(含资源打包)
/// </summary>
/// <param name="buildParameters">打包参数</param>
public static BuildResult DoBuild(BuildParameters buildParameters)
{
Debug.Log("BuildTool.DoBuild()");
if(buildParameters == null)
{
Debug.LogError("不允许传入空参数,打包失败!");
return BuildResult.No_Build_Parameters;
}
Debug.Log($"打包参数:{buildParameters}");
var outputFolderPath = BuildEditorUtilities.GetBuildOutputFolderPath(buildParameters.BuildTarget);
var buildPipeline = CreateBuildPipeline(outputFolderPath, buildParameters);
if(buildPipeline == null)
{
return BuildResult.Not_Supported_Build_Target;
}
var buildResult = buildPipeline.DoBuild();
if(buildResult != BuildResult.Success)
{
Debug.LogError($"[BuildPatch] App打包失败,打包结果:{buildResult},打包终止!");
return buildResult;
}
Debug.Log($"[BuildPatch] 打包成功,打包结束!");
return buildResult;
}
}

BuildCommand.cs(Jenkins打包命令参数静态类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/// <summary>
/// BuildCommand.cs
/// 打包命令静态类
/// </summary>
public static class BuildCommand
{
/// <summary>
/// 解析命令行参数为BuildParameters对象
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
public static BuildParameters ParseCommandArgsToBuildParameters(string[] args)
{
******
}

/// <summary>
/// 解析命令行参数为字典
/// Note:
/// 1. 统一打包命令格式为:Unity执行程序路径 -命令1 值1(可以没值) -命令2 值2(可以没值) ******
/// 2. 没有参数值的Value为null
/// 3. 解析结果不包含Unity执行程序路径
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
public static Dictionary<string, string> ParseCommandArgsToDictionary(string[] args)
{
******
}

/// <summary>
/// 根据参数Map构建BuildParameters对象
/// </summary>
/// <param name="parameterMap"></param>
/// <returns></returns>
private static BuildParameters ConstructBuildParametersByParamsMap(Dictionary<string, string> parameterMap)
{
******
}
}

我保留了Android核心打包流程读取平台渠道配置并设置相关参数代码和执行打包代码,其他不重要的代码省略了,详情请参考文章最后放的Github地址。

从上面代码流程可以看出BuildTool.cs提供了菜单打包和Jenkins命令行打包相关接口方法。触发菜单打包时,主动构建打包参数并传入BuildTool.DoBuild(),BuildTool通过构建对应平台的BuildPipeline执行对应的DoBuild()打包方法流程。

通过不同平台构建不同的BuildPipeline子类,我们实现了流程详细高度可自定义化的打包流程编码。

让我们来看看完成了所有打包参数后的Jenkins配置参数面板:

AllJenkinsParametersConfig

然后触发打包后,我们来看看打包日志和最终的安装效果:

JenkinsBuildAndroidLog

JenkinsBuildAndroidInstallSuccess

JenkinsFinalAndroidRun

可以看出我们Android Jenkins打包无论是打包名设置,Keystore设置还是Android图标设置都成功并打包运行成功了。

这里顺带编写一个纯Editor打包的编辑器窗口BuildWindow.cs,这里就不放代码了,详细去Github看。这个工具是直接让AI写的,核心需求就是让所有BuildParameters里的属性参数都能在EditorWindow的打包窗口上配置并点击打包,打包时做必要的一些版本号和资源版本号合法性校验,以及一些二次确认。

打包窗口打开流程Build->打包窗口:

BuildWindow

Note:

  1. 考虑到Jenkins引入Keystore相关配置传递会有很多东西要配置,所以不同渠道所有相关东西的配置选择我准备全部做到Unity打包脚本里,通过AllPlatformChannelConfig.cs的ScriptableObject进行所有平台所有渠道的静态资源配置(比如)。
Git SSH访问报错

上传Jenkinsfile后再次打包会发现依然打包报错:

BuildFailedAfterConfigureSSHWithPrivateKey

这是因为我们配置Git Host Key的Host Keys只添加了支持http访问的Host Key而没有添加SSH访问的Host Key,按照前面添加SSH Credential的方式添加上后,我们再次打开新建的Agent会看到Jenkins的参数配置界面显示出来了:

JenkinsBuildWithParametersPage

打包CS脚本传漏报错

修改SSH访问的Host Key后我们再次触发打包:

BuildFailedAfterFixGitProblem

发现依然打包失败,但我们打包流程里无论是Jenkins的打印输出还是BatchBuildAndroid.py里的打印输出都成功打印了,从日志可以看到我们触发了Unity Batch Mode打包,但过了一会就打包失败了,这里是因为我忘记上传打包相关脚本了,Git仓库上根本没有BuildTool.DoBuiuldFromCommands的代码文件。

Jenkins没输出Unity日志问题

原因如下:

  1. Unity Batch Mode运行模式需要添加**-logFile -**参数,表示将将日志输出到控制台。

  2. 通过Jenkins->cmd.exe->Python->Untiy这个打包流程后,Unity没能正确得到能写入的stdout

方案:

  1. 我们需要再Python里显示去获取Unity日志并转发打印到Jenkins控制台

通过在打包脚本BatchBuildAndroid.py里添加以下代码,将

1
2
3
4
5
6
7
8
9
10
11
12
13
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)

if process.stdout is not None:
for line in process.stdout:
print(line, end="", flush=True)
Unity中文日志乱码问题

原因如下:

  1. Windows控制台,Python,Jenkins使用的字符编码不一致造成的。

方案:

  1. 在打包流程脚本里统一字符编码格式

Jenkinsfile(统一Jenkins bat的字符编码到UTF-8,并将Python输入输出指定为UTF-8)

1
2
3
4
5
6
7
8
9
10
bat '''
@echo off
rem 将Windows CMD代码页切换为UTF-8
chcp 65001 >nul

rem 强制Python标准输入输出使用UTF-8(确保Python向Jenkins输出使用UTF-8)
set PYTHONIOENCODING=utf-8

******
'''

BatchBuildAndroid.py(确保Python保持UTF-8解码)

1
2
3
4
5
6
7
8
9
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
打包成功

上传打包文件后再次触发Jenkins打包:

RunBatchModeBuildSuccessLog

可以看到我们终于正确触发Unity Batch Mode打包并打包成功:

JenkinsPipelineBuildSuccess

最后在JenkinsAgent对应Agent和Pipeline目录下找到了打包输出的APK文件:

JenkinsBuildAPKLocation

安装运行成功:

JenkinsBuildInstallSuccess

Pipeline打包归档

Pipeline打包归档是指我们打包生成的APK等产物,默认生成在JenkinsAgent对应Agent对应Pipeline项目工程下的特定位置,这种位置会受项目Git拉取,Jenkins清理工作区等操作影响。所以最好的方式就是Jenkins打包的最终产物统一放到一个独立长期保存的位置,这个就是Pipeline打包归档。

设置好打包归档后我们能在Jenkins对应Pipeline页面点击Build Artifacts里找到所有打包产物。

归档后的存储位置在%JENKINS_HOME%\jobs\任务名\builds\构件编号\archive路径下。

SuccessArchivingArtifacts

可以看到打包成功后Jenkins成功将打包产物符合的文件全部归档到了%JENKINS_HOME%目录对应目录下。

Note:

  1. 归档成功后没在Jenkins页面看到相关入口,原因不明
多Agent多Pipeline打包

之前我们只创建了一个UnityNativeFrameworkBuild Agent(只有1个Executor),但我们却创建了2个指定Agent Lable是UnityNativeFrameworkBuild的Pipeline,如果我们同时触发这2个Pipeline打包会发生什么?

答案是排队等待。

PipelineWaitForExector

还记着我们之前给Pipeline设置的禁止并发打包吗?

创建的Agent只有1个Executor且禁止并发打包,这就是为什么2个Pipeline触发打包会排队等待的原因。

解决方案:

创建多个可用的Agent并启起来,2个Pipeline指向的Agent Lable多个Agent都符合,那么就会选空闲的Agent的executor进行打包。

为了快速创建相似的Agent,我们可以通过Jenkins->Manage Jenkins->Nodes->New Node->复制现有节点:

CreateAgentFromAgent

BuildAgent2Create

取名UnityNativeFrameworkBuild2

编写启动UnityNativeFrameworkBuild2的Agent的start-UnityNavitveFrameworkBuild2-agent.bat:

1
2
3
4
5
@echo off
curl.exe -sO http://localhost:8080/jnlpJars/agent.jar
java -jar agent.jar -url http://localhost:8080/ -secret ****** -name UnityNavitveFrameworkBuild2 -webSocket -workDir "G:\Software\JenkinsAgent\UnityNavitveFrameworkBuild"
pause

启动后新Agent就可用了。

这是我们再次同时触发2个Pipeline打包会发现,2个Agent都同时运作起来,2个Pipeline也同时开始了打包流程:

MultiplePipelineRunConcurrently

Note:

  1. 复制Agent时远程目录记着要配套修改,不然就指向同一个远程目录了。
多分支打包方案

常规游戏开发中我们都会创建不止一个分支,比如develop用于开发版本功能,release用于线上发布版本,main作为主分支存在。

那么我们在Jenkins里如何支持多分支的打包操作了?

方案1:

使用Jenkins的Multiplebranch Pipeline默认支持多分支自由选择打包。

实战:

创建Multiplebranch Pipline:

MultipleBranchPipelineCreate

配置Git仓库地址:

MultiplebranchPipelineConfig

可以看到Multiplebranch Pipeline可以配置自动发现分支,也就说我们不需要去配置具体有哪些分支,它可以自行发现供我们打包时选择打包哪个分支。

创建后Multiplebranch Pipeline会自行开始检测多分支并开始任务,这个我们自行停止下。然后我们进入Multiplebranch Pipiline页面会看到有多个分支的Pipeline选择:

MultiplebranchPipelinePage

随便点一个分支名字进去后会发现跟我们创建的普通Pipeline分支没区别,后续就走正常流程打包即可:

MultiplebranchDevelopBrachPage

方案2:

新建多个普通Pipeline,每个用于固定分支的打包。

实战2:

方案2实战没什么好说的,就是在配置Pipeline的分支名字时设置成对应分支名字即可。

Reference

Using a Jenkinsfile

Pipeline

Getting started with Pipeline

Pipeline Syntax

Using credentials

Using Jenkins agents

前言

游戏发行后总是伴随着有bug或者功能需要发布出去,常规的方法是通过强更版本让玩家去商店下载新版本或者游戏内下载新版本安装,如果是资源bug可以通过资源热更的方式进行游戏内热更后再进入游戏即可。但C#在IOS上禁止热更(核心原因后续会聊)。

C#代码热更原理

在深入学习HybridCLR之前,我们先了解下C#代码热更原理。

C#运行原理

在聊C#为什么被IOS禁止热更前,我们要了解C#是如何从代码到真机生效运行的。

C#->编译器->IL->.NET/Mono->机器码(运行时IL转机器码(JIT))

IOS不允许应用在运行时随意生成机器码,也就是上述流程在IL->.NET/Mono->机器码这一步禁止了。

IOS禁止JIT

JIT本质是运行时将IL转成对应机器码,也就等价于运行时生成代码。

为什么IOS禁止运行时生成代码了?

这和 iOS 的安全原则冲突。iOS 通常要求:

  • 可执行代码必须经过签名;
  • App 能执行的原生代码应当在打包、签名时确定;
  • 普通应用不能随意创建“既可写又可执行”的内存;
  • 不能下载一段新的原生机器码后直接执行。

这类限制通常可以从代码签名、W^X(Writable or Executable)和禁止动态生成可执行代码几个角度理解。

JIT 的本质恰恰是“运行时动态生成新机器码”,所以普通 iOS App 无法使用传统的 Mono JIT 模式。

Unity IL2CPP

最初Unity使用的Mono来实现跨平台,后来推出了IL2CPP。

为什么Unity会从Mono转向IL2CPP了?

因为不能依赖JIT,所以Unity选择把IL全部转换成C++代码(IL2CPP这一步)。

使用IL2CPP流程就会变成如下:

C#->编译器->IL->IL2CPP->C++->Xcode/Clang AOT 编译->ARM64/ARMV7原生机器码

但AOT带来的一个问题就是,如果新写的C#代码如果没经过IL2CPP->C++->ARM64->签名这条流程,那么在IOS上就没法直接热更运行。

ILRuntime

讲到这里,那顺便一提ILRuntime这个老牌C#热更框架,为什么ILRuntime能绕过IOS这个限制了?

ILRuntime 的思路不是在手机上把新 IL 编译成机器码,而是:

用安装包中已经 AOT 编译并签名过的解释器,逐条解释热更新 DLL 的 IL 指令。

使用ILRuntime流程就会变成如下:

热更新DLL->读取IL指令->ILRuntime解释器->逐条模拟IL的语义

真正被 CPU 执行的是提前打包、签名过的 ILRuntime 解释器。新下载的 DLL 在这个模型中更像是解释器读取的“指令数据”。

这也就是ILRuntime新的DLL为什么不走IL2CPP也能正常执行的原因。

但ILRuntime代价也很明显:

  • 解释执行通常比原生代码慢;
  • 热更代码和主工程之间需要跨域调用;
  • 泛型、委托、继承、反射等场景需要适配;
  • 经常需要生成绑定代码来降低反射和跨域调用开销;
  • 调试体验与标准 CLR 有差异。

HybridCLR

然后进入本章节的主题,HybridCLR,让我们来看看HybridCLR是如何从原理上实现支持C#热更的。

HybridCLR解决问题的思路更贴近标准 CLR。它扩展了 Unity 的 IL2CPP 运行时,使其具备执行热更新程序集 IL 的能力。

1
2
3
4
5
6
7
AOT 程序集
→ IL2CPP 提前编译为原生机器码
→ 高性能执行

热更新程序集
→ 运行时加载 DLL
→ HybridCLR 解释执行 IL

HybridCLRTheory

也就是说,HybridCLR仍然遵守 iOS 的限制:

  • 不在运行时 JIT 生成新的 ARM64 代码;
  • 新下载的 DLL 主要以 IL 解释方式执行;
  • CPU 实际执行的解释器和运行时本身已经随 App AOT 编译并签名。

HybridCLR为什么还需要“补充元数据”?

IL2CPP在打包时会对代码和元数据进行裁剪、转换。某些泛型方法虽然可以复用已有的 AOT 原生代码,但运行时可能缺少足够的类型元数据。

例如热更新代码新增了一个调用:

1
List<MyNewType>

AOT 阶段未必知道未来会出现这个具体类型组合。相应泛型函数有时可以通过泛型共享机制复用,但运行时仍需要知道:

  • MyNewType 的字段布局;
  • 泛型参数信息;
  • 方法签名;
  • 类型关系;
  • 虚方法和反射信息。

HybridCLR可以加载裁剪前 AOT 程序集的补充元数据,让运行时重新获得这些信息。需要注意:

补充元数据不是把新代码变成原生机器码。

它主要用于恢复类型和方法描述,使热更新 IL 能正确调用已有 AOT 泛型代码、反射能力和运行时设施。没有可复用 AOT 实现的热更新方法,仍然由解释器执行。

ILRuntime、HybridCLR与传统 JIT 的本质区别

方案 新 DLL 如何执行 是否运行时生成原生机器码 iOS 可行性
Mono/.NET JIT IL 即时编译为 ARM64 是 普通 App 不可行
下载原生动态库 直接执行新 ARM64 代码 代码在安装包外生成 通常不可行
ILRuntime 独立解释器解释 IL 否 可行
HybridCLR IL2CPP 运行时扩展后解释 IL 否 可行
原生 IL2CPP 发布前把 IL 编译为 ARM64 仅构建阶段生成 可行,但不能代码热更

两种热更新技术的共同本质是:

把“运行时加载并执行新的机器码”,转换成“用已有、已签名的原生解释器读取并执行新的 IL 数据”。

区别主要在于:

  • ILRuntime建立了一套相对独立的 IL 执行环境,和 IL2CPP 主工程之间存在明显的解释域边界。
  • HybridCLR直接扩展 IL2CPP 运行时,类型系统、泛型、反射以及主工程与热更程序集的交互通常更接近正常 C# 开发体验。

Lua

说到这里提一下Lua为什么可以热更新,因为Lua是解释执行,代码内部已经包含Lua虚拟机,已经支持Lua代码的解析执行,Lua可以像代码一样热更后解释执行。

总结

C# 本身并不是不能热更新,真正的问题是:

  1. C# DLL通常包含的是 IL,不是CPU可以直接执行的ARM64机器码;
  2. 传统 CLR需要 JIT 把新 IL 转成机器码;
  3. iOS 的代码签名和内存执行策略禁止普通 App 随意 JIT 或加载未签名原生代码;
  4. Unity因此使用 IL2CPP/AOT,但 AOT 只能编译发布时已知的代码;
  5. 发布后下载的新 DLL 没有对应的 AOT 机器码,因而无法直接运行;
  6. ILRuntime和HybridCLR通过“解释执行 IL”解决问题,不在运行时生成新的原生代码。

HybridCLR学习

HybridCLR Github地址

HybridCLR官方文档

HybridCLR工程搭建事例

HybridCLR安装

不知道是不是因为国外Unity和国内Unity分区了,国外PackageManager里没法直接搜索到HybridCLR,这里采用从Git URL的安装方式。

HybridCLRInstallFromURL

注意地址要填:https://github.com/focus-creative-games/hybridclr_unity.git而非官方Github地址,上面这个地址才是专门给Unity制作的Github安装包。

安装完成后如下:

HybridCLRInstallCompleted

HybridCLR初始化

安装好后初始化HybridCLR,点击菜单HybridCLR/Installer…

HybridCLRInstaller

HybridInstallerEditorWindow

安装完成后我们会看到项目工程目录下多了LocalIl2CppData-WindowsEditor目录(看得出是跟平台挂钩的,不同平台执行应该会复制到不一样的目录):

HybridCLRFolderAfterWindowsInstaller

创建ConsoleToCreen.cs脚本(方便日志打印到屏幕验证流程),复制以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ConsoleToScreen : MonoBehaviour
{
const int maxLines = 50;
const int maxLineLength = 120;
private string _logStr = "";

private readonly List<string> _lines = new List<string>();

public int fontSize = 15;

void OnEnable() { Application.logMessageReceived += Log; }
void OnDisable() { Application.logMessageReceived -= Log; }

public void Log(string logString, string stackTrace, LogType type)
{
foreach (var line in logString.Split('\n'))
{
if (line.Length <= maxLineLength)
{
_lines.Add(line);
continue;
}
var lineCount = line.Length / maxLineLength + 1;
for (int i = 0; i < lineCount; i++)
{
if ((i + 1) * maxLineLength <= line.Length)
{
_lines.Add(line.Substring(i * maxLineLength, maxLineLength));
}
else
{
_lines.Add(line.Substring(i * maxLineLength, line.Length - i * maxLineLength));
}
}
}
if (_lines.Count > maxLines)
{
_lines.RemoveRange(0, _lines.Count - maxLines);
}
_logStr = string.Join("\n", _lines);
}

void OnGUI()
{
GUI.matrix = Matrix4x4.TRS(Vector3.zero, Quaternion.identity,
new Vector3(Screen.width / 1200.0f, Screen.height / 800.0f, 1.0f));
GUI.Label(new Rect(10, 10, 800, 370), _logStr, new GUIStyle() { fontSize = Math.Max(10, fontSize) });
}
}

HybridCLR划分程序集

前面我们说过HybridCLR实现C#热更新原理是扩展IL2CPP运行时,将热更程序集IL通过扩展的IL2CPP利用已经打进包的类型数据信息进行解释执行。

所以第一步我们需要针对热更和非热更进行程序集划分(以下只是学习热更目的所以划分的比较粗暴,实际工程会划分更细):

  1. Main.asmdef(主AOT程序集)

    MainAssemblyDefinitionAsset

  2. HotUpdate.asmdef(热更新程序集)

    HotUpdateAssemblyDefinitionAsset

    如果Main.asmdef作为AOT不热更的程序集,最好关闭HotUpdate.asmdef的Auto Referenced避免出现失误引用热更新程序集导致打包失败的情况。

将热更新程序集HotUpdate.asmdef设置成HybridCLR的热更新程序集:

HybridCLR/Settings/Hot Update Assembly Definitions

HotUpdateAssemblyDefinitionsSetting

HybridCLR项目设置

前面已经提到HybridCLR是通过扩展IL2CPP运行时来实现热更DLL解析执行的,那么我们想使用HybridCLR那么肯定必须将打包编译后台设置成IL2CPP:

SettingScriptingBackendToIL2CPP

创建热更新代码

在热更新程序集(HotUpdate)目录下创建HotUpdateEntry.cs文件作为热更新入口代码:

HotUpdateEntry.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System.Collections;
using UnityEngine;

/// <summary>
/// HotUpdateEntry.cs
/// 热更新入口脚本
/// </summary>
public class HotUpdateEntry
{
/// <summary>
/// 热更新入口
/// </summary>
public static void Entry()
{
Debug.Log("Hello, HybridCLR");
}
}

使用GameLauncher.cs脚本(挂载在启动场景上)进行热更程序集(HotUpdate.dll)的资源加载和代码使用:

GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System.Linq;
using System.Reflection;
using UnityEngine;

/// <summary>
/// GameLauncher.cs
/// 游戏启动脚本
/// </summary>
public class GameLauncher : MonoBehaviour
{
/// <summary>
/// 热更新程序集名
/// </summary>
private const string HotUpdateAssemblyName = "HotUpdate";

private void Start()
{
Debug.Log($"GameLauncher.Start()");
LoadHotUpdateDlls();
}

/// <summary>
/// 加载热更新DLL
/// </summary>
private void LoadHotUpdateDlls()
{
Debug.Log($"GameLauncher.LoadHotUpdateDlls()");
// Editor环境下,HotUpdate.dll.bytes已经被自动加载,不需要加载,重复加载反而会出问题。
#if !UNITY_EDITOR
Assembly hotUpdateAss = Assembly.Load(File.ReadAllBytes($"{Application.streamingAssetsPath}/{HotUpdateAssemblyName}.dll.bytes"));
#else
// Editor下无需加载,直接查找获得HotUpdate程序集
Assembly hotUpdateAss = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == HotUpdateAssemblyName);
#endif
}
}

生成热更程序集

在HybridCLR里热更新程序集DLL是当做资源下载和加载的,所以在加载热更新程序集之前我们还要生成热更新程序集DLL,点击HybridCLR->Generate->All

HybridCLRGenerateAllDLL

生成后会在HybridCLRData/HotUpdateDlls/StandaloneWindows64目录下看到生成热更新Hotupdate.dll:

AfterGenerateAllDLL

Note:

  1. 热更新的DLL要当做二进制文件加载,要给热更新DLL加.bytes后缀。

加载并使用热更新程序集

在HybridCLR里,热更新DLL就相当于资源一样加载,所以热更新代码走的热更流程跟资源一样,当前我是测试方便就直接放在SteamingAsset目录下,同时热更新DLL直接走Assembly.Load加载程序集即可,所以首先将HyrbidCLR->Generate->All生产的HotUpdate.dll拷贝到StreamingAssets目录:

CopyHotUpdateDllToStreamingAssets

HyrbidCLR->Generate->All同时会生成HybridCLR相关放裁剪文件。

拷贝到StreamingAssets目录后,在Editor下可以直接通过System.AppDomain.CurrentDomain.GetAssemblies()加载因为已经加载过了,非Editor则把热更程序集当做二进制数据加载:

GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
using System.Linq;
using System.Reflection;
using UnityEngine;
using System;
using System.IO;

/// <summary>
/// GameLauncher.cs
/// 游戏启动脚本
/// </summary>
public class GameLauncher : MonoBehaviour
{
/// <summary>
/// 热更新程序集名
/// </summary>
private const string HotUpdateAssemblyName = "HotUpdate";

/// <summary>
/// 热更新入口类型名
/// </summary>
private const string HotUpdateEntryTypeName = "HotUpdateEntry";

/// <summary>
/// 热更新入口方法名
/// </summary>
private const string HotUpdateEntryMethodName = "Entry";

/// <summary>
/// 热更新程序集
/// </summary>
private Assembly mHotUpdateAssembly;

private void Start()
{
Debug.Log($"GameLauncher.Start()");
LoadHotUpdateDlls();
ExecuteHotUpdateEntry();
}

/// <summary>
/// 加载热更新DLL
/// </summary>
private void LoadHotUpdateDlls()
{
Debug.Log($"GameLauncher.LoadHotUpdateDlls()");
// Editor环境下,HotUpdate.dll.bytes已经被自动加载,不需要加载,重复加载反而会出问题。
#if !UNITY_EDITOR
var hotUpdateBytes = File.ReadAllBytes($"{Application.streamingAssetsPath}/{HotUpdateAssemblyName}.dll.bytes");
mHotUpdateAssembly = Assembly.Load(hotUpdateBytes);
#else
// Editor下无需加载,直接查找获得HotUpdate程序集
mHotUpdateAssembly = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == HotUpdateAssemblyName);
#endif
}

/// <summary>
/// 执行热更新入口
/// </summary>
/// <returns></returns>
private bool ExecuteHotUpdateEntry()
{
Debug.Log($"GameLauncher.ExecuteHotUpdateEntry()");
if (mHotUpdateAssembly == null)
{
Debug.LogError($"GameLauncher.ExecuteHotUpdateEntry() failed, HotUpdate assembly is null.");
return false;
}

var entryType = mHotUpdateAssembly.GetType(HotUpdateEntryTypeName);
if (entryType == null)
{
Debug.LogError($"GameLauncher.ExecuteHotUpdateEntry() failed, {HotUpdateEntryTypeName} type not found in HotUpdate assembly.");
return false;
}

var runMethod = entryType.GetMethod(HotUpdateEntryMethodName, BindingFlags.Public | BindingFlags.Static);
if (runMethod == null)
{
Debug.LogError($"GameLauncher.ExecuteHotUpdateEntry() failed, {HotUpdateEntryMethodName} method not found in Hello type.");
return false;
}

runMethod.Invoke(null, null);
return true;
}
}

运行项目就会看到热更新程序集入口我们加载成功并打印除了”Hello, HybridCLR”:

HotUpdateEntryExecuteLog

然后Editor打包Windows进行测试:

File->Build Profiler->Build

运行Windows打包Exe结果:

HybridCLRWindowsExeLog

可以看到即使是打包成Windows真机包,通过加载StreamingAssets目录下的热更新程序集也是能正确运行热更新代码的。

Note:

  1. 主工程不能直接引用热更新代码。有多种方式可以从主工程调用热更新程序集中的代码,比如反射来调用热更新代码。

修改热更代码生效

修改HotUpdateEntry.cs的方法,添加新的热更方法调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Collections;
using UnityEngine;

/// <summary>
/// HotUpdateEntry.cs
/// 热更新入口脚本
/// </summary>
public class HotUpdateEntry
{
/// <summary>
/// 热更新入口
/// </summary>
public static void Entry()
{
Debug.Log("Hello, HybridCLR");
HotUpdateFunc();
}

/// <summary>
/// 热更新方法
/// </summary>
public static void HotUpdateFunc()
{
Debug.Log("HotUpdateFunc called");
}
}

Editor因为是直接编译,所以热更新程序集能实时生效:

HotUpdateFuncLogInEditor

但要想打包热更新程序集修改生效,还得走一次HybridCLR->CompileDll->ActiveBuildTarget(如果有明确需要触发重新编译的平台可以选对应平台菜单)触发重新编译热更新代码+新热更新程序集拷贝到StreamingAssets目录:

HotUpdateFuncRecompileDllMenu

新生成的热更新程序集放到StreamingAssets目录后,重新打包运行EXE:

HotUpdateFuncLogInWindowsExe

可以看到直接放入最新热更新程序集到StreammingAssets目录重新打包时能成功生效最新代码的,这个结果也是预期之类的,核心我们需要验证的是热更新热更程序集到包外后并加载看是否生效,这一点等后续接入热更新流程后再验证。

HybridCLR热更注意事项

Monobehaviour热更注意事项

  1. 由于Unity资源管理系统的限制,热更新脚本所挂载的资源(prefab、scene、ScriptableObject资源)必须打成assetbundle,从ab包中实例化资源,才能正确还原脚本。
  2. 如果将热更新脚本挂载到Resources等随主包的资源上,会发生scripting missing的错误!但如果先打成assetbundle包,再放到Resources下,运行时加载该随包assetbundle则没有问题。

泛型热更注意事项

  1. 使用热更新中定义的泛型类或函数,直接使用即可。
  2. AOT中已经有代码实例化过某个泛型类或者函数,则热更新中可以直接使用
  3. AOT中没有实例化过某个AOT泛型类或者函数,解决办法有以下几种:
    1. 在AOT代码添加相应的实例化代码。(需要强更不合适)
    2. 补充元数据技术。 这是HybridCLR的专利技术,社区版本也能使用。
    3. full generic sharing完全泛型共享技术,相比补充元数据技术,工作流更简单,既不需要随包携带或者下载补充元数据dll,也不需要加载补充元数据dll,包体大小和内存都明显降低。该技术目前只在商业化版本提供。

因为没买商业版这里主要针对补充元数据技术进行学习验证:

补充元数据技术

这里以热更新程序集泛型访问热更新程序集的类为例:

首先我在HotUpdate程序集定义一个HotUpdateClass,然后在热更程序集尝试List。

HotUpdate.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using UnityEngine;

/// <summary>
/// HotUpdateClass.cs
/// HotUpdate程序集类
/// </summary>
public class HotUpdateClass
{
/// <summary>
/// 构造函数
/// </summary>
public HotUpdateClass()
{
Debug.Log($"HotUpdateClass()");
}

/// <summary>
/// 打印方法
/// </summary>
public void Print()
{
Debug.Log($"HotUpdateClass.Print()");
}
}

然后在热更新工程泛型访问HotUpdateClass:

HotUpdateEntry.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// HotUpdateEntry.cs
/// 热更新入口脚本
/// </summary>
public class HotUpdateEntry
{
/// <summary>
/// 热更新入口
/// </summary>
public static void Entry()
{
Debug.Log("Hello, HybridCLR");
HotUpdateFunc();
AccessHotUpdateGenericStruct();
}

/// <summary>
/// 热更新方法
/// </summary>
public static void HotUpdateFunc()
{
Debug.Log("HotUpdateFunc called");
}

/// <summary>
/// 访问热更程序集泛型Struct
/// </summary>
public static void AccessHotUpdateGenericStruct()
{
Debug.Log("AccessHotUpdateGenericStruct()");
List<HotUpdateStruct> genricHotUpdateStructList = new List<HotUpdateStruct>();
Debug.Log("List<HotUpdateStruct> genricHotUpdateStructList = new List<HotUpdateStruct>();");
var hotUpdateStructInstance = new HotUpdateStruct();
genricHotUpdateStructList.Add(hotUpdateStructInstance);
hotUpdateStructInstance.Print();
}
}

然后按照正常流程HybridCLR->CompileDll->ActiveBuildTarget,然后拷贝HotUpdate.dll到StreamingAssets的HotUpdate.dll.bytes,然后打包运行:

AOTGenericMissingMethodLog

可以看到即使打包到真机在热更程序集访问一个主程序集里没用到泛型Struct依然不行,这个时候就需要用到补充元数据去补充List这个类型的元数据,这个元数据位于mscorlib.dll里:

GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// <summary>
/// 需要补充元数据的Dll
/// </summary>
private static readonly List<string> AotDllList = new List<string>
{
"mscorlib.dll",
"System.dll",
"System.Core.dll", // 如果使用了Linq,需要这个
};

private void Start()
{
Debug.Log($"GameLauncher.Start()");
// 先补充元数据
LoadMetadataForAOTAssemblies();
******
}

/// <summary>
/// 加载补充元数据
/// </summary>
private static void LoadMetadataForAOTAssemblies()
{
foreach (var aotDllName in AotDllList)
{
byte[] dllBytes = File.ReadAllBytes($"{Application.streamingAssetsPath}/{aotDllName}.bytes");
var err = HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly(dllBytes, HomologousImageMode.SuperSet);
Debug.Log($"LoadMetadataForAOTAssembly:{aotDllName}. ret:{err}");
}
}

因为使用到了HybridCLR相关的接口,所以主程序集还需要添加引用HybridCLR.Runtime的引用:

HotUpdateReferenceHybridCLRRuntimeSetting

补充元数据的程序集可以通过HybridCLR/Generate/AotDlls之后在{project}/HybridCLRData/AssembliesPostIl2CppStrip/{target}目录找到相关用于补充元数据的程序集:

StripAssembleLocation

然后生成热更新程序集并拷贝到StreamingAssets目录(补充元数据的程序集也要拷贝)并打包测试:

CopyExtraAssembleToStreamingAssets

LoadMetadataForAOTAssembly

可以看到通过补充元数据,我们正确访问到List结构了。

关于哪些Dll需要进行补充元数据,HybridCLR->Generate->AOTGenericReference时(包含在All里)会生成AOTGenericReferences.cs文件,里面有需要进行补充的元数据列表,如果不在意补充元数据过多的内存问题,可以直接使用这个列表作为依据:

AOTGenericReferenceCSFile

HybridCLR的基础学习先到这,更多深入的学习以后实战用到深入学习。

Note:

  1. 直接在热更新程序集里访问Main程序集会提示报错,因为HotUpdate程序集没设置引用Main程序集,所以我们需要给HotUpdate程序集添加Main程序集作为Assembly Definition References。

    HotUpdateAssembleRefMainAssemble

  2. 因为主程序集有使用过List相关,所以AOT可能已经有相关List泛型Class的元数据,在测试热更程序集的泛型访问问题时最好定义成Struct

Reference

前言

本篇文章是为了分享自己做的一款基于韩剧《请回答1988》制作的文字怀旧问答游戏”1988问答“,制作初期没考虑过版权问题,在上架Steam的过程中发现Steam提示此游戏引用了《请回答1988》的资源(即使是AI漫改后也不行)不允许上架,所以最后打算以百度网盘免费分享的方式,将游戏分享出来,希望有玩家或者请回答1988的粉丝喜欢。

希望未来能做出更好的独立游戏。

百度网盘链接:

1988问答

提取码: t42y

1988问答

1988问答是一款基于韩剧**《请回答1988》制作的文字怀旧问答游戏**。

游戏介绍

游戏以原剧结构为基础,设计了20个关卡,分别对应20 集内容。

每个关卡都会从该集题库中随机抽取5道问题,题目覆盖剧情细节、人物关系、经典台词与代表性场景。玩家需要在多项选择中完成作答,单关答对 3 题(60%准确率)及以上即可通关。

在重复挑战机制上,同一关卡在再次进入时会 重新随机抽题。

这意味着玩家并不是在机械记忆固定答案,而是在不断调动自己对剧集内容的真实印象。问答过程也因此更接近一种“被重新唤起的观看记忆”。

游戏还加入了一个收集式奖励设计:

每通关一关,玩家就能解锁一张《请回答1988》十周年综艺漫画海报。

因此,推进流程不仅意味着完成答题,也意味着逐步解锁和收藏与作品相关的纪念内容。

致敬游戏开发,致敬《请回答1988》。

游戏画面

从这里重新走进1988

主界面

你还记得这些剧情细节吗

关卡问答

闯关之外,也是在收集双门洞的回忆

十周年海报解锁

从喜欢的人物开始这场问答

头像选择

不只是题库,也想保留一点沉浸感

设置界面

游戏视频转的gif:

游戏宣传片

游戏打赏

喜欢此游戏的话,欢迎打赏支持:

支付宝收款码

前言

Unity游戏开发过程中,无论是UI还是3D物体之间是什么因素决定了它的渲染顺序的了,本章是为了深入学习理解渲染顺序相关知识而写。

Note:

  1. 本章节本非深入渲染管线学习,而是理解Unity渲染管线里的一些渲染顺序相关的决定因素设计。

渲染顺序相关概念

一个物体的渲染无论3D还是2D都需要经过摄像机的照射,然后通过Shader经历渲染管线处理后最终显示在屏幕上。

在正确理解渲染顺序之前,让我们先了解一些相关概念知识:

  1. Camera(OpaqueSortMode,TransparencySortMode,Depth)
  2. RenderQueue
  3. SortingLayer
  4. SortingOrder
  5. ZTest & ZWrite

Camera

OpaqueSortMode

Opaque object sorting mode of a Camera. Opaque objects are sorted by various criteria (sorting layers, shader queues, materials, distance, lightmaps etc.) to maximize both the CPU efficiency (reduce number of state changes and improve draw call batching), and to maximize GPU efficiency (many GPUs prefer rough front-to-back rendering order for faster rejection of invisible surfaces).

简单的理解OpaqueSortMode是设置摄像机对不透明物体的排序顺序的策略标准。

OpaqueSortMode.FrontToBack(根据距离排序,先绘制近的后绘制远的结合ZTest实现正确渲染显示)

OpaqueSortMode.NoDistanceSort(不根据距离排序)

TransparencySortMode

Transparent object sorting mode. By default, perspective Cameras sort objects based on distance from Camera position to the object center; and orthographic Cameras sort based on distance along the view direction.

简单的理解TransparencySortMode是设置摄像机对透明物体的排序顺序的策略标准。

TransparencySortMode.Perspective(透视投影–判定物体中心与摄像机距离)

TransparencySortMode.Orthographic(正交投影–判定物体与摄像机方向距离)

TransparencySortMode.CustomAxis(轴判定–判定与指定轴距离)

比如纯2D游戏,可能就希望使用正交投影方式。

Depth

Camera’s depth in the camera rendering order. Cameras with lower depth are rendered before cameras with higher depth. Use this to control the order in which cameras are drawn if you have multiple cameras and some of them don’t cover the full screen.

简单的理解Camera.depth是用于控制多个摄像机之间的渲染顺序,值越低越先渲染。

常规的场景摄像机和UI摄像机采用不同摄像机,为了确保UI摄像机在场景摄像机之上,所以一般把UI摄像机的depth设置的更小。

RenderQueue

Determine in which order objects are renderered. This way for example transparent objects are rendered after opaque objects.

从上面的介绍可以看出,RenderQueue决定物体的渲染顺序(e.g. 透明物体在不透明物体之后渲染)

让我们看下不同的RenderQueue的详细介绍:

RenderQueueIntroduction

RenderQueue越大显示层级越高

Note:

  1. 不透明物体RenderQueue<= 2500,透明物体RenderQueue>2500

SortingLayer

SortingLayer allows you to set the render order of multiple sprites easily. There is always a default SortingLayer named “Default” which all sprites are added to initially.

从上面介绍可以看出,SortingLayer也是决定物体显示顺序优先级,SortingLayer越大显示层级越高,至于它和RenderQueu之间的优先级后续会讲到。

SortingOrder

Renderer’s order within a sorting layer. You can group GameObjects into layers in their SpriteRenderercomponent. This is called the SortingLayer. The sorting order decides what priority each GameObject has to the Renderer within each Sorting Layer.

从上面介绍可以看出,SortingOrder是在SortingLayer之后的一个显示优先级设置,(SortingLayer相同前提下)值越大显示层级越高。

ZTest & ZWrite

ZWrite(深度写入)

Sets whether the depth buffer contents are updated during rendering. Normally, ZWrite is enabled for opaque objects and disabled for semi-transparent ones. Disabling ZWrite can lead to incorrect depth ordering. In this case, you need to sort geometry on the CPU.

从上面的介绍可以看出ZWrite是决定渲染时是否写入深度信息。通常情况情况不透明物体的ZWrite是打开的,半透明物体ZWrite是关闭的。

那么什么是深度信息了?

深度其实就是该像素点在3d世界中距离摄像机的距离。离摄像机越远,则深度值(Z值)越大。

这里需要了解两个渲染管线里的概念:

  1. Z-Buffer(深度缓冲区)(用于记录当前渲染过程中渲染记录的深度信息)
  2. Color-Buff(颜色缓冲区)(用于记录当前渲染过程中渲染记录的颜色信息)

Note:

  1. 深度信息写入的大前提是通过ZTest(深度测试)

ZTest(深度测试)

Sets the conditions under which geometry passes or fails depth testing. Depth testing allows GPUs that have “Early-Z” functionality to reject geometry early in the pipeline, and also ensures correct ordering of the geometry.

从上面的介绍可以看出ZTest是设置深度测试判定策略。深度测试可以实现“Early-Z”从而实现提前扔掉不必要的参与渲染的数据,同时ZTest是确保渲染顺序的关键。

ZWrite结合ZTest的逻辑流程如下:

  1. 当ZTest开启时,以深度缓冲为判断基准,来判断是否满足通过条件,如果不满足则直接丢弃,如果满足执行2
  2. 更新颜色缓存,同时判断是否可以更新深度缓冲,(也就是 ZWrite 是On还是 Off),如果Off,不更新深度缓冲(之后如果有同位置的颜色要深度测试,用的还是之前深度缓冲里对应颜色的深度),如果满足执行3**
  3. 更新深度缓冲

ZTest与ZWrite的所有组合关系如下

  1. 深度测试通过,深度写入开启:更新颜色缓冲区,更新深度缓冲区;
  2. 深度测试通过,深度写入关闭:更新颜色缓冲区,不更新深度缓冲区;
  3. 深度测试失败,深度写入开启:不更新颜色缓冲区,不更新深度缓冲区;
  4. 深度测试失败,深度写入关闭:不更新颜色缓冲区,不更新深度缓冲区。

了解了ZWrite和ZTest概念后,这里就会好奇,ZWrite和ZTest和RenderQueue都是用于决定渲染顺序的,那他们之间的优先级是怎样的了?详细测试见后面实战。

Note:

  1. 只有通过ZTest才会把像素信息写入Color-Buffer(颜色缓冲区)

Alpha Blend(透明度混合)

透明度混合使用當前片元的透明度作為混合因子,與已經存儲在顏色緩衝中的顏色進行混合,得到新的顏色,不過透明度混合必須要關閉深度寫入(Zwrite) 需要特別注意的是,透明度混合只會關閉深度寫入不會關閉深度測試(ZTest),這意味著當透明度混合渲染一個片元時,還是會比較他的深度與深度緩存中的深度值,如果深度值距離相機更遠就不會進行混合操作。

正是因为透明度混合只关闭ZWrite不关闭ZTest,所以不透明物体依然可以通过ZTest进行对透明物体的遮挡。

实战

学习了影响渲染顺序相关的概念后,我们知道了影响物体渲染顺序的东西很多,特别是材质的Shader里的RenderQueue,但Unity有一些数据默认是没有显示在Inspector面板上的,为了方便快速查看或修改渲染相关数据,这里我通过扩展Inspector的方式让相关数据显示出来。

Inspecotr扩展

Camera Inspector扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

/// <summary>
/// CameraInspector.cs
/// Camera组件Inspector扩展显示
/// </summary>
[CanEditMultipleObjects]
[CustomEditor(typeof(Camera))]
public class CameraInspector : CameraEditor
{
// Note:
// opaqueSortMode和TransparentSortMode属性好像不属于Serialized成员,FindProperty得不到

/// <summary>
/// 目标组件对象
/// </summary>
private Camera mTarget;

// Note:
// 这里不能重写OnEnable,会导致CameraEditor原始部分流程异常

public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if(mTarget == null)
{
mTarget = (Camera)target;
}
if (mTarget != null)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("opaqueSortMode", GUILayout.Width(170f));
EditorGUI.BeginChangeCheck();
var newOpaqueSortMode = (OpaqueSortMode)EditorGUILayout.EnumPopup(mTarget.opaqueSortMode);
if(EditorGUI.EndChangeCheck())
{
mTarget.opaqueSortMode = newOpaqueSortMode;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("transparencySortMode", GUILayout.Width(170f));
EditorGUI.BeginChangeCheck();
var newTransparencySortMode = (TransparencySortMode)EditorGUILayout.EnumPopup(mTarget.transparencySortMode);
if(EditorGUI.EndChangeCheck())
{
mTarget.transparencySortMode = newTransparencySortMode;
}
EditorGUILayout.EndHorizontal();
}
}
}

CameraInspector

可以看到通过扩展,我把Camera.opaqueSortMode和Camera.transparencySortMode都在Inspecotr面板显示出来了。

MeshRenderer Inspector扩展

MeshRendererInspector.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// MeshRendererInspector.cs
/// MeshRenderer编辑器扩展
/// </summary>
[CustomEditor(typeof(MeshRenderer))]
public class MeshRendererInspector : Editor
{
/// <summary>
/// SortingOrder属性
/// </summary>
private SerializedProperty mSortingOrderProperty;

/// <summary>
/// Materials属性
/// </summary>
private SerializedProperty mMaterialsProperty;

/// <summary>
/// 目标组件
/// </summary>
private MeshRenderer mTarget;

/// <summary>
/// SortingLayer名数组
/// </summary>
private string[] mSortingLayersNameArray;

/// <summary>
/// SortingLayer索引
/// </summary>
private int mSortingLayerIndex;

private void OnEnable()
{
mTarget = (MeshRenderer)target;
mSortingLayersNameArray = SortingLayer.layers.Select(x => x.name).ToArray();
mSortingOrderProperty = serializedObject.FindProperty("m_SortingOrder");
mMaterialsProperty = serializedObject.FindProperty("m_Materials");
UpdateSortingLayerIndex();
}

public override void OnInspectorGUI()
{
base.OnInspectorGUI();
serializedObject.Update();
EditorGUI.BeginChangeCheck();
var selectedIndex = EditorGUILayout.Popup("SortingLayerName", mSortingLayerIndex, mSortingLayersNameArray);
if(EditorGUI.EndChangeCheck())
{
mTarget.sortingLayerName = SortingLayer.layers[selectedIndex].name;
UpdateSortingLayerIndex();
}
if(mSortingOrderProperty != null)
{
EditorGUILayout.PropertyField(mSortingOrderProperty);
}
if(mMaterialsProperty != null)
{
for(int i = 0; i < mMaterialsProperty.arraySize; i++)
{
var material = mMaterialsProperty.GetArrayElementAtIndex(i);
if(material != null && material.objectReferenceValue != null)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"Materials[{i}]", GUILayout.Width(170f));
EditorGUILayout.BeginVertical();
var mat = (Material)material.objectReferenceValue;
EditorGUILayout.ObjectField(mat, EditorType.MATERIAL_TYPE, false);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("RenderQueue", GUILayout.Width(80f));
EditorGUILayout.IntField(mat.renderQueue);
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}
}
}
serializedObject.ApplyModifiedProperties();
}

/// <summary>
/// 更新SortingLayer索引
/// </summary>
private void UpdateSortingLayerIndex()
{
mSortingLayerIndex = Array.FindIndex(mSortingLayersNameArray, FindSortingLayerNameIndex);
}

/// <summary>
/// 查找当前SortingLayerName索引
/// </summary>
/// <param name="sortingName"></param>
/// <returns></returns>
private bool FindSortingLayerNameIndex(string sortingName)
{
return mTarget.sortingLayerName == sortingName;
}
}

MeshRendererInspector

可以看到通过扩展,我把MeshRenderer的SortingLayerName和Sorting Order以及材质的RenderQueue值都显示出来了。

SpriteRenderer Inspector扩展

SpriteRendererInspector.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using System;
using System.Reflection;
using UnityEditor;
using UnityEngine;

/// <summary>
/// SpriteRendererInspector.cs
/// SpriteRenderer编辑器扩展
/// </summary>
[CustomEditor(typeof(SpriteRenderer))]
public class SpriteRendererInspector : Editor
{
/// <summary>
/// Materials属性
/// </summary>
private SerializedProperty mMaterialsProperty;

/// <summary>
/// SpriteRendererEditor类型信息
/// </summary>
private Type mSpriteRendererEditorType;

/// <summary>
/// SpriteRenderer Editor
/// </summary>
private Editor mSpriteRendererEditor;

/// <summary>
/// SortingLayer索引
/// </summary>
private int mSortingLayerIndex;

private void OnEnable()
{
mMaterialsProperty = serializedObject.FindProperty("m_Materials");
var assembly = Assembly.GetAssembly(typeof(Editor));
mSpriteRendererEditorType = assembly.GetType("UnityEditor.SpriteRendererEditor", true);
mSpriteRendererEditor = Editor.CreateEditor(target, mSpriteRendererEditorType);
}

public override void OnInspectorGUI()
{
if(mSpriteRendererEditor != null)
{
mSpriteRendererEditor.OnInspectorGUI();
}
serializedObject.Update();
if(mMaterialsProperty != null)
{
for(int i = 0; i < mMaterialsProperty.arraySize; i++)
{
var material = mMaterialsProperty.GetArrayElementAtIndex(i);
if(material != null && material.objectReferenceValue != null)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"Materials[{i}]", GUILayout.Width(170f));
EditorGUILayout.BeginVertical();
var mat = (Material)material.objectReferenceValue;
EditorGUILayout.ObjectField(mat, EditorType.MATERIAL_TYPE, false);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("RenderQueue", GUILayout.Width(80f));
EditorGUILayout.IntField(mat.renderQueue);
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}
}
}
serializedObject.ApplyModifiedProperties();
}
}

SpriteRendererInspector

可以看到通过扩展我把SpriteRenderer材质相关的RenderQueue值显示出来了。

SkinnedMeshRenderer Inspector扩展

SKinnedMeshRendererInspector.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// SkinnedMeshRendererInspector.cs
/// SkinnedMeshRenderer编辑器扩展
/// </summary>
[CustomEditor(typeof(SkinnedMeshRenderer))]
public class SkinnedMeshRendererInspector : Editor
{
/// <summary>
/// SortingOrder属性
/// </summary>
private SerializedProperty mSortingOrderProperty;

/// <summary>
/// Materials属性
/// </summary>
private SerializedProperty mMaterialsProperty;

/// <summary>
/// 目标组件
/// </summary>
private SkinnedMeshRenderer mTarget;

/// <summary>
/// SortingLayer名数组
/// </summary>
private string[] mSortingLayersNameArray;

/// <summary>
/// SortingLayer索引
/// </summary>
private int mSortingLayerIndex;

private void OnEnable()
{
mTarget = (SkinnedMeshRenderer)target;
mSortingLayersNameArray = SortingLayer.layers.Select(x => x.name).ToArray();
mSortingOrderProperty = serializedObject.FindProperty("m_SortingOrder");
mMaterialsProperty = serializedObject.FindProperty("m_Materials");
UpdateSortingLayerIndex();
}

public override void OnInspectorGUI()
{
base.OnInspectorGUI();
serializedObject.Update();
EditorGUI.BeginChangeCheck();
var selectedIndex = EditorGUILayout.Popup("SortingLayerName", mSortingLayerIndex, mSortingLayersNameArray);
if(EditorGUI.EndChangeCheck())
{
mTarget.sortingLayerName = SortingLayer.layers[selectedIndex].name;
UpdateSortingLayerIndex();
}
if(mSortingOrderProperty != null)
{
EditorGUILayout.PropertyField(mSortingOrderProperty);
}
if(mMaterialsProperty != null)
{
for(int i = 0; i < mMaterialsProperty.arraySize; i++)
{
var material = mMaterialsProperty.GetArrayElementAtIndex(i);
if(material != null && material.objectReferenceValue != null)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"Materials[{i}]", GUILayout.Width(170f));
EditorGUILayout.BeginVertical();
var mat = (Material)material.objectReferenceValue;
EditorGUILayout.ObjectField(mat, EditorType.MATERIAL_TYPE, false);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("RenderQueue", GUILayout.Width(80f));
EditorGUILayout.IntField(mat.renderQueue);
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}
}
}
serializedObject.ApplyModifiedProperties();
}

/// <summary>
/// 更新SortingLayer索引
/// </summary>
private void UpdateSortingLayerIndex()
{
mSortingLayerIndex = Array.FindIndex(mSortingLayersNameArray, FindSortingLayerNameIndex);
}

/// <summary>
/// 查找当前SortingLayerName索引
/// </summary>
/// <param name="sortingName"></param>
/// <returns></returns>
private bool FindSortingLayerNameIndex(string sortingName)
{
return mTarget.sortingLayerName == sortingName;
}
}

SkinnedMeshRendererInspector

可以看到通过扩展我把SkinnedMeshRenderer的SortingLayerName和Sorting Order以及材质设置的RenderQueue值都显示出来了。

Camera Depth

摄像机深度在所有影响渲染排序的因素里排首位

游戏开发过程中我们的UI往往在3D场景之上,为了做不同的渲染合批,我们一般会设置2个摄像机,一个作为3D主摄像机,一个作为UI摄像机。

MainCameraSetting

UICameraSetting

可以看到为了使UI摄像机渲染结果再3D主摄像机之上,我们把UI摄像机的Depth设置值>3D主摄像机

3DAnd2DMixResultPreview

可以看到UI摄像机照射的结果显示在了3D主摄像机之上,所以可以得出Camera.Depth值越大越晚渲染。

(ZWrite & ZTest) VS RenderQueue

前面提到过ZWrite&ZTest和RenderQueue都是影响渲染顺序的关键。那么他们之间优先级又是怎样得了?

这里直接说结论:

如果开启了ZTest和ZWrite,可不管物件绘制順序 (rendering order),使得里摄像机越近的物件,永远都绘制在其他离摄像机越远的物件之前 (ZTest LEqual)。

首先我们创建一个ZWrite On和ZTest LEqual的Shader:

ZWriteZTestOnSurfaceShader.shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Shader "Custom/ZWriteZTestOnSurfaceShader"
{
******

SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
ZWrite On
ZTest LEqual

******
}
}

从上面可以看到我们指定了ZWrite On和ZTest LEqual表示开始深度写入,且深度比较标准是距离越近或相等的情况通过深度测试。同时我们将默认的RenderQueue设置成Opaque,用于不透明物体的渲染。

ZWriteZTestOnRedMaterial

ZWriteZTestOnBlueMaterial

ZWriteZTestOnCubeSceneView

ZWriteZTestOnCubeGameView.PNG

从上面可以看到我们创建了一个ZWrite On ZTest LEqual且RenderQueue分别为2010(红色)和2000(蓝色)的材质球,分别用于两个Cube渲染显示。

可以看到即使红色小球的RenderQueue值更高,但因为开启了ZWrite On和ZTest LEqual,红色Cube离摄像机更远依然渲染在蓝色Cube背后。

接下来让我们看一下两个Cube距离摄像机位置一致时,RenderQueue不一样,两个Cube是怎么显示的?

ZWriteZTestOnRedCubePosition

ZWriteZTestOnBlueCubePosition

ZWriteZTestOnWithSameDistanceResult

从上面可以看到,距离摄像机相同距离的前提下,红色Cube拥有更高的RenderQueue(2010)显示在了蓝色Cube RenderQueue(2000)之上。

结论:

  1. 开启ZWrite和ZTest的情况下,深度优先于RenderQueue,深度一样则比较RenderQueue

Sorting Layer VS RenderQueue

那么Sorting Layer和RenderQueue在渲染顺序里谁的优先级更高了?

不透明物体 > 透明物体

同为不透明物体或透明物体时,Sorting Layer > RenderQueue

在测试之前让我们首先添加几个3D Sorting Layer:

SortingLayerSetting

接下来让我们实战测试:

红色方块设置:

ZOn3D1Layer2000RedSetting

蓝色方块设置:

ZOnDefaultLayer2500BlueSetting

绿色方块设置:

ZOnDefaultLayer2501GreenSetting

Game渲染结果:

SortingLayerAndRenderQueueResultPreview

从上面的测试可以看到:

  1. 同时开启ZWrite和ZTest,当物体都是不透明物体时Sorting Layer设置优先于RenderQueue
  2. 同时开启ZWrite和ZTest,一个为不透明物体一个为透明物体时,透明物体在不透明物体之后渲染,此时RenderQueue的影响优先与Sorting Layer

Note:

  1. RenderQueue 2500是不透明物体(<=2500)和透明物体(>2500)的分界线

Sorting Order VS RenderQueue

那么Sorting Order和RenderQueue在渲染顺序里谁的优先级更高了?

在前面我们了解到RenderQueue会影响物体处于透明或不透明渲染队列里,所以

不透明物体 > 透明物体

同为不透明物体或透明物体时,Sorting Order> RenderQueue

这里我就不一一截图单个物体材质设置了,通过材质球名字就能看出我的测试单元设置:

AllSortingOrderAndRenderQueueSetting

ZOnSortingOrder10GreenMaterial2000 VS ZOnSortingOder5RedMaterial2005 VS ZOnSortingOrder1BlueMaterial2009

SortingOrderAndRenderQueueResultPreview1

从上面测试结果可以看看出:

  1. 同为不透明物体状态下,Sorting Order > RenderQueue,而不是Sorting Order+RenderQueue的值决定的

ZOnSortingOrder6BlueMaterial2100 VS ZOnSortingOrder7GreenMaterial2000

SortingOrderAndRenderQueueResultPreview2

从上面测试结果可以看看出:

  1. 不透明物体 > 透明物体依然适用

学习总结

渲染顺序优先级:

大的优先级如下:

Camera.depth > Material type((RenderQueue不透明 > RenderQueue透明) ) > SortingLayer> SortingOrder > RenderQueue

其他规则:

如果开启了ZTest和ZWrite,可不管物件绘制順序 (rendering order),使得里摄像机越近的物件,永远都绘制在其他离摄像机越远的物件之前 (ZTest LEqual)。

同为不透明物体或透明物体时,Sorting Layer > RenderQueue

Github

Reference

Unity rendering order 整理筆記

Unity 渲染顺序详解

Unity渲染顺序(Queue,ZWrite,ZTest)

[UnityShader]渲染队列、ZWrite和ZTest

[Shader筆記]透明物體(Z-Test、Z-Write、Z-Buffer、Alpha Blending)

前言

Unity游戏开发过程中,2D UI上常常会通过添加Mask或者RectMask2D组件进行UI遮罩显示。本章是为了深入学习理解Mask和RectMask2D原理相关知识,同时学习了解3D粒子和UI层级的混合显示方案。

Mask

Mask是指通过遮罩控制可见显示区域的一种遮罩显示。

UGUI里常见的遮罩有两种:

  1. Mask
  2. Rect2DMask

虽然都是实现Mask效果,但两个组件的底层遮罩实现原理却大不相同,接下来让我们深入Mask和Rect2DMask底层实现,深入理解Mask底层的相关知识,会后续3D和2D混合显示遮罩打下基础。

Mask组件

在深入了解Mask组件之前,我们需要了解一下什么是Stencil Buffer(模板缓存),Stencil Buffer是实现遮罩的底层渲染原理。

Stencil Buffer

The stencil buffer stores an 8-bit integer value for each pixel in the frame buffer. Before executing the fragment shader for a given pixel, the GPU can compare the current value in the stencil buffer against a given reference value. This is called a stencil test. If the stencil test passes, the GPU performs the depth test. If the stencil test fails, the GPU skips the rest of the processing for that pixel. This means that you can use the stencil buffer as a mask to tell the GPU which pixels to draw, and which pixels to discard.

从上面的介绍可以看出,Stencil Buffer(模板缓存)不同于深度Buffer和颜色Buffer,不是用来存储深度或颜色信息的,而是一个每个像素分配8bit(0-255)用来存储Stencil比较值的地方。如果一个pixel通不过Stencil Test(类似Depth Test,但是是用来比较Stencil Buffer值的方式),那么这个pixel将不会再走后续渲染流程(Depth Test)。

Unity Stencil有很多参数,这里调几个重要的参数介绍:

  • comparisonOperation(Stencil值比较方式)

    StencilTestCompareValue

    Stencil Test比较公式:

    ​ (ref & readMask) comparisonFunction (stencilBufferValue & readMask)

  • passOperation(Stencil值通过替换方式)

    StencilTestReplaceValue

  • ref(引用参考值)

    用于Stencil值比较或Stencil Pass后值替换等

  • ReadMask(Stencil比较掩码)

    用于Stencil Test时值比较过滤,从上面的Stencil Test公式可以看到Ref和Stencil Buffer Value值都与ReadMask与之后在进行比较的

  • WriteMask(Stencil写掩码)

    用于Stencil Test通过后passOperation值写入过滤,即与passOperation后的值进行比较过滤再写入Stencil Buffer

更多参数参考官网:

ShaderLab command: Stencil

Note:

  1. Stencil Test发生Depth Test之前,Aplha Test和fragment shader之后。顺序是:Alpha Test -> fragment shader -> Stencil Test -> depth test
  2. 模板缓冲可以用来制作物体的遮罩、轮廓描边、阴影、遮挡显示等等效果

Mask+UI组件

先让我们尝试一下Mask组件的使用:

使用Mask组件前:

BeforeMaskComponentUsing

使用Mask组件后:

MaskComponentUsing

AfterMaskComponentUsing

可以看到通过使用一个圆形遮罩我们成功的将√实现了圆形的遮罩显示,当同时右上角Status数据也可以看到,激活Mask给我们增加了1个Batches和2个SetPass calls,至于原因后续会讲到。

那么第一个疑问,我们想知道的是Mask组件式如何实现遮罩的了?

首先我打开FrameDebug看了下Image是实际绘制流程:

MaskFrameDebugDrawPreview

可以看到1个Mask和1张图绘制总共分为3步,绘制了3次Mesh。

打开Mask.cs源码,我们会看到两个跟材质挂钩的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/// Stencil calculation time!    
public class Mask : UIBehaviour, ICanvasRaycastFilter, IMaterialModifier
{
******

[NonSerialized]
private Material m_MaskMaterial;

[NonSerialized]
private Material m_UnmaskMaterial;

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
if (!MaskEnabled())
return baseMaterial;

var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
if (stencilDepth >= 8)
{
Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
return baseMaterial;
}

int desiredStencilBit = 1 << stencilDepth;

// if we are at the first level...
// we want to destroy what is there
if (desiredStencilBit == 1)
{
var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial;

var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}

//otherwise we need to be a bit smarter and set some read / write masks
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial2;

graphic.canvasRenderer.hasPopInstruction = true;
var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial2;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}
******
}

从源码可以看到,Mask组件实现了IMaterialModifier接口。

IMaterialModifier is Interface which allows for the modification of the Material used to render a Graphic before they are passed to the CanvasRenderer.

UGUI我们是通过Canvas统一进行绘制渲染的,从上面的介绍可以看出要实现IMaterialModifier允许我们在传递给CanvasRenderer绘制渲染UI(Graphic)之前进行材质处理,而IMaterialModifier.GetModifiedMaterial()正是这么个接口。

打开StencilMaterial.cs查看源码StencilMaterials.Add()接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/// <summary>
/// Add a new material using the specified base and stencil ID.
/// </summary>
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)
{
if ((stencilID <= 0 && colorWriteMask == ColorWriteMask.All) || baseMat == null)
return baseMat;

if (!baseMat.HasProperty("_Stencil"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _Stencil property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilOp"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilOp property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilComp"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilComp property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilReadMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilReadMask property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_StencilWriteMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilWriteMask property", baseMat);
return baseMat;
}
if (!baseMat.HasProperty("_ColorMask"))
{
Debug.LogWarning("Material " + baseMat.name + " doesn't have _ColorMask property", baseMat);
return baseMat;
}

for (int i = 0; i < m_List.Count; ++i)
{
MatEntry ent = m_List[i];

if (ent.baseMat == baseMat
&& ent.stencilId == stencilID
&& ent.operation == operation
&& ent.compareFunction == compareFunction
&& ent.readMask == readMask
&& ent.writeMask == writeMask
&& ent.colorMask == colorWriteMask)
{
++ent.count;
return ent.customMat;
}
}

var newEnt = new MatEntry();
newEnt.count = 1;
newEnt.baseMat = baseMat;
newEnt.customMat = new Material(baseMat);
newEnt.customMat.hideFlags = HideFlags.HideAndDontSave;
newEnt.stencilId = stencilID;
newEnt.operation = operation;
newEnt.compareFunction = compareFunction;
newEnt.readMask = readMask;
newEnt.writeMask = writeMask;
newEnt.colorMask = colorWriteMask;
newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0;

newEnt.customMat.name = string.Format("Stencil Id:{0}, Op:{1}, Comp:{2}, WriteMask:{3}, ReadMask:{4}, ColorMask:{5} AlphaClip:{6} ({7})", stencilID, operation, compareFunction, writeMask, readMask, colorWriteMask, newEnt.useAlphaClip, baseMat.name);

newEnt.customMat.SetInt("_Stencil", stencilID);
newEnt.customMat.SetInt("_StencilOp", (int)operation);
newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
newEnt.customMat.SetInt("_StencilReadMask", readMask);
newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
newEnt.customMat.SetInt("_UseUIAlphaClip", newEnt.useAlphaClip ? 1 : 0);

if (newEnt.useAlphaClip)
newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP");
else
newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP");

m_List.Add(newEnt);
return newEnt.customMat;
}

可以看到Mask在IMaterialModifies.GetModifiedMaterial()接口里是对UI的原始材质进行复制后,对Stencil(模板缓存)进行修改实现的遮罩效果。

结合首层Mask逻辑,我们来理一下遮罩原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// if we are at the first level...
// we want to destroy what is there
if (desiredStencilBit == 1)
{
var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial;

var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

return m_MaskMaterial;
}

如果是第一层,stencilDepth值为0,目标模版缓存值(desiredStencilBit)为1。

  1. Unity Mask先添加了基于baseMaterial(原始配置的材质球)新裁剪材质球maskMaterial,将新材质球的Stencile(模版参考比较值)值改为1,将StencilOp的模板操作设置成Replace(模版缓存比较通过则替换成新的模板参考比较值),CompareFunction(模版比较方式)设置成Always(模板测试总是通过),根据是否显示Mask图形将ColorWriteMask(颜色写入FrameBuffer的Mask值)设置成255(所有颜色值都写入)或0(所有颜色值都不写入),至于Stencile的ReadMask和WriteMask(第一层默认传255)

  2. 通过第1步的新增裁剪材质球maskMaterial处理,当我们渲染Mask图形的时候,ColorWriteMask会决定将Mask图形颜色写入,反之不写入(这一步决定了mask图形是否显示)。

  3. 通过第1步的新材裁剪质球maskMaterial处理,因为模板比较是Always(总是通过),结合模版参考值1和模板操作方式Replace,此时渲染完Mask图形后Stencil Ref值应该为1

    AfterMaskGraphRenderStatus

  4. 接着创建unmaskMaterial材质球,并将其设置成遮罩Graphic.canvasRendererd的SetPopMaterial(个人理解是后续用于渲染完所有子对象后的额外材质球处理)用于取消Mask的相关设置,这一步发生在Mask图形所有子对象渲染完成时(从结果额外绘制了一个Mesh触发了unmaskMaterial逻辑来猜测的),后续详细介绍

  5. 接下来渲染遮罩图形的子对象tickImg

    MaskChildGraphicStencilSetting

    RenderMaskChildGraphicStatus

    可以看到遮罩图形的子对象tickImg的模板相关设置是Stencil Ref(模版参考比较值)为1,StencilOp的模板操作是Keep(保持原模板缓存值),StencilComp的模板比较操作是Equal(模版值和模板参考值相同才通过模板测试),Stencil Write Mask(模版缓存值写入时的Mask值)值为0(不允许写入模板缓存值),Stencil Read Mask(模版缓存读取时的Mask值)值为1(只允许读取第一位的模板值),ColorMask(颜色写入Mask值,8bit标识,15为所有颜色值写入)值为15。

    我这里使用的是Unity自带的UI/Default Shader,这些模板值设置是发生在我给UI挂载对象时或者增删Mask组件时。

    因此子对象渲染时发现模版缓存值为1,且自身设置模板参考比较值也为1,同时模板比较方法是Equal,从而实现只有在Mask图形区域类模板缓存值为1的部分满足显示,从而只有Mask图形区域内的子对象显示部分通过了模板缓存测试,同时通过后因为StencilOp的模板操作是Keep所以模板缓存值不变依然是1

  6. 子对象渲染完成后,我们接着看第4步里unmaskMaterial的后续逻辑,unmaskMaterial的Stencil Ref(模版参考比较值)为1,StencilOp的模板操作是Zero(设置模板值到0),StencilComp的模板比较操作是Always(总是通过模板缓存测试),ColorMask为0(不写入任何颜色)

    UnmaskMaterialStatus

    因为maskMaterial和子对象渲染完成后,模版缓存里的值为1,此时unmaskMaterial的StencilComp是Always,StencilOp的模板操作是Zero,所以会将模板缓存里的值更新到0,同时因为ColorMask为0所以这一次unmaskMaterial的额外Mesh渲染绘制不会显示任何东西。

Note:

  1. Mask实现遮罩效果底层原理是通过Stencil(模板缓存)

Mask挖洞+UI组件

在了解了Mask的底层原理后,让我们来看看游戏开发过程中还有一种最常见的需求,Mask挖洞。Mask和Mask挖洞字面意思,前者是在范围内的才显示,后者是不在范围内的才显示。

新手引导里需要挖洞显示下层按钮,需求上需要支持不同形状的挖洞,同时挖出来的洞要支持点击响应。

考虑到为了支持原生Mask组件流程,所以不继承去修改Mask的流程,既然Mask已经通过maskMaterial修改了stencil值,那么我们要做的就是将子对象Graph渲染时实现反向Stencil比较的方式实现反向显示,而Mask组件只作为挖洞形状的Image。

Graphic实现遮罩(不管是Mask还是RectMask2D)原理的核心逻辑是继承MaskableGraphic.cs(Image继承至MaskableGraphic),而针对Mask模版缓存实现遮罩的核心逻辑是接口IMaterialModifier的GetModifiedMaterial()方法

MaskableGraphi.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/// <summary>
/// A Graphic that is capable of being masked out.
/// </summary>
public abstract class MaskableGraphic : Graphic, IClippable, IMaskable, IMaterialModifier
{
******

/// <summary>
/// See IMaterialModifier.GetModifiedMaterial
/// </summary>
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;

if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

// if we have a enabled Mask component then it will
// generate the mask material. This is an optimisation
// it adds some coupling between components though :(
Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
return toUse;
}

******
}

IMaterialModifier.GetModifiedMaterial()这个接口方法会在Graphic渲染之前允许对材质球进行计算操作。

可以看MaskableGraphic.cs的GetModifiedMaterial()方法对没有Mask组件的Image进行了Stencil相关的Shader参数设置,这里最重要的就是StencilID设置成(1 << m_StencilValue) - 1,Stencil的CompareFunction设置成Equal(模版缓存比较采取相等才通过)和Stencil的Operation设置成Keep(保持原始Stencil值)

因为如果上层有Mask组件,mask的字Graphic渲染时Stencil Value已经为1了,这里StencilID最终计算就会是1<<1 - 1=1

也就是说子Graphic要满足Stencil ID=1的时候才通过模板缓存且不改变Stencil Value值。

那么这个时候如果我们的Mask子Graphic要想实现反向遮罩,我们只需要将子Graphic的材质Stencil的CompareFunction设置成NotEqual即能实现挖洞反向显示的效果。

接下来我们通过继承Image,实现一个CustomImage.cs,然后通过重写IMaterialModifier的GetModifiedMaterial()方法来实现反向遮罩的Image组件。

CustomImage.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// CustomImage.cs
/// 自定义Image用于支持反向遮罩和透明点击穿透设置
/// </summary>
public class CustomImage : Image
{
/// <summary>
/// 是否开启反向遮罩
/// </summary>
[Header("是否开启反向遮罩")]
public bool EnableInvertMask = false;

/// <summary>
/// See IMaterialModifier.GetModifiedMaterial
/// </summary>
public override Material GetModifiedMaterial(Material baseMaterial)
{
if(!EnableInvertMask)
{
return base.GetModifiedMaterial(baseMaterial);
}

return GetInvertMaskModifiedMaterial(baseMaterial);
}

/// <summary>
/// 获取反向遮罩修改材质球
/// </summary>
/// <param name="baseMaterial"></param>
/// <returns></returns>
protected Material GetInvertMaskModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;

if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.NotEqual, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
return toUse;
}
}

CustomImageEditor.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

/// <summary>
/// CustomImageEditor.cs
/// CustomImage的Inspector扩展
/// </summary>
[CustomEditor(typeof(CustomImage))]
[CanEditMultipleObjects]
public class CustomImageEditor : ImageEditor
{
/// <summary>
/// 是否开启反向遮罩属性
/// </summary>
private SerializedProperty mEnableInvertMask;
protected override void OnEnable()
{
base.OnEnable();
mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask");
}

public override void OnInspectorGUI()
{
base.OnInspectorGUI();

serializedObject.Update();

EditorGUILayout.PropertyField(mEnableInvertMask);

serializedObject.ApplyModifiedProperties();
}
}

核心代码是开启反向遮罩后我将CompareFunction.Equal修改成了CompareFunction.NotEqual

为了测试被遮挡按钮同步遮罩效果以及被遮挡按钮模拟遮挡点击效果,编写了MainUIInvertMask.cs脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/// <summary>
/// MainUIInvertMask.cs
/// 主UI反向遮罩
/// </summary>
public class MainUIInvertMask : MonoBehaviour
{
/// <summary>
/// 被遮挡的真正UI组件
/// </summary>
[Header("被遮挡的真正UI组件")]
public Button BtnMasked;

/// <summary>
/// 反向遮罩按钮组件
/// </summary>
[Header("反向遮罩按钮组件")]
public Button BtnInvertMask;

void Start()
{
AddAllListeners();
}

/// <summary>
/// 添加所有监听器
/// </summary>
private void AddAllListeners()
{
BtnMasked.onClick.AddListener(OnBtnMaskedClick);
BtnInvertMask.onClick.AddListener(OnBtnInvertMaskClick);
}

void Update()
{
SyncInvertMaskImagePos();
}

/// <summary>
/// 同步反向遮罩Image的遮挡按钮的位置
/// </summary>
private void SyncInvertMaskImagePos()
{
if(BtnMasked == null || BtnInvertMask == null)
{
return;
}

BtnInvertMask.transform.position = BtnMasked.transform.position;
}

/// <summary>
/// 响应被遮挡按钮点击
/// </summary>
private void OnBtnMaskedClick()
{
Debug.Log($"响应被遮挡按钮点击");
}

/// <summary>
/// 响应遮罩按钮点击
/// </summary>
private void OnBtnInvertMaskClick()
{
Debug.Log($"响应遮挡按钮点击");
BtnMasked?.onClick.Invoke();
}
}

这里带Mask组件的是遮罩挖洞按钮,背景遮罩是全屏很大的Image(Mask的子对象),被遮挡的按钮在最底层。

效果如下:

MainUIInvertMaskInspector

InvertMaskInspector

InvertMaskFinalResult

BtnMaskClickLog

可以看到我们拖动被遮罩的按钮时会发现遮挡按钮同步位置后实时实现了挖洞效果。

对于RawImage组件的挖洞原理相同(因为RawImage也继承了MaskableGraphic),我们也通过定义开关和重写GetModifiedMaterial()方法即可实现挖洞效果。

CustomRawImage.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// <summary>
/// TRawImage.cs
/// 自定义RawImage用于支持反向遮罩和透明点击穿透设置
/// </summary>
public class CustomRawImage : RawImage, ICanvasRaycastFilter
{
/// <summary>
/// 是否开启反向遮罩
/// </summary>
[Header("是否开启反向遮罩")]
public bool EnableInvertMask = false;

/// <summary>
/// See IMaterialModifier.GetModifiedMaterial
/// </summary>
public override Material GetModifiedMaterial(Material baseMaterial)
{
if(!EnableInvertMask)
{
return base.GetModifiedMaterial(baseMaterial);
}

return GetInvertMaskModifiedMaterial(baseMaterial);
}

/// <summary>
/// 获取反向遮罩修改材质球
/// </summary>
/// <param name="baseMaterial"></param>
/// <returns></returns>
protected Material GetInvertMaskModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;

if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.NotEqual, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
return toUse;
}
}

CustomRawImageEditor.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// <summary>
/// CustomRawImageEditor.cs
/// CustomRawImage組件的自定义编辑器界面
/// </summary>
[CustomEditor(typeof(CustomRawImage), true)]
[CanEditMultipleObjects]
public class CustomRawImageEditor : RawImageEditor
{
/// <summary>
/// 是否开启反向遮罩属性
/// </summary>
private SerializedProperty mEnableInvertMask;

protected override void OnEnable()
{
base.OnEnable();
mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask");
}

public override void OnInspectorGUI()
{
base.OnInspectorGUI();

serializedObject.Update();

EditorGUILayout.PropertyField(mEnableInvertMask);

serializedObject.ApplyModifiedProperties();
}
}

InverMask2Inspector

InvertMaskFinalResult2

可以看到通过自定义CustomRawImage我们也成功实现了RawImage组件的反向挖洞显示。

Note:

  1. 此方案Unity升级需要跟着同步MaskableGraphic的GetModifiedMaterial()的相关代码

Mask透明区域是否可点击+UI组件

前面的Mask挖洞点击圆形周边透明区域时会发现点击依然生效了,但这种情况我们只希望挖洞的区域响应点击,此时我们需要自定义CustomImage.cs用于支持透明区域穿透设置,Image的透明度穿透核心是通过修改alphaHitTestMinimumThreshold值(<=0表示全部可点击,>=1表示全不可点击,其他值表示小于该值不可点击)实现的。

CustomImage.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// <summary>
/// CustomImage.cs
/// 自定义Image用于支持反向遮罩和透明点击穿透设置
/// </summary>
public class CustomImage : Image
{
/// <summary>
/// 是否开启反向遮罩
/// </summary>
[Header("是否开启反向遮罩")]
public bool EnableInvertMask = false;

/// <summary>
/// 是否激活透明Alpha透明可点击阈值
/// </summary>
[Header("是否激活透明Alpha透明可点击阈值")]
public bool EnableAlphaHitTestMinimusThreshold = false;

/// <summary>
/// 透明Alpha可点击阈值(<=0表示全部可点击,>1表示全不可点击,其他值表示小于该值不可点击)
/// Note:
/// 仅当EnableAlphaHitTestMinimusThreshold=true时有效
/// </summary>
[Header("透明Alpha可点击阈值")]
public float AlphaHitTestMinimumThreshold = 0.1f;

protected override void Start()
{
base.Start();
UpdateAlphaHitTestMinimumThreshold();
}

/// <summary>
/// 更新透明Alpha可点击阈值
/// Note:
/// 外部修改EnableAlphaHitTestMinimusThreshold或AlphaHitTestMinimumThreshold值后请调用此方法确保更新成功
/// alphaHitTestMinimumThreshold值需要EnableAlphaHitTestMinimusThreshold为true的情况下设置才有效
/// </summary>
public void UpdateAlphaHitTestMinimumThreshold()
{
if(EnableAlphaHitTestMinimusThreshold)
{
alphaHitTestMinimumThreshold = AlphaHitTestMinimumThreshold;
}
}

******
}

CustomImageEditor.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/// <summary>
/// CustomImageEditor.cs
/// CustomImage的Inspector扩展
/// </summary>
[CustomEditor(typeof(CustomImage))]
[CanEditMultipleObjects]
public class CustomImageEditor : ImageEditor
{
/// <summary>
/// 是否开启反向遮罩属性
/// </summary>
private SerializedProperty mEnableInvertMask;

/// <summary>
/// 是否激活透明Alpha透明可点击阈值属性
/// </summary> <summary>
private SerializedProperty mEnableAlphaHitTestMinimusThreshold;

/// <summary>
/// 透明Alpha可点击阈值属性
/// </summary>
private SerializedProperty mAlphaHitTestMinimumThreshold;

protected override void OnEnable()
{
base.OnEnable();
mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask");
mEnableAlphaHitTestMinimusThreshold = serializedObject.FindProperty("EnableAlphaHitTestMinimusThreshold");
mAlphaHitTestMinimumThreshold = serializedObject.FindProperty("AlphaHitTestMinimumThreshold");
}

public override void OnInspectorGUI()
{
base.OnInspectorGUI();

serializedObject.Update();

EditorGUILayout.PropertyField(mEnableInvertMask);

EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(mEnableAlphaHitTestMinimusThreshold);
bool enableChanged = EditorGUI.EndChangeCheck();

EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(mAlphaHitTestMinimumThreshold);
bool thresholdChanged = EditorGUI.EndChangeCheck();

serializedObject.ApplyModifiedProperties();

if (enableChanged || thresholdChanged)
{
UpdateAllAlphaHitTextMinimusThresholdoTargets();
}
}

/// <summary>
/// 更新所有对象的透明Alpha穿透阈值
/// </summary>
private void UpdateAllAlphaHitTextMinimusThresholdoTargets()
{
if(targets == null)
{
return;
}
foreach (var targetObj in targets)
{
var penetrateImage = targetObj as CustomImage;
if (penetrateImage != null)
{
penetrateImage.UpdateAlphaHitTestMinimumThreshold();
EditorUtility.SetDirty(targetObj);
}
}
}
}

PenetrateImageInspector

此时再去点圆形周边透明区域就会发现不会触发btnMask的点击事件了。

Note:

  1. 代码手动修改EnableAlphaHitTestMinimusThreshold或AlphaHitTestMinimumThreshold值时请手动调用UpdateAlphaHitTestMinimumThreshold()方法确保生效
  2. 修改透明可点击阈值时的Sprite必须勾选Read/Write

设置alphaHitTestMinimumThreshold实现透明点击穿透在RawImage这里是行不通的,因为alphaHitTextMinimusThreshold是Image组件的属性,但穿透原理还是一样的,自定义RawImage想实现透明点击穿透控制,核心是实现ICanvasRaycastFilter的IsRaycastLocationValid()方法(告知Canvas射线检测时是否参与检测),判定逻辑是通过将点击位置转换到自身Rectransform的相对位置后,基于显示Rect信息将点击相对位置转换成UV坐标去读取对应UV坐标的像素颜色信息比较Alpha值。

CustomRawImage.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/// <summary>
/// TRawImage.cs
/// 自定义RawImage用于支持反向遮罩和透明点击穿透设置
/// </summary>
public class CustomRawImage : RawImage, ICanvasRaycastFilter
{
/// <summary>
/// 是否开启反向遮罩
/// </summary>
[Header("是否开启反向遮罩")]
public bool EnableInvertMask = false;

/// <summary>
/// 是否激活透明Alpha透明可点击阈值
/// </summary>
[Header("是否激活透明Alpha透明可点击阈值")]
public bool EnableAlphaHitTestMinimusThreshold = false;

/// <summary>
/// 透明Alpha可点击阈值(<=0表示全部可点击,>1表示全不可点击,其他值表示小于该值不可点击)
/// Note:
/// 仅当EnableAlphaHitTestMinimusThreshold=true时有效
/// </summary>
[Header("透明Alpha可点击阈值")]
[Tooltip("<=0表示全部可点击,>1表示全不可点击,其他值表示小于该值不可点击")]
public float AlphaHitTestMinimumThreshold = 0.1f;

******

/// <summary>
/// 重写射线检测,实现透明穿透功能
/// </summary>
public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
if(!EnableAlphaHitTestMinimusThreshold)
{
return true;
}

if (AlphaHitTestMinimumThreshold <= 0f)
return true;

if (AlphaHitTestMinimumThreshold > 1f)
return false;

Texture tex = texture;
if (tex == null)
return true;

Texture2D tex2D = tex as Texture2D;
if (tex2D == null)
return true;

if (!tex2D.isReadable)
{
Debug.LogError($"TRawImage Alpha穿透检测需要纹理开启Read/Write Enabled: {tex2D.name}", this);
return true;
}

// 将屏幕坐标转换为RectTransform的本地坐标
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint,
eventCamera, out Vector2 localPoint))
{
return false;

}

Rect rect = GetPixelAdjustedRect();

// 计算UV坐标 (考虑uvRect偏移和缩放)
float u = (localPoint.x - rect.x) / rect.width;
float v = (localPoint.y - rect.y) / rect.height;

// 应用uvRect的偏移和缩放
Rect uv = uvRect;
u = uv.x + u * uv.width;
v = uv.y + v * uv.height;

// 确保UV在有效范围内
u = Mathf.Clamp01(u);
v = Mathf.Clamp01(v);

try
{
return tex2D.GetPixelBilinear(u, v).a >= AlphaHitTestMinimumThreshold;
}
catch (Exception ex)
{
Debug.LogError(
$"TRawImage Alpha穿透检测读取纹理像素失败: {ex.Message}", this);
return true;
}
}
}

CustomRawImageEditor.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/// <summary>
/// CustomRawImageEditor.cs
/// CustomRawImage組件的自定义编辑器界面
/// </summary>
[CustomEditor(typeof(CustomRawImage), true)]
[CanEditMultipleObjects]
public class CustomRawImageEditor : RawImageEditor
{
******

/// <summary>
/// 是否激活透明Alpha透明可点击阈值属性
/// </summary> <summary>
private SerializedProperty mEnableAlphaHitTestMinimusThreshold;

/// <summary>
/// 透明Alpha可点击阈值属性
/// </summary>
private SerializedProperty mAlphaHitTestMinimumThreshold;

******

public override void OnInspectorGUI()
{
base.OnInspectorGUI();

serializedObject.Update();

EditorGUILayout.PropertyField(mEnableInvertMask);
EditorGUILayout.PropertyField(mEnableAlphaHitTestMinimusThreshold);
EditorGUILayout.PropertyField(mAlphaHitTestMinimumThreshold);

DrawClearTextureButton();

serializedObject.ApplyModifiedProperties();
}

******
}

PenetrateRawImageInspector

此时再去点圆形周边透明区域就会发现不会触发btnMask的点击事件了。

Note:

  1. 参与CustomRawImage反向挖洞功能开启的Texture需要开启Read/Write设置

Mask+粒子特效

Mask+自带ParticleAdditive Shader

在了解完Mask组件的常规UI遮罩原理后,接下来让我们尝试下UI上放入粒子特效

ParticleAdditiveInspector

可以看到我将Unity自带的Particle Additive的Shader导入工程并创建了一个材质球使用到了粒子系统身上,同时为了确保粒子特效显示层级在UI上,还专门设置了Sorting Layer ID为Default,Order in Layer为101(因为MainUI节点上挂了Canvas且设置的Sorting Layer为Default,Order in Layer为100)。

让我们看下Unity自带的Particle Additive的Shader代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Shader "Custom/Legacy Shaders/Particles/Additive" {
Properties {
_TintColor ("Tint Color", Color) = (0.5,0.5,0.5,0.5)
_MainTex ("Particle Texture", 2D) = "white" {}
_InvFade ("Soft Particles Factor", Range(0.01,3.0)) = 1.0
}

Category {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }
Blend SrcAlpha One
ColorMask RGB
Cull Off Lighting Off ZWrite Off

SubShader {
Pass {
******
}
}
}

可以看到默认的Particle Additive的Shader是没有带模板缓存相关的Shader代码支持的。渲染结果如下:

ParticleAdditiveShowResult

不出意外Particle Additive的Shader的粒子特效超出了Mask显示范围。

Mask+修改ParticleAdditive Mask Shader

接下来我们尝试改造Particle Additive的Shader,复制一份改名Particle Additive Mask同时创建一个对应的材质球(ParticleAdditiveMaskMat),赋值到particleSystem上。

ParticleAdditiveMaskInspector

为了匹配Mask的模版缓存判定,我将Stencil ID设置成1(模版缓存参考比较值),Stencil Comparison设置成3(Equal),Stencil Operation设置成0(Keep),ColorMask设置成15(所有颜色写入),这些设置是为了在Mask下去比较模板缓存值1的才通过,从而实现超出Mask区域部分不通过不绘制。

让我们看看改造后的显示结果:

ParticleAdditiveMaskShowResult

结果发现粒子特效啥都没显示,让我们看看FrameDebugger是怎么回事。

ParticleAdditiveMaskFrameDebugger

从FrameDebugger可以看到粒子特效的渲染和常规UI的渲染流程不一样,因为粒子特效是当做3D物体渲染的,所以他的渲染顺序不想UI子节点图形没有在Canvas的渲染流程里,而是Canvas渲染完成后才通过Draw Dynamic的方式绘制的。

还记得我们前面说的Mask组件在绘制完成后通过unmaskMaterial将模板缓存里的值已经修改成0了吗?

这个模版缓存值0也就是粒子特效不显示的原因,因为粒子特效Particle Additive Mask我们设置的Stencil ID(模版参考比较值)为1,Stencil Comparion为3(Equal),也就意味着粒子特效通不过模板缓存测试所以什么都不显示了。

仅使用自带UI Default Shader

unmaskMaterial这个流程是Mask流程里自带的,不修改源码是无法修改的,所以目前看来想通过粒子特效支持模板缓存功能从而通过Mask遮罩显示的方案是行不通的。

既然我们已经知道模板缓存是Mask实现遮罩的核心原理,那么我们如果不使用Mask组件,直接导入自带的UI Default Shader,通过直接创建一个非Mask的UI Default材质,一个负责Mask的UI Default Mask材质和一个支持被UI Default Mask遮罩后显示的UI Default Masked材质来实现模版遮罩功。

UIDefaultMatInspector

UIDefaultMaskMatInspector

UIDefaultMaskedMatInspector

可以看到三份材质球都是使用功能的UI Default Shader,但为了Mask不同情况下正确显示,分别设置了不同的Stencil ID,Stencil Comparison,Stencil Operation值。UIDefaultMat用于不需要管遮罩时,UIDefaultMaskMat用在当做遮罩(相当于Mask挂载的情况)时,UIDefaultMaskedMat用在在遮罩里需要被遮罩时。

为了方便查看层级显示和遮罩效果,我创建了一个红色的使用UI Default Mat的最底层图和一个黄色的使用UI Default Mat的最顶层图(同时添加了Canvas设置了order高于特效显示order)

来看看图片和特效相关的层级:

UIDefaultMaskSceneAndHierachy

来看看运行时的效果:

UIDefaultMaskGame

可以看到无论是普通背景和前景图还是被遮罩的图还是粒子特效,通过赋予对应的UI Default Shader的材质球成功实现了Mask遮罩效果。

让我们来看看Frame Debugger是什么情况:

UIDefaultMaskFrameDebugger

可以看到带遮罩的相关图形显示在第一个Canvas.RenderSubBatch里,因为使用Mask组件,所以没有产生unmaskMaterial的流程,所以第一个Canvas.RenderSubBatch最后一个Draw Mesh是被遮罩的那个勾选图形,因为设置的是UI Default Masked Mat(Stencil ID为1,Stencil Comparison为3(Equal),Stencil Operation为0(Keep))所以成功通过了模板缓存并遮罩显示。

接着看看粒子特效显示是什么情况:

UIDefaultMaskParticleSystemFrameDebugger

粒子特效的显示和被遮罩的勾选图形原理和参数一致,紧接着第一个Canvas.RenderSubBatch之后渲染,所以也成功遮罩显示了。

接着看看defaultFrontImg前景图是什么情况:

UIDefaultMaskSecondCanvasFrameDebugger

可以看到因为我给defaultFrontImg添加了额外的Canvas并设置了比特效Order还高的Order,所以单独进行了一次Canvas.RenderSubBatch绘制,即第二个Canvas.RenderSubBatch绘制通过Stencil Ref值为0和Stencil Comparison为8(Always)成功将第二个Canvas渲染绘制的模板缓存值修改成0了。

UIDefaultMaskFrontImgFrameDebugger

可以看到我给defaultFrontImg设置的UI Default Mat(Stencil ID为0,Stencile Comparison为8(Always),Stencile Operation为0(Keep))完美符合了第二个Canvas.RenderSubBatch将模板值修改成0后的显示逻辑,所以成功绘制出来了。

总结:

  1. 虽然通过制作不同的UI Default材质球可以实现遮罩不同的使用情况,但游戏开发过程中遮罩情况可能是动态变化,无法提前预知哪些是用于遮罩,哪些是在遮罩内,哪些是在遮罩外,所以这种方案虽然能实现遮罩效果但并没有实战使用价值。

ParticleEffectForUGUI

接下来让我们看看Github上最强特效和UI显示层级解决方案:

ParticleEffectForUGUI

特点介绍:

  • Sortable: Sort particle effects and other UI elements by sibling index.
  • Maskable: Supports Mask or RectMask2D.
  • No extra components required: No need for an additional Camera, RenderTexture, or Canvas.
  • Trail module support: Fully supports the Trail module.
  • CanvasGroup alpha support: Integrates with CanvasGroup alpha.
  • No allocations: Efficiently renders particles without allocations.
  • Any canvas render mode support: Works with overlay, camera space, and world space.
  • Any Render pipeline support: Compatible with Universal Render Pipeline (URP) and High Definition Render Pipeline (HDRP).
  • 省略

从上面介绍可以看到ParticleEffectForUGUI能将特效在UI排序显示,无需任何Camera,RenderTexture或者Canvas。支持CanvasGroup的Alpha,支持Canvas的各种Render Mode。兼容URP,HDRP等。

ParticleEffectForUGUI导入

我们通过Package Manager选择Git地址(注意#后面的数字是版本号)导入:

1
https://github.com/mob-sakai/ParticleEffectForUGUI.git#4.11.4

Note:

  1. 导入完成后如果想直接使用源码版本,可以直接从Package下拷贝到Asset目录下使用,然后删除Package Manager里的导入库即可。
扩展粒子特效Shader支持Stencil和Clip

粒子特效的Shader要像UI Default Shader一样支持UI的Mask和Clip功能。

Particle Add UI Mask.Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
Shader "Custom/Legacy Shaders/Particles/Additive UI Mask" {
Properties {
******

// #### required for Mask ####
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
_StencilOp ("Stencil Operation", Float) = 0
_StencilWriteMask ("Stencil Write Mask", Float) = 255
_StencilReadMask ("Stencil Read Mask", Float) = 255

_ColorMask ("Color Mask", Float) = 15

[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
}

Category {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }

// #### required for Mask ####
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}

Blend SrcAlpha One
// #### required for Mask ####
ColorMask [_ColorMask]
Cull Off Lighting Off ZWrite Off

SubShader {
Pass {

******

#include "UnityCG.cginc"
// #### required for RectMask2D ####
#include "UnityUI.cginc"
#pragma multi_compile __ UNITY_UI_CLIP_RECT
#pragma multi_compile __ UNITY_UI_ALPHACLIP

******

struct v2f {
******

// #### required for RectMask2D ####
float4 worldPosition : TEXCOORD1;

UNITY_FOG_COORDS(2)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD3;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};

float4 _MainTex_ST;
// #### required for RectMask2D ####
float4 _ClipRect;

v2f vert (appdata_t v)
{
******

// #### required for RectMask2D ####
o.worldPosition = v.vertex;

******

return o;
}

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;

fixed4 frag (v2f i) : SV_Target
{
******

// #### required for RectMask2D ####
#ifdef UNITY_UI_CLIP_RECT
col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
#endif

#ifdef UNITY_UI_ALPHACLIP
clip(col.a - 0.001);
#endif

col.rgb *= col.a;

return col;
}
ENDCG
}
}
}
}

​ 核心是像UI Default Shader里加入了UNITY_UI_CLIP_RECT和UNITY_UI_ALPHACLIP以及Stencil模板相关的定义。

Note:

  1. 注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
制作粒子特效GameObject

第1种情况,从0制作粒子特效:

  1. 创建UIParticle GameObject

    CreateEmptyUIParticleGameObject

  2. 在UIParticle GameObject节点下只做粒子特效

  3. 做完粒子特效后点击UIParticle面板上的Refresh按钮

    UIParticleRefreshButton

  4. 保存UIParticle GameObject当做特效使用

第二种情况,在已有的特效GameObject上制作粒子特效:

  1. 对已有粒子特效GameObject右键创建UIParticle

    CreateUIParticleFromGameObject

  2. 保存UIParticle GameObject当做特效使用

Note:

  1. 通过ParticleEffectForUGUI制作的粒子特效我们不再需要手动调整Order,我们可以像调整UI节点一样放到对应位置即可
  2. 要想UIParticle制作的特效支持Mask需要勾选UIParticle的Maskable
UIParticle缩放

UIParticle的显示大小根据UIParticle的AutoScalingMode设置不同而决定因素不同。UIParticle.scale是UIParticle提供控制粒子特效显示大小的一个参数。

粒子最终渲染尺寸的计算公式如下:

最终尺寸 = Scale3D值 × Canvas缩放因子 × 粒子系统自身缩放 × 父级UI元素缩放

UIParticle.AutoScalingMode是解决Canvas分辨率适配的核心参数,有三种模式:

看网上的说法Position Mode是Absolute或者Relative对于AutoScalingMode.Transform还是AutoScalingMode.UIParticle选择有影响,目前未做测试。

个人目前想法结论如下:

AutoScalingMode推荐UIParticle,制作粒子特效的Simulation Space推荐Local,UIParticle.scale创建时默认是10所有特效制作时基于scale修改成1的标准来做,这样我们设置UIParticle.scale缩放时就是相对1的比例来考虑(这样更符合类似Transform Scale缩放理念的设置),同时这种方式也支持我们去修改UIParticle自身Transform的localScale

Note:

  1. UIParticle.scale无论在任何模式下都会对粒子特效的最终显示大小都有影响
UIParticle遮罩

Mask遮罩,就像常规Mask遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。

ParticleEffectForUGUIMaskInspector

ParticleEffectForUGUIMaskShowResult

我们结合Mask遮罩来看看UIParticle是如何实现粒子特效支持Mask显示的。

首先看看粒子特效在FrameDebugger里是如何绘制的:

ParticleEffectForUGUIMaskFrameDebugger

可以看到粒子特效这一次没有在被当做3D对象单独绘制而是在Canvas.RenderSubBatch流程里绘制且绘制时机是在unmaskMaterial清除模板缓存值之前,我想这就就是为什么UIParticle绘制的粒子特效为什么能支持UI Mask和RectMask2D的原因。

那么UIParticle是如何实现将UI粒子特效当做UI对象在Canvas里绘制的了?

首先UIParticle是取消了ParticleSystem的Renderer组件激活(避免3D粒子特效的渲染)

ParticleEffectForUGUICancelPSRenderer

然后我们来看看UIParticle.cs的代码定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// <summary>
/// Render maskable and sortable particle effect ,without Camera, RenderTexture or Canvas.
/// </summary>
[Icon("Packages/com.coffee.ui-particle/Editor/UIParticleIcon.png")]
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
[RequireComponent(typeof(CanvasRenderer))]
public class UIParticle : MaskableGraphic, ISerializationCallbackReceiver
{
******

private readonly List<UIParticleRenderer> _renderers = new List<UIParticleRenderer>();
private Camera _bakeCamera;

protected override void UpdateMaterial()
{
}

/// <summary>
/// Call to update the geometry of the Graphic onto the CanvasRenderer.
/// </summary>
protected override void UpdateGeometry()
{
}

******
}

可以看到UIParticle组件添加时强制添加了CanvasRenderer组件的,同时UIParticle继承了MaskGraphic。

CanvasRenderer组件是UIParticle融入到Canvas渲染流程里的必要组件

MaskGraphic是UIParticle获取Canvas UI渲染能力和遮罩支持的关键

UIParticle通过重写MaskGraphic.UpdateMaterial和MaskGraphic.UpdateGeometry方法避免任何渲染和材质相关的逻辑。UIParticle真正的渲染逻辑是通过**UIParticleRenderer(也继承至MaskableGraphic)**类完成的,也就是UIParticle._renders这个成员对象。

核心渲染流程方法是UIParticle.UpdateRenderers(),UIParticle.UpdateRenderers()方法是在UIParticleUpdater.cs里通过注入Canvas.onAfterCanvasRebuild(在Canvas渲染前调用)流程调用的

UIParticle.UpdateRenderers()会先收集之前通过UIParticle面板Refresh按钮搜集到的所有粒子特效,创建相关UIParticleRenderer对象(这里就是UIParticle._renders构建的地方)

然后UIParticle通过GetBakeCamera()方法获取到当前绘制Canvas的UICamera,然后UICamera传递调用UIParticleRenderer.UpdateMesh(BakeCamera)方法

UIParticleRenderer.UpdateMesh()方法里,通过调用ParticleSystemRenderer.BakeMesh()方法传入指定摄像机,将通过该UICamera渲染的Mesh保存到CombineInstance类数据里

然后将所有烘焙得到的CombineInstance的Mesh数据合并到一个mesh里(workerMesh.CombineMeshes())

最后将烘焙的bakeMesh数据设置到UIParticleRenderer.canvasRenderer.SetMesh()的Mesh里,然后UIParticleRenderer通过canvasRenderer参与到Canvas的绘制流程里,从而实现了粒子特效转换成UI Mesh参与到Canvas绘制流程里的过程。

那么UIParticleRenderer是如何实现支持UGUI的Mask和RectMask2D遮罩功能的了?

UIParticleRenderer.GetModifiedMaterial()里调用了base.GetModifiedMaterial(),接下来让我们看看内部的代码实现:

MaskableGraphic.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// <summary>
/// See IMaterialModifier.GetModifiedMaterial
/// </summary>
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;

if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

// if we have a enabled Mask component then it will
// generate the mask material. This is an optimisation
// it adds some coupling between components though :(
Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
return toUse;
}

可以看到,通过获取当前节点的最上层Canvas获取到最近的m_StencilValue值,然后通过判定是否有Mask组件决定是否要触发StencilMaterial的材质球效果(Stencil ID值为(1<< m_StencilValue) - 1),StencilOp值为0(Keep),StencilComparison值为3(Equal),Stencil ReadMask值为(1 << m_StencilValue) - 1),Stencil WriteMask值为0)。也就是与模板Stencil缓存值相比一致就通过模板测试的maskMat。个人认为这也就是UIParticleRenderer支持Mask的原因。

那么UIParticle又是如何实现RectMask2D的遮罩支持的了?

核心是RectMask2D会在RectMask2D.PerformClipping()里遍历访问MaskableGraphic,并调用MaskableGraphic.SetClipRect(),让我们来看看MaskableGraphic.SetClipRect()的代码:

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// See IClippable.SetClipRect
/// </summary>
public virtual void SetClipRect(Rect clipRect, bool validRect)
{
if (validRect)
canvasRenderer.EnableRectClipping(clipRect);
else
canvasRenderer.DisableRectClipping();
}

可以看到这里应该是通过canvasRenderer.EnableRectClipping()成功将Shader里的UNITY_UI_CLIP_RECT的Shader关键词激活了,后续应该是设置并传入了Shader里_ClipRect变量的裁剪区域值。

RectMask2D遮罩,就像常规RectMask2D遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。

ParticleEffectForUGUIRectMask2DInspector

ParticleEffectForUGUIRectMask2DShowResult

至此我们成功通关ParticleEffectForUGUI插件实现了特效在UI里的Mask和RectMask2D遮罩显示。

UIParticle层级

通过ParticleEffectForUGUI制作的特效,在处理UI层级问题时可以直接向处理UI节点一样,只要保证节点顺序就能保证显示层级。

ParticleEffectForUGUILayerInspector

ParticleEffectForUGUILayerShowResult

可以看到我制作的白色粒子特效夹在UI中间显示,绿色粒子特效在最顶层显示正确显示了

UIParticleAttractor

UIParticleAttractor脚本是ParticleEffectForUGUI提供的一个队粒子做额外动画的组件。

比如指定粒子按半径的方式移动到目标UIParticleAttrctor所在为止。

UIParticleAttractorInspector

UIParticleAttractorShowResult

可以看到粒子在运行指定延迟时间后按目标终点以指定路线方式(e.g. 直线,曲线等)移动。

效果挺神奇的,可以看出ParticleEffectForUGUI对粒子的控制做到了单个粒子级别。

RectMask2D组件

RectMask2D是通过CanvasRenderer的裁剪矩形(ClipRect)实现,底层是CPU裁剪顶点。

标准ParticleSystem渲染在Camera空间,走的是Renderer,不是UGUI的CanvasRenderer。

从上面的介绍可以看到正常流程粒子特效没走CanvasRenderer,是无法被RectMask2D裁剪的,如果想支持粒子被RectMask2D裁剪,有两种方案:

  1. 将粒子特效重定向到CanvasRenderer里去渲染
  2. 手动写代码将RectMask2D的裁剪区域传递到粒子特效Shader里去,然后写Shader代码去判定裁剪显示

将粒子特效重定向到CanvasRenderer里去渲染

此方案直接学习ParticleEffectForUGUI组件,参考前面的ParticleEffectForUGUI学习

手动传递RectMask2D裁剪区域到粒子Shader

第二个方案参考:

Rect Mask2D遮住特效

Particle Add Mask.shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Shader "Custom/Legacy Shaders/Particles/Additive Mask" {
Properties {
******

// #### required for RectMask2D ####
_ClipRect ("ClipRect", Vector) = (0,0,0,0)
_UNITY_UI_CLIP_RECT("UNITY_UI_CLIP_RECT", float) = 0
}

Category {
******

SubShader {
Pass {

******

// #### required for RectMask2D ####
#include "UnityUI.cginc"

// #### required for RectMask2D ####
float4 _ClipRect;
float _UNITY_UI_CLIP_RECT;

sampler2D _MainTex;
fixed4 _TintColor;

struct appdata_t {
float4 vertex : POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f {
******

// #### required for RectMask2D ####
float4 worldPosition : TEXCOORD1;

UNITY_FOG_COORDS(2)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD3;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};

float4 _MainTex_ST;

v2f vert (appdata_t v)
{
******

// #### required for RectMask2D ####
o.worldPosition = mul(unity_ObjectToWorld, v.vertex);

#ifdef SOFTPARTICLES_ON
o.projPos = ComputeScreenPos (o.vertex);
COMPUTE_EYEDEPTH(o.projPos.z);
#endif
o.color = v.color;
o.texcoord = TRANSFORM_TEX(v.texcoord,_MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;

fixed4 frag (v2f i) : SV_Target
{
******

// #### required for RectMask2D ####
//等效于 if(_UNITY_UI_CLIP_RECT == 1) col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
col.a *= pow(UnityGet2DClipping(i.worldPosition.xy, _ClipRect) + 1, _UNITY_UI_CLIP_RECT) - _UNITY_UI_CLIP_RECT;

return col;
}
ENDCG
}
}
}
}

Note:

  1. 注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
  2. 因为粒子特效默认不是在UI的CanvasRenderer模式下渲染的,所以__ UNITY_UI_CLIP_RECT和__ UNITY_UI_ALPHACLIP宏默认是不是起作用的

ParticleClip.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

/// <summary>
/// ParticleClip.cs
/// 用于粒子系统遮罩裁剪
/// </summary>
public class ParticleClip : MonoBehaviour
{
/// <summary>
/// RectMask2D裁剪区域
/// </summary>
[Header("RectMask2D裁剪区域")]
public Vector4 ClipRect;

/// <summary>
/// 子Renderer组件列表
/// </summary>
private Renderer[] mChildRenderers;

void Start()
{
var mask = GetComponentInParent<UnityEngine.UI.RectMask2D>();
if (mask == null)
{
return;
}

mChildRenderers = GetComponentsInChildren<Renderer>();
if(mChildRenderers == null || mChildRenderers.Length == 0)
{
return;
}

Vector3[] vector3s = new Vector3[4];
mask.rectTransform.GetWorldCorners(vector3s);
ClipRect = new Vector4(vector3s[0].x, vector3s[0].y, vector3s[2].x, vector3s[2].y);

foreach (var renderer in mChildRenderers)
{
var rendererMaterial = renderer.material;
if(rendererMaterial == null)
{
continue;
}
if(rendererMaterial.HasVector("_ClipRect"))
{
rendererMaterial.SetVector("_ClipRect", ClipRect);
}
if(rendererMaterial.HasFloat("_UNITY_UI_CLIP_RECT"))
{
rendererMaterial.SetFloat("_UNITY_UI_CLIP_RECT", 1);
}
}
}
}

RectMask2DAndParticleClipInspector

ParticleClipAdditiveMaskShaderInspector

可以看到通过ParticleClip.cs脚本,我成功将RectMask2D的裁剪区域数据和是否启用UNITY_UI_CLIP_RECT的数据传递到了Particle Add Mask.shader,让我们看看最终效果:

RectMask2DAndParticleClipShowResult

可以看到通过传递自定义RectMask2D裁剪区域以及粒子特效Shader支持裁剪区域显示判定,我们成功实现了支持RectMask2D的粒子特效遮罩显示

总结:

  1. 此方案虽然能实现粒子特效的RectMask2D的遮罩效果,但节点挂载情况无时无刻都在变化,此方案不适合放到实战里去运用,只做为学习理解RectMask2D的裁剪原理

学习总结

  1. Mask的底层遮罩原理是使用了模板缓存机制
  2. RectMask2D的底层遮罩原理是利用C#层计算裁剪区域,然后传递给Shader开启UNITY_UI_CLIP_RECT进行裁剪区域显示判定
  3. 粒子特效默认是当做3D物体渲染,不参与Canvas Renderer流程,ParticleEffectForUGUI是通过将粒子特效自定义绘制到MaskableGraphic的UIParticleRenderer组件从而实现的粒子特效参与到Canvas Renderer以及支持Mask和RectMask2D遮罩的

Github

ParticleEffectForUGUI

RenderOrderAndUIMask

Reference

走进 Stencil Buffer 系列 0 : 模板缓冲和模板测试是什么?

模板测试(Stencil Test)基本概念

ShaderLab: Stencil Buffer 的理解和应用

Unity 粒子特效在模型前面显示 unity ui粒子特效

UI上的特效的裁剪问题

解决Unity UI粒子缩放异常:ParticleEffectForUGUI的Scale参数完全指南

前言

AI编程工具盛行,学习AI编程工具的使用和底层原理是很有必要的,在AI工具通过多方面考量和对比市面上的AI编程工具,决定选择Copilot作为深入AI编程学习的切入口,本章节是为了详细记录AI编程工具的使用和深入学习AI编程相关概念。

Copilot

GitHub Copilot是GitHub和OpenAI合作开发的一个人工智能工具,用户在使用Visual Studio Code、Microsoft Visual Studio、Vim、Cursor或JetBrains集成开发环境时可以通过GitHub Copilot自动补全代码。Copilot的OpenAI Codex接受了一系列英语语言、公共 GitHub 存储库和其他公开可用源代码的训练。这包括来自 5400 万个公共 GitHub 存储库的 159 GB Python 代码的过滤数据集。

从介绍可以看出Copilot主打的是基于代码的学习,提供代码AI自动补全的功能。

Copilot是微软旗下的,所以对Visual Studio,VS Code等微软IDE支持比较完善,下载VS时就自带集成Copilot。

支持基于Github大量代码学习的代码智能推断补全,支持Agent聊天问答,支持多个模型选择,支持代码Review等功能。

优势:

  1. 不用切换IDE,直接嵌入各大主流IDE
  2. 微软官方支持且基于Github的大量代码知识

Note:

  1. Copilot基础版免费,Pro版需要付费购买(10美元每月-个人推荐这个),Pro+(39美元每月)[https://visualstudio.microsoft.com/zh-hans/vs/pricing/?tab=paid-subscriptions])。

Copilot安装

  1. 官方下载带Copilot的Visual Studio 2022[https://visualstudio.microsoft.com/github-copilot/]

  2. 新建工程

  3. 右上角点击Github Copilot->打开聊天框

CopilotEntry

Note:

  1. Ctrl+Alt+L快捷键打开VSCode Copilot Chat

Completions

敲击代码,Copilot会根据代码智能推断即将编写的代码,敲击tab应用智能推断的代码

IntelligentPrompt

敲击注释,Copilot也会根据注释去推断即将编写的代码,敲击tab应用智能推断的代码

IntelligentPromptFromNotation

只接受一部分Copilot智能推断的代码,Windows上Ctrl+→按键

AcceptPartOfIntelligentPrompt

决定不启用智能推断的代码,点击Esc按键

Prompt

**Prompt(提示词)**可以帮助AI更加准确的理解我们的需求,给出更更准确的代码推断和需求编写。

前面的Completions提到注释描述给AI提示,以及Chat中提到针对代码片段的提问(Alt+I),在提问时我们通过自然语言准确描述需求比泛化的描述可以更加准确的让AI理解我们给出的指令,得到更准确的目标结果。

泛化的Prompt,比如注释描述”// Some Code”

此时无论是AI还是人其实都无法理解我们具体想做什么,所以AI并没与给出任何有效的代码推断

BadPrompt

好的Prompt,比如注释描述”// define a function that is used to compare to int value”

GoodPrompt

可以看到好的提示词成功让AI理解了需求并给出了准确的推断编写代码。

Chat

聊天模式氛围Ask Mode和Agent Mode

聊天模式切换:

AgentModeChosen

Ask Mode

AI提问模式交互,打开Copilot后,右下方有聊天窗口

CopilotInteraction

点击代码块右上方的应用则可以将Copilot生成的代码生成到我们的代码里

AICodeApply

在代码区点击**放弃(Alt+Del)**则可以放弃方才应用的代码

AICodeGiveUp

代码块交互聊天,选中需要询问的代码,按Alt+/

CodeSnippetChat

交互聊天有一系列的快捷操作指令

输入@会看到几个大方向或内容给AI作为参考

AtQuickCommand

输入#会看到可以在询问前添加相关文件作为给AI的参考文件

NumberSighQuickCommand

输入/会看到有内置支持的快捷代码操作指令(比如fix,optimization……)

SlashQuickCommand

想引入更多复杂类型的资源作为AI参考文件,可以通过+添加:

AddReferenceAttach

自动以快捷交互指令详情参考:

Customize chat responses and set context

Agent Mode

With the GitHub Copilot agent mode in Visual Studio, you can use natural language to specify a high-level task. AI creates a plan, makes code edits, runs terminal commands, invokes tools, and applies changes across your codebase. It monitors outcomes, such as build results, unit-test failures, or tool outputs, and iterates as needed.

Unlike ask mode, agent mode doesn’t stop after a single response. It continues running and refining steps until you reach the goal in your prompt or more input is required.

从介绍可以看出Copilot Agent Mode可以让我们通过自然语言区描述高阶任务需求,让AI使用特定工具自动化完成高阶任务需求。

Agent Mode工具选择:

AgentModeToolsChosen

比如指挥Copilot调用指定方法并应用代码。

FirstAgenModeUsing

相比Ask Mode生成的代码我们要选择应用,Agent Mode已经通过我的指令自动应用到了Program.cs文件里,同时Agent Mode还通过调用编译进行了生成代码的对错验证(工具自动化的使用是和Ask Mode的最大区别)。

Agent Mode可以通过选择保留和放弃去确认生成代码的去留。

当成功应用后,Copilot通过创建还原点,可以实现快速的放弃之前的操作

RevertAIAction

Note:

  1. 需要使用一些列工具进行自动化的操作推荐用Agent Mode
  2. 需要使用MCP Servers功能,必须使用Agent Mode而非Ask Mode

MCP

Model Context Protocol (MCP) is an open standard that enables AI models to interact with external tools and services through a unified interface.

Using MCP, AI applications like Claude or ChatGPT can connect to data sources (e.g. local files, databases), tools (e.g. search engines, calculators) and workflows (e.g. specialized prompts)—enabling them to access key information and perform tasks.

从介绍可以看出MCP是AI模型和外部工具交互的标准。

如果想通过外部工具扩展Agent Mode工作流,可以通过MCP引入外部工具。

MCPWorkFlow

通过调用外部工具和数据,我们就能实现更多的功能,无论是简单的周报总结,自动化测试,2D或3D模型资源生成等都成为了可能。

简单了解下MCP里的核心组成部分:

简单了解下MCP的分层设计:

MCP的详细设计这里暂时不深入追究,暂时知道MCP是一套通用的AI模型嗯哼外部工具交互的标准即可。

详情参考:

Use MCP servers

因为本人只买了Copilot Pro,没有买Claude Code付费版本,所以接下来MCP和Agent Skills都已Copilot使用学习说明。

Extending Copilot MCP Servers

查看可用的MCP servers repository:

Model Context Protocol servers

支持搜索支持的MCP servers repository:

Official MCP Registry

给VS Codo配置MCP servers要求VS Code版本大于1.99(感觉官方版本写错了,目前最新才1.108–2026/1/12)

MPC servers配置有两种方式:

  1. Using GitHub MCP Registery(这种方式只有MCP servers在MCP Registery的才能配置)
  2. Configuring MCP servers manually(这种方式可以自由配置MCP Server)
Using GitHub MCP Registery

使用GitHub MCP Registery步骤:

  1. 打开VSCode

  2. ctrl+shift+x打开插件界面

  3. 过滤选择里选择MCP Register

    VSCodeMCPFilter

  4. 然后直接再搜索里搜索想找的MCP Server,如果是第一次打开还需要先开启MCP Servers MarketPlace

    EnableMCPServersMarketplace

  5. 接下来搜索一个想找的MCP Server

    VSCodeCopilotGitHubMCPRegisteryInstall

  6. 这里点击Install即可安装对应MCP Server

这里我尝试安装MarkDown MCP Server但运行失败了:

  • 发现Markitdown MCP Server安装后启动失败了,原因是uvx安装没成功

    MarkitdownMCPServerInstallFailed

  • 我们通过uv官网的教程,在PowerShell里输入一下命令安装uvx:

    “powershell -ExecutionPolicy ByPass -c “irm https://astral.sh/uv/install.ps1 | iex””

    InstallUvxInPowerShell

    安装后需要自己设置uvx的环境变量,按安装后的提示输入命令或手动添加环境变量即可,后续VSCode里还是找不到注意重新打开VSCode。

    • 安装Uvx成功后,发现Markitdown还是运行不起来,于是我让Copilot Agent帮我分析原因,它通过调用”uvx markitdown –help”命令把所有相关的Python库都下载下来了。

    UvxPythonInstallation

  • 但最后依然运行不成功Markitdown,Copilot分析的结论是Python版本不对,为避免多个Python版本安装后出问题,这里不再继续了。

    UvxStartPythonVersionProblem

接下来以安装GitHub MCP Server为例:

  1. 打开插件(ctrl+shift+x)

  2. 设置过滤为MCP Server

  3. 搜索GitHub

  4. 点击安装

  5. 输入GitHub Authentication token(在Github个人设置页面->Developer Settings->Tokens)

    GitHubPersonalAuthenticationTokenInput

  6. 在Copilot Chat下方打开MCP配置工具查看GitHub MCP Server配置的工具结合

    CopilotConfigureTools

    可以看到GitHub MCP Server已经添加成功,相关工具也支持在Agent模式下通过GitHub MCP Server选择使用了。

  7. 接下来验证下通过Copilot+GitHub MCP Server我们是否能快速通过AI操作Github仓库相关东西

    CommitReadMeFileByGitHubMCPServer

    可以看到我们成功通过给Copilot输入命令,通过GitHub MCP Server的相关工具将README.md文件成功自动上传到了GitHub对应仓库。

Configuring MCP servers manually

VSCode支持两种MCP手动配置方式:

  1. .vscode/mcp.json用于给当前VSCode指定所有人能用的MCP服务器配置信息
  2. %APPDATA%/Code/User/settings.json里配置当前电脑通用的MCP服务器配置信息(但实际上我发现VSCode扩展里安装的好像是配置在%APPDATA%/Code/User/mcp.json文件)

这里重点学习下第一种给工程手动配置MCP Server的方式

  1. 在工程.vscode目录下新建mcp.json文件

  2. 打开mcp.json文件,点击Add Server按钮(VSCode提供了几种安装MCP的方式)

    WorkspaceMCPServerAdd

    VSCodeMCPInstallationMethods

  3. 这里使用手动添加mcp.json描述的方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "servers": {
    "filesystem": {
    "command": "npx",
    "args": [
    "-y",
    "@modelcontextprotocol/server-filesystem",
    "${workspaceFolder}"
    ]
    }
    }
    }
  4. 添加配置后会在对应Servers名字上方看到启动MCP Server的按钮

    StartFileSystemMCPServer

  5. 打开Copilot的Configure Tools可以看到我们成功运行FileSystem MCP Server后相关工具都被使用了

    FileSystemTools

  6. 接下来通过Copilot进行验证FileSytem MCP Server被正确安装和使用(将.vscode/mcp.json和***/User/mcp.json作为上下文文件进行提问)

    CreateMCPServerIntroductionByFileSystem

    MCPMarkDown

    MCPServerMarkdownContent

    上面这个例子因为有一些默认工具支持了目录的创建和文件的创建读写,导致并没有直接使用FileSystem MCP Server的相关工具

  7. 显示指定使用create_directory工具创建一个FileSystemCreatedDirectory目录

    CreateDirectoryByUsingFileSystemMCPServerTool

    FileSystemCreatedDirectory

    可以看到通过#显示的指定工具名,我们成功使用了FileSystem MCP Server的create_directory工具创建了名为FileSystemCreatedDirectory目录。

Note:

  1. 官方推荐相同MCP Server只在全局或本地一处添加,避免冲突
  2. .vscode/mcp.json当前VSCode级别的MCP也可以通过浏览扩展里的MCP进行安装
  3. MCP Server无需一开始就启动,当需要触发相关工具时,对应MCP Server会自动启动
  4. Copilot CLI和VS Code Copilot是独立的工具,他们不会共享MCP Server配置

Coding Agent

With Copilot coding agent, GitHub Copilot can work independently in the background to complete tasks, just like a human developer.

从介绍可以看出Coding Agent可以用于像人工一样自动化完成任务。

Copilot coding agent is distinct from the “agent mode” feature available in your IDE. Copilot coding agent works autonomously in a GitHub Actions-powered environment to complete development tasks assigned through GitHub issues or GitHub Copilot Chat prompts, and creates pull requests with the results. In contrast, agent mode in your IDE makes autonomous edits directly in your local development environment.

相比Agent Mode,Coding Agent能独立于IDE在Github环境下运行完成任务。

更多了解:

TODO

Note:

  1. Coding Agent是付费功能(Pro或Pro+等)

Copilot Skills

TODO

多模型选择

​ 打开Copilot聊天框正下方有底层AI模型选择

​ ChangeAIModel

Code Review

通常合作开发时,提交代码时代码review以往都是人工查看的,有了AI工具,Code Review也能实现AI自动化分析。

Visual Studio Code Review步骤:

  1. Github创建不同分支并推上去

  2. 在对应分支进行修改并提交

  3. 在Github上或者Git发出Pull Request请求

    GithubPullRequestOperation

  4. 在Github上Pull Request操作发起Copilot Review请求

    CopilotReviewRequest

  5. 等待AI Copilot Review完成则可看到AI Review结论

    CopilotReviewResult

Note:

  1. Code Review是付费功能(Pro或Pro+等)

Copilot CLI

The command-line interface (CLI) for GitHub Copilot allows you to use Copilot directly from your terminal.

Copilot CLI允许通过命令行界面使用Copilot,这一点很像Claude Code。

Copilot CLI安装:

  1. 打开PowerSheel(Windows)

  2. 输入 winget install GitHub.Copilot

    CopilotCLIInstallation

Copilot CLI使用:

  1. 在需要Copilot CLI访问的目录下打开PowerShell输入Copilot命令

    StartCopilotCLI

  2. 选择选项1(Yes)–表明信任Copilot CLI对当前目录的修改

  3. 输入/login(输入后按照操作流程一步一步登录账号即可) – 登录Github账号开始使用Copilot(本人买的Copilot Pro)

    LoginCopilotCLI

  4. 在Copilot里输入/help就可以像常规命令行一样看到所有可使用的命令介绍了

    HelpCommand

  5. 可以看到如果AI模型没选对,我们可以输入/model进入模型切换(部分AI模型需要Github Copilot后台开启才能使用)

    ChangeAIModel

  6. 提交Git采用人类语言描述即可

    CopilotCLIGitPush

    上传失败是因为命令行还没有配置SSH秘钥,这里需要安装OpenSSH Client

    PowerShell里输入”Get-Service ssh-agent”确认开启ssh-agent服务,如果未开启,输入”Start-Service ssh-agent”,如果开启报错,输入”Set-Service ssh-agent -StartupType Automatic”将ssh-agent的开启方式设置成自动,然后输入”ssh-add”输入SSH的秘钥即可

    OpenSSHClientAndSshAdd

    然后我们再次测试Copilot的Git上传,发现还是上传不了,询问Copilit说是Copilot CLI默认使用的不是SSH KEY而是HTTPS,需要安装GitHub CLI(输入”winget install –id GitHub.cli -e”命令安装)然后使用gh相关命令(输入”gh auth login”命令登录)登录GitHub授权(注意网页版通过授权,网页版要求填的码需要Ctrl+R或者Ctrl+O打开Copilot的详情信息才能看到)

    GitHubCLILogin

    然后我们再次测试Copilot的Git上传,这次终于通过Copilot上传GitHub成功了

    CopilotGitHubCommitAndPush

Copilot CLI使用详情参考:

Using GitHub Copilot CLI

命令行查看Token使用情况:

1
/context

CopilotContextInfo

命令行查看CLI所有操作介绍:

1
/help

CopilotHelpInfo

Note:

  1. Copilot CLI是付费功能(Pro或Pro+等)
  2. 部分功能可能要求PowerShell版本大于6,需要自己去装一个最新的PowerShell版本
  3. OpenSSH Client的安装和SSH配置要用管理员身份打开PowerShell(版本大于6)
  4. Copilot等待操作时,Ctrl+C是退出,Ctrl+R是打开最近输出,Ctrl+O是打开所有输出,Ctrl+T是显示原因
  5. 如果不希望Copilot每次关键时候询问是否继续,可以输入–allow-all-tools允许所有工具自动执行,或者–allow-tool允许特定工具执行,或者–deny-tool阻止特定工具执行

Copilot CLI MCP Server

Copilot CLI和VS Code Copilot的MCP Server是独立添加的,但添加MCP Server的逻辑和VSCode Copilot MCP Server添加是相同的,只是保存的目录位置不同(目录C:/User/UserName/.copilot/mcp-config.json)

Copilot CLI里添加MCP Server是通过输入”/mcp add“命令:

CopilotCLIAddMCP

Server Name – 取一个本地添加MCP Server的对应名字(必填)

Server Type – MCP Server连接类型(stdio(Standard I/O)多用于本地MCP Server。http(Hypertext Transfer Protocol)多用于网络远程MCP Server。sse(Server-Send Events)http不支持时的一种fallback方案)

Command – 启动MCP Server的执行程序命令(含命令执行参数,本地MCP Server才需要的启动命令)(最后在mcp.json里会自动拆分成command和args两个变量)

Enviroment Variables – 传递个MCP Server的环境变量数据(本地MCP Server才需要)

URL – 远程MCP Server的URL地址(远程MCP Server才需要)

Http Headers – 远程MCP Server请求时带的参数数据(比如GitHub秘钥)

Tools – MCP Server需要激活的工具(本地和远程MCP Server都需要)

参数详细介绍:

MCP

  • 添加MicrosoftLearn MCP Server(输入**/mcp add**然后填充以下信息)

    MicrosoftLearnMCPServerAdd

    MicrosoftLearn MCP Server使用测试:

    MicrosoftLearnMCPServerUsingTest

  • 添加GitHub MCP Server(输入**/mcp add**然后填充以下信息)

    GitHubMCPServerAdd

    GitHub MCP Server使用测试:

    GitHubMCPServerUsingTest

  • 添加FileSystem MCP Server(输入**/mcp add**然后填充以下信息)

    FileSystemMCPServerAdd

    FileSystem MCP Server使用测试(Copilot CLI里无法通过描述使用stdio本地添加的MCP Server工具,只能直接指明使用):

    FileSystemMCPServerUsingTest

Note:

  1. Copilot CLI的MCP Server添加时,不是所有的MCP Server都通过Http方式远程添加,也可以通过stdio本地添加的方式。
  2. stdio本地添加的方式不能直接通过Copilot CLI描述触发,需要直接输入”@MCP名.工具名 参数”的方式直接触发
  3. Copilot CLI里@除了指明用某个文件,还能@MCP名.工具名 参数的方式指定使用MCP的工具
  4. 所有Copilot CLI需要访问的目录必须确保授权访问,除了默认打开所在目录和/Local/Temp目录默认授权外,其他目录都要通过/add-dir进行授权添加*
  5. 通过/add-dir添加授权访问的目录暂时没有命令清除,只能通过重新开Copilot Session实现重置

Copilot付费

Copilot有多个付费模式:

Free – **只允许使用优先功能(50次Agent Mode或Chat每月,2000次智能代码提示补全每月等)

Pro – 无限次Agent Mode或Chat每月,无限次智能代码补全提示,更多AI模型选择,Code Review,Coding Agent等功能

Pro+ – 相比Pro更多的AI模型选择等

Copilot付费支持的Visa卡好像要要求支持跨境汇款,PayPal这个我个人没有。

不同付费模式功能详情参考:

Github-copilot

Github地址

无

Reference