普通视图

发现新文章,点击刷新页面。
昨天以前掘金 前端

520了,程序员就得有点儿独特的浪漫

作者 李剑一
2026年5月20日 17:05

又到了一年一度的520了,可能是大家现在都觉得没啥意思了。

我在垃圾桶也捡不到免费的玫瑰花和蛋糕了,现在大家是都不买了?还是都不扔了?

哈哈!不过520了也不能啥都不送,作为程序员群体送给她一个3D炫彩爱心岂不美哉。

QQ20260520-170301.gif

实现原理

粒子想要组成爱心形状需要用到一个数学公式+粒子采样,我们可以把它比喻成「用小积木拼爱心」:

爱心的数学公式(核心)

我们用到了经典的"爱心隐函数",这是爱心形状的"设计图纸",公式如下(已适配代码中的计算逻辑):

$(x^2 + frac{9}{4}z^2 + y^2 - 1)^3 - x^2y^3 - frac{9}{80}z^2y^3 < 0$

在3D空间中,我们遍历x、y、z三个轴的所有点(就像在空间里撒满小积木),只要某个点满足这个公式,就说明它在爱心的轮廓内,我们就把这个点保留下来——相当于"筛选出能拼成爱心的积木"。

代码中,我们设置了x、y、z的范围(x:-1.12~1.12,y:-0.96~1.2,z:-0.64~0.64),还设置了采样间隔(unitSpacing=0\.04),间隔越小,粒子越密集,爱心轮廓越清晰(就像积木越小,拼出来的形状越精致)。

粒子的批量渲染(性能关键)

如果每个粒子都单独创建一个Mesh,会导致性能崩溃(几百上千个粒子同时渲染,浏览器扛不住)。

这里我们用到了Three.js的InstancedMesh,我们创建一个基础的立方体几何体(每个粒子的形状),再创建一个材质。

然后通过InstancedMesh批量生成所有粒子。

相当于"先做好一个标准积木,再复制出几百个,批量摆成爱心形状",既能保证效果,又能大幅提升性能。

补充:代码中的去重函数(objArrDistinct),是为了去掉重复的粒子点(避免同一个位置有多个积木重叠),让爱心轮廓更干净。

流光效果

代码中,我们在动画循环(blingbling函数)里,给每个粒子动态设置颜色:

色相循环

用全局时间 time = Date.now() * 0.001 控制色相变化,每个粒子的色相再加上独立的偏移量 i * 0.006

相当于"调色盘匀速旋转,每个积木的漆色都比上一个稍偏一点",这样就形成了流光效果。

饱和度和亮度控制

我们设置饱和度为1(颜色最浓郁),亮度为0.42(避免过亮发白)。

注意:每个粒子的颜色和缩放动画是同步的,缩放用正弦函数(sin)控制,实现"呼吸感"。

实战代码

const containerRef = ref(null)

let renderer, scene, camera, controls, pointLight
let transform = new THREE.Object3D()
let result = []
let heartMesh
let bloomComposer, finalComposer
let animationId = null

const materials = {}
const BLOOM_SCENE = 1
const bloomLayer = new THREE.Layers()
bloomLayer.set(BLOOM_SCENE)
const darkMaterial = new THREE.MeshBasicMaterial({ color: 'black' })

// 降低光晕叠加强度,避免吞色
const vertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`
const fragmentShader = `
  uniform sampler2D baseTexture;
  uniform sampler2D bloomTexture;
  varying vec2 vUv;
  void main() {
    gl_FragColor = texture2D(baseTexture, vUv) + 0.8 * texture2D(bloomTexture, vUv);
  }
`

const randomSort = () => Math.random() > 0.5 ? -1 : 1

const darkenNonBloomed = (obj) => {
    if (obj instanceof THREE.Scene) {
        materials.scene = obj.background
        obj.background = null
        return
    }
    if (obj.isMesh && bloomLayer.test(obj.layers) === false) {
        materials[obj.uuid] = obj.material
        obj.material = darkMaterial
    }
}
const restoreMaterial = (obj) => {
    if (obj instanceof THREE.Scene) {
        obj.background = materials.scene
        delete materials.background
        return
    }
    if (materials[obj.uuid]) {
        obj.material = materials[obj.uuid]
        delete materials[obj.uuid]
    }
}

const initBloom = () => {
    const effectFXAA = new ShaderPass(FXAAShader)
    effectFXAA.uniforms['resolution'].value.set(
        0.6 / containerRef.value.clientWidth,
        0.6 / containerRef.value.clientHeight
    )
    effectFXAA.renderToScreen = true

    const renderScene = new RenderPass(scene, camera)
    // 弱化发光:提高阈值、降低强度、缩小范围
    const bloomPass = new UnrealBloomPass(
        new THREE.Vector2(containerRef.value.clientWidth, containerRef.value.clientHeight),
        1.0, 0.3, 0.6
    )
    bloomPass.threshold = 0.2    // 提高发光阈值,减少泛光
    bloomPass.strength = 0.8     // 降低发光强度
    bloomPass.radius = 0.2       // 缩小光晕范围

    bloomComposer = new EffectComposer(renderer)
    bloomComposer.renderToScreen = false
    bloomComposer.addPass(renderScene)
    bloomComposer.addPass(bloomPass)
    bloomComposer.addPass(effectFXAA)

    const finalPass = new ShaderPass(
        new THREE.ShaderMaterial({
            uniforms: { baseTexture: { value: null }, bloomTexture: { value: bloomComposer.renderTarget2.texture } },
            vertexShader, fragmentShader, defines: {}
        }),
        'baseTexture'
    )
    finalPass.needsSwap = true

    finalComposer = new EffectComposer(renderer)
    finalComposer.addPass(renderScene)
    finalComposer.addPass(finalPass)
    finalComposer.addPass(effectFXAA)
}

const initThree = () => {
    renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setPixelRatio(window.devicePixelRatio)
    // 开启色调映射,防止画面过曝
    renderer.toneMapping = THREE.ACESFilmicToneMapping
    renderer.toneMappingExposure = 0.9
    renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
    containerRef.value.appendChild(renderer.domElement)

    scene = new THREE.Scene()
    scene.background = new THREE.Color(0x050508) // 深暗色背景衬托彩色

    camera = new THREE.PerspectiveCamera(
        45,
        containerRef.value.clientWidth / containerRef.value.clientHeight,
        1, 10000
    )
    camera.position.set(3, 3, 6)
    camera.lookAt(0, 0, 0)

    initOrbit()
    initLight()
    initHeart()
    toggleBloom()
    initBloom()

    window.addEventListener('resize', onWindowResize)
}

// 降低灯光亮度,避免冲淡粒子颜色
const initLight = () => {
    pointLight = new THREE.PointLight('#ffffff', 0.4)
    scene.add(pointLight)
}

const objArrDistinct = (objArr) => {
    const resultArr = []
    const itemKeyVal = {}
    objArr.forEach(item => {
        const key = `${item.x}_${item.y}_${item.z}`
        if (!itemKeyVal[key]) {
            itemKeyVal[key] = true
            resultArr.push(item)
        }
    })
    return resultArr
}

const initHeart = () => {
    const arr_xyz = []
    let arr_xy = []
    let arr_yz = []
    let arr_xz = []
    const unitSize = 0.012
    const unitSpacing = 0.04

    let kvx = {}, kvy = {}, kvz = {}

    for (let x = -1.12; x <= 1.12; x += unitSpacing) {
        for (let y = -0.96; y <= 1.2; y += unitSpacing) {
            for (let z = -0.64; z <= 0.64; z += unitSpacing) {
                const xNum = Number(x.toFixed(2))
                const yNum = Number(y.toFixed(2))
                const zNum = Number(z.toFixed(2))

                const func = Math.pow(Math.pow(xNum, 2) + 9 / 4 * Math.pow(zNum, 2) + Math.pow(yNum, 2) - 1, 3)
                    - Math.pow(xNum, 2) * Math.pow(yNum, 3)
                    - 9 / 80 * Math.pow(zNum, 2) * Math.pow(yNum, 3)

                if (func < 0) {
                    arr_xyz.push({ x: xNum, y: yNum, z: zNum })
                    kvx[`${yNum}_${zNum}`] = (kvx[`${yNum}_${zNum}`] || []).concat(xNum)
                    kvy[`${xNum}_${zNum}`] = (kvy[`${xNum}_${zNum}`] || []).concat(yNum)
                    kvz[`${xNum}_${yNum}`] = (kvz[`${xNum}_${yNum}`] || []).concat(zNum)
                    arr_xy.push({ x: xNum, y: yNum })
                    arr_yz.push({ z: zNum, y: yNum })
                    arr_xz.push({ x: xNum, z: zNum })
                }
            }
        }
    }

    arr_xy = objArrDistinct(arr_xy)
    arr_yz = objArrDistinct(arr_yz)
    arr_xz = objArrDistinct(arr_xz)

    arr_xy.forEach(xy => { xy.min_z = Math.min(...kvz[`${xy.x}_${xy.y}`]); xy.max_z = Math.max(...kvz[`${xy.x}_${xy.y}`]) })
    arr_yz.forEach(yz => { yz.min_x = Math.min(...kvx[`${yz.y}_${yz.z}`]); yz.max_x = Math.max(...kvx[`${yz.y}_${yz.z}`]) })
    arr_xz.forEach(xz => { xz.min_y = Math.min(...kvy[`${xz.x}_${xz.z}`]); xz.max_y = Math.max(...kvy[`${xz.x}_${xz.z}`]) })

    arr_xy.map(xy => { result.push({ x: xy.x, y: xy.y, z: xy.max_z }); result.push({ x: xy.x, y: xy.y, z: xy.min_z }) })
    arr_yz.map(yz => { result.push({ x: yz.max_x, y: yz.y, z: yz.z }); result.push({ x: yz.min_x, y: yz.y, z: yz.z }) })
    arr_xz.map(xz => { result.push({ x: xz.x, y: xz.max_y, z: xz.z }); result.push({ x: xz.x, y: xz.min_y, z: xz.z }) })

    result = objArrDistinct(result)

    const geometry = new THREE.BoxGeometry(unitSize, unitSize, unitSize)
    const material = new THREE.MeshBasicMaterial()
    heartMesh = new THREE.InstancedMesh(geometry, material, result.length)
    heartMesh.name = 'Heart'
    result = result.sort(randomSort)

    result.map((res, i) => {
        transform.position.set(res.x, res.y, res.z)
        transform.updateMatrix()
        heartMesh.setMatrixAt(i, transform.matrix)
    })

    scene.add(heartMesh)
}

const toggleBloom = () => {
    scene.traverse((obj) => { if (obj.name === 'Heart') obj.layers.toggle(1) })
}

const blingbling = () => {
    const time = Date.now() * 0.001
    result.map((res, i) => {
        const scale = 1.1 * (0.5 * Math.sin(time * 1.5 + i * 0.12) + 0.5)
        transform.position.set(res.x, res.y, res.z)
        transform.scale.setScalar(scale)
        transform.updateMatrix()
        heartMesh.setMatrixAt(i, transform.matrix)

        const hue = (time * 0.25 + i * 0.006) % 1
        const color = new THREE.Color().setHSL(hue, 1, 0.42)
        heartMesh.setColorAt(i, color)
    })

    heartMesh.instanceMatrix.needsUpdate = true
    heartMesh.instanceColor.needsUpdate = true
}

const animate = () => {
    animationId = requestAnimationFrame(animate)

    blingbling()
    controls.update()

    scene.traverse(darkenNonBloomed)
    bloomComposer.render()
    scene.traverse(restoreMaterial)
    finalComposer.render()

    pointLight.position.copy(camera.position)
}

onMounted(() => { initThree(); animate() })

总结

效果看起来还不错,整体实现也比较简单。

主要是InstancedMesh、粒子去重,另外记得在组件销毁之前将相应的变量销毁掉,避免内存泄漏。

如果想让炫彩更明显,可适当提高HSL亮度(不超过0.5)。

想让发光更柔和,可降低Bloom强度;想让旋转更快,可调整autoRotateSpeed。

耗时 2 小时!我复刻了全网超火的通透 3D 水晶球动效,Vue3+Three.js 做出高级视觉特效

作者 李剑一
2026年5月7日 16:31

之前做的3D文本效果# 别再写死静态文字了!Vue3+Three.js 实现超酷3D流光旋转文字科技感拉满!【附完整源码】有兄弟表示效果太单一,其实那个效果能做的非常炫酷。

核心就是材质上要把握好,重点其实不在技术,而在调色

简单搞一个3D的水晶球弹跳效果,具备一定的物理效果。

QQ20260507-162509.gif

需求实现

球体效果相对比较简单,直接使用SphereGeometry创建即可。

主要是重力弹跳的物理效果实现起来比较麻烦,落地回弹的效果通过控制球体高度的正负实现。

另外就是阴影部分,阴影跟随球体的高度进行变化。

最后就是旋转视角和窗口自适应,还是用OrbitControlswindow.resize实现。

物体实现代码

灯光部分拆分成三个部分:环境光、平行光、点光源。

function addLights() {
    // 环境光:照亮整个场景,无阴影
    const ambient = new THREE.AmbientLight(0xffffff, 0.4)
    scene.add(ambient)

    // 平行光:太阳光效果,能投射阴影
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.3)
    dirLight.position.set(4, 6, 3)    // 光源位置
    dirLight.castShadow = true        // 开启投影
    dirLight.shadow.mapSize.set(2048, 2048) // 阴影清晰度
    dirLight.shadow.radius = 10       // 阴影柔和度
    scene.add(dirLight)

    // 蓝色氛围点光源
    const blueLight = new THREE.PointLight(0x639bff, 1.6, 12)
    blueLight.position.set(-5, 3, 4)
    scene.add(blueLight)

    // 紫色氛围点光源
    const purpleLight = new THREE.PointLight(0xc563ff, 1.4, 12)
    purpleLight.position.set(5, -1, 4)
    scene.add(purpleLight)
}

但是只有光,没有地面无法正常显示物体在光下的投影,所以需要增加地面。

function createGround() {
    const geo = new THREE.PlaneGeometry(25, 25) // 地面大小
    const mat = new THREE.MeshStandardMaterial({
        color: 0x1e2238,    // 地面颜色
        roughness: 0.75,    // 粗糙度
        metalness: 0.05    // 金属感
    })
    ground = new THREE.Mesh(geo, mat)
    ground.rotation.x = -Math.PI / 2  // 平放地面
    ground.position.y = groundHeight  // 地面位置
    ground.receiveShadow = true       // 允许接收阴影
    scene.add(ground)
}

除此之外就是创建水晶球了,这部分直接使用SphereGeometry创建球体。

然后用MeshPhysicalMaterial增加相应的材质即可。

function createCrystalBall() {
    // 球体几何体(半径,横向分段,纵向分段)
    const geo = new THREE.SphereGeometry(1, 128, 128)

    // 水晶/玻璃材质(超通透质感)
    const mat = new THREE.MeshPhysicalMaterial({
        color: 0xffffff,        // 基础颜色
        transparent: true,      // 开启透明
        transmission: 0.95,     // 透光率(1=完全透明)
        opacity: 1,            // 不透明度
        roughness: 0,          // 表面光滑度(0=镜面)
        metalness: 0,          // 金属感
        ior: 1.58,             // 折射率(水晶标准值)
        thickness: 1.8,        // 物体厚度
        envMapIntensity: 2.2,  // 环境光反射强度
        clearcoat: 1,          // 表面清漆(更亮)
        clearcoatRoughness: 0  // 清漆光滑度
    })

    ball = new THREE.Mesh(geo, mat)
    ball.castShadow = true    // 允许投射阴影
    ball.receiveShadow = true // 允许接收阴影
    ball.position.y = 3       // 初始高度(从空中落下)
    scene.add(ball)
}

物理弹跳效果

核心代码是通过控制小球的高度变化,实现弹跳效果。

通过控制其下落的速度不断增加,实现重力加速效果。

落地碰撞主要是通过检测小球高度和地面高度,当相等的时候,就认为已经落地了。

unction updateBallBounce() {
    // 1. 重力效果:速度不断增加
    velocity += gravity

    // 2. 根据速度更新小球 Y 轴位置
    ball.position.y += velocity

    // 3. 落地碰撞检测 + 回弹
    // 小球最低位置 = 地面高度 + 小球半径(防止陷进地里)
    const minHeight = groundHeight + ballRadius
    if (ball.position.y <= minHeight) {
        ball.position.y = minHeight // 重置位置
        velocity = -velocity * bounce // 反向速度 × 弹性(回弹)
    }

    // 4. 小球自动旋转
    ball.rotation.y += 0.005

    // 5. 阴影同步效果:
    // 跳得越高 → 越小越淡
    // 离地面越近 → 越大越浓
    const height = ball.position.y - minHeight
    const shadowScale = THREE.MathUtils.clamp(1.2 - height * 0.15, 0.5, 1.2)
    ball.scale.set(shadowScale, shadowScale, shadowScale)
}

总结

其实场景的搭建非常简单,鼠标操作也是用的封装好的东西。

重点是在物理弹跳效果上,通过控制速度,间接控制小球的高度。

以及小球高度与地面高度的对比实现落地反弹效果。

还有就是模拟真实世界的重力衰减效果,随着不断地反弹落地,最终小球高度完全等于地面高度。

❌
❌