尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Godot 4.7 用颜色贴图驱动场景物体散布:从原理到性能优化

Godot 4.7 用颜色贴图驱动场景物体散布:从原理到性能优化 在游戏开发里手动摆放植被、石头、道具这类重复性工作做过的都懂——几百上千个物件一个个拖进场景调位置、调旋转、调缩放眼睛都快看瞎了改一次地形还得全部重来。我最近在 Godot 4.7 里折腾出一套用地面 UV 贴图颜色来驱动物体散布的方案核心思路特别朴素把哪里该长草、哪里该放石头这件事直接画进一张贴图里让程序读颜色自己判断。这篇就把整套流程从原理到落地完整拆一遍包括贴图怎么画、UV 怎么对齐、采样怎么算、随机怎么控、性能怎么保以及我踩过的那些坑。不管你是刚打开 Godot 的新手还是已经在做开放世界地形的老手这套思路都能直接抄去用。1. 为什么用颜色贴图来驱动散布而不是刷子或噪声1.1 传统散布方式的三个痛点先说清楚为什么要绕这么一圈。最常见的做法有三种手动摆放、程序化噪声比如 FastNoiseLite、以及引擎自带的笔刷工具。手动摆放的问题不用多说量一大就废而且地形一改之前摆的全对不上。噪声方案听起来很美但它的分布是数学均匀的你很难精确控制这片区域只长松树、那片区域只长灌木想微调就得改代码参数来回试错成本极高。笔刷工具比如地形插件里的绘制交互友好但往往和特定插件绑定导出、复用、版本管理都麻烦而且笔刷数据存在插件自己的格式里换个工具就迁移不了。颜色贴图方案的本质是把分布规则从代码和插件里抽出来变成一张标准的图片资源。这张图你可以用任何画图软件画可以用程序生成可以手绘可以拿卫星图改它就是一个普通的 PNG。规则可视、可编辑、可版本管理、可跨工具复用这是它最大的价值。1.2 颜色即规则一张图说清哪里放什么具体怎么理解颜色即规则假设你有一块 100x100 米的地形上面铺了一张 1024x1024 的分布贴图。贴图上的每个像素对应地面上的一小块区域。像素是红色代表这里该长草像素是蓝色代表这里该放石头像素是绿色代表这里该种树像素是黑色代表这里什么都不放。程序要做的就是在贴图上按一定密度撒点读每个点的颜色根据颜色去对应的物体列表里挑一个实例化出来。这套逻辑的好处在于它把美术判断和程序逻辑彻底解耦了。美术只需要画图程序只需要读图两边通过颜色约定这个接口沟通。你想调整分布改图就行不用碰一行代码。你想加一种新物体在约定里加一个颜色、在配置里加一条映射就行。1.3 和高度图、权重图方案的对比有人会问那用高度图或者权重图行不行行但各有取舍。高度图只能表达高度一个维度你想同时控制种类和密度就不够用了。权重图比如 splatmap能表达多个通道红通道控制草、绿通道控制石头本质和颜色贴图是一回事只是把颜色拆成了通道。颜色贴图更直观的地方在于你画出来直接能看到最终效果不用在脑子里把通道合成回颜色。对于中小规模项目颜色贴图的可读性优势很明显对于超大规模、需要精细混合的场景权重图的多通道方案更省内存。我个人的选择是种类少于 8 种、区域边界比较清晰的项目直接用颜色贴图种类多、需要平滑过渡的再上权重图。2. 贴图制作与 UV 对齐整套方案最容易翻车的地方2.1 分布贴图的尺寸与格式选择贴图尺寸不是随便定的。它决定了分布的最小精度一张 1024 的图铺在 100 米地形上一个像素大约对应 10 厘米这个精度对植被散布足够了。如果你要散布的是很小的物件比如小石子、花草可能需要 2048 甚至更高。但要注意贴图越大采样时的内存和带宽开销越大而且画起来也越累。我的经验值是地形每 10 米对应 100 到 200 像素这个比例下精度和成本比较平衡。格式上强烈建议用PNG 无损格式不要用 JPG。JPG 的有损压缩会在颜色边界产生杂色本来该是纯红的地方可能混进一点蓝采样时就会误判成石头。如果贴图很大想省空间可以用 PNG 的调色板模式索引色把颜色数量压到几十种既小又不会有压缩杂色。另外贴图的色彩空间要注意Godot 里如果贴图被当作 sRGB 处理采样出来的颜色值会经过伽马变换和你画图软件里看到的 RGB 值对不上。做颜色判断时最好在导入设置里把这张图的 sRGB 关掉或者干脆用Image直接读原始像素绕开色彩空间转换。2.2 地形 UV 和贴图坐标的映射关系这是整套方案里最容易出错的一环。Godot 的MeshInstance3D上的 UV取决于你的地形网格是怎么生成的。如果你用的是PlaneMesh它的 UV 默认是 0 到 1 铺满整个平面那贴图正好对应整个地形简单直接。但如果你用的是HeightMapShape3D配合自定义网格或者用了地形插件生成的网格UV 的分布可能不是 0 到 1而是按世界坐标平铺的比如每米对应 1 个 UV 单位这时候你直接拿 UV 去采样贴图就会错位。我的做法是不依赖网格自带的 UV而是用世界坐标自己算 UV。具体来说记录地形的世界包围盒AABB把物体候选点的世界坐标 X、Z 归一化到 0 到 1再乘以贴图尺寸就得到了贴图上的像素坐标。这样做的好处是完全可控不管地形网格怎么生成映射关系都由你说了算。代价是要多存一个包围盒信息但这个信息本来就有几乎零成本。# 用世界坐标计算贴图 UV 的示例 func world_to_uv(world_pos: Vector3, terrain_aabb: AABB) - Vector2: var u (world_pos.x - terrain_aabb.position.x) / terrain_aabb.size.x var v (world_pos.z - terrain_aabb.position.z) / terrain_aabb.size.z return Vector2(u, v)2.3 边界处理与采样精度问题采样时有两个细节要注意。第一是边界钳制如果候选点刚好在地形边缘算出来的 UV 可能是 0 或 1采样时如果贴图设置了重复repeat会读到对面的像素导致边缘出现莫名其妙的物体。解决办法是把 UV 钳制到[0.001, 0.999]这种安全范围或者采样前判断点是否在包围盒内。第二是采样精度Image.get_pixel()接受的是整数像素坐标如果你传浮点数它会自动取整。取整方式可能导致边界像素判断偏移所以最好自己用floor()明确取整避免不同平台行为不一致。还有一个隐蔽的坑贴图的 Y 轴方向。Godot 的Image坐标原点在左上角Y 向下增长而世界坐标的 Z 轴在俯视视角下通常对应屏幕的上下。如果你直接把世界 Z 映射到贴图 Y可能会发现分布上下颠倒了。这时候要么在画图时就把图上下翻转要么在计算 V 坐标时用1.0 - v翻转一下。我建议在代码里翻转因为画图的人不一定知道这个约定代码里处理更稳妥。3. 采样与实例化从像素颜色到场景里的物体3.1 候选点生成均匀撒点还是抖动网格要在贴图上撒点最直接的方式是遍历每个像素但那样点太密了而且完全均匀看起来像棋盘很假。更好的做法是抖动网格jittered grid把地形划分成固定大小的格子每个格子里随机放一个点点的位置在格子内随机偏移。这样既保证了整体密度均匀又避免了规则排列的机械感。格子大小怎么定它决定了物体的平均间距。假设你想要每 2 米一个候选点格子就设成 2x2 米。但要注意候选点密度和最终物体密度是两回事候选点只是可能放物体的位置最终放不放、放什么还要看颜色和概率。所以候选点可以撒得比目标密度稍密一些给随机性留空间。# 抖动网格生成候选点 func generate_candidates(aabb: AABB, cell_size: float, rng: RandomNumberGenerator) - Array[Vector3]: var points: Array[Vector3] [] var x_count int(aabb.size.x / cell_size) var z_count int(aabb.size.z / cell_size) for i in x_count: for j in z_count: var base_x aabb.position.x i * cell_size var base_z aabb.position.z j * cell_size var offset_x rng.randf() * cell_size var offset_z rng.randf() * cell_size points.append(Vector3(base_x offset_x, 0, base_z offset_z)) return points3.2 颜色匹配精确匹配还是容差匹配采样到颜色后怎么判断它属于哪一类最严格的是精确匹配比如Color(1,0,0)就是草。但实际画图时由于抗锯齿、笔刷边缘、压缩等原因颜色往往不是纯的会有偏差。所以要用容差匹配设定一个阈值颜色距离小于阈值就算匹配。距离可以用欧氏距离也可以用各通道差值的最大值。# 带容差的颜色匹配 func match_color(sampled: Color, palette: Array[Dictionary], tolerance: float) - int: for idx in palette.size(): var target: Color palette[idx][color] var dr abs(sampled.r - target.r) var dg abs(sampled.g - target.g) var db abs(sampled.b - target.b) if max(dr, max(dg, db)) tolerance: return idx return -1 # 不匹配任何一类容差设多大我的经验是 0.1 到 0.15 比较合适。太小了边缘像素匹配不上太大了不同颜色会混淆。如果你的贴图是手绘的、边缘模糊容差可以适当放大如果是程序生成的纯色块容差可以设很小甚至精确匹配。3.3 概率控制让分布有疏密变化如果每个匹配的候选点都放物体分布会太均匀。真实的自然分布是有疏密的林子中间密、边缘稀石头成簇出现。要模拟这个可以在颜色匹配通过后再加一层概率判断。概率可以来自几个地方一是颜色本身的亮度亮的地方概率高、暗的地方概率低二是另一张噪声图用噪声值当概率三是简单的随机数按固定概率决定放不放。我常用的是颜色亮度 随机数的组合把匹配到的颜色转成灰度灰度值当基础概率再乘一个随机因子。这样画图时同一个颜色画得深一点就稀、浅一点就密控制粒度很细。# 用颜色亮度控制散布概率 func should_place(sampled: Color, base_chance: float, rng: RandomNumberGenerator) - bool: var luminance sampled.get_luminance() var chance base_chance * luminance return rng.randf() chance3.4 实例化方式MultiMesh 还是独立节点物体数量少几百个时直接instantiate()成独立节点没问题。但一旦上千独立节点的开销就上来了每个节点都有 transform、脚本、渲染批次。这时候要用MultiMeshInstance3D。MultiMesh 把同一网格的多个实例合并成一次绘制调用性能提升非常明显。代价是每个实例不能有独立的脚本逻辑只能共享材质而且 transform 要通过set_instance_transform()设置。对于纯装饰性的散布草、石头、树MultiMesh 是首选。如果物体需要独立交互比如可采集的资源那就得用独立节点或者用 MultiMesh 做视觉、另用轻量数据结构管理逻辑。我的建议是视觉用 MultiMesh逻辑用数组两者通过索引对应兼顾性能和灵活性。# 用 MultiMesh 批量实例化 func build_multimesh(mesh: Mesh, transforms: Array[Transform3D]) - MultiMeshInstance3D: var mm MultiMesh.new() mm.transform_format MultiMesh.TRANSFORM_3D mm.mesh mesh mm.instance_count transforms.size() for i in transforms.size(): mm.set_instance_transform(i, transforms[i]) var mmi MultiMeshInstance3D.new() mmi.multimesh mm return mmi4. 随机性与自然感让散布看起来不像程序生成的4.1 随机旋转、缩放与位置微调程序生成的散布最容易露馅的地方就是所有物体朝向一致、大小一致。真实的植被和石头朝向是随机的大小有差异。所以每个实例的 transform 都要加随机扰动绕 Y 轴随机旋转 0 到 360 度缩放随机在 0.8 到 1.2 之间位置在候选点基础上再随机偏移一点点。这三样加起来视觉上的机械感基本就消掉了。但要注意旋转和缩放要基于物体自身的局部坐标系。如果你直接改Transform3D的 basis可能会把物体压扁或拉歪。正确做法是用Transform3D的rotated()和scaled()方法或者用Basis.from_euler()构造旋转再组合。# 给实例加随机扰动 func randomize_transform(base_pos: Vector3, rng: RandomNumberGenerator) - Transform3D: var yaw rng.randf_range(0, TAU) var scale_factor rng.randf_range(0.8, 1.2) var offset Vector3(rng.randf_range(-0.3, 0.3), 0, rng.randf_range(-0.3, 0.3)) var basis Basis(Vector3.UP, yaw).scaled(Vector3.ONE * scale_factor) return Transform3D(basis, base_pos offset)4.2 用噪声打破网格感抖动网格虽然比规则网格好但如果格子大小固定还是能看出隐约的网格纹理。要彻底打破可以叠加一层低频噪声让候选点的位置整体偏移。噪声的波长设成格子大小的 2 到 3 倍偏移幅度设成格子大小的 0.3 到 0.5 倍这样既保留了密度均匀性又打散了网格感。Godot 的FastNoiseLite用起来很方便采样两个通道分别偏移 X 和 Z 就行。4.3 避免重叠与穿模随机扰动之后两个物体可能靠得太近甚至重叠。对于草这种小物件重叠无所谓但对于树、大石头重叠会穿模很难看。解决办法是加一个最小间距检查维护一个已放置位置的列表新点如果和任何已有点的距离小于阈值就跳过。这个检查用简单的距离平方比较就行不用开方性能可以接受。如果物体数量特别大可以用空间网格加速但一般几千个点用暴力检查也够快。还有一个穿模问题是物体和地形的贴合。如果地形有起伏物体底部可能悬空或陷进地里。解决办法是采样地形高度把物体的 Y 坐标设成地形高度。Godot 里可以用PhysicsDirectSpaceState3D做射线检测从物体上方往下打一条射线命中点就是地面高度。射线检测有开销所以只在需要精确贴地的物体上做草这类小物件可以省略。5. 性能优化与工程化从能跑到好用5.1 采样和实例化的耗时分布整套流程的耗时主要在三块候选点生成、颜色采样、实例化。候选点生成是纯计算很快颜色采样要读贴图像素如果贴图大、候选点多可能成为瓶颈实例化如果是 MultiMesh一次性设置 transform 也很快但如果是独立节点节点数一多就慢。优化的重点是减少不必要的采样。比如如果某个区域整片都是黑色不放物体可以先做一次粗粒度的区域判断跳过整片黑色区域不用逐点采样。再比如颜色匹配可以用查表法把贴图预先量化成有限的几种颜色建一个颜色到类别的查找表采样时直接查表比逐个比较调色板快得多。5.2 分块加载与视距剔除如果地形很大一次性生成所有物体不现实。这时候要分块把地形切成若干块每块单独生成物体根据玩家位置动态加载和卸载。Godot 里可以用VisibleOnScreenNotifier3D或者自己算距离来判断哪些块该加载。MultiMesh 的实例也可以动态增删但增删有开销所以块的大小要权衡太小了加载频繁太大了单块生成卡顿。视距剔除是另一个手段远处的物体用低模或者干脆不显示。MultiMesh 支持visibility_range设置可以按距离淡出。对于草这种密集小物件视距剔除效果特别明显能省下大量绘制。5.3 把配置抽成资源文件最后说工程化。整套方案里有很多参数贴图路径、调色板、容差、格子大小、概率、随机种子、物体网格和材质。这些如果硬编码在脚本里改一次就要改代码很痛苦。我的做法是定义一个自定义Resource把这些参数都做成导出属性在编辑器里配置存成.tres文件。这样美术和策划也能自己调不用找程序。# 自定义散布配置资源 class_name ScatterConfig extends Resource export var distribution_texture: Texture2D export var cell_size: float 2.0 export var color_tolerance: float 0.12 export var base_chance: float 0.8 export var random_seed: int 0 export var palette: Array[Color] [] export var prefabs: Array[PackedScene] []随机种子单独抽出来很重要。做程序化生成最怕的就是每次运行结果不一样没法复现问题。把种子固定下来同样的配置永远生成同样的分布调试和回归测试都方便。想换一种分布改种子就行不用改其他参数。6. 我踩过的几个坑和对应的解法6.1 贴图 sRGB 导致的颜色判断失效这个坑我卡了最久。画图软件里明明是纯红(255,0,0)采样出来却是(1,0,0)经过伽马变换后的值和调色板里的Color(1,0,0)对不上容差再大也匹配不上。原因是 Godot 默认把贴图当 sRGB 处理采样时做了线性化。解法是在贴图导入设置里关掉 sRGB或者用Image.load()直接读原始数据。我现在的习惯是凡是用于数据判断的贴图一律关 sRGB避免这类问题。6.2 UV 翻转导致的分布镜像前面提过世界 Z 和贴图 Y 的方向可能相反。我第一次做的时候画好的分布图放进去物体全跑到镜像位置去了排查了半天才发现是 Y 轴翻转。后来我在代码里统一做了v 1.0 - v的处理并且在地形包围盒计算时也注意了 Z 轴方向。这个坑不难但很隐蔽尤其是地形不是正方形的时候镜像了也不容易一眼看出来。6.3 MultiMesh 实例数上限与动态更新MultiMesh 的instance_count一旦设定再改会有开销而且设太大浪费内存。我一开始图省事直接设成候选点总数结果内存占用很高。后来改成按实际放置数量设置并且分块管理每块一个 MultiMesh动态增删。另外set_instance_transform()在循环里调用时如果实例数很多会有明显的卡顿最好在生成阶段一次性设完运行时不频繁改。6.4 随机种子不固定导致的不可复现这个坑是团队协作时暴露的。我本地测试没问题美术那边跑出来分布完全不一样因为随机种子没固定每次运行都不同。后来把种子做成配置项默认固定需要变化时才手动改。这样任何人拿到同样的配置生成的结果都一致沟通成本大大降低。7. 进阶玩法让颜色贴图承担更多职责7.1 用不同通道控制不同属性颜色贴图有 RGB 三个通道可以一个通道管种类、一个通道管密度、一个通道管缩放。比如红通道的值决定放什么绿通道的值决定密度蓝通道的值决定物体大小。这样一张图就能表达三个维度的信息比单纯用颜色种类丰富得多。代价是画图时要理解通道含义不如直接画颜色直观。适合对分布控制要求高的项目。7.2 结合高度和坡度做二次筛选颜色贴图管水平分布高度和坡度管垂直分布。可以在颜色匹配通过后再检查该点的地形高度和坡度比如树只长在坡度小于 30 度的地方石头可以长在陡坡上。这样分布会更自然也避免了树长在悬崖上的尴尬。高度和坡度可以从地形高度图或者射线检测得到成本不高效果提升明显。7.3 运行时动态修改分布因为分布规则就是一张贴图运行时改贴图就能改分布。比如玩家砍了一片树可以把对应区域涂成黑色下次生成时那里就不长树了。或者做季节变化夏天涂绿、冬天涂白物体种类跟着变。这种动态性用传统方式很难做用颜色贴图就是改几个像素的事。当然运行时改贴图要注意性能别每帧都改按需更新就行。这套方案我从去年开始用到现在覆盖了植被、碎石、道具、装饰物好几类散布需求最大的感受就是规则可视化带来的效率提升。以前调分布要改代码、重编译、看效果现在画图、重载、看效果迭代速度快了一个数量级。如果你也在做地形相关的散布强烈建议试试这个思路尤其是项目里美术和程序分工明确的团队这套接口能让两边都舒服很多。
返回列表