我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛
用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
中执行初始化
标准月饼实现
简要说明
传统意义的月饼,标准的,就是中间由一个多边形,四周围是由若干个半圆形组成的,如下所示
实现
主要分为以下几步
- 创建定义一个叫
MooncakeStand
的类继承基础类BaseMooncake
- 重写
create
方法,绘制月饼形状(中间部分+边)
绘制月饼中间部分:
大体步骤主要分为以下三步:
- 绘制形状
需要用到
CylinderGeometry
这个用来构造图形部分, 这里只用到了其中的四个参数分别是,顶面半径,底面半径,高度,共有多少个边,如果边数足够多,则近似是个圆柱体,实际上没有真正的曲面,只是足够多的多边形削得足够圆,看起来像个曲面而已。
const mooncakeCenterGeo = new CylinderGeometry(r, r, height, totalSides);
- 绘制素材
第二部要创建素材元素,也就是给绘制出的图形,添加颜色以及纹理等表皮的东西,如果设置
wireFrame
,则使用边框线进行绘制而不用颜色进行填充,这个在初期调整形状位置时候比较有用,可以很清楚看清边界
const defaultMaterial = new MeshBasicMaterial({
color,
// wireframe: true, //设置边框线
});
- 合成上面两个元素,生成月饼中间部分的多边形
const moonCake = new Mesh(mooncakeCenterGeo, defaultMaterial); //网格模型对象
绘制边缘部分及对齐:
分析: 由于标准的月饼圆形的边框,所以首先要构造一个圆柱体,其实就会一个边数相对多一些的圆柱体,然后绘制一半形成半圆柱体,然后找到中心的圆柱体的一个边,调整对应角度和位置,其他边以此类推。
- 一、计算边框半圆的位置:
给出一个示意图:
黄色的线代表x,y轴,灰色代表坐标线,由图可知,求半圆的位置也就是求半圆的x,y坐标,
- 求半圆的半径 这个相对简单,只要知道角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;
- 求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
,
- 求结果
经过以上两步,角度和距离都求出了,由于每个边的角度都不同,以x轴作为参考轴,每个边的圆心连线与x轴形成的夹角刚好是圆心角的n倍,所以要加上
最终y坐标:
const y = Math.sin(centerAngel / 2 + currentIndex * centerAngel) * shortR;
同理可以得出x的坐标:
const x = Math.cos(centerAngel / 2 + currentIndex * centerAngel) * shortR;
- 二、图形对齐
- 旋转中心大圆柱体使角处于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);
使边框产生阴影
- 材质需要使用
MeshLambertMaterial
创建 - 设置
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);
打造多角月饼
分析:
实现思路基本和 标准月饼一样,只是将边的形状变成三角形,定位有区别,因为三角形的圆心和半圆的圆心有所不同,标准的月饼的圆心正好在多边形边的中心点上,而三角形的圆心是在多边形边的外面,因此计算它的坐标要复杂一些,为了方便理解,请参考下图
实现要点:
-
计算三角形坐标
想要计算坐标的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领域就用到一些初中的几何知识,看来深入的话会涉及更多的几何知识,所以打好数学基础还是很有必要的!!