Dans un précédent article, on expliquait comment on a écrit une extension C pour connecter Ruby à l’électronique du baby-foot Tekbak via I2C. Mais le baby ne se contente pas de détecter des buts — il les affiche. Et c’est là que les choses se sont corsées.
Des afficheurs pas comme les autres
Les afficheurs de Tekbak ne sont pas des écrans LCD, ni des matrices LED standard qu’on trouve chez Adafruit. Ce sont des panneaux LED custom, fabriqués spécialement pour le baby-foot. Ils sont insérés sur les côtés de la table, sous un plaquage bois, visibles à travers une fente.
Les specs :
- 48 pixels de large, 7 pixels de haut — c’est tout
- On/Off uniquement — pas de niveaux de gris, pas de couleur, chaque pixel est soit allumé soit éteint
- Deux afficheurs — un de chaque côté de la table (hinge et lock)
- Connexion UART à 230400 baud via
/dev/ttyO1et/dev/ttyO2
Le challenge : le client veut pouvoir afficher des messages arbitraires — noms de joueurs, scores, “BUT !”, “PROLONGATION”, et tout ça dans plusieurs langues. Pas question de précalculer des bitmaps. Il faut un vrai rendu typographique, à 7 pixels de haut.
Le protocole UART
La communication avec les afficheurs est brutalement simple. On envoie un buffer de 336 octets (48 × 7) précédé d’un header de 2 octets :
[0x02] [0x01] [pixel₀] [pixel₁] ... [pixel₃₃₅]
Chaque octet vaut soit 0 (éteint) soit 255 (allumé). Le connecteur Ruby gère ça en quelques lignes :
class DisplayConnector < Connector
def sync_publish(payload, parameters={})
if payload.kind_of? Array
payload = payload.collect { |c| c.chr }.join
end
prefix = 2.chr + 1.chr
@serial.write(prefix + payload)
end
end
Les afficheurs répondent aussi : un TAP quand un joueur touche l’écran (détection capacitive), et ILU|128 pour le niveau de luminosité ambiante. Ces messages arrivent ligne par ligne via un thread de lecture dédié.
Recoder la rasterisation
Le vrai problème, c’est de transformer une chaîne de caractères en une grille de 48×7 pixels. On ne peut pas utiliser de bibliothèque de rendu HTML ou Cairo — on est sur un BeagleBone avec des contraintes mémoire, et le résultat doit être un simple tableau d’octets.
On s’est appuyé sur FreeType2 via la gem ft2-ruby pour accéder aux polices TrueType, mais toute la logique de composition — kerning, baseline, cadrage — a dû être recodée à la main.
Le pipeline de rendu
Passe 1 : les métriques
Avant de dessiner quoi que ce soit, on doit savoir quelle taille fera le texte rendu. Chaque caractère a son propre advance (largeur effective), son ascend (hauteur au-dessus de la baseline) et son descent (en dessous). Et entre chaque paire de caractères, il peut y avoir du kerning — un ajustement horizontal pour que “AV” ou “To” ne semblent pas trop espacés.
def compute_string_metrics(characters, face)
@width = 0
@ascend = 0
@baseline = 0
kerning = 0
previous_character = nil
characters.each_with_index do |character, index|
if previous_character
kerning = face.kerning(previous_character, character)[0]
end
@ascend = [@ascend, character.ascend].max
@baseline = [@baseline, character.descent].max
if index == characters.size - 1
@width += character.width # dernier : largeur réelle
else
@width += character.advance + kerning
end
previous_character = character
end
@rows = @ascend + @baseline
end
Le détail qui tue : pour le dernier caractère, on utilise width (la largeur du bitmap réel) au lieu de advance (qui inclut un espacement pour le caractère suivant). Sans ça, on a un espace vide à droite qui décale le centrage.
Passe 2 : le rendu pixel par pixel
Une fois les métriques calculées, on alloue un buffer et on y copie chaque glyphe :
def render(characters, face)
@buffer = [0] * (width * rows)
kerning = 0
previous_character = nil
offset_x = 0
characters.each_with_index do |character, index|
if previous_character
kerning = face.kerning(previous_character, character)[0]
end
offset_x += kerning
offset_y = rows - character.ascend - @baseline
copy_buffers(self, character, offset_x, offset_y)
offset_x += character.advance
previous_character = character
end
end
La fonction copy_buffers fait une copie pixel par pixel avec une gestion de transparence minimaliste : si le pixel source vaut 0, on ne l’écrit pas (on ne veut pas écraser un pixel déjà allumé d’un caractère précédent).
Le chargement des glyphes
Chaque Character est construit en demandant à FreeType de rasteriser le glyphe à 7 pixels de haut :
class Character
def initialize(character, face)
glyph = face.glyph_for_char(@char)
bitmap = glyph.bitmap
# On duplique TOUT ici car l'objet C de FreeType
# est écrasé à chaque appel à load_char...
@rows = bitmap.rows
@width = bitmap.width
@top = glyph.bitmap_top
@advance = glyph.advance[0] / 64
@descent = [0, @rows - @top].max
@ascend = [0, [@rows, @top].max - @descent].max
@buffer = bitmap.buffer.bytes
end
end
Le commentaire dans le code est un piège vécu : la gem ft2-ruby réutilise le même slot mémoire C pour chaque glyphe. Si on ne duplique pas les données immédiatement, le caractère précédent est silencieusement écrasé. On a mis un moment à comprendre pourquoi tous les caractères se transformaient en “e” (le dernier chargé).
Le threshold : de grayscale à on/off
FreeType produit des bitmaps en niveaux de gris (0-255). Nos afficheurs sont binaires — allumé ou éteint, pas d’entre-deux. Le Buffer fait la conversion :
def get_matrix
buffer.collect { |byte| byte > 127 ? @luminosity : 0 }
end
Le seuil à 127 est un compromis. Trop bas, les caractères deviennent gras et illisibles à cette résolution. Trop haut, on perd des pixels et certaines lettres se cassent. À 7 pixels de haut, chaque pixel compte.
Le scrolling
Un message comme “PROLONGATION” fait bien plus que 48 pixels de large. La solution : le scrolling horizontal, piloté par des timers EventMachine.
def start_animation(side, rendered_text)
position = [0, 0]
# centrage vertical
position[1] = [((screen_height - rendered_text.rows) / 2).to_i, 0].max
if rendered_text.width > @screen_buffer.width
start_text_scrolling(side, rendered_text, position)
else
# centrage horizontal + affichage statique
position[0] = [((screen_width - rendered_text.width) / 2).to_i, 0].max
keep_static_text(side, rendered_text, position)
end
end
Le scroll avance d’un pixel toutes les 70ms (environ 14 pixels/seconde). Quand le texte a fini de défiler, on attend 500ms puis on recommence :
def start_text_scrolling(side, rendered_text, position)
@animation_timers[side] = EM::PeriodicTimer.new(0.07) {
position[0] -= 1
render_message_step(side, rendered_text, position)
if rendered_text.width + position[0] <= @screen_buffer.width
@animation_timers[side].cancel
@animation_timers[side] = EM::Timer.new(0.5) {
position[0] = 0
start_text_scrolling(side, rendered_text, position)
}
end
}
end
Pour les textes courts (“4 - 2”, “BUT !”), on centre horizontalement et on rafraîchit toutes les 300ms en statique — les afficheurs n’ont pas de mémoire persistante.
Le système de messages à priorités
Le referee ne se contente pas d’envoyer un message et d’attendre. En pleine partie, on peut recevoir un score à afficher pendant qu’un flash “BUT !” est encore visible. Le service display gère trois niveaux de priorité :
@messages = {
lock: {
message: nil, # message par défaut (score, noms...)
flash: nil, # notification temporaire (0.9s)
error: nil, # erreur prioritaire
notice_timer: nil
}
}
À chaque événement, on affiche le message de plus haute priorité disponible : error > flash > message. Les flash disparaissent automatiquement après 0.9 seconde, et le message par défaut reprend.
Les polices
Trouver des polices TrueType lisibles à 7 pixels de haut, c’est un problème en soi. On a testé une dizaine de polices pixel-art avant de trouver celles qui marchent. La config finale :
{
"fonts": {
"pixelated": "./other/pixelated.ttf",
"Golden-Sun": "./other/Golden-Sun.ttf",
"PIXIES": "./other/PIXIES.ttf",
"Minecraftia": "./other/Minecraftia.ttf"
},
"default_font": "pixelated"
}
pixelated.ttf est la police par défaut — elle a été pensée pour du pixel art et rend bien à très basse résolution. Minecraftia est le fallback pour les caractères manquants. Le renderer charge toutes les polices au démarrage via FreeType :
def register_face(name, file_path)
ft_face = FT2::Face.new(file_path)
ft_face.set_pixel_sizes(0, @screen_height) # 0 = auto width, 7 = height
@faces[name.to_sym] = Face.new(ft_face, @screen_height)
end
Le set_pixel_sizes(0, 7) demande à FreeType de rasteriser les glyphes pour une hauteur de 7 pixels. Le hinting de FreeType fait un travail correct à cette taille, mais certains caractères accentués (é, è, ê) débordent ou perdent leur accent — on a dû ajuster les polices pour le français.
Le debug en ASCII art
Un détail qu’on a beaucoup apprécié : chaque rendu est loggé en ASCII art dans les logs du service :
def buffer_to_string(source)
result = ''
source.buffer.each_slice(source.width) do |s|
result << s.collect { |x| x == 0 ? '.' : '#' }.join('')
result << "\n"
end
result
end
Ce qui donne dans les logs :
rendering message "4 - 2" with font pixelated
rendered message:
..#..........#...
..#....###...#...
..#...........#..
#####..###...##..
..#..........#...
..#....###..#....
..#.........####.
En SSH sur un BeagleBone dans un bar, c’est le seul moyen de débugger ce qui s’affiche. On a beaucoup utilisé ça pour ajuster les polices et le threshold.
Ce qu’on a appris
FreeType est surpuissant mais bas-niveau. La lib fait un travail formidable de rasterisation, mais le compositing (kerning, baseline, alignement) est entièrement à notre charge. À 7 pixels de haut, chaque erreur d’un pixel est visible.
La gem ft2-ruby a des pièges. Le slot de glyphe partagé en mémoire C est un classique des bindings Ruby vers du C — l’objet Ruby semble exister mais les données en dessous ont changé. Il faut tout dupliquer immédiatement.
Le threshold binaire est impitoyable. Pas de dithering, pas d’antialiasing — un pixel est allumé ou éteint. À cette résolution, c’est le choix de la police qui fait 90% du travail, pas le code.
Les timers EventMachine marchent bien pour le scrolling. On s’attendait à des saccades, mais à 70ms par frame sur un ARM à 1 GHz, c’est fluide. Le fait que tout le service soit single-threaded (reactor pattern) évite les problèmes de concurrence sur les buffers.