352 lines
11 KiB
Markdown
352 lines
11 KiB
Markdown
# TactileIpc3D:圆点(传感点)绘制说明
|
||
|
||
这份 README 专门把“圆点(dots)是怎么画出来的”单独拎出来讲清楚:从 **CPU 端准备数据**、到 **GPU 端着色器如何把一个 quad 变成很多个圆点**。
|
||
|
||
---
|
||
|
||
## 关键文件(只看这几个就够)
|
||
|
||
- C++ 侧
|
||
- `src/glwidget.cpp`
|
||
- `GLWidget::initDotGeometry_()`:创建 dots 的 VAO/VBO/instanceVBO,并声明 attribute 布局
|
||
- `GLWidget::updateInstanceBufferIfNeeded_()`:把每个点的 `(offsetX, offsetZ, value)` 上传到 GPU
|
||
- `GLWidget::paintGL()`:设置 uniforms,然后 `glDrawArraysInstanced()` 一次性画出 N 个点
|
||
- GLSL 侧(shader)
|
||
- `shaders/dots.vert`:把“单位 quad”放到每个点的位置,并乘上 `uMVP`
|
||
- `shaders/dots.frag`:在像素级别把 quad “裁剪”为圆形,并根据 value 上色
|
||
|
||
---
|
||
|
||
## 总体思路:一个 quad + instanced rendering
|
||
|
||
我们并没有为每个点生成一个圆形网格(那样会很重),而是:
|
||
|
||
1. **只建一个单位 quad(两个三角形)**,共 6 个顶点。
|
||
2. 用 **Instanced Rendering**:一条 draw call 画很多个 instance。
|
||
3. 每个 instance 在 GPU 上拿到自己的 **位置偏移** + **传感值**,从而变成不同位置/不同颜色的圆点。
|
||
|
||
对应的 OpenGL draw call:
|
||
|
||
```cpp
|
||
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, m_instanceCount);
|
||
```
|
||
|
||
- `0..6`:每个点用 6 个顶点(两个三角形)组成 quad
|
||
- `m_instanceCount`:要画多少个点(rows*cols)
|
||
|
||
---
|
||
|
||
## 1) Dot 的“基础几何”:单位 quad(VBO, per-vertex)
|
||
|
||
在 `GLWidget::initDotGeometry_()` 里,先创建一个单位 quad。它在局部空间的范围是 `[-1, 1]`:
|
||
|
||
```cpp
|
||
struct V { float x, y; float u, v; };
|
||
V quad[6] = {
|
||
{-1,-1, 0,0}, { 1,-1, 1,0}, { 1, 1, 1,1},
|
||
{-1,-1, 0,0}, { 1, 1, 1,1}, {-1, 1, 0,1},
|
||
};
|
||
```
|
||
|
||
这里的含义:
|
||
|
||
- `x,y`:这个 quad 的“局部顶点坐标”,后续会乘以 `uDotRadius` 变成实际大小
|
||
- `u,v`:UV 坐标,fragment shader 用它来判断像素是否在圆内(圆外就 `discard`)
|
||
|
||
然后绑定 attribute:
|
||
|
||
```cpp
|
||
// location 0: quad 顶点位置 (x,y)
|
||
glEnableVertexAttribArray(0);
|
||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0);
|
||
|
||
// location 1: UV (u,v)
|
||
glEnableVertexAttribArray(1);
|
||
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(2 * sizeof(float)));
|
||
```
|
||
|
||
这必须和 `shaders/dots.vert` 的声明对应:
|
||
|
||
```glsl
|
||
layout(location = 0) in vec2 qQuadPos;
|
||
layout(location = 1) in vec2 aUV;
|
||
```
|
||
|
||
---
|
||
|
||
## 2) Instance 数据:每个点一条记录(instanceVBO, per-instance)
|
||
|
||
每个点(instance)需要独有的数据。我们用 3 个 float 表示:
|
||
|
||
- `offsetX`
|
||
- `offsetZ`
|
||
- `value`(传感值,用于上色)
|
||
|
||
在 C++ 里分配 instance VBO:
|
||
|
||
```cpp
|
||
glGenBuffers(1, &m_instanceVbo);
|
||
glBindBuffer(GL_ARRAY_BUFFER, m_instanceVbo);
|
||
glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 3 * qMax(1, dotCount()), nullptr, GL_DYNAMIC_DRAW);
|
||
```
|
||
|
||
然后把它绑定到 attribute location 2/3,并设置 divisor=1:
|
||
|
||
```cpp
|
||
// location 2: vec2 iOffsetXZ(每个 instance 使用一次)
|
||
glEnableVertexAttribArray(2);
|
||
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
|
||
glVertexAttribDivisor(2, 1);
|
||
|
||
// location 3: float iValue(每个 instance 使用一次)
|
||
glEnableVertexAttribArray(3);
|
||
glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)(2 * sizeof(float)));
|
||
glVertexAttribDivisor(3, 1);
|
||
```
|
||
|
||
`glVertexAttribDivisor(..., 1)` 是 instancing 的关键:
|
||
|
||
- divisor=0(默认):每个顶点取一条数据(per-vertex)
|
||
- divisor=1:每画一个 instance 才更新一次(per-instance)
|
||
|
||
对应 shader 里的声明:
|
||
|
||
```glsl
|
||
layout(location = 2) in vec2 iOffsetXZ;
|
||
layout(location = 3) in float iValue;
|
||
```
|
||
|
||
---
|
||
|
||
## 3) 每帧如何更新 dots 的位置和值(上传 instance buffer)
|
||
|
||
在 `GLWidget::updateInstanceBufferIfNeeded_()` 里,代码会把点阵居中摆放,并把 value 写进去:
|
||
|
||
```cpp
|
||
const float w = (m_cols - 1) * m_pitch;
|
||
const float h = (m_rows - 1) * m_pitch;
|
||
|
||
for (int i = 0; i < n; ++i) {
|
||
const int r = (m_cols > 0) ? (i / m_cols) : 0;
|
||
const int c = (m_cols > 0) ? (i % m_cols) : 0;
|
||
|
||
const float x = (c * m_pitch) - w * 0.5f;
|
||
const float z = (r * m_pitch) - h * 0.5f;
|
||
|
||
inst[i * 3 + 0] = x; // offsetX
|
||
inst[i * 3 + 1] = z; // offsetZ
|
||
inst[i * 3 + 2] = valuesCopy[i]; // value
|
||
}
|
||
```
|
||
|
||
最后把 `inst` 上传到 GPU:
|
||
|
||
```cpp
|
||
glBindBuffer(GL_ARRAY_BUFFER, m_instanceVbo);
|
||
glBufferSubData(GL_ARRAY_BUFFER, 0, inst.size() * sizeof(float), inst.constData());
|
||
```
|
||
|
||
---
|
||
|
||
## 4) Vertex Shader:把 quad “放到每个点的位置”
|
||
|
||
`shaders/dots.vert`(简化后的核心逻辑):
|
||
|
||
```glsl
|
||
layout(location = 0) in vec2 qQuadPos; // 单位 quad 顶点
|
||
layout(location = 1) in vec2 aUV; // UV
|
||
layout(location = 2) in vec2 iOffsetXZ; // 每个点的 offset(X,Z)
|
||
layout(location = 3) in float iValue; // 每个点的 value
|
||
|
||
out vec2 vUV;
|
||
out float vValue;
|
||
|
||
uniform mat4 uMVP;
|
||
uniform float uDotRadius;
|
||
uniform float uBaseY;
|
||
|
||
void main() {
|
||
vUV = aUV;
|
||
vValue = iValue;
|
||
|
||
// dot 中心点(世界坐标)
|
||
vec3 world = vec3(iOffsetXZ.x, uBaseY, iOffsetXZ.y);
|
||
|
||
// 把单位 quad 按半径缩放,并铺在 XZ 平面上
|
||
world.x += qQuadPos.x * uDotRadius;
|
||
world.z += qQuadPos.y * uDotRadius;
|
||
|
||
// 世界坐标 -> 裁剪空间
|
||
gl_Position = uMVP * vec4(world, 1.0);
|
||
}
|
||
```
|
||
|
||
这里要理解的点:
|
||
|
||
- `qQuadPos` 是局部空间([-1,1]),乘 `uDotRadius` 才是实际大小
|
||
- `iOffsetXZ` 是每个点的平移(让同一个 quad 出现在不同位置)
|
||
- `uMVP` 把世界坐标变换到屏幕(不然你会“啥也看不到”)
|
||
|
||
---
|
||
|
||
## 5) Fragment Shader:把 quad “裁剪成圆”,并用 value 上色
|
||
|
||
`shaders/dots.frag`(核心逻辑):
|
||
|
||
```glsl
|
||
in vec2 vUV;
|
||
in float vValue;
|
||
out vec4 FragColor;
|
||
|
||
uniform float uMinV;
|
||
uniform float uMaxV;
|
||
|
||
void main() {
|
||
// vUV: [0,1] -> [-1,1]
|
||
vec2 p = vUV * 2.0 - 1.0;
|
||
float r2 = dot(p, p);
|
||
if (r2 > 1.0) discard; // 圆外丢弃像素
|
||
|
||
float t = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0);
|
||
vec3 heat = mix(vec3(0, 1, 0), vec3(1, 0, 0), t); // 绿->红
|
||
|
||
FragColor = vec4(heat, 1.0);
|
||
}
|
||
```
|
||
|
||
这样一个 quad 就“看起来像圆点”了。
|
||
|
||
---
|
||
|
||
## 6) 真正开始画:设置 uniforms + draw
|
||
|
||
在 `GLWidget::paintGL()` 里,绘制 dots 的部分大概是:
|
||
|
||
```cpp
|
||
m_dotsProg->bind();
|
||
m_dotsProg->setUniformValue("uMVP", m_mvp);
|
||
m_dotsProg->setUniformValue("uDotRadius", m_dotRadius);
|
||
m_dotsProg->setUniformValue("uBaseY", (m_panelH * 0.5f) + 0.001f);
|
||
m_dotsProg->setUniformValue("uMinV", float(m_min));
|
||
m_dotsProg->setUniformValue("uMaxV", float(m_max));
|
||
|
||
glBindVertexArray(m_dotsVao);
|
||
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, m_instanceCount);
|
||
glBindVertexArray(0);
|
||
m_dotsProg->release();
|
||
```
|
||
|
||
> `uBaseY` 加 `0.001f` 是为了避免 dots 和 panel 顶面完全重合产生 z-fighting(闪烁)。
|
||
|
||
---
|
||
|
||
## 常见“看不到点”的原因(排查清单)
|
||
|
||
1. **vertex shader 没写 `gl_Position`**(会直接编译失败/链接失败)
|
||
2. **shader 文件没读到**(Qt Resource 一定要用 `:/shaders/xxx.vert` 这种路径)
|
||
3. **`m_instanceCount == 0`** 或 `dotCount()==0`(rows/cols 没设置)
|
||
4. **忘了 `glVertexAttribDivisor(2/3, 1)`**(instance 数据会“按顶点乱跳”)
|
||
5. **uniform 类型不匹配**(例如 shader 里是 `float`,C++ 却用 `int` 设置)
|
||
6. **相机没对准**(`uMVP` 不正确/相机离得太近或太远)
|
||
|
||
---
|
||
|
||
## 想继续改进?
|
||
|
||
- 想让 dot 大小随 value 变化:在 `dots.vert` 里用 `iValue` 改 `uDotRadius`(比如乘一个比例)
|
||
- 想要更柔和的圆边:用 `smoothstep` 做 alpha 边缘 + 开启 blending
|
||
- 想要真正的 3D 圆柱/球:就不能靠 discard 了,需要真正的几何或法线光照
|
||
|
||
## Metrics(指标计算)
|
||
|
||
Metrics 是对**单帧 payload**的统计结果,payload 即 `DataFrame.data`。每次接收新帧或回放 tick,
|
||
`DataBackend::emitFrame_()` 会先调用 `updateMetrics_()`,然后发出 `metricsChanged`,QML 端通过
|
||
`Backend.data.metric*` 读取这些值。
|
||
|
||
- Peak:`max(v)`
|
||
- RMS:`sqrt(mean(v^2))`
|
||
- Avg:`mean(v)`
|
||
- Delta:`max(v) - min(v)`
|
||
- Sum:`sum(v)`
|
||
|
||
代码位置:`src/data_backend.cpp`(`updateMetrics_()`)
|
||
|
||
## Live Trend(Payload Sum)
|
||
|
||
右侧面板的 **Payload Sum** 折线图来自 `metricSum`:每次 `metricsChanged` 时,如果已有帧数据,
|
||
就会执行 `card.plot.append(Backend.data.metricSum)`。
|
||
|
||
- 计算来源:`DataBackend::updateMetrics_()`(`metricSum = sum(DataFrame.data)`)
|
||
- 触发位置:`qml/content/RightPanel.qml`(`Connections { onMetricsChanged { ... } }`)
|
||
- 绘图组件:`qml/content/LiveTrendCard.qml` + `SparklinePlot`(`LiveTrend` 模块)
|
||
|
||
## Live Trend 的 HiDPI 对齐(文字清晰度/位置)
|
||
|
||
SparklinePlot 的刻度文字通过 `QImage -> QSGTexture` 绘制。为避免高 DPI 下文字发虚或 y 方向偏移,
|
||
实现上做了两件事:
|
||
|
||
- 按 `devicePixelRatio` 生成缓存 key 与 QImage 像素尺寸
|
||
- `QImage::setDevicePixelRatio(dpr)`,并在布局时按像素网格对齐
|
||
|
||
相关实现:
|
||
|
||
```cpp
|
||
// src/sparkline_plotitem.h
|
||
private:
|
||
QSGTexture* getTextTexture(const QString& text, const QFont& font, QSize* outSize = nullptr);
|
||
```
|
||
|
||
```cpp
|
||
// src/sparkling_plotitem.cpp
|
||
QSGTexture* SparklinePlotItem::getTextTexture(const QString& text, const QFont& font, QSize* outSize) {
|
||
const qreal dpr = window() ? window()->devicePixelRatio() : 1.0;
|
||
const QString key = text + "|" + font.family() + "|" + QString::number(font.pixelSize()) +
|
||
"|" + QString::number(dpr, 'f', 2);
|
||
...
|
||
const QSize pixelSize(qMax(1, qRound(sz.width() * dpr)), qMax(1, qRound(sz.height() * dpr)));
|
||
QImage img(pixelSize, QImage::Format_ARGB32_Premultiplied);
|
||
img.setDevicePixelRatio(dpr);
|
||
...
|
||
m_textCache[key] = { tex, sz };
|
||
if (outSize)
|
||
*outSize = sz;
|
||
return tex;
|
||
}
|
||
|
||
QSGNode* SparklinePlotItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
|
||
const qreal dpr = window()->devicePixelRatio();
|
||
auto alignToPixel = [dpr](float v) -> float {
|
||
return (dpr > 0.0) ? (std::round(v * dpr) / dpr) : v;
|
||
};
|
||
...
|
||
float py = alignToPixel(bottom - yn * plotH);
|
||
...
|
||
QSize logicalSize;
|
||
QSGTexture* tex = getTextTexture(label, font, &logicalSize);
|
||
float ty = alignToPixel(py - logicalSize.height() / 2.0f);
|
||
tnode->setRect(QRectF(tx, ty, logicalSize.width(), logicalSize.height()));
|
||
...
|
||
}
|
||
```
|
||
|
||
## Export Logic(导出流程)
|
||
|
||
导出由保存对话框驱动,最终落到 `DataBackend::exportHandler()`:
|
||
|
||
- 触发入口:`qml/content/LeftPanel.qml` → `SaveAsExportDialog` → `Backend.data.exportHandler(...)`
|
||
- `folder` 为本地 `QUrl`(如 `file:///C:/...`),C++ 端会转换为本地路径
|
||
- `format`:`csv` / `json` / `xlsx`(为空时会根据文件后缀推断)
|
||
- `method`:`overwrite`(默认) / `append` / `zip`(UI 有入口但后端未实现压缩)
|
||
|
||
追加行为:
|
||
|
||
- CSV append:以追加模式写入 `buildCsv_()` 的内容(无表头)
|
||
- JSON append:读取已有数组(如果存在),追加当前帧后整体重写
|
||
|
||
当前限制:
|
||
|
||
- `xlsx` 仅提示未实现(不会写出文件)
|
||
- `zip` 未实现压缩逻辑(请视为暂不支持)
|
||
|
||
相关代码:`src/data_backend.cpp`(`exportHandler` / `buildCsv_` / `buildJson_`),`qml/content/SaveAsExportDialog.qml`
|