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。
我没法断定谁对谁错,合理的解释至少有三种:
- 他那句话是 4 月说的,古埃及 8 月才发布 —— 期间场景长大了
- 我只测了第一幕(The Delta),后面几幕的数字可能不同
- 口径不同(比如他算的是"不含阴影 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.com10.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.
只烘焙图集是不够的。 很多人辛辛苦苦烘了一张图集,材质槽一个没删,导出器照切不误 —— 你以为你合并了,其实一点没合并。这是那种"做了优化但数字没动"的典型场景,最耗人心态。
他的做法(五步,可以直接抄):
- Bake —— 把最终场景烘焙到单张图集(1k 或 2k)
- Purge —— 回 Blender,把静态网格上每一个材质槽都删掉
- Dummy —— 建一个"主材质",关联到所有要合并的对象(或者干脆不挂材质)
- Join & Export ——
Ctrl+J合并所有静态对象,导出.glb。 因为导出器只看见一个材质槽,它会把所有顶点粘成一整块 - 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
两个结论:
- GLSL 源码逐字保留 —— 那套让植被随风摆动的顶点着色器就完整躺在里面
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 证伪一个"框架",只需要查三个地方
这次整个证伪过程不到十分钟,而且不需要读一行它的代码:
- 包管理器 ——
npm search的total=0比 404 更致命 - 代码仓库 —— 搜
<名字> + engine/<名字> + 3d,看total_count是不是 0 - 域名全 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 参考
真站与官方文档
- 真站:https://expeditione.fun/
- 授权与商用条款:https://expeditione.fun/licensing.html
robots.txt("Intentional Asset Protection"):https://expeditione.fun/robots.txtai.txt(点名code不得用于训练):https://expeditione.fun/ai.txtllms.txt(自我描述,含那条 WebGPU 不准确项):https://expeditione.fun/llms.txt- 作者 GitHub(1 个仓库,
license=NONE):https://github.com/aureon-de-veyra
技术帖与报道
- 作者技术帖(168→35→9 的原始出处):three.js forum #90075
- 社区报道:webgpu.com showcase
错误信息的样本(仅供对照)
- ★ 假消息样本(不要照着做):dev.to 葡语文章
- 基本准确但用词有歧义的中文源:CSDN 使用指南
- HN 官方检索 API:查 Expeditione 的真实热度
本文所有数字均为本机实测(Chrome 154 / Intel Iris Xe / Node v22.19.0), 文中提到的"未测"项如实标注,对不上的数字给出了可能的解释而不是结论。 涉及他人作品的章节,只报告"可获取性"的实测结果,不转载其代码与资产。
posted on 2026-10-08 19:38 fox_charon 阅读(2) 评论(0) 收藏 举报
浙公网安备 33010602011771号