静态网页的三维地形优化
把一张方格纸放在桌上,抬起中间几处交点,四周保持低平,再沿格子的对角线折出三角面,就有了一片山地。
网页里的地形也可以这样造出来。它不一定需要一份庞大的三维模型,更不一定需要服务器不停计算。浏览器拿到一组高度,就能把它变成可以转动观察的山川。
这篇文章关心的是:这块地形模型怎样少占内存、少传重复数据、少做无用绘制,同时保留看得见的高低差? 文件存储和下载体积也是成本,但要与运行资源分开计算。
动手生成地形
下面的 Demo 可以直接运行。拖动山体旋转,展开“塑造这片土地”调整起伏、宽度、采样网格和种子;下方灰度图与黄线剖面,分别从俯视和侧面解释同一片地形。
建议按这个顺序试一遍:
- 保持 65 × 65 采样,依次选择“逐面顶点”“共享顶点”“共享 + 量化”,观察山体和缓冲字节数。
- 打开“三角网格”,将采样从 129 × 129 降到 17 × 17。山坡仍有起伏,但小山脊会逐渐变成折线。
- 关闭“水面、底座与剖面线”和“三角网格”,看每帧绘制次数降为 1。
- 停止操作,观察“累计绘制”保持不变。再转动一次地形,计数才继续增加。
它没有自动播放的展示动画。每次操作都会重新生成数据或改变实际绘制方案;相同种子与参数可重复生成,分享链接可复现地形,也能导出高度数据、生成配方和 OBJ 地表。
静态网页的三维绘制
“静态”描述的是服务器交付文件的方式。HTML、CSS、JavaScript 都可以是普通文件;浏览器加载后,JavaScript 仍能计算顶点、处理鼠标并调用 WebGL 绘制三维画面。WebGL 通过 Canvas 提供硬件加速的图形能力,实际可用性取决于浏览器和设备。MDN:WebGL
本例的流程是:
1 | 种子与参数 → 高度数组 → 三角网格与法线 → WebGL 缓冲 → 三维画面 |
一块 N × N 的规则地形,只需要先回答 N² 个问题:“这个交点有多高?”横纵坐标可以根据网格间距计算。每个格子沿同一方向切成两个三角形,连接关系也能计算出来。
1 | a ── b 两个三角形:a、c、b 和 b、c、d |
法线相当于每个点上方的一根小箭头,告诉光照“这里朝向哪里”。本例根据相邻高度的坡度计算法线,让山坡自然出现明暗。明暗能增强起伏感,却不能替代真正的高低坐标。
这种表示叫高度场:每个地面位置只有一个高度,适合山地、岛屿和盆地;悬空桥、洞穴顶底和向外伸出的岩檐,需要额外网格,不能全部塞进同一张高度图。
顶点复用
假设做模型的人为每个三角形单独写下三个顶点。相邻三角形共有的交点,也会被反复写下。地形内部的一个网格点,通常会被六个三角形使用。
更省的办法是先编一本“顶点通讯录”:位置与法线只记录一次,再用索引告诉 GPU 每个三角形由哪三个编号组成。本例的共享方案通过 drawElements 按索引绘制。WebGL 1 原生支持 UInt16 索引,UInt32 索引需要相应扩展;本 Demo 最大只有 16,641 个顶点,索引不会超出 UInt16 范围。MDN:drawElements
设 V = N²,C = (N − 1)²。本例地表不使用 UV 和纹理,逐面与共享方案都保存 Float32 位置 XYZ、法线 XYZ,每条记录共 24 字节。
| 地表方案 | 字节计算 | 65 × 65 时的实际数组大小 |
|---|---|---|
| 逐面顶点 | 6C × 24 |
589,824 B · 576.00 KiB |
| 共享顶点 | V × 24 + 6C × 2 |
150,552 B · 147.02 KiB |
| 共享 + 量化 | V × 9 + 6C × 2 |
87,177 B · 85.13 KiB |
这些数字来自 Demo 创建的类型化数组长度,1 KiB = 1024 B,不是预设的压缩百分比。
仅复用顶点,就减少约 74.5% 的地表几何缓冲。 顶点记录从 24,576 条变成 4,225 条,但三种方案仍然都是 8,192 个三角面。形状和面数相同,不意味着 GPU 耗时一定相同;缓存和设备实现会影响收益,不能把字节减少比例直接说成帧率提升比例。
坐标量化
地图只有一公里宽,却给每个坐标保存过多精度,有时就像记录书桌长度时写出很多位小数:有数据,不一定有可见价值。
本例的量化方案把每条顶点记录从 24 字节缩短为 9 字节:三个 UInt16 分量保存横向格号、高度和纵向格号,共 6 字节;三个 Int8 分量保存法线,共 3 字节。位置在顶点着色器中用缩放与偏移还原,法线在光照前重新归一化。
量化用有限的整数刻度近似原始值,因此需要同时检查误差。设高度范围为 R,使用 b 位无符号整数并四舍五入,忽略浮点运算误差时,最大高度误差不超过:
1 | R ÷ [2 × (2^b − 1)] |
例如 300 m 高差,8 位刻度的误差上界约为 0.588 m,16 位则约为 0.00229 m。Demo 会用当前地形逐点比较,显示实际最大高度误差和法线夹角误差;不是把这个理论上界冒充实测值。
Khronos 的 KHR_mesh_quantization 也采用减少顶点属性位数的思路,在精度和资源之间取舍。本例直接使用 WebGL 类型化缓冲,没有实现 glTF 文件扩展;例如这里单独的 Int8 法线缓冲是每点 3 字节,不能直接套用到要求属性对齐的 glTF 文件中。Khronos:KHR_mesh_quantization
量化主要减少数据量,不会减少三角面。它还增加了还原坐标的运算,应根据误差和实际设备表现决定是否采用。
网格密度
如果想减少三角面,就要改变采样密度。N 个采样点沿边排列,只有 N − 1 个格子,所以地表三角面数为 2 × (N − 1)²。
| 采样网格 | 顶点数 | 三角面数 | 量化地表缓冲 |
|---|---|---|---|
| 17 × 17 | 289 | 512 | 5,673 B |
| 33 × 33 | 1,089 | 2,048 | 22,089 B |
| 65 × 65 | 4,225 | 8,192 | 87,177 B |
| 129 × 129 | 16,641 | 32,768 | 346,377 B |
把网格从 129 降到 65,沿边格子数减半,三角面数变成四分之一。代价也很直观:细小山脊和岸线会丢失,低密度网格无法凭空还原没有采到的峰谷。
先在实际显示尺寸下观察轮廓,再决定密度。对一块文章里的小地形,我会从本例的 65 × 65 开始尝试;如果 33 × 33 已经能表达所需山势,就没有必要保留更多面。这是此演示的选择起点,并非所有地形的通用标准。
更大的世界通常需要按距离选择细节等级,也就是 LOD:近处用细网格,远处用粗网格;同时分块,只保留需要的区域。分块过碎会增加绘制调用,不同密度的边界还要处理裂缝。本 Demo 的网格切换是手动比较,没有实现自动 LOD、视锥裁剪或地形流式加载。
减少绘制与刷新
模型的成本不仅在顶点。水面、底座、侧壁和剖面线都要提交绘制,三角网格叠加还要额外保存线条数据。Demo 把这些成本计入“全部几何缓冲”,并在绘制函数真正执行时累计调用数;关闭辅助显示时,会释放对应缓冲。
整块地表共用着色器与颜色规则,一次调用就能画完。等高线在同一次地表绘制中计算,不额外增加调用,但仍有像素着色开销。批量提交、释放不用的对象、控制画布分辨率,都是 WebGL 官方实践文档建议关注的方向。MDN:WebGL 最佳实践
地形不变化时,也无需每秒重画几十遍。本例只在用户操作、数据改变或画布尺寸变化后请求一帧,并把同一时刻的多次请求合并:
1 | let pending = false; |
因此静止时累计帧数不再增加。它说明本页没有持续提交地形绘制,不等于整个浏览器“零耗电”;窗口合成和其他网页仍有自己的工作。
这个小场景也没有大尺寸贴图、实时阴影或后处理,颜色来自高度与光照。作为量级参考,一张普通 2048 × 2048 的 RGBA8 纹理,基础像素数据就是 16 MiB,完整 mipmap 链约 21.33 MiB,远大于这里的地表缓冲。贴图确有必要时,应根据显示距离选择尺寸与 mipmap,不能只看 JPEG 或 PNG 文件有多小。
画布则把设备像素比限制在 2 以内。固定 CSS 尺寸时,像素比从 3 降到 2,像素数量变成原来的 4/9;这减少需要处理的画布像素,也会牺牲部分清晰度。帧缓冲的实际占用还取决于颜色、深度和抗锯齿配置。
网络加载与缓存
前面讨论的是地形进入浏览器后的运行成本。换到云端拉取,需要重新列指标:请求数、传输字节、等待响应的时间、缓存命中,以及首次访问后还会不会继续下载。本地旋转很流畅,不能证明首次加载很快;CDN 下载很快,也不能证明地形绘制很省资源。
对本 Demo,可以分别作出以下判断:
| 判断对象 | 本例能确认的事实 | 还不能由此推出的结论 |
|---|---|---|
| 本地地表数据 | 65 × 65 网格量化后缓冲为 87,177 B,地表可单次调用绘制,静止时不持续刷新 | 所有手机都流畅,或总显存只有 87,177 B |
| 初次云端拉取 | 需要网页、一个样式和三个脚本;浏览器另外可能请求站点图标 | 没有外部模型就等于“零下载” |
| 后续改变种子与参数 | 本例在本地重新生成,不发起地形模型、贴图请求 | 本地生成没有 CPU 成本 |
| 文件格式比较 | 可计算原始文件与 gzip 大小 | 这些字节就是本次 HTTP 传输,或能直接换算下载耗时 |
下方网络面板读取当前浏览器可观察到的页面、脚本与样式加载记录。transferSize 包括响应头和响应体;缓存命中或测量未暴露时可能为 0,因此不能只看一个零就认定没有请求。它与模型的几何缓冲数字没有可直接相减或换算的关系。MDN:transferSize
如果正式项目从云端拉取高度块或模型,可以分别采用这些办法:先下载相机附近的地形,移动后再加载新块;给有版本标识的资源合理设置缓存;压缩高度文件和网格;避免为每个小格子建立独立请求。拆得越细,单块越小,但请求与调度也越多,需要独立测量。HTTP 缓存控制的是资源复用与重新验证,缓存命中不会自动降低该模型渲染时的面数。MDN:HTTP 缓存
本例没有云端地形分块服务,也没有假装进行 CDN 速度测试。“刷新加载记录”只重新读取已有记录,不会重新下载地形。
文件体积与内存占用
同一块 65 × 65 地形,16 位高度数据加上 32 字节文件头,仅需 8,482 B。但读入后,本例先解码为 Float32 高度,再建立位置、法线与索引;共享模型的地表缓冲仍是 150,552 B。
所以只把高度文件从 16 位改成 8 位,并不会自动让 Float32 顶点缓冲缩小。要减少上传的几何数据,还需真正改变顶点布局或网格密度。
Demo 的补充表格会比较完整网格基线、浮点高度、16 位高度、8 位高度,以及种子配方;gzip 一栏调用浏览器 Compression Streams API 实测,不支持时保留原始字节数据。gzip 是无损压缩,量化才会引入这里讨论的高度近似。MDN:Compression Streams API
种子配方看起来最小,是因为生成算法放在共用脚本里。它适合本例的程序化地形,无法用几个参数完整保存任意实测山地或手工修改;还必须保留对应版本的生成器。节省下载数据的同时,生成与重建会花费客户端计算。
最后也要认清面板的边界:几何缓冲字节是上传数据的合计,不是设备总显存实测。 浏览器可能对齐或转换缓冲,还需要帧缓冲和驱动对象。本实验为了比较,保留原始高度、当前高度和临时压缩数据,也不能把某一个数组的大小当作全部 CPU 内存。
真正测试时,也分两轮做。先让资源加载完成,在固定设备、画布尺寸和相机动作下,测生成、解码、主线程工作及绘制表现;再对正式网址分别测冷缓存和热缓存,查看 Network 中的请求数、传输体积与瀑布时间。不要把 localhost 的加载速度当作云端测试,也不要将一次页面总耗时全部归因于模型。
在自己的静态页面里使用
本实验的生成器和渲染器没有外部依赖。将 terrain.js 与 renderer.js 放在静态目录,给 Canvas 明确的显示尺寸,就可以用下面的最小连接方式建立一块地形:
1 | <canvas id="land" style="display:block;width:100%;height:420px"></canvas> |
部署时按普通静态文件提供即可。真实产品还应处理 WebGL 不可用的情况;本实验会保留灰度图、SVG 剖面与导出功能作为替代。移除交互组件时调用 view.destroy(),释放它持有的缓冲、监听器和观察器。
判断一块模型是否“轻”,可以先问三件具体的事:相同顶点是否重复保存,肉眼看不到的细节是否仍在绘制,静止画面是否仍在刷新。把这些地方处理好,再对目标设备做性能测量,优化方向才有依据。