Files
TimeTravel/src/utils/createTravelScene.ts
T
2026-05-26 02:24:10 +08:00

905 lines
33 KiB
TypeScript

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<THREE.PlaneGeometry, THREE.MeshBasicMaterial>
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<THREE.PlaneGeometry, THREE.ShaderMaterial>[] = []
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<StarState>(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<THREE.PlaneGeometry, THREE.MeshBasicMaterial>(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 }