在 3D 场景里,用 SVG 或 HTML 叠加描边、标签和提示,是一种很常见的实现方式。它们比在 Three.js 里创建真实几何体更容易控制,也更适合做细线、文字和交互反馈。
但 2D 覆盖层有一个基础限制:它没有 3D 深度信息。 只要画在这一层里的内容,默认都会出现在场景最上方,不会自动被前面的模型遮挡。
我之前在一个立方体工具 demo 里遇到过这个问题。未选中的立方体用 SVG 画一圈细描边,用来和背景区分。物体少的时候没有明显问题,但多个立方体密集摆放后,后方立方体的轮廓会直接穿透到前方立方体表面上。
这并不是描边坐标算错了,而是实现层级不对:SVG 只知道每条线在屏幕上的位置,并不知道它属于远处还是近处的 3D 对象。
根因诊断和后续实现方案主要来自 Claude Code,我负责在密集场景中验证修改后的遮挡结果是否符合预期。
最终没有继续给 SVG 增加遮挡判断,而是重新分配了不同描边的实现层。
未选中物体的普通描边改成 3D 线框。 使用立方体自身的 EdgesGeometry 生成边线。它是真实存在于 3D 场景里的对象,会参与深度测试,因此前后遮挡关系可以直接交给渲染器处理。
选中和 Hover 高亮继续保留在 SVG 覆盖层。 这两类反馈的语义不同:即使目标的一部分被别的模型挡住,用户仍然需要明确知道"当前选中的是谁"或"鼠标正指向谁"。它们本来就应该浮在场景上方,因此不参与深度测试反而更合理。
最后使用的判断标准很简单:如果一个视觉效果应该遵守真实空间遮挡,就放进 3D 场景;如果它需要始终可见,再放到 2D 覆盖层。
理论上也可以继续使用 SVG,然后自己计算每条边是否被别的对象遮挡,再决定哪些线段显示。
但这会把一个原本由深度缓冲天然解决的问题,重新变成一套额外的几何判断逻辑。随着物体数量、相机角度和模型复杂度增加,这套逻辑也会越来越重。
对于普通物体轮廓来说,没有必要复制一遍渲染器已经具备的能力。把视觉效果放回正确的 3D 层级后,遮挡关系可以直接由深度测试得到。
这次调整的重点不是"SVG 做不到",而是没有必要让 2D 层负责它本来不擅长的空间关系。
从 SVG 换到 WebGL 线框后,也失去了一部分原来的视觉控制能力。
SVG 可以精确指定 0.5px 一类的亚像素线宽;WebGL 的基础线条在很多平台上无法稳定控制这么细的宽度,通常会被限制在接近 1px。
这里最终接受了线宽上的差异,因为两个问题的影响程度不同:
为了让稍粗的 3D 边线仍然保持足够区分度,又重新调整了普通状态和 Hover 状态的不透明度。这样即使失去了原来的精细线宽控制,相邻立方体之间仍然有清楚的边界。
这个问题在少量物体时很难被发现,因为即使后方轮廓始终显示,只要它没有投影到前方物体表面上,视觉上就不会明显出错。
真正暴露问题的是多个物体在屏幕投影中互相重叠。
因此,涉及 3D 对象描边、标签和覆盖层时,除了检查单个对象状态,还需要主动测试:
这类问题不是单个组件画得对不对,而是它进入完整空间关系以后是否仍然成立。
这次具体根因和 EdgesGeometry 的实现方案主要来自 Claude Code,我负责确认哪一类描边应该遵守真实遮挡,以及修改后的视觉结果是否满足设计目标。
对我来说更有用的判断不是记住"SVG 不适合 3D 描边",而是先问一句:这个视觉效果在语义上应该被挡住吗?
同样是描边,普通状态和选中状态的答案并不一样。答案确定以后,实现层其实就很自然了。