# 体积云海 Volumetric Clouds > 贴着日出云海往前飞的背景;云有真实的厚度和阴影,近处偶尔擦过一缕薄云。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:旅行、航空、户外、度假类的首屏,要开阔、明亮的感觉。 - 不适合:老电脑、手机为主的项目(很吃显卡);深色风格的网站;背景上要放大段文字。 - 适合的网站:官网、活动页、本地小店 - 放在:首屏、背景 - 手机上:要改:手机显卡吃力、会发热,手机上建议换成一张截图或短视频。 - 性能代价:高:最吃显卡的两个背景之一(另一个是黑洞)。每个像素都要穿过三维云层算光照,M5 Pro 的 Mac 全屏约 60 帧;已按一半分辨率渲染,跟不上时自动再降。 - 常搭配:[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `cloud-sea.tsx`(比如 `src/components/cloud-sea.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/cloud-sea.tsx),比手抄准:`curl -fsSL -o src/components/cloud-sea.tsx https://www.dachuiai.com/code/cloud-sea.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountCloudSea`、`CloudSea` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。画面偏亮,白字要加阴影(示例里有)。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountCloudSea(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountCloudSea** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **CloudSea** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用 WebGL2 光线步进写一个体积云海背景:云的密度来自三维噪声纹理叠加的 FBM,有真实厚度;每个采样点朝太阳方向走几步算自阴影(比尔定律加 powder 效果),HG 相位函数做朝阳一侧的银边,再加多次散射近似让云内部不发黑;日出天空渐变,不画太阳圆盘;镜头低空贴着云层慢慢前飞,近处偶尔擦过半透明薄云丝;半分辨率渲染加 ACES 色调映射。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * CloudSea 体积云海 * 日出时贴着云海低空往前飞。云是有真实厚度的体积:每个像素沿视线一步步穿过三维密度场, * 每一步再朝太阳走几步算自阴影(比尔定律加 powder 效果),朝阳一侧有银边, * 云的里面加了多次散射,不发黑。近处偶尔擦过一缕半透明的薄云丝。鼠标让镜头微微转向。 * 用 WebGL2 着色器实时画,不用视频和图片。 * 性能:这是最吃显卡的两个背景之一(另一个是黑洞)。M5 Pro 的 Mac 全屏约 60 帧; * 已经按一半分辨率渲染再放大,电脑跟不上时会自动再降一点分辨率。滚出屏幕、切到别的标签页时暂停。 * 开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ const SCENE = `#version 300 es precision highp float; precision highp sampler3D; in vec2 vUv; out vec4 o; uniform vec2 uRes; uniform float uTime; uniform vec3 uCam, uFwd, uRight, uUp; uniform sampler3D uNoise; uniform vec3 uSun; uniform float uSpeed; const float BOT = 0.0, TOP = 1.15, TOPMAX = 2.9; float hash(vec2 p){ vec3 q = fract(vec3(p.xyx)*0.1031); q += dot(q, q.yzx+33.33); return fract((q.x+q.y)*q.z); } vec3 skyCol(vec3 d){ float e = clamp(d.y, -0.2, 1.0); vec3 zen = vec3(0.12, 0.26, 0.5), blu = vec3(0.42, 0.56, 0.72), mid = vec3(1.0, 0.68, 0.48), hor = vec3(1.3, 0.6, 0.26); vec3 c = mix(hor, mid, smoothstep(0.0, 0.05, e)); c = mix(c, blu, smoothstep(0.04, 0.2, e)); c = mix(c, zen, smoothstep(0.18, 0.5, e)); float s = max(dot(d, uSun), 0.0); // 当背景用:不画清楚的太阳圆盘,只有雾里透出的一团暖光 c += vec3(1.8, 0.75, 0.28) * pow(s, 30.0) * 0.45 + vec3(1.5, 0.75, 0.35) * pow(s, 300.0) * 0.35; return c; } float h11(float n){ return fract(sin(n * 127.1) * 43758.5453); } // 镜头不钻进大云团;飞行高度上飘着几缕又小又薄的半透明云丝,放在航线两旁,隔一会儿就有一缕擦过画面边上 float wisps(vec3 p, vec3 q){ float S = 6.5, d = 0.0; float id0 = floor((-uCam.z) / S) - 1.0; // 只看镜头附近的几缕 for (int k = 0; k < 4; k++) { float id = id0 + float(k); if (id < 0.0) continue; float zc = -(id + 0.5) * S - 2.0; float tp = -zc / uSpeed; float sideSign = mod(id, 2.0) < 0.5 ? 1.0 : -1.0; float xc = sin(tp * 0.05) * 0.6 + sideSign * (0.32 + 0.18 * fract(id * 0.618)); vec3 c = vec3(xc, 1.9 + 0.1 * sin(id * 2.1), zc); vec3 e = (p - c) / vec3(0.6, 0.2, 0.45); // 小、扁、横着拉长 float r = length(e); if (r > 1.5) continue; // 离这缕云丝远:不用采样 float body = smoothstep(1.0, 0.35, r + (texture(uNoise, q * 1.2).r - 0.5) * 1.0); float tear = smoothstep(0.3, 0.6, texture(uNoise, q * 2.6 + 3.0).g * 0.65 + texture(uNoise, q * 6.0).r * 0.35); d = max(d, body * tear); } return d; } float density(vec3 p, out float h){ h = clamp((p.y - BOT) / (TOP - BOT), 0.0, 1.0); vec3 q = p + vec3(uTime*0.03, 0.0, uTime*0.012); float base = texture(uNoise, q*vec3(0.06, 0.1, 0.06)).r*0.6 + texture(uNoise, q*0.15).r*0.4; float cover = smoothstep(0.38, 0.62, base); // 积云的形状:顶是圆的,底比较平 float hh = (p.y - BOT) / (TOP - BOT); float prof = smoothstep(0.0, 0.12, hh) * smoothstep(1.0, 0.45 + 0.4*(1.0-cover), hh); float d = cover * prof; if (d < 0.02) return 0.0; if (p.y > TOP) h = 1.0; float w = texture(uNoise, q*0.55).g*0.55 + texture(uNoise, q*1.3).g*0.3 + texture(uNoise, q*3.1).r*0.15; d = clamp((d - (1.0 - w) * (0.5 + 0.3*h)) * 1.6, 0.0, 1.0); return d * 2.2; } float lightMarch(vec3 p){ float s = 0.0, h; float st = 0.09; for (int i = 0; i < 6; i++) { p += uSun * st; s += density(p, h) * st; st *= 1.6; } return s; } float hg(float c, float g){ float g2 = g*g; return (1.0 - g2) / (4.0*3.14159*pow(1.0 + g2 - 2.0*g*c, 1.5)); } void main(){ vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y; vec3 rd = normalize(uFwd + 0.95*(uv.x*uRight + uv.y*uUp)); vec3 sky = skyCol(rd); // 在云层里一步步走(镜头可能就在云层里):近处步子小,远处步子大 float tA = (TOPMAX - uCam.y) / rd.y, tB = (BOT - uCam.y) / rd.y; float t0 = max(min(tA, tB), 0.0), t1 = min(max(tA, tB), 70.0); if (abs(rd.y) < 1e-4) { t0 = 0.0; t1 = 70.0; } vec3 hz = skyCol(normalize(vec3(rd.x, 0.004, rd.z))); vec3 col = vec3(0.0); float T = 1.0; float cosT = dot(rd, uSun); float phase = mix(hg(cosT, 0.45), hg(cosT, -0.15), 0.45) * 4.0*3.14159; vec3 sunC = vec3(1.75, 0.9, 0.45) * 0.72; // 云顶不过曝 float tHit = -1.0; float t = t0 + 0.04 * hash(gl_FragCoord.xy + fract(uTime)*17.0); for (int i = 0; i < 150; i++) { if (t > t1) break; float dt = clamp(0.035 + t * 0.022, 0.035, 1.1); vec3 p = uCam + rd*t; float h; float d = density(p, h); if (d > 0.0) { if (tHit < 0.0) tHit = t; float ls = lightMarch(p); float beer = exp(-ls * 3.2), powder = 1.0 - exp(-d * 5.0); vec3 amb = mix(vec3(0.05, 0.075, 0.14), vec3(0.18, 0.26, 0.4), h*h); // 在云里散射了很多次的光:云的里面也是亮的、暖的,不发黑 vec3 multi = sunC * 0.32 * exp(-ls * 0.7) + vec3(0.25, 0.22, 0.2) * 0.35; vec3 L = sunC * beer * phase * mix(1.0, powder, 0.55) + amb + multi * smoothstep(0.0, 0.6, d); float a = 1.0 - exp(-d * dt * 9.0); col += T * a * L; T *= 1.0 - a; if (T < 0.02) break; } // 镜头附近的薄云丝:简单打光(太薄,自己不挡光) float ahead = uCam.z - p.z; if (ahead > -1.5 && ahead < 14.0 && abs(p.y - 1.9) < 0.45) { float dw = wisps(p, p + vec3(uTime*0.03, 0.0, uTime*0.012)) * 0.34; if (dw > 0.0005) { vec3 Lw = sunC * phase * 0.55 + vec3(0.42, 0.42, 0.5); float aw = 1.0 - exp(-dw * dt * 9.0); col += T * aw * Lw; T *= 1.0 - aw; } } t += dt * (d > 0.0 ? 1.0 : 1.3); } if (rd.y > 0.0) { o = vec4(col + T * sky, 1.0); return; } if (tHit < 0.0 && t0 >= t1) { o = vec4(hz, 1.0); return; } // 云下面:山谷里柔和的暗雾 vec3 under = vec3(0.06, 0.08, 0.13); col += T * under * 0.6; float dist = tHit > 0.0 ? tHit : t1; float fog = 1.0 - exp(-dist * 0.03); col = mix(col, hz, smoothstep(0.0, 1.0, fog) * 0.9 + 0.1 * smoothstep(40.0, 70.0, dist)); o = vec4(col, 1.0); }`; /* ---------------- WebGL2 小工具:着色器、全屏三角形 ---------------- */ type Prog = {p: WebGLProgram; u: Record; use: () => void}; const VS = `#version 300 es in vec2 aPos; out vec2 vUv; void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`; function program(gl: WebGL2RenderingContext, fs: string): Prog { const sh = (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 p = gl.createProgram()!; gl.attachShader(p, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(p, 0, 'aPos'); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'link'); const cache: Record = {}; const u = new Proxy(cache, {get: (c, k: string) => (k in c ? c[k] : (c[k] = gl.getUniformLocation(p, k)))}); return {p, u, use: () => gl.useProgram(p)}; } // 一个大三角形盖住整个画面(比两个三角形拼的矩形少一条对角线接缝) function triangle(gl: WebGL2RenderingContext) { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); return () => { gl.bindVertexArray(vao); gl.drawArrays(gl.TRIANGLES, 0, 3); }; } /* ---------------- 辉光和色调映射:场景先画进半浮点缓冲,6 级下采样 / 上采样出辉光,再调色、暗角、加一点颗粒 ---------------- */ type Target = {tex: WebGLTexture; fb: WebGLFramebuffer; w: number; h: number}; type Grade = {bloom: number; threshold: number; exposure: number; vignette: number; grain: number; time: number; sat: number; tone?: 'aces' | 'agx'}; const DOWN = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uSrc; uniform vec2 uTexel; uniform float uFirst; uniform float uThreshold; vec3 s(vec2 p){ return texture(uSrc, p).rgb; } void main(){ vec2 t = uTexel; vec3 a = s(vUv + t*vec2(-2, 2)), b = s(vUv + t*vec2(0, 2)), c = s(vUv + t*vec2(2, 2)); vec3 d = s(vUv + t*vec2(-2, 0)), e = s(vUv), f = s(vUv + t*vec2(2, 0)); vec3 g = s(vUv + t*vec2(-2, -2)), h = s(vUv + t*vec2(0, -2)), i = s(vUv + t*vec2(2, -2)); vec3 j = s(vUv + t*vec2(-1, 1)), k = s(vUv + t*vec2(1, 1)), l = s(vUv + t*vec2(-1, -1)), m = s(vUv + t*vec2(1, -1)); vec3 col = e*0.125 + (a+c+g+i)*0.03125 + (b+d+f+h)*0.0625 + (j+k+l+m)*0.125; if (uFirst > 0.5) { float br = max(col.r, max(col.g, col.b)); col *= max(br - uThreshold, 0.0) / max(br, 1e-4); col = min(col, vec3(60.0)); } o = vec4(col, 1.0); }`; const UP = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uSrc; uniform vec2 uTexel; void main(){ vec2 t = uTexel; vec3 c = vec3(0.0); c += texture(uSrc, vUv + t*vec2(-1, 1)).rgb + texture(uSrc, vUv + t*vec2(1, 1)).rgb + texture(uSrc, vUv + t*vec2(-1, -1)).rgb + texture(uSrc, vUv + t*vec2(1, -1)).rgb; c += 2.0*(texture(uSrc, vUv + t*vec2(0, 1)).rgb + texture(uSrc, vUv + t*vec2(0, -1)).rgb + texture(uSrc, vUv + t*vec2(-1, 0)).rgb + texture(uSrc, vUv + t*vec2(1, 0)).rgb); c += 4.0*texture(uSrc, vUv).rgb; o = vec4(c / 16.0, 1.0); }`; const FINAL = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uScene, uBloom; uniform float uBloomStrength, uExposure, uVignette, uGrain, uTime, uSat; uniform vec2 uRes; uniform int uTone; vec3 aces(vec3 x){ const float a=2.51,b=0.03,c=2.43,d=0.59,e=0.14; return clamp((x*(a*x+b))/(x*(c*x+d)+e),0.0,1.0); } // AgX 式的柔和压缩:很亮的颜色不会太早变成一片白 vec3 agx(vec3 v){ mat3 inM = mat3(0.842479062253094,0.0423282422610123,0.0423756549057051, 0.0784335999999992,0.878468636469772,0.0784336, 0.0792237451477643,0.0791661274605434,0.879142973793104); mat3 outM = mat3(1.19687900512017,-0.0528968517574562,-0.0529716355144438, -0.0980208811401368,1.15190312990417,-0.0980434501171241, -0.0990297440797205,-0.0989611768448433,1.15107367264116); v = inM * max(v, 0.0); v = clamp(log2(max(v,1e-10)), -12.47393, 4.026069); v = (v + 12.47393) / 16.49999; vec3 x2 = v*v, x4 = x2*x2; v = 15.5*x4*x2 - 40.14*x4*v + 31.96*x4 - 6.868*x2*v + 0.4298*x2 + 0.1191*v - 0.00232; v = outM * v; return clamp(pow(max(v,0.0), vec3(2.2)), 0.0, 1.0); } float hash(vec2 p){ p = fract(p*vec2(443.897,441.423)); p += dot(p, p.yx+19.19); return fract((p.x+p.y)*p.x); } void main(){ vec3 c = (texture(uScene, vUv).rgb + texture(uBloom, vUv).rgb * uBloomStrength) * uExposure; c = uTone == 1 ? agx(c) : aces(c); float l = dot(c, vec3(0.2126,0.7152,0.0722)); c = mix(vec3(l), c, uSat); vec2 q = vUv - 0.5; q.x *= uRes.x/uRes.y; c *= mix(1.0, smoothstep(1.15, 0.25, length(q)), uVignette); c = pow(c, vec3(1.0/2.2)); c += (hash(vUv*uRes + fract(uTime)*91.0) - 0.5) * uGrain; o = vec4(c, 1.0); }`; function createPost(gl: WebGL2RenderingContext, draw: () => void, levels = 6) { gl.getExtension('EXT_color_buffer_float'); gl.getExtension('OES_texture_float_linear'); const down = program(gl, DOWN), up = program(gl, UP), fin = program(gl, FINAL); const target = (w: number, h: number): Target => { const tex = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, 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); const fb = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); return {tex, fb, w, h}; }; let scene: Target | null = null, mips: Target[] = [], W = 0, H = 0; const bind = (p: Prog, name: string, unit: number, tex: WebGLTexture) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(p.u[name], unit); }; return { get scene() { return scene!; }, resize(w: number, h: number, sceneScale: number) { const sw = Math.max(2, Math.round(w * sceneScale)), sh = Math.max(2, Math.round(h * sceneScale)); if (scene && scene.w === sw && scene.h === sh && W === w && H === h) return; for (const t of [scene, ...mips]) if (t) { gl.deleteTexture(t.tex); gl.deleteFramebuffer(t.fb); } W = w; H = h; scene = target(sw, sh); mips = []; let mw = sw, mh = sh; for (let i = 0; i < levels; i++) { mw = Math.max(2, mw >> 1); mh = Math.max(2, mh >> 1); mips.push(target(mw, mh)); } }, begin() { gl.bindFramebuffer(gl.FRAMEBUFFER, scene!.fb); gl.viewport(0, 0, scene!.w, scene!.h); }, end(g: Grade) { gl.disable(gl.BLEND); let src = scene!; down.use(); mips.forEach((m, i) => { gl.bindFramebuffer(gl.FRAMEBUFFER, m.fb); gl.viewport(0, 0, m.w, m.h); bind(down, 'uSrc', 0, src.tex); gl.uniform2f(down.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(down.u.uFirst, i === 0 ? 1 : 0); gl.uniform1f(down.u.uThreshold, g.threshold); draw(); src = m; }); up.use(); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); for (let i = mips.length - 1; i > 0; i--) { const s = mips[i], d = mips[i - 1]; gl.bindFramebuffer(gl.FRAMEBUFFER, d.fb); gl.viewport(0, 0, d.w, d.h); bind(up, 'uSrc', 0, s.tex); gl.uniform2f(up.u.uTexel, 1 / s.w, 1 / s.h); draw(); } gl.disable(gl.BLEND); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); fin.use(); bind(fin, 'uScene', 0, scene!.tex); bind(fin, 'uBloom', 1, mips[0].tex); gl.uniform1f(fin.u.uBloomStrength, g.bloom / mips.length); gl.uniform1f(fin.u.uExposure, g.exposure); gl.uniform1f(fin.u.uVignette, g.vignette); gl.uniform1f(fin.u.uGrain, g.grain); gl.uniform1f(fin.u.uTime, g.time); gl.uniform1f(fin.u.uSat, g.sat); gl.uniform2f(fin.u.uRes, W, H); gl.uniform1i(fin.u.uTone, g.tone === 'agx' ? 1 : 0); draw(); }, }; } // 64³ 的可平铺三维噪声贴图,开始时在 CPU 上算一次:R = 平滑值噪声,G = 反过来的细胞(Worley)噪声 function noiseTexture(gl: WebGL2RenderingContext, N = 64) { let seed = 1234567; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); const L = 16, lat = new Float32Array(L * L * L).map(rnd); const C = 8, pts: number[][] = []; for (let i = 0; i < C * C * C; i++) pts.push([rnd(), rnd(), rnd()]); const q = (t: number) => t * t * t * (t * (t * 6 - 15) + 10); const data = new Uint8Array(N * N * N * 2); const s = L / N, cs = C / N; const v = (a: number, b: number, c: number) => lat[((a % L) * L + (b % L)) * L + (c % L)]; for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { const fx = x * s, fy = y * s, fz = z * s, ix = Math.floor(fx), iy = Math.floor(fy), iz = Math.floor(fz); const ux = q(fx - ix), uy = q(fy - iy), uz = q(fz - iz); const lx0 = v(ix, iy, iz) + (v(ix + 1, iy, iz) - v(ix, iy, iz)) * ux, lx1 = v(ix, iy + 1, iz) + (v(ix + 1, iy + 1, iz) - v(ix, iy + 1, iz)) * ux; const lx2 = v(ix, iy, iz + 1) + (v(ix + 1, iy, iz + 1) - v(ix, iy, iz + 1)) * ux, lx3 = v(ix, iy + 1, iz + 1) + (v(ix + 1, iy + 1, iz + 1) - v(ix, iy + 1, iz + 1)) * ux; const val = (lx0 + (lx1 - lx0) * uy) + ((lx2 + (lx3 - lx2) * uy) - (lx0 + (lx1 - lx0) * uy)) * uz; const px = x * cs, py = y * cs, pz = z * cs, cx = Math.floor(px), cy = Math.floor(py), cz = Math.floor(pz); let best = 9; for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const gx = cx + dx, gy = cy + dy, gz = cz + dz; const p = pts[(((gz + C) % C) * C + ((gy + C) % C)) * C + ((gx + C) % C)]; const ddx = gx + p[0] - px, ddy = gy + p[1] - py, ddz = gz + p[2] - pz; best = Math.min(best, ddx * ddx + ddy * ddy + ddz * ddz); } const i = ((z * N + y) * N + x) * 2; data[i] = Math.round(val * 255); data[i + 1] = Math.round((1 - Math.min(1, Math.sqrt(best))) * 255); } const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_3D, tex); gl.texImage3D(gl.TEXTURE_3D, 0, gl.RG8, N, N, N, 0, gl.RG, gl.UNSIGNED_BYTE, data); gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); for (const w of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R]) gl.texParameteri(gl.TEXTURE_3D, w, gl.REPEAT); return tex; } /* ---------------- 鼠标和循环 ---------------- */ // 鼠标在画面上时跟着走(带平滑),移出画面后慢慢回到中间。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); }; } // 显卡跟不上时自动降分辨率:连续约 2 秒低于 45 帧就降一档(最多两档),不再升回去,免得画面来回跳 function governor(onChange: (quality: number) => void) { let quality = 1, slow = 0, frames = 0; return (dt: number) => { if (++frames < 60 || quality <= 0.65) return; slow = dt > 1 / 45 ? slow + dt : Math.max(0, slow - dt); if (slow > 2) { quality = quality > 0.9 ? 0.8 : 0.65; slow = 0; onChange(quality); } }; } /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ type V3 = number[]; const norm = (v: V3) => { const n = Math.hypot(...v); return v.map((x) => x / n); }; const cross = (a: V3, b: V3) => [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 SUN = norm([-0.17, 0.075, -1]); const SPEED = 1.1; /** 在 host 里铺满云海背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountCloudSea(host: HTMLElement): () => void { const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; const gl = canvas.getContext('webgl2', {antialias: false, alpha: false, depth: false, stencil: false, powerPreference: 'high-performance'}); if (!gl) return () => {}; // 不支持 WebGL2:留着 host 自己的底色 let scene: Prog, post: ReturnType, noise: WebGLTexture | null; const draw = triangle(gl); try { scene = program(gl, SCENE); post = createPost(gl, draw); noise = noiseTexture(gl); } catch { return () => {}; } if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); const ptr = pointer(host, 1.5); // 屏幕像素最多按 1.5 倍算,云再按一半画;跟不上时 quality 往下降 let quality = 1; const fit = () => { const r = host.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 1.5); canvas.width = Math.max(2, Math.round(r.width * dpr)); canvas.height = Math.max(2, Math.round(r.height * dpr)); post.resize(canvas.width, canvas.height, 0.5 * quality); }; const slow = governor((q) => { quality = q; fit(); }); const stop = animate(host, fit, (t, dt) => { ptr.update(dt); if (dt) slow(dt); // 镜头一直往前飞,左右、上下轻轻晃,机身微微侧倾 const cam = [Math.sin(t * 0.05) * 0.6, 1.72 + 0.1 * Math.sin(t * 0.3), -t * SPEED]; const yaw = -0.08 + (ptr.x - 0.5) * 0.25 + 0.05 * Math.sin(t * 0.07), pitch = -0.14 + (ptr.y - 0.5) * 0.12; const fwd = norm([Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch)]); const bank = 0.03 * Math.sin(t * 0.11), c = Math.cos(bank), s = Math.sin(bank); const right = norm(cross(fwd, [0, 1, 0])), up = cross(right, fwd); const R = right.map((v, i) => v * c + up[i] * s), U = up.map((v, i) => -right[i] * s + v * c); post.begin(); scene.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_3D, noise); gl.uniform1i(scene.u.uNoise, 0); gl.uniform2f(scene.u.uRes, post.scene.w, post.scene.h); gl.uniform1f(scene.u.uTime, t); gl.uniform3fv(scene.u.uCam, cam); gl.uniform3fv(scene.u.uFwd, fwd); gl.uniform3fv(scene.u.uRight, R); gl.uniform3fv(scene.u.uUp, U); gl.uniform3fv(scene.u.uSun, SUN); gl.uniform1f(scene.u.uSpeed, SPEED); draw(); post.end({bloom: 0.25, threshold: 1.4, exposure: 0.55, vignette: 0.25, grain: 0.012, time: t, sat: 1.05}); }, 6); return () => { stop(); ptr.off(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function CloudSea({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountCloudSea(ref.current!), []); return