Spaces:
Running
Running
| <html lang="zh"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Three.js 云霄飞车动画</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| background: #ffeef7; /* 淡粉色背景(浏览器无WebGL时可见) */ | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| color: #333; | |
| background: rgba(255, 255, 255, 0.85); | |
| padding: 8px 12px; | |
| border-radius: 8px; | |
| font-size: 12px; | |
| z-index: 10; | |
| user-select: none; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.1); | |
| } | |
| #info strong { color: #d23; } | |
| #info a { | |
| color: #06c; | |
| text-decoration: none; | |
| border-bottom: 1px dashed #06c; | |
| } | |
| #info a:hover { color: #028; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info"> | |
| <div><strong>Three.js 云霄飞车</strong></div> | |
| <div>• 视角模式: 1 第一人称 | 2 第三人称 | R 重置视角</div> | |
| <div>• 鼠标滚轮缩放,右键/中键平移(左键旋转,模式2)</div> | |
| <div>• 自动循环播放</div> | |
| <div style="margin-top:4px">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">anycoder</a></div> | |
| </div> | |
| <!-- Three.js 和 OrbitControls(全局变量) --> | |
| <script src="https://unpkg.com/three@0.158.0/build/three.min.js"></script> | |
| <script src="https://unpkg.com/three@0.158.0/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| // 核心全局 | |
| let scene, renderer, camera, orbit; | |
| let rideCam, followCam; | |
| let coasterCurve, tubeMesh, ball; | |
| let clock = new THREE.Clock(); | |
| let tParam = 0; // 0..1 路径参数 | |
| let viewMode = 1; // 1: 第一人称, 2: 第三人称 | |
| const TUBULAR_SEGMENTS = 1400; | |
| const RADIAL_SEGMENTS = 16; | |
| const TRACK_RADIUS = 0.8; | |
| // 初始化 | |
| init(); | |
| animate(); | |
| function init() { | |
| // 场景 | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xffeef7); // 淡粉色 | |
| // 渲染器 | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| document.body.appendChild(renderer.domElement); | |
| // 相机 | |
| camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000); | |
| camera.position.set(50, 40, 60); | |
| // OrbitControls(第三人称使用) | |
| orbit = new THREE.OrbitControls(camera, renderer.domElement); | |
| orbit.enableDamping = true; | |
| orbit.dampingFactor = 0.08; | |
| // 光照 | |
| const ambient = new THREE.AmbientLight(0xffffff, 0.45); | |
| scene.add(ambient); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.9); | |
| dirLight.position.set(80, 100, 40); | |
| dirLight.castShadow = true; | |
| dirLight.shadow.mapSize.set(2048, 2048); | |
| dirLight.shadow.camera.near = 1; | |
| dirLight.shadow.camera.far = 300; | |
| dirLight.shadow.camera.left = -120; | |
| dirLight.shadow.camera.right = 120; | |
| dirLight.shadow.camera.top = 120; | |
| dirLight.shadow.camera.bottom = -120; | |
| scene.add(dirLight); | |
| // 地面(灰白色) | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry(400, 400), | |
| new THREE.MeshStandardMaterial({ color: 0xf3f3f3, roughness: 0.95, metalness: 0.0 }) | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| // 构建轨道 | |
| buildCoaster(); | |
| // 小球 | |
| const ballGeo = new THREE.SphereGeometry(0.7, 32, 16); | |
| const ballMat = new THREE.MeshStandardMaterial({ color: 0xff5555, roughness: 0.35, metalness: 0.2 }); | |
| ball = new THREE.Mesh(ballGeo, ballMat); | |
| ball.castShadow = true; | |
| ball.position.copy(coasterCurve.getPointAt(0)); | |
| scene.add(ball); | |
| // 相机 - 第一人称(rideCam) | |
| rideCam = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.05, 2000); | |
| // 初始放在小球后方 | |
| const initForward = coasterCurve.getTangentAt(0); | |
| const up = new THREE.Vector3(0, 1, 0); | |
| const side = new THREE.Vector3().crossVectors(up, initForward).normalize(); | |
| const upOrth = new THREE.Vector3().crossVectors(initForward, side).normalize(); | |
| const behind = initForward.clone().multiplyScalar(-2.5).add(upOrth.clone().multiplyScalar(0.6)); | |
| rideCam.position.copy(ball.position.clone().add(behind)); | |
| scene.add(rideCam); | |
| // 相机 - 第三人称(followCam,使用主相机) | |
| followCam = camera; | |
| // 事件 | |
| window.addEventListener('resize', onResize, false); | |
| window.addEventListener('keydown', onKeyDown, false); | |
| } | |
| function onResize() { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| renderer.setSize(w, h); | |
| camera.aspect = w / h; | |
| camera.updateProjectionMatrix(); | |
| rideCam.aspect = w / h; | |
| rideCam.updateProjectionMatrix(); | |
| } | |
| function onKeyDown(e) { | |
| if (e.key === '1') { | |
| viewMode = 1; | |
| } else if (e.key === '2') { | |
| viewMode = 2; | |
| } else if (e.key.toLowerCase() === 'r') { | |
| // 重置相机 | |
| camera.position.set(50, 40, 60); | |
| camera.lookAt(0, 10, 0); | |
| orbit.target.set(0, 10, 0); | |
| orbit.update(); | |
| } | |
| } | |
| // 构建复杂轨道(多段组合 + 过渡 + 平滑) | |
| function buildCoaster() { | |
| const pts = []; | |
| // 起点 | |
| const start = new THREE.Vector3(0, 0, -30); | |
| pts.push(start); | |
| // 辅助函数 | |
| const addPoints = (arr) => { for (const p of arr) pts.push(p.clone()); }; | |
| const normalizeToUnit = (v) => v.lengthSq() < 1e-6 ? new THREE.Vector3(1,0,0) : v.clone().normalize(); | |
| // 段1: 初始缓坡(平滑过渡到螺旋) | |
| const rampLen = 12; | |
| const rampPts = []; | |
| for (let i = 0; i <= rampLen; i++) { | |
| const t = i / rampLen; | |
| const z = THREE.MathUtils.lerp(-30, -28, t); | |
| const y = THREE.MathUtils.lerp(0, 2, t); // 缓坡上升 | |
| rampPts.push(new THREE.Vector3(0, y, z)); | |
| } | |
| addPoints(rampPts); | |
| // 段2: 螺旋上升(绕Y轴旋转) | |
| const helix2 = generateHelixPoints({ | |
| startPoint: rampPts[rampPts.length - 1], | |
| turns: 3, | |
| height: 24, | |
| radiusStart: 2, | |
| radiusEnd: 10, | |
| direction: +1, // 上升 | |
| verticalOffset: +4, | |
| samplesPerTurn: 80 | |
| }); | |
| addPoints(helix2); | |
| // 段3: 垂直环(Loop-the-Loop) | |
| const afterHelix2 = helix2[helix2.length - 1]; | |
| const loopRadius = 10; | |
| const loopCenter = afterHelix2.clone().add(new THREE.Vector3(0, loopRadius, 0)); // 环底在当前点 | |
| const loopStart = afterHelix2.clone(); | |
| const loopUpDir = normalizeToUnit(loopStart.clone().sub(loopCenter)); | |
| const loopRightDir = new THREE.Vector3().crossVectors(loopUpDir, new THREE.Vector3(0,1,0)).normalize(); | |
| const loop = generateVerticalLoopPoints({ | |
| center: loopCenter, | |
| radius: loopRadius, | |
| startDirection: loopUpDir, // 从底部向上 | |
| samples: 140 | |
| }); | |
| addPoints(loop); | |
| // 段4: 波浪/S 形横向穿梭(多层交叉感) | |
| const afterLoop = loop[loop.length - 1]; | |
| const wave = generateWaveCrossoverPoints({ | |
| startPoint: afterLoop, | |
| length: 70, | |
| waves: 2, | |
| amplitudeX: 16, | |
| amplitudeZ: 8, | |
| deltaY: 8, | |
| verticalDir: +1, // 渐升 | |
| samples: 200 | |
| }); | |
| addPoints(wave); | |
| // 段5: 漏斗式俯冲(半径缩小 + 高度下降 + 半圈旋转) | |
| const afterWave = wave[wave.length - 1]; | |
| const funnel = generateFunnelPoints({ | |
| startPoint: afterWave, | |
| turns: 1.6, | |
| heightDrop: 20, | |
| radiusStart: 10, | |
| radiusEnd: 2, | |
| direction: -1, // 下降 | |
| samplesPerTurn: 100 | |
| }); | |
| addPoints(funnel); | |
| // 段6: 抛射与回归(抛物线段) | |
| const afterFunnel = funnel[funnel.length - 1]; | |
| const launch = generateLaunchAndReturnPoints({ | |
| startPoint: afterFunnel, | |
| length: 40, | |
| height: 22, | |
| samples: 90 | |
| }); | |
| addPoints(launch); | |
| // 段7: 缓坡与过渡,回到起点附近 | |
| const afterLaunch = launch[launch.length - 1]; | |
| const backToStart = []; | |
| const lastToFirst = afterLaunch.clone().lerp(start, 1); | |
| // 逐渐回到起点同高同位置 | |
| for (let i = 0; i <= 40; i++) { | |
| const t = i / 40; | |
| const p = new THREE.Vector3().lerpVectors(afterLaunch, start, t); | |
| // 轻微抬高过渡,避免锐角 | |
| p.y = THREE.MathUtils.lerp(afterLaunch.y, start.y, t) + Math.sin(t * Math.PI) * 0.8; | |
| backToStart.push(p); | |
| } | |
| addPoints(backToStart); | |
| // 点平滑(Chaikin) | |
| const smoothed = chaikinSmooth(pts, 2); | |
| // 构建闭合曲线 | |
| coasterCurve = new THREE.CatmullRomCurve3(smoothed, true, 'chordal', 0.12); | |
| // 轨道管道 | |
| const tubeGeo = new THREE.TubeGeometry(coasterCurve, TUBULAR_SEGMENTS, TRACK_RADIUS, RADIAL_SEGMENTS, true); | |
| const tubeMat = new THREE.MeshStandardMaterial({ | |
| color: 0xffffff, | |
| roughness: 0.25, | |
| metalness: 0.0, | |
| transparent: true, | |
| opacity: 0.92, | |
| envMapIntensity: 0.3 | |
| }); | |
| tubeMesh = new THREE.Mesh(tubeGeo, tubeMat); | |
| tubeMesh.castShadow = false; | |
| tubeMesh.receiveShadow = true; | |
| scene.add(tubeMesh); | |
| // 支撑系统 | |
| addSupportPosts(coasterCurve, { | |
| every: 0.004, // 约每0.4%路径一个支柱(密度可调) | |
| minHeight: 10, // 仅当轨道离地高度大于该值时放置 | |
| poleRadius: 0.18, | |
| poleColor: 0x9e9e9e | |
| }); | |
| // 轨道装饰标记(在部分关键点放置小标记) | |
| addTrackMarkers(coasterCurve, 60, 0x3366ff); | |
| } | |
| // 生成螺旋段(可改变半径和高度) | |
| function generateHelixPoints({ | |
| startPoint, | |
| turns = 2, | |
| height = 10, | |
| radiusStart = 5, | |
| radiusEnd = 5, | |
| direction = +1, // +1 up, -1 down | |
| verticalOffset = 0, // 额外Y偏移 | |
| samplesPerTurn = 60 | |
| }) { | |
| const pts = []; | |
| const totalSamples = Math.max(20, Math.floor(turns * samplesPerTurn)); | |
| for (let i = 0; i <= totalSamples; i++) { | |
| const t = i / totalSamples; | |
| const angle = t * turns * Math.PI * 2 * direction; | |
| const r = THREE.MathUtils.lerp(radiusStart, radiusEnd, t); | |
| const x = startPoint.x + r * Math.cos(angle); | |
| const z = startPoint.z + r * Math.sin(angle); | |
| const y = startPoint.y + (t * height * direction) + verticalOffset; | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return pts; | |
| } | |
| // 生成垂直环(Loop-the-Loop) | |
| function generateVerticalLoopPoints({ | |
| center, | |
| radius, | |
| startDirection, // 环底处的切线方向(向上) | |
| samples = 80 | |
| }) { | |
| const pts = []; | |
| // 构建正交基 | |
| const up = startDirection.clone().normalize(); // 切向 | |
| let side = new THREE.Vector3().crossVectors(up, new THREE.Vector3(0,0,1)); | |
| if (side.lengthSq() < 1e-6) side = new THREE.Vector3().crossVectors(up, new THREE.Vector3(1,0,0)); | |
| side.normalize(); // 横向 | |
| const forward = new THREE.Vector3().crossVectors(side, up).normalize(); // 指向环内部 | |
| // 从环底开始(-90°)到 270°(等价 -90°),完整一圈 | |
| for (let i = 0; i <= samples; i++) { | |
| const t = i / samples; | |
| const theta = -Math.PI / 2 + t * Math.PI * 2; | |
| const offset = up.clone().multiplyScalar(radius * Math.sin(theta)) | |
| .add(side.clone().multiplyScalar(radius * Math.cos(theta))); | |
| const p = center.clone().add(offset); | |
| pts.push(p); | |
| } | |
| return pts; | |
| } | |
| // 生成波浪/横向S形穿梭段(模拟多层交叉) | |
| function generateWaveCrossoverPoints({ | |
| startPoint, | |
| length = 50, | |
| waves = 2, | |
| amplitudeX = 12, | |
| amplitudeZ = 6, | |
| deltaY = 6, | |
| verticalDir = +1, // 向上为+ | |
| samples = 120 | |
| }) { | |
| const pts = []; | |
| for (let i = 0; i <= samples; i++) { | |
| const t = i / samples; | |
| const s = t * waves * Math.PI * 2; // 多个S周期 | |
| const x = startPoint.x + Math.sin(s) * amplitudeX; | |
| const z = startPoint.z + Math.cos(s) * amplitudeZ; | |
| const y = startPoint.y + t * deltaY * verticalDir; | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return pts; | |
| } | |
| // 生成漏斗段(半径缩小 + 下降 + 半圈旋转) | |
| function generateFunnelPoints({ | |
| startPoint, | |
| turns = 1.5, | |
| heightDrop = 12, | |
| radiusStart = 8, | |
| radiusEnd = 2, | |
| direction = -1, // 下降 | |
| samplesPerTurn = 80 | |
| }) { | |
| const totalSamples = Math.max(20, Math.floor(turns * samplesPerTurn)); | |
| const pts = []; | |
| for (let i = 0; i <= totalSamples; i++) { | |
| const t = i / totalSamples; | |
| const angle = t * turns * Math.PI * 2 * direction; | |
| const r = THREE.MathUtils.lerp(radiusStart, radiusEnd, t); | |
| const x = startPoint.x + r * Math.cos(angle); | |
| const z = startPoint.z + r * Math.sin(angle); | |
| const y = startPoint.y - t * heightDrop * (direction === -1 ? 1 : -1); | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return pts; | |
| } | |
| // 生成抛射与回归段(抛物线) | |
| function generateLaunchAndReturnPoints({ | |
| startPoint, | |
| length = 30, | |
| height = 15, | |
| samples = 60 | |
| }) { | |
| const pts = []; | |
| const axis = new THREE.Vector3(1, 0, 0); // 沿X方向 | |
| for (let i = 0; i <= samples; i++) { | |
| const t = i / samples; | |
| const s = t * length; | |
| // 抛物线:y = -4h/L^2 * s^2 + 4h/L * s | |
| const y = -4 * height / (length * length) * s * s + 4 * height / length * s; | |
| const p = startPoint.clone().add(axis.clone().multiplyScalar(s)); | |
| p.y += y; | |
| pts.push(p); | |
| } | |
| return pts; | |
| } | |
| // Chaikin 曲线平滑算法(1-2次迭代足够) | |
| function chaikinSmooth(points, iterations = 1) { | |
| let pts = points.map(p => p.clone()); | |
| for (let it = 0; it < iterations; it++) { | |
| const newPts = []; | |
| newPts.push(pts[0].clone()); | |
| for (let i = 0; i < pts.length - 1; i++) { | |
| const p = pts[i]; | |
| const q = pts[i + 1]; | |
| const Q = p.clone().multiplyScalar(0.75).add(q.clone().multiplyScalar(0.25)); | |
| const R = p.clone().multiplyScalar(0.25).add(q.clone().multiplyScalar(0.75)); | |
| newPts.push(Q, R); | |
| } | |
| newPts.push(pts[pts.length - 1].clone()); | |
| pts = newPts; | |
| } | |
| return pts; | |
| } | |
| // 添加支撑柱 | |
| function addSupportPosts(curve, { | |
| every = 0.01, // 0..1 间距 | |
| minHeight = 10, // 最小离地高度 | |
| poleRadius = 0.15, | |
| poleColor = 0x999999 | |
| } = {}) { | |
| const groundY = 0; | |
| const poleMat = new THREE.MeshStandardMaterial({ color: poleColor, roughness: 0.5, metalness: 0.2 }); | |
| for (let t = 0; t <= 1; t += every) { | |
| const p = curve.getPointAt(t); | |
| const height = p.y - groundY - TRACK_RADIUS; | |
| if (height < minHeight) continue; | |
| const poleHeight = Math.max(0.5, height); | |
| const poleGeo = new THREE.CylinderGeometry(poleRadius, poleRadius, poleHeight, 12); | |
| const pole = new THREE.Mesh(poleGeo, poleMat); | |
| pole.castShadow = true; | |
| pole.receiveShadow = true; | |
| pole.position.set(p.x, groundY + poleHeight / 2, p.z); | |
| scene.add(pole); | |
| // 顶部连接件(装饰) | |
| const capGeo = new THREE.CylinderGeometry(poleRadius * 1.4, poleRadius * 1.4, 0.2, 12); | |
| const cap = new THREE.Mesh(capGeo, poleMat); | |
| cap.position.set(p.x, groundY + poleHeight + 0.1, p.z); | |
| cap.castShadow = true; | |
| scene.add(cap); | |
| // 底部底座(装饰) | |
| const baseGeo = new THREE.CylinderGeometry(poleRadius * 1.8, poleRadius * 1.8, 0.15, 12); | |
| const base = new THREE.Mesh(baseGeo, poleMat); | |
| base.position.set(p.x, groundY + 0.075, p.z); | |
| base.receiveShadow = true; | |
| scene.add(base); | |
| } | |
| } | |
| // 轨道关键点标记(可调试用) | |
| function addTrackMarkers(curve, count = 40, color = 0x3366ff) { | |
| const markerGeo = new THREE.SphereGeometry(0.25, 12, 8); | |
| const markerMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.8 }); | |
| for (let i = 0; i < count; i++) { | |
| const t = i / count; | |
| const p = curve.getPointAt(t); | |
| const m = new THREE.Mesh(markerGeo, markerMat); | |
| m.position.copy(p); | |
| scene.add(m); | |
| } | |
| } | |
| // 动画循环 | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = clock.getDelta(); | |
| const speed = 0.02; // 路径速度(越大越快) | |
| tParam = (tParam + speed * dt) % 1.0; | |
| // 更新小球位置与朝向 | |
| const pos = coasterCurve.getPointAt(tParam); | |
| const tan = coasterCurve.getTangentAt(tParam); | |
| ball.position.copy(pos); | |
| // 让小球看起来沿着切线方向(可选) | |
| const lookTarget = pos.clone().add(tan); | |
| ball.lookAt(lookTarget); | |
| // 相机更新 | |
| if (viewMode === 1) { | |
| // 第一人称:rideCam 跟随小球(稍上方后方) | |
| const up = new THREE.Vector3(0, 1, 0); | |
| const side = new THREE.Vector3().crossVectors(up, tan).normalize(); | |
| const upOrth = new THREE.Vector3().crossVectors(tan, side).normalize(); | |
| const camOffset = tan.clone().multiplyScalar(-2.2).add(upOrth.clone().multiplyScalar(0.7)); | |
| rideCam.position.copy(pos.clone().add(camOffset)); | |
| const forwardTarget = pos.clone().add(tan.clone().multiplyScalar(10)); | |
| rideCam.lookAt(forwardTarget); | |
| } else { | |
| // 第三人称:OrbitControls(不影响rideCam位置) | |
| orbit.update(); | |
| } | |
| // 渲染 | |
| const activeCam = viewMode === 1 ? rideCam : camera; | |
| renderer.render(scene, activeCam); | |
| } | |
| </script> | |
| </body> | |
| </html> |