[C#] GDI의 Graphic 객체 사용법과 더블 버퍼링 구현하는 방법


Development note/C#  2021. 1. 15. 16:36

안녕하세요. 명월입니다.


이 글은 C#에서 GDI의 Graphic 객체 사용법과 더블 버퍼링 구현하는 방법에 대한 글입니다.


우리가 윈도우 프로그래밍을 하면서 기본 Window의 컨트롤도 충분히 다양한 기능을 가지고 있고 좋다고 생각합니다만, 개성있게 버튼 스타일을 바꾸거나 또는 나만의 컨트럴을 만들 때 그래픽을 조금 건드려야 하는 부분이 있습니다.

그럼 우리가 윈도우 폼에서 화면에 그리기 위해서는 Graphic 객체를 받아야 합니다. 이 객체를 받는 방법은 두가지가 있습니다.


하나는 Form의 함수인 this.CreateGraphics()로 생성하는 방법과 OnPaint에서 이벤트로 받는 방법이 있습니다.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Example
{
  // 전광판 클래스
  class Billboard
  {
    // 폼에 그릴 위치와 크기 설정값
    private readonly Rectangle paintLocation;
    // 전광판에 나타낼 문구 설정
    private readonly string context;
    // 폰트 설정
    private readonly Font font = new Font(new FontFamily("Arial"), 15);
    // 타이핑 효과를 내기 위한 인덱스
    private int index = 1;
    // 생성자
    public Billboard(int index, string context)
    {
      // 초기 전광판 위치 설정
      this.paintLocation = new Rectangle(new Point(10, 10 + (index * 40)), new Size(250, 30));
      // 문구 설정
      this.context = context;
    }
    // 그리기
    public void Draw(Graphics g)
    {
      // 배경 클리어
      g.FillRectangle(Brushes.White, paintLocation);
      // 문구 그리기
      g.DrawString(context.Substring(0, index), font, Brushes.Black, paintLocation.Location);
      // 타이핑 효과 인덱스 조정
      index++;
      if (index >= context.Length)
      {
        index = 1;
      }
    }
  }
  // Form 클래스를 상속받는다.	
  public class Program : Form
  {
    // 그래픽 객체 설정
    private readonly Graphics graphics;
    // 전광판 클래스
    private readonly Billboard bilboard1;
    private readonly Billboard bilboard2;

    // 생성자
    public Program()
    {
      // OnPaint 이벤트에 넣을 전광판
      bilboard1 = new Billboard(0, "This is OnPaint!!");
      // OnLoad 이벤트에 넣을 전광판
      bilboard2 = new Billboard(1, "This is Tick in OnLoad!!");
      // Graphic 객체 생성
      graphics = this.CreateGraphics();
      // 주소 값 확인하기
      Console.WriteLine("constructor - " + graphics.GetHashCode());
    }
    // OnLoad 이벤트
    protected override void OnLoad(EventArgs e)
    {
      base.OnLoad(e);
      // 타이머 생성
      var timer = new Timer();
      // 인터벌 0.1초
      timer.Interval = 100;
      timer.Tick += (_, __) =>
      {
        // 전광판 그리기 (생성자에서 생성한 Graphic 객체)
        bilboard2.Draw(graphics);
        // OnPaint를 호출하기 
        //this.Invalidate();
      };
      timer.Start();
    }
    // OnPaint 이벤트
    protected override void OnPaint(PaintEventArgs e)
    {
      base.OnPaint(e);
      // 이벤트 파리미터에 Graphic이 있다.
      var g = e.Graphics;
      // 주소 값 확인하기
      Console.WriteLine("OnPaint - " + g.GetHashCode());
      // 전광판 그리기
      bilboard1.Draw(g);
    }

    // 싱글 스레드 어트리뷰트	
    [STAThread]
    // 실행 함수	
    static void Main(string[] args)
    {
      // 환경에 맞는 스타일 설정	
      Application.EnableVisualStyles();
      Application.SetCompatibleTextRenderingDefault(false);
      // 메시지 루프에 인스턴스를 생성한다.	
      Application.Run(new Program());
    }
  }
}

위 소스에서 제가 생성자에서 this.CreateGraphics()를 호출해서 Graphic 객체를 받은 후에 OnLoad 이벤트에 Tick을 만들어서 0.1단위로 다시 그리는 액션을 만들었습니다.

두번째는 OnPaint에서 이벤트로 e.Graphics로 객체를 받은 후에 바로 폼에 그리는 액션을 만들었습니다.

여기서 OnPaint는 화면을 다시 그릴 때 불리는 함수입니다. 즉, 처음에 폼이 생성될 때 한번 호출되고 중간에 다른 Window와 겹치거나 최소화했다가 다시 띄웠을 때, 호출이 되는 것입니다.

즉, 첫번째의 Tick같은 뭐랄까 액션 효과가 나오지 않습니다. 그럼 OnPaint를 메시지 호출이 아닌 강제 호출을 할 수 있는데 그것이 this.Invalidate 함수입니다.

this.Invalidate 함수의 주석을 제거해 보겠습니다.

두번째 전광판이 지워져 버렸습니다. 지워지는 현상은 더블 버퍼링일 때 자세히 설명하고 일단은 Graphic 객체에 설명을 계속 하겠습니다.

일단 Graphics 객체를 받는 곳이 두 곳이 있습니다. 하나는 this.CreateGraphics()를 이용해서 받는 방법인데 이것은 Form의 public 형식으로 된 함수이니 어디서든 호출할 수 있습니다.

그리고 OnPaint의 이벤트에 있는 e.Graphic을 보시면 Graphic 객체를 가져올 수 있습니다. 이것은 조금 가져오기가 애매합니다. 그런데 옆에 콘솔창으로 OnPaint에 발생하는 로그를 보시면 Graphic의 HashCode가 OnPaint가 호출될 때마다 다르다는 것을 보입니다. 즉, 상위에서 OnPaint를 호출할 때 아마도 this.CreateGraphics로 새로 생성하는 듯하네요.

그러므로 굳이 이벤트에서 Graphic를 가져오는 것을 필요가 없어 보입니다. 그냥 this.CreateGraphics로 가져와도 된다는 뜻입니다.

더블 버퍼링

더블 버퍼링이란 누가 이런 명칭을 만들었는지 의문이기는 합니다만, 어쨋든 이 더블 버퍼링이라는 것은 여러가지 이미지를 합칠 때 각각에 객체에서 CreateGraphics를 호출해서 form에 그리는 것이 아니고 메모리DC, 즉 메모리 상의 Bitmap에 표현할 이미지를 모두 그리고 한 번에 화면에 그려내는 것입니다.

왜 이 더블 버퍼링이 필요한가 했을 때, 위 소스를 보면 OnPaint를 호출하면 전광판 두번째 이미지가 안 보이는 것을 확인할 수 있습니다. 즉, 실제로 작업을 할 때에 어디서든 Graphic 객체를 가져올 수 있지만, 이벤트 메시지로 OnPaint가 호출이 되면 일부가 안보이거나 이상하게 보일 가능성이 큽니다.

그것을 해결하기 위해서는 하나의 Image를 메모리에 만들어서 DC에 맞게 그려낸 다음에 한번에 OnPaint에서 그려내야 이미지가 짤리거나 깜빡이는 문제를 해결할 수 있습니다.


이번에는 스트라이프 이미지를 구해서 좀 더 움직이는 이미지를 구현해 보겠습니다.

참조 링크 - https://soulares.itch.io/free-npc-alchemist


무료 스트라이트 이미지를 itch 사이트에서 구했습니다. 참고로 itch는 무료 리소스등을 배포하는 사이트입니다. 게임 개발하시는 분들은 참고해 주세요.


먼저 Visual studio에 이미지를 첨부하였습니다.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Example
{
  // 이미지 액션 클래스
  class ActionImage
  {
    // 스트라이프 이미지
    private readonly Image original;
    // 스트라이프 이미지를 잘라서 한 컷마다 넣는다.
    private readonly List<Image> workAction = new List<Image>();
    // 움직일 index
    private int index = 0;
    // 생성자
    public ActionImage(string filepath, int stripeCount)
    {
      // 이미지 경로를 받아서 Image객체 생성
      this.original = Bitmap.FromFile(filepath);
      // 스트라이프 이미지 자르기
      for (int i = 0; i < stripeCount; i++)
      {
        workAction.Add(ExtractStripeImage(i));
      }
    }
    // 스트라이프 이미지 자르기
    private Image ExtractStripeImage(int index)
    {
      // 버퍼 이미지 만든다.
      var buffer = new Bitmap(64, this.original.Height);
      // Graphics 객체 취득
      var g = Graphics.FromImage(buffer);
      // 이미지를 잘라서
      g.DrawImage(original, new Rectangle(Point.Empty, buffer.Size), index * 64, 0, buffer.Width, buffer.Height, GraphicsUnit.Pixel);
      // 저장
      g.Save();
      // 반환
      return buffer;
    }
    public void Next()
    {
      // 인덱스 증가
      index++;
      // 스트라이프 Count 보다 인덱스가 높으면 초기화
      if (index >= workAction.Count)
      {
        index = 0;
      }
    }
    // 그리기
    public void Draw(Graphics g, Point p)
    {
      // Graphics에 액션을 그리자
      g.DrawImage(workAction[index], p);
    }
    // 액션 인터벌 설정
    public void ActionInterval(int interval)
    {
      // Window Form 타이머
      var timer = new Timer();
      // 인터벌 설정
      timer.Interval = interval;
      // 틱 설정
      timer.Tick += (_, __) =>
      {
        Next();
      };
      // 실행
      timer.Start();
    }
  }
  // 통합 Graphic 이미지 (Double buffering)
  class GDIBuffer
  {
    // 싱글톤
    private static GDIBuffer instance = null;
    // Graphic 객체
    private Graphics graphics;
    // Bitmap 객체(메모리)
    private Bitmap bitmap;
    // 생성자
    private GDIBuffer(Size size)
    {
      // Bitmap 객체 생성
      this.bitmap = new Bitmap(size.Width, size.Height);
      // Graphic 객체 생성
      this.graphics = Graphics.FromImage(this.bitmap);
    }
    // 초기화
    public static void Init(Size size)
    {
      // 기존에 객체가 있었다면
      if (instance != null)
      {
        // Graphic과 Bitmap를 해제한다.
        instance.graphics.Dispose();
        instance.bitmap.Dispose();
      }
      // 생성자 생성
      instance = new GDIBuffer(size);
    }
    // Graphic 객체 취득
    public static Graphics Graphics
    {
      get
      {
        // 초기화가 되지 않으면 null를 Return
        if (instance == null)
        {
          return null;
        }
        // Graphic 객체 반환
        return instance.graphics;
      }
    }
    // Image 객체 취득
    public static Image Image
    {
      get
      {
        // 초기화가 되지 않으면 null를 Return
        if (instance == null)
        {
          return null;
        }
        // Bitmap 객체 반환
        return instance.bitmap;
      }
    }
  }
  // Form 클래스를 상속받는다.	
  public class Program : Form
  {
    // 액션 이미지 생성
    private readonly ActionImage action1;
    private readonly ActionImage action2;
    // 액션 이미지 위치 설정
    private readonly Point action1Location = new Point(40, 40);
    private readonly Point action2Location = new Point(40, 100);

    // 생성자
    public Program()
    {
      // 파일 부터 로드한다.
      action1 = new ActionImage(AppDomain.CurrentDomain.BaseDirectory + "work1.png", 14);
      action2 = new ActionImage(AppDomain.CurrentDomain.BaseDirectory + "work2.png", 10);
      // Double buffering 객체 초기화
      GDIBuffer.Init(new Size(this.Width, this.Height));
      // DoubleBuffered를 true한다.
      DoubleBuffered = true;
      // 0.2초마다 action1의 장면 스킵
      action1.ActionInterval(200);
      // 0.1초마다 action2의 장면 스킵
      action2.ActionInterval(100);
    }
    // OnLoad 이벤트
    protected override void OnLoad(EventArgs e)
    {
      base.OnLoad(e);
      // Double buffering 설정
      // 타이머 생성
      var timer = new Timer();
      // 인터벌 0.01초
      timer.Interval = 10;
      timer.Tick += (_, __) =>
      {
        // Double buffering 객체 화면 초기화
        GDIBuffer.Graphics.Clear(Color.White);
        // Double buffering 객체에 action1 객체 그리기
        action1.Draw(GDIBuffer.Graphics, action1Location);
        // Double buffering 객체에 action2 객체 그리기
        action2.Draw(GDIBuffer.Graphics, action2Location);
        // OnPaint를 호출하기
        this.Invalidate();
        // OnPaint를 사용하지 않는다면 아래로 사용해도 괜찮다.
        //this.CreateGraphics().DrawImage(GDIBuffer.Image, Point.Empty);
      };
      timer.Start();
    }
    // OnPaint 이벤트
    protected override void OnPaint(PaintEventArgs e)
    {
      base.OnPaint(e);
      // 이벤트 파리미터에 Graphic이 있다.
      var g = e.Graphics;
      // 화면에 Double buffering 그리기
      g.DrawImage(GDIBuffer.Image, Point.Empty);
    }

    // 싱글 스레드 어트리뷰트	
    [STAThread]
    // 실행 함수	
    static void Main(string[] args)
    {
      // 환경에 맞는 스타일 설정	
      Application.EnableVisualStyles();
      Application.SetCompatibleTextRenderingDefault(false);
      // 메시지 루프에 인스턴스를 생성한다.	
      Application.Run(new Program());
    }
  }
}

실행해 보면 스트라이프 이미지가 움직이는 것을 확인할 수 있습니다.

위 소스를 보시면 두개의 액션 이미지가 서로의 움직이는 속도가 다릅니다. action1의 경우는 0.2마다 장면이 바뀌고 action2의 경우는 0.1마다 장면이 바뀝니다. 이러한 시간의 딜레이가 있어도 깜빡이거나 화면이 짤리는 버그는 없습니다. 왜냐하면 Draw할 때의 상태를 따로 메모리에 그려서 그것이 모두 그려지고나서 윈도우에 새롭게 다시 그려지는 형태이기 때문입니다. 영화에서 영사기와 같은 원리입니다.


사실 설명은 여기까지입니다만 예전에 제가 작성해 놓은 시계 소스가 있는데 그냥 없애버리기는 좀 아까워서 소스 작성해 놓습니다.

참고하세요.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Example
{
  // 시계 클래스
  class Clock
  {
    // 시계 크기
    private readonly Size size;
    // 메인 시계 색깔
    private readonly Pen mainCasePen = new Pen(Brushes.Black, 2);
    // 시침 색
    private readonly Pen hourHandPen = new Pen(Brushes.Red, 5);
    // 분침 색
    private readonly Pen minHandPen = new Pen(Brushes.Blue, 3);
    // 초침 색
    private readonly Pen secHanPen = new Pen(Brushes.Gray, 2);
    // 가운데 위치
    private int center;
    // 바늘 길이
    private int handLength;
    // 시간 위치
    private Point hour;
    // 분 위치
    private Point min;
    // 초 위치
    private Point sec;
    // 시계 시간 폰트
    private Font font;
    // 시계 시간 위치
    private Point[] numbers = new Point[12];
    // 생성자
    public Clock(int size)
    {
      // 크기 설정
      this.size = new Size(size, size);
      // 중앙 설정
      this.center = size / 2;
      // 바늘 길이 계산
      this.handLength = (size / 2) - 20;
      // 폰트 설정
      this.font = new Font(new FontFamily("Arial"), 20);
      // 시계 시간 글자 위치 설정
      for (int i = 0; i < numbers.Length; i++)
      {
        var n = 2 * Math.PI * (((i + 1) * 60 * 60) - 15 * 60 * 60) / (12 * 60 * 60);
        numbers[i] = new Point((this.center -15) + (int)(this.handLength * Math.Cos(n)), (this.center - 15) + (int)(this.handLength * Math.Sin(n)));
      }
      // 1초당 재 설정
      this.Action();
    }
    // 시계 케이스 그리기
    private void DrawMainCase(Graphics g)
    {
      // 원 그리기
      g.DrawEllipse(mainCasePen, 0, 0, size.Width, size.Height);
      // 시간 글자 그리기
      for (int i = 0; i < numbers.Length; i++)
      {
        g.DrawString((i + 1).ToString(), this.font, Brushes.Black, numbers[i]);
      }
    }
    // 시침 그리기
    private void DrawHourHand(Graphics g)
    {
      g.DrawLine(hourHandPen, center, center, this.hour.X, this.hour.Y);
    }
    // 분침 그리기
    private void DrawMinuteHand(Graphics g)
    {
      g.DrawLine(minHandPen, center, center, this.min.X, this.min.Y);
    }
    // 초침 그리기
    private void DrawSecondHand(Graphics g)
    {
      g.DrawLine(secHanPen, center, center, this.sec.X, this.sec.Y);
    }
    // 시계 그리기
    public void DrawAnalogClock(Graphics g)
    {
      DrawMainCase(g);
      DrawHourHand(g);
      DrawMinuteHand(g);
      DrawSecondHand(g);
    }
    // 시침, 분침, 초침 위치 계산하기
    private void Action()
    {
      // 폼 타이머 설정
      var timer = new Timer();
      // 0.1초 마다 다시 계산하기
      timer.Interval = 100;
      timer.Tick += (_, __) =>
      {
        // 시간 설정
        var hours = 2 * Math.PI * ((DateTime.Now.Hour * 60 * 60 + DateTime.Now.Minute * 60 + DateTime.Now.Second) - 15 * 60 * 60) / (12 * 60 * 60);
        // 분 설정
        var minutes = 2 * Math.PI * ((DateTime.Now.Minute * 60 + DateTime.Now.Second) - 15 * 60) / (60 * 60);
        // 초 설정
        var seconds = Math.PI * ((DateTime.Now.Second + (DateTime.Now.Millisecond / 1000.0)) - 15) / 60;
        // 위치 설정
        this.hour = new Point(this.center + (int)((this.handLength - 20) * Math.Cos(hours)), this.center + (int)((this.handLength - 20) * Math.Sin(hours)));
        this.min = new Point(this.center + (int)(this.handLength * Math.Cos(minutes)), this.center + (int)(this.handLength * Math.Sin(minutes)));
        this.sec = new Point(this.center + (int)(this.handLength * Math.Cos(seconds)), this.center + (int)(this.handLength * Math.Sin(seconds)));
      };
      // 스레드 시작
      timer.Start();
    }
  }
  // 통합 Graphic 이미지 (Double buffering)
  class GDIBuffer
  {
    // 싱글톤
    private static GDIBuffer instance = null;
    // Graphic 객체
    private Graphics graphics;
    // Bitmap 객체(메모리)
    private Bitmap bitmap;
    // 생성자
    private GDIBuffer(Size size)
    {
      // Bitmap 객체 생성
      this.bitmap = new Bitmap(size.Width, size.Height);
      // Graphic 객체 생성
      this.graphics = Graphics.FromImage(this.bitmap);
    }
    // 초기화
    public static void Init(Size size)
    {
      // 기존에 객체가 있었다면
      if (instance != null)
      {
        // Graphic과 Bitmap를 해제한다.
        instance.graphics.Dispose();
        instance.bitmap.Dispose();
      }
      // 생성자 생성
      instance = new GDIBuffer(size);
    }
    // Graphic 객체 취득
    public static Graphics Graphics
    {
      get
      {
        // 초기화가 되지 않으면 null를 Return
        if (instance == null)
        {
          return null;
        }
        // Graphic 객체 반환
        return instance.graphics;
      }
    }
    // Image 객체 취득
    public static Image Image
    {
      get
      {
        // 초기화가 되지 않으면 null를 Return
        if (instance == null)
        {
          return null;
        }
        // Bitmap 객체 반환
        return instance.bitmap;
      }
    }
  }
  // Form 클래스를 상속받는다.	
  public class Program : Form
  {
    // 시계 생성
    private readonly Clock clock;
    // 생성자
    public Program()
    {
      // 시계 인스턴스 생성
      this.clock = new Clock(this.Size.Width - 40);
      // Double buffering 객체 초기화
      GDIBuffer.Init(new Size(this.Width, this.Height));
      // DoubleBuffered를 true한다.
      DoubleBuffered = true;
    }
    // OnLoad 이벤트
    protected override void OnLoad(EventArgs e)
    {
      base.OnLoad(e);
      // Double buffering 설정
      // 타이머 생성
      var timer = new Timer();
      // 인터벌 0.01초
      timer.Interval = 10;
      timer.Tick += (_, __) =>
      {
        // Double buffering 객체 화면 초기화
        GDIBuffer.Graphics.Clear(Color.White);
        // 아날로그 시계를 Double buffering에 그리기
        this.clock.DrawAnalogClock(GDIBuffer.Graphics);
        // OnPaint를 호출하기
        this.Invalidate();
      };
      timer.Start();
    }
    // OnPaint 이벤트
    protected override void OnPaint(PaintEventArgs e)
    {
      base.OnPaint(e);
      // 이벤트 파리미터에 Graphic이 있다.
      var g = e.Graphics;
      // 화면에 Double buffering 그리기
      g.DrawImage(GDIBuffer.Image, Point.Empty);
    }

    // 싱글 스레드 어트리뷰트	
    [STAThread]
    // 실행 함수	
    static void Main(string[] args)
    {
      // 환경에 맞는 스타일 설정	
      Application.EnableVisualStyles();
      Application.SetCompatibleTextRenderingDefault(false);
      // 메시지 루프에 인스턴스를 생성한다.	
      Application.Run(new Program());
    }
  }
}

여기까지 C#에서 GDI의 Graphic 객체 사용법과 더블 버퍼링 구현하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.