# 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` 通过QXlsx是实现但是缺少传感器没有测试
- `zip` 未实现压缩逻辑（请视为暂不支持）

相关代码：`src/data_backend.cpp`（`exportHandler` / `buildCsv_` / `buildJson_`），`qml/content/SaveAsExportDialog.qml`
