import * as THREE from 'three' import { APP_CONFIG } from '@/constants/config' type PointerTarget = { x: number y: number } type TravelSceneController = { setStarted: (started: boolean) => void setSpeed: (speed: number) => void setPointerTarget: (pointer: PointerTarget) => void dispose: () => void } type StarState = { x: number y: number z: number vx: number vy: number vz: number ax: number ay: number az: number delay: number baseSize: number baseAlpha: number } type SmokeState = { mesh: THREE.Mesh x: number y: number z: number age: number vx: number vy: number vz: number rotation: number spin: number baseScaleX: number baseScaleY: number opacity: number wobble: number wobbleSpeed: number } const STAR_COUNT = APP_CONFIG.starfield.count const STAR_SOURCE_RADIUS = APP_CONFIG.starfield.sourceRadius const STAR_FAR_Z = APP_CONFIG.starfield.farZ const STAR_RESET_Z = APP_CONFIG.starfield.resetZ const STAR_BOUNDS_X = APP_CONFIG.starfield.boundsX const STAR_BOUNDS_Y = APP_CONFIG.starfield.boundsY const SMOKE_COUNT = APP_CONFIG.smoke.count const SMOKE_SOURCE_RADIUS = APP_CONFIG.smoke.sourceRadius const SMOKE_FAR_Z = APP_CONFIG.smoke.farZ const SMOKE_RESET_Z = APP_CONFIG.smoke.resetZ const SMOKE_BOUNDS_X = APP_CONFIG.smoke.boundsX const SMOKE_BOUNDS_Y = APP_CONFIG.smoke.boundsY const STAR_COLORS = [ '#00e5ff', // 亮青 '#ff00ea', // 亮粉 '#ffcc00', // 亮金 '#00ff66', // 亮绿 '#ff3333', // 亮红 ] as const const SMOKE_COLORS = ['#8dd8ff', '#adc4ff', '#d6b5ff', '#ffe7ff'] as const const STAR_VERTEX_SHADER = ` attribute float aSizeBase; attribute float aAlphaBase; attribute float aProgress; attribute float aTail; attribute vec2 aDir; varying vec3 vColor; varying float vAlpha; varying float vTail; varying vec2 vDir; void main() { vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); float scaledProgress = max(aProgress, 0.0); // 放大精灵尺寸以容纳更长的拖尾 float pointSize = aSizeBase * (0.95 + scaledProgress * 2.85) * ${APP_CONFIG.starfield.pointScale.toFixed(1)} / max(1.0, -mvPosition.z) * 2.5; gl_Position = projectionMatrix * mvPosition; gl_PointSize = clamp(pointSize, ${APP_CONFIG.starfield.pointMinSize.toFixed(1)} * 2.5, ${APP_CONFIG.starfield.pointMaxSize.toFixed(1)} * 2.5); vColor = color; // 取消原本透明度的距离衰减,直接用最高的 baseAlpha 甚至更高,保持全局最亮 vAlpha = aAlphaBase * 1.5; vTail = aTail; vDir = aDir; } ` const STAR_FRAGMENT_SHADER = ` varying vec3 vColor; varying float vAlpha; varying float vTail; varying vec2 vDir; uniform float uSpeed; void main() { // 放大坐标系,这样中心点画得相对较小,留出极大空间画长尾巴 vec2 centered = (gl_PointCoord - vec2(0.5)) * 2.5; float distanceToCenter = length(centered); float hotspot = 1.0 - smoothstep(0.0, 0.06, distanceToCenter); hotspot = pow(hotspot, 0.32); float core = 1.0 - smoothstep(0.0, 0.13, distanceToCenter); core = pow(core, 0.44); float halo = 1.0 - smoothstep(0.015, 0.22, distanceToCenter); halo = pow(halo, 1.95); // 翻转 Y 轴以匹配 WebGL 纹理坐标系 vec2 dir = length(vDir) > 0.0001 ? normalize(vec2(vDir.x, -vDir.y)) : vec2(0.0, -1.0); vec2 side = vec2(-dir.y, dir.x); float along = dot(centered, dir); float across = dot(centered, side); float back = -along; // 拖尾长度根据当前速度动态变化 float speedFactor = max(uSpeed, 0.1); float tailLength = clamp(${(APP_CONFIG.starfield.trailLength).toFixed(2)} * 2.5 * speedFactor, 0.05, 2.5); float tailFade = 1.0 - smoothstep(0.0, tailLength, back); float tailMask = smoothstep(-0.02, 0.05, back); float maxWidth = ${(APP_CONFIG.starfield.trailWidth).toFixed(2)}; float width = maxWidth * mix(0.1, 1.0, pow(tailFade, 0.8)); float tailProfile = 1.0 - smoothstep(0.0, width, abs(across)); float tailHotspot = 1.0 - smoothstep(0.0, width * 0.3, abs(across)); // 速度越快,尾巴亮度也适当增强 float tail = (tailProfile * 0.8 + tailHotspot * 1.5) * tailFade * tailMask * vTail * (0.4 + 0.6 * speedFactor); // 极高的能量累加,不丢弃任何 alpha,强制发光 float alpha = (hotspot * 3.5 + core * 2.5 + halo * 0.5 + tail * 2.8) * vAlpha; if (alpha < 0.01) { discard; } vec3 color = vColor; // 让高光点保持自身颜色的同时极度明亮,而不仅是变白 vec3 hotColor = mix(vColor, vec3(1.0), 0.5); color = mix(color, hotColor, hotspot * 0.8 + core * 0.4); color += hotColor * hotspot * 2.0; color += vColor * core * 1.5; color += vColor * tail * 1.8; // 用极其暴力的颜色叠加 gl_FragColor = vec4(min(color, vec3(1.0)), min(alpha, 1.0)); } ` const GALAXY_VERTEX_SHADER = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const GALAXY_FRAGMENT_SHADER = ` varying vec2 vUv; uniform float uTime; uniform float uOpacity; uniform float uArms; uniform float uTwist; uniform float uArmSharpness; uniform float uNoiseScale; uniform float uNoiseStrength; uniform float uCoreRadius; uniform float uCoreGlow; uniform float uGlowStrength; uniform float uSeed; uniform vec3 uCoreWhite; uniform vec3 uCoreGold; uniform vec3 uWarmOrange; uniform vec3 uWarmMagenta; uniform vec3 uOuterBlue; uniform vec3 uDeepBlue; uniform vec3 uInnerColor; uniform vec3 uOuterColor; uniform vec3 uDustColor; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y ); } float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; for (int i = 0; i < 5; i += 1) { value += amplitude * noise(p); p = p * 2.03 + vec2(17.3, 9.2); amplitude *= 0.5; } return value; } vec3 sampleGalaxyRamp(float t) { float value = clamp(t, 0.0, 1.0); vec3 color = uCoreWhite; color = mix(color, uCoreGold, smoothstep(0.03, 0.18, value)); color = mix(color, uWarmOrange, smoothstep(0.12, 0.32, value)); color = mix(color, uWarmMagenta, smoothstep(0.24, 0.58, value)); color = mix(color, uOuterBlue, smoothstep(0.5, 0.84, value)); color = mix(color, uDeepBlue, smoothstep(0.76, 1.0, value)); return color; } void main() { vec2 p = vUv * 2.0 - 1.0; p.x *= 1.08; float radius = length(p); float angle = atan(p.y, p.x); float time = uTime * 0.045; vec2 flowUv = p * uNoiseScale; float lowNoise = fbm(flowUv + vec2(time * 0.35 + uSeed * 5.1, -time * 0.18)); float highNoise = fbm(flowUv * 2.1 - vec2(time * 0.42, time * 0.26 + uSeed * 3.7)); float veilNoise = fbm(p * (uNoiseScale * 0.55) + vec2(-time * 0.12, time * 0.08 + uSeed * 1.9)); float filamentNoise = fbm(flowUv * 3.4 + vec2(time * 0.18 - uSeed * 2.7, time * 0.24)); float clusterNoise = fbm(flowUv * 5.1 + vec2(-time * 0.32 + uSeed * 8.4, time * 0.21)); float grainNoise = noise(flowUv * 13.5 + vec2(time * 1.8 + uSeed * 14.0, -time * 1.3)); float fineNoise = fbm(flowUv * 8.2 - vec2(time * 0.65 - uSeed * 6.2, time * 0.52)); float warpedAngle = angle - radius * uTwist + (lowNoise - 0.5) * uNoiseStrength * 3.2; vec2 swirlDir = vec2(cos(warpedAngle), sin(warpedAngle)); vec2 swirlUv = vec2(dot(p, swirlDir), dot(p, vec2(-swirlDir.y, swirlDir.x))); float streakNoise = fbm(swirlUv * vec2(24.0, 3.2) + vec2(time * 0.12 + uSeed * 4.6, -time * 0.06)); float mottledNoise = fbm(swirlUv * vec2(10.0, 6.5) - vec2(time * 0.08 - uSeed * 5.2, time * 0.04)); float fineFiberNoise = fbm(swirlUv * vec2(46.0, 1.8) + vec2(time * 0.06 + uSeed * 11.4, -time * 0.03)); float occlusionNoise = fbm(swirlUv * vec2(5.4, 4.1) - vec2(time * 0.04 - uSeed * 2.6, time * 0.02)); float spiralWave = cos(warpedAngle * uArms); float armBase = spiralWave * 0.5 + 0.5; float armMask = smoothstep(1.0 - uArmSharpness, 1.0, armBase); float softArms = smoothstep(0.2, 0.92, armBase + (lowNoise - 0.5) * 0.18); float halo = 1.0 - smoothstep(0.12, 1.45, radius); float outerHalo = 1.0 - smoothstep(0.4, 1.6, radius); float core = 1.0 - smoothstep(0.0, uCoreRadius, radius); float coreBloom = 1.0 - smoothstep(0.0, uCoreRadius * 1.6, radius); float hotCore = 1.0 - smoothstep(0.0, uCoreRadius * 0.95, radius); float dust = smoothstep(0.24, 0.9, highNoise); float wisps = smoothstep(0.44, 0.9, lowNoise) * (1.0 - smoothstep(0.38, 1.08, radius)); float outerVeil = smoothstep(0.34, 0.88, veilNoise) * outerHalo; float rim = smoothstep(0.3, 0.98, radius) * (1.0 - smoothstep(0.92, 1.3, radius)); float armRidge = pow(armMask, 1.45) * halo; float lanePattern = 1.0 - smoothstep(0.2, 0.72, abs(sin(warpedAngle * (uArms * 0.55) + filamentNoise * 4.4))); float dustLane = lanePattern * smoothstep(0.12, 0.88, radius) * halo * (0.35 + (1.0 - armMask) * 0.9); float clumps = smoothstep(0.62, 0.9, filamentNoise) * softArms * rim; float pulse = 0.5 + 0.5 * sin(uTime * 1.6 + uSeed * 9.0); float shockRadius = uCoreRadius * (1.08 + pulse * 0.42); float shockRing = smoothstep(shockRadius - 0.17, shockRadius - 0.04, radius) * (1.0 - smoothstep(shockRadius + 0.03, shockRadius + 0.11, radius)) * mix(0.55, 1.0, armMask); float clusterMask = smoothstep(0.72, 0.9, clusterNoise) * pow(softArms, 1.45) * rim; float clusterTwinkle = 0.55 + 0.45 * sin(uTime * 2.6 + angle * uArms * 3.2 + radius * 16.0 + uSeed * 12.0); float armSpark = clusterMask * clusterTwinkle; float edgeBreak = smoothstep(0.72, 0.95, veilNoise) * smoothstep(0.55, 1.08, radius) * outerHalo; float grainMask = smoothstep(0.36, 0.84, grainNoise) * mix(0.32, 1.0, softArms) * halo; float dustTexture = smoothstep(0.28, 0.78, fineNoise) * halo * (0.45 + (1.0 - armMask) * 0.8); float fiberBands = smoothstep(0.48, 0.88, streakNoise) * halo * smoothstep(0.12, 0.92, radius); float mottledDust = smoothstep(0.34, 0.82, mottledNoise) * halo; float fineFibers = smoothstep(0.54, 0.9, fineFiberNoise) * halo * smoothstep(0.1, 0.96, radius); float deepOcclusion = smoothstep(0.38, 0.8, occlusionNoise) * halo * smoothstep(0.18, 1.0, radius); float dustFilaments = fiberBands * (0.4 + (1.0 - armMask) * 0.9) * (0.6 + dustLane * 0.8); float outerSparseFibers = smoothstep(0.44, 0.88, streakNoise) * smoothstep(0.48, 1.08, radius) * outerHalo * (0.45 + veilNoise * 0.8); float microSpeckles = smoothstep(0.955, 0.992, grainNoise) * (0.18 + clusterTwinkle * 0.32) * softArms * rim; float density = halo * mix(0.44, 1.0, armMask) * mix(0.88, 1.12, dust); density = mix(density, halo * 0.96, 0.34); float coreGlow = pow(core, uCoreGlow) * uGlowStrength; float bloomGlow = pow(coreBloom, 1.8) * (uGlowStrength * 0.92); float pulseCore = pow(coreBloom, 1.28) * (0.24 + pulse * 0.34); float innerCloud = pow(core, 1.15) * 0.58 + pow(coreBloom, 1.3) * 0.38; float starDust = smoothstep(0.9, 0.985, highNoise) * softArms * rim * 0.07; density += wisps * 0.4 + outerVeil * 0.42 + innerCloud + coreGlow + bloomGlow + starDust; density += armRidge * 0.2 + clumps * 0.24 + edgeBreak * 0.18 + shockRing * 0.28 + armSpark * 0.1 + pulseCore; density += grainMask * 0.04 + microSpeckles * 0.018; density *= 1.0 - dustLane * 0.34; density *= 0.94 + grainNoise * 0.11; density *= 1.0 - dustTexture * 0.1; density *= 1.0 - dustFilaments * 0.26; density *= 1.0 - fineFibers * 0.18; density *= 1.0 - outerSparseFibers * 0.13; density *= 1.0 - deepOcclusion * (0.12 + dustLane * 0.1); density *= 0.9 + (1.0 - mottledDust) * 0.12; density *= exp(-radius * 0.85); float rampT = clamp(pow(radius, 0.72) + dustLane * 0.05 - coreBloom * 0.14 - armRidge * 0.03 + (1.0 - softArms) * 0.04, 0.0, 1.0); vec3 rampColor = sampleGalaxyRamp(rampT); vec3 layerTint = mix(uOuterColor, uInnerColor, clamp(core * 0.75 + armRidge * 0.24 + softArms * 0.12, 0.0, 1.0)); vec3 color = mix(rampColor, layerTint, 0.25); // 增加 layerTint 的比重,提亮 // 提亮灰尘的颜色,防止在纯黑背景下太暗 vec3 brightDust = mix(uDustColor, vec3(0.3, 0.5, 0.8), 0.3); color = mix(color, brightDust, wisps * 0.12 + dustLane * 0.5 + (1.0 - softArms) * 0.1); color = mix(color, brightDust, dustTexture * 0.15 + dustFilaments * 0.25 + fineFibers * 0.15 + mottledDust * 0.12 + deepOcclusion * 0.1); color = mix(color, uWarmOrange, armRidge * 0.16 + shockRing * 0.1); color = mix(color, uWarmMagenta, smoothstep(0.18, 0.62, radius) * (1.0 - armMask) * 0.24); color = mix(color, uOuterBlue, outerVeil * 0.18 + edgeBreak * 0.12); color = mix(color, mix(uOuterBlue, uDustColor, 0.4), outerSparseFibers * 0.36); color += mix(rampColor, vec3(1.0, 0.98, 0.95), 0.4) * grainMask * 0.018; color += mix(uCoreGold, vec3(1.0, 0.96, 0.86), 0.45) * armRidge * 0.18; color += mix(uOuterBlue, vec3(0.9, 0.97, 1.0), 0.35) * clumps * 0.12; color += mix(uCoreGold, uWarmOrange, 0.45) * shockRing * 0.34; color += mix(uCoreWhite, uOuterBlue, 0.28) * armSpark * 0.12; color += mix(uCoreWhite, uCoreGold, 0.4) * microSpeckles * 0.04; color += uCoreWhite * pow(coreBloom, 1.8) * 0.65; color += mix(uCoreWhite, uCoreGold, 0.22) * pow(hotCore, 1.2) * 1.0; float alpha = density * uOpacity * (1.0 - smoothstep(1.25, 1.6, radius)); if (alpha < 0.01) { discard; } gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0)); } ` function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function lerp(start: number, end: number, alpha: number) { return start + (end - start) * alpha } function smoothstep(edge0: number, edge1: number, value: number) { const t = clamp((value - edge0) / (edge1 - edge0), 0, 1) return t * t * (3 - 2 * t) } function randomRange(min: number, max: number) { return min + Math.random() * (max - min) } function getGalaxyBlending(blendMode: 'normal' | 'additive') { return blendMode === 'additive' ? THREE.AdditiveBlending : THREE.NormalBlending } function sampleDisk(radius: number) { const angle = Math.random() * Math.PI * 2 const distance = Math.pow(Math.random(), APP_CONFIG.starfield.radialExponent) * radius return { x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, } } function pickTier() { const roll = Math.random() if (roll > 0.84) { return 2 } if (roll > 0.5) { return 1 } return 0 } function createSmokeTexture() { const canvas = document.createElement('canvas') canvas.width = 256 canvas.height = 256 const context = canvas.getContext('2d') if (!context) { throw new Error('Unable to create smoke texture context.') } context.clearRect(0, 0, canvas.width, canvas.height) context.globalCompositeOperation = 'screen' for (let index = 0; index < 12; index += 1) { const radius = randomRange(42, 94) const x = randomRange(64, 192) const y = randomRange(64, 192) const gradient = context.createRadialGradient(x, y, radius * 0.12, x, y, radius) gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)') gradient.addColorStop(0.4, 'rgba(220, 235, 255, 0.26)') gradient.addColorStop(1, 'rgba(255, 255, 255, 0)') context.fillStyle = gradient context.beginPath() context.arc(x, y, radius, 0, Math.PI * 2) context.fill() } const texture = new THREE.CanvasTexture(canvas) texture.colorSpace = THREE.SRGBColorSpace texture.needsUpdate = true return texture } function normalizeDirection(x: number, y: number) { const length = Math.hypot(x, y) if (length < 1e-4) { const angle = Math.random() * Math.PI * 2 return { x: Math.cos(angle), y: Math.sin(angle), } } return { x: x / length, y: y / length, } } export function createTravelScene(container: HTMLElement): TravelSceneController { const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, APP_CONFIG.scene.maxPixelRatio)) renderer.setSize(container.clientWidth, container.clientHeight) renderer.setClearColor(APP_CONFIG.scene.background, 1) container.appendChild(renderer.domElement) const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera( APP_CONFIG.camera.fov, container.clientWidth / container.clientHeight, APP_CONFIG.camera.near, APP_CONFIG.camera.far, ) camera.position.set(0, 0, APP_CONFIG.camera.initialZ) const travelRoot = new THREE.Group() scene.add(travelRoot) const ambientLight = new THREE.AmbientLight('#d9efff', 0.45) scene.add(ambientLight) const galaxyGroup = new THREE.Group() galaxyGroup.position.z = APP_CONFIG.galaxy.zPosition galaxyGroup.rotation.x = APP_CONFIG.galaxy.tiltX galaxyGroup.rotation.y = APP_CONFIG.galaxy.tiltY galaxyGroup.rotation.z = APP_CONFIG.galaxy.tiltZ scene.add(galaxyGroup) const galaxyGeometry = new THREE.PlaneGeometry(APP_CONFIG.galaxy.width, APP_CONFIG.galaxy.height, 1, 1) const galaxyLayers: THREE.Mesh[] = [] const galaxyMaterials: THREE.ShaderMaterial[] = [] const starsGeometry = new THREE.BufferGeometry() const starPositions = new Float32Array(STAR_COUNT * 3) const starColors = new Float32Array(STAR_COUNT * 3) const starProgress = new Float32Array(STAR_COUNT) const starSizes = new Float32Array(STAR_COUNT) const starAlpha = new Float32Array(STAR_COUNT) const starTail = new Float32Array(STAR_COUNT) const starDirections = new Float32Array(STAR_COUNT * 2) const starStates = new Array(STAR_COUNT) const starColor = new THREE.Color() const smokeGeometry = new THREE.PlaneGeometry(1, 1, 1, 1) const smokeTexture = createSmokeTexture() const smokeStates: SmokeState[] = [] const pointerTarget = { x: 0, y: 0 } const pointerCurrent = { x: 0, y: 0 } let started = false let targetSpeed: number = APP_CONFIG.animation.defaultSpeed let currentSpeed = 0 let accumulatedWobbleTime = 0 let animationFrameId = 0 let disposed = false const starMaterial = new THREE.ShaderMaterial({ vertexShader: STAR_VERTEX_SHADER, fragmentShader: STAR_FRAGMENT_SHADER, transparent: true, depthWrite: false, vertexColors: true, blending: THREE.AdditiveBlending, uniforms: { uSpeed: { value: 1.0 }, }, }) function spawnStar(index: number, seededProgress?: number) { const tier = pickTier() const source = sampleDisk(STAR_SOURCE_RADIUS) const direction = normalizeDirection( source.x + randomRange(-1.3, 1.3), source.y + randomRange(-1.3, 1.3), ) const progress = seededProgress ?? 0 const tierSpeed = [18, 26, 36][tier] const tierDrift = [3.2, 5.2, 8.2][tier] const tierScale = [0.95, 1.25, 1.65][tier] const z = lerp(STAR_FAR_Z, STAR_RESET_Z - 3, progress) const radialTravel = Math.pow(progress, APP_CONFIG.starfield.progressExponent) * (18 + tier * 10) const state: StarState = { x: source.x + direction.x * radialTravel + randomRange(-0.6, 0.6) * progress, y: source.y + direction.y * radialTravel + randomRange(-0.6, 0.6) * progress, z, vx: direction.x * tierDrift * (1 + progress * 0.7), vy: direction.y * tierDrift * (1 + progress * 0.7), vz: tierSpeed * randomRange(0.9, 1.16) * (1 + progress * 0.5), ax: direction.x * randomRange(1.4, 3.6), ay: direction.y * randomRange(1.4, 3.6), az: randomRange(5.5, 10.5), delay: seededProgress === undefined ? randomRange(0.03, 0.42) : randomRange(0, 0.12), baseSize: randomRange(APP_CONFIG.starfield.baseSizeMin, APP_CONFIG.starfield.baseSizeMax) * tierScale, baseAlpha: randomRange(APP_CONFIG.starfield.baseAlphaMin, APP_CONFIG.starfield.baseAlphaMax), } starStates[index] = state starSizes[index] = state.baseSize starAlpha[index] = state.baseAlpha starTail[index] = 0 starDirections[index * 2] = direction.x starDirections[index * 2 + 1] = direction.y if (Math.random() < 0.9) { starColor.set('#ffffff') } else { starColor.set(STAR_COLORS[Math.floor(Math.random() * STAR_COLORS.length)]) } starColors[index * 3] = starColor.r starColors[index * 3 + 1] = starColor.g starColors[index * 3 + 2] = starColor.b starPositions[index * 3] = state.x starPositions[index * 3 + 1] = state.y starPositions[index * 3 + 2] = state.z starProgress[index] = seededProgress ?? 0 } function spawnSmoke(mesh?: SmokeState['mesh']) { const source = sampleDisk(SMOKE_SOURCE_RADIUS) const direction = normalizeDirection( source.x + randomRange(-2.2, 2.2), source.y + randomRange(-2.2, 2.2), ) const scaleBase = randomRange(5.8, 14.5) const material = mesh ? mesh.material : new THREE.MeshBasicMaterial({ map: smokeTexture, transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, }) material.color.set(SMOKE_COLORS[Math.floor(Math.random() * SMOKE_COLORS.length)]) const smokeMesh = mesh ?? new THREE.Mesh(smokeGeometry, material) const state: SmokeState = { mesh: smokeMesh, x: source.x, y: source.y, z: randomRange(SMOKE_FAR_Z, SMOKE_FAR_Z + 16), age: 0, vx: direction.x * randomRange(1.8, 4.6), vy: direction.y * randomRange(1.8, 4.6), vz: randomRange(9.5, 17.5), rotation: randomRange(0, Math.PI * 2), spin: randomRange(-0.16, 0.16), baseScaleX: scaleBase * randomRange(0.8, 1.2), baseScaleY: scaleBase * randomRange(0.75, 1.25), opacity: randomRange(0.05, 0.13), wobble: randomRange(0.5, 1.2), wobbleSpeed: randomRange(0.35, 0.9), } smokeMesh.position.set(state.x, state.y, state.z) smokeMesh.scale.set(state.baseScaleX, state.baseScaleY, 1) smokeMesh.material.opacity = 0 return state } for (let index = 0; index < STAR_COUNT; index += 1) { spawnStar(index, Math.pow(Math.random(), APP_CONFIG.starfield.seedProgressExponent)) } starsGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)) starsGeometry.setAttribute('color', new THREE.BufferAttribute(starColors, 3)) starsGeometry.setAttribute('aProgress', new THREE.BufferAttribute(starProgress, 1)) starsGeometry.setAttribute('aSizeBase', new THREE.BufferAttribute(starSizes, 1)) starsGeometry.setAttribute('aAlphaBase', new THREE.BufferAttribute(starAlpha, 1)) starsGeometry.setAttribute('aTail', new THREE.BufferAttribute(starTail, 1)) starsGeometry.setAttribute('aDir', new THREE.BufferAttribute(starDirections, 2)) const stars = new THREE.Points(starsGeometry, starMaterial) travelRoot.add(stars) for (let index = 0; index < SMOKE_COUNT; index += 1) { const state = spawnSmoke() smokeStates.push(state) travelRoot.add(state.mesh) } APP_CONFIG.galaxy.layers.forEach((layerConfig, index) => { const galaxyMaterial = new THREE.ShaderMaterial({ vertexShader: GALAXY_VERTEX_SHADER, fragmentShader: GALAXY_FRAGMENT_SHADER, transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: getGalaxyBlending(layerConfig.blendMode), uniforms: { uTime: { value: 0 }, uOpacity: { value: layerConfig.opacity }, uArms: { value: layerConfig.armCount }, uTwist: { value: layerConfig.twist }, uArmSharpness: { value: layerConfig.armSharpness }, uNoiseScale: { value: layerConfig.noiseScale }, uNoiseStrength: { value: layerConfig.noiseStrength }, uCoreRadius: { value: layerConfig.coreRadius }, uCoreGlow: { value: layerConfig.coreGlow }, uGlowStrength: { value: layerConfig.glowStrength }, uSeed: { value: index * 0.173 + 0.31 }, uCoreWhite: { value: new THREE.Color(APP_CONFIG.galaxy.palette.coreWhite) }, uCoreGold: { value: new THREE.Color(APP_CONFIG.galaxy.palette.coreGold) }, uWarmOrange: { value: new THREE.Color(APP_CONFIG.galaxy.palette.warmOrange) }, uWarmMagenta: { value: new THREE.Color(APP_CONFIG.galaxy.palette.warmMagenta) }, uOuterBlue: { value: new THREE.Color(APP_CONFIG.galaxy.palette.outerBlue) }, uDeepBlue: { value: new THREE.Color(APP_CONFIG.galaxy.palette.deepBlue) }, uInnerColor: { value: new THREE.Color(layerConfig.innerColor) }, uOuterColor: { value: new THREE.Color(layerConfig.outerColor) }, uDustColor: { value: new THREE.Color(layerConfig.dustColor) }, }, }) const galaxyMesh = new THREE.Mesh(galaxyGeometry, galaxyMaterial) galaxyMesh.scale.setScalar(layerConfig.scale) galaxyMesh.position.z = layerConfig.zOffset galaxyMesh.rotation.z = index * 0.8 galaxyGroup.add(galaxyMesh) galaxyLayers.push(galaxyMesh) galaxyMaterials.push(galaxyMaterial) }) const clock = new THREE.Clock() const onResize = () => { const width = container.clientWidth || window.innerWidth const height = container.clientHeight || window.innerHeight camera.aspect = width / height camera.updateProjectionMatrix() renderer.setPixelRatio(Math.min(window.devicePixelRatio, APP_CONFIG.scene.maxPixelRatio)) renderer.setSize(width, height) } function updateStars(delta: number) { const positionAttribute = starsGeometry.getAttribute('position') as THREE.BufferAttribute const progressAttribute = starsGeometry.getAttribute('aProgress') as THREE.BufferAttribute const tailAttribute = starsGeometry.getAttribute('aTail') as THREE.BufferAttribute const directionAttribute = starsGeometry.getAttribute('aDir') as THREE.BufferAttribute for (let index = 0; index < STAR_COUNT; index += 1) { const state = starStates[index] if (state.delay > 0) { state.delay = Math.max(0, state.delay - delta) progressAttribute.array[index] = 0 continue } const progress = clamp((state.z - STAR_FAR_Z) / (STAR_RESET_Z - STAR_FAR_Z), 0, 1) const edgeRatio = clamp( Math.max(Math.abs(state.x) / STAR_BOUNDS_X, Math.abs(state.y) / STAR_BOUNDS_Y), 0, 1.2, ) const nearBoost = 1 + Math.pow(progress, 2.2) * APP_CONFIG.starfield.nearFieldBoost const edgeBoost = 1 + Math.pow(edgeRatio, 3.1) * APP_CONFIG.starfield.edgeBoost const burstBoost = 1 + Math.pow(edgeRatio, 4.2) * APP_CONFIG.starfield.edgeBurst state.vx += state.ax * delta * APP_CONFIG.starfield.accelerationXY * nearBoost * edgeBoost state.vy += state.ay * delta * APP_CONFIG.starfield.accelerationXY * nearBoost * edgeBoost state.vz += state.az * delta * APP_CONFIG.starfield.accelerationZ * nearBoost * edgeBoost state.x += state.vx * delta * burstBoost state.y += state.vy * delta * burstBoost state.z += state.vz * delta * (1 + (burstBoost - 1) * 0.45) const nextProgress = clamp((state.z - STAR_FAR_Z) / (STAR_RESET_Z - STAR_FAR_Z), 0, 1) const nextEdgeRatio = clamp( Math.max(Math.abs(state.x) / STAR_BOUNDS_X, Math.abs(state.y) / STAR_BOUNDS_Y), 0, 1, ) const progressTrail = clamp( (nextProgress - APP_CONFIG.starfield.trailStartProgress) / (1 - APP_CONFIG.starfield.trailStartProgress), 0, 1, ) const edgeTrail = Math.pow(nextEdgeRatio, 2.4) const direction = normalizeDirection(state.vx, state.vy) const tailStrength = clamp( Math.max(progressTrail * 0.75, edgeTrail) * APP_CONFIG.starfield.trailStrength, 0, 1, ) const offset = index * 3 const directionOffset = index * 2 if ( state.z > STAR_RESET_Z || Math.abs(state.x) > STAR_BOUNDS_X || Math.abs(state.y) > STAR_BOUNDS_Y ) { spawnStar(index) continue } positionAttribute.array[offset] = state.x positionAttribute.array[offset + 1] = state.y positionAttribute.array[offset + 2] = state.z progressAttribute.array[index] = nextProgress tailAttribute.array[index] = tailStrength directionAttribute.array[directionOffset] = direction.x directionAttribute.array[directionOffset + 1] = direction.y } positionAttribute.needsUpdate = true progressAttribute.needsUpdate = true tailAttribute.needsUpdate = true directionAttribute.needsUpdate = true } function updateSmoke(delta: number, elapsed: number) { for (const state of smokeStates) { state.age += delta state.x += state.vx * delta state.y += state.vy * delta state.z += state.vz * delta state.rotation += state.spin * delta if ( state.z > SMOKE_RESET_Z || Math.abs(state.x) > SMOKE_BOUNDS_X || Math.abs(state.y) > SMOKE_BOUNDS_Y ) { const resetState = spawnSmoke(state.mesh) Object.assign(state, resetState) } const progress = clamp((state.z - SMOKE_FAR_Z) / (SMOKE_RESET_Z - SMOKE_FAR_Z), 0, 1) const depthFadeIn = smoothstep(0.02, 0.28, progress) const ageFadeIn = smoothstep(0, 0.55, state.age) const fadeIn = depthFadeIn * ageFadeIn const fadeOut = 1 - smoothstep(0.68, 1, progress) const pulse = 1 + Math.sin(elapsed * state.wobbleSpeed + state.rotation) * 0.12 * state.wobble const scale = lerp(0.56, 1.9, progress) * pulse state.mesh.position.set(state.x, state.y, state.z) state.mesh.lookAt(camera.position) state.mesh.rotateZ(state.rotation) state.mesh.scale.set(state.baseScaleX * scale, state.baseScaleY * scale, 1) state.mesh.material.opacity = state.opacity * fadeIn * fadeOut } } function render() { if (disposed) { return } const deltaSeconds = Math.min(clock.getDelta(), 0.033) const elapsed = clock.elapsedTime pointerCurrent.x = lerp(pointerCurrent.x, pointerTarget.x, APP_CONFIG.interaction.pointerSmoothing) pointerCurrent.y = lerp(pointerCurrent.y, pointerTarget.y, APP_CONFIG.interaction.pointerSmoothing) const speedGoal = started ? targetSpeed : 0 currentSpeed = lerp(currentSpeed, speedGoal, 0.06) const motionDelta = deltaSeconds * currentSpeed updateStars(motionDelta) updateSmoke(motionDelta * 0.62, elapsed) starMaterial.uniforms.uSpeed.value = currentSpeed galaxyLayers.forEach((galaxyLayer, index) => { galaxyLayer.rotation.z -= APP_CONFIG.galaxy.layers[index].rotationSpeed * currentSpeed }) galaxyMaterials.forEach((material, index) => { material.uniforms.uTime.value = elapsed + index * 0.37 }) // 自动视角随机摆动,摆动速度跟随当前速度 (currentSpeed) 动态变化 // 修复抖动:通过 deltaSeconds 累加时间,而不是直接用 elapsed 乘以可变的速度,避免相位突变 accumulatedWobbleTime += deltaSeconds * (0.8 + currentSpeed * 2.0) // 利用多个不同频率的三角函数叠加,生成平滑且看似随机的摆动 const wobbleX = (Math.sin(accumulatedWobbleTime * 0.8) + Math.sin(accumulatedWobbleTime * 1.7 + 2.1) + Math.sin(accumulatedWobbleTime * 0.5 + 4.3)) / 3 const wobbleY = (Math.cos(accumulatedWobbleTime * 0.9) + Math.cos(accumulatedWobbleTime * 1.5 + 1.2) + Math.cos(accumulatedWobbleTime * 0.6 + 3.5)) / 3 // 基础摆动幅度 + 随速度增加的额外幅度 (显著增加摆动幅度) const wobbleAmpX = 0.8 + currentSpeed * 0.8 const wobbleAmpY = 0.5 + currentSpeed * 0.5 // 综合鼠标位置与自动摆动 const viewX = pointerCurrent.x + wobbleX * wobbleAmpX const viewY = pointerCurrent.y + wobbleY * wobbleAmpY // 较大幅度地影响场景根节点,增加整体动感 travelRoot.rotation.z = viewX * 0.06 travelRoot.position.x = -viewX * 1.8 travelRoot.position.y = -viewY * 1.0 // 加大相机的偏移幅度和观察点偏移 // 增加一点默认偏移,让相机的中心略微向左上,从而使画面整体偏向右下,填补空旷 const baseLookX = -3.0 const baseLookY = 5.0 camera.position.x = viewX * APP_CONFIG.interaction.maxOffset * 4.5 camera.position.y = viewY * APP_CONFIG.interaction.maxOffset * 3.0 camera.lookAt(baseLookX + viewX * 2.5, baseLookY + viewY * 1.5, -30) renderer.render(scene, camera) animationFrameId = window.requestAnimationFrame(render) } window.addEventListener('resize', onResize) render() return { setStarted(nextStarted) { started = nextStarted }, setSpeed(nextSpeed) { targetSpeed = clamp(nextSpeed, APP_CONFIG.animation.speedMin, APP_CONFIG.animation.speedMax) }, setPointerTarget(pointer) { pointerTarget.x = clamp(pointer.x, -1, 1) pointerTarget.y = clamp(pointer.y, -1, 1) }, dispose() { if (disposed) { return } disposed = true window.cancelAnimationFrame(animationFrameId) window.removeEventListener('resize', onResize) for (const smokeState of smokeStates) { smokeState.mesh.material.dispose() travelRoot.remove(smokeState.mesh) } galaxyMaterials.forEach((material) => material.dispose()) galaxyGeometry.dispose() starsGeometry.dispose() starMaterial.dispose() smokeGeometry.dispose() smokeTexture.dispose() renderer.dispose() scene.clear() if (renderer.domElement.parentElement === container) { container.removeChild(renderer.domElement) } }, } } export type { PointerTarget, TravelSceneController }