Threejs 中秋佳节感受闽南名俗 | 中秋博饼🥮🥮🥮

1,414 阅读6分钟

中秋博饼是流行于福建省厦门市的传统民俗,国家级非物质文化遗产之一。

明月洒光,洒出人间无限浪漫;桂花飘香,飘出大地五彩缤纷;月饼圆圆,圆出家人欢声笑语;提前祝大伙祝你中秋快乐,永远平安。

今天让我们走进闽南的乡俗,一起使用Threejs实现 中秋博饼 游戏。

实现步骤

实现主要有以下几个步骤:

  1. 等待骰子、盆子模型加载
  2. 物理引擎场景模拟
  3. 3D场景物体与引擎物体 "绑定"
  4. 游戏规则

模型加载

3D 模型网站有很多,其中较为知名的是 Sketchfab ,其中有丰富的免费模型作为使用,并且有很多模型格式提供下载,如fbx、obj、gltf、glb等。

glTF与glb模型格式

gltf是JSON文件,里面包括了所有场景的信息,如scene、textures等,是常见的三维模型标准文件格式。 gltf的贴图、几何等资源可以直接通过base64的格式内联,也可指向外部的资源。所以gltf通常以文件夹的方式传输,里面含有需要外联的资源文件,

image.png

glb格式则是gltf的二进制版本,由于base64的编解码需要时间与空间,所以引用了glb格式文件。相同文件下,glb格式文件会比gltf文件更小一些,本文使用到的正是glb格式。

Threejs模型加载

Threejs包中 提供了 FBXLoader、OBJLoader、GLTFLoader等方式进行模型的加载,其中GLTFLoader可加载gltf与glb两种格式。用法非常简单,直接贴代码。

/**
 * 加载GLTF
 */

let loadGLTF = async (url): Promise<THREE.Group> => {
  return new Promise(resolve => {
    const loader = new GLTFLoader();
    loader.load(url, object => {
        resolve(object as THREE.Group);
    });
  });
};


/**
 * 初始化模型
 */

let loadModel = async () => {
  let basinModel = await loadGLTF("/basin2.glb");
  let diceModel = await loadGLTF("/dice.glb");

  // 加载色子
  dice = diceModel.scene.children[0];
  dice.scale.set(0.1, 0.1, 0.1);
  dice.traverse(function (child) {
    if (child.isMesh) {
      child.castShadow = true;
      child.material.metalness = 1;
      child.material.emissive = child.material.color;
      child.material.emissiveMap = child.material.map;
    }
  });

  // 加载盆子
  basin = basinModel.scene.children[0];
  basin.scale.set(18, 18, 18);
  basin.position.y = 0.7;
  basin.position.x = 0;
  scene.add(basin);
};

现在我们可以看到一个盆子在屏幕中

物理引擎场景模拟

Threejs的物理引擎我选择使用 cannon-es,主要是使用其中的碰撞和重力模拟。

文档的首页示例是偏简单的,我们首先需要初始化一个世界,然后设置他的重力方向与大小,并设置在物体静止的时候是睡眠状态的,减少计算

let world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
world.allowSleep = true;

然后我们进行物体的模拟,骰子我们可以使用引擎中提供的 Box 类来进行生成。

/**
 * 创建色子物理引擎函数
 */

const generateDiceBody = () => {
  const size = 0.1;
  const halfExtents = new CANNON.Vec3(size, size, size);

  const dice = new CANNON.Body({
    mass: 0.1,
    material: new CANNON.Material({
      friction: 0.1,
      restitution: 0.7
    }),
    shape: new CANNON.Box(halfExtents)
  });

  dice.sleepSpeedLimit = 1.0;
  world.addBody(dice);

  return dice;
};

我们使用了Body类生成World需要的主体,mass是物体质量,material是材质,由Material类生成,friction是摩擦力,restitution是弹性系数,可通过不断调整来达到最好的效果。

sleepSpeedLimit 属性是刚体进入SLEEP状态的判断条件,当速度此值则进入睡眠状态。

盆子模拟

cannon-es文档中提供了以下方式生成形状

Box、Sphere、Plane都是 threejs 熟悉的几何体无法生成盆子这种凹面体,ConvexPolyhedron是凸多面体,Particle是粒子,显然都无法实现。

只剩下Trimesh修剪网格,用法是用顶点数据进行生成,模型中正好有这些信息。

正当我写完以后以为可以大功告成时,运行一看却发现碰撞效果失效,骰子直接从盆子传过去。 百思不得其解之时,在github提的issue中看到以下回答

image.png

原来修建网格不支持碰撞!!!!

好好好,你这样整是吧,行行行。

现在只剩下Heightfield高度场了,高度场是通过提供的高度数据来生成形状。我们通过勾股定理判断X、Z是否在圆内,如果在则高度为-1.7,否则为0,这样我们通过Heightfield类来生成凹面体的形状。

/**
 * 初始化盆子刚体
 */

const initBasin = () => {
  const numRows = 60;
  const numCols = 60;
  let heights: number[][] = [];
  for (let i = 0; i < numRows; i++) {
    const row: number[] = [];
    for (let j = 0; j < numCols; j++) {
      const x = (j / (numCols - 1) - 0.5) * 20;
      const z = (i / (numRows - 1) - 0.5) * 20;
      const radius = 4.2; 
      const height = Math.sqrt(x * x + z * z) <= radius ? -1.7 : 0;
      row.push(height);
    }
    heights.push(row);
  }

  const heightfieldShape = new CANNON.Heightfield(heights, {
    elementSize: 0.2
  });
  const heightfieldBody = new CANNON.Body({
    mass: 0,
    material: new CANNON.Material({
      friction: 0.1,
      restitution: 0.7
    })
  });
  heightfieldBody.addShape(heightfieldShape);
  heightfieldBody.position.set(-5.9, 1.3, 5.9);
  heightfieldBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
  world.addBody(heightfieldBody);
};

在写文章的时候突然想到其实并不需要借助物理引擎,碰撞可以通过八叉树Octree进行检测,重力可以自己模拟,有兴趣的小伙伴可以试试看

这里也不得不抱怨一下cannon-es,首先没有中文文档,二来是使用人数也相对较少,遇到问题解决需要花很长的时间来找答案。

3D场景物体与引擎物体 "绑定"

我们定义一个对象dices保存骰子的引擎刚体和threejs物体的信息。

let  dices: { tDice?: THREE.Object3D; cDice?: CANNON.Body }[] = [];

/**
 * 初始化色子位置
 */

let initDice = () => {
  for (let i = 1; i <= 6; i++) {
    let cDice = generateDiceBody();
    cDice.quaternion.setFromEuler(Math.PI / 2, 0, 0);
    let tDice = dice.clone();
    scene.add(tDice);
    dices.push({ tDice: tDice, cDice: cDice });
  }
};

然后将引擎刚体的旋转位移赋值给threejs物体

 dices.forEach(({ cDice, tDice }) => {
    if (cDice && tDice) {
      tDice.position.copy(cDice.position);
      tDice.quaternion.copy(cDice.quaternion);
    }
 });

至此,3D场景就已经搭建好了,接下来就到了游戏逻辑的处理。

游戏规则

游戏规则如下图

image.png

要想知道博的什么名首先需要获得每个骰子的点数,我们可以通过每个方向的旋转角度与朝上初始面来得出

/**
 * 获取色子点数
 */

let getDicePoints = () => {
  let points: number[] = [];
  dices.forEach(({ cDice, tDice }) => {
    let xAngle = Math.round((tDice!.rotation.x / Math.PI) * 180);
    let zAngle = Math.round((tDice!.rotation.z / Math.PI) * 180);

    let point;

    if (xAngle == -90) {
      point = 1;
    } else if (xAngle == 90) {
      point = 5;
    } else if (xAngle + zAngle == -90 || xAngle + zAngle == 270) {
      point = 4;
    } else if (xAngle + zAngle == 0 || xAngle + zAngle == 360) {
      point = 3;
    } else if (xAngle + zAngle == 180 || xAngle + zAngle == -180) {
      point = 6;
    } else {
      point = 2;
    }

    points.push(point);
  });

  return points;
};

得到每个骰子的点数后,我们需要根据规则来看博饼结果。

const getName = () => {
  let obj = {};

  result.value.forEach(index => {
    if (obj[index]) {
      obj[index] += 1;
    } else {
      obj[index] = 1;
    }
  });

  if (obj[4] == 1) {
    if (Object.keys(obj).length === 6) {
      return "对堂";
    } else {
      return "一秀";
    }
  } else if (obj[4] == 2) {
    return "二举";
  } else if (obj[4] == 3) {
    return "三红";
  } else if (obj[4] == 4) {
    if (obj[2] == 2) {
      return "状元插金花";
    } else {
      return "状元";
    }
  } else if (obj[4] == 5) {
    return "五王";
  } else if (obj[4] == 6) {
    return "六捧红";
  } else if (Object.keys(obj).some((key)=> obj[key] == 4)) {
    return "四进";
  } else if (obj[6] == 5) {
    return "五子登科";
  } else if (obj[6] == 6) {
    return "手捧黑";
  }

  return "再接再厉";
};

result 是一个数字元组,保存每个骰子的点数

let result: Ref<number[]> = ref([]);

我们需要每个点 与 每个点出现的次数,通过上面的函数得到以下数据结构

{1:1,2:1,3:4}  // key = 点数, value = 出现次数

然后就可以通过规则来得出博饼结果。

以上就是这个小游戏的核心,完结啦!

最终效果:

2.gif

是不是非常nice ~

最后

我们通过本文学习到了以下知识

  1. 模型知识、下载地址、gltf与glb的区别等
  2. 物理引擎的使用与简单形状和复杂形状的刚体模拟 (高度场)
  3. 博饼游戏规则。

相信大家在看完这篇文章后,能够对博饼、Threejs 都有了深刻的理解,也相信你们能够实现更棒的效果。

最后球球大家点点赞和收藏一下吧,球球了