设计目标
上图是一个机器监控页面,需要对开机的机器做一个指标监控,并显示在孪生空间中
- 在性能较好的PC(macbook pro)上能够流畅显示 100W个数字指标
- 指标支持动态更新,秒级全刷
- 指标支持强调显示,根据不同的值显示不同颜色,大小,animation
- 指标查看能够适应 3D空间相机的位置,进行动态调整,能看全,也可看清
方案思考
在 3D场景中有很多种方法显示文字,以R3F 的 Drei就有 Html与Text 两种方式。 其具体的实现原理不同
HTML组件
range(0, count).map(i => {
const position = [ i / side - side/2, 0, i % side - side/2]
const metricValue = parseInt(Math.random() * 300)
let color = "#00aa00"
if (metricValue > 100 && metricValue < 200) {
color = "#ffff00"
} else if (metricValue > 200) {
color = "#ff0000"
}
return <Html style={{color: color}} position={position}>
{metricValue}
</Html>
})
Html组件本质上是一个 DOM元素,通过获取 3D场景中相机位置,利用 CSS的 transfrom进行大小,方向,位置的适配。 styles的变化会引起浏览器重绘,同时 transform的计算也是在 CPU中进行。 这个方案没有验证过,在我的 Macbook M3 Pro 36G 超过 1000 个元素已经开始卡起来了, 可以从下图的 CPU时间看出
Text组件
Text组件的原理是通过 tff字体文件生成字的 SDF MapTexture, 通过 SDF在 Shader中渲染出文字。 关于 SDF的介绍可以翻看我前面大部分的文章,通过 SDF渲染文字这项技术最早是在 07 年有这篇valve开发《半条命》论文steamcdn-a.akamaihd.net/apps/valve/… 基于这项技术能够用非常少的贴图获取非常优秀的文字渲染质量。
而 R3F中则是使用了troika-3d-text。这个库对 sdf渲染文字做了一系列封装,能够在代码中引入字体并直接转化为 sdf, 并在 shader中渲染出来。 同样我们尝试下性能基线,相较于 HTML实现改动非常少,只需要将 Html标签变成Text标签即可.
<Text key={i}
color={color} position={position} fontSize={0.5}
rotation={[Math.PI * 1.5, Math.PI * 0., Math.PI * 1.0]}>
{metricValue}
</Text>
关注上面的图片,当数量到到 10K 的时候,FPS已经20 以下, 整体操作非常卡顿。可以看到 calls的数量在 6K+ 也就是我们画面中能看到的文本数。而面数到了 60K, 说明 Text这个组件一个大概会有 10 个面。这个数量有点超乎我的预料。 在WebGL中,绘制调用(draw call)是指发起绘制操作的过程,在WebGL中,常用的绘制函数是 gl.drawArrays() 和 gl.drawElements()。这将会把所有设置好的数据传递给GPU,开始执行渲染。调用draw call 相关因素
- 材质:每种材质通常会被视为一个独立的 draw call。如果一个对象使用了多种材质,每种材质都会导致单独的 draw call。
- 几何体:如果多个对象是使用同一种材质,但它们的几何体没有合并,那么每个对象都会产生一个 draw call。
- 状态变更:每一次状态变更(比如更换材质、改变渲染状态等)都会导致一个新的 draw call。
方案
方案简单来说就一句话:使用用 instancedMesh减少 drawcall的次数,同时通过 shader来渲染和控制文字。
Shader 绘制 font
16 年evanw利用javascript实现的字体转 贴图font-texture-generator和[online工具](evanw.github.io/font-textur… . 在例子中他只需要下面这个65.1kb的贴图,就可生成高清无码的文字。贴图中白色的强弱表示与字体边缘的距离。
在工具中除了生成 sdf texture之外,还会生成一份texture的 offset的数据,这份数据指导如何每个文字在贴图中位置和长宽。 当然基于这个原理也可以做中文,就像前面troika-3d-text做的一样,因为我不需要渲染中文所以没有接下去做。 渲染过程很简单. 假设我们的指标值通过 uniform传入shader. 这里为了方便将offset信息直接存储在shader中,这样我们不用给 shader传 offset信息。 如果要渲染中文,就需要将渲染的文字的纹理 offset通过 uniform数据传入。
vec4 getBoxByAsciiCode(float a) {
if (a < 44.5) return vec4(0.02707749766573296, 0.11176470588235295, 0.24929971988795518, 0.15588235294117647);
if (a < 45.5) return vec4(0.03734827264239029, 0.08235294117647059, 0.3865546218487395, 0.15588235294117647);
if (a < 46.5) return vec4(0.02707749766573296, 0.08529411764705883, 0.35947712418300654, 0.15588235294117647);
if (a < 47.5) return vec4(0.03734827264239029, 0.20588235294117646, 0.9253034547152195, 1.0);
if (a < 48.5) return vec4(0.04948646125116713, 0.20294117647058824, 0.2530345471521942, 0.7588235294117647);
if (a < 49.5) return vec4(0.03734827264239029, 0.2, 0.9561157796451915, 0.5558823529411765);
if (a < 50.5) return vec4(0.04948646125116713, 0.2, 0.715219421101774, 0.5558823529411765);
if (a < 51.5) return vec4(0.04948646125116713, 0.20294117647058824, 0.055088702147525676, 0.7588235294117647);
if (a < 52.5) return vec4(0.05042016806722689, 0.2, 0.6143790849673203, 0.5558823529411765);
if (a < 53.5) return vec4(0.04948646125116713, 0.2, 0.7647058823529411, 0.5558823529411765);
if (a < 54.5) return vec4(0.04948646125116713, 0.20294117647058824, 0.3025210084033613, 0.7588235294117647);
if (a < 55.5) return vec4(0.04855275443510738, 0.19705882352941176, 0.10830999066293184, 0.3558823529411764);
}
vec4 getDigitalCode(int a) {
return getBoxByAsciiCode(float(a) + 48.);
}
渲染的过程就是基本 SDF绘制的操作,当在边缘内在一个值就着成白色。
vec4 whxy = getDigitalCode(num);
float dist = texture2D(map, vec2(
whxy.z + whxy.x * st.x,
whxy.w - whxy.y * (1. - st.y)
)).r;
float scale = 1.0 / fwidth(dist);
float signedDistance = (dist - 0.5) * scale;
float intensity = clamp(signedDistance + 0.5, 0.0, 1.0);
color = intensity * vec3(1.0)
www.shadertoy.com/view/3tySRR
使用 SDF渲染文字更多技巧可以看看 Shadertoy, 比如这个
InstanceMesh
基本介绍
InstanceMesh 是一种用于 3D 图形渲染的技术,尤其是在 GPU 渲染管线中使用广泛。它的主要目的是优化多个相同对象的渲染,以提高性能和效率。在 3D 渲染中,如果场景中有很多相同的对象(例如树、石头或敌人角色),传统的方法是分别为每个对象构建一个网格并进行单独渲染,这会造成大量的 CPU 和 GPU 计算负担。instanceMesh 允许你使用单一的网格实例,重复渲染多个相同的对象,而不必为每一个对象单独发送数据。
-
共享数据:所有实例共享同一个网格数据,只需在 GPU 上存储一次。这包括顶点缓冲区、索引缓冲区、材质等。
-
实例化:每个实例会被赋予不同的变换矩阵(位置、旋转、缩放),这使得同一个网格数据能在不同位置和不同变形下被渲染。这些变换矩阵会在顶点着色器中使用,来计算每个实例的最终位置和形状。
-
批量渲染:GPU 可以同时处理多个实例的渲染请求,从而减少 CPU 到 GPU 的调用开销。这种批量处理可以显著提高渲染效率。
在整个渲染管线中优化的地方有
- 减少 draw calls:传统的渲染方式每个对象需要进行一个 draw call,而使用 instanceMesh,多个实例可以通过一个 draw call 一起渲染,极大减少了 CPU 的负担。
- 内存效率:通过共享网格数据,减少了内存占用,使得 GPU 的带宽利用更加高效。
- 并行处理:实例化使得 GPU 可以并行处理多个实例,因为所有的实例可以在一个渲染过程中一次性发送到 GPU,这样可以更好地利用 GPU 的并行计算能力。
单个指标属性
使用instanceMesh,我们就没办法想之前一样,为每一个指标配置不同的material. 但我们又需要为每个 instanced设置不同呃指标值,也就是说我们要找到一种方法,让每一个 instance读取到不同的值。这通常有两种实现方式
- 通过增加一个 attribute。 webgl的 attribute是为每一个点增加一些值,在这个场景下相对有点浪费,因为我们是一个 instaned传入一个值。 前面
troika-3d-text作者开源的另外一个库three-instanced-uniforms-mesh使用这种方法。 - 增加一个
THREE.DataTexture.这个 texture可以让 Shader通过 instanceIndex获取到一个值。@agargaro提供的这个@three.ez/instanced-mesh库封装了大量的辅助方法。 作者非常热心解答使用问题,并对修改意见快速响应。可以为这个项目 github.com/agargaro/in… 点一个 star
实现
一样是使用 r3f. 这里我们直接上 1 百万的文字,由于文字数量太多了,为了尽量能渲染到,我们这次使用了立方体摆放位置, 我使用了 planeGeometry 也就是一个指标会用到两个面。 经测试最个画面中到了 20w的指标还是能保持在 60fps以上。 由于我的电脑还开了很多其他东西,实际效果肯定更好。
const count = 1000000
const side = Math.ceil(Math.cbrt(count)); // 计算立方体的边长
const texture = useTexture("/font_arial.png");
const geo =new PlaneGeometry(1.0, 1.0)
const mat = new RttMaterial(count, texture, {side: THREE.DoubleSide})
const gl = useThree((state) => state.gl)
const words = new InstancedMesh2(gl, count, geo, mat);
words.createInstances((obj, i)=>{
const x = i % side; // 模运算获得 x 坐标
const y = Math.floor((i / side) % side); // 计算 y 坐标
const z = Math.floor(i / (side * side)); // 计算 z 坐标
obj.position.setX(x).setY(y).setZ(z);
obj.setUniform('vTexture', parseInt(Math.random() * 300))
})
return <primitive object={words} />
控制优化
由于我是在做一个监控页面,同时对于3D空间的操作使用 OrbitControl。这里简单分享一下我在 Shader做的控制体验优化. 包括
- 不同级别的指标显示不同大小和颜色。 例如绿色安全,黄色告警,红色危险。
- 让文字始终面对相机
- 部分渲染,当文字离视线过远,就不渲染
- 一个 plane显示多个文字,平面分块
OrbitControl介绍
OrbitControl用于实现相机的轨道控制(即围绕某个目标旋转)。它允许用户通过鼠标交互来旋转、缩放和移动相机。 球形坐标系 在三维空间中,球形坐标系是一种使用半径、极角和方位角来表示点的位置的方式。以下是这三种参数的说明:
- 半径(r):从原点到点的距离。
- 极角(θ,theta):从正 Z 轴到点的线的角度,通常在 0 到 π 之间变化。
- 方位角(φ,phi):从正 X 轴到点的线在 XY 平面上的投影的角度,通常在 0 到 2π 之间变化。
在 OrbitControls 中,操作相机时可以理解为在球形坐标系中对这些参数的变化:
- 旋转(Orbiting):通过改变方位角(φ)和极角(θ)来改变相机的视角。
- 缩放(Zooming):通过改变半径(r)来控制相机与目标的距离。
OrbitControls 鼠标交互:
- 鼠标左键拖拽:通常用于旋转相机,改变 φ 和 θ 的值。
- 鼠标滚轮滚动:用于缩放,相当于改变 r 的值,让相机靠近或远离目标。
- 鼠标右键拖拽(可选):可能用于平移相机,具体行为取决于控制器的设置。
- 目标点:OrbitControls 会围绕一个特定的目标点进行操作,这个目标点通常是你想要观察的对象或场景的中心
部分渲染
能够渲染 1 百万的指标是一个技术挑战,但在实际场景中,很难设计出一个画面显示出如此多的数字,信息还能被人类很好的吸收。 所以我们可以根据视线关注的地方,进行渲染, 视线之外就不再渲染。 这里主要是给 shader传入一个 OrbitControls的 target坐标uniform。 然后根据视线目标距离与文字位置距离。决定是否显示
uniform vec3 utargetPos;
uniform vec3 uControlSpherical;
varying float hidden;
// vertex main
hidden = distance(worldPosition.xz, utargetPos.xz) > 100. ? 1.0 : 0.0;
// frag main
if (hidden > 0.5) {
discard;
}
一个 geometry显示多个文字
其实这也是优化的地方,如果一个 plane 显示一个文字,其消耗必然会增加。毕竟指标值基本上是多位数。 通过一下 shader,将 plane划分出网格,并在不同的网格利用 st坐标绘制出不同的文字
vec2 iResolution = vec2(1.0);
vec2 uv = vUv; // plane 1*1 uv 0->1
vec2 count = vec2(6., 3.); // divided plane to 5*3 cell
vec2 tileXY = floor(uv * count);
float tileW = iResolution.x / count.x;
float tileH = iResolution.y / count.y;
float tileAspectRatio = tileH / tileW;
vec2 st = vec2(
uv.x * count.x - tileXY.x,
(uv.y * count.y - tileXY.y - 0.5) * tileAspectRatio + .5
);
文字面对相机
在画面旋转时候,我希望文字始终面对相机,通常的做法将相机的四元数旋转量给到 Geometry。
obj.quaternion.copy(camera.quaternion)
但是通过将 球坐标uControlSpherical(raidus, theta, phi) 传入 shader,可以减少对每个点做操作. 同时如果相机很高,文字会小到看不清可以更具相机距离设置文字放大系数
vec3 newPosition = position;
float rorateX = uControlSpherical.y - PI / 2.0 ;
newPosition.yz = mat2(cos(rorateX), sin(rorateX), -sin(rorateX), cos(rorateX)) * newPosition.yz;
float rotateY = PI * 1.0 - uControlSpherical.z ;
newPosition.xz = mat2(cos(rotateY), sin(rotateY), -sin(rotateY), cos(rotateY)) * newPosition.xz;
vec3 scaleConfig = rttScaleConfig(v);
float scale = min(max(uControlSpherical.x / scaleConfig.x, scaleConfig.y), scaleConfig.z);