标签归档:Unity3D

macOS 安装配置 Jenkins 持续集成 Unity 项目指南

安装 Java 和 JDK

由于 Jenkins 是一个基于 Java 实现的项目,所以我们需要先确保我们的 macOS 上安装有 Java,直接从 Oracle 的官方网站上下载安装即可。

安装完成之后,理论上咱们就可以安装 Jenkins 了,但是考虑到咱们实际上使用 Jenkins 来进行持续集成的目的是让其自动将 Unity 工程打包成 Android 平台和 iOS 平台的安装包,而 Android 工程的编译又依赖于 JDK,所以我们还需要安装一个 JDK,同理也可以直接从 Orcale 的官方网站上下载后安装。

安装完成之后在命令行终端中输入一下 java -version 命令,输出内容类似:

java version “1.8.0_91”
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

安装 Jenkins

从 Jenkins 官网下载适用 macOS(截止写这篇文章的时候,Jenkins 官网上依然写的是 Mac OS X)的 pkg 文件,我下载的是 LTS 版本 2.19.2。双击一步一步安装完成即可,安装完成之后执行一下 defaults read /Library/Preferences/org.jenkins-ci 命令,输出内容如下:

{
“JENKINS_HOME” = “/Users/Shared/Jenkins/Home”;
heapSize = 512m;
httpListenAddress = “192.168.200.99”;
httpPort = 8080;
minHeapSize = 256m;
minPermGen = 256m;
permGen = 512m;
tmpdir = “/Users/Shared/Jenkins/tmp”;
}

这个就是 Jenkins 的各项启动参数,从以上输出的参数就得到了 Jenkins 的后台管理页面的地址:http://192.168.200.99:8080 ,在浏览器中输入该地址之后应该能看到如下的页面:
jenkins_dashboard

当然默认肯定是没有任何构建项目的,截图中已有的 3 个项目是我添加的,到这一步就意味着我们已经将 Jenkins 安装好了,而且使用 pkg 文件安装的方式会帮我们把 macOS 开机自启动 Jenkins 服务的工作都做好,省去了自己去折腾 LaunchDaemons 相关的各种配置的麻烦,不过也有其他的问题,咱们一个个来解决。接下来,我们需要安装一些插件,或者更新一些插件。

安装 Git 插件

通过 Jenkins Dashboard 页面中系统管理菜单项进入 Jenkins 管理页面,点击管理插件选项即可进入插件管理页面,找到在右上方的过滤输入栏中输入 Git 筛选一下跟 Git 相关的插件,找到自己需要的插件勾选,然后点击安装即可,等插件下载安装完成之后,按照页面的提示可以重启一下 Jenkins 让这些新安装的插件生效。

设置 Credentials

由于我们团队使用 Git 对 Unity 项目进行版本管理,所以我们需要设置一个 Credentials 能让 Jenkins 中的 Git 插件可以用来与 Git 服务器进行验证和交互。还是通过 Jenkins Dashboard 页面的 Credentials 菜单项进入管理页面,然后新建一个 Credentials,我选择的是 SSH Username with private key 的方式,输入 Username(例如安装 Jenkins 的机器当前登录的用户名为 developer,那么就填入 developer),Private Key 选择Enter directly(因为其他的两个方式不知道 Jenkins master 具体指的是啥,也没有帮助没有设置成功,最终只能选择直接输入 private key 的方式了),确保当前登录的用户目录下有 .ssh 目录并且已经有 id_rsa 和 id_rsa.pub 文件(如果没有的话,通过 ssh-keygen 生成一个 SSH 的密钥对吧),然后在终端中执行一下 cat ~/.ssh/id_rsa 命令,把输出的内容全部拷贝,粘贴到 Jenkins 的 Private Key 设置输入框中,保存即可。

注意:~/.ssh 目录下的 id_rsa.pub 应该已经被添加到 Git 服务器信任的公钥列表中,例如我们团队中使用的是自己搭建的 GitLab 服务,那么就需要将 id_rsa.pub 中的内容通过 Profile Settings -> SSH Keys 设置页面,将这个 SSH Key 添加到账户的 Profile 中,这样 Jenkins 在使用 Git 拉取代码的时候才能验证成功。

修改用于 Jenkins 服务启动的用户和组

由于 Unity Editor 的限制,如果要在 Jenkins 中调用 Unity Editor 提供的命令行执行 BuildPlayer 方法来完成构建任务的话,必须要通过用户名和密码登入 macOS 系统后再执行构建任务才能正常调用 Unity Editor 的命令行,否则整个构建任务会一直 Hang 住。

这个是在经历了各种各样的坑之后才总结出来的,例如我曾经经历过 Mac 电脑自动更新后重启,未主动输入用户密码登录系统,通过浏览器可以其他电脑上访问 Jenkins Dashboard 并且能查看到所有构建任务的状态,可是所有的构建任务永远都不会完成。这个就是因为 Jenkins 默认在系统启动之后就自动启动了,感谢 LaunchDaemons,所以可以访问 Jenkins Dashboard 页面。但是由于未成功登录账号,Unity Editor 无法正常执行其提供的命令行,最终所有的构建任务全 Hang 住了,通过 GUI 登入用户账号之后恢复正常(通过 Remote Desktop 输入用户的密码登入也行,但是必须登入用户账号)。

这个问题我的理解是这样的,由于 Unity Editor 是在当前登入的用户(developer:wheel)下安装的,所以通过 Jenkins 调用 Unity Editor 的命令行也需要使用当前登入的用户来调用,才有权限能执行 Untiy Editor 的命令行。然而通过 pkg 文件的方式安装的 Jenkins 会自动创建一个名为 jenkins 的用户,所在组也是 jenkins。在 jenkins 用户空间中执行的 Jenkins 服务再调用 Unity Editor 的命令行肯定也是在 jenkins:jenkins 用户空间下执行的,而 jenkins 用户组应该是没有权限调用 Unity Editor 的命令行,所以出现了上面提到的整个构建任务 Hang 住的情况。

如果在通过 pkg 文件安装了 Jenkins 之后不做任何调整,直接配置好构建任务,然后点击 Build Now 直接开始构建,我们会发现前面拉取代码的流程都是正常的,但是 Unity 的图标却一直不显示在 macOS 的桌面 Docker 栏上(我们知道虽然调用 Unity Editor 的命令行不会显示 Unity Editor 的窗口,但是在 Docker 栏上还是会显示 Unity 的图标的,这个我们可以直接执行一下 /Applications/Unity/Unity.app/Contents/MacOS/Unity -batchmode -quit -projectPath [Unity工程路径] -executeMethod [public 的 static 无参方法] 命令来验证一下,详细可以参考 Unity 的命令行使用方法官方文档),所以我们可以确认 Unity Editor 的命令行压根就没有执行,接下来我们就要做些调整来让 Jenkins 成功构建 Unity 工程。

1.修改 LaunchDaemons 目录下 Jenkins 启动配置 plist 文件

执行 sudo launchctl unload /Library/LaunchDaemons/org.jenkins-ci.plist 命令,先将安装完之后默认自动启动的 Jenkins 服务关掉,然后使用文本编辑器打开 /Library/LaunchDaemons/org.jenkins-ci.plist 文件,找到 <key>GroupName</key> 将其下方一行中的 <string>daemon</string> 修改为 <string>wheel</string>,找到 <key>UserName</key> 将其下方一行中的 <string>jenkins</string> 修改为 <string>developer</string>,然后保存该文件。

由于我们没有修改 /Library/LaunchDaemons/org.jenkins-ci.plist 文件中 JENKINS_HOME 的配置,也没有修改 /Library/Preferences/org.jenkins-ci.plist 文件中 JENKINS_HOME 的默认配置,所以目前 Jenkins 还是会使用 /Users/Shared/Jenkins/Home 作为其主目录,使用 /Users/Shared/Jenkins/tmp 作为其临时目录,Jenkins 的日志将会输出到 /var/log/jenkins/jenkins.log 文件。

这些目录和文件在 Jenkins 安装成功之后就都创建出来了,并且都是使用 jenkins:jenkins 用户创建的,但是由于上面我们提到的问题,我们现在需要使用 developer:wheel 这个用户来启动 Jenkins,也就意味着我们需要把 Jenkins 可能用到的所有目录和文件的所有者修改为 developer:wheel 用户,这样 Jenkins 启动的时候才不会出现问题。所以我们需要依次执行以下命令:
sudo chown -R developer:wheel /Users/Shared/Jenkins
sudo chown -R developer:wheel /var/log/jenkins
将相关的目录和文件的归属修改为 developer:wheel 用户,然后再执行一下 sudo launchctl load /Library/LaunchDaemons/org.jenkins-ci.plist 命令,启动 Jenkins 服务,然后在浏览器中输入一下 http://192.168.200.99:8080 测试一下。

2.疑难杂症

如果按照以上的流程进行操作出现了问题,这当然是很有可能的,毕竟每个人的电脑的软硬件环境各不相同,这里就把我自己遇到的一些问题罗列一下,以供参考。

  • 用于启动 Jenkins 的用户跟安装 Unity 的用户不是同一个,导致在 Jenkins 中正确配置了所有的参数后,无法正常编译 Unity 工程,这个问题需要我们保证 Jenkins 跟 Unity 是在同一个用户空间下运行的,详细方法见上一步;
  • 按照上述的步骤安装和设置好了 Jenkins 之后依然无法启动,那么可以尝试查看一下 /var/log/jenkins/jenkins.log 文件中的日志信息,如果启动出错的话,通常错误日志以及相关的异常信息都会输出到这里的,但是如果你看到 jenkins.log 文件中有类似 logfile turned over 之类的字眼的时候,先把 Jenkins 服务关闭,然后把整个 /var/log/jenkins 目录删了再自己手动创建一个 jenkins 目录,在新建的 jenkins 目录下创建一个 jenkins.log 文件,再重新启动 Jenkins 服务就好了;
  • 如果想调整 Jenkins 启动的参数,例如日志文件的位置和 Jenkins 的 Home 目录等等,咱们可以通过文本编辑器编辑 /Library/LaunchDaemons/org.jenkins-ci.plist 文本文件,或者通过 defaults 命令修改 /Library/Preferences/org.jenkins-ci.plist 这个加密的配置文件,详细的教程可以参考 Jenkins 的官方 Wiki。记住这两个文件是有区别的哦,别使用 defaults 命令去修改 /Library/LaunchDaemons/org.jenkins-ci.plist 这个文件哦,否则这个文件被改成加密的 plist 文件后(使用 defaults 命令行貌似编辑后的 plist 文件貌似都是加密的),launchctl 不认识了就得重装 jenkins 了,如果怕操作出错可以在编辑前先备份一下这个文件。

如何正确地使用 Unity3D 中的 Bounds.Encapsulate 方法

在我们的游戏中有个很简单的需求,有一个物体 A 挂有所有控制的脚本,所有点击的处理逻辑代码都在这些脚本里头,在某个时机需要在物体 A 上再挂上一个武器物体 B,武器物体 B 有自己的 BoxCollider,原有的物体 A 也有自己的 BoxCollider,为了简单起见我的想法是当我把物体物体 B 挂到 物体 A 上的同时,我就将 A 物体的 BoxCollider 的 bounds 调整一下尺寸,让其能将物体物体 B 的 BoxCollider 给包含进来就好了,然后我再将武器物体 B 的 BoxCollider 给关闭。

看起来这个需求一点都不复杂呢,对伐?所以我就简单的这样写了以下的代码:

baseCollider.bounds.Encapsulate (skillCollider.bounds);

然而毛用都木有呢,mBaseCollider 在调用了 Encapsulate 方法之后,自身的尺寸并没有发生任何变化,当然前提肯定是 mSkillCollider 自身的 bounds 肯定不在 mBaseCollider 的 bounds 范围内的,那么问题是什么呢?

一开始,我以为是 mSkillCollider 自身的 GameObject 当前不是 Active 的导致了获取到的 bounds 是空的,毕竟在 Unity3D 的官方文档里头有这么一段嘛:

Collider.bounds

public Bounds bounds; 

Description
The world space bounding volume of the collider.

Note that this will be an empty bounding box if the collider is disabled or the game object is inactive.

可是试了很多次依然不行,最后发现 Bounds 这货只是一个结构体,而且 Unity3D 对它的处理跟对 Transform 的 Position 的处理类似,而且更严格。首先我们不能直接修改 Bounds 结构体里头的 center 和 size 属性都不能直接设置,而且 BoxCollider 的 bounds 属性也不能直接设置。

由此我们可以推断 BoxCollider 的 bounds 属性每次调用都是复制了一个 Bounds 结构体出来,所以后续我们调用 Encapsulate 方法实际上操作的 Bounds 结构体已经跟 BoxCollider 自身没有关系了,这样就造成了我们即便调用了 Encapsulate 方法,BoxCollider 自身的 Bounds 尺寸并无任何变化的原因。如果我们想修改 BoxCollider 的 Bounds 尺寸,那么我们就应该是先获取 BoxCollider 的 Bounds 存起来,然后针对这个 Bounds 结构体进行操作,操作结束后,将该 Bounds 的数值反算回去,将反算出来的数值设置给 BoxCollider。

最终的代码就是这个样子的

Bounds bounds = baseCollider.bounds;
bounds.Encapsulate (skillCollider.bounds);
baseCollider.bounds.SetMinMax (bounds.min, bounds.max);
BoxCollider boxCollider = baseCollider as BoxCollider;
boxCollider.center = boxCollider.transform.InverseTransformPoint (bounds.center);
boxCollider.size = bounds.size;

测试一切正常了,BoxCollider 如我所愿地调整了自身的尺寸,并将 skillCollider 的 bounds 也给包围住了。

Unity3D Editor 中加载移动平台的 AssetBundle 资源显示出错的解决方法

注意:本文的测试环境为 Mac OSX 、Unity 4.6.9,Unity 5 在 AssetBundle 上做了诸多调整,因未实际测试不保证测试效果会相同。


在 Unity3D 项目开发的过程中,我们肯定会遇到需要使用 AssetBundle 的时候,而且这货还确实应用之处满多的,今天咱们不展开聊 AssetBundle 能干嘛了 ,咱们把重点放到 Unity Editor 加载移动平台的 AssetBundle 资源之后,显示出现错误的问题。我们直接来看一下对比图,快速了解一下我们要解决的问题:

asset bundle-miss-shader assetbundle-with-shader

两个效果图一对比,我们马上就明白了,我们碰到的问题是左侧这个显示成粉色/紫色的这个图片呈现出来的,而右侧这个图呈现的就是我们想要的效果。

第一步,让我们来明确这个问题的定义:

  1. 首先需要说明的一点,这个问题只在 Unity Editor 下出现,在 iOS 和 Android 平台的设备上运行并不会出现这个问题,也就是该问题不影响游戏在移动设备真机上运行,只是让我们在开发的过程中感到很迷惑和无奈;
  2. 对于 Unity 系统 built-in 的那些 Shader 也不会出现这个问题,只针对我们自行编写的 Custom Shader 会出现这个问题;

第二步,让我们来确定如何重现这个问题,通常找到重现问题的方法也就找到了问题的根源所在了:

  1. 新建一个 Custom 的 Shader,咱们可以直接把 Unity 官方提供的 Shader 源码包给下载下来,然后直接修改其中的 Diffuse Shader,咱们不做任何其他改动,就简单的改两个 Property 的 Name 就好了;
  2. 将新建的 Custom-Diffuse Shader 应用于场景中某个 3D 对象的 Material 上,然后将该对象保存为 Prefab 文件;
  3. 将该 Prefab 文件打包成 AssetBundle 资源文件,打包选项为 BuildAssetBundleOptions.CollectDependencies|BuildAssetBundleOptions.CompleteAssets,编译目标设置为 Android 或者 iOS ;
  4. 新建一个空的 Unity 工程,将上一步中打包出来的 AssetBundle 文件放到 Assets/StreamingAssets 目录下,然后通过 WWW 或者通过读取文件内容字节数组后创建 AssetBundle,然后将该 AssetBundle 资源中的 Prefab 读取出来并实例化到场景中,最终我们看到的效果就是上面左侧图片呈现的情况。

第三步,找到重现的方法了,接下来就是探究问题根源了,我们可以先做几个假设,问自己几个问题:

  1. 如果不使用自己编写的 Shader,直接给 Prefab 中的 3D 对象设置一个 built-in 的 Shader,是不是就不会出现这个问题了?
  2. 如果自己编写的 Shader 中加入 Fallback “Diffuse” 代码片段之后,是否可以挽回这个显示成粉色/紫色的情况,转而使用 Unity built-in 的 Diffuse Shader 进行渲染呢?

经过测试之后,上面两个问题的答案如下:

  1. 使用 Unity built-in 的 Shader 确实不会出现丢失 Shader 导致显示错误的问题;
  2. 加入 Fallback “Diffuse” 代码片段后的 Custom Shader 不会出现丢失 Shader 之后显示成粉色/紫色的情况,但是会直接使用 Unity built-in Diffuse 的 Shader 进行渲染,跟我们在 Custom Shader 中编写的渲染逻辑半毛钱关系都木有。

至此,我们大体可以确定就是我们打包到指定 BuildTarget 平台上的 AssetBundle 中的 Prefab 资源中使用的 Shader 是成功被打包进去了,因为从上面的第二个问题中,我们能确定 Fallback 语句是生效的,如果我们想确认得更明确一些,我们可以把 Fallback 中指定的 Shader 更换为其他的 built-in 的 Shader 再试试看效果,相信我木有错滴。

既然我们现在已经确定了 Shader 是成功编译后打包进了 AssetBundle 中,那么我们可以看看直接读取 AssetBundle 资源然后初始化 Prefab 之后,这个出问题的 3D 对象使用的 Shader 是个什么情况,以及正常情况下的 Shader 应该是啥情况:

shader-in-assetbundle-for-ios shader-in-assetbundle-for-osx

上面两个图片是我们已经找到问题之后针对出现渲染错误和正确渲染两个情况打包的两个 Shader 在 Editor 中运行时通过点击3D 对象的 Material 组件 Inspector 面板右侧的 Edit 按钮进入的 Shader 的 Inspector 面板。由此我们可以看到我们在 OSX 系统下的 Unity Editor 中使用针对 iPhone 平台打包的 AssetBundle 中的 Shader 在运行时有一个警告输出,而使用针对 OSX 平台打包的 AssetBundle 中的 Shader 就一切正常。

那么我们再来看看这个警告的信息是啥意思呗,No subshaders can run on this graphics card,这个信息也很明确,就是说在你当前的这个显卡上没法执行这个 AssetBundle 中打包的 Shader 中的 subshader。噢,原来如此,针对移动平台打包的 Shader 应该是针对移动设备显卡进行适配和优化的,因为它们通常都是基于 OpenGL ES 标准的,而 Windows 和 OSX 分别是机遇 Direct X 和 OpenGL 的,这也就说得通了。

但是我们还会问一个问题,那就是我明明已经将我的 Unity Editor 的 Build Setting 设置为了目标移动平台,例如 iOS 或者 Android 了啊,为啥加载 AssetBundle 中的 Shader 进行显示时,这个 Unity Editor 就不能按照在手机上的机制来加载这个 AssetBundle 中的 Shader 资源并进行渲染呢?好吧,你把我问住了,实际上我也并不知道为什么 Unity 没有这么做,跪了。那我们就放狗搜索一下吧,最终我找到了两篇不错的文章,咱们先来看这篇点睛之文,这个讨论中有一个名字叫 superpig (超猪)的 Unity 官方的哥们写了以下两个回复:

Just to be clear: this is not one single bug.

The pink material stuff just means there was a problem setting up the material and/or shader. There’s a bunch of ways this can happen. Two of them include:

  • Loading bundles on one platform that were built for another. For example, if you build your bundles for iOS and then try loading them on PC, it won’t work because PC needs the Direct3D versions of the shaders and the bundle won’t contain those (because there is no Direct3D on iOS). To fix this you just need to make sure that the bundles you are loading were built for the platform you are loading them on.
  • Failing to load the bundle that contains your shader (or loading it after the one with the material in). As of Unity 5.0 the AssetBundle pipeline will calculate the dependencies between the bundles, but it won’t automatically load the bundles for you (because in general it has no idea where to get them from). To fix this you just need to load all the required bundles, and you need to do it in the correct order. The AssetBundleManager demo project on the Asset Store demonstrates how you can use the AssetBundleManifest to implement your own automatic dependency loading.

We can and will improve the error reporting around these scenarios, but in both cases there is no engine ‘bug’ per se – just a relatively unhelpful failure behavior.

So, if you are still facing this issue, and neither of the mistakes described above fix your problem, please file a bug report instead of assuming that somebody else has done it – because it is very likely that their problem is not the same as yours exactly, and their problem will get fixed, but yours will not because it’s actually a different case.

——————– 我是两个不同论坛回复内容的分割线 ——————

Switching the platform to Android won’t work. If your Editor is running on Windows, you need a Windows version of the bundle. If it’s Mac, you need a Mac version. It doesn’t matter which platform is your active build target platform.

我把重点的内容标红了一下,看完了这两段回复之后,我们终于搞清楚了 Unity 对于 AssetBundle 中编译打包的 Shader 是个什么处理机制了,这就因为着我们在 Unity Editor 中如果需要正确加载 AssetBundle 中 Shader 并进行渲染的话,我们就需要使用针对我们 Unity Editor 所在的宿主系统环境进行打包,例如我们使用的是 Windows 系统下的 Unity Editor 那么就需要使用针对 Windows 平台打包的 AssetBundle,对于 OSX 系统也是一样的。

至此我们就可以再赶紧验证一下看看素不素这个鬼样子的,建议在资源很少的工程中单独进行实现,因为我知道你们的 Unity Editor 现在肯定是在某个移动平台的 Build Setting 下的,这样贸然打包一个非移动平台的 AssetBundle 会直接触发 Unity Editor 整个的 Switch Platform 操作,我想你肯定不会想这样的,我们都已经被 Unity Editor 这个切换平台耗费过很多生命了。最终测试通过,确实如这位「超猪」先生所说的。


本文原本到这儿呢,就应该结束了,对不对捏?嗯,是的,但是我还有些话需要说,那就是我自己在碰到这个问题的时候我第一时间也放狗搜索了,找到了很多类似的解决方案,大部分都说可以通过在加载 AssetBundle 资源成功之后,将其中的 Material 资源读取出来,然后将这些 Material 使用的 Shader 重新设置一遍,然后就可以了(其实在放狗之前,我自己已经发现可以通过自己手动地在 Editor 中对这些显示出错的 Material 指定一下 Shader 就可以让其正确显示了)。那么这个问题的根源是啥呢?把这个归结为 Unity 的 Bug?显然,这样是一种很不负责任的做法,因为我只用了一个很简单的测试就把这个 Hack Fix 给推翻了,并且确定了这并非 Unity 的问题。

测试这个 Hack Fix 是否真正触及到问题的根源很简单。

首先,我们准备一个工程用于创建 AssetBundle 资源,名为 CreateAssetBundle,在这个工程里头我们只放我们需要打包的 Model、Texture、Material 和 Shader 等资源文件,然后创建一个使用了这些资源的 Prefab 文件,再将我们创建出来的 Prefab 文件打包成为 AssetBundle 文件,这样我们就准备好了一个可用的 AssetBundle 文件了,对伐。

接下来,我们创建一个新的空工程,只把上一步中打包出来的 AssetBundle 资源放到 Assets/StreamingAssets 目录下,然后我们读取这个 AssetBundle 资源,取出里头的 Prefab 资源,在场景中实例化一下,并且使用下面这段 Hack Fix 代码:

#if UNITY_EDITOR_OSX || UNITY_EDITOR
		var mats = bundle.assetBundle.LoadAll (typeof (Material));
		foreach (Material mat in mats) {
			var shaderName = mat.shader.name;
			var shaderInRuntime = Shader.Find (shaderName);
			if (shaderInRuntime != null) {
				mat.shader = shaderInRuntime;
				Debug.Log (string.Format ("Found the shader: {0} used in mat: {1}", shaderName, mat.name));
			} else {
				Debug.Log (string.Format ("Cant not find the shader: {0} used in mat: {1}", shaderName, mat.name));
			}
		}
#endif

然后你会发现,WTF,根本就是然并卵嘛,说好的自行车呢?那么我们再来问问自己,这段代码最核心的是啥?

就是使用 Shader.Find (string name) 方法重新按照我们 Prefab 中指定使用的 Shader 名称在整个程序内容空间中查找了一次 Shader,然后如果成功找到的话,就将其重新设置给我们从 AssetBundle 中读出来的 Material 对象,对伐?那么为何网上那么多的人都说这个解决了他们的问题呢?而且然后就没有然后了呢?

咱们来想想我们为啥会碰到出现在 Editor 中出现加载了 iOS 或者 Android 平台的 AssetBundle 资源呢?也就是我们还是在开发的过程中,我们已经将某一部分资源针对移动平台打包了,这些资源中会把我们在工程中的 Shader 文件一并编译打包,但是我们的工程中的 Shader 文件并不会移除,所以在 Editor 中运行游戏时,当我们出现了因为 Editor Host 不支持针对移动平台编译的 AssetBundle 中的 Shader 渲染的情况时,我们通过了一个上面代码中的 Hack 的手段,使用了 Shader.Find 方法,而且这个方法查找到的就只是工程中未编译的 Shader,根本就不会去我们加载的 AssetBundle 中加载,实际上是在运行时将游戏中的 Materail 中使用的 Shader 偷换成了我们工程中的 Shader,而这些 Shader 并未编译成某个移动平台的,在 Editor Host 环境下肯定是可以正常渲染的,否则我们早都发现渲染不对了。这就是为何我需要创建一个新的空的工程来测试我们打包出来的 AssetBundle 资源,这样我们就可以保证我们在工程中不会有同名的Shader,如此一来这个 Shader.Find 方法肯定是找不到同名的 Shader 了,因为我们这个工程就只有一个 AssetBundle 资源,由此我们就找到了整个问题的各个根源,同时也对网上很多解决方案中提出来的 Hack 方法也提出了疑问并做出了自己的解答。

不过话再说回来,如果为了省事避免维护多个不会使用到的平台的 AssetBundle (通常我们的游戏都只会在 Android 和 iOS 平台发布,实际上 Windows 和 OSX 平台下的 AssetBundle 就没有什么必要维护了),这个 Hack 还是可以用的,但是我们还是要搞清楚为啥这个东西是可用的,下次如果碰到类似的问题或者不是 Shader 的问题,至少也可以提供一个正确的思考方式和参考。

Unity3D 中粒子特效不在摄像机渲染范围内 Play 之后导致特效延时播放的解决方法

从项目启动之初,我们就引进了 PoolManager 插件来做诸多对象的缓存,防止游戏中对象的频繁 Instantiate 和 Destroy,造成不必要的性能问题。PoolManager 是一个非常成熟的插件,也提供了很多非常友好的接口以供开发者使用,其中也有针对 ParticleSystem 对象的 Spawn/Despawn 机制,所以我们就很自然的使用了 PoolManager 插件来管理整个游戏中的所有特效对象。

particle_system_spawn_pool_management

对于特效对象,我们游戏中的管理逻辑大体如下:

也就是说我们的粒子特效对象是重用的,在每次重新被 Spawn 出来的时候都会重新调用一次 Play 方法来重新播放这个粒子特效,这个方案看上去很正常有木有?确实没有啥硬伤对吧。

但是在我们实际的测试过程中,我们很早就发现了一个很奇怪的问题,就是偶尔场景中会出现一些不应该出现的技能特效。例如,在场景中偶遇到一个小怪,小怪在攻击的过程中玩家控制主角快速跑开了(主摄像机一直跟随主角),然后灯小怪的攻击结束之后,主角再次跑回到小怪面前进行战斗,然后此时偶尔就会看到小怪并没有在进行任何攻击,只是在战斗待机或者移动,但是屏幕上会凭空播放一些这个小怪的攻击技能对应的特效(其实找到这个问题重现的方法,也就是这个 Bug 的规律也是费尽心思啊,想想看这货都存在快 TMD 一年了,直到现在我才真正地捉到它)。

既然已经找到了重现的方法,也清楚了 Bug 出现的规律,也就是当小怪释放技能时(技能动作带有技能粒子特效),由于主角的移动导致摄像机跟着主角在场景中移动,最终小怪释放的技能粒子特效并不在摄像机的 Culling 区域中,而此时我们通过了 PoolManager 插件 Spawn 出来了一个新的粒子特效,然后调用了 ParticleSystem 的 Play 方法来播放粒子特效。我们预想的是该粒子特效在我们调用 Play 之后就会播放,然后就交给 PoolManager 自行管理就好了(PoolManager 插件会监听粒子是否已经完全发射/播放结束,如果是,就会将这个粒子对象 Despawn 掉,放回到 PoolManager 中对应的 SpawnPool 中以备后续再次使用)。

问题就出在这里,PoolManager 中监听粒子对象是否完全发射/播放结束的方法是通过轮询 ParticleSystem 的 IsAlive 方法来进行判断的,代码如下:

// ParticleSystem (Shuriken) Version...
private IEnumerator ListenForEmitDespawn(ParticleSystem emitter)
{
    // Wait for the delay time to complete
    // Waiting the extra frame seems to be more stable and means at least one 
    //  frame will always pass
    yield return new WaitForSeconds(emitter.startDelay + 0.25f);

    // Do nothing until all particles die or the safecount hits a max value
    float safetimer = 0;   // Just in case! See Spawn() for more info
    while (emitter.IsAlive(true))
    {
        if (!PoolManagerUtils.activeInHierarchy(emitter.gameObject))
        {
            emitter.Clear(true);
            yield break;  // Do nothing, already despawned. Quit.
        }

        safetimer += Time.deltaTime;
        if (safetimer > this.maxParticleDespawnTime)
            Debug.LogWarning
            (
                string.Format
                (
                    "SpawnPool {0}: " +
                        "Timed out while listening for all particles to die. " +
                        "Waited for {1}sec.",
                    this.poolName,
                    this.maxParticleDespawnTime
                )
            );

        yield return null;
    }

    // Turn off emit before despawning
    //emitter.Clear(true);
    this.Despawn(emitter.transform);
}

这个 IsAlive 判断就是问题的关键,经过测试我们发现了一个 Unity3D 的机制,当我们首次将 PartileSystem 对象 Instantiate 出来时,不论其是否在摄像机的 Culling 区域内,调用 ParticleSystem 的 Play 方法或者将其 playOnAwake 设置为 True 都会播放该 ParticleSystem 对象。而在其首次 Instantiate 之后,再次调用 Play 方法来播放该粒子特效时,如果粒子不在任何一个 Camera 的Culling 区域内(包括 Culling Layer Mask 是否相符以及是否在 Camera 的可视范围内),实际上粒子并不会发射,直到其进入到某个 Camera 的 Culling 区域内才会触发粒子的发射(实际在 Unity Editor 中测试的时候,最终发现粒子进入 Scene Window 的可视区域和 Game Window 的可视区域都会触发粒子的发射,实际上 Scene Window 的 Preview 就是有一个我们不可见的 Camera 在进行渲染)。此处需要说明的是以下两点:

  1. Unity 版本为 4.6.9f1;
  2. 粒子特效的 Renderer Mode 是 Billboard。

至此,我们终于完全弄明白了为什么会出现某些技能的粒子特效无端地在场景里播放了,原因就是因为该特效在 Instantiate 出来调用过一次 Play 方法之后(由于我们所有的特效默认 playOnAwake 属性都是 true,所以我们通过 PoolManager 加载的粒子特效的 Prefab 实际上在首次 Spawn 之前都已经自动调用过一次 Play 方法了),再次调用 Play 方法来播放该粒子特效的时候,该粒子特效所处的坐标位置并不在主摄像机的 Culling 区域内,所以根本就不会触发粒子的发射而是在等候下次进入摄像机的 Culling 区域时再触发。在我们游戏中的最终表现就是在屏幕边缘与小怪发生战斗,当小怪释放技能的时候快速往小怪的反方向移动,等着小怪来追击,我们再折返跑回刚刚与小怪发生战斗的地方,这个时候我们就能看到几个小怪的技能特效凭空在场景中自动播放了,真乃煞是奇妙啊。

好吧,说了这么多,那么我们既然知道是这个原因了,那么怎么解决呢?我们看到了  ListenForEmitDespawn 方法中有一个针对粒子特效在 Spawn 出来之后时间的检测,其检测粒子对象自 Spawn 以来的时长是否超出 SpawnPool 中定义的最大 Despawn 时长,跟实际的粒子对象并无直接关系,而且即便该粒子对象 Spawn 出来的时长已经超出最大的 Despawn 时限,也只是会简单地输出了一个警告的 Log 信息。

那么我们自己的处理逻辑应该是怎样的呢?我的处理方法是在粒子对象被 Spawn 出来之后,除了通过其自身的 IsAlive 方法来检测该粒子对象是否还是激活状态,在其 Spawn 出来的同时就启动一个 Coroutine 用来检测其自 Spawn 出来的时长是否超出 ParticleSystem 的 duration 设置值,如果已超出,那么就将其 Clear 并且 Stop,然后再将其 Despawn 掉。依次修改后的 ListenForEmitDespawn 方法如下:

// ParticleSystem (Shuriken) Version...
private IEnumerator ListenForEmitDespawn(ParticleSystem emitter)
{
    // Wait for the delay time to complete
    // Waiting the extra frame seems to be more stable and means at least one 
    //  frame will always pass
    yield return new WaitForSeconds(emitter.startDelay + 0.25f);

    // Do nothing until all particles die or the safecount hits a max value
    float safetimer = 0;   // Just in case! See Spawn() for more info
    while (emitter.IsAlive(true))
    {
        if (!PoolManagerUtils.activeInHierarchy(emitter.gameObject))
        {
            emitter.Clear(true);
            yield break;  // Do nothing, already despawned. Quit.
        }

        safetimer += Time.deltaTime;
        if (safetimer > emitter.duration) {
	        Debug.LogWarning
	        (
	            string.Format
	            (
	                "SpawnPool {0}: " +
	                    "Timed out while listening for all particles to die. " +
	                    "Waited for {1}sec.",
	                this.poolName,
	                emitter.duration
	            )
	        );
			break;
		}
            
        yield return null;
    }

    // Turn off emit before despawning
	emitter.Stop(true);
    emitter.Clear(true);
    this.Despawn(emitter.transform);
}

由于误用 NGUITools.Destroy 导致所有 UI 控件的层都被重置的问题

最近在项目中碰到一个很奇怪的 Bug,表现是在背包中消耗掉某些道具之后,会将所有 UIRoot 下的子对象的层全部都修改的 UI 层,这个会造成很多奇怪的问题。由于我们游戏中使用了多个用于 UI 显示的 Camera,针对不同层的 GameObject 使用了不同的 Camera,这么一整搞得完全乱了套了。

当然确定是因为消耗了道具之后才造成 Bug 出现的这一步也是花费了很长的时间才搞明白的,在此之前我已经把我能想到的所有情况都给试了一遍(当然这个少不了我们士海和杨威童鞋两个人在旁边的指点和敲击),最终发现只能给要被 Destroy 掉的控件添加一个 UIPanel 组件就可以彻底避免这个问题,当然这个还是还是有点懵,直到追查到了 NGUITools 中的 CreateUI 方法中,看到了 SetChildLayer 方法,才知道最终是因为调用了这个方法,那么接下来就是一步步定位了。

通过各种日志输出和调试,最终确认了就是因为调用 NGUITools.Destroy 销毁掉的控件在被彻底的销毁之前调用了自身的 Update 方法,然后调用到了 OnUpdate 这个方法:

protected override void OnUpdate ()
{
    if (panel == null) CreatePanel();
#if UNITY_EDITOR
    else if (!mPlayMode) ParentHasChanged();
#endif
}

正常情况下,CreatePanel 这个方法是不会调用的,但是在我们这个游戏中,道具使用的时候会跳转到另一个 UI 界面下,而背包界面的整个 UIPanel 在玩家操作道具使用时是隐藏的,这样一来背包中所有条目的 UIWidget 中的 panel 属性就都为空了(这个 panel 属性设置为空是在整个背包面板被隐藏的时候 UIWidget 的 RemoveFromPanel 方法被自动调用了),所以在我们消耗掉某个指定的道具回到背包界面的时候,我们会调用 NGUITools.Destroy 方法来将被消耗掉的这个道具的 Item 给销毁掉,这样背包中剩余的所有条目就是当前实际背包中所有的道具条目了。但是我们忽略了 NGUITools.Destroy 方法中对目标销毁条目做了什么操作,这个可以参考我的另一篇文章,最终导致调用 CreatePanel 方法的时候错误的将整个 UIRoot 下的所有 UI 控件的 layer 都给设置成了目标销毁对象所在的层。

public UIPanel CreatePanel ()
{
    if (mStarted && panel == null && enabled && NGUITools.GetActive(gameObject))
    {
        panel = UIPanel.Find(cachedTransform, true, cachedGameObject.layer);

        if (panel != null)
        {
            mParentFound = false;
            panel.AddWidget(this);
            CheckLayer();
            Invalidate(true);
        }
    }
    return panel;
}

就是在 CreatePanel 方法中调用到了 UIPanel.Find 方法:

static public UIPanel Find (Transform trans, bool createIfMissing, int layer)
{
	UIPanel panel = NGUITools.FindInParents<UIPanel>(trans);
	if (panel != null) return panel;
	return createIfMissing ? NGUITools.CreateUI(trans, false, layer) : null;
}

继而又调用了 NGUITools.CreateUI 方法:

static public UIPanel CreateUI (Transform trans, bool advanced3D, int layer)
{
    // Find the existing UI Root
    UIRoot root = (trans != null) ? NGUITools.FindInParents<UIRoot>(trans.gameObject) : null;

    if (root == null && UIRoot.list.Count > 0)
    {
        foreach (UIRoot r in UIRoot.list)
        {
            if (r.gameObject.layer == layer)
            {
                root = r;
                break;
            }
        }
    }

    // If we are working with a different UI type, we need to treat it as a brand-new one instead
    if (root != null)
    {
        UICamera cam = root.GetComponentInChildren<UICamera>();

#if UNITY_4_3 || UNITY_4_5 || UNITY_4_6
        if (cam != null && cam.camera.isOrthoGraphic == advanced3D)
#else
        if (cam != null && cam.GetComponent<Camera>().orthographic == advanced3D)
#endif
        {
            trans = null;
            root = null;
        }
    }

    // If no root found, create one
    if (root == null)
    {
        GameObject go = NGUITools.AddChild(null, false);
        root = go.AddComponent<UIRoot>();

        // Automatically find the layers if none were specified
        if (layer == -1) layer = LayerMask.NameToLayer("UI");
        if (layer == -1) layer = LayerMask.NameToLayer("2D UI");
        go.layer = layer;

        if (advanced3D)
        {
            go.name = "UI Root (3D)";
            root.scalingStyle = UIRoot.Scaling.Constrained;
        }
        else
        {
            go.name = "UI Root";
            root.scalingStyle = UIRoot.Scaling.Flexible;
        }
    }

    // Find the first panel
    UIPanel panel = root.GetComponentInChildren<UIPanel>();

    if (panel == null)
    {
        // Find other active cameras in the scene
        Camera[] cameras = NGUITools.FindActive<Camera>();

        float depth = -1f;
        bool colorCleared = false;
        int mask = (1 << root.gameObject.layer);

        for (int i = 0; i < cameras.Length; ++i)
        {
            Camera c = cameras[i];

            // If the color is being cleared, we won't need to
            if (c.clearFlags == CameraClearFlags.Color ||
                c.clearFlags == CameraClearFlags.Skybox)
                colorCleared = true;

            // Choose the maximum depth
            depth = Mathf.Max(depth, c.depth);

            // Make sure this camera can't see the UI
            c.cullingMask = (c.cullingMask & (~mask));
        }

        // Create a camera that will draw the UI
        Camera cam = NGUITools.AddChild<Camera>(root.gameObject, false);
        cam.gameObject.AddComponent<UICamera>();
        cam.clearFlags = colorCleared ? CameraClearFlags.Depth : CameraClearFlags.Color;
        cam.backgroundColor = Color.grey;
        cam.cullingMask = mask;
        cam.depth = depth + 1f;

        if (advanced3D)
        {
            cam.nearClipPlane = 0.1f;
            cam.farClipPlane = 4f;
            cam.transform.localPosition = new Vector3(0f, 0f, -700f);
        }
        else
        {
            cam.orthographic = true;
            cam.orthographicSize = 1;
            cam.nearClipPlane = -10;
            cam.farClipPlane = 10;
        }

        // Make sure there is an audio listener present
        AudioListener[] listeners = NGUITools.FindActive<AudioListener>();
        if (listeners == null || listeners.Length == 0)
            cam.gameObject.AddComponent<AudioListener>();

        // Add a panel to the root
        panel = root.gameObject.AddComponent<UIPanel>();
#if UNITY_EDITOR
        UnityEditor.Selection.activeGameObject = panel.gameObject;
#endif
    }

    if (trans != null)
    {
        // Find the root object
        while (trans.parent != null) trans = trans.parent;

        if (NGUITools.IsChild(trans, panel.transform))
        {
            // Odd hierarchy -- can't reparent
            panel = trans.gameObject.AddComponent<UIPanel>();
        }
        else
        {
            // Reparent this root object to be a child of the panel
            trans.parent = panel.transform;
            trans.localScale = Vector3.one;
            trans.localPosition = Vector3.zero;
            SetChildLayer(panel.cachedTransform, panel.cachedGameObject.layer);
        }
    }
    return panel;
}

我们在设置整个背包界面可见之前已经调用了 NGUITools.Destroy 方法将我们消耗掉的道具的 Item 销毁了,而在道具被消耗掉的同时我们除了调用了 NGUITools.Destroy 方法将其销毁掉,还同时关闭了道具使用的界面回到了背包界面,这样一来这个在道具使用界面被销毁掉的道具 Item 实际上在背包界面再次显示的时候还是会调用 Update 方法,直到其真正被销毁掉 Update 才不会再次被调用,而这一次 Update 的调用就导致了前面一连串的反应,最终导致了错误地将整个 UIRoot 下的所有子控件的 layer 都设置为了被销毁道具 Item 所在的层。

那么我们应该怎么来避免这个问题呢?是不是就应该不是用 NGUITools.Destroy 方法,当然这是一个不错的想法,也是很直接的想法,但是如果你参考了我的另一篇文章之后,也许你就不会这么想了,原来 NGUITools.Destroy 方法还是蛮有门道的,如果盲目的替换成 UnityEngine.Object.Destroy 方法,也许带来的只会是麻烦。那么我是怎么解决的呢?好吧,代码来了:

static public void Destroy (UnityEngine.Object obj)
{
    if (obj != null)
    {
        if (Application.isPlaying)
        {
            if (obj is GameObject)
            {
                GameObject go = obj as GameObject;
                SetActive (go, false);
                go.transform.parent = null;
            }

            UnityEngine.Object.Destroy(obj);
        }
        else UnityEngine.Object.DestroyImmediate(obj);
    }
}

我在 go.transform.parent = null; 之前加入了一行代码 SetActive (go, false); 这样就避免了已经要被销毁了的对象还会再次调用 Update 的问题,所有后续引发的问题就都一一化解了。不过于我而言,实际上我并不太喜欢这种直接修改 nGUI 代码的修改方式,这样的修改总是会在你更新 nGUI 插件之后丢失掉(Unity3D Editor 在更新插件的时候都是直接覆盖的,并不存在智能合并这种东西的亲),所以呢还是有点小遗憾和 Dirty,可是目前我能想到的这个可能是最简单的方法,当然我们也可以自己写一个工具方法,直接把这段代码拷贝过去,然后换个类名或者方法名都是可以的啦,看大家喜好吧。