Gemini生成的3D交互圣诞树(娱乐版)

文章目录

一、简介

这是一个运行在浏览器中的 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
是否需要操作
是否支持手势 基础 完整
互动反馈
适合反复玩 一般
定位 看效果 玩效果

相关推荐
终极前端开发协会3 小时前
【web前端 - 齐枭飞】乾坤【qiankun】应用,主项目与子项目交互详细代码,里面有详细代码,可直接粘贴过去 直接应用 ,
前端·前端框架·交互
时光Autistic4 小时前
【搭建教程】腾讯混元3D模型部署
开发语言·python·3d·github
世界唯一最大变量5 小时前
实现了类似光线追踪的效果,用之前的车辆算法,自创的3d渲染算法,100物体时跑到了240帧
3d·html
一个没有感情的程序猿5 小时前
前端实现交互式3D人体肌肉解剖图:基于 Three.js + React Three Fiber 的完整方案
前端·javascript·3d
苏州知芯传感6 小时前
柔性抓取的“慧眼”:MEMS 3D视觉如何让机器人精准识别无序堆叠的复杂钣金件?
算法·3d·机器人·mems
数智前线7 小时前
火山引擎智能3D视频启动商业化,计划落地直播应用
3d·音视频·火山引擎
大刘讲IT7 小时前
精准检索-数据交互-专业交付:2026企业AI落地的三维价值重构
人工智能·程序人生·重构·交互·创业创新·制造
Cv打怪升级7 小时前
3D-Front数据集 json说明
3d·json
天远数科7 小时前
Node.js Crypto 模块详解:如何处理金融借贷信用风险探查加密数据交互
大数据·金融·node.js·交互