atlas: bit-pack pixel storage (1 bit/pixel) for 3.15× bundle shrink

Unifont glyphs are inherently 1-bit (every pixel is 0 or 255). Storing as
8-bit bytes wasted 7 bits per pixel.

Changes:
- scripts/gen-atlas.ts: emit ATLAS_PIXELS as bit-packed MSB-first. OFFSETS
  now indexes bits not bytes. Per-cell paint thresholds R-channel at 128
  defensively (Unifont is already 0/255).
- src/core/render.ts:blitGlyph: byte read replaced with bit extraction
  (~3 extra ops per pixel; negligible). max() blending removed since bit
  data is binary, no AA edges, no glyph overlap.
- src/core/atlas.ts: regenerated with the new format. Header comment
  documents the bit-extraction formula.
- tests/render.test.ts: +1 structural assertion that ATLAS_PIXELS.byteLength
  matches the bit-packed total.

Bundle size:
  atlas.ts raw:    5.47 MB → 1.06 MB  (5.2× shrink)
  atlas.ts gzip:   1.55 MB → 479 KB   (3.2× shrink)
  dist gzipped:    1.57 MB → 488 KB   (3.15× shrink, well under Workers
                                       free-tier 1 MB cap)

Pre-deflate shrink is 8× (theoretical max). The 3.15× post-deflate
result reflects that deflate was already exploiting long runs of 0/255
in the 8-bit data; the bit-packed bytes are denser and less compressible.

Render output is byte-identical to pre-bit-pack (storage format change
only). 108/108 vitest, 4/4 shell tests, typecheck + build clean.
This commit is contained in:
teamchong
2026-05-18 21:54:07 -04:00
parent efcd2a5e3e
commit e328c93d52
4 changed files with 100 additions and 25 deletions
+50 -15
View File
@@ -209,17 +209,28 @@ const narrowCtx = narrowCanvas.getContext('2d');
narrowCtx.font = `${FONT_PX}px ${FONT_FAMILY}`;
narrowCtx.textBaseline = 'alphabetic';
// Pixel data is BIT-PACKED, MSB-first. Unifont glyphs are inherently 1-bit
// (every pixel is exactly 0 or 255 — no antialiasing), so storing as bytes
// wastes 7 bits/pixel. The runtime decoder extracts the bit at index
// bitIdx = OFFSETS[rank] + row * srcW + col
// where srcW is `cellW` for narrow glyphs or `2*cellW` for wide (CJK) ones —
// driven by WIDE_FLAGS[rank]. OFFSETS is now a BIT offset (was byte offset
// in the prior 8-bit format). Bit indices fit comfortably in Uint32:
// 35k glyphs × 10×11 px max = ~3.9M bits, well under 2^32.
const codepoints = new Uint32Array(found.length);
const offsets = new Uint32Array(found.length);
const wideFlags = new Uint8Array(found.length);
const cellSlices: Uint8Array[] = [];
let totalBytes = 0;
// Collect per-glyph bit slices first; we'll pack them into a single
// MSB-first bitstream at the end so glyph N starts exactly where glyph N-1
// ended (no byte alignment between glyphs).
const cellBitSlices: Uint8Array[] = []; // one entry per glyph; values are 0 or 1
let totalBits = 0;
for (let i = 0; i < found.length; i++) {
const { cp, wide } = found[i]!;
codepoints[i] = cp;
wideFlags[i] = wide ? 1 : 0;
offsets[i] = totalBytes;
offsets[i] = totalBits;
const w = wide ? 2 * cellW : cellW;
const ctx = wide ? wideCtx : narrowCtx;
@@ -231,20 +242,36 @@ for (let i = 0; i < found.length; i++) {
ctx.fillText(String.fromCodePoint(cp), 0, ascent);
const img = ctx.getImageData(0, 0, w, cellH);
const buf = new Uint8Array(w * cellH);
for (let p = 0; p < buf.length; p++) buf[p] = img.data[p * 4]!;
cellSlices.push(buf);
totalBytes += buf.length;
// Threshold the R channel to 1-bit. Unifont is already 0/255 black-and-
// white so the threshold is academic, but use ≥128 for safety against
// any subpixel-rendering surprises.
const bits = new Uint8Array(w * cellH);
for (let p = 0; p < bits.length; p++) bits[p] = img.data[p * 4]! >= 128 ? 1 : 0;
cellBitSlices.push(bits);
totalBits += bits.length;
}
// Pack into MSB-first bitstream: bit at index B lives in byte B>>3 at
// position 7-(B&7). Total bytes = ceil(totalBits / 8).
const totalBytes = (totalBits + 7) >>> 3;
const pixels = new Uint8Array(totalBytes);
{
let off = 0;
for (const s of cellSlices) {
pixels.set(s, off);
off += s.length;
let bitOff = 0;
for (const bits of cellBitSlices) {
for (let p = 0; p < bits.length; p++) {
if (bits[p]!) {
const byteIdx = bitOff >>> 3;
const bitShift = 7 - (bitOff & 7);
pixels[byteIdx]! |= 1 << bitShift;
}
bitOff++;
}
}
}
console.log(
`[gen-atlas] bit-packed pixel storage: ${totalBits} bits → ${totalBytes} bytes ` +
`(was ${totalBits} bytes at 8-bit; ${(totalBits / totalBytes).toFixed(1)}× pre-deflate shrink)`,
);
// --- Encode binary blobs as base64 ----------------------------------------
// JSON array literals of 41k numbers would blow up atlas.ts to several MB
@@ -313,14 +340,22 @@ function decodeU32(b64: string): Uint32Array {
* at \`rank\` in OFFSETS / WIDE_FLAGS / PIXELS. */
export const ATLAS_CODEPOINTS: Uint32Array = /* @__PURE__ */ decodeU32(CODEPOINTS_B64);
/** Byte offset into ATLAS_PIXELS for the glyph at each rank. */
/** BIT offset into ATLAS_PIXELS for the glyph at each rank. (Was byte offset
* in the prior 8-bit format.) */
export const ATLAS_OFFSETS: Uint32Array = /* @__PURE__ */ decodeU32(OFFSETS_B64);
/** 1 if the glyph at this rank is double-wide (East Asian Wide), 0 otherwise. */
export const ATLAS_WIDE_FLAGS: Uint8Array = /* @__PURE__ */ decodeB64(WIDE_FLAGS_B64);
/** Packed grayscale pixel buffer. Glyph at rank \`r\` occupies
* \`OFFSETS[r] .. OFFSETS[r] + (WIDE_FLAGS[r] ? 2 : 1) * CELL_W * CELL_H\`. */
/** Bit-packed 1-bit pixel data, MSB-first. Unifont glyphs are inherently
* 1-bit (every pixel is 0 or 255 — no antialiasing), so we store 8 px per
* byte. The runtime decoder (blitGlyph) extracts:
* bitIdx = OFFSETS[rank] + row * srcW + col
* byteIdx = bitIdx >>> 3
* bitOff = 7 - (bitIdx & 7)
* pixel = (ATLAS_PIXELS[byteIdx] >>> bitOff) & 1 // 0 or 1
* where srcW is CELL_W (narrow) or 2*CELL_W (wide, per WIDE_FLAGS[rank]).
* Output framebuffer maps 0 → 0 (background) and 1 → 255 (full ink). */
export const ATLAS_PIXELS: Uint8Array = /* @__PURE__ */ decodeB64(PIXELS_B64);
/** Number of glyphs in the atlas. */
@@ -345,5 +380,5 @@ export function atlasRank(codepoint: number): number {
writeFileSync(OUT_PATH, banner + body);
console.log(
`[gen-atlas] wrote ${OUT_PATH} ` +
`(${pixels.length} pixel bytes, ${pixelsB64.length} b64 chars; total file ~${Math.round((banner.length + body.length) / 1024)} KB)`,
`(${pixels.length} pixel bytes packed from ${totalBits} bits, ${pixelsB64.length} b64 chars; total file ~${Math.round((banner.length + body.length) / 1024)} KB)`,
);
+12 -4
View File
File diff suppressed because one or more lines are too long
+13 -5
View File
@@ -186,15 +186,23 @@ function blitGlyph(
if (rank < 0) return 0;
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
// ATLAS_OFFSETS is a BIT offset since the bit-pack slice. To read pixel
// (gx, gy) of this glyph: bitIdx = srcOff + gy*srcW + gx, then extract
// the MSB-first bit at byte (bitIdx >>> 3), position 7 - (bitIdx & 7).
// Output pixel = 0 (background) or 255 (full ink).
const srcOff = ATLAS_OFFSETS[rank]!;
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
const dstRow = (y + gy) * fbW + x;
const srcRow = srcOff + gy * srcW;
const bitRowStart = srcOff + gy * srcW;
for (let gx = 0; gx < srcW; gx++) {
const v = ATLAS_PIXELS[srcRow + gx]!;
// max() blending preserves antialiased edges if glyphs overlap (they
// shouldn't in our grid layout, but the math is correct either way).
if (v > fb[dstRow + gx]!) fb[dstRow + gx] = v;
const bitIdx = bitRowStart + gx;
const byte = ATLAS_PIXELS[bitIdx >>> 3]!;
const bit = (byte >>> (7 - (bitIdx & 7))) & 1;
if (bit) {
// 1-bit pixel: full ink. No max() blending needed since glyphs
// never overlap in our grid layout — set unconditionally.
fb[dstRow + gx] = 255;
}
}
}
return wide ? 2 : 1;
+25 -1
View File
@@ -7,7 +7,14 @@ import {
} from '../src/core/render.js';
import { encodeGrayPng, bytesToBase64 } from '../src/core/png.js';
import { transformRequest } from '../src/core/transform.js';
import { atlasRank, ATLAS_CELL_H } from '../src/core/atlas.js';
import {
atlasRank,
ATLAS_CELL_H,
ATLAS_CELL_W,
ATLAS_PIXELS,
ATLAS_WIDE_FLAGS,
ATLAS_NUM_GLYPHS,
} from '../src/core/atlas.js';
describe('png encoder', () => {
it('produces a valid PNG signature', async () => {
@@ -169,6 +176,23 @@ describe('renderer', () => {
expect(atlasRank('⑩'.codePointAt(0)!)).toBeGreaterThanOrEqual(0);
});
it('atlas pixel storage is bit-packed (1 bit per pixel)', () => {
// Sanity check on the storage format. Total pixels across all glyphs =
// numGlyphs × cellH × (cellW or 2*cellW) depending on each glyph's
// wide-flag. The packed byte buffer must be ceil(totalBits / 8) bytes
// — within 1 byte of the theoretical minimum.
let totalBits = 0;
for (let r = 0; r < ATLAS_NUM_GLYPHS; r++) {
const srcW = ATLAS_WIDE_FLAGS[r] === 1 ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
totalBits += srcW * ATLAS_CELL_H;
}
const expectedBytes = Math.ceil(totalBits / 8);
expect(ATLAS_PIXELS.byteLength).toBe(expectedBytes);
// Hard guarantee: the 8-bit format would have used totalBits bytes, so
// bit-packed is exactly 8× smaller (modulo the rounding to whole bytes).
expect(ATLAS_PIXELS.byteLength).toBeLessThanOrEqual(totalBits / 8 + 1);
});
it('atlas covers Hangul Syllables (한 글 안 녕) — full-bmp profile only', () => {
// The default profile is now `full-bmp`, which ships ~11k Hangul
// Syllables (U+AC00..U+D7AF). The `practical` profile drops these