520了,程序員就得有點兒獨特的浪漫

又到了一年一度的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)控制,實現「呼吸感」。

實戰程式碼

js 体验AI代码助手 代码解读复制代码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。


原文出處:https://juejin.cn/post/7641797742009548809


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝16   💬11   ❤️1
526
🥈
alicec
📝1   ❤️2
82
🥉
我愛JS
💬2  
7
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登