# 银河 Milky Way > 抬头看银河带的星空背景;约 9 万颗星,近中远三层慢慢飘,偶尔划过一颗流星。用 three.js 做。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:科普、教育、天文、户外露营类的开场页或 PPT 封面。 - 不适合:温暖、生活化的页面;同一页已经有别的 WebGL 背景;不想多装一个依赖的项目(换星云)。 - 适合的网站:内容站、官网、活动页 - 放在:首屏、背景 - 手机上:好用:竖屏时视野变宽,银河带斜着穿过画面。 - 性能代价:中:9 万颗星加辉光,一般电脑 60 帧;three.js 大约增加 150 KB(压缩后)。 - 常搭配:[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md)、[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i three` - 文件:原样存成 `milky-way.tsx`(比如 `src/components/milky-way.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/milky-way.tsx),比手抄准:`curl -fsSL -o src/components/milky-way.tsx https://www.dachuiai.com/code/milky-way.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountMilkyWay`、`MilkyWay` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。依赖 three(npm i three),TypeScript 项目再装 npm i -D @types/three。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountMilkyWay(元素) 不依赖 React,删掉类型标注存成 .js 就能调用,返回的函数用来清理;那个元素要有大小(比如 position:absolute; inset:0)。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountMilkyWay** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **MilkyWay** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用 three.js 做一个银河背景,像站在地上抬头看:天空着色器画出银河带的光晕、暗色尘带和暖色核心;约 9 万颗星作为三维点云,向银河带聚集,大小和颜色随亮度变化;近中远三层星星以不同速度漂移,形成视差;偶尔划过一颗带点状拖尾的流星;UnrealBloomPass 加柔和辉光;镜头沿银河带缓慢转动,鼠标让视角轻微倾斜。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; import * as THREE from 'three'; import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js'; import {RenderPass} from 'three/addons/postprocessing/RenderPass.js'; import {UnrealBloomPass} from 'three/addons/postprocessing/UnrealBloomPass.js'; import {OutputPass} from 'three/addons/postprocessing/OutputPass.js'; /* --------------------------------------------------------------- * MilkyWay 银河 * 像站在地上抬头看到的那条银河带:天空着色器画出银河带的光晕、暗色尘带和暖色核心, * 约 9 万颗星作为三维点云向银河带聚集,大小和颜色随亮度变化;近、中、远三层星星以不同速度漂移,形成视差; * 偶尔划过一颗带点状拖尾的流星;UnrealBloomPass 加柔和辉光。镜头沿银河带缓慢转动,鼠标让视角轻轻倾斜。 * 用 three.js(MIT)实时画:npm i three。滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ const SKY_FS = `uniform vec3 uPole, uCore; varying vec3 vDir; float h(vec3 p){ p = fract(p*0.3183099+vec3(.11,.23,.37)); p *= 17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); } float n3(vec3 p){ vec3 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f); return mix(mix(mix(h(i),h(i+vec3(1,0,0)),f.x),mix(h(i+vec3(0,1,0)),h(i+vec3(1,1,0)),f.x),f.y), mix(mix(h(i+vec3(0,0,1)),h(i+vec3(1,0,1)),f.x),mix(h(i+vec3(0,1,1)),h(i+vec3(1,1,1)),f.x),f.y),f.z); } float fbm(vec3 p){ float v = 0.0, a = 0.5; for (int i = 0; i < 6; i++) { v += a*n3(p); p = p*2.03 + 4.1; a *= 0.5; } return v; } void main(){ vec3 d = normalize(vDir); float lat = dot(d, uPole); // −1 到 1,银河带上是 0 vec3 onPlane = normalize(d - uPole * lat); float toCore = dot(onPlane, uCore); // 正对核心是 1 float warp = (fbm(d * 3.0) - 0.5) * 0.12; float w = mix(0.11, 0.2, smoothstep(0.2, 1.0, toCore)); // 靠近核心,银河带更宽 float band = exp(-pow((lat + warp) / w, 2.0)); float core = exp(-pow((lat + warp) / (w * 0.7), 2.0)) * pow(max(toCore, 0.0), 3.0); // 星云团:许多暗星组成的细颗粒,大尺度上再聚成团 float clump = fbm(d * 7.0); float grain = n3(d * 420.0) * 0.5 + n3(d * 900.0) * 0.5; float haze = clump * (0.55 + 0.9 * grain * grain); // 尘埃带:集中在银河带中间的细暗丝(脊状噪声),加上大片的暗云 float ridge = 1.0 - abs(2.0 * fbm(d * 6.0 + 2.0) - 1.0); float fil = smoothstep(0.72, 0.95, ridge) * 0.8 + smoothstep(0.5, 0.75, fbm(d * 3.2 + 7.0)) * 0.6; float dust = clamp(fil, 0.0, 1.0) * exp(-pow((lat + warp * 0.6 - 0.01) / (w * 0.55), 2.0)); vec3 cool = vec3(0.55, 0.62, 0.78), warm = vec3(1.0, 0.76, 0.52); vec3 col = cool * band * 0.07 * haze + warm * core * 0.22 * haze + mix(cool, warm, core) * band * 0.02; col *= 1.0 - 0.9 * dust; col += vec3(0.75, 0.28, 0.32) * smoothstep(0.74, 0.86, fbm(d * 14.0 + 9.0)) * band * 0.06; // 淡淡的粉色星云 col += vec3(0.008, 0.011, 0.02); // 夜空底色 gl_FragColor = vec4(col, 1.0); #include #include }`; /* ---------------- 鼠标和循环 ---------------- */ // 鼠标在画面上时跟着走(带平滑),移出画面后慢慢回到中间。x 从左到右、y 从下到上,都是 0–1 function pointer(host: HTMLElement, smooth: number) { const p = {x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, moved: -1e9, update(dt: number) { const k = 1 - Math.exp(-smooth * dt); p.x += (p.tx - p.x) * k; p.y += (p.ty - p.y) * k; }, off() { removeEventListener('pointermove', on); }}; const on = (e: PointerEvent) => { const r = host.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height; const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1; p.tx = inside ? x : 0.5; p.ty = inside ? y : 0.5; if (inside) p.moved = performance.now(); }; addEventListener('pointermove', on); return p; } // 只在看得见的时候画:滚出屏幕、切到别的标签页、开了"减弱动态效果"都会停。停着时改了大小,就按 still 秒的画面重画一帧 function animate(host: HTMLElement, fit: () => void, frame: (t: number, dt: number) => void, still: number) { const reduce = matchMedia('(prefers-reduced-motion: reduce)'); let raf = 0, last = 0, time = 0, seen = false; const tick = (now: number) => { raf = requestAnimationFrame(tick); const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60; last = now; time += dt; frame(time, dt); }; const sync = () => { const run = seen && !document.hidden && !reduce.matches; if (run && !raf) { last = 0; raf = requestAnimationFrame(tick); } if (!run && raf) { cancelAnimationFrame(raf); raf = 0; } }; const redraw = () => { if (!raf) frame(reduce.matches ? still : time, 0); }; const ro = new ResizeObserver(() => { fit(); redraw(); }); ro.observe(host); const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; sync(); }); io.observe(host); const onMotion = () => { sync(); redraw(); }; document.addEventListener('visibilitychange', sync); reduce.addEventListener('change', onMotion); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', sync); reduce.removeEventListener('change', onMotion); }; } // 银河所在的平面:银河带沿着这个大圆走 const POLE = new THREE.Vector3(0.35, 0.82, 0.45).normalize(); const CORE = new THREE.Vector3().crossVectors(POLE, new THREE.Vector3(0, 0, 1)).normalize(); // 明亮核心的方向 const SIDE = new THREE.Vector3().crossVectors(POLE, CORE); /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ /** 在 host 里铺满银河背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountMilkyWay(host: HTMLElement): () => void { let renderer: THREE.WebGLRenderer; try { renderer = new THREE.WebGLRenderer({antialias: false, powerPreference: 'high-performance'}); } catch { return () => {}; } // 不支持 WebGL:留着 host 自己的底色 const DPR = Math.min(devicePixelRatio || 1, 1.5); renderer.setPixelRatio(DPR); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1; const canvas = renderer.domElement; canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 3000); const dispose: {dispose: () => void}[] = []; const keep = void}>(x: T) => { dispose.push(x); return x; }; // 天球:银河带的光晕、尘埃带、看不清的星雾,几片淡粉的星云 const sky = new THREE.Mesh(keep(new THREE.SphereGeometry(1500, 64, 32)), keep(new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, uniforms: {uPole: {value: POLE}, uCore: {value: CORE}}, vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', fragmentShader: SKY_FS, }))); scene.add(sky); // 三维星星:越靠近银河带越密,远近都有(镜头一挪就有视差)。固定种子,每次打开都一样 let seed = 11; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const gauss = () => { let u = 0, v = 0; while (!u) u = rnd(); while (!v) v = rnd(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }; const N = 90000, pos = new Float32Array(N * 3), col = new Float32Array(N * 3), size = new Float32Array(N); const c = new THREE.Color(); for (let i = 0; i < N; i++) { const inBand = rnd() < 0.72; const lon = rnd() * Math.PI * 2, lat = inBand ? gauss() * 0.13 : Math.asin(rnd() * 2 - 1); const dir = new THREE.Vector3().addScaledVector(CORE, Math.cos(lat) * Math.cos(lon)).addScaledVector(SIDE, Math.cos(lat) * Math.sin(lon)).addScaledVector(POLE, Math.sin(lat)); const r = 60 + Math.pow(rnd(), 0.6) * 700; pos.set([dir.x * r, dir.y * r, dir.z * r], i * 3); const t = rnd(); c.set(t < 0.15 ? '#ffc58f' : t < 0.45 ? '#fff1dc' : t < 0.85 ? '#dfe8ff' : '#a9c4ff'); const bright = Math.pow(rnd(), 9) * 3.5 + 0.25; col.set([c.r * bright, c.g * bright, c.b * bright], i * 3); size[i] = 0.7 + Math.pow(rnd(), 6) * 2.8; } const g = keep(new THREE.BufferGeometry()); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.setAttribute('size', new THREE.BufferAttribute(size, 1)); const uniforms = {uTime: {value: 0}, uScale: {value: 1}}; scene.add(new THREE.Points(g, keep(new THREE.ShaderMaterial({ uniforms, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, vertexShader: `attribute float size; uniform float uTime, uScale; varying vec3 vC; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; float tw = 0.8 + 0.2 * sin(uTime * (1.5 + fract(position.x * 0.137) * 3.0) + position.y); gl_PointSize = size * uScale * (1.0 + 120.0 / -mv.z); vC = color * tw; }`, fragmentShader: `varying vec3 vC; void main(){ vec2 q = gl_PointCoord - 0.5; float a = exp(-dot(q, q) * 22.0); if (a < 0.02) discard; gl_FragColor = vec4(vC * a, 1.0); }`, })))); // 近、中、远三层飘动的星星:在空间里漂得一样快,所以近的一层划过画面最快,远的几乎不动 const DRIFT = new THREE.Vector3().addScaledVector(SIDE, -1).addScaledVector(POLE, 0.25).normalize().multiplyScalar(5.5); const layers = [ {n: 260, box: 70, size: 3.2, bright: 1.25}, // 近:少、大、快 {n: 1400, box: 240, size: 1.9, bright: 0.9}, // 中 {n: 5000, box: 700, size: 1.2, bright: 0.65}, // 远:多、小、慢 ].map((L) => { const p = new Float32Array(L.n * 3), cl = new Float32Array(L.n * 3), sz = new Float32Array(L.n); for (let i = 0; i < L.n; i++) { p.set([(rnd() - 0.5) * L.box, (rnd() - 0.5) * L.box, (rnd() - 0.5) * L.box], i * 3); const t = rnd(); c.set(t < 0.2 ? '#ffd2a6' : t < 0.7 ? '#fff4e6' : '#d6e2ff'); const b = L.bright * (0.4 + Math.pow(rnd(), 3) * 1.2); cl.set([c.r * b, c.g * b, c.b * b], i * 3); sz[i] = L.size * (0.6 + rnd() * 0.8); } const geo = keep(new THREE.BufferGeometry()); geo.setAttribute('position', new THREE.BufferAttribute(p, 3)); geo.setAttribute('color', new THREE.BufferAttribute(cl, 3)); geo.setAttribute('size', new THREE.BufferAttribute(sz, 1)); const u = {uOffset: {value: new THREE.Vector3()}, uBox: {value: L.box}, uCam: {value: new THREE.Vector3()}, uScale: uniforms.uScale}; const pts = new THREE.Points(geo, keep(new THREE.ShaderMaterial({ uniforms: u, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, // 星星的位置在一个跟着镜头走的盒子里循环:一片没有尽头的星场 vertexShader: `attribute float size; uniform vec3 uOffset, uCam; uniform float uBox, uScale; varying vec3 vC; varying float vFade; void main(){ vec3 q = mod(position + uOffset - uCam + 0.5 * uBox, uBox) - 0.5 * uBox; vec3 w = uCam + q; vec4 mv = modelViewMatrix * vec4(w, 1.0); gl_Position = projectionMatrix * mv; float d = -mv.z; vFade = smoothstep(2.0, 8.0, d) * (1.0 - smoothstep(0.42, 0.5, max(max(abs(q.x), abs(q.y)), abs(q.z)) / uBox)); gl_PointSize = size * uScale * (1.0 + 60.0 / max(d, 1.0)); vC = color; }`, fragmentShader: `varying vec3 vC; varying float vFade; void main(){ vec2 q = gl_PointCoord - 0.5; float a = exp(-dot(q, q) * 18.0) * vFade; if (a < 0.01) discard; gl_FragColor = vec4(vC * a, 1.0); }`, }))); pts.frustumCulled = false; scene.add(pts); return u; }); // 流星:偶尔一颗,出现在三种距离之一(近的更长更亮);画成一串发光的点,头大而亮,尾巴渐淡 type Meteor = {start: THREE.Vector3; dir: THREE.Vector3; born: number; life: number; speed: number; len: number; bright: number; near: boolean}; const meteors: Meteor[] = []; const MP = 90, MMAX = 4; const meteorGeo = keep(new THREE.BufferGeometry()); meteorGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(MP * MMAX * 3), 3)); meteorGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(MP * MMAX * 3), 3)); meteorGeo.setAttribute('size', new THREE.BufferAttribute(new Float32Array(MP * MMAX), 1)); const meteorPts = new THREE.Points(meteorGeo, keep(new THREE.ShaderMaterial({ uniforms: {uScale: uniforms.uScale}, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, vertexShader: 'attribute float size; uniform float uScale; varying vec3 vC; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; gl_PointSize = size * uScale; vC = color; }', fragmentShader: 'varying vec3 vC; void main(){ vec2 q = gl_PointCoord - 0.5; float a = exp(-dot(q, q) * 16.0); if (a < 0.01) discard; gl_FragColor = vec4(vC * a, 1.0); }', }))); meteorPts.frustumCulled = false; scene.add(meteorPts); let nextMeteor = 1.5; const spawnMeteor = (look: THREE.Vector3, t: number) => { const depth = [25, 70, 160][Math.floor(rnd() * 3)]; const side = new THREE.Vector3().crossVectors(look, camera.up).normalize(); const start = camera.position.clone().addScaledVector(look, depth).addScaledVector(side, (rnd() - 0.2) * depth * 0.6).addScaledVector(camera.up, (rnd() * 0.6 + 0.1) * depth * 0.8); const dir = side.clone().multiplyScalar(-1).addScaledVector(camera.up, -0.55 - rnd() * 0.3).normalize(); meteors.push({start, dir, born: t, life: 0.9 + rnd() * 0.5, speed: depth * 1.6, len: depth * 0.28, bright: 80 / depth, near: depth < 30}); }; const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); composer.addPass(new UnrealBloomPass(new THREE.Vector2(1, 1), 0.4, 0.5, 0.6)); composer.addPass(new OutputPass()); const fit = () => { const r = host.getBoundingClientRect(), w = Math.max(1, r.width), h = Math.max(1, r.height); renderer.setSize(w, h, false); composer.setSize(w, h); camera.aspect = w / h; camera.fov = camera.aspect < 1 ? 70 : 55; camera.updateProjectionMatrix(); uniforms.uScale.value = Math.max(1, (h * DPR) / 1080) * 1.1; }; // 沿着银河带朝核心看,慢慢转;竖屏时银河带斜着穿过画面 const ptr = pointer(host, 1.2); const stop = animate(host, fit, (t, dt) => { ptr.update(dt); uniforms.uTime.value = t; const a = -0.35 + t * 0.018 + (ptr.x - 0.5) * 0.15; const look = new THREE.Vector3().addScaledVector(CORE, Math.cos(a)).addScaledVector(SIDE, Math.sin(a)).addScaledVector(POLE, 0.08 + (ptr.y - 0.5) * 0.12).normalize(); camera.position.copy(SIDE).multiplyScalar(t * 1.2).addScaledVector(POLE, Math.sin(t * 0.1) * 3); camera.up.copy(POLE).applyAxisAngle(look, 0.75); camera.lookAt(camera.position.clone().add(look)); sky.position.copy(camera.position); for (const u of layers) { u.uOffset.value.copy(DRIFT).multiplyScalar(t); u.uCam.value.copy(camera.position); } if (dt && t > nextMeteor) { spawnMeteor(look, t); nextMeteor = t + 2.2 + rnd() * 2.5; } const mp = meteorGeo.attributes.position.array as Float32Array, mc = meteorGeo.attributes.color.array as Float32Array, ms = meteorGeo.attributes.size.array as Float32Array; mc.fill(0); ms.fill(0); for (let i = meteors.length - 1; i >= 0; i--) if (t - meteors[i].born > meteors[i].life) meteors.splice(i, 1); meteors.slice(0, MMAX).forEach((m, i) => { const age = (t - m.born) / m.life, k = Math.sin(Math.PI * Math.min(age, 1)); const head = m.start.clone().addScaledVector(m.dir, m.speed * (t - m.born)); for (let j = 0; j < MP; j++) { const f = j / (MP - 1), q = head.clone().addScaledVector(m.dir, -m.len * (0.35 + 0.65 * k) * f), o = i * MP + j; mp.set([q.x, q.y, q.z], o * 3); const b = Math.min(2.2, m.bright) * k * Math.pow(1 - f, 1.4); mc.set([b, b * 0.96, b * 0.9], o * 3); ms[o] = (m.near ? 8 : 5) * (1 - 0.65 * f); } }); meteorGeo.attributes.position.needsUpdate = true; meteorGeo.attributes.color.needsUpdate = true; meteorGeo.attributes.size.needsUpdate = true; composer.render(); }, 10); return () => { stop(); ptr.off(); for (const d of dispose) d.dispose(); composer.dispose(); renderer.dispose(); renderer.forceContextLoss(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function MilkyWay({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountMilkyWay(ref.current!), []); return