Three.js打造3d月饼生成器

1,718 阅读13分钟

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

用three.js造个月饼生成器

前言

之前一直没有接触过threejs这个库,只是听说过挺强大,正好赶上中秋这个活动,就用拿threejs做个月饼生成器练练手体验一下web端是如何制作3d效果的,先从基本的图形下手,大概熟悉一下基本的构建流程。

效果概览

预览地址nabaonan.gitee.io/mooncake/

初版还略显简陋,还在不断完善中。。。

技术栈

主要采用主流的vite+vue3操作页面数据,threejs为核心3d构建

设计思路

决定采用面向对象的思想设计,便于理解和管理,主要功能分为以下几个部分

  • 设计三个形状的月饼,分为标准形状,多角形,还有圆形
  • 制作一个月饼制造器,使用这个制造器,根据参数不同生成不同的月饼,就是个简单工厂的方法
  • 设计一个月饼盒的类,用来装月饼,一个月饼一个小方盒,有数量限制,不可无休止添加

绘制实现

定义基本接口

定义这个接口,主要包括月饼类需要包括的通用的方法,比如创建 和销毁方法,还有更改颜色,更改图案的方法。确定一些基本能力 具体实现如下:

export interface IMooncake {
  group: Group;
  changeColor(color: number): void;
  changeTexture(url: string): void;
  name: string;
  destroy(): void;
  create(): Mesh;
}

定义一个基础抽象类

此类主要实现月饼接口,并提取一些通用的方法以便子类继承,比如销毁和改变纹理图案的方法,由于子类更改纹理的方式基本都一样,所以抽取到基础类,如果有不同则在子类里覆盖,同时,基础类还定义了通用的属性,如高度和半径,颜色名称等,而不必再每个子类里重新定义 代码如下:

abstract class BaseMoonCake implements IMooncake {
  name: string;
  group: Group;
  height: number;
  r: number;
  color: number;
  texture: string | undefined;
  moonCake: Mesh | undefined;
  constructor(params: {
    name: string;
    height: number;
    r: number;
    color: number;
    texture?: string;
  }) {
    const { name, height, r, color, texture } = params;
    this.group = new Group();
    this.name = name;
    this.texture = texture;
    this.height = height;
    this.r = r;
    this.color = color;
  }
  abstract changeColor(color: number): void;
  changeTexture(url: string): void {
    const { color, moonCake } = this;
    //增加纹理
    const loader = new TextureLoader();
    loader.load(
      url,
      (texture) => {
        const material = new MeshLambertMaterial({
          color: new Color(color),
          map: texture, //设置纹理贴图
        });

        texture.needsUpdate = true;
        if (moonCake) {
          console.log('render');
          moonCake.material = material;
        }
        render();
      },
      undefined,
      function (err) {
        console.error('发生错误');
      }
    );
  }
  destroy(): void {
    // throw new Error('Method not implemented.');
    scene.remove(this.group);
    render();
  }
  abstract create(): Mesh<BufferGeometry, Material | Material[]>;
}

export default BaseMoonCake;

定义场景

场景是用来装绘制的东西,并且要用一个renderer对象绘制3d图形,由于我只需要有一个场景,并且多个图形要同时绘制到同一个场景中,所以我定义了一个hooks函数便于其他对象复用场景对象的方法 这里主要包括一个场景,摄像机,和一个renderer方法,为了便于更好的观看位置,所以添加一个辅助坐标系,通过THREE.AxesHelper进行创建,传入的参数是坐标系的边界,也就是x(红色),y(绿色),z(蓝色)坐标轴的最长长度。

export function useThree(): {
  scene: Scene;
  camera: Camera;
  renderer: WebGLRenderer;
} {
  if (!init) {
    scene = createScene();
    camera = createCamera();
    renderer = createRenderer();
    // addPlane();
    addAxis();
    render();

    init = true;
  }

  return {
    scene,
    camera,
    renderer,
  };
}


function createScene(): Scene {
  console.log('创建场景');
  const scene = new THREE.Scene(); //创建场景
  scene.background = new THREE.Color(0xcce0ff);
  return scene;
}
function createCamera() {
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    500
  ); //摄像机
  camera.position.z = 20;
  return camera;
}

function addAxis() {
  //坐标系
  const axishelper = new THREE.AxesHelper(100);
  scene.add(axishelper);
}
export function render() {
  renderer.render(scene, camera);
}

要在页面展示渲染后的图形,就需要将创建出的renderer中的dom对象添加到页面的div中 所以要在onMounted时候进行添加 操作如下:

const { renderer } = useThree();
const threeContainer = ref();
onMounted(() => {
  threeContainer.value.appendChild(renderer.domElement);
});

为了便于手动操作旋转对象,需要引入一个控制器OrbitControls,这个控制器没有在主类中,而是在examples这个文件夹中,引入方法

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

代码如下:

export function controls() {
  const controls = new OrbitControls(camera, renderer.domElement); //拖拽旋转
  controls.addEventListener('change', render);
  controls.enableDamping = true;
  //动态阻尼系数 就是鼠标拖拽旋转灵敏度
  controls.dampingFactor = 0.5;
  //是否可以缩放
  controls.enableZoom = true;
  //是否自动旋转
  controls.autoRotate = false;
  //设置相机距离原点的最远距离
  controls.minDistance = 10;
  //设置相机距离原点的最远距离
  controls.maxDistance = 50;
  //是否开启右键拖拽
  controls.enablePan = true;
}

注意:这个方法需要传入dom对象,所以放到了onMounted中执行初始化

标准月饼实现

简要说明

传统意义的月饼,标准的,就是中间由一个多边形,四周围是由若干个半圆形组成的,如下所示 97ff0eeb-8ff1-4297-97a3-afd903c5d87c.png

实现

主要分为以下几步

  • 创建定义一个叫MooncakeStand的类继承基础类BaseMooncake
  • 重写create方法,绘制月饼形状(中间部分+边)

绘制月饼中间部分:

大体步骤主要分为以下三步:

  1. 绘制形状 需要用到CylinderGeometry这个用来构造图形部分, 这里只用到了其中的四个参数分别是,顶面半径,底面半径,高度,共有多少个边,如果边数足够多,则近似是个圆柱体,实际上没有真正的曲面,只是足够多的多边形削得足够圆,看起来像个曲面而已。
 const mooncakeCenterGeo = new CylinderGeometry(r, r, height, totalSides);
  1. 绘制素材 第二部要创建素材元素,也就是给绘制出的图形,添加颜色以及纹理等表皮的东西,如果设置wireFrame,则使用边框线进行绘制而不用颜色进行填充,这个在初期调整形状位置时候比较有用,可以很清楚看清边界
   const defaultMaterial = new MeshBasicMaterial({
      color,
      // wireframe: true, //设置边框线
    });
  1. 合成上面两个元素,生成月饼中间部分的多边形
const moonCake = new Mesh(mooncakeCenterGeo, defaultMaterial); //网格模型对象

绘制边缘部分及对齐:

分析: 由于标准的月饼圆形的边框,所以首先要构造一个圆柱体,其实就会一个边数相对多一些的圆柱体,然后绘制一半形成半圆柱体,然后找到中心的圆柱体的一个边,调整对应角度和位置,其他边以此类推。

  • 一、计算边框半圆的位置

给出一个示意图: c476f968-b1f8-48aa-b633-80ec14ff61c9.png

黄色的线代表x,y轴,灰色代表坐标线,由图可知,求半圆的位置也就是求半圆的x,y坐标,

  1. 求半圆的半径 这个相对简单,只要知道角1的大小,半圆的半径就是中间大圆的半径的cos值 那么如何求角1呢 这个就需要用到初中的知识,求多边形的内角角度了, 多边形内角和公式:(边数-2)*180度 多边形每个内角角度=内角和/总边数 写成代码就是:
const perAngel = ((totalSides - 2) * Math.PI) / totalSides;

Math.PI表示180度, 角1的角度就是perAngel/2, 因此半圆的半径就是:

const cos = Math.cos(perAngel / 2);
const sideR = cos * r;
  1. 求x,y坐标
    • 求圆心距 半圆的圆心的y值就是大圆和半圆圆心连线的距离乘以sin角2的值,就先要求得圆心连线的值,由上一步已经求得小圆半径,直接用sin角1乘以大圆半径r就可以了,或者由勾股定理求也可以。
const sin = Math.sin(perAngel / 2);
const shortR = sin * r; 
    • 求圆心角: 角2是每个边的圆心角的一半,所以先求圆心角,坐标系是360度,所以很简单,圆心角就是360度除以边数 写成代码:
const centerAngel = (Math.PI * 2) / totalSides;
    • 求结果 经过以上两步,角度和距离都求出了,由于每个边的角度都不同,以x轴作为参考轴,每个边的圆心连线与x轴形成的夹角刚好是圆心角的n倍,所以要加上currentIndex * centerAngel,

最终y坐标:

const y = Math.sin(centerAngel / 2 + currentIndex * centerAngel) * shortR;

同理可以得出x的坐标:

const x = Math.cos(centerAngel / 2 + currentIndex * centerAngel) * shortR;
  • 二、图形对齐
  1. 旋转中心大圆柱体使角处于x轴 因为在计算半圆和多边形的边对齐的时候,是以多边形的角位于x轴进行计算的,但是当多边形为6边形的时候,此时x轴刚好平分一个边,导致角没有刚好落在x轴,导致半圆与多边形的边对不齐。 因此只能将任意多边形的角旋转到x轴上才行, 经过测试发现,通过threejs创建的任意多边形,总有一个角是位于y轴上的,因此只需要将图形旋转90度即可。

代码如下:

const isRotateCenter = 90 % (360 / totalSides) !== 0;
  if (isRotateCenter) {
    //解决中心和边错位的问题
      mooncakeCenterGeo.rotateZ(Math.PI / 2);
  }

由于刚开始创建出的图形,是正面朝上,为了便于调整,正面调整为与x,y轴平行 2. 调整半圆旋转的角度 半圆边默认是垂直于x轴的,所以要想刚好和大圆的边对齐则需要旋转与角2相等的度数 对于其他边,每增加一个边,角度都要增加一个圆心角的度数,因此对于每个边旋转后的角度如下:

sideGeometry.rotateZ(Math.PI / totalSides + centerAngel * currentIndex);

注:这里Math.PI / totalSides其实应该写成Math.PI*2 / ((totalSides*2)更好理解

最后通过translate变换使x,y坐标生效

sideGeometry.translate(x, y, 0);

使边框产生阴影

  1. 材质需要使用MeshLambertMaterial创建
  2. 设置 mesh.castShadow = true;

添加组

three中提供了一个组的概念,就是可以将多个图形打包到一起然后一起操作,作为一个月饼,当然边和中心是一个整体,所以在create方法中,将生成的多个边和中心圆柱体打包到一个group中,便于后续对于整个元素进行位置操作, 注意:既然都已经添加到一个group中了,所以在渲染的时候也要将这个group作为参数传入,而不是将mesh对象传入

至此,一个标准的月饼就造出来了!!

圆形月饼实现

分析:

造一个类似‘自来红‘的月饼,实际上就是一个朝上的半球体,球体其实也是有多个面组成,如果面切得足够多,则足够圆,每个切面都是一个三角形,为了实现一个半圆球,纵向只需要从0度到90度,横向要实360度旋转一周

实现

利用SphereGeometry构造球体

 const geometry = new SphereGeometry(
      this.r,
      30,//横向切分的数量
      30,//纵向切分的数量
      0,//指定横向起始角度
      Math.PI * 2,//横向终止的角度
      0,//纵向起始角度
      Math.PI / 2//纵向终止的角度
    );

上述实现可以打造一个半球,但是底部是空的,查看相关api也没有封底的操作,所以只能再造个二维平面当做底部,封上 由于创建出的平面是在x,y坐标系创建,整体垂直于z轴,要他与z轴平行,需要绕x轴旋90度 代码如下

const geometry = new CircleGeometry(this.r, 30);
const material = new MeshBasicMaterial({ color: this.color });
const circle = new Mesh(geometry, material);
circle.rotateX(Math.PI / 2);
this.group.add(circle);

打造多角月饼

分析:

实现思路基本和 标准月饼一样,只是将边的形状变成三角形,定位有区别,因为三角形的圆心和半圆的圆心有所不同,标准的月饼的圆心正好在多边形边的中心点上,而三角形的圆心是在多边形边的外面,因此计算它的坐标要复杂一些,为了方便理解,请参考下图

183f1a5c-4a78-4590-9055-e20953863873.png

实现要点:

  • 计算三角形坐标

想要计算坐标的x,y值只需要用圆心距离乘以角2的sin,cos值,但是圆心距离分为两段,前面那段之前已经求过了,所以只需要求出后面那段,两者相加即可。 由于创建都是圆柱体,所以创建出的三角形也是等边三角形,因此三角形的圆心(红线交叉点)和每个角的连线都会平分每个角,原本每个角是60度,平分后是30度,角3是30度,因此三角形圆心距离多形性 的边的距离deltaX(绿线)就是三角形的半径乘以sin30度,现在只需要求出三角形的半径就可以了,按照求标准月饼的半径的方法可以得出多边形的边的一半,恰好这个值除以三角形的半径刚好等于cos30度,因此可以很容易得出三角形半径为

const sideR = (cos * r) / cos30;

因此deltaX(绿线)就是sideR * sin30 最后圆心之间的距离就是 绿线加上蓝线:

sideR * sin30 + shortR;
  • 旋转图形

三角形如果没有旋转的情况是,角正朝下,为了和边刚好齐平,要将三角形向右旋转一定角度,这个角度是内角的一半减去60度,在加上每个边固定偏移的角度 代码如下:

 sideGeometry.rotateZ(
  centerAngel * currentIndex - (perAngel / 2 - Math.PI / 3)
 );

创建简单工厂生产月饼

通过传入参数,生成出不同类型的月饼,造个简单工厂类

export default class MooncakeFactory {
  makeOne(data: IMooncakeParam) {
    let mooncake: BaseMoonCake;
    switch (data.type) {
      case TYPE.ROUND:
        mooncake = new MooncakeRound(data);
        break;
      case TYPE.STANDARD:
        mooncake = new MooncakeStand(data);
        break;
      case TYPE.TRINGLE:
        mooncake = new MooncakeTringle(data);
        break;
    }
    return mooncake;
  }
}

造月饼盒

分析:

首先一个月饼一个盒子,盒子的长宽均大于月饼直径加上边的直径,月饼盒要有数量限制,不能无限制添加,这就需要在创建的时候指定几行几列,定义好每个盒子的长宽高

实现

首先根据行数和列数生成盒子,并且盒子之间要有间距 通过如下方式创建一个立方体盒子

 const geometry = new BoxGeometry(
   this.perWidth,
   this.perLong,
   this.height
);

盒子是从左到右依次生成,所以只需要动态改盒子的x值,对于第二层的盒子,只需要改y的值即可 实现代码如下:

 for (let r = 0; r < this.rows; r++) {
      for (let i = 0; i < this.cols; i++) {
            cubeLine.position.x = i * (this.perLong + 1);
            cubeLine.position.y = r * (this.perWidth + 1);
      }
  }

在添加月饼的时候,要根据已有的月饼数量,如果数量已满则给提示,确定当前要添加到哪个盒子中,动态计算一下位置 实现代码如下:

	
  addMooncake(mooncake: BaseMoonCake) {
    if (this.mooncakes.length === this.rows * this.cols) {
      throw new Error('装不下了');
    } else {
      mooncake.group.position.x =
        (this.mooncakes.length % this.cols) * (this.perLong + 1);
      mooncake.group.position.y =
        Math.floor(this.mooncakes.length / this.cols) * this.perWidth;
        this.mooncakes.push(mooncake);
    }
  }

说明:新添加的月饼的坐标肯定是比之前的坐标+1,所以直接用之前总数当做新的月饼的坐标,比如一行有三个,那么添加第四个的时候,实际坐标是(3,1),应该放在第二行的第一个,所以他的起始坐标是0,纵坐标要+1

删除月饼

这个很简单,就是直接从场景scene中将月饼对应的group进行删除即可,这个方法写在基础类中,需要注意的是,删除之后要重新渲染一次,不然不会生效 实现代码如下

destroy(): void {
    scene.remove(this.group);
    render();
  }

待完成

  • 场景完善,增加更多中秋元素
  • 增加礼盒外围部分 贴图
  • 月饼支持自定义文字或者图片
  • 操作月饼的位置
  • 使用颜色选择器自定义颜色

最后

通过这次开发,基本了解了threejs如何构建模型,基本图形的使用,曲面实现的基本原理,还有纹理贴图实现,感觉要是做出类似动画那种3d效果,确实挺难的,当然那种效果可能就需要专业的建模工具了,而不是纯手动js创建,threejs可以导入建模工具导出的文件,这就很方便了。初次接触3d领域就用到一些初中的几何知识,看来深入的话会涉及更多的几何知识,所以打好数学基础还是很有必要的!!

参考文档:

www.webgl3d.cn/Three.js/