又到了一年一度的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
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。