# 黑洞引力透镜 Black Hole > 《星际穿越》那样的黑洞背景;光线被引力弯曲,吸积盘绕成光环,整体慢慢逆时针转。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:讲宇宙、科幻、未来感的开场页或 PPT 封面,只要一个震撼的主视觉。 - 不适合:要长时间停留阅读的页面;文字多的首屏(黑洞本身就是视觉中心);老电脑、手机为主的项目(很吃显卡)。 - 适合的网站:活动页、官网、作品集 - 放在:首屏、背景 - 手机上:要改:竖屏时吸积盘自动斜放铺满画面;但手机显卡吃力、会发热,手机上建议换成一张截图。 - 性能代价:高:最吃显卡的两个背景之一(另一个是体积云海)。每个像素都要算几百步光线,M5 Pro 的 Mac 全屏约 60 帧;已按一半分辨率渲染,跟不上时自动再降。 - 常搭配:[乱码解码 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,不用另外安装 - 文件:原样存成 `black-hole.tsx`(比如 `src/components/black-hole.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/black-hole.tsx),比手抄准:`curl -fsSL -o src/components/black-hole.tsx https://www.dachuiai.com/code/black-hole.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountBlackHole`、`BlackHole` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。黑洞在横屏时偏右,左边留给标题。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountBlackHole(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountBlackHole** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **BlackHole** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用 WebGL2 全屏片元着色器写一个黑洞背景:按史瓦西黑洞的引力逐步弯曲每条光线;吸积盘有厚度,用体积光线步进累积发光和遮挡,噪声在圆周上采样保证无缝,纹理沿轨道拉成条纹,外缘做成螺旋拉丝;整体逆时针慢转,加减弱的多普勒增亮;每个像素的采样位置加交错梯度噪声抖动,消除步进折痕;背景星空跟着光线一起被弯曲;HDR、辉光、ACES 色调映射,最亮处压成暖奶油色;参考《星际穿越》的黑洞,镜头固定构图慢慢绕转。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * BlackHole 黑洞引力透镜 * 《星际穿越》那样的黑洞:每个像素的光线都按史瓦西黑洞的引力一步步弯曲, * 吸积盘背面的光被掰到黑洞上方,绕成一道光弧。吸积盘有厚度,逆时针慢慢转, * 外缘撕成螺旋拉丝;最亮的地方压成暖奶油色,当背景不刺眼。 * 鼠标让黑洞在画面里轻轻挪一点;竖屏时吸积盘改成从一角斜到另一角。 * 用 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, uOff; uniform float uTime, uFov, uSpin; uniform vec3 uCam, uFwd, uRight, uUp; uniform sampler3D uNoise; float hash13(vec3 p){ p = fract(p*0.1031); p += dot(p, p.zyx+31.32); return fract((p.x+p.y)*p.z); } float tn(vec3 p){ return texture(uNoise, p).r; } // 平滑噪声(可平铺) float tw(vec3 p){ return texture(uNoise, p).g; } // 细胞噪声:团块和颗粒 // 星星:每颗星在屏幕上画成约 1 像素的点(按相邻像素光线方向的差,把角度换成像素距离), // 所以引力透镜只会挪动星星,不会把它拉糊 vec3 stars(vec3 d, vec3 ddx, vec3 ddy){ vec3 col = vec3(0.0); float pa = uFov / uRes.y; float axb = length(cross(ddx, ddy)); float mag = clamp(pa * pa / max(axb, 1e-12), 0.2, 6.0); float aa = dot(ddx, ddx), ab = dot(ddx, ddy), bb = dot(ddy, ddy), det = aa * bb - ab * ab; if (det <= 0.0) return col; for (int k = 0; k < 3; k++) { float sc = 60.0 + float(k)*90.0; vec3 p = d*sc; vec3 c = floor(p); float h = hash13(c + float(k)*17.0); if (h > 0.982 - float(k)*0.004) { vec3 off = vec3(hash13(c+3.1), hash13(c+7.7), hash13(c+1.9)) - 0.5; vec3 s = normalize(c + 0.5 + off * 0.5); vec3 D = s - d; float ra = dot(ddx, D), rb = dot(ddy, D); vec2 px = vec2(bb * ra - ab * rb, aa * rb - ab * ra) / det; vec3 resid = D - ddx * px.x - ddy * px.y; float dist2 = dot(px, px) + dot(resid, resid) / (pa * pa); float b = pow(hash13(c+11.0), 6.0) * 2.2 + 0.12; col += vec3(1.0, 0.9, 0.8) * b * sqrt(mag) * smoothstep(0.12, 0.45, mag) * exp(-dist2 / (2.0 * 0.75 * 0.75)); } } return col; } const float RIN = 2.8, ROUT = 12.5; float thick(float rr){ return 0.035 + 0.028 * max(rr - RIN, 0.0); } // 吸积盘越往外越厚 // 吸积盘在点 p 处每单位长度的发光(rgb)和遮挡(a),rd 是光线方向。整个盘朝同一个方向转 vec4 disk(vec3 p, vec3 rd, float flip){ float rr = length(p.xz); if (rr < RIN * 0.9 || rr > ROUT) return vec4(0.0); float edge = smoothstep(ROUT * 0.45, ROUT * 0.95, rr); // 0 靠近黑洞,1 在撕开的外缘 float lr = log(rr); // 大的流动:整个盘朝一个方向转,里圈快一点 float turn = uTime * 0.062 * (0.55 + 0.45 * sqrt(RIN / rr)) * flip; float a = atan(p.z, p.x) * uSpin - turn; // 小涡流跟着大流动走、不停变化:把气体往前、往后、往外推,纹理会旋转、拉长、合并,不是整块平移 float tt = uTime * 0.055; vec2 c0 = vec2(cos(a), sin(a)); float e1 = tn(vec3(c0 * 0.75, lr * 3.4 + tt)) - 0.5; float e2 = fract(e1 * 7.13 + 0.5) - 0.5; a += (e1 * 0.2 + e2 * 0.08) * (0.6 + 0.6 * edge); lr += (e2 * 0.012 - e1 * 0.008) * (0.6 + 0.8 * edge); float sp = a + lr * 2.4; // 往后拖的螺旋臂 vec2 cs = vec2(cos(a), sin(a)), cp = vec2(cos(sp), sin(sp)); // 外缘的物质往上飘、鼓起来 float H = thick(rr) * (1.0 + edge * 0.45); float lift = edge > 0.01 ? (tn(vec3(cp * 0.35, lr * 1.6)) - 0.5) * H * 1.4 * edge : 0.0; float vz = (p.y - lift) / H; float vert = exp(-vz * vz * 1.4); if (vert < 0.01) return vec4(0.0); // 内圈:炽热、顺着轨道的细条纹 float inner = 0.0; if (edge < 0.99) { float mid = tn(vec3(cs * 0.4, lr * 2.6 + p.y * 0.4)); float fine = tn(vec3(cs * 0.6, lr * 16.0 + p.y * 1.1 + mid * 0.15 + tt * 0.4)); float lines = 0.5 + 0.5 * sin(lr * 120.0 + mid * 9.0 + fine * 3.0); inner = (0.25 + 1.1 * mid * mid + 0.5 * fine) * (0.55 + 0.65 * lines * lines); } // 外缘:像星云一样撕开的拉丝,沿螺旋方向做域扭曲的脊状噪声,中间有空隙 vec3 wq = vec3(cp * 0.55, lr * 2.0 + p.y * 0.35 + tt * 0.3); float w1 = tn(wq), w2 = tn(wq * 2.1 + 3.1); float outer = 0.0; if (edge > 0.01) { float rid = 1.0 - abs(2.0 * tn(vec3(cp * 0.22 + (vec2(w1, w2) - 0.5) * 0.1, lr * 9.0 + p.y * 0.6 + (w2 - 0.5) * 1.2)) - 1.0); float strands = pow(rid, 4.0); float silk = pow(0.5 + 0.5 * sin(lr * 70.0 + w1 * 8.0 + w2 * 5.0), 3.0); float tear = smoothstep(0.32 + 0.25 * edge, 0.72, w1 * 0.55 + w2 * 0.3 + strands * 0.4); outer = (strands * 1.3 + 0.04) * tear + silk * 0.75 * (0.35 + 0.65 * tear) * smoothstep(0.25, 0.6, w1); } float radial = smoothstep(RIN * 0.9, RIN * 1.2, rr) * smoothstep(ROUT * 0.92, ROUT * 0.45, rr + (w1 - 0.5) * 2.0); float gas = radial * vert * mix(inner, outer, edge); // 深色尘埃带,主要在中圈和外圈 float dust = radial * vert * smoothstep(0.52, 0.72, w2 * 0.68 + w1 * 0.32) * smoothstep(RIN * 1.4, RIN * 2.6, rr) * (1.0 - 0.5 * edge); // 温度、减弱的多普勒增亮(朝我们来的一侧更亮)和引力红移 float T = pow(RIN / rr, 0.75) * pow(max(1.0 - sqrt(RIN * 0.9 / rr), 0.0), 0.25) * 2.0; float v = sqrt(0.5 / rr); vec3 vel = normalize(vec3(-p.z, 0.0, p.x)) * uSpin * v; float gam = 1.0 / sqrt(1.0 - v * v); float dop = 1.0 / (gam * (1.0 - dot(vel, -normalize(rd)))); float g = mix(1.0, dop, 0.6) * sqrt(max(1.0 - 1.0 / rr, 0.0)); float temp = T * g; vec3 c = mix(vec3(0.82, 0.3, 0.1), vec3(1.0, 0.56, 0.36), smoothstep(0.05, 0.4, temp)); c = mix(c, vec3(1.0, 0.86, 0.74), smoothstep(0.55, 1.15, temp)); // 最热的地方是暖奶油色,不发白 float I = T * pow(g, 2.2) + 0.012 * edge; vec3 em = c * I * gas * 3.2 + vec3(0.42, 0.15, 0.05) * dust * I * 0.4; return vec4(em, gas * 2.6 + dust * 20.0); } void main(){ vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y - uOff; vec3 rd = normalize(uFwd + uFov * (uv.x * uRight + uv.y * uUp)); vec3 pos = uCam, dir = rd; vec3 hv = cross(pos, dir); float h2 = dot(hv, hv); vec3 col = vec3(0.0); float trans = 1.0; bool captured = false; // 每个像素在一步里的采样位置加一个固定的交错梯度抖动,消掉步进的折痕 float jit = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); for (int i = 0; i < 520; i++) { float r2 = dot(pos, pos), r = sqrt(r2); if (r < 1.0) { captured = true; break; } float rr = length(pos.xz), H = thick(rr); bool inRing = rr > RIN * 0.85 && rr < ROUT * 1.04; float dt = clamp(0.09 * (r - 0.9), 0.012, 0.9) * (r > 22.0 ? 2.0 : 1.0); if (inRing) { float above = abs(pos.y) - H * 3.2; if (above < 0.0) dt = min(dt, max(H * 0.72, 0.036)); // 盘里面:小步走 else dt = min(dt, max(above / max(abs(dir.y), 0.04) * 0.95, 0.03)); // 不会一步跨过盘 } vec3 acc = -1.5 * h2 * pos / (r2 * r2 * r); vec3 np = pos + dir * dt + 0.5 * acc * dt * dt; if (inRing) { vec3 mp = mix(pos, np, jit); vec4 e = disk(mp, dir, 1.0); if (e.a > 0.0) { col += trans * e.rgb * dt; trans *= exp(-e.a * dt); if (trans < 0.01) break; } } dir += acc * dt; pos = np; if (r > 40.0 && dot(pos, dir) > 0.0) break; } // 当背景用:吸积盘的光压一压,最亮处保持柔和的奶油色(颜色不变);在加星空之前做,太空还是黑的 float m = max(col.r, max(col.g, col.b)); col *= 0.62 / (m + 0.35); vec3 fd = normalize(dir); vec3 ddx = dFdx(fd), ddy = dFdy(fd); if (!captured) col += trans * (vec3(0.006, 0.005, 0.005) + trans * stars(fd, ddx, ddy)); 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 里也能直接调用 ---------------- */ /** 在 host 里铺满黑洞背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountBlackHole(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, 2); // 屏幕像素最多按 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 tall = canvas.width < canvas.height; // 高度和距离固定,镜头绕着黑洞慢慢转,黑洞的样子不会变 const az = 2.2 + t * 0.015, el = 0.095, dist = 28; const cam = [dist * Math.cos(el) * Math.cos(az), dist * Math.sin(el), dist * Math.cos(el) * Math.sin(az)]; const n = Math.hypot(...cam), f0 = cam.map((v) => -v / n); let R = [f0[2], 0, -f0[0]]; const rn = Math.hypot(...R); R = R.map((v) => -v / rn); const U = [R[1] * f0[2] - R[2] * f0[1], R[2] * f0[0] - R[0] * f0[2], R[0] * f0[1] - R[1] * f0[0]]; // 画面倾斜:吸积盘从左下往右上升起;竖屏时斜得更多,从一角斜到另一角,下半屏不空 const roll = tall ? -0.72 : -0.24, c = Math.cos(roll), s = Math.sin(roll); const R2 = R.map((v, i) => v * c + U[i] * s), U2 = U.map((v, i) => -R[i] * s + v * c); // 鼠标只让黑洞在画面里挪一点,形状不变 const off = tall ? [0.02 + (ptr.x - 0.5) * 0.04, -0.03 + (ptr.y - 0.5) * 0.04] : [0.24 + (ptr.x - 0.5) * 0.05, 0.05 + (ptr.y - 0.5) * 0.05]; // 选转动方向,让盘的近侧在屏幕上往右走:整体逆时针 const ln = Math.hypot(cam[0], cam[2]); const spin = (-cam[2] / ln) * R2[0] + (cam[0] / ln) * R2[2] > 0 ? 1 : -1; 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.uniform2f(scene.u.uOff, off[0], off[1]); gl.uniform1f(scene.u.uSpin, spin); gl.uniform3fv(scene.u.uCam, cam); gl.uniform3fv(scene.u.uFwd, f0); gl.uniform3fv(scene.u.uRight, R2); gl.uniform3fv(scene.u.uUp, U2); gl.uniform1f(scene.u.uFov, tall ? 0.74 : 0.52); draw(); post.end({bloom: 0.8, threshold: 0.95, exposure: 0.75, vignette: 0.35, grain: 0.012, time: t, sat: 1.15}); }, 8); return () => { stop(); ptr.off(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function BlackHole({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountBlackHole(ref.current!), []); return