Lode 的计算机图形学教程

绘画

目录

返回索引

简介

本教程将介绍一个程序,该程序允许你像专业绘画程序一样使用各种笔刷进行绘画。

你可以在这里下载最终程序的源代码,该程序支持不透明度。

鼠标

绘画时最好的输入设备(除了数位板之外)当然是鼠标,因此我们使用鼠标进行绘画。下面这个非常简单直接的程序每帧都会检测鼠标的位置,如果按下鼠标左键,则在该位置绘制一个黑色像素;如果按下鼠标右键,则绘制一个白色像素。如果同时按下两个按键,则将屏幕清除为白色。

int main(int argc, char *argv[])
{
  screen(256, 256, 0, "Painting");
  cls(RGB_White);
  int mouseX, mouseY;
  bool LMB, RMB;
  while(!done())
  {
    getMouseState(mouseX, mouseY, LMB, RMB);
    if(LMB) pset(mouseX, mouseY, RGB_Black);
    if(RMB) pset(mouseX, mouseY, RGB_White);
    if(RMB && LMB) cls(RGB_White);
    redraw();
  }
}

选择较小的屏幕尺寸只是为了在此处截图时足够小。不过这个程序有一个问题!如果你移动鼠标的速度超过了程序能处理的速度,就不是所有像素都会被覆盖,你会得到点状图案:



为了解决这个问题,绘画程序不在当前鼠标位置绘制像素,而是在当前位置和上一位置之间绘制一条线。这样看起来就好像鼠标指针经过的所有像素都被覆盖了,而且这些线段足够短,看起来是曲线形状的。对程序的扩展非常简单,oldMouseX 和 oldMouseY 表示上一帧的鼠标位置,并使用 drawLine 替代 pset:

int main(int argc, char *argv[])
{
  screen(256, 256, 0, "Painting");
  cls(RGB_White);
  int mouseX, mouseY;
  int oldMouseX, oldMouseY;
  bool LMB, RMB;
  while(!done())
  {
    oldMouseX = mouseX;
    oldMouseY = mouseY;
    getMouseState(mouseX, mouseY, LMB, RMB);
    if(LMB) drawLine(oldMouseX, oldMouseY, mouseX, mouseY, RGB_Black);
    if(RMB) drawLine(oldMouseX, oldMouseY, mouseX, mouseY, RGB_White);
    if(RMB && LMB) cls(RGB_White);
    redraw();
  }
}

现在你可以随心所欲地快速绘画,曲线现在完全连续,而不只是一些散点:



颜色

能够进行彩色绘画总是更好的,因此我们可以为绘画程序添加一个颜色选择器。颜色选择器将使用 HSVtoRGB 函数,详情请参阅颜色教程。该函数允许你以色相饱和度明度(Hue Saturation Value)格式显示颜色,在选择颜色时更加方便。

该程序的工作方式基本上与上一个相同,但添加了一个额外的函数来绘制颜色选择器,定义了一些颜色,并且加入了检测鼠标是否位于颜色选择器上方的逻辑,如果是,则点击鼠标按键会选择颜色而不是进行绘画。

主函数同样从设置屏幕、将其清除为白色以及定义变量开始。现在定义了 2 种颜色:前景色(fore)和背景色(back)。前景色用于鼠标左键,背景色用于鼠标右键以及同时按下两个按键时清除屏幕。

还定义了一个亮度值,该值仅用于颜色选择器本身,不影响前景色和背景色的值。

void drawToolbar();
Uint8 brightness = 255; //for the color picker

int main(int argc, char *argv[])
{
  screen(256, 256, 0, "Painting");
  cls(RGB_White);
  int mouseX, mouseY;
  int oldMouseX, oldMouseY;
  bool LMB, RMB;
  ColorRGB fore = RGB_Black; //foreground color
  ColorRGB back = RGB_White; //background color

然后主循环开始,首先设置当前帧和上一帧的鼠标位置值(用于绘制线段)。然后检测鼠标的 Y 坐标是否大于 64:如果大于,则鼠标位于绘画区域;如果小于,则鼠标位于工具栏区域(窗口顶部的 64 行)。

如果在绘画区域,则与之前的操作相同,但现在使用的是颜色而不只是黑色和白色。

如果在工具栏区域,则检测鼠标位于工具栏的哪个部分:如果 mouseX 小于 128,则鼠标位于颜色选择器上,并通过 HSVtoRGB 函数将颜色值赋给前景色或背景色。

如果 mouseX 在 128 到 144 之间,则鼠标位于颜色选择器的亮度区域,可以改变颜色选择器的亮度。

最后,绘制工具栏区域的灰色背景,并调用 drawToolbar 函数。

  while(!done())
  {

    oldMouseX = mouseX;
    oldMouseY = mouseY;
    getMouseState(mouseX, mouseY, LMB, RMB);

    if(mouseY >= 64) //drawing area
    {
      if(LMB) drawLine(oldMouseX, oldMouseY, mouseX, mouseY, fore);
      if(RMB) drawLine(oldMouseX, oldMouseY, mouseX, mouseY, back);
      if(RMB && LMB) cls(back);
    }
    else //toolbar area
    {
      if(mouseX < 128) //color picker
      {
        if(LMB) fore = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
        if(RMB) back = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
      }
      if(mouseX >= 128 && mouseX < 144) //color picker brightness
      {
        if(LMB || RMB) brightness = (mouseY * 4);
      }
    }
    drawRect(0, 0, w - 1, 64, RGB_Gray);
    drawLine(0, 64, w - 1, 64, RGB_Black);
    drawToolbar();
    redraw();
  }
}

drawColorPicker 函数简单地以当前亮度绘制色相饱和度图案,并绘制亮度选择器。

void drawToolbar()
{
  ColorRGB color;
  for(int x = 0; x <128; x++)
  for(int y = 0; y < 64; y++)
  {
    color = HSVtoRGB(ColorHSV(x * 2, y * 4, brightness));
    pset(x, y, color);
  }
  for(int x = 128; x < 144; x++)
  for(int y = 0; y < 64; y++)
  {
    pset(x, y, ColorRGB(y * 4, y * 4, y * 4));
  }
} 

以下是程序的外观以及你可以用它做的事情:



使用方法:用对应的鼠标按键点击左上角的色相饱和度区域,为左键或右键选择颜色。通过点击灰度区域(从黑到白的渐变)来改变色相饱和度区域的亮度。这不会改变鼠标按键对应的颜色,只会改变颜色选择器的颜色,这样你就可以选择更深或更亮的颜色。

用鼠标左键或右键点击工具栏下方的区域,以对应颜色进行绘画;或者同时按下两个按键,将整个屏幕清除为鼠标右键对应的颜色(背景色)。

下图展示了颜色选择器的几种不同亮度值:



大小

始终使用只有 1 像素大小的笔刷绘画是相当困难的,特别是当你需要给大面积区域上色时。因此,能够改变笔刷的大小会非常方便。如果笔刷大小大于 1,它可以具有特定形状,在本例中为实心圆。

使用较大的笔刷尺寸时,我们不能再使用"line"函数来连接两点,因为该函数绘制的线条粗细为 1。取而代之,我们将使用一种完全不同的方法,该方法尽量模拟 Paint Shop Pro 的工作方式。在这种方法中,笔刷形状每隔一定像素才重新绘制一次,如果这个像素数足够小,笔刷形状就会频繁地叠加,看起来像一个连续的笔触;如果像素数较大,则可以更清楚地看到形状是如何重复的。在 Paint Shop Pro 中,你可以通过"Step"(步长)来控制这个像素数,step = 100 表示笔刷仅重复一次自身大小的距离,而如果你将其设置得更小,则重复得更频繁。例如,以下是在 Paint Shop Pro 中分别以步长 100、50 和 10 绘制的 3 条水平线,笔刷大小为 25:



当笔刷大小为 1 时,最好坚持使用线段绘制,这比绘制直径为 1 的实心圆效果更好。代码稍微有些长,因为它尽量做到尽可能精确,而且"step"(步长)的处理上有一些复杂之处。

代码首先初始化函数和变量。函数 drawToolbar 将绘制颜色选择器以及大小和步长的设置。函数 drawBrush 替代了上一版本中简单的 drawLine,它将在旧位置和当前位置之间绘制实心圆,每个实心圆之间的距离由步长决定。函数 distance2D 被引入是因为有时需要计算二维距离。

变量 brightness 用于颜色选择器,size 和 step 是笔刷的新参数。

在主函数内部,初始化鼠标位置和颜色,以及布尔值"initiate",该值仅在你刚开始按下鼠标按键时为真。它用于确保当你刚点击按键时,无论与上一位置的距离是多少,笔刷都一定会在该处绘制。

void drawToolbar();
void drawBrush(int & x1, int & y1, int x2, int y2, ColorRGB color);
double distance2D(int x1, int y1, int x2, int y2);
bool inBetween(int x1, int y1, int x2, int y2, int x3, int y3);

Uint8 brightness = 255; //for the color picker
int size = 10, step = 5; //brush size and step
bool initiate; //when you just start pressing mouse button

int main(int argc, char *argv[])
{
  screen(384, 384, 0, "Painting");
  cls(RGB_White);
  int mouseX, mouseY;
  int oldMouseX, oldMouseY;
  bool LMB, RMB;
  ColorRGB fore = RGB_Black; //foreground color
  ColorRGB back = RGB_White; //background color

然后主循环开始,首先获取当前鼠标状态。如果没有按下任何鼠标按键,则可以将 initiate 设置为 1,这样下次按下鼠标按键时程序就知道这是一次新的按键操作。

如果按下鼠标按键,且鼠标的 Y 坐标大于 64(即位于绘画区域而非工具栏),则绘制引擎启动:如果 initiate 为真,则直接在当前鼠标位置绘制笔刷,将旧位置设置为当前位置,并将 initiate 设置为 0,因为当你持续按住鼠标按键时,下一帧鼠标就不再是"刚刚按下"的状态了。

当上一绘制位置与当前绘制位置之间的距离足够大时,可以调用 drawBrush 函数。

  while(!done())
  {
    getMouseState(mouseX, mouseY, LMB, RMB);
    if(!LMB && !RMB) initiate = 1;
    if((LMB || RMB) && mouseY > 64) //drawing area
    {
      if(initiate)
      {
        oldMouseX = mouseX;
        oldMouseY = mouseY;
        if(LMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, fore);
        else if(RMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, back);
        initiate = 0;
      }
      if(distance2D(mouseX, mouseY, oldMouseX, oldMouseY) > (size * step / 100.0))
      {
        if(LMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, fore);
        else if(RMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, back);
      }
      if(RMB && LMB) cls(back);
    }

循环的下一部分检测你是否位于工具栏区域,如果是,则允许你选择颜色、亮度,以及如果你点击大小或步长指示器,则通过 getInput 函数要求你输入新值。

    if(mouseY < 64 && initiate) //toolbar area
    {
      if(mouseX < 128) //color picker
      {
        if(LMB) fore = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
        if(RMB) back = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
      }
      if(mouseX >= 128 && mouseX < 144) //color picker brightness
      {
        if(LMB || RMB) brightness = (mouseY * 4);
      }
      if(mouseY >= 0 && mouseY < 8 && mouseX > 144 && LMB) //brush size
      {
        size = int(getInput<int>("Enter Size:", false, 144, 0, RGB_Red, 1, RGB_Gray));
        size = max(1, min(255, size));
      }
      if(mouseY >= 8 && mouseY < 16 && mouseX > 144 && LMB) //brush step
      {
        step = int(getInput<int>("Enter Step:", false, 144, 8, RGB_Red, 1, RGB_Gray));
        step = max(1, min(200, step));
      }
    }

最后,主循环绘制工具栏并重新绘制屏幕。

    drawRect(0, 0, w - 1, 64, RGB_Gray);
    drawLine(0, 64, w - 1, 64, RGB_Black);
    drawToolbar();
    redraw();
  }
  return 0;
}

这是替代 drawLine 函数的 drawBrush 函数。前两个参数 x1 和 y1 是通过引用传递的,因为它们是一种返回值:它们将被设置为最后一个绘制的实心圆的坐标,以便你可以将其作为位置来比较下一个笔刷位置的距离。

如果 initiate 为真,则在鼠标位置绘制一个圆盘,由于距离为 0,不会绘制后续的圆盘。

在 while 循环中,持续绘制所有圆盘,直到距离小于步长所规定的值。如果笔刷大小为 1,则使用 drawLine 替代,因为这样看起来更好。如果坐标跳过了终点位置,while 循环也会结束,否则距离会再次增大,导致无限循环。为此使用了"inBetween"函数,如果点 x 不在 x1 和 x2 之间,该函数返回 false。

void drawBrush(int & x1, int & y1, int x2, int y2, ColorRGB color)
{
  int x = x1, y = y1;
  if(size > 1)
  {
    if(initiate) drawDisk(x, y, size / 2, color);

    int i = 0;
    while(distance2D(x, y, x2, y2) > (size * step / 100.0) && inBetween(x1, y1, x, y, x2, y2))
    {
      i++;
      x = int(x1 + i * (size * step / 100.0) * (x2 - x1) / distance2D(x1, y1, x2, y2));
      y = int(y1 + i * (size * step / 100.0) * (y2 - y1) / distance2D(x1, y1, x2, y2));
      drawDisk(x, y, size / 2, color);
    }
    x1 = x;
    y1 = y;
  }
  else //if brush size is 1, drawLine looks nicer
  {
    drawLine(x1, y1, x2, y2, color);
    x1 = x2;
    y1 = y2;
  }
}

函数 drawToolbar 再次绘制颜色选择器,现在还打印大小和步长值。distance2D 函数简单地使用距离公式。

void drawToolbar()
{
  ColorRGB color;
  for(int x = 0; x < 128; x++)
  for(int y = 0; y < 64; y++)
  {
    color = HSVtoRGB(ColorHSV(x * 2, y * 4, brightness));
    pset(x, y, color);
  }
  for(int x = 128; x < 144; x++)
  for(int y = 0; y < 64; y++)
  {
    pset(x, y, ColorRGB(y * 4, y * 4, y * 4));
  }
  print("Size:", 144, 0);
  print(size, 184, 0);
  print("Step:", 144, 8);
  print(step, 184, 8);
}

double distance2D(int x1, int y1, int x2, int y2)
{
  return sqrt(double((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)));
}


//returns true if point2 is between point1 and point3 (the 3 points are supposed to be on the same line)
bool inBetween(int x1, int y1, int x2, int y2, int x3, int y3)
{
  if((x1 - x2) * (x3 - x2) <= 0 && (y1 - y2) * (y3 - y2) <= 0) return true;
  else return false;
}

以下是用它绘制的作品:



不透明度

不透明度可以用来使笔刷变透明。不透明度与透明度相反,不透明度表示笔刷有多不透明,而透明度表示笔刷有多透明。不透明度为 0 表示笔刷不可见(无法用它绘画),128 表示半透明,255 表示完全不透明(无法透过它看到任何像素)。

要实现透明效果(即可以透过绘制的内容看到旧像素),需要能够读取旧像素,为此如果我们将内容绘制到缓冲区并从那里读取像素,速度会快得多。此外,drawLine、drawDisk 等函数无法再使用,因为它们不支持透明度,必须全部重新实现。这些函数的新版本添加了前缀 paint_,例如 drawLine 变为 paint_drawLine。函数 paint_pset 与旧的 pset 函数类似地绘制像素,但支持透明度。drawLine、drawDisk、horLine 等函数除了调用 paint_pset 和 paint_horLine 之外完全相同,因此这里不再列出这些函数。

这个程序相当大,你可以在这里下载完整源代码。

在这里你可以看到新函数的声明,使用 clearScreenBuffer 替代 cls(),因为现在需要清除缓冲区而不是实际屏幕。值 opacity 用于笔刷的不透明度。

#define screenW 384
#define screenH 384

void drawToolbar();
void drawBrush(int & x1, int & y1, int x2, int y2, ColorRGB color);
double distance2D(int x1, int y1, int x2, int y2);
bool inBetween(int x1, int y1, int x2, int y2, int x3, int y3);
void paint_pset(int x, int y, ColorRGB color, Uint8 opacity);
bool paint_horLine(int y, int x1, int x2, ColorRGB color, Uint8 opacity);
bool paint_drawLine(int x1, int y1, int x2, int y2, ColorRGB color, Uint8 opacity);
bool paint_drawDisk(int xc, int yc, int radius, ColorRGB color, Uint8 opacity);
void clearScreenBuffer(ColorRGB color);

Uint8 brightness = 255; //for the color picker
int size = 25, step = 10; //brush size and step
Uint8 opacity = 16;
bool initiate; //when you just start pressing mouse button

Uint32 screenBuffer[screenH][screenW];

double frameTime, oldFrameTime;

int main(int argc, char *argv[])
{
  screen(screenW, screenH, 0, "Painting");
  clearScreenBuffer(RGB_White);
  int mouseX, mouseY;
  int oldMouseX, oldMouseY;
  bool LMB, RMB;
  ColorRGB fore = RGB_Black; //foreground color
  ColorRGB back = RGB_White; //background color

在主循环中,几乎没有变化,除了使用"drawBuffer"以及工具栏中的不透明度选择器。使用"opacityNoWrap"变量是因为 opacity 是 Uint8 类型,因此如果你输入过高或过低的值会产生溢出回绕,在这些情况下让它设置为最大值或最小值更为合理。另一个变化是每 20 毫秒才重新绘制一次屏幕和缓冲区,这样在较高分辨率下运行速度更快。

  while(!done())
  {
    getMouseState(mouseX, mouseY, LMB, RMB);
    if(!LMB && !RMB) initiate = 1;
    if((LMB || RMB) && mouseY > 64) //drawing area
    {
      if(initiate)
      {
        oldMouseX = mouseX;
        oldMouseY = mouseY;
        if(LMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, fore);
        if(RMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, back);
        initiate = 0;
      }
      if(distance2D(mouseX, mouseY, oldMouseX, oldMouseY) > (size * step / 100.0))
      {
        if(LMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, fore);
        if(RMB) drawBrush(oldMouseX, oldMouseY, mouseX, mouseY, back);
      }
      if(RMB && LMB) clearScreenBuffer(back);
    }
    if(mouseY < 64 && initiate) //toolbar area
    {
      if(mouseX < 128) //color picker
      {
        if(LMB) fore = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
        if(RMB) back = HSVtoRGB(ColorHSV(mouseX * 2, mouseY * 4, brightness));
      }
      if(mouseX >= 128 && mouseX < 144) //color picker brightness
      {
        if(LMB || RMB) brightness = (mouseY * 4);
      }
      if(mouseY >= 0 && mouseY < 8 && mouseX > 144 && LMB) //brush size
      {
        size = int(getInput<int>("Enter Size:", false, 144, 0, RGB_Red, 1, RGB_Gray));
        size = max(1, min(255, size));
      }
      if(mouseY >= 8 && mouseY < 16 && mouseX > 144 && LMB) //brush step
      {
        step = int(getInput<int>("Enter Step:", false, 144, 8, RGB_Red, 1, RGB_Gray));
        step = max(1, min(200, step));
      }
      if(mouseY >= 16 && mouseY < 24 && mouseX > 144 && LMB) //brush step
      {
        int opacityNoWrap = int(getInput<int>("Enter Opacity:", false, 144, 16, RGB_Red, 1, RGB_Gray));
        opacity = max(0, min(255, opacityNoWrap));
      }
    }
    frameTime = getTicks();
    if(frameTime - oldFrameTime >= 20)
    {
      drawBuffer(screenBuffer[0]);
      drawRect(0, 0, w - 1, 64, RGB_Gray);
      drawLine(0, 64, w - 1, 64, RGB_Black);
      drawToolbar();
      redraw();
      oldFrameTime = frameTime;
    }
  }
  return 0;
}

drawBrush 函数与之前几乎相同,唯一的区别是它使用了带有 opacity 参数的 paint_drawDisk 和 paint_drawLine。函数 paint_drawLine、paint_horLine 和 paint_drawDisk 在这里也不列出,所有这些函数都可以在可下载的 c++ 文件中找到。

paint_pset 函数向 screenBuffer 添加新像素,它使用"opacity"和"256 - opacity"对新旧像素值进行加权平均来实现透明效果。由于 screenBuffer 使用单个整数而不是 3 个字节来表示颜色,因此使用 RGBtoINT 函数将颜色转换为整数。

void paint_pset(int x, int y, ColorRGB color, Uint8 opacity)
{
  ColorRGB color2 = INTtoRGB(screenBuffer[y][x]);
  ColorRGB color3 = (color * opacity + color2 * (256 - opacity)) / 256;
  Uint32 colorINT = RGBtoINT(color3);
  screenBuffer[y][x] = colorINT;
}

clearScreenBuffer 函数简单地将缓冲区的所有像素设置为给定颜色。

void clearScreenBuffer(ColorRGB color)
{
  for(int y = 0; y < h; y++)
  for(int x = 0; x < w; x++)
  {
    screenBuffer[y][x] = RGBtoINT(color);
  }
}

drawToolbar 函数几乎相同,只是还打印了不透明度,因此这里也不列出。

以下是使用透明笔刷可以实现的效果之一:



请注意,由于笔刷会多次覆盖自身(如果步长较小),它看起来会比设置值更不透明。为了解决这个问题,你可以创建一个额外的缓冲区,在绘画时记录已经用笔刷绘制过的位置,不再对这些位置进行重复覆盖。在 Paint Shop Pro 中,可以通过禁用"Build up brush"(积累笔刷)设置来实现这一点。

本教程后续可能会扩展一个关于"硬度"的章节。


最后编辑:2004 年

版权所有 © 2004-2007 Lode Vandevenne。保留所有权利。