《游戏引擎实现原理》16.2 SDF 文本与矢量 UI

文字和矢量图形由曲线定义,位图放大会糊、图集也装不下任意字号。本节用有向距离场(SDF)把形状变成一段距离函数:距离直接转成抗锯齿覆盖率,一个全屏三角形就能画出边缘半透明的圆角矩形,任意放大都不糊。CPU 参考与 WGSL 逐字对齐,跑出中心全白、角外全黑、边界 128 的真实像素。

16.2 SDF 文本与矢量 UI

16.1 的精灵是位图:一张固定分辨率的图,放大就糊。但界面里还有两类东西不能当位图存——文字和矢量图形。一段 12 号字放大到 48 号,位图会糊成一团;一个圆角按钮要适配任意尺寸,也不可能为每个尺寸烘一张图。它们的形状本质是曲线,该用有向距离场(Signed Distance Field, SDF)描述。

SDF 不存「这里是不是实心」,而存「这个像素离最近边界多远」:形状内部为负、外部为正、边界为零。有了距离,抗锯齿就是免费送的——把距离映射成覆盖率即可,边缘自然软化一个像素。本节先在 CPU 上把形状写成距离函数并算覆盖率,再原样搬进 WGSL,跑出真实像素。

本节产出一个 L1 的 src/render/ui2d/sdf-shape.ts(Node 直跑)与一个 L2 的 src/render/ui2d/sdf-ui-draw.ts + shaders/sdf-ui-quad.wgsl(headless Chromium 实跑)。文中所有覆盖率与像素都来自本机实跑。

距离场:把形状变成一段距离函数

一个形状就是一个函数 d(p),返回点 p 到边界的最短距离。圆角矩形、圆、线段各有闭式解,再用 min 取并集、max(-b) 取差集,就能拼出任意复杂图形——图标、描边、甚至文字的笔画。文字走的是同一条路:离线把字形栅格化成 SDF(或 MSDF,多通道距离场),按 16.1 的图集打进一张纹理;渲染时采样出距离,再用同一个覆盖率公式阈值化。区别只是「距离从哪来」:矢量 UI 现场算,文字从图集采样。

关键只有一行:coverage = clamp(0.5 - d, 0, 1)。距离以像素为单位,边界像素拿 0.5,向内一个像素就饱和到 1。下面是完整的 src/render/ui2d/sdf-shape.ts。

// file: src/render/ui2d/sdf-shape.ts
// tier: L1
// verify: node src/render/ui2d/sdf-shape.ts
//
// helio 渲染卷 16.2:SDF 文本与矢量 UI 的 CPU 参考。
// 圆角矩形 / 圆 / 线段的最短距离,用 min/max 组合成并集、差集;
// 距离场直接转成抗锯齿覆盖率 coverage = clamp(0.5 - d, 0, 1)。
// 同一套公式稍后原样搬进 WGSL,CPU 算出的覆盖率就是 GPU 的期望值。

export type Vec2 = readonly [number, number];

/** 圆角矩形有向距离:p 相对中心的偏移,half 是半宽半高,r 是圆角半径。 */
export function sdRoundRect(p: Vec2, half: Vec2, r: number): number {
  const qx = Math.abs(p[0]) - half[0] + r;
  const qy = Math.abs(p[1]) - half[1] + r;
  const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
  return outside + Math.min(Math.max(qx, qy), 0) - r;
}

/** 圆的有向距离:|p| - r。 */
export function sdCircle(p: Vec2, r: number): number {
  return Math.hypot(p[0], p[1]) - r;
}

/** 线段的有向距离:点到线段 [a,b] 的最近距离。 */
export function sdSegment(p: Vec2, a: Vec2, b: Vec2): number {
  const pax = p[0] - a[0], pay = p[1] - a[1];
  const bax = b[0] - a[0], bay = b[1] - a[1];
  const h = Math.min(1, Math.max(0, (pax * bax + pay * bay) / (bax * bax + bay * bay)));
  return Math.hypot(pax - bax * h, pay - bay * h);
}

/** 并集:两形状取距离的 min。 */
export function union(a: number, b: number): number {
  return Math.min(a, b);
}

/** 差集:a 挖掉 b(对 b 取反再取 max)。 */
export function subtract(a: number, b: number): number {
  return Math.max(a, -b);
}

/**
 * 距离场转抗锯齿覆盖率:距离以像素为单位。
 * d = 0 恰在边界 → 0.5;d = -0.5 完全在内 → 1;d = +0.5 完全在外 → 0。
 */
export function coverage(d: number): number {
  return Math.min(1, Math.max(0, 0.5 - d));
}

if ((import.meta as { main?: boolean }).main) {
  // 一个 8x8 像素的圆角矩形,圆角半径 2,扫描一条水平线上的覆盖率
  const half: Vec2 = [4, 4];
  const r = 2;
  let row = '';
  let covered = 0;
  for (let x = 0; x < 16; x++) {
    const cov = coverage(sdRoundRect([x - 8, 0], half, r));
    row += cov >= 0.999 ? '#' : cov <= 0.001 ? '.' : '+';
    if (cov >= 0.5) covered++;
  }
  console.log(`rounded-rect half=8x8 r=${r}  水平中线 x=0..15`);
  console.log(row + `  (${covered}/16 过半覆盖)`);

  // 十字形:竖条 ∪ 横条,用差集挖一个中心孔
  const vbar = (p: Vec2): number => sdRoundRect(p, [2, 8], 1);
  const hbar = (p: Vec2): number => sdRoundRect(p, [8, 2], 1);
  const hole = (p: Vec2): number => sdCircle(p, 2);
  const cross = (p: Vec2): number => subtract(union(vbar(p), hbar(p)), hole(p));
  console.log(`cross@(0,5) ${cross([0, 5]).toFixed(3)}  <0 在十字臂内`);
  console.log(`cross@(0,0) ${cross([0, 0]).toFixed(3)}  >0 中心孔已被挖空`);

  // 覆盖率剖面:沿竖条中线在整数像素中心采样,看边界那 1 像素的过渡
  const edge = [];
  for (let x = -4; x <= 4; x++) {
    edge.push(coverage(sdRoundRect([x, 0], [2, 8], 0)).toFixed(2));
  }
  console.log('coverage 剖面 x=-4..4: ' + edge.join(' '));
}

在 /tmp/enginebook 里 node src/render/ui2d/sdf-shape.ts(本机实测,数值不可移植):

rounded-rect half=8x8 r=2  水平中线 x=0..15
....+#######+...  (9/16 过半覆盖)
cross@(0,5) -2.000  <0 在十字臂内
cross@(0,0) 2.000  >0 中心孔已被挖空
coverage 剖面 x=-4..4: 0.00 0.00 0.50 1.00 1.00 1.00 0.50 0.00 0.00

第一行的 ASCII 剖面里,# 是实心、. 是空、+ 是半覆盖——圆角矩形水平中线上只有左右各一个 +,正是被圆角软化掉的那两个边界像素。最后一行更直白:覆盖率剖面是 0 → 0.5 → 1 → 1 → 1 → 0.5 → 0,边界恰好 0.50,两侧各一个像素内就饱和。十字形那两行演示了布尔组合:cross@(0,5) 在竖臂内为负,cross@(0,0) 因中心被挖了半径为 2 的孔而为正。

可运行产物:一个三角形画出抗锯齿圆角矩形

把 sdRoundRect 原样搬进片元着色器,形状就完全由距离函数定义——没有顶点级的边界几何,只有一个盖住全屏的三角形。下面是 shaders/sdf-ui-quad.wgsl。

// file: shaders/sdf-ui-quad.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/sdf-ui-draw.ts
//
// 16.2 的矢量 UI:一个全屏三角形,片元里按像素坐标求圆角矩形的有向距离,
// 距离直接转抗锯齿覆盖率。没有顶点级的边界几何——形状完全由 SDF 定义,
// 放大缩小都不糊,这就是 SDF 矢量 UI 与位图 UI 的根本区别。

struct Params {
  center: vec2<f32>, // @0  形状中心(framebuffer 像素坐标)
  half: vec2<f32>,   // @8  半宽半高(像素)
  radius: f32,       // @16 圆角半径(像素)
  pad: f32,          // @20 对齐填充
};

@group(0) @binding(0) var<uniform> params: Params;

/** 圆角矩形有向距离:与 CPU 版逐字一致。 */
fn sd_round_rect(p: vec2<f32>, half: vec2<f32>, r: f32) -> f32 {
  let q = abs(p) - half + vec2<f32>(r, r);
  let outside = length(max(q, vec2<f32>(0.0, 0.0)));
  return outside + min(max(q.x, q.y), 0.0) - r;
}

struct VSOut {
  @builtin(position) pos: vec4<f32>,
};

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
  var p = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0),
  );
  var o: VSOut;
  o.pos = vec4<f32>(p[i], 0.0, 1.0);
  return o;
}

@fragment
fn fs(@builtin(position) fc: vec4<f32>) -> @location(0) vec4<f32> {
  // fc.xy 是 framebuffer 坐标,像素中心落在 x+0.5 处
  let d = sd_round_rect(fc.xy - params.center, params.half, params.radius);
  let cov = clamp(0.5 - d, 0.0, 1.0); // 边界像素拿 0.5,内 1、外 0
  return vec4<f32>(cov, cov, cov, 1.0);
}

src/render/ui2d/sdf-ui-draw.ts 在 16×16 目标上画一个圆角矩形,回读中心、角外与边界三处像素。

// file: src/render/ui2d/sdf-ui-draw.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/sdf-ui-draw.ts
//
// helio 渲染卷 16.2:SDF 矢量 UI 的 L2 验收。
// 在 16x16 目标上画一个圆角矩形(中心 8.5,8.5,半宽高 4,圆角 2),片元用
// SDF 求覆盖率:中心像素全白、角外全黑、边界像素半透明(抗锯齿)。回读核对。
// 需 headless Chromium + 本地 http 服务(navigator.gpu 只在安全上下文可用)。

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };

  const device = await (await navigator.gpu.requestAdapter())!.requestDevice();
  const wgsl = await (await fetch('/shaders/sdf-ui-quad.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errs = info.messages.filter((m) => m.type === 'error')
    .map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  push('wgsl 编译 0 error', errs.length === 0, errs.join('; ') || 'no diagnostics');

  // Params:center(8.5,8.5) half(4,4) radius 2 pad 0(8 个 float = 32 字节)
  const params = new Float32Array([8.5, 8.5, 4.0, 4.0, 2.0, 0.0, 0.0, 0.0]);
  const paramBuf = device.createBuffer({ size: params.byteLength,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(paramBuf, 0, params);

  const pipeline = device.createRenderPipeline({ layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
    primitive: { topology: 'triangle-list' } });
  const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: paramBuf } }] });

  const width = 16, height = 16, bytesPerRow = 256;
  const target = device.createTexture({ size: [width, height], format: 'rgba8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const rb = device.createBuffer({ size: bytesPerRow * height,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });

  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(),
    clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bind);
  pass.draw(3);
  pass.end();
  enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow }, [width, height]);
  device.queue.submit([enc.finish()]);

  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap();
  device.destroy();

  const gray = (x: number, y: number): number => px[y * bytesPerRow + x * 4]!;
  const center = gray(8, 8), corner = gray(0, 0), edge = gray(4, 8);
  data.gray = { center, corner, edge };
  push('中心像素全白', center === 255, `center=${center}`);
  push('角外像素全黑', corner === 0, `corner=${corner}`);
  push('边界像素抗锯齿', edge > 100 && edge < 156, `edge=${edge} (期望≈128)`);
  push('一次绘制全屏三角形', true, 'draw(3)');
  return { checks, data };
}

本机 node tools/run-l2.mjs src/render/ui2d/sdf-ui-draw.ts(headless Chromium + SwiftShader):

PASS  wgsl 编译 0 error  no diagnostics
PASS  中心像素全白  center=255
PASS  角外像素全黑  corner=0
PASS  边界像素抗锯齿  edge=128 (期望≈128)
PASS  一次绘制全屏三角形  draw(3)

三处像素验证了 SDF 的三种状态:中心 255(形状内,覆盖率 1)、角外 0(形状外,覆盖率 0)、左边界 128(边界像素,覆盖率 0.5——GPU 把 0.5 四舍五入成 128/255)。整幅图只发了一次 draw(3),却画出了带抗锯齿的圆角矩形。把它换成任何尺寸的 half 与 radius,边缘质量都不变,这就是矢量 UI 相对位图 UI 的根本优势。

小结

这一节把「形状」变成了「距离函数」。圆角矩形、圆、线段各有闭式 SDF,用 min/max 拼出并集与差集;距离一行就转成抗锯齿覆盖率 clamp(0.5 - d, 0, 1)。文字走同一条路,只是距离来自离线烘好的字形 SDF 图集(复用 16.1 的图集与批次)。L2 实测一个 draw(3) 画出中心全白、角外全黑、边界 128 的圆角矩形,任意放大不糊。

到这里,界面上的精灵(16.1)与矢量图形(16.2)都能画了。但它们和 3D 世界还在各自的坐标系里——谁先画、谁压在谁上面、半透明怎么混,还没有规矩。下一节把这三种绘制收进一个 render pass,定下 UI 与 3D 的合成顺序。

阅读导航:上一节:16.1 精灵批次与图集 · 下一节:16.3 UI 与 3D 的合成顺序 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径