first commit
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ CMakeLists.txt.user
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
/.cache/
|
||||||
|
/build/
|
||||||
|
|||||||
151
AUTO_PANEL_SIZING.md
Normal file
151
AUTO_PANEL_SIZING.md
Normal file
@@ -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。
|
||||||
|
|
||||||
134
BACKGROUND_GRID.md
Normal file
134
BACKGROUND_GRID.md
Normal file
@@ -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” 的淡淡雾化效果
|
||||||
|
|
||||||
66
CMakeLists.txt
Normal file
66
CMakeLists.txt
Normal file
@@ -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
|
||||||
|
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>/plugins/platforms/")
|
||||||
|
# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
|
||||||
|
# COMMAND ${CMAKE_COMMAND} -E copy
|
||||||
|
# "${QT_INSTALL_PATH}/plugins/platforms/qwindows${DEBUG_SUFFIX}.dll"
|
||||||
|
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>/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"
|
||||||
|
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>")
|
||||||
|
# endforeach (QT_LIB)
|
||||||
|
#endif ()
|
||||||
259
README.md
Normal file
259
README.md
Normal file
@@ -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 了,需要真正的几何或法线光照
|
||||||
|
|
||||||
BIN
images/metal.jpeg
Normal file
BIN
images/metal.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
74
main.cpp
Normal file
74
main.cpp
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#include <QApplication>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QQuickWidget>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQuickWindow>
|
||||||
|
#include <QSGRendererInterface>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
|
||||||
|
#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<float> 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();
|
||||||
|
}
|
||||||
6
qml/Main.qml
Normal file
6
qml/Main.qml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import QtQuick
|
||||||
|
import "content"
|
||||||
|
|
||||||
|
App {
|
||||||
|
|
||||||
|
}
|
||||||
18
qml/content/App.qml
Normal file
18
qml/content/App.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
131
qml/content/ControlPanel.qml
Normal file
131
qml/content/ControlPanel.qml
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
20
qml/content/LabeledSlider.qml
Normal file
20
qml/content/LabeledSlider.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
qml/content/Legend.qml
Normal file
49
qml/content/Legend.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
18
qml/content/Toggle.qml
Normal file
18
qml/content/Toggle.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
resources.qrc
Normal file
17
resources.qrc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/">
|
||||||
|
<file>qml/Main.qml</file>
|
||||||
|
<file>qml/content/App.qml</file>
|
||||||
|
<file>qml/content/ControlPanel.qml</file>
|
||||||
|
<file>qml/content/Legend.qml</file>
|
||||||
|
<file>qml/content/LabeledSlider.qml</file>
|
||||||
|
<file>qml/content/Toggle.qml</file>
|
||||||
|
<file>shaders/dots.frag</file>
|
||||||
|
<file>shaders/dots.vert</file>
|
||||||
|
<file>shaders/bg.frag</file>
|
||||||
|
<file>shaders/bg.vert</file>
|
||||||
|
<file>shaders/panel.frag</file>
|
||||||
|
<file>shaders/panel.vert</file>
|
||||||
|
<file>images/metal.jpeg</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
48
shaders/bg.frag
Normal file
48
shaders/bg.frag
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
8
shaders/bg.vert
Normal file
8
shaders/bg.vert
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
153
shaders/dots.frag
Normal file
153
shaders/dots.frag
Normal file
@@ -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);
|
||||||
|
}
|
||||||
31
shaders/dots.vert
Normal file
31
shaders/dots.vert
Normal file
@@ -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);
|
||||||
|
}
|
||||||
183
shaders/panel.frag
Normal file
183
shaders/panel.frag
Normal file
@@ -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);
|
||||||
|
}
|
||||||
18
shaders/panel.vert
Normal file
18
shaders/panel.vert
Normal file
@@ -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);
|
||||||
|
}
|
||||||
64
src/backend.cpp
Normal file
64
src/backend.cpp
Normal file
@@ -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);
|
||||||
|
}
|
||||||
54
src/backend.h
Normal file
54
src/backend.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// Created by Lenn on 2025/12/16.
|
||||||
|
//
|
||||||
|
|
||||||
|
#ifndef TACTILEIPC3D_BACKEND_H
|
||||||
|
#define TACTILEIPC3D_BACKEND_H
|
||||||
|
#include <qobject.h>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
851
src/glwidget.cpp
Normal file
851
src/glwidget.cpp
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
//
|
||||||
|
// Created by Lenn on 2025/12/16.
|
||||||
|
//
|
||||||
|
|
||||||
|
#include "glwidget.h"
|
||||||
|
#include <QOpenGLShaderProgram>
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QtMath>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <qevent.h>
|
||||||
|
#include <QVector3D>
|
||||||
|
#include <QVector2D>
|
||||||
|
#include <QVector4D>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
// 读取文本文件内容(这里主要用来从 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<float> &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<float> 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<float>::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<float> 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<float> 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;
|
||||||
|
}
|
||||||
151
src/glwidget.h
Normal file
151
src/glwidget.h
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
//
|
||||||
|
// Created by Lenn on 2025/12/16.
|
||||||
|
//
|
||||||
|
|
||||||
|
#ifndef TACTILEIPC3D_GLWIDGET_H
|
||||||
|
#define TACTILEIPC3D_GLWIDGET_H
|
||||||
|
|
||||||
|
#include <QOpenGLWidget>
|
||||||
|
#include <QOpenGLFunctions>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QOpenGLShaderProgram>
|
||||||
|
#include <QOpenGLFunctions_3_3_Core>
|
||||||
|
#include <QMatrix4x4>
|
||||||
|
#include <QVector3D>
|
||||||
|
#include <QString>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
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<float>& 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<float> m_latestValues;
|
||||||
|
bool m_valuesDirty = false;
|
||||||
|
|
||||||
|
std::atomic<bool> 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<bool> m_rightDown{false};
|
||||||
|
QPoint m_lastPos;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#endif //TACTILEIPC3D_GLWIDGET_H
|
||||||
Reference in New Issue
Block a user