注册
web

手把手使用Blender+ThreeJS制作跨栏小游戏

效果展示



  • 先录制的视频,再转化为GIF图片导致展示效果有点延迟,实际效果还是挺丝滑的,感兴趣的可以上手尝试一下
    demo.gif

人物模型和动画获取



  • mixamo.com网站,需要先登录一下,可以直接使用Google邮箱登录,然后来到Characters页,下方有100多种人物模型,点击左边卡片可以选择自己喜欢的人物模型,或者如下图和我选的一样
    image.png
  • 然后来到Animations页,默认如下图红框内展示的也是刚才我们选择的人物模型,如果右侧展示不对,需要回到Characters页重新选择人物模型
    image.png
  • 因为动画比较多,这里我们直接在左上角搜索框内搜索自己想要的动作动画即可,首先搜索Idle,我这里使用的动画是Happy Idle,还将右侧的Overdrive的值从50调整到75,值越大动画频率越快,调整好后直接点击右上方的DOWNLOAD
    image.png
  • 弹出的弹窗里的内容都不想要修改,直接点击如下图右下角的DOWNLOAD,等待一会后,选择本地文件夹下载即可
    image.png
  • 接着左上角搜索Running,选择Running卡片,并且勾选右侧的In Place,让人物模型在原地跑动;如果不勾选的话,因为动画本身带有位移,会影响我们使用ThreeJS改变人物模型的position控制人物位移的;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png
  • 继续左上角搜索Jump,选择Jump卡片,并且勾选右侧的In Place,让人物模型在原地跑动;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png
  • 继续左上角搜索Death,选择Falling Back Death卡片,这个动画不需要其他调整,直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png
  • 这样就下载好了Idle(待机动作)、Running(跑步动作)、Jump(跳跃动作)、Death(死亡动作)的一组动作,以上动作的都可以根据个人喜好调整;如果打开mixamo.com网站比较慢或者下载有问题的话,也可以直接使用我下载好的actions里的动画模型
    image.png

动画模型合并



  • 打开Blender新建文件选择常规,我使用的版本是3.6.14的,不同版本可能存在差异
    image.png
  • 在右上角场景集合内,鼠标左键拖拽框选默认已有的Camera、Cube和Light,然后右键选择删除或者英文输入法下按下x键快速删除;后续在Blender里的所有操作,均需将输入法切换到英文输入;如果对Blender不是特别熟悉,可以优先阅读我之前整理的Blender学习整理这篇文章
    image.png
  • 选择左上角菜单栏里文件-->导入-->FBX(.fbx),就会弹出导入设置弹窗
    image.png
  • 弹窗内只需选中之前下载好的Idle.fbx文件,然后直接点击右下角导入FBX即可
    image.png
  • 文件导入后,在右上角场景集合内,将动画下一级的目录鼠标双击重命名成idle
    image.png
  • 在中间布局窗口,将右上角的视图着色方式切换到材质预览,然后把观察点切换到-X方向,将下方窗口切换到非线性动画
    image.png
  • 点击动画右侧的按键,作用是下推动作块(将动作块作为新的片段下推到NLA堆栈顶部),有点类似展开下拉菜单的效果,实际作用是创建NLA轨道,然后将idle动作放到一个单独的通道中
    image.png
  • 取消勾选通道对结果是否有影响(切换通道是否启用)
    image.png
  • 导入Running.fbx,导入流程和上述一致,导入后在右上角场景集合内重命名动画下一级目录为running
    image.png
  • 同样点击动画右侧的按键,下推动作块
    image.png
  • 同样取消勾选通道对结果是否有影响(切换通道是否启用)
    image.png
  • 鼠标点击选中idle动作,变成如下图颜色即被选中
    image.png
  • 选择上方添加-->添加动作片段
    image.png
  • 然后选中running
    image.png
  • 这时,在idle动作上方就添加了一个running动作通道,双击左侧如下图红框内重命名为running,左侧的名称就是我们后续使用ThreeJS控制人物模型切换动画时要使用的动作变量名
    image.png
  • 在右上角场景集合内,选中后面导入的模型右键后点击删除或者按下x键快速删除,将其删除
    image.png
  • 第一次删除可能删除不干净,场景集合内还有多余的文件,布局窗口也有遮挡物,这个遮挡物其实就是第二个模型里的人物建模,第一次只是把动画给删除掉了,所以需要再次如下图选中右侧红框内文件继续删除才能删除干净;注意删除的时候最好把导入的第一个模型收起来,防止误删
    image.png
  • 如上述操作后,就把idle动画和running动画合并到一个人物模型里,再重复上述操作,把Jump.fbxDeath.fbx文件依次导入进来,并且把jump动画和death动画添加到idle动画上方,每个动作一个通道;全部搞定后,可以点击如下图红框内的五角星,实心就代表被选中,依次选中每个动作,取消勾选通道影响,还可以按下键盘的空格播放各个动画
    image.png
  • 最后选择左上角文件-->导出-->glTF2.0(.glb/.gltf),会弹出导出设置弹窗
    image.png
  • 弹窗内选择本地合适的文件夹,直接点击右下角的导出glTF2.0即可
    image.png
  • 可以参考我仓库里的models里的actions.glb
    image.png

跨栏和跑道模型获取



  • sketchfab.com网站,首先需要先登录,同样可以使用Google邮箱登录,然后在搜索框输入hurdle,按下回车
    image.png
  • 然后可以在下方很多的跨栏模型中选择自己喜欢的,我这里选择的是Wii - Wii Play - Hurdle
    image.png
  • 点击模型左下角的Download 3D Model,会弹出下载选项弹窗
    image.png
  • 弹窗里选择下载GLB格式的文件,这个格式会将所有内容(包含模型的结构、材质、动画和其他元数据等)打包在一起,文件大小可能会更大,但管理方便,所有内容都在一个文件中
    image.png
  • 下载到本地后,重命名为hurdle.glb
    image.png
  • 同样的方式,搜索track,我这边使用的模型是Dusty foot path way in grass garden,忘记了当时的筛选条件了,怎么就在搜索track的时候找到了这个模型;因为需要游戏里面的跑道不间断的出现,所以需要这种能够重复拼接的跑道模型,大家也可以自行选择喜欢的模型,下载好后,本地重命名为track.glb
    image.png
  • 如果访问sketchfab.com网站比较慢,或者下载有问题的话,可以直接使用我仓库里的models里的hurdle.glbtrack.glb
    image.png

模型合并



  • 为了和之前的内容不搅合,就不在之前的actions.blender文件里添加其他两个模型,这里使用Blender新建一个常规文件,同样删除默认的Camera、Cube和Light
    image.png
  • 选择左上角文件-->导入-->glTF2.0(.glb/.gltf),会弹出导入弹窗
    image.png
  • 选择之前保存的actions.glb,直接点击右下角的导入glTF2.0即可
    image.png
  • 导入后,同样把视图着色方式切换到材质预览,观察点切换到—X方向上
    image.png
  • 在右上角的场景集合内,将当前模型重命名为acitons
    image.png
  • 导入下载好的hurdle.glb,在右上角的场景集合内,刚导入的模型只有最里面的Object_2才是真的跨栏网格对象,外面两层结构没有作用,还可能出现外层结构的旋转缩放等属性和内部实际的网格对象的属性不一致,影响我们对实际网格对象的控制,所以最好删除掉
    image.png
  • 依次选中外面两层文件,按下x键删除,最后把Object_2重命名为hurdle
    image.png
  • hurdle模型尺寸比较大,旋转方向也不对,鼠标左键选中hurdle模型,然后在属性栏,将旋转的X调整成-1.0,将缩放的XYZ全部调整成0.1,这里不需要调整的特别精确,后续编码时还能使用ThreeJS继续调整模型的旋转和尺寸;如果布局窗口没有属性栏,可以按下n键显示/隐藏属性栏;输入值时,鼠标拖动三个输入框能够同时改变XYZ的值
    image.png
  • 继续选中hurdle模型,按下ctrl + a然后选择全部变换,需要把之前调整的旋转和缩放应用为自身数据
    image.png
  • 导入下载好的track.glb,可以切换右侧红框的眼睛图标显示/隐藏该对象来观察整体模型变化,会发现实际起作用的是Object_2Object_3的两个网格对象,两个外层结构的显示/隐藏看似对模型的显示没有影响,其实它们是有属性是对模型有影响的,需要把它们的属性变换应用到自身
    image.png
  • 在右上角场景集合选中第一层结构,在属性栏会发现它有一些旋转和缩放,在布局窗口按下ctrl+a,然后选择应用全部变换;再选中第二层结构,同样按下ctrl+a应用全部变换
    image.png
  • 这时就会发现,外层的旋转和缩放都已经作用到Object_2Object_3的两个网格对象上了,就可以依次选中两个外层结构,按下x键删除两个外层结构了;并且依次选中Object_2Object_3的两个网格对象按下ctrl+a选择全部变换把外层结构的旋转和缩放应用到自身
    image.png
  • 然后在右上角场景集合内,鼠标先选中Object_3,按下shift键再选中Object_2,然后鼠标回到布局窗口,按下ctrl+p选择物体,这里发现Object_2变成了Object_3的子级了,理论上最后被选中的对象是父级,我想要的效果是Object_3变成了Object_2的子级,所以这里我又撤销(ctrl+z)重新先选中Object_2,按下shift键再选中Object_3,再按下ctrl+p选择物体绑定的父子关系;这里不清楚是因为我使用中文翻译的问题还是Blender有更新,有了解的大佬希望帮忙解释一下
    image.png
  • 将合并后的父级Object_2重命名为track
    image.png
  • 左键选中track模型,右键选择设置原点-->原点->几何中心
    image.png
  • 在选中track模型的情况下,继续按下shift+s选择选中项->游标,如果游标没有在世界原点,需要先将游标设置到世界原点
    image.png
  • 将观察点切换到顶视图,按下r+z绕着Z轴旋转,使跑道的长边和Y轴平行
    image.png
  • 再切换到-X方向,按下r+x绕着X轴旋转,使跑道的上面和Y轴平行
    image.png
  • 选择左上角文件-->导出-->glTF2.0(.glb/.gltf)
    image.png
  • 导出设置弹窗内,直接将合并后的模型导出到我们后续编码要用的文件夹内,其他无需设置,直接选择导出glTF2.0即可
    image.png

编码渲染



  • 使用vite搭建的项目工程,完整源码点这里code,整个渲染过程都在src/hooks/userDraw.js文件里
    image.png
  • 首先是ThreeJS的基础代码,放到了src/hooks/modules/base.js文件里,我这里只是往scene添加了一个背景纹理,最后就是把scenecamerarenderer暴露出来方便其他模块引用

import * as THREE from 'three';

/**
* 基础代码
*/

export default function () {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 0, 5); // 设置相机位置
const renderer = new THREE.WebGLRenderer({
antialias: true // 开启抗锯齿
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 加载背景纹理
const textureLoader = new THREE.TextureLoader();
textureLoader.load('./bg.jpeg', function (texture) {
// 将纹理设置为场景背景
scene.background = texture;
});

// 适配窗口
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; // 重置摄像机视锥体的长宽比
camera.updateProjectionMatrix(); // 更新摄像机投影矩阵
renderer.setSize(window.innerWidth, window.innerHeight); // 重置画布大小
});

return {
scene,
camera,
renderer
};
}


  • src/hooks/modules/controls.js文件里添加控制器,并且需要禁用控制器

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/**
* 控制器
*/

export default function (camera, renderer) {
const orbitControls = new OrbitControls(camera, renderer.domElement); // 轨道控制器
orbitControls.enabled = false; // 禁用控制器
orbitControls.update(); // 更新控制器
}


  • src/hooks/modules/light.js文件里添加环境光,上下方各添加了一个平行光

import * as THREE from 'three';

/**
* 灯光
*/

export default function (scene) {
const ambientLight = new THREE.AmbientLight(0x404040, 20); // 环境光
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight.position.set(0, 10, 5);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight2.position.set(0, -10, -5);
scene.add(directionalLight2);
}


  • src/hooks/userDraw.js文件没有进一步优化,下面主要介绍几个核心部分

    • 把之前合并好的模型,使用GLTFLoader加载进来,然后初始化人物、跨栏、跑道等模型;因为模型的XYZ轴向和ThreeJS的没有对应上,所以需要给各个模型创建一个Gr0up方便单独控;获取跑道宽度时,因为获取的不是特别准确,所以又减去了2,让跑道叠加在一起了,不是特别严丝合缝;然后就是创建动画混合器,把动作保存起来了,然后默认播放待机动作;最后开始帧循环渲染

    // 加载人物模型
    const loader = new GLTFLoader();
    loader.load('./models/group.glb', function (gltf) {
    const children = [...gltf.scene.children];

    // 初始化人物模型
    global.characterGr0up.add(children[0]);
    global.characterGr0up.rotation.set(0, Math.PI / 2, 0); // 改变人物朝向
    scene.add(global.characterGr0up);

    // 初始化跨栏模型
    global.hurdleGr0up.add(children[1]);
    global.hurdleGr0up.scale.set(0.7, 0.7, 0.7); // 缩小跨栏
    global.hurdleGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跨栏朝向
    global.hurdleGr0up.position.set(3, 0, 0); // 设置第一个跨栏位置
    global.hurdleArr.push(global.hurdleGr0up); // 添加第一个跨栏触发碰撞检测
    scene.add(global.hurdleGr0up);

    // 初始化跑道模型
    global.trackGr0up.add(children[2]);
    global.trackGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跑道朝向
    scene.add(global.trackGr0up);

    // 获取跑道宽度
    const boundingBox = new THREE.Box3().setFromObject(global.trackGr0up); // 创建包围盒
    const size = new THREE.Vector3(); // 计算包围盒的尺寸
    boundingBox.getSize(size);
    global.trackWidth = size.x - 2; // 跑道宽度不是特别准确,需要模糊计算
    // 默认使用trackNum个跑道拼接
    for (let i = 0; i < trackNum; i++) {
    const newTrackModel = global.trackGr0up.clone(); // 克隆原始跑道模型
    newTrackModel.position.x = i * global.trackWidth; // 按照宽度依次排列
    scene.add(newTrackModel);
    global.trackArr.push(newTrackModel); // 保存引用
    }

    // 创建动画混合器
    global.animationMixer = new THREE.AnimationMixer(global.characterGr0up);

    // 将每个动画剪辑存储在actions对象中
    gltf.animations.forEach((clip) => {
    global.actions[clip.name] = global.animationMixer.clipAction(clip);
    });

    // 播放默认的 idle 动作
    global.currentAction = global.actions['idle'];
    global.currentAction.play();

    // 开始渲染循环
    animate();
    });


    • animate函数里主要功能有开启帧循环;更新动画混合器;在跳跃动作结束后切换回跑步动作;当人物处于跑步和跳跃动作时,更新人物位置及让相机跟随人物移动,并且在移动过程中,在间隔帧数内生成新的跨栏;更新跑道的位置,如果最左侧的跑道超出屏幕后,把它移动到最右侧;当人物处于跳跃动作时,更新人物Y轴位置;如果人物和跨栏发生碰撞时,切换到死亡动作并且开启死亡状态,防止键盘按键还能继续触发;当播放完死亡动作后,提示游戏结束,并结束帧数循环;渲染场景

    function animate() {
    global.frame = requestAnimationFrame(animate); // 开启帧循环

    global.animationMixer.update(global.clock.getDelta()); // 更新动画混合器

    // 检查 jump 动作是否完成,并恢复到 running 动作
    if (
    global.currentAction === global.actions['jump'] &&
    global.currentAction.time >= global.currentAction.getClip().duration
    ) {
    switchAction('running', 0.3);
    }

    // 当处于 running 动作时,移动相机
    if (
    global.currentAction === global.actions['running'] ||
    global.currentAction === global.actions['jump']
    ) {
    global.characterGr0up.position.x += moveSpeed;
    camera.position.x = global.characterGr0up.position.x;

    // 间隔随机帧数生成跨栏
    if (
    global.hurdleCountFrame++ >
    hurdleInterval + Math.random() * hurdleInterval
    ) {
    generateHurdles(global.hurdleGr0up, global.hurdleArr, scene); // 生成跨栏
    global.hurdleCountFrame = 0;
    }
    }

    // 更新跑道位置
    updateTrack(camera, global.trackArr, global.trackWidth);

    // 当人物处于跳跃动作时,更新人物位置
    updateCharacterPosition(
    global.animationMixer,
    global.clock,
    global.currentAction,
    global.actions,
    global.characterGr0up
    );

    // 碰撞检测
    if (
    checkCollisions(
    global.characterGr0up,
    global.characterBoundingBox,
    global.hurdlesBoundingBoxes,
    global.hurdleArr
    )
    ) {
    switchAction('death');
    global.isDeath = true;
    }

    // 如果 death 动作完成了,则停止帧动画
    if (
    global.currentAction === global.actions['death'] &&
    !global.currentAction.isRunning()
    ) {
    Modal.error({
    title: 'Game Over',
    width: 300
    });
    cancelAnimationFrame(global.frame);
    }

    // 渲染场景
    renderer.render(scene, camera);
    }


    • 切换动作函数主要是在一定时间内淡出前一个动作,并且淡入新动作,如果是跳跃动作或者死亡动作的话只执行一次

    function switchAction(newActionName, fadeDuration = 0.5) {
    const newAction = global.actions[newActionName];
    if (newAction && global.currentAction !== newAction) {
    global.previousAction = global.currentAction; // 保留当前的动作
    // 淡出前一个动作
    if (global.previousAction) {
    global.previousAction.fadeOut(fadeDuration);
    }

    // 如果切换到 jump 动作,设置播放一次并在结束后停止
    if (newActionName === 'jump') {
    newAction.loop = THREE.LoopOnce;
    newAction.clampWhenFinished = true; // 停止在最后一帧
    }

    // 如果切换到 death 动作,设置播放一次并在结束后停止
    if (newActionName === 'death') {
    newAction.loop = THREE.LoopOnce;
    newAction.clampWhenFinished = true; // 停止在最后一帧
    }
    global.currentAction = newAction; // 设置新的活动动作

    // 复位并淡入新动作
    global.currentAction.reset();
    global.currentAction.setEffectiveTimeScale(1);
    global.currentAction.setEffectiveWeight(1);
    global.currentAction.fadeIn(fadeDuration).play();
    }
    }


    • 键盘事件监听,给按键WSAD和方向键上下左右都添加了切换动作的功能,如果是死亡状态的,按键失效

    window.addEventListener('keydown', (event) => {
    if (global.isDeath) {
    return;
    }
    switch (event.code) {
    case 'keyD':
    case 'ArrowRight':
    switchAction('running');
    break;
    case 'keyA':
    case 'ArrowLeft':
    switchAction('idle');
    break;
    case 'keyW':
    case 'ArrowUp':
    switchAction('jump');
    break;
    }
    });


  • src/configs/index.js文件配置了一些常量,可以用来控制游戏状态

// 初始跑道数量
export const trackNum = 3;

// 跨栏之间的间隔帧数
export const hurdleInterval = 50; // 50~100帧之间

// 跨栏之间的间隔最小距离
export const hurdleMinDistance = 5; // 5~10距离之间

// 人物移动的速度
export const moveSpeed = 0.03;


  • src/utils/index.js文件主要是一些辅助函数

import * as THREE from 'three';
import { hurdleMinDistance } from '../configs/index';

/**
* 生成新的跨栏
*
* @param {Object} oldModel - 要克隆的原始跨栏模型。
* @param {Array} hurdleArr - 现有跨栏模型的数组。
* @param {Object} scene - 要添加新跨栏模型的场景。
* @return {undefined}
*/

export function generateHurdles(oldModel, hurdleArr, scene) {
const newModel = oldModel.clone(); // 克隆原始跨栏模型

const nextPosition =
hurdleArr[hurdleArr.length - 1].position.x +
hurdleMinDistance +
Math.random() * hurdleMinDistance;

newModel.position.set(nextPosition, 0, 0);
hurdleArr.push(newModel);
scene.add(newModel);
}

/**
* 更新跑道位置
*
* @param {Object} camera - 具有位置属性的摄像机对象。
* @param {Array} trackArr - 具有位置属性的轨道段对象数组。
* @param {Number} trackWidth - 每个轨道段的宽度。
* @return {undefined}
*/

export function updateTrack(camera, trackArr, trackWidth) {
const cameraPositionX = camera.position.x; // 相机的 x 坐标
// 遍历所有跑道段
for (let i = 0; i < trackArr.length; i++) {
const trackSegment = trackArr[i];
// 提前检测跑道段是否即将超出视野(增加一个提前量,比如半个跑道段的宽度)
const threshold = cameraPositionX - trackWidth * 1.5;
if (trackSegment.position.x < threshold) {
// 找到当前最右边的跑道段
let maxX = -Infinity;
for (let j = 0; j < trackArr.length; j++) {
if (trackArr[j].position.x > maxX) {
maxX = trackArr[j].position.x;
}
}
// 将当前跑道段移动到最右边
trackSegment.position.x = maxX + trackWidth;
}
}
}

/**
* 人物跳跃时,更新人物Y轴位置
*
* @param {Object} animationMixer - 动画混合器对象。
* @param {Object} clock - 用于获取增量时间的时钟对象。
* @param {Object} currentAction - 当前正在执行的动作。
* @param {Object} action - 可用动作的集合。
* @param {Object} characterGr0up - 角色组对象。
* @return {undefined}
*/

export function updateCharacterPosition(
animationMixer,
clock,
currentAction,
actions,
characterGr0up
) {
// 更新动画混合器
animationMixer.update(clock.getDelta());

// 检查动画状态并调整位置
if (currentAction === actions['jump']) {
// 根据跳跃动画的时间调整人物位置
const jumpHeight = 0.8; // 你可以调整这个值
characterGr0up.position.y =
Math.sin(currentAction.time * Math.PI) * jumpHeight;
} else {
characterGr0up.position.y = 0; // 恢复到地面位置
}
}

/**
* 检测人物是否与跨栏发生了碰撞
*
* @param {Object} characterGr0up - 角色组对象。
* @param {Object} characterBoundingBox - 角色的边界框对象。
* @param {Array} hurdlesBoundingBoxes - 跨栏的边界框数组。
* @param {Array} hurdleArr - 跨栏对象数组。
* @return {Boolean} 是否发生了碰撞。
*/

export function checkCollisions(
characterGr0up,
characterBoundingBox,
hurdlesBoundingBoxes,
hurdleArr
) {
// 更新人物的边界框
if (characterGr0up) {
characterBoundingBox.setFromObject(characterGr0up);
}

// 更新跨栏的边界框
hurdlesBoundingBoxes = hurdleArr.map((hurdle) => {
const box = new THREE.Box3();
box.setFromObject(hurdle);
return box;
});

for (let i = 0; i < hurdlesBoundingBoxes.length; i++) {
if (characterBoundingBox.intersectsBox(hurdlesBoundingBoxes[i])) {
return true; // 检测到碰撞
}
}
return false; // 没有检测到碰撞
}

不足



  • 跑道的纹理和材质没有渲染出来,不知道是否是导出的模型有问题,有懂的大佬可以帮忙看看
  • 目前合并后的模型导出后体积比较大,还需要解决模型压缩的问题

作者:LoveDreaMing
来源:juejin.cn/post/7405153695506022451

0 个评论

要回复文章请先登录注册