从打开 HTML 到实时 3D 世界:游戏的加载、运行与画质原理全解析
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
一个文件,装下引擎、场景、音效和玩法。本文以游戏页面为例,拆解浏览器如何加载这款游戏,以及程序化资源、实时渲染与域扭曲如何共同营造“3A 观感”。
最近微信群里面看到一个很有意思的项目(下载链接),是一个不到2MB,就实现了一些3A大作都没法弄好的小游戏,下面是我基于这个项目的一些见解
整体页面打开,眼前却出现港区建筑、第一人称武器、阴云与落日、湿润地面、战斗音效和任务提示——这些内容究竟从哪里来?
它是不是偷偷下载了一个大型游戏?单个网页为什么能调动显卡?启动界面的“加载”,又到底在加载什么?
先给出答案:这个文件并没有省略游戏引擎,而是把引擎与业务代码内嵌进 HTML;它也不是不需要资源,而是把大量资源改为运行时生成。浏览器执行游戏逻辑,显卡计算画面,HTML/CSS 叠加界面,最终形成一个完整的实时交互页面。
本文基于源码静态分析。没有进行浏览器抓包、设备兼容性测试或帧率实测;文中会区分实际实现、通用原理和待验证事项。“3A”指页面标题与视觉定位,不等于商业 AAA 制作规格。

一、技术全景:一个文件,不代表只有一种技术
1.1 HTML 只是容器,不是整个引擎
这份文件包含的技术可以分成以下几层:
| 技术或能力 | 通俗解释 | 本页面中的用途 |
|---|---|---|
| HTML | 页面的结构 | 画布、启动层、菜单、HUD 容器 |
| CSS | 页面的外观和布局 | 指南针、任务提示、准星、触摸控件及过渡动画 |
| JavaScript | 浏览器执行的程序 | 初始化、输入、场景构造、战斗与任务逻辑 |
| Three.js r180 | 封装三维图形操作的库 | 管理场景、相机、几何体、材质、光源与渲染器 |
| WebGL2 | 浏览器使用 GPU 绘图的接口 | 提交几何数据、纹理与绘制指令 |
| GLSL | GPU 上运行的着色器语言 | 材质、天空、云、阴影与后处理计算 |
| Canvas 2D | 在内存画布上绘图 | 生成标牌、编号、标线和部分图集 |
| Web Audio | 浏览器音频处理能力 | 本地合成和播放音效 |
| Pointer Lock | 鼠标指针锁定能力 | 连续控制第一人称视角 |
| localStorage | 浏览器本地键值存储 | 保存设置,不是远端存档服务 |
内嵌引擎的版本为 Three.js r180,即 0.180.0。这里没有依赖 Vue、Arco Design 等应用框架;界面主要由原生 HTML/CSS 与 JavaScript 配合完成。
引擎也不是 Unity 或 Unreal。Three.js 提供图形基础设施,而玩家、敌人、关卡与任务等游戏系统由这份文件中的业务逻辑实现。
1.2 CPU、GPU、界面层分别做什么?
代码 1|最小页面层叠结构(教学简化,非逐字原文)
下面保留页面结构与布局样式的核心关系,省略启动条内部元素和非布局样式:
<style>
#app { position: fixed; inset: 0; }
#view, #ui, #boot { position: absolute; inset: 0; }
#view { width: 100%; height: 100%; display: block; }
#ui { pointer-events: none; }
#boot { z-index: 50; }
#boot.done { opacity: 0; pointer-events: none; }
</style>
<div id="app">
<canvas id="view" tabindex="0"></canvas>
<div id="ui"></div>
<div id="boot">正在载入港区</div>
</div>
画布承担三维画面,后面的 UI 元素覆盖在同一视口上;启动层用较高的 z-index 遮住未准备好的画面。pointer-events: none 让 UI 容器默认不拦截指针,具体交互控件可另行覆盖这一规则。CSS 尺寸决定显示区域,但画布内部绘制分辨率仍由渲染器管理。
浏览器
│
┌─────────────┼─────────────┐
▼ ▼ ▼
JavaScript/CPU WebGL2/GPU HTML/CSS
输入与玩法逻辑 几何与像素计算 菜单与HUD
程序资源构建 材质与光影 文字与提示
状态与音频调度 后处理合成 界面动画
│ ▲ │
└─更新数据并提交绘制─────────┘
│
▼
浏览器合成最终页面
这是一张职责示意图,不是严格的线程图。浏览器和驱动会安排底层任务,不能把每个模块简单理解成一个独立线程。
二、如何打开:本地文件、静态服务与线上部署
2.1 方式一:直接双击 HTML
这份文件使用普通内联脚本,核心资源主要内嵌或程序生成,没有发现依赖外部模块导入和素材下载的初始化链。因此,可以首先尝试直接用现代 Chrome 或 Edge 打开本地文件。
此时地址栏使用 file:// 协议,浏览器从磁盘读取 HTML,而不是向游戏服务器请求页面。
但“可以尝试直接打开”不等于已验证所有浏览器配置:本地文件的存储策略、鼠标锁、音频权限及安全限制可能因环境不同而变化。
2.2 方式二:通过本地静态 HTTP 服务打开
开发、调试和检查 Network 时,更推荐使用稳定的本地 HTTP 地址。例如,使用编辑器的静态预览服务,或已有的静态文件服务器,将服务根目录指向文件所在目录。
打开服务地址下对应的 HTML 即可。这样做的目的包括:
- 获得明确、稳定的页面来源,而不是本地文件来源;
- 更接近正式部署环境;
- 方便查看请求、缓存、控制台与性能记录;
- 减少本地文件协议带来的行为差异。
静态服务只负责把 HTML 传给浏览器,不承担敌人 AI、战斗判定或画面计算。这些工作仍在客户端执行。
2.3 方式三:部署到静态网站
由于没有发现核心玩法依赖后端 API,部署时原则上只需要能提供该 HTML 的静态托管环境。正式部署建议使用 HTTPS,并保证服务器以正确的 HTML 内容类型返回文件。
主文件可以使用 HTTP 内容压缩与缓存来减少传输成本,但要注意:这是部署层的优化,不是当前源码已经证明配置好的功能。
如果把页面嵌入 iframe,还需检查宿主的沙箱与权限策略,尤其是鼠标锁相关限制。单独打开能运行,不代表放进任意嵌入容器后都能获得相同能力。
2.4 运行条件与首次交互
- 需要 WebGL2。实际渲染器不支持以 WebGL1 替代运行。
- 需要浏览器和显卡驱动允许创建可用的图形上下文;启用硬件加速通常更合适。
- 音频需要用户交互后解锁,打开页面不一定立即有声音。
- 第一人称视角会尝试获取鼠标锁;是否允许取决于浏览器与宿主环境。
- 有触摸输入与移动端布局,不代表所有移动设备都能承担高画质效果。
三、它到底请求了哪些资源?
3.1 核心资源清单
静态检查没有发现游戏业务初始化链下载外部模型、纹理图片或音频,也未发现外部游戏资源 URL。
| 资源 | 来源 | 是否发现运行时外部下载依赖 |
|---|---|---|
| Three.js 引擎 | 内嵌 JavaScript | 未发现 |
| CSS 与业务脚本 | 内嵌在 HTML | 未发现 |
| 场景、道具、武器与敌人 | 代码构造几何体与层级 | 未发现 |
| 材质纹理 | 程序噪声、GLSL 离屏生成 | 未发现 |
| 文字、标牌、道路标线 | Canvas 绘制 | 未发现 |
| 天空、云、太阳、闪电 | 自定义着色器 | 未发现 |
| 音效 | Web Audio 本地生成采样 | 未发现 |
| 字体 | 系统字体栈 | 未发现远程字体 |
| 玩法数据 | 客户端初始化与更新 | 未发现后端请求 |
| 设置 | localStorage | 不属于网络请求 |
“没有外部图片”不等于“没有纹理”。生成的 Canvas、随机数据或离屏渲染结果,依然可以作为纹理传给 GPU。
3.2 为什么代码里仍然有 fetch 和 URL?
Three.js 内部包含通用文件、图片和音频加载器,因而能看到 fetch()、图片 src 与 .load()。这只是库具备的能力,不足以证明游戏调用了它们下载资源。
另外,http://www.w3.org/1999/xhtml 是 DOM 命名空间标识,不是待下载的文件地址。
3.3 不同打开方式下的实际含义
- 本地文件:浏览器读取磁盘上的 HTML。
- HTTP/HTTPS:首先请求 HTML 主文档,随后执行其中代码。
- 浏览器可能尝试请求站点图标,宿主或扩展也可能产生自己的请求。
因此,严谨的结论是:源码中没有发现核心游戏的额外联网资源依赖,但尚未通过运行时抓包证明“整个页面绝对零请求”。
3.4 如何亲自验证?
可以在浏览器开发者工具中进行以下检查:
- 打开 Network,关闭缓存后重新加载页面。
- 查看主文档以外是否存在 Script、Stylesheet、Img、Media、Fetch/XHR 或 WebSocket 请求。
- 对可疑请求查看 Initiator,确认是谁触发的。
- 不仅观察标题界面,还要进入关卡、开火、切换设置并完成一次交互。
- 使用网络离线模拟后重新加载或再次打开页面,检查关键行为;浏览器离线模拟不等于禁用了整台电脑的网络。
本地文件协议下 Network 的显示方式可能不同,验证联网依赖时使用静态 HTTP 环境更直观。
四、从打开页面到准备就绪:完整加载流程
4.1 浏览器先构建页面,再执行内嵌脚本
文档前部包含样式与页面结构,靠后出现普通内联 <script>,不是 type="module"。浏览器解析到这里时执行脚本,代码被包在立即执行函数中,并在末尾调用启动入口 QC()。
因为界面结构已经位于脚本之前,入口不需要再依赖 DOMContentLoaded 才能找到这些元素。
普通内联脚本在执行期间会占用解析与主线程时间,所以源码里的阶段性让出执行很重要。不过,解析和执行大型内联脚本本身仍有成本,加载层并不能消除这部分开销。
4.2 “加载”主要是本地初始化,不是下载进度
正常启动分支的主要阶段如下:
| 显示进度 | 页面提示 | 对应工作 |
|---|---|---|
| 4% | 正在启动渲染器 | 初始化渲染系统,取得场景、渲染器和相机 |
| 12% | 正在生成涂装、锈迹与雨水 | 材质 → 碰撞/物理相关模块 → 道具构造 → 输入 |
| 34% | 正在布置集装箱 | 构建关卡 |
| 50% | 正在架设起重机 | 构建背景与远景 |
| 58% | 正在生成港区地面 | 构建地面,再建立关卡碰撞数据 |
| 66% | 正在加载弹道与烟雾 | 特效 → 音频 → 玩家 → 战斗 |
| 74% | 正在检查制式步枪 | 初始化武器 |
| 82% | 正在启动港区主控 | 敌人 → HUD → 任务和游戏控制 |
| 92% | 正在准备首帧画面 | 执行关卡就绪后的渲染准备等工作 |
| 100% | 准备就绪 | 结束启动界面,进入持续动画循环 |
这些百分比是预设阶段标记,不是按下载字节数计算,也不代表已经消耗了对应比例的启动时间。
代码会在提示更新后通过短暂的异步让出执行,让浏览器有机会处理界面更新;但如果某个阶段内部工作量很大,该阶段本身仍可能造成主线程阻塞。
代码 2|更新进度后让出主线程(源码摘录,仅换行)
源码中的 KC 与 Nr 配合实现阶段性让出执行:
var KC=()=>new Promise(i=>setTimeout(i,0));
async function Nr(i,e){
xM&&(xM.textContent=i),
yM&&(yM.style.width=Math.round(e*100)+"%"),
await KC()
}
Nr 先改提示和进度宽度,再等待定时器任务。setTimeout(..., 0) 不是精确等待零毫秒,而是结束当前执行片段,让事件循环获得调度机会;浏览器可能在此间绘制加载界面,但不保证每次都会绘制。仅写 await Promise.resolve() 通常只进入微任务,不能等同于这种让出方式。async 也没有把构建场景自动搬到后台线程。
原理图 A|资源来源与启动时序(静态分析示意)
文字解读:入口只从磁盘或服务器取得主文档,之后的主要启动工作发生在本地 CPU;GPU 负责提交后的图形工作。进度提示和异步让出穿插在初始化阶段之间,不能把箭头长度或百分比理解为真实耗时。
4.3 首次绘制时还要做什么?
从通用图形管线看,JavaScript 构建对象,并不意味着所有内容已经在显卡上可用。首次使用相关资源时,还可能发生:
- 上传顶点、索引与纹理;
- 为渲染目标分配 GPU 存储;
- 编译、链接所需的着色器程序;
- 准备阴影与后处理所需状态。
这些步骤可能被引擎提前准备,也可能在首次使用时触发,不能仅凭进度条认定所有准备都已完成。
所以单文件避免的是很多外部素材请求,并没有消除资源生成、脚本执行与 GPU 准备成本。
五、进入页面后:标题、开始、暂停与结束
5.1 默认先进入标题状态
页面初始化完成,并不代表玩家已经进入战斗。正常分支默认进入标题状态,用户开始游戏后才进入开场与玩法流程。
代码还包含自动开始、固定画面、性能观察和独立展示等 URL 参数分支。这些属于辅助路径,不应和默认用户流程混为一谈。
5.2 为什么要点击后才能听到声音?
现代浏览器通常限制无用户操作的音频播放。代码会在鼠标、键盘或触摸交互发生时尝试解锁 AudioContext。
音频资源可以提前生成,但能否真正播放,还要满足浏览器的交互策略。没有可用 Web Audio 时,代码可以静音运行。
5.3 鼠标锁定如何控制第一人称视角?
普通鼠标会碰到屏幕边缘,无法无限转头。Pointer Lock 可以隐藏并锁定指针,让游戏持续接收相对移动量,用来修改相机朝向。
这份代码在首次锁定失败时存在降级处理;在已经锁定并进入玩法后,丢失锁定会触发暂停。页面隐藏时,也会对正在进行的游戏暂停;恢复可见不会自动替玩家继续战斗。
5.4 暂停不等于停止所有绘制
暂停会关闭玩法输入、释放鼠标、调整音频、隐藏战斗 HUD 并显示菜单。
但动画循环仍然存在,部分输入、音频、HUD 与渲染工作仍会执行。也就是说,暂停主要冻结玩法,不等于完全停止 GPU 工作。
5.5 任务闭环
主要关卡目标是切断三个中继,然后前往撤离区。任务系统处理交互进度、敌人增援、死亡与完成,并在结束后给出结算。
这说明页面不只是一个自动播放的 3D 演示,而是具有持续状态变化的游戏。
六、每一帧具体如何执行?
启动后,renderer.setAnimationLoop() 持续调用更新逻辑。正常循环的顺序可以概括为:
计算帧间隔
↓
更新输入
↓
更新任务与游戏状态
↓
按状态更新玩家、武器、敌人
↓
更新武器湿润表现
↓
非暂停时更新特效、地面、关卡、背景与材质
↓
更新音频与HUD
↓
可选固定镜头、性能统计
↓
渲染当前帧
↓
清理本帧输入状态,进入下一帧
6.1 为什么要计算 dt?
不同设备每秒能绘制的帧数不同,运动不能简单地“每帧移动固定距离”。dt 表示两帧之间的时间,用于让更新量与经过的时间相关。
正常分支中,代码把模拟步长限制为最多 0.05 秒,减少长卡顿后单次更新推进过大的问题。这是变步长保护,不是固定步长物理,也不会自动补算卡顿期间的全部模拟。
代码 3|dt 与玩法状态门控(教学重排与变量重命名,非逐字原文)
下面按正常主循环重排,保留实际的 game.update() 门控条件;省略展示模式、错误捕获、武器湿润度、固定镜头及统计等分支。update 是示例中的本地辅助函数,不是 Three.js API。
const update = (name, dt) => gameContext[name]?.update?.(dt);
let previous = performance.now();
renderer.setAnimationLoop(() => {
const now = performance.now();
const dt = Math.min((now - previous) / 1000, 0.05);
previous = now;
update("input", dt);
const active = game.update(dt) !== false
&& game.state !== "title" && game.state !== "paused";
if (active) {
for (const name of ["player", "weapon", "enemies"]) update(name, dt);
}
if (game.state !== "paused") {
for (const name of ["fx", "ground", "level", "backdrop", "mats"]) update(name, dt);
}
update("audio", dt);
update("hud", dt);
render.frame(dt);
input.endFrame?.();
});
关键是两道不同的门:玩法对象受 active 控制,环境效果只检查是否暂停;音频、HUD 和绘制仍位于门外。源码还累加模拟时间并支持 dt 参数覆盖,所以上面的限幅描述针对默认路径,而不是所有辅助测试模式。
原理图 B|一帧中的状态门控
文字解读:沿“否”分支也能走到绘制提交,因此暂停不是停止帧回调。图中 GPU 是提交后的职责,并不表示 CPU 清理输入必须等待 GPU 完成;浏览器与驱动会调度实际执行。
6.2 任务与渲染不是同一个系统
玩家移动、子弹命中和任务完成属于玩法逻辑;阴影、反射和颜色合成属于渲染逻辑。玩法更新修改状态,渲染系统再把这些状态转成图像。
因此,画面效果越复杂,主要影响的是绘制成本;敌人和碰撞逻辑越复杂,则会增加 CPU 侧负担。真正的性能瓶颈需要测量,不能只凭模型数量判断。
6.3 窗口大小改变怎么办?
代码监听 resize,重算画布与相关渲染目标的尺寸,并更新相机投影;HUD 也有自己的布局更新。设备像素比上限设为 2,避免高分屏无节制放大内部像素数量。
七、资源如何“现场生产”?
7.1 几何体:用规则描述模型
场景与道具构造器通过几何体组合、位置旋转缩放和部分几何合并,构建集装箱、建筑、机械以及武器、敌人等对象。
这种方案很适合结构规则、工业风格明显的内容。但算法不会自动提供高质量美术:比例、轮廓、配色和细节规则仍然需要设计。
7.2 Canvas:把二维绘图变成三维表面细节
Canvas 可以在内存里画文字、编号、标线和磨损图案,再作为纹理贴到三维物体上。
这是一条“代码绘图 → 纹理 → 模型表面”的链路,不需要先准备一张 PNG。字体来自系统字体时,不同设备的字体选择也可能带来视觉差异。
代码 4|Canvas 标识变成纹理(教学简化与可读命名,非逐字原文)
源码中的文字绘制与贴花材质沿用这条链路。内嵌引擎将纹理构造器缩写为 xs;这里使用标准 Three.js 名称说明同一链路,省略字体镂空、尺寸计算及贴花偏移配置:
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 128;
const ctx = canvas.getContext("2d");
ctx.font = '900 64px "Microsoft YaHei", sans-serif';
ctx.fillStyle = "#e8e4da";
ctx.fillText("A-03", 12, 84);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshStandardMaterial({
map: texture, transparent: true, depthWrite: false, roughness: 0.7
});
这不是下载图片,而是在 CPU 侧画出像素,再由纹理承载这些像素供 GPU 采样。后续若修改 Canvas,需要设置 texture.needsUpdate = true 触发更新;源码也对需要重绘的文字执行这一步。示例尺寸和编号仅用于教学,不是源码的固定素材。
7.3 GLSL:把数学函数绘制成纹理或动态表面
GLSL 既可以直接计算可见表面的颜色,也可以先离屏绘制,将结果存入纹理供后续使用。
两者要区分:
- 生成后复用的纹理:把一些计算前置,后面主要采样结果;
- 每帧动态计算的着色器:适合云层移动、光照变化等,但持续消耗 GPU。
“程序化”不代表所有效果都每帧从头生成。
7.4 Web Audio:合成声音而不是下载声音
音频代码创建采样缓冲区、写入声道数据,再安排播放。它减少了外部音频文件依赖,但合成与缓存同样有计算和内存成本。
代码 5|生成结果写入 Web Audio 采样缓冲(摘录与教学改写分别标明)
音频合成由 r_ 内部调用生成器,再通过 u_ 写入缓冲。生成器调用的原有表达式为:
n.gen(s,ao(Kb(i)+o*7919),o)
这里 n 是音效配置,s 是采样率,o 是变体序号;生成函数使用种子随机源产生声道采样。一些生成器返回迭代器,源码会推进迭代器后再整理结果,并非所有声音都能一次函数调用立即完成。
下面是同一缓冲写入逻辑的可读命名改写,不是逐字原文;generated 对应源码中的 { sr, ch } 生成结果,audioContext 对应已有音频上下文:
const buffer = audioContext.createBuffer(
generated.ch.length, generated.ch[0].length, generated.sr
);
for (let channel = 0; channel < generated.ch.length; channel++) {
buffer.getChannelData(channel).set(generated.ch[channel]);
}
每个声道是一串振幅采样,采样数除以采样率就是时长。写入 AudioBuffer 只完成资源准备,不等于已经播放;后续仍需播放节点、音频连接和用户交互解锁。此链路属于 CPU 侧合成和浏览器音频系统,不经过 WebGL/GPU。
八、一帧好画面如何形成?
以下是职责示意,不是声称所有通道都严格按图示顺序执行。实际管线会受到画质档位、缓冲区复用与回退路径影响。
场景、相机、光源
├── 阴影相关绘制
├── 按需更新反射
└── 场景颜色与深度
│
环境遮蔽、体积光等
│
泛光、曝光、色调映射等
│
抗锯齿与最终合成
│
浏览器叠加HTML界面
原理图 C|渲染多通道的职责与数据依赖(不是严格 pass 顺序)
文字解读:阴影和反射提供场景着色所需的信息,颜色与深度又为屏幕空间效果提供输入,最终图像再与 DOM 界面合成。箭头表达概念依赖,不是完整渲染图,也不承诺某个效果必然在另一效果之后执行;具体通道、缓冲区复用、效果开关与回退由渲染管理逻辑统筹。
8.1 材质:不只是一张颜色图
材质决定表面如何响应光照。颜色变化、表面粗糙感、裂纹、湿润度与积水共同作用,才让简单几何体呈现更丰富的质感。
8.2 PCSS 式软阴影
源码包含遮挡物搜索与半影估计等软阴影逻辑,使阴影软硬程度不再固定。它仍建立在阴影贴图与多次采样上,不是硬件光线追踪。
8.3 SSAO:局部接触处的层次
环境遮蔽根据屏幕深度关系估计附近遮挡,增强墙角与物体接地处的暗部。代码会由深度重建位置,并利用邻近深度估计局部表面朝向。
屏幕空间方法只知道当前图像里可见的信息,因此不能替代完整的全局光照。
8.4 湿地平面反射
平面反射为地面等近似平面表面提供倒影信息,再结合湿润与积水分布表现局部反射。它的适用范围与任意曲面上的完整反射不同。
8.5 体积光
沿视线采样光照相关信息,让空气和雾气产生可见亮度层次。部分效果通过半分辨率计算与双边上采样控制成本,尽量在减少像素工作量的同时保留边界。
这里的体积光不等于下一节的背景云是完整三维体积云。
8.6 后处理
| 效果 | 作用 |
|---|---|
| Bloom | 让高亮区域向周围扩散,形成泛光 |
| 自动曝光 | 调整画面的亮度表现 |
| ACES/AgX 混合色调映射 | 将高动态范围亮度映射到屏幕显示范围,塑造高光与色彩 |
| 镜头光晕、色差 | 模拟镜头视觉特征 |
| 暗角、颗粒 | 统一画面风格 |
| 锐化与 FXAA | 调整清晰度并减轻锯齿 |
FXAA 主要在最终图像上工作,不能完全解决所有闪烁和时间稳定性问题;锐化也不会凭空恢复缺失的细节。
九、天空与云:每个像素都在计算什么?
9.1 用方向定义天空,而不是用角色位置定义天空
天空使用立方体作为载体。顶点着色器去掉相机平移影响,保留旋转,并将天空放在投影远端。因此转头可以改变观察方向,移动却不会“走出天空”。
片元着色器取得归一化方向 d,通过高度 d.y 和与太阳方向的夹角关系,计算当前像素颜色。
代码 6|去掉平移,只保留观察方向(源码摘录,仅换行)
天空顶点着色器通过以下计算保留观察方向:
varying vec3 vDir;
void main(){
vDir = position;
vec4 p = projectionMatrix * vec4(mat3(viewMatrix) * position, 1.0);
gl_Position = p.xyww;
}
mat3(viewMatrix) 去掉视图矩阵的平移部分,避免天空随玩家位移出现近景视差;p.xyww 把裁剪坐标的 z 设为 w,使透视除法后的深度位于远端。插值后的 vDir 供片元阶段归一化并计算天空颜色。它改变的是 GPU 顶点投影,不是在 CPU 上每帧搬动整片天空。
9.2 天空底色和太阳光晕
代码通过 mix()、smoothstep() 和 pow() 混合天空颜色:上方冷蓝,地平线偏暖,太阳附近叠加不同范围的高亮光晕。
这是艺术化渐变与散射观感近似,不是完整物理大气模型。
9.3 噪声与 fBm
JavaScript 本地生成一张 256×256 的随机数据纹理。着色器的噪声函数对它进行平滑采样,避免直接显示无结构的随机点。
fbm3() 与 fbm5() 分别叠加三层、五层噪声。逐层提高频率、降低权重,并旋转、偏移坐标,得到大云团、中尺度起伏和小尺度细节的组合。
代码 7|平滑噪声与多尺度叠加(源码摘录,仅排版)
噪声函数先平滑采样坐标,再叠加不同尺度的结果:
uniform sampler2D tNoise;
float n2(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
return texture2D(tNoise, (i + f + 0.5) / 256.0).r;
}
float fbm5(vec2 p){
float a = 0.5, s = 0.0;
for (int i = 0; i < 5; i++){
s += a * n2(p);
p = mat2(1.6, 1.2, -1.2, 1.6) * p + 17.3;
a *= 0.5;
}
return s / 0.96875;
}
f*f*(3−2*f) 是端点斜率为零的平滑曲线,再配合纹理过滤避免格点之间突变。矩阵让下一层坐标旋转并放大约两倍,权重则减半;0.96875 是五层权重之和,用于归一化。对应的 fbm3 只循环三次,并除以三层权重和 0.875。这比单层随机纹理更容易形成兼有大轮廓和小细节的云团。
9.4 透视、风与覆盖率
观察方向被映射到二维云层坐标;靠近地平线时,这种映射产生远景压缩的视觉效果。加上与时间相关的风偏移后,云层整体漂移。
随后,噪声通过阈值变成覆盖率,用于混合天空色与云色。代码还加入脊状细节、大范围密度变化、太阳附近云洞和薄碎云,并削弱远处的部分细节以减少闪烁。
太阳附近的云洞是明确的美术控制,让云隙与夕阳形成更好的构图,不是纯粹自然模拟的必然结果。
9.5 用近似光照制造云的体积感
代码比较当前位置和沿太阳方向偏移位置的密度,近似云底起伏的明暗。同时让厚云偏暗冷、薄云更透亮,面向太阳的边缘出现暖光。
再加上空气透视、太阳盘与闪电,就形成了有厚薄和层次的背景云。
这属于二维程序云层加光照近似,不是沿视线反复采样三维密度场的完整体积云。适合远处天空,不适合让玩家飞入云内观察。
另外,天空还能被捕获为环境光照使用的预过滤环境贴图。源码会在环境捕获模式下去掉极亮太阳盘并限制极端亮度,避免产生异常亮点;这也说明天空既是背景,也能参与场景表面受光的整体氛围。
代码 8|沿太阳方向比较密度,近似云底受光(源码连续摘录)
云光照计算通过密度差与厚薄参数塑造明暗:
vec2 sd = normalize(uSunDir.xz);
float densS = fbm5(qq + sd * 0.22);
float relief = clamp((dens - densS) * 4.5 + 0.5, 0.0, 1.0);
float thick = smoothstep(0.40, 0.72, body);
vec3 cool = mix(vec3(0.155, 0.195, 0.255), vec3(0.030, 0.042, 0.064), thick); // slate, darker where the deck is thick
cool *= 0.55 + 0.9 * relief * relief;
cool += vec3(0.07, 0.10, 0.15) * pow(1.0 - thick, 3.0); // thin spots let cold light through
densS 是沿太阳水平投影方向偏移后的一次密度采样,和当前位置相减得到受光起伏信号;thick 控制厚云变暗,薄处再补冷光。这里没有沿光线积分厚度,relief 也不是由真实三维云表面法线计算出的光照。后续的暖光与边缘光计算进一步塑造朝阳边缘。
原理图 D|天空从方向到颜色的生成链
文字解读:CPU 提供数据和参数,逐像素云形与光照主要在 GPU 上计算。密度一方面决定“哪里有云”,另一方面参与“云有多亮”,因此轮廓与明暗具有联系;图中是计算关系,环境捕获不代表每个像素都额外捕获一次。
十、核心技巧:域扭曲如何改变云的形状?
域扭曲不是让随机数更乱,而是改变另一个函数的输入空间。
10.1 三步实现
代码 9|先扭曲坐标,再读取云密度(源码连续摘录)
下面保留域扭曲实现的原有表达式和注释:
vec2 cuv = d.xz / (h + 0.10) * vec2(0.62, 0.95); // rolls of cloud run roughly east-west
vec2 wind = vec2(-0.012, 0.004) * uTime;
vec2 q = cuv + wind;
float warp = fbm3(q * 0.45 + 7.1);
vec2 qq = q + (warp - 0.5) * 1.3;
float dens = fbm5(qq);
这里 cuv 来自观察方向,wind 随时间线性变化,而 warp 决定每个位置的局部偏移;最后一行才取得用于云形的基础密度。关键表达式拆解如下:
| 表达式 | 作用 |
|---|---|
q = cuv + wind |
得到随风平移的云层坐标 |
warp = fbm3(q * 0.45 + 7.1) |
生成坐标偏移的控制信号 |
qq = q + (warp - 0.5) * 1.3 |
扭曲原始坐标 |
dens = fbm5(qq) |
从变形后的坐标读取云密度 |
普通噪声直接读取 fbm5(q);这里先改变位置,再读取 fbm5(qq)。
10.2 参数分别是什么意思?
0.45:降低扭曲信号的频率,使形变在较大范围发生。7.1:固定采样偏移,不是动画速度。- 减去
0.5:使偏移量大致围绕零变化。 1.3:控制形变幅度,单位是采样坐标,不是米或屏幕像素。
10.3 用数值理解坐标如何改变
假设原坐标为 (2.0, 3.0),该位置的 warp = 0.8:
偏移量 = (0.8 - 0.5) × 1.3 = 0.39
新坐标 = (2.0 + 0.39, 3.0 + 0.39)
= (2.39, 3.39)
各处噪声不同,因此各处的偏移距离不同,产生局部拉伸、压缩和弯曲。如果所有位置偏移都相同,那只是平移。
10.4 这份实现的一个重要限制
warp 是标量,q 是二维向量,因此 GLSL 将同一个偏移同时加到 X、Y 分量上。
所以位移沿二维 (1,1) 或反方向变化,大小因位置而异,并非两个方向独立的随机位移。更复杂的实现可以使用两套噪声控制两个方向,甚至多层嵌套,但会增加采样成本。
后续云底细节与部分光照计算也沿用 qq,让形状、细节和明暗保持关联。
10.5 一个直观比喻
把噪声想象成画在橡胶膜上的图案:
- 改亮度:重新上色;
- 加风偏移:整张膜移动;
- 域扭曲:不同位置被推拉不同距离。
这里不需要真的移动网格顶点。改变采样坐标,就可以改变最终图案。
原理图 E|标量域扭曲的数据流(GPU 片元内)
文字解读:从 q 分出一条支路计算偏移,再和原坐标汇合,最后才采样主噪声。因为偏移是标量,两个分量得到相同位移,箭头应理解为沿 (1,1) 或反向的局部推拉,而不是任意二维流场;整个过程不需要移动实际网格顶点。
十一、性能:程序生成不等于免费
11.1 三类主要成本
| 成本 | 典型工作 |
|---|---|
| 启动 CPU 成本 | 解析脚本、构建几何、生成纹理和音频 |
| 每帧 CPU 成本 | 输入、玩家与敌人、碰撞、任务、状态更新 |
| GPU 与显存成本 | 纹理、缓冲区、阴影、反射、多次绘制和后处理 |
单文件可以减少外部素材分发的复杂度,但大型内嵌代码仍要传输、解析、执行;生成资源仍占内存;高画质仍会消耗电量并产生热量。
11.2 代码中的优化策略
- low、medium、high、ultra 多档画质;
- 调节阴影、纹理、体积光步数、反射和雨量;
- DPR 上限 2,并限制内部像素预算;
- 动态分辨率与持续低性能时的降档;
- 部分效果半分辨率计算、双边上采样;
- 图集、部分实例化元素与差异化 HUD 更新。
像素越多,复杂片元着色器就要执行越多次,因此在高分屏上控制内部分辨率十分关键。
11.3 怎样评估真实性能?
测试时应记录设备、浏览器、分辨率、画质与测试场景,再观察平均帧耗时、长帧、显存压力和持续运行表现。标题界面流畅,不代表战斗和大量特效出现时同样流畅。
源码存在性能统计分支,不等于本文已经测得任何帧率结论。
十二、异常、兼容性与工程边界
启动入口带有错误捕获,发生启动失败时可以显示失败信息。实际渲染器要求 WebGL2:即使其他探测代码出现 WebGL1 回退,也不能据此认为游戏兼容 WebGL1。
高级渲染发生问题时,代码存在较简单绘制路径的回退处理,但这不能代替对所有驱动或上下文异常的完整验证。
音频、鼠标锁、窗口缩放和页面隐藏都有相应处理;这些说明作者考虑了浏览器游戏常见边界,但不代表所有异常都已覆盖。
单文件交付也有代价:
- 查看和调试超长压缩代码更困难;
- 更新一部分逻辑,也可能需要重新分发整个文件;
- 协作开发和独立资源缓存不如模块化工程方便;
- 没有外部资源依赖,不等于所有浏览器环境都完全一致。
从最终文件也无法反推出作者完整的原始工程结构。打包后的单文件,并不能证明开发时没有使用模块、构建工具或多人协作。
十三、它与商业 AAA 游戏的区别
商业 AAA 涉及预算、内容规模、角色和动画、物理交互、关卡生产、叙事、音频制作与测试覆盖。不能因为画面采用一些先进视觉技巧,就把一个单关卡页面等同于大型商业作品。
更准确的定位是:一个采用多种高规格游戏常见渲染技巧、拥有基础完整玩法的单文件浏览器射击作品。
它最值得学习的不是“单文件替代所有游戏工程”,而是几种取舍:
- 用程序规则替代部分预制素材。
- 用符合场景的近似算法替代昂贵模拟。
- 用光影、构图、材质和后处理共同塑造画面。
- 把视觉预算集中在玩家最容易感知的位置。
- 根据设备能力降低成本,而不是无条件堆叠效果。
结语:文件里装的不只是世界,还有生成世界的方法
回看整个流程:浏览器读取 HTML,执行内嵌程序;程序构建场景、生成纹理和音效,连接输入与玩法;每一帧更新状态,再通过 WebGL2 将绘制工作交给显卡;GLSL 计算表面、天空和后处理,HTML/CSS 则提供清晰的交互界面。
所谓“一个 HTML 撑起 3D 游戏”,并不是跳过了引擎、资源和渲染,而是改变了它们的组织和生产方式。
引擎装进文件,部分素材交给算法,视觉计算交给显卡,玩法状态由浏览器中的程序持续推动。这才是这个页面背后完整的技术逻辑。
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。


一个不到2MB的HTML文件,通过内嵌Three.js引擎与程序化生成资源,实现3A级视觉效果。浏览器加载后,利用WebGL2、GLSL着色器与Canvas 2D实时构建场景、材质与音效,无需外部下载资源。页面结构分层清晰:HTML提供容器,CSS控制界面,JavaScript驱动逻辑,GPU负责渲染。所有交互均在客户端完成,依赖本地存储与硬件加速。经静态分析,无外部资源请求,真正实现“单文件即游戏”。
浙公网安备 33010602011771号