灵感图
每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看
前言
这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割效果,下文不包含激光的圆圈和工作平台,只有切割的光线、切割效果和分离动画,灵感图中切割的部分是超过logo的,如果有UI设计师,可以让设计师给提供分段的svg,我孤军奋战没有那么些资源,文中的点位都是从logo的svg文件获取的,场景创建就不赘述了,以前的文章也讲过很多次,那么我们开始吧
准备工作
- threejs
- ts
- vite
找一个这个小鸟的svg文件。
将svg文件的点位获取出来并将svg加入到场景中
渲染svg
// 加载模型
const loadModel = async () => {
svgLoader.load('./svg/logo.svg', (data) => {
const material = new THREE.MeshBasicMaterial({
color: '#000',
});
for (const path of data.paths) {
const shapes = SVGLoader.createShapes(path);
for (const shape of shapes) {
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh)
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
渲染结果
svg加载出来后的shape
就是组成当前logo的所有关键点位信息,接下来要做的是将这个logo以正确的角度放置在场景,再将这些关键点位生成激光运动路径,比如一个圆弧,是一个贝塞尔曲线,有两个定点,几个手柄,通过不同的角度组成曲线,而我们要做的是一条布满点位的曲线作为运动路径
获取曲线点位
这里用到的api是# CubicBezierCurve
贝塞尔曲线的基类Curve对象提供的方法getPoints
.getPoints ( divisions : Integer ) : Array
divisions — 要将曲线划分为的分段数。默认是 5.
为了更方便的查看我们创建的点位,我们将生成的点位信息创建一个cube
// 加载模型
const loadModel = async () => {
...
for (const curve of shape.curves) {
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const points = curve.getPoints(100);
console.log(points);
for (const v2 of points) {
const geometry = new THREE.BoxGeometry(10, 10, 10);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(v2.x, v2.y, 0)
scene.add(cube);
}
}
...
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
从图中可以看出,现在cube已经绕着logo围成一圈了,但是有一个现象,就是路径长的地方cube比较稀疏,而路径比较短的曲线cube比较密集,上面代码创建的关键点位信息都是以100的数量创建,所以会导致这种情况,曲线的疏密程度决定将来激光的行走速度,为了保证不管多长的路径,他们的行走速度是一样的,那么我们需要动态计算一下到底该以多少个点位来生成这条路径
...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...
在遍历curve的时候,通过getLength
获取曲线的长度,根据长度的不同,决定分段的点位数量,这样就保证了点位之间的距离是一样的,将来激光行走的速度也是可以控制成一样的,速度一样,距离越短,越先完成,当然你想让所有激光都同时完成,那是不需要让分割的点位分布均匀的。
以上代码地址 v.logo.1.0.1
提取点位信息
由于之前我们获取到了所有的点位信息,那么是不要加载原有的svg生成的logo,所以我们现在要将获取到的分割点,改为vector3,并缩小一下logo,这样方便以后操作
// 新建一个二维数组用于收集组成logo的点位信息
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector2[] = []
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector3[] = []
// 将遍历贝塞尔曲线的地方再改造一下
let list: THREE.Vector3[] = []
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const length = curve.getLength();
const points = curve.getPoints(Math.floor(length / 20));
for (const v2 of points) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
}
paths.push(list)
制作底板并将logo和底板统一放在视图中心
在此之前需要先定义几个变量,用于之后的使用
const logoSize = new THREE.Vector2()
const logoCenter = new THREE.Vector2()
// 底板厚度
const floorHeight = 3
let floor: THREE.Mesh | null
// 底板比logo的扩张尺寸
let floorOffset = 8
根据点位信息收集logo 的信息
根据之前收集的点位信息创建出底板和logo
const handlePaths = () => {
const box2 = new THREE.Box2();
box2.setFromPoints(divisionPoints)
box2.getSize(logoSize)
box2.getCenter(logoCenter)
createFloor()
}
创建地板和logo
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
const material = new THREE.MeshLambertMaterial({ color: 0x6ac3f7 });
floor = new THREE.Mesh(geometry, material);
scene.add(floor);
createLine()
}
const createLine = () => {
const material = new THREE.LineBasicMaterial({
color: 0x0000ff
});
const points: THREE.Vector3[] = [];
divisionPoints.forEach(point => {
points.push(new THREE.Vector3(point.x, floorHeight, point.y))
})
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
const linePos = logoSize.clone().divideScalar(-2)
line.position.set(linePos.x, 0, linePos.y)
scene.add(line);
}
我们之前加载的svg已经没有用了,只是为了提供点位信息,所以需要再根据整理后的点位信息创建一个logo的Line
对象
效果图
以上代码地址v.logo.1.0.2
绘制激光
创建4(可自定)条激光,起点从底板上方30的位置,结束于logo,然后结束的点位随着logo的点位进行改变,从而实现激光运动的效果,提前先确定一下激光起点,
判断起点
由于激光数量可以自定,那么我们需要自定义一个激光的数量,当前用的数量是10,而要配置不同数量的激光,位置就需要有一定的规则,下面代码是创建了一个圆弧,以激光数量为基础,在圆弧上获取相应的点位,这样不管多少个激光,都可以从这个圆弧上取起点位置,圆弧的半径是以logo为基础向内缩进的,而结束点,目前定在底板的下面。
// 激光组
const buiGroup = new THREE.Group()
// 激光起点相对于logo缩进的位置
const buiDivide = 3
// 决定激光起点距离场景中心的距离
const buiOffsetH = 30
// 决定有几条激光
const buiCount = 10
const createBui = () => {
// 创建一个圆弧,将来如果有很多激光,那么起点就从圆弧的点位上取
var R = Math.min(...logoSize.toArray()) / buiDivide; //圆弧半径
var N = buiCount * 10; // 根据激光的条数生成圆弧上的点位数量
// 批量生成圆弧上的顶点数据
const vertices: number[] = []
for (var i = 0; i < N; i++) {
var angle = 2 * Math.PI / N * i;
var x = R * Math.sin(angle);
var y = R * Math.cos(angle);
vertices.push(x, buiOffsetH, y)
}
// 创建圆弧的辅助线
initArc(vertices)
for (let i = 0; i < buiCount; i++) {
const startPoint = new THREE.Vector3().fromArray(vertices, i * buiCount * 3)
const endPoint = new THREE.Vector3()
endPoint.copy(startPoint.clone().setY(-floorHeight))
// 创建cube辅助块
const color = new THREE.Color(Math.random() * 0xffffff)
initCube(startPoint, color)
initCube(endPoint, color)
}
}
效果图
每两个相同的颜色就是当前激光一条激光的两段
line2
下面该创建激光biu~
,原理上是一条可控制宽度的线,虽然threejs中的线条材质提供的linewidth来控制线宽,但是属性下面有说明,无论怎么设置,线宽始终是1,所以我们要用另一种表现形式:Line2
.linewidth : Float
控制线宽。默认值为 1。
由于OpenGL Core Profile与 大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
...
const createLine2 = (linePoints: number[]) => {
const geometry = new LineGeometry();
geometry.setPositions(linePoints);
const matLine = new LineMaterial({
linewidth: 0.002, // 可以调整线宽
dashed: true,
opacity: 0.5,
color: 0x4cb2f8,
vertexColors: false, // 是否使用顶点颜色
});
let biu = new Line2(geometry, matLine);
biuGroup.add(biu);
}
调用initBiu~
createLine2([...startPoint.toArray(),...endPoint.toArray()])
效果图
准备工作大致就到此结束了,接下来要实现的效果是激光运动
、激光发光
、logo切割
。
以上代码地址v.logo.1.0.3
激光效果
首先先把激光的数量改为4,再将之前收集到的logo坐标点位分成四份,每根激光负责切割其中一份,切割的过程就是将激光的endpoint进行改变。
激光运动
计算激光结束点位置
在创建好激光后调用biuAnimate
方法,这个方法更新了激光的结束点,遍历之前从svg上获取的点位信息,将这些点位以激光的数量等分,再将这些点位信息作为Line2的顶点信息,通过setInterval的形式更新到激光的Line2
const biuAnimate = () => {
console.log('paths', paths, divisionPoints);
// biuCount
// todo 这里要改成points这样的 每次切割完 收缩一下激光,再伸展出来
const allPoints = [...divisionPoints]
const len = Math.ceil(allPoints.length / biuCount)
for (let i = 0; i < biuCount; i++) {
const s = (i - 1) * len
const points = allPoints.splice(0, len);
const biu = biuGroup.children[i] as Line2;
const biuStartPoint = biu.userData.startPoint
let j = 0;
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const attrPosition = [...biuStartPoint.toArray(), ...new THREE.Vector3(point.x, floorHeight/2, point.y).add(getlogoPos()).toArray()]
uploadBiuLine(biu, attrPosition)
j++
} else {
clearInterval(interval)
}
}, 100)
}
}
// 更新激光信息
const uploadBiuLine = (line2: Line2, attrPosition) => {
const geometry = new LineGeometry();
line2.geometry.setPositions(attrPosition);
}
效果图
根据激光经过的路径绘制logo
首先隐藏掉原有的logo,以每一条激光为维度,创建一个THREE.Line
,这样我们就有了4条曲线,在每次激光经过的点作为这条曲线的节点,去更新BufferGeometry
。
创建激光的部分代码
for (let i = 0; i < biuCount; i++) {
...
// 创建线段
const line = createLine()
scene.add(line)
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const endArray = new THREE.Vector3(point.x, floorHeight / 2, point.y).add(getlogoPos()).toArray()
const attrPosition = [...biuStartPoint.toArray(), ...endArray]
...
// 获取原有的点位信息
const logoLinePointArray = [...(line.geometry.attributes['position']?.array||[])];
logoLinePointArray.push(...endArray)
// 更新线段
line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(logoLinePointArray), 3))
j++
} else {
clearInterval(interval)
}
}, 100)
}
从图中可以看到,每根曲线之间的衔接做的并不是很到位,所以稍微改造一下代码,将上一根线的最后一个点位给到当前的线,
const points = allPoints.splice(0, len);
// allPoints是截取到上一轮点位的其余点位,所以第一个就是当前激光相邻的第一个点
if(i<biuCount-1) {
points.push(allPoints[0])
} else {
//最后一条曲线需要加的点是第一条线的第一个点
points.push(divisionPoints[0])
}
以上代码地址v.logo.1.0.4
logo分离
激光切割完毕后,logo和底板将分离,之前想用的是threeBSP
进行布尔运算进行裁切,但是对于复杂的logo使用布尔运算去裁切太消耗资源了,简单的几何形状可以,如果有同学对threebsp
感兴趣的可以看一下这个案例 布尔运算
创建裁切的多余部分
创建裁切的过程其实就是新增和删除的过程,新增一个logo和多余部分,再将原有的底板删除掉
这里多余的部分使用shape的孔洞,底板尺寸生成的形状作为主体,logo作为孔洞,结合起来后,将得到的shape进行挤压
创建logo和多余部分的几何体
在外部创建logo和多余部分的shape
// 用于创建logo挤压模型的形状Shape
const logoShape = new THREE.Shape()
// 用于创建多余部分的挤压模型形状
const moreShape = new THREE.Shape()
loadModel
方法新增代码,用于收集logoShape的点位信息
// 加载模型
const loadModel = async () => {
...
for (let i = 0; i < points.length - 1; i++) {
const v2 = points[i]
if (v2.x !== 0 && v2.x && v2.y !== 0 && v2.y) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量,后面依赖向量的元素都需要重新绘制
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
if (i === 0) {
logoShape.moveTo(v2.x, v2.y)
} else {
logoShape.lineTo(v2.x, v2.y)
}
}
}
...
}
createFloor
方法创建moreMesh多余部分的挤压几何体
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
floor = new THREE.Mesh(geometry, logoMaterial);
// scene.add(floor);
moreShape.moveTo(floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, -floorSize.y / 2);
moreShape.lineTo(floorSize.x / 2, -floorSize.y / 2);
const path = new THREE.Path()
const logoPos = new THREE.Vector3(logoCenter.x, floorHeight / 2, logoCenter.y).negate()
// logo实例
logoMesh = createLogoMesh(logoShape)
logoMesh.position.copy(logoPos.clone().setY(floorHeight))
logoMesh.material = new THREE.MeshLambertMaterial({ color: 0xff0000, side: THREE.DoubleSide });
scene.add(logoMesh);
// 孔洞path
divisionPoints.forEach((point, i) => {
point.add(logoCenter.clone().negate())
if (i === 0) {
path.moveTo(point.x, point.y);
} else {
path.lineTo(point.x, point.y);
}
})
// 多余部分添加孔洞
moreShape.holes.push(path)
// 多余部分实例
moreMesh = createLogoMesh(moreShape)
// moreMesh.visible = false
scene.add(moreMesh)
}
经过以上的改造,画面总共分为三个主要部分,激光、多余部分、logo。
大概效果就是这样的,再加上动画,让激光有收起和展开,再加上切割完以后,多余部分的动画,那这篇教程基本上就完事儿了,下面优化的部分就不一一展示了,可以看最终的效果动图,也可以从gitee上将代码下载下来自行运行
以上代码地址v.logo.1.0.5
总体优化后的效果
推特logo
抖音 logo
github logo
动图比较大,可以保存在本地查看
以上代码地址v.logo.1.0.6
项目地址
原文链接:https://juejin.cn/post/7337169269951283235 作者:孙_华鹏