터미널 용 텍스트 기반 렌더링 / 애니메이션 엔진

Aug 17 2020

이 프로젝트는 점자 유니 코드 문자를 사용하여 터미널에 그림을 그릴 수있는 인기있는 드로 유 프로젝트에서 큰 영감을 받았습니다.

일반 ASCII 문자에 비해 점자 문자로 그리는 것의 장점은 간단합니다. 모든 "점자 문자"는 2 x 4 = 8가능한 스팟 으로 구성되어 있기 때문에 문자 256별로 그릴 수 있는 가능한 변형 이 있습니다. 이러한 점자 패턴을 사용하면 훨씬 더 미세하고 부드러운 그림을 그릴 수 있습니다.

내 구현에는 ncurses 라이브러리를 사용하여 화면에 그려지는 모든 것을 애니메이션화 할 수있는 렌더링 엔진도 함께 제공됩니다. 내 구현은 다음과 같이 매우 성능이 뛰어난 것을 목표로합니다.

  1. 최소한의 메모리 사용.
  2. 아주 좋은 런타임을 가지고 있습니다.

여전히 사용하기 쉽습니다.

다음은이 라이브러리로 수행 할 수있는 작업을 보여주는 몇 가지 예입니다. 이러한 예는 다음에서도 찾을 수 있습니다 examples.c.

저는 이미 매우 간결한 방식으로 데이터를 저장하고 액세스하는 그리드 구조의 구현에 상당히 만족합니다. 렌더링 구조의 성능을 더 향상시킬 수 있는지 궁금합니다. 이미 이전 프레임에서 변경된 사항 만 렌더링하려고하는데 더 최적화 할 수 있을까요?

또한 내 구현이 C 스타일 코딩 지침을 잘 사용하는지 확실하지 않습니다. 또한 라이브러리가 사용자 친화적인지 확인하고 싶습니다. 따라서 사용자로서이 라이브러리의 API에서 기대할 수있는 기능과 현재 상태에서 사용할 때 놓친 부분이 있으면 알려주세요.

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

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

constants.c

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

constants.h

#ifndef CONSTANTS_H
#define CONSTANTS_H

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

#endif

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

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

마지막으로 모든 것을 컴파일하는 Makefile :

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

모든 피드백에 감사드립니다! 이 프로젝트는 GitHub에서도 사용할 수 있습니다.https://github.com/766F6964/DotDotDot

참고 :이를 테스트 할 때 점자 문자를 올바르게 표시 할 수있는 터미널 글꼴이 설치되어 있는지 확인하십시오. 그렇지 않으면 엉망이되어 보입니다.

답변

3 chux-ReinstateMonica Aug 19 2020 at 13:08

꽤 멋진 코드.

몇 가지 부수적 인 문제에 대한 약간의 검토.

sizeof *ptrsizeof type

코드 sizeof *ptr는 3 가지 중 2 가지 경우에 잘 사용 됩니다.

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

계속하는 것이 좋습니다.

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

대리자에 대한 부적절한 처리

코드 에는 중요하지 않지만 서로 게이트를 감지하고 오류로 처리하는 것이 더 좋습니다 (유니 코드 대체 문자 형식).


Bresenham의 선 알고리즘

일반적인 구현보다 낫습니다.

위해 코드, 더 문제는 볼 수 없습니다.

에서 일반 코드 때 실패 x1 - x2또는 y1 - y2오버 플로우. unsigned더 넓은 수학에 의존하지 않고 차이를 처리하기 위해 사용하여 이것을 처리하는 방법이 있습니다 .

샘플 코드를 게시하고 싶지만 내 참조 코드가 최신 상태가 아닙니다.

3 G.Sliepen Aug 17 2020 at 22:09

일관되게 명명 된 상수 사용

당신은 정의 grid_width하고 grid_height매우 훌륭하지만 불행히도 일관되게 사용하고 있지 않습니다. 예 grid_new()를 들어, 첫 번째 줄은 다음과 같이 바꿀 수 있습니다.

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

또한 이러한 전역 상수를 모두 대문자로 작성하는 것이 일반적이므로 변수와 구별하기가 더 쉽습니다.

활용 memset()

grid_clear()및에 루프를 작성 grid_fill()했지만을 사용하여이 작업을 쉽게 수행 할 수 memset()있으며 최적화 가능성이 더 높습니다. 확실히 grid_clear()할 수 있도록 다시 작성할 수 있습니다 memset(g->buffer, 0, g->buffer_size * sizeof(*g->buffer)). 경우 g->buffer을했다 uint8_t *, 당신은 또한 사용할 수있는 memset()내부 grid_fill().

사용 uint8_t그리드에 대한

당신은 그리드의 각 문자에 대해 8 비트를 사용하고, 그래서 당신은 그것을 저장할 수있는 uint8_t대신의 int. 이렇게하면 그리드의 메모리 사용량이 4 배 감소 memset()하고 grid_fill().

조회 테이블 하드 코딩 고려

이게 무슨 신성 모독이라고 생각할 수 있습니까?! 누구나 하드 코딩을 피해야한다는 것을 알고 있습니다! 그러나이 경우 유니 코드 점자 문자는 고정되어 있으며 문자를 생성하는 데 많은 코드를 낭비하고 프로그램을 시작할 때마다 일부 CPU주기를 다음과 같이 작성할 수 있습니다.

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

ncursesw 사용 고려

wchar_tUTF-8 문자열로 직접 변환하는 대신 wchar_t문자열을 직접 인쇄 할 수있는 광범위한 버전의 ncurses를 사용할 수 있습니다. ncurses 버전 6부터 이것은 기본적으로 포함되어 있으며 mvaddwstr()대신 사용할 수있는 넓은 문자열을 인쇄 할 수 있습니다 mvprintw().

그리드를 직접 캐싱하지 않는 것이 좋습니다.

ncurses의 큰 특징은 화면에있는 내용을 캐시하고 실제로 변경된 내용을 업데이트하기 위해 필요한 문자와 제어 코드 만 터미널에 전송한다는 것입니다. 당신은 스스로 똑같이하고 있으므로 ncurses가하는 일을 복제합니다.

이 비 효율성을 제거하는 두 가지 방법이 있습니다. 첫째, 자신의 버퍼를 모두 없애고 curses 함수를 사용하여 화면에 직접 쓸 수 있습니다. 물론 점자 문자의 단일 점을 업데이트해야하는 경우 화면에 이미 어떤 점자 패턴이 있는지 알아야합니다. 와 같은 명령을 사용하여 화면의 내용을 다시 읽을 수 있습니다 mvin_wch(). 단점은 개별 문자를 다시 읽으면 많은 함수 호출이 발생할 수 있으며 점자 문자를 비트 마스크로 다시 디코딩해야한다는 것입니다.

또 다른 옵션은 단일 버퍼를 유지하고 새로 고칠 때마다 전체 버퍼를 ncurses에 제공하는 것입니다. 이것이 비효율적이라고 생각되면 새로 고칠 때마다 전체 버퍼를 캐시 된 버퍼에 복사했다고 생각하십시오. 그래도 이런 식으로 가면 개별 도트를 쉽게 조작 할 수있는 원래 버퍼와 wchar_t *병렬로 업데이트 하는 두 번째 버퍼 유형 을 원할 것이며 한 번에 인쇄하기 위해 ncurses로 보낼 수 있습니다. 참고 또한이 wmemset()여기에 도움이 될 수있다.

두 번째 옵션을 선택하는 것이 좋습니다. 코드의 성능을 객관적으로 측정 할 수 있도록 코드 벤치마킹을 시작해야합니다.