|
|
<!DOCTYPE html> |
|
|
<html lang="zh-CN"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" /> |
|
|
<title>Three.js 云霄飞车动画</title> |
|
|
<style> |
|
|
html, body { |
|
|
margin: 0; |
|
|
height: 100%; |
|
|
background: #ffeef6; |
|
|
overflow: hidden; |
|
|
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",sans-serif; |
|
|
} |
|
|
#container { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
} |
|
|
.hud { |
|
|
position: fixed; |
|
|
top: 10px; |
|
|
left: 10px; |
|
|
padding: 8px 12px; |
|
|
background: rgba(0,0,0,0.5); |
|
|
color: #fff; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
z-index: 10; |
|
|
user-select: none; |
|
|
} |
|
|
.hud a { |
|
|
color: #7fd4ff; |
|
|
text-decoration: none; |
|
|
} |
|
|
.hud .hint { |
|
|
opacity: 0.8; |
|
|
margin-top: 6px; |
|
|
font-size: 12px; |
|
|
line-height: 1.4; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="container"></div> |
|
|
<div class="hud"> |
|
|
<div><strong>Three.js 云霄飞车</strong></div> |
|
|
<div class="hint"> |
|
|
模式:<span id="mode">第三人称</span><br/> |
|
|
快捷键:V 切换视角<br/> |
|
|
鼠标左键:旋转 | 右键:平移 | 滚轮:缩放 |
|
|
</div> |
|
|
<div style="margin-top:6px; opacity:0.85;"> |
|
|
Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">anycoder</a> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<script src="https://unpkg.com/[email protected]/build/three.min.js"></script> |
|
|
<script src="https://unpkg.com/[email protected]/examples/js/controls/OrbitControls.js"></script> |
|
|
|
|
|
<script> |
|
|
|
|
|
const container = document.getElementById('container'); |
|
|
const scene = new THREE.Scene(); |
|
|
scene.background = new THREE.Color(0xffeef6); |
|
|
|
|
|
const 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; |
|
|
container.appendChild(renderer.domElement); |
|
|
|
|
|
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000); |
|
|
camera.position.set(80, 45, 80); |
|
|
camera.lookAt(0, 10, 0); |
|
|
|
|
|
const controls = new THREE.OrbitControls(camera, renderer.domElement); |
|
|
controls.enableDamping = true; |
|
|
controls.dampingFactor = 0.08; |
|
|
controls.maxDistance = 200; |
|
|
controls.minDistance = 10; |
|
|
controls.target.set(0, 10, 0); |
|
|
|
|
|
|
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6); |
|
|
scene.add(ambient); |
|
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9); |
|
|
dirLight.position.set(50, 80, 40); |
|
|
dirLight.castShadow = true; |
|
|
dirLight.shadow.mapSize.set(2048, 2048); |
|
|
dirLight.shadow.camera.near = 10; |
|
|
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 groundGeo = new THREE.PlaneGeometry(600, 600); |
|
|
const groundMat = new THREE.MeshStandardMaterial({ |
|
|
color: 0xf0f0f3, |
|
|
roughness: 0.95, |
|
|
metalness: 0.0 |
|
|
}); |
|
|
const ground = new THREE.Mesh(groundGeo, groundMat); |
|
|
ground.rotation.x = -Math.PI / 2; |
|
|
ground.receiveShadow = true; |
|
|
scene.add(ground); |
|
|
|
|
|
|
|
|
function chaikinSmooth(points, iterations = 2) { |
|
|
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 p0 = pts[i]; |
|
|
const p1 = pts[i + 1]; |
|
|
const Q = p0.clone().lerp(p1, 0.25); |
|
|
const R = p0.clone().lerp(p1, 0.75); |
|
|
newPts.push(Q, R); |
|
|
} |
|
|
|
|
|
newPts.push(pts[pts.length - 1].clone()); |
|
|
pts = newPts; |
|
|
} |
|
|
return pts; |
|
|
} |
|
|
|
|
|
|
|
|
function sampleCurve(curve, targetSegments = 300, smoothIterations = 1) { |
|
|
const points = []; |
|
|
for (let i = 0; i <= targetSegments; i++) { |
|
|
const t = i / targetSegments; |
|
|
points.push(curve.getPointAt(t)); |
|
|
} |
|
|
if (smoothIterations > 0) { |
|
|
return chaikinSmooth(points, smoothIterations); |
|
|
} |
|
|
return points; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function generateHelixPoints(start, radius = 15, heightDelta = 20, turns = 3, direction = 1, samples = 300) { |
|
|
const pts = []; |
|
|
const yStart = start.y; |
|
|
for (let i = 0; i <= samples; i++) { |
|
|
const t = i / samples; |
|
|
const angle = t * turns * Math.PI * 2 * direction; |
|
|
const y = yStart + t * heightDelta * direction; |
|
|
const x = start.x + radius * Math.cos(angle); |
|
|
const z = start.z + radius * Math.sin(angle); |
|
|
pts.push(new THREE.Vector3(x, y, z)); |
|
|
} |
|
|
return { points: pts, end: pts[pts.length - 1].clone() }; |
|
|
} |
|
|
|
|
|
|
|
|
function generateWaveYPoints(start, length = 60, amplitude = 4, frequency = 2, height = 6, samples = 180) { |
|
|
const pts = []; |
|
|
for (let i = 0; i <= samples; i++) { |
|
|
const t = i / samples; |
|
|
const x = start.x + t * length; |
|
|
const z = start.z + amplitude * Math.sin(t * Math.PI * 2 * frequency); |
|
|
const y = height; |
|
|
pts.push(new THREE.Vector3(x, y, z)); |
|
|
} |
|
|
return { points: pts, end: pts[pts.length - 1].clone() }; |
|
|
} |
|
|
|
|
|
|
|
|
function generateFunnelPoints(start, startRadius = 12, endRadius = 3, heightDelta = 20, turns = 4, direction = 1, samples = 280) { |
|
|
const pts = []; |
|
|
const yStart = start.y; |
|
|
for (let i = 0; i <= samples; i++) { |
|
|
const t = i / samples; |
|
|
const angle = t * turns * Math.PI * 2 * direction; |
|
|
const r = THREE.MathUtils.lerp(startRadius, endRadius, t); |
|
|
const y = yStart - t * heightDelta; |
|
|
const x = start.x + r * Math.cos(angle); |
|
|
const z = start.z + r * Math.sin(angle); |
|
|
pts.push(new THREE.Vector3(x, y, z)); |
|
|
} |
|
|
return { points: pts, end: pts[pts.length - 1].clone() }; |
|
|
} |
|
|
|
|
|
|
|
|
function generateLoopPoints(center, radius = 12, samples = 120) { |
|
|
const pts = []; |
|
|
const cx = center.x; |
|
|
const cy = center.y; |
|
|
const cz = center.z; |
|
|
for (let i = 0; i <= samples; i++) { |
|
|
const t = i / samples; |
|
|
const ang = t * Math.PI * 2; |
|
|
|
|
|
const y = cy + radius * Math.cos(ang); |
|
|
const z = cz + radius * Math.sin(ang); |
|
|
const x = cx; |
|
|
pts.push(new THREE.Vector3(x, y, z)); |
|
|
} |
|
|
return { points: pts, end: pts[pts.length - 1].clone() }; |
|
|
} |
|
|
|
|
|
|
|
|
function generateTransitionPoints(start, end, align = 'end', samples = 80) { |
|
|
|
|
|
|
|
|
const pts = []; |
|
|
for (let i = 0; i <= samples; i++) { |
|
|
const t = i / samples; |
|
|
|
|
|
const p = start.clone().lerp(end, t); |
|
|
const midLift = Math.sin(t * Math.PI) * 3.0; |
|
|
p.y += midLift; |
|
|
pts.push(p); |
|
|
} |
|
|
return { points: pts, end: pts[pts.length - 1].clone() }; |
|
|
} |
|
|
|
|
|
|
|
|
function generateTrackPoints() { |
|
|
const pts = []; |
|
|
|
|
|
const start = new THREE.Vector3(0, 6, 0); |
|
|
|
|
|
|
|
|
const helix1 = generateHelixPoints(start, 15, 20, 3, 1, 260); |
|
|
|
|
|
const trans1 = generateTransitionPoints(helix1.end, helix1.end.clone().add(new THREE.Vector3(0, 0, 0)), 'end', 60); |
|
|
|
|
|
const wave = generateWaveYPoints(helix1.end, 60, 5, 2, 6, 220); |
|
|
|
|
|
const trans2 = generateTransitionPoints(wave.end, wave.end.clone().add(new THREE.Vector3(0, 0, 0)), 'end', 60); |
|
|
|
|
|
const funnel = generateFunnelPoints(wave.end, 12, 3, 20, 4, -1, 300); |
|
|
|
|
|
const trans3 = generateTransitionPoints(funnel.end, funnel.end.clone().add(new THREE.Vector3(0, 0, 0)), 'end', 60); |
|
|
|
|
|
const loop = generateLoopPoints(funnel.end, 12, 140); |
|
|
|
|
|
|
|
|
const all = [] |
|
|
.concat(helix1.points.slice(1)) |
|
|
.concat(trans1.points.slice(1)) |
|
|
.concat(wave.points.slice(1)) |
|
|
.concat(trans2.points.slice(1)) |
|
|
.concat(funnel.points.slice(1)) |
|
|
.concat(trans3.points.slice(1)) |
|
|
.concat(loop.points.slice(1)); |
|
|
|
|
|
|
|
|
const smoothed = chaikinSmooth(all, 2); |
|
|
return smoothed; |
|
|
} |
|
|
|
|
|
|
|
|
const trackPoints = generateTrackPoints(); |
|
|
const curve = new THREE.CatmullRomCurve3(trackPoints, true, 'chordal', 0.15); |
|
|
|
|
|
const tubularSegments = 1500; |
|
|
const radialSegments = 16; |
|
|
const tubeRadius = 1.2; |
|
|
|
|
|
const tubeGeo = new THREE.TubeGeometry(curve, tubularSegments, tubeRadius, radialSegments, true); |
|
|
const tubeMat = new THREE.MeshStandardMaterial({ |
|
|
color: 0xffffff, |
|
|
roughness: 0.25, |
|
|
metalness: 0.1, |
|
|
transparent: true, |
|
|
opacity: 0.85, |
|
|
side: THREE.DoubleSide |
|
|
}); |
|
|
const track = new THREE.Mesh(tubeGeo, tubeMat); |
|
|
track.castShadow = true; |
|
|
track.receiveShadow = true; |
|
|
scene.add(track); |
|
|
|
|
|
|
|
|
const startPoint = curve.getPointAt(0); |
|
|
const startMarkerGeo = new THREE.SphereGeometry(0.6, 16, 16); |
|
|
const startMarkerMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, emissive: 0x0, roughness: 0.5 }); |
|
|
const startMarker = new THREE.Mesh(startMarkerGeo, startMarkerMat); |
|
|
startMarker.position.copy(startPoint); |
|
|
startMarker.castShadow = true; |
|
|
scene.add(startMarker); |
|
|
|
|
|
|
|
|
const ballGeo = new THREE.SphereGeometry(0.9, 24, 24); |
|
|
const ballMat = new THREE.MeshStandardMaterial({ color: 0xff4d4d, roughness: 0.3, metalness: 0.0 }); |
|
|
const ball = new THREE.Mesh(ballGeo, ballMat); |
|
|
ball.castShadow = true; |
|
|
scene.add(ball); |
|
|
|
|
|
|
|
|
function buildSupports(curve, tubeRadius, groundY = 0, intervalU = 0.02, minHeight = 12) { |
|
|
const supports = new THREE.Group(); |
|
|
const points = []; |
|
|
for (let u = 0; u <= 1.0; u += intervalU) { |
|
|
points.push(curve.getPointAt(u)); |
|
|
} |
|
|
|
|
|
const cylGeo = new THREE.CylinderGeometry(0.15, 0.15, 1, 8, 1, true); |
|
|
const cylMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a6, metalness: 0.4, roughness: 0.7 }); |
|
|
|
|
|
const capGeoTop = new THREE.CylinderGeometry(0.25, 0.25, 0.3, 12); |
|
|
const capGeoBottom = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 12); |
|
|
const capMat = new THREE.MeshStandardMaterial({ color: 0x8b9098, metalness: 0.2, roughness: 0.8 }); |
|
|
|
|
|
for (const p of points) { |
|
|
const height = p.y - tubeRadius - groundY; |
|
|
if (height < minHeight) continue; |
|
|
const cyl = new THREE.Mesh(cylGeo, cylMat); |
|
|
cyl.position.set(p.x, groundY + height / 2, p.z); |
|
|
cyl.scale.y = height; |
|
|
cyl.castShadow = true; |
|
|
cyl.receiveShadow = true; |
|
|
supports.add(cyl); |
|
|
|
|
|
|
|
|
const capTop = new THREE.Mesh(capGeoTop, capMat); |
|
|
capTop.position.set(p.x, p.y - tubeRadius, p.z); |
|
|
capTop.castShadow = true; |
|
|
supports.add(capTop); |
|
|
|
|
|
|
|
|
const capBottom = new THREE.Mesh(capGeoBottom, capMat); |
|
|
capBottom.position.set(p.x, groundY + 0.1, p.z); |
|
|
capBottom.castShadow = true; |
|
|
supports.add(capBottom); |
|
|
} |
|
|
|
|
|
return supports; |
|
|
} |
|
|
|
|
|
const supports = buildSupports(curve, tubeRadius, 0, 0.025, 10); |
|
|
scene.add(supports); |
|
|
|
|
|
|
|
|
const clock = new THREE.Clock(); |
|
|
let t = 0; |
|
|
let speed = 0.06; |
|
|
let isFirstPerson = false; |
|
|
const modeLabel = document.getElementById('mode'); |
|
|
|
|
|
function animate() { |
|
|
const dt = clock.getDelta(); |
|
|
t = (t + dt * speed) % 1.0; |
|
|
|
|
|
|
|
|
const pos = curve.getPointAt(t); |
|
|
const tan = curve.getTangentAt(t); |
|
|
ball.position.copy(pos); |
|
|
ball.lookAt(pos.clone().add(tan)); |
|
|
|
|
|
|
|
|
if (isFirstPerson) { |
|
|
|
|
|
const backOffset = tan.clone().multiplyScalar(-6); |
|
|
const up = new THREE.Vector3(0, 1, 0); |
|
|
const side = new THREE.Vector3().crossVectors(tan, up).normalize(); |
|
|
const camPos = pos.clone().add(backOffset).add(side.multiplyScalar(1.2)).add(up.multiplyScalar(1.5)); |
|
|
camera.position.lerp(camPos, 0.15); |
|
|
const lookAtPoint = pos.clone().add(tan.clone().multiplyScalar(10)); |
|
|
camera.lookAt(lookAtPoint); |
|
|
} else { |
|
|
|
|
|
controls.update(); |
|
|
} |
|
|
|
|
|
renderer.render(scene, camera); |
|
|
requestAnimationFrame(animate); |
|
|
} |
|
|
|
|
|
animate(); |
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => { |
|
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
|
camera.updateProjectionMatrix(); |
|
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
|
}); |
|
|
|
|
|
window.addEventListener('keydown', (e) => { |
|
|
if (e.key.toLowerCase() === 'v') { |
|
|
isFirstPerson = !isFirstPerson; |
|
|
modeLabel.textContent = isFirstPerson ? '第一人称' : '第三人称'; |
|
|
} |
|
|
if (e.key === '+' || e.key === '=') { |
|
|
speed = Math.min(0.2, speed + 0.01); |
|
|
} |
|
|
if (e.key === '-' || e.key === '_') { |
|
|
speed = Math.max(0.01, speed - 0.01); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
window.addEventListener('contextmenu', (e) => e.preventDefault(), false); |
|
|
</script> |
|
|
</body> |
|
|
</html> |