骨磨削可视化¶
复现下 MAKO 的专利 "视角无关的切割过程可视化表示 (US 2019/0183580 A1)"。谷歌专利检索
核心思想:不要在图像空间减刀路,而是在“物体空间”里维护一个“刀具扫掠体的体素并集”,再用光线行进实时求 CSG 差集。
1 骨磨削 / 几何切除方式¶
骨磨削可视化其实就是时间连续的 CSG(Constructive Solid Geometry)问题。
不同方法的区别在于 CSG 的表示空间、CSG 的求值策略、CSG 的可视化方式。
| 维度 | SDF + 表面重建 | SDF + 直接渲染(Raymarch) |
|---|---|---|
| 稳定性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 效率 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 多次切削一致性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 渲染效果 | ⭐⭐ | ⭐⭐⭐⭐ |
| 工具边缘贴合 | ⭐⭐ | ⭐⭐⭐⭐ |
| 微小切削保真度 | ⭐⭐⭐ | ⭐⭐⭐ |
| 无采样漏切 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 生成显式拓扑 | ✅ 有 | ❌ 无 |
1.1 显式 Mesh 布尔¶
几何最纯,但工程最脆弱
本质:对当前骨骼三角网格与工具网格做反复 Boolean Difference
适用性:离线、高精度、非实时的几何建模或 CAD 级别分析
局限性:拓扑与数值稳定性高度依赖网格质量,在连续、多次、小步切削中不可避免地产生退化与失败。可通过容差处理、网格修复等工程手段进行缓解,但在实时连续切削中仍面临巨大挑战。
1.2 SDF + 隐式函数裁剪 + 表面重建¶
工业医疗的黄金平衡点
本质:在体素空间维护骨体的有符号距离场,通过工具隐式函数对距离场逐步裁剪并累积切削结果,并在必要时通过等值面提取或局部表面重建获得真实切削表面。
适用性:实时医疗磨削、可导出几何、稳定长期累积(工业主流)
局限性:表面重建质量与实时性之间存在不可避免的权衡:更高精度的局部重建(高分辨率体素、频繁等值面更新)会带来明显的计算开销;而以性能为优先的重建策略(降低更新频率或采用近似裁剪)则可能在工具接触区域产生边缘贴合误差。
1.3 SDF + 光线求值¶
视觉最爽,但只是“看起来切了”
本质:直接对隐式函数(如SDF)进行光线步进以渲染其零等值面(即物体表面),无需预先转换为多边形网格。
适用性:计算成本恒定、高帧率可视化、训练模拟器、视觉反馈,不强调几何导出
局限性:几何状态主要存在于渲染与查询过程中,缺乏稳定、可复用、可编辑的显式边界表示,难以用于精确的几何分析、导出或后续CAD操作。
2 骨磨削渲染实现¶
使用体素 Union + Raymarch 的方式,刀路采样稀疏但表面平滑,实时、增量、计算成本恒定。猜测割过程的实时可视化流程如下:
-
连续刀具位姿追踪(Tracking)
-
形状扫描连续姿态(Shape Sweeping)
-
累积切割轨迹到体素化CSG网格
-
执行CSG操作 (Raymarch Voxel DDA)
-
显示CSG结果(2D图像)
![]() |
![]() |
|---|---|
2.1 连续刀具位姿追踪¶
以恒定的采样频率(离散时间序列)记录工具位置。
2.2 形状扫描(Shape Sweeping)¶
专利提到的关键创新:在显示阶段消除采样离散造成的视觉伪影。本质就是“连续刀具运动的几何补偿”。
对连续位姿做 Minkowski Sweep(扫掠体),可以解决因姿态采样稀疏导致的视觉伪影。针对不同的工具形状(球体、长方体),将离散的连续姿态包裹在一个平滑的曲面中。扫描方式可采用分段线性近似或高阶样条逼近。
我主要是想学习通过 GPU 去 Raymarch 计算得到CSG结果,这个步骤我就没去研究,也没实现。
2.3 体素化 CSG 网格¶
增量维护一个最小距离场(Signed Distance Field, SDF)。
不需要可视化,可以直接用计算着色器。高版本 vtkShader 已经支持 COMPUTE_SHADER 了,我这里还是旧的写法。
void vtkGrindPolyDataMapper::ComputeSDFPass(vtkRenderer * ren, vtkActor * actor)
{
if (!ToolMatrixDirty) {
return;
}
ToolMatrixDirty = false;
vtkOpenGLRenderWindow * renWin = static_cast<vtkOpenGLRenderWindow *>(ren->GetRenderWindow());
renWin->MakeCurrent();
#if SHADER_HOT_RELOAD
if (SDFShaderProgram) {
glDeleteProgram(SDFShaderProgram);
SDFShaderProgram = 0;
}
#endif
if (!SDFShaderProgram) {
std::string csSource = LoadQtResource(":/shader/sdf.comp");
GLuint cs = glCreateShader(GL_COMPUTE_SHADER);
const char * src = csSource.c_str();
glShaderSource(cs, 1, &src, nullptr);
glCompileShader(cs);
SDFShaderProgram = glCreateProgram();
glAttachShader(SDFShaderProgram, cs);
glLinkProgram(SDFShaderProgram);
glDeleteShader(cs);
}
glUseProgram(SDFShaderProgram);
GLuint texDistID = SDFTexture->GetHandle();
glBindImageTexture(0, texDistID, 0, GL_TRUE, 0, GL_READ_WRITE, GL_R32F);
glUniform3fv(glGetUniformLocation(SDFShaderProgram, "gridOrigin"), 1, GridOrigin);
glUniform3fv(glGetUniformLocation(SDFShaderProgram, "gridSpacing"), 1, GridSpacing);
glUniform3iv(glGetUniformLocation(SDFShaderProgram, "gridSize"), 1, GridSize);
glUniform3fv(glGetUniformLocation(SDFShaderProgram, "toolSize"), 1, ToolSize);
vtkNew<vtkMatrix4x4> invToolMat;
vtkMatrix4x4::Invert(ToolMatrix, invToolMat);
float invToolMatrix[16];
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
invToolMatrix[i * 4 + j] = static_cast<float>(invToolMat->GetElement(i, j));
}
}
glUniformMatrix4fv(glGetUniformLocation(SDFShaderProgram, "world2ToolMatrix"), 1, GL_TRUE, invToolMatrix);
int workGroupX = (GridSize[0] + 7) / 8;
int workGroupY = (GridSize[1] + 7) / 8;
int workGroupZ = (GridSize[2] + 7) / 8;
glDispatchCompute(workGroupX, workGroupY, workGroupZ);
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT | GL_TEXTURE_FETCH_BARRIER_BIT);
glUseProgram(0);
}
vec3 texCoordToworldPos(vec3 tc) {
vec3 voxelCoords = tc * vec3(gridSize);
vec3 worldPos = (voxelCoords - 0.5) * gridSpacing + gridOrigin;
return worldPos;
}
float sdSphere(vec3 worldPos)
{
vec3 pLocal = (world2ToolMatrix * vec4(worldPos, 1.0)).xyz;
return length(pLocal) - toolSize.x;
}
void main()
{
// 获取当前线程处理的体素坐标(3D 并行,自动遍历所有层)
ivec3 voxelCoord = ivec3(gl_GlobalInvocationID.xyz);
// 边界检查
if (any(greaterThanEqual(voxelCoord, gridSize))) {
return;
}
// 计算纹理坐标(归一化到 [0,1])
vec3 toolTexCoord = (vec3(voxelCoord) + 0.5) / vec3(gridSize);
// 读取旧的距离值
float odist = imageLoad(texDist, voxelCoord).x;
// 计算世界坐标
vec3 worldPos = texCoordToworldPos(toolTexCoord);
// 计算新的 SDF 距离
float ndist = sdSphere(worldPos);
// 更新距离值
if (ndist < odist) {
imageStore(texDist, voxelCoord, vec4(ndist, 0, 0, 0));
}
}
2.4 执行CSG操作 (Raymarch Voxel DDA)¶
1) 构建 ABuffer¶
首先在对象模型上执行单次 A 缓冲区光栅化创建对象模型的 A 缓冲区。我额外存了表面法线方便后续计算光照。
这里不涉及半透明和前后排序,也完全无需无需深度测试。ABuffer 主要用于验证与研究,实际工程更倾向于 entry/exit buffer。
struct ABufferNode {
vec4 worldPos; // 16 bytes
vec4 normalVC; // 16 bytes
float depth; // 4 bytes
uint next; // 4 bytes
vec2 _padding; // 8 bytes
};
void main()
{
// 检查像素坐标
ivec2 coord = ivec2(gl_FragCoord.xy);
if (coord.x < 0 || coord.x >= windowSize.x ||
coord.y < 0 || coord.y >= windowSize.y) {
discard;
}
vec4 vertexVC = vertexVCVSOutput;
// 计算法向量(View Space)
vec3 normalVC = normalize(normalVCVSOutput);
// 分配新节点
uint newNodeIndex = atomicCounterIncrement(nodeCounter);
uint maxNodes = uint(windowSize.x) * uint(windowSize.y) * uint(maxLayers);
if (newNodeIndex >= maxNodes) {
discard;
return;
}
// 存储几何信息
uint previousHead = imageAtomicExchange(headPointers, coord, newNodeIndex);
nodes[newNodeIndex].worldPos = vec4(worldPosVSOutput.xyz, 1.0);
nodes[newNodeIndex].normalVC = vec4(normalVC, 0.0);
nodes[newNodeIndex].depth = gl_FragCoord.z;
nodes[newNodeIndex].next = previousHead;
discard;
}
每次工具移动、相机移动都需要清空 ABuffer 结果,重新计算。
void vtkGrindPolyDataMapper::InitializeABufferData(vtkRenderer * ren)
{
vtkOpenGLRenderWindow * renWin = vtkOpenGLRenderWindow::SafeDownCast(ren->GetRenderWindow());
int width = renWin->GetSize()[0];
int height = renWin->GetSize()[1];
{ // Atomic Counter Buffer
if (AtomicCounterBuffer == 0) {
glGenBuffers(1, &AtomicCounterBuffer);
}
// 每帧重置 Atomic Counter
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, AtomicCounterBuffer);
GLuint initialValue = 0;
glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), &initialValue, GL_DYNAMIC_DRAW);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
}
{ // A-Buffer Head Pointer Texture
if (ABHeadPointerTexture->GetHandle() == 0) {
ABHeadPointerTexture->SetContext(renWin);
ABHeadPointerTexture->Allocate2D(width, height, 1, VTK_UNSIGNED_INT);
glBindTexture(GL_TEXTURE_2D, ABHeadPointerTexture->GetHandle());
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, width, height);
glBindTexture(GL_TEXTURE_2D, 0);
ABHeadPointerTexture->SetWrapS(vtkTextureObject::ClampToEdge);
ABHeadPointerTexture->SetWrapT(vtkTextureObject::ClampToEdge);
ABHeadPointerTexture->SetMinificationFilter(vtkTextureObject::Nearest);
ABHeadPointerTexture->SetMagnificationFilter(vtkTextureObject::Nearest);
}
// 每帧清空 Head Pointer Texture
GLuint clearValue = 0xFFFFFFFF;
glClearTexImage(ABHeadPointerTexture->GetHandle(), 0, GL_RED_INTEGER, GL_UNSIGNED_INT, &clearValue);
}
{ // A-Buffer SSBO
if (ABufferSSBO == 0) {
glGenBuffers(1, &ABufferSSBO);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ABufferSSBO);
size_t nodeSize = 48; // vec4(16) + vec4(16) + float(4) + uint(4) + vec2(8) = 48
size_t maxNodes = static_cast<size_t>(width) * static_cast<size_t>(height) * MaxLayers;
size_t bufferSize = maxNodes * nodeSize;
glBufferData(GL_SHADER_STORAGE_BUFFER, bufferSize, nullptr, GL_DYNAMIC_DRAW);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
}
}
}
2) Raymarch¶
非多边形等值面光线追踪:根据 ABuffer 可以定义有效光线区间,然后沿 ray 行进 Voxel DDA / SDF。
光线行进过程:从虚拟摄像机发射光线;在活动范围内迭代搜索交点【交点即为物体内部且位于切割轨迹外部的最近点,代表切割表面。
Raymarch 步进精度必须与 SDF 体素尺度一致,否则会产生视觉与几何不一致的问题。
void main()
{
ivec2 coord = ivec2(gl_FragCoord.xy);
// 像素坐标是否有效
if (coord.x < 0 || coord.x >= windowSize.x || coord.y < 0 || coord.y >= windowSize.y) {
discard;
return;
}
// 收集片元
ABufferNode fragments[64];
int fragmentCount = collectFragments(coord, fragments);
if (fragmentCount == 0) {
discard;
}
ABufferNode frontNode, backNode;
if (!findNearestTwo(fragments, fragmentCount, frontNode, backNode)) {
// 片元数量异常检查
vec4 debugColor;
if (isFragmentCountAbnormal(fragmentCount, debugColor)) {
fragOutput0 = debugColor;
gl_FragDepth = fragments[0].depth;
}
discard;
return;
}
vec3 startPos = frontNode.worldPos.xyz;
vec3 endPos = backNode.worldPos.xyz;
float sdf = sampleSDF(startPos);
if (sdf >= 0) { // 原始表面(未切割区域)
vec3 normalVC = normalize(frontNode.normalVC.xyz);
vec3 clr = getCutColor(startPos, true);
fragOutput0 = computeSimplifiedLighting(normalVC, clr);
gl_FragDepth = frontNode.depth;
return;
}
// 切面 Raymarch
vec3 rd = normalize(endPos - startPos);
float voxel = min(gridSpacing.x, min(gridSpacing.y, gridSpacing.z));
float pad = voxel * 2.0;
vec3 ro = startPos - rd * pad;
float maxd = distance(endPos, startPos) + 2.0 * pad;
vec3 hitp, hitn;
float hitd = 0;
bool face = false;
if (!raymarch(startPos, rd, maxd, hitp, hitn, hitd, voxel, face)) {
// Raymarch 失败,退回原始 front
vec3 normalVC = normalize(frontNode.normalVC.xyz);
vec3 clr = getCutColor(startPos, true);
fragOutput0 = computeSimplifiedLighting(normalVC, clr);
gl_FragDepth = frontNode.depth;
return;
}
float sdepth = frontNode.depth;
float tdepth = backNode.depth - sdepth;
float ndepth = sdepth + tdepth * hitd / maxd;
vec3 cutColor = getCutColor(hitp, face);
vec3 hitnWS = hitn;
vec3 hitnVC = normalize(normalMatrix * hitnWS);
fragOutput0 = computeSimplifiedLighting(hitnVC, cutColor);
gl_FragDepth = ndepth;
}
2.5 显示CSG结果¶
预先定义不同空间区域(如计划切削区、理想切削区、过切区)与颜色的对应关系,在渲染阶段根据每个屏幕像素对应的世界坐标判断其所属区域,从而确定最终显示颜色,并用于实时的违规切削提示。
当前实现采用简化的局部光照模型(基于法向量的环境光、漫反射与镜面反射组合)。实际工程通常会采用多光源或更复杂的光照配置,以增强表面形态与切削边界的视觉辨识度。
// composite.vert
#version 430 core
out vec2 texCoord;
void main() {
vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
texCoord = pos;
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
}
// 根据坐标计算颜色
vec3 getCutColor(vec3 worldPos, bool isFace)
{
if (useRegionLayering == 0) {
return baseColor;
}
vec3 regionCenter = regionMatrix[3].xyz;
float dist = length(worldPos - regionCenter) - regionRadius;
vec3 clr;
if (isFace && dist > resectThreshold) {
clr = baseColor;
} else if (dist <= resectThreshold) {
clr = resectColor;
} else if (dist <= overcutThreshold) {
clr = resectIdealColor;
} else if (dist > overcutThreshold) {
clr = overcutColor;
}
return clr;
}
vec4 computeSimplifiedLighting(vec3 normalVC, vec3 baseColor)
{
vec3 ambientColor = ambientIntensity * baseColor;
vec3 diffuseColor = diffuseIntensity * baseColor;
vec3 specularColor = specularIntensity * baseColor;
float specularPower = specularPowerUniform;
// 使用法向量 Z 分量作为光照强度
float df = max(0.000001, normalVC.z);
float sf = pow(df, specularPower);
vec3 diffuse = df * diffuseColor * lightColor0;
vec3 specular = sf * specularColor * lightColor0;
return vec4(ambientColor + diffuse + specular, 1.0);
}
vec3 normalVC = normalize(frontNode.normalVC.xyz);
vec3 clr = getCutColor(startPos, true);
fragOutput0 = computeSimplifiedLighting(normalVC, clr);
gl_FragDepth = frontNode.depth;

