├── public ├── photos │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ ├── 9.jpg │ ├── 10.jpg │ ├── 11.jpg │ ├── 12.jpg │ ├── 13.jpg │ ├── 14.jpg │ ├── 15.jpg │ ├── 16.jpg │ ├── 17.jpg │ ├── 18.jpg │ ├── 19.jpg │ ├── 20.jpg │ ├── 21.jpg │ ├── 22.jpg │ ├── 23.jpg │ ├── 24.jpg │ ├── 25.jpg │ ├── 26.jpg │ ├── 27.jpg │ ├── 28.jpg │ ├── 29.jpg │ ├── 30.jpg │ ├── 31.jpg │ └── top.jpg └── vite.svg ├── tsconfig.json ├── vite.config.ts ├── src ├── App.css ├── main.tsx ├── index.css ├── assets │ └── react.svg └── App.tsx ├── .gitignore ├── index.html ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── package.json └── README.md /public/photos/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/1.jpg -------------------------------------------------------------------------------- /public/photos/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/2.jpg -------------------------------------------------------------------------------- /public/photos/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/3.jpg -------------------------------------------------------------------------------- /public/photos/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/4.jpg -------------------------------------------------------------------------------- /public/photos/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/5.jpg -------------------------------------------------------------------------------- /public/photos/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/6.jpg -------------------------------------------------------------------------------- /public/photos/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/7.jpg -------------------------------------------------------------------------------- /public/photos/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/8.jpg -------------------------------------------------------------------------------- /public/photos/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/9.jpg -------------------------------------------------------------------------------- /public/photos/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/10.jpg -------------------------------------------------------------------------------- /public/photos/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/11.jpg -------------------------------------------------------------------------------- /public/photos/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/12.jpg -------------------------------------------------------------------------------- /public/photos/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/13.jpg -------------------------------------------------------------------------------- /public/photos/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/14.jpg -------------------------------------------------------------------------------- /public/photos/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/15.jpg -------------------------------------------------------------------------------- /public/photos/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/16.jpg -------------------------------------------------------------------------------- /public/photos/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/17.jpg -------------------------------------------------------------------------------- /public/photos/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/18.jpg -------------------------------------------------------------------------------- /public/photos/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/19.jpg -------------------------------------------------------------------------------- /public/photos/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/20.jpg -------------------------------------------------------------------------------- /public/photos/21.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/21.jpg -------------------------------------------------------------------------------- /public/photos/22.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/22.jpg -------------------------------------------------------------------------------- /public/photos/23.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/23.jpg -------------------------------------------------------------------------------- /public/photos/24.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/24.jpg -------------------------------------------------------------------------------- /public/photos/25.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/25.jpg -------------------------------------------------------------------------------- /public/photos/26.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/26.jpg -------------------------------------------------------------------------------- /public/photos/27.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/27.jpg -------------------------------------------------------------------------------- /public/photos/28.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/28.jpg -------------------------------------------------------------------------------- /public/photos/29.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/29.jpg -------------------------------------------------------------------------------- /public/photos/30.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/30.jpg -------------------------------------------------------------------------------- /public/photos/31.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/31.jpg -------------------------------------------------------------------------------- /public/photos/top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moleculemmeng020425/christmas-tree/HEAD/public/photos/top.jpg -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* src/index.css */ 2 | body, html { 3 | margin: 0; 4 | padding: 0; 5 | width: 100%; 6 | height: 100%; 7 | background-color: #000000; 8 | overflow: hidden; /* 彻底禁止滚动 */ 9 | } 10 | 11 | #root { 12 | width: 100vw; 13 | height: 100vh; 14 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | christmas-tree 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "christmas-tree", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@mediapipe/tasks-vision": "^0.10.22-rc.20250304", 14 | "@react-three/drei": "^9.117.0", 15 | "@react-three/fiber": "^8.17.10", 16 | "@react-three/postprocessing": "^2.16.3", 17 | "maath": "^0.10.8", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "three": "^0.169.0", 21 | "uuid": "^11.0.3" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.14.0", 25 | "@types/node": "^22.9.0", 26 | "@types/react": "^18.3.12", 27 | "@types/react-dom": "^18.3.1", 28 | "@types/three": "^0.169.0", 29 | "@vitejs/plugin-react": "^4.3.3", 30 | "eslint": "^9.14.0", 31 | "eslint-plugin-react-hooks": "^5.0.0", 32 | "eslint-plugin-react-refresh": "^0.4.14", 33 | "globals": "^15.11.0", 34 | "typescript": "~5.6.2", 35 | "typescript-eslint": "^8.11.0", 36 | "vite": "^5.4.11" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎄 Grand Luxury Interactive 3D Christmas Tree 2 | 3 | > 一个基于 **React**, **Three.js (R3F)** 和 **AI 手势识别** 的高保真 3D 圣诞树 Web 应用。 4 | 5 | 这个项目不仅仅是一棵树,它是一个承载记忆的交互式画廊。成百上千个粒子、璀璨的彩灯和悬浮的拍立得照片共同组成了一棵奢华的圣诞树。用户可以通过手势控制树的形态(聚合/散开)和视角旋转,体验电影级的视觉盛宴。 6 | 7 | ![Project Preview](public/preview.png) 8 | *(注:建议在此处上传一张你的项目运行截图)* 9 | 10 | ## ✨ 核心特性 11 | 12 | * **极致视觉体验**:由 45,000+ 个发光粒子组成的树身,配合动态光晕 (Bloom) 和辉光效果,营造梦幻氛围。 13 | * **记忆画廊**:照片以“拍立得”风格悬浮在树上,每一张都是一个独立的发光体,支持双面渲染。 14 | * **AI 手势控制**:无需鼠标,通过摄像头捕捉手势即可控制树的形态(聚合/散开)和视角旋转。 15 | * **丰富细节**:包含动态闪烁的彩灯、飘落的金银雪花、以及随机分布的圣诞礼物和糖果装饰。 16 | * **高度可定制**:**支持用户轻松替换为自己的照片,并自由调整照片数量。** 17 | 18 | ## 🛠️ 技术栈 19 | 20 | * **框架**: React 18, Vite 21 | * **3D 引擎**: React Three Fiber (Three.js) 22 | * **工具库**: @react-three/drei, Maath 23 | * **后期处理**: @react-three/postprocessing 24 | * **AI 视觉**: MediaPipe Tasks Vision (Google) 25 | 26 | ## 🚀 快速开始 27 | 28 | ### 1. 环境准备 29 | 确保你的电脑已安装 [Node.js](https://nodejs.org/) (建议 v18 或更高版本)。 30 | 31 | ### 2. 安装依赖 32 | 在项目根目录下打开终端,运行:```bash npm install 33 | ### 3. 启动项目 34 | npm run dev 35 | ### 🖼️ 自定义照片 36 | ### 1. 准备照片 37 | 找到项目目录下的 public/photos/ 文件夹。 38 | 39 | 顶端大图/封面图:命名为 top.jpg(将显示在树顶的立体五角星上)。 40 | 41 | 树身照片:命名为 1.jpg, 2.jpg, 3.jpg ... 依次类推。 42 | 43 | 建议:使用正方形或 4:3 比例的图片,文件大小不宜过大(建议单张 500kb 以内以保证流畅度) 44 | ### 2. 替换照片 45 | 直接将你自己的照片复制到 public/photos/ 文件夹中,覆盖原有的图片即可。请保持文件名格式不变(1.jpg, 2.jpg 等)。 46 | ### 3. 修改照片数量 (增加或减少) 47 | 如果你放入了更多照片(例如从默认的 31 张增加到 100 张),需要修改代码以通知程序加载它们。 48 | 打开文件:src/App.tsx 49 | 找到大约 第 19 行 的代码:// --- 动态生成照片列表 (top.jpg + 1.jpg 到 31.jpg) --- 50 | const TOTAL_NUMBERED_PHOTOS = 31; // <--- 修改这个数字! 51 | ### 🖐️ 手势控制说明 52 | * **本项目内置了 AI 手势识别系统,请站在摄像头前进行操作(屏幕右下角有 DEBUG 按钮可查看摄像头画面)**: 53 | 🖐 张开手掌 (Open Palm) Disperse (散开) 圣诞树炸裂成漫天飞舞的粒子和照片 54 | ✊ 握紧拳头 (Closed Fist) Assemble (聚合) 所有元素瞬间聚合成一棵完美的圣诞树 55 | 👋 手掌左右移动 旋转视角 手向左移,树向左转;手向右移,树向右转 56 | 👋 手掌上下移动 俯仰视角 手向上移,视角抬高;手向下移,视角降低 57 | ### ⚙️ 进阶配置 58 | * **如果你熟悉代码,可以在 src/App.tsx 中的 CONFIG 对象里调整更多视觉参数**: 59 | const CONFIG = { 60 | colors: { ... }, // 修改树、灯光、边框的颜色 61 | counts: { 62 | foliage: 15000, // 修改树叶粒子数量 (配置低可能会卡) 63 | ornaments: 300, // 修改悬挂的照片/拍立得数量 64 | lights: 400 // 修改彩灯数量 65 | }, 66 | tree: { height: 22, radius: 9 }, // 修改树的大小 67 | // ... 68 | }; 69 | ### 📄 License 70 | MIT License. Feel free to use and modify for your own holiday celebrations! 71 | ### Merry Christmas! 🎄✨ 72 | 73 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useRef, useEffect, Suspense } from 'react'; 2 | import { Canvas, useFrame, extend } from '@react-three/fiber'; 3 | import { 4 | OrbitControls, 5 | Environment, 6 | PerspectiveCamera, 7 | shaderMaterial, 8 | Float, 9 | Stars, 10 | Sparkles, 11 | useTexture 12 | } from '@react-three/drei'; 13 | import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing'; 14 | import * as THREE from 'three'; 15 | import { MathUtils } from 'three'; 16 | import * as random from 'maath/random'; 17 | import { GestureRecognizer, FilesetResolver, DrawingUtils } from "@mediapipe/tasks-vision"; 18 | 19 | // --- 动态生成照片列表 (top.jpg + 1.jpg 到 31.jpg) --- 20 | const TOTAL_NUMBERED_PHOTOS = 31; 21 | // 修改:将 top.jpg 加入到数组开头 22 | const bodyPhotoPaths = [ 23 | '/photos/top.jpg', 24 | ...Array.from({ length: TOTAL_NUMBERED_PHOTOS }, (_, i) => `/photos/${i + 1}.jpg`) 25 | ]; 26 | 27 | // --- 视觉配置 --- 28 | const CONFIG = { 29 | colors: { 30 | emerald: '#004225', // 纯正祖母绿 31 | gold: '#FFD700', 32 | silver: '#ECEFF1', 33 | red: '#D32F2F', 34 | green: '#2E7D32', 35 | white: '#FFFFFF', // 纯白色 36 | warmLight: '#FFD54F', 37 | lights: ['#FF0000', '#00FF00', '#0000FF', '#FFFF00'], // 彩灯 38 | // 拍立得边框颜色池 (复古柔和色系) 39 | borders: ['#FFFAF0', '#F0E68C', '#E6E6FA', '#FFB6C1', '#98FB98', '#87CEFA', '#FFDAB9'], 40 | // 圣诞元素颜色 41 | giftColors: ['#D32F2F', '#FFD700', '#1976D2', '#2E7D32'], 42 | candyColors: ['#FF0000', '#FFFFFF'] 43 | }, 44 | counts: { 45 | foliage: 15000, 46 | ornaments: 300, // 拍立得照片数量 47 | elements: 200, // 圣诞元素数量 48 | lights: 400 // 彩灯数量 49 | }, 50 | tree: { height: 22, radius: 9 }, // 树体尺寸 51 | photos: { 52 | // top 属性不再需要,因为已经移入 body 53 | body: bodyPhotoPaths 54 | } 55 | }; 56 | 57 | // --- Shader Material (Foliage) --- 58 | const FoliageMaterial = shaderMaterial( 59 | { uTime: 0, uColor: new THREE.Color(CONFIG.colors.emerald), uProgress: 0 }, 60 | `uniform float uTime; uniform float uProgress; attribute vec3 aTargetPos; attribute float aRandom; 61 | varying vec2 vUv; varying float vMix; 62 | float cubicInOut(float t) { return t < 0.5 ? 4.0 * t * t * t : 0.5 * pow(2.0 * t - 2.0, 3.0) + 1.0; } 63 | void main() { 64 | vUv = uv; 65 | vec3 noise = vec3(sin(uTime * 1.5 + position.x), cos(uTime + position.y), sin(uTime * 1.5 + position.z)) * 0.15; 66 | float t = cubicInOut(uProgress); 67 | vec3 finalPos = mix(position, aTargetPos + noise, t); 68 | vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0); 69 | gl_PointSize = (60.0 * (1.0 + aRandom)) / -mvPosition.z; 70 | gl_Position = projectionMatrix * mvPosition; 71 | vMix = t; 72 | }`, 73 | `uniform vec3 uColor; varying float vMix; 74 | void main() { 75 | float r = distance(gl_PointCoord, vec2(0.5)); if (r > 0.5) discard; 76 | vec3 finalColor = mix(uColor * 0.3, uColor * 1.2, vMix); 77 | gl_FragColor = vec4(finalColor, 1.0); 78 | }` 79 | ); 80 | extend({ FoliageMaterial }); 81 | 82 | // --- Helper: Tree Shape --- 83 | const getTreePosition = () => { 84 | const h = CONFIG.tree.height; const rBase = CONFIG.tree.radius; 85 | const y = (Math.random() * h) - (h / 2); const normalizedY = (y + (h/2)) / h; 86 | const currentRadius = rBase * (1 - normalizedY); const theta = Math.random() * Math.PI * 2; 87 | const r = Math.random() * currentRadius; 88 | return [r * Math.cos(theta), y, r * Math.sin(theta)]; 89 | }; 90 | 91 | // --- Component: Foliage --- 92 | const Foliage = ({ state }: { state: 'CHAOS' | 'FORMED' }) => { 93 | const materialRef = useRef(null); 94 | const { positions, targetPositions, randoms } = useMemo(() => { 95 | const count = CONFIG.counts.foliage; 96 | const positions = new Float32Array(count * 3); const targetPositions = new Float32Array(count * 3); const randoms = new Float32Array(count); 97 | const spherePoints = random.inSphere(new Float32Array(count * 3), { radius: 25 }) as Float32Array; 98 | for (let i = 0; i < count; i++) { 99 | positions[i*3] = spherePoints[i*3]; positions[i*3+1] = spherePoints[i*3+1]; positions[i*3+2] = spherePoints[i*3+2]; 100 | const [tx, ty, tz] = getTreePosition(); 101 | targetPositions[i*3] = tx; targetPositions[i*3+1] = ty; targetPositions[i*3+2] = tz; 102 | randoms[i] = Math.random(); 103 | } 104 | return { positions, targetPositions, randoms }; 105 | }, []); 106 | useFrame((rootState, delta) => { 107 | if (materialRef.current) { 108 | materialRef.current.uTime = rootState.clock.elapsedTime; 109 | const targetProgress = state === 'FORMED' ? 1 : 0; 110 | materialRef.current.uProgress = MathUtils.damp(materialRef.current.uProgress, targetProgress, 1.5, delta); 111 | } 112 | }); 113 | return ( 114 | 115 | 116 | 117 | 118 | 119 | 120 | {/* @ts-ignore */} 121 | 122 | 123 | ); 124 | }; 125 | 126 | // --- Component: Photo Ornaments (Double-Sided Polaroid) --- 127 | const PhotoOrnaments = ({ state }: { state: 'CHAOS' | 'FORMED' }) => { 128 | const textures = useTexture(CONFIG.photos.body); 129 | const count = CONFIG.counts.ornaments; 130 | const groupRef = useRef(null); 131 | 132 | const borderGeometry = useMemo(() => new THREE.PlaneGeometry(1.2, 1.5), []); 133 | const photoGeometry = useMemo(() => new THREE.PlaneGeometry(1, 1), []); 134 | 135 | const data = useMemo(() => { 136 | return new Array(count).fill(0).map((_, i) => { 137 | const chaosPos = new THREE.Vector3((Math.random()-0.5)*70, (Math.random()-0.5)*70, (Math.random()-0.5)*70); 138 | const h = CONFIG.tree.height; const y = (Math.random() * h) - (h / 2); 139 | const rBase = CONFIG.tree.radius; 140 | const currentRadius = (rBase * (1 - (y + (h/2)) / h)) + 0.5; 141 | const theta = Math.random() * Math.PI * 2; 142 | const targetPos = new THREE.Vector3(currentRadius * Math.cos(theta), y, currentRadius * Math.sin(theta)); 143 | 144 | const isBig = Math.random() < 0.2; 145 | const baseScale = isBig ? 2.2 : 0.8 + Math.random() * 0.6; 146 | const weight = 0.8 + Math.random() * 1.2; 147 | const borderColor = CONFIG.colors.borders[Math.floor(Math.random() * CONFIG.colors.borders.length)]; 148 | 149 | const rotationSpeed = { 150 | x: (Math.random() - 0.5) * 1.0, 151 | y: (Math.random() - 0.5) * 1.0, 152 | z: (Math.random() - 0.5) * 1.0 153 | }; 154 | const chaosRotation = new THREE.Euler(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); 155 | 156 | return { 157 | chaosPos, targetPos, scale: baseScale, weight, 158 | textureIndex: i % textures.length, 159 | borderColor, 160 | currentPos: chaosPos.clone(), 161 | chaosRotation, 162 | rotationSpeed, 163 | wobbleOffset: Math.random() * 10, 164 | wobbleSpeed: 0.5 + Math.random() * 0.5 165 | }; 166 | }); 167 | }, [textures, count]); 168 | 169 | useFrame((stateObj, delta) => { 170 | if (!groupRef.current) return; 171 | const isFormed = state === 'FORMED'; 172 | const time = stateObj.clock.elapsedTime; 173 | 174 | groupRef.current.children.forEach((group, i) => { 175 | const objData = data[i]; 176 | const target = isFormed ? objData.targetPos : objData.chaosPos; 177 | 178 | objData.currentPos.lerp(target, delta * (isFormed ? 0.8 * objData.weight : 0.5)); 179 | group.position.copy(objData.currentPos); 180 | 181 | if (isFormed) { 182 | const targetLookPos = new THREE.Vector3(group.position.x * 2, group.position.y + 0.5, group.position.z * 2); 183 | group.lookAt(targetLookPos); 184 | 185 | const wobbleX = Math.sin(time * objData.wobbleSpeed + objData.wobbleOffset) * 0.05; 186 | const wobbleZ = Math.cos(time * objData.wobbleSpeed * 0.8 + objData.wobbleOffset) * 0.05; 187 | group.rotation.x += wobbleX; 188 | group.rotation.z += wobbleZ; 189 | 190 | } else { 191 | group.rotation.x += delta * objData.rotationSpeed.x; 192 | group.rotation.y += delta * objData.rotationSpeed.y; 193 | group.rotation.z += delta * objData.rotationSpeed.z; 194 | } 195 | }); 196 | }); 197 | 198 | return ( 199 | 200 | {data.map((obj, i) => ( 201 | 202 | {/* 正面 */} 203 | 204 | 205 | 211 | 212 | 213 | 214 | 215 | 216 | {/* 背面 */} 217 | 218 | 219 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | ))} 232 | 233 | ); 234 | }; 235 | 236 | // --- Component: Christmas Elements --- 237 | const ChristmasElements = ({ state }: { state: 'CHAOS' | 'FORMED' }) => { 238 | const count = CONFIG.counts.elements; 239 | const groupRef = useRef(null); 240 | 241 | const boxGeometry = useMemo(() => new THREE.BoxGeometry(0.8, 0.8, 0.8), []); 242 | const sphereGeometry = useMemo(() => new THREE.SphereGeometry(0.5, 16, 16), []); 243 | const caneGeometry = useMemo(() => new THREE.CylinderGeometry(0.15, 0.15, 1.2, 8), []); 244 | 245 | const data = useMemo(() => { 246 | return new Array(count).fill(0).map(() => { 247 | const chaosPos = new THREE.Vector3((Math.random()-0.5)*60, (Math.random()-0.5)*60, (Math.random()-0.5)*60); 248 | const h = CONFIG.tree.height; 249 | const y = (Math.random() * h) - (h / 2); 250 | const rBase = CONFIG.tree.radius; 251 | const currentRadius = (rBase * (1 - (y + (h/2)) / h)) * 0.95; 252 | const theta = Math.random() * Math.PI * 2; 253 | 254 | const targetPos = new THREE.Vector3(currentRadius * Math.cos(theta), y, currentRadius * Math.sin(theta)); 255 | 256 | const type = Math.floor(Math.random() * 3); 257 | let color; let scale = 1; 258 | if (type === 0) { color = CONFIG.colors.giftColors[Math.floor(Math.random() * CONFIG.colors.giftColors.length)]; scale = 0.8 + Math.random() * 0.4; } 259 | else if (type === 1) { color = CONFIG.colors.giftColors[Math.floor(Math.random() * CONFIG.colors.giftColors.length)]; scale = 0.6 + Math.random() * 0.4; } 260 | else { color = Math.random() > 0.5 ? CONFIG.colors.red : CONFIG.colors.white; scale = 0.7 + Math.random() * 0.3; } 261 | 262 | const rotationSpeed = { x: (Math.random()-0.5)*2.0, y: (Math.random()-0.5)*2.0, z: (Math.random()-0.5)*2.0 }; 263 | return { type, chaosPos, targetPos, color, scale, currentPos: chaosPos.clone(), chaosRotation: new THREE.Euler(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI), rotationSpeed }; 264 | }); 265 | }, [boxGeometry, sphereGeometry, caneGeometry]); 266 | 267 | useFrame((_, delta) => { 268 | if (!groupRef.current) return; 269 | const isFormed = state === 'FORMED'; 270 | groupRef.current.children.forEach((child, i) => { 271 | const mesh = child as THREE.Mesh; 272 | const objData = data[i]; 273 | const target = isFormed ? objData.targetPos : objData.chaosPos; 274 | objData.currentPos.lerp(target, delta * 1.5); 275 | mesh.position.copy(objData.currentPos); 276 | mesh.rotation.x += delta * objData.rotationSpeed.x; mesh.rotation.y += delta * objData.rotationSpeed.y; mesh.rotation.z += delta * objData.rotationSpeed.z; 277 | }); 278 | }); 279 | 280 | return ( 281 | 282 | {data.map((obj, i) => { 283 | let geometry; if (obj.type === 0) geometry = boxGeometry; else if (obj.type === 1) geometry = sphereGeometry; else geometry = caneGeometry; 284 | return ( 285 | 286 | )})} 287 | 288 | ); 289 | }; 290 | 291 | // --- Component: Fairy Lights --- 292 | const FairyLights = ({ state }: { state: 'CHAOS' | 'FORMED' }) => { 293 | const count = CONFIG.counts.lights; 294 | const groupRef = useRef(null); 295 | const geometry = useMemo(() => new THREE.SphereGeometry(0.8, 8, 8), []); 296 | 297 | const data = useMemo(() => { 298 | return new Array(count).fill(0).map(() => { 299 | const chaosPos = new THREE.Vector3((Math.random()-0.5)*60, (Math.random()-0.5)*60, (Math.random()-0.5)*60); 300 | const h = CONFIG.tree.height; const y = (Math.random() * h) - (h / 2); const rBase = CONFIG.tree.radius; 301 | const currentRadius = (rBase * (1 - (y + (h/2)) / h)) + 0.3; const theta = Math.random() * Math.PI * 2; 302 | const targetPos = new THREE.Vector3(currentRadius * Math.cos(theta), y, currentRadius * Math.sin(theta)); 303 | const color = CONFIG.colors.lights[Math.floor(Math.random() * CONFIG.colors.lights.length)]; 304 | const speed = 2 + Math.random() * 3; 305 | return { chaosPos, targetPos, color, speed, currentPos: chaosPos.clone(), timeOffset: Math.random() * 100 }; 306 | }); 307 | }, []); 308 | 309 | useFrame((stateObj, delta) => { 310 | if (!groupRef.current) return; 311 | const isFormed = state === 'FORMED'; 312 | const time = stateObj.clock.elapsedTime; 313 | groupRef.current.children.forEach((child, i) => { 314 | const objData = data[i]; 315 | const target = isFormed ? objData.targetPos : objData.chaosPos; 316 | objData.currentPos.lerp(target, delta * 2.0); 317 | const mesh = child as THREE.Mesh; 318 | mesh.position.copy(objData.currentPos); 319 | const intensity = (Math.sin(time * objData.speed + objData.timeOffset) + 1) / 2; 320 | if (mesh.material) { (mesh.material as THREE.MeshStandardMaterial).emissiveIntensity = isFormed ? 3 + intensity * 4 : 0; } 321 | }); 322 | }); 323 | 324 | return ( 325 | 326 | {data.map((obj, i) => ( 327 | 328 | ))} 329 | 330 | ); 331 | }; 332 | 333 | // --- Component: Top Star (No Photo, Pure Gold 3D Star) --- 334 | const TopStar = ({ state }: { state: 'CHAOS' | 'FORMED' }) => { 335 | const groupRef = useRef(null); 336 | 337 | const starShape = useMemo(() => { 338 | const shape = new THREE.Shape(); 339 | const outerRadius = 1.3; const innerRadius = 0.7; const points = 5; 340 | for (let i = 0; i < points * 2; i++) { 341 | const radius = i % 2 === 0 ? outerRadius : innerRadius; 342 | const angle = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2; 343 | i === 0 ? shape.moveTo(radius*Math.cos(angle), radius*Math.sin(angle)) : shape.lineTo(radius*Math.cos(angle), radius*Math.sin(angle)); 344 | } 345 | shape.closePath(); 346 | return shape; 347 | }, []); 348 | 349 | const starGeometry = useMemo(() => { 350 | return new THREE.ExtrudeGeometry(starShape, { 351 | depth: 0.4, // 增加一点厚度 352 | bevelEnabled: true, bevelThickness: 0.1, bevelSize: 0.1, bevelSegments: 3, 353 | }); 354 | }, [starShape]); 355 | 356 | // 纯金材质 357 | const goldMaterial = useMemo(() => new THREE.MeshStandardMaterial({ 358 | color: CONFIG.colors.gold, 359 | emissive: CONFIG.colors.gold, 360 | emissiveIntensity: 1.5, // 适中亮度,既发光又有质感 361 | roughness: 0.1, 362 | metalness: 1.0, 363 | }), []); 364 | 365 | useFrame((_, delta) => { 366 | if (groupRef.current) { 367 | groupRef.current.rotation.y += delta * 0.5; 368 | const targetScale = state === 'FORMED' ? 1 : 0; 369 | groupRef.current.scale.lerp(new THREE.Vector3(targetScale, targetScale, targetScale), delta * 3); 370 | } 371 | }); 372 | 373 | return ( 374 | 375 | 376 | 377 | 378 | 379 | ); 380 | }; 381 | 382 | // --- Main Scene Experience --- 383 | const Experience = ({ sceneState, rotationSpeed }: { sceneState: 'CHAOS' | 'FORMED', rotationSpeed: number }) => { 384 | const controlsRef = useRef(null); 385 | useFrame(() => { 386 | if (controlsRef.current) { 387 | controlsRef.current.setAzimuthalAngle(controlsRef.current.getAzimuthalAngle() + rotationSpeed); 388 | controlsRef.current.update(); 389 | } 390 | }); 391 | 392 | return ( 393 | <> 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | ); 423 | }; 424 | 425 | // --- Gesture Controller --- 426 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 427 | const GestureController = ({ onGesture, onMove, onStatus, debugMode }: any) => { 428 | const videoRef = useRef(null); 429 | const canvasRef = useRef(null); 430 | 431 | useEffect(() => { 432 | let gestureRecognizer: GestureRecognizer; 433 | let requestRef: number; 434 | 435 | const setup = async () => { 436 | onStatus("DOWNLOADING AI..."); 437 | try { 438 | const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"); 439 | gestureRecognizer = await GestureRecognizer.createFromOptions(vision, { 440 | baseOptions: { 441 | modelAssetPath: "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task", 442 | delegate: "GPU" 443 | }, 444 | runningMode: "VIDEO", 445 | numHands: 1 446 | }); 447 | onStatus("REQUESTING CAMERA..."); 448 | if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 449 | const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 450 | if (videoRef.current) { 451 | videoRef.current.srcObject = stream; 452 | videoRef.current.play(); 453 | onStatus("AI READY: SHOW HAND"); 454 | predictWebcam(); 455 | } 456 | } else { 457 | onStatus("ERROR: CAMERA PERMISSION DENIED"); 458 | } 459 | } catch (err: any) { 460 | onStatus(`ERROR: ${err.message || 'MODEL FAILED'}`); 461 | } 462 | }; 463 | 464 | const predictWebcam = () => { 465 | if (gestureRecognizer && videoRef.current && canvasRef.current) { 466 | if (videoRef.current.videoWidth > 0) { 467 | const results = gestureRecognizer.recognizeForVideo(videoRef.current, Date.now()); 468 | const ctx = canvasRef.current.getContext("2d"); 469 | if (ctx && debugMode) { 470 | ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); 471 | canvasRef.current.width = videoRef.current.videoWidth; canvasRef.current.height = videoRef.current.videoHeight; 472 | if (results.landmarks) for (const landmarks of results.landmarks) { 473 | const drawingUtils = new DrawingUtils(ctx); 474 | drawingUtils.drawConnectors(landmarks, GestureRecognizer.HAND_CONNECTIONS, { color: "#FFD700", lineWidth: 2 }); 475 | drawingUtils.drawLandmarks(landmarks, { color: "#FF0000", lineWidth: 1 }); 476 | } 477 | } else if (ctx && !debugMode) ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); 478 | 479 | if (results.gestures.length > 0) { 480 | const name = results.gestures[0][0].categoryName; const score = results.gestures[0][0].score; 481 | if (score > 0.4) { 482 | if (name === "Open_Palm") onGesture("CHAOS"); if (name === "Closed_Fist") onGesture("FORMED"); 483 | if (debugMode) onStatus(`DETECTED: ${name}`); 484 | } 485 | if (results.landmarks.length > 0) { 486 | const speed = (0.5 - results.landmarks[0][0].x) * 0.15; 487 | onMove(Math.abs(speed) > 0.01 ? speed : 0); 488 | } 489 | } else { onMove(0); if (debugMode) onStatus("AI READY: NO HAND"); } 490 | } 491 | requestRef = requestAnimationFrame(predictWebcam); 492 | } 493 | }; 494 | setup(); 495 | return () => cancelAnimationFrame(requestRef); 496 | }, [onGesture, onMove, onStatus, debugMode]); 497 | 498 | return ( 499 | <> 500 |