# 星云 Nebula > 三层星云叠出纵深的太空背景;星点朝镜头飞来,镜头每隔几秒转一步,空白处按住鼠标能拖动视角。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:科技、AI、航天类的首屏或 PPT 封面,要深邃、安静的太空感。 - 不适合:温暖、生活化、本地小店的页面;背景上要放大段文字(选更暗更平的背景或纯色);同一页已经有别的 WebGL 背景。 - 适合的网站:官网、SaaS、活动页、作品集 - 放在:首屏、背景 - 手机上:好用:竖屏时自动把星云带转 90 度,从上往下铺满;手指拖动留给页面滚动,不会拖天空。 - 性能代价:中:三层云只在开始时画一次,之后每帧只是叠图和画 5600 颗星;一般电脑和手机都能流畅跑。 - 常搭配:[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md)、[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `nebula.tsx`(比如 `src/components/nebula.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/nebula.tsx),比手抄准:`curl -fsSL -o src/components/nebula.tsx https://www.dachuiai.com/code/nebula.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountNebula`、`Nebula` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountNebula(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountNebula** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **Nebula** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用原生 WebGL 写一个全屏星云背景:三层星云放在不同深度,每层用三维值噪声叠加的 FBM 生成后缓存成纹理,按深度做视差;约 5600 颗星点,偶尔一颗拖着尾巴的流星;用一张流动纹理让星云慢慢变化;镜头绕中心分步转动,鼠标拖动可以小幅改变视角;深蓝色调,整体偏暗,适合当背景。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * Nebula 星云 * 三层星云放在不同深度(远、中、近),每层用三维值噪声叠加的 FBM 画一次、缓存成纹理,按深度做视差; * 约 5600 颗星点朝镜头飞来,偶尔一颗拖着尾巴的流星;一张流动纹理让星云慢慢变化。 * 镜头每隔几秒绕中心转一步,前后几层错开,越往里越深;在空白处按住鼠标拖动,可以小幅改变视角。 * 竖屏时把横版的天空转 90 度铺满,星云带从上往下走。 * 用 WebGL 实时画,不用视频和图片。滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧,也不自动转。 * ------------------------------------------------------------- */ const CAMERA = ` uniform vec3 uCameraEye; void cameraBasis(out vec3 right,out vec3 up,out vec3 forward){ forward=normalize(vec3(0.,0.,-220.)-uCameraEye); right=normalize(cross(forward,vec3(0.,1.,0.))); up=cross(right,forward); }`; const SCREEN_VS = `attribute vec2 aPosition;varying vec2 vUV;void main(){vUV=aPosition*.5+.5;gl_Position=vec4(aPosition,0.,1.);}`; // 云层:三层各画一次、缓存成纹理(远、中、近) const CLOUD_FS = `precision highp float;varying vec2 vUV;uniform float uLayer;uniform float uZoom; float hash(vec3 p){p=fract(p*.3183099+vec3(.11,.23,.37));p*=17.;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));} float noise(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*f*(f*(f*6.-15.)+10.); return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y), mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);} float fbm(vec3 p){float n=0.,a=.52;mat3 rot=mat3(.00,.80,.60,-.80,.36,-.48,-.60,-.48,.64); for(int i=0;i<6;i++){n+=a*noise(p);p=rot*p*2.04+vec3(7.1,3.7,5.2);a*=.50;}return n;} vec3 distantStars(vec2 p,float cells,float density){ vec2 grid=p*cells,cell=floor(grid),f=fract(grid); float rnd=hash(vec3(cell,3.7));vec2 center=vec2(hash(vec3(cell,12.8)),hash(vec3(cell,53.2)))*.74+.13; float dotStar=exp(-dot(f-center,f-center)*150.); float gate=smoothstep(.986-density*.022,1.,rnd); vec3 tint=mix(vec3(.58,.71,1.),vec3(1.,.90,.73),hash(vec3(cell,91.))); return tint*dotStar*gate*.85; } vec3 nebula(vec2 sky,out float band){ vec3 p=vec3(sky*2.05,.73); vec2 warp=vec2(fbm(p*.62+vec3(2.1,6.3,1.2)),fbm(p*.69+vec3(9.2,3.5,5.4))); vec3 q=p+vec3(warp*1.8,warp.x-warp.y); float broad=fbm(q*.72),fine=fbm(q*2.8+vec3(3.7,1.2,8.3)); float center=sky.y-.31*sky.x+.11+.12*sin(sky.x*1.9)+(warp.y-.5)*.63; band=exp(-pow(center/(.27+warp.x*.26),2.)); float halo=exp(-pow(center/1.0,2.)); float clouds=pow(max(0.,broad-.24)*2.6,1.6); float filaments=pow(max(0.,1.-abs(fine-.49)*3.2),8.); float dust=smoothstep(.33,.59,fbm(q*1.4+vec3(1.,8.,2.))); float density=band*clouds; vec3 hue=mix(vec3(.20,.11,.35),vec3(.075,.27,.39),smoothstep(-1.5,1.5,sky.x)); vec3 color=vec3(.010,.015,.036)+hue*halo*clouds*.14; color+=hue*density*(.85+filaments*.48)*(1.-dust*.60); color+=vec3(.29,.37,.43)*density*filaments*.18; color+=vec3(.26,.18,.23)*band*pow(fine,5.)*.34; color*=.94+noise(q*34.)*.12; return color; } void main(){ vec2 sky=(vUV-.5)*2.*(.956/uZoom);sky.x*=16./9.; float band,leftBand,redBand;vec3 base=vec3(.010,.015,.036); vec3 original=max(vec3(0.),nebula(sky,band)-base); float leftMask=1.-smoothstep(-.30,.40,sky.x); vec3 companion=max(vec3(0.),nebula(vec2(-sky.x*.95+.12,sky.y+.62),leftBand)-base); vec3 blue=original+companion*vec3(1.10,.89,1.06)*leftMask*.96; vec3 color; if(uLayer<.5){ // 远处的尘埃和小星星几乎钉在镜头的对焦深度上 vec3 red=max(vec3(0.),nebula(vec2(sky.x*1.60+.60,sky.y*1.90+.18),redBand)-base); float glow=dot(red,vec3(.25,.50,.25)); float core=exp(-dot(sky*vec2(1.60,2.0),sky*vec2(1.60,2.0))*1.5); color=base+blue*.24+glow*vec3(.72,.26,.61)*core*.85; float starsBand=max(band,leftBand*leftMask); color+=distantStars(sky,85.,starsBand)+distantStars(sky+vec2(7.4,1.2),160.,starsBand)*.45; }else if(uLayer<1.5){ color=blue*.82; }else{ // 近处是蓝色的云丝;玫瑰紫的小核心只在远处 vec3 mist=max(vec3(0.),nebula(vec2(-sky.x+.65,sky.y+.50),redBand)-base); float glow=dot(mist,vec3(.25,.50,.25)); color=glow*vec3(.17,.46,.80)*smoothstep(-.20,.70,sky.x)*.95; color+=companion*vec3(.64,.69,1.)*leftMask*.10; } color*=max(.60,1.-.16*pow(length(sky*vec2(.45,.60)),1.5)); gl_FragColor=vec4(color,1.); }`; // 每一帧:按镜头位置从三层云里取样叠起来,近的层视差大 const COPY_FS = `precision highp float;varying vec2 vUV; uniform sampler2D uFar;uniform sampler2D uMid;uniform sampler2D uNear;uniform sampler2D uFlow; uniform float uTime;uniform vec2 uFlowSeed; ${CAMERA} vec2 planeUV(vec3 ray,float depth,float zoom){ vec3 world=uCameraEye+ray*((-depth-uCameraEye.z)/ray.z); vec2 uv=world.xy*(1.9/depth)/vec2(16./9.,1.)*.5+.5; return (uv-.5)*zoom+.5; } vec3 flow(vec2 uv,float speed,float seed){ return texture2D(uFlow,uv*.77+uFlowSeed+vec2(uTime*.004*speed,-uTime*.0031*speed)+seed).rgb; } void main(){ vec2 screen=(vUV-.5)*2.;screen.x*=16./9.; vec3 right,up,forward;cameraBasis(right,up,forward); vec3 ray=normalize(forward+right*screen.x/1.9+up*screen.y/1.9); vec2 farUV=planeUV(ray,220.,.82),midUV=planeUV(ray,125.,.76),nearUV=planeUV(ray,66.,.56); vec3 f=flow(farUV,.25,.11),m=flow(midUV,.85,.37),n=flow(nearUV,1.20,.69); // 一张流动纹理让每个区域各自慢慢扭动,不用重画云层 vec3 color=texture2D(uFar,farUV+(f.rg-.5)*.006).rgb; color+=texture2D(uMid,midUV+(m.rg-.5)*.065).rgb*(.93+m.b*.14); color+=texture2D(uNear,nearUV+(n.rg-.5)*.085).rgb*(.89+n.b*.22); gl_FragColor=vec4(color,1.); }`; // 星星:三维点云,往镜头飞过来,越近越大 const STAR_VS = `attribute vec3 aPosition;attribute vec3 aColor;attribute float aSeed; uniform float uTime;uniform float uDensity; ${CAMERA} varying vec3 vColor;varying float vGlow; void main(){ float depth=2.+mod(aPosition.z-uTime*1.0+180.,180.); vec2 xy=aPosition.xy; float angle=sin(uTime*.012)*.035;xy=mat2(cos(angle),-sin(angle),sin(angle),cos(angle))*xy; vec3 right,up,forward;cameraBasis(right,up,forward); vec3 relative=vec3(xy,-depth)-uCameraEye; float viewDepth=dot(relative,forward); vec2 screen=vec2(dot(relative,right),dot(relative,up))*1.9/max(viewDepth,.1); screen/=vec2(16./9.,1.); gl_Position=viewDepth>1.?vec4(screen,0.,1.):vec4(4.,4.,0.,1.); float fade=smoothstep(2.,9.,depth)*(.65+.35*(1.-depth/182.)); float twinkle=.97+.03*sin(uTime*(.25+aSeed*.3)+aSeed*72.); vColor=aColor*fade*twinkle*1.25;vGlow=step(.992,aSeed); gl_PointSize=clamp((1.4+32./depth)*(1.+vGlow*3.4)*uDensity,1.5,12.); }`; const STAR_FS = `precision highp float;varying vec3 vColor;varying float vGlow; void main(){ vec2 q=gl_PointCoord-.5;float r=length(q);if(r>.5)discard; float core=exp(-r*r*28.),halo=exp(-r*r*11.); float flare=exp(-abs(q.x)*70.-abs(q.y)*7.)+exp(-abs(q.y)*70.-abs(q.x)*7.); float a=core*.87+halo*vGlow*.06+flare*vGlow*.085; gl_FragColor=vec4(vColor*a,a); }`; // 流星:拖着尾巴的一条带子 const TRAIL_VS = `attribute vec2 aPosition;uniform vec3 uStar;uniform float uTime;uniform float uLength; ${CAMERA} varying vec2 vTrail; vec2 project(vec3 p,vec3 right,vec3 up,vec3 forward){ vec3 relative=p-uCameraEye;float z=max(.1,dot(relative,forward)); return vec2(dot(relative,right),dot(relative,up))*1.9/z*360.; } void main(){ float depth=2.+mod(uStar.z-uTime+180.,180.); vec2 xy=uStar.xy; float angle=sin(uTime*.012)*.035;xy=mat2(cos(angle),-sin(angle),sin(angle),cos(angle))*xy; vec3 right,up,forward;cameraBasis(right,up,forward); vec2 head=project(vec3(xy,-depth),right,up,forward),previous=project(vec3(xy,-depth-.5),right,up,forward); vec2 direction=normalize(head-previous+vec2(.00001)),normal=vec2(-direction.y,direction.x); float along=(aPosition.x+1.)*.5; vec2 pixel=head-direction*along*uLength+normal*aPosition.y*1.65; gl_Position=vec4(pixel/vec2(640.,360.),0.,1.);vTrail=vec2(along,aPosition.y); }`; const TRAIL_FS = `precision highp float;uniform float uLife;uniform vec3 uTint;varying vec2 vTrail; void main(){ float body=pow(1.-vTrail.x,2.0)*exp(-vTrail.y*vTrail.y*5.5); float fade=smoothstep(0.,.20,uLife)*(1.-smoothstep(.62,1.,uLife)); float a=body*fade*.66;gl_FragColor=vec4(uTint*a,a); }`; /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ type Layer = {depth: number; zoom: number; w: number; h: number; tex?: WebGLTexture | null; fbo?: WebGLFramebuffer | null}; type Trail = {position: number[]; tint: number[]; start: number; duration: number; length: number}; const STAR_COUNT = 5600, ORBIT_RADIUS = 220, ORBIT_STEP = 0.7, ORBIT_AMP = 0.3, ORBIT_PITCH = 0.06, ORBIT_SPRING = 5; const CLOUD_SPEED = 3, STAR_SPEED = 4, TURN_EVERY = 3.8; const VIEW_YAW = 0.52, VIEW_PITCH = 0.28; // 在这些元素上按下鼠标不算拖天空(按钮、链接、文字照常能点、能选) const CONTENT = 'a,button,input,textarea,select,label,[contenteditable]:not([contenteditable="false"]),[role="button"],h1,h2,h3,h4,p,li,svg,img,video'; /** 在 host 里铺满星云背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountNebula(host: HTMLElement): () => void { const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;display:block;transform-origin:50% 50%'; const gl = canvas.getContext('webgl', {alpha: false, antialias: false, powerPreference: 'low-power'}); if (!gl) return () => {}; // 不支持 WebGL:留着 host 自己的底色 const shader = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader'); return s; }; const program = (v: string, f: string) => { const p = gl.createProgram()!; gl.attachShader(p, shader(gl.VERTEX_SHADER, v)); gl.attachShader(p, shader(gl.FRAGMENT_SHADER, f)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'link'); return p; }; let bg: WebGLProgram, copy: WebGLProgram, star: WebGLProgram, trail: WebGLProgram; try { bg = program(SCREEN_VS, CLOUD_FS); copy = program(SCREEN_VS, COPY_FS); star = program(STAR_VS, STAR_FS); trail = program(TRAIL_VS, TRAIL_FS); } catch { return () => {}; } const locs = new Map>(); const loc = (p: WebGLProgram, name: string) => { let m = locs.get(p); if (!m) locs.set(p, (m = new Map())); if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); return m.get(name)!; }; const uni = (p: WebGLProgram, name: string, v: number | number[]) => { const u = loc(p, name); if (typeof v === 'number') gl.uniform1f(u, v); else if (v.length === 3) gl.uniform3fv(u, v); else gl.uniform2fv(u, v); }; const quad = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); const screen = (p: WebGLProgram) => { gl.useProgram(p); gl.bindBuffer(gl.ARRAY_BUFFER, quad); const a = gl.getAttribLocation(p, 'aPosition'); gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0); }; // 星星:固定种子,每次打开都一样;一部分散在全天,一部分沿星云带,少数聚成几团 let seed = 260906; const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; }; const gaussian = () => Math.sqrt(-2 * Math.log(Math.max(0.0001, random()))) * Math.cos(6.283185 * random()); const clusters = [[-0.9, -0.35], [0.45, 0.19], [1.08, 0.36], [-0.1, 0.02]]; const starData: number[] = []; for (let i = 0; i < STAR_COUNT; i++) { const z = 32 + random() * 148, kind = random(); let sx: number, sy: number; if (kind < 0.43) { sx = (random() - 0.5) * 4.2; sy = (random() - 0.5) * 2.5; } else if (kind < 0.89) { sx = (random() - 0.5) * 4.3; sy = 0.31 * sx - 0.11 + gaussian() * 0.25; } else { const c = clusters[i % clusters.length]; sx = c[0] + gaussian() * 0.085; sy = c[1] + gaussian() * 0.07; } const h = random(), b = 0.22 + Math.pow(random(), 3.7) * 1.55, rare = random(); const tint = h < 0.18 ? [1, 0.84, 0.66] : h > 0.56 ? [0.68, 0.83, 1] : [0.91, 0.95, 1]; starData.push((sx * z) / 1.9, (sy * z) / 1.9, z, ...tint.map((x) => x * b), rare); } const stars = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, stars); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(starData), gl.STATIC_DRAW); // 三层云的缓存纹理(按横版 16:9 算,竖屏转过来用) const layers: Layer[] = [{depth: 220, zoom: 0.82, w: 2560, h: 1440}, {depth: 125, zoom: 0.76, w: 2048, h: 1152}, {depth: 66, zoom: 0.56, w: 1638, h: 922}]; for (const l of layers) { l.tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, l.tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, l.w, l.h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); l.fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, l.fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, l.tex, 0); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 流动纹理:一张小的平滑随机向量场,星云的缓慢变化全靠它 const flowTex = (() => { const N = 128, px = new Uint8Array(N * N * 4), grids = [4, 8].map((n) => ({n, data: Array.from({length: n * n * 3}, () => Math.random())})); const noise = (x: number, y: number, c: number, g: {n: number; data: number[]}) => { const gx = x * g.n, gy = y * g.n, ix = Math.floor(gx), iy = Math.floor(gy); let fx = gx - ix, fy = gy - iy; fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy); const at = (a: number, b: number) => g.data[((b % g.n) * g.n + (a % g.n)) * 3 + c]; return (at(ix, iy) * (1 - fx) + at(ix + 1, iy) * fx) * (1 - fy) + (at(ix, iy + 1) * (1 - fx) + at(ix + 1, iy + 1) * fx) * fy; }; for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { const i = (y * N + x) * 4; for (let c = 0; c < 3; c++) px[i + c] = Math.round(255 * (noise(x / N, y / N, c, grids[0]) * 0.7 + noise(x / N, y / N, c, grids[1]) * 0.3)); px[i + 3] = 255; } const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, N, N, 0, gl.RGBA, gl.UNSIGNED_BYTE, px); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); return t; })(); const flowSeed = [Math.random(), Math.random()]; // 镜头:绕中心的轨道(弹簧,转一步会微微过冲再停稳)+ 拖动出来的视角 let orbit = 0, orbitTarget = 0, orbitVel = 0, viewYaw = 0, viewPitch = 0, time = 0, skyReady = false; const eye = () => { const yaw = viewYaw + ORBIT_AMP * Math.sin(orbit), pitch = viewPitch + ORBIT_PITCH * (Math.cos(orbit) - 1), d = ORBIT_RADIUS; return [d * Math.sin(yaw) * Math.cos(pitch), d * Math.sin(pitch), -ORBIT_RADIUS + d * Math.cos(yaw) * Math.cos(pitch)]; }; const updateOrbit = (dt: number) => { const w = ORBIT_SPRING, err = orbit - orbitTarget, k = orbitVel + w * err, decay = Math.exp(-w * dt); orbit = orbitTarget + (err + k * dt) * decay; orbitVel = (orbitVel - w * k * dt) * decay; if (Math.abs(orbit - orbitTarget) < 0.00005 && Math.abs(orbitVel) < 0.0004) { orbit = orbitTarget; orbitVel = 0; } }; // 流星:每隔两三秒挑一颗中等距离、在画面里的亮星,拖一条尾巴 let active: Trail | null = null, nextTrail = 1.5 + Math.random() * 1.5; const chooseTrail = () => { if (active && time - active.start >= active.duration) active = null; if (time < nextTrail) return; nextTrail = time + 2.4 + Math.random() * 1.6; const e = eye(), nrm = (v: number[]) => { const n = Math.hypot(...v); return v.map((x) => x / n); }; const crs = (a: number[], b: number[]) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const fwd = nrm([-e[0], -e[1], -ORBIT_RADIUS - e[2]]), right = nrm(crs(fwd, [0, 1, 0])), up = crs(right, fwd); const ang = Math.sin(time * STAR_SPEED * 0.012) * 0.035, c = Math.cos(ang), s = Math.sin(ang); for (let k = 0; k < 160; k++) { const o = Math.floor(Math.random() * STAR_COUNT) * 7, p = starData.slice(o, o + 3), depth = 2 + (((p[2] - time * STAR_SPEED) % 180) + 180) % 180; if (depth < 18 || depth > 75 || starData[o + 4] < 0.38) continue; const rel = [c * p[0] + s * p[1] - e[0], -s * p[0] + c * p[1] - e[1], -depth - e[2]], z = dot(rel, fwd); const sx = (dot(rel, right) * 1.9) / z / (16 / 9), sy = (dot(rel, up) * 1.9) / z; if (z < 5 || Math.abs(sx) > 0.83 || Math.abs(sy) > 0.78 || Math.hypot(sx, sy) < 0.28) continue; active = {position: p, tint: starData.slice(o + 3, o + 6).map((v) => Math.min(1.3, 0.6 + v * 0.4)), start: time, duration: 1.8 + Math.random() * 0.6, length: 46 + Math.random() * 36}; return; } nextTrail = time + 2; }; const render = () => { gl.disable(gl.BLEND); if (!skyReady) { layers.forEach((l, i) => { gl.bindFramebuffer(gl.FRAMEBUFFER, l.fbo!); gl.viewport(0, 0, l.w, l.h); screen(bg); uni(bg, 'uLayer', i); uni(bg, 'uZoom', l.zoom); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); }); skyReady = true; } const e = eye(); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvas.width, canvas.height); screen(copy); layers.forEach((l, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, l.tex!); gl.uniform1i(loc(copy, ['uFar', 'uMid', 'uNear'][i]), i); }); gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, flowTex); gl.uniform1i(loc(copy, 'uFlow'), 3); uni(copy, 'uTime', time * CLOUD_SPEED); uni(copy, 'uCameraEye', e); uni(copy, 'uFlowSeed', flowSeed); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.useProgram(star); gl.bindBuffer(gl.ARRAY_BUFFER, stars); for (const [name, n, off] of [['aPosition', 3, 0], ['aColor', 3, 12], ['aSeed', 1, 24]] as const) { const a = gl.getAttribLocation(star, name); gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a, n, gl.FLOAT, false, 28, off); } uni(star, 'uTime', time * STAR_SPEED); uni(star, 'uDensity', canvas.width / 1280); uni(star, 'uCameraEye', e); gl.drawArrays(gl.POINTS, 0, STAR_COUNT); if (active) { screen(trail); uni(trail, 'uStar', active.position); uni(trail, 'uTint', active.tint); uni(trail, 'uTime', time * STAR_SPEED); uni(trail, 'uCameraEye', e); uni(trail, 'uLife', (time - active.start) / active.duration); uni(trail, 'uLength', active.length); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } }; // 画布永远是横版 16:9、盖满 host;host 是竖的时候把画布转 90 度 const place = () => { const r = host.getBoundingClientRect(), tall = r.height > r.width; const w = tall ? Math.max(r.height, (r.width * 16) / 9) : Math.max(r.width, (r.height * 16) / 9), h = (w * 9) / 16; Object.assign(canvas.style, {width: w + 'px', height: h + 'px', left: (r.width - w) / 2 + 'px', top: (r.height - h) / 2 + 'px', transform: tall ? 'rotate(90deg)' : 'none'}); const pw = Math.min(tall ? 2560 : 1920, Math.max(640, Math.round(w * Math.min(devicePixelRatio || 1, tall ? 2 : 1.5)))); if (canvas.width !== pw) { canvas.width = pw; canvas.height = Math.round((pw * 9) / 16); } }; // 拖动看天空:只在 host 上方的空白处按下才算(按钮、文字照常用);手指触摸留给页面滚动 let drag: {x: number; y: number; yaw: number; pitch: number} | null = null; const down = (e: PointerEvent) => { if (e.pointerType === 'touch' || !e.isPrimary || e.button !== 0) return; const t = e.target as Element | null, box = host.parentElement ?? host; if (!t || !box.contains(t) || t.closest(CONTENT)) return; const r = host.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return; e.preventDefault(); drag = {x: e.clientX, y: e.clientY, yaw: viewYaw, pitch: viewPitch}; }; const move = (e: PointerEvent) => { if (!drag) return; if (!(e.buttons & 1)) { drag = null; return; } viewYaw = Math.max(-VIEW_YAW, Math.min(VIEW_YAW, drag.yaw + (e.clientX - drag.x) * 0.00125)); viewPitch = Math.max(-VIEW_PITCH, Math.min(VIEW_PITCH, drag.pitch + (e.clientY - drag.y) * 0.001)); if (!raf) render(); }; const up = () => { drag = null; }; addEventListener('pointerdown', down); addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up); addEventListener('blur', up); // 循环:只在看得见时画(滚出屏幕、标签页隐藏、减弱动态效果时停);镜头每隔几秒转一步 const reduce = matchMedia('(prefers-reduced-motion: reduce)'); let raf = 0, last = 0, seen = false, nextTurn = 1.2; const tick = (now: number) => { raf = requestAnimationFrame(tick); const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60; last = now; time += dt; if (time > nextTurn) { orbitTarget += ORBIT_STEP; nextTurn = time + TURN_EVERY; } updateOrbit(dt); chooseTrail(); render(); }; const sync = () => { const run = seen && !document.hidden && !reduce.matches; if (run && !raf) { last = 0; raf = requestAnimationFrame(tick); } if (!run && raf) { cancelAnimationFrame(raf); raf = 0; } }; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); place(); render(); const ro = new ResizeObserver(() => { place(); if (!raf) render(); }); ro.observe(host); const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; sync(); }); io.observe(host); const onMotion = () => { sync(); if (!raf) render(); }; document.addEventListener('visibilitychange', sync); reduce.addEventListener('change', onMotion); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); removeEventListener('pointerdown', down); removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up); removeEventListener('blur', up); document.removeEventListener('visibilitychange', sync); reduce.removeEventListener('change', onMotion); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function Nebula({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountNebula(ref.current!), []); return
; } /* ---------------- 示例:首屏背景 ---------------- */ export default function NebulaDemo() { return (
); } ```