Mecanismo de renderização/animação baseado em texto para o terminal

Aug 17 2020

Este projeto foi altamente inspirado no popular projeto drawille, que permite desenhar no terminal usando os caracteres braille unicode.

A vantagem de desenhar com caracteres braille em comparação com caracteres ASCII normais é simples: como cada "caractere braille" é composto de 2 x 4 = 8pontos possíveis, temos 256variantes possíveis que podemos desenhar por caractere. Esses padrões de braille permitem um desenho muito "mais fino/suave".

Minha implementação também vem com um mecanismo de renderização que permite animar o que for desenhado na tela usando a biblioteca ncurses. Minha implementação visa por muito desempenho por:

  1. Usando quantidade mínima de memória.
  2. Tendo muito bom tempo de execução.

enquanto ainda é fácil de usar.

Aqui estão alguns exemplos que demonstram o que pode ser feito com esta biblioteca. Esses exemplos também podem ser encontrados em examples.c:

Já estou bastante satisfeito com a implantação da minha estrutura em grid, que armazena e acessa dados de forma bastante compacta. Estou curioso para saber se o desempenho da estrutura de renderização pode ser melhorado ainda mais? Já estou tentando renderizar apenas o que mudou em relação ao quadro anterior, mas talvez eu possa otimizá-lo ainda mais?

Além disso, não tenho certeza se minha implementação faz bom uso das diretrizes de codificação do estilo C. Além disso, quero ter certeza de que a biblioteca é amigável. Portanto, deixe-me saber qual funcionalidade você (como usuário) esperaria da API desta biblioteca e se há algo que você sente falta ao usá-la no estado atual.

grid.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);
}

grade.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

renderizador.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;
    }
}

renderizador.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

constantes.c

const int group_height = 4;
const int group_width = 2;
const int render_delay_ms = 10;

constantes.h

#ifndef CONSTANTS_H
#define CONSTANTS_H

extern const int group_height;
extern const int group_width;
extern const int render_delay_ms;

#endif

exemplos.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);
}

exemplos.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

main.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;
}

E por fim, o Makefile para compilar tudo:

prog:
    gcc -g -o dots examples.c constants.c grid.c unicode.c renderer.c main.c -Wall -Werror -lncursesw -lm
clean:
    rm dots

Agradeço cada feedback! O projeto também está disponível no GitHub:https://github.com/766F6964/DotDotDot

Observação : ao testar isso, certifique-se de ter uma fonte de terminal instalada que possa exibir caracteres braille corretamente, caso contrário, parecerá confusa.

Respostas

3 chux-ReinstateMonica Aug 19 2020 at 13:08

Código bem legal.

Pequena revisão de algumas questões secundárias.

sizeof *ptrcontrasizeof type

Código bem usado sizeof *ptrem 2 de 3 casos.

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));

Recomendo continuar assim

// 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.

Manuseio impróprio de Substitutos

Embora não seja importante para este código, é melhor detectar substitutos e talvez tratar como um erro (forma de caractere de substituição Unicode).


Algoritmo da linha de Bresenham

Uma implementação melhor do que o normal.

Para este código, nenhum problema foi visto.

Em geral , o código falha quando x1 - x2ou y1 - y2transborda. Existem maneiras de lidar com isso usando unsignedpara lidar com a diferença sem recorrer a uma matemática mais ampla.

Eu postaria algum código de exemplo, mas meu código de referência não está atualizado.

3 G.Sliepen Aug 17 2020 at 22:09

Use constantes nomeadas consistentemente

Você definiu grid_widthe grid_height, muito bom, mas infelizmente não está usando de forma consistente. Por grid_new()exemplo, a primeira linha pode ser substituída por:

if ((grid_width % group_width != 0) || (grid_height % group_height != 0))

Além disso, é comum ter constantes globais como essas escritas em MAIÚSCULAS, para facilitar a distinção das variáveis.

Fazer uso dememset()

Você escreveu loops em grid_clear()e grid_fill(), mas pode fazer essa tarefa facilmente com memset(), que tem mais chances de ser otimizado. Com certeza, grid_clear()pode ser reescrito para fazer memset(g->buffer, 0, g->buffer_size * sizeof(*g->buffer)). Se g->bufferfor a uint8_t *, você também pode usar memset()inside grid_fill().

Use uint8_tpara a grade

Você está usando apenas 8 bits para cada caractere na grade, portanto, pode armazená-lo em um arquivo em uint8_tvez de int. Isso reduz o uso de memória da grade por um fator 4 e também permite memset()ser usado em arquivos grid_fill().

Considere codificar a tabela de pesquisa

Você pode pensar, que blasfêmia é essa?! Todo mundo sabe que você deve evitar codificar coisas! Mas, neste caso, os caracteres Unicode Braille são imutáveis ​​e você está desperdiçando muito código para gerar os caracteres e alguns ciclos de CPU toda vez que inicia seu programa, quando pode apenas escrever:

wchar_t lookup_table[256] = L"⠁⠂⠃⠄⠅⠆⠇⡀⡁⡂⡃⡄⡅⡆⡇"
                            L"⠈⠉⠊⠋⠌⠍⠎⠏...      "
                              ...
                            L"              ...⣿";

Considere usar ncursesw

Em vez de ter que converter wchar_tvocê mesmo para uma string UTF-8, você pode usar a versão ampla do ncurses que permite imprimir wchar_tstrings diretamente. Desde a versão 6 do ncurses, isso é incluído por padrão e você pode imprimir strings largas que pode usar mvaddwstr()em vez de mvprintw().

Considere não armazenar em cache a grade você mesmo

Uma grande característica do ncurses é que ele armazena em cache o que está na tela e enviará apenas os caracteres e códigos de controle necessários ao terminal para atualizar o que realmente foi alterado. Você está fazendo o mesmo, duplicando assim o que o ncurses está fazendo.

Vejo duas maneiras de acabar com essa ineficiência. Primeiro, você pode acabar com seus próprios buffers e apenas escrever diretamente na tela com funções curses. Obviamente, se você precisar atualizar um único ponto em um caractere Braille, precisará saber qual padrão Braille já está na tela. Você pode ler o conteúdo da tela de volta com comandos como mvin_wch(). A desvantagem é que a leitura de caracteres individuais pode resultar em muitas chamadas de função e você precisa decodificar o caractere Braille de volta em uma máscara de bits.

Outra opção é manter um único buffer e apenas fornecer todo o buffer para ncurses a cada atualização. Se você acha que isso é ineficiente, considere que você mesmo estava copiando todo o buffer para o buffer em cache a cada atualização. Se você seguir esse caminho, provavelmente desejará ter o buffer original para facilitar a manipulação de pontos individuais e um segundo buffer do tipo wchar_t *que você atualiza em paralelo e que pode enviar para ncurses para imprimir de uma só vez. Observe que também há um wmemset()que pode ser útil aqui.

Eu sugeriria ir para a segunda opção. Você deve começar a comparar seu código para poder medir seu desempenho objetivamente.