You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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);
}

算法优化建议

  1. 减少内存分配:在GetCentdroidsByChunk中,预先估算List的容量(比如new List<...>((int)Math.Ceiling(centroidsPerChunk) + 1)),避免List动态扩容带来的性能损耗。
  2. 并行生成目标块生物群系:目标块的每个像素计算是独立的,可以用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);
        }
    });
    
  3. 优化最近邻计算:如果centroids数量较多,可使用空间分区(如网格划分、四叉树),将centroids按位置分组,每个像素只需要计算所在分区内的centroids距离,大幅减少计算量。
  4. 缓存随机种子计算:如果同一个chunk会被多次生成,可以缓存Randomizer.GetSeed的结果,避免重复计算MD5哈希。

内容的提问来源于stack exchange,提问作者LetsHenne

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 10:40:17