LibTxt 引擎解析:ParagraphBuilder 如何将样式映射到 Skia/Impeller

好的,我们开始今天的讲座。主题是 LibTxt 引擎解析:ParagraphBuilder 如何将样式映射到 Skia/Impeller。

LibTxt 是一个用于文本布局和渲染的库,它抽象了底层的图形 API,例如 Skia 和 Impeller。 ParagraphBuilder 是 LibTxt 中用于构建文本段落的核心组件。它的主要职责是将文本内容和样式信息转换为底层图形 API 可以理解的形式,最终完成文本的绘制。 今天我们将深入探讨 ParagraphBuilder 如何将文本样式(例如字体、颜色、字号、粗细等)映射到 Skia 和 Impeller 这两个不同的渲染引擎。

ParagraphBuilder 的总体架构

在深入细节之前,我们先了解一下 ParagraphBuilder 的总体架构。ParagraphBuilder 接收文本内容和样式指令,并将其存储在一个内部的数据结构中。这个数据结构通常是一个由 TextRun 对象组成的列表,每个 TextRun 对象代表一段具有相同样式的文本。

// 简化版的 TextRun 结构体
struct TextRun {
  std::string text;
  TextStyle style;
};

// 简化版的 TextStyle 结构体
struct TextStyle {
  std::string fontFamily;
  double fontSize;
  SkColor color;
  SkFontStyle fontStyle;  // 包含粗细、斜体等信息
  // 其他样式属性
};

class ParagraphBuilder {
 public:
  ParagraphBuilder();
  ~ParagraphBuilder();

  void addText(const std::string& text);
  void pushStyle(const TextStyle& style);
  void pop();
  // ... 其他方法

 private:
  std::vector<TextRun> runs_;
  std::vector<TextStyle> styleStack_;
};

addText 方法将文本添加到当前段落,并应用当前样式栈顶部的样式。pushStyle 方法将新的样式压入栈中,而 pop 方法则弹出栈顶的样式。通过这种方式,ParagraphBuilder 可以处理复杂的嵌套样式。

样式映射到 Skia

Skia 是一个开源的 2D 图形库,被广泛应用于 Chrome、Android 等平台。ParagraphBuilder 在使用 Skia 作为渲染后端时,需要将 TextStyle 中的属性转换为 Skia 对应的类型。

  1. 字体映射:

TextStyle 中的 fontFamilyfontSize 属性需要转换为 Skia 的 SkFont 对象。这涉及到字体查找和字形缓存。

#include "include/core/SkFont.h"
#include "include/core/SkFontMgr.h"

SkFont CreateSkFont(const TextStyle& style) {
  SkFontMgr* fontMgr = SkFontMgr::RefDefault();
  SkString familyName(style.fontFamily.c_str());
  SkFontStyle fontStyle = style.fontStyle;

  SkFont skFont(fontMgr->matchFamilyStyle(familyName.c_str(), fontStyle), style.fontSize);

  fontMgr->unref();
  return skFont;
}

SkFontMgr::matchFamilyStyle 方法用于查找与给定字体名称和样式匹配的字体。SkFont 对象包含了字体数据和字形信息,可以用于绘制文本。 SkFontStyle 可以由 TextStyle 中的粗细(weight)、倾斜(slant)、宽度(width)转换而来,例如:

SkFontStyle ConvertToSkFontStyle(const TextStyle& style) {
  SkFontStyle::Slant slant;
  switch (style.fontStyle.slant) {
    case FontSlant::kUpright:
      slant = SkFontStyle::Slant::kUpright;
      break;
    case FontSlant::kItalic:
      slant = SkFontStyle::Slant::kItalic;
      break;
    case FontSlant::kOblique:
      slant = SkFontStyle::Slant::kOblique;
      break;
    default:
      slant = SkFontStyle::Slant::kUpright;
      break;
  }

  int weight = style.fontStyle.weight; // 例如 400 代表 Normal, 700 代表 Bold
  int width = style.fontStyle.width;   // 字体宽度,正常情况下是 5 (kNormal_Width)

  return SkFontStyle(weight, width, slant);
}
  1. 颜色映射:

TextStyle 中的 color 属性可以直接转换为 Skia 的 SkColor 类型。

SkColor ConvertToSkColor(const TextStyle& style) {
  return style.color;
}
  1. 其他样式:

TextStyle 中可能包含其他样式属性,例如下划线、删除线、背景色等。这些属性需要转换为 Skia 对应的绘制操作。例如,下划线可以通过绘制一条线来实现。

void DrawUnderline(SkCanvas* canvas, const SkFont& font, const SkColor& color, SkScalar x, SkScalar y, SkScalar width) {
  SkPaint paint;
  paint.setColor(color);
  paint.setStyle(SkPaint::kStroke_Style);
  paint.setStrokeWidth(1.0); // 可根据需要调整

  SkFontMetrics metrics;
  font.getMetrics(&metrics);
  SkScalar underlinePosition = y + metrics.underlinePosition;
  canvas->drawLine(x, underlinePosition, x + width, underlinePosition, paint);
}

在 ParagraphBuilder 中,这些转换后的 Skia 对象和绘制操作会被用于构建 SkTextBlob 对象。SkTextBlob 对象是一个包含文本和样式信息的不可变对象,可以高效地进行绘制。

#include "include/core/SkTextBlob.h"

SkTextBlobBuilder blobBuilder;
SkFont skFont = CreateSkFont(run.style);
SkColor skColor = ConvertToSkColor(run.style);
SkPaint paint;
paint.setColor(skColor);
blobBuilder.allocRunText(skFont, run.text.size(), 0, 0).utf8Text(run.text.c_str(), run.text.size());
sk_sp<SkTextBlob> blob = blobBuilder.make();

// 绘制 TextBlob
canvas->drawTextBlob(blob.get(), x, y, paint);

样式映射到 Impeller

Impeller 是 Flutter 团队开发的新的渲染引擎,旨在解决 Skia 在移动设备上的一些性能问题。Impeller 使用 Metal (iOS) 和 Vulkan (Android) 作为图形 API。ParagraphBuilder 在使用 Impeller 作为渲染后端时,需要将 TextStyle 中的属性转换为 Impeller 对应的类型。

Impeller 与 Skia 的主要区别在于 Impeller 在设计上更加面向硬件,并且使用了不同的数据结构和渲染流程。因此,ParagraphBuilder 需要进行不同的样式映射。

  1. 字体映射:

Impeller 使用自己的字体引擎,因此 ParagraphBuilder 需要将 TextStyle 中的 fontFamilyfontSize 属性转换为 Impeller 字体引擎可以理解的形式。这涉及到查找字体文件、解析字体数据,并创建 Impeller 的字体对象。

#include "impeller/typographer/font.h"
#include "impeller/typographer/font_context.h"

std::shared_ptr<impeller::Font> CreateImpellerFont(const TextStyle& style, impeller::FontContext& font_context) {
  // 根据字体名称查找字体文件
  std::string fontPath = FindFontPath(style.fontFamily);

  // 创建字体对象
  auto font = font_context.GetFontCollection().FindFont(
      style.fontFamily.c_str(),
      style.fontSize,
      ConvertToImpellerFontStyle(style.fontStyle)
  );

  return font;
}

impeller::FontWeight ConvertToImpellerFontWeight(int weight) {
    if (weight >= 700) {
        return impeller::FontWeight::kBold;
    } else if (weight >= 500) {
        return impeller::FontWeight::kMedium;
    } else {
        return impeller::FontWeight::kNormal;
    }
}

impeller::FontStyle ConvertToImpellerFontStyle(const TextStyle& style) {
    impeller::FontStyle font_style;
    font_style.weight = ConvertToImpellerFontWeight(style.fontStyle.weight);
    font_style.slant = (style.fontStyle.slant == FontSlant::kItalic || style.fontStyle.slant == FontSlant::kOblique) ? impeller::FontSlant::kItalic : impeller::FontSlant::kUpright;
    return font_style;
}

FindFontPath 函数负责根据字体名称查找字体文件的路径。impeller::FontContext 用于管理字体资源。impeller::FontCollection::FindFont 方法用于创建字体对象。

  1. 颜色映射:

TextStyle 中的 color 属性需要转换为 Impeller 的 Color 类型。

#include "impeller/geometry/color.h"

impeller::Color ConvertToImpellerColor(const TextStyle& style) {
  return impeller::Color(
      SkColorGetR(style.color) / 255.0f,
      SkColorGetG(style.color) / 255.0f,
      SkColorGetB(style.color) / 255.0f,
      SkColorGetA(style.color) / 255.0f
  );
}
  1. 文本布局:

与 Skia 不同,Impeller 并没有 SkTextBlob 这样的概念。Impeller 的文本布局过程更加精细,需要手动计算每个字形的坐标和绘制指令。

#include "impeller/typographer/text_run.h"
#include "impeller/typographer/glyph_map.h"

impeller::TextRun CreateImpellerTextRun(const std::string& text, const TextStyle& style, impeller::FontContext& font_context) {
    auto font = CreateImpellerFont(style, font_context);
    if (!font) {
        return {}; // 或者抛出异常
    }

    impeller::TextRun text_run;
    text_run.font = font;
    text_run.text = text;

    impeller::GlyphMap glyph_map = font->GetGlyphMap(text);
    text_run.glyph_data = glyph_map.GetGlyphData(); // 获取字形数据(索引、偏移等)

    // 计算文本宽度
    text_run.size = font->MeasureText(text);

    return text_run;
}

// 绘制文本run
void DrawImpellerTextRun(const impeller::TextRun& run, const impeller::Color& color, impeller::ContentContext& content_context, impeller::Matrix matrix, impeller::Allocator& allocator) {
  if (!run.font || run.text.empty()) {
    return;
  }

  // 构建绘制指令,例如使用SDF渲染
  impeller::GlyphAtlas::TextureSet glyph_atlas_texture_set = content_context.GetGlyphAtlas()->GetTextureSet();
  if(!glyph_atlas_texture_set.valid) {
    return;
  }

  // 创建渲染通道(RenderPass)并添加绘制指令
  auto render_target = impeller::RenderTarget::CreateOffscreen(content_context.GetDevice(), {1024, 1024}); // 示例尺寸
  auto pass = render_target.CreateRenderPass();
  pass->SetColorSource(render_target.GetColorTexture());

  size_t glyph_count = run.glyph_data.size();
  for(size_t i = 0; i < glyph_count; ++i) {
    impeller::GlyphData glyph = run.glyph_data[i];

    // 计算字形的位置和变换矩阵
    impeller::Matrix glyph_matrix = matrix * impeller::Matrix::MakeTranslation(glyph.offset);

    // 构建顶点数据和索引数据,并添加到渲染通道
    // 具体实现取决于 Impeller 的渲染管线和使用的 Shader

      // 示例:使用SDF渲染字形
      // ... 构建顶点数据和索引数据,使用 glyph_atlas_texture_set 中的纹理坐标 ...
      // pass->AddCommand(command); // 添加绘制命令
  }

  if (pass->IsValid()) {
    pass->EncodeCommands(content_context.GetCommandEncoder());
    content_context.Submit(render_target);
  }

}

这段代码展示了如何创建 Impeller 的 TextRun 对象,并使用 ContentContext 和 RenderTarget 进行渲染。 实际的 Impeller 渲染过程非常复杂,涉及到顶点数据的构建、Shader 的编写和纹理的管理。

Skia 与 Impeller 的差异对比

为了更清晰地理解 Skia 和 Impeller 的差异,我们用表格的形式进行对比。

特性 Skia Impeller
API 抽象程度 较高,抽象了底层图形 API 较低,更接近硬件
文本对象 SkTextBlob 无类似对象,需要手动布局和绘制
字体处理 使用 SkFontMgr 和 SkFont 使用 FontContext 和 Font
渲染流程 基于 Canvas 的命令式渲染 基于 RenderPass 的声明式渲染
性能 在移动设备上可能存在性能瓶颈 针对移动设备优化,性能更佳
跨平台性 良好,支持多种平台 主要针对 iOS 和 Android 平台

ParagraphBuilder 的策略选择

ParagraphBuilder 需要根据不同的渲染后端选择不同的样式映射策略。这可以通过条件编译或者运行时判断来实现。

class ParagraphBuilder {
 public:
  // ...

  void build(RenderingBackend backend) {
    if (backend == RenderingBackend::kSkia) {
      buildWithSkia();
    } else if (backend == RenderingBackend::kImpeller) {
      buildWithImpeller();
    } else {
      // ...
    }
  }

 private:
  void buildWithSkia();
  void buildWithImpeller();

  // ...
};

buildWithSkiabuildWithImpeller 方法中,ParagraphBuilder 可以使用不同的代码路径来将样式映射到对应的渲染引擎。

处理复杂样式

除了基本的字体、颜色和字号之外,TextStyle 还可能包含其他复杂的样式属性,例如:

  • 文本阴影: 在 Skia 中,可以使用 SkMaskFilter::MakeShadow 来创建阴影效果。在 Impeller 中,需要手动实现阴影的绘制。
  • 渐变色: 在 Skia 中,可以使用 SkShader::MakeLinearGradientSkShader::MakeRadialGradient 来创建渐变色。在 Impeller 中,也需要使用类似的 API。
  • 文本装饰(下划线、删除线): 这些装饰可以通过绘制额外的线条来实现。

ParagraphBuilder 需要根据不同的样式属性选择合适的绘制方法,并将这些方法应用到文本的绘制过程中。

性能优化

文本渲染是一个计算密集型的任务,因此性能优化至关重要。ParagraphBuilder 可以采用以下策略来提高性能:

  • 缓存字体对象: 避免重复创建字体对象。
  • 使用 TextBlob (Skia): 将文本和样式信息打包成 TextBlob 对象,减少绘制调用次数。
  • 批量渲染: 将多个文本段落合并成一个批次进行渲染。
  • 使用硬件加速: 尽可能利用 GPU 进行渲染。
  • 减少状态切换: 尽量减少绘制状态的切换次数。

多语言支持

LibTxt 需要支持多种语言,包括从左到右 (LTR) 和从右到左 (RTL) 的文本。ParagraphBuilder 需要根据文本的方向选择合适的布局算法,并将文本正确地排列。这涉及到 Unicode 双向算法 (Bidirectional Algorithm) 的实现。

总结

ParagraphBuilder 在 LibTxt 引擎中扮演着至关重要的角色,它负责将文本内容和样式信息转换为底层图形 API 可以理解的形式。为了支持 Skia 和 Impeller 两种不同的渲染引擎,ParagraphBuilder 需要采用不同的样式映射策略。理解 Skia 和 Impeller 的差异,以及掌握性能优化的技巧,对于开发高性能的文本渲染应用至关重要。

思考题

  • 如何在 ParagraphBuilder 中实现文本换行和对齐?
  • 如何支持自定义字体和字体回退?
  • 如何在 Impeller 中实现复杂的文本效果,例如文本阴影和渐变色?

进一步学习的建议

  • 阅读 Skia 和 Impeller 的官方文档。
  • 研究 LibTxt 的源代码。
  • 学习 Unicode 双向算法。
  • 关注文本渲染领域的最新技术发展。

一些关键点的概括

ParagraphBuilder 负责文本样式到Skia/Impeller的转换。
Skia和Impeller的架构差异导致了不同的样式映射方法。
性能优化和多语言支持是文本渲染的重要考虑因素。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注