# 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 了,需要真正的几何或法线光照