Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Three.js 云霄飞车动画</title> | |
| <meta name="description" content="使用 Three.js 实现的云霄飞车管道轨道动画,小球沿复杂3D轨道运动并循环往复。" /> | |
| <meta name="keywords" content="Three.js,云霄飞车,动画,轨道,3D" /> | |
| <style> | |
| html, body { | |
| height: 100%; | |
| margin: 0; | |
| overflow: hidden; | |
| background: #fce4ec; /* 淡粉色背景 */ | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", "Apple Color Emoji", "Segoe UI Emoji"; | |
| } | |
| #app { | |
| width: 100%; | |
| height: 100%; | |
| position: relative; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| #ui { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| right: 10px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| pointer-events: none; | |
| color: #333; | |
| z-index: 10; | |
| } | |
| #hud { | |
| background: rgba(255,255,255,0.8); | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.15); | |
| max-width: 320px; | |
| pointer-events: auto; | |
| backdrop-filter: blur(4px); | |
| } | |
| #hud h1 { | |
| margin: 0 0 6px 0; | |
| font-size: 16px; | |
| font-weight: 700; | |
| } | |
| #hud .small { | |
| font-size: 12px; | |
| opacity: 0.8; | |
| margin-bottom: 6px; | |
| line-height: 1.4; | |
| } | |
| #hud .row { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| margin: 4px 0; | |
| } | |
| #hud .key { | |
| display: inline-block; | |
| padding: 1px 6px; | |
| background: #eee; | |
| border: 1px solid #ccc; | |
| border-radius: 4px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-size: 11px; | |
| } | |
| #stats { | |
| background: rgba(255,255,255,0.8); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| min-width: 160px; | |
| text-align: right; | |
| pointer-events: none; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.15); | |
| backdrop-filter: blur(4px); | |
| font-size: 12px; | |
| line-height: 1.6; | |
| } | |
| #brand { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| font-size: 12px; | |
| color: #555; | |
| background: rgba(255,255,255,0.75); | |
| padding: 6px 8px; | |
| border-radius: 6px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.1); | |
| backdrop-filter: blur(4px); | |
| } | |
| #brand a { | |
| color: #0066cc; | |
| text-decoration: none; | |
| font-weight: 600; | |
| } | |
| #brand a:hover { | |
| text-decoration: underline; | |
| } | |
| #controls { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| background: rgba(255,255,255,0.8); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.15); | |
| pointer-events: auto; | |
| backdrop-filter: blur(4px); | |
| } | |
| #controls label { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| } | |
| input[type="range"] { | |
| width: 160px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="ui"> | |
| <div id="hud"> | |
| <h1>Three.js 云霄飞车</h1> | |
| <div class="small"> | |
| 视角:<span id="viewMode">第三人称</span><br/> | |
| 操作: | |
| <span class="key">C</span> 切换视角, | |
| <span class="key">鼠标</span> 旋转/缩放, | |
| <span class="key">空格</span> 暂停/继续 | |
| </div> | |
| <div class="row"> | |
| 速度:<input id="speed" type="range" min="0.05" max="1.00" step="0.05" value="0.35"> | |
| <span id="speedVal">0.35x</span> | |
| </div> | |
| </div> | |
| <div id="stats"> | |
| 粒子点:<span id="ptCount">-</span><br/> | |
| 支架数:<span id="supCount">-</span><br/> | |
| 帧率:<span id="fps">-</span> FPS | |
| </div> | |
| </div> | |
| <div id="brand">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">anycoder</a></div> | |
| <div id="controls"> | |
| <label><input id="toggleTrack" type="checkbox" checked> 显示轨道</label> | |
| <label><input id="toggleSupports" type="checkbox" checked> 显示支架</label> | |
| </div> | |
| </div> | |
| <!-- Three.js 和 OrbitControls(通过CDN) --> | |
| <script src="https://unpkg.com/three@0.157.0/build/three.min.js"></script> | |
| <script src="https://unpkg.com/three@0.157.0/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| // ======= 全局变量与基础场景 ======= | |
| let scene, camera, renderer, controls; | |
| let curve, trackMesh, ball; | |
| let supportsGroup; | |
| let firstPerson = false; | |
| let isPaused = false; | |
| let clock = new THREE.Clock(); | |
| let elapsed = 0; | |
| let speedMultiplier = 0.35; // 基础速度倍率 | |
| const HUD = { | |
| viewMode: document.getElementById('viewMode'), | |
| speed: document.getElementById('speed'), | |
| speedVal: document.getElementById('speedVal'), | |
| ptCount: document.getElementById('ptCount'), | |
| supCount: document.getElementById('supCount'), | |
| fps: document.getElementById('fps'), | |
| toggleTrack: document.getElementById('toggleTrack'), | |
| toggleSupports: document.getElementById('toggleSupports') | |
| }; | |
| init(); | |
| animate(); | |
| function init() { | |
| // 场景与渲染器 | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xfce4ec); // 淡粉色背景 | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| document.getElementById('app').appendChild(renderer.domElement); | |
| // 相机与控制器 | |
| camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000); | |
| camera.position.set(0, 35, 70); | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.08; | |
| controls.target.set(0, 10, 0); | |
| controls.maxDistance = 200; | |
| controls.minDistance = 5; | |
| controls.maxPolarAngle = Math.PI * 0.95; | |
| // 光照 | |
| const ambient = new THREE.AmbientLight(0xffffff, 0.55); | |
| scene.add(ambient); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); | |
| dirLight.position.set(-40, 80, 40); | |
| dirLight.castShadow = true; | |
| dirLight.shadow.mapSize.set(2048, 2048); | |
| dirLight.shadow.camera.near = 1; | |
| dirLight.shadow.camera.far = 200; | |
| 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(600, 600), | |
| new THREE.MeshStandardMaterial({ color: 0xf5f5f5, roughness: 0.95, metalness: 0.0 }) | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| // 构建轨道和动画球 | |
| createRollerCoaster(); | |
| // UI 事件 | |
| HUD.speed.addEventListener('input', () => { | |
| speedMultiplier = parseFloat(HUD.speed.value); | |
| HUD.speedVal.textContent = speedMultiplier.toFixed(2) + 'x'; | |
| }); | |
| HUD.toggleTrack.addEventListener('change', () => { | |
| if (trackMesh) trackMesh.visible = HUD.toggleTrack.checked; | |
| }); | |
| HUD.toggleSupports.addEventListener('change', () => { | |
| if (supportsGroup) supportsGroup.visible = HUD.toggleSupports.checked; | |
| }); | |
| window.addEventListener('resize', onResize); | |
| window.addEventListener('keydown', onKeyDown); | |
| } | |
| function onResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function onKeyDown(e) { | |
| if (e.code === 'KeyC') { | |
| firstPerson = !firstPerson; | |
| HUD.viewMode.textContent = firstPerson ? '第一人称' : '第三人称'; | |
| // 切换时重置一下控制器 | |
| controls.enabled = !firstPerson; | |
| } else if (e.code === 'Space') { | |
| isPaused = !isPaused; | |
| } | |
| } | |
| // ======= 平滑算法(Chaikin)用于轨道点平滑 ======= | |
| function chaikinSmoothPoints(points, iterations = 2) { | |
| let pts = points.slice(); | |
| for (let iter = 0; iter < iterations; iter++) { | |
| if (pts.length < 3) break; | |
| const newPts = []; | |
| for (let i = 0; i < pts.length; i++) { | |
| const p0 = pts[i]; | |
| const p1 = pts[(i + 1) % pts.length]; | |
| const Q = new THREE.Vector3().copy(p0).lerp(p1, 0.25); | |
| const R = new THREE.Vector3().copy(p0).lerp(p1, 0.75); | |
| newPts.push(Q, R); | |
| } | |
| pts = newPts; | |
| } | |
| return pts; | |
| } | |
| // ======= 轨道段落生成函数 ======= | |
| // 1) 螺旋上升段(半径从 R1 到 R2,升高 dy,共 turns 圈) | |
| function generateHelixPoints(start, R1, R2, dy, turns = 3, ptsPerTurn = 60) { | |
| const totalPts = Math.max(20, Math.floor(turns * ptsPerTurn)); | |
| const pts = []; | |
| for (let i = 0; i <= totalPts; i++) { | |
| const t = i / totalPts; | |
| const angle = t * turns * Math.PI * 2; | |
| const r = THREE.MathUtils.lerp(R1, R2, t); | |
| const x = start.x + r * Math.cos(angle); | |
| const z = start.z + r * Math.sin(angle); | |
| const y = start.y + dy * t + 0.6 * Math.sin(angle * 3) * (1 - t); // 轻微起伏 | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: pts, end: pts[pts.length - 1].clone() }; | |
| } | |
| // 2) 波浪前进段(在 XZ 前进同时 Y 上下振荡) | |
| function generateWavePoints(start, length = 120, amplitudeY = 8, frequency = 1.5, ptsPerUnit = 1.5) { | |
| const totalPts = Math.max(50, Math.floor(length * ptsPerUnit)); | |
| const pts = []; | |
| for (let i = 0; i <= totalPts; i++) { | |
| const t = i / totalPts; | |
| const x = start.x + length * t; | |
| const y = start.y + amplitudeY * Math.sin(t * Math.PI * 2 * frequency); | |
| const z = start.z + 15 * Math.sin(t * Math.PI * 2); | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: pts, end: pts[pts.length - 1].clone() }; | |
| } | |
| // 3) 漏斗下降(螺旋半径快速缩小,同时下降) | |
| function generateFunnelPoints(start, R1, R2, dy, turns = 2.5, ptsPerTurn = 80) { | |
| const totalPts = Math.max(30, Math.floor(turns * ptsPerTurn)); | |
| const pts = []; | |
| for (let i = 0; i <= totalPts; i++) { | |
| const t = i / totalPts; | |
| const angle = t * turns * Math.PI * 2; | |
| const r = THREE.MathUtils.lerp(R1, R2, t); | |
| const x = start.x + r * Math.cos(angle); | |
| const z = start.z + r * Math.sin(angle); | |
| const y = start.y - dy * t + 0.4 * Math.cos(angle * 4) * (1 - t); | |
| pts.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: pts, end: pts[pts.length - 1].clone() }; | |
| } | |
| // 4) 垂直环(Loop-the-Loop) | |
| function generateLoopPoints(center, radius = 16, startAngle = Math.PI / 2, endAngle = startAngle + Math.PI * 2, pts = 96) { | |
| const arr = []; | |
| for (let i = 0; i <= pts; i++) { | |
| const t = i / pts; | |
| const ang = THREE.MathUtils.lerp(startAngle, endAngle, t); | |
| const x = center.x + radius * Math.cos(ang); | |
| const y = center.y + radius * Math.sin(ang); | |
| const z = center.z; | |
| arr.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: arr, end: arr[arr.length - 1].clone() }; | |
| } | |
| // 5) 多层交叉(在不同高度层之间左右穿梭) | |
| function generateCrossoverPoints(start, spanX = 80, layers = 3, heightStep = 10, ptsPerLayer = 36) { | |
| const arr = []; | |
| const total = layers * ptsPerLayer; | |
| for (let i = 0; i <= total; i++) { | |
| const t = i / total; | |
| const layerIndex = Math.floor(t * layers); | |
| const y = start.y + layerIndex * heightStep; | |
| const phase = (t * layers - layerIndex) * Math.PI * 2; | |
| const x = start.x + (t - 0.5) * spanX + 10 * Math.sin(phase); | |
| const z = start.z + 30 * Math.cos(phase); | |
| arr.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: arr, end: arr[arr.length - 1].clone() }; | |
| } | |
| // 6) 弹珠台式分支(带震荡的通道段) | |
| function generatePinballPoints(start, length = 100, swing = 14, freq = 2.0, ptsPerUnit = 1.2) { | |
| const totalPts = Math.max(40, Math.floor(length * ptsPerUnit)); | |
| const arr = []; | |
| for (let i = 0; i <= totalPts; i++) { | |
| const t = i / totalPts; | |
| const x = start.x + length * t; | |
| const y = start.y + 3 * Math.sin(t * Math.PI * 2); | |
| const z = start.z + swing * Math.sin(t * Math.PI * 2 * freq); | |
| arr.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: arr, end: arr[arr.length - 1].clone() }; | |
| } | |
| // 7) 抛射与回归(抛物线段,落到更低轨道) | |
| function generateLaunchReturnPoints(start, dx = 80, dy = 35, drop = 45, pts = 72) { | |
| const arr = []; | |
| for (let i = 0; i <= pts; i++) { | |
| const t = i / pts; | |
| const x = start.x + dx * t; | |
| const y = start.y + dy * (4 * t * (1 - t)) - drop * t; // 抛物线加向下位移 | |
| const z = start.z; | |
| arr.push(new THREE.Vector3(x, y, z)); | |
| } | |
| return { points: arr, end: arr[arr.length - 1].clone() }; | |
| } | |
| // 8) 过渡段(用于连接不同形状,保证切线连续) | |
| function generateTransitionPoints(start, end, points = 20) { | |
| const arr = []; | |
| for (let i = 0; i <= points; i++) { | |
| const t = i / points; | |
| // 简单线性过渡,若需要更平滑可换用贝塞尔 | |
| arr.push(new THREE.Vector3( | |
| THREE.MathUtils.lerp(start.x, end.x, t), | |
| THREE.MathUtils.lerp(start.y, end.y, t), | |
| THREE.MathUtils.lerp(start.z, end.z, t) | |
| )); | |
| } | |
| return { points: arr, end: end.clone() }; | |
| } | |
| // ======= 轨道与场景的构建 ======= | |
| function createRollerCoaster() { | |
| // 组合所有段落的点 | |
| const allPoints = []; | |
| let current = new THREE.Vector3(0, 0, 0); | |
| // 1) 初始螺旋 | |
| let seg = generateHelixPoints(current, 20, 8, 24, 3.5, 60); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 2) 波浪段 + 过渡 | |
| seg = generateWavePoints(current, 120, 8, 1.6, 1.5); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 3) 过渡到漏斗 | |
| const beforeFunnel = new THREE.Vector3(current.x, current.y, current.z); | |
| const funnelStart = new THREE.Vector3(beforeFunnel.x + 6, beforeFunnel.y, beforeFunnel.z + 6); | |
| seg = generateTransitionPoints(beforeFunnel, funnelStart, 16); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 4) 漏斗下降 | |
| seg = generateFunnelPoints(current, 10, 3.5, 28, 2.5, 80); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 5) 过渡到环 | |
| const loopCenter = new THREE.Vector3(current.x + 6, current.y + 2, current.z + 6); | |
| seg = generateTransitionPoints(current, loopCenter, 16); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 6) 垂直环 | |
| seg = generateLoopPoints(loopCenter, 16, Math.PI / 2, Math.PI / 2 + Math.PI * 2, 96); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 7) 过渡到多层交叉 | |
| const crossStart = new THREE.Vector3(current.x + 4, current.y + 2, current.z + 4); | |
| seg = generateTransitionPoints(current, crossStart, 12); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 8) 多层交叉 | |
| seg = generateCrossoverPoints(current, 100, 3, 10, 42); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 9) 过渡到弹珠台段 | |
| const pinballStart = new THREE.Vector3(current.x + 3, current.y + 1, current.z + 3); | |
| seg = generateTransitionPoints(current, pinballStart, 12); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 10) 弹珠台震荡段 | |
| seg = generatePinballPoints(current, 110, 14, 2.2, 1.3); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 11) 过渡到抛射段 | |
| const launchStart = new THREE.Vector3(current.x + 2, current.y, current.z); | |
| seg = generateTransitionPoints(current, launchStart, 10); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 12) 抛射与回归 | |
| seg = generateLaunchReturnPoints(current, 90, 35, 55, 80); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| // 13) 最后回到起点附近(闭环) | |
| const backToStart1 = new THREE.Vector3(current.x - 40, current.y - 8, current.z - 40); | |
| const backToStart2 = new THREE.Vector3(0, 0, 0); | |
| seg = generateTransitionPoints(current, backToStart1, 24); | |
| allPoints.push(...seg.points); | |
| current.copy(seg.end); | |
| seg = generateTransitionPoints(current, backToStart2, 30); | |
| allPoints.push(...seg.points); | |
| // 使用 Chaikin 进行平滑,保证 G1 连续 | |
| const smoothed = chaikinSmoothPoints(allPoints, 2); | |
| // 构建闭合曲线(chordal 类型,张力 0.15) | |
| curve = new THREE.CatmullRomCurve3(smoothed, true, 'chordal', 0.15); | |
| // 生成管道轨道 | |
| const tubularSegments = 1600; // 高分段,保证平滑 | |
| const radialSegments = 16; // 圆形截面 | |
| const pipeRadius = 0.7; // 管道半径(半径) | |
| const trackGeo = new THREE.TubeGeometry(curve, tubularSegments, pipeRadius, radialSegments, true); | |
| const trackMat = new THREE.MeshPhysicalMaterial({ | |
| color: 0xffffff, | |
| roughness: 0.25, | |
| metalness: 0.0, | |
| transparent: true, | |
| opacity: 0.7, | |
| transmission: 0.2, | |
| thickness: 0.6, | |
| clearcoat: 0.9, | |
| clearcoatRoughness: 0.2, | |
| side: THREE.DoubleSide | |
| }); | |
| trackMesh = new THREE.Mesh(trackGeo, trackMat); | |
| trackMesh.castShadow = true; | |
| trackMesh.receiveShadow = true; | |
| scene.add(trackMesh); | |
| // 动画小球 | |
| const ballGeo = new THREE.SphereGeometry(1.2, 32, 32); | |
| const ballMat = new THREE.MeshStandardMaterial({ | |
| color: 0x2979ff, | |
| roughness: 0.4, | |
| metalness: 0.1 | |
| }); | |
| ball = new THREE.Mesh(ballGeo, ballMat); | |
| ball.castShadow = true; | |
| ball.receiveShadow = true; | |
| scene.add(ball); | |
| // 支架系统 | |
| supportsGroup = new THREE.Group(); | |
| scene.add(supportsGroup); | |
| buildSupports(curve, pipeRadius); | |
| HUD.ptCount.textContent = smoothed.length.toString(); | |
| HUD.supCount.textContent = supportsGroup.children.length.toString(); | |
| } | |
| // ======= 支架系统 ======= | |
| function buildSupports(curve, trackRadius) { | |
| const sampleEveryNPoints = 40; // 采样密度 | |
| const minHeightDiff = 10; // 轨道距离地面的最小高度 | |
| const pillarRadius = 0.18; | |
| const footRadius = 0.65; | |
| const pts = curve.getPoints(800); // 获取路径点 | |
| for (let i = 0; i < pts.length; i += sampleEveryNPoints) { | |
| const p = pts[i]; | |
| const heightDiff = p.y - trackRadius; // 轨道外壁到地面的高度 | |
| if (heightDiff < minHeightDiff) continue; | |
| // 圆柱支柱 | |
| const pillarGeo = new THREE.CylinderGeometry(pillarRadius, pillarRadius, heightDiff, 12); | |
| const pillarMat = new THREE.MeshStandardMaterial({ color: 0x9e9e9e, roughness: 0.6, metalness: 0.7 }); | |
| const pillar = new THREE.Mesh(pillarGeo, pillarMat); | |
| pillar.position.set(p.x, heightDiff / 2, p.z); | |
| pillar.castShadow = true; | |
| pillar.receiveShadow = true; | |
| supportsGroup.add(pillar); | |
| // 顶部圆盘 | |
| const topDiskGeo = new THREE.CylinderGeometry(trackRadius * 0.75, trackRadius * 0.75, 0.12, 16); | |
| const topDisk = new THREE.Mesh(topDiskGeo, new THREE.MeshStandardMaterial({ color: 0xbdbdbd, roughness: 0.5, metalness: 0.6 })); | |
| topDisk.position.set(p.x, p.y - trackRadius - 0.06, p.z); | |
| topDisk.castShadow = true; | |
| topDisk.receiveShadow = true; | |
| supportsGroup.add(topDisk); | |
| // 底部底座 | |
| const footGeo = new THREE.CylinderGeometry(footRadius, footRadius, 0.12, 16); | |
| const foot = new THREE.Mesh(footGeo, new THREE.MeshStandardMaterial({ color: 0x9e9e9e, roughness: 0.65, metalness: 0.6 })); | |
| foot.position.set(p.x, 0.06, p.z); | |
| foot.castShadow = true; | |
| foot.receiveShadow = true; | |
| supportsGroup.add(foot); | |
| } | |
| } | |
| // ======= 动画循环 ======= | |
| let fpsCounter = { last: performance.now(), frames: 0 }; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| if (!isPaused) { | |
| elapsed += delta; | |
| } | |
| // 小球沿轨道运动(恒速参数化:getPointAt) | |
| if (curve && ball) { | |
| const pathLength = 800; // 抽象长度,映射到 [0,1] 保持稳定 | |
| const s = (elapsed * speedMultiplier * 120) % pathLength; // 基础速度 | |
| const u = s / pathLength; | |
| const pos = curve.getPointAt(u); | |
| const tan = curve.getTangentAt(u); | |
| ball.position.copy(pos); | |
| // 球体朝向(可选) | |
| const lookTarget = new THREE.Vector3().copy(pos).add(tan); | |
| ball.lookAt(lookTarget); | |
| // 第一人称:相机在小球后方/上方并看向前进方向 | |
| if (firstPerson) { | |
| const backOffset = tan.clone().multiplyScalar(-3.2); | |
| const upOffset = new THREE.Vector3(0, 1.3, 0); | |
| const camPos = new THREE.Vector3().copy(pos).add(backOffset).add(upOffset); | |
| camera.position.lerp(camPos, 0.25); // 平滑过渡 | |
| const ahead = new THREE.Vector3().copy(pos).add(tan.multiplyScalar(10)).add(new THREE.Vector3(0, 0.5, 0)); | |
| camera.lookAt(ahead); | |
| } else { | |
| controls.update(); | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| // 简易 FPS 统计 | |
| fpsCounter.frames++; | |
| const now = performance.now(); | |
| if (now - fpsCounter.last >= 1000) { | |
| const fps = Math.round((fpsCounter.frames * 1000) / (now - fpsCounter.last)); | |
| HUD.fps.textContent = fps.toString(); | |
| fpsCounter.frames = 0; | |
| fpsCounter.last = now; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |