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

引擎工具包 @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...

相关推荐
拾光拾趣录4 分钟前
CSS 深入解析:提升网页样式技巧与常见问题解决方案
前端·css
莫空00005 分钟前
深入理解JavaScript属性描述符:从数据属性到存取器属性
前端·面试
guojl6 分钟前
深度剖析Kafka读写机制
前端
FogLetter6 分钟前
图片懒加载:让网页飞起来的魔法技巧 ✨
前端·javascript·css
Mxuan7 分钟前
vscode webview 插件开发(精装篇)
前端
Mxuan8 分钟前
vscode webview 插件开发(交付篇)
前端
Mxuan9 分钟前
vscode 插件与 electron 应用跳转网页进行登录的实践
前端
拾光拾趣录10 分钟前
JavaScript 加载对浏览器渲染的影响
前端·javascript·浏览器
Codebee10 分钟前
OneCode图表配置速查手册
大数据·前端·数据可视化
然我10 分钟前
React 开发通关指南:用 HTML 的思维写 JS🚀🚀
前端·react.js·html