从天气状态机到粒子池:pygame农场雨天系统与性能优化 系列做到第 17 篇农场的地、作物、角色移动、节气时间这些硬骨头都啃完了这一篇专门聊下雨。很多朋友第一次在 pygame 里写雨画出来能看但一跑起来就露馅雨丝忽明忽暗像接触不良同屏雨滴一多帧率从 60 掉到 30切个场景进屋子雨还在屋里下第二天一看作物全旱死了。这篇就按我在做的这个星露谷物语风格农场游戏为蓝本把下雨这件事从天气状态机、粒子池、三层视觉、世界交互一直到踩坑记录完整拆一遍。内容适合已经能跑起一个 pygame 主循环、手里有张地图和几个角色的朋友也适合完全没写过天气系统的朋友跟着抄——代码都能直接塞进你的项目里跑。1. 天气系统先按状态机搭别用 if-else 堆1.1 为什么天气不能只用一个字符串变量我见过不少入门写法是全局挂一个weather rain然后主循环里到处if weather rain:判断。单机小 Demo 能活但只要你想加“雨势渐变”“雨天转晴的那几秒”“雷阵雨和细雨视觉不同”这些需求代码立刻炸成意大利面。真正好维护的做法是天气是一个带强度和过渡的状态机而不是一个瞬时值。它至少要有四个字段当前天气类型、下一段天气类型、过渡进度、当前强度。强度用 0.0 到 1.0 的浮点数表示雨天强度 0.6 就是中雨1.0 是暴雨。这么设计的好处很直接——所有跟天气相关的表现雨滴生成速率、环境光变暗程度、雨声音量、作物浇水判定都只需要读同一个intensity不用各自再写一套判断。import random class WeatherSystem: 挂在全局 GameState 上的天气状态机 CLEAR clear RAIN rain STORM storm # 季节 - 天气 - 概率权重每天清晨掷一次 SEASON_TABLE { spring: {CLEAR: 0.50, RAIN: 0.40, STORM: 0.10}, summer: {CLEAR: 0.72, RAIN: 0.22, STORM: 0.06}, autumn: {CLEAR: 0.55, RAIN: 0.36, STORM: 0.09}, winter: {CLEAR: 0.70, RAIN: 0.20, STORM: 0.10}, } def __init__(self, seedNone): self.rng random.Random(seed) self.type self.CLEAR self.target_type self.CLEAR self.intensity 0.0 # 当前实际强度 self.target_intensity 0.0 # 目标强度 self.fade_speed 0.45 # 每秒强度变化量约 2.2 秒完成转场 self.transition 0.0 # 0~1转场进度给音效淡入用 def roll_daily(self, season): table self.SEASON_TABLE[season] keys list(table.keys()) weights list(table.values()) self.target_type self.rng.choices(keys, weightsweights, k1)[0] self.target_intensity { self.CLEAR: 0.0, self.RAIN: 0.65, self.STORM: 1.0, }[self.target_type]注意target_type和type是两个东西。清晨掷出“今天下雨”不代表画面上要立刻倾盆大雨那太突兀了。我让它用两秒多过渡过去玩家从屋里走出来时雨势正在慢慢变大这个观感比瞬间切换舒服太多。1.2 转场和强度更新的写法update(dt)里只做一件事把intensity朝target_intensity逼近逼近到一定程度再改type。这样type永远滞后于视觉表现逻辑判定用target_type渲染用intensity两者不打架。def update(self, dt): diff self.target_intensity - self.intensity if abs(diff) 0.001: self.intensity self.target_intensity self.type self.target_type else: step self.fade_speed * dt self.intensity step if diff 0 else -step self.intensity max(0.0, min(1.0, self.intensity)) # 转场进度归一化给 BGM / 雨声淡入淡出用 if self.target_intensity 0: self.transition self.intensity / max(self.target_intensity, 0.001) else: self.transition 1.0 - self.intensity self.transition max(0.0, min(1.0, self.transition))第一遍写的时候我把转场写在了渲染层结果只要玩家切场景、渲染对象被重建转场进度就归零雨势从零开始重新涨。教训是状态和表现一定要分层状态机只在 GameState 里更新渲染层每帧去读它。提示fade_speed别设太大。超过 1.5 的时候转场只有半秒多玩家几乎注意不到视觉上就是“啪”一下全暗了反而廉价。我实测 0.4 到 0.6 之间最自然。2. 雨滴粒子对象池和容量计算是性能命门2.1 每帧 new 一个雨滴是掉帧的第一元凶最直觉的写法是在主循环里这样干# 反面教材别这么写 drops [] while len(drops) 400: drops.append({x: random.randint(0, W), y: 0, v: random.randint(800, 1200)})每帧创建 400 个字典一秒 60 帧就是 24000 次对象分配再加上垃圾回收帧率曲线会像心电图。Python 的对象创建开销比你想的大得多尤其在 pygame 这种每帧只有 16.6ms 预算的环境里光分配就能吃掉几毫秒。正确做法是对象池启动时一次性创建固定数量的雨滴对象之后只改字段、绝不再新建。雨滴掉到屏幕底部就回收——把y重置到屏幕上方重新给个随机 x 和速度视觉上完全看不出这是同一个对象。2.2 容量到底该开多少这个数不能拍脑袋得算。目标是同屏视觉上看起来是连续的雨帘判断依据是屏幕横向的雨丝密度。假设窗口 1280×720中等雨势下我想让每 6 到 8 个像素的横向距离上有一条雨丝那就是 1280÷7≈183 条同屏可见。但雨丝长度只有 18 到 26 像素从顶部落到底部的时间很短池子里必须有一部分雨滴正在屏幕上方待命。按下落速度 1000 像素/秒、屏幕高 720 算单条雨丝在屏时间约 0.72 秒屏上 183 条意味着每秒要补充 254 条池子得留出余量。我的实际配置项目数值说明池容量中雨600覆盖 1280×720 屏幕视觉够密池容量暴雨1100暴雨额外叠加速率和斜度雨丝长度18~26 px每帧变化会产生抖动所以要固定下落速度 y900~1400 px/s速度差制造纵深水平速度 x-60~-140 px/s统一风向斜雨更好看重生位置 y-40 ~ -10保证从屏幕外飘进来渲染层 Surface全屏一张所有雨滴画在它上面再整体 blit池容量不要按最大值给每一档天气都开满我用一个active_count来控制当前多少滴参与更新和绘制其余保持休眠。这样小雨天只需要遍历 300 个对象而不是 1100 个。2.3 雨滴对象的字段设计用__slots__的目的是省内存、省属性查找开销600 个对象不算多但这是零成本的好习惯。class RainDrop: __slots__ (x, y, vy, vx, length, alpha, alive) def __init__(self): self.x 0.0 self.y 0.0 self.vy 1000.0 self.vx -80.0 self.length 20 self.alpha 160 self.alive Falselength我是初始化时随机一次就固定下来不在每帧重新随机的。这一点很多人会踩坑如果每帧给雨丝随机长度人眼看到的就是一条条不停伸缩的线像抽搐一样。深度感靠的是不同雨丝之间的长度差异不是单条雨丝自身的抖动。3. 三层视觉雨丝、地面溅水、环境压暗3.1 雨丝层预渲染 Surface 一次整体 blit直接对每条雨丝调pygame.draw.line打到主屏上600 条就是 600 次绘制调用。我实测在 1280×720 下这个量级还在 60 帧边缘加个地图重绘就掉了。更稳的做法是准备一张和窗口等大的SRCALPHA图层每帧清空、把雨丝画在这张图层上最后整张 blit 到主屏。绘制调用还是 600 次但主屏 blit 只有 1 次而且雨丝可以统一带透明度。class RainLayer: def __init__(self, width, height): self.w width self.h height self.surface pygame.Surface((width, height), pygame.SRCALPHA) self.drops [RainDrop() for _ in range(1100)] self.active 0 # 预渲染几种长度的雨丝贴图避免每帧 draw.line self.streak_cache {} for ln in range(16, 29, 2): s pygame.Surface((2, ln), pygame.SRCALPHA) pygame.draw.line(s, (200, 215, 235, 170), (1, 0), (1, ln), 1) self.streak_cache[ln] s def resize(self, width, height): self.w, self.h width, height self.surface pygame.Surface((width, height), pygame.SRCALPHA) for d in self.drops: d.alive False self.active 0注意resize必须清空池子。窗口拉伸之后旧雨滴的坐标全部越界不清的话要么画到屏幕外要么在左上角堆成一团。我第一次做全屏切换就踩了这个窗口拉大之后雨滴全挤在原来那块区域里特别诡异。更新逻辑def update(self, dt, intensity, wind-80.0): target int(300 intensity * 800) self.active min(target, len(self.drops)) for i in range(self.active): d self.drops[i] if not d.alive: self._spawn(d, initialTrue) continue d.x (d.vx wind) * dt d.y d.vy * dt if d.y self.h 10 or d.x -30: self._spawn(d, initialFalse) def _spawn(self, d, initialFalse): d.alive True # 初始铺满全屏之后统一从顶上方进入避免齐刷刷的雨幕边 d.y random.uniform(-40, self.h * 0.9) if initial else random.uniform(-50, -10) d.x random.uniform(-60, self.w 60) d.vy random.uniform(900, 1400) d.vx random.uniform(-30, -10) d.length random.choice((16, 18, 20, 22, 24, 26, 28)) d.alpha random.randint(120, 200)那个initial参数很关键。如果所有雨滴都从y -40开始天气刚转雨的那一瞬间屏幕上会出现一条整齐的水线从顶上压下来像瀑布闸门打开。所以第一次激活时让它们随机分布在整个屏幕高度上之后重生才从顶部进入。绘制def draw(self, screen): self.surface.fill((0, 0, 0, 0)) cache self.streak_cache for i in range(self.active): d self.drops[i] if not d.alive: continue img cache.get(d.length) or cache[20] # 斜雨用旋转代价高这里用水平偏移近似 self.surface.blit(img, (int(d.x), int(d.y))) screen.blit(self.surface, (0, 0))斜雨这里我用了个偷懒方案雨丝本身是竖直贴图靠vx让它整体横向漂移人眼在动态画面里几乎分辨不出雨丝不是真的斜的。真要精细可以把贴图预渲染几个角度版本15 度、20 度、25 度按风速选贴图成本是一次性的。我试过每帧pygame.transform.rotate600 次旋转直接掉到 20 帧以下千万别那么干。3.2 溅水层只在下半屏生成数量要掐死只有雨丝容易显得飘在空中没有落地。加一层地面水花观感立刻扎实。但溅水粒子千万不能和雨丝同数量级否则性能翻倍还乱。我的做法每帧从激活的雨滴里抽一小部分大概 3%在它们落地的位置生成水花水花存活 0.25 秒用一个小圆环从大到小、从亮到暗的 3 帧动画。class Splash: __slots__ (x, y, life, max_life) def __init__(self): self.x 0.0 self.y 0.0 self.life 0.0 self.max_life 0.25池子开 120 个就够因为水花只活 0.25 秒每秒补充 480 个也远低于这个量——实际我控制在每帧最多生成 4 个视觉上已经是地面到处在炸水花了。这个数量必须掐死不然玩家走到水边、地图上水面反射一叠加满屏小白点会很脏。生成位置也要限制只在y落在画面下方 40% 区域、且该点在地图上是室外地面不是水面、不是建筑时才生成。判断地块类型直接查地图的 tile 属性一次数组访问很便宜。3.3 环境压暗一张半透明黑胜过一切后期雨天最容易被忽略的是光。雨丝画得再密画面明度不变玩家就是感觉不到阴天。我的做法极简在所有世界内容画完之后、UI 之前叠一张覆盖全屏的黑色SRCALPHASurfacealpha 由强度控制范围 0 到 70。overlay pygame.Surface((W, H), pygame.SRCALPHA) overlay.fill((10, 18, 30, int(70 * intensity))) screen.blit(overlay, (0, 0))用(10, 18, 30)这种偏冷的深蓝黑而不是纯黑。纯黑叠上去画面发灰发闷冷色调才有雨天的湿润感。这个值我调了很多次最后定在最高 70超过 90 之后 UI 上的文字和物品图标开始看不清玩家会骂人。还有个小细节UI 层不能压在暗色层下面。背包、生命条、对话框这些必须画在 overlay 之后否则雨天打开背包字全是灰的。这个层级顺序我在项目里固定成世界内容 → 天气视觉 → 环境压暗 → 场景 UI → 全局弹窗五层不要乱。4. 雨落到世界里得真的改变点什么4.1 自动浇水判定放在日切不要每帧扫视觉做完了如果雨只是看着在下那就是个屏保。星露谷这类游戏里雨的核心玩法价值是免浇水这个逻辑必须做对。我的实现是在每天开始时游戏时间 6:00做一次批量判定而不是每帧遍历农田def on_new_day(self, farm_tiles, weather): if weather.target_type in (WeatherSystem.RAIN, WeatherSystem.STORM): for tile in farm_tiles: if tile.tilled and tile.crop is not None: tile.water tile.max_water为什么不在每帧扫因为农田 tile 可能有几百块每帧遍历是纯浪费而且下雨自动浇水在玩家认知里就是睡一觉起来地是湿的日切判定完全符合直觉还省性能。但有个容易漏的点雨天不应该触发作物干涸。如果你的干涸逻辑写在日切里顺序必须是「先判天气浇水 → 再判干涸 → 再判生长」否则刚浇完又被扣掉白干。我第一次就把顺序写反了玩家反馈下雨天作物还是旱死查了半天才发现是判定顺序问题。提示如果雨是中途开始的比如上午从晴转雨那么在转雨的那一刻也要补一次浇水判定。但已经湿的地不要再重复加water max_water是幂等的直接赋值就行别写。4.2 湿地面和泥点低成本高回报的细节浇水之后地面贴图要跟着变。我的做法是给每个已耕地的 tile 准备两张图干土和湿土water 0时切湿土。这是纯贴图替换零额外开销。再加一层角色在雨天走过泥地脚下随机溅起小泥点。这个我做得很克制——只有在泥地上移动、且雨强度大于 0.4 时才生成每 0.2 秒最多一个池子 30 个。效果是走过的地方有点脏不会糊成一片。4.3 NPC 和动物要躲雨这一条是下雨这个功能真正的灵魂。雨丝再好看NPC 还在院子里傻站着晒太阳整个世界的可信度就崩了。我给每个 NPC 加了indoor_preference字段晴天走室外路径雨天切到室内路径点。切换判定读weather.target_type在日切时一次性算好当天的路径别在每帧的 AI 里加天气判断——那会让行为树变得非常难读。动物同理鸡舍、畜棚的动物在雨天不出门。这里有个小坑已经在室外的动物不能瞬间传送回室内那样玩家能看到啪一下消失。我改成让它沿着路径走回去速度乘 1.5 表示急着躲雨观感自然很多。4.4 雨声音量跟着强度走播放只调一次音频这块坑最多。正确姿势是用pygame.mixer.Sound加载一个循环雨声用play(loops-1)起一次之后只调set_volume绝不重复play。self.rain_sound pygame.mixer.Sound(assets/rain_loop.ogg) self.rain_channel None def update_audio(self, intensity, transition): volume min(1.0, intensity * 0.8) if volume 0.01: if self.rain_channel is None or not self.rain_channel.get_busy(): self.rain_channel self.rain_sound.play(loops-1) self.rain_channel.set_volume(volume) elif self.rain_channel is not None: self.rain_channel.fadeout(600) self.rain_channel Noneget_busy()那层判断很关键。切场景时如果又调一次play你会听到两轨雨声叠在一起音量翻倍还带相位差特别明显。我用fadeout(600)而不是stop()雨停的时候声音是慢慢退去的跟视觉上的强度渐变对得上。BGM 也要交叉淡入。我的做法是 BGM 走独立的 channel雨天把 BGM 音量压到平时的 55%雨声负责主体氛围。5. 性能实测哪些写法真的会掉帧这部分是我自己拿这个项目跑出来的对比环境是 1280×720 窗口、同屏 600 条雨丝测的是渲染阶段单独耗时。写法单帧渲染耗时现象主屏直接draw.line600 次约 6.8 ms60 帧勉强加地图重绘就掉每帧transform.rotate雨丝约 22 ms直接 20 帧以下不可用预渲染贴图 图层整体 blit约 2.4 ms稳定 60 帧每帧Surface(...)新建图层约 9 ms 内存抖动长跑后卡顿加剧复用图层 fill((0,0,0,0))约 0.3 ms可忽略结论很清楚贴图预渲染 图层复用 一次 blit是这套方案的性能底线。三件事缺一不可。还有两个容易忽略的开销点。第一是fill的清屏范围。如果你的雨层 Surface 是全屏的每帧fill全屏也是有成本的。可以只清上一帧雨丝覆盖过的区域但实现复杂、收益不大我最后没做因为全屏 fill 在 1280×720 下只要 0.3ms不值得。第二是int()转换。雨滴坐标是浮点blit 前要转整数600 次int()是微秒级无所谓。但如果你的雨滴逻辑里出现了浮点的round()加字符串格式化有人在调试时留了print那个才是灾难。发布前一定要把所有循环内的print清干净我吃过这个亏一个残留的 print 让帧率掉了 15 帧。6. 踩过的坑和排查速查表6.1 雨滴闪烁、拖影、撕裂闪烁八成是每帧随机字符串导致的——雨滴的位置每帧被重新随机而不是按速度推进。检查你的更新里有没有d.x random...这种写法随机只能出现在_spawn里。拖影图层没有正确清空。用SRCALPHA的 Surface 必须fill((0,0,0,0))fill((0,0,0))会把 alpha 通道写成不透明你看到的是上一帧残留的黑色雨丝。撕裂感雨丝长度每帧变化。前面说过长度要初始化时定死。6.2 切场景之后雨还在屋里下这是架构问题不是渲染问题。天气系统必须挂在全局 GameState上而不是某个 Scene 的成员。如果你的 Scene 在__init__里创建了天气对象那么进出屋子就会重置一次进屋时雨强度归零、出门时又从零涨起看起来就是雨停了又下。同理雨层 Surface 和粒子池也应该挂在全局渲染器上切场景只切换当前是否绘制天气层。室内场景直接跳过天气层绘制逻辑上天气还在继续只是你看不见。6.3 常见问题速查表现象最可能原因处理方式雨丝整片抖动每帧重新随机长度/横坐标只在_spawn中随机帧率随雨量线性下降每帧新建对象或 Surface对象池 预渲染贴图雨声叠成两轨重复调用play判断get_busy()雨层盖住 UI层级顺序错误天气层在场景 UI 之前地面不湿但雨在下日切判定顺序写反浇水 → 干涸 → 生长读档后雨从零开始涨序列化时只存了类型存intensity并直接赋值窗口缩放后雨滴堆在角落未在resize中重置池子清空所有alive标记暴雨时画面全黑压暗 alpha 超过 90上限控制在 70存档这块单独说一句如果你把天气序列化成{type: rain, intensity: 0.65}读档时直接赋值给self.intensity别让它走过渡。否则玩家读档进来明明存储时是暴雨画面却从晴天开始慢慢变暗那种错乱感非常出戏。至于target_intensity可以按类型表推导一遍就行。6.4 关于雷阵雨的额外两个坑如果你要做暴雨加闪电闪电的闪白千万不要每帧随机触发那样会把人闪出癫痫。正确做法是按固定节奏比如每 6 到 12 秒一次闪白持续 0.08 秒用一张全白SRCALPHA图层叠上去alpha 从 120 迅速衰减到 0。闪电触发的同时把雷声音效延迟 0.3 到 0.8 秒播放用pygame.time.set_timer发一个自定义事件去做延迟比在主循环里塞 sleep 靠谱得多。另一个坑是暴雨时雨声和雷声会争抢混音通道。默认pygame.mixer.init()的通道数是 8如果你的项目里音效本来就多雨声可能会被挤掉。启动时把通道数开到 16 或 32一劳永逸。7. 我个人在这块的一点体会下雨这个功能我前后改了三版第一版是纯视觉、画完就完第二版加了浇水判定和 NPC 躲雨但性能垮了第三版才把状态机、粒子池、图层复用这三块理清。回头看最值得先做的其实不是雨滴而是那个天气状态机和小小的intensity变量。有了它雨滴密度、环境压暗、雨声音量、作物判定、NPC 行为全都挂在同一个数上改一处参数整体联动调试起来非常舒服。反过来如果一开始就扑在雨滴怎么画后面每加一个交互都要再写一套天气判断那种维护成本会让人想重开项目。另外一个小建议在游戏里加一个调试快捷键直接切换天气类型并打印当前同屏粒子和帧率。我绑的是 F3按住能看到实时的active数量、intensity和帧时间。这个两行代码的调试面板比我反复重启游戏试效果省下来的时间多太多了。要是你也在做类似系统可以先把这套天气框架跑通再回头去堆美术细节路会顺很多。