文章目录
-
- 一、简介
- 二、基础使用方式(两个版本通用)
-
- [1️⃣ 打开方式](#1️⃣ 打开方式)
- [2️⃣ 页面加载后你会看到什么?](#2️⃣ 页面加载后你会看到什么?)
- 三、版本一(v1)玩法说明:观赏型(含源码)
-
- [▶ v1 的核心特点](#▶ v1 的核心特点)
- [🎮 可以怎么玩?](#🎮 可以怎么玩?)
-
- [① 直接观看](#① 直接观看)
- [② 简单交互(可选)](#② 简单交互(可选))
- 四、版本二(v2)玩法说明:互动型(含源码)
-
- [▶ v2 新增的核心玩法](#▶ v2 新增的核心玩法)
- [🎮 主要玩法一:手势交互(摄像头)](#🎮 主要玩法一:手势交互(摄像头))
-
- [✋ 1. 张开手掌](#✋ 1. 张开手掌)
- [✊ 2. 收拢手掌](#✊ 2. 收拢手掌)
- [🤏 3. 捏合手势](#🤏 3. 捏合手势)
- [🎮 主要玩法二:上传图片](#🎮 主要玩法二:上传图片)
- [🎮 主要玩法三:音乐与界面控制(v2)](#🎮 主要玩法三:音乐与界面控制(v2))
- 五、两个版本的玩法对比总结
一、简介
这是一个运行在浏览器中的 3D 页面:
- 打开即用,无需安装
- 主要内容是一棵由大量几何体构成的圣诞树
- 页面会自动播放动画,并支持用户交互
整体定位:
👉 娱乐展示 + 交互体验
二、基础使用方式(两个版本通用)
1️⃣ 打开方式
- 新建txt文件,将源码粘贴进去,将txt后缀改为
.html - 直接用浏览器打开
index.html - 推荐使用 Chrome / Edge
- 首次打开会请求 摄像头权限(用于手势交互,可拒绝,推荐同意)
2️⃣ 页面加载后你会看到什么?
- 屏幕中央:一棵 3D 圣诞树
- 树会缓慢旋转、漂浮
- 场景中有光效、粒子和背景动画
即使你不做任何操作,画面也会持续运行。
三、版本一(v1)玩法说明:观赏型(含源码)

bash
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grand Luxury Tree Final v2</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000000; font-family: 'Times New Roman', serif; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
/* UI Overlay - Minimalist */
#ui-layer {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 10; pointer-events: none;
display: flex; flex-direction: column;
align-items: center;
padding-top: 40px;
box-sizing: border-box;
/* Remove transition here as we don't hide the whole layer anymore */
}
/* When hidden class is applied to specific elements */
.ui-hidden {
opacity: 0;
pointer-events: none !important;
}
/* Loading */
#loader {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: #000; z-index: 100;
display: flex; flex-direction: column; align-items: center; justify-content: center;
transition: opacity 0.8s ease-out;
}
.loader-text {
color: #d4af37; font-size: 14px; letter-spacing: 4px; margin-top: 20px;
text-transform: uppercase; font-weight: 100;
}
.spinner {
width: 40px; height: 40px; border: 1px solid rgba(212, 175, 55, 0.2);
border-top: 1px solid #d4af37; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* Typography - Centerpiece */
h1 {
color: #fceea7; font-size: 56px; margin: 0; font-weight: 400;
letter-spacing: 6px;
text-shadow: 0 0 50px rgba(252, 238, 167, 0.6);
background: linear-gradient(to bottom, #fff, #eebb66);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
font-family: 'Cinzel', 'Times New Roman', serif;
opacity: 0.9;
transition: opacity 0.5s ease; /* Ensure smooth transitions if needed */
}
/* Upload Button - Restored & Elegant */
.upload-wrapper {
margin-top: 20px;
pointer-events: auto;
text-align: center;
transition: opacity 0.5s ease; /* Add transition for smooth hiding */
}
.upload-btn {
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37;
padding: 10px 25px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 3px;
font-size: 10px;
transition: all 0.4s;
display: inline-block;
backdrop-filter: blur(5px);
}
.upload-btn:hover {
background: #d4af37;
color: #000;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
}
.hint-text {
color: rgba(212, 175, 55, 0.5);
font-size: 9px;
margin-top: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
#file-input { display: none; }
/* Webcam feedback */
#webcam-wrapper {
position: absolute; bottom: 40px; right: 40px;
width: 120px; height: 90px;
border: 1px solid rgba(255,255,255,0.1);
overflow: hidden; opacity: 0; /* Hidden by default but functional */
pointer-events: none;
}
</style>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap');
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/+esm"
}
}
</script>
</head>
<body>
<div id="loader">
<div class="spinner"></div>
<div class="loader-text">Loading Holiday Magic</div>
</div>
<div id="canvas-container"></div>
<div id="ui-layer">
<h1>Merry Christmas</h1>
<div class="upload-wrapper">
<label class="upload-btn">
Add Memories
<input type="file" id="file-input" multiple accept="image/*">
</label>
<div class="hint-text">Press 'H' to Hide Controls</div>
</div>
</div>
<!-- Webcam hidden structure -->
<div id="webcam-wrapper">
<video id="webcam" autoplay playsinline style="display:none;"></video>
<canvas id="webcam-preview"></canvas>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision';
// --- CONFIGURATION ---
const CONFIG = {
colors: {
bg: 0x000000,
champagneGold: 0xffd966,
deepGreen: 0x03180a,
accentRed: 0x990000,
},
particles: {
count: 1500,
dustCount: 2500,
treeHeight: 24,
treeRadius: 8
},
camera: {
z: 50
}
};
const STATE = {
mode: 'TREE',
focusIndex: -1,
focusTarget: null,
hand: { detected: false, x: 0, y: 0 },
rotation: { x: 0, y: 0 }
};
let scene, camera, renderer, composer;
let mainGroup;
let clock = new THREE.Clock();
let particleSystem = [];
let photoMeshGroup = new THREE.Group();
let handLandmarker, video, webcamCanvas, webcamCtx;
let caneTexture;
async function init() {
initThree();
setupEnvironment();
setupLights();
createTextures();
createParticles();
createDust();
createDefaultPhotos();
setupPostProcessing();
setupEvents();
await initMediaPipe();
const loader = document.getElementById('loader');
loader.style.opacity = 0;
setTimeout(() => loader.remove(), 800);
animate();
}
function initThree() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(CONFIG.colors.bg);
scene.fog = new THREE.FogExp2(CONFIG.colors.bg, 0.01);
camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 2, CONFIG.camera.z);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 2.2;
container.appendChild(renderer.domElement);
mainGroup = new THREE.Group();
scene.add(mainGroup);
}
function setupEnvironment() {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
}
function setupLights() {
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const innerLight = new THREE.PointLight(0xffaa00, 2, 20);
innerLight.position.set(0, 5, 0);
mainGroup.add(innerLight);
const spotGold = new THREE.SpotLight(0xffcc66, 1200);
spotGold.position.set(30, 40, 40);
spotGold.angle = 0.5;
spotGold.penumbra = 0.5;
scene.add(spotGold);
const spotBlue = new THREE.SpotLight(0x6688ff, 600);
spotBlue.position.set(-30, 20, -30);
scene.add(spotBlue);
const fill = new THREE.DirectionalLight(0xffeebb, 0.8);
fill.position.set(0, 0, 50);
scene.add(fill);
}
function setupPostProcessing() {
const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
bloomPass.threshold = 0.7;
bloomPass.strength = 0.45;
bloomPass.radius = 0.4;
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
}
function createTextures() {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0,0,128,128);
ctx.fillStyle = '#880000';
ctx.beginPath();
for(let i=-128; i<256; i+=32) {
ctx.moveTo(i, 0); ctx.lineTo(i+32, 128); ctx.lineTo(i+16, 128); ctx.lineTo(i-16, 0);
}
ctx.fill();
caneTexture = new THREE.CanvasTexture(canvas);
caneTexture.wrapS = THREE.RepeatWrapping;
caneTexture.wrapT = THREE.RepeatWrapping;
caneTexture.repeat.set(3, 3);
}
class Particle {
constructor(mesh, type, isDust = false) {
this.mesh = mesh;
this.type = type;
this.isDust = isDust;
this.posTree = new THREE.Vector3();
this.posScatter = new THREE.Vector3();
this.baseScale = mesh.scale.x;
// Individual Spin Speed
// Photos spin slower to be readable
const speedMult = (type === 'PHOTO') ? 0.3 : 2.0;
this.spinSpeed = new THREE.Vector3(
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult
);
this.calculatePositions();
}
calculatePositions() {
// TREE: Tight Spiral
const h = CONFIG.particles.treeHeight;
const halfH = h / 2;
let t = Math.random();
t = Math.pow(t, 0.8);
const y = (t * h) - halfH;
let rMax = CONFIG.particles.treeRadius * (1.0 - t);
if (rMax < 0.5) rMax = 0.5;
const angle = t * 50 * Math.PI + Math.random() * Math.PI;
const r = rMax * (0.8 + Math.random() * 0.4);
this.posTree.set(Math.cos(angle) * r, y, Math.sin(angle) * r);
// SCATTER: 3D Sphere
let rScatter = this.isDust ? (12 + Math.random()*20) : (8 + Math.random()*12);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
this.posScatter.set(
rScatter * Math.sin(phi) * Math.cos(theta),
rScatter * Math.sin(phi) * Math.sin(theta),
rScatter * Math.cos(phi)
);
}
update(dt, mode, focusTargetMesh) {
let target = this.posTree;
if (mode === 'SCATTER') target = this.posScatter;
else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) {
const desiredWorldPos = new THREE.Vector3(0, 2, 35);
const invMatrix = new THREE.Matrix4().copy(mainGroup.matrixWorld).invert();
target = desiredWorldPos.applyMatrix4(invMatrix);
} else {
target = this.posScatter;
}
}
// Movement Easing
const lerpSpeed = (mode === 'FOCUS' && this.mesh === focusTargetMesh) ? 5.0 : 2.0;
this.mesh.position.lerp(target, lerpSpeed * dt);
// Rotation Logic - CRITICAL: Ensure spin happens in Scatter
if (mode === 'SCATTER') {
this.mesh.rotation.x += this.spinSpeed.x * dt;
this.mesh.rotation.y += this.spinSpeed.y * dt;
this.mesh.rotation.z += this.spinSpeed.z * dt; // Added Z for more natural tumble
} else if (mode === 'TREE') {
// Reset rotations slowly
this.mesh.rotation.x = THREE.MathUtils.lerp(this.mesh.rotation.x, 0, dt);
this.mesh.rotation.z = THREE.MathUtils.lerp(this.mesh.rotation.z, 0, dt);
this.mesh.rotation.y += 0.5 * dt;
}
if (mode === 'FOCUS' && this.mesh === focusTargetMesh) {
this.mesh.lookAt(camera.position);
}
// Scale Logic
let s = this.baseScale;
if (this.isDust) {
s = this.baseScale * (0.8 + 0.4 * Math.sin(clock.elapsedTime * 4 + this.mesh.id));
if (mode === 'TREE') s = 0;
} else if (mode === 'SCATTER' && this.type === 'PHOTO') {
// Large preview size in scatter
s = this.baseScale * 2.5;
} else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) s = 4.5;
else s = this.baseScale * 0.8;
}
this.mesh.scale.lerp(new THREE.Vector3(s,s,s), 4*dt);
}
}
// --- CREATION ---
function createParticles() {
const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32);
const boxGeo = new THREE.BoxGeometry(0.55, 0.55, 0.55);
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, -0.5, 0), new THREE.Vector3(0, 0.3, 0),
new THREE.Vector3(0.1, 0.5, 0), new THREE.Vector3(0.3, 0.4, 0)
]);
const candyGeo = new THREE.TubeGeometry(curve, 16, 0.08, 8, false);
const goldMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.champagneGold,
metalness: 1.0, roughness: 0.1,
envMapIntensity: 2.0,
emissive: 0x443300,
emissiveIntensity: 0.3
});
const greenMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.deepGreen,
metalness: 0.2, roughness: 0.8,
emissive: 0x002200,
emissiveIntensity: 0.2
});
const redMat = new THREE.MeshPhysicalMaterial({
color: CONFIG.colors.accentRed,
metalness: 0.3, roughness: 0.2, clearcoat: 1.0,
emissive: 0x330000
});
const candyMat = new THREE.MeshStandardMaterial({ map: caneTexture, roughness: 0.4 });
for (let i = 0; i < CONFIG.particles.count; i++) {
const rand = Math.random();
let mesh, type;
if (rand < 0.40) {
mesh = new THREE.Mesh(boxGeo, greenMat);
type = 'BOX';
} else if (rand < 0.70) {
mesh = new THREE.Mesh(boxGeo, goldMat);
type = 'GOLD_BOX';
} else if (rand < 0.92) {
mesh = new THREE.Mesh(sphereGeo, goldMat);
type = 'GOLD_SPHERE';
} else if (rand < 0.97) {
mesh = new THREE.Mesh(sphereGeo, redMat);
type = 'RED';
} else {
mesh = new THREE.Mesh(candyGeo, candyMat);
type = 'CANE';
}
const s = 0.4 + Math.random() * 0.5;
mesh.scale.set(s,s,s);
mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6);
mainGroup.add(mesh);
particleSystem.push(new Particle(mesh, type, false));
}
const starGeo = new THREE.OctahedronGeometry(1.2, 0);
const starMat = new THREE.MeshStandardMaterial({
color: 0xffdd88, emissive: 0xffaa00, emissiveIntensity: 1.0,
metalness: 1.0, roughness: 0
});
const star = new THREE.Mesh(starGeo, starMat);
star.position.set(0, CONFIG.particles.treeHeight/2 + 1.2, 0);
mainGroup.add(star);
mainGroup.add(photoMeshGroup);
}
function createDust() {
const geo = new THREE.TetrahedronGeometry(0.08, 0);
const mat = new THREE.MeshBasicMaterial({ color: 0xffeebb, transparent: true, opacity: 0.8 });
for(let i=0; i<CONFIG.particles.dustCount; i++) {
const mesh = new THREE.Mesh(geo, mat);
mesh.scale.setScalar(0.5 + Math.random());
mainGroup.add(mesh);
particleSystem.push(new Particle(mesh, 'DUST', true));
}
}
function createDefaultPhotos() {
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#050505'; ctx.fillRect(0,0,512,512);
ctx.strokeStyle = '#eebb66'; ctx.lineWidth = 15; ctx.strokeRect(20,20,472,472);
ctx.font = '500 60px Times New Roman'; ctx.fillStyle = '#eebb66';
ctx.textAlign = 'center';
ctx.fillText("JOYEUX", 256, 230);
ctx.fillText("NOEL", 256, 300);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(tex);
}
function addPhotoToScene(texture) {
const frameGeo = new THREE.BoxGeometry(1.4, 1.4, 0.05);
const frameMat = new THREE.MeshStandardMaterial({ color: CONFIG.colors.champagneGold, metalness: 1.0, roughness: 0.1 });
const frame = new THREE.Mesh(frameGeo, frameMat);
const photoGeo = new THREE.PlaneGeometry(1.2, 1.2);
const photoMat = new THREE.MeshBasicMaterial({ map: texture });
const photo = new THREE.Mesh(photoGeo, photoMat);
photo.position.z = 0.04;
const group = new THREE.Group();
group.add(frame);
group.add(photo);
const s = 0.8;
group.scale.set(s,s,s);
photoMeshGroup.add(group);
particleSystem.push(new Particle(group, 'PHOTO', false));
}
function handleImageUpload(e) {
const files = e.target.files;
if(!files.length) return;
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = (ev) => {
new THREE.TextureLoader().load(ev.target.result, (t) => {
t.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(t);
});
}
reader.readAsDataURL(f);
});
}
// --- MEDIAPIPE ---
async function initMediaPipe() {
video = document.getElementById('webcam');
webcamCanvas = document.getElementById('webcam-preview');
webcamCtx = webcamCanvas.getContext('2d');
webcamCanvas.width = 160; webcamCanvas.height = 120;
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"
);
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: "GPU"
},
runningMode: "VIDEO",
numHands: 1
});
if (navigator.mediaDevices?.getUserMedia) {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.srcObject = stream;
video.addEventListener("loadeddata", predictWebcam);
}
}
let lastVideoTime = -1;
async function predictWebcam() {
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
if (handLandmarker) {
const result = handLandmarker.detectForVideo(video, performance.now());
processGestures(result);
}
}
requestAnimationFrame(predictWebcam);
}
function processGestures(result) {
if (result.landmarks && result.landmarks.length > 0) {
STATE.hand.detected = true;
const lm = result.landmarks[0];
STATE.hand.x = (lm[9].x - 0.5) * 2;
STATE.hand.y = (lm[9].y - 0.5) * 2;
const thumb = lm[4]; const index = lm[8]; const wrist = lm[0];
const pinchDist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
const tips = [lm[8], lm[12], lm[16], lm[20]];
let avgDist = 0;
tips.forEach(t => avgDist += Math.hypot(t.x - wrist.x, t.y - wrist.y));
avgDist /= 4;
if (pinchDist < 0.05) {
if (STATE.mode !== 'FOCUS') {
STATE.mode = 'FOCUS';
const photos = particleSystem.filter(p => p.type === 'PHOTO');
if (photos.length) STATE.focusTarget = photos[Math.floor(Math.random()*photos.length)].mesh;
}
} else if (avgDist < 0.25) {
STATE.mode = 'TREE';
STATE.focusTarget = null;
} else if (avgDist > 0.4) {
STATE.mode = 'SCATTER';
STATE.focusTarget = null;
}
} else {
STATE.hand.detected = false;
}
}
function setupEvents() {
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
document.getElementById('file-input').addEventListener('change', handleImageUpload);
// Toggle UI logic - ONLY hide controls, keep title
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'h') {
const controls = document.querySelector('.upload-wrapper');
if (controls) controls.classList.toggle('ui-hidden');
}
});
}
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
// Rotation Logic
if (STATE.mode === 'SCATTER' && STATE.hand.detected) {
const targetRotY = STATE.hand.x * Math.PI * 0.9;
const targetRotX = STATE.hand.y * Math.PI * 0.25;
STATE.rotation.y += (targetRotY - STATE.rotation.y) * 3.0 * dt;
STATE.rotation.x += (targetRotX - STATE.rotation.x) * 3.0 * dt;
} else {
if(STATE.mode === 'TREE') {
STATE.rotation.y += 0.3 * dt;
STATE.rotation.x += (0 - STATE.rotation.x) * 2.0 * dt;
} else {
STATE.rotation.y += 0.1 * dt;
}
}
mainGroup.rotation.y = STATE.rotation.y;
mainGroup.rotation.x = STATE.rotation.x;
particleSystem.forEach(p => p.update(dt, STATE.mode, STATE.focusTarget));
composer.render();
}
init();
</script>
</body>
</html>
▶ v1 的核心特点
- 偏展示
- 操作很少
- 画面稳定
🎮 可以怎么玩?
① 直接观看
- 打开页面后什么都不做
- 圣诞树会自动旋转
- 粒子和光效持续变化
👉 这是 v1 最主要的"玩法"。
② 简单交互(可选)
- 允许上传图片(作为装饰元素)
- 上传后,图片会作为"装饰"出现在树上
- 不影响整体结构,只是增加内容
四、版本二(v2)玩法说明:互动型(含源码)

bash
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Grand Luxury Tree - Refined Light</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000000; font-family: 'Times New Roman', serif; user-select: none; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
/* Start Overlay */
#start-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); z-index: 200;
display: flex; flex-direction: column; align-items: center; justify-content: center;
transition: opacity 0.8s ease; cursor: pointer;
}
.start-btn {
border: 1px solid #d4af37; color: #d4af37; padding: 15px 40px;
text-transform: uppercase; letter-spacing: 4px; font-size: 14px;
background: transparent; margin-top: 20px; transition: all 0.3s;
}
.start-btn:hover { background: #d4af37; color: #000; box-shadow: 0 0 30px rgba(212, 175, 55, 0.6); }
/* UI Layer */
#ui-layer {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center;
padding-top: 40px; box-sizing: border-box;
opacity: 0; transition: opacity 1s ease;
}
.ui-visible { opacity: 1 !important; }
h1 {
color: #fceea7; font-size: min(10vw, 56px); margin: 0; font-weight: 400;
letter-spacing: 6px;
text-shadow: 0 0 40px rgba(252, 238, 167, 0.6);
background: linear-gradient(to bottom, #fff, #eebb66);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
font-family: 'Cinzel', serif; text-align: center;
}
.subtitle { color: #886600; font-size: 10px; letter-spacing: 3px; margin-top: 10px; text-transform: uppercase; }
/* Controls */
.controls-wrapper {
position: absolute; bottom: 30px;
pointer-events: auto; display: flex; gap: 15px;
background: rgba(10,10,10,0.5); padding: 10px 20px; border-radius: 30px;
border: 1px solid rgba(212, 175, 55, 0.2); backdrop-filter: blur(4px);
}
.btn {
background: transparent; border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37; padding: 8px 18px; cursor: pointer;
text-transform: uppercase; letter-spacing: 2px; font-size: 10px;
transition: all 0.3s; display: flex; align-items: center; gap: 5px; border-radius: 15px;
}
.btn:hover { background: #d4af37; color: #000; box-shadow: 0 0 15px rgba(212, 175, 55, 0.5); }
#file-input { display: none; }
/* Webcam feedback */
#webcam-wrapper {
position: absolute; bottom: 80px; right: 20px;
width: 120px; height: 90px;
border: 1px solid rgba(255,255,255,0.1);
opacity: 0; pointer-events: none; transition: opacity 0.3s;
}
</style>
<style>@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap');</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/+esm"
}
}
</script>
</head>
<body>
<!-- Start Screen -->
<div id="start-overlay">
<h1 style="font-size: 40px;">Luxury Tree</h1>
<div class="start-btn" id="start-btn">Enter Experience</div>
</div>
<div id="canvas-container"></div>
<div id="ui-layer">
<h1>Merry Christmas</h1>
<div class="subtitle">Interactive 3D Experience</div>
<div class="controls-wrapper">
<label class="btn">
📷 Add Photos
<input type="file" id="file-input" multiple accept="image/*">
</label>
<button class="btn" id="music-btn">🎵 Music: OFF</button>
<button class="btn" id="hide-btn">👁️ Hide</button>
</div>
</div>
<div id="webcam-wrapper">
<video id="webcam" autoplay playsinline style="display:none;"></video>
<canvas id="webcam-preview"></canvas>
</div>
<!-- Default Audio -->
<audio id="bgm" loop>
<source src="https://cdn.pixabay.com/download/audio/2022/11/22/audio_febc508520.mp3?filename=christmas-magic-126526.mp3" type="audio/mpeg">
</audio>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision';
// --- CONFIG ---
const CONFIG = {
colors: {
bg: 0x000000,
gold: 0xffd700,
darkGreen: 0x03180a,
red: 0x880000,
},
particles: {
count: 1800,
dustCount: 4000,
treeHeight: 26,
treeRadius: 9
}
};
const STATE = {
mode: 'TREE',
focusTarget: null,
hand: { detected: false, x: 0, y: 0 },
rotation: { x: 0, y: 0 },
time: 0
};
let scene, camera, renderer, composer;
let mainGroup, snowSystem;
let clock = new THREE.Clock();
let particleSystem = [];
let photoMeshGroup = new THREE.Group();
let handLandmarker, video, webcamCanvas, webcamCtx;
let caneTexture;
let isMusicPlaying = false;
// --- INIT ---
document.getElementById('start-btn').addEventListener('click', () => {
const overlay = document.getElementById('start-overlay');
overlay.style.opacity = 0;
setTimeout(() => overlay.remove(), 800);
document.getElementById('ui-layer').classList.add('ui-visible');
init();
});
async function init() {
initThree();
setupEnvironment();
createTextures();
createParticles();
createSnow();
createDefaultPhotos();
setupPostProcessing();
setupEvents();
animate();
initMediaPipe();
}
function initThree() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(CONFIG.colors.bg);
scene.fog = new THREE.FogExp2(CONFIG.colors.bg, 0.015);
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 2, 45);
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.8;
container.appendChild(renderer.domElement);
mainGroup = new THREE.Group();
scene.add(mainGroup);
}
function setupEnvironment() {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
const innerLight = new THREE.PointLight(0xffaa00, 3, 25);
innerLight.position.set(0, 5, 0);
mainGroup.add(innerLight);
const spotGold = new THREE.SpotLight(0xffcc66, 800);
spotGold.position.set(30, 40, 40);
spotGold.angle = 0.6;
scene.add(spotGold);
const spotRim = new THREE.SpotLight(0x4455aa, 400);
spotRim.position.set(-30, 20, -30);
scene.add(spotRim);
}
function setupPostProcessing() {
const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
bloomPass.threshold = 0.2;
bloomPass.strength = 0.8;
bloomPass.radius = 0.5;
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
}
function createTextures() {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,128,128);
ctx.fillStyle = '#880000';
ctx.beginPath();
for(let i=-128; i<256; i+=32) {
ctx.moveTo(i, 0); ctx.lineTo(i+32, 128); ctx.lineTo(i+16, 128); ctx.lineTo(i-16, 0);
}
ctx.fill();
caneTexture = new THREE.CanvasTexture(canvas);
caneTexture.wrapS = THREE.RepeatWrapping; caneTexture.wrapT = THREE.RepeatWrapping;
caneTexture.repeat.set(3, 3);
}
class Particle {
constructor(mesh, type) {
this.mesh = mesh;
this.type = type;
this.posTree = new THREE.Vector3();
this.posScatter = new THREE.Vector3();
this.baseScale = mesh.scale.x;
this.randomPhase = Math.random() * Math.PI * 2;
const speedMult = (type === 'PHOTO') ? 0.3 : 2.0;
this.spinSpeed = new THREE.Vector3(
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult
);
this.calculatePositions();
}
calculatePositions() {
const h = CONFIG.particles.treeHeight;
let t = Math.random();
t = Math.pow(t, 0.8);
const y = (t * h) - (h/2);
let rMax = CONFIG.particles.treeRadius * (1.0 - t + 0.1);
const r = rMax * (0.3 + 0.7 * Math.sqrt(Math.random()));
const angle = t * 40 + Math.random() * Math.PI * 2;
this.posTree.set(Math.cos(angle) * r, y, Math.sin(angle) * r);
let rScatter = 15 + Math.random() * 15;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
this.posScatter.set(
rScatter * Math.sin(phi) * Math.cos(theta),
rScatter * Math.sin(phi) * Math.sin(theta),
rScatter * Math.cos(phi)
);
}
update(dt, mode, focusTargetMesh, time) {
let target = this.posTree;
if (mode === 'SCATTER') target = this.posScatter;
else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) {
const invMatrix = new THREE.Matrix4().copy(mainGroup.matrixWorld).invert();
target = new THREE.Vector3(0, 1, 38).applyMatrix4(invMatrix);
} else {
target = this.posScatter;
}
}
const floatY = Math.sin(time * 2 + this.randomPhase) * 0.1;
const floatTarget = target.clone().add(new THREE.Vector3(0, floatY, 0));
const lerpSpeed = (mode === 'FOCUS' && this.mesh === focusTargetMesh) ? 5.0 : 2.0;
this.mesh.position.lerp(floatTarget, lerpSpeed * dt);
if (mode === 'SCATTER') {
this.mesh.rotation.x += this.spinSpeed.x * dt;
this.mesh.rotation.y += this.spinSpeed.y * dt;
} else if (mode === 'TREE') {
this.mesh.rotation.x *= (1 - dt);
this.mesh.rotation.z *= (1 - dt);
this.mesh.rotation.y += 0.5 * dt;
}
let s = this.baseScale;
if (this.type === 'PHOTO') {
if (mode === 'FOCUS' && this.mesh === focusTargetMesh) {
this.mesh.lookAt(camera.position);
s = 4.0;
} else if (mode === 'TREE') {
this.mesh.lookAt(0, this.mesh.position.y, 0);
this.mesh.rotateY(Math.PI);
s = this.baseScale * 1.5;
} else if (mode === 'FOCUS') {
s = 0;
}
} else if (mode === 'TREE') {
const twinkle = Math.sin(time * 4 + this.randomPhase);
if (this.type === 'GOLD_SPHERE') {
this.mesh.material.emissiveIntensity = 0.5 + twinkle * 0.3;
}
}
this.mesh.scale.lerp(new THREE.Vector3(s,s,s), 4*dt);
}
}
function createParticles() {
const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16);
const boxGeo = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, -0.5, 0), new THREE.Vector3(0, 0.3, 0),
new THREE.Vector3(0.1, 0.5, 0), new THREE.Vector3(0.3, 0.4, 0)
]);
const candyGeo = new THREE.TubeGeometry(curve, 16, 0.08, 8, false);
const goldMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.gold,
metalness: 1.0, roughness: 0.1,
emissive: 0xaa6600, emissiveIntensity: 0.4
});
const greenMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.darkGreen,
metalness: 0.2, roughness: 0.8,
});
const redMat = new THREE.MeshPhysicalMaterial({
color: CONFIG.colors.red,
metalness: 0.4, roughness: 0.2, clearcoat: 1.0,
emissive: 0x440000, emissiveIntensity: 0.2
});
const candyMat = new THREE.MeshStandardMaterial({ map: caneTexture, roughness: 0.4 });
for (let i = 0; i < CONFIG.particles.count; i++) {
const rand = Math.random();
let mesh, type;
if (rand < 0.40) {
mesh = new THREE.Mesh(boxGeo, greenMat);
type = 'BOX';
} else if (rand < 0.70) {
mesh = new THREE.Mesh(boxGeo, goldMat);
type = 'GOLD_BOX';
} else if (rand < 0.90) {
mesh = new THREE.Mesh(sphereGeo, goldMat);
type = 'GOLD_SPHERE';
} else if (rand < 0.96) {
mesh = new THREE.Mesh(sphereGeo, redMat);
type = 'RED';
} else {
mesh = new THREE.Mesh(candyGeo, candyMat);
type = 'CANE';
}
const s = 0.4 + Math.random() * 0.5;
mesh.scale.set(s,s,s);
mainGroup.add(mesh);
particleSystem.push(new Particle(mesh, type));
}
const starGeo = new THREE.OctahedronGeometry(1.5, 0);
const starMat = new THREE.MeshBasicMaterial({ color: 0xffffaa });
const star = new THREE.Mesh(starGeo, starMat);
star.position.set(0, CONFIG.particles.treeHeight/2 + 1.2, 0);
mainGroup.add(star);
const starLight = new THREE.PointLight(0xffffee, 2, 10);
starLight.position.copy(star.position);
mainGroup.add(starLight);
mainGroup.add(photoMeshGroup);
}
function createSnow() {
const geometry = new THREE.BufferGeometry();
const count = CONFIG.particles.dustCount;
const posArray = new Float32Array(count * 3);
const velArray = [];
for(let i=0; i<count; i++) {
posArray[i*3] = (Math.random()-0.5) * 100;
posArray[i*3+1] = (Math.random()-0.5) * 100;
posArray[i*3+2] = (Math.random()-0.5) * 100;
velArray.push(0.05 + Math.random() * 0.1);
}
geometry.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
const mat = new THREE.PointsMaterial({
color: 0xffffff, size: 0.3, transparent: true, opacity: 0.6,
blending: THREE.AdditiveBlending, depthWrite: false
});
snowSystem = new THREE.Points(geometry, mat);
snowSystem.userData = { vels: velArray };
scene.add(snowSystem);
}
function createDefaultPhotos() {
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#050505'; ctx.fillRect(0,0,512,512);
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 20; ctx.strokeRect(20,20,472,472);
ctx.font = 'bold 70px Times New Roman'; ctx.fillStyle = '#ffd700';
ctx.textAlign = 'center';
ctx.fillText("JOYEUX", 256, 220); ctx.fillText("NOEL", 256, 310);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(tex);
}
// --- KEY MODIFICATION HERE ---
function addPhotoToScene(texture) {
const frameGeo = new THREE.BoxGeometry(1.4, 1.4, 0.05);
// 1. Frame Material: Adjusted to be Matte Gold (Not Shiny Mirror)
// Lower metalness, Higher roughness, Darker base color
const frameMat = new THREE.MeshStandardMaterial({
color: 0xb8860b, // Dark Golden Rod (deeper base)
metalness: 0.4, // Reduced from 1.0 (Less reflection)
roughness: 0.7, // Increased from 0.1 (More matte/diffuse)
emissive: 0x000000 // No glow on frame
});
const frame = new THREE.Mesh(frameGeo, frameMat);
const photoGeo = new THREE.PlaneGeometry(1.2, 1.2);
// 2. Photo Material: Basic (Unaffected by shadows) but Dimmed
// Color is 0xcccccc instead of white to prevent Bloom blowout
const photoMat = new THREE.MeshBasicMaterial({
map: texture,
color: 0xcccccc
});
const photo = new THREE.Mesh(photoGeo, photoMat);
photo.position.z = 0.04;
const group = new THREE.Group();
group.add(frame);
group.add(photo);
const s = 0.8;
group.scale.set(s,s,s);
photoMeshGroup.add(group);
particleSystem.push(new Particle(group, 'PHOTO'));
}
async function initMediaPipe() {
video = document.getElementById('webcam');
webcamCanvas = document.getElementById('webcam-preview');
webcamCtx = webcamCanvas.getContext('2d');
webcamCanvas.width = 160; webcamCanvas.height = 120;
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"
);
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: "GPU"
},
runningMode: "VIDEO",
numHands: 1
});
if (navigator.mediaDevices?.getUserMedia) {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.srcObject = stream;
video.addEventListener("loadeddata", predictWebcam);
}
}
let lastVideoTime = -1;
async function predictWebcam() {
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
if (handLandmarker) {
const result = handLandmarker.detectForVideo(video, performance.now());
processGestures(result);
}
}
requestAnimationFrame(predictWebcam);
}
function processGestures(result) {
if (result.landmarks && result.landmarks.length > 0) {
STATE.hand.detected = true;
const lm = result.landmarks[0];
STATE.hand.x = (lm[9].x - 0.5) * 2;
STATE.hand.y = (lm[9].y - 0.5) * 2;
const thumb = lm[4]; const index = lm[8]; const wrist = lm[0];
const pinchDist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
const tips = [lm[8], lm[12], lm[16], lm[20]];
let avgDist = 0;
tips.forEach(t => avgDist += Math.hypot(t.x - wrist.x, t.y - wrist.y));
avgDist /= 4;
if (pinchDist < 0.05) {
if (STATE.mode !== 'FOCUS') {
STATE.mode = 'FOCUS';
const photos = particleSystem.filter(p => p.type === 'PHOTO');
if (photos.length) STATE.focusTarget = photos[Math.floor(Math.random()*photos.length)].mesh;
}
} else if (avgDist < 0.25) {
STATE.mode = 'TREE';
STATE.focusTarget = null;
} else if (avgDist > 0.4) {
STATE.mode = 'SCATTER';
STATE.focusTarget = null;
}
} else {
STATE.hand.detected = false;
}
}
function setupEvents() {
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
document.getElementById('file-input').addEventListener('change', (e) => {
const files = e.target.files;
if(!files.length) return;
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = (ev) => {
new THREE.TextureLoader().load(ev.target.result, (t) => {
t.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(t);
});
}
reader.readAsDataURL(f);
});
});
document.getElementById('music-btn').addEventListener('click', (e) => {
const bgm = document.getElementById('bgm');
const btn = e.target;
if(isMusicPlaying) {
bgm.pause();
btn.textContent = "🎵 Music: OFF";
isMusicPlaying = false;
} else {
bgm.play();
btn.textContent = "🎵 Music: ON";
isMusicPlaying = true;
}
});
document.getElementById('hide-btn').addEventListener('click', () => {
document.getElementById('ui-layer').classList.remove('ui-visible');
setTimeout(() => {
window.addEventListener('click', function show(){
document.getElementById('ui-layer').classList.add('ui-visible');
window.removeEventListener('click', show);
}, {once:true});
}, 500);
});
}
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
STATE.time += dt;
if(snowSystem) {
const pos = snowSystem.geometry.attributes.position.array;
const vels = snowSystem.userData.vels;
for(let i=0; i<vels.length; i++) {
pos[i*3 + 1] -= vels[i] * dt * 10;
pos[i*3] += Math.sin(STATE.time + i) * 0.02;
if(pos[i*3 + 1] < -50) pos[i*3 + 1] = 50;
}
snowSystem.geometry.attributes.position.needsUpdate = true;
snowSystem.rotation.y += 0.05 * dt;
}
if (STATE.mode === 'SCATTER' && STATE.hand.detected) {
const targetRotY = STATE.hand.x * Math.PI * 0.9;
const targetRotX = STATE.hand.y * Math.PI * 0.25;
STATE.rotation.y += (targetRotY - STATE.rotation.y) * 3.0 * dt;
STATE.rotation.x += (targetRotX - STATE.rotation.x) * 3.0 * dt;
} else {
if(STATE.mode === 'TREE') {
STATE.rotation.y += 0.2 * dt;
STATE.rotation.x += (0 - STATE.rotation.x) * 2.0 * dt;
} else {
STATE.rotation.y += 0.1 * dt;
}
}
mainGroup.rotation.y = STATE.rotation.y;
mainGroup.rotation.x = STATE.rotation.x;
particleSystem.forEach(p => p.update(dt, STATE.mode, STATE.focusTarget, STATE.time));
composer.render();
}
</script>
</body>
</html>
▶ v2 新增的核心玩法
v2 的玩法可以概括为一句话:
你做不同的动作,树会切换不同状态
🎮 主要玩法一:手势交互(摄像头)
如果你允许浏览器使用摄像头:
✋ 1. 张开手掌
- 树的装饰会向外散开
- 整体变成"散射"状态
- 画面更动态、更活跃
✊ 2. 收拢手掌
- 装饰会重新聚合
- 树回到完整形态
- 画面变得稳定
🤏 3. 捏合手势
- 随机选中一个装饰元素
- 将其放大并居中展示
- 类似"聚焦查看"
不同手势 ≈ 不同展示模式
不需要很标准,稍微比划即可触发
🎮 主要玩法二:上传图片
- 点击页面按钮选择图片
- 每张图片都会作为一个"相框装饰"
- 自动加入到树的结构中
玩法特点:
- 上传越多,装饰越丰富
- 画面会越来越"满"
🎮 主要玩法三:音乐与界面控制(v2)
- 可开启 / 关闭背景音乐
- 可隐藏 UI,只保留画面
- 适合全屏展示或录屏
五、两个版本的玩法对比总结
| 项目 | v1 | v2 |
|---|---|---|
| 是否需要操作 | 否 | 是 |
| 是否支持手势 | 基础 | 完整 |
| 互动反馈 | 弱 | 强 |
| 适合反复玩 | 一般 | 是 |
| 定位 | 看效果 | 玩效果 |