简介:这份资源面向使用 Cocos Creator 2.4.10 进行游戏开发的初学者与进阶开发者,聚焦「2D 人物 + 3D 背景」的混合场景实现,解决人物移动控制与透视视角设置两大核心问题。包内共 33 个文件,以 15 个 meta 资源描述文件、6 个 json 配置、4 个 ts 脚本、4 张 png 贴图及 1 个 atlas 图集为主,另有 fire 场景文件与项目配置,压缩包约 313KB,结构完整可直接导入编辑器运行。脚本部分承担人物位移与输入监听逻辑,场景文件则承载 3D 背景与摄像机参数,便于对照理解透视投影的调整方式。目前已有 462 人学习下载,适合想快速上手 2D/3D 混合渲染、理清节点与摄像机配置思路的开发者参考,也可作为课程设计或小型 Demo 的起步模板。
1. 2D人物踩进3D场景:这套Cocos Creator 2.4.10方案到底能跑出什么效果
如果你做过2D横版或者俯视角项目,大概都遇到过同一个尴尬:人物动作调得挺顺,但背景永远是几张静态图叠出来的“纸片感”,镜头一拉近就露馅。这套基于Cocos Creator 2.4.10的工程,思路正好反过来——背景用真3D场景撑起纵深,人物仍然是2D精灵,靠透视相机把两者压进同一个画面。人物移动时,2D角色在3D地面上走位,近大远小的透视关系实时生效,视觉上比纯2D方案多出一层空间感,又不用把角色换成3D模型去重新做骨骼和动画。它适合两类人:一类是手里已经有2D角色资源、想低成本升级画面层次的小团队;另一类是正在学Cocos Creator、想搞明白2D与3D混合渲染到底怎么接的开发者。下面我按“资源是什么、怎么搭、坑在哪”的顺序,把这份工程拆开讲清楚。
2. 2D精灵与3D场景共存:相机分层与节点结构怎么定
2.1 为什么不是把2D角色直接塞进3D世界
Cocos Creator 2.4.10的渲染管线里,2D和3D走的是两套节点体系。2D节点挂在Canvas下,靠UITransform和Sprite组件驱动,坐标是屏幕空间的;3D节点挂在场景根下,靠MeshRenderer和Camera驱动,坐标是世界空间的。如果你直接把一个带Sprite的节点丢进3D场景,它不会自动获得透视缩放,反而会因为缺少正确的相机投影矩阵而显示异常。
常见做法是让2D角色单独占一个Camera层,3D背景占另一个Camera层,两个相机共享同一个透视投影参数,这样2D精灵在屏幕上的位置就能和3D世界里的地面坐标做映射。工程里通常会把主相机设为透视模式,FOV控制在45到60之间,再建一个专门渲染UI和2D角色的正交相机,通过Camera的visibility和depth来控制渲染顺序。这样做的代价是2D角色不会真正被3D物体遮挡,但换来的是角色动画、碰撞、状态机全部沿用2D那套成熟方案,改动量最小。
2.2 节点树与相机参数的实际配置
先看节点结构。一个能跑通的典型层级是这样的:
// 场景根节点结构示意(在Creator编辑器中手动搭建) // Scene // ├── MainCamera (3D透视相机, 渲染背景和地面) // ├── Canvas // │ ├── UICamera (正交相机, 渲染2D角色和UI) // │ └── Player (2D Sprite节点) // └── Ground (3D Plane或Tilemap转的Mesh)MainCamera的关键参数:Projection设为PERSPECTIVE,FOV设50,Near设0.1,Far设1000,ClearFlags选SOLID_COLOR或DEPTH_ONLY。UICamera的Projection设为ORTHO,orthoHeight根据设计分辨率算,比如设计分辨率640×960时orthoHeight取480。两个相机的depth值要拉开,MainCamera的depth设0,UICamera设1,保证2D角色画在3D背景之上。
Player节点上挂Sprite组件,同时挂一个自定义脚本负责把2D屏幕坐标转成3D世界坐标。转换的核心是Camera的screenToWorld方法,但要注意2.4.10里这个方法返回的是相机空间坐标,还需要结合节点层级做一次变换。我一般会在Player脚本的update里做这件事:
// PlayerController.js 核心移动与坐标映射 cc.Class({ extends: cc.Component, properties: { mainCamera: cc.Camera, // 拖入MainCamera moveSpeed: 200, // 像素/秒 groundY: 0, // 3D地面在世界空间的Y值 }, onLoad() { this._targetPos = this.node.position.clone(); }, update(dt) { // 键盘输入驱动2D角色在屏幕平面移动 let dx = 0, dy = 0; if (cc.systemEvent.isKeyPressed(cc.macro.KEY.a)) dx -= 1; if (cc.systemEvent.isKeyPressed(cc.macro.KEY.d)) dx += 1; if (cc.systemEvent.isKeyPressed(cc.macro.KEY.w)) dy += 1; if (cc.systemEvent.isKeyPressed(cc.macro.KEY.s)) dy -= 1; if (dx !== 0 || dy !== 0) { let len = Math.sqrt(dx * dx + dy * dy); dx /= len; dy /= len; this._targetPos.x += dx * this.properties.moveSpeed * dt; this._targetPos.y += dy * this.properties.moveSpeed * dt; this.node.position = this._targetPos; } // 把2D角色的屏幕位置映射到3D地面,用于后续阴影或碰撞检测 let screenPos = this.mainCamera.worldToScreen(this.node.position); let worldPos = this.mainCamera.screenToWorld( new cc.Vec3(screenPos.x, screenPos.y, 10) // z取10表示地面深度 ); this.node.emit('positionChanged', worldPos); } });这段代码里moveSpeed控制移动快慢,groundY用来校准地面高度,screenToWorld的z参数决定映射到3D空间的哪个深度平面。参数改错最直接的表现是角色移动方向和背景透视对不上,比如按W角色往上走,但3D地面看起来是往远处缩,这时候要检查MainCamera的旋转角度和FOV是否匹配。
2.3 透视视角下的人物缩放与地面贴合
透视相机的特点是离镜头近的物体大、远的物体小。2D角色如果固定大小,走到画面下方(近处)会显得偏小,走到上方(远处)又显得偏大,和3D背景的透视关系打架。工程里常见的处理是给Player节点加一个动态scale,根据角色在屏幕上的Y坐标做线性插值:
// 在PlayerController的update末尾追加 let screenY = this.node.position.y; let minY = -480, maxY = 480; // 屏幕上下边界 let minScale = 0.8, maxScale = 1.2; // 缩放范围 let t = (screenY - minY) / (maxY - minY); this.node.scale = minScale + (maxScale - minScale) * t;minScale和maxScale要根据实际美术资源调,一般差值不超过0.5,否则角色大小变化太突兀。地面贴合则是把3D地面的碰撞检测结果反馈给2D角色,让角色在视觉上踩在地面上而不是浮空。这一步需要3D地面有Collider,然后用射线检测从相机往角色方向打,命中点就是角色应该站的世界坐标。
3. 人物移动与透视映射:输入、坐标转换与帧同步
3.1 键盘与触摸输入的统一处理
2.4.10里键盘事件用cc.systemEvent.on监听,触摸事件用node.on(cc.Node.EventType.TOUCH_MOVE)。为了同时支持PC调试和移动端,我一般会封装一个输入管理器,把两种输入归一化成方向向量:
// InputManager.js cc.Class({ extends: cc.Component, properties: { joystickRadius: 80, }, onLoad() { this._dir = cc.v2(0, 0); cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN, this.onKeyDown, this); cc.systemEvent.on(cc.SystemEvent.EventType.KEY_UP, this.onKeyUp, this); this.node.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this); this.node.on(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this); }, onKeyDown(e) { switch (e.keyCode) { case cc.macro.KEY.w: this._dir.y = 1; break; case cc.macro.KEY.s: this._dir.y = -1; break; case cc.macro.KEY.a: this._dir.x = -1; break; case cc.macro.KEY.d: this._dir.x = 1; break; } }, onKeyUp(e) { switch (e.keyCode) { case cc.macro.KEY.w: case cc.macro.KEY.s: this._dir.y = 0; break; case cc.macro.KEY.a: case cc.macro.KEY.d: this._dir.x = 0; break; } }, onTouchMove(e) { let delta = e.getDelta(); let pos = e.getLocation(); // 以屏幕中心为原点算方向 let center = cc.v2(cc.winSize.width / 2, cc.winSize.height / 2); let offset = pos.sub(center); if (offset.mag() > this.joystickRadius) { offset = offset.normalize().mul(this.joystickRadius); } this._dir = offset.normalize(); }, onTouchEnd() { this._dir = cc.v2(0, 0); }, getDirection() { return this._dir; } });joystickRadius控制虚拟摇杆的死区,太小会导致轻微触摸就触发移动,太大则手感迟钝。键盘部分用KEY_DOWN和KEY_UP分别置位和清零,避免长按时的重复触发问题。触摸部分用getDelta而不是getLocation,是为了兼容不同分辨率下的偏移量计算。
3.2 屏幕坐标到3D世界坐标的转换链路
这是整个方案里最容易翻车的一环。2D角色的position是相对于Canvas的,Canvas又可能被适配策略缩放,所以不能直接把node.position丢给screenToWorld。正确的链路是:先通过Canvas的convertToWorldSpaceAR把节点坐标转成世界空间,再用MainCamera的worldToScreen得到屏幕坐标,最后用screenToWorld映射到3D平面。
// CoordinateMapper.js cc.Class({ extends: cc.Component, properties: { mainCamera: cc.Camera, canvas: cc.Node, groundPlaneY: 0, }, screenToGround(node) { // 1. 节点坐标 -> Canvas世界坐标 let worldPos = this.canvas.convertToWorldSpaceAR(node.position); // 2. 世界坐标 -> 屏幕坐标 let screenPos = this.mainCamera.worldToScreen(worldPos); // 3. 屏幕坐标 -> 3D世界坐标(指定深度平面) let groundPos = this.mainCamera.screenToWorld( new cc.Vec3(screenPos.x, screenPos.y, this.groundPlaneY) ); return groundPos; } });groundPlaneY决定映射到3D空间的哪个Y平面,通常设成地面Mesh的Y值。如果发现角色影子位置偏移,优先检查这个值。另外2.4.10的screenToWorld在相机有旋转时结果会偏,建议MainCamera只做俯仰旋转,不要有Roll。
3.3 移动平滑与帧率无关的位移计算
直接用dt乘速度在低帧率下会出现移动抖动,因为输入采样和渲染帧不同步。我一般会加一个简单的速度插值:
// 在PlayerController里替换直接赋值 let targetVel = cc.v2(dx, dy).mul(this.moveSpeed); this._velocity = this._velocity.lerp(targetVel, 0.2); // 0.2是平滑系数 this._targetPos.x += this._velocity.x * dt; this._targetPos.y += this._velocity.y * dt;lerp的第二个参数越小,加速越慢但越平滑,0.1到0.3之间比较合适。如果项目对操作响应要求高,可以把这个值调到0.5以上,代价是移动会有轻微顿挫。
4. 避坑与排查:2D/3D混合渲染里最容易翻车的五件事
4.1 角色被3D背景遮挡或穿透
现象:2D角色走到某些3D物体后面时,要么完全消失,要么直接画在物体前面,没有正确的遮挡关系。 原因:2D和3D分属不同相机渲染,depth排序只保证相机之间的顺序,不保证单个2D精灵和单个3D Mesh之间的逐像素遮挡。 解决:如果项目不需要精确遮挡,把UICamera的depth设得比MainCamera高,让2D角色永远在最上层。如果需要遮挡,得用3D相机渲染一个深度图,在2D角色的Shader里采样深度做裁剪,2.4.10里可以用cc.Material自定义Effect实现,但工作量不小,建议先评估是否真的需要。
4.2 透视相机下2D角色点击区域偏移
现象:点击角色身体时没反应,点旁边空白处反而触发了。 原因:2D角色的碰撞体是屏幕空间的,但透视相机下角色的视觉位置和实际节点位置有偏差,尤其是角色做了缩放之后。 解决:把角色的碰撞体挂在一个不缩放的父节点上,或者用cc.Intersection.rectRect手动做点击检测,检测矩形根据角色当前scale动态计算。
4.3 移动端帧率骤降
现象:编辑器里跑60帧,打包到手机后掉到20帧以下。 原因:3D背景的DrawCall没合批,或者2D角色的Sprite每帧都在重建。 解决:3D背景尽量用静态合批,把相同材质的Mesh合并;2D角色如果用序列帧动画,检查是否开了cc.macro.CLEANUP_IMAGE_CACHE,关掉它可以减少纹理上传开销。另外2.4.10的3D渲染在低端机上本身偏重,FOV不要设太大,Far值尽量小。
4.4 坐标转换结果在分辨率变化时错乱
现象:在编辑器里角色位置正常,切到不同分辨率的设备后,角色和3D地面的对应关系全乱了。 原因:Canvas的适配策略(fitHeight/fitWidth)改变了节点坐标到世界坐标的映射比例,而screenToWorld用的是屏幕像素坐标。 解决:所有坐标转换统一走Canvas.convertToWorldSpaceAR,不要直接用node.position。适配策略建议用fitHeight,让高度固定,宽度自适应,这样Y轴映射关系稳定。
4.5 打包APK后3D背景不显示
现象:编辑器预览正常,打包成APK安装后3D背景一片黑或直接消失。 原因:3D资源没有被打进包,或者材质在移动端平台不兼容。 解决:检查resources目录下是否包含了3D模型和材质,2.4.10打包时非resources目录的资源不会自动包含。材质方面,移动端不支持某些桌面端的Shader特性,把材质换成builtin-unlit或builtin-standard,避免用自定义的复杂Shader。
5. 进阶技巧:用深度偏移做伪遮挡与性能兜底
前面说的遮挡问题,如果不想写自定义Shader,有一个取巧的办法:给2D角色加一个深度偏移,让它在3D相机的深度测试里“假装”在某个位置。具体做法是在Player节点上挂一个3D节点作为代理,每帧把2D角色的屏幕坐标映射到3D空间,更新代理节点的位置,然后让3D相机渲染这个代理节点写入深度。2D角色本身还是由UICamera渲染,但深度缓冲里已经有了它的深度信息,3D物体就能正确遮挡它。
// DepthProxy.js 挂在3D代理节点上 cc.Class({ extends: cc.Component, properties: { playerNode: cc.Node, mainCamera: cc.Camera, canvas: cc.Node, }, update() { let worldPos = this.canvas.convertToWorldSpaceAR(this.playerNode.position); let screenPos = this.mainCamera.worldToScreen(worldPos); let depthPos = this.mainCamera.screenToWorld( new cc.Vec3(screenPos.x, screenPos.y, 5) ); this.node.position = depthPos; } });这个代理节点用一个极小的Mesh(比如1×1的Quad),材质设成只写深度不写颜色。这样3D背景在渲染时就能根据深度缓冲正确遮挡2D角色。代价是多了一次DrawCall和一次坐标转换,低端机上要测一下帧率。
另一个兜底是性能分级。我一般会在游戏启动时根据设备型号或帧率采样决定是否开启3D背景的实时阴影和抗锯齿。2.4.10里可以通过cc.game.on(cc.Game.EVENT_ENGINE_INITED)里读cc.sys.platform和cc.sys.isNative来判断,移动端默认关阴影,桌面端开。
// PerformanceTier.js cc.Class({ extends: cc.Component, properties: { mainCamera: cc.Camera, }, onLoad() { let isMobile = cc.sys.isMobile; if (isMobile) { this.mainCamera.fov = 55; // 移动端视野收窄,减少渲染面积 cc.director.getScheduler().setTimeScale(1); // 保持逻辑帧率 // 关闭阴影 let shadows = this.node.getComponentsInChildren(cc.Shadow); shadows.forEach(s => s.enabled = false); } else { this.mainCamera.fov = 50; } } });fov在移动端调大反而会让边缘物体变形更明显,我习惯是移动端稍微收窄到55,桌面端保持50。阴影组件在2.4.10里如果用的是3D光照,关掉后画面会平一些,但帧率能回来不少。
从那以后我每次搭2D/3D混合场景,都会先把相机分层和坐标转换链路跑通再动美术资源,不然等角色动画和背景都铺完了再回头改坐标,返工量能让人崩溃。希望帮到你。
本文还有配套的精品资源,点击获取