65,535 Vertices: When Text Export Breaks Your Renderer
Render a simple rectangle. Works.
Render a star shape. Works.
Render flattened text saying "Hello World". Crash.
The error: vertex index out of bounds.
The problem: we used Uint16 for vertex indices. Maximum value: 65,535.
The flattened text had 100,000+ vertices.
The Problem
Vector networks store vertices in an array, segments reference them by index:
struct Segment {
uint16_t fromVertex; // Index into vertices array
uint16_t toVertex;
};
uint16_t can represent 0–65,535. Seemed like plenty. Who needs 65,000 vertices in a single shape?
Figma users exporting flattened text, apparently.
When you "flatten" text in Figma, it converts font outlines to vector paths. A single letter might have dozens of curves. A word has hundreds. A paragraph has tens of thousands.
Export a text-heavy design. Vertices: 100,000+. Our renderer: "Nope."
First Attempt: Validate and Reject
Add bounds checking:
uint32_t addVertex(float x, float y) {
if (fVertices.size() >= 65535) {
throw std::runtime_error("Too many vertices");
}
fVertices.push_back({x, y});
return fVertices.size() - 1;
}
This prevented crashes. But it also prevented rendering valid Figma exports.
Not a solution—a band-aid.
The Solution: Migrate to Uint32
Change all vertex indices from uint16_t to uint32_t:
struct Segment {
uint32_t fromVertex; // Now supports 4 billion indices
uint32_t toVertex;
};
Seems simple. Except vertex indices are everywhere:
- Segment storage
- Loop definitions
- Corner radius virtual vertices
- WASM bindings (packed data format)
- JavaScript typed arrays (Uint16Array → Uint32Array)
The Migration Process
Step 1: Change C++ data structures:
// Before
skia_private::TArray fSegmentIndices;
// After
skia_private::TArray fSegmentIndices;
Step 2: Update WASM bindings:
// Before
typedef uintptr_t WASMPointerU16;
// After (add new type)
typedef uintptr_t WASMPointerU32;
// Update binding signatures
.function("_addSegments", optional_override([](
CfVectorNetwork& vn,
WASMPointerU32 fromToPtr, // Changed from WASMPointerU16
int fromToLength,
// ...
)))
Step 3: Update JavaScript arrays:
// Before
const segments = new Uint16Array([0, 1, 1, 2, 2, 3]);
// After
const segments = new Uint32Array([0, 1, 1, 2, 2, 3]);
Step 4: Update memory allocation:
// Before: 2 bytes per index
var ptr = CanvasKit._malloc(count * 2);
// After: 4 bytes per index
var ptr = CanvasKit._malloc(count * 4);
Step 5: Fix packed data formats:
Many functions packed multiple values into single arrays. Changing from Uint16 to Uint32 doubled the memory layout size:
// Before: [from0, to0, from1, to1, ...] - 2 bytes each
// After: [from0, to0, from1, to1, ...] - 4 bytes each
// Same logical structure, different memory size
All the memory management code needed updating.
The Memory Cost
Going from 16-bit to 32-bit indices doubles the index storage:
Before:
- 1000 segments = 2000 indices × 2 bytes = 4KB
After:
- 1000 segments = 2000 indices × 4 bytes = 8KB
For typical scenes (hundreds of segments), this is negligible. For complex scenes (thousands of segments), it's still <1MB.
The trade-off: 2× index memory for unlimited vertex count.
Worth it to support real-world Figma exports.
Results
Vector networks now support up to 4,294,967,295 vertices (2^32). Flattened text renders correctly regardless of complexity.
The migration touched:
- ~15 C++ files (data structure changes)
- ~10 binding functions (WASM interface)
- ~8 JavaScript files (typed array handling)
- ~200 lines of code changes total
Not a huge change, but pervasive—indices are used everywhere in a vector graphics system.
The lesson: don't assume your "generous" limits are actually generous. Real-world data breaks assumptions. Flattened text routinely exceeds 65K vertices.
Design for the actual data, not the expected data.
Read next: Loop Detection: Finding Holes in Polygon Soup - Unordered segments to renderable topology.