Three.js 无限地面网格(WebGPU)

发布时间:2026/8/4 22:51:18
Three.js 无限地面网格(WebGPU) Three.js 无限地面网格基于WebGPUTSL实现的无限地面网格0. 先看最终效果关键特征你走一步网格也跟着走一步 → 永远在你脚下近处的线很清晰远处的线慢慢淡出有粗线和细线两层像坐标纸一样1. 先搞清楚网格到底是个什么东西别想复杂了。网格本质上就是一块跟着你走的大地板。用生活来类比想象你在一个巨大的操场上手里拿着一张A4 大小的坐标纸。你把纸放在脚下低头看——纸上画满了格子。你往前走 10 米纸也跟着你往前挪 10 米。你再低头看——纸上还是画满格子跟刚才一模一样。这就是无限网格的全部秘密不是操场上有画满格子的地面而是你手里那张纸一直跟着你在代码里那张纸 一个 200×200 的大平面PlaneGeometry纸上的格子 用数学公式在 GPU 里画出来的线跟着你走 每帧把平面的位置设成相机的位置2. 搭一个最简框架先让平面出现在场景里我们先不画线先把那张纸铺在地上。import*asTHREEfromthree/webgpu;// 步骤1做一个 200×200 的大平面constgeometrynewTHREE.PlaneGeometry(200,200);// 步骤2旋转一下让它平躺在地上默认是竖着的geometry.rotateX(-Math.PI/2);// 为什么要转PlaneGeometry 默认是 XY 平面竖着的墙// 转 -90° 就变成 XZ 平面平躺的地板// 步骤3给平面一个材质先随便给个颜色看得见就行constmaterialnewTHREE.MeshBasicNodeMaterial();// 步骤4组合成网格对象constgridMeshnewTHREE.Mesh(geometry,material);// 步骤5扔进场景scene.add(gridMesh);现在你的场景里多了一个巨大的平面。但它还只是个纯色的板子没有格子。3. 在平面上画线——认识两个神奇的数学工具画格子的代码写在着色器Shader里。着色器是在GPU上运行的小程序专门负责计算每个像素的颜色。我们用的不是传统的 GLSL 写法而是 Three.js 的TSL节点式写法看起来更像普通 JavaScript。但在写代码之前先认识两个数学工具工具一fract(x)—— 把数字切成无限重复的片段fract的意思是取小数部分。fract(0.0) 0.0 fract(0.3) 0.3 fract(0.9) 0.9 fract(1.0) 0.0 ← 回到 0 fract(1.2) 0.2 fract(2.7) 0.7 fract(3.0) 0.0 ← 又回到 0它的作用把任意大的数字折叠回 0 到 1 之间。就像钟表不管现在是 1 点、13 点还是 25 点时针都指在同一个位置。fract就是给数字做取模运算。这对画网格有什么用网格线每隔 1 米就要重复一次。fract(世界坐标)正好把你离原点多少米折叠成你离最近的那条网格线多少米。工具二fwidth(x)—— 自动计算线条该多粗这个更难理解但你可以先把它当成魔法。fwidth(x)回答的问题是“在屏幕上相邻两个像素之间x 的值差了多少”屏幕上的两个相邻像素 ┌──┐ ┌──┐ x 的值: 0.1 0.3 fwidth(x) |0.3 - 0.1| 0.2用它来做什么让线条无论屏幕分辨率高低始终只占 1 个像素宽。你不需要完全理解它。先记住某个值 / fwidth(那个值) 自动适配线宽 自动抗锯齿。后面会看到怎么用。4. 画第一条线——只画 X 方向的细线我们来一步步推导。先只画一个方向的线比如 X 方向让你看清每一行代码在做什么。第一步把世界坐标按间距缩放constr1xwp.x.div(uSize1);// 世界坐标 X ÷ 1 X 本身假设uSize1 1每 1 米一条线相机站在 (3.4, 0, 5.2)wp.x 3.4 → r1x 3.4 wp.x 4.0 → r1x 4.0 ← 在网格线上 wp.x 4.7 → r1x 4.7第二步用 fract 找离最近网格线有多近fract(r1x-0.5)-0.5来算几个例子r1x 3.4: fract(3.4 - 0.5) - 0.5 fract(2.9) - 0.5 0.9 - 0.5 0.4 r1x 4.0: fract(4.0 - 0.5) - 0.5 fract(3.5) - 0.5 0.5 - 0.5 0.0 ← 正好在线上 r1x 4.7: fract(4.7 - 0.5) - 0.5 fract(4.2) - 0.5 0.2 - 0.5 -0.3发现了什么当坐标正好在整数位置即网格线位置时结果 0。第三步取绝对值abs(fract(r1x-0.5)-0.5)r1x 3.4: | 0.4| 0.4 r1x 4.0: | 0.0| 0.0 ← 线上 0 r1x 4.7: |-0.3| 0.3第四步除以 fwidth魔法时刻abs(fract(r1x-0.5)-0.5)/fwidth(r1x)这个除法的效果在网格线上值 ≈ 00 ÷ fwidth 很小 → 黑色像素 → 线条 在两线中间值 ≈ 0.50.5 ÷ fwidth ≈ 很大 → 白色像素 → 空白这就是画线的全部秘密不是真的画一条线而是让每个像素自己计算——“我离最近的线有多近越近越黑。”5. 画完整的细网格X Z 两个方向上面只画了 X 方向。加上 Z 方向就得到完整的格子// X 方向constr1xwp.x.div(uSize1);// Z 方向constr1zwp.z.div(uSize1);// 合并成二维向量constr1vec2(r1x,r1z);// 两个方向同时计算constgr1abs(fract(r1.sub(0.5)).sub(0.5)).div(fwidth(r1));// gr1.x X 方向的离线条距离// gr1.z Z 方向的离线条距离// 取两个方向中离线条更近的那个constg1float(1).sub(min(min(gr1.x,gr1.y),1));用大白话解释最后一行g1表示这个像素在不在一根细线上。如果在线上不管 X 方向还是 Z 方向→g1 ≈ 1不透明如果不在线上 →g1 ≈ 0完全透明6. 加入粗网格——复制粘贴再改个间距粗网格和细网格的代码一模一样只改一个数字——间距从 1 改成 10// 细网格间距 1constr1vec2(wp.x.div(uSize1),wp.z.div(uSize1));// ...// 粗网格间距 10 ← 只有这个数字不一样constr2vec2(wp.x.div(uSize2),wp.z.div(uSize2));constgr2abs(fract(r2.sub(0.5)).sub(0.5)).div(fwidth(r2));constg2float(1).sub(min(min(gr2.x,gr2.y),1));效果细线每 1 米一条粗线每 10 米一条。粗线刚好和某些细线重合10 的倍数位置在那些位置上粗线覆盖细线。7. 让远处淡出——距离衰减现在网格延伸到无限远不太真实。我们要让远处慢慢变透明// 计算当前像素到相机的距离只算水平方向constdxwp.x.sub(cp.x);// 水平 X 距离constdzwp.z.sub(cp.z);// 水平 Z 距离constdistdx.mul(dx).add(dz.mul(dz)).sqrt();// 勾股定理√(dx² dz²)// 归一化距离 ÷ 最大可见距离constdistNormdist.div(uDistance);// 0 脚下, 1 边界, 1 超出// 反转1 - 距离近处1远处0constdfloat(1).sub(min(distNorm,1));// 三次方让近处更清晰远处快速衰减constfadepow(d,3);为什么要三次方对比一下线性d¹: 10m处90%可见, 50m处50%可见, 90m处10%可见 ← 过渡太慢 三次方d³: 10m处73%可见, 50m处12%可见, 90m处0.1%可见 ← 近处清晰远处快消失8. 合成所有效果——拼图时刻现在我们有g1细网格蒙版在细线上1不在0g2粗网格蒙版在粗线上1不在0fade距离衰减系数近处1远处0把它们拼在一起// 步骤1细线优先。有细线g1≈1就用它没有就用粗线letalphamix(g2,g1,g1).mul(fade);// ↑ ↑ ↑ ↑// 粗线 细线 权重 距离衰减// 解读g11 → 选 g1细线g10 → 选 g2粗线// 步骤2粗线处翻倍透明度让它更显眼alphamix(alpha.mul(0.5),alpha,g2);// 普通区域半透明 粗线处全不透明 只在粗线处生效// 步骤3颜色也混合——粗线处更白constcolmix(uColorFine.rgb,uColorCoarse.rgb,g2);// 灰色 #888888 白色 #ffffff 粗线处混入白色// 步骤4输出最终颜色returnvec4(col,alpha);9. 配置材质——最后一步设置constmaterialnewTHREE.MeshBasicNodeMaterial();material.colorNodecolorNode;// 把我们写的着色器挂上去material.transparenttrue;// 开启透明不然看不到线之间的空隙material.depthWritefalse;// 不写深度网格后面的东西不会被挡住还有几个重要设置this.frustumCulledfalse;// 不做视锥裁剪防止平面被意外切掉this.renderOrder-99999;// 最先渲染让其他物体画在网格上面10. 让网格跟着相机走在渲染循环里每帧执行update(camera){// 对齐到整数坐标防止线条抖动this.position.xMath.round(camera.position.x);this.position.zMath.round(camera.position.z);// 稍微低于地面避免和其他物体重叠闪烁this.position.y-0.01;}为什么用Math.round不用 round直接跟相机 用了 round对齐到整数 相机在 (3.4, 0, 5.2) 相机在 (3.4, 0, 5.2) 网格在 (3.4, 0, 5.2) 网格在 (3.0, 0, 5.0) → 线条在 3.4 位置 → 线条在 3.0 位置 → 下一帧相机动了线条又变了 → 线条位置始终稳定 → 看起来在抖因为着色器用fract(世界坐标)生成线条平面移动了 0.1 米线条也移动 0.1 米。对齐到整数后线条始终落在整数位置不会抖。11. 完整代码可以直接复制使用import*asTHREEfromthree/webgpu;import{Fn,uniform,vec2,vec4,float,positionWorld,cameraPosition,fract,fwidth,abs,min,mix,pow,}fromthree/tsl;exportclassCustomGridextendsTHREE.Mesh{constructor(size11,// 细线间距默认 1 米size210,// 粗线间距默认 10 米colorFine#888888,// 细线颜色colorCoarse#ffffff,// 粗线颜色distance100,// 最远可见距离){// 1 创建大平面躺在地上constgeometrynewTHREE.PlaneGeometry(distance*2,distance*2);geometry.rotateX(-Math.PI/2);// 2 准备参数传给 GPU 的变量constuSize1uniform(size1);constuSize2uniform(size2);constuColorFineuniform(newTHREE.Color(colorFine));constuColorCoarseuniform(newTHREE.Color(colorCoarse));constuDistanceuniform(distance);// 3 着色器计算每个像素的颜色constcolorNodeFn((){constwppositionWorld;// 当前像素在世界中的位置constcpcameraPosition;// 相机位置// --- 距离衰减 ---constdxwp.x.sub(cp.x);constdzwp.z.sub(cp.z);constdistdx.mul(dx).add(dz.mul(dz)).sqrt().div(uDistance);constdfloat(1).sub(min(dist,1));constfadepow(d,3);// --- 细网格 ---constr1vec2(wp.x.div(uSize1),wp.z.div(uSize1));constgr1abs(fract(r1.sub(0.5)).sub(0.5)).div(fwidth(r1));constg1float(1).sub(min(min(gr1.x,gr1.y),1));// --- 粗网格 ---constr2vec2(wp.x.div(uSize2),wp.z.div(uSize2));constgr2abs(fract(r2.sub(0.5)).sub(0.5)).div(fwidth(r2));constg2float(1).sub(min(min(gr2.x,gr2.y),1));// --- 合成 ---letalphamix(g2,g1,g1).mul(fade);alphamix(alpha.mul(0.5),alpha,g2);constcolmix(uColorFine.rgb,uColorCoarse.rgb,g2);returnvec4(col,alpha);})();// 4 材质配置constmaterialnewTHREE.MeshBasicNodeMaterial();material.colorNodecolorNode;material.transparenttrue;material.depthWritefalse;super(geometry,material);this.frustumCulledfalse;this.renderOrder-99999;}// 5 每帧更新跟着相机走update(camera){this.position.xMath.round(camera.position.x);this.position.zMath.round(camera.position.z);this.position.y-0.01;}dispose(){this.material.dispose();this.geometry.dispose();if(this.parent)this.removeFromParent();}}使用方式// 创建网格constgridnewCustomGrid();scene.add(grid);// 在渲染循环中functionanimate(){requestAnimationFrame(animate);grid.update(camera);// ← 每帧调用让网格跟着相机renderer.render(scene,camera);}animate();12 答疑问题答案为什么不用真的画线段性能。画几万条线段 GPU 扛不住着色器算一个公式就能搞定所有像素fract 和 fwidth 一定要懂原理吗刚开始不需要。先用起来看到效果后再慢慢理解能在 WebGL 上用吗可以把 TSL 写法换成 GLSL 字符串数学原理完全一样怎么改线的颜色改colorFine和colorCoarse参数怎么改线间距改size1细线间距和size2粗线间距参考https://github.com/Fyrestar/THREE.InfiniteGridHelper