Surface 是一块像素,不是「一张图」。图片加载进来也是 Surface。
subsurface 开的是窗口不是副本,改子就是改父。
PNG 无损、JPG 有损;小图上 JPG 反而更大,这是它的固定开销。
上一篇把窗口开起来了,这一篇往里放东西。 教材接下来三节是【窗口管理】【Surface 对象】【图片加载】。 三节看着各说各的,其实是同一件事的三个角度:窗口是最大的那块 Surface, 图片加载进来是小的 Surface,subsurface 是从大的里切一块出来。 演示里存的图和读回来的图都是真的,文件就在临时目录里,字节数可查。
一、窗口能设的三样
set_mode 第一个参数是尺寸,第二个参数是标志。
标志用按位或拼:pygame.RESIZABLE | pygame.NOFRAME 就是「能拉大、没有边框」。
演示里打了三个常量的值:RESIZABLE 是 16,NOFRAME 是 32,FULLSCREEN 是一个很大的负数。
这几个数是位掩码,一个整数里每一位代表一个开关。
16 是第五位,32 是第六位,按位或就把两个开关同时打开。
set_caption 设标题栏的字,set_icon 设图标。
这两个都是「门面」——不影响任何逻辑,但用户第一眼看到的就是它们。
二、Surface 是一块像素,不是一张图
这句话要反复说。
pygame.Surface((200, 100)) 建出来的东西,本质是一块宽 200、高 100、每像素 4 字节的内存,
一共 80 KB。
它没有「文件」这个概念。图片是「磁盘上的编码数据」,Surface 是「内存里解开的像素」。
load() 干的事就是解码。
演示里新建了一块 200×100 的 Surface,第一个像素是 (0, 0, 0, 255)。
注意最后那个 255——新建的 Surface 是黑的、不透明的,不是「空白」。
save() 反过来:把内存里的像素编码成文件。所以你可以「程序生成一张图」,不一定非要从磁盘读。
本课后面画飞机、画方块,全是这么来的。
三、subsurface 开的是窗口,不是副本
这是本篇最该记住的一条。 演示里建了一块 200×200 的灰底,从 (50,50) 切一块 100×100 出来,给子块填红, 回头再看父块——父块那个位置也变红了。 因为它俩指向同一块内存。子块只是「父块里某个矩形范围」的另一个名字。
这个特性有用:想只更新屏幕的一小块,就用 subsurface 圈出来,只重画那一块。
第 3 章做界面分块时用得上。
要真的另存一份,用 copy()。演示里复制一份再填绿,父块一点没变——这就是区别。
四、图片:存出去,再读回来
演示里先画了一张 64×64 的图(黄底红圆),存成 PNG,再 load 回来。
读回来的尺寸一样、像素一个不差——PNG 是无损的。
但位深从 32 变成 24,因为那张图没有透明区域,PNG 就省掉了 alpha 通道。
再存成 JPG,情况就变了:字节数从几百涨到一千多,像素也从 (250, 210, 120) 变成了 (250, 211, 120)。
差 1 个色阶,肉眼看不出来,但数据确实变了。
为什么小图上 JPG 反而更大?因为它有块结构和量化表的固定开销,这部分跟图多大多小没关系。 图越小、色块越纯,PNG 越划算。 反过来说:一张 2000×1500 的照片,JPG 可能只有 PNG 的十分之一。 选格式要看图的内容,不是看哪个「更先进」。 游戏里怎么选?精灵图、像素画、界面元素用 PNG,要透明通道;背景大图、过场动画帧用 JPG,省空间。
五、blit 的第三个参数
surface.blit(源图, 目标位置) 是整张贴。加第三个参数 area,就只贴源图的一块。
演示里从 64×64 的图上抠左上角 32×32,贴到 (10,10)。
验证:(15,15) 有颜色,(45,45) 还是黑的——超出那块区域的部分没贴过来。
这个参数在精灵图(sprite sheet)里天天用:一张大图上排着走、跑、跳几十帧,
每帧就用一个 area 抠出来。第 6 章讲帧动画时会用到。
六、透明度:两种完全不同的东西
set_alpha(128) 设的是「这张图整体多透明」。
演示里设完之后 get_alpha() 返回 128,但像素里的 alpha 通道还是 255。
为什么?因为它不改数据,只在 blit 的时候把整张图的透明度算进去。做淡入淡出就用它,一个数管全图。
set_colorkey(颜色) 是另一回事:「这个颜色在贴的时候跳过」。
老式素材常用纯品红当透明色,就是靠这个。
两者不能混用。一个管整体浓淡,一个管某个颜色要不要。 现代做法是直接用带 alpha 通道的 PNG,两种都不需要。
七、动手 Demo:窗口、Surface 与图片
先把窗口的三样设置看清楚,再建一块 Surface 看它里面到底是什么。 然后用 subsurface 演示「切的不是副本」,用 copy 做对照。 最后存 PNG 和 JPG 各一份,读回来对比字节和像素。
import os
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['SDL_AUDIODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame
def info(s):
print(' ' + s)
def note(s):
print(' ! ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
pygame.init()
# ① 窗口三样
sec('① 窗口能设的三样')
scr = pygame.display.set_mode((640, 480))
pygame.display.set_caption('标题栏上的字')
info('尺寸 %s,标题 %r' % (scr.get_size(), pygame.display.get_caption()[0]))
info('RESIZABLE=%d NOFRAME=%d FULLSCREEN=%d'
% (pygame.RESIZABLE, pygame.NOFRAME, pygame.FULLSCREEN))
# ② Surface 里是什么
sec('② Surface 就是一块像素')
s = pygame.Surface((200, 100))
info('尺寸 %s,每像素 %d 位、%d 字节'
% (s.get_size(), s.get_bitsize(), s.get_bytesize()))
info('没填过色时,第一个像素是 %s' % (tuple(s.get_at((0, 0))),))
s.fill((30, 144, 255))
info('填成天蓝之后,第一个像素是 %s' % (tuple(s.get_at((0, 0))),))
# ③ subsurface 不是副本
sec('③ subsurface 是窗口,不是副本')
big = pygame.Surface((200, 200))
big.fill((80, 80, 80))
sub = big.subsurface(pygame.Rect(50, 50, 100, 100))
info('父 %s,子 %s' % (big.get_size(), sub.get_size()))
info('填充前,父的 (60,60) 是 %s' % (tuple(big.get_at((60, 60))),))
sub.fill((255, 0, 0))
info('给子填红之后,父的 (60,60) 变成 %s' % (tuple(big.get_at((60, 60))),))
copy = big.copy()
copy.fill((0, 255, 0))
info('copy() 一份再填绿,父的 (60,60) 还是 %s' % (tuple(big.get_at((60, 60))),))
# ④ 存图读图
sec('④ 图片:存出去再读回来')
art = pygame.Surface((64, 64))
art.fill((250, 210, 120))
pygame.draw.circle(art, (220, 60, 60), (32, 32), 20)
pygame.image.save(art, 'art.png')
info('PNG 存出去:%d 字节' % os.path.getsize('art.png'))
back = pygame.image.load('art.png')
info('读回来:尺寸 %s、每像素 %d 位' % (back.get_size(), back.get_bitsize()))
info('像素 %s -> %s' % (tuple(art.get_at((0, 0))), tuple(back.get_at((0, 0)))))
pygame.image.save(art, 'art.jpg')
jpg = pygame.image.load('art.jpg')
info('JPG 存出去:%d 字节,像素变成 %s'
% (os.path.getsize('art.jpg'), tuple(jpg.get_at((0, 0)))))
# ⑤ blit 的第三个参数
sec('⑤ blit 的第三个参数:只贴一块')
target = pygame.Surface((100, 50))
target.fill((0, 0, 0))
target.blit(art, (10, 10), pygame.Rect(0, 0, 32, 32))
info('抠左上 32x32 贴到 (10,10):(15,15) 是 %s'
% (tuple(target.get_at((15, 15)))[:3],))
info('区域外的 (45,45) 还是 %s' % (tuple(target.get_at((45, 45)))[:3],))
# ⑥ 透明度
sec('⑥ 透明度:两种办法')
fade = pygame.Surface((40, 40))
fade.fill((255, 255, 255))
fade.set_alpha(128)
info('set_alpha(128):get_alpha()=%s,像素 alpha 还是 %d'
% (fade.get_alpha(), fade.get_at((0, 0)).a))
key = pygame.Surface((40, 40))
key.fill((10, 10, 10))
key.set_colorkey((10, 10, 10))
info('set_colorkey 之后像素没动:(0,0) 还是 %s'
% (tuple(key.get_at((0, 0)))[:3],))
pygame.quit()
实跑输出:
--------------------------------------------------------------------
窗口、Surface 与图片
--------------------------------------------------------------------
① 窗口能设的三样
--------------------------------------------------------------------
尺寸 (640, 480),标题 '标题栏上的字'
可调大小的标志 = 16,无边框 = 32
全屏的标志 = -2147483648
! 尺寸类标志用按位或拼:set_mode((640, 480), pygame.RESIZABLE)。
② Surface 就是一块像素
--------------------------------------------------------------------
新建一块:尺寸 (200, 100),每像素 32 位,4 字节
没填过色的时候,第一个像素是 (0, 0, 0, 255)
填成天蓝之后,第一个像素是 (30, 144, 255, 255)
! 新建的 Surface 默认是黑的、不透明的,不是「空白」。
③ subsurface 是窗口,不是副本
--------------------------------------------------------------------
父 (200, 200),子 (100, 100)
填充前,父的 (60,60) 是 (80, 80, 80, 255)
给子填红之后,父的 (60,60) 变成 (255, 0, 0, 255)
! subsurface 不是复制,是开了个窗口。改子就是改父。
copy() 一份再填绿,父的 (60,60) 还是 (255, 0, 0, 255)
要真的另存一份,得用 copy()。
④ 图片:存出去再读回来
--------------------------------------------------------------------
存成 PNG:332 字节,64×64 圆
读回来:尺寸 (64, 64),每像素 24 位
原图第一个像素 (250, 210, 120, 255),读回来 (250, 210, 120, 255)
! PNG 是无损的,像素一个不差。换成 JPG 再看就不一样了。
存成 JPG:1434 字节
JPG 读回来第一个像素 (250, 211, 120, 255)
| 格式 | 字节数 | (0,0) 的像素 | 无损吗 |
|---|---:|---|---|
| PNG | 332 | (250, 210, 120) | 是 |
| JPG | 1434 | (250, 211, 120) | 否 |
! 这张小图上 JPG 反而比 PNG 大——它有块结构和量化表的固定开销。
而且像素已经变了:原图 (250, 210, 120),JPG 读回来 (250, 211, 120)。
! 图越小、色块越纯,PNG 越划算;照片那种连续渐变,JPG 才占便宜。
⑤ blit 的第三个参数:只贴一块
--------------------------------------------------------------------
从原图左上角抠 32×32 贴过去,贴在 (10,10)
贴到了吗:(15,15) 是 (250, 210, 120),(45,45) 还是 (0, 0, 0)
! 第三个参数不给就整张贴;给了就只贴那一块,位置按给的区域算。
⑥ 透明度:两种办法
--------------------------------------------------------------------
set_alpha(128) 之后:get_alpha() 返回 128,像素的 alpha 通道是 255
! set_alpha 设的是「这张图整体多透明」,blit 时才算进去,不改数据本身。
设了 colorkey 之后 get_colorkey() 返回 (10, 10, 10, 255)
像素数据没动:(0,0) 还是 (10, 10, 10)
! set_alpha 是「整张变淡」,colorkey 是「这个颜色贴的时候跳过」。两回事。
第二段那个 (0, 0, 0, 255) 里最后一位是 alpha。新建的 Surface 是不透明的黑,不是「什么也没有」。
这一点很关键:如果你忘了 fill,看到的是黑的,不是背景。
第三段是两个对照实验。先给子块填红,父块跟着红——说明是一块内存。
再用 copy() 复制一份填绿,父块没变——说明复制才是真另存。
两个实验只差一个方法名,结论正好相反,这就是为什么要都做一遍。
第四段里 PNG 那个「32 位变 24 位」值得停一下:原图是新建的 Surface,带 alpha;
存成 PNG 时因为整张图不透明,编码器就把 alpha 通道省了。读回来是 24 位。
这不是丢信息,是没有的信息不存。
JPG 那一行是本篇最有意思的地方:字节数涨了、像素也变了。
小图上 JPG 比 PNG 大,跟「JPG 压缩率高」的常识反着。原因是有固定开销,这部分摊不薄。
第六段两个「透明度」别混:set_alpha 改的是「整张多浓」,set_colorkey 改的是「哪个颜色不要」。
一个改浓淡,一个改取舍。
30 秒实验
- 把
copy()那一行换成subsurface再跑,看父块变不变,验证两者的区别。 - 把演示里那张 64×64 的图改成 640×640 再存一次 PNG 和 JPG,看字节数的差距是不是反过来了。
- 把
set_alpha(128)换成set_alpha(0)和set_alpha(255),看get_alpha()分别返回什么, 再想想「0 是全透明还是全不透明」。
常见误区
- 「Surface 就是图片。」Surface 是内存里解开的像素,图片是磁盘上编码的数据。
load()是解码,save()是编码。一个 Surface 可以没有对应的文件。 - 「
subsurface是切一块出来。」切出来的是窗口,不是副本。 改子块,父块跟着变。 要真另存用copy()。 - 「JPG 一定比 PNG 小。」小图、纯色块上反过来了,因为 JPG 有块结构和量化表的固定开销。 选格式看内容,不看名气。
自测三问(点开对答案)
1. `big.subsurface(rect)` 和 `big.copy()` 拿到的都是 Surface,有什么本质区别? —— `subsurface` 拿到的是**父 Surface 里某块区域的别名**,两者共用同一块内存, 改任何一个另一个都变。`copy()` 是**新分配一块内存**,把像素拷过去,之后两者互不影响。 判断方法:改一个看另一个变不变。 2. 为什么把一张 64×64 的图存成 JPG,字节数反而比 PNG 多? —— 因为 JPG 编码有固定开销——量化表、哈夫曼表、8×8 块的结构信息,这些跟图的尺寸关系不大。 64×64 一共才 4096 个像素,这点数据摊不薄那些开销。图越大,JPG 的相对优势才越明显。 3. `set_alpha(128)` 之后,为什么 `get_at((0,0)).a` 还是 255? —— 因为 `set_alpha` 设的是 Surface 级别的「整体透明度」,它不改像素数据, 只在 `blit` 混合时参与计算。 要改像素本身的 alpha,得用 `fill` 带 alpha 的颜色,或者 `set_at` 一个个写。下一篇往这块像素上画东西:六个画图函数、写字、还有那个到处都在用的 Rect。
- ☐ (本篇完,下一篇 2.3 画图形、写字与 Rect)