Unity C#生物群系生成算法多线程改造后结果不一致问题
生物群系生成算法多线程一致性问题排查与修复
问题描述
我正在学习游戏开发,设计了一套生物群系(Biome)生成算法。同步运行时每次生成结果完全一致且正常,但改成多线程版本后,每次调用结果都不一样。我试过用线程安全集合、给集合加锁,问题还是没解决,完全不知道原因。需要问题排查思路、修复方案以及算法优化建议。
可正常运行的同步代码
private Biome[,] Generate(string worldSeed, Vector2Int targetChunk, List<(Biome, float)> multiplier, float centroidsPerChunk) { //Calculate the NeighboursToGenerate depeding on the cendroids per Chunk value int chunkNeighboursToGenerate = (int)Math.Ceiling(Math.Sqrt(1f / centroidsPerChunk * 12.5f)); int chunkSize = 8; //Create List that contains all centroids of the chunk List<(Vector2Int, Biome)> centroids = new(); //Create Centdroids for every chunk of the generated region around the targetchunk for (int chunkX = targetChunk.x - chunkNeighboursToGenerate; chunkX < targetChunk.x + chunkNeighboursToGenerate + 1; chunkX++) { for (int chunkZ = targetChunk.y - chunkNeighboursToGenerate; chunkZ < targetChunk.y + chunkNeighboursToGenerate + 1; chunkZ++) { List<(Vector2Int, Biome)> generatedCentdroids = GetCentdroidsByChunk(worldSeed, new(chunkX, chunkZ), centroidsPerChunk, chunkSize, multiplier, targetChunk, chunkNeighboursToGenerate); foreach ((Vector2Int, Biome) generatedCentdroid in generatedCentdroids) { centroids.Add(generatedCentdroid); } } } Biome[,] biomeMap = new Biome[chunkSize, chunkSize]; //---Generate biomeMap of the target Chunk--- for (int tx = 0; tx < chunkSize; tx++) { for (int tz = 0; tz < chunkSize; tz++) { int x = chunkSize * chunkNeighboursToGenerate + tx; int z = chunkSize * chunkNeighboursToGenerate + tz; biomeMap[tz, tx] = GetClosestCentroidBiome(new(x, z), centroids.ToArray()); }; }; //Return the biome map of the target chunk return biomeMap; } private static List<(Vector2Int, Biome)> GetCentdroidsByChunk(string worldSeed, Vector2Int chunkToGenerate, float centroidsPerChunk, int chunkSize, List<(Biome, float)> multiplier, Vector2Int targetChunk, int chunkNeighboursToGenerate) { List<(Vector2Int, Biome)> centroids = new(); //---Generate Cendroids of a single chunk--- float centroidsInThisChunk = centroidsPerChunk; //Init randomizer System.Random randomInstance = new(Randomizer.GetSeed(worldSeed, chunkToGenerate.x, chunkToGenerate.y)); while (centroidsInThisChunk > 0.0f) { //if at least one more centroid is to generate do it //if not randomize by the given probability if another one should be generated if (centroidsInThisChunk >= 1 || (float)randomInstance.NextDouble() * (1 - 0) + 0 <= centroidsInThisChunk) { //Generate random point for a new centroid Vector2Int pos = new(randomInstance.Next(0, chunkSize + 1), randomInstance.Next(0, chunkSize + 1)); //map the point to a zerobased coordinatesystem int mappedX = (((chunkToGenerate.x - targetChunk.x) + chunkNeighboursToGenerate) * chunkSize) + pos.x; int mappedZ = (((chunkToGenerate.y - targetChunk.y) + chunkNeighboursToGenerate) * chunkSize) + pos.y; Vector2Int mappedPos = new Vector2Int(mappedX, mappedZ); //Select the biom randomized Biome biome = Randomizer.GetRandomBiom(randomInstance, multiplier); centroids.Add(new(mappedPos, biome)); centroidsInThisChunk -= 1.0f; } //if no centroid is left to generate, end the loop else { break; } } return centroids; } //Calculates the closest Centroid to the given possition Biome GetClosestCentroidBiome(Vector2Int pixelPos, IEnumerable<(Vector2Int, Biome)> centroids) { //Warp the possition so the biom borders won't be straight //Vector2 warpedPos = pixelPos + Get2DTurbulence(pixelPos); Vector2 warpedPos = pixelPos; float smallestDst = float.MaxValue; Biome closestBiome = Biome.Empty; foreach ((Vector2Int, Biome) centroid in centroids) { float distance = Vector2.Distance(warpedPos, centroid.Item1); if (distance < smallestDst) { smallestDst = distance; closestBiome = centroid.Item2; } } return closestBiome; } public static class Randomizer { //Generates a random integerseed by combining an hashing the inputvalues public static int GetSeed(string worldSeed, int chunkx, int chunkz) { var stringSeed = worldSeed + ":" + chunkx + ";" + chunkz; MD5 md5Hasher = MD5.Create(); byte[] hashed = md5Hasher.ComputeHash(Encoding.UTF8.GetBytes(stringSeed)); return BitConverter.ToInt32(hashed, 0); } //Returns a random biome based on the given properbilities/multiplier //multiplier = 2 for example means the biom is generated twice as often as usually public static Biome GetRandomBiom(System.Random rndm, List<(Biome, float)> multiplier) { float multmax = 0.0f; multiplier.ForEach(x => multmax += x.Item2); //Generate a random value that is in the range of all multiplieres added float biome = (float)rndm.NextDouble() * (multmax + 0.01f); //Map the biome to the multipliers and return the biome float multcalc = 0.0f; for (int r = 0; r < multiplier.Count; r++) { multcalc += multiplier[r].Item2; if (multcalc >= biome) { return multiplier[r].Item1; } } //Return Biome.Empty if something did't worked correct return Biome.Empty; } }
无法正常运行的多线程代码
private Biome[,] Generate(string worldSeed, Vector2Int targetChunk, List<(Biome, float)> multiplier, float centroidsPerChunk) { //Calculate the NeighboursToGenerate depeding on the cendroids per Chunk value int chunkNeighboursToGenerate = (int)Math.Ceiling(Math.Sqrt(1f / centroidsPerChunk * 12.5f)); int chunkSize = 8; //Create List that contains all centroids of the chunk ConcurrentBag<(Vector2Int, Biome)> centroids = new(); ConcurrentQueue<Task> tasks = new(); //Create Centdroids for every chunk of the generated region around the targetchunk for (int chunkX = targetChunk.x - chunkNeighboursToGenerate; chunkX < targetChunk.x + chunkNeighboursToGenerate + 1; chunkX++) { for (int chunkZ = targetChunk.y - chunkNeighboursToGenerate; chunkZ < targetChunk.y + chunkNeighboursToGenerate + 1; chunkZ++) { tasks.Enqueue(Task.Run(() => { List<(Vector2Int, Biome)> generatedCentdroids = GetCentdroidsByChunk(worldSeed, new(chunkX, chunkZ), centroidsPerChunk, chunkSize, multiplier, targetChunk, chunkNeighboursToGenerate); foreach ((Vector2Int, Biome) generatedCentdroid in generatedCentdroids) { centroids.Add(generatedCentdroid); } })); } } Biome[,] biomeMap = new Biome[chunkSize, chunkSize]; Task.WaitAll(tasks.ToArray()); //---Generate biomeMap of the target Chunk--- for (int tx = 0; tx < chunkSize; tx++) { for (int tz = 0; tz < chunkSize; tz++) { int x = chunkSize * chunkNeighboursToGenerate + tx; int z = chunkSize * chunkNeighboursToGenerate + tz; biomeMap[tz, tx] = GetClosestCentroidBiome(new(x, z), centroids.ToArray()); }; }; //Return the biome map of the target chunk return biomeMap; }
问题排查
核心问题是闭包的延迟绑定特性:
在多线程循环中,Task.Run捕获的chunkX和chunkZ是循环的迭代变量,而非当前迭代的数值。循环迭代时这两个变量会持续更新,当线程实际执行时,拿到的可能不是当前循环迭代的坐标值,而是循环结束后的最终值(或其他迭代的数值)。这会导致GetCentdroidsByChunk接收到错误的chunk坐标,生成错误的随机种子,最终centroids内容和同步版本不一致,导致生物群系生成结果随机变化。
线程安全集合本身没有问题,错误根源在于传入的参数不正确,而非集合的线程安全问题。
修复方案
方案1:修复闭包变量捕获
在循环内部将chunkX和chunkZ复制到局部变量,让每个Task捕获局部变量的副本,确保每个线程拿到正确的坐标:
for (int chunkX = targetChunk.x - chunkNeighboursToGenerate; chunkX < targetChunk.x + chunkNeighboursToGenerate + 1; chunkX++) { for (int chunkZ = targetChunk.y - chunkNeighboursToGenerate; chunkZ < targetChunk.y + chunkNeighboursToGenerate + 1; chunkZ++) { // 复制到局部变量,避免闭包捕获循环变量引用 int currentChunkX = chunkX; int currentChunkZ = chunkZ; tasks.Enqueue(Task.Run(() => { var generatedCentdroids = GetCentdroidsByChunk(worldSeed, new Vector2Int(currentChunkX, currentChunkZ), centroidsPerChunk, chunkSize, multiplier, targetChunk, chunkNeighboursToGenerate); foreach (var centroid in generatedCentdroids) { centroids.Add(centroid); } })); } }
方案2:使用Parallel.For简化代码
Parallel.For会自动处理变量捕获问题,代码更简洁且不易出错:
// 替换原有的Task队列逻辑 Parallel.For(targetChunk.x - chunkNeighboursToGenerate, targetChunk.x + chunkNeighboursToGenerate + 1, chunkX => { Parallel.For(targetChunk.y - chunkNeighboursToGenerate, targetChunk.y + chunkNeighboursToGenerate + 1, chunkZ => { var generatedCentdroids = GetCentdroidsByChunk(worldSeed, new Vector2Int(chunkX, chunkZ), centroidsPerChunk, chunkSize, multiplier, targetChunk, chunkNeighboursToGenerate); foreach (var centroid in generatedCentdroids) { centroids.Add(centroid); } }); });
方案3:避免ConcurrentBag开销(性能优化)
每个线程生成的centroids是独立的,可以让线程返回自己的List,最后在主线程合并,避免ConcurrentBag的同步开销:
// 替换原有的Task队列和ConcurrentBag逻辑 var taskList = new List<Task<List<(Vector2Int, Biome)>>>(); for (int chunkX = targetChunk.x - chunkNeighboursToGenerate; chunkX < targetChunk.x + chunkNeighboursToGenerate + 1; chunkX++) { for (int chunkZ = targetChunk.y - chunkNeighboursToGenerate; chunkZ < targetChunk.y + chunkNeighboursToGenerate + 1; chunkZ++) { int cx = chunkX; int cz = chunkZ; taskList.Add(Task.Run(() => GetCentdroidsByChunk(worldSeed, new Vector2Int(cx, cz), centroidsPerChunk, chunkSize, multiplier, targetChunk, chunkNeighboursToGenerate))); } } Task.WaitAll(taskList.ToArray()); // 合并所有线程的结果 var centroids = new List<(Vector2Int, Biome)>(); foreach (var task in taskList) { centroids.AddRange(task.Result); }
算法优化建议
- 减少内存分配:在
GetCentdroidsByChunk中,预先估算List的容量(比如new List<...>((int)Math.Ceiling(centroidsPerChunk) + 1)),避免List动态扩容带来的性能损耗。 - 并行生成目标块生物群系:目标块的每个像素计算是独立的,可以用Parallel.For并行处理:
var centroidsArray = centroids.ToArray(); // 提前转换为数组,避免多次调用ToArray() Parallel.For(0, chunkSize, tx => { for (int tz = 0; tz < chunkSize; tz++) { int x = chunkSize * chunkNeighboursToGenerate + tx; int z = chunkSize * chunkNeighboursToGenerate + tz; biomeMap[tz, tx] = GetClosestCentroidBiome(new Vector2Int(x, z), centroidsArray); } }); - 优化最近邻计算:如果centroids数量较多,可使用空间分区(如网格划分、四叉树),将centroids按位置分组,每个像素只需要计算所在分区内的centroids距离,大幅减少计算量。
- 缓存随机种子计算:如果同一个chunk会被多次生成,可以缓存
Randomizer.GetSeed的结果,避免重复计算MD5哈希。
内容的提问来源于stack exchange,提问作者LetsHenne
相关产品推荐
相关产品推荐

