Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Adding Simplified Chinese

The base engine::text setup ships Noto Sans (Latin-only). Adding Chinese is more involved than it looks — you need to (a) get a font that actually contains Han glyphs, (b) be careful about which TTF you pick, and (c) keep the atlas from melting your VRAM.

This chapter records what worked and the dead-ends I hit on the way.

TL;DR

// game.cpp
engine::LoadFonts(engine::CodepointSet::AsciiPlusCJK);

Then ship assets/fonts/noto/NotoSansSC-Regular.ttf next to the existing NotoSans-Regular.ttf. Both engine::text and ImGuiLayer read engine::GetCodepointSet() and pick the SC face automatically.

Why Noto Sans alone is not enough

NotoSans-Regular.ttf (the Latin Noto) doesn’t contain CJK Unified Ideographs. If you DrawText("你好") with it you get tofu (◻◻).

The CJK glyphs live in a separate font family:

familycovers
NotoSansSCSimplified Chinese + Latin
NotoSansTCTraditional Chinese + Latin
NotoSansJPJapanese (kana + kanji)
NotoSansKRKorean (hangul + hanja)
NotoSansCJKUnified pan-CJK

We ship NotoSansSC because the game is shipping in zh-CN. Switching to JP/KR is a one-line change in engine::text.cpp’s kCjkPath.

Trap #1: don’t use the Variable TTF

The canonical noto-cjk repo distributes its TTFs as variable fonts (NotoSansSC-VF.ttf). VFs pack every weight from Thin through Black into one file, with the actual rendered weight selected via the fvar axis at draw time.

stb_truetype — used by both raylib and ImGui — does not parse the fvar axis. It just renders the file’s default instance, which for the upstream Noto CJK VFs is wght=100 (Thin). The result is text that looks like it’s about to evaporate.

Confirming the effect on a real machine:

Before fix: thin, hairline strokes
After  fix: normal Regular weight

There are two ways out:

  1. Use a static TTF at the weight you want. Both Google Fonts and the noto-cjk repo unfortunately only distribute VF TTFs publicly. The static OTFs they ship are CFF-based, which raylib’s stb_truetype doesn’t reliably handle either.
  2. Instance the VF down to a static TTF yourself. This is the approach we use.

Trap #2: don’t use the OTF static distribution

The static-weight files at noto-cjk/Sans/OTF/SimplifiedChinese/ are in the right weight (Regular = wght 400) — but they’re CFF-based OTF (.otf with the CFF table, PostScript Type 2 outlines). Newer stb_truetype builds added partial CFF support, but coverage is uneven and at the time of writing the version raylib ships with renders these files badly.

Conclusion: use TrueType outlines (glyf table) only. That means either a TTF that’s already static, or a VF that we statically instance ourselves.

Static-instance the VF with fonttools

fonttools.varLib.instancer takes a VF and pins one or more axes to a constant, producing a static TTF as output. It also drops fvar / gvar / STAT / HVAR so downstream consumers don’t get confused.

# 1. Get the upstream variable font (~17 MB for the SC subset).
curl -L -o /tmp/NotoSansSC-VF.ttf \
  https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/Variable/TTF/Subset/NotoSansSC-VF.ttf

# 2. Pin the wght axis to 400 (Regular) and write out a static TTF.
python -m pip install --user fonttools
python -m fontTools.varLib.instancer \
  /tmp/NotoSansSC-VF.ttf wght=400 \
  -o assets/fonts/noto/NotoSansSC-Regular.ttf

The resulting file is ~10.6 MB, contains only glyf outlines at wght=400, and renders identically through stb_truetype, FreeType, and the OS shaper. This is what we commit into the repo.

If you ever need a Bold variant, run the instancer again with wght=700 and update the kCjkPath switch — that’s it.

Codepoint coverage

engine::text.cpp::BuildCodepoints decides which characters are baked into the atlas:

void BuildCodepoints(CodepointSet cps) {
  g_codepoints.clear();
  for (int c = 0x20; c <= 0x7E; ++c) g_codepoints.push_back(c);  // ASCII

  if (cps == CodepointSet::AsciiPlusCJK) {
    for (int c = 0x3000; c <= 0x303F; ++c) g_codepoints.push_back(c);  // CJK punct
    for (int c = 0x4E00; c <= 0x9FFF; ++c) g_codepoints.push_back(c);  // Han
    for (int c = 0xFF00; c <= 0xFFEF; ++c) g_codepoints.push_back(c);  // Half/Full
  }
}

The Han range alone is ~21 000 codepoints. That’s not free.

VRAM budget: super-sampling and preload list

For ASCII we bake the atlas at 2× the render size to dodge stb_truetype’s small-size rounding artifacts (see the previous chapter). With CJK that doubles the atlas footprint:

setglyphssuper-sampleatlas (rough)
ASCII95~1 MB
ASCII+CJK21 200~30 MB
ASCII+CJK21 200~7 MB

Multiply by the number of preloaded sizes and the cost adds up fast.

So engine::text makes two adjustments when CJK is on:

  1. Drop super-sampling to 1×. CJK strokes are thicker than Latin ones — the half-pixel rounding artefacts that 2× was hiding aren’t visible on Han to begin with.
  2. Preload only one size (18pt). Lazy-load any other size on first use. ASCII still preloads {16, 18, 20, 24, 32} because the per-atlas cost is negligible.
constexpr int kPreloadAsciiSizes[] = {16, 18, 20, 24, 32};
constexpr int kPreloadCjkSizes[]   = {18};

constexpr int kSuperSampleAscii = 2;
constexpr int kSuperSampleCjk   = 1;

Wiring it up: raylib side

const char* PathFor(CodepointSet cps) {
  return cps == CodepointSet::AsciiPlusCJK ? kCjkPath : kRegularPath;
}

void LoadFonts(CodepointSet cps, float ui_scale) {
  if (!g_cache.empty()) return;  // idempotent
  g_cps = cps;
  g_ui_scale = (ui_scale > 0.0f) ? ui_scale : DetectUiScale();
  BuildCodepoints(cps);
  const char* path = PathFor(cps);
  if (cps == CodepointSet::AsciiPlusCJK) {
    for (int s : kPreloadCjkSizes)   g_cache[s] = LoadOne(path, s);
  } else {
    for (int s : kPreloadAsciiSizes) g_cache[s] = LoadOne(path, s);
  }
}

g_cps is exposed via engine::GetCodepointSet() so other layers can mirror our choice without a separate config.

Wiring it up: ImGui side

ImGuiLayer::LoadFonts reads the same global and picks both the font file and the glyph range to load:

void ImGuiLayer::LoadFonts(float dpi_scale) {
  ImGuiIO& io = ImGui::GetIO();
  const float font_size = kImGuiBaseFontSize * dpi_scale;
  const bool cjk = (engine::GetCodepointSet()
                    == engine::CodepointSet::AsciiPlusCJK);

  const std::filesystem::path path =
      cjk ? std::filesystem::path{"assets/fonts/noto/NotoSansSC-Regular.ttf"}
          : std::filesystem::path{"assets/fonts/noto/NotoSans-Regular.ttf"};

  const ImWchar* ranges =
      cjk ? io.Fonts->GetGlyphRangesChineseFull()
          : io.Fonts->GetGlyphRangesDefault();

  io.Fonts->Clear();
  if (std::filesystem::exists(path)) {
    io.FontDefault = io.Fonts->AddFontFromFileTTF(
        path.string().c_str(), font_size, nullptr, ranges);
  }
  if (io.FontDefault == nullptr) {
    io.FontDefault = io.Fonts->AddFontDefault();
  }
}

GetGlyphRangesChineseFull() covers Latin + Hiragana + Katakana + Half/Fullwidth + CJK Unified Ideographs — same set as our raylib-side codepoint list, give or take. The atlas ImGui builds is independent from the raylib atlas, but both end up with the same glyph repertoire.

For tighter VRAM, swap in GetGlyphRangesChineseSimplifiedCommon() — that ships ~2 500 of the most common simplified characters instead of the full ~21 000.

Testing

The placeholder scene writes a Chinese line so the regression is visible at a glance:

engine::DrawText("应无所住,而生其心。", Vector2{16, 48}, 24,
                 Color{220, 220, 240, 255});

If that line renders as tofu, your assets/fonts/noto/NotoSansSC-Regular.ttf is missing or the wrong file. If it renders as Thin (visibly skinny strokes), you grabbed the upstream VF directly without instancing it.