零依赖 WebGL2 体素引擎:6221 行单文件怎么搭起来

上一篇讲的是这个游戏怎么在 20 分钟里被模型写出来。这一篇只讲代码本身:一个 238.7 KB 的 html,6221 行,渲染部分全部手写 WebGL2,没有框架,没有引擎,没有外部资源。值得看的原因是它把体素渲染的几个基本件都完整实现了一遍,而且规模小到能读完。

模块行数职责
01_util.js203数学、噪声、4×4 矩阵、视锥体
02_blocks.js17969 种方块 / 111 种物品注册表
03_textures.js728程序化贴图,128×128,每块独占一层
04_worldgen.js624地形、洞穴、矿脉、树、水、村庄、宝箱
05_renderer.js756WebGL2 渲染器、区块网格化、水面
06_render_pass.js376阴影贴图、HDR、后处理
07_icons.js564111 个物品图标,Canvas2D 绘制
08_player.js406物理、碰撞、射线拾取
09_entity_render.js321村民、掉落物、魔法球
10_ui.js460背包、36 条合成、宝箱界面
11_audio.js190WebAudio 合成音效
12_main.js1101主循环、输入、任务、昼夜、存档
各模块行数(合计 5908 行源码)
12_main1101
05_renderer756
03_textures728
04_worldgen624
07_icons564
10_ui460
08_player406
06_render_pass376
09_entity_render321
01_util203
11_audio190
02_blocks179

区块网格化:只提交看得见的面

世界是 192 × 96 × 192,切成 16×16 的区块,高度铺满。每个区块生成两份顶点缓冲:opaque 和 water,因为水的透明要和实体分开排。生成网格时逐个体素看六个邻居,只有邻居是空气、水或植物时才把这个面写进缓冲——一个区块内部的面全部丢掉,这是体素渲染最省事也最有效的一步。

AO 和光照不是算出来的颜色叠加,是打包进顶点属性的:一个 aLight 装三通道,x 是天光、y 是顶点 AO、z 是发光方块。片元着色器里取出来直接用:

float ao       = clamp(vLight.y, 0.0, 1.0);
float sh       = shadowFactor(n, l);
vec3 lighting  = diffuse + ambient * (0.55 + 0.45*ao);

每个面 4 个顶点各自取 AO,角落里的面自然压暗。这样不用屏幕空间 AO,也不用额外的室内遮蔽,代价是顶点数多了一点。

阴影:2048² 阴影贴图 + PCF 3×3

太阳是唯一的方向光,先跑一遍深度 pass 把整片地形画进阴影贴图,主 pass 里按偏移采样 3×3 做柔化。阴影尺寸写死在配置里:

const SHADOW_SIZE = 2048;

昼夜循环改的是太阳方向、光色和环境色,阴影跟着一起变。夜里所有直射项归零,只剩月光环境光,所以不会出现纯黑的地面。

HDR + Bloom + ACES

场景先渲到 RGBA16F 的 FBO(不支持浮点纹理时退到 RGBA8)。然后亮部提取、两遍高斯模糊做 Bloom,再进 ACES 色调映射,最后叠暗角和颗粒,出屏前还有一遍 FXAA。整条链是一串全屏三角形,成本很低。

湖面日落:菲涅尔反射、波动法线和深度吸收同时生效

水和天空都是算出来的

水面在同一个着色器里分两支:看到水面时按菲涅尔混反射色,角度越平反射越强,透明度也跟着变;沉到水下时整屏换成蓝绿的水下雾,并按距离加深。天空是一个全屏 pass,地平线到天顶做渐变,叠太阳光晕和日面,夜里加星星,云层用噪声驱动,会飘也会随时间变色。

贴图:一张图都没有

方块贴图用的是 TEXTURE_2D_ARRAY,每个方块一层 128×128,好处是没有图集接缝,也不用处理 UV 偏移。每层都是 Canvas2D 现画:底色渐变、多倍频噪声、污渍、矿脉里的高光点,画完直接 texImage3D 上传,开 mipmap 和各项异性过滤。

开箱与背包界面,所有物品图标都是 Canvas2D 画的

111 个物品图标同理,07_icons.js 里全是路径和渐变,没有一个 png。好处是改个颜色只要改数字,坏处是画得难看的时候只能靠调贝塞尔控制点,没办法换图。

音效也是合成的

挖掘、放置、开箱、拾取的声音都是 WebAudio 振荡器加包络实时算出来的,没有音频文件。存档就是 localStorage 存一个种子和改动列表,刷新后重建世界。

三个真实的坑

  1. 手持物品用 WebGL 精灵画,怎么都不显示。最后改成 DOM 覆盖层放一个 img,跟着视角晃,反正是第一人称,看不出破绽。
  2. 血量爱心用 innerHTML 插 SVG,渲染出来是字母 b。SVG 必须 createElementNS + setAttribute,这个错很隐蔽,因为 HTML 里完全正常。
  3. 删区块时报 m.bufs is not iterable。区块网格是 {opaque, water} 两个对象,回收时只遍历了一个,另一个没被释放。

什么时候该手写,什么时候别

这个项目手写是因为三个约束:必须离线、必须单文件、必须能双击打开。满足这三条的时候,手写反而比引引擎省事,因为不用打包、不用考虑加载失败。如果要 PBR 材质、骨骼动画、场景编辑器、后处理堆栈,那就别手写了,直接用引擎,自己写只会把时间花在重复造上。

说明行数与体积来自本机文件实测(源码 5908 行 / 成品 6221 行 / 238.7 KB)。无头 SwiftShader 下只有 1~2 FPS,是软件渲染的正常表现,不代表真实帧率。

The previous post covered how the model produced this game in 20 minutes. This one is about the code: a 238.7 KB HTML file, 6,221 lines, with the rendering written entirely in WebGL2 — no framework, no engine, no external assets. It's worth reading because it implements every core piece of voxel rendering end to end, at a size you can actually finish.

ModuleLinesResponsibility
01_util.js203Math, noise, 4×4 matrices, frustum
02_blocks.js179Registry for 69 blocks and 111 items
03_textures.js728Procedural textures, 128×128, one array layer each
04_worldgen.js624Terrain, caves, ore veins, trees, water, villages, chests
05_renderer.js756WebGL2 renderer, chunk meshing, water
06_render_pass.js376Shadow map, HDR, post-processing
07_icons.js564111 item icons drawn with Canvas2D
08_player.js406Physics, collision, ray picking
09_entity_render.js321Villagers, dropped items, magic orbs
10_ui.js460Inventory, 36 recipes, chest UI
11_audio.js190WebAudio synthesised effects
12_main.js1101Main loop, input, quests, day/night, save
Lines per module (5,908 total)
12_main1101
05_renderer756
03_textures728
04_worldgen624
07_icons564
10_ui460
08_player406
06_render_pass376
09_entity_render321
01_util203
11_audio190
02_blocks179

Chunk meshing: submit only visible faces

The world is 192 × 96 × 192, cut into 16×16 chunks at full height. Each chunk produces two vertex buffers — opaque and water — so transparency can be sorted separately from solids. While meshing, every voxel checks its six neighbours and only emits a face when the neighbour is air, water or a plant. All interior faces are dropped, which is the cheapest and most effective optimisation in voxel rendering.

AO and lighting aren't post-multiplied — they're packed into a vertex attribute. A single aLight carries three channels: sky light, vertex AO and block emission. The fragment shader just reads them:

float ao       = clamp(vLight.y, 0.0, 1.0);
float sh       = shadowFactor(n, l);
vec3 lighting  = diffuse + ambient * (0.55 + 0.45*ao);

Each of the four corners of a face samples its own AO, so corners darken naturally. No screen-space AO pass, no extra cavity shading — the cost is a slightly larger vertex stream.

Shadows: a 2048² map with 3×3 PCF

The sun is the only directional light. A depth pass renders the whole terrain into the shadow map, then the main pass samples a 3×3 kernel with offsets to soften edges. The size is a constant:

const SHADOW_SIZE = 2048;

The day/night cycle drives sun direction, light colour and ambient colour, and shadows follow. At night direct terms go to zero and only moonlight ambient remains, which is why the ground never goes pure black.

HDR, Bloom, ACES

The scene renders into an RGBA16F FBO, falling back to RGBA8 when float textures aren't available. Then a bright pass, a two-pass Gaussian for Bloom, ACES tone mapping, vignette and grain, and finally FXAA before present. The whole chain is a handful of full-screen triangles, so it's cheap.

Lake at sunset with Fresnel reflection, animated normals and depth absorption at work

Water and sky are computed

Water is one shader with two branches: above the surface it mixes reflection by Fresnel, stronger at grazing angles, with alpha following the same curve; below the surface the whole screen switches to a blue-green underwater fog that deepens with distance. The sky is a full-screen pass: horizon-to-zenith gradient, sun halo and disc, stars at night, and noise-driven clouds that drift and shift colour over time.

Textures: not a single image file

Block textures use TEXTURE_2D_ARRAY with one 128×128 layer per block — no atlas seams and no UV offset math. Every layer is drawn with Canvas2D: base gradient, multi-octave noise, stains, highlight specks in ore veins. The result goes straight into texImage3D, with mipmaps and anisotropic filtering enabled.

Chest and inventory UI; every item icon is drawn with Canvas2D

The same goes for the 111 item icons — 07_icons.js is nothing but paths and gradients. The upside: recolouring is a number change. The downside: when an icon looks wrong, the only fix is nudging Bézier control points; there's no art to swap in.

Audio is synthesised too

Mining, placing, opening chests and pickups are all WebAudio oscillators plus envelopes computed at runtime — no audio files. Saving is a seed and an edit list in localStorage; on reload the world is rebuilt from those.

Three real bugs

  1. The held item was a WebGL sprite and simply never showed up. The fix was a DOM overlay with an img that sways with the camera — in first person you can't tell.
  2. Health hearts were inserted as SVG via innerHTML and rendered as the letter 'b'. SVG needs createElementNS + setAttribute; the bug is sneaky because the markup looks perfectly fine.
  3. Deleting a chunk threw 'm.bufs is not iterable'. A chunk mesh is {opaque, water}; cleanup iterated only one of them, leaving the other unfreed.

When to hand-roll, when not to

Hand-writing made sense here because of three constraints: offline, single file, double-click to open. Under those constraints it's less work than an engine, because there's no bundling and no load failures to handle. If you need PBR materials, skeletal animation, a scene editor or a post-processing stack, don't hand-roll it — use an engine, or you'll spend the time rebuilding what already exists.

NotesLine counts and size are measured locally (5,908 lines of source, 6,221 in the built file, 238.7 KB). Headless SwiftShader runs at 1–2 FPS — normal for software rendering, and not indicative of real frame rates.
返回文章列表