Motore di rendering/animazione basato su testo per il terminale
Questo progetto è stato fortemente ispirato dal popolare progetto drawille, che consente di disegnare sul terminale utilizzando i caratteri unicode braille.
Il vantaggio di disegnare con caratteri braille rispetto ai normali caratteri ASCII è semplice: poiché ogni "carattere braille" è composto da 2 x 4 = 8
possibili punti, abbiamo 256
possibili varianti che possiamo disegnare per carattere. Questi modelli braille consentono un disegno molto più "fine/liscio".
La mia implementazione include anche un motore di rendering che consente di animare qualsiasi cosa venga disegnata sullo schermo utilizzando la libreria ncurses. La mia implementazione mira a essere molto performante:
- Utilizzo di una quantità minima di memoria.
- Avere un'ottima autonomia.
pur essendo facile da usare.
Ecco alcuni esempi che dimostrano cosa si può fare con questa libreria. Questi esempi si possono trovare anche in examples.c
:


Sono già abbastanza soddisfatto dell'implementazione della mia struttura a griglia, che archivia e accede ai dati in modo molto compatto. Sono curioso di sapere se le prestazioni della struttura di rendering possono essere ulteriormente migliorate? Sto già cercando di renderizzare solo ciò che è cambiato rispetto al fotogramma precedente, ma forse posso ottimizzarlo ancora di più?
Inoltre, non sono sicuro che la mia implementazione faccia buon uso delle linee guida di codifica in stile C. Inoltre, voglio assicurarmi che la libreria sia di facile utilizzo. Quindi, fammi sapere quale funzionalità ti aspetteresti (come utente) dall'API di questa libreria e se c'è qualcosa che ti manca quando la usi nello stato attuale.
griglia.c
#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <time.h>
#include "grid.h"
#include "unicode.h"
#include "constants.h"
grid *grid_new(int grid_width, int grid_height)
{
if ((grid_width % 2 != 0) || (grid_height % 4 != 0))
return NULL;
grid *p_grid = calloc(1, sizeof(*p_grid));
p_grid->width = grid_width;
p_grid->height = grid_height;
p_grid->buffer_size = grid_width / group_width * grid_height / group_height;
p_grid->buffer = calloc(p_grid->buffer_size, sizeof(int));
return p_grid;
}
void grid_free(grid *p_grid)
{
free(p_grid->buffer);
free(p_grid);
}
void grid_clear(grid *g)
{
for (int i = 0; i < g->buffer_size; ++i)
{
g->buffer[i] = 0x00;
}
}
void grid_fill(grid *g)
{
for (int i = 0; i < g->buffer_size; ++i)
{
g->buffer[i] = 0xFF;
}
}
void grid_print_buffer(grid *g, char* tag) {
printf(tag);
for (int i = 0; i < g->buffer_size; i++)
{
printf("0x%02x%s", g->buffer[i], i == g->buffer_size - 1 ? "\n" : ",");
}
}
void grid_modify_pixel(grid *g, int x, int y, int value)
{
// ToDo validate coords
int bytes_per_line = g->width / group_width;
int byte_idx = (x / group_width) + (y / group_height) * bytes_per_line;
int bit_idx = (x % group_width) * group_height + (y % group_height);
g->buffer[byte_idx] = (g->buffer[byte_idx] & ~(1 << bit_idx)) | (value << bit_idx);
}
void grid_set_pixel(grid *g, int x, int y)
{
grid_modify_pixel(g, x, y, 1);
}
void grid_unset_pixel(grid *g, int x, int y)
{
grid_modify_pixel(g, x, y, 0);
}
void grid_draw_line(grid *g, int x1, int y1, int x2, int y2)
{
// Bresenham's line algorithm
int x_diff = x1 > x2 ? x1 - x2 : x2 - x1;
int y_diff = y1 > y2 ? y1 - y2 : y2 - y1;
int x_direction = x1 <= x2 ? 1 : -1;
int y_direction = y1 <= y2 ? 1 : -1;
int err = (x_diff > y_diff ? x_diff : -y_diff) / 2;
while (1)
{
grid_set_pixel(g, x1, y1);
if (x1 == x2 && y1 == y2)
{
break;
}
int err2 = err;
if (err2 > -x_diff)
{
err -= y_diff;
x1 += x_direction;
}
if (err2 < y_diff)
{
err += x_diff;
y1 += y_direction;
}
}
}
void grid_draw_triangle(grid *g, int x1, int y1, int x2, int y2, int x3, int y3)
{
// ToDo: Add filling algorithm
grid_draw_line(g, x1, y1, x2, y2);
grid_draw_line(g, x2, y2, x3, y3);
grid_draw_line(g, x3, y3, x1, y1);
}
griglia.h
#ifndef GRID_H
#define GRID_H
typedef struct
{
int width;
int height;
int buffer_size;
int *buffer;
} grid;
grid *grid_new(int grid_width, int grid_height);
void grid_free(grid *p_grid);
void grid_clear(grid *g);
void grid_fill(grid *g);
void grid_print_buffer(grid *g, char* tag);
void grid_modify_pixel(grid *g, int x, int y, int value);
void grid_set_pixel(grid *g, int x, int y);
void grid_unset_pixel(grid *g, int x, int y);
void grid_draw_line(grid *g, int x1, int y1, int x2, int y2);
void grid_draw_triangle(grid *g, int x1, int y1, int x2, int y2, int x3, int y3);
#endif
renderer.c
#include "grid.h"
#include "unicode.h"
#include "renderer.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "constants.h"
#include <ncurses.h>
#include <unistd.h>
#include <locale.h>
render_context* p_render_context;
const int braille_offset = 0x2800;
const int TRANSFORMATION_MATRIX[8] ={ 0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80 };
wchar_t lookup_table[256] ={};
void renderer_new(grid *p_grid) {
// Set locale for ncurses to process unicode correctly
setlocale(LC_ALL, "");
// Generate braille lookup table
grid_generate_lookup_table();
// Create copy of initial grid for caching, but zero out buffer
grid *p_cached_grid = calloc(1, sizeof(*p_grid));
p_cached_grid->width = p_grid->width;
p_cached_grid->height = p_grid->height;
p_cached_grid->buffer_size = p_grid->buffer_size;
p_cached_grid->buffer = calloc(p_grid->buffer_size, sizeof(int));
// Store cached grid in render_context
p_render_context = calloc(1, sizeof(*p_render_context));
p_render_context->p_cached_grid = p_cached_grid;
p_render_context->frames_rendered = 0;
// Initialize ncurses
initscr();
noecho();
curs_set(0);
}
void renderer_update(grid* p_grid)
{
// Notes:
// Should only render the characters that changed from current grid buffer to the cached one
// Iterate over grid and look for differences to cached_grid
for (int i = 0; i < p_grid->buffer_size; i++)
{
// Difference was found, note that this character must be re-rendered
if (p_grid->buffer[i] != p_render_context->p_cached_grid->buffer[i]) {
// Compute row and column index of the character we need to re-render
int pos_x = i % (p_render_context->p_cached_grid->width / group_width);
int pos_y = i / (p_render_context->p_cached_grid->width / group_width);
// Obtain correct braille character
char uc[5];
int braille = lookup_table[p_grid->buffer[i]];
int_to_unicode_char(braille, uc);
// Linebreak if we reached the right end of the grid
if (i % (p_grid->width / group_width) == 0 && i != 0)
{
printw("\n");
}
// Render the braille character at the position that changed
mvprintw(pos_y, pos_x, uc);
//printw("Change index %i [%i->%i] Rerendering coordinate (%i, %i).\n", i, p_render_context->p_cached_grid->buffer[i], p_grid->buffer[i], pos_x, pos_y);
}
}
// ToDo: Update p_cached_grid
p_render_context->frames_rendered++;
//grid_print_buffer(p_render_context->p_cached_grid, "cached: ");
//grid_print_buffer(p_grid, "current: ");
// Update cached buffer with current one
memcpy(p_render_context->p_cached_grid->buffer, p_grid->buffer, sizeof(int) * p_grid->buffer_size);
// Sleep some milliseconds so that changes are visible to the human eye
napms(render_delay_ms);
// Refresh terminal to render changes
refresh();
}
void renderer_free()
{
// Wait before all allocations are free'd
napms(2000);
// Free all allocations and end ncurses window
free(p_render_context->p_cached_grid->buffer);
free(p_render_context->p_cached_grid);
free(p_render_context);
endwin();
}
void grid_generate_lookup_table()
{
for (int i = 0; i < 256; ++i)
{
int unicode = braille_offset;
for (int j = 0; j < 8; ++j)
{
if (((i & (1 << j)) != 0))
{
unicode += TRANSFORMATION_MATRIX[j];
}
}
lookup_table[i] = unicode;
}
}
renderer.h
#ifndef RENDERER_H
#define RENDERER_H
#include "grid.h"
typedef struct {
grid* p_cached_grid;
int frames_rendered;
} render_context;
void renderer_new(grid* p_grid);
void renderer_update(grid* p_grid);
void renderer_free();
void grid_generate_lookup_table();
#endif
unicode.c
void int_to_unicode_char(unsigned int code, char *chars)
{
if (code <= 0x7F)
{
chars[0] = (code & 0x7F);
chars[1] = '\0';
}
else if (code <= 0x7FF)
{
// one continuation byte
chars[1] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[0] = 0xC0 | (code & 0x1F);
chars[2] = '\0';
}
else if (code <= 0xFFFF)
{
// two continuation bytes
chars[2] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[1] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[0] = 0xE0 | (code & 0xF);
chars[3] = '\0';
}
else if (code <= 0x10FFFF)
{
// three continuation bytes
chars[3] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[2] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[1] = 0x80 | (code & 0x3F);
code = (code >> 6);
chars[0] = 0xF0 | (code & 0x7);
chars[4] = '\0';
}
else
{
// unicode replacement character
chars[2] = 0xEF;
chars[1] = 0xBF;
chars[0] = 0xBD;
chars[3] = '\0';
}
}
unicode.h
#ifndef UNICODE_H
#define UNICODE_H
void int_to_unicode_char(unsigned int code, char *chars);
#endif
costanti.c
const int group_height = 4;
const int group_width = 2;
const int render_delay_ms = 10;
costanti.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
extern const int group_height;
extern const int group_width;
extern const int render_delay_ms;
#endif
esempi.c
#include <math.h>
#include "grid.h"
#include "renderer.h"
#include <stdio.h>
void example_filling_bar()
{
int width = 100;
int height = 24;
grid *g = grid_new(width, height);
renderer_new(g);
// Fill grid from left to right (simple animation)
renderer_update(g);
for (int i = 0; i < width; i++)
{
for (int j = 0; j < height; j++)
{
grid_set_pixel(g, i, j);
}
renderer_update(g);
}
// Free allocations
renderer_free();
grid_free(g);
}
void example_build_block()
{
int width = 100;
int height = 40;
grid *g = grid_new(width, height);
renderer_new(g);
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
grid_set_pixel(g, x, y);
renderer_update(g);
}
}
// Free allocations
renderer_free();
grid_free(g);
}
void example_sine_tracking()
{
int width = 124;
int height = 40;
grid *g = grid_new(width, height);
renderer_new(g);
double shift = 0;
while (1)
{
grid_clear(g);
// Draw line
grid_draw_line(g, 0, height / 2, width - 1, (height + sin(shift) * height) / 2);
// Draw curve
for (int j = 0; j < width; j++)
{
grid_set_pixel(g, j, (height / 2 * sin(0.05 * j + shift) + height / 2));
}
// Move curve
shift += 0.05;
renderer_update(g);
}
// Free allocations
renderer_free();
grid_free(g);
}
void example_spiral_effect()
{
int width = 60;
int height = 32;
grid *g = grid_new(width, height);
renderer_new(g);
// Start with an empty grid
grid_clear(g);
int m = width, n = height;
int sr = 0, sc = 0, er = m - 1, ec = n - 1;
while (sr <= er && sc <= ec)
{
for (int i = sc; i <= ec; ++i)
{
grid_set_pixel(g, sr, i);
renderer_update(g);
}
for (int i = sr + 1; i <= er; ++i)
{
grid_set_pixel(g, i, ec);
renderer_update(g);
}
for (int i = ec - 1; sr != er && i >= sc; --i)
{
grid_set_pixel(g, er, i);
renderer_update(g);
}
for (int i = er - 1; sc != ec && i > sr; --i)
{
grid_set_pixel(g, i, sc);
renderer_update(g);
}
sr++, sc++;
er--, ec--;
}
// Free allocations
renderer_free();
grid_free(g);
}
esempi.h
#ifndef EXAMPLES_H
#define EXAMPLES_H
#include "grid.h"
void example_filling_bar();
void example_build_block();
void example_sine_tracking();
void example_spiral_effect();
#endif
principale.c
#include <stdio.h>
#include <unistd.h>
#include <math.h>
#include "examples.h"
int main()
{
//example_sine_tracking();
//example_build_block();
example_spiral_effect();
return 0;
}
Ed infine il Makefile per compilare il tutto:
prog:
gcc -g -o dots examples.c constants.c grid.c unicode.c renderer.c main.c -Wall -Werror -lncursesw -lm
clean:
rm dots
Apprezzo ogni feedback! Il progetto è disponibile anche su GitHub:https://github.com/766F6964/DotDotDot
Nota : durante il test, assicurati di avere installato un carattere terminale in grado di visualizzare correttamente i caratteri braille, altrimenti sembrerà incasinato.
Risposte
Codice piuttosto interessante.
Piccola rassegna di alcuni problemi collaterali.
sizeof *ptr
vs.sizeof type
Codice ben utilizzato sizeof *ptr
in 2 casi su 3.
grid *p_cached_grid = calloc(1, sizeof(*p_grid));
p_cached_grid->buffer = calloc(p_grid->buffer_size, sizeof(int)); // why sizeof(int)
p_render_context = calloc(1, sizeof(*p_render_context));
Consiglio di continuare così
// p_cached_grid->buffer = calloc(p_grid->buffer_size, sizeof(int));
p_cached_grid->buffer = calloc(p_grid->buffer_size, sizeof *(p_cached_grid->buffer));
// or
p_cached_grid->buffer = calloc(p_grid->buffer_size, sizeof p_cached_grid->buffer[0]);
// or other variations.
Gestione impropria dei surrogati
Sebbene non sia importante per questo codice, è meglio rilevare i surrogati e magari gestirli come un errore (forma carattere di sostituzione Unicode).
Algoritmo di linea di Bresenham
Un'implementazione migliore del solito.
Per questo codice, nessun problema riscontrato.
In generale il codice fallisce quando x1 - x2
o va in y1 - y2
overflow. Ci sono modi per gestirlo usando unsigned
per gestire la differenza senza ricorrere a una matematica più ampia.
Pubblicherei del codice di esempio, ma il mio codice di riferimento non è aggiornato.
Usa le costanti denominate in modo coerente
Hai definito grid_width
e grid_height
, molto bene, ma sfortunatamente non lo stai usando in modo coerente. Ad grid_new()
esempio, la prima riga può essere sostituita con:
if ((grid_width % group_width != 0) || (grid_height % group_height != 0))
Inoltre, è consuetudine avere costanti globali come queste scritte in TUTTO MAIUSCOLO, quindi è più facile distinguerle dalle variabili.
Utilizzarememset()
Hai scritto loop in grid_clear()
e grid_fill()
, ma puoi facilmente eseguire questa attività con memset(), che è più probabile che sia ottimizzato. Di sicuro, grid_clear()
può essere riscritto per fare memset(g->buffer, 0, g->buffer_size * sizeof(*g->buffer))
. Se g->buffer
era un uint8_t *
, allora puoi usare anche memset()
inside grid_fill()
.
Utilizzare uint8_t
per la griglia
Stai usando solo 8 bit per ogni carattere nella griglia, quindi puoi memorizzarlo in un uint8_t
file anziché in un file int
. Ciò riduce l'utilizzo della memoria della griglia di un fattore 4 e consente anche memset()
di essere utilizzato in file grid_fill()
.
Prendi in considerazione l'hardcoding della tabella di ricerca
Potresti pensare, che bestemmia è questa?! Tutti sanno che dovresti evitare di codificare le cose! Ma in questo caso, i caratteri Unicode Braille sono scolpiti nella pietra e stai sprecando molto codice per generare i caratteri e alcuni cicli della CPU ogni volta che avvii il tuo programma, quando puoi semplicemente scrivere:
wchar_t lookup_table[256] = L"⠁⠂⠃⠄⠅⠆⠇⡀⡁⡂⡃⡄⡅⡆⡇"
L"⠈⠉⠊⠋⠌⠍⠎⠏... "
...
L" ...⣿";
Prendi in considerazione l'utilizzo di ncursesw
Invece di dover convertire tu stesso da wchar_t
in una stringa UTF-8, puoi usare la versione ampia di ncurses che ti consente di stampare wchar_t
direttamente le stringhe. Dalla versione 6 di ncurses, questo è incluso per impostazione predefinita e per stampare stringhe larghe puoi usare mvaddwstr()invece di mvprintw()
.
Considera di non memorizzare tu stesso la griglia nella cache
Una grande caratteristica di ncurses è che memorizza nella cache ciò che è sullo schermo e invia solo i caratteri e i codici di controllo necessari al terminale per aggiornare ciò che è stato realmente modificato. Stai facendo lo stesso tu stesso, duplicando così ciò che sta facendo ncurses.
Vedo due modi per sbarazzarsi di questa inefficienza. Innanzitutto, puoi eliminare del tutto i tuoi buffer e scrivere direttamente sullo schermo con le funzioni curses. Ovviamente, se devi aggiornare un singolo punto in un carattere Braille, devi sapere quale schema Braille è già presente sullo schermo. Puoi rileggere il contenuto dello schermo con comandi come mvin_wch(). Lo svantaggio è che la rilettura di singoli caratteri potrebbe comportare molte chiamate di funzione e devi decodificare il carattere Braille in una maschera di bit.
Un'altra opzione è mantenere un singolo buffer e dare l'intero buffer a ncurses ad ogni aggiornamento. Se ritieni che sia inefficiente, considera che tu stesso stavi copiando l'intero buffer nel buffer memorizzato nella cache ogni aggiornamento. Se procedi in questo modo, probabilmente vorrai avere il buffer originale per una facile manipolazione dei singoli punti e un secondo buffer di tipo wchar_t *
che aggiorni in parallelo e che puoi inviare a ncurses per stampare in una volta sola. Nota, c'è anche un wmemset()che potrebbe essere utile qui.
Suggerirei di optare per la seconda opzione. Dovresti iniziare a eseguire il benchmarking del tuo codice in modo da poter misurare le sue prestazioni in modo obiettivo.