引擎工具包 @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,就可以实现两个平面的差值过度。实际试一下之后会看到,在透视投影矩阵下,网格会卷起来:
这样会导致转场过程会呈现很奇怪的卷曲动作,就像是盗梦空间中的折叠那样。出现卷曲的主要原因,是因为透视投影的视锥体深度并不是线性增长的,越靠近远平面深度变化越大。由于我们再两个平面中间做的是线性插值,会导致不同速度的变换,带来网格卷曲的表现。得到这一结论,也是因为我们观察到在正交投影矩阵下,网格就不会出现卷曲:
所以实现平滑过度的核心,就是控制投影矩阵的转换和网格旋转之间的先后顺序:
- 从 3D 到 2D:先做从透视投影转到正交投影(Camera 组件中支持自定义投影矩阵,两个矩阵进行插值后设置进去就可以),然后旋转网格
- 从 2D 到 3D:先旋转网格。再从正交投影转到透视投影。
旋转过程中可以添加一些相机运动的动画,使得相机的切换旋转更加平滑自然。
总结
本文介绍一种绘制无限大网格的方法。之前有读者可能会认为,绘制图案必须要一个模型,但实际上,利用着色器中 fract 和 mod 函数,可以在屏幕空间直接绘制出非常绚丽的分形图案。结合有关透视投影矩阵和正交投影矩阵性质的理解,实现了不同空间中网格的自然旋转过度。
如何联系我们
Galacean 开源社区群 (钉钉):
Galacean 开源社区群 (微信):
添加群管理员微信:zengxinxin2010 , 并备注 "galacean 加群"
网站
Engine 源码地址
github.com/galacean/en...
Engine Toolkit 源码地址
github.com/galacean/en...