THREE.JS——让你的logo切割出高级感

2,286 阅读10分钟

灵感图

2024-02-03 18.05.27.gif

每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有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()

渲染结果

image.png

svg加载出来后的shape就是组成当前logo的所有关键点位信息,接下来要做的是将这个logo以正确的角度放置在场景,再将这些关键点位生成激光运动路径,比如一个圆弧,是一个贝塞尔曲线,有两个定点,几个手柄,通过不同的角度组成曲线,而我们要做的是一条布满点位的曲线作为运动路径

image.png

获取曲线点位

这里用到的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的数量创建,所以会导致这种情况,曲线的疏密程度决定将来激光的行走速度,为了保证不管多长的路径,他们的行走速度是一样的,那么我们需要动态计算一下到底该以多少个点位来生成这条路径

image.png

...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...

在遍历curve的时候,通过getLength获取曲线的长度,根据长度的不同,决定分段的点位数量,这样就保证了点位之间的距离是一样的,将来激光行走的速度也是可以控制成一样的,速度一样,距离越短,越先完成,当然你想让所有激光都同时完成,那是不需要让分割的点位分布均匀的。

提取点位信息

由于之前我们获取到了所有的点位信息,那么是不要加载原有的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对象

效果图

image.png

绘制激光

创建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)

    }
}

效果图

image.png

每两个相同的颜色就是当前激光一条激光的两段

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()])

效果图

image.png

准备工作大致就到此结束了,接下来要实现的效果是激光运动激光发光logo切割

激光效果

首先先把激光的数量改为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);
}

效果图

2024-02-05 16.21.15.gif

根据激光经过的路径绘制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)

    }

2024-02-19 09.51.53.gif

image.png

从图中可以看到,每根曲线之间的衔接做的并不是很到位,所以稍微改造一下代码,将上一根线的最后一个点位给到当前的线,

const points = allPoints.splice(0, len);
// allPoints是截取到上一轮点位的其余点位,所以第一个就是当前激光相邻的第一个点
if(i<biuCount-1) {
    points.push(allPoints[0])
} else {
    //最后一条曲线需要加的点是第一条线的第一个点
    points.push(divisionPoints[0])
}
image.png

logo分离

激光切割完毕后,logo和底板将分离,之前想用的是threeBSP进行布尔运算进行裁切,但是对于复杂的logo使用布尔运算去裁切太消耗资源了,简单的几何形状可以。

创建裁切的多余部分

创建裁切的过程其实就是新增和删除的过程,新增一个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。

2024-02-19 15.58.25.gif

大概效果就是这样的,再加上动画,让激光有收起和展开,再加上切割完以后,多余部分的动画,那这篇教程基本上就完事儿了,下面优化的部分就不一一展示了,可以看最终的效果动图,也可以从gitee上将代码下载下来自行运行

推特logo

2024-02-19 18.20.28.gif

抖音 logo

2024-02-19 18.20.52.gif

github logo

2024-02-19 18.19.59.gif

动图比较大,可以保存在本地查看

项目地址