初版成品

This commit is contained in:
2026-05-26 01:22:30 +08:00
parent f56f8d9191
commit f73d069fe0
4 changed files with 632 additions and 171 deletions
+121 -27
View File
@@ -1,7 +1,7 @@
export const APP_CONFIG = {
// Rendering and canvas constraints.
scene: {
background: '#030712',
background: '#000000',
maxPixelRatio: 1.75,
},
// Default perspective camera setup.
@@ -13,13 +13,13 @@ export const APP_CONFIG = {
},
// Shared motion timing and presets for the travel sequence.
animation: {
speedMin: 0.1,
speedMin: 0.05,
speedMax: 3,
defaultSpeed: 1,
defaultSpeed: 0.55,
speedPresets: {
slow: 0.55,
steady: 1,
deep: 1.65,
slow: 0.2,
steady: 0.55,
deep: 1.0,
},
},
// Starfield behavior tuned for center-area spawning and parallax depth.
@@ -35,20 +35,20 @@ export const APP_CONFIG = {
seedProgressExponent: 0.72,
baseSizeMin: 1.3,
baseSizeMax: 3.6,
baseAlphaMin: 0.62,
baseAlphaMax: 1,
baseAlphaMin: 2.0,
baseAlphaMax: 2.5,
pointScale: 52,
pointMinSize: 2.8,
pointMaxSize: 76,
accelerationXY: 0.22,
accelerationZ: 0.1,
nearFieldBoost: 1.8,
edgeBoost: 7.2,
edgeBurst: 3.4,
trailStartProgress: 0.58,
trailStrength: 1,
trailLength: 0.38,
trailWidth: 0.16,
nearFieldBoost: 2.25,
edgeBoost: 8.8,
edgeBurst: 4.4,
trailStartProgress: 0.38,
trailStrength: 1.8,
trailLength: 0.85,
trailWidth: 0.12,
},
// Smoke layers create volumetric light fog passing through the camera.
smoke: {
@@ -64,19 +64,113 @@ export const APP_CONFIG = {
pointerSmoothing: 0.08,
maxOffset: 0.4,
},
// Galaxy parameters for background spiral galaxy layer.
// Background galaxy rendered as layered shader fog instead of dense point clouds.
galaxy: {
count: 50000,
radius: 15,
branches: 20,
spin: 0.3,
randomness: 0.15,
randomnessPower: 6,
size: 0.008,
rotationSpeed: 0.001,
zPosition: -35,
insideColor: '#ff6030',
outsideColor: '#3861cbff',
zPosition: -40,
width: 72,
height: 48,
tiltX: Math.PI * 0.35,
tiltY: Math.PI * 0.15,
palette: {
coreWhite: '#ffffff',
coreGold: '#ffd36b',
warmOrange: '#ff9850',
warmMagenta: '#7b4a86',
outerBlue: '#38d9ff',
deepBlue: '#103f97',
},
layers: [
{
scale: 1.05,
opacity: 0.42,
blendMode: 'normal',
rotationSpeed: 0.0024,
armCount: 2,
twist: 12,
armSharpness: 0.6,
noiseScale: 2.7,
noiseStrength: 0.54,
coreRadius: 0.2,
coreGlow: 3.8,
glowStrength: 1.06,
zOffset: 0,
innerColor: '#fff5de',
outerColor: '#63d8ff',
dustColor: '#1e3e78',
},
{
scale: 1.25,
opacity: 0.38,
blendMode: 'normal',
rotationSpeed: 0.0016,
armCount: 3,
twist: 10.4,
armSharpness: 0.52,
noiseScale: 2.1,
noiseStrength: 0.68,
coreRadius: 0.24,
coreGlow: 3.5,
glowStrength: 0.82,
zOffset: -0.4,
innerColor: '#ffd8c2',
outerColor: '#6c89ff',
dustColor: '#45206b',
},
{
scale: 1.45,
opacity: 0.52,
blendMode: 'additive',
rotationSpeed: 0.0009,
armCount: 4,
twist: 9.2,
armSharpness: 0.58,
noiseScale: 1.55,
noiseStrength: 0.76,
coreRadius: 0.28,
coreGlow: 2.8,
glowStrength: 1.08,
zOffset: -0.8,
innerColor: '#d8f4ff',
outerColor: '#25c7ef',
dustColor: '#0c2558',
},
{
scale: 1.75,
opacity: 0.25,
blendMode: 'normal',
rotationSpeed: 0.00036,
armCount: 2,
twist: 6.8,
armSharpness: 0.28,
noiseScale: 1.05,
noiseStrength: 0.62,
coreRadius: 0.32,
coreGlow: 3.8,
glowStrength: 0.34,
zOffset: -1.3,
innerColor: '#b7e9ff',
outerColor: '#4ed5ef',
dustColor: '#10356b',
},
{
scale: 2.1,
opacity: 0.22,
blendMode: 'normal',
rotationSpeed: 0.00018,
armCount: 2,
twist: 4.2,
armSharpness: 0.16,
noiseScale: 0.78,
noiseStrength: 0.56,
coreRadius: 0.42,
coreGlow: 3.9,
glowStrength: 0.2,
zOffset: -1.9,
innerColor: '#8fddff',
outerColor: '#53bfd8',
dustColor: '#143f73',
},
],
},
// Shared UI timings used by overlays and toolbars.
ui: {
+58 -50
View File
@@ -297,62 +297,70 @@ onBeforeUnmount(() => {
<section class="hud-layer" :class="{ 'hud-layer--active': started }">
<transition name="controls-fade">
<div
v-if="started && controlsVisible"
class="glass-toolbar"
@pointerdown.stop="markActivity"
@pointermove.stop="markActivity"
@touchstart.stop.passive="markActivity"
>
<div class="toolbar-group">
<span class="toolbar-label">速度</span>
<div class="segment-control" role="group" aria-label="旅行速度">
<div v-if="started && controlsVisible" class="hud-bottom-container">
<div
class="glass-toolbar"
@pointerdown.stop="markActivity"
@pointermove.stop="markActivity"
@touchstart.stop.passive="markActivity"
>
<div class="toolbar-group">
<span class="toolbar-label">速度</span>
<div class="segment-control" role="group" aria-label="旅行速度">
<button
v-for="option in speedOptions"
:key="option.value"
class="segment-button"
:class="{ 'segment-button--active': option.value === speedPreset }"
type="button"
@click="handleSpeedChange(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<label class="toolbar-group toolbar-group--stretch">
<span class="toolbar-label">背景音乐</span>
<select class="glass-select" :value="currentTrack" @change="handleTrackChange">
<option v-for="trackName in trackOptions" :key="trackName" :value="trackName">
{{ trackName }}
</option>
</select>
</label>
<label class="toolbar-group toolbar-group--stretch">
<span class="toolbar-label">音量</span>
<div class="range-wrapper" :style="{ '--volume-percent': `${volume * 100}%` }">
<input
class="glass-range"
type="range"
min="0"
max="1"
step="0.01"
:value="volume"
@input="handleVolumeChange"
/>
<span class="range-value">{{ volumePercent }}</span>
</div>
</label>
<div class="toolbar-group toolbar-group--compact">
<span class="toolbar-label">显示</span>
<button
v-for="option in speedOptions"
:key="option.value"
class="segment-button"
:class="{ 'segment-button--active': option.value === speedPreset }"
class="toolbar-action"
:class="{ 'toolbar-action--active': isFullscreen }"
type="button"
@click="handleSpeedChange(option.value)"
:disabled="!fullscreenAvailable"
@click="handleFullscreenToggle"
>
{{ option.label }}
{{ fullscreenButtonLabel }}
</button>
</div>
</div>
<label class="toolbar-group toolbar-group--stretch">
<span class="toolbar-label">BGM</span>
<select class="glass-select" :value="currentTrack" @change="handleTrackChange">
<option v-for="trackName in trackOptions" :key="trackName" :value="trackName">
{{ trackName }}
</option>
</select>
</label>
<label class="toolbar-group toolbar-group--stretch">
<span class="toolbar-label">音量 {{ volumePercent }}</span>
<input
class="glass-range"
type="range"
min="0"
max="1"
step="0.01"
:value="volume"
@input="handleVolumeChange"
/>
</label>
<div class="toolbar-group toolbar-group--compact">
<span class="toolbar-label">显示</span>
<button
class="toolbar-action"
:class="{ 'toolbar-action--active': isFullscreen }"
type="button"
:disabled="!fullscreenAvailable"
@click="handleFullscreenToggle"
>
{{ fullscreenButtonLabel }}
</button>
<div class="footer-credit">
Created by <a href="https://airnan.cn/" target="_blank" rel="noopener noreferrer">@码农阿楠</a> with AI.
</div>
</div>
</transition>
+147 -18
View File
@@ -13,7 +13,7 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg-primary: #030712;
--bg-primary: #000000;
--bg-secondary: rgba(7, 15, 30, 0.72);
--border-soft: rgba(148, 163, 184, 0.24);
--text-primary: #e2e8f0;
@@ -37,9 +37,7 @@ body,
body {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 32%),
linear-gradient(180deg, #020617 0%, #030712 45%, #02030a 100%);
background: #000000;
color: var(--text-primary);
}
@@ -63,7 +61,7 @@ canvas {
position: relative;
min-height: 100vh;
overflow: hidden;
background: radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 34%);
background: #000000;
}
.scene-layer {
@@ -80,8 +78,7 @@ canvas {
position: absolute;
inset: 0;
background:
radial-gradient(circle at center, transparent 0%, rgba(2, 6, 23, 0.08) 48%, rgba(2, 6, 23, 0.54) 100%),
linear-gradient(180deg, rgba(2, 6, 23, 0.16) 0%, rgba(2, 6, 23, 0.48) 100%);
radial-gradient(circle at center, transparent 30%, rgba(0, 0, 0, 0.4) 70%, rgba(0, 0, 0, 0.9) 100%);
pointer-events: none;
z-index: 1;
}
@@ -182,14 +179,23 @@ canvas {
pointer-events: none;
}
.glass-toolbar {
.hud-bottom-container {
position: absolute;
left: 50%;
bottom: 28px;
width: min(860px, calc(100% - 24px));
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.glass-toolbar {
display: flex;
align-items: center;
gap: 18px;
width: min(860px, calc(100% - 24px));
width: 100%;
padding: 18px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 28px;
@@ -198,7 +204,23 @@ canvas {
backdrop-filter: blur(20px);
box-shadow: 0 18px 56px rgba(2, 6, 23, 0.48);
pointer-events: auto;
transform: translateX(-50%);
}
.footer-credit {
font-size: 12px;
color: rgba(226, 232, 240, 0.5);
pointer-events: auto;
}
.footer-credit a {
color: rgba(226, 232, 240, 0.8);
text-decoration: none;
font-weight: 500;
transition: text-decoration 160ms ease;
}
.footer-credit a:hover {
text-decoration: underline;
}
.toolbar-group {
@@ -226,10 +248,20 @@ canvas {
.segment-control {
display: inline-flex;
align-items: center;
height: 46px;
padding: 4px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 999px;
background: rgba(2, 6, 23, 0.34);
transition:
border-color 160ms ease,
background 160ms ease;
}
.segment-control:hover {
border-color: rgba(125, 211, 252, 0.32);
background: rgba(15, 23, 42, 0.56);
}
.segment-button {
@@ -238,7 +270,11 @@ canvas {
background: transparent;
color: rgba(226, 232, 240, 0.78);
cursor: pointer;
padding: 10px 16px;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
transition:
background 160ms ease,
color 160ms ease;
@@ -250,13 +286,17 @@ canvas {
}
.toolbar-action {
height: 46px;
min-height: 46px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
border-radius: 999px;
background: rgba(2, 6, 23, 0.36);
color: var(--text-primary);
cursor: pointer;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
border-color 160ms ease,
background 160ms ease,
@@ -288,17 +328,102 @@ canvas {
.glass-select {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
background: rgba(2, 6, 23, 0.36);
border-radius: 999px;
background-color: rgba(2, 6, 23, 0.36);
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(226, 232, 240, 0.82)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 16px;
-webkit-appearance: none;
appearance: none;
color: var(--text-primary);
height: 46px;
min-height: 46px;
outline: none;
padding: 0 14px;
padding: 0 36px 0 16px;
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.glass-select:hover {
border-color: rgba(125, 211, 252, 0.32);
background-color: rgba(15, 23, 42, 0.56);
}
.range-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 46px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
background: linear-gradient(to right, rgba(56, 189, 248, 0.16) var(--volume-percent, 0%), rgba(2, 6, 23, 0.36) var(--volume-percent, 0%));
background-clip: padding-box;
transition: border-color 160ms ease;
overflow: hidden;
}
.range-wrapper:hover {
border-color: rgba(125, 211, 252, 0.32);
}
.range-value {
position: absolute;
pointer-events: none;
font-size: 14px;
color: rgba(226, 232, 240, 0.9);
font-weight: 500;
z-index: 1;
}
.glass-range {
accent-color: var(--accent);
-webkit-appearance: none;
appearance: none;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
background: transparent;
outline: none;
cursor: pointer;
margin: 0;
padding: 0;
}
.glass-range::-webkit-slider-runnable-track {
width: 100%;
height: 100%;
background: transparent;
border: none;
}
.glass-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 0;
height: 0;
opacity: 0;
border: none;
background: transparent;
}
.glass-range::-moz-range-track {
width: 100%;
height: 100%;
background: transparent;
border: none;
}
.glass-range::-moz-range-thumb {
width: 0;
height: 0;
opacity: 0;
border: none;
background: transparent;
}
.overlay-fade-enter-active,
@@ -340,12 +465,16 @@ canvas {
font-size: 15px;
}
.glass-toolbar {
.hud-bottom-container {
bottom: 16px;
width: min(520px, calc(100% - 16px));
gap: 12px;
}
.glass-toolbar {
flex-direction: column;
align-items: stretch;
gap: 14px;
width: min(520px, calc(100% - 16px));
border-radius: 24px;
padding: 20px;
}
+306 -76
View File
@@ -61,7 +61,13 @@ 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 = ['#ffffff', '#f3f8ff', '#e8f4ff', '#d7eeff', '#fff7ea'] as const
const STAR_COLORS = [
'#00e5ff', // 亮青
'#ff00ea', // 亮粉
'#ffcc00', // 亮金
'#00ff66', // 亮绿
'#ff3333', // 亮红
] as const
const SMOKE_COLORS = ['#8dd8ff', '#adc4ff', '#d6b5ff', '#ffe7ff'] as const
const STAR_VERTEX_SHADER = `
@@ -78,11 +84,13 @@ 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);
// 放大精灵尺寸以容纳更长的拖尾
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)}, ${APP_CONFIG.starfield.pointMaxSize.toFixed(1)});
gl_PointSize = clamp(pointSize, ${APP_CONFIG.starfield.pointMinSize.toFixed(1)} * 2.5, ${APP_CONFIG.starfield.pointMaxSize.toFixed(1)} * 2.5);
vColor = color;
vAlpha = aAlphaBase * (0.32 + scaledProgress * 0.68);
// 取消原本透明度的距离衰减,直接用最高的 baseAlpha 甚至更高,保持全局最亮
vAlpha = aAlphaBase * 1.5;
vTail = aTail;
vDir = aDir;
}
@@ -93,27 +101,246 @@ varying vec3 vColor;
varying float vAlpha;
varying float vTail;
varying vec2 vDir;
uniform float uSpeed;
void main() {
vec2 centered = gl_PointCoord - vec2(0.5);
// 放大坐标系,这样中心点画得相对较小,留出极大空间画长尾巴
vec2 centered = (gl_PointCoord - vec2(0.5)) * 2.5;
float distanceToCenter = length(centered);
float core = 1.0 - smoothstep(0.0, 0.22, distanceToCenter);
float halo = 1.0 - smoothstep(0.12, 0.5, distanceToCenter);
vec2 dir = length(vDir) > 0.0001 ? normalize(vDir) : vec2(0.0, 1.0);
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 tailLong = 1.0 - smoothstep(-${(0.18 + APP_CONFIG.starfield.trailLength).toFixed(2)}, 0.05, along);
float tailThin = 1.0 - smoothstep(${(APP_CONFIG.starfield.trailWidth * 0.35).toFixed(2)}, ${(APP_CONFIG.starfield.trailWidth).toFixed(2)}, abs(across));
float tailFade = smoothstep(0.01, 0.52, -along);
float tail = tailLong * tailThin * tailFade * vTail;
float alpha = (core * 0.95 + halo * 0.45 + tail * 0.85) * vAlpha;
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;
}
gl_FragColor = vec4(vColor, alpha);
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));
}
`
@@ -134,6 +361,10 @@ 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
@@ -236,14 +467,13 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
const galaxyGroup = new THREE.Group()
galaxyGroup.position.z = APP_CONFIG.galaxy.zPosition
// Tilt the galaxy to give it a perspective view
galaxyGroup.rotation.x = Math.PI * 0.35
galaxyGroup.rotation.y = Math.PI * 0.15
galaxyGroup.rotation.x = APP_CONFIG.galaxy.tiltX
galaxyGroup.rotation.y = APP_CONFIG.galaxy.tiltY
scene.add(galaxyGroup)
// Create an inner group for the galaxy rotation to preserve the tilt
const galaxyInnerGroup = new THREE.Group()
galaxyGroup.add(galaxyInnerGroup)
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)
@@ -264,7 +494,7 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
const pointerCurrent = { x: 0, y: 0 }
let started = false
let targetSpeed = 1
let targetSpeed: number = APP_CONFIG.animation.defaultSpeed
let currentSpeed = 0
let animationFrameId = 0
let disposed = false
@@ -276,6 +506,9 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
depthWrite: false,
vertexColors: true,
blending: THREE.AdditiveBlending,
uniforms: {
uSpeed: { value: 1.0 },
},
})
function spawnStar(index: number, seededProgress?: number) {
@@ -314,7 +547,11 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
starDirections[index * 2] = direction.x
starDirections[index * 2 + 1] = direction.y
starColor.set(STAR_COLORS[Math.floor(Math.random() * STAR_COLORS.length)])
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
@@ -393,61 +630,47 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
travelRoot.add(state.mesh)
}
const galaxyGeometry = new THREE.BufferGeometry()
const galaxyPositions = new Float32Array(APP_CONFIG.galaxy.count * 3)
const galaxyColors = new Float32Array(APP_CONFIG.galaxy.count * 3)
const colorInside = new THREE.Color(APP_CONFIG.galaxy.insideColor)
const colorOutside = new THREE.Color(APP_CONFIG.galaxy.outsideColor)
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) },
},
})
for (let i = 0; i < APP_CONFIG.galaxy.count; i += 1) {
const i3 = i * 3
const radius = Math.random() * APP_CONFIG.galaxy.radius
const branchesAngle =
((i % APP_CONFIG.galaxy.branches) / APP_CONFIG.galaxy.branches) * Math.PI * 2
const spinAngle = radius * APP_CONFIG.galaxy.spin
const mixedColor = colorInside.clone()
mixedColor.lerp(colorOutside, radius / APP_CONFIG.galaxy.radius)
const randomX =
Math.random() ** APP_CONFIG.galaxy.randomnessPower *
(Math.random() < 0.5 ? 1 : -1) *
APP_CONFIG.galaxy.randomness *
radius
const randomY =
Math.random() ** APP_CONFIG.galaxy.randomnessPower *
(Math.random() < 0.5 ? 1 : -1) *
APP_CONFIG.galaxy.randomness *
radius
const randomZ =
Math.random() ** APP_CONFIG.galaxy.randomnessPower *
(Math.random() < 0.5 ? 1 : -1) *
APP_CONFIG.galaxy.randomness *
radius
galaxyPositions[i3] = Math.cos(branchesAngle + spinAngle) * radius + randomX
galaxyPositions[i3 + 1] = Math.sin(branchesAngle + spinAngle) * radius + randomY
galaxyPositions[i3 + 2] = randomZ
galaxyColors[i3] = mixedColor.r
galaxyColors[i3 + 1] = mixedColor.g
galaxyColors[i3 + 2] = mixedColor.b
}
galaxyGeometry.setAttribute('position', new THREE.BufferAttribute(galaxyPositions, 3))
galaxyGeometry.setAttribute('color', new THREE.BufferAttribute(galaxyColors, 3))
const galaxyMaterial = new THREE.PointsMaterial({
size: APP_CONFIG.galaxy.size,
sizeAttenuation: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true,
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 galaxyPoints = new THREE.Points(galaxyGeometry, galaxyMaterial)
galaxyInnerGroup.add(galaxyPoints)
const clock = new THREE.Clock()
const onResize = () => {
@@ -588,8 +811,15 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
const motionDelta = deltaSeconds * currentSpeed
updateStars(motionDelta)
updateSmoke(motionDelta * 0.62, elapsed)
starMaterial.uniforms.uSpeed.value = currentSpeed
galaxyInnerGroup.rotation.z += APP_CONFIG.galaxy.rotationSpeed * 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
})
travelRoot.rotation.z = pointerCurrent.x * 0.03
travelRoot.position.x = -pointerCurrent.x * 0.8
@@ -631,8 +861,8 @@ export function createTravelScene(container: HTMLElement): TravelSceneController
travelRoot.remove(smokeState.mesh)
}
galaxyMaterials.forEach((material) => material.dispose())
galaxyGeometry.dispose()
galaxyMaterial.dispose()
starsGeometry.dispose()
starMaterial.dispose()