builtin-programs/draw/text.folk
When the image library is /imageLib/ {
set cc [C]
$cc extend $imageLib
$cc include <math.h>
$cc struct GlyphInfo {
float advance;
float planeBounds[4];
float atlasBounds[4];
}
$cc struct Font {
Image atlasImage;
int gpuAtlasImage;
// TODO: This only handles ASCII, obviously.
GlyphInfo glyphInfos[128];
}
$cc struct vec2f { float x; float y; }
$cc proc vec2f_add {vec2f a vec2f b} vec2f {
return (vec2f) { a.x + b.x, a.y + b.y };
}
$cc proc vec2f_rotate {vec2f a float radians} vec2f {
return (vec2f) {
a.x*cosf(radians) + a.y*sinf(radians),
-a.x*sinf(radians) + a.y*cosf(radians)
};
}
$cc proc vec2f_toObj {vec2f a} Jim_Obj* {
Jim_Obj *v[] = { Jim_NewDoubleObj(interp, a.x), Jim_NewDoubleObj(interp, a.y) };
return Jim_NewListObj(interp, v, 2);
}
$cc proc charOrFallback {Font* font int ch} int {
if (ch < ' ' || ch >= sizeof(font->glyphInfos)/sizeof(font->glyphInfos[0])) {
return '?';
}
return ch;
}
$cc proc textExtent {Font* font char* text float scale} vec2f {
float em = scale;
float x = 0; float y = 0;
float width = 0;
for (int i = 0; text[i] != 0; i++) {
int ch = text[i];
if (ch == '\n') {
y = y + em; x = 0; continue;
}
ch = charOrFallback(font, ch);
x = x + font->glyphInfos[ch].advance * em;
if (x > width) { width = x; }
}
return (vec2f) { width, y };
}
$cc proc textShape {Jim_Obj* viewport Jim_Obj* surfaceToClip
Font* font char* text
float x0 float y0 float scale
float blockAnchorX float blockAnchorY float lineAnchorX float lineAnchorY
float radians Jim_Obj* color} Jim_Obj* {
vec2f extent = textExtent(font, text, scale);
float em = scale;
// The anchor origin goes from top-left (0.0) to bottom-right (1.0).
float blockOffsetX = -(blockAnchorX * extent.x);
// `lineAnchorY - 1` because the font is relative to the bottom, while we're relative to the top.
float blockOffsetY = -(blockAnchorY * extent.y + (lineAnchorY - 1) * em);
// Offset is rotated before adding (x0, y0), so that text is relative
// to (x0, y0).
vec2f rotatedBlockOffset = vec2f_rotate((vec2f) {blockOffsetX, blockOffsetY}, radians);
vec2f blockStart = (vec2f) { x0 + rotatedBlockOffset.x, y0 + rotatedBlockOffset.y };
// Need to get the initial line width so the line is relative to lineAnchorX. We
// later recalculate this whenever we hit '\n', but obviously that doesn't work at
// the start.
float lineWidth = 0.0;
for (int i = 0; text[i] != '\n' && text[i] != '\0'; i++) {
lineWidth += font->glyphInfos[charOrFallback(font, text[i])].advance * em;
}
// Get the string representations of the shared per-label args once,
// before the loop, so we don't call Jim_GetString (which may trigger
// UpdateStringOfDouble for every float) N times per glyph.
const char* sc_str = Jim_GetString(surfaceToClip, NULL);
const char* color_str = Jim_GetString(color, NULL);
const char* vp_str = Jim_GetString(viewport, NULL);
float atlas_w = (float)font->atlasImage.width;
float atlas_h = (float)font->atlasImage.height;
// Build the instances list as a pre-formatted Tcl list string.
// Each element is a brace-enclosed instance: {{sc} {atlas} {color} {vp} {a} {b} {c} {d} n}
// This avoids Jim ever calling UpdateStringOfList / ListElementQuotingType on instances.
int textLen = strlen(text);
int bufSize = (textLen + 1) * 320;
char* buf = (char*)malloc(bufSize);
int pos = 0;
int needSpace = 0;
// Relative character position (relative to (0, 0), not rotated).
float relCharX = 0;
float relCharY = 0;
for (int i = 0; text[i] != 0; i++) {
int ch = text[i];
if (ch == '\n') {
relCharX = 0;
relCharY += em;
lineWidth = 0.0;
for (int j = i + 1; text[j] != '\n' && text[j] != '\0'; j++) {
lineWidth += font->glyphInfos[charOrFallback(font, text[j])].advance * em;
}
continue;
}
ch = charOrFallback(font, ch);
GlyphInfo* glyphInfo = &font->glyphInfos[ch];
if (ch != ' ') {
// Calculate the absolute glyph position.
float lineOffsetX = -(lineAnchorX * lineWidth) - blockOffsetX;
// `lineOffsetY` doesn't exist, since it's already included in the `blockOffsetY` calculation.
vec2f rotatedLineOffset = vec2f_rotate((vec2f) { lineOffsetX, 0 }, radians);
vec2f combinedOffset = vec2f_add(blockStart, rotatedLineOffset);
vec2f charPos = vec2f_add(combinedOffset, vec2f_rotate((vec2f) { relCharX, relCharY }, radians));
float left = glyphInfo->planeBounds[0] * em;
float bottom = glyphInfo->planeBounds[1] * em;
float right = glyphInfo->planeBounds[2] * em;
float top = glyphInfo->planeBounds[3] * em;
vec2f topLeft = vec2f_add(charPos, vec2f_rotate((vec2f) {left, -top}, radians));
vec2f topRight = vec2f_add(charPos, vec2f_rotate((vec2f) {right, -top}, radians));
vec2f bottomRight = vec2f_add(charPos, vec2f_rotate((vec2f) {right, -bottom}, radians));
vec2f bottomLeft = vec2f_add(charPos, vec2f_rotate((vec2f) {left, -bottom}, radians));
if (needSpace) buf[pos++] = ' ';
needSpace = 1;
pos += snprintf(buf + pos, bufSize - pos,
"{{%s} {%g %g %g %g} {%s} {%s} {%g %g} {%g %g} {%g %g} {%g %g} %d}",
sc_str,
glyphInfo->atlasBounds[0] / atlas_w,
glyphInfo->atlasBounds[1] / atlas_h,
glyphInfo->atlasBounds[2] / atlas_w,
glyphInfo->atlasBounds[3] / atlas_h,
color_str,
vp_str,
topLeft.x, topLeft.y,
topRight.x, topRight.y,
bottomRight.x, bottomRight.y,
bottomLeft.x, bottomLeft.y,
font->gpuAtlasImage);
}
// Advance to next character position.
relCharX += glyphInfo->advance * em;
}
Jim_Obj* result = Jim_NewStringObj(interp, buf, pos);
free(buf);
return result;
}
$cc proc fontNew {Image atlasImage int gpuAtlasImage {GlyphInfo[128]} glyphInfos} Font* {
Font* font = (Font*) malloc(sizeof(Font));
font->atlasImage = atlasImage;
font->gpuAtlasImage = gpuAtlasImage;
memcpy(font->glyphInfos, glyphInfos, sizeof(font->glyphInfos));
return font;
}
$cc proc fontFree {Font* font} void {
free(font);
}
set fontLib [$cc compile]
When the image loader is /loadImage/ {
fn loadImage
fn loadFont {name} {
set csvFd [open "vendor/fonts/$name.csv" r]; set csv [read $csvFd]; close $csvFd
set fields [list ]
# HACK: Create list of null glyphs to initialize.
set glyphInfos [list]
for {set i 0} {$i < 128} {incr i} {
lappend glyphInfos {}
}
foreach line [split $csv "\n"] {
set values [lassign [split $line ,] glyphIdx]
if {![string is integer -strict $glyphIdx]} { continue }
lassign $values advance \
planeLeft planeBottom planeRight planeTop \
atlasLeft atlasBottom atlasRight atlasTop
lset glyphInfos $glyphIdx \
[dict create advance $advance \
planeBounds [list $planeLeft $planeBottom $planeRight $planeTop] \
atlasBounds [list $atlasLeft $atlasBottom $atlasRight $atlasTop]]
}
set defaultGlyphInfo [lindex $glyphInfos 63] ;# '?'
for {set i 0} {$i < [llength $glyphInfos]} {incr i} {
if {[lindex $glyphInfos $i] eq {}} {
lset glyphInfos $i $defaultGlyphInfo
}
}
set im [{*}$loadImage "[pwd]/vendor/fonts/$name.png"]
Wish the GPU loads image $im as texture
When the GPU has loaded image $im as texture /gim/ {
puts "text: Loaded $name as GPU texture $gim"
set font [$fontLib fontNew $im $gim $glyphInfos]
Claim the GPU has font $name with data $font \
-destructor [list $fontLib fontFree $font]
}
}
foreach fontPath [list {*}[glob vendor/fonts/*.png]] {
set fontName ""
regexp {vendor/fonts/(.*).png} $fontPath -> fontName
if {!($fontName eq "")} {
loadFont $fontName
}
}
}
Wish the GPU compiles function "glyphMsd" {
{sampler2D atlas vec4 atlasGlyphBounds vec2 glyphUv} vec4 {
vec2 atlasUv = mix(atlasGlyphBounds.xw, atlasGlyphBounds.zy, glyphUv);
return texture(atlas, vec2(atlasUv.x, 1.0-atlasUv.y));
}
}
Wish the GPU compiles function "median" {
{float r float g float b} float {
return max(min(r, g), min(max(r, g), b));
}
}
# HACK: (?) the push constant args are ordered to minimize padding so
# that it fits into 128 bytes.
Wish the GPU compiles pipeline "glyph" {
{mat3 surfaceToClip
vec4 atlasGlyphBounds
vec4 color
vec2 viewport
vec2 a vec2 b vec2 c vec2 d
sampler2D atlas
fn rotate} {
vec2 vertices[6] = vec2[6](a, b, c, a, c, d);
vec3 v = surfaceToClip * vec3(vertices[gl_VertexIndex], 1.0);
return vec4(v.xy/v.z, 0.0, 1.0);
} {fn rotate fn invBilinear fn glyphMsd fn median} {
vec2 clipXy = (gl_FragCoord.xy / viewport) * 2.0 - 1.0;
vec3 surfaceXy = inverse(surfaceToClip) * vec3(clipXy, 1.0);
surfaceXy /= surfaceXy.z;
vec2 glyphUv = invBilinear(surfaceXy.xy, a, b, c, d);
if( max( abs(glyphUv.x-0.5), abs(glyphUv.y-0.5))>=0.5 ) {
return vec4(0.0);
}
vec3 msd = glyphMsd(atlas, atlasGlyphBounds, glyphUv).rgb;
// https://blog.mapbox.com/drawing-text-with-signed-distance-fields-in-mapbox-gl-b0933af6f817
float sd = median(msd.r, msd.g, msd.b);
float uBuffer = 0.2;
float uGamma = 0.2;
float opacity = smoothstep(uBuffer - uGamma, uBuffer + uGamma, sd);
return (opacity < 0.01) ? vec4(0.0) : vec4(color.rgb, opacity * color.a);
}}
When the color map is /colorMap/ &\
/someone/ wishes to draw text onto /p/ with /...options/ {
if {[dict exists $options position]} {
lassign [dict get $options position] x0 y0
} else {
set x0 [dict get $options x]
set y0 [dict get $options y]
}
set scale [dict getdef $options scale 0.01] ;# 1cm default scale
set font [dict getdef $options font "PTSans-Regular"]
set text [dict get $options text]
set anchor [dict getdef $options anchor "center"]
set radians [dict getdef $options radians 0]
set color [dict getdef $options color white]
set color [dict getdef $colorMap $color $color]
set layer [dict getdef $options layer 0]
if {$anchor == "topleft"} {
set anchor [list 0 0 0 0]
} elseif {$anchor == "top"} {
set anchor [list 0.5 0 0.5 0]
} elseif {$anchor == "topright"} {
set anchor [list 1.0 0 1 0]
} elseif {$anchor == "left"} {
set anchor [list 0 0.5 0 0.5]
} elseif {$anchor == "center"} {
set anchor [list 0.5 0.5 0.5 0.5]
} elseif {$anchor == "right"} {
set anchor [list 1.0 0.5 1 0.5]
} elseif {$anchor == "bottomleft"} {
set anchor [list 0 1 0 1]
} elseif {$anchor == "bottom"} {
set anchor [list 0.5 1 0.5 1]
} elseif {$anchor == "bottomright"} {
set anchor [list 1 1 1 1]
}
When $p has canvas /id/ with /...wiOptions/ &\
$p has canvas projection /surfaceToClip/ &\
the GPU has font $font with data /fontData/ {
set wiResolution [list [dict get $wiOptions width] [dict get $wiOptions height]]
set instances [$fontLib textShape \
$wiResolution $surfaceToClip \
$fontData \
$text $x0 $y0 $scale {*}$anchor $radians $color]
# We need to batch into one wish so we don't deal with n^2
# checks for existing statements for n glyphs.
Wish the GPU draws pipeline "glyph" onto canvas $id \
with instances $instances layer $layer
}
}
}