从打开 HTML 到实时 3D 世界:游戏的加载、运行与画质原理全解析

🧑‍💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣

一个文件,装下引擎、场景、音效和玩法。本文以游戏页面为例,拆解浏览器如何加载这款游戏,以及程序化资源、实时渲染与域扭曲如何共同营造“3A 观感”。

最近微信群里面看到一个很有意思的项目(下载链接),是一个不到2MB,就实现了一些3A大作都没法弄好的小游戏,下面是我基于这个项目的一些见解

整体页面打开,眼前却出现港区建筑、第一人称武器、阴云与落日、湿润地面、战斗音效和任务提示——这些内容究竟从哪里来?

它是不是偷偷下载了一个大型游戏?单个网页为什么能调动显卡?启动界面的“加载”,又到底在加载什么?

先给出答案:这个文件并没有省略游戏引擎,而是把引擎与业务代码内嵌进 HTML;它也不是不需要资源,而是把大量资源改为运行时生成。浏览器执行游戏逻辑,显卡计算画面,HTML/CSS 叠加界面,最终形成一个完整的实时交互页面。

本文基于源码静态分析。没有进行浏览器抓包、设备兼容性测试或帧率实测;文中会区分实际实现、通用原理和待验证事项。“3A”指页面标题与视觉定位,不等于商业 AAA 制作规格。

screenshot-20261111-160127

一、技术全景:一个文件,不代表只有一种技术

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 如何亲自验证?

可以在浏览器开发者工具中进行以下检查:

  1. 打开 Network,关闭缓存后重新加载页面。
  2. 查看主文档以外是否存在 Script、Stylesheet、Img、Media、Fetch/XHR 或 WebSocket 请求。
  3. 对可疑请求查看 Initiator,确认是谁触发的。
  4. 不仅观察标题界面,还要进入关卡、开火、切换设置并完成一次交互。
  5. 使用网络离线模拟后重新加载或再次打开页面,检查关键行为;浏览器离线模拟不等于禁用了整台电脑的网络。

本地文件协议下 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|资源来源与启动时序(静态分析示意)

sequenceDiagram participant D as 磁盘或静态服务器 participant C as 浏览器主线程 CPU participant U as 浏览器界面 participant G as WebGL与GPU D->>C: HTML 主文档(内嵌脚本与样式) C->>U: 建立画布、UI 与启动层 C->>G: 创建渲染系统 loop 分阶段本地初始化 C->>U: Nr 更新提示和预设进度 C->>C: await KC 等待定时器任务 Note over C,U: 调度机会,不保证此次必然绘制 C->>C: 生成材质、关卡、玩法等数据 end C->>G: 渲染准备与绘制提交 C->>U: 准备就绪,退出启动层 Note over C,G: 后续进入持续帧循环,并非开始持续下载素材

文字解读:入口只从磁盘或服务器取得主文档,之后的主要启动工作发生在本地 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|一帧中的状态门控

flowchart TD subgraph CPU[CPU:JavaScript 帧回调] T[计算并限幅 dt] --> I[输入与 game.update] I --> A{玩法允许更新?} A -->|是| P[玩家、武器、敌人] A -->|否| S{当前非暂停?} P --> S S -->|是| E[特效、地面、关卡、背景、材质] S -->|否| H[音频与 HUD] E --> H H --> R[提交当前帧绘制] R --> F[清理本帧输入] end R --> G[GPU:执行图形计算]

文字解读:沿“否”分支也能走到绘制提交,因此暂停不是停止帧回调。图中 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 顺序)

flowchart LR C[CPU:场景、相机、光源及画质参数] --> S C --> R C --> M subgraph GPU[GPU:图形计算职责] S[阴影信息] --> M[场景颜色与深度] R[按需反射信息] --> M M --> D[深度相关效果:环境遮蔽、体积光等] M --> P[泛光、曝光、色调映射及图像合成] D --> P P --> O[最终图像处理与输出] end O --> B[浏览器合成页面] U[HTML/CSS:HUD 与菜单] --> B

文字解读:阴影和反射提供场景着色所需的信息,颜色与深度又为屏幕空间效果提供输入,最终图像再与 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|天空从方向到颜色的生成链

flowchart TD C[CPU:随机纹理、时间与太阳参数] --> N C --> L subgraph GPU[GPU:天空着色器] V[立方体顶点:去平移投影] --> D[插值并归一化观察方向] D --> B[天空底色与太阳光晕] D --> Q[方向映射为云层坐标,加风偏移] Q --> N[噪声与域扭曲,生成密度] N --> K[阈值、云洞、碎云与覆盖率] N --> L[密度差与厚薄,近似冷暖光照] B --> F[混合天空、云、太阳等颜色] K --> F L --> F end F --> O[背景画面;另有环境捕获用途]

文字解读: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 片元内)

flowchart LR C[CPU:提供时间 uTime] --> W subgraph GPU[GPU:每个天空片元的坐标计算] U[方向映射 cuv] --> Q[原坐标 q] W[风偏移 wind] --> Q Q --> N[低频 fbm3:得到标量 warp] N --> O[减 0.5,再乘 1.3] Q --> A[同时偏移 q 的两个分量,得到 qq] O --> A A --> F[fbm5 在新位置取样] F --> D[输出密度 dens] end

文字解读:从 q 分出一条支路计算偏移,再和原坐标汇合,最后才采样主噪声。因为偏移是标量,两个分量得到相同位移,箭头应理解为沿 (1,1) 或反向的局部推拉,而不是任意二维流场;整个过程不需要移动实际网格顶点。

十一、性能:程序生成不等于免费

11.1 三类主要成本

成本 典型工作
启动 CPU 成本 解析脚本、构建几何、生成纹理和音频
每帧 CPU 成本 输入、玩家与敌人、碰撞、任务、状态更新
GPU 与显存成本 纹理、缓冲区、阴影、反射、多次绘制和后处理

单文件可以减少外部素材分发的复杂度,但大型内嵌代码仍要传输、解析、执行;生成资源仍占内存;高画质仍会消耗电量并产生热量。

11.2 代码中的优化策略

  • low、medium、high、ultra 多档画质;
  • 调节阴影、纹理、体积光步数、反射和雨量;
  • DPR 上限 2,并限制内部像素预算;
  • 动态分辨率与持续低性能时的降档;
  • 部分效果半分辨率计算、双边上采样;
  • 图集、部分实例化元素与差异化 HUD 更新。

像素越多,复杂片元着色器就要执行越多次,因此在高分屏上控制内部分辨率十分关键。

11.3 怎样评估真实性能?

测试时应记录设备、浏览器、分辨率、画质与测试场景,再观察平均帧耗时、长帧、显存压力和持续运行表现。标题界面流畅,不代表战斗和大量特效出现时同样流畅。

源码存在性能统计分支,不等于本文已经测得任何帧率结论。

十二、异常、兼容性与工程边界

启动入口带有错误捕获,发生启动失败时可以显示失败信息。实际渲染器要求 WebGL2:即使其他探测代码出现 WebGL1 回退,也不能据此认为游戏兼容 WebGL1。

高级渲染发生问题时,代码存在较简单绘制路径的回退处理,但这不能代替对所有驱动或上下文异常的完整验证。

音频、鼠标锁、窗口缩放和页面隐藏都有相应处理;这些说明作者考虑了浏览器游戏常见边界,但不代表所有异常都已覆盖。

单文件交付也有代价:

  • 查看和调试超长压缩代码更困难;
  • 更新一部分逻辑,也可能需要重新分发整个文件;
  • 协作开发和独立资源缓存不如模块化工程方便;
  • 没有外部资源依赖,不等于所有浏览器环境都完全一致。

从最终文件也无法反推出作者完整的原始工程结构。打包后的单文件,并不能证明开发时没有使用模块、构建工具或多人协作。

十三、它与商业 AAA 游戏的区别

商业 AAA 涉及预算、内容规模、角色和动画、物理交互、关卡生产、叙事、音频制作与测试覆盖。不能因为画面采用一些先进视觉技巧,就把一个单关卡页面等同于大型商业作品。

更准确的定位是:一个采用多种高规格游戏常见渲染技巧、拥有基础完整玩法的单文件浏览器射击作品。

它最值得学习的不是“单文件替代所有游戏工程”,而是几种取舍:

  1. 用程序规则替代部分预制素材。
  2. 用符合场景的近似算法替代昂贵模拟。
  3. 用光影、构图、材质和后处理共同塑造画面。
  4. 把视觉预算集中在玩家最容易感知的位置。
  5. 根据设备能力降低成本,而不是无条件堆叠效果。

结语:文件里装的不只是世界,还有生成世界的方法

回看整个流程:浏览器读取 HTML,执行内嵌程序;程序构建场景、生成纹理和音效,连接输入与玩法;每一帧更新状态,再通过 WebGL2 将绘制工作交给显卡;GLSL 计算表面、天空和后处理,HTML/CSS 则提供清晰的交互界面。

所谓“一个 HTML 撑起 3D 游戏”,并不是跳过了引擎、资源和渲染,而是改变了它们的组织和生产方式。

引擎装进文件,部分素材交给算法,视觉计算交给显卡,玩法状态由浏览器中的程序持续推动。这才是这个页面背后完整的技术逻辑。


如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

图片

posted @ 2026-11-11 16:12  林恒  阅读(42)  评论(0)    收藏  举报