














解读对象:
https://github.com/norio/speedball-gi
解读结构:① 名称/作者/作用/背景 ② 安装与使用 ③ 工作流程与执行步骤 ④ 文件逐个解读 ⑤ 优势/不足/创新点
系列序号:本文为仓库源码解读系列第 16 篇。
| 项 | 内容 |
|---|---|
| 仓库名 | speedball-gi(npm 包名 speedball-gi-sample,版本 0.1.0) |
| 标题 | SPEEDBALL GI WebGPU Showcase |
| 作者 | norio(GitHub 用户 norio) |
| 许可证 | MIT(LICENSE 文件) |
| 类型 | 交互式 Three.js WebGPU 演示应用(Vite 构建) |
| 语言 | TypeScript/JavaScript(ESM,"type": "module") |
这是一个实时动态漫反射全局光照(DDGI,Dynamic Diffuse Global Illumination) 的 Three.js WebGPU 展示项目。它基于上游 speedball-gi(作者 cl0nazepamm,仓库 cl0nazepamm/speedball)这一 WebGPU DDGI + 局部反射库,搭建了三个互补的演示场景,用来直观呈现"世界空间探针体积(probe volume)如何对世界空间内的几何与光源做实时、可交互的间接光照":
speedball-gi 进一步用 BVH 追踪(three-mesh-bvh) 做世界空间探针光线追踪,使其无需预烘焙。cl0nazepamm/speedball 提供了 DDGI 实现与 Sponza 演示;norio 在此基础上用 Vite 重写入口,并新增两个聚焦测试场景 + 一套共享的 GI/后处理辅助模块 + 资源优化管线,作为"官方 Sponza 演示(WebGPU DDGI + Sky)适配 Vite"的样板。speedball-gi 提供 WebGPU DDGI 与局部反射实现;@perplexdotgg/bounce,世界空间间接光照用 SPEEDBALL GI。| 依赖 | 版本/要求 | 说明 |
|---|---|---|
| Node.js | ^20.19.0 或 >=22.12.0 |
package.json 的 engines.node |
| npm | >=11.10.0 |
engines.npm |
| 浏览器 | 支持 WebGPU 的浏览器 | Chrome/Edge 121+、Firefox Nightly |
| 安全上下文 | 开发用 localhost,托管用 HTTPS |
WebGPU 要求 secure context |
cwebp / dwebp(libwebp) |
可选 | 仅 npm run optimize:sponza 资源重建时需要 |
| WebGPU storage 特性 | 必需 | speedball-gi 依赖;无 WebGL 回退 |
"dependencies": {
"@perplexdotgg/bounce": "^1.9.0", // 刚体物理(球池)
"lil-gui": "^0.21.0", // 调试 GUI
"speedball-gi": "^0.6.7", // WebGPU DDGI + 局部反射核心库
"stats-gl": "^4.2.3", // 性能统计面板
"three": "0.185.1", // Three.js(固定版本)
"three-mesh-bvh": "0.9.14" // GI 用到的 BVH 追踪
},
"devDependencies": { "vite": "^8.2.1" }
git clone https://github.com/norio/speedball-gi.git
cd speedball-gi
npm ci # 锁定版本安装
npm run dev # 启动 Vite 开发服务器(默认端口 5173)
打开 Vite 打印的 URL(通常 http://localhost:5173/speedball-gi/)即进入 Sponza。其余两个场景在同一源下:
/speedball-gi/simple.html(Afterimage Court)/speedball-gi/ballpool.html(Ball Pool)应用内左上角 HUD 也提供了三个场景的互相跳转链接。
| 命令 | 说明 |
|---|---|
npm run dev |
启动 Vite 开发服务器(端口 5173) |
npm run build |
用 Vite 把三个 HTML 入口构建进 dist/ |
npm run preview |
本地预览已构建产物(需先 build) |
npm test |
用 node --test 校验 Sponza WebP 清单与 30 MiB 负载预算 |
npm run optimize:sponza |
用 cwebp/dwebp 把已还原的 JPEG/PNG 源集转 WebP、去重、更新 glTF 清单(需 libwebp) |
每次推送到 main,GitHub Actions(deploy-pages.yml)会 npm ci → npm test → npm run build,把 dist/ 部署到 GitHub Pages(项目路径 /speedball-gi/),在线演示地址:https://norio.github.io/speedball-gi/。
G 对比"仅直接光照 vs GI";Court 文件夹调太阳强度与曝光;共享 GI/Post 文件夹。G 对比;"drop 20 balls" 立即重置一批。README 与三套入口代码都强调:installSpeedballGI() 必须在首次渲染或 renderer.setAnimationLoop() 之前调用。原因是 SPEEDBALL GI 会在 lit 材质编译前注入一个"GI 感知的 Three.js lights 节点",若渲染循环启动后才安装,缓存的非 GI lights 节点无法被替换,GI 永远接不进来。
每个场景的运行时流程:
GLTFLoader 加载 public/Sponza/glTF/Sponza.gltf;Court 用 simple_scene.js 程序化生成;Ball Pool 用 ballpool_scene.js 物理世界)。gi.requestRebuild() 请求探针场重建(自动拟合包围盒)。gi.update({ playing: false })。renderPipeline.render() 渲染。
playing: false并非关闭 GI 或暂停球池物理,而是标记"场景不在播放时间线",让 SPEEDBALL GI 的空闲门控(idle gate)把繁重的 BVH 构建与 GPU 求解推迟到相机/GUI 交互静止后再进行——从而绝不会在交互中途出现帧卡顿。
天空网格、探针辅助体、诊断反射体等都通过 excludeFromGI() 排除在 GI 追踪之外,避免它们撑大自动包围盒或污染自身光照。
color、depth、velocity、view-space normals。因 TRAA 已提供抗锯齿阶段,画布 MSAA 被显式关闭(
new THREE.WebGPURenderer({ canvas, antialias: false }))。
src/main.js 为例)#c 画布与 #status 状态元素,注册 error/unhandledrejection 全局监听以显示错误浮层。WebGPURenderer(antialias:false),设 DPR、size、ACES Filmic 色调映射,await renderer.init();随后检查 renderer.backend.isWebGPUBackend !== true → 非 WebGPU 直接报错退出。Scene、PerspectiveCamera(62°, …)、OrbitControls(带阻尼)。SkyMesh(excludeFromGI)+ DirectionalLight(带阴影)+ 微弱 HemisphereLight 兜底。关键:updateGiSky() 把无太阳的 SkyMesh 穹顶近似为 CPU 端 9 系数球谐(SH-9),只给探针核 9 个 vec3 uniform,避免纹理采样/导数/探针内存;方向太阳光保持 direct-only。gi = installSpeedballGI({ renderer, scene, camera, enabled, intensity, divisions, hysteresis, roughReflections:true, reflectionIntensity, reflectionSkyFallback:true }),随后 gi.setSmoothness(1)、gi.setFilterStrength(1)、applyGiSettings(gi, params)、updateGiSky()、refreshSunConsumers()。createProbeHelpers(scene, gi, params) 拿到 updateProbeHelpers 回调;addGiPanel(...) 注入共享 GI 面板(不持久化到 Sponza);addPostPipeline(...) 注入 GTAO/TRAA/Bloom 并返回 { renderPipeline, aoPass, traaPass }。GLTFLoader.load(sponzaUrl, …),遍历 mesh 设阴影并调用 prepareSponzaMaterialForGi()(把金属度 >0.5 归一到介电 metalness=0、粗糙度 <0.85 提到 0.85,让漫反射反弹可读);加入场景后按包围盒调整阴影相机与天空缩放;添加一个纯金属诊断球(excludeFromGI,便于观察局部反射);最后 gi.requestRebuild()。setAnimationLoop):可选帧率上限 → controls.update() → 金属球上下浮动 → 若 sunGiDirty 则 flushSunGiRefresh() → gi.update({ playing:false }) → updateProbeHelpers() → renderPipeline.render() → stats.update()。ballpool_scene.js 用 @perplexdotgg/bounce 建立 World(重力 -9.81、速度/位置求解迭代、linearDamping/angularDamping),创建六面墙(白墙 + 左红墙 + 右绿墙)与按视口宽算出的实例化球体(InstancedMesh,按颜色分组,DynamicDrawUsage)。step(deltaSeconds)(推进物理)→ syncMeshes()(把刚体位置/朝向写回实例矩阵,睡眠或微小位移则跳过以省开销)→ gi.update({ playing:false }) → 渲染。pointermove 用 Raycaster 与 frontPlane 求交得到灯光目标位置;移动时 pushAlongRay() 沿射线把半径内小球施加线性冲量;按住指针(触屏双指)则持续 respawnBalls() 把球送回顶部。灯光位置做指数缓动跟随指针。ballPool.rebuild(camera.aspect) + gi.requestRebuild() 重建。scripts/optimize-sponza-textures.mjs:把已还原的 JPEG/PNG 源集用 cwebp 转 WebP(基础色 quality 82、法线/金属粗糙度 quality 95、alpha 掩码无损),用 SHA-256 对逐字节相同的法线贴图去重,更新 glTF 的 EXT_texture_webp 扩展与 textures[].extensions.source,删旧 JPEG/PNG,并统计引用负载。tests/sponza-assets.test.mjs:node --test 跑两组断言——(1)Sponza 仅引用 WebP(校验 EXT_texture_webp、纹理映射 SHA-256、alpha MASK 图像数=3、各 WebP 尺寸 1024²/white.webp 4²、WebP 资产总 SHA-256、无遗留 legacy 纹理);(2)引用负载 ≤ 30 MiB(校验单一 buffer 的 SHA-256 与大小、累加 gltf + buffer + 全部图片字节数)。预期哈希硬编码进测试,作为"已验证内容"的契约。| 文件 | 作用 | 模块 |
|---|---|---|
package.json |
项目元数据、engines、5 个 scripts、依赖(speedball-gi/three/@perplexdotgg/bounce/lil-gui/stats-gl/three-mesh-bvh)、devDep vite |
构建/依赖 |
package-lock.json |
锁定依赖树(CI 用 npm ci) |
构建 |
vite.config.js |
base: '/speedball-gi/';build.rollupOptions.input 把 index.html/simple.html/ballpool.html 三个入口都打进 dist/;resolve.dedupe:['three']、alias 'three/addons';optimizeDeps.include 预打包 WebGPU/物理/GI 相关包 |
构建(多页) |
.npmrc |
npm 配置(如 engine-strict 等,随仓库) |
构建 |
.gitignore |
忽略 node_modules、dist 等 |
工程 |
LICENSE |
MIT 许可证 | 法律 |
README.md |
项目总览、Highlights、三场景说明、Requirements、Getting started、Controls、集成原理、渲染管线、限制、脚本、部署、Sponza 资产、致谢 | 文档 |
dependabot.yml |
依赖自动升级(如把 @perplexdotgg/bounce 1.8.1→1.9.0 的 PR) |
工程 |
assets/sponza-ballpool.webp |
README 用的 Sponza + Ball Pool 合照 | 文档资产 |
| 文件 | 作用 | 模块 |
|---|---|---|
index.html |
Sponza 入口页:<canvas id="c">、左上 #hud(标题 + 场景导航到 simple.html/ballpool.html)、#status 状态层、<script type=module src=/src/main.js> |
Sponza 入口 |
src/main.js |
Sponza 主程序(见 §3.3):渲染器/WebGPU 检查、SkyMesh+太阳、Sponza glTF 加载与材质归一化、动画金属球、installSpeedballGI、共享 GI/Post 面板、Display 面板、空闲门控、每帧 gi.update({playing:false}) |
Sponza 核心 |
simple.html |
Afterimage Court 入口页(结构与 index.html 类似,引 src/simple.js) |
Court 入口 |
src/simple.js |
Court 主程序:渲染器/WebGPU 检查、installSpeedballGI、createSimpleScene()、OrbitControls、Court 文件夹(太阳强度/曝光)、G 键切换 GI 开关(gi.setEnabled)、每帧 gi.update({playing:false})、共享 GI/Post 面板、HUD 显示 LIVE/BUILDING/OFF 状态 |
Court 核心 |
src/simple_scene.js |
程序化"余像庭院"几何:standard() 材质工厂、box() 造盒、addColonnade() 十柱、addRoofBeams() 横梁、addSculptures()(漫反射弹力球、钴蓝环面、局部反射金属球 excludeFromGI);红墙(oxide)/绿墙(viridian) + 石膏柱 + 中央步道 + 天窗方向光 + 半球光。createSimpleScene() 返回 { root, sun } |
Court 场景生成 |
ballpool.html |
Ball Pool 入口页(引 src/ballpool.js) |
球池入口 |
src/ballpool.js |
球池主程序:渲染器/WebGPU 检查、installSpeedballGI、黑底场景、fitCameraToBox()、createBallPoolScene(scene).rebuild(aspect)、跟随指针的 PointLight(投影)、共享 GI/Post 面板、Ball Pool 文件夹(灯光强度/"drop 20 balls")、G 键切换、pointerdown/move/up 交互、resize 时重建、setAnimationLoop 中缓动灯光 + respawnBalls/pushAlongRay/step/syncMeshes/gi.update/渲染 |
球池核心 |
src/ballpool_scene.js |
物理与实例化渲染世界(见 §3.4):World(bounce)、六面墙(addWall 同时建碰撞体 + 网格)、createBalls()(按 getBallCount(boxWidth) 体积算球数、InstancedMesh 按颜色分组)、rebuild(aspect)(按视口宽 getBallPoolWidth 重建)、step()、syncMeshes()(睡眠/ε 跳过优化)、respawnBalls(count)、pushAlongRay(origin,dir)(射线最近点 + 半径衰减冲量)、dispose()。导出常量 BALL_RADIUS/BOX_HEIGHT/BOX_DEPTH/CAM_FOV |
球池物理/渲染 |
| 文件 | 作用 | 模块 |
|---|---|---|
src/gi_settings.js |
共享 GI 控制面板的 Demo 辅助(非库的一部分)。GI_DEFAULTS(Sponza 调参 = 规范值)、localStorage 持久化(KEY='speedball-gi-settings-v2',类型安全合并,自适应时间混合键不恢复)、addGiPanel(gui, gi, params, {onInteract, onStructure})(为每个页面生成完全一致的 GI 控件:enabled/intensity/sky/reflections/showProbes、Grid 结构旋钮 divisions/rays/cascades、Temporal、Quality)、applyGiSettings(gi, s) 一次性把全部共享设置推入探针场 |
GI 设置 |
src/post_settings.js |
共享后处理管线(GTAO→TRAA→Bloom,均在色调映射前)。POST_DEFAULTS、addPostPipeline(gui, renderer, scene, camera, params):建 RenderPipeline + MRT pass(写 output/velocity/normal)、ao()(GTAO,可 useTemporalFiltering)、traa()(TRAA)、bloom(),用 uniform 控制 GTAO 混合与开关,applyPostOutput() 重组输出节点;返回 { renderPipeline, aoPass, traaPass, applyPostOutput } |
后处理 |
src/TRAANode.js |
本地 TRAA(时间重投影抗锯齿)实现:class TRAANode extends TempNode(导出 traa 工厂与默认类)。双 RenderTarget(history/resolve)、Halton 序列抖动(setViewOffset/clearViewOffset 与 renderPipeline 的 onBeforeRenderPipeline/onAfterRenderPipeline 钩子同步)、updateBefore() 每帧累加历史、setup() 用 TSL 实现 resolve:3×3 深度采样(最近/最远)、速度重投影、varianceClipping(方差裁剪,NVIDIA GDC2016)、subpixelCorrection、flickerReduction(Lottes max3 压缩 RGB 防闪烁)、AABB 裁剪(Playdead temporal)。尺寸变化时用 beauty 纹理重置历史避免黑屏渐变 |
抗锯齿(TRAA) |
src/probe_helpers.js |
可选探针网格可视化:createProbeHelpers(scene, gi, params) 返回 updateProbeHelpers()。当 params.showProbes 且 gi.hasData() 时,按 gi.getResolution()/getBounds() 用 InstancedMesh 在每格中心放小球;lit 模式下用 gi.node.sampleIrradiance(...) 把球染成实际辐照度颜色(直接看到探针采到的 GI),否则染灰;签名缓存避免每帧重建 |
探针调试 |
src/style.css |
HUD(左上无面板纯文字叠加 + 场景导航 + GI 状态 LIVE/BUILDING/OFF)、#status 加载/错误浮层、stats-gl 定位、移动端响应式(≤700px/≤560px 时 GUI 改为底部抽屉、隐藏副标题) |
样式 |
| 文件/目录 | 作用 | 模块 |
|---|---|---|
scripts/optimize-sponza-textures.mjs |
Sponza 纹理 WebP 化脚本(见 §3.5):检测现有 WebP 清单完整性、cwebp 转换(按角色分 quality)、SHA-256 去重、EXT_texture_webp 扩展注入、更新 Sponza.gltf、删旧图、统计负载 |
资源管线 |
tests/sponza-assets.test.mjs |
node --test 校验:① 仅 WebP 引用 + 纹理映射 SHA-256 + alpha MASK 数=3 + 尺寸 + WebP 资产总 SHA-256 + 无 legacy;② 单 buffer SHA-256 + 引用负载 ≤ 30 MiB。硬编码预期哈希作契约 |
质量门禁 |
public/Sponza/glTF/Sponza.gltf + Sponza.bin + 64×.webp |
打包的 Sponza 模型(Crytek→Frank Meinl/Marko Dabrovic,PBR 贴图来自 alexandre-pestana.com,MikkTSpace 切线、法线/金属粗糙度重打包、顶点/索引网格优化) | 3D 资产 |
public/Sponza/README.md |
模型来源、处理笔记(切线、alpha 掩码重采样、金属粗糙度合并、恒定漫反射系数 0.58、网格优化)、Web 交付优化说明、许可声明 | 资产文档 |
public/Sponza/screenshot/large.jpg、screenshot.jpg |
截图 | 资产 |
.github/workflows/deploy-pages.yml |
GitHub Pages 部署:push main 触发 → checkout → setup-node 24 → npm ci → npm test → npm run build → configure-pages → upload-pages-artifact(dist) → deploy-pages;含 concurrency 取消旧运行、permissions 最小集 |
CI/CD |
gi_settings.js 让三页共享完全一致的 GI 控件,且 GI 键 localStorage 持久化(类型安全合并,避免陈旧/手改数据注入 NaN)。node:test 用硬编码 SHA-256 与 30 MiB 预算做"内容契约"校验,保证线上资产可信且体积小。speedball-gi(npm ^0.6.7)与 three@0.185.1 决定,本仓库主要是"展示/适配/测试场景",核心算法不在本仓。TRAANode.js):把 three.js WebGPU 的 TAA 思路落地为本仓可控节点——方差裁剪(NVIDIA GDC2016)、子像素校正、Lottes 闪烁抑制、AABB 裁剪(Playdead temporal),并用 Halton 抖动 + renderPipeline 前后钩子做相机偏移同步;尺寸变化用 beauty 纹理重置历史防黑屏。这是本仓最"自研"的亮点代码。playing:false):把繁重 BVH 构建/GPU 求解推迟到交互静止,从架构上消除"交互中途帧卡顿",是实时 GI 演示中很关键的体验设计。gi_settings.js/post_settings.js 抽离出"单一控制面",一处增减滑块即在三页同时生效;addGiPanel 的 onInteract/onStructure 回调把各页的交互/结构事件统一回灌 GI。probe_helpers.js 的 lit 模式直接采样 sampleIrradiance 把探针染成实际辐照度颜色,把抽象的探针场变成肉眼可读的调试信息。tests/sponza-assets.test.mjs 用硬编码 SHA-256 + 30 MiB 上限,把"资产正确性"变成可回归的 CI 门禁,配合 optimize-sponza-textures.mjs 形成可重建、可验证的资源闭环。vite.config.js 用 rollupOptions.input 把三个 HTML 同时打包、base:'/speedball-gi/' 适配 GitHub Pages 项目路径、dedupe:['three'] 避免多副本,体现成熟的前端工程实践。| 维度 | 选型 |
|---|---|
| 渲染 | Three.js 0.185.1(three/webgpu,WebGPURenderer,TSL 节点) |
| GI | speedball-gi ^0.6.7(WebGPU DDGI + local reflections,BVH 追踪 three-mesh-bvh 0.9.14) |
| 物理 | @perplexdotgg/bounce ^1.9.0(球池刚体) |
| GUI | lil-gui ^0.21.0 |
| 后处理 | 自研 TRAA(TRAANode.js)+ GTAONode + BloomNode(three/addons) |
| 构建 | Vite ^8.2.1(多页输入,base /speedball-gi/) |
| 测试 | Node.js 原生 node:test(tests/sponza-assets.test.mjs) |
| 资源 | libwebp cwebp/dwebp(WebP 化 + 去重) |
| 部署 | GitHub Actions → GitHub Pages |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。