From d4975da9a5bf0ff54b4485fe1b0ab53d25c97a8c Mon Sep 17 00:00:00 2001 From: lenn Date: Thu, 18 Dec 2025 09:19:39 +0800 Subject: [PATCH] first commit --- .gitignore | 2 + AUTO_PANEL_SIZING.md | 151 ++++++ BACKGROUND_GRID.md | 134 ++++++ CMakeLists.txt | 66 +++ README.md | 259 +++++++++++ images/metal.jpeg | Bin 0 -> 15504 bytes main.cpp | 74 +++ qml/Main.qml | 6 + qml/content/App.qml | 18 + qml/content/ControlPanel.qml | 131 ++++++ qml/content/LabeledSlider.qml | 20 + qml/content/Legend.qml | 49 ++ qml/content/Toggle.qml | 18 + resources.qrc | 17 + shaders/bg.frag | 48 ++ shaders/bg.vert | 8 + shaders/dots.frag | 153 ++++++ shaders/dots.vert | 31 ++ shaders/panel.frag | 183 ++++++++ shaders/panel.vert | 18 + src/backend.cpp | 64 +++ src/backend.h | 54 +++ src/glwidget.cpp | 851 ++++++++++++++++++++++++++++++++++ src/glwidget.h | 151 ++++++ 24 files changed, 2506 insertions(+) create mode 100644 AUTO_PANEL_SIZING.md create mode 100644 BACKGROUND_GRID.md create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 images/metal.jpeg create mode 100644 main.cpp create mode 100644 qml/Main.qml create mode 100644 qml/content/App.qml create mode 100644 qml/content/ControlPanel.qml create mode 100644 qml/content/LabeledSlider.qml create mode 100644 qml/content/Legend.qml create mode 100644 qml/content/Toggle.qml create mode 100644 resources.qrc create mode 100644 shaders/bg.frag create mode 100644 shaders/bg.vert create mode 100644 shaders/dots.frag create mode 100644 shaders/dots.vert create mode 100644 shaders/panel.frag create mode 100644 shaders/panel.vert create mode 100644 src/backend.cpp create mode 100644 src/backend.h create mode 100644 src/glwidget.cpp create mode 100644 src/glwidget.h diff --git a/.gitignore b/.gitignore index 1895492..6a9a7d6 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ CMakeLists.txt.user Thumbs.db *.swp *~ +/.cache/ +/build/ diff --git a/AUTO_PANEL_SIZING.md b/AUTO_PANEL_SIZING.md new file mode 100644 index 0000000..d2ed5d9 --- /dev/null +++ b/AUTO_PANEL_SIZING.md @@ -0,0 +1,151 @@ +# 自动适配:点阵不越界 + 面板尺寸跟随点阵 + +这份文档说明本项目里为了实现: + +1. **圆点(点阵)始终落在面板顶面上,不越界** +2. **面板的宽/深(W/D)根据点阵规格自动缩放** +3. **面板厚度(H)保持独立可控(不随点阵改变)** + +所做的代码改动和使用方式。 + +--- + +## 现象 & 根因 + +当前点阵的摆放逻辑(`src/glwidget.cpp` 的 `GLWidget::updateInstanceBufferIfNeeded_()`)是: + +- 点阵中心围绕原点居中摆放 +- 每个点的中心坐标: + - `x = c * pitch - (cols-1)*pitch/2` + - `z = r * pitch - (rows-1)*pitch/2` +- 所以 **点中心** 的范围一定在: + - `x ∈ [-gridW/2, +gridW/2]`,其中 `gridW = (cols-1)*pitch` + - `z ∈ [-gridD/2, +gridD/2]`,其中 `gridD = (rows-1)*pitch` + +但每个点是“有半径”的(`dotRadius`),在 shader 里实际画出来的圆会占据: + +- `x` 方向还会向外多出 `dotRadius` +- `z` 方向还会向外多出 `dotRadius` + +如果面板的 `W/D` 没有随点阵变化而调整,就会出现你截图里那种:**外圈点的圆形被画到了面板外**(看起来像“越界”)。 + +--- + +## 解决思路(数学上怎么保证不越界) + +要让“圆点的边缘”也不越界,需要满足: + +- 面板半宽 `panelW/2 >= gridW/2 + dotRadius` +- 面板半深 `panelD/2 >= gridD/2 + dotRadius` + +等价于: + +- `panelW >= gridW + 2*dotRadius` +- `panelD >= gridD + 2*dotRadius` + +本项目采用的就是这条公式。 + +> 如果你希望四周再留白一点,只要把公式改成:`+ 2*(dotRadius + margin)` 即可。 + +--- + +## 代码改动点(做了什么) + +### 1) `setSpec()` 里自动计算面板 W/D + +文件:`src/glwidget.cpp`,函数:`GLWidget::setSpec(...)` + +做的事: + +- 对 `rows/cols/pitch/dotRadius` 做了下限保护(避免负数) +- 根据点阵规格计算面板顶面 `m_panelW/m_panelD`: + - `gridW = (cols-1) * pitch` + - `gridD = (rows-1) * pitch` + - `m_panelW = gridW + 2*dotRadius` + - `m_panelD = gridD + 2*dotRadius` +- 标记面板几何和 dots 几何需要重建(dirty flag) + +这样一旦你改点阵规格,面板会自动缩放到刚好包住点阵。 + +--- + +### 2) 厚度单独控制:`setPanelThickness()` + +文件:`src/glwidget.h` / `src/glwidget.cpp` + +新增接口: + +- `void setPanelThickness(float h);` + +它只修改 `m_panelH`(厚度),并标记面板几何需要重建。 + +--- + +### 3) 由于 W/D 会变:需要“重建几何体 buffer” + +原因:面板顶面矩形的顶点坐标依赖 `m_panelW/m_panelD`,改变后必须重建 VBO/IBO/VAO 才能反映到 GPU。 + +做法: + +- 新增两个 dirty flag: + - `m_panelGeometryDirty` + - `m_dotsGeometryDirty` +- 在 `GLWidget::paintGL()` 的开头判断 dirty: + - dirty 就调用 `initPanelGeometry_()` / `initDotGeometry_()` 重建 + - dots 重建后会重新分配 instanceVBO,所以把 `m_valuesDirty = true`,确保下一帧重新上传 instance 数据 + +--- + +### 4) `initPanelGeometry_()` / `initDotGeometry_()` 现在会先删旧 buffer + +因为会重复调用重建,所以这两个函数里加入了: + +- 先 `glDeleteBuffers` / `glDeleteVertexArrays` +- 再重新 `glGen*` 创建新资源 + +保证不会重复堆积资源(泄漏)或引用旧 VAO。 + +--- + +### 5) `main.cpp` 不再手动写死面板 W/D + +文件:`main.cpp` + +把原来的: + +- `glw->setPanelSize(1.2f, 0.08f, 1.2f);` + +改为: + +- `glw->setPanelThickness(0.08f);` + +原因:如果继续调用 `setPanelSize()`,你会把 `setSpec()` 自动算出来的 W/D 覆盖掉,越界问题就会回来。 + +--- + +## 使用方式(你应该怎么调用) + +推荐顺序: + +1. `glw->setSpec(rows, cols, pitch, dotRadius);` + - 会自动算出 `panelW/panelD`,保证点不越界 +2. `glw->setPanelThickness(panelH);` + - 只改厚度,不影响自动宽深 +3. `glw->setRange(minV, maxV);` +4. `glw->submitValues(values);` + +如果你确实想手动指定面板大小(不走自动适配),可以再调用: + +- `glw->setPanelSize(w, h, d);` + +但要理解:这会覆盖自动计算的 `W/D`,点可能再次越界(除非你按本文公式算好)。 + +--- + +## 可选改进(按你需求继续拓展) + +- **加边距(不要贴边)**:在 `setSpec()` 里引入 `margin`,把公式改成: + `m_panelW = gridW + 2*(m_dotRadius + margin)` + `m_panelD = gridD + 2*(m_dotRadius + margin)` +- **让点阵填满面板但不改变 pitch**:目前面板跟着 pitch 走;如果你希望面板固定而 pitch 自适配,需要反过来解 pitch。 + diff --git a/BACKGROUND_GRID.md b/BACKGROUND_GRID.md new file mode 100644 index 0000000..206a422 --- /dev/null +++ b/BACKGROUND_GRID.md @@ -0,0 +1,134 @@ +# 屏幕空间背景(白底 + 灰色网格线)说明 + +这份文档解释项目里新增的“3D 软件常见的白色背景 + 灰色分割线网格”是怎么实现的,以及如何调整效果。 + +需求点: + +- 背景看起来更“科技感”(轻微渐变 + 网格线 + vignette) +- **背景不随相机旋转**(不受 yaw/pitch/zoom 影响) + +--- + +## 实现方式概览(为什么它不会旋转) + +我们采用“屏幕空间(screen-space)”绘制方式: + +1. **画一个全屏 quad**(两个三角形),顶点坐标直接写在裁剪空间 NDC([-1,1]) +2. fragment shader 使用 `gl_FragCoord`(屏幕像素坐标)生成网格线 + +由于整个背景是直接画在屏幕坐标系里,不使用 `uMVP`,所以它不会跟着相机旋转或移动。 + +--- + +## 关键文件 + +- 资源注册:`resources.qrc` + - 新增:`shaders/bg.vert`、`shaders/bg.frag` +- Shader: + - `shaders/bg.vert`:全屏 quad 的顶点 shader(裁剪空间直出) + - `shaders/bg.frag`:背景颜色 + 抗锯齿网格线(基于 `gl_FragCoord`) +- C++(OpenGLWidget): + - `src/glwidget.h`:新增 `m_bgProg`、`m_bgVao/m_bgVbo`、`initBackgroundGeometry_()` + - `src/glwidget.cpp` + - `GLWidget::initPrograms_()`:编译/链接背景 program + - `GLWidget::initBackgroundGeometry_()`:创建全屏 quad VAO/VBO + - `GLWidget::paintGL()`:先画背景(关闭深度写入),再画 3D 内容 + +--- + +## 渲染顺序(为什么不会影响 3D 深度) + +背景绘制发生在 `GLWidget::paintGL()` 的最前面: + +1. `glClear(...)` 清空颜色/深度 +2. **绘制背景**(屏幕空间) + - `glDisable(GL_DEPTH_TEST);` + - `glDepthMask(GL_FALSE);`(不写深度) +3. 恢复深度状态 + - `glDepthMask(GL_TRUE);` + - `glEnable(GL_DEPTH_TEST);` +4. 再绘制 panel / dots(正常 3D 深度测试) + +因此:背景永远在最底层,而且不会把深度缓冲弄脏。 + +--- + +## 全屏 quad(背景几何) + +`GLWidget::initBackgroundGeometry_()` 创建一个覆盖整个屏幕的矩形(两个三角形): + +- 顶点坐标是 NDC(裁剪空间): + - (-1,-1) 到 (1,1) +- 顶点 shader(`shaders/bg.vert`)仅仅把它输出到 `gl_Position` + +这样不需要任何相机矩阵,也不会“跟着相机”动。 + +--- + +## 网格线怎么画出来的(bg.frag) + +`shaders/bg.frag` 主要做了三件事: + +### 1) 背景渐变底色 + +- 使用 `uv = gl_FragCoord.xy / uViewport` 得到 0..1 的屏幕坐标 +- 在 y 方向做一个轻微渐变(上更亮、下稍灰) + +### 2) 细网格 + 粗网格(分割线) + +用 `uMinorStep` / `uMajorStep`(单位:像素)控制网格间距: + +- `uMinorStep`:细分格子(更淡) +- `uMajorStep`:粗分格子(更明显) + +网格线本质是对 `fract(coord / step)` 做“距离最近线”的计算,然后用 `fwidth` 做抗锯齿: + +- `fwidth` 会随着屏幕像素密度和视角变化自动调整边缘过渡,避免锯齿 + +### 3) 轻微 vignette + +四角略暗、中心略亮,让画面更聚焦、更像 3D 软件视口。 + +--- + +## HiDPI 注意点(为什么要乘 devicePixelRatio) + +Qt 的 `width()/height()` 是“逻辑像素”;而 `gl_FragCoord` 是“物理像素”。 + +所以背景在 C++ 里传入: + +- `uViewport = (width * dpr, height * dpr)` +- 网格 step 也乘 `dpr` + +否则在高 DPI 屏幕上网格会显得“变密/变粗”。 + +--- + +## 如何调整外观(常用参数) + +### 调整网格密度 + +在 `src/glwidget.cpp` 里设置了: + +- `uMinorStep = 24 px`(细线间距) +- `uMajorStep = 120 px`(粗线间距) + +改这两个值就能让网格更密/更稀。 + +### 调整颜色/强度/科技感 + +在 `shaders/bg.frag` 里可以改: + +- `topCol/botCol`:背景渐变颜色 +- `minorCol/majorCol`:网格线颜色 +- `mix(...)` 的系数:线条深浅 +- vignette 强度:`dot(p,p)` 前面的系数 + +--- + +## 可选增强(如果你想更像 Blender/Unity) + +- 加“坐标轴线”(X 红、Z 蓝或灰色加深)并在中心画十字 +- 增加 UI 开关:显示/隐藏网格、调 step、调强度 +- 增加“地平线”或 “ground plane” 的淡淡雾化效果 + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..98e0148 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,66 @@ +cmake_minimum_required(VERSION 4.0) +project(TactileIpc3D LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src) + +find_package(Qt6 COMPONENTS + Core + Gui + Widgets + QuickWidgets + OpenGLWidgets + REQUIRED +) + +qt_standard_project_setup() + +add_executable(TactileIpc3D + main.cpp + resources.qrc + src/backend.h + src/backend.cpp + src/glwidget.cpp + src/glwidget.h +) +target_link_libraries(TactileIpc3D + Qt::Core + Qt::Gui + Qt::Widgets + Qt::QuickWidgets + Qt::OpenGLWidgets +) + +#if (WIN32 AND NOT DEFINED CMAKE_TOOLCHAIN_FILE) +# set(DEBUG_SUFFIX) +# if (MSVC AND CMAKE_BUILD_TYPE MATCHES "Debug") +# set(DEBUG_SUFFIX "d") +# endif () +# set(QT_INSTALL_PATH "${CMAKE_PREFIX_PATH}") +# if (NOT EXISTS "${QT_INSTALL_PATH}/bin") +# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..") +# if (NOT EXISTS "${QT_INSTALL_PATH}/bin") +# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..") +# endif () +# endif () +# if (EXISTS "${QT_INSTALL_PATH}/plugins/platforms/qwindows${DEBUG_SUFFIX}.dll") +# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD +# COMMAND ${CMAKE_COMMAND} -E make_directory +# "$/plugins/platforms/") +# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD +# COMMAND ${CMAKE_COMMAND} -E copy +# "${QT_INSTALL_PATH}/plugins/platforms/qwindows${DEBUG_SUFFIX}.dll" +# "$/plugins/platforms/") +# endif () +# foreach (QT_LIB Core Gui Widgets) +# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD +# COMMAND ${CMAKE_COMMAND} -E copy +# "${QT_INSTALL_PATH}/bin/Qt6${QT_LIB}${DEBUG_SUFFIX}.dll" +# "$") +# endforeach (QT_LIB) +#endif () diff --git a/README.md b/README.md new file mode 100644 index 0000000..22d30a2 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# 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 了,需要真正的几何或法线光照 + diff --git a/images/metal.jpeg b/images/metal.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ae2b57fdedd67bb4762c542f802e50d3ca302d43 GIT binary patch literal 15504 zcmZvCcTiJb)GdgJT>kWxBO?P7BO@a-6B7$72P+#ZD;paJH^)ORwuc-%TpZjyTp|xSxCObnxOoA5f@0!Q zQqruvvWl{j3L=tH;?y^9-n@I~F8lra9Fl@Of|CC~$oHi-mdHIQo{yG(snvsT@ zkLn6NH4Q!WW4f1vtbT%oy2O+|a{AE$bsn(7KQ%|BeBy+TVvLrX<{g`Ss& z0YJs4dDZ+GqfFp`>9ktl(q?|wpwAP({yum`k<~7{W|7e{`R{GE{~Pun{x|Tyqxct_ z_8-MX|3A#XFaJN5nw}S+X&%UM<#L|tHqF1C^fdHT8dOaNUCr@2PNPMQ3aa++efokC z#9CSz7~-JV;V+wnX7sGuvrUzEBqTLl>`|Khm0_1OA&|fzSGrx;!^!%5rGtTsG?y#A zb&p4lCvPS?2&;I0VYjdSa1;nHDHlts2L4w|L31jMUnjiG-I~hiS~`5baa7i7c`6e4 zC4^D}l(RRFxAReY8a}WP2BIlerF^F=M~M1QXX$?q6JX>3J|3<=av_mP-UXqbd`%b@ z3zz4Hoh^jd(9i;!uqg!VP}b3SbG zoP$Ce|5^2M!SSaV-}1d(e(HDyL76aBV4y=r&_i?bX^1k4SERrSj^ErBU;KsUwBkJ7 zi!C>C5CGaN8Ye|RM*h`fdTxJakUn$W2OxQ;jN{E`H=wMCM{lz7_gCIsSyta-lGz?E zM4W4A%k@|$GPvD8fdW~JO5wVxHeZS(fiSGAH3#p=a4mqqyl|lm5ZPm&6lO3Vq~cF_ zr_?ScO{jY>ymSjMv=;CGG53wcxrW?SZ>^ii2zbFx|ESzw26eD&sR7SblH&8Zf>?&? zRJLN(0aHCPu^lSIn{wh2RPWk#tEl*TO)*Aqt80~3N4`tb6}SfKLw~EkhJN6E8=U5T zyFJ2AonXCe6Un8PT7AdfBh!)o_VeA{i5E4n=S1=%Eak_7_DJt6PuMX$5*Yu3i7C5| z!_|U?q^PJ$ez99zBc}=T)Y0z%?G9QzuyCm=!4YuarUb7fOu05>UK+@ zv-Xx7lTwDq`W^c{Wr}Tc^0LII9oncNUltMt*TelmqAW~2r==6kt2w&kigFw@((+`J zUdnv<;;V**`e_cpZSX>6^SY+Y9qn)AF?4K9$r^xA+e251iw{W}>wE$!P~~o>NmaTXYP44qPHzJ-MIAp+$3dt>3t=|^QF1D=Up%+6osR&1W}oE zJU#h=n|Vk2IJ?-uF1+eHE~ht>mYflTObN5n@Y7x&cZ8Z$)q_RlrDkN(l$y1ky!omJ z7vHfrpVvXg9*iz9X#ToYep(Sus9&yJY0_Vl#&qzPV@6*VT#>D}DQ#s0n_FJja>Ml#S|1OF(m`W;hQ9z?^DI_&JZyf}N=o%Ddca zMLdK;muUrn)yR8xmG?}IgL~iqk~c7l5bJ3r&E&uN(4Eha_P3Dh5qv%cs}wt1=&nK8 zvQ6H8EfIrW>q(&AF~{195EuR3D?wt|DA`LYtT2~~_LQOO*a{L7@ac*ICECWGXdnWs z-9-jPty$$>Qo)Pt6W{emw6;?clFy)BGXadFQ}=t;&OF;vXqB#91dh$B@dX#f>k0%9 zev*AVy?z?(6shAO@-a7NiReG3EKL2Dm+M zO0C$g&8d@mY1Dg_od_P}F`v+_)!(Cp+BK19AL7g}snA`q4NyF%d$IPoleE$b;78V6%RMnR>b98+zL)o#xnoi)d z%I_#P(>+jqac3nt2+!Q((;t91JyhhIf!K)kOu25WMLL&E^OeT@$kc@Zft+!2y_4t4 zSy|3=qan^Gi9kVf5Np8LTjkK=!YG3))DI3x<}P{q2?Nqk?$bY+9WXQ=jNNt7LHF!?N7aG zhb}}eO0d>^2v2uJdwX`Q!PNpI(#<|50^$k(zRbv!)R*>=Iakft3k^XL$H#MI{-niW z>$yq)56+fneZ>Wv!b1lMfo!f#LH0{tXy%rUext`_-;(?fNWg}{lV6uqX9-BB8d+~< z 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 T^{2SFKBrkIUU5d_^2h%H!MODS literal 0 HcmV?d00001 diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..f3eea83 --- /dev/null +++ b/main.cpp @@ -0,0 +1,74 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "backend.h" +#include "backend.h" +#include "glwidget.h" + +int main(int argc, char *argv[]) { + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + // 统一OpenGL格式 + QSurfaceFormat fmt; + fmt.setVersion(3, 3); + fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setDepthBufferSize(24); + fmt.setStencilBufferSize(8); + fmt.setSamples(4); + fmt.setSwapInterval(1); + QSurfaceFormat::setDefaultFormat(fmt); + + QApplication a(argc, argv); + + auto *win = new QMainWindow; + auto *splitter = new QSplitter; + splitter->setOrientation(Qt::Horizontal); + auto *quick = new QQuickWidget; + quick->setResizeMode(QQuickWidget::SizeRootObjectToView); + + Backend backend; + quick->rootContext()->setContextProperty("backend", &backend); + quick->setSource(QUrl("qrc:/qml/Main.qml")); + + auto *glw = new GLWidget; + glw->setSpec(8, 11, 0.1f, 0.03f); + glw->setPanelThickness(0.08f); + glw->setRange(backend.minValue(), backend.maxValue()); + glw->setRenderModeString(backend.renderMode()); + glw->setLabelModeString(backend.labelMode()); + + QObject::connect(&backend, &Backend::rangeChanged, glw, &GLWidget::setRange); + QObject::connect(&backend, &Backend::renderModeValueChanged, glw, &GLWidget::setRenderModeString); + QObject::connect(&backend, &Backend::labelModeValueChanged, glw, &GLWidget::setLabelModeString); + + auto *t = new QTimer(win); + t->setTimerType(Qt::PreciseTimer); + t->setInterval(10); + QObject::connect(t, &QTimer::timeout, glw, [glw] { + const int n = glw->dotCount(); + QVector v; + v.resize(n); + for (int i = 0; i < n; ++i) { + // TODO: value + v[i] = 100.0f + float(QRandomGenerator::global()->generateDouble()) * (2000.0f - 100.0f); + } + glw->submitValues(v); + }); + + splitter->addWidget(quick); + splitter->addWidget(glw); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + win->setCentralWidget(splitter); + win->resize(1100, 650); + win->show(); + t->start(); + return QApplication::exec(); +} diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..e8d6567 --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,6 @@ +import QtQuick +import "content" + +App { + +} diff --git a/qml/content/App.qml b/qml/content/App.qml new file mode 100644 index 0000000..6b9311c --- /dev/null +++ b/qml/content/App.qml @@ -0,0 +1,18 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls.Material +import "./" + +Rectangle { + // width: Constants.width + // height: Constants.height + width: 360 + // minimumWidth: 800 + // minimumHeight: 600 + + visible: true + + ControlPanel { + anchors.fill: parent + } +} diff --git a/qml/content/ControlPanel.qml b/qml/content/ControlPanel.qml new file mode 100644 index 0000000..2064186 --- /dev/null +++ b/qml/content/ControlPanel.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtQuick3D +import QtQuick.Controls.Material +import QtQuick.Controls +import QtQuick.Layouts +import QtQml +import "." + +Pane { + id: root + Material.theme: darkModeToggle.checked ? Material.Dark : Material.Light + Material.accent: Material.Green + leftPadding: 18 + rightPadding: 18 + topPadding: 18 + bottomPadding: 18 + + ColumnLayout { + anchors.fill: parent + spacing: 14 + + Toggle { + id: darkModeToggle + text: qsTr("Dark mode") + } + + GroupBox { + title: qsTr("Render") + Layout.fillWidth: true + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + RowLayout { + Layout.fillWidth: true + Label { text: qsTr("Mode"); Layout.alignment: Qt.AlignVCenter } + ComboBox { + id: renderModeBox + Layout.fillWidth: true + model: ["dataViz", "realistic"] + Component.onCompleted: currentIndex = backend.renderMode === "realistic" ? 1 : 0 + onActivated: backend.renderMode = currentText + Connections { + target: backend + function onRenderModeChanged() { + renderModeBox.currentIndex = backend.renderMode === "realistic" ? 1 : 0 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Label { text: qsTr("Labels"); Layout.alignment: Qt.AlignVCenter } + ComboBox { + id: labelModeBox + Layout.fillWidth: true + model: ["off", "hover", "always"] + Component.onCompleted: { + if (backend.labelMode === "always") labelModeBox.currentIndex = 2 + else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 + else labelModeBox.currentIndex = 0 + } + onActivated: backend.labelMode = currentText + Connections { + target: backend + function onLabelModeChanged() { + if (backend.labelMode === "always") labelModeBox.currentIndex = 2 + else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 + else labelModeBox.currentIndex = 0 + } + } + } + } + + Toggle { + id: legendToggle + text: qsTr("Legend") + checked: backend.showLegend + onCheckedChanged: backend.showLegend = checked + } + } + } + + GroupBox { + title: qsTr("Scale") + Layout.fillWidth: true + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + RowLayout { + Layout.fillWidth: true + Label { text: qsTr("Min"); Layout.alignment: Qt.AlignVCenter } + SpinBox { + id: minBox + Layout.fillWidth: true + from: -999999 + to: 999999 + value: backend.minValue + onValueModified: backend.minValue = value + } + } + + RowLayout { + Layout.fillWidth: true + Label { text: qsTr("Max"); Layout.alignment: Qt.AlignVCenter } + SpinBox { + id: maxBox + Layout.fillWidth: true + from: -999999 + to: 999999 + value: backend.maxValue + onValueModified: backend.maxValue = value + } + } + + Legend { + Layout.alignment: Qt.AlignHCenter + visible: backend.showLegend + minValue: backend.minValue + maxValue: backend.maxValue + } + } + } + + Item { Layout.fillHeight: true } + } +} diff --git a/qml/content/LabeledSlider.qml b/qml/content/LabeledSlider.qml new file mode 100644 index 0000000..1c971d6 --- /dev/null +++ b/qml/content/LabeledSlider.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtQuick.Controls + +Slider { + property string lableText: qsTr("Text") + stepSize: 1 + + Label { + text: parent.lableText + anchors.left: parent.left + anchors.bottom: parent.top + bottomPadding: -12 + } + Label { + text: parent.value + anchors.right: parent.right + anchors.bottom: parent.top + bottomPadding: -12 + } +} diff --git a/qml/content/Legend.qml b/qml/content/Legend.qml new file mode 100644 index 0000000..3757852 --- /dev/null +++ b/qml/content/Legend.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property int minValue: 0 + property int maxValue: 100 + + implicitWidth: 90 + implicitHeight: 220 + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Label { + text: root.maxValue + font.pixelSize: 12 + opacity: 0.9 + Layout.alignment: Qt.AlignHCenter + } + + Rectangle { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + width: 26 + radius: 6 + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.18) + + gradient: Gradient { + // must match shaders/dots.frag:dataColorRamp (high at top) + GradientStop { position: 0.00; color: Qt.rgba(1.00, 0.22, 0.10, 1.0) } // c3 + GradientStop { position: 0.34; color: Qt.rgba(1.00, 0.92, 0.22, 1.0) } // c2 + GradientStop { position: 0.67; color: Qt.rgba(0.10, 0.95, 0.35, 1.0) } // c1 + GradientStop { position: 1.00; color: Qt.rgba(0.10, 0.75, 1.00, 1.0) } // c0 + } + } + + Label { + text: root.minValue + font.pixelSize: 12 + opacity: 0.9 + Layout.alignment: Qt.AlignHCenter + } + } +} + diff --git a/qml/content/Toggle.qml b/qml/content/Toggle.qml new file mode 100644 index 0000000..ccbbe51 --- /dev/null +++ b/qml/content/Toggle.qml @@ -0,0 +1,18 @@ +import QtQuick +import QtQuick.Controls + +Item { + property string text + property alias checked: toggleIndicator.checked + + Label { + id: toggleText + text: parent.text + anchors.verticalCenter: toggleIndicator.verticalCenter + } + Switch { + id: toggleIndicator + anchors.left: toggleText.right + anchors.leftMargin: 8 + } +} diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..9ebd99c --- /dev/null +++ b/resources.qrc @@ -0,0 +1,17 @@ + + + qml/Main.qml + qml/content/App.qml + qml/content/ControlPanel.qml + qml/content/Legend.qml + qml/content/LabeledSlider.qml + qml/content/Toggle.qml + shaders/dots.frag + shaders/dots.vert + shaders/bg.frag + shaders/bg.vert + shaders/panel.frag + shaders/panel.vert + images/metal.jpeg + + diff --git a/shaders/bg.frag b/shaders/bg.frag new file mode 100644 index 0000000..2287715 --- /dev/null +++ b/shaders/bg.frag @@ -0,0 +1,48 @@ +#version 330 core +out vec4 FragColor; + +// 视口大小(像素,建议传入 framebuffer 尺寸,HiDPI 下要乘 devicePixelRatio) +uniform vec2 uViewport; + +// 以像素为单位的网格间距:细网格/粗网格 +uniform float uMinorStep; +uniform float uMajorStep; + +// 生成抗锯齿网格线(返回 0..1,1 表示在线上) +float gridLine(float stepPx) { + vec2 coord = gl_FragCoord.xy; + vec2 q = coord / stepPx; + + // 距离最近网格线的归一化距离,再用 fwidth 做抗锯齿 + vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q); + float line = 1.0 - min(min(g.x, g.y), 1.0); + return line; +} + +void main() { + vec2 viewport = max(uViewport, vec2(1.0)); + vec2 uv = gl_FragCoord.xy / viewport; // 0..1 + + // 背景渐变:上更亮、下稍灰,常见 3D 软件的“科技感”底色 + vec3 topCol = vec3(0.99, 0.99, 1.00); + vec3 botCol = vec3(0.94, 0.95, 0.98); + vec3 col = mix(botCol, topCol, uv.y); + + // 网格线:细线 + 粗线(每隔一段更深一点) + float minor = gridLine(max(uMinorStep, 1.0)); + float major = gridLine(max(uMajorStep, 1.0)); + + vec3 minorCol = vec3(0.80, 0.82, 0.87); + vec3 majorCol = vec3(0.70, 0.73, 0.80); + + col = mix(col, minorCol, minor * 0.22); + col = mix(col, majorCol, major * 0.35); + + // 轻微 vignette(四角略暗),让画面更“聚焦” + vec2 p = uv * 2.0 - 1.0; + float v = clamp(1.0 - dot(p, p) * 0.12, 0.0, 1.0); + col *= mix(1.0, v, 0.35); + + FragColor = vec4(col, 1.0); +} + diff --git a/shaders/bg.vert b/shaders/bg.vert new file mode 100644 index 0000000..29aaa5e --- /dev/null +++ b/shaders/bg.vert @@ -0,0 +1,8 @@ +#version 330 core +// 全屏背景:直接在裁剪空间画一个矩形(不受相机/旋转影响) +layout(location = 0) in vec2 aPos; // NDC: [-1,1] + +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} + diff --git a/shaders/dots.frag b/shaders/dots.frag new file mode 100644 index 0000000..c6d6fc7 --- /dev/null +++ b/shaders/dots.frag @@ -0,0 +1,153 @@ +#version 330 core +in vec2 vUV; +in float vValue; +in vec3 vWorldPos; +out vec4 FragColor; + +uniform float uMinV; +uniform float uMaxV; +uniform sampler2D uDotTex; +uniform int uHasData; // 0 = no data, 1 = has data +uniform vec3 uCameraPos; +uniform float uDotRadius; +uniform int uRenderMode; // 0=realistic, 1=dataViz + +const float PI = 3.14159265359; + +float saturate(float x) { return clamp(x, 0.0, 1.0); } + +vec3 dataColorRamp(float t) { + t = saturate(t); + vec3 c0 = vec3(0.10, 0.75, 1.00); // cyan-blue (low) + vec3 c1 = vec3(0.10, 0.95, 0.35); // green + vec3 c2 = vec3(1.00, 0.92, 0.22); // yellow + vec3 c3 = vec3(1.00, 0.22, 0.10); // red (high) + + if (t < 0.33) return mix(c0, c1, t / 0.33); + if (t < 0.66) return mix(c1, c2, (t - 0.33) / 0.33); + return mix(c2, c3, (t - 0.66) / 0.34); +} + +vec3 fresnelSchlick(float cosTheta, vec3 F0) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +float D_GGX(float NdotH, float roughness) { + float a = max(0.04, roughness); + float alpha = a * a; + float alpha2 = alpha * alpha; + float denom = (NdotH * NdotH) * (alpha2 - 1.0) + 1.0; + return alpha2 / (PI * denom * denom + 1e-7); +} + +float G_SchlickGGX(float NdotV, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k + 1e-7); +} + +float G_Smith(float NdotV, float NdotL, float roughness) { + float ggx1 = G_SchlickGGX(NdotV, roughness); + float ggx2 = G_SchlickGGX(NdotL, roughness); + return ggx1 * ggx2; +} + +float D_GGX_Aniso(vec3 N, vec3 H, vec3 T, vec3 B, float ax, float ay) { + float NdotH = saturate(dot(N, H)); + float TdotH = dot(T, H); + float BdotH = dot(B, H); + float ax2 = ax * ax; + float ay2 = ay * ay; + float denom = (TdotH * TdotH) / (ax2 + 1e-7) + (BdotH * BdotH) / (ay2 + 1e-7) + NdotH * NdotH; + return 1.0 / (PI * ax * ay * denom * denom + 1e-7); +} + +vec3 evalLight( + vec3 N, + vec3 V, + vec3 L, + vec3 lightColor, + vec3 baseColor, + float metallic, + float roughness, + float aniso, + vec3 brushDir +) { + float NdotL = saturate(dot(N, L)); + float NdotV = saturate(dot(N, V)); + if (NdotL <= 0.0 || NdotV <= 0.0) return vec3(0.0); + + vec3 H = normalize(V + L); + float NdotH = saturate(dot(N, H)); + float VdotH = saturate(dot(V, H)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 F = fresnelSchlick(VdotH, F0); + + float D = D_GGX(NdotH, roughness); + if (aniso > 0.001) { + vec3 T = normalize(brushDir - N * dot(brushDir, N)); + vec3 B = normalize(cross(N, T)); + float alpha = max(0.04, roughness); + float a = alpha * alpha; + float ax = mix(a, a * 0.30, aniso); + float ay = mix(a, a * 2.00, aniso); + D = D_GGX_Aniso(N, H, T, B, ax, ay); + } + + float G = G_Smith(NdotV, NdotL, roughness); + vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-6); + + vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); + vec3 diff = kD * baseColor / PI; + + return (diff + spec) * lightColor * NdotL; +} + +void main() { + vec2 p = vUV * 2.0 - 1.0; + float r = length(p); + if (r > 1.0) discard; + float r01 = saturate(r); + + // Industrial engineering model: simple plated metal pad (brass/gold-ish). + // When no data, keep a bright gold base. When data is present, render the + // data color directly (no remaining gold tint), while preserving depth cues. + vec3 metalBase = vec3(0.98, 0.82, 0.30); + float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0); + vec3 dataCol = dataColorRamp(value01); + + bool hasData = (uHasData != 0); + vec3 baseColor = hasData ? dataCol : metalBase; + + // Mostly flat, with a slight bevel near the edge to catch highlights. + float slope = mix(0.06, 0.28, smoothstep(0.55, 1.0, r01)); + vec3 N = normalize(vec3(p.x * slope, 1.0, p.y * slope)); + vec3 V = normalize(uCameraPos - vWorldPos); + + float metallic = hasData ? 0.0 : 0.90; + float roughness = hasData ? 0.78 : ((uRenderMode == 1) ? 0.70 : 0.55); + + vec3 keyL = normalize(vec3(0.55, 1.00, 0.25)); + vec3 fillL = normalize(vec3(-0.30, 0.70, -0.80)); + vec3 keyC = vec3(1.00, 0.98, 0.95) * 1.8; + vec3 fillC = vec3(0.85, 0.90, 1.00) * 0.9; + + vec3 Lo = vec3(0.0); + Lo += evalLight(N, V, keyL, keyC, baseColor, metallic, roughness, 0.0, vec3(1.0, 0.0, 0.0)); + Lo += evalLight(N, V, fillL, fillC, baseColor, metallic, roughness, 0.0, vec3(1.0, 0.0, 0.0)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 ambient = baseColor * 0.10 + F0 * 0.04; + + float edgeAO = smoothstep(0.88, 1.0, r01); + float ao = 1.0 - edgeAO * 0.10; + + // Subtle boundary ring (engineering-model crispness, not a UI outline). + float ring = smoothstep(0.82, 0.92, r01) - smoothstep(0.92, 1.00, r01); + + vec3 col = (ambient + Lo) * ao; + col = mix(col, col * 0.82, ring * 0.35); + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/shaders/dots.vert b/shaders/dots.vert new file mode 100644 index 0000000..bcaf537 --- /dev/null +++ b/shaders/dots.vert @@ -0,0 +1,31 @@ +#version 330 core + +layout(location = 0) in vec2 qQuadPos; // 单位 quad 的局部顶点坐标(范围 [-1,1]) +layout(location = 1) in vec2 aUV; // UV(用于 fragment shader 把 quad 变成圆形) + +layout(location = 2) in vec2 iOffsetXZ; // 每个点的偏移(世界坐标 XZ) +layout(location = 3) in float iValue; // 每个点的数值(用于颜色映射) + +out vec2 vUV; +out float vValue; +out vec3 vWorldPos; + +uniform mat4 uMVP; // Projection * View * Model(这里 Model 约等于单位矩阵) +uniform float uDotRadius; // dot 半径(世界坐标单位) +uniform float uBaseY; // dot 的高度(通常 = panel 顶面 y + 一点点偏移) + +void main() { + vUV = aUV; + vValue = iValue; + + // 先确定 dot 的中心点(世界坐标) + vec3 world = vec3(iOffsetXZ.x, uBaseY, iOffsetXZ.y); + + // 再把单位 quad 按半径缩放并加到中心点上(让 quad 落在 XZ 平面) + world.x += qQuadPos.x * uDotRadius; + world.z += qQuadPos.y * uDotRadius; + + // 输出裁剪空间坐标(最终会进行透视除法与视口映射,变成屏幕上的像素) + vWorldPos = world; + gl_Position = uMVP * vec4(world, 1.0); +} diff --git a/shaders/panel.frag b/shaders/panel.frag new file mode 100644 index 0000000..38a1228 --- /dev/null +++ b/shaders/panel.frag @@ -0,0 +1,183 @@ +#version 330 core +in vec3 vWorldPos; +in vec3 vWorldNormal; +out vec4 FragColor; + +uniform vec3 uCameraPos; +uniform float uPanelW; +uniform float uPanelH; +uniform float uPanelD; +uniform int uRows; +uniform int uCols; +uniform float uPitch; +uniform float uDotRadius; +uniform int uRenderMode; // 0=realistic, 1=dataViz + +const float PI = 3.14159265359; + +float saturate(float x) { return clamp(x, 0.0, 1.0); } + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float noise2d(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash12(i); + float b = hash12(i + vec2(1.0, 0.0)); + float c = hash12(i + vec2(0.0, 1.0)); + float d = hash12(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 4; ++i) { + v += a * noise2d(p); + p *= 2.0; + a *= 0.5; + } + return v; +} + +vec3 fresnelSchlick(float cosTheta, vec3 F0) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +float D_GGX(float NdotH, float roughness) { + float a = max(0.04, roughness); + float alpha = a * a; + float alpha2 = alpha * alpha; + float denom = (NdotH * NdotH) * (alpha2 - 1.0) + 1.0; + return alpha2 / (PI * denom * denom + 1e-7); +} + +float G_SchlickGGX(float NdotV, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k + 1e-7); +} + +float G_Smith(float NdotV, float NdotL, float roughness) { + float ggx1 = G_SchlickGGX(NdotV, roughness); + float ggx2 = G_SchlickGGX(NdotL, roughness); + return ggx1 * ggx2; +} + +float D_GGX_Aniso(vec3 N, vec3 H, vec3 T, vec3 B, float ax, float ay) { + float NdotH = saturate(dot(N, H)); + float TdotH = dot(T, H); + float BdotH = dot(B, H); + float ax2 = ax * ax; + float ay2 = ay * ay; + float denom = (TdotH * TdotH) / (ax2 + 1e-7) + (BdotH * BdotH) / (ay2 + 1e-7) + NdotH * NdotH; + return 1.0 / (PI * ax * ay * denom * denom + 1e-7); +} + +vec3 evalLight( + vec3 N, + vec3 V, + vec3 L, + vec3 lightColor, + vec3 baseColor, + float metallic, + float roughness, + float aniso, + vec3 brushDir +) { + float NdotL = saturate(dot(N, L)); + float NdotV = saturate(dot(N, V)); + if (NdotL <= 0.0 || NdotV <= 0.0) return vec3(0.0); + + vec3 H = normalize(V + L); + float NdotH = saturate(dot(N, H)); + float VdotH = saturate(dot(V, H)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 F = fresnelSchlick(VdotH, F0); + + float D = D_GGX(NdotH, roughness); + if (aniso > 0.001) { + vec3 T = normalize(brushDir - N * dot(brushDir, N)); + vec3 B = normalize(cross(N, T)); + float alpha = max(0.04, roughness); + float a = alpha * alpha; + float ax = mix(a, a * 0.35, aniso); + float ay = mix(a, a * 1.80, aniso); + D = D_GGX_Aniso(N, H, T, B, ax, ay); + } + + float G = G_Smith(NdotV, NdotL, roughness); + vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-6); + + vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); + vec3 diff = kD * baseColor / PI; + + return (diff + spec) * lightColor * NdotL; +} + +float nearestDotDistanceXZ(vec2 xz) { + if (uPitch <= 0.0 || uRows <= 0 || uCols <= 0) return 1e6; + int colsM1 = max(uCols - 1, 0); + int rowsM1 = max(uRows - 1, 0); + float halfGridW = float(colsM1) * uPitch * 0.5; + float halfGridD = float(rowsM1) * uPitch * 0.5; + + vec2 g = (xz + vec2(halfGridW, halfGridD)) / max(1e-6, uPitch); + vec2 gi = floor(g + 0.5); + gi = clamp(gi, vec2(0.0), vec2(float(colsM1), float(rowsM1))); + + vec2 c = gi * uPitch - vec2(halfGridW, halfGridD); + return length(xz - c); +} + +void main() { + // panel 先用一个固定颜色(后续可以加光照/材质) + vec3 N = normalize(vWorldNormal); + vec3 V = normalize(uCameraPos - vWorldPos); + + float isTop = step(0.75, N.y); + + // ------------------------------------------------------------ + // Industrial engineering model: neutral matte gray panel (support layer only) + // ------------------------------------------------------------ + vec3 topBase = vec3(0.30, 0.31, 0.32); + vec3 sideBase = vec3(0.27, 0.28, 0.29); + vec3 baseColor = mix(sideBase, topBase, isTop); + + vec2 xz = vWorldPos.xz; + float dotContact = 0.0; + if (isTop > 0.5 && uDotRadius > 0.0) { + float d = nearestDotDistanceXZ(xz); + float w = max(0.002, uDotRadius * 0.22); + dotContact = 1.0 - smoothstep(uDotRadius, uDotRadius + w, d); + } + + vec3 L = normalize(vec3(0.45, 1.00, 0.20)); + float diff = saturate(dot(N, L)); + float lighting = 0.90 + 0.10 * diff; + + float hw = max(1e-6, uPanelW * 0.5); + float hd = max(1e-6, uPanelD * 0.5); + float edgeDist = min(hw - abs(vWorldPos.x), hd - abs(vWorldPos.z)); + float edgeW = max(0.002, min(hw, hd) * 0.012); + float edgeLine = (1.0 - smoothstep(edgeW, edgeW * 2.5, edgeDist)) * isTop; + + float rim = pow(1.0 - saturate(dot(N, V)), 2.2) * isTop; + float ao = 1.0 - dotContact * 0.08; + + vec3 col = baseColor * lighting * ao; + col += edgeLine * vec3(0.020); + col += rim * vec3(0.015); + + // Slightly deepen the bottom face to read as thickness, but keep it subtle. + float isBottom = step(0.75, -N.y); + col *= mix(1.0, 0.92, isBottom); + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/shaders/panel.vert b/shaders/panel.vert new file mode 100644 index 0000000..16f310b --- /dev/null +++ b/shaders/panel.vert @@ -0,0 +1,18 @@ +#version 330 core +// 顶点输入(来自 VBO) +layout(location=0) in vec3 aPos; // 顶点位置(当前我们直接当作“世界坐标”来用) +layout(location=1) in vec3 aN; // 法线(当前没用到,先保留) + +// uMVP = Projection * View * Model +// 把顶点从“世界坐标”变换到“裁剪空间(clip space)”,OpenGL 用 gl_Position 来完成屏幕投影 +out vec3 vWorldPos; +out vec3 vWorldNormal; + +uniform mat4 uMVP; + +void main() { + // Model is identity in this project; treat vertex data as world space. + vWorldPos = aPos; + vWorldNormal = aN; + gl_Position = uMVP * vec4(aPos, 1.0); +} diff --git a/src/backend.cpp b/src/backend.cpp new file mode 100644 index 0000000..f6cb8fa --- /dev/null +++ b/src/backend.cpp @@ -0,0 +1,64 @@ +// +// Created by Lenn on 2025/12/16. +// + +#include "backend.h" + +static QString normalizeRenderMode_(const QString& mode) { + if (mode.compare(QStringLiteral("realistic"), Qt::CaseInsensitive) == 0) + return QStringLiteral("realistic"); + return QStringLiteral("dataViz"); +} + +static QString normalizeLabelMode_(const QString& mode) { + if (mode.compare(QStringLiteral("hover"), Qt::CaseInsensitive) == 0) + return QStringLiteral("hover"); + if (mode.compare(QStringLiteral("always"), Qt::CaseInsensitive) == 0) + return QStringLiteral("always"); + return QStringLiteral("off"); +} + +Backend::Backend(QObject *parent) + : QObject(parent) { +} + +void Backend::setMinValue(int v) { + if (m_min == v) + return; + m_min = v; + emit minValueChanged(); + emit rangeChanged(m_min, m_max); +} + +void Backend::setMaxValue(int v) { + if (m_max == v) + return; + m_max = v; + emit maxValueChanged(); + emit rangeChanged(m_min, m_max); +} + +void Backend::setRenderMode(const QString &mode) { + const QString norm = normalizeRenderMode_(mode); + if (m_renderMode == norm) + return; + m_renderMode = norm; + emit renderModeChanged(); + emit renderModeValueChanged(m_renderMode); +} + +void Backend::setShowLegend(bool show) { + if (m_showLegend == show) + return; + m_showLegend = show; + emit showLegendChanged(); +} + +void Backend::setLabelMode(const QString &mode) { + const QString norm = normalizeLabelMode_(mode); + if (m_labelMode == norm) + return; + m_labelMode = norm; + emit labelModeChanged(); + emit labelModeValueChanged(m_labelMode); +} diff --git a/src/backend.h b/src/backend.h new file mode 100644 index 0000000..1f04a2a --- /dev/null +++ b/src/backend.h @@ -0,0 +1,54 @@ +// +// Created by Lenn on 2025/12/16. +// + +#ifndef TACTILEIPC3D_BACKEND_H +#define TACTILEIPC3D_BACKEND_H +#include +#include + + +class Backend : public QObject { + Q_OBJECT + Q_PROPERTY(int minValue READ minValue WRITE setMinValue NOTIFY minValueChanged) + Q_PROPERTY(int maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) + Q_PROPERTY(QString renderMode READ renderMode WRITE setRenderMode NOTIFY renderModeChanged) + Q_PROPERTY(bool showLegend READ showLegend WRITE setShowLegend NOTIFY showLegendChanged) + Q_PROPERTY(QString labelMode READ labelMode WRITE setLabelMode NOTIFY labelModeChanged) + +public: + explicit Backend(QObject* parent = nullptr); + + int minValue() const { return m_min; } + int maxValue() const { return m_max; } + QString renderMode() const { return m_renderMode; } + bool showLegend() const { return m_showLegend; } + QString labelMode() const { return m_labelMode; } + +public slots: + void setMinValue(int v); + void setMaxValue(int v); + void setRenderMode(const QString& mode); + void setShowLegend(bool show); + void setLabelMode(const QString& mode); + +signals: + void minValueChanged(); + void maxValueChanged(); + void renderModeChanged(); + void showLegendChanged(); + void labelModeChanged(); + + void rangeChanged(int minV, int maxV); + void renderModeValueChanged(const QString& mode); + void labelModeValueChanged(const QString& mode); + +private: + int m_min = 100; + int m_max = 2000; + QString m_renderMode = QStringLiteral("dataViz"); + bool m_showLegend = true; + QString m_labelMode = QStringLiteral("off"); +}; + +#endif //TACTILEIPC3D_BACKEND_H diff --git a/src/glwidget.cpp b/src/glwidget.cpp new file mode 100644 index 0000000..7d7ea8a --- /dev/null +++ b/src/glwidget.cpp @@ -0,0 +1,851 @@ +// +// Created by Lenn on 2025/12/16. +// + +#include "glwidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 读取文本文件内容(这里主要用来从 Qt Resource `:/shaders/...` 读取 shader 源码) +static QByteArray readFile(const QString& path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) return {}; + return f.readAll(); +} + +// 简单的 4x4 单位矩阵(目前只用于保留的 float[16] 版本) +static void matIdentity(float m[16]) { + for (int i = 0; i < 16; i++) { + m[i] = 0; + } + + m[0] = m[5] = m[10] = m[15] = 1; +} + +GLWidget::GLWidget(QWidget *parent) + : QOpenGLWidget(parent) { + setMinimumSize(640, 480); +} + +GLWidget::~GLWidget() { + makeCurrent(); + delete m_bgProg; + delete m_panelProg; + delete m_dotsProg; + + if (m_panelIbo) + glDeleteBuffers(1, &m_panelIbo); + if (m_panelVbo) + glDeleteBuffers(1, &m_panelVbo); + if (m_panelVao) + glDeleteVertexArrays(1, &m_panelVao); + if (m_dotsVao) + glDeleteVertexArrays(1, &m_dotsVao); + if (m_dotsVbo) + glDeleteBuffers(1, &m_dotsVbo); + if (m_instanceVbo) + glDeleteBuffers(1, &m_instanceVbo); + if (m_bgVbo) + glDeleteBuffers(1, &m_bgVbo); + if (m_bgVao) + glDeleteVertexArrays(1, &m_bgVao); + + if (m_dotTex) + glDeleteTextures(1, &m_dotTex); + doneCurrent(); +} + +void GLWidget::setPanelSize(float w, float h, float d) { + m_panelW = w; + m_panelH = h; + m_panelD = d; + m_panelGeometryDirty = true; + update(); +} + +void GLWidget::setPanelThickness(float h) { + if (qFuzzyCompare(m_panelH, h)) + return; + m_panelH = h; + m_panelGeometryDirty = true; + update(); +} + +void GLWidget::setSpec(int rows, int cols, float pitch, float dotRaius) { + m_rows = qMax(0, rows); + m_cols = qMax(0, cols); + m_pitch = qMax(0.0f, pitch); + m_dotRadius = qMax(0.0f, dotRaius); + + // 自动根据点阵尺寸调整 panel 的尺寸(保证圆点不会越过顶面边界)。 + // 约定:点中心覆盖的范围是 (cols-1)*pitch / (rows-1)*pitch,面板需要额外留出 dotRadius 的边缘空间。 + const float gridW = float(qMax(0, m_cols - 1)) * m_pitch; + const float gridD = float(qMax(0, m_rows - 1)) * m_pitch; + m_panelW = gridW + 2.0f * m_dotRadius; + m_panelD = gridD + 2.0f * m_dotRadius; + m_panelGeometryDirty = true; + m_dotsGeometryDirty = true; + + QMutexLocker lk(&m_dataMutex); + m_latestValues.resize(dotCount()); + for (int i = 0; i < m_latestValues.size(); ++i) { + m_latestValues[i] = m_min; + } + m_valuesDirty = true; + m_hasData = false; + update(); +} + +void GLWidget::submitValues(const QVector &values) { + if (values.size() != dotCount()) return; + + { + QMutexLocker lk(&m_dataMutex); + m_latestValues = values; + m_valuesDirty = true; + m_hasData = true; + } + + update(); +} + +void GLWidget::setRange(int minV, int maxV) { + m_min = minV; + m_max = maxV; + update(); +} + +void GLWidget::setYaw(float yawDeg) { + if (qFuzzyCompare(m_camYawDeg, yawDeg)) + return; + m_camYawDeg = yawDeg; + emit yawChanged(); + update(); +} + +void GLWidget::setRenderModeString(const QString &mode) { + RenderMode next = DataViz; + if (mode.compare(QStringLiteral("realistic"), Qt::CaseInsensitive) == 0) { + next = Realistic; + } else if (mode.compare(QStringLiteral("dataViz"), Qt::CaseInsensitive) == 0) { + next = DataViz; + } + + if (m_renderMode == next) + return; + m_renderMode = next; + update(); +} + +void GLWidget::setLabelModeString(const QString &mode) { + LabelMode next = LabelsOff; + if (mode.compare(QStringLiteral("hover"), Qt::CaseInsensitive) == 0) { + next = LabelsHover; + } else if (mode.compare(QStringLiteral("always"), Qt::CaseInsensitive) == 0) { + next = LabelsAlways; + } else if (mode.compare(QStringLiteral("off"), Qt::CaseInsensitive) == 0) { + next = LabelsOff; + } + + if (m_labelMode == next) + return; + m_labelMode = next; + if (m_labelMode == LabelsOff) + m_hoveredIndex = -1; + update(); +} + +void GLWidget::initializeGL() { + initializeOpenGLFunctions(); + + // 基础状态:开启深度测试,否则 3D 物体的遮挡关系会不正确 + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LESS); + initPrograms_(); + initDotTexture_(); + initBackgroundGeometry_(); + initPanelGeometry_(); + initDotGeometry_(); + m_panelGeometryDirty = false; + m_dotsGeometryDirty = false; + + matIdentity(m_modelPanel); + matIdentity(m_view); + matIdentity(m_proj); +} + +void GLWidget::resizeGL(int w, int h) { + glViewport(0, 0, w, h); +} + +void GLWidget::paintGL() { + glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // 如果点阵规格/面板尺寸发生变化,需要在有 GL 上下文时重建几何体 buffer。 + if (m_panelGeometryDirty) { + initPanelGeometry_(); + m_panelGeometryDirty = false; + } + if (m_dotsGeometryDirty) { + initDotGeometry_(); + m_dotsGeometryDirty = false; + m_valuesDirty = true; // instanceVBO 重新分配后需要重新上传数据 + } + + // --- 背景:屏幕空间网格(不随相机旋转)--- + if (m_bgProg && m_bgVao) { + const float dpr = devicePixelRatioF(); + const QVector2D viewport(float(width()) * dpr, float(height()) * dpr); + + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + m_bgProg->bind(); + m_bgProg->setUniformValue("uViewport", viewport); + m_bgProg->setUniformValue("uMinorStep", 24.0f * dpr); + m_bgProg->setUniformValue("uMajorStep", 120.0f * dpr); + + glBindVertexArray(m_bgVao); + glDrawArrays(GL_TRIANGLES, 0, 6); + glBindVertexArray(0); + + m_bgProg->release(); + + glDepthMask(GL_TRUE); + glEnable(GL_DEPTH_TEST); + } + + // 1) 更新相机/投影矩阵(MVP),决定如何把 3D 世界投影到屏幕 + updateMatrices_(); + // 2) 如果外部提交了新数据,就把 CPU 生成的 instance 数据更新到 GPU + updateInstanceBufferIfNeeded_(); + + if (m_panelProg) { + m_panelProg->bind(); + // uniforms:每次 draw 前设置的一组“常量参数”(对当前 draw call 的所有顶点/片元都一致) + // uMVP: Model-View-Projection 矩阵(把顶点从世界坐标 -> 裁剪空间;gl_Position 必须输出裁剪空间坐标) + m_panelProg->setUniformValue("uMVP", m_mvp); + m_panelProg->setUniformValue("uCameraPos", m_cameraPos); + m_panelProg->setUniformValue("uPanelW", m_panelW); + m_panelProg->setUniformValue("uPanelH", m_panelH); + m_panelProg->setUniformValue("uPanelD", m_panelD); + m_panelProg->setUniformValue("uRows", m_rows); + m_panelProg->setUniformValue("uCols", m_cols); + m_panelProg->setUniformValue("uPitch", m_pitch); + m_panelProg->setUniformValue("uDotRadius", m_dotRadius); + m_panelProg->setUniformValue("uRenderMode", int(m_renderMode)); + glBindVertexArray(m_panelVao); + glDrawElements(GL_TRIANGLES, m_panelIndexCount, GL_UNSIGNED_INT, nullptr); + glBindVertexArray(0); + m_panelProg->release(); + } + + if (m_dotsProg) { + m_dotsProg->bind(); + // uniforms:每次 draw 前设置的一组“常量参数”(对当前 draw call 的所有 instance 都一致) + // uMVP: 同上;用于把每个 dot 的世界坐标变换到屏幕 + m_dotsProg->setUniformValue("uMVP", m_mvp); + m_dotsProg->setUniformValue("uRenderMode", int(m_renderMode)); + // uDotRadius: dot 的半径(世界坐标单位) + m_dotsProg->setUniformValue("uDotRadius", m_dotRadius); + // uBaseY: dot 的高度(放在 panel 顶面上方一点点,避免 z-fighting/闪烁) + m_dotsProg->setUniformValue("uBaseY", (m_panelH * 0.5f) + 0.001f); + // uMinV/uMaxV: 传感值范围,用于 fragment shader 把 value 映射成颜色 + m_dotsProg->setUniformValue("uMinV", float(m_min)); + m_dotsProg->setUniformValue("uMaxV", float(m_max)); + const int hasData = (m_hasData.load() || m_dotTex == 0) ? 1 : 0; + m_dotsProg->setUniformValue("uHasData", hasData); + m_dotsProg->setUniformValue("uCameraPos", m_cameraPos); + m_dotsProg->setUniformValue("uDotTex", 0); + if (m_dotTex) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, m_dotTex); + } + + glBindVertexArray(m_dotsVao); + glDrawArraysInstanced(GL_TRIANGLES, 0, 6, m_instanceCount); + glBindVertexArray(0); + if (m_dotTex) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + } + + m_dotsProg->release(); + } + + if (m_labelMode != LabelsOff && dotCount() > 0) { + QVector valuesCopy; + { + QMutexLocker lk(&m_dataMutex); + valuesCopy = m_latestValues; + } + + auto projectToScreen = [&](const QVector3D& world, QPointF& out) -> bool { + const QVector4D clip = m_mvp * QVector4D(world, 1.0f); + if (clip.w() <= 1e-6f) + return false; + const QVector3D ndc = clip.toVector3D() / clip.w(); + if (ndc.z() < -1.2f || ndc.z() > 1.2f) + return false; + + out.setX((ndc.x() * 0.5f + 0.5f) * float(width())); + out.setY((1.0f - (ndc.y() * 0.5f + 0.5f)) * float(height())); + return true; + }; + + const float baseY = (m_panelH * 0.5f) + 0.001f; + const float w = (m_cols - 1) * m_pitch; + const float h = (m_rows - 1) * m_pitch; + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + + QFont font = painter.font(); + font.setPixelSize(11); + painter.setFont(font); + const QFontMetrics fm(font); + + auto drawLabel = [&](const QPointF& anchor, const QPointF& offset, const QString& text) { + QRectF box = fm.boundingRect(text); + box.adjust(-6.0, -3.0, 6.0, 3.0); + box.moveCenter(anchor + offset); + + const float margin = 4.0f; + if (box.left() < margin) box.moveLeft(margin); + if (box.top() < margin) box.moveTop(margin); + if (box.right() > width() - margin) box.moveRight(width() - margin); + if (box.bottom() > height() - margin) box.moveBottom(height() - margin); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 150)); + painter.drawRoundedRect(box, 4.0, 4.0); + painter.setPen(QColor(255, 255, 255, 235)); + painter.drawText(box, Qt::AlignCenter, text); + }; + + auto dotWorldCenter = [&](int i) -> QVector3D { + const int rr = (m_cols > 0) ? (i / m_cols) : 0; + const int cc = (m_cols > 0) ? (i % m_cols) : 0; + const float x = (cc * m_pitch) - w * 0.5f; + const float z = (rr * m_pitch) - h * 0.5f; + return QVector3D(x, baseY, z); + }; + + auto dotRadiusPx = [&](const QVector3D& worldCenter, const QPointF& centerPx) -> float { + QPointF edge; + if (!projectToScreen(worldCenter + QVector3D(m_dotRadius, 0.0f, 0.0f), edge)) + return 0.0f; + const float dx = float(edge.x() - centerPx.x()); + const float dy = float(edge.y() - centerPx.y()); + return std::sqrt(dx * dx + dy * dy); + }; + + if (m_labelMode == LabelsAlways && valuesCopy.size() == dotCount()) { + painter.setPen(QColor(255, 255, 255, 220)); + for (int i = 0; i < dotCount(); ++i) { + QPointF centerPx; + const QVector3D world = dotWorldCenter(i); + if (!projectToScreen(world, centerPx)) + continue; + + const float rPx = dotRadiusPx(world, centerPx); + if (rPx < 12.0f) + continue; + + const QString text = QString::number(int(valuesCopy[i] + 0.5f)); + drawLabel(centerPx, QPointF(0.0, 0.0), text); + } + } + + if (m_hoveredIndex >= 0 && m_hoveredIndex < dotCount() && valuesCopy.size() == dotCount()) { + QPointF centerPx; + const QVector3D world = dotWorldCenter(m_hoveredIndex); + if (projectToScreen(world, centerPx)) { + const float rPx = dotRadiusPx(world, centerPx); + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(255, 255, 255, 210), 1.5)); + painter.drawEllipse(centerPx, rPx * 1.08f, rPx * 1.08f); + + if (m_labelMode == LabelsHover) { + const QString text = QString::number(int(valuesCopy[m_hoveredIndex] + 0.5f)); + drawLabel(centerPx, QPointF(0.0, -rPx - 12.0f), text); + } + } + } + } +} + +void GLWidget::mousePressEvent(QMouseEvent *event) { + m_lastPos = event->pos(); + if (event->button() == Qt::RightButton) { + m_rightDown = true; + event->accept(); + return; + } + QOpenGLWidget::mousePressEvent(event); +} + +void GLWidget::mouseMoveEvent(QMouseEvent *event) { + const QPoint delta = event->pos() - m_lastPos; + m_lastPos = event->pos(); + + if (m_rightDown) { + m_camYawDeg += delta.x() * 0.3f; + m_camPitchDeg = qBound(-89.0f, m_camPitchDeg + delta.y() * 0.3f, 89.0f); + emit yawChanged(); + update(); + event->accept(); + return; + } + + if (m_labelMode != LabelsOff && dotCount() > 0) { + auto projectToScreen = [&](const QVector3D& world, QPointF& out) -> bool { + const QVector4D clip = m_mvp * QVector4D(world, 1.0f); + if (clip.w() <= 1e-6f) + return false; + const QVector3D ndc = clip.toVector3D() / clip.w(); + if (ndc.z() < -1.2f || ndc.z() > 1.2f) + return false; + + out.setX((ndc.x() * 0.5f + 0.5f) * float(width())); + out.setY((1.0f - (ndc.y() * 0.5f + 0.5f)) * float(height())); + return true; + }; + + const QPoint mousePos = event->pos(); + const float baseY = (m_panelH * 0.5f) + 0.001f; + const float w = (m_cols - 1) * m_pitch; + const float h = (m_rows - 1) * m_pitch; + + int best = -1; + float bestDist2 = std::numeric_limits::infinity(); + for (int i = 0; i < dotCount(); ++i) { + const int rr = (m_cols > 0) ? (i / m_cols) : 0; + const int cc = (m_cols > 0) ? (i % m_cols) : 0; + const float x = (cc * m_pitch) - w * 0.5f; + const float z = (rr * m_pitch) - h * 0.5f; + + QPointF center; + const QVector3D worldCenter(x, baseY, z); + if (!projectToScreen(worldCenter, center)) + continue; + + QPointF edge; + if (!projectToScreen(worldCenter + QVector3D(m_dotRadius, 0.0f, 0.0f), edge)) + continue; + + const float radDx = float(edge.x() - center.x()); + const float radDy = float(edge.y() - center.y()); + const float radPx = std::sqrt(radDx * radDx + radDy * radDy); + const float threshold = radPx + 6.0f; + + const float dx = float(mousePos.x()) - float(center.x()); + const float dy = float(mousePos.y()) - float(center.y()); + const float dist2 = dx * dx + dy * dy; + if (dist2 <= threshold * threshold && dist2 < bestDist2) { + best = i; + bestDist2 = dist2; + } + } + + if (best != m_hoveredIndex) { + m_hoveredIndex = best; + update(); + } + } + + QOpenGLWidget::mouseMoveEvent(event); +} + +void GLWidget::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() == Qt::RightButton) { + m_rightDown = false; + event->accept(); + return; + } + QOpenGLWidget::mouseReleaseEvent(event); +} + +void GLWidget::wheelEvent(QWheelEvent *event) { + const float steps = event->angleDelta().y() / 120.0f; + const float factor = std::pow(0.9f, steps); + m_zoom_ = qBound(0.2f, m_zoom_ * factor, 45.0f); + update(); + event->accept(); +} + +void GLWidget::initBackgroundGeometry_() { + if (m_bgVbo) { + glDeleteBuffers(1, &m_bgVbo); + m_bgVbo = 0; + } + if (m_bgVao) { + glDeleteVertexArrays(1, &m_bgVao); + m_bgVao = 0; + } + + // 全屏 quad(两个三角形),坐标直接是裁剪空间 NDC [-1,1] + const float verts[] = { + -1.0f, -1.0f, + 1.0f, -1.0f, + 1.0f, 1.0f, + -1.0f, -1.0f, + 1.0f, 1.0f, + -1.0f, 1.0f, + }; + + glGenVertexArrays(1, &m_bgVao); + glBindVertexArray(m_bgVao); + + glGenBuffers(1, &m_bgVbo); + glBindBuffer(GL_ARRAY_BUFFER, m_bgVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); + + glBindVertexArray(0); +} + +void GLWidget::initPanelGeometry_() { + // panel:目前只画“顶面矩形”,y 固定在 +panelH/2 + if (m_panelIbo) { + glDeleteBuffers(1, &m_panelIbo); + m_panelIbo = 0; + } + if (m_panelVbo) { + glDeleteBuffers(1, &m_panelVbo); + m_panelVbo = 0; + } + if (m_panelVao) { + glDeleteVertexArrays(1, &m_panelVao); + m_panelVao = 0; + } + + const float y = m_panelH * 0.5f; + const float hw = m_panelW * 0.5; + const float hd = m_panelD * 0.5f; + + struct V { + float x, y, z; + float nx, ny, nz; + }; + V verts[24] = { + // +Y 顶面 (normal 0, +1, 0) + {-hw, +y, -hd, 0, +1, 0}, // 0 + {+hw, +y, -hd, 0, +1, 0}, // 1 + {+hw, +y, +hd, 0, +1, 0}, // 2 + {-hw, +y, +hd, 0, +1, 0}, // 3 + + // +Z 前面 (normal 0, 0, +1) + {-hw, +y, +hd, 0, 0, +1}, // 4 + {+hw, +y, +hd, 0, 0, +1}, // 5 + {+hw, -y, +hd, 0, 0, +1}, // 6 + {-hw, -y, +hd, 0, 0, +1}, // 7 + + // -Y 底面 (normal 0, -1, 0) + {-hw, -y, +hd, 0, -1, 0}, // 8 + {+hw, -y, +hd, 0, -1, 0}, // 9 + {+hw, -y, -hd, 0, -1, 0}, // 10 + {-hw, -y, -hd, 0, -1, 0}, // 11 + + // -Z 后面 (normal 0, 0, -1) + {+hw, +y, -hd, 0, 0, -1}, // 12 + {-hw, +y, -hd, 0, 0, -1}, // 13 + {-hw, -y, -hd, 0, 0, -1}, // 14 + {+hw, -y, -hd, 0, 0, -1}, // 15 + + // -X 左面 (normal -1, 0, 0) + {-hw, +y, -hd, -1, 0, 0}, // 16 + {-hw, +y, +hd, -1, 0, 0}, // 17 + {-hw, -y, +hd, -1, 0, 0}, // 18 + {-hw, -y, -hd, -1, 0, 0}, // 19 + + // +X 右面 (normal +1, 0, 0) + {+hw, +y, +hd, +1, 0, 0}, // 20 + {+hw, +y, -hd, +1, 0, 0}, // 21 + {+hw, -y, -hd, +1, 0, 0}, // 22 + {+hw, -y, +hd, +1, 0, 0}, // 23 + }; + + unsigned int idx[36] = { + 0, 1, 2, 0, 2, 3, // top + 4, 5, 6, 4, 6, 7, // front + 8, 9, 10, 8, 10,11, // bottom + 12,13,14, 12,14,15, // back + 16,17,18, 16,18,19, // left + 20,21,22, 20,22,23 // right + }; + + m_panelIndexCount = 36; + + glGenVertexArrays(1, &m_panelVao); + glBindVertexArray(m_panelVao); + + glGenBuffers(1, &m_panelVbo); + glBindBuffer(GL_ARRAY_BUFFER, m_panelVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + + glGenBuffers(1, &m_panelIbo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_panelIbo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW); + + // attribute layout: + // location 0: position (x,y,z) + // location 1: normal (nx,ny,nz) —— 当前 shader 没用到,先留着 + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(3*sizeof(float))); + glBindVertexArray(0); +} + +void GLWidget::initDotGeometry_() { + // dot:使用 instanced rendering 画很多个“圆点”(本质是一个 quad + fragment shader 里 discard 成圆) + // vertex buffer 里只有一个单位 quad,instance buffer 里存每个点的位置和数值。 + if (m_instanceVbo) { + glDeleteBuffers(1, &m_instanceVbo); + m_instanceVbo = 0; + } + if (m_dotsVbo) { + glDeleteBuffers(1, &m_dotsVbo); + m_dotsVbo = 0; + } + if (m_dotsVao) { + glDeleteVertexArrays(1, &m_dotsVao); + m_dotsVao = 0; + } + + 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}, + }; + + glGenVertexArrays(1, &m_dotsVao); + glBindVertexArray(m_dotsVao); + glGenBuffers(1, &m_dotsVbo); + glBindBuffer(GL_ARRAY_BUFFER, m_dotsVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + + // attribute layout: + // location 0: quad 顶点位置 (x,y),范围 [-1,1] + // location 1: UV (u,v),用于 fragment shader 生成圆形 + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(2 * sizeof(float))); + + // instance buffer:每个点 3 个 float(offsetX, offsetZ, value) + // layout location 2/3 对应 shader 里的 iOffsetXZ / iValue + glGenBuffers(1, &m_instanceVbo); + glBindBuffer(GL_ARRAY_BUFFER, m_instanceVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 3 * qMax(1, dotCount()), nullptr, GL_DYNAMIC_DRAW); + + // location 2: vec2 iOffsetXZ(每个 instance 一次,divisor=1) + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); + glVertexAttribDivisor(2, 1); + + // location 3: float iValue(每个 instance 一次,divisor=1) + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)(2 * sizeof(float))); + glVertexAttribDivisor(3, 1); + + glBindVertexArray(0); +} + +void GLWidget::initDotTexture_() { + if (m_dotTex) { + glDeleteTextures(1, &m_dotTex); + m_dotTex = 0; + } + + const QString path = QStringLiteral(":/images/metal.jpeg"); + QImage img(path); + if (img.isNull()) { + qWarning() << "dot texture load failed:" << path; + return; + } + + QImage rgba = img.convertToFormat(QImage::Format_RGBA8888); + glGenTextures(1, &m_dotTex); + glBindTexture(GL_TEXTURE_2D, m_dotTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + rgba.width(), + rgba.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + rgba.constBits() + ); + glGenerateMipmap(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GLWidget::initPrograms_() { + // Qt Resource 里打包的 shader 文件路径:使用 `:/` 前缀(不是文件系统路径) + const QString vsd_path = QStringLiteral(":/shaders/dots.vert"); + const QString fsd_path = QStringLiteral(":/shaders/dots.frag"); + const QString vsb_path = QStringLiteral(":/shaders/bg.vert"); + const QString fsb_path = QStringLiteral(":/shaders/bg.frag"); + const QString vsp_path = QStringLiteral(":/shaders/panel.vert"); + const QString fsp_path = QStringLiteral(":/shaders/panel.frag"); + + { + auto *p = new QOpenGLShaderProgram; + const bool okV = p->addShaderFromSourceCode(QOpenGLShader::Vertex, readFile(vsb_path)); + const bool okF = p->addShaderFromSourceCode(QOpenGLShader::Fragment, readFile(fsb_path)); + const bool okL = okV && okF && p->link(); + if (!okL) { + qWarning() << "bg program build failed:" << vsb_path << fsb_path << p->log(); + delete p; + p = nullptr; + } + m_bgProg = p; + } + { + auto *p = new QOpenGLShaderProgram; + const bool okV = p->addShaderFromSourceCode(QOpenGLShader::Vertex, readFile(vsp_path)); + const bool okF = p->addShaderFromSourceCode(QOpenGLShader::Fragment, readFile(fsp_path)); + const bool okL = okV && okF && p->link(); + if (!okL) { + qWarning() << "panel program build failed:" << vsp_path << fsp_path << p->log(); + delete p; + p = nullptr; + } + m_panelProg = p; + } + { + auto *p = new QOpenGLShaderProgram; + const bool okV = p->addShaderFromSourceCode(QOpenGLShader::Vertex, readFile(vsd_path)); + const bool okF = p->addShaderFromSourceCode(QOpenGLShader::Fragment, readFile(fsd_path)); + const bool okL = okV && okF && p->link(); + if (!okL) { + qWarning() << "dots program build failed:" << vsd_path << fsd_path << p->log(); + delete p; + p = nullptr; + } + m_dotsProg = p; + } +} + +void GLWidget::updateInstanceBufferIfNeeded_() { + if (dotCount() <= 0) { + m_instanceCount = 0; + return; + } + + QVector valuesCopy; + bool dirty = false; + { + QMutexLocker lk(&m_dataMutex); + dirty = m_valuesDirty; + if (dirty) { + valuesCopy = m_latestValues; + m_valuesDirty = false; + } + } + if (!dirty) + return; + + const int n = dotCount(); + m_instanceCount = n; + QVector inst; + // 每个点 3 个 float:offsetX, offsetZ, value(对应 dots.vert: iOffsetXZ + iValue) + inst.resize(n * 3); + + const float w = (m_cols - 1) * m_pitch; + const float h = (m_rows - 1) * m_pitch; + + // 把点阵居中到原点附近(x/z 以中心对称) + 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; + inst[i * 3 + 1] = z; + inst[i * 3 + 2] = (i < valuesCopy.size()) ? valuesCopy[i] : m_min; + } + + // 把 CPU 生成的 instance 数据上传到 GPU(后续 draw 时由 VAO 里的 attribute 2/3 读取) + glBindBuffer(GL_ARRAY_BUFFER, m_instanceVbo); + glBufferSubData(GL_ARRAY_BUFFER, 0, inst.size() * sizeof(float), inst.constData()); +} + +void GLWidget::updateMatrices_() { + // MVP = Projection * View * Model + // + // OpenGL 的顶点最终要写到 gl_Position(裁剪空间),所以我们需要一个矩阵把“世界坐标”变成“屏幕可见”的坐标。 + // 这里为了入门简单: + // - panel/dots 顶点数据直接当作世界坐标(Model=Identity) + // - View 用 lookAt 放一个相机 + // - Projection 用透视投影(perspective) + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0) { + m_mvp.setToIdentity(); + return; + } + + const float aspect = float(w) / float(h); + const float radius = 0.5f * qSqrt(m_panelW * m_panelW + m_panelD * m_panelD); + const float distance = qMax(0.5f, radius * 2.5f); + + // 让相机看向 panel 的中心点 + const QVector3D center(0.0f, m_panelH * 0.5f, 0.0f); + + // yaw/pitch 控制相机绕目标点“环绕”(orbit camera) + const float yawRad = qDegreesToRadians(m_camYawDeg); + const float pitchRad = qDegreesToRadians(m_camPitchDeg); + const float cosPitch = qCos(pitchRad); + const float sinPitch = qSin(pitchRad); + + const QVector3D eye = center + QVector3D( + distance * cosPitch * qCos(yawRad), + distance * sinPitch, + distance * cosPitch * qSin(yawRad) + ); + m_cameraPos = eye; + + QMatrix4x4 view; + view.lookAt(eye, center, QVector3D(0.0f, 1.0f, 0.0f)); + + QMatrix4x4 proj; + // fov=45°,near=0.01,far=足够大(保证 panel + dots 在裁剪范围内) + proj.perspective(m_zoom_, aspect, 0.01f, qMax(10.0f, distance * 10.0f)); + + m_mvp = proj * view; +} diff --git a/src/glwidget.h b/src/glwidget.h new file mode 100644 index 0000000..ae9d9cf --- /dev/null +++ b/src/glwidget.h @@ -0,0 +1,151 @@ +// +// Created by Lenn on 2025/12/16. +// + +#ifndef TACTILEIPC3D_GLWIDGET_H +#define TACTILEIPC3D_GLWIDGET_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core { + Q_OBJECT + Q_PROPERTY(float yaw READ yaw WRITE setYaw NOTIFY yawChanged) +public: + enum RenderMode { + Realistic = 0, + DataViz = 1, + }; + Q_ENUM(RenderMode) + + enum LabelMode { + LabelsOff = 0, + LabelsHover = 1, + LabelsAlways = 2, + }; + Q_ENUM(LabelMode) + + explicit GLWidget(QWidget* parent = nullptr); + ~GLWidget(); + + // panel 的物理尺寸(单位随你:米/毫米都行,只要全程一致) + void setPanelSize(float w, float h, float d); + void setPanelThickness(float h); + // rows/cols: 传感点阵行列;pitch: 点与点的间距;dotRaius: 单个点的“显示半径” + void setSpec(int rows, int cols, float pitch, float dotRaius); + // 总点数 = 行 * 列 + int dotCount() const { return m_cols * m_rows; } + + // 提交一帧传感值(数量必须等于 dotCount),在 paintGL 里用 instanced 的方式绘制 + void submitValues(const QVector& values); + + float yaw() const { return m_camYawDeg; } + +public slots: + // 值域范围,用于 shader 里把 value 映射到颜色(绿->红) + void setRange(int minV, int maxV); + void setYaw(float yawDeg); + void setRenderModeString(const QString& mode); + void setLabelModeString(const QString& mode); + +signals: + void yawChanged(); + +protected: + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + void initPanelGeometry_(); + void initDotGeometry_(); + void initBackgroundGeometry_(); + void initPrograms_(); + void initDotTexture_(); + void updateInstanceBufferIfNeeded_(); + void updateMatrices_(); + +private: + // 传感值范围(用于颜色映射) + int m_min = 0; + int m_max = 1000; + + // 点阵规格 + int m_rows = 3; + int m_cols = 4; + + // panel: 一个长方体/板子(当前只画顶面矩形) + float m_panelW = 1.2f; + float m_panelH = 0.08f; + float m_panelD = 0.08f; + + // 点阵布局参数 + float m_pitch = 0.1f; + float m_dotRadius = 0.03f; + + // 传感数据(GUI/定时器线程提交,GL 线程绘制) + QMutex m_dataMutex; + QVector m_latestValues; + bool m_valuesDirty = false; + + std::atomic m_hasData{false}; + + // shader program(编译/链接后的可执行 GPU 程序) + QOpenGLShaderProgram* m_bgProg = nullptr; + QOpenGLShaderProgram* m_panelProg = nullptr; + QOpenGLShaderProgram* m_dotsProg = nullptr; + + unsigned int m_dotTex = 0; + + // panel 的 VAO/VBO/IBO(VAO: 顶点格式/绑定状态;VBO: 顶点数据;IBO: 索引数据) + unsigned int m_panelVao = 0; + unsigned int m_panelVbo = 0; + unsigned int m_panelIbo = 0; + int m_panelIndexCount = 0; + bool m_panelGeometryDirty = false; + + // dots 的 VAO/VBO + instance VBO(instance VBO 每个点一条:offsetXZ + value) + unsigned int m_dotsVao = 0; + unsigned int m_dotsVbo = 0; + unsigned int m_instanceVbo = 0; + int m_instanceCount = 0; + bool m_dotsGeometryDirty = false; + + unsigned int m_bgVao = 0; + unsigned int m_bgVbo = 0; + + // MVP = Projection * View * Model。 + // 这里 panel/dots 顶点基本已经是“世界坐标”,所以我们用 proj*view 即可(model 先省略)。 + QMatrix4x4 m_mvp; + QVector3D m_cameraPos{0.0f, 0.0f, 1.0f}; + + RenderMode m_renderMode = DataViz; + LabelMode m_labelMode = LabelsOff; + int m_hoveredIndex = -1; + + // 早期/预留的矩阵数组(现在主要使用 QMatrix4x4 的 m_mvp,这些暂时保留做对照/扩展) + float m_proj[16]{}; + float m_view[16]{}; + float m_modelPanel[16]{}; + + float m_zoom_ = 45.0; + float m_camYawDeg = 45.0f; + float m_camPitchDeg = 35.0f; + + std::atomic m_rightDown{false}; + QPoint m_lastPos; +}; + + +#endif //TACTILEIPC3D_GLWIDGET_H