编写背景
最近刷抖音时,频繁刷到用 AI 编写炫酷的 3D+AI 手势交互粒子动画代码的视频。这类动画视觉层次细腻、动态效果灵动,已成内容创作新风口。作品里代码与艺术交融,让静态粒子汇聚成鲜活形态,既彰显前端技术魅力,又极具情感传递力,也让我萌生将这一技术潮流与即将到来的圣诞节结合的想法。
圣诞节满是温暖与祝福,传统贺卡、海报早已满足不了当下年轻人对 “仪式感 + 科技感” 的双重需求。大家更渴望可互动、可分享、能直观感知心意的祝福形式 —— 粒子特效恰好是绝佳载体:无数微小题粒子从混沌归为有序,最终汇聚成圣诞树形态,这一过程恰似 “思念与祝福凝聚成真” 的隐喻;而飘落的雪花、滚动的弹幕,更能为这份祝福增添沉浸式节日氛围。
恰逢 AI 开发工具日趋成熟,技术实现效率大幅提升。我选用Trae+GPT-5,基于 Vue3 与 Three.js 开发了这款单文件 HTML 页面,无需安装、点开即玩,让每个打开链接的人,都能在粒子汇聚的瞬间,感受圣诞的惊喜与温暖。
站点演示

页面代码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>圣诞快乐</title>
<style>
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: #000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, PingFangSC, "Microsoft YaHei", sans-serif; }
#app { position: relative; width: 100vw; height: 100vh; }
#canvas-container { position: absolute; inset: 0; }
.barrage { position: fixed; inset: 0; pointer-events: none; z-index: 9; }
.barrage-item { position: fixed; left: 100vw; white-space: nowrap; font-weight: 700; text-shadow: 0 0 8px rgba(255,255,255,0.35), 0 0 16px rgba(255,255,255,0.2); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35)); animation-name: fly; animation-timing-function: linear; animation-fill-mode: forwards; }
@keyframes fly { 0% { transform: translateX(0); } 100% { transform: translateX(-120vw); } }
.fullscreen-btn { position: fixed; right: 14px; bottom: 14px; z-index: 11; padding: 10px 14px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.12); color: #fff; font-weight: 600; letter-spacing: 0.5px; backdrop-filter: blur(4px); cursor: pointer; }
.fullscreen-btn:hover { background: rgba(255,255,255,0.18); }
@media (max-width: 768px) { .fullscreen-btn { padding: 9px 12px; right: 10px; bottom: 10px; font-size: 13px; } }
@media (max-width: 768px) {
.barrage-item { font-size: clamp(14px, 3.5vw, 20px); }
}
@media (min-width: 769px) {
.barrage-item { font-size: clamp(16px, 2.1vw, 28px); }
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createApp, ref, onMounted, h } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import * as THREE from 'https://unpkg.com/three@0.164.1/build/three.module.js';
const App = {
setup() {
const isFs = ref(false);
const barrageItems = ref([]);
const containerEl = ref(null);
let renderer, scene, camera, group;
let geoLeaves, matLeaves, pointsLeaves, posLeaves, colLeaves, velLeaves, tgtLeaves, startMorphLeaves;
let geoOrnaments, matOrnaments, pointsOrnaments, posOrnaments, colOrnaments, velOrnaments, tgtOrnaments, startMorphOrnaments;
let geoTrunk, matTrunk, pointsTrunk, posTrunk, colTrunk, velTrunk, tgtTrunk, startMorphTrunk;
let geoStar, matStar, pointsStar, posStar, colStar, velStar, tgtStar, startMorphStar;
let geoAccents, matAccents, pointsAccents, posAccents, colAccents, velAccents, tgtAccents, startMorphAccents;
let snowGroup, snowSprites, snowVel, snowRot, snowScale, snowOpacityPhase;
let startTime = 0;
const disperseDuration = 3500;
const morphDuration = 3000;
const particleCountLeaves = 5400;
const particleCountOrnaments = 400;
const particleCountTrunk = 1200;
const particleCountStar = 320;
const particleCountAccents = 160;
const snowCount = 200;
const treeHeight = 14;
const baseRadius = 6.2;
let running = true;
const topY = treeHeight + 1.2;
const bottomY = -1.4;
const messages = [
'圣诞快乐', '平安喜乐', '万事胜意', '心想事成', '前程似锦', '阖家幸福',
'福星高照', '岁岁常欢愉', '诸事顺遂', '新年好运常在', '健康平安', '幸福常伴'
];
function randomHsl() {
const h = Math.floor(Math.random() * 360);
const s = 68 + Math.random() * 22;
const l = 50 + Math.random() * 10;
return `hsl(${h}deg, ${s}%, ${l}%)`;
}
function createSnowflakeTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 128;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, 128, 128);
ctx.translate(64, 64);
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 2;
for (let i = 0; i < 6; i++) {
const ang = (Math.PI * 2 / 6) * i;
const x = Math.cos(ang) * 48;
const y = Math.sin(ang) * 48;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(x, y);
ctx.stroke();
for (let b = 1; b <= 3; b++) {
const t = b / 4;
const bx = Math.cos(ang) * 48 * t;
const by = Math.sin(ang) * 48 * t;
const sideAng = ang + Math.PI / 2;
const len = 10 * (1 - t);
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + Math.cos(sideAng) * len, by + Math.sin(sideAng) * len);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx - Math.cos(sideAng) * len, by - Math.sin(sideAng) * len);
ctx.stroke();
}
}
const tex = new THREE.CanvasTexture(c);
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
return tex;
}
function createSnowGlowTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 128;
const ctx = c.getContext('2d');
const grd = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
grd.addColorStop(0, 'rgba(255,255,255,0.57)');
grd.addColorStop(0.4, 'rgba(255,255,255,0.23)');
grd.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(c);
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
return tex;
}
function spawnBarrage() {
const count = 2 + Math.floor(Math.random() * 3);
const now = Date.now();
for (let i = 0; i < count; i++) {
const text = messages[Math.floor(Math.random() * messages.length)];
const top = Math.random() * 90 + 5;
const duration = 10 + Math.random() * 8;
const color = randomHsl();
const id = `${now}-${i}-${Math.random().toString(36).slice(2,7)}`;
barrageItems.value.push({ id, text, top: `${top}%`, duration: `${duration}s`, color, shadow: color });
setTimeout(() => {
const idx = barrageItems.value.findIndex(x => x.id === id);
if (idx >= 0) barrageItems.value.splice(idx, 1);
}, duration * 1000 + 400);
}
}
function enterFs() {
const el = document.getElementById('app') || containerEl.value || document.documentElement;
const rfs = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen;
if (rfs) rfs.call(el);
}
function exitFs() {
const efs = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
if (efs) efs.call(document);
}
function toggleFs() { if (document.fullscreenElement) exitFs(); else enterFs(); }
function computeLeafTargets(count) {
const arr = new Float32Array(count * 3);
const layers = 12;
for (let i = 0; i < count; i++) {
const layer = Math.floor(Math.random() * layers);
const yBase = (layer / (layers - 1)) * treeHeight;
const y = yBase + (Math.random() - 0.5) * (treeHeight * 0.035 + (1 - layer / layers) * 0.12);
const t = y / treeHeight;
const shelf = 0.45 * Math.sin(3.5 * (1 - t));
const wobble = 0.25 * Math.sin(8 * t + Math.random() * 0.8);
const r = Math.max(0.04, (baseRadius * (1 - t)) + shelf + wobble);
const a = Math.random() * Math.PI * 2;
const x = r * Math.cos(a);
const z = r * Math.sin(a);
arr[i * 3] = x;
arr[i * 3 + 1] = y;
arr[i * 3 + 2] = z;
}
return arr;
}
function computeTrunkTargets(count) {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const ty = -1 + Math.random() * (treeHeight * 0.22);
const tr = 0.35 + Math.random() * 0.22;
const ta = Math.random() * Math.PI * 2;
arr[i * 3] = tr * Math.cos(ta);
arr[i * 3 + 1] = ty;
arr[i * 3 + 2] = tr * Math.sin(ta);
}
return arr;
}
function computeOrnamentTargets(count) {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const y = Math.random() * treeHeight * 0.95;
const t = y / treeHeight;
const r = Math.max(0.05, (baseRadius * (1 - t)) + 0.3 * Math.sin(7 * t + Math.random()));
const a = Math.random() * Math.PI * 2;
const x = r * Math.cos(a);
const z = r * Math.sin(a);
arr[i * 3] = x;
arr[i * 3 + 1] = y;
arr[i * 3 + 2] = z;
}
return arr;
}
function computeStarTargets(count) {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const rBase = 0.75;
const r = rBase + 0.25 * Math.cos(5 * a) + (Math.random() - 0.5) * 0.12;
const x = r * Math.cos(a);
const z = r * Math.sin(a);
const y = treeHeight + 0.9 + (Math.random() - 0.5) * 0.35;
arr[i * 3] = x;
arr[i * 3 + 1] = y;
arr[i * 3 + 2] = z;
}
return arr;
}
function computeAccentsTargets(count) {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const y = Math.random() * treeHeight;
const t = y / treeHeight;
const r = Math.max(0.05, (baseRadius * (1 - t)) + 0.35 * Math.sin(6 * t + Math.random()));
const a = Math.random() * Math.PI * 2;
const x = r * Math.cos(a);
const z = r * Math.sin(a);
arr[i * 3] = x;
arr[i * 3 + 1] = y;
arr[i * 3 + 2] = z;
}
return arr;
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function ornamentColors(index) {
const palette = [
new THREE.Color(0xff4d6d), new THREE.Color(0xffb703), new THREE.Color(0x32c3ff),
new THREE.Color(0x8ce99a), new THREE.Color(0xb197fc), new THREE.Color(0xffaad4)
];
return palette[index % palette.length];
}
function initSet(count, size, colorFn, computeTargets) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const ix = i * 3;
pos[ix] = (Math.random() - 0.5) * 0.4;
pos[ix + 1] = (Math.random() - 0.5) * 0.4;
pos[ix + 2] = (Math.random() - 0.5) * 0.4;
let vx = (Math.random() - 0.5);
let vy = (Math.random() - 0.5) * 0.6;
let vz = (Math.random() - 0.5);
const s = 0.012 + Math.random() * 0.018;
vel[ix] = vx * s;
vel[ix + 1] = vy * s;
vel[ix + 2] = vz * s;
const c = colorFn(i);
col[ix] = c.r;
col[ix + 1] = c.g;
col[ix + 2] = c.b;
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
const mat = new THREE.PointsMaterial({ size, sizeAttenuation: true, transparent: true, opacity: 0.95, vertexColors: true, blending: THREE.AdditiveBlending, depthWrite: false });
const points = new THREE.Points(geo, mat);
const targets = computeTargets(count);
group.add(points);
return { geo, pos, col, vel, mat, points, targets };
}
function initThree() {
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x06101a, 0.035);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 6.5, 20);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
containerEl.value.appendChild(renderer.domElement);
group = new THREE.Group();
scene.add(group);
const ambient = new THREE.AmbientLight(0xffffff, 0.35);
scene.add(ambient);
const point = new THREE.PointLight(0xfff3e0, 3.2, 100);
point.position.set(4, treeHeight + 2, 6);
scene.add(point);
({ geo: geoLeaves, pos: posLeaves, col: colLeaves, vel: velLeaves, mat: matLeaves, points: pointsLeaves, targets: tgtLeaves } = initSet(
particleCountLeaves,
0.12,
() => {
const g = 0.35 + Math.random() * 0.65;
const r = 0.2 + 0.8 * Math.random();
const green = 0.5 + 0.5 * Math.random();
return new THREE.Color(r * g, green * g, 0.2 * g);
},
computeLeafTargets
));
({ geo: geoOrnaments, pos: posOrnaments, col: colOrnaments, vel: velOrnaments, mat: matOrnaments, points: pointsOrnaments, targets: tgtOrnaments } = initSet(
particleCountOrnaments,
0.14,
(i) => ornamentColors(i),
computeOrnamentTargets
));
({ geo: geoTrunk, pos: posTrunk, col: colTrunk, vel: velTrunk, mat: matTrunk, points: pointsTrunk, targets: tgtTrunk } = initSet(
particleCountTrunk,
0.12,
() => new THREE.Color(0.36, 0.24, 0.18),
computeTrunkTargets
));
({ geo: geoStar, pos: posStar, col: colStar, vel: velStar, mat: matStar, points: pointsStar, targets: tgtStar } = initSet(
particleCountStar,
0.12,
() => new THREE.Color(1.0, 0.83, 0.3),
computeStarTargets
));
({ geo: geoAccents, pos: posAccents, col: colAccents, vel: velAccents, mat: matAccents, points: pointsAccents, targets: tgtAccents } = initSet(
particleCountAccents,
0.22,
(i) => ornamentColors(i + 3),
computeAccentsTargets
));
const snowTex = createSnowflakeTexture();
const glowTex = createSnowGlowTexture();
snowGroup = new THREE.Group();
snowGroup.renderOrder = 0;
scene.add(snowGroup);
snowSprites = new Array(snowCount);
const snowHalos = new Array(snowCount);
snowVel = new Float32Array(snowCount * 2);
snowRot = new Float32Array(snowCount);
snowScale = new Float32Array(snowCount);
snowOpacityPhase = new Float32Array(snowCount);
for (let i = 0; i < snowCount; i++) {
const mat = new THREE.SpriteMaterial({ map: snowTex, color: 0xffffff, transparent: true, opacity: 1.0, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, fog: false });
const spr = new THREE.Sprite(mat);
const s = (1.6 + Math.random() * 1.6) * (2/3);
spr.scale.set(s, s, 1);
const x = (Math.random() - 0.5) * 120;
const y = 20 + Math.random() * 40;
const z = -22 - Math.random() * 12;
spr.position.set(x, y, z);
snowScale[i] = s;
snowVel[i * 2] = (Math.random() - 0.5) * 0.006;
snowVel[i * 2 + 1] = -0.008 - Math.random() * 0.01;
snowRot[i] = (-0.004 + Math.random() * 0.008);
snowOpacityPhase[i] = Math.random() * Math.PI * 2;
snowSprites[i] = spr;
snowGroup.add(spr);
const haloMat = new THREE.SpriteMaterial({ map: glowTex, color: 0xffffff, transparent: true, opacity: 0.6, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, fog: false });
const halo = new THREE.Sprite(haloMat);
halo.scale.set(s * 2.6, s * 2.6, 1);
halo.position.copy(spr.position);
snowHalos[i] = halo;
snowGroup.add(halo);
}
pointsLeaves.renderOrder = 1;
pointsOrnaments.renderOrder = 1;
pointsTrunk.renderOrder = 1;
pointsStar.renderOrder = 1;
pointsAccents.renderOrder = 1;
fitCamera();
startTime = performance.now();
animate();
window.addEventListener('resize', onResize);
}
function animate() {
if (!running) return;
const now = performance.now();
const elapsed = now - startTime;
function updateSet(geo, posArr, velArr, startMorphArr, targetsArr, count) {
const attr = geo.getAttribute('position');
const arr = attr.array;
if (elapsed < disperseDuration) {
const dt = 16;
for (let i = 0; i < count; i++) {
const ix = i * 3;
arr[ix] += velArr[ix] * dt;
arr[ix + 1] += velArr[ix + 1] * dt;
arr[ix + 2] += velArr[ix + 2] * dt;
}
} else {
if (!startMorphArr) {
startMorphArr = Float32Array.from(arr);
if (geo === geoLeaves) startMorphLeaves = startMorphArr;
if (geo === geoOrnaments) startMorphOrnaments = startMorphArr;
if (geo === geoTrunk) startMorphTrunk = startMorphArr;
if (geo === geoStar) startMorphStar = startMorphArr;
if (geo === geoAccents) startMorphAccents = startMorphArr;
}
const t = Math.min(1, (elapsed - disperseDuration) / morphDuration);
const e = easeInOutCubic(t);
for (let i = 0; i < count; i++) {
const ix = i * 3;
arr[ix] = startMorphArr[ix] + (targetsArr[ix] - startMorphArr[ix]) * e;
arr[ix + 1] = startMorphArr[ix + 1] + (targetsArr[ix + 1] - startMorphArr[ix + 1]) * e;
arr[ix + 2] = startMorphArr[ix + 2] + (targetsArr[ix + 2] - startMorphArr[ix + 2]) * e;
}
}
attr.needsUpdate = true;
}
const dt = 16;
for (let i = 0; i < snowCount; i++) {
const spr = snowSprites[i];
spr.position.x += snowVel[i * 2] * dt;
spr.position.y += snowVel[i * 2 + 1] * dt;
spr.material.rotation += snowRot[i];
const f = Math.min(1.0, 0.9 + 0.45 * Math.abs(Math.sin(now * 0.0015 + snowOpacityPhase[i])));
spr.material.opacity = 0.67 * f;
const halo = snowGroup.children[(i * 2) + 1];
if (halo) {
halo.position.copy(spr.position);
halo.material.opacity = Math.min(1.0, 0.47 * f);
}
if (spr.position.y < -30) {
spr.position.set((Math.random() - 0.5) * 120, 20 + Math.random() * 40, -22 - Math.random() * 12);
spr.material.rotation = Math.random() * Math.PI * 2;
if (halo) {
halo.position.copy(spr.position);
halo.material.rotation = spr.material.rotation;
}
}
}
updateSet(geoLeaves, posLeaves, velLeaves, startMorphLeaves, tgtLeaves, particleCountLeaves);
updateSet(geoOrnaments, posOrnaments, velOrnaments, startMorphOrnaments, tgtOrnaments, particleCountOrnaments);
updateSet(geoTrunk, posTrunk, velTrunk, startMorphTrunk, tgtTrunk, particleCountTrunk);
updateSet(geoStar, posStar, velStar, startMorphStar, tgtStar, particleCountStar);
updateSet(geoAccents, posAccents, velAccents, startMorphAccents, tgtAccents, particleCountAccents);
group.rotation.y += 0.0032;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
function onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
fitCamera();
}
function fitCamera() {
const aspect = camera.aspect;
const vfov = THREE.MathUtils.degToRad(camera.fov);
const halfH = (topY - bottomY) * 0.5 * 1.06;
const radNeeded = (baseRadius + 1.6) * 1.06;
const zH = halfH / Math.tan(vfov / 2);
const zW = radNeeded / (Math.tan(vfov / 2) * aspect);
const z = Math.max(zH, zW);
camera.position.z = Math.max(z, 20);
const centerY = (topY + bottomY) * 0.5;
camera.position.y = centerY;
camera.lookAt(0, centerY, 0);
}
onMounted(() => {
const el = document.createElement('div');
el.id = 'canvas-container';
document.getElementById('app').appendChild(el);
containerEl.value = el;
initThree();
setInterval(spawnBarrage, 1200);
document.addEventListener('fullscreenchange', () => { isFs.value = !!document.fullscreenElement; });
});
return () => h('div', { style: { width: '100%', height: '100%' } }, [
h('div', { class: 'barrage' }, barrageItems.value.map(item => h('div', {
key: item.id,
class: 'barrage-item',
style: {
top: item.top,
color: item.color,
animationDuration: item.duration,
}
}, item.text))),
h('button', { class: 'fullscreen-btn', onClick: toggleFs }, isFs.value ? '退出全屏' : '全屏'),
]);
}
};
createApp(App).mount('#app');
</script>
</body>
</html>
AI提示词
- 你是一名资深前端与图形工程师,使用 Vue3 与 Three.js 的 CDN 版本构建单文件 HTML(内联 CSS 与 `<script type="module">`)。
- 不使用打包工具或额外依赖;仅加载官方 CDN;遵循安全与性能最佳实践。
- 三维场景包含 Scene、PerspectiveCamera、WebGLRenderer,启用 SRGB、雾效与基础光源;渲染器像素比限制在合理范围。
- 所有效果基于独立粒子或精灵实现:分阶段发散→缓动汇聚为分层圣诞树,整组缓慢自转;背景雪花为 Sprite(含光晕),不遮挡主体;弹幕为 CSS 动画层。
- 移动端与 PC 适配:初始化与窗口缩放时相机自适应,保证树体在不同纵横比下完整可见。
**请按以下要求帮我编写代码**
- 使用 Vue3 + Three.js(CDN ESM)输出一个完整单文件 HTML 页面,包含:
- 粒子动画:从中心向外发散,再缓动汇聚为“分层”圣诞树;整体缓慢自转
- 粒子分集:树冠、主干、挂饰、顶部装饰与点缀均用粒子或精灵实现,并参与统一发散/汇聚
- 背景雪花:Sprite 雪花纹理与柔光光晕;轻微旋转、漂移与闪烁;层级最低且不遮挡树体
- 弹幕:多条、色彩丰富,从右向左飘过,位于画布上层
- 适配:相机自适应保证在移动端与 PC 均完整显示树体,`resize` 时更新
- 功能:右上角有一个全屏按钮,点击按钮页面全屏显示
- 禁用外部依赖与打包,保持代码清晰结构化:初始化、目标分布计算、动画循环、相机适配与 UI 层分离。