如何快速抄袭别人酷炫的网页效果? Opus5手把手教学来了!

昨天我分别让GPT6,GLM5.3,Opus5去复现了GPT6官网酷炫的3D星系动效。
只有Opus5真的是独立完成了!
我非常好奇它是如何做的,以及如何克服困难的。 尤其拦住GPT6的403,它是如何绕过去的!
学会它的思路之后,我们以后就可以轻松抄袭别人的效果了。
文章有一定的难度,看不懂很正常,就把笔记给Agent 就行了!
我让O哥整理了一份笔记,它是直接做成了网页!

它做了一个名为"我没有从像素逆推,我读了它们的源代码"的网页!
网页开头它就提到了:
真正决定成败的那一步技术含量最低:把他们的 JS 源码拿到手。 剩下的是判断哪些东西被压缩工具改没了、哪些原样留着, 以及看懂这个效果真正靠的是什么。
这就是高端的食材,往往用最简单的烹饪手法。天下文章一大抄,关键是看怎么抄。有的人抄都抄不会,有的人一看就会了。
为了便于我和大家理解,我让O哥先做了一些名词解释 !
先看全貌 · 名词与主线
六个词先约定!

图片可能看的不是很清楚,下面我把文字单独列出来!
这几个词后面会一直用。它们本身不难,只是平时不常在中文语境里出现, 先花两分钟约定一下,后面就不用再停下来解释了。
分包 chunk:
打包工具把整站的 JS 切成的几十上百个小文件,浏览器按需下载。 这个页面加载了 95 个,我要找的东西藏在其中 5 个里。
着色器 shader:
跑在显卡上的小程序,对每个图元并行执行。分两种: 顶点着色器 决定「这个点画到屏幕的哪个位置、多大」, 片元着色器决定「这个点覆盖的每个像素是什么颜色」。
uniform:
从 JS 传进着色器的参数,同一帧里所有点读到的值都一样 。 滚动进度就是通过它送进去的,名字一般以 u 开头。
attribute:
每个点各自独有的数据:初始坐标、亮度、闪烁相位...... 在显存里一颗星占一份,从头到尾不改写。
贴图 texture:
显卡上的一张图。但它也常被当成只读数组用来存任意数值------ 这个效果里,「6」的曲线和目标图形都是以贴图的形式喂给着色器的。
离屏缓冲 FBO:
让显卡把结果画进一张贴图而不是画到屏幕上,下一帧再读回来。 在 GPU 上做迭代计算(比如物理模拟)的标准手法。
一帧四步的数据流!从滚动条到屏幕,一帧只发生四件事!

我的复刻流程同样是四步,跟这条线一一对应: ①拿到源码 → ②抄几何与着色器 (第三步那一环)→ ③把滚动接上去 (第一、二环)→ ④对着官方基准图调辉光(第四环)。 下面两部分就是这两条线的展开。
P1:怎么把代码拿到手
第一步:先看它给不支持 JS 的浏览器留了什么?
打开页面,DOM 里 canvas(画布,网页上做 3D 的那块区域)数量是 0 。 说明效果是懒加载的------相关脚本要等页面滚到附近才下载执行; 而且我这边有几个分包返回了 403(服务器拒绝)。
这时候正常人会去截图硬看。但 <noscript>(浏览器禁用 JS 时才显示的备用内容) 里躺着一整段降级方案,包括一张预渲染的封面图和它的替代文字:
ini
alt="A luminous particle spiral forming
the number six against a black background."
class="AstraHero-module___Ja0lG__poster"
三件事一次到手:模块叫 AstraHero、图形是一个由粒子构成的「6」 、 还有一张官方渲染的成品图。同一段 DOM 里还有个按钮, aria-label(给读屏软件用的说明)写着「拖拽或用方向键旋转星场」------交互方式也交代了。
无障碍属性是逆向的第一手资料:它们的存在意义就是诚实描述画面,所以不会含糊其辞。
PS:O哥是懂废物利用的!
第二步<fetch()>: 403 是拦 curl 的,不是拦页面的!
用 curl(命令行下载工具)去拉分包,直接 403;伪装浏览器标识和来源地址也没用。 但在页面自己的上下文里 ------也就是打开这个网站,在它的控制台里------ 执行 fetch() 就是 200:
csharp
// 在目标页面的控制台里跑
const t = await (await fetch(chunkUrl)).text();
区别在于这次请求是同源的(从这个网站自己的页面发出),带着完整的会话 cookie 和真实浏览器指纹,服务端认定这是页面在加载自己的资源,没有理由拒绝。
这一步之后所有事情都是顺理成章的。它也是整件事里唯一一个真正的「诀窍」, 而它跟图形学没有半点关系。
第三步 :在 95 个分包里找地址,而不是读代码!
把页面上所有 <script src> 全下载一遍,然后按关键词筛。 这一步不需要理解任何一行代码,只需要知道往哪看:
gl_Position → 顶点着色器的输出,标志着色器在这
gl_PointSize → 说明是「点精灵」:一个顶点画成一个小方块
ShaderMaterial → three.js 的自定义材质,确认用的是 three.js
astra → 上一步从 CSS 类名里捡到的模块名
viewBox → SVG 的坐标系声明,说明形状以 SVG 存着
95 个分包里,4 个命中 astra,其中 1 个同时命中 gl_PointSize------ 就是它了。这些搜索词全是从第 1 步的 CSS 类名和 DOM 结构里白捡的, 这就是为什么先读 noscript 很划算。
第四步<e.A(130010)>:顺着模块编号往下挖懒加载链!
第一个命中的分包里只有编排逻辑,真正的渲染器被动态 import 挡住了------ 代码写的是「等要用的时候再去下载编号 130010 的模块」,而不是直接写出文件名。
但打包工具必须把「编号 → 文件名」的映射表留在运行时能读到的地方,于是它就在同一个文件里:
javascript
let A = dynamic(() => e.A(130010), { ssr: !1 })
// 同一个文件里往下翻,就能找到这一行:
130010, e => e.v(t => Promise.all(["chunks/2jgpexr4u1zu_.js"]) ...)
搜数字 130010 就能拿到文件名,把新文件再下载下来重复一次,两轮就见底了: 一个分包装几何与着色器,一个装每帧的滚动映射,一个装渲染器和后期链 (画面渲染完之后再统一加工的一串效果,这里是辉光和色调映射)。
模块编号一定是明文的,因为运行时自己也得靠它查表------这是懒加载绕不开的代价。
第五步<poster.webp> :找一张基准图,把「像不像」变成可判定的
第 1 步捡到的那张封面图是官方自己渲染的成品,托管在图片 CDN 上,没有任何反爬。 下载下来当参照,复刻的每一版都能直接对比:星臂卷曲的圈数、核心亮团的大小、 冷色星和暖色星的比例、辉光扩散的半径。
没有基准图的话,「差不多了吧」是个说不清的判断;有了它,偏差是看得见的。 我唯一一次改动实现(换掉 three.js 自带的辉光)就是对着它才发现问题的。
真正的红利:压缩工具不动字符串!
线上代码都经过压缩 (minify):删空格、删注释、把长变量名换成单字母, 目的是让文件更小。变量名因此全没了,function generateAstraField 变成了 function g,读起来确实费劲。
但是------写视觉效果的代码,信息量最大的部分恰好全是「字面量」: 直接写死在代码里的字符串和数字。Terser(业界最常用的 JS 压缩工具)不会去动字符串的内容, 不会改数字常数,也不会重命名对象的键名。于是压缩之后,数学全须全尾地留在原地:

换句话说:逆向前端视觉效果,难的从来不是「看懂」,是「拿到」。 拿到之后,你面对的是一份没有变量名、但公式完整的论文。
PS:O哥对这个问题的认识很透彻!
P2:这个效果真正靠的是什么
核心前提: 滚动条不移动任何一颗星
最容易走弯路的地方在这里。直觉做法是「滚动的时候把粒子从 A 挪到 B」, 于是你开始写补间动画、管理动画状态、处理用户中途反向滚动的打断。原站不是这么干的。
滚动只产出一个 0 到 1 的数 ,传给顶点着色器。每一帧,每颗星都在着色器里 当场算出「我此刻该在哪」------没有状态,没有补间,没有打断处理。
用编程的话说,这是个纯函数 :同样的滚动位置永远得到同样的画面。 所以你把滚动条拖回去,画面完全可逆,不会出现「散开动画播到一半又被聚拢动画抢走」这种事。 几何体的 position 属性从头到尾没被改写过,CPU 每帧只更新十来个 uniform。
位置的算法:三个目标位置,链式插值
整个效果的骨架就是两行 mix。 mix(a, b, t) 是着色器里的线性插值 : t=0 取 a,t=1 取 b,中间按比例混合。理解了这两行,剩下的都是修饰。

两次插值就是全部。散开在前、聚形在后------顺序即优先级: 形状那一步在最后,会盖掉散开的结果,所以经过锚点时星星必然聚拢, 不用写任何「此时应该听谁的」的判断。
ini
vec3 p = samplePath(progress); // 先取「6」曲线上的点
p = mix(p, scattered, uScrollPositionProgress); // 滚动 → 散开
p = mix(p, shapePos, uPathShapePositionProgress); // 锚点 → 聚形
为什么绕一层贴图:形状存在贴图里,不是存在顶点属性里
「6」的曲线明明可以在 CPU 上算好,直接写进每颗星的 position 属性------ 为什么要多绕一层贴图?
因为星星要沿着曲线流动 。position 是 attribute,一颗星一份、固定不变; 而贴图可以按任意参数随时重新采样。给每颗星一个「我在曲线上的进度」, 再加一个每帧递增的全局偏移,星星就顺着星臂缓缓漂移起来了。
具体做法:把曲线上 512 个点存进一张 512×1 的浮点贴图, 顶点着色器里读两个相邻点、再 mix 一下,就完成了任意精度的曲线求值。 顺带白拿了切线方向------前后各读一个点相减就是切线, 转 90° 就是法线,用来把星星向曲线两侧散开一点,星臂才有粗细。
省显存的关键:「随机」不是随机数,是每颗星自己的哈希
散开位置需要给每颗星一个随机的三维坐标。多存三个数?没必要------ 直接从这颗星已有的属性 算一个哈希(把任意输入打散成一个看起来随机、 但输入相同时结果必定相同的数):
scss
float sx = fract(sin(dot(vec2(orbitProgress, twinklePhase),
vec2(127.1, 311.7))) * 43758.5453);
省显存只是次要好处。真正重要的是确定性:同一颗星,无论散开多少次、 从哪个方向滚回来,落点永远是同一个。这让「散开」和「聚拢」互为逆运算------ 来回拖滚动条时,星星是原路返回,而不是每次重新抽一遍位置(那样看起来会像画面在闪、在重新洗牌)。
有意思的是 CPU 端算主星位置时也要用同一套哈希,所以原站在 JS 里复刻了一份 Math.sin(t * a + e * b) * 43758.5453,并且用 Math.fround() 把精度从 64 位降到 32 位------就为了和 GPU 算出同一个数。 这种细节猜不出来,只能读。
银河感的来源:两个正弦,把一条线变成一条星臂
均匀撒在曲线上的点看起来像一串项链,不像星系。两处扰动把它救回来:
scss
// 1) 沿曲线的密度衰减:中段挤、两头疏
t = t + 0.22 * sin(2π·t) / 2π;
// 2) 垂直于屏幕方向的起伏:正面看是数字,一转就有厚度
z = sin(t·π·1.35 + phase) * depth * sin(t·π);
第二式末尾那个 sin(t·π) 是包络------一个从 0 升到 1 再降回 0 的系数, 用来约束整条曲线两端的起伏必须归零、只让中段鼓出来。 所以 5 条曲线在端点仍然对齐成一个干净的「6」,只有腰部错开,看上去才有立体的厚度。
5 条曲线各有不同的起伏幅度(.62 / −.46 / .78 / −.7 / .42)和相位, 正负交替,于是有的星臂朝前有的朝后。再叠上尺寸包络和端点淡出, 星臂就有了「从虚到实再到虚」的走向。
最巧的一处设计:一张贴图装 6 条互不相连的曲线
目标图形(比如 OpenAI 那个结绳)由 6 条独立的曲线组成,但只有一张 1024 点的贴图。 如果一颗星在整条贴图上自由取样,它就可能从第 3 条的末尾跑到第 4 条的开头------ 两条本来不相连的曲线之间会被连出一道直线,图形立刻糊掉。
解法藏在贴图的通道里。贴图每个点有 RGBA 四个通道,这里全被挪用来存坐标而不是颜色 : 前两个(xy)存归一化坐标,后两个(zw)存「这个点属于哪一段」的起止范围。

采样本身用的是浏览器自带的 path.getPointAtLength(): 按累计弧长 (沿着曲线走过的实际长度,而不是数学参数)把 6 条路径等距切成 1024 份, 边界落在哪一段就记哪一段。不需要任何几何库。
一份着色器两用:鼠标推动粒子,全程没有一次 CPU 循环
4642 颗星逐颗算受力,主线程会被拖垮。原站把它整个搬进显卡: 每颗星在一张 128×N 的贴图里占一个像素 , 用它的四个通道存 (位移x, 位移y, 速度x, 速度y)。 两张这样的贴图轮流当读写方(这一帧读 A 写 B,下一帧读 B 写 A), 因为显卡不允许同一张贴图同时被读和被写。
精妙的地方是:算物理的那一趟和画到屏幕的那一趟,用的是同一个顶点着色器 , 只靠一个编译期开关(#ifdef,编译时决定哪段代码生效)切换出口:

不做逐帧积分:用公式一步算出结果,所以静止时零开销
粒子被推开后要慢慢滑回原位。常规做法是逐帧积分 :每一帧根据当前速度更新一点位置、 再让速度衰减一点,一帧一帧逼近。问题是这要求每帧都跑一遍模拟,哪怕鼠标一动不动。
原站直接写出了这个阻尼系统的解析解------一个闭合的公式, 输入「距上次受力过了多久」,一步就能算出此刻的位移,不需要中间的每一帧:
scss
float drag = 2.3 / sqrt(mass);
state.xy = state.xy * exp(-age)
+ state.zw * (exp(-age) - exp(-drag*age)) / (drag - 1.0);
state.zw *= exp(-drag*age);
于是物理那一趟只在有新的鼠标推力时才跑,其余时间连贴图都不用重写------ 着色器拿着旧状态加上「过了多久」,当场就能算出正确的当前值。
质量取自星星的尺寸(大星质量 2.4、小星 0.65),所以大星沉、小星轻, 鼠标扫过时自然分出层次。
抗闪烁:不到一个像素的星星怎么画
远处的星星算出来只有半个像素大。如果按常规画一个圆盘,它会因为落在像素网格的不同位置 而忽明忽暗、疯狂闪烁------这是粒子系统「廉价感」最常见的来源。
解法是不画形状,画覆盖率 :不去问「这个像素在不在圆里」, 而是算「这颗星的光有多少落进了这个像素」。用一个平滑的数学核函数 (三次 B 样条,一个常用于图像重采样的平滑权重曲线)解析地积出这个比例, 再乘以直径的平方------这一步保证总光量不随尺寸变化, 星星变小时是变暗,而不是凭空消失或突然跳亮。
scss
float alpha = mix(
astraFilteredCore(pixel, 0.1509), // 小于 2px:算覆盖率
max(disc, rays), // 大于 4px:圆盘 + 十字星芒
smoothstep(2.0, 4.0, diameter)); // 2~4px 之间平滑过渡
十字星芒只给最亮的那一小批星(亮度 > 1.45)。 辉光的触发阈值之所以能压到 0.08 这么低还不糊, 靠的就是绝大多数星星本身足够暗,根本够不着这个门槛。
P3 · 如果你要从零做一个

参考实现来自 openai.com 公开的前端产物。着色器代码、SVG 路径数据、默认参数表按原样还原, 演示页仅作技术说明用途,与 OpenAI 无关。文中所有数值------4,642 颗星、512 / 1024 个采样点、 −52° 倾斜角、
0.08辉光阈值------均来自实测或源码常量,未作估算。
大概就是这个样子,我全程没有修改任何内容!
主要是我也不知道怎么改!
作为一个技术贴,O哥已经写的很好了!
我虽然没有看全懂,但是这是一个非常扎实的技术贴!
用了AI之后,我遇到的最大的一个问题,或者说最让我难以接受的问题是:技术分享还有意义么?
希望有吧!tokens不能白烧啊 !
如果大家喜欢看这一类,记得告诉我,我可以多写写!
如果你看懂了,那是你厉害!
如果你没看到了,可以让你的Agent把它抽象成一个技能或者经验文档。有了这个文档,垃圾模型,应该也能搞定这类复刻了!
不管你看没看懂,看到这里就点个赞吧。让我知道多少人坚持看到了最后!