第10章 高级绘图(II)—— Mesh, Canvas, SpriteBatch

本章将继续介绍一些love的高级绘图功能。Mesh绘图,Canvas绘图缓存,以及SpriteBatch绘制组,以及一些数据知识。

高级绘图(II)

Mesh 网格

Mesh实际上是显卡绘制图片的基础,所有的图片都是通过用mesh组成的多边形,从图片的各个像素映射到渲染对象上的。Mesh由存储一个一个顶点位置,顶点对应的材质的位置,以及渲染颜色组成。Mesh中的顶点组成的多边形的基础单位是最简单的面,即三角形,任何复杂的图形都可以用三角形来拼接起来。而Mesh的绘制模式,则指定了这些顶点如何拼接三角形。
Mesh的主要作用是绘制任意形状,任意变形的材质,以及颜色的渐变等。

这里仅介绍标准型(非用户自定义属性mesh)Mesh的相关用法,而自定义mesh必须配合shader使用,这里暂不讲解。

mesh的创建

1
mesh = love.graphics.newMesh( vertices, mode, usage )

vertices 顶点

这个参数是一个表,包含以下列格式组成的顶点组。

1
{x,y,u,v,r,g,b,a}

其中x,y指的是mesh系统中顶点的位置(绝对位置)。u,v指的是这个顶点对应的材质的位置(它们是相对位置,取值0,1之间),r,g,b,a指的是该顶点对应的绘制颜色。

mode 构型模式

这个参数接受一个字符串,来指定顶点通过什么形式来构型的,默认为“fan”
fan 扇形,以第一个点为中心,然后其他点分别与中心点相连来构成三角形。一般用作画一些中心放射的图形。
strip 三角形链,由诸如1,2,3;3,2,4;3,4,5这种每连续3个三个顶点组成。一般用作画带状的图形。
triangles 三角形表,每三个顶点组成一个独立表,没有公用关系。它可以画任意图形,只不过如果用在前两种的话,效率稍低。因为顶点更多,有重复的。
points 孤点表,这里暂不学习。
你也可以单独的指定 setVertexMap来单独指定某个顶点的加入顺序。

usage 使用方式

这个参数也接受一个字符串,来确定顶点的读取行为,默认为“动态”
dynamic 普通的动态加载
static 顶点位置不再改变
stream 流式加载,顶点位置每帧都要变化。

解释完参数,让我们举一个形象的例子来说明mesh是如何工作的。

  1. 首先准备一个素材图片或者纯白的图片。
  2. 我们选取这个素材图片上的三个点来构成一个三角形。
  3. 准备一个可以伸缩的薄膜,裁剪成三角形同样的形状和大小,并把这个三角形内的素材复制下来。然后按照需求在三个顶点进行染色。色彩将按其距离远近进行扩散。
  4. 现在,我们这个薄膜就是一个只有一个三角形的mesh。然后我们按照我们的需求,通过拉伸这三个顶点来控制这个三角形的形状。
  5. 然后我们把这个薄膜作为一个图层放到我们的屏幕上,这就是mesh的绘制的。
  6. 如果是一组顶点,那么可以想象 把一大块薄膜蒙在图片上,然后各个顶点用图钉固定住,裁剪掉没有用的部分,我们就得到了一个带图钉的图片,图片复制到薄膜上。然后我们通过移动图钉的位置,带动薄膜改变形状,从而形成mesh新的状态。

设置材质

1
Mesh:setTexture( texture )

如果你不设置材质,就相当于用纯白填充一个矩形。

动态Mesh的更新

1
2
3
Mesh:setVertex( index, x, y, u, v, r, g, b, a )
Mesh:setVertex( index, {x, y, u, v, r, g, b, a} )
Mesh:setVertices( vertices )

上面前两种方法是针对某一个顶点的,必须指定顶点的序号。第三种可以直接整体修改。

Mesh的绘制

mesh是一个drawable对象,所以可以直接像绘制。跟普通的图片是一样的,可以拉伸,旋转等。

Canvas 绘图缓存

canvas有很多中叫法,比如内存dc,画布,帧缓存,虚拟绘制对象等等。它就是一块内存区域,在设置后,把drawcall的结果并非发送到屏幕上,而是发送到这块内存里。
canvas的作用当然就是存储了,单单存储还不够,它的核心在于“累积”。
因为之前我们讲到love绘图就跟ps一样,但是唯一的区别在于,ps是静态的积累的,而love是动态的,每一帧都要用背景颜色把画面重新填充一次。而canvas就给了我们一个积累的平台。除非手动清理,不然canvas的结果将一直被保留。另外,它作为虚拟绘制对象,是不受love.draw回调限制的,也就是对canvas的绘制可以写在任意位置。
比如有一个图片,它是动态拼接而成的,如果我们不希望重复的进行拼接工作,而直接使用拼接结果,那么我们就可以先把它绘制在canvas上,然后每次仅仅绘制这个canvas就可以了。
canvas的创建很简单,只需要提供一个宽和高就行了,默认是屏幕的大小。

1
2
3
4
5
6
7
8
9
canvas = love.graphics.newCanvas()
love.graphics.setCanvas(canvas)
love.graphics.setColor(230,240,120)
love.graphics.rectangle('fill',0,0,100,100)
love.graphics.setCanvas()
function love.draw()
love.graphics.setColor(255,255,255)
love.graphics.draw(canvas, 200,100, 0, .5,.5)
end

canvas也是一个开关设置,与其他一样,开启时加参数,关闭是无参数调用。注意,仅仅在canvas上绘制不会体现到屏幕上。
还有一种回调方式来绘制canvas,如下:

1
2
3
4
5
6
canvas:renderTo(function()
love.graphics.setColor(love.math.random(255), 0, 0);
love.graphics.line(0, 0,
love.math.random(0, love.graphics.getWidth()),
love.math.random(0, love.graphics.getHeight()));
end);

一些摄像机库也用同样的方式来绑定绘制函数。

SpriteBatch 绘制组

SpriteBatch成为精灵组、绘制组,是一种提高渲染效率的方法。它通过合并很多重复的对于同一个对象的绘制来达到减少drawcall的目的。注意,它的合并是针对某一个图片而言的,也就是不同的图片是不能合并在一个图片组里。但是由于一些素材采用的是精灵清单的形式,这种合并就十分强大而有效率,尤其对于大量的重复出现的图片。不过相比而言它的使用比较复杂,有一些库能够帮到你。试试autobatch。
创建

1
spriteBatch = love.graphics.newSpriteBatch( image, maxsprites,usage )

它要求指定一个素材,和最大组数。默认为1000. 用法与上面mesh是一样的,可以选择动态,静态和流式三种。
每当要添加绘制的时候,代码如下

1
2
id = SpriteBatch:add( x, y, r, sx, sy, ox, oy, kx, ky )
id = SpriteBatch:add( quad, x, y, r, sx, sy, ox, oy, kx, ky )

参数就是每个精灵单独绘制的参数了,实际上它是一个Matrix矩阵。返回值是id,用来单独控制某一个精灵组中的精灵。
当然,每次绘制玩了,你需要清空这个组,不然是要积累下去的。

1
SpriteBatch:clear()

精灵组的绘制同任何drawable一样。一般而言,精灵组直接绘制到0,0就行了。

数据对象

相信每一个用love的同学最开始对love.image.newImageData()这类函数比较疑惑。因为他们只是数据对象而非绘图对象。他们用来做什么呢?
在回答这个问题之前,我们首先要看看love中类似的“数据对象”,以data为基类的对象有哪些:
CompressedData 压缩数据
CompressedImageData 压缩图像数据
FileData 文件数据
GlyphData 字体数据
ImageData 图片数据
SoundData 声音数据
他们分别在love.filesystem,love.font,love.sound,love.image下。对于一般新用户来讲,很容易把他们误认为是可以直接拿来放到游戏中使用了,然而并不是这样。他们可以说是文件缓存,或者是文件与游戏可用对象之间的桥梁。一般而言,任何直接创建的可用游戏资源比如声音,图片,字体等,都是先从文件读入到数据,再从数据转到可用资源这个过程,只是中间过程省略了,没有展现到用户(类似的比如spine在读取后,会生成data对象)。
那么他们的作用是什么呢? 首先可以进行预读,比如重复播放的声音(一个场景下某个声音同时播放),需要建立多个播放对象,如果每次都从文件读取,那么效率就降低了,而从缓存读取,速度就快很多。另外,我们可以修改数据,比如imageData对象,我们可以像改任何文件一样,修改它某个像素的颜色,或者进行遍历。比如原来的图像的人物衣服是带红色配饰的,我们可以通过把所有像素红色像素改为蓝色,从而生成蓝色的人物。另外,还可以截取音效等等。还有一个功能是将数据以文件导出。最后这个功能赋予你可以用love做图形编辑软件,声音编辑软件等等(当然,专业软件不会用这种的,不过自己使用足够了。)
同时,一般而言,数据都可以作为参数来创建游戏使用对象。也就是他们是可以相互转化的。

编程时间

设计阶段

  1. 使用mesh画一个色带,送红,绿,蓝的渐变。
  2. 使用mesh画圆,并绑定在love的logo图片上。
  3. 使用canvas做一个绘图板,鼠标拖动时,在图片上画出轨迹。
  4. 使用imageData的mapPixel来制作一个圆形图案,离圆心距离越近,颜色越黑。
    分析: 第一个目标很容易,是无素材绑定的mesh,只需要6个顶点(因为有3段渐变,色带上下各一个顶点),用strip模式来组成三角形。然后把颜色值放进去就行了,uv用0,0即可(因为没有贴图)。第二个目标,跟第一个一样,只是要学会如何使用uv绑定贴图。因为没有颜色渐变,所以使用白色255,255,255,uv因为他们是比例值,所以要计算每个点在图片的位置比例,加入即可。这里logo的半径是30,素材大小是64。使用“fan”模式加入顶点。第三个目标是canvas的基本用法,只要每帧向画布上画画笔的位置即可。但是,千万别忘了,你用鼠标的轨迹,实际上是非连续的,还记得之前说的么,所以简单的画点是不行的,需要把上一帧的位置和当前帧的位置连线才行。第四个目标稍微复杂一些,因为可能大家还不太习惯这种逐像素遍历的做法和数学思想。这里关键的是用距离函数。

代码阶段

渐变色带

1
2
3
4
5
6
7
8
9
local colorVert = {
{0,0,0,0,255,0,0,255},
{0,1,0,0,255,0,0,255},
{0.5,0,0,0,0,255,0,255},
{0.5,1,0,0,0,255,0,255},
{1,0,0,0,0,0,255,255},
{1,1,0,0,0,0,255,255}
}
colorMesh = love.graphics.newMesh(colorVert, "strip") --quest 1

这里唯一注意的是,由于mesh和任何绘图模式一样可以任意拉伸,而且是无损的,所以一般我们把mesh都写成单位长度1,方便我们设置宽和高。
绑定贴图mesh

1
2
3
4
5
6
7
8
9
10
11
local circleVert = {}
circleVert[1] = {0,0,0.5,0.5}
for i = 0,30 do
local rad = i*2*math.pi/30
local x = math.sin(rad)
local y = math.cos(rad)
table.insert(circleVert,{x,y,(32+x*30)/64,(32+y*32)/64})
end
circleMesh = love.graphics.newMesh(circleVert,"fan") --quest 2
local logo = love.graphics.newImage("assets/res/logo.png")
circleMesh:setTexture(logo)

这里要说的有两点,一个是圆的本质,我们一般画圆的时候,第四个参数那个取值一般为默认,而现在我们知道了,是那个30。画圆实际不是真实的圆,而是像上面方法一样生成多边形。实际上,真实的画圆在每一次circle函数时都要进行上面的计算,因此用mesh画圆的效率要比直接circle要高(当然更占内存,功是一样的,区别在于力和力距^^)。另一个需要注意的,是”fan”的第一个顶点是圆心,千万别忘了。而且最后的一个顶点一定要回归到第二个顶点,这样才是完整的图形,而不是一个带缺口的圆。
绘图画板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
paintCanvas = love.graphics.newCanvas()
pen = {ox=0,oy=0,x=0,y=0}
function love.update()
if love.mouse.isDown(1) then
if pen.drawing then
pen.ox,pen.oy = pen.x,pen.y
pen.x,pen.y = love.mouse.getPosition()
else
pen.ox,pen.oy = love.mouse.getPosition()
pen.x,pen.y = pen.ox,pen.oy
pen.drawing = true
end
else
pen.drawing = false
end
if pen.drawing then
love.graphics.setCanvas(paintCanvas)
love.graphics.line(pen.ox,pen.oy,pen.x,pen.y)
love.graphics.setCanvas()
end
end

在建立画板时,取全屏的默认值即可。而Update中,我们跟按钮的操作是一样的,在按下是,要把ox,oy的值设定了,否者将绘制出最后一次鼠标释放的位置的连线,这不是我们想要的。 另外,canvas绘制就很简单了,只有在有笔触时绘制,因为canvas是积累的,所以不用担心没有绘制就不被保存了。
图片数据与mapPixel

1
2
3
4
5
6
7
8
9
10
11
local function dist(x,y,a,b)
return math.sqrt((x-a)^2+(y-b)^2)
end
local function mapFunc(x,y)
local color = dist(x,y,100,100)
local gray = color>100 and 0 or 255 - color*2
return gray,gray,gray,255
end
local imageData = love.image.newImageData(200, 200)
imageData:mapPixel(mapFunc)
image = love.graphics.newImage(imageData)

dist方法很简单啦,就是两点间距离公式。我们来分析下map方法,如果距离大于100,就不绘制,否则灰度值从距离100,100为0的位置起从255 - 55之间渐变。我们知道,圆就是距离到圆心小于半径的点的集合。所以,上面的方法,实际上是绘制了一个圆。这个集合的思想一定要能够贯彻理解。因为后面我们的shader的主要的思想就是它。
最后imagedata并不能用于绘制,必须转化为image才可以。

作业

  1. 做一个颜色渐变的圆。自己查询HSV与RGB公式,色调沿着圆周分布。
  2. 上面使用logo绑定的mesh,这里要求可以通过拖动来修改顶点来达到变形的目的。提示:做一个handle类,与mesh的顶点进行绑定。当handle移动时,更改对应id的mesh顶点的位置(xy改变,uv不变)。
  3. 增加一些新的功能到我们的绘图板上,模仿一下win自带的。利用button来改变笔触。想使用油漆桶?需要知道多边形的边缘,可以利用递归来计算边缘,相关算法请自行百度。
  4. 用mapPixel画一个矩形。

本章代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function love.load()
local colorVert = {
{0,0,0,0,255,0,0,255},
{0,1,0,0,255,0,0,255},
{0.5,0,0,0,0,255,0,255},
{0.5,1,0,0,0,255,0,255},
{1,0,0,0,0,0,255,255},
{1,1,0,0,0,0,255,255}
}
colorMesh = love.graphics.newMesh(colorVert, "strip") --quest 1
local circleVert = {}
circleVert[1] = {0,0,0.5,0.5}
for i = 0,30 do
local rad = i*2*math.pi/30
local x = math.sin(rad)
local y = math.cos(rad)
table.insert(circleVert,{x,y,(32+x*30)/64,(32+y*32)/64})
end
circleMesh = love.graphics.newMesh(circleVert,"fan") --quest 2
local logo = love.graphics.newImage("assets/res/logo.png")
circleMesh:setTexture(logo)
paintCanvas = love.graphics.newCanvas() --quest3
pen = {ox=0,oy=0,x=0,y=0}
local function dist(x,y,a,b)
return math.sqrt((x-a)^2+(y-b)^2)
end
local function mapFunc(x,y)
local color = dist(x,y,100,100)
local gray = color>100 and 0 or 255 - color*2
return gray,gray,gray,255
end
local imageData = love.image.newImageData(200, 200)
imageData:mapPixel(mapFunc)
image = love.graphics.newImage(imageData)
end
function love.update()
if love.mouse.isDown(1) then
if pen.drawing then
pen.ox,pen.oy = pen.x,pen.y
pen.x,pen.y = love.mouse.getPosition()
else
pen.ox,pen.oy = love.mouse.getPosition()
pen.x,pen.y = pen.ox,pen.oy
pen.drawing = true
end
else
pen.drawing = false
end
if pen.drawing then
love.graphics.setCanvas(paintCanvas)
love.graphics.line(pen.ox,pen.oy,pen.x,pen.y)
love.graphics.setCanvas()
end
end
function love.draw()
love.graphics.draw(paintCanvas)
love.graphics.draw(colorMesh,400,100,0,300,30)
love.graphics.draw(circleMesh,200,200,0,100,100)
love.graphics.draw(image,500,300)
end