又到了一年一度的520了,可能是大家現在都覺得沒啥意思了。
我在垃圾桶也撿不到免費的玫瑰花和蛋糕了,現在大家是都不買了?還是都不丟了?
哈哈!不過520了也不能啥都不送,作為程式設計師群體送給她一個3D炫彩愛心豈不美哉。
粒子想要組成愛心形狀需要用到一個數學公式 + 粒子採樣,我們可以把它比喻成「用小積木拼愛心」:
我們用到了經典的「愛心隱函數」,這是愛心形狀的「設計圖紙」,公式如下(已適配程式碼中的計算邏輯):
$(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。