“O哥”手把手教学,复刻GPT6酷炫动效!

如何快速抄袭别人酷炫的网页效果? 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。

位置的算法:三个目标位置,链式插值

整个效果的骨架就是两行 mixmix(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把它抽象成一个技能或者经验文档。有了这个文档,垃圾模型,应该也能搞定这类复刻了!

不管你看没看懂,看到这里就点个赞吧。让我知道多少人坚持看到了最后!

相关推荐
宋哥转AI1 小时前
深入理解 AI Agent · Agent 安全威胁全景与四层纵深防御
人工智能·agent·ai编程
外域速览1 小时前
GPT-6 Astra 开放、标普罕见拉响 AI 基建信用警报、三星 Arm 联手 2nm 端侧芯片:算力下半场的三条主线
arm开发·人工智能·gpt
yunwei371 小时前
ACRFence:防止 AI Agent 检查点恢复中的语义回滚攻击
人工智能·安全·架构
思考着亮1 小时前
9.Wiki 和 Rag 如何如何选择?
人工智能
luobing43651 小时前
新书推荐-《AI辅助芯片制造:算法与工程化落地》
人工智能·制造
deepseek231 小时前
Anthropic开源Commerce Agents:购物与商户智能体如何把审批写进工具链
人工智能·ai agent·mcp
程序员cxuan1 小时前
Claude Fable 5.1 的提示词又被扒了
人工智能·后端·程序员
residual_fan1 小时前
深度渐进收缩学习(DPSL):面向强噪声与类内分散的机械复合故障诊断方法
人工智能·算法·数据挖掘·数据分析