“编辑语:STEAM 上的 3D 解密游戏《InOutPath》以其清新的画面,独特的玩法,受到了广大 STEAM 玩家,以及 Cocos 开发者们的关注。今天有幸邀请到了这款游戏的开发商,为大家做一次技术分享。希望能够对在用 Cocos Creator 开发 3D 游戏的朋友们,有所启发。
”
在《InOutPath》的关卡中,除了画面渲染效果外,我们还设计了许多细节和彩蛋,相信大家在玩的时候就能体会到这款游戏带来的惊喜和挑战。

今天,我们就来聊聊《InOutPath》的制作细节,看看我们团队是如何利用 Cocos 游戏引擎,实现这个游戏的画风和独特的关卡机制的,以及在打包上架 Steam 的过程中,获得的一些实用经验。

团队介绍
《InOutPath》的研发是一支小型独立游戏团队,团队成员都是身经百战的游戏老兵和游戏研发老兵,源于团队对益智解谜游戏类型的喜爱和在纪念碑谷/linelight中吸取的灵感,由此制作了《InOutPath》这款冒险解谜游戏。
项目简介
《InOutPath》是一款冒险解谜游戏,一共 7 个大章节 300+ 小章节。包含了包括初晨草原、忘竹林、黄金乐园、奥秘云谷、分界地、蔚蓝边境、水下世界等 7 种完全不同风格的场景,也包含了 20 多种迥异的解谜机制。玩家将扮演一只可爱的小猫咪寻找和主人的记忆。

不同于点击解密的解密场景完全处于静止状态,在InOutPath中由于猫咪和解密要素机关都处于动态中,这导致整个场景的状态一直在变化,进而除了考验玩家的逻辑推理能力也同时考验玩家的操作能力。在设计关卡的规则当中,遵循优先设计一条看起来是正确但是实际却是错误的原则来设计关卡。
技术分享
编辑器插件
由于关卡的复杂性和策划多变的需求,开发团队基于 Cocos 编辑器插件 API 开发了一套制作关卡功能的辅助插件,使整个游戏都架构在曲线数据上。
其次是在地图数据和渲染上进行了分离,这可以很方便地调整关卡。由于制作关卡的便利性,我们一共搭建了不下于 300 关的关卡。
接下来,我们说说是如何实现的。
首先数据部分,如果屏蔽掉渲染物体,只保留逻辑物体,场景是这样的:

如图所见,场景非常简单,但是却包含了最主要的逻辑数据,这是整个游戏驱动的关键所见。当把渲染物体显示出来的时候,场景是这样的:

当然这里要特别感谢 2youyou2 提供的思路。可以利用 gizmos.ControllerBase
创建一个控制器,然后调用gizmos.ControllerUtils.cube
创建可以在世界空间中被选择方块选择器。最后在使用 gizmos.ControllerBase.initHandle
将他们关联起来就能实现图中的效果,这样策划就可以随意编辑关卡了,程序只需要在这些曲线上实现各种功能即可。
let cube = window.cce.gizmos.ControllerUtils.cube(SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,Color.YELLOW);cube.parent = this.shape;this.positionNode = cube;this.initAxis(cube, SplineMoveType.Position);cube = window.cce.gizmos.ControllerUtils.cube(SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,Color.GREEN);cube.parent = this.shape;this.directionNode = cube;this.initAxis(cube, SplineMoveType.Direction);cube = window.cce.gizmos.ControllerUtils.cube(SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,SPLINE_NODE_SIZE,Color.RED);cube.parent = this.shape;this.invDirectionNode = cube;this.initAxis(cube, SplineMoveType.InvDirection);initAxis(node: Node, axisName: string ) {return window.cce.gizmos.ControllerBase.prototype.initHandle.call(this,node,axisName);}
提示系统
首先开发的是关卡提示系统。本身解密游戏的提示功能要做到"Show,don't tell",但要做一个"Show,don't tell"实在是太难了。
而由于游戏架构设计就是奔着多人合作架构去,游戏天然的支持帧同步,帧数据(数据中主要操作数据)播放出录像。所以我们做了一个录像系统,录制了策划的指令,当玩家打开提示系统的时候,就会实时播放这个指令。还原策划当时的场景。结构如下:
export const enum OperateEventType {DEFALUT = 'defalut',UP = 'up',DOWN = 'down',LEFT = 'left',RIGHT = 'right',FRONT = 'front',BACK = 'back'... more value}export interface LevelFrame {/**帧数 */id: number;/**添加事件操作 */adds?: Array<OperateEventType | string>;/**移除事件操作 */deletes?: Array<OperateEventType | string>;/**帧dt时间 */dt?: number;}最后录制的数据差不多如此:'lv1-1': {'1': { id: 1, dt: 0.016 },'31': { id: 31, adds: ['right'] },'101': { id: 101, adds: ['back'] },'102': { id: 102, deletes: ['right'] },'182': { id: 182, adds: ['right'] },'184': { id: 184, deletes: ['back'] }},'lv1-2': {'1': { id: 1, dt: 0.016 },'160': { id: 160, adds: ['right'] },'231': { id: 231, adds: ['back'] },'233': { id: 233, deletes: ['right'] },'280': { id: 280, adds: ['right'] },'281': { id: 281, deletes: ['back'] }},
碰撞
在游戏中有比肩火箭速度一样的鸟,早期偶然会有物体穿透现象发生,而且由于逻辑表现分离,使用的是时间和速度位移,这种情况下无法使用物理系统的 ccd 功能。于是模拟一个类似 CCD 的检测机制,最大限度针对性对场景中的角色做碰撞检测。
于是我们在探照灯/雪球和玩家碰撞检测,从上一帧obb 到当前帧 obb 逐渐插值 obbWithOBB 碰撞检测,然后普通行动角色碰撞检测,从上一帧到当前帧位置线段的射线检测,然后由于角色都是跑在曲线上,因为贝塞尔曲线是非匀速的,角色逻辑 dt 不能当作曲线上行动时间。游戏中将曲线时间和行动时间分成两个时间。
// 激光/子弹const lasers = player.getAllLaser();const tempOBB = new geometry.OBB();for (let i = 0, len = lasers.length; i < len; i++) {const laser = lasers[i];for (let k = 0, kLen = characters.length; k < kLen; k++) {const character = characters[k];const cobb = character.getOBB();for (let m = 0; m < 10; m++) {const lobb = laser.lerpOBB(m / 9, tempOBB);const isCP = geometry.intersect.obbWithOBB(cobb, lobb);if (isCP) {const bCollider = character.node.getComponent(Collider);laser.onColliderEnter({ otherCollider: bCollider } as any);}}}}
操作适配
游戏目前支持键盘/手柄等多种操作模式。这就需要开发一个较为方便的游戏控制器系统。于是我们将键盘和手柄按键转换到游戏操作事件,这样在处理逻辑的地方监听操作事件即可。
渲染技术分析
渲染部分得益于 Cocos 引擎本身在渲染上就已经足够强大,所以我们做的事情并不是很多,这里列几个简单的应用场景。
深度图获取
游戏里面很多处理都依赖一张深度图,特别是后处理阶段,由于游戏采用的是前向渲染管线(Forward Rendering Pipeline),所以为了不增加不必要的深度图渲染代价,我们采取的方案是直接在渲染半透明物体之前拷贝出深度图进行使用。这需要修改 Cocos目前的内置前向渲染管线。先在 ForwardStage 阶段初始化的时候创建一张目标深度纹理。
if (!this._depthTexture) {this._depthTexture = device.createTexture(new gfx.TextureInfo(gfx.TextureType.TEX2D,gfx.TextureUsageBit.DEPTH_STENCIL_ATTACHMENT | gfx.TextureUsageBit.SAMPLED,gfx.Format.DEPTH_STENCIL,this._renderArea.width * pipeline.shadingScale,this._renderArea.height * pipeline.shadingScale));}
然后获取当前的深度缓冲区数据后,调用 blitTexture 将深度缓冲器的深度数据拷贝到我们自己创建的深度图中。
const bufferCopy = new gfx.TextureBlit();bufferCopy.srcOffset.x = 0;bufferCopy.srcOffset.y = 0;bufferCopy.srcExtent.width = depth.width;bufferCopy.srcExtent.height = depth.height;bufferCopy.dstOffset.x = 0;bufferCopy.dstOffset.y = 0;bufferCopy.dstExtent.width = depth.width;bufferCopy.dstExtent.height = depth.height;cmdBuff.blitTexture(depth, this._depthTexture!, [bufferCopy], gfx.Filter.POINT);
这样就可以很方便的在渲染半透明物体和后处理渲染阶段进行深度图依赖了。而且也不需要增加一次 PreDepthPass 的损耗。
全屏抓屏Pass
全屏抓屏Pass主要拿来制作毛玻璃和水下物体波动效果。
这里讲一下水下效果的制作。由于光的折射/反射问题会导致人在水面上看水面下的物体的时候,会发现物体断开和波动。

由于渲染一个模型要波动,一般都会想到采用顶点扰动的方式,但由于我们水面下物体实在太多,这种方式不可取。所以我们采用了对全屏抓屏Pass进行扰动的方式。效果如图水面下的波动和断开。

老版本由于渲染水面是半透明物体,所以我们只需要在渲染不透明物体之后,在渲染半透明物体之前拷贝当前颜色缓冲器即可。首先在任何需要使用到 GrabPass 的半透明物体里面任意定义
#pragma define-meta USE_GRAB_PASS
然后老办法在 ForwardStage 阶段对 define进 行判断,如果渲染 pass 有 USE_GRAB_PASS 的定义,则需要进行一次 GrabPass。
if (pass.defines['USE_ALPHA_TEST'] &&renderCutoutQueues.phases(pass.phase)) {if (isTransparent && pass.defines['USE_GRAB_PASS']) {hasGrabPass = true;}renderCutoutQueues.insertRenderPassNoCheck(ro, m, p);} else if (!isTransparent && renderOpaqueQueues.phases(pass.phase)) {renderOpaqueQueues.insertRenderPassNoCheck(ro, m, p);} else if (isTransparent && renderTransparentQueues.phases(pass.phase)) {if (isTransparent && pass.defines['USE_GRAB_PASS']) {hasGrabPass = true;}renderTransparentQueues.insertRenderPassNoCheck(ro, m, p);}
最后调用 blitFramebuffer 复制颜色缓冲区到自己创建的归属于 GrabPass 的 RenderTexture 即可。
1,创建GrabPass的RenderTextureif (this.hasGrabPass) {const colorAttachment = new gfx.ColorAttachment();colorAttachment.format = gfx.Format.RGBA8;colorAttachment.loadOp = gfx.LoadOp.CLEAR;colorAttachment.storeOp = gfx.StoreOp.STORE;const rt = new RenderTexture();rt.reset({width: this._width * sceneData.shadingScale,height: this._height * sceneData.shadingScale,passInfo: new gfx.RenderPassInfo([colorAttachment],new gfx.DepthStencilAttachment(gfx.Format.DEPTH_STENCIL))});data.grabOutputRenderTarget = rt;data.grabOutputRenderTargets.push(device.createTexture(new gfx.TextureInfo(gfx.TextureType.TEX2D,gfx.TextureUsageBit.COLOR_ATTACHMENT | gfx.TextureUsageBit.SAMPLED,gfx.Format.RGBA16F, // normals need more precisionthis._width * sceneData.shadingScale,this._height * sceneData.shadingScale)));}//拷贝颜色缓冲区if (hasGrabPass) {cmdBuff.blitFramebuffer(framebuffer,renderData.grabOutputRenderTarget.window!.framebuffer,this._renderArea,this._renderArea,gfx.Filter.POINT);}
这里调用了blitFramebuffer,但是cocos pipeline上并没有暴露出这个API,我们只需要自定义一下。因为在webgl2-commands中cocos已经实现好了。
export function WebGL2CmdFuncBlitFramebuffer (device: WebGL2Device,src: IWebGL2GPUFramebuffer,dst: IWebGL2GPUFramebuffer,srcRect: Readonly<Rect>,dstRect: Readonly<Rect>,filter: Filter,): void {...}
到这里我们已经准备好了当前渲染阶段的颜色区,那么只需要在渲染水面的时候,最后加到 albedo 上即可。
vec4 refractColorRefr = texture(grabTexture, v_screenPos.xy / v_screenPos.z + _RefractOffset * v_offsetPos.xy);vec3 refractColorRefl = SRGBToLinear(fragTextureLod(_Sky, rotationDir, 0.0).rgb);refractColorRefr.xyz = lerp(refractColorRefl.xyz, refractColorRefr.xyz, refractColorRefr.a);
这样我们就实现了水面下的渲染效果。
实时反射
由于 Cocos 在实时反射渲染阶段需要重新编译 shader 变体,这导致实时反射渲染一旦使用,整个帧率直接就奔着 4FPS 去。
因为每时每刻编译大量 shader 变体实在是太耗时了。所以我们在渲染反射探针的队列中禁用了编译 shader 变体的代码,转而采用全局参数传递的方式输出不同阶段的颜色需求。
在render-reflection-probe-queue中禁用// if (!bUseReflectPass) {// this._patches = [];// this._patches = this._patches.concat(subModel.patches!);// const useRGBEPatchs: IMacroPatch[] = [// { name: CC_USE_RGBE_OUTPUT, value: true },// ];// this._patches = this._patches.concat(useRGBEPatchs);// subModel.onMacroPatchesStateChanged(this._patches);// this._rgbeSubModelsArray.push(subModel);// }
在 forward 阶段的 chunk 文件中修改最后的输出为:
// Color output#if CC_USE_RGBE_OUTPUTcolor = packRGBE(color.rgb); // for reflection-map#elseif(cc_probeInfo.y == 1.0){color = packRGBE(color.rgb); // for reflection-map 这里的判断是我们加的,让cc_probeInfo的y分值变成阶段判断常量}else{color = CCSurfacesDebugDisplayInvalidNumber(color);#if !CC_USE_FLOAT_OUTPUT || CC_IS_TRANSPARENCY_PASScolor.rgb = HDRToLDR(color.rgb);color.rgb = LinearToSRGB(color.rgb);#endif }#endif
最后重新修改渲染反射探针的流程为:
export class SRPReflectionProbeFlow extends ReflectionProbeFlow {public render(camera: renderer.scene.Camera): void {const pipeline = this.pipeline as ISRPRenderPipeline;pipeline.beforeReflectionProbe();super.render(camera);pipeline.afterReflectionProbe();}}public beforeReflectionProbe(): void {const globalDSManager = this.globalDSManager;const ds = this.descriptorSet;const cmdBuffer = this.commandBuffers;const globalUBO = this.pipelineUBO['_globalUBO'];globalUBO[UBOGlobal.PROBE_INFO_OFFSET + 1] = 1.0;cmdBuffer[0].updateBuffer(ds.getBuffer(UBOGlobal.BINDING), globalUBO);globalDSManager.bindBuffer(UBOGlobal.BINDING, ds.getBuffer(UBOGlobal.BINDING));globalDSManager.update();}public afterReflectionProbe(): void {const globalDSManager = this.globalDSManager;const ds = this.descriptorSet;const cmdBuffer = this.commandBuffers;const globalUBO = this.pipelineUBO['_globalUBO'];globalUBO[UBOGlobal.PROBE_INFO_OFFSET + 1] = 0.0;cmdBuffer[0].updateBuffer(ds.getBuffer(UBOGlobal.BINDING), globalUBO);globalDSManager.bindBuffer(UBOGlobal.BINDING, ds.getBuffer(UBOGlobal.BINDING));globalDSManager.update();}
经过修改后,渲染水面实时反射就可以稳定 60FPS了。当然这对游戏来说是够用了,如果要渲染大场景,还是需要 SSR/SSPR 才行。
下雨的地面涟漪
游戏中,第二章的场景是竹林雨季的风格,借用于 Cocos 强大的粒子系统我们就随手一做下雨效果就 OK了。
与下雨效果配合的,是雨滴滴落到地面的涟漪效果,这里采用的是 shader 实现。

#if WEATHER_RAIN//ripple topvec3 _emissive = vec3(1.0) - vec3(fract(cc_time.x * rainSpeed));vec3 _emissive2 = vec3(1.0) - vec3(fract((cc_time.x *rainSpeed)+ 0.5));vec3 _mask = vec3(texture(rainRipple, v_texCoord2 * v_scale / rainRippleScala).r);vec3 _mask2 = vec3(texture(rainRipple,v_texCoord2 * v_scale / rainRippleScala + vec2(0.5,0.5)).r);float _maskColor = saturate(1.0 - distance(_mask.r - _emissive.r,0.05)/0.05) * _mask.r;float _maskColor2 = saturate(1.0 - distance(_mask2.r - _emissive2.r,0.05)/0.05) * _mask2.r;vec3 finalColor = vec3(_maskColor + _maskColor2);s.albedo.rgb = finalColor + s.albedo.rgb;#endif

后处理
在《InOutPath》中,为了渲染出不同场景新颖的画风,我们实现了许多后处理效果。
我们并未使用自定义管线,而是基于 Cocos 的内置管线开发了一套后处理架构。由于之前已经做过分享,这里就不再阐述,大家可以去论坛查看。
后处理的确可以非常低成本地增加游戏画面的美术表现,建议朋友们都多研究研究。
这里只说一下水下的全屏焦散。焦散处理采用的是白嫖来的全屏后处理:https://www.shadertoy.com/view/mdtyRr
效果如下:
所有物体都处于焦散状态,效果很好,处理很高效。其他后处理没什么好讲的,大家都懂。这里就贴个图说明一下当前游戏已经使用的后处理效果有哪些。

风格切换
为了实现游戏中多个风格迥异的关卡氛围,我们实现了 9 个不同的场景,这些场景只是作为氛围配置表。里面包含了主方向光、雾效、环境光、阴影等各类参数。
游戏会在加载不同的风格的时候,使用对应的场景数据同步到当前场景。
this.syncAmbient(curr, next);this.syncSkybox(curr, next);if (style.fog.enabled) {this.syncFogInfo(curr!.scene.globals.fog, style.fog);} else {this.syncFog(curr, next);}this.syncShadow(curr, next);this.syncMainLight(curr, next);this.syncPostProcess(curr, style);//参考代码:private syncSkybox(curr: Scene, next: SceneAsset): void {let currSkybox = curr!.scene.globals.skybox;let nextSkybox = next!.scene?.globals.skybox;if (!nextSkybox || !currSkybox) return;currSkybox.enabled = nextSkybox.enabled;currSkybox.envLightingType = nextSkybox.envLightingType;currSkybox.rotationAngle = nextSkybox.rotationAngle;if (nextSkybox.skyboxMaterial) {let mat = new Material();mat.copy(nextSkybox.skyboxMaterial);currSkybox.skyboxMaterial = mat;}currSkybox.useHDR = nextSkybox.useHDR;currSkybox.envmap = nextSkybox.envmap;if (nextSkybox.reflectionMap) {currSkybox.reflectionMap = nextSkybox.reflectionMap;}}
场景文件除开main,其他场景都是一个空盒子当做配置用。

STEAM打包及加密
打包
STEAM 打包的时候选择 Cocos 的 web-mobile 打包。打包后再配合 steamwork.js+electron 接入 STEAM SDK 即可。
加密
需要知道的是 steamwork.js 是包装的 rust 实现,打包后的二进制文件是 node 后缀,而 node 后缀文件是可以作为 electron 启动文件的。
所以先修改 package.json 里面的 main 入口为打包后的启动文件 plugin-win.node。
"main": "./plugin-win.node",
这里的问题是这个 plugin 是怎么来的呢?其实就是使用 rust 开发的 napi 插件,这里说一下重点。
先禁用掉监听调试入口。
let process: JsObject = global.get_named_property("process").unwrap();let argv: JsObject = process.get_named_property("argv").unwrap();let leng = argv.get_array_length().unwrap();for x in 0..leng {let arg: JsString = argv.get_element::<JsString>(x).unwrap();if arg.into_utf8()?.as_str()?.contains("--inspect")|| arg.into_utf8()?.as_str()?.contains("--remote-debugging-port"){return Err(Error::new(napi::Status::InvalidArg,"Not allow debugging this program.".to_string(),));}}
然后在 napi 插件中使用 rust 劫持下载脚本的函数。
let _ = module_prototype.define_properties(&[Property::new("_compile")?.with_method(encrypt::module_prototype_compile)]);let _ = s.get_named_property::<JsObject>("__proto__").unwrap().define_properties(&[Property::new("createScript")?.with_method(encrypt::systemjs_create_scripts)]);let _ = env.get_global().unwrap().define_properties(&[Property::new("downloadScript")?.with_method(encrypt::ccjs_download_scripts)]);
劫持这个函数后,使用 rust 重新实现。在里面进行解密并返回源码即可。
let content = decrypt(ctx.env, content.into_utf8().unwrap().as_str().unwrap());content
到这里解密就已经完成了,那么加密在什么时候进行呢?在打包 electron 应用的时候进行。
const iv = crypto.randomBytes(16);let append = false;const cipher = crypto.createCipheriv('aes-256-cbc',key,iv);cipher.setAutoPadding(true);cipher.setEncoding('base64');const _p = cipher.push;cipher.push = function (chunk, enc) {if (!append && chunk != null) {append = truereturn _p.call(this, Buffer.concat([iv, chunk]), enc);} else {return _p.call(this, chunk, enc);};};return cipher;
采用 electron-packager 进行打包,然后对 asar 包进行自定义处理。
asar: {unpack: "*.{node,dylib,dll,lib}",transform(filename) {... pipe}}
这里需要注意的是打包的时候有个关键参数需要添加,这会让电脑自动使用最佳显卡。
process.env['SHIM_MCCOMPAT'] = '0x800000001'
为什么使用Cocos
最后我们采访下开发者为什么使用 Cocos Creator 开发《InOutPath》
熟悉
你没看错就是 2 个字熟悉,对于 Cocos 的源码我们团队已经滚瓜烂熟,没道理不用。
优秀
你没看错,Cocos 目前阶段对于我们的项目需求来说,就是两个字优秀。
开源
如果你从头到尾看到这里来的话,你就知道我们基于 Cocos 改造了那些地方。
如果不开源,我想改代码那是不可能的,对于我们小团队且可以改的动代码的团队来讲,开源就是一切选择的基石。
易用性
得益于 Cocos 超现代化的架构,可以很方便的让我们随便蹂躏完成项目需求。
跨平台
可以打包任意主流平台就可以让我们的游戏增加更多的曝光,拥有更多盈利机会。
社区
Cocos 社区应该是目前国产游戏引擎中最强的交流社区,没有之一。
相比一些一潭死水的社区氛围,我更喜欢 Cocos 社区活跃的学习与交流气氛,可以捡到很多珍宝知识和前辈们的经验,只能说非常 nice。

游戏截图
最后在放几张图,都是实机录制。






想要亲身体验《InOutPath》的朋友,点击【阅读原文】即可进入游戏主页。