AIGC标识 Expeditione 拆解:一个 1MB 的 3D 百科全书,和一个根本不存在的"3D 引擎"

起因是听说"有个很厉害的 3D 引擎框架叫 Expeditione"。

查证下来,这个引擎不存在 —— 但底下压着一个真东西,而且是个好东西。 这篇记录两件事:一是怎么用可复现的方式证伪一个"框架", 二是那个真东西在 draw call 上做的优化,为什么比框架选型更值得抄。

所有数字都是本机实测,工具是本仓库的 tools/probe-site.mjs。 有一条我没测到(土壤分册的 draw call),文里如实标了"未测"。


0. 先说结论

问题 答案
Expeditione 是什么? 一个交互式 3D 百科全书网站:expeditione.fun
是引擎吗? 不是。 它是 three.js 的使用方,不是框架本身
能 npm install 吗? 不能。 三个源上都是 404
有 GitHub 仓库吗? 没有引擎仓库。 只有一个放美术素材的
开源吗? 不开源,而且是主动不开源。 详见第 7 章
用什么渲染? WebGL2(不是 WebGPU)
谁做的? Aureon de Veyra,单人,印度
值得看吗? 值得,但值得看的是它的资源管线方法论,不是它的架构

一句话:你要找的"框架"不存在,你要学的东西在它的 Blender 操作步骤里。


1. 先证伪:它在三个源上都不存在

听到"很厉害的框架",我的第一反应不是去看官网 —— 官网当然说自己是框架。 第一步是查分发渠道。一个框架要有东西让人装,装的地方无非是包管理器或代码仓库。

1.1 npm 上查空

# 三个源都查,因为公司内网走 Nexus 代理,可能和公网不一致
$regs = @(
  'https://registry.npmmirror.com/expeditione',
  'http://<nexus>/nexus/content/groups/npm-public/expeditione',
  'https://registry.npmjs.org/expeditione'
)

结果:

=== https://registry.npmmirror.com/expeditione ===          404
=== http://<nexus>/.../npm-public/expeditione ===           404
=== https://registry.npmjs.org/expeditione ===              404
=== npm search "expeditione" ===                            total=0

包管理器搜索 total=0,是比 404 更强的信号:不只是这个包名没被占用,而是连名字沾边的包都没有。

1.2 GitHub 上查空

$h = @{ 'User-Agent' = 'Mozilla/5.0'; 'Accept' = 'application/vnd.github+json' }
Invoke-WebRequest "https://api.github.com/search/repositories?q=expeditione+engine&per_page=10" -Headers $h
q=expeditione           -> total_count=35
q=expeditione+engine    -> total_count=0     ← 关键
q=expeditione+3d        -> total_count=0     ← 关键

那 35 个仓库里,唯一相关的只有一个:

aureon-de-veyra/expeditione-press   stars=0   "Official downloadable media assets for Expeditione."

注意这个描述:media assets(美术素材)。它是给记者发图的 press kit,不是代码。 作者自己的 GitHub 上都没有引擎 —— 因为本来就没有引擎。

1.3 域名查空

假消息里经常出现一个"看起来更正规"的域名。顺手把所有 TLD 都探一遍:

foreach ($d in @('expeditione.fun','expeditione.com','expeditione.org','expeditione.dev','expeditione.io')) { ... }
expeditione.fun    DNS=OK [172.67.134.246,104.21.25.253]  HTTP=200   ← 真站(Cloudflare)
expeditione.com    DNS=OK [13.248.169.48,76.223.54.146]   HTTP=200   ← 但是 114 字节的空页
expeditione.org    不存在 / 无法解析
expeditione.dev    不存在 / 无法解析
expeditione.io     不存在 / 无法解析

expeditione.com 那个 HTTP 200 很唬人,但 bytes=114、<title> 为空、正文为空。 它是个注册了但没内容的地皮。 后面会看到,有个假消息正好就让读者去这个域名注册账号。


2. 那它在浏览器里到底是什么?

证伪了"框架",接下来要看它实际是什么。这一步不能读它的自我描述,得看它加载了什么。

2.1 它请求的是哪种图形上下文

在页面脚本执行之前,劫持 HTMLCanvasElement.prototype.getContext,记录页面到底要了哪种上下文:

await page.evaluateOnNewDocument(() => {
  const orig = HTMLCanvasElement.prototype.getContext
  HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
    window.__probe.contexts[type] = (window.__probe.contexts[type] || 0) + 1
    return orig.call(this, type, ...rest)
  }
})

结果:

落地页    页面请求的上下文类型: {"webgl2":2,"2d":2}
古埃及    页面请求的上下文类型: {"webgl2":3,"2d":2}
土壤      页面请求的上下文类型: {"webgl2":2}
navigator.gpu 存在: true   (存在不代表用了)

这是全文最干净的一条证据。

同一台机器上,我们前面测 vgpu 的时候 navigator.gpu 是真的能拿到 adapter 的 ({"vendor":"intel","architecture":"gen-12lp"})。也就是说这个浏览器支持 WebGPU, 但 Expeditione 一个 webgpu 上下文都没请求过。

顺带说个自我校准:上面每个数字里有 1 个是我自己的探测器建的 (探 GPU 型号时会 createElement('canvas').getContext('webgl2'))。 扣掉之后:落地页 1 个真上下文,古埃及 2 个。仪器的存在会改变读数,这个必须自己交代。

2.2 它加载了什么脚本

从 CDP 的 Network.loadingFinished 拿到压缩后的真实字节:

193.0 KB  [Script] https://expeditione.fun/assets/three-Aq26VQAk.js
 51.1 KB  [Script] https://expeditione.fun/assets/supabase-Dbr-1ZRQ.js
 36.1 KB  [Script] https://expeditione.fun/assets/gsap-BAq08fny.js

文件名把话说完了:

  • three-*.js —— three.js。它就是个 three.js 项目
  • gsap-*.js —— 动画库,和作者说的技术栈一致
  • supabase-*.js —— 后端,用来存社区投票

另外我还探了一遍常见全局变量:

for (const k of ['THREE','gsap','TWEEN','BABYLON','Cesium','PIXI','anime','Lenis','barba']) { ... }
// => 探测到的全局库: {}

全是空。因为它是 Vite 打的 ESM 包,什么都不挂 window。 这也说明"探全局变量"这种手段在 2026 年基本失效了 —— 得看网络请求。


3. 怎么量 draw call(以及一个非常漂亮的假数字)

这一节是方法论,因为我在里面踩了两个坑,两个坑都值得说。

3.1 钩子必须比页面脚本更早

Draw call 是 WebGL 的函数调用。要数它,就得替换 WebGLRenderingContext.prototype.drawElements 之类。 关键在于时机 —— 如果用 page.evaluate 装钩子,页面早就建好上下文开始画了,你什么都采不到。

必须用 evaluateOnNewDocument:

await page.evaluateOnNewDocument(() => {
  const bump = (kind) => {
    window.__probe.dc.total++
    window.__probe.dc.byKind[kind] = (window.__probe.dc.byKind[kind] || 0) + 1
  }
  const wrap = (proto, names) => {
    for (const n of names) {
      const orig = proto[n]
      if (typeof orig !== 'function') continue
      proto[n] = function (...a) { bump(n); return orig.apply(this, a) }
    }
  }
  const DRAW_NAMES = ['drawArrays','drawElements','drawArraysInstanced','drawElementsInstanced','drawRangeElements']
  // WebGL2RenderingContext 并不继承 WebGLRenderingContext,两个原型都要包
  wrap(window.WebGLRenderingContext?.prototype, DRAW_NAMES)
  wrap(window.WebGL2RenderingContext?.prototype, DRAW_NAMES)
})

注意 WebGL2RenderingContext 不继承 WebGLRenderingContext。 只包一个会漏掉一半调用。(这跟我之前在着色器里栽的那个 cross 手性问题属于同一类: 两个东西看起来是一套,其实各算各的。)

3.2 光有总数没有意义,要按帧切

总调用次数除以总帧数,会被"静止时不重绘"的帧稀释成一个毫无意义的平均值。 正确做法是按 requestAnimationFrame 分帧,每帧结束时记一次累计值,相邻两帧相减:

const origRAF = window.requestAnimationFrame.bind(window)
window.requestAnimationFrame = function (cb) {
  return origRAF(function (ts) {
    try { return cb(ts) }
    finally {
      // 放在 finally 里:即使回调抛异常,这一帧的账也要记上
      window.__probe.dc.frames.push({ ts: Math.round(ts), total: window.__probe.dc.total })
    }
  })
}

然后取最后 180 帧的差值排序取中位数 —— 中位数而不是平均值,就是为了不被那些 0 帧带偏。

3.3 坑一:我量出了"0 次 draw call / 120 fps"

第一次跑古埃及分册,输出是这样的:

draw call 总计 48 次,采到 2143 帧;每帧中位数 = 0
实测帧率: 120 fps

这两行数字放一起看,是个灾难性的误导。

  • 120 fps 听起来很棒 —— 但那只是一个空转的 rAF 循环,页面根本没在画东西
  • "总计 48 次"来自开场动画,之后场景停在"点击进入"的门口,一帧都没画

原因很简单:Expeditione 的古埃及分册有一道门 —— 先选向导,再看开场,才进场景。 不点进去,你测的是一个静态页面。

这跟我之前在 vgpu-lab 里栽的"假成功"是同一个病: 当时画廊报"亮度 20.5 方差 32.9 有内容 ✓",其实那是画廊自己的报错卡片。 空转的指标比没有指标更危险,因为它长得像好消息。

修法:给探针加 --click <文字>,先穿过门再开始统计,并且在穿门后把计数器清零:

await page.evaluate(() => {
  const p = window.__probe
  p.dc.total = 0; p.dc.byKind = {}; p.dc.frames.length = 0
})

清零点很重要 —— 否则你统计的是"开场动画 + 场景"的混合体。

3.4 坑二:按钮文字是 CSS 转的大写

加完 --click 之后第一次还是失败:

⚠ 没找到含「Begin Expedition」的可点元素

进去一看截图 —— 门上的按钮写的是 SELECT GUIDE,不是我猜的 Begin Expedition。 而且照着截图写 --click "SELECT GUIDE" 照样匹配不到,因为 那几个大写字母是 CSS text-transform 渲染出来的,DOM 里存的是 Select Guide。

修法:比较前统一小写并压缩空白。

const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()

还有一个连带问题:我最初点的是"含关键词的最小元素", 结果命中的是 <button> 里的内层 <span>,那里没有事件处理器,点了等于没点。 改成点可点击祖先才对:

const inner = cands[0].el                                    // 最小的含关键词元素
const target = inner.closest('button, a, [role="button"]') ?? inner   // 真正的处理器在这

最后,因为门是多级的(选向导 → 主故事线 → 开场),加了个"继续"循环, 按 begin / continue / start / enter / explore / next / skip 依次找可点的东西往下走。

跑通之后:

已点击 <BUTTON>「select guide」
  继续: 已点击 <BUTTON>「continue main story →」
  继续: 已点击 <BUTTON>「skip intro」
进入后已录得 draw call 25464 次
计数器已清零,开始统计进入场景后的绘制

4. 实测数据

4.1 总表

落地页 古埃及 · The Delta 土壤分层
传输体积(压缩后) 1298.8 KB 6137.9 KB 4294.1 KB
请求数 46 94 271
图形 API WebGL2 WebGL2 WebGL2
draw call 总计 6615 40416 未测
采样帧数 1035 1684 未测
活跃帧 draw call 9 48 未测
实测帧率 60 fps 112.6 fps 未测
测试窗口 20.6 s 66.2 s 22.4 s

古埃及那 6137.9 KB 包含了我的脚本连续穿过三道门、跨场景加载的量, 不能直接和作者说的"每册约 4MB"对比。土壤那册是老实加载完的数字。

4.2 "9 次 draw call" 我独立复现了,一字不差

作者在 three.js 论坛的标题就是 "How I packed an Interactive 3D Encyclopedia into 1MB and 9 Draw Calls"。 我量到的:

draw call 总计 6615 次,采到 1035 帧;每帧中位数 = 9
调用构成: {"drawElementsInstanced":735,"drawElements":5880}

把这两个数字除一下:

5880 / 735 = 8.0  次 drawElements
 735 / 735 = 1.0  次 drawElementsInstanced
            ─────
            合计 9.0

735 × 9 = 6615,严丝合缝。 1035 帧里有 735 帧在画,每一帧都正好 9 次调用, 方差为零;另外 300 帧是静止不重绘的 0。

这个数字的干净程度本身就是证据:只有当管线被彻底规整过,才会出现零方差的整数。

4.3 古埃及:48 次

draw call 总计 40416 次,采到 1684 帧;每帧中位数 = 48
调用构成: {"drawElements":29470,"drawElementsInstanced":8420,"drawArraysInstanced":1684,"drawArrays":842}

同样除一下:

29470 / 842 = 35.0  次 drawElements
 8420 / 842 = 10.0  次 drawElementsInstanced
 1684 / 842 =  2.0  次 drawArraysInstanced
  842 / 842 =  1.0  次 drawArrays
             ─────
             合计 48.0

又是零方差。842 × 48 = 40416。这个人是真的把管线焊死了。

但这里有一条对不上,如实标出:

作者 2026-04-10 在论坛回复里说古埃及"约 30 次 draw call"。我量到的是 48。

我没法断定谁对谁错,合理的解释至少有三种:

  1. 他那句话是 4 月说的,古埃及 8 月才发布 —— 期间场景长大了
  2. 我只测了第一幕(The Delta),后面几幕的数字可能不同
  3. 口径不同(比如他算的是"不含阴影 pass"或"不含 UI")

所以这一条我的结论是"对不上",不是"他说错了"。 差别很大。

4.4 体积:两个宣传数字都属实

  • "落地页 ~1MB" → 实测 1298.8 KB(1.27 MB)。 其中 161.7 KB 还是个 favicon.ico,扣掉之后约 1.11 MB。属实。
  • "每册 ~4MB" → 土壤实测 4294.1 KB(4.19 MB)。属实。

落地页的体积构成特别值得看:

Image       327.7 KB  25.2%
Script      317.2 KB  24.4%    ← 其中 three.js 一个就 193 KB
Fetch       241.1 KB  18.6%    ← glb 模型 + draco 解码器
Other       161.7 KB  12.4%    ← favicon.ico ???
Media       125.7 KB   9.7%    ← opus 音频
Font         89.9 KB   6.9%

一个整页 1MB 的 3D 站点,有 193KB 是 three.js 自己,有 161.7KB 是 favicon。 那三个 demo 模型加起来才 142.5 KB。

这件事的讽刺在于:"极致优化"的成果里,引擎和图标吃掉了 27%。 优化做到最后,能优化的其实只有你自己那部分 —— 上限是别人定的。

4.5 顺手测到的

  • GPU:ANGLE (Intel, Intel(R) Iris(R) Xe Graphics ..., D3D11) —— 和我这台机器同款集显。60 fps / 112 fps 是集显跑出来的
  • Draco 解码器:落地页从自己域名加载(82.2 KB),土壤分册从 www.gstatic.com 加载(86.9 KB)。作者说的 gltf-transform draco 属实
  • 音频:.opus 格式,落地页 86 KB,古埃及场景内单曲 198 KB。是真做了音频设计的,不是摆设
  • 第三方:static.cloudflareinsights.com 10.2 KB(无 cookie 统计)+ Supabase(社区投票)

5. 它真正厉害的地方:168 → 35 → 9

以下方法论来自作者在 three.js 论坛的技术帖(原文), 我复现了他的终值(9),过程没法验证,但步骤本身是可操作的。

5.1 第一阶段:实例化(168 → 35)

场景里的动态植被(藤蔓、叶子)靠自定义顶点着色器做风吹摇曳。直接导出会拖死渲染器。 他没有在代码里救,而是在 .glb 进 three.js 之前就把它重构掉:

gltf-transform instance raw.glb temp.glb && gltf-transform draco temp.glb opt.glb

几十个相同网格被折叠成 instanced mesh。这就是我实测里那 1 次 drawElementsInstanced 的来源。

5.2 第二阶段:Material Purge(35 → 9)—— 这才是真坑

这一段是全篇最有价值的内容。 原文写得很直接:

The deep engine trap that most devs fall into is that the glTF exporter slices your geometry based on Material Slots. 1 Baked Texture + 10 Old Blender Materials = 10 separate Primitives = 10 Draw Calls in Three.js. Even if you override the material in Three.js later, the geometry is already fractured.

翻译过来就是:

glTF 导出器按材质槽切几何体。1 张烘焙图集 + 10 个残留材质槽 = 10 个图元 = 10 次 draw call。 就算你事后在 three.js 里覆盖材质也没用 —— 几何体已经被切碎了。

还有个更阴的地方,他也点破了:

Texture Atlasing alone does NOT fix this. Most devs bake a single atlas but leave the original material slots on their meshes. When that happens, the glTF exporter still slices the geometry, ruining your draw calls.

只烘焙图集是不够的。 很多人辛辛苦苦烘了一张图集,材质槽一个没删,导出器照切不误 —— 你以为你合并了,其实一点没合并。这是那种"做了优化但数字没动"的典型场景,最耗人心态。

他的做法(五步,可以直接抄):

  1. Bake —— 把最终场景烘焙到单张图集(1k 或 2k)
  2. Purge —— 回 Blender,把静态网格上每一个材质槽都删掉
  3. Dummy —— 建一个"主材质",关联到所有要合并的对象(或者干脆不挂材质)
  4. Join & Export —— Ctrl+J 合并所有静态对象,导出 .glb。 因为导出器只看见一个材质槽,它会把所有顶点粘成一整块
  5. Three.js —— 加载后遍历子节点,套上烘焙好的 MeshBasicMaterial

他的算式:

导出器看见 1 个材质槽 → 1 个图元 → 1 个 Mesh → 正好 1 次 draw call

对照我实测的落地页每帧构成 —— 8 次 drawElements + 1 次 drawElementsInstanced = 9 —— 那 8 次就是"Purge 过的静态基座 + 少量必须分开的部件",1 次是实例化的植被。完全对得上。

5.3 为什么这条对做数字孪生的人更有用

因为这不是 three.js 的问题,是 glTF 格式 + 导出器的问题。

CesiumJS 的 3D Tiles 底下也是 glTF/b3dm。同样一个模型,材质槽碎不碎, 决定了它在 Cesium 里是 1 次 draw call 还是 12 次。 如果你的倾斜摄影 + BIM 模型合并后帧率上不去,先别怀疑渲染器 —— 用 Spector.js 之类的东西数一下 draw call,然后回建模软件看材质槽。

工具链不一样,坑是同一个。


6. 解剖一条假消息

查证过程中撞到一篇 dev.to 的葡语文章, 标题是《Expeditione:AI + WebGL 打造交互式 3D 百科全书》。

它把一个真实项目,写成了另一个不存在的东西。

它说 实际
去 expeditione.com 注册账号,送 5 个免费模型 真站是 .fun;官网 FAQ 明说"无需登录";.com 是 114 字节空页
有 cdn.expeditione.com/models/coliseum.glb 这种模型 CDN 不存在,我探过
有 Stable Diffusion + DreamFusion 的自动建模 API 作者用的是 Blender 手工建模(官网 FAQ 里专门否认了 AI 生成)
"在 Reddit 和 Hacker News 上爆火" 见下
Gartner 74% 消费者偏好交互体验 / Grand View 38% CAGR / 内部研究 27% 转化 三个数字都查不到出处
照着它的代码片段,能从 cdn.expeditione.com 加载 coliseum.glb 那个域名下的东西一件都不存在

6.1 关于"爆火",我去查了

HN 有官方 API,不用猜:

Invoke-WebRequest 'https://hn.algolia.com/api/v1/search?query=Expeditione&tags=story'
[2026-03-09] pts=2  comments=2   I packed an Interactive 3D Encyclopedia into 1MB
[2026-09-08] pts=4  comments=0   Ancient Egypt in 3D

2 分、4 分。 那不是爆火,那是沉底。

我不是在嘲笑这个项目 —— 单人作品没上 HN 首页太正常了。 我是在指出那篇文章在编造它的传播数据。

6.2 一条硬证据:结尾的落款

文章最后一行是:

_Herramienta mencionada: GitHub Copilot_

一篇葡萄牙语文章,结尾混进了一句西班牙语。

这是模板跨语种复用的痕迹 —— 至少说明这篇的产出流程里,有一段是西班牙语的。 这种"语言串味"是识别模板化内容最省事的信号之一,比读完全文去猜靠谱得多。

6.3 那用户可能是从哪看到的

更可能的来源是 CSDN 一篇《Expeditione 使用指南》。 这篇基本准确(体积、draw call 数字、技术栈都对得上),但技术栈那一行写的是:

- Three.js — 3D渲染引擎

"引擎"两个字大概就是从这类句子串味的。 那说的是 three.js 是引擎,不是 Expeditione 是引擎。 中间隔着一层,但中文排版里这一层很容易被读没。

另有一个同名混淆源:游戏 《Clair Obscur: Expedition 33》,那个用的是 Unreal Engine 5。 搜"Expeditione"的时候它会出现。


7. 开源吗?能下载源码吗?

这是查证任何项目时第二个必问的问题(第一个是"它是什么")。 答案分两层:授权上不开放,技术上够不着。

7.1 授权层面:主动不开放

先看作者账号本身:

Invoke-WebRequest 'https://api.github.com/users/aureon-de-veyra/repos?per_page=100' -Headers $h
仓库总数: 1
  expeditione-press    lang=(空)    license=NONE    size=1KB    fork=False
       Official downloadable media assets for Expeditione.
查什么 结果
作者的公开仓库数 1 个
那个仓库的 license 字段 NONE(无许可证 = 保留所有权利)
仓库内容 1 KB,给媒体发图用的美术素材
/LICENSE /LICENSE.txt /LICENSE.md /license 四个路径全 404
站内 GitHub 链接 一个都没有(页脚只有 X / Discord / Reddit / LinkedIn)

授权页写得很直:

Please don't modify, redistribute, or republish Expeditione without written permission. —— Rights & Distribution / "Respect the work."

他连白标都不给:"I don't offer white-label licensing or rebranding."

但真正的证据不是这些 —— 是他专门为此配了文件。

# https://expeditione.fun/robots.txt
# Expeditione - Intentional Asset Protection
Disallow: /static/models/
Disallow: /static/textures/
# https://expeditione.fun/ai.txt        ← 这个文件本身就很少见
# AI Governance for Expeditione
# Using assets (models, sounds, code) for generative AI training
# or model imitation is restricted.

ai.txt 里点名了 code。 robots.txt 的标题直接叫"Intentional Asset Protection"。

所以这条的结论必须说准:

这不是"作者忘了加许可证",是主动选择不开放。 两种情况对使用者的含义完全不同 —— 忘加许可证的项目可以直接去问; 明确声明保护的,问了也是"不"。

(顺带一个配置疏漏:Disallow 写的是 /static/models/ 和 /static/textures/, 但实测素材实际在 /landing/models/、/expeditions/ancient-egypt/scene-1/models/、/textures/soil/ —— 规则没覆盖真实路径。 不过这点值得立刻补一句: robots.txt 从来不是授权文件,它只约束爬虫,不授予任何人任何权利。 所以这个疏漏不改变上面任何一条的法律状态。这是该反馈给他本人修的东西,不是别人的机会。)

7.2 技术层面:能拿到什么,拿不到什么

它是个网页,所以已发布的 JS 你永远下得到 —— 这是 Web 的固有属性,不是漏洞。 但"下得到"和"有源码"是两件事。实测:

① 没有 sourcemap。

/assets/main-8YlodzUY.js.map    -> 404
/assets/egypt-BlmtaP-u.js.map   -> 404
/assets/soil-AtrdzvQF.js.map    -> 404
/assets/three-Aq26VQAk.js.map   -> 404

四个 chunk 全试了,.map 一个都不存在;JS 里也没有 sourceMappingURL 引用。 不是没引用,是根本没部署。

② 拿到的是压缩产物,不是源码。

chunk 大小 行数 平均每行 最长一行
egypt-BlmtaP-u.js 595,457 字节 1,966 行 303 字符 42,715 字符
main-8YlodzUY.js 29,502 字节 96 行 307 字符 8,787 字符

平均每行 300 字符、最长一行四万字符 —— 变量名被整体重命名,注释、类型、原始文件结构全没了。 你可以读,但读的是"结果",不是"怎么想的"。

③ 最关键的:Blender 源文件根本不在客户端。

.glb、贴图、音频都是服务端下发的产物。你拿不到分层文件、材质节点、烘焙设置 —— 而那恰恰是"168 → 9"整件事发生的地方。 第 5 章那五步里,四步在 Blender 里。

所以"下载源码"这件事,在技术上就够不着。 不是他藏得好,是那些东西从来没下发过。

7.3 一个值得知道的技术事实:着色器是明文的

压缩工具重命名的是标识符,不会动字符串字面量。 而 GLSL 在打包产物里就是字符串。我在 egypt chunk 里数了一遍:

void main        x26        varying          x37
uniform          x58        gl_FragColor     x16
gl_Position      x12        #include <       x22
onBeforeCompile   x6        ShaderMaterial    x0

两个结论:

  1. GLSL 源码逐字保留 —— 那套让植被随风摆动的顶点着色器就完整躺在里面
  2. ShaderMaterial 是 0,onBeforeCompile 是 6,还有 22 处 #include < —— 他是往 three.js 自带材质里注入代码,不是另起一套 ShaderMaterial。 这跟作者说的"光照全烘焙 + 自定义顶点着色器做摆动"严丝合缝, 也解释了为什么没有实时灯光还能有明暗

我故意没有把着色器内容贴出来。 贴出来正好就是这个授权明确禁止的 redistribute。 上面那些计数已经足够证明"能不能拿到",不需要真的复制一份。 证明一个东西可获取,和把获取到的东西传播出去,是两回事。

7.4 想学它,走正门其实更好走

作者自己把方法完整写出来了 —— 第 5 章那套 168 → 35 → 9 就出自 three.js 论坛帖, 结尾那句就是他自己划的线:

"Hopefully, this helps anyone else out there who is wondering why their baked scene is somehow generating 100+ draw calls."

方法公开,资产不公开。 这条线划得很清楚,也划得很合理。

而且整条工具链本来就是开源的:

工具 许可证 用途
three.js MIT 渲染
GSAP 标准免费许可 动画编排
gltf-transform MIT 实例化 + Draco
Draco Apache-2.0 几何压缩
Blender GPL 建模与 Material Purge

你需要的每一件工具都能合法拿到,你需要的方法他自己公开了。 唯一拿不到的是他的美术资产 —— 而美术资产本来也不是你想复用的东西。


8. 顺手发现的几个问题

8.1 它自己有个 CSP bug

控制台稳定报两条错:

Loading a manifest from 'https://expeditione.fun/site.webmanifest' violates the following
Content Security Policy directive: "default-src 'none'". Note that 'manifest-src' was not
explicitly set...

CSP 设了 default-src 'none'(很严,值得表扬),但忘了给 manifest-src 开口子, 结果自己的 PWA manifest 加载失败。

这是个典型的"安全策略收得越紧,越容易打到自己"的 bug。 影响很小(PWA 装不了而已), 但说明 CSP 上线前没有在真实浏览器里看过控制台。

8.2 "不留痕"这个说法有一点点水分

官网页脚的徽标是 Privacy-First(图标文件名就叫 no-cookie.svg), FAQ 里的原话是"without having their data tracked"。实际请求里有两个第三方:

static.cloudflareinsights.com    10.2 KB   ← Cloudflare Web Analytics
pxaxxlshaqnullsufcex.supabase.co  3.7 KB   ← 社区投票的后端

公平地说:Cloudflare Web Analytics 是无 cookie 的,Supabase 那点是用户主动投票才发的。 两者都不和"永不出售数据"冲突,也和 no-cookie 这个字面表述不冲突。

但"data not tracked"严格讲是过了 —— 无 cookie ≠ 无统计。 服务端照样知道你来了、从哪来、看了多久。 这是营销语言的常规松弛,不是欺骗,记一笔即可。

8.3 llms.txt 里有一条技术描述不准确

这个站在根目录放了 llms.txt(给 AI 读的自我介绍),其中一行:

- **Core Technology**: Three.js, GSAP, Blender, WebGL/WebGPU.

WebGPU 这半边不成立。 第 2 章实测过三种页面, getContext 记录里 webgpu 一次都没出现,全是 webgl2。

公平起见要说另一半:同一份 llms.txt 里那句 "a strictly enforced 9 draw calls",我实测过了,一字不差。

所以这不是"整份文件都在吹",是其中一条写飘了。 比较可能的解释是他把"将来可能上 WebGPU"写成了现在时 —— 但读者没法区分这两种意思,这就是技术描述写飘的代价。

8.4 一条方法论:自我描述也要交叉验证

上面 8.1 / 8.2 / 8.3 三条,本质是同一件事: llms.txt、ai.txt、robots.txt、FAQ、授权页,全都是"自我描述"。

自我描述的价值很高(ai.txt 直接给出了授权立场,省了我很多猜测), 但它必须和实测对账:

它自己说 实测
9 draw calls ✓ 属实(零方差)
~1MB 落地页 ✓ 属实(1298.8 KB)
每册 ~4MB ✓ 属实(4294.1 KB)
古埃及 ~30 draw calls ✗ 对不上(实测 48)
WebGL/WebGPU ✗ 实测只有 WebGL2
no cookies / data not tracked ◐ 无 cookie 属实,无统计不属实

六条里三条完全属实、一条口径不明、两条写飘了。 对一个单人项目来说,这个准确率其实相当高 —— 但如果不实测,你没法知道哪三条是真的。


9. 复盘:这篇真正的收获

9.1 证伪一个"框架",只需要查三个地方

这次整个证伪过程不到十分钟,而且不需要读一行它的代码:

  1. 包管理器 —— npm search 的 total=0 比 404 更致命
  2. 代码仓库 —— 搜 <名字> + engine / <名字> + 3d,看 total_count 是不是 0
  3. 域名全 TLD —— 假消息里的域名常常是空壳,bytes 会暴露它

顺序很重要:先查分发渠道,再看官网。 官网的定义权在它自己手里,分发渠道的定义权不在。

9.2 "空转的指标"比没有指标更危险

0 draw call / 120 fps 这个组合,我第一次看到时差点当成好消息。 它和之前 vgpu-lab 里那个"亮度 20.5,✓ 有内容"是完全同构的错误:

一个什么都没做的循环,看起来和一切正常一模一样。

两次都是同一个修法:在断言里加上"到底挂了什么"这一项。 前者加了 window.__vgpuLab.mountedId 断言,后者加了 --click 穿门 + 计数器清零。

指标必须永远和一个"这东西应该是谁"的断言绑定。 否则它只是个数。

9.3 仪器的存在会改变读数

我探 GPU 型号那一步会自己创建一个 WebGL2 上下文, 所以 {"webgl2":2} 里有 1 个是我的。如果不主动扣掉,我就会多报一次上下文创建。

这类自污染在测量里是常态。报数字的时候要连"仪器占了几个"一起报。

9.4 但最关键的一条是:不要因为项目小,就以为它的技术含量低

这个项目的"传播数据"确实很惨(HN 2 分),宣传上确实有水分, 周围确实围了一圈 AI 生成的假消息。

但它的 draw call 是真的 9。

零方差的 735 × 9 —— 这个数字比大部分融资千万的"3D 可视化平台"都干净。 一个人用 Blender + three.js + gltf-transform 做到的。

传播热度和技术密度是两件事,而且经常反向。


10. 附:工具与复现

10.1 工具

tools/probe-site.mjs —— 第三方 3D 网站体检,本仓库内:

# 基本用法:url + 采集毫秒
node tools/probe-site.mjs https://expeditione.fun/ 15000

# 穿过"点击进入"的门再统计
node tools/probe-site.mjs https://expeditione.fun/expeditions/ancient-egypt/ 15000 --click "select guide"

输出:

  • 传输体积(CDP encodedDataLength,压缩后真实字节),按类型 / 域名分组 + Top 12 大文件
  • 页面请求的图形上下文类型(劫持 getContext)
  • 每帧 draw call 中位数 + 调用构成
  • 实测帧率(rAF 时间戳跨度算,不是数次数)
  • 自动存一张截图到 shots/,作为"这 9 次 draw call 画出来的是什么"的证据

10.2 已知限制(别把它当万能)

  • 只统计 WebGL。 如果页面用 WebGPU 或 WebGL1,当前钩子会得出"0 次",这是个假阴性
  • 穿门靠猜文字。 --click 是启发式,多级门要靠后面的"继续"循环撞,撞不到就测了个静态页
  • draw* 次数 ≠ 渲染耗时。 9 次超重的 draw call 也能跑满 GPU。它衡量的是管线规整程度,不是性能
  • 会被自己的探测器污染(每次 +1 个 webgl2 上下文)

10.3 参考

真站与官方文档

技术帖与报道

错误信息的样本(仅供对照)


本文所有数字均为本机实测(Chrome 154 / Intel Iris Xe / Node v22.19.0), 文中提到的"未测"项如实标注,对不上的数字给出了可能的解释而不是结论。 涉及他人作品的章节,只报告"可获取性"的实测结果,不转载其代码与资产。

posted on 2026-10-08 19:38  fox_charon  阅读(2)  评论(0)    收藏  举报

导航