程序员叨叨叨 http://wjnovember.github.io/ Sat, 20 Nov 2021 09:36:24 GMT http://hexo.io/ 《Unity Shader入门精要》笔记(三) http://wjnovember.github.io/UnityShadersBook03/ http://wjnovember.github.io/UnityShadersBook03/ Sat, 20 Nov 2021 08:02:57 GMT <blockquote> <p>本文记录《第4章 学习Shader所需的数学基础》的矩阵基础知识内容。</p> </blockquote> <h1 id="笛卡尔坐标系"><a href="#笛卡尔坐标系" class="headerlink" title="笛卡尔坐标系"></

本文记录《第4章 学习Shader所需的数学基础》的矩阵基础知识内容。

笛卡尔坐标系

二维笛卡尔坐标系

二维笛卡尔坐标系:

  • 原点
  • x轴、y轴(基矢量)

x轴、y轴朝向并非固定,如:OpenGL和DirectX使用了不同的二维笛卡尔坐标系。

三维笛卡尔坐标系

三维笛卡尔坐标系:

  • 原点
  • x轴、y轴、z轴(基矢量)

标准基矢量:互相垂直,且长度为1的基矢量。
正交基:互相垂直,但长度不为1的基矢量。

左手坐标系和右手坐标系

以手的大拇指作为+x轴,食指作为+y轴,中指作为+z轴,将3根手指互相垂直,可以用左手示意的坐标系,为左手坐标系:

可以用右手示意的坐标系,为右手坐标系:

左手坐标系和右手坐标系无法通过旋转实现坐标轴指向重合。

左手坐标系和右手坐标系分别对应左手法则右手法则,用来在坐标系中定义旋转的正方向,下图4个手指指向的方向即为正方向:

Unity使用的坐标系

Unity的模型空间和世界空间使用的是左手坐标系,注意观看下图红、绿、蓝轴在右上角分别对应x轴、y轴、z轴:

Unity的观察空间使用的是右手坐标系。观察空间,就是以摄像机作为原点的坐标系,在这个坐标系中,摄像机的前向是z轴的负方向,与模型/世界空间的定义相反。即:z轴坐标的减少意味着场景深度的增加。

点和矢量

点是n维空间(游戏中主要是用二维、三维空间)中的一个位置,没有大小、宽度的概念。
二维空间点的表示:p = (x, y)
三维空间点的表示:p = (x, y, z)

矢量是n为空间中包含模和方向的有向线段,没有位置的概念。
矢量的模:矢量的长度,非负数。
矢量的方向:矢量在空间中的指向。
矢量的表示与点类似,v = (x, y),v = (x, y, z),v = (x, y, z, w)

为区分点和矢量,在变量书写上,标量用小写字母表示,如:a, b, x, y, z等;矢量用小写的粗体字母表示,如:a, b, u, v等。

矢量通常有一个箭头表示:

矢量和标量的乘法/除法

标量是只有模,没有方向的量,比如:距离、速度等。
矢量无法与标量进行加减运算,但是可以进行乘法或除法运算。

矢量与标量的乘法:
kv = (kvx, kvy, kvz)

矢量可以被非0的标量除,但是矢量无法作为除数:

从几何意义上看,一个矢量v和一个标量k相乘,意味着对矢量v进行一个大小为|k|的缩放。若k<0,则矢量方向取反,如下图:

矢量的加法和减法

两个矢量加减,即:两个矢量的对应分量进行加减,公式如下:
a + b = (ax+bx, ay+by, az+bz)
a - b = (ax-bx, ay-by, az-bz)

从几何意义上看,矢量加法,即:把矢量a的头连接到矢量b的尾,然后画一条从a的尾到b的头的矢量,来得到ab相加后的矢量,如下图所示:

也可以理解为:一个点从a的尾进行位置偏移a,在进行位置偏移b,就等同于进行了a+b的位置偏移,这被称为矢量加法的三角形定则

矢量的减法类似:

在图形学中,矢量通常用于描述位置偏移(简称位移)。我们可以利用矢量的加法和减法来计算一点相对于另一点的位移。

矢量的模

矢量的模是一个标量,可以理解为矢量在空间中的长度。表示符号通常是在矢量的两边加上竖线,比如:|v|。

三维矢量的模的计算公式:

其他维度的矢量的模计算类似,都是对每个分量平方相加后开根号。几何意义,可用下图解释:

单位矢量

单位矢量指模为1的矢量,也被称为被归一化的矢量(normalized vector)。通常用在只关心方向,不关心模的矢量,比如:模型的发现方向、光源方向等。

把非零矢量转换成单位矢量的过程叫归一化
单位矢量的表示为:

单位矢量的公式:

零矢量:每个分量的值都为0的矢量,如:v = (0, 0, 0)。零矢量不能被归一化,因为除法运算时,分母不能为0。

从几何意义上看,对于二维空间,单位矢量就是从圆心出发、到圆边界的矢量:

对于三维空间,单位矢量就是从圆心出发、到球面的矢量。

在Unity Shader中,会经常遇到法线方向、光源方向,这些矢量不一定是归一化后的矢量,计算的时候需要将这些矢量归一化成单位矢量。

矢量的点积

矢量的乘法有两种类型:点积(dot product)、叉积(cross product)。

矢量的点积,也叫内积。点积的运算表示:a·b,中间的点不能省略。

点积公式一
a·b = (ax, ay, az) · (bx, by, by) = axby + ayby + azbz

点积满足交换律:
a·b = b·a

点积的几何意义:投影

投影的值可能是负数,投影结果的正负号与ab两个矢量的方向有关:方向相反,结果小于0;方向相同,结果大于0;方向垂直,结果等于0。

性质一:
点积可结合标量乘法
(kab = a·(kb)=k(a·b)
k的几何意义是:对矢量进行缩放。

性质二:
点积可结合矢量加减法
a·(b+c) = a·b + a·c
c换成-c就是减法的版本。

性质三:
一个矢量与自身点积的结果是该矢量模的平方
v·v = vxvx + vyvy + vzvz = |v|2
可以用矢量点积的形式来求矢量的模,Shader中常用模的平方来直接做比较或运算,目的是减少开放带来的性能消耗。

点积公式二
a·b = |a||b|cosθ

公式二的证明:
假设对两个单位矢量进行点积

如下图所示:

由上图可知,cosθ对应的直角边是:a·b的点积(b矢量在a矢量的投影),且cosθ = 直角边 / 斜边,则a·b的点积 = cosθ * 斜边,因为单位矢量b的模是1(斜边长度为1),所以:a·b的点积 = cosθ,也就是两个单位矢量的点积为夹角的cos值。

再由之前性质一,可得推导公式二:

由公式二可知,点积可用于求两个矢量的夹角:

矢量的叉积

叉积,也叫外积。与点积不同,叉积的结果仍然是矢量,而非标量。

叉积的表示:a x b,叉号不能省略。叉积的计算公式如下:
a x b = (ax, ay, az) x (bx, by, bz) = (aybz-azby, azbx-axbz, axby-aybx)

具体的记法,可以这样:

  1. 先看每个分量的被减数
    aybz、azbx、axby,它们下标的规律是不包含当前分量下标,比如:x分量的被减数只有y、z下标,y分量的被减数只有x、z下标,z分量的被减数只有x、y下标。
  2. 然后看被减数中a的下标
    ay、az、ax,a的下标永远是当前分量的下一个,比如:x分量的下标是y,y分量的下标是z,z分量的下标是x。
  3. 再看被减数中b的下标
    bz、bx、by,b的下标永远和a不一样,再结合第1点记忆,b的下标不是当前分量,所以x分量b的下标只能是除了x以外的y、z的其中一个,而y被a用了,所以b的下标只能是z,其他分量的b以此类推。
  4. 再看每个分量的减数
    azby、axbz、aybx,他们的下标就是被减数中a和b下标的互换,所以aybz的减数是azby,其他以此类推。

叉积不满足交换律,即:a x bb x a;但是叉积满足反交换律,即:a x b = - (b x a)。
叉积不满足结合律,即:(a x b) x ca x (b x c)。

叉积的几何意义:
对两个矢量进行叉积的结果,会得到同时垂直于这两个矢量的新矢量。

叉积的模
公式:
|a x b| = |a||b|sinθ

这容易联想到平行四边形求面积:

面积A = |b| h = |b| (|a| sinθ) = |a||b|sinθ

叉积的方向
从几何意义可知,两个矢量的叉积,会得到垂直于两个矢量的新矢量,但是与其垂直的有两个向量。这时前面学到的左/右手坐标系就派上用场了,它用来确定叉积得到新矢量的方向朝哪边。

将大拇指与a同向,食指与b同向,中指指向的方向就是叉积结果的方向,所以使用左、右手就会得到不同的朝向,如下图:

同理,左右手法则也通用可以用来判断,如下图:

矩阵

矩阵的定义

矩阵(Matrix),就是有m x n个标量组成的长方形数组,通常用方括号在左右两侧围住这些数字,大概像这样:

有些资料也会用圆括号或花括号,其实都一样的。

矩阵有行、列之分,上图的数组就是三行四列。以3x3矩阵为例,它可以写成:

mij表示这个元素在矩阵M的第i行、第j列。

和矢量联系起来

矢量,我们通常写成:a = (x, y, z),可以看出矢量与矩阵一样,也是个数组。将矢量按照矩阵的写法,可以看成是n x 1的列矩阵或1 x n的行矩阵,n对应矢量的维度。

以矢量v = (3, 8, 6)举例,写成行矩阵:
[3, 8, 6]

写成列矩阵:

为什么要和矢量联系起来?因为Shader中经常会将法线(矢量)进行坐标变换,而坐标变换是矩阵的几何意义,所以需要运用矩阵的运算来将法线从模型空间转变成世界空间。(后续会学到)

矩阵运算

矩阵和标量的乘法

与矢量类似,矩阵和标量相乘后,结果仍然是一个矩阵。公式如下:

矩阵和矩阵的乘法

矩阵和矩阵相乘后,结果也是矩阵。新的矩阵的维度与两个原矩阵的维度有关。一个rxn的矩阵A和一个nxc的矩阵B相乘后,得到的结果AB是一个rxc大小的矩阵。需要注意,第一个矩阵的列数必须和第二个矩阵的行数相等,才能相乘

比如:矩阵A的维度是4x3,矩阵B的维度是3x6,则AB的维度是4x6

矩阵乘法的表达式:
假设有rxn的矩阵A和nxc的矩阵B,相乘后得到一个rxc的矩阵C = AB,那么C中的每个元素Cij等于A的第i行所对应的矢量和B的第j列所对应的矢量进行点乘的结果,即:

简单解释为:
对于每个元素cij,找到A中的第i行和B中的第j列,把他们对应的元素相乘后再加起来,这个和就是cij

性质一:
矩阵乘法不满足交换律:AB ≠ BA

性质二:
矩阵乘法满足结合律:(AB)C = A(BC)ABCDE = ((A(BC))D)E = (AB)(CD)E

特殊的矩阵

方块矩阵

方块矩阵,简称方阵。指行数和列数相等的矩阵,比如:3x34x4的矩阵。

方块矩阵独有的:对角元素——行号和列号相等的元素。只有对角元素非0的矩阵叫对角矩阵

单位矩阵

对角元素都为1的对角矩阵,叫做单位矩阵,用In表示,比如:

单位矩阵特性:任何矩阵和它相乘的结果还是原来的矩阵。相当于标量中1的地位。
MI = IM = M

转置矩阵

转置矩阵实际是对原矩阵的一种运算,即转置运算。一个rxc的矩阵M,其转置表示成MT,是一个cxr的矩阵,本质是原来的矩阵行、列对换。

性质一:
矩阵转置的转置等于原矩阵。
(MT)T = M

性质二:
矩阵串联的转置,等于反向串联各个矩阵的转置。
(AB)T = BTAT

逆矩阵

只有方阵才有逆矩阵,逆矩阵表示为M-1。一个矩阵与它的逆矩阵相乘,结果是一个单位矩阵:
MM-1 = M-1M = I
有点标量里面倒数的味道。

不是所有方阵都有对应逆矩阵,比如:所有元素都为0的矩阵。
如果一个矩阵有对应的逆矩阵,则它是可逆的非奇异性的
相反,则它是不可逆的奇异性的

判断矩阵是否可逆:
矩阵的行列式不为0,则它是可逆的。
参考视频链接:https://www.bilibili.com/video/BV1aW411Q7x1?p=2

性质一:
逆矩阵的逆矩阵是原矩阵本身。
(M-1)-1 = M

性质二:
单位矩阵的逆矩阵是它本身。
I-1 = I

性质三:
转置矩阵的逆矩阵是逆矩阵的转置。
(MT)-1 = (M-1)T

性质四:
矩阵串联相乘后的逆矩阵等于反串联各个矩阵的逆矩阵。
(AB)-1 = B-1A-1
(ABCD)-1 = D-1C-1B-1A-1

矩阵的几何意义是变换,逆矩阵表示还原这个变换,或这个变换的反向变换。
使用变化矩阵M对矢量v进行一次变换,然后再使用逆矩阵M-1进行一次变换,会得到原来的矢量v。
M-1(Mv) = (M-1M)v = Iv = v

正交矩阵

正交矩阵是特殊的方阵。一个方阵M和它的转置矩阵的乘积是单位矩阵,则这个矩阵是正交的。
MMT = MTM = I

有逆矩阵的性质MM-1 = M-1M = I可以得出正交矩阵的逆矩阵是它的转置矩阵:
MT = M-1

正交矩阵可以用转置矩阵的运算代替逆矩阵的运算,因为逆矩阵计算更复杂。

怎样判定一个矩阵是正交矩阵?来看一下它有哪些定义。

因为:

所以:

于是可以得到以下结论:

  • 矩阵的每一行,即c1、c2、c3是单位矢量;(因为他们与自己的点积是1)
  • 矩阵的每一行,即c1、c2、c3之间相互垂直;(因为他们的点积是0)
  • 上述两条,对矩阵的每一列同样适用;(因为正交矩阵的转置通用是正交矩阵)

行矩阵还是列矩阵

一个矢量(比如:平行光的方向、表面发现方向),既可以写成行矩阵的形式,也可以写成列矩阵的形式,但是当它和矩阵相乘时,使用行矩阵还是列矩阵对其乘法的书写次序和结果值是有影响的。

假设有一个矢量v = (x, y, z),写成行矩阵是:v = [x y z],写成列矩阵是:v = [x y z]T(这里使用转置符号表示列矩阵的写法,纯粹为了排版)。另外有一个矩阵M:

当M和行矩阵相乘时,写法为:
vM = [xm11+ym21+zm31 xm12+ym22+zm32 xm13+ym23+zm33]

当M和列矩阵相乘时,写法为:

可以看到两者相乘的书写次序和结果里面元素也是不一样的。

Unity中通常把矢量当做列矩阵,所以相乘时,矢量是放在矩阵的右侧的,且阅读顺序也是从右到左。例如:
CBAv = (C(B(Av)))
表示先对v进行A矩阵变换,再进行B矩阵变换,最后进行C矩阵变换。

]]>
http://wjnovember.github.io/UnityShadersBook03/#disqus_thread
《Unity Shader入门精要》笔记(二) http://wjnovember.github.io/UnityShadersBook02/ http://wjnovember.github.io/UnityShadersBook02/ Sat, 13 Nov 2021 04:49:44 GMT <h1 id="材质和Unity-Shader"><a href="#材质和Unity-Shader" class="headerlink" title="材质和Unity Shader"></a>材质和Unity Shader</h1><p>Unity Shader定义了渲染所 材质和Unity Shader

Unity Shader定义了渲染所需的各种代码、属性和指令;材质则允许我们调整这些属性,并将其最终赋给相应的模型。
通俗讲就是:Shader制定了渲染的规则,材质是让这个物体在这个规则下调整渲染效果。

材质的创建:
方法1:Unity菜单中选择Assets->Create->Material;
方法2:Project视图中右击->Create->Material;
Unity新建的材质,默认使用Standard Shader。

材质结合GameObject的MeshParticle Systems组件来工作:

材质Inspector窗口:

Unity Shader的创建:
方法1:Unity菜单中选择Assets->Create->Shader;
方法2:Project视图中右击->Create->Shader;

Unity提供了4种Unity Shader模板:

  • Standard Surface Shader
    产生一个包含标准光照模型的表面着色器。
  • Unlit Shader
    产生一个不包含光照(但包含雾效)的基本的定点/片元着色器。
  • Image Effect Shader
    屏幕后处理的基本模板。
  • Compute Shader
    特殊的Shader文件,利用GPU的并行性进行一些与常规渲染流水线无关的计算。

前期我们使用比较多的是Unlit Shader

Unity Shader的基础:ShaderLab

Unity Shader是Unity为开发者提供的高层级的渲染抽象层,为我们自定义渲染效果提供遍历,防止和很多文件、设置打交道。

ShaderLab是编写Unity Shader的一种说明性语言,所有Unity Shader都是用ShaderLab编写的。它使用一些嵌套在花括号内部的语义(syntax)来描述Unity Shader文件的结构。

Unity Shader结构

ShaderLab的语义有:Properties、SubShader、Fallback等,这些语义定义了Unity Shader的结构。

Unity Shader基础结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shader "ShaderName" {
Properties {
// 属性
}

SubShader {
// 显卡A使用的子着色器
}

SubShader {
// 显卡B使用的子着色器
}

Fallback "VertexLit"
}

给Shader命名

通过Shader语义指定当前Unity Shader的名字,名字由字符串定义,字符串内可添加斜杠(”/“)对Shader进行分组管理:

1
Shader "Custom/MyShader" { }

Shader命名后,材质面板上可以找到当前Shader的位置:Shader->Custom->MyShader:

Properties

Properties语义块包含一系列属性(Property),这些属性是材质和Unity Shader连通的桥梁。好比:C#脚本里定义一些public变量,在Inspector面板上对应的脚本组件里可以看见并设置这些变量。

1
2
3
4
5
Properties {
Name ("display name", PropertyType) = DefaultValue
Name ("display name", PropertyType) = DefaultValue
// 更多属性
}

Name是Shader访问该属性的变量名,通常以一个下划线开始,比如:_Color、_MainTex等。
display name是Shader在材质面板上看到的属性名称,没有严格的命名规范,闻其名、知其意即可。
PropertyType是当前属性的类型,Unity Shader的属性类型,可以是:颜色、数值、范围值、向量、纹理等。
DefaultValue是当前属性的默认值,不同的PropertyType,其默认值的结构也不同。

  • Int、Float、Range的默认值是单独的数字;
  • Color、Vector的默认值是圆括号包围的一个四维向量;
  • 2D、Cube、3D的默认值是字符串+换括号,字符串可以是空的,也可以是内置的纹理名称,如:”white”、”black”、”bump”等;花括号以前版本用来指定纹理属性的,如:TexGen CubeReflect、TexGen CubeNormal等固定管线坐标的生成,目前基本弃用,所以花括号里内容一般为空;

属性代码例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Shader "Custom/ShaderLabProperties" {
Properties {
// Numbers and Sliders
_Int ("Int", Int) = 2
_Float ("Float", Float) = 1.5
_Range ("Range", Range(0.0, 5.0)) = 3.0

// Colors and Vectors
_Color ("Color", Color) = (1, 1, 1, 1)
_Vector = ("Vector", Vector) = (2, 3, 6, 1)

// Textures
_2D ("2D", 2D) = "" {}
_Cube ("Cube", Cube) = "white" {}
_3D ("3D", 3D) = "black" {}
}
}

对应材质面板的显示:

SubShader

每个Unity Shader里至少包含一个SubShader语义块,可以有多个SubShader。
Unity Shader可以定义不同的SubShader来适应不同平台的显卡,如:高性能显卡使用精度更大的变量、更多的渲染指令,低性能显卡使用精度较低的变量。

SubShader语义块:

1
2
3
4
5
6
7
8
9
10
11
12
SubShader {
// 可选的
[Tags]

// 可选的
[RenderSetup]

Pass {
}

// Other Passes
}

SubShader中可定义很多Pass和一些可选的状态(RenderSetup)和标签(Tags)。
每个Pass定义一次完整的渲染流程,Pass数量越多,渲染性能消耗越大。
状态和标签也可以在Pass中定义,但Pass中使用的标签是特定的,在SubShader中定义的状态会应用于里面的所有Pass。

常见渲染状态:

  • Cull
    Cull Back | Front | Off
    设置剔除模式:剔除背面/正面/关闭剔除。
  • ZTest
    ZTest Less Greater | LEqual | GEqual | Equal | NotEqual | Always
    深度测试函数比较配置。
  • ZWrite
    ZWrite On | Off
    深度写入开启/关闭。
  • Blend
    Blend SrcFactor DstFactor
    开启混合模式并设置混合因子。

配置在SubShader,则所有Pass都生效;
配置在Pass,则只有当前Pass生效。

SubShader的标签:

  • Queue
    控制渲染顺序,指定当前SubShader渲染的物体在哪个渲染队列。
  • RenderType
    对着色器进行分类,比如:不透明的着色器、透明的着色器。可被用于着色器替换功能。
  • DisableBatching
    控制是否禁用批处理,涉及模型空间计算时,需要禁用,因为批处理会让模型坐标丢失,比如:顶点动画。
  • ForceNoShadowCasting
    控制当前SubShader渲染的物体是否会向其他物体投射阴影。
  • IgnoreProjector
    控制当前SubShader渲染的物体是否不接受其他物体投射的阴影,通常用于半透明物体。
  • CanUseSpriteAtlas
    若当前SubShader用于精灵时,将标签设置为“False”。
  • PreviewType
    控制材质面板上显示的预览样式,默认球形,此外还可以设置为“Plane”、“SkyBox”。

写法例子:

1
Tags {"Queue" = "Transparent" "RenderType" = "Opaque" "DisableBatching" = "True"}

Pass的标签:

  • LightMode
    定义当前Pass在Unity的渲染流水线中的角色,比如:“ForwardBase”、“ForwardAdd”。
  • RequireOptions
    用于指定当满足条件时才渲染该Pass,它的值是一个由空格分隔的字符串,目前Unity支持的选项有:“SoftVegetation”。

写法例子:

1
Tags {"LightMode" = "ForwardBase"}

Pass语义块

1
2
3
4
5
6
7
Pass {
[Name]
[Tags]
[RenderSetup]

// Other code
}

【Name】通过Pass的名称,可以使用ShaderLab的UsePass命令来直接使用其他Unity Shader中的Pass,提高Shader代码复用性。如:

1
UsePass "MyShader/MYPASSNAME"

Unity内部会将所有Pass名称转为大写。

【Tags】SubShader的Tags同样适用于Pass,但Pass的Tags不能用于SubShader。

一些特殊的Pass:

  • UsePass
    使用该指令,复用其他的Pass。
  • GrabPass
    该Pass负责抓取屏幕并将结果存储在一张纹理中,以用于后续的Pass处理。

Fallback

1
2
3
Fallback "Name"
// 或者
Fallback Off

若当前Shader的所有SubShader都不能在当前显卡上运行,则使用Fallback定义的Shader;若Fallback定义为Off,则没有后备的Shader支持,物体将显示为洋红色。

Unity Shader的形式

Unity Shader的形式有:表面着色器、顶点着色器、片元着色器、固定函数着色器。

表面着色器

本质是顶点/片元着色器,是Unity内置的更高一层的抽象,Unity内部处理了很多光照细节,代码量更少,但渲染代价比较大。

表面着色器代码使用CG/HLSL编写,写在CGPROGRAMENDCG之间。

表面着色器的代码定义在SubShader语句块中,案例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Shader "Custom/Simple Surface Shader" {
SubShader {
Tags {"RenderType" = "Opaque"}

CGPROGRAM

#pragma surface surf Lambert

struct Input {
float4 color : COLOR;
};

void surf(Input IN, input SurfaceOutput o) {
o.Albedo = 1;
}

ENDCG
}

Fallback "Diffuse"
}

顶点/片元着色器

定点着色器、片元着色器使用CG/HLSL编写,写在CGPROGRAMENDCG之间。
顶点/片元着色器的代码定义在Pass语句块中,案例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Shader "Custom/Simple VertexFragment Shader" {
SubShader {
Pass {
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

float4 vert(float4 v : POSITION) : SV_POSITION {
return mul(UNITY_MATRIX_MVP, v);
}

fixed4 frag() : SV_Target {
return fixed4(1.0, 0.0, 0.0, 1.0);
}

ENDCG
}
}
}

固定函数着色器

固定函数着色器不像前两类着色器一样,它不支持可编程,用于比较老的,不支持可编程渲染管线着色器的设备中,目前基本被淘汰。案例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Shader "Tutorial/Basic" {
Properties {
_Color ("Main Color", Color) = (1, 0.5, 0.5, 1)
}

SubShader {
Pass {
Material {
Diffuse [_Color]
}
Lighting On
}
}
}

从上面代码中可以看到,固定函数着色器仅支持使用ShaderLab的语法配置一些渲染命令,不支持使用CG/HLSL语言编写比较复杂的代码逻辑。

如果需要跟各种光源打交道,建议使用表面着色器,但是需要留意移动平台的性能;
其他情况下,建议使用顶点/片元着色器;
若需要更多自定义的渲染效果,也建议使用顶点/片元着色器。

Unity Shader != 真正的Shader

Unity Shader实际指的是ShaderLab文件,以.shader作为后缀的文件。而ShaderLab是Unity对传统Shader的封装。传统的Shader需要编写冗长的代码来设置着色器的输入输出,需要处理很多的文件;而Unity中的Shader只需要处理一个ShaderLab文件就好。

]]>
http://wjnovember.github.io/UnityShadersBook02/#disqus_thread
游戏开发编码规范 http://wjnovember.github.io/LuaCodeRule/ http://wjnovember.github.io/LuaCodeRule/ Tue, 09 Nov 2021 15:31:52 GMT <p>最近梳理了下公司游戏开发的编码规范,考虑到平时开发主要以Lua语言为主,所以本文也以Lua代码作为案例,除了Lua语言自身的特性,其他规范思路其他语言亦可适用。<br>本文主要从命名规范、格式规范以及性能相关三个方面进行规范的说明,后续开发过程中若有新的规范制定,持续更新本 最近梳理了下公司游戏开发的编码规范,考虑到平时开发主要以Lua语言为主,所以本文也以Lua代码作为案例,除了Lua语言自身的特性,其他规范思路其他语言亦可适用。
本文主要从命名规范、格式规范以及性能相关三个方面进行规范的说明,后续开发过程中若有新的规范制定,持续更新本文内容。

命名规范

大规则:命名需做到“闻其名、知其意”,在不影响其表意的基础上精简命名长度。

local变量命名

以“小驼峰命名法”作为命名规范,如:myKingdom、userInfo等。

若对视图层变量进行命名,可以“视图类型+表意”方式命名,可参考:

1
2
3
4
5
6
7
8
-- 确认按钮
local btnConfirm = V.createButton()

-- 描述文本
local lblDesc = V.createLabel()

-- 王国名称输入框
local editKingdomName = V.createInputBox()

亦可以“表意+视图用处”方式命名,可参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 名字下方的背景图
local nameBg = lc.createSprite("g_bg_01")

-- 资源区域
local resArea = lc.createNode(500, 200)

-- 奖励条目
local bonusItem = self:createItem(...)

-- 聊天列表
local chatList = lcList.create(...)

-- 聊天面板
local chatPanel = ChatPanel.create(...)

-- 主要内容区域
local contentContainer = lc.createNode(...)

为了和方法内局部变量做区分,全文件范围的local变量在上述规则基础上,以下划线开始,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local M = class("ChatForm", Form)

-->>>>> 以下划线开始的文件范围局部变量的命名规范
local _animInterval = 0.5

local _tabId = {
world_chat = 1,
kingdom_chat = 2,
}

function M:init(...)
-- 方法内局部变量命名,不以下划线开始
local form = M.super.init(self, ...)
form:setTabId(_tabId.world_chat)
end

table中的key,以小写字母+下划线命名,比如:

1
2
3
4
M.TabId = {
world_chat = 1,
kingdom_chat = 2,
}

有时为了简化书写、简化阅读,从成员变量中取视图变量,赋值给local变量的时候,命名可以简写为“视图用途”或“视图类型”,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function M:updateTopArea()
-- topArea简写为area
local area = self._topArea
area:setContentSize(...)

-- btnAdd简写为btn
local btn = area._btnAdd
btn:setLabel(...)
btn:setPosition(...)

-- 但是

-- 当存在多个获取不同button的情况,建议使用全写(btnAdd、btnCancel)
-- 因为相同命名时,代码位置调整可能导致逻辑错乱
local btnAdd = area._btnAdd
btnAdd:setLabel(...)
btnAdd:setPosition(...)

local btnCancel = area._btnCancel
btnCancel:setLabel(...)
btnCancel:setPosition(...)
end

成员变量命名

lua中成员变量一般指存储在self里的变量,其命名规则为:在local变量命名规范基础上,以下划线开始,如:

1
2
3
4
5
6
7
8
9
local topArea = lc.createNode(...)
lc.addChildToPos(self._form, topArea, lc.p(...))
-- 存储成员变量
self._topArea = topArea

local btnConfirm = V.createButton()
lc.addChildToPos(topArea, btnConfirm, lc.p(...))
-- 存储成员变量
self._btnConfirm = btnConfirm

存在类里(比如:M类)的变量,其命名同样以下划线开始,如:

1
2
3
function M:initDefines()
M._defs = {...}
end

方法命名

方法的命名大小写规范,一般以小驼峰命名,如:

1
2
function M:updateTopArea()
end

但是考虑到C#转Lua导致一些Unity内置方法名必须是大驼峰命名,为了保持一致,可以考虑项目中的所有方法以大驼峰方式命名,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local M = class("InfoPanel")

-->>>>> 内置方法
function M:Awake()
end

function M:Start()
end

function M:OnEnable()
end

--<<<<< 自定义方法
function M:UpdateTopArea()
end

方法命名格式,一般是“动词+名词”,可参考如下:

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
-->>>>> 创建某个组件或初始化某块区域,以create、init开头

-- 创建一个节点
function M:createNode()
end

-- 创建一个按钮
function M:createButton()
end

-- 初始化顶部区域
function M:initTopArea()
-- 当这个方法里只要一个表示区域的变量,可以考虑简写,规则同“成员变量命名”的规则
-- 比如这里局部变量写area,而非topArea
local area = self:createNode()
lc.addChildToPos(self._form, area, lc.p(...))
self._topArea = area

local btnConfirm = self:createButton()
lc.addChildToPos(area, btnConfirm, lc.p(...))
area._btnConfirm = btnConfirm

local btnCancel = self:createButton()
lc.addChildToPos(area, btnCancel, lc.p(...))
area._btnCancel = btnCancel
end

-->>>>> 以update开头作为视图刷新方法的开头

-- 更新顶部的区域
function M:updateTopArea()
end

-->>>>> 更改、获取数值的方法分别以set、get开头

-- 设置道具数量
function M:setItemNum(...)
end

-- 获取道具数量
function M:getItemNum()
end

-->>>>> 以is、can、should等作为返回布尔值的判断方法的开头

-- 判断技能模块是否解锁
function M:isUnlockSkill()
end

-- 判断玩家能否成为海盗
function M:canBePirate()
end

-- 判断是否应该检查刷新该区域
function M:shouldCheckUpdateTopArea()
end

-->>>>> 以check作为开头,用于将判断方法和更新方法包起来

-- 判断并刷新顶部区域
function M:checkUpdateTopArea()
if not self:shouldCheckUpdateTopArea() then return end

self:updateTopArea()
end

格式规范

如果把一个代码文件想象成一个房间,房间里的东西应该是近似强迫症一样地整齐摆放好,而不是像杂货堆,这是本小节规范格式需要达到的效果——“整齐、清爽、不杂糅”。对于不艰涩的逻辑,全篇代码甚至可以一行注释都不加就能读懂,类似这样:

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
local M = class("OptionForm", Form)

function M:init()
self:initTopArea()
self:initBottomArea()

self:syncData(V.SyncFlag.init)
end

function M:initTopArea()
local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))
self._topArea = area

local lblName = V.createLabel()
lc.addChildToPos(area, lblName, lc.p(x2, y2))
area._lblName = lblName

local lblDesc = V.createLabel()
lc.addChildToPos(area, lblDesc, lc.p(x3, y3))
area._lblDesc = lblDesc
end

function M:initBottomArea()
local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))
self._midArea = area

local lblOption = V.createLabel()
lc.addChildToPos(area, lblOption, lc.p(x2, y2))
area._lblOption = lblOption

local btnConfirm = V.createButton(function() self:onBtnConfirm() end)
lc.addChildToPos(area, btnConfirm, lc.p(x3, y3))
area._btnConfirm = btnConfirm

local btnCancel = V.createButton(function() self:onBtnCancel() end)
lc.addChildToPos(area, btnCancel, lc.p(x4, y4))
area._btnCancel = btnCancel
end

function M:syncData(flag)
M.super.syncData(self, flag)

self:updateData()
self:updateTopArea()
self:updateBottomArea()
end

function M:updateData()
self._data = P:getOptionData()
end

function M:updateTopArea()
local area = self._topArea
local data = self._data

local lblName = area._lblName
local user = data._user
lblName:setString(user._name)
lblName:setTextColor(user._isNpc and V.Color.gray or V.Color.white)

area._lblDesc:setString(data._desc)
end

function M:updateBottomArea()
local lblOption = self._bottomArea._lblOption
lblOption:setString(self._data._option)
lblOption:setPosition(x1, y1)
end

function M:onEnter()
M.super.onEnter(self)

self:addListener(Event.Player.name_change, function(evt) self:onPlayerNameChange(evt._param) end)
self:addListener(Event.Option.dirty, function(evt) self:onOptionDirty(evt._param) end)
self:addListener(Event.Option.remove, function() self:hide() end)
end

function M:onPlayerNameChange(user)
local data = self._data
if data == nil then return end
if data._user._id ~= user._id then return end

self:updateTopArea()
end

function M:onOptionDirty(option)
local data = self._data
if data == nil then return end
if data._optionId ~= option._id then return end

self:syncData(V.SyncFlag.update)
end

function M:onBtnConfirm()
self._data._user:setConfirmOption()
self:hide()
end

function M:onBtnCancel()
self._data._user:setDenyOption()
self:hide()
end

空格规范

逗号后面留空格,比如:

1
2
3
4
5
-- 正例
local btnConfirm, btnCancel

-- 反例
local btnConfirm,btnCancel

运算符、等号两边留空格,比如:

1
2
3
4
5
-- 正例
local a = b > c and b or (c - b) * 2

-- 反例
local a=b>c and b or (c-b)*2

小括号内侧不留空格,比如:

1
2
3
4
5
-- 正例
local a = (b - c) * 2

-- 反例
local a = ( b - c ) * 2

条件、循环语句

if语句块内侧,开始和结束不要留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 正例
local data = self._data
if data._id > 0 then
lblName:setString(data._name)
end

-- 反例
local data = self._data
if data._id > 0 then

lblName:setString(data._name)

end

if语句块前留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 正例
local data = self._data
local isVisible = (data._id > 0)

local lblName = self._lblName
lblName:setVisible(isVisible)

if isVisible then
lblName:setString(data._name)
end

-- 反例
local data = self._data
local isVisible = (data._id > 0)
local lblName = self._lblName
lblName:setVisible(isVisible)
if isVisible then
lblName:setString(data._name)
end

如果if前面存在单独一行代码,且这行代码与判断逻辑本身存在密切关联,if前面可以不留空行,比如:

1
2
3
4
5
6
7
local data = self._data
self._lblDesc:setString(data._desc)

local user = data._user
if user._isNpc then
self._lblNpcName:setString(user._name)
end

elseif语句块前面留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 正例
local user = data._user
if user._isNpc then
self._lblNpcName:setString(user._name)

elseif user._id == myId then
self._lblMyName:setString(user._name)
end

-- 反例
local user = data._user
if user._isNpc then
self._lblNpcName:setString(user._name)
elseif user._id == myId then
self._lblMyName:setString(user._name)
end

else语句块是否留空行取决于前面的ifelseif
如果前面是elseif,则else前面必留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 正例
local user = data._user
if user._isNpc then
self._lblNpcName:setString(user._name)

elseif user._id == myId then
self._lblMyName:setString(user._name)

else
self._lblOtherName:setString(user._name)
end

-- 反例
local user = data._user
if user._isNpc then
self._lblNpcName:setString(user._name)

elseif user._id == myId then
self._lblMyName:setString(user._name)
else
self._lblOtherName:setString(user._name)
end

如果前面是if,且if语句块是多行,则else前面必留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 正例
local data = self._data
local lblName = self._lblName

if user._isNpc then
lblName:setString(data._npcName)
lblName:setTextColor(V.Color.gray)

else
lblName:setString(data._playerName)
lblName:setTextColor(V.Color.white)
end

-- 反例
local data = self._data
local lblName = self._lblName

if user._isNpc then
lblName:setString(data._npcName)
lblName:setTextColor(V.Color.gray)
else
lblName:setString(data._playerName)
lblName:setTextColor(V.Color.white)
end

如果前面if语句块是单行,且else语句块也是单行,则else前面可以不留空行,比如:

1
2
3
4
5
6
7
8
local data = self._data
local lblName = self._lblName

if user._isNpc then
lblName:setString(data._npcName)
else
lblName:setString(data._playerName)
end

如果前面if语句块是单行,但else语句块是多行,则else前面需要留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 正例
local data = self._data
local lblName = self._lblName

if user._isNpc then
lblName:setString(data._npcName)

else
lblName:setString(data._playerName)
self._lblNpcDesc:setString(data._npcDesc)
end

-- 反例
local data = self._data
local lblName = self._lblName

if user._isNpc then
lblName:setString(data._npcName)
else
lblName:setString(data._playerName)
self._lblNpcDesc:setString(data._npcDesc)
end

上面else留空行的规则可以简单理解为:只允许ifelse里都只有一行代码时,可以不留空行,其他情况均需要留空行。

whiledo ... whilefor循环语义块前面留空行规则与if相同。

方法定义

每个定义的方法之间需要留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 正例
function M:initTopArea()
...
end

function M:initBottomArea()
...
end

-- 反例
function M:initTopArea()
...
end
function M:initBottomArea()
...
end

方法定义内部,开始和结束不留空行,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 正例
function M:initTopArea()
local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))

local lblTitle = V.createLabel()
lc.addChildToPos(area, lblTitle, lc.p(x2, y2))
end

-- 反例
function M:initTopArea()

local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))

local lblTitle = V.createLabel()
lc.addChildToPos(area, lblTitle, lc.p(x2, y2))

end

视图的创建与刷新

不同组件的创建、刷新逻辑之间以空行分隔,比如:

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
-- 正例
local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))
self._midArea = area

local lblOption = V.createLabel()
lc.addChildToPos(area, lblOption, lc.p(x2, y2))
area._lblOption = lblOption

local btnConfirm = V.createButton(function() self:onBtnConfirm() end)
lc.addChildToPos(area, btnConfirm, lc.p(x3, y3))
area._btnConfirm = btnConfirm

local btnCancel = V.createButton(function() self:onBtnCancel() end)
lc.addChildToPos(area, btnCancel, lc.p(x4, y4))
area._btnCancel = btnCancel

-- 反例1
local area = lc.createNode()
lc.addChildToPos(self._form, area, lc.p(x1, y1))
self._midArea = area
local lblOption = V.createLabel()
lc.addChildToPos(area, lblOption, lc.p(x2, y2))
area._lblOption = lblOption
local btnConfirm = V.createButton(function() self:onBtnConfirm() end)
lc.addChildToPos(area, btnConfirm, lc.p(x3, y3))
area._btnConfirm = btnConfirm
local btnCancel = V.createButton(function() self:onBtnCancel() end)
lc.addChildToPos(area, btnCancel, lc.p(x4, y4))
area._btnCancel = btnCancel

-- 反例2
local area = lc.createNode()
local lblOption = V.createLabel()
local btnConfirm = V.createButton(function() self:onBtnConfirm() end)
local btnCancel = V.createButton(function() self:onBtnCancel() end)

lc.addChildToPos(self._form, area, lc.p(x1, y1))
lc.addChildToPos(area, lblOption, lc.p(x2, y2))
lc.addChildToPos(area, btnConfirm, lc.p(x3, y3))
lc.addChildToPos(area, btnCancel, lc.p(x4, y4))

self._midArea = area
area._lblOption = lblOption
area._btnConfirm = btnConfirm
area._btnCancel = btnCancel

上述代码,反例2虽然看起来也很整齐,但是一旦涉及相关代码删除,或是添加新的组件创建代码,就需要上下移动光标到指定位置修改3个地方代码,且我们没法保证所有的节点创建只有createlc.addChildToPosself._xxx = xxx三个部分,所有节点没法做到统一,为了方便维护,还是建议不同的组件以空行分开。

其他

上述规则没有提及的部分,暂时没有硬性规定,但是在书写的过程中,还是需要我们凭借语感来判定是否需要用空行分开,何为语感:
就是当我写了一坨很厚的代码,review起来感觉有点费力时,用空行将其分开后,再看这坨代码,感觉舒服多了,这就是语感。

合并行规范

local变量声明

有时我们需要在一开始声明并赋值多个local变量,但是这些变量存在着一定关联,可以将多个变量合并为一行:

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
-- 多行声明local变量
function M:updateActivity()
-- 分多行声明
local btnActivity = self._btnActivity
local btnWelfare = self._btnWelfare

local isVisible = self:isVisible()
btnActivity:setVisible(isVisible)
btnWelfare:setVisible(isVisible)

if isVisible then
return
end

-- 分多行声明
local data = {}
local rawData = P._playerActivity:getData()

for i = 1, #rawData do
local info = rawData[i]
if self:isValid() then
data[#data + 1] = info
end
end

...
end

-- 单行声明local变量
function M:updateActivity()
-- 单行声明
local btnActivity, btnWelfare = self._btnActivity, self._btnWelfare
local isVisible = self:isVisible()

btnActivity:setVisible(isVisible)
btnWelfare:setVisible(isVisible)

if isVisible then
return
end

-- 单行声明
local data, rawData = {}, P._playerActivity:getData()
for i = 1, #rawData do
local info = rawData[i]
if self:isValid() then
data[#data + 1] = info
end
end

...
end

条件语句

有时条件语句里面没有包含过多的逻辑代码,比如只有:breakreturn等,我们允许这些关键词和if条件判断放到同一行,比如:

1
2
3
4
5
6
7
8
9
10
11
function M:checkUpdateTopArea()
local area = self._topArea
if area == nil then return end

local rawData = P:getRawData()
for i = 1, #rawData do
if rawData[i]._isNpc then break end
end

...
end

但是当if条件后跟着elseifelse时,不建议将break等关键字写在同一行,这样会让代码结构看起来很难看,比如:

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
-- 正例
local data, rawData ={}, P:getRawData()
for i = 1, #rawData do
if rawData[i]._isNpc then
break
else
data[#data + 1] = rawData[i]
end
end

-- 反例1
local data, rawData ={}, P:getRawData()
for i = 1, #rawData do
-- 这样代码看起来排布太密,有点窒息的感觉
if rawData[i]._isNpc then break
else data[#data + 1] = rawData[i]
end
end

-- 反例2
local data, rawData ={}, P:getRawData()
for i = 1, #rawData do
-- 不建议一行代码里包含超过2个逻辑,且断点调试不太方便
if rawData[i]._isNpc then break else data[#data + 1] = rawData[i] end
end

缩进规范

大规则:所有的Tab由制表符改为4个空格。
VS Code里可设置,如下图:

之所以如此改,是考虑一份代码文件里,缩进有时用制表符,有时会用空格。而不同平台下,解析制表符得到的缩进和4个空格的缩进宽度是不一致的,所以干脆将Tab按键由制表符统一为4个空格。

基于此规则,下面提到的“缩进”表示“缩进4个空格”。

长条件语句

我们经常会遇到if语句太长,超出一屏的情况,这时会考虑将if语句写成多行,而换行后的条件判断,建议以2个缩进书写,比如:

1
2
3
4
5
6
7
8
9
10
-- 单行if语句
if data._type ~= D.Type.world_chat and data._type ~= D.Type.area_chat and data._user._id ~= P._id and data._content ~= "" then
...
end

-- 改写成多行if语句
if data._type ~= D.Type.world_chat and data._type ~= D.Type.area_chat
and data._user._id ~= P._id and data._content ~= "" then
...
end

两个缩进的好处是:明显与if语句里的逻辑代码区分开,很容易知晓这个缩进是if条件判断。

方法作为参数传入

比如按钮的创建封装,会经常把回调方法传进去,而调用这个封装方法时,会传入回调方法,可以有这么几种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 按钮的创建封装方法定义
function V.createButton(bgName, callback)
...
end

-- 调用这个封装方法,对于回调方法的传入,有诸多写法

-- 参考1:方法主体不做额外缩进
local btnConfirm = V.createButton("g_bg_01", function()
local ...
end, btnW, btnH)

-- 参考2:方法主体缩进2个单位(8个空格)
local btnConfirm = V.createButton("g_bg_01", function()
local ...
end, btnW, btnH)

-- 参考3:function关键词换行缩进2个单位(8个空格),方法主体与function缩进齐平
local btnConfirm = V.createButton("g_bg_01",
function()
local ...
end,
btnW,
btnH)

其他代码逻辑走传统缩进规则即可。

提前return代替嵌套

有时因为逻辑需要,会涉及到很多的if条件嵌套,针对这种情况,建议走not条件,提前return,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 嵌套写法
function M:checkUpdateTopArea()
if cond1() then
...
if cond2() then
...
if cond3() then
self:updateTopArea()
end
end
end
end

-- 提前return写法
function M:checkUpdateTopArea()
if not cond1() then return end
if not cond2() then return end
if not cond3() then return end

self:updateTopArea()
end

这样的写法是不是更加优雅?

性能相关

性能优化是一款游戏上线后必做的事,但是性能瓶颈有很大程度是代码书写不规范导致的,因此有必要在这里提一提影响性能的代码书写。

事件分发与接收

避免在循环中分发事件,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 正例
local params = {}
local data = self._data

for i = 1, #data do
local info = data[i]
if self:isValid(info) then
params[info._id] = info
end
end
lc.sendEvent(eventName, params)

-- 反例
local data = self._data
for i = 1, n do
local info = data[i]
if cond() then
lc.sendEvent(eventName, info)
end
end

如果可以预见有些事件会在同一帧分发多次,建议在收事件的地方用dirty延迟刷新代替立即刷新,比如:

1
2
3
4
5
6
7
8
9
10
11
-- 正例
self:addListener(eventName, function(evt) self._viewDirty = true end)

function M:onSchedule()
if self._viewDirty then
self:updateView()
end
end

-- 反例
self:addListener(eventName, function() self:updateView() end)

事件接收的监听回调需要做好严格筛选、层层把关,防止出现不必要的刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 正例
self:addListener(eventName, function(evt) self:onEvent(evt._param) end)

function M:onEvent(param)
if not cond1(param) then return end
if not cond2(param) then return end
if not cond3(param) then return end
if not cond4(param) then return end

self:updateView()
end

-- 反例
self:addListener(eventName, function() self:updateView() end)

逻辑筛选一般没有视图刷新来得复杂。

能做局部刷新,尽量不要全局刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function M:updateView()
self:updateArea1()
self:updateArea2()
self:updateArea3()
self:updateArea4()
self:updateArea5()
end

-- 正例
self:addListener(eventName, function()
self:updateArea3()
self:updateArea5()
end)

-- 反例
self:addListener(eventName, function() self:updateView() end)

频繁调用的方法需要格外注意

在定时器这类每帧都调用的回调方法中,避免做遍历逻辑,尤其是无效的遍历,如果实在无法规避遍历的坑,建议将数组遍历改为Map索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 正例
function M:onSchedule()
local target = map[targetId]
if target then
-- do something
end
end

-- 反例
function M:onSchedule()
local target
for i = 1, #array do
if array[i]._id == targetId then
target = array[i]
end
end

if target then
-- do something
end
end

避免在频繁调用的方法里做复杂的计算,比如:

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
-- 之前做的一款2D游戏,客人消费后往地上扔银币
-- 客人可能会一次性扔3~10个银币

-- 在每帧都会调用的Update方法,在里面做模拟银币抛物线扔到地上的位置计算
function M:Update()
local progress = ...
local pos = Vector3.zero
pos.x = startPos + spdX * progress

if progress < 0.3 then
pos.y = startY + progress * 0.3

elseif progress >= 0.3 and progress < 0.5 then
pos.y = startY + (progress - 0.3) * 0.2 * factor1

elseif progress >= 0.5 and progress < 0.7 then
pos.y = startY + (0.7 - progress) * 0.2 * factor2

elseif progress >= 0.7 then
pos.y = startY + (1 - progress) * factor3
end

object.transform.localPosition = pos
end

-- 优化后,改成用Spine代替实时位置计算

引入缓存机制,避免做重复且结果不变的计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function M:onSchedule()
local isSupport = Buff.getVal(buffId) > 0
if isSupport then
-- do something
end
end

-- 正例
function Buff.getVal(buffId)
if cache[buffId] then return cache[buffId] end

local val = 0
...
cache[buffId] = val
return val
end

-- 反例:没有缓存机制
function Buff.getVal(buffId)
local val = 0
...
return val
end

关于local变量和全局变量

在Lua里,访问局部变量比访问全局变量的速度快,而Lua里全局变量使用较多的则是self变量以及全局类名。在平时的调用中,在一段代码块里,若出现使用self获取相同的变量超过2次(包含2次),建议将self变量先赋值给local变量,再调用,比如:

1
2
3
4
5
6
7
8
9
10
11
12
-- 正例
function M:updateTopArea()
local btnAct = self._btnAct
btnAct:setLabel(...)
btnAct:setPosition(...)
end

-- 反例
function M:updateTopArea()
self._btnAct:setLabel(...)
self._btnAct:setPosition(...)
end

如果存在全局的类,调用多次,也建议先赋值给local变量,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- WorldGrid是一个类,记录在全局变量里

-- 正例
local WorldGrid = WorldGrid
local grids = {}

for i = 1, 200 do
grids[#grids + 1] = WorldGrid.create(...)
end

-- 反例
local grids = {}
for i = 1, 200 do
grids[#grids + 1] = WorldGrid.create(...)
end

尤其不要在循环中使用多重索引,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 正例
local WorldGrid = WorldGrid
local group = self._grids._group
for i = 1, 200 do
local gridData = WorldGrid.create(rawData[i])
gridData._index = i
group[#group + 1] = gridData
end

-- 反例
for i = 1, 200 do
self._grids._group[#self._grids._group + 1] = WorldGrid.create(self._rawData[i])
self._grids._group[#self._grids._group]._index = i
end

本文内容至此,若有不正确之处,欢迎评论区留言指正!

]]>
http://wjnovember.github.io/LuaCodeRule/#disqus_thread
《Unity Shader入门精要》笔记(一) http://wjnovember.github.io/UnityShadersBook01/ http://wjnovember.github.io/UnityShadersBook01/ Sun, 07 Nov 2021 08:29:58 GMT <h1 id="渲染流水线"><a href="#渲染流水线" class="headerlink" title="渲染流水线"></a>渲染流水线</h1><p><strong>渲染流水线</strong>的工作任务是:将三维场景里的物体投到屏幕上,生成一张二维图像。<br>可 渲染流水线

渲染流水线的工作任务是:将三维场景里的物体投到屏幕上,生成一张二维图像。
可分为三个阶段:应用阶段几何阶段光栅化阶段

  • 应用阶段
    CPU负责的阶段,应用主导,开发者有绝对的控制权,主要有三个任务:

    • 准备好场景数据
    • 不可见物体剔除,提高渲染性能
    • 设置好每个模型的渲染状态,如:材质、纹理、Shader等

    该阶段最重要的输出是渲染图元,如:点、线、三角面等,会被传递到下一个有GPU负责的阶段——几何阶段。

  • 几何阶段
    GPU负责的阶段,与每个渲染图元打交道,将三维空间的顶点数据转换到屏幕空间中,再将转换后的数据交给下一个阶段——光栅化阶段处理。关键词:逐顶点

  • 光栅化阶段
    GPU负责的阶段,从上一阶段接过图元在屏幕空间的数据,差值计算后,决定图元里哪些像素会被绘制到屏幕中、被绘制成什么颜色。关键词:逐像素

CPU和GPU之间的通信

应用阶段的三个阶段:

  • 把数据加载到显存
    数据加载到显存后,RAM的数据就可以移除了。但从硬盘加载到RAM过程十分耗时,CPU依然要访问数据,所以有些RAM中的数据不会马上移除。

  • 设置渲染状态
    这些状态定义了场景中的网格是怎么被渲染的。

  • 调用Draw Call
    Draw Call就是CPU发起命令,告诉GPU去执行一个渲染过程。一次DC(Draw Call)会指向本次调用需要渲染的图源列表。

GPU流水线

GPU从CPU那里拿到顶点数据后,经过几何阶段光栅化阶段将场景里的物体绘制到屏幕中。

  • 几何阶段
    • 顶点着色器
      完全可编程,实现顶点的空间变换、顶点着色等功能。
    • 曲面细分着色器
      可选的着色器,用于细分图元。
    • 几何着色器
      可选的着色器,执行逐图元的着色操作,或者生产更多的图元。
    • 裁剪
      将不存在摄像机视野内的顶点裁掉,并剔除某些三角图元的面片;也可以通过指令控制裁剪三角图元的正面或背面。
    • 屏幕映射
      不可配置、不可编程,负责把每个图元的坐标转换到屏幕坐标系中。
  • 光栅化阶段
    • 三角形设置
      固定函数的阶段。
    • 三角形遍历
      固定函数的阶段。
    • 片元着色器
      完全可编程,实现逐片元的着色操作。
    • 逐片元操作
      不可编程,但可配置性很高,负责执行很多重要操作,如:修改颜色、深度缓冲、进行混合等。

我们需要重点关注的是顶点着色器(Vertex Shader)片元着色器(Fragment Shader)

顶点着色器

顶点着色器需要完成工作主要有:坐标转换逐顶点光照

坐标转换,将模型的顶点坐标从模型空间转换到其次裁剪空间。

需要注意:
OpenGL中NDC的z分量范围是[-1, 1]
DirectX中NDC的z分量范围是[0, 1]

NDC,全称Normalized Device Coordinates,归一化的设备坐标。(后续会详细了解)

裁剪

一个图元和摄像机视野的关系有3种:

  • 完全在视野范围内
    不裁剪,直接进入下一流水线阶段。
  • 部分在视野范围内
    进行裁剪后,进入下一流水线阶段。
  • 完全在视野范围外
    被剔除,不会进入下一流水线阶段。

屏幕映射

屏幕映射前,顶点的坐标仍然在三维坐标系下,屏幕映射的任务是将每个图元的x、y坐标转换到屏幕坐标系下。
屏幕坐标系和z坐标一起构成了窗口坐标系

屏幕坐标系在OpenGL和DirectX之间的差异:

三角形设置

光栅化的第一个流水线阶段。
光栅化两个最重要的目标:

  • 计算每个图元(一般是三角形面片)覆盖了哪些像素
  • 为这些像素计算颜色

三角形设置是一个计算三角形网格表示数据的过程,提供三角形边界的表示方式,为下阶段三角形遍历做准备。

三角形遍历

遍历判断每个像素是否被一个三角网格覆盖,若覆盖,则生成一个片元(fragment),这个过程也叫扫描变换。片元的信息数据通过三个顶点差值得到。

片元着色器

DirectX中也被称为像素着色器(Pixel Shader)
片元着色器的输入是顶点着色器的输出差值得到的结果,片元着色器的输出是一个或多个颜色值。

逐片元操作

OpenGL里称为逐片元操作,DirectX中称为输出合并阶段。这个阶段有几个主要任务:

  • 决定每个片元可见性,涉及:深度测试、模板测试等。
  • 通过测试后的片元与颜色缓冲区的颜色进行合并/混合。

深度测试、模板测试的简化流程图:

  • 模板测试
    高度可配置。
    模板缓冲,和颜色缓冲、深度缓冲几乎是一类东西。即当前像素读取的参考值和模板缓冲中读取的参考值进行比较,满足条件则通过模板测试,条件规则由开发者指定。
    不管模板测试有没有通过,我们都可以根据模板测试和深度测试的结果来修改模板缓冲区,操作修改可由开发者指定。

  • 深度测试
    高度可配置。
    与模板测试类似,将当前片元的深度值和深度缓冲区的深度值进行比较,比较函数可由开发者设置,通常这个比较函数是小于等于的关系,也就是显示距离相机更近的物体。
    如果深度测试没有通过,它没有权利更改深度缓冲区中的值;如果通过了,开发者可以指定是否用这个片元的深度值盖掉缓冲区中的深度值——通过开启/关闭深度写入来控制。

  • 混合
    高度可配置。
    开发者可选择开启/关闭混合模式,来控制是直接覆盖,还是将源颜色(当前片元的颜色)和目标颜色(颜色缓冲区的颜色)进行混合后写入颜色缓冲区。

有些GPU为了提高性能,将深度测试放到片元着色器之前处理,这种技术称为Early-Z技术

经过上述流程,颜色缓冲区中的颜色值被显示到屏幕上,但是为了防止正在进行光栅化的图元被显示在屏幕上,GPU采取了 双重缓冲(Double Buffering) 的策略,所以对场景的渲染是发生在幕后的,即: 后置缓冲(Back Buffer) 中。

什么是Shader

Shader本质就是运行在GPU流水线上的可高度编程的代码,主要有:顶点着色器(Vertex Shader)片元着色器(Fragment Shader),今后的开发学习中也主要是和这两个着色器打交道。

]]>
http://wjnovember.github.io/UnityShadersBook01/#disqus_thread
一文入门游戏中的引导设计 http://wjnovember.github.io/guideSummary/ http://wjnovember.github.io/guideSummary/ Mon, 01 Nov 2021 13:34:59 GMT <blockquote> <p>一年多以前写了两篇从零开始设计游戏引导框架,第二篇写完后,发现写不下去了。游戏引擎形形色色,语言多种多样,如果继续写下去发现并不具有通用性,所以中断了。现在,引导的代码经过多个项目的打磨,有了一定的改进,想着简单分享一下引导的实现思路吧!<br>本

一年多以前写了两篇从零开始设计游戏引导框架,第二篇写完后,发现写不下去了。游戏引擎形形色色,语言多种多样,如果继续写下去发现并不具有通用性,所以中断了。现在,引导的代码经过多个项目的打磨,有了一定的改进,想着简单分享一下引导的实现思路吧!
本文以Lua代码作为样例,重在思路分享,其他语言亦可实现。

1. 引导认知

1.1 引导分类

引导的分类可分为新手引导触发式引导任务前往引导

1)新手引导

新注册的账号进入游戏后,出现立绘对话、交代游戏背景、告知玩家基础操作的引导,称为新手引导。一般新手引导持续至玩家可以自由操作时结束。

2)触发式引导

玩家自由操作过程中,因达到某种条件而触发的引导,称为触发式引导。大多数情况下,触发式引导会随着新系统的开启。

3)任务前往引导

点击任务前往按钮后,出现手指引导玩家按照任务实现流程去操作的过程,称为任务前往引导

1.2 组别和步骤

1)组别

为方便后续内容达成统一认知,在此将一个个不同功能作用的引导以组别进行区分。比如:一进入游戏即出现的新手引导、英雄升星引导、文字版的抽卡引导等,每一个引导特定功能的引导都是一个组别。

2)步骤

在一个引导组别里,每一步引导的操作都成为步骤,比如:引导点击一个按钮、出现一个立绘对话、出现一个高亮框等,都是引导的步骤。

1.3 触发条件与触发点

触发条件触发点,是引导组别的概念。
举个例子,文字版里有一个抽卡引导,条件是玩家通关1-4。那么对于抽卡这个组别的引导,通关1-4就是它的触发条件,而触发点就是每一个关卡通关那一刻。通俗来讲就是:

1
2
3
4
5
触发点:
什么时候判断

触发条件:
判断是否满足条件

1.4 操作

操作是引导步骤的概念。
举个例子,文字版抽卡引导的步骤流程是:

  • 出现立绘对话,告知玩家“招仙台”系统已开启
  • 点击主界面“道场”页签
  • 点击“招仙台”按钮
  • 提示免费单抽
  • 点击“单抽”按钮

上面的每一个步骤都是操作

操作根据表现形式可分为如下几类:

  • 点击
  • 拖动
  • 立绘对话
  • 提示文本
  • 效果(高亮框等)

1.5 保存点

保存点的作用:为保证引导在玩家中途退出重进时依然能从中断点恢复。保存点需要上传记录到服务端。

结合以上几点,我们建立起对引导的认知:
在某个 触发点 ,判断某个 组别 的引导满足 触发条件 ,于是一个 步骤 一个步骤地引导玩家去完成最基础的系统流程的 操作 ,并且支持退出游戏重进时能从中途的 保存点 恢复并继续引导。

2. 引导的配表

引导的配表可分为:guide_group表、guide_step表、guide_text表。

2.1 guide_group配表

guide_group表,主要控制某个组别的引导要不要触发,具体配置如下:

ID说明起始步骤触发条件优先级
_id_stepId_triggerCond_priority
II[SI
1初始的新手引导1010
2抽卡引导201{“scene”:”MainScene”,”form”:””,”missionId”:100004}1

2.2 guide_step配表

guide_step表,主要控制在某个组别的引导触发时,每一步操作具体是什么。相同组别引导的步骤ID连续,且ID不为100的倍数。具体配置如下:

ID说明操作数值其他参数保存步骤
_id_action_val_param_saveId
HIISI
201立绘对话300011002202
202点击“道场”页签1000110
203点击“招仙台”按钮1000210
204提示免费单抽400011002{“form_show”:105,”form”:105,”node”:”_btnLotteryOne”,”dir”:”top”}0
205点击“单抽”按钮后关闭提示100030{“form”:105,”node”:”_btnLotteryOne”}100

_action说明

10000+:点击
10001 点击主界面的页签 ,val 表示第几个页签
10002 点击某个系统入口,val 表示哪个系统
10003 按照代码变量名找到按钮进行点击

20000+:拖拽

30000+:立绘对话
30001 普通立绘对话

40000+:文本提示
40001 普通文本提示

_val说明

不同_action,对应_val表意不同

10001 主界面第几个页签
10002 表示系统ID枚举

30000+ 表示立绘对话的文本ID,从guide_text表里读取
40000+ 表示文本提示的文本ID,从guide_text表里读取

_param说明

一些无法通过_val单个值表意的参数,用_param表意。使用Json字符串配置,扩展性较好,自由度大,可根据需求任意定义。

form_show 某个界面打开的时候执行操作,数值为:界面ID枚举
form 执行当前操作需要在哪个界面,数值为:界面ID枚举
node 关联某个node,通常与form配合使用,旨在找到目标节点,与不同_action关联,表意不同,与点击 关联,表示点击哪个按钮;与提示文本关联,表示文本框依附于哪个UI控件
dir 表示文本框依附在UI控件的哪个方位,数值可以是:”top”、”bottom”、”left”、”right”

_saveId说明

在当前操作结束时,将_saveId保存到服务器,下次断线重连时,从保存点继续引导

0 不设置保存点,不做任何操作
100 将当前引导保存点去掉,标记当前组别引导已完成
其他 保存该步骤ID到服务器

2.3 guide_text表

guide_text表,主要配置立绘对话、文本提示等操作的一些文本内容。具体配置如下:

ID说明文本内容参数
_id_textSid_param
IDS
1001立绘对话招仙台已开启,请道友移步入内,招募仙士。{“role_index”:1,”role_id”:1}
1002文本提示每隔一段时间都会获得一次免费召唤机会

_param说明

role_index 立绘的位置,1:左,2:中,3:右
role_id 立绘角色ID

2.4 程序与策划的配合

1)添加新的引导时

策划出一份引导流程文档,程序根据负责添加引导的逻辑配表:guide_group、guide_step,策划配置负责配置引导的文案:guide_text。

2)修改引导时

如果仅涉及引导文案修改时,策划只需要维护guide_text表即可,如果仅涉及文案增删,策划也可在了解引导配表逻辑的基础上,维护guide_step表;
如果涉及逻辑改动,需要程序配合策划进行改表,程序负责guide_group、guide_step,策划负责guide_text。

可有效规避策划和程序改一份表,影响引导逻辑。

3. 引导代码的实现

根据:

在某个 触发点 ,判断某个 组别 的引导满足 触发条件 ,于是一个 步骤 一个步骤地引导玩家去完成最基础的系统流程的 操作 ,并且支持退出游戏重进时能从中途的 保存点 恢复并继续引导。

代码需要实现的逻辑包括:

  • 引导的触发
  • 引导的操作
  • 引导的保存点(结合在操作中)
  • 引导步骤的连接(如何从当前步骤跳到下一步)

我们把引导的框架逻辑写在GuideMgr.lua里。

1
2
3
4
5
6
7
8
9
10
11
12
13
local M = class("GuideMgr")

function M.tryStart() end

function M.start(groupConfig) end

function M.step(stepId) end

function M.finish() end

function M.saveGuideStep(stepId) end

return M

3.1 引导的触发

引导的触发,可分为:触发总入口、触发条件判定和正式触发。

1)触发总入口

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
local M = class("GuideMgr")

...

-- 引导触发判定总入口
function M.tryStart()
-- 引导禁用
if M.isForbidGuide() then return end
-- 正在引导,不触发判定
if M.isGuiding() then return end

-- 获取没有触发过的引导组别,判定是否满足触发条件
local groupConfigs = M.getGroupConfigs()
for i = 1, #groupConfigs do
local config = groupConfigs[i]
-- 如果满足触发条件
if CondMgr.isConditionArrayMet(config._triggerCond) then
-- 触发引导
M.start(config)
return true
end
end
end

-- 获取没有触发过的引导组别
function M.getGroupConfigs()
local configs = M._groupConfigs
if configs == nil then
configs = {}

for k, v in pairs(D._guideGroupConfig) do
-- 取没有触发过的引导组别
if not M.isFinished(k) then
configs[#configs + 1] = v
end
end

table.sort(configs, function(a, b) return a._priority < b._priority end)
M._groupConfigs = configs
end
return configs
end

...

return M

2)条件判定

ID说明起始步骤触发条件优先级
_id_stepId_triggerCond_priority
II[SI
2抽卡引导201{“scene”:”MainScene”,”form”:””,”missionId”:100004}1
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
local M = class("CondMgr")

...

--[[--
配表类型:[S
配表写法:{"form": 101, "hero_level": 20} | {"form":101, "vip_level":3}
在101界面,英雄等级达到20级或vip等级达到3级,触发引导

cond参数结构:
table数组
{
{form = 101, hero_level = 20},
{form = 101, vip_level = 3}
}

只要数组中其中一个条件满足即可
--]]--
function M.isConditionArrayMet(conds)
for i = 1, #conds do
if M.isConditionMet(conds[i]) then
return true
end
end
return false
end

function M.isConditionMet(cond)
for k, v in pairs(cond) do
if not M.CondCheck[k](v) then return false end
end
return true
end

M.CondCheck = {
["form"] = function(val)
local form = Form.getTop()
return form and form._id == val
end,
["hero_level"] = function(val)
local lv = P._playerHero:getOwnedHeroMaxLevel()
return lv >= val
end,
["vip_level"] = function(val)
return P:vipLevel() >= val
end
}

...

return M

3)触发引导

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
local M = class("GuideMgr")

...

function M.start(groupConfig)
-- 如果有引导正在进行,结束所有引导,开始触发传入组别的引导
M.finish()

-- 保存当前正在触发的引导组别ID
local groupId = groupConfig._id
M.saveGuideGroup(groupId)

local stepId = groupConfig._stepId
M.saveGuideStep(stepId)

-- 引导视图层准备工作
M.prepareLayer()

-- 开始当前引导组的第一步操作
M._groupId = groupId
M._stepId = stepId
M.step(stepId)
end

-- 创建引导图层,后续的所有操作添加的元素都放在这里
function M.prepareLayer()
local layer = M._layer
if layer == nil then
layer = class(nil, V).new(lc.CocosClass.ui_widget)
layer:setContentSize(lc.ScrSize)
lc.addChildToCenter(Scene._current, layer, V.ZOrder.guide)
M._layer = layer

layer:setTouchListener(function() end)
end
end

function M.saveGuideGroup(groupId)
-- TODO:保存到服务端
...
end

function M.saveGuideStep(stepId)
-- TODO:保存到服务端
...
end

...

return M

3.2 引导的操作

引导的操作涉及:保存点逻辑、参数逻辑、具体操作(点击、对话等)。

1)引导步骤

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
local M = class("GuideMgr")

...

function M.step(stepId)
-- 保证step在引导中执行(由M.start发起)
if not M.isGuiding() then return end

--[[--
stepId不为空,表示开始当前步骤操作;
stepId为空,表示在M._stepId基础上,进入下一步操作;
--]]--
if stepId == nil then
stepId = M._stepId

-- 在当前步骤结束,准备进入下一步时,记录当前步骤的保存点
local stepConfig = D._guideStepConfig[stepId]
local saveId = stepConfig._saveId
if saveId then
if saveId == 100 then
M.setGroupFinished(M._groupId)
M.saveGuideStep()

elseif saveId > 0 then
M.saveGuideStep(saveId)
end
end

stepId = stepId + 1
end

-- 传入的步骤ID不存在,结束引导
local config = D._guideStepConfig[stepId]
if config == nil then
M.finish()
return
end

M._stepId = stepId

-- 清除所有引导元素
M.clearLayer()

-- 根据步骤参数做相应处理
-- (后面继续)
...
end

function M.isGuiding()
return M._stepId and D._guideStepConfig[M._stepId]
end

-- 清理所有layer上的引导元素,包括手指、高亮、立绘对话等
function M.clearLayer()
local layer = M._layer
if layer == nil then return end

layer:setSwallowTouches(true)
layer:setTouchListener(function() end)

M.removeFinger()
M.removeDilog()
M.removeTipText()
...
end

...

return M

2)参数处理

ID说明操作数值其他参数保存步骤
_id_action_val_param_saveId
HIISI
204400011002{“form_show”:105,”form”:105,”node”:”_btnLotteryOne”,”dir”:”top”}0
205100030{“form”:105,”node”:”_btnLotteryOne”}100
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
local M = class("GuideMgr")

...

function M.step(stepId)
...

-- (接上面的代码)
local config = D._guideStepConfig[stepId]

-- 根据步骤参数做相应处理
local param = M.getStepParam(config)
if param then
-- 允许玩家自由点击
if param.free_op then
M._layer:setSwallowTouches(false)
end

M._waitParam = nil

if param.delay then
M._waitParam = V.scheduleCall(function() M.doAction() end, param.delay)

elseif param.event then
_, M._waitParam = lc.event.once(param.event, function() M.doAction() end)

if not param.free_op then
M._layer:setTouchListener(function()
-- 提示当前无法点击
end)
end

elseif param.form_show then
local form = Form.getTop()
if form == nil or form._id ~= param.form_show or not form._isShown then
_, M._waitParam = lc.event.on("form.show", function(form)
if form._id == param.form_show then M.doAction() end
end)
end

elseif param.form_close then
local form = Form.getTop()
if form and form._id == param.form_close then
_, M._waitParam = lc.event.on("form.close", function(formId)
if param.form_close == formId then M.doAction() end
end)
end
end

if M._waitParam == nil then
M.doAction()
end
end
end

function M.getStepParam(stepConfig)
stepConfig = stepConfig or D._guideStepConfig[M._stepId]
return json.decode(stepConfig._param or "")
end

...

return M

3)执行操作

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
local M = class("GuideMgr")

...

function M.doAction()
if not M.isGuiding() then return end

local config = D._guideStepConfig[M._stepId]
M._waitParam = nil

local layer = M._layer
layer:setSwallowTouches(true)

local action = config._action
if action == 0 then
-- 执行下一步操作
M.step()

else
local func = M.ActionFuncs[action]
if func then
if not func(config) then
M.finish()
end

else
-- 交给外部处理
V.scheduleCall(function() lc.event.emit("guide.step", config) end, lc.FrameInterval)
end
end
end

M.Action = {
click_main_tab = 10001,
click_sys_entrance = 10002,
click_by_name = 10003,
...
dialog = 30001,
...
tip_text = 40001,
...
}

M.ActionFuncs = {
[M.Action.click_main_tab] = function(config)
local index = config._val
local tab = table.walk(Scene._curent, "_tabs", index)
if tab and tab:isVisible() then
M.addTouchFinger(tab)
return true
end
end,
[M.Action.click_sys_entrance] = function(config) ... end,
[M.Action.click_by_name] = function(config) ... end,

[M.Action.role_dialog] = function(config)
local textConfig = D._guideTextConfig[config._val]
M.addDialog(textConfig)
return true
end,

[M.Action.tip_text] = function(config)
local textConfig = D._guideTextConfig[config._val]
M.addTipText(textConfid._textSid, config._param)
return true
end
}

...

return M

回顾

10000+:点击
10001 点击主界面的页签 ,val 表示第几个页签
10002 点击某个系统入口,val 表示哪个系统
10003 按照代码变量名找到按钮进行点击

20000+:拖拽

30000+:立绘对话
30001 普通立绘对话

40000+:文本提示
40001 普通文本提示

3.3 引导的结束

引导的结束包括:引导数据处理、视图层移除、尝试继续触发下一组引导。所有逻辑囊括在GuideMgr.finish()方法里。

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
local M = class("GuideMgr")
...

function M.finish()
-- 数据层
M.saveGuideGroup()
M.saveGuideStep()

if M._groupId then
M.setGroupFinished(M._groupId)
end

M._groupId = 0
M._stepId = 0

-- 视图层
M.clearLayer()
M.removeLayer()

local param = M._waitParam
if param then
-- 移除事件监听
if param._event then
lc.event.remove(param)

-- 移除延迟操作的定时器
else
param:unschedule()
end
M._waitParam = nil
end

-- 延迟一帧判断是否有新的引导可以触发
V.scheduleCall(M.tryStart, lc.FrameInterval)
end

...

return M

3.4 步骤间的连接

步骤间的连接,也就是当前步骤如何跳到下一步骤,需要根据操作情景做特殊处理,这里以点击、立绘对话为例,做一下讲解。

1)点击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local M = class("GuideMgr")
...

function M.addTouchFinger(node, ...)
...
node._lastListener = node._listener
node._listener = M.fingerTouchListener
...
end

function M.fingerTouchListener(...)
...
if node._lastListener then
node._lastListener(...)
node._listener = node._lastListener
end

-- 按钮点击后,执行下一步
M.step()
...
end

...
return M

2)立绘对话、文本

点击屏幕任意位置,执行下一步引导操作。

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
local M = class("GuideMgr")
...

function M.addDialog()
...

M._layer:setTouchListener(function()
...
-- 设置layer点击事件
M.step()
...
end)

...
end

function M.addTipText()
...

M._layer:setTouchListener(function()
...
-- 设置layer点击事件
M.step()
...
end)

...
end

...
return M
]]>
http://wjnovember.github.io/guideSummary/#disqus_thread