Cocos Creator次世代人物渲染实战:眼睛篇
眼睛往往是项目中腾挪空间较大的资产:它可以很简单,用一张贴图即可;它也可以非常复杂,美术大神会手动雕刻虹膜的每一条沟壑。作为通俗的“灵魂的窗口”,即便是风格化的卡通美术项目,眼睛的重要性也不容忽视。在关于眼睛的美术资产制作流程,可以参考这篇文档。
确立目标
与皮肤篇和头发篇一样,我们将基于 Cocos Creator 的 PBR 流程实现引擎中的眼球渲染效果。
我们的美术资源包括一张表现“眼白”(学名是巩膜)部分的颜色贴图,一张表现“眼眸”(学名是巩膜)部分的颜色贴图,一张法线贴图和一张MatCap贴图。其中,虹膜圆形的边缘用虹膜贴图的alpha通道表达。除此之外,我们还需要一些小技巧来表现眼珠在眼眶中的遮蔽关系,这将会在后文中详说。
奠定理论
眼睛的结构需要我们关注哪些点呢?我们仍然需要求教于参考图:
- 虹膜直径大约等于整个眼球的半径;
- 瞳孔的直径大约等于虹膜的半径;
- 眼球并不是正球体,在虹膜前方又突起的液泡结构;
首先我们需要了解的是:眼球不是一个正球形,在虹膜的正前方位置有一个圆形的突起。这是因为虹膜正前方有一个液泡的结构,而整个眼球又包裹在透明的巩膜里,所以眼球是一个整体流线型,在正前方有小突起的球体。这些细节,美术的同学会进行表现。
综合来说,虹膜将会是我们的核心,我们需要重点处理虹膜和巩膜、瞳孔以及它正前方的液泡的关系。
UV的处理和归一化
在头发篇中,我们已经聊到了UV数据和其他类型的数据一样,可以对它进行算数运算。我们熟悉的UV Tiling的功能就是通过用UV乘以一个常量实现的。对于虹膜贴图,我们也可以采用相同的处理:
vec2 offsetUV = v_uv * irisSize;
我们新建了一个浮点参数irisSize,并让他与UV数据直接相乘。结果和UV Tiling是一样的:虹膜贴图在UV上的比例缩小了(在irisSize取值大于1的情况下),并且在UV空出来的部分叠加上了同样的虹膜贴图。
当然,我们的眼珠只需要一个虹膜。我们既希望利用常量相乘的办法缩放UV,又不需要贴图的叠加,只需要在贴图的属性中将Wrap Mode设为clamp-to-edge即可。
叠加消除了,我们又遇到了新的问题:贴图似乎缩放到了左下方的角落里。我们需要对坐标系做归一化处理,让我们在缩放UV的同时,贴图可以保持在UDIM正中心。
vec2 offsetUV = (v_uv - 0.5) * irisSize + 0.5;
我们的虹膜大小和位置已经差不多了,下面我们需要将虹膜向后“推”进眼球里,以表现液泡和虹膜的前后关系。我们可以使用视差贴图的方法实现这个效果。
视差贴图
如上图所示,灰色平面代表物体的基本网格平面,在此基础上物体有突起的表面结构,用红色曲线表示。当我们以上图V向量的方向观察物体时,我们理应观察到红色曲线上的B点,当突起的表面结构不存在时,我们则会观察到基本网格上的A点。换言之:我们需要A点上的网格数据,去实现高度在B点的渲染效果。
我们知道,高度贴图(Height Map)表达的是物体切线空间的高度数据。也就是说,A点的切线空间高度数值(H(A))是可以通过贴图获得的。但是B点呢?我们通常会以A点的切线空间高度作为数值权重,以观察向量V的反方向(从片元指向摄像机)进行缩放,就可以大致得到B的位置坐标。这样的计算当然不能做到完全精准,但效果是我们可以接受的。
方法有了,我们需要做的第一步是获得从片元指向摄像机的向量,并将其转化到切线空间当中:
vec3 worldDirPosToCam = normalize(cc_cameraPos.xyz - v_position); vec3 tangentDirPosToCam = vec3(dot(worldDirPosToCam, v_tangent), dot(worldDirPosToCam, v_bitangent), dot(worldDirPosToCam, v_normal)); 我们可以利用得到的切线空间向量,对UV进行偏移,以偏移后的UV坐标读取切线空间的高度信息。这样我们就在A点得到了B点的高度输出: vec2 parallaxUV( vec3 V, vec2 uv, float iniHeight, float scalar ){ vec2 delta = V.xy / V.z * iniHeight * scalar; return uv - delta; }
上面的代码需要带入四个参数:V为我们刚求得的切线空间中的从片元指向摄像机的向量,uv为物体的原uv(即我们已经在皮肤篇和头发篇中使用过的“v_uv”),scalar为自定义的权重参数,iniHeight是片元的原高度数据,这个数据应该由一张贴图提供。在我们的着色器中,我们只需要用视差贴图做一些简单的像素偏移,因此没有准备专门的高度贴图,我们可以用颜色贴图的任意一个通道,或者直接使用一个常量0.5作为代替。
得到了视差贴图的函数,我们就可以把它用在虹膜上面了。
vec2 offsetUV = (v_uv - 0.5) * irisSize + 0.5; vec4 irisTex = texture(irisMap, offsetUV); vec2 irispUV = parallaxUV( tangentDirPosToCam, offsetUV, irisTex.r, parallaxScale ); vec3 irisColor = SRGBToLinear(texture(irisMap, irispUV).xyz);
我们可以用之前的缩放归一后的UV得到有视差效果的虹膜UV,用这套新UV赋予我们的虹膜贴图,得到的结果应该类似下图:
(gif传不了图,尴尬)
如图所示,随着权重数值的变化,我们的虹膜贴图应该能够沿着法线方向向前“推”或向后“缩”,同时我们也发现,我们目前的视差贴图只能达到一种近似的效果,随着权重数值增大,视差的效果也会越来越失真。因此我们在使用它时,需要注意将数值控制在比较低的范围内。
完成虹膜
虹膜的处理已经差不多了,下面我们需要处理一下瞳孔。
完成了虹膜的视差,我们如法炮制,对我们得到的视差UV做归一化处理。区别在于,这次我们将UV归一化,这相当于将所有的UV塌陷到归一化坐标的原点上。使用这个UV采样贴图,得到像素向坐标中心拉伸的效果。
接下来,就是制作一个遮罩将虹膜和瞳孔混合在一起了。
vec2 pupilpUV = normalize(irispUV - 0.5) + 0.5; float pupilIndex = (1.0 - length(v_uv - 0.5) * 2.0 * irisSize) * (0.8 * pupilSize); vec2 irisUV = mix(irispUV, pupilpUV, pupilIndex); vec3 irisColor = SRGBToLinear(texture(irisMap, irisUV).xyz) * irisColor.xyz;
通过自定义参数irisSize和pupilSize,我们可以分别控制虹膜和瞳孔的大小。我们也可以为虹膜贴图自定义一个偏转的颜色irisColor,快速制作出不同颜色的眼眸。
下面我们可以把虹膜贴到眼球上了。眼球的基本材质使用巩膜贴图,我们只需要把虹膜的部分叠加在上面即可。虹膜贴图的边缘部分是用alpha通道的渐变完成的,我们可以用指数运算控制渐变的曲线强度,从而控制虹膜边缘的硬度:
vec3 scleraTex = SRGBToLinear(texture(scleraMap, v_uv).xyz); float irisEdgeIndex = clamp(pow(irisTex.a, irisEdge), 0.0, 1.0); vec3 eyeBase = mix(scleraTex, irisColor, irisEdgeIndex) * irisColor.xyz;
目前眼球的固有颜色信息已经得到了。但是我们的眼球看上去和直接贴了一张颜色贴图没有什么区别。下面我们需要做的是:为眼球赋予“神”。
MatCap贴图
所谓有“神”的眼睛,可以简单概括为“有高光和/或有反光的眼睛”。如参考图所示,上面两张参考图中的眼睛显得更加生动和有活力,而下面两张则看上去非常死板,好似无机物。
然而,游戏中角色的眼睛并不是总能恰好反射环境中的光照,当环境有某些特定的需求或者从某些特定的角度观察时,眼睛很有可能没有足够的高光或反光。更何况,眼睛固然重要,但毕竟是一个较小的反射面,为此专门进行反射的光照计算似乎有点得不偿失。一个常见的折中办法是:把高光和反光作为贴图,永久地“贴”在眼睛表面。这样无论任何环境和角度,角色的眼睛里永远有星辰大海。
所谓MatCap贴图,顾名思义,是一张把整个材质(“Mat”-erial)的特性捕捉(“Cap”-ture)到像素内的贴图。MatCap贴图通常绘制的是一个球体,着色器会根据球体上的明暗面、高光和反射,为整个材质绘制明暗关系和高反光。美术的同学应该对MatCap并不陌生——ZBrush中用于渲染动辄上百万个多边形的材质正是使用MatCap着色器。因此MatCap有着效率极高,又足够能表现明暗关系和质感的优点。同时,MatCap的缺点也是显而易见的:无论从哪个角度观察,MatCap材质的明暗关系和高反光永远一成不变。
在我们的着色器中实现MatCap材质也非常简单:我们知道MatCap的特性是它永远正对观察方向,既然如此,得到一套永远正对摄像机的UV,用它来采样MatCap贴图即可。我们知道,法线数据表达的是物体表面片元正对的方向,因此把法线数据转换到视图空间,只取X和Y轴数据,就能得到我们想要的UV:
vec4 matCapUV = (cc_matView * vec4(v_normal, 0.0)) * 0.5 + 0.5; vec4 matCapUV = (cc_matView * vec4(v_normal, 0.0)) * 0.5 + 0.5;
确定了UV,剩下的工作就水到渠成了:
vec3 matCapColor = SRGBToLinear(texture(reflecMap, matCapUV.xy).xyz) * reflecAmt; vec3 eyeColor = eyeBase + matCapColor;
我们的着色器已经编写完成了,让我们来看看效果:
按理来说,我们该参考的图都参考了,该考虑的变量都考虑了,该做的工作都做了,但这白森森的眼神,还是直接营造出一种纸人既视感。尤其是从较远距离观察的时候,白的发亮的眼珠更是莫名惊悚。
这是因为:眼珠和身体的其他部位一样,应该相互产生遮蔽的关系。我们的眼珠是单独制作的,所以和眼皮没有暗部遮蔽,因此在整张脸上特别出挑。这也是角色渲染的一个常见问题:我们对人脸都太熟悉不过了,以至于人脸上如果出现异于常理的现象都会触发本能的警觉。而且当其他的部分越趋近于真实时,这种恐怖感越严重。
如果是一个静态的部位,这个问题非常好解决:烘培一张AO即可。但是对于角色来说,绝大多数的角色眼球是需要骨骼动画的,直接把AO烘培在眼球上显然不可取。我们需要做的是在眼球的模型前方再新建一个遮蔽的模型,给它赋予一个AO的透明贴图,单独作为AO保留在模型上。这个模型除了AO将不会起任何其他作用,因此只需要给予一个基本的Unlit材质,也不会消耗额外资源。这种做法,也是包括UE4在内的许多引擎选择的做法。
增加了AO之后,我们角色的眼神柔和了许多,眼球和眼眶的衔接也更自然了。
结语
我们对人物渲染的探索到这里就可以成功收官了。在我们试图解答一个个渲染问题的过程中,我们获得的不仅是皮肤、头发和眼睛,同时也包括了:
- 了解方形模糊和高斯模糊的原理,并实现高效率的模糊效果;
- 探索人类皮肤的Diffuse Profile并用代码重现现实观测的数据;
- 了解次表面散射和各向异性高光的逻辑和原理;
- 学习纵横行业30余年的Kajiya-Kay模型;
- 尝试视差贴图的渲染方法;
- 学习和使用MatCap贴图;
虽然我们只是在 Cocos Creator 现有的着色器上进行修改,但相信你也已经发现:在Cocos Creator着色器的基础上编写自己的着色器,不仅省去了大量GLSL基础工作,而且可以一步到位地获得PBR的基础渲染效果。在一个稳固的基础上,我们可以自由发挥,尝试各种各样的方法和模型, 实现丰富多样的渲染需求。
Cocos Creator次世代人物渲染实战:头发篇
在《Cocos Creator 次世代人物渲染实战:皮肤篇》中,我们主要聚焦皮肤渲染与次表面散射。而在本环节中,我们将主要聊一聊头发。
相较于皮肤,头发可能就更加令人挠头了:
在美术层面,我们需要以发束为单位处理大量的多边形并堆砌成各式各样发型的形态;
在渲染层面,我们需要让着色器使用alpha通道,剔除多边形上不需要的像素,呈现出单独纤细,整体密集的发丝形态。
头发模型的形态和细节的刻画,会由美术的同学帮助我们完成,如果你也有兴趣了解个中一二,可以参考大神 Adam Skutt 的角色头发建模行业标杆教程。
确立目标
我们的目标是将一个标准的 PBR 角色头发美术资源导入 Cocos Creator 渲染呈现。美术资源包含了固有色贴图(包含alpha通道)、法线贴图和AO贴图。与皮肤篇一样,我们会基于Cocos Creator内置的标准PBR着色器制作自定义头发着色器。
需要注意的是,我们需要对美术资源做出一点小小的要求:固有色贴图(包含alpha通道)必须以单独alpha通道的.tga格式存储。我们将会编写的着色器并不能正确渲染带有像素剔除的.png格式图像。
奠定理论
首先我们仍然需要解答一个问题:什么样的效果能够让头发更真实?
观察上图,首先抓住人眼球的无疑是她头发上长条状的高光,进一步细看,我们可以注意到长条状的高光有两种颜色变化:一种是我们熟悉的偏白色高光颜色,强度也略高;另一种则是比头发固有色明度和饱和度略高的高光颜色。
在上图中我们可以看到两条看似相交的高光,这当然是他发型的梳理方式而决定的。这告诉我们头发的高光基本遵循每一根单独发丝的走向。在同一束头发中所有的发丝走向基本一致,所以他们的高光聚集在一起形成了条状。
在这个例子中,我们同样可以看到遵循发丝走向的两种颜色的高光,而且高光的位置似乎集中在发型弯曲的位置上。
综合起来,我们可以观察到的规律是:
- 头发的高光呈带状,并遵循发丝的走向;
- 头发有两条高光带,一条较强的偏白色高光带,一条偏向头发固有色的高光带;
- 头发的高光带通常会出现在弯曲的部分。
我们知道,Specular 表述的是材质的反射光线,无论材质表面的粗糙度如何,Specular光线传播的方向都可以从宏观上看作一个锥形,在这个锥形范围内光线的传播是平均的,这也是为什么我们在材质表面观察到的高光通常是一个圆形。这种光线传播的特性,物理上称之为各向同性(Isotropy),其涵义正如其字面意思:“在各个方向上一致的”。
然而在参考图中,头发上的高光并不是圆形的。高光只有在单个发丝上出现,而从发丝之间的横向来看,并没有产生高光的条件。整体来看,头发是一种只有垂直方向会产生高光,水平方向没有高光,垂直方向的高光密集排列在一起成带状的材质。这种在各个方向上不统一的特性,称之为各向异性(Anisotropy)。
除了头发之外,任何物理上由无数会产生高光的细丝密集组合成的材质,都会表现各向异性的高光特性,比如丝绸、大多数的晶体、抛光的木材、拉丝抛光处理的金属等。
实现各向异性
目前我们在游戏中看到的大多数头发各向异性渲染效果,都基于早在1989年发表的Kajiya-Key模型。那么,什么是Kajiya-Key模型?
既然头发的各向异性特征表现在高光上,那么,我们应该从 Specular 入手。
我们在皮肤篇中已经聊过的“N·L”方法,利用物体法线方向和光照方向可以让我们快速得出光照明暗关系的数值。既然高光同样和物体的表面特征和光照方向相关,那么我们是否可以使用同样的方法得出Specular呢?
当然,我们不能机械地把Specular理解为强度极高、范围极小的Diffuse。高光除了收到光照方向的影响之外,还与我们观察的角度相关。因此我们引入一个光照方向(L)和观察方向(V)之和的半向量H,套用“N·L”的方法用它与法线N求点积。高光的强度远高于Diffuse的强度,所以我们把强度参数作为点积的指数输出。由此,我们就得到了一个基本的计算Specular的公式:
然而,这个公式仅适用于各向同性的情况,对于头发的各向异性特征并没有考虑进去。
如下图所示,在一般情况下,我们的Specular公式求的是法线和半向量的点积。但是我们通过观察得知:头发的各向异性与发丝的走向相关,与头发整体的结构关系不大。因此,N对我们来说失去了意义,我们需要的是表达发丝走向的向量T。
得到了T向量后,我们如法炮制继续套用“N·L”方法,这需要得到T在半向量上的投影。这个投影实际是T和N夹角的正弦,而点积只能获得两者夹角的余弦。所幸的是,我们可以通过正余弦定理,通过换算得到正弦:
vec4 worldViewDir = cc_matView * vec4(0.0, 0.0, 1.0, 0.0) - vec4(v_position, 0.0); vec4 worldHalfDir = normalize(normalize(cc_mainLitDir) + normalize(worldViewDir)); float THdot = dot(normalize(T), normalize(worldHalfDir.xyz)); float sinTH = sqrt(1.0 - pow(NHdot, 2.0));
在上面的代码中,cc_matView和cc_mainLitDir都已经在皮肤篇中出现过了,分别返回的是View Matrix和光源方向。
这些看上去都比较简单。那么问题是:我们如何得到向量T?
我们知道,物体的顶点存储了切线空间数据:以顶点法线方向为一轴,顶点切线(与顶点法线垂直,与表面平行)为另一轴,与顶点法线和切线都垂直的第三个向量为第三轴。其中顶点法线和顶点切线已经包含在网格的顶点数据当中了,模型的同学已经帮我们处理好了光滑组或软硬边(取决于美术同学用的是3ds max还是Maya),并按照需求提供了法线贴图。第三个向量,通常被称为副切线向量(Bi-tangent,或Bi-normal),我们可以根据它垂直于顶点法线和顶点切线的特性,用叉积计算得到它:
v_tangent = normalize((matWorld * vec4(In.tangent.xyz, 0.0)).xyz); v_bitangent = cross(v_normal, v_tangent) * In.tangent.w;
其实在Cocos Creator的PBR着色器中,副切线向量已经为我们计算好了,我们可以通过v_bitangent使用它。
目前为止,我们已经把Specular与发丝的走向建立了联系,但是我们的Specular依然是模型的高光,看上去并不像头发。我们需要使用一张发丝的灰度图,作为一个数值权重来偏移副切线向量的方向,使我们的高光向发丝的方向拉伸,从而更像头发的形态。
首先我们需要知道的是:切线空间的数据是基于物体表面的切面的,而物体的表面又由法线方向决定。因此我们对副切线向量的偏移,一定是朝着法线的方向偏移。
接下来,我们需要一张发丝的灰度图作为拉伸的权重。这张灰度图可以是一张使用头发模型UV的贴图,或者一张四方连续的贴图。如果是后者,我们要为它写入相应的UV Tiling的功能。
vec2 anisotropyUV = v_uv * anisotropyTile.xy + anisotropyTile.zw; vec4 jitterMap = texture(jitterTex, anisotropyUV);
我们声明了一个vec4参数anisotropyTile,用它来实现UV的控制功能。v_uv我们已经在皮肤篇中使用过了,返回的是顶点着色器传递的UV数据。
我们先把法线数据与权重灰度图相乘,将法线方向加以扰动,除此之外,我们还需要相加一个位移权重的自定义参数,这个参数的意义在后面会体现出来。让后将其与副切线向量相加。这与我们求得H向量的方法是一样的,归一后我们得到的就是副切线向量与扰动后法线方向向量的半向量。
求得我们的T向量之后,对其进行点积计算,换算为正弦,代入我们的简单Specular公式,各向异性的高光就的出来了。在这里我们还使用了GLSL的smoothstep函数,类似于mix函数它会把输入的参数投射到定义的最小值和最大值的区间中,并在两个极值之间生成一条平滑的过度曲线。
float anisotropyIndex( float offset, float factor, float amt ) { vec3 jitterT = v_bitangent + (v_normal * (offset + factor)); float THdot = dot(normalize(jitterT), normalize(worldHalfDir.xyz)); float sinTH = sqrt(1.0 - pow(NHdot, 2.0)); float atten = smoothstep(-1.0, 0.0, NHdot); return pow(sinNH, amt) * atten; }
现在,Specular已经基本遵循发丝的走向了,但是我们的高光似乎太强烈了,这是因为我们并没有考虑头发自身相互遮蔽的问题。解决它非常容易,只要把头发的AO叠加到高光上即可。
float aoFactor = mix(1.0, 0.0, pbr.x);
最后就是联动的环节了。回想一下我们观察的参考图,我们需要使用我们编写的各向异性函数生成两道高光带,还记得我们在扰动副切线向量时写入的位移参数吗?给两条高光带分别带入不同的位移参数值(hairSpecMOffset, hairSpecAOffset),他们就不会重叠在一起,并且你可以把高光移动到模型弯曲度较高的地方,获得更真实的效果。除此之外,他们分别有各自的强度参数(hairSpecMAmt, hairSpecAAmt)和颜色参数(hairSpecColor01, hairSpecColor02),我们还可以给一个总强度进行整体协调(hairSpecIntensity)。
vec4 hairSpec = clamp((anisotropyIndex(hairSpecMOffset, jitterMap, hairSpecMAmt) * hairSpecColor01 * hairSpecIntensity + anisotropyIndex(hairSpecAOffset, jitterMap, hairSpecAAmt) * hairSpecColor02 * s.albedo * hairSpecIntensity) * aoFactor), 0.0, 1.51);
做到这一步,我们的各向异性高光的函数已经基本就绪了,然而我们又遇到了与皮肤篇中相似的问题:我们在哪个通道输出高光呢?
你可能已经想到可以利用我们的各向异性高光的函数调整roughness通道以达到控制Specular输出的目的,但这个结果并不是我们想要的。我们的函数返回的并不是高光的遮罩,而且我们并不希望在高光的部分看到如同镜面一般的反射。更何况,标准PBR的高光仍然存在,我们更加不希望看到各向同性和各向异性的高光同时出现。既然如此,最简单的办法是:把roughness设为常量1,消除所有的各向同性高光,然后把各向异性高光的输出叠加在albedo通道上。
头发的呈现
我们的着色器已经编写完成了,但我们的工作还没有完成。如何使用这个着色器才能呈现最好的效果呢?
新建一个材质,赋予我们的各向异性着色器。将Technique设为1-transparent。
首先将各个贴图赋予到相应的通道上。法线贴图对应法线通道,AO贴图对应Occlusion通道,固有色贴图对应Albedo通道。
开启USE ALPHA TEST,使用alpha通道剔除不需要的像素。这里可以使用红通道或alpha通道调整剔除的阈值,令“抠图”更干净。
做到这一步,头发该有的样子应该有了。然而你会发现,头发的前后关系似乎有点奇怪。你需要展开编辑器最下方的PipelineStates标签,在DepthStencilState标签下开启DepthWrite,确认DepthFunc设为Less。
当然,在默认情况下,模型的背面是不会被渲染的。如果需要实现双面材质的效果,在RasterizeState下的CullMode设为None即可。
做到这里,我们在Cocos Creator中重现的经典Kajiya-Key模型头发着色器就基本完成了。
最佳 Game Jam 游戏之一《消费主义》开发总结!
今年由于疫情原因,CiGA Game Jam 改为线上进行,在经过两个月的报名,与两个月的漫长评审期之后,主办方最终从 330 款游戏 DEMO 中选出了 10 款入围今年最佳 Game Jam。Cocos 引擎团队5位同学 Santy、YunHsiao、ArthurWang、jiaxin、gameall3d 自发组队,加一位音乐外援妹子 SHJRI 一起参加了这场极限开发活动,很幸运,作品《消费主义》成功入围!这次我们联系6位成员,针对开发历程进行了专访!
CiGA Game Jam 是 CiGA 旗下最大的华人游戏圈线下 Game Jam 活动,是对开发者的一种极限挑战,在 48 个小时内放下一切束缚,通过一个周末的时间唤醒游戏的创意理念同时体验游戏的开发过程,包括编程、互动设计、叙事探索、以及美术设计。听起来很疯狂,但是他们做到了!
游戏简介:
《消费主义》游戏原型采用 Cocos Creator 3D 开发,建立消费主义模型,扮演资本家通过价格调控、广告等手段,贩卖产品,榨取消费者利益。当资本家掠夺殆尽,空留一地消费者陷入迷茫。
思路简介:
这次的思路是希望能让玩家通过第一人称的生产者角度,来理解和体会消费主义背后的商业机器,和每个个体扮演的角色。
专访总结
能向我们的读者介绍一下成员及分工吗?
YunHsiao:Santy 是我们 Cocos Creator 2D 组的,负责资源加载系统等;ArthurWang、jiaxin、gameall3d 和我都是 Cocos Creator 3D 组的,负责编辑器开发、引擎开发等。
Game Jam 中我们其实没有固定的分工,我们都是一起参与,碰撞灵感;到了实现玩法时,引擎都再熟悉不过,任务拆分相对非常自然。每个人都全力参与其中,以模块为单位,比如相机控制、场景生成、抽象逻辑、UI、模型资源对接等,在一个 git 仓库内大家共同协作开发。
刚看到题目时是什么感觉呢?
YunHsiao:题目发布时我们还没下班,看着直播放出题图就开始明目张胆地划水,一头雾水有一句没一句地开始解读。
gameall3d:当时真的是好震惊,这个比赛的题目都是这么抽象的吗?不过想想也是,越是抽象的题目,给开发者发挥的空间也就越大,但这么抽象的题确实很难让人在短时间内直接想出一个点子。
图片为 2020 Game Jam 题目
如何解读题图的呢?
YunHsiao:讨论了许久后有人从满地的快递箱看出了图里讽刺消费主义的味道,我们忽然觉得找到了图的灵魂,所有的细节都有了明确的主题指向(快递是消费品,地面的裂缝是内心的欲望沟壑,墙外的蚂蚁是让人心痒的诱惑等等),开始往这个方向探索,看能做点什么,和这幅画要表达的东西共鸣。
gameall3d:刚开始我们也思考了各种游戏类型,比如地板会塌陷的多人大乱斗游戏、蚂蚁塔防游戏、密室逃脱等等,但觉得都不够理想,经过一晚上的讨论,我们决定做一个展示商人如何通过各种手段极限压榨消费者的游戏。
YunHsiao:与其展示一个个体在整个环境下的挣扎,让玩家去勉强共情,不如直接把屠刀放到玩家手中,真正体会一把收割的角色,从收割过程中去理解资本机器的运作,进而在生活中不再被同样的手段迷惑。
48 小时的压力下,时间安排是否会有困难呢?
ArthurWang:队里有多次参加过的老手,所以我们的时间安排其实不算特别紧迫,当然还是没少肝。在题目出了之后我已经记不清我们提出了多少点子和方向了,最后基本确定了这个方向后还在不停论证表现形式和可行性之类的,总算在第一天晚上确定了雏形。
这么多想法碰撞,如何做取舍呢?
gameall3d:在制作过程中其实会有很多新的想法冒出来,但是因为时间问题,我们会收敛着做需求,将我们核心的玩法打磨好,而不是一直扩展系统。
比如周六晚上我想了一些改进,例如把核心玩法改成通过广告手段让一群人的喜好一样,然后他们就可以去团购同一件物品等等,但是这就相当于要抛弃周六一天的部分工作,很有可能得不尝失,所以我们选择在原来的玩法上继续深入。
ArthurWang:Game Jam 能给每个人足够的自由,这是我们共同的作品,每个人都可以把自己的想法加进去,但有时候确实是因为时间原因不得不否掉一些新的想法。
我们在离提交还有 4 个小时的时候还有新的点子,但冷静下来觉着肯定完成不了了,所以只好放弃,但一些小的设计还是尽可能地加到了作品中,包括一些小的不明确展示给玩家的小障碍,也包括我们平衡过的数值,这些都让这个作品略微完整了一些。
能否介绍下《消费主义》最终的玩法设计呢?
gameall3d:游戏的核心玩法是:玩家扮演一个商人,利用手头的资源让整个地图的人都购买你家的商品,你就成功了。商人有初始资金,需要使用它来进货,然后定一个价格来卖出,而商品需要广告才能吸引消费者来购买,可以使用的广告手段有“发传单”、“广告牌”、“飞艇”三种,对应了不同的价格和效果,需要玩家去权衡使用。
游戏广告效果
在音乐上是如何考量的呢?
SHJRI:考虑到游戏的机制会因玩家的操作而在画面和数值上加减人数,所以音乐随着游戏的进程,比如人数的递增,加入新的元素。元素来自于画面和内容,比如鼓的部分以 beatbox 来表现,加以动态音乐的形式,增减轨道来丰富玩家反馈。
如何想到将广告作为《消费主义》的核心设计呢?
YunHsiao:前期尝试时我们也陷入了受限的思路,本质上还是没有摸清楚到底什么才是那个不可再分的,消费主义的最小子集。直到后来深入思索一番后,发现从“欲望”入手,一切似乎都可以变得清晰起来。
我们的时代是自由的。自由意味着每个人都可以追求欲望。这意味着一个巨大的市场,掌控了更多人的欲望,就掌控了更多……资本。
这就引入了广告,广告是一个再完美不过的消费主义具象化的落脚点。广告创造欲望,广告引导欲望,广告升级欲望。
它是我们会一个接一个下单取快递的原因,它像被蚂蚁一般无时无刻不在挠得我们心头犯痒想要更多,它是我们明明已经有了那么多盒子可心中的空洞和裂缝还是越来越大的原因。
消费主义不过如此了。
如何确定作品形态的呢?
YunHsiao:结合我们几个的实际情况,我们的长项是逻辑(同时短板是美术),设计一个相对复杂精巧的系统更接近我们的主场,这正符合资本机器冰冷严谨的运作风格。
引擎更是没理由不用自家的狗粮,所以基础设施的长项是骨骼动画(instancing),加上相对合理数量的粒子和物理,所以我们做的东西,更像一个实时策略类游戏了噢。
实时策略游戏的核心正是系统构建,虽然我们都没有真正专业地做过,但这听起来是一个我们可以尝试搞的东西!
酷,如何构建这个游戏呢?
YunHsiao:我们需要一个模型,一个最纯粹又在直觉上能让人亲近的情景,来让玩家“感受”。
我们把基本元素确定为消费者和产品:消费者在这世界中大量随机分布着,每个消费者都有随机数量的“快乐值”、“欲望值”和“剩余价值”等几个基本属性。
玩家作为生产者,拥有固定的初始资本,决定要在哪里投放产品,尽可能榨取最多的“剩余价值”。
所有的游戏性全都围绕广告开展,消费者也只需要一个“购买欲望”的属性。玩家的任务就是在有限的成本下,平衡出货和广告投放。只有货物没有广告是没有人买的,只有广告没有货物也会为产品带来负面口碑,影响更多人的购买欲望。
游戏的核心点似乎是广告,技术上我们如何实现的呢?
Santy:在原型中我们使用了多个特效表达广告效果,基本是使用模型 + 动画 + 粒子的形式来组织的。Cocos Creator 3D 中的粒子系统除了拥有众多模块之外,还支持多种曲线,所以各种特效实现起来相对简单。
Creator 3D 制作粒子效果
使用 Cocos Creator 3D 极限开发体验如何呢?
Santy:首先 Cocos Creator 3D 对资源的支持很全面,包括网格、材质、动画都可以通过 FBX、GLB 等格式快速导入编辑器中,这对于原型开发非常方便。
在游戏设计中,我们希望场景中能同时显示上千个带动画的人物模型,从而模拟出市场的效果,在不启用 GPU Instancing 时,Drawcall 很容易达到 200 以上,造成性能严重下降。
启用 Instancing 前效果图
而在启用了 instancing 之后,这一问题被非常轻松地解决掉了,Drawcall 数量降到了个位数,在手机浏览器上也能非常流畅地运行,留给了我们更多的空间去做 GamePlay 方面的发挥。
启用 Instancing 后效果图
极限开发的时间压力下,Cocos Creator3D 有什么独特的优势吗?
Santy:Cocos Creator 3D 保持了 Cocos Creator 的调试风格,在修改场景和代码后,你不需要经历漫长的编译过程,就可以非常方便地使用智能设备进行效果测试。只需要扫一扫编辑器上的二维码即可快速预览效果。
这对于快速原型开发是非常重要的,当我们不确定这是否是我们需要的东西时,快速地测试、讨论、形成正向的反馈系统,促进设计不断地改善,最终确定设计,我们不希望开发中大部分时间是在等待游戏编译。
相较于其他作品,我们的游戏原型,更容易让大家玩起来。得益于 web 的快速分发能力,我们可以使用 Cocos Creator 3D 将游戏发布到 web 平台,并使用 iFrame 嵌入到我们想要嵌入的网页中,玩家不需要下载其他内容,即可开玩。这对于一个游戏原型来说,是非常重要的,越容易接入玩家,则意味着开发者能获得更多的反馈,从而调整自己的设计。
另外,版本的更新也是非常方便的,当更新版本后,只需更新服务器上内容,而所有玩家将获得最新的内容。
总体而言,使用 Cocos Creator 3D 开发游戏原型非常方便与快捷,虽然目前还有部分功能还未集成,例如查看运行时节点树等。但 Cocos Creator 3D 将会越来越完善,成为原型开发的高效工具。
听说大家都是挑灯夜战,肝还好吗?
ArthurWang:要说完全不累是假的,周六那天我大概熬到了凌晨 4 点,最后和队友交流都已经不清醒了才去休息,第二天也是强打精神开发。但看到我们的作品被队友很认真地游玩之后还是很高兴。
那天结束之后感觉几个人都已经完全肝废了,Santy 更是吃着饭睡着了,但看到直播中我们的作品得到的反馈又觉得这一切都值了。
对于成品还满意吗?是否有什么遗憾?
ArthurWang:成品是大家思维火花碰撞的结果,经过不停地迭代,最终的成品其实和我们预先设定的想法已经不是严格一致了,在做的时候不停地有新的想法冒出来,作品本身自己会逐渐成长。
gameall3d:这次的队伍没有美术,所以画面上还是有所欠缺,连个像样的封面都没有,但也因此让我们能更加专注在玩法上进行思考,一群人在一起边思考边制作游戏的体验真的很棒。
结语
以上就是本次的专访内容,能够一起做一些真正想做的游戏,抓住一闪而过的灵感,并使之变成现实,这段过程和感觉都值得铭记,相信这也是 Game Jam 迷人的所在。
很高兴引擎组成员的作品《消费主义》游戏原型,成功提名年度最佳 Game Jam 游戏,获得评委认可,也期待团队取得更好的成绩。感兴趣的童鞋可以点击《消费主义》游戏原型体验喔~
基于有向距离场(SDF)的地图碰撞系统 | Cocos 技术派第15期
团队介绍
《吃鸡联盟》是由南京壹游网络科技有限公司基于 Cocos Creator 3D 研发的一款 IO 竞技小游戏。这支成立于2017年的团队,在经历了创业初期 H5 页游的失利和中期的迷茫时期后,如今坚定地选择了走小游戏开发路线,团队负责人笑称自己“是芸芸小开发者的真实缩影”。目前团队共有4人,分别担任项目制作人书生(兼行政、商务、运营…..)、技术开发毛毛熊、美术设计啊翔和策划汤包五十六,“能者多劳”,作为公司负责人的书生很苦逼的对应了 N 个岗位。
《吃鸡联盟》立项于2020年3月,疫情期间,成员们就通过远程办公的方式不断寻找新的产品方向。团队之前的产品都是关卡制,受限于团队规模,关卡制的游戏又对内容的要求比较多,制作成本较高,此前的产品效果不是很理想。考虑到小游戏的特性、用户需求以及团队本身的能力,最终选择了 IO 类作为后期团队的主要研发方向。
3月初团队复工,产品立项,4月底完成了第一个版本,开发周期将近两个月。这是团队的第二款 3D 产品,也是第一款 IO 类产品,增加了 AI 来提高游戏可玩性,花费了相当多的时间在 AI 的研究上。
游戏介绍
游戏融合了吃鸡+IO 元素,玩法很简单,拖动人物即可控制行走并发射道具,玩家可以通过灵活的走位来发射子弹攻击敌人,也可以通过掩体来躲避敌人攻击。拾取游戏内的紫钻可以提升等级,进而对技能进行解锁和升级,比如回复血量、提高攻击力、提高攻速、提高射程等。
随着时间的推进,游戏地图会缩小,玩家必须移动至安全区域,否则就将丧生在毒圈中。
除了缩圈机制之外,《吃鸡联盟》区别于其他类型射击游戏的地方,还表现在子弹的速度上。大部分的射击游戏属于不对称攻击(闯关类的都是玩家射速高、怪物射速慢)和硬扛类(射速相同拼武器和血量),《吃鸡联盟》的子弹速度相对较慢,有比较多的可操作性,玩家可以通过操作躲避敌人的子弹,利用走位去攻击其他玩家,这样的机制更能让玩家体验操作的乐趣。
背景
游戏中玩家与障碍物的碰撞检测便是采用的 SDF 技术,是怎么想到把 SDF 技术运用在这款游戏中的呢?洪磊分享道,“目前团队主要发布的平台是微信小游戏,但是微信小游戏平台对于性能的压制还是比较严重的,尤其是苹果手机。在项目初期,团队有两个选择:一是基于物理引擎,二是使用 SDF 技术。这款游戏涉及到比较多寻路算法,而且模型较多,基于性能的考虑,我们尽量在其他的方面进行优化,所以选择使用 SDF 技术来实现碰撞检测。”
传统方案上,对于这种场景的设计,大家首先想到的肯定是物理引擎,通过设置建筑物和障碍物的碰撞体(Collider)来阻挡人物的行动。
在这种思路下,如果场景中的建筑物和人物比较多,会造成比较严重的性能问题,因为每一帧内对每一个人物和每一个障碍物都需要做碰撞检测,计算量是:N (人物) * M (障碍物)。再加上飞镖的碰撞检测计算量,在不支持 JIT 的 iOS 平台上可能会有不小的性能压力。当然,基于物理引擎的碰撞检测方式也有不少可以优化的点,比如说:
- 使用简单的 Builtin Physics 替代 Cannon 物理后端
- 通过场景管理剔除不在可视范围内的物体的碰撞计算
- 简化 3D 碰撞检测为 2D 碰撞检测,简化盒子碰撞体为圆形碰撞体
但是这些优化的效率都远远不如《吃鸡联盟》中所应用的有向距离场碰撞系统。
下面就来看看开发团队倾囊相授的基于 Cocos Creator 3D 如何实现这样一套场景碰撞检测系统吧!
分享者:kx-dz
首先,大概实现的原理是通过插值计算得出任意点的有向距离数据,然后与单位的碰撞大小做比对,来检测单位是否可以通行。实例图如下:
如果你做的是类似于《王者荣耀》这样的伪 3D 游戏,只需要考虑平面位置因素,不需要考虑高度,不需要太精准的碰撞判定,并且地图元素固定不会变动,这套高效的、基于有向距离场(SDF)的地图碰撞系统可以参考使用。
1. 原理
将地图划分为 N*N 个格子,每个格子的四个角存储有距离数据,这些数据是每个角所在点到最近的障碍的距离。如下图:
深色格子不可通行,交叉点数字代表该点到最近的不可通行格子的距离(下文称“有向距离”)。
通过有向距离数据,我们可以通过计算差值的方式算出任意点到最近障碍的距离。
如图所示,在判断精灵是否可通行时,只要在精灵当前位置所在格子上的数据进行一次插值计算,即可判断是否可通行,非常高效。
2. 具体实现
既然这么棒,那么,要怎样获得这些数据呢?
2.1:栅格化地图数据
就是将地图划分为N*N个格子,每个格子标记为可通行/不可通行。当然,划分的格子越多,精度越高。建议使用高度图来存储通行数据,高度图长这个样子:
这是一张 128*128 的图片,代表将地图划分出的 128*128 个格子。图片上每个像素点的颜色表示是否通行,黑色为障碍,白色为通行区域。
2.2:读取栅格数据
准备好图片后就需要读取像素信息了。
(关于原生url的获取,暂时没太好的方法,只有先load资源然后再获取nativeUrl值。如果有更好的方法请告知)
//获取指定图片文件的像素数据。返回Promise //path写到文件名就行,不需要加spriteFrame和后缀 loadImagePixelData(path:string){ var self = this return new Promise((resolve,reject)=>{ loader.loadRes(path+"/spriteFrame",SpriteFrame,(err,res)=>{ if(err){ console.error(err) return reject(); } var spriteFrame = <SpriteFrame>res; var rect = spriteFrame.rect; var img = new Image(); img.src = spriteFrame.texture.image.nativeUrl; // console.log(spriteFrame._image.nativeUrl); // console.log(spriteFrame._image.url); img.onload=()=>{ self.context.drawImage(img,0,0,rect.width,rect.height); var imageData = self.context.getImageData(0,0,rect.width,rect.height); resolve(imageData); } img.onerror=()=>{ reject("Error:load img failed!Path="+path); } }) }); } }
成功的话,你会获取到 imageData,格式差不多这样:
{ data: [0,0,0,255,0,0,0,255,…], height:128, width:128}
data 数据每 4 个一组,存储了一张图片上每个像素的 RGBA 值,顺序则是按照由左向右、由上往下的顺序(遵循 canvas 坐标系)。将颜色数据转换为二维的布尔数组,即为地图每个栅格的通行数据。
实现代码:
//高度图数据转化为地图通行数据 //imgData格式:{data:Uint8ClampedArray,width:number,height:number}imgData2PassData(imgData:any){ var data = imgData.data; var result = []; var width = imgData.width; var height = imgData.height; if(data.length<width*height*4){ console.error("Error:图片数据长度不足!") return []; } var count = 0; for(var y=0;y<height;y++){ var arr = []; for(var x=0;x<width;x++){ var r = data[count]; var g = data[count+1]; var b = data[count+2]; arr.push(r>128&&g>128&&b>128); count+=4; } result.push(arr); } return result; }
2.3:计算栅格四角的有向距离数据
(比较麻烦的一步。这里介绍一个笨办法,如果有更简单的办法,欢迎告知)
每个角(即栅格划分线的交叉点)都需要计算一次。如果你将地图划分成了N*N个栅格,那将有(N+1)*(N+1)个交叉点的有向距离数据需要计算。
对于每个交叉点:
首先要遍历所有的栅格。如果是不可通行的栅格,判断栅格和当前点的方位关系,决定用栅格的哪个角去计算到当前点的距离。
决定好了后,计算两点距离。所有不可通行的栅格都要和当前点计算距离,最后取它们的最小值,即为有向距离值。
差不多是这个意思
实现代码:
//存储通行数据,这一步上面做过了 private _blocks=[] //用来存储有向距离数据 private _distances=[]; initSdfSys(){ var gridCountH = 128; var gridCountV = t128; this._distances=[]; for(let i=0;i<gridCountV+1;i++){ let dataArr = []; for(let j=0;j<gridCountH+1;j++){ var value=0; dataArr.push(value); } this._distances.push(dataArr); } this.refreshData(); } private refreshData(){ for(let y=0;y<this._distances.length;y++){ for(let x=0;x<this._distances[y].length;x++){ this._distances[y][x] = this._checkDis(x,y); } } } //距离检测 private _checkDis(vertX:number,vertY:number):number{ var result; for(let y=0;y<this._blocks.length;y++){ for(let x=0;x<this._blocks[y].length;x++){ if(this._blocks[y][x]){ let dis; if(y>=vertY&&x>=vertX){ dis = Math.floor(this.gridSize*(Math.sqrt(Math.pow(y-vertY,2)+Math.pow(x-vertX,2)))); } else if(y<vertY&&x>=vertX){ dis = Math.floor(this.gridSize*(Math.sqrt(Math.pow(y-vertY+1,2)+Math.pow(x-vertX,2)))); } else if(y>=vertY&&x<vertX){ dis = Math.floor(this.gridSize*(Math.sqrt(Math.pow(y-vertY,2)+Math.pow(x-vertX+1,2)))); } else if(y<vertY&&x<vertX){ dis = Math.floor(this.gridSize*(Math.sqrt(Math.pow(y-vertY+1,2)+Math.pow(x-vertX+1,2)))); } if(isNaN(result)||dis<result) result=dis; } } } return result||0; }
优化:
因为计算量高达 (N+1)(N+1)N*N 次,可能会消耗大量时间。经试验,一张网格尺寸为 128*128 的地图,在纯 H5 环境以及安卓的微信小游戏环境下,计算速度尚能接受,但是在 iOS 的微信小游戏环境下,计算时间高达 50s,这显然是不能接受的。所以,推荐使用事先处理好数据,然后导出 json 文件的方式,游戏运行时直接读取现成的 json 文件即可。这就是以内存空间换取速度的思想,也是 SDF 系统的核心思想。
2.4:使用 SDF 碰撞系统
分三步:
第一步:判断精灵当前位置属于哪个格子,这个很容易;
第二步:获取格子四个角的有向距离,并计算插值;
插值计算代码:
calPointDis(pos:Vec3){ var gridLen = 32; var gridPos = this.nodePos2GridPos(pos); if(this._block[gridPos.y]&& this._block[gridPos.y][gridPos.x]) return 0; var posZero = this.vertexPos2NodePos(gridPos.x,gridPos.y); var parmX = (pos.x-posZero.x)/gridLen; var parmY = (pos.z-posZero.z)/gridLen; var dis_lt = this._distances[gridPos.y+1][gridPos.x]; var dis_ld = this._distances[gridPos.y][gridPos.x]; var dis_rt = this._distances[gridPos.y+1][gridPos.x+1]; var dis_rd = this._distances[gridPos.y][gridPos.x+1]; var dis = (1-parmX)*(1-parmY)*dis_ld+parmX*(1-parmY)*dis_rd+(1-parmX)*parmY*dis_lt+parmX*parmY*dis_rt; return dis; }
第三步:最后取得的数值表示精灵体积半径为多少时才能通过,否则判定为阻拦。
2.5:检测到碰撞后的处理
游戏中玩家使用摇杆控制角色时,如果撞到墙面了,肯定不可以让角色立刻停下来,那样的操作体验就很糟糕了。通常的做法是让角色沿着墙面滑行。
基于 SDF 的碰撞系统有一套处理这类情况的方式,即通过计算碰撞法线来得出玩家移动时碰到障碍后的正确方位。
计算碰撞法线方向的代码:
calGradient(pos:Vec3):Vec3{ var delta=0.1; var dis0 = this.calPointDis(new Vec3(pos.x+delta,0,pos.z)); var dis1 = this.calPointDis(new Vec3(pos.x-delta,0,pos.z)); var dis2 = this.calPointDis(new Vec3(pos.x,0,pos.z+delta)); var dis3 = this.calPointDis(new Vec3(pos.x,0,pos.z-delta)); var result = new Vec3(dis0-dis1,0,dis2-dis3).multiplyScalar(0.5); return result.normalize(); }
具体的处理碰撞的代码:
update (deltaTime: number) { if(this._isControlledByJoystick&&this._speedRatio>0){ var curPos = this.node.position.clone(); var moveDis_dt = this.curSpeed*deltaTime; var newPos = curPos.clone().add(this.dir.clone().multiplyScalar(moveDis_dt)); var sd = this.ground.calPointDis(newPos); if(sd<this.collideRaduis){ //console.log("sd=",sd); var gradient = this.ground.calGradient(newPos); var adjustDir = this.dir.clone().subtract(gradient.clone().multiplyScalar(Vec3.dot(gradient,this.dir))) //console.log(StringUtils.format("dir=%s,gradient=%s,adjustDir=%s",this.dir,gradient,adjustDir)); newPos = curPos.clone().add(adjustDir.normalize().multiplyScalar(moveDis_dt)); for(var i=0;i<3;i++){ sd = this.ground.calPointDis(newPos); if(sd>=this.collideRaduis) break; newPos.add(this.ground.calGradient(newPos.clone()).multiplyScalar(this.collideRaduis-sd)); } // sd = this.ground.calPointDis(newPos); // if(sd<this.collideRaduis){ // newPos.add(this.ground.calGradient(newPos.clone()).multiplyScalar(this.collideRaduis-sd)); // } //避免往返 if(Vec3.dot(newPos.clone().subtract(curPos),this.dir.clone())<0){ newPos = curPos; } } this.node.setPosition(newPos); this.onMove(); } }
关于 SDF 的细节就不在这里向大家详细解读了,目前这方面的资料相对比较稀少,感兴趣的开发者朋友,可以参考《腾讯游戏开发精粹》这本书进行学习,此外,也可以多逛逛 Cocos 官方论坛,论坛上还是有很多有价值的学习资料可以挖掘的。
最后,要感谢 Cocos 团队在这一年多来给予我们的支持。由于 Cocos Creator 引擎的易用性,在19年做小游戏时,我们就把 Cocos Creator 作为首选引擎,后期推出了 3D 引擎,我们几乎没有花学习成本就成功完成了从 2D 到 3D 的团队转型。在最开始使用 3D 引擎时,我们对于优化毫无经验,Cocos 给我们提供了很多很好的思路。特别是到了后期,产品开始一些跨渠道跨平台的运营时,关于各个渠道的平台差异所产生的分包问题以及参数配置问题,这些对于小开发者很不友好,没有经验的情况下,可能要花很长时间来应对这些问题,幸好我们跟 Cocos 团队有密切的沟通,这些问题都很快得到解决。
以上就是本期技术派的全部内容啦!非常感谢制作人书生接受 Cocos 的专访,也非常感谢《吃鸡联盟》团队慷慨的技术分享,希望游戏可以取得好成绩!
技术派,是 Cocos 专为开发者打造了知识分享专栏,我们将不定期邀请知名的游戏制作者,为广大开发者分享来自真实项目的实用的开发技术和实战经验。欢迎大家推荐想要学习的游戏产品和想要了解的技术知识,也诚邀有技术分享意愿的开发者联系我们噢~
腾讯光子 《最强魔斗士》3D 开发优化经验分享 | Cocos 技术派第14期
《最强魔斗士》是由腾讯光子游戏工作室采用 Cocos Creator 3D 开发的一款画风精湛、玩法有趣的 3D 小游戏,近日腾讯光子开发团队接受了 Cocos 的专访,从策划、运营和技术多个角度,大方分享了游戏的开发经验,包括关卡设计、动画效果、角色换装、碰撞检测、资源管理和加载、性能优化等,相信能给大家带来新的思考和启发。
游戏亮点
游戏设计
《最强魔斗士》沿用了当前非常火的弓箭类操作设计,移动虚拟摇杆即可控制角色移动躲避,停下来的时候会自动对敌人进行攻击。角色会发射子弹自动攻击怪物,省去了选择攻击目标、选择技能、进行攻击的步骤。这种允许玩家单手操作的方式,更适合广泛的休闲用户,同时又赋予了玩家一种 「Play to Win」的乐趣。
在玩家成就反馈上,用相对慢速但可永久保存积累的装备+秘籍系统,搭配每局战斗中临时获得的局内等级+技能,让玩家在局内大概 2 秒杀一怪、30 秒清一关、60 秒升一级、10~15 分钟通一关,能在游戏过程中体验到飞速成长的即时满足感。
戳链接查看游戏视频:
https://v.qq.com/x/page/d0968ekcndk.html?pcsharecode=&sf=uri
游戏采用了 2D 场景 + 2D 弹幕 + 3D 怪物和角色的做法:
- 2D 场景的设计大幅降低了同屏的顶点数,一方面降低运行在 iOS 小游戏平台的性能压力,另一方面也留出足够的性能空间来丰富场景美术。所以我们可以看到和其他弓箭类游戏不同的是,《最强魔斗士》场景细节极其丰富,各种建筑、农田、树木都做得非常细腻。
- 2D 弹幕则是弓箭类的标准做法,这可以大幅降低碰撞逻辑运算的压力,使得游戏可以承载像「3 连发连弩 + 正向箭 + 斜向箭 + 左右箭 + 子弹折返 + 弹射」这样同屏存在七八十个子弹的轨迹和碰撞计算。
- 3D 角色和怪物,则方便做各种技能和换装,特别是换装及其背后的经济系统,这个毋庸多说。
美术品质
毕竟是腾讯光子的大厂作品,《最强魔斗士》里无懈可击的高品质美术令人由衷赞叹。甚至有多个游戏团队看到这种美术品质后,惊叹之余,转身就把自己公司里处于立项早期的弓箭类项目直接砍掉了 —— 不是技术上做不出来,而是真心没法做到这种美术水平。
细节是魔鬼
《最强魔斗士》除了美术细节之外,在其他方面的细节上同样打磨了很久。
作为 Cocos Creator 3D beta 的第一批内测参与者,腾讯光子在 2019 年 7 月份就已经立该项目,微信小游戏里注册这款游戏名字的时间是 2019 年 8 月 15 日,也就是到现在已经打磨了足足 8 个月。Cocos 团队在保密条件下,也看着这款游戏逐步完善细节到今天的水平。
比如在打击感和操控空间上,不同怪物面对不同武器时的击退计算、不同的受击硬直时间、近战怪物的攻击前摇时间,都非常精妙;在音效上,不同的怪物配置了不同的击中音效,这就可以在怪物超出屏幕范围的时候给予玩家听觉反馈;在关卡设计上,充满了各种让人会心一笑的卡地形、夹角、障碍物缝隙,等着玩家发现和利用。
在装备-技能搭配的策略空间上,玩家可以自己搭配出不同的打法流派。不同的套装对应不同的打法,可苟可浪。即使在同样装备的情况下,有时欧皇眷顾,局内所有技能不断正向叠加,而有时则非酋附体,始终抽不到对应技能,这两者完全就是全场压制和满地找牙的区别。所以在《最强魔斗士》里,装备强度 + 运气水平 + 操控水准,构成游戏过关三个核心要素之间的数值平衡,其实非常微妙。
顺便提示一下:关卡里随机给予的局内技能,其实并不是完全随机的,所以并不是表面上看起来的那么简单哦。
专访实录
游戏中丰富的关卡设计是如何提高制作效率的呢?
我们有专门的小组开发关卡编辑器,除了实现传统的刷子等地表编辑功能外,还有一个更上层的抽象-岛屿,通过表格配置以及一定的随机规则,可在工具层自动拼接岛屿生成完整的关卡,这是支撑目前数百上千个关卡制作的重要能力。
游戏中丰富的动画和效果是如何制作的呢?
这部分得益于 Cocos Creator 3D 本身强大的动画系统,我们所有动画都是美术在 Creator 里制作的,粒子系统和时间轴动画系统能满足所有需求,应用到程序里也非常方便。
角色换装是如何制作的呢?
目前产品里的角色是由武器决定外观的,所以换装系统并不复杂,武器决定了主角使用的整个模型和贴图,不过同动作的人物形象是复用骨骼和动画的,这能节省不少资源量。
能否分享下我们在碰撞检测上做了哪些优化策略呢?
复杂情况下有七八十个子弹和 10 个左右同屏的怪物,这个量其实不算很大,不过因为渲染已经消耗了一半的时间,再加上 iOS 下 JavaScript 解释执行的效率有限,所以还是遇到了一定挑战。
碰撞检测我们没有用物理引擎,为了简化运算,整个游戏里仅支持圆形和矩形碰撞体。障碍物首先对相邻的矩形进行合并减少碰撞体数量,然后用四叉树做空间规划,对每个子弹来说,每次参与运算的障碍物只有 0-2 个,所以这个消耗控制得很低。
更主要的消耗在于子弹和怪物的碰撞,这方面除了碰撞算法本身要简化到极致外,更重要的是从上层根据实际业务需求来复用子弹的运行路径和碰撞测试结果,从而达到大量减少运算的效果。
如何缓解同屏子弹和怪物数量较多时的渲染性能压力?
怪物是 3D 模型,引擎的渲染性能已经很不错了,另外我们扩展支持了 GPU Instancing,某些情况下能有一定性能提升,目前在低端 iOS 上怪物渲染占了不到一半的帧时间,算是比较可接受的范围内。
子弹大多数为 2D 精灵,同屏精灵数量最复杂的情况下有数百个之多,在合理安排层级控制 drawcall 在 40 以下之后,引擎本身的渲染效率已经不错。
不过针对如此大量同屏精灵数的情况,我们还是做了比较多的针对性优化才避免了运算峰值带来的卡顿。
主要的优化方案大致有这些:
- 很大部分矩阵运算可以简化为坐标求和;
- 实现轻量的 active 功能以优化大量频繁的节点增删;
- 用定制的仅支持 Simple 模式的 sprite 渲染减少动态合批的运算量;
- 用静态合批优化不动的精灵渲染;
- 用懒渲染模式减少序列帧动画的消耗等。
Cocos 团队:v1.1 已经自带 GPU Instancing 支持,不仅支持静态模型,还支持蒙皮模型的 Instancing 合批。
游戏整体非常流畅,可以为我们分享些小技巧吗?
资源管理和加载方面我们做了深度的定制,在不改动引擎原生实现的情况下,我们进入新手关卡需要下载超过 300 个文件,总体积在 9M 以上。而定制了文件组织形式和下载流程之后,进入新手关卡只需要下载约 15 个文件,总体积不到 2M。资源定制的主要思路是把大量零散的小 json 合并成大 json,然后根据 prefab 的依赖关系把多个文件压缩成一个 zip 包,运行时下载这些 zip 包解压使用。另外在关卡内战斗的时候,我们会利用空闲时间去下载下一关的 zip 包,从而达到更快的切换速度。
对 Cocos Creator 3D 的表现是否满意呢?
目前来看对 Cocos Creator 3D 的性能表现是比较满意的,beta 版本缺乏的一些对性能特别重要的组件也已经陆续支持了,据了解 1.1 版本还会支持 GPU 粒子系统,把性能上留下的一块短板补上,这个是我们特别期待的。至于性能优化方面,对于大型的复杂游戏来说,即使引擎的通用功能性能再好,都避免不了要定制化部分实现,从这个角度来说,希望 Cocos 引擎后续在用户定制与扩展方面提供更好的支持,这样能降低用户直接修改引擎源码的需求和维护成本,变得更加友好。
Cocos 团队:v1.1 的粒子系统开始支持利用 GPU 运算能力进行模拟,大幅度提升运行性能,特别是在不支持 JIT 的 iOS 设备上,可以愉快地增加特效的使用啦。
最终为什么选择 Cocos Creator 3D 来制作呢?
由于微信小游戏平台上的复杂 3D 游戏案例并不多,所以技术选型是我们特别慎重的事情。项目在选型阶段花了接近一个月时间,预研了多个现今市场上支持 3D 渲染的H5引擎,并且分别用 Cocos Creator 2.5D 版本,Cocos Creator 3D beta 以及 LayaBox 2.0 引擎实现了原始 Demo,做了详细性能测试。
对比了开发流程、技术支持和运行性能的各方面因素,发现 Cocos Creator 3D 在前两者有明显优势,性能上也基本持平,所以成为了我们的首选。
是否有计划发布到原生平台呢?
目前我们也构建了安卓 App 版本,运行性能高非常多,不过暂时没有发布计划,从能力上来看 Cocos Creator 3D 是能满足跨平台需要的。
Cocos 团队:原生性能一直是我们非常重视的关键指标,开发者们可以尝试把自己的游戏发布到原生平台,可能会有惊喜哦。
为什么选择用 3D 的形式来呈现这个游戏呢?
按照之前的一些经验,我们希望《最强魔斗士》这个项目在动作层面上拥有更好的表现力,同时也有更好的移动手感、更加流畅的移动体验、以及在整个美术制作流程和表现力上有更高的上限,所以最后我们决定试用 3D 的呈现方式。
选择斜 45° 的视角是出于什么考量呢?
45° 的视角从对抗模式来看,相较于弹幕体验,会更接近传统 RPG 的视角表现方式。在这样的视角上,可以突破纯弹幕的玩法设计禁锢,扩展更多的设计空间。比如后续我们会设计更丰富的武器体验,甚至近战等等,同时 45° 的视角也会更适合表现有压迫力的大型怪物,例如游戏中的 Boss 战斗,玩家体验就会更丰富一些。
如何提高游戏中关卡的制作效率呢?
在 Cocos Creator 3D 引擎下,项目组内部针对游戏的关卡和怪物都搭建了比较高效的编辑器,大幅度提高了关卡制作的效率。游戏前三章的体验量级,就有 600-700 个不同的关卡小岛,没有高效的编辑器是完全无法跟上内容消耗速度的。
美术方面的制作管线有涉及到哪些工具和岗位呢?
美术涉及到的工具和岗位都算是行业中比较常规的标配,二维绘图软件及 3ds max,岗位有交互、视觉、原画、3D 动效设计师这些。相对比较有挑战的是引擎的选择。产品在小程序上发布,角色需要 360° 自由旋转、射击,用 2D 图素就不那么好表现,图量也会很多,权衡之后用 3D,选用 Cocos Creator 3D,兼固了开发及美术 3D 需求。由于工具比较新,人力有限,美术的一些效果功能都是对应岗位的同学提给 Cocos 那边帮忙实现,相当于联合的技美。
游戏中丰富的动画和效果是如何制作的呢?
特效方面:采用 Cocos Creator 3D 编辑器开发制作,粒子系统、模型、序列图都结合使用,较多采用小型特效贴图,在编辑器里以 2D 模式,组合搭建出不同的动画特效。比如游戏里的子弹及受击,部分结合了粒子、模型、系列图等,单图居多,特效师做好每个子弹样式,由程序去实现弹道逻辑,比如飞行、抛射、折返、追踪、多弹道等不同效果,这种方法能保障在全屏群攻的时候,还能流畅地操作。
UI 界面动效方面,分解界面素材,针对每个 UI 节点做动画。有些也需要程序协助触发的动效,比如技能选取,特效设计师先做好选取技能前后所要表达的特效文件,然后配合程序做好逻辑接入。
游戏中有丰富的装备系统,角色换装是如何制作的呢?
角色的换装是在三维软件里制作好模型动画,导出 fbx 格式,合入到 Cocos Creator 3D 里面,皮肤跟武器是分开的,可以自由搭配,并能实时旋转预览,这也是 3D 的优势。
激励广告是如何融入到游戏流程中的呢?
我们把广告加入到了对局内复活的功能上,对于类似类型的 PVE 游戏来说,这样的广告形式不影响玩家体验,比较自然。
我们是如何考量游戏的策略性的呢?
这里介绍两个魔斗士里面的技能组合:
弹道增强+追踪箭/折返子弹+背刺暴击,类似的思路,一方面这样的技能组合能够带来足够的视觉冲击力提升;另一方面,通过核心技能的搭配,可以达成 1+1 远远大于 2 的强度体验,包括能够突破一些特殊地形阻挡的限制,后续也会设计更多类似的技能组合,敬请期待哈~
可以简单分享下装备系统的设计思路吗?
我们希望能够通过外围的策略系统提供给玩家更多的长线追求和策略技能选择,主要是下述几个方面:
- 不同的套装提供不同的玩法倾向;
- 高品质套装的特殊魔法属性,可以让玩家打造属于自己的专属极品装备;
- 上个版本更新的特殊套件装备,可以让玩家选择放弃一部分能力,来获得更多的关卡资源收益。
我们在用户黏性上有做哪些措施呢?
一方面是尽快提高我们更新关卡的速度,能够跟得上玩家消耗内容的速度;另一方面也是前面提到的挑战玩法,给平台期玩家提供了更多持续游戏的动力,后续也会继续在这个方向发力,给平台期的玩家提供更多有趣有深度的新玩法模式。
方便透露下后续会做哪些调整吗?
目前上线阶段还只是很少量级的数据测试,从测试数据结果来看,基本符合我们的预期吧,玩家的在线时长数据比较可观,这也为我们后续继续迭代内容提供了更多信心。另一方面我们也希望可以稍微降低前期的关卡难度,以及优化最基础的体验(摇杆手感、镜头逻辑、稳定帧率等等),希望有更多的玩家可以体验到这个玩法的深度乐趣~
对于各个岗位上手 Cocos Creator 3D 是否有什么建议呢?
在 Cocos Creator 3D beta 版本阶段,引擎和工具在稳定性以及易用性上面有较多不足,不过随着版本迭代,我们能感受到引擎的进化非常快,对 bug 的响应及修复都非常敏捷。
目前到了正式版阶段,我们开发团队认为问题不多了,引擎运行层面比较稳定,主要是编辑器方面的稳定性希望进一步加强。
Cocos Creator 3D 和 2D 在工作流体验上是否有差异呢?
非常接近,涉及 3D 的部分需要看一下文档,其它方面可以无缝切换。
如何评价 Cocos Creator 3D 的整体使用体验呢?
功能丰富性能强大、使用上很简单符合过往经验,IDE 集成度高,对团队协作支持得很好,代码开源对性能分析和优化很友好。建议方面还是集中在编辑器,希望有更高的稳定性和扩展能力,进一步提升开发效率。
以上就是我们今天想跟大家分享的内容啦,再次感谢腾讯光子团队为我们带来干货喔,也祝愿《最强魔斗士》能取得好成绩。
以上就是我们今天想跟大家分享的内容啦,非常感谢腾讯光子团队为我们带来的技术干货 。我们也希望越来越多的开发者,能够通过 Cocos Creator 3D 创作出更多的精品游戏,也祝愿《最强魔斗士》能取得好成绩喔!
3D 小游戏《欢乐贪吃龙》关键技术盘点 | Cocos 技术派第13期
《欢乐贪吃龙》是由 SK2GAME 基于 Cocos Creator v2.2 研发的一款 3D 休闲小游戏,游戏画面卡通精美,玩法简单,玩家将扮演一只“贪吃龙”,在 3D 大场景中,捕食各种可爱又凶残的怪物,享受毁灭敌人的快感。
今日,《欢乐贪吃龙》项目的客户端主程 小涛 作客 Cocos 技术派,为广大开发者带来这款 3D 小游戏的开发经验分享,内容涵盖场景地图生成、关卡设计、分包分阶段加载资源以及性能优化等,以下为技术派正文:
一、项目概述
1.团队介绍
SK2GAME 团队成员共 20 人,主要来自原七道神曲项目组,包括原博雅,腾讯等。在积累了相对深厚的手游研发运营经验后,于 2019 年正式进军小游戏研发领域,目前我们也在摸索各大小游戏平台的核心用户玩法,团队擅长跑酷,音乐,休闲动作类,模拟经营类,rougelike 等游戏品类的研发和制作。目前团队资本结构相对纯净,属于天使轮。
2.游戏介绍
《欢乐贪吃龙》这款游戏玩法很简单,进入游戏主界面后,玩家可以在形态各样的贪吃龙中选择一条喜欢后进入副本,通过捕食各种怪物即可积累能量,能力积累到一定程度,可以喷射高能量的龙炎,进入无敌状态,毁灭一切敌人,顺利通关副本。
不同的贪吃龙除了有体态有迷你、小、中、大的区别之外,不同的皮肤也会在比如吞噬距离、速度、爆发冲刺、承伤等方面有着不同的 Buff 加成。
3.游戏结构
游戏的结构主要包括登录、主界面、副本、结算四个场景。
玩家进入到登录场景,将进行微信 sdk 登录,游戏服务器注册/登录,资源预加载流程,这个阶段的流程需要尽量快且失败后提示重试逻辑要做好,卡登录流程对新玩家是极不友好的,将导致用户直接流失。
主场景是玩家对龙的周边系统的认识及选择。
点击开始游戏打开任务界面,任务界面是当前副本的过关要求,全部任务完成,才能过关。
副本场景主要包括 3D 场景、玩家操作的龙、场景中的怪物、场景中的各种 buff 及收集物。各模块的交互判断主要是碰撞检测,例如龙和怪物的碰撞检测,龙的处理逻辑:
怪物的处理逻辑:
结算场景主要是对副本场景的一些游戏行为进行总结,游戏资源的奖励发放。
二、开发过程
1.开发引擎选择
开发立项是在 2019 年 11 月份,由于当时主要的任务,是替另一个项目接入新的渠道 SDK 以及一些零碎的工作,所以我有空就会对本项目一些可能遇到的难点就行预研,包括写了一个简单的 Cocos Creater 3D 版本的 Demo。考虑到 Cocos Creator 3D 当时的版本只支持微信小游戏,所以忍痛放弃了(个人对于 Cocos Creator 3D 很是喜欢)。Cocos Creator 2.2.0 版本对性能提升较大,预见本项目性能是个大瓶颈,所以我们果断使用了 Cocos Creator 当时的最新版本 2.2.2。Cocos 官方最近发了新版本 2.3.0,其中的 3D 粒子我们需要用到,对于其他内容则没有很高的需求,所以暂时还不考虑升级。
2.开发周期
项目正式制作始于 2019 年 12 月初,第一周用一些测试 3D 模型,做了个简单的 3D 场景,一条龙在其中漫游,顺便测试了 3D 模型+物理场景性能的峰值。达到 3 万多面时,iphone6 就只有不到 20 帧,安卓低端机 30 帧左右。后续开发过程中,每个功能都会考虑到性能的问题。
2020 年 1 月 17日,我们第一个版本上线微信小游戏,从无到有仅花了一个半月的时间(无良老板压榨),这个版本的定义只是一个基础功能版本,周边系统缺少,中期目标太弱,更别说后期了。值得开心的是,在年初的 Cocos 官方直播中,Cocos Creator 技术总监 Panda,在直播中推荐了我们的游戏。视频地址:https://www.bilibili.com/video/av86788767,时间定位 64 分 30 秒。
3.开发人员配置
《欢乐贪吃龙》项目由 1 个策划+2 个前端开发+1 个原画+1 个 UI+1 个特效(技术美术)组成,游戏中场景配置优化,怪物和龙的动画及其他游戏特效效果,都由前端开发和技术美术一起配合完成。过程只有一句话:技术美术真香!
前端只要提供好一些挂载组件和 prefab,效果由美术在编辑器中调整测试效果,效率提高不止一个档次。
三、几个重点
1.场景地图生成
当前版本只有1个场景,但是设计时,有考虑到场景变化可配置,将地图分块,如图所示:
场景地图分成 n 个小块,每个 block 节点都是地图的位置节点,上面挂载着一个地图块加载组件 MapBlock。
每个 block 节点显示掩藏是受父节点 RootNode 上的 ActiveState 控制。这个脚本的主要工作是对子节点遍历,定时 updateTime(毫秒)计算 3D 摄像机相对上次移动了多少距离,当大于 updatePosStep 时,计算子节点是否在 3D 摄像机视线中,进行掩藏显示操作。block 节点显示,就会加载显示对应的地图块,掩藏将显示的地图块掩藏(优化性能)。
因为是 3D 场景,所以需要获取当前 3D 摄像机去计算。上面的代码是当前屏幕的尺寸 viewRect 与当前 block 节点在 3D 摄像机中映射到屏幕上的 rect 比较是否相交。相交就是需要显示。UIHelp.getRectInView 方法:
2.关卡设计
关卡过关条件的任务,是每一关配置一个任务库,随机生成。怪物的配置是配置在如下的 prefab 中。怪物节点也是受 ActiveState 组件控制,实现动态显示加载。
这里用 prefab 配置关卡的怪物生成,是为了方便策划,后期会写一个插件,将此 prefab 转换成配置文件,这样数据会小很多,毕竟 prefab 里面的数据有很多没用的。怪物创建有一个管理类管理,里面对怪物的创建、销毁用了对象池。同样怪物的显示掩藏逻辑也在里面处理,原理同地图生成。
3.分包分阶段加载资源
游戏中的资源按照各个阶段需要,分成 1 个主包,2 个子包。主包包括代码文件及登录场景的背景图,分包 1 的资源主要是整个游戏的必要资源,分包 2 的资源是副本场景必要的资源。
登录时加载分包 1,进入主场景后,开始下载分包 2,进入副本时的加载界面也会加载分包 2,如果前面下载好了,此处可以更快点。这些处理对新玩家首次进入游戏,起到一定优化作用。进入副本的加载界面,不只是加载分包 2,还做了当前关卡中地图块和会出现怪物的预加载。虽然这样会让加载时间变长,但是玩家游戏体验会好很多。看到的东西不会延迟显示。
4.性能优化
- drawcall 优化:UI 的所有图片文件夹都配置了自动图集,并且分模块,每个模块一个图集。节点层级按照合批逻辑优化(3D 模型的合批,本项目没有开启,合批的性能在iOS 的微信小游戏上,是负提升)
- 字体尽量用 bmfont:系统字体的文本设置缓存类型(游戏中获得 buff 时,会创建一个 buff 图标和 buff 描述文字 lab,安卓上会出现卡顿,后面修改成 bmfont 解决)
- 对于可能重复出现的效果,尽量用对象池。
- 上面对于地图块,怪物的动态显示掩藏,对于物件比较多,又可能不在屏幕范围之内的游戏,是一个很好的剪裁优化方案。
- 模型的面数需要把握好,太高的面数,扛不住。
结语:
以上就是我今天带来的技术分享,欢迎大家点击下方的小程序体验我们的《欢乐贪吃龙》。
非常感谢 小涛 带来的技术实现分享,《欢乐贪吃龙》目前已上线微信小游戏,受到许多玩家的喜爱, 欢迎各位开发者搜索体验!最后,祝《欢乐贪吃龙》取得更好的成绩,期待 SK2GAME 有更多好玩的游戏上线!
团队仅3人 | 原生 3D 超休闲游戏《弹无虚发》是如何炼成的?
Cocos Creator 3D v1.0.3 已于1月20日正式发布,新增原生平台支持。正式版本的发布离不开社区开发者的意见和反馈,此前 v1.0.3 在社区开启公测,仅仅不到两周时间就有一位开发者就在 Cocos 中文社区中发布了一款采用 Cocos Creator 3D 制作的原生超休闲游戏《弹无虚发》,并已成功上线 iOS App Store,如此高的效率,引擎组都惊掉了下巴。
一、游戏介绍
《弹无虚发》是由游统科技开发的一款竖版休闲益智射击游戏,没有繁琐的游戏系统和操作,玩家通过一根手指即可在碎片化的时间里享受到游戏的乐趣,对于挤地铁的人来说,尤其友好呢(笑),目前游戏测试版已上线 iOS 平台,搜索“弹无虚发”即可下载体验。
战斗方式:
《弹无虚发》采用单摇杆+自动瞄准的方式,为玩家省去了选择攻击目标、选择技能和发动攻击等步骤。
游戏操作很简单,手指在屏幕任意处滑动可以控制英雄移动。玩家除了要移动躲避怪物之外,还需要考虑体力,因为移动速度越快,主角消耗的体力也就越高。若因过快的移动导致体力下降,移动速度会随之变慢,容易被怪物接近攻击。相较于传统的射击游戏,《弹无虚发》更添了一层策略趣味。
在竖屏单手操作的游戏中,不用选择技能即可直接攻击,给了玩家一种耳目一新的操作体验,并且游戏的操作难度也在休闲用户可接受的范围内。
场景表现:
随着战斗视角的转换,不论是树木、怪物还是主角、道具等 3D 阴影方向也随着发生变化:
怪物们奔跑时有气泡扬起的效果:
射击效果:
游戏设有枪支系统,包含丰富多样的枪支选择,如冲锋枪、沙漠之鹰、高达枪、黄金 AK 等等,不同的枪支在速度、射速和威力方面具有不同的表现力,可以满足玩家对于枪支的战斗和审美需求,同时这些不同种类的枪支在发动攻击时,也具有不同的效果:
沙漠之鹰
猎户者,子弹是绿色的
打击效果:
所有怪物被击中时带有反白效果。
主角被怪物攻击时带有反红效果。
道具效果:
游戏中设计了不同的增益道具,游戏过程中会随机产生各种各样的增益道具,如子弹数增多,防护罩,分身等,能够帮助主角更好地打击怪物。
二、团队专访
游戏研发商是来自上海的游统科技,这支研发团队仅有3名成员,包括1名程序,1名美术和1名策划,却能在短短两个多月时间能做出一款成熟的 3D 原生游戏,不可谓不惊叹呀!今日C姐也联系到了开发团队,一起来了解一下吧!
请为我们简单介绍一下游统科技以及团队各个成员的分工
我们公司其实不止3个人,还有其他伙伴在进行别的项目。游统科技是一家比较年轻的公司,但是公司里的成员都是相识已久志同道合的小伙伴。其中我们的主美杨斌和主策小韩是一起共事了五六年的同事兼朋友,而主程羊群加入的时间比较短,不过在羊群加入我们公司之前,我们已经通过朋友的介绍认识一年多了。
我们团队里的每个人都十分热爱游戏,包括但不限于主机游戏(PS,Swithc),PC 游戏以及手游。其实在这之前,我们团队曾经也制作出深受好评的游戏《像素制作者》等多款游戏。
目前团队的分工如下:
是什么契机,让你们动了做这款游戏的念头?
纵观这两年的游戏环境,超休闲游戏迎来了大爆发,经过我们内部讨论,觉得超休闲游戏市场前景还是非常不错的,我们希望制作一款上手非常简单,操作非常傻瓜式的游戏,方便玩家单手握持,单指操作。
但是游戏简单并不意味着对着手机点点点就完事了,我们团队里的每个人对游戏的品味都是很高的,也接受过各种大作的洗礼,在我们看来,目前市面上有许多游戏,需要玩家各种点按钮,签到,领取金币,参与活动,仿佛不是自己在玩游戏,而是游戏在玩自己一样 。
所以,我们的游戏必须要有游戏性,虽然操作简单,几分钟一局,但是也要让玩家感到有操作,有难度,还有惊喜,于是《弹无虚发》这个游戏的雏形就诞生了。
在游戏创作过程中遇到的最大的坎是?
《弹无虚发》是我们团队的第一款 3D 游戏,我们团队包括主美和主程之前都是做的 2D 游戏,所以各种各样子的幺蛾子也真是层出不穷。其中有个问题真是弄了我们很久,就是游戏在 iOS 上运行的时候。有的关卡会掉帧到 10 帧。这个问题的跨度跨越了两周,我们主程羊群做了各种假设,然后论证,然后推翻,最后终于发现是因为我们天空有一个粒子发射器占据了太多了性能,关掉之后终于解决了。
投入「弹无虚发」这款游戏的制作周期大概多久?
大概2个半月。虽然我们主美之前完全没有做过 3D 模型,但是多年的美术积累让他上手得非常快,很快就能做成一个栩栩如生的模型。而主程在 Cocos 的 2D 方面也有很深的积累,所以上手起来还是很快的。
美术风格后续还会大变动吗?
目前来说,在这个游戏上是不太会变动的。但是我们团队的美术风格也不是仅仅局限于此的,在这之前还有很多游戏的美术风格都是不同的。我可以随便截取几张。
《弹无虚发》使用的是引擎哪个版本?为什么会选择用 Cocos Creator 3D 来制作这款游戏?对引擎有 Cocos Creator 3D 哪些意见或建议?
我们大概在 Cocos Creator 3D 1.0.0 版本发布一周后就开始使用这个引擎了,当时我们也是准备做第一款 3D 游戏,在想着是用 u3d 还是 c3d 好,我们主程是做了5年 Cocos 的程序员,在他的力荐下,我们使用了 Cocos Creator 3D。
Cocos Creator 3D 相比 u3d 来说,他首先是免费开源的,这意味着我们的主程可以稍微魔改一下底层代码,其次现在 Cocos 的社区活跃度也是非常高了,引擎组对开发者的问题反馈都处理得非常快速,而且也没有语言的障碍,这真的是非常好。
对于 Cocos Creator 3D 这个引擎来说,才刚发布不到三个月,肯定没法拿成熟的 u3d 去跟它做对比,但是对于超休闲游戏来说,已经很够用了。希望未来的 Cocos Creator 3D 越来越好,争取早日成为国产游戏引擎之光。
目前的版本还未加入广告系统的原因是?
还没来得及加啊(苦笑),想先上 App Store 审核一下,没想到很快就过了,广告很快就会加上的。
方便透露一下游戏接下来的迭代节奏是怎样吗?除了原生平台之外,还会考虑发其他平台吗?比如小游戏和出海?
我们计划在后续版本里加入 Boss 战斗机制,Roguelike 系统在上完原生平台之后,国内众多的小游戏平台,微信小游戏,头条小游戏等等都会慢慢去上的,如果数据很理想的话,出海自然也会考虑的。
感谢团队接受 Cocos 的专访,在最后您还有其他想要分享的话嘛?
我,找投资,打钱(* ̄︶ ̄)。
Cocos Creator 3D v1.0.3 下载
https://www.cocos.com/creator3d
Cocos Creator 3D 材质系统:曲面效果如何实现?
前不久发布的 Cocos Creator 1.0.2 版本中正式加入了对 OPPO 小游戏、vivo 小游戏以及华为快游戏平台的支持,在诸多 Creator 3D 制作的小游戏案例中,《猪猪侠:极速狂飙》已上线 OPPO 小游戏平台。
这款休闲跑酷小游戏,采用了曲面材质效果来使跑酷赛道更加多变有趣,今日,Cocos 引擎开发工程师 ChiaNing 将为各位开发者来解析这种曲面效果的实现思路和方案,在阅读完本文之后,大家便可以将这种效果应用在自己的游戏中。
1、使用背景
在固定背后视角的跑酷游戏中,玩家面对的始终是前方布满障碍的赛道,除去有趣的障碍设计可以一直吸引玩家的注意外,许多游戏还会添加一些视觉效果来使游戏画面看起来更丰富有趣,比如今天我们要分享的这款《猪猪侠:极速狂飙》就采用了曲面的效果使得赛道看起来更多变,也更有立体感和纵深感。
那么,这样的曲面效果在 Cocos Creator 3D 中是如何实现的呢?
2、实现方案分析
要实现曲面的效果,我们有几种方案可选择:
1. 直接使用曲面模型
这是最直观最容易想到的实现方案,从模型层面直接将效果做好,省去了其他处理,但这种方案也存在着很多严重的问题:
(1)模型复用不便,模型生成时的状态几乎决定了它的使用场合,这对于游戏开发中需要大量复用资源以减小包体来说有严重的问题。
(2)对于跑酷游戏这种物理需求并不复杂的游戏来说,大部分的游戏逻辑都可以直接通过计算直接完成而并不需要依赖物理引擎实现,对于正常的模型来说,规则的形状对于逻辑实现是很友好的,但是启用曲面模型就会对这种计算带来很多困难,几乎只能通过使用物理引擎来实现,过多的物理计算对性能是会有较大的影响的。
包体不友好,性能不友好,异型模型还会对制作带来麻烦,对于只是为了实现显示效果来说,这些损耗得不偿失。
2. 使用材质系统实现
我们需要明白一点,要实现的曲面的效果,实际上影响的只有显示效果,与其他的任何系统是不相关的,它不应当影响到其他无关的模块,既然只想改变显示,那采用材质系统相较于采用曲面模型的方案有着诸多好处:
(1)不必使用物理引擎,简单的物理效果可以通过计算来实现,效率更优。
(2)模型可复用,想要实现不同的弯曲效果也很方便,只要使用带有曲面效果的不同参数的材质即可实现同一模型的不同效果。相较于方案一的多重模型来说,只需要几个材质即可解决问题。
(3)参数可配置,可以通过参数调节来得到不同的效果。
分析看来,相较于直接使用曲面模型的方案来说,使用材质系统实现的方案优势很明显,没有额外的开销,也没有太大的包体负担。
综上所述,使用材质系统实现更能满足我们的需求,因此采用材质系统来实现这个效果。
3、方案思路分析
从需求来看,我们的目的是实现一个与我们的观察点相关的模型变形,既然只是变形,并不涉及到颜色的变化和处理,那么需要处理的就只有顶点着色器部分,并不涉及片段着色器。对于不太清楚渲染管线各个阶段的读者,可以参考 [LearnOpenGL]的渲染管线介绍。
在 Shader 中,通过顶点着色器即可完成对模型顶点位置的操作。明确了是对顶点位置进行操作后,我们将摄像机所在的点定为原点。
由于我们的摄像机是固定在人物背后,且赛道始终保持向 Z 轴负方向延伸,所以可以将模型与摄像机的 Z 轴方向的距离看作函数的输入值,想要得到曲面的效果,模型的点的变化规律如下:
- 距离原点越远的点产生的偏置值越大(函数在区间内为增函数)
- 距离原点越远的点偏置的变化速度越快(函数的导数为一次函数)
由上述两条规律不难得出,二次函数的变化规律与我们想要实现的曲面效果的规律契合,所以我们的顶点着色器的运算为一个关于顶点位置 Z 值的二次函数运算。
我们刚刚得出的规律是建立在一个特定空间下的,即以摄像机为原点的空间,这个空间正是空间变换中的观察空间阶段,所以我们之后对顶点的操作正是在这个空间中进行才能够得到正确的结果。
4、材质系统简介
Cocos Creator 3D 提供了完备的材质系统,基于这套材质系统,我们能够很方便地在引擎中创建使用编辑材质,并且在场景预览窗口能够随时观察到材质更改所带来的变化。
在 Cocos Creator 3D 编辑器中与材质系统相关的资源有两种,分别为:
Effect 资源
此类型资源为符合 Cocos Effect 语法标准的渲染流程描述文件,由 YAML 格式的流程控制清单和基于 GLSL 300 ES 语法的 Shader 片段共同组成。
Material 资源
此资源可看做是 Effect 资源在场景中的资源实例,其本身除了 Effect 资源的引用外,还包括很多可配置参数以决定 Material 的状态。在实际使用中,我们的模型是需要使用 Material 资源的,这样就可以实现使用同一个 Effect 但参数不同以实现不同效果的需求了。
材质的使用也非常的方便,在 Cocos Creator 3D 编辑器的资源管理器中右键即可新建出 Effect 资源和 Material 资源。
在 Material 资源可选择需要使用的 Effect 还可对其他参数进行配置,完成配置并保存后,选中需要使用材质的模型,选中需要的 material 或者直接将 material 拖入框中即可完成材质的设置。
具体详尽的材质介绍和参数表请参看官方文档[材质系统]。
5、具体实现
思路已经很清晰了,那么现在开始着手实现 Shader。下面是具体的实现步骤:
1、启动 Cocos Creator 3D 编辑器(以下简称编辑器),为实验方便,使用最简单的场景即可,新建场景后,在场景编辑器中新建一个 Plane 模型,之后以此对象作为查看 Shader 效果的对象。
2、在编辑器的资源管理器中右键新建 Effect,将其命名为 curved 或者符合要求的名字。
3、这时新建的 Effect 文件为编辑器内置的 Effect 模板,其包含了最基础的 shader 结构,我们需要在这个基础上添加我们需要的功能,关于 Effect 的具体介绍请参看我们的[材质 Effect 文档],在里我只对我们需要的更改做出介绍。
4、先来看 CCEffect 部分:
CCEffect %{
techniques:
- name: opaque
passes:
- vert: general-vs:vert # builtin header
frag: unlit-fs:frag
properties: &props
mainTexture: { value: white }
mainColor: { value: [1, 1, 1, 1], editor: { type: color } }
- name: transparent
passes:
- vert: general-vs:vert # builtin header
frag: unlit-fs:frag
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
blendSrcAlpha: src_alpha
blendDstAlpha: one_minus_src_alpha
properties: *props
}%
- 在默认的 Effect 模板中,我们需要更改的是 vert 字段所使用的 Shader 片段,默认模板中提供的 general-vs:vert 是内置的顶点着色器,所以我们需要将其替换为我们即将实现的顶点着色器的名字(暂定为 unlit-vs).
- 接下来,需要对 properties 部分进行修改,property 列表将会将属性暴露在编辑器面板上方便我们的编辑和更改。
- 此时需要决定哪些数据是需要作为 uniform 传入 shader 中对效果做出影响的了,结合之前分析的需求:需要有一个决定模型点在各个分量轴上偏置值的偏置位置信息,我们使用一个 vec4 来存储这个偏置值(allOffset);需要有一个决定偏置变化的系数的值,使用一个 float 即可(dist);还可以添加模型的主贴图等(mainTexture)
- 经过以上更改之后,Effect 的 CCEffect 部分看起来是这个样子的:
CCEffect %{
techniques:
- name: opaque
passes:
- vert: unlit-vs:vert
frag: unlit-fs:frag
properties: &props
mainTexture: { value: grey }
allOffset: { value: [0, 0, 0, 0] }
dist: { value: 1 }
- name: transparent
passes:
- vert: unlit-vs:vert
frag: unlit-fs:frag
depthStencilState:
depthTest: true
depthWrite: false
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
blendDstAlpha: one_minus_src_alpha
properties: *props
}%
5、由于默认的 Effect 模板中使用了内置的顶点着色器,所以这里需要实现自己的顶点着色器,可以参考内置的 builtin-unlit 的实现来编写此段 shader:
- 添加需要的 uniform :
uniform Constants {
vec4 allOffset;
float dist;
};
- 编写入口函数:vert
- 按照引擎要求对接骨骼动画和数据解压,直接在开头调用 CCVertInput 工具函数。
- 模型资源在场景中可能出现很多重复的,这样就需要对模型进行动态合批,对接引擎的动态合批流程,在包含 cc-local-batch 头文件后,通过 CCGetWorldMatrix 函数获取世界矩阵。
vec4 position;
CCVertInput(position);
highp mat4 matWorld;
CCGetWorldMatrix(matWorld);
- 在分析时提到,需要在观察空间下对顶点进行处理,所以需要将坐标转换到观察空间下。曲面效果是和 Z 坐标直接相关的,所以系数也是直接影响 Z 坐标的。
- dist 系数为影响变化的系数,所以在和 vpos.z 的运算时,可以使用乘法也可以使用除法,但这个改变会直接影响 dist 的取值,所以在决定是使用除法还是乘法后,需要对值进行对应修改,且注意使用除法时 dist 的值不可为 0。
- 对于各轴分量的修改,需要 allOffset 参与运算然后造成影响,此处的 zOff 的平方运算即为分析中的二次函数符合变化规律的实现。
- 在处理完成之后,按照正常的变换逻辑继续将观察空间通过投影矩阵变换为裁剪空间下的坐标之后继续传递给片段着色器即可。
highp vec4 vpos = cc_matView * matWorld * position;
highp float zOff = vpos.z / dist;
vpos += allOffset * zOff * zOff;
highp vec4 pos = cc_matProj * vpos;
v_uv = a_texCoord;
return pos;
6、对于片段着色器,我们并未做特殊的操作,所以直接使用默认提供的就可以。
7、最终的 effect 如下:
CCEffect %{
techniques:
- name: opaque
passes:
- vert: unlit-vs:vert
frag: unlit-fs:frag
properties: &props
mainTexture: { value: grey }
allOffset: { value: [0, 0, 0, 0] }
dist: { value: 1 }
- name: transparent
passes:
- vert: unlit-vs:vert
frag: unlit-fs:frag
depthStencilState:
depthTest: true
depthWrite: false
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
blendDstAlpha: one_minus_src_alpha
properties: *props
}%
CCProgram unlit-vs %{
precision highp float;
#include <cc-global>
#include <cc-local-batch>
#include <input>
in vec2 a_texCoord;
out vec2 v_uv;
uniform Constants {
vec4 allOffset;
float dist;
};
highp vec4 vert () {
vec4 position;
CCVertInput(position);
highp mat4 matWorld;
CCGetWorldMatrix(matWorld);
highp vec4 vpos = cc_matView * matWorld * position;
highp float zOff = vpos.z / dist;
vpos += allOffset * zOff * zOff;
highp vec4 pos = cc_matProj * vpos;
v_uv = a_texCoord;
#if FLIP_UV
v_uv.y = 1.0 - v_uv.y;
#endif
return pos;
}
}%
CCProgram unlit-fs %{
precision highp float;
#include <output>
in vec2 v_uv;
uniform sampler2D mainTexture;
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
o *= texture(mainTexture, v_uv);
return CCFragOutput(o);
}
}%
8、在完成了 effect 之后,我们可以在编辑器中新建一个材质,在材质的 Effect 中选择刚刚完成的 curved 之后传入想要的贴图,填入 dist 和 AllOffset 参数,保存之后将这个材质赋予刚刚提到的 plane 对象,调整参数可以看到我们的片面就能出现偏置效果了,移动摄像机可看到偏置效果是与顶点距离摄像机的距离相关的:
9、请注意,Shader 的参数与模型的尺寸是相关的,上图所示效果是在 dist 为 100,AllOffset 的 Y 值为 10 时的效果,各位开发者可尝试不同的组合来达到想要的效果。
10、似乎上图的效果还不是太直观,所以我用一些建筑模型和一些路面模型简单搭建了一段赛道来模拟游戏可能会出现的场景。建议最好是能够显示出纵深效果的连续模型段,更能显示出效果,当然这个效果并不只限于 Y 轴方向,还可以同时满足 X 轴方向的偏置需求,下图所示即为 Dist 为 100,X 为 -20,Y 为 -10 时的效果图:
6、总结
相信各位对 [ShaderToy] 并不陌生,之后我们将提供从 ShaderToy 中迁移 Shader 的方法,海量 Shader 等你来学习!目前在我们的[官方案例仓库]的 demo02 中,已经实现了一个迁移好的场景案例(ShaderToy),感兴趣的小伙伴们赶快去试试吧!
有很多小伙伴在社区分享了炫酷的材质效果,在这里感谢 @yans 做出的分享,欢迎有想法的开发者多做分享,共同促进社区成长。
希望通过这个简单的案例为各位提供一个了解材质系统的入口。材质系统功能十分丰富,能够实现的效果也是多种多样的,各位快快打开脑洞,用 Cocos Creator 3D 来实现各种炫酷的效果吧!
欢迎小伙伴们继续通过论坛、GitHub、Cocos 企业服务等渠道向我们提交 Cocos Creator 3D 使用反馈!
以上,谢谢!
Cocos 技术派|3D 小游戏《快上车》技术分享
7月初,Cocos Creator 3D 发布了第一个公测版本,超过千名开发者报名参与公测。为了高效收集测试反馈,集中精力进行产品的迭代和优化,快速推动产品达到功能全面并相对稳定的状态,第一个版本只发放了少量测试资格。经过近2个月的公测,Cocos Creator 3D 取得了飞速的进展,并在8月31日开启了全面公测。在此,向所有参与公测的开发者以及合作立项的重要合作伙伴表示衷心的感谢。Cocos Creator 3D 正式版将在不久与大家见面,敬请期待!
截止目前,已经有不少开发者使用 Cocos Creator 3D 制作出了自己的 3D 游戏,《快上车3D》便是其中之一。
游戏介绍
《快上车3D》是采用 Cocos Creator 3D 测试版本开发的一款超级刺激的 3D 休闲小游戏。玩家在游戏中扮演一位快车师傅,控制小车在各种道路上进行移动,接送乘客安全到达目的地,即可过关并获得金币奖励。游戏操作很简单,按住屏幕即可控制小车移动,长按为加速,松开屏幕可以减速。在控制小车行动的过程中,需要时刻注意来往的车辆,及时增减车速,以免发生碰撞车祸,导致任务失败。游戏采用闯关制度,每一个关卡都有不同的订单任务,随着关卡的深入,游戏难度会逐步增加。此外,游戏设置了汽车皮肤系统,使用金币可解锁更多车型和皮肤。
技术分享
《快上车3D》开发团队接受了 Cocos 技术派的专访,并大方分享了这款 3D 小游戏的制作故事和 Cocos Creator 3D 开发经验及使用技巧,一起来了解看看吧!
受访者:《快上车3D》团队
编辑整理:C姐
《快上车3D》使用的测试版本是?
线上版本使用的是 Cocos Creator 3D 1.0.0-beta5 版本,当时还没有地图编辑器,因此我们的关卡地图都是自己来实现的,也没有图集合并和压缩功能,导致我们的包体比较大,据说 Cocos Creator 3D 新版本已经有这些功能,我们也正在接入新版本的引擎。
3D场景地图和关卡编辑方式?
关卡编辑,我们修改了多次方案。最初的想法是一个场景对应一个关卡,然后关卡编辑人员直接通过 Cocos Creator 3D 进行关卡编辑,直接运行便可看到效果。考虑到游戏中有太多的公用元素,如灯光,UI,以及车辆管理等公用节点,我们将地图修改为一个个 Prefab,然后使用通用场景,启动时直接实例化该地图预制体。后续发现随着关卡复杂度的提升,直接使用预制体,包体将大幅度增大,因为 Prefab 会记录大量的节点关系及地图所不需要的信息。
于是,我们决定增加一个地图导出功能,把原有的 Prefab 下的信息进行解析,解析出有几栋房子,几棵树,几条路,然后分别用的是哪些模型,以及对应的坐标、旋转、缩放等信息进行记录,并将这些信息进行压缩,最后持久化自定义格式的文件中,然后再加载地图时根据动态去创建地图元素。
这样的实现方式,让关卡编辑人员可视化编辑的同时,又不用额外开发关卡编辑器,也解决了包大小的问题。
小车移动跟随的阴影是贴图吗?
车辆移动过程中的阴影是实时计算的,可在 Scene 节点 planarShadows 组件上配置开启,但目前阴影只能投到某个平面上,平面的位置通过 Normal 进行配置:
开启阴影支持外,还需要对哪些物体拥有阴影进行设置,即在模型的 ModelComponent 组件下将 ShadowCastingMode 设为 ON,便会有阴影效果。需要注意的是,目前引擎在一个场景里只支持一个平行光,多个平行光将会没有效果,如果要补光可能要采用其它方式。
拖尾效果如何制作?
首先,创建新的粒子系统,调整对应的粒子参数,如图:
选择 TrailMaterial 一栏,拖入对应的材质:
双击进入材质编辑,调整对应的材质模式和贴图效果,拖入对应的贴图文件,参数如下:
最后记得保存效果,拖动粒子效果,浏览效果。
汽车尾气的特效怎么制作的?
首先,新建默认的一个粒子系统,调整对应参数,比如粒子的数量、初始速度、粒子大小尺寸、结束时间。其中最重要的一个选项是图中红框的内容,将模式改为 word,这个模式使整个粒子的拖尾效果会变得符合现实中的运动规律:
发射器选择的是 BOX,具体参数如下图:
接着,粒子材质选择放在了 ParticleMaterial 这一栏:
双击材质,进入材质编辑器,选择对应的材质和贴图模式,如图:
贴图资源根据自己想要的效果去找对应的贴图文件,到这一步尾气效果基本就出来了,记得保存哦。
汽车的3D展示怎么制作?
相对于 2D 游戏来说,3D 能够展现出汽车更多的细节,让玩家更想尽快获得这些车,在一定程度上,提升玩家的游戏动力。
为了能让模型在 UI 上展示,需要给模型的节点上(即挂载着 cc.ModelComponent 组件的节点)添加 cc.UIModelComponent:
同时要更改车的材质,将材质的 Effect 修改为 builtin-unlit,这样车辆展示在 UI 上面就不需要受到灯光的影响。
为了实现车辆旋转,可以先将车辆挂到某个空节点下面,空节点先调整好一个展示角度:
之后,只需修改自身的欧拉角 y 值,便可实现车辆围绕某个视角进行旋转。
如何更好地表现出“撞车”效果呢?
为了让撞车时有比较好的表现效果,我们使用了引擎提供的物理引擎:cannon.js,能够拥有比较好的翻转及撞击感,但出于性能上的考量以及游戏自身情况,我们对刚体进行了分组,分成了玩家控制车辆、AI控制车辆、地面等三组,默认情况下,玩家控制的车辆只跟 AI 控制车辆进行碰撞检测,并且不开启重力影响,只有当玩家车辆与 AI 车辆碰撞触发时(即那一瞬间),玩家控制的车辆开启重力影响,并且与所有元素开启碰撞检测,然后给车辆一股冲量,这样既能实现物理特性,又能优化性能。
UI 和编辑器体验如何?
Cocos Creator 3D 延续了 Cocos Creator 2D 的 UI 设计,学习成本比较低,2D 所拥有的各类布局神器,widget,layout 都有继承过来,开发效率高,适配好,因此我们之前在 2D 的 UI 框架设计可以直接沿用过来。
从工作流角度简述游戏的开发过程?
以关卡制作的开发过程为例,关卡制作是《快上车 3D》项目开发过程中比较重要的部分,所以这个流程我们做了比较细致的规划:
首先,由策划规划好大致会用到的地图元素,如有多少种路面,多少种树,多少种石头等。
然后,开始并行开发:
- 美术开始开工,定好整体风格后,开始制作地图元素
- 程序同时开工,开发对关卡编辑的支持
- 策划开始规划每一关卡的路线图及 AI 车辆等
最后开始组装,由关卡编辑人员根据之前规划的路线图,使用美术提供的素材,在Cocos Creator 3D 中完成关卡的编辑,最后在游戏中运行起来。
后续的迭代和立项计划是?
我们团队一直立志于开发休闲小游戏,使用 Cocos Creator 开发的游戏已经有十多款,包括卡牌游戏《五子大作战》、休闲游戏《梦幻甜品》《虫虫向前冲》《分手餐厅》等。之前开发的游戏都是使用 2D 或 2.5D,这次终于可以使用 Cocos Creator 3D 引擎提升游戏整体的效果。后续会进一步优化游戏,增加更多的场景、关卡、汽车,也会增加更多 3D 表现。有了这次的开发经验,我们也会扩充开发的游戏类型,参与开发更多 3D 游戏。
感谢团队的无私分享!
技术派,是 Cocos 全新推出的专栏,我们将不定期邀请优秀的游戏制作者,为广大开发者分享来自真实项目的实用的开发技术和实战经验。如果您在使用 Cocos Creator 的过程中,获得了独到的开发心得、见解或是方法,并且乐于分享出来,帮助更多开发者解决技术问题,加速游戏开发效率,期待您与我们联系!
Cocos 技术派|实时竞技小游戏技术实现分享
前言
李清是来自华夏乐游 BigRoad 工作室的客户端主程,今日他将带来其团队制作的实时竞技小游戏《保卫豆豆-欢乐枪战》的技术实现方案。
游戏简介
《保卫豆豆-欢乐枪战》是一款北京华夏乐游科技股份基于 Cocos 引擎研发的休闲射击乱斗小游戏,融合了射击、MOBA、吃鸡等热门玩法。
游戏特点:
- 萌宠射击,实时竞技
- 四人乱斗,双人组队
- 多个英雄,身怀绝技
本文主要从三个方面来进行分享,分别是:
- ECS 架构
- 网络同步机制
- 技术难点及解决方案
一、ECS架构
1、ECS 架构目的:
降低不断增长的代码库的复杂度。
2、游戏原型需求:
- 子弹:移动、碰撞
- 英雄:移动、碰撞、发射子弹
- 炮台:发射子弹
3、传统架构的弊端
要实现游戏原型,按照我们之前的做法,是用一个类来实现一种游戏实体的所有功能,这个类既有状态,又有行为。代码复用使用继承来解决。如果用这种做法,那么类大概长这个样子:
大家可以看到,父类会有很多共享的属性和方法,子类继承父类去做具体的事情。但是这种做法有很多弊端,比如说,随着项目规模的增长,代码库复杂度也不断增长,父类会越来越复杂,子类的功能越来越不明确,与多个类相关的代码你不能太确切知道应该放在哪里,拓展功能的时候极其不灵活,如果后期需要增加新功能的话,我们需要对整个继承树进行功能重构才能使其比较合理
在经历过几个项目之后,我们回头反思,发现之前的做法,违反了很多面向对象设计原则。比如说:
- 单一责任原则(Single responsibility principle) 每个类都应该只有单一的功能,并且该功能应该由这个类完全封装起来。
- 组合重用原则(Composite Reuse Principle) 默认情况下应当使用组合,只有在必须时才使用继承。
在总结了从前的项目经验,并参考了大量技术文章后,我们找到了一种架构,把大量的模块进行拆分解耦,然后再集成起来,这就是我们接下来要介绍的 ECS 架构。
4、ECS架构
ECS 分别是:
- Entity(实体)
- Component(组件)
- System(系统)
看到实体和组件大家可能觉得比较熟悉,但是这里要注意,这跟我们引擎中的实体组件框架可不是一回事,接下来我为大家简单介绍一下 ECS 架构的元素。
(1)ECS 架构元素:
Component:组件,存储游戏状态
Entity:实体,组件的集合
System:系统,实现游戏行为
World:系统和实体的集合,就是我们的游戏世界,他们的关系大概是这个样子的:
我们可以看到,游戏世界中有很多 System,每个 System 负责实现一种游戏行为,同时有很多组件,每种组件中会有一些游戏状态,实体上可以挂载一个或多个组件,实体和 System 聚合成了我们的游戏世界。
(2)ECS 架构设计:
这个架构有个基础原则:
- 组件只有状态,没有行为
- 系统只有行为,没有状态
刚看到这个原则的时候,大家可能会有一些疑问,什么是游戏行为呢?游戏行为,其实就是根据一定的规则去修改游戏状态。比如说移动,就是根据实体的方向和移动速度去改变这个实体的位置。如果系统没有游戏状态,它如何去实现游戏行为呢?
这就是 ECS 架构最重要的职责了:为系统筛选出它关心的实体子集,只展示给它关心的游戏状态。具体我们是怎么做的呢?
首先把可能单独使用的游戏状态归纳为一个个组件:
比如最常见的位置、方向我们可以归纳为变换组件;移动速度这个组件可能会在移动系统中单独使用,所以我们把它归纳到移动组件中;碰撞组件则有碰撞盒的大小;攻击组件有攻击方向,这样我们就把各种属性给拆开了。
接着,我们在系统实现的时候,要向框架声明我关心哪些“组件元组”(Component Tuple)
什么是“组件元组”?还是举刚刚移动的例子。移动系统的移动行为,应该是关心实体的位置、方向以及移动速度,就是我们归纳的变换组件和移动组件,那么只要一个实体同时挂载这 2 个组件,它就可以被移动系统遍历到,系统就会进行操作从而实现移动行为。
最关键的一点,“组件元组”其实就是用来实现框架筛选实体的功能,实体只需要根据自身功能需求挂载相应的组件元组就可以了。比如说子弹它有移动和碰撞的功能,那么就挂载上变换、移动和碰撞这 3 个组件。
最终实现的效果就是移动系统遍历了英雄和子弹实体,在他们身上实现了移动的行为。攻击系统遍历了英雄和炮台实体,然后他们就可以发射子弹。
(3)ECS 架构实例:
接下来,我们看一下比较复杂的碰撞逻辑,这里我们可以对碰撞进行拆解:
首先是碰撞的触发系统。当碰撞发生时将产生一个碰撞事件,然后这个系统只干这件事。剩下的碰撞处理呢,对于子弹来说,会有一个碰撞后销毁系统,它会在碰撞之后把子弹销毁。对于英雄来说,他有一个碰撞后的损血系统,通过这种方式,我们就可以把碰撞进行拆分,再通过刚刚的方式集成在一起。
(4)ECS 架构作用:
这种架构可以让每个开发人员负责不同模块的开发,有效地提高多人开发效率。最重要的就是模块的复用,可以便于功能拓展。如果你想改变一个实体的功能,只需要添加或者移除实体的组件就可以了。
比如说:一个英雄死亡之后,他应该失去移动功能,那么在英雄死亡之后,我们只需要把移动组件给移除就可以了,等他复活的时候再给他加回来。可以看到,这种方式非常方便。既然这么方便了,我们就可以做出一个编辑器,把这种能力开放给策划人员。
实际上,暴雪就专门为 Overwatch 开发了一套 Statescript 的脚本语言,它用起来就是一个可视化的编辑器,策划人员可以在这个编辑器中编辑每个英雄在各种游戏状态中拥有什么游戏能力,程序只要实现具体的功能模块,然后开放给策划人员使用,非常地灵活。
以下是我们在实践过程中参考的技术文章:
[参考文档]
- 《守望先锋》架构设计与网络同步
- http://gad.qq.com/article/detail/28682
- 《守望先锋》回放技术-阵亡镜头、全场最佳和亮眼表现
- http://gad.qq.com/article/detail/29595
- 《守望先锋》中的网络脚本化的武器和技能系统
- http://gad.qq.com/article/detail/28219
- 浅谈《守望先锋》中的 ECS 构架
- https://blog.codingnow.com/2017/06/overwatch_ecs.html
二、网络同步机制
1、常见同步机制:
常见的网络同步机制可以分为以下三种:
- 确定性帧同步(Deterministic lockstep)
- 快照插值(Snapshot interpolation)
- 状态同步(State synchronization)
(1)确定性帧同步
服务端:收集并转发玩家输入数据,不运算游戏逻辑
客户端:在玩家输入数据以后各自运算游戏逻辑
优点:只有玩家输入会被传输,数据流量非常小;代码都是写在客户端上的,所以代码复杂度较低。
缺点:对网络延迟要求非常高;每个机器浮点数运算不一致,需要将浮点数运算转换成整数运算;断线重连时间较长;因为游戏逻辑写在客户端,所以不是很安全。
(2)快照插值
服务端:运算游戏逻辑,将快照发送给客户端。
快照,就是我这一帧所有游戏实体的游戏状态。
客户端:不运算游戏逻辑,收到快照以后进行差值平滑播放。
实际上,客户端只是一个播放器。
优点:客户端运算量小;断线重连容易实现;游戏逻辑全在服务端,所以非常安全。
缺点:带宽占用非常大。
所以这种方式之前多用于像 CS 这种局域网对战。
(3)状态同步
服务端:运算游戏逻辑, 将玩家输入和部分状态发送给客户端
客户端:在玩家输入时,不等服务器就立马运算游戏逻辑,就有点像单机游戏了,但这种运算结果未经过服务器,不一定是正确的,所以它实际上是一个游戏逻辑的预测。在收到服务器数据后,会对预测结果进行校验,如果错误,就需要平滑地将其纠正到正确的状态。
这里说一下校验的过程,其实就是先回滚再前滚。
服务端下发的数据是之前一个时间点的数据,我们本地赋值以后相当于回滚到之前的时间,然后我们会一帧帧的运算到当前的时间,这就叫前滚,最后将计算结果与预测结果进行比较,可以看到校验的计算量是非常大的。
优点:客户端可以进行游戏逻辑预测;网络游戏体验好;以服务器数据为准,比较安全。
缺点:代码复杂度高;客户端运算量大;因为有客户端预测,所以客户端之间是不完全同步的。
2、小游戏平台特点
一开始我们的项目采用的是状态同步的方式,但由于我们的项目是针对小游戏平台的,小游戏平台有以下几个特点:
- 运算性能较差,客户端计算量不能太大
- Javascript 代码很容易被破解,玩家想要作弊的话很容易
- 网络连接只能使用 TCP,所以带宽占用不能太高
3、欢乐枪战的实现方案
(1)带宽优化
基于小游戏平台的特点,我们项目从状态同步开始做简化,一直简化到以下这种实现方案:
- 服务端:运算游戏逻辑,将变化的状态发送给客户端
- 客户端:不运算游戏逻辑,收到数据以后进行差值平滑播放
- 优化了带宽占用的快照插值
这个大家可能看着就有点眼熟了,其实就是优化了带宽占用的快照插值。这种方案最关键的一点是,你要把带宽优化下来。而带宽优化最关键的,是只有在必要的情况比如游戏开始和断线重连时才发送全量状态,平时玩的过程中,只发送变化的状态。
另外一方面是数据压缩,比如方向,刚开始我们用的是方向向量,但其实用弧度制乘以一千就可以了,这样就把两个 Float 优化成一个 Short。
经过带宽优化成果:
上行:2~15pkg/s,流量占用:0.1 KB/s
下行:0~15pkg/s,流量占用:2.5 KB/s
这个流量占用对于目前的手机网络来说,是完全可以接受的。
(2)网络抖动优化
介绍完了带宽优化,接下来我们来聊聊网络抖动。
网络抖动指的是,网络的传输是不稳定的,服务端每个逻辑帧会发送一个包,它发送的频率是稳定的,但是对于客户端,可能在一个逻辑帧内收不到包,也可能收到多个包。
这在游戏中的体现就是,玩家在移动过程中,这一帧没有收到包,就停下来了,下一帧收到 2 个包,就跳过去了,体现出走走停停的状态。
对于这种网络抖动,最常见的优化方法是航位推测法。
航位推测法(Dead Reckoning):
- 客户端和服务端约定至少每500ms同步一次
- 客户端若没有按时收到移动状态,则用最后一次收到的移动状态继续预测一段时间
- 服务端若没有按时收到玩家输入,则用最后一次收到的玩家输入继续运算一段时间
用这种方案优化之后,走走停停的现象就基本没有了。
抖动缓存法
另一种优化方案是抖动缓存,这是指收到包后不立马处理,而是放入抖动缓存中,延迟一段时间后再取出。
这种优化方案关键点在于缓存的大小。如果缓存太小,对于抖动还是比较敏感,抗抖动效果比较弱,缓存太大,玩家的延迟又特别高,所以你需要根据算法动态调整缓存的大小以适应网络环境。
(3)全区全服
- 所有玩家都在同一个大区里
- 前台服务器处理登录等战斗外逻辑
- 游戏服务器处理战斗逻辑
(4)分地域部署
我们的项目是实时竞技游戏,对于延迟比较敏感,因此我们的游戏服务器采用了分地域部署。服务器入口使用的是阿里云的“云解析 DNS”服务,按照地域自动分配游戏服务器(华北、华东、华南、西南),玩家在进行快速匹配战斗时,会根据地域分配服务器,同一地域玩家进入该地域所属服务器。
以下是我们在网络优化方面参考的文章,都是干货,如果感兴趣可以去了解一下。
[参考资料]
- Networked Physics —— Glenn Fiedler
- https://gafferongames.com/categories/networked-physics/
- 《王者荣耀》技术总监复盘回炉历程
- http://gad.qq.com/article/detail/30902
- 150ms流畅体验 NBA2KOnline如何网络同步优化
- http://gad.qq.com/article/detail/10118
三、技术难点以及解决方案
1、Javascript 语言使用
Cocos Creator上手很容易,不过 Javascript 语言非常灵活,需要统一代码规范。
所以在项目初期,我们就制定了程序和资源规范,包括代码格式、资源制作标准等,并且会定期去整理代码和资源。
2、客户端性能优化
性能优化这一块是我们比较头疼的问题。腾讯方面对于技术标准要求很高,对加载时间、帧率、内存等都有卡死的严格限制,不通过技术评审则无法上线。
我们项目一开始用的是 Cocos Creator 1.9 版本,用尽毕生所学优化了好几轮,还是只能跑到 40 多帧。在项目快要上线之际,Cocos 推出了 2.0 Beta4 版本,我们就在上线前 2 周去升级了大版本,现在想想还是挺刺激的。升级之后,体验很流畅,2.0 对于性能的提升是非常明显的。
- iPhoneX 从 50 帧提升到 60 帧(iOS 机型)
- 一加 5T 从 40 帧提升到 55 帧(安卓机型)
- OPPO Y55 与 iPhone6 稳定在 25-30 帧(老机型)
3、自定义裁剪功能
出于对效率的综合考虑,Cocos Creator 2.0 移除了自动裁剪功能(cc.macro.ENABLE_CULLING),所以屏幕外的节点仍然会进行渲染,战斗中drawcall 较高。
于是我们就自己实现了一套裁剪功能。
- 当镜头或节点移动时判断节点是否需要进行渲染。
- 修改了一部分 Cocos 源码,在渲染底层添加了一个自定义标志位用来跳过不需要渲染的节点,从而快速实现我们想要的功能。
4、Spine 换装实现
整体换装:
在《保卫豆豆-欢乐枪战》中,英雄是可以进行整体换肤的,但是 Spine 官方并不支持一套动画数据对应多套图片。所以我们便开始研究源码,研究后发现,使用 SkeletonTexture.setRealTexture() 设置为另一张图片资源就可以实现整体整体换装功能了。
局部换装:
《保卫豆豆-欢乐枪战》战斗中,英雄可以自由拾取武器,但是英雄与武器是两套动画文件,具有交叉层叠关系,渲染时需要交叉渲染。这个功能 Spine 也是不支持的。在研究过源码之后,我们发现使用 Slot.setAttachment() 设置为另一个动画文件中的附件,就可以实现局部换装功能了。
结语:
Cocos 是开源的,所以大家在使用过程中,可以去多看看源码,都可以找到自己的解决方案。以上就是我今天带来的分享,感谢各位!
《保卫豆豆-欢乐枪战》,欢迎各位扫码体验
非常感谢李清带来的技术实现分享,《保卫豆豆-欢乐枪战》自上线微信小游戏精品平台后受到了广大玩家的喜爱。此前,华夏乐游制作团队也曾接受 Cocos 的专访,分享项目的 2.0 升级之路。
技术派,是 Cocos 全新推出的专栏,我们将不定期邀请优秀的游戏制作者,为广大开发者分享来自真实项目的实用的开发技术和实战经验。如果您在使用 Cocos Creator 的过程中,获得了独到的开发心得、见解或是方法,并且乐于分享出来,帮助更多开发者解决技术问题,加速游戏开发效率,期待您与我们联系!
畅销 SLG《乱世王者》深度优化方案
前言
《乱世王者》是由腾讯天美工作室出品的一款战争策略手游,2017 年 8 月开启测试,数天之内便取得了 App Store 中国区畅销榜前三的成绩,充分体现了市场、玩家对于这款游戏的认可。
游戏介绍
在尊重和传承经典 SLG 玩法的前提下,游戏做出了不少突破:游戏构建了一个大世界,为玩家呈现更加真实、热点的游戏环境和生态;游戏融入了极具乐趣的养成模式,使得游戏策略性更强;游戏内加入了如 AR 寻宝玩法、 自定义头像等酷炫新玩法。
作者介绍
来自腾讯的游戏高级工程师肖程祺受邀作客 Cocos 技术派,他将分享《乱世王者》的深度优化方案,包括《乱世王者》项目中地形处理和优化思路,深入讲解游戏针对纹理内存优化的解决方案,并分享项目对 Cocos 引擎渲染层所做的一些优化。
《乱世王者》深度优化方案分享
腾讯 肖程祺
深度优化方案
《乱世王者》是基于 Cocos2d-x 开发的一款战争策略类游戏,需要在游戏玩法创新的同时,在美术品质和性能方面也做出突破。
一、SLG 地图的微创新和优化
美术上想要增加细节表现力,让地形的细节更加丰富,同时需要增加 3D 透视的感觉。
实现成果:
1、丰富的细节
地形细节丰富的代价:
- 资源量膨胀,地形纹理是原来的5倍。
- 地形类型新增:沙漠,雪地,每个地形的变化增加。
tile 数量从 100 块左右提升到 500 多块。
渲染压力提升,渲染层数从 2 层变 5 层,拖动地图的时候重新组织数据的开销增加。
面临的问题有:
- 内存增长:纹理扩大了 5 倍
- 地图层数增加后,加载和拖动地图更新的效率成倍降低
- 贴图数量增加后,需要自己去实现 batch
我们的优化方案:
(1)提升贴图的利用率,减少地图纹理的内存
重新排列 tile,贴图利用率从 50%提升到 85%
效果:
5 张贴图最终变成 3 张
(2)建立更高效的数据组织,提升加载效率
地图数据离线处理后,以 protobuff 的数据格式存储,并使用 zstd 无损压缩进行处理,加载速度和大小都得到保证。
(3)合并地图批次,减少不必要的顶点数据
每一层地图的都根据 TextureID 排序后绘制,由于顶点和 UV 数据已经存储在 VBO 里面,只需要根据裁剪结果修改 indexbuffer 即可。5 层一共 3 张贴图,意味着 drawcall 数量不会超过 15 个。
2、3D 透视关系效果
(1)处理3D透视关系
- 场景用 3D 相机绘制
- 地图上的物体“站”起来
所有物件绕x轴旋转至垂直于摄像机的fov中线的角度
(2)解决大物件的视角偏差
边缘位移:
对于皇城和虎牢关这种比较“宽大”建筑单位,在拖动大地图的过程中会出现边缘位移的现象,这个效果也是非常影响美术表现的。
我想到的解决方案是,将大的建筑单位拆分为多个部件,并对每个部件分别作 billboard 处理。
以皇城为例,考虑到部件之间的关联性,使用了如下的拆分方案,可以看到边缘位移问题得到了很好的解决。
(3)解决 3D 物体的透视带来的偏差
为了表现更加丰富的怪物细节以及更流畅的动画,美术希望直接在现有的场景中加入 3D 模型。由于我们改用了 3D 摄像机,同时大地图场景单位都是 2D 资源,加入 3D 模型后两者的透视效果不一致。
在透视相机下,左右移动相机透视问题严重
容易想到的解决方案:先将 3D 模型按照指定角度渲染到 RT 上,再将 RT 上的纹理和其他 2D 元素一样渲染到场景中。但是 Cocos2d-x 对多照相机和多渲染路径的支持不够友好,这种方案需要对引擎作比较大的改造,而且会增加更多的显存占用。
我们采用的是第二个方案:在每帧绘制 3D 模型前,计算出其缩放和位移,然后再使用正投影照相机参数绘制 3D 模型到屏幕。整个过程只需要一次绘制,没有额外的纹理占用,而且对引擎的改造也是最小的。
渲染 3D 物体前自己计算正交投影矩阵
上述这些效果提高了《乱世王者》大地图的美术品质,与同类 SLG 游戏对比,我们游戏获得了更多的关注。
二、纹理内存的深度优化
《乱世王者》内存中资源的大致占比为:
用 4 步搞定内存大户:纹理
- SmashTexture
- FontTexture
- 压缩纹理的优化
- Texture Pool
1、Smash Texture
Smash Texture 解决的问题:
- UI 制作过程中需要使用 atlas 来尽可能合并渲染批次
- 因为使用 atlas 需要一套规范避免一个界面加载过多的 atlas(维护成本很高)
- 一旦出现一个元素多次重复出现且用到多张不同的纹理,需要额外的机制去避免 drawcall 爆炸般增加
- png 使用的 deflate 压缩率不算很高,安装包体积压力山大。有些人选择使用 jpg 等有损压缩来牺牲美术品质
(1)Smash Texture Pipeline
- 通过 tinypng 减色,增加压缩率(如果减色效果不好,则不做减色处理)
- 打包成合图后,供在编辑器中使用
- 转换成 smash
drawcall的降低
未作任何其他改造的情况下,批量使用 smash texture 后,当前界面的 drawcall 从 64 降低到了35。
(2)减少 IO:Block Cache
- 加入 Cache 机制, 达到上限时使用 LRU 算法清理,每次清理固定数量
- 特定时机,清理 Cache,分帧清理,不卡顿
- 回收操作只需要回收分配信息,不需要对贴图显存操作,速度快
现网版本用户行为上报的 cache 命中率(75%左右)
(3)提升Smash Texture的使用率
原本 smash texture 里面的每一行是 32 像素统一高度,利用率比较低(比如说 36 像素高的图片,最后还会留下 6 像素左右的空间。填充到 32 像素高的 smash texture 行里会存在浪费),因此我们按照下图,重新划分了高度行,容纳更多的 UI 图元。
经过优化,smash texture 的使用率可以从 80%提升到 90%。
(4)总结 Smash Texture 的优缺点
优点:
- 内存峰值可控:所有使用 smash texture 的贴图共享一张纹理
- 增加了 UI 利用 dynamic batching 自动合批的概率
- 最大程度还原 UI 的品质
- 相对 PNG,他有着更小的文件尺寸(zstd 压缩)
缺点:
- 对于大型背景图不太适合,容易填满。背景图需要额外拆分。
- smash texture 目前只支持离线预处理,动态下载的图片还不支持。
2.文字系统的内存优化
- 用一张 2048x2048 大的 A8 格式纹理解决所有字体需求,当贴图不够用的时候尝试清理整张贴图。
- 不同的字号,字形,格式生成唯一的 key 在这张统一贴图中进行索引。
提升贴图利用率:改良装箱算法
基于 maxrect 做优化,将纹理利用率提升到 95%+。
提升贴图利用率:减少字号数量
统计了文字的字号使用频率,根据字号分布,我们取了 5 个字号,其他字号向下取到最接近的预定义字号上,缩放后渲染,大于最大字号的时候则用最大字号放大后渲染。
3.压缩纹理
压缩纹理的一些痛点:
压缩纹理优化:
对于 1/2 方图的 atlas,分离 pvrtc 的 rgb 和 a 通道,合并成方图后,再压缩。
对于不符合 2 的 n 次幂的不规则的单图(背景图为主),通过计算出最小的 2 的 n 次幂包围尺寸,切分成多块来优化文件大小,解决 pvrtc 必须方图的不友好设定。
4.Texture Pool
- 把所有贴图统一管理生命周期
- 根据设备内存大小,分别设置贴图 GC 的阈值
- 当贴图使用量超过 GC 阈值的时候,根据引用计数释放没用到的纹理
- 添加运行时查看工具,运行时也可以查看不正确的贴图格式设定
内存管理的小结:
- 所有模块必须有上限,避免随着迭代内容不断增加而持续上升
- 避免浪费
三、解决 UI batch 的痛点
即便 Smash Texture,即便 Font 在一张纹理上,我们还是面临着一些 darwcall 明显可以优化的地方。
期望的渲染顺序:
通过 TriangleCommand 底层的 dynamic batching,自动合并批次。
Cocos2d-x的默认渲染顺序:
充分利用 TriangleCommand:
- 将所有 Cocos2d-x 默认的控件以及自定义控件都尽可能改造成 TriangleCommand 实现,使得支持 Dynamic Batching。
- CCProgressTimer
- LayerColor
利用 GlobalZ 来解决合批的问题:
- 对于 2D 元素,GlobalZ 的排序可以很好的让相同材质的物件进行 batch
- 所以我们可以离线设置好 GlobalZ(存储在csb里面)
- 根据遮挡关系和纹理自动计算
- 人工设置
- 自动递增
新的问题:
当 UI 有多层弹窗的时候,上下两层的控件由于 GlobalZ 都从 1 开始,所以会出问题。
改动最小的解决方案:
改动后的情况:
- 解决了多层 UI 层级问题
- 解决了同层 UI 利用 GlobalZ 能尽可能 Batch 到一起
结语
程祺:Cocos2d-x 使用上手难度比较低,实际使用项目也很多,在开发的过程中也存在一些普遍的性能和优化痛点,希望这次为大家带来的经验分享,能帮大家在优化的道路上走得更加顺利。我们的腾讯天美上海 T1 工作室正在招聘 Cocos2d-x开发,对游戏开发感兴趣且有 Cocos2d-x 使用经验的朋友,欢迎加入我们,简历投递邮箱:cloudxiao@tencent.com。
C 姐:非常感谢程祺带来的分享!并祝项目更加顺利!
C 姐:Cocos 引擎一路走来,收到了很多来自用户的宝贵建议,通过不断的优化和改进,在 Cocos Creator 中,上述许多性能问题都已得到解决。包括:
- 支持 Texture Packer 的 Polygon outline 裁剪,使用 Polygon outline 裁剪后的图集将对每个小图按透明区域精确裁剪为不规则的多边形,将达到类似 Smash Texture 节省图片空间的效果,对渲染性能也有一定提升。该功能无需在 Creator 中进行额外操作,导入图集后即可自动开启。
- 从 Cocos Creator 2.0.9 开始,支持将 Label 设为 CHAR 模式,即可实现类似的优化方案。能够以“字”为单位将文本缓存到全局共享的位图中,相同字体样式和字号的每个字符将在全局共享一份缓存。提升纹理利用率的同时,也大大提升了文本刷新时的性能。详见文档[文本缓存类型]
- 从 Cocos Creator 2.1 开始,集成了压缩纹理支持,编辑器可设置不同平台所需格式,构建时将会自动进行压缩,支持 etc/pvr 的 alpha 通道分离。详见文档[压缩纹理]
- 从 Cocos Creator 2.0 开始,默认会在原生和 Web 平台上启用动态合图,该功能支持运行时根据渲染的先后顺序动态对纹理进行合并,降低 drawcall,用户无需对 z 进行手动指定。当 Label 启用了 BITMAP 模式后,会同时进入合批。详见文档[动态合图]
Cocos Creator 3D 入门教程:快速上手,制作第一款游戏!
快速上手制作你的第一个游戏
Cocos Creator 3D编辑器的强大之处就是可以让开发者快速地制作游戏原型,下面我们将跟随教程制作一款名叫 《一步两步》 的魔性小游戏。这款游戏非常考验玩家的反应能力,玩家根据路况选择是要跳一步还是跳两步,“一步两步,一步两步,一步一步似爪牙似魔鬼的步伐”。
体验游戏(仅支持桌面鼠标操作):
https://gameu3d.github.io/MindYourStep_Tutorial/index.html
Cocos Creator 3D 下载地址:
https://forum.cocos.com/t/cocos-creator-3d-beta/82849
一、新建项目
如果您还不了解如何获取和启动 Cocos Creator 3D,请阅读 [安装和启动]一节。
1. 首先启动 Cocos Creator 3D,然后新建一个名为 MindYourStep 的项目,如果不知道如果创建项目,请阅读 [Hello World!]
2. 新建项目后会看到如下的编辑器界面:
二、创建游戏场景
在 Cocos Creator 3D 中,游戏场景(Scene)是开发时组织游戏内容的中心,也是呈现给玩家所有游戏内容的载体,游戏场景中一般会包括以下内容:
- 场景物体
- 角色
- UI
- 以组件形式附加在场景节点上的游戏逻辑脚本
当玩家运行游戏时,就会载入游戏场景,游戏场景加载后就会自动运行所包含组件的游戏脚本,实现各种各样开发者设置的逻辑功能。所以除了资源以外,游戏场景是一切内容创作的基础。
现在,让我们来新建一个场景。
1. 在 资源管理器 中点击选中 asset 目录,点击 资源管理器 左上角的 加号 按钮,选择文件夹,命名为 Scenes。
2.点击先中 Scenes 目录(下图把一些常用的文件夹都提前创建好了),点击鼠标右键,在弹出的菜单中选择 场景文件。
3. 我们创建了一个名叫 New Scene 的场景文件,创建完成后场景文件 New Scene 的名称会处于编辑状态,将它重命名为 Main。
4. 双击 Main,就会在场景编辑器和层级管理器中打开这个场景。
三、添加跑道
我们的主角需要在一个由方块(Block)组成的跑道上从屏幕左边向右边移动。我们使用编辑器自带的立方体(Cube)来组成道路。
1. 在 层级管理器 中创建一个立方体(Cube),并命名为 Cube。
2. 选中 Cube,按 Ctrl+D 来复制出 3 个 Cube。
3. 将 3 个 Cube 按以下坐标排列:第一个节点位置(0,-1.5,0),第二个节点位置(1,-1.5,0),第三个节点位置(2,-1.5,0)。
效果如下:
四、添加主角
1、创建主角节点
首先创建一个名字为Player的空节点,然后在这个空节点下创建名为Body的主角模型节点,为了方便,我们采用编辑器自带的胶囊体模型做为主角模型。
分为两个节点的好处是,我们可以使用脚本控制 Player 节点来使主角进行水平方向移动,而在 Body 节点上做一些垂直方向上的动画(比如原地跳起后下落),两者叠加形成一个跳越动画。
将 Player 节点设置在(0,0,0)位置,使得它能站在第一个方块上。效果如下:
2、编写主角脚本
想要主角影响鼠标事件来进行移动,我们就需要编写自定义的脚本。如果您从没写过程序也不用担心,我们会在教程中提供所有需要的代码,只要复制粘贴到正确的位置就可以了,之后这部分工作可以找您的程序员小伙伴来解决。下面让我们开始创建驱动主角行动的脚本吧。
(1)创建脚本
1. 如果还没有创建 Scripts 文件夹,首先在 资源管理器 中右键点击 assets 文件夹,选择 新建 -> 文件夹,重命名为 Scripts。
2. 右键点击 Scripts 文件夹,选择 新建 -> TypeScript ,创建一个 TypeScript 脚本,有关 TypeScript 资料可以查看 [TypeScript 官方网站]
3. 将新建脚本的名字改为 PlayerController,双击这个脚本,打开代码编辑器,例如VSCode。
注意:Cocos Creator 3D 中脚本名称就是组件的名称,这个命名是大小写敏感的!如果组件名称的大小写不正确,将无法正确通过名称使用组件!
(2)编写脚本代码
在打开的 PlayerController 脚本里已经有了预先设置好的一些代码块,如下所示:
import { _decorator, Component } from "cc";
const { ccclass, property } = _decorator;
@ccclass("PlayerController")
export class PlayerController extends Component {
/* class member could be defined like this */
// dummy = '';
/* use `property` decorator if your want the member to be serializable */
// @property
// serializableDummy = 0;
start () {
// Your initialization goes here.
}
// update (deltaTime: number) {
// // Your update function goes here.
// }
}
这些代码就是编写一个组件(脚本)所需的结构,具有这样结构的脚本就是 Cocos Creator 3D 中的组件(Component),他们能够挂载到场景中的节点上,提供控制节点的各种功能,更详细的脚本信息可以查看 [脚本]。
我们在脚本中添加对鼠标事件的监听,然后让 Player 动起来,将 PlayerController 中代码做如下修改:
import { _decorator, Component, Vec3, systemEvent, SystemEvent, EventMouse, AnimationComponent } from "cc";
const { ccclass, property } = _decorator;
@ccclass("PlayerController")
export class PlayerController extends Component {
/* class member could be defined like this */
// dummy = '';
/* use `property` decorator if your want the member to be serializable */
// @property
// serializableDummy = 0;
// for fake tween
private _startJump: boolean = false;
private _jumpStep: number = 0;
private _curJumpTime: number = 0;
private _jumpTime: number = 0.1;
private _curJumpSpeed: number = 0;
private _curPos: Vec3 = cc.v3();
private _deltaPos: Vec3 = cc.v3(0, 0, 0);
private _targetPos: Vec3 = cc.v3();
private _isMoving = false;
start () {
// Your initialization goes here.
systemEvent.on(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
}
onMouseUp(event: EventMouse) {
if (event.getButton() === 0) {
this.jumpByStep(1);
} else if (event.getButton() === 2) {
this.jumpByStep(2);
}
}
jumpByStep(step: number) {
if (this._isMoving) {
return;
}
this._startJump = true;
this._jumpStep = step;
this._curJumpTime = 0;
this._curJumpSpeed = this._jumpStep / this._jumpTime;
this.node.getPosition(this._curPos);
Vec3.add(this._targetPos, this._curPos, cc.v3(this._jumpStep, 0, 0));
this._isMoving = true;
}
onOnceJumpEnd() {
this._isMoving = false;
}
update (deltaTime: number) {
if (this._startJump) {
this._curJumpTime += deltaTime;
if (this._curJumpTime > this._jumpTime) {
// end
this.node.setPosition(this._targetPos);
this._startJump = false;
this.onOnceJumpEnd();
} else {
// tween
this.node.getPosition(this._curPos);
this._deltaPos.x = this._curJumpSpeed * deltaTime;
Vec3.add(this._curPos, this._curPos, this._deltaPos);
this.node.setPosition(this._curPos);
}
}
}
}
现在我们可以把 PlayerController 组件添加到主角节点上。在 层级管理器 中选中 Player 节点,然后在 属性检查器 中点击 添加组件 按钮,选择 添加用户脚本组件 -> PlayerController ,为主角节点添加 PlayerController 组件。
为了能在运行时看到物体,我们需要将场景中的 Camera 进行一些参数调整,将位置放到(0,0,13),Color设置为(50,90,255,255):
现在点击工具栏中心位置的 Play 按钮:在打开的网页中点击鼠标左键和右键,可以看到如下画面:
更多的预览功能,可以参考 [项目预览调试]。
3、添加角色动画
从上面运行的结果可以看到单纯对 Player 进行水平方向的移动是十分呆板的,我们要让 Player 跳跃起来才比较有感觉,我们可以通过为角色添加垂直方向的动画来达到这个效果。有关 动画编辑器 的更多信息,请阅读 [动画编辑器]
(1)选中场景中的 Body 节点,编辑器下方 控制台 边上的 动画编辑器,添加 Animation 组件并创建 Clip,命名为 oneStep。
(2)进入动画编辑模式,添加 position 属性轨道,并添加三个关键帧,position 值分别为(0,0,0)、(0,0.5,0)、(0,0,0)。
退出动画编辑模式前前记得要保存动画,否则做的动画就白费了。
(3)我们还可以通过 资源管理器 来创建 Clip,下面我们创建一个名为 twoStep 的 Clip 并将它添加到 Body 身上的 AnimationComponent 上,这里为了录制方便调整了一下面板布局。
(4)进入动画编辑模式,选择并编辑 twoStep 的 clip,类似第 2 步,添加三个 position 的关键帧,分别为(0,0,0)、(0,1,0)、(0,0,0)。
(5) 在 PlayerController 组件中引用动画组件,我们需要在代码中根据跳的步数不同来播放不同的动画。
首先需要在 PlayerController 组件中引用 Body 身上的 AnimationComponent。
@property({type: AnimationComponent})
public BodyAnim: AnimationComponent = null;
然后在属性检查器中将 Body 身上的 AnimationComponent 拖到这个变量上。
在跳跃的函数 jumpByStep 中加入动画播放的代码:
if (step === 1) {
this.BodyAnim.play('oneStep');
} else if (step === 2) {
this.BodyAnim.play('twoStep');
}
点击 Play 按钮,点击鼠标左键、右键,可以看到新的跳跃效果:
五、跑道升级
为了让游戏有更久的生命力,我们需要一个很长的跑道来让 Player 在上面一直往右边跑,在场景中复制一堆 Cube 并编辑位置来组成跑道显然不是一个明智的做法,我们通过脚本完成跑道的自动创建。
1、游戏管理器(GameManager)
一般游戏都会有一个管理器,主要负责整个游戏生命周期的管理,可以将跑道的动态创建代码放到这里。在场景中创建一个名为 GameManager 的节点,然后在 assets/Scripts 中创建一个名为 GameManager 的 ts 脚本文件,并将它添加到 GameManager 节点上。
2、制作 Prefab
对于需要重复生成的节点,我们可以将他保存成 Prefab(预制)资源,作为我们动态生成节点时使用的模板。关于 Prefab 的更多信息,请阅读 [预制资源(Prefab)]
我们将生成跑道的基本元素 正方体(Cube)制作成 Prefab,之后可以把场景中的三个 Cube 都删除了。
3、添加自动创建跑道代码
我们需要一个很长的跑道,理想的方法是能动态增加跑道的长度,这样可以永无止境的跑下去,这里为了方便我们先生成一个固定长度的跑道,跑道长度可以自己定义。跑道上会生成一些坑,跳到坑上就 GameOver了。
将 GameManager 脚本中代码替换成以下代码:
import { _decorator, Component, Prefab, instantiate, Node, CCInteger} from "cc";
const { ccclass, property } = _decorator;
enum BlockType{
BT_NONE,
BT_STONE,
};
@ccclass("GameManager")
export class GameManager extends Component {
@property({type: Prefab})
public cubePrfb: Prefab = null;
@property({type: CCInteger})
public roadLength: Number = 50;
private _road: number[] = [];
start () {
this.generateRoad();
}
generateRoad() {
this.node.removeAllChildren(true);
this._road = [];
// startPos
this._road.push(BlockType.BT_STONE);
for (let i = 1; i < this.roadLength; i++) {
if (this._road[i-1] === BlockType.BT_NONE) {
this._road.push(BlockType.BT_STONE);
} else {
this._road.push(Math.floor(Math.random() * 2));
}
}
for (let j = 0; j < this._road.length; j++) {
let block: Node = this.spawnBlockByType(this._road[j]);
if (block) {
this.node.addChild(block);
block.setPosition(j, -1.5, 0);
}
}
}
spawnBlockByType(type: BlockType) {
let block = null;
switch(type) {
case BlockType.BT_STONE:
block = instantiate(this.cubePrfb);
break;
}
return block;
}
// update (deltaTime: number) {
// // Your update function goes here.
// }
}
在 GameManager 的 inspector 面板中可以通过修改 roadLength 的值来改变跑道的长度。预览可以看到现在自动生成了跑道,不过因为 Camera 没有跟随 Player 移动,所以看不到后面的跑道,我们可以将场景中的 Camera 设置为 Player 的子节点。
这样 Camera 就会跟随 Player 的移动而移动,现在预览可以从头跑到尾的观察生成的跑道了。
五、增加开始菜单
开始菜单是游戏不可或缺的一部分,我们可以在这里加入游戏名称、游戏简介、制作人员等信息。
1、添加一个名为 Play 的按钮
这个操作生成了一个 Canvas 节点,一个 PlayButton 节点和一个 Label 节点。因为 UI 组件需要在带有 CanvasComponent 的父节点下才能显示,所以编辑器在发现目前场景中没有带这个组件的节点时会自动添加一个。
创建按钮后,将 Label 节点上的 cc.LabelComponent 的 String 属性从 Button 改为 Play。
2、在 Canvas 底下创建一个名字为 StartMenu 的空节点,将 PlayButton 拖到它底下。我们可以通过点击工具栏上的 2D/3D 按钮来切换到 2D 编辑视图下进行 UI 编辑操作,详细的描述请查阅 [场景编辑]
3、增加一个背景框,在 StartMenu 下新建一个名字为 BG 的 Sprite 节点,调节它的位置到 PlayButton 的上方,设置它的宽高为(200,200),并将它的 SpriteFrame 设置为 internal/default_ui/default_sprite_splash 。
4、添加一个名为 Title 的 Label 用于开始菜单的标题。
5、修改 Title 的文字,并调整 Title 的位置、文字大小、颜色。
6、增加操作的 Tips,然后调整 PlayButton 的位置,一个简单的开始菜单就完成了。
7、增加游戏状态逻辑,一般我们可以将游戏分为三个状态:
- 初始化(Init):显示游戏菜单,初始化一些资源。
- 游戏进行中(Playing):隐藏游戏菜单,玩家可以操作角度进行游戏。
- 结束(End):游戏结束,显示结束菜单。
使用一个枚举(enum)类型来表示这几个状态。
enum BlockType{
BT_NONE,
BT_STONE,
};
enum GameState{
GS_INIT,
GS_PLAYING,
GS_END,
};
GameManager 脚本中加入表示当前状态的私有变量
private _curState: GameState = GameState.GS_INIT;
为了在开始时不让用户操作角色,而在游戏进行时让用户操作角色,我们需要动态的开启和关闭角色对鼠标消息的监听。所以对 PlayerController 做如下的修改:
start () {
// Your initialization goes here.
//systemEvent.on(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
}
setInputActive(active: boolean) {
if (active) {
systemEvent.on(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
} else {
systemEvent.off(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
}
}
然后需要在 GameManager 脚本中引用 PlayerController,需要在 Inspector 中将场景的 Player 拖入到这个变量中。
@property({type: PlayerController})
public playerCtrl: PlayerController = null;
为了动态的开启\关闭开启菜单,我们需要在 GameManager 中引用 StartMenu 节点,需要在 Inspector 中将场景的 StartMenu 拖入到这个变量中。
@property({type: Node})
public startMenu: Node = null;
增加状态切换代码,并修改 GameManger 的初始化方法:
start () {
this.curState = GameState.GS_INIT;
}
init() {
this.startMenu.active = true;
this.generateRoad();
this.playerCtrl.setInputActive(false);
this.playerCtrl.node.setPosition(cc.v3());
}
set curState (value: GameState) {
switch(value) {
case GameState.GS_INIT:
this.init();
break;
case GameState.GS_PLAYING:
this.startMenu.active = false;
setTimeout(() => { //直接设置active会直接开始监听鼠标事件,做了一下延迟处理
this.playerCtrl.setInputActive(true);
}, 0.1);
break;
case GameState.GS_END:
break;
}
this._curState = value;
}
8、添加对 Play 按钮的事件监听。
为了能在点击 Play 按钮后开始游戏,我们需要对按钮的点击事件做出响应。在 GameManager 脚本中加入响应按钮点击的代码,在点击后进入游戏的 Playing 状态:
onStartButtonClicked() {
this.curState = GameState.GS_PLAYING;
}
然后在 Play 按钮的 Inspector 上添加 ClickEvents 的响应函数。
现在预览场景就可以点击 Play 按钮开始游戏了。
七、添加游戏结束逻辑
目前游戏角色只是呆呆的往前跑,我们需要添加游戏规则,来让他跑的更有挑战性。
1、角色每一次跳跃结束需要发出消息,并将自己当前所在位置做为参数发出消息。在 PlayerController 中记录自己跳了多少步:
private _curMoveIndex = 0;
// ...
jumpByStep(step: number) {
// ...
this._curMoveIndex += step;
}
在每次跳跃结束发出消息:
onOnceJumpEnd() {
this._isMoving = false;
this.node.emit('JumpEnd', this._curMoveIndex);
}
2. 在 GameManager 中监听角色跳跃结束事件,并根据规则判断输赢,增加失败和结束判断,如果跳到空方块或是超过了最大长度值都结束:
checkResult(moveIndex: number) {
if (moveIndex <= this.roadLength) {
if (this._road[moveIndex] == BlockType.BT_NONE) { //跳到了空方块上
this.curState = GameState.GS_INIT;
}
} else { // 跳过了最大长度
this.curState = GameState.GS_INIT;
}
}
监听角色跳跃消息,并调用判断函数:
start () {
this.curState = GameState.GS_INIT;
this.playerCtrl.node.on('JumpEnd', this.onPlayerJumpEnd, this);
}
// ...
onPlayerJumpEnd(moveIndex: number) {
this.checkResult(moveIndex);
}
此时预览,会发现重新开始游戏时会有判断出错,是因为我们重新开始时没有重置 PlayerController 中的 _curMoveIndex 属性值。所以我们在 PlayerController 中增加一个 reset 函数:
reset() {
this._curMoveIndex = 0;
}
在GameManager的init函数调用reset来重置PlayerController的属性。
init() {
\\ ...
this.playerCtrl.reset();
}
八、步数显示
我们可以将当前跳的步数显示到界面上,这样在跳跃过程中看着步数的不断增长会十分有成就感。
1、在 Canvas 下新建一个名为 Steps 的 Label,调整位置、字体大小等属性。
2、在 GameManager 中引用这个 Label:
@property({type: LabelComponent})
public stepsLabel: LabelComponent = null;
3、将当前步数数据更新到这个 Label 中,因为我们现在没有结束界面,游戏结束就跳回开始界面,所以在开始界面要看到上一次跳的步数,因此我们在进入 Playing 状态时,将步数重置为 0。
set curState (value: GameState) {
switch(value) {
case GameState.GS_INIT:
this.init();
break;
case GameState.GS_PLAYING:
this.startMenu.active = false;
this.stepsLabel.string = '0'; // 将步数重置为0
setTimeout(() => { //直接设置active会直接开始监听鼠标事件,做了一下延迟处理
this.playerCtrl.setInputActive(true);
}, 0.1);
break;
case GameState.GS_END:
break;
}
this._curState = value;
}
在响应角色跳跃的函数中,将步数更新到 Label 控件上
onPlayerJumpEnd(moveIndex: number) {
this.stepsLabel.string = '' + moveIndex;
this.checkResult(moveIndex);
}
九、光照和阴影
有光的地方就会有影子,光和影构成明暗交错的 3D 世界。接下来我们为角色加上简单的影子。
1、开启阴影
(2) 在 层级管理器 中点击最顶部的 Scene 节点,将 planarShadows 选项中的 Enabled 打钩,并修改 Distance 和 Normal 参数。
(2)点击 Player 节点下的 Body 节点,将 cc.ModelComponent 下的 ShadowCastingMode 设置为 ON。
此时在场景编辑器中会看到一个阴影面片,预览会发现看不到这个阴影,因为它在模型的正后方,被胶囊体盖住了。
2、调整光照
新建场景时默认会添加一个 DirctionalLight,由这个平行光计算阴影,所以为了让阴影换个位置显示,我们可以调整这个平行光的方向。
在 层级管理器 中点击选中 Main Light 节点,调整 Rotation 参数为(-10,17,0)。
预览可以看到影子效果:
十、添加主角模型
用胶囊体当主角显的有点寒碜,所以我们花(低)重(预)金(算)制作了一个 Cocos 主角。
1、导入模型资源
从原始资源导入模型、材质、动画等资源不是本篇基础教程的重点,所以这边直接使用已经导入工程的资源。
将[项目工程]中 assets 目录下的 Cocos 文件夹拷贝到你自己工程的 assets目录下。
2、添加到场景中
在 Cocos 文件中已经包含了一个名为 Cocos 的 Prefab,将它拖到场景中 Player 下的 Body 节点中。
此时会发现模型有些暗,可以加个聚光灯,以突出它锃光瓦亮的脑门。
3、添加跳跃动画
现在预览可以看到主角初始会有一个待机动画,但是跳跃时还是用这个待机动画会显得很不协调,所以我们在跳跃过程中换成跳跃的动画。在 PlayerController 类中添加一个引用模型动画的变量:
@property({type: SkeletalAnimationComponent})
public CocosAnim: SkeletalAnimationComponent = null;
然后在 Inspector 中要将 Cocos 节点拖入这个变量里。在 jumpByStep 函数中播放跳跃动画
jumpByStep(step: number) {
if (this._isMoving) {
return;
}
this._startJump = true;
this._jumpStep = step;
this._curJumpTime = 0;
this._curJumpSpeed = this._jumpStep / this._jumpTime;
this.node.getPosition(this._curPos);
Vec3.add(this._targetPos, this._curPos, cc.v3(this._jumpStep, 0, 0));
this._isMoving = true;
this.CocosAnim.getState('cocos_anim_jump').speed = 3.5; //跳跃动画时间比较长,这里加速播放
this.CocosAnim.play('cocos_anim_jump'); //播放跳跃动画
if (step === 1) {
//this.BodyAnim.play('oneStep');
} else if (step === 2) {
this.BodyAnim.play('twoStep');
}
this._curMoveIndex += step;
}
在 onOnceJumpEnd 函数中让主角变为待机状态,播放待机动画。
onOnceJumpEnd() {
this._isMoving = false;
this.CocosAnim.play('cocos_anim_idle');
this.node.emit('JumpEnd', this._curMoveIndex);
}
预览效果:
十一、最终代码
PlayerController.ts
import { _decorator, Component, Vec3, systemEvent, SystemEvent, EventMouse, AnimationComponent, SkeletalAnimationComponent } from "cc";
const { ccclass, property } = _decorator;
@ccclass("PlayerController")
export class PlayerController extends Component {
@property({type: AnimationComponent})
public BodyAnim: AnimationComponent = null;
@property({type: SkeletalAnimationComponent})
public CocosAnim: SkeletalAnimationComponent = null;
// for fake tween
private _startJump: boolean = false;
private _jumpStep: number = 0;
private _curJumpTime: number = 0;
private _jumpTime: number = 0.3;
private _curJumpSpeed: number = 0;
private _curPos: Vec3 = cc.v3();
private _deltaPos: Vec3 = cc.v3(0, 0, 0);
private _targetPos: Vec3 = cc.v3();
private _isMoving = false;
private _curMoveIndex = 0;
start () {
}
reset() {
this._curMoveIndex = 0;
}
setInputActive(active: boolean) {
if (active) {
systemEvent.on(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
} else {
systemEvent.off(SystemEvent.EventType.MOUSE_UP, this.onMouseUp, this);
}
}
onMouseUp(event: EventMouse) {
if (event.getButton() === 0) {
this.jumpByStep(1);
} else if (event.getButton() === 2) {
this.jumpByStep(2);
}
}
jumpByStep(step: number) {
if (this._isMoving) {
return;
}
this._startJump = true;
this._jumpStep = step;
this._curJumpTime = 0;
this._curJumpSpeed = this._jumpStep / this._jumpTime;
this.node.getPosition(this._curPos);
Vec3.add(this._targetPos, this._curPos, cc.v3(this._jumpStep, 0, 0));
this._isMoving = true;
this.CocosAnim.getState('cocos_anim_jump').speed = 3.5; //跳跃动画时间比较长,这里加速播放
this.CocosAnim.play('cocos_anim_jump'); //播放跳跃动画
if (step === 1) {
//this.BodyAnim.play('oneStep');
} else if (step === 2) {
this.BodyAnim.play('twoStep');
}
this._curMoveIndex += step;
}
onOnceJumpEnd() {
this._isMoving = false;
this.CocosAnim.play('cocos_anim_idle');
this.node.emit('JumpEnd', this._curMoveIndex);
}
update (deltaTime: number) {
if (this._startJump) {
this._curJumpTime += deltaTime;
if (this._curJumpTime > this._jumpTime) {
// end
this.node.setPosition(this._targetPos);
this._startJump = false;
this.onOnceJumpEnd();
} else {
// tween
this.node.getPosition(this._curPos);
this._deltaPos.x = this._curJumpSpeed * deltaTime;
Vec3.add(this._curPos, this._curPos, this._deltaPos);
this.node.setPosition(this._curPos);
}
}
}
}
GameManager.ts
import { _decorator, Component, Prefab, instantiate, Node, LabelComponent, CCInteger} from "cc";
import { PlayerController } from "./PlayerController";
const { ccclass, property } = _decorator;
enum BlockType{
BT_NONE,
BT_STONE,
};
enum GameState{
GS_INIT,
GS_PLAYING,
GS_END,
};
@ccclass("GameManager")
export class GameManager extends Component {
@property({type: Prefab})
public cubePrfb: Prefab = null;
@property({type: CCInteger})
public roadLength: Number = 50;
private _road: number[] = [];
@property({type: Node})
public startMenu: Node = null;
@property({type: PlayerController})
public playerCtrl: PlayerController = null;
private _curState: GameState = GameState.GS_INIT;
@property({type: LabelComponent})
public stepsLabel: LabelComponent = null;
start () {
this.curState = GameState.GS_INIT;
this.playerCtrl.node.on('JumpEnd', this.onPlayerJumpEnd, this);
}
init() {
this.startMenu.active = true;
this.generateRoad();
this.playerCtrl.setInputActive(false);
this.playerCtrl.node.setPosition(cc.v3());
this.playerCtrl.reset();
}
set curState (value: GameState) {
switch(value) {
case GameState.GS_INIT:
this.init();
break;
case GameState.GS_PLAYING:
this.startMenu.active = false;
this.stepsLabel.string = '0'; // 将步数重置为0
setTimeout(() => { //直接设置active会直接开始监听鼠标事件,做了一下延迟处理
this.playerCtrl.setInputActive(true);
}, 0.1);
break;
case GameState.GS_END:
break;
}
this._curState = value;
}
generateRoad() {
this.node.removeAllChildren();
this._road = [];
// startPos
this._road.push(BlockType.BT_STONE);
for (let i = 1; i < this.roadLength; i++) {
if (this._road[i-1] === BlockType.BT_NONE) {
this._road.push(BlockType.BT_STONE);
} else {
this._road.push(Math.floor(Math.random() * 2));
}
}
for (let j = 0; j < this._road.length; j++) {
let block: Node = this.spawnBlockByType(this._road[j]);
if (block) {
this.node.addChild(block);
block.setPosition(j, -1.5, 0);
}
}
}
spawnBlockByType(type: BlockType) {
let block = null;
switch(type) {
case BlockType.BT_STONE:
block = instantiate(this.cubePrfb);
break;
}
return block;
}
onStartButtonClicked() {
this.curState = GameState.GS_PLAYING;
}
checkResult(moveIndex: number) {
if (moveIndex <= this.roadLength) {
if (this._road[moveIndex] == BlockType.BT_NONE) { //跳到了空方块上
this.curState = GameState.GS_INIT;
}
} else { // 跳过了最大长度
this.curState = GameState.GS_INIT;
}
}
onPlayerJumpEnd(moveIndex: number) {
this.stepsLabel.string = '' + moveIndex;
this.checkResult(moveIndex);
}
// update (deltaTime: number) {
// // Your update function goes here.
// }
}
十二、总结
希望这篇快速入门教程能帮助您了解 Cocos Creator 3D 游戏开发流程中的基本概念和工作流程。如果您对编写和学习脚本编程不感兴趣,也可以直接从完成版的项目工程中把写好的脚本复制过来使用。
接下来您还可以继续完善游戏的各方各面,以下是一些推荐的改进方向:
- 为游戏增加难度,当角色在原地停留 1 秒就算失败
- 改为无限跑道,动态的删除已经跑过的跑道,延长后面的跑道。
- 增加游戏音效
- 为游戏增加结束菜单界面,统计玩家跳跃步数和所花的时间
- 用更漂亮的资源替换角色和跑道
- 可以增加一些可拾取物品来引导玩家“犯错”
- 添加一些粒子特效,例如角色运动时的拖尾、落地时的灰尘
- 为触屏设备加入两个操作按钮来代替鼠标左右键操作
下载完整工程:
https://github.com/cocos-creator/tutorial-mind-your-step-3d
仓库地址:
https://github.com/cocos-creator/docs-3d/tree/master/zh/getting-started/first-game
社区地址:
Cocos 引擎 UI 全新升级:进一步提升编辑器体验
我就知道你“在看”
点击[阅读原文]进入GitHub仓库!
Cocos Creator 3D 案例《弹弹乐》技术实现分享
前言
《弹弹乐》是一款简单的休闲物理弹跳类 3D 小游戏,用手指轻轻划动屏幕来控制小球运动方向,跳中板心或是板边可获得不同分数,此外,留心获取游戏场景中设置的钻石,可以为玩家增加更多分数。
Cocos 引擎开发工程师放空将分享这款3D 小游戏最基础的完整开发流程,各位开发者可以在阅读学习本篇教程后,继续发挥创造力,将这款简单的 3D 小游戏进行拓展开发,变成一款更有趣的、可对外发布的小游戏。
游戏源码
https://github.com/cocos-creator/demo-ball
结构说明
下图是《弹弹乐》的草图以及整体设计思路:
在理出整体设计思路之后,就可以开始设计每个阶段应该完成的目标,以便于提高代码编写的效率。
以下是我划分的每个阶段的开发任务:
游戏初始化
- 跳板初始化
- 屏幕事件监听,小球与普通板块弹跳计算
- 提供相机跟随接口
游戏核心逻辑编写
- 跳板复用逻辑编写
- 小球与不同板块弹跳计算
- 游戏开始与结束逻辑编写
游戏丰富
- 添加钻石以及吃砖石表现
- 添加跳板表现
- 增加小球粒子以及拖尾表现
初期设计完成后,我们就可以开始整个游戏场景的搭建。
整个游戏一共就一个场景,一个主程序 Game,负责管理所有分支管理的 Manager 以及事件的监听和派发;多个分支 Manager,负责管理跳板创建摆放或游戏页面等;一个全局配置模块,负责存储游戏中使用的配置;独立对象的运作脚本,负责自身行为运作。
编写游戏内容逻辑
由于最终呈现出来的详细步骤代码太多,就不一一演示了,今天主要针对每个流程的几个关键部分做个说明。整个游戏的制作流程主要分为以下几点:
(1)跳板初始化
跳板初始化主要体现在 BoardManager 里的 initBoard 和 getNextPos 两个方法。在整个游戏过程中,使用的板一共只有 5 个,后续的跳板生成都是通过复用的方式,不断重新计算位置以及序号。跳板的生成也是严格根据上一个跳板的位置来计算,避免出现长距离位置偏移影响游戏进行。
getNextPos(board: Board, count: number, out ?: Vec3) {
const pos: Vec3 = out ? out.set(board.node.position) : board.node.position.clone();
const o = utils.getDiffCoeff(count, 1, 2);
pos.x = (Math.random() - .5) * Constants.SCENE_MAX_OFFSET_X * o;
if (board.type === Constants.BOARD_TYPE.SPRINT) {
pos.y += Constants.BOARD_GAP_SPRINT;
pos.x = board.node.position.x;
}
if (board.type === Constants.BOARD_TYPE.SPRING) {
pos.y += Constants.BOARD_GAP_SPRING;
} else {
pos.y += Constants.BOARD_GAP;
}
return pos;
}
getDiffCoeff(e: number, t: number, a: number) {
return (a * e + 1) / (1 * e + ((a + 1) / t - 1));
}
(2)屏幕事件监听,小球与普通板块弹跳计算
跳板初始化后,开始做小球的弹跳。整个游戏的入口函数都设定在 Game 类上,Game 又添加在 Canvas 节点上,因此,Game 类所挂载的节点作为全局对象的事件监听节点来使用最合适不过。因为主要接受该事件的对象是小球,所以,我们在小球里做监听的回调。
start () {
Constants.game.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
Constants.game.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
Constants.game.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.updateBall();
this.reset();
}
onTouchStart(touch: Touch, event: EventTouch){
this.isTouch = true;
this.touchPosX = touch.getLocation().x;
this.movePosX = this.touchPosX;
}
onTouchMove(touch: Touch, event: EventTouch){
this.movePosX = touch.getLocation().x;
}
onTouchEnd(touch: Touch, event: EventTouch){
this.isTouch = false;
}
然后,小球根据一定比例的换算来做实际移动距离的计算。在 update 里每帧根据冲刺等状态对小球进行 setPosX,setPosY 调整,小球的上升与下降是通过拟重力加速减速来实现。
// Constants
static BALL_JUMP_STEP = [0.8, 0.6, 0.5, 0.4, 0.3, 0.2, 0.15, 0.1, 0.05, 0.03]; // 正常跳跃步长
static BALL_JUMP_FRAMES = 20; // 正常跳跃帧数
//Ball
_tempPos.set(this.node.position);
_tempPos.y += Constants.BALL_JUMP_STEP[Math.floor(this._currJumpFrame / 2)];
this.node.setPosition(_tempPos);
(3)提供相机跟随接口
相机的位置移动不是由自身来操控的,而是根据小球当前的位置来进行实时跟踪。因此,相机只需要调整好设置接口,按照一定脱离距离去跟随小球即可。
update() {
_tempPos.set(this.node.position);
if (_tempPos.x === this._originPos.x && _tempPos.y === this._originPos.y) {
return;
}
// 横向位置误差纠正
if (Math.abs(_tempPos.x - this._originPos.x) <= Constants.CAMERA_MOVE_MINI_ERR) {
_tempPos.x = this._originPos.x;
this.setPosition(_tempPos);
} else {
const x = this._originPos.x - _tempPos.x;
_tempPos.x += x / Constants.CAMERA_MOVE_X_FRAMES;
this.setPosition(_tempPos);
}
_tempPos.set(this.node.position);
// 纵向位置误差纠正
if (Math.abs(_tempPos.y - this._originPos.y) <= Constants.CAMERA_MOVE_MINI_ERR) {
_tempPos.y = this._originPos.y;
this.setPosition(_tempPos);
} else {
const y = this._originPos.y - _tempPos.y;
if (this.preType === Constants.BOARD_TYPE.SPRING) {
_tempPos.y += y / Constants.CAMERA_MOVE_Y_FRAMES_SPRING;
this.setPosition(_tempPos);
} else {
_tempPos.y += y / Constants.CAMERA_MOVE_Y_FRAMES;
this.setPosition(_tempPos);
}
}
}
核心逻辑
整个游戏的节奏控制其实都是通过小球来的,小球通过弹跳位置决定什么时候开始新板的生成,小球在游戏过程中的得分决定了板子后续生成的丰富性(比如长板或者弹簧板)以及小球的死亡以及复活决定了游戏的状态等等;最后通过 UI 配合来完成游戏开始结束复活的界面引导交互操作。
(1)跳板复用逻辑编写
保持场景中的跳板就是初始化的数量,所以需要提前度量好板块间的最小距离。那么,屏幕最下方的板块在什么时机开始复用到屏幕最上方呢?举个例子:假设当前场景的板上限是 5 块,在数组里的顺序就是 0 - 4,按前面说的所有板在全显示的情况下是会均匀分布的,因此,屏幕的分割板就是在中间板的 2 号板,因此只要超过了 2,就代表小球已经跳过的屏幕的一半,这个时候就要开始清理无用的板了。
for (let i = this.currBoardIdx + 1; i >= 0; i--) {
const board = boardList[i];
// 超过当前跳板应该弹跳高度,开始下降
if (this.jumpState === Constants.BALL_JUMP_STATE.FALLDOWN) {
if (this.currJumpFrame > Constants.PLAYER_MAX_DOWN_FRAMES || this.currBoard.node.position.y - this.node.position.y > Constants.BOARD_GAP + Constants.BOARD_HEIGTH) {
Constants.game.gameDie();
return;
}
// 是否在当前检测的板上
if (this.isOnBoard(board)) {
this.currBoard = board;
this.currBoardIdx = i;
this.activeCurrBoard();
break;
}
}
}
// 当超过中间板就开始做板复用
for (let l = this.currBoardIdx - Constants.BOARD_NEW_INDEX; l > 0; l--) {
this.newBoard();
}
(2)小球与不同板块弹跳计算
上面的制作过程中,我们已经实现了在普通板上小球是一个乒乓球状态,那么遇到弹簧板或者冲刺板的时候,也可以用类似逻辑结构来继续补充不同板子的不同处理。这里的实现因为结构已定较为简单,就不再多做说明,只需要在全局数据类里加上相应的相同配置即可。
(3)游戏开始与结束逻辑编写
游戏开始以及结束都是通过 UI 界面来实现。定义一个 UIManager 管理类来管理当前 UI 界面,所有的 UI 打开与关闭都通过此管理类来统一管理,点击事件的响应都直接回调给游戏主循环 Game 类。
以上部分就基本完成了整个游戏的逻辑部分。
游戏丰富
接下来丰富一下游戏的真实表现力。
(1)添加钻石以及吃砖石表现
因为游戏内的跳板数量限制,因此,我们可以大方的给每个跳板配置 5 个钻石,通过随机概率决定钻石的显示。
if (this.type === Constants.BOARD_TYPE.GIANT) {
for (let i = 0; i < 5; i++) {
this.diamondList[i].active = true;
this.hasDiamond = true;
}
} else if (this.type === Constants.BOARD_TYPE.NORMAL || this.type === Constants.BOARD_TYPE.DROP) {
if (Math.random() > .7) {
this.diamondList[2].active = true;
this.hasDiamond = true;
}
}
既然有了钻石,那吃钻石的时候,肯定也要有些表示,那就是掉落一些粒子来增加表现。由于游戏设计过程中如果有很多对频繁的创建和销毁的话,对性能其实是很不友好的,因此,提供一个对象池在一款游戏中是必不可少。
在这里,我们就可以把散落的粒子存放在对象池里进行复用。在这款游戏的设计过程中,小球部分的计算量是很频繁的,特别是在每帧需要更新的地方,想要去做性能优化的同学可以根据对象池的概念对小球里的一些向量进行复用。
getNode(prefab: Prefab, parent: Node) {
let name = prefab.data.name;
this.dictPrefab[name] = prefab;
let node: Node = null;
if (this.dictPool.hasOwnProperty(name)) {
//已有对应的对象池
let pool = this.dictPool[name];
if (pool.size() > 0) {
node = pool.get();
} else {
node = instantiate(prefab);
}
} else {
//没有对应对象池,创建他!
let pool = new NodePool();
this.dictPool[name] = pool;
node = instantiate(prefab);
}
node.parent = parent;
return node;
}
putNode(node: Node) {
let name = node.name;
let pool = null;
if (this.dictPool.hasOwnProperty(name)) {
//已有对应的对象池
pool = this.dictPool[name];
} else {
//没有对应对象池,创建他!
pool = new cc.NodePool();
this.dictPool[name] = pool;
}
pool.put(node);
}
(2)添加跳板表现、增加小球粒子以及拖尾表现
其实这两点功能都基本类似,都是增加一些波动、拖尾粒子等来丰富表现,在这里就不过多说明,具体的表现都写在了 Board 类和 Ball 类相对应关键字的方法里。
(3)增加音效和音乐
因为是基础教程,游戏内的表现也不是很多,所以就选取了按钮被点击的音效和背景音乐来做效果。
playSound(play = true) {
if (!play) {
this.audioComp.stop();
return;
}
this.audioComp.clip = this.bg;
this.audioComp.play();
}
playClip() {
this.audioComp.playOneShot(this.click);
}
运行结果
以上就是本教程的全部内容,接下来看一下运行结果吧。
————/ END /————