拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Cocos Creator 2.4实现2D人物+3D背景的2.5D透视视角

简介面向 Cocos Creator 2.4.10 开发者的 2D 人物 3D 背景示例工程围绕人物移动控制与透视视角设置展开适合希望在单一项目中混用 2D/3D 元素、学习精灵动画表现与三维场景布局及灯光配置的初中级游戏开发者。压缩包共 33 个文件体积仅 313KB包括 4 个 ts 脚本、6 个 json 配置、4 个 png 贴图、1 个 fire 场景与 1 个 atlas 图集等meta 文件则负责资源导入参数整体轻量便于拆解学习。已有 460 人浏览学习具有一定的实战参考价值。资源覆盖从项目创建、2D 精灵/动画与 3D 模型结合、角色移动脚本编写含输入监听到透视摄像机位置/角度调优的完整路径同时体现 Cocos Creator 多平台发布配置思路开发者可按需调整参数快速打造出具有空间深度的 2D 人物行走 demo。 做了一版 Cocos Creator 2.4.10 的“2D 人物 3D 背景 透视视角”小 Demo也算给正在纠结 2.5D 玩法的朋友探个路。这个方案的直观效果就是场景是带纵深的 3D 地图人物是一只会在场景里自由跑动的 2D 纸片人镜头带一点俯视跑动时近大远小、墙体会自然遮挡人物视觉上非常接近《八方旅人》那类伪 3D 或者说 2.5D 的观感。如果你正在用 Cocos Creator 2.4.x 做游戏尤其是想要 3D 场景表现、但美术资源还是 2D 序列帧的项目这篇文章基本可以当一份可直接照抄的实操记录。核心思路就四个字纸片人物。人物不是 UI也不是 3D 模型而是 3D 场景里的一张带透明通道的 Quad 面片配合每帧转向相机的广告牌逻辑再加一套移动控制器和透视镜头跟随。1. 这个项目到底在做什么1.1 为什么选“2D 人物 3D 背景”而不是全 3D 或全 2D先说我为什么这么组合。全 3D 的人物需要建模、蒙皮、动画中小团队和独立开发者很难在短期内堆出足够丰富的角色表现尤其很多项目已经有了大量 2D 角色资源。而全 2D 地图做横向或者俯视背景只能靠多层视差模拟纵深碰到高低差、绕柱子、转角遮挡这类场景就非常吃力。2D 人物 3D 背景等于把两者优势拉满背景是真正的 3D 场景有真实的透视和遮挡人物用 2D 序列帧或者一张立绘美术成本低、动画量大也不怕。你在《原神》周边同人、《八方旅人》这类作品里看到的很多角色表现本质上也是“一张图贴在 3D 世界”的思路。Cocos Creator 2.4.10 虽然 3D 能力不如 3.x 完整但做一个这样的 2.5D 场景完全够用没必要为了一个背景跑到 3.x 去重构项目。1.2 透视视角为什么是体验核心这个 Demo 之所以要把相机开成 Perspective透视而不是 Ortho正交是因为正交相机下 3D 背景会被压得没有纵深人物和背景像两层皮贴在一起。透视相机有近大远小人物往远处跑会越来越小、往镜头跑会越来越大空间感完全不一样。透视相机的FOV是体验关键。我调了几轮最后锁定在 50 到 60 之间太大会让地图边缘变形严重太小透视又太弱像拿望远镜看。地面相机俯角我设在 30 到 45 度左右既能看清人物前方路线又不会因为俯角太直丢失远景层次。FOV、俯角、相机高度这三个参数建议在真机和编辑器里反复微调每个项目的场景体量不一样没有万能数值。2. 核心技术点解析纸片人、移动映射与镜头跟随2.1 纸片人的原理Quad 透明贴图 广告牌核心是让角色成为 3D 世界里的一张“面片”。Cocos Creator 2.4.10 场景里可以创建 3D 的 Quad 节点一个矩形平面我给这个 Quad 挂上一个带透明通道的材质把 2D 人物贴图贴上去然后在 Update 里每帧让这张面片朝向相机这就是俗称的 Billboard广告牌技术。这里有两个容易踩的坑。第一材质不能直接用默认标准材质否则人物周围会出现大片黑底后边我会单独讲材质参数。第二Quad 默认是单面渲染人物朝向相机那面必须是对的方向不然会看到人物消失或镜像翻转。广告牌代码我习惯放在lateUpdate里确保相机先完成移动和旋转人物再去对齐避免一帧内用旧相机参数计算出残影。动态序列帧动画也很好办本质就是定期替换 Quad 材质上的贴图。我准备一个 8 帧的走路序列数组每 0.1 秒切一张方向则由移动方向决定切换成正面、背面或者侧面的一组帧。这样 2D 动画师出的资源完全不用改直接进 3D 场景。2.2 从 2D 输入到 3D 移动屏幕方向与地面坐标换算人物在 3D 场景里移动跟 2D 游戏最大的不同是坐标系。2D 游戏里按方向键就直接往屏幕上下左右走3D 场景里如果直接拿输入向量当世界方向你就会发现按 W 人物往场景某个固定方向跑跟镜头转过去之后的感觉完全不同。正确做法是输入向量键盘 WASD是屏幕空间方向要把这个向量根据相机朝向转换到世界空间。具体来说取相机的worldForward投影到 XZ 平面作为“屏幕上方”取相机worldRight投影到 XZ 平面作为“屏幕右方”用这两个向量合成实际移动方向。这样不管相机怎么转按 W 永远是远离镜头移动按 S 永远是靠近镜头移动手感跟 2D 游戏完全一致。移动平面上的人物高度固定为 0这也是 Demo 里的约定。3D 地形的起伏暂时不管如果以后要爬山涉水需要改为“根据地面高度采样更新 Y”这边先不展开。2.3 透视相机的镜头跟随与参数取舍镜头跟随不能做得太死。人物一跑相机立刻瞬移过去画面会非常硬。我做了一个平滑跟随相机位置等于目标位置加上一个固定距离的偏移量然后对位置做指数插值或者 useVec3.moveTowards保证相机有一个轻微的滞后感。具体参数上我的偏移量是(0, 8, -8)也就是镜头在人物的后上方。Camera的near设 0.1far设 100防止近裁剪面裁掉人物、远裁剪面砍掉大远景。clearFlags设成天空盒颜色就好后面如果要加载真实地图再替换天空盒。这里还涉及一个深度问题人物作为 3D 节点会被 Cube 墙体、大树模型正常遮挡这是 3D 节点天然具备的能力。比如人物绕到一堵墙后边墙会把人物盖住绕出来又重新出现非常自然。这也是我认为纸片人方案优于“UI 层放角色”方案的最大原因。3. 从 0 搭建可运行的 Demo3.1 场景、灯光、材质与基础节点配置我先建一个 3D 场景然后按下面步骤搭节点新建空场景加一个Camera设为透视模式位置放在(0, 8, -8)角度看向原点。加一个Directional Light平行光角度稍微偏一点避免正面打平光导致纸片人细节看不清。创建一个Plane作为地面创建一个或几个Cube作为围墙、遮挡物用来测试人物绕遮挡。创建一个Quad重命名为 Player挂在场景根节点下稍微缩小到人物合适大小。材质部分很关键。我在资源管理器新建一个 MaterialShader 选择builtin-standard或者带透明渲染的 effect打开USE_ALPHA_TEST或者把材质混合模式改成透明。然后把人物贴图SpriteFrame 的纹理拖到材质的albedoMap槽位。Cocos 2.4.10 的默认标准材质会在USE_ALBEDO_MAP开启后把贴图叠加到基础色上不用额外调太多只要保证 alpha 通道被正确识别即可。如果发现人物周围有黑底说明 alpha test 没开启去材质细节里把 Alpha 模式改为 Test 或 Blend再用 Cutoff 值调一下阈值。3.2 人物控制器核心代码与动画切换逻辑控制器是整个 Demo 最核心的脚本我用 TypeScript 实现挂到 Player 节点上。它负责读取 WASD、合成世界方向移动、根据移动方向切换序列帧材质。import { _decorator, Component, Vec3, input, Input, EventKeyboard, KeyCode, Camera, Material, SpriteFrame, MeshRenderer, Texture2D } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { property({ type: Camera }) mainCamera: Camera | null null; property speed 5; property({ type: [SpriteFrame] }) frontFrames: SpriteFrame[] []; property({ type: [SpriteFrame] }) backFrames: SpriteFrame[] []; property({ type: [SpriteFrame] }) sideLeftFrames: SpriteFrame[] []; property({ type: [SpriteFrame] }) sideRightFrames: SpriteFrame[] []; private _keys: { [key: number]: boolean } {}; private _moveDir new Vec3(); private _frameIndex 0; private _frameTimer 0; private _material: Material | null null; onLoad() { input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this); input.on(Input.EventType.KEY_UP, this._onKeyUp, this); const mr this.getComponent(MeshRenderer); if (mr) { // 避免影响场景里其它用同一个材质的节点 this._material mr.material ? mr.material : null; } } onDestroy() { input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this); input.off(Input.EventType.KEY_UP, this._onKeyUp, this); } private _onKeyDown(evt: EventKeyboard) { this._keys[evt.keyCode] true; } private _onKeyUp(evt: EventKeyboard) { this._keys[evt.keyCode] false; } update(dt: number) { this._moveDir.set(0, 0, 0); if (this._keys[KeyCode.KEY_W]) this._moveDir.z - 1; if (this._keys[KeyCode.KEY_S]) this._moveDir.z 1; if (this._keys[KeyCode.KEY_A]) this._moveDir.x - 1; if (this._keys[KeyCode.KEY_D]) this._moveDir.x 1; if (this._moveDir.lengthSqr() 0) return; // 屏幕空间方向 - 世界平面方向 this._moveDir.normalize(); this._applyCameraTransform(this._moveDir); const dx this._moveDir.x * this.speed * dt; const dz this._moveDir.z * this.speed * dt; const pos this.node.position; this.node.setPosition(pos.x dx, pos.y, pos.z dz); this._playWalkFrame(dt); } /** 把输入方向根据相机角度旋转到世界平面 */ private _applyCameraTransform(dir: Vec3) { if (!this.mainCamera) return; const cameraNode this.mainCamera.node; const forward cameraNode.worldForward; const right cameraNode.worldRight; // 只取 XZ 平面的投影 const fProj new Vec3(forward.x, 0, forward.z).normalize(); const rProj new Vec3(right.x, 0, right.z).normalize(); const x dir.x, z dir.z; // 原本 dir 的 (x, z) 是相对屏幕坐标的偏移 const worldDir new Vec3(); Vec3.scaleAndAdd(worldDir, rProj, rProj, x); Vec3.scaleAndAdd(worldDir, fProj, fProj, -z); worldDir.normalize(); dir.set(worldDir.x, worldDir.y, worldDir.z); } private _playWalkFrame(dt: number) { this._frameTimer dt; if (this._frameTimer 0.1) return; this._frameTimer 0; const frames this._getFramesByMoveDir(); if (!frames || frames.length 0) return; this._frameIndex (this._frameIndex 1) % frames.length; const frame frames[this._frameIndex]; if (this._material) { const tex frame.texture as Texture2D; if (tex) { this._material.setProperty(albedoMap, tex); } } } private _getFramesByMoveDir(): SpriteFrame[] | null { if (!this.mainCamera) return null; const dir this._moveDir.clone(); const cam this.mainCamera; const forward cam.node.worldForward; const right cam.node.worldRight; const screenX Vec3.dot(dir, new Vec3(right.x, 0, right.z).normalize()); const screenZ Vec3.dot(dir, new Vec3(forward.x, 0, forward.z).normalize()); if (Math.abs(screenZ) Math.abs(screenX)) { return screenZ 0 ? this.frontFrames : this.backFrames; } else { return screenX 0 ? this.sideLeftFrames : this.sideRightFrames; } } }这里要提醒一句this._material.setProperty修改的是材质上的albedoMap如果场景里多个节点复用了同一个材质资源就会互相污染。建议在onLoad里用mr.material mr.material.instantiate()给当前节点克隆一份独立材质再操作。如果你的人物不需要换序列帧只想挂一张静态图那连_playWalkFrame都可以省掉。3.3 相机平滑跟随与广告牌逻辑相机跟随脚本我挂在 Camera 节点上。每帧取目标位置加上一个固定偏移量然后用 lerp 让位置慢慢逼近目标点。import { _decorator, Component, Vec3, Node } from cc; const { ccclass, property } _decorator; ccclass(CameraFollow) export class CameraFollow extends Component { property({ type: Node }) target: Node | null null; property offsetX 0; property offsetY 8; property offsetZ -8; property smooth 0.1; private _tempPos new Vec3(); lateUpdate(dt: number) { if (!this.target) return; const targetPos this.target.worldPosition; this._tempPos.set( targetPos.x this.offsetX, targetPos.y this.offsetY, targetPos.z this.offsetZ ); const camPos this.node.worldPosition; const lerpFactor 1 - Math.pow(1 - this.smooth, dt * 60); camPos.lerp(this._tempPos, lerpFactor); this.node.setWorldPosition(camPos); // 镜头始终看向目标 this.node.lookAt(targetPos); } }广告牌脚本挂在 Player 节点上让 Quad 始终正对相机。Quad 默认的正方向不一定是相机一侧所以要在保持竖直的前提下旋转 Y 轴import { _decorator, Component, Camera, Node } from cc; const { ccclass, property } _decorator; ccclass(BillboardToCamera) export class BillboardToCamera extends Component { property({ type: Camera }) mainCamera: Camera | null null; lateUpdate() { if (!this.mainCamera) return; this.node.lookAt(this.mainCamera.node.worldPosition); const euler this.node.eulerAngles; // 只保留 Y 轴旋转保证纸片人不会跟随镜头俯仰而后仰 this.node.setRotationFromEuler(0, euler.y, 0); } }这里有一个很常见的现象加了lookAt之后人物可能背对相机或者歪 90 度因为 Quad 的正方向和lookAt的默认朝向不一定一致。解决办法不是改算法而是在编辑器里先把 Player 节点的初始旋转试着调成 Y 轴旋转 90 度或 180 度再跑一下看看朝向。每种模型的建模方向不同以实际显示为准。4. 实战中遇到的问题与解决方案4.1 纸片人发黑、黑底、人物太小或太大纸片人发黑多半是材质没有处理光照。场景里没有加平行光或者方向不对标准材质算光照时默认没光线模型就黑了。这种情况最简单的方式是加一个 Directional Light或者改用一个不受光的 unlit 材质。Cocos 2.4.10 的标准材质也能通过把Albedo的强度调大、Emission初始化为一点自发光来做应急。黑底问题基本都是 alpha 没生效。材质要么开USE_ALPHA_TEST要么把渲染队列改成透明。透明贴图在引擎里经常是“彩图正常但通道没解析”检查贴图的Type是不是SpriteFrame并且确认你在项目设置里没有把贴图压缩改成不带 alpha 的格式。人物大小我是在Quad节点上直接调scale。建议把speed和角色 scale 做联动比如人物越大、移动速度越快不然大图小人跑看着会像“太空步”。我最后把人物 scale 调到(0.8, 0.8, 0.8)速度 5匹配 2D 素材原始像素密度后手感比较舒服。4.2 移动方向跟按键盘方向对不上这是伪 3D 移动最容易翻车的地方。方向对不上的原因几乎都是输入向量没有经过相机旋转直接拿去当世界方向用。比如相机从上方俯视时屏幕的“上”是世界的某个斜向按 W 应该往那个斜向走而不是往世界 Z 轴负方向走。换一个排查方法在 Update 中画一条调试线或者日志打印实际移动方向与相机 forward 的夹角。如果按 W 时移动方向不是相机 forward 的投影方向那就是_applyCameraTransform没生效检查 Camera 引用是否为空。还有一种特殊情况如果你的场景支持鼠标点击移动需要把屏幕点击坐标转成 3D 射线与地面交点再让角色走寻路过去。这个 Demo 里键盘移动不用射线但从 2D 输入到 3D 移动的核心思路是一样的——先转世界方向再沿平面 Move。4.3 遮挡层级与项目后续扩展方向很多刚开始做 2.5D 的朋友会问人物能不能被树、墙挡住答案是在这个纸片人方案里天然支持。因为人物就是 3D 世界里的一个节点深度缓冲会正常处理遮挡关系不需要手动改 zIndex。这一点比把人物放在 UI 层要实用得多。后续扩展方向比较顺的是把人物移动改为点击目的地寻路、给地面加碰撞体实现边界阻挡、用动画状态机管理站立/走路/攻击的切帧以及把单张人物立绘换成多个方向的多套序列帧。只要这一套跑通后面加地图、加 NPC 都只是资源量的问题。4.4 备选方案用 2D UI 层做人物适合快速原型如果觉得材质、Quad、广告牌这套太折腾还有一个更快的方案把人物做成 Canvas 下的Sprite放在 UI 层主相机只渲染 3D 背景另加一个 UI 相机渲染人物。每帧把人物在 3D 世界坐标通过Camera.worldToScreen转成屏幕坐标再设置 UI Sprite 的 position。这个方案的好处是实现简单人物动画直接用Animation/Spine/DragonBones贴图管理和 2D 游戏完全一致。缺点是做不了真实遮挡人物永远浮在背景前。想让树挡人的话只能自己按“人物被哪个区域遮挡”手动切 UI 层级或者做半透明遮罩模拟复杂度反而更高。我建议这个概念 Demo、原型阶段用一下可以正式项目如果对场景纵深有要求还是回到纸片人方案。我做这套 Demo 时踩得最深的一个坑是材质共享问题。复用一个材质资源导致所有纸片人角色同时切换到了同一帧动画排查了很久才发现是没做材质实例化。所以几个关键点再强调一遍Player 节点上的材质必须独立实例化、输入方向必须经过相机旋转、广告牌只保留 Y 轴旋转。按照这套配置跑通之后后续你想给人物加跳跃、加 NPC 对话、加地图切换都只是在既有骨架上填肉的事情。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门