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

灵感图

每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看

前言

这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割效果,下文不包含激光的圆圈和工作平台,只有切割的光线、切割效果和分离动画,灵感图中切割的部分是超过logo的,如果有UI设计师,可以让设计师给提供分段的svg,我孤军奋战没有那么些资源,文中的点位都是从logo的svg文件获取的,场景创建就不赘述了,以前的文章也讲过很多次,那么我们开始吧

准备工作

  • threejs
  • ts
  • vite

找一个这个小鸟的svg文件。

将svg文件的点位获取出来并将svg加入到场景中

渲染svg

typescript 复制代码
// 加载模型
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

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

ts 复制代码
...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...

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

以上代码地址 v.logo.1.0.1

提取点位信息

由于之前我们获取到了所有的点位信息,那么是不要加载原有的svg生成的logo,所以我们现在要将获取到的分割点,改为vector3,并缩小一下logo,这样方便以后操作

ts 复制代码
// 新建一个二维数组用于收集组成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和底板统一放在视图中心

在此之前需要先定义几个变量,用于之后的使用

ts 复制代码
const logoSize = new THREE.Vector2()
const logoCenter = new THREE.Vector2()
// 底板厚度
const floorHeight = 3
let floor: THREE.Mesh | null
// 底板比logo的扩张尺寸
let floorOffset = 8

根据之前收集的点位信息创建出底板和logo

ts 复制代码
const handlePaths = () => {
    const box2 = new THREE.Box2();
    box2.setFromPoints(divisionPoints)
    box2.getSize(logoSize)
    box2.getCenter(logoCenter)
    createFloor()
}
ts 复制代码
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为基础向内缩进的,而结束点,目前定在底板的下面。

ts 复制代码
// 激光组
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。

typescript 复制代码
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~

js 复制代码
createLine2([...startPoint.toArray(),...endPoint.toArray()])

效果图

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

以上代码地址v.logo.1.0.3

激光效果

首先先把激光的数量改为4,再将之前收集到的logo坐标点位分成四份,每根激光负责切割其中一份,切割的过程就是将激光的endpoint进行改变。

激光运动

计算激光结束点位置

在创建好激光后调用biuAnimate方法,这个方法更新了激光的结束点,遍历之前从svg上获取的点位信息,将这些点位以激光的数量等分,再将这些点位信息作为Line2的顶点信息,通过setInterval的形式更新到激光的Line2

ts 复制代码
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,以每一条激光为维度,创建一个THREE.Line,这样我们就有了4条曲线,在每次激光经过的点作为这条曲线的节点,去更新BufferGeometry

创建激光的部分代码

ts 复制代码
 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)

    }


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

ts 复制代码
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

ts 复制代码
// 用于创建logo挤压模型的形状Shape
const logoShape = new THREE.Shape()
// 用于创建多余部分的挤压模型形状
const moreShape = new THREE.Shape()

loadModel方法新增代码,用于收集logoShape的点位信息

ts 复制代码
// 加载模型
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多余部分的挤压几何体

ini 复制代码
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

总体优化后的效果

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

以上代码地址v.logo.1.0.6

项目地址

相关推荐
腾讯TNTWeb前端团队6 分钟前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰3 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪4 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪4 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy4 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom5 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom5 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom5 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom5 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom5 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试