在 3D 场景里,UI 控件经常需要跟随模型移动。例如选中一个立方体后,在旁边显示删除按钮、尺寸标签或其他操作入口。
一种直觉做法,是先拿到模型的世界坐标,再加一个固定偏移。但这种方法有一个明显风险:3D 偏移投影到屏幕后的长度,会随相机角度变化。
我之前做的一个 demo 里,删除按钮的锚点是"立方体中心 + 竖直方向偏移"。真机测试时发现,俯视模式下按钮会跑到立方体正中央,正好压住 Gizmo 的中心手柄。
最初看起来像一个"俯视模式的特殊 bug",但 Claude Code 的诊断是:问题来自投影退化。俯视时,相机几乎正对竖直轴,原本那段 3D 偏移在屏幕上的投影会趋近于 0,所以按钮逐渐塌回中心。
这也意味着问题并不只发生在俯视。相机接近垂直角度时,按钮就会开始向中心靠拢;俯视只是最极端的情况。
因此,不能简单写成"如果是俯视,就换个位置"。真正需要改的是定位规则本身:不要再依赖某个世界空间方向,而是直接根据当前屏幕上的实际几何关系定位。
最终方案改用屏幕空间坐标,并且不再从模型中心起算,而是从当前画面里真正最靠下的元素起算。
第 1 步 贴合位置 = max(A, B, C) + 35px
A = 立方体投影包围盒的下沿
B = 朝下的可见手柄箭头末端 + 16px
C = 最靠下的边长标签锚点 + 16px
第 2 步 按钮中心 Y = clamp(贴合位置, 28px, 视口高度 - 28px)
第 3 步 按钮中心 X = clamp(立方体投影包围盒的水平中心, 48px, 视口宽度 - 48px)这里有两个关键点。
第一,从"当前最下沿"起算,而不是只看模型本身。 随着模型旋转和相机变化,最靠下的可能是立方体、Gizmo 箭头,也可能是尺寸标签。只跟随其中一个对象,都会在某些角度下失效。
第二,用投影包围盒,而不是某一条具体边。 立方体旋转时,真正位于最下方的边会不断切换。如果直接把某条边当锚点,按钮位置会跳;投影包围盒的下沿变化更连续。
clamp 解决了另一个原本看起来需要单独处理的问题:模型太大、已经超出视口时,按钮应该放在哪里。
如果模型下沿已经跑出屏幕,算出来的 Y 坐标自然会超过视口高度。经过 clamp 后,按钮会被限制在屏幕底部的安全范围内,而不是继续跟着模型跑出视口。
同一条规则可以覆盖正常尺寸的模型、靠近屏幕边缘的模型、以及放大后部分超出屏幕的模型,这里没有额外增加"如果模型过大"的特殊分支。
规则定下来后,开发提出过一个更简单的实现:既然 Gizmo 的屏幕尺寸基本固定,删除按钮能不能直接放在"中心往下固定像素"的位置,这样就不用计算包围盒。
这个方案逻辑上很顺,也能省掉不少计算。但真正跑起来后,相机拉远、立方体变小时,按钮会离模型越来越远。
问题在于它把两种性质不同的对象放进了同一个假设里:Gizmo 更接近屏幕空间 UI,视觉尺寸相对稳定;立方体是真实 3D 物体,会随着相机距离变化在屏幕上变大或变小。固定像素偏移对前者成立,对后者并不成立。
而且这个固定值还必须按"最坏情况"设计:例如考虑某根箭头正好朝下。这样一来,在没有箭头朝下的大多数场景里,间距又会显得过大。模型放得很大时,同样的问题会反过来出现:按钮不再稳定地位于模型外侧,而可能落回模型表面。
因此最后还是回到原来的规则:以当前实际屏幕几何为准,而不是为某个相机距离或某一种姿态设固定像素。
1. 公式定位的是元素中心。 按钮使用 CSS translate(-50%, -50%),所以公式算的是按钮中心坐标,不是上沿位置。如果按钮高度是 20px,而希望肉眼看到的间距是 25px,那么中心点需要留出 25px + 按钮高度 / 2 = 35px。按钮高度变化时,这个数值也需要一起调整。
2. 安全边距同样夹的是中心点。 28px 和 48px 不是肉眼最终看到的空白,而是按钮中心到视口边缘的最小距离。真实视觉留白还需要减去按钮的半宽或半高。
这次根因诊断和公式分析主要来自 Claude Code,我负责确认现象、测试修改后的结果,以及判断后续简化方案是否真的成立。
对我来说更有价值的一步,是开发提出"中心下方固定像素"之后,没有直接凭直觉接受或拒绝,而是把它放回真实场景里测试:拉远相机、放大模型,再看规则是否还能成立。
这类简化方案最容易通过讨论,因为它在常见状态下通常确实没问题。真正需要验证的是它依赖的前提,在视角、距离和模型尺寸变化后是否还成立。