如何绘制一个无限大的网格

引擎工具包 @galacean/engine-toolkit 里提供了一个 GridControl 的新工具,这工具为编辑器提供了绘制无限大的网格的能力,并且支持 2D 空间和 3D 空间网格的切换。在编辑器的早期网格实现当中,网格的绘制是通过一个巨大的平面上贴上网格的贴图来实现的。当相机拉的足够远的时候,能明显看到这个平面的边界,并且由于采样贴图,很容易带来额外的锯齿问题。这一篇文章我将介绍如何通过纯 Shader 的方式绘制无限网格,使得相机无论移动到哪里,都可以自然地显示网格线。

屏幕空间绘制

我们整个绘制都在屏幕空间中进行。因此首先需要一个刚好覆盖屏幕空间的 Mesh:

typescript 复制代码
function createGridPlane(engine: Engine): ModelMesh {
    const positions: Vector3[] = new Array(6);
    positions[0] = new Vector3(1, 1, 0);
    positions[1] = new Vector3(-1, -1, 0);
    positions[2] = new Vector3(-1, 1, 0);
    positions[3] = new Vector3(-1, -1, 0);
    positions[4] = new Vector3(1, 1, 0);
    positions[5] = new Vector3(1, -1, 0);

    const indices = new Uint8Array(6);
    indices[0] = 2;
    indices[1] = 1;
    indices[2] = 0;
    indices[3] = 5;
    indices[4] = 4;
    indices[5] = 3;

    const mesh = new ModelMesh(engine);
    mesh.setPositions(positions);
    mesh.setIndices(indices);
    mesh.uploadData(true);
    mesh.addSubMesh(0, 6);

    const {bounds} = mesh;
    bounds.min.set(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
    bounds.max.set(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
    return mesh;
}

这个 Mesh 是由两个三角形组成的,一共六个顶点。同时需要使得该 Mesh 的包围盒是无限大,避免移动相机时超出了网格的边界从而被视锥体剔除掉。对于这样一个屏幕空间的 Mesh,我们的顶点着色器就不需要再乘以 MVP 矩阵:

typescript 复制代码
void main() {
    gl_Position = vec4(POSITION, 1.0);// using directly the clipped coordinates
}

3D 空间中的网格

3D 空间中的网格即世界空间中 y = 0 对应的 x-z 平面,但目前我们只有屏幕空间坐标,因此需要通过逆 MVP 变换把他变回到世界空间当中。我们需要视锥体近平面和远平面的的点变回去,并且传到片段着色器中作进一步的计算:

typescript 复制代码
vec3 UnprojectPoint(float x, float y, float z) {
    vec4 unprojectedPoint =  u_viewInvMat * u_projInvMat * vec4(x, y, z, 1.0);
    return unprojectedPoint.xyz / unprojectedPoint.w;
}

void main() {
    nearPoint = UnprojectPoint(POSITION.x, POSITION.y, -1.0).xyz;// unprojecting on the near plane
    farPoint = UnprojectPoint(POSITION.x, POSITION.y, 1.0).xyz;// unprojecting on the far plane
    gl_Position = vec4(POSITION, 1.0);// using directly the clipped coordinates
}`,

当片段着色器拿到这两个点之后,其实就类似于获得了一条从相机出发,沿着这两个点发射出去的射线。由此就可以计算射线与 y = 0 的交点。

typescript 复制代码
float ty = -nearPoint.y / (farPoint.y - nearPoint.y);
vec3 fragPos3D = nearPoint + ty * (farPoint - nearPoint);

fragPos3D 就是 x-z 平面上的交点。有了这个点我们就可以计算该点的深度,并且赋值给:gl_FragDepth

typescript 复制代码
float computeDepth(vec3 pos) {
    vec4 clip_space_pos = u_projMat * u_viewMat * vec4(pos.xyz, 1.0);
    // map to 0-1
    return (clip_space_pos.z / clip_space_pos.w) * 0.5 + 0.5;
}

需要特别注意的是,在 WebGL 当中,视锥体 z 值的取值范围是[-1, 1],但是深度贴图的范围是[0, 1],所以还额外做了一个尺度的缩放。这样一来,就可以绘制 x-z 的平面,并且该平面有一个正确的深度:

有了这个平面,我们就可以绘制网格了,网格绘制的核心是对 fragPos3D 做周期变换,从而使得 alpha 值在网格线上是 1:

typescript 复制代码
vec4 grid(vec3 fragPos3D, float scale, bool drawAxis) {
    vec2 derivative = fwidth(fragPos3D.xz);
    vec2 grid = abs(fract(fragPos3D.xz - 0.5) - 0.5) / derivative;
    float line = min(grid.x, grid.y);
    return vec4(u_gridIntensity, u_gridIntensity, u_gridIntensity, 1.0 - min(line, 1.0));
}

其中的核心是 fract 函数,该函数返回 fragPos3D.xz - 0.5 的小数部分,这样就可以让相机移动到任何位置,都可以无限绘制出对应的网格线。这样的方式绘制出来的网格,远处会越来越密,从而显得很白:

所以最终的效果还需要对 alpha 值按照距离做一个缩放,类似雾的效果。由于透视投影矩阵的深度是非线性的,因此我们需要一个线性化的距离:

typescript 复制代码
float computeLinearDepth(vec3 pos) {
    vec4 clip_space_pos = u_projMat * u_viewMat * vec4(pos.xyz, 1.0);
    float clip_space_depth = clip_space_pos.z / clip_space_pos.w;
    float linearDepth = (2.0 * u_near * u_far) / (u_far + u_near - clip_space_depth * (u_far - u_near));
    return linearDepth / u_far;// normalize
}

根据距离计算出一个 fade 系数乘到 alpha 上面:

typescript 复制代码
float linearDepth = computeLinearDepth(fragPos3D);
float fading = max(0.0, (0.5 - linearDepth));
gl_FragColor = grid(fragPos3D, u_primaryScale, true);
gl_FragColor.a *= fading;

还可以通过叠加网格线的方式,使得网格呈现出一种大格子嵌套小格子的表现。具体细节可以参考代码的实现。

2D 空间中的网格

2D 空间的网格是覆盖全屏幕的,但放在 3D 空间的话其实是 x-y 平面,等于说转了一个角度。但是有了上面的代码,需要修改的就是将 z 变成 y:

typescript 复制代码
float tz = -nearPoint.z / (farPoint.z - nearPoint.z);
vec3 fragPos3D = nearPoint + tz * (farPoint - nearPoint);

也就是求射线与 z = 0 平面的交点,从而绘制出 x-y 平面。

2D 与 3D 空间的转换

在编辑器当中,有时候需要在 2D 和 3D 空间中进行转换。在过去基于贴图的方案中,这件事非常容易做,直接旋转网格平面就好了。但是这一次改成了纯 Shader 生成的方式,这件事就没有那么容易进行。

首先想到了的是,既然 3D 空间是 x-z 平面,2D 空间是 x-y 平面,也就是说每一条从相机发出的射线,和这两个平面分别都有一个交点,我只需在这两个交点之间做插值不就可以旋转平面了吗?于是首先把 Shader 改成:

typescript 复制代码
float ty = -nearPoint.y / (farPoint.y - nearPoint.y);
float tz = -nearPoint.z / (farPoint.z - nearPoint.z);
float t = mix(ty, tz, u_flipProgress);
vec3 fragPos3D = nearPoint + t * (farPoint - nearPoint);

随着 u_flipProgress 从 0 增长到 1,就可以实现两个平面的差值过度。实际试一下之后会看到,在透视投影矩阵下,网格会卷起来:

这样会导致转场过程会呈现很奇怪的卷曲动作,就像是盗梦空间中的折叠那样。出现卷曲的主要原因,是因为透视投影的视锥体深度并不是线性增长的,越靠近远平面深度变化越大。由于我们再两个平面中间做的是线性插值,会导致不同速度的变换,带来网格卷曲的表现。得到这一结论,也是因为我们观察到在正交投影矩阵下,网格就不会出现卷曲:

所以实现平滑过度的核心,就是控制投影矩阵的转换和网格旋转之间的先后顺序:

  1. 从 3D 到 2D:先做从透视投影转到正交投影(Camera 组件中支持自定义投影矩阵,两个矩阵进行插值后设置进去就可以),然后旋转网格
  2. 从 2D 到 3D:先旋转网格。再从正交投影转到透视投影。

旋转过程中可以添加一些相机运动的动画,使得相机的切换旋转更加平滑自然。

总结

本文介绍一种绘制无限大网格的方法。之前有读者可能会认为,绘制图案必须要一个模型,但实际上,利用着色器中 fract 和 mod 函数,可以在屏幕空间直接绘制出非常绚丽的分形图案。结合有关透视投影矩阵和正交投影矩阵性质的理解,实现了不同空间中网格的自然旋转过度。

如何联系我们

Galacean 开源社区群 (钉钉):

Galacean 开源社区群 (微信):

添加群管理员微信:zengxinxin2010 , 并备注 "galacean 加群"

网站

官网地址
galacean.antgroup.com

Engine 源码地址
github.com/galacean/en...

Engine Toolkit 源码地址
github.com/galacean/en...

相关推荐
yery32 分钟前
Ubuntu24.04中安装Electron
前端·javascript·electron
小夏同学呀37 分钟前
使用elementplus中的分页器,后端一次性返100条数据,前端自己做分页处理,vue3写法
前端·javascript·vue.js
Mr.Lee082139 分钟前
electron-vite使用vue-i18n,ts 检查报错上不存在属性“$t”
前端·javascript·vue.js·typescript·electron
你的Maya1 小时前
使用 Vite 打包工具库并使用 GitHub Actions 自动化发布npm流程
前端·npm·github
zzzzzzzziu1 小时前
vue3基础
前端·javascript·vue.js
Jasonakeke2 小时前
【JavaWeb】二、HTML 入门
前端·html
2301_796143792 小时前
Vue的指令v-model的原理
前端·javascript·vue.js
anyup_前端梦工厂2 小时前
探索 Web Speech API:实现浏览器语音识别与合成
前端·javascript·html
xgq2 小时前
Wake Lock API:保持设备唤醒的利器
前端·javascript·面试
Jacky-YY2 小时前
Nginx-HTTP和反向代理web服务器
服务器·前端·nginx·http