角色选中描边为何覆盖整张脸:双 Pass 外扩、像素宽度与绘制顺序
敌人被选中后,预期结果是身体外沿出现一圈橙色提示;实际画面却可能变成一整块橙色剪影。外扩公式仍然执行了,颜色也没有写错,问题发生在两个 Draw 的先后关系:原表面先画,随后扩大的壳层又把它完整盖住。描边不是片元颜色上的一个开关,而是两份几何结果共同组成的提交。
前一篇顶点风留下了一个直接约束:当前几何是位移后的顶点,任何后续 Pass 都不能退回静止网格。描边沿用这份位置输入,对同一份当前顶点执行两次绘制。宿主持有选中状态、宽度、Pass 顺序和深度策略;顶点 Shader 只在外扩 Pass 中移动顶点,片元 Shader 只输出本次 Draw 的颜色。

外扩量必须先回答“以什么单位稳定”
对象空间沿法线外扩最容易实现,但相机拉远时,对象和外扩量会一起缩小。它适合世界中确实有厚度的壳,不适合要求 UI 式稳定可读的选中提示。本次探针把宽度定义为当前视口像素,在裁剪空间按每轴尺寸换算:一个像素对应 2 / viewportPixels 的 NDC 距离。
#version 300 es
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aNormal;
uniform float uScale;
uniform float uOutlinePixels;
uniform vec2 uViewportPixels;
uniform bool uOutlinePass;
void main() {
vec2 position = aPosition * uScale;
if (uOutlinePass) {
vec2 offset = aNormal * uOutlinePixels * 2.0
/ uViewportPixels;
position += offset;
}
gl_Position = vec4(position, 0.0, 1.0);
}
这里的 aNormal 是探针二维轮廓的单位外法线。真实三维模型若直接把对象空间法线加到裁剪坐标,会混合不同空间;至少要先把当前顶点与法线变换到一致空间,再处理投影。屏幕像素外扩还要考虑透视除法:常见做法是在裁剪空间按 clip.w 补偿,使 NDC 中的偏移在远近变化后仍对应目标像素。当前二维探针的 w 固定为 1,因此只验证视口换算与投影尺度变化,不把它包装成完整三维实现。
视口尺寸也不能被编进材质常量。动态分辨率、分屏相机或离屏目标会让同一个对象在不同 Render Target 中获得不同的像素换算;宿主必须从当前 Pass 的实际目标尺寸提交 uViewportPixels。若误用窗口 CSS 尺寸,高 DPI 设备上的 drawing buffer 可能是它的两倍,期望 8 像素的线就会变成 16 个物理像素。这里固定单格视口为 300×640,让换算输入与 gl.viewport 完全一致。
近处缩放 0.78、远处缩放 0.46 时,固定 8 像素路径的描边占可见像素比例从 0.1557 变为 0.2429。比例增大并不表示线变粗,而是同样像素宽度围住了更小的投影面积。错误的对象空间路径比例为 0.1257 与 0.1248,几乎不变,说明描边随对象一起缩小;远处实际像素线宽已经变薄。面积比例不是通用线宽计量,只是同一轮廓、同一分辨率下区分两种单位合同的回归信号。
两个 Draw 必须作为一个有序效果提交
宿主在参数入口把宽度限制到 [0,18] 像素,非有限值退化为零。然后先画橙色外扩壳,再用原位置画蓝色表面。这个顺序让第二个 Draw 覆盖壳层内部,只留下轮廓外缘:
export function normalizeOutline(widthPixels) {
return Number.isFinite(widthPixels)
? Math.min(Math.max(widthPixels, 0), 18)
: 0;
}
function drawSelection(item) {
setOutlineUniforms(item, true);
drawMesh();
setOutlineUniforms(item, false);
drawMesh();
}
固定 10 像素样本中,正确顺序保留 20,740 个蓝色表面像素,并产生 5,778 个橙色描边像素。把顺序反转后,可见像素总数仍为 26,518,但表面像素变成 0:外扩壳最后写入,整张脸都被它覆盖。这个失败样本也解释了为什么“两个 Pass 都成功执行”不足以证明效果正确;顺序本身就是状态的一部分。

若接入三维闭合网格,常用实现会绘制外扩后的背面并剔除正面,让壳只在轮廓外侧出现,再绘制正常表面。尖角处共享法线可能产生裂口或厚度突变,硬边模型还可能沿拆分法线外扩出不连续轮廓;这些是网格拓扑与法线数据问题,不能靠增大宽度掩盖。本次探针用连续二维轮廓隔离顺序和单位,不声称已经解决尖角。
前篇的顶点风还带来一个容易漏掉的跨 Pass 条件:如果表面 Pass 使用位移后的草叶,而描边 Pass 仍从静态顶点缓冲计算外扩,两份轮廓会在风动时分离。可行方案是让两个 Pass 调用同一段顶点位移函数,或把已经变形的位置作为共享输入;只复制一份近似公式会在参数、时间或权重更新后产生漂移。描边新增的是外扩步骤,不是另一套几何真相。
遮挡策略也必须由宿主显式选择。若描边只应在目标可见处出现,外扩 Pass 继续使用场景深度测试;若设计要求被墙遮挡时仍显示提示,就需要单独的遮挡颜色、模板或深度比较策略。简单关闭深度测试会让整圈轮廓穿墙,也可能暴露本应被自身遮挡的内部边缘。第七篇透明护盾会进一步把深度写入、混合因子和绘制排序加入同一份渲染状态合同。
这也意味着选中状态不能只改变一个共享材质布尔值。渲染队列需要把“对象是否选中、描边宽度、可见遮挡策略、当前几何版本”组装成同一次效果提交;对象取消选中时,两次 Draw 应一起消失,不能留下只有壳层或只有表面的半状态。批处理系统可以按这些状态排序,但不能把顺序重排为所有表面先画、所有壳层后画,除非另有深度或模板机制保证壳层不会覆盖表面。

像素回读同时检查颜色归属和退化边界
实验入口为 .tmp/game-shader-webgl-lab/chapters/06-selection-outline/app.js。真实 Chrome 编译并链接 GLSL ES 3.00,对 192 个顶点的轮廓执行每样本两次 Draw,再用 gl.readPixels 分别统计橙色描边与蓝色表面:
$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-10-2000/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=12.091s
screen_space_outline_ratio=0.1557,0.2429
object_space_outline_ratio=0.1257,0.1248
correct_surface_pixels=20740
reversed_surface_pixels=0
clamped_outline_pixels=10842
输入 30 像素时,宿主提交 18 像素,回读得到 10,842 个描边像素;输入无穷值时提交零,描边像素为零但表面仍完整。探针共有 8 个样本、16 次 Draw,没有纹理采样,也没有 GPU 时间数据,因此这里只能确认行为和 Pass 数,不能推导性能收益。真实项目还应分别统计选中对象数、额外 Draw 数与被描边覆盖的屏幕面积,再决定双 Pass 是否需要批处理或替换为后处理边缘检测。
这个切片解决了选中描边最容易混淆的三件事:外扩宽度的单位、两次绘制的提交顺序,以及非法输入的稳定退化。它适用于轮廓可由当前顶点和法线生成、选中对象数量受控的场景;蒙皮法线、硬边裂缝、MSAA、透明表面和穿墙提示仍需在各自渲染状态下验证。下一篇透明护盾会复用宿主拥有 Pass 状态的边界,但不再依靠第二个不透明表面覆盖内部,而要同时约束颜色表示、深度写入与排序。