Static Batching、Dynamic Batching 、GPU Instancing的对比

  1. 什么是 GPU Instancing?
    GPU Instancing(GPU 实例化)是一种现代图形渲染技术,允许你用一次 Draw Call在 GPU 上同时渲染大量相同 Mesh 和材质但变换(位置、旋转、缩放)不同的物体。

典型应用:大面积草地、森林、士兵、金币、粒子等。

  1. 原理简述
    传统渲染:每个物体一个 Draw Call,CPU 需要多次提交渲染指令,效率低。
    Instancing:CPU 只发一次 Draw Call,告诉 GPU:“这是同一个模型和材质,但有 N 个实例,每个实例有不同的变换矩阵(和可选参数)。”
    GPU 在渲染时自动为每个实例应用不同的变换和属性。
  2. Unity 中的 GPU Instancing
    开启方式:在材质面板勾选“Enable GPU Instancing”即可。
    代码方式:使用 Graphics.DrawMeshInstanced、DrawMeshInstancedIndirect 等 API。
    Shader 支持:Shader 里要支持 instancing(Unity 标准 Shader 已支持)。
  3. 优点
    极大减少 Draw Call 数量,大幅提升渲染效率。
    CPU 负担小,适合大量重复物体。
    支持每个实例不同的变换、颜色等属性(通过 instanced properties)。
  4. 局限与注意事项
    所有实例必须用同一个 Mesh 和材质(但可以通过 instanced properties 传递不同参数)。
    不支持完全不同的模型或材质。
    Shader 必须支持 Instancing。
    每次 Draw Call 的实例数量有上限(通常 500~1000,受平台和 API 限制)。

Unity Shader模板测试用法记录

之前经常忘记模板测试的用法,现在把这些用法记录在博客上免得以后忘记了。

在Shader的Pass开头写Stencil{ }结构体,如果每个Pass都用,则可以提到外面。

Stencil {  
                Ref 2                     //参考值为2,stencilBuffer值默认为0  
                Comp always               //stencil比较方式是永远通过  
                Pass replace              //pass的处理是替换,就是拿2替换buffer 的值  
                ZFail decrWrap            //ZFail的处理是溢出型减1  
            }  

Ref 2:设置参考值。除了2,还可以设置0-255的任意数。

Comp equal:表示通过模板测试的条件。这里只有等于2的像素才算通过测试。除了equal,还有Greater、Less、Always、Never等,类似ZTest。

Pass keep:表示通过模板测试和Z测试(注意是都通过)的像素,怎么处置它的模板值,这里我们保留它的模板值。除了keep,还有Replace,IncrWrap(循环自增1,超过255为0),IncrSat(自增1,超过255还是255),DecrWrap,DecrSat等。

Fail decrWrap:表示没通过模板测试的像素, 怎么处置它的模板值。这里为循环自减。

ZFail keep:表示通过了模板测试但没通过Z测试的像素,怎么处置它的模板值。这里为保持不变。

除此之外,还有ReadMask和WriteMask语法,用来提取Ref值或模板缓存的某几位,默认是255,全部提取。

jenkins打包环境搭建的一些坑点

特色

最近为了提高版本更新的效率,搭建了jenkins环境,因为我们现在比较多的海外版本,更新频率越来越高。

导致我们前端花费了大量的时间在导UI和版本更新上,而且这些基本上都是一些重复性的工作。由于一些历史原因,每次策划的一些UI修改和调整都需要前端去导UI,然后提交SVN,测试和策划更新下来才能看到效果,非常麻烦。

所以特地花了2天的时间去做这件事,其实本身我们的框架导UI和打包都挺方便的,但是现在海外版本多,还是会比较复杂,而且人工操作容易出错。

jenkins相信做游戏的都不陌生,搭建起来其实也比较简单,去官网下载一个安装包即可,但是要适配自己的项目情况,配置一下参数是比较麻烦的,特别是要做一些自动化的操作,比如复制文件自动提交SVN等。

最麻烦的是在写批处理脚本的时候遇到的一些在jenkins上比较蛋疼的问题:

一、调用unity函数打AssetBundle的过程中发现老是会出现AB还没打完jenkins任务就执行完成了,也不是执行失败,就是提前结束了,这样导致C#里面一些自动拷贝文件的函数都没法执行下去,因为UI都没有完全导完。这个问题也是困扰了我很久,因为我特地试过脚本在BAT文件上执行是没问题的,但是就是在jenkins执行的时候出问题,后面实在是没办法就,写了一个循环每隔5秒钟去检测一次导出的LOG判断UI有没有导完,在C#导出完成的时候输入一段完成的LOG信息。

批处理调用U3D导AB的命令如下:start %UNITY_PATH% -projectPath %PROJ_PATH% -quit -batchmode -logFile %LOG_PATH% -executeMethod JenkinsExportManager.ExportHotFixOnlyJenkins

其中%LOG_PATH%即是导UI过程中unity输出的LOG路径(自己定义),在这个命令后执行循环检测:

:begin
@echo off
ping /n 5 127.0.0.1>nul
echo on
echo 正在导出,请稍后!
findstr /i /c:”export end” “%LOG_PATH%” >nul 2>nul && goto end || goto begin
:end

循环查找LOG中的“export end”字符串,如果有则UI导出完成了,可以继续往下执行,不然jenkins是无法判断导出进度的。

二、第2个坑点是我们想Jenkins上导出的UI复制到FTP共享盘上,这样策划和测试都可以用,因为Jenkins是运行在打包机上的,这台机器的配置也比较高,打包和导UI的效率比较高,他们可以使用Jenkins导出UI到FTP上,然后再从FTP复制到本地,查看UI的效果,确定没有问题了再提交SVN,这样既实现了人人可以用,又保证了安全性。

但是就在这个地方又卡了我好久,因为在Jenkins上死活识别不了共享盘的路径,明明已经做了共享盘的映射,但就是识别不了,我直接在CMD上执行是可以识别的,但是一放到Jenkins上就不行了,最后在Jenkins上执行了一次享盘映射的命令:net use X: \172.16.20.35\ftp /y 才识别上,真是奇葩。

三、第3个坑点是在Jenkins上是可以设置参数然后在命令中获取的,但是如果在设置的参数判断里面定义set命令然后给变量赋值是不起作用的,这就很奇怪了。本来是想利用不同的参数值设置不同的路径实现一些自动化操作,结果导致我只能把绝对路径全部写上。

比如在Jenkins上设置了一个region参数用来选择地区,然后在命令上做如下判断:

if "%region%"=="国内" (
   set NEWPATH=C:\ui
)

结果发现变量%NEWPATH%是空的,根本赋值不了,在if外面试可以赋值的。

对于这个问题,暂时还没有找到别的方法,只能是写死绝对路径不在Jenkins参数的判断里面去赋值。

四、第4个坑点和第一个类似,也是命令还没执行完成Jenkins就结束任务了,本来是想在导出UI完成的时候,复制一些文件到某个地方的,但是经常发现文件还没复制完成只复制了部分文件Jenkins就结束任务了。对于这个问题我也只能使用命令:

@echo off
ping /n 10 127.0.0.1>nul
echo on

来达到延迟的效果了,10代表延迟10秒才往后执行。

最终配置效果如下:

从此再也不用我们前端导UI啦!前端是万能的。

Unity中UGUI空格不自动换行处理

在UGUI的Text组件中,文字超出宽度会自动换行,如果有空格则会从空格处自动换行,在英文中这种方式是完全没有问题,但是某些语言比如泰文,是不需要在空格处换行的,那就需要特殊处理了,可以通过使用富文本的方式把空格替换掉,比如使用Replace(” “,”<color=#00000000>.</color>”)的方式把空格替换为一个透明的点。

如果不想使用富文本也可以使用unicode字符“\u00A0”,比如Replace(” “,”\u00A0”)替换我们常用的空格,或者使用全角空格替换半角空格。因为我们常用的空格是半角空格,这三种方式都可以实现替换空格后保留空格的效果并且不在空格处自动换行。

我们项目的发行商最近反馈泰文的换行问题,我暂时是使用的“\u00A0”替换字符的,感觉还不错,没出现其他问题。

关于U3D谷歌AAB分包和打包

特色

最近在搞海外相关需求,借此机会记录一下谷歌出包和上架过程。

谷歌宣布从 2019 年 8 月 1 日 开始,在 Google Play 上发布的应用必现支持 64 位架构,这个意味着如果你要上谷歌平台就不能使用Mono的方式打包,只能选择IL2CPP,因为Mono是不支持64位的。

另外上架的应用要包含多份架构的 SO 包,导致应用大小增加,以前只有 armeabi-v7a 一种 CPU 架构,但是为支持 64 位,需要增加 arm64-v8a 架构的库。

所以谷歌宣布从2021年8月份开始,所有提交到Google Play商店的新应用必须采用AAB格式,因为AppBundle(AAB)是Google在2018年推出的动态打包方式,这种包格式以.aab为文件扩展名。

通过aab包发布到 Google Play 的应用,Google Play 能自动将 aab 根据不同维度分解为一个基础包和一系列的“拆分包”,这些拆分包其实就是一个个apk,其中有分为配置拆分包、PFD拆分包和PAD拆分包。

当用户下载应用时,Google Play 会根据用户设备的特性只下发基础包和对应的拆分包,从而减小用户下载应用的大小。

同时谷歌还要求AAB包下的Base目录大小不能超过150M不然上传不了谷歌商店,不过谷歌提供了其他目录用于存放资源和代码。

这些目录分别如下:

install-time:安装时分发

fast-follow:快速跟进式分发模式,在安装游戏完成后,自动下载。

on-demand:按需分发模式,通过google API下载

现在大部分游戏资源其实都是放在Base目录下的,也就是安卓工程的src/main/assets目录, 而且基本上是超过150M的,也就是说我们开发者需要更改自己原有的资源加载方式,把大部分资源移动到install-time,fast-follow,on-demand这3个目录中,谷歌有提供相关的API去这些目录中获取资源。

其中有专门针对Unity的API和工具:

文档地址:https://developer.android.google.cn/guide/app-bundle/asset-delivery/build-unity

如果你不想更改原来的加载方式,可以把资源全部放在install-time目录下,在这个目录下的资源不需要使用谷歌的API进行加载,而是在安装的时候会自动帮你安装到手机上。

如下,把Unity导出的工程划分为以下几个目录:

然后把src/main/assets下的资源分配到install-time,fast-follow,on-demand目录,但是要注意在这3个目录下的目录结构需要和src下的目录结构一样。

比如原来的资源存放在src\main\assets\res目录,现在你要把res下的部分资源移动到install-time目录下,那你需要在install-time目录下新建一个src\main\assets\res的目录结构,然后把src\main\assets\res的资源移动到install-time\src\main\assets\res目录下,这样就大功告成了。

只要保证原来src\main\assets目录的大小加上库文件的大小不超过150M那么打包出来AAB的base目录也不会超过150M。

AAB包的结构如下:

观察发现,base目录的大小主要是assets+lib的大小,而lib下是我们的库文件,这些是无法缩减和移动的。

其实从测试的角度上来说光打出AAB包还不算真正的结束,这个时候如果你想测试AAB包是不行的,因为AAB无法直接安装在你的手机上。

所以发行那边或者测试通常会向你要一个AAB包对应的APK包,幸好谷歌有提供相关的工具把AAB包反编译为APK。

首先要去Github下载这个工具:https://github.com/google/bundletool/releases

然后使用命令:java -jar 你下载的bundletool路径.jar build-apks –bundle=你的AAB包路径.aab –output=输出apks的路径.apks –ks=安卓签名文件路径 –ks-pass=pass:ks密码 –ks-key-alias=bf_lmzh_yn –key-pass=pass::key密码

执行后等待几分钟(根据每个人的电脑性能的不同编译时间有快有慢)会在你设置的输出目录下生成一个.apks的文件,使用解压软件打开它,standalones目录下的apk文件即是你AAB对应的APK文件,把APK和AAB一起发给发行和测试,到此出包工作才全部结束。

如何让Unity3D生成的安卓包启动更快

特色

Unity生成的游戏包启动确实会比其他引擎慢些,即使打包的时候勾选了Splash Image选项,如果包比较大的话,安卓启动还是会黑屏一段时间才能看到画面。

这种情况是没办法从U3D游戏这边去解决的了,因为这是原生安卓的问题了,需要从SDK这边去解决,网上看到有些资料是在主Activity的onCreate里创建一个view来覆盖Splash,其实完全没必要这么复杂。

只需两步就可以解决:
1.在AndroidManifest文件中设置:

<activity
    android:name=".ui.SplashActivity"
    android:theme="@style/Theme.Splash"
    android:screenOrientation="portrait">
    <intent-filter>
          <action android:name="android.intent.action.MAIN" />
          <category android:name="android.intent.category.LAUNCHER" />
     </intent-filter>
</activity>


2.在style文件中定义:

<style name="Theme.Splash" parent="android:Theme.Holo.Light.NoActionBar.Fullscreen">
    <item name="windowNoTitle">true</item>
    <item name="android:windowContentOverlay">@null</item>
    <item name="android:windowBackground">@drawable/splash</item>
    <item name="android:windowFullscreen">true</item>
</style>

然后在src/main/res/drawable下放一张名为splash.png的启动图即可。这张启动图会在你启动APP之后立马显示出来,然后再显示Unity设置的Splash图。

不过这么做的话这张图会有一个问题,那就是和手机的屏幕分辨率不匹配的问题,有可能会拉伸和挤压的情况出现,其实只要这张图中间放一张LOGO其他地方是纯黑色或者白色是看不出来的,也可以使用.9的方式设置图片拉伸区域。

大大缩短了安卓APP的启动时间,据发行反馈这样体验更好。

关于Unity游戏在安卓SDK弹出时从后台返回黑屏问题

特色

今天发行那边反馈过来一个问题,是之前我们都没发现过的,当在安卓SDK登录界面的时候切换到后台或者切换其他到其他应用,再次返回游戏的时候,发现SDK登录界面后面的游戏界面黑屏了。

我的初步判断是,游戏的View应该是从后台返回到前台的时候还没被激活,因为只要点击一下SDK的登录,游戏界面就显示出来了。

查询资料发现确实是这个原因。


一、当Unity所在的Activity之上没有其他Activity时候,生命周期的变化如下:

启动App
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

点Home进入后台
D/MainActivity: onPause
D/MainActivity: onWindowFocusChanged false
D/MainActivity: onStop

从后台回来
D/MainActivity: onRestart
D/MainActivity: onStart
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

退出应用
D/MainActivity: onWindowFocusChanged false
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onDestroy

二、当Unity所在的Activity之上有其他Activity时候,生命周期的变化如下

弹窗显示另外一个Activity
D/MainActivity: onPause
D/MainActivity: onWindowFocusChanged false

点Home进入后台
D/MainActivity: onStop

从后台回来
D/MainActivity: onRestart
D/MainActivity: onStart

关闭 弹窗Activity
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

三、对比两次的日志打印,我们可以发现:第二次从后台返回少了两个生命周期的调用

D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

综上分析所得,要想做到从后台返回不黑屏,只需要在SDK的主Activity的 onStart里面调用onResume和onWindowFocusChanged方法即可。

public void onStart() {
    super.onStart();
    this.mUnityPlayer.resume();
    onWindowFocusChanged(true);
}

这样当Unity所在的Activity之上有其他Activity时候,即使切换到后台再返回游戏,也不会黑屏。

关于Particle System is trying to spawn on a mesh with zero surface area的警告

今天测试反馈了个刷屏的警告”Particle System is trying to spawn on a mesh with zero surface area“根据翻译判断是使用了体积为0的mesh,但是当我在网上查找资料的时候,网上说的是因为 ParticleSystem 中 ShapeModule 要求引用的 Mesh 必须开启 Read/Write 选项.

经过我的实验和调试发现并不是这么回事,简单的就是因为某个特效的shape类型选择了mesh然而没有指定一个mesh给他,而且选择的Mode为Edge,如下:

满足以上几个箭头所指的条件后就会不断刷如下警告:

要想去掉这些警告,可以改变shape的类型或者指定一个mesh给它,也可以改变Mode的类型为Vertex都可以去掉这些烦人的警告,具体方法需要根据自己的特效情况设置.

如果需要查找项目中有问题的特效可以使用以下编辑器脚本:

using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

public static class MeshToolForParticle
{
    [MenuItem("Tools/ParticleSystem/FindScenes", false, 20)]
    public static void FindScenes()
    {
        var guids = AssetDatabase.FindAssets("t:Scene");

        foreach (var guid in guids)
        {
            string path = AssetDatabase.GUIDToAssetPath(guid);
            var scene = EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
            var roots = scene.GetRootGameObjects();
            bool found = false;
            foreach (var go in roots)
            {
                found |= Find(go);
            }

            if (found)
            {
                Debug.Log(path);
            }
        }
    }

    [MenuItem("Tools/ParticleSystem/FindPrefabs", false, 20)]
    public static void FindPrefabs()
    {
        var guids = AssetDatabase.FindAssets("t:Prefab");

        foreach (var guid in guids)
        {
            string path = AssetDatabase.GUIDToAssetPath(guid);
            var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
            Find(prefab);
        }
    }

    private static bool Find(GameObject go)
    {
        bool found = false;
        var particles = go.GetComponentsInChildren<ParticleSystem>(true);
        foreach (var particle in particles)
        {
            if (particle.shape.enabled && particle.shape.shapeType == ParticleSystemShapeType.Mesh &&
                particle.shape.mesh == null)
            {
                string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(particle);
                Debug.LogWarning(string.Format("Path: {0}\nPrefab: {1}", particle.gameObject.GetGameObjectPath(), prefabPath));
                found = true;
            }
        }

        return found;
    }
}

把上面的脚本放到项目的Editor目录下然后点击菜单Tools/ParticleSystem/FindPrefabs,即可打印出有问题的特效名称.

unity3D Shader变灰原理

特色

使用shader置灰一张图或者一个文字的代码很简单,但是之前一直不知道它的原理是啥。

今天通过查找资料发现,其实很简单,置灰的核心代码如下:

 fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                //-----------add-------------
                // gray
                float gray = dot(col.rgb, float3(0.299, 0.587, 0.114));
                col.rgb = float3(gray, gray, gray);
                //---------------------------
                return col;
            }
 float gray = dot(col.rgb, float3(0.299, 0.587, 0.114));
这一句是最主要的一句,其实就是把采样出来的颜色和一个固定的颜色值做一个点乘,但是为什么是这个颜色呢?为什么要和这个颜色值做点乘返回最终的片元着色器颜色?

原理:是因为人眼对绿色的敏感度最高,对红色的敏感度次之,对蓝色的敏感度最低,因此使用不同的权重将得到比较合理的灰度图像。实验和理论推导出来的结果是0.299、0.587、0.114。
所以为图片的RGB分别乘以权重然后加起来得到一个灰色值,并且将这个灰色值作为新的RGB值
返回给最终的颜色值。 

如何优化结构体和类的大小?

类和结构体的区别,大家应该都知道,但是在开发过程中到底是用类还是结构呢?

要想知道这个问题的答案首先应该知道怎样估算对象和结构体的大小。

一、如何估算结构体的大小

结构是值类型,它的结构体的实例是存放在栈中或者堆中。结构体在内存中所占的大小,就是其字段所占的大小,但是,它的大小并不是所有字段大小相加,而是存在一个对齐的规则,在默认的对齐规则中,基本类型字段是按照自身大小对齐的,如byte是按1字节对齐。

struct A
{
byte a1;
}

如上面这个结构体的大小就是1字节,如果是下面这个:

struct A
{
byte a1;
int a2;
}

这个结构体所占内存大小是8字节,因为int是4字节对齐的,所以只能从第四个字节开始。

如果再添加一个字段:

struct A
{
byte a1;
int a2;
byte a3;
}

这个结构体大小是12,由于struct本身也要是对齐的,所以它的对齐规则是按照其中元素最大的对齐规则决定的。也就是说上面这个结构体要按照4字节对齐,不足4字节要补齐,所以是12个字节大小。

如果想要优化它的大小,可以调整顺序如下:

struct A
{
byte a1;
byte a3;
int a2;
}

这个时候这个结构体所占的大小就是8字节了。

二、如何估算类的大小

类是引用类型,它的对象实例存放在堆中,对象实例一定是会占用堆内存的,而在栈中,保存的是实例的引用。对象在堆中分成3个区域,vtable、monitor和字段。其中vtable是类的共有数据,包含静态变量和方法表,这个应该就是类本身所占用的大小和具体的对象无关。monitor是线程同步用的,这2个指针分别占用一个inptr.Size大小,字段是从第9个字节或17个字节开始的,字段的对齐规则和结构体的对齐规则相同,区别是Mono中对象的实例会把引用类型的引用放在最前面。一个对象实例的大小就是 inptr.Size *2+字段的大小。

通过调整字段的顺序,也可以优化对象的大小。

还可以通过StructLayoutAttribute自定义类和结构体的对齐方式。

[StructLayout(LayoutKind, Sequential, Pack = 1)]
public struct A
{
byte a1;
int a2;
byte a3;
}

上面这个结构体强制按照1字节对齐,所以他的大小是6字节。但是这样做可能会降低性能。

所以具体情况还是要具体分析,懂得了如何估算结构体和类的大小,就更容易知道该如何使用它们了。