From f0861af515708d3aa2c609fac9d2b5803717f130 Mon Sep 17 00:00:00 2001 From: xiaoiver Date: Sun, 12 Jan 2025 21:29:43 +0800 Subject: [PATCH] feat: use esdt to enhance sdf-based text rendering --- README.md | 2 +- README.zh_CN.md | 2 +- packages/core/src/drawcalls/SDFText.ts | 5 +- packages/core/src/shaders/sdf_text.ts | 10 +- packages/core/src/shapes/Text.ts | 8 + .../core/src/utils/glyph/glyph-manager.ts | 5 +- packages/core/src/utils/glyph/sdf-edt.ts | 77 +++ packages/core/src/utils/glyph/sdf-esdt.ts | 639 ++++++++++++++++++ packages/core/src/utils/glyph/tiny-sdf.ts | 235 ++++--- packages/site/docs/.vitepress/config/en.js | 4 + packages/site/docs/.vitepress/config/zh.js | 4 + packages/site/docs/components/Emoji.vue | 47 ++ packages/site/docs/components/MSDFText.vue | 8 +- packages/site/docs/example/emoji.md | 11 + packages/site/docs/example/msdf-text.md | 2 +- packages/site/docs/guide/lesson-015.md | 10 +- packages/site/docs/zh/example/emoji.md | 11 + packages/site/docs/zh/example/msdf-text.md | 2 +- packages/site/docs/zh/guide/lesson-015.md | 10 +- packages/site/docs/zh/guide/lesson-016.md | 12 + 20 files changed, 964 insertions(+), 140 deletions(-) create mode 100644 packages/core/src/utils/glyph/sdf-edt.ts create mode 100644 packages/core/src/utils/glyph/sdf-esdt.ts create mode 100644 packages/site/docs/components/Emoji.vue create mode 100644 packages/site/docs/example/emoji.md create mode 100644 packages/site/docs/zh/example/emoji.md diff --git a/README.md b/README.md index 43fb209..1e2ed6a 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ pnpm run dev ## Lesson 15 - Draw text [🔗](https://infinitecanvas.cc/guide/lesson-015) - What's TextMetrics and how to get it in server and browser side -- What's shaping +- What's shaping? Implement letterSpacing and kerning - Paragraph layout, wordbreak, BiDi and cluster - How to generate SDF atlas and use it to draw - How to use MSDF to improve text rendering quality diff --git a/README.zh_CN.md b/README.zh_CN.md index fe20146..1902ed5 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -174,7 +174,7 @@ pnpm run dev ## 课程 15 - 绘制文本 [🔗](https://infinitecanvas.cc/zh/guide/lesson-015) - 什么是 TextMetrics,如何在服务端和浏览器端获取 -- 什么是 Shaping +- 什么是 Shaping?实现 letterSpacing 与 kerning - 处理段落。分段与自动换行、BiDi 和 cluster - 如何生成 SDF atlas 并使用它绘制 - 如何使用 MSDF 提升文本渲染质量 diff --git a/packages/core/src/drawcalls/SDFText.ts b/packages/core/src/drawcalls/SDFText.ts index 8b1d300..b015e0a 100644 --- a/packages/core/src/drawcalls/SDFText.ts +++ b/packages/core/src/drawcalls/SDFText.ts @@ -62,8 +62,8 @@ export class SDFText extends Drawcall { } createGeometry(): void { - const { metrics, fontFamily, fontWeight, fontStyle, bitmapFont } = this - .shapes[0] as Text; + const { metrics, fontFamily, fontWeight, fontStyle, bitmapFont, esdt } = + this.shapes[0] as Text; const indices: number[] = []; const positions: number[] = []; @@ -85,6 +85,7 @@ export class SDFText extends Drawcall { fontStyle, allText, this.device, + esdt, ); } diff --git a/packages/core/src/shaders/sdf_text.ts b/packages/core/src/shaders/sdf_text.ts index a8308e6..b724ca6 100644 --- a/packages/core/src/shaders/sdf_text.ts +++ b/packages/core/src/shaders/sdf_text.ts @@ -108,23 +108,21 @@ void main() { outputColor = texture(SAMPLER_2D(u_Texture), v_Uv); #else float dist; + lowp float buff; #ifdef USE_SDF + // fillColor = texture(SAMPLER_2D(u_Texture), v_Uv); dist = texture(SAMPLER_2D(u_Texture), v_Uv).a; + buff = (256.0 - 64.0) / 256.0; #endif #ifdef USE_MSDF vec3 s = texture(SAMPLER_2D(u_Texture), v_Uv).rgb; dist = median(s.r, s.g, s.b); + buff = 0.5; #endif float fontSize = u_ZIndexStrokeWidth.z; - lowp float buff = (256.0 - 64.0) / 256.0; - - #ifdef USE_MSDF - buff = 0.5; - #endif - // float opacity = u_FillOpacity; // if (u_HasStroke > 0.5 && u_StrokeWidth > 0.0) { // color = u_StrokeColor; diff --git a/packages/core/src/shapes/Text.ts b/packages/core/src/shapes/Text.ts index ff0e78a..d2b9d5a 100644 --- a/packages/core/src/shapes/Text.ts +++ b/packages/core/src/shapes/Text.ts @@ -128,6 +128,11 @@ export interface TextAttributes extends ShapeAttributes { * Whether to use kerning in bitmap font. Default is `true`. */ bitmapFontKerning: boolean; + + /** + * Whether to use esdt SDF generation. Default is `true`. + */ + esdt: boolean; } // @ts-ignore @@ -157,6 +162,7 @@ export function TextWrapper(Base: TBase) { #textBaseline: CanvasTextBaseline; bitmapFont: BitmapFont; bitmapFontKerning: boolean; + esdt: boolean; static getGeometryBounds( attributes: Partial & { metrics: TextMetrics }, ) { @@ -214,6 +220,7 @@ export function TextWrapper(Base: TBase) { leading, bitmapFont, bitmapFontKerning, + esdt, } = attributes; this.#x = x ?? 0; @@ -236,6 +243,7 @@ export function TextWrapper(Base: TBase) { this.leading = leading ?? 0; this.bitmapFont = bitmapFont ?? null; this.bitmapFontKerning = bitmapFontKerning ?? true; + this.esdt = esdt ?? true; } containsPoint(x: number, y: number) { diff --git a/packages/core/src/utils/glyph/glyph-manager.ts b/packages/core/src/utils/glyph/glyph-manager.ts index 017f63a..48bb195 100644 --- a/packages/core/src/utils/glyph/glyph-manager.ts +++ b/packages/core/src/utils/glyph/glyph-manager.ts @@ -159,6 +159,7 @@ export class GlyphManager { fontStyle = '', text: string, device: Device, + esdt: boolean, ) { let newChars: string[] = []; if (!this.glyphMap[fontStack]) { @@ -183,6 +184,7 @@ export class GlyphManager { fontWeight, fontStyle, char, + esdt, ); }) .reduce((prev, cur) => { @@ -228,6 +230,7 @@ export class GlyphManager { fontWeight: string, fontStyle: string, char: string, + esdt: boolean, ): StyleGlyph { let sdfGenerator = this.sdfGeneratorCache[fontStack]; if (!sdfGenerator) { @@ -263,7 +266,7 @@ export class GlyphManager { glyphLeft, glyphTop, glyphAdvance, - } = sdfGenerator.draw(char); + } = sdfGenerator.draw(char, esdt); return { id: char, diff --git a/packages/core/src/utils/glyph/sdf-edt.ts b/packages/core/src/utils/glyph/sdf-edt.ts new file mode 100644 index 0000000..c237d48 --- /dev/null +++ b/packages/core/src/utils/glyph/sdf-edt.ts @@ -0,0 +1,77 @@ +import { edt, getSDFStage, glyphToRGBA, INF, SDFStage } from './tiny-sdf'; + +// Paint glyph data into stage +export const paintIntoStage = ( + stage: SDFStage, + data: Uint8ClampedArray, + w: number, + h: number, + pad: number, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + const np = wp * hp; + + const { outer, inner } = stage; + + outer.fill(INF, 0, np); + inner.fill(0, 0, np); + + const getData = (x: number, y: number) => + (data[4 * (y * w + x) + 3] ?? 0) / 255; + // const getData = (x: number, y: number) => (data[y * w + x] ?? 0) / 255; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const a = getData(x, y); + const i = (y + pad) * wp + x + pad; + + if (a >= 254 / 255) { + // Fix for bad rasterizer rounding + data[4 * (y * w + x) + 3] = 255; + + outer[i] = 0; + inner[i] = INF; + } else if (a > 0) { + const d = 0.5 - a; + outer[i] = d > 0 ? d * d : 0; + inner[i] = d < 0 ? d * d : 0; + } + } + } +}; + +// Convert grayscale glyph to SDF using pixel-based distance transform +export const glyphToEDT = ( + data: Uint8ClampedArray, + w: number, + h: number, + pad: number = 4, + radius: number = 3, + cutoff: number = 0.25, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + const np = wp * hp; + const sp = Math.max(wp, hp); + + const out = new Uint8Array(np); + + const stage = getSDFStage(sp); + paintIntoStage(stage, data, w, h, pad); + + const { outer, inner, f, z, v } = stage; + + edt(outer, 0, 0, wp, hp, wp, f, z, v); + edt(inner, pad, pad, w, h, wp, f, z, v); + + for (let i = 0; i < np; i++) { + const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]); + out[i] = Math.max( + 0, + Math.min(255, Math.round(255 - 255 * (d / radius + cutoff))), + ); + } + + return glyphToRGBA(out, wp, hp); +}; diff --git a/packages/core/src/utils/glyph/sdf-esdt.ts b/packages/core/src/utils/glyph/sdf-esdt.ts new file mode 100644 index 0000000..727f9ac --- /dev/null +++ b/packages/core/src/utils/glyph/sdf-esdt.ts @@ -0,0 +1,639 @@ +/** + * @see https://gitlab.com/unconed/use.gpu/-/blob/master/packages/glyph/src/sdf-esdt.ts + */ + +import { + getSDFStage, + glyphToRGBA, + INF, + isBlack, + isSolid, + isWhite, + SDFStage, + sqr, +} from './tiny-sdf'; + +// Paint alpha channel into SDF stage +export const paintIntoStage = ( + stage: SDFStage, + data: Uint8ClampedArray, + w: number, + h: number, + pad: number, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + const np = wp * hp; + + const { outer, inner } = stage; + + outer.fill(INF, 0, np); + inner.fill(0, 0, np); + + // const getData = (x: number, y: number) => data[y * w + x] ?? 0; + const getData = (x: number, y: number) => data[4 * (y * w + x) + 3] ?? 0; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const a = getData(x, y); + if (!a) continue; + + const i = (y + pad) * wp + x + pad; + if (a >= 254) { + // Fix for bad rasterizer rounding + data[4 * (y * w + x) + 3] = 255; + + outer[i] = 0; + inner[i] = INF; + } else { + outer[i] = 0; + inner[i] = 0; + } + } + } +}; + +// Paint original alpha channel into final SDF when gray +export const paintIntoDistanceField = ( + image: Uint8Array, + data: Uint8ClampedArray, + w: number, + h: number, + pad: number, + radius: number, + cutoff: number, +) => { + const wp = w + pad * 2; + + // const getData = (x: number, y: number) => (data[y * w + x] ?? 0) / 255; + const getData = (x: number, y: number) => + (data[4 * (y * w + x) + 3] ?? 0) / 255; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const a = getData(x, y); + if (!isSolid(a)) { + const j = x + pad + (y + pad) * wp; + const d = 0.5 - a; + image[j] = Math.max( + 0, + Math.min(255, Math.round(255 - 255 * (d / radius + cutoff))), + ); + } + } + } +}; + +// Generate subpixel offsets for all border pixels +export const paintSubpixelOffsets = ( + stage: SDFStage, + data: Uint8ClampedArray, + w: number, + h: number, + pad: number, + relax?: boolean, + half?: number | boolean, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + const np = wp * hp; + + const { outer, inner, xo, yo, xi, yi } = stage; + + xo.fill(0, 0, np); + yo.fill(0, 0, np); + xi.fill(0, 0, np); + yi.fill(0, 0, np); + + // const getData = (x: number, y: number) => + // x >= 0 && x < w && y >= 0 && y < h ? (data[y * w + x] ?? 0) / 255 : 0; + const getData = (x: number, y: number) => + x >= 0 && x < w && y >= 0 && y < h + ? (data[4 * (y * w + x) + 3] ?? 0) / 255 + : 0; + + // Make vector from pixel center to nearest boundary + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const c = getData(x, y); + const j = (y + pad) * wp + x + pad; + + if (!isSolid(c)) { + const dc = c - 0.5; + + const l = getData(x - 1, y); + const r = getData(x + 1, y); + const t = getData(x, y - 1); + const b = getData(x, y + 1); + + const tl = getData(x - 1, y - 1); + const tr = getData(x + 1, y - 1); + const bl = getData(x - 1, y + 1); + const br = getData(x + 1, y + 1); + + const ll = (tl + l * 2 + bl) / 4; + const rr = (tr + r * 2 + br) / 4; + const tt = (tl + t * 2 + tr) / 4; + const bb = (bl + b * 2 + br) / 4; + + const min = Math.min(l, r, t, b, tl, tr, bl, br); + const max = Math.max(l, r, t, b, tl, tr, bl, br); + + if (min > 0) { + // Interior creases + inner[j] = INF; + continue; + } + if (max < 1) { + // Exterior creases + outer[j] = INF; + continue; + } + + let dx = rr - ll; + let dy = bb - tt; + const dl = 1 / Math.sqrt(sqr(dx) + sqr(dy)); + dx *= dl; + dy *= dl; + + xo[j] = -dc * dx; + yo[j] = -dc * dy; + } else if (isWhite(c)) { + const l = getData(x - 1, y); + const r = getData(x + 1, y); + const t = getData(x, y - 1); + const b = getData(x, y + 1); + + if (isBlack(l)) { + xo[j - 1] = 0.4999; + outer[j - 1] = 0; + inner[j - 1] = 0; + } + if (isBlack(r)) { + xo[j + 1] = -0.4999; + outer[j + 1] = 0; + inner[j + 1] = 0; + } + + if (isBlack(t)) { + yo[j - wp] = 0.4999; + outer[j - wp] = 0; + inner[j - wp] = 0; + } + if (isBlack(b)) { + yo[j + wp] = -0.4999; + outer[j + wp] = 0; + inner[j + wp] = 0; + } + } + } + } + + // Blend neighboring offsets but preserve normal direction + // Uses xo as input, xi as output + // Improves quality slightly, but slows things down. + let xs = xo; + let ys = yo; + if (relax) { + const checkCross = ( + nx: number, + ny: number, + dc: number, + dl: number, + dr: number, + dxl: number, + dyl: number, + dxr: number, + dyr: number, + ) => { + return ( + (dxl * nx + dyl * ny) * (dc * dl) > 0 && + (dxr * nx + dyr * ny) * (dc * dr) > 0 && + (dxl * dxr + dyl * dyr) * (dl * dr) > 0 + ); + }; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const j = (y + pad) * wp + x + pad; + + const nx = xo[j]; + const ny = yo[j]; + if (!nx && !ny) continue; + + const c = getData(x, y); + const l = getData(x - 1, y); + const r = getData(x + 1, y); + const t = getData(x, y - 1); + const b = getData(x, y + 1); + + const dxl = xo[j - 1]; + const dxr = xo[j + 1]; + const dxt = xo[j - wp]; + const dxb = xo[j + wp]; + + const dyl = yo[j - 1]; + const dyr = yo[j + 1]; + const dyt = yo[j - wp]; + const dyb = yo[j + wp]; + + let dx = nx; + let dy = ny; + let dw = 1; + + const dc = c - 0.5; + const dl = l - 0.5; + const dr = r - 0.5; + const dt = t - 0.5; + const db = b - 0.5; + + if (!isSolid(l) && !isSolid(r)) { + if (checkCross(nx, ny, dc, dl, dr, dxl, dyl, dxr, dyr)) { + dx += (dxl + dxr) / 2; + dy += (dyl + dyr) / 2; + dw++; + } + } + + if (!isSolid(t) && !isSolid(b)) { + if (checkCross(nx, ny, dc, dt, db, dxt, dyt, dxb, dyb)) { + dx += (dxt + dxb) / 2; + dy += (dyt + dyb) / 2; + dw++; + } + } + + if (!isSolid(l) && !isSolid(t)) { + if (checkCross(nx, ny, dc, dl, dt, dxl, dyl, dxt, dyt)) { + dx += (dxl + dxt - 1) / 2; + dy += (dyl + dyt - 1) / 2; + dw++; + } + } + + if (!isSolid(r) && !isSolid(t)) { + if (checkCross(nx, ny, dc, dr, dt, dxr, dyr, dxt, dyt)) { + dx += (dxr + dxt + 1) / 2; + dy += (dyr + dyt - 1) / 2; + dw++; + } + } + + if (!isSolid(l) && !isSolid(b)) { + if (checkCross(nx, ny, dc, dl, db, dxl, dyl, dxb, dyb)) { + dx += (dxl + dxb - 1) / 2; + dy += (dyl + dyb + 1) / 2; + dw++; + } + } + + if (!isSolid(r) && !isSolid(b)) { + if (checkCross(nx, ny, dc, dr, db, dxr, dyr, dxb, dyb)) { + dx += (dxr + dxb + 1) / 2; + dy += (dyr + dyb + 1) / 2; + dw++; + } + } + + const nn = Math.sqrt(nx * nx + ny * ny); + const ll = (dx * nx + dy * ny) / nn; + + dx = (nx * ll) / dw / nn; + dy = (ny * ll) / dw / nn; + + xi[j] = dx; + yi[j] = dy; + } + } + xs = xi; + ys = yi; + } + + if (half) return; + + // Produce zero points for positive and negative DF, at +0.5 / -0.5. + // Splits xs into xo/xi + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const j = (y + pad) * wp + x + pad; + + const nx = xs[j]; + const ny = ys[j]; + if (!nx && !ny) continue; + + const nn = Math.sqrt(sqr(nx) + sqr(ny)); + + const sx = Math.abs(nx / nn) - 0.5 > 0 ? Math.sign(nx) : 0; + const sy = Math.abs(ny / nn) - 0.5 > 0 ? Math.sign(ny) : 0; + + const c = getData(x, y); + const d = getData(x + sx, y + sy); + const s = Math.sign(d - c); + + let dlo = nn + 0.4999 * s; + let dli = nn - 0.4999 * s; + + dli /= nn; + dlo /= nn; + + xo[j] = nx * dlo; + yo[j] = ny * dlo; + xi[j] = nx * dli; + yi[j] = ny * dli; + } + } +}; + +// Snap distance targets to neighboring target points, if closer. +// Makes the SDF more correct and less XY vs YX dependent, but has only little effect on final contours. +export const relaxSubpixelOffsets = ( + stage: SDFStage, + data: Uint8ClampedArray, + w: number, + h: number, + pad: number, +) => { + const wp = w + pad * 2; + + const { xo, yo, xi, yi } = stage; + + // Check if target's neighbor is closer + const check = ( + xs: Float32Array, + ys: Float32Array, + x: number, + y: number, + dx: number, + dy: number, + tx: number, + ty: number, + d: number, + j: number, + ) => { + const k = (y + pad) * wp + x + pad; + + const dx2 = dx + xs[k]; + const dy2 = dy + ys[k]; + const d2 = Math.sqrt(sqr(dx2) + sqr(dy2)); + + if (d2 < d) { + xs[j] = dx2; + ys[j] = dy2; + return d2; + } + return d; + }; + + const relax = (xs: Float32Array, ys: Float32Array) => { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const j = (y + pad) * wp + x + pad; + + const dx = xs[j]; + const dy = ys[j]; + if (!dx && !dy) continue; + + // Step towards target minus 0.5px to find contour + let d = Math.sqrt(dx * dx + dy * dy); + const ds = (d - 0.5) / d; + const tx = x + dx * ds; + const ty = y + dy * ds; + + // Check area around array index + const ix = Math.round(tx); + const iy = Math.round(ty); + d = check(xs, ys, ix + 1, iy, ix - x + 1, iy - y, tx, ty, d, j); + d = check(xs, ys, ix - 1, iy, ix - x - 1, iy - y, tx, ty, d, j); + d = check(xs, ys, ix, iy + 1, ix - x, iy - y + 1, tx, ty, d, j); + d = check(xs, ys, ix, iy - 1, ix - x, iy - y - 1, tx, ty, d, j); + } + } + }; + + relax(xo, yo); + relax(xi, yi); +}; + +// Paint original color data into final RGBA (emoji) +export const paintIntoRGB = ( + image: Uint8Array, + color: Uint8Array | number[], + xs: Float32Array, + ys: Float32Array, + w: number, + h: number, + pad: number, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + + { + let i = 0; + let o = (pad + pad * wp) * 4; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (color[i + 3]) { + image[o] = color[i]; + image[o + 1] = color[i + 1]; + image[o + 2] = color[i + 2]; + image[o + 3] = color[i + 3]; + } + i += 4; + o += 4; + } + o += pad * 8; + } + } + + { + let i = 0, + j = 0; + for (let y = 0; y < hp; y++) { + for (let x = 0; x < wp; x++) { + if (image[i + 3] === 0) { + const dx = xs[j]; + const dy = ys[j]; + const ox = Math.round(x + dx); + const oy = Math.round(y + dy); + + const k = (ox + oy * wp) * 4; + image[i] = image[k]; + image[i + 1] = image[k + 1]; + image[i + 2] = image[k + 2]; + image[i + 3] = 1; + } + i += 4; + j++; + } + } + } +}; + +// Paint sdf alpha data into final RGBA (emoji) +export const paintIntoAlpha = ( + image: Uint8Array, + alpha: Uint8Array | number[], + w: number, + h: number, + pad: number, +) => { + const wp = w + pad * 2; + + let i = 0; + let o = (pad + pad * wp) * 4; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + image[o + 3] = alpha[i]; + i++; + o += 4; + } + o += pad * 8; + } +}; + +// 1D subpixel distance transform +export const esdt1d = ( + mask: Float32Array, + xs: Float32Array, + ys: Float32Array, + offset: number, + stride: number, + length: number, + f: Float32Array, // Squared distance + z: Float32Array, // Voronoi threshold + b: Float32Array, // Subpixel offset parallel + t: Float32Array, // Subpixel offset perpendicular + v: Uint16Array, // Array index +) => { + v[0] = 0; + b[0] = xs[offset]; + t[0] = ys[offset]; + z[0] = -INF; + z[1] = INF; + f[0] = mask[offset] ? INF : ys[offset] * ys[offset]; + + // Scan along array and build list of critical minima + let k = 0; + for (let q = 1, s = 0; q < length; q++) { + const o = offset + q * stride; + + // Perpendicular + const dx = xs[o]; + const dy = ys[o]; + const fq = (f[q] = mask[o] ? INF : dy * dy); + t[q] = dy; + + // Parallel + const qs = q + dx; + const q2 = qs * qs; + b[q] = qs; + + // Remove any minima eclipsed by this one + do { + const r = v[k]; + const rs = b[r]; + + const r2 = rs * rs; + s = (fq - f[r] + q2 - r2) / (qs - rs) / 2; + } while (s <= z[k] && --k > -1); + + // Add to minima list + k++; + v[k] = q; + z[k] = s; + z[k + 1] = INF; + } + + // Resample array based on critical minima + for (let q = 0, k = 0; q < length; q++) { + // Skip eclipsed minima + while (z[k + 1] < q) k++; + + const r = v[k]; + const rs = b[r]; + const dy = t[r]; + + // Distance from integer index to subpixel location of minimum + const rq = rs - q; + + const o = offset + q * stride; + xs[o] = rq; + ys[o] = dy; + + // Mark cell as having propagated + if (r !== q) mask[o] = 0; + } +}; + +// 2D subpixel distance transform by unconed +// extended from Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/papers/dt-final.pdf +export const esdt = ( + mask: Float32Array, + xs: Float32Array, + ys: Float32Array, + w: number, + h: number, + f: Float32Array, + z: Float32Array, + b: Float32Array, + t: Float32Array, + v: Uint16Array, + half: number = 0, +) => { + if (half !== 1) + for (let x = 0; x < w; ++x) esdt1d(mask, ys, xs, x, w, h, f, z, b, t, v); + if (half !== 2) + for (let y = 0; y < h; ++y) + esdt1d(mask, xs, ys, y * w, 1, w, f, z, b, t, v); +}; + +// Convert grayscale or color glyph to SDF using subpixel distance transform +export const glyphToESDT = ( + data: Uint8ClampedArray, + color: Uint8Array | null, + w: number, + h: number, + pad: number = 4, + radius: number = 3, + cutoff: number = 0.25, + preprocess: boolean = false, + postprocess: boolean = false, +) => { + const wp = w + pad * 2; + const hp = h + pad * 2; + const np = wp * hp; + const sp = Math.max(wp, hp); + + const stage = getSDFStage(sp); + const { outer, inner, xo, yo, xi, yi, f, z, b, t, v } = stage; + + paintIntoStage(stage, data, w, h, pad); + paintSubpixelOffsets(stage, data, w, h, pad, preprocess); + + esdt(outer, xo, yo, wp, hp, f, z, b, t, v); + esdt(inner, xi, yi, wp, hp, f, z, b, t, v); + if (postprocess) relaxSubpixelOffsets(stage, data, w, h, pad); + + const alpha = new Uint8Array(np); + for (let i = 0; i < np; ++i) { + const outer = Math.max(0, Math.sqrt(sqr(xo[i]) + sqr(yo[i])) - 0.5); + const inner = Math.max(0, Math.sqrt(sqr(xi[i]) + sqr(yi[i])) - 0.5); + const d = outer >= inner ? outer : -inner; + alpha[i] = Math.max( + 0, + Math.min(255, Math.round(255 - 255 * (d / radius + cutoff))), + ); + } + + if (!preprocess) + paintIntoDistanceField(alpha, data, w, h, pad, radius, cutoff); + + if (color) { + const out = new Uint8Array(np * 4); + paintIntoRGB(out, color, xo, yo, w, h, pad); + paintIntoAlpha(out, alpha, wp, hp, 0); + return { data: out, width: wp, height: hp }; + } else { + return glyphToRGBA(alpha, wp, hp); + } +}; diff --git a/packages/core/src/utils/glyph/tiny-sdf.ts b/packages/core/src/utils/glyph/tiny-sdf.ts index 6137059..14cb66d 100644 --- a/packages/core/src/utils/glyph/tiny-sdf.ts +++ b/packages/core/src/utils/glyph/tiny-sdf.ts @@ -3,6 +3,8 @@ * @see https://gitlab.com/unconed/use.gpu/-/blob/master/packages/glyph/src/sdf.ts * @see https://acko.net/blog/subpixel-distance-transform/ */ +import { glyphToEDT } from './sdf-edt'; +import { glyphToESDT } from './sdf-esdt'; // Convert grayscale glyph to rgba export const glyphToRGBA = ( @@ -24,25 +26,52 @@ export const glyphToRGBA = ( return { data: out, width: wp, height: hp }; }; -const INF = 1e20; +export const INF = 1e20; -export class TinySDF { - ctx: CanvasRenderingContext2D; - size: number; - buffer: number; - cutoff: number; - radius: number; +export type SDFStage = { outer: Float32Array; inner: Float32Array; - f: Float32Array; - z: Float32Array; - v: Uint16Array; + xo: Float32Array; yo: Float32Array; xi: Float32Array; yi: Float32Array; + + f: Float32Array; + z: Float32Array; b: Float32Array; t: Float32Array; + v: Uint16Array; + + size: number; +}; + +export const getSDFStage = (size: number) => { + const n = size * size; + + const outer = new Float32Array(n); + const inner = new Float32Array(n); + + const xo = new Float32Array(n); + const yo = new Float32Array(n); + const xi = new Float32Array(n); + const yi = new Float32Array(n); + + const f = new Float32Array(size); + const z = new Float32Array(size + 1); + const b = new Float32Array(size); + const t = new Float32Array(size); + const v = new Uint16Array(size); + + return { outer, inner, xo, yo, xi, yi, f, z, b, t, v, size }; +}; + +export class TinySDF { + ctx: CanvasRenderingContext2D; + size: number; + buffer: number; + cutoff: number; + radius: number; constructor({ fontSize = 24, @@ -70,22 +99,6 @@ export class TinySDF { ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; // Necessary so that RTL text doesn't have different alignment ctx.fillStyle = 'black'; - - const n = size * size; - // temporary arrays for the distance transform - this.outer = new Float32Array(n); - this.inner = new Float32Array(n); - - this.xo = new Float32Array(n); - this.yo = new Float32Array(n); - this.xi = new Float32Array(n); - this.yi = new Float32Array(n); - - this.f = new Float32Array(size); - this.z = new Float32Array(size + 1); - this.b = new Float32Array(size); - this.t = new Float32Array(size); - this.v = new Uint16Array(size); } _createCanvas(size: number) { @@ -94,7 +107,7 @@ export class TinySDF { return canvas; } - draw(char: string) { + draw(char: string, esdt = false) { const { width: glyphAdvance, actualBoundingBoxAscent, @@ -121,100 +134,68 @@ export class TinySDF { glyphTop + Math.ceil(actualBoundingBoxDescent), ); - const pad = this.buffer; - const wp = w + pad * 2; - const hp = h + pad * 2; - const np = wp * hp; - // const sp = Math.max(wp, hp); - - const out = new Uint8Array(np); - - const glyph = { - data: out, - width: wp, - height: hp, - glyphWidth: w, - glyphHeight: h, - glyphTop, - glyphLeft, - glyphAdvance, - }; if (w === 0 || h === 0) { - glyph.data = new Uint8Array(np * 4); - return glyph; + return { + data: new Uint8Array(0), + width: 0, + height: 0, + glyphWidth: 0, + glyphHeight: 0, + glyphTop: 0, + glyphLeft: 0, + glyphAdvance: 0, + }; } - const { ctx, buffer, inner, outer } = this; + const pad = this.buffer; + + const { ctx, buffer } = this; ctx.clearRect(buffer, buffer, w, h); ctx.fillText(char, buffer, buffer + glyphTop); const imageData = ctx.getImageData(buffer, buffer, w, h); - const data = imageData.data; - - outer.fill(INF, 0, np); - inner.fill(0, 0, np); - - const getData = (x: number, y: number) => - (data[4 * (y * w + x) + 3] ?? 0) / 255; - - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const a = getData(x, y); - const i = (y + pad) * wp + x + pad; - - if (a >= 254 / 255) { - // Fix for bad rasterizer rounding - data[4 * (y * w + x) + 3] = 255; - - outer[i] = 0; - inner[i] = INF; - } else if (a > 0) { - const d = 0.5 - a; - outer[i] = d > 0 ? d * d : 0; - inner[i] = d < 0 ? d * d : 0; - } - } - } - - edt(outer, 0, 0, wp, hp, wp, this.f, this.z, this.v); - edt(inner, pad, pad, w, h, wp, this.f, this.z, this.v); - for (let i = 0; i < np; i++) { - const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]); - out[i] = Math.max( - 0, - Math.min(255, Math.round(255 - 255 * (d / this.radius + this.cutoff))), - ); + let data: Uint8Array; + let width: number; + let height: number; + + if (esdt) { + ({ data, width, height } = glyphToESDT( + imageData.data, + null, + w, + h, + pad, + this.radius, + this.cutoff, + // true, + // true, + )); + } else { + ({ data, width, height } = glyphToEDT( + imageData.data, + w, + h, + pad, + this.radius, + this.cutoff, + )); } - glyph.data = glyphToRGBA(out, w, h, pad).data; - - return glyph; + return { + data, + width, + height, + glyphWidth: w, + glyphHeight: h, + glyphTop, + glyphLeft, + glyphAdvance, + }; } } -// 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/papers/dt-final.pdf -function edt( - data: Float32Array, - x0: number, - y0: number, - width: number, - height: number, - gridWidth: number, - f: Float32Array, - z: Float32Array, - v: Uint16Array, - half?: number, -) { - if (half !== 2) - for (let y = y0; y < y0 + height; y++) - edt1d(data, y * gridWidth + x0, 1, width, f, z, v); - if (half !== 1) - for (let x = x0; x < x0 + width; x++) - edt1d(data, y0 * gridWidth + x, gridWidth, height, f, z, v); -} - // 1D squared distance transform -function edt1d( +export const edt1d = ( grid: Float32Array, offset: number, stride: number, @@ -222,7 +203,7 @@ function edt1d( f: Float32Array, z: Float32Array, v: Uint16Array, -) { +) => { v[0] = 0; z[0] = -INF; z[1] = INF; @@ -230,6 +211,7 @@ function edt1d( for (let q = 1, k = 0, s = 0; q < length; q++) { f[q] = grid[offset + q * stride]; + const q2 = q * q; do { const r = v[k]; @@ -246,6 +228,37 @@ function edt1d( while (z[k + 1] < q) k++; const r = v[k]; const qr = q - r; - grid[offset + q * stride] = f[r] + qr * qr; + const fr = f[r]; + grid[offset + q * stride] = fr + qr * qr; } -} +}; + +// 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/papers/dt-final.pdf +export const edt = ( + data: Float32Array, + x0: number, + y0: number, + width: number, + height: number, + gridWidth: number, + f: Float32Array, + z: Float32Array, + v: Uint16Array, + half?: number, +) => { + if (half !== 2) + for (let y = y0; y < y0 + height; y++) + edt1d(data, y * gridWidth + x0, 1, width, f, z, v); + if (half !== 1) + for (let x = x0; x < x0 + width; x++) + edt1d(data, y0 * gridWidth + x, gridWidth, height, f, z, v); +}; + +// Helpers +export const isBlack = (x: number) => !x; +export const isWhite = (x: number) => x === 1; +export const isSolid = (x: number) => !(x && 1 - x); + +export const sqr = (x: number) => x * x; +export const seq = (n: number, start: number = 0, step: number = 1) => + Array.from({ length: n }).map((_, i) => start + i * step); diff --git a/packages/site/docs/.vitepress/config/en.js b/packages/site/docs/.vitepress/config/en.js index 90a26db..8abb348 100644 --- a/packages/site/docs/.vitepress/config/en.js +++ b/packages/site/docs/.vitepress/config/en.js @@ -152,6 +152,10 @@ export const en = defineConfig({ text: 'Use MSDF to draw text', link: 'msdf-text', }, + { + text: 'Draw emoji', + link: 'emoji', + }, ], }, ], diff --git a/packages/site/docs/.vitepress/config/zh.js b/packages/site/docs/.vitepress/config/zh.js index ce482a7..03e54f4 100644 --- a/packages/site/docs/.vitepress/config/zh.js +++ b/packages/site/docs/.vitepress/config/zh.js @@ -134,6 +134,10 @@ export const zh = defineConfig({ text: '使用 MSDF 绘制文本', link: 'msdf-text', }, + { + text: '绘制表情符号', + link: 'emoji', + }, ], }, ], diff --git a/packages/site/docs/components/Emoji.vue b/packages/site/docs/components/Emoji.vue new file mode 100644 index 0000000..56a8e8c --- /dev/null +++ b/packages/site/docs/components/Emoji.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/site/docs/components/MSDFText.vue b/packages/site/docs/components/MSDFText.vue index 2406f0c..6648eb1 100644 --- a/packages/site/docs/components/MSDFText.vue +++ b/packages/site/docs/components/MSDFText.vue @@ -28,8 +28,8 @@ onMounted(() => { { const text = new Text({ - x: 50, - y: 50, + x: 10, + y: 10, content: 'Hello, world!', fontSize: 45, fill: '#F67676', @@ -60,7 +60,7 @@ onMounted(() => { diff --git a/packages/site/docs/example/emoji.md b/packages/site/docs/example/emoji.md new file mode 100644 index 0000000..dd05ed1 --- /dev/null +++ b/packages/site/docs/example/emoji.md @@ -0,0 +1,11 @@ +--- +publish: false +--- + +Draw text + + + + diff --git a/packages/site/docs/example/msdf-text.md b/packages/site/docs/example/msdf-text.md index 07c9c81..9d68f79 100644 --- a/packages/site/docs/example/msdf-text.md +++ b/packages/site/docs/example/msdf-text.md @@ -2,7 +2,7 @@ publish: false --- -[Use generated MSDF to render text](/guide/lesson-015#msdf) +[Use generated MSDF to render text](/guide/lesson-015#msdf). It can be seen that, compared to the implementation of SDF, the text edges remain sharp even after magnification. + + diff --git a/packages/site/docs/zh/example/msdf-text.md b/packages/site/docs/zh/example/msdf-text.md index 5bef24e..70c75e3 100644 --- a/packages/site/docs/zh/example/msdf-text.md +++ b/packages/site/docs/zh/example/msdf-text.md @@ -2,7 +2,7 @@ publish: false --- -[使用预生成的 MSDF 渲染文字](/zh/guide/lesson-015#msdf) +[使用预生成的 MSDF 渲染文字](/zh/guide/lesson-015#msdf)。可以看到相比 SDF 的实现,放大后文字边缘依然锐利。